forked from defunkt/toes
Merge branch 'git-public-private'
This commit is contained in:
commit
a4a08bfe65
|
|
@ -2,17 +2,39 @@ 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.classList.toggle('public', next === 'public');
|
||||
fetch('/api/visibility/' + encodeURIComponent(repo), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ visibility: next })
|
||||
}).catch(function() {
|
||||
btn.dataset.visibility = current;
|
||||
btn.textContent = current;
|
||||
btn.classList.toggle('public', current === 'public');
|
||||
});
|
||||
}
|
||||
`
|
||||
|
||||
const app = new Hype({ prettyHTML: false, layout: false })
|
||||
const deployLocks = new Map<string, Promise<void>>()
|
||||
|
|
@ -92,6 +114,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 +156,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 +283,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 +393,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 +435,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 +544,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 +579,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 +597,7 @@ function RepoListPage({ baseUrl, repos }: RepoListPageProps) {
|
|||
</RepoItem>
|
||||
))}
|
||||
</RepoList>
|
||||
{!external && <script src="/toggle.js" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -542,6 +625,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$/, '')
|
||||
|
|
@ -645,9 +732,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) {
|
||||
|
|
@ -664,11 +767,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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
},
|
||||
"toes": {
|
||||
"tool": true,
|
||||
"dashboard": true,
|
||||
"icon": "🔀"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user