Compare commits
8 Commits
3ad7145229
...
7ee9163f76
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ee9163f76 | |||
| f9b67c03bb | |||
| dd5d9254c0 | |||
| 01f23ace16 | |||
| 5f1de651eb | |||
| 460d625f60 | |||
| a87f0a9651 | |||
| dc570cc6e9 |
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.2",
|
||||||
"@because/sneaker": "^0.0.3",
|
"@because/sneaker": "^0.0.3",
|
||||||
"commander": "^14.0.3",
|
"commander": "14.0.3",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5"
|
"kleur": "^4.1.5"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
scripts/setup-ssh.sh
Executable file
65
scripts/setup-ssh.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# setup-ssh.sh - Configure SSH for the toes CLI user
|
||||||
|
#
|
||||||
|
# This script:
|
||||||
|
# 1. Creates a `cli` system user with /usr/local/bin/toes as shell
|
||||||
|
# 2. Sets an empty password on `cli` for passwordless SSH
|
||||||
|
# 3. Adds a Match block in sshd_config to allow empty passwords for `cli`
|
||||||
|
# 4. Adds /usr/local/bin/toes to /etc/shells
|
||||||
|
# 5. Restarts sshd
|
||||||
|
#
|
||||||
|
# Run as root on the toes machine.
|
||||||
|
# Usage: ssh cli@toes.local
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOES_SHELL="/usr/local/bin/toes"
|
||||||
|
SSHD_CONFIG="/etc/ssh/sshd_config"
|
||||||
|
|
||||||
|
echo "==> Setting up SSH CLI user for toes"
|
||||||
|
|
||||||
|
# 1. Create cli system user
|
||||||
|
if ! id cli &>/dev/null; then
|
||||||
|
useradd --system --home-dir /home/cli --shell "$TOES_SHELL" --create-home cli
|
||||||
|
echo " Created cli user"
|
||||||
|
else
|
||||||
|
echo " cli user already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Set empty password
|
||||||
|
passwd -d cli
|
||||||
|
echo " Set empty password on cli"
|
||||||
|
|
||||||
|
# 3. Add Match block for cli user in sshd_config
|
||||||
|
if ! grep -q 'Match User cli' "$SSHD_CONFIG"; then
|
||||||
|
cat >> "$SSHD_CONFIG" <<EOF
|
||||||
|
|
||||||
|
# toes CLI: allow passwordless SSH for the cli user
|
||||||
|
Match User cli
|
||||||
|
PermitEmptyPasswords yes
|
||||||
|
EOF
|
||||||
|
echo " Added Match User cli block to sshd_config"
|
||||||
|
else
|
||||||
|
echo " sshd_config already has Match User cli block"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Ensure /usr/local/bin/toes is in /etc/shells
|
||||||
|
if ! grep -q "^${TOES_SHELL}$" /etc/shells; then
|
||||||
|
echo "$TOES_SHELL" >> /etc/shells
|
||||||
|
echo " Added $TOES_SHELL to /etc/shells"
|
||||||
|
else
|
||||||
|
echo " $TOES_SHELL already in /etc/shells"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Warn if toes binary doesn't exist yet
|
||||||
|
if [ ! -f "$TOES_SHELL" ]; then
|
||||||
|
echo " WARNING: $TOES_SHELL does not exist yet"
|
||||||
|
echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Restart sshd
|
||||||
|
echo " Restarting sshd..."
|
||||||
|
systemctl restart sshd || service ssh restart || true
|
||||||
|
|
||||||
|
echo "==> Done. Connect with: ssh cli@toes.local"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { LogLine } from '@types'
|
import type { LogLine } from '@types'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { get, handleError, makeUrl, post } from '../http'
|
import { get, getSignal, handleError, makeUrl, post } from '../http'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
interface CronJobSummary {
|
interface CronJobSummary {
|
||||||
|
|
@ -195,7 +195,7 @@ const printCronLog = (line: LogLine) =>
|
||||||
async function tailCronLogs(app: string, grep?: string) {
|
async function tailCronLogs(app: string, grep?: string) {
|
||||||
try {
|
try {
|
||||||
const url = makeUrl(`/api/apps/${app}/logs/stream`)
|
const url = makeUrl(`/api/apps/${app}/logs/stream`)
|
||||||
const res = await fetch(url)
|
const res = await fetch(url, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`App not found: ${app}`)
|
console.error(`App not found: ${app}`)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { LogLine } from '@types'
|
import type { LogLine } from '@types'
|
||||||
import { get, handleError, makeUrl } from '../http'
|
import { get, getSignal, handleError, makeUrl } from '../http'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
interface LogOptions {
|
interface LogOptions {
|
||||||
|
|
@ -120,7 +120,7 @@ export async function logApp(arg: string | undefined, options: LogOptions) {
|
||||||
export async function tailLogs(name: string, grep?: string) {
|
export async function tailLogs(name: string, grep?: string) {
|
||||||
try {
|
try {
|
||||||
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
const url = makeUrl(`/api/apps/${name}/logs/stream`)
|
||||||
const res = await fetch(url)
|
const res = await fetch(url, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`App not found: ${name}`)
|
console.error(`App not found: ${name}`)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import color from 'kleur'
|
||||||
import { diffLines } from 'diff'
|
import { diffLines } from 'diff'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
|
import { del, download, get, getManifest, getSignal, handleError, makeUrl, post, put } from '../http'
|
||||||
import { confirm, prompt } from '../prompts'
|
import { confirm, prompt } from '../prompts'
|
||||||
import { getAppName, getAppPackage, isApp, resolveAppName } from '../name'
|
import { getAppName, getAppPackage, isApp, resolveAppName } from '../name'
|
||||||
|
|
||||||
|
|
@ -592,7 +592,7 @@ export async function syncApp() {
|
||||||
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
|
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
res = await fetch(url)
|
res = await fetch(url, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`Failed to connect to server: ${res.status} ${res.statusText}`)
|
console.error(`Failed to connect to server: ${res.status} ${res.statusText}`)
|
||||||
watcher.close()
|
watcher.close()
|
||||||
|
|
@ -967,7 +967,7 @@ interface VersionsResponse {
|
||||||
|
|
||||||
async function getVersions(appName: string): Promise<VersionsResponse | null> {
|
async function getVersions(appName: string): Promise<VersionsResponse | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`))
|
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`), { signal: getSignal() })
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
console.error(`App not found: ${appName}`)
|
console.error(`App not found: ${appName}`)
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import type { Manifest } from '@types'
|
import type { Manifest } from '@types'
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
|
|
||||||
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
|
||||||
|
const signalStore = new AsyncLocalStorage<AbortSignal>()
|
||||||
|
|
||||||
const normalizeUrl = (url: string) =>
|
const normalizeUrl = (url: string) =>
|
||||||
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
|
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
|
||||||
|
|
@ -17,6 +20,12 @@ export const HOST = process.env.TOES_URL
|
||||||
? normalizeUrl(process.env.TOES_URL)
|
? normalizeUrl(process.env.TOES_URL)
|
||||||
: DEFAULT_HOST
|
: DEFAULT_HOST
|
||||||
|
|
||||||
|
export const getSignal = () => signalStore.getStore()
|
||||||
|
|
||||||
|
export function withSignal<T>(signal: AbortSignal, fn: () => T): T {
|
||||||
|
return signalStore.run(signal, fn)
|
||||||
|
}
|
||||||
|
|
||||||
export function makeUrl(path: string): string {
|
export function makeUrl(path: string): string {
|
||||||
return `${HOST}${path}`
|
return `${HOST}${path}`
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +45,7 @@ export function handleError(error: unknown): void {
|
||||||
|
|
||||||
export async function get<T>(url: string): Promise<T | undefined> {
|
export async function get<T>(url: string): Promise<T | undefined> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(url))
|
const res = await fetch(makeUrl(url), { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
|
@ -50,7 +59,7 @@ export async function get<T>(url: string): Promise<T | undefined> {
|
||||||
|
|
||||||
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
|
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() })
|
||||||
if (res.status === 404) return { exists: false }
|
if (res.status === 404) return { exists: false }
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -68,6 +77,7 @@ export async function post<T, B = unknown>(url: string, body?: B): Promise<T | u
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
signal: getSignal(),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
@ -85,6 +95,7 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
|
||||||
const res = await fetch(makeUrl(url), {
|
const res = await fetch(makeUrl(url), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: body as BodyInit,
|
body: body as BodyInit,
|
||||||
|
signal: getSignal(),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
@ -101,7 +112,7 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
|
||||||
export async function download(url: string): Promise<Buffer | undefined> {
|
export async function download(url: string): Promise<Buffer | undefined> {
|
||||||
try {
|
try {
|
||||||
const fullUrl = makeUrl(url)
|
const fullUrl = makeUrl(url)
|
||||||
const res = await fetch(fullUrl)
|
const res = await fetch(fullUrl, { signal: getSignal() })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
|
||||||
|
|
@ -117,6 +128,7 @@ export async function del(url: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(makeUrl(url), {
|
const res = await fetch(makeUrl(url), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
signal: getSignal(),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { program } from './setup'
|
import { program } from './setup'
|
||||||
|
|
||||||
|
const isCliUser = process.env.USER === 'cli'
|
||||||
|
const noArgs = process.argv.length <= 2
|
||||||
|
const isTTY = !!process.stdin.isTTY
|
||||||
|
|
||||||
|
if (isCliUser && noArgs && isTTY) {
|
||||||
|
const { shell } = await import('./shell')
|
||||||
|
await shell()
|
||||||
|
} else {
|
||||||
program.parse()
|
program.parse()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -322,4 +322,14 @@ program
|
||||||
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
||||||
.action((name, options) => rollbackApp(name, options.version))
|
.action((name, options) => rollbackApp(name, options.version))
|
||||||
|
|
||||||
|
// Shell
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('shell')
|
||||||
|
.description('Interactive shell')
|
||||||
|
.action(async () => {
|
||||||
|
const { shell } = await import('./shell')
|
||||||
|
await shell()
|
||||||
|
})
|
||||||
|
|
||||||
export { program }
|
export { program }
|
||||||
|
|
|
||||||
227
src/cli/shell.ts
Normal file
227
src/cli/shell.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import type { App } from '@types'
|
||||||
|
|
||||||
|
import * as readline from 'readline'
|
||||||
|
|
||||||
|
import color from 'kleur'
|
||||||
|
|
||||||
|
import { get, handleError, HOST, withSignal } from './http'
|
||||||
|
import { program } from './setup'
|
||||||
|
import { STATE_ICONS } from './commands/manage'
|
||||||
|
|
||||||
|
let appNamesCache: string[] = []
|
||||||
|
let appNamesCacheTime = 0
|
||||||
|
|
||||||
|
const APP_CACHE_TTL = 5000
|
||||||
|
|
||||||
|
function tokenize(input: string): string[] {
|
||||||
|
const tokens: string[] = []
|
||||||
|
let current = ''
|
||||||
|
let quote: string | null = null
|
||||||
|
|
||||||
|
for (const ch of input) {
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) {
|
||||||
|
quote = null
|
||||||
|
} else {
|
||||||
|
current += ch
|
||||||
|
}
|
||||||
|
} else if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch
|
||||||
|
} else if (ch === ' ' || ch === '\t') {
|
||||||
|
if (current) {
|
||||||
|
tokens.push(current)
|
||||||
|
current = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) tokens.push(current)
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAppNames(): Promise<string[]> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (appNamesCache.length > 0 && now - appNamesCacheTime < APP_CACHE_TTL) {
|
||||||
|
return appNamesCache
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const apps = await get<App[]>('/api/apps')
|
||||||
|
if (apps) {
|
||||||
|
appNamesCache = apps.map(a => a.name)
|
||||||
|
appNamesCacheTime = now
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// use stale cache
|
||||||
|
}
|
||||||
|
return appNamesCache
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommandNames(): string[] {
|
||||||
|
return program.commands
|
||||||
|
.filter((cmd: { _hidden?: boolean }) => !cmd._hidden)
|
||||||
|
.map((cmd: { name: () => string }) => cmd.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printBanner(): Promise<void> {
|
||||||
|
const apps = await get<App[]>('/api/apps')
|
||||||
|
if (!apps) {
|
||||||
|
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
|
||||||
|
console.log()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache app names from banner fetch
|
||||||
|
appNamesCache = apps.map(a => a.name)
|
||||||
|
appNamesCacheTime = Date.now()
|
||||||
|
|
||||||
|
const visibleApps = apps.filter(a => !a.tool)
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
// App status line
|
||||||
|
const parts = visibleApps.map(a => {
|
||||||
|
const icon = STATE_ICONS[a.state] ?? '\u25CB'
|
||||||
|
return `${icon} ${a.name}`
|
||||||
|
})
|
||||||
|
if (parts.length > 0) {
|
||||||
|
console.log(' ' + parts.join(' '))
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
const running = visibleApps.filter(a => a.state === 'running').length
|
||||||
|
const stopped = visibleApps.filter(a => a.state !== 'running').length
|
||||||
|
const summary = []
|
||||||
|
if (running) summary.push(`${running} running`)
|
||||||
|
if (stopped) summary.push(`${stopped} stopped`)
|
||||||
|
if (summary.length > 0) {
|
||||||
|
console.log(color.gray(` ${summary.join(', ')} \u2014 type "help" for commands`))
|
||||||
|
} else {
|
||||||
|
console.log(color.gray(' no apps \u2014 type "help" for commands'))
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shell(): Promise<void> {
|
||||||
|
await printBanner()
|
||||||
|
|
||||||
|
// Configure Commander to throw instead of exiting
|
||||||
|
program.exitOverride()
|
||||||
|
program.configureOutput({
|
||||||
|
writeOut: (str: string) => process.stdout.write(str),
|
||||||
|
writeErr: (str: string) => process.stderr.write(str),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
prompt: color.cyan('toes> '),
|
||||||
|
completer: (line: string, callback: (err: null, result: [string[], string]) => void) => {
|
||||||
|
const tokens = tokenize(line)
|
||||||
|
const trailing = line.endsWith(' ')
|
||||||
|
|
||||||
|
if (tokens.length === 0 || (tokens.length === 1 && !trailing)) {
|
||||||
|
// Complete command names
|
||||||
|
const partial = tokens[0] ?? ''
|
||||||
|
const commands = getCommandNames()
|
||||||
|
const hits = commands.filter(c => c.startsWith(partial))
|
||||||
|
callback(null, [hits, partial])
|
||||||
|
} else {
|
||||||
|
// Complete app names
|
||||||
|
const partial = trailing ? '' : (tokens[tokens.length - 1] ?? '')
|
||||||
|
const names = appNamesCache
|
||||||
|
const hits = names.filter(n => n.startsWith(partial))
|
||||||
|
callback(null, [hits, partial])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh app names cache in background for tab completion
|
||||||
|
fetchAppNames()
|
||||||
|
|
||||||
|
let activeAbort: AbortController | null = null
|
||||||
|
|
||||||
|
rl.on('SIGINT', () => {
|
||||||
|
if (activeAbort) {
|
||||||
|
activeAbort.abort()
|
||||||
|
activeAbort = null
|
||||||
|
console.log()
|
||||||
|
rl.prompt()
|
||||||
|
} else {
|
||||||
|
// Clear current line
|
||||||
|
rl.write(null, { ctrl: true, name: 'u' })
|
||||||
|
console.log()
|
||||||
|
rl.prompt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
rl.prompt()
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
const input = line.trim()
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
rl.prompt()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'exit' || input === 'quit') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'clear') {
|
||||||
|
console.clear()
|
||||||
|
rl.prompt()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'help') {
|
||||||
|
program.outputHelp()
|
||||||
|
rl.prompt()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = tokenize(input)
|
||||||
|
|
||||||
|
// Set up AbortController for this command
|
||||||
|
activeAbort = new AbortController()
|
||||||
|
const signal = activeAbort.signal
|
||||||
|
|
||||||
|
// Pause readline so commands can use their own prompts
|
||||||
|
rl.pause()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withSignal(signal, () => program.parseAsync(['node', 'toes', ...tokens]))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Commander throws on exitOverride — suppress help/version exits
|
||||||
|
if (err && typeof err === 'object' && 'code' in err) {
|
||||||
|
const code = (err as { code: string }).code
|
||||||
|
if (code === 'commander.helpDisplayed' || code === 'commander.version') {
|
||||||
|
// Already printed, just continue
|
||||||
|
} else if (code === 'commander.unknownCommand') {
|
||||||
|
console.error(`Unknown command: ${tokens[0]}`)
|
||||||
|
} else {
|
||||||
|
// Other Commander errors (missing arg, etc.)
|
||||||
|
// Commander already printed the error message
|
||||||
|
}
|
||||||
|
} else if (signal.aborted) {
|
||||||
|
// Command was cancelled by Ctrl+C
|
||||||
|
} else {
|
||||||
|
handleError(err)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeAbort = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh app names cache after commands that might change state
|
||||||
|
fetchAppNames()
|
||||||
|
|
||||||
|
rl.resume()
|
||||||
|
rl.prompt()
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.close()
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user