toes/CLAUDE.md

13 KiB

Toes

Personal web appliance that auto-discovers and runs multiple web apps on your home network.

"Plug it in, turn it on, and forget about the cloud."

How It Works

  1. Server scans APPS_DIR for directories with a package.json containing a scripts.toes entry
  2. Each app is spawned as a child process with a unique port (3001-3100)
  3. Dashboard UI shows all apps with status, logs, and links via SSE
  4. CLI communicates with the server over HTTP

Tech Stack

  • Bun runtime (not Node)
  • Hype (custom HTTP framework wrapping Hono) from @because/hype
  • Forge (typed CSS-in-JS) from @because/forge
  • Commander + ansis for CLI
  • TypeScript + Hono JSX
  • Client renders with hono/jsx/dom (no build step, served directly)

Running

bun run dev      # Hot reload (rebuilds client bundle on change)
bun run start    # Production (generates templates, then runs server)
bun run check    # Type check
bun run test     # Tests
bun run build    # Build client JS bundle (pub/client/index.js)
bun run release  # Build release tarball for the Pi

Building & Releasing

bun run release (runs scripts/release.sh) produces a self-contained tarball the Pi can install without building anything:

  1. Client JS bundle → pub/client/index.js
  2. Embedded templates → src/lib/templates.data.ts (generated from templates/ by scripts/embed-templates.ts)
  3. Pre-built bare git repos for bundled apps → dist/repos/*.git (built by scripts/build-repos.sh)
  4. Cross-compiled CLI binary → dist/toes (linux-arm64, built by scripts/build.ts)
  5. Everything staged and packed into dist/toes-<version>.tar.gz

The Pi installer (install/install.sh) downloads this tarball, runs bun install for the server and all bundled apps (in parallel), copies pre-built repos and CLI binary into place, and starts the systemd service. No git commands, no compilation on the Pi.

Key scripts

  • scripts/build.sh -- Builds client JS bundle only (used during dev)
  • scripts/build-repos.sh -- Pre-builds bare git repos for bundled apps (excludes node_modules, snapshots, logs)
  • scripts/release.sh -- Full release pipeline: client + templates + repos + CLI → tarball
  • scripts/build.ts -- CLI binary compiler (current platform or --all for cross-compile)
  • scripts/embed-templates.ts -- Generates src/lib/templates.data.ts from templates/
  • install/install.sh -- Pi installer, downloads release tarball and sets everything up
  • scripts/remote-install.sh -- Runs the installer on a remote Pi over SSH

Project Structure

src/
  server/         # HTTP server and process management ($)
  client/         # Browser-side dashboard
  shared/         # Types shared between server and client (@)
  lib/            # Code shared between CLI and server (%)
  cli/            # CLI tool
  tools/          # @because/toes package exports
  pages/          # Hype page routes

Path aliases: $ = server, @ = shared, % = lib (defined in tsconfig.json).

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).
  • 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/events.ts -- SSE stream for discrete lifecycle events (/stream). Used by app processes (not the dashboard). Includes a 60s heartbeat ping.
  • api/sync.ts -- File sync API: manifest endpoint, file read/write, app delete, app reload (triggered by git tool after deploy), file watch SSE.
  • api/system.ts -- System info, metrics (CPU/RAM/disk per-app via ps/du), unified log aggregation, perf timing toggle, update check/apply, server restart.
  • index.tsx -- Entry point. Mounts API routers, tool URL redirects (/tool/:tool), tool API proxy (/api/tools/:tool/*), on-demand CLI binary builds (/dist/:file), install script endpoint (/install), SPA catch-all routes, subdomain proxy (including WebSocket). Initializes apps.
  • mdns.ts -- mDNS publishing via avahi-publish on Linux production. Publishes <app>.hostname.local A records pointing to local IP. Auto-republishes on unexpected exit with exponential backoff.
  • proxy.ts -- Subdomain routing: extracts subdomain from *.localhost or *.X.local, proxies HTTP requests and WebSocket connections to the app's port. Sets x-app-url header. Optional perf timing.
  • shell.tsx -- Minimal HTML shell for the SPA.
  • sync.ts -- Re-exports computeHash and generateManifest from %sync (lib).
  • tui.ts -- Terminal UI for the server process (renders app status table when TTY, plain logs otherwise). Debounced rendering at 50ms.
  • tunnels.ts -- Public tunnel management via @because/sneaker. Persists tunnel config in TOES_DIR/tunnels.json. Auto-reconnects on drop with exponential backoff (max 10 attempts). Manages share/unshare lifecycle.

Client (src/client/)

Client-side SPA rendered with hono/jsx/dom. No build step -- Bun serves .tsx files directly.

  • index.tsx -- Entry point. Initializes rendering, SSE connection, theme, tool iframes.
  • state.ts -- Mutable module-level state (apps, selectedApp, sidebarCollapsed, etc.) with localStorage persistence. Components import state directly.
  • api.ts -- Fetch wrappers for server API calls.
  • tool-iframes.ts -- Manages tool iframe lifecycle (caching, visibility, height communication).
  • update.tsx -- SSE connection to /api/apps/stream for real-time state updates.
  • router.ts -- Client-side router. Intercepts link clicks, handles popstate, maps URL paths to state (/app/:name/:tab, /settings, dashboard tabs).
  • ansi.ts -- ANSI escape code handling for log rendering.
  • components/ -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection, DashboardLanding, SettingsPage, Vitals, UnifiedLogs, Urls, emoji-picker, modal.
  • modals/ -- NewApp, RenameApp, DeleteApp dialogs.
  • styles/ -- Forge CSS-in-JS (themes, buttons, forms, layout, logs, misc).
  • themes/ -- Light/dark theme token definitions.

CLI (src/cli/)

  • index.ts -- Entry point (#!/usr/bin/env bun).
  • setup.ts -- Commander program definition with all commands.
  • commands/ -- Command implementations.
  • http.ts -- HTTP client for talking to the toes server.
  • name.ts -- App name resolution (argument or current directory).
  • prompts.ts -- Interactive prompts.
  • pager.ts -- Pipe output through system pager.

CLI commands:

  • Apps: status (list or info), new, get, open, rename, rm
  • Lifecycle: start, stop, restart, share, unshare, logs, metrics, cron (list/log/status/run)
  • Config: env (list/set/rm, per-app or --global), perf (toggle request timing)
  • Hidden: list, info, log, version, shell

Some commands (shell, get, open) are disabled when running over SSH (USER=cli).

Shared (src/shared/)

Types shared between browser and server. Cannot use Node/filesystem APIs (runs in browser).

  • types.ts -- App, AppState, LogLine, Manifest, FileInfo, DEFAULT_EMOJI, VALID_NAME
  • events.ts -- ToesEvent, ToesEventType, ToesEventInput type definitions
  • urls.ts -- toSubdomain() and buildAppUrl() for subdomain URL construction
  • gitignore.ts -- .gitignore pattern matching (used by sync API and file watchers)

Lib (src/lib/)

Server-side code shared between CLI and server. Can use Node APIs.

  • config.ts -- HOSTNAME and LOCAL_HOST (hostname.local) constants
  • templates.ts -- Template generation for toes new (bare, ssr, spa), reads embedded templates from templates.data.ts
  • templates.data.ts -- Generated file containing embedded template contents (built by scripts/embed-templates.ts)
  • sync.ts -- Manifest generation, hash computation (used by sync API for file diffing in tools)

Tools Package (src/tools/)

The @because/toes package that apps/tools import. Published exports:

  • @because/toes -- re-exports computeHash, generateManifest, FileInfo, Manifest, VALID_NAME (src/index.ts -> src/tools/index.ts)
  • @because/toes/tools -- baseStyles, ToolScript, theme, loadAppEnv, on (event subscription), appUrl, VALID_NAME

Pages (src/pages/)

Hype page routes. index.tsx renders the Shell.

Key Concepts

App Lifecycle

States: invalid | error | stopped <-> starting -> running -> stopping -> stopped

  • Discovery: scan APPS_DIR, read each package.json for scripts.toes
  • Spawn: Bun.spawn(['bun', 'run', 'toes']) with PORT, APP_URL, APPS_DIR, DATA_DIR, DATA_ROOT, TOES_URL, TOES_DIR, NO_AUTOPORT, plus per-app env vars from loadAppEnv()
  • Startup: runs bun install first if node_modules/ missing, then polls /ok every 500ms (30s timeout)
  • Health checks: every 30s to /ok (5s timeout), 3 consecutive failures trigger restart
  • Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), max 5 attempts, resets after 60s stable run. State becomes error after max attempts.
  • Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
  • On startup, kills stale processes on ports 3001-3100 and orphaned bun run toes processes

Subdomain Proxy

Every app gets a subdomain: <app>.localhost (dev) or <app>.hostname.local (prod). The server's fetch handler (index.tsx) checks for subdomains first and proxies to the app's port. WebSocket connections are also proxied via Bun's server.upgrade() with upstream bridging. The x-app-url header is set so apps know their public URL.

Tools vs Apps

Tools are apps with "toes": { "tool": true } in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server via subdomain: /tool/:tool?app=foo -> http://<tool>.host/?app=foo. Tool API calls can also be proxied: /api/tools/:tool/* -> http://localhost:<toolPort>/*.

The apps field in package.json controls whether a tool shows on app detail pages (false to hide). The dashboard field controls whether a tool shows on the dashboard landing page.

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.

Environment Variables

Per-app env files in TOES_DIR/env/:

  • _global.env -- shared by all apps
  • <appname>.env -- per-app overrides

The server sets these on each app process: PORT, APP_URL, APPS_DIR, DATA_DIR (per-app at TOES_DIR/<name>), DATA_ROOT, TOES_DIR, TOES_URL, NO_AUTOPORT.

SSE Streaming

Two SSE endpoints serve different consumers:

  • /api/apps/stream -- Full app state snapshots on every change. Used by the dashboard UI. Driven by onChange() in apps.ts.
  • /api/events/stream -- Discrete lifecycle events (app:start, app:stop, app:reload, app:create, app:delete). Used by app processes to react to other apps' lifecycle changes. Driven by emit()/onEvent() in apps.ts. Apps subscribe via on() from @because/toes/tools.

Coding Guidelines

TS files should be organized in the following way:

  • imports
  • re-exports
  • const/lets
  • enums
  • interfaces
  • types
  • classes
  • functions
  • module init (top level function calls)

In each section, put the exports first, in alphabetical order.

Then, after the exports (if there were any), put everything else, also in alphabetical order.

For single-line functions, use const fn = () => {} and put them in the "functions" section of the file.

All other functions use the function blah(){} format.

Example:

import { code } from "coders"
import { something } from "somewhere"

export type { SomeType }

const RETRY_TIMES = 5
const WIDTH = 480

enum State {
  Stopped,
  Starting,
  Running,
}

interface Config {
  name: string
  port: number
}

type Handler = (req: Request) => Response

class App {
  config: Config

  constructor(config: Config) {
    this.config = config
  }
}

const isApp = (name: string) =>
  apps.has(name)

function createApp(name: string): App {
  const app = new App({ name, port: 3000 })
  apps.set(name, app)
  return app
}

function start(app: App): void {
  console.log(`Starting ${app.config.name}`)
}

Install & Deployment

The install script (install/install.sh) is designed to run on a fresh Pi or as an updater:

  1. Installs system packages (git, fish, avahi-utils, etc.) via apt
  2. Installs Bun and grants cap_net_bind_service
  3. Downloads and extracts the release tarball into ~/toes
  4. Runs bun install for the server
  5. Copies bundled apps to ~/apps/ and runs bun install for each (in parallel)
  6. Copies pre-built bare repos to ~/data/repos/ (for git-based versioning)
  7. Installs the pre-built CLI binary to /usr/local/bin/toes
  8. Sets up SSH access and the systemd service

The release tarball URL is configured as RELEASE_URL at the top of install/install.sh.

Writing Apps and Tools

See docs/GUIDE.md for the guide to writing toes apps and tools.