v1

AuthorKonata <konata@posteo.jp>
Date
Commit8de77c0aace3541af5c159bc26fa468e09eb1991
76 files changed, 14658 insertions(+)
▾A.gitignore
@@ -0,0 +1,39 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
memory/
public/assets/simplewebauthn-browser.js
public/assets/jxl-polyfill.js
public/assets/*.wasm
# Hearthforge data
data/
.env
# Test data
data-test/
▾AContainerfile
@@ -0,0 +1,25 @@
FROM oven/bun:1 AS base
WORKDIR /app
RUN apt-get --update install -y --no-install-recommends \
tini openssh-client git-core zstd \
&& rm -rf /var/lib/apt/lists/*
# Copy public first so postinstall can write vendor files into it
COPY public ./public
# Install dependencies (postinstall vendors simplewebauthn + jxl-polyfill into public/)
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile --production
# Copy source
COPY src ./src
COPY tsconfig.json ./
EXPOSE 3000
ENV DATA_DIR=/data
VOLUME ["/data"]
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["sh", "-c", "bun run db:init && bun run start"]
▾AREADME.md
@@ -0,0 +1,93 @@
# Hearthforge ![](public/assets/favicon.svg)
![](preview.png)
A self-hosted git forge designed to host your own repositories, but allow others to interact with them via issues and patch proposals.
Frontend works without any JS at all enabled, just required for WebAuthn (with graceful fallback to password-only).
## Features
- **Repository browser** — file tree, blob view, commit log, README rendering
- **Issues** — create, comment, react
- **Patches** — submit diffs for review & comments. Admin can merge applicable patches directly into the repository.
- **Releases** — tag-based releases with source archives and extra uploaded assets
- **SSH push/pull** — built-in SSH server, no external git daemon needed
- **Auth** — password login or passkeys (WebAuthn/FIDO2)
- **Optional registration** — others can create accounts to file issues and patches; can be disabled
## Stack
- [Bun](https://bun.sh) — runtime and package manager
- [ElysiaJS](https://elysiajs.com) — HTTP framework
- SQLite — single-file database via Kysely
- Server-side JSX via @kitajs/html (no client-side framework)
- Shiki — syntax highlighting
## Running
```bash
bun install
bun run db:init # creates database and admin account
bun run start # http://localhost:3000, SSH on port 2222
```
Default admin credentials: `admin` / `changeme`
## Manual repository creation
Repositories can bei either created through the UI, or existing ones can be copied manually to the `data/repos` directory.
Non-bare repos are automatically converted to bare repos on startup, discarding uncomitted changes and worktrees.
### Docker / Podman
Container images are provided.
```bash
docker compose up # or
podman compose up
```
### Configuration
All settings are environment variables:
| Variable | Default | Description |
|-------------------------|-------------------------|-------------------------------------------------|
| `PORT` | `3000` | HTTP port |
| `SSH_PORT` | `2222` | SSH port |
| `DATA_DIR` | `./data` | Repos, database, uploads |
| `ADMIN_PASSWORD` | `changeme` | Initial admin password |
| `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner |
| `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links |
| `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups |
| `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) |
| `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) |
| `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view |
| `SSH_DISABLED` | `0` | Disable the embedded SSH-server |
| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
| `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
\* More workers mean more CPU cores can be used to parallelize highlighting of files.
Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB.
So be careful when increasing this.
### SSH access
Add your public key under Settings → SSH keys, then clone with:
```
git clone ssh://git@localhost:{SSH_PORT}/{REPO_NAME}
```
Pushing is also supported for the admin.
## Development
```bash
bun run dev # watch mode
bun run lint # Biome lint
bun run format # Biome format
bun run test # Playwright E2E tests (don't use bun test, it doesn't respect the timeout)
```
## Roadmap
- Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation
- Issue labels
- Repository list reordering (e.g. last committed) and starring
- redirect image urls in readme
▾Abiome.json
@@ -0,0 +1,40 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["src/**"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
▾Abun.lock
@@ -0,0 +1,461 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "hearthforge",
"dependencies": {
"@elysiajs/static": "^1.4.7",
"@jsquash/jxl": "^1.3.0",
"@kitajs/html": "^4.2.13",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"argon2": "^0.44.0",
"elysia": "^1.4.27",
"file-type": "^21.3.2",
"isomorphic-dompurify": "^3.3.0",
"jxl-rs-polyfill": "^0.1.1",
"kysely": "^0.28.12",
"kysely-bun-sqlite": "^0.4.0",
"linguist-languages": "^9.3.1",
"marked": "^17.0.4",
"sharp": "^0.34.5",
"shiki": "^4.0.2",
"ssh2": "^1.17.0",
},
"devDependencies": {
"@biomejs/biome": "^2.4.7",
"@types/argon2": "^0.15.4",
"@types/bun": "latest",
"@types/ssh2": "^1.15.5",
"playwright": "^1.58.2",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
"@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=="],
"@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=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@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=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ=="],
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@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=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
"@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=="],
"@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=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@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=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@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=="],
"@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=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jsquash/jxl": ["@jsquash/jxl@1.3.0", "", { "dependencies": { "wasm-feature-detect": "^1.5.1" } }, "sha512-IVOPTneyOd9eBAuow+FnCYIP6wkIc7CjrCDnZGiqAPyJ63vMVxv3zoWiucGiZVh6u9vi/l40AkcS9QwkoVSAqA=="],
"@kitajs/html": ["@kitajs/html@4.2.13", "", { "dependencies": { "csstype": "^3.1.3" } }, "sha512-o+8e61EsoLDPTP7rsPkYolca1YFybHuxU2Lr5fWDZCUkYT/6uBlVkvnZUdCXMQKentJL9dxwpR8/xK2Q+U4LhA=="],
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="],
"@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=="],
"@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=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
"@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
"@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=="],
"@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
"@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=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="],
"@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=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/argon2": ["@types/argon2@0.15.4", "", { "dependencies": { "argon2": "*" } }, "sha512-fZPJNvZTvoyt/okKhbyj99UsDOwqzEXYPvKqfK26mViH9JB/hOwzUwDUn5qbwq9XJkLw0kDcFNu6+bnb3u5a0A=="],
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"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=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"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=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"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=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
"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=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"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=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"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=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"isomorphic-dompurify": ["isomorphic-dompurify@3.3.0", "", { "dependencies": { "dompurify": "^3.3.3", "jsdom": "^28.1.0" } }, "sha512-Xx3GlNZNAXIISX49OMpfNgxrRU49KVQ5hqnK2zbkqkPucdWnCZXquTMK/COnb8pmIOJlfiaNYav9qyHX3gRDMA=="],
"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=="],
"jxl-rs-polyfill": ["jxl-rs-polyfill@0.1.1", "", {}, "sha512-BFrrvbnInmDYqEcZZZ2nYJq248VMq6BBBcnc7uhAndz1uhIEclW6uMPLQjqTrEVcDg1JeRhq796kuSc+C/JasQ=="],
"kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="],
"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=="],
"linguist-languages": ["linguist-languages@9.3.1", "", {}, "sha512-Mum2sqg3MyhgKfpulFhKZMAK/1VnV6m9vCV8YQCSqWs+pbKouKn9EqRshZjVWUaJjl6NTTDcYJk/1+C02siXEQ=="],
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
"marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="],
"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=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"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=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"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=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="],
"node-addon-api": ["node-addon-api@8.6.0", "", {}, "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q=="],
"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=="],
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
"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=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"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=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"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=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"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=="],
"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=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="],
"tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="],
"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=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici": ["undici@7.24.3", "", {}, "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"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=="],
"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=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"wasm-feature-detect": ["wasm-feature-detect@1.8.0", "", {}, "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"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=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}
▾Acompose.yml
@@ -0,0 +1,20 @@
services:
hearthforge:
build:
context: .
ports:
- "3000:3000"
- "2222:2222"
volumes:
- ./data:/data
environment:
DATA_DIR: /data
PORT: 3000
SSH_PORT: 2222
OWNER_DISPLAY_NAME: Admin
BASE_URL: "http://localhost:3000"
# ADMIN_PASSWORD: change-me
# SSH_DISABLED: 0
# ARCHIVE_ZST_ENABLED: 0
# TRUSTED_PROXY: 1
# REGISTRATION_DISABLED: 0
▾Apackage.json
@@ -0,0 +1,47 @@
{
"name": "hearthforge",
"module": "src/index.tsx",
"scripts": {
"dev": "bun run --watch src/index.tsx",
"start": "bun run src/index.tsx",
"db:init": "bun run src/db/init.ts",
"db:seed": "bun run src/db/seed.ts",
"test": "bun test tests/e2e.test.ts --timeout 60000",
"vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm",
"vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js",
"postinstall": "bun run vendor:simplewebauthn && bun run vendor:jxl-polyfill",
"lint": "biome check src/",
"format": "biome check --write src/"
},
"type": "module",
"private": true,
"devDependencies": {
"@biomejs/biome": "^2.4.7",
"@types/argon2": "^0.15.4",
"@types/bun": "latest",
"@types/ssh2": "^1.15.5",
"playwright": "^1.58.2"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@elysiajs/static": "^1.4.7",
"@jsquash/jxl": "^1.3.0",
"@kitajs/html": "^4.2.13",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"argon2": "^0.44.0",
"elysia": "^1.4.27",
"file-type": "^21.3.2",
"isomorphic-dompurify": "^3.3.0",
"jxl-rs-polyfill": "^0.1.1",
"kysely": "^0.28.12",
"kysely-bun-sqlite": "^0.4.0",
"linguist-languages": "^9.3.1",
"marked": "^17.0.4",
"sharp": "^0.34.5",
"shiki": "^4.0.2",
"ssh2": "^1.17.0"
}
}
▾Apreview.png
Bin 0 → 99613 bytes@ 8de77c0@ master
Binary file — not shown
▾Apublic/assets/favicon.svg
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 32 32">
<!-- Hearth stone base -->
<rect x="3" y="23" width="26" height="7" rx="2" fill="#6b2e0c"/>
<!-- Stone highlight band -->
<rect x="5" y="24" width="22" height="2" rx="1" fill="#8b4220"/>
<!-- Outer flame (deep red-orange) -->
<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"/>
<!-- Mid flame (bright orange) -->
<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"/>
<!-- Inner glow (amber) -->
<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"/>
<!-- Hot core (golden yellow) -->
<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"/>
</svg>
▾Apublic/assets/main.css
@@ -0,0 +1,1807 @@
/* View Transitions */
@view-transition {
navigation: auto;
}
.site-nav { view-transition-name: site-nav; }
.repo-header { view-transition-name: repo-header; }
.breadcrumb { view-transition-name: breadcrumb; }
/* ============================================================
@layer reset
============================================================ */
@layer reset {
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100dvh; min-width: 500px; }
img, video { max-width: 100%; height: auto; }
button, input, textarea, select { font: inherit; }
a { color: inherit; }
ul, ol { list-style: none; }
h1, h2, h3, h4, h5, h6 { font-weight: 600; line-height: 1.2; }
pre, code { font-family: var(--font-mono); }
}
/* ============================================================
@layer tokens
============================================================ */
@layer tokens {
:root {
/* Colors — light */
--color-bg: #ffffff;
--color-bg-subtle: #f6f8fa;
--color-bg-inset: #eef0f2;
--color-border: #d0d7de;
--color-border-muted: #e4e7eb;
--color-text: #1f2328;
--color-text-muted: #656d76;
--color-link: #0969da;
--color-link-hover: #0550ae;
--color-accent: #fd7e14;
--color-accent-bg: #fff3cd;
--color-success: #1a7f37;
--color-success-bg: #dafbe1;
--color-danger: #cf222e;
--color-danger-bg: #ffebe9;
--color-warning: #9a6700;
--color-warning-bg: #fff8c5;
--color-merged: #8250df;
--color-merged-bg: #fbefff;
/* Typography */
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
/* Border radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 0 rgba(27,31,36,0.04);
--shadow-md: 0 3px 6px rgba(140,149,159,0.15);
--shadow-lg: 0 8px 24px rgba(140,149,159,0.2);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0d1117;
--color-bg-subtle: #161b22;
--color-bg-inset: #1c2128;
--color-border: #30363d;
--color-border-muted: #21262d;
--color-text: #e6edf3;
--color-text-muted: #7d8590;
--color-link: #58a6ff;
--color-link-hover: #79c0ff;
--color-accent: #f0883e;
--color-accent-bg: #3d2f00;
--color-success: #3fb950;
--color-success-bg: #0d2b19;
--color-danger: #f85149;
--color-danger-bg: #2d1419;
--color-warning: #d29922;
--color-warning-bg: #2c2000;
--color-merged: #bc8cff;
--color-merged-bg: #1e0b3e;
}
}
/* Explicit theme overrides — applied via data-theme attribute set by cookie */
:root[data-theme="dark"] {
--color-bg: #0d1117;
--color-bg-subtle: #161b22;
--color-bg-inset: #1c2128;
--color-border: #30363d;
--color-border-muted: #21262d;
--color-text: #e6edf3;
--color-text-muted: #7d8590;
--color-link: #58a6ff;
--color-link-hover: #79c0ff;
--color-accent: #f0883e;
--color-accent-bg: #3d2f00;
--color-success: #3fb950;
--color-success-bg: #0d2b19;
--color-danger: #f85149;
--color-danger-bg: #2d1419;
--color-warning: #d29922;
--color-warning-bg: #2c2000;
--color-merged: #bc8cff;
--color-merged-bg: #1e0b3e;
}
:root[data-theme="light"] {
--color-bg: #ffffff;
--color-bg-subtle: #f6f8fa;
--color-bg-inset: #eef0f2;
--color-border: #d0d7de;
--color-border-muted: #e4e7eb;
--color-text: #1f2328;
--color-text-muted: #656d76;
--color-link: #0969da;
--color-link-hover: #0550ae;
--color-accent: #fd7e14;
--color-accent-bg: #fff3cd;
--color-success: #1a7f37;
--color-success-bg: #dafbe1;
--color-danger: #cf222e;
--color-danger-bg: #ffebe9;
--color-warning: #9a6700;
--color-warning-bg: #fff8c5;
--color-merged: #8250df;
--color-merged-bg: #fbefff;
}
}
/* ============================================================
@layer layout
============================================================ */
@layer layout {
body {
font-family: var(--font-sans);
font-size: var(--text-base);
color: var(--color-text);
background: var(--color-bg);
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
}
.site-header {
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
}
.site-nav {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
gap: var(--space-4);
}
.site-logo {
display: flex;
align-items: center;
gap: var(--space-2);
text-decoration: none;
font-weight: 700;
font-size: var(--text-lg);
color: var(--color-text);
}
.logo-icon { width: 1.6em; height: 1.6em; display: block; flex-shrink: 0; }
.nav-links {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-3);
}
.nav-user {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.site-main {
padding: var(--space-8) var(--space-4);
min-width: 0;
}
.site-footer {
text-align: center;
padding: var(--space-6) var(--space-4);
font-size: var(--text-sm);
color: var(--color-text-muted);
border-top: 1px solid var(--color-border);
}
.container {
max-width: 1000px;
margin: 0 auto;
overflow-x: clip;
}
.container-narrow {
max-width: 680px;
}
.inline-form {
display: inline;
}
/* --- Two-step delete confirmation --- */
.confirm-details { position: relative; display: inline-block; }
.confirm-details > summary { list-style: none; }
.confirm-details > summary::-webkit-details-marker { display: none; }
.confirm-popup {
position: absolute;
right: 0;
top: calc(100% + var(--space-1));
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
gap: var(--space-3);
white-space: nowrap;
z-index: 100;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
}
/* ============================================================
@layer components
============================================================ */
@layer components {
/* --- Buttons --- */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
cursor: pointer;
background: var(--color-bg-subtle);
color: var(--color-text);
transition: background 0.1s, border-color 0.1s;
white-space: nowrap;
}
.btn:hover { background: var(--color-bg-inset); border-color: var(--color-border); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--color-accent);
border-color: transparent;
color: #fff;
}
.btn-primary:hover { background: color-mix(in srgb, var(--color-accent) 82%, #000); }
.btn-secondary {
background: var(--color-bg-subtle);
border-color: var(--color-border);
}
.btn-ghost {
background: transparent;
border-color: transparent;
}
.btn-ghost:hover { background: var(--color-bg-inset); }
/* Keep ghost transparent even when combined with size variants */
.btn-ghost.btn-xs,
.btn-ghost.btn-sm { background: transparent; border-color: transparent; }
.btn-ghost.btn-xs:hover,
.btn-ghost.btn-sm:hover { background: var(--color-bg-inset); }
.btn-sm { padding: var(--space-1) var(--space-3); font-size: var(--text-xs); }
.btn-block { width: 100%; justify-content: center; }
/* --- Forms --- */
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.form-group label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text);
}
.form-group input:not([type="file"]):not([type="checkbox"]),
.form-group textarea,
.form-group select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
}
.form-group input:not([type="file"]):not([type="checkbox"]):focus,
.form-group textarea:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.form-group input[type="file"] {
font-size: var(--text-sm);
color: var(--color-text);
padding: 0;
border: none;
background: none;
}
.form-group textarea { resize: vertical; min-height: 120px; }
.form-success {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.form-hint {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin: 0;
}
.form-error {
background: var(--color-danger-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.form-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.form-card {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
margin-top: var(--space-4);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
/* --- Auth --- */
.auth-container {
max-width: 400px;
margin: var(--space-12) auto;
}
.auth-container .page-title { margin-bottom: var(--space-6); }
.auth-form { margin-bottom: var(--space-4); }
.auth-footer {
text-align: center;
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: var(--space-4);
}
.passkey-btn { display: none; margin-top: var(--space-3); }
/* --- Page header --- */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
}
.page-title {
font-size: var(--text-2xl);
color: var(--color-text);
margin-bottom: var(--space-4);
}
.page-title a { text-decoration: none; color: var(--color-link); }
.page-title a:hover { text-decoration: underline; }
.section-title {
font-size: var(--text-lg);
margin-bottom: var(--space-4);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--color-border);
}
/* --- Repo header --- */
.repo-header {
margin-bottom: var(--space-2);
}
.repo-title-row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.repo-title-row .page-title { margin-bottom: 0; }
.repo-header-title {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.repo-description {
color: var(--color-text-muted);
margin-top: var(--space-2);
}
.repo-header-meta {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-3);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.icon { font-style: normal; }
/* --- Branch selector --- */
.branch-selector {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.branch-selector-icon {
color: var(--color-text-muted);
font-size: var(--text-sm);
user-select: none;
}
.branch-select {
padding: var(--space-1) var(--space-2);
padding-right: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
font-size: var(--text-sm);
font-family: var(--font-mono);
color: var(--color-text);
cursor: pointer;
appearance: auto;
max-width: 180px;
}
.branch-select:hover { border-color: var(--color-text-muted); }
.branch-select:focus { outline: 2px solid var(--color-accent); outline-offset: -1px; }
/* toolbar row above file tree */
.tree-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
/* commits-header flex row */
.commits-header {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.commits-header .section-title { margin-bottom: 0; }
/* --- Repo nav bar (tabs + private badge) --- */
.repo-nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-6);
gap: var(--space-4);
}
/* --- Repo tabs --- */
.repo-tabs {
display: flex;
gap: 0;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
}
.repo-tab {
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
text-decoration: none;
color: var(--color-text-muted);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.1s;
}
.repo-tab:hover { color: var(--color-text); }
.repo-tab.active {
color: var(--color-text);
font-weight: 600;
border-bottom-color: var(--color-accent);
}
/* Align active indicator with nav-bar border */
.repo-tab { margin-bottom: -1px; }
/* --- Repo list --- */
.repo-list { display: flex; flex-direction: column; gap: var(--space-2); }
.repo-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg);
gap: var(--space-4);
transition: border-color 0.15s;
}
.repo-card:hover { border-color: var(--color-accent); }
.repo-card-main { flex: 1; min-width: 0; }
.repo-card-title {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
margin-bottom: var(--space-1);
}
.repo-name {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-link);
text-decoration: none;
}
.repo-name:hover { text-decoration: underline; }
.repo-card-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
}
.repo-date { color: var(--color-text-muted); }
/* --- Badges --- */
.badge {
display: inline-flex;
align-items: center;
padding: 1px var(--space-2);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
border: 1px solid;
}
.badge-private {
color: var(--color-warning);
border-color: var(--color-warning);
background: var(--color-warning-bg);
}
.issue-badge, .patch-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
text-transform: capitalize;
}
.issue-badge.open, .patch-badge.open {
background: var(--color-success-bg);
color: var(--color-success);
}
.issue-badge.closed, .patch-badge.closed {
background: var(--color-danger-bg);
color: var(--color-danger);
}
.patch-badge.merged {
background: var(--color-merged-bg);
color: var(--color-merged);
}
/* --- Empty state --- */
.empty-state {
text-align: center;
padding: var(--space-16) var(--space-4);
color: var(--color-text-muted);
}
.empty-state p { margin-bottom: var(--space-4); font-size: var(--text-lg); }
/* --- Breadcrumb --- */
.breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.breadcrumb a { color: var(--color-link); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.breadcrumb-sep { color: var(--color-text-muted); }
.breadcrumb-current { color: var(--color-text-muted); }
/* --- File tree --- */
.file-tree {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
border-collapse: collapse;
overflow: hidden;
margin-bottom: var(--space-6);
}
.file-tree-row {
border-bottom: 1px solid var(--color-border-muted);
}
.file-tree-row:last-child { border-bottom: none; }
.file-tree-row:hover { background: var(--color-bg-subtle); }
.file-icon, .file-name, .file-size {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
vertical-align: middle;
}
.file-icon {
width: 36px;
padding-right: var(--space-1);
text-align: center;
line-height: 0;
}
.file-name { width: 100%; }
.file-name a { color: var(--color-link); text-decoration: none; }
.file-name a:hover { text-decoration: underline; }
.file-name a.file-name-dir { font-weight: 500; }
.file-size {
text-align: right;
color: var(--color-text-muted);
font-size: var(--text-xs);
white-space: nowrap;
padding-right: var(--space-4);
}
.file-icon-dir { color: var(--color-link); }
.file-icon-file { color: var(--color-text-muted); }
.tree-icon { display: inline-block; vertical-align: text-bottom; }
.file-tree-row-up .file-name a { color: var(--color-text-muted); }
.file-tree-row-up .file-name a:hover { color: var(--color-link); }
/* --- File blob --- */
.file-blob-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.file-blob-name { font-size: var(--text-sm); font-weight: 500; }
.file-blob-body {
border: 1px solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
overflow: auto;
}
.file-blob-body .shiki-wrapper pre {
margin: 0;
padding: var(--space-4);
font-size: var(--text-sm);
line-height: 1.6;
min-width: max-content;
}
.file-download-notice {
padding: var(--space-8);
text-align: center;
color: var(--color-text-muted);
}
.file-download-notice p { margin-bottom: var(--space-4); }
/* --- Commit list --- */
.commit-list { display: flex; flex-direction: column; }
.commit-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border-muted);
gap: var(--space-4);
flex-wrap: wrap;
}
.commit-item:last-child { border-bottom: none; }
.commit-main { flex: 1; min-width: 0; }
.commit-subject {
color: var(--color-text);
text-decoration: none;
font-size: var(--text-sm);
font-weight: 500;
}
.commit-subject:hover { color: var(--color-link); }
.commit-meta {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
}
.commit-hash {
font-family: var(--font-mono);
color: var(--color-link);
text-decoration: none;
background: var(--color-bg-inset);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
}
.commit-hash:hover { text-decoration: underline; }
.mono { font-family: var(--font-mono); }
/* --- Commit detail viewer --- */
.commit-page-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.commit-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.commit-card-subject {
font-size: var(--text-xl);
font-weight: 600;
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border);
line-height: 1.3;
}
.commit-card-body {
padding: var(--space-4) var(--space-6);
font-family: inherit;
font-size: var(--text-sm);
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.commit-card-meta {
padding: var(--space-4) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-2);
background: var(--color-bg-subtle);
}
.commit-card-meta-row {
display: flex;
align-items: baseline;
gap: var(--space-3);
font-size: var(--text-sm);
flex-wrap: wrap;
}
.commit-meta-label {
font-weight: 600;
color: var(--color-text-muted);
min-width: 5rem;
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.commit-sha-full {
font-size: var(--text-xs);
background: var(--color-bg-inset);
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
word-break: break-all;
}
.commit-stats-bar {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-4);
}
/* --- Commit layout (sidebar + diffs) --- */
.commit-layout {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-5);
align-items: start;
}
.commit-diffs { min-width: 0; }
@media (min-width: 900px) {
.commit-layout { grid-template-columns: 220px 1fr; }
.commit-layout:has(.file-nav-details:not([open])) { grid-template-columns: auto 1fr; }
}
/* --- File nav sidebar --- */
.commit-file-nav {
position: sticky;
top: calc(56px + var(--space-4));
max-height: calc(100vh - 140px);
overflow-y: auto;
}
.file-nav-details {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-subtle);
overflow: hidden;
}
.file-nav-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
list-style: none;
user-select: none;
border-bottom: 1px solid var(--color-border);
}
.file-nav-toggle::-webkit-details-marker { display: none; }
.file-nav-details:not([open]) .file-nav-toggle { border-bottom: none; }
.file-nav-toggle-icon {
font-size: 1em;
transition: transform 0.15s;
}
.file-nav-details:not([open]) .file-nav-toggle-icon { transform: rotate(-90deg); }
.file-nav-list {
padding: var(--space-1) 0;
list-style: none;
}
.file-nav-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
text-decoration: none;
color: var(--color-text);
border-radius: 0;
overflow: hidden;
}
.file-nav-item:hover { background: var(--color-bg-inset); }
.file-nav-status {
flex-shrink: 0;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 9px;
font-weight: 700;
font-family: var(--font-mono);
}
.file-status-added { background: var(--color-success-bg); color: var(--color-success); }
.file-status-deleted { background: var(--color-danger-bg); color: var(--color-danger); }
.file-status-modified { background: var(--color-accent-bg); color: var(--color-accent); }
.file-status-renamed { background: var(--color-merged-bg); color: var(--color-merged); }
.file-status-copied { background: var(--color-merged-bg); color: var(--color-merged); }
.file-nav-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
}
.file-nav-stat {
flex-shrink: 0;
display: flex;
gap: var(--space-1);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.nav-add { color: var(--color-success); }
.nav-del { color: var(--color-danger); }
.file-nav-dir > details > .file-nav-list { padding-left: var(--space-4); }
.file-nav-dir-toggle {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-family: var(--font-mono);
color: var(--color-text-muted);
cursor: pointer;
list-style: none;
user-select: none;
}
.file-nav-dir-toggle::-webkit-details-marker { display: none; }
.file-nav-dir > details:not([open]) .file-nav-toggle-icon { transform: rotate(-90deg); }
/* --- Diff file cards --- */
.diff-file {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.diff-file-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-bg-subtle);
cursor: pointer;
list-style: none;
user-select: none;
flex-wrap: wrap;
}
.diff-file-header::-webkit-details-marker { display: none; }
.diff-file-header-left {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
flex: 1;
}
.diff-file-header-right {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.diff-file-toggle-icon {
font-size: 0.7em;
flex-shrink: 0;
transition: transform 0.15s;
}
.diff-file:not([open]) .diff-file-toggle-icon { transform: rotate(-90deg); }
.diff-status-badge {
flex-shrink: 0;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 700;
font-family: var(--font-mono);
}
.diff-status-added { background: var(--color-success-bg); color: var(--color-success); }
.diff-status-deleted { background: var(--color-danger-bg); color: var(--color-danger); }
.diff-status-modified { background: var(--color-accent-bg); color: var(--color-accent); }
.diff-status-renamed { background: var(--color-merged-bg); color: var(--color-merged); }
.diff-status-copied { background: var(--color-merged-bg); color: var(--color-merged); }
.diff-file-path {
font-size: var(--text-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diff-rename-arrow {
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.diff-stat-add { color: var(--color-success); font-size: var(--text-sm); font-family: var(--font-mono); font-weight: 600; }
.diff-stat-del { color: var(--color-danger); font-size: var(--text-sm); font-family: var(--font-mono); font-weight: 600; }
.btn-xs {
padding: 1px var(--space-2);
font-size: var(--text-xs);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
text-decoration: none;
cursor: pointer;
white-space: nowrap;
font-family: var(--font-mono);
display: inline-flex;
align-items: center;
transition: background 0.1s;
}
.btn-xs:hover { background: var(--color-bg-inset); }
/* --- Diff file body --- */
.diff-file-body { }
.diff-binary-notice {
padding: var(--space-4) var(--space-6);
font-size: var(--text-sm);
color: var(--color-text-muted);
font-style: italic;
}
.diff-hunk { border-top: 1px solid var(--color-border-muted); overflow-x: auto; }
.diff-hunk:first-child { border-top: none; }
.diff-hunk-header {
padding: var(--space-1) var(--space-4);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: normal;
text-align: left;
background: color-mix(in srgb, var(--color-merged-bg) 50%, var(--color-bg));
color: var(--color-merged);
border-bottom: 1px solid var(--color-border-muted);
white-space: pre;
}
/* --- Diff table --- */
.diff-table {
min-width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.5;
}
.diff-ln {
width: 44px;
min-width: 44px;
text-align: right;
padding: 0 var(--space-2);
color: var(--color-text-muted);
border-right: 1px solid var(--color-border-muted);
user-select: none;
vertical-align: top;
white-space: nowrap;
}
.diff-sign {
width: 18px;
min-width: 18px;
text-align: center;
padding: 0 var(--space-1);
user-select: none;
vertical-align: top;
}
.diff-code {
padding: 0 var(--space-3);
white-space: pre;
overflow: visible;
vertical-align: top;
word-break: normal;
}
/* Row type backgrounds */
.diff-row-add { background: color-mix(in srgb, var(--color-success-bg) 70%, transparent); }
.diff-row-del { background: color-mix(in srgb, var(--color-danger-bg) 70%, transparent); }
.diff-row-add .diff-ln,
.diff-row-add .diff-sign { background: color-mix(in srgb, var(--color-success-bg) 90%, transparent); }
.diff-row-del .diff-ln,
.diff-row-del .diff-sign { background: color-mix(in srgb, var(--color-danger-bg) 90%, transparent); }
.diff-row-add .diff-sign { color: var(--color-success); font-weight: 700; }
.diff-row-del .diff-sign { color: var(--color-danger); font-weight: 700; }
/* Shiki dark-mode support in diff code cells */
@media (prefers-color-scheme: dark) {
.diff-code span[style] { color: var(--shiki-dark) !important; }
}
/* Shiki dark-mode support in file blob view */
@media (prefers-color-scheme: dark) {
.shiki-wrapper .shiki { background-color: var(--shiki-dark-bg) !important; color: var(--shiki-dark) !important; }
.shiki-wrapper .shiki span { color: var(--shiki-dark) !important; }
}
/* --- Issue / Patch list --- */
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
gap: var(--space-4);
}
.list-header-tabs { display: flex; gap: var(--space-2); }
.list-tab {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-sm);
text-decoration: none;
color: var(--color-text-muted);
}
.list-tab:hover { background: var(--color-bg-inset); color: var(--color-text); }
.list-tab.active {
background: var(--color-bg-inset);
color: var(--color-text);
font-weight: 600;
}
.issue-list { border: 1px solid var(--color-border); border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
.issue-item {
padding: var(--space-4) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
}
.issue-item:last-child { border-bottom: none; }
.issue-main {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-1);
flex-wrap: wrap;
}
.issue-title {
font-weight: 500;
text-decoration: none;
color: var(--color-text);
flex: 1;
}
.issue-title:hover { color: var(--color-link); }
.issue-meta {
font-size: var(--text-xs);
color: var(--color-text-muted);
display: flex;
gap: var(--space-3);
}
.issue-status-dot, .patch-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.issue-status-dot.open, .patch-status-dot.open { background: var(--color-success); }
.issue-status-dot.closed, .patch-status-dot.closed { background: var(--color-danger); }
.patch-status-dot.merged { background: var(--color-merged); }
/* --- Issue detail / Timeline --- */
.issue-detail-header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-6);
flex-wrap: wrap;
}
.issue-number {
font-size: var(--text-2xl);
font-weight: 600;
color: var(--color-text-muted);
white-space: nowrap;
}
.issue-detail-title {
font-size: var(--text-2xl);
font-weight: 600;
flex: 1;
}
.issue-detail-meta-actions { margin-left: auto; display: flex; gap: var(--space-2); }
.timeline-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.timeline-author {
background: var(--color-bg-subtle);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.timeline-author time { color: var(--color-text-muted); font-size: var(--text-xs); }
.timeline-author-right { margin-left: auto; display: flex; align-items: center; gap: var(--space-2); }
.edited-indicator { color: var(--color-text-muted); font-size: var(--text-xs); cursor: default; }
/* --- Avatars --- */
.avatar {
display: inline-flex;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
vertical-align: middle;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
color: transparent;
}
.avatar-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: var(--color-bg-inset);
color: var(--color-text-muted);
font-weight: 600;
flex-shrink: 0;
vertical-align: middle;
}
.avatar-settings {
display: flex;
align-items: flex-start;
gap: var(--space-5);
}
.avatar-actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.nav-user {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.timeline-body { padding: var(--space-4); }
.timeline-item-new { padding: var(--space-4); }
.timeline-item-new .section-title { font-size: var(--text-base); border: none; padding-bottom: 0; }
/* Edit <details> (summary only) in .timeline-author; form is a sibling shown via :has() */
.timeline-author-right .inline-edit-details { border: none; }
.timeline-author-right .inline-edit-details > summary { padding: 0; list-style: none; }
.timeline-author-right .inline-edit-details > summary::-webkit-details-marker { display: none; }
.inline-edit-details .when-open { display: none; }
.inline-edit-details[open] .when-closed { display: none; }
.inline-edit-details[open] .when-open { display: inline; }
.inline-edit-form-area { display: none; border-top: 1px solid var(--color-border-muted); }
.timeline-item:has(.inline-edit-details[open]) .inline-edit-form-area { display: block; }
.inline-edit-form { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
/* --- Reactions --- */
.reaction-bar {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-top: 1px solid var(--color-border-muted);
}
.reaction-form { display: inline; }
.reaction-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background: var(--color-bg-subtle);
color: var(--color-text);
font-size: var(--text-sm);
cursor: pointer;
transition: background 0.1s;
}
.reaction-btn:hover { background: var(--color-bg-inset); }
.reaction-btn.reacted {
background: var(--color-accent-bg);
border-color: var(--color-accent);
}
.reaction-picker {
position: relative;
}
.reaction-add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px dashed var(--color-border);
border-radius: var(--radius-full);
background: transparent;
cursor: pointer;
font-size: var(--text-base);
color: var(--color-text-muted);
list-style: none;
}
.reaction-add-btn::-webkit-details-marker { display: none; }
.reaction-picker-dropdown {
position: absolute;
bottom: calc(100% + var(--space-2));
left: 0;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-2);
display: flex;
gap: var(--space-1);
z-index: 10;
}
.reaction-picker-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: var(--space-1);
border-radius: var(--radius-sm);
}
.reaction-picker-btn:hover { background: var(--color-bg-inset); }
/* --- Patch subview tabs --- */
.patch-tab-nav {
display: flex;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-6);
}
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4em;
padding: 0 var(--space-1);
margin-left: var(--space-1);
font-size: var(--text-xs);
font-weight: 500;
background: var(--color-bg-inset);
border-radius: 999px;
color: var(--color-text-muted);
}
/* --- Patch apply status --- */
.apply-status {
margin: var(--space-4) 0;
}
.apply-result {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
flex-direction: column;
}
.apply-result > div { display: flex; gap: var(--space-2); align-items: center; }
.apply-clean {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.apply-conflict {
background: var(--color-danger-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger);
}
.apply-checking, .apply-unknown {
color: var(--color-text-muted);
font-style: italic;
font-size: var(--text-sm);
}
.apply-output {
font-family: var(--font-mono);
font-size: var(--text-xs);
white-space: pre-wrap;
margin-top: var(--space-2);
padding: var(--space-2);
background: rgba(0,0,0,0.05);
border-radius: var(--radius-sm);
width: 100%;
}
.patch-actions {
display: flex;
gap: var(--space-3);
margin: var(--space-4) 0;
flex-wrap: wrap;
}
.patch-card-title-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
justify-content: space-between;
margin-bottom: var(--space-3);
}
.patch-card-title-row .commit-card-subject { margin-bottom: 0; }
.patch-card-description {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-muted);
}
/* --- Repo home layout --- */
.repo-home-layout {
display: grid;
gap: var(--space-8);
}
@media (min-width: 768px) {
.repo-home-layout {
grid-template-columns: 1fr 1fr;
}
}
.readme-section {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.readme-header {
background: var(--color-bg-subtle);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: 500;
border-bottom: 1px solid var(--color-border);
}
.readme-section .markdown-body {
padding: var(--space-4);
}
/* --- Code setup block --- */
.code-setup {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
font-size: var(--text-sm);
margin-top: var(--space-4);
overflow: auto;
text-align: left;
}
.code-plain {
background: var(--color-bg-subtle);
padding: var(--space-4);
overflow: auto;
font-size: var(--text-sm);
line-height: 1.6;
}
/* --- Markdown body --- */
.markdown-body {
font-size: var(--text-sm);
line-height: 1.6;
color: var(--color-text);
}
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
margin-top: var(--space-6);
margin-bottom: var(--space-3);
}
.markdown-body p { margin-bottom: var(--space-4); }
.markdown-body a { color: var(--color-link); }
.markdown-body code {
background: var(--color-bg-inset);
padding: 0.1em 0.3em;
border-radius: var(--radius-sm);
font-size: 0.9em;
}
.markdown-body pre {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
overflow: auto;
margin-bottom: var(--space-4);
}
.markdown-body pre code { background: none; padding: 0; }
.markdown-body ul, .markdown-body ol {
padding-left: var(--space-6);
margin-bottom: var(--space-4);
}
.markdown-body ul { list-style: disc; }
.markdown-body ol { list-style: decimal; }
.markdown-body li { margin-bottom: var(--space-1); }
.markdown-body blockquote {
border-left: 3px solid var(--color-border);
padding-left: var(--space-4);
color: var(--color-text-muted);
margin: var(--space-4) 0;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--space-4);
}
.markdown-body th, .markdown-body td {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
}
.markdown-body th { background: var(--color-bg-subtle); font-weight: 600; }
.markdown-body hr {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--space-6) 0;
}
.markdown-body img { max-width: 100%; border-radius: var(--radius-md); }
.markdown-body details { margin-bottom: var(--space-4); }
.markdown-body summary { cursor: pointer; font-weight: 500; }
/* --- Search form --- */
.search-form { margin-bottom: var(--space-5); }
.search-input-wrap {
display: flex;
gap: var(--space-2);
max-width: 480px;
}
.search-input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
}
.search-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
/* --- Issue close bar --- */
.issue-close-bar {
display: flex;
justify-content: flex-end;
padding: var(--space-3) 0;
border-top: 1px solid var(--color-border-muted);
margin-top: var(--space-2);
}
/* --- Auth divider --- */
.auth-divider {
display: flex;
align-items: center;
gap: var(--space-3);
margin: var(--space-4) 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.auth-divider::before,
.auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border);
}
/* --- Passkey UI in settings --- */
.passkey-actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
align-items: flex-start;
margin-top: var(--space-3);
}
.passkey-status { font-size: var(--text-sm); }
/* --- Danger zone --- */
.danger-zone { margin-top: var(--space-8); }
.danger-title { color: var(--color-danger); border-bottom-color: var(--color-danger); }
.danger-card {
border-color: var(--color-danger);
background: var(--color-danger-bg);
}
.danger-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.danger-item p { margin: var(--space-1) 0 0; }
.btn-danger {
background: var(--color-danger);
border-color: transparent;
color: #fff;
}
.btn-danger:hover { filter: brightness(0.9); }
/* --- Release list items --- */
.release-item-header {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
.release-item-main { flex: 1; min-width: 0; }
.release-item-title {
font-size: var(--text-lg);
font-weight: 700;
text-decoration: none;
color: var(--color-text);
display: block;
margin-bottom: var(--space-1);
}
.release-item-title:hover { color: var(--color-link); }
.release-item-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.release-item-date {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
/* --- Release notes preview in list --- */
.release-notes-section { margin-top: var(--space-3); }
.release-notes-label {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-2);
}
.notes-expand > summary { list-style: none; cursor: pointer; outline: none; }
.notes-expand > summary::-webkit-details-marker { display: none; }
.notes-preview {
max-height: calc(5 * 1.6em);
overflow: hidden;
position: relative;
white-space: pre-wrap;
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
word-break: break-word;
margin-bottom: var(--space-1);
}
.notes-preview::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2.5em;
background: linear-gradient(transparent, var(--color-bg));
pointer-events: none;
}
.notes-expand[open] .notes-preview { display: none; }
.notes-expand[open] { display: flex; flex-direction: column; }
.notes-expand[open] > summary { order: 1; }
.notes-expand[open] > .notes-full { order: 0; }
.notes-toggle-label {
display: inline-block;
font-size: var(--text-xs);
color: var(--color-link);
margin-top: var(--space-1);
}
.notes-toggle-label:hover { text-decoration: underline; }
.notes-toggle-label::before { content: "Show more ↓"; }
.notes-expand[open] .notes-toggle-label::before { content: "Show less ↑"; }
.notes-full { padding-top: var(--space-3); padding-bottom: var(--space-1); }
/* --- Release detail --- */
.release-detail { padding: var(--space-6) 0; }
.release-header { margin-bottom: var(--space-6); }
.release-notes { margin-bottom: var(--space-6); }
.release-assets { margin-bottom: var(--space-6); }
.release-assets-heading {
font-size: var(--text-base);
font-weight: 600;
margin-bottom: var(--space-3);
}
.asset-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.asset-item {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
font-size: var(--text-sm);
}
.asset-item:last-child { border-bottom: none; }
.asset-name {
flex: 1;
color: var(--color-link);
text-decoration: none;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.asset-name:hover { text-decoration: underline; }
.asset-name-pending {
color: var(--color-text-muted);
cursor: default;
}
.asset-item-pending { opacity: 0.6; }
.asset-size, .asset-meta {
color: var(--color-text-muted);
font-size: var(--text-xs);
white-space: nowrap;
}
.release-back { margin-top: var(--space-4); display: flex; gap: var(--space-3); align-items: center; justify-content: space-between; }
}
/* ============================================================
@layer utilities
============================================================ */
@layer utilities {
.text-muted { color: var(--color-text-muted); }
.text-sm { font-size: var(--text-sm); }
.text-xs { font-size: var(--text-xs); }
.font-mono { font-family: var(--font-mono); }
.mt-1 { margin-top: var(--space-1); }
.mt-2 { margin-top: var(--space-2); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
.mb-4 { margin-bottom: var(--space-4); }
.flex { display: flex; }
.gap-2 { gap: var(--space-2); }
.gap-4 { gap: var(--space-4); }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.hidden { display: none; }
}
/* ============================================================
Pagination
============================================================ */
.commit-cursor-nav {
display: flex;
justify-content: space-between;
border-top: 1px solid var(--color-border);
margin-top: var(--space-2);
padding: var(--space-4) 0 var(--space-2);
}
.commit-cursor-prev { flex: 1; }
.commit-cursor-next { flex: 1; text-align: right; }
.pagination {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
border-top: 1px solid var(--color-border);
margin-top: var(--space-2);
padding: var(--space-4) 0 var(--space-2);
}
.pagination-prev { justify-self: start; }
.pagination-next { justify-self: end; }
.pagination-info {
justify-self: center;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.pagination-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
color: var(--color-text);
background: var(--color-bg-subtle);
transition: background 0.1s, border-color 0.1s;
white-space: nowrap;
}
.pagination-btn:hover {
background: var(--color-bg-inset);
border-color: var(--color-border);
color: var(--color-text);
text-decoration: none;
}
/* Settings */
.theme-options { display: flex; gap: var(--space-4); margin-bottom: var(--space-4); flex-wrap: wrap; }
.theme-option { display: flex; align-items: center; gap: var(--space-2); cursor: pointer; font-size: var(--text-sm); }
.settings-form { display: flex; flex-direction: column; gap: var(--space-4); }
.passkey-list { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-4); }
.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); }
.passkey-item:last-child { border-bottom: none; }
.passkey-date { flex: 1; color: var(--color-text-muted); }
.ssh-key-info { flex: 1; display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.ssh-key-name { font-weight: 500; }
.ssh-key-fingerprint { font-family: var(--font-mono); font-size: var(--text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.form-textarea { resize: vertical; min-height: 4.5rem; font-family: var(--font-mono); font-size: var(--text-sm); }
/* Clone popup */
.clone-popup-details { position: relative; display: inline-block; }
.clone-popup-details > summary { list-style: none; }
.clone-popup-details > summary::-webkit-details-marker { display: none; }
.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); }
.clone-url-row { display: flex; align-items: center; gap: var(--space-2); }
.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; }
.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; }
.user-list { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-6); }
.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); }
.user-item:last-child { border-bottom: none; }
.user-name { font-weight: 500; flex: 1; }
.user-date { color: var(--color-text-muted); font-size: var(--text-xs); }
▾Apublic/assets/theme.js
@@ -0,0 +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 @@
import path from "node:path";
//env configs
export const OWNER_DISPLAY_NAME = process.env.OWNER_DISPLAY_NAME ?? "Admin";
export const INLINE_MAX_BYTES =
parseInt(process.env.INLINE_MAX_BYTES ?? "", 10) || 524288;
export const MAX_UPLOAD_BYTES =
parseInt(process.env.MAX_UPLOAD_BYTES ?? "", 10) || 10 * 1024 * 1024;
export const MAX_USER_UPLOAD_BYTES =
parseInt(process.env.MAX_USER_UPLOAD_BYTES ?? "", 10) || 2 * 1024 * 1024;
export const TRUSTED_PROXY = !!process.env.TRUSTED_PROXY;
export const RATE_LIMIT_DISABLED = !!process.env.RATE_LIMIT_DISABLED;
export const SSH_DISABLED = !!process.env.SSH_DISABLED;
export const PORT = parseInt(process.env.PORT ?? "", 10) || 3000;
export const SSH_PORT = parseInt(process.env.SSH_PORT ?? "", 10) || 2222;
export const REGISTRATION_DISABLED = !!process.env.REGISTRATION_DISABLED;
export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data");
export const HIGHLIGHT_WORKERS =
parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4;
▾Asrc/constants.ts
@@ -0,0 +1,47 @@
import path from "node:path";
import { DATA_DIR, OWNER_DISPLAY_NAME } from "./config.ts";
// Auth / identity
export const WEBAUTHN_RP_NAME = `${OWNER_DISPLAY_NAME}'s Hearthforge`;
export const ADMIN_USERNAME = "admin";
export const VALID_USERNAME_RE = /^[a-zA-Z0-9_-]+$/;
export const VALID_REPO_NAME_RE = /^[a-zA-Z0-9._-]+$/;
export const ALLOWED_REACTIONS = new Set([
"👍",
"👎",
"❤️",
"🎉",
"😕",
"👀",
"🚀",
]);
export const VALID_KEY_TYPES = new Set([
"ssh-rsa",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com",
]);
export const CHALLENGE_TTL_MS = 5 * 60 * 1000;
// Pagination
export const REPOS_PER_PAGE = 20;
export const COMMITS_PER_PAGE = 30;
export const ISSUES_PER_PAGE = 20;
export const PATCHES_PER_PAGE = 20;
export const RELEASES_PER_PAGE = 20;
// Cache sizes
export const MAX_MD_CACHE = 50;
export const MAX_FILE_CACHE = 500;
export const MAX_DIFF_CACHE = 500;
export const MAX_PATCH_CACHE = 100;
// Paths
export const DB_PATH = path.join(DATA_DIR, "hearthforge.db");
export const REPOS_DIR = path.join(DATA_DIR, "repos");
export const AVATARS_DIR = path.join(DATA_DIR, "avatars");
export const RELEASES_DIR = path.join(DATA_DIR, "releases");
export const SSH_HOST_KEY_PATH = path.join(DATA_DIR, "ssh_host_key");
▾Asrc/db/index.ts
@@ -0,0 +1,180 @@
import { Database as BunDatabase } from "bun:sqlite";
import { type Generated, Kysely, type Selectable } from "kysely";
import { BunSqliteDialect } from "kysely-bun-sqlite";
import { DB_PATH } from "../constants.ts";
interface UserTable {
id: Generated<number>;
username: string;
password_hash: string | null;
created_at: string;
avatar_version: Generated<number>;
}
interface PasskeyTable {
id: Generated<number>;
user_id: number;
credential_id: string;
public_key: string;
counter: number;
created_at: string;
}
interface SessionTable {
id: string;
user_id: number;
expires_at: string;
created_at: string;
}
interface RepositoryTable {
id: Generated<number>;
name: string;
description: string | null;
is_private: number;
default_branch: string;
created_at: string;
issue_seq: Generated<number>;
patch_seq: Generated<number>;
}
interface IssueTable {
id: Generated<number>;
repo_id: number;
author_id: number | null;
number: number;
title: string;
body: string;
status: string;
created_at: string;
updated_at: string;
edited_at: string | null;
}
interface IssueCommentTable {
id: Generated<number>;
issue_id: number;
author_id: number | null;
body: string;
created_at: string;
edited_at: string | null;
}
interface IssueReactionTable {
id: Generated<number>;
issue_id: number;
comment_id: number | null;
user_id: number;
emoji: string;
}
interface PatchTable {
id: Generated<number>;
repo_id: number;
author_id: number | null;
number: number;
title: string;
description: string;
patch_content: string;
status: string;
created_at: string;
updated_at: string;
edited_at: string | null;
}
interface PatchCommentTable {
id: Generated<number>;
patch_id: number;
author_id: number | null;
body: string;
created_at: string;
edited_at: string | null;
}
interface PatchReactionTable {
id: Generated<number>;
patch_id: number;
comment_id: number | null;
user_id: number;
emoji: string;
}
interface SshKeyTable {
id: Generated<number>;
user_id: number;
name: string;
public_key: string;
fingerprint: string;
created_at: string;
}
interface ReleaseTable {
id: Generated<number>;
repo_id: number;
tag_name: string;
name: string | null;
notes: string | null;
commit_hash: string;
include_source_code: number;
created_at: string;
}
interface ReleaseAssetTable {
id: Generated<number>;
release_id: number;
filename: string;
size: number;
content_type: string;
created_at: string;
}
export interface Database {
users: UserTable;
passkeys: PasskeyTable;
sessions: SessionTable;
repositories: RepositoryTable;
issues: IssueTable;
issue_comments: IssueCommentTable;
issue_reactions: IssueReactionTable;
patches: PatchTable;
patch_comments: PatchCommentTable;
patch_reactions: PatchReactionTable;
ssh_keys: SshKeyTable;
releases: ReleaseTable;
release_assets: ReleaseAssetTable;
}
// Selectable row types (id is plain number, as returned by queries)
export type UserRow = Selectable<UserTable>;
export type PasskeyRow = Selectable<PasskeyTable>;
export type SessionRow = Selectable<SessionTable>;
export type RepositoryRow = Selectable<RepositoryTable>;
export type IssueRow = Selectable<IssueTable>;
export type IssueCommentRow = Selectable<IssueCommentTable>;
export type IssueReactionRow = Selectable<IssueReactionTable>;
export type PatchRow = Selectable<PatchTable>;
export type PatchCommentRow = Selectable<PatchCommentTable>;
export type PatchReactionRow = Selectable<PatchReactionTable>;
export type SshKeyRow = Selectable<SshKeyTable>;
export type ReleaseRow = Selectable<ReleaseTable>;
export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
const sqlite = new BunDatabase(DB_PATH);
sqlite.run("PRAGMA journal_mode=WAL");
sqlite.run("PRAGMA foreign_keys=ON");
export const db = new Kysely<Database>({
dialect: new BunSqliteDialect({ database: sqlite }),
});
export async function getRepo(name: string, isAdmin: boolean) {
const repo = await db
.selectFrom("repositories")
.selectAll()
.where("name", "=", name)
.executeTakeFirst();
if (!repo) return null;
if (repo.is_private && !isAdmin) return null;
return repo;
}
▾Asrc/db/init.ts
@@ -0,0 +1,51 @@
import { Database } from "bun:sqlite";
import { mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import * as argon2 from "argon2";
import { DATA_DIR } from "../config.ts";
import {
ADMIN_USERNAME,
AVATARS_DIR,
DB_PATH,
REPOS_DIR,
} from "../constants.ts";
// Ensure data directories exist
mkdirSync(DATA_DIR, { recursive: true });
mkdirSync(REPOS_DIR, { recursive: true });
mkdirSync(AVATARS_DIR, { recursive: true });
const db = new Database(DB_PATH);
db.run("PRAGMA journal_mode=WAL");
db.run("PRAGMA foreign_keys=ON");
// Run schema
const schema = readFileSync(path.join(import.meta.dir, "schema.sql"), "utf-8");
db.run(schema);
// Seed admin account if not present
const existing = db
.query("SELECT id FROM users WHERE username = ?")
.get(ADMIN_USERNAME);
if (!existing) {
const password = process.env.ADMIN_PASSWORD ?? "changeme";
const hash = await argon2.hash(password);
const now = new Date().toISOString();
db.run(
"INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)",
[ADMIN_USERNAME, hash, now],
);
console.log(
`Created admin account (username: ${ADMIN_USERNAME}, password: ${password})`,
);
if (password === "changeme") {
console.warn(
"WARNING: Using default admin password. Set ADMIN_PASSWORD env var before running db:init.",
);
}
} else {
console.log("Admin account already exists.");
}
console.log("Database initialized at", DB_PATH);
db.close();
▾Asrc/db/schema.sql
@@ -0,0 +1,129 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT,
created_at TEXT NOT NULL,
avatar_version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
is_private INTEGER NOT NULL DEFAULT 0,
default_branch TEXT NOT NULL DEFAULT 'main',
created_at TEXT NOT NULL,
issue_seq INTEGER NOT NULL DEFAULT 0,
patch_seq INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
number INTEGER NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
edited_at TEXT,
UNIQUE(repo_id, number)
);
CREATE TABLE IF NOT EXISTS issue_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
edited_at TEXT
);
CREATE TABLE IF NOT EXISTS issue_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
comment_id INTEGER REFERENCES issue_comments(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
UNIQUE(issue_id, comment_id, user_id)
);
CREATE TABLE IF NOT EXISTS patches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
number INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
patch_content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
edited_at TEXT,
UNIQUE(repo_id, number)
);
CREATE TABLE IF NOT EXISTS patch_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
edited_at TEXT
);
CREATE TABLE IF NOT EXISTS patch_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
comment_id INTEGER REFERENCES patch_comments(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
UNIQUE(patch_id, comment_id, user_id)
);
CREATE TABLE IF NOT EXISTS ssh_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
fingerprint TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS releases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
tag_name TEXT NOT NULL,
name TEXT,
notes TEXT,
commit_hash TEXT NOT NULL,
include_source_code INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE(repo_id, tag_name)
);
CREATE TABLE IF NOT EXISTS release_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
content_type TEXT NOT NULL,
created_at TEXT NOT NULL
);
▾Asrc/db/seed.ts
@@ -0,0 +1,256 @@
/**
* Seed script — populates the live database with enough data to test pagination.
* Run with: bun run db:seed
*
* Safe to run multiple times — skips existing items.
*/
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import path from "node:path";
import * as argon2 from "argon2";
import { ADMIN_USERNAME, DB_PATH, REPOS_DIR } from "../constants.ts";
const db = new Database(DB_PATH);
db.run("PRAGMA journal_mode=WAL");
db.run("PRAGMA foreign_keys=ON");
// ── Resolve admin user ────────────────────────────────────────────────────────
const adminRow = db
.query<{ id: number }, [string]>("SELECT id FROM users WHERE username = ?")
.get(ADMIN_USERNAME);
if (!adminRow) {
console.error("Admin user not found. Run `bun run db:init` first.");
process.exit(1);
}
const adminId = adminRow.id;
// Ensure a second user "alice" exists for variety
let aliceId: number;
const aliceRow = db
.query<{ id: number }, [string]>("SELECT id FROM users WHERE username = ?")
.get("alice");
if (aliceRow) {
aliceId = aliceRow.id;
} else {
const hash = await argon2.hash("password123");
const now = new Date().toISOString();
db.run(
"INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)",
["alice", hash, now],
);
aliceId = db
.query<{ id: number }, [string]>(
"SELECT id FROM users WHERE username = ?",
)
.get("alice")!.id;
console.log("Created user alice (password: password123)");
}
// ── Helper ────────────────────────────────────────────────────────────────────
function getOrCreateRepo(name: string, description: string): number {
const existing = db
.query<{ id: number }, [string]>(
"SELECT id FROM repositories WHERE name = ?",
)
.get(name);
if (existing) return existing.id;
const now = new Date().toISOString();
db.run(
"INSERT INTO repositories (name, description, is_private, default_branch, created_at) VALUES (?, ?, 0, 'main', ?)",
[name, description, now],
);
const repoPath = path.join(REPOS_DIR, `${name}.git`);
mkdirSync(repoPath, { recursive: true });
// init bare repo (sync-ish via Bun.spawnSync)
Bun.spawnSync(["git", "init", "--bare", "--initial-branch=main", repoPath]);
console.log(`Created repo: ${name}`);
return db
.query<{ id: number }, [string]>(
"SELECT id FROM repositories WHERE name = ?",
)
.get(name)!.id;
}
// ── 25 extra repositories (for repo list pagination) ─────────────────────────
const topics = [
"A web framework",
"CLI toolkit",
"Database driver",
"Auth library",
"Test runner",
"Build system",
"Linter plugin",
"ORM layer",
"Cache client",
"Queue worker",
"API gateway",
"Graph engine",
"ML utilities",
"Crypto helpers",
"File watcher",
"Schema validator",
"Logger library",
"Rate limiter",
"Metrics exporter",
"Job scheduler",
"Config manager",
"Template engine",
"Markdown parser",
"Image resizer",
"Email sender",
];
for (let i = 1; i <= 25; i++) {
const n = String(i).padStart(2, "0");
getOrCreateRepo(`seed-repo-${n}`, topics[i - 1]!);
}
// ── "demo" repo — seed issues and patches ────────────────────────────────────
const demoRepoId = getOrCreateRepo(
"demo",
"Demo repository for pagination testing",
);
// Seed 35 issues (mix of open and closed)
const issueCount =
db
.query<{ n: number }, [number]>(
"SELECT COUNT(*) as n FROM issues WHERE repo_id = ?",
)
.get(demoRepoId)?.n ?? 0;
if (issueCount < 35) {
const start = issueCount + 1;
const issueTitles = [
"Fix null pointer dereference in parser",
"Add dark mode support",
"Improve error messages",
"Upgrade dependencies to latest",
"Memory leak in connection pool",
"Race condition in event loop",
"Add pagination to commit log",
"Slow query on large datasets",
"Missing CORS headers",
"Typo in README",
"Refactor authentication middleware",
"Support IPv6 addresses",
"Add unit tests for utils",
"Broken link in documentation",
"Config file not loaded on Windows",
"Infinite loop when input is empty",
"Add rate limiting",
"Log rotation broken",
"Session cookie not cleared on logout",
"Crash on malformed JSON input",
"Add export to CSV feature",
"Support custom themes",
"API returns 500 on edge case",
"Update license to MIT",
"Improve startup time",
"Handle timeout errors gracefully",
"Add health check endpoint",
"Support environment variables in config",
"Fix XSS in search",
"Stale cache after update",
"Add OpenAPI spec",
"Sort order wrong in list view",
"Binary file detection false positive",
"Column alignment off in table view",
"Wrong timezone in timestamps",
];
for (let i = start; i <= 35; i++) {
const titleIndex = (i - 1) % issueTitles.length;
const status = i <= 25 ? "open" : "closed";
const authorId = i % 3 === 0 ? aliceId : adminId;
const now = new Date(Date.now() - (35 - i) * 3600_000).toISOString();
db.run(
"INSERT OR IGNORE INTO issues (repo_id, author_id, number, title, body, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[
demoRepoId,
authorId,
i,
issueTitles[titleIndex]!,
`Details for issue #${i}.`,
status,
now,
now,
],
);
}
console.log(`Seeded issues up to #35 in demo repo`);
}
// Seed 25 patches
const patchCount =
db
.query<{ n: number }, [number]>(
"SELECT COUNT(*) as n FROM patches WHERE repo_id = ?",
)
.get(demoRepoId)?.n ?? 0;
if (patchCount < 25) {
const start = patchCount + 1;
const SAMPLE_PATCH = [
"diff --git a/placeholder.txt b/placeholder.txt",
"new file mode 100644",
"index 0000000..e69de29",
"--- /dev/null",
"+++ b/placeholder.txt",
"@@ -0,0 +1 @@",
"+placeholder",
"",
].join("\n");
const patchTitles = [
"Fix null deref",
"Add dark mode",
"Improve errors",
"Upgrade deps",
"Fix memory leak",
"Fix race condition",
"Add pagination",
"Optimise query",
"Add CORS headers",
"Fix typo",
"Refactor auth",
"Support IPv6",
"Add unit tests",
"Fix broken link",
"Fix Windows config",
"Fix infinite loop",
"Add rate limiting",
"Fix log rotation",
"Fix logout cookie",
"Handle bad JSON",
"Add CSV export",
"Custom themes",
"Fix 500 error",
"Update license",
"Improve startup",
];
for (let i = start; i <= 25; i++) {
const titleIndex = (i - 1) % patchTitles.length;
const status = i <= 20 ? "open" : "closed";
const authorId = i % 3 === 0 ? aliceId : adminId;
const now = new Date(Date.now() - (25 - i) * 3600_000).toISOString();
db.run(
"INSERT OR IGNORE INTO patches (repo_id, author_id, number, title, description, patch_content, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
demoRepoId,
authorId,
i,
patchTitles[titleIndex]!,
`Description for patch #${i}.`,
SAMPLE_PATCH,
status,
now,
now,
],
);
}
console.log(`Seeded patches up to #25 in demo repo`);
}
db.close();
console.log("Seed complete.");
▾Asrc/index.tsx
@@ -0,0 +1,44 @@
import path from "node:path";
import { staticPlugin } from "@elysiajs/static";
import { Elysia } from "elysia";
import { MAX_UPLOAD_BYTES, PORT, SSH_DISABLED } from "./config.ts";
import { authRoutes } from "./routes/auth.tsx";
import { avatarRoutes } from "./routes/avatars.ts";
import { gitRoutes } from "./routes/git.ts";
import { issueRoutes } from "./routes/issues.tsx";
import { patchRoutes } from "./routes/patches.tsx";
import { releasesRoutes } from "./routes/releases.tsx";
import { repoRoutes } from "./routes/repos.tsx";
import { settingsRoutes } from "./routes/settings.tsx";
import { syncStartup } from "./services/repoSync.ts";
import { startSshServer } from "./services/sshServer.ts";
for (const cmd of ["git", "ssh-keygen"]) {
const result = Bun.spawnSync(["which", cmd]);
if (result.exitCode !== 0) {
console.error(`Missing required command: ${cmd}`);
process.exit(1);
}
}
await syncStartup();
if (!SSH_DISABLED) await startSshServer();
const _app = new Elysia({ serve: { maxRequestBodySize: MAX_UPLOAD_BYTES } })
.use(
staticPlugin({
assets: path.resolve("./public"),
prefix: "/",
}),
)
.use(gitRoutes)
.use(settingsRoutes)
.use(authRoutes)
.use(repoRoutes)
.use(issueRoutes)
.use(patchRoutes)
.use(releasesRoutes)
.use(avatarRoutes)
.listen(PORT);
console.log(`Hearthforge running at http://localhost:${PORT}`);
▾Asrc/lib/rateLimiter.ts
@@ -0,0 +1,38 @@
import { RATE_LIMIT_DISABLED, TRUSTED_PROXY } from "../config.ts";
interface Bucket {
count: number;
resetAt: number;
}
const buckets = new Map<string, Bucket>();
export function checkRateLimit(
ip: string | null,
maxRequests: number,
windowMs: number,
): boolean {
if (RATE_LIMIT_DISABLED || !ip) return true;
const now = Date.now();
const bucket = buckets.get(ip);
if (!bucket || now > bucket.resetAt) {
buckets.set(ip, { count: 1, resetAt: now + windowMs });
return true;
}
if (bucket.count >= maxRequests) return false;
bucket.count++;
return true;
}
export function getClientIp(
request: Request,
server: Bun.Server<unknown> | null,
): string | null {
if (TRUSTED_PROXY) {
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
null
);
}
return server?.requestIP(request)?.address ?? null;
}
▾Asrc/lib/redirect.ts
@@ -0,0 +1,5 @@
export function redirect(location: string, setCookie?: string): Response {
const headers: Record<string, string> = { Location: location };
if (setCookie) headers["Set-Cookie"] = setCookie;
return new Response(null, { status: 302, headers });
}
▾Asrc/lib/users.ts
@@ -0,0 +1,8 @@
import { OWNER_DISPLAY_NAME } from "../config.ts";
import { ADMIN_USERNAME } from "../constants.ts";
/** Show OWNER_DISPLAY_NAME for the admin account, [Deleted user] for null, otherwise the raw username. */
export function displayName(username: string | null | undefined): string {
if (!username) return "[Deleted user]";
return username === ADMIN_USERNAME ? OWNER_DISPLAY_NAME : username;
}
▾Asrc/middleware/session.ts
@@ -0,0 +1,61 @@
import { ADMIN_USERNAME } from "../constants.ts";
import { db } from "../db";
export interface SessionUser {
id: number;
username: string;
isAdmin: boolean;
avatar_version: number;
}
export async function resolveSession(
cookie: string | undefined,
): Promise<SessionUser | null> {
if (!cookie) return null;
const now = new Date().toISOString();
const session = await db
.selectFrom("sessions")
.innerJoin("users", "users.id", "sessions.user_id")
.select([
"users.id",
"users.username",
"users.avatar_version",
"sessions.expires_at",
])
.where("sessions.id", "=", cookie)
.where("sessions.expires_at", ">", now)
.executeTakeFirst();
if (!session) return null;
return {
id: session.id,
username: session.username,
isAdmin: session.username === ADMIN_USERNAME,
avatar_version: session.avatar_version,
};
}
export function requireAuth(user: SessionUser | null): Response | null {
if (!user) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
return null;
}
export function requireAdmin(user: SessionUser | null): Response | null {
if (!user) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
if (!user.isAdmin) {
return new Response("Forbidden", {
status: 403,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
return null;
}
▾Asrc/routes/auth.tsx
@@ -0,0 +1,513 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import * as argon2 from "argon2";
import { Elysia, t } from "elysia";
import { REGISTRATION_DISABLED } from "../config.ts";
import {
ADMIN_USERNAME,
CHALLENGE_TTL_MS,
VALID_USERNAME_RE,
WEBAUTHN_RP_NAME,
} from "../constants.ts";
import { db } from "../db/index.ts";
import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts";
import { redirect } from "../lib/redirect.ts";
import { resolveSession } from "../middleware/session.ts";
import { Login } from "../views/auth/Login.tsx";
import { Register } from "../views/auth/Register.tsx";
import { html } from "../views/render.tsx";
function rpFromRequest(request: Request): { origin: string; rpId: string } {
const origin = request.headers.get("origin");
if (!origin) throw new Error("Missing Origin header");
return { origin, rpId: new URL(origin).hostname };
}
function randomHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function createSession(userId: number): Promise<string> {
const id = randomHex(32);
const now = new Date();
const expires = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
await db
.insertInto("sessions")
.values({
id,
user_id: userId,
expires_at: expires.toISOString(),
created_at: now.toISOString(),
})
.execute();
return id;
}
function sessionCookie(id: string): string {
return `session=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 60 * 60}`;
}
function clearCookie(): string {
return "session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
}
// In-memory challenge store (fine for single-process)
type ChallengeEntry = {
challenge: string;
timeoutId: ReturnType<typeof setTimeout>;
};
const pendingChallenges = new Map<string, ChallengeEntry>();
function setChallenge(key: string, challenge: string): void {
const existing = pendingChallenges.get(key);
if (existing) clearTimeout(existing.timeoutId);
const timeoutId = setTimeout(
() => pendingChallenges.delete(key),
CHALLENGE_TTL_MS,
);
pendingChallenges.set(key, { challenge, timeoutId });
}
function deleteChallenge(key: string): void {
const entry = pendingChallenges.get(key);
if (entry) clearTimeout(entry.timeoutId);
pendingChallenges.delete(key);
}
export const authRoutes = new Elysia()
.guard({
cookie: t.Cookie({ session: t.Optional(t.String()) }),
})
.get("/login", () => {
return html(<Login />);
})
.post(
"/login",
async ({ body, request, server }) => {
const ip = getClientIp(request, server);
if (!checkRateLimit(ip, 10, 60_000)) {
return html(
<Login error="Too many login attempts. Please try again later." />,
);
}
const { username, password } = body;
const user = await db
.selectFrom("users")
.selectAll()
.where("username", "=", username)
.executeTakeFirst();
if (!user || !user.password_hash) {
return html(<Login error="Invalid username or password" />);
}
const valid = await argon2.verify(user.password_hash, password);
if (!valid) {
return html(<Login error="Invalid username or password" />);
}
const sessionId = await createSession(user.id);
return redirect("/", sessionCookie(sessionId));
},
{
body: t.Object({ username: t.String(), password: t.String() }),
},
)
.get("/register", () => {
if (REGISTRATION_DISABLED)
return new Response("Registration is disabled", { status: 403 });
return html(<Register />);
})
.post(
"/register",
async ({ body, request, server }) => {
if (REGISTRATION_DISABLED)
return new Response("Registration is disabled", {
status: 403,
});
const ip = getClientIp(request, server);
if (!checkRateLimit(ip, 3, 60 * 60_000)) {
return html(
<Register error="Too many registration attempts. Please try again later." />,
);
}
const { username, password, password2 } = body;
if (!VALID_USERNAME_RE.test(username)) {
return html(
<Register error="Username may only contain letters, numbers, hyphens, and underscores" />,
);
}
if (username === ADMIN_USERNAME) {
return html(<Register error="That username is reserved" />);
}
if (!password?.trim()) {
return html(
<Register error="Password is required (use the passkey button for passwordless registration)" />,
);
}
if (password !== password2) {
return html(<Register error="Passwords do not match" />);
}
if (password.length < 8) {
return html(
<Register error="Password must be at least 8 characters" />,
);
}
const hash = await argon2.hash(password);
const now = new Date().toISOString();
let result: { id: number };
try {
result = await db
.insertInto("users")
.values({
username,
password_hash: hash,
created_at: now,
})
.returning("id")
.executeTakeFirstOrThrow();
} catch (err) {
if (
err instanceof Error &&
err.message.includes(
"UNIQUE constraint failed: users.username",
)
) {
return html(<Register error="Username already taken" />);
}
throw err;
}
const sessionId = await createSession(result.id);
return redirect("/", sessionCookie(sessionId));
},
{
body: t.Object({
username: t.String(),
password: t.Optional(t.String()),
password2: t.Optional(t.String()),
}),
},
)
.post("/logout", async ({ cookie }) => {
const sessionId = cookie.session.value;
if (sessionId) {
await db
.deleteFrom("sessions")
.where("id", "=", sessionId)
.execute();
}
return redirect("/", clearCookie());
})
// Create account (passkey-only path, called before passkey registration)
.post(
"/auth/passkey/create-user",
async ({ body }) => {
const { username } = body;
if (!username || !VALID_USERNAME_RE.test(username)) {
return new Response(
JSON.stringify({ error: "Invalid username" }),
{
status: 400,
},
);
}
if (username === ADMIN_USERNAME) {
return new Response(
JSON.stringify({ error: "That username is reserved" }),
{ status: 400 },
);
}
const now = new Date().toISOString();
let result: { id: number };
try {
result = await db
.insertInto("users")
.values({
username,
password_hash: null,
created_at: now,
})
.returning("id")
.executeTakeFirstOrThrow();
} catch (err) {
if (
err instanceof Error &&
err.message.includes(
"UNIQUE constraint failed: users.username",
)
) {
return new Response(
JSON.stringify({ error: "Username already taken" }),
{ status: 400 },
);
}
throw err;
}
const sessionId = await createSession(result.id);
return new Response(JSON.stringify({ ok: true }), {
headers: {
"Content-Type": "application/json",
"Set-Cookie": sessionCookie(sessionId),
},
});
},
{
body: t.Object({ username: t.String() }),
},
)
// Passkey registration
.post("/auth/passkey/register/options", async ({ cookie, request }) => {
const user = await resolveSession(cookie.session.value);
if (!user)
return new Response(
JSON.stringify({ error: "Not authenticated" }),
{
status: 401,
},
);
const { rpId } = rpFromRequest(request);
const passkeys = await db
.selectFrom("passkeys")
.select(["credential_id"])
.where("user_id", "=", user.id)
.execute();
const options = await generateRegistrationOptions({
rpName: WEBAUTHN_RP_NAME,
rpID: rpId,
userID: Buffer.from(String(user.id)),
userName: user.username,
attestationType: "none",
excludeCredentials: passkeys.map((p) => ({
id: p.credential_id,
type: "public-key" as const,
})),
});
setChallenge(`${user.username}:reg`, options.challenge);
return new Response(JSON.stringify(options), {
headers: { "Content-Type": "application/json" },
});
})
.post(
"/auth/passkey/register/verify",
async ({ body, cookie, request }) => {
const user = await resolveSession(cookie.session.value);
if (!user)
return new Response(
JSON.stringify({ error: "Not authenticated" }),
{
status: 401,
},
);
const challenge = pendingChallenges.get(
`${user.username}:reg`,
)?.challenge;
if (!challenge)
return new Response(JSON.stringify({ error: "No challenge" }), {
status: 400,
});
const { origin, rpId } = rpFromRequest(request);
try {
const verification = await verifyRegistrationResponse({
response: body as Parameters<
typeof verifyRegistrationResponse
>[0]["response"],
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpId,
});
if (!verification.verified || !verification.registrationInfo) {
return new Response(
JSON.stringify({ error: "Verification failed" }),
{ status: 400 },
);
}
const { credential } = verification.registrationInfo;
const now = new Date().toISOString();
await db
.insertInto("passkeys")
.values({
user_id: user.id,
credential_id: credential.id,
public_key: Buffer.from(
new Uint8Array(credential.publicKey),
).toString("base64"),
counter: credential.counter,
created_at: now,
})
.execute();
deleteChallenge(`${user.username}:reg`);
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" },
});
} catch (e) {
return new Response(JSON.stringify({ error: String(e) }), {
status: 400,
});
}
},
{
body: t.Any(),
},
)
// Passkey login
.post("/auth/passkey/login/options", async ({ request }) => {
const { rpId } = rpFromRequest(request);
const options = await generateAuthenticationOptions({
rpID: rpId,
});
setChallenge(`login:${options.challenge}`, options.challenge);
return new Response(JSON.stringify(options), {
headers: { "Content-Type": "application/json" },
});
})
.post(
"/auth/passkey/login/verify",
async ({ body, request }) => {
const reqBody = body as { id?: string };
const credentialId = reqBody?.id;
if (!credentialId) {
return new Response(
JSON.stringify({ error: "Missing credential" }),
{
status: 400,
},
);
}
const passkey = await db
.selectFrom("passkeys")
.innerJoin("users", "users.id", "passkeys.user_id")
.selectAll("passkeys")
.select("users.username")
.where("passkeys.credential_id", "=", credentialId)
.executeTakeFirst();
if (!passkey) {
return new Response(
JSON.stringify({ error: "Unknown credential" }),
{
status: 400,
},
);
}
// Look up the challenge by decoding it from the signed clientDataJSON.
// This ensures each verify request finds its own challenge rather than
// an arbitrary "first login:" entry, which would fail under concurrency.
const reqBodyTyped = body as {
response?: { clientDataJSON?: string };
};
const clientDataJSON = reqBodyTyped?.response?.clientDataJSON;
let challengeKey: string | undefined;
let challenge: string | undefined;
if (clientDataJSON) {
try {
const cd = JSON.parse(
Buffer.from(clientDataJSON, "base64url").toString(),
) as { challenge?: string };
if (cd.challenge) {
challengeKey = `login:${cd.challenge}`;
challenge =
pendingChallenges.get(challengeKey)?.challenge;
}
} catch {
// malformed clientDataJSON — handled by the check below
}
}
if (!challenge) {
return new Response(JSON.stringify({ error: "No challenge" }), {
status: 400,
});
}
const { origin, rpId } = rpFromRequest(request);
try {
const verification = await verifyAuthenticationResponse({
response: body as Parameters<
typeof verifyAuthenticationResponse
>[0]["response"],
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpId,
credential: {
id: passkey.credential_id,
publicKey: Buffer.from(passkey.public_key, "base64"),
counter: passkey.counter,
},
});
if (!verification.verified) {
return new Response(
JSON.stringify({ error: "Verification failed" }),
{ status: 401 },
);
}
// Atomically advance the counter using the expected old value as
// a guard. If another concurrent request already updated it, 0
// rows are affected and we reject — this preserves WebAuthn's
// monotonic-counter replay-attack protection.
const updated = await db
.updateTable("passkeys")
.set({
counter: verification.authenticationInfo.newCounter,
})
.where("id", "=", passkey.id)
.where("counter", "=", passkey.counter)
.executeTakeFirst();
if (!updated || updated.numUpdatedRows === 0n) {
return new Response(
JSON.stringify({ error: "Credential replay detected" }),
{ status: 401 },
);
}
deleteChallenge(challengeKey!);
const sessionId = await createSession(passkey.user_id);
return new Response(JSON.stringify({ ok: true }), {
headers: {
"Content-Type": "application/json",
"Set-Cookie": sessionCookie(sessionId),
},
});
} catch (e) {
return new Response(JSON.stringify({ error: String(e) }), {
status: 400,
});
}
},
{
body: t.Any(),
},
);
▾Asrc/routes/avatars.ts
@@ -0,0 +1,84 @@
import { Elysia, t } from "elysia";
import { MAX_USER_UPLOAD_BYTES } from "../config.ts";
import { db } from "../db/index.ts";
import { requireAuth, resolveSession } from "../middleware/session.ts";
import {
avatarJxlPath,
createDefaultAvatar,
processAndStoreAvatar,
} from "../services/avatar.ts";
const CACHE = "public, max-age=31536000, immutable";
async function bumpAvatarVersion(userId: number): Promise<void> {
await db
.updateTable("users")
.set((eb) => ({ avatar_version: eb("avatar_version", "+", 1) }))
.where("id", "=", userId)
.execute();
}
export const avatarRoutes = new Elysia()
.guard({ cookie: t.Cookie({ session: t.Optional(t.String()) }) })
.get("/avatars/:id", async ({ params, request }) => {
const userId = parseInt(params.id, 10);
if (Number.isNaN(userId))
return new Response("Not found", { status: 404 });
if (!new URL(request.url).searchParams.has("v"))
return new Response("Not found", { status: 404 });
return new Response(Bun.file(avatarJxlPath(userId)), {
headers: {
"Content-Type": "image/jxl",
"Cache-Control": CACHE,
},
});
})
.post(
"/settings/avatar",
async ({ body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
if (body.avatar.size > MAX_USER_UPLOAD_BYTES) {
return new Response("Avatar file too large", { status: 400 });
}
const buffer = Buffer.from(await body.avatar.arrayBuffer());
try {
await processAndStoreAvatar(user!.id, buffer);
} catch (err) {
if (
err instanceof Error &&
err.message === "Invalid image type"
) {
return new Response("File is not a supported image type", {
status: 400,
});
}
throw err;
}
await bumpAvatarVersion(user!.id);
return new Response(null, {
status: 302,
headers: { Location: "/settings" },
});
},
{
body: t.Object({ avatar: t.File() }),
},
)
.post("/settings/avatar/delete", async ({ cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
await createDefaultAvatar(user!.id, user!.username);
await bumpAvatarVersion(user!.id);
return new Response(null, {
status: 302,
headers: { Location: "/settings" },
});
});
▾Asrc/routes/git.ts
@@ -0,0 +1,177 @@
import { existsSync } from "node:fs";
import path from "node:path";
import * as argon2 from "argon2";
import { Elysia, t } from "elysia";
import { ADMIN_USERNAME, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts";
import { db } from "../db";
function pktLine(str: string): Buffer {
const len = Buffer.byteLength(str, "utf-8") + 4;
return Buffer.from(len.toString(16).padStart(4, "0") + str, "utf-8");
}
const PKT_FLUSH = Buffer.from("0000");
async function verifyBasicAuth(
authHeader: string | null,
adminOnly: boolean,
): Promise<boolean> {
if (!authHeader?.startsWith("Basic ")) return false;
const decoded = Buffer.from(authHeader.slice(6), "base64").toString(
"utf-8",
);
const sep = decoded.indexOf(":");
if (sep === -1) return false;
const username = decoded.slice(0, sep);
const password = decoded.slice(sep + 1);
if (adminOnly && username !== ADMIN_USERNAME) return false;
const user = await db
.selectFrom("users")
.select("password_hash")
.where("username", "=", username)
.executeTakeFirst();
if (!user?.password_hash) return false;
try {
return await argon2.verify(user.password_hash, password);
} catch {
return false;
}
}
function unauthorized(): Response {
return new Response("Unauthorized", {
status: 401,
headers: {
"WWW-Authenticate": 'Basic realm="Hearthforge"',
"Content-Type": "text/plain",
},
});
}
async function getRepo(
slug: string,
): Promise<{ repoPath: string; isPrivate: boolean } | null> {
const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug;
if (!VALID_REPO_NAME_RE.test(repoName)) return null;
const repo = await db
.selectFrom("repositories")
.select(["name", "is_private"])
.where("name", "=", repoName)
.executeTakeFirst();
if (!repo) return null;
const repoPath = path.join(REPOS_DIR, `${repo.name}.git`);
if (!existsSync(repoPath)) return null;
return { repoPath, isPrivate: repo.is_private === 1 };
}
async function spawnGit(
args: string[],
stdinBytes?: Uint8Array,
): Promise<Uint8Array> {
const proc = Bun.spawn(args, {
stdin: stdinBytes ?? "ignore",
stdout: "pipe",
stderr: "pipe",
});
await proc.exited;
return new Uint8Array(await Bun.readableStreamToArrayBuffer(proc.stdout));
}
export const gitRoutes = new Elysia()
// info/refs — serves both upload-pack (clone/fetch) and receive-pack (push)
.get(
"/:repo/info/refs",
async ({ params, query, request }) => {
const service = query.service;
if (
service !== "git-upload-pack" &&
service !== "git-receive-pack"
) {
return new Response("Bad Request", { status: 400 });
}
const repo = await getRepo(params.repo);
if (!repo) return new Response("Not Found", { status: 404 });
const authHeader = request.headers.get("Authorization");
if (service === "git-receive-pack") {
if (!(await verifyBasicAuth(authHeader, true)))
return unauthorized();
} else if (repo.isPrivate) {
if (!(await verifyBasicAuth(authHeader, false)))
return unauthorized();
}
const gitCmd =
service === "git-receive-pack" ? "receive-pack" : "upload-pack";
const refs = await spawnGit([
"git",
gitCmd,
"--stateless-rpc",
"--advertise-refs",
repo.repoPath,
]);
const body = Buffer.concat([
pktLine(`# service=git-${gitCmd}\n`),
PKT_FLUSH,
refs,
]);
return new Response(body, {
headers: {
"Content-Type": `application/x-git-${gitCmd}-advertisement`,
"Cache-Control": "no-cache",
},
});
},
{
query: t.Object({ service: t.Optional(t.String()) }),
},
)
// upload-pack POST — clone/fetch pack transfer (public for public repos)
.post("/:repo/git-upload-pack", async ({ params, request }) => {
const repo = await getRepo(params.repo);
if (!repo) return new Response("Not Found", { status: 404 });
if (repo.isPrivate) {
if (
!(await verifyBasicAuth(
request.headers.get("Authorization"),
false,
))
)
return unauthorized();
}
const body = new Uint8Array(await request.arrayBuffer());
const result = await spawnGit(
["git", "upload-pack", "--stateless-rpc", repo.repoPath],
body,
);
return new Response(result, {
headers: {
"Content-Type": "application/x-git-upload-pack-result",
"Cache-Control": "no-cache",
},
});
})
// receive-pack POST — push pack transfer (admin only)
.post("/:repo/git-receive-pack", async ({ params, request }) => {
if (
!(await verifyBasicAuth(request.headers.get("Authorization"), true))
)
return unauthorized();
const repo = await getRepo(params.repo);
if (!repo) return new Response("Not Found", { status: 404 });
const body = new Uint8Array(await request.arrayBuffer());
const result = await spawnGit(
["git", "receive-pack", "--stateless-rpc", repo.repoPath],
body,
);
return new Response(result, {
headers: {
"Content-Type": "application/x-git-receive-pack-result",
"Cache-Control": "no-cache",
},
});
});
▾Asrc/routes/issues.tsx
@@ -0,0 +1,539 @@
import { Elysia, t } from "elysia";
import { sql } from "kysely";
import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import {
requireAdmin,
requireAuth,
resolveSession,
} from "../middleware/session.ts";
import { renderMarkdown } from "../services/markdown.ts";
import { buildReactionCounts } from "../services/reactions.ts";
import { IssueDetail } from "../views/issues/IssueDetail.tsx";
import { IssueList } from "../views/issues/IssueList.tsx";
import { NewIssue } from "../views/issues/NewIssue.tsx";
import { html } from "../views/render.tsx";
export const issueRoutes = new Elysia()
.guard({
cookie: t.Cookie({ session: t.Optional(t.String()) }),
})
.get(
"/:repo/issues",
async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const status =
query.status === "closed"
? ("closed" as const)
: ("open" as const);
const page = Math.max(1, query.page ?? 1);
const allCounts = await db
.selectFrom("issues")
.select(["status", db.fn.countAll<number>().as("count")])
.where("repo_id", "=", repo.id)
.groupBy("status")
.execute();
const counts: Record<string, number> = Object.fromEntries(
allCounts.map((r) => [r.status, Number(r.count)]),
);
const totalPages = Math.max(
1,
Math.ceil((counts[status] ?? 0) / ISSUES_PER_PAGE),
);
const safePage = Math.min(page, totalPages);
const offset = (safePage - 1) * ISSUES_PER_PAGE;
const issues = await db
.selectFrom("issues")
.leftJoin("users", "users.id", "issues.author_id")
.select([
"issues.id",
"issues.repo_id",
"issues.author_id",
"issues.number",
"issues.title",
"issues.body",
"issues.status",
"issues.created_at",
"issues.updated_at",
"issues.edited_at",
"users.username as author_username",
])
.where("issues.repo_id", "=", repo.id)
.where("issues.status", "=", status)
.orderBy("issues.number", "desc")
.limit(ISSUES_PER_PAGE)
.offset(offset)
.execute();
const pagination = {
page: safePage,
totalPages,
pageUrlTemplate: `/${repo.name}/issues?status=${status}&page={page}`,
};
return html(
<IssueList
user={user}
repo={repo}
issues={
issues as ((typeof issues)[0] & {
author_username: string;
})[]
}
status={status}
counts={counts}
pagination={pagination}
/>,
);
},
{
query: t.Object({
status: t.Optional(t.String()),
page: t.Optional(t.Numeric()),
}),
},
)
.get("/:repo/issues/new", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
return html(<NewIssue user={user!} repo={repo} />);
})
.post(
"/:repo/issues",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const { title, body: issueBody } = body;
if (!title?.trim()) {
return html(
<NewIssue
user={user!}
repo={repo}
error="Title is required"
/>,
);
}
const now = new Date().toISOString();
const { number } = await db.transaction().execute(async (trx) => {
const { issue_seq } = await trx
.updateTable("repositories")
.set({ issue_seq: sql`issue_seq + 1` })
.where("id", "=", repo.id)
.returning("issue_seq")
.executeTakeFirstOrThrow();
await trx
.insertInto("issues")
.values({
repo_id: repo.id,
author_id: user?.id,
number: issue_seq,
title: title.trim(),
body: issueBody ?? "",
status: "open",
created_at: now,
updated_at: now,
})
.execute();
return { number: issue_seq };
});
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues/${number}` },
});
},
{
body: t.Object({
title: t.String(),
body: t.Optional(t.String()),
}),
},
)
.get("/:repo/issues/:number", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.leftJoin("users", "users.id", "issues.author_id")
.select([
"issues.id",
"issues.repo_id",
"issues.author_id",
"issues.number",
"issues.title",
"issues.body",
"issues.status",
"issues.created_at",
"issues.updated_at",
"issues.edited_at",
"users.username as author_username",
"users.avatar_version as author_avatar_version",
])
.where("issues.repo_id", "=", repo.id)
.where("issues.number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
const bodyHtml = renderMarkdown(issue.body);
const comments = await db
.selectFrom("issue_comments")
.leftJoin("users", "users.id", "issue_comments.author_id")
.select([
"issue_comments.id",
"issue_comments.issue_id",
"issue_comments.author_id",
"issue_comments.body",
"issue_comments.created_at",
"issue_comments.edited_at",
"users.username as author_username",
"users.avatar_version as author_avatar_version",
])
.where("issue_comments.issue_id", "=", issue.id)
.orderBy("issue_comments.created_at", "asc")
.execute();
const commentsWithHtml = comments.map((c) => ({
...c,
bodyHtml: renderMarkdown(c.body),
}));
// Reactions on the issue itself
const allReactions = await db
.selectFrom("issue_reactions")
.selectAll()
.where("issue_id", "=", issue.id)
.execute();
const reactions = buildReactionCounts(allReactions, null, user?.id);
const commentReactions = new Map(
comments.map((c) => [
c.id,
buildReactionCounts(allReactions, c.id, user?.id),
]),
);
return html(
<IssueDetail
user={user}
repo={repo}
issue={
issue as typeof issue & {
author_username: string;
author_avatar_version: number | null;
}
}
bodyHtml={bodyHtml}
comments={
commentsWithHtml as ((typeof commentsWithHtml)[0] & {
author_username: string;
author_avatar_version: number | null;
})[]
}
reactions={reactions}
commentReactions={commentReactions}
/>,
);
})
.post(
"/:repo/issues/:number/comments",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.select(["id", "status"])
.where("repo_id", "=", repo.id)
.where("number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
// Only admin can comment on closed issues
if (issue.status === "closed" && !user?.isAdmin) {
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues/${issueNum}` },
});
}
const { body: commentBody } = body;
if (!commentBody?.trim()) {
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues/${issueNum}` },
});
}
await db.transaction().execute(async (trx) => {
const now = new Date().toISOString();
await trx
.insertInto("issue_comments")
.values({
issue_id: issue.id,
author_id: user?.id,
body: commentBody.trim(),
created_at: now,
})
.execute();
await trx
.updateTable("issues")
.set({ updated_at: now })
.where("id", "=", issue.id)
.execute();
});
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues/${issueNum}` },
});
},
{
body: t.Object({ body: t.String() }),
},
)
.post(
"/:repo/issues/:number/react",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const { emoji, comment_id } = body;
if (!ALLOWED_REACTIONS.has(emoji)) {
return new Response("Invalid emoji", { status: 400 });
}
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.select(["id"])
.where("repo_id", "=", repo.id)
.where("number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
const commentId = comment_id ? parseInt(comment_id, 10) : null;
// One reaction per user per target: toggle off if same emoji, replace if different
await db.transaction().execute(async (trx) => {
const existing = await trx
.selectFrom("issue_reactions")
.select(["id", "emoji"])
.where("issue_id", "=", issue.id)
.where((eb) =>
commentId !== null
? eb("comment_id", "=", commentId)
: eb("comment_id", "is", null),
)
.where("user_id", "=", user!.id)
.executeTakeFirst();
if (existing) {
if (existing.emoji === emoji) {
await trx
.deleteFrom("issue_reactions")
.where("id", "=", existing.id)
.execute();
} else {
await trx
.updateTable("issue_reactions")
.set({ emoji })
.where("id", "=", existing.id)
.execute();
}
} else {
await trx
.insertInto("issue_reactions")
.values({
issue_id: issue.id,
comment_id: commentId,
user_id: user!.id,
emoji,
})
.execute();
}
});
return new Response(null, {
status: 303,
headers: { Location: `/${repo.name}/issues/${issueNum}` },
});
},
{
body: t.Object({
emoji: t.String(),
comment_id: t.Optional(t.String()),
}),
},
)
.post("/:repo/issues/:number/close", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.select("id")
.where("repo_id", "=", repo.id)
.where("number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
await db
.updateTable("issues")
.set({
status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`,
updated_at: new Date().toISOString(),
})
.where("id", "=", issue.id)
.execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues/${issueNum}` },
});
})
.post("/:repo/issues/:number/delete", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.select(["id", "author_id"])
.where("repo_id", "=", repo.id)
.where("number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
if (issue.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
await db.deleteFrom("issues").where("id", "=", issue.id).execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues` },
});
})
.post(
"/:repo/issues/:number/edit",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const issueNum = parseInt(params.number, 10);
const issue = await db
.selectFrom("issues")
.select(["id", "author_id"])
.where("repo_id", "=", repo.id)
.where("number", "=", issueNum)
.executeTakeFirst();
if (!issue) return new Response("Not found", { status: 404 });
if (issue.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
await db
.updateTable("issues")
.set({
title: body.title.trim(),
body: body.edit_body ?? "",
edited_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.where("id", "=", issue.id)
.execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues/${issueNum}` },
});
},
{
body: t.Object({
title: t.String(),
edit_body: t.Optional(t.String()),
}),
},
)
.post(
"/:repo/issues/:number/comments/:id/edit",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const comment = await db
.selectFrom("issue_comments")
.select(["id", "author_id", "issue_id"])
.where("id", "=", params.id)
.executeTakeFirst();
if (!comment) return new Response("Not found", { status: 404 });
if (comment.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
const issueNum = parseInt(params.number, 10);
await db
.updateTable("issue_comments")
.set({
body: body.edit_body.trim(),
edited_at: new Date().toISOString(),
})
.where("id", "=", comment.id)
.execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/issues/${issueNum}` },
});
},
{
params: t.Object({
repo: t.String(),
number: t.String(),
id: t.Numeric(),
}),
body: t.Object({ edit_body: t.String() }),
},
);
▾Asrc/routes/patches.tsx
@@ -0,0 +1,698 @@
import { Elysia, t } from "elysia";
import { sql } from "kysely";
import { MAX_USER_UPLOAD_BYTES } from "../config.ts";
import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import {
requireAdmin,
requireAuth,
resolveSession,
} from "../middleware/session.ts";
import { git } from "../services/git.ts";
import { prepareDiff } from "../services/highlightWorker.ts";
import { renderMarkdown } from "../services/markdown.ts";
import { patchCache } from "../services/patchCache.ts";
import { buildReactionCounts } from "../services/reactions.ts";
import { NewPatch } from "../views/patches/NewPatch.tsx";
import { PatchDetail } from "../views/patches/PatchDetail.tsx";
import { PatchList } from "../views/patches/PatchList.tsx";
import { html } from "../views/render.tsx";
function isValidPatch(content: string): boolean {
const lines = content.split("\n");
return lines.some(
(l) =>
l.startsWith("diff --git ") ||
l.startsWith("--- ") ||
l.startsWith("+++ ") ||
l.startsWith("@@ ") ||
l.startsWith("Index: "),
);
}
async function runPatchCheck(
repoName: string,
patchId: number,
patchContent: string,
) {
const result = await git.checkPatch(repoName, patchContent);
const applyResult = {
status: result.clean ? ("clean" as const) : ("conflict" as const),
output: result.output,
};
patchCache.set(patchId, applyResult);
return applyResult;
}
export const patchRoutes = new Elysia()
.guard({
cookie: t.Cookie({ session: t.Optional(t.String()) }),
})
.get(
"/:repo/patches",
async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const status = ["open", "merged", "closed"].includes(
query.status ?? "",
)
? query.status!
: "open";
const page = Math.max(1, query.page ?? 1);
const allCounts = await db
.selectFrom("patches")
.select(["status", db.fn.countAll<number>().as("count")])
.where("repo_id", "=", repo.id)
.groupBy("status")
.execute();
const counts: Record<string, number> = Object.fromEntries(
allCounts.map((r) => [r.status, Number(r.count)]),
);
const totalPages = Math.max(
1,
Math.ceil((counts[status] ?? 0) / PATCHES_PER_PAGE),
);
const safePage = Math.min(page, totalPages);
const offset = (safePage - 1) * PATCHES_PER_PAGE;
const patches = await db
.selectFrom("patches")
.leftJoin("users", "users.id", "patches.author_id")
.select([
"patches.id",
"patches.repo_id",
"patches.author_id",
"patches.number",
"patches.title",
"patches.description",
"patches.patch_content",
"patches.status",
"patches.created_at",
"patches.updated_at",
"patches.edited_at",
"users.username as author_username",
])
.where("patches.repo_id", "=", repo.id)
.where("patches.status", "=", status)
.orderBy("patches.number", "desc")
.limit(PATCHES_PER_PAGE)
.offset(offset)
.execute();
const pagination = {
page: safePage,
totalPages,
pageUrlTemplate: `/${repo.name}/patches?status=${status}&page={page}`,
};
return html(
<PatchList
user={user}
repo={repo}
patches={
patches as ((typeof patches)[0] & {
author_username: string;
})[]
}
status={status}
counts={counts}
pagination={pagination}
/>,
);
},
{
query: t.Object({
status: t.Optional(t.String()),
page: t.Optional(t.Numeric()),
}),
},
)
.get("/:repo/patches/new", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
return html(<NewPatch user={user!} repo={repo} />);
})
.post(
"/:repo/patches",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
if (!body.title?.trim()) {
return html(
<NewPatch
user={user!}
repo={repo}
error="Title is required"
/>,
);
}
if (!body.patch_file) {
return html(
<NewPatch
user={user!}
repo={repo}
error="Patch file is required"
/>,
);
}
if (body.patch_file.size > MAX_USER_UPLOAD_BYTES) {
return html(
<NewPatch
user={user!}
repo={repo}
error="Patch file is too large"
/>,
);
}
const patchContent = await body.patch_file.text();
if (!patchContent.trim()) {
return html(
<NewPatch
user={user!}
repo={repo}
error="Patch file is empty"
/>,
);
}
// Validate it looks like a patch/diff file
if (!isValidPatch(patchContent)) {
return html(
<NewPatch
user={user!}
repo={repo}
error="File does not appear to be a valid patch or diff file"
/>,
);
}
const now = new Date().toISOString();
const { number, result } = await db
.transaction()
.execute(async (trx) => {
const { patch_seq } = await trx
.updateTable("repositories")
.set({ patch_seq: sql`patch_seq + 1` })
.where("id", "=", repo.id)
.returning("patch_seq")
.executeTakeFirstOrThrow();
const inserted = await trx
.insertInto("patches")
.values({
repo_id: repo.id,
author_id: user?.id,
number: patch_seq,
title: body.title!.trim(),
description: body.description?.trim() ?? "",
patch_content: patchContent,
status: "open",
created_at: now,
updated_at: now,
})
.returning("id")
.executeTakeFirstOrThrow();
return { number: patch_seq, result: inserted };
});
await runPatchCheck(repo.name, result.id, patchContent);
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${number}` },
});
},
{
body: t.Object({
title: t.Optional(t.String()),
description: t.Optional(t.String()),
patch_file: t.Optional(t.File()),
}),
},
)
.get(
"/:repo/patches/:number",
async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.leftJoin("users", "users.id", "patches.author_id")
.select([
"patches.id",
"patches.repo_id",
"patches.author_id",
"patches.number",
"patches.title",
"patches.description",
"patches.patch_content",
"patches.status",
"patches.created_at",
"patches.updated_at",
"patches.edited_at",
"users.username as author_username",
"users.avatar_version as author_avatar_version",
])
.where("patches.repo_id", "=", repo.id)
.where("patches.number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
const descriptionHtml = patch.description
? renderMarkdown(patch.description)
: "";
let applyResult = patchCache.get(patch.id) ?? null;
// Cold cache (e.g. server restart) — re-check synchronously for open patches only
if (!applyResult && patch.status === "open") {
applyResult = await runPatchCheck(
repo.name,
patch.id,
patch.patch_content,
);
}
const files = await prepareDiff(
patch.patch_content,
`patch:${patch.id}`,
);
const comments = await db
.selectFrom("patch_comments")
.leftJoin("users", "users.id", "patch_comments.author_id")
.select([
"patch_comments.id",
"patch_comments.patch_id",
"patch_comments.author_id",
"patch_comments.body",
"patch_comments.created_at",
"patch_comments.edited_at",
"users.username as author_username",
"users.avatar_version as author_avatar_version",
])
.where("patch_comments.patch_id", "=", patch.id)
.orderBy("patch_comments.created_at", "asc")
.execute();
const commentsWithHtml = comments.map((c) => ({
...c,
bodyHtml: renderMarkdown(c.body),
}));
const allReactions = await db
.selectFrom("patch_reactions")
.selectAll()
.where("patch_id", "=", patch.id)
.execute();
const reactions = buildReactionCounts(allReactions, null, user?.id);
const commentReactions = new Map(
comments.map((c) => [
c.id,
buildReactionCounts(allReactions, c.id, user?.id),
]),
);
const tab =
query.tab === "changes"
? ("changes" as const)
: ("conversation" as const);
return html(
<PatchDetail
user={user}
repo={repo}
patch={
patch as typeof patch & {
author_username: string;
author_avatar_version: number | null;
}
}
descriptionHtml={descriptionHtml}
applyResult={applyResult}
files={files}
tab={tab}
comments={
commentsWithHtml as ((typeof commentsWithHtml)[0] & {
author_username: string;
author_avatar_version: number | null;
})[]
}
reactions={reactions}
commentReactions={commentReactions}
/>,
);
},
{
query: t.Object({ tab: t.Optional(t.String()) }),
},
)
.post("/:repo/patches/:number/merge", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id", "title", "description", "patch_content", "status"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
// Atomically claim the merge slot before the slow git operation to
// prevent two concurrent requests from both applying the same patch.
const claimed = await db
.updateTable("patches")
.set({ status: "merged", updated_at: new Date().toISOString() })
.where("id", "=", patch.id)
.where("status", "=", "open")
.executeTakeFirst();
if (!claimed || claimed.numUpdatedRows === 0n)
return new Response("Patch is not open", { status: 400 });
try {
await git.applyPatch(
repo.name,
patch.patch_content,
patch.title,
patch.description,
);
} catch (err) {
// Roll back the status if the git operation fails
await db
.updateTable("patches")
.set({ status: "open", updated_at: new Date().toISOString() })
.where("id", "=", patch.id)
.execute();
throw err;
}
patchCache.invalidate(patch.id);
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
})
.post("/:repo/patches/:number/close", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select("id")
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
// Toggle open↔closed atomically; exclude merged patches from the WHERE
// so that numUpdatedRows = 0 means the patch is merged (or gone).
const toggled = await db
.updateTable("patches")
.set({
status: sql`CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END`,
updated_at: new Date().toISOString(),
})
.where("id", "=", patch.id)
.where("status", "!=", "merged")
.executeTakeFirst();
if (!toggled || toggled.numUpdatedRows === 0n)
return new Response("Patch is merged", { status: 400 });
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
})
.post("/:repo/patches/:number/delete", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id", "author_id"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
if (patch.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
patchCache.invalidate(patch.id);
await db.deleteFrom("patches").where("id", "=", patch.id).execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches` },
});
})
.post(
"/:repo/patches/:number/comments",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id", "status"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
const { body: commentBody } = body;
if (!commentBody?.trim()) {
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
}
await db.transaction().execute(async (trx) => {
const now = new Date().toISOString();
await trx
.insertInto("patch_comments")
.values({
patch_id: patch.id,
author_id: user?.id,
body: commentBody.trim(),
created_at: now,
})
.execute();
await trx
.updateTable("patches")
.set({ updated_at: now })
.where("id", "=", patch.id)
.execute();
});
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
},
{
body: t.Object({ body: t.String() }),
},
)
.post(
"/:repo/patches/:number/react",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const { emoji, comment_id } = body;
if (!ALLOWED_REACTIONS.has(emoji)) {
return new Response("Invalid emoji", { status: 400 });
}
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
const commentId = comment_id ? parseInt(comment_id, 10) : null;
await db.transaction().execute(async (trx) => {
const existing = await trx
.selectFrom("patch_reactions")
.select(["id", "emoji"])
.where("patch_id", "=", patch.id)
.where((eb) =>
commentId !== null
? eb("comment_id", "=", commentId)
: eb("comment_id", "is", null),
)
.where("user_id", "=", user!.id)
.executeTakeFirst();
if (existing) {
if (existing.emoji === emoji) {
await trx
.deleteFrom("patch_reactions")
.where("id", "=", existing.id)
.execute();
} else {
await trx
.updateTable("patch_reactions")
.set({ emoji })
.where("id", "=", existing.id)
.execute();
}
} else {
await trx
.insertInto("patch_reactions")
.values({
patch_id: patch.id,
comment_id: commentId,
user_id: user!.id,
emoji,
})
.execute();
}
});
return new Response(null, {
status: 303,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
},
{
body: t.Object({
emoji: t.String(),
comment_id: t.Optional(t.String()),
}),
},
)
.post(
"/:repo/patches/:number/comments/:id/edit",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const comment = await db
.selectFrom("patch_comments")
.select(["id", "author_id"])
.where("id", "=", params.id)
.executeTakeFirst();
if (!comment) return new Response("Not found", { status: 404 });
if (comment.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
const patchNum = parseInt(params.number, 10);
await db
.updateTable("patch_comments")
.set({
body: body.edit_body.trim(),
edited_at: new Date().toISOString(),
})
.where("id", "=", comment.id)
.execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
},
{
params: t.Object({
repo: t.String(),
number: t.String(),
id: t.Numeric(),
}),
body: t.Object({ edit_body: t.String() }),
},
)
.post(
"/:repo/patches/:number/edit",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAuth(user);
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select(["id", "author_id"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
if (!patch) return new Response("Not found", { status: 404 });
if (patch.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
await db
.updateTable("patches")
.set({
title: body.title.trim(),
description: body.edit_description ?? "",
edited_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.where("id", "=", patch.id)
.execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/patches/${patchNum}` },
});
},
{
body: t.Object({
title: t.String(),
edit_description: t.Optional(t.String()),
}),
},
);
▾Asrc/routes/releases.tsx
@@ -0,0 +1,575 @@
import { mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { Elysia, t } from "elysia";
import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { archiveRepo, validateCommit } from "../services/git.ts";
import { renderMarkdown } from "../services/markdown.ts";
import { NewRelease } from "../views/releases/NewRelease.tsx";
import { ReleaseDetail } from "../views/releases/ReleaseDetail.tsx";
import { ReleaseList } from "../views/releases/ReleaseList.tsx";
import { html } from "../views/render.tsx";
// Tracks AbortControllers for source archive generation tasks that are
// currently in progress, keyed by release ID. Used to cancel generation
// immediately when the corresponding release is deleted.
const archivingTasks = new Map<number, AbortController>();
function sanitizeFilename(name: string): string {
const safe = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, "_");
if (!safe || /^\.+$/.test(safe)) return "_";
return safe;
}
export const releasesRoutes = new Elysia()
.guard({
cookie: t.Cookie({ session: t.Optional(t.String()) }),
})
.get(
"/:repo/releases",
async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const page = Math.max(1, query.page ?? 1);
const countRow = await db
.selectFrom("releases")
.select(db.fn.countAll<number>().as("count"))
.where("repo_id", "=", repo.id)
.executeTakeFirst();
const totalPages = Math.max(
1,
Math.ceil(Number(countRow?.count ?? 0) / RELEASES_PER_PAGE),
);
const safePage = Math.min(page, totalPages);
const offset = (safePage - 1) * RELEASES_PER_PAGE;
const releasesRaw = await db
.selectFrom("releases")
.selectAll()
.where("repo_id", "=", repo.id)
.orderBy("id", "desc")
.limit(RELEASES_PER_PAGE)
.offset(offset)
.execute();
// Attach asset counts
const releaseIds = releasesRaw.map((r) => r.id);
const assetCounts =
releaseIds.length > 0
? await db
.selectFrom("release_assets")
.select([
"release_id",
db.fn.countAll<number>().as("count"),
])
.where("release_id", "in", releaseIds)
.groupBy("release_id")
.execute()
: [];
const countMap = new Map(
assetCounts.map((r) => [r.release_id, Number(r.count)]),
);
const releases = releasesRaw.map((r) => ({
...r,
asset_count: countMap.get(r.id) ?? 0,
}));
const pagination = {
page: safePage,
totalPages,
pageUrlTemplate: `/${repo.name}/releases?page={page}`,
};
return html(
<ReleaseList
user={user}
repo={repo}
releases={releases}
pagination={pagination}
/>,
);
},
{
query: t.Object({
page: t.Optional(t.Numeric()),
}),
},
)
.get("/:repo/releases/new", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
return html(<NewRelease user={user!} repo={repo} />);
})
.post(
"/:repo/releases",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const tagName = body.tag_name?.trim() ?? "";
const commitHash = body.commit_hash?.trim() ?? "";
if (!tagName) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Tag name is required"
values={{
...body,
include_source_code:
body.include_source_code === "on",
}}
/>,
);
}
if (tagName.includes("/")) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Tag name must not contain slashes"
values={{
...body,
include_source_code:
body.include_source_code === "on",
}}
/>,
);
}
if (!commitHash) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Commit hash is required"
values={{
...body,
include_source_code:
body.include_source_code === "on",
}}
/>,
);
}
const validCommit = await validateCommit(repo.name, commitHash);
if (!validCommit) {
return html(
<NewRelease
user={user!}
repo={repo}
error="Invalid commit hash — no matching commit found in this repository"
values={{
...body,
include_source_code:
body.include_source_code === "on",
}}
/>,
);
}
// Check for duplicate tag
const existing = await db
.selectFrom("releases")
.select("id")
.where("repo_id", "=", repo.id)
.where("tag_name", "=", tagName)
.executeTakeFirst();
if (existing) {
return html(
<NewRelease
user={user!}
repo={repo}
error={`A release with tag "${tagName}" already exists`}
values={{
...body,
include_source_code:
body.include_source_code === "on",
}}
/>,
);
}
const includeSource = body.include_source_code === "on";
const now = new Date().toISOString();
// Collect uploaded file data before opening the transaction so we
// don't hold it open across slow I/O.
const rawFiles = body.files;
const uploadedFiles: {
filename: string;
data: Blob;
size: number;
contentType: string;
}[] = [];
if (rawFiles) {
const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
for (const file of files) {
if (file.size === 0) continue;
uploadedFiles.push({
filename: sanitizeFilename(file.name),
data: file,
size: file.size,
contentType: file.type || "application/octet-stream",
});
}
}
// Insert the release record and all asset records in one transaction
// so that partial failures don't leave orphaned DB rows.
// releaseDir is captured inside the callback so the catch can clean
// up files even though the auto-increment ID isn't known until after
// the INSERT.
let releaseDir: string | null = null;
const releaseId = await db
.transaction()
.execute(async (trx) => {
const inserted = await trx
.insertInto("releases")
.values({
repo_id: repo.id,
tag_name: tagName,
name: body.name?.trim() || null,
notes: body.notes?.trim() || null,
commit_hash: commitHash,
include_source_code: includeSource ? 1 : 0,
created_at: now,
})
.returning("id")
.executeTakeFirstOrThrow();
const id = inserted.id;
releaseDir = path.join(RELEASES_DIR, String(id));
if (includeSource) {
const sourceDir = path.join(releaseDir, "source");
mkdirSync(sourceDir, { recursive: true });
// Write a sentinel file; the actual archives are
// generated asynchronously after the response is sent.
await Bun.write(path.join(sourceDir, ".pending"), "");
}
if (uploadedFiles.length > 0) {
const assetsDir = path.join(releaseDir, "assets");
mkdirSync(assetsDir, { recursive: true });
for (const f of uploadedFiles) {
await Bun.write(
path.join(assetsDir, f.filename),
f.data,
);
await trx
.insertInto("release_assets")
.values({
release_id: id,
filename: f.filename,
size: f.size,
content_type: f.contentType,
created_at: now,
})
.execute();
}
}
return id;
})
.catch((err) => {
// Roll back any partially-written files if the transaction
// failed — the DB rollback handles the DB side automatically.
if (releaseDir) {
rmSync(releaseDir, { recursive: true, force: true });
}
throw err;
});
// Kick off source archive generation in the background so the
// response can be sent immediately. The .pending sentinel written
// inside the transaction signals to the detail view that archives
// are still being prepared. If the release is deleted while
// generation is in progress the background task will hit errors
// (the directory will have been removed) and silently bail out;
// SQLite AUTOINCREMENT guarantees the ID is never reused, so there
// is no risk of contaminating a later release.
if (includeSource) {
const sourceDir = path.join(
RELEASES_DIR,
String(releaseId),
"source",
);
const controller = new AbortController();
archivingTasks.set(releaseId, controller);
(async () => {
try {
await archiveRepo(
repo.name,
commitHash,
repo.name,
sourceDir,
controller.signal,
);
rmSync(path.join(sourceDir, ".pending"), {
force: true,
});
} catch {
// Either the release was deleted (abort) or archiving
// failed. Remove the source dir so the UI shows no
// stale state.
rmSync(sourceDir, { recursive: true, force: true });
} finally {
archivingTasks.delete(releaseId);
}
})();
}
return new Response(null, {
status: 302,
headers: {
Location: `/${repo.name}/releases/${releaseId}`,
},
});
},
{
body: t.Object({
tag_name: t.Optional(t.String()),
name: t.Optional(t.String()),
notes: t.Optional(t.String()),
commit_hash: t.Optional(t.String()),
include_source_code: t.Optional(t.String()),
files: t.Optional(t.Union([t.File(), t.Array(t.File())])),
}),
type: "multipart/form-data",
},
)
.get(
"/:repo/releases/:id",
async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const release = await db
.selectFrom("releases")
.selectAll()
.where("repo_id", "=", repo.id)
.where("id", "=", params.id)
.executeTakeFirst();
if (!release) return new Response("Not found", { status: 404 });
const assets = await db
.selectFrom("release_assets")
.selectAll()
.where("release_id", "=", release.id)
.orderBy("id", "asc")
.execute();
const notesHtml = release.notes
? renderMarkdown(release.notes)
: "";
// Detect which source archives exist on disk, and whether
// generation is still in progress (indicated by a .pending file).
const sourceArchives: {
format: string;
filename: string;
size: number;
}[] = [];
let sourceArchivesPending = false;
if (release.include_source_code) {
const base = `${repo.name}-${release.commit_hash.slice(0, 8)}`;
const sourceDir = path.join(
RELEASES_DIR,
String(release.id),
"source",
);
if (await Bun.file(path.join(sourceDir, ".pending")).exists()) {
sourceArchivesPending = true;
} else {
for (const [format, ext] of [
["zip", ".zip"],
["tar.gz", ".tar.gz"],
["tar.zst", ".tar.zst"],
] as const) {
const filePath = path.join(sourceDir, `${base}${ext}`);
const f = Bun.file(filePath);
if (await f.exists()) {
sourceArchives.push({
format,
filename: `${base}${ext}`,
size: f.size,
});
}
}
}
}
return html(
<ReleaseDetail
user={user}
repo={repo}
release={release}
notesHtml={notesHtml}
assets={assets}
sourceArchives={sourceArchives}
sourceArchivesPending={sourceArchivesPending}
/>,
);
},
{
params: t.Object({
repo: t.String(),
id: t.Numeric(),
}),
},
)
.post(
"/:repo/releases/:id/delete",
async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const release = await db
.selectFrom("releases")
.select("id")
.where("repo_id", "=", repo.id)
.where("id", "=", params.id)
.executeTakeFirst();
if (!release) return new Response("Not found", { status: 404 });
// Abort any in-progress archive generation before touching disk so
// the background task doesn't race with the rmSync below.
archivingTasks.get(release.id)?.abort();
archivingTasks.delete(release.id);
// Remove files from disk before the DB record so that a crash
// between the two leaves a broken-but-visible repo rather than a
// DB record pointing to missing files.
const releaseDir = path.join(RELEASES_DIR, String(release.id));
rmSync(releaseDir, { recursive: true, force: true });
await db
.deleteFrom("releases")
.where("id", "=", release.id)
.execute();
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/releases` },
});
},
{
params: t.Object({
repo: t.String(),
id: t.Numeric(),
}),
},
)
.get(
"/:repo/releases/:id/assets/:filename",
async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const release = await db
.selectFrom("releases")
.select("id")
.where("repo_id", "=", repo.id)
.where("id", "=", params.id)
.executeTakeFirst();
if (!release) return new Response("Not found", { status: 404 });
const safeFilename = path.basename(params.filename);
const asset = await db
.selectFrom("release_assets")
.selectAll()
.where("release_id", "=", release.id)
.where("filename", "=", safeFilename)
.executeTakeFirst();
if (!asset) return new Response("Not found", { status: 404 });
const filePath = path.join(
RELEASES_DIR,
String(release.id),
"assets",
safeFilename,
);
const file = Bun.file(filePath);
if (!(await file.exists()))
return new Response("Not found", { status: 404 });
return new Response(file, {
headers: {
"Content-Disposition": `attachment; filename="${safeFilename}"`,
"Content-Type": "application/octet-stream",
},
});
},
{
params: t.Object({
repo: t.String(),
id: t.Numeric(),
filename: t.String(),
}),
},
)
.get(
"/:repo/releases/:id/source/:filename",
async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const release = await db
.selectFrom("releases")
.select(["id", "include_source_code"])
.where("repo_id", "=", repo.id)
.where("id", "=", params.id)
.executeTakeFirst();
if (!release || !release.include_source_code)
return new Response("Not found", { status: 404 });
const safeFilename = path.basename(params.filename);
const filePath = path.join(
RELEASES_DIR,
String(release.id),
"source",
safeFilename,
);
const file = Bun.file(filePath);
if (!(await file.exists()))
return new Response("Not found", { status: 404 });
return new Response(file, {
headers: {
"Content-Disposition": `attachment; filename="${safeFilename}"`,
"Content-Type": "application/octet-stream",
},
});
},
{
params: t.Object({
repo: t.String(),
id: t.Numeric(),
filename: t.String(),
}),
},
);
▾Asrc/routes/repos.tsx
@@ -0,0 +1,617 @@
import { rmSync } from "node:fs";
import path from "node:path";
import { Elysia, t } from "elysia";
import { fileTypeFromBuffer } from "file-type";
import {
COMMITS_PER_PAGE,
REPOS_PER_PAGE,
VALID_REPO_NAME_RE,
} from "../constants.ts";
import { db } from "../db/index.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { git, repoPath } from "../services/git.ts";
import {
hasBinaryContent,
prepareDiff,
serveFile,
} from "../services/highlightWorker.ts";
import { renderMarkdown } from "../services/markdown.ts";
import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
import { html } from "../views/render.tsx";
import { CommitDetail } from "../views/repos/CommitDetail.tsx";
import { CommitLog } from "../views/repos/CommitLog.tsx";
import { FileBlob } from "../views/repos/FileBlob.tsx";
import { FileTree } from "../views/repos/FileTree.tsx";
import { NewRepo } from "../views/repos/NewRepo.tsx";
import { RepoHome } from "../views/repos/RepoHome.tsx";
import { RepoList } from "../views/repos/RepoList.tsx";
import { RepoSettings } from "../views/repos/RepoSettings.tsx";
async function getRepo(name: string, isAdmin: boolean) {
if (!repoDiskExists(name)) return null;
const repo = await ensureRepoRecord(name);
if (repo.is_private && !isAdmin) return null;
return repo;
}
async function mimeForContent(content: Buffer): Promise<string> {
const result = await fileTypeFromBuffer(content);
if (result) return result.mime;
return hasBinaryContent(content.subarray(0, 8000))
? "text/plain; charset=utf-8"
: "application/octet-stream";
}
async function readReadme(
repo: string,
ref: string,
dir = "",
): Promise<Buffer | null> {
const prefix = dir ? `${dir}/` : "";
const [md, mdLc, readme, readmeLc] = await Promise.all([
git.show(repo, ref, `${prefix}README.md`),
git.show(repo, ref, `${prefix}readme.md`),
git.show(repo, ref, `${prefix}README`),
git.show(repo, ref, `${prefix}readme`),
]);
return md ?? mdLc ?? readme ?? readmeLc;
}
export const repoRoutes = new Elysia()
.guard({
cookie: t.Cookie({ session: t.Optional(t.String()) }),
})
.get(
"/",
async ({ cookie, query }) => {
const user = await resolveSession(cookie.session.value);
const search = query.q?.trim() || undefined;
const page = Math.max(1, query.page ?? 1);
const isAdmin = user?.isAdmin ?? false;
const countResult = await db
.selectFrom("repositories")
.select(db.fn.countAll<number>().as("count"))
.where((eb) =>
isAdmin
? eb.or([
eb("is_private", "=", 0),
eb("is_private", "=", 1),
])
: eb("is_private", "=", 0),
)
.$if(!!search, (qb) =>
qb.where((eb) =>
eb.or([
eb("name", "like", `%${search}%`),
eb("description", "like", `%${search}%`),
]),
),
)
.executeTakeFirst();
const totalCount = Number(countResult?.count ?? 0);
const totalPages = Math.max(
1,
Math.ceil(totalCount / REPOS_PER_PAGE),
);
const safePage = Math.min(page, totalPages);
const repos = await db
.selectFrom("repositories")
.selectAll()
.where((eb) =>
isAdmin
? eb.or([
eb("is_private", "=", 0),
eb("is_private", "=", 1),
])
: eb("is_private", "=", 0),
)
.$if(!!search, (qb) =>
qb.where((eb) =>
eb.or([
eb("name", "like", `%${search}%`),
eb("description", "like", `%${search}%`),
]),
),
)
.orderBy("created_at", "desc")
.limit(REPOS_PER_PAGE)
.offset((safePage - 1) * REPOS_PER_PAGE)
.execute();
const searchParam = search
? `&q=${encodeURIComponent(search)}`
: "";
const pagination = {
page: safePage,
totalPages,
pageUrlTemplate: `/?page={page}${searchParam}`,
};
return html(
<RepoList
user={user}
repos={repos}
search={search}
pagination={pagination}
/>,
);
},
{
query: t.Object({
q: t.Optional(t.String()),
page: t.Optional(t.Numeric()),
}),
},
)
.get("/new", async ({ cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
return html(<NewRepo user={user!} />);
})
.post(
"/new",
async ({ body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const { name, description, is_private, default_branch } = body;
if (!VALID_REPO_NAME_RE.test(name)) {
return html(
<NewRepo user={user!} error="Invalid repository name" />,
);
}
const branch = (default_branch?.trim() || "main").replace(
/[^a-zA-Z0-9._/-]/g,
"",
);
const existing = await db
.selectFrom("repositories")
.select("id")
.where("name", "=", name)
.executeTakeFirst();
if (existing) {
return html(
<NewRepo
user={user!}
error="Repository name already taken"
/>,
);
}
const now = new Date().toISOString();
await db
.insertInto("repositories")
.values({
name,
description: description || null,
is_private: is_private === "1" ? 1 : 0,
default_branch: branch,
created_at: now,
})
.execute();
// Initialise the git repo after the DB record is committed. If
// git.init fails we roll back the DB record so the two stay in sync.
try {
await git.init(name, branch);
} catch (err) {
await db
.deleteFrom("repositories")
.where("name", "=", name)
.execute();
throw err;
}
return new Response(null, {
status: 302,
headers: { Location: `/${name}` },
});
},
{
body: t.Object({
name: t.String(),
description: t.Optional(t.String()),
is_private: t.Optional(t.String()),
default_branch: t.Optional(t.String()),
}),
},
)
.get("/:repo", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const hasContent = await git.hasCommits(repo.name);
let readmeHtml: string | null = null;
let entries: Awaited<ReturnType<typeof git.lsTree>> = [];
let branches: string[] = [];
if (hasContent) {
const [lsResult, branchResult, resolved] = await Promise.all([
git.lsTree(repo.name, repo.default_branch),
git.branches(repo.name),
git.resolveRef(repo.name, repo.default_branch),
]);
entries = lsResult;
branches = branchResult;
const readmeBuf = await readReadme(repo.name, repo.default_branch);
if (readmeBuf) {
const key = resolved
? `readme:${repo.name}:${resolved}:`
: undefined;
readmeHtml = renderMarkdown(readmeBuf.toString("utf-8"), key);
}
}
return html(
<RepoHome
user={user}
repo={repo}
entries={entries}
readmeHtml={readmeHtml}
hasContent={hasContent}
branches={branches}
/>,
);
})
.get(
"/:repo/branch-switch",
async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const ref = query.ref?.trim();
if (!ref)
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}` },
});
const view = query.view;
const subpath = query.path ?? "";
if (view === "commits") {
return new Response(null, {
status: 302,
headers: { Location: `/${repo.name}/commits/${ref}` },
});
}
if (view === "blob" && subpath) {
return new Response(null, {
status: 302,
headers: {
Location: `/${repo.name}/blob/${ref}/${subpath}`,
},
});
}
const location = subpath
? `/${repo.name}/tree/${ref}/${subpath}`
: `/${repo.name}/tree/${ref}`;
return new Response(null, {
status: 302,
headers: { Location: location },
});
},
{
query: t.Object({
ref: t.Optional(t.String()),
view: t.Optional(t.String()),
path: t.Optional(t.String()),
}),
},
)
.get("/:repo/tree/:ref", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const resolved = await git.resolveRef(repo.name, params.ref);
if (!resolved) return new Response("Not found", { status: 404 });
const [entries, branches] = await Promise.all([
git.lsTree(repo.name, params.ref),
git.branches(repo.name),
]);
const readmeBuf = await readReadme(repo.name, params.ref);
const readmeHtml = readmeBuf
? renderMarkdown(
readmeBuf.toString("utf-8"),
`readme:${repo.name}:${resolved}:`,
)
: null;
return html(
<FileTree
user={user}
repo={repo}
ref={params.ref}
subpath=""
entries={entries}
branches={branches}
readmeHtml={readmeHtml}
/>,
);
})
.get("/:repo/tree/:ref/*", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const resolved = await git.resolveRef(repo.name, params.ref);
if (!resolved) return new Response("Not found", { status: 404 });
const subpath = params["*"];
const [entries, branches] = await Promise.all([
git.lsTree(repo.name, params.ref, subpath),
git.branches(repo.name),
]);
if (entries.length === 0) {
// Could be a file — redirect to blob
return new Response(null, {
status: 302,
headers: {
Location: `/${repo.name}/blob/${params.ref}/${subpath}`,
},
});
}
const readmeBuf = await readReadme(repo.name, params.ref, subpath);
const readmeHtml = readmeBuf
? renderMarkdown(
readmeBuf.toString("utf-8"),
`readme:${repo.name}:${resolved}:${subpath}`,
)
: null;
return html(
<FileTree
user={user}
repo={repo}
ref={params.ref}
subpath={subpath}
entries={entries}
branches={branches}
readmeHtml={readmeHtml}
/>,
);
})
.get("/:repo/blob/:ref/*", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const filePath = params["*"];
const [content, branches, commitSHA] = await Promise.all([
git.show(repo.name, params.ref, filePath),
git.branches(repo.name),
git.resolveRef(repo.name, params.ref),
]);
if (!content || !commitSHA)
return new Response("Not found", { status: 404 });
const filename = path.basename(filePath);
const view = await serveFile(
content,
filename,
`${repo.name}:${commitSHA}:${filePath}`,
);
return html(
<FileBlob
user={user}
repo={repo}
ref={params.ref}
filePath={filePath}
view={view}
branches={branches}
/>,
);
})
.get("/:repo/raw/:ref/*", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const filePath = params["*"];
const content = await git.show(repo.name, params.ref, filePath);
if (!content) return new Response("Not found", { status: 404 });
const filename = path.basename(filePath);
const contentType = await mimeForContent(content);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${filename}"`,
"Content-Length": String(content.length),
},
});
})
.get(
"/:repo/commits/:ref",
async ({ params, cookie, query }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
// Cursor-based pagination — O(1) regardless of history depth.
// `after` = SHA of the last commit on the previous page (resume cursor).
// `prev` = the `after` value used on the page that linked here, so we can
// reconstruct a "← Newer" link without a full history traversal.
const after = query.after?.trim() || null;
const prev = query.prev?.trim() || null;
const [rawCommits, branches] = await Promise.all([
// When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
// then fetch LIMIT+1 to detect whether another page exists.
after
? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
: git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
git.branches(repo.name),
]);
const hasNext = rawCommits.length > COMMITS_PER_PAGE;
const commits = rawCommits.slice(0, COMMITS_PER_PAGE);
// Build cursor URLs.
// "Older" advances past the last commit on this page.
// "Newer" goes back one page using the `prev` cursor saved in the URL,
// or to the first page if we're on page 2.
const base = `/${repo.name}/commits/${params.ref}`;
const olderUrl = hasNext
? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}`
: null;
const newerUrl = after
? prev
? `${base}?after=${prev}`
: base
: null;
return html(
<CommitLog
user={user}
repo={repo}
ref={params.ref}
commits={commits}
branches={branches}
olderUrl={olderUrl}
newerUrl={newerUrl}
/>,
);
},
{
query: t.Object({
after: t.Optional(t.String()),
prev: t.Optional(t.String()),
}),
},
)
.get("/:repo/commit/:sha", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const [meta, rawDiff] = await Promise.all([
git.commitMeta(repo.name, params.sha),
git.diff(repo.name, params.sha),
]);
if (!meta) return new Response("Commit not found", { status: 404 });
const files = await prepareDiff(
rawDiff,
`commit:${repo.name}:${params.sha}`,
);
return html(
<CommitDetail
user={user}
repo={repo}
sha={params.sha}
meta={meta}
files={files}
/>,
);
})
.get("/:repo/settings", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const branches = await git.branches(repo.name);
return html(
<RepoSettings user={user!} repo={repo} branches={branches} />,
);
})
.post(
"/:repo/settings",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const { description, is_private, default_branch } = body;
const branches = await git.branches(repo.name);
const newBranch = default_branch?.trim() || repo.default_branch;
// Validate the selected branch exists (only if repo has commits)
if (branches.length > 0 && !branches.includes(newBranch)) {
return html(
<RepoSettings
user={user!}
repo={repo}
branches={branches}
error={`Branch "${newBranch}" does not exist.`}
/>,
);
}
await db
.updateTable("repositories")
.set({
description: description?.trim() || null,
is_private: is_private === "1" ? 1 : 0,
default_branch: newBranch,
})
.where("id", "=", repo.id)
.execute();
// Keep git HEAD in sync if the branch actually exists
if (branches.includes(newBranch)) {
await git.setHead(repo.name, newBranch).catch(() => {});
}
const updated = await db
.selectFrom("repositories")
.selectAll()
.where("id", "=", repo.id)
.executeTakeFirstOrThrow();
return html(
<RepoSettings
user={user!}
repo={updated}
branches={branches}
success="Settings saved."
/>,
);
},
{
body: t.Object({
description: t.Optional(t.String()),
is_private: t.Optional(t.String()),
default_branch: t.Optional(t.String()),
}),
},
)
.post("/:repo/settings/delete", async ({ params, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
// Remove the on-disk repo first. If this fails (e.g. permission error),
// we abort before touching the DB so the repo remains accessible.
rmSync(repoPath(repo.name), { recursive: true, force: true });
await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
return new Response(null, { status: 302, headers: { Location: "/" } });
});
▾Asrc/routes/settings.tsx
@@ -0,0 +1,433 @@
import * as argon2 from "argon2";
import { Elysia, t } from "elysia";
import { utils as sshUtils } from "ssh2";
import {
ADMIN_USERNAME,
VALID_KEY_TYPES,
VALID_USERNAME_RE,
} from "../constants.ts";
import { db } from "../db";
import { redirect } from "../lib/redirect.ts";
import { resolveSession } from "../middleware/session.ts";
import { createDefaultAvatar } from "../services/avatar.ts";
import { fingerprintFromLine } from "../services/sshServer.ts";
import { html } from "../views/render.tsx";
import { Settings } from "../views/Settings.tsx";
export const settingsRoutes = new Elysia()
.guard({
cookie: t.Cookie({
session: t.Optional(t.String()),
theme: t.Optional(t.String()),
}),
})
.get(
"/settings",
async ({ cookie, query }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
const { success, error } = query;
const userRow = await db
.selectFrom("users")
.select(["id", "password_hash"])
.where("id", "=", user.id)
.executeTakeFirst();
const passkeys = await db
.selectFrom("passkeys")
.select(["id", "created_at"])
.where("user_id", "=", user.id)
.execute();
const sshKeys = await db
.selectFrom("ssh_keys")
.select(["id", "name", "fingerprint", "created_at"])
.where("user_id", "=", user.id)
.execute();
const theme = (cookie.theme?.value as string | undefined) ?? "auto";
const hasPassword = !!userRow?.password_hash;
const decodedError = error
? decodeURIComponent((error as string).replace(/\+/g, " "))
: null;
return html(
<Settings
user={user}
hasPassword={hasPassword}
passkeys={passkeys}
sshKeys={sshKeys}
theme={theme}
success={(success as string | null) ?? null}
error={decodedError}
/>,
);
},
{
query: t.Object({
success: t.Optional(t.String()),
error: t.Optional(t.String()),
}),
},
)
.post(
"/settings/password",
async ({ cookie, body }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
const { current_password, new_password, confirm_password } = body;
if (!new_password || new_password.length < 8) {
return redirect(
"/settings?error=Password+must+be+at+least+8+characters",
);
}
if (new_password !== confirm_password) {
return redirect("/settings?error=Passwords+do+not+match");
}
const userRow = await db
.selectFrom("users")
.select(["id", "password_hash"])
.where("id", "=", user.id)
.executeTakeFirst();
if (userRow?.password_hash) {
if (!current_password) {
return redirect(
"/settings?error=Current+password+is+required",
);
}
const valid = await argon2.verify(
userRow.password_hash,
current_password,
);
if (!valid) {
return redirect(
"/settings?error=Current+password+is+incorrect",
);
}
}
const hash = await argon2.hash(new_password);
await db
.updateTable("users")
.set({ password_hash: hash })
.where("id", "=", user.id)
.execute();
return redirect("/settings?success=password");
},
{
body: t.Object({
current_password: t.Optional(t.String()),
new_password: t.String(),
confirm_password: t.String(),
}),
},
)
.post(
"/settings/password/remove",
async ({ cookie }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
const passkeys = await db
.selectFrom("passkeys")
.select(["id"])
.where("user_id", "=", user.id)
.execute();
if (passkeys.length === 0) {
return redirect(
"/settings?error=Cannot+remove+password+without+a+passkey",
);
}
await db
.updateTable("users")
.set({ password_hash: null })
.where("id", "=", user.id)
.execute();
return redirect("/settings?success=password_removed");
},
{
body: t.Object({}),
},
)
.post(
"/settings/passkey/revoke",
async ({ cookie, body }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
const { id } = body;
const passkey = await db
.selectFrom("passkeys")
.select(["id", "user_id"])
.where("id", "=", id)
.executeTakeFirst();
if (!passkey || passkey.user_id !== user.id) {
return redirect("/settings?error=Passkey+not+found");
}
const userRow = await db
.selectFrom("users")
.select(["password_hash"])
.where("id", "=", user.id)
.executeTakeFirst();
const hasPassword = !!userRow?.password_hash;
// Wrap the count check and delete in a transaction so that two
// concurrent revocations can't both pass the "last auth method"
// guard and both succeed.
let lastAuthMethod = false;
await db.transaction().execute(async (trx) => {
const allPasskeys = await trx
.selectFrom("passkeys")
.select(["id"])
.where("user_id", "=", user.id)
.execute();
const remaining = allPasskeys.filter((p) => p.id !== id);
if (!hasPassword && remaining.length === 0) {
lastAuthMethod = true;
return;
}
await trx.deleteFrom("passkeys").where("id", "=", id).execute();
});
if (lastAuthMethod) {
return redirect(
"/settings?error=Cannot+revoke+last+auth+method",
);
}
return redirect("/settings?success=passkey_revoked");
},
{
body: t.Object({ id: t.Numeric() }),
},
)
.post(
"/settings/theme",
async ({ cookie, body }) => {
const _user = await resolveSession(
cookie.session?.value as string | undefined,
);
// Theme can be set even without auth, but we check session for settings redirect
const { theme } = body;
if (!["auto", "light", "dark"].includes(theme)) {
return redirect("/settings?error=Invalid+theme");
}
const cookieHeader = `theme=${theme}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`;
return redirect("/settings?success=theme", cookieHeader);
},
{
body: t.Object({ theme: t.String() }),
},
)
.post(
"/admin/users",
async ({ cookie, body }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
if (!user.isAdmin)
return new Response("Forbidden", { status: 403 });
const { username, password } = body;
if (!VALID_USERNAME_RE.test(username)) {
return redirect(
"/settings?error=Username+may+only+contain+letters,+numbers,+hyphens,+and+underscores",
);
}
if (username === ADMIN_USERNAME) {
return redirect("/settings?error=That+username+is+reserved");
}
if (!password || password.length < 8) {
return redirect(
"/settings?error=Password+must+be+at+least+8+characters",
);
}
const existing = await db
.selectFrom("users")
.select("id")
.where("username", "=", username)
.executeTakeFirst();
if (existing) {
return redirect("/settings?error=Username+already+taken");
}
const hash = await argon2.hash(password);
const now = new Date().toISOString();
const result = await db
.insertInto("users")
.values({
username,
password_hash: hash,
created_at: now,
})
.executeTakeFirstOrThrow();
await createDefaultAvatar(Number(result.insertId), username);
return redirect("/settings?success=user_created");
},
{
body: t.Object({ username: t.String(), password: t.String() }),
},
)
.post(
"/admin/users/delete",
async ({ cookie, body }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
if (!user.isAdmin)
return new Response("Forbidden", { status: 403 });
const { username } = body;
if (username === ADMIN_USERNAME) {
return redirect("/settings?error=Cannot+delete+admin+user");
}
const targetUser = await db
.selectFrom("users")
.select("id")
.where("username", "=", username)
.executeTakeFirst();
if (!targetUser) {
return redirect("/settings?error=User+not+found");
}
await db
.deleteFrom("users")
.where("id", "=", targetUser.id)
.execute();
return redirect("/settings?success=user_deleted");
},
{
body: t.Object({ username: t.String() }),
},
)
.post(
"/settings/ssh-keys",
async ({ cookie, body }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
const { name, public_key } = body;
const keyLine = public_key.trim();
const parts = keyLine.split(/\s+/);
if (!VALID_KEY_TYPES.has(parts[0] ?? "")) {
return redirect("/settings?error=Unsupported+key+type");
}
// Validate the key is parseable
try {
const parsed = sshUtils.parseKey(
Buffer.from(parts[1] ?? "", "base64"),
);
if (parsed instanceof Error) throw parsed;
} catch {
return redirect("/settings?error=Invalid+public+key");
}
const fingerprint = fingerprintFromLine(keyLine);
if (!fingerprint)
return redirect("/settings?error=Invalid+public+key");
try {
await db
.insertInto("ssh_keys")
.values({
user_id: user.id,
name: name.trim() || "Unnamed key",
public_key: keyLine,
fingerprint,
created_at: new Date().toISOString(),
})
.execute();
} catch (err) {
if (
err instanceof Error &&
err.message.includes(
"UNIQUE constraint failed: ssh_keys.fingerprint",
)
) {
return redirect(
"/settings?error=This+key+is+already+registered",
);
}
throw err;
}
return redirect("/settings?success=ssh_key_added");
},
{
body: t.Object({ name: t.String(), public_key: t.String() }),
},
)
.post(
"/settings/ssh-keys/delete",
async ({ cookie, body }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
const key = await db
.selectFrom("ssh_keys")
.select(["id", "user_id"])
.where("id", "=", body.id)
.executeTakeFirst();
if (!key || key.user_id !== user.id) {
return redirect("/settings?error=Key+not+found");
}
await db.deleteFrom("ssh_keys").where("id", "=", body.id).execute();
return redirect("/settings?success=ssh_key_deleted");
},
{
body: t.Object({ id: t.Numeric() }),
},
);
▾Asrc/services/avatar.ts
@@ -0,0 +1,123 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { encode } from "@jsquash/jxl";
import { fileTypeFromBuffer } from "file-type";
import sharp from "sharp";
import { AVATARS_DIR } from "../constants.ts";
mkdirSync(AVATARS_DIR, { recursive: true });
// FNV-1a: produces n deterministic bytes from a string
function hashBytes(s: string, n: number): number[] {
const out: number[] = [];
let h = 0x811c9dc5;
for (const c of s) {
h ^= c.charCodeAt(0);
h = Math.imul(h, 0x01000193) >>> 0;
}
while (out.length < n) {
h = Math.imul(h ^ (out.length & 0xff), 0x01000193) >>> 0;
out.push(
h & 0xff,
(h >>> 8) & 0xff,
(h >>> 16) & 0xff,
(h >>> 24) & 0xff,
);
}
return out.slice(0, n);
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
return Math.round(
(l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255,
);
};
return [f(0), f(8), f(4)];
}
export function avatarJxlPath(userId: number): string {
return path.join(AVATARS_DIR, `${userId}.jxl`);
}
export async function processAndStoreAvatar(
userId: number,
buffer: Buffer,
): Promise<void> {
const type = await fileTypeFromBuffer(buffer);
if (!type?.mime.startsWith("image/")) {
throw new Error("Invalid image type");
}
const { data, info } = await sharp(buffer)
.resize(128, 128, { fit: "cover", position: "center" })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const jxl = await encode(
{
data: new Uint8ClampedArray(
data.buffer,
data.byteOffset,
data.byteLength,
),
width: info.width,
height: info.height,
},
{
progressive: true,
quality: 90,
effort: 9,
},
);
await Bun.write(avatarJxlPath(userId), jxl);
}
export async function createDefaultAvatar(
userId: number,
username: string,
): Promise<void> {
const b = hashBytes(username, 16);
const hue = (b[0]! | (b[1]! << 8)) % 360;
const sat = (b[2]! % 20) + 65; // 65–84%
const lit = (b[3]! % 20) + 40; // 40–59%
const [fr, fg, fb] = hslToRgb(hue, sat, lit);
// 128×128, background #f0f0f0, 5×5 symmetric identicon
// PADDING=24, CELL=16: 2×24 + 5×16 = 128
const SIZE = 128,
PADDING = 24,
CELL = 16;
const pixels = new Uint8ClampedArray(SIZE * SIZE * 4).fill(255);
for (let i = 0; i < SIZE * SIZE * 4; i += 4) {
pixels[i] = 240;
pixels[i + 1] = 240;
pixels[i + 2] = 240;
}
// 5×5 symmetric identicon (col mapping: 0→0, 1→1, 2→2, 3→1, 4→0)
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 5; col++) {
const srcCol = col < 3 ? col : 4 - col;
if ((b[row * 3 + srcCol]! & 1) === 0) continue;
const x0 = PADDING + col * CELL;
const y0 = PADDING + row * CELL;
for (let y = y0; y < y0 + CELL; y++) {
for (let x = x0; x < x0 + CELL; x++) {
const i = (y * SIZE + x) * 4;
pixels[i] = fr;
pixels[i + 1] = fg;
pixels[i + 2] = fb;
}
}
}
}
const jxl = await encode({ data: pixels, width: SIZE, height: SIZE });
await Bun.write(avatarJxlPath(userId), jxl);
}
▾Asrc/services/diffHighlight.ts
@@ -0,0 +1,268 @@
import path from "node:path";
import type { BundledLanguage } from "shiki";
import { MAX_DIFF_CACHE } from "../constants.ts";
import { detectLang, getHighlighter } from "./highlight.ts";
// ─── Types ───────────────────────────────────────────────────────────────────
export type DiffStatus =
| "added"
| "deleted"
| "modified"
| "renamed"
| "copied";
export interface RenderedRow {
type: "add" | "del" | "context";
oldLine: number | null;
newLine: number | null;
html: string;
}
export interface RenderedHunk {
header: string;
rows: RenderedRow[];
}
export interface RenderedDiffFile {
oldPath: string;
newPath: string;
status: DiffStatus;
added: number;
removed: number;
isBinary: boolean;
hunks: RenderedHunk[];
}
// ─── Parser ──────────────────────────────────────────────────────────────────
interface ParsedLine {
type: "add" | "del" | "context";
content: string;
}
interface ParsedHunk {
header: string;
oldStart: number;
newStart: number;
lines: ParsedLine[];
}
export interface ParsedFile {
oldPath: string;
newPath: string;
status: DiffStatus;
added: number;
removed: number;
isBinary: boolean;
hunks: ParsedHunk[];
}
export function parseDiff(raw: string): ParsedFile[] {
const files: ParsedFile[] = [];
const allLines = raw.split("\n");
let i = 0;
while (i < allLines.length) {
if (!allLines[i]!.startsWith("diff --git ")) {
i++;
continue;
}
const m = allLines[i]!.match(/^diff --git a\/(.+) b\/(.+)$/);
const fallback = m ? (m[2] ?? "") : "";
const file: ParsedFile = {
oldPath: fallback,
newPath: fallback,
status: "modified",
added: 0,
removed: 0,
isBinary: false,
hunks: [],
};
i++;
while (i < allLines.length) {
const line = allLines[i]!;
if (line.startsWith("diff --git ") || line.startsWith("@@ ")) break;
if (line.startsWith("new file")) file.status = "added";
else if (line.startsWith("deleted file")) file.status = "deleted";
else if (line.startsWith("rename from ")) {
file.status = "renamed";
file.oldPath = line.slice(12);
} else if (line.startsWith("rename to ")) {
file.newPath = line.slice(10);
} else if (line.startsWith("copy from ")) {
file.status = "copied";
file.oldPath = line.slice(10);
} else if (line.startsWith("copy to ")) {
file.newPath = line.slice(8);
} else if (line.startsWith("--- ") && line !== "--- /dev/null")
file.oldPath = line.slice(6);
else if (line.startsWith("+++ ") && line !== "+++ /dev/null")
file.newPath = line.slice(6);
else if (line.startsWith("Binary files ")) file.isBinary = true;
i++;
}
while (i < allLines.length && allLines[i]!.startsWith("@@ ")) {
const hm = allLines[i]!.match(
/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/,
);
const hunk: ParsedHunk = {
header: allLines[i]!,
oldStart: hm ? parseInt(hm[1]!, 10) : 1,
newStart: hm ? parseInt(hm[2]!, 10) : 1,
lines: [],
};
i++;
while (
i < allLines.length &&
!allLines[i]!.startsWith("@@ ") &&
!allLines[i]!.startsWith("diff --git ")
) {
const l = allLines[i]!;
if (l.startsWith("+")) {
hunk.lines.push({ type: "add", content: l.slice(1) });
file.added++;
} else if (l.startsWith("-")) {
hunk.lines.push({ type: "del", content: l.slice(1) });
file.removed++;
} else if (l.startsWith(" ")) {
hunk.lines.push({ type: "context", content: l.slice(1) });
}
// skip "\ No newline at end of file"
i++;
}
file.hunks.push(hunk);
}
files.push(file);
}
return files;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ─── Highlight a hunk ────────────────────────────────────────────────────────
async function highlightHunk(
hunk: ParsedHunk,
lang: string,
): Promise<string[]> {
if (hunk.lines.length === 0) return [];
const code = hunk.lines.map((l) => l.content).join("\n");
try {
const h = await getHighlighter();
const tokensByLine = h.codeToTokensWithThemes(code, {
lang: lang as BundledLanguage,
themes: { light: "github-light", dark: "github-dark" },
});
const lines = tokensByLine.map((lineTokens) =>
lineTokens
.map((token) => {
const style = Object.entries(token.variants)
.map(
([theme, v]) =>
`--shiki-${theme}:${v.color ?? "inherit"}`,
)
.join(";");
return `<span style="${style}">${escapeHtml(token.content)}</span>`;
})
.join(""),
);
while (lines.length < hunk.lines.length) lines.push("");
return lines;
} catch {
return hunk.lines.map((l) => escapeHtml(l.content));
}
}
// ─── Build rendered rows ──────────────────────────────────────────────────────
function buildRows(hunk: ParsedHunk, highlighted: string[]): RenderedRow[] {
const rows: RenderedRow[] = [];
let oldLine = hunk.oldStart;
let newLine = hunk.newStart;
for (let i = 0; i < hunk.lines.length; i++) {
const { type } = hunk.lines[i]!;
const html = highlighted[i] ?? "";
if (type === "context") {
rows.push({ type, oldLine: oldLine++, newLine: newLine++, html });
} else if (type === "add") {
rows.push({ type, oldLine: null, newLine: newLine++, html });
} else {
rows.push({ type, oldLine: oldLine++, newLine: null, html });
}
}
return rows;
}
// ─── Highlight a single parsed file ──────────────────────────────────────────
export async function highlightFile(
file: ParsedFile,
): Promise<RenderedDiffFile> {
const displayPath = file.newPath || file.oldPath;
const lang = detectLang(path.basename(displayPath));
const highlightedHunks = await Promise.all(
file.hunks.map((hunk) => highlightHunk(hunk, lang)),
);
const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
header: hunk.header,
rows: buildRows(hunk, highlightedHunks[i]!),
}));
return {
oldPath: file.oldPath,
newPath: file.newPath,
status: file.status,
added: file.added,
removed: file.removed,
isBinary: file.isBinary,
hunks,
};
}
// ─── Public API ──────────────────────────────────────────────────────────────
const diffCache = new Map<string, RenderedDiffFile[]>();
export async function prepareDiff(
rawDiff: string,
cacheKey: string,
): Promise<RenderedDiffFile[]> {
const cached = diffCache.get(cacheKey);
if (cached) return cached;
const parsed = parseDiff(rawDiff);
const result = await Promise.all(
parsed.map(async (file) => {
const displayPath = file.newPath || file.oldPath;
const lang = detectLang(path.basename(displayPath));
const highlightedHunks = await Promise.all(
file.hunks.map((hunk) => highlightHunk(hunk, lang)),
);
const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
header: hunk.header,
rows: buildRows(hunk, highlightedHunks[i]!),
}));
return {
oldPath: file.oldPath,
newPath: file.newPath,
status: file.status,
added: file.added,
removed: file.removed,
isBinary: file.isBinary,
hunks,
};
}),
);
if (cacheKey) {
if (diffCache.size >= MAX_DIFF_CACHE)
diffCache.delete(diffCache.keys().next().value!);
diffCache.set(cacheKey, result);
}
return result;
}
▾Asrc/services/git.ts
@@ -0,0 +1,438 @@
import path from "node:path";
import { $ as _$ } from "bun";
import { REPOS_DIR } from "../constants.ts";
const $ = _$.env({ ...process.env, LC_ALL: "C", LANG: "C" });
// Per-repo mutex: prevents concurrent git write operations on the same repo
// (e.g. two patches being merged simultaneously, which would corrupt the index).
const repoWriteLocks = new Map<string, Promise<void>>();
async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
const prev = repoWriteLocks.get(name) ?? Promise.resolve();
let unlock!: () => void;
repoWriteLocks.set(
name,
prev.then(
() =>
new Promise<void>((res) => {
unlock = res;
}),
),
);
await prev;
try {
return await fn();
} finally {
unlock();
}
}
export function repoPath(name: string): string {
return path.join(REPOS_DIR, `${name}.git`);
}
export async function validateCommit(
repoName: string,
hash: string,
): Promise<boolean> {
const p = repoPath(repoName);
try {
const out = await $`git -C ${p} cat-file -t ${hash}`.text();
return out.trim() === "commit";
} catch {
return false;
}
}
export async function archiveRepo(
repoName: string,
commitHash: string,
slug: string,
outDir: string,
signal?: AbortSignal,
): Promise<void> {
const p = repoPath(repoName);
const base = `${slug}-${commitHash.slice(0, 8)}`;
const env = { ...process.env, LC_ALL: "C", LANG: "C" };
const zip = Bun.spawn(
[
"git",
"-C",
p,
"archive",
"--format=zip",
`--output=${path.join(outDir, `${base}.zip`)}`,
commitHash,
],
{ signal, env },
);
if ((await zip.exited) !== 0) throw new Error("git archive (zip) failed");
const tgz = Bun.spawn(
[
"git",
"-C",
p,
"archive",
"--format=tar.gz",
`--output=${path.join(outDir, `${base}.tar.gz`)}`,
commitHash,
],
{ signal, env },
);
if ((await tgz.exited) !== 0)
throw new Error("git archive (tar.gz) failed");
try {
const tar = Bun.spawn(
["git", "-C", p, "archive", "--format=tar", commitHash],
{ signal, env, stdout: "pipe" },
);
const zst = Bun.spawn(
["zstd", "-o", path.join(outDir, `${base}.tar.zst`)],
{ signal, env, stdin: tar.stdout },
);
await Promise.all([tar.exited, zst.exited]);
} catch {
// zstd not available — skip silently
}
}
export interface CommitEntry {
hash: string;
subject: string;
author: string;
date: string;
}
export interface CommitMeta {
hash: string;
subject: string;
body: string;
author: string;
email: string;
date: string;
parents: string[];
}
export interface TreeEntry {
mode: string;
type: "blob" | "tree";
hash: string;
size: string;
name: string;
}
function parseLog(out: string): CommitEntry[] {
return out
.split("\n")
.filter(Boolean)
.map((line) => {
const parts = line.split("\x1f");
return {
hash: parts[0] ?? "",
subject: parts[1] ?? "",
author: parts[2] ?? "",
date: parts[3] ?? "",
};
});
}
function parseLsTree(out: string): TreeEntry[] {
return out
.split("\n")
.filter(Boolean)
.map((line) => {
// format: <mode> SP <type> SP <object> SP <object size> TAB <file>
const tabIdx = line.indexOf("\t");
const name = line.slice(tabIdx + 1);
const meta = line.slice(0, tabIdx).trim().split(/\s+/);
return {
mode: meta[0] ?? "",
type: (meta[1] ?? "blob") as "blob" | "tree",
hash: meta[2] ?? "",
size: meta[3] ?? "-",
name,
};
});
}
function extractPatchSubject(patch: string): string {
for (const line of patch.split("\n").slice(0, 30)) {
if (line.startsWith("Subject: ")) {
// Strip "[PATCH ...] " prefix added by git format-patch
return line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
}
}
return "";
}
export const git = {
async init(name: string, branch = "main") {
return withRepoLock(name, async () => {
const p = repoPath(name);
await $`git init --bare --initial-branch=${branch} ${p}`;
});
},
async log(
name: string,
ref = "HEAD",
limit = 30,
skip = 0,
): Promise<CommitEntry[]> {
const p = repoPath(name);
try {
const out =
await $`git -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai --max-count=${limit} --skip=${skip}`.text();
return parseLog(out);
} catch {
return [];
}
},
async lsTree(
name: string,
ref: string,
subpath = "",
): Promise<TreeEntry[]> {
const p = repoPath(name);
try {
const args = subpath
? [
"git",
"-C",
p,
"ls-tree",
"--long",
ref,
"--",
`${subpath}/`,
]
: ["git", "-C", p, "ls-tree", "--long", ref];
const out = await $`${args}`.text();
const entries = parseLsTree(out);
if (subpath) {
// git ls-tree returns full paths like "subpath/name" — strip the prefix
const prefix = `${subpath}/`;
return entries.map((e) => ({
...e,
name: e.name.startsWith(prefix)
? e.name.slice(prefix.length)
: e.name,
}));
}
return entries;
} catch {
return [];
}
},
async show(
name: string,
ref: string,
filePath: string,
): Promise<Buffer | null> {
const p = repoPath(name);
try {
const buf =
await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer();
return Buffer.from(buf);
} catch {
return null;
}
},
async diff(name: string, sha: string): Promise<string> {
const p = repoPath(name);
try {
return await $`git -C ${p} diff-tree --no-commit-id -r -p --root ${sha}`.text();
} catch {
return "";
}
},
async branches(name: string): Promise<string[]> {
const p = repoPath(name);
try {
// %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
const fmt = "%(refname:short)";
const out = await $`git -C ${p} branch --format=${fmt}`.text();
return out.split("\n").filter(Boolean);
} catch {
return [];
}
},
async defaultBranch(name: string): Promise<string> {
const p = repoPath(name);
try {
const fmt = "%(refname:short)";
const branchesOut =
await $`git -C ${p} branch --format=${fmt}`.text();
const branches = branchesOut.split("\n").filter(Boolean);
// Read what HEAD points to (may be an unborn branch).
let headBranch: string | null = null;
try {
const out =
await $`git -C ${p} symbolic-ref --short HEAD`.text();
headBranch = out.trim();
} catch {
// detached HEAD — fall through
}
// Only trust HEAD if it names a branch that actually exists.
if (headBranch && branches.includes(headBranch)) {
return headBranch;
}
// HEAD points to an unborn branch or is detached — prefer "main",
// then "master", then whatever branch exists first.
return (
branches.find((b) => b === "main") ??
branches.find((b) => b === "master") ??
branches[0] ??
"main"
);
} catch {
return "main";
}
},
async getFileSize(
name: string,
ref: string,
filePath: string,
): Promise<number | null> {
const p = repoPath(name);
try {
const out =
await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
return parseInt(out.trim(), 10);
} catch {
return null;
}
},
async checkPatch(
name: string,
patchContent: string,
): Promise<{ clean: boolean; output: string }> {
const p = repoPath(name);
const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
try {
await Bun.write(tmpFile, patchContent);
// Bare repos have no working tree; populate the index from HEAD so we can
// check against git objects (--cached) rather than the filesystem.
await $`git -C ${p} read-tree HEAD`.quiet();
const result =
await $`git -C ${p} apply --check --cached ${tmpFile}`
.quiet()
.nothrow();
return {
clean: result.exitCode === 0,
output: result.stderr.toString(),
};
} catch (e) {
return { clean: false, output: String(e) };
} finally {
await $`rm -f ${tmpFile}`.quiet().nothrow();
}
},
async applyPatch(
name: string,
patchContent: string,
title: string,
description?: string,
): Promise<void> {
return withRepoLock(name, async () => {
const p = repoPath(name);
const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
try {
await Bun.write(tmpFile, patchContent);
// Populate index, apply to index, then create a real commit in the bare repo.
await $`git -C ${p} read-tree HEAD`;
await $`git -C ${p} apply --cached ${tmpFile}`;
const tree = (await $`git -C ${p} write-tree`.text()).trim();
const parent = (
await $`git -C ${p} rev-parse HEAD`.text()
).trim();
const fallback = description?.trim()
? `${title}\n\n${description.trim()}`
: title;
const msg = extractPatchSubject(patchContent) || fallback;
const commit = (
await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`.text()
).trim();
const ref = (
await $`git -C ${p} symbolic-ref HEAD`.text()
).trim();
await $`git -C ${p} update-ref ${ref} ${commit}`;
} finally {
await $`rm -f ${tmpFile}`.quiet().nothrow();
}
});
},
async setHead(name: string, branch: string): Promise<void> {
const p = repoPath(name);
await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
},
async resolveRef(name: string, ref: string): Promise<string | null> {
const p = repoPath(name);
try {
const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
return out.trim() || null;
} catch {
return null;
}
},
async hasCommits(name: string): Promise<boolean> {
const p = repoPath(name);
try {
const out = await $`git -C ${p} log --oneline -1`.quiet().text();
return out.trim().length > 0;
} catch {
return false;
}
},
async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
const p = repoPath(name);
try {
const [metaOut, msgOut] = await Promise.all([
$`git -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%P ${sha}`.text(),
$`git -C ${p} log --format=%B -1 ${sha}`.text(),
]);
const parts = metaOut.trim().split("\x1f");
const fullMsg = msgOut.trimEnd();
const firstNl = fullMsg.indexOf("\n");
const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
const body =
firstNl >= 0
? fullMsg
.slice(firstNl + 1)
.trimStart()
.trimEnd()
: "";
return {
hash: parts[0] ?? sha,
subject,
body,
author: parts[1] ?? "",
email: parts[2] ?? "",
date: parts[3] ?? "",
parents: (parts[4] ?? "").trim().split(/\s+/).filter(Boolean),
};
} catch {
return null;
}
},
};
▾Asrc/services/highlight.ts
@@ -0,0 +1,143 @@
import type { Language } from "linguist-languages";
import * as linguistLangs from "linguist-languages";
import {
bundledLanguages,
bundledLanguagesInfo,
createHighlighter,
type Highlighter,
} from "shiki";
import { INLINE_MAX_BYTES } from "../config.ts";
import { MAX_FILE_CACHE } from "../constants.ts";
let highlighter: Highlighter | null = null;
let extToLangId: Map<string, string> | null = null;
let filenameToLangId: Map<string, string> | null = null;
export async function highlightStartup(): Promise<void> {
console.log(
"[highlight] initializing highlighter and language detection maps...",
);
// Index linguist languages by lowercase name and aliases for precise lookup.
// Many shiki languages share the same tmScope (e.g. source.js has 31 entries),
// so scope-based matching is unreliable — name/alias matching is used instead.
const linguistByName = new Map<string, Language>();
for (const lang of Object.values(
linguistLangs as Record<string, Language>,
)) {
linguistByName.set(lang.name.toLowerCase(), lang);
for (const alias of lang.aliases ?? []) {
if (!linguistByName.has(alias.toLowerCase())) {
linguistByName.set(alias.toLowerCase(), lang);
}
}
}
const extMap = new Map<string, string>();
const fnMap = new Map<string, string>();
for (const { id, aliases } of bundledLanguagesInfo) {
// Match shiki lang → linguist language by ID then aliases (no scope fallback)
const linguistLang =
linguistByName.get(id) ??
aliases?.reduce<Language | undefined>(
(found, a) => found ?? linguistByName.get(a.toLowerCase()),
undefined,
);
if (!linguistLang) continue;
for (const ext of linguistLang.extensions ?? []) {
if (!extMap.has(ext)) extMap.set(ext, id);
}
for (const fn of linguistLang.filenames ?? []) {
if (!fnMap.has(fn)) fnMap.set(fn, id);
}
}
extToLangId = extMap;
filenameToLangId = fnMap;
highlighter = await createHighlighter({
themes: ["github-light", "github-dark"],
langs: Object.keys(bundledLanguages),
});
console.log(
`[highlight] ready: ${extMap.size} extensions, ${fnMap.size} filenames`,
);
}
export function getHighlighter(): Highlighter {
if (!highlighter) throw new Error("highlightStartup() has not been called");
return highlighter;
}
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional binary content detection
const BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
export function hasBinaryContent(buf: Buffer): boolean {
const sample = buf.subarray(0, 8000);
return BINARY_RE.test(sample.toString("binary"));
}
export function detectLang(filename: string): string {
if (!extToLangId || !filenameToLangId) return "text";
const base = filename.split("/").pop() ?? filename;
const ext = base.includes(".") ? base.slice(base.lastIndexOf(".")) : "";
return (
filenameToLangId.get(base) ??
(ext ? extToLangId.get(ext) : undefined) ??
"text"
);
}
export type FileView =
| { type: "inline"; html: string; lines: number }
| { type: "download"; size: number }
| { type: "binary"; size: number };
const fileCache = new Map<string, FileView>();
export async function serveFile(
content: Buffer,
filename: string,
cacheKey: string,
): Promise<FileView> {
const cached = fileCache.get(cacheKey);
if (cached) return cached;
const isBinary = hasBinaryContent(content);
if (isBinary) {
return { type: "binary", size: content.length };
}
if (content.length > INLINE_MAX_BYTES) {
return { type: "download", size: content.length };
}
const text = content.toString("utf-8");
const lines = text.split("\n").length;
let view: FileView;
try {
const h = getHighlighter();
const lang = detectLang(filename);
const html = h.codeToHtml(text, {
lang,
themes: { light: "github-light", dark: "github-dark" },
});
view = { type: "inline", html, lines };
} catch {
// fallback: plain pre
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
view = {
type: "inline",
html: `<pre class="code-plain"><code>${escaped}</code></pre>`,
lines,
};
}
if (cacheKey) {
if (fileCache.size >= MAX_FILE_CACHE)
fileCache.delete(fileCache.keys().next().value!);
fileCache.set(cacheKey, view);
}
return view;
}
▾Asrc/services/highlightWorker.ts
@@ -0,0 +1,122 @@
import { HIGHLIGHT_WORKERS } from "../config.ts";
import { MAX_DIFF_CACHE } from "../constants.ts";
import type { ParsedFile, RenderedDiffFile } from "./diffHighlight.ts";
import { parseDiff } from "./diffHighlight.ts";
import type { FileView } from "./highlight.ts";
import { hasBinaryContent } from "./highlight.ts";
export { hasBinaryContent };
// ─── Worker pool ──────────────────────────────────────────────────────────────
interface WorkerHandle {
worker: Worker;
ready: Promise<void>;
pending: number;
handlers: Map<
number,
{ resolve: (v: unknown) => void; reject: (e: unknown) => void }
>;
}
let nextId = 0;
function createHandle(): WorkerHandle {
const handlers = new Map<
number,
{ resolve: (v: unknown) => void; reject: (e: unknown) => void }
>();
const handle: WorkerHandle = {
worker: null!,
ready: null!,
pending: 0,
handlers,
};
handle.worker = new Worker(
new URL("../workers/highlight.worker.ts", import.meta.url).href,
);
handle.ready = new Promise<void>((resolve) => {
handle.worker.onmessage = (event: MessageEvent) => {
if (event.data.type === "ready") {
resolve();
handle.worker.onmessage = (e) => onMessage(handle, e);
return;
}
onMessage(handle, event);
};
});
return handle;
}
function onMessage(handle: WorkerHandle, event: MessageEvent) {
const { id, result, error } = event.data;
const p = handle.handlers.get(id);
if (!p) return;
handle.handlers.delete(id);
handle.pending--;
if (error !== undefined) p.reject(new Error(error));
else p.resolve(result);
}
const pool: WorkerHandle[] = Array.from(
{ length: HIGHLIGHT_WORKERS },
createHandle,
);
function leastBusy(): WorkerHandle {
return pool.reduce((a, b) => (a.pending <= b.pending ? a : b));
}
function request<T>(msg: Record<string, unknown>): Promise<T> {
const handle = leastBusy();
const id = nextId++;
handle.pending++;
return new Promise<T>((resolve, reject) => {
handle.handlers.set(id, {
resolve: resolve as (v: unknown) => void,
reject,
});
handle.ready.then(() => handle.worker.postMessage({ id, ...msg }));
});
}
// ─── Diff cache (lives on main thread now that dispatch is per-file) ──────────
const diffCache = new Map<string, RenderedDiffFile[]>();
// ─── Public API ───────────────────────────────────────────────────────────────
export function serveFile(
content: Buffer,
filename: string,
cacheKey: string,
): Promise<FileView> {
return request({
type: "serveFile",
content: content.buffer,
filename,
cacheKey,
});
}
export async function prepareDiff(
rawDiff: string,
cacheKey: string,
): Promise<RenderedDiffFile[]> {
const cached = diffCache.get(cacheKey);
if (cached) return cached;
const parsed = parseDiff(rawDiff);
const result = await Promise.all(
parsed.map((file: ParsedFile) =>
request<RenderedDiffFile>({ type: "highlightFile", file }),
),
);
if (cacheKey) {
if (diffCache.size >= MAX_DIFF_CACHE)
diffCache.delete(diffCache.keys().next().value!);
diffCache.set(cacheKey, result);
}
return result;
}
▾Asrc/services/markdown.ts
@@ -0,0 +1,139 @@
import DOMPurify from "isomorphic-dompurify";
import { Marked, marked, type Tokens } from "marked";
import { MAX_MD_CACHE } from "../constants.ts";
marked.setOptions({ gfm: true });
const mdCache = new Map<string, string>();
export function renderMarkdown(md: string, cacheKey?: string): string {
if (cacheKey) {
const cached = mdCache.get(cacheKey);
if (cached) return cached;
}
const raw = marked(md) as string;
const result = DOMPurify.sanitize(raw, {
ADD_TAGS: ["details", "summary"],
ADD_ATTR: ["class"],
});
if (cacheKey) {
if (mdCache.size >= MAX_MD_CACHE)
mdCache.delete(mdCache.keys().next().value!);
mdCache.set(cacheKey, result);
}
return result;
}
const _plaintextMarked = new Marked({ gfm: true });
_plaintextMarked.use({
renderer: {
// Block renderers
heading({ tokens }) {
return `${String(this.parser.parseInline(tokens))}\n\n`;
},
paragraph({ tokens }) {
return `${String(this.parser.parseInline(tokens))}\n\n`;
},
blockquote({ tokens }) {
return `${String(this.parser.parse(tokens))
.trim()
.split("\n")
.map((l) => `> ${l}`)
.join("\n")}\n\n`;
},
code({ text }) {
return `${text}\n\n`;
},
list(token) {
const start = typeof token.start === "number" ? token.start : 1;
let body = "";
for (let i = 0; i < token.items.length; i++) {
const item = token.items[i]!;
const prefix = token.ordered ? `${start + i}. ` : "- ";
const checkedPrefix = item.task
? item.checked
? "[x] "
: "[ ] "
: "";
const content = String(this.parser.parse(item.tokens))
.trim()
.replace(/\n+/g, " ");
body += `${prefix + checkedPrefix + content}\n`;
}
return `${body}\n`;
},
listitem() {
// Handled entirely inside list()
return "";
},
table(token: Tokens.Table) {
const cells = (row: Tokens.TableCell[]) =>
row
.map((c) => String(this.parser.parseInline(c.tokens)))
.join(" | ");
let out = `${cells(token.header)}\n`;
for (const row of token.rows) {
out += `${cells(row)}\n`;
}
return `${out}\n`;
},
hr() {
return "---\n\n";
},
html() {
return "";
},
// Inline renderers
strong({ tokens }) {
return String(this.parser.parseInline(tokens));
},
em({ tokens }) {
return String(this.parser.parseInline(tokens));
},
del({ tokens }) {
return String(this.parser.parseInline(tokens));
},
codespan({ text }) {
return `\`${text}\``;
},
link({ tokens }) {
return String(this.parser.parseInline(tokens));
},
image({ text, title }) {
const label = (title || text || "").trim();
return label ? `[Image: ${label}]` : "[Image]";
},
br() {
return "\n";
},
text(token) {
if ("tokens" in token && token.tokens) {
return String(this.parser.parseInline(token.tokens));
}
return token.text;
},
},
});
/** Convert markdown to plaintext, preserving structure (list prefixes, headings, etc.) */
export function markdownToPlaintext(md: string): string {
return (_plaintextMarked.parse(md) as string)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/**
* Returns a short single-line preview of a plaintext string:
* the first paragraph/heading line, truncated to maxLen chars.
*/
export function plaintextPreview(text: string, maxLen = 180): string {
const firstBlock = text.split("\n\n")[0]?.trim() ?? "";
const firstLine = firstBlock.split("\n")[0] ?? "";
if (firstLine.length <= maxLen) return firstLine;
const truncated = firstLine.slice(0, maxLen);
const lastSpace = truncated.lastIndexOf(" ");
return (
(lastSpace > maxLen * 0.6 ? truncated.slice(0, lastSpace) : truncated) +
"…"
);
}
▾Asrc/services/patchCache.ts
@@ -0,0 +1,15 @@
import { MAX_PATCH_CACHE } from "../constants.ts";
export type ApplyResult = { status: "clean" | "conflict"; output: string };
const cache = new Map<number, ApplyResult>();
export const patchCache = {
get: (id: number): ApplyResult | undefined => cache.get(id),
set: (id: number, result: ApplyResult) => {
if (cache.size >= MAX_PATCH_CACHE) {
cache.delete(cache.keys().next().value!);
}
cache.set(id, result);
},
invalidate: (id: number) => cache.delete(id),
};
▾Asrc/services/reactions.ts
@@ -0,0 +1,24 @@
interface ReactionRow {
emoji: string;
comment_id: number | null;
user_id: number;
}
export function buildReactionCounts(
reactions: ReactionRow[],
commentId: number | null,
userId: number | undefined,
): { emoji: string; count: number; userReacted: boolean }[] {
const filtered = reactions.filter((r) =>
commentId === null ? r.comment_id === null : r.comment_id === commentId,
);
const map = new Map<string, { count: number; userReacted: boolean }>();
for (const r of filtered) {
const entry = map.get(r.emoji) ?? { count: 0, userReacted: false };
entry.count++;
if (userId !== undefined && r.user_id === userId)
entry.userReacted = true;
map.set(r.emoji, entry);
}
return [...map.entries()].map(([emoji, d]) => ({ emoji, ...d }));
}
▾Asrc/services/repoSync.ts
@@ -0,0 +1,211 @@
import {
type Dirent,
existsSync,
readdirSync,
renameSync,
rmSync,
} from "node:fs";
import path from "node:path";
import { $ } from "bun";
import { RELEASES_DIR, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts";
import type { RepositoryRow } from "../db/index.ts";
import { db } from "../db/index.ts";
import { git, repoPath } from "../services/git.ts";
// Converts a non-bare repo to bare in-place by extracting the .git directory.
// Case 1: "myrepo/" — move myrepo/.git → myrepo.git/, delete myrepo/
// Case 2: "myrepo.git/" — move .git/ to a temp dir, delete myrepo.git/, rename temp → myrepo.git/
async function convertNonBareRepo(
entryName: string,
entryPath: string,
): Promise<void> {
const dotGitPath = path.join(entryPath, ".git");
const hasGitSuffix = entryName.endsWith(".git");
const baseName = hasGitSuffix ? entryName : `${entryName}.git`;
const targetPath = path.join(REPOS_DIR, baseName);
try {
if (hasGitSuffix) {
// Case 2: source and target path are the same dir — use a temp location.
const tmpPath = path.join(REPOS_DIR, `.${entryName}.bare_tmp`);
renameSync(dotGitPath, tmpPath);
rmSync(entryPath, { recursive: true, force: true });
renameSync(tmpPath, targetPath);
} else {
// Case 1: simple move.
renameSync(dotGitPath, targetPath);
rmSync(entryPath, { recursive: true, force: true });
}
const worktreesPath = path.join(targetPath, "worktrees");
if (existsSync(worktreesPath)) {
rmSync(worktreesPath, { recursive: true, force: true });
}
console.log(`Converted non-bare repo to bare: ${baseName}`);
} catch (err) {
console.error(`Failed to convert non-bare repo ${entryName}:`, err);
}
}
// Scans REPOS_DIR for non-bare repos and converts them before the main sync.
async function convertNonBareRepos(): Promise<void> {
let entries: Dirent[];
try {
entries = readdirSync(REPOS_DIR, { withFileTypes: true });
} catch {
return;
}
const conversions: Promise<void>[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const entryPath = path.join(REPOS_DIR, entry.name);
if (existsSync(path.join(entryPath, ".git"))) {
conversions.push(convertNonBareRepo(entry.name, entryPath));
}
}
await Promise.all(conversions);
}
export function listDiskRepoNames(): string[] {
try {
return readdirSync(REPOS_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.endsWith(".git"))
.map((e) => e.name.slice(0, -4));
} catch {
return [];
}
}
export function repoDiskExists(name: string): boolean {
return existsSync(repoPath(name));
}
export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
const existing = await db
.selectFrom("repositories")
.selectAll()
.where("name", "=", name)
.executeTakeFirst();
if (existing) return existing;
await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
const branch = await git.defaultBranch(name);
const now = new Date().toISOString();
return await db
.insertInto("repositories")
.values({
name,
description: null,
is_private: 0,
default_branch: branch,
created_at: now,
})
.returningAll()
.executeTakeFirstOrThrow();
}
export async function syncStartup(): Promise<void> {
await convertNonBareRepos();
const diskNames = new Set(listDiskRepoNames());
const dbRepos = await db
.selectFrom("repositories")
.select(["id", "name"])
.execute();
// Ensure all on-disk repos have DB records (e.g. repos pushed externally).
// Skip names that fail validation — they can't be served anyway.
const validDiskNames = [...diskNames].filter((n) =>
VALID_REPO_NAME_RE.test(n),
);
await Promise.all(validDiskNames.map((name) => ensureRepoRecord(name)));
const stale = dbRepos.filter((r) => !diskNames.has(r.name));
if (stale.length > 0) {
await db
.deleteFrom("repositories")
.where(
"id",
"in",
stale.map((r) => r.id),
)
.execute();
console.log(
`Removed ${stale.length} stale repo record(s): ${stale.map((r) => r.name).join(", ")}`,
);
}
// Release cleanup: sync DB records against on-disk release directories.
// Orphaned directories (no DB record) arise when the server crashes after
// files are written but before the transaction commits. Stale DB records
// (directory missing) arise when the server crashes after rmSync but before
// the DB delete during release deletion.
//
// Note: releases with no source code and no assets have no on-disk
// directory, so we only apply the stale-record check to releases that
// should have a directory (include_source_code=1 or has release_assets).
const allReleaseIds = new Set(
(await db.selectFrom("releases").select("id").execute()).map(
(r) => r.id,
),
);
try {
for (const entry of readdirSync(RELEASES_DIR, {
withFileTypes: true,
})) {
if (!entry.isDirectory()) continue;
const id = Number(entry.name);
if (!Number.isNaN(id) && !allReleaseIds.has(id)) {
rmSync(path.join(RELEASES_DIR, entry.name), {
recursive: true,
force: true,
});
console.log(
`Removed orphaned release directory: ${entry.name}`,
);
}
}
} catch {
// RELEASES_DIR may not exist yet on first run
}
// Only check for missing dirs on releases that should have one.
const releasesWithDirs = await db
.selectFrom("releases")
.select("releases.id")
.where((eb) =>
eb.or([
eb("releases.include_source_code", "=", 1),
eb.exists(
eb
.selectFrom("release_assets")
.select("release_assets.id")
.whereRef(
"release_assets.release_id",
"=",
"releases.id",
),
),
]),
)
.execute();
const staleReleases = releasesWithDirs.filter(
(r) => !existsSync(path.join(RELEASES_DIR, String(r.id))),
);
if (staleReleases.length > 0) {
await db
.deleteFrom("releases")
.where(
"id",
"in",
staleReleases.map((r) => r.id),
)
.execute();
console.log(
`Removed ${staleReleases.length} stale release record(s): ${staleReleases.map((r) => r.id).join(", ")}`,
);
}
}
▾Asrc/services/sshServer.ts
@@ -0,0 +1,160 @@
import { type ChildProcess, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { Server, utils } from "ssh2";
import { SSH_PORT } from "../config.ts";
import { ADMIN_USERNAME, REPOS_DIR, SSH_HOST_KEY_PATH } from "../constants.ts";
import { db } from "../db/index.ts";
/** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */
function fingerprintFromBytes(keyBytes: Buffer): string {
const hash = createHash("sha256")
.update(keyBytes)
.digest("base64")
.replace(/=+$/, "");
return `SHA256:${hash}`;
}
/**
* Compute fingerprint from a full public key line
* (e.g. "ssh-ed25519 AAAA... comment").
* Returns null if the line is malformed.
*/
export function fingerprintFromLine(pubkeyLine: string): string | null {
const parts = pubkeyLine.trim().split(/\s+/);
if (parts.length < 2) return null;
try {
const keyBytes = Buffer.from(parts[1] ?? "", "base64");
return fingerprintFromBytes(keyBytes);
} catch {
return null;
}
}
export async function startSshServer() {
if (!existsSync(SSH_HOST_KEY_PATH)) {
await Bun.spawn([
"ssh-keygen",
"-t",
"ed25519",
"-N",
"",
"-f",
SSH_HOST_KEY_PATH,
"-C",
"hearthforge-host",
]).exited;
console.log("Generated SSH host key at", SSH_HOST_KEY_PATH);
}
const hostKey = readFileSync(SSH_HOST_KEY_PATH);
const server = new Server({ hostKeys: [hostKey] }, (client) => {
let authedUser: { id: number; username: string } | null = null;
client.on("authentication", async (ctx) => {
if (ctx.method !== "publickey") {
return ctx.reject(["publickey"]);
}
// Probe phase: accept so the client proceeds to send a signature
if (!ctx.signature) return ctx.accept();
// Signature phase: look up the stored key by fingerprint
const fingerprint = fingerprintFromBytes(ctx.key.data);
const sshKey = await db
.selectFrom("ssh_keys")
.innerJoin("users", "users.id", "ssh_keys.user_id")
.select([
"users.id as userId",
"users.username",
"ssh_keys.public_key",
])
.where("ssh_keys.fingerprint", "=", fingerprint)
.executeTakeFirst();
if (!sshKey) return ctx.reject();
// Verify signature using the stored public key text (parseKey needs key file format, not raw bytes)
const parsed = utils.parseKey(sshKey.public_key);
if (parsed instanceof Error || Array.isArray(parsed))
return ctx.reject();
const verifyResult = parsed.verify(ctx.blob!, ctx.signature);
if (verifyResult !== true) return ctx.reject();
authedUser = { id: sshKey.userId, username: sshKey.username };
ctx.accept();
});
client.on("ready", () => {
client.on("session", (accept) => {
const session = accept();
session.on("exec", async (accept, reject, info) => {
// git sends: git-upload-pack '/reponame.git'
const match = info.command.match(
/^(git-upload-pack|git-receive-pack)\s+'?\/?([a-zA-Z0-9_.-]+?)(?:\.git)?'?$/,
);
if (!match) return reject();
const command = match[1]!;
const repoName = match[2]!;
const repo = await db
.selectFrom("repositories")
.select(["name", "is_private"])
.where("name", "=", repoName)
.executeTakeFirst();
if (!repo) return reject();
const repoPath = path.join(REPOS_DIR, `${repo.name}.git`);
const stream = accept();
if (command === "git-receive-pack") {
if (
!authedUser ||
authedUser.username !== ADMIN_USERNAME
) {
stream.stderr.write("error: push access denied\n");
stream.exit(128);
stream.end();
return;
}
}
if (repo.is_private && !authedUser) {
stream.stderr.write(
"error: repository access denied\n",
);
stream.exit(128);
stream.end();
return;
}
const proc: ChildProcess = spawn(command, [repoPath]);
stream.pipe(proc.stdin!);
proc.stdout?.pipe(stream, { end: false });
proc.stderr?.pipe(stream.stderr as NodeJS.WritableStream, {
end: false,
});
proc.on("close", (code: number | null) => {
stream.exit(code ?? 0);
stream.end();
});
stream.on("close", () => proc.kill());
});
});
});
client.on("error", () => {
/* absorb ECONNRESET etc. */
});
});
server.listen(SSH_PORT, "0.0.0.0", () => {
console.log(`SSH server listening on port ${SSH_PORT}`);
});
}
▾Asrc/views/Avatar.tsx
@@ -0,0 +1,42 @@
// 1×1 transparent GIF — used as img fallback so the browser never enters
// broken-image state while waiting for the JXL polyfill to decode the source.
const TRANSPARENT =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
export function Avatar({
userId,
version,
size = 32,
}: {
userId: number | null;
version?: number | null;
size?: number;
}) {
const style = `width:${size}px;height:${size}px`;
if (!userId) {
return (
<span
class="avatar avatar-placeholder"
style={`${style};font-size:${Math.round(size * 0.45)}px`}
>
?
</span>
);
}
const src =
version != null
? `/avatars/${userId}.jxl?v=${version}`
: `/avatars/${userId}.jxl`;
return (
<picture class="avatar" style={style}>
<source srcset={src} type="image/jxl" />
<img
src={TRANSPARENT}
alt=""
width={String(size)}
height={String(size)}
class="avatar-img"
/>
</picture>
);
}
▾Asrc/views/DateWithEdited.tsx
@@ -0,0 +1,19 @@
export function DateWithEdited({
date,
editedAt,
}: {
date: string;
editedAt: string | null;
}) {
const label = new Date(date).toLocaleDateString();
if (!editedAt) return <time datetime={date}>{label}</time>;
return (
<time
datetime={date}
class="edited-indicator"
title={`Edited ${new Date(editedAt).toLocaleString()}`}
>
*{label}
</time>
);
}
▾Asrc/views/DiffView.tsx
@@ -0,0 +1,287 @@
import type { RepositoryRow } from "../db/index.ts";
import type { RenderedDiffFile } from "../services/diffHighlight.ts";
export function slugify(s: string): string {
return `diff-${s.replace(/[^a-zA-Z0-9]/g, "-")}`;
}
export function statusLetter(status: string): string {
return (
(
{
added: "A",
deleted: "D",
modified: "M",
renamed: "R",
copied: "C",
} as Record<string, string>
)[status] ?? "M"
);
}
export type FileTreeNode =
| { type: "file"; file: RenderedDiffFile }
| { type: "dir"; children: Map<string, FileTreeNode> };
export function buildFileTree(
files: RenderedDiffFile[],
): Map<string, FileTreeNode> {
const root = new Map<string, FileTreeNode>();
for (const f of files) {
const p = f.newPath || f.oldPath;
const parts = p.split("/");
let current = root;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
if (!current.has(part)) {
current.set(part, { type: "dir", children: new Map() });
}
const node = current.get(part)!;
if (node.type === "dir") current = node.children;
}
current.set(parts[parts.length - 1]!, { type: "file", file: f });
}
return root;
}
export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element {
return (
<ul class="file-nav-list">
{[...tree.entries()]
.sort(([, a], [, b]) =>
a.type === b.type ? 0 : a.type === "dir" ? -1 : 1,
)
.map(([name, node]) =>
node.type === "dir" ? (
<li class="file-nav-dir">
<details open>
<summary class="file-nav-dir-toggle">
<span class="file-nav-toggle-icon">▾</span>
{name}/
</summary>
{renderFileTree(node.children)}
</details>
</li>
) : (
<li>
<a
href={`#${slugify(node.file.newPath || node.file.oldPath)}`}
class="file-nav-item"
title={node.file.newPath || node.file.oldPath}
>
<span
class={`file-nav-status file-status-${node.file.status}`}
>
{statusLetter(node.file.status)}
</span>
<span class="file-nav-name">{name}</span>
<span class="file-nav-stat">
{node.file.added > 0 && (
<span class="nav-add">
+{node.file.added}
</span>
)}
{node.file.removed > 0 && (
<span class="nav-del">
-{node.file.removed}
</span>
)}
</span>
</a>
</li>
),
)}
</ul>
);
}
interface DiffViewProps {
files: RenderedDiffFile[];
repo: RepositoryRow;
/** If provided, an extra "view at sha" link is shown in each file header. */
sha?: string;
}
export function DiffView({ files, repo, sha }: DiffViewProps) {
const totalAdded = files.reduce((s, f) => s + f.added, 0);
const totalRemoved = files.reduce((s, f) => s + f.removed, 0);
const changedStr = [
`${files.length} file${files.length !== 1 ? "s" : ""} changed`,
totalAdded > 0
? `${totalAdded} insertion${totalAdded !== 1 ? "s" : ""}(+)`
: "",
totalRemoved > 0
? `${totalRemoved} deletion${totalRemoved !== 1 ? "s" : ""}(-)`
: "",
]
.filter(Boolean)
.join(", ");
return (
<>
{files.length > 0 && (
<div class="commit-stats-bar">
<span class="commit-stats-text">{changedStr}</span>
</div>
)}
{files.length === 0 ? (
<p class="text-muted" style="margin-top: var(--space-6)">
No diff available.
</p>
) : (
<div class="commit-layout">
<aside class="commit-file-nav">
<details class="file-nav-details" open>
<summary class="file-nav-toggle">
<span class="file-nav-toggle-icon">▾</span>
<span>Files changed ({files.length})</span>
</summary>
{renderFileTree(buildFileTree(files))}
</details>
</aside>
<div class="commit-diffs">
{files.map((f) => {
const displayPath = f.newPath || f.oldPath;
const sl = statusLetter(f.status);
return (
<details
class="diff-file"
id={slugify(displayPath)}
open
>
<summary class="diff-file-header">
<div class="diff-file-header-left">
<span class="diff-file-toggle-icon">
▾
</span>
<span
class={`diff-status-badge diff-status-${f.status}`}
>
{sl}
</span>
<span class="diff-file-path mono">
{displayPath}
</span>
{f.status === "renamed" &&
f.oldPath !== f.newPath && (
<span class="diff-rename-arrow">
← {f.oldPath}
</span>
)}
</div>
<div class="diff-file-header-right">
{f.added > 0 && (
<span class="diff-stat-add">
+{f.added}
</span>
)}
{f.removed > 0 && (
<span class="diff-stat-del">
-{f.removed}
</span>
)}
{!f.isBinary &&
f.status !== "deleted" && (
<>
{sha && (
<a
href={`/${repo.name}/blob/${sha}/${displayPath}`}
class="btn btn-xs btn-secondary"
title={`View file at ${sha.slice(0, 7)}`}
>
@{" "}
{sha.slice(
0,
7,
)}
</a>
)}
<a
href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
class="btn btn-xs btn-secondary"
title={`View file at ${repo.default_branch}`}
>
@{" "}
{
repo.default_branch
}
</a>
</>
)}
</div>
</summary>
{f.isBinary ? (
<div class="diff-binary-notice">
Binary file — not shown
</div>
) : f.hunks.length === 0 ? (
<div class="diff-binary-notice">
No textual changes.
</div>
) : (
<div class="diff-file-body">
{f.hunks.map((hunk) => (
<div class="diff-hunk">
<table class="diff-table">
<thead>
<tr>
<th
colspan="4"
class="diff-hunk-header"
>
{
hunk.header
}
</th>
</tr>
</thead>
<tbody>
{hunk.rows.map(
(row) => (
<tr
class={`diff-row diff-row-${row.type}`}
>
<td class="diff-ln diff-ln-old">
{row.oldLine ??
""}
</td>
<td class="diff-ln diff-ln-new">
{row.newLine ??
""}
</td>
<td class="diff-sign">
{row.type ===
"add"
? "+"
: row.type ===
"del"
? "-"
: " "}
</td>
<td class="diff-code">
{
row.html
}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
))}
</div>
)}
</details>
);
})}
</div>
</div>
)}
</>
);
}
▾Asrc/views/Pagination.tsx
@@ -0,0 +1,40 @@
export interface PaginationInfo {
page: number;
totalPages: number;
/** URL template — use `{page}` as placeholder, e.g. "/repo/issues?status=open&page={page}" */
pageUrlTemplate: string;
}
interface PaginationProps extends PaginationInfo {}
export function Pagination({
page,
totalPages,
pageUrlTemplate,
}: PaginationProps) {
if (totalPages <= 1) return null;
const url = (p: number) => pageUrlTemplate.replace("{page}", String(p));
return (
<nav class="pagination" aria-label="Pagination">
<div class="pagination-prev">
{page > 1 && (
<a href={url(page - 1)} class="pagination-btn">
← Previous
</a>
)}
</div>
<span class="pagination-info">
Page {page} of {totalPages}
</span>
<div class="pagination-next">
{page < totalPages && (
<a href={url(page + 1)} class="pagination-btn">
Next →
</a>
)}
</div>
</nav>
);
}
▾Asrc/views/ReactionBar.tsx
@@ -0,0 +1,84 @@
import { ALLOWED_REACTIONS } from "../constants.ts";
import type { SessionUser } from "../middleware/session.ts";
export interface ReactionCount {
emoji: string;
count: number;
userReacted: boolean;
}
interface ReactionBarProps {
reactions: ReactionCount[];
postUrl: string;
commentId?: number;
user: SessionUser | null;
}
export function ReactionBar({
reactions,
postUrl,
commentId,
user,
}: ReactionBarProps) {
if (!user && reactions.length === 0) return null;
const existing = new Set(reactions.map((r) => r.emoji));
const all = [...ALLOWED_REACTIONS];
return (
<div class="reaction-bar">
{reactions.map((r) => (
<form method="POST" action={postUrl} class="reaction-form">
{commentId != null && (
<input
type="hidden"
name="comment_id"
value={String(commentId)}
/>
)}
<input type="hidden" name="emoji" value={r.emoji} />
<button
type="submit"
class={`reaction-btn${r.userReacted ? " reacted" : ""}`}
disabled={!user}
>
{r.emoji} {r.count}
</button>
</form>
))}
{user && (
<details class="reaction-picker">
<summary class="reaction-add-btn">+</summary>
<div class="reaction-picker-dropdown">
{all
.filter((e) => !existing.has(e))
.map((e) => (
<form
method="POST"
action={postUrl}
class="reaction-form"
>
{commentId != null && (
<input
type="hidden"
name="comment_id"
value={String(commentId)}
/>
)}
<input
type="hidden"
name="emoji"
value={e}
/>
<button
type="submit"
class="reaction-picker-btn"
>
{e}
</button>
</form>
))}
</div>
</details>
)}
</div>
);
}
▾Asrc/views/Settings.tsx
@@ -0,0 +1,469 @@
import type { SessionUser } from "../middleware/session.ts";
import { Avatar } from "./Avatar.tsx";
import { Layout } from "./layout.tsx";
interface SettingsProps {
user: SessionUser;
hasPassword: boolean;
passkeys: { id: number; created_at: string }[];
sshKeys: {
id: number;
name: string;
fingerprint: string;
created_at: string;
}[];
theme: string;
success: string | null;
error: string | null;
}
const successMessages: Record<string, string> = {
password: "Password updated.",
password_removed: "Password removed.",
passkey_revoked: "Passkey revoked.",
theme: "Theme preference saved.",
user_created: "Account created.",
user_deleted: "Account deleted.",
ssh_key_added: "SSH key added.",
ssh_key_deleted: "SSH key removed.",
};
export function Settings({
user,
hasPassword,
passkeys,
sshKeys,
theme,
success,
error,
}: SettingsProps) {
const successMsg = success ? (successMessages[success] ?? null) : null;
return (
<Layout user={user} title="Settings">
<div class="container container-narrow">
<h1 class="page-title">Settings</h1>
{successMsg && <p class="form-success">{successMsg}</p>}
{error && <p class="form-error">{error}</p>}
{/* Avatar */}
<div class="form-card">
<h2 class="section-title">Avatar</h2>
<div class="avatar-settings">
<Avatar
userId={user.id}
version={user.avatar_version}
size={80}
/>
<div class="avatar-actions">
<form
method="POST"
action="/settings/avatar"
enctype="multipart/form-data"
>
<div class="form-group">
<input
type="file"
name="avatar"
accept="image/*"
class="form-input"
required
/>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Upload avatar
</button>
</div>
</form>
<form
method="POST"
action="/settings/avatar/delete"
class="inline-form"
>
<button
type="submit"
class="btn btn-sm btn-ghost"
>
Remove avatar
</button>
</form>
</div>
</div>
</div>
{/* Appearance */}
<div class="form-card">
<h2 class="section-title">Appearance</h2>
<form method="POST" action="/settings/theme">
<div class="theme-options">
<label class="theme-option">
<input
type="radio"
name="theme"
value="auto"
checked={
theme === "auto" ? true : undefined
}
/>
Auto
</label>
<label class="theme-option">
<input
type="radio"
name="theme"
value="light"
checked={
theme === "light" ? true : undefined
}
/>
Light
</label>
<label class="theme-option">
<input
type="radio"
name="theme"
value="dark"
checked={
theme === "dark" ? true : undefined
}
/>
Dark
</label>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">
Save
</button>
</div>
</form>
</div>
{/* Password */}
<div class="form-card">
<h2 class="section-title">Password</h2>
<p class="text-muted">
{hasPassword ? "Password is set." : "No password set."}
</p>
<form
method="POST"
action="/settings/password"
class="settings-form"
>
{hasPassword && (
<div class="form-group">
<label
class="form-label"
for="current_password"
>
Current password
</label>
<input
class="form-input"
type="password"
id="current_password"
name="current_password"
autocomplete="current-password"
/>
</div>
)}
<div class="form-group">
<label class="form-label" for="new_password">
{hasPassword ? "New password" : "Password"}
</label>
<input
class="form-input"
type="password"
id="new_password"
name="new_password"
autocomplete="new-password"
/>
</div>
<div class="form-group">
<label class="form-label" for="confirm_password">
Confirm password
</label>
<input
class="form-input"
type="password"
id="confirm_password"
name="confirm_password"
autocomplete="new-password"
/>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">
{hasPassword
? "Change password"
: "Set password"}
</button>
</div>
</form>
{hasPassword && passkeys.length > 0 && (
<form
method="POST"
action="/settings/password/remove"
style="margin-top: var(--space-4)"
>
<button
class="btn btn-danger btn-sm"
type="submit"
onclick="return confirm('Remove password? You will need a passkey to sign in.')"
>
Remove password
</button>
</form>
)}
</div>
{/* Passkeys */}
<div class="form-card">
<h2 class="section-title">Passkeys</h2>
{passkeys.length > 0 && (
<div class="passkey-list">
{passkeys.map((pk) => (
<div class="passkey-item">
<span class="passkey-date">
Added{" "}
{new Date(
pk.created_at,
).toLocaleDateString()}
</span>
<form
method="POST"
action="/settings/passkey/revoke"
>
<input
type="hidden"
name="id"
value={String(pk.id)}
/>
<button
class="btn btn-danger btn-sm"
type="submit"
disabled={
!hasPassword &&
passkeys.length <= 1
? true
: undefined
}
title={
!hasPassword &&
passkeys.length <= 1
? "Register another auth method first"
: undefined
}
>
Revoke
</button>
</form>
</div>
))}
</div>
)}
<div id="passkey-section" style="display:none">
<button
id="add-passkey-btn"
class="btn btn-secondary"
type="button"
>
Add passkey
</button>
<p id="passkey-status" class="text-muted"></p>
</div>
<noscript>
<p class="text-muted">
Enable JavaScript to register or add passkeys.
</p>
</noscript>
</div>
{/* SSH Keys */}
<div class="form-card">
<h2 class="section-title">SSH Keys</h2>
{sshKeys.length > 0 && (
<div class="passkey-list">
{sshKeys.map((key) => (
<div class="passkey-item">
<div class="ssh-key-info">
<span class="ssh-key-name">
{key.name}
</span>
<span class="passkey-date ssh-key-fingerprint">
{key.fingerprint}
</span>
<span class="passkey-date">
Added{" "}
{new Date(
key.created_at,
).toLocaleDateString()}
</span>
</div>
<form
method="POST"
action="/settings/ssh-keys/delete"
>
<input
type="hidden"
name="id"
value={String(key.id)}
/>
<button
class="btn btn-danger btn-sm"
type="submit"
>
Remove
</button>
</form>
</div>
))}
</div>
)}
<form
method="POST"
action="/settings/ssh-keys"
class="settings-form"
style="margin-top: var(--space-4)"
>
<div class="form-group">
<label class="form-label" for="ssh_key_name">
Name
</label>
<input
class="form-input"
type="text"
id="ssh_key_name"
name="name"
placeholder="e.g. My laptop"
autocomplete="off"
/>
</div>
<div class="form-group">
<label class="form-label" for="ssh_public_key">
Public key
</label>
<textarea
class="form-input form-textarea"
id="ssh_public_key"
name="public_key"
placeholder="ssh-ed25519 AAAA..."
rows="3"
required
></textarea>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">
Add SSH key
</button>
</div>
</form>
</div>
{/* Admin: User Management */}
{user.isAdmin && (
<div class="form-card">
<h2 class="section-title">User Management</h2>
<h3>Create account</h3>
<form
method="POST"
action="/admin/users"
class="settings-form"
style="margin-top: var(--space-4)"
>
<div class="form-group">
<label class="form-label" for="new_username">
Username
</label>
<input
class="form-input"
type="text"
id="new_username"
name="username"
autocomplete="off"
/>
</div>
<div class="form-group">
<label
class="form-label"
for="new_user_password"
>
Password
</label>
<input
class="form-input"
type="password"
id="new_user_password"
name="password"
autocomplete="new-password"
/>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">
Create account
</button>
</div>
</form>
<h3 style="margin-top: var(--space-6)">
Delete account
</h3>
<form
method="POST"
action="/admin/users/delete"
class="settings-form"
style="margin-top: var(--space-4)"
>
<div class="form-group">
<label class="form-label" for="del_username">
Username
</label>
<input
class="form-input"
type="text"
id="del_username"
name="username"
autocomplete="off"
/>
</div>
<div class="form-actions">
<button class="btn btn-danger" type="submit">
Delete account
</button>
</div>
</form>
</div>
)}
</div>
<script type="module">{`
import { startRegistration } from '/assets/simplewebauthn-browser.js';
document.getElementById('passkey-section').style.display = 'block';
document.getElementById('add-passkey-btn').addEventListener('click', async () => {
const status = document.getElementById('passkey-status');
try {
status.textContent = 'Starting...';
const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' });
const opts = await optsResp.json();
const result = await startRegistration({ optionsJSON: opts });
const verResp = await fetch('/auth/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
});
if (verResp.ok) {
window.location.reload();
} else {
const err = await verResp.json();
status.textContent = 'Error: ' + (err.error ?? 'Registration failed');
}
} catch (e) {
status.textContent = 'Error: ' + e.message;
}
});
`}</script>
</Layout>
);
}
▾Asrc/views/auth/Login.tsx
@@ -0,0 +1,80 @@
import { Layout } from "../layout.tsx";
interface LoginProps {
error?: string;
}
export function Login({ error }: LoginProps) {
return (
<Layout user={null} title="Sign in">
<div class="auth-container">
<h1 class="page-title">Sign in</h1>
{error && <p class="form-error">{error}</p>}
<form method="POST" action="/login" class="auth-form">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
required
autocomplete="username"
/>
</div>
<div id="passkey-section" style="display:none">
<button
id="passkey-btn"
class="btn btn-secondary btn-block"
type="button"
>
Sign in with passkey
</button>
<div class="auth-divider">
<span>or</span>
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
required
autocomplete="current-password"
/>
</div>
<button type="submit" class="btn btn-primary btn-block">
Sign in with password
</button>
</form>
<p class="auth-footer">
Don't have an account? <a href="/register">Register</a>
</p>
</div>
<script type="module">{`
import { startAuthentication } from '/assets/simplewebauthn-browser.js';
document.getElementById('passkey-section').style.display = 'block';
document.getElementById('passkey-btn').addEventListener('click', async () => {
try {
const optsResp = await fetch('/auth/passkey/login/options', { method: 'POST' });
const opts = await optsResp.json();
const result = await startAuthentication({ optionsJSON: opts });
const verResp = await fetch('/auth/passkey/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
});
if (verResp.ok) {
window.location.href = '/';
} else {
const err = await verResp.json();
alert(err.error ?? 'Passkey sign in failed');
}
} catch (e) {
alert('Passkey sign in failed: ' + e.message);
}
});
`}</script>
</Layout>
);
}
▾Asrc/views/auth/Register.tsx
@@ -0,0 +1,110 @@
import { Layout } from "../layout.tsx";
interface RegisterProps {
error?: string;
}
export function Register({ error }: RegisterProps) {
return (
<Layout user={null} title="Register">
<div class="auth-container">
<h1 class="page-title">Create account</h1>
{error && <p class="form-error">{error}</p>}
<form method="POST" action="/register" class="auth-form">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
required
autocomplete="username"
pattern="[a-zA-Z0-9_-]+"
title="Letters, numbers, hyphens and underscores only"
/>
</div>
<div id="passkey-section" style="display:none">
<button
id="passkey-register-btn"
class="btn btn-secondary btn-block"
type="button"
>
Register with passkey
</button>
<div class="auth-divider">
<span>or</span>
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="new-password"
minlength="8"
/>
</div>
<div class="form-group">
<label for="password2">Confirm password</label>
<input
id="password2"
name="password2"
type="password"
autocomplete="new-password"
minlength="8"
/>
</div>
<button type="submit" class="btn btn-primary btn-block">
Register with password
</button>
</form>
<p class="auth-footer">
Already have an account? <a href="/login">Sign in</a>
</p>
</div>
<script type="module">{`
import { startRegistration } from '/assets/simplewebauthn-browser.js';
const usernameInput = document.getElementById('username');
document.getElementById('passkey-section').style.display = 'block';
document.getElementById('passkey-register-btn').addEventListener('click', async () => {
const username = usernameInput.value.trim();
if (!username) { usernameInput.focus(); return; }
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
alert('Username may only contain letters, numbers, hyphens, and underscores');
return;
}
try {
const createResp = await fetch('/auth/passkey/create-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
if (!createResp.ok) {
const err = await createResp.json();
alert(err.error ?? 'Failed to create account');
return;
}
await createResp.json();
const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' });
const opts = await optsResp.json();
const result = await startRegistration({ optionsJSON: opts });
const verResp = await fetch('/auth/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
});
if (verResp.ok) {
window.location.href = '/';
} else {
const err = await verResp.json();
alert(err.error ?? 'Passkey registration failed');
}
} catch (e) {
alert('Passkey registration failed: ' + e.message);
}
});
`}</script>
</Layout>
);
}
▾Asrc/views/issues/IssueDetail.tsx
@@ -0,0 +1,297 @@
import type {
IssueCommentRow,
IssueRow,
RepositoryRow,
} from "../../db/index.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Avatar } from "../Avatar.tsx";
import { DateWithEdited } from "../DateWithEdited.tsx";
import { Layout } from "../layout.tsx";
import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
interface IssueDetailProps {
user: SessionUser | null;
repo: RepositoryRow;
issue: IssueRow & {
author_username: string;
author_avatar_version: number | null;
};
bodyHtml: string;
comments: (IssueCommentRow & {
author_username: string;
author_avatar_version: number | null;
bodyHtml: string;
})[];
reactions: ReactionCount[];
commentReactions: Map<number, ReactionCount[]>;
}
export function IssueDetail({
user,
repo,
issue,
bodyHtml,
comments,
reactions,
commentReactions,
}: IssueDetailProps) {
const canEditIssue =
user != null && (user.isAdmin || user.id === issue.author_id);
const canEditComment = (c: IssueCommentRow) =>
user != null && (user.isAdmin || user.id === c.author_id);
return (
<Layout user={user} title={`${issue.title} — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="issues" user={user} />
<div class="issue-detail">
<div class="issue-detail-header">
<span class="issue-number">#{issue.number}</span>
<h2 class="issue-detail-title">{issue.title}</h2>
<span class={`issue-badge ${issue.status}`}>
{issue.status}
</span>
{canEditIssue && (
<div class="issue-detail-meta-actions">
{user?.isAdmin && (
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/close`}
class="inline-form"
>
<button
type="submit"
class="btn btn-sm"
>
{issue.status === "open"
? "Close issue"
: "Reopen issue"}
</button>
</form>
)}
<details class="confirm-details">
<summary class="btn btn-sm btn-danger">
Delete
</summary>
<div class="confirm-popup">
Permanently delete this issue?
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/delete`}
class="inline-form"
>
<button
type="submit"
class="btn btn-sm btn-danger"
>
Yes, delete
</button>
</form>
</div>
</details>
</div>
)}
</div>
<div class="timeline-item">
<div class="timeline-author">
<Avatar
userId={issue.author_id}
version={issue.author_avatar_version}
size={24}
/>
<strong>
{displayName(issue.author_username)}
</strong>
<div class="timeline-author-right">
{canEditIssue && (
<details class="inline-edit-details">
<summary class="btn btn-xs btn-ghost">
<span class="when-closed">
Edit
</span>
<span class="when-open">
Stop editing
</span>
</summary>
</details>
)}
<DateWithEdited
date={issue.created_at}
editedAt={issue.edited_at}
/>
</div>
</div>
{canEditIssue && (
<div class="inline-edit-form-area">
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/edit`}
class="inline-edit-form"
>
<div class="form-group">
<label
class="form-label"
for="edit-issue-title"
>
Title
</label>
<input
class="form-input"
id="edit-issue-title"
name="title"
value={issue.title}
required
/>
</div>
<div class="form-group">
<label
class="form-label"
for="edit-issue-body"
>
Description
</label>
<textarea
class="form-input"
id="edit-issue-body"
name="edit_body"
rows="6"
>
{issue.body}
</textarea>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
</div>
</form>
</div>
)}
<div class="timeline-body markdown-body">
{bodyHtml || (
<em class="text-muted">
No description provided.
</em>
)}
</div>
<ReactionBar
reactions={reactions}
postUrl={`/${repo.name}/issues/${issue.number}/react`}
user={user}
/>
</div>
{comments.map((comment) => (
<div class="timeline-item">
<div class="timeline-author">
<Avatar
userId={comment.author_id}
version={comment.author_avatar_version}
size={24}
/>
<strong>
{displayName(comment.author_username)}
</strong>
<div class="timeline-author-right">
{canEditComment(comment) && (
<details class="inline-edit-details">
<summary class="btn btn-xs btn-ghost">
<span class="when-closed">
Edit
</span>
<span class="when-open">
Stop editing
</span>
</summary>
</details>
)}
<DateWithEdited
date={comment.created_at}
editedAt={comment.edited_at}
/>
</div>
</div>
{canEditComment(comment) && (
<div class="inline-edit-form-area">
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/comments/${comment.id}/edit`}
class="inline-edit-form"
>
<div class="form-group">
<textarea
class="form-input"
name="edit_body"
rows="6"
>
{comment.body}
</textarea>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
</div>
</form>
</div>
)}
<div class="timeline-body markdown-body">
{comment.bodyHtml}
</div>
<ReactionBar
reactions={
commentReactions.get(comment.id) ?? []
}
postUrl={`/${repo.name}/issues/${issue.number}/react`}
commentId={comment.id}
user={user}
/>
</div>
))}
{user && (
<div class="timeline-item timeline-item-new">
<h3 class="section-title">Add a comment</h3>
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/comments`}
>
<div class="form-group">
<textarea
name="body"
rows="6"
placeholder="Leave a comment (Markdown supported)"
required
/>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
>
Comment
</button>
</div>
</form>
</div>
)}
{!user && (
<p class="text-muted">
<a href="/login">Sign in</a> to leave a comment.
</p>
)}
</div>
</div>
</Layout>
);
}
▾Asrc/views/issues/IssueList.tsx
@@ -0,0 +1,102 @@
import type { IssueRow, RepositoryRow } from "../../db/index.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { Pagination, type PaginationInfo } from "../Pagination.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
interface IssueListProps {
user: SessionUser | null;
repo: RepositoryRow;
issues: (IssueRow & { author_username: string })[];
status: "open" | "closed";
counts: Record<string, number>;
pagination: PaginationInfo;
}
export function IssueList({
user,
repo,
issues,
status,
counts,
pagination,
}: IssueListProps) {
return (
<Layout user={user} title={`Issues — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="issues" user={user} />
<div class="list-header">
<div class="list-header-tabs">
<a
href={`/${repo.name}/issues?status=open`}
class={`list-tab${status === "open" ? " active" : ""}`}
>
Open{" "}
{(counts.open ?? 0) > 0 && (
<span class="tab-count">{counts.open}</span>
)}
</a>
<a
href={`/${repo.name}/issues?status=closed`}
class={`list-tab${status === "closed" ? " active" : ""}`}
>
Closed{" "}
{(counts.closed ?? 0) > 0 && (
<span class="tab-count">{counts.closed}</span>
)}
</a>
</div>
{user && (
<a
href={`/${repo.name}/issues/new`}
class="btn btn-primary btn-sm"
>
New issue
</a>
)}
</div>
{issues.length === 0 ? (
<div class="empty-state">
<p>No {status} issues.</p>
</div>
) : (
<ul class="issue-list">
{issues.map((issue) => (
<li class="issue-item">
<div class="issue-main">
<span
class={`issue-status-dot ${issue.status}`}
/>
<a
href={`/${repo.name}/issues/${issue.number}`}
class="issue-title"
>
{issue.title}
</a>
<span class="issue-number">
#{issue.number}
</span>
</div>
<div class="issue-meta">
<span class="issue-author">
opened by{" "}
{displayName(issue.author_username)}
</span>
<time datetime={issue.created_at}>
{new Date(
issue.created_at,
).toLocaleDateString()}
</time>
</div>
</li>
))}
</ul>
)}
<Pagination {...pagination} />
</div>
</Layout>
);
}
▾Asrc/views/issues/NewIssue.tsx
@@ -0,0 +1,60 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
interface NewIssueProps {
user: SessionUser;
repo: RepositoryRow;
error?: string;
}
export function NewIssue({ user, repo, error }: NewIssueProps) {
return (
<Layout user={user} title={`New issue — ${repo.name}`}>
<div class="container container-narrow">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="issues" user={user} />
<h2 class="section-title">New issue</h2>
{error && <p class="form-error">{error}</p>}
<form
method="POST"
action={`/${repo.name}/issues`}
class="form-card"
>
<div class="form-group">
<label for="title">Title</label>
<input
id="title"
name="title"
type="text"
required
placeholder="Short, descriptive title"
/>
</div>
<div class="form-group">
<label for="body">
Description{" "}
<span class="text-muted">(Markdown supported)</span>
</label>
<textarea
id="body"
name="body"
rows="10"
placeholder="Describe the issue..."
/>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Submit issue
</button>
<a href={`/${repo.name}/issues`} class="btn btn-ghost">
Cancel
</a>
</div>
</form>
</div>
</Layout>
);
}
▾Asrc/views/layout.tsx
@@ -0,0 +1,91 @@
import { OWNER_DISPLAY_NAME } from "../config.ts";
import { displayName } from "../lib/users.ts";
import type { SessionUser } from "../middleware/session.ts";
import { Avatar } from "./Avatar.tsx";
interface LayoutProps {
user: SessionUser | null;
title?: string;
children?: JSX.Element | JSX.Element[] | string | null;
}
export function Layout({ user, title, children }: LayoutProps) {
const pageTitle = title ? `${title} — Hearthforge` : "Hearthforge";
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=500" />
<title>{pageTitle}</title>
<script src="/assets/theme.js"></script>
<link
rel="icon"
type="image/svg+xml"
href="/assets/favicon.svg"
/>
<link rel="stylesheet" href="/assets/main.css" />
<script src="/assets/jxl-polyfill.js" defer></script>
</head>
<body>
<header class="site-header">
<nav class="site-nav">
<a href="/" class="site-logo">
<img
src="/assets/favicon.svg"
class="logo-icon"
alt=""
aria-hidden="true"
/>
<span class="logo-text">Hearthforge</span>
</a>
<div class="nav-links">
{user ? (
<>
<a href="/settings" class="nav-user">
<Avatar
userId={user.id}
version={user.avatar_version}
size={24}
/>
{displayName(user.username)}
</a>
<form
method="POST"
action="/logout"
class="inline-form"
>
<button
type="submit"
class="btn btn-sm btn-ghost"
>
Sign out
</button>
</form>
</>
) : (
<>
<a
href="/login"
class="btn btn-sm btn-ghost"
>
Sign in
</a>
<a
href="/register"
class="btn btn-sm btn-primary"
>
Register
</a>
</>
)}
</div>
</nav>
</header>
<main class="site-main">{children}</main>
<footer class="site-footer">
<p>Hearthforge — hosted by {OWNER_DISPLAY_NAME}</p>
</footer>
</body>
</html>
);
}
▾Asrc/views/patches/NewPatch.tsx
@@ -0,0 +1,75 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
interface NewPatchProps {
user: SessionUser;
repo: RepositoryRow;
error?: string;
}
export function NewPatch({ user, repo, error }: NewPatchProps) {
return (
<Layout user={user} title={`New patch — ${repo.name}`}>
<div class="container container-narrow">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="patches" user={user} />
<h2 class="section-title">Upload patch</h2>
{error && <p class="form-error">{error}</p>}
<form
method="POST"
action={`/${repo.name}/patches`}
enctype="multipart/form-data"
class="form-card"
>
<div class="form-group">
<label for="title">Title</label>
<input
id="title"
name="title"
type="text"
required
placeholder="What does this patch do?"
/>
</div>
<div class="form-group">
<label for="description">
Description{" "}
<span class="text-muted">
(Markdown supported, optional)
</span>
</label>
<textarea
id="description"
name="description"
rows="5"
/>
</div>
<div class="form-group">
<label for="patch_file">
Patch file{" "}
<span class="text-muted">(.patch or .diff)</span>
</label>
<input
id="patch_file"
name="patch_file"
type="file"
accept=".patch,.diff,text/plain"
required
/>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Upload patch
</button>
<a href={`/${repo.name}/patches`} class="btn btn-ghost">
Cancel
</a>
</div>
</form>
</div>
</Layout>
);
}
▾Asrc/views/patches/PatchDetail.tsx
@@ -0,0 +1,382 @@
import type {
PatchCommentRow,
PatchRow,
RepositoryRow,
} from "../../db/index.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
import type { ApplyResult } from "../../services/patchCache.ts";
import { Avatar } from "../Avatar.tsx";
import { DateWithEdited } from "../DateWithEdited.tsx";
import { DiffView } from "../DiffView.tsx";
import { Layout } from "../layout.tsx";
import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
interface PatchDetailProps {
user: SessionUser | null;
repo: RepositoryRow;
patch: PatchRow & {
author_username: string;
author_avatar_version: number | null;
};
descriptionHtml: string;
applyResult: ApplyResult | null;
files: RenderedDiffFile[];
tab: "conversation" | "changes";
comments: (PatchCommentRow & {
author_username: string;
author_avatar_version: number | null;
bodyHtml: string;
})[];
reactions: ReactionCount[];
commentReactions: Map<number, ReactionCount[]>;
}
export function PatchDetail({
user,
repo,
patch,
descriptionHtml,
applyResult,
files,
tab,
comments,
reactions,
commentReactions,
}: PatchDetailProps) {
const canEdit =
user != null && (user.isAdmin || user.id === patch.author_id);
const canEditComment = (c: PatchCommentRow) =>
user != null && (user.isAdmin || user.id === c.author_id);
const baseUrl = `/${repo.name}/patches/${patch.number}`;
const reactUrl = `${baseUrl}/react`;
return (
<Layout user={user} title={`${patch.title} — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="patches" user={user} />
<div class="issue-detail">
<div class="issue-detail-header">
<span class="issue-number">#{patch.number}</span>
<h2 class="issue-detail-title">{patch.title}</h2>
<span class={`patch-badge ${patch.status}`}>
{patch.status}
</span>
{canEdit && (
<div class="issue-detail-meta-actions">
{user?.isAdmin &&
patch.status === "open" &&
applyResult?.status === "clean" && (
<form
method="POST"
action={`${baseUrl}/merge`}
class="inline-form"
>
<button
type="submit"
class="btn btn-sm btn-primary"
>
Merge patch
</button>
</form>
)}
{user?.isAdmin && patch.status !== "merged" && (
<form
method="POST"
action={`${baseUrl}/close`}
class="inline-form"
>
<button
type="submit"
class="btn btn-sm"
>
{patch.status === "open"
? "Close patch"
: "Reopen patch"}
</button>
</form>
)}
<details class="confirm-details">
<summary class="btn btn-sm btn-danger">
Delete
</summary>
<div class="confirm-popup">
Permanently delete this patch?
<form
method="POST"
action={`${baseUrl}/delete`}
class="inline-form"
>
<button
type="submit"
class="btn btn-sm btn-danger"
>
Yes, delete
</button>
</form>
</div>
</details>
</div>
)}
</div>
</div>
{/* Subview tabs */}
<div class="patch-tab-nav">
<a
href={baseUrl}
class={`repo-tab${tab === "conversation" ? " active" : ""}`}
>
Conversation{" "}
{comments.length > 0 && (
<span class="tab-count">{comments.length}</span>
)}
</a>
<a
href={`${baseUrl}?tab=changes`}
class={`repo-tab${tab === "changes" ? " active" : ""}`}
>
Changes{" "}
{files.length > 0 && (
<span class="tab-count">{files.length}</span>
)}
</a>
</div>
{tab === "conversation" ? (
<div class="issue-detail">
{/* Patch description */}
<div class="timeline-item">
<div class="timeline-author">
<Avatar
userId={patch.author_id}
version={patch.author_avatar_version}
size={24}
/>
<strong>
{displayName(patch.author_username)}
</strong>
<div class="timeline-author-right">
{canEdit && (
<details class="inline-edit-details">
<summary class="btn btn-xs btn-ghost">
<span class="when-closed">
Edit
</span>
<span class="when-open">
Stop editing
</span>
</summary>
</details>
)}
<DateWithEdited
date={patch.created_at}
editedAt={patch.edited_at}
/>
</div>
</div>
{canEdit && (
<div class="inline-edit-form-area">
<form
method="POST"
action={`${baseUrl}/edit`}
class="inline-edit-form"
>
<div class="form-group">
<label
class="form-label"
for="edit-patch-title"
>
Title
</label>
<input
class="form-input"
id="edit-patch-title"
name="title"
value={patch.title}
required
/>
</div>
<div class="form-group">
<label
class="form-label"
for="edit-patch-desc"
>
Description
</label>
<textarea
class="form-input"
id="edit-patch-desc"
name="edit_description"
rows="6"
>
{patch.description}
</textarea>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
</div>
</form>
</div>
)}
<div class="timeline-body markdown-body">
{descriptionHtml || (
<em class="text-muted">
No description provided.
</em>
)}
</div>
<ReactionBar
reactions={reactions}
postUrl={reactUrl}
user={user}
/>
</div>
{/* Apply status */}
{applyResult && (
<div class="timeline-item">
<div
class={`apply-result apply-${applyResult.status}`}
>
<span class="apply-icon">
{applyResult.status === "clean"
? "✓"
: "✗"}
</span>
<span>
{applyResult.status === "clean"
? "Applies cleanly"
: "Has conflicts"}
</span>
{applyResult.output && (
<pre class="apply-output">
{applyResult.output}
</pre>
)}
</div>
</div>
)}
{/* Comments */}
{comments.map((comment) => (
<div class="timeline-item">
<div class="timeline-author">
<Avatar
userId={comment.author_id}
version={comment.author_avatar_version}
size={24}
/>
<strong>
{displayName(comment.author_username)}
</strong>
<div class="timeline-author-right">
{canEditComment(comment) && (
<details class="inline-edit-details">
<summary class="btn btn-xs btn-ghost">
<span class="when-closed">
Edit
</span>
<span class="when-open">
Stop editing
</span>
</summary>
</details>
)}
<DateWithEdited
date={comment.created_at}
editedAt={comment.edited_at}
/>
</div>
</div>
{canEditComment(comment) && (
<div class="inline-edit-form-area">
<form
method="POST"
action={`${baseUrl}/comments/${comment.id}/edit`}
class="inline-edit-form"
>
<div class="form-group">
<textarea
class="form-input"
name="edit_body"
rows="6"
>
{comment.body}
</textarea>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
</div>
</form>
</div>
)}
<div class="timeline-body markdown-body">
{comment.bodyHtml}
</div>
<ReactionBar
reactions={
commentReactions.get(comment.id) ?? []
}
postUrl={reactUrl}
commentId={comment.id}
user={user}
/>
</div>
))}
{user && (
<div class="timeline-item timeline-item-new">
<h3 class="section-title">Add a comment</h3>
<form
method="POST"
action={`${baseUrl}/comments`}
>
<div class="form-group">
<textarea
name="body"
rows="6"
placeholder="Leave a comment (Markdown supported)"
required
/>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
>
Comment
</button>
</div>
</form>
</div>
)}
{!user && (
<p class="text-muted">
<a href="/login">Sign in</a> to leave a comment.
</p>
)}
</div>
) : (
<DiffView files={files} repo={repo} />
)}
</div>
</Layout>
);
}
▾Asrc/views/patches/PatchList.tsx
@@ -0,0 +1,110 @@
import type { PatchRow, RepositoryRow } from "../../db/index.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { Pagination, type PaginationInfo } from "../Pagination.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
interface PatchListProps {
user: SessionUser | null;
repo: RepositoryRow;
patches: (PatchRow & { author_username: string })[];
status: string;
counts: Record<string, number>;
pagination: PaginationInfo;
}
export function PatchList({
user,
repo,
patches,
status,
counts,
pagination,
}: PatchListProps) {
return (
<Layout user={user} title={`Patches — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="patches" user={user} />
<div class="list-header">
<div class="list-header-tabs">
<a
href={`/${repo.name}/patches?status=open`}
class={`list-tab${status === "open" ? " active" : ""}`}
>
Open{" "}
{(counts.open ?? 0) > 0 && (
<span class="tab-count">{counts.open}</span>
)}
</a>
<a
href={`/${repo.name}/patches?status=merged`}
class={`list-tab${status === "merged" ? " active" : ""}`}
>
Merged{" "}
{(counts.merged ?? 0) > 0 && (
<span class="tab-count">{counts.merged}</span>
)}
</a>
<a
href={`/${repo.name}/patches?status=closed`}
class={`list-tab${status === "closed" ? " active" : ""}`}
>
Closed{" "}
{(counts.closed ?? 0) > 0 && (
<span class="tab-count">{counts.closed}</span>
)}
</a>
</div>
{user && (
<a
href={`/${repo.name}/patches/new`}
class="btn btn-primary btn-sm"
>
Upload patch
</a>
)}
</div>
{patches.length === 0 ? (
<div class="empty-state">
<p>No {status} patches.</p>
</div>
) : (
<ul class="issue-list">
{patches.map((patch) => (
<li class="issue-item">
<div class="issue-main">
<span
class={`patch-status-dot ${patch.status}`}
/>
<a
href={`/${repo.name}/patches/${patch.number}`}
class="issue-title"
>
{patch.title}
</a>
<span class="issue-number">
#{patch.number}
</span>
</div>
<div class="issue-meta">
<span>
by {displayName(patch.author_username)}
</span>
<time datetime={patch.created_at}>
{new Date(
patch.created_at,
).toLocaleDateString()}
</time>
</div>
</li>
))}
</ul>
)}
<Pagination {...pagination} />
</div>
</Layout>
);
}
▾Asrc/views/releases/NewRelease.tsx
@@ -0,0 +1,136 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
interface NewReleaseProps {
user: SessionUser;
repo: RepositoryRow;
error?: string;
values?: {
tag_name?: string;
name?: string;
notes?: string;
commit_hash?: string;
include_source_code?: boolean;
};
}
export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
return (
<Layout user={user} title={`New Release — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="releases" user={user} />
<div class="form-page">
<h2 class="page-title">New Release</h2>
{error && <div class="flash flash-error">{error}</div>}
<form
method="post"
action={`/${repo.name}/releases`}
enctype="multipart/form-data"
class="form"
>
<div class="form-group">
<label class="form-label" for="tag_name">
Tag name <span class="form-required">*</span>
</label>
<input
type="text"
id="tag_name"
name="tag_name"
class="form-input"
required
value={values?.tag_name ?? ""}
placeholder="v1.0.0"
/>
</div>
<div class="form-group">
<label class="form-label" for="name">
Release title
</label>
<input
type="text"
id="name"
name="name"
class="form-input"
value={values?.name ?? ""}
placeholder="Optional display name"
/>
</div>
<div class="form-group">
<label class="form-label" for="commit_hash">
Commit hash <span class="form-required">*</span>
</label>
<input
type="text"
id="commit_hash"
name="commit_hash"
class="form-input monospace"
required
value={values?.commit_hash ?? ""}
placeholder="Full or abbreviated commit SHA"
/>
</div>
<div class="form-group">
<label class="form-label" for="notes">
Release notes
</label>
<textarea
id="notes"
name="notes"
class="form-input form-textarea"
rows="8"
>
{values?.notes ?? ""}
</textarea>
</div>
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
name="include_source_code"
value="on"
checked={
values?.include_source_code ?? false
}
/>
<span>
Include source code archives (zip, tar.gz,
tar.zst)
</span>
</label>
</div>
<div class="form-group">
<label class="form-label" for="files">
Attach files
</label>
<input
type="file"
id="files"
name="files"
class="form-input"
multiple
/>
<p class="form-hint">
You can attach multiple files to this release.
</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Create release
</button>
<a
href={`/${repo.name}/releases`}
class="btn btn-secondary"
>
Cancel
</a>
</div>
</form>
</div>
</div>
</Layout>
);
}
▾Asrc/views/releases/ReleaseDetail.tsx
@@ -0,0 +1,179 @@
import type {
ReleaseAssetRow,
ReleaseRow,
RepositoryRow,
} from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
interface SourceArchive {
format: string;
filename: string;
size: number;
}
interface ReleaseDetailProps {
user: SessionUser | null;
repo: RepositoryRow;
release: ReleaseRow;
notesHtml: string;
assets: ReleaseAssetRow[];
sourceArchives: SourceArchive[];
sourceArchivesPending?: boolean;
}
export function ReleaseDetail({
user,
repo,
release,
notesHtml,
assets,
sourceArchives,
sourceArchivesPending,
}: ReleaseDetailProps) {
const hasDownloads =
sourceArchives.length > 0 || assets.length > 0 || sourceArchivesPending;
return (
<Layout
user={user}
title={`${release.name || release.tag_name} — ${repo.name}`}
>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="releases" user={user} />
<div class="release-detail">
<div class="release-header">
<div class="release-item-header">
<div class="release-item-main">
<h2
class="page-title"
style={`view-transition-name: release-title-${release.id}`}
>
{release.name || release.tag_name}
</h2>
<div class="release-item-meta">
<a
href={`/${repo.name}/tree/${release.commit_hash}`}
class="monospace"
>
{release.commit_hash.slice(0, 8)}
</a>
</div>
</div>
<div class="release-item-date">
<span class="badge">{release.tag_name}</span>
<time datetime={release.created_at}>
{new Date(
release.created_at,
).toLocaleDateString()}
</time>
</div>
</div>
</div>
{notesHtml && (
<div class="markdown-body release-notes">
{notesHtml}
</div>
)}
{hasDownloads && (
<div class="release-assets">
<h3 class="release-assets-heading">Downloads</h3>
<ul class="asset-list">
{assets.map((asset) => (
<li class="asset-item">
<a
href={`/${repo.name}/releases/${release.id}/assets/${asset.filename}`}
class="asset-name"
>
{asset.filename}
</a>
<span class="asset-size">
{formatBytes(asset.size)}
</span>
</li>
))}
{sourceArchives.map((archive) => (
<li class="asset-item">
<a
href={`/${repo.name}/releases/${release.id}/source/${archive.filename}`}
class="asset-name"
>
{archive.filename}
</a>
<span class="asset-meta">
Source code ({archive.format})
</span>
<span class="asset-size">
{formatBytes(archive.size)}
</span>
</li>
))}
{sourceArchivesPending &&
(
[
["zip", ".zip"],
["tar.gz", ".tar.gz"],
["tar.zst", ".tar.zst"],
] as const
).map(([format, ext]) => (
<li class="asset-item asset-item-pending">
<span class="asset-name asset-name-pending">
{`${repo.name}-${release.commit_hash.slice(0, 8)}${ext}`}
</span>
<span class="asset-meta">
Source code ({format}) —
generating…
</span>
</li>
))}
</ul>
</div>
)}
<div class="release-back">
<a
href={`/${repo.name}/releases`}
class="btn btn-secondary btn-sm"
>
← All releases
</a>
{user?.isAdmin && (
<details class="confirm-details">
<summary class="btn btn-sm btn-danger">
Delete release
</summary>
<div class="confirm-popup">
Permanently delete this release and all its
files?
<form
method="POST"
action={`/${repo.name}/releases/${release.id}/delete`}
class="inline-form"
>
<button
type="submit"
class="btn btn-sm btn-danger"
>
Yes, delete
</button>
</form>
</div>
</details>
)}
</div>
</div>
</div>
</Layout>
);
}
▾Asrc/views/releases/ReleaseList.tsx
@@ -0,0 +1,122 @@
import type { ReleaseRow, RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import {
markdownToPlaintext,
renderMarkdown,
} from "../../services/markdown.ts";
import { Layout } from "../layout.tsx";
import { Pagination, type PaginationInfo } from "../Pagination.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "../repos/RepoNav.tsx";
interface ReleaseListProps {
user: SessionUser | null;
repo: RepositoryRow;
releases: (ReleaseRow & { asset_count: number })[];
pagination: PaginationInfo;
}
function NotesPreview({ notes }: { notes: string }) {
const plaintext = markdownToPlaintext(notes);
return (
<details class="notes-expand">
<summary class="notes-toggle">
<div class="notes-preview">{plaintext}</div>
<span class="notes-toggle-label" />
</summary>
<div class="notes-full markdown-body">{renderMarkdown(notes)}</div>
</details>
);
}
export function ReleaseList({
user,
repo,
releases,
pagination,
}: ReleaseListProps) {
return (
<Layout user={user} title={`Releases — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="releases" user={user} />
<div class="list-header">
<h2 class="list-heading">Releases</h2>
{user?.isAdmin && (
<a
href={`/${repo.name}/releases/new`}
class="btn btn-primary btn-sm"
>
New release
</a>
)}
</div>
{releases.length === 0 ? (
<div class="empty-state">
<p>No releases yet.</p>
</div>
) : (
<ul class="issue-list">
{releases.map((release) => (
<li class="issue-item">
<div class="release-item-header">
<div class="release-item-main">
<a
href={`/${repo.name}/releases/${release.id}`}
class="release-item-title"
style={`view-transition-name: release-title-${release.id}`}
>
{release.name || release.tag_name}
</a>
<div class="release-item-meta">
<a
href={`/${repo.name}/tree/${release.commit_hash}`}
class="monospace"
>
{release.commit_hash.slice(
0,
8,
)}
</a>
{release.asset_count > 0 && (
<span>
{release.asset_count} asset
{release.asset_count !== 1
? "s"
: ""}
</span>
)}
{release.include_source_code ===
1 && (
<span>source archives</span>
)}
</div>
</div>
<div class="release-item-date">
<span class="badge">
{release.tag_name}
</span>
<time datetime={release.created_at}>
{new Date(
release.created_at,
).toLocaleDateString()}
</time>
</div>
</div>
{release.notes && (
<div class="release-notes-section">
<p class="release-notes-label">
Release Notes
</p>
<NotesPreview notes={release.notes} />
</div>
)}
</li>
))}
</ul>
)}
<Pagination {...pagination} />
</div>
</Layout>
);
}
▾Asrc/views/render.tsx
@@ -0,0 +1,8 @@
export function html(node: JSX.Element): Response {
return new Response(`<!DOCTYPE html>${node}`, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
▾Asrc/views/repos/BranchSelector.tsx
@@ -0,0 +1,58 @@
interface BranchSelectorProps {
repoName: string;
branches: string[];
currentRef: string;
view: "tree" | "commits" | "blob";
/** subpath for tree, full file path for blob */
path?: string;
}
export function BranchSelector({
repoName,
branches,
currentRef,
view,
path,
}: BranchSelectorProps) {
if (branches.length === 0) return "";
const isDetached = !branches.includes(currentRef);
const shortRef =
isDetached && currentRef.length > 8
? currentRef.slice(0, 8)
: currentRef;
return (
<form
method="GET"
action={`/${repoName}/branch-switch`}
class="branch-selector"
>
<input type="hidden" name="view" value={view} />
{path && <input type="hidden" name="path" value={path} />}
<span class="branch-selector-icon">⎇</span>
<select
name="ref"
class="branch-select"
onchange="this.form.submit()"
>
{isDetached && (
<option value={currentRef} selected>
{shortRef} (commit)
</option>
)}
{branches.map((b) => (
<option
value={b}
selected={b === currentRef ? true : undefined}
>
{b}
</option>
))}
</select>
<noscript>
<button type="submit" class="btn btn-sm btn-ghost">
Go
</button>
</noscript>
</form>
);
}
▾Asrc/views/repos/CommitDetail.tsx
@@ -0,0 +1,111 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
import type { CommitMeta } from "../../services/git.ts";
import { DiffView } from "../DiffView.tsx";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
interface CommitDetailProps {
user: SessionUser | null;
repo: RepositoryRow;
sha: string;
meta: CommitMeta;
files: RenderedDiffFile[];
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
} catch {
return iso;
}
}
export function CommitDetail({
user,
repo,
sha,
meta,
files,
}: CommitDetailProps) {
return (
<Layout user={user} title={`${sha.slice(0, 7)} — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="commits" user={user} />
{/* Back link */}
<div class="commit-page-top">
<a
href={`/${repo.name}/commits/${repo.default_branch}`}
class="btn btn-ghost btn-sm"
>
← Back to log
</a>
<a
href={`/${repo.name}/tree/${sha}`}
class="btn btn-secondary btn-sm"
title={`Browse tree at ${sha.slice(0, 7)}`}
>
@ {sha.slice(0, 7)}
</a>
</div>
{/* Commit metadata card */}
<div class="commit-card">
<h2 class="commit-card-subject">{meta.subject}</h2>
{meta.body && (
<pre class="commit-card-body">{meta.body}</pre>
)}
<div class="commit-card-meta">
<div class="commit-card-meta-row">
<span class="commit-meta-label">Author</span>
<span class="commit-meta-value">
{meta.author} &lt;{meta.email}&gt;
</span>
</div>
<div class="commit-card-meta-row">
<span class="commit-meta-label">Date</span>
<time
class="commit-meta-value"
datetime={meta.date}
>
{formatDate(meta.date)}
</time>
</div>
<div class="commit-card-meta-row">
<span class="commit-meta-label">Commit</span>
<code class="commit-meta-value commit-sha-full mono">
{meta.hash}
</code>
</div>
{meta.parents.length > 0 && (
<div class="commit-card-meta-row">
<span class="commit-meta-label">
Parent{meta.parents.length > 1 ? "s" : ""}
</span>
<span class="commit-meta-value">
{meta.parents.map((p) => (
<a
href={`/${repo.name}/commit/${p}`}
class="commit-hash mono"
>
{p.slice(0, 7)}
</a>
))}
</span>
</div>
)}
</div>
</div>
<DiffView files={files} repo={repo} sha={sha} />
</div>
</Layout>
);
}
▾Asrc/views/repos/CommitLog.tsx
@@ -0,0 +1,100 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { CommitEntry } from "../../services/git.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { BranchSelector } from "./BranchSelector.tsx";
import { RepoNav } from "./RepoNav.tsx";
interface CommitLogProps {
user: SessionUser | null;
repo: RepositoryRow;
ref: string;
commits: CommitEntry[];
branches: string[];
/** URL for the next (older) page, or null if this is the last page. */
olderUrl: string | null;
/** URL for the previous (newer) page, or null if this is the first page. */
newerUrl: string | null;
}
export function CommitLog({
user,
repo,
ref: logRef,
commits,
branches,
olderUrl,
newerUrl,
}: CommitLogProps) {
return (
<Layout user={user} title={`Commits — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="commits" user={user} />
<div class="commits-header">
<h2 class="section-title">Commits</h2>
<BranchSelector
repoName={repo.name}
branches={branches}
currentRef={logRef}
view="commits"
/>
</div>
{commits.length === 0 ? (
<p class="text-muted">No commits yet.</p>
) : (
<ul class="commit-list commit-log">
{commits.map((c) => (
<li class="commit-item">
<div class="commit-main">
<a
href={`/${repo.name}/commit/${c.hash}`}
class="commit-subject"
>
{c.subject}
</a>
</div>
<div class="commit-meta">
<span class="commit-author">
{c.author}
</span>
<a
href={`/${repo.name}/commit/${c.hash}`}
class="commit-hash mono"
>
{c.hash.slice(0, 7)}
</a>
<time class="commit-date" datetime={c.date}>
{new Date(c.date).toLocaleString()}
</time>
</div>
</li>
))}
</ul>
)}
{(newerUrl || olderUrl) && (
<nav
class="commit-cursor-nav"
aria-label="Commit history navigation"
>
<div class="commit-cursor-prev">
{newerUrl && (
<a href={newerUrl} class="pagination-btn">
← Newer
</a>
)}
</div>
<div class="commit-cursor-next">
{olderUrl && (
<a href={olderUrl} class="pagination-btn">
Older →
</a>
)}
</div>
</nav>
)}
</div>
</Layout>
);
}
▾Asrc/views/repos/FileBlob.tsx
@@ -0,0 +1,115 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { FileView } from "../../services/highlight.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { BranchSelector } from "./BranchSelector.tsx";
import { RepoNav } from "./RepoNav.tsx";
interface FileBlobProps {
user: SessionUser | null;
repo: RepositoryRow;
ref: string;
filePath: string;
view: FileView;
branches: string[];
}
export function FileBlob({
user,
repo,
ref: blobRef,
filePath,
view,
branches,
}: FileBlobProps) {
const parts = filePath.split("/");
const filename = parts[parts.length - 1] ?? filePath;
const dir = parts.slice(0, -1).join("/");
const _backHref = dir
? `/${repo.name}/tree/${blobRef}/${dir}`
: `/${repo.name}/tree/${blobRef}`;
return (
<Layout user={user} title={`${repo.name}/${filePath}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="code" user={user} />
<div class="breadcrumb">
<a href={`/${repo.name}/tree/${blobRef}`}>{repo.name}</a>
{parts.map((part, i) => {
const partPath = parts.slice(0, i + 1).join("/");
const isLast = i === parts.length - 1;
return (
<>
<span class="breadcrumb-sep">/</span>
{isLast ? (
<span class="breadcrumb-current">
{part}
</span>
) : (
<a
href={`/${repo.name}/tree/${blobRef}/${partPath}`}
>
{part}
</a>
)}
</>
);
})}
</div>
<div class="file-blob-header">
<span class="file-blob-name">{filename}</span>
<div class="file-blob-actions">
<BranchSelector
repoName={repo.name}
branches={branches}
currentRef={blobRef}
view="blob"
path={filePath}
/>
<a
href={`/${repo.name}/raw/${blobRef}/${filePath}`}
class="btn btn-sm btn-ghost"
>
Raw
</a>
</div>
</div>
<div class="file-blob-body">
{view.type === "inline" ? (
<div class="shiki-wrapper">{view.html}</div>
) : view.type === "binary" ? (
<div class="file-download-notice">
<p>Binary file ({formatSize(view.size)})</p>
<a
href={`/${repo.name}/raw/${blobRef}/${filePath}`}
class="btn btn-primary"
>
Download
</a>
</div>
) : (
<div class="file-download-notice">
<p>
File too large to display inline (
{formatSize(view.size)})
</p>
<a
href={`/${repo.name}/raw/${blobRef}/${filePath}`}
class="btn btn-primary"
>
Download
</a>
</div>
)}
</div>
</div>
</Layout>
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
▾Asrc/views/repos/FileTree.tsx
@@ -0,0 +1,82 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { TreeEntry } from "../../services/git.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { BranchSelector } from "./BranchSelector.tsx";
import { FileTreeTable } from "./FileTreeTable.tsx";
import { RepoNav } from "./RepoNav.tsx";
interface FileTreeProps {
user: SessionUser | null;
repo: RepositoryRow;
ref: string;
subpath: string;
entries: TreeEntry[];
branches: string[];
readmeHtml?: string | null;
}
export function FileTree({
user,
repo,
ref: treeRef,
subpath,
entries,
branches,
readmeHtml,
}: FileTreeProps) {
const parts = subpath ? subpath.split("/") : [];
return (
<Layout
user={user}
title={`${repo.name}${subpath ? `/${subpath}` : ""}`}
>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="code" user={user} />
<div class="tree-toolbar">
<BranchSelector
repoName={repo.name}
branches={branches}
currentRef={treeRef}
view="tree"
path={subpath}
/>
</div>
{subpath && (
<div class="breadcrumb">
<a href={`/${repo.name}/tree/${treeRef}`}>
{repo.name}
</a>
{parts.map((part, i) => {
const partPath = parts.slice(0, i + 1).join("/");
return (
<>
<span class="breadcrumb-sep">/</span>
<a
href={`/${repo.name}/tree/${treeRef}/${partPath}`}
>
{part}
</a>
</>
);
})}
</div>
)}
<FileTreeTable
repoName={repo.name}
treeRef={treeRef}
subpath={subpath}
entries={entries}
/>
{readmeHtml && (
<div class="readme-section">
<div class="readme-header">README</div>
<div class="markdown-body">{readmeHtml}</div>
</div>
)}
</div>
</Layout>
);
}
▾Asrc/views/repos/FileTreeTable.tsx
@@ -0,0 +1,90 @@
import type { TreeEntry } from "../../services/git.ts";
interface FileTreeTableProps {
repoName: string;
treeRef: string;
subpath: string;
entries: TreeEntry[];
}
export function FileTreeTable({
repoName,
treeRef,
subpath,
entries,
}: FileTreeTableProps) {
const parts = subpath ? subpath.split("/") : [];
const parentPath = parts.slice(0, -1).join("/");
const parentHref = parentPath
? `/${repoName}/tree/${treeRef}/${parentPath}`
: `/${repoName}/tree/${treeRef}`;
const sorted = [...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
return a.name.localeCompare(b.name);
});
return (
<table class="file-tree">
<tbody>
{subpath && (
<tr class="file-tree-row file-tree-row-up">
<td class="file-icon file-icon-dir">{DirIcon()}</td>
<td class="file-name" colspan="2">
<a href={parentHref}>..</a>
</td>
</tr>
)}
{sorted.map((entry) => {
const entryPath = subpath
? `${subpath}/${entry.name}`
: entry.name;
const href =
entry.type === "tree"
? `/${repoName}/tree/${treeRef}/${entryPath}`
: `/${repoName}/blob/${treeRef}/${entryPath}`;
return (
<tr class="file-tree-row">
<td
class={`file-icon ${entry.type === "tree" ? "file-icon-dir" : "file-icon-file"}`}
>
{entry.type === "tree" ? DirIcon() : FileIcon()}
</td>
<td class="file-name">
<a
href={href}
class={
entry.type === "tree"
? "file-name-dir"
: undefined
}
>
{entry.name}
</a>
</td>
<td class="file-size">
{entry.type === "blob" && entry.size !== "-"
? formatSize(parseInt(entry.size, 10))
: ""}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function DirIcon(): JSX.Element {
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>`;
}
function FileIcon(): JSX.Element {
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>`;
}
▾Asrc/views/repos/NewRepo.tsx
@@ -0,0 +1,67 @@
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
interface NewRepoProps {
user: SessionUser;
error?: string;
}
export function NewRepo({ user, error }: NewRepoProps) {
return (
<Layout user={user} title="New repository">
<div class="container container-narrow">
<h1 class="page-title">Create new repository</h1>
{error && <p class="form-error">{error}</p>}
<form method="POST" action="/new" class="form-card">
<div class="form-group">
<label for="name">Repository name</label>
<input
id="name"
name="name"
type="text"
required
pattern="[a-zA-Z0-9._-]+"
title="Letters, numbers, dots, hyphens, underscores"
placeholder="my-project"
/>
</div>
<div class="form-group">
<label for="description">
Description{" "}
<span class="text-muted">(optional)</span>
</label>
<input
id="description"
name="description"
type="text"
placeholder="A short description"
/>
</div>
<div class="form-group">
<label for="default_branch">Default branch</label>
<input
id="default_branch"
name="default_branch"
type="text"
value="main"
placeholder="main"
/>
</div>
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
name="is_private"
value="1"
/>
Private repository
</label>
</div>
<button type="submit" class="btn btn-primary">
Create repository
</button>
</form>
</div>
</Layout>
);
}
▾Asrc/views/repos/RepoHeader.tsx
@@ -0,0 +1,20 @@
import type { RepositoryRow } from "../../db/index.ts";
interface RepoHeaderProps {
repo: RepositoryRow;
}
export function RepoHeader({ repo }: RepoHeaderProps) {
return (
<div class="repo-header">
<div class="repo-title-row">
<h1 class="page-title">
<a href={`/${repo.name}`}>{repo.name}</a>
</h1>
{repo.is_private ? (
<span class="badge badge-private">Private</span>
) : null}
</div>
</div>
);
}
▾Asrc/views/repos/RepoHome.tsx
@@ -0,0 +1,112 @@
import { BASE_URL, SSH_PORT } from "../../config.ts";
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { TreeEntry } from "../../services/git.ts";
import { Layout } from "../layout.tsx";
import { BranchSelector } from "./BranchSelector.tsx";
import { FileTreeTable } from "./FileTreeTable.tsx";
import { RepoHeader } from "./RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
interface RepoHomeProps {
user: SessionUser | null;
repo: RepositoryRow;
entries: TreeEntry[];
readmeHtml: string | null;
hasContent: boolean;
branches: string[];
}
export function RepoHome({
user,
repo,
entries,
readmeHtml,
hasContent,
branches,
}: RepoHomeProps) {
return (
<Layout user={user} title={repo.name}>
<div class="container">
<RepoHeader repo={repo} />
{repo.description && (
<p class="repo-description">{repo.description}</p>
)}
<RepoNav repo={repo} active="code" user={user} />
{!hasContent ? (
<div class="empty-state">
<h2>This repository is empty.</h2>
<p>Push your first commit to get started:</p>
<pre class="code-setup">
<code>{`git clone ${sshUrl(repo.name)}
cd ${repo.name}
echo "# ${repo.name}" > README.md
git add .
git commit -m "Initial commit"
git push origin main`}</code>
</pre>
</div>
) : (
<>
<div class="tree-toolbar">
<BranchSelector
repoName={repo.name}
branches={branches}
currentRef={repo.default_branch}
view="tree"
/>
<details class="clone-popup-details">
<summary class="btn btn-sm btn-primary">
Clone
</summary>
<div class="clone-popup">
<div class="clone-url-row">
<span class="clone-url-label">
HTTP
</span>
<input
type="text"
readonly
value={httpUrl(repo.name)}
class="clone-url-input"
/>
</div>
<div class="clone-url-row">
<span class="clone-url-label">SSH</span>
<input
type="text"
readonly
value={sshUrl(repo.name)}
class="clone-url-input"
/>
</div>
</div>
</details>
</div>
<FileTreeTable
repoName={repo.name}
treeRef={repo.default_branch}
subpath=""
entries={entries}
/>
{readmeHtml && (
<div class="readme-section">
<div class="readme-header">README</div>
<div class="markdown-body">{readmeHtml}</div>
</div>
)}
</>
)}
</div>
</Layout>
);
}
function httpUrl(name: string) {
return `${BASE_URL}/${name}.git`;
}
function sshUrl(name: string) {
const host = new URL(BASE_URL).hostname;
return `ssh://git@${host}:${SSH_PORT}/${name}.git`;
}
▾Asrc/views/repos/RepoList.tsx
@@ -0,0 +1,99 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { Pagination, type PaginationInfo } from "../Pagination.tsx";
interface RepoListProps {
user: SessionUser | null;
repos: RepositoryRow[];
search?: string;
pagination: PaginationInfo;
}
export function RepoList({ user, repos, search, pagination }: RepoListProps) {
return (
<Layout user={user} title="Repositories">
<div class="container">
<div class="page-header">
<h1 class="page-title">Repositories</h1>
{user?.isAdmin && (
<a href="/new" class="btn btn-primary">
New repository
</a>
)}
</div>
<form method="GET" action="/" class="search-form">
<div class="search-input-wrap">
<input
type="search"
name="q"
value={search ?? ""}
placeholder="Search repositories…"
class="search-input"
autocomplete="off"
/>
<button type="submit" class="btn btn-ghost btn-sm">
Search
</button>
</div>
</form>
{repos.length === 0 ? (
<div class="empty-state">
{search ? (
<p>
No repositories match <strong>{search}</strong>.
</p>
) : (
<>
<p>No repositories yet.</p>
{user?.isAdmin && (
<a href="/new" class="btn btn-primary">
Create your first repository
</a>
)}
</>
)}
</div>
) : (
<ul class="repo-list">
{repos.map((repo) => (
<li class="repo-card">
<div class="repo-card-main">
<div class="repo-card-title">
<a
href={`/${repo.name}`}
class="repo-name"
>
{repo.name}
</a>
{repo.is_private ? (
<span class="badge badge-private">
Private
</span>
) : null}
</div>
{repo.description && (
<p class="repo-description">
{repo.description}
</p>
)}
</div>
<div class="repo-card-meta">
<time
class="repo-date"
datetime={repo.created_at}
>
{new Date(
repo.created_at,
).toLocaleDateString()}
</time>
</div>
</li>
))}
</ul>
)}
<Pagination {...pagination} />
</div>
</Layout>
);
}
▾Asrc/views/repos/RepoNav.tsx
@@ -0,0 +1,44 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
interface RepoNavProps {
repo: RepositoryRow;
active: "code" | "commits" | "issues" | "patches" | "releases" | "settings";
user?: SessionUser | null;
}
export function RepoNav({ repo, active, user }: RepoNavProps) {
const tabs = [
{ key: "code", label: "Code", href: `/${repo.name}` },
{
key: "commits",
label: "Commits",
href: `/${repo.name}/commits/${repo.default_branch}`,
},
{ key: "issues", label: "Issues", href: `/${repo.name}/issues` },
{ key: "patches", label: "Patches", href: `/${repo.name}/patches` },
{ key: "releases", label: "Releases", href: `/${repo.name}/releases` },
] as const;
return (
<div class="repo-nav-bar">
<nav class="repo-tabs">
{tabs.map((t) => (
<a
href={t.href}
class={`repo-tab${active === t.key ? " active" : ""}`}
>
{t.label}
</a>
))}
{user?.isAdmin && (
<a
href={`/${repo.name}/settings`}
class={`repo-tab${active === "settings" ? " active" : ""}`}
>
Settings
</a>
)}
</nav>
</div>
);
}
▾Asrc/views/repos/RepoSettings.tsx
@@ -0,0 +1,123 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "./RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
interface RepoSettingsProps {
user: SessionUser;
repo: RepositoryRow;
branches: string[];
success?: string;
error?: string;
}
export function RepoSettings({
user,
repo,
branches,
success,
error,
}: RepoSettingsProps) {
return (
<Layout user={user} title={`Settings — ${repo.name}`}>
<div class="container container-narrow">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="settings" user={user} />
{success && <p class="form-success">{success}</p>}
{error && <p class="form-error">{error}</p>}
<form
method="POST"
action={`/${repo.name}/settings`}
class="form-card"
>
<div class="form-group">
<label for="description">Description</label>
<input
id="description"
name="description"
type="text"
value={repo.description ?? ""}
placeholder="A short description"
/>
</div>
<div class="form-group">
<label for="default_branch">Default branch</label>
{branches.length > 0 ? (
<select
id="default_branch"
name="default_branch"
class="branch-select"
>
{branches.map((b) => (
<option
value={b}
selected={
b === repo.default_branch
? true
: undefined
}
>
{b}
</option>
))}
</select>
) : (
<p class="form-hint">
No branches yet — push your first commit to set
the default branch.
</p>
)}
</div>
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
name="is_private"
value="1"
checked={repo.is_private === 1}
/>
Private repository
</label>
</div>
<button type="submit" class="btn btn-primary">
Save settings
</button>
</form>
<div class="danger-zone">
<h2 class="section-title danger-title">Danger zone</h2>
<div class="form-card danger-card">
<div class="danger-item">
<div>
<strong>Delete this repository</strong>
<p class="text-muted">
Once deleted, there is no going back.
</p>
</div>
<details class="confirm-details">
<summary class="btn btn-danger">
Delete repository
</summary>
<div class="confirm-popup">
Delete {repo.name}? This cannot be undone.
<form
method="POST"
action={`/${repo.name}/settings/delete`}
class="inline-form"
>
<button
type="submit"
class="btn btn-danger"
>
Yes, delete
</button>
</form>
</div>
</details>
</div>
</div>
</div>
</div>
</Layout>
);
}
▾Asrc/workers/highlight.worker.ts
@@ -0,0 +1,26 @@
import { highlightFile } from "../services/diffHighlight.ts";
import { highlightStartup, serveFile } from "../services/highlight.ts";
declare const self: {
postMessage(message: unknown): void;
onmessage: ((event: MessageEvent) => void) | null;
};
await highlightStartup();
self.postMessage({ type: "ready" });
self.onmessage = async (event: MessageEvent) => {
const { id, type, ...data } = event.data;
try {
let result: unknown;
if (type === "serveFile") {
const content = Buffer.from(data.content as ArrayBuffer);
result = await serveFile(content, data.filename, data.cacheKey);
} else if (type === "highlightFile") {
result = await highlightFile(data.file);
}
self.postMessage({ id, result });
} catch (err) {
self.postMessage({ id, error: String(err) });
}
};
▾Atests/e2e.test.ts
@@ -0,0 +1,2036 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import { $ } from 'bun';
import {
BASE,
ADMIN_PASS,
setupTestEnv,
spawnServer,
waitForServer,
login,
logout,
seedRepo,
seedBranch,
seedSubdir,
writeTempFile,
getHeadCommit,
PORT,
} from './helpers.ts';
// ─── Global setup ────────────────────────────────────────────────────────────
let browser: Browser;
let server: ReturnType<typeof spawnServer>;
beforeAll(async () => {
await setupTestEnv();
server = spawnServer();
await waitForServer();
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
server.kill();
});
// Helper: create a context already logged in as a given user.
async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page, username, password);
await page.close();
return ctx;
}
// Helper: create N issues in a repo using the server API (no browser rendering)
async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) {
for (let i = 1; i <= count; i++) {
await ctx.request.fetch(`${BASE}/${repo}/issues`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: `title=Issue+number+${i}&body=`,
maxRedirects: 0,
}).catch(() => {}); // 302 redirect throws; that's fine
}
}
// Helper: create N patches in a repo using the server API
async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) {
const VALID_PATCH = [
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n');
writeTempFile('/tmp/bulk.patch', VALID_PATCH);
for (let i = 1; i <= count; i++) {
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/${repo}/patches/new`);
await page.fill('[name=title]', `Patch number ${i}`);
await page.locator('[name=patch_file]').setInputFiles('/tmp/bulk.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(new RegExp(`/${repo}/patches/\\d+`));
} finally { await page.close(); }
}
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
describe('auth', () => {
test('homepage loads', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(BASE);
expect(await page.title()).toContain('Hearthforge');
} finally { await ctx.close(); }
});
test('wrong password shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/login`);
await page.fill('[name=username]', 'admin');
await page.fill('[name=password]', 'wrongpassword');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Invalid');
} finally { await ctx.close(); }
});
test('correct credentials redirect to homepage', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page);
expect(page.url()).toBe(BASE + '/');
expect(await page.locator('.nav-user').isVisible()).toBe(true);
} finally { await ctx.close(); }
});
test('register new user', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'alice');
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'password123');
await page.click('button[type=submit]');
await page.waitForURL(BASE + '/');
expect(await page.locator('.nav-user').textContent()).toBe('alice');
} finally { await ctx.close(); }
});
test('register with mismatched passwords shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'bob');
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'different456');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('match');
} finally { await ctx.close(); }
});
test('register with duplicate username shows error', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/register`);
await page.fill('[name=username]', 'alice'); // already registered above
await page.fill('[name=password]', 'password123');
await page.fill('[name=password2]', 'password123');
await page.click('button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('taken');
} finally { await ctx.close(); }
});
test('logout clears session', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await login(page);
await logout(page);
expect(await page.locator('.nav-user').count()).toBe(0);
expect(await page.locator('a[href="/login"]').isVisible()).toBe(true);
} finally { await ctx.close(); }
});
});
// ─── Repos ────────────────────────────────────────────────────────────────────
describe('repos', () => {
// Shared admin context — cookies persist across tests in this block.
let adminCtx: BrowserContext;
// Alice's context, created after alice is registered in auth tests.
let aliceCtx: BrowserContext;
// Set after 'commit log' test; used by all commit-detail tests below.
let commitUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('non-admin gets 403 on /new', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/new`);
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('create repository', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'my-repo');
await page.fill('[name=description]', 'A test repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/my-repo`);
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('repository appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
} finally { await page.close(); }
});
test('search finds matching repo', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await page.fill('[name=q]', 'my-repo');
await page.click('.search-form button[type=submit]');
expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
} finally { await page.close(); }
});
test('search returns empty for unknown term', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
await page.fill('[name=q]', 'zzz-nothing-here');
await page.click('.search-form button[type=submit]');
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('browse file tree after seeding content', async () => {
await seedRepo('my-repo');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main`);
const files = await page.locator('.file-name a').allTextContents();
expect(files).toContain('README.md');
expect(files).toContain('index.js');
} finally { await page.close(); }
});
test('view file blob with syntax highlighting', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/blob/main/index.js`);
expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
expect(await page.locator('.file-blob-body').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('raw file download responds 200', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`);
expect(resp.status()).toBe(200);
expect(resp.headers()['content-disposition']).toContain('README.md');
} finally { await page.close(); }
});
test('commit log shows initial commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
const subjects = await page.locator('.commit-subject').allTextContents();
expect(subjects.some(s => s.includes('Initial commit'))).toBe(true);
// Navigate to the commit page and capture the URL for subsequent tests
await page.locator('.commit-hash').first().click();
await page.waitForURL(/\/my-repo\/commit\//);
commitUrl = page.url();
} finally { await page.close(); }
});
test('commit detail shows metadata card', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
expect(await page.locator('.commit-card').isVisible()).toBe(true);
expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit');
// Author, date and SHA rows are all present
const metaText = await page.locator('.commit-card-meta').textContent();
expect(metaText).toContain('Test'); // author name set by seedRepo
expect(metaText).toContain('Author'); // label (CSS uppercases visually)
expect(metaText).toContain('Date');
expect(metaText).toContain('Commit');
} finally { await page.close(); }
});
test('commit detail full SHA is shown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const sha = commitUrl.split('/commit/')[1] ?? null;
expect(await page.locator('.commit-sha-full').textContent()).toBe(sha);
} finally { await page.close(); }
});
test('commit detail shows file nav sidebar', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
expect(await page.locator('.file-nav-details').isVisible()).toBe(true);
const navItems = await page.locator('.file-nav-item').allTextContents();
// seedRepo adds README.md and index.js
expect(navItems.some(t => t.includes('README.md'))).toBe(true);
expect(navItems.some(t => t.includes('index.js'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail file nav items are anchor links to diff sections', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const hrefs = await page.locator('.file-nav-item').evaluateAll(
els => els.map(el => el.getAttribute('href') ?? ''),
);
expect(hrefs.every(h => h.startsWith('#'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail shows diff table with added lines', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// Initial commit only adds lines
expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
expect(await page.locator('.diff-row-del').count()).toBe(0);
} finally { await page.close(); }
});
test('commit detail diff table has line numbers', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// New-side line numbers (column 2) on add rows start at 1
const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent();
expect(firstNewLn?.trim()).toBe('1');
} finally { await page.close(); }
});
test('commit detail shows added stats on file header', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const addStats = await page.locator('.diff-stat-add').allTextContents();
expect(addStats.length).toBeGreaterThan(0);
expect(addStats.every(s => s.startsWith('+'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail view-at-sha button links to blob at that commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const sha = commitUrl.split('/commit/')[1];
const btn = page.locator('.btn-xs').first();
const href = await btn.getAttribute('href');
expect(href).toContain(`/blob/${sha}/`);
} finally { await page.close(); }
});
test('commit detail view-at-branch button links to blob at default branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const btns = await page.locator('.btn-xs').allTextContents();
expect(btns.some(t => t.includes('@ main'))).toBe(true);
const branchBtns = await page.locator('.btn-xs').evaluateAll(
els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''),
);
expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true);
} finally { await page.close(); }
});
test('commit detail file diff can be collapsed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
// File body is visible when details is open
const diffFile = page.locator('.diff-file').first();
expect(await diffFile.getAttribute('open')).not.toBeNull();
// Click the summary to collapse
await diffFile.locator('.diff-file-header').click();
expect(await diffFile.getAttribute('open')).toBeNull();
} finally { await page.close(); }
});
test('commit detail file nav sidebar can be collapsed', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(commitUrl);
const nav = page.locator('.file-nav-details');
expect(await nav.getAttribute('open')).not.toBeNull();
await nav.locator('.file-nav-toggle').click();
expect(await nav.getAttribute('open')).toBeNull();
} finally { await page.close(); }
});
test('readme renders on repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
expect(await page.locator('.readme-header').isVisible()).toBe(true);
expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo');
} finally { await page.close(); }
});
test('private repo hidden from other users', async () => {
// Make private
const adminPage = await adminCtx.newPage();
try {
await adminPage.goto(`${BASE}/my-repo/settings`);
await adminPage.check('[name=is_private]');
await adminPage.click('form[action$="/settings"] button[type=submit]');
expect(await adminPage.locator('.form-success').isVisible()).toBe(true);
} finally { await adminPage.close(); }
// Alice should get 404
const alicePage = await aliceCtx.newPage();
try {
const resp = await alicePage.request.get(`${BASE}/my-repo`);
expect(resp.status()).toBe(404);
await alicePage.goto(BASE);
expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo');
} finally { await alicePage.close(); }
// Restore to public
const adminPage2 = await adminCtx.newPage();
try {
await adminPage2.goto(`${BASE}/my-repo/settings`);
await adminPage2.uncheck('[name=is_private]');
await adminPage2.click('form[action$="/settings"] button[type=submit]');
} finally { await adminPage2.close(); }
});
test('settings tab visible for admin, hidden for others', async () => {
const adminPage = await adminCtx.newPage();
try {
await adminPage.goto(`${BASE}/my-repo`);
expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true);
} finally { await adminPage.close(); }
const alicePage = await aliceCtx.newPage();
try {
await alicePage.goto(`${BASE}/my-repo`);
expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0);
} finally { await alicePage.close(); }
});
});
// ─── Issues ───────────────────────────────────────────────────────────────────
describe('issues', () => {
let adminCtx: BrowserContext;
let issueUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
});
afterAll(async () => { await adminCtx.close(); });
test('create issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'First issue');
await page.fill('[name=body]', 'Body with **markdown**.');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
issueUrl = page.url();
expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue');
} finally { await page.close(); }
});
test('issue body renders markdown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>');
} finally { await page.close(); }
});
test('issue appears in open list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('First issue'))).toBe(true);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from new issue form', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('add comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
const beforeCount = await page.locator('.timeline-item').count();
await page.fill('textarea[name=body]', 'A follow-up comment.');
await page.click('form[action*="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount);
} finally { await page.close(); }
});
test('react to issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.locator('.reaction-picker').first().click();
await page.locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('close issue changes status badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('.issue-detail-meta-actions button');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('closed');
} finally { await page.close(); }
});
test('closed issue appears in closed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues?status=closed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('First issue'))).toBe(true);
} finally { await page.close(); }
});
test('reopen issue', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.click('.issue-detail-meta-actions button');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-badge').textContent()).toBe('open');
} finally { await page.close(); }
});
});
// ─── Patches ──────────────────────────────────────────────────────────────────
describe('patches', () => {
let adminCtx: BrowserContext;
let cleanPatchUrl: string;
let conflictPatchUrl: string;
let closePatchUrl: string;
// Adds a new file — applies cleanly to my-repo
const CLEAN_PATCH = [
'diff --git a/patch-test.txt b/patch-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/patch-test.txt',
'@@ -0,0 +1 @@',
'+patch test content',
'',
].join('\n');
// References non-existent lines in README.md — always conflicts
const CONFLICT_PATCH = [
'diff --git a/README.md b/README.md',
'index abc1234..def5678 100644',
'--- a/README.md',
'+++ b/README.md',
'@@ -50,3 +50,3 @@',
' nonexistent context line',
'-nonexistent old line',
'+nonexistent new line',
'',
].join('\n');
// Adds another new file — for testing close flow
const CLOSE_PATCH = [
'diff --git a/patch-close.txt b/patch-close.txt',
'new file mode 100644',
'index 0000000..9daeafb',
'--- /dev/null',
'+++ b/patch-close.txt',
'@@ -0,0 +1 @@',
'+close test',
'',
].join('\n');
beforeAll(async () => {
adminCtx = await loggedInContext();
});
afterAll(async () => { await adminCtx.close(); });
test('reject file without patch markers', async () => {
writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Bad patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('valid patch');
} finally { await page.close(); }
});
test('upload clean patch', async () => {
writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Add patch-test.txt');
await page.fill('[name=description]', 'Adds a file with **markdown** desc.');
await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
cleanPatchUrl = page.url();
expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt');
} finally { await page.close(); }
});
test('patch description renders markdown', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>');
} finally { await page.close(); }
});
test('clean patch shows apply-clean status immediately', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('.apply-result').isVisible()).toBe(true);
expect(await page.locator('.apply-clean').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('merge button appears for clean patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch diff is displayed with highlighted table', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl + '?tab=changes');
expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('patch appears in open list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from patch upload', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('upload conflict patch', async () => {
writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Conflict patch');
await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
conflictPatchUrl = page.url();
} finally { await page.close(); }
});
test('conflict patch shows apply-conflict status', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
expect(await page.locator('.apply-conflict').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('merge button absent for conflict patch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
expect(await page.locator('form[action*="/merge"] button').count()).toBe(0);
} finally { await page.close(); }
});
test('merge clean patch changes status to merged', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
await page.click('form[action*="/merge"] button');
await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('merged');
} finally { await page.close(); }
});
test('merged patch appears in merged list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches?status=merged`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
} finally { await page.close(); }
});
test('upload and close a patch', async () => {
writeTempFile('/tmp/close.patch', CLOSE_PATCH);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'Close me');
await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch');
await page.click('form[action$="/patches"] button[type=submit]');
await page.waitForURL(/\/my-repo\/patches\/\d+/);
closePatchUrl = page.url();
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('closed');
} finally { await page.close(); }
});
test('closed patch appears in closed list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches?status=closed`);
const titles = await page.locator('.issue-title').allTextContents();
expect(titles.some(t => t.includes('Close me'))).toBe(true);
} finally { await page.close(); }
});
test('closed patch can be reopened', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(closePatchUrl);
expect(await page.locator('.patch-badge').textContent()).toBe('closed');
await page.click('form[action*="/close"] button');
await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
expect(await page.locator('.patch-badge').textContent()).toBe('open');
} finally { await page.close(); }
});
test('patch title and description can be edited', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
await page.click('.timeline-author .inline-edit-details summary');
await page.fill('.inline-edit-form-area [name=title]', 'Edited Conflict Patch');
await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
await page.click('.inline-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
} finally { await page.close(); }
});
test('patch comment: add and edit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
await page.fill('[name=body]', 'My patch comment');
await page.click('form[action$="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment');
// Edit the comment — scope to the timeline-item containing the comment text
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' });
await commentItem.locator('.inline-edit-details summary').click();
await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment');
await commentItem.locator('.inline-edit-form-area [type=submit]').click();
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment');
} finally { await page.close(); }
});
test('patch reaction on description', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(conflictPatchUrl);
// Open reaction picker on the first timeline-item (description)
await page.locator('.timeline-item').first().locator('.reaction-add-btn').click();
await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click();
await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
} finally { await page.close(); }
});
});
// ─── Pagination ───────────────────────────────────────────────────────────────
describe('pagination', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a repo dedicated to pagination testing
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'paged-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/paged-repo`);
} finally { await page.close(); }
// Create 21 issues via the API (triggers page 2 at 20 per page)
await bulkCreateIssues(adminCtx, 'paged-repo', 21);
// Create 21 patches via browser (patch upload requires multipart)
await bulkCreatePatches(adminCtx, 'paged-repo', 21);
});
afterAll(async () => { await adminCtx.close(); });
// ── Repo list pagination ──────────────────────────────────────────────────
test('repo list page 1 shows repos and no pagination when few repos', async () => {
// With only a handful of test repos (< 20), there should be no pagination nav
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
// Repos are shown
expect(await page.locator('.repo-name').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
// ── Issue pagination ──────────────────────────────────────────────────────
test('issue list page 1 shows at most 20 items', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('issue list pagination nav appears when more than 20 issues', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('issue list page 2 shows remaining issues', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('issue list page 2 prev link goes to page 1', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues?page=2`);
const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href');
expect(prevHref).toContain('page=1');
} finally { await page.close(); }
});
test('issue list page 1 next link goes to page 2', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/issues`);
const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href');
expect(nextHref).toContain('page=2');
} finally { await page.close(); }
});
// ── Patch pagination ──────────────────────────────────────────────────────
test('patch list page 1 shows at most 20 items', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('patch list pagination nav appears when more than 20 patches', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch list page 2 shows remaining patches', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/paged-repo/patches?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
} finally { await page.close(); }
});
// ── Commit log pagination ─────────────────────────────────────────────────
test('commit log with few commits shows no cursor nav', async () => {
// my-repo has 1 commit — both newer and older links should be absent
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
expect(await page.locator('.commit-cursor-nav').count()).toBe(0);
} finally { await page.close(); }
});
});
// ─── Branch selector ──────────────────────────────────────────────────────────
describe('branch selector', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Add a second branch so the selector is meaningful
await seedBranch('my-repo', 'dev');
});
afterAll(async () => { await adminCtx.close(); });
test('branch selector appears on repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
expect(await page.locator('.branch-selector').isVisible()).toBe(true);
expect(await page.locator('.branch-select').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('branch selector shows all branches on repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const options = await page.locator('.branch-select option').allTextContents();
expect(options).toContain('main');
expect(options).toContain('dev');
} finally { await page.close(); }
});
test('branch selector appears on file tree with current ref selected', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main`);
expect(await page.locator('.branch-selector').isVisible()).toBe(true);
expect(await page.locator('.branch-select').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('branch selector appears on commit log with current ref selected', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
expect(await page.locator('.branch-selector').isVisible()).toBe(true);
expect(await page.locator('.branch-select').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('branch selector appears on file blob', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/blob/main/README.md`);
expect(await page.locator('.branch-selector').isVisible()).toBe(true);
expect(await page.locator('.branch-select').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('switching branch on commit log navigates to the selected branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
await page.locator('.branch-select').selectOption('dev');
await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
await page.waitForURL(`${BASE}/my-repo/commits/dev`);
expect(page.url()).toContain('/commits/dev');
} finally { await page.close(); }
});
test('switching branch on file tree navigates to the selected branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main`);
await page.locator('.branch-select').selectOption('dev');
await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
await page.waitForURL(`${BASE}/my-repo/tree/dev`);
expect(page.url()).toContain('/tree/dev');
} finally { await page.close(); }
});
test('branch-switch route preserves subpath when switching tree', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(
`${BASE}/my-repo/branch-switch?view=tree&ref=dev&path=src/foo`,
{ maxRedirects: 0 },
).catch(r => r);
// 302 redirect to /my-repo/tree/dev/src/foo
const loc = (resp as any).headers()?.['location'] ?? '';
expect(loc).toContain('/tree/dev/src/foo');
} finally { await page.close(); }
});
test('branch-switch route redirects commits view correctly', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(
`${BASE}/my-repo/branch-switch?view=commits&ref=dev`,
{ maxRedirects: 0 },
).catch(r => r);
const loc = (resp as any).headers()?.['location'] ?? '';
expect(loc).toContain('/commits/dev');
} finally { await page.close(); }
});
test('branch-switch route redirects blob view correctly', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(
`${BASE}/my-repo/branch-switch?view=blob&ref=dev&path=README.md`,
{ maxRedirects: 0 },
).catch(r => r);
const loc = (resp as any).headers()?.['location'] ?? '';
expect(loc).toContain('/blob/dev/README.md');
} finally { await page.close(); }
});
});
// ─── Default branch settings ──────────────────────────────────────────────────
describe('default branch settings', () => {
// Runs after 'branch selector', so my-repo already has both main and dev branches.
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('settings page shows default branch select', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
expect(await page.locator('#default_branch').isVisible()).toBe(true);
const options = await page.locator('#default_branch option').allTextContents();
expect(options).toContain('main');
expect(options).toContain('dev');
} finally { await page.close(); }
});
test('current default branch is pre-selected', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
expect(await page.locator('#default_branch').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('changing default branch saves and is reflected in the repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.locator('#default_branch').selectOption('dev');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
// The select now shows dev as current
expect(await page.locator('#default_branch').inputValue()).toBe('dev');
// Repo home branch selector should reflect the new default
await page.goto(`${BASE}/my-repo`);
expect(await page.locator('.branch-select').inputValue()).toBe('dev');
} finally { await page.close(); }
});
test('commit log link in repo nav uses the new default branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href');
expect(commitsHref).toContain('/commits/dev');
} finally { await page.close(); }
});
test('changing default branch back to main restores original state', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.locator('#default_branch').selectOption('main');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
expect(await page.locator('#default_branch').inputValue()).toBe('main');
} finally { await page.close(); }
});
test('settings page shows hint instead of select when repo has no branches', async () => {
// Create an empty repo (no commits → no branches)
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'empty-for-branch-test');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/empty-for-branch-test`);
await page.goto(`${BASE}/empty-for-branch-test/settings`);
expect(await page.locator('#default_branch').count()).toBe(0);
expect(await page.locator('.form-hint').isVisible()).toBe(true);
} finally { await page.close(); }
});
});
// ─── File browser ─────────────────────────────────────────────────────────────
describe('file browser', () => {
// my-repo already has README.md + index.js from the 'repos' describe block.
// We add a subdirectory here so we can test directory navigation.
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
await seedSubdir('my-repo', 'src', {
'app.ts': 'export {};\n',
'README.md': '# src readme\n',
});
});
afterAll(async () => { await adminCtx.close(); });
test('repo home shows file tree instead of recent commits', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
expect(await page.locator('.file-tree').isVisible()).toBe(true);
expect(await page.locator('.repo-commits-section').count()).toBe(0);
} finally { await page.close(); }
});
test('repo home file tree lists files and directories', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const names = await page.locator('.file-name a').allTextContents();
expect(names).toContain('README.md');
expect(names).toContain('index.js');
expect(names).toContain('src');
} finally { await page.close(); }
});
test('directories appear before files in file tree', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const names = await page.locator('.file-name a').allTextContents();
const srcIdx = names.indexOf('src');
const readmeIdx = names.indexOf('README.md');
expect(srcIdx).toBeGreaterThanOrEqual(0);
expect(readmeIdx).toBeGreaterThanOrEqual(0);
expect(srcIdx).toBeLessThan(readmeIdx);
} finally { await page.close(); }
});
test('no ".." entry at repository root', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const names = await page.locator('.file-name a').allTextContents();
expect(names).not.toContain('..');
} finally { await page.close(); }
});
test('clicking directory navigates into it', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
await page.locator('.file-name a', { hasText: 'src' }).click();
await page.waitForURL(`${BASE}/my-repo/tree/main/src`);
expect(page.url()).toContain('/tree/main/src');
} finally { await page.close(); }
});
test('".." entry appears in subdirectory', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main/src`);
const names = await page.locator('.file-name a').allTextContents();
expect(names).toContain('..');
} finally { await page.close(); }
});
test('".." at one level deep links to tree root', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main/src`);
const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href');
expect(upHref).toBe('/my-repo/tree/main');
} finally { await page.close(); }
});
test('files in subdirectory show plain names, not full paths', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main/src`);
const names = await page.locator('.file-name a').allTextContents();
expect(names).toContain('app.ts');
// Must NOT contain the full path with prefix
expect(names).not.toContain('src/app.ts');
expect(names).not.toContain('src/README.md');
} finally { await page.close(); }
});
test('readme is shown below file tree on repo home', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo`);
const treeBox = await page.locator('.file-tree').boundingBox();
const readmeBox = await page.locator('.readme-section').boundingBox();
expect(treeBox).not.toBeNull();
expect(readmeBox).not.toBeNull();
expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1);
} finally { await page.close(); }
});
test('readme in subdirectory is shown when present', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main/src`);
expect(await page.locator('.readme-section').isVisible()).toBe(true);
expect(await page.locator('.readme-section .markdown-body').innerHTML())
.toContain('src readme');
} finally { await page.close(); }
});
test('file tree on /tree/:ref also shows readme', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/tree/main`);
expect(await page.locator('.file-tree').isVisible()).toBe(true);
expect(await page.locator('.readme-section').isVisible()).toBe(true);
} finally { await page.close(); }
});
});
// ─── Releases ─────────────────────────────────────────────────────────────────
describe('releases', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
let commitHash: string;
let releaseUrl: string;
let srcReleaseUrl: string;
let releaseWithAssetsUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create a dedicated repo with at least one commit
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'releases-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/releases-repo`);
} finally { await page.close(); }
await seedRepo('releases-repo');
commitHash = await getHeadCommit('releases-repo');
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
// ── Navigation ──────────────────────────────────────────────────────────────
test('releases tab visible in repo nav', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo`);
expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true);
} finally { await page.close(); }
});
test('releases list shows empty state when no releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.empty-state').isVisible()).toBe(true);
} finally { await page.close(); }
});
// ── Access control ──────────────────────────────────────────────────────────
test('non-admin cannot access /releases/new', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/releases-repo/releases/new`);
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('non-admin POST to /releases returns 403', async () => {
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
multipart: { tag_name: 'v0.1.0', commit_hash: commitHash },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('unauthenticated user is redirected to login from /releases/new', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases/new`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
// ── Validation ──────────────────────────────────────────────────────────────
test('empty tag name shows error', async () => {
// Omit tag_name entirely — route now uses t.Optional so app validation runs
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { commit_hash: commitHash },
});
expect(await resp.text()).toContain('Tag name is required');
});
test('invalid commit hash shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { tag_name: 'v-bad', commit_hash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' },
});
expect(await resp.text()).toContain('Invalid commit');
});
// ── Create ──────────────────────────────────────────────────────────────────
test('create a basic release', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {
tag_name: 'v1.0.0',
name: 'First release',
commit_hash: commitHash,
notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
},
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
expect(location).toMatch(/\/releases-repo\/releases\/\d+/);
releaseUrl = `${BASE}${location}`;
});
test('duplicate tag name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { tag_name: 'v1.0.0', commit_hash: commitHash },
});
expect(await resp.text()).toContain('already exists');
});
// ── List ────────────────────────────────────────────────────────────────────
test('release appears in list with tag badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.release-item-title').textContent()).toContain('First release');
expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
} finally { await page.close(); }
});
test('release list shows commit hash link', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.release-item-meta a.monospace').textContent())
.toBe(commitHash.slice(0, 8));
} finally { await page.close(); }
});
test('new release button hidden for non-admin', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0);
} finally { await page.close(); }
});
// ── Detail ──────────────────────────────────────────────────────────────────
test('release detail shows title, tag badge, and commit link', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseUrl);
expect(await page.locator('h2.page-title').textContent()).toBe('First release');
expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
expect(await page.locator('.release-item-meta a.monospace').textContent())
.toBe(commitHash.slice(0, 8));
} finally { await page.close(); }
});
test('release notes rendered in detail view', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseUrl);
expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release');
} finally { await page.close(); }
});
// ── Source archives ─────────────────────────────────────────────────────────
test('create release with source code archives', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { tag_name: 'v1.1.0', commit_hash: commitHash, include_source_code: 'on' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
srcReleaseUrl = `${BASE}${location}`;
});
test('zip and tar.gz archives appear in downloads', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const assetNames = await page.locator('.asset-name').allTextContents();
expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true);
expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true);
} finally { await page.close(); }
});
test('source archive download responds with 200', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href');
const resp = await page.request.get(`${BASE}${zipLink}`);
expect(resp.status()).toBe(200);
} finally { await page.close(); }
});
// ── File upload ─────────────────────────────────────────────────────────────
test('create release with attached file', async () => {
const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: {
tag_name: 'v1.2.0',
commit_hash: commitHash,
files: {
name: 'release-asset.txt',
mimeType: 'text/plain',
buffer: Buffer.from('binary-like content for testing\n'),
},
},
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location']!;
releaseWithAssetsUrl = `${BASE}${location}`;
});
test('uploaded asset appears in downloads with filename and size', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseWithAssetsUrl);
const names = await page.locator('.asset-name').allTextContents();
expect(names.some(n => n.includes('release-asset.txt'))).toBe(true);
expect(await page.locator('.asset-size').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('asset download responds with 200', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(releaseWithAssetsUrl);
const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href');
const resp = await page.request.get(`${BASE}${link}`);
expect(resp.status()).toBe(200);
} finally { await page.close(); }
});
// ── Delete ──────────────────────────────────────────────────────────────────
test('non-admin cannot delete a release', async () => {
const page = await aliceCtx.newPage();
try {
const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('admin can delete a release', async () => {
const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
const resp = await adminCtx.request.post(
`${BASE}/releases-repo/releases/${idMatch![1]}/delete`,
{ maxRedirects: 0 },
);
expect(resp.status()).toBe(302);
// Verify it's gone from the list
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
const titles = await page.locator('.release-item-title').allTextContents();
expect(titles.some(t => t.includes('First release'))).toBe(false);
} finally { await page.close(); }
});
// ── Pagination ──────────────────────────────────────────────────────────────
test('release list shows at most 20 per page', async () => {
// Bulk-create 25 releases with a distinct prefix to guarantee > 20 total
// regardless of which browser-based tests above succeeded
for (let i = 1; i <= 25; i++) {
await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
multipart: { tag_name: `v9.${i}.0`, commit_hash: commitHash },
maxRedirects: 0,
}).catch(() => {});
}
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('pagination nav appears with more than 20 releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases`);
expect(await page.locator('.pagination').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('release list page 2 shows remaining releases', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/releases-repo/releases?page=2`);
const count = await page.locator('.issue-item').count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThanOrEqual(20);
} finally { await page.close(); }
});
test('source archive tar.zst appears in downloads', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(srcReleaseUrl);
const assetNames = await page.locator('.asset-name').allTextContents();
expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true);
} finally { await page.close(); }
});
});
// ─── Settings ─────────────────────────────────────────────────────────────────
describe('settings', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
// Public key generated once in beforeAll, reused across SSH key tests
let testPubKey: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Generate a throwaway ed25519 key for SSH key tests.
// Use Bun.spawn so the empty passphrase arg is passed correctly.
const keyPath = '/tmp/hf-e2e-sshkey';
await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
const keygen = Bun.spawn(
['ssh-keygen', '-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
{ stdout: 'ignore', stderr: 'ignore' },
);
await keygen.exited;
testPubKey = await Bun.file(`${keyPath}.pub`).text();
testPubKey = testPubKey.trim();
await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('settings page requires auth', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(page.url()).toContain('/login');
} finally { await ctx.close(); }
});
test('settings page loads for logged-in user', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
} finally { await page.close(); }
});
// ── Password ──────────────────────────────────────────────────────────────
test('password change with mismatched passwords shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=new_password]', 'newpass123');
await page.fill('[name=confirm_password]', 'different456');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('password change with wrong current password shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=current_password]', 'wrongpassword');
await page.fill('[name=new_password]', 'newpass123');
await page.fill('[name=confirm_password]', 'newpass123');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('password change too short shows error', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('[name=current_password]', 'password123');
await page.fill('[name=new_password]', 'short');
await page.fill('[name=confirm_password]', 'short');
await page.click('form[action="/settings/password"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
// ── SSH keys ──────────────────────────────────────────────────────────────
test('add SSH key with unsupported key type shows error', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'Bad key');
await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('add valid SSH key shows success and key appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'My Laptop');
await page.fill('#ssh_public_key', testPubKey);
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=ssh_key_added');
await page.goto(`${BASE}/settings`);
expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
} finally { await page.close(); }
});
test('add duplicate SSH key shows error', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#ssh_key_name', 'Duplicate');
await page.fill('#ssh_public_key', testPubKey);
await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('delete SSH key removes it from list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
// Click the Remove button for the key added above
await page.click('form[action="/settings/ssh-keys/delete"] button');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=ssh_key_deleted');
await page.goto(`${BASE}/settings`);
expect(await page.locator('.ssh-key-name').count()).toBe(0);
} finally { await page.close(); }
});
// ── Admin user management ────────────────────────────────────────────────
test('admin can create a new user account', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#new_username', 'charlie');
await page.fill('#new_user_password', 'charliepw1');
await page.click('form[action="/admin/users"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('success=user_created');
} finally { await page.close(); }
});
test('admin cannot create duplicate username', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
await page.fill('#new_username', 'charlie');
await page.fill('#new_user_password', 'charliepw1');
await page.click('form[action="/admin/users"] button[type=submit]');
await page.waitForURL(/\/settings/);
expect(page.url()).toContain('error');
} finally { await page.close(); }
});
test('admin cannot create user with invalid username characters', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
form: { username: 'bad user!', password: 'password123' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const location = resp.headers()['location'] ?? '';
expect(location).toContain('error');
});
test('non-admin gets 403 when creating user', async () => {
const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
form: { username: 'hacker', password: 'password123' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
});
test('admin can delete user account', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
form: { username: 'charlie' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('success=user_deleted');
});
test('admin cannot delete the admin account', async () => {
const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
form: { username: 'admin' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('error');
});
});
// ─── Repository deletion ──────────────────────────────────────────────────────
describe('repository deletion', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
// Create a repo to delete
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'deleteme-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/deleteme-repo`);
} finally { await page.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('admin can delete repository', async () => {
const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toBe('/');
});
test('deleted repository returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/deleteme-repo`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('deleted repository no longer appears in list', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(BASE);
expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
} finally { await page.close(); }
});
test('non-admin cannot delete repository', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally {
await page.close();
await aliceCtx.close();
}
});
});
// ─── 404 handling ─────────────────────────────────────────────────────────────
describe('404 handling', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('non-existent repository returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/no-such-repo`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent issue returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent commit returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
test('non-existent file blob returns 404', async () => {
const page = await adminCtx.newPage();
try {
const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
expect(resp.status()).toBe(404);
} finally { await page.close(); }
});
});
// ─── Issue editing and deletion ───────────────────────────────────────────────
describe('issue editing', () => {
let adminCtx: BrowserContext;
let aliceCtx: BrowserContext;
let issueUrl: string;
beforeAll(async () => {
adminCtx = await loggedInContext();
aliceCtx = await loggedInContext('alice', 'password123');
// Create an issue to edit
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', 'Issue to edit');
await page.fill('[name=body]', 'Original body.');
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
issueUrl = page.url();
} finally { await page.close(); }
});
afterAll(async () => {
await adminCtx.close();
await aliceCtx.close();
});
test('author can edit issue title and body', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
await page.locator('.timeline-author .inline-edit-details').first().locator('summary').click();
await page.fill('.inline-edit-form-area [name=title]', 'Edited issue title');
await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
await page.click('.inline-edit-form-area [type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
} finally { await page.close(); }
});
test('non-author non-admin cannot edit issue', async () => {
const page = await aliceCtx.newPage();
try {
const issueNum = issueUrl.split('/issues/')[1];
const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
form: { title: 'Hacked title', edit_body: '' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await page.close(); }
});
test('author can edit issue comment', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
// Add a comment first
await page.fill('textarea[name=body]', 'Comment to edit.');
await page.click('form[action*="/comments"] button[type=submit]');
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
// Edit the comment
const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
await commentItem.locator('.inline-edit-details summary').click();
await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
await commentItem.locator('.inline-edit-form-area [type=submit]').click();
await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
} finally { await page.close(); }
});
test('non-admin user can create an issue', async () => {
const page = await aliceCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/issues/new`);
await page.fill('[name=title]', "Alice's issue");
await page.click('form[action$="/issues"] button[type=submit]');
await page.waitForURL(/\/my-repo\/issues\/\d+/);
expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
} finally { await page.close(); }
});
test('non-admin cannot comment on a closed issue', async () => {
// Close the issue as admin first
const issueNum = issueUrl.split('/issues/')[1];
await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
const page = await aliceCtx.newPage();
try {
const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
form: { body: 'comment on closed issue' },
maxRedirects: 0,
});
// Non-admin gets redirected (silently ignored), not an error
expect(resp.status()).toBe(302);
// The comment should NOT appear
await page.goto(issueUrl);
const bodies = await page.locator('.timeline-body').allTextContents();
expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
} finally { await page.close(); }
});
test('admin can delete issue', async () => {
const issueNum = issueUrl.split('/issues/')[1];
const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
// Issue should be gone
const page = await adminCtx.newPage();
try {
const checkResp = await page.request.get(issueUrl);
expect(checkResp.status()).toBe(404);
} finally { await page.close(); }
});
});
// ─── Repository description update ───────────────────────────────────────────
describe('repo description', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('updating repo description is reflected on list page', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=description]', 'A freshly updated description');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(BASE);
const desc = await page.locator('.repo-description').allTextContents();
expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
} finally { await page.close(); }
});
});
▾Atests/helpers.ts
@@ -0,0 +1,146 @@
import { $ } from 'bun';
import { createServer } from 'net';
import { rmSync, mkdirSync, writeFileSync } from 'fs';
import path from 'path';
import type { Page } from 'playwright';
export const DATA_DIR = './data-test';
export const ADMIN_PASS = 'testpass123';
/** Bind to port 0 and return the OS-assigned free port number. */
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = createServer();
srv.listen(0, '127.0.0.1', () => {
const port = (srv.address() as { port: number }).port;
srv.close((err) => (err ? reject(err) : resolve(port)));
});
srv.on('error', reject);
});
}
// Resolved once in setupTestEnv() and re-exported for tests.
export let PORT = 0;
export let BASE = '';
export async function setupTestEnv() {
PORT = await getFreePort();
BASE = `http://localhost:${PORT}`;
rmSync(DATA_DIR, { recursive: true, force: true });
mkdirSync(`${DATA_DIR}/repos`, { recursive: true });
const init = Bun.spawn(['bun', 'run', 'src/db/init.ts'], {
env: { ...process.env, DATA_DIR, ADMIN_PASSWORD: ADMIN_PASS },
stdout: 'pipe',
stderr: 'pipe',
});
await init.exited;
}
export function spawnServer() {
return Bun.spawn(['bun', 'run', 'src/index.tsx'], {
env: {
...process.env,
PORT: String(PORT),
DATA_DIR,
RATE_LIMIT_DISABLED: 'true',
SSH_DISABLED: 'true',
},
stdout: 'ignore',
stderr: 'ignore',
});
}
export async function waitForServer(maxMs = 10_000) {
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
try {
await fetch(BASE);
return;
} catch {
await Bun.sleep(150);
}
}
throw new Error('Server did not start in time');
}
export async function login(
page: Page,
username = 'admin',
password = ADMIN_PASS,
) {
await page.goto(`${BASE}/login`);
await page.fill('[name=username]', username);
await page.fill('[name=password]', password);
await page.click('button[type=submit]');
await page.waitForURL(BASE + '/');
}
export async function logout(page: Page) {
await page.click('form[action="/logout"] button');
await page.waitForURL(BASE + '/');
}
/** Push an initial commit into a bare repo that already exists on disk. */
export async function seedRepo(name: string) {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
const tmp = `/tmp/hf-seed-${Date.now()}`;
try {
await $`git clone ${repoPath} ${tmp}`.quiet();
await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
await $`git -C ${tmp} config user.name "Test"`.quiet();
writeFileSync(`${tmp}/README.md`, `# ${name}\n`);
writeFileSync(`${tmp}/index.js`, `console.log("hello");\n`);
await $`git -C ${tmp} add -A`.quiet();
await $`git -C ${tmp} commit -m "Initial commit"`.quiet();
await $`git -C ${tmp} push origin HEAD:main`.quiet();
} finally {
await $`rm -rf ${tmp}`.quiet().nothrow();
}
}
export function writeTempFile(path: string, content: string) {
writeFileSync(path, content);
}
/** Commit a subdirectory with the given files into an existing repo on main. */
export async function seedSubdir(repoName: string, dirPath: string, files: Record<string, string>) {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
const tmp = `/tmp/hf-subdir-${Date.now()}`;
try {
await $`git clone ${repoDir} ${tmp}`.quiet();
await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
await $`git -C ${tmp} config user.name "Test"`.quiet();
mkdirSync(path.join(tmp, dirPath), { recursive: true });
for (const [fileName, content] of Object.entries(files)) {
writeFileSync(path.join(tmp, dirPath, fileName), content);
}
await $`git -C ${tmp} add -A`.quiet();
await $`git -C ${tmp} commit -m ${'Add ' + dirPath}`.quiet();
await $`git -C ${tmp} push origin HEAD:main`.quiet();
} finally {
await $`rm -rf ${tmp}`.quiet().nothrow();
}
}
/** Return the HEAD commit hash of a repo. */
export async function getHeadCommit(repoName: string): Promise<string> {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${repoName}.git`;
return (await $`git -C ${repoPath} rev-parse HEAD`.quiet()).text().trim();
}
/** Create a new branch in an existing repo (from current HEAD). */
export async function seedBranch(name: string, branchName: string) {
const repoPath = `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
const tmp = `/tmp/hf-branch-${Date.now()}`;
try {
await $`git clone ${repoPath} ${tmp}`.quiet();
await $`git -C ${tmp} config user.email "test@test.com"`.quiet();
await $`git -C ${tmp} config user.name "Test"`.quiet();
await $`git -C ${tmp} checkout -b ${branchName}`.quiet();
await $`git -C ${tmp} push origin ${branchName}`.quiet();
} finally {
await $`rm -rf ${tmp}`.quiet().nothrow();
}
}
▾Atests/highlight.test.ts
@@ -0,0 +1,37 @@
import { describe, test, expect, beforeAll } from "bun:test";
import { detectLang, highlightStartup } from "../src/services/highlight.ts";
beforeAll(async () => {
await highlightStartup();
});
describe("detectLang", () => {
// Extensions
test("detects TypeScript", () => expect(detectLang("foo.ts")).toBe("typescript"));
test("detects TSX", () => expect(detectLang("foo.tsx")).toBe("tsx"));
test("detects JavaScript", () => expect(detectLang("foo.js")).toBe("javascript"));
test("detects Python", () => expect(detectLang("foo.py")).toBe("python"));
test("detects Rust", () => expect(detectLang("foo.rs")).toBe("rust"));
test("detects Go", () => expect(detectLang("foo.go")).toBe("go"));
test("detects C", () => expect(detectLang("foo.c")).toBe("c"));
test("detects C++", () => expect(detectLang("foo.cpp")).toBe("cpp"));
test("detects C header", () => expect(detectLang("foo.h")).toBe("c"));
test("detects YAML", () => expect(detectLang("foo.yml")).toBe("yaml"));
test("detects TOML", () => expect(detectLang("foo.toml")).toBe("toml"));
test("detects JSON", () => expect(detectLang("foo.json")).toBe("json"));
test("detects Markdown", () => expect(detectLang("foo.md")).toBe("markdown"));
test("detects Shell", () => expect(detectLang("foo.sh")).toBe("shellscript"));
test("detects diff/patch", () => expect(detectLang("foo.patch")).toBe("diff"));
// Specific filenames
test("detects Dockerfile by filename", () => expect(detectLang("Dockerfile")).toBe("docker"));
test("detects Makefile by filename", () => expect(detectLang("Makefile")).toBe("make"));
// Path stripping
test("strips path before detecting", () => expect(detectLang("src/foo/bar.ts")).toBe("typescript"));
test("strips path for Dockerfile", () => expect(detectLang("infra/Dockerfile")).toBe("docker"));
// Fallback
test("falls back to text for unknown extension", () => expect(detectLang("foo.xyz")).toBe("text"));
test("falls back to text for no extension", () => expect(detectLang("LICENSE")).toBe("text"));
});
▾Atsconfig.json
@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}