Compare commits

..

4 Commits

137 changed files with 3403 additions and 3018 deletions

3
.gitignore vendored
View File

@ -5,9 +5,6 @@ node_modules
pub/client/index.js pub/client/index.js
toes/ toes/
# generated
src/lib/templates.data.ts
# output # output
out out
dist dist

View File

@ -47,8 +47,8 @@ Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
### Server (`src/server/`) ### Server (`src/server/`)
- `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields). - `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management. - `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create), `DELETE /:name`, `PUT /:name/rename`, `PUT /:name/icon`.
- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app reload (triggered by git tool after deploy), file watch SSE. - `api/sync.ts` -- File sync protocol: manifest comparison, push/pull with hash-based diffing.
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps. - `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps.
- `shell.tsx` -- Minimal HTML shell for the SPA. - `shell.tsx` -- Minimal HTML shell for the SPA.
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY). - `tui.ts` -- Terminal UI for the server process (renders app status table when TTY).
@ -79,22 +79,23 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
CLI commands: CLI commands:
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm` - **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`, `share`, `unshare` - **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`
- **Config**: `env` - **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash`
- **Config**: `config`, `env`, `versions`, `history`, `rollback`
### Shared (`src/shared/`) ### Shared (`src/shared/`)
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser). Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo` - `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers) - `gitignore.ts` -- `.toesignore` pattern matching
### Lib (`src/lib/`) ### Lib (`src/lib/`)
Server-side code shared between CLI and server. Can use Node APIs. Server-side code shared between CLI and server. Can use Node APIs.
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa) - `templates.ts` -- Template generation for `toes new` (bare, ssr, spa)
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools) - `sync.ts` -- Manifest generation, hash computation
### Tools Package (`src/tools/`) ### Tools Package (`src/tools/`)
@ -125,7 +126,7 @@ Tools are apps with `"toes": { "tool": true }` in package.json. From the server'
### Versioning ### Versioning
Apps use git for version control. Each app has a bare git repo at `DATA_DIR/repos/<name>.git`. Deploying is a `git push` to the server's git tool, which extracts HEAD into `APPS_DIR/<name>/` and reloads the app. History, diffing, and rollback use standard git commands. Apps live at `APPS_DIR/<name>/` with timestamped version directories and a `current` symlink. Push creates a new version; rollback moves the symlink.
### Environment Variables ### Environment Variables
@ -214,4 +215,4 @@ function start(app: App): void {
## Writing Apps and Tools ## Writing Apps and Tools
See `docs/GUIDE.md` for the guide to writing toes apps and tools. See `docs/CLAUDE.md` for the guide to writing toes apps and tools.

View File

@ -1,38 +1,22 @@
# 🐾 Toes # 🐾 Toes
Toes is a personal web appliance you run on your home network. 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.
## setup ## quickstart
Toes runs on a Raspberry Pi. You'll need: 1. Plug in and turn on your Toes computer.
2. Connect to the **Toes Setup** WiFi network (password: **toessetup**).
- A Raspberry Pi 5 running the latest Raspberry Pi OS A setup page will appear — choose your home WiFi and enter its password.
- A `toes` user with passwordless sudo 3. Visit https://toes.local to get started!
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 and tools (clock, code, cron, env, stats)
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.
- Effortlessly hosts bun/hype webapps - both SSR and SPA. - `toes` CLI for pushing and pulling from your server.
- `git push`, Heroku-style deploys - `toes` CLI for local dev mode.
- https://toes.local web UI for managing your projects. - https://toes.local web UI for managing your projects.
- `toes` CLI for managing your projects. - Per-branch staging environments for Claude.
## cli configuration ## cli configuration
@ -44,6 +28,8 @@ TOES_URL=http://192.168.1.50:3000 toes list # connect to IP
TOES_URL=http://mypi.local toes list # connect to hostname TOES_URL=http://mypi.local toes list # connect to hostname
``` ```
set `NODE_ENV=production` to default to `toes.local:80`.
## fun stuff ## fun stuff
- textOS (TODO, more?) - textOS (TODO, more?)

View File

@ -3,18 +3,11 @@ import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools' import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, stat } from 'fs/promises' import { readdir, stat } from 'fs/promises'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { join, resolve, extname, basename } from 'path' import { join, extname, basename } from 'path'
import type { Child } from 'hono/jsx' import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR! const APPS_DIR = process.env.APPS_DIR!
const safePath = (base: string, ...segments: string[]) => {
const norm = resolve(base)
const full = resolve(norm, ...segments)
if (!full.startsWith(norm + '/') && full !== norm) return null
return full
}
const app = new Hype({ prettyHTML: false }) const app = new Hype({ prettyHTML: false })
const Container = define('Container', { const Container = define('Container', {
@ -264,15 +257,21 @@ const fileMemoryScript = `
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
var app = params.get('app'); var app = params.get('app');
var file = params.get('file'); var file = params.get('file');
var version = params.get('version') || 'current';
if (!app) return; if (!app) return;
var key = 'code-app:' + app + ':file'; var key = 'code-app:' + app + ':' + version + ':file';
if (params.has('file')) { if (params.has('file')) {
// Explicit file param (even empty) - save it
if (file) localStorage.setItem(key, file); if (file) localStorage.setItem(key, file);
else localStorage.removeItem(key); else localStorage.removeItem(key);
} else { } else {
// No file param - restore saved location
var saved = localStorage.getItem(key); var saved = localStorage.getItem(key);
if (saved) { if (saved) {
window.location.replace('/?app=' + encodeURIComponent(app) + '&file=' + encodeURIComponent(saved)); var url = '/?app=' + encodeURIComponent(app);
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
url += '&file=' + encodeURIComponent(saved);
window.location.replace(url);
} }
} }
})(); })();
@ -328,14 +327,14 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
app.get('/raw', async c => { app.get('/raw', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') const filePath = c.req.query('file')
if (!appName || !filePath) { if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400) return c.text('Missing app or file parameter', 400)
} }
const fullPath = safePath(APPS_DIR, appName, filePath) const fullPath = join(APPS_DIR, appName, version, filePath)
if (!fullPath) return c.text('Invalid path', 400)
const file = Bun.file(fullPath) const file = Bun.file(fullPath)
if (!await file.exists()) { if (!await file.exists()) {
@ -347,14 +346,14 @@ app.get('/raw', async c => {
app.post('/save', async c => { app.post('/save', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') const filePath = c.req.query('file')
if (!appName || !filePath) { if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400) return c.text('Missing app or file parameter', 400)
} }
const fullPath = safePath(APPS_DIR, appName, filePath) const fullPath = join(APPS_DIR, appName, version, filePath)
if (!fullPath) return c.text('Invalid path', 400)
const content = await c.req.text() const content = await c.req.text()
try { try {
@ -386,9 +385,10 @@ async function listFiles(appPath: string, subPath: string = '') {
interface BreadcrumbProps { interface BreadcrumbProps {
appName: string appName: string
filePath: string filePath: string
versionParam: string
} }
function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) { function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
const parts = filePath ? filePath.split('/').filter(Boolean) : [] const parts = filePath ? filePath.split('/').filter(Boolean) : []
const crumbs: { name: string; path: string }[] = [] const crumbs: { name: string; path: string }[] = []
@ -401,7 +401,7 @@ function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
return ( return (
<Breadcrumb> <Breadcrumb>
{crumbs.length > 0 ? ( {crumbs.length > 0 ? (
<BreadcrumbLink href={`/?app=${appName}&file=`}>{appName}</BreadcrumbLink> <BreadcrumbLink href={`/?app=${appName}${versionParam}&file=`}>{appName}</BreadcrumbLink>
) : ( ) : (
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent> <BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
)} )}
@ -411,7 +411,7 @@ function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
{i === crumbs.length - 1 ? ( {i === crumbs.length - 1 ? (
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent> <BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
) : ( ) : (
<BreadcrumbLink href={`/?app=${appName}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink> <BreadcrumbLink href={`/?app=${appName}${versionParam}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
)} )}
</> </>
))} ))}
@ -479,6 +479,7 @@ function getPrismLanguage(filename: string): string {
app.get('/', async c => { app.get('/', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') || '' const filePath = c.req.query('file') || ''
if (!appName) { if (!appName) {
@ -489,34 +490,19 @@ app.get('/', async c => {
) )
} }
const appPath = safePath(APPS_DIR, appName) const appPath = join(APPS_DIR, appName, version)
if (!appPath) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Invalid app name</ErrorBox>
</Layout>
)
}
try { try {
await stat(appPath) await stat(appPath)
} catch { } catch {
return c.html( return c.html(
<Layout title="Code Browser"> <Layout title="Code Browser">
<ErrorBox>App "{appName}" not found</ErrorBox> <ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
</Layout>
)
}
const fullPath = safePath(appPath, filePath)
if (!fullPath) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Invalid file path</ErrorBox>
</Layout> </Layout>
) )
} }
const fullPath = join(appPath, filePath)
let fileStats let fileStats
try { try {
@ -529,16 +515,18 @@ app.get('/', async c => {
) )
} }
const versionParam = version !== 'current' ? `&version=${version}` : ''
if (fileStats.isFile()) { if (fileStats.isFile()) {
const filename = basename(fullPath) const filename = basename(fullPath)
const fileType = getFileType(filename) const fileType = getFileType(filename)
const rawUrl = `/raw?app=${appName}&file=${filePath}` const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
const downloadUrl = `${rawUrl}&download=1` const downloadUrl = `${rawUrl}&download=1`
if (fileType === 'image') { if (fileType === 'image') {
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`}> <Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<MediaContainer> <MediaContainer>
<MediaHeader> <MediaHeader>
<span>{filename}</span> <span>{filename}</span>
@ -555,7 +543,7 @@ app.get('/', async c => {
if (fileType === 'audio') { if (fileType === 'audio') {
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`}> <Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<MediaContainer> <MediaContainer>
<MediaHeader> <MediaHeader>
<span>{filename}</span> <span>{filename}</span>
@ -572,7 +560,7 @@ app.get('/', async c => {
if (fileType === 'video') { if (fileType === 'video') {
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`}> <Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<MediaContainer> <MediaContainer>
<MediaHeader> <MediaHeader>
<span>{filename}</span> <span>{filename}</span>
@ -627,7 +615,7 @@ saveBtn.onclick = async () => {
status.textContent = 'Saving...'; status.textContent = 'Saving...';
try { try {
const res = await fetch('/save?app=${appName}&file=${filePath}', { const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'text/plain' }, headers: { 'Content-Type': 'text/plain' },
body: jar.toString() body: jar.toString()
@ -653,14 +641,14 @@ document.addEventListener('keydown', (e) => {
` `
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`} editable> <Layout title={`${appName}/${filePath}`} editable>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<CodeBlock> <CodeBlock>
<CodeHeader> <CodeHeader>
<span>{filename}</span> <span>{filename}</span>
<div style="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
<span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span> <span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span>
<EditButton id="save-btn">Save</EditButton> <EditButton id="save-btn">Save</EditButton>
<EditLink href={`/?app=${appName}&file=${filePath}`}>Done</EditLink> <EditLink href={`/?app=${appName}${versionParam}&file=${filePath}`}>Done</EditLink>
</div> </div>
</CodeHeader> </CodeHeader>
<pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre> <pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre>
@ -672,11 +660,11 @@ document.addEventListener('keydown', (e) => {
return c.html( return c.html(
<Layout title={`${appName}/${filePath}`} highlight> <Layout title={`${appName}/${filePath}`} highlight>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<CodeBlock> <CodeBlock>
<CodeHeader> <CodeHeader>
<span>{filename}</span> <span>{filename}</span>
<EditLink href={`/?app=${appName}&file=${filePath}&edit=1`}>Edit</EditLink> <EditLink href={`/?app=${appName}${versionParam}&file=${filePath}&edit=1`}>Edit</EditLink>
</CodeHeader> </CodeHeader>
<pre><code class={`language-${language}`}>{content}</code></pre> <pre><code class={`language-${language}`}>{content}</code></pre>
</CodeBlock> </CodeBlock>
@ -688,11 +676,11 @@ document.addEventListener('keydown', (e) => {
return c.html( return c.html(
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}> <Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
<PathBreadcrumb appName={appName} filePath={filePath} /> <PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<FileList> <FileList>
{files.map(file => ( {files.map(file => (
<FileItem> <FileItem>
<FileLink href={`/?app=${appName}&file=${file.path}`}> <FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />} {file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
<span>{file.name}</span> <span>{file.name}</span>
</FileLink> </FileLink>

View File

@ -15,7 +15,7 @@ const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false }) const app = new Hype({ prettyHTML: false })
// Styles // Styles (follow versions tool pattern)
const Container = define('Container', { const Container = define('Container', {
fontFamily: theme('fonts-sans'), fontFamily: theme('fonts-sans'),
padding: '20px', padding: '20px',
@ -572,7 +572,7 @@ app.post('/new', async c => {
return c.redirect('/new?error=invalid-name') return c.redirect('/new?error=invalid-name')
} }
const cronDir = join(APPS_DIR, appName, 'cron') const cronDir = join(APPS_DIR, appName, 'current', 'cron')
const filePath = join(cronDir, `${name}.ts`) const filePath = join(cronDir, `${name}.ts`)
// Check if file already exists // Check if file already exists
@ -691,7 +691,7 @@ watch(APPS_DIR, { recursive: true }, (_event, filename) => {
debounceTimer = setTimeout(rediscover, 100) debounceTimer = setTimeout(rediscover, 100)
}) })
on(['app:reload', 'app:delete'], (event) => { on(['app:activate', 'app:delete'], (event) => {
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`) console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
rediscover() rediscover()
}) })

View File

@ -13,7 +13,8 @@ export async function getApps(): Promise<string[]> {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue if (!entry.isDirectory()) continue
if (existsSync(join(APPS_DIR, entry.name, 'package.json'))) { // Check if it has a current symlink (valid app)
if (existsSync(join(APPS_DIR, entry.name, 'current'))) {
apps.push(entry.name) apps.push(entry.name)
} }
} }
@ -34,7 +35,7 @@ export async function discoverCronJobs(): Promise<DiscoveryResult> {
for (const app of apps) { for (const app of apps) {
if (!app.isDirectory()) continue if (!app.isDirectory()) continue
const cronDir = join(APPS_DIR, app.name, 'cron') const cronDir = join(APPS_DIR, app.name, 'current', 'cron')
if (!existsSync(cronDir)) continue if (!existsSync(cronDir)) continue
const files = await readdir(cronDir) const files = await readdir(cronDir)

View File

@ -37,7 +37,7 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
job.lastDuration = undefined job.lastDuration = undefined
onUpdate() onUpdate()
const cwd = join(APPS_DIR, job.app) const cwd = join(APPS_DIR, job.app, 'current')
forwardLog(job.app, `[cron] Running ${job.name}`) forwardLog(job.app, `[cron] Running ${job.name}`)

View File

@ -300,35 +300,9 @@ app.get('/', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
if (!appName) { if (!appName) {
// Dashboard view: global env vars only
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
return c.html( return c.html(
<Layout title="Global Environment Variables"> <Layout title="Environment Variables">
{globalVars.length === 0 ? ( <ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox>
<EmptyState>No global environment variables</EmptyState>
) : (
<EnvList>
{globalVars.map(v => (
<EnvItem data-env-item>
<EnvKey>{v.key}</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions>
<Button data-reveal>Reveal</Button>
<form method="post" action={`/delete-global?key=${v.key}`} style="margin:0">
<DangerButton type="submit">Delete</DangerButton>
</form>
</EnvActions>
</EnvItem>
))}
</EnvList>
)}
<Form method="POST" action="/set-global">
<Input type="text" name="key" placeholder="KEY" required />
<Input type="text" name="value" placeholder="value" required />
<Button type="submit">Add</Button>
</Form>
<Hint>Global vars are available to all apps. Changes take effect on next app restart.</Hint>
</Layout> </Layout>
) )
} }
@ -463,6 +437,7 @@ app.post('/delete', async c => {
app.post('/set-global', async c => { app.post('/set-global', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
if (!appName) return c.text('Missing app', 400)
const body = await c.req.parseBody() const body = await c.req.parseBody()
const key = String(body.key).trim().toUpperCase() const key = String(body.key).trim().toUpperCase()
@ -480,17 +455,17 @@ app.post('/set-global', async c => {
} }
writeEnvFile(GLOBAL_ENV_PATH, vars) writeEnvFile(GLOBAL_ENV_PATH, vars)
return c.redirect(appName ? `/?app=${appName}&tab=global` : '/') return c.redirect(`/?app=${appName}&tab=global`)
}) })
app.post('/delete-global', async c => { app.post('/delete-global', async c => {
const appName = c.req.query('app') const appName = c.req.query('app')
const key = c.req.query('key') const key = c.req.query('key')
if (!key) return c.text('Missing key', 400) if (!appName || !key) return c.text('Missing app or key', 400)
const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key) const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key)
writeEnvFile(GLOBAL_ENV_PATH, vars) writeEnvFile(GLOBAL_ENV_PATH, vars)
return c.redirect(appName ? `/?app=${appName}&tab=global` : '/') return c.redirect(`/?app=${appName}&tab=global`)
}) })
export default app.defaults export default app.defaults

View File

@ -10,8 +10,7 @@
}, },
"toes": { "toes": {
"tool": ".env", "tool": ".env",
"icon": "🔑", "icon": "🔑"
"dashboard": true
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

View File

@ -1,49 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "git",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.12",
},
"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/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
"@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="],
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@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.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"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=="],
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
}
}

View File

@ -1,865 +0,0 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme, on, VALID_NAME } from '@because/toes/tools'
import { mkdirSync } from 'fs'
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
import { join, resolve } from 'path'
import type { Child } from 'hono/jsx'
const APP_URL = process.env.APP_URL!
const APPS_DIR = process.env.APPS_DIR!
const DATA_DIR = process.env.DATA_DIR!
const DATA_ROOT = process.env.DATA_ROOT!
const TOES_URL = process.env.TOES_URL!
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')
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'),
})
const Tab = define('Tab', {
base: 'button',
padding: '6px 0',
background: 'none',
border: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
fontSize: '14px',
color: theme('colors-textMuted'),
states: {
':hover': { color: theme('colors-text') },
'.active': {
color: theme('colors-text'),
borderBottomColor: theme('colors-primary'),
fontWeight: '500',
},
},
})
const TabBar = define('TabBar', {
display: 'flex',
gap: '24px',
marginBottom: '20px',
})
const Toggle = define('Toggle', {
base: 'button',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '3px 10px',
borderRadius: theme('radius-md'),
border: `1px solid ${theme('colors-border')}`,
backgroundColor: theme('colors-bgElement'),
color: theme('colors-textMuted'),
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.15s ease',
states: {
':hover': { borderColor: theme('colors-textMuted') },
'.public': {
backgroundColor: theme('colors-statusRunning'),
color: 'white',
borderColor: 'transparent',
},
},
})
// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------
interface AppRepoProps {
appName: string
baseUrl: string
branch: string
exists: boolean
commits: boolean
}
interface LayoutProps {
title: string
children: Child
}
interface RepoListPageProps {
baseUrl: string
external: boolean
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility; tool: boolean }>
tunnelUrl?: string
}
type Visibility = 'public' | 'private'
// ---------------------------------------------------------------------------
// Functions
// ---------------------------------------------------------------------------
const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)
// 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): Promise<string | null> {
const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/reload`, {
method: 'POST',
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const msg = (body as Record<string, string>).error ?? `reload returned ${res.status}`
console.error(`Reload failed for ${name}:`, msg)
return msg
}
return null
}
async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }> {
const bare = repoPath(repoName)
if (!(await hasCommits(bare))) {
return { ok: false, error: 'No commits in repository' }
}
// Validate in a temp dir before touching the real app dir
const tmpDir = join(APPS_DIR, `.${repoName}-deploy-tmp`)
await rm(tmpDir, { recursive: true, force: true })
await mkdir(tmpDir, { recursive: true })
// Extract HEAD into the temp directory
const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
stdout: 'pipe',
stderr: 'pipe',
})
const tar = Bun.spawn(['tar', '-x', '-C', tmpDir], {
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(tmpDir, { recursive: true, force: true })
return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
}
// Verify package.json with scripts.toes exists
const pkgPath = join(tmpDir, 'package.json')
if (!(await Bun.file(pkgPath).exists())) {
await rm(tmpDir, { 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(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'package.json missing scripts.toes entry' }
}
} catch {
await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'Invalid package.json' }
}
// Stop the app before swapping directories
await stopIfRunning(repoName)
// Validation passed — swap directories (reload endpoint handles restart)
const appDir = join(APPS_DIR, repoName)
await rm(appDir, { recursive: true, force: true })
await rename(tmpDir, appDir)
return { ok: true }
}
// 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 getVisibility(repo: string): Promise<Visibility> {
const all = await loadVisibility()
return all[repo] ?? 'private'
}
async function getDefaultBranch(bare: string): Promise<string> {
const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], {
cwd: bare,
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: Uint8Array | 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
}
async function loadVisibility(): Promise<Record<string, Visibility>> {
try {
const data = await readFile(VISIBILITY_PATH, 'utf-8')
return JSON.parse(data)
} catch {
return {}
}
}
function Layout({ title, children }: LayoutProps) {
return (
<html>
<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 saveVisibility(repo: string, visibility: Visibility): Promise<void> {
const all = await loadVisibility()
all[repo] = visibility
await writeFile(VISIBILITY_PATH, JSON.stringify(all, null, 2))
}
async function stopIfRunning(name: string): Promise<void> {
const res = await fetch(`${TOES_URL}/api/apps/${name}`)
if (!res.ok) return
const app = await res.json() as { state: string }
if (app.state !== 'running' && app.state !== 'starting') return
await fetch(`${TOES_URL}/api/apps/${name}/stop`, { method: 'POST' })
const maxWait = 10000
const poll = 100
let waited = 0
while (waited < maxWait) {
await new Promise(r => setTimeout(r, poll))
waited += poll
const check = await fetch(`${TOES_URL}/api/apps/${name}`)
if (!check.ok) break
const { state } = await check.json() as { state: string }
if (state !== 'running' && state !== 'stopping' && state !== 'starting') break
}
}
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)
}
}
function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
return (
<Layout title={`Git - ${appName}`}>
{exists && commits ? (
<>
<Heading>Repository</Heading>
<RepoList>
<RepoItem>
<div>
<RepoName>{appName}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{appName}
</HelpText>
</div>
<div style="display: flex; gap: 8px; align-items: center">
<Badge>{branch}</Badge>
<Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
</div>
</RepoItem>
</RepoList>
<Heading>Push Changes</Heading>
<CodeBlock>{[
`git push toes ${branch}`,
'',
'# Or if remote not yet added:',
`git remote add toes ${baseUrl}/${appName}`,
`git push toes ${branch}`,
].join('\n')}</CodeBlock>
</>
) : exists ? (
<>
<Heading>Repository</Heading>
<RepoList>
<RepoItem>
<div>
<RepoName>{appName}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{appName}
</HelpText>
</div>
<Badge>empty</Badge>
</RepoItem>
</RepoList>
<Heading>Push to Deploy</Heading>
<CodeBlock>{[
`git remote add toes ${baseUrl}/${appName}`,
'git push toes main',
].join('\n')}</CodeBlock>
</>
) : (
<>
<Heading>Push to Deploy</Heading>
<HelpText>
No git repository for <strong>{appName}</strong> yet.
Push to create one and deploy.
</HelpText>
<CodeBlock>{[
`git remote add toes ${baseUrl}/${appName}`,
'git push toes main',
].join('\n')}</CodeBlock>
</>
)}
</Layout>
)
}
function RepoListItems({ baseUrl, external, repos, tunnelUrl }: {
baseUrl: string
external: boolean
repos: RepoListPageProps['repos']
tunnelUrl?: string
}) {
if (repos.length === 0) {
return <HelpText>No repositories yet.</HelpText>
}
return (
<RepoList>
{repos.map(({ name, commits, branch, visibility }) => (
<RepoItem>
<div>
<RepoName>{name}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{name}
</HelpText>
{!external && tunnelUrl && visibility === 'public' && (
<HelpText style="margin: 2px 0 0; font-size: 12px">
git clone {tunnelUrl}/{name}
</HelpText>
)}
</div>
<div style="display: flex; gap: 8px; align-items: center">
{!external && (
<Toggle
class={visibility === 'public' ? 'public' : ''}
data-repo={name}
data-visibility={visibility}
onclick="toggleVisibility(this)"
>
{visibility === 'public' ? 'public' : 'private'}
</Toggle>
)}
<Badge>{branch}</Badge>
{commits
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
: <Badge>empty</Badge>}
</div>
</RepoItem>
))}
</RepoList>
)
}
function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps) {
const appRepos = repos.filter(r => !r.tool)
const toolRepos = repos.filter(r => r.tool)
return (
<Layout title="Git">
{!external && (
<>
<Heading>Push to Deploy</Heading>
<HelpText>
Push a git repository to deploy it as a toes app.
The repo must contain a <code>package.json</code> with a <code>scripts.toes</code> entry.
</HelpText>
<CodeBlock>{[
'# Add this server as a remote and push',
`git remote add toes ${baseUrl}/<app-name>`,
'git push toes main',
'',
'# Or push an existing repo',
`git push ${baseUrl}/<app-name> main`,
].join('\n')}</CodeBlock>
</>
)}
{repos.length > 0 && appRepos.length > 0 && toolRepos.length > 0 && (
<>
<Heading>Repositories</Heading>
<TabBar>
<Tab class="active" data-tab="tab-apps" onclick="switchTab(this)">Apps</Tab>
<Tab data-tab="tab-tools" onclick="switchTab(this)">Tools</Tab>
</TabBar>
<div>
<div id="tab-apps">
<RepoListItems baseUrl={baseUrl} external={external} repos={appRepos} tunnelUrl={tunnelUrl} />
</div>
<div id="tab-tools" style="display: none">
<RepoListItems baseUrl={baseUrl} external={external} repos={toolRepos} tunnelUrl={tunnelUrl} />
</div>
</div>
{!external && <script src="/client/toggle.js" />}
<script src="/client/tabs.js" />
</>
)}
{repos.length > 0 && (appRepos.length === 0 || toolRepos.length === 0) && (
<>
<Heading>Repositories</Heading>
<RepoListItems baseUrl={baseUrl} external={external} repos={repos} tunnelUrl={tunnelUrl} />
{!external && <script src="/client/toggle.js" />}
</>
)}
{repos.length === 0 && (
<HelpText>No repositories yet. Push one to get started.</HelpText>
)}
</Layout>
)
}
// ---------------------------------------------------------------------------
// Module init
// ---------------------------------------------------------------------------
mkdirSync(REPOS_DIR, { recursive: true })
on('app:delete', async ({ app: name }) => {
const bare = repoPath(name)
if (await dirExists(bare)) await rm(bare, { recursive: true, force: 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.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/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 (c.req.header('x-sneaker')) {
if (service === 'git-receive-pack') {
return c.text('Push access denied over sneaker', 403)
}
if (await getVisibility(repoParam) !== 'public') {
return c.text('Repository not found', 404)
}
}
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.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/git-upload-pack'], async c => {
const repoParam = c.req.param('repo').replace(/\.git$/, '')
if (!validRepoName(repoParam)) {
return c.text('Invalid repository name', 400)
}
if (c.req.header('x-sneaker') && await getVisibility(repoParam) !== 'public') {
return c.text('Repository not found', 404)
}
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.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'], async c => {
if (c.req.header('x-sneaker')) {
return c.text('Push access denied over sneaker', 403)
}
const repoParam = c.req.param('repo').replace(/\.git$/, '')
if (!validRepoName(repoParam)) {
return c.text('Invalid repository name', 400)
}
await ensureBareRepo(repoParam)
// Buffer the request body before passing to git-receive-pack. Piping a live
// HTTP ReadableStream directly to subprocess stdin deadlocks on large pushes:
// the pipe buffer fills, stalling the stream reader, while git-receive-pack
// can't finish reading stdin to produce stdout — both sides block.
const body = new Uint8Array(await c.req.raw.arrayBuffer())
const response = await gitRpc(repoParam, 'git-receive-pack', 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) {
const err = await activateApp(repoParam)
if (err) {
console.error(`Reload failed for ${repoParam}: ${err}`)
return `Deploy succeeded but reload failed: ${err}`
}
console.log(`Deployed ${repoParam}`)
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.post('/api/visibility/:repo', async c => {
if (c.req.header('x-sneaker')) return c.json({ error: 'Forbidden' }, 403)
const repo = c.req.param('repo')
if (!validRepoName(repo)) return c.json({ error: 'Invalid repository name' }, 400)
const body = await c.req.json<{ visibility: string }>()
if (body.visibility !== 'public' && body.visibility !== 'private') {
return c.json({ error: 'Visibility must be "public" or "private"' }, 400)
}
await saveVisibility(repo, body.visibility)
return c.json({ ok: true })
})
app.get('/', async c => {
const appName = c.req.query('app')
const sneakerHost = c.req.header('x-sneaker')
const external = !!sneakerHost
const baseUrl = sneakerHost ? `https://${sneakerHost}` : APP_URL
// When viewing a specific app, only show that app's repo
if (appName) {
const bare = repoPath(appName)
const exists = await dirExists(bare)
const [commits, branch] = exists
? await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
: [false, 'main']
return c.html(<AppRepo appName={appName} baseUrl={baseUrl} branch={branch} exists={exists} commits={commits} />)
}
// No app selected — show all repos
const repos = await listRepos()
// Fetch all apps to determine which repos are tools
let toolSet = new Set<string>()
try {
const res = await fetch(`${TOES_URL}/api/apps`)
if (res.ok) {
const apps = await res.json() as Array<{ name: string; tool?: boolean | string }>
for (const a of apps) {
if (a.tool) toolSet.add(a.name)
}
}
} catch {}
const repoData = await Promise.all(repos.map(async name => {
const bare = repoPath(name)
const [commits, branch, visibility] = await Promise.all([
hasCommits(bare),
getDefaultBranch(bare),
getVisibility(name),
])
return { name, commits, branch, visibility, tool: toolSet.has(name) }
}))
// Hide private repos from external (sneaker) requests
const filtered = external
? repoData.filter(r => r.visibility === 'public')
: repoData
// Fetch tunnel URL for the git tool so we can show it for public repos
let tunnelUrl: string | undefined
if (!external) {
try {
const res = await fetch(`${TOES_URL}/api/apps/git`)
if (res.ok) {
const info = await res.json() as { tunnelUrl?: string }
tunnelUrl = info.tunnelUrl
}
} catch {}
}
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} tunnelUrl={tunnelUrl} />)
})
export default app.defaults

View File

@ -1,13 +0,0 @@
function switchTab(btn: HTMLButtonElement) {
const tabs = btn.parentElement!.querySelectorAll('button')
for (const tab of tabs) tab.classList.remove('active')
btn.classList.add('active')
const panels = btn.parentElement!.nextElementSibling!.children
for (const panel of panels) (panel as HTMLElement).style.display = 'none'
const target = document.getElementById(btn.dataset.tab!)
if (target) target.style.display = 'block'
}
Object.assign(window, { switchTab })

View File

@ -1,19 +0,0 @@
function toggleVisibility(btn: HTMLButtonElement) {
const repo = btn.dataset.repo!
const current = btn.dataset.visibility!
const next = current === 'public' ? 'private' : 'public'
btn.dataset.visibility = next
btn.textContent = next
btn.classList.toggle('public', next === 'public')
fetch('/api/visibility/' + encodeURIComponent(repo), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visibility: next }),
}).catch(() => {
btn.dataset.visibility = current
btn.textContent = current
btn.classList.toggle('public', current === 'public')
})
}
Object.assign(window, { toggleVisibility })

View File

@ -55,12 +55,6 @@ interface ProcessMetrics {
rss: number rss: number
} }
interface SystemMetrics {
cpu: number
ram: { used: number, total: number, percent: number }
disk: { used: number, total: number, percent: number }
}
// ============================================================================ // ============================================================================
// Process Metrics Collection // Process Metrics Collection
// ============================================================================ // ============================================================================
@ -408,40 +402,6 @@ const ChartWrapper = define('ChartWrapper', {
height: '150px', height: '150px',
}) })
const GaugeLabel = define('GaugeLabel', {
fontSize: '13px',
fontWeight: 600,
color: theme('colors-textMuted'),
textTransform: 'uppercase',
letterSpacing: '0.5px',
})
const GaugeValueText = define('GaugeValueText', {
textAlign: 'center',
fontSize: '20px',
fontWeight: 'bold',
marginTop: '-4px',
color: theme('colors-text'),
})
const GaugesCard = define('GaugesCard', {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
padding: '24px',
})
const GaugesGrid = define('GaugesGrid', {
display: 'flex',
justifyContent: 'center',
gap: '40px',
padding: '20px 0',
})
const NoDataMessage = define('NoDataMessage', { const NoDataMessage = define('NoDataMessage', {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -475,14 +435,6 @@ function formatRss(kb?: number): string {
return `${(kb / 1024 / 1024).toFixed(2)} GB` return `${(kb / 1024 / 1024).toFixed(2)} GB`
} }
async function fetchSystemMetrics(): Promise<SystemMetrics> {
try {
const res = await fetch(`${TOES_URL}/api/system/metrics`)
if (res.ok) return await res.json() as SystemMetrics
} catch {}
return { cpu: 0, ram: { used: 0, total: 0, percent: 0 }, disk: { used: 0, total: 0, percent: 0 } }
}
function getStatusColor(state: string): string { function getStatusColor(state: string): string {
switch (state) { switch (state) {
case 'running': case 'running':
@ -525,107 +477,6 @@ function Layout({ title, children }: LayoutProps) {
) )
} }
// ============================================================================
// Gauge Rendering
// ============================================================================
const G_SEGMENTS = 19
const G_START = -225
const G_SWEEP = 270
const G_CX = 60
const G_CY = 60
const G_R = 44
const G_GAP = 3
const G_SW = 8
const G_NL = 38
const gToRad = (deg: number) => (deg * Math.PI) / 180
const gSegColor = (i: number): string => {
const t = i / (G_SEGMENTS - 1)
if (t < 0.4) return '#4caf50'
if (t < 0.6) return '#8bc34a'
if (t < 0.75) return '#ffc107'
if (t < 0.9) return '#ff9800'
return '#f44336'
}
function renderGauge(value: number, id: string) {
const segSweep = G_SWEEP / G_SEGMENTS
const active = Math.round((value / 100) * G_SEGMENTS)
const innerR = G_R - G_SW / 2
const outerR = G_R + G_SW / 2
const segments = []
for (let i = 0; i < G_SEGMENTS; i++) {
const s = G_START + i * segSweep + G_GAP / 2
const e = G_START + (i + 1) * segSweep - G_GAP / 2
const x1 = G_CX + outerR * Math.cos(gToRad(s))
const y1 = G_CY + outerR * Math.sin(gToRad(s))
const x2 = G_CX + outerR * Math.cos(gToRad(e))
const y2 = G_CY + outerR * Math.sin(gToRad(e))
const x3 = G_CX + innerR * Math.cos(gToRad(e))
const y3 = G_CY + innerR * Math.sin(gToRad(e))
const x4 = G_CX + innerR * Math.cos(gToRad(s))
const y4 = G_CY + innerR * Math.sin(gToRad(s))
segments.push(
<path
key={i}
data-segment={i}
d={`M ${x1} ${y1} A ${outerR} ${outerR} 0 0 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 0 0 ${x4} ${y4} Z`}
fill={i < active ? gSegColor(i) : 'var(--colors-border)'}
/>
)
}
const angle = G_START + (value / 100) * G_SWEEP
const nx = G_CX + G_NL * Math.cos(gToRad(angle))
const ny = G_CY + G_NL * Math.sin(gToRad(angle))
const pa = angle + 90
const bw = 3
const bx1 = G_CX + bw * Math.cos(gToRad(pa))
const by1 = G_CY + bw * Math.sin(gToRad(pa))
const bx2 = G_CX - bw * Math.cos(gToRad(pa))
const by2 = G_CY - bw * Math.sin(gToRad(pa))
return (
<GaugesCard>
<GaugeLabel>{id}</GaugeLabel>
<svg id={`gauge-${id}`} viewBox="10 10 100 55" width="140" height="80" style="overflow: visible">
{segments}
<polygon data-needle points={`${nx},${ny} ${bx1},${by1} ${bx2},${by2}`} fill="var(--colors-text)" />
<circle cx={G_CX} cy={G_CY} r="4" fill="var(--colors-textMuted)" />
</svg>
<GaugeValueText id={`value-${id}`}>{value}%</GaugeValueText>
</GaugesCard>
)
}
const gaugeScript = `
(function() {
var S=19,ST=-225,SW=270,CX=60,CY=60,R=44,W=8,NL=38,GAP=3;
var iR=R-W/2, oR=R+W/2;
function rad(d){return d*Math.PI/180}
function sc(i){var t=i/(S-1);return t<.4?'#4caf50':t<.6?'#8bc34a':t<.75?'#ffc107':t<.9?'#ff9800':'#f44336'}
function upd(id,v){
var svg=document.getElementById('gauge-'+id);if(!svg)return;
var a=Math.round((v/100)*S);
svg.querySelectorAll('[data-segment]').forEach(function(p,i){p.setAttribute('fill',i<a?sc(i):'var(--colors-border)')});
var ang=ST+(v/100)*SW,nx=CX+NL*Math.cos(rad(ang)),ny=CY+NL*Math.sin(rad(ang));
var pa=ang+90,bw=3;
var bx1=CX+bw*Math.cos(rad(pa)),by1=CY+bw*Math.sin(rad(pa));
var bx2=CX-bw*Math.cos(rad(pa)),by2=CY-bw*Math.sin(rad(pa));
var n=svg.querySelector('[data-needle]');if(n)n.setAttribute('points',nx+','+ny+' '+bx1+','+by1+' '+bx2+','+by2);
var el=document.getElementById('value-'+id);if(el)el.textContent=v+'%';
}
setInterval(function(){
fetch('/api/system').then(function(r){return r.json()}).then(function(m){
upd('cpu',m.cpu);upd('ram',m.ram.percent);upd('disk',m.disk.percent);
}).catch(function(){});
},2000);
})();
`
// ============================================================================ // ============================================================================
// App // App
// ============================================================================ // ============================================================================
@ -659,11 +510,6 @@ app.get('/api/data-history/:name', c => {
return c.json(history) return c.json(history)
}) })
app.get('/api/system', async c => {
const metrics = await fetchSystemMetrics()
return c.json(metrics)
})
app.get('/api/history/:name', c => { app.get('/api/history/:name', c => {
const name = c.req.param('name') const name = c.req.param('name')
const history = getHistory(name) const history = getHistory(name)
@ -1110,17 +956,67 @@ app.get('/', async c => {
) )
} }
// Dashboard view: system metrics gauges // All apps view
const sys = await fetchSystemMetrics() const metrics = await getAppMetrics()
// Sort: running first, then by name
metrics.sort((a, b) => {
if (a.state === 'running' && b.state !== 'running') return -1
if (a.state !== 'running' && b.state === 'running') return 1
return a.name.localeCompare(b.name)
})
if (metrics.length === 0) {
return c.html(
<Layout title="Metrics">
<EmptyState>No apps found</EmptyState>
</Layout>
)
}
const running = metrics.filter(s => s.state === 'running')
const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0)
const totalData = metrics.reduce((sum, s) => sum + (s.dataSize ?? 0), 0)
return c.html( return c.html(
<Layout title="Metrics"> <Layout title="Metrics">
<GaugesGrid> <Table>
{renderGauge(sys.cpu, 'cpu')} <thead>
{renderGauge(sys.ram.percent, 'ram')} <tr>
{renderGauge(sys.disk.percent, 'disk')} <Th>Name</Th>
</GaugesGrid> <Th>State</Th>
<script dangerouslySetInnerHTML={{ __html: gaugeScript }} /> <ThRight>PID</ThRight>
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr>
</thead>
<tbody>
{metrics.map(s => (
<Tr>
<Td>
{s.name}
{s.tool && <ToolBadge>[tool]</ToolBadge>}
</Td>
<Td>
<StatusBadge style={`color: ${getStatusColor(s.state)}`}>
{s.state}
</StatusBadge>
</Td>
<TdRight>{s.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(s.cpu)}</TdRight>
<TdRight>{formatPercent(s.memory)}</TdRight>
<TdRight>{formatRss(s.rss)}</TdRight>
<TdRight>{formatBytes(s.dataSize)}</TdRight>
</Tr>
))}
</tbody>
</Table>
<Summary>
{running.length} running &middot; {formatPercent(totalCpu)} CPU &middot; {formatRss(totalRss)} RSS &middot; {formatBytes(totalData)} data
</Summary>
</Layout> </Layout>
) )
}) })

View File

@ -10,8 +10,7 @@
}, },
"toes": { "toes": {
"tool": true, "tool": true,
"icon": "📊", "icon": "📊"
"dashboard": true
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

View File

@ -0,0 +1,45 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "versions",
"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.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"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.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
"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.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

View File

@ -0,0 +1,177 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, readlink, stat } from 'fs/promises'
import { join } from 'path'
import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR!
const TOES_URL = process.env.TOES_URL!
const app = new Hype({ prettyHTML: false })
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
const VersionList = define('VersionList', {
listStyle: 'none',
padding: 0,
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
const VersionItem = define('VersionItem', {
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 VersionLink = define('VersionLink', {
base: 'a',
textDecoration: 'none',
color: theme('colors-link'),
fontFamily: theme('fonts-mono'),
fontSize: '15px',
cursor: 'pointer',
states: {
':hover': {
textDecoration: 'underline',
},
},
})
const Badge = define('Badge', {
fontSize: '12px',
padding: '2px 8px',
borderRadius: theme('radius-md'),
backgroundColor: theme('colors-bgElement'),
color: theme('colors-statusRunning'),
fontWeight: 'bold',
})
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
margin: '20px 0',
})
interface LayoutProps {
title: string
children: Child
}
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>
)
}
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
async function getVersions(appPath: string): Promise<{ name: string; isCurrent: boolean }[]> {
const entries = await readdir(appPath, { withFileTypes: true })
let currentTarget = ''
try {
currentTarget = await readlink(join(appPath, 'current'))
} catch { }
return entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => ({ name: e.name, isCurrent: e.name === currentTarget }))
.sort((a, b) => b.name.localeCompare(a.name))
}
function formatTimestamp(ts: string): string {
return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(9, 11)}:${ts.slice(11, 13)}:${ts.slice(13, 15)}`
}
app.get('/', async c => {
const appName = c.req.query('app')
if (!appName) {
return c.html(
<Layout title="Versions">
<ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox>
</Layout>
)
}
const appPath = join(APPS_DIR, appName)
try {
await stat(appPath)
} catch {
return c.html(
<Layout title="Versions">
<ErrorBox>App "{appName}" not found</ErrorBox>
</Layout>
)
}
const versions = await getVersions(appPath)
if (versions.length === 0) {
return c.html(
<Layout title="Versions">
<ErrorBox>No versions found</ErrorBox>
</Layout>
)
}
return c.html(
<Layout title="Versions">
<VersionList>
{versions.map(v => (
<VersionItem>
<VersionLink
href={`${TOES_URL}/tool/code?app=${appName}&version=${v.name}`}
>
{formatTimestamp(v.name)}
</VersionLink>
{v.isCurrent && <Badge>current</Badge>}
</VersionItem>
))}
</VersionList>
</Layout>
)
})
export default app.defaults

View File

@ -1,5 +1,5 @@
{ {
"name": "git", "name": "versions",
"module": "index.tsx", "module": "index.tsx",
"type": "module", "type": "module",
"private": true, "private": true,
@ -10,9 +10,7 @@
}, },
"toes": { "toes": {
"tool": true, "tool": true,
"dashboard": true, "icon": "📦"
"share": true,
"icon": "🔀"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
@ -23,6 +21,6 @@
"dependencies": { "dependencies": {
"@because/forge": "^0.0.1", "@because/forge": "^0.0.1",
"@because/hype": "^0.0.2", "@because/hype": "^0.0.2",
"@because/toes": "0.0.12" "@because/toes": "^0.0.5"
} }
} }

View File

@ -7,9 +7,8 @@
"dependencies": { "dependencies": {
"@because/forge": "^0.0.1", "@because/forge": "^0.0.1",
"@because/hype": "^0.0.2", "@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.4", "@because/sneaker": "^0.0.3",
"@because/toes": "^0.0.12", "commander": "^14.0.3",
"commander": "14.0.3",
"diff": "^8.0.3", "diff": "^8.0.3",
"kleur": "^4.1.5", "kleur": "^4.1.5",
}, },
@ -27,17 +26,15 @@
"@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/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/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="], "@because/sneaker": ["@because/sneaker@0.0.3", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.3.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-4cG8w/tYPGbDtLw89k1PiASJKfWUdd1NXv+GKad2d7Ckw3FpZ+dnN2+gR2ihs81dqAkNaZomo+9RznBju2WaOw=="],
"@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="], "@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/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], "@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@types/node": ["@types/node@25.2.3", "https://npm.nose.space/@types/node/-/node-25.2.3.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], "@types/node": ["@types/node@25.2.3", "https://npm.nose.space/@types/node/-/node-25.2.3.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "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=="], "commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],

View File

@ -1,45 +0,0 @@
#!/usr/bin/env bash
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
echo ""
echo " ┌── CLI installer (curl | bash) ──────────────┐"
echo ""
echo " ${b}🐾 toes cli${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " ${d}Fetching macos/arm64...${r}"
echo " ${g}Installed to${r} ${b}/Users/chris/.local/bin/toes${r}"
echo ""
echo " ${y}Add /Users/chris/.local/bin to your \$PATH, then:${r}"
echo " Run ${c}toes${r} to get started."
echo ""
echo ""
echo " ┌── After deploy ─────────────────────────────┐"
echo ""
echo " ${b}${g}🐾 Deployed${r} to ${b}pi@toes.local${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://toes.local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://toes.local/install | bash${r}"
echo ""
echo ""
echo " ┌── After server install ─────────────────────┐"
echo ""
echo " ${d}╔══════════════════════════════════╗${r}"
echo " ${d}${r} ${b}🐾 toes${r} ${d}- personal web appliance ║${r}"
echo " ${d}╚══════════════════════════════════╝${r}"
echo ""
echo " ${d}>>${r} Updating system packages"
echo " ${d}>>${r} Installing bun"
echo " ${d}>>${r} Building"
echo ""
echo " ${b}${g}🐾 toes abc1234 is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://toes.local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://toes.local/install | bash${r}"
echo ""

View File

@ -6,8 +6,10 @@ An app is an HTTP server that runs on its assigned port.
``` ```
apps/<name>/ apps/<name>/
package.json <timestamp>/ # YYYYMMDD-HHMMSS
index.tsx package.json
index.tsx
current -> <timestamp> # symlink to active version
``` ```
**package.json** must have `scripts.toes`: **package.json** must have `scripts.toes`:

View File

@ -9,7 +9,7 @@ The cron tool discovers jobs from all apps and runs them automatically.
Add a file to `cron/` in any app: Add a file to `cron/` in any app:
```ts ```ts
// apps/my-app/cron/daily-cleanup.ts // apps/my-app/current/cron/daily-cleanup.ts
export const schedule = "day" export const schedule = "day"
export default async function() { export default async function() {
@ -73,7 +73,7 @@ Jobs track:
## discovery ## discovery
The cron tool: The cron tool:
1. Scans `APPS_DIR/*/cron/*.ts` 1. Scans `APPS_DIR/*/current/cron/*.ts`
2. Imports each file to read `schedule` 2. Imports each file to read `schedule`
3. Validates the schedule 3. Validates the schedule
4. Registers with croner 4. Registers with croner

View File

@ -19,11 +19,13 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
- [CLI Reference](#cli-reference) - [CLI Reference](#cli-reference)
- [App Management](#app-management) - [App Management](#app-management)
- [Lifecycle](#lifecycle) - [Lifecycle](#lifecycle)
- [Deploying Code](#deploying-code) - [Syncing Code](#syncing-code)
- [Environment Variables](#environment-variables) - [Environment Variables](#environment-variables)
- [Versioning](#versioning)
- [Cron Jobs](#cron-jobs-1) - [Cron Jobs](#cron-jobs-1)
- [Metrics](#metrics) - [Metrics](#metrics)
- [Sharing](#sharing) - [Sharing](#sharing)
- [Configuration](#configuration)
- [Environment Variables](#environment-variables-1) - [Environment Variables](#environment-variables-1)
- [Health Checks](#health-checks) - [Health Checks](#health-checks)
- [App Lifecycle](#app-lifecycle) - [App Lifecycle](#app-lifecycle)
@ -38,7 +40,7 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
# Install the CLI # Install the CLI
curl -fsSL http://toes.local/install | bash curl -fsSL http://toes.local/install | bash
# Create a new app (scaffolds, inits git, and pushes to server) # Create a new app
toes new my-app toes new my-app
# Enter the directory, install deps, and develop locally # Enter the directory, install deps, and develop locally
@ -46,9 +48,8 @@ cd my-app
bun install bun install
bun dev bun dev
# Deploy changes (standard git) # Push to the server
git add . && git commit -m "my changes" toes push
git push toes main
# Open in browser # Open in browser
toes open toes open
@ -56,7 +57,7 @@ toes open
Your app is now running at `http://my-app.toes.local`. Your app is now running at `http://my-app.toes.local`.
`toes new` automatically sets up a `toes` git remote pointing at the server. Pushing to it triggers a deploy. > **Tip:** Add `.toes` to your `.gitignore`. This file tracks local sync state and shouldn't be committed.
--- ---
@ -84,8 +85,8 @@ A generated SSR app looks like this:
``` ```
my-app/ my-app/
.gitignore # Files to exclude from sync and deploy
.npmrc # Points to the private registry .npmrc # Points to the private registry
.toesignore # Files to exclude from sync (like .gitignore)
package.json # Must have scripts.toes package.json # Must have scripts.toes
tsconfig.json # TypeScript config tsconfig.json # TypeScript config
index.tsx # Entry point (re-exports from src/server) index.tsx # Entry point (re-exports from src/server)
@ -456,11 +457,13 @@ app.get('/', c => {
const appName = c.req.query('app') const appName = c.req.query('app')
if (!appName) return c.html(<p>No app selected</p>) if (!appName) return c.html(<p>No app selected</p>)
const appPath = join(APPS_DIR, appName) const appPath = join(APPS_DIR, appName, 'current')
// Read files from appPath... // Read files from appPath...
}) })
``` ```
Always go through the `current` symlink — never access version directories directly.
**Calling the Toes API:** **Calling the Toes API:**
```tsx ```tsx
@ -519,21 +522,19 @@ toes new my-app --bare # Minimal template
toes new my-app --spa # SPA template toes new my-app --spa # SPA template
``` ```
Scaffolds the app locally, initializes a git repo with a `toes` remote pointing at the server, and pushes. The git push triggers a deploy. If run without a name, scaffolds the current directory. Creates the app locally, then pushes it to the server. If run without a name, scaffolds the current directory.
**`toes info [name]`** — Show details for an app (state, URL, port, PID, uptime). **`toes info [name]`** — Show details for an app (state, URL, port, PID, uptime).
**`toes get <name>`** — Clone an app from the server to your local machine. **`toes get <name>`** — Download an app from the server to your local machine.
```bash ```bash
toes get my-app # Clones into ./my-app/ toes get my-app # Creates ./my-app/ with all files
cd my-app cd my-app
bun install bun install
bun dev # Develop locally bun dev # Develop locally
``` ```
The clone comes with a `toes` remote already configured, so `git push toes main` deploys.
**`toes open [name]`** — Open a running app in your browser. **`toes open [name]`** — Open a running app in your browser.
**`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation. **`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation.
@ -561,38 +562,54 @@ toes logs my-app -f -g error # Follow and filter
Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months). Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months).
### Deploying Code ### Syncing Code
Toes uses git for deployments. Each app has a `toes` remote that points to the server's git tool. Pushing to it extracts the latest commit and deploys it. Toes uses a manifest-based sync protocol. Each file is tracked by SHA-256 hash. The server stores versioned snapshots with timestamps.
**`toes push`** — Push local changes to the server.
```bash ```bash
# Make changes, commit, and deploy toes push # Push changes (fails if server changed)
git add . toes push --force # Overwrite server changes
git commit -m "update homepage"
git push toes main
``` ```
The git push triggers the server to: Creates a new version on the server, uploads changed files, deletes removed files, then activates the new version. The app auto-restarts.
1. Store the commit in a bare repo at `DATA_DIR/repos/<name>.git`
2. Extract HEAD into the app directory
3. Run `bun install` and restart the app
Use standard git commands for history, diffing, and rollback: **`toes pull`** — Pull changes from the server.
```bash ```bash
git log # View deploy history toes pull # Pull changes (fails if you have local changes)
git diff HEAD~1 # See what changed toes pull --force # Overwrite local changes
git revert HEAD # Undo last deploy
git push toes main # Deploy the revert
``` ```
To clone an existing app from the server: **`toes status`** — Show what would be pushed or pulled.
```bash ```bash
git clone http://git.toes.local/my-app toes status
cd my-app # Changes to push:
bun install # * index.tsx
bun dev # Develop locally # + new-file.ts
# - removed-file.ts
```
**`toes diff`** — Show a line-by-line diff of changed files.
**`toes sync`** — Watch for changes and sync bidirectionally in real-time. Useful during development when editing on the server.
**`toes clean`** — Remove local files that don't exist on the server.
```bash
toes clean # Interactive confirmation
toes clean --force # No confirmation
toes clean --dry-run # Show what would be removed
```
**`toes stash`** — Stash local changes (like `git stash`).
```bash
toes stash # Save local changes
toes stash pop # Restore stashed changes
toes stash list # List all stashes
``` ```
### Environment Variables ### Environment Variables
@ -623,12 +640,26 @@ toes env rm -g API_KEY # Remove global var
### Versioning ### Versioning
Every `git push toes main` creates a new deploy. Version history is managed through git. Every push creates a timestamped version. The server keeps the last 5 versions.
**`toes versions [name]`** — List deployed versions.
```bash ```bash
git log --oneline # List deploys toes versions my-app
git revert HEAD # Undo last change # Versions for my-app:
git push toes main # Deploy the revert #
# → 20260219-143022 2/19/2026, 2:30:22 PM (current)
# 20260218-091500 2/18/2026, 9:15:00 AM
# 20260217-160845 2/17/2026, 4:08:45 PM
```
**`toes history [name]`** — Show file changes between versions.
**`toes rollback [name]`** — Rollback to a previous version.
```bash
toes rollback my-app # Interactive version picker
toes rollback my-app -v 20260218-091500 # Rollback to specific version
``` ```
### Cron Jobs ### Cron Jobs
@ -686,6 +717,10 @@ toes share my-app
**`toes unshare [name]`** — Stop sharing an app. **`toes unshare [name]`** — Stop sharing an app.
### Configuration
**`toes config`** — Show the current server URL and sync state.
--- ---
## Environment Variables ## Environment Variables

View File

@ -44,17 +44,21 @@ app.get('/', c => {
## accessing app files ## accessing app files
Always go through the `current` symlink:
```ts ```ts
const APPS_DIR = process.env.APPS_DIR! const APPS_DIR = process.env.APPS_DIR!
const appPath = join(APPS_DIR, appName) const appPath = join(APPS_DIR, appName, 'current')
``` ```
Not `APPS_DIR/appName` directly.
## linking to tools ## linking to tools
Use `/tool/:name` URLs to link directly to tools with params: Use `/tool/:name` URLs to link directly to tools with params:
```html ```html
<a href="/tool/code?app=my-app"> <a href="/tool/code?app=my-app&version=20260130-000000">
View in Code View in Code
</a> </a>
``` ```

View File

@ -1,26 +0,0 @@
{
"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=="],
}
}

View File

@ -1,137 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
##
# toes installer
# Usage: curl -fsSL https://toes.dev/install | sh
#
# Installs or updates toes on a Raspberry Pi.
# Must be run as the 'toes' user with passwordless sudo.
REPO="https://git.nose.space/defunkt/toes"
DEST=~/toes
APPS_DIR=~/apps
DATA_DIR=~/data
# ── Helpers ──────────────────────────────────────────────
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
quiet() { "$@" > /dev/null 2>&1; }
info() { echo " ${d}>>${r} $1"; }
fail() { echo " ${y}ERROR:${r} $1" >&2; exit 1; }
# ── Preflight ────────────────────────────────────────────
echo ""
echo " ${d}╔══════════════════════════════════╗${r}"
echo " ${d}${r} ${b}🐾 toes${r} ${d}- personal web appliance ║${r}"
echo " ${d}╚══════════════════════════════════╝${r}"
echo ""
[ "$(whoami)" = "toes" ] || fail "Must be run as the 'toes' user."
sudo -n true 2>/dev/null || fail "Requires passwordless sudo."
# ── System packages ──────────────────────────────────────
info "Updating system packages"
quiet sudo apt-get update
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
info "Setting fish as default shell"
quiet sudo chsh -s /usr/bin/fish toes
fi
# ── Bun ──────────────────────────────────────────────────
BUN="$HOME/.bun/bin/bun"
if [ ! -x "$BUN" ]; then
info "Installing bun"
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
[ -x "$BUN" ] || fail "bun installation failed."
fi
sudo ln -sf "$BUN" /usr/local/bin/bun
sudo setcap 'cap_net_bind_service=+ep' "$BUN"
# ── Clone or pull ────────────────────────────────────────
if [ -d "$DEST/.git" ]; then
info "Pulling latest toes"
git -C "$DEST" fetch origin main
git -C "$DEST" reset --hard origin/main
else
info "Cloning toes"
git clone "$REPO" "$DEST"
fi
# ── Directories ──────────────────────────────────────────
mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes"
# ── Dependencies & build ─────────────────────────────────
cd "$DEST"
info "Installing dependencies"
quiet bun install
info "Building"
rm -rf "$DEST/dist"
quiet bun run build
# ── Bundled apps ─────────────────────────────────────────
REPOS_DIR="$DATA_DIR/repos"
mkdir -p "$REPOS_DIR"
info "Installing bundled apps"
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
echo " $app"
cp -a "$app_dir" "$APPS_DIR/$app"
quiet bun install --frozen-lockfile --cwd "$APPS_DIR/$app" || quiet bun install --cwd "$APPS_DIR/$app"
# Seed bare repo for git-based versioning
bare="$REPOS_DIR/$app.git"
quiet git -C "$APPS_DIR/$app" init -b main
quiet git -C "$APPS_DIR/$app" add -A
quiet git -C "$APPS_DIR/$app" -c user.name=toes -c user.email=toes@localhost commit -m "install"
if [ -d "$bare" ]; then
quiet git -C "$APPS_DIR/$app" push --force "$bare" main
else
quiet git clone --bare "$APPS_DIR/$app" "$bare"
quiet git -C "$bare" config http.receivepack true
fi
rm -rf "$APPS_DIR/$app/.git"
done
# ── Systemd ──────────────────────────────────────────────
info "Installing toes service"
sudo install -m 644 "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
sudo systemctl daemon-reload
sudo systemctl enable toes
info "Restarting toes"
sudo systemctl restart toes
# ── Done ─────────────────────────────────────────────────
VERSION=$(git describe --tags --always 2>/dev/null || echo "unknown")
echo ""
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://$(hostname).local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"
echo ""

View File

@ -1,16 +0,0 @@
{
"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"
}
}

View File

@ -1,17 +0,0 @@
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}`)

View File

@ -1,43 +0,0 @@
{
"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

@ -1,6 +1,6 @@
{ {
"name": "@because/toes", "name": "@because/toes",
"version": "0.0.12", "version": "0.0.8",
"description": "personal web appliance - turn it on and forget about the cloud", "description": "personal web appliance - turn it on and forget about the cloud",
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
@ -15,7 +15,7 @@
"toes": "src/cli/index.ts" "toes": "src/cli/index.ts"
}, },
"scripts": { "scripts": {
"check": "bun run templates && bunx tsc --noEmit", "check": "bunx tsc --noEmit",
"build": "./scripts/build.sh", "build": "./scripts/build.sh",
"cli:build": "bun run scripts/build.ts", "cli:build": "bun run scripts/build.ts",
"cli:build:all": "bun run scripts/build.ts --all", "cli:build:all": "bun run scripts/build.ts --all",
@ -24,16 +24,14 @@
"cli:uninstall": "sudo rm /usr/local/bin", "cli:uninstall": "sudo rm /usr/local/bin",
"deploy": "./scripts/deploy.sh", "deploy": "./scripts/deploy.sh",
"debug": "DEBUG=1 bun run dev", "debug": "DEBUG=1 bun run dev",
"dev": "bun run templates && rm -f pub/client/index.js && bun run --hot src/server/index.tsx", "dev": "rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
"remote:deploy": "./scripts/deploy.sh", "remote:deploy": "./scripts/deploy.sh",
"remote:migrate": "bun run scripts/migrate.ts",
"remote:install": "./scripts/remote-install.sh", "remote:install": "./scripts/remote-install.sh",
"remote:logs": "./scripts/remote-logs.sh", "remote:logs": "./scripts/remote-logs.sh",
"remote:restart": "./scripts/remote-restart.sh", "remote:restart": "./scripts/remote-restart.sh",
"remote:start": "./scripts/remote-start.sh", "remote:start": "./scripts/remote-start.sh",
"remote:stop": "./scripts/remote-stop.sh", "remote:stop": "./scripts/remote-stop.sh",
"start": "bun run templates && bun run src/server/index.tsx", "start": "bun run src/server/index.tsx",
"templates": "bun run scripts/embed-templates.ts",
"test": "bun test" "test": "bun test"
}, },
"devDependencies": { "devDependencies": {
@ -46,9 +44,8 @@
"dependencies": { "dependencies": {
"@because/forge": "^0.0.1", "@because/forge": "^0.0.1",
"@because/hype": "^0.0.2", "@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.4", "@because/sneaker": "^0.0.3",
"@because/toes": "^0.0.12", "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

@ -9,7 +9,6 @@ import { join } from 'path'
const DIST_DIR = join(import.meta.dir, '..', 'dist') const DIST_DIR = join(import.meta.dir, '..', 'dist')
const ENTRY_POINT = join(import.meta.dir, '..', 'src', 'cli', 'index.ts') const ENTRY_POINT = join(import.meta.dir, '..', 'src', 'cli', 'index.ts')
const GIT_SHA = Bun.spawnSync(['git', 'rev-parse', '--short', 'HEAD']).stdout.toString().trim() || 'unknown'
interface BuildTarget { interface BuildTarget {
arch: string arch: string
@ -24,6 +23,47 @@ const TARGETS: BuildTarget[] = [
{ os: 'linux', arch: 'x64', name: 'toes-linux-x64' }, { os: 'linux', arch: 'x64', name: 'toes-linux-x64' },
] ]
// Ensure dist directory exists
if (!existsSync(DIST_DIR)) {
mkdirSync(DIST_DIR, { recursive: true })
}
// Parse command line args
const args = process.argv.slice(2)
const buildAll = args.includes('--all')
const targetArg = args.find(arg => arg.startsWith('--target='))?.split('=')[1]
async function buildTarget(target: BuildTarget) {
console.log(`Building ${target.name}...`)
const output = join(DIST_DIR, target.name)
const proc = Bun.spawn([
'bun',
'build',
ENTRY_POINT,
'--compile',
'--target',
`bun-${target.os}-${target.arch}`,
'--minify',
'--sourcemap=external',
'--outfile',
output,
], {
stdout: 'inherit',
stderr: 'inherit',
})
const exitCode = await proc.exited
if (exitCode === 0) {
console.log(`✓ Built ${target.name}`)
} else {
console.error(`✗ Failed to build ${target.name}`)
process.exit(exitCode)
}
}
async function buildCurrent() { async function buildCurrent() {
const platform = process.platform const platform = process.platform
const arch = process.arch const arch = process.arch
@ -41,7 +81,6 @@ async function buildCurrent() {
'bun', 'bun',
'--minify', '--minify',
'--sourcemap=external', '--sourcemap=external',
`--define=__GIT_SHA__="${GIT_SHA}"`,
'--outfile', '--outfile',
output, output,
], { ], {
@ -61,58 +100,6 @@ async function buildCurrent() {
} }
} }
async function buildTarget(target: BuildTarget) {
console.log(`Building ${target.name}...`)
const output = join(DIST_DIR, target.name)
const proc = Bun.spawn([
'bun',
'build',
ENTRY_POINT,
'--compile',
'--target',
`bun-${target.os}-${target.arch}`,
'--minify',
'--sourcemap=external',
`--define=__GIT_SHA__="${GIT_SHA}"`,
'--outfile',
output,
], {
stdout: 'inherit',
stderr: 'inherit',
})
const exitCode = await proc.exited
if (exitCode === 0) {
console.log(`✓ Built ${target.name}`)
} else {
console.error(`✗ Failed to build ${target.name}`)
process.exit(exitCode)
}
}
// Embed template files before compiling
const embedProc = Bun.spawn(['bun', 'run', join(import.meta.dir, 'embed-templates.ts')], {
stdout: 'inherit',
stderr: 'inherit',
})
if (await embedProc.exited !== 0) {
console.error('✗ Failed to embed templates')
process.exit(1)
}
// Ensure dist directory exists
if (!existsSync(DIST_DIR)) {
mkdirSync(DIST_DIR, { recursive: true })
}
// Parse command line args
const args = process.argv.slice(2)
const buildAll = args.includes('--all')
const targetArg = args.find(arg => arg.startsWith('--target='))?.split('=')[1]
// Main build logic // Main build logic
if (buildAll) { if (buildAll) {
console.log('Building for all targets...\n') console.log('Building for all targets...\n')

View File

@ -2,10 +2,8 @@
# 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.
TOES_USER="${TOES_USER:-toes}" HOST="${HOST:-toes@toes.local}"
HOST="${HOST:-toes.local}" URL="${URL:-http://toes.local}"
SSH_HOST="$TOES_USER@$HOST" DEST="${DEST:-~/toes}"
URL="${URL:-http://$HOST}" DATA_DIR="${DATA_DIR:-~/data}"
DEST="${DEST:-$HOME/toes}" APPS_DIR="${APPS_DIR:-~/apps}"
DATA_DIR="${DATA_DIR:-$HOME/data}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"

View File

@ -11,60 +11,29 @@ 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 "$SSH_HOST" bash <<'SCRIPT' ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
set -e set -e
DEST="${DEST:-$HOME/toes}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"
DATA_DIR="${DATA_DIR:-$HOME/data}"
REPOS_DIR="$DATA_DIR/repos"
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..."
for app_dir in "$DEST"/apps/*/; do for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir") app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue for version_dir in "$app_dir"*/; do
target="$APPS_DIR/$app" [ -d "$version_dir" ] || continue
mkdir -p "$target" version=$(basename "$version_dir")
cp -a "$app_dir"/. "$target"/ [ -f "$version_dir/package.json" ] || continue
echo " $app" target="$APPS_DIR/$app/$version"
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install) mkdir -p "$target"
done cp -a "$version_dir"/. "$target"/
rm -f "$APPS_DIR/$app/current"
echo "=> Initializing bare repos..." echo " $app/$version"
mkdir -p "$REPOS_DIR" (cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
for app_dir in "$DEST"/apps/*/; do done
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
bare="$REPOS_DIR/$app.git"
if [ ! -d "$bare" ]; then
git init --bare -b main "$bare" > /dev/null
git -C "$bare" config http.receivepack true
fi
tmp=$(mktemp -d)
cp -a "$app_dir"/. "$tmp"/
git -C "$tmp" init -b main > /dev/null 2>&1
git -C "$tmp" add -A > /dev/null
git -C "$tmp" -c user.name=toes -c user.email=toes@localhost commit -m "deploy" > /dev/null 2>&1
git -C "$tmp" push --force "$bare" main > /dev/null 2>&1
rm -rf "$tmp"
echo " $app"
done done
sudo systemctl restart toes.service sudo systemctl restart toes.service
SCRIPT SCRIPT
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' r=$'\033[0m' echo "=> Deployed to $HOST"
echo "=> Visit $URL"
echo ""
echo " ${b}${g}🐾 Deployed${r} to ${b}$SSH_HOST${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}$URL${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL $URL/install | bash${r}"
echo ""

View File

@ -1,71 +0,0 @@
#!/usr/bin/env bun
// Generates src/lib/templates.data.ts with embedded template file contents.
// Run: bun run templates
import { readdirSync, readFileSync, statSync } from 'fs'
import { extname, join, relative } from 'path'
const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.svg'])
const TEMPLATES_DIR = join(import.meta.dir, '..', 'templates')
const binary: Record<string, Record<string, string>> = {}
const shared: Record<string, string> = {}
const templates: Record<string, Record<string, string>> = {}
const isBinary = (path: string) =>
BINARY_EXTENSIONS.has(extname(path))
function readDir(dir: string): string[] {
const files: string[] = []
for (const entry of readdirSync(dir)) {
const path = join(dir, entry)
if (statSync(path).isDirectory()) {
files.push(...readDir(path))
} else {
files.push(path)
}
}
return files.sort()
}
// First pass: collect shared files (root level)
for (const entry of readdirSync(TEMPLATES_DIR).sort()) {
const path = join(TEMPLATES_DIR, entry)
if (!statSync(path).isDirectory()) {
shared[entry] = readFileSync(path, 'utf-8')
}
}
// Second pass: build template maps with shared files folded in
for (const entry of readdirSync(TEMPLATES_DIR).sort()) {
const path = join(TEMPLATES_DIR, entry)
if (statSync(path).isDirectory()) {
templates[entry] = { ...shared }
binary[entry] = {}
for (const filePath of readDir(path)) {
const filename = relative(path, filePath)
if (isBinary(filePath)) {
binary[entry]![filename] = readFileSync(filePath).toString('base64')
} else {
templates[entry]![filename] = readFileSync(filePath, 'utf-8')
}
}
if (Object.keys(binary[entry]!).length === 0) {
delete binary[entry]
}
}
}
// Generate TypeScript module
const lines: string[] = [
'// Auto-generated by scripts/embed-templates.ts',
'// Run `bun run templates` to regenerate',
'',
`export const TEMPLATES: Record<string, Record<string, string>> = ${JSON.stringify(templates, null, 2)}`,
'',
`export const BINARY: Record<string, Record<string, string>> = ${JSON.stringify(binary, null, 2)}`,
'',
]
const outPath = join(import.meta.dir, '..', 'src', 'lib', 'templates.data.ts')
await Bun.write(outPath, lines.join('\n'))
console.log(`✓ Embedded templates → ${relative(join(import.meta.dir, '..'), outPath)}`)

View File

@ -1,9 +1,126 @@
#!/usr/bin/env bash #!/usr/bin/env bash
## ##
# installs toes on your Raspberry Pi # installs systemd files to keep toes running on your Raspberry Pi
# delegates to the canonical installer at install/install.sh
set -euo pipefail set -euo pipefail
exec "$(dirname "$0")/../install/install.sh" quiet() { "$@" > /dev/null 2>&1; }
SYSTEMD_DIR="/etc/systemd/system"
SERVICE_NAME="toes"
SERVICE_FILE="$(dirname "$0")/${SERVICE_NAME}.service"
SYSTEMD_PATH="${SYSTEMD_DIR}/${SERVICE_NAME}.service"
BUN_SYMLINK="/usr/local/bin/bun"
BUN_REAL="$HOME/.bun/bin/bun"
echo ">> Updating system libraries"
quiet sudo apt-get update
quiet sudo apt-get install -y libcap2-bin
quiet sudo apt-get install -y avahi-utils
quiet sudo apt-get install -y dnsmasq
quiet sudo apt-get install -y fish
echo ">> Setting fish as default shell for toes user"
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
quiet sudo chsh -s /usr/bin/fish toes
echo "Default shell changed to fish"
else
echo "fish already set as default shell"
fi
echo ">> Ensuring bun is available in /usr/local/bin"
if [ ! -x "$BUN_SYMLINK" ]; then
if [ -x "$BUN_REAL" ]; then
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
else
echo ">> Installing bun at $BUN_REAL"
quiet sudo apt install unzip
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
if [ ! -x "$BUN_REAL" ]; then
echo "ERROR: bun installation failed - $BUN_REAL not found"
exit 1
fi
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
fi
else
echo "bun already available at $BUN_SYMLINK"
fi
echo ">> Setting CAP_NET_BIND_SERVICE on $BUN_REAL"
quiet sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
quiet /usr/sbin/getcap "$BUN_REAL" || true
echo ">> Creating data and apps directories"
mkdir -p ~/data
mkdir -p ~/apps
echo ">> Installing bundled apps"
BUNDLED_APPS="clock code cron env stats versions"
for app in $BUNDLED_APPS; do
if [ -d "apps/$app" ]; then
echo " Installing $app..."
cp -r "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
if ! (cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1; then
echo " WARNING: bun install failed for $app, trying without lockfile..."
(cd ~/apps/$app/current && bun install) > /dev/null 2>&1 || echo " ERROR: bun install failed for $app"
fi
else
echo " WARNING: no version directory found for $app, skipping"
fi
fi
done
echo ">> Installing dependencies"
bun install
echo ">> Building client bundle"
bun run build
echo ">> Installing toes service"
quiet sudo install -m 644 -o root -g root "$SERVICE_FILE" "$SYSTEMD_PATH"
echo ">> Reloading systemd daemon"
quiet sudo systemctl daemon-reload
echo ">> Enabling $SERVICE_NAME to start at boot"
quiet sudo systemctl enable "$SERVICE_NAME"
echo ">> Starting (or restarting) $SERVICE_NAME"
quiet sudo systemctl restart "$SERVICE_NAME"
echo ">> Enabling kiosk mode"
sudo raspi-config nonint do_boot_behaviour B4
# labwc (older RPi OS / manual installs)
mkdir -p ~/.config/labwc
cat > ~/.config/labwc/autostart <<'EOF'
chromium --noerrdialogs --disable-infobars --kiosk http://localhost
EOF
# Wayfire (RPi OS Bookworm default)
WAYFIRE_CONFIG="$HOME/.config/wayfire.ini"
if [ -f "$WAYFIRE_CONFIG" ]; then
# Remove existing chromium autostart if present
sed -i '/^chromium = /d' "$WAYFIRE_CONFIG"
# Add to existing [autostart] section or create it
if grep -q '^\[autostart\]' "$WAYFIRE_CONFIG"; then
sed -i '/^\[autostart\]/a chromium = chromium --noerrdialogs --disable-infobars --kiosk http://localhost' "$WAYFIRE_CONFIG"
else
cat >> "$WAYFIRE_CONFIG" <<'EOF'
[autostart]
chromium = chromium --noerrdialogs --disable-infobars --kiosk http://localhost
EOF
fi
fi
echo ">> Done! Rebooting in 5 seconds..."
systemctl status "$SERVICE_NAME" --no-pager -l || true
sleep 5
sudo reboot

View File

@ -9,13 +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 "$SSH_HOST" bash <<'SCRIPT' ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
set -e
DEST="${DEST:-$HOME/toes}"
if [ -d "$DEST/.git" ]; then
cd "$DEST" && git pull
else
git clone https://git.nose.space/defunkt/toes "$DEST" && cd "$DEST"
fi
./scripts/install.sh
SCRIPT

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh" source "$ROOT_DIR/scripts/config.sh"
ssh "$SSH_HOST" "journalctl -u toes -n 100" 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 "$SSH_HOST" "sudo systemctl restart toes.service" 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 "$SSH_HOST" "sudo systemctl start toes.service" 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 "$SSH_HOST" "sudo systemctl stop toes.service" ssh "$HOST" "sudo systemctl stop toes.service"

View File

@ -1,65 +0,0 @@
#!/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"

15
scripts/wifi-captive.conf Normal file
View File

@ -0,0 +1,15 @@
# dnsmasq config for Toes WiFi setup captive portal
# Redirect ALL DNS queries to the hotspot gateway IP
# Only listen on the hotspot interface
interface=wlan0
bind-interfaces
# Resolve everything to our IP (captive portal)
address=/#/10.42.0.1
# Don't use /etc/resolv.conf
no-resolv
# Don't read /etc/hosts
no-hosts

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, getSignal, handleError, makeUrl, post } from '../http' import { get, 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, { signal: getSignal() }) const res = await fetch(url)
if (!res.ok) { if (!res.ok) {
console.error(`App not found: ${app}`) console.error(`App not found: ${app}`)
return return

View File

@ -2,7 +2,7 @@ export { cronList, cronLog, cronRun, cronStatus } from './cron'
export { envList, envRm, envSet } from './env' export { envList, envRm, envSet } from './env'
export { logApp } from './logs' export { logApp } from './logs'
export { export {
getApp, configShow,
infoApp, infoApp,
listApps, listApps,
newApp, newApp,
@ -16,3 +16,4 @@ export {
unshareApp, unshareApp,
} from './manage' } from './manage'
export { metricsApp } from './metrics' export { metricsApp } from './metrics'
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'

View File

@ -1,5 +1,5 @@
import type { LogLine } from '@types' import type { LogLine } from '@types'
import { get, getSignal, handleError, makeUrl } from '../http' import { get, 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, { signal: getSignal() }) const res = await fetch(url)
if (!res.ok) { if (!res.ok) {
console.error(`App not found: ${name}`) console.error(`App not found: ${name}`)
return return

View File

@ -1,12 +1,14 @@
import type { App } from '@types' import type { App } from '@types'
import { generateTemplates, type TemplateType } from '%templates' import { generateTemplates, type TemplateType } from '%templates'
import { readSyncState } from '%sync'
import color from 'kleur' import color from 'kleur'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { basename, join } from 'path' import { basename, join } from 'path'
import { buildAppUrl } from '@urls' import { buildAppUrl } from '@urls'
import { del, get, getManifest, gitUrl, HOST, post } from '../http' import { del, get, getManifest, HOST, post } from '../http'
import { confirm, prompt } from '../prompts' import { confirm, prompt } from '../prompts'
import { resolveAppName } from '../name' import { resolveAppName } from '../name'
import { pushApp } from './sync'
export const STATE_ICONS: Record<string, string> = { export const STATE_ICONS: Record<string, string> = {
error: color.red('●'), error: color.red('●'),
@ -34,6 +36,15 @@ async function waitForState(name: string, target: string, timeout: number): Prom
return app?.state return app?.state
} }
export async function configShow() {
console.log(`Host: ${color.bold(HOST)}`)
const syncState = readSyncState(process.cwd())
if (syncState) {
console.log(`Version: ${color.bold(syncState.version)}`)
}
}
export async function infoApp(arg?: string) { export async function infoApp(arg?: string) {
const name = resolveAppName(arg) const name = resolveAppName(arg)
if (!name) return if (!name) return
@ -173,42 +184,17 @@ export async function newApp(name: string | undefined, options: NewAppOptions) {
writeFileSync(join(appPath, filename), content) writeFileSync(join(appPath, filename), content)
} }
// Initialize git repo and push to server (git push creates the app via the git tool) process.chdir(appPath)
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: appPath, stdout: 'ignore', stderr: 'ignore' }).exited await pushApp()
await run(['git', 'init'])
await run(['git', 'add', '.'])
await run(['git', 'commit', '-m', 'init'])
await run(['git', 'remote', 'add', 'toes', gitUrl(appName)])
await run(['git', 'push', 'toes', 'main'])
console.log(color.green(`✓ Created ${appName}`)) console.log(color.green(`✓ Created ${appName}`))
console.log()
console.log('Next steps:')
if (name) { if (name) {
console.log(`\n cd ${name}`) console.log(` cd ${name}`)
} }
} console.log(' bun install')
console.log(' bun dev')
export async function getApp(name: string, directory?: string) {
const target = directory ?? name
if (existsSync(target)) {
console.error(`Directory already exists: ${target}`)
return
}
const url = gitUrl(name)
const args = ['git', 'clone', url]
if (directory) args.push(directory)
const proc = Bun.spawn(args, { stdout: 'inherit', stderr: 'inherit' })
const exitCode = await proc.exited
if (exitCode !== 0) {
console.error(color.red(`Failed to clone ${name}`))
return
}
console.log(color.green(`✓ Cloned ${name}`))
console.log(`\n cd ${target}\n bun install`)
} }
export async function openApp(arg?: string) { export async function openApp(arg?: string) {

1224
src/cli/commands/sync.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,6 @@
import type { Manifest } from '@types' import type { Manifest } from '@types'
import { buildAppUrl } from '@urls'
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}`
@ -21,14 +18,6 @@ export const HOST = process.env.TOES_URL
? normalizeUrl(process.env.TOES_URL) ? normalizeUrl(process.env.TOES_URL)
: DEFAULT_HOST : DEFAULT_HOST
export const gitUrl = (name: string) => `${buildAppUrl('git', HOST)}/${name}`
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}`
} }
@ -48,7 +37,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), { signal: getSignal() }) const res = await fetch(makeUrl(url))
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}`
@ -60,13 +49,14 @@ export async function get<T>(url: string): Promise<T | undefined> {
} }
} }
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | 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`), { signal: getSignal() }) const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
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 manifest = await res.json() const data = await res.json()
return { exists: true, manifest } const { version, ...manifest } = data
return { exists: true, manifest, version }
} catch (error) { } catch (error) {
handleError(error) handleError(error)
return null return null
@ -79,7 +69,6 @@ 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()
@ -97,7 +86,6 @@ 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()
@ -114,7 +102,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, { signal: getSignal() }) const res = await fetch(fullUrl)
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}`
@ -130,7 +118,6 @@ 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,13 +1,4 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { program } from './setup' import { program } from './setup'
const isCliUser = process.env.USER === 'cli' program.parse()
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

@ -3,34 +3,47 @@ import { program } from 'commander'
import color from 'kleur' import color from 'kleur'
import pkg from '../../package.json' import pkg from '../../package.json'
import { SHA } from './sha' import { withPager } from './pager'
import { import {
cleanApp,
configShow,
cronList, cronList,
cronLog, cronLog,
cronRun, cronRun,
cronStatus, cronStatus,
diffApp,
envList, envList,
envRm, envRm,
envSet, envSet,
getApp, getApp,
historyApp,
infoApp, infoApp,
listApps, listApps,
logApp, logApp,
newApp, newApp,
openApp, openApp,
pullApp,
pushApp,
renameApp, renameApp,
restartApp, restartApp,
rmApp, rmApp,
shareApp, rollbackApp,
stashApp,
stashListApp,
stashPopApp,
startApp, startApp,
metricsApp, metricsApp,
shareApp,
statusApp,
stopApp, stopApp,
syncApp,
unshareApp, unshareApp,
versionsApp,
} from './commands' } from './commands'
program program
.name('toes') .name('toes')
.version(`v${pkg.version}-${SHA}`, '-v, --version') .version(`v${pkg.version}`, '-v, --version')
.addHelpText('beforeAll', (ctx) => { .addHelpText('beforeAll', (ctx) => {
if (ctx.command === program) { if (ctx.command === program) {
return color.bold().cyan('🐾 Toes') + color.gray(' - personal web appliance\n') return color.bold().cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
@ -80,9 +93,8 @@ program
program program
.command('get') .command('get')
.helpGroup('Apps:') .helpGroup('Apps:')
.description('Clone an app from the server') .description('Download an app from server')
.argument('<name>', 'app name') .argument('<name>', 'app name')
.argument('[directory]', 'target directory (defaults to app name)')
.action(getApp) .action(getApp)
program program
@ -197,8 +209,72 @@ cron
.argument('<job>', 'job identifier (app:name)') .argument('<job>', 'job identifier (app:name)')
.action(cronRun) .action(cronRun)
// Sync
program
.command('push')
.helpGroup('Sync:')
.description('Push local changes to server')
.option('-f, --force', 'overwrite remote changes')
.action(pushApp)
program
.command('pull')
.helpGroup('Sync:')
.description('Pull changes from server')
.option('-f, --force', 'overwrite local changes')
.action(pullApp)
program
.command('status')
.helpGroup('Sync:')
.description('Show what would be pushed/pulled')
.action(statusApp)
program
.command('diff')
.helpGroup('Sync:')
.description('Show diff of changed files')
.action(() => withPager(diffApp))
program
.command('sync')
.helpGroup('Sync:')
.description('Watch and sync changes bidirectionally')
.action(syncApp)
program
.command('clean')
.helpGroup('Sync:')
.description('Remove local files not on server')
.option('-f, --force', 'skip confirmation')
.option('-n, --dry-run', 'show what would be removed')
.action(cleanApp)
const stash = program
.command('stash')
.helpGroup('Sync:')
.description('Stash local changes')
.action(stashApp)
stash
.command('pop')
.description('Restore stashed changes')
.action(stashPopApp)
stash
.command('list')
.description('List all stashes')
.action(stashListApp)
// Config // Config
program
.command('config')
.helpGroup('Config:')
.description('Show current host configuration')
.action(configShow)
const env = program const env = program
.command('env') .command('env')
.helpGroup('Config:') .helpGroup('Config:')
@ -224,14 +300,26 @@ env
.option('-g, --global', 'remove a global variable') .option('-g, --global', 'remove a global variable')
.action(envRm) .action(envRm)
// Shell program
.command('versions')
.helpGroup('Config:')
.description('List deployed versions')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(versionsApp)
program program
.command('shell') .command('history')
.description('Interactive shell') .helpGroup('Config:')
.action(async () => { .description('Show file changes between versions')
const { shell } = await import('./shell') .argument('[name]', 'app name (uses current directory if omitted)')
await shell() .action(historyApp)
})
program
.command('rollback')
.helpGroup('Config:')
.description('Rollback to a previous version')
.argument('[name]', 'app name (uses current directory if omitted)')
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
.action((name, options) => rollbackApp(name, options.version))
export { program } export { program }

View File

@ -1,3 +0,0 @@
declare var __GIT_SHA__: string | undefined
export const SHA: string = typeof __GIT_SHA__ !== 'undefined' ? __GIT_SHA__ : 'dev'

View File

@ -1,227 +0,0 @@
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) => !(cmd as any)._hidden)
.map((cmd) => 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

@ -1,29 +1,32 @@
import type { ConnectResult, WifiNetwork, WifiStatus } from '../shared/types'
export const connectToWifi = (ssid: string, password?: string): Promise<ConnectResult> =>
fetch('/api/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, password }),
}).then(r => r.json())
export const getLogDates = (name: string): Promise<string[]> => export const getLogDates = (name: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json()) fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
export const getLogsForDate = (name: string, date: string): Promise<string[]> => export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json()) fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
export const getSystemInfo = (): Promise<{ version: string, sha: string }> => export const getWifiStatus = (): Promise<WifiStatus & { setupMode: boolean, url: string }> =>
fetch('/api/system/info').then(r => r.json()) fetch('/api/wifi/status').then(r => r.json())
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
export const scanWifiNetworks = (): Promise<WifiNetwork[]> =>
fetch('/api/wifi/scan').then(r => r.json())
export const shareApp = (name: string) => export const shareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' }) fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
export const unshareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
export const applyUpdate = () =>
fetch('/api/system/update', { method: 'POST' }).then(r => { if (!r.ok) throw new Error('update failed'); return r.json() })
export const checkForUpdate = (): Promise<{ available: boolean, current: string, latest: string, commits: string[] }> =>
fetch('/api/system/update').then(r => { if (!r.ok) throw new Error('check failed'); return r.json() })
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
export const restartServer = () =>
fetch('/api/system/restart', { method: 'POST' }).then(r => { if (!r.ok) throw new Error('restart failed'); return r.json() })
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' }) export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' }) export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
export const unshareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })

View File

@ -2,14 +2,13 @@ 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 { openDeleteAppModal, openRenameAppModal } from '../modals' import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state' import { apps, getSelectedTab, isNarrow } from '../state'
import { import {
ActionBar, ActionBar,
AppSelectorChevron,
Button, Button,
ClickableAppName, ClickableAppName,
HamburgerButton,
HamburgerLine,
HeaderActions, HeaderActions,
InfoLabel, InfoLabel,
InfoRow, InfoRow,
@ -45,26 +44,25 @@ const OpenEmojiPicker = define('OpenEmojiPicker', {
}) })
export function AppDetail({ app, render }: { app: App, render: () => void }) { export function AppDetail({ app, render }: { app: App, render: () => void }) {
// Find tools that show on app pages (apps !== false) // Find all tools
const tools = apps.filter(a => a.tool && a.apps !== false) const tools = apps.filter(a => a.tool)
const selectedTab = getSelectedTab(app.name) const selectedTab = getSelectedTab(app.name)
return ( return (
<Main> <Main>
<MainHeader> <MainHeader>
<MainTitle> <MainTitle>
{isNarrow && (
<HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)}
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker> <OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
&nbsp;
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName> <ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
{isNarrow && (
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
</AppSelectorChevron>
)}
</MainTitle> </MainTitle>
<HeaderActions> <HeaderActions>
{(!app.tool || app.share) && ( {!app.tool && (
app.tunnelUrl app.tunnelUrl
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button> ? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
: app.tunnelEnabled : app.tunnelEnabled

View File

@ -2,6 +2,7 @@ import type { CSSProperties } from 'hono/jsx'
import { import {
apps, apps,
selectedApp, selectedApp,
setSelectedApp,
setSidebarSection, setSidebarSection,
sidebarSection, sidebarSection,
} from '../state' } from '../state'
@ -16,13 +17,19 @@ 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, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) { export function AppSelector({ render, onSelect, onDashboard, collapsed, 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()
@ -36,18 +43,18 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
<> <>
{!collapsed && toolApps.length > 0 && ( {!collapsed && toolApps.length > 0 && (
<SectionSwitcher style={switcherStyle}> <SectionSwitcher style={switcherStyle}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}> <SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}>
Apps Apps
</SectionTab> </SectionTab>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}> <SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
Tools Tools
</SectionTab> </SectionTab>
</SectionSwitcher> </SectionSwitcher>
)} )}
<AppList style={listStyle}> <AppList style={listStyle}>
{collapsed && ( {collapsed && onDashboard && (
<AppItem <AppItem
href="/" onClick={onDashboard}
selected={!selectedApp ? true : undefined} selected={!selectedApp ? true : undefined}
style={{ justifyContent: 'center', padding: '10px 12px' }} style={{ justifyContent: 'center', padding: '10px 12px' }}
title="Toes" title="Toes"
@ -58,9 +65,7 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
{activeApps.map(app => ( {activeApps.map(app => (
<AppItem <AppItem
key={app.name} key={app.name}
href={`/app/${app.name}`} onClick={() => selectApp(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}
@ -69,7 +74,7 @@ export function AppSelector({ render, onSelect, collapsed, large, switcherStyle,
<span style={{ fontSize: 18 }}>{app.icon}</span> <span style={{ fontSize: 18 }}>{app.icon}</span>
) : ( ) : (
<> <>
<span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span> <span style={{ fontSize: 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,47 +1,13 @@
import { Styles } from '@because/forge' import { Styles } from '@because/forge'
import { openNewAppModal } from '../modals' import { apps, currentView, isNarrow, selectedApp, setupMode } from '../state'
import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state' import { Layout } from '../styles'
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 { 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} />
@ -52,8 +18,9 @@ export function Dashboard({ render }: { render: () => void }) {
return ( return (
<Layout> <Layout>
<Styles /> <Styles />
{!isNarrow && <Sidebar render={render} />} {!isNarrow && !setupMode && <Sidebar render={render} />}
<MainContent render={render} /> <MainContent render={render} />
<Modal />
</Layout> </Layout>
) )
} }

View File

@ -1,44 +1,36 @@
import { useEffect } from 'hono/jsx' import { useEffect } from 'hono/jsx'
import { navigate } from '../router' import { openAppSelectorModal } from '../modals'
import { apps, dashboardTab, isNarrow, setMobileSidebar } from '../state' import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
import { import {
HamburgerButton, AppSelectorChevron,
HamburgerLine,
DashboardContainer, DashboardContainer,
DashboardHeader, DashboardHeader,
DashboardTitle, DashboardTitle,
Section,
SettingsGear, SettingsGear,
Tab, StatusDot,
TabBar, StatusDotLink,
TabContent, StatusDotsRow,
} from '../styles' } from '../styles'
import { theme } from '../themes' 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 { 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 dashboardTools = apps.filter(a => a.tool && a.dashboard)
const openSettings = () => { const openSettings = () => {
navigate('/settings') setSelectedApp(null)
setCurrentView('settings')
render()
} }
const switchTab = (tab: string) => {
navigate(tab === 'urls' ? '/' : `/${tab}`)
if (tab === 'logs') scrollLogsToBottom()
}
const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1))
return ( return (
<DashboardContainer narrow={narrow} relative> <DashboardContainer narrow={narrow} relative>
<SettingsGear <SettingsGear
@ -48,68 +40,43 @@ 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>
<TabBar centered> <StatusDotsRow>
<Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>🔗 URLs</Tab> {[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
<Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>📋 Logs</Tab> <StatusDotLink
{dashboardTools.map(tool => { key={app.name}
const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name data-tooltip={app.name}
return ( tooltipVisible={activeTooltip === app.name || undefined}
<Tab onClick={(e: Event) => {
key={tool.name} e.preventDefault()
active={dashboardTab === tool.name || undefined} if (isNarrow && activeTooltip !== app.name) {
onClick={() => switchTab(tool.name)} activeTooltip = app.name
> render()
{tool.icon} {titlecase(toolName)} return
</Tab> }
) activeTooltip = null
})} setSelectedApp(app.name)
</TabBar> update()
}}
>
<StatusDot state={app.state} data-app={app.name} />
</StatusDotLink>
))}
</StatusDotsRow>
<TabContent active={dashboardTab === 'urls' || undefined}> <Vitals />
<Urls render={render} />
</TabContent>
<TabContent active={dashboardTab === 'logs' || undefined}> <UnifiedLogs />
<UnifiedLogs />
</TabContent>
{dashboardTools.map(tool => {
const isSelected = dashboardTab === tool.name
return (
<TabContent key={tool.name} active={isSelected || undefined}>
<Section>
{tool.state !== 'running' && (
<p style={{ color: theme('colors-textFaint') }}>
Tool is {tool.state}
</p>
)}
{tool.state === 'running' && (
<div
data-tool-target={isSelected ? tool.name : undefined}
style={{ width: '100%', height: '600px' }}
/>
)}
</Section>
</TabContent>
)
})}
</DashboardContainer> </DashboardContainer>
) )
} }

View File

@ -1,6 +1,5 @@
import type { App } from '../../shared/types' import type { App } from '../../shared/types'
import { navigate } from '../router' import { apps, getSelectedTab, setSelectedTab } from '../state'
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'
@ -13,11 +12,12 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
resetToolIframe(tab, app.name) resetToolIframe(tab, app.name)
return return
} }
navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`) setSelectedTab(app.name, tab)
render()
} }
// Find tools that show on app pages (apps !== false) // Find all tools
const tools = apps.filter(a => a.tool && a.apps !== false) const tools = apps.filter(a => a.tool)
const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1)) const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1))
return ( return (

View File

@ -1,184 +1,306 @@
import { useEffect, useState } from 'hono/jsx' import { useEffect, useState } from 'hono/jsx'
import { applyUpdate, checkForUpdate, getSystemInfo, restartServer } from '../api' import { connectToWifi, getWifiStatus, scanWifiNetworks } from '../api'
import { setTheme } from '../index' import { setCurrentView, setupMode } from '../state'
import { navigate } from '../router'
import { import {
Button, Button,
DashboardInstallCmd, DashboardInstallCmd,
ErrorBox,
FormActions,
FormField, FormField,
FormInput,
FormLabel, FormLabel,
FormSelect,
HeaderActions, HeaderActions,
InfoLabel,
InfoRow,
InfoValue,
Main, Main,
MainContent, MainContent,
MainHeader, MainHeader,
MainTitle, MainTitle,
NetworkItem,
NetworkListWrap,
NetworkMeta,
NetworkName,
Section, Section,
SectionTitle, SectionTitle,
SignalBarSegment,
SignalBarsWrap,
Spinner,
SpinnerWrap,
SuccessCheck,
WifiColumn,
} from '../styles' } from '../styles'
import { theme } from '../themes'
import type { WifiNetwork } from '../../shared/types'
type UpdateInfo = { available: boolean, current: string, latest: string, commits: string[] } type WifiStep = 'status' | 'scanning' | 'networks' | 'password' | 'connecting' | 'success'
function pollUntilBack(onBack: () => void, onTimeout?: () => void) { function signalBars(signal: number) {
let elapsed = 0 const level = signal > 75 ? 4 : signal > 50 ? 3 : signal > 25 ? 2 : 1
const poll = setInterval(async () => { return (
elapsed += 2000 <SignalBarsWrap>
if (elapsed > 60000) { {[1, 2, 3, 4].map(i => (
clearInterval(poll) <SignalBarSegment
onTimeout?.() key={i}
return level={i <= level ? 'active' : 'inactive'}
} style={{ height: 3 + i * 3 }}
try { />
const res = await fetch('/api/system/info') ))}
if (res.ok) { </SignalBarsWrap>
clearInterval(poll) )
onBack()
}
} catch {}
}, 2000)
} }
export function SettingsPage({ render }: { render: () => void }) { function NetworkList({ networks, onSelect }: { networks: WifiNetwork[], onSelect: (net: WifiNetwork) => void }) {
const [version, setVersion] = useState('') if (networks.length === 0) {
const [sha, setSha] = useState('') return (
const [themeChoice, setThemeChoice] = useState(localStorage.getItem('theme') || 'system') <SpinnerWrap>
const [restarting, setRestarting] = useState(false) No networks found. Try scanning again.
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null) </SpinnerWrap>
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(false)
useEffect(() => {
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
})
}, [])
const goBack = () => {
navigate('/')
}
const handleThemeChange = (e: Event) => {
const value = (e.target as HTMLSelectElement).value
setThemeChoice(value)
if (value === 'system') {
localStorage.removeItem('theme')
} else {
localStorage.setItem('theme', value)
}
setTheme()
}
const refreshSystemInfo = () => {
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
})
}
const handleRestart = () => {
if (!confirm('Are you sure you want to restart the server?')) return
setRestarting(true)
restartServer().catch(() => {})
pollUntilBack(
() => { setRestarting(false); refreshSystemInfo() },
() => { setRestarting(false) },
) )
} }
const handleCheckUpdate = async () => { return (
setChecking(true) <NetworkListWrap>
try { {networks.map(net => (
const info = await checkForUpdate() <NetworkItem key={net.ssid} onClick={() => onSelect(net)}>
setUpdateInfo(info) <NetworkName>{net.ssid}</NetworkName>
} catch { <NetworkMeta>
setUpdateInfo(null) {net.security && net.security !== '' && net.security !== '--' && <span style={{ fontSize: 12 }}>🔒</span>}
} {signalBars(net.signal)}
setChecking(false) </NetworkMeta>
</NetworkItem>
))}
</NetworkListWrap>
)
}
export function SettingsPage({ render }: { render: () => void }) {
const [step, setStep] = useState<WifiStep>('status')
const [connected, setConnected] = useState(false)
const [currentSsid, setCurrentSsid] = useState('')
const [currentIp, setCurrentIp] = useState('')
const [networks, setNetworks] = useState<WifiNetwork[]>([])
const [selectedNetwork, setSelectedNetwork] = useState<WifiNetwork | null>(null)
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [successSsid, setSuccessSsid] = useState('')
const [successIp, setSuccessIp] = useState('')
const [serverUrl, setServerUrl] = useState('')
const fetchStatus = () => {
getWifiStatus().then(status => {
setConnected(status.connected)
setCurrentSsid(status.ssid)
setCurrentIp(status.ip)
if (status.url) setServerUrl(status.url)
}).catch(() => {})
} }
const handleApplyUpdate = async () => { useEffect(() => {
if (!confirm('This will update and restart the server. Continue?')) return fetchStatus()
setUpdating(true) if (setupMode) doScan()
try { }, [])
await applyUpdate()
pollUntilBack( const goBack = () => {
() => { setUpdating(false); refreshSystemInfo(); setUpdateInfo(null) }, setCurrentView('dashboard')
() => { setUpdating(false) }, render()
) }
} catch {
setUpdating(false) const doScan = () => {
setStep('scanning')
setError('')
scanWifiNetworks()
.then(nets => {
setNetworks(nets)
setStep('networks')
})
.catch(() => {
setError('Failed to scan networks')
setStep('networks')
})
}
const handleSelectNetwork = (net: WifiNetwork) => {
setSelectedNetwork(net)
setPassword('')
setError('')
const needsPassword = net.security && net.security !== '' && net.security !== '--'
if (needsPassword) {
setStep('password')
} else {
doConnect(net.ssid)
} }
} }
const doConnect = (ssid: string, pw?: string) => {
setStep('connecting')
setError('')
connectToWifi(ssid, pw)
.then(result => {
if (result.ok) {
setSuccessSsid(result.ssid || ssid)
setSuccessIp(result.ip || '')
setStep('success')
fetchStatus()
} else {
setError(result.error || 'Connection failed. Check your password and try again.')
setStep('password')
}
})
.catch(() => {
setError('Connection failed. Please try again.')
setStep('password')
})
}
const handleConnect = (e: Event) => {
e.preventDefault()
if (!selectedNetwork) return
doConnect(selectedNetwork.ssid, password || undefined)
}
const title = setupMode ? 'WiFi Setup' : 'Settings'
return ( return (
<Main> <Main>
<MainHeader centered> <MainHeader centered>
<MainTitle>Settings</MainTitle> <MainTitle>{title}</MainTitle>
<HeaderActions> {!setupMode && (
<Button onClick={goBack}>Back</Button> <HeaderActions>
</HeaderActions> <Button onClick={goBack}>Back</Button>
</HeaderActions>
)}
</MainHeader> </MainHeader>
<MainContent centered> <MainContent centered>
<Section> <Section>
<SectionTitle>Theme</SectionTitle> <SectionTitle>WiFi</SectionTitle>
<FormField>
<FormLabel>Appearance</FormLabel> {/* Status display */}
<FormSelect onChange={handleThemeChange}> {step === 'status' && (
<option value="system" selected={themeChoice === 'system'}>System</option> <WifiColumn>
<option value="light" selected={themeChoice === 'light'}>Light</option>
<option value="dark" selected={themeChoice === 'dark'}>Dark</option>
</FormSelect>
</FormField>
</Section>
<Section>
<SectionTitle>About</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 14 }}>
<span>Version: {version}</span>
<span>SHA: <a href={`https://git.nose.space/defunkt/toes/commit/${sha}`} target="_blank">{sha}</a></span>
</div>
</Section>
<Section>
<SectionTitle>Install CLI</SectionTitle>
<DashboardInstallCmd>
curl -fsSL {location.origin}/install | bash
</DashboardInstallCmd>
</Section>
<Section>
<SectionTitle>Update</SectionTitle>
{updating ? (
<div style={{ fontSize: 14 }}>Updating... server will restart shortly.</div>
) : updateInfo?.available ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 14 }}>
{updateInfo.commits.length} update{updateInfo.commits.length !== 1 ? 's' : ''} available ({updateInfo.current} {updateInfo.latest})
</div>
<div style={{ fontSize: 13, color: 'var(--colors-textMuted)', display: 'flex', flexDirection: 'column', gap: 2 }}>
{updateInfo.commits.map(c => <div>{c}</div>)}
</div>
<div> <div>
<Button variant="primary" onClick={handleApplyUpdate}>Update & Restart</Button> <InfoRow>
<InfoLabel>Status</InfoLabel>
<InfoValue style={{ color: connected ? theme('colors-statusRunning') : theme('colors-error'), fontWeight: 500 }}>
{connected ? 'Connected' : 'Disconnected'}
</InfoValue>
</InfoRow>
{connected && currentSsid && (
<InfoRow>
<InfoLabel>Network</InfoLabel>
<InfoValue>{currentSsid}</InfoValue>
</InfoRow>
)}
{connected && currentIp && (
<InfoRow>
<InfoLabel>IP</InfoLabel>
<InfoValue style={{ fontFamily: theme('fonts-mono') }}>{currentIp}</InfoValue>
</InfoRow>
)}
</div> </div>
</div> <FormActions>
) : updateInfo ? ( <Button onClick={doScan}>Scan Networks</Button>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> </FormActions>
<span style={{ fontSize: 14 }}>Up to date</span> </WifiColumn>
<Button onClick={handleCheckUpdate} disabled={checking}> )}
{checking ? 'Checking...' : 'Check Again'}
</Button> {/* Scanning spinner */}
</div> {step === 'scanning' && (
) : ( <SpinnerWrap>
<Button onClick={handleCheckUpdate} disabled={checking}> <Spinner />
{checking ? 'Checking...' : 'Check for Updates'} <p style={{ color: theme('colors-textMuted') }}>Scanning for networks...</p>
</Button> </SpinnerWrap>
)}
{/* Network list */}
{step === 'networks' && (
<WifiColumn style={{ gap: 12 }}>
<NetworkList networks={networks} onSelect={handleSelectNetwork} />
{error && <ErrorBox>{error}</ErrorBox>}
<FormActions>
{!setupMode && <Button onClick={() => setStep('status')}>Back</Button>}
<Button onClick={doScan}>Rescan</Button>
</FormActions>
</WifiColumn>
)}
{/* Password entry */}
{step === 'password' && (
<WifiColumn>
<FormField>
<FormLabel>Network</FormLabel>
<div style={{ fontWeight: 500, fontSize: 16 }}>{selectedNetwork?.ssid}</div>
</FormField>
<form onSubmit={handleConnect} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField>
<FormLabel>Password</FormLabel>
<FormInput
type="password"
value={password}
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
placeholder="Enter WiFi password"
autofocus
/>
</FormField>
{error && <ErrorBox>{error}</ErrorBox>}
<FormActions>
<Button onClick={() => { setError(''); setStep('networks') }}>Back</Button>
<Button variant="primary" type="submit">Connect</Button>
</FormActions>
</form>
</WifiColumn>
)}
{/* Connecting spinner */}
{step === 'connecting' && (
<SpinnerWrap>
<Spinner />
<p style={{ color: theme('colors-textMuted') }}>Connecting to <strong>{selectedNetwork?.ssid}</strong>...</p>
</SpinnerWrap>
)}
{/* Success */}
{step === 'success' && (
<WifiColumn style={{ textAlign: 'center' }}>
<SuccessCheck></SuccessCheck>
<h3 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Connected!</h3>
<p style={{ color: theme('colors-textMuted'), marginBottom: 16 }}>
Connected to <strong>{successSsid}</strong>
{successIp && <span> ({successIp})</span>}
</p>
{setupMode ? (
<div style={{
marginTop: 20,
padding: 16,
background: 'var(--colors-bgSubtle)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--colors-border)',
}}>
<p style={{ marginBottom: 8 }}>Reconnect your device to <strong>{successSsid}</strong> and visit:</p>
<a
href={serverUrl}
style={{ color: theme('colors-statusRunning'), fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
>
{serverUrl}
</a>
</div>
) : (
<FormActions>
<Button onClick={() => { fetchStatus(); setStep('status') }}>Done</Button>
</FormActions>
)}
</WifiColumn>
)} )}
</Section> </Section>
<Section>
<SectionTitle>Server</SectionTitle> {!setupMode && (
<Button variant="danger" onClick={handleRestart} disabled={restarting}> <Section>
{restarting ? 'Restarting...' : 'Restart Server'} <SectionTitle>Install CLI</SectionTitle>
</Button> <DashboardInstallCmd>
</Section> curl -fsSL {location.origin}/install | bash
</DashboardInstallCmd>
</Section>
)}
</MainContent> </MainContent>
</Main> </Main>
) )

View File

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

View File

@ -105,13 +105,6 @@ 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

@ -1,48 +0,0 @@
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,21 +1,19 @@
import type { Child } from 'hono/jsx' import type { Child } from 'hono/jsx'
import { render } from 'hono/jsx/dom'
import { define } from '@because/forge' import { define } from '@because/forge'
import { theme } from '../themes' import { theme } from '../themes'
let modalTitle: string | null = null let modalTitle: string | null = null
let modalContent: (() => Child) | null = null let modalContent: (() => Child) | null = null
let renderFn: (() => void) | null = null
const root = document.getElementById('modal')! export const initModal = (render: () => void) => {
renderFn = render
const renderModal = () => {
render(<Modal />, root)
} }
export const openModal = (title: string, content: () => Child) => { export const openModal = (title: string, content: () => Child) => {
modalTitle = title modalTitle = title
modalContent = content modalContent = content
renderModal() renderFn?.()
requestAnimationFrame(() => { requestAnimationFrame(() => {
document.querySelector<HTMLInputElement>('[data-modal-body] input')?.focus() document.querySelector<HTMLInputElement>('[data-modal-body] input')?.focus()
}) })
@ -24,10 +22,12 @@ export const openModal = (title: string, content: () => Child) => {
export const closeModal = () => { export const closeModal = () => {
modalTitle = null modalTitle = null
modalContent = null modalContent = null
renderModal() renderFn?.()
} }
export { renderModal } export const rerenderModal = () => {
renderFn?.()
}
// ESC key handler // ESC key handler
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
@ -45,7 +45,6 @@ const ModalBackdrop = define('ModalBackdrop', {
justifyContent: 'center', justifyContent: 'center',
paddingTop: '20vh', paddingTop: '20vh',
zIndex: 1000, zIndex: 1000,
fontFamily: theme('fonts-sans'),
}) })
const ModalBox = define('ModalBox', { const ModalBox = define('ModalBox', {

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 { initRouter, navigate } from './router' import { getWifiStatus } from './api'
import { apps, dashboardTab, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state' import { apps, getSelectedTab, selectedApp, setApps, setCurrentView, setIsNarrow, setSelectedApp, setSetupMode } from './state'
import { initModal } from './components/modal'
import { initToolIframes, updateToolIframes } from './tool-iframes' import { initToolIframes, updateToolIframes } from './tool-iframes'
import { initUpdate } from './update' import { initUpdate } from './update'
@ -9,32 +10,23 @@ const render = () => {
renderApp(<Dashboard render={render} />, document.getElementById('app')!) renderApp(<Dashboard render={render} />, document.getElementById('app')!)
// Update tool iframes after DOM settles // Update tool iframes after DOM settles
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (selectedApp) { const tools = apps.filter(a => a.tool)
const tools = apps.filter(a => a.tool && a.apps !== false) updateToolIframes(getSelectedTab(selectedApp), tools, selectedApp)
updateToolIframes(getSelectedTab(selectedApp), tools, selectedApp)
} else {
const tools = apps.filter(a => a.tool && a.dashboard)
updateToolIframes(dashboardTab, tools, null)
}
}) })
} }
// Initialize render functions // Initialize render functions
initModal(render)
initUpdate(render) initUpdate(render)
initToolIframes() initToolIframes()
// Set theme based on localStorage preference or system preference // Set theme based on system preference
export const setTheme = () => { const setTheme = () => {
const stored = localStorage.getItem('theme')
if (stored === 'light' || stored === 'dark') {
document.documentElement.setAttribute('data-theme', stored)
return
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light') document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
} }
// Listen for system theme changes (only applies when using system theme) // Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
setTheme() setTheme()
render() render()
@ -50,16 +42,22 @@ narrowQuery.addEventListener('change', e => {
render() render()
}) })
// Initialize router (sets initial state from URL and renders) // Check WiFi setup mode on load
initRouter(render) getWifiStatus().then(status => {
if (status.setupMode) {
setSetupMode(true)
setCurrentView('settings')
render()
}
}).catch(() => {})
// SSE connection // SSE connection for app state
const events = new EventSource('/api/apps/stream') const events = new EventSource('/api/apps/stream')
events.onmessage = e => { events.onmessage = e => {
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)) {
navigate('/') setSelectedApp(null)
} }
render() render()

View File

@ -0,0 +1,17 @@
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' }}
/>
))
}

Some files were not shown because too many files have changed in this diff Show More