diff --git a/apps/git/20260228-000000/bun.lock b/apps/git/20260228-000000/bun.lock new file mode 100644 index 0000000..2249ac2 --- /dev/null +++ b/apps/git/20260228-000000/bun.lock @@ -0,0 +1,45 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "git", + "dependencies": { + "@because/forge": "^0.0.1", + "@because/hype": "^0.0.2", + "@because/toes": "^0.0.5", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="], + + "@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="], + + "@because/toes": ["@because/toes@0.0.5", "https://npm.nose.space/@because/toes/-/toes-0.0.5.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-YM1VuR1sym7m7pFcaiqnjg6eJUyhJYUH2ROBb+xi+HEXajq46ZL8KDyyCtz7WiHTfrbxcEWGjqyj20a7UppcJg=="], + + "@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/node": ["@types/node@25.3.3", "https://npm.nose.space/@types/node/-/node-25.3.3.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + + "bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "hono": ["hono@4.12.3", "https://npm.nose.space/hono/-/hono-4.12.3.tgz", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], + + "kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/apps/git/20260228-000000/index.tsx b/apps/git/20260228-000000/index.tsx new file mode 100644 index 0000000..89cdd01 --- /dev/null +++ b/apps/git/20260228-000000/index.tsx @@ -0,0 +1,565 @@ +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 diff --git a/apps/git/20260228-000000/package.json b/apps/git/20260228-000000/package.json new file mode 100644 index 0000000..52045b8 --- /dev/null +++ b/apps/git/20260228-000000/package.json @@ -0,0 +1,26 @@ +{ + "name": "git", + "module": "index.tsx", + "type": "module", + "private": true, + "scripts": { + "toes": "bun run --watch index.tsx", + "start": "bun toes", + "dev": "bun run --hot index.tsx" + }, + "toes": { + "tool": true, + "icon": "🔀" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.9.3" + }, + "dependencies": { + "@because/forge": "^0.0.1", + "@because/hype": "^0.0.2", + "@because/toes": "^0.0.5" + } +} diff --git a/apps/git/20260228-000000/tsconfig.json b/apps/git/20260228-000000/tsconfig.json new file mode 100644 index 0000000..545396c --- /dev/null +++ b/apps/git/20260228-000000/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "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 + } +}