Compare commits

..

8 Commits

Author SHA1 Message Date
7ee9163f76 Merge branch 'ssh-cli-auto' 2026-02-28 23:04:54 -08:00
f9b67c03bb Remove caret from commander version pin 2026-02-28 23:04:48 -08:00
dd5d9254c0 Merge branch 'git' 2026-02-28 22:53:26 -08:00
01f23ace16 Add git HTTP server tool for push-to-deploy
A toes tool that implements the git smart HTTP protocol, allowing
users to push repos via `git push` and clone via `git clone`. On
receive, it extracts the code into a timestamped APPS_DIR directory
and activates it through the toes sync API.
2026-02-28 22:49:45 -08:00
5f1de651eb Use AsyncLocalStorage for abort signal propagation 2026-02-28 22:48:38 -08:00
460d625f60 Simplify SSH access via dedicated cli user 2026-02-28 22:38:39 -08:00
a87f0a9651 Add abort signals; rename guest to toes-cli 2026-02-28 13:34:14 -08:00
dc570cc6e9 Add SSH shell and NSS guest user support 2026-02-27 07:25:46 -08:00
13 changed files with 1001 additions and 12 deletions

View 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=="],
}
}

View 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

View 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"
}
}

View 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
}
}

View File

@ -45,7 +45,7 @@
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.3",
"commander": "^14.0.3",
"commander": "14.0.3",
"diff": "^8.0.3",
"kleur": "^4.1.5"
}

65
scripts/setup-ssh.sh Executable file
View 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"

View File

@ -1,6 +1,6 @@
import type { LogLine } from '@types'
import color from 'kleur'
import { get, handleError, makeUrl, post } from '../http'
import { get, getSignal, handleError, makeUrl, post } from '../http'
import { resolveAppName } from '../name'
interface CronJobSummary {
@ -195,7 +195,7 @@ const printCronLog = (line: LogLine) =>
async function tailCronLogs(app: string, grep?: string) {
try {
const url = makeUrl(`/api/apps/${app}/logs/stream`)
const res = await fetch(url)
const res = await fetch(url, { signal: getSignal() })
if (!res.ok) {
console.error(`App not found: ${app}`)
return

View File

@ -1,5 +1,5 @@
import type { LogLine } from '@types'
import { get, handleError, makeUrl } from '../http'
import { get, getSignal, handleError, makeUrl } from '../http'
import { resolveAppName } from '../name'
interface LogOptions {
@ -120,7 +120,7 @@ export async function logApp(arg: string | undefined, options: LogOptions) {
export async function tailLogs(name: string, grep?: string) {
try {
const url = makeUrl(`/api/apps/${name}/logs/stream`)
const res = await fetch(url)
const res = await fetch(url, { signal: getSignal() })
if (!res.ok) {
console.error(`App not found: ${name}`)
return

View File

@ -5,7 +5,7 @@ import color from 'kleur'
import { diffLines } from 'diff'
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
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 { getAppName, getAppPackage, isApp, resolveAppName } from '../name'
@ -592,7 +592,7 @@ export async function syncApp() {
const url = makeUrl(`/api/sync/apps/${appName}/watch`)
let res: Response
try {
res = await fetch(url)
res = await fetch(url, { signal: getSignal() })
if (!res.ok) {
console.error(`Failed to connect to server: ${res.status} ${res.statusText}`)
watcher.close()
@ -967,7 +967,7 @@ interface VersionsResponse {
async function getVersions(appName: string): Promise<VersionsResponse | null> {
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) {
console.error(`App not found: ${appName}`)
return null

View File

@ -1,5 +1,8 @@
import type { Manifest } from '@types'
import { AsyncLocalStorage } from 'node:async_hooks'
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
const signalStore = new AsyncLocalStorage<AbortSignal>()
const normalizeUrl = (url: string) =>
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)
: 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 {
return `${HOST}${path}`
}
@ -36,7 +45,7 @@ export function handleError(error: unknown): void {
export async function get<T>(url: string): Promise<T | undefined> {
try {
const res = await fetch(makeUrl(url))
const res = await fetch(makeUrl(url), { signal: getSignal() })
if (!res.ok) {
const text = await res.text()
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> {
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.ok) throw new Error(`${res.status} ${res.statusText}`)
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',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: getSignal(),
})
if (!res.ok) {
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), {
method: 'PUT',
body: body as BodyInit,
signal: getSignal(),
})
if (!res.ok) {
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> {
try {
const fullUrl = makeUrl(url)
const res = await fetch(fullUrl)
const res = await fetch(fullUrl, { signal: getSignal() })
if (!res.ok) {
const text = await res.text()
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
@ -117,6 +128,7 @@ export async function del(url: string): Promise<boolean> {
try {
const res = await fetch(makeUrl(url), {
method: 'DELETE',
signal: getSignal(),
})
if (!res.ok) {
const text = await res.text()

View File

@ -1,4 +1,13 @@
#!/usr/bin/env bun
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()
}

View File

@ -322,4 +322,14 @@ program
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
.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 }

227
src/cli/shell.ts Normal file
View 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()
}