Add repo visibility toggle to git app

This commit is contained in:
Chris Wanstrath 2026-03-03 08:05:27 -08:00
parent 03b4634e8b
commit c81513b0ea
2 changed files with 130 additions and 20 deletions

View File

@ -2,17 +2,36 @@ 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, rename, rm, stat } from 'fs/promises'
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>>()
@ -92,6 +111,29 @@ const RepoName = define('RepoName', {
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
// ---------------------------------------------------------------------------
@ -111,9 +153,12 @@ interface LayoutProps {
interface RepoListPageProps {
baseUrl: string
repos: Array<{ name: string; commits: boolean; branch: string }>
external: boolean
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility }>
}
type Visibility = 'public' | 'private'
// ---------------------------------------------------------------------------
// Functions
// ---------------------------------------------------------------------------
@ -235,6 +280,11 @@ function findLastFlush(data: Uint8Array): number {
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,
@ -340,6 +390,15 @@ function insertBeforeFlush(gitBody: Uint8Array, msg: Uint8Array): Uint8Array {
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>
@ -373,6 +432,12 @@ function serviceHeader(service: string): Uint8Array {
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
@ -476,29 +541,33 @@ function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
)
}
function RepoListPage({ baseUrl, repos }: RepoListPageProps) {
function RepoListPage({ baseUrl, external, repos }: RepoListPageProps) {
return (
<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>
{!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>
<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 }) => (
{repos.map(({ name, commits, branch, visibility }) => (
<RepoItem>
<div>
<RepoName>{name}</RepoName>
@ -507,6 +576,16 @@ function RepoListPage({ baseUrl, repos }: RepoListPageProps) {
</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>
@ -515,6 +594,7 @@ function RepoListPage({ baseUrl, repos }: RepoListPageProps) {
</RepoItem>
))}
</RepoList>
{!external && <script src="/toggle.js" />}
</>
)}
@ -542,6 +622,10 @@ 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$/, '')
@ -637,9 +721,25 @@ app.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'],
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) {
@ -656,11 +756,20 @@ app.get('/', async c => {
const repos = await listRepos()
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 }
const [commits, branch, visibility] = await Promise.all([
hasCommits(bare),
getDefaultBranch(bare),
getVisibility(name),
])
return { name, commits, branch, visibility }
}))
return c.html(<RepoListPage baseUrl={baseUrl} repos={repoData} />)
// 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

View File

@ -10,6 +10,7 @@
},
"toes": {
"tool": true,
"dashboard": true,
"icon": "🔀"
},
"devDependencies": {