import { Hype } from '@because/hype' import { define, stylesToCSS } from '@because/forge' import { baseStyles, ToolScript, theme } from '@because/toes/tools' import { mkdirSync } from 'fs' import { mkdir, readdir, readlink, rm, stat } from 'fs/promises' import { join, resolve } from 'path' import type { Child } from 'hono/jsx' const APPS_DIR = process.env.APPS_DIR! const DATA_DIR = process.env.DATA_DIR! const TOES_URL = process.env.TOES_URL! const MAX_VERSIONS = 5 const REPOS_DIR = join(DATA_DIR, 'repos') const VALID_NAME = /^[a-zA-Z0-9_-]+$/ const app = new Hype({ prettyHTML: false, layout: false }) const deployLocks = new Map>() // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const Badge = define('Badge', { fontSize: '12px', padding: '2px 8px', borderRadius: theme('radius-md'), backgroundColor: theme('colors-bgElement'), color: theme('colors-textMuted'), }) const CodeBlock = define('CodeBlock', { base: 'pre', backgroundColor: theme('colors-bgElement'), border: `1px solid ${theme('colors-border')}`, borderRadius: theme('radius-md'), padding: theme('spacing-lg'), fontFamily: theme('fonts-mono'), fontSize: '13px', overflowX: 'auto', color: theme('colors-text'), lineHeight: '1.5', }) const Container = define('Container', { fontFamily: theme('fonts-sans'), padding: '20px', paddingTop: 0, maxWidth: '800px', margin: '0 auto', color: theme('colors-text'), }) const Heading = define('Heading', { base: 'h3', margin: '24px 0 8px', color: theme('colors-text'), }) const HelpText = define('HelpText', { color: theme('colors-textMuted'), fontSize: '14px', lineHeight: '1.6', margin: '12px 0', }) const RepoItem = define('RepoItem', { padding: '12px 15px', borderBottom: `1px solid ${theme('colors-border')}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', states: { ':last-child': { borderBottom: 'none' }, ':hover': { backgroundColor: theme('colors-bgHover') }, }, }) const RepoList = define('RepoList', { listStyle: 'none', padding: 0, margin: '20px 0', border: `1px solid ${theme('colors-border')}`, borderRadius: theme('radius-md'), overflow: 'hidden', }) const RepoName = define('RepoName', { fontFamily: theme('fonts-mono'), fontSize: '15px', fontWeight: 'bold', color: theme('colors-text'), }) // --------------------------------------------------------------------------- // Interfaces // --------------------------------------------------------------------------- interface LayoutProps { title: string children: Child } // --------------------------------------------------------------------------- // Functions // --------------------------------------------------------------------------- const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`) const timestamp = () => { const [date, time] = new Date().toISOString().slice(0, 19).split('T') return `${date.replaceAll('-', '')}-${time.replaceAll(':', '')}` } // resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal const validRepoName = (name: string) => VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name) async function activateApp(name: string, version: string): Promise { const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/activate?version=${version}`, { method: 'POST', }) if (!res.ok) { const body = await res.json().catch(() => ({})) const msg = (body as Record).error ?? `activate returned ${res.status}` console.error(`Activate failed for ${name}@${version}:`, msg) return msg } return null } async function cleanOldVersions(appDir: string): Promise { if (!(await dirExists(appDir))) return // Read the current symlink target so we never delete the active version let current: string | null = null try { const target = await readlink(join(appDir, 'current')) current = target.split('/').pop() ?? null } catch {} const entries = await readdir(appDir, { withFileTypes: true }) const versions = entries .filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name)) .map(e => e.name) .sort() if (versions.length <= MAX_VERSIONS) return const toRemove = versions .slice(0, versions.length - MAX_VERSIONS) .filter(v => v !== current) for (const dir of toRemove) { await rm(join(appDir, dir), { recursive: true, force: true }) } } async function deploy(repoName: string): Promise<{ ok: boolean; error?: string; version?: string }> { const bare = repoPath(repoName) if (!(await hasCommits(bare))) { return { ok: false, error: 'No commits in repository' } } const ts = timestamp() const appDir = join(APPS_DIR, repoName) const versionDir = join(appDir, ts) await mkdir(versionDir, { recursive: true }) // Extract HEAD into the version directory — no shell, pipe git archive into tar const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], { stdout: 'pipe', stderr: 'pipe', }) const tar = Bun.spawn(['tar', '-x', '-C', versionDir], { stdin: archive.stdout, stdout: 'ignore', stderr: 'pipe', }) // Consume stderr concurrently to prevent pipe buffer from filling and blocking the process const [archiveExit, tarExit, archiveErr, tarErr] = await Promise.all([ archive.exited, tar.exited, new Response(archive.stderr).text(), new Response(tar.stderr).text(), ]) if (archiveExit !== 0 || tarExit !== 0) { await rm(versionDir, { recursive: true, force: true }) return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` } } // Verify package.json with scripts.toes exists const pkgPath = join(versionDir, 'package.json') if (!(await Bun.file(pkgPath).exists())) { await rm(versionDir, { recursive: true, force: true }) return { ok: false, error: 'No package.json found in repository' } } try { const pkg = JSON.parse(await Bun.file(pkgPath).text()) if (!pkg.scripts?.toes) { await rm(versionDir, { recursive: true, force: true }) return { ok: false, error: 'package.json missing scripts.toes entry' } } } catch { await rm(versionDir, { recursive: true, force: true }) return { ok: false, error: 'Invalid package.json' } } // Clean up old versions beyond MAX_VERSIONS await cleanOldVersions(appDir) return { ok: true, version: ts } } // Bun.file().exists() is for files only — it returns false for directories. // Use stat() to check directory existence instead. async function dirExists(path: string): Promise { try { return (await stat(path)).isDirectory() } catch { return false } } async function ensureBareRepo(name: string): Promise { const bare = repoPath(name) if (!(await dirExists(bare))) { await mkdir(bare, { recursive: true }) const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: bare }).exited await run(['git', 'init', '--bare']) await run(['git', 'symbolic-ref', 'HEAD', 'refs/heads/main']) await run(['git', 'config', 'http.receivepack', 'true']) } return bare } function findLastFlush(data: Uint8Array): number { for (let i = data.length - 4; i >= 0; i--) { if (data[i] === 0x30 && data[i + 1] === 0x30 && data[i + 2] === 0x30 && data[i + 3] === 0x30) { return i } } return -1 } async function getDefaultBranch(bare: string): Promise { const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], { cwd: bare, stdout: 'pipe', // Ignore stderr to avoid filling the pipe buffer and blocking the process stderr: 'ignore', }) if ((await proc.exited) === 0) { const ref = await new Response(proc.stdout).text() return ref.trim().replace('refs/heads/', '') } return 'main' } async function gitRpc( repo: string, service: string, body: ReadableStream | null, ): Promise { const bare = repoPath(repo) const proc = Bun.spawn([service, '--stateless-rpc', bare], { stdin: body ?? 'ignore', stdout: 'pipe', // Ignore stderr to avoid filling the pipe buffer and blocking the process stderr: 'ignore', }) return new Response(proc.stdout, { headers: { 'Content-Type': `application/x-${service}-result`, 'Cache-Control': 'no-cache', }, }) } async function gitService(repo: string, service: string): Promise { const bare = repoPath(repo) if (!(await dirExists(bare))) return null const proc = Bun.spawn([service, '--stateless-rpc', '--advertise-refs', bare], { stdout: 'pipe', // Ignore stderr to avoid filling the pipe buffer and blocking the process stderr: 'ignore', }) const stdout = new Uint8Array(await new Response(proc.stdout).arrayBuffer()) await proc.exited const header = serviceHeader(service) const body = new Uint8Array(header.length + stdout.byteLength) body.set(header, 0) body.set(stdout, header.length) return new Response(body, { headers: { 'Content-Type': `application/x-${service}-advertisement`, 'Cache-Control': 'no-cache', }, }) } function gitSidebandMessage(text: string): Uint8Array { const encoder = new TextEncoder() const lines = text.split('\n').filter(Boolean) const parts: Uint8Array[] = [] for (const line of lines) { const msg = `\x02remote: ${line}\n` const hex = (4 + msg.length).toString(16).padStart(4, '0') parts.push(encoder.encode(hex + msg)) } const total = parts.reduce((sum, p) => sum + p.length, 0) const out = new Uint8Array(total) let offset = 0 for (const part of parts) { out.set(part, offset) offset += part.length } return out } async function hasCommits(bare: string): Promise { const proc = Bun.spawn(['git', 'rev-parse', 'HEAD'], { cwd: bare, // Only checking exit code; ignore stdout/stderr to avoid filling the pipe buffer stdout: 'ignore', stderr: 'ignore', }) return (await proc.exited) === 0 } function insertBeforeFlush(gitBody: Uint8Array, msg: Uint8Array): Uint8Array { const pos = findLastFlush(gitBody) if (pos === -1) { const out = new Uint8Array(gitBody.length + msg.length) out.set(gitBody, 0) out.set(msg, gitBody.length) return out } const out = new Uint8Array(gitBody.length + msg.length) out.set(gitBody.subarray(0, pos), 0) out.set(msg, pos) out.set(gitBody.subarray(pos), pos + msg.length) return out } function Layout({ title, children }: LayoutProps) { return ( {title} {children} ) } async function listRepos(): Promise { if (!(await dirExists(REPOS_DIR))) return [] const entries = await readdir(REPOS_DIR, { withFileTypes: true }) return entries .filter(e => e.isDirectory() && e.name.endsWith('.git')) .map(e => e.name.replace(/\.git$/, '')) .sort() } function serviceHeader(service: string): Uint8Array { const line = `# service=${service}\n` const hex = (4 + line.length).toString(16).padStart(4, '0') const header = `${hex}${line}0000` return new TextEncoder().encode(header) } async function withDeployLock(repo: string, fn: () => Promise): Promise { const prev = deployLocks.get(repo) ?? Promise.resolve() const { promise: lock, resolve: release } = Promise.withResolvers() deployLocks.set(repo, lock) await prev try { return await fn() } finally { release() if (deployLocks.get(repo) === lock) deployLocks.delete(repo) } } // --------------------------------------------------------------------------- // Module init // --------------------------------------------------------------------------- mkdirSync(REPOS_DIR, { recursive: true }) app.get('/ok', c => c.text('ok')) app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }), ) // GET /:repo.git/info/refs?service=git-upload-pack|git-receive-pack app.get('/:repo{.+\\.git}/info/refs', async c => { const repoParam = c.req.param('repo').replace(/\.git$/, '') const service = c.req.query('service') if (!validRepoName(repoParam)) { return c.text('Invalid repository name', 400) } if (service !== 'git-upload-pack' && service !== 'git-receive-pack') { return c.text('Invalid service', 400) } if (service === 'git-receive-pack') { await ensureBareRepo(repoParam) } const bare = repoPath(repoParam) if (!(await dirExists(bare))) { return c.text('Repository not found', 404) } const res = await gitService(repoParam, service) return res ?? c.text('Repository not found', 404) }) // POST /:repo.git/git-upload-pack app.post('/:repo{.+\\.git}/git-upload-pack', async c => { const repoParam = c.req.param('repo').replace(/\.git$/, '') if (!validRepoName(repoParam)) { return c.text('Invalid repository name', 400) } const bare = repoPath(repoParam) if (!(await dirExists(bare))) { return c.text('Repository not found', 404) } return gitRpc(repoParam, 'git-upload-pack', c.req.raw.body) }) // POST /:repo.git/git-receive-pack app.post('/:repo{.+\\.git}/git-receive-pack', async c => { const repoParam = c.req.param('repo').replace(/\.git$/, '') if (!validRepoName(repoParam)) { return c.text('Invalid repository name', 400) } await ensureBareRepo(repoParam) const response = await gitRpc(repoParam, 'git-receive-pack', c.req.raw.body) // Buffer the full response so we can inject sideband error messages before the // final flush-pkt on deploy failure. The receive-pack response is just ref status // lines (not pack data), so the buffer is small regardless of push size. const gitBody = new Uint8Array(await response.arrayBuffer()) const deployError = await withDeployLock(repoParam, async () => { try { const result = await deploy(repoParam) if (result.ok && result.version) { const err = await activateApp(repoParam, result.version) if (err) { console.error(`Activate failed for ${repoParam}: ${err}`) return `Deploy succeeded but activation failed: ${err}` } console.log(`Deployed ${repoParam}@${result.version}`) return null } console.error(`Deploy failed for ${repoParam}: ${result.error}`) return `Deploy failed: ${result.error}` } catch (e) { console.error(`Deploy error for ${repoParam}:`, e) return `Deploy failed: ${e instanceof Error ? e.message : String(e)}` } }) const headers = { 'Content-Type': response.headers.get('Content-Type') ?? 'application/x-git-receive-pack-result', 'Cache-Control': 'no-cache', } if (deployError) { return new Response(insertBeforeFlush(gitBody, gitSidebandMessage(deployError)), { headers }) } return new Response(gitBody, { headers }) }) app.get('/', async c => { const repos = await listRepos() const host = c.req.header('host') ?? 'git.toes.local' const baseUrl = `http://${host}` const repoData = await Promise.all(repos.map(async name => { const bare = repoPath(name) const [commits, branch] = await Promise.all([hasCommits(bare), getDefaultBranch(bare)]) return { name, commits, branch } })) return c.html( Push to Deploy Push a git repository to deploy it as a toes app. The repo must contain a package.json with a scripts.toes entry. {[ '# Add this server as a remote and push', `git remote add toes ${baseUrl}/.git`, 'git push toes main', '', '# Or push an existing repo', `git push ${baseUrl}/.git main`, ].join('\n')} {repoData.length > 0 && ( <> Repositories {repoData.map(({ name, commits, branch }) => (
{name} git clone {baseUrl}/{name}.git
{branch} {commits ? deployed : empty}
))}
)} {repoData.length === 0 && ( No repositories yet. Push one to get started. )}
, ) }) export default app.defaults