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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { hashPassword } from "../../src/server/auth.js";
|
|
5
|
+
import { buildApp } from "../../src/server/app.js";
|
|
6
|
+
import { ConfigStore } from "../../src/config/store.js";
|
|
7
|
+
import { openDatabase } from "../../src/db/open.js";
|
|
8
|
+
import { makeOriginRepo, tmpDir } from "../fixtures/repos.js";
|
|
9
|
+
async function harness() {
|
|
10
|
+
const origin = await makeOriginRepo({ "fastlane/Fastfile": "lane :beta do\nend\n" });
|
|
11
|
+
const root = await tmpDir("laneyard-api-");
|
|
12
|
+
const configPath = join(root, "config.yml");
|
|
13
|
+
await writeFile(configPath, `
|
|
14
|
+
server:
|
|
15
|
+
password_hash: "${hashPassword("secret")}"
|
|
16
|
+
projects:
|
|
17
|
+
- slug: sample
|
|
18
|
+
name: Sample
|
|
19
|
+
git_url: ${origin}
|
|
20
|
+
`, "utf8");
|
|
21
|
+
const config = new ConfigStore(configPath);
|
|
22
|
+
await config.load();
|
|
23
|
+
const app = await buildApp({
|
|
24
|
+
config,
|
|
25
|
+
db: openDatabase(":memory:"),
|
|
26
|
+
root,
|
|
27
|
+
lanes: async () => [{ name: "beta", platform: "ios", description: "Beta", private: false }],
|
|
28
|
+
});
|
|
29
|
+
return { app, root };
|
|
30
|
+
}
|
|
31
|
+
async function login(app) {
|
|
32
|
+
const res = await app.inject({
|
|
33
|
+
method: "POST",
|
|
34
|
+
url: "/api/login",
|
|
35
|
+
payload: { password: "secret" },
|
|
36
|
+
});
|
|
37
|
+
return res.cookies[0].value;
|
|
38
|
+
}
|
|
39
|
+
describe("API", () => {
|
|
40
|
+
it("refuses access without a session", async () => {
|
|
41
|
+
const { app } = await harness();
|
|
42
|
+
const res = await app.inject({ method: "GET", url: "/api/projects" });
|
|
43
|
+
expect(res.statusCode).toBe(401);
|
|
44
|
+
});
|
|
45
|
+
it("refuses a wrong password", async () => {
|
|
46
|
+
const { app } = await harness();
|
|
47
|
+
const res = await app.inject({ method: "POST", url: "/api/login", payload: { password: "wrong" } });
|
|
48
|
+
expect(res.statusCode).toBe(401);
|
|
49
|
+
});
|
|
50
|
+
it("lists the projects once logged in", async () => {
|
|
51
|
+
const { app } = await harness();
|
|
52
|
+
const session = await login(app);
|
|
53
|
+
const res = await app.inject({
|
|
54
|
+
method: "GET",
|
|
55
|
+
url: "/api/projects",
|
|
56
|
+
cookies: { laneyard_session: session },
|
|
57
|
+
});
|
|
58
|
+
expect(res.statusCode).toBe(200);
|
|
59
|
+
expect(res.json()).toMatchObject([{ slug: "sample", name: "Sample" }]);
|
|
60
|
+
});
|
|
61
|
+
it("returns a project's lanes", async () => {
|
|
62
|
+
const { app } = await harness();
|
|
63
|
+
const session = await login(app);
|
|
64
|
+
const res = await app.inject({
|
|
65
|
+
method: "GET",
|
|
66
|
+
url: "/api/projects/sample/lanes",
|
|
67
|
+
cookies: { laneyard_session: session },
|
|
68
|
+
});
|
|
69
|
+
expect(res.statusCode).toBe(200);
|
|
70
|
+
expect(res.json()).toMatchObject([{ name: "beta", platform: "ios" }]);
|
|
71
|
+
});
|
|
72
|
+
it("responds 404 for a project absent from the configuration", async () => {
|
|
73
|
+
const { app } = await harness();
|
|
74
|
+
const session = await login(app);
|
|
75
|
+
const res = await app.inject({
|
|
76
|
+
method: "GET",
|
|
77
|
+
url: "/api/projects/unknown/lanes",
|
|
78
|
+
cookies: { laneyard_session: session },
|
|
79
|
+
});
|
|
80
|
+
expect(res.statusCode).toBe(404);
|
|
81
|
+
});
|
|
82
|
+
it("creates a queued run and makes it viewable", async () => {
|
|
83
|
+
const { app } = await harness();
|
|
84
|
+
const session = await login(app);
|
|
85
|
+
const created = await app.inject({
|
|
86
|
+
method: "POST",
|
|
87
|
+
url: "/api/projects/sample/runs",
|
|
88
|
+
cookies: { laneyard_session: session },
|
|
89
|
+
payload: { lane: "beta", platform: "ios", params: {} },
|
|
90
|
+
});
|
|
91
|
+
expect(created.statusCode).toBe(201);
|
|
92
|
+
const { id } = created.json();
|
|
93
|
+
const fetched = await app.inject({
|
|
94
|
+
method: "GET",
|
|
95
|
+
url: `/api/runs/${id}`,
|
|
96
|
+
cookies: { laneyard_session: session },
|
|
97
|
+
});
|
|
98
|
+
expect(fetched.json()).toMatchObject({ id, lane: "beta", projectSlug: "sample" });
|
|
99
|
+
});
|
|
100
|
+
it("refuses to launch an unknown lane", async () => {
|
|
101
|
+
const { app } = await harness();
|
|
102
|
+
const session = await login(app);
|
|
103
|
+
const res = await app.inject({
|
|
104
|
+
method: "POST",
|
|
105
|
+
url: "/api/projects/sample/runs",
|
|
106
|
+
cookies: { laneyard_session: session },
|
|
107
|
+
payload: { lane: "does-not-exist", params: {} },
|
|
108
|
+
});
|
|
109
|
+
expect(res.statusCode).toBe(400);
|
|
110
|
+
});
|
|
111
|
+
it("refuses a second run while the previous one occupies the workspace", async () => {
|
|
112
|
+
const { app } = await harness();
|
|
113
|
+
const session = await login(app);
|
|
114
|
+
const cookies = { laneyard_session: session };
|
|
115
|
+
const first = await app.inject({
|
|
116
|
+
method: "POST",
|
|
117
|
+
url: "/api/projects/sample/runs",
|
|
118
|
+
cookies,
|
|
119
|
+
payload: { lane: "beta", params: {} },
|
|
120
|
+
});
|
|
121
|
+
expect(first.statusCode).toBe(201);
|
|
122
|
+
// The first run is still active: two runs would share the same git clone.
|
|
123
|
+
const second = await app.inject({
|
|
124
|
+
method: "POST",
|
|
125
|
+
url: "/api/projects/sample/runs",
|
|
126
|
+
cookies,
|
|
127
|
+
payload: { lane: "beta", params: {} },
|
|
128
|
+
});
|
|
129
|
+
expect(second.statusCode).toBe(409);
|
|
130
|
+
expect(second.json().error).toMatch(/in progress/);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { hashPassword, LoginThrottle, verifyPassword } from "../../src/server/auth.js";
|
|
3
|
+
describe("verifyPassword", () => {
|
|
4
|
+
it("accepts the right password and refuses others", async () => {
|
|
5
|
+
const stored = hashPassword("correct horse");
|
|
6
|
+
expect(await verifyPassword("correct horse", stored)).toBe(true);
|
|
7
|
+
expect(await verifyPassword("correct hors", stored)).toBe(false);
|
|
8
|
+
expect(await verifyPassword("", stored)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
it("refuses instead of throwing when the stored hash is corrupted", async () => {
|
|
11
|
+
// A broken configuration must produce a refusal, not a server error.
|
|
12
|
+
for (const corrupted of ["", "scrypt$", "scrypt$zz$zz", "bcrypt$a$b", "scrypt$aa$"]) {
|
|
13
|
+
expect(await verifyPassword("x", corrupted)).toBe(false);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
it("doesn't block the event loop", async () => {
|
|
17
|
+
const stored = hashPassword("a password");
|
|
18
|
+
let ticked = false;
|
|
19
|
+
// A 0ms timer can only fire if the event loop stays free.
|
|
20
|
+
setTimeout(() => (ticked = true), 0);
|
|
21
|
+
await verifyPassword("a password", stored);
|
|
22
|
+
expect(ticked).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe("LoginThrottle", () => {
|
|
26
|
+
it("lets the first attempts through without bothering the distracted user", () => {
|
|
27
|
+
const t = new LoginThrottle();
|
|
28
|
+
for (let i = 0; i < 3; i += 1)
|
|
29
|
+
t.recordFailure(0);
|
|
30
|
+
expect(t.retryAfterMs(0)).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
it("then slows down, more and more", () => {
|
|
33
|
+
const t = new LoginThrottle();
|
|
34
|
+
for (let i = 0; i < 4; i += 1)
|
|
35
|
+
t.recordFailure(0);
|
|
36
|
+
expect(t.retryAfterMs(0)).toBe(1000);
|
|
37
|
+
t.recordFailure(0);
|
|
38
|
+
expect(t.retryAfterMs(0)).toBe(2000);
|
|
39
|
+
});
|
|
40
|
+
it("caps the delay rather than blocking indefinitely", () => {
|
|
41
|
+
const t = new LoginThrottle();
|
|
42
|
+
for (let i = 0; i < 40; i += 1)
|
|
43
|
+
t.recordFailure(0);
|
|
44
|
+
expect(t.retryAfterMs(0)).toBe(60_000);
|
|
45
|
+
});
|
|
46
|
+
it("forgets everything as soon as a login succeeds", () => {
|
|
47
|
+
const t = new LoginThrottle();
|
|
48
|
+
for (let i = 0; i < 10; i += 1)
|
|
49
|
+
t.recordFailure(0);
|
|
50
|
+
t.recordSuccess();
|
|
51
|
+
expect(t.retryAfterMs(0)).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { RunSockets } from "../../src/server/ws.js";
|
|
3
|
+
const socket = () => ({ sent: [], send(d) { this.sent.push(d); } });
|
|
4
|
+
describe("RunSockets", () => {
|
|
5
|
+
it("broadcasts a fragment to the run's subscribers", () => {
|
|
6
|
+
const hub = new RunSockets();
|
|
7
|
+
const a = socket();
|
|
8
|
+
hub.subscribe(1, a);
|
|
9
|
+
hub.broadcast(1, "output", 10);
|
|
10
|
+
expect(JSON.parse(a.sent[0])).toEqual({ type: "chunk", offset: 10, data: "output" });
|
|
11
|
+
});
|
|
12
|
+
it("sends nothing to another run's subscribers", () => {
|
|
13
|
+
const hub = new RunSockets();
|
|
14
|
+
const other = socket();
|
|
15
|
+
hub.subscribe(2, other);
|
|
16
|
+
hub.broadcast(1, "output", 0);
|
|
17
|
+
expect(other.sent).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
it("stops writing to an unsubscribed subscriber", () => {
|
|
20
|
+
const hub = new RunSockets();
|
|
21
|
+
const s = socket();
|
|
22
|
+
hub.subscribe(1, s);
|
|
23
|
+
hub.unsubscribe(1, s);
|
|
24
|
+
hub.broadcast(1, "output", 0);
|
|
25
|
+
expect(s.sent).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
it("survives a subscriber whose send fails", () => {
|
|
28
|
+
const hub = new RunSockets();
|
|
29
|
+
const broken = { send() { throw new Error("closed socket"); } };
|
|
30
|
+
const healthy = socket();
|
|
31
|
+
hub.subscribe(1, broken);
|
|
32
|
+
hub.subscribe(1, healthy);
|
|
33
|
+
expect(() => hub.broadcast(1, "output", 0)).not.toThrow();
|
|
34
|
+
expect(healthy.sent).toHaveLength(1);
|
|
35
|
+
});
|
|
36
|
+
it("announces the end of a run", () => {
|
|
37
|
+
const hub = new RunSockets();
|
|
38
|
+
const s = socket();
|
|
39
|
+
hub.subscribe(1, s);
|
|
40
|
+
hub.finish(1, "success");
|
|
41
|
+
expect(JSON.parse(s.sent[0])).toEqual({ type: "finished", status: "success" });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { CacheStore } from "../../src/db/cache.js";
|
|
5
|
+
import { openDatabase } from "../../src/db/open.js";
|
|
6
|
+
import { LaneReader } from "../../src/sidecar/lanes.js";
|
|
7
|
+
import { tmpDir } from "../fixtures/repos.js";
|
|
8
|
+
async function fastlaneDir(files) {
|
|
9
|
+
const dir = await tmpDir("laneyard-lanes-");
|
|
10
|
+
await mkdir(join(dir, "fastlane"), { recursive: true });
|
|
11
|
+
for (const [name, content] of Object.entries(files)) {
|
|
12
|
+
await writeFile(join(dir, "fastlane", name), content, "utf8");
|
|
13
|
+
}
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
const LANES = [{ name: "beta", platform: "ios", description: "", private: false }];
|
|
17
|
+
describe("LaneReader", () => {
|
|
18
|
+
it("queries the sidecar then serves the cache on the second call", async () => {
|
|
19
|
+
const dir = await fastlaneDir({ Fastfile: "lane :beta do\nend\n" });
|
|
20
|
+
const invoke = vi.fn().mockResolvedValue({ ok: true, lanes: LANES });
|
|
21
|
+
const reader = new LaneReader(new CacheStore(openDatabase(":memory:")), invoke);
|
|
22
|
+
expect(await reader.read("p", dir, "fastlane")).toEqual(LANES);
|
|
23
|
+
expect(await reader.read("p", dir, "fastlane")).toEqual(LANES);
|
|
24
|
+
expect(invoke).toHaveBeenCalledTimes(1);
|
|
25
|
+
});
|
|
26
|
+
it("re-queries the sidecar when a file in the folder changes", async () => {
|
|
27
|
+
const dir = await fastlaneDir({ Fastfile: "lane :beta do\nend\n" });
|
|
28
|
+
const invoke = vi.fn().mockResolvedValue({ ok: true, lanes: LANES });
|
|
29
|
+
const reader = new LaneReader(new CacheStore(openDatabase(":memory:")), invoke);
|
|
30
|
+
await reader.read("p", dir, "fastlane");
|
|
31
|
+
await writeFile(join(dir, "fastlane", "Fastfile"), "lane :beta do\n puts 1\nend\n", "utf8");
|
|
32
|
+
await reader.read("p", dir, "fastlane");
|
|
33
|
+
expect(invoke).toHaveBeenCalledTimes(2);
|
|
34
|
+
});
|
|
35
|
+
it("also re-queries when a neighbouring file changes, not just the Fastfile", async () => {
|
|
36
|
+
const dir = await fastlaneDir({ Fastfile: "lane :beta do\nend\n", Appfile: "app_identifier 'a'\n" });
|
|
37
|
+
const invoke = vi.fn().mockResolvedValue({ ok: true, lanes: LANES });
|
|
38
|
+
const reader = new LaneReader(new CacheStore(openDatabase(":memory:")), invoke);
|
|
39
|
+
await reader.read("p", dir, "fastlane");
|
|
40
|
+
await writeFile(join(dir, "fastlane", "Appfile"), "app_identifier 'b'\n", "utf8");
|
|
41
|
+
await reader.read("p", dir, "fastlane");
|
|
42
|
+
expect(invoke).toHaveBeenCalledTimes(2);
|
|
43
|
+
});
|
|
44
|
+
it("propagates the sidecar's error without caching anything", async () => {
|
|
45
|
+
const dir = await fastlaneDir({ Fastfile: "broken" });
|
|
46
|
+
const invoke = vi.fn().mockResolvedValue({ ok: false, error: "unreadable Fastfile" });
|
|
47
|
+
const reader = new LaneReader(new CacheStore(openDatabase(":memory:")), invoke);
|
|
48
|
+
await expect(reader.read("p", dir, "fastlane")).rejects.toThrow(/unreadable/);
|
|
49
|
+
await expect(reader.read("p", dir, "fastlane")).rejects.toThrow(/unreadable/);
|
|
50
|
+
expect(invoke).toHaveBeenCalledTimes(2);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { resolveRubyEnv } from "../../src/sidecar/ruby-env.js";
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
describe("resolveRubyEnv", () => {
|
|
7
|
+
it("returns an environment where Ruby can load fastlane", async () => {
|
|
8
|
+
const resolved = await resolveRubyEnv();
|
|
9
|
+
expect(resolved).not.toBeNull();
|
|
10
|
+
const { stdout } = await exec("ruby", ["-e", 'require "fastlane"; print "ok"'], {
|
|
11
|
+
env: resolved.env,
|
|
12
|
+
timeout: 180_000,
|
|
13
|
+
});
|
|
14
|
+
expect(stdout).toBe("ok");
|
|
15
|
+
}, 240_000);
|
|
16
|
+
it("indicates where the chosen environment comes from", async () => {
|
|
17
|
+
const resolved = await resolveRubyEnv();
|
|
18
|
+
expect(["process", "launcher"]).toContain(resolved.source);
|
|
19
|
+
}, 240_000);
|
|
20
|
+
it("memoizes the result rather than probing again on every call", async () => {
|
|
21
|
+
const a = await resolveRubyEnv();
|
|
22
|
+
const b = await resolveRubyEnv();
|
|
23
|
+
expect(b).toBe(a);
|
|
24
|
+
}, 240_000);
|
|
25
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;--bg: #14161a;--bg-raised: #1b1e24;--bg-inset: #171a1f;--border: #262a31;--text: #c8ccd4;--text-dim: #676e7b;--text-bright: #e6e9ee;--accent: #7ee787;--ok: #7ee787;--running: #e3b341;--error: #f8746a;--info: #79c0ff;--term-bg: #0e1013;--term-text: #c9d1d9}:root[data-theme=light]{--bg: #f2efe6;--bg-raised: #e7e2d5;--bg-inset: #ebe7dc;--border: #d9d3c4;--text: #2f2b25;--text-dim: #8b8375;--text-bright: #14110c;--accent: #3f7d3f;--ok: #3f7d3f;--running: #a76c14;--error: #b03a34;--info: #2f6f9e}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font-mono);font-size:13px;line-height:1.6}.panel{background:var(--bg-raised);border:1px solid var(--border)}.status-success{color:var(--ok)}.status-running{color:var(--running)}.status-failed,.status-interrupted{color:var(--error)}.status-queued,.status-preparing,.dim{color:var(--text-dim)}.bright{color:var(--text-bright)}.accent{color:var(--accent)}.status-cancelled{color:var(--text-dim)}a{color:inherit;text-decoration:none}a:hover{color:var(--text-bright)}h1,h2,h3{font-size:13px;font-weight:400;margin:0}.section{color:var(--text-dim);text-transform:uppercase;letter-spacing:.14em;font-size:11px}button,input,select{font:inherit;color:var(--text);background:var(--bg-inset);border:1px solid var(--border);border-radius:0;padding:2px 8px}button{cursor:pointer}button:hover:not(:disabled){color:var(--text-bright);border-color:var(--text-dim)}button:disabled,input:disabled{color:var(--text-dim);cursor:default}input:focus,button:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}ul{list-style:none;margin:0;padding:0}code{color:var(--text-bright)}.shell{display:grid;grid-template-columns:240px minmax(0,1fr);grid-template-rows:32px 1fr;height:100vh}.shell>header{grid-column:1 / -1;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 12px;border-bottom:1px solid var(--border);background:var(--bg-raised)}.brand{color:var(--accent);letter-spacing:.12em}.shell>nav{border-right:1px solid var(--border);background:var(--bg-raised);overflow-y:auto;padding:12px 0}.shell>main{min-width:0;overflow:auto;padding:16px}.nav-head{padding:0 12px 6px}.nav-item{display:block;padding:2px 12px;border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.nav-item:hover{background:var(--bg-inset)}.nav-item.current{border-left-color:var(--accent);background:var(--bg-inset);color:var(--text-bright)}.login{height:100vh;display:flex;align-items:center;justify-content:center}.login form{width:320px;padding:20px;display:flex;flex-direction:column;gap:12px}.login input{width:100%;padding:4px 8px}.rows>li{display:flex;align-items:baseline;gap:8px;padding:4px 0;border-bottom:1px solid var(--border)}.rows>li:last-child{border-bottom:none}.grow{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mark{width:1ch;display:inline-block}table{border-collapse:collapse;width:100%}td,th{text-align:left;font-weight:400;padding:2px 12px 2px 0}.run-head{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px 20px;padding:8px 12px;margin-bottom:12px}.run-body{display:grid;grid-template-columns:260px minmax(0,1fr);gap:12px;align-items:start;min-width:0}.steps>li{display:flex;gap:8px;padding:2px 8px}.steps>li.clickable{cursor:pointer}.steps>li.clickable:hover{background:var(--bg-inset);color:var(--text-bright)}.steps>li.inert{color:var(--text-dim)}.pane-title{padding:6px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;gap:12px}.terminal{background:var(--term-bg);color:var(--term-text);display:flex;flex-direction:column;height:calc(100vh - 190px);min-height:260px}.terminal pre{margin:0;flex:1;overflow:auto;padding:8px 12px;white-space:pre;font:inherit}.terminal .a-black{color:#6e7681}.terminal .a-red{color:#ff7b72}.terminal .a-green{color:#7ee787}.terminal .a-yellow{color:#d29922}.terminal .a-blue{color:#79c0ff}.terminal .a-magenta{color:#d2a8ff}.terminal .a-cyan{color:#56d4dd}.terminal .a-white{color:#b1bac4}.terminal .a-bright-black{color:#8b949e}.terminal .a-bright-red{color:#ffa198}.terminal .a-bright-green{color:#56d364}.terminal .a-bright-yellow{color:#e3b341}.terminal .a-bright-blue{color:#a5d6ff}.terminal .a-bright-magenta{color:#e2c5ff}.terminal .a-bright-cyan{color:#b3f0ff}.terminal .a-bright-white{color:#f0f6fc}.terminal .a-bold{font-weight:700}.terminal .a-dim{opacity:.7}.terminal-input{display:flex;align-items:center;gap:8px;padding:4px 12px;border-top:1px solid var(--border)}.terminal-input input{flex:1;background:transparent;border:none;color:var(--term-text);padding:0}.terminal-input input:disabled{color:#4c525c}.terminal-input .prompt{color:var(--accent)}.terminal-input .reason{color:#676e7b}@media (max-width: 820px){.shell{grid-template-columns:1fr;grid-template-rows:32px auto 1fr;height:auto;min-height:100vh}.shell>nav{border-right:none;border-bottom:1px solid var(--border);display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding:6px 12px}.nav-head{padding:0;width:100%}.nav-item{border-left:none;border-bottom:2px solid transparent;padding:0 2px}.nav-item.current{border-left-color:transparent;border-bottom-color:var(--accent)}.run-body{grid-template-columns:1fr}.terminal{height:60vh}}
|