forked from defunkt/toes
776 lines
24 KiB
TypeScript
776 lines
24 KiB
TypeScript
import { Hype } from '@because/hype'
|
|
import { define, stylesToCSS } from '@because/forge'
|
|
import { baseStyles, ToolScript, theme, on } 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 VALID_NAME = /^[a-zA-Z0-9_-]+$/
|
|
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.className = btn.className.replace(' public', '');
|
|
if (next === 'public') btn.className += ' public';
|
|
fetch('/api/visibility/' + encodeURIComponent(repo), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ visibility: next })
|
|
});
|
|
}
|
|
`
|
|
|
|
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'),
|
|
})
|
|
|
|
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 }>
|
|
}
|
|
|
|
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<string | null> {
|
|
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<string, string>).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<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 getVisibility(repo: string): Promise<Visibility> {
|
|
const all = await loadVisibility()
|
|
return all[repo] ?? 'private'
|
|
}
|
|
|
|
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: Uint8Array | 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
|
|
}
|
|
|
|
async function loadVisibility(): Promise<Record<string, Visibility>> {
|
|
try {
|
|
const data = await readFile(VISIBILITY_PATH, 'utf-8')
|
|
return JSON.parse(data)
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
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 saveVisibility(repo: string, visibility: Visibility): Promise<void> {
|
|
const all = await loadVisibility()
|
|
all[repo] = visibility
|
|
await writeFile(VISIBILITY_PATH, JSON.stringify(all, null, 2))
|
|
}
|
|
|
|
async function stopIfRunning(name: string): Promise<void> {
|
|
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<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)
|
|
}
|
|
}
|
|
|
|
function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
|
|
return (
|
|
<Layout title={`Git - ${appName}`}>
|
|
{exists && commits ? (
|
|
<>
|
|
<Heading>Repository</Heading>
|
|
<RepoList>
|
|
<RepoItem>
|
|
<div>
|
|
<RepoName>{appName}</RepoName>
|
|
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
|
git clone {baseUrl}/{appName}
|
|
</HelpText>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; align-items: center">
|
|
<Badge>{branch}</Badge>
|
|
<Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
|
</div>
|
|
</RepoItem>
|
|
</RepoList>
|
|
|
|
<Heading>Push Changes</Heading>
|
|
<CodeBlock>{[
|
|
`git push toes ${branch}`,
|
|
'',
|
|
'# Or if remote not yet added:',
|
|
`git remote add toes ${baseUrl}/${appName}`,
|
|
`git push toes ${branch}`,
|
|
].join('\n')}</CodeBlock>
|
|
</>
|
|
) : exists ? (
|
|
<>
|
|
<Heading>Repository</Heading>
|
|
<RepoList>
|
|
<RepoItem>
|
|
<div>
|
|
<RepoName>{appName}</RepoName>
|
|
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
|
git clone {baseUrl}/{appName}
|
|
</HelpText>
|
|
</div>
|
|
<Badge>empty</Badge>
|
|
</RepoItem>
|
|
</RepoList>
|
|
|
|
<Heading>Push to Deploy</Heading>
|
|
<CodeBlock>{[
|
|
`git remote add toes ${baseUrl}/${appName}`,
|
|
'git push toes main',
|
|
].join('\n')}</CodeBlock>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Heading>Push to Deploy</Heading>
|
|
<HelpText>
|
|
No git repository for <strong>{appName}</strong> yet.
|
|
Push to create one and deploy.
|
|
</HelpText>
|
|
<CodeBlock>{[
|
|
`git remote add toes ${baseUrl}/${appName}`,
|
|
'git push toes main',
|
|
].join('\n')}</CodeBlock>
|
|
</>
|
|
)}
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
function RepoListPage({ baseUrl, external, repos }: RepoListPageProps) {
|
|
return (
|
|
<Layout title="Git">
|
|
{!external && (
|
|
<>
|
|
<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 push toes main',
|
|
'',
|
|
'# Or push an existing repo',
|
|
`git push ${baseUrl}/<app-name> main`,
|
|
].join('\n')}</CodeBlock>
|
|
</>
|
|
)}
|
|
|
|
{repos.length > 0 && (
|
|
<>
|
|
<Heading>Repositories</Heading>
|
|
<RepoList>
|
|
{repos.map(({ name, commits, branch, visibility }) => (
|
|
<RepoItem>
|
|
<div>
|
|
<RepoName>{name}</RepoName>
|
|
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
|
git clone {baseUrl}/{name}
|
|
</HelpText>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; align-items: center">
|
|
{!external && (
|
|
<Toggle
|
|
class={visibility === 'public' ? 'public' : ''}
|
|
data-repo={name}
|
|
data-visibility={visibility}
|
|
onclick="toggleVisibility(this)"
|
|
>
|
|
{visibility === 'public' ? 'public' : 'private'}
|
|
</Toggle>
|
|
)}
|
|
<Badge>{branch}</Badge>
|
|
{commits
|
|
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
|
: <Badge>empty</Badge>}
|
|
</div>
|
|
</RepoItem>
|
|
))}
|
|
</RepoList>
|
|
{!external && <script src="/toggle.js" />}
|
|
</>
|
|
)}
|
|
|
|
{repos.length === 0 && (
|
|
<HelpText>No repositories yet. Push one to get started.</HelpText>
|
|
)}
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Module init
|
|
// ---------------------------------------------------------------------------
|
|
|
|
mkdirSync(REPOS_DIR, { recursive: true })
|
|
|
|
on('app:delete', async ({ app: name }) => {
|
|
const bare = repoPath(name)
|
|
if (await dirExists(bare)) await rm(bare, { recursive: true, force: 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' }),
|
|
)
|
|
|
|
app.get('/toggle.js', c =>
|
|
c.text(TOGGLE_SCRIPT, 200, { 'Content-Type': 'application/javascript; charset=utf-8' }),
|
|
)
|
|
|
|
// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
|
|
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/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.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/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.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/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)
|
|
|
|
// Buffer the request body before passing to git-receive-pack. Piping a live
|
|
// HTTP ReadableStream directly to subprocess stdin deadlocks on large pushes:
|
|
// the pipe buffer fills, stalling the stream reader, while git-receive-pack
|
|
// can't finish reading stdin to produce stdout — both sides block.
|
|
const body = new Uint8Array(await c.req.raw.arrayBuffer())
|
|
const response = await gitRpc(repoParam, 'git-receive-pack', 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) {
|
|
const err = await activateApp(repoParam)
|
|
if (err) {
|
|
console.error(`Reload failed for ${repoParam}: ${err}`)
|
|
return `Deploy succeeded but reload failed: ${err}`
|
|
}
|
|
console.log(`Deployed ${repoParam}`)
|
|
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.post('/api/visibility/:repo', async c => {
|
|
if (c.req.header('x-sneaker')) return c.json({ error: 'Forbidden' }, 403)
|
|
|
|
const repo = c.req.param('repo')
|
|
if (!validRepoName(repo)) return c.json({ error: 'Invalid repository name' }, 400)
|
|
|
|
const body = await c.req.json<{ visibility: string }>()
|
|
if (body.visibility !== 'public' && body.visibility !== 'private') {
|
|
return c.json({ error: 'Visibility must be "public" or "private"' }, 400)
|
|
}
|
|
|
|
await saveVisibility(repo, body.visibility)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
app.get('/', async c => {
|
|
const appName = c.req.query('app')
|
|
const baseUrl = APP_URL
|
|
const external = !!c.req.header('x-sneaker')
|
|
|
|
// When viewing a specific app, only show that app's repo
|
|
if (appName) {
|
|
const bare = repoPath(appName)
|
|
const exists = await dirExists(bare)
|
|
const [commits, branch] = exists
|
|
? await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
|
|
: [false, 'main']
|
|
|
|
return c.html(<AppRepo appName={appName} baseUrl={baseUrl} branch={branch} exists={exists} commits={commits} />)
|
|
}
|
|
|
|
// No app selected — show all repos
|
|
const repos = await listRepos()
|
|
const repoData = await Promise.all(repos.map(async name => {
|
|
const bare = repoPath(name)
|
|
const [commits, branch, visibility] = await Promise.all([
|
|
hasCommits(bare),
|
|
getDefaultBranch(bare),
|
|
getVisibility(name),
|
|
])
|
|
return { name, commits, branch, visibility }
|
|
}))
|
|
|
|
// Hide private repos from external (sneaker) requests
|
|
const filtered = external
|
|
? repoData.filter(r => r.visibility === 'public')
|
|
: repoData
|
|
|
|
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} />)
|
|
})
|
|
|
|
export default app.defaults
|