forked from defunkt/toes
Add repo visibility toggle to git app
This commit is contained in:
parent
03b4634e8b
commit
c81513b0ea
|
|
@ -2,17 +2,36 @@ import { Hype } from '@because/hype'
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
import { baseStyles, ToolScript, theme, on } from '@because/toes/tools'
|
import { baseStyles, ToolScript, theme, on } from '@because/toes/tools'
|
||||||
import { mkdirSync } from 'fs'
|
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 { join, resolve } from 'path'
|
||||||
import type { Child } from 'hono/jsx'
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
const APP_URL = process.env.APP_URL!
|
const APP_URL = process.env.APP_URL!
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
const DATA_DIR = process.env.DATA_DIR!
|
||||||
const DATA_ROOT = process.env.DATA_ROOT!
|
const DATA_ROOT = process.env.DATA_ROOT!
|
||||||
const TOES_URL = process.env.TOES_URL!
|
const TOES_URL = process.env.TOES_URL!
|
||||||
|
|
||||||
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
|
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
|
||||||
const VALID_NAME = /^[a-zA-Z0-9_-]+$/
|
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 app = new Hype({ prettyHTML: false, layout: false })
|
||||||
const deployLocks = new Map<string, Promise<void>>()
|
const deployLocks = new Map<string, Promise<void>>()
|
||||||
|
|
@ -92,6 +111,29 @@ const RepoName = define('RepoName', {
|
||||||
color: theme('colors-text'),
|
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
|
// Interfaces
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -111,9 +153,12 @@ interface LayoutProps {
|
||||||
|
|
||||||
interface RepoListPageProps {
|
interface RepoListPageProps {
|
||||||
baseUrl: string
|
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
|
// Functions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -235,6 +280,11 @@ function findLastFlush(data: Uint8Array): number {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getVisibility(repo: string): Promise<Visibility> {
|
||||||
|
const all = await loadVisibility()
|
||||||
|
return all[repo] ?? 'private'
|
||||||
|
}
|
||||||
|
|
||||||
async function getDefaultBranch(bare: string): Promise<string> {
|
async function getDefaultBranch(bare: string): Promise<string> {
|
||||||
const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], {
|
const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], {
|
||||||
cwd: bare,
|
cwd: bare,
|
||||||
|
|
@ -340,6 +390,15 @@ function insertBeforeFlush(gitBody: Uint8Array, msg: Uint8Array): Uint8Array {
|
||||||
return out
|
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) {
|
function Layout({ title, children }: LayoutProps) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -373,6 +432,12 @@ function serviceHeader(service: string): Uint8Array {
|
||||||
return new TextEncoder().encode(header)
|
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> {
|
async function stopIfRunning(name: string): Promise<void> {
|
||||||
const res = await fetch(`${TOES_URL}/api/apps/${name}`)
|
const res = await fetch(`${TOES_URL}/api/apps/${name}`)
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
|
|
@ -476,9 +541,11 @@ function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RepoListPage({ baseUrl, repos }: RepoListPageProps) {
|
function RepoListPage({ baseUrl, external, repos }: RepoListPageProps) {
|
||||||
return (
|
return (
|
||||||
<Layout title="Git">
|
<Layout title="Git">
|
||||||
|
{!external && (
|
||||||
|
<>
|
||||||
<Heading>Push to Deploy</Heading>
|
<Heading>Push to Deploy</Heading>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Push a git repository to deploy it as a toes app.
|
Push a git repository to deploy it as a toes app.
|
||||||
|
|
@ -493,12 +560,14 @@ function RepoListPage({ baseUrl, repos }: RepoListPageProps) {
|
||||||
'# Or push an existing repo',
|
'# Or push an existing repo',
|
||||||
`git push ${baseUrl}/<app-name> main`,
|
`git push ${baseUrl}/<app-name> main`,
|
||||||
].join('\n')}</CodeBlock>
|
].join('\n')}</CodeBlock>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{repos.length > 0 && (
|
{repos.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Heading>Repositories</Heading>
|
<Heading>Repositories</Heading>
|
||||||
<RepoList>
|
<RepoList>
|
||||||
{repos.map(({ name, commits, branch }) => (
|
{repos.map(({ name, commits, branch, visibility }) => (
|
||||||
<RepoItem>
|
<RepoItem>
|
||||||
<div>
|
<div>
|
||||||
<RepoName>{name}</RepoName>
|
<RepoName>{name}</RepoName>
|
||||||
|
|
@ -507,6 +576,16 @@ function RepoListPage({ baseUrl, repos }: RepoListPageProps) {
|
||||||
</HelpText>
|
</HelpText>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 8px; align-items: center">
|
<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>
|
<Badge>{branch}</Badge>
|
||||||
{commits
|
{commits
|
||||||
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
||||||
|
|
@ -515,6 +594,7 @@ function RepoListPage({ baseUrl, repos }: RepoListPageProps) {
|
||||||
</RepoItem>
|
</RepoItem>
|
||||||
))}
|
))}
|
||||||
</RepoList>
|
</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' }),
|
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
|
// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
|
||||||
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
|
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
|
||||||
const repoParam = c.req.param('repo').replace(/\.git$/, '')
|
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 })
|
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 => {
|
app.get('/', async c => {
|
||||||
const appName = c.req.query('app')
|
const appName = c.req.query('app')
|
||||||
const baseUrl = APP_URL
|
const baseUrl = APP_URL
|
||||||
|
const external = !!c.req.header('x-sneaker')
|
||||||
|
|
||||||
// When viewing a specific app, only show that app's repo
|
// When viewing a specific app, only show that app's repo
|
||||||
if (appName) {
|
if (appName) {
|
||||||
|
|
@ -656,11 +756,20 @@ app.get('/', async c => {
|
||||||
const repos = await listRepos()
|
const repos = await listRepos()
|
||||||
const repoData = await Promise.all(repos.map(async name => {
|
const repoData = await Promise.all(repos.map(async name => {
|
||||||
const bare = repoPath(name)
|
const bare = repoPath(name)
|
||||||
const [commits, branch] = await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
|
const [commits, branch, visibility] = await Promise.all([
|
||||||
return { name, commits, branch }
|
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
|
export default app.defaults
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
},
|
},
|
||||||
"toes": {
|
"toes": {
|
||||||
"tool": true,
|
"tool": true,
|
||||||
|
"dashboard": true,
|
||||||
"icon": "🔀"
|
"icon": "🔀"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user