Compare commits

...

42 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
3ad7145229 dumb 2026-02-28 20:27:21 -08:00
a87f0a9651 Add abort signals; rename guest to toes-cli 2026-02-28 13:34:14 -08:00
d2b0eb410f fix tool iframes 2026-02-28 12:58:21 -08:00
ffe1df22e6 domain 2026-02-28 08:29:55 -08:00
7f82a37c63 toes.dev 2026-02-28 08:12:38 -08:00
6055b9798d Replace quickstart with detailed setup docs 2026-02-28 08:04:23 -08:00
f7397dc060 install 2026-02-27 20:40:30 -08:00
d69dc6ae9d Merge branch 'ssh-install' 2026-02-27 19:35:11 -08:00
4853ee4f7a Skip bundled app install if already exists 2026-02-27 19:35:06 -08:00
Chris Wanstrath
74f9062a89 fix reconnect 2026-02-27 15:35:49 -08:00
Chris Wanstrath
55316027c0 heartbeat 2026-02-27 15:14:43 -08:00
cfba207077 no no no 2026-02-27 07:40:19 -08:00
702019279a no 2026-02-27 07:29:23 -08:00
141622f86f Add test123 app and support tunnelUrl in Urls 2026-02-27 07:28:58 -08:00
526678e87a Add active variant flex column styles 2026-02-27 07:26:15 -08:00
dc570cc6e9 Add SSH shell and NSS guest user support 2026-02-27 07:25:46 -08:00
d29e306e61 Merge branch 'mobile' 2026-02-26 20:37:52 -08:00
671f51ca0c Replace app selector modal with mobile sidebar state 2026-02-26 20:37:50 -08:00
604ac96b30 Add paw print emoji to install banner 2026-02-26 20:28:07 -08:00
d082af4e33 Add width 100% to active style 2026-02-26 20:21:51 -08:00
9bce15b871 Add flex layout to LogsSection container 2026-02-26 19:59:37 -08:00
7ab27f2767 Replace chevron with hamburger menu for app selector 2026-02-26 19:58:42 -08:00
45b1903e6b Use URL-based routing instead of local state 2026-02-26 19:43:18 -08:00
68274d8651 Intercept link clicks for client-side routing 2026-02-26 18:49:48 -08:00
98a1c1ad97 Add client-side router, use URLs for navigation 2026-02-26 11:40:50 -08:00
6d02f1db3f Make stopped tiles link to app page instead of nowhere 2026-02-26 07:28:05 -08:00
b0c5a11cde Add hostname setup and kiosk mode to install 2026-02-25 20:44:05 -08:00
029e349c5b add toes installer and install server 2026-02-25 20:38:47 -08:00
1a71656508 app tiles 2026-02-25 20:33:02 -08:00
363a82a845 Add icon span and conditional URL/name display 2026-02-25 19:58:01 -08:00
271bf018b8 Add tabbed dashboard with URLs/Logs/Metrics views 2026-02-25 19:55:19 -08:00
Chris Wanstrath
488c643342 mkdir-p 2026-02-25 16:08:39 -08:00
Chris Wanstrath
8fc54bd349 deploy to any host 2026-02-25 16:06:19 -08:00
Chris Wanstrath
3cbb25a82a yeah 2026-02-25 15:35:01 -08:00
87d0ff50c1 Centralize hostname config in shared module 2026-02-25 12:55:41 -08:00
0499060676 Use dynamic hostname instead of toes.local 2026-02-25 12:11:43 -08:00
54 changed files with 1661 additions and 185 deletions

View File

@ -4,11 +4,28 @@ Toes is a personal web server you run in your home.
Plug it in, turn it on, and forget about the cloud. Plug it in, turn it on, and forget about the cloud.
## quickstart ## setup
1. Plug in and turn on your Toes computer. Toes runs on a Raspberry Pi. You'll need:
2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
3. Visit https://toes.local to get started! - A Raspberry Pi running Raspberry Pi OS
- A `toes` user with passwordless sudo
SSH into your Pi as the `toes` user and run:
```bash
curl -fsSL https://toes.dev/install | bash
```
This will:
1. Install system dependencies (git, fish shell, networking tools)
2. Install Bun and grant it network binding capabilities
3. Clone and build the toes server
4. Set up bundled apps (clock, code, cron, env, stats, versions)
5. Install and enable a systemd service for auto-start
Once complete, visit `http://<hostname>.local` on your local network.
## features ## features
- Hosts bun/hono/hype webapps - both SSR and SPA. - Hosts bun/hono/hype webapps - both SSR and SPA.

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

26
install/bun.lock Normal file
View File

@ -0,0 +1,26 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "toes-install",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@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.2", "https://npm.nose.space/@types/node/-/node-25.3.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"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=="],
}
}

123
install/install.sh Normal file
View File

@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
##
# toes installer
# Usage: curl -sSL https://toes.dev/install | bash
# Must be run as the 'toes' user.
DEST=~/toes
REPO="https://git.nose.space/defunkt/toes"
quiet() { "$@" > /dev/null 2>&1; }
echo ""
echo " ╔══════════════════════════════════╗"
echo " ║ 🐾 toes - personal web appliance ║"
echo " ╚══════════════════════════════════╝"
echo ""
# Must be running as toes
if [ "$(whoami)" != "toes" ]; then
echo "ERROR: This script must be run as the 'toes' user."
echo "Create the user during Raspberry Pi OS setup."
exit 1
fi
# Must have passwordless sudo (can't prompt when piped from curl)
if ! sudo -n true 2>/dev/null; then
echo "ERROR: This script requires passwordless sudo."
echo "On Raspberry Pi OS, the default user has this already."
exit 1
fi
# -- System packages --
echo ">> Updating system packages"
quiet sudo apt-get update
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
echo ">> Setting fish as default shell"
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
quiet sudo chsh -s /usr/bin/fish toes
fi
# -- Bun --
BUN_REAL="$HOME/.bun/bin/bun"
BUN_SYMLINK="/usr/local/bin/bun"
if [ ! -x "$BUN_REAL" ]; then
echo ">> Installing bun"
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
if [ ! -x "$BUN_REAL" ]; then
echo "ERROR: bun installation failed"
exit 1
fi
fi
if [ ! -x "$BUN_SYMLINK" ]; then
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
fi
echo ">> Setting CAP_NET_BIND_SERVICE on bun"
sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
# -- Clone --
if [ ! -d "$DEST" ]; then
echo ">> Cloning toes"
git clone "$REPO" "$DEST"
else
echo ">> Updating toes"
cd "$DEST" && git pull origin main
fi
# -- Directories --
mkdir -p ~/data ~/apps
# -- Dependencies & build --
echo ">> Installing dependencies"
cd "$DEST" && bun install
echo ">> Building client"
cd "$DEST" && bun run build
# -- Bundled apps --
echo ">> Installing bundled apps"
BUNDLED_APPS="clock code cron env stats versions"
for app in $BUNDLED_APPS; do
if [ -d "$DEST/apps/$app" ]; then
if [ -d ~/apps/"$app" ]; then
echo " $app (exists, skipping)"
continue
fi
echo " $app"
cp -r "$DEST/apps/$app" ~/apps/
version_dir=$(ls -1 ~/apps/"$app" | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1)
if [ -n "$version_dir" ]; then
ln -sfn "$version_dir" ~/apps/"$app"/current
(cd ~/apps/"$app"/current && bun install --frozen-lockfile) > /dev/null 2>&1 || true
fi
fi
done
# -- Systemd --
echo ">> Installing toes service"
sudo install -m 644 -o root -g root "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
sudo systemctl daemon-reload
sudo systemctl enable toes
echo ">> Starting toes"
sudo systemctl restart toes
# -- Done --
echo ""
echo " toes is installed and running!"
echo " Visit: http://$(hostname).local"
echo ""

16
install/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "toes-install",
"version": "0.0.1",
"description": "install toes",
"module": "server.ts",
"type": "module",
"scripts": {
"start": "bun run server.ts"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.3"
}
}

17
install/server.ts Normal file
View File

@ -0,0 +1,17 @@
import { resolve } from "path"
const script = await Bun.file(resolve(import.meta.dir, "install.sh")).text()
Bun.serve({
port: parseInt(process.env.PORT || "3000"),
fetch(req) {
if (new URL(req.url).pathname === "/install") {
return new Response(script, {
headers: { "content-type": "text/plain" },
})
}
return new Response("404 Not Found", { status: 404 })
},
})
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)

43
install/tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"exclude": ["apps", "templates"],
"compilerOptions": {
// Environment setup & latest features
"lib": [
"ESNext",
"DOM"
],
"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,
"baseUrl": ".",
"paths": {
"$*": [
"./src/server/*"
],
"@*": [
"./src/shared/*"
],
"%*": [
"./src/lib/*"
]
}
}
}

View File

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

View File

@ -2,8 +2,12 @@
# It isn't enough to modify this yet. # It isn't enough to modify this yet.
# You also need to manually update the toes.service file. # You also need to manually update the toes.service file.
HOST="${HOST:-toes@toes.local}" TOES_USER="${TOES_USER:-toes}"
URL="${URL:-http://toes.local}" HOST="${HOST:-toes.local}"
DEST="${DEST:-~/toes}" SSH_HOST="$TOES_USER@$HOST"
DATA_DIR="${DATA_DIR:-~/data}" URL="${URL:-http://$HOST}"
APPS_DIR="${APPS_DIR:-~/apps}" DEST="${DEST:-$HOME/toes}"
DATA_DIR="${DATA_DIR:-$HOME/data}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"
mkdir -p "$DEST" "$DATA_DIR" "$APPS_DIR"

View File

@ -11,9 +11,12 @@ source "$ROOT_DIR/scripts/config.sh"
git push origin main git push origin main
# SSH to target: pull, build, sync apps, restart # SSH to target: pull, build, sync apps, restart
ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT' ssh "$SSH_HOST" bash <<'SCRIPT'
set -e set -e
DEST="${DEST:-$HOME/toes}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
echo "=> Syncing default apps..." echo "=> Syncing default apps..."
@ -35,5 +38,5 @@ done
sudo systemctl restart toes.service sudo systemctl restart toes.service
SCRIPT SCRIPT
echo "=> Deployed to $HOST" echo "=> Deployed to $SSH_HOST"
echo "=> Visit $URL" echo "=> Visit $URL"

View File

@ -9,4 +9,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh" source "$ROOT_DIR/scripts/config.sh"
# Run remote install on the target # Run remote install on the target
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh" ssh "$SSH_HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh" source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "journalctl -u toes -n 100" ssh "$SSH_HOST" "journalctl -u toes -n 100"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh" source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "sudo systemctl restart toes.service" ssh "$SSH_HOST" "sudo systemctl restart toes.service"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh" source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "sudo systemctl start toes.service" ssh "$SSH_HOST" "sudo systemctl start toes.service"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh" source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "sudo systemctl stop toes.service" ssh "$SSH_HOST" "sudo systemctl stop toes.service"

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

View File

@ -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

View File

@ -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

View File

@ -1,6 +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}`
@ -18,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}`
} }
@ -37,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}`
@ -51,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()
@ -69,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()
@ -86,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()
@ -102,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}`
@ -118,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()

View File

@ -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()
}

View File

@ -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
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()
}

View File

@ -2,13 +2,14 @@ import { define } from '@because/forge'
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { buildAppUrl } from '../../shared/urls' import { buildAppUrl } from '../../shared/urls'
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api' import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals' import { openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, getSelectedTab, isNarrow } from '../state' import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
import { import {
ActionBar, ActionBar,
AppSelectorChevron,
Button, Button,
ClickableAppName, ClickableAppName,
HamburgerButton,
HamburgerLine,
HeaderActions, HeaderActions,
InfoLabel, InfoLabel,
InfoRow, InfoRow,
@ -52,14 +53,15 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
<Main> <Main>
<MainHeader> <MainHeader>
<MainTitle> <MainTitle>
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
&nbsp;
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
{isNarrow && ( {isNarrow && (
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}> <HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
<HamburgerLine />
</AppSelectorChevron> <HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)} )}
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
</MainTitle> </MainTitle>
<HeaderActions> <HeaderActions>
{!app.tool && ( {!app.tool && (

View File

@ -2,7 +2,6 @@ import type { CSSProperties } from 'hono/jsx'
import { import {
apps, apps,
selectedApp, selectedApp,
setSelectedApp,
setSidebarSection, setSidebarSection,
sidebarSection, sidebarSection,
} from '../state' } from '../state'
@ -17,19 +16,13 @@ import {
interface AppSelectorProps { interface AppSelectorProps {
render: () => void render: () => void
onSelect?: () => void onSelect?: () => void
onDashboard?: () => void
collapsed?: boolean collapsed?: boolean
large?: boolean
switcherStyle?: CSSProperties switcherStyle?: CSSProperties
listStyle?: CSSProperties listStyle?: CSSProperties
} }
export function AppSelector({ render, onSelect, onDashboard, collapsed, switcherStyle, listStyle }: AppSelectorProps) { export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
const selectApp = (name: string) => {
setSelectedApp(name)
onSelect?.()
render()
}
const switchSection = (section: 'apps' | 'tools') => { const switchSection = (section: 'apps' | 'tools') => {
setSidebarSection(section) setSidebarSection(section)
render() render()
@ -43,18 +36,18 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
<> <>
{!collapsed && toolApps.length > 0 && ( {!collapsed && toolApps.length > 0 && (
<SectionSwitcher style={switcherStyle}> <SectionSwitcher style={switcherStyle}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}> <SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}>
Apps Apps
</SectionTab> </SectionTab>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}> <SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
Tools Tools
</SectionTab> </SectionTab>
</SectionSwitcher> </SectionSwitcher>
)} )}
<AppList style={listStyle}> <AppList style={listStyle}>
{collapsed && onDashboard && ( {collapsed && (
<AppItem <AppItem
onClick={onDashboard} href="/"
selected={!selectedApp ? true : undefined} selected={!selectedApp ? true : undefined}
style={{ justifyContent: 'center', padding: '10px 12px' }} style={{ justifyContent: 'center', padding: '10px 12px' }}
title="Toes" title="Toes"
@ -65,7 +58,9 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
{activeApps.map(app => ( {activeApps.map(app => (
<AppItem <AppItem
key={app.name} key={app.name}
onClick={() => selectApp(app.name)} href={`/app/${app.name}`}
onClick={onSelect}
large={large || undefined}
selected={app.name === selectedApp ? true : undefined} selected={app.name === selectedApp ? true : undefined}
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined} style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={collapsed ? app.name : undefined} title={collapsed ? app.name : undefined}
@ -74,7 +69,7 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
<span style={{ fontSize: 18 }}>{app.icon}</span> <span style={{ fontSize: 18 }}>{app.icon}</span>
) : ( ) : (
<> <>
<span style={{ fontSize: 14 }}>{app.icon}</span> <span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span>
{app.name} {app.name}
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} /> <StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
</> </>

View File

@ -1,13 +1,48 @@
import { Styles } from '@because/forge' import { Styles } from '@because/forge'
import { apps, currentView, isNarrow, selectedApp } from '../state' import { openNewAppModal } from '../modals'
import { Layout } from '../styles' import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
import {
HamburgerButton,
HamburgerLine,
Layout,
Main,
MainContent as MainContentContainer,
MainHeader,
MainTitle,
NewAppButton,
} from '../styles'
import { AppDetail } from './AppDetail' import { AppDetail } from './AppDetail'
import { AppSelector } from './AppSelector'
import { DashboardLanding } from './DashboardLanding' import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal' import { Modal } from './modal'
import { SettingsPage } from './SettingsPage' import { SettingsPage } from './SettingsPage'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
function MobileSidebar({ render }: { render: () => void }) {
return (
<Main>
<MainHeader>
<MainTitle>
<HamburgerButton onClick={() => { setMobileSidebar(false); render() }} title="Hide apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
<a href="/" style={{ textDecoration: 'none', color: 'inherit' }}>🐾 Toes</a>
</MainTitle>
</MainHeader>
<MainContentContainer>
<AppSelector render={render} large />
<div style={{ padding: '12px 16px' }}>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
</div>
</MainContentContainer>
</Main>
)
}
function MainContent({ render }: { render: () => void }) { function MainContent({ render }: { render: () => void }) {
if (isNarrow && mobileSidebar) return <MobileSidebar render={render} />
const selected = apps.find(a => a.name === selectedApp) const selected = apps.find(a => a.name === selectedApp)
if (selected) return <AppDetail app={selected} render={render} /> if (selected) return <AppDetail app={selected} render={render} />
if (currentView === 'settings') return <SettingsPage render={render} /> if (currentView === 'settings') return <SettingsPage render={render} />

View File

@ -1,34 +1,37 @@
import { useEffect } from 'hono/jsx' import { useEffect } from 'hono/jsx'
import { openAppSelectorModal } from '../modals' import { navigate } from '../router'
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state' import { dashboardTab, isNarrow, setMobileSidebar } from '../state'
import { import {
AppSelectorChevron, HamburgerButton,
HamburgerLine,
DashboardContainer, DashboardContainer,
DashboardHeader, DashboardHeader,
DashboardTitle, DashboardTitle,
SettingsGear, SettingsGear,
StatusDot, Tab,
StatusDotLink, TabBar,
StatusDotsRow, TabContent,
} from '../styles' } from '../styles'
import { update } from '../update' import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs' import { Urls } from './Urls'
import { Vitals, initVitals } from './Vitals' import { Vitals, initVitals } from './Vitals'
let activeTooltip: string | null = null
export function DashboardLanding({ render }: { render: () => void }) { export function DashboardLanding({ render }: { render: () => void }) {
useEffect(() => { useEffect(() => {
initUnifiedLogs() initUnifiedLogs()
initVitals() initVitals()
if (dashboardTab === 'logs') scrollLogsToBottom()
}, []) }, [])
const narrow = isNarrow || undefined const narrow = isNarrow || undefined
const openSettings = () => { const openSettings = () => {
setSelectedApp(null) navigate('/settings')
setCurrentView('settings') }
render()
const switchTab = (tab: typeof dashboardTab) => {
navigate(tab === 'urls' ? '/' : `/${tab}`)
if (tab === 'logs') scrollLogsToBottom()
} }
return ( return (
@ -40,43 +43,40 @@ export function DashboardLanding({ render }: { render: () => void }) {
> >
</SettingsGear> </SettingsGear>
{isNarrow && (
<HamburgerButton
onClick={() => { setMobileSidebar(true); render() }}
title="Show apps"
style={{ position: 'absolute', top: 16, left: 16 }}
>
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)}
<DashboardHeader> <DashboardHeader>
<DashboardTitle narrow={narrow}> <DashboardTitle narrow={narrow}>
🐾 Toes 🐾 Toes
{isNarrow && (
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
</AppSelectorChevron>
)}
</DashboardTitle> </DashboardTitle>
</DashboardHeader> </DashboardHeader>
<StatusDotsRow> <TabBar centered>
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => ( <Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>URLs</Tab>
<StatusDotLink <Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>Logs</Tab>
key={app.name} <Tab active={dashboardTab === 'metrics' || undefined} onClick={() => switchTab('metrics')}>Metrics</Tab>
data-tooltip={app.name} </TabBar>
tooltipVisible={activeTooltip === app.name || undefined}
onClick={(e: Event) => {
e.preventDefault()
if (isNarrow && activeTooltip !== app.name) {
activeTooltip = app.name
render()
return
}
activeTooltip = null
setSelectedApp(app.name)
update()
}}
>
<StatusDot state={app.state} data-app={app.name} />
</StatusDotLink>
))}
</StatusDotsRow>
<Vitals /> <TabContent active={dashboardTab === 'urls' || undefined}>
<Urls render={render} />
</TabContent>
<TabContent active={dashboardTab === 'logs' || undefined}>
<UnifiedLogs /> <UnifiedLogs />
</TabContent>
<TabContent active={dashboardTab === 'metrics' || undefined}>
<Vitals />
</TabContent>
</DashboardContainer> </DashboardContainer>
) )
} }

View File

@ -1,5 +1,6 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { apps, getSelectedTab, setSelectedTab } from '../state' import { navigate } from '../router'
import { apps, getSelectedTab } from '../state'
import { Tab, TabBar } from '../styles' import { Tab, TabBar } from '../styles'
import { resetToolIframe } from '../tool-iframes' import { resetToolIframe } from '../tool-iframes'
@ -12,8 +13,7 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
resetToolIframe(tab, app.name) resetToolIframe(tab, app.name)
return return
} }
setSelectedTab(app.name, tab) navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`)
render()
} }
// Find all tools // Find all tools

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'hono/jsx' import { useEffect, useState } from 'hono/jsx'
import { getWifiConfig, saveWifiConfig } from '../api' import { getWifiConfig, saveWifiConfig } from '../api'
import { setCurrentView } from '../state' import { navigate } from '../router'
import { import {
Button, Button,
DashboardInstallCmd, DashboardInstallCmd,
@ -31,8 +31,7 @@ export function SettingsPage({ render }: { render: () => void }) {
}, []) }, [])
const goBack = () => { const goBack = () => {
setCurrentView('dashboard') navigate('/')
render()
} }
const handleSave = async (e: Event) => { const handleSave = async (e: Event) => {

View File

@ -1,7 +1,5 @@
import { openNewAppModal } from '../modals' import { openNewAppModal } from '../modals'
import { import {
setCurrentView,
setSelectedApp,
setSidebarCollapsed, setSidebarCollapsed,
sidebarCollapsed, sidebarCollapsed,
} from '../state' } from '../state'
@ -17,12 +15,6 @@ import {
import { AppSelector } from './AppSelector' import { AppSelector } from './AppSelector'
export function Sidebar({ render }: { render: () => void }) { export function Sidebar({ render }: { render: () => void }) {
const goToDashboard = () => {
setSelectedApp(null)
setCurrentView('dashboard')
render()
}
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed) setSidebarCollapsed(!sidebarCollapsed)
render() render()
@ -40,7 +32,7 @@ export function Sidebar({ render }: { render: () => void }) {
</div> </div>
) : ( ) : (
<Logo> <Logo>
<LogoLink onClick={goToDashboard} title="Go to dashboard"> <LogoLink href="/" title="Go to dashboard">
🐾 Toes 🐾 Toes
</LogoLink> </LogoLink>
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar"> <HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
@ -50,7 +42,7 @@ export function Sidebar({ render }: { render: () => void }) {
</HamburgerButton> </HamburgerButton>
</Logo> </Logo>
)} )}
<AppSelector render={render} collapsed={sidebarCollapsed} onDashboard={goToDashboard} /> <AppSelector render={render} collapsed={sidebarCollapsed} />
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<SidebarFooter> <SidebarFooter>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton> <NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>

View File

@ -105,6 +105,13 @@ function renderLogs() {
}) })
} }
export function scrollLogsToBottom() {
requestAnimationFrame(() => {
const el = document.getElementById('unified-logs-body')
if (el) el.scrollTop = el.scrollHeight
})
}
export function initUnifiedLogs() { export function initUnifiedLogs() {
if (_source) return if (_source) return
_source = new EventSource('/api/system/logs/stream') _source = new EventSource('/api/system/logs/stream')

View File

@ -0,0 +1,48 @@
import { buildAppUrl } from '../../shared/urls'
import { navigate } from '../router'
import { apps, isNarrow } from '../state'
import {
EmptyState,
Tile,
TileGrid,
TileIcon,
TileName,
TileStatus,
} from '../styles'
export function Urls({ render }: { render: () => void }) {
const nonTools = apps.filter(a => !a.tool)
if (nonTools.length === 0) {
return <EmptyState>No apps installed</EmptyState>
}
return (
<TileGrid narrow={isNarrow || undefined}>
{nonTools.map(app => {
const url = app.tunnelUrl || buildAppUrl(app.name, location.origin)
const running = app.state === 'running'
const appPage = `/app/${app.name}`
const openAppPage = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
navigate(appPage)
}
return (
<Tile
key={app.name}
href={running ? url : appPage}
target={running ? '_blank' : undefined}
narrow={isNarrow || undefined}
>
<TileStatus state={app.state} onClick={openAppPage} />
<TileIcon>{app.icon}</TileIcon>
<TileName>{app.name}</TileName>
</Tile>
)
})}
</TileGrid>
)
}

View File

@ -1,7 +1,8 @@
import { render as renderApp } from 'hono/jsx/dom' import { render as renderApp } from 'hono/jsx/dom'
import { Dashboard } from './components' import { Dashboard } from './components'
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow, setSelectedApp } from './state'
import { initModal } from './components/modal' import { initModal } from './components/modal'
import { initRouter, navigate } from './router'
import { apps, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state'
import { initToolIframes, updateToolIframes } from './tool-iframes' import { initToolIframes, updateToolIframes } from './tool-iframes'
import { initUpdate } from './update' import { initUpdate } from './update'
@ -41,14 +42,16 @@ narrowQuery.addEventListener('change', e => {
render() render()
}) })
// Initialize router (sets initial state from URL and renders)
initRouter(render)
// SSE connection // SSE connection
const events = new EventSource('/api/apps/stream') const events = new EventSource('/api/apps/stream')
events.onmessage = e => { events.onmessage = e => {
const prev = apps
setApps(JSON.parse(e.data)) setApps(JSON.parse(e.data))
if (selectedApp && !apps.some(a => a.name === selectedApp)) { if (selectedApp && !apps.some(a => a.name === selectedApp)) {
setSelectedApp(null) navigate('/')
} }
render() render()

View File

@ -1,17 +0,0 @@
import { closeModal, openModal } from '../components/modal'
import { AppSelector } from '../components/AppSelector'
let renderFn: () => void
export function openAppSelectorModal(render: () => void) {
renderFn = render
openModal('Select App', () => (
<AppSelector
render={renderFn}
onSelect={closeModal}
switcherStyle={{ padding: '0 0 12px', marginLeft: -20, marginRight: -20, paddingLeft: 20, paddingRight: 20, marginBottom: 8 }}
listStyle={{ maxHeight: 300, overflow: 'auto' }}
/>
))
}

View File

@ -1,6 +1,7 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { closeModal, openModal, rerenderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { selectedApp, setSelectedApp } from '../state' import { navigate } from '../router'
import { selectedApp } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles' import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
import { theme } from '../themes' import { theme } from '../themes'
@ -32,11 +33,11 @@ async function deleteApp(input: HTMLInputElement) {
throw new Error(`Failed to delete app: ${res.statusText}`) throw new Error(`Failed to delete app: ${res.statusText}`)
} }
// Success - close modal and clear selection // Success - close modal and navigate to dashboard
if (selectedApp === deleteAppTarget.name) {
setSelectedApp(null)
}
closeModal() closeModal()
if (selectedApp === deleteAppTarget.name) {
navigate('/')
}
} catch (err) { } catch (err) {
deleteAppError = err instanceof Error ? err.message : 'Failed to delete app' deleteAppError = err instanceof Error ? err.message : 'Failed to delete app'
deleteAppDeleting = false deleteAppDeleting = false

View File

@ -1,5 +1,6 @@
import { closeModal, openModal, rerenderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { apps, setSelectedApp } from '../state' import { navigate } from '../router'
import { apps } from '../state'
import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles' import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles'
type TemplateType = 'ssr' | 'spa' | 'bare' type TemplateType = 'ssr' | 'spa' | 'bare'
@ -48,9 +49,9 @@ async function createNewApp() {
throw new Error(data.error || 'Failed to create app') throw new Error(data.error || 'Failed to create app')
} }
// Success - close modal and select the new app // Success - close modal and navigate to the new app
setSelectedApp(name)
closeModal() closeModal()
navigate(`/app/${name}`)
} catch (err) { } catch (err) {
newAppError = err instanceof Error ? err.message : 'Failed to create app' newAppError = err instanceof Error ? err.message : 'Failed to create app'
newAppCreating = false newAppCreating = false

View File

@ -1,6 +1,7 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { closeModal, openModal, rerenderModal } from '../components/modal' import { closeModal, openModal, rerenderModal } from '../components/modal'
import { apps, setSelectedApp } from '../state' import { navigate } from '../router'
import { apps } from '../state'
import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles' import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles'
let renameAppError = '' let renameAppError = ''
@ -58,9 +59,9 @@ async function doRenameApp(input: HTMLInputElement) {
throw new Error(data.error || 'Failed to rename app') throw new Error(data.error || 'Failed to rename app')
} }
// Success - update selection and close modal // Success - close modal and navigate to renamed app
setSelectedApp(data.name || newName)
closeModal() closeModal()
navigate(`/app/${data.name || newName}`)
} catch (err) { } catch (err) {
renameAppError = err instanceof Error ? err.message : 'Failed to rename app' renameAppError = err instanceof Error ? err.message : 'Failed to rename app'
renameAppRenaming = false renameAppRenaming = false

View File

@ -1,4 +1,3 @@
export { openAppSelectorModal } from './AppSelector'
export { openDeleteAppModal } from './DeleteApp' export { openDeleteAppModal } from './DeleteApp'
export { openNewAppModal } from './NewApp' export { openNewAppModal } from './NewApp'
export { openRenameAppModal } from './RenameApp' export { openRenameAppModal } from './RenameApp'

60
src/client/router.ts Normal file
View File

@ -0,0 +1,60 @@
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
let _render: () => void
export function navigate(href: string) {
history.pushState(null, '', href)
route()
}
export function initRouter(render: () => void) {
_render = render
// Intercept link clicks
document.addEventListener('click', e => {
const a = (e.target as Element).closest('a')
if (!a || !a.href || a.origin !== location.origin || a.target === '_blank') return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
e.preventDefault()
history.pushState(null, '', a.href)
route()
})
// Handle back/forward
window.addEventListener('popstate', route)
// Initial route from URL
route()
}
function route() {
setMobileSidebar(false)
const path = location.pathname
if (path.startsWith('/app/')) {
const rest = decodeURIComponent(path.slice(5))
const slashIdx = rest.indexOf('/')
const name = slashIdx === -1 ? rest : rest.slice(0, slashIdx)
const tab = slashIdx === -1 ? 'overview' : rest.slice(slashIdx + 1)
setSelectedApp(name)
setSelectedTab(name, tab)
setCurrentView('dashboard')
} else if (path === '/settings') {
setSelectedApp(null)
setCurrentView('settings')
} else if (path === '/logs') {
setSelectedApp(null)
setDashboardTab('logs')
setCurrentView('dashboard')
} else if (path === '/metrics') {
setSelectedApp(null)
setDashboardTab('metrics')
setCurrentView('dashboard')
} else {
setSelectedApp(null)
setDashboardTab('urls')
setCurrentView('dashboard')
}
_render()
}

View File

@ -1,36 +1,43 @@
import type { App } from '../shared/types' import type { App } from '../shared/types'
export type DashboardTab = 'urls' | 'logs' | 'metrics'
// UI state (survives re-renders) // UI state (survives re-renders)
export let currentView: 'dashboard' | 'settings' = 'dashboard' export let currentView: 'dashboard' | 'settings' = 'dashboard'
export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches export let isNarrow: boolean = window.matchMedia('(max-width: 768px)').matches
export let selectedApp: string | null = localStorage.getItem('selectedApp') export let selectedApp: string | null = null
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true' export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
export let dashboardTab: DashboardTab = 'urls'
export let mobileSidebar: boolean = false
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps' export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
// Server state (from SSE) // Server state (from SSE)
export let apps: App[] = [] export let apps: App[] = []
// Tab state // Tab state
export let appTabs: Record<string, string> = JSON.parse(localStorage.getItem('appTabs') || '{}') export let appTabs: Record<string, string> = {}
// State setters // State setters
export function setDashboardTab(tab: DashboardTab) {
dashboardTab = tab
}
export function setCurrentView(view: 'dashboard' | 'settings') { export function setCurrentView(view: 'dashboard' | 'settings') {
currentView = view currentView = view
} }
export function setSelectedApp(name: string | null) { export function setSelectedApp(name: string | null) {
selectedApp = name selectedApp = name
if (name) {
localStorage.setItem('selectedApp', name)
} else {
localStorage.removeItem('selectedApp')
}
} }
export function setIsNarrow(narrow: boolean) { export function setIsNarrow(narrow: boolean) {
isNarrow = narrow isNarrow = narrow
} }
export function setMobileSidebar(open: boolean) {
mobileSidebar = open
}
export function setSidebarCollapsed(collapsed: boolean) { export function setSidebarCollapsed(collapsed: boolean) {
sidebarCollapsed = collapsed sidebarCollapsed = collapsed
localStorage.setItem('sidebarCollapsed', String(collapsed)) localStorage.setItem('sidebarCollapsed', String(collapsed))
@ -51,5 +58,4 @@ export const getSelectedTab = (appName: string | null) =>
export function setSelectedTab(appName: string | null, tab: string) { export function setSelectedTab(appName: string | null, tab: string) {
if (!appName) return if (!appName) return
appTabs[appName] = tab appTabs[appName] = tab
localStorage.setItem('appTabs', JSON.stringify(appTabs))
} }

View File

@ -65,7 +65,6 @@ export const GaugeValue = define('GaugeValue', {
// Unified Logs Section // Unified Logs Section
export const LogsSection = define('LogsSection', { export const LogsSection = define('LogsSection', {
width: '100%', width: '100%',
maxWidth: 800,
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
display: 'flex', display: 'flex',
@ -202,3 +201,86 @@ export const LogStatus = define('LogStatus', {
warning: { color: '#f59e0b' }, warning: { color: '#f59e0b' },
}, },
}) })
// App Tiles Grid
export const TileGrid = define('TileGrid', {
width: '100%',
maxWidth: 900,
margin: '0 auto',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 20,
variants: {
narrow: { gridTemplateColumns: '1fr' },
},
})
export const Tile = define('Tile', {
base: 'a',
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
padding: '28px 20px 24px',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
textDecoration: 'none',
cursor: 'pointer',
selectors: {
'&:hover': {
background: theme('colors-bgHover'),
borderColor: theme('colors-textFaint'),
},
},
variants: {
narrow: {
flexDirection: 'row',
alignItems: 'center',
padding: '16px 20px',
gap: 16,
},
},
})
export const TileIcon = define('TileIcon', {
fontSize: 48,
lineHeight: 1,
userSelect: 'none',
})
export const TileName = define('TileName', {
fontSize: 15,
fontWeight: 600,
color: theme('colors-text'),
textAlign: 'center',
})
export const TilePort = define('TilePort', {
fontFamily: theme('fonts-mono'),
fontSize: 13,
color: theme('colors-textFaint'),
})
export const TileStatus = define('TileStatus', {
position: 'absolute',
top: 8,
right: 8,
width: 2,
height: 2,
borderRadius: '50%',
cursor: 'pointer',
padding: 4,
backgroundClip: 'content-box',
variants: {
state: {
error: { background: theme('colors-statusInvalid') },
invalid: { background: theme('colors-statusInvalid') },
stopped: { background: theme('colors-statusStopped') },
starting: { background: theme('colors-statusStarting') },
running: { background: theme('colors-statusRunning') },
stopping: { background: theme('colors-statusStarting') },
},
},
})

View File

@ -15,6 +15,12 @@ export {
LogStatus, LogStatus,
LogText, LogText,
LogTimestamp, LogTimestamp,
Tile,
TileGrid,
TileIcon,
TileName,
TilePort,
TileStatus,
VitalCard, VitalCard,
VitalLabel, VitalLabel,
VitalsSection, VitalsSection,
@ -23,7 +29,6 @@ export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel,
export { export {
AppItem, AppItem,
AppList, AppList,
AppSelectorChevron,
ClickableAppName, ClickableAppName,
DashboardContainer, DashboardContainer,
DashboardHeader, DashboardHeader,

View File

@ -29,7 +29,10 @@ export const Logo = define('Logo', {
}) })
export const LogoLink = define('LogoLink', { export const LogoLink = define('LogoLink', {
base: 'a',
cursor: 'pointer', cursor: 'pointer',
color: 'inherit',
textDecoration: 'none',
borderRadius: theme('radius-md'), borderRadius: theme('radius-md'),
padding: '4px 8px', padding: '4px 8px',
margin: '-4px -8px', margin: '-4px -8px',
@ -103,6 +106,7 @@ export const SectionTab = define('SectionTab', {
background: theme('colors-bgSelected'), background: theme('colors-bgSelected'),
color: theme('colors-text'), color: theme('colors-text'),
}, },
large: { fontSize: 14, padding: '8px 12px' },
}, },
}) })
@ -112,6 +116,7 @@ export const AppList = define('AppList', {
}) })
export const AppItem = define('AppItem', { export const AppItem = define('AppItem', {
base: 'a',
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -125,6 +130,7 @@ export const AppItem = define('AppItem', {
'&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') }, '&:hover': { background: theme('colors-bgHover'), color: theme('colors-text') },
}, },
variants: { variants: {
large: { fontSize: 18, padding: '12px 16px', gap: 12 },
selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 }, selected: { background: theme('colors-bgSelected'), color: theme('colors-text'), fontWeight: 500 },
}, },
}) })
@ -166,20 +172,6 @@ export const MainTitle = define('MainTitle', {
margin: 0, margin: 0,
}) })
export const AppSelectorChevron = define('AppSelectorChevron', {
base: 'button',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 8px',
marginLeft: 4,
fontSize: 14,
color: theme('colors-textMuted'),
selectors: {
'&:hover': { color: theme('colors-text') },
},
})
export const ClickableAppName = define('ClickableAppName', { export const ClickableAppName = define('ClickableAppName', {
cursor: 'pointer', cursor: 'pointer',
borderRadius: theme('radius-md'), borderRadius: theme('radius-md'),
@ -233,6 +225,7 @@ export const DashboardContainer = define('DashboardContainer', {
export const DashboardHeader = define('DashboardHeader', { export const DashboardHeader = define('DashboardHeader', {
textAlign: 'center', textAlign: 'center',
width: '100%',
}) })
export const DashboardTitle = define('DashboardTitle', { export const DashboardTitle = define('DashboardTitle', {

View File

@ -127,6 +127,12 @@ export const TabBar = define('TabBar', {
display: 'flex', display: 'flex',
gap: 24, gap: 24,
marginBottom: 20, marginBottom: 20,
variants: {
centered: {
justifyContent: 'center',
marginBottom: 0,
},
},
}) })
export const Tab = define('Tab', { export const Tab = define('Tab', {
@ -159,6 +165,7 @@ export const TabContent = define('TabContent', {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
flex: 1, flex: 1,
width: '100%',
} }
} }
}) })

4
src/lib/config.ts Normal file
View File

@ -0,0 +1,4 @@
import { hostname } from 'os'
export const HOSTNAME = hostname()
export const LOCAL_HOST = `${HOSTNAME}.local`

View File

@ -8,7 +8,11 @@ const router = Hype.router()
// individual events so apps can react to specific lifecycle changes. // individual events so apps can react to specific lifecycle changes.
router.sse('/stream', (send) => { router.sse('/stream', (send) => {
const unsub = onEvent(event => send(event)) const unsub = onEvent(event => send(event))
return unsub const heartbeat = setInterval(() => send('', 'ping'), 60_000)
return () => {
clearInterval(heartbeat)
unsub()
}
}) })
export default router export default router

View File

@ -4,7 +4,7 @@ import type { Subprocess } from 'bun'
import { DEFAULT_EMOJI } from '@types' import { DEFAULT_EMOJI } from '@types'
import { buildAppUrl, toSubdomain } from '@urls' import { buildAppUrl, toSubdomain } from '@urls'
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs' import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
import { hostname } from 'os' import { LOCAL_HOST } from '%config'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import { loadAppEnv } from '../tools/env' import { loadAppEnv } from '../tools/env'
import { publishApp, unpublishAll, unpublishApp } from './mdns' import { publishApp, unpublishAll, unpublishApp } from './mdns'
@ -16,7 +16,7 @@ export type { AppState } from '@types'
export const APPS_DIR = process.env.APPS_DIR ?? resolve(join(process.env.DATA_DIR ?? '.', 'apps')) export const APPS_DIR = process.env.APPS_DIR ?? resolve(join(process.env.DATA_DIR ?? '.', 'apps'))
export const TOES_DIR = process.env.TOES_DIR ?? join(process.env.DATA_DIR ?? '.', 'toes') export const TOES_DIR = process.env.TOES_DIR ?? join(process.env.DATA_DIR ?? '.', 'toes')
const defaultHost = process.env.NODE_ENV === 'production' ? `${hostname()}.local` : 'localhost' const defaultHost = process.env.NODE_ENV === 'production' ? LOCAL_HOST : 'localhost'
export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}` export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}`
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3 const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3

View File

@ -7,6 +7,7 @@ import systemRouter from './api/system'
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { cleanupStalePublishers } from './mdns' import { cleanupStalePublishers } from './mdns'
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy' import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
import { Shell } from './shell'
import type { Server } from 'bun' import type { Server } from 'bun'
import type { WsData } from './proxy' import type { WsData } from './proxy'
@ -89,7 +90,7 @@ async function buildBinary(name: string): Promise<boolean> {
return promise return promise
} }
// Install script: curl -fsSL http://toes.local/install | bash // Install script: curl -fsSL http://<hostname>.local/install | bash
app.get('/install', c => { app.get('/install', c => {
if (!TOES_URL) return c.text('TOES_URL is not configured', 500) if (!TOES_URL) return c.text('TOES_URL is not configured', 500)
const script = INSTALL_SCRIPT.replace('__TOES_URL__', TOES_URL) const script = INSTALL_SCRIPT.replace('__TOES_URL__', TOES_URL)
@ -113,6 +114,13 @@ app.get('/dist/:file', async c => {
}) })
}) })
// SPA routes — serve the shell for all client-side paths
app.get('/app/:name/:tab', c => c.html(<Shell />))
app.get('/app/:name', c => c.html(<Shell />))
app.get('/logs', c => c.html(<Shell />))
app.get('/metrics', c => c.html(<Shell />))
app.get('/settings', c => c.html(<Shell />))
cleanupStalePublishers() cleanupStalePublishers()
await initApps() await initApps()

View File

@ -1,5 +1,6 @@
import type { Subprocess } from 'bun' import type { Subprocess } from 'bun'
import { toSubdomain } from '@urls' import { toSubdomain } from '@urls'
import { LOCAL_HOST } from '%config'
import { networkInterfaces } from 'os' import { networkInterfaces } from 'os'
import { hostLog } from './tui' import { hostLog } from './tui'
@ -24,7 +25,7 @@ export function cleanupStalePublishers() {
if (!isEnabled) return if (!isEnabled) return
try { try {
const result = Bun.spawnSync(['pkill', '-f', 'avahi-publish.*toes\\.local']) const result = Bun.spawnSync(['pkill', '-f', `avahi-publish.*${LOCAL_HOST}`])
if (result.exitCode === 0) { if (result.exitCode === 0) {
hostLog('mDNS: cleaned up stale avahi-publish processes') hostLog('mDNS: cleaned up stale avahi-publish processes')
} }
@ -41,22 +42,22 @@ export function publishApp(name: string) {
return return
} }
const hostname = `${toSubdomain(name)}.toes.local` const host = `${toSubdomain(name)}.${LOCAL_HOST}`
try { try {
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], { const proc = Bun.spawn(['avahi-publish', '-a', host, '-R', ip], {
stdout: 'ignore', stdout: 'ignore',
stderr: 'ignore', stderr: 'ignore',
}) })
_publishers.set(name, proc) _publishers.set(name, proc)
hostLog(`mDNS: published ${hostname} -> ${ip}`) hostLog(`mDNS: published ${host} -> ${ip}`)
proc.exited.then(() => { proc.exited.then(() => {
_publishers.delete(name) _publishers.delete(name)
}) })
} catch { } catch {
hostLog(`mDNS: failed to publish ${hostname}`) hostLog(`mDNS: failed to publish ${host}`)
} }
} }
@ -68,7 +69,7 @@ export function unpublishApp(name: string) {
proc.kill() proc.kill()
_publishers.delete(name) _publishers.delete(name)
hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`) hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
} }
export function unpublishAll() { export function unpublishAll() {
@ -76,7 +77,7 @@ export function unpublishAll() {
for (const [name, proc] of _publishers) { for (const [name, proc] of _publishers) {
proc.kill() proc.kill()
hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`) hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
} }
_publishers.clear() _publishers.clear()
} }

View File

@ -146,18 +146,19 @@ export function renameTunnelConfig(oldName: string, newName: string) {
saveConfig(config) saveConfig(config)
} }
function cancelReconnect(appName: string) { function cancelReconnect(appName: string, resetAttempts = true) {
const timer = _reconnectTimers.get(appName) const timer = _reconnectTimers.get(appName)
if (timer) { if (timer) {
clearTimeout(timer) clearTimeout(timer)
_reconnectTimers.delete(appName) _reconnectTimers.delete(appName)
} }
_reconnectAttempts.delete(appName) if (resetAttempts) _reconnectAttempts.delete(appName)
} }
function openTunnel(appName: string, port: number, subdomain?: string) { function openTunnel(appName: string, port: number, subdomain?: string, isReconnect = false) {
// Cancel any pending reconnect timer to prevent duplicate loops // Cancel any pending reconnect timer to prevent duplicate loops
cancelReconnect(appName) // but preserve attempts counter during reconnection so backoff works
cancelReconnect(appName, !isReconnect)
// Close existing tunnel if any // Close existing tunnel if any
const existing = _tunnels.get(appName) const existing = _tunnels.get(appName)
@ -232,7 +233,7 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
const config = loadConfig() const config = loadConfig()
if (!config[appName]) return if (!config[appName]) return
hostLog(`Tunnel reconnecting: ${appName}`) hostLog(`Tunnel reconnecting: ${appName}`)
openTunnel(appName, port, config[appName]?.subdomain) openTunnel(appName, port, config[appName]?.subdomain, true)
}, delay) }, delay)
_reconnectTimers.set(appName, timer) _reconnectTimers.set(appName, timer)

View File

@ -35,8 +35,10 @@ function ensureConnection() {
buf = lines.pop()! buf = lines.pop()!
for (const line of lines) { for (const line of lines) {
if (!line.startsWith('data: ')) continue if (!line.startsWith('data: ')) continue
const payload = line.slice(6)
if (!payload) continue
try { try {
const event: ToesEvent = JSON.parse(line.slice(6)) const event: ToesEvent = JSON.parse(payload)
_listeners.forEach(l => { _listeners.forEach(l => {
if (l.types.includes(event.type)) l.callback(event) if (l.types.includes(event.type)) l.callback(event)
}) })