import { Hype } from '@because/hype' import { define, stylesToCSS } from '@because/forge' import { baseStyles, ToolScript, theme, on, VALID_NAME } from '@because/toes/tools' import { mkdirSync } from 'fs' import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises' import { join, resolve } from 'path' import type { Child } from 'hono/jsx' const APP_URL = process.env.APP_URL! const APPS_DIR = process.env.APPS_DIR! const DATA_DIR = process.env.DATA_DIR! const DATA_ROOT = process.env.DATA_ROOT! const TOES_URL = process.env.TOES_URL! const REPOS_DIR = resolve(DATA_ROOT, 'repos') const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json') const TOGGLE_SCRIPT = ` function toggleVisibility(btn) { var repo = btn.dataset.repo; var current = btn.dataset.visibility; var next = current === 'public' ? 'private' : 'public'; btn.dataset.visibility = next; btn.textContent = next; btn.classList.toggle('public', next === 'public'); fetch('/api/visibility/' + encodeURIComponent(repo), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ visibility: next }) }).catch(function() { btn.dataset.visibility = current; btn.textContent = current; btn.classList.toggle('public', current === 'public'); }); } ` 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'), }) const Toggle = define('Toggle', { base: 'button', display: 'inline-flex', alignItems: 'center', gap: '6px', padding: '3px 10px', borderRadius: theme('radius-md'), border: `1px solid ${theme('colors-border')}`, backgroundColor: theme('colors-bgElement'), color: theme('colors-textMuted'), fontSize: '12px', cursor: 'pointer', transition: 'all 0.15s ease', states: { ':hover': { borderColor: theme('colors-textMuted') }, '.public': { backgroundColor: theme('colors-statusRunning'), color: 'white', borderColor: 'transparent', }, }, }) // --------------------------------------------------------------------------- // Interfaces // --------------------------------------------------------------------------- interface AppRepoProps { appName: string baseUrl: string branch: string exists: boolean commits: boolean } interface LayoutProps { title: string children: Child } interface RepoListPageProps { baseUrl: string external: boolean repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility }> tunnelUrl?: string } type Visibility = 'public' | 'private' // --------------------------------------------------------------------------- // Functions // --------------------------------------------------------------------------- const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`) // 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): Promise { const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/reload`, { method: 'POST', }) if (!res.ok) { const body = await res.json().catch(() => ({})) const msg = (body as Record).error ?? `reload returned ${res.status}` console.error(`Reload failed for ${name}:`, msg) return msg } return null } async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }> { const bare = repoPath(repoName) if (!(await hasCommits(bare))) { return { ok: false, error: 'No commits in repository' } } // Validate in a temp dir before touching the real app dir const tmpDir = join(APPS_DIR, `.${repoName}-deploy-tmp`) await rm(tmpDir, { recursive: true, force: true }) await mkdir(tmpDir, { recursive: true }) // Extract HEAD into the temp directory const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], { stdout: 'pipe', stderr: 'pipe', }) const tar = Bun.spawn(['tar', '-x', '-C', tmpDir], { 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(tmpDir, { recursive: true, force: true }) return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` } } // Verify package.json with scripts.toes exists const pkgPath = join(tmpDir, 'package.json') if (!(await Bun.file(pkgPath).exists())) { await rm(tmpDir, { 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(tmpDir, { recursive: true, force: true }) return { ok: false, error: 'package.json missing scripts.toes entry' } } } catch { await rm(tmpDir, { recursive: true, force: true }) return { ok: false, error: 'Invalid package.json' } } // Stop the app before swapping directories await stopIfRunning(repoName) // Validation passed — swap directories (reload endpoint handles restart) const appDir = join(APPS_DIR, repoName) await rm(appDir, { recursive: true, force: true }) await rename(tmpDir, appDir) return { ok: true } } // 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 getVisibility(repo: string): Promise { const all = await loadVisibility() return all[repo] ?? 'private' } 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: Uint8Array | 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 } async function loadVisibility(): Promise> { try { const data = await readFile(VISIBILITY_PATH, 'utf-8') return JSON.parse(data) } catch { return {} } } 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 saveVisibility(repo: string, visibility: Visibility): Promise { const all = await loadVisibility() all[repo] = visibility await writeFile(VISIBILITY_PATH, JSON.stringify(all, null, 2)) } async function stopIfRunning(name: string): Promise { const res = await fetch(`${TOES_URL}/api/apps/${name}`) if (!res.ok) return const app = await res.json() as { state: string } if (app.state !== 'running' && app.state !== 'starting') return await fetch(`${TOES_URL}/api/apps/${name}/stop`, { method: 'POST' }) const maxWait = 10000 const poll = 100 let waited = 0 while (waited < maxWait) { await new Promise(r => setTimeout(r, poll)) waited += poll const check = await fetch(`${TOES_URL}/api/apps/${name}`) if (!check.ok) break const { state } = await check.json() as { state: string } if (state !== 'running' && state !== 'stopping' && state !== 'starting') break } } 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) } } function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) { return ( {exists && commits ? ( <> Repository
{appName} git clone {baseUrl}/{appName}
{branch} deployed
Push Changes {[ `git push toes ${branch}`, '', '# Or if remote not yet added:', `git remote add toes ${baseUrl}/${appName}`, `git push toes ${branch}`, ].join('\n')} ) : exists ? ( <> Repository
{appName} git clone {baseUrl}/{appName}
empty
Push to Deploy {[ `git remote add toes ${baseUrl}/${appName}`, 'git push toes main', ].join('\n')} ) : ( <> Push to Deploy No git repository for {appName} yet. Push to create one and deploy. {[ `git remote add toes ${baseUrl}/${appName}`, 'git push toes main', ].join('\n')} )}
) } function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps) { return ( {!external && ( <> 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 push toes main', '', '# Or push an existing repo', `git push ${baseUrl}/ main`, ].join('\n')} )} {repos.length > 0 && ( <> Repositories {repos.map(({ name, commits, branch, visibility }) => (
{name} git clone {baseUrl}/{name} {!external && tunnelUrl && visibility === 'public' && ( git clone {tunnelUrl}/{name} )}
{!external && ( {visibility === 'public' ? 'public' : 'private'} )} {branch} {commits ? deployed : empty}
))}
{!external &&