Add git HTTP server tool for push-to-deploy
A toes tool that implements the git smart HTTP protocol, allowing users to push repos via `git push` and clone via `git clone`. On receive, it extracts the code into a timestamped APPS_DIR directory and activates it through the toes sync API.
This commit is contained in:
parent
ffe1df22e6
commit
01f23ace16
45
apps/git/20260228-000000/bun.lock
Normal file
45
apps/git/20260228-000000/bun.lock
Normal file
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
565
apps/git/20260228-000000/index.tsx
Normal file
565
apps/git/20260228-000000/index.tsx
Normal file
|
|
@ -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<string, Promise<void>>()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string | null> {
|
||||
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<string, string>).error ?? `activate returned ${res.status}`
|
||||
console.error(`Activate failed for ${name}@${version}:`, msg)
|
||||
return msg
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function cleanOldVersions(appDir: string): Promise<void> {
|
||||
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<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBareRepo(name: string): Promise<string> {
|
||||
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<string> {
|
||||
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<Uint8Array> | null,
|
||||
): Promise<Response> {
|
||||
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<Response | null> {
|
||||
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<boolean> {
|
||||
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 (
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<ToolScript />
|
||||
<Container>{children}</Container>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
async function listRepos(): Promise<string[]> {
|
||||
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<T>(repo: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = deployLocks.get(repo) ?? Promise.resolve()
|
||||
const { promise: lock, resolve: release } = Promise.withResolvers<void>()
|
||||
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(
|
||||
<Layout title="Git">
|
||||
<Heading>Push to Deploy</Heading>
|
||||
<HelpText>
|
||||
Push a git repository to deploy it as a toes app.
|
||||
The repo must contain a <code>package.json</code> with a <code>scripts.toes</code> entry.
|
||||
</HelpText>
|
||||
|
||||
<CodeBlock>{[
|
||||
'# Add this server as a remote and push',
|
||||
`git remote add toes ${baseUrl}/<app-name>.git`,
|
||||
'git push toes main',
|
||||
'',
|
||||
'# Or push an existing repo',
|
||||
`git push ${baseUrl}/<app-name>.git main`,
|
||||
].join('\n')}</CodeBlock>
|
||||
|
||||
{repoData.length > 0 && (
|
||||
<>
|
||||
<Heading>Repositories</Heading>
|
||||
<RepoList>
|
||||
{repoData.map(({ name, commits, branch }) => (
|
||||
<RepoItem>
|
||||
<div>
|
||||
<RepoName>{name}</RepoName>
|
||||
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
||||
git clone {baseUrl}/{name}.git
|
||||
</HelpText>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<Badge>{branch}</Badge>
|
||||
{commits
|
||||
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
||||
: <Badge>empty</Badge>}
|
||||
</div>
|
||||
</RepoItem>
|
||||
))}
|
||||
</RepoList>
|
||||
</>
|
||||
)}
|
||||
|
||||
{repoData.length === 0 && (
|
||||
<HelpText>No repositories yet. Push one to get started.</HelpText>
|
||||
)}
|
||||
</Layout>,
|
||||
)
|
||||
})
|
||||
|
||||
export default app.defaults
|
||||
26
apps/git/20260228-000000/package.json
Normal file
26
apps/git/20260228-000000/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
30
apps/git/20260228-000000/tsconfig.json
Normal file
30
apps/git/20260228-000000/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -206,6 +206,7 @@ export const LogStatus = define('LogStatus', {
|
|||
export const TileGrid = define('TileGrid', {
|
||||
width: '100%',
|
||||
maxWidth: 900,
|
||||
margin: '0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: 20,
|
||||
|
|
|
|||
|
|
@ -164,7 +164,6 @@ export const TabContent = define('TabContent', {
|
|||
active: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user