v1

AuthorKonata <konata@posteo.jp>
Date
Commit8de77c0aace3541af5c159bc26fa468e09eb1991
76 files changed, 14658 insertions(+)
A.gitignore
@@ -0,0 +1,39 @@
1+# dependencies (bun install)
2+node_modules
3+
4+# output
5+out
6+dist
7+*.tgz
8+
9+# code coverage
10+coverage
11+*.lcov
12+
13+# logs
14+logs
15+_.log
16+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17+
18+# caches
19+.eslintcache
20+.cache
21+*.tsbuildinfo
22+
23+# IntelliJ based IDEs
24+.idea
25+
26+# Finder (MacOS) folder config
27+.DS_Store
28+
29+memory/
30+public/assets/simplewebauthn-browser.js
31+public/assets/jxl-polyfill.js
32+public/assets/*.wasm
33+
34+# Hearthforge data
35+data/
36+.env
37+
38+# Test data
39+data-test/
AContainerfile
@@ -0,0 +1,25 @@
1+FROM oven/bun:1 AS base
2+WORKDIR /app
3+
4+RUN apt-get --update install -y --no-install-recommends \
5+ tini openssh-client git-core zstd \
6+ && rm -rf /var/lib/apt/lists/*
7+
8+# Copy public first so postinstall can write vendor files into it
9+COPY public ./public
10+
11+# Install dependencies (postinstall vendors simplewebauthn + jxl-polyfill into public/)
12+COPY package.json bun.lock* ./
13+RUN bun install --frozen-lockfile --production
14+
15+# Copy source
16+COPY src ./src
17+COPY tsconfig.json ./
18+
19+EXPOSE 3000
20+
21+ENV DATA_DIR=/data
22+VOLUME ["/data"]
23+
24+ENTRYPOINT ["/usr/bin/tini", "--"]
25+CMD ["sh", "-c", "bun run db:init && bun run start"]
AREADME.md
@@ -0,0 +1,93 @@
1+# Hearthforge ![](public/assets/favicon.svg)
2+![](preview.png)
3+
4+A self-hosted git forge designed to host your own repositories, but allow others to interact with them via issues and patch proposals.
5+Frontend works without any JS at all enabled, just required for WebAuthn (with graceful fallback to password-only).
6+
7+## Features
8+
9+- **Repository browser** — file tree, blob view, commit log, README rendering
10+- **Issues** — create, comment, react
11+- **Patches** — submit diffs for review & comments. Admin can merge applicable patches directly into the repository.
12+- **Releases** — tag-based releases with source archives and extra uploaded assets
13+- **SSH push/pull** — built-in SSH server, no external git daemon needed
14+- **Auth** — password login or passkeys (WebAuthn/FIDO2)
15+- **Optional registration** — others can create accounts to file issues and patches; can be disabled
16+
17+## Stack
18+
19+- [Bun](https://bun.sh) — runtime and package manager
20+- [ElysiaJS](https://elysiajs.com) — HTTP framework
21+- SQLite — single-file database via Kysely
22+- Server-side JSX via @kitajs/html (no client-side framework)
23+- Shiki — syntax highlighting
24+
25+## Running
26+
27+```bash
28+bun install
29+bun run db:init # creates database and admin account
30+bun run start # http://localhost:3000, SSH on port 2222
31+```
32+
33+Default admin credentials: `admin` / `changeme`
34+
35+## Manual repository creation
36+
37+Repositories can bei either created through the UI, or existing ones can be copied manually to the `data/repos` directory.
38+Non-bare repos are automatically converted to bare repos on startup, discarding uncomitted changes and worktrees.
39+
40+### Docker / Podman
41+Container images are provided.
42+```bash
43+docker compose up # or
44+podman compose up
45+```
46+
47+### Configuration
48+
49+All settings are environment variables:
50+
51+| Variable | Default | Description |
52+|-------------------------|-------------------------|-------------------------------------------------|
53+| `PORT` | `3000` | HTTP port |
54+| `SSH_PORT` | `2222` | SSH port |
55+| `DATA_DIR` | `./data` | Repos, database, uploads |
56+| `ADMIN_PASSWORD` | `changeme` | Initial admin password |
57+| `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner |
58+| `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links |
59+| `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups |
60+| `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) |
61+| `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) |
62+| `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view |
63+| `SSH_DISABLED` | `0` | Disable the embedded SSH-server |
64+| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
65+| `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
66+| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
67+\* More workers mean more CPU cores can be used to parallelize highlighting of files.
68+Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB.
69+So be careful when increasing this.
70+### SSH access
71+
72+Add your public key under Settings → SSH keys, then clone with:
73+
74+```
75+git clone ssh://git@localhost:{SSH_PORT}/{REPO_NAME}
76+```
77+
78+Pushing is also supported for the admin.
79+
80+## Development
81+
82+```bash
83+bun run dev # watch mode
84+bun run lint # Biome lint
85+bun run format # Biome format
86+bun run test # Playwright E2E tests (don't use bun test, it doesn't respect the timeout)
87+```
88+
89+## Roadmap
90+- Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
91+- Issue labels
92+- Repository list reordering (e.g. last committed) and starring
93+- redirect image urls in readme
Abiome.json
@@ -0,0 +1,40 @@
1+{
2+ "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
3+ "vcs": {
4+ "enabled": true,
5+ "clientKind": "git",
6+ "useIgnoreFile": true
7+ },
8+ "files": {
9+ "ignoreUnknown": true,
10+ "includes": ["src/**"]
11+ },
12+ "formatter": {
13+ "enabled": true,
14+ "indentStyle": "space",
15+ "indentWidth": 4
16+ },
17+ "linter": {
18+ "enabled": true,
19+ "rules": {
20+ "recommended": true,
21+ "style": {
22+ "noNonNullAssertion": "off"
23+ }
24+ }
25+ },
26+ "javascript": {
27+ "formatter": {
28+ "quoteStyle": "double",
29+ "trailingCommas": "all"
30+ }
31+ },
32+ "assist": {
33+ "enabled": true,
34+ "actions": {
35+ "source": {
36+ "organizeImports": "on"
37+ }
38+ }
39+ }
40+}
Abun.lock
@@ -0,0 +1,461 @@
1+{
2+ "lockfileVersion": 1,
3+ "configVersion": 1,
4+ "workspaces": {
5+ "": {
6+ "name": "hearthforge",
7+ "dependencies": {
8+ "@elysiajs/static": "^1.4.7",
9+ "@jsquash/jxl": "^1.3.0",
10+ "@kitajs/html": "^4.2.13",
11+ "@simplewebauthn/browser": "^13.3.0",
12+ "@simplewebauthn/server": "^13.3.0",
13+ "argon2": "^0.44.0",
14+ "elysia": "^1.4.27",
15+ "file-type": "^21.3.2",
16+ "isomorphic-dompurify": "^3.3.0",
17+ "jxl-rs-polyfill": "^0.1.1",
18+ "kysely": "^0.28.12",
19+ "kysely-bun-sqlite": "^0.4.0",
20+ "linguist-languages": "^9.3.1",
21+ "marked": "^17.0.4",
22+ "sharp": "^0.34.5",
23+ "shiki": "^4.0.2",
24+ "ssh2": "^1.17.0",
25+ },
26+ "devDependencies": {
27+ "@biomejs/biome": "^2.4.7",
28+ "@types/argon2": "^0.15.4",
29+ "@types/bun": "latest",
30+ "@types/ssh2": "^1.15.5",
31+ "playwright": "^1.58.2",
32+ },
33+ "peerDependencies": {
34+ "typescript": "^5",
35+ },
36+ },
37+ },
38+ "packages": {
39+ "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
40+
41+ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="],
42+
43+ "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="],
44+
45+ "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
46+
47+ "@biomejs/biome": ["@biomejs/biome@2.4.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.7", "@biomejs/cli-darwin-x64": "2.4.7", "@biomejs/cli-linux-arm64": "2.4.7", "@biomejs/cli-linux-arm64-musl": "2.4.7", "@biomejs/cli-linux-x64": "2.4.7", "@biomejs/cli-linux-x64-musl": "2.4.7", "@biomejs/cli-win32-arm64": "2.4.7", "@biomejs/cli-win32-x64": "2.4.7" }, "bin": { "biome": "bin/biome" } }, "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng=="],
48+
49+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA=="],
50+
51+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ=="],
52+
53+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w=="],
54+
55+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw=="],
56+
57+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ=="],
58+
59+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g=="],
60+
61+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw=="],
62+
63+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ=="],
64+
65+ "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
66+
67+ "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
68+
69+ "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
70+
71+ "@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="],
72+
73+ "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.2", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw=="],
74+
75+ "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
76+
77+ "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.1", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w=="],
78+
79+ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
80+
81+ "@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="],
82+
83+ "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
84+
85+ "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
86+
87+ "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
88+
89+ "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
90+
91+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
92+
93+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
94+
95+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
96+
97+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
98+
99+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
100+
101+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
102+
103+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
104+
105+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
106+
107+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
108+
109+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
110+
111+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
112+
113+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
114+
115+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
116+
117+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
118+
119+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
120+
121+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
122+
123+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
124+
125+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
126+
127+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
128+
129+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
130+
131+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
132+
133+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
134+
135+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
136+
137+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
138+
139+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
140+
141+ "@jsquash/jxl": ["@jsquash/jxl@1.3.0", "", { "dependencies": { "wasm-feature-detect": "^1.5.1" } }, "sha512-IVOPTneyOd9eBAuow+FnCYIP6wkIc7CjrCDnZGiqAPyJ63vMVxv3zoWiucGiZVh6u9vi/l40AkcS9QwkoVSAqA=="],
142+
143+ "@kitajs/html": ["@kitajs/html@4.2.13", "", { "dependencies": { "csstype": "^3.1.3" } }, "sha512-o+8e61EsoLDPTP7rsPkYolca1YFybHuxU2Lr5fWDZCUkYT/6uBlVkvnZUdCXMQKentJL9dxwpR8/xK2Q+U4LhA=="],
144+
145+ "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
146+
147+ "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
148+
149+ "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw=="],
150+
151+ "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w=="],
152+
153+ "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g=="],
154+
155+ "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw=="],
156+
157+ "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw=="],
158+
159+ "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pfx": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw=="],
160+
161+ "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA=="],
162+
163+ "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
164+
165+ "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA=="],
166+
167+ "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ=="],
168+
169+ "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="],
170+
171+ "@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="],
172+
173+ "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
174+
175+ "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
176+
177+ "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
178+
179+ "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
180+
181+ "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
182+
183+ "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
184+
185+ "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
186+
187+ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
188+
189+ "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="],
190+
191+ "@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="],
192+
193+ "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
194+
195+ "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
196+
197+ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
198+
199+ "@types/argon2": ["@types/argon2@0.15.4", "", { "dependencies": { "argon2": "*" } }, "sha512-fZPJNvZTvoyt/okKhbyj99UsDOwqzEXYPvKqfK26mViH9JB/hOwzUwDUn5qbwq9XJkLw0kDcFNu6+bnb3u5a0A=="],
200+
201+ "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
202+
203+ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
204+
205+ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
206+
207+ "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
208+
209+ "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
210+
211+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
212+
213+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
214+
215+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
216+
217+ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
218+
219+ "argon2": ["argon2@0.44.0", "", { "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.0.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" } }, "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig=="],
220+
221+ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
222+
223+ "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
224+
225+ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
226+
227+ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
228+
229+ "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
230+
231+ "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
232+
233+ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
234+
235+ "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
236+
237+ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
238+
239+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
240+
241+ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
242+
243+ "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
244+
245+ "cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="],
246+
247+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
248+
249+ "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
250+
251+ "cssstyle": ["cssstyle@6.2.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.28", "css-tree": "^3.1.0", "lru-cache": "^11.2.6" } }, "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig=="],
252+
253+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
254+
255+ "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
256+
257+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
258+
259+ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
260+
261+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
262+
263+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
264+
265+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
266+
267+ "dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
268+
269+ "elysia": ["elysia@1.4.27", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-2UlmNEjPJVA/WZVPYKy+KdsrfFwwNlqSBW1lHz6i2AHc75k7gV4Rhm01kFeotH7PDiHIX2G8X3KnRPc33SGVIg=="],
270+
271+ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
272+
273+ "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
274+
275+ "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
276+
277+ "file-type": ["file-type@21.3.2", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w=="],
278+
279+ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
280+
281+ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
282+
283+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
284+
285+ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
286+
287+ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
288+
289+ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
290+
291+ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
292+
293+ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
294+
295+ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
296+
297+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
298+
299+ "isomorphic-dompurify": ["isomorphic-dompurify@3.3.0", "", { "dependencies": { "dompurify": "^3.3.3", "jsdom": "^28.1.0" } }, "sha512-Xx3GlNZNAXIISX49OMpfNgxrRU49KVQ5hqnK2zbkqkPucdWnCZXquTMK/COnb8pmIOJlfiaNYav9qyHX3gRDMA=="],
300+
301+ "jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="],
302+
303+ "jxl-rs-polyfill": ["jxl-rs-polyfill@0.1.1", "", {}, "sha512-BFrrvbnInmDYqEcZZZ2nYJq248VMq6BBBcnc7uhAndz1uhIEclW6uMPLQjqTrEVcDg1JeRhq796kuSc+C/JasQ=="],
304+
305+ "kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="],
306+
307+ "kysely-bun-sqlite": ["kysely-bun-sqlite@0.4.0", "", { "dependencies": { "bun-types": "^1.1.31" }, "peerDependencies": { "kysely": "^0.28.2" } }, "sha512-2EkQE5sT4ewiw7IWfJsAkpxJ/QPVKXKO5sRYI/xjjJIJlECuOdtG+ssYM0twZJySrdrmuildNPFYVreyu1EdZg=="],
308+
309+ "linguist-languages": ["linguist-languages@9.3.1", "", {}, "sha512-Mum2sqg3MyhgKfpulFhKZMAK/1VnV6m9vCV8YQCSqWs+pbKouKn9EqRshZjVWUaJjl6NTTDcYJk/1+C02siXEQ=="],
310+
311+ "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
312+
313+ "marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="],
314+
315+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
316+
317+ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
318+
319+ "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
320+
321+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
322+
323+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
324+
325+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
326+
327+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
328+
329+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
330+
331+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
332+
333+ "nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="],
334+
335+ "node-addon-api": ["node-addon-api@8.6.0", "", {}, "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q=="],
336+
337+ "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
338+
339+ "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
340+
341+ "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
342+
343+ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
344+
345+ "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
346+
347+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
348+
349+ "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
350+
351+ "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
352+
353+ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
354+
355+ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
356+
357+ "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
358+
359+ "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
360+
361+ "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
362+
363+ "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
364+
365+ "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
366+
367+ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
368+
369+ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
370+
371+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
372+
373+ "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
374+
375+ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
376+
377+ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
378+
379+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
380+
381+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
382+
383+ "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
384+
385+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
386+
387+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
388+
389+ "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
390+
391+ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
392+
393+ "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
394+
395+ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
396+
397+ "tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="],
398+
399+ "tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="],
400+
401+ "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
402+
403+ "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
404+
405+ "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
406+
407+ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
408+
409+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
410+
411+ "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
412+
413+ "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
414+
415+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
416+
417+ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
418+
419+ "undici": ["undici@7.24.3", "", {}, "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA=="],
420+
421+ "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
422+
423+ "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
424+
425+ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
426+
427+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
428+
429+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
430+
431+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
432+
433+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
434+
435+ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
436+
437+ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
438+
439+ "wasm-feature-detect": ["wasm-feature-detect@1.8.0", "", {}, "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="],
440+
441+ "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
442+
443+ "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
444+
445+ "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
446+
447+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
448+
449+ "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
450+
451+ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
452+
453+ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
454+
455+ "bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
456+
457+ "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
458+
459+ "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
460+ }
461+}
Acompose.yml
@@ -0,0 +1,20 @@
1+services:
2+ hearthforge:
3+ build:
4+ context: .
5+ ports:
6+ - "3000:3000"
7+ - "2222:2222"
8+ volumes:
9+ - ./data:/data
10+ environment:
11+ DATA_DIR: /data
12+ PORT: 3000
13+ SSH_PORT: 2222
14+ OWNER_DISPLAY_NAME: Admin
15+ BASE_URL: "http://localhost:3000"
16+ # ADMIN_PASSWORD: change-me
17+ # SSH_DISABLED: 0
18+ # ARCHIVE_ZST_ENABLED: 0
19+ # TRUSTED_PROXY: 1
20+ # REGISTRATION_DISABLED: 0
Apackage.json
@@ -0,0 +1,47 @@
1+{
2+ "name": "hearthforge",
3+ "module": "src/index.tsx",
4+ "scripts": {
5+ "dev": "bun run --watch src/index.tsx",
6+ "start": "bun run src/index.tsx",
7+ "db:init": "bun run src/db/init.ts",
8+ "db:seed": "bun run src/db/seed.ts",
9+ "test": "bun test tests/e2e.test.ts --timeout 60000",
10+ "vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm",
11+ "vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js",
12+ "postinstall": "bun run vendor:simplewebauthn && bun run vendor:jxl-polyfill",
13+ "lint": "biome check src/",
14+ "format": "biome check --write src/"
15+ },
16+ "type": "module",
17+ "private": true,
18+ "devDependencies": {
19+ "@biomejs/biome": "^2.4.7",
20+ "@types/argon2": "^0.15.4",
21+ "@types/bun": "latest",
22+ "@types/ssh2": "^1.15.5",
23+ "playwright": "^1.58.2"
24+ },
25+ "peerDependencies": {
26+ "typescript": "^5"
27+ },
28+ "dependencies": {
29+ "@elysiajs/static": "^1.4.7",
30+ "@jsquash/jxl": "^1.3.0",
31+ "@kitajs/html": "^4.2.13",
32+ "@simplewebauthn/browser": "^13.3.0",
33+ "@simplewebauthn/server": "^13.3.0",
34+ "argon2": "^0.44.0",
35+ "elysia": "^1.4.27",
36+ "file-type": "^21.3.2",
37+ "isomorphic-dompurify": "^3.3.0",
38+ "jxl-rs-polyfill": "^0.1.1",
39+ "kysely": "^0.28.12",
40+ "kysely-bun-sqlite": "^0.4.0",
41+ "linguist-languages": "^9.3.1",
42+ "marked": "^17.0.4",
43+ "sharp": "^0.34.5",
44+ "shiki": "^4.0.2",
45+ "ssh2": "^1.17.0"
46+ }
47+}
Apreview.png
Bin 099613 bytes@ 8de77c0@ master
Binary file — not shown
Apublic/assets/favicon.svg
@@ -0,0 +1,18 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 32 32">
2+ <!-- Hearth stone base -->
3+ <rect x="3" y="23" width="26" height="7" rx="2" fill="#6b2e0c"/>
4+ <!-- Stone highlight band -->
5+ <rect x="5" y="24" width="22" height="2" rx="1" fill="#8b4220"/>
6+
7+ <!-- Outer flame (deep red-orange) -->
8+ <path d="M16 3 C21 7 26.5 12 25 19 C24 23 21 26 16 26 C11 26 8 23 7 19 C5.5 12 11 7 16 3Z" fill="#b83c14"/>
9+
10+ <!-- Mid flame (bright orange) -->
11+ <path d="M16 8 C19.5 11 22.5 15.5 21.5 20 C21 22.5 19 26 16 26 C13 26 11 22.5 10.5 20 C9.5 15.5 12.5 11 16 8Z" fill="#e06018"/>
12+
13+ <!-- Inner glow (amber) -->
14+ <path d="M16 13 C18.2 16 19.5 19 18.8 21.5 C18.4 23 17.2 25 16 25 C14.8 25 13.6 23 13.2 21.5 C12.5 19 13.8 16 16 13Z" fill="#f5a020"/>
15+
16+ <!-- Hot core (golden yellow) -->
17+ <path d="M16 17.5 C17 19.5 17.5 21.5 17 23 C16.8 23.8 16 24.8 16 24.8 C16 24.8 15.2 23.8 15 23 C14.5 21.5 15 19.5 16 17.5Z" fill="#ffe060"/>
18+</svg>
Apublic/assets/main.css
@@ -0,0 +1,1807 @@
1+/* View Transitions */
2+@view-transition {
3+ navigation: auto;
4+}
5+
6+.site-nav { view-transition-name: site-nav; }
7+.repo-header { view-transition-name: repo-header; }
8+.breadcrumb { view-transition-name: breadcrumb; }
9+
10+/* ============================================================
11+ @layer reset
12+ ============================================================ */
13+@layer reset {
14+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
15+ html { -webkit-text-size-adjust: 100%; }
16+ body { min-height: 100dvh; min-width: 500px; }
17+ img, video { max-width: 100%; height: auto; }
18+ button, input, textarea, select { font: inherit; }
19+ a { color: inherit; }
20+ ul, ol { list-style: none; }
21+ h1, h2, h3, h4, h5, h6 { font-weight: 600; line-height: 1.2; }
22+ pre, code { font-family: var(--font-mono); }
23+}
24+
25+/* ============================================================
26+ @layer tokens
27+ ============================================================ */
28+@layer tokens {
29+ :root {
30+ /* Colors — light */
31+ --color-bg: #ffffff;
32+ --color-bg-subtle: #f6f8fa;
33+ --color-bg-inset: #eef0f2;
34+ --color-border: #d0d7de;
35+ --color-border-muted: #e4e7eb;
36+ --color-text: #1f2328;
37+ --color-text-muted: #656d76;
38+ --color-link: #0969da;
39+ --color-link-hover: #0550ae;
40+
41+ --color-accent: #fd7e14;
42+ --color-accent-bg: #fff3cd;
43+
44+ --color-success: #1a7f37;
45+ --color-success-bg: #dafbe1;
46+ --color-danger: #cf222e;
47+ --color-danger-bg: #ffebe9;
48+ --color-warning: #9a6700;
49+ --color-warning-bg: #fff8c5;
50+ --color-merged: #8250df;
51+ --color-merged-bg: #fbefff;
52+
53+ /* Typography */
54+ --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
55+ --font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
56+
57+ --text-xs: 0.75rem;
58+ --text-sm: 0.875rem;
59+ --text-base: 1rem;
60+ --text-lg: 1.125rem;
61+ --text-xl: 1.25rem;
62+ --text-2xl: 1.5rem;
63+ --text-3xl: 1.875rem;
64+
65+ /* Spacing */
66+ --space-1: 0.25rem;
67+ --space-2: 0.5rem;
68+ --space-3: 0.75rem;
69+ --space-4: 1rem;
70+ --space-5: 1.25rem;
71+ --space-6: 1.5rem;
72+ --space-8: 2rem;
73+ --space-10: 2.5rem;
74+ --space-12: 3rem;
75+ --space-16: 4rem;
76+
77+ /* Border radius */
78+ --radius-sm: 4px;
79+ --radius-md: 6px;
80+ --radius-lg: 8px;
81+ --radius-full: 9999px;
82+
83+ /* Shadows */
84+ --shadow-sm: 0 1px 0 rgba(27,31,36,0.04);
85+ --shadow-md: 0 3px 6px rgba(140,149,159,0.15);
86+ --shadow-lg: 0 8px 24px rgba(140,149,159,0.2);
87+ }
88+
89+ @media (prefers-color-scheme: dark) {
90+ :root {
91+ --color-bg: #0d1117;
92+ --color-bg-subtle: #161b22;
93+ --color-bg-inset: #1c2128;
94+ --color-border: #30363d;
95+ --color-border-muted: #21262d;
96+ --color-text: #e6edf3;
97+ --color-text-muted: #7d8590;
98+ --color-link: #58a6ff;
99+ --color-link-hover: #79c0ff;
100+
101+ --color-accent: #f0883e;
102+ --color-accent-bg: #3d2f00;
103+
104+ --color-success: #3fb950;
105+ --color-success-bg: #0d2b19;
106+ --color-danger: #f85149;
107+ --color-danger-bg: #2d1419;
108+ --color-warning: #d29922;
109+ --color-warning-bg: #2c2000;
110+ --color-merged: #bc8cff;
111+ --color-merged-bg: #1e0b3e;
112+ }
113+ }
114+
115+ /* Explicit theme overrides — applied via data-theme attribute set by cookie */
116+ :root[data-theme="dark"] {
117+ --color-bg: #0d1117;
118+ --color-bg-subtle: #161b22;
119+ --color-bg-inset: #1c2128;
120+ --color-border: #30363d;
121+ --color-border-muted: #21262d;
122+ --color-text: #e6edf3;
123+ --color-text-muted: #7d8590;
124+ --color-link: #58a6ff;
125+ --color-link-hover: #79c0ff;
126+ --color-accent: #f0883e;
127+ --color-accent-bg: #3d2f00;
128+ --color-success: #3fb950;
129+ --color-success-bg: #0d2b19;
130+ --color-danger: #f85149;
131+ --color-danger-bg: #2d1419;
132+ --color-warning: #d29922;
133+ --color-warning-bg: #2c2000;
134+ --color-merged: #bc8cff;
135+ --color-merged-bg: #1e0b3e;
136+ }
137+ :root[data-theme="light"] {
138+ --color-bg: #ffffff;
139+ --color-bg-subtle: #f6f8fa;
140+ --color-bg-inset: #eef0f2;
141+ --color-border: #d0d7de;
142+ --color-border-muted: #e4e7eb;
143+ --color-text: #1f2328;
144+ --color-text-muted: #656d76;
145+ --color-link: #0969da;
146+ --color-link-hover: #0550ae;
147+ --color-accent: #fd7e14;
148+ --color-accent-bg: #fff3cd;
149+ --color-success: #1a7f37;
150+ --color-success-bg: #dafbe1;
151+ --color-danger: #cf222e;
152+ --color-danger-bg: #ffebe9;
153+ --color-warning: #9a6700;
154+ --color-warning-bg: #fff8c5;
155+ --color-merged: #8250df;
156+ --color-merged-bg: #fbefff;
157+ }
158+}
159+
160+/* ============================================================
161+ @layer layout
162+ ============================================================ */
163+@layer layout {
164+ body {
165+ font-family: var(--font-sans);
166+ font-size: var(--text-base);
167+ color: var(--color-text);
168+ background: var(--color-bg);
169+ display: grid;
170+ grid-template-rows: auto 1fr auto;
171+ min-height: 100dvh;
172+ }
173+
174+ .site-header {
175+ background: var(--color-bg-subtle);
176+ border-bottom: 1px solid var(--color-border);
177+ position: sticky;
178+ top: 0;
179+ z-index: 100;
180+ }
181+
182+ .site-nav {
183+ max-width: 1200px;
184+ margin: 0 auto;
185+ padding: var(--space-3) var(--space-4);
186+ display: flex;
187+ align-items: center;
188+ gap: var(--space-4);
189+ }
190+
191+ .site-logo {
192+ display: flex;
193+ align-items: center;
194+ gap: var(--space-2);
195+ text-decoration: none;
196+ font-weight: 700;
197+ font-size: var(--text-lg);
198+ color: var(--color-text);
199+ }
200+
201+ .logo-icon { width: 1.6em; height: 1.6em; display: block; flex-shrink: 0; }
202+
203+ .nav-links {
204+ margin-left: auto;
205+ display: flex;
206+ align-items: center;
207+ gap: var(--space-3);
208+ }
209+
210+ .nav-user {
211+ font-size: var(--text-sm);
212+ color: var(--color-text-muted);
213+ }
214+
215+ .site-main {
216+ padding: var(--space-8) var(--space-4);
217+ min-width: 0;
218+ }
219+
220+ .site-footer {
221+ text-align: center;
222+ padding: var(--space-6) var(--space-4);
223+ font-size: var(--text-sm);
224+ color: var(--color-text-muted);
225+ border-top: 1px solid var(--color-border);
226+ }
227+
228+ .container {
229+ max-width: 1000px;
230+ margin: 0 auto;
231+ overflow-x: clip;
232+ }
233+
234+ .container-narrow {
235+ max-width: 680px;
236+ }
237+
238+ .inline-form {
239+ display: inline;
240+ }
241+
242+ /* --- Two-step delete confirmation --- */
243+ .confirm-details { position: relative; display: inline-block; }
244+ .confirm-details > summary { list-style: none; }
245+ .confirm-details > summary::-webkit-details-marker { display: none; }
246+ .confirm-popup {
247+ position: absolute;
248+ right: 0;
249+ top: calc(100% + var(--space-1));
250+ background: var(--color-bg);
251+ border: 1px solid var(--color-border);
252+ border-radius: var(--radius-md);
253+ padding: var(--space-3) var(--space-4);
254+ display: flex;
255+ align-items: center;
256+ gap: var(--space-3);
257+ white-space: nowrap;
258+ z-index: 100;
259+ font-size: var(--text-sm);
260+ color: var(--color-text-muted);
261+ }
262+}
263+
264+/* ============================================================
265+ @layer components
266+ ============================================================ */
267+@layer components {
268+
269+ /* --- Buttons --- */
270+ .btn {
271+ display: inline-flex;
272+ align-items: center;
273+ gap: var(--space-2);
274+ padding: var(--space-2) var(--space-4);
275+ border: 1px solid var(--color-border);
276+ border-radius: var(--radius-md);
277+ font-size: var(--text-sm);
278+ font-weight: 500;
279+ text-decoration: none;
280+ cursor: pointer;
281+ background: var(--color-bg-subtle);
282+ color: var(--color-text);
283+ transition: background 0.1s, border-color 0.1s;
284+ white-space: nowrap;
285+ }
286+ .btn:hover { background: var(--color-bg-inset); border-color: var(--color-border); }
287+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
288+ .btn-primary {
289+ background: var(--color-accent);
290+ border-color: transparent;
291+ color: #fff;
292+ }
293+ .btn-primary:hover { background: color-mix(in srgb, var(--color-accent) 82%, #000); }
294+ .btn-secondary {
295+ background: var(--color-bg-subtle);
296+ border-color: var(--color-border);
297+ }
298+ .btn-ghost {
299+ background: transparent;
300+ border-color: transparent;
301+ }
302+ .btn-ghost:hover { background: var(--color-bg-inset); }
303+ /* Keep ghost transparent even when combined with size variants */
304+ .btn-ghost.btn-xs,
305+ .btn-ghost.btn-sm { background: transparent; border-color: transparent; }
306+ .btn-ghost.btn-xs:hover,
307+ .btn-ghost.btn-sm:hover { background: var(--color-bg-inset); }
308+ .btn-sm { padding: var(--space-1) var(--space-3); font-size: var(--text-xs); }
309+ .btn-block { width: 100%; justify-content: center; }
310+
311+ /* --- Forms --- */
312+ .form-group {
313+ display: flex;
314+ flex-direction: column;
315+ gap: var(--space-2);
316+ margin-bottom: var(--space-4);
317+ }
318+ .form-group label {
319+ font-size: var(--text-sm);
320+ font-weight: 500;
321+ color: var(--color-text);
322+ }
323+ .form-group input:not([type="file"]):not([type="checkbox"]),
324+ .form-group textarea,
325+ .form-group select {
326+ padding: var(--space-2) var(--space-3);
327+ border: 1px solid var(--color-border);
328+ border-radius: var(--radius-md);
329+ background: var(--color-bg);
330+ color: var(--color-text);
331+ font-size: var(--text-sm);
332+ }
333+ .form-group input:not([type="file"]):not([type="checkbox"]):focus,
334+ .form-group textarea:focus {
335+ outline: 2px solid var(--color-accent);
336+ outline-offset: -1px;
337+ border-color: var(--color-accent);
338+ }
339+ .form-group input[type="file"] {
340+ font-size: var(--text-sm);
341+ color: var(--color-text);
342+ padding: 0;
343+ border: none;
344+ background: none;
345+ }
346+ .form-group textarea { resize: vertical; min-height: 120px; }
347+ .form-success {
348+ background: var(--color-success-bg);
349+ color: var(--color-success);
350+ border: 1px solid var(--color-success);
351+ border-radius: var(--radius-md);
352+ padding: var(--space-3) var(--space-4);
353+ font-size: var(--text-sm);
354+ margin-bottom: var(--space-4);
355+ }
356+ .form-hint {
357+ font-size: var(--text-sm);
358+ color: var(--color-text-muted);
359+ margin: 0;
360+ }
361+ .form-error {
362+ background: var(--color-danger-bg);
363+ color: var(--color-danger);
364+ border: 1px solid var(--color-danger);
365+ border-radius: var(--radius-md);
366+ padding: var(--space-3) var(--space-4);
367+ font-size: var(--text-sm);
368+ margin-bottom: var(--space-4);
369+ }
370+ .form-actions {
371+ display: flex;
372+ gap: var(--space-3);
373+ align-items: center;
374+ flex-wrap: wrap;
375+ }
376+ .form-card {
377+ background: var(--color-bg-subtle);
378+ border: 1px solid var(--color-border);
379+ border-radius: var(--radius-lg);
380+ padding: var(--space-6);
381+ margin-top: var(--space-4);
382+ }
383+ .checkbox-label {
384+ display: flex;
385+ align-items: center;
386+ gap: var(--space-2);
387+ cursor: pointer;
388+ }
389+
390+ /* --- Auth --- */
391+ .auth-container {
392+ max-width: 400px;
393+ margin: var(--space-12) auto;
394+ }
395+ .auth-container .page-title { margin-bottom: var(--space-6); }
396+ .auth-form { margin-bottom: var(--space-4); }
397+ .auth-footer {
398+ text-align: center;
399+ font-size: var(--text-sm);
400+ color: var(--color-text-muted);
401+ margin-top: var(--space-4);
402+ }
403+ .passkey-btn { display: none; margin-top: var(--space-3); }
404+
405+ /* --- Page header --- */
406+ .page-header {
407+ display: flex;
408+ align-items: center;
409+ justify-content: space-between;
410+ margin-bottom: var(--space-6);
411+ gap: var(--space-4);
412+ }
413+ .page-title {
414+ font-size: var(--text-2xl);
415+ color: var(--color-text);
416+ margin-bottom: var(--space-4);
417+ }
418+ .page-title a { text-decoration: none; color: var(--color-link); }
419+ .page-title a:hover { text-decoration: underline; }
420+ .section-title {
421+ font-size: var(--text-lg);
422+ margin-bottom: var(--space-4);
423+ padding-bottom: var(--space-2);
424+ border-bottom: 1px solid var(--color-border);
425+ }
426+
427+ /* --- Repo header --- */
428+ .repo-header {
429+ margin-bottom: var(--space-2);
430+ }
431+ .repo-title-row {
432+ display: flex;
433+ align-items: center;
434+ gap: var(--space-3);
435+ flex-wrap: wrap;
436+ }
437+ .repo-title-row .page-title { margin-bottom: 0; }
438+ .repo-header-title {
439+ display: flex;
440+ align-items: center;
441+ gap: var(--space-3);
442+ flex-wrap: wrap;
443+ }
444+ .repo-description {
445+ color: var(--color-text-muted);
446+ margin-top: var(--space-2);
447+ }
448+ .repo-header-meta {
449+ display: flex;
450+ align-items: center;
451+ gap: var(--space-4);
452+ margin-bottom: var(--space-3);
453+ font-size: var(--text-sm);
454+ color: var(--color-text-muted);
455+ }
456+ .icon { font-style: normal; }
457+
458+ /* --- Branch selector --- */
459+ .branch-selector {
460+ display: inline-flex;
461+ align-items: center;
462+ gap: var(--space-2);
463+ }
464+ .branch-selector-icon {
465+ color: var(--color-text-muted);
466+ font-size: var(--text-sm);
467+ user-select: none;
468+ }
469+ .branch-select {
470+ padding: var(--space-1) var(--space-2);
471+ padding-right: var(--space-5);
472+ border: 1px solid var(--color-border);
473+ border-radius: var(--radius-md);
474+ background: var(--color-bg-subtle);
475+ font-size: var(--text-sm);
476+ font-family: var(--font-mono);
477+ color: var(--color-text);
478+ cursor: pointer;
479+ appearance: auto;
480+ max-width: 180px;
481+ }
482+ .branch-select:hover { border-color: var(--color-text-muted); }
483+ .branch-select:focus { outline: 2px solid var(--color-accent); outline-offset: -1px; }
484+
485+ /* toolbar row above file tree */
486+ .tree-toolbar {
487+ display: flex;
488+ align-items: center;
489+ justify-content: space-between;
490+ margin-bottom: var(--space-3);
491+ }
492+
493+ /* commits-header flex row */
494+ .commits-header {
495+ display: flex;
496+ align-items: center;
497+ gap: var(--space-4);
498+ margin-bottom: var(--space-4);
499+ flex-wrap: wrap;
500+ }
501+ .commits-header .section-title { margin-bottom: 0; }
502+
503+ /* --- Repo nav bar (tabs + private badge) --- */
504+ .repo-nav-bar {
505+ display: flex;
506+ align-items: center;
507+ justify-content: space-between;
508+ border-bottom: 1px solid var(--color-border);
509+ margin-bottom: var(--space-6);
510+ gap: var(--space-4);
511+ }
512+
513+
514+ /* --- Repo tabs --- */
515+ .repo-tabs {
516+ display: flex;
517+ gap: 0;
518+ flex: 1;
519+ overflow-x: auto;
520+ overflow-y: hidden;
521+ }
522+ .repo-tab {
523+ padding: var(--space-3) var(--space-4);
524+ font-size: var(--text-sm);
525+ text-decoration: none;
526+ color: var(--color-text-muted);
527+ border-bottom: 2px solid transparent;
528+ margin-bottom: -1px;
529+ transition: color 0.1s;
530+ }
531+ .repo-tab:hover { color: var(--color-text); }
532+ .repo-tab.active {
533+ color: var(--color-text);
534+ font-weight: 600;
535+ border-bottom-color: var(--color-accent);
536+ }
537+ /* Align active indicator with nav-bar border */
538+ .repo-tab { margin-bottom: -1px; }
539+
540+ /* --- Repo list --- */
541+ .repo-list { display: flex; flex-direction: column; gap: var(--space-2); }
542+ .repo-card {
543+ display: flex;
544+ align-items: flex-start;
545+ justify-content: space-between;
546+ padding: var(--space-4) var(--space-5);
547+ border: 1px solid var(--color-border);
548+ border-radius: var(--radius-lg);
549+ background: var(--color-bg);
550+ gap: var(--space-4);
551+ transition: border-color 0.15s;
552+ }
553+ .repo-card:hover { border-color: var(--color-accent); }
554+ .repo-card-main { flex: 1; min-width: 0; }
555+ .repo-card-title {
556+ display: flex;
557+ align-items: center;
558+ gap: var(--space-2);
559+ flex-wrap: wrap;
560+ margin-bottom: var(--space-1);
561+ }
562+ .repo-name {
563+ font-size: var(--text-lg);
564+ font-weight: 600;
565+ color: var(--color-link);
566+ text-decoration: none;
567+ }
568+ .repo-name:hover { text-decoration: underline; }
569+ .repo-card-meta {
570+ display: flex;
571+ flex-direction: column;
572+ align-items: flex-end;
573+ gap: var(--space-1);
574+ font-size: var(--text-xs);
575+ color: var(--color-text-muted);
576+ white-space: nowrap;
577+ }
578+ .repo-date { color: var(--color-text-muted); }
579+
580+ /* --- Badges --- */
581+ .badge {
582+ display: inline-flex;
583+ align-items: center;
584+ padding: 1px var(--space-2);
585+ border-radius: var(--radius-full);
586+ font-size: var(--text-xs);
587+ font-weight: 500;
588+ border: 1px solid;
589+ }
590+ .badge-private {
591+ color: var(--color-warning);
592+ border-color: var(--color-warning);
593+ background: var(--color-warning-bg);
594+ }
595+ .issue-badge, .patch-badge {
596+ display: inline-flex;
597+ align-items: center;
598+ padding: var(--space-1) var(--space-3);
599+ border-radius: var(--radius-full);
600+ font-size: var(--text-xs);
601+ font-weight: 500;
602+ text-transform: capitalize;
603+ }
604+ .issue-badge.open, .patch-badge.open {
605+ background: var(--color-success-bg);
606+ color: var(--color-success);
607+ }
608+ .issue-badge.closed, .patch-badge.closed {
609+ background: var(--color-danger-bg);
610+ color: var(--color-danger);
611+ }
612+ .patch-badge.merged {
613+ background: var(--color-merged-bg);
614+ color: var(--color-merged);
615+ }
616+
617+ /* --- Empty state --- */
618+ .empty-state {
619+ text-align: center;
620+ padding: var(--space-16) var(--space-4);
621+ color: var(--color-text-muted);
622+ }
623+ .empty-state p { margin-bottom: var(--space-4); font-size: var(--text-lg); }
624+
625+ /* --- Breadcrumb --- */
626+ .breadcrumb {
627+ display: flex;
628+ align-items: center;
629+ flex-wrap: wrap;
630+ gap: var(--space-1);
631+ font-size: var(--text-sm);
632+ margin-bottom: var(--space-4);
633+ }
634+ .breadcrumb a { color: var(--color-link); text-decoration: none; }
635+ .breadcrumb a:hover { text-decoration: underline; }
636+ .breadcrumb-sep { color: var(--color-text-muted); }
637+ .breadcrumb-current { color: var(--color-text-muted); }
638+
639+ /* --- File tree --- */
640+ .file-tree {
641+ width: 100%;
642+ border: 1px solid var(--color-border);
643+ border-radius: var(--radius-lg);
644+ border-collapse: collapse;
645+ overflow: hidden;
646+ margin-bottom: var(--space-6);
647+ }
648+ .file-tree-row {
649+ border-bottom: 1px solid var(--color-border-muted);
650+ }
651+ .file-tree-row:last-child { border-bottom: none; }
652+ .file-tree-row:hover { background: var(--color-bg-subtle); }
653+ .file-icon, .file-name, .file-size {
654+ padding: var(--space-2) var(--space-3);
655+ font-size: var(--text-sm);
656+ vertical-align: middle;
657+ }
658+ .file-icon {
659+ width: 36px;
660+ padding-right: var(--space-1);
661+ text-align: center;
662+ line-height: 0;
663+ }
664+ .file-name { width: 100%; }
665+ .file-name a { color: var(--color-link); text-decoration: none; }
666+ .file-name a:hover { text-decoration: underline; }
667+ .file-name a.file-name-dir { font-weight: 500; }
668+ .file-size {
669+ text-align: right;
670+ color: var(--color-text-muted);
671+ font-size: var(--text-xs);
672+ white-space: nowrap;
673+ padding-right: var(--space-4);
674+ }
675+ .file-icon-dir { color: var(--color-link); }
676+ .file-icon-file { color: var(--color-text-muted); }
677+ .tree-icon { display: inline-block; vertical-align: text-bottom; }
678+ .file-tree-row-up .file-name a { color: var(--color-text-muted); }
679+ .file-tree-row-up .file-name a:hover { color: var(--color-link); }
680+
681+ /* --- File blob --- */
682+ .file-blob-header {
683+ display: flex;
684+ align-items: center;
685+ justify-content: space-between;
686+ padding: var(--space-3) var(--space-4);
687+ background: var(--color-bg-subtle);
688+ border: 1px solid var(--color-border);
689+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
690+ }
691+ .file-blob-name { font-size: var(--text-sm); font-weight: 500; }
692+ .file-blob-body {
693+ border: 1px solid var(--color-border);
694+ border-top: none;
695+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
696+ overflow: auto;
697+ }
698+ .file-blob-body .shiki-wrapper pre {
699+ margin: 0;
700+ padding: var(--space-4);
701+ font-size: var(--text-sm);
702+ line-height: 1.6;
703+ min-width: max-content;
704+ }
705+ .file-download-notice {
706+ padding: var(--space-8);
707+ text-align: center;
708+ color: var(--color-text-muted);
709+ }
710+ .file-download-notice p { margin-bottom: var(--space-4); }
711+
712+ /* --- Commit list --- */
713+ .commit-list { display: flex; flex-direction: column; }
714+ .commit-item {
715+ display: flex;
716+ align-items: flex-start;
717+ justify-content: space-between;
718+ padding: var(--space-3) 0;
719+ border-bottom: 1px solid var(--color-border-muted);
720+ gap: var(--space-4);
721+ flex-wrap: wrap;
722+ }
723+ .commit-item:last-child { border-bottom: none; }
724+ .commit-main { flex: 1; min-width: 0; }
725+ .commit-subject {
726+ color: var(--color-text);
727+ text-decoration: none;
728+ font-size: var(--text-sm);
729+ font-weight: 500;
730+ }
731+ .commit-subject:hover { color: var(--color-link); }
732+ .commit-meta {
733+ display: flex;
734+ align-items: center;
735+ gap: var(--space-3);
736+ font-size: var(--text-xs);
737+ color: var(--color-text-muted);
738+ white-space: nowrap;
739+ }
740+ .commit-hash {
741+ font-family: var(--font-mono);
742+ color: var(--color-link);
743+ text-decoration: none;
744+ background: var(--color-bg-inset);
745+ padding: 1px var(--space-2);
746+ border-radius: var(--radius-sm);
747+ }
748+ .commit-hash:hover { text-decoration: underline; }
749+ .mono { font-family: var(--font-mono); }
750+
751+ /* --- Commit detail viewer --- */
752+ .commit-page-top {
753+ display: flex;
754+ align-items: center;
755+ justify-content: space-between;
756+ margin-bottom: var(--space-4);
757+ }
758+ .commit-card {
759+ border: 1px solid var(--color-border);
760+ border-radius: var(--radius-lg);
761+ margin-bottom: var(--space-4);
762+ overflow: hidden;
763+ }
764+ .commit-card-subject {
765+ font-size: var(--text-xl);
766+ font-weight: 600;
767+ padding: var(--space-5) var(--space-6);
768+ border-bottom: 1px solid var(--color-border);
769+ line-height: 1.3;
770+ }
771+ .commit-card-body {
772+ padding: var(--space-4) var(--space-6);
773+ font-family: inherit;
774+ font-size: var(--text-sm);
775+ color: var(--color-text-muted);
776+ border-bottom: 1px solid var(--color-border);
777+ overflow-x: auto;
778+ }
779+ .commit-card-meta {
780+ padding: var(--space-4) var(--space-6);
781+ display: flex;
782+ flex-direction: column;
783+ gap: var(--space-2);
784+ background: var(--color-bg-subtle);
785+ }
786+ .commit-card-meta-row {
787+ display: flex;
788+ align-items: baseline;
789+ gap: var(--space-3);
790+ font-size: var(--text-sm);
791+ flex-wrap: wrap;
792+ }
793+ .commit-meta-label {
794+ font-weight: 600;
795+ color: var(--color-text-muted);
796+ min-width: 5rem;
797+ font-size: var(--text-xs);
798+ text-transform: uppercase;
799+ letter-spacing: 0.04em;
800+ }
801+ .commit-sha-full {
802+ font-size: var(--text-xs);
803+ background: var(--color-bg-inset);
804+ padding: 2px var(--space-2);
805+ border-radius: var(--radius-sm);
806+ word-break: break-all;
807+ }
808+ .commit-stats-bar {
809+ font-size: var(--text-sm);
810+ color: var(--color-text-muted);
811+ margin-bottom: var(--space-4);
812+ }
813+
814+ /* --- Commit layout (sidebar + diffs) --- */
815+ .commit-layout {
816+ display: grid;
817+ grid-template-columns: 1fr;
818+ gap: var(--space-5);
819+ align-items: start;
820+ }
821+ .commit-diffs { min-width: 0; }
822+ @media (min-width: 900px) {
823+ .commit-layout { grid-template-columns: 220px 1fr; }
824+ .commit-layout:has(.file-nav-details:not([open])) { grid-template-columns: auto 1fr; }
825+ }
826+
827+ /* --- File nav sidebar --- */
828+ .commit-file-nav {
829+ position: sticky;
830+ top: calc(56px + var(--space-4));
831+ max-height: calc(100vh - 140px);
832+ overflow-y: auto;
833+ }
834+ .file-nav-details {
835+ border: 1px solid var(--color-border);
836+ border-radius: var(--radius-lg);
837+ background: var(--color-bg-subtle);
838+ overflow: hidden;
839+ }
840+ .file-nav-toggle {
841+ display: flex;
842+ align-items: center;
843+ gap: var(--space-2);
844+ padding: var(--space-3) var(--space-4);
845+ font-size: var(--text-sm);
846+ font-weight: 500;
847+ cursor: pointer;
848+ list-style: none;
849+ user-select: none;
850+ border-bottom: 1px solid var(--color-border);
851+ }
852+ .file-nav-toggle::-webkit-details-marker { display: none; }
853+ .file-nav-details:not([open]) .file-nav-toggle { border-bottom: none; }
854+ .file-nav-toggle-icon {
855+ font-size: 1em;
856+ transition: transform 0.15s;
857+ }
858+ .file-nav-details:not([open]) .file-nav-toggle-icon { transform: rotate(-90deg); }
859+ .file-nav-list {
860+ padding: var(--space-1) 0;
861+ list-style: none;
862+ }
863+ .file-nav-item {
864+ display: flex;
865+ align-items: center;
866+ gap: var(--space-2);
867+ padding: var(--space-1) var(--space-3);
868+ font-size: var(--text-xs);
869+ text-decoration: none;
870+ color: var(--color-text);
871+ border-radius: 0;
872+ overflow: hidden;
873+ }
874+ .file-nav-item:hover { background: var(--color-bg-inset); }
875+ .file-nav-status {
876+ flex-shrink: 0;
877+ width: 16px;
878+ height: 16px;
879+ display: inline-flex;
880+ align-items: center;
881+ justify-content: center;
882+ border-radius: var(--radius-sm);
883+ font-size: 9px;
884+ font-weight: 700;
885+ font-family: var(--font-mono);
886+ }
887+ .file-status-added { background: var(--color-success-bg); color: var(--color-success); }
888+ .file-status-deleted { background: var(--color-danger-bg); color: var(--color-danger); }
889+ .file-status-modified { background: var(--color-accent-bg); color: var(--color-accent); }
890+ .file-status-renamed { background: var(--color-merged-bg); color: var(--color-merged); }
891+ .file-status-copied { background: var(--color-merged-bg); color: var(--color-merged); }
892+ .file-nav-name {
893+ flex: 1;
894+ overflow: hidden;
895+ text-overflow: ellipsis;
896+ white-space: nowrap;
897+ font-family: var(--font-mono);
898+ }
899+ .file-nav-stat {
900+ flex-shrink: 0;
901+ display: flex;
902+ gap: var(--space-1);
903+ font-size: var(--text-xs);
904+ font-family: var(--font-mono);
905+ }
906+ .nav-add { color: var(--color-success); }
907+ .nav-del { color: var(--color-danger); }
908+ .file-nav-dir > details > .file-nav-list { padding-left: var(--space-4); }
909+ .file-nav-dir-toggle {
910+ display: flex;
911+ align-items: center;
912+ gap: var(--space-1);
913+ padding: var(--space-1) var(--space-3);
914+ font-size: var(--text-xs);
915+ font-family: var(--font-mono);
916+ color: var(--color-text-muted);
917+ cursor: pointer;
918+ list-style: none;
919+ user-select: none;
920+ }
921+ .file-nav-dir-toggle::-webkit-details-marker { display: none; }
922+ .file-nav-dir > details:not([open]) .file-nav-toggle-icon { transform: rotate(-90deg); }
923+
924+ /* --- Diff file cards --- */
925+ .diff-file {
926+ border: 1px solid var(--color-border);
927+ border-radius: var(--radius-lg);
928+ margin-bottom: var(--space-4);
929+ overflow: hidden;
930+ }
931+ .diff-file-header {
932+ display: flex;
933+ align-items: center;
934+ justify-content: space-between;
935+ gap: var(--space-3);
936+ padding: var(--space-3) var(--space-4);
937+ background: var(--color-bg-subtle);
938+ cursor: pointer;
939+ list-style: none;
940+ user-select: none;
941+ flex-wrap: wrap;
942+ }
943+ .diff-file-header::-webkit-details-marker { display: none; }
944+ .diff-file-header-left {
945+ display: flex;
946+ align-items: center;
947+ gap: var(--space-2);
948+ min-width: 0;
949+ flex: 1;
950+ }
951+ .diff-file-header-right {
952+ display: flex;
953+ align-items: center;
954+ gap: var(--space-2);
955+ flex-shrink: 0;
956+ flex-wrap: wrap;
957+ }
958+ .diff-file-toggle-icon {
959+ font-size: 0.7em;
960+ flex-shrink: 0;
961+ transition: transform 0.15s;
962+ }
963+ .diff-file:not([open]) .diff-file-toggle-icon { transform: rotate(-90deg); }
964+ .diff-status-badge {
965+ flex-shrink: 0;
966+ width: 18px;
967+ height: 18px;
968+ display: inline-flex;
969+ align-items: center;
970+ justify-content: center;
971+ border-radius: var(--radius-sm);
972+ font-size: 10px;
973+ font-weight: 700;
974+ font-family: var(--font-mono);
975+ }
976+ .diff-status-added { background: var(--color-success-bg); color: var(--color-success); }
977+ .diff-status-deleted { background: var(--color-danger-bg); color: var(--color-danger); }
978+ .diff-status-modified { background: var(--color-accent-bg); color: var(--color-accent); }
979+ .diff-status-renamed { background: var(--color-merged-bg); color: var(--color-merged); }
980+ .diff-status-copied { background: var(--color-merged-bg); color: var(--color-merged); }
981+ .diff-file-path {
982+ font-size: var(--text-sm);
983+ font-weight: 500;
984+ overflow: hidden;
985+ text-overflow: ellipsis;
986+ white-space: nowrap;
987+ }
988+ .diff-rename-arrow {
989+ font-size: var(--text-xs);
990+ color: var(--color-text-muted);
991+ white-space: nowrap;
992+ overflow: hidden;
993+ text-overflow: ellipsis;
994+ }
995+ .diff-stat-add { color: var(--color-success); font-size: var(--text-sm); font-family: var(--font-mono); font-weight: 600; }
996+ .diff-stat-del { color: var(--color-danger); font-size: var(--text-sm); font-family: var(--font-mono); font-weight: 600; }
997+ .btn-xs {
998+ padding: 1px var(--space-2);
999+ font-size: var(--text-xs);
1000+ border: 1px solid var(--color-border);
1001+ border-radius: var(--radius-sm);
1002+ background: var(--color-bg);
1003+ color: var(--color-text);
1004+ text-decoration: none;
1005+ cursor: pointer;
1006+ white-space: nowrap;
1007+ font-family: var(--font-mono);
1008+ display: inline-flex;
1009+ align-items: center;
1010+ transition: background 0.1s;
1011+ }
1012+ .btn-xs:hover { background: var(--color-bg-inset); }
1013+
1014+ /* --- Diff file body --- */
1015+ .diff-file-body { }
1016+ .diff-binary-notice {
1017+ padding: var(--space-4) var(--space-6);
1018+ font-size: var(--text-sm);
1019+ color: var(--color-text-muted);
1020+ font-style: italic;
1021+ }
1022+ .diff-hunk { border-top: 1px solid var(--color-border-muted); overflow-x: auto; }
1023+ .diff-hunk:first-child { border-top: none; }
1024+ .diff-hunk-header {
1025+ padding: var(--space-1) var(--space-4);
1026+ font-family: var(--font-mono);
1027+ font-size: var(--text-xs);
1028+ font-weight: normal;
1029+ text-align: left;
1030+ background: color-mix(in srgb, var(--color-merged-bg) 50%, var(--color-bg));
1031+ color: var(--color-merged);
1032+ border-bottom: 1px solid var(--color-border-muted);
1033+ white-space: pre;
1034+ }
1035+
1036+ /* --- Diff table --- */
1037+ .diff-table {
1038+ min-width: 100%;
1039+ border-collapse: collapse;
1040+ font-family: var(--font-mono);
1041+ font-size: var(--text-xs);
1042+ line-height: 1.5;
1043+ }
1044+ .diff-ln {
1045+ width: 44px;
1046+ min-width: 44px;
1047+ text-align: right;
1048+ padding: 0 var(--space-2);
1049+ color: var(--color-text-muted);
1050+ border-right: 1px solid var(--color-border-muted);
1051+ user-select: none;
1052+ vertical-align: top;
1053+ white-space: nowrap;
1054+ }
1055+ .diff-sign {
1056+ width: 18px;
1057+ min-width: 18px;
1058+ text-align: center;
1059+ padding: 0 var(--space-1);
1060+ user-select: none;
1061+ vertical-align: top;
1062+ }
1063+ .diff-code {
1064+ padding: 0 var(--space-3);
1065+ white-space: pre;
1066+ overflow: visible;
1067+ vertical-align: top;
1068+ word-break: normal;
1069+ }
1070+ /* Row type backgrounds */
1071+ .diff-row-add { background: color-mix(in srgb, var(--color-success-bg) 70%, transparent); }
1072+ .diff-row-del { background: color-mix(in srgb, var(--color-danger-bg) 70%, transparent); }
1073+ .diff-row-add .diff-ln,
1074+ .diff-row-add .diff-sign { background: color-mix(in srgb, var(--color-success-bg) 90%, transparent); }
1075+ .diff-row-del .diff-ln,
1076+ .diff-row-del .diff-sign { background: color-mix(in srgb, var(--color-danger-bg) 90%, transparent); }
1077+ .diff-row-add .diff-sign { color: var(--color-success); font-weight: 700; }
1078+ .diff-row-del .diff-sign { color: var(--color-danger); font-weight: 700; }
1079+
1080+ /* Shiki dark-mode support in diff code cells */
1081+ @media (prefers-color-scheme: dark) {
1082+ .diff-code span[style] { color: var(--shiki-dark) !important; }
1083+ }
1084+
1085+ /* Shiki dark-mode support in file blob view */
1086+ @media (prefers-color-scheme: dark) {
1087+ .shiki-wrapper .shiki { background-color: var(--shiki-dark-bg) !important; color: var(--shiki-dark) !important; }
1088+ .shiki-wrapper .shiki span { color: var(--shiki-dark) !important; }
1089+ }
1090+
1091+ /* --- Issue / Patch list --- */
1092+ .list-header {
1093+ display: flex;
1094+ align-items: center;
1095+ justify-content: space-between;
1096+ padding: var(--space-3) var(--space-4);
1097+ background: var(--color-bg-subtle);
1098+ border: 1px solid var(--color-border);
1099+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
1100+ gap: var(--space-4);
1101+ }
1102+ .list-header-tabs { display: flex; gap: var(--space-2); }
1103+ .list-tab {
1104+ padding: var(--space-1) var(--space-3);
1105+ border-radius: var(--radius-md);
1106+ font-size: var(--text-sm);
1107+ text-decoration: none;
1108+ color: var(--color-text-muted);
1109+ }
1110+ .list-tab:hover { background: var(--color-bg-inset); color: var(--color-text); }
1111+ .list-tab.active {
1112+ background: var(--color-bg-inset);
1113+ color: var(--color-text);
1114+ font-weight: 600;
1115+ }
1116+ .issue-list { border: 1px solid var(--color-border); border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
1117+ .issue-item {
1118+ padding: var(--space-4) var(--space-4);
1119+ border-bottom: 1px solid var(--color-border-muted);
1120+ }
1121+ .issue-item:last-child { border-bottom: none; }
1122+ .issue-main {
1123+ display: flex;
1124+ align-items: center;
1125+ gap: var(--space-2);
1126+ margin-bottom: var(--space-1);
1127+ flex-wrap: wrap;
1128+ }
1129+ .issue-title {
1130+ font-weight: 500;
1131+ text-decoration: none;
1132+ color: var(--color-text);
1133+ flex: 1;
1134+ }
1135+ .issue-title:hover { color: var(--color-link); }
1136+ .issue-meta {
1137+ font-size: var(--text-xs);
1138+ color: var(--color-text-muted);
1139+ display: flex;
1140+ gap: var(--space-3);
1141+ }
1142+ .issue-status-dot, .patch-status-dot {
1143+ width: 8px;
1144+ height: 8px;
1145+ border-radius: 50%;
1146+ flex-shrink: 0;
1147+ }
1148+ .issue-status-dot.open, .patch-status-dot.open { background: var(--color-success); }
1149+ .issue-status-dot.closed, .patch-status-dot.closed { background: var(--color-danger); }
1150+ .patch-status-dot.merged { background: var(--color-merged); }
1151+
1152+ /* --- Issue detail / Timeline --- */
1153+ .issue-detail-header {
1154+ display: flex;
1155+ align-items: center;
1156+ gap: var(--space-3);
1157+ margin-bottom: var(--space-6);
1158+ flex-wrap: wrap;
1159+ }
1160+ .issue-number {
1161+ font-size: var(--text-2xl);
1162+ font-weight: 600;
1163+ color: var(--color-text-muted);
1164+ white-space: nowrap;
1165+ }
1166+ .issue-detail-title {
1167+ font-size: var(--text-2xl);
1168+ font-weight: 600;
1169+ flex: 1;
1170+ }
1171+ .issue-detail-meta-actions { margin-left: auto; display: flex; gap: var(--space-2); }
1172+ .timeline-item {
1173+ border: 1px solid var(--color-border);
1174+ border-radius: var(--radius-lg);
1175+ margin-bottom: var(--space-4);
1176+ overflow: hidden;
1177+ }
1178+ .timeline-author {
1179+ background: var(--color-bg-subtle);
1180+ padding: var(--space-3) var(--space-4);
1181+ font-size: var(--text-sm);
1182+ display: flex;
1183+ align-items: center;
1184+ justify-content: space-between;
1185+ flex-wrap: wrap;
1186+ gap: var(--space-3);
1187+ border-bottom: 1px solid var(--color-border);
1188+ }
1189+ .timeline-author time { color: var(--color-text-muted); font-size: var(--text-xs); }
1190+ .timeline-author-right { margin-left: auto; display: flex; align-items: center; gap: var(--space-2); }
1191+ .edited-indicator { color: var(--color-text-muted); font-size: var(--text-xs); cursor: default; }
1192+
1193+ /* --- Avatars --- */
1194+ .avatar {
1195+ display: inline-flex;
1196+ border-radius: 4px;
1197+ overflow: hidden;
1198+ flex-shrink: 0;
1199+ vertical-align: middle;
1200+ }
1201+ .avatar-img {
1202+ width: 100%;
1203+ height: 100%;
1204+ object-fit: cover;
1205+ display: block;
1206+ color: transparent;
1207+ }
1208+ .avatar-placeholder {
1209+ display: inline-flex;
1210+ align-items: center;
1211+ justify-content: center;
1212+ border-radius: 4px;
1213+ background: var(--color-bg-inset);
1214+ color: var(--color-text-muted);
1215+ font-weight: 600;
1216+ flex-shrink: 0;
1217+ vertical-align: middle;
1218+ }
1219+ .avatar-settings {
1220+ display: flex;
1221+ align-items: flex-start;
1222+ gap: var(--space-5);
1223+ }
1224+ .avatar-actions {
1225+ display: flex;
1226+ flex-direction: column;
1227+ gap: var(--space-3);
1228+ }
1229+ .nav-user {
1230+ display: inline-flex;
1231+ align-items: center;
1232+ gap: var(--space-2);
1233+ }
1234+ .timeline-body { padding: var(--space-4); }
1235+ .timeline-item-new { padding: var(--space-4); }
1236+ .timeline-item-new .section-title { font-size: var(--text-base); border: none; padding-bottom: 0; }
1237+ /* Edit <details> (summary only) in .timeline-author; form is a sibling shown via :has() */
1238+ .timeline-author-right .inline-edit-details { border: none; }
1239+ .timeline-author-right .inline-edit-details > summary { padding: 0; list-style: none; }
1240+ .timeline-author-right .inline-edit-details > summary::-webkit-details-marker { display: none; }
1241+ .inline-edit-details .when-open { display: none; }
1242+ .inline-edit-details[open] .when-closed { display: none; }
1243+ .inline-edit-details[open] .when-open { display: inline; }
1244+ .inline-edit-form-area { display: none; border-top: 1px solid var(--color-border-muted); }
1245+ .timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area { display: block; }
1246+ .inline-edit-form { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
1247+
1248+ /* --- Reactions --- */
1249+ .reaction-bar {
1250+ display: flex;
1251+ flex-wrap: wrap;
1252+ gap: var(--space-2);
1253+ padding: var(--space-2) var(--space-4);
1254+ border-top: 1px solid var(--color-border-muted);
1255+ }
1256+ .reaction-form { display: inline; }
1257+ .reaction-btn {
1258+ display: inline-flex;
1259+ align-items: center;
1260+ gap: var(--space-1);
1261+ padding: var(--space-1) var(--space-3);
1262+ border: 1px solid var(--color-border);
1263+ border-radius: var(--radius-full);
1264+ background: var(--color-bg-subtle);
1265+ color: var(--color-text);
1266+ font-size: var(--text-sm);
1267+ cursor: pointer;
1268+ transition: background 0.1s;
1269+ }
1270+ .reaction-btn:hover { background: var(--color-bg-inset); }
1271+ .reaction-btn.reacted {
1272+ background: var(--color-accent-bg);
1273+ border-color: var(--color-accent);
1274+ }
1275+ .reaction-picker {
1276+ position: relative;
1277+ }
1278+ .reaction-add-btn {
1279+ display: inline-flex;
1280+ align-items: center;
1281+ justify-content: center;
1282+ width: 28px;
1283+ height: 28px;
1284+ border: 1px dashed var(--color-border);
1285+ border-radius: var(--radius-full);
1286+ background: transparent;
1287+ cursor: pointer;
1288+ font-size: var(--text-base);
1289+ color: var(--color-text-muted);
1290+ list-style: none;
1291+ }
1292+ .reaction-add-btn::-webkit-details-marker { display: none; }
1293+ .reaction-picker-dropdown {
1294+ position: absolute;
1295+ bottom: calc(100% + var(--space-2));
1296+ left: 0;
1297+ background: var(--color-bg);
1298+ border: 1px solid var(--color-border);
1299+ border-radius: var(--radius-lg);
1300+ padding: var(--space-2);
1301+ display: flex;
1302+ gap: var(--space-1);
1303+ z-index: 10;
1304+ }
1305+ .reaction-picker-btn {
1306+ background: none;
1307+ border: none;
1308+ font-size: 1.25rem;
1309+ cursor: pointer;
1310+ padding: var(--space-1);
1311+ border-radius: var(--radius-sm);
1312+ }
1313+ .reaction-picker-btn:hover { background: var(--color-bg-inset); }
1314+
1315+ /* --- Patch subview tabs --- */
1316+ .patch-tab-nav {
1317+ display: flex;
1318+ border-bottom: 1px solid var(--color-border);
1319+ margin-bottom: var(--space-6);
1320+ }
1321+ .tab-count {
1322+ display: inline-flex;
1323+ align-items: center;
1324+ justify-content: center;
1325+ min-width: 1.4em;
1326+ padding: 0 var(--space-1);
1327+ margin-left: var(--space-1);
1328+ font-size: var(--text-xs);
1329+ font-weight: 500;
1330+ background: var(--color-bg-inset);
1331+ border-radius: 999px;
1332+ color: var(--color-text-muted);
1333+ }
1334+
1335+ /* --- Patch apply status --- */
1336+ .apply-status {
1337+ margin: var(--space-4) 0;
1338+ }
1339+ .apply-result {
1340+ display: flex;
1341+ align-items: flex-start;
1342+ gap: var(--space-2);
1343+ padding: var(--space-3) var(--space-4);
1344+ border-radius: var(--radius-md);
1345+ font-size: var(--text-sm);
1346+ flex-direction: column;
1347+ }
1348+ .apply-result > div { display: flex; gap: var(--space-2); align-items: center; }
1349+ .apply-clean {
1350+ background: var(--color-success-bg);
1351+ color: var(--color-success);
1352+ border: 1px solid var(--color-success);
1353+ }
1354+ .apply-conflict {
1355+ background: var(--color-danger-bg);
1356+ color: var(--color-danger);
1357+ border: 1px solid var(--color-danger);
1358+ }
1359+ .apply-checking, .apply-unknown {
1360+ color: var(--color-text-muted);
1361+ font-style: italic;
1362+ font-size: var(--text-sm);
1363+ }
1364+ .apply-output {
1365+ font-family: var(--font-mono);
1366+ font-size: var(--text-xs);
1367+ white-space: pre-wrap;
1368+ margin-top: var(--space-2);
1369+ padding: var(--space-2);
1370+ background: rgba(0,0,0,0.05);
1371+ border-radius: var(--radius-sm);
1372+ width: 100%;
1373+ }
1374+ .patch-actions {
1375+ display: flex;
1376+ gap: var(--space-3);
1377+ margin: var(--space-4) 0;
1378+ flex-wrap: wrap;
1379+ }
1380+ .patch-card-title-row {
1381+ display: flex;
1382+ align-items: flex-start;
1383+ gap: var(--space-3);
1384+ justify-content: space-between;
1385+ margin-bottom: var(--space-3);
1386+ }
1387+ .patch-card-title-row .commit-card-subject { margin-bottom: 0; }
1388+ .patch-card-description {
1389+ margin-top: var(--space-4);
1390+ padding-top: var(--space-4);
1391+ border-top: 1px solid var(--color-border-muted);
1392+ }
1393+
1394+ /* --- Repo home layout --- */
1395+ .repo-home-layout {
1396+ display: grid;
1397+ gap: var(--space-8);
1398+ }
1399+ @media (min-width: 768px) {
1400+ .repo-home-layout {
1401+ grid-template-columns: 1fr 1fr;
1402+ }
1403+ }
1404+ .readme-section {
1405+ border: 1px solid var(--color-border);
1406+ border-radius: var(--radius-lg);
1407+ overflow: hidden;
1408+ }
1409+ .readme-header {
1410+ background: var(--color-bg-subtle);
1411+ padding: var(--space-3) var(--space-4);
1412+ font-size: var(--text-sm);
1413+ font-weight: 500;
1414+ border-bottom: 1px solid var(--color-border);
1415+ }
1416+ .readme-section .markdown-body {
1417+ padding: var(--space-4);
1418+ }
1419+
1420+ /* --- Code setup block --- */
1421+ .code-setup {
1422+ background: var(--color-bg-subtle);
1423+ border: 1px solid var(--color-border);
1424+ border-radius: var(--radius-lg);
1425+ padding: var(--space-4);
1426+ font-size: var(--text-sm);
1427+ margin-top: var(--space-4);
1428+ overflow: auto;
1429+ text-align: left;
1430+ }
1431+ .code-plain {
1432+ background: var(--color-bg-subtle);
1433+ padding: var(--space-4);
1434+ overflow: auto;
1435+ font-size: var(--text-sm);
1436+ line-height: 1.6;
1437+ }
1438+
1439+ /* --- Markdown body --- */
1440+ .markdown-body {
1441+ font-size: var(--text-sm);
1442+ line-height: 1.6;
1443+ color: var(--color-text);
1444+ }
1445+ .markdown-body h1, .markdown-body h2, .markdown-body h3,
1446+ .markdown-body h4, .markdown-body h5, .markdown-body h6 {
1447+ margin-top: var(--space-6);
1448+ margin-bottom: var(--space-3);
1449+ }
1450+ .markdown-body p { margin-bottom: var(--space-4); }
1451+ .markdown-body a { color: var(--color-link); }
1452+ .markdown-body code {
1453+ background: var(--color-bg-inset);
1454+ padding: 0.1em 0.3em;
1455+ border-radius: var(--radius-sm);
1456+ font-size: 0.9em;
1457+ }
1458+ .markdown-body pre {
1459+ background: var(--color-bg-subtle);
1460+ border: 1px solid var(--color-border);
1461+ border-radius: var(--radius-md);
1462+ padding: var(--space-4);
1463+ overflow: auto;
1464+ margin-bottom: var(--space-4);
1465+ }
1466+ .markdown-body pre code { background: none; padding: 0; }
1467+ .markdown-body ul, .markdown-body ol {
1468+ padding-left: var(--space-6);
1469+ margin-bottom: var(--space-4);
1470+ }
1471+ .markdown-body ul { list-style: disc; }
1472+ .markdown-body ol { list-style: decimal; }
1473+ .markdown-body li { margin-bottom: var(--space-1); }
1474+ .markdown-body blockquote {
1475+ border-left: 3px solid var(--color-border);
1476+ padding-left: var(--space-4);
1477+ color: var(--color-text-muted);
1478+ margin: var(--space-4) 0;
1479+ }
1480+ .markdown-body table {
1481+ width: 100%;
1482+ border-collapse: collapse;
1483+ margin-bottom: var(--space-4);
1484+ }
1485+ .markdown-body th, .markdown-body td {
1486+ padding: var(--space-2) var(--space-3);
1487+ border: 1px solid var(--color-border);
1488+ }
1489+ .markdown-body th { background: var(--color-bg-subtle); font-weight: 600; }
1490+ .markdown-body hr {
1491+ border: none;
1492+ border-top: 1px solid var(--color-border);
1493+ margin: var(--space-6) 0;
1494+ }
1495+ .markdown-body img { max-width: 100%; border-radius: var(--radius-md); }
1496+ .markdown-body details { margin-bottom: var(--space-4); }
1497+ .markdown-body summary { cursor: pointer; font-weight: 500; }
1498+
1499+ /* --- Search form --- */
1500+ .search-form { margin-bottom: var(--space-5); }
1501+ .search-input-wrap {
1502+ display: flex;
1503+ gap: var(--space-2);
1504+ max-width: 480px;
1505+ }
1506+ .search-input {
1507+ flex: 1;
1508+ padding: var(--space-2) var(--space-3);
1509+ border: 1px solid var(--color-border);
1510+ border-radius: var(--radius-md);
1511+ background: var(--color-bg);
1512+ color: var(--color-text);
1513+ font-size: var(--text-sm);
1514+ }
1515+ .search-input:focus {
1516+ outline: 2px solid var(--color-accent);
1517+ outline-offset: -1px;
1518+ border-color: var(--color-accent);
1519+ }
1520+
1521+ /* --- Issue close bar --- */
1522+ .issue-close-bar {
1523+ display: flex;
1524+ justify-content: flex-end;
1525+ padding: var(--space-3) 0;
1526+ border-top: 1px solid var(--color-border-muted);
1527+ margin-top: var(--space-2);
1528+ }
1529+
1530+ /* --- Auth divider --- */
1531+ .auth-divider {
1532+ display: flex;
1533+ align-items: center;
1534+ gap: var(--space-3);
1535+ margin: var(--space-4) 0;
1536+ color: var(--color-text-muted);
1537+ font-size: var(--text-sm);
1538+ }
1539+ .auth-divider::before,
1540+ .auth-divider::after {
1541+ content: '';
1542+ flex: 1;
1543+ height: 1px;
1544+ background: var(--color-border);
1545+ }
1546+
1547+ /* --- Passkey UI in settings --- */
1548+ .passkey-actions {
1549+ display: flex;
1550+ flex-direction: column;
1551+ gap: var(--space-3);
1552+ align-items: flex-start;
1553+ margin-top: var(--space-3);
1554+ }
1555+ .passkey-status { font-size: var(--text-sm); }
1556+
1557+ /* --- Danger zone --- */
1558+ .danger-zone { margin-top: var(--space-8); }
1559+ .danger-title { color: var(--color-danger); border-bottom-color: var(--color-danger); }
1560+ .danger-card {
1561+ border-color: var(--color-danger);
1562+ background: var(--color-danger-bg);
1563+ }
1564+ .danger-item {
1565+ display: flex;
1566+ align-items: center;
1567+ justify-content: space-between;
1568+ gap: var(--space-4);
1569+ flex-wrap: wrap;
1570+ }
1571+ .danger-item p { margin: var(--space-1) 0 0; }
1572+ .btn-danger {
1573+ background: var(--color-danger);
1574+ border-color: transparent;
1575+ color: #fff;
1576+ }
1577+ .btn-danger:hover { filter: brightness(0.9); }
1578+
1579+ /* --- Release list items --- */
1580+ .release-item-header {
1581+ display: flex;
1582+ gap: var(--space-3);
1583+ align-items: flex-start;
1584+ }
1585+ .release-item-main { flex: 1; min-width: 0; }
1586+ .release-item-title {
1587+ font-size: var(--text-lg);
1588+ font-weight: 700;
1589+ text-decoration: none;
1590+ color: var(--color-text);
1591+ display: block;
1592+ margin-bottom: var(--space-1);
1593+ }
1594+ .release-item-title:hover { color: var(--color-link); }
1595+ .release-item-meta {
1596+ display: flex;
1597+ flex-wrap: wrap;
1598+ gap: var(--space-3);
1599+ align-items: center;
1600+ font-size: var(--text-xs);
1601+ color: var(--color-text-muted);
1602+ }
1603+ .release-item-date {
1604+ display: flex;
1605+ flex-direction: column;
1606+ align-items: flex-end;
1607+ gap: var(--space-2);
1608+ font-size: var(--text-xs);
1609+ color: var(--color-text-muted);
1610+ white-space: nowrap;
1611+ flex-shrink: 0;
1612+ }
1613+
1614+ /* --- Release notes preview in list --- */
1615+ .release-notes-section { margin-top: var(--space-3); }
1616+ .release-notes-label {
1617+ font-size: var(--text-xs);
1618+ font-weight: 600;
1619+ color: var(--color-text-muted);
1620+ text-transform: uppercase;
1621+ letter-spacing: 0.05em;
1622+ margin-bottom: var(--space-2);
1623+ }
1624+ .notes-expand > summary { list-style: none; cursor: pointer; outline: none; }
1625+ .notes-expand > summary::-webkit-details-marker { display: none; }
1626+ .notes-preview {
1627+ max-height: calc(5 * 1.6em);
1628+ overflow: hidden;
1629+ position: relative;
1630+ white-space: pre-wrap;
1631+ font-size: var(--text-sm);
1632+ color: var(--color-text);
1633+ line-height: 1.6;
1634+ word-break: break-word;
1635+ margin-bottom: var(--space-1);
1636+ }
1637+ .notes-preview::after {
1638+ content: "";
1639+ position: absolute;
1640+ bottom: 0;
1641+ left: 0;
1642+ right: 0;
1643+ height: 2.5em;
1644+ background: linear-gradient(transparent, var(--color-bg));
1645+ pointer-events: none;
1646+ }
1647+ .notes-expand[open] .notes-preview { display: none; }
1648+ .notes-expand[open] { display: flex; flex-direction: column; }
1649+ .notes-expand[open] > summary { order: 1; }
1650+ .notes-expand[open] > .notes-full { order: 0; }
1651+ .notes-toggle-label {
1652+ display: inline-block;
1653+ font-size: var(--text-xs);
1654+ color: var(--color-link);
1655+ margin-top: var(--space-1);
1656+ }
1657+ .notes-toggle-label:hover { text-decoration: underline; }
1658+ .notes-toggle-label::before { content: "Show more ↓"; }
1659+ .notes-expand[open] .notes-toggle-label::before { content: "Show less ↑"; }
1660+ .notes-full { padding-top: var(--space-3); padding-bottom: var(--space-1); }
1661+
1662+ /* --- Release detail --- */
1663+ .release-detail { padding: var(--space-6) 0; }
1664+ .release-header { margin-bottom: var(--space-6); }
1665+ .release-notes { margin-bottom: var(--space-6); }
1666+ .release-assets { margin-bottom: var(--space-6); }
1667+ .release-assets-heading {
1668+ font-size: var(--text-base);
1669+ font-weight: 600;
1670+ margin-bottom: var(--space-3);
1671+ }
1672+ .asset-list {
1673+ border: 1px solid var(--color-border);
1674+ border-radius: var(--radius-lg);
1675+ overflow: hidden;
1676+ }
1677+ .asset-item {
1678+ display: flex;
1679+ align-items: center;
1680+ gap: var(--space-4);
1681+ padding: var(--space-3) var(--space-4);
1682+ border-bottom: 1px solid var(--color-border-muted);
1683+ font-size: var(--text-sm);
1684+ }
1685+ .asset-item:last-child { border-bottom: none; }
1686+ .asset-name {
1687+ flex: 1;
1688+ color: var(--color-link);
1689+ text-decoration: none;
1690+ font-family: var(--font-mono);
1691+ font-size: var(--text-xs);
1692+ }
1693+ .asset-name:hover { text-decoration: underline; }
1694+ .asset-name-pending {
1695+ color: var(--color-text-muted);
1696+ cursor: default;
1697+ }
1698+ .asset-item-pending { opacity: 0.6; }
1699+ .asset-size, .asset-meta {
1700+ color: var(--color-text-muted);
1701+ font-size: var(--text-xs);
1702+ white-space: nowrap;
1703+ }
1704+ .release-back { margin-top: var(--space-4); display: flex; gap: var(--space-3); align-items: center; justify-content: space-between; }
1705+}
1706+
1707+/* ============================================================
1708+ @layer utilities
1709+ ============================================================ */
1710+@layer utilities {
1711+ .text-muted { color: var(--color-text-muted); }
1712+ .text-sm { font-size: var(--text-sm); }
1713+ .text-xs { font-size: var(--text-xs); }
1714+ .font-mono { font-family: var(--font-mono); }
1715+ .mt-1 { margin-top: var(--space-1); }
1716+ .mt-2 { margin-top: var(--space-2); }
1717+ .mt-4 { margin-top: var(--space-4); }
1718+ .mt-6 { margin-top: var(--space-6); }
1719+ .mb-4 { margin-bottom: var(--space-4); }
1720+ .flex { display: flex; }
1721+ .gap-2 { gap: var(--space-2); }
1722+ .gap-4 { gap: var(--space-4); }
1723+ .items-center { align-items: center; }
1724+ .justify-between { justify-content: space-between; }
1725+ .hidden { display: none; }
1726+}
1727+
1728+/* ============================================================
1729+ Pagination
1730+ ============================================================ */
1731+.commit-cursor-nav {
1732+ display: flex;
1733+ justify-content: space-between;
1734+ border-top: 1px solid var(--color-border);
1735+ margin-top: var(--space-2);
1736+ padding: var(--space-4) 0 var(--space-2);
1737+}
1738+
1739+.commit-cursor-prev { flex: 1; }
1740+.commit-cursor-next { flex: 1; text-align: right; }
1741+
1742+.pagination {
1743+ display: grid;
1744+ grid-template-columns: 1fr auto 1fr;
1745+ align-items: center;
1746+ border-top: 1px solid var(--color-border);
1747+ margin-top: var(--space-2);
1748+ padding: var(--space-4) 0 var(--space-2);
1749+}
1750+
1751+.pagination-prev { justify-self: start; }
1752+.pagination-next { justify-self: end; }
1753+
1754+.pagination-info {
1755+ justify-self: center;
1756+ font-size: var(--text-sm);
1757+ color: var(--color-text-muted);
1758+}
1759+
1760+.pagination-btn {
1761+ display: inline-flex;
1762+ align-items: center;
1763+ gap: var(--space-1);
1764+ padding: var(--space-1) var(--space-3);
1765+ border: 1px solid var(--color-border);
1766+ border-radius: var(--radius-md);
1767+ font-size: var(--text-sm);
1768+ font-weight: 500;
1769+ text-decoration: none;
1770+ color: var(--color-text);
1771+ background: var(--color-bg-subtle);
1772+ transition: background 0.1s, border-color 0.1s;
1773+ white-space: nowrap;
1774+}
1775+
1776+.pagination-btn:hover {
1777+ background: var(--color-bg-inset);
1778+ border-color: var(--color-border);
1779+ color: var(--color-text);
1780+ text-decoration: none;
1781+}
1782+
1783+ /* Settings */
1784+ .theme-options { display: flex; gap: var(--space-4); margin-bottom: var(--space-4); flex-wrap: wrap; }
1785+ .theme-option { display: flex; align-items: center; gap: var(--space-2); cursor: pointer; font-size: var(--text-sm); }
1786+ .settings-form { display: flex; flex-direction: column; gap: var(--space-4); }
1787+ .passkey-list { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-4); }
1788+ .passkey-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border-muted); font-size: var(--text-sm); }
1789+ .passkey-item:last-child { border-bottom: none; }
1790+ .passkey-date { flex: 1; color: var(--color-text-muted); }
1791+ .ssh-key-info { flex: 1; display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
1792+ .ssh-key-name { font-weight: 500; }
1793+ .ssh-key-fingerprint { font-family: var(--font-mono); font-size: var(--text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1794+ .form-textarea { resize: vertical; min-height: 4.5rem; font-family: var(--font-mono); font-size: var(--text-sm); }
1795+ /* Clone popup */
1796+ .clone-popup-details { position: relative; display: inline-block; }
1797+ .clone-popup-details > summary { list-style: none; }
1798+ .clone-popup-details > summary::-webkit-details-marker { display: none; }
1799+ .clone-popup { position: absolute; right: 1rem; top: calc(100% + var(--space-2)); background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-3); min-width: 22rem; z-index: 100; box-shadow: 0 4px 20px rgba(0,0,0,0.15); display: flex; flex-direction: column; gap: var(--space-2); }
1800+ .clone-url-row { display: flex; align-items: center; gap: var(--space-2); }
1801+ .clone-url-label { font-size: var(--text-xs); font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; min-width: 2.75rem; }
1802+ .clone-url-input { flex: 1; font-family: var(--font-mono); font-size: var(--text-sm); background: var(--color-bg-subtle); border: 1px solid var(--color-border-muted); border-radius: var(--radius-md); padding: var(--space-1) var(--space-2); color: var(--color-text); cursor: text; min-width: 0; }
1803+ .user-list { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-6); }
1804+ .user-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border-muted); font-size: var(--text-sm); }
1805+ .user-item:last-child { border-bottom: none; }
1806+ .user-name { font-weight: 500; flex: 1; }
1807+ .user-date { color: var(--color-text-muted); font-size: var(--text-xs); }
Apublic/assets/theme.js
@@ -0,0 +1 @@
1+(function(){try{var m=document.cookie.match(/(?:^|;)\s*theme=([^;]+)/);var t=m&&m[1];if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t)}catch(e){}})()
Asrc/config.ts
@@ -0,0 +1,20 @@
1+import path from "node:path";
2+
3+//env configs
4+export const OWNER_DISPLAY_NAME = process.env.OWNER_DISPLAY_NAME ?? "Admin";
5+export const INLINE_MAX_BYTES =
6+ parseInt(process.env.INLINE_MAX_BYTES ?? "", 10) || 524288;
7+export const MAX_UPLOAD_BYTES =
8+ parseInt(process.env.MAX_UPLOAD_BYTES ?? "", 10) || 10 * 1024 * 1024;
9+export const MAX_USER_UPLOAD_BYTES =
10+ parseInt(process.env.MAX_USER_UPLOAD_BYTES ?? "", 10) || 2 * 1024 * 1024;
11+export const TRUSTED_PROXY = !!process.env.TRUSTED_PROXY;
12+export const RATE_LIMIT_DISABLED = !!process.env.RATE_LIMIT_DISABLED;
13+export const SSH_DISABLED = !!process.env.SSH_DISABLED;
14+export const PORT = parseInt(process.env.PORT ?? "", 10) || 3000;
15+export const SSH_PORT = parseInt(process.env.SSH_PORT ?? "", 10) || 2222;
16+export const REGISTRATION_DISABLED = !!process.env.REGISTRATION_DISABLED;
17+export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
18+export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data");
19+export const HIGHLIGHT_WORKERS =
20+ parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4;
Asrc/constants.ts
@@ -0,0 +1,47 @@
1+import path from "node:path";
2+import { DATA_DIR, OWNER_DISPLAY_NAME } from "./config.ts";
3+
4+// Auth / identity
5+export const WEBAUTHN_RP_NAME = `${OWNER_DISPLAY_NAME}'s Hearthforge`;
6+export const ADMIN_USERNAME = "admin";
7+export const VALID_USERNAME_RE = /^[a-zA-Z0-9_-]+$/;
8+export const VALID_REPO_NAME_RE = /^[a-zA-Z0-9._-]+$/;
9+export const ALLOWED_REACTIONS = new Set([
10+ "👍",
11+ "👎",
12+ "❤️",
13+ "🎉",
14+ "😕",
15+ "👀",
16+ "🚀",
17+]);
18+export const VALID_KEY_TYPES = new Set([
19+ "ssh-rsa",
20+ "ssh-ed25519",
21+ "ecdsa-sha2-nistp256",
22+ "ecdsa-sha2-nistp384",
23+ "ecdsa-sha2-nistp521",
24+ "sk-ssh-ed25519@openssh.com",
25+ "sk-ecdsa-sha2-nistp256@openssh.com",
26+]);
27+export const CHALLENGE_TTL_MS = 5 * 60 * 1000;
28+
29+// Pagination
30+export const REPOS_PER_PAGE = 20;
31+export const COMMITS_PER_PAGE = 30;
32+export const ISSUES_PER_PAGE = 20;
33+export const PATCHES_PER_PAGE = 20;
34+export const RELEASES_PER_PAGE = 20;
35+
36+// Cache sizes
37+export const MAX_MD_CACHE = 50;
38+export const MAX_FILE_CACHE = 500;
39+export const MAX_DIFF_CACHE = 500;
40+export const MAX_PATCH_CACHE = 100;
41+
42+// Paths
43+export const DB_PATH = path.join(DATA_DIR, "hearthforge.db");
44+export const REPOS_DIR = path.join(DATA_DIR, "repos");
45+export const AVATARS_DIR = path.join(DATA_DIR, "avatars");
46+export const RELEASES_DIR = path.join(DATA_DIR, "releases");
47+export const SSH_HOST_KEY_PATH = path.join(DATA_DIR, "ssh_host_key");
Asrc/db/index.ts
@@ -0,0 +1,180 @@
1+import { Database as BunDatabase } from "bun:sqlite";
2+import { type Generated, Kysely, type Selectable } from "kysely";
3+import { BunSqliteDialect } from "kysely-bun-sqlite";
4+
5+import { DB_PATH } from "../constants.ts";
6+
7+interface UserTable {
8+ id: Generated<number>;
9+ username: string;
10+ password_hash: string | null;
11+ created_at: string;
12+ avatar_version: Generated<number>;
13+}
14+
15+interface PasskeyTable {
16+ id: Generated<number>;
17+ user_id: number;
18+ credential_id: string;
19+ public_key: string;
20+ counter: number;
21+ created_at: string;
22+}
23+
24+interface SessionTable {
25+ id: string;
26+ user_id: number;
27+ expires_at: string;
28+ created_at: string;
29+}
30+
31+interface RepositoryTable {
32+ id: Generated<number>;
33+ name: string;
34+ description: string | null;
35+ is_private: number;
36+ default_branch: string;
37+ created_at: string;
38+ issue_seq: Generated<number>;
39+ patch_seq: Generated<number>;
40+}
41+
42+interface IssueTable {
43+ id: Generated<number>;
44+ repo_id: number;
45+ author_id: number | null;
46+ number: number;
47+ title: string;
48+ body: string;
49+ status: string;
50+ created_at: string;
51+ updated_at: string;
52+ edited_at: string | null;
53+}
54+
55+interface IssueCommentTable {
56+ id: Generated<number>;
57+ issue_id: number;
58+ author_id: number | null;
59+ body: string;
60+ created_at: string;
61+ edited_at: string | null;
62+}
63+
64+interface IssueReactionTable {
65+ id: Generated<number>;
66+ issue_id: number;
67+ comment_id: number | null;
68+ user_id: number;
69+ emoji: string;
70+}
71+
72+interface PatchTable {
73+ id: Generated<number>;
74+ repo_id: number;
75+ author_id: number | null;
76+ number: number;
77+ title: string;
78+ description: string;
79+ patch_content: string;
80+ status: string;
81+ created_at: string;
82+ updated_at: string;
83+ edited_at: string | null;
84+}
85+
86+interface PatchCommentTable {
87+ id: Generated<number>;
88+ patch_id: number;
89+ author_id: number | null;
90+ body: string;
91+ created_at: string;
92+ edited_at: string | null;
93+}
94+
95+interface PatchReactionTable {
96+ id: Generated<number>;
97+ patch_id: number;
98+ comment_id: number | null;
99+ user_id: number;
100+ emoji: string;
101+}
102+
103+interface SshKeyTable {
104+ id: Generated<number>;
105+ user_id: number;
106+ name: string;
107+ public_key: string;
108+ fingerprint: string;
109+ created_at: string;
110+}
111+
112+interface ReleaseTable {
113+ id: Generated<number>;
114+ repo_id: number;
115+ tag_name: string;
116+ name: string | null;
117+ notes: string | null;
118+ commit_hash: string;
119+ include_source_code: number;
120+ created_at: string;
121+}
122+
123+interface ReleaseAssetTable {
124+ id: Generated<number>;
125+ release_id: number;
126+ filename: string;
127+ size: number;
128+ content_type: string;
129+ created_at: string;
130+}
131+
132+export interface Database {
133+ users: UserTable;
134+ passkeys: PasskeyTable;
135+ sessions: SessionTable;
136+ repositories: RepositoryTable;
137+ issues: IssueTable;
138+ issue_comments: IssueCommentTable;
139+ issue_reactions: IssueReactionTable;
140+ patches: PatchTable;
141+ patch_comments: PatchCommentTable;
142+ patch_reactions: PatchReactionTable;
143+ ssh_keys: SshKeyTable;
144+ releases: ReleaseTable;
145+ release_assets: ReleaseAssetTable;
146+}
147+
148+// Selectable row types (id is plain number, as returned by queries)
149+export type UserRow = Selectable<UserTable>;
150+export type PasskeyRow = Selectable<PasskeyTable>;
151+export type SessionRow = Selectable<SessionTable>;
152+export type RepositoryRow = Selectable<RepositoryTable>;
153+export type IssueRow = Selectable<IssueTable>;
154+export type IssueCommentRow = Selectable<IssueCommentTable>;
155+export type IssueReactionRow = Selectable<IssueReactionTable>;
156+export type PatchRow = Selectable<PatchTable>;
157+export type PatchCommentRow = Selectable<PatchCommentTable>;
158+export type PatchReactionRow = Selectable<PatchReactionTable>;
159+export type SshKeyRow = Selectable<SshKeyTable>;
160+export type ReleaseRow = Selectable<ReleaseTable>;
161+export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
162+
163+const sqlite = new BunDatabase(DB_PATH);
164+sqlite.run("PRAGMA journal_mode=WAL");
165+sqlite.run("PRAGMA foreign_keys=ON");
166+
167+export const db = new Kysely<Database>({
168+ dialect: new BunSqliteDialect({ database: sqlite }),
169+});
170+
171+export async function getRepo(name: string, isAdmin: boolean) {
172+ const repo = await db
173+ .selectFrom("repositories")
174+ .selectAll()
175+ .where("name", "=", name)
176+ .executeTakeFirst();
177+ if (!repo) return null;
178+ if (repo.is_private && !isAdmin) return null;
179+ return repo;
180+}
Asrc/db/init.ts
@@ -0,0 +1,51 @@
1+import { Database } from "bun:sqlite";
2+import { mkdirSync, readFileSync } from "node:fs";
3+import path from "node:path";
4+import * as argon2 from "argon2";
5+import { DATA_DIR } from "../config.ts";
6+import {
7+ ADMIN_USERNAME,
8+ AVATARS_DIR,
9+ DB_PATH,
10+ REPOS_DIR,
11+} from "../constants.ts";
12+
13+// Ensure data directories exist
14+mkdirSync(DATA_DIR, { recursive: true });
15+mkdirSync(REPOS_DIR, { recursive: true });
16+mkdirSync(AVATARS_DIR, { recursive: true });
17+
18+const db = new Database(DB_PATH);
19+db.run("PRAGMA journal_mode=WAL");
20+db.run("PRAGMA foreign_keys=ON");
21+
22+// Run schema
23+const schema = readFileSync(path.join(import.meta.dir, "schema.sql"), "utf-8");
24+db.run(schema);
25+
26+// Seed admin account if not present
27+const existing = db
28+ .query("SELECT id FROM users WHERE username = ?")
29+ .get(ADMIN_USERNAME);
30+if (!existing) {
31+ const password = process.env.ADMIN_PASSWORD ?? "changeme";
32+ const hash = await argon2.hash(password);
33+ const now = new Date().toISOString();
34+ db.run(
35+ "INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)",
36+ [ADMIN_USERNAME, hash, now],
37+ );
38+ console.log(
39+ `Created admin account (username: ${ADMIN_USERNAME}, password: ${password})`,
40+ );
41+ if (password === "changeme") {
42+ console.warn(
43+ "WARNING: Using default admin password. Set ADMIN_PASSWORD env var before running db:init.",
44+ );
45+ }
46+} else {
47+ console.log("Admin account already exists.");
48+}
49+
50+console.log("Database initialized at", DB_PATH);
51+db.close();
Asrc/db/schema.sql
@@ -0,0 +1,129 @@
1+CREATE TABLE IF NOT EXISTS users (
2+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3+ username TEXT UNIQUE NOT NULL,
4+ password_hash TEXT,
5+ created_at TEXT NOT NULL,
6+ avatar_version INTEGER NOT NULL DEFAULT 1
7+);
8+
9+CREATE TABLE IF NOT EXISTS passkeys (
10+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
12+ credential_id TEXT UNIQUE NOT NULL,
13+ public_key TEXT NOT NULL,
14+ counter INTEGER NOT NULL DEFAULT 0,
15+ created_at TEXT NOT NULL
16+);
17+
18+CREATE TABLE IF NOT EXISTS sessions (
19+ id TEXT PRIMARY KEY,
20+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
21+ expires_at TEXT NOT NULL,
22+ created_at TEXT NOT NULL
23+);
24+
25+CREATE TABLE IF NOT EXISTS repositories (
26+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27+ name TEXT UNIQUE NOT NULL,
28+ description TEXT,
29+ is_private INTEGER NOT NULL DEFAULT 0,
30+ default_branch TEXT NOT NULL DEFAULT 'main',
31+ created_at TEXT NOT NULL,
32+ issue_seq INTEGER NOT NULL DEFAULT 0,
33+ patch_seq INTEGER NOT NULL DEFAULT 0
34+);
35+
36+CREATE TABLE IF NOT EXISTS issues (
37+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38+ repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
39+ author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
40+ number INTEGER NOT NULL,
41+ title TEXT NOT NULL,
42+ body TEXT NOT NULL DEFAULT '',
43+ status TEXT NOT NULL DEFAULT 'open',
44+ created_at TEXT NOT NULL,
45+ updated_at TEXT NOT NULL,
46+ edited_at TEXT,
47+ UNIQUE(repo_id, number)
48+);
49+
50+CREATE TABLE IF NOT EXISTS issue_comments (
51+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52+ issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
53+ author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
54+ body TEXT NOT NULL,
55+ created_at TEXT NOT NULL,
56+ edited_at TEXT
57+);
58+
59+CREATE TABLE IF NOT EXISTS issue_reactions (
60+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61+ issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
62+ comment_id INTEGER REFERENCES issue_comments(id) ON DELETE CASCADE,
63+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
64+ emoji TEXT NOT NULL,
65+ UNIQUE(issue_id, comment_id, user_id)
66+);
67+
68+CREATE TABLE IF NOT EXISTS patches (
69+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70+ repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
71+ author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
72+ number INTEGER NOT NULL,
73+ title TEXT NOT NULL,
74+ description TEXT NOT NULL DEFAULT '',
75+ patch_content TEXT NOT NULL,
76+ status TEXT NOT NULL DEFAULT 'open',
77+ created_at TEXT NOT NULL,
78+ updated_at TEXT NOT NULL,
79+ edited_at TEXT,
80+ UNIQUE(repo_id, number)
81+);
82+
83+CREATE TABLE IF NOT EXISTS patch_comments (
84+ id INTEGER PRIMARY KEY AUTOINCREMENT,
85+ patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
86+ author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
87+ body TEXT NOT NULL,
88+ created_at TEXT NOT NULL,
89+ edited_at TEXT
90+);
91+
92+CREATE TABLE IF NOT EXISTS patch_reactions (
93+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94+ patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
95+ comment_id INTEGER REFERENCES patch_comments(id) ON DELETE CASCADE,
96+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
97+ emoji TEXT NOT NULL,
98+ UNIQUE(patch_id, comment_id, user_id)
99+);
100+
101+CREATE TABLE IF NOT EXISTS ssh_keys (
102+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
104+ name TEXT NOT NULL,
105+ public_key TEXT NOT NULL,
106+ fingerprint TEXT NOT NULL UNIQUE,
107+ created_at TEXT NOT NULL
108+);
109+
110+CREATE TABLE IF NOT EXISTS releases (
111+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112+ repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
113+ tag_name TEXT NOT NULL,
114+ name TEXT,
115+ notes TEXT,
116+ commit_hash TEXT NOT NULL,
117+ include_source_code INTEGER NOT NULL DEFAULT 0,
118+ created_at TEXT NOT NULL,
119+ UNIQUE(repo_id, tag_name)
120+);
121+
122+CREATE TABLE IF NOT EXISTS release_assets (
123+ id INTEGER PRIMARY KEY AUTOINCREMENT,
124+ release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
125+ filename TEXT NOT NULL,
126+ size INTEGER NOT NULL,
127+ content_type TEXT NOT NULL,
128+ created_at TEXT NOT NULL
129+);
Asrc/db/seed.ts
@@ -0,0 +1,256 @@
1+/**
2+ * Seed script — populates the live database with enough data to test pagination.
3+ * Run with: bun run db:seed
4+ *
5+ * Safe to run multiple times — skips existing items.
6+ */
7+import { Database } from "bun:sqlite";
8+import { mkdirSync } from "node:fs";
9+import path from "node:path";
10+import * as argon2 from "argon2";
11+import { ADMIN_USERNAME, DB_PATH, REPOS_DIR } from "../constants.ts";
12+
13+const db = new Database(DB_PATH);
14+db.run("PRAGMA journal_mode=WAL");
15+db.run("PRAGMA foreign_keys=ON");
16+
17+// ── Resolve admin user ────────────────────────────────────────────────────────
18+const adminRow = db
19+ .query<{ id: number }, [string]>("SELECT id FROM users WHERE username = ?")
20+ .get(ADMIN_USERNAME);
21+if (!adminRow) {
22+ console.error("Admin user not found. Run `bun run db:init` first.");
23+ process.exit(1);
24+}
25+const adminId = adminRow.id;
26+
27+// Ensure a second user "alice" exists for variety
28+let aliceId: number;
29+const aliceRow = db
30+ .query<{ id: number }, [string]>("SELECT id FROM users WHERE username = ?")
31+ .get("alice");
32+if (aliceRow) {
33+ aliceId = aliceRow.id;
34+} else {
35+ const hash = await argon2.hash("password123");
36+ const now = new Date().toISOString();
37+ db.run(
38+ "INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)",
39+ ["alice", hash, now],
40+ );
41+ aliceId = db
42+ .query<{ id: number }, [string]>(
43+ "SELECT id FROM users WHERE username = ?",
44+ )
45+ .get("alice")!.id;
46+ console.log("Created user alice (password: password123)");
47+}
48+
49+// ── Helper ────────────────────────────────────────────────────────────────────
50+function getOrCreateRepo(name: string, description: string): number {
51+ const existing = db
52+ .query<{ id: number }, [string]>(
53+ "SELECT id FROM repositories WHERE name = ?",
54+ )
55+ .get(name);
56+ if (existing) return existing.id;
57+
58+ const now = new Date().toISOString();
59+ db.run(
60+ "INSERT INTO repositories (name, description, is_private, default_branch, created_at) VALUES (?, ?, 0, 'main', ?)",
61+ [name, description, now],
62+ );
63+ const repoPath = path.join(REPOS_DIR, `${name}.git`);
64+ mkdirSync(repoPath, { recursive: true });
65+ // init bare repo (sync-ish via Bun.spawnSync)
66+ Bun.spawnSync(["git", "init", "--bare", "--initial-branch=main", repoPath]);
67+ console.log(`Created repo: ${name}`);
68+ return db
69+ .query<{ id: number }, [string]>(
70+ "SELECT id FROM repositories WHERE name = ?",
71+ )
72+ .get(name)!.id;
73+}
74+
75+// ── 25 extra repositories (for repo list pagination) ─────────────────────────
76+const topics = [
77+ "A web framework",
78+ "CLI toolkit",
79+ "Database driver",
80+ "Auth library",
81+ "Test runner",
82+ "Build system",
83+ "Linter plugin",
84+ "ORM layer",
85+ "Cache client",
86+ "Queue worker",
87+ "API gateway",
88+ "Graph engine",
89+ "ML utilities",
90+ "Crypto helpers",
91+ "File watcher",
92+ "Schema validator",
93+ "Logger library",
94+ "Rate limiter",
95+ "Metrics exporter",
96+ "Job scheduler",
97+ "Config manager",
98+ "Template engine",
99+ "Markdown parser",
100+ "Image resizer",
101+ "Email sender",
102+];
103+
104+for (let i = 1; i <= 25; i++) {
105+ const n = String(i).padStart(2, "0");
106+ getOrCreateRepo(`seed-repo-${n}`, topics[i - 1]!);
107+}
108+
109+// ── "demo" repo — seed issues and patches ────────────────────────────────────
110+const demoRepoId = getOrCreateRepo(
111+ "demo",
112+ "Demo repository for pagination testing",
113+);
114+
115+// Seed 35 issues (mix of open and closed)
116+const issueCount =
117+ db
118+ .query<{ n: number }, [number]>(
119+ "SELECT COUNT(*) as n FROM issues WHERE repo_id = ?",
120+ )
121+ .get(demoRepoId)?.n ?? 0;
122+if (issueCount < 35) {
123+ const start = issueCount + 1;
124+ const issueTitles = [
125+ "Fix null pointer dereference in parser",
126+ "Add dark mode support",
127+ "Improve error messages",
128+ "Upgrade dependencies to latest",
129+ "Memory leak in connection pool",
130+ "Race condition in event loop",
131+ "Add pagination to commit log",
132+ "Slow query on large datasets",
133+ "Missing CORS headers",
134+ "Typo in README",
135+ "Refactor authentication middleware",
136+ "Support IPv6 addresses",
137+ "Add unit tests for utils",
138+ "Broken link in documentation",
139+ "Config file not loaded on Windows",
140+ "Infinite loop when input is empty",
141+ "Add rate limiting",
142+ "Log rotation broken",
143+ "Session cookie not cleared on logout",
144+ "Crash on malformed JSON input",
145+ "Add export to CSV feature",
146+ "Support custom themes",
147+ "API returns 500 on edge case",
148+ "Update license to MIT",
149+ "Improve startup time",
150+ "Handle timeout errors gracefully",
151+ "Add health check endpoint",
152+ "Support environment variables in config",
153+ "Fix XSS in search",
154+ "Stale cache after update",
155+ "Add OpenAPI spec",
156+ "Sort order wrong in list view",
157+ "Binary file detection false positive",
158+ "Column alignment off in table view",
159+ "Wrong timezone in timestamps",
160+ ];
161+
162+ for (let i = start; i <= 35; i++) {
163+ const titleIndex = (i - 1) % issueTitles.length;
164+ const status = i <= 25 ? "open" : "closed";
165+ const authorId = i % 3 === 0 ? aliceId : adminId;
166+ const now = new Date(Date.now() - (35 - i) * 3600_000).toISOString();
167+ db.run(
168+ "INSERT OR IGNORE INTO issues (repo_id, author_id, number, title, body, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
169+ [
170+ demoRepoId,
171+ authorId,
172+ i,
173+ issueTitles[titleIndex]!,
174+ `Details for issue #${i}.`,
175+ status,
176+ now,
177+ now,
178+ ],
179+ );
180+ }
181+ console.log(`Seeded issues up to #35 in demo repo`);
182+}
183+
184+// Seed 25 patches
185+const patchCount =
186+ db
187+ .query<{ n: number }, [number]>(
188+ "SELECT COUNT(*) as n FROM patches WHERE repo_id = ?",
189+ )
190+ .get(demoRepoId)?.n ?? 0;
191+if (patchCount < 25) {
192+ const start = patchCount + 1;
193+ const SAMPLE_PATCH = [
194+ "diff --git a/placeholder.txt b/placeholder.txt",
195+ "new file mode 100644",
196+ "index 0000000..e69de29",
197+ "--- /dev/null",
198+ "+++ b/placeholder.txt",
199+ "@@ -0,0 +1 @@",
200+ "+placeholder",
201+ "",
202+ ].join("\n");
203+
204+ const patchTitles = [
205+ "Fix null deref",
206+ "Add dark mode",
207+ "Improve errors",
208+ "Upgrade deps",
209+ "Fix memory leak",
210+ "Fix race condition",
211+ "Add pagination",
212+ "Optimise query",
213+ "Add CORS headers",
214+ "Fix typo",
215+ "Refactor auth",
216+ "Support IPv6",
217+ "Add unit tests",
218+ "Fix broken link",
219+ "Fix Windows config",
220+ "Fix infinite loop",
221+ "Add rate limiting",
222+ "Fix log rotation",
223+ "Fix logout cookie",
224+ "Handle bad JSON",
225+ "Add CSV export",
226+ "Custom themes",
227+ "Fix 500 error",
228+ "Update license",
229+ "Improve startup",
230+ ];
231+
232+ for (let i = start; i <= 25; i++) {
233+ const titleIndex = (i - 1) % patchTitles.length;
234+ const status = i <= 20 ? "open" : "closed";
235+ const authorId = i % 3 === 0 ? aliceId : adminId;
236+ const now = new Date(Date.now() - (25 - i) * 3600_000).toISOString();
237+ db.run(
238+ "INSERT OR IGNORE INTO patches (repo_id, author_id, number, title, description, patch_content, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
239+ [
240+ demoRepoId,
241+ authorId,
242+ i,
243+ patchTitles[titleIndex]!,
244+ `Description for patch #${i}.`,
245+ SAMPLE_PATCH,
246+ status,
247+ now,
248+ now,
249+ ],
250+ );
251+ }
252+ console.log(`Seeded patches up to #25 in demo repo`);
253+}
254+
255+db.close();
256+console.log("Seed complete.");
Asrc/index.tsx
@@ -0,0 +1,44 @@
1+import path from "node:path";
2+import { staticPlugin } from "@elysiajs/static";
3+import { Elysia } from "elysia";
4+import { MAX_UPLOAD_BYTES, PORT, SSH_DISABLED } from "./config.ts";
5+import { authRoutes } from "./routes/auth.tsx";
6+import { avatarRoutes } from "./routes/avatars.ts";
7+import { gitRoutes } from "./routes/git.ts";
8+import { issueRoutes } from "./routes/issues.tsx";
9+import { patchRoutes } from "./routes/patches.tsx";
10+import { releasesRoutes } from "./routes/releases.tsx";
11+import { repoRoutes } from "./routes/repos.tsx";
12+import { settingsRoutes } from "./routes/settings.tsx";
13+import { syncStartup } from "./services/repoSync.ts";
14+import { startSshServer } from "./services/sshServer.ts";
15+
16+for (const cmd of ["git", "ssh-keygen"]) {
17+ const result = Bun.spawnSync(["which", cmd]);
18+ if (result.exitCode !== 0) {
19+ console.error(`Missing required command: ${cmd}`);
20+ process.exit(1);
21+ }
22+}
23+
24+await syncStartup();
25+if (!SSH_DISABLED) await startSshServer();
26+
27+const _app = new Elysia({ serve: { maxRequestBodySize: MAX_UPLOAD_BYTES } })
28+ .use(
29+ staticPlugin({
30+ assets: path.resolve("./public"),
31+ prefix: "/",
32+ }),
33+ )
34+ .use(gitRoutes)
35+ .use(settingsRoutes)
36+ .use(authRoutes)
37+ .use(repoRoutes)
38+ .use(issueRoutes)
39+ .use(patchRoutes)
40+ .use(releasesRoutes)
41+ .use(avatarRoutes)
42+ .listen(PORT);
43+
44+console.log(`Hearthforge running at http://localhost:${PORT}`);
Asrc/lib/rateLimiter.ts
@@ -0,0 +1,38 @@
1+import { RATE_LIMIT_DISABLED, TRUSTED_PROXY } from "../config.ts";
2+
3+interface Bucket {
4+ count: number;
5+ resetAt: number;
6+}
7+
8+const buckets = new Map<string, Bucket>();
9+
10+export function checkRateLimit(
11+ ip: string | null,
12+ maxRequests: number,
13+ windowMs: number,
14+): boolean {
15+ if (RATE_LIMIT_DISABLED || !ip) return true;
16+ const now = Date.now();
17+ const bucket = buckets.get(ip);
18+ if (!bucket || now > bucket.resetAt) {
19+ buckets.set(ip, { count: 1, resetAt: now + windowMs });
20+ return true;
21+ }
22+ if (bucket.count >= maxRequests) return false;
23+ bucket.count++;
24+ return true;
25+}
26+
27+export function getClientIp(
28+ request: Request,
29+ server: Bun.Server<unknown> | null,
30+): string | null {
31+ if (TRUSTED_PROXY) {
32+ return (
33+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
34+ null
35+ );
36+ }
37+ return server?.requestIP(request)?.address ?? null;
38+}
Asrc/lib/redirect.ts
@@ -0,0 +1,5 @@
1+export function redirect(location: string, setCookie?: string): Response {
2+ const headers: Record<string, string> = { Location: location };
3+ if (setCookie) headers["Set-Cookie"] = setCookie;
4+ return new Response(null, { status: 302, headers });
5+}
Asrc/lib/users.ts
@@ -0,0 +1,8 @@
1+import { OWNER_DISPLAY_NAME } from "../config.ts";
2+import { ADMIN_USERNAME } from "../constants.ts";
3+
4+/** Show OWNER_DISPLAY_NAME for the admin account, [Deleted user] for null, otherwise the raw username. */
5+export function displayName(username: string | null | undefined): string {
6+ if (!username) return "[Deleted user]";
7+ return username === ADMIN_USERNAME ? OWNER_DISPLAY_NAME : username;
8+}
Asrc/middleware/session.ts
@@ -0,0 +1,61 @@
1+import { ADMIN_USERNAME } from "../constants.ts";
2+import { db } from "../db";
3+
4+export interface SessionUser {
5+ id: number;
6+ username: string;
7+ isAdmin: boolean;
8+ avatar_version: number;
9+}
10+
11+export async function resolveSession(
12+ cookie: string | undefined,
13+): Promise<SessionUser | null> {
14+ if (!cookie) return null;
15+ const now = new Date().toISOString();
16+ const session = await db
17+ .selectFrom("sessions")
18+ .innerJoin("users", "users.id", "sessions.user_id")
19+ .select([
20+ "users.id",
21+ "users.username",
22+ "users.avatar_version",
23+ "sessions.expires_at",
24+ ])
25+ .where("sessions.id", "=", cookie)
26+ .where("sessions.expires_at", ">", now)
27+ .executeTakeFirst();
28+ if (!session) return null;
29+ return {
30+ id: session.id,
31+ username: session.username,
32+ isAdmin: session.username === ADMIN_USERNAME,
33+ avatar_version: session.avatar_version,
34+ };
35+}
36+
37+export function requireAuth(user: SessionUser | null): Response | null {
38+ if (!user) {
39+ return new Response(null, {
40+ status: 302,
41+ headers: { Location: "/login" },
42+ });
43+ }
44+ return null;
45+}
46+
47+export function requireAdmin(user: SessionUser | null): Response | null {
48+ if (!user) {
49+ return new Response(null, {
50+ status: 302,
51+ headers: { Location: "/login" },
52+ });
53+ }
54+ if (!user.isAdmin) {
55+ return new Response("Forbidden", {
56+ status: 403,
57+ headers: { "Content-Type": "text/plain; charset=utf-8" },
58+ });
59+ }
60+ return null;
61+}
Asrc/routes/auth.tsx
@@ -0,0 +1,513 @@
1+import {
2+ generateAuthenticationOptions,
3+ generateRegistrationOptions,
4+ verifyAuthenticationResponse,
5+ verifyRegistrationResponse,
6+} from "@simplewebauthn/server";
7+import * as argon2 from "argon2";
8+import { Elysia, t } from "elysia";
9+import { REGISTRATION_DISABLED } from "../config.ts";
10+import {
11+ ADMIN_USERNAME,
12+ CHALLENGE_TTL_MS,
13+ VALID_USERNAME_RE,
14+ WEBAUTHN_RP_NAME,
15+} from "../constants.ts";
16+import { db } from "../db/index.ts";
17+import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts";
18+import { redirect } from "../lib/redirect.ts";
19+import { resolveSession } from "../middleware/session.ts";
20+import { Login } from "../views/auth/Login.tsx";
21+import { Register } from "../views/auth/Register.tsx";
22+import { html } from "../views/render.tsx";
23+
24+function rpFromRequest(request: Request): { origin: string; rpId: string } {
25+ const origin = request.headers.get("origin");
26+ if (!origin) throw new Error("Missing Origin header");
27+ return { origin, rpId: new URL(origin).hostname };
28+}
29+
30+function randomHex(bytes: number): string {
31+ const arr = new Uint8Array(bytes);
32+ crypto.getRandomValues(arr);
33+ return Array.from(arr)
34+ .map((b) => b.toString(16).padStart(2, "0"))
35+ .join("");
36+}
37+
38+async function createSession(userId: number): Promise<string> {
39+ const id = randomHex(32);
40+ const now = new Date();
41+ const expires = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
42+ await db
43+ .insertInto("sessions")
44+ .values({
45+ id,
46+ user_id: userId,
47+ expires_at: expires.toISOString(),
48+ created_at: now.toISOString(),
49+ })
50+ .execute();
51+ return id;
52+}
53+
54+function sessionCookie(id: string): string {
55+ return `session=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 60 * 60}`;
56+}
57+
58+function clearCookie(): string {
59+ return "session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
60+}
61+
62+// In-memory challenge store (fine for single-process)
63+type ChallengeEntry = {
64+ challenge: string;
65+ timeoutId: ReturnType<typeof setTimeout>;
66+};
67+const pendingChallenges = new Map<string, ChallengeEntry>();
68+
69+function setChallenge(key: string, challenge: string): void {
70+ const existing = pendingChallenges.get(key);
71+ if (existing) clearTimeout(existing.timeoutId);
72+ const timeoutId = setTimeout(
73+ () => pendingChallenges.delete(key),
74+ CHALLENGE_TTL_MS,
75+ );
76+ pendingChallenges.set(key, { challenge, timeoutId });
77+}
78+
79+function deleteChallenge(key: string): void {
80+ const entry = pendingChallenges.get(key);
81+ if (entry) clearTimeout(entry.timeoutId);
82+ pendingChallenges.delete(key);
83+}
84+
85+export const authRoutes = new Elysia()
86+ .guard({
87+ cookie: t.Cookie({ session: t.Optional(t.String()) }),
88+ })
89+ .get("/login", () => {
90+ return html(<Login />);
91+ })
92+
93+ .post(
94+ "/login",
95+ async ({ body, request, server }) => {
96+ const ip = getClientIp(request, server);
97+ if (!checkRateLimit(ip, 10, 60_000)) {
98+ return html(
99+ <Login error="Too many login attempts. Please try again later." />,
100+ );
101+ }
102+ const { username, password } = body;
103+ const user = await db
104+ .selectFrom("users")
105+ .selectAll()
106+ .where("username", "=", username)
107+ .executeTakeFirst();
108+
109+ if (!user || !user.password_hash) {
110+ return html(<Login error="Invalid username or password" />);
111+ }
112+
113+ const valid = await argon2.verify(user.password_hash, password);
114+ if (!valid) {
115+ return html(<Login error="Invalid username or password" />);
116+ }
117+
118+ const sessionId = await createSession(user.id);
119+ return redirect("/", sessionCookie(sessionId));
120+ },
121+ {
122+ body: t.Object({ username: t.String(), password: t.String() }),
123+ },
124+ )
125+
126+ .get("/register", () => {
127+ if (REGISTRATION_DISABLED)
128+ return new Response("Registration is disabled", { status: 403 });
129+ return html(<Register />);
130+ })
131+
132+ .post(
133+ "/register",
134+ async ({ body, request, server }) => {
135+ if (REGISTRATION_DISABLED)
136+ return new Response("Registration is disabled", {
137+ status: 403,
138+ });
139+ const ip = getClientIp(request, server);
140+ if (!checkRateLimit(ip, 3, 60 * 60_000)) {
141+ return html(
142+ <Register error="Too many registration attempts. Please try again later." />,
143+ );
144+ }
145+ const { username, password, password2 } = body;
146+
147+ if (!VALID_USERNAME_RE.test(username)) {
148+ return html(
149+ <Register error="Username may only contain letters, numbers, hyphens, and underscores" />,
150+ );
151+ }
152+ if (username === ADMIN_USERNAME) {
153+ return html(<Register error="That username is reserved" />);
154+ }
155+
156+ if (!password?.trim()) {
157+ return html(
158+ <Register error="Password is required (use the passkey button for passwordless registration)" />,
159+ );
160+ }
161+ if (password !== password2) {
162+ return html(<Register error="Passwords do not match" />);
163+ }
164+ if (password.length < 8) {
165+ return html(
166+ <Register error="Password must be at least 8 characters" />,
167+ );
168+ }
169+
170+ const hash = await argon2.hash(password);
171+ const now = new Date().toISOString();
172+ let result: { id: number };
173+ try {
174+ result = await db
175+ .insertInto("users")
176+ .values({
177+ username,
178+ password_hash: hash,
179+ created_at: now,
180+ })
181+ .returning("id")
182+ .executeTakeFirstOrThrow();
183+ } catch (err) {
184+ if (
185+ err instanceof Error &&
186+ err.message.includes(
187+ "UNIQUE constraint failed: users.username",
188+ )
189+ ) {
190+ return html(<Register error="Username already taken" />);
191+ }
192+ throw err;
193+ }
194+
195+ const sessionId = await createSession(result.id);
196+ return redirect("/", sessionCookie(sessionId));
197+ },
198+ {
199+ body: t.Object({
200+ username: t.String(),
201+ password: t.Optional(t.String()),
202+ password2: t.Optional(t.String()),
203+ }),
204+ },
205+ )
206+
207+ .post("/logout", async ({ cookie }) => {
208+ const sessionId = cookie.session.value;
209+ if (sessionId) {
210+ await db
211+ .deleteFrom("sessions")
212+ .where("id", "=", sessionId)
213+ .execute();
214+ }
215+ return redirect("/", clearCookie());
216+ })
217+
218+ // Create account (passkey-only path, called before passkey registration)
219+ .post(
220+ "/auth/passkey/create-user",
221+ async ({ body }) => {
222+ const { username } = body;
223+ if (!username || !VALID_USERNAME_RE.test(username)) {
224+ return new Response(
225+ JSON.stringify({ error: "Invalid username" }),
226+ {
227+ status: 400,
228+ },
229+ );
230+ }
231+ if (username === ADMIN_USERNAME) {
232+ return new Response(
233+ JSON.stringify({ error: "That username is reserved" }),
234+ { status: 400 },
235+ );
236+ }
237+ const now = new Date().toISOString();
238+ let result: { id: number };
239+ try {
240+ result = await db
241+ .insertInto("users")
242+ .values({
243+ username,
244+ password_hash: null,
245+ created_at: now,
246+ })
247+ .returning("id")
248+ .executeTakeFirstOrThrow();
249+ } catch (err) {
250+ if (
251+ err instanceof Error &&
252+ err.message.includes(
253+ "UNIQUE constraint failed: users.username",
254+ )
255+ ) {
256+ return new Response(
257+ JSON.stringify({ error: "Username already taken" }),
258+ { status: 400 },
259+ );
260+ }
261+ throw err;
262+ }
263+
264+ const sessionId = await createSession(result.id);
265+ return new Response(JSON.stringify({ ok: true }), {
266+ headers: {
267+ "Content-Type": "application/json",
268+ "Set-Cookie": sessionCookie(sessionId),
269+ },
270+ });
271+ },
272+ {
273+ body: t.Object({ username: t.String() }),
274+ },
275+ )
276+
277+ // Passkey registration
278+ .post("/auth/passkey/register/options", async ({ cookie, request }) => {
279+ const user = await resolveSession(cookie.session.value);
280+ if (!user)
281+ return new Response(
282+ JSON.stringify({ error: "Not authenticated" }),
283+ {
284+ status: 401,
285+ },
286+ );
287+
288+ const { rpId } = rpFromRequest(request);
289+ const passkeys = await db
290+ .selectFrom("passkeys")
291+ .select(["credential_id"])
292+ .where("user_id", "=", user.id)
293+ .execute();
294+
295+ const options = await generateRegistrationOptions({
296+ rpName: WEBAUTHN_RP_NAME,
297+ rpID: rpId,
298+ userID: Buffer.from(String(user.id)),
299+ userName: user.username,
300+ attestationType: "none",
301+ excludeCredentials: passkeys.map((p) => ({
302+ id: p.credential_id,
303+ type: "public-key" as const,
304+ })),
305+ });
306+
307+ setChallenge(`${user.username}:reg`, options.challenge);
308+ return new Response(JSON.stringify(options), {
309+ headers: { "Content-Type": "application/json" },
310+ });
311+ })
312+
313+ .post(
314+ "/auth/passkey/register/verify",
315+ async ({ body, cookie, request }) => {
316+ const user = await resolveSession(cookie.session.value);
317+ if (!user)
318+ return new Response(
319+ JSON.stringify({ error: "Not authenticated" }),
320+ {
321+ status: 401,
322+ },
323+ );
324+
325+ const challenge = pendingChallenges.get(
326+ `${user.username}:reg`,
327+ )?.challenge;
328+ if (!challenge)
329+ return new Response(JSON.stringify({ error: "No challenge" }), {
330+ status: 400,
331+ });
332+
333+ const { origin, rpId } = rpFromRequest(request);
334+ try {
335+ const verification = await verifyRegistrationResponse({
336+ response: body as Parameters<
337+ typeof verifyRegistrationResponse
338+ >[0]["response"],
339+ expectedChallenge: challenge,
340+ expectedOrigin: origin,
341+ expectedRPID: rpId,
342+ });
343+
344+ if (!verification.verified || !verification.registrationInfo) {
345+ return new Response(
346+ JSON.stringify({ error: "Verification failed" }),
347+ { status: 400 },
348+ );
349+ }
350+
351+ const { credential } = verification.registrationInfo;
352+ const now = new Date().toISOString();
353+ await db
354+ .insertInto("passkeys")
355+ .values({
356+ user_id: user.id,
357+ credential_id: credential.id,
358+ public_key: Buffer.from(
359+ new Uint8Array(credential.publicKey),
360+ ).toString("base64"),
361+ counter: credential.counter,
362+ created_at: now,
363+ })
364+ .execute();
365+
366+ deleteChallenge(`${user.username}:reg`);
367+ return new Response(JSON.stringify({ ok: true }), {
368+ headers: { "Content-Type": "application/json" },
369+ });
370+ } catch (e) {
371+ return new Response(JSON.stringify({ error: String(e) }), {
372+ status: 400,
373+ });
374+ }
375+ },
376+ {
377+ body: t.Any(),
378+ },
379+ )
380+
381+ // Passkey login
382+ .post("/auth/passkey/login/options", async ({ request }) => {
383+ const { rpId } = rpFromRequest(request);
384+ const options = await generateAuthenticationOptions({
385+ rpID: rpId,
386+ });
387+ setChallenge(`login:${options.challenge}`, options.challenge);
388+ return new Response(JSON.stringify(options), {
389+ headers: { "Content-Type": "application/json" },
390+ });
391+ })
392+
393+ .post(
394+ "/auth/passkey/login/verify",
395+ async ({ body, request }) => {
396+ const reqBody = body as { id?: string };
397+ const credentialId = reqBody?.id;
398+ if (!credentialId) {
399+ return new Response(
400+ JSON.stringify({ error: "Missing credential" }),
401+ {
402+ status: 400,
403+ },
404+ );
405+ }
406+
407+ const passkey = await db
408+ .selectFrom("passkeys")
409+ .innerJoin("users", "users.id", "passkeys.user_id")
410+ .selectAll("passkeys")
411+ .select("users.username")
412+ .where("passkeys.credential_id", "=", credentialId)
413+ .executeTakeFirst();
414+
415+ if (!passkey) {
416+ return new Response(
417+ JSON.stringify({ error: "Unknown credential" }),
418+ {
419+ status: 400,
420+ },
421+ );
422+ }
423+
424+ // Look up the challenge by decoding it from the signed clientDataJSON.
425+ // This ensures each verify request finds its own challenge rather than
426+ // an arbitrary "first login:" entry, which would fail under concurrency.
427+ const reqBodyTyped = body as {
428+ response?: { clientDataJSON?: string };
429+ };
430+ const clientDataJSON = reqBodyTyped?.response?.clientDataJSON;
431+ let challengeKey: string | undefined;
432+ let challenge: string | undefined;
433+ if (clientDataJSON) {
434+ try {
435+ const cd = JSON.parse(
436+ Buffer.from(clientDataJSON, "base64url").toString(),
437+ ) as { challenge?: string };
438+ if (cd.challenge) {
439+ challengeKey = `login:${cd.challenge}`;
440+ challenge =
441+ pendingChallenges.get(challengeKey)?.challenge;
442+ }
443+ } catch {
444+ // malformed clientDataJSON — handled by the check below
445+ }
446+ }
447+ if (!challenge) {
448+ return new Response(JSON.stringify({ error: "No challenge" }), {
449+ status: 400,
450+ });
451+ }
452+
453+ const { origin, rpId } = rpFromRequest(request);
454+ try {
455+ const verification = await verifyAuthenticationResponse({
456+ response: body as Parameters<
457+ typeof verifyAuthenticationResponse
458+ >[0]["response"],
459+ expectedChallenge: challenge,
460+ expectedOrigin: origin,
461+ expectedRPID: rpId,
462+ credential: {
463+ id: passkey.credential_id,
464+ publicKey: Buffer.from(passkey.public_key, "base64"),
465+ counter: passkey.counter,
466+ },
467+ });
468+
469+ if (!verification.verified) {
470+ return new Response(
471+ JSON.stringify({ error: "Verification failed" }),
472+ { status: 401 },
473+ );
474+ }
475+
476+ // Atomically advance the counter using the expected old value as
477+ // a guard. If another concurrent request already updated it, 0
478+ // rows are affected and we reject — this preserves WebAuthn's
479+ // monotonic-counter replay-attack protection.
480+ const updated = await db
481+ .updateTable("passkeys")
482+ .set({
483+ counter: verification.authenticationInfo.newCounter,
484+ })
485+ .where("id", "=", passkey.id)
486+ .where("counter", "=", passkey.counter)
487+ .executeTakeFirst();
488+
489+ if (!updated || updated.numUpdatedRows === 0n) {
490+ return new Response(
491+ JSON.stringify({ error: "Credential replay detected" }),
492+ { status: 401 },
493+ );
494+ }
495+
496+ deleteChallenge(challengeKey!);
497+ const sessionId = await createSession(passkey.user_id);
498+ return new Response(JSON.stringify({ ok: true }), {
499+ headers: {
500+ "Content-Type": "application/json",
501+ "Set-Cookie": sessionCookie(sessionId),
502+ },
503+ });
504+ } catch (e) {
505+ return new Response(JSON.stringify({ error: String(e) }), {
506+ status: 400,
507+ });
508+ }
509+ },
510+ {
511+ body: t.Any(),
512+ },
513+ );
Asrc/routes/avatars.ts
@@ -0,0 +1,84 @@
1+import { Elysia, t } from "elysia";
2+import { MAX_USER_UPLOAD_BYTES } from "../config.ts";
3+import { db } from "../db/index.ts";
4+import { requireAuth, resolveSession } from "../middleware/session.ts";
5+import {
6+ avatarJxlPath,
7+ createDefaultAvatar,
8+ processAndStoreAvatar,
9+} from "../services/avatar.ts";
10+
11+const CACHE = "public, max-age=31536000, immutable";
12+
13+async function bumpAvatarVersion(userId: number): Promise<void> {
14+ await db
15+ .updateTable("users")
16+ .set((eb) => ({ avatar_version: eb("avatar_version", "+", 1) }))
17+ .where("id", "=", userId)
18+ .execute();
19+}
20+
21+export const avatarRoutes = new Elysia()
22+ .guard({ cookie: t.Cookie({ session: t.Optional(t.String()) }) })
23+
24+ .get("/avatars/:id", async ({ params, request }) => {
25+ const userId = parseInt(params.id, 10);
26+ if (Number.isNaN(userId))
27+ return new Response("Not found", { status: 404 });
28+
29+ if (!new URL(request.url).searchParams.has("v"))
30+ return new Response("Not found", { status: 404 });
31+
32+ return new Response(Bun.file(avatarJxlPath(userId)), {
33+ headers: {
34+ "Content-Type": "image/jxl",
35+ "Cache-Control": CACHE,
36+ },
37+ });
38+ })
39+
40+ .post(
41+ "/settings/avatar",
42+ async ({ body, cookie }) => {
43+ const user = await resolveSession(cookie.session.value);
44+ const deny = requireAuth(user);
45+ if (deny) return deny;
46+ if (body.avatar.size > MAX_USER_UPLOAD_BYTES) {
47+ return new Response("Avatar file too large", { status: 400 });
48+ }
49+ const buffer = Buffer.from(await body.avatar.arrayBuffer());
50+ try {
51+ await processAndStoreAvatar(user!.id, buffer);
52+ } catch (err) {
53+ if (
54+ err instanceof Error &&
55+ err.message === "Invalid image type"
56+ ) {
57+ return new Response("File is not a supported image type", {
58+ status: 400,
59+ });
60+ }
61+ throw err;
62+ }
63+ await bumpAvatarVersion(user!.id);
64+ return new Response(null, {
65+ status: 302,
66+ headers: { Location: "/settings" },
67+ });
68+ },
69+ {
70+ body: t.Object({ avatar: t.File() }),
71+ },
72+ )
73+
74+ .post("/settings/avatar/delete", async ({ cookie }) => {
75+ const user = await resolveSession(cookie.session.value);
76+ const deny = requireAuth(user);
77+ if (deny) return deny;
78+ await createDefaultAvatar(user!.id, user!.username);
79+ await bumpAvatarVersion(user!.id);
80+ return new Response(null, {
81+ status: 302,
82+ headers: { Location: "/settings" },
83+ });
84+ });
Asrc/routes/git.ts
@@ -0,0 +1,177 @@
1+import { existsSync } from "node:fs";
2+import path from "node:path";
3+import * as argon2 from "argon2";
4+import { Elysia, t } from "elysia";
5+import { ADMIN_USERNAME, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts";
6+import { db } from "../db";
7+
8+function pktLine(str: string): Buffer {
9+ const len = Buffer.byteLength(str, "utf-8") + 4;
10+ return Buffer.from(len.toString(16).padStart(4, "0") + str, "utf-8");
11+}
12+
13+const PKT_FLUSH = Buffer.from("0000");
14+
15+async function verifyBasicAuth(
16+ authHeader: string | null,
17+ adminOnly: boolean,
18+): Promise<boolean> {
19+ if (!authHeader?.startsWith("Basic ")) return false;
20+ const decoded = Buffer.from(authHeader.slice(6), "base64").toString(
21+ "utf-8",
22+ );
23+ const sep = decoded.indexOf(":");
24+ if (sep === -1) return false;
25+ const username = decoded.slice(0, sep);
26+ const password = decoded.slice(sep + 1);
27+ if (adminOnly && username !== ADMIN_USERNAME) return false;
28+ const user = await db
29+ .selectFrom("users")
30+ .select("password_hash")
31+ .where("username", "=", username)
32+ .executeTakeFirst();
33+ if (!user?.password_hash) return false;
34+ try {
35+ return await argon2.verify(user.password_hash, password);
36+ } catch {
37+ return false;
38+ }
39+}
40+
41+function unauthorized(): Response {
42+ return new Response("Unauthorized", {
43+ status: 401,
44+ headers: {
45+ "WWW-Authenticate": 'Basic realm="Hearthforge"',
46+ "Content-Type": "text/plain",
47+ },
48+ });
49+}
50+
51+async function getRepo(
52+ slug: string,
53+): Promise<{ repoPath: string; isPrivate: boolean } | null> {
54+ const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug;
55+ if (!VALID_REPO_NAME_RE.test(repoName)) return null;
56+ const repo = await db
57+ .selectFrom("repositories")
58+ .select(["name", "is_private"])
59+ .where("name", "=", repoName)
60+ .executeTakeFirst();
61+ if (!repo) return null;
62+ const repoPath = path.join(REPOS_DIR, `${repo.name}.git`);
63+ if (!existsSync(repoPath)) return null;
64+ return { repoPath, isPrivate: repo.is_private === 1 };
65+}
66+
67+async function spawnGit(
68+ args: string[],
69+ stdinBytes?: Uint8Array,
70+): Promise<Uint8Array> {
71+ const proc = Bun.spawn(args, {
72+ stdin: stdinBytes ?? "ignore",
73+ stdout: "pipe",
74+ stderr: "pipe",
75+ });
76+ await proc.exited;
77+ return new Uint8Array(await Bun.readableStreamToArrayBuffer(proc.stdout));
78+}
79+
80+export const gitRoutes = new Elysia()
81+ // info/refs — serves both upload-pack (clone/fetch) and receive-pack (push)
82+ .get(
83+ "/:repo/info/refs",
84+ async ({ params, query, request }) => {
85+ const service = query.service;
86+ if (
87+ service !== "git-upload-pack" &&
88+ service !== "git-receive-pack"
89+ ) {
90+ return new Response("Bad Request", { status: 400 });
91+ }
92+
93+ const repo = await getRepo(params.repo);
94+ if (!repo) return new Response("Not Found", { status: 404 });
95+
96+ const authHeader = request.headers.get("Authorization");
97+ if (service === "git-receive-pack") {
98+ if (!(await verifyBasicAuth(authHeader, true)))
99+ return unauthorized();
100+ } else if (repo.isPrivate) {
101+ if (!(await verifyBasicAuth(authHeader, false)))
102+ return unauthorized();
103+ }
104+
105+ const gitCmd =
106+ service === "git-receive-pack" ? "receive-pack" : "upload-pack";
107+ const refs = await spawnGit([
108+ "git",
109+ gitCmd,
110+ "--stateless-rpc",
111+ "--advertise-refs",
112+ repo.repoPath,
113+ ]);
114+ const body = Buffer.concat([
115+ pktLine(`# service=git-${gitCmd}\n`),
116+ PKT_FLUSH,
117+ refs,
118+ ]);
119+
120+ return new Response(body, {
121+ headers: {
122+ "Content-Type": `application/x-git-${gitCmd}-advertisement`,
123+ "Cache-Control": "no-cache",
124+ },
125+ });
126+ },
127+ {
128+ query: t.Object({ service: t.Optional(t.String()) }),
129+ },
130+ )
131+
132+ // upload-pack POST — clone/fetch pack transfer (public for public repos)
133+ .post("/:repo/git-upload-pack", async ({ params, request }) => {
134+ const repo = await getRepo(params.repo);
135+ if (!repo) return new Response("Not Found", { status: 404 });
136+ if (repo.isPrivate) {
137+ if (
138+ !(await verifyBasicAuth(
139+ request.headers.get("Authorization"),
140+ false,
141+ ))
142+ )
143+ return unauthorized();
144+ }
145+ const body = new Uint8Array(await request.arrayBuffer());
146+ const result = await spawnGit(
147+ ["git", "upload-pack", "--stateless-rpc", repo.repoPath],
148+ body,
149+ );
150+ return new Response(result, {
151+ headers: {
152+ "Content-Type": "application/x-git-upload-pack-result",
153+ "Cache-Control": "no-cache",
154+ },
155+ });
156+ })
157+
158+ // receive-pack POST — push pack transfer (admin only)
159+ .post("/:repo/git-receive-pack", async ({ params, request }) => {
160+ if (
161+ !(await verifyBasicAuth(request.headers.get("Authorization"), true))
162+ )
163+ return unauthorized();
164+ const repo = await getRepo(params.repo);
165+ if (!repo) return new Response("Not Found", { status: 404 });
166+ const body = new Uint8Array(await request.arrayBuffer());
167+ const result = await spawnGit(
168+ ["git", "receive-pack", "--stateless-rpc", repo.repoPath],
169+ body,
170+ );
171+ return new Response(result, {
172+ headers: {
173+ "Content-Type": "application/x-git-receive-pack-result",
174+ "Cache-Control": "no-cache",
175+ },
176+ });
177+ });
Asrc/routes/issues.tsx
@@ -0,0 +1,539 @@
1+import { Elysia, t } from "elysia";
2+import { sql } from "kysely";
3+import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
4+import { db, getRepo } from "../db/index.ts";
5+import {
6+ requireAdmin,
7+ requireAuth,
8+ resolveSession,
9+} from "../middleware/session.ts";
10+import { renderMarkdown } from "../services/markdown.ts";
11+import { buildReactionCounts } from "../services/reactions.ts";
12+import { IssueDetail } from "../views/issues/IssueDetail.tsx";
13+import { IssueList } from "../views/issues/IssueList.tsx";
14+import { NewIssue } from "../views/issues/NewIssue.tsx";
15+import { html } from "../views/render.tsx";
16+
17+export const issueRoutes = new Elysia()
18+ .guard({
19+ cookie: t.Cookie({ session: t.Optional(t.String()) }),
20+ })
21+ .get(
22+ "/:repo/issues",
23+ async ({ params, query, cookie }) => {
24+ const user = await resolveSession(cookie.session.value);
25+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
26+ if (!repo) return new Response("Not found", { status: 404 });
27+
28+ const status =
29+ query.status === "closed"
30+ ? ("closed" as const)
31+ : ("open" as const);
32+ const page = Math.max(1, query.page ?? 1);
33+
34+ const allCounts = await db
35+ .selectFrom("issues")
36+ .select(["status", db.fn.countAll<number>().as("count")])
37+ .where("repo_id", "=", repo.id)
38+ .groupBy("status")
39+ .execute();
40+ const counts: Record<string, number> = Object.fromEntries(
41+ allCounts.map((r) => [r.status, Number(r.count)]),
42+ );
43+ const totalPages = Math.max(
44+ 1,
45+ Math.ceil((counts[status] ?? 0) / ISSUES_PER_PAGE),
46+ );
47+ const safePage = Math.min(page, totalPages);
48+ const offset = (safePage - 1) * ISSUES_PER_PAGE;
49+
50+ const issues = await db
51+ .selectFrom("issues")
52+ .leftJoin("users", "users.id", "issues.author_id")
53+ .select([
54+ "issues.id",
55+ "issues.repo_id",
56+ "issues.author_id",
57+ "issues.number",
58+ "issues.title",
59+ "issues.body",
60+ "issues.status",
61+ "issues.created_at",
62+ "issues.updated_at",
63+ "issues.edited_at",
64+ "users.username as author_username",
65+ ])
66+ .where("issues.repo_id", "=", repo.id)
67+ .where("issues.status", "=", status)
68+ .orderBy("issues.number", "desc")
69+ .limit(ISSUES_PER_PAGE)
70+ .offset(offset)
71+ .execute();
72+
73+ const pagination = {
74+ page: safePage,
75+ totalPages,
76+ pageUrlTemplate: `/${repo.name}/issues?status=${status}&page={page}`,
77+ };
78+ return html(
79+ <IssueList
80+ user={user}
81+ repo={repo}
82+ issues={
83+ issues as ((typeof issues)[0] & {
84+ author_username: string;
85+ })[]
86+ }
87+ status={status}
88+ counts={counts}
89+ pagination={pagination}
90+ />,
91+ );
92+ },
93+ {
94+ query: t.Object({
95+ status: t.Optional(t.String()),
96+ page: t.Optional(t.Numeric()),
97+ }),
98+ },
99+ )
100+
101+ .get("/:repo/issues/new", async ({ params, cookie }) => {
102+ const user = await resolveSession(cookie.session.value);
103+ const deny = requireAuth(user);
104+ if (deny) return deny;
105+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
106+ if (!repo) return new Response("Not found", { status: 404 });
107+ return html(<NewIssue user={user!} repo={repo} />);
108+ })
109+
110+ .post(
111+ "/:repo/issues",
112+ async ({ params, body, cookie }) => {
113+ const user = await resolveSession(cookie.session.value);
114+ const deny = requireAuth(user);
115+ if (deny) return deny;
116+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
117+ if (!repo) return new Response("Not found", { status: 404 });
118+
119+ const { title, body: issueBody } = body;
120+ if (!title?.trim()) {
121+ return html(
122+ <NewIssue
123+ user={user!}
124+ repo={repo}
125+ error="Title is required"
126+ />,
127+ );
128+ }
129+
130+ const now = new Date().toISOString();
131+ const { number } = await db.transaction().execute(async (trx) => {
132+ const { issue_seq } = await trx
133+ .updateTable("repositories")
134+ .set({ issue_seq: sql`issue_seq + 1` })
135+ .where("id", "=", repo.id)
136+ .returning("issue_seq")
137+ .executeTakeFirstOrThrow();
138+ await trx
139+ .insertInto("issues")
140+ .values({
141+ repo_id: repo.id,
142+ author_id: user?.id,
143+ number: issue_seq,
144+ title: title.trim(),
145+ body: issueBody ?? "",
146+ status: "open",
147+ created_at: now,
148+ updated_at: now,
149+ })
150+ .execute();
151+ return { number: issue_seq };
152+ });
153+
154+ return new Response(null, {
155+ status: 302,
156+ headers: { Location: `/${repo.name}/issues/${number}` },
157+ });
158+ },
159+ {
160+ body: t.Object({
161+ title: t.String(),
162+ body: t.Optional(t.String()),
163+ }),
164+ },
165+ )
166+
167+ .get("/:repo/issues/:number", async ({ params, cookie }) => {
168+ const user = await resolveSession(cookie.session.value);
169+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
170+ if (!repo) return new Response("Not found", { status: 404 });
171+
172+ const issueNum = parseInt(params.number, 10);
173+ const issue = await db
174+ .selectFrom("issues")
175+ .leftJoin("users", "users.id", "issues.author_id")
176+ .select([
177+ "issues.id",
178+ "issues.repo_id",
179+ "issues.author_id",
180+ "issues.number",
181+ "issues.title",
182+ "issues.body",
183+ "issues.status",
184+ "issues.created_at",
185+ "issues.updated_at",
186+ "issues.edited_at",
187+ "users.username as author_username",
188+ "users.avatar_version as author_avatar_version",
189+ ])
190+ .where("issues.repo_id", "=", repo.id)
191+ .where("issues.number", "=", issueNum)
192+ .executeTakeFirst();
193+ if (!issue) return new Response("Not found", { status: 404 });
194+
195+ const bodyHtml = renderMarkdown(issue.body);
196+
197+ const comments = await db
198+ .selectFrom("issue_comments")
199+ .leftJoin("users", "users.id", "issue_comments.author_id")
200+ .select([
201+ "issue_comments.id",
202+ "issue_comments.issue_id",
203+ "issue_comments.author_id",
204+ "issue_comments.body",
205+ "issue_comments.created_at",
206+ "issue_comments.edited_at",
207+ "users.username as author_username",
208+ "users.avatar_version as author_avatar_version",
209+ ])
210+ .where("issue_comments.issue_id", "=", issue.id)
211+ .orderBy("issue_comments.created_at", "asc")
212+ .execute();
213+
214+ const commentsWithHtml = comments.map((c) => ({
215+ ...c,
216+ bodyHtml: renderMarkdown(c.body),
217+ }));
218+
219+ // Reactions on the issue itself
220+ const allReactions = await db
221+ .selectFrom("issue_reactions")
222+ .selectAll()
223+ .where("issue_id", "=", issue.id)
224+ .execute();
225+
226+ const reactions = buildReactionCounts(allReactions, null, user?.id);
227+ const commentReactions = new Map(
228+ comments.map((c) => [
229+ c.id,
230+ buildReactionCounts(allReactions, c.id, user?.id),
231+ ]),
232+ );
233+
234+ return html(
235+ <IssueDetail
236+ user={user}
237+ repo={repo}
238+ issue={
239+ issue as typeof issue & {
240+ author_username: string;
241+ author_avatar_version: number | null;
242+ }
243+ }
244+ bodyHtml={bodyHtml}
245+ comments={
246+ commentsWithHtml as ((typeof commentsWithHtml)[0] & {
247+ author_username: string;
248+ author_avatar_version: number | null;
249+ })[]
250+ }
251+ reactions={reactions}
252+ commentReactions={commentReactions}
253+ />,
254+ );
255+ })
256+
257+ .post(
258+ "/:repo/issues/:number/comments",
259+ async ({ params, body, cookie }) => {
260+ const user = await resolveSession(cookie.session.value);
261+ const deny = requireAuth(user);
262+ if (deny) return deny;
263+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
264+ if (!repo) return new Response("Not found", { status: 404 });
265+
266+ const issueNum = parseInt(params.number, 10);
267+ const issue = await db
268+ .selectFrom("issues")
269+ .select(["id", "status"])
270+ .where("repo_id", "=", repo.id)
271+ .where("number", "=", issueNum)
272+ .executeTakeFirst();
273+ if (!issue) return new Response("Not found", { status: 404 });
274+
275+ // Only admin can comment on closed issues
276+ if (issue.status === "closed" && !user?.isAdmin) {
277+ return new Response(null, {
278+ status: 302,
279+ headers: { Location: `/${repo.name}/issues/${issueNum}` },
280+ });
281+ }
282+
283+ const { body: commentBody } = body;
284+ if (!commentBody?.trim()) {
285+ return new Response(null, {
286+ status: 302,
287+ headers: { Location: `/${repo.name}/issues/${issueNum}` },
288+ });
289+ }
290+
291+ await db.transaction().execute(async (trx) => {
292+ const now = new Date().toISOString();
293+ await trx
294+ .insertInto("issue_comments")
295+ .values({
296+ issue_id: issue.id,
297+ author_id: user?.id,
298+ body: commentBody.trim(),
299+ created_at: now,
300+ })
301+ .execute();
302+ await trx
303+ .updateTable("issues")
304+ .set({ updated_at: now })
305+ .where("id", "=", issue.id)
306+ .execute();
307+ });
308+
309+ return new Response(null, {
310+ status: 302,
311+ headers: { Location: `/${repo.name}/issues/${issueNum}` },
312+ });
313+ },
314+ {
315+ body: t.Object({ body: t.String() }),
316+ },
317+ )
318+
319+ .post(
320+ "/:repo/issues/:number/react",
321+ async ({ params, body, cookie }) => {
322+ const user = await resolveSession(cookie.session.value);
323+ const deny = requireAuth(user);
324+ if (deny) return deny;
325+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
326+ if (!repo) return new Response("Not found", { status: 404 });
327+
328+ const { emoji, comment_id } = body;
329+ if (!ALLOWED_REACTIONS.has(emoji)) {
330+ return new Response("Invalid emoji", { status: 400 });
331+ }
332+
333+ const issueNum = parseInt(params.number, 10);
334+ const issue = await db
335+ .selectFrom("issues")
336+ .select(["id"])
337+ .where("repo_id", "=", repo.id)
338+ .where("number", "=", issueNum)
339+ .executeTakeFirst();
340+ if (!issue) return new Response("Not found", { status: 404 });
341+
342+ const commentId = comment_id ? parseInt(comment_id, 10) : null;
343+
344+ // One reaction per user per target: toggle off if same emoji, replace if different
345+ await db.transaction().execute(async (trx) => {
346+ const existing = await trx
347+ .selectFrom("issue_reactions")
348+ .select(["id", "emoji"])
349+ .where("issue_id", "=", issue.id)
350+ .where((eb) =>
351+ commentId !== null
352+ ? eb("comment_id", "=", commentId)
353+ : eb("comment_id", "is", null),
354+ )
355+ .where("user_id", "=", user!.id)
356+ .executeTakeFirst();
357+
358+ if (existing) {
359+ if (existing.emoji === emoji) {
360+ await trx
361+ .deleteFrom("issue_reactions")
362+ .where("id", "=", existing.id)
363+ .execute();
364+ } else {
365+ await trx
366+ .updateTable("issue_reactions")
367+ .set({ emoji })
368+ .where("id", "=", existing.id)
369+ .execute();
370+ }
371+ } else {
372+ await trx
373+ .insertInto("issue_reactions")
374+ .values({
375+ issue_id: issue.id,
376+ comment_id: commentId,
377+ user_id: user!.id,
378+ emoji,
379+ })
380+ .execute();
381+ }
382+ });
383+
384+ return new Response(null, {
385+ status: 303,
386+ headers: { Location: `/${repo.name}/issues/${issueNum}` },
387+ });
388+ },
389+ {
390+ body: t.Object({
391+ emoji: t.String(),
392+ comment_id: t.Optional(t.String()),
393+ }),
394+ },
395+ )
396+
397+ .post("/:repo/issues/:number/close", async ({ params, cookie }) => {
398+ const user = await resolveSession(cookie.session.value);
399+ const deny = requireAdmin(user);
400+ if (deny) return deny;
401+ const repo = await getRepo(params.repo, true);
402+ if (!repo) return new Response("Not found", { status: 404 });
403+
404+ const issueNum = parseInt(params.number, 10);
405+ const issue = await db
406+ .selectFrom("issues")
407+ .select("id")
408+ .where("repo_id", "=", repo.id)
409+ .where("number", "=", issueNum)
410+ .executeTakeFirst();
411+ if (!issue) return new Response("Not found", { status: 404 });
412+
413+ await db
414+ .updateTable("issues")
415+ .set({
416+ status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`,
417+ updated_at: new Date().toISOString(),
418+ })
419+ .where("id", "=", issue.id)
420+ .execute();
421+
422+ return new Response(null, {
423+ status: 302,
424+ headers: { Location: `/${repo.name}/issues/${issueNum}` },
425+ });
426+ })
427+
428+ .post("/:repo/issues/:number/delete", async ({ params, cookie }) => {
429+ const user = await resolveSession(cookie.session.value);
430+ const deny = requireAuth(user);
431+ if (deny) return deny;
432+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
433+ if (!repo) return new Response("Not found", { status: 404 });
434+
435+ const issueNum = parseInt(params.number, 10);
436+ const issue = await db
437+ .selectFrom("issues")
438+ .select(["id", "author_id"])
439+ .where("repo_id", "=", repo.id)
440+ .where("number", "=", issueNum)
441+ .executeTakeFirst();
442+ if (!issue) return new Response("Not found", { status: 404 });
443+ if (issue.author_id !== user?.id && !user?.isAdmin)
444+ return new Response("Forbidden", { status: 403 });
445+
446+ await db.deleteFrom("issues").where("id", "=", issue.id).execute();
447+
448+ return new Response(null, {
449+ status: 302,
450+ headers: { Location: `/${repo.name}/issues` },
451+ });
452+ })
453+
454+ .post(
455+ "/:repo/issues/:number/edit",
456+ async ({ params, body, cookie }) => {
457+ const user = await resolveSession(cookie.session.value);
458+ const deny = requireAuth(user);
459+ if (deny) return deny;
460+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
461+ if (!repo) return new Response("Not found", { status: 404 });
462+
463+ const issueNum = parseInt(params.number, 10);
464+ const issue = await db
465+ .selectFrom("issues")
466+ .select(["id", "author_id"])
467+ .where("repo_id", "=", repo.id)
468+ .where("number", "=", issueNum)
469+ .executeTakeFirst();
470+ if (!issue) return new Response("Not found", { status: 404 });
471+ if (issue.author_id !== user?.id && !user?.isAdmin)
472+ return new Response("Forbidden", { status: 403 });
473+
474+ await db
475+ .updateTable("issues")
476+ .set({
477+ title: body.title.trim(),
478+ body: body.edit_body ?? "",
479+ edited_at: new Date().toISOString(),
480+ updated_at: new Date().toISOString(),
481+ })
482+ .where("id", "=", issue.id)
483+ .execute();
484+
485+ return new Response(null, {
486+ status: 302,
487+ headers: { Location: `/${repo.name}/issues/${issueNum}` },
488+ });
489+ },
490+ {
491+ body: t.Object({
492+ title: t.String(),
493+ edit_body: t.Optional(t.String()),
494+ }),
495+ },
496+ )
497+
498+ .post(
499+ "/:repo/issues/:number/comments/:id/edit",
500+ async ({ params, body, cookie }) => {
501+ const user = await resolveSession(cookie.session.value);
502+ const deny = requireAuth(user);
503+ if (deny) return deny;
504+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
505+ if (!repo) return new Response("Not found", { status: 404 });
506+
507+ const comment = await db
508+ .selectFrom("issue_comments")
509+ .select(["id", "author_id", "issue_id"])
510+ .where("id", "=", params.id)
511+ .executeTakeFirst();
512+ if (!comment) return new Response("Not found", { status: 404 });
513+ if (comment.author_id !== user?.id && !user?.isAdmin)
514+ return new Response("Forbidden", { status: 403 });
515+
516+ const issueNum = parseInt(params.number, 10);
517+ await db
518+ .updateTable("issue_comments")
519+ .set({
520+ body: body.edit_body.trim(),
521+ edited_at: new Date().toISOString(),
522+ })
523+ .where("id", "=", comment.id)
524+ .execute();
525+
526+ return new Response(null, {
527+ status: 302,
528+ headers: { Location: `/${repo.name}/issues/${issueNum}` },
529+ });
530+ },
531+ {
532+ params: t.Object({
533+ repo: t.String(),
534+ number: t.String(),
535+ id: t.Numeric(),
536+ }),
537+ body: t.Object({ edit_body: t.String() }),
538+ },
539+ );
Asrc/routes/patches.tsx
@@ -0,0 +1,698 @@
1+import { Elysia, t } from "elysia";
2+import { sql } from "kysely";
3+import { MAX_USER_UPLOAD_BYTES } from "../config.ts";
4+import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
5+import { db, getRepo } from "../db/index.ts";
6+import {
7+ requireAdmin,
8+ requireAuth,
9+ resolveSession,
10+} from "../middleware/session.ts";
11+import { git } from "../services/git.ts";
12+import { prepareDiff } from "../services/highlightWorker.ts";
13+import { renderMarkdown } from "../services/markdown.ts";
14+import { patchCache } from "../services/patchCache.ts";
15+import { buildReactionCounts } from "../services/reactions.ts";
16+import { NewPatch } from "../views/patches/NewPatch.tsx";
17+import { PatchDetail } from "../views/patches/PatchDetail.tsx";
18+import { PatchList } from "../views/patches/PatchList.tsx";
19+import { html } from "../views/render.tsx";
20+
21+function isValidPatch(content: string): boolean {
22+ const lines = content.split("\n");
23+ return lines.some(
24+ (l) =>
25+ l.startsWith("diff --git ") ||
26+ l.startsWith("--- ") ||
27+ l.startsWith("+++ ") ||
28+ l.startsWith("@@ ") ||
29+ l.startsWith("Index: "),
30+ );
31+}
32+
33+async function runPatchCheck(
34+ repoName: string,
35+ patchId: number,
36+ patchContent: string,
37+) {
38+ const result = await git.checkPatch(repoName, patchContent);
39+ const applyResult = {
40+ status: result.clean ? ("clean" as const) : ("conflict" as const),
41+ output: result.output,
42+ };
43+ patchCache.set(patchId, applyResult);
44+ return applyResult;
45+}
46+
47+export const patchRoutes = new Elysia()
48+ .guard({
49+ cookie: t.Cookie({ session: t.Optional(t.String()) }),
50+ })
51+ .get(
52+ "/:repo/patches",
53+ async ({ params, query, cookie }) => {
54+ const user = await resolveSession(cookie.session.value);
55+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
56+ if (!repo) return new Response("Not found", { status: 404 });
57+
58+ const status = ["open", "merged", "closed"].includes(
59+ query.status ?? "",
60+ )
61+ ? query.status!
62+ : "open";
63+ const page = Math.max(1, query.page ?? 1);
64+
65+ const allCounts = await db
66+ .selectFrom("patches")
67+ .select(["status", db.fn.countAll<number>().as("count")])
68+ .where("repo_id", "=", repo.id)
69+ .groupBy("status")
70+ .execute();
71+ const counts: Record<string, number> = Object.fromEntries(
72+ allCounts.map((r) => [r.status, Number(r.count)]),
73+ );
74+ const totalPages = Math.max(
75+ 1,
76+ Math.ceil((counts[status] ?? 0) / PATCHES_PER_PAGE),
77+ );
78+ const safePage = Math.min(page, totalPages);
79+ const offset = (safePage - 1) * PATCHES_PER_PAGE;
80+
81+ const patches = await db
82+ .selectFrom("patches")
83+ .leftJoin("users", "users.id", "patches.author_id")
84+ .select([
85+ "patches.id",
86+ "patches.repo_id",
87+ "patches.author_id",
88+ "patches.number",
89+ "patches.title",
90+ "patches.description",
91+ "patches.patch_content",
92+ "patches.status",
93+ "patches.created_at",
94+ "patches.updated_at",
95+ "patches.edited_at",
96+ "users.username as author_username",
97+ ])
98+ .where("patches.repo_id", "=", repo.id)
99+ .where("patches.status", "=", status)
100+ .orderBy("patches.number", "desc")
101+ .limit(PATCHES_PER_PAGE)
102+ .offset(offset)
103+ .execute();
104+
105+ const pagination = {
106+ page: safePage,
107+ totalPages,
108+ pageUrlTemplate: `/${repo.name}/patches?status=${status}&page={page}`,
109+ };
110+ return html(
111+ <PatchList
112+ user={user}
113+ repo={repo}
114+ patches={
115+ patches as ((typeof patches)[0] & {
116+ author_username: string;
117+ })[]
118+ }
119+ status={status}
120+ counts={counts}
121+ pagination={pagination}
122+ />,
123+ );
124+ },
125+ {
126+ query: t.Object({
127+ status: t.Optional(t.String()),
128+ page: t.Optional(t.Numeric()),
129+ }),
130+ },
131+ )
132+
133+ .get("/:repo/patches/new", async ({ params, cookie }) => {
134+ const user = await resolveSession(cookie.session.value);
135+ const deny = requireAuth(user);
136+ if (deny) return deny;
137+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
138+ if (!repo) return new Response("Not found", { status: 404 });
139+ return html(<NewPatch user={user!} repo={repo} />);
140+ })
141+
142+ .post(
143+ "/:repo/patches",
144+ async ({ params, body, cookie }) => {
145+ const user = await resolveSession(cookie.session.value);
146+ const deny = requireAuth(user);
147+ if (deny) return deny;
148+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
149+ if (!repo) return new Response("Not found", { status: 404 });
150+
151+ if (!body.title?.trim()) {
152+ return html(
153+ <NewPatch
154+ user={user!}
155+ repo={repo}
156+ error="Title is required"
157+ />,
158+ );
159+ }
160+
161+ if (!body.patch_file) {
162+ return html(
163+ <NewPatch
164+ user={user!}
165+ repo={repo}
166+ error="Patch file is required"
167+ />,
168+ );
169+ }
170+
171+ if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) {
172+ return html(
173+ <NewPatch
174+ user={user!}
175+ repo={repo}
176+ error="Patch file is too large"
177+ />,
178+ );
179+ }
180+
181+ const patchContent = await body.patch_file.text();
182+ if (!patchContent.trim()) {
183+ return html(
184+ <NewPatch
185+ user={user!}
186+ repo={repo}
187+ error="Patch file is empty"
188+ />,
189+ );
190+ }
191+
192+ // Validate it looks like a patch/diff file
193+ if (!isValidPatch(patchContent)) {
194+ return html(
195+ <NewPatch
196+ user={user!}
197+ repo={repo}
198+ error="File does not appear to be a valid patch or diff file"
199+ />,
200+ );
201+ }
202+
203+ const now = new Date().toISOString();
204+ const { number, result } = await db
205+ .transaction()
206+ .execute(async (trx) => {
207+ const { patch_seq } = await trx
208+ .updateTable("repositories")
209+ .set({ patch_seq: sql`patch_seq + 1` })
210+ .where("id", "=", repo.id)
211+ .returning("patch_seq")
212+ .executeTakeFirstOrThrow();
213+ const inserted = await trx
214+ .insertInto("patches")
215+ .values({
216+ repo_id: repo.id,
217+ author_id: user?.id,
218+ number: patch_seq,
219+ title: body.title!.trim(),
220+ description: body.description?.trim() ?? "",
221+ patch_content: patchContent,
222+ status: "open",
223+ created_at: now,
224+ updated_at: now,
225+ })
226+ .returning("id")
227+ .executeTakeFirstOrThrow();
228+ return { number: patch_seq, result: inserted };
229+ });
230+
231+ await runPatchCheck(repo.name, result.id, patchContent);
232+
233+ return new Response(null, {
234+ status: 302,
235+ headers: { Location: `/${repo.name}/patches/${number}` },
236+ });
237+ },
238+ {
239+ body: t.Object({
240+ title: t.Optional(t.String()),
241+ description: t.Optional(t.String()),
242+ patch_file: t.Optional(t.File()),
243+ }),
244+ },
245+ )
246+
247+ .get(
248+ "/:repo/patches/:number",
249+ async ({ params, query, cookie }) => {
250+ const user = await resolveSession(cookie.session.value);
251+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
252+ if (!repo) return new Response("Not found", { status: 404 });
253+
254+ const patchNum = parseInt(params.number, 10);
255+ const patch = await db
256+ .selectFrom("patches")
257+ .leftJoin("users", "users.id", "patches.author_id")
258+ .select([
259+ "patches.id",
260+ "patches.repo_id",
261+ "patches.author_id",
262+ "patches.number",
263+ "patches.title",
264+ "patches.description",
265+ "patches.patch_content",
266+ "patches.status",
267+ "patches.created_at",
268+ "patches.updated_at",
269+ "patches.edited_at",
270+ "users.username as author_username",
271+ "users.avatar_version as author_avatar_version",
272+ ])
273+ .where("patches.repo_id", "=", repo.id)
274+ .where("patches.number", "=", patchNum)
275+ .executeTakeFirst();
276+ if (!patch) return new Response("Not found", { status: 404 });
277+
278+ const descriptionHtml = patch.description
279+ ? renderMarkdown(patch.description)
280+ : "";
281+
282+ let applyResult = patchCache.get(patch.id) ?? null;
283+ // Cold cache (e.g. server restart) — re-check synchronously for open patches only
284+ if (!applyResult && patch.status === "open") {
285+ applyResult = await runPatchCheck(
286+ repo.name,
287+ patch.id,
288+ patch.patch_content,
289+ );
290+ }
291+
292+ const files = await prepareDiff(
293+ patch.patch_content,
294+ `patch:${patch.id}`,
295+ );
296+
297+ const comments = await db
298+ .selectFrom("patch_comments")
299+ .leftJoin("users", "users.id", "patch_comments.author_id")
300+ .select([
301+ "patch_comments.id",
302+ "patch_comments.patch_id",
303+ "patch_comments.author_id",
304+ "patch_comments.body",
305+ "patch_comments.created_at",
306+ "patch_comments.edited_at",
307+ "users.username as author_username",
308+ "users.avatar_version as author_avatar_version",
309+ ])
310+ .where("patch_comments.patch_id", "=", patch.id)
311+ .orderBy("patch_comments.created_at", "asc")
312+ .execute();
313+
314+ const commentsWithHtml = comments.map((c) => ({
315+ ...c,
316+ bodyHtml: renderMarkdown(c.body),
317+ }));
318+
319+ const allReactions = await db
320+ .selectFrom("patch_reactions")
321+ .selectAll()
322+ .where("patch_id", "=", patch.id)
323+ .execute();
324+
325+ const reactions = buildReactionCounts(allReactions, null, user?.id);
326+ const commentReactions = new Map(
327+ comments.map((c) => [
328+ c.id,
329+ buildReactionCounts(allReactions, c.id, user?.id),
330+ ]),
331+ );
332+
333+ const tab =
334+ query.tab === "changes"
335+ ? ("changes" as const)
336+ : ("conversation" as const);
337+
338+ return html(
339+ <PatchDetail
340+ user={user}
341+ repo={repo}
342+ patch={
343+ patch as typeof patch & {
344+ author_username: string;
345+ author_avatar_version: number | null;
346+ }
347+ }
348+ descriptionHtml={descriptionHtml}
349+ applyResult={applyResult}
350+ files={files}
351+ tab={tab}
352+ comments={
353+ commentsWithHtml as ((typeof commentsWithHtml)[0] & {
354+ author_username: string;
355+ author_avatar_version: number | null;
356+ })[]
357+ }
358+ reactions={reactions}
359+ commentReactions={commentReactions}
360+ />,
361+ );
362+ },
363+ {
364+ query: t.Object({ tab: t.Optional(t.String()) }),
365+ },
366+ )
367+
368+ .post("/:repo/patches/:number/merge", async ({ params, cookie }) => {
369+ const user = await resolveSession(cookie.session.value);
370+ const deny = requireAdmin(user);
371+ if (deny) return deny;
372+ const repo = await getRepo(params.repo, true);
373+ if (!repo) return new Response("Not found", { status: 404 });
374+
375+ const patchNum = parseInt(params.number, 10);
376+ const patch = await db
377+ .selectFrom("patches")
378+ .select(["id", "title", "description", "patch_content", "status"])
379+ .where("repo_id", "=", repo.id)
380+ .where("number", "=", patchNum)
381+ .executeTakeFirst();
382+ if (!patch) return new Response("Not found", { status: 404 });
383+
384+ // Atomically claim the merge slot before the slow git operation to
385+ // prevent two concurrent requests from both applying the same patch.
386+ const claimed = await db
387+ .updateTable("patches")
388+ .set({ status: "merged", updated_at: new Date().toISOString() })
389+ .where("id", "=", patch.id)
390+ .where("status", "=", "open")
391+ .executeTakeFirst();
392+ if (!claimed || claimed.numUpdatedRows === 0n)
393+ return new Response("Patch is not open", { status: 400 });
394+
395+ try {
396+ await git.applyPatch(
397+ repo.name,
398+ patch.patch_content,
399+ patch.title,
400+ patch.description,
401+ );
402+ } catch (err) {
403+ // Roll back the status if the git operation fails
404+ await db
405+ .updateTable("patches")
406+ .set({ status: "open", updated_at: new Date().toISOString() })
407+ .where("id", "=", patch.id)
408+ .execute();
409+ throw err;
410+ }
411+ patchCache.invalidate(patch.id);
412+
413+ return new Response(null, {
414+ status: 302,
415+ headers: { Location: `/${repo.name}/patches/${patchNum}` },
416+ });
417+ })
418+
419+ .post("/:repo/patches/:number/close", async ({ params, cookie }) => {
420+ const user = await resolveSession(cookie.session.value);
421+ const deny = requireAdmin(user);
422+ if (deny) return deny;
423+ const repo = await getRepo(params.repo, true);
424+ if (!repo) return new Response("Not found", { status: 404 });
425+
426+ const patchNum = parseInt(params.number, 10);
427+ const patch = await db
428+ .selectFrom("patches")
429+ .select("id")
430+ .where("repo_id", "=", repo.id)
431+ .where("number", "=", patchNum)
432+ .executeTakeFirst();
433+ if (!patch) return new Response("Not found", { status: 404 });
434+
435+ // Toggle open↔closed atomically; exclude merged patches from the WHERE
436+ // so that numUpdatedRows = 0 means the patch is merged (or gone).
437+ const toggled = await db
438+ .updateTable("patches")
439+ .set({
440+ status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`,
441+ updated_at: new Date().toISOString(),
442+ })
443+ .where("id", "=", patch.id)
444+ .where("status", "!=", "merged")
445+ .executeTakeFirst();
446+ if (!toggled || toggled.numUpdatedRows === 0n)
447+ return new Response("Patch is merged", { status: 400 });
448+
449+ return new Response(null, {
450+ status: 302,
451+ headers: { Location: `/${repo.name}/patches/${patchNum}` },
452+ });
453+ })
454+
455+ .post("/:repo/patches/:number/delete", async ({ params, cookie }) => {
456+ const user = await resolveSession(cookie.session.value);
457+ const deny = requireAuth(user);
458+ if (deny) return deny;
459+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
460+ if (!repo) return new Response("Not found", { status: 404 });
461+
462+ const patchNum = parseInt(params.number, 10);
463+ const patch = await db
464+ .selectFrom("patches")
465+ .select(["id", "author_id"])
466+ .where("repo_id", "=", repo.id)
467+ .where("number", "=", patchNum)
468+ .executeTakeFirst();
469+ if (!patch) return new Response("Not found", { status: 404 });
470+ if (patch.author_id !== user?.id && !user?.isAdmin)
471+ return new Response("Forbidden", { status: 403 });
472+
473+ patchCache.invalidate(patch.id);
474+ await db.deleteFrom("patches").where("id", "=", patch.id).execute();
475+
476+ return new Response(null, {
477+ status: 302,
478+ headers: { Location: `/${repo.name}/patches` },
479+ });
480+ })
481+
482+ .post(
483+ "/:repo/patches/:number/comments",
484+ async ({ params, body, cookie }) => {
485+ const user = await resolveSession(cookie.session.value);
486+ const deny = requireAuth(user);
487+ if (deny) return deny;
488+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
489+ if (!repo) return new Response("Not found", { status: 404 });
490+
491+ const patchNum = parseInt(params.number, 10);
492+ const patch = await db
493+ .selectFrom("patches")
494+ .select(["id", "status"])
495+ .where("repo_id", "=", repo.id)
496+ .where("number", "=", patchNum)
497+ .executeTakeFirst();
498+ if (!patch) return new Response("Not found", { status: 404 });
499+
500+ const { body: commentBody } = body;
501+ if (!commentBody?.trim()) {
502+ return new Response(null, {
503+ status: 302,
504+ headers: { Location: `/${repo.name}/patches/${patchNum}` },
505+ });
506+ }
507+
508+ await db.transaction().execute(async (trx) => {
509+ const now = new Date().toISOString();
510+ await trx
511+ .insertInto("patch_comments")
512+ .values({
513+ patch_id: patch.id,
514+ author_id: user?.id,
515+ body: commentBody.trim(),
516+ created_at: now,
517+ })
518+ .execute();
519+ await trx
520+ .updateTable("patches")
521+ .set({ updated_at: now })
522+ .where("id", "=", patch.id)
523+ .execute();
524+ });
525+
526+ return new Response(null, {
527+ status: 302,
528+ headers: { Location: `/${repo.name}/patches/${patchNum}` },
529+ });
530+ },
531+ {
532+ body: t.Object({ body: t.String() }),
533+ },
534+ )
535+
536+ .post(
537+ "/:repo/patches/:number/react",
538+ async ({ params, body, cookie }) => {
539+ const user = await resolveSession(cookie.session.value);
540+ const deny = requireAuth(user);
541+ if (deny) return deny;
542+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
543+ if (!repo) return new Response("Not found", { status: 404 });
544+
545+ const { emoji, comment_id } = body;
546+ if (!ALLOWED_REACTIONS.has(emoji)) {
547+ return new Response("Invalid emoji", { status: 400 });
548+ }
549+
550+ const patchNum = parseInt(params.number, 10);
551+ const patch = await db
552+ .selectFrom("patches")
553+ .select(["id"])
554+ .where("repo_id", "=", repo.id)
555+ .where("number", "=", patchNum)
556+ .executeTakeFirst();
557+ if (!patch) return new Response("Not found", { status: 404 });
558+
559+ const commentId = comment_id ? parseInt(comment_id, 10) : null;
560+
561+ await db.transaction().execute(async (trx) => {
562+ const existing = await trx
563+ .selectFrom("patch_reactions")
564+ .select(["id", "emoji"])
565+ .where("patch_id", "=", patch.id)
566+ .where((eb) =>
567+ commentId !== null
568+ ? eb("comment_id", "=", commentId)
569+ : eb("comment_id", "is", null),
570+ )
571+ .where("user_id", "=", user!.id)
572+ .executeTakeFirst();
573+
574+ if (existing) {
575+ if (existing.emoji === emoji) {
576+ await trx
577+ .deleteFrom("patch_reactions")
578+ .where("id", "=", existing.id)
579+ .execute();
580+ } else {
581+ await trx
582+ .updateTable("patch_reactions")
583+ .set({ emoji })
584+ .where("id", "=", existing.id)
585+ .execute();
586+ }
587+ } else {
588+ await trx
589+ .insertInto("patch_reactions")
590+ .values({
591+ patch_id: patch.id,
592+ comment_id: commentId,
593+ user_id: user!.id,
594+ emoji,
595+ })
596+ .execute();
597+ }
598+ });
599+
600+ return new Response(null, {
601+ status: 303,
602+ headers: { Location: `/${repo.name}/patches/${patchNum}` },
603+ });
604+ },
605+ {
606+ body: t.Object({
607+ emoji: t.String(),
608+ comment_id: t.Optional(t.String()),
609+ }),
610+ },
611+ )
612+
613+ .post(
614+ "/:repo/patches/:number/comments/:id/edit",
615+ async ({ params, body, cookie }) => {
616+ const user = await resolveSession(cookie.session.value);
617+ const deny = requireAuth(user);
618+ if (deny) return deny;
619+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
620+ if (!repo) return new Response("Not found", { status: 404 });
621+
622+ const comment = await db
623+ .selectFrom("patch_comments")
624+ .select(["id", "author_id"])
625+ .where("id", "=", params.id)
626+ .executeTakeFirst();
627+ if (!comment) return new Response("Not found", { status: 404 });
628+ if (comment.author_id !== user?.id && !user?.isAdmin)
629+ return new Response("Forbidden", { status: 403 });
630+
631+ const patchNum = parseInt(params.number, 10);
632+ await db
633+ .updateTable("patch_comments")
634+ .set({
635+ body: body.edit_body.trim(),
636+ edited_at: new Date().toISOString(),
637+ })
638+ .where("id", "=", comment.id)
639+ .execute();
640+
641+ return new Response(null, {
642+ status: 302,
643+ headers: { Location: `/${repo.name}/patches/${patchNum}` },
644+ });
645+ },
646+ {
647+ params: t.Object({
648+ repo: t.String(),
649+ number: t.String(),
650+ id: t.Numeric(),
651+ }),
652+ body: t.Object({ edit_body: t.String() }),
653+ },
654+ )
655+
656+ .post(
657+ "/:repo/patches/:number/edit",
658+ async ({ params, body, cookie }) => {
659+ const user = await resolveSession(cookie.session.value);
660+ const deny = requireAuth(user);
661+ if (deny) return deny;
662+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
663+ if (!repo) return new Response("Not found", { status: 404 });
664+
665+ const patchNum = parseInt(params.number, 10);
666+ const patch = await db
667+ .selectFrom("patches")
668+ .select(["id", "author_id"])
669+ .where("repo_id", "=", repo.id)
670+ .where("number", "=", patchNum)
671+ .executeTakeFirst();
672+ if (!patch) return new Response("Not found", { status: 404 });
673+ if (patch.author_id !== user?.id && !user?.isAdmin)
674+ return new Response("Forbidden", { status: 403 });
675+
676+ await db
677+ .updateTable("patches")
678+ .set({
679+ title: body.title.trim(),
680+ description: body.edit_description ?? "",
681+ edited_at: new Date().toISOString(),
682+ updated_at: new Date().toISOString(),
683+ })
684+ .where("id", "=", patch.id)
685+ .execute();
686+
687+ return new Response(null, {
688+ status: 302,
689+ headers: { Location: `/${repo.name}/patches/${patchNum}` },
690+ });
691+ },
692+ {
693+ body: t.Object({
694+ title: t.String(),
695+ edit_description: t.Optional(t.String()),
696+ }),
697+ },
698+ );
Asrc/routes/releases.tsx
@@ -0,0 +1,575 @@
1+import { mkdirSync, rmSync } from "node:fs";
2+import path from "node:path";
3+import { Elysia, t } from "elysia";
4+import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts";
5+import { db, getRepo } from "../db/index.ts";
6+import { requireAdmin, resolveSession } from "../middleware/session.ts";
7+import { archiveRepo, validateCommit } from "../services/git.ts";
8+import { renderMarkdown } from "../services/markdown.ts";
9+import { NewRelease } from "../views/releases/NewRelease.tsx";
10+import { ReleaseDetail } from "../views/releases/ReleaseDetail.tsx";
11+import { ReleaseList } from "../views/releases/ReleaseList.tsx";
12+import { html } from "../views/render.tsx";
13+
14+// Tracks AbortControllers for source archive generation tasks that are
15+// currently in progress, keyed by release ID. Used to cancel generation
16+// immediately when the corresponding release is deleted.
17+const archivingTasks = new Map<number, AbortController>();
18+
19+function sanitizeFilename(name: string): string {
20+ const safe = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, "_");
21+ if (!safe || /^\.+$/.test(safe)) return "_";
22+ return safe;
23+}
24+
25+export const releasesRoutes = new Elysia()
26+ .guard({
27+ cookie: t.Cookie({ session: t.Optional(t.String()) }),
28+ })
29+
30+ .get(
31+ "/:repo/releases",
32+ async ({ params, query, cookie }) => {
33+ const user = await resolveSession(cookie.session.value);
34+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
35+ if (!repo) return new Response("Not found", { status: 404 });
36+
37+ const page = Math.max(1, query.page ?? 1);
38+
39+ const countRow = await db
40+ .selectFrom("releases")
41+ .select(db.fn.countAll<number>().as("count"))
42+ .where("repo_id", "=", repo.id)
43+ .executeTakeFirst();
44+ const totalPages = Math.max(
45+ 1,
46+ Math.ceil(Number(countRow?.count ?? 0) / RELEASES_PER_PAGE),
47+ );
48+ const safePage = Math.min(page, totalPages);
49+ const offset = (safePage - 1) * RELEASES_PER_PAGE;
50+
51+ const releasesRaw = await db
52+ .selectFrom("releases")
53+ .selectAll()
54+ .where("repo_id", "=", repo.id)
55+ .orderBy("id", "desc")
56+ .limit(RELEASES_PER_PAGE)
57+ .offset(offset)
58+ .execute();
59+
60+ // Attach asset counts
61+ const releaseIds = releasesRaw.map((r) => r.id);
62+ const assetCounts =
63+ releaseIds.length > 0
64+ ? await db
65+ .selectFrom("release_assets")
66+ .select([
67+ "release_id",
68+ db.fn.countAll<number>().as("count"),
69+ ])
70+ .where("release_id", "in", releaseIds)
71+ .groupBy("release_id")
72+ .execute()
73+ : [];
74+ const countMap = new Map(
75+ assetCounts.map((r) => [r.release_id, Number(r.count)]),
76+ );
77+
78+ const releases = releasesRaw.map((r) => ({
79+ ...r,
80+ asset_count: countMap.get(r.id) ?? 0,
81+ }));
82+
83+ const pagination = {
84+ page: safePage,
85+ totalPages,
86+ pageUrlTemplate: `/${repo.name}/releases?page={page}`,
87+ };
88+
89+ return html(
90+ <ReleaseList
91+ user={user}
92+ repo={repo}
93+ releases={releases}
94+ pagination={pagination}
95+ />,
96+ );
97+ },
98+ {
99+ query: t.Object({
100+ page: t.Optional(t.Numeric()),
101+ }),
102+ },
103+ )
104+
105+ .get("/:repo/releases/new", async ({ params, cookie }) => {
106+ const user = await resolveSession(cookie.session.value);
107+ const deny = requireAdmin(user);
108+ if (deny) return deny;
109+ const repo = await getRepo(params.repo, true);
110+ if (!repo) return new Response("Not found", { status: 404 });
111+ return html(<NewRelease user={user!} repo={repo} />);
112+ })
113+
114+ .post(
115+ "/:repo/releases",
116+ async ({ params, body, cookie }) => {
117+ const user = await resolveSession(cookie.session.value);
118+ const deny = requireAdmin(user);
119+ if (deny) return deny;
120+ const repo = await getRepo(params.repo, true);
121+ if (!repo) return new Response("Not found", { status: 404 });
122+
123+ const tagName = body.tag_name?.trim() ?? "";
124+ const commitHash = body.commit_hash?.trim() ?? "";
125+
126+ if (!tagName) {
127+ return html(
128+ <NewRelease
129+ user={user!}
130+ repo={repo}
131+ error="Tag name is required"
132+ values={{
133+ ...body,
134+ include_source_code:
135+ body.include_source_code === "on",
136+ }}
137+ />,
138+ );
139+ }
140+ if (tagName.includes("/")) {
141+ return html(
142+ <NewRelease
143+ user={user!}
144+ repo={repo}
145+ error="Tag name must not contain slashes"
146+ values={{
147+ ...body,
148+ include_source_code:
149+ body.include_source_code === "on",
150+ }}
151+ />,
152+ );
153+ }
154+ if (!commitHash) {
155+ return html(
156+ <NewRelease
157+ user={user!}
158+ repo={repo}
159+ error="Commit hash is required"
160+ values={{
161+ ...body,
162+ include_source_code:
163+ body.include_source_code === "on",
164+ }}
165+ />,
166+ );
167+ }
168+
169+ const validCommit = await validateCommit(repo.name, commitHash);
170+ if (!validCommit) {
171+ return html(
172+ <NewRelease
173+ user={user!}
174+ repo={repo}
175+ error="Invalid commit hash — no matching commit found in this repository"
176+ values={{
177+ ...body,
178+ include_source_code:
179+ body.include_source_code === "on",
180+ }}
181+ />,
182+ );
183+ }
184+
185+ // Check for duplicate tag
186+ const existing = await db
187+ .selectFrom("releases")
188+ .select("id")
189+ .where("repo_id", "=", repo.id)
190+ .where("tag_name", "=", tagName)
191+ .executeTakeFirst();
192+ if (existing) {
193+ return html(
194+ <NewRelease
195+ user={user!}
196+ repo={repo}
197+ error={`A release with tag "${tagName}" already exists`}
198+ values={{
199+ ...body,
200+ include_source_code:
201+ body.include_source_code === "on",
202+ }}
203+ />,
204+ );
205+ }
206+
207+ const includeSource = body.include_source_code === "on";
208+ const now = new Date().toISOString();
209+
210+ // Collect uploaded file data before opening the transaction so we
211+ // don't hold it open across slow I/O.
212+ const rawFiles = body.files;
213+ const uploadedFiles: {
214+ filename: string;
215+ data: Blob;
216+ size: number;
217+ contentType: string;
218+ }[] = [];
219+ if (rawFiles) {
220+ const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
221+ for (const file of files) {
222+ if (file.size === 0) continue;
223+ uploadedFiles.push({
224+ filename: sanitizeFilename(file.name),
225+ data: file,
226+ size: file.size,
227+ contentType: file.type || "application/octet-stream",
228+ });
229+ }
230+ }
231+
232+ // Insert the release record and all asset records in one transaction
233+ // so that partial failures don't leave orphaned DB rows.
234+ // releaseDir is captured inside the callback so the catch can clean
235+ // up files even though the auto-increment ID isn't known until after
236+ // the INSERT.
237+ let releaseDir: string | null = null;
238+ const releaseId = await db
239+ .transaction()
240+ .execute(async (trx) => {
241+ const inserted = await trx
242+ .insertInto("releases")
243+ .values({
244+ repo_id: repo.id,
245+ tag_name: tagName,
246+ name: body.name?.trim() || null,
247+ notes: body.notes?.trim() || null,
248+ commit_hash: commitHash,
249+ include_source_code: includeSource ? 1 : 0,
250+ created_at: now,
251+ })
252+ .returning("id")
253+ .executeTakeFirstOrThrow();
254+
255+ const id = inserted.id;
256+ releaseDir = path.join(RELEASES_DIR, String(id));
257+
258+ if (includeSource) {
259+ const sourceDir = path.join(releaseDir, "source");
260+ mkdirSync(sourceDir, { recursive: true });
261+ // Write a sentinel file; the actual archives are
262+ // generated asynchronously after the response is sent.
263+ await Bun.write(path.join(sourceDir, ".pending"), "");
264+ }
265+
266+ if (uploadedFiles.length > 0) {
267+ const assetsDir = path.join(releaseDir, "assets");
268+ mkdirSync(assetsDir, { recursive: true });
269+ for (const f of uploadedFiles) {
270+ await Bun.write(
271+ path.join(assetsDir, f.filename),
272+ f.data,
273+ );
274+ await trx
275+ .insertInto("release_assets")
276+ .values({
277+ release_id: id,
278+ filename: f.filename,
279+ size: f.size,
280+ content_type: f.contentType,
281+ created_at: now,
282+ })
283+ .execute();
284+ }
285+ }
286+
287+ return id;
288+ })
289+ .catch((err) => {
290+ // Roll back any partially-written files if the transaction
291+ // failed — the DB rollback handles the DB side automatically.
292+ if (releaseDir) {
293+ rmSync(releaseDir, { recursive: true, force: true });
294+ }
295+ throw err;
296+ });
297+
298+ // Kick off source archive generation in the background so the
299+ // response can be sent immediately. The .pending sentinel written
300+ // inside the transaction signals to the detail view that archives
301+ // are still being prepared. If the release is deleted while
302+ // generation is in progress the background task will hit errors
303+ // (the directory will have been removed) and silently bail out;
304+ // SQLite AUTOINCREMENT guarantees the ID is never reused, so there
305+ // is no risk of contaminating a later release.
306+ if (includeSource) {
307+ const sourceDir = path.join(
308+ RELEASES_DIR,
309+ String(releaseId),
310+ "source",
311+ );
312+ const controller = new AbortController();
313+ archivingTasks.set(releaseId, controller);
314+ (async () => {
315+ try {
316+ await archiveRepo(
317+ repo.name,
318+ commitHash,
319+ repo.name,
320+ sourceDir,
321+ controller.signal,
322+ );
323+ rmSync(path.join(sourceDir, ".pending"), {
324+ force: true,
325+ });
326+ } catch {
327+ // Either the release was deleted (abort) or archiving
328+ // failed. Remove the source dir so the UI shows no
329+ // stale state.
330+ rmSync(sourceDir, { recursive: true, force: true });
331+ } finally {
332+ archivingTasks.delete(releaseId);
333+ }
334+ })();
335+ }
336+
337+ return new Response(null, {
338+ status: 302,
339+ headers: {
340+ Location: `/${repo.name}/releases/${releaseId}`,
341+ },
342+ });
343+ },
344+ {
345+ body: t.Object({
346+ tag_name: t.Optional(t.String()),
347+ name: t.Optional(t.String()),
348+ notes: t.Optional(t.String()),
349+ commit_hash: t.Optional(t.String()),
350+ include_source_code: t.Optional(t.String()),
351+ files: t.Optional(t.Union([t.File(), t.Array(t.File())])),
352+ }),
353+ type: "multipart/form-data",
354+ },
355+ )
356+
357+ .get(
358+ "/:repo/releases/:id",
359+ async ({ params, cookie }) => {
360+ const user = await resolveSession(cookie.session.value);
361+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
362+ if (!repo) return new Response("Not found", { status: 404 });
363+
364+ const release = await db
365+ .selectFrom("releases")
366+ .selectAll()
367+ .where("repo_id", "=", repo.id)
368+ .where("id", "=", params.id)
369+ .executeTakeFirst();
370+ if (!release) return new Response("Not found", { status: 404 });
371+
372+ const assets = await db
373+ .selectFrom("release_assets")
374+ .selectAll()
375+ .where("release_id", "=", release.id)
376+ .orderBy("id", "asc")
377+ .execute();
378+
379+ const notesHtml = release.notes
380+ ? renderMarkdown(release.notes)
381+ : "";
382+
383+ // Detect which source archives exist on disk, and whether
384+ // generation is still in progress (indicated by a .pending file).
385+ const sourceArchives: {
386+ format: string;
387+ filename: string;
388+ size: number;
389+ }[] = [];
390+ let sourceArchivesPending = false;
391+ if (release.include_source_code) {
392+ const base = `${repo.name}-${release.commit_hash.slice(0, 8)}`;
393+ const sourceDir = path.join(
394+ RELEASES_DIR,
395+ String(release.id),
396+ "source",
397+ );
398+ if (await Bun.file(path.join(sourceDir, ".pending")).exists()) {
399+ sourceArchivesPending = true;
400+ } else {
401+ for (const [format, ext] of [
402+ ["zip", ".zip"],
403+ ["tar.gz", ".tar.gz"],
404+ ["tar.zst", ".tar.zst"],
405+ ] as const) {
406+ const filePath = path.join(sourceDir, `${base}${ext}`);
407+ const f = Bun.file(filePath);
408+ if (await f.exists()) {
409+ sourceArchives.push({
410+ format,
411+ filename: `${base}${ext}`,
412+ size: f.size,
413+ });
414+ }
415+ }
416+ }
417+ }
418+
419+ return html(
420+ <ReleaseDetail
421+ user={user}
422+ repo={repo}
423+ release={release}
424+ notesHtml={notesHtml}
425+ assets={assets}
426+ sourceArchives={sourceArchives}
427+ sourceArchivesPending={sourceArchivesPending}
428+ />,
429+ );
430+ },
431+ {
432+ params: t.Object({
433+ repo: t.String(),
434+ id: t.Numeric(),
435+ }),
436+ },
437+ )
438+
439+ .post(
440+ "/:repo/releases/:id/delete",
441+ async ({ params, cookie }) => {
442+ const user = await resolveSession(cookie.session.value);
443+ const deny = requireAdmin(user);
444+ if (deny) return deny;
445+ const repo = await getRepo(params.repo, true);
446+ if (!repo) return new Response("Not found", { status: 404 });
447+
448+ const release = await db
449+ .selectFrom("releases")
450+ .select("id")
451+ .where("repo_id", "=", repo.id)
452+ .where("id", "=", params.id)
453+ .executeTakeFirst();
454+ if (!release) return new Response("Not found", { status: 404 });
455+
456+ // Abort any in-progress archive generation before touching disk so
457+ // the background task doesn't race with the rmSync below.
458+ archivingTasks.get(release.id)?.abort();
459+ archivingTasks.delete(release.id);
460+
461+ // Remove files from disk before the DB record so that a crash
462+ // between the two leaves a broken-but-visible repo rather than a
463+ // DB record pointing to missing files.
464+ const releaseDir = path.join(RELEASES_DIR, String(release.id));
465+ rmSync(releaseDir, { recursive: true, force: true });
466+ await db
467+ .deleteFrom("releases")
468+ .where("id", "=", release.id)
469+ .execute();
470+
471+ return new Response(null, {
472+ status: 302,
473+ headers: { Location: `/${repo.name}/releases` },
474+ });
475+ },
476+ {
477+ params: t.Object({
478+ repo: t.String(),
479+ id: t.Numeric(),
480+ }),
481+ },
482+ )
483+
484+ .get(
485+ "/:repo/releases/:id/assets/:filename",
486+ async ({ params, cookie }) => {
487+ const user = await resolveSession(cookie.session.value);
488+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
489+ if (!repo) return new Response("Not found", { status: 404 });
490+
491+ const release = await db
492+ .selectFrom("releases")
493+ .select("id")
494+ .where("repo_id", "=", repo.id)
495+ .where("id", "=", params.id)
496+ .executeTakeFirst();
497+ if (!release) return new Response("Not found", { status: 404 });
498+
499+ const safeFilename = path.basename(params.filename);
500+ const asset = await db
501+ .selectFrom("release_assets")
502+ .selectAll()
503+ .where("release_id", "=", release.id)
504+ .where("filename", "=", safeFilename)
505+ .executeTakeFirst();
506+ if (!asset) return new Response("Not found", { status: 404 });
507+
508+ const filePath = path.join(
509+ RELEASES_DIR,
510+ String(release.id),
511+ "assets",
512+ safeFilename,
513+ );
514+ const file = Bun.file(filePath);
515+ if (!(await file.exists()))
516+ return new Response("Not found", { status: 404 });
517+
518+ return new Response(file, {
519+ headers: {
520+ "Content-Disposition": `attachment; filename="${safeFilename}"`,
521+ "Content-Type": "application/octet-stream",
522+ },
523+ });
524+ },
525+ {
526+ params: t.Object({
527+ repo: t.String(),
528+ id: t.Numeric(),
529+ filename: t.String(),
530+ }),
531+ },
532+ )
533+
534+ .get(
535+ "/:repo/releases/:id/source/:filename",
536+ async ({ params, cookie }) => {
537+ const user = await resolveSession(cookie.session.value);
538+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
539+ if (!repo) return new Response("Not found", { status: 404 });
540+
541+ const release = await db
542+ .selectFrom("releases")
543+ .select(["id", "include_source_code"])
544+ .where("repo_id", "=", repo.id)
545+ .where("id", "=", params.id)
546+ .executeTakeFirst();
547+ if (!release || !release.include_source_code)
548+ return new Response("Not found", { status: 404 });
549+
550+ const safeFilename = path.basename(params.filename);
551+ const filePath = path.join(
552+ RELEASES_DIR,
553+ String(release.id),
554+ "source",
555+ safeFilename,
556+ );
557+ const file = Bun.file(filePath);
558+ if (!(await file.exists()))
559+ return new Response("Not found", { status: 404 });
560+
561+ return new Response(file, {
562+ headers: {
563+ "Content-Disposition": `attachment; filename="${safeFilename}"`,
564+ "Content-Type": "application/octet-stream",
565+ },
566+ });
567+ },
568+ {
569+ params: t.Object({
570+ repo: t.String(),
571+ id: t.Numeric(),
572+ filename: t.String(),
573+ }),
574+ },
575+ );
Asrc/routes/repos.tsx
@@ -0,0 +1,617 @@
1+import { rmSync } from "node:fs";
2+import path from "node:path";
3+import { Elysia, t } from "elysia";
4+import { fileTypeFromBuffer } from "file-type";
5+import {
6+ COMMITS_PER_PAGE,
7+ REPOS_PER_PAGE,
8+ VALID_REPO_NAME_RE,
9+} from "../constants.ts";
10+import { db } from "../db/index.ts";
11+import { requireAdmin, resolveSession } from "../middleware/session.ts";
12+import { git, repoPath } from "../services/git.ts";
13+import {
14+ hasBinaryContent,
15+ prepareDiff,
16+ serveFile,
17+} from "../services/highlightWorker.ts";
18+import { renderMarkdown } from "../services/markdown.ts";
19+import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
20+import { html } from "../views/render.tsx";
21+import { CommitDetail } from "../views/repos/CommitDetail.tsx";
22+import { CommitLog } from "../views/repos/CommitLog.tsx";
23+import { FileBlob } from "../views/repos/FileBlob.tsx";
24+import { FileTree } from "../views/repos/FileTree.tsx";
25+import { NewRepo } from "../views/repos/NewRepo.tsx";
26+import { RepoHome } from "../views/repos/RepoHome.tsx";
27+import { RepoList } from "../views/repos/RepoList.tsx";
28+import { RepoSettings } from "../views/repos/RepoSettings.tsx";
29+
30+async function getRepo(name: string, isAdmin: boolean) {
31+ if (!repoDiskExists(name)) return null;
32+ const repo = await ensureRepoRecord(name);
33+ if (repo.is_private && !isAdmin) return null;
34+ return repo;
35+}
36+
37+async function mimeForContent(content: Buffer): Promise<string> {
38+ const result = await fileTypeFromBuffer(content);
39+ if (result) return result.mime;
40+ return hasBinaryContent(content.subarray(0, 8000))
41+ ? "text/plain; charset=utf-8"
42+ : "application/octet-stream";
43+}
44+
45+async function readReadme(
46+ repo: string,
47+ ref: string,
48+ dir = "",
49+): Promise<Buffer | null> {
50+ const prefix = dir ? `${dir}/` : "";
51+ const [md, mdLc, readme, readmeLc] = await Promise.all([
52+ git.show(repo, ref, `${prefix}README.md`),
53+ git.show(repo, ref, `${prefix}readme.md`),
54+ git.show(repo, ref, `${prefix}README`),
55+ git.show(repo, ref, `${prefix}readme`),
56+ ]);
57+ return md ?? mdLc ?? readme ?? readmeLc;
58+}
59+
60+export const repoRoutes = new Elysia()
61+ .guard({
62+ cookie: t.Cookie({ session: t.Optional(t.String()) }),
63+ })
64+ .get(
65+ "/",
66+ async ({ cookie, query }) => {
67+ const user = await resolveSession(cookie.session.value);
68+ const search = query.q?.trim() || undefined;
69+ const page = Math.max(1, query.page ?? 1);
70+
71+ const isAdmin = user?.isAdmin ?? false;
72+
73+ const countResult = await db
74+ .selectFrom("repositories")
75+ .select(db.fn.countAll<number>().as("count"))
76+ .where((eb) =>
77+ isAdmin
78+ ? eb.or([
79+ eb("is_private", "=", 0),
80+ eb("is_private", "=", 1),
81+ ])
82+ : eb("is_private", "=", 0),
83+ )
84+ .$if(!!search, (qb) =>
85+ qb.where((eb) =>
86+ eb.or([
87+ eb("name", "like", `%${search}%`),
88+ eb("description", "like", `%${search}%`),
89+ ]),
90+ ),
91+ )
92+ .executeTakeFirst();
93+
94+ const totalCount = Number(countResult?.count ?? 0);
95+ const totalPages = Math.max(
96+ 1,
97+ Math.ceil(totalCount / REPOS_PER_PAGE),
98+ );
99+ const safePage = Math.min(page, totalPages);
100+
101+ const repos = await db
102+ .selectFrom("repositories")
103+ .selectAll()
104+ .where((eb) =>
105+ isAdmin
106+ ? eb.or([
107+ eb("is_private", "=", 0),
108+ eb("is_private", "=", 1),
109+ ])
110+ : eb("is_private", "=", 0),
111+ )
112+ .$if(!!search, (qb) =>
113+ qb.where((eb) =>
114+ eb.or([
115+ eb("name", "like", `%${search}%`),
116+ eb("description", "like", `%${search}%`),
117+ ]),
118+ ),
119+ )
120+ .orderBy("created_at", "desc")
121+ .limit(REPOS_PER_PAGE)
122+ .offset((safePage - 1) * REPOS_PER_PAGE)
123+ .execute();
124+
125+ const searchParam = search
126+ ? `&q=${encodeURIComponent(search)}`
127+ : "";
128+ const pagination = {
129+ page: safePage,
130+ totalPages,
131+ pageUrlTemplate: `/?page={page}${searchParam}`,
132+ };
133+
134+ return html(
135+ <RepoList
136+ user={user}
137+ repos={repos}
138+ search={search}
139+ pagination={pagination}
140+ />,
141+ );
142+ },
143+ {
144+ query: t.Object({
145+ q: t.Optional(t.String()),
146+ page: t.Optional(t.Numeric()),
147+ }),
148+ },
149+ )
150+
151+ .get("/new", async ({ cookie }) => {
152+ const user = await resolveSession(cookie.session.value);
153+ const deny = requireAdmin(user);
154+ if (deny) return deny;
155+ return html(<NewRepo user={user!} />);
156+ })
157+
158+ .post(
159+ "/new",
160+ async ({ body, cookie }) => {
161+ const user = await resolveSession(cookie.session.value);
162+ const deny = requireAdmin(user);
163+ if (deny) return deny;
164+
165+ const { name, description, is_private, default_branch } = body;
166+
167+ if (!VALID_REPO_NAME_RE.test(name)) {
168+ return html(
169+ <NewRepo user={user!} error="Invalid repository name" />,
170+ );
171+ }
172+
173+ const branch = (default_branch?.trim() || "main").replace(
174+ /[^a-zA-Z0-9._/-]/g,
175+ "",
176+ );
177+
178+ const existing = await db
179+ .selectFrom("repositories")
180+ .select("id")
181+ .where("name", "=", name)
182+ .executeTakeFirst();
183+ if (existing) {
184+ return html(
185+ <NewRepo
186+ user={user!}
187+ error="Repository name already taken"
188+ />,
189+ );
190+ }
191+
192+ const now = new Date().toISOString();
193+ await db
194+ .insertInto("repositories")
195+ .values({
196+ name,
197+ description: description || null,
198+ is_private: is_private === "1" ? 1 : 0,
199+ default_branch: branch,
200+ created_at: now,
201+ })
202+ .execute();
203+
204+ // Initialise the git repo after the DB record is committed. If
205+ // git.init fails we roll back the DB record so the two stay in sync.
206+ try {
207+ await git.init(name, branch);
208+ } catch (err) {
209+ await db
210+ .deleteFrom("repositories")
211+ .where("name", "=", name)
212+ .execute();
213+ throw err;
214+ }
215+ return new Response(null, {
216+ status: 302,
217+ headers: { Location: `/${name}` },
218+ });
219+ },
220+ {
221+ body: t.Object({
222+ name: t.String(),
223+ description: t.Optional(t.String()),
224+ is_private: t.Optional(t.String()),
225+ default_branch: t.Optional(t.String()),
226+ }),
227+ },
228+ )
229+
230+ .get("/:repo", async ({ params, cookie }) => {
231+ const user = await resolveSession(cookie.session.value);
232+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
233+ if (!repo) return new Response("Not found", { status: 404 });
234+
235+ const hasContent = await git.hasCommits(repo.name);
236+ let readmeHtml: string | null = null;
237+ let entries: Awaited<ReturnType<typeof git.lsTree>> = [];
238+ let branches: string[] = [];
239+
240+ if (hasContent) {
241+ const [lsResult, branchResult, resolved] = await Promise.all([
242+ git.lsTree(repo.name, repo.default_branch),
243+ git.branches(repo.name),
244+ git.resolveRef(repo.name, repo.default_branch),
245+ ]);
246+ entries = lsResult;
247+ branches = branchResult;
248+ const readmeBuf = await readReadme(repo.name, repo.default_branch);
249+ if (readmeBuf) {
250+ const key = resolved
251+ ? `readme:${repo.name}:${resolved}:`
252+ : undefined;
253+ readmeHtml = renderMarkdown(readmeBuf.toString("utf-8"), key);
254+ }
255+ }
256+
257+ return html(
258+ <RepoHome
259+ user={user}
260+ repo={repo}
261+ entries={entries}
262+ readmeHtml={readmeHtml}
263+ hasContent={hasContent}
264+ branches={branches}
265+ />,
266+ );
267+ })
268+
269+ .get(
270+ "/:repo/branch-switch",
271+ async ({ params, query, cookie }) => {
272+ const user = await resolveSession(cookie.session.value);
273+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
274+ if (!repo) return new Response("Not found", { status: 404 });
275+
276+ const ref = query.ref?.trim();
277+ if (!ref)
278+ return new Response(null, {
279+ status: 302,
280+ headers: { Location: `/${repo.name}` },
281+ });
282+
283+ const view = query.view;
284+ const subpath = query.path ?? "";
285+
286+ if (view === "commits") {
287+ return new Response(null, {
288+ status: 302,
289+ headers: { Location: `/${repo.name}/commits/${ref}` },
290+ });
291+ }
292+ if (view === "blob" && subpath) {
293+ return new Response(null, {
294+ status: 302,
295+ headers: {
296+ Location: `/${repo.name}/blob/${ref}/${subpath}`,
297+ },
298+ });
299+ }
300+ const location = subpath
301+ ? `/${repo.name}/tree/${ref}/${subpath}`
302+ : `/${repo.name}/tree/${ref}`;
303+ return new Response(null, {
304+ status: 302,
305+ headers: { Location: location },
306+ });
307+ },
308+ {
309+ query: t.Object({
310+ ref: t.Optional(t.String()),
311+ view: t.Optional(t.String()),
312+ path: t.Optional(t.String()),
313+ }),
314+ },
315+ )
316+
317+ .get("/:repo/tree/:ref", async ({ params, cookie }) => {
318+ const user = await resolveSession(cookie.session.value);
319+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
320+ if (!repo) return new Response("Not found", { status: 404 });
321+
322+ const resolved = await git.resolveRef(repo.name, params.ref);
323+ if (!resolved) return new Response("Not found", { status: 404 });
324+
325+ const [entries, branches] = await Promise.all([
326+ git.lsTree(repo.name, params.ref),
327+ git.branches(repo.name),
328+ ]);
329+ const readmeBuf = await readReadme(repo.name, params.ref);
330+ const readmeHtml = readmeBuf
331+ ? renderMarkdown(
332+ readmeBuf.toString("utf-8"),
333+ `readme:${repo.name}:${resolved}:`,
334+ )
335+ : null;
336+ return html(
337+ <FileTree
338+ user={user}
339+ repo={repo}
340+ ref={params.ref}
341+ subpath=""
342+ entries={entries}
343+ branches={branches}
344+ readmeHtml={readmeHtml}
345+ />,
346+ );
347+ })
348+
349+ .get("/:repo/tree/:ref/*", async ({ params, cookie }) => {
350+ const user = await resolveSession(cookie.session.value);
351+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
352+ if (!repo) return new Response("Not found", { status: 404 });
353+
354+ const resolved = await git.resolveRef(repo.name, params.ref);
355+ if (!resolved) return new Response("Not found", { status: 404 });
356+
357+ const subpath = params["*"];
358+ const [entries, branches] = await Promise.all([
359+ git.lsTree(repo.name, params.ref, subpath),
360+ git.branches(repo.name),
361+ ]);
362+ if (entries.length === 0) {
363+ // Could be a file — redirect to blob
364+ return new Response(null, {
365+ status: 302,
366+ headers: {
367+ Location: `/${repo.name}/blob/${params.ref}/${subpath}`,
368+ },
369+ });
370+ }
371+ const readmeBuf = await readReadme(repo.name, params.ref, subpath);
372+ const readmeHtml = readmeBuf
373+ ? renderMarkdown(
374+ readmeBuf.toString("utf-8"),
375+ `readme:${repo.name}:${resolved}:${subpath}`,
376+ )
377+ : null;
378+ return html(
379+ <FileTree
380+ user={user}
381+ repo={repo}
382+ ref={params.ref}
383+ subpath={subpath}
384+ entries={entries}
385+ branches={branches}
386+ readmeHtml={readmeHtml}
387+ />,
388+ );
389+ })
390+
391+ .get("/:repo/blob/:ref/*", async ({ params, cookie }) => {
392+ const user = await resolveSession(cookie.session.value);
393+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
394+ if (!repo) return new Response("Not found", { status: 404 });
395+
396+ const filePath = params["*"];
397+ const [content, branches, commitSHA] = await Promise.all([
398+ git.show(repo.name, params.ref, filePath),
399+ git.branches(repo.name),
400+ git.resolveRef(repo.name, params.ref),
401+ ]);
402+ if (!content || !commitSHA)
403+ return new Response("Not found", { status: 404 });
404+
405+ const filename = path.basename(filePath);
406+ const view = await serveFile(
407+ content,
408+ filename,
409+ `${repo.name}:${commitSHA}:${filePath}`,
410+ );
411+ return html(
412+ <FileBlob
413+ user={user}
414+ repo={repo}
415+ ref={params.ref}
416+ filePath={filePath}
417+ view={view}
418+ branches={branches}
419+ />,
420+ );
421+ })
422+
423+ .get("/:repo/raw/:ref/*", async ({ params, cookie }) => {
424+ const user = await resolveSession(cookie.session.value);
425+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
426+ if (!repo) return new Response("Not found", { status: 404 });
427+
428+ const filePath = params["*"];
429+ const content = await git.show(repo.name, params.ref, filePath);
430+ if (!content) return new Response("Not found", { status: 404 });
431+
432+ const filename = path.basename(filePath);
433+ const contentType = await mimeForContent(content);
434+ return new Response(content, {
435+ headers: {
436+ "Content-Type": contentType,
437+ "Content-Disposition": `inline; filename="${filename}"`,
438+ "Content-Length": String(content.length),
439+ },
440+ });
441+ })
442+
443+ .get(
444+ "/:repo/commits/:ref",
445+ async ({ params, cookie, query }) => {
446+ const user = await resolveSession(cookie.session.value);
447+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
448+ if (!repo) return new Response("Not found", { status: 404 });
449+
450+ // Cursor-based pagination — O(1) regardless of history depth.
451+ // `after` = SHA of the last commit on the previous page (resume cursor).
452+ // `prev` = the `after` value used on the page that linked here, so we can
453+ // reconstruct a "← Newer" link without a full history traversal.
454+ const after = query.after?.trim() || null;
455+ const prev = query.prev?.trim() || null;
456+
457+ const [rawCommits, branches] = await Promise.all([
458+ // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
459+ // then fetch LIMIT+1 to detect whether another page exists.
460+ after
461+ ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
462+ : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
463+ git.branches(repo.name),
464+ ]);
465+
466+ const hasNext = rawCommits.length > COMMITS_PER_PAGE;
467+ const commits = rawCommits.slice(0, COMMITS_PER_PAGE);
468+
469+ // Build cursor URLs.
470+ // "Older" advances past the last commit on this page.
471+ // "Newer" goes back one page using the `prev` cursor saved in the URL,
472+ // or to the first page if we're on page 2.
473+ const base = `/${repo.name}/commits/${params.ref}`;
474+ const olderUrl = hasNext
475+ ? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}`
476+ : null;
477+ const newerUrl = after
478+ ? prev
479+ ? `${base}?after=${prev}`
480+ : base
481+ : null;
482+
483+ return html(
484+ <CommitLog
485+ user={user}
486+ repo={repo}
487+ ref={params.ref}
488+ commits={commits}
489+ branches={branches}
490+ olderUrl={olderUrl}
491+ newerUrl={newerUrl}
492+ />,
493+ );
494+ },
495+ {
496+ query: t.Object({
497+ after: t.Optional(t.String()),
498+ prev: t.Optional(t.String()),
499+ }),
500+ },
501+ )
502+
503+ .get("/:repo/commit/:sha", async ({ params, cookie }) => {
504+ const user = await resolveSession(cookie.session.value);
505+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
506+ if (!repo) return new Response("Not found", { status: 404 });
507+
508+ const [meta, rawDiff] = await Promise.all([
509+ git.commitMeta(repo.name, params.sha),
510+ git.diff(repo.name, params.sha),
511+ ]);
512+ if (!meta) return new Response("Commit not found", { status: 404 });
513+ const files = await prepareDiff(
514+ rawDiff,
515+ `commit:${repo.name}:${params.sha}`,
516+ );
517+ return html(
518+ <CommitDetail
519+ user={user}
520+ repo={repo}
521+ sha={params.sha}
522+ meta={meta}
523+ files={files}
524+ />,
525+ );
526+ })
527+
528+ .get("/:repo/settings", async ({ params, cookie }) => {
529+ const user = await resolveSession(cookie.session.value);
530+ const deny = requireAdmin(user);
531+ if (deny) return deny;
532+ const repo = await getRepo(params.repo, true);
533+ if (!repo) return new Response("Not found", { status: 404 });
534+ const branches = await git.branches(repo.name);
535+ return html(
536+ <RepoSettings user={user!} repo={repo} branches={branches} />,
537+ );
538+ })
539+
540+ .post(
541+ "/:repo/settings",
542+ async ({ params, body, cookie }) => {
543+ const user = await resolveSession(cookie.session.value);
544+ const deny = requireAdmin(user);
545+ if (deny) return deny;
546+ const repo = await getRepo(params.repo, true);
547+ if (!repo) return new Response("Not found", { status: 404 });
548+
549+ const { description, is_private, default_branch } = body;
550+
551+ const branches = await git.branches(repo.name);
552+ const newBranch = default_branch?.trim() || repo.default_branch;
553+
554+ // Validate the selected branch exists (only if repo has commits)
555+ if (branches.length > 0 && !branches.includes(newBranch)) {
556+ return html(
557+ <RepoSettings
558+ user={user!}
559+ repo={repo}
560+ branches={branches}
561+ error={`Branch "${newBranch}" does not exist.`}
562+ />,
563+ );
564+ }
565+
566+ await db
567+ .updateTable("repositories")
568+ .set({
569+ description: description?.trim() || null,
570+ is_private: is_private === "1" ? 1 : 0,
571+ default_branch: newBranch,
572+ })
573+ .where("id", "=", repo.id)
574+ .execute();
575+
576+ // Keep git HEAD in sync if the branch actually exists
577+ if (branches.includes(newBranch)) {
578+ await git.setHead(repo.name, newBranch).catch(() => {});
579+ }
580+
581+ const updated = await db
582+ .selectFrom("repositories")
583+ .selectAll()
584+ .where("id", "=", repo.id)
585+ .executeTakeFirstOrThrow();
586+ return html(
587+ <RepoSettings
588+ user={user!}
589+ repo={updated}
590+ branches={branches}
591+ success="Settings saved."
592+ />,
593+ );
594+ },
595+ {
596+ body: t.Object({
597+ description: t.Optional(t.String()),
598+ is_private: t.Optional(t.String()),
599+ default_branch: t.Optional(t.String()),
600+ }),
601+ },
602+ )
603+
604+ .post("/:repo/settings/delete", async ({ params, cookie }) => {
605+ const user = await resolveSession(cookie.session.value);
606+ const deny = requireAdmin(user);
607+ if (deny) return deny;
608+ const repo = await getRepo(params.repo, true);
609+ if (!repo) return new Response("Not found", { status: 404 });
610+
611+ // Remove the on-disk repo first. If this fails (e.g. permission error),
612+ // we abort before touching the DB so the repo remains accessible.
613+ rmSync(repoPath(repo.name), { recursive: true, force: true });
614+ await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
615+
616+ return new Response(null, { status: 302, headers: { Location: "/" } });
617+ });
Asrc/routes/settings.tsx
@@ -0,0 +1,433 @@
1+import * as argon2 from "argon2";
2+import { Elysia, t } from "elysia";
3+import { utils as sshUtils } from "ssh2";
4+import {
5+ ADMIN_USERNAME,
6+ VALID_KEY_TYPES,
7+ VALID_USERNAME_RE,
8+} from "../constants.ts";
9+import { db } from "../db";
10+import { redirect } from "../lib/redirect.ts";
11+import { resolveSession } from "../middleware/session.ts";
12+import { createDefaultAvatar } from "../services/avatar.ts";
13+import { fingerprintFromLine } from "../services/sshServer.ts";
14+import { html } from "../views/render.tsx";
15+import { Settings } from "../views/Settings.tsx";
16+
17+export const settingsRoutes = new Elysia()
18+ .guard({
19+ cookie: t.Cookie({
20+ session: t.Optional(t.String()),
21+ theme: t.Optional(t.String()),
22+ }),
23+ })
24+
25+ .get(
26+ "/settings",
27+ async ({ cookie, query }) => {
28+ const user = await resolveSession(
29+ cookie.session?.value as string | undefined,
30+ );
31+ if (!user) return redirect("/login");
32+
33+ const { success, error } = query;
34+
35+ const userRow = await db
36+ .selectFrom("users")
37+ .select(["id", "password_hash"])
38+ .where("id", "=", user.id)
39+ .executeTakeFirst();
40+
41+ const passkeys = await db
42+ .selectFrom("passkeys")
43+ .select(["id", "created_at"])
44+ .where("user_id", "=", user.id)
45+ .execute();
46+
47+ const sshKeys = await db
48+ .selectFrom("ssh_keys")
49+ .select(["id", "name", "fingerprint", "created_at"])
50+ .where("user_id", "=", user.id)
51+ .execute();
52+
53+ const theme = (cookie.theme?.value as string | undefined) ?? "auto";
54+ const hasPassword = !!userRow?.password_hash;
55+
56+ const decodedError = error
57+ ? decodeURIComponent((error as string).replace(/\+/g, " "))
58+ : null;
59+
60+ return html(
61+ <Settings
62+ user={user}
63+ hasPassword={hasPassword}
64+ passkeys={passkeys}
65+ sshKeys={sshKeys}
66+ theme={theme}
67+ success={(success as string | null) ?? null}
68+ error={decodedError}
69+ />,
70+ );
71+ },
72+ {
73+ query: t.Object({
74+ success: t.Optional(t.String()),
75+ error: t.Optional(t.String()),
76+ }),
77+ },
78+ )
79+
80+ .post(
81+ "/settings/password",
82+ async ({ cookie, body }) => {
83+ const user = await resolveSession(
84+ cookie.session?.value as string | undefined,
85+ );
86+ if (!user) return redirect("/login");
87+
88+ const { current_password, new_password, confirm_password } = body;
89+
90+ if (!new_password || new_password.length < 8) {
91+ return redirect(
92+ "/settings?error=Password+must+be+at+least+8+characters",
93+ );
94+ }
95+ if (new_password !== confirm_password) {
96+ return redirect("/settings?error=Passwords+do+not+match");
97+ }
98+
99+ const userRow = await db
100+ .selectFrom("users")
101+ .select(["id", "password_hash"])
102+ .where("id", "=", user.id)
103+ .executeTakeFirst();
104+
105+ if (userRow?.password_hash) {
106+ if (!current_password) {
107+ return redirect(
108+ "/settings?error=Current+password+is+required",
109+ );
110+ }
111+ const valid = await argon2.verify(
112+ userRow.password_hash,
113+ current_password,
114+ );
115+ if (!valid) {
116+ return redirect(
117+ "/settings?error=Current+password+is+incorrect",
118+ );
119+ }
120+ }
121+
122+ const hash = await argon2.hash(new_password);
123+ await db
124+ .updateTable("users")
125+ .set({ password_hash: hash })
126+ .where("id", "=", user.id)
127+ .execute();
128+
129+ return redirect("/settings?success=password");
130+ },
131+ {
132+ body: t.Object({
133+ current_password: t.Optional(t.String()),
134+ new_password: t.String(),
135+ confirm_password: t.String(),
136+ }),
137+ },
138+ )
139+
140+ .post(
141+ "/settings/password/remove",
142+ async ({ cookie }) => {
143+ const user = await resolveSession(
144+ cookie.session?.value as string | undefined,
145+ );
146+ if (!user) return redirect("/login");
147+
148+ const passkeys = await db
149+ .selectFrom("passkeys")
150+ .select(["id"])
151+ .where("user_id", "=", user.id)
152+ .execute();
153+
154+ if (passkeys.length === 0) {
155+ return redirect(
156+ "/settings?error=Cannot+remove+password+without+a+passkey",
157+ );
158+ }
159+
160+ await db
161+ .updateTable("users")
162+ .set({ password_hash: null })
163+ .where("id", "=", user.id)
164+ .execute();
165+
166+ return redirect("/settings?success=password_removed");
167+ },
168+ {
169+ body: t.Object({}),
170+ },
171+ )
172+
173+ .post(
174+ "/settings/passkey/revoke",
175+ async ({ cookie, body }) => {
176+ const user = await resolveSession(
177+ cookie.session?.value as string | undefined,
178+ );
179+ if (!user) return redirect("/login");
180+
181+ const { id } = body;
182+
183+ const passkey = await db
184+ .selectFrom("passkeys")
185+ .select(["id", "user_id"])
186+ .where("id", "=", id)
187+ .executeTakeFirst();
188+
189+ if (!passkey || passkey.user_id !== user.id) {
190+ return redirect("/settings?error=Passkey+not+found");
191+ }
192+
193+ const userRow = await db
194+ .selectFrom("users")
195+ .select(["password_hash"])
196+ .where("id", "=", user.id)
197+ .executeTakeFirst();
198+
199+ const hasPassword = !!userRow?.password_hash;
200+
201+ // Wrap the count check and delete in a transaction so that two
202+ // concurrent revocations can't both pass the "last auth method"
203+ // guard and both succeed.
204+ let lastAuthMethod = false;
205+ await db.transaction().execute(async (trx) => {
206+ const allPasskeys = await trx
207+ .selectFrom("passkeys")
208+ .select(["id"])
209+ .where("user_id", "=", user.id)
210+ .execute();
211+
212+ const remaining = allPasskeys.filter((p) => p.id !== id);
213+ if (!hasPassword && remaining.length === 0) {
214+ lastAuthMethod = true;
215+ return;
216+ }
217+
218+ await trx.deleteFrom("passkeys").where("id", "=", id).execute();
219+ });
220+
221+ if (lastAuthMethod) {
222+ return redirect(
223+ "/settings?error=Cannot+revoke+last+auth+method",
224+ );
225+ }
226+
227+ return redirect("/settings?success=passkey_revoked");
228+ },
229+ {
230+ body: t.Object({ id: t.Numeric() }),
231+ },
232+ )
233+
234+ .post(
235+ "/settings/theme",
236+ async ({ cookie, body }) => {
237+ const _user = await resolveSession(
238+ cookie.session?.value as string | undefined,
239+ );
240+ // Theme can be set even without auth, but we check session for settings redirect
241+ const { theme } = body;
242+
243+ if (!["auto", "light", "dark"].includes(theme)) {
244+ return redirect("/settings?error=Invalid+theme");
245+ }
246+
247+ const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`;
248+ return redirect("/settings?success=theme", cookieHeader);
249+ },
250+ {
251+ body: t.Object({ theme: t.String() }),
252+ },
253+ )
254+
255+ .post(
256+ "/admin/users",
257+ async ({ cookie, body }) => {
258+ const user = await resolveSession(
259+ cookie.session?.value as string | undefined,
260+ );
261+ if (!user) return redirect("/login");
262+ if (!user.isAdmin)
263+ return new Response("Forbidden", { status: 403 });
264+
265+ const { username, password } = body;
266+
267+ if (!VALID_USERNAME_RE.test(username)) {
268+ return redirect(
269+ "/settings?error=Username+may+only+contain+letters,+numbers,+hyphens,+and+underscores",
270+ );
271+ }
272+ if (username === ADMIN_USERNAME) {
273+ return redirect("/settings?error=That+username+is+reserved");
274+ }
275+ if (!password || password.length < 8) {
276+ return redirect(
277+ "/settings?error=Password+must+be+at+least+8+characters",
278+ );
279+ }
280+
281+ const existing = await db
282+ .selectFrom("users")
283+ .select("id")
284+ .where("username", "=", username)
285+ .executeTakeFirst();
286+
287+ if (existing) {
288+ return redirect("/settings?error=Username+already+taken");
289+ }
290+
291+ const hash = await argon2.hash(password);
292+ const now = new Date().toISOString();
293+ const result = await db
294+ .insertInto("users")
295+ .values({
296+ username,
297+ password_hash: hash,
298+ created_at: now,
299+ })
300+ .executeTakeFirstOrThrow();
301+ await createDefaultAvatar(Number(result.insertId), username);
302+
303+ return redirect("/settings?success=user_created");
304+ },
305+ {
306+ body: t.Object({ username: t.String(), password: t.String() }),
307+ },
308+ )
309+
310+ .post(
311+ "/admin/users/delete",
312+ async ({ cookie, body }) => {
313+ const user = await resolveSession(
314+ cookie.session?.value as string | undefined,
315+ );
316+ if (!user) return redirect("/login");
317+ if (!user.isAdmin)
318+ return new Response("Forbidden", { status: 403 });
319+
320+ const { username } = body;
321+ if (username === ADMIN_USERNAME) {
322+ return redirect("/settings?error=Cannot+delete+admin+user");
323+ }
324+
325+ const targetUser = await db
326+ .selectFrom("users")
327+ .select("id")
328+ .where("username", "=", username)
329+ .executeTakeFirst();
330+
331+ if (!targetUser) {
332+ return redirect("/settings?error=User+not+found");
333+ }
334+
335+ await db
336+ .deleteFrom("users")
337+ .where("id", "=", targetUser.id)
338+ .execute();
339+
340+ return redirect("/settings?success=user_deleted");
341+ },
342+ {
343+ body: t.Object({ username: t.String() }),
344+ },
345+ )
346+
347+ .post(
348+ "/settings/ssh-keys",
349+ async ({ cookie, body }) => {
350+ const user = await resolveSession(
351+ cookie.session?.value as string | undefined,
352+ );
353+ if (!user) return redirect("/login");
354+
355+ const { name, public_key } = body;
356+ const keyLine = public_key.trim();
357+ const parts = keyLine.split(/\s+/);
358+
359+ if (!VALID_KEY_TYPES.has(parts[0] ?? "")) {
360+ return redirect("/settings?error=Unsupported+key+type");
361+ }
362+
363+ // Validate the key is parseable
364+ try {
365+ const parsed = sshUtils.parseKey(
366+ Buffer.from(parts[1] ?? "", "base64"),
367+ );
368+ if (parsed instanceof Error) throw parsed;
369+ } catch {
370+ return redirect("/settings?error=Invalid+public+key");
371+ }
372+
373+ const fingerprint = fingerprintFromLine(keyLine);
374+ if (!fingerprint)
375+ return redirect("/settings?error=Invalid+public+key");
376+
377+ try {
378+ await db
379+ .insertInto("ssh_keys")
380+ .values({
381+ user_id: user.id,
382+ name: name.trim() || "Unnamed key",
383+ public_key: keyLine,
384+ fingerprint,
385+ created_at: new Date().toISOString(),
386+ })
387+ .execute();
388+ } catch (err) {
389+ if (
390+ err instanceof Error &&
391+ err.message.includes(
392+ "UNIQUE constraint failed: ssh_keys.fingerprint",
393+ )
394+ ) {
395+ return redirect(
396+ "/settings?error=This+key+is+already+registered",
397+ );
398+ }
399+ throw err;
400+ }
401+
402+ return redirect("/settings?success=ssh_key_added");
403+ },
404+ {
405+ body: t.Object({ name: t.String(), public_key: t.String() }),
406+ },
407+ )
408+
409+ .post(
410+ "/settings/ssh-keys/delete",
411+ async ({ cookie, body }) => {
412+ const user = await resolveSession(
413+ cookie.session?.value as string | undefined,
414+ );
415+ if (!user) return redirect("/login");
416+
417+ const key = await db
418+ .selectFrom("ssh_keys")
419+ .select(["id", "user_id"])
420+ .where("id", "=", body.id)
421+ .executeTakeFirst();
422+
423+ if (!key || key.user_id !== user.id) {
424+ return redirect("/settings?error=Key+not+found");
425+ }
426+
427+ await db.deleteFrom("ssh_keys").where("id", "=", body.id).execute();
428+ return redirect("/settings?success=ssh_key_deleted");
429+ },
430+ {
431+ body: t.Object({ id: t.Numeric() }),
432+ },
433+ );
Asrc/services/avatar.ts
@@ -0,0 +1,123 @@
1+import { mkdirSync } from "node:fs";
2+import path from "node:path";
3+import { encode } from "@jsquash/jxl";
4+import { fileTypeFromBuffer } from "file-type";
5+import sharp from "sharp";
6+import { AVATARS_DIR } from "../constants.ts";
7+
8+mkdirSync(AVATARS_DIR, { recursive: true });
9+
10+// FNV-1a: produces n deterministic bytes from a string
11+function hashBytes(s: string, n: number): number[] {
12+ const out: number[] = [];
13+ let h = 0x811c9dc5;
14+ for (const c of s) {
15+ h ^= c.charCodeAt(0);
16+ h = Math.imul(h, 0x01000193) >>> 0;
17+ }
18+ while (out.length < n) {
19+ h = Math.imul(h ^ (out.length & 0xff), 0x01000193) >>> 0;
20+ out.push(
21+ h & 0xff,
22+ (h >>> 8) & 0xff,
23+ (h >>> 16) & 0xff,
24+ (h >>> 24) & 0xff,
25+ );
26+ }
27+ return out.slice(0, n);
28+}
29+
30+function hslToRgb(h: number, s: number, l: number): [number, number, number] {
31+ s /= 100;
32+ l /= 100;
33+ const a = s * Math.min(l, 1 - l);
34+ const f = (n: number) => {
35+ const k = (n + h / 30) % 12;
36+ return Math.round(
37+ (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255,
38+ );
39+ };
40+ return [f(0), f(8), f(4)];
41+}
42+
43+export function avatarJxlPath(userId: number): string {
44+ return path.join(AVATARS_DIR, `${userId}.jxl`);
45+}
46+
47+export async function processAndStoreAvatar(
48+ userId: number,
49+ buffer: Buffer,
50+): Promise<void> {
51+ const type = await fileTypeFromBuffer(buffer);
52+ if (!type?.mime.startsWith("image/")) {
53+ throw new Error("Invalid image type");
54+ }
55+ const { data, info } = await sharp(buffer)
56+ .resize(128, 128, { fit: "cover", position: "center" })
57+ .ensureAlpha()
58+ .raw()
59+ .toBuffer({ resolveWithObject: true });
60+
61+ const jxl = await encode(
62+ {
63+ data: new Uint8ClampedArray(
64+ data.buffer,
65+ data.byteOffset,
66+ data.byteLength,
67+ ),
68+ width: info.width,
69+ height: info.height,
70+ },
71+ {
72+ progressive: true,
73+ quality: 90,
74+ effort: 9,
75+ },
76+ );
77+ await Bun.write(avatarJxlPath(userId), jxl);
78+}
79+
80+export async function createDefaultAvatar(
81+ userId: number,
82+ username: string,
83+): Promise<void> {
84+ const b = hashBytes(username, 16);
85+
86+ const hue = (b[0]! | (b[1]! << 8)) % 360;
87+ const sat = (b[2]! % 20) + 65; // 65–84%
88+ const lit = (b[3]! % 20) + 40; // 40–59%
89+ const [fr, fg, fb] = hslToRgb(hue, sat, lit);
90+
91+ // 128×128, background #f0f0f0, 5×5 symmetric identicon
92+ // PADDING=24, CELL=16: 2×24 + 5×16 = 128
93+ const SIZE = 128,
94+ PADDING = 24,
95+ CELL = 16;
96+ const pixels = new Uint8ClampedArray(SIZE * SIZE * 4).fill(255);
97+ for (let i = 0; i < SIZE * SIZE * 4; i += 4) {
98+ pixels[i] = 240;
99+ pixels[i + 1] = 240;
100+ pixels[i + 2] = 240;
101+ }
102+
103+ // 5×5 symmetric identicon (col mapping: 0→0, 1→1, 2→2, 3→1, 4→0)
104+ for (let row = 0; row < 5; row++) {
105+ for (let col = 0; col < 5; col++) {
106+ const srcCol = col < 3 ? col : 4 - col;
107+ if ((b[row * 3 + srcCol]! & 1) === 0) continue;
108+ const x0 = PADDING + col * CELL;
109+ const y0 = PADDING + row * CELL;
110+ for (let y = y0; y < y0 + CELL; y++) {
111+ for (let x = x0; x < x0 + CELL; x++) {
112+ const i = (y * SIZE + x) * 4;
113+ pixels[i] = fr;
114+ pixels[i + 1] = fg;
115+ pixels[i + 2] = fb;
116+ }
117+ }
118+ }
119+ }
120+
121+ const jxl = await encode({ data: pixels, width: SIZE, height: SIZE });
122+ await Bun.write(avatarJxlPath(userId), jxl);
123+}
Asrc/services/diffHighlight.ts
@@ -0,0 +1,268 @@
1+import path from "node:path";
2+import type { BundledLanguage } from "shiki";
3+import { MAX_DIFF_CACHE } from "../constants.ts";
4+import { detectLang, getHighlighter } from "./highlight.ts";
5+
6+// ─── Types ───────────────────────────────────────────────────────────────────
7+
8+export type DiffStatus =
9+ | "added"
10+ | "deleted"
11+ | "modified"
12+ | "renamed"
13+ | "copied";
14+
15+export interface RenderedRow {
16+ type: "add" | "del" | "context";
17+ oldLine: number | null;
18+ newLine: number | null;
19+ html: string;
20+}
21+
22+export interface RenderedHunk {
23+ header: string;
24+ rows: RenderedRow[];
25+}
26+
27+export interface RenderedDiffFile {
28+ oldPath: string;
29+ newPath: string;
30+ status: DiffStatus;
31+ added: number;
32+ removed: number;
33+ isBinary: boolean;
34+ hunks: RenderedHunk[];
35+}
36+
37+// ─── Parser ──────────────────────────────────────────────────────────────────
38+
39+interface ParsedLine {
40+ type: "add" | "del" | "context";
41+ content: string;
42+}
43+
44+interface ParsedHunk {
45+ header: string;
46+ oldStart: number;
47+ newStart: number;
48+ lines: ParsedLine[];
49+}
50+
51+export interface ParsedFile {
52+ oldPath: string;
53+ newPath: string;
54+ status: DiffStatus;
55+ added: number;
56+ removed: number;
57+ isBinary: boolean;
58+ hunks: ParsedHunk[];
59+}
60+
61+export function parseDiff(raw: string): ParsedFile[] {
62+ const files: ParsedFile[] = [];
63+ const allLines = raw.split("\n");
64+ let i = 0;
65+
66+ while (i < allLines.length) {
67+ if (!allLines[i]!.startsWith("diff --git ")) {
68+ i++;
69+ continue;
70+ }
71+
72+ const m = allLines[i]!.match(/^diff --git a\/(.+) b\/(.+)$/);
73+ const fallback = m ? (m[2] ?? "") : "";
74+ const file: ParsedFile = {
75+ oldPath: fallback,
76+ newPath: fallback,
77+ status: "modified",
78+ added: 0,
79+ removed: 0,
80+ isBinary: false,
81+ hunks: [],
82+ };
83+ i++;
84+
85+ while (i < allLines.length) {
86+ const line = allLines[i]!;
87+ if (line.startsWith("diff --git ") || line.startsWith("@@ ")) break;
88+ if (line.startsWith("new file")) file.status = "added";
89+ else if (line.startsWith("deleted file")) file.status = "deleted";
90+ else if (line.startsWith("rename from ")) {
91+ file.status = "renamed";
92+ file.oldPath = line.slice(12);
93+ } else if (line.startsWith("rename to ")) {
94+ file.newPath = line.slice(10);
95+ } else if (line.startsWith("copy from ")) {
96+ file.status = "copied";
97+ file.oldPath = line.slice(10);
98+ } else if (line.startsWith("copy to ")) {
99+ file.newPath = line.slice(8);
100+ } else if (line.startsWith("--- ") && line !== "--- /dev/null")
101+ file.oldPath = line.slice(6);
102+ else if (line.startsWith("+++ ") && line !== "+++ /dev/null")
103+ file.newPath = line.slice(6);
104+ else if (line.startsWith("Binary files ")) file.isBinary = true;
105+ i++;
106+ }
107+
108+ while (i < allLines.length && allLines[i]!.startsWith("@@ ")) {
109+ const hm = allLines[i]!.match(
110+ /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/,
111+ );
112+ const hunk: ParsedHunk = {
113+ header: allLines[i]!,
114+ oldStart: hm ? parseInt(hm[1]!, 10) : 1,
115+ newStart: hm ? parseInt(hm[2]!, 10) : 1,
116+ lines: [],
117+ };
118+ i++;
119+ while (
120+ i < allLines.length &&
121+ !allLines[i]!.startsWith("@@ ") &&
122+ !allLines[i]!.startsWith("diff --git ")
123+ ) {
124+ const l = allLines[i]!;
125+ if (l.startsWith("+")) {
126+ hunk.lines.push({ type: "add", content: l.slice(1) });
127+ file.added++;
128+ } else if (l.startsWith("-")) {
129+ hunk.lines.push({ type: "del", content: l.slice(1) });
130+ file.removed++;
131+ } else if (l.startsWith(" ")) {
132+ hunk.lines.push({ type: "context", content: l.slice(1) });
133+ }
134+ // skip "\ No newline at end of file"
135+ i++;
136+ }
137+ file.hunks.push(hunk);
138+ }
139+
140+ files.push(file);
141+ }
142+ return files;
143+}
144+
145+function escapeHtml(s: string): string {
146+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
147+}
148+
149+// ─── Highlight a hunk ────────────────────────────────────────────────────────
150+
151+async function highlightHunk(
152+ hunk: ParsedHunk,
153+ lang: string,
154+): Promise<string[]> {
155+ if (hunk.lines.length === 0) return [];
156+ const code = hunk.lines.map((l) => l.content).join("\n");
157+ try {
158+ const h = await getHighlighter();
159+ const tokensByLine = h.codeToTokensWithThemes(code, {
160+ lang: lang as BundledLanguage,
161+ themes: { light: "github-light", dark: "github-dark" },
162+ });
163+ const lines = tokensByLine.map((lineTokens) =>
164+ lineTokens
165+ .map((token) => {
166+ const style = Object.entries(token.variants)
167+ .map(
168+ ([theme, v]) =>
169+ `--shiki-${theme}:${v.color ?? "inherit"}`,
170+ )
171+ .join(";");
172+ return `<span style="${style}">${escapeHtml(token.content)}</span>`;
173+ })
174+ .join(""),
175+ );
176+ while (lines.length < hunk.lines.length) lines.push("");
177+ return lines;
178+ } catch {
179+ return hunk.lines.map((l) => escapeHtml(l.content));
180+ }
181+}
182+
183+// ─── Build rendered rows ──────────────────────────────────────────────────────
184+
185+function buildRows(hunk: ParsedHunk, highlighted: string[]): RenderedRow[] {
186+ const rows: RenderedRow[] = [];
187+ let oldLine = hunk.oldStart;
188+ let newLine = hunk.newStart;
189+ for (let i = 0; i < hunk.lines.length; i++) {
190+ const { type } = hunk.lines[i]!;
191+ const html = highlighted[i] ?? "";
192+ if (type === "context") {
193+ rows.push({ type, oldLine: oldLine++, newLine: newLine++, html });
194+ } else if (type === "add") {
195+ rows.push({ type, oldLine: null, newLine: newLine++, html });
196+ } else {
197+ rows.push({ type, oldLine: oldLine++, newLine: null, html });
198+ }
199+ }
200+ return rows;
201+}
202+
203+// ─── Highlight a single parsed file ──────────────────────────────────────────
204+
205+export async function highlightFile(
206+ file: ParsedFile,
207+): Promise<RenderedDiffFile> {
208+ const displayPath = file.newPath || file.oldPath;
209+ const lang = detectLang(path.basename(displayPath));
210+ const highlightedHunks = await Promise.all(
211+ file.hunks.map((hunk) => highlightHunk(hunk, lang)),
212+ );
213+ const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
214+ header: hunk.header,
215+ rows: buildRows(hunk, highlightedHunks[i]!),
216+ }));
217+ return {
218+ oldPath: file.oldPath,
219+ newPath: file.newPath,
220+ status: file.status,
221+ added: file.added,
222+ removed: file.removed,
223+ isBinary: file.isBinary,
224+ hunks,
225+ };
226+}
227+
228+// ─── Public API ──────────────────────────────────────────────────────────────
229+
230+const diffCache = new Map<string, RenderedDiffFile[]>();
231+
232+export async function prepareDiff(
233+ rawDiff: string,
234+ cacheKey: string,
235+): Promise<RenderedDiffFile[]> {
236+ const cached = diffCache.get(cacheKey);
237+ if (cached) return cached;
238+
239+ const parsed = parseDiff(rawDiff);
240+ const result = await Promise.all(
241+ parsed.map(async (file) => {
242+ const displayPath = file.newPath || file.oldPath;
243+ const lang = detectLang(path.basename(displayPath));
244+ const highlightedHunks = await Promise.all(
245+ file.hunks.map((hunk) => highlightHunk(hunk, lang)),
246+ );
247+ const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
248+ header: hunk.header,
249+ rows: buildRows(hunk, highlightedHunks[i]!),
250+ }));
251+ return {
252+ oldPath: file.oldPath,
253+ newPath: file.newPath,
254+ status: file.status,
255+ added: file.added,
256+ removed: file.removed,
257+ isBinary: file.isBinary,
258+ hunks,
259+ };
260+ }),
261+ );
262+ if (cacheKey) {
263+ if (diffCache.size >= MAX_DIFF_CACHE)
264+ diffCache.delete(diffCache.keys().next().value!);
265+ diffCache.set(cacheKey, result);
266+ }
267+ return result;
268+}
Asrc/services/git.ts
@@ -0,0 +1,438 @@
1+import path from "node:path";
2+import { $ as _$ } from "bun";
3+
4+import { REPOS_DIR } from "../constants.ts";
5+
6+const $ = _$.env({ ...process.env, LC_ALL: "C", LANG: "C" });
7+
8+// Per-repo mutex: prevents concurrent git write operations on the same repo
9+// (e.g. two patches being merged simultaneously, which would corrupt the index).
10+const repoWriteLocks = new Map<string, Promise<void>>();
11+
12+async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
13+ const prev = repoWriteLocks.get(name) ?? Promise.resolve();
14+ let unlock!: () => void;
15+ repoWriteLocks.set(
16+ name,
17+ prev.then(
18+ () =>
19+ new Promise<void>((res) => {
20+ unlock = res;
21+ }),
22+ ),
23+ );
24+ await prev;
25+ try {
26+ return await fn();
27+ } finally {
28+ unlock();
29+ }
30+}
31+
32+export function repoPath(name: string): string {
33+ return path.join(REPOS_DIR, `${name}.git`);
34+}
35+
36+export async function validateCommit(
37+ repoName: string,
38+ hash: string,
39+): Promise<boolean> {
40+ const p = repoPath(repoName);
41+ try {
42+ const out = await $`git -C ${p} cat-file -t ${hash}`.text();
43+ return out.trim() === "commit";
44+ } catch {
45+ return false;
46+ }
47+}
48+
49+export async function archiveRepo(
50+ repoName: string,
51+ commitHash: string,
52+ slug: string,
53+ outDir: string,
54+ signal?: AbortSignal,
55+): Promise<void> {
56+ const p = repoPath(repoName);
57+ const base = `${slug}-${commitHash.slice(0, 8)}`;
58+ const env = { ...process.env, LC_ALL: "C", LANG: "C" };
59+
60+ const zip = Bun.spawn(
61+ [
62+ "git",
63+ "-C",
64+ p,
65+ "archive",
66+ "--format=zip",
67+ `--output=${path.join(outDir, `${base}.zip`)}`,
68+ commitHash,
69+ ],
70+ { signal, env },
71+ );
72+ if ((await zip.exited) !== 0) throw new Error("git archive (zip) failed");
73+
74+ const tgz = Bun.spawn(
75+ [
76+ "git",
77+ "-C",
78+ p,
79+ "archive",
80+ "--format=tar.gz",
81+ `--output=${path.join(outDir, `${base}.tar.gz`)}`,
82+ commitHash,
83+ ],
84+ { signal, env },
85+ );
86+ if ((await tgz.exited) !== 0)
87+ throw new Error("git archive (tar.gz) failed");
88+
89+ try {
90+ const tar = Bun.spawn(
91+ ["git", "-C", p, "archive", "--format=tar", commitHash],
92+ { signal, env, stdout: "pipe" },
93+ );
94+ const zst = Bun.spawn(
95+ ["zstd", "-o", path.join(outDir, `${base}.tar.zst`)],
96+ { signal, env, stdin: tar.stdout },
97+ );
98+ await Promise.all([tar.exited, zst.exited]);
99+ } catch {
100+ // zstd not available — skip silently
101+ }
102+}
103+
104+export interface CommitEntry {
105+ hash: string;
106+ subject: string;
107+ author: string;
108+ date: string;
109+}
110+
111+export interface CommitMeta {
112+ hash: string;
113+ subject: string;
114+ body: string;
115+ author: string;
116+ email: string;
117+ date: string;
118+ parents: string[];
119+}
120+
121+export interface TreeEntry {
122+ mode: string;
123+ type: "blob" | "tree";
124+ hash: string;
125+ size: string;
126+ name: string;
127+}
128+
129+function parseLog(out: string): CommitEntry[] {
130+ return out
131+ .split("\n")
132+ .filter(Boolean)
133+ .map((line) => {
134+ const parts = line.split("\x1f");
135+ return {
136+ hash: parts[0] ?? "",
137+ subject: parts[1] ?? "",
138+ author: parts[2] ?? "",
139+ date: parts[3] ?? "",
140+ };
141+ });
142+}
143+
144+function parseLsTree(out: string): TreeEntry[] {
145+ return out
146+ .split("\n")
147+ .filter(Boolean)
148+ .map((line) => {
149+ // format: <mode> SP <type> SP <object> SP <object size> TAB <file>
150+ const tabIdx = line.indexOf("\t");
151+ const name = line.slice(tabIdx + 1);
152+ const meta = line.slice(0, tabIdx).trim().split(/\s+/);
153+ return {
154+ mode: meta[0] ?? "",
155+ type: (meta[1] ?? "blob") as "blob" | "tree",
156+ hash: meta[2] ?? "",
157+ size: meta[3] ?? "-",
158+ name,
159+ };
160+ });
161+}
162+
163+function extractPatchSubject(patch: string): string {
164+ for (const line of patch.split("\n").slice(0, 30)) {
165+ if (line.startsWith("Subject: ")) {
166+ // Strip "[PATCH ...] " prefix added by git format-patch
167+ return line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
168+ }
169+ }
170+ return "";
171+}
172+
173+export const git = {
174+ async init(name: string, branch = "main") {
175+ return withRepoLock(name, async () => {
176+ const p = repoPath(name);
177+ await $`git init --bare --initial-branch=${branch} ${p}`;
178+ });
179+ },
180+
181+ async log(
182+ name: string,
183+ ref = "HEAD",
184+ limit = 30,
185+ skip = 0,
186+ ): Promise<CommitEntry[]> {
187+ const p = repoPath(name);
188+ try {
189+ const out =
190+ await $`git -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai --max-count=${limit} --skip=${skip}`.text();
191+ return parseLog(out);
192+ } catch {
193+ return [];
194+ }
195+ },
196+
197+ async lsTree(
198+ name: string,
199+ ref: string,
200+ subpath = "",
201+ ): Promise<TreeEntry[]> {
202+ const p = repoPath(name);
203+ try {
204+ const args = subpath
205+ ? [
206+ "git",
207+ "-C",
208+ p,
209+ "ls-tree",
210+ "--long",
211+ ref,
212+ "--",
213+ `${subpath}/`,
214+ ]
215+ : ["git", "-C", p, "ls-tree", "--long", ref];
216+ const out = await $`${args}`.text();
217+ const entries = parseLsTree(out);
218+ if (subpath) {
219+ // git ls-tree returns full paths like "subpath/name" — strip the prefix
220+ const prefix = `${subpath}/`;
221+ return entries.map((e) => ({
222+ ...e,
223+ name: e.name.startsWith(prefix)
224+ ? e.name.slice(prefix.length)
225+ : e.name,
226+ }));
227+ }
228+ return entries;
229+ } catch {
230+ return [];
231+ }
232+ },
233+
234+ async show(
235+ name: string,
236+ ref: string,
237+ filePath: string,
238+ ): Promise<Buffer | null> {
239+ const p = repoPath(name);
240+ try {
241+ const buf =
242+ await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer();
243+ return Buffer.from(buf);
244+ } catch {
245+ return null;
246+ }
247+ },
248+
249+ async diff(name: string, sha: string): Promise<string> {
250+ const p = repoPath(name);
251+ try {
252+ return await $`git -C ${p} diff-tree --no-commit-id -r -p --root ${sha}`.text();
253+ } catch {
254+ return "";
255+ }
256+ },
257+
258+ async branches(name: string): Promise<string[]> {
259+ const p = repoPath(name);
260+ try {
261+ // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
262+ const fmt = "%(refname:short)";
263+ const out = await $`git -C ${p} branch --format=${fmt}`.text();
264+ return out.split("\n").filter(Boolean);
265+ } catch {
266+ return [];
267+ }
268+ },
269+
270+ async defaultBranch(name: string): Promise<string> {
271+ const p = repoPath(name);
272+ try {
273+ const fmt = "%(refname:short)";
274+ const branchesOut =
275+ await $`git -C ${p} branch --format=${fmt}`.text();
276+ const branches = branchesOut.split("\n").filter(Boolean);
277+
278+ // Read what HEAD points to (may be an unborn branch).
279+ let headBranch: string | null = null;
280+ try {
281+ const out =
282+ await $`git -C ${p} symbolic-ref --short HEAD`.text();
283+ headBranch = out.trim();
284+ } catch {
285+ // detached HEAD — fall through
286+ }
287+
288+ // Only trust HEAD if it names a branch that actually exists.
289+ if (headBranch && branches.includes(headBranch)) {
290+ return headBranch;
291+ }
292+
293+ // HEAD points to an unborn branch or is detached — prefer "main",
294+ // then "master", then whatever branch exists first.
295+ return (
296+ branches.find((b) => b === "main") ??
297+ branches.find((b) => b === "master") ??
298+ branches[0] ??
299+ "main"
300+ );
301+ } catch {
302+ return "main";
303+ }
304+ },
305+
306+ async getFileSize(
307+ name: string,
308+ ref: string,
309+ filePath: string,
310+ ): Promise<number | null> {
311+ const p = repoPath(name);
312+ try {
313+ const out =
314+ await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
315+ return parseInt(out.trim(), 10);
316+ } catch {
317+ return null;
318+ }
319+ },
320+
321+ async checkPatch(
322+ name: string,
323+ patchContent: string,
324+ ): Promise<{ clean: boolean; output: string }> {
325+ const p = repoPath(name);
326+ const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
327+ try {
328+ await Bun.write(tmpFile, patchContent);
329+ // Bare repos have no working tree; populate the index from HEAD so we can
330+ // check against git objects (--cached) rather than the filesystem.
331+ await $`git -C ${p} read-tree HEAD`.quiet();
332+ const result =
333+ await $`git -C ${p} apply --check --cached ${tmpFile}`
334+ .quiet()
335+ .nothrow();
336+ return {
337+ clean: result.exitCode === 0,
338+ output: result.stderr.toString(),
339+ };
340+ } catch (e) {
341+ return { clean: false, output: String(e) };
342+ } finally {
343+ await $`rm -f ${tmpFile}`.quiet().nothrow();
344+ }
345+ },
346+
347+ async applyPatch(
348+ name: string,
349+ patchContent: string,
350+ title: string,
351+ description?: string,
352+ ): Promise<void> {
353+ return withRepoLock(name, async () => {
354+ const p = repoPath(name);
355+ const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
356+ try {
357+ await Bun.write(tmpFile, patchContent);
358+ // Populate index, apply to index, then create a real commit in the bare repo.
359+ await $`git -C ${p} read-tree HEAD`;
360+ await $`git -C ${p} apply --cached ${tmpFile}`;
361+ const tree = (await $`git -C ${p} write-tree`.text()).trim();
362+ const parent = (
363+ await $`git -C ${p} rev-parse HEAD`.text()
364+ ).trim();
365+ const fallback = description?.trim()
366+ ? `${title}\n\n${description.trim()}`
367+ : title;
368+ const msg = extractPatchSubject(patchContent) || fallback;
369+ const commit = (
370+ await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`.text()
371+ ).trim();
372+ const ref = (
373+ await $`git -C ${p} symbolic-ref HEAD`.text()
374+ ).trim();
375+ await $`git -C ${p} update-ref ${ref} ${commit}`;
376+ } finally {
377+ await $`rm -f ${tmpFile}`.quiet().nothrow();
378+ }
379+ });
380+ },
381+
382+ async setHead(name: string, branch: string): Promise<void> {
383+ const p = repoPath(name);
384+ await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
385+ },
386+
387+ async resolveRef(name: string, ref: string): Promise<string | null> {
388+ const p = repoPath(name);
389+ try {
390+ const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
391+ return out.trim() || null;
392+ } catch {
393+ return null;
394+ }
395+ },
396+
397+ async hasCommits(name: string): Promise<boolean> {
398+ const p = repoPath(name);
399+ try {
400+ const out = await $`git -C ${p} log --oneline -1`.quiet().text();
401+ return out.trim().length > 0;
402+ } catch {
403+ return false;
404+ }
405+ },
406+
407+ async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
408+ const p = repoPath(name);
409+ try {
410+ const [metaOut, msgOut] = await Promise.all([
411+ $`git -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%P ${sha}`.text(),
412+ $`git -C ${p} log --format=%B -1 ${sha}`.text(),
413+ ]);
414+ const parts = metaOut.trim().split("\x1f");
415+ const fullMsg = msgOut.trimEnd();
416+ const firstNl = fullMsg.indexOf("\n");
417+ const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
418+ const body =
419+ firstNl >= 0
420+ ? fullMsg
421+ .slice(firstNl + 1)
422+ .trimStart()
423+ .trimEnd()
424+ : "";
425+ return {
426+ hash: parts[0] ?? sha,
427+ subject,
428+ body,
429+ author: parts[1] ?? "",
430+ email: parts[2] ?? "",
431+ date: parts[3] ?? "",
432+ parents: (parts[4] ?? "").trim().split(/\s+/).filter(Boolean),
433+ };
434+ } catch {
435+ return null;
436+ }
437+ },
438+};
Asrc/services/highlight.ts
@@ -0,0 +1,143 @@
1+import type { Language } from "linguist-languages";
2+import * as linguistLangs from "linguist-languages";
3+import {
4+ bundledLanguages,
5+ bundledLanguagesInfo,
6+ createHighlighter,
7+ type Highlighter,
8+} from "shiki";
9+import { INLINE_MAX_BYTES } from "../config.ts";
10+import { MAX_FILE_CACHE } from "../constants.ts";
11+
12+let highlighter: Highlighter | null = null;
13+let extToLangId: Map<string, string> | null = null;
14+let filenameToLangId: Map<string, string> | null = null;
15+
16+export async function highlightStartup(): Promise<void> {
17+ console.log(
18+ "[highlight] initializing highlighter and language detection maps...",
19+ );
20+
21+ // Index linguist languages by lowercase name and aliases for precise lookup.
22+ // Many shiki languages share the same tmScope (e.g. source.js has 31 entries),
23+ // so scope-based matching is unreliable — name/alias matching is used instead.
24+ const linguistByName = new Map<string, Language>();
25+ for (const lang of Object.values(
26+ linguistLangs as Record<string, Language>,
27+ )) {
28+ linguistByName.set(lang.name.toLowerCase(), lang);
29+ for (const alias of lang.aliases ?? []) {
30+ if (!linguistByName.has(alias.toLowerCase())) {
31+ linguistByName.set(alias.toLowerCase(), lang);
32+ }
33+ }
34+ }
35+
36+ const extMap = new Map<string, string>();
37+ const fnMap = new Map<string, string>();
38+
39+ for (const { id, aliases } of bundledLanguagesInfo) {
40+ // Match shiki lang → linguist language by ID then aliases (no scope fallback)
41+ const linguistLang =
42+ linguistByName.get(id) ??
43+ aliases?.reduce<Language | undefined>(
44+ (found, a) => found ?? linguistByName.get(a.toLowerCase()),
45+ undefined,
46+ );
47+ if (!linguistLang) continue;
48+ for (const ext of linguistLang.extensions ?? []) {
49+ if (!extMap.has(ext)) extMap.set(ext, id);
50+ }
51+ for (const fn of linguistLang.filenames ?? []) {
52+ if (!fnMap.has(fn)) fnMap.set(fn, id);
53+ }
54+ }
55+
56+ extToLangId = extMap;
57+ filenameToLangId = fnMap;
58+ highlighter = await createHighlighter({
59+ themes: ["github-light", "github-dark"],
60+ langs: Object.keys(bundledLanguages),
61+ });
62+
63+ console.log(
64+ `[highlight] ready: ${extMap.size} extensions, ${fnMap.size} filenames`,
65+ );
66+}
67+
68+export function getHighlighter(): Highlighter {
69+ if (!highlighter) throw new Error("highlightStartup() has not been called");
70+ return highlighter;
71+}
72+
73+// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional binary content detection
74+const BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
75+
76+export function hasBinaryContent(buf: Buffer): boolean {
77+ const sample = buf.subarray(0, 8000);
78+ return BINARY_RE.test(sample.toString("binary"));
79+}
80+
81+export function detectLang(filename: string): string {
82+ if (!extToLangId || !filenameToLangId) return "text";
83+ const base = filename.split("/").pop() ?? filename;
84+ const ext = base.includes(".") ? base.slice(base.lastIndexOf(".")) : "";
85+ return (
86+ filenameToLangId.get(base) ??
87+ (ext ? extToLangId.get(ext) : undefined) ??
88+ "text"
89+ );
90+}
91+
92+export type FileView =
93+ | { type: "inline"; html: string; lines: number }
94+ | { type: "download"; size: number }
95+ | { type: "binary"; size: number };
96+
97+const fileCache = new Map<string, FileView>();
98+
99+export async function serveFile(
100+ content: Buffer,
101+ filename: string,
102+ cacheKey: string,
103+): Promise<FileView> {
104+ const cached = fileCache.get(cacheKey);
105+ if (cached) return cached;
106+
107+ const isBinary = hasBinaryContent(content);
108+ if (isBinary) {
109+ return { type: "binary", size: content.length };
110+ }
111+ if (content.length > INLINE_MAX_BYTES) {
112+ return { type: "download", size: content.length };
113+ }
114+ const text = content.toString("utf-8");
115+ const lines = text.split("\n").length;
116+ let view: FileView;
117+ try {
118+ const h = getHighlighter();
119+ const lang = detectLang(filename);
120+ const html = h.codeToHtml(text, {
121+ lang,
122+ themes: { light: "github-light", dark: "github-dark" },
123+ });
124+ view = { type: "inline", html, lines };
125+ } catch {
126+ // fallback: plain pre
127+ const escaped = text
128+ .replace(/&/g, "&amp;")
129+ .replace(/</g, "&lt;")
130+ .replace(/>/g, "&gt;");
131+ view = {
132+ type: "inline",
133+ html: `<pre class="code-plain"><code>${escaped}</code></pre>`,
134+ lines,
135+ };
136+ }
137+ if (cacheKey) {
138+ if (fileCache.size >= MAX_FILE_CACHE)
139+ fileCache.delete(fileCache.keys().next().value!);
140+ fileCache.set(cacheKey, view);
141+ }
142+ return view;
143+}
Asrc/services/highlightWorker.ts
@@ -0,0 +1,122 @@
1+import { HIGHLIGHT_WORKERS } from "../config.ts";
2+import { MAX_DIFF_CACHE } from "../constants.ts";
3+import type { ParsedFile, RenderedDiffFile } from "./diffHighlight.ts";
4+import { parseDiff } from "./diffHighlight.ts";
5+import type { FileView } from "./highlight.ts";
6+import { hasBinaryContent } from "./highlight.ts";
7+
8+export { hasBinaryContent };
9+
10+// ─── Worker pool ──────────────────────────────────────────────────────────────
11+
12+interface WorkerHandle {
13+ worker: Worker;
14+ ready: Promise<void>;
15+ pending: number;
16+ handlers: Map<
17+ number,
18+ { resolve: (v: unknown) => void; reject: (e: unknown) => void }
19+ >;
20+}
21+
22+let nextId = 0;
23+
24+function createHandle(): WorkerHandle {
25+ const handlers = new Map<
26+ number,
27+ { resolve: (v: unknown) => void; reject: (e: unknown) => void }
28+ >();
29+ const handle: WorkerHandle = {
30+ worker: null!,
31+ ready: null!,
32+ pending: 0,
33+ handlers,
34+ };
35+ handle.worker = new Worker(
36+ new URL("../workers/highlight.worker.ts", import.meta.url).href,
37+ );
38+ handle.ready = new Promise<void>((resolve) => {
39+ handle.worker.onmessage = (event: MessageEvent) => {
40+ if (event.data.type === "ready") {
41+ resolve();
42+ handle.worker.onmessage = (e) => onMessage(handle, e);
43+ return;
44+ }
45+ onMessage(handle, event);
46+ };
47+ });
48+ return handle;
49+}
50+
51+function onMessage(handle: WorkerHandle, event: MessageEvent) {
52+ const { id, result, error } = event.data;
53+ const p = handle.handlers.get(id);
54+ if (!p) return;
55+ handle.handlers.delete(id);
56+ handle.pending--;
57+ if (error !== undefined) p.reject(new Error(error));
58+ else p.resolve(result);
59+}
60+
61+const pool: WorkerHandle[] = Array.from(
62+ { length: HIGHLIGHT_WORKERS },
63+ createHandle,
64+);
65+
66+function leastBusy(): WorkerHandle {
67+ return pool.reduce((a, b) => (a.pending <= b.pending ? a : b));
68+}
69+
70+function request<T>(msg: Record<string, unknown>): Promise<T> {
71+ const handle = leastBusy();
72+ const id = nextId++;
73+ handle.pending++;
74+ return new Promise<T>((resolve, reject) => {
75+ handle.handlers.set(id, {
76+ resolve: resolve as (v: unknown) => void,
77+ reject,
78+ });
79+ handle.ready.then(() => handle.worker.postMessage({ id, ...msg }));
80+ });
81+}
82+
83+// ─── Diff cache (lives on main thread now that dispatch is per-file) ──────────
84+
85+const diffCache = new Map<string, RenderedDiffFile[]>();
86+
87+// ─── Public API ───────────────────────────────────────────────────────────────
88+
89+export function serveFile(
90+ content: Buffer,
91+ filename: string,
92+ cacheKey: string,
93+): Promise<FileView> {
94+ return request({
95+ type: "serveFile",
96+ content: content.buffer,
97+ filename,
98+ cacheKey,
99+ });
100+}
101+
102+export async function prepareDiff(
103+ rawDiff: string,
104+ cacheKey: string,
105+): Promise<RenderedDiffFile[]> {
106+ const cached = diffCache.get(cacheKey);
107+ if (cached) return cached;
108+
109+ const parsed = parseDiff(rawDiff);
110+ const result = await Promise.all(
111+ parsed.map((file: ParsedFile) =>
112+ request<RenderedDiffFile>({ type: "highlightFile", file }),
113+ ),
114+ );
115+
116+ if (cacheKey) {
117+ if (diffCache.size >= MAX_DIFF_CACHE)
118+ diffCache.delete(diffCache.keys().next().value!);
119+ diffCache.set(cacheKey, result);
120+ }
121+ return result;
122+}
Asrc/services/markdown.ts
@@ -0,0 +1,139 @@
1+import DOMPurify from "isomorphic-dompurify";
2+import { Marked, marked, type Tokens } from "marked";
3+import { MAX_MD_CACHE } from "../constants.ts";
4+
5+marked.setOptions({ gfm: true });
6+
7+const mdCache = new Map<string, string>();
8+
9+export function renderMarkdown(md: string, cacheKey?: string): string {
10+ if (cacheKey) {
11+ const cached = mdCache.get(cacheKey);
12+ if (cached) return cached;
13+ }
14+ const raw = marked(md) as string;
15+ const result = DOMPurify.sanitize(raw, {
16+ ADD_TAGS: ["details", "summary"],
17+ ADD_ATTR: ["class"],
18+ });
19+ if (cacheKey) {
20+ if (mdCache.size >= MAX_MD_CACHE)
21+ mdCache.delete(mdCache.keys().next().value!);
22+ mdCache.set(cacheKey, result);
23+ }
24+ return result;
25+}
26+
27+const _plaintextMarked = new Marked({ gfm: true });
28+_plaintextMarked.use({
29+ renderer: {
30+ // Block renderers
31+ heading({ tokens }) {
32+ return `${String(this.parser.parseInline(tokens))}\n\n`;
33+ },
34+ paragraph({ tokens }) {
35+ return `${String(this.parser.parseInline(tokens))}\n\n`;
36+ },
37+ blockquote({ tokens }) {
38+ return `${String(this.parser.parse(tokens))
39+ .trim()
40+ .split("\n")
41+ .map((l) => `> ${l}`)
42+ .join("\n")}\n\n`;
43+ },
44+ code({ text }) {
45+ return `${text}\n\n`;
46+ },
47+ list(token) {
48+ const start = typeof token.start === "number" ? token.start : 1;
49+ let body = "";
50+ for (let i = 0; i < token.items.length; i++) {
51+ const item = token.items[i]!;
52+ const prefix = token.ordered ? `${start + i}. ` : "- ";
53+ const checkedPrefix = item.task
54+ ? item.checked
55+ ? "[x] "
56+ : "[ ] "
57+ : "";
58+ const content = String(this.parser.parse(item.tokens))
59+ .trim()
60+ .replace(/\n+/g, " ");
61+ body += `${prefix + checkedPrefix + content}\n`;
62+ }
63+ return `${body}\n`;
64+ },
65+ listitem() {
66+ // Handled entirely inside list()
67+ return "";
68+ },
69+ table(token: Tokens.Table) {
70+ const cells = (row: Tokens.TableCell[]) =>
71+ row
72+ .map((c) => String(this.parser.parseInline(c.tokens)))
73+ .join(" | ");
74+ let out = `${cells(token.header)}\n`;
75+ for (const row of token.rows) {
76+ out += `${cells(row)}\n`;
77+ }
78+ return `${out}\n`;
79+ },
80+ hr() {
81+ return "---\n\n";
82+ },
83+ html() {
84+ return "";
85+ },
86+ // Inline renderers
87+ strong({ tokens }) {
88+ return String(this.parser.parseInline(tokens));
89+ },
90+ em({ tokens }) {
91+ return String(this.parser.parseInline(tokens));
92+ },
93+ del({ tokens }) {
94+ return String(this.parser.parseInline(tokens));
95+ },
96+ codespan({ text }) {
97+ return `\`${text}\``;
98+ },
99+ link({ tokens }) {
100+ return String(this.parser.parseInline(tokens));
101+ },
102+ image({ text, title }) {
103+ const label = (title || text || "").trim();
104+ return label ? `[Image: ${label}]` : "[Image]";
105+ },
106+ br() {
107+ return "\n";
108+ },
109+ text(token) {
110+ if ("tokens" in token && token.tokens) {
111+ return String(this.parser.parseInline(token.tokens));
112+ }
113+ return token.text;
114+ },
115+ },
116+});
117+
118+/** Convert markdown to plaintext, preserving structure (list prefixes, headings, etc.) */
119+export function markdownToPlaintext(md: string): string {
120+ return (_plaintextMarked.parse(md) as string)
121+ .replace(/\n{3,}/g, "\n\n")
122+ .trim();
123+}
124+
125+/**
126+ * Returns a short single-line preview of a plaintext string:
127+ * the first paragraph/heading line, truncated to maxLen chars.
128+ */
129+export function plaintextPreview(text: string, maxLen = 180): string {
130+ const firstBlock = text.split("\n\n")[0]?.trim() ?? "";
131+ const firstLine = firstBlock.split("\n")[0] ?? "";
132+ if (firstLine.length <= maxLen) return firstLine;
133+ const truncated = firstLine.slice(0, maxLen);
134+ const lastSpace = truncated.lastIndexOf(" ");
135+ return (
136+ (lastSpace > maxLen * 0.6 ? truncated.slice(0, lastSpace) : truncated) +
137+ "…"
138+ );
139+}
Asrc/services/patchCache.ts
@@ -0,0 +1,15 @@
1+import { MAX_PATCH_CACHE } from "../constants.ts";
2+
3+export type ApplyResult = { status: "clean" | "conflict"; output: string };
4+const cache = new Map<number, ApplyResult>();
5+
6+export const patchCache = {
7+ get: (id: number): ApplyResult | undefined => cache.get(id),
8+ set: (id: number, result: ApplyResult) => {
9+ if (cache.size >= MAX_PATCH_CACHE) {
10+ cache.delete(cache.keys().next().value!);
11+ }
12+ cache.set(id, result);
13+ },
14+ invalidate: (id: number) => cache.delete(id),
15+};
Asrc/services/reactions.ts
@@ -0,0 +1,24 @@
1+interface ReactionRow {
2+ emoji: string;
3+ comment_id: number | null;
4+ user_id: number;
5+}
6+
7+export function buildReactionCounts(
8+ reactions: ReactionRow[],
9+ commentId: number | null,
10+ userId: number | undefined,
11+): { emoji: string; count: number; userReacted: boolean }[] {
12+ const filtered = reactions.filter((r) =>
13+ commentId === null ? r.comment_id === null : r.comment_id === commentId,
14+ );
15+ const map = new Map<string, { count: number; userReacted: boolean }>();
16+ for (const r of filtered) {
17+ const entry = map.get(r.emoji) ?? { count: 0, userReacted: false };
18+ entry.count++;
19+ if (userId !== undefined && r.user_id === userId)
20+ entry.userReacted = true;
21+ map.set(r.emoji, entry);
22+ }
23+ return [...map.entries()].map(([emoji, d]) => ({ emoji, ...d }));
24+}
Asrc/services/repoSync.ts
@@ -0,0 +1,211 @@
1+import {
2+ type Dirent,
3+ existsSync,
4+ readdirSync,
5+ renameSync,
6+ rmSync,
7+} from "node:fs";
8+import path from "node:path";
9+import { $ } from "bun";
10+import { RELEASES_DIR, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts";
11+import type { RepositoryRow } from "../db/index.ts";
12+import { db } from "../db/index.ts";
13+import { git, repoPath } from "../services/git.ts";
14+
15+// Converts a non-bare repo to bare in-place by extracting the .git directory.
16+// Case 1: "myrepo/" — move myrepo/.git → myrepo.git/, delete myrepo/
17+// Case 2: "myrepo.git/" — move .git/ to a temp dir, delete myrepo.git/, rename temp → myrepo.git/
18+async function convertNonBareRepo(
19+ entryName: string,
20+ entryPath: string,
21+): Promise<void> {
22+ const dotGitPath = path.join(entryPath, ".git");
23+ const hasGitSuffix = entryName.endsWith(".git");
24+ const baseName = hasGitSuffix ? entryName : `${entryName}.git`;
25+ const targetPath = path.join(REPOS_DIR, baseName);
26+
27+ try {
28+ if (hasGitSuffix) {
29+ // Case 2: source and target path are the same dir — use a temp location.
30+ const tmpPath = path.join(REPOS_DIR, `.${entryName}.bare_tmp`);
31+ renameSync(dotGitPath, tmpPath);
32+ rmSync(entryPath, { recursive: true, force: true });
33+ renameSync(tmpPath, targetPath);
34+ } else {
35+ // Case 1: simple move.
36+ renameSync(dotGitPath, targetPath);
37+ rmSync(entryPath, { recursive: true, force: true });
38+ }
39+
40+ const worktreesPath = path.join(targetPath, "worktrees");
41+ if (existsSync(worktreesPath)) {
42+ rmSync(worktreesPath, { recursive: true, force: true });
43+ }
44+
45+ console.log(`Converted non-bare repo to bare: ${baseName}`);
46+ } catch (err) {
47+ console.error(`Failed to convert non-bare repo ${entryName}:`, err);
48+ }
49+}
50+
51+// Scans REPOS_DIR for non-bare repos and converts them before the main sync.
52+async function convertNonBareRepos(): Promise<void> {
53+ let entries: Dirent[];
54+ try {
55+ entries = readdirSync(REPOS_DIR, { withFileTypes: true });
56+ } catch {
57+ return;
58+ }
59+
60+ const conversions: Promise<void>[] = [];
61+ for (const entry of entries) {
62+ if (!entry.isDirectory()) continue;
63+ const entryPath = path.join(REPOS_DIR, entry.name);
64+ if (existsSync(path.join(entryPath, ".git"))) {
65+ conversions.push(convertNonBareRepo(entry.name, entryPath));
66+ }
67+ }
68+ await Promise.all(conversions);
69+}
70+
71+export function listDiskRepoNames(): string[] {
72+ try {
73+ return readdirSync(REPOS_DIR, { withFileTypes: true })
74+ .filter((e) => e.isDirectory() && e.name.endsWith(".git"))
75+ .map((e) => e.name.slice(0, -4));
76+ } catch {
77+ return [];
78+ }
79+}
80+
81+export function repoDiskExists(name: string): boolean {
82+ return existsSync(repoPath(name));
83+}
84+
85+export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
86+ const existing = await db
87+ .selectFrom("repositories")
88+ .selectAll()
89+ .where("name", "=", name)
90+ .executeTakeFirst();
91+ if (existing) return existing;
92+
93+ await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
94+
95+ const branch = await git.defaultBranch(name);
96+ const now = new Date().toISOString();
97+ return await db
98+ .insertInto("repositories")
99+ .values({
100+ name,
101+ description: null,
102+ is_private: 0,
103+ default_branch: branch,
104+ created_at: now,
105+ })
106+ .returningAll()
107+ .executeTakeFirstOrThrow();
108+}
109+
110+export async function syncStartup(): Promise<void> {
111+ await convertNonBareRepos();
112+ const diskNames = new Set(listDiskRepoNames());
113+ const dbRepos = await db
114+ .selectFrom("repositories")
115+ .select(["id", "name"])
116+ .execute();
117+ // Ensure all on-disk repos have DB records (e.g. repos pushed externally).
118+ // Skip names that fail validation — they can't be served anyway.
119+ const validDiskNames = [...diskNames].filter((n) =>
120+ VALID_REPO_NAME_RE.test(n),
121+ );
122+ await Promise.all(validDiskNames.map((name) => ensureRepoRecord(name)));
123+
124+ const stale = dbRepos.filter((r) => !diskNames.has(r.name));
125+ if (stale.length > 0) {
126+ await db
127+ .deleteFrom("repositories")
128+ .where(
129+ "id",
130+ "in",
131+ stale.map((r) => r.id),
132+ )
133+ .execute();
134+ console.log(
135+ `Removed ${stale.length} stale repo record(s): ${stale.map((r) => r.name).join(", ")}`,
136+ );
137+ }
138+
139+ // Release cleanup: sync DB records against on-disk release directories.
140+ // Orphaned directories (no DB record) arise when the server crashes after
141+ // files are written but before the transaction commits. Stale DB records
142+ // (directory missing) arise when the server crashes after rmSync but before
143+ // the DB delete during release deletion.
144+ //
145+ // Note: releases with no source code and no assets have no on-disk
146+ // directory, so we only apply the stale-record check to releases that
147+ // should have a directory (include_source_code=1 or has release_assets).
148+ const allReleaseIds = new Set(
149+ (await db.selectFrom("releases").select("id").execute()).map(
150+ (r) => r.id,
151+ ),
152+ );
153+
154+ try {
155+ for (const entry of readdirSync(RELEASES_DIR, {
156+ withFileTypes: true,
157+ })) {
158+ if (!entry.isDirectory()) continue;
159+ const id = Number(entry.name);
160+ if (!Number.isNaN(id) && !allReleaseIds.has(id)) {
161+ rmSync(path.join(RELEASES_DIR, entry.name), {
162+ recursive: true,
163+ force: true,
164+ });
165+ console.log(
166+ `Removed orphaned release directory: ${entry.name}`,
167+ );
168+ }
169+ }
170+ } catch {
171+ // RELEASES_DIR may not exist yet on first run
172+ }
173+
174+ // Only check for missing dirs on releases that should have one.
175+ const releasesWithDirs = await db
176+ .selectFrom("releases")
177+ .select("releases.id")
178+ .where((eb) =>
179+ eb.or([
180+ eb("releases.include_source_code", "=", 1),
181+ eb.exists(
182+ eb
183+ .selectFrom("release_assets")
184+ .select("release_assets.id")
185+ .whereRef(
186+ "release_assets.release_id",
187+ "=",
188+ "releases.id",
189+ ),
190+ ),
191+ ]),
192+ )
193+ .execute();
194+
195+ const staleReleases = releasesWithDirs.filter(
196+ (r) => !existsSync(path.join(RELEASES_DIR, String(r.id))),
197+ );
198+ if (staleReleases.length > 0) {
199+ await db
200+ .deleteFrom("releases")
201+ .where(
202+ "id",
203+ "in",
204+ staleReleases.map((r) => r.id),
205+ )
206+ .execute();
207+ console.log(
208+ `Removed ${staleReleases.length} stale release record(s): ${staleReleases.map((r) => r.id).join(", ")}`,
209+ );
210+ }
211+}
Asrc/services/sshServer.ts
@@ -0,0 +1,160 @@
1+import { type ChildProcess, spawn } from "node:child_process";
2+import { createHash } from "node:crypto";
3+import { existsSync, readFileSync } from "node:fs";
4+import path from "node:path";
5+import { Server, utils } from "ssh2";
6+import { SSH_PORT } from "../config.ts";
7+import { ADMIN_USERNAME, REPOS_DIR, SSH_HOST_KEY_PATH } from "../constants.ts";
8+import { db } from "../db/index.ts";
9+
10+/** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */
11+function fingerprintFromBytes(keyBytes: Buffer): string {
12+ const hash = createHash("sha256")
13+ .update(keyBytes)
14+ .digest("base64")
15+ .replace(/=+$/, "");
16+ return `SHA256:${hash}`;
17+}
18+
19+/**
20+ * Compute fingerprint from a full public key line
21+ * (e.g. "ssh-ed25519 AAAA... comment").
22+ * Returns null if the line is malformed.
23+ */
24+export function fingerprintFromLine(pubkeyLine: string): string | null {
25+ const parts = pubkeyLine.trim().split(/\s+/);
26+ if (parts.length < 2) return null;
27+ try {
28+ const keyBytes = Buffer.from(parts[1] ?? "", "base64");
29+ return fingerprintFromBytes(keyBytes);
30+ } catch {
31+ return null;
32+ }
33+}
34+
35+export async function startSshServer() {
36+ if (!existsSync(SSH_HOST_KEY_PATH)) {
37+ await Bun.spawn([
38+ "ssh-keygen",
39+ "-t",
40+ "ed25519",
41+ "-N",
42+ "",
43+ "-f",
44+ SSH_HOST_KEY_PATH,
45+ "-C",
46+ "hearthforge-host",
47+ ]).exited;
48+ console.log("Generated SSH host key at", SSH_HOST_KEY_PATH);
49+ }
50+
51+ const hostKey = readFileSync(SSH_HOST_KEY_PATH);
52+
53+ const server = new Server({ hostKeys: [hostKey] }, (client) => {
54+ let authedUser: { id: number; username: string } | null = null;
55+
56+ client.on("authentication", async (ctx) => {
57+ if (ctx.method !== "publickey") {
58+ return ctx.reject(["publickey"]);
59+ }
60+
61+ // Probe phase: accept so the client proceeds to send a signature
62+ if (!ctx.signature) return ctx.accept();
63+
64+ // Signature phase: look up the stored key by fingerprint
65+ const fingerprint = fingerprintFromBytes(ctx.key.data);
66+ const sshKey = await db
67+ .selectFrom("ssh_keys")
68+ .innerJoin("users", "users.id", "ssh_keys.user_id")
69+ .select([
70+ "users.id as userId",
71+ "users.username",
72+ "ssh_keys.public_key",
73+ ])
74+ .where("ssh_keys.fingerprint", "=", fingerprint)
75+ .executeTakeFirst();
76+
77+ if (!sshKey) return ctx.reject();
78+
79+ // Verify signature using the stored public key text (parseKey needs key file format, not raw bytes)
80+ const parsed = utils.parseKey(sshKey.public_key);
81+ if (parsed instanceof Error || Array.isArray(parsed))
82+ return ctx.reject();
83+
84+ const verifyResult = parsed.verify(ctx.blob!, ctx.signature);
85+ if (verifyResult !== true) return ctx.reject();
86+
87+ authedUser = { id: sshKey.userId, username: sshKey.username };
88+ ctx.accept();
89+ });
90+
91+ client.on("ready", () => {
92+ client.on("session", (accept) => {
93+ const session = accept();
94+
95+ session.on("exec", async (accept, reject, info) => {
96+ // git sends: git-upload-pack '/reponame.git'
97+ const match = info.command.match(
98+ /^(git-upload-pack|git-receive-pack)\s+'?\/?([a-zA-Z0-9_.-]+?)(?:\.git)?'?$/,
99+ );
100+ if (!match) return reject();
101+
102+ const command = match[1]!;
103+ const repoName = match[2]!;
104+
105+ const repo = await db
106+ .selectFrom("repositories")
107+ .select(["name", "is_private"])
108+ .where("name", "=", repoName)
109+ .executeTakeFirst();
110+ if (!repo) return reject();
111+
112+ const repoPath = path.join(REPOS_DIR, `${repo.name}.git`);
113+ const stream = accept();
114+
115+ if (command === "git-receive-pack") {
116+ if (
117+ !authedUser ||
118+ authedUser.username !== ADMIN_USERNAME
119+ ) {
120+ stream.stderr.write("error: push access denied\n");
121+ stream.exit(128);
122+ stream.end();
123+ return;
124+ }
125+ }
126+
127+ if (repo.is_private && !authedUser) {
128+ stream.stderr.write(
129+ "error: repository access denied\n",
130+ );
131+ stream.exit(128);
132+ stream.end();
133+ return;
134+ }
135+
136+ const proc: ChildProcess = spawn(command, [repoPath]);
137+ stream.pipe(proc.stdin!);
138+ proc.stdout?.pipe(stream, { end: false });
139+ proc.stderr?.pipe(stream.stderr as NodeJS.WritableStream, {
140+ end: false,
141+ });
142+
143+ proc.on("close", (code: number | null) => {
144+ stream.exit(code ?? 0);
145+ stream.end();
146+ });
147+ stream.on("close", () => proc.kill());
148+ });
149+ });
150+ });
151+
152+ client.on("error", () => {
153+ /* absorb ECONNRESET etc. */
154+ });
155+ });
156+
157+ server.listen(SSH_PORT, "0.0.0.0", () => {
158+ console.log(`SSH server listening on port ${SSH_PORT}`);
159+ });
160+}
Asrc/views/Avatar.tsx
@@ -0,0 +1,42 @@
1+// 1×1 transparent GIF — used as img fallback so the browser never enters
2+// broken-image state while waiting for the JXL polyfill to decode the source.
3+const TRANSPARENT =
4+ "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
5+
6+export function Avatar({
7+ userId,
8+ version,
9+ size = 32,
10+}: {
11+ userId: number | null;
12+ version?: number | null;
13+ size?: number;
14+}) {
15+ const style = `width:${size}px;height:${size}px`;
16+ if (!userId) {
17+ return (
18+ <span
19+ class="avatar avatar-placeholder"
20+ style={`${style};font-size:${Math.round(size * 0.45)}px`}
21+ >
22+ ?
23+ </span>
24+ );
25+ }
26+ const src =
27+ version != null
28+ ? `/avatars/${userId}.jxl?v=${version}`
29+ : `/avatars/${userId}.jxl`;
30+ return (
31+ <picture class="avatar" style={style}>
32+ <source srcset={src} type="image/jxl" />
33+ <img
34+ src={TRANSPARENT}
35+ alt=""
36+ width={String(size)}
37+ height={String(size)}
38+ class="avatar-img"
39+ />
40+ </picture>
41+ );
42+}
Asrc/views/DateWithEdited.tsx
@@ -0,0 +1,19 @@
1+export function DateWithEdited({
2+ date,
3+ editedAt,
4+}: {
5+ date: string;
6+ editedAt: string | null;
7+}) {
8+ const label = new Date(date).toLocaleDateString();
9+ if (!editedAt) return <time datetime={date}>{label}</time>;
10+ return (
11+ <time
12+ datetime={date}
13+ class="edited-indicator"
14+ title={`Edited ${new Date(editedAt).toLocaleString()}`}
15+ >
16+ *{label}
17+ </time>
18+ );
19+}
Asrc/views/DiffView.tsx
@@ -0,0 +1,287 @@
1+import type { RepositoryRow } from "../db/index.ts";
2+import type { RenderedDiffFile } from "../services/diffHighlight.ts";
3+
4+export function slugify(s: string): string {
5+ return `diff-${s.replace(/[^a-zA-Z0-9]/g, "-")}`;
6+}
7+
8+export function statusLetter(status: string): string {
9+ return (
10+ (
11+ {
12+ added: "A",
13+ deleted: "D",
14+ modified: "M",
15+ renamed: "R",
16+ copied: "C",
17+ } as Record<string, string>
18+ )[status] ?? "M"
19+ );
20+}
21+
22+export type FileTreeNode =
23+ | { type: "file"; file: RenderedDiffFile }
24+ | { type: "dir"; children: Map<string, FileTreeNode> };
25+
26+export function buildFileTree(
27+ files: RenderedDiffFile[],
28+): Map<string, FileTreeNode> {
29+ const root = new Map<string, FileTreeNode>();
30+ for (const f of files) {
31+ const p = f.newPath || f.oldPath;
32+ const parts = p.split("/");
33+ let current = root;
34+ for (let i = 0; i < parts.length - 1; i++) {
35+ const part = parts[i]!;
36+ if (!current.has(part)) {
37+ current.set(part, { type: "dir", children: new Map() });
38+ }
39+ const node = current.get(part)!;
40+ if (node.type === "dir") current = node.children;
41+ }
42+ current.set(parts[parts.length - 1]!, { type: "file", file: f });
43+ }
44+ return root;
45+}
46+
47+export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element {
48+ return (
49+ <ul class="file-nav-list">
50+ {[...tree.entries()]
51+ .sort(([, a], [, b]) =>
52+ a.type === b.type ? 0 : a.type === "dir" ? -1 : 1,
53+ )
54+ .map(([name, node]) =>
55+ node.type === "dir" ? (
56+ <li class="file-nav-dir">
57+ <details open>
58+ <summary class="file-nav-dir-toggle">
59+ <span class="file-nav-toggle-icon">▾</span>
60+ {name}/
61+ </summary>
62+ {renderFileTree(node.children)}
63+ </details>
64+ </li>
65+ ) : (
66+ <li>
67+ <a
68+ href={`#${slugify(node.file.newPath || node.file.oldPath)}`}
69+ class="file-nav-item"
70+ title={node.file.newPath || node.file.oldPath}
71+ >
72+ <span
73+ class={`file-nav-status file-status-${node.file.status}`}
74+ >
75+ {statusLetter(node.file.status)}
76+ </span>
77+ <span class="file-nav-name">{name}</span>
78+ <span class="file-nav-stat">
79+ {node.file.added > 0 && (
80+ <span class="nav-add">
81+ +{node.file.added}
82+ </span>
83+ )}
84+ {node.file.removed > 0 && (
85+ <span class="nav-del">
86+ -{node.file.removed}
87+ </span>
88+ )}
89+ </span>
90+ </a>
91+ </li>
92+ ),
93+ )}
94+ </ul>
95+ );
96+}
97+
98+interface DiffViewProps {
99+ files: RenderedDiffFile[];
100+ repo: RepositoryRow;
101+ /** If provided, an extra "view at sha" link is shown in each file header. */
102+ sha?: string;
103+}
104+
105+export function DiffView({ files, repo, sha }: DiffViewProps) {
106+ const totalAdded = files.reduce((s, f) => s + f.added, 0);
107+ const totalRemoved = files.reduce((s, f) => s + f.removed, 0);
108+
109+ const changedStr = [
110+ `${files.length} file${files.length !== 1 ? "s" : ""} changed`,
111+ totalAdded > 0
112+ ? `${totalAdded} insertion${totalAdded !== 1 ? "s" : ""}(+)`
113+ : "",
114+ totalRemoved > 0
115+ ? `${totalRemoved} deletion${totalRemoved !== 1 ? "s" : ""}(-)`
116+ : "",
117+ ]
118+ .filter(Boolean)
119+ .join(", ");
120+
121+ return (
122+ <>
123+ {files.length > 0 && (
124+ <div class="commit-stats-bar">
125+ <span class="commit-stats-text">{changedStr}</span>
126+ </div>
127+ )}
128+
129+ {files.length === 0 ? (
130+ <p class="text-muted" style="margin-top: var(--space-6)">
131+ No diff available.
132+ </p>
133+ ) : (
134+ <div class="commit-layout">
135+ <aside class="commit-file-nav">
136+ <details class="file-nav-details" open>
137+ <summary class="file-nav-toggle">
138+ <span class="file-nav-toggle-icon">▾</span>
139+ <span>Files changed ({files.length})</span>
140+ </summary>
141+ {renderFileTree(buildFileTree(files))}
142+ </details>
143+ </aside>
144+
145+ <div class="commit-diffs">
146+ {files.map((f) => {
147+ const displayPath = f.newPath || f.oldPath;
148+ const sl = statusLetter(f.status);
149+ return (
150+ <details
151+ class="diff-file"
152+ id={slugify(displayPath)}
153+ open
154+ >
155+ <summary class="diff-file-header">
156+ <div class="diff-file-header-left">
157+ <span class="diff-file-toggle-icon">
158+
159+ </span>
160+ <span
161+ class={`diff-status-badge diff-status-${f.status}`}
162+ >
163+ {sl}
164+ </span>
165+ <span class="diff-file-path mono">
166+ {displayPath}
167+ </span>
168+ {f.status === "renamed" &&
169+ f.oldPath !== f.newPath && (
170+ <span class="diff-rename-arrow">
171+ ← {f.oldPath}
172+ </span>
173+ )}
174+ </div>
175+ <div class="diff-file-header-right">
176+ {f.added > 0 && (
177+ <span class="diff-stat-add">
178+ +{f.added}
179+ </span>
180+ )}
181+ {f.removed > 0 && (
182+ <span class="diff-stat-del">
183+ -{f.removed}
184+ </span>
185+ )}
186+ {!f.isBinary &&
187+ f.status !== "deleted" && (
188+ <>
189+ {sha && (
190+ <a
191+ href={`/${repo.name}/blob/${sha}/${displayPath}`}
192+ class="btn btn-xs btn-secondary"
193+ title={`View file at ${sha.slice(0, 7)}`}
194+ >
195+ @{" "}
196+ {sha.slice(
197+ 0,
198+ 7,
199+ )}
200+ </a>
201+ )}
202+ <a
203+ href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
204+ class="btn btn-xs btn-secondary"
205+ title={`View file at ${repo.default_branch}`}
206+ >
207+ @{" "}
208+ {
209+ repo.default_branch
210+ }
211+ </a>
212+ </>
213+ )}
214+ </div>
215+ </summary>
216+
217+ {f.isBinary ? (
218+ <div class="diff-binary-notice">
219+ Binary file — not shown
220+ </div>
221+ ) : f.hunks.length === 0 ? (
222+ <div class="diff-binary-notice">
223+ No textual changes.
224+ </div>
225+ ) : (
226+ <div class="diff-file-body">
227+ {f.hunks.map((hunk) => (
228+ <div class="diff-hunk">
229+ <table class="diff-table">
230+ <thead>
231+ <tr>
232+ <th
233+ colspan="4"
234+ class="diff-hunk-header"
235+ >
236+ {
237+ hunk.header
238+ }
239+ </th>
240+ </tr>
241+ </thead>
242+ <tbody>
243+ {hunk.rows.map(
244+ (row) => (
245+ <tr
246+ class={`diff-row diff-row-${row.type}`}
247+ >
248+ <td class="diff-ln diff-ln-old">
249+ {row.oldLine ??
250+ ""}
251+ </td>
252+ <td class="diff-ln diff-ln-new">
253+ {row.newLine ??
254+ ""}
255+ </td>
256+ <td class="diff-sign">
257+ {row.type ===
258+ "add"
259+ ? "+"
260+ : row.type ===
261+ "del"
262+ ? "-"
263+ : " "}
264+ </td>
265+ <td class="diff-code">
266+ {
267+ row.html
268+ }
269+ </td>
270+ </tr>
271+ ),
272+ )}
273+ </tbody>
274+ </table>
275+ </div>
276+ ))}
277+ </div>
278+ )}
279+ </details>
280+ );
281+ })}
282+ </div>
283+ </div>
284+ )}
285+ </>
286+ );
287+}
Asrc/views/Pagination.tsx
@@ -0,0 +1,40 @@
1+export interface PaginationInfo {
2+ page: number;
3+ totalPages: number;
4+ /** URL template — use `{page}` as placeholder, e.g. "/repo/issues?status=open&page={page}" */
5+ pageUrlTemplate: string;
6+}
7+
8+interface PaginationProps extends PaginationInfo {}
9+
10+export function Pagination({
11+ page,
12+ totalPages,
13+ pageUrlTemplate,
14+}: PaginationProps) {
15+ if (totalPages <= 1) return null;
16+
17+ const url = (p: number) => pageUrlTemplate.replace("{page}", String(p));
18+
19+ return (
20+ <nav class="pagination" aria-label="Pagination">
21+ <div class="pagination-prev">
22+ {page > 1 && (
23+ <a href={url(page - 1)} class="pagination-btn">
24+ ← Previous
25+ </a>
26+ )}
27+ </div>
28+ <span class="pagination-info">
29+ Page {page} of {totalPages}
30+ </span>
31+ <div class="pagination-next">
32+ {page < totalPages && (
33+ <a href={url(page + 1)} class="pagination-btn">
34+ Next →
35+ </a>
36+ )}
37+ </div>
38+ </nav>
39+ );
40+}
Asrc/views/ReactionBar.tsx
@@ -0,0 +1,84 @@
1+import { ALLOWED_REACTIONS } from "../constants.ts";
2+import type { SessionUser } from "../middleware/session.ts";
3+
4+export interface ReactionCount {
5+ emoji: string;
6+ count: number;
7+ userReacted: boolean;
8+}
9+
10+interface ReactionBarProps {
11+ reactions: ReactionCount[];
12+ postUrl: string;
13+ commentId?: number;
14+ user: SessionUser | null;
15+}
16+
17+export function ReactionBar({
18+ reactions,
19+ postUrl,
20+ commentId,
21+ user,
22+}: ReactionBarProps) {
23+ if (!user && reactions.length === 0) return null;
24+ const existing = new Set(reactions.map((r) => r.emoji));
25+ const all = [...ALLOWED_REACTIONS];
26+ return (
27+ <div class="reaction-bar">
28+ {reactions.map((r) => (
29+ <form method="POST" action={postUrl} class="reaction-form">
30+ {commentId != null && (
31+ <input
32+ type="hidden"
33+ name="comment_id"
34+ value={String(commentId)}
35+ />
36+ )}
37+ <input type="hidden" name="emoji" value={r.emoji} />
38+ <button
39+ type="submit"
40+ class={`reaction-btn${r.userReacted ? " reacted" : ""}`}
41+ disabled={!user}
42+ >
43+ {r.emoji} {r.count}
44+ </button>
45+ </form>
46+ ))}
47+ {user && (
48+ <details class="reaction-picker">
49+ <summary class="reaction-add-btn">+</summary>
50+ <div class="reaction-picker-dropdown">
51+ {all
52+ .filter((e) => !existing.has(e))
53+ .map((e) => (
54+ <form
55+ method="POST"
56+ action={postUrl}
57+ class="reaction-form"
58+ >
59+ {commentId != null && (
60+ <input
61+ type="hidden"
62+ name="comment_id"
63+ value={String(commentId)}
64+ />
65+ )}
66+ <input
67+ type="hidden"
68+ name="emoji"
69+ value={e}
70+ />
71+ <button
72+ type="submit"
73+ class="reaction-picker-btn"
74+ >
75+ {e}
76+ </button>
77+ </form>
78+ ))}
79+ </div>
80+ </details>
81+ )}
82+ </div>
83+ );
84+}
Asrc/views/Settings.tsx
@@ -0,0 +1,469 @@
1+import type { SessionUser } from "../middleware/session.ts";
2+import { Avatar } from "./Avatar.tsx";
3+import { Layout } from "./layout.tsx";
4+
5+interface SettingsProps {
6+ user: SessionUser;
7+ hasPassword: boolean;
8+ passkeys: { id: number; created_at: string }[];
9+ sshKeys: {
10+ id: number;
11+ name: string;
12+ fingerprint: string;
13+ created_at: string;
14+ }[];
15+ theme: string;
16+ success: string | null;
17+ error: string | null;
18+}
19+
20+const successMessages: Record<string, string> = {
21+ password: "Password updated.",
22+ password_removed: "Password removed.",
23+ passkey_revoked: "Passkey revoked.",
24+ theme: "Theme preference saved.",
25+ user_created: "Account created.",
26+ user_deleted: "Account deleted.",
27+ ssh_key_added: "SSH key added.",
28+ ssh_key_deleted: "SSH key removed.",
29+};
30+
31+export function Settings({
32+ user,
33+ hasPassword,
34+ passkeys,
35+ sshKeys,
36+ theme,
37+ success,
38+ error,
39+}: SettingsProps) {
40+ const successMsg = success ? (successMessages[success] ?? null) : null;
41+
42+ return (
43+ <Layout user={user} title="Settings">
44+ <div class="container container-narrow">
45+ <h1 class="page-title">Settings</h1>
46+
47+ {successMsg && <p class="form-success">{successMsg}</p>}
48+ {error && <p class="form-error">{error}</p>}
49+
50+ {/* Avatar */}
51+ <div class="form-card">
52+ <h2 class="section-title">Avatar</h2>
53+ <div class="avatar-settings">
54+ <Avatar
55+ userId={user.id}
56+ version={user.avatar_version}
57+ size={80}
58+ />
59+ <div class="avatar-actions">
60+ <form
61+ method="POST"
62+ action="/settings/avatar"
63+ enctype="multipart/form-data"
64+ >
65+ <div class="form-group">
66+ <input
67+ type="file"
68+ name="avatar"
69+ accept="image/*"
70+ class="form-input"
71+ required
72+ />
73+ </div>
74+ <div class="form-actions">
75+ <button
76+ type="submit"
77+ class="btn btn-sm btn-primary"
78+ >
79+ Upload avatar
80+ </button>
81+ </div>
82+ </form>
83+ <form
84+ method="POST"
85+ action="/settings/avatar/delete"
86+ class="inline-form"
87+ >
88+ <button
89+ type="submit"
90+ class="btn btn-sm btn-ghost"
91+ >
92+ Remove avatar
93+ </button>
94+ </form>
95+ </div>
96+ </div>
97+ </div>
98+
99+ {/* Appearance */}
100+ <div class="form-card">
101+ <h2 class="section-title">Appearance</h2>
102+ <form method="POST" action="/settings/theme">
103+ <div class="theme-options">
104+ <label class="theme-option">
105+ <input
106+ type="radio"
107+ name="theme"
108+ value="auto"
109+ checked={
110+ theme === "auto" ? true : undefined
111+ }
112+ />
113+ Auto
114+ </label>
115+ <label class="theme-option">
116+ <input
117+ type="radio"
118+ name="theme"
119+ value="light"
120+ checked={
121+ theme === "light" ? true : undefined
122+ }
123+ />
124+ Light
125+ </label>
126+ <label class="theme-option">
127+ <input
128+ type="radio"
129+ name="theme"
130+ value="dark"
131+ checked={
132+ theme === "dark" ? true : undefined
133+ }
134+ />
135+ Dark
136+ </label>
137+ </div>
138+ <div class="form-actions">
139+ <button class="btn btn-primary" type="submit">
140+ Save
141+ </button>
142+ </div>
143+ </form>
144+ </div>
145+
146+ {/* Password */}
147+ <div class="form-card">
148+ <h2 class="section-title">Password</h2>
149+ <p class="text-muted">
150+ {hasPassword ? "Password is set." : "No password set."}
151+ </p>
152+ <form
153+ method="POST"
154+ action="/settings/password"
155+ class="settings-form"
156+ >
157+ {hasPassword && (
158+ <div class="form-group">
159+ <label
160+ class="form-label"
161+ for="current_password"
162+ >
163+ Current password
164+ </label>
165+ <input
166+ class="form-input"
167+ type="password"
168+ id="current_password"
169+ name="current_password"
170+ autocomplete="current-password"
171+ />
172+ </div>
173+ )}
174+ <div class="form-group">
175+ <label class="form-label" for="new_password">
176+ {hasPassword ? "New password" : "Password"}
177+ </label>
178+ <input
179+ class="form-input"
180+ type="password"
181+ id="new_password"
182+ name="new_password"
183+ autocomplete="new-password"
184+ />
185+ </div>
186+ <div class="form-group">
187+ <label class="form-label" for="confirm_password">
188+ Confirm password
189+ </label>
190+ <input
191+ class="form-input"
192+ type="password"
193+ id="confirm_password"
194+ name="confirm_password"
195+ autocomplete="new-password"
196+ />
197+ </div>
198+ <div class="form-actions">
199+ <button class="btn btn-primary" type="submit">
200+ {hasPassword
201+ ? "Change password"
202+ : "Set password"}
203+ </button>
204+ </div>
205+ </form>
206+ {hasPassword && passkeys.length > 0 && (
207+ <form
208+ method="POST"
209+ action="/settings/password/remove"
210+ style="margin-top: var(--space-4)"
211+ >
212+ <button
213+ class="btn btn-danger btn-sm"
214+ type="submit"
215+ onclick="return confirm('Remove password? You will need a passkey to sign in.')"
216+ >
217+ Remove password
218+ </button>
219+ </form>
220+ )}
221+ </div>
222+
223+ {/* Passkeys */}
224+ <div class="form-card">
225+ <h2 class="section-title">Passkeys</h2>
226+ {passkeys.length > 0 && (
227+ <div class="passkey-list">
228+ {passkeys.map((pk) => (
229+ <div class="passkey-item">
230+ <span class="passkey-date">
231+ Added{" "}
232+ {new Date(
233+ pk.created_at,
234+ ).toLocaleDateString()}
235+ </span>
236+ <form
237+ method="POST"
238+ action="/settings/passkey/revoke"
239+ >
240+ <input
241+ type="hidden"
242+ name="id"
243+ value={String(pk.id)}
244+ />
245+ <button
246+ class="btn btn-danger btn-sm"
247+ type="submit"
248+ disabled={
249+ !hasPassword &&
250+ passkeys.length <= 1
251+ ? true
252+ : undefined
253+ }
254+ title={
255+ !hasPassword &&
256+ passkeys.length <= 1
257+ ? "Register another auth method first"
258+ : undefined
259+ }
260+ >
261+ Revoke
262+ </button>
263+ </form>
264+ </div>
265+ ))}
266+ </div>
267+ )}
268+ <div id="passkey-section" style="display:none">
269+ <button
270+ id="add-passkey-btn"
271+ class="btn btn-secondary"
272+ type="button"
273+ >
274+ Add passkey
275+ </button>
276+ <p id="passkey-status" class="text-muted"></p>
277+ </div>
278+ <noscript>
279+ <p class="text-muted">
280+ Enable JavaScript to register or add passkeys.
281+ </p>
282+ </noscript>
283+ </div>
284+
285+ {/* SSH Keys */}
286+ <div class="form-card">
287+ <h2 class="section-title">SSH Keys</h2>
288+ {sshKeys.length > 0 && (
289+ <div class="passkey-list">
290+ {sshKeys.map((key) => (
291+ <div class="passkey-item">
292+ <div class="ssh-key-info">
293+ <span class="ssh-key-name">
294+ {key.name}
295+ </span>
296+ <span class="passkey-date ssh-key-fingerprint">
297+ {key.fingerprint}
298+ </span>
299+ <span class="passkey-date">
300+ Added{" "}
301+ {new Date(
302+ key.created_at,
303+ ).toLocaleDateString()}
304+ </span>
305+ </div>
306+ <form
307+ method="POST"
308+ action="/settings/ssh-keys/delete"
309+ >
310+ <input
311+ type="hidden"
312+ name="id"
313+ value={String(key.id)}
314+ />
315+ <button
316+ class="btn btn-danger btn-sm"
317+ type="submit"
318+ >
319+ Remove
320+ </button>
321+ </form>
322+ </div>
323+ ))}
324+ </div>
325+ )}
326+ <form
327+ method="POST"
328+ action="/settings/ssh-keys"
329+ class="settings-form"
330+ style="margin-top: var(--space-4)"
331+ >
332+ <div class="form-group">
333+ <label class="form-label" for="ssh_key_name">
334+ Name
335+ </label>
336+ <input
337+ class="form-input"
338+ type="text"
339+ id="ssh_key_name"
340+ name="name"
341+ placeholder="e.g. My laptop"
342+ autocomplete="off"
343+ />
344+ </div>
345+ <div class="form-group">
346+ <label class="form-label" for="ssh_public_key">
347+ Public key
348+ </label>
349+ <textarea
350+ class="form-input form-textarea"
351+ id="ssh_public_key"
352+ name="public_key"
353+ placeholder="ssh-ed25519 AAAA..."
354+ rows="3"
355+ required
356+ ></textarea>
357+ </div>
358+ <div class="form-actions">
359+ <button class="btn btn-primary" type="submit">
360+ Add SSH key
361+ </button>
362+ </div>
363+ </form>
364+ </div>
365+
366+ {/* Admin: User Management */}
367+ {user.isAdmin && (
368+ <div class="form-card">
369+ <h2 class="section-title">User Management</h2>
370+ <h3>Create account</h3>
371+ <form
372+ method="POST"
373+ action="/admin/users"
374+ class="settings-form"
375+ style="margin-top: var(--space-4)"
376+ >
377+ <div class="form-group">
378+ <label class="form-label" for="new_username">
379+ Username
380+ </label>
381+ <input
382+ class="form-input"
383+ type="text"
384+ id="new_username"
385+ name="username"
386+ autocomplete="off"
387+ />
388+ </div>
389+ <div class="form-group">
390+ <label
391+ class="form-label"
392+ for="new_user_password"
393+ >
394+ Password
395+ </label>
396+ <input
397+ class="form-input"
398+ type="password"
399+ id="new_user_password"
400+ name="password"
401+ autocomplete="new-password"
402+ />
403+ </div>
404+ <div class="form-actions">
405+ <button class="btn btn-primary" type="submit">
406+ Create account
407+ </button>
408+ </div>
409+ </form>
410+ <h3 style="margin-top: var(--space-6)">
411+ Delete account
412+ </h3>
413+ <form
414+ method="POST"
415+ action="/admin/users/delete"
416+ class="settings-form"
417+ style="margin-top: var(--space-4)"
418+ >
419+ <div class="form-group">
420+ <label class="form-label" for="del_username">
421+ Username
422+ </label>
423+ <input
424+ class="form-input"
425+ type="text"
426+ id="del_username"
427+ name="username"
428+ autocomplete="off"
429+ />
430+ </div>
431+ <div class="form-actions">
432+ <button class="btn btn-danger" type="submit">
433+ Delete account
434+ </button>
435+ </div>
436+ </form>
437+ </div>
438+ )}
439+ </div>
440+
441+ <script type="module">{`
442+ import { startRegistration } from '/assets/simplewebauthn-browser.js';
443+ document.getElementById('passkey-section').style.display = 'block';
444+ document.getElementById('add-passkey-btn').addEventListener('click', async () => {
445+ const status = document.getElementById('passkey-status');
446+ try {
447+ status.textContent = 'Starting...';
448+ const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' });
449+ const opts = await optsResp.json();
450+ const result = await startRegistration({ optionsJSON: opts });
451+ const verResp = await fetch('/auth/passkey/register/verify', {
452+ method: 'POST',
453+ headers: { 'Content-Type': 'application/json' },
454+ body: JSON.stringify(result),
455+ });
456+ if (verResp.ok) {
457+ window.location.reload();
458+ } else {
459+ const err = await verResp.json();
460+ status.textContent = 'Error: ' + (err.error ?? 'Registration failed');
461+ }
462+ } catch (e) {
463+ status.textContent = 'Error: ' + e.message;
464+ }
465+ });
466+ `}</script>
467+ </Layout>
468+ );
469+}
Asrc/views/auth/Login.tsx
@@ -0,0 +1,80 @@
1+import { Layout } from "../layout.tsx";
2+
3+interface LoginProps {
4+ error?: string;
5+}
6+
7+export function Login({ error }: LoginProps) {
8+ return (
9+ <Layout user={null} title="Sign in">
10+ <div class="auth-container">
11+ <h1 class="page-title">Sign in</h1>
12+ {error && <p class="form-error">{error}</p>}
13+ <form method="POST" action="/login" class="auth-form">
14+ <div class="form-group">
15+ <label for="username">Username</label>
16+ <input
17+ id="username"
18+ name="username"
19+ type="text"
20+ required
21+ autocomplete="username"
22+ />
23+ </div>
24+ <div id="passkey-section" style="display:none">
25+ <button
26+ id="passkey-btn"
27+ class="btn btn-secondary btn-block"
28+ type="button"
29+ >
30+ Sign in with passkey
31+ </button>
32+ <div class="auth-divider">
33+ <span>or</span>
34+ </div>
35+ </div>
36+ <div class="form-group">
37+ <label for="password">Password</label>
38+ <input
39+ id="password"
40+ name="password"
41+ type="password"
42+ required
43+ autocomplete="current-password"
44+ />
45+ </div>
46+ <button type="submit" class="btn btn-primary btn-block">
47+ Sign in with password
48+ </button>
49+ </form>
50+ <p class="auth-footer">
51+ Don't have an account? <a href="/register">Register</a>
52+ </p>
53+ </div>
54+ <script type="module">{`
55+ import { startAuthentication } from '/assets/simplewebauthn-browser.js';
56+ document.getElementById('passkey-section').style.display = 'block';
57+ document.getElementById('passkey-btn').addEventListener('click', async () => {
58+ try {
59+ const optsResp = await fetch('/auth/passkey/login/options', { method: 'POST' });
60+ const opts = await optsResp.json();
61+ const result = await startAuthentication({ optionsJSON: opts });
62+ const verResp = await fetch('/auth/passkey/login/verify', {
63+ method: 'POST',
64+ headers: { 'Content-Type': 'application/json' },
65+ body: JSON.stringify(result),
66+ });
67+ if (verResp.ok) {
68+ window.location.href = '/';
69+ } else {
70+ const err = await verResp.json();
71+ alert(err.error ?? 'Passkey sign in failed');
72+ }
73+ } catch (e) {
74+ alert('Passkey sign in failed: ' + e.message);
75+ }
76+ });
77+ `}</script>
78+ </Layout>
79+ );
80+}
Asrc/views/auth/Register.tsx
@@ -0,0 +1,110 @@
1+import { Layout } from "../layout.tsx";
2+
3+interface RegisterProps {
4+ error?: string;
5+}
6+
7+export function Register({ error }: RegisterProps) {
8+ return (
9+ <Layout user={null} title="Register">
10+ <div class="auth-container">
11+ <h1 class="page-title">Create account</h1>
12+ {error && <p class="form-error">{error}</p>}
13+ <form method="POST" action="/register" class="auth-form">
14+ <div class="form-group">
15+ <label for="username">Username</label>
16+ <input
17+ id="username"
18+ name="username"
19+ type="text"
20+ required
21+ autocomplete="username"
22+ pattern="[a-zA-Z0-9_-]+"
23+ title="Letters, numbers, hyphens and underscores only"
24+ />
25+ </div>
26+ <div id="passkey-section" style="display:none">
27+ <button
28+ id="passkey-register-btn"
29+ class="btn btn-secondary btn-block"
30+ type="button"
31+ >
32+ Register with passkey
33+ </button>
34+ <div class="auth-divider">
35+ <span>or</span>
36+ </div>
37+ </div>
38+ <div class="form-group">
39+ <label for="password">Password</label>
40+ <input
41+ id="password"
42+ name="password"
43+ type="password"
44+ autocomplete="new-password"
45+ minlength="8"
46+ />
47+ </div>
48+ <div class="form-group">
49+ <label for="password2">Confirm password</label>
50+ <input
51+ id="password2"
52+ name="password2"
53+ type="password"
54+ autocomplete="new-password"
55+ minlength="8"
56+ />
57+ </div>
58+ <button type="submit" class="btn btn-primary btn-block">
59+ Register with password
60+ </button>
61+ </form>
62+ <p class="auth-footer">
63+ Already have an account? <a href="/login">Sign in</a>
64+ </p>
65+ </div>
66+ <script type="module">{`
67+ import { startRegistration } from '/assets/simplewebauthn-browser.js';
68+ const usernameInput = document.getElementById('username');
69+ document.getElementById('passkey-section').style.display = 'block';
70+ document.getElementById('passkey-register-btn').addEventListener('click', async () => {
71+ const username = usernameInput.value.trim();
72+ if (!username) { usernameInput.focus(); return; }
73+ if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
74+ alert('Username may only contain letters, numbers, hyphens, and underscores');
75+ return;
76+ }
77+ try {
78+ const createResp = await fetch('/auth/passkey/create-user', {
79+ method: 'POST',
80+ headers: { 'Content-Type': 'application/json' },
81+ body: JSON.stringify({ username }),
82+ });
83+ if (!createResp.ok) {
84+ const err = await createResp.json();
85+ alert(err.error ?? 'Failed to create account');
86+ return;
87+ }
88+ await createResp.json();
89+ const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' });
90+ const opts = await optsResp.json();
91+ const result = await startRegistration({ optionsJSON: opts });
92+ const verResp = await fetch('/auth/passkey/register/verify', {
93+ method: 'POST',
94+ headers: { 'Content-Type': 'application/json' },
95+ body: JSON.stringify(result),
96+ });
97+ if (verResp.ok) {
98+ window.location.href = '/';
99+ } else {
100+ const err = await verResp.json();
101+ alert(err.error ?? 'Passkey registration failed');
102+ }
103+ } catch (e) {
104+ alert('Passkey registration failed: ' + e.message);
105+ }
106+ });
107+ `}</script>
108+ </Layout>
109+ );
110+}
Asrc/views/issues/IssueDetail.tsx
@@ -0,0 +1,297 @@
1+import type {
2+ IssueCommentRow,
3+ IssueRow,
4+ RepositoryRow,
5+} from "../../db/index.ts";
6+import { displayName } from "../../lib/users.ts";
7+import type { SessionUser } from "../../middleware/session.ts";
8+import { Avatar } from "../Avatar.tsx";
9+import { DateWithEdited } from "../DateWithEdited.tsx";
10+import { Layout } from "../layout.tsx";
11+import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
12+import { RepoHeader } from "../repos/RepoHeader.tsx";
13+import { RepoNav } from "../repos/RepoNav.tsx";
14+
15+interface IssueDetailProps {
16+ user: SessionUser | null;
17+ repo: RepositoryRow;
18+ issue: IssueRow & {
19+ author_username: string;
20+ author_avatar_version: number | null;
21+ };
22+ bodyHtml: string;
23+ comments: (IssueCommentRow & {
24+ author_username: string;
25+ author_avatar_version: number | null;
26+ bodyHtml: string;
27+ })[];
28+ reactions: ReactionCount[];
29+ commentReactions: Map<number, ReactionCount[]>;
30+}
31+
32+export function IssueDetail({
33+ user,
34+ repo,
35+ issue,
36+ bodyHtml,
37+ comments,
38+ reactions,
39+ commentReactions,
40+}: IssueDetailProps) {
41+ const canEditIssue =
42+ user != null && (user.isAdmin || user.id === issue.author_id);
43+ const canEditComment = (c: IssueCommentRow) =>
44+ user != null && (user.isAdmin || user.id === c.author_id);
45+ return (
46+ <Layout user={user} title={`${issue.title} — ${repo.name}`}>
47+ <div class="container">
48+ <RepoHeader repo={repo} />
49+ <RepoNav repo={repo} active="issues" user={user} />
50+ <div class="issue-detail">
51+ <div class="issue-detail-header">
52+ <span class="issue-number">#{issue.number}</span>
53+ <h2 class="issue-detail-title">{issue.title}</h2>
54+ <span class={`issue-badge ${issue.status}`}>
55+ {issue.status}
56+ </span>
57+ {canEditIssue && (
58+ <div class="issue-detail-meta-actions">
59+ {user?.isAdmin && (
60+ <form
61+ method="POST"
62+ action={`/${repo.name}/issues/${issue.number}/close`}
63+ class="inline-form"
64+ >
65+ <button
66+ type="submit"
67+ class="btn btn-sm"
68+ >
69+ {issue.status === "open"
70+ ? "Close issue"
71+ : "Reopen issue"}
72+ </button>
73+ </form>
74+ )}
75+ <details class="confirm-details">
76+ <summary class="btn btn-sm btn-danger">
77+ Delete
78+ </summary>
79+ <div class="confirm-popup">
80+ Permanently delete this issue?
81+ <form
82+ method="POST"
83+ action={`/${repo.name}/issues/${issue.number}/delete`}
84+ class="inline-form"
85+ >
86+ <button
87+ type="submit"
88+ class="btn btn-sm btn-danger"
89+ >
90+ Yes, delete
91+ </button>
92+ </form>
93+ </div>
94+ </details>
95+ </div>
96+ )}
97+ </div>
98+
99+ <div class="timeline-item">
100+ <div class="timeline-author">
101+ <Avatar
102+ userId={issue.author_id}
103+ version={issue.author_avatar_version}
104+ size={24}
105+ />
106+ <strong>
107+ {displayName(issue.author_username)}
108+ </strong>
109+ <div class="timeline-author-right">
110+ {canEditIssue && (
111+ <details class="inline-edit-details">
112+ <summary class="btn btn-xs btn-ghost">
113+ <span class="when-closed">
114+ Edit
115+ </span>
116+ <span class="when-open">
117+ Stop editing
118+ </span>
119+ </summary>
120+ </details>
121+ )}
122+ <DateWithEdited
123+ date={issue.created_at}
124+ editedAt={issue.edited_at}
125+ />
126+ </div>
127+ </div>
128+ {canEditIssue && (
129+ <div class="inline-edit-form-area">
130+ <form
131+ method="POST"
132+ action={`/${repo.name}/issues/${issue.number}/edit`}
133+ class="inline-edit-form"
134+ >
135+ <div class="form-group">
136+ <label
137+ class="form-label"
138+ for="edit-issue-title"
139+ >
140+ Title
141+ </label>
142+ <input
143+ class="form-input"
144+ id="edit-issue-title"
145+ name="title"
146+ value={issue.title}
147+ required
148+ />
149+ </div>
150+ <div class="form-group">
151+ <label
152+ class="form-label"
153+ for="edit-issue-body"
154+ >
155+ Description
156+ </label>
157+ <textarea
158+ class="form-input"
159+ id="edit-issue-body"
160+ name="edit_body"
161+ rows="6"
162+ >
163+ {issue.body}
164+ </textarea>
165+ </div>
166+ <div class="form-actions">
167+ <button
168+ type="submit"
169+ class="btn btn-sm btn-primary"
170+ >
171+ Save
172+ </button>
173+ </div>
174+ </form>
175+ </div>
176+ )}
177+ <div class="timeline-body markdown-body">
178+ {bodyHtml || (
179+ <em class="text-muted">
180+ No description provided.
181+ </em>
182+ )}
183+ </div>
184+ <ReactionBar
185+ reactions={reactions}
186+ postUrl={`/${repo.name}/issues/${issue.number}/react`}
187+ user={user}
188+ />
189+ </div>
190+
191+ {comments.map((comment) => (
192+ <div class="timeline-item">
193+ <div class="timeline-author">
194+ <Avatar
195+ userId={comment.author_id}
196+ version={comment.author_avatar_version}
197+ size={24}
198+ />
199+ <strong>
200+ {displayName(comment.author_username)}
201+ </strong>
202+ <div class="timeline-author-right">
203+ {canEditComment(comment) && (
204+ <details class="inline-edit-details">
205+ <summary class="btn btn-xs btn-ghost">
206+ <span class="when-closed">
207+ Edit
208+ </span>
209+ <span class="when-open">
210+ Stop editing
211+ </span>
212+ </summary>
213+ </details>
214+ )}
215+ <DateWithEdited
216+ date={comment.created_at}
217+ editedAt={comment.edited_at}
218+ />
219+ </div>
220+ </div>
221+ {canEditComment(comment) && (
222+ <div class="inline-edit-form-area">
223+ <form
224+ method="POST"
225+ action={`/${repo.name}/issues/${issue.number}/comments/${comment.id}/edit`}
226+ class="inline-edit-form"
227+ >
228+ <div class="form-group">
229+ <textarea
230+ class="form-input"
231+ name="edit_body"
232+ rows="6"
233+ >
234+ {comment.body}
235+ </textarea>
236+ </div>
237+ <div class="form-actions">
238+ <button
239+ type="submit"
240+ class="btn btn-sm btn-primary"
241+ >
242+ Save
243+ </button>
244+ </div>
245+ </form>
246+ </div>
247+ )}
248+ <div class="timeline-body markdown-body">
249+ {comment.bodyHtml}
250+ </div>
251+ <ReactionBar
252+ reactions={
253+ commentReactions.get(comment.id) ?? []
254+ }
255+ postUrl={`/${repo.name}/issues/${issue.number}/react`}
256+ commentId={comment.id}
257+ user={user}
258+ />
259+ </div>
260+ ))}
261+
262+ {user && (
263+ <div class="timeline-item timeline-item-new">
264+ <h3 class="section-title">Add a comment</h3>
265+ <form
266+ method="POST"
267+ action={`/${repo.name}/issues/${issue.number}/comments`}
268+ >
269+ <div class="form-group">
270+ <textarea
271+ name="body"
272+ rows="6"
273+ placeholder="Leave a comment (Markdown supported)"
274+ required
275+ />
276+ </div>
277+ <div class="form-actions">
278+ <button
279+ type="submit"
280+ class="btn btn-primary"
281+ >
282+ Comment
283+ </button>
284+ </div>
285+ </form>
286+ </div>
287+ )}
288+ {!user && (
289+ <p class="text-muted">
290+ <a href="/login">Sign in</a> to leave a comment.
291+ </p>
292+ )}
293+ </div>
294+ </div>
295+ </Layout>
296+ );
297+}
Asrc/views/issues/IssueList.tsx
@@ -0,0 +1,102 @@
1+import type { IssueRow, RepositoryRow } from "../../db/index.ts";
2+import { displayName } from "../../lib/users.ts";
3+import type { SessionUser } from "../../middleware/session.ts";
4+import { Layout } from "../layout.tsx";
5+import { Pagination, type PaginationInfo } from "../Pagination.tsx";
6+import { RepoHeader } from "../repos/RepoHeader.tsx";
7+import { RepoNav } from "../repos/RepoNav.tsx";
8+
9+interface IssueListProps {
10+ user: SessionUser | null;
11+ repo: RepositoryRow;
12+ issues: (IssueRow & { author_username: string })[];
13+ status: "open" | "closed";
14+ counts: Record<string, number>;
15+ pagination: PaginationInfo;
16+}
17+
18+export function IssueList({
19+ user,
20+ repo,
21+ issues,
22+ status,
23+ counts,
24+ pagination,
25+}: IssueListProps) {
26+ return (
27+ <Layout user={user} title={`Issues — ${repo.name}`}>
28+ <div class="container">
29+ <RepoHeader repo={repo} />
30+ <RepoNav repo={repo} active="issues" user={user} />
31+ <div class="list-header">
32+ <div class="list-header-tabs">
33+ <a
34+ href={`/${repo.name}/issues?status=open`}
35+ class={`list-tab${status === "open" ? " active" : ""}`}
36+ >
37+ Open{" "}
38+ {(counts.open ?? 0) > 0 && (
39+ <span class="tab-count">{counts.open}</span>
40+ )}
41+ </a>
42+ <a
43+ href={`/${repo.name}/issues?status=closed`}
44+ class={`list-tab${status === "closed" ? " active" : ""}`}
45+ >
46+ Closed{" "}
47+ {(counts.closed ?? 0) > 0 && (
48+ <span class="tab-count">{counts.closed}</span>
49+ )}
50+ </a>
51+ </div>
52+ {user && (
53+ <a
54+ href={`/${repo.name}/issues/new`}
55+ class="btn btn-primary btn-sm"
56+ >
57+ New issue
58+ </a>
59+ )}
60+ </div>
61+ {issues.length === 0 ? (
62+ <div class="empty-state">
63+ <p>No {status} issues.</p>
64+ </div>
65+ ) : (
66+ <ul class="issue-list">
67+ {issues.map((issue) => (
68+ <li class="issue-item">
69+ <div class="issue-main">
70+ <span
71+ class={`issue-status-dot ${issue.status}`}
72+ />
73+ <a
74+ href={`/${repo.name}/issues/${issue.number}`}
75+ class="issue-title"
76+ >
77+ {issue.title}
78+ </a>
79+ <span class="issue-number">
80+ #{issue.number}
81+ </span>
82+ </div>
83+ <div class="issue-meta">
84+ <span class="issue-author">
85+ opened by{" "}
86+ {displayName(issue.author_username)}
87+ </span>
88+ <time datetime={issue.created_at}>
89+ {new Date(
90+ issue.created_at,
91+ ).toLocaleDateString()}
92+ </time>
93+ </div>
94+ </li>
95+ ))}
96+ </ul>
97+ )}
98+ <Pagination {...pagination} />
99+ </div>
100+ </Layout>
101+ );
102+}
Asrc/views/issues/NewIssue.tsx
@@ -0,0 +1,60 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import { Layout } from "../layout.tsx";
4+import { RepoHeader } from "../repos/RepoHeader.tsx";
5+import { RepoNav } from "../repos/RepoNav.tsx";
6+
7+interface NewIssueProps {
8+ user: SessionUser;
9+ repo: RepositoryRow;
10+ error?: string;
11+}
12+
13+export function NewIssue({ user, repo, error }: NewIssueProps) {
14+ return (
15+ <Layout user={user} title={`New issue — ${repo.name}`}>
16+ <div class="container container-narrow">
17+ <RepoHeader repo={repo} />
18+ <RepoNav repo={repo} active="issues" user={user} />
19+ <h2 class="section-title">New issue</h2>
20+ {error && <p class="form-error">{error}</p>}
21+ <form
22+ method="POST"
23+ action={`/${repo.name}/issues`}
24+ class="form-card"
25+ >
26+ <div class="form-group">
27+ <label for="title">Title</label>
28+ <input
29+ id="title"
30+ name="title"
31+ type="text"
32+ required
33+ placeholder="Short, descriptive title"
34+ />
35+ </div>
36+ <div class="form-group">
37+ <label for="body">
38+ Description{" "}
39+ <span class="text-muted">(Markdown supported)</span>
40+ </label>
41+ <textarea
42+ id="body"
43+ name="body"
44+ rows="10"
45+ placeholder="Describe the issue..."
46+ />
47+ </div>
48+ <div class="form-actions">
49+ <button type="submit" class="btn btn-primary">
50+ Submit issue
51+ </button>
52+ <a href={`/${repo.name}/issues`} class="btn btn-ghost">
53+ Cancel
54+ </a>
55+ </div>
56+ </form>
57+ </div>
58+ </Layout>
59+ );
60+}
Asrc/views/layout.tsx
@@ -0,0 +1,91 @@
1+import { OWNER_DISPLAY_NAME } from "../config.ts";
2+import { displayName } from "../lib/users.ts";
3+import type { SessionUser } from "../middleware/session.ts";
4+import { Avatar } from "./Avatar.tsx";
5+
6+interface LayoutProps {
7+ user: SessionUser | null;
8+ title?: string;
9+ children?: JSX.Element | JSX.Element[] | string | null;
10+}
11+
12+export function Layout({ user, title, children }: LayoutProps) {
13+ const pageTitle = title ? `${title} — Hearthforge` : "Hearthforge";
14+ return (
15+ <html lang="en">
16+ <head>
17+ <meta charset="UTF-8" />
18+ <meta name="viewport" content="width=500" />
19+ <title>{pageTitle}</title>
20+ <script src="/assets/theme.js"></script>
21+ <link
22+ rel="icon"
23+ type="image/svg+xml"
24+ href="/assets/favicon.svg"
25+ />
26+ <link rel="stylesheet" href="/assets/main.css" />
27+ <script src="/assets/jxl-polyfill.js" defer></script>
28+ </head>
29+ <body>
30+ <header class="site-header">
31+ <nav class="site-nav">
32+ <a href="/" class="site-logo">
33+ <img
34+ src="/assets/favicon.svg"
35+ class="logo-icon"
36+ alt=""
37+ aria-hidden="true"
38+ />
39+ <span class="logo-text">Hearthforge</span>
40+ </a>
41+ <div class="nav-links">
42+ {user ? (
43+ <>
44+ <a href="/settings" class="nav-user">
45+ <Avatar
46+ userId={user.id}
47+ version={user.avatar_version}
48+ size={24}
49+ />
50+ {displayName(user.username)}
51+ </a>
52+ <form
53+ method="POST"
54+ action="/logout"
55+ class="inline-form"
56+ >
57+ <button
58+ type="submit"
59+ class="btn btn-sm btn-ghost"
60+ >
61+ Sign out
62+ </button>
63+ </form>
64+ </>
65+ ) : (
66+ <>
67+ <a
68+ href="/login"
69+ class="btn btn-sm btn-ghost"
70+ >
71+ Sign in
72+ </a>
73+ <a
74+ href="/register"
75+ class="btn btn-sm btn-primary"
76+ >
77+ Register
78+ </a>
79+ </>
80+ )}
81+ </div>
82+ </nav>
83+ </header>
84+ <main class="site-main">{children}</main>
85+ <footer class="site-footer">
86+ <p>Hearthforge — hosted by {OWNER_DISPLAY_NAME}</p>
87+ </footer>
88+ </body>
89+ </html>
90+ );
91+}
Asrc/views/patches/NewPatch.tsx
@@ -0,0 +1,75 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import { Layout } from "../layout.tsx";
4+import { RepoHeader } from "../repos/RepoHeader.tsx";
5+import { RepoNav } from "../repos/RepoNav.tsx";
6+
7+interface NewPatchProps {
8+ user: SessionUser;
9+ repo: RepositoryRow;
10+ error?: string;
11+}
12+
13+export function NewPatch({ user, repo, error }: NewPatchProps) {
14+ return (
15+ <Layout user={user} title={`New patch — ${repo.name}`}>
16+ <div class="container container-narrow">
17+ <RepoHeader repo={repo} />
18+ <RepoNav repo={repo} active="patches" user={user} />
19+ <h2 class="section-title">Upload patch</h2>
20+ {error && <p class="form-error">{error}</p>}
21+ <form
22+ method="POST"
23+ action={`/${repo.name}/patches`}
24+ enctype="multipart/form-data"
25+ class="form-card"
26+ >
27+ <div class="form-group">
28+ <label for="title">Title</label>
29+ <input
30+ id="title"
31+ name="title"
32+ type="text"
33+ required
34+ placeholder="What does this patch do?"
35+ />
36+ </div>
37+ <div class="form-group">
38+ <label for="description">
39+ Description{" "}
40+ <span class="text-muted">
41+ (Markdown supported, optional)
42+ </span>
43+ </label>
44+ <textarea
45+ id="description"
46+ name="description"
47+ rows="5"
48+ />
49+ </div>
50+ <div class="form-group">
51+ <label for="patch_file">
52+ Patch file{" "}
53+ <span class="text-muted">(.patch or .diff)</span>
54+ </label>
55+ <input
56+ id="patch_file"
57+ name="patch_file"
58+ type="file"
59+ accept=".patch,.diff,text/plain"
60+ required
61+ />
62+ </div>
63+ <div class="form-actions">
64+ <button type="submit" class="btn btn-primary">
65+ Upload patch
66+ </button>
67+ <a href={`/${repo.name}/patches`} class="btn btn-ghost">
68+ Cancel
69+ </a>
70+ </div>
71+ </form>
72+ </div>
73+ </Layout>
74+ );
75+}
Asrc/views/patches/PatchDetail.tsx
@@ -0,0 +1,382 @@
1+import type {
2+ PatchCommentRow,
3+ PatchRow,
4+ RepositoryRow,
5+} from "../../db/index.ts";
6+import { displayName } from "../../lib/users.ts";
7+import type { SessionUser } from "../../middleware/session.ts";
8+import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
9+import type { ApplyResult } from "../../services/patchCache.ts";
10+import { Avatar } from "../Avatar.tsx";
11+import { DateWithEdited } from "../DateWithEdited.tsx";
12+import { DiffView } from "../DiffView.tsx";
13+import { Layout } from "../layout.tsx";
14+import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
15+import { RepoHeader } from "../repos/RepoHeader.tsx";
16+import { RepoNav } from "../repos/RepoNav.tsx";
17+
18+interface PatchDetailProps {
19+ user: SessionUser | null;
20+ repo: RepositoryRow;
21+ patch: PatchRow & {
22+ author_username: string;
23+ author_avatar_version: number | null;
24+ };
25+ descriptionHtml: string;
26+ applyResult: ApplyResult | null;
27+ files: RenderedDiffFile[];
28+ tab: "conversation" | "changes";
29+ comments: (PatchCommentRow & {
30+ author_username: string;
31+ author_avatar_version: number | null;
32+ bodyHtml: string;
33+ })[];
34+ reactions: ReactionCount[];
35+ commentReactions: Map<number, ReactionCount[]>;
36+}
37+
38+export function PatchDetail({
39+ user,
40+ repo,
41+ patch,
42+ descriptionHtml,
43+ applyResult,
44+ files,
45+ tab,
46+ comments,
47+ reactions,
48+ commentReactions,
49+}: PatchDetailProps) {
50+ const canEdit =
51+ user != null && (user.isAdmin || user.id === patch.author_id);
52+ const canEditComment = (c: PatchCommentRow) =>
53+ user != null && (user.isAdmin || user.id === c.author_id);
54+
55+ const baseUrl = `/${repo.name}/patches/${patch.number}`;
56+ const reactUrl = `${baseUrl}/react`;
57+
58+ return (
59+ <Layout user={user} title={`${patch.title} — ${repo.name}`}>
60+ <div class="container">
61+ <RepoHeader repo={repo} />
62+ <RepoNav repo={repo} active="patches" user={user} />
63+
64+ <div class="issue-detail">
65+ <div class="issue-detail-header">
66+ <span class="issue-number">#{patch.number}</span>
67+ <h2 class="issue-detail-title">{patch.title}</h2>
68+ <span class={`patch-badge ${patch.status}`}>
69+ {patch.status}
70+ </span>
71+ {canEdit && (
72+ <div class="issue-detail-meta-actions">
73+ {user?.isAdmin &&
74+ patch.status === "open" &&
75+ applyResult?.status === "clean" && (
76+ <form
77+ method="POST"
78+ action={`${baseUrl}/merge`}
79+ class="inline-form"
80+ >
81+ <button
82+ type="submit"
83+ class="btn btn-sm btn-primary"
84+ >
85+ Merge patch
86+ </button>
87+ </form>
88+ )}
89+ {user?.isAdmin && patch.status !== "merged" && (
90+ <form
91+ method="POST"
92+ action={`${baseUrl}/close`}
93+ class="inline-form"
94+ >
95+ <button
96+ type="submit"
97+ class="btn btn-sm"
98+ >
99+ {patch.status === "open"
100+ ? "Close patch"
101+ : "Reopen patch"}
102+ </button>
103+ </form>
104+ )}
105+ <details class="confirm-details">
106+ <summary class="btn btn-sm btn-danger">
107+ Delete
108+ </summary>
109+ <div class="confirm-popup">
110+ Permanently delete this patch?
111+ <form
112+ method="POST"
113+ action={`${baseUrl}/delete`}
114+ class="inline-form"
115+ >
116+ <button
117+ type="submit"
118+ class="btn btn-sm btn-danger"
119+ >
120+ Yes, delete
121+ </button>
122+ </form>
123+ </div>
124+ </details>
125+ </div>
126+ )}
127+ </div>
128+ </div>
129+
130+ {/* Subview tabs */}
131+ <div class="patch-tab-nav">
132+ <a
133+ href={baseUrl}
134+ class={`repo-tab${tab === "conversation" ? " active" : ""}`}
135+ >
136+ Conversation{" "}
137+ {comments.length > 0 && (
138+ <span class="tab-count">{comments.length}</span>
139+ )}
140+ </a>
141+ <a
142+ href={`${baseUrl}?tab=changes`}
143+ class={`repo-tab${tab === "changes" ? " active" : ""}`}
144+ >
145+ Changes{" "}
146+ {files.length > 0 && (
147+ <span class="tab-count">{files.length}</span>
148+ )}
149+ </a>
150+ </div>
151+
152+ {tab === "conversation" ? (
153+ <div class="issue-detail">
154+ {/* Patch description */}
155+ <div class="timeline-item">
156+ <div class="timeline-author">
157+ <Avatar
158+ userId={patch.author_id}
159+ version={patch.author_avatar_version}
160+ size={24}
161+ />
162+ <strong>
163+ {displayName(patch.author_username)}
164+ </strong>
165+ <div class="timeline-author-right">
166+ {canEdit && (
167+ <details class="inline-edit-details">
168+ <summary class="btn btn-xs btn-ghost">
169+ <span class="when-closed">
170+ Edit
171+ </span>
172+ <span class="when-open">
173+ Stop editing
174+ </span>
175+ </summary>
176+ </details>
177+ )}
178+ <DateWithEdited
179+ date={patch.created_at}
180+ editedAt={patch.edited_at}
181+ />
182+ </div>
183+ </div>
184+ {canEdit && (
185+ <div class="inline-edit-form-area">
186+ <form
187+ method="POST"
188+ action={`${baseUrl}/edit`}
189+ class="inline-edit-form"
190+ >
191+ <div class="form-group">
192+ <label
193+ class="form-label"
194+ for="edit-patch-title"
195+ >
196+ Title
197+ </label>
198+ <input
199+ class="form-input"
200+ id="edit-patch-title"
201+ name="title"
202+ value={patch.title}
203+ required
204+ />
205+ </div>
206+ <div class="form-group">
207+ <label
208+ class="form-label"
209+ for="edit-patch-desc"
210+ >
211+ Description
212+ </label>
213+ <textarea
214+ class="form-input"
215+ id="edit-patch-desc"
216+ name="edit_description"
217+ rows="6"
218+ >
219+ {patch.description}
220+ </textarea>
221+ </div>
222+ <div class="form-actions">
223+ <button
224+ type="submit"
225+ class="btn btn-sm btn-primary"
226+ >
227+ Save
228+ </button>
229+ </div>
230+ </form>
231+ </div>
232+ )}
233+ <div class="timeline-body markdown-body">
234+ {descriptionHtml || (
235+ <em class="text-muted">
236+ No description provided.
237+ </em>
238+ )}
239+ </div>
240+ <ReactionBar
241+ reactions={reactions}
242+ postUrl={reactUrl}
243+ user={user}
244+ />
245+ </div>
246+
247+ {/* Apply status */}
248+ {applyResult && (
249+ <div class="timeline-item">
250+ <div
251+ class={`apply-result apply-${applyResult.status}`}
252+ >
253+ <span class="apply-icon">
254+ {applyResult.status === "clean"
255+ ? "✓"
256+ : "✗"}
257+ </span>
258+ <span>
259+ {applyResult.status === "clean"
260+ ? "Applies cleanly"
261+ : "Has conflicts"}
262+ </span>
263+ {applyResult.output && (
264+ <pre class="apply-output">
265+ {applyResult.output}
266+ </pre>
267+ )}
268+ </div>
269+ </div>
270+ )}
271+
272+ {/* Comments */}
273+ {comments.map((comment) => (
274+ <div class="timeline-item">
275+ <div class="timeline-author">
276+ <Avatar
277+ userId={comment.author_id}
278+ version={comment.author_avatar_version}
279+ size={24}
280+ />
281+ <strong>
282+ {displayName(comment.author_username)}
283+ </strong>
284+ <div class="timeline-author-right">
285+ {canEditComment(comment) && (
286+ <details class="inline-edit-details">
287+ <summary class="btn btn-xs btn-ghost">
288+ <span class="when-closed">
289+ Edit
290+ </span>
291+ <span class="when-open">
292+ Stop editing
293+ </span>
294+ </summary>
295+ </details>
296+ )}
297+ <DateWithEdited
298+ date={comment.created_at}
299+ editedAt={comment.edited_at}
300+ />
301+ </div>
302+ </div>
303+ {canEditComment(comment) && (
304+ <div class="inline-edit-form-area">
305+ <form
306+ method="POST"
307+ action={`${baseUrl}/comments/${comment.id}/edit`}
308+ class="inline-edit-form"
309+ >
310+ <div class="form-group">
311+ <textarea
312+ class="form-input"
313+ name="edit_body"
314+ rows="6"
315+ >
316+ {comment.body}
317+ </textarea>
318+ </div>
319+ <div class="form-actions">
320+ <button
321+ type="submit"
322+ class="btn btn-sm btn-primary"
323+ >
324+ Save
325+ </button>
326+ </div>
327+ </form>
328+ </div>
329+ )}
330+ <div class="timeline-body markdown-body">
331+ {comment.bodyHtml}
332+ </div>
333+ <ReactionBar
334+ reactions={
335+ commentReactions.get(comment.id) ?? []
336+ }
337+ postUrl={reactUrl}
338+ commentId={comment.id}
339+ user={user}
340+ />
341+ </div>
342+ ))}
343+
344+ {user && (
345+ <div class="timeline-item timeline-item-new">
346+ <h3 class="section-title">Add a comment</h3>
347+ <form
348+ method="POST"
349+ action={`${baseUrl}/comments`}
350+ >
351+ <div class="form-group">
352+ <textarea
353+ name="body"
354+ rows="6"
355+ placeholder="Leave a comment (Markdown supported)"
356+ required
357+ />
358+ </div>
359+ <div class="form-actions">
360+ <button
361+ type="submit"
362+ class="btn btn-primary"
363+ >
364+ Comment
365+ </button>
366+ </div>
367+ </form>
368+ </div>
369+ )}
370+ {!user && (
371+ <p class="text-muted">
372+ <a href="/login">Sign in</a> to leave a comment.
373+ </p>
374+ )}
375+ </div>
376+ ) : (
377+ <DiffView files={files} repo={repo} />
378+ )}
379+ </div>
380+ </Layout>
381+ );
382+}
Asrc/views/patches/PatchList.tsx
@@ -0,0 +1,110 @@
1+import type { PatchRow, RepositoryRow } from "../../db/index.ts";
2+import { displayName } from "../../lib/users.ts";
3+import type { SessionUser } from "../../middleware/session.ts";
4+import { Layout } from "../layout.tsx";
5+import { Pagination, type PaginationInfo } from "../Pagination.tsx";
6+import { RepoHeader } from "../repos/RepoHeader.tsx";
7+import { RepoNav } from "../repos/RepoNav.tsx";
8+
9+interface PatchListProps {
10+ user: SessionUser | null;
11+ repo: RepositoryRow;
12+ patches: (PatchRow & { author_username: string })[];
13+ status: string;
14+ counts: Record<string, number>;
15+ pagination: PaginationInfo;
16+}
17+
18+export function PatchList({
19+ user,
20+ repo,
21+ patches,
22+ status,
23+ counts,
24+ pagination,
25+}: PatchListProps) {
26+ return (
27+ <Layout user={user} title={`Patches — ${repo.name}`}>
28+ <div class="container">
29+ <RepoHeader repo={repo} />
30+ <RepoNav repo={repo} active="patches" user={user} />
31+ <div class="list-header">
32+ <div class="list-header-tabs">
33+ <a
34+ href={`/${repo.name}/patches?status=open`}
35+ class={`list-tab${status === "open" ? " active" : ""}`}
36+ >
37+ Open{" "}
38+ {(counts.open ?? 0) > 0 && (
39+ <span class="tab-count">{counts.open}</span>
40+ )}
41+ </a>
42+ <a
43+ href={`/${repo.name}/patches?status=merged`}
44+ class={`list-tab${status === "merged" ? " active" : ""}`}
45+ >
46+ Merged{" "}
47+ {(counts.merged ?? 0) > 0 && (
48+ <span class="tab-count">{counts.merged}</span>
49+ )}
50+ </a>
51+ <a
52+ href={`/${repo.name}/patches?status=closed`}
53+ class={`list-tab${status === "closed" ? " active" : ""}`}
54+ >
55+ Closed{" "}
56+ {(counts.closed ?? 0) > 0 && (
57+ <span class="tab-count">{counts.closed}</span>
58+ )}
59+ </a>
60+ </div>
61+ {user && (
62+ <a
63+ href={`/${repo.name}/patches/new`}
64+ class="btn btn-primary btn-sm"
65+ >
66+ Upload patch
67+ </a>
68+ )}
69+ </div>
70+ {patches.length === 0 ? (
71+ <div class="empty-state">
72+ <p>No {status} patches.</p>
73+ </div>
74+ ) : (
75+ <ul class="issue-list">
76+ {patches.map((patch) => (
77+ <li class="issue-item">
78+ <div class="issue-main">
79+ <span
80+ class={`patch-status-dot ${patch.status}`}
81+ />
82+ <a
83+ href={`/${repo.name}/patches/${patch.number}`}
84+ class="issue-title"
85+ >
86+ {patch.title}
87+ </a>
88+ <span class="issue-number">
89+ #{patch.number}
90+ </span>
91+ </div>
92+ <div class="issue-meta">
93+ <span>
94+ by {displayName(patch.author_username)}
95+ </span>
96+ <time datetime={patch.created_at}>
97+ {new Date(
98+ patch.created_at,
99+ ).toLocaleDateString()}
100+ </time>
101+ </div>
102+ </li>
103+ ))}
104+ </ul>
105+ )}
106+ <Pagination {...pagination} />
107+ </div>
108+ </Layout>
109+ );
110+}
Asrc/views/releases/NewRelease.tsx
@@ -0,0 +1,136 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import { Layout } from "../layout.tsx";
4+import { RepoHeader } from "../repos/RepoHeader.tsx";
5+import { RepoNav } from "../repos/RepoNav.tsx";
6+
7+interface NewReleaseProps {
8+ user: SessionUser;
9+ repo: RepositoryRow;
10+ error?: string;
11+ values?: {
12+ tag_name?: string;
13+ name?: string;
14+ notes?: string;
15+ commit_hash?: string;
16+ include_source_code?: boolean;
17+ };
18+}
19+
20+export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
21+ return (
22+ <Layout user={user} title={`New Release — ${repo.name}`}>
23+ <div class="container">
24+ <RepoHeader repo={repo} />
25+ <RepoNav repo={repo} active="releases" user={user} />
26+ <div class="form-page">
27+ <h2 class="page-title">New Release</h2>
28+ {error && <div class="flash flash-error">{error}</div>}
29+ <form
30+ method="post"
31+ action={`/${repo.name}/releases`}
32+ enctype="multipart/form-data"
33+ class="form"
34+ >
35+ <div class="form-group">
36+ <label class="form-label" for="tag_name">
37+ Tag name <span class="form-required">*</span>
38+ </label>
39+ <input
40+ type="text"
41+ id="tag_name"
42+ name="tag_name"
43+ class="form-input"
44+ required
45+ value={values?.tag_name ?? ""}
46+ placeholder="v1.0.0"
47+ />
48+ </div>
49+ <div class="form-group">
50+ <label class="form-label" for="name">
51+ Release title
52+ </label>
53+ <input
54+ type="text"
55+ id="name"
56+ name="name"
57+ class="form-input"
58+ value={values?.name ?? ""}
59+ placeholder="Optional display name"
60+ />
61+ </div>
62+ <div class="form-group">
63+ <label class="form-label" for="commit_hash">
64+ Commit hash <span class="form-required">*</span>
65+ </label>
66+ <input
67+ type="text"
68+ id="commit_hash"
69+ name="commit_hash"
70+ class="form-input monospace"
71+ required
72+ value={values?.commit_hash ?? ""}
73+ placeholder="Full or abbreviated commit SHA"
74+ />
75+ </div>
76+ <div class="form-group">
77+ <label class="form-label" for="notes">
78+ Release notes
79+ </label>
80+ <textarea
81+ id="notes"
82+ name="notes"
83+ class="form-input form-textarea"
84+ rows="8"
85+ >
86+ {values?.notes ?? ""}
87+ </textarea>
88+ </div>
89+ <div class="form-group">
90+ <label class="checkbox-label">
91+ <input
92+ type="checkbox"
93+ name="include_source_code"
94+ value="on"
95+ checked={
96+ values?.include_source_code ?? false
97+ }
98+ />
99+ <span>
100+ Include source code archives (zip, tar.gz,
101+ tar.zst)
102+ </span>
103+ </label>
104+ </div>
105+ <div class="form-group">
106+ <label class="form-label" for="files">
107+ Attach files
108+ </label>
109+ <input
110+ type="file"
111+ id="files"
112+ name="files"
113+ class="form-input"
114+ multiple
115+ />
116+ <p class="form-hint">
117+ You can attach multiple files to this release.
118+ </p>
119+ </div>
120+ <div class="form-actions">
121+ <button type="submit" class="btn btn-primary">
122+ Create release
123+ </button>
124+ <a
125+ href={`/${repo.name}/releases`}
126+ class="btn btn-secondary"
127+ >
128+ Cancel
129+ </a>
130+ </div>
131+ </form>
132+ </div>
133+ </div>
134+ </Layout>
135+ );
136+}
Asrc/views/releases/ReleaseDetail.tsx
@@ -0,0 +1,179 @@
1+import type {
2+ ReleaseAssetRow,
3+ ReleaseRow,
4+ RepositoryRow,
5+} from "../../db/index.ts";
6+import type { SessionUser } from "../../middleware/session.ts";
7+import { Layout } from "../layout.tsx";
8+import { RepoHeader } from "../repos/RepoHeader.tsx";
9+import { RepoNav } from "../repos/RepoNav.tsx";
10+
11+function formatBytes(bytes: number): string {
12+ if (bytes < 1024) return `${bytes} B`;
13+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
14+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
15+}
16+
17+interface SourceArchive {
18+ format: string;
19+ filename: string;
20+ size: number;
21+}
22+
23+interface ReleaseDetailProps {
24+ user: SessionUser | null;
25+ repo: RepositoryRow;
26+ release: ReleaseRow;
27+ notesHtml: string;
28+ assets: ReleaseAssetRow[];
29+ sourceArchives: SourceArchive[];
30+ sourceArchivesPending?: boolean;
31+}
32+
33+export function ReleaseDetail({
34+ user,
35+ repo,
36+ release,
37+ notesHtml,
38+ assets,
39+ sourceArchives,
40+ sourceArchivesPending,
41+}: ReleaseDetailProps) {
42+ const hasDownloads =
43+ sourceArchives.length > 0 || assets.length > 0 || sourceArchivesPending;
44+
45+ return (
46+ <Layout
47+ user={user}
48+ title={`${release.name || release.tag_name} — ${repo.name}`}
49+ >
50+ <div class="container">
51+ <RepoHeader repo={repo} />
52+ <RepoNav repo={repo} active="releases" user={user} />
53+ <div class="release-detail">
54+ <div class="release-header">
55+ <div class="release-item-header">
56+ <div class="release-item-main">
57+ <h2
58+ class="page-title"
59+ style={`view-transition-name: release-title-${release.id}`}
60+ >
61+ {release.name || release.tag_name}
62+ </h2>
63+ <div class="release-item-meta">
64+ <a
65+ href={`/${repo.name}/tree/${release.commit_hash}`}
66+ class="monospace"
67+ >
68+ {release.commit_hash.slice(0, 8)}
69+ </a>
70+ </div>
71+ </div>
72+ <div class="release-item-date">
73+ <span class="badge">{release.tag_name}</span>
74+ <time datetime={release.created_at}>
75+ {new Date(
76+ release.created_at,
77+ ).toLocaleDateString()}
78+ </time>
79+ </div>
80+ </div>
81+ </div>
82+
83+ {notesHtml && (
84+ <div class="markdown-body release-notes">
85+ {notesHtml}
86+ </div>
87+ )}
88+
89+ {hasDownloads && (
90+ <div class="release-assets">
91+ <h3 class="release-assets-heading">Downloads</h3>
92+ <ul class="asset-list">
93+ {assets.map((asset) => (
94+ <li class="asset-item">
95+ <a
96+ href={`/${repo.name}/releases/${release.id}/assets/${asset.filename}`}
97+ class="asset-name"
98+ >
99+ {asset.filename}
100+ </a>
101+ <span class="asset-size">
102+ {formatBytes(asset.size)}
103+ </span>
104+ </li>
105+ ))}
106+ {sourceArchives.map((archive) => (
107+ <li class="asset-item">
108+ <a
109+ href={`/${repo.name}/releases/${release.id}/source/${archive.filename}`}
110+ class="asset-name"
111+ >
112+ {archive.filename}
113+ </a>
114+ <span class="asset-meta">
115+ Source code ({archive.format})
116+ </span>
117+ <span class="asset-size">
118+ {formatBytes(archive.size)}
119+ </span>
120+ </li>
121+ ))}
122+ {sourceArchivesPending &&
123+ (
124+ [
125+ ["zip", ".zip"],
126+ ["tar.gz", ".tar.gz"],
127+ ["tar.zst", ".tar.zst"],
128+ ] as const
129+ ).map(([format, ext]) => (
130+ <li class="asset-item asset-item-pending">
131+ <span class="asset-name asset-name-pending">
132+ {`${repo.name}-${release.commit_hash.slice(0, 8)}${ext}`}
133+ </span>
134+ <span class="asset-meta">
135+ Source code ({format}) —
136+ generating…
137+ </span>
138+ </li>
139+ ))}
140+ </ul>
141+ </div>
142+ )}
143+
144+ <div class="release-back">
145+ <a
146+ href={`/${repo.name}/releases`}
147+ class="btn btn-secondary btn-sm"
148+ >
149+ ← All releases
150+ </a>
151+ {user?.isAdmin && (
152+ <details class="confirm-details">
153+ <summary class="btn btn-sm btn-danger">
154+ Delete release
155+ </summary>
156+ <div class="confirm-popup">
157+ Permanently delete this release and all its
158+ files?
159+ <form
160+ method="POST"
161+ action={`/${repo.name}/releases/${release.id}/delete`}
162+ class="inline-form"
163+ >
164+ <button
165+ type="submit"
166+ class="btn btn-sm btn-danger"
167+ >
168+ Yes, delete
169+ </button>
170+ </form>
171+ </div>
172+ </details>
173+ )}
174+ </div>
175+ </div>
176+ </div>
177+ </Layout>
178+ );
179+}
Asrc/views/releases/ReleaseList.tsx
@@ -0,0 +1,122 @@
1+import type { ReleaseRow, RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import {
4+ markdownToPlaintext,
5+ renderMarkdown,
6+} from "../../services/markdown.ts";
7+import { Layout } from "../layout.tsx";
8+import { Pagination, type PaginationInfo } from "../Pagination.tsx";
9+import { RepoHeader } from "../repos/RepoHeader.tsx";
10+import { RepoNav } from "../repos/RepoNav.tsx";
11+
12+interface ReleaseListProps {
13+ user: SessionUser | null;
14+ repo: RepositoryRow;
15+ releases: (ReleaseRow & { asset_count: number })[];
16+ pagination: PaginationInfo;
17+}
18+
19+function NotesPreview({ notes }: { notes: string }) {
20+ const plaintext = markdownToPlaintext(notes);
21+ return (
22+ <details class="notes-expand">
23+ <summary class="notes-toggle">
24+ <div class="notes-preview">{plaintext}</div>
25+ <span class="notes-toggle-label" />
26+ </summary>
27+ <div class="notes-full markdown-body">{renderMarkdown(notes)}</div>
28+ </details>
29+ );
30+}
31+
32+export function ReleaseList({
33+ user,
34+ repo,
35+ releases,
36+ pagination,
37+}: ReleaseListProps) {
38+ return (
39+ <Layout user={user} title={`Releases — ${repo.name}`}>
40+ <div class="container">
41+ <RepoHeader repo={repo} />
42+ <RepoNav repo={repo} active="releases" user={user} />
43+ <div class="list-header">
44+ <h2 class="list-heading">Releases</h2>
45+ {user?.isAdmin && (
46+ <a
47+ href={`/${repo.name}/releases/new`}
48+ class="btn btn-primary btn-sm"
49+ >
50+ New release
51+ </a>
52+ )}
53+ </div>
54+ {releases.length === 0 ? (
55+ <div class="empty-state">
56+ <p>No releases yet.</p>
57+ </div>
58+ ) : (
59+ <ul class="issue-list">
60+ {releases.map((release) => (
61+ <li class="issue-item">
62+ <div class="release-item-header">
63+ <div class="release-item-main">
64+ <a
65+ href={`/${repo.name}/releases/${release.id}`}
66+ class="release-item-title"
67+ style={`view-transition-name: release-title-${release.id}`}
68+ >
69+ {release.name || release.tag_name}
70+ </a>
71+ <div class="release-item-meta">
72+ <a
73+ href={`/${repo.name}/tree/${release.commit_hash}`}
74+ class="monospace"
75+ >
76+ {release.commit_hash.slice(
77+ 0,
78+ 8,
79+ )}
80+ </a>
81+ {release.asset_count > 0 && (
82+ <span>
83+ {release.asset_count} asset
84+ {release.asset_count !== 1
85+ ? "s"
86+ : ""}
87+ </span>
88+ )}
89+ {release.include_source_code ===
90+ 1 && (
91+ <span>source archives</span>
92+ )}
93+ </div>
94+ </div>
95+ <div class="release-item-date">
96+ <span class="badge">
97+ {release.tag_name}
98+ </span>
99+ <time datetime={release.created_at}>
100+ {new Date(
101+ release.created_at,
102+ ).toLocaleDateString()}
103+ </time>
104+ </div>
105+ </div>
106+ {release.notes && (
107+ <div class="release-notes-section">
108+ <p class="release-notes-label">
109+ Release Notes
110+ </p>
111+ <NotesPreview notes={release.notes} />
112+ </div>
113+ )}
114+ </li>
115+ ))}
116+ </ul>
117+ )}
118+ <Pagination {...pagination} />
119+ </div>
120+ </Layout>
121+ );
122+}
Asrc/views/render.tsx
@@ -0,0 +1,8 @@
1+export function html(node: JSX.Element): Response {
2+ return new Response(`<!DOCTYPE html>${node}`, {
3+ headers: {
4+ "Content-Type": "text/html; charset=utf-8",
5+ "Cache-Control": "no-store",
6+ },
7+ });
8+}
Asrc/views/repos/BranchSelector.tsx
@@ -0,0 +1,58 @@
1+interface BranchSelectorProps {
2+ repoName: string;
3+ branches: string[];
4+ currentRef: string;
5+ view: "tree" | "commits" | "blob";
6+ /** subpath for tree, full file path for blob */
7+ path?: string;
8+}
9+
10+export function BranchSelector({
11+ repoName,
12+ branches,
13+ currentRef,
14+ view,
15+ path,
16+}: BranchSelectorProps) {
17+ if (branches.length === 0) return "";
18+ const isDetached = !branches.includes(currentRef);
19+ const shortRef =
20+ isDetached && currentRef.length > 8
21+ ? currentRef.slice(0, 8)
22+ : currentRef;
23+ return (
24+ <form
25+ method="GET"
26+ action={`/${repoName}/branch-switch`}
27+ class="branch-selector"
28+ >
29+ <input type="hidden" name="view" value={view} />
30+ {path && <input type="hidden" name="path" value={path} />}
31+ <span class="branch-selector-icon">⎇</span>
32+ <select
33+ name="ref"
34+ class="branch-select"
35+ onchange="this.form.submit()"
36+ >
37+ {isDetached && (
38+ <option value={currentRef} selected>
39+ {shortRef} (commit)
40+ </option>
41+ )}
42+ {branches.map((b) => (
43+ <option
44+ value={b}
45+ selected={b === currentRef ? true : undefined}
46+ >
47+ {b}
48+ </option>
49+ ))}
50+ </select>
51+ <noscript>
52+ <button type="submit" class="btn btn-sm btn-ghost">
53+ Go
54+ </button>
55+ </noscript>
56+ </form>
57+ );
58+}
Asrc/views/repos/CommitDetail.tsx
@@ -0,0 +1,111 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
4+import type { CommitMeta } from "../../services/git.ts";
5+import { DiffView } from "../DiffView.tsx";
6+import { Layout } from "../layout.tsx";
7+import { RepoHeader } from "../repos/RepoHeader.tsx";
8+import { RepoNav } from "./RepoNav.tsx";
9+
10+interface CommitDetailProps {
11+ user: SessionUser | null;
12+ repo: RepositoryRow;
13+ sha: string;
14+ meta: CommitMeta;
15+ files: RenderedDiffFile[];
16+}
17+
18+function formatDate(iso: string): string {
19+ try {
20+ return new Date(iso).toLocaleString(undefined, {
21+ dateStyle: "medium",
22+ timeStyle: "short",
23+ });
24+ } catch {
25+ return iso;
26+ }
27+}
28+
29+export function CommitDetail({
30+ user,
31+ repo,
32+ sha,
33+ meta,
34+ files,
35+}: CommitDetailProps) {
36+ return (
37+ <Layout user={user} title={`${sha.slice(0, 7)} — ${repo.name}`}>
38+ <div class="container">
39+ <RepoHeader repo={repo} />
40+ <RepoNav repo={repo} active="commits" user={user} />
41+
42+ {/* Back link */}
43+ <div class="commit-page-top">
44+ <a
45+ href={`/${repo.name}/commits/${repo.default_branch}`}
46+ class="btn btn-ghost btn-sm"
47+ >
48+ ← Back to log
49+ </a>
50+ <a
51+ href={`/${repo.name}/tree/${sha}`}
52+ class="btn btn-secondary btn-sm"
53+ title={`Browse tree at ${sha.slice(0, 7)}`}
54+ >
55+ @ {sha.slice(0, 7)}
56+ </a>
57+ </div>
58+
59+ {/* Commit metadata card */}
60+ <div class="commit-card">
61+ <h2 class="commit-card-subject">{meta.subject}</h2>
62+ {meta.body && (
63+ <pre class="commit-card-body">{meta.body}</pre>
64+ )}
65+ <div class="commit-card-meta">
66+ <div class="commit-card-meta-row">
67+ <span class="commit-meta-label">Author</span>
68+ <span class="commit-meta-value">
69+ {meta.author} &lt;{meta.email}&gt;
70+ </span>
71+ </div>
72+ <div class="commit-card-meta-row">
73+ <span class="commit-meta-label">Date</span>
74+ <time
75+ class="commit-meta-value"
76+ datetime={meta.date}
77+ >
78+ {formatDate(meta.date)}
79+ </time>
80+ </div>
81+ <div class="commit-card-meta-row">
82+ <span class="commit-meta-label">Commit</span>
83+ <code class="commit-meta-value commit-sha-full mono">
84+ {meta.hash}
85+ </code>
86+ </div>
87+ {meta.parents.length > 0 && (
88+ <div class="commit-card-meta-row">
89+ <span class="commit-meta-label">
90+ Parent{meta.parents.length > 1 ? "s" : ""}
91+ </span>
92+ <span class="commit-meta-value">
93+ {meta.parents.map((p) => (
94+ <a
95+ href={`/${repo.name}/commit/${p}`}
96+ class="commit-hash mono"
97+ >
98+ {p.slice(0, 7)}
99+ </a>
100+ ))}
101+ </span>
102+ </div>
103+ )}
104+ </div>
105+ </div>
106+
107+ <DiffView files={files} repo={repo} sha={sha} />
108+ </div>
109+ </Layout>
110+ );
111+}
Asrc/views/repos/CommitLog.tsx
@@ -0,0 +1,100 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import type { CommitEntry } from "../../services/git.ts";
4+import { Layout } from "../layout.tsx";
5+import { RepoHeader } from "../repos/RepoHeader.tsx";
6+import { BranchSelector } from "./BranchSelector.tsx";
7+import { RepoNav } from "./RepoNav.tsx";
8+
9+interface CommitLogProps {
10+ user: SessionUser | null;
11+ repo: RepositoryRow;
12+ ref: string;
13+ commits: CommitEntry[];
14+ branches: string[];
15+ /** URL for the next (older) page, or null if this is the last page. */
16+ olderUrl: string | null;
17+ /** URL for the previous (newer) page, or null if this is the first page. */
18+ newerUrl: string | null;
19+}
20+
21+export function CommitLog({
22+ user,
23+ repo,
24+ ref: logRef,
25+ commits,
26+ branches,
27+ olderUrl,
28+ newerUrl,
29+}: CommitLogProps) {
30+ return (
31+ <Layout user={user} title={`Commits — ${repo.name}`}>
32+ <div class="container">
33+ <RepoHeader repo={repo} />
34+ <RepoNav repo={repo} active="commits" user={user} />
35+ <div class="commits-header">
36+ <h2 class="section-title">Commits</h2>
37+ <BranchSelector
38+ repoName={repo.name}
39+ branches={branches}
40+ currentRef={logRef}
41+ view="commits"
42+ />
43+ </div>
44+ {commits.length === 0 ? (
45+ <p class="text-muted">No commits yet.</p>
46+ ) : (
47+ <ul class="commit-list commit-log">
48+ {commits.map((c) => (
49+ <li class="commit-item">
50+ <div class="commit-main">
51+ <a
52+ href={`/${repo.name}/commit/${c.hash}`}
53+ class="commit-subject"
54+ >
55+ {c.subject}
56+ </a>
57+ </div>
58+ <div class="commit-meta">
59+ <span class="commit-author">
60+ {c.author}
61+ </span>
62+ <a
63+ href={`/${repo.name}/commit/${c.hash}`}
64+ class="commit-hash mono"
65+ >
66+ {c.hash.slice(0, 7)}
67+ </a>
68+ <time class="commit-date" datetime={c.date}>
69+ {new Date(c.date).toLocaleString()}
70+ </time>
71+ </div>
72+ </li>
73+ ))}
74+ </ul>
75+ )}
76+ {(newerUrl || olderUrl) && (
77+ <nav
78+ class="commit-cursor-nav"
79+ aria-label="Commit history navigation"
80+ >
81+ <div class="commit-cursor-prev">
82+ {newerUrl && (
83+ <a href={newerUrl} class="pagination-btn">
84+ ← Newer
85+ </a>
86+ )}
87+ </div>
88+ <div class="commit-cursor-next">
89+ {olderUrl && (
90+ <a href={olderUrl} class="pagination-btn">
91+ Older →
92+ </a>
93+ )}
94+ </div>
95+ </nav>
96+ )}
97+ </div>
98+ </Layout>
99+ );
100+}
Asrc/views/repos/FileBlob.tsx
@@ -0,0 +1,115 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import type { FileView } from "../../services/highlight.ts";
4+import { Layout } from "../layout.tsx";
5+import { RepoHeader } from "../repos/RepoHeader.tsx";
6+import { BranchSelector } from "./BranchSelector.tsx";
7+import { RepoNav } from "./RepoNav.tsx";
8+
9+interface FileBlobProps {
10+ user: SessionUser | null;
11+ repo: RepositoryRow;
12+ ref: string;
13+ filePath: string;
14+ view: FileView;
15+ branches: string[];
16+}
17+
18+export function FileBlob({
19+ user,
20+ repo,
21+ ref: blobRef,
22+ filePath,
23+ view,
24+ branches,
25+}: FileBlobProps) {
26+ const parts = filePath.split("/");
27+ const filename = parts[parts.length - 1] ?? filePath;
28+ const dir = parts.slice(0, -1).join("/");
29+ const _backHref = dir
30+ ? `/${repo.name}/tree/${blobRef}/${dir}`
31+ : `/${repo.name}/tree/${blobRef}`;
32+ return (
33+ <Layout user={user} title={`${repo.name}/${filePath}`}>
34+ <div class="container">
35+ <RepoHeader repo={repo} />
36+ <RepoNav repo={repo} active="code" user={user} />
37+ <div class="breadcrumb">
38+ <a href={`/${repo.name}/tree/${blobRef}`}>{repo.name}</a>
39+ {parts.map((part, i) => {
40+ const partPath = parts.slice(0, i + 1).join("/");
41+ const isLast = i === parts.length - 1;
42+ return (
43+ <>
44+ <span class="breadcrumb-sep">/</span>
45+ {isLast ? (
46+ <span class="breadcrumb-current">
47+ {part}
48+ </span>
49+ ) : (
50+ <a
51+ href={`/${repo.name}/tree/${blobRef}/${partPath}`}
52+ >
53+ {part}
54+ </a>
55+ )}
56+ </>
57+ );
58+ })}
59+ </div>
60+ <div class="file-blob-header">
61+ <span class="file-blob-name">{filename}</span>
62+ <div class="file-blob-actions">
63+ <BranchSelector
64+ repoName={repo.name}
65+ branches={branches}
66+ currentRef={blobRef}
67+ view="blob"
68+ path={filePath}
69+ />
70+ <a
71+ href={`/${repo.name}/raw/${blobRef}/${filePath}`}
72+ class="btn btn-sm btn-ghost"
73+ >
74+ Raw
75+ </a>
76+ </div>
77+ </div>
78+ <div class="file-blob-body">
79+ {view.type === "inline" ? (
80+ <div class="shiki-wrapper">{view.html}</div>
81+ ) : view.type === "binary" ? (
82+ <div class="file-download-notice">
83+ <p>Binary file ({formatSize(view.size)})</p>
84+ <a
85+ href={`/${repo.name}/raw/${blobRef}/${filePath}`}
86+ class="btn btn-primary"
87+ >
88+ Download
89+ </a>
90+ </div>
91+ ) : (
92+ <div class="file-download-notice">
93+ <p>
94+ File too large to display inline (
95+ {formatSize(view.size)})
96+ </p>
97+ <a
98+ href={`/${repo.name}/raw/${blobRef}/${filePath}`}
99+ class="btn btn-primary"
100+ >
101+ Download
102+ </a>
103+ </div>
104+ )}
105+ </div>
106+ </div>
107+ </Layout>
108+ );
109+}
110+
111+function formatSize(bytes: number): string {
112+ if (bytes < 1024) return `${bytes} B`;
113+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
114+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
115+}
Asrc/views/repos/FileTree.tsx
@@ -0,0 +1,82 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import type { TreeEntry } from "../../services/git.ts";
4+import { Layout } from "../layout.tsx";
5+import { RepoHeader } from "../repos/RepoHeader.tsx";
6+import { BranchSelector } from "./BranchSelector.tsx";
7+import { FileTreeTable } from "./FileTreeTable.tsx";
8+import { RepoNav } from "./RepoNav.tsx";
9+
10+interface FileTreeProps {
11+ user: SessionUser | null;
12+ repo: RepositoryRow;
13+ ref: string;
14+ subpath: string;
15+ entries: TreeEntry[];
16+ branches: string[];
17+ readmeHtml?: string | null;
18+}
19+
20+export function FileTree({
21+ user,
22+ repo,
23+ ref: treeRef,
24+ subpath,
25+ entries,
26+ branches,
27+ readmeHtml,
28+}: FileTreeProps) {
29+ const parts = subpath ? subpath.split("/") : [];
30+ return (
31+ <Layout
32+ user={user}
33+ title={`${repo.name}${subpath ? `/${subpath}` : ""}`}
34+ >
35+ <div class="container">
36+ <RepoHeader repo={repo} />
37+ <RepoNav repo={repo} active="code" user={user} />
38+ <div class="tree-toolbar">
39+ <BranchSelector
40+ repoName={repo.name}
41+ branches={branches}
42+ currentRef={treeRef}
43+ view="tree"
44+ path={subpath}
45+ />
46+ </div>
47+ {subpath && (
48+ <div class="breadcrumb">
49+ <a href={`/${repo.name}/tree/${treeRef}`}>
50+ {repo.name}
51+ </a>
52+ {parts.map((part, i) => {
53+ const partPath = parts.slice(0, i + 1).join("/");
54+ return (
55+ <>
56+ <span class="breadcrumb-sep">/</span>
57+ <a
58+ href={`/${repo.name}/tree/${treeRef}/${partPath}`}
59+ >
60+ {part}
61+ </a>
62+ </>
63+ );
64+ })}
65+ </div>
66+ )}
67+ <FileTreeTable
68+ repoName={repo.name}
69+ treeRef={treeRef}
70+ subpath={subpath}
71+ entries={entries}
72+ />
73+ {readmeHtml && (
74+ <div class="readme-section">
75+ <div class="readme-header">README</div>
76+ <div class="markdown-body">{readmeHtml}</div>
77+ </div>
78+ )}
79+ </div>
80+ </Layout>
81+ );
82+}
Asrc/views/repos/FileTreeTable.tsx
@@ -0,0 +1,90 @@
1+import type { TreeEntry } from "../../services/git.ts";
2+
3+interface FileTreeTableProps {
4+ repoName: string;
5+ treeRef: string;
6+ subpath: string;
7+ entries: TreeEntry[];
8+}
9+
10+export function FileTreeTable({
11+ repoName,
12+ treeRef,
13+ subpath,
14+ entries,
15+}: FileTreeTableProps) {
16+ const parts = subpath ? subpath.split("/") : [];
17+ const parentPath = parts.slice(0, -1).join("/");
18+ const parentHref = parentPath
19+ ? `/${repoName}/tree/${treeRef}/${parentPath}`
20+ : `/${repoName}/tree/${treeRef}`;
21+
22+ const sorted = [...entries].sort((a, b) => {
23+ if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
24+ return a.name.localeCompare(b.name);
25+ });
26+
27+ return (
28+ <table class="file-tree">
29+ <tbody>
30+ {subpath && (
31+ <tr class="file-tree-row file-tree-row-up">
32+ <td class="file-icon file-icon-dir">{DirIcon()}</td>
33+ <td class="file-name" colspan="2">
34+ <a href={parentHref}>..</a>
35+ </td>
36+ </tr>
37+ )}
38+ {sorted.map((entry) => {
39+ const entryPath = subpath
40+ ? `${subpath}/${entry.name}`
41+ : entry.name;
42+ const href =
43+ entry.type === "tree"
44+ ? `/${repoName}/tree/${treeRef}/${entryPath}`
45+ : `/${repoName}/blob/${treeRef}/${entryPath}`;
46+ return (
47+ <tr class="file-tree-row">
48+ <td
49+ class={`file-icon ${entry.type === "tree" ? "file-icon-dir" : "file-icon-file"}`}
50+ >
51+ {entry.type === "tree" ? DirIcon() : FileIcon()}
52+ </td>
53+ <td class="file-name">
54+ <a
55+ href={href}
56+ class={
57+ entry.type === "tree"
58+ ? "file-name-dir"
59+ : undefined
60+ }
61+ >
62+ {entry.name}
63+ </a>
64+ </td>
65+ <td class="file-size">
66+ {entry.type === "blob" && entry.size !== "-"
67+ ? formatSize(parseInt(entry.size, 10))
68+ : ""}
69+ </td>
70+ </tr>
71+ );
72+ })}
73+ </tbody>
74+ </table>
75+ );
76+}
77+
78+function formatSize(bytes: number): string {
79+ if (bytes < 1024) return `${bytes} B`;
80+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
81+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
82+}
83+
84+function DirIcon(): JSX.Element {
85+ return `<svg class="tree-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></svg>`;
86+}
87+
88+function FileIcon(): JSX.Element {
89+ return `<svg class="tree-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>`;
90+}
Asrc/views/repos/NewRepo.tsx
@@ -0,0 +1,67 @@
1+import type { SessionUser } from "../../middleware/session.ts";
2+import { Layout } from "../layout.tsx";
3+
4+interface NewRepoProps {
5+ user: SessionUser;
6+ error?: string;
7+}
8+
9+export function NewRepo({ user, error }: NewRepoProps) {
10+ return (
11+ <Layout user={user} title="New repository">
12+ <div class="container container-narrow">
13+ <h1 class="page-title">Create new repository</h1>
14+ {error && <p class="form-error">{error}</p>}
15+ <form method="POST" action="/new" class="form-card">
16+ <div class="form-group">
17+ <label for="name">Repository name</label>
18+ <input
19+ id="name"
20+ name="name"
21+ type="text"
22+ required
23+ pattern="[a-zA-Z0-9._-]+"
24+ title="Letters, numbers, dots, hyphens, underscores"
25+ placeholder="my-project"
26+ />
27+ </div>
28+ <div class="form-group">
29+ <label for="description">
30+ Description{" "}
31+ <span class="text-muted">(optional)</span>
32+ </label>
33+ <input
34+ id="description"
35+ name="description"
36+ type="text"
37+ placeholder="A short description"
38+ />
39+ </div>
40+ <div class="form-group">
41+ <label for="default_branch">Default branch</label>
42+ <input
43+ id="default_branch"
44+ name="default_branch"
45+ type="text"
46+ value="main"
47+ placeholder="main"
48+ />
49+ </div>
50+ <div class="form-group">
51+ <label class="checkbox-label">
52+ <input
53+ type="checkbox"
54+ name="is_private"
55+ value="1"
56+ />
57+ Private repository
58+ </label>
59+ </div>
60+ <button type="submit" class="btn btn-primary">
61+ Create repository
62+ </button>
63+ </form>
64+ </div>
65+ </Layout>
66+ );
67+}
Asrc/views/repos/RepoHeader.tsx
@@ -0,0 +1,20 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+
3+interface RepoHeaderProps {
4+ repo: RepositoryRow;
5+}
6+
7+export function RepoHeader({ repo }: RepoHeaderProps) {
8+ return (
9+ <div class="repo-header">
10+ <div class="repo-title-row">
11+ <h1 class="page-title">
12+ <a href={`/${repo.name}`}>{repo.name}</a>
13+ </h1>
14+ {repo.is_private ? (
15+ <span class="badge badge-private">Private</span>
16+ ) : null}
17+ </div>
18+ </div>
19+ );
20+}
Asrc/views/repos/RepoHome.tsx
@@ -0,0 +1,112 @@
1+import { BASE_URL, SSH_PORT } from "../../config.ts";
2+import type { RepositoryRow } from "../../db/index.ts";
3+import type { SessionUser } from "../../middleware/session.ts";
4+import type { TreeEntry } from "../../services/git.ts";
5+import { Layout } from "../layout.tsx";
6+import { BranchSelector } from "./BranchSelector.tsx";
7+import { FileTreeTable } from "./FileTreeTable.tsx";
8+import { RepoHeader } from "./RepoHeader.tsx";
9+import { RepoNav } from "./RepoNav.tsx";
10+
11+interface RepoHomeProps {
12+ user: SessionUser | null;
13+ repo: RepositoryRow;
14+ entries: TreeEntry[];
15+ readmeHtml: string | null;
16+ hasContent: boolean;
17+ branches: string[];
18+}
19+
20+export function RepoHome({
21+ user,
22+ repo,
23+ entries,
24+ readmeHtml,
25+ hasContent,
26+ branches,
27+}: RepoHomeProps) {
28+ return (
29+ <Layout user={user} title={repo.name}>
30+ <div class="container">
31+ <RepoHeader repo={repo} />
32+ {repo.description && (
33+ <p class="repo-description">{repo.description}</p>
34+ )}
35+ <RepoNav repo={repo} active="code" user={user} />
36+ {!hasContent ? (
37+ <div class="empty-state">
38+ <h2>This repository is empty.</h2>
39+ <p>Push your first commit to get started:</p>
40+ <pre class="code-setup">
41+ <code>{`git clone ${sshUrl(repo.name)}
42+cd ${repo.name}
43+echo "# ${repo.name}" > README.md
44+git add .
45+git commit -m "Initial commit"
46+git push origin main`}</code>
47+ </pre>
48+ </div>
49+ ) : (
50+ <>
51+ <div class="tree-toolbar">
52+ <BranchSelector
53+ repoName={repo.name}
54+ branches={branches}
55+ currentRef={repo.default_branch}
56+ view="tree"
57+ />
58+ <details class="clone-popup-details">
59+ <summary class="btn btn-sm btn-primary">
60+ Clone
61+ </summary>
62+ <div class="clone-popup">
63+ <div class="clone-url-row">
64+ <span class="clone-url-label">
65+ HTTP
66+ </span>
67+ <input
68+ type="text"
69+ readonly
70+ value={httpUrl(repo.name)}
71+ class="clone-url-input"
72+ />
73+ </div>
74+ <div class="clone-url-row">
75+ <span class="clone-url-label">SSH</span>
76+ <input
77+ type="text"
78+ readonly
79+ value={sshUrl(repo.name)}
80+ class="clone-url-input"
81+ />
82+ </div>
83+ </div>
84+ </details>
85+ </div>
86+ <FileTreeTable
87+ repoName={repo.name}
88+ treeRef={repo.default_branch}
89+ subpath=""
90+ entries={entries}
91+ />
92+ {readmeHtml && (
93+ <div class="readme-section">
94+ <div class="readme-header">README</div>
95+ <div class="markdown-body">{readmeHtml}</div>
96+ </div>
97+ )}
98+ </>
99+ )}
100+ </div>
101+ </Layout>
102+ );
103+}
104+
105+function httpUrl(name: string) {
106+ return `${BASE_URL}/${name}.git`;
107+}
108+
109+function sshUrl(name: string) {
110+ const host = new URL(BASE_URL).hostname;
111+ return `ssh://git@${host}:${SSH_PORT}/${name}.git`;
112+}
Asrc/views/repos/RepoList.tsx
@@ -0,0 +1,99 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import { Layout } from "../layout.tsx";
4+import { Pagination, type PaginationInfo } from "../Pagination.tsx";
5+
6+interface RepoListProps {
7+ user: SessionUser | null;
8+ repos: RepositoryRow[];
9+ search?: string;
10+ pagination: PaginationInfo;
11+}
12+
13+export function RepoList({ user, repos, search, pagination }: RepoListProps) {
14+ return (
15+ <Layout user={user} title="Repositories">
16+ <div class="container">
17+ <div class="page-header">
18+ <h1 class="page-title">Repositories</h1>
19+ {user?.isAdmin && (
20+ <a href="/new" class="btn btn-primary">
21+ New repository
22+ </a>
23+ )}
24+ </div>
25+ <form method="GET" action="/" class="search-form">
26+ <div class="search-input-wrap">
27+ <input
28+ type="search"
29+ name="q"
30+ value={search ?? ""}
31+ placeholder="Search repositories…"
32+ class="search-input"
33+ autocomplete="off"
34+ />
35+ <button type="submit" class="btn btn-ghost btn-sm">
36+ Search
37+ </button>
38+ </div>
39+ </form>
40+ {repos.length === 0 ? (
41+ <div class="empty-state">
42+ {search ? (
43+ <p>
44+ No repositories match <strong>{search}</strong>.
45+ </p>
46+ ) : (
47+ <>
48+ <p>No repositories yet.</p>
49+ {user?.isAdmin && (
50+ <a href="/new" class="btn btn-primary">
51+ Create your first repository
52+ </a>
53+ )}
54+ </>
55+ )}
56+ </div>
57+ ) : (
58+ <ul class="repo-list">
59+ {repos.map((repo) => (
60+ <li class="repo-card">
61+ <div class="repo-card-main">
62+ <div class="repo-card-title">
63+ <a
64+ href={`/${repo.name}`}
65+ class="repo-name"
66+ >
67+ {repo.name}
68+ </a>
69+ {repo.is_private ? (
70+ <span class="badge badge-private">
71+ Private
72+ </span>
73+ ) : null}
74+ </div>
75+ {repo.description && (
76+ <p class="repo-description">
77+ {repo.description}
78+ </p>
79+ )}
80+ </div>
81+ <div class="repo-card-meta">
82+ <time
83+ class="repo-date"
84+ datetime={repo.created_at}
85+ >
86+ {new Date(
87+ repo.created_at,
88+ ).toLocaleDateString()}
89+ </time>
90+ </div>
91+ </li>
92+ ))}
93+ </ul>
94+ )}
95+ <Pagination {...pagination} />
96+ </div>
97+ </Layout>
98+ );
99+}
Asrc/views/repos/RepoNav.tsx
@@ -0,0 +1,44 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+
4+interface RepoNavProps {
5+ repo: RepositoryRow;
6+ active: "code" | "commits" | "issues" | "patches" | "releases" | "settings";
7+ user?: SessionUser | null;
8+}
9+
10+export function RepoNav({ repo, active, user }: RepoNavProps) {
11+ const tabs = [
12+ { key: "code", label: "Code", href: `/${repo.name}` },
13+ {
14+ key: "commits",
15+ label: "Commits",
16+ href: `/${repo.name}/commits/${repo.default_branch}`,
17+ },
18+ { key: "issues", label: "Issues", href: `/${repo.name}/issues` },
19+ { key: "patches", label: "Patches", href: `/${repo.name}/patches` },
20+ { key: "releases", label: "Releases", href: `/${repo.name}/releases` },
21+ ] as const;
22+ return (
23+ <div class="repo-nav-bar">
24+ <nav class="repo-tabs">
25+ {tabs.map((t) => (
26+ <a
27+ href={t.href}
28+ class={`repo-tab${active === t.key ? " active" : ""}`}
29+ >
30+ {t.label}
31+ </a>
32+ ))}
33+ {user?.isAdmin && (
34+ <a
35+ href={`/${repo.name}/settings`}
36+ class={`repo-tab${active === "settings" ? " active" : ""}`}
37+ >
38+ Settings
39+ </a>
40+ )}
41+ </nav>
42+ </div>
43+ );
44+}
Asrc/views/repos/RepoSettings.tsx
@@ -0,0 +1,123 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import { Layout } from "../layout.tsx";
4+import { RepoHeader } from "./RepoHeader.tsx";
5+import { RepoNav } from "./RepoNav.tsx";
6+
7+interface RepoSettingsProps {
8+ user: SessionUser;
9+ repo: RepositoryRow;
10+ branches: string[];
11+ success?: string;
12+ error?: string;
13+}
14+
15+export function RepoSettings({
16+ user,
17+ repo,
18+ branches,
19+ success,
20+ error,
21+}: RepoSettingsProps) {
22+ return (
23+ <Layout user={user} title={`Settings — ${repo.name}`}>
24+ <div class="container container-narrow">
25+ <RepoHeader repo={repo} />
26+ <RepoNav repo={repo} active="settings" user={user} />
27+ {success && <p class="form-success">{success}</p>}
28+ {error && <p class="form-error">{error}</p>}
29+ <form
30+ method="POST"
31+ action={`/${repo.name}/settings`}
32+ class="form-card"
33+ >
34+ <div class="form-group">
35+ <label for="description">Description</label>
36+ <input
37+ id="description"
38+ name="description"
39+ type="text"
40+ value={repo.description ?? ""}
41+ placeholder="A short description"
42+ />
43+ </div>
44+ <div class="form-group">
45+ <label for="default_branch">Default branch</label>
46+ {branches.length > 0 ? (
47+ <select
48+ id="default_branch"
49+ name="default_branch"
50+ class="branch-select"
51+ >
52+ {branches.map((b) => (
53+ <option
54+ value={b}
55+ selected={
56+ b === repo.default_branch
57+ ? true
58+ : undefined
59+ }
60+ >
61+ {b}
62+ </option>
63+ ))}
64+ </select>
65+ ) : (
66+ <p class="form-hint">
67+ No branches yet — push your first commit to set
68+ the default branch.
69+ </p>
70+ )}
71+ </div>
72+ <div class="form-group">
73+ <label class="checkbox-label">
74+ <input
75+ type="checkbox"
76+ name="is_private"
77+ value="1"
78+ checked={repo.is_private === 1}
79+ />
80+ Private repository
81+ </label>
82+ </div>
83+ <button type="submit" class="btn btn-primary">
84+ Save settings
85+ </button>
86+ </form>
87+ <div class="danger-zone">
88+ <h2 class="section-title danger-title">Danger zone</h2>
89+ <div class="form-card danger-card">
90+ <div class="danger-item">
91+ <div>
92+ <strong>Delete this repository</strong>
93+ <p class="text-muted">
94+ Once deleted, there is no going back.
95+ </p>
96+ </div>
97+ <details class="confirm-details">
98+ <summary class="btn btn-danger">
99+ Delete repository
100+ </summary>
101+ <div class="confirm-popup">
102+ Delete {repo.name}? This cannot be undone.
103+ <form
104+ method="POST"
105+ action={`/${repo.name}/settings/delete`}
106+ class="inline-form"
107+ >
108+ <button
109+ type="submit"
110+ class="btn btn-danger"
111+ >
112+ Yes, delete
113+ </button>
114+ </form>
115+ </div>
116+ </details>
117+ </div>
118+ </div>
119+ </div>
120+ </div>
121+ </Layout>
122+ );
123+}
Asrc/workers/highlight.worker.ts
@@ -0,0 +1,26 @@
1+import { highlightFile } from "../services/diffHighlight.ts";
2+import { highlightStartup, serveFile } from "../services/highlight.ts";
3+
4+declare const self: {
5+ postMessage(message: unknown): void;
6+ onmessage: ((event: MessageEvent) => void) | null;
7+};
8+
9+await highlightStartup();
10+self.postMessage({ type: "ready" });
11+
12+self.onmessage = async (event: MessageEvent) => {
13+ const { id, type, ...data } = event.data;
14+ try {
15+ let result: unknown;
16+ if (type === "serveFile") {
17+ const content = Buffer.from(data.content as ArrayBuffer);
18+ result = await serveFile(content, data.filename, data.cacheKey);
19+ } else if (type === "highlightFile") {
20+ result = await highlightFile(data.file);
21+ }
22+ self.postMessage({ id, result });
23+ } catch (err) {
24+ self.postMessage({ id, error: String(err) });
25+ }
26+};
Atests/e2e.test.ts
@@ -0,0 +1,2036 @@
1+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import { $ } from 'bun';
5+import {
6+ BASE,
7+ ADMIN_PASS,
8+ setupTestEnv,
9+ spawnServer,
10+ waitForServer,
11+ login,
12+ logout,
13+ seedRepo,
14+ seedBranch,
15+ seedSubdir,
16+ writeTempFile,
17+ getHeadCommit,
18+ PORT,
19+} from './helpers.ts';
20+
21+// ─── Global setup ────────────────────────────────────────────────────────────
22+
23+let browser: Browser;
24+let server: ReturnType<typeof spawnServer>;
25+
26+beforeAll(async () => {
27+ await setupTestEnv();
28+ server = spawnServer();
29+ await waitForServer();
30+ browser = await chromium.launch();
31+});
32+
33+afterAll(async () => {
34+ await browser.close();
35+ server.kill();
36+});
37+
38+// Helper: create a context already logged in as a given user.
39+async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
40+ const ctx = await browser.newContext();
41+ const page = await ctx.newPage();
42+ await login(page, username, password);
43+ await page.close();
44+ return ctx;
45+}
46+
47+// Helper: create N issues in a repo using the server API (no browser rendering)
48+async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) {
49+ for (let i = 1; i <= count; i++) {
50+ await ctx.request.fetch(`${BASE}/${repo}/issues`, {
51+ method: 'POST',
52+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
53+ data: `title=Issue+number+${i}&body=`,
54+ maxRedirects: 0,
55+ }).catch(() => {}); // 302 redirect throws; that's fine
56+ }
57+}
58+
59+// Helper: create N patches in a repo using the server API
60+async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) {
61+ const VALID_PATCH = [
62+ 'diff --git a/f.txt b/f.txt',
63+ 'new file mode 100644',
64+ 'index 0000000..9daeafb',
65+ '--- /dev/null',
66+ '+++ b/f.txt',
67+ '@@ -0,0 +1 @@',
68+ '+x',
69+ '',
70+ ].join('\n');
71+
72+ writeTempFile('/tmp/bulk.patch', VALID_PATCH);
73+ for (let i = 1; i <= count; i++) {
74+ const page = await ctx.newPage();
75+ try {
76+ await page.goto(`${BASE}/${repo}/patches/new`);
77+ await page.fill('[name=title]', `Patch number ${i}`);
78+ await page.locator('[name=patch_file]').setInputFiles('/tmp/bulk.patch');
79+ await page.click('form[action$="/patches"] button[type=submit]');
80+ await page.waitForURL(new RegExp(`/${repo}/patches/\\d+`));
81+ } finally { await page.close(); }
82+ }
83+}
84+
85+// ─── Auth ─────────────────────────────────────────────────────────────────────
86+
87+describe('auth', () => {
88+ test('homepage loads', async () => {
89+ const ctx = await browser.newContext();
90+ const page = await ctx.newPage();
91+ try {
92+ await page.goto(BASE);
93+ expect(await page.title()).toContain('Hearthforge');
94+ } finally { await ctx.close(); }
95+ });
96+
97+ test('wrong password shows error', async () => {
98+ const ctx = await browser.newContext();
99+ const page = await ctx.newPage();
100+ try {
101+ await page.goto(`${BASE}/login`);
102+ await page.fill('[name=username]', 'admin');
103+ await page.fill('[name=password]', 'wrongpassword');
104+ await page.click('button[type=submit]');
105+ expect(await page.locator('.form-error').textContent()).toContain('Invalid');
106+ } finally { await ctx.close(); }
107+ });
108+
109+ test('correct credentials redirect to homepage', async () => {
110+ const ctx = await browser.newContext();
111+ const page = await ctx.newPage();
112+ try {
113+ await login(page);
114+ expect(page.url()).toBe(BASE + '/');
115+ expect(await page.locator('.nav-user').isVisible()).toBe(true);
116+ } finally { await ctx.close(); }
117+ });
118+
119+ test('register new user', async () => {
120+ const ctx = await browser.newContext();
121+ const page = await ctx.newPage();
122+ try {
123+ await page.goto(`${BASE}/register`);
124+ await page.fill('[name=username]', 'alice');
125+ await page.fill('[name=password]', 'password123');
126+ await page.fill('[name=password2]', 'password123');
127+ await page.click('button[type=submit]');
128+ await page.waitForURL(BASE + '/');
129+ expect(await page.locator('.nav-user').textContent()).toBe('alice');
130+ } finally { await ctx.close(); }
131+ });
132+
133+ test('register with mismatched passwords shows error', async () => {
134+ const ctx = await browser.newContext();
135+ const page = await ctx.newPage();
136+ try {
137+ await page.goto(`${BASE}/register`);
138+ await page.fill('[name=username]', 'bob');
139+ await page.fill('[name=password]', 'password123');
140+ await page.fill('[name=password2]', 'different456');
141+ await page.click('button[type=submit]');
142+ expect(await page.locator('.form-error').textContent()).toContain('match');
143+ } finally { await ctx.close(); }
144+ });
145+
146+ test('register with duplicate username shows error', async () => {
147+ const ctx = await browser.newContext();
148+ const page = await ctx.newPage();
149+ try {
150+ await page.goto(`${BASE}/register`);
151+ await page.fill('[name=username]', 'alice'); // already registered above
152+ await page.fill('[name=password]', 'password123');
153+ await page.fill('[name=password2]', 'password123');
154+ await page.click('button[type=submit]');
155+ expect(await page.locator('.form-error').textContent()).toContain('taken');
156+ } finally { await ctx.close(); }
157+ });
158+
159+ test('logout clears session', async () => {
160+ const ctx = await browser.newContext();
161+ const page = await ctx.newPage();
162+ try {
163+ await login(page);
164+ await logout(page);
165+ expect(await page.locator('.nav-user').count()).toBe(0);
166+ expect(await page.locator('a[href="/login"]').isVisible()).toBe(true);
167+ } finally { await ctx.close(); }
168+ });
169+});
170+
171+// ─── Repos ────────────────────────────────────────────────────────────────────
172+
173+describe('repos', () => {
174+ // Shared admin context — cookies persist across tests in this block.
175+ let adminCtx: BrowserContext;
176+ // Alice's context, created after alice is registered in auth tests.
177+ let aliceCtx: BrowserContext;
178+ // Set after 'commit log' test; used by all commit-detail tests below.
179+ let commitUrl: string;
180+
181+ beforeAll(async () => {
182+ adminCtx = await loggedInContext();
183+ aliceCtx = await loggedInContext('alice', 'password123');
184+ });
185+
186+ afterAll(async () => {
187+ await adminCtx.close();
188+ await aliceCtx.close();
189+ });
190+
191+ test('non-admin gets 403 on /new', async () => {
192+ const page = await aliceCtx.newPage();
193+ try {
194+ const resp = await page.request.get(`${BASE}/new`);
195+ expect(resp.status()).toBe(403);
196+ } finally { await page.close(); }
197+ });
198+
199+ test('create repository', async () => {
200+ const page = await adminCtx.newPage();
201+ try {
202+ await page.goto(`${BASE}/new`);
203+ await page.fill('[name=name]', 'my-repo');
204+ await page.fill('[name=description]', 'A test repo');
205+ await page.click('form[action="/new"] button[type=submit]');
206+ await page.waitForURL(`${BASE}/my-repo`);
207+ expect(await page.locator('.empty-state').isVisible()).toBe(true);
208+ } finally { await page.close(); }
209+ });
210+
211+ test('repository appears in list', async () => {
212+ const page = await adminCtx.newPage();
213+ try {
214+ await page.goto(BASE);
215+ expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
216+ } finally { await page.close(); }
217+ });
218+
219+ test('search finds matching repo', async () => {
220+ const page = await adminCtx.newPage();
221+ try {
222+ await page.goto(BASE);
223+ await page.fill('[name=q]', 'my-repo');
224+ await page.click('.search-form button[type=submit]');
225+ expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
226+ } finally { await page.close(); }
227+ });
228+
229+ test('search returns empty for unknown term', async () => {
230+ const page = await adminCtx.newPage();
231+ try {
232+ await page.goto(BASE);
233+ await page.fill('[name=q]', 'zzz-nothing-here');
234+ await page.click('.search-form button[type=submit]');
235+ expect(await page.locator('.empty-state').isVisible()).toBe(true);
236+ } finally { await page.close(); }
237+ });
238+
239+ test('browse file tree after seeding content', async () => {
240+ await seedRepo('my-repo');
241+ const page = await adminCtx.newPage();
242+ try {
243+ await page.goto(`${BASE}/my-repo/tree/main`);
244+ const files = await page.locator('.file-name a').allTextContents();
245+ expect(files).toContain('README.md');
246+ expect(files).toContain('index.js');
247+ } finally { await page.close(); }
248+ });
249+
250+ test('view file blob with syntax highlighting', async () => {
251+ const page = await adminCtx.newPage();
252+ try {
253+ await page.goto(`${BASE}/my-repo/blob/main/index.js`);
254+ expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
255+ expect(await page.locator('.file-blob-body').isVisible()).toBe(true);
256+ } finally { await page.close(); }
257+ });
258+
259+ test('raw file download responds 200', async () => {
260+ const page = await adminCtx.newPage();
261+ try {
262+ const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`);
263+ expect(resp.status()).toBe(200);
264+ expect(resp.headers()['content-disposition']).toContain('README.md');
265+ } finally { await page.close(); }
266+ });
267+
268+ test('commit log shows initial commit', async () => {
269+ const page = await adminCtx.newPage();
270+ try {
271+ await page.goto(`${BASE}/my-repo/commits/main`);
272+ const subjects = await page.locator('.commit-subject').allTextContents();
273+ expect(subjects.some(s => s.includes('Initial commit'))).toBe(true);
274+ // Navigate to the commit page and capture the URL for subsequent tests
275+ await page.locator('.commit-hash').first().click();
276+ await page.waitForURL(/\/my-repo\/commit\//);
277+ commitUrl = page.url();
278+ } finally { await page.close(); }
279+ });
280+
281+ test('commit detail shows metadata card', async () => {
282+ const page = await adminCtx.newPage();
283+ try {
284+ await page.goto(commitUrl);
285+ expect(await page.locator('.commit-card').isVisible()).toBe(true);
286+ expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit');
287+ // Author, date and SHA rows are all present
288+ const metaText = await page.locator('.commit-card-meta').textContent();
289+ expect(metaText).toContain('Test'); // author name set by seedRepo
290+ expect(metaText).toContain('Author'); // label (CSS uppercases visually)
291+ expect(metaText).toContain('Date');
292+ expect(metaText).toContain('Commit');
293+ } finally { await page.close(); }
294+ });
295+
296+ test('commit detail full SHA is shown', async () => {
297+ const page = await adminCtx.newPage();
298+ try {
299+ await page.goto(commitUrl);
300+ const sha = commitUrl.split('/commit/')[1] ?? null;
301+ expect(await page.locator('.commit-sha-full').textContent()).toBe(sha);
302+ } finally { await page.close(); }
303+ });
304+
305+ test('commit detail shows file nav sidebar', async () => {
306+ const page = await adminCtx.newPage();
307+ try {
308+ await page.goto(commitUrl);
309+ expect(await page.locator('.file-nav-details').isVisible()).toBe(true);
310+ const navItems = await page.locator('.file-nav-item').allTextContents();
311+ // seedRepo adds README.md and index.js
312+ expect(navItems.some(t => t.includes('README.md'))).toBe(true);
313+ expect(navItems.some(t => t.includes('index.js'))).toBe(true);
314+ } finally { await page.close(); }
315+ });
316+
317+ test('commit detail file nav items are anchor links to diff sections', async () => {
318+ const page = await adminCtx.newPage();
319+ try {
320+ await page.goto(commitUrl);
321+ const hrefs = await page.locator('.file-nav-item').evaluateAll(
322+ els => els.map(el => el.getAttribute('href') ?? ''),
323+ );
324+ expect(hrefs.every(h => h.startsWith('#'))).toBe(true);
325+ } finally { await page.close(); }
326+ });
327+
328+ test('commit detail shows diff table with added lines', async () => {
329+ const page = await adminCtx.newPage();
330+ try {
331+ await page.goto(commitUrl);
332+ // Initial commit only adds lines
333+ expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
334+ expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
335+ expect(await page.locator('.diff-row-del').count()).toBe(0);
336+ } finally { await page.close(); }
337+ });
338+
339+ test('commit detail diff table has line numbers', async () => {
340+ const page = await adminCtx.newPage();
341+ try {
342+ await page.goto(commitUrl);
343+ // New-side line numbers (column 2) on add rows start at 1
344+ const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent();
345+ expect(firstNewLn?.trim()).toBe('1');
346+ } finally { await page.close(); }
347+ });
348+
349+ test('commit detail shows added stats on file header', async () => {
350+ const page = await adminCtx.newPage();
351+ try {
352+ await page.goto(commitUrl);
353+ const addStats = await page.locator('.diff-stat-add').allTextContents();
354+ expect(addStats.length).toBeGreaterThan(0);
355+ expect(addStats.every(s => s.startsWith('+'))).toBe(true);
356+ } finally { await page.close(); }
357+ });
358+
359+ test('commit detail view-at-sha button links to blob at that commit', async () => {
360+ const page = await adminCtx.newPage();
361+ try {
362+ await page.goto(commitUrl);
363+ const sha = commitUrl.split('/commit/')[1];
364+ const btn = page.locator('.btn-xs').first();
365+ const href = await btn.getAttribute('href');
366+ expect(href).toContain(`/blob/${sha}/`);
367+ } finally { await page.close(); }
368+ });
369+
370+ test('commit detail view-at-branch button links to blob at default branch', async () => {
371+ const page = await adminCtx.newPage();
372+ try {
373+ await page.goto(commitUrl);
374+ const btns = await page.locator('.btn-xs').allTextContents();
375+ expect(btns.some(t => t.includes('@ main'))).toBe(true);
376+ const branchBtns = await page.locator('.btn-xs').evaluateAll(
377+ els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''),
378+ );
379+ expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true);
380+ } finally { await page.close(); }
381+ });
382+
383+ test('commit detail file diff can be collapsed', async () => {
384+ const page = await adminCtx.newPage();
385+ try {
386+ await page.goto(commitUrl);
387+ // File body is visible when details is open
388+ const diffFile = page.locator('.diff-file').first();
389+ expect(await diffFile.getAttribute('open')).not.toBeNull();
390+ // Click the summary to collapse
391+ await diffFile.locator('.diff-file-header').click();
392+ expect(await diffFile.getAttribute('open')).toBeNull();
393+ } finally { await page.close(); }
394+ });
395+
396+ test('commit detail file nav sidebar can be collapsed', async () => {
397+ const page = await adminCtx.newPage();
398+ try {
399+ await page.goto(commitUrl);
400+ const nav = page.locator('.file-nav-details');
401+ expect(await nav.getAttribute('open')).not.toBeNull();
402+ await nav.locator('.file-nav-toggle').click();
403+ expect(await nav.getAttribute('open')).toBeNull();
404+ } finally { await page.close(); }
405+ });
406+
407+ test('readme renders on repo home', async () => {
408+ const page = await adminCtx.newPage();
409+ try {
410+ await page.goto(`${BASE}/my-repo`);
411+ expect(await page.locator('.readme-header').isVisible()).toBe(true);
412+ expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo');
413+ } finally { await page.close(); }
414+ });
415+
416+ test('private repo hidden from other users', async () => {
417+ // Make private
418+ const adminPage = await adminCtx.newPage();
419+ try {
420+ await adminPage.goto(`${BASE}/my-repo/settings`);
421+ await adminPage.check('[name=is_private]');
422+ await adminPage.click('form[action$="/settings"] button[type=submit]');
423+ expect(await adminPage.locator('.form-success').isVisible()).toBe(true);
424+ } finally { await adminPage.close(); }
425+
426+ // Alice should get 404
427+ const alicePage = await aliceCtx.newPage();
428+ try {
429+ const resp = await alicePage.request.get(`${BASE}/my-repo`);
430+ expect(resp.status()).toBe(404);
431+ await alicePage.goto(BASE);
432+ expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo');
433+ } finally { await alicePage.close(); }
434+
435+ // Restore to public
436+ const adminPage2 = await adminCtx.newPage();
437+ try {
438+ await adminPage2.goto(`${BASE}/my-repo/settings`);
439+ await adminPage2.uncheck('[name=is_private]');
440+ await adminPage2.click('form[action$="/settings"] button[type=submit]');
441+ } finally { await adminPage2.close(); }
442+ });
443+
444+ test('settings tab visible for admin, hidden for others', async () => {
445+ const adminPage = await adminCtx.newPage();
446+ try {
447+ await adminPage.goto(`${BASE}/my-repo`);
448+ expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true);
449+ } finally { await adminPage.close(); }
450+
451+ const alicePage = await aliceCtx.newPage();
452+ try {
453+ await alicePage.goto(`${BASE}/my-repo`);
454+ expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0);
455+ } finally { await alicePage.close(); }
456+ });
457+});
458+
459+// ─── Issues ───────────────────────────────────────────────────────────────────
460+
461+describe('issues', () => {
462+ let adminCtx: BrowserContext;
463+ let issueUrl: string;
464+
465+ beforeAll(async () => {
466+ adminCtx = await loggedInContext();
467+ });
468+
469+ afterAll(async () => { await adminCtx.close(); });
470+
471+ test('create issue', async () => {
472+ const page = await adminCtx.newPage();
473+ try {
474+ await page.goto(`${BASE}/my-repo/issues/new`);
475+ await page.fill('[name=title]', 'First issue');
476+ await page.fill('[name=body]', 'Body with **markdown**.');
477+ await page.click('form[action$="/issues"] button[type=submit]');
478+ await page.waitForURL(/\/my-repo\/issues\/\d+/);
479+ issueUrl = page.url();
480+ expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue');
481+ } finally { await page.close(); }
482+ });
483+
484+ test('issue body renders markdown', async () => {
485+ const page = await adminCtx.newPage();
486+ try {
487+ await page.goto(issueUrl);
488+ expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>');
489+ } finally { await page.close(); }
490+ });
491+
492+ test('issue appears in open list', async () => {
493+ const page = await adminCtx.newPage();
494+ try {
495+ await page.goto(`${BASE}/my-repo/issues`);
496+ const titles = await page.locator('.issue-title').allTextContents();
497+ expect(titles.some(t => t.includes('First issue'))).toBe(true);
498+ } finally { await page.close(); }
499+ });
500+
501+ test('unauthenticated user is redirected to login from new issue form', async () => {
502+ const ctx = await browser.newContext();
503+ const page = await ctx.newPage();
504+ try {
505+ await page.goto(`${BASE}/my-repo/issues/new`);
506+ expect(page.url()).toContain('/login');
507+ } finally { await ctx.close(); }
508+ });
509+
510+ test('add comment', async () => {
511+ const page = await adminCtx.newPage();
512+ try {
513+ await page.goto(issueUrl);
514+ const beforeCount = await page.locator('.timeline-item').count();
515+ await page.fill('textarea[name=body]', 'A follow-up comment.');
516+ await page.click('form[action*="/comments"] button[type=submit]');
517+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
518+ expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount);
519+ } finally { await page.close(); }
520+ });
521+
522+ test('react to issue', async () => {
523+ const page = await adminCtx.newPage();
524+ try {
525+ await page.goto(issueUrl);
526+ await page.locator('.reaction-picker').first().click();
527+ await page.locator('.reaction-picker-btn').first().click();
528+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
529+ expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
530+ } finally { await page.close(); }
531+ });
532+
533+ test('close issue changes status badge', async () => {
534+ const page = await adminCtx.newPage();
535+ try {
536+ await page.goto(issueUrl);
537+ await page.click('.issue-detail-meta-actions button');
538+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
539+ expect(await page.locator('.issue-badge').textContent()).toBe('closed');
540+ } finally { await page.close(); }
541+ });
542+
543+ test('closed issue appears in closed list', async () => {
544+ const page = await adminCtx.newPage();
545+ try {
546+ await page.goto(`${BASE}/my-repo/issues?status=closed`);
547+ const titles = await page.locator('.issue-title').allTextContents();
548+ expect(titles.some(t => t.includes('First issue'))).toBe(true);
549+ } finally { await page.close(); }
550+ });
551+
552+ test('reopen issue', async () => {
553+ const page = await adminCtx.newPage();
554+ try {
555+ await page.goto(issueUrl);
556+ await page.click('.issue-detail-meta-actions button');
557+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
558+ expect(await page.locator('.issue-badge').textContent()).toBe('open');
559+ } finally { await page.close(); }
560+ });
561+});
562+
563+// ─── Patches ──────────────────────────────────────────────────────────────────
564+
565+describe('patches', () => {
566+ let adminCtx: BrowserContext;
567+ let cleanPatchUrl: string;
568+ let conflictPatchUrl: string;
569+ let closePatchUrl: string;
570+
571+ // Adds a new file — applies cleanly to my-repo
572+ const CLEAN_PATCH = [
573+ 'diff --git a/patch-test.txt b/patch-test.txt',
574+ 'new file mode 100644',
575+ 'index 0000000..9daeafb',
576+ '--- /dev/null',
577+ '+++ b/patch-test.txt',
578+ '@@ -0,0 +1 @@',
579+ '+patch test content',
580+ '',
581+ ].join('\n');
582+
583+ // References non-existent lines in README.md — always conflicts
584+ const CONFLICT_PATCH = [
585+ 'diff --git a/README.md b/README.md',
586+ 'index abc1234..def5678 100644',
587+ '--- a/README.md',
588+ '+++ b/README.md',
589+ '@@ -50,3 +50,3 @@',
590+ ' nonexistent context line',
591+ '-nonexistent old line',
592+ '+nonexistent new line',
593+ '',
594+ ].join('\n');
595+
596+ // Adds another new file — for testing close flow
597+ const CLOSE_PATCH = [
598+ 'diff --git a/patch-close.txt b/patch-close.txt',
599+ 'new file mode 100644',
600+ 'index 0000000..9daeafb',
601+ '--- /dev/null',
602+ '+++ b/patch-close.txt',
603+ '@@ -0,0 +1 @@',
604+ '+close test',
605+ '',
606+ ].join('\n');
607+
608+ beforeAll(async () => {
609+ adminCtx = await loggedInContext();
610+ });
611+
612+ afterAll(async () => { await adminCtx.close(); });
613+
614+ test('reject file without patch markers', async () => {
615+ writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text');
616+ const page = await adminCtx.newPage();
617+ try {
618+ await page.goto(`${BASE}/my-repo/patches/new`);
619+ await page.fill('[name=title]', 'Bad patch');
620+ await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt');
621+ await page.click('form[action$="/patches"] button[type=submit]');
622+ expect(await page.locator('.form-error').textContent()).toContain('valid patch');
623+ } finally { await page.close(); }
624+ });
625+
626+ test('upload clean patch', async () => {
627+ writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
628+ const page = await adminCtx.newPage();
629+ try {
630+ await page.goto(`${BASE}/my-repo/patches/new`);
631+ await page.fill('[name=title]', 'Add patch-test.txt');
632+ await page.fill('[name=description]', 'Adds a file with **markdown** desc.');
633+ await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch');
634+ await page.click('form[action$="/patches"] button[type=submit]');
635+ await page.waitForURL(/\/my-repo\/patches\/\d+/);
636+ cleanPatchUrl = page.url();
637+ expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt');
638+ } finally { await page.close(); }
639+ });
640+
641+ test('patch description renders markdown', async () => {
642+ const page = await adminCtx.newPage();
643+ try {
644+ await page.goto(cleanPatchUrl);
645+ expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>');
646+ } finally { await page.close(); }
647+ });
648+
649+ test('clean patch shows apply-clean status immediately', async () => {
650+ const page = await adminCtx.newPage();
651+ try {
652+ await page.goto(cleanPatchUrl);
653+ expect(await page.locator('.apply-result').isVisible()).toBe(true);
654+ expect(await page.locator('.apply-clean').isVisible()).toBe(true);
655+ } finally { await page.close(); }
656+ });
657+
658+ test('merge button appears for clean patch', async () => {
659+ const page = await adminCtx.newPage();
660+ try {
661+ await page.goto(cleanPatchUrl);
662+ expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true);
663+ } finally { await page.close(); }
664+ });
665+
666+ test('patch diff is displayed with highlighted table', async () => {
667+ const page = await adminCtx.newPage();
668+ try {
669+ await page.goto(cleanPatchUrl + '?tab=changes');
670+ expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
671+ expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
672+ } finally { await page.close(); }
673+ });
674+
675+ test('patch appears in open list', async () => {
676+ const page = await adminCtx.newPage();
677+ try {
678+ await page.goto(`${BASE}/my-repo/patches`);
679+ const titles = await page.locator('.issue-title').allTextContents();
680+ expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
681+ } finally { await page.close(); }
682+ });
683+
684+ test('unauthenticated user is redirected to login from patch upload', async () => {
685+ const ctx = await browser.newContext();
686+ const page = await ctx.newPage();
687+ try {
688+ await page.goto(`${BASE}/my-repo/patches/new`);
689+ expect(page.url()).toContain('/login');
690+ } finally { await ctx.close(); }
691+ });
692+
693+ test('upload conflict patch', async () => {
694+ writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH);
695+ const page = await adminCtx.newPage();
696+ try {
697+ await page.goto(`${BASE}/my-repo/patches/new`);
698+ await page.fill('[name=title]', 'Conflict patch');
699+ await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch');
700+ await page.click('form[action$="/patches"] button[type=submit]');
701+ await page.waitForURL(/\/my-repo\/patches\/\d+/);
702+ conflictPatchUrl = page.url();
703+ } finally { await page.close(); }
704+ });
705+
706+ test('conflict patch shows apply-conflict status', async () => {
707+ const page = await adminCtx.newPage();
708+ try {
709+ await page.goto(conflictPatchUrl);
710+ expect(await page.locator('.apply-conflict').isVisible()).toBe(true);
711+ } finally { await page.close(); }
712+ });
713+
714+ test('merge button absent for conflict patch', async () => {
715+ const page = await adminCtx.newPage();
716+ try {
717+ await page.goto(conflictPatchUrl);
718+ expect(await page.locator('form[action*="/merge"] button').count()).toBe(0);
719+ } finally { await page.close(); }
720+ });
721+
722+ test('merge clean patch changes status to merged', async () => {
723+ const page = await adminCtx.newPage();
724+ try {
725+ await page.goto(cleanPatchUrl);
726+ await page.click('form[action*="/merge"] button');
727+ await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, '')));
728+ expect(await page.locator('.patch-badge').textContent()).toBe('merged');
729+ } finally { await page.close(); }
730+ });
731+
732+ test('merged patch appears in merged list', async () => {
733+ const page = await adminCtx.newPage();
734+ try {
735+ await page.goto(`${BASE}/my-repo/patches?status=merged`);
736+ const titles = await page.locator('.issue-title').allTextContents();
737+ expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
738+ } finally { await page.close(); }
739+ });
740+
741+ test('upload and close a patch', async () => {
742+ writeTempFile('/tmp/close.patch', CLOSE_PATCH);
743+ const page = await adminCtx.newPage();
744+ try {
745+ await page.goto(`${BASE}/my-repo/patches/new`);
746+ await page.fill('[name=title]', 'Close me');
747+ await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch');
748+ await page.click('form[action$="/patches"] button[type=submit]');
749+ await page.waitForURL(/\/my-repo\/patches\/\d+/);
750+ closePatchUrl = page.url();
751+ await page.click('form[action*="/close"] button');
752+ await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
753+ expect(await page.locator('.patch-badge').textContent()).toBe('closed');
754+ } finally { await page.close(); }
755+ });
756+
757+ test('closed patch appears in closed list', async () => {
758+ const page = await adminCtx.newPage();
759+ try {
760+ await page.goto(`${BASE}/my-repo/patches?status=closed`);
761+ const titles = await page.locator('.issue-title').allTextContents();
762+ expect(titles.some(t => t.includes('Close me'))).toBe(true);
763+ } finally { await page.close(); }
764+ });
765+
766+ test('closed patch can be reopened', async () => {
767+ const page = await adminCtx.newPage();
768+ try {
769+ await page.goto(closePatchUrl);
770+ expect(await page.locator('.patch-badge').textContent()).toBe('closed');
771+ await page.click('form[action*="/close"] button');
772+ await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
773+ expect(await page.locator('.patch-badge').textContent()).toBe('open');
774+ } finally { await page.close(); }
775+ });
776+
777+ test('patch title and description can be edited', async () => {
778+ const page = await adminCtx.newPage();
779+ try {
780+ await page.goto(conflictPatchUrl);
781+ await page.click('.timeline-author .inline-edit-details summary');
782+ await page.fill('.inline-edit-form-area [name=title]', 'Edited Conflict Patch');
783+ await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
784+ await page.click('.inline-edit-form-area [type=submit]');
785+ await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
786+ expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
787+ } finally { await page.close(); }
788+ });
789+
790+ test('patch comment: add and edit', async () => {
791+ const page = await adminCtx.newPage();
792+ try {
793+ await page.goto(conflictPatchUrl);
794+ await page.fill('[name=body]', 'My patch comment');
795+ await page.click('form[action$="/comments"] button[type=submit]');
796+ await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
797+ expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment');
798+
799+ // Edit the comment — scope to the timeline-item containing the comment text
800+ const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' });
801+ await commentItem.locator('.inline-edit-details summary').click();
802+ await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment');
803+ await commentItem.locator('.inline-edit-form-area [type=submit]').click();
804+ await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
805+ expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment');
806+ } finally { await page.close(); }
807+ });
808+
809+ test('patch reaction on description', async () => {
810+ const page = await adminCtx.newPage();
811+ try {
812+ await page.goto(conflictPatchUrl);
813+ // Open reaction picker on the first timeline-item (description)
814+ await page.locator('.timeline-item').first().locator('.reaction-add-btn').click();
815+ await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click();
816+ await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
817+ expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
818+ } finally { await page.close(); }
819+ });
820+});
821+
822+// ─── Pagination ───────────────────────────────────────────────────────────────
823+
824+describe('pagination', () => {
825+ let adminCtx: BrowserContext;
826+
827+ beforeAll(async () => {
828+ adminCtx = await loggedInContext();
829+
830+ // Create a repo dedicated to pagination testing
831+ const page = await adminCtx.newPage();
832+ try {
833+ await page.goto(`${BASE}/new`);
834+ await page.fill('[name=name]', 'paged-repo');
835+ await page.click('form[action="/new"] button[type=submit]');
836+ await page.waitForURL(`${BASE}/paged-repo`);
837+ } finally { await page.close(); }
838+
839+ // Create 21 issues via the API (triggers page 2 at 20 per page)
840+ await bulkCreateIssues(adminCtx, 'paged-repo', 21);
841+
842+ // Create 21 patches via browser (patch upload requires multipart)
843+ await bulkCreatePatches(adminCtx, 'paged-repo', 21);
844+ });
845+
846+ afterAll(async () => { await adminCtx.close(); });
847+
848+ // ── Repo list pagination ──────────────────────────────────────────────────
849+
850+ test('repo list page 1 shows repos and no pagination when few repos', async () => {
851+ // With only a handful of test repos (< 20), there should be no pagination nav
852+ const page = await adminCtx.newPage();
853+ try {
854+ await page.goto(BASE);
855+ // Repos are shown
856+ expect(await page.locator('.repo-name').count()).toBeGreaterThan(0);
857+ } finally { await page.close(); }
858+ });
859+
860+ // ── Issue pagination ──────────────────────────────────────────────────────
861+
862+ test('issue list page 1 shows at most 20 items', async () => {
863+ const page = await adminCtx.newPage();
864+ try {
865+ await page.goto(`${BASE}/paged-repo/issues`);
866+ expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
867+ } finally { await page.close(); }
868+ });
869+
870+ test('issue list pagination nav appears when more than 20 issues', async () => {
871+ const page = await adminCtx.newPage();
872+ try {
873+ await page.goto(`${BASE}/paged-repo/issues`);
874+ expect(await page.locator('.pagination').isVisible()).toBe(true);
875+ } finally { await page.close(); }
876+ });
877+
878+ test('issue list page 2 shows remaining issues', async () => {
879+ const page = await adminCtx.newPage();
880+ try {
881+ await page.goto(`${BASE}/paged-repo/issues?page=2`);
882+ const count = await page.locator('.issue-item').count();
883+ expect(count).toBeGreaterThan(0);
884+ expect(count).toBeLessThanOrEqual(20);
885+ } finally { await page.close(); }
886+ });
887+
888+ test('issue list page 2 prev link goes to page 1', async () => {
889+ const page = await adminCtx.newPage();
890+ try {
891+ await page.goto(`${BASE}/paged-repo/issues?page=2`);
892+ const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href');
893+ expect(prevHref).toContain('page=1');
894+ } finally { await page.close(); }
895+ });
896+
897+ test('issue list page 1 next link goes to page 2', async () => {
898+ const page = await adminCtx.newPage();
899+ try {
900+ await page.goto(`${BASE}/paged-repo/issues`);
901+ const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href');
902+ expect(nextHref).toContain('page=2');
903+ } finally { await page.close(); }
904+ });
905+
906+ // ── Patch pagination ──────────────────────────────────────────────────────
907+
908+ test('patch list page 1 shows at most 20 items', async () => {
909+ const page = await adminCtx.newPage();
910+ try {
911+ await page.goto(`${BASE}/paged-repo/patches`);
912+ expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
913+ } finally { await page.close(); }
914+ });
915+
916+ test('patch list pagination nav appears when more than 20 patches', async () => {
917+ const page = await adminCtx.newPage();
918+ try {
919+ await page.goto(`${BASE}/paged-repo/patches`);
920+ expect(await page.locator('.pagination').isVisible()).toBe(true);
921+ } finally { await page.close(); }
922+ });
923+
924+ test('patch list page 2 shows remaining patches', async () => {
925+ const page = await adminCtx.newPage();
926+ try {
927+ await page.goto(`${BASE}/paged-repo/patches?page=2`);
928+ const count = await page.locator('.issue-item').count();
929+ expect(count).toBeGreaterThan(0);
930+ } finally { await page.close(); }
931+ });
932+
933+ // ── Commit log pagination ─────────────────────────────────────────────────
934+
935+ test('commit log with few commits shows no cursor nav', async () => {
936+ // my-repo has 1 commit — both newer and older links should be absent
937+ const page = await adminCtx.newPage();
938+ try {
939+ await page.goto(`${BASE}/my-repo/commits/main`);
940+ expect(await page.locator('.commit-cursor-nav').count()).toBe(0);
941+ } finally { await page.close(); }
942+ });
943+});
944+
945+// ─── Branch selector ──────────────────────────────────────────────────────────
946+
947+describe('branch selector', () => {
948+ let adminCtx: BrowserContext;
949+
950+ beforeAll(async () => {
951+ adminCtx = await loggedInContext();
952+ // Add a second branch so the selector is meaningful
953+ await seedBranch('my-repo', 'dev');
954+ });
955+
956+ afterAll(async () => { await adminCtx.close(); });
957+
958+ test('branch selector appears on repo home', async () => {
959+ const page = await adminCtx.newPage();
960+ try {
961+ await page.goto(`${BASE}/my-repo`);
962+ expect(await page.locator('.branch-selector').isVisible()).toBe(true);
963+ expect(await page.locator('.branch-select').inputValue()).toBe('main');
964+ } finally { await page.close(); }
965+ });
966+
967+ test('branch selector shows all branches on repo home', async () => {
968+ const page = await adminCtx.newPage();
969+ try {
970+ await page.goto(`${BASE}/my-repo`);
971+ const options = await page.locator('.branch-select option').allTextContents();
972+ expect(options).toContain('main');
973+ expect(options).toContain('dev');
974+ } finally { await page.close(); }
975+ });
976+
977+ test('branch selector appears on file tree with current ref selected', async () => {
978+ const page = await adminCtx.newPage();
979+ try {
980+ await page.goto(`${BASE}/my-repo/tree/main`);
981+ expect(await page.locator('.branch-selector').isVisible()).toBe(true);
982+ expect(await page.locator('.branch-select').inputValue()).toBe('main');
983+ } finally { await page.close(); }
984+ });
985+
986+ test('branch selector appears on commit log with current ref selected', async () => {
987+ const page = await adminCtx.newPage();
988+ try {
989+ await page.goto(`${BASE}/my-repo/commits/main`);
990+ expect(await page.locator('.branch-selector').isVisible()).toBe(true);
991+ expect(await page.locator('.branch-select').inputValue()).toBe('main');
992+ } finally { await page.close(); }
993+ });
994+
995+ test('branch selector appears on file blob', async () => {
996+ const page = await adminCtx.newPage();
997+ try {
998+ await page.goto(`${BASE}/my-repo/blob/main/README.md`);
999+ expect(await page.locator('.branch-selector').isVisible()).toBe(true);
1000+ expect(await page.locator('.branch-select').inputValue()).toBe('main');
1001+ } finally { await page.close(); }
1002+ });
1003+
1004+ test('switching branch on commit log navigates to the selected branch', async () => {
1005+ const page = await adminCtx.newPage();
1006+ try {
1007+ await page.goto(`${BASE}/my-repo/commits/main`);
1008+ await page.locator('.branch-select').selectOption('dev');
1009+ await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
1010+ await page.waitForURL(`${BASE}/my-repo/commits/dev`);
1011+ expect(page.url()).toContain('/commits/dev');
1012+ } finally { await page.close(); }
1013+ });
1014+
1015+ test('switching branch on file tree navigates to the selected branch', async () => {
1016+ const page = await adminCtx.newPage();
1017+ try {
1018+ await page.goto(`${BASE}/my-repo/tree/main`);
1019+ await page.locator('.branch-select').selectOption('dev');
1020+ await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
1021+ await page.waitForURL(`${BASE}/my-repo/tree/dev`);
1022+ expect(page.url()).toContain('/tree/dev');
1023+ } finally { await page.close(); }
1024+ });
1025+
1026+ test('branch-switch route preserves subpath when switching tree', async () => {
1027+ const page = await adminCtx.newPage();
1028+ try {
1029+ const resp = await page.request.get(
1030+ `${BASE}/my-repo/branch-switch?view=tree&ref=dev&path=src/foo`,
1031+ { maxRedirects: 0 },
1032+ ).catch(r => r);
1033+ // 302 redirect to /my-repo/tree/dev/src/foo
1034+ const loc = (resp as any).headers()?.['location'] ?? '';
1035+ expect(loc).toContain('/tree/dev/src/foo');
1036+ } finally { await page.close(); }
1037+ });
1038+
1039+ test('branch-switch route redirects commits view correctly', async () => {
1040+ const page = await adminCtx.newPage();
1041+ try {
1042+ const resp = await page.request.get(
1043+ `${BASE}/my-repo/branch-switch?view=commits&ref=dev`,
1044+ { maxRedirects: 0 },
1045+ ).catch(r => r);
1046+ const loc = (resp as any).headers()?.['location'] ?? '';
1047+ expect(loc).toContain('/commits/dev');
1048+ } finally { await page.close(); }
1049+ });
1050+
1051+ test('branch-switch route redirects blob view correctly', async () => {
1052+ const page = await adminCtx.newPage();
1053+ try {
1054+ const resp = await page.request.get(
1055+ `${BASE}/my-repo/branch-switch?view=blob&ref=dev&path=README.md`,
1056+ { maxRedirects: 0 },
1057+ ).catch(r => r);
1058+ const loc = (resp as any).headers()?.['location'] ?? '';
1059+ expect(loc).toContain('/blob/dev/README.md');
1060+ } finally { await page.close(); }
1061+ });
1062+});
1063+
1064+// ─── Default branch settings ──────────────────────────────────────────────────
1065+
1066+describe('default branch settings', () => {
1067+ // Runs after 'branch selector', so my-repo already has both main and dev branches.
1068+ let adminCtx: BrowserContext;
1069+
1070+ beforeAll(async () => { adminCtx = await loggedInContext(); });
1071+ afterAll(async () => { await adminCtx.close(); });
1072+
1073+ test('settings page shows default branch select', async () => {
1074+ const page = await adminCtx.newPage();
1075+ try {
1076+ await page.goto(`${BASE}/my-repo/settings`);
1077+ expect(await page.locator('#default_branch').isVisible()).toBe(true);
1078+ const options = await page.locator('#default_branch option').allTextContents();
1079+ expect(options).toContain('main');
1080+ expect(options).toContain('dev');
1081+ } finally { await page.close(); }
1082+ });
1083+
1084+ test('current default branch is pre-selected', async () => {
1085+ const page = await adminCtx.newPage();
1086+ try {
1087+ await page.goto(`${BASE}/my-repo/settings`);
1088+ expect(await page.locator('#default_branch').inputValue()).toBe('main');
1089+ } finally { await page.close(); }
1090+ });
1091+
1092+ test('changing default branch saves and is reflected in the repo home', async () => {
1093+ const page = await adminCtx.newPage();
1094+ try {
1095+ await page.goto(`${BASE}/my-repo/settings`);
1096+ await page.locator('#default_branch').selectOption('dev');
1097+ await page.click('form[action$="/settings"] button[type=submit]');
1098+ expect(await page.locator('.form-success').isVisible()).toBe(true);
1099+ // The select now shows dev as current
1100+ expect(await page.locator('#default_branch').inputValue()).toBe('dev');
1101+
1102+ // Repo home branch selector should reflect the new default
1103+ await page.goto(`${BASE}/my-repo`);
1104+ expect(await page.locator('.branch-select').inputValue()).toBe('dev');
1105+ } finally { await page.close(); }
1106+ });
1107+
1108+ test('commit log link in repo nav uses the new default branch', async () => {
1109+ const page = await adminCtx.newPage();
1110+ try {
1111+ await page.goto(`${BASE}/my-repo`);
1112+ const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href');
1113+ expect(commitsHref).toContain('/commits/dev');
1114+ } finally { await page.close(); }
1115+ });
1116+
1117+ test('changing default branch back to main restores original state', async () => {
1118+ const page = await adminCtx.newPage();
1119+ try {
1120+ await page.goto(`${BASE}/my-repo/settings`);
1121+ await page.locator('#default_branch').selectOption('main');
1122+ await page.click('form[action$="/settings"] button[type=submit]');
1123+ expect(await page.locator('.form-success').isVisible()).toBe(true);
1124+ expect(await page.locator('#default_branch').inputValue()).toBe('main');
1125+ } finally { await page.close(); }
1126+ });
1127+
1128+ test('settings page shows hint instead of select when repo has no branches', async () => {
1129+ // Create an empty repo (no commits → no branches)
1130+ const page = await adminCtx.newPage();
1131+ try {
1132+ await page.goto(`${BASE}/new`);
1133+ await page.fill('[name=name]', 'empty-for-branch-test');
1134+ await page.click('form[action="/new"] button[type=submit]');
1135+ await page.waitForURL(`${BASE}/empty-for-branch-test`);
1136+
1137+ await page.goto(`${BASE}/empty-for-branch-test/settings`);
1138+ expect(await page.locator('#default_branch').count()).toBe(0);
1139+ expect(await page.locator('.form-hint').isVisible()).toBe(true);
1140+ } finally { await page.close(); }
1141+ });
1142+});
1143+
1144+// ─── File browser ─────────────────────────────────────────────────────────────
1145+
1146+describe('file browser', () => {
1147+ // my-repo already has README.md + index.js from the 'repos' describe block.
1148+ // We add a subdirectory here so we can test directory navigation.
1149+ let adminCtx: BrowserContext;
1150+
1151+ beforeAll(async () => {
1152+ adminCtx = await loggedInContext();
1153+ await seedSubdir('my-repo', 'src', {
1154+ 'app.ts': 'export {};\n',
1155+ 'README.md': '# src readme\n',
1156+ });
1157+ });
1158+
1159+ afterAll(async () => { await adminCtx.close(); });
1160+
1161+ test('repo home shows file tree instead of recent commits', async () => {
1162+ const page = await adminCtx.newPage();
1163+ try {
1164+ await page.goto(`${BASE}/my-repo`);
1165+ expect(await page.locator('.file-tree').isVisible()).toBe(true);
1166+ expect(await page.locator('.repo-commits-section').count()).toBe(0);
1167+ } finally { await page.close(); }
1168+ });
1169+
1170+ test('repo home file tree lists files and directories', async () => {
1171+ const page = await adminCtx.newPage();
1172+ try {
1173+ await page.goto(`${BASE}/my-repo`);
1174+ const names = await page.locator('.file-name a').allTextContents();
1175+ expect(names).toContain('README.md');
1176+ expect(names).toContain('index.js');
1177+ expect(names).toContain('src');
1178+ } finally { await page.close(); }
1179+ });
1180+
1181+ test('directories appear before files in file tree', async () => {
1182+ const page = await adminCtx.newPage();
1183+ try {
1184+ await page.goto(`${BASE}/my-repo`);
1185+ const names = await page.locator('.file-name a').allTextContents();
1186+ const srcIdx = names.indexOf('src');
1187+ const readmeIdx = names.indexOf('README.md');
1188+ expect(srcIdx).toBeGreaterThanOrEqual(0);
1189+ expect(readmeIdx).toBeGreaterThanOrEqual(0);
1190+ expect(srcIdx).toBeLessThan(readmeIdx);
1191+ } finally { await page.close(); }
1192+ });
1193+
1194+ test('no ".." entry at repository root', async () => {
1195+ const page = await adminCtx.newPage();
1196+ try {
1197+ await page.goto(`${BASE}/my-repo`);
1198+ const names = await page.locator('.file-name a').allTextContents();
1199+ expect(names).not.toContain('..');
1200+ } finally { await page.close(); }
1201+ });
1202+
1203+ test('clicking directory navigates into it', async () => {
1204+ const page = await adminCtx.newPage();
1205+ try {
1206+ await page.goto(`${BASE}/my-repo`);
1207+ await page.locator('.file-name a', { hasText: 'src' }).click();
1208+ await page.waitForURL(`${BASE}/my-repo/tree/main/src`);
1209+ expect(page.url()).toContain('/tree/main/src');
1210+ } finally { await page.close(); }
1211+ });
1212+
1213+ test('".." entry appears in subdirectory', async () => {
1214+ const page = await adminCtx.newPage();
1215+ try {
1216+ await page.goto(`${BASE}/my-repo/tree/main/src`);
1217+ const names = await page.locator('.file-name a').allTextContents();
1218+ expect(names).toContain('..');
1219+ } finally { await page.close(); }
1220+ });
1221+
1222+ test('".." at one level deep links to tree root', async () => {
1223+ const page = await adminCtx.newPage();
1224+ try {
1225+ await page.goto(`${BASE}/my-repo/tree/main/src`);
1226+ const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href');
1227+ expect(upHref).toBe('/my-repo/tree/main');
1228+ } finally { await page.close(); }
1229+ });
1230+
1231+ test('files in subdirectory show plain names, not full paths', async () => {
1232+ const page = await adminCtx.newPage();
1233+ try {
1234+ await page.goto(`${BASE}/my-repo/tree/main/src`);
1235+ const names = await page.locator('.file-name a').allTextContents();
1236+ expect(names).toContain('app.ts');
1237+ // Must NOT contain the full path with prefix
1238+ expect(names).not.toContain('src/app.ts');
1239+ expect(names).not.toContain('src/README.md');
1240+ } finally { await page.close(); }
1241+ });
1242+
1243+ test('readme is shown below file tree on repo home', async () => {
1244+ const page = await adminCtx.newPage();
1245+ try {
1246+ await page.goto(`${BASE}/my-repo`);
1247+ const treeBox = await page.locator('.file-tree').boundingBox();
1248+ const readmeBox = await page.locator('.readme-section').boundingBox();
1249+ expect(treeBox).not.toBeNull();
1250+ expect(readmeBox).not.toBeNull();
1251+ expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1);
1252+ } finally { await page.close(); }
1253+ });
1254+
1255+ test('readme in subdirectory is shown when present', async () => {
1256+ const page = await adminCtx.newPage();
1257+ try {
1258+ await page.goto(`${BASE}/my-repo/tree/main/src`);
1259+ expect(await page.locator('.readme-section').isVisible()).toBe(true);
1260+ expect(await page.locator('.readme-section .markdown-body').innerHTML())
1261+ .toContain('src readme');
1262+ } finally { await page.close(); }
1263+ });
1264+
1265+ test('file tree on /tree/:ref also shows readme', async () => {
1266+ const page = await adminCtx.newPage();
1267+ try {
1268+ await page.goto(`${BASE}/my-repo/tree/main`);
1269+ expect(await page.locator('.file-tree').isVisible()).toBe(true);
1270+ expect(await page.locator('.readme-section').isVisible()).toBe(true);
1271+ } finally { await page.close(); }
1272+ });
1273+});
1274+
1275+// ─── Releases ─────────────────────────────────────────────────────────────────
1276+
1277+describe('releases', () => {
1278+ let adminCtx: BrowserContext;
1279+ let aliceCtx: BrowserContext;
1280+ let commitHash: string;
1281+ let releaseUrl: string;
1282+ let srcReleaseUrl: string;
1283+ let releaseWithAssetsUrl: string;
1284+
1285+ beforeAll(async () => {
1286+ adminCtx = await loggedInContext();
1287+ aliceCtx = await loggedInContext('alice', 'password123');
1288+
1289+ // Create a dedicated repo with at least one commit
1290+ const page = await adminCtx.newPage();
1291+ try {
1292+ await page.goto(`${BASE}/new`);
1293+ await page.fill('[name=name]', 'releases-repo');
1294+ await page.click('form[action="/new"] button[type=submit]');
1295+ await page.waitForURL(`${BASE}/releases-repo`);
1296+ } finally { await page.close(); }
1297+
1298+ await seedRepo('releases-repo');
1299+ commitHash = await getHeadCommit('releases-repo');
1300+ });
1301+
1302+ afterAll(async () => {
1303+ await adminCtx.close();
1304+ await aliceCtx.close();
1305+ });
1306+
1307+ // ── Navigation ──────────────────────────────────────────────────────────────
1308+
1309+ test('releases tab visible in repo nav', async () => {
1310+ const page = await adminCtx.newPage();
1311+ try {
1312+ await page.goto(`${BASE}/releases-repo`);
1313+ expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true);
1314+ } finally { await page.close(); }
1315+ });
1316+
1317+ test('releases list shows empty state when no releases', async () => {
1318+ const page = await adminCtx.newPage();
1319+ try {
1320+ await page.goto(`${BASE}/releases-repo/releases`);
1321+ expect(await page.locator('.empty-state').isVisible()).toBe(true);
1322+ } finally { await page.close(); }
1323+ });
1324+
1325+ // ── Access control ──────────────────────────────────────────────────────────
1326+
1327+ test('non-admin cannot access /releases/new', async () => {
1328+ const page = await aliceCtx.newPage();
1329+ try {
1330+ const resp = await page.request.get(`${BASE}/releases-repo/releases/new`);
1331+ expect(resp.status()).toBe(403);
1332+ } finally { await page.close(); }
1333+ });
1334+
1335+ test('non-admin POST to /releases returns 403', async () => {
1336+ const page = await aliceCtx.newPage();
1337+ try {
1338+ const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
1339+ multipart: { tag_name: 'v0.1.0', commit_hash: commitHash },
1340+ maxRedirects: 0,
1341+ });
1342+ expect(resp.status()).toBe(403);
1343+ } finally { await page.close(); }
1344+ });
1345+
1346+ test('unauthenticated user is redirected to login from /releases/new', async () => {
1347+ const ctx = await browser.newContext();
1348+ const page = await ctx.newPage();
1349+ try {
1350+ await page.goto(`${BASE}/releases-repo/releases/new`);
1351+ expect(page.url()).toContain('/login');
1352+ } finally { await ctx.close(); }
1353+ });
1354+
1355+ // ── Validation ──────────────────────────────────────────────────────────────
1356+
1357+ test('empty tag name shows error', async () => {
1358+ // Omit tag_name entirely — route now uses t.Optional so app validation runs
1359+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1360+ multipart: { commit_hash: commitHash },
1361+ });
1362+ expect(await resp.text()).toContain('Tag name is required');
1363+ });
1364+
1365+ test('invalid commit hash shows error', async () => {
1366+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1367+ multipart: { tag_name: 'v-bad', commit_hash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' },
1368+ });
1369+ expect(await resp.text()).toContain('Invalid commit');
1370+ });
1371+
1372+ // ── Create ──────────────────────────────────────────────────────────────────
1373+
1374+ test('create a basic release', async () => {
1375+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1376+ multipart: {
1377+ tag_name: 'v1.0.0',
1378+ name: 'First release',
1379+ commit_hash: commitHash,
1380+ notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
1381+ },
1382+ maxRedirects: 0,
1383+ });
1384+ expect(resp.status()).toBe(302);
1385+ const location = resp.headers()['location']!;
1386+ expect(location).toMatch(/\/releases-repo\/releases\/\d+/);
1387+ releaseUrl = `${BASE}${location}`;
1388+ });
1389+
1390+ test('duplicate tag name shows error', async () => {
1391+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1392+ multipart: { tag_name: 'v1.0.0', commit_hash: commitHash },
1393+ });
1394+ expect(await resp.text()).toContain('already exists');
1395+ });
1396+
1397+ // ── List ────────────────────────────────────────────────────────────────────
1398+
1399+ test('release appears in list with tag badge', async () => {
1400+ const page = await adminCtx.newPage();
1401+ try {
1402+ await page.goto(`${BASE}/releases-repo/releases`);
1403+ expect(await page.locator('.release-item-title').textContent()).toContain('First release');
1404+ expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
1405+ } finally { await page.close(); }
1406+ });
1407+
1408+ test('release list shows commit hash link', async () => {
1409+ const page = await adminCtx.newPage();
1410+ try {
1411+ await page.goto(`${BASE}/releases-repo/releases`);
1412+ expect(await page.locator('.release-item-meta a.monospace').textContent())
1413+ .toBe(commitHash.slice(0, 8));
1414+ } finally { await page.close(); }
1415+ });
1416+
1417+ test('new release button hidden for non-admin', async () => {
1418+ const page = await aliceCtx.newPage();
1419+ try {
1420+ await page.goto(`${BASE}/releases-repo/releases`);
1421+ expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0);
1422+ } finally { await page.close(); }
1423+ });
1424+
1425+ // ── Detail ──────────────────────────────────────────────────────────────────
1426+
1427+ test('release detail shows title, tag badge, and commit link', async () => {
1428+ const page = await adminCtx.newPage();
1429+ try {
1430+ await page.goto(releaseUrl);
1431+ expect(await page.locator('h2.page-title').textContent()).toBe('First release');
1432+ expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
1433+ expect(await page.locator('.release-item-meta a.monospace').textContent())
1434+ .toBe(commitHash.slice(0, 8));
1435+ } finally { await page.close(); }
1436+ });
1437+
1438+ test('release notes rendered in detail view', async () => {
1439+ const page = await adminCtx.newPage();
1440+ try {
1441+ await page.goto(releaseUrl);
1442+ expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release');
1443+ } finally { await page.close(); }
1444+ });
1445+
1446+ // ── Source archives ─────────────────────────────────────────────────────────
1447+
1448+ test('create release with source code archives', async () => {
1449+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1450+ multipart: { tag_name: 'v1.1.0', commit_hash: commitHash, include_source_code: 'on' },
1451+ maxRedirects: 0,
1452+ });
1453+ expect(resp.status()).toBe(302);
1454+ const location = resp.headers()['location']!;
1455+ srcReleaseUrl = `${BASE}${location}`;
1456+ });
1457+
1458+ test('zip and tar.gz archives appear in downloads', async () => {
1459+ const page = await adminCtx.newPage();
1460+ try {
1461+ await page.goto(srcReleaseUrl);
1462+ const assetNames = await page.locator('.asset-name').allTextContents();
1463+ expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true);
1464+ expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true);
1465+ } finally { await page.close(); }
1466+ });
1467+
1468+ test('source archive download responds with 200', async () => {
1469+ const page = await adminCtx.newPage();
1470+ try {
1471+ await page.goto(srcReleaseUrl);
1472+ const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href');
1473+ const resp = await page.request.get(`${BASE}${zipLink}`);
1474+ expect(resp.status()).toBe(200);
1475+ } finally { await page.close(); }
1476+ });
1477+
1478+ // ── File upload ─────────────────────────────────────────────────────────────
1479+
1480+ test('create release with attached file', async () => {
1481+ const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1482+ multipart: {
1483+ tag_name: 'v1.2.0',
1484+ commit_hash: commitHash,
1485+ files: {
1486+ name: 'release-asset.txt',
1487+ mimeType: 'text/plain',
1488+ buffer: Buffer.from('binary-like content for testing\n'),
1489+ },
1490+ },
1491+ maxRedirects: 0,
1492+ });
1493+ expect(resp.status()).toBe(302);
1494+ const location = resp.headers()['location']!;
1495+ releaseWithAssetsUrl = `${BASE}${location}`;
1496+ });
1497+
1498+ test('uploaded asset appears in downloads with filename and size', async () => {
1499+ const page = await adminCtx.newPage();
1500+ try {
1501+ await page.goto(releaseWithAssetsUrl);
1502+ const names = await page.locator('.asset-name').allTextContents();
1503+ expect(names.some(n => n.includes('release-asset.txt'))).toBe(true);
1504+ expect(await page.locator('.asset-size').isVisible()).toBe(true);
1505+ } finally { await page.close(); }
1506+ });
1507+
1508+ test('asset download responds with 200', async () => {
1509+ const page = await adminCtx.newPage();
1510+ try {
1511+ await page.goto(releaseWithAssetsUrl);
1512+ const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href');
1513+ const resp = await page.request.get(`${BASE}${link}`);
1514+ expect(resp.status()).toBe(200);
1515+ } finally { await page.close(); }
1516+ });
1517+
1518+ // ── Delete ──────────────────────────────────────────────────────────────────
1519+
1520+ test('non-admin cannot delete a release', async () => {
1521+ const page = await aliceCtx.newPage();
1522+ try {
1523+ const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
1524+ const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, {
1525+ maxRedirects: 0,
1526+ });
1527+ expect(resp.status()).toBe(403);
1528+ } finally { await page.close(); }
1529+ });
1530+
1531+ test('admin can delete a release', async () => {
1532+ const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
1533+ const resp = await adminCtx.request.post(
1534+ `${BASE}/releases-repo/releases/${idMatch![1]}/delete`,
1535+ { maxRedirects: 0 },
1536+ );
1537+ expect(resp.status()).toBe(302);
1538+ // Verify it's gone from the list
1539+ const page = await adminCtx.newPage();
1540+ try {
1541+ await page.goto(`${BASE}/releases-repo/releases`);
1542+ const titles = await page.locator('.release-item-title').allTextContents();
1543+ expect(titles.some(t => t.includes('First release'))).toBe(false);
1544+ } finally { await page.close(); }
1545+ });
1546+
1547+ // ── Pagination ──────────────────────────────────────────────────────────────
1548+
1549+ test('release list shows at most 20 per page', async () => {
1550+ // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total
1551+ // regardless of which browser-based tests above succeeded
1552+ for (let i = 1; i <= 25; i++) {
1553+ await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1554+ multipart: { tag_name: `v9.${i}.0`, commit_hash: commitHash },
1555+ maxRedirects: 0,
1556+ }).catch(() => {});
1557+ }
1558+ const page = await adminCtx.newPage();
1559+ try {
1560+ await page.goto(`${BASE}/releases-repo/releases`);
1561+ expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
1562+ } finally { await page.close(); }
1563+ });
1564+
1565+ test('pagination nav appears with more than 20 releases', async () => {
1566+ const page = await adminCtx.newPage();
1567+ try {
1568+ await page.goto(`${BASE}/releases-repo/releases`);
1569+ expect(await page.locator('.pagination').isVisible()).toBe(true);
1570+ } finally { await page.close(); }
1571+ });
1572+
1573+ test('release list page 2 shows remaining releases', async () => {
1574+ const page = await adminCtx.newPage();
1575+ try {
1576+ await page.goto(`${BASE}/releases-repo/releases?page=2`);
1577+ const count = await page.locator('.issue-item').count();
1578+ expect(count).toBeGreaterThan(0);
1579+ expect(count).toBeLessThanOrEqual(20);
1580+ } finally { await page.close(); }
1581+ });
1582+
1583+ test('source archive tar.zst appears in downloads', async () => {
1584+ const page = await adminCtx.newPage();
1585+ try {
1586+ await page.goto(srcReleaseUrl);
1587+ const assetNames = await page.locator('.asset-name').allTextContents();
1588+ expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true);
1589+ } finally { await page.close(); }
1590+ });
1591+});
1592+
1593+// ─── Settings ─────────────────────────────────────────────────────────────────
1594+
1595+describe('settings', () => {
1596+ let adminCtx: BrowserContext;
1597+ let aliceCtx: BrowserContext;
1598+ // Public key generated once in beforeAll, reused across SSH key tests
1599+ let testPubKey: string;
1600+
1601+ beforeAll(async () => {
1602+ adminCtx = await loggedInContext();
1603+ aliceCtx = await loggedInContext('alice', 'password123');
1604+
1605+ // Generate a throwaway ed25519 key for SSH key tests.
1606+ // Use Bun.spawn so the empty passphrase arg is passed correctly.
1607+ const keyPath = '/tmp/hf-e2e-sshkey';
1608+ await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
1609+ const keygen = Bun.spawn(
1610+ ['ssh-keygen', '-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
1611+ { stdout: 'ignore', stderr: 'ignore' },
1612+ );
1613+ await keygen.exited;
1614+ testPubKey = await Bun.file(`${keyPath}.pub`).text();
1615+ testPubKey = testPubKey.trim();
1616+ await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
1617+ });
1618+
1619+ afterAll(async () => {
1620+ await adminCtx.close();
1621+ await aliceCtx.close();
1622+ });
1623+
1624+ test('settings page requires auth', async () => {
1625+ const ctx = await browser.newContext();
1626+ const page = await ctx.newPage();
1627+ try {
1628+ await page.goto(`${BASE}/settings`);
1629+ expect(page.url()).toContain('/login');
1630+ } finally { await ctx.close(); }
1631+ });
1632+
1633+ test('settings page loads for logged-in user', async () => {
1634+ const page = await adminCtx.newPage();
1635+ try {
1636+ await page.goto(`${BASE}/settings`);
1637+ expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
1638+ } finally { await page.close(); }
1639+ });
1640+
1641+ // ── Password ──────────────────────────────────────────────────────────────
1642+
1643+ test('password change with mismatched passwords shows error', async () => {
1644+ const page = await aliceCtx.newPage();
1645+ try {
1646+ await page.goto(`${BASE}/settings`);
1647+ await page.fill('[name=new_password]', 'newpass123');
1648+ await page.fill('[name=confirm_password]', 'different456');
1649+ await page.click('form[action="/settings/password"] button[type=submit]');
1650+ await page.waitForURL(/\/settings/);
1651+ expect(page.url()).toContain('error');
1652+ } finally { await page.close(); }
1653+ });
1654+
1655+ test('password change with wrong current password shows error', async () => {
1656+ const page = await aliceCtx.newPage();
1657+ try {
1658+ await page.goto(`${BASE}/settings`);
1659+ await page.fill('[name=current_password]', 'wrongpassword');
1660+ await page.fill('[name=new_password]', 'newpass123');
1661+ await page.fill('[name=confirm_password]', 'newpass123');
1662+ await page.click('form[action="/settings/password"] button[type=submit]');
1663+ await page.waitForURL(/\/settings/);
1664+ expect(page.url()).toContain('error');
1665+ } finally { await page.close(); }
1666+ });
1667+
1668+ test('password change too short shows error', async () => {
1669+ const page = await aliceCtx.newPage();
1670+ try {
1671+ await page.goto(`${BASE}/settings`);
1672+ await page.fill('[name=current_password]', 'password123');
1673+ await page.fill('[name=new_password]', 'short');
1674+ await page.fill('[name=confirm_password]', 'short');
1675+ await page.click('form[action="/settings/password"] button[type=submit]');
1676+ await page.waitForURL(/\/settings/);
1677+ expect(page.url()).toContain('error');
1678+ } finally { await page.close(); }
1679+ });
1680+
1681+ // ── SSH keys ──────────────────────────────────────────────────────────────
1682+
1683+ test('add SSH key with unsupported key type shows error', async () => {
1684+ const page = await adminCtx.newPage();
1685+ try {
1686+ await page.goto(`${BASE}/settings`);
1687+ await page.fill('#ssh_key_name', 'Bad key');
1688+ await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
1689+ await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
1690+ await page.waitForURL(/\/settings/);
1691+ expect(page.url()).toContain('error');
1692+ } finally { await page.close(); }
1693+ });
1694+
1695+ test('add valid SSH key shows success and key appears in list', async () => {
1696+ const page = await adminCtx.newPage();
1697+ try {
1698+ await page.goto(`${BASE}/settings`);
1699+ await page.fill('#ssh_key_name', 'My Laptop');
1700+ await page.fill('#ssh_public_key', testPubKey);
1701+ await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
1702+ await page.waitForURL(/\/settings/);
1703+ expect(page.url()).toContain('success=ssh_key_added');
1704+ await page.goto(`${BASE}/settings`);
1705+ expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
1706+ } finally { await page.close(); }
1707+ });
1708+
1709+ test('add duplicate SSH key shows error', async () => {
1710+ const page = await adminCtx.newPage();
1711+ try {
1712+ await page.goto(`${BASE}/settings`);
1713+ await page.fill('#ssh_key_name', 'Duplicate');
1714+ await page.fill('#ssh_public_key', testPubKey);
1715+ await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
1716+ await page.waitForURL(/\/settings/);
1717+ expect(page.url()).toContain('error');
1718+ } finally { await page.close(); }
1719+ });
1720+
1721+ test('delete SSH key removes it from list', async () => {
1722+ const page = await adminCtx.newPage();
1723+ try {
1724+ await page.goto(`${BASE}/settings`);
1725+ // Click the Remove button for the key added above
1726+ await page.click('form[action="/settings/ssh-keys/delete"] button');
1727+ await page.waitForURL(/\/settings/);
1728+ expect(page.url()).toContain('success=ssh_key_deleted');
1729+ await page.goto(`${BASE}/settings`);
1730+ expect(await page.locator('.ssh-key-name').count()).toBe(0);
1731+ } finally { await page.close(); }
1732+ });
1733+
1734+ // ── Admin user management ────────────────────────────────────────────────
1735+
1736+ test('admin can create a new user account', async () => {
1737+ const page = await adminCtx.newPage();
1738+ try {
1739+ await page.goto(`${BASE}/settings`);
1740+ await page.fill('#new_username', 'charlie');
1741+ await page.fill('#new_user_password', 'charliepw1');
1742+ await page.click('form[action="/admin/users"] button[type=submit]');
1743+ await page.waitForURL(/\/settings/);
1744+ expect(page.url()).toContain('success=user_created');
1745+ } finally { await page.close(); }
1746+ });
1747+
1748+ test('admin cannot create duplicate username', async () => {
1749+ const page = await adminCtx.newPage();
1750+ try {
1751+ await page.goto(`${BASE}/settings`);
1752+ await page.fill('#new_username', 'charlie');
1753+ await page.fill('#new_user_password', 'charliepw1');
1754+ await page.click('form[action="/admin/users"] button[type=submit]');
1755+ await page.waitForURL(/\/settings/);
1756+ expect(page.url()).toContain('error');
1757+ } finally { await page.close(); }
1758+ });
1759+
1760+ test('admin cannot create user with invalid username characters', async () => {
1761+ const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
1762+ form: { username: 'bad user!', password: 'password123' },
1763+ maxRedirects: 0,
1764+ });
1765+ expect(resp.status()).toBe(302);
1766+ const location = resp.headers()['location'] ?? '';
1767+ expect(location).toContain('error');
1768+ });
1769+
1770+ test('non-admin gets 403 when creating user', async () => {
1771+ const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
1772+ form: { username: 'hacker', password: 'password123' },
1773+ maxRedirects: 0,
1774+ });
1775+ expect(resp.status()).toBe(403);
1776+ });
1777+
1778+ test('admin can delete user account', async () => {
1779+ const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
1780+ form: { username: 'charlie' },
1781+ maxRedirects: 0,
1782+ });
1783+ expect(resp.status()).toBe(302);
1784+ expect(resp.headers()['location']).toContain('success=user_deleted');
1785+ });
1786+
1787+ test('admin cannot delete the admin account', async () => {
1788+ const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
1789+ form: { username: 'admin' },
1790+ maxRedirects: 0,
1791+ });
1792+ expect(resp.status()).toBe(302);
1793+ expect(resp.headers()['location']).toContain('error');
1794+ });
1795+});
1796+
1797+// ─── Repository deletion ──────────────────────────────────────────────────────
1798+
1799+describe('repository deletion', () => {
1800+ let adminCtx: BrowserContext;
1801+
1802+ beforeAll(async () => {
1803+ adminCtx = await loggedInContext();
1804+
1805+ // Create a repo to delete
1806+ const page = await adminCtx.newPage();
1807+ try {
1808+ await page.goto(`${BASE}/new`);
1809+ await page.fill('[name=name]', 'deleteme-repo');
1810+ await page.click('form[action="/new"] button[type=submit]');
1811+ await page.waitForURL(`${BASE}/deleteme-repo`);
1812+ } finally { await page.close(); }
1813+ });
1814+
1815+ afterAll(async () => { await adminCtx.close(); });
1816+
1817+ test('admin can delete repository', async () => {
1818+ const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
1819+ maxRedirects: 0,
1820+ });
1821+ expect(resp.status()).toBe(302);
1822+ expect(resp.headers()['location']).toBe('/');
1823+ });
1824+
1825+ test('deleted repository returns 404', async () => {
1826+ const page = await adminCtx.newPage();
1827+ try {
1828+ const resp = await page.request.get(`${BASE}/deleteme-repo`);
1829+ expect(resp.status()).toBe(404);
1830+ } finally { await page.close(); }
1831+ });
1832+
1833+ test('deleted repository no longer appears in list', async () => {
1834+ const page = await adminCtx.newPage();
1835+ try {
1836+ await page.goto(BASE);
1837+ expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
1838+ } finally { await page.close(); }
1839+ });
1840+
1841+ test('non-admin cannot delete repository', async () => {
1842+ const aliceCtx = await loggedInContext('alice', 'password123');
1843+ const page = await aliceCtx.newPage();
1844+ try {
1845+ const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
1846+ maxRedirects: 0,
1847+ });
1848+ expect(resp.status()).toBe(403);
1849+ } finally {
1850+ await page.close();
1851+ await aliceCtx.close();
1852+ }
1853+ });
1854+});
1855+
1856+// ─── 404 handling ─────────────────────────────────────────────────────────────
1857+
1858+describe('404 handling', () => {
1859+ let adminCtx: BrowserContext;
1860+
1861+ beforeAll(async () => { adminCtx = await loggedInContext(); });
1862+ afterAll(async () => { await adminCtx.close(); });
1863+
1864+ test('non-existent repository returns 404', async () => {
1865+ const page = await adminCtx.newPage();
1866+ try {
1867+ const resp = await page.request.get(`${BASE}/no-such-repo`);
1868+ expect(resp.status()).toBe(404);
1869+ } finally { await page.close(); }
1870+ });
1871+
1872+ test('non-existent issue returns 404', async () => {
1873+ const page = await adminCtx.newPage();
1874+ try {
1875+ const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
1876+ expect(resp.status()).toBe(404);
1877+ } finally { await page.close(); }
1878+ });
1879+
1880+ test('non-existent commit returns 404', async () => {
1881+ const page = await adminCtx.newPage();
1882+ try {
1883+ const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
1884+ expect(resp.status()).toBe(404);
1885+ } finally { await page.close(); }
1886+ });
1887+
1888+ test('non-existent file blob returns 404', async () => {
1889+ const page = await adminCtx.newPage();
1890+ try {
1891+ const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
1892+ expect(resp.status()).toBe(404);
1893+ } finally { await page.close(); }
1894+ });
1895+});
1896+
1897+// ─── Issue editing and deletion ───────────────────────────────────────────────
1898+
1899+describe('issue editing', () => {
1900+ let adminCtx: BrowserContext;
1901+ let aliceCtx: BrowserContext;
1902+ let issueUrl: string;
1903+
1904+ beforeAll(async () => {
1905+ adminCtx = await loggedInContext();
1906+ aliceCtx = await loggedInContext('alice', 'password123');
1907+
1908+ // Create an issue to edit
1909+ const page = await adminCtx.newPage();
1910+ try {
1911+ await page.goto(`${BASE}/my-repo/issues/new`);
1912+ await page.fill('[name=title]', 'Issue to edit');
1913+ await page.fill('[name=body]', 'Original body.');
1914+ await page.click('form[action$="/issues"] button[type=submit]');
1915+ await page.waitForURL(/\/my-repo\/issues\/\d+/);
1916+ issueUrl = page.url();
1917+ } finally { await page.close(); }
1918+ });
1919+
1920+ afterAll(async () => {
1921+ await adminCtx.close();
1922+ await aliceCtx.close();
1923+ });
1924+
1925+ test('author can edit issue title and body', async () => {
1926+ const page = await adminCtx.newPage();
1927+ try {
1928+ await page.goto(issueUrl);
1929+ await page.locator('.timeline-author .inline-edit-details').first().locator('summary').click();
1930+ await page.fill('.inline-edit-form-area [name=title]', 'Edited issue title');
1931+ await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
1932+ await page.click('.inline-edit-form-area [type=submit]');
1933+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
1934+ expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
1935+ } finally { await page.close(); }
1936+ });
1937+
1938+ test('non-author non-admin cannot edit issue', async () => {
1939+ const page = await aliceCtx.newPage();
1940+ try {
1941+ const issueNum = issueUrl.split('/issues/')[1];
1942+ const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
1943+ form: { title: 'Hacked title', edit_body: '' },
1944+ maxRedirects: 0,
1945+ });
1946+ expect(resp.status()).toBe(403);
1947+ } finally { await page.close(); }
1948+ });
1949+
1950+ test('author can edit issue comment', async () => {
1951+ const page = await adminCtx.newPage();
1952+ try {
1953+ await page.goto(issueUrl);
1954+ // Add a comment first
1955+ await page.fill('textarea[name=body]', 'Comment to edit.');
1956+ await page.click('form[action*="/comments"] button[type=submit]');
1957+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
1958+
1959+ // Edit the comment
1960+ const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
1961+ await commentItem.locator('.inline-edit-details summary').click();
1962+ await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
1963+ await commentItem.locator('.inline-edit-form-area [type=submit]').click();
1964+ await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
1965+ expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
1966+ } finally { await page.close(); }
1967+ });
1968+
1969+ test('non-admin user can create an issue', async () => {
1970+ const page = await aliceCtx.newPage();
1971+ try {
1972+ await page.goto(`${BASE}/my-repo/issues/new`);
1973+ await page.fill('[name=title]', "Alice's issue");
1974+ await page.click('form[action$="/issues"] button[type=submit]');
1975+ await page.waitForURL(/\/my-repo\/issues\/\d+/);
1976+ expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
1977+ } finally { await page.close(); }
1978+ });
1979+
1980+ test('non-admin cannot comment on a closed issue', async () => {
1981+ // Close the issue as admin first
1982+ const issueNum = issueUrl.split('/issues/')[1];
1983+ await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
1984+
1985+ const page = await aliceCtx.newPage();
1986+ try {
1987+ const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
1988+ form: { body: 'comment on closed issue' },
1989+ maxRedirects: 0,
1990+ });
1991+ // Non-admin gets redirected (silently ignored), not an error
1992+ expect(resp.status()).toBe(302);
1993+ // The comment should NOT appear
1994+ await page.goto(issueUrl);
1995+ const bodies = await page.locator('.timeline-body').allTextContents();
1996+ expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
1997+ } finally { await page.close(); }
1998+ });
1999+
2000+ test('admin can delete issue', async () => {
2001+ const issueNum = issueUrl.split('/issues/')[1];
2002+ const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
2003+ maxRedirects: 0,
2004+ });
2005+ expect(resp.status()).toBe(302);
2006+ // Issue should be gone
2007+ const page = await adminCtx.newPage();
2008+ try {
2009+ const checkResp = await page.request.get(issueUrl);
2010+ expect(checkResp.status()).toBe(404);
2011+ } finally { await page.close(); }
2012+ });
2013+});
2014+
2015+// ─── Repository description update ───────────────────────────────────────────
2016+
2017+describe('repo description', () => {
2018+ let adminCtx: BrowserContext;
2019+
2020+ beforeAll(async () => { adminCtx = await loggedInContext(); });
2021+ afterAll(async () => { await adminCtx.close(); });
2022+
2023+ test('updating repo description is reflected on list page', async () => {
2024+ const page = await adminCtx.newPage();
2025+ try {
2026+ await page.goto(`${BASE}/my-repo/settings`);
2027+ await page.fill('[name=description]', 'A freshly updated description');
2028+ await page.click('form[action$="/settings"] button[type=submit]');
2029+ expect(await page.locator('.form-success').isVisible()).toBe(true);
2030+
2031+ await page.goto(BASE);
2032+ const desc = await page.locator('.repo-description').allTextContents();
2033+ expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
2034+ } finally { await page.close(); }
2035+ });
2036+});
Atests/helpers.ts
@@ -0,0 +1,146 @@
1+import { $ } from 'bun';
2+import { createServer } from 'net';
3+import { rmSync, mkdirSync, writeFileSync } from 'fs';
4+import path from 'path';
5+import type { Page } from 'playwright';
6+
7+export const DATA_DIR = './data-test';
8+export const ADMIN_PASS = 'testpass123';
9+
10+/** Bind to port 0 and return the OS-assigned free port number. */
11+function getFreePort(): Promise<number> {
12+ return new Promise((resolve, reject) => {
13+ const srv = createServer();
14+ srv.listen(0, '127.0.0.1', () => {
15+ const port = (srv.address() as { port: number }).port;
16+ srv.close((err) => (err ? reject(err) : resolve(port)));
17+ });
18+ srv.on('error', reject);
19+ });
20+}
21+
22+// Resolved once in setupTestEnv() and re-exported for tests.
23+export let PORT = 0;
24+export let BASE = '';
25+
26+export async function setupTestEnv() {
27+ PORT = await getFreePort();
28+ BASE = `http://localhost:${PORT}`;
29+
30+ rmSync(DATA_DIR, { recursive: true, force: true });
31+ mkdirSync(`${DATA_DIR}/repos`, { recursive: true });
32+
33+ const init = Bun.spawn(['bun', 'run', 'src/db/init.ts'], {
34+ env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS },
35+ stdout: 'pipe',
36+ stderr: 'pipe',
37+ });
38+ await init.exited;
39+}
40+
41+export function spawnServer() {
42+ return Bun.spawn(['bun', 'run', 'src/index.tsx'], {
43+ env: {
44+ ...process.env,
45+ PORT: String(PORT),
46+ DATA_DIR,
47+ RATE_LIMIT_DISABLED: 'true',
48+ SSH_DISABLED: 'true',
49+ },
50+ stdout: 'ignore',
51+ stderr: 'ignore',
52+ });
53+}
54+
55+export async function waitForServer(maxMs = 10_000) {
56+ const deadline = Date.now() + maxMs;
57+ while (Date.now() < deadline) {
58+ try {
59+ await fetch(BASE);
60+ return;
61+ } catch {
62+ await Bun.sleep(150);
63+ }
64+ }
65+ throw new Error('Server did not start in time');
66+}
67+
68+export async function login(
69+ page: Page,
70+ username = 'admin',
71+ password = ADMIN_PASS,
72+) {
73+ await page.goto(`${BASE}/login`);
74+ await page.fill('[name=username]', username);
75+ await page.fill('[name=password]', password);
76+ await page.click('button[type=submit]');
77+ await page.waitForURL(BASE + '/');
78+}
79+
80+export async function logout(page: Page) {
81+ await page.click('form[action="/logout"] button');
82+ await page.waitForURL(BASE + '/');
83+}
84+
85+/** Push an initial commit into a bare repo that already exists on disk. */
86+export async function seedRepo(name: string) {
87+ const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
88+ const tmp = `/tmp/hf-seed-${Date.now()}`;
89+ try {
90+ await $`git clone ${repoPath} ${tmp}`.quiet();
91+ await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
92+ await $`git -C ${tmp} config user.name "Test"`.quiet();
93+ writeFileSync(`${tmp}/README.md`, `# ${name}\n`);
94+ writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`);
95+ await $`git -C ${tmp} add -A`.quiet();
96+ await $`git -C ${tmp} commit -m "Initial commit"`.quiet();
97+ await $`git -C ${tmp} push origin HEAD:main`.quiet();
98+ } finally {
99+ await $`rm -rf ${tmp}`.quiet().nothrow();
100+ }
101+}
102+
103+export function writeTempFile(path: string, content: string) {
104+ writeFileSync(path, content);
105+}
106+
107+/** Commit a subdirectory with the given files into an existing repo on main. */
108+export async function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) {
109+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
110+ const tmp = `/tmp/hf-subdir-${Date.now()}`;
111+ try {
112+ await $`git clone ${repoDir} ${tmp}`.quiet();
113+ await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
114+ await $`git -C ${tmp} config user.name "Test"`.quiet();
115+ mkdirSync(path.join(tmp, dirPath), { recursive: true });
116+ for (const [fileName, content] of Object.entries(files)) {
117+ writeFileSync(path.join(tmp, dirPath, fileName), content);
118+ }
119+ await $`git -C ${tmp} add -A`.quiet();
120+ await $`git -C ${tmp} commit -m ${'Add ' + dirPath}`.quiet();
121+ await $`git -C ${tmp} push origin HEAD:main`.quiet();
122+ } finally {
123+ await $`rm -rf ${tmp}`.quiet().nothrow();
124+ }
125+}
126+
127+/** Return the HEAD commit hash of a repo. */
128+export async function getHeadCommit(repoName: string): Promise<string> {
129+ const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
130+ return (await $`git -C ${repoPath} rev-parse HEAD`.quiet()).text().trim();
131+}
132+
133+/** Create a new branch in an existing repo (from current HEAD). */
134+export async function seedBranch(name: string, branchName: string) {
135+ const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
136+ const tmp = `/tmp/hf-branch-${Date.now()}`;
137+ try {
138+ await $`git clone ${repoPath} ${tmp}`.quiet();
139+ await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
140+ await $`git -C ${tmp} config user.name "Test"`.quiet();
141+ await $`git -C ${tmp} checkout -b ${branchName}`.quiet();
142+ await $`git -C ${tmp} push origin ${branchName}`.quiet();
143+ } finally {
144+ await $`rm -rf ${tmp}`.quiet().nothrow();
145+ }
146+}
Atests/highlight.test.ts
@@ -0,0 +1,37 @@
1+import { describe, test, expect, beforeAll } from "bun:test";
2+import { detectLang, highlightStartup } from "../src/services/highlight.ts";
3+
4+beforeAll(async () => {
5+ await highlightStartup();
6+});
7+
8+describe("detectLang", () => {
9+ // Extensions
10+ test("detects TypeScript", () => expect(detectLang("foo.ts")).toBe("typescript"));
11+ test("detects TSX", () => expect(detectLang("foo.tsx")).toBe("tsx"));
12+ test("detects JavaScript", () => expect(detectLang("foo.js")).toBe("javascript"));
13+ test("detects Python", () => expect(detectLang("foo.py")).toBe("python"));
14+ test("detects Rust", () => expect(detectLang("foo.rs")).toBe("rust"));
15+ test("detects Go", () => expect(detectLang("foo.go")).toBe("go"));
16+ test("detects C", () => expect(detectLang("foo.c")).toBe("c"));
17+ test("detects C++", () => expect(detectLang("foo.cpp")).toBe("cpp"));
18+ test("detects C header", () => expect(detectLang("foo.h")).toBe("c"));
19+ test("detects YAML", () => expect(detectLang("foo.yml")).toBe("yaml"));
20+ test("detects TOML", () => expect(detectLang("foo.toml")).toBe("toml"));
21+ test("detects JSON", () => expect(detectLang("foo.json")).toBe("json"));
22+ test("detects Markdown", () => expect(detectLang("foo.md")).toBe("markdown"));
23+ test("detects Shell", () => expect(detectLang("foo.sh")).toBe("shellscript"));
24+ test("detects diff/patch", () => expect(detectLang("foo.patch")).toBe("diff"));
25+
26+ // Specific filenames
27+ test("detects Dockerfile by filename", () => expect(detectLang("Dockerfile")).toBe("docker"));
28+ test("detects Makefile by filename", () => expect(detectLang("Makefile")).toBe("make"));
29+
30+ // Path stripping
31+ test("strips path before detecting", () => expect(detectLang("src/foo/bar.ts")).toBe("typescript"));
32+ test("strips path for Dockerfile", () => expect(detectLang("infra/Dockerfile")).toBe("docker"));
33+
34+ // Fallback
35+ test("falls back to text for unknown extension", () => expect(detectLang("foo.xyz")).toBe("text"));
36+ test("falls back to text for no extension", () => expect(detectLang("LICENSE")).toBe("text"));
37+});
Atsconfig.json
@@ -0,0 +1,30 @@
1+{
2+ "compilerOptions": {
3+ // Environment setup & latest features
4+ "lib": ["ESNext"],
5+ "target": "ESNext",
6+ "module": "Preserve",
7+ "moduleDetection": "force",
8+ "jsx": "react-jsx",
9+ "jsxImportSource": "@kitajs/html",
10+ "allowJs": true,
11+
12+ // Bundler mode
13+ "moduleResolution": "bundler",
14+ "allowImportingTsExtensions": true,
15+ "verbatimModuleSyntax": true,
16+ "noEmit": true,
17+
18+ // Best practices
19+ "strict": true,
20+ "skipLibCheck": true,
21+ "noFallthroughCasesInSwitch": true,
22+ "noUncheckedIndexedAccess": true,
23+ "noImplicitOverride": true,
24+
25+ // Some stricter flags (disabled by default)
26+ "noUnusedLocals": false,
27+ "noUnusedParameters": false,
28+ "noPropertyAccessFromIndexSignature": false
29+ }
30+}