Compare commits

...

65 Commits
main ... main

Author SHA1 Message Date
d53e8c8cf1 Add HTTP guide and WebSocket header forwarding 2026-05-14 00:25:05 -07:00
75b40b7ed1 Add env var paste parsing and improve SSE reconnection 2026-05-14 00:17:17 -07:00
be6d4f58ff Fix tar macOS compatibility and add deploy logs 2026-05-13 23:41:27 -07:00
70d9fd6fbb Add --no-xattrs flag and reorder mkdir 2026-05-13 23:33:34 -07:00
a8c4975ed5 Add remote:deploy script for release deployment 2026-05-13 23:32:10 -07:00
e5e82f3085 Add comprehensive documentation for releases and deployment 2026-05-13 23:23:29 -07:00
3baf943a53 Switch from git clone to archive-based install 2026-05-12 20:35:43 -07:00
7935469776 Refactor app reload logic into dedicated function 2026-05-12 18:29:05 -07:00
b303fa1eef Refactor app reload to handle state transitions 2026-05-12 18:19:23 -07:00
c634d488b6 Set Host header for static app tunnel routing 2026-05-11 16:15:49 -07:00
31ca1d9849 Refactor app reload logic into reloadApp function 2026-05-11 15:52:01 -07:00
3743a01c28 Add SERVER_PORT export and fix tunnel port selection 2026-05-11 14:52:44 -07:00
875c89de18 Add app publish/unpublish lifecycle hooks 2026-05-11 14:46:55 -07:00
c0276389eb Add static site support to apps 2026-05-11 14:37:12 -07:00
44dc7527fe Add .rev/ to gitignore 2026-05-11 14:15:29 -07:00
2937fb2372 Add mDNS republish logic with exponential backoff 2026-04-13 16:55:07 -07:00
c66a40df96 Add server uptime tracking to settings 2026-04-13 16:53:41 -07:00
00c37bd9e8 0.0.19 2026-04-04 19:36:32 -07:00
99492a35e8 Fix SSE ping heartbeat message format 2026-04-04 19:36:28 -07:00
cf018004a2 Bump @because/toes to 0.0.18 2026-04-04 15:57:00 -07:00
efead147fb 0.0.18 2026-04-04 15:56:36 -07:00
7ca1f94160 Add payload validation before parsing JSON 2026-04-04 15:46:54 -07:00
e6afc0d797 0.0.17 2026-04-04 15:05:10 -07:00
211e441dd4 Update appUrl import path to @because/toes/tools 2026-04-04 15:05:04 -07:00
184a77f909 0.0.16 2026-04-04 15:01:03 -07:00
70f52a9b55 Add x-app-url header to tunnel requests 2026-04-04 14:56:29 -07:00
eb2e5a4436 Add appUrl() helper and x-app-url header 2026-04-04 14:40:07 -07:00
db46287695 Remove old CLI binary before install 2026-03-27 15:02:45 -07:00
5ab634e245 Enable colored output in CLI 2026-03-27 11:56:16 -07:00
5f4d512cdf Bump @because/toes to 0.0.15 2026-03-27 11:45:05 -07:00
5e13948be3 0.0.15 2026-03-25 21:12:15 -07:00
164abebeba my my 2026-03-25 21:12:13 -07:00
433eb0b990 0.0.14 2026-03-25 21:12:00 -07:00
5ce65b3096 oh, that too 2026-03-25 21:11:59 -07:00
3c3a90b4f5 0.0.13 2026-03-25 21:10:58 -07:00
0e467a1bdf Fix git init failing when no default branch or user config is set 2026-03-24 18:40:06 -07:00
7588ef5564 Reorder setup steps and remove quiet wrapper 2026-03-23 18:26:31 -07:00
df05cbd3aa Add error handling for lsof and pgrep commands 2026-03-23 16:58:44 -07:00
1f0c7bd099 Remove try-catch from perfToggle and fix perf timing flag read
Let errors propagate to the caller instead of catching locally,
simplify the request body construction, and snapshot perf.timing
before the fetch to avoid a TOCTOU race.
2026-03-19 11:50:10 -07:00
4cc0ff2bed Inline PerfState interface and move subscriptions before routes
The PerfState interface was only used twice, so inline it. Move
onChange/onHostLog subscriptions above the route definitions to keep
side-effects grouped. Skip perf.now() in proxy when timing is off.
2026-03-19 11:29:17 -07:00
9a19c0a861 Consolidate perfTiming state into a single perf object
Removes the separate variable and setter in favor of a plain object,
making the mutable state easier to track and eliminating a needless
abstraction.
2026-03-19 11:21:50 -07:00
b9f94a6c98 Move setPerfTiming next to perfTiming and always capture request start time
Colocate the setter with its variable for readability. Remove the
conditional around performance.now() since the call is negligible
and simplifies the timing logic.
2026-03-19 11:09:27 -07:00
c42c73fe70 Simplify perf toggle by deduplicating branching logic
Early-return on invalid input, then unify the GET/POST and display
paths so each concern is handled once instead of per-subcommand.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:02:06 -07:00
c3ad78f1be Add CLI perf command to toggle request timing on proxied apps 2026-03-19 10:50:23 -07:00
1e4d66cbe4 Fix log search filtering to match against plain text instead of ANSI escape codes
Move styles array outside the parse loop in ansiToHtml so styles accumulate across sequences, and use stripAnsi when filtering live logs so search matches visible text.
2026-03-18 11:23:06 -07:00
a824d62058 Refactor ANSI parser to support SGR reset (code 39) and bold/dim styles
Merge color and style maps into a unified STYLES table, hoist the
regex to module scope, export stripAnsi for use in log parsing, and
handle SGR 39 (default foreground) by removing only color styles
instead of clearing all styles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 11:14:03 -07:00
33d91747af Combine ANSI style codes into a single span element
Multiple SGR parameters in one escape sequence (e.g. bold + color)
were each opening a new span, losing earlier styles. Collect styles
per sequence and emit one span with all of them.
2026-03-18 10:59:07 -07:00
33d21777d3 Add ANSI color code to HTML conversion for log display
Terminal color codes were rendering as raw escape sequences in the
web UI, making logs hard to read.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:55:31 -07:00
62f936cdef Merge branch 'switch-kleur-ansis' 2026-03-17 21:35:47 -07:00
3a6ed5d546 Handle -c flag position for both shebang and compiled argv layouts
When run via shebang, bun prepends itself so -c appears at argv[2]
instead of argv[1]. Search both positions to support either mode.
2026-03-17 21:35:19 -07:00
3328009af6 Promote status to the primary command over list and info
Hide `list` and `info` as aliases so existing scripts keep working
while surfacing `status` as the canonical entry point in help output.
2026-03-17 19:44:57 -07:00
c12d60119f Add hidden status command for listing and inspecting apps 2026-03-17 19:22:46 -07:00
99a3a25131 Merge branch 'cli' 2026-03-17 19:18:30 -07:00
35a9053308 Reduce nesting in SSH-disabled command setup with early continue 2026-03-17 19:18:26 -07:00
0abf03e64e Disable shell, get, and open commands for SSH sessions
These commands require local access and cannot function when
connected over SSH (USER=cli).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 19:17:12 -07:00
bbed8c49b7 Add .hushlogin creation to setup-ssh script 2026-03-17 17:46:32 -07:00
a9f8a3885d Remove deploy scripts and npm tasks 2026-03-17 17:42:37 -07:00
21c6c27c92 Add SSH CLI access and update install docs 2026-03-16 16:35:19 -07:00
195be426f1 Convert getAppMetrics to async and replace spawnSync with async Bun.spawn
spawnSync blocks the event loop while waiting for ps and du, which
stalls the SSE metrics stream and other requests. Running these
concurrently with async spawn (and Promise.all for du) keeps the
server responsive under load.
2026-03-16 16:32:17 -07:00
926e57e34e Make install.sh executable 2026-03-16 16:19:11 -07:00
0a7c2b0f1f Add CLI build and install to deploy, handle SSH login shell commands
The deploy script now builds the CLI binary and copies it to /usr/local/bin.
SSH passes commands as `toes -c "command args"`, so parse that form
before falling through to the interactive shell or normal arg handling.
2026-03-16 16:14:31 -07:00
aebafdf496 Remove package.json requirement, auto-deploy bare repos 2026-03-12 14:45:57 -07:00
63b0709649 shout 2026-03-10 15:51:38 -07:00
0471e7b26d yea 2026-03-10 15:43:31 -07:00
18834fcd2b Replace kleur with ansis for CLI colors 2026-03-09 18:39:14 -07:00
51 changed files with 1361 additions and 386 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.sandlot/
.rev/
# dependencies (bun install)
node_modules

106
CLAUDE.md
View File

@ -16,19 +16,43 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
- **Bun** runtime (not Node)
- **Hype** (custom HTTP framework wrapping Hono) from `@because/hype`
- **Forge** (typed CSS-in-JS) from `@because/forge`
- **Commander** + **kleur** for CLI
- **Commander** + **ansis** for CLI
- TypeScript + Hono JSX
- Client renders with `hono/jsx/dom` (no build step, served directly)
## Running
```bash
bun run dev # Hot reload (deletes pub/client/index.js first)
bun run start # Production
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
```
@ -48,10 +72,16 @@ Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
- `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/sync.ts` -- File sync API: manifest endpoint, file read/write, app reload (triggered by git tool after deploy), file watch SSE.
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps.
- `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.
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY).
- `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/`)
@ -62,9 +92,11 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
- `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.
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection.
- `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).
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout, logs, misc).
- `themes/` -- Light/dark theme token definitions.
### CLI (`src/cli/`)
@ -78,30 +110,37 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
- `pager.ts` -- Pipe output through system pager.
CLI commands:
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`, `share`, `unshare`
- **Config**: `env`
- **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`
- `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.
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa)
- `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 from server (`src/index.ts` -> `src/server/sync.ts`)
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`
- `@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/`)
@ -111,17 +150,25 @@ Hype page routes. `index.tsx` renders the Shell.
### App Lifecycle
States: `invalid` -> `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
States: `invalid` | `error` | `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
- Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes`
- Spawn: `Bun.spawn()` with `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, plus per-app env vars
- Health checks: every 30s to `/ok`, 3 consecutive failures trigger restart
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), resets after 60s stable run
- 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: `/tool/:tool?app=foo` -> `http://host:toolPort/?app=foo`.
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
@ -133,14 +180,14 @@ 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`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, `DATA_DIR`.
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:activate`, `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`.
- `/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
@ -212,6 +259,21 @@ function start(app: App): void {
}
```
## 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.

View File

@ -1,55 +1,82 @@
# 🐾 Toes
Toes is a personal web appliance you run on your home network.
Personal web appliance you run on your home network.
Plug it in, turn it on, and forget about the cloud.
## setup
## Development
Toes runs on a Raspberry Pi. You'll need:
```bash
bun run dev # Hot reload (rebuilds client bundle on change)
bun run start # Production mode
bun run check # Type check
bun run test # Tests
bun run build # Build client JS bundle
bun run release # Build a release tarball for the Pi
```
- A Raspberry Pi 5 running the latest Raspberry Pi OS
- A `toes` user with passwordless sudo
### Releasing
SSH into your Pi as the `toes` user and run:
`bun run release` builds everything the Pi needs into a single tarball:
1. Client JS bundle (`pub/client/index.js`)
2. Embedded templates (`src/lib/templates.data.ts`)
3. Pre-built bare git repos for bundled apps (`dist/repos/`)
4. Cross-compiled CLI binary for linux-arm64 (`dist/toes`)
Output: `dist/toes-<version>.tar.gz`
The Pi does zero building — it untars, runs `bun install`, and starts. Upload the tarball to wherever `RELEASE_URL` in `install/install.sh` points (currently `https://toes.dev/release/latest.tar.gz`).
### Scripts
| Script | What it does |
|--------|-------------|
| `scripts/build.sh` | Builds the client JS bundle into `pub/client/index.js` |
| `scripts/build-repos.sh` | Pre-builds bare git repos for bundled apps in `dist/repos/` |
| `scripts/release.sh` | Full release: client + templates + repos + CLI → tarball |
| `scripts/build.ts` | Builds the CLI binary (current platform or cross-compile) |
| `scripts/embed-templates.ts` | Generates `src/lib/templates.data.ts` from `templates/` |
| `scripts/setup-ssh.sh` | Configures SSH access for the `cli` user on the Pi |
| `scripts/remote-install.sh` | Runs the installer on a remote Pi over SSH |
## Setup
Toes runs on a Raspberry Pi 5 with a `toes` user and passwordless sudo.
```bash
curl -fsSL https://toes.dev/install | bash
```
This will:
The installer downloads the release tarball, installs bun and system packages, runs `bun install` for the server and all bundled apps (in parallel), copies the pre-built CLI and git repos into place, and starts the systemd service.
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
Dashboard: `http://<hostname>.local`
Once complete, visit `http://<hostname>.local` on your local network.
## Features
## features
- Hosts Bun/Hype webapps (SSR and SPA)
- `git push` Heroku-style deploys
- Web dashboard with real-time status, logs, and tools
- `toes` CLI (local install or SSH)
- Per-app environment variables, cron jobs, metrics
- Public sharing via tunnels
- Effortlessly hosts bun/hype webapps - both SSR and SPA.
- `git push`, Heroku-style deploys
- https://toes.local web UI for managing your projects.
- `toes` CLI for managing your projects.
## SSH CLI
## cli configuration
Manage your server from any machine on the network — no install required.
by default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
```bash
ssh cli@toes.local # interactive shell with tab completion
ssh cli@toes.local list # run a single command
ssh cli@toes.local logs fog # stream logs for an app
```
## CLI Configuration
By default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
```bash
toes config # show current host
TOES_URL=http://192.168.1.50:3000 toes list # connect to IP
TOES_URL=http://mypi.local toes list # connect to hostname
```
## fun stuff
- textOS (TODO, more?)
- Claude that knows about all your toes APIS and your projects.
- non-webapps
## february goal
- [ ] Corey and Chris are running Toes servers on their home networks, hosting personal projects and games.

View File

@ -13,7 +13,7 @@
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.8",
"@because/toes": "^0.0.18",
"croner": "^9.1.0"
},
"devDependencies": {

17
apps/env/index.tsx vendored
View File

@ -288,6 +288,23 @@ document.querySelectorAll('[data-reveal]').forEach(btn => {
}
});
});
document.querySelectorAll('input[name="key"]').forEach(input => {
input.addEventListener('paste', e => {
const text = e.clipboardData?.getData('text') ?? '';
const eqIndex = text.indexOf('=');
if (eqIndex === -1) return;
e.preventDefault();
const key = text.slice(0, eqIndex).trim();
const value = text.slice(eqIndex + 1).trim();
input.value = key;
const valueInput = input.closest('form').querySelector('input[name="value"]');
if (valueInput) {
valueInput.value = value;
valueInput.focus();
}
});
});
`
app.get('/ok', c => c.text('ok'))

View File

@ -227,24 +227,6 @@ async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }
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)
@ -661,6 +643,28 @@ function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps
mkdirSync(REPOS_DIR, { recursive: true })
// Auto-deploy bare repos that don't have a corresponding app directory
async function deployUndeployedRepos() {
const repos = await listRepos()
for (const name of repos) {
const appDir = join(APPS_DIR, name)
if (await dirExists(appDir)) continue
const bare = repoPath(name)
if (!(await hasCommits(bare))) continue
console.log(`Auto-deploying undeployed repo: ${name}`)
const result = await deploy(name)
if (result.ok) {
await activateApp(name)
} else {
console.error(`Auto-deploy failed for ${name}: ${result.error}`)
}
}
}
deployUndeployedRepos()
on('app:delete', async ({ app: name }) => {
const bare = repoPath(name)
if (await dirExists(bare)) await rm(bare, { recursive: true, force: true })

View File

@ -5,51 +5,48 @@
"": {
"name": "@because/toes",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.4",
"@because/toes": "^0.0.12",
"@because/forge": "^0.0.7",
"@because/hype": "^0.0.9",
"@because/sneaker": "^0.0.5",
"@because/toes": "^0.0.15",
"ansis": "^4.2.0",
"commander": "14.0.3",
"diff": "^8.0.3",
"kleur": "^4.1.5",
"diff": "^8.0.4",
},
"devDependencies": {
"@types/bun": "latest",
"@types/diff": "^8.0.0",
},
"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/forge": ["@because/forge@0.0.7", "https://npm.nose.space/@because/forge/-/forge-0.0.7.tgz", {}, "sha512-vrpo9/l3YbpJikr4eGNBbXDoXa1q0TtretyXwlJys/5qWEuHJJ3F8sFQ8SEeqWq3j0k9LQfS1278YE6a9mpv6g=="],
"@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.9", "https://npm.nose.space/@because/hype/-/hype-0.0.9.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" } }, "sha512-pCaGAP0d4JDkeuVDR+8x0AemaC/EjFNNBNHhC8eKcw7soQVoXG0kYJW4bIBlEBrY8pd2TBXmMEzRIcW4QnwXNw=="],
"@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.5", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.5.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" } }, "sha512-GAvsh/i6N+KRV5mK/YosOMlZ3Lnm/y8kppQiKTZ5HlBDBnJfuxGJSp7aLyHZlHtSQi1jcGJWsx6NJ4RqCD7hNA=="],
"@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=="],
"@because/toes": ["@because/toes@0.0.15", "https://npm.nose.space/@because/toes/-/toes-0.0.15.tgz", { "dependencies": { "@because/forge": "^0.0.7", "@because/hype": "^0.0.9", "@because/sneaker": "^0.0.5", "@because/toes": "^0.0.15", "ansis": "^4.2.0", "commander": "14.0.3", "diff": "^8.0.4" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-vHCMIx3w7AK1buWIKXTTWb2oxKCrOdXj0/p4+r+prjjfp1Q6RPmkfPFy1GVRZ4P1kVr8LO7PpDITCHNMr8cAlQ=="],
"@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/bun": ["@types/bun@1.3.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@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.5.0", "https://npm.nose.space/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"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=="],
"ansis": ["ansis@4.2.0", "https://npm.nose.space/ansis/-/ansis-4.2.0.tgz", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"bun-types": ["bun-types@1.3.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"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=="],
"diff": ["diff@8.0.4", "https://npm.nose.space/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"hono": ["hono@4.11.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"hono": ["hono@4.12.9", "https://npm.nose.space/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"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=="],
"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,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

@ -26,6 +26,7 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
- [Sharing](#sharing)
- [Environment Variables](#environment-variables-1)
- [Health Checks](#health-checks)
- [Running over HTTP](#running-over-http)
- [App Lifecycle](#app-lifecycle)
- [Cron Jobs](#cron-jobs)
- [Data Persistence](#data-persistence)
@ -681,11 +682,25 @@ toes metrics my-app # Single app
```bash
toes share my-app
# ↗ Sharing my-app... https://abc123.trycloudflare.com
# ↗ Sharing my-app... https://myapp.toes.space
```
**`toes unshare [name]`** — Stop sharing an app.
Every request to your app includes an `x-app-url` header with the app's public-facing URL. When shared, this is the tunnel URL (e.g., `https://myapp.toes.space`). When not shared, it's the local URL (e.g., `http://myapp.toes.local`). This works whether the request arrives through the local proxy or through a tunnel.
Use `appUrl()` from `@because/toes/tools` to read it — never hardcode your app's URL:
```tsx
import { appUrl } from '@because/toes/tools'
app.get('/callback', c => {
const url = appUrl(c.req.raw)
// "https://myapp.toes.space" when shared, "http://myapp.toes.local" otherwise
return c.redirect(`${url}/done`)
})
```
---
## Environment Variables
@ -697,8 +712,9 @@ Toes injects these variables into every app process automatically:
| `PORT` | Assigned port (3001-3100). Your app must listen on this port. |
| `APPS_DIR` | Path to the apps directory on the server. |
| `DATA_DIR` | Per-app data directory for persistent storage. |
| `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local:3000`). |
| `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local`). |
| `TOES_DIR` | Path to the Toes config directory. |
| `APP_URL` | The app's local URL (e.g., `http://myapp.toes.local`). For the public URL that accounts for sharing, use `appUrl(req)` from `@because/toes` (see [Sharing](#sharing)). |
You can set custom variables per-app or globally. Global variables are inherited by all apps. Per-app variables override globals.
@ -732,6 +748,92 @@ app.get('/ok', c => c.text('ok'))
---
## Running over HTTP
Toes serves apps over plain HTTP (`http://<app>.toes.local`), not HTTPS. This is fine for a home network appliance, but a few browser features assume HTTPS and will silently break if you're not aware of them.
> **Note:** `localhost` gets a special pass — browsers treat it as a secure context even over HTTP. But `.local` domains don't get that exemption, so these gotchas apply when accessing your apps at `<app>.toes.local` from another device.
### Cookies
If you set cookies with the `Secure` flag, browsers will silently ignore them — the cookie just won't be stored.
Don't do this:
```tsx
c.header('Set-Cookie', 'session=abc123; HttpOnly; Secure; SameSite=Lax')
```
Do this instead:
```tsx
c.header('Set-Cookie', 'session=abc123; HttpOnly; SameSite=Lax')
```
If you're using a cookie library, make sure `secure` is set to `false` (or omitted):
```tsx
import { setCookie } from 'hono/cookie'
setCookie(c, 'session', token, {
httpOnly: true,
sameSite: 'Lax',
secure: false, // toes apps run over HTTP
})
```
### Clipboard API
`navigator.clipboard.writeText()` and `navigator.clipboard.readText()` require a secure context. They'll throw on `.local` domains.
Use the legacy fallback instead:
```tsx
function copyToClipboard(text: string) {
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
```
### Service Workers
Service workers only register on HTTPS origins (plus `localhost`). If you're building a PWA or want offline caching, it won't work on `.local`. This is a hard browser restriction with no workaround.
### Web Push Notifications
The Push API and `Notification.requestPermission()` require a secure context. For notifications on the local network, consider polling or SSE instead:
```tsx
app.sse('/notifications', (send, c) => {
// push updates over SSE instead of Web Push
send({ title: 'New item', body: 'Something happened' })
return () => {}
})
```
### Geolocation & Camera/Mic
`navigator.geolocation` and `navigator.mediaDevices.getUserMedia()` require a secure context. These won't work on `.local` domains.
### Web Crypto
`crypto.subtle` (for hashing, encryption, key generation) requires a secure context. Use a library like `tweetnacl` if you need crypto in the browser, or do it server-side:
```tsx
// Server-side — works fine, no secure context needed
const hash = new Bun.CryptoHasher('sha256').update(data).digest('hex')
```
### What about `toes share`?
`toes share` tunnels your app through HTTPS, so all of the above works when accessed through the tunnel URL. But since your app should also work locally, don't rely on secure-context APIs unless you're okay with them only working when shared.
---
## App Lifecycle
Apps move through these states:

81
docs/WEBHOOKS.md Normal file
View File

@ -0,0 +1,81 @@
# Rev Webhooks for Toes
Deploy Toes apps by saving to rev.host — no manual deploy step, no rsync scripts.
## How It Works
```
rev save "fix combat" → rev.host → relay (sneaker.toes.space) → toes.local pulls + deploys
```
toes.local can't receive inbound connections (home NAT), so it maintains an outbound connection to a relay — same tunnel infrastructure used by `toes share`.
## Setup Flow
1. In the Toes dashboard (or `toes` CLI via SSH), enable rev webhooks for an app
2. Toes connects to the relay and gets a stable webhook URL
3. Add that URL in rev.host project settings as a webhook endpoint
4. Done — `rev save` and `rev merge` now trigger deploys
## What Toes Does on Webhook
1. Receives event from relay (repo, ref, timestamp)
2. Pulls latest from rev.host (needs a rev auth token stored in Toes env)
3. Runs `scripts.predeploy` if defined in package.json (type-check, build, etc.)
4. Runs `bun install`
5. Restarts the app
## CLI
```bash
# Enable/disable rev webhooks
toes webhook enable [name] # Shows the relay URL to paste into rev.host
toes webhook disable [name]
# Manual trigger (pull latest and deploy now)
toes deploy [name]
# Check webhook status
toes webhook status [name]
```
## Settings UI
App settings page gets a "Rev Webhooks" section:
- Toggle to enable/disable
- Displays the relay URL (copy button)
- Field for rev.host auth token
- Last deploy timestamp + status
- "Deploy Now" button (manual trigger)
## Auth
Toes needs read access to pull from rev.host. Store a rev API token per-app (or globally):
```bash
toes env set -g REV_TOKEN rt_abc123
# or per-app
toes env set my-app REV_TOKEN rt_abc123
```
## Predeploy Scripts
Project-specific build steps go in package.json:
```json
{
"scripts": {
"toes": "bun run --watch index.tsx",
"predeploy": "bunx tsc --noEmit && bun build client/main.tsx --outdir dist --minify"
}
}
```
Toes runs `predeploy` after pulling but before restarting. If it exits non-zero, the deploy is aborted and the previous version stays running.
## Open Questions
- Should the relay URL be per-app or per-Toes-instance? (Per-instance with app routing via path seems simpler: `https://sneaker.toes.space/hooks/<instance-id>/<app-name>`)
- Webhook secret/signature verification — rev.host should sign payloads so the relay can't be spoofed
- Should `toes deploy` work without webhooks enabled? (Just pull from rev.host on demand — useful as a migration path from deploy.sh)
- Rollback: `toes rollback [name]` to revert to previous rev version?

56
install/install.sh Normal file → Executable file
View File

@ -8,7 +8,7 @@ set -euo pipefail
# 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"
RELEASE_URL="https://toes.dev/release/latest.tar.gz"
DEST=~/toes
APPS_DIR=~/apps
DATA_DIR=~/data
@ -59,59 +59,56 @@ sudo ln -sf "$BUN" /usr/local/bin/bun
sudo setcap 'cap_net_bind_service=+ep' "$BUN"
# ── Clone or pull ────────────────────────────────────────
# ── Download ─────────────────────────────────────────────
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
info "Downloading toes"
mkdir -p "$DEST"
curl -fsSL "$RELEASE_URL" | tar xz --strip-components=1 -C "$DEST"
# ── Directories ──────────────────────────────────────────
mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes"
# ── Dependencies & build ─────────────────────────────────
# ── Dependencies ─────────────────────────────────────────
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"
pids=()
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"
) &
pids+=("$!")
done
for pid in "${pids[@]}"; do
wait "$pid" || fail "A bundled app failed to install."
done
# Copy pre-built bare repos for git-based versioning
cp -a "$DEST"/dist/repos/*.git "$REPOS_DIR/"
# ── CLI + SSH ────────────────────────────────────────────
info "Setting up SSH access"
sudo bash "$DEST/scripts/setup-ssh.sh"
info "Installing CLI"
sudo install -m 755 "$DEST/dist/toes" /usr/local/bin/toes
# ── Systemd ──────────────────────────────────────────────
info "Installing toes service"
@ -124,13 +121,14 @@ sudo systemctl restart toes
# ── Done ─────────────────────────────────────────────────
VERSION=$(git describe --tags --always 2>/dev/null || echo "unknown")
VERSION=$(grep '"version"' "$DEST/package.json" | head -1 | sed 's/.*"version": *"\(.*\)".*/\1/')
echo ""
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://$(hostname).local${r}"
echo " SSH CLI: ${c}ssh cli@$(hostname).local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"

View File

@ -10,6 +10,14 @@ Bun.serve({
headers: { "content-type": "text/plain" },
})
}
if (new URL(req.url).pathname === "/shout") {
return Response.redirect(
"https://git.nose.space/defunkt/go-shout/raw/branch/main/install.sh",
302
)
}
return new Response("404 Not Found", { status: 404 })
},
})

View File

@ -1,6 +1,6 @@
{
"name": "@because/toes",
"version": "0.0.12",
"version": "0.0.19",
"description": "personal web appliance - turn it on and forget about the cloud",
"module": "src/index.ts",
"type": "module",
@ -17,16 +17,16 @@
"scripts": {
"check": "bun run templates && bunx tsc --noEmit",
"build": "./scripts/build.sh",
"release": "./scripts/release.sh",
"cli:build": "bun run scripts/build.ts",
"cli:build:all": "bun run scripts/build.ts --all",
"cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin",
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes",
"cli:uninstall": "sudo rm /usr/local/bin",
"deploy": "./scripts/deploy.sh",
"debug": "DEBUG=1 bun run dev",
"dev": "bun run templates && rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
"remote:deploy": "./scripts/deploy.sh",
"remote:migrate": "bun run scripts/migrate.ts",
"remote:deploy": "./scripts/remote-deploy.sh",
"remote:install": "./scripts/remote-install.sh",
"remote:logs": "./scripts/remote-logs.sh",
"remote:restart": "./scripts/remote-restart.sh",
@ -40,16 +40,13 @@
"@types/bun": "latest",
"@types/diff": "^8.0.0"
},
"peerDependencies": {
"typescript": "^5.9.3"
},
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.4",
"@because/toes": "^0.0.12",
"@because/forge": "^0.0.7",
"@because/hype": "^0.0.9",
"@because/sneaker": "^0.0.5",
"@because/toes": "^0.0.15",
"ansis": "^4.2.0",
"commander": "14.0.3",
"diff": "^8.0.3",
"kleur": "^4.1.5"
"diff": "^8.0.4"
}
}

36
scripts/build-repos.sh Executable file
View File

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Pre-builds bare git repos for bundled apps so the install script
# doesn't need to run any git commands.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$SCRIPT_DIR/.."
APPS_DIR="$ROOT/apps"
OUT_DIR="$ROOT/dist/repos"
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
for app_dir in "$APPS_DIR"/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
tmp=$(mktemp -d)
tar -C "$app_dir" \
--exclude='node_modules' \
--exclude='logs' \
--exclude='current' \
--exclude='[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' \
-cf - . | tar -C "$tmp" -xf -
git -C "$tmp" init -b main -q
git -C "$tmp" add -A
git -C "$tmp" -c user.name=toes -c user.email=toes@localhost commit -q -m "install"
git clone --bare -q "$tmp" "$OUT_DIR/$app.git"
git -C "$OUT_DIR/$app.git" config http.receivepack true
rm -rf "$tmp"
echo " $app"
done
echo ">> Bare repos built in dist/repos/"

View File

@ -16,3 +16,4 @@ bun build src/client/index.tsx \
echo ">> Client bundle created at pub/client/index.js"
ls -lh pub/client/index.js

View File

@ -1,70 +0,0 @@
#!/usr/bin/env bash
set -e
# Get absolute path of this script's directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
# Load config
source "$ROOT_DIR/scripts/config.sh"
git push origin main
# SSH to target: pull, build, sync apps, restart
ssh "$SSH_HOST" bash <<'SCRIPT'
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
echo "=> Syncing default apps..."
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
target="$APPS_DIR/$app"
mkdir -p "$target"
cp -a "$app_dir"/. "$target"/
echo " $app"
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
done
echo "=> Initializing bare repos..."
mkdir -p "$REPOS_DIR"
for app_dir in "$DEST"/apps/*/; do
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
sudo systemctl restart toes.service
SCRIPT
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' r=$'\033[0m'
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 ""

86
scripts/release.sh Executable file
View File

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Builds a release tarball with all artifacts pre-built.
# The Pi just untars, runs bun install, and starts.
#
# Usage: bun run release
# Output: dist/toes-<version>.tar.gz
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": *"\(.*\)".*/\1/')
STAGING="$ROOT/dist/toes-$VERSION"
echo ">> Building toes $VERSION release"
# ── Clean ────────────────────────────────────────────────
rm -rf dist
mkdir -p dist
# ── Client bundle ────────────────────────────────────────
echo ">> Building client bundle"
rm -rf pub/client
mkdir -p pub/client
bun build src/client/index.tsx \
--outfile pub/client/index.js \
--target browser \
--minify
# ── Embedded templates ───────────────────────────────────
echo ">> Embedding templates"
bun run scripts/embed-templates.ts
# ── Bare repos ───────────────────────────────────────────
echo ">> Building bundled app repos"
bash scripts/build-repos.sh
# ── CLI binary (linux-arm64 for Pi) ──────────────────────
echo ">> Building CLI for linux-arm64"
bun build src/cli/index.ts \
--compile \
--target bun-linux-arm64 \
--minify \
--sourcemap=external \
--define="__GIT_SHA__=\"$VERSION\"" \
--outfile dist/toes-linux-arm64
# ── Stage release ────────────────────────────────────────
echo ">> Staging release"
mkdir -p "$STAGING"
# Source (excluding dev artifacts)
COPYFILE_DISABLE=1 tar -C "$ROOT" \
--no-xattrs \
--exclude='node_modules' \
--exclude='.git' \
--exclude='dist' \
--exclude='apps/*/node_modules' \
--exclude='apps/*/logs' \
--exclude='apps/*/current' \
--exclude='apps/*/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]' \
-cf - . | tar -C "$STAGING" -xf -
# Pre-built artifacts
mkdir -p "$STAGING/pub" "$STAGING/dist"
cp -a pub/client "$STAGING/pub/client"
cp -a dist/repos "$STAGING/dist/repos"
cp dist/toes-linux-arm64 "$STAGING/dist/toes"
# Generated templates (so the server doesn't need to generate them)
cp src/lib/templates.data.ts "$STAGING/src/lib/templates.data.ts"
# ── Tarball ──────────────────────────────────────────────
echo ">> Creating tarball"
COPYFILE_DISABLE=1 tar -C dist --no-xattrs -czf "dist/toes-$VERSION.tar.gz" "toes-$VERSION"
rm -rf "$STAGING"
SIZE=$(du -h "dist/toes-$VERSION.tar.gz" | cut -f1)
echo ">> dist/toes-$VERSION.tar.gz ($SIZE)"

66
scripts/remote-deploy.sh Executable file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# Build a release tarball, upload it to the Pi, and install it.
# Usage: bun run remote:deploy
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
# Build release tarball
"$ROOT_DIR/scripts/release.sh"
# Find the tarball
TARBALL=$(ls -1 "$ROOT_DIR"/dist/toes-*.tar.gz 2>/dev/null | head -1)
[ -f "$TARBALL" ] || { echo "ERROR: No tarball found in dist/"; exit 1; }
FILENAME=$(basename "$TARBALL")
echo ""
echo ">> Uploading $FILENAME to $SSH_HOST"
scp "$TARBALL" "$SSH_HOST:/tmp/$FILENAME"
echo ">> Installing on $SSH_HOST"
ssh "$SSH_HOST" bash <<SCRIPT
set -euo pipefail
DEST=~/toes
APPS_DIR=~/apps
DATA_DIR=~/data
echo ">> Extracting"
mkdir -p "\$DEST"
tar xzf "/tmp/$FILENAME" --strip-components=1 -C "\$DEST"
rm "/tmp/$FILENAME"
echo ">> Installing dependencies"
cd "\$DEST"
~/.bun/bin/bun install > /dev/null 2>&1
# Bundled apps (parallel)
REPOS_DIR="\$DATA_DIR/repos"
mkdir -p "\$REPOS_DIR" "\$APPS_DIR"
echo ">> Installing bundled apps"
pids=()
for app_dir in "\$DEST"/apps/*/; do
app=\$(basename "\$app_dir")
[ -f "\$app_dir/package.json" ] || continue
(
cp -a "\$app_dir" "\$APPS_DIR/\$app"
~/.bun/bin/bun install --frozen-lockfile --cwd "\$APPS_DIR/\$app" > /dev/null 2>&1 \
|| ~/.bun/bin/bun install --cwd "\$APPS_DIR/\$app" > /dev/null 2>&1
) &
pids+=("\$!")
done
for pid in "\${pids[@]}"; do wait "\$pid"; done
echo ">> Installing CLI and repos"
cp -a "\$DEST"/dist/repos/*.git "\$REPOS_DIR/" 2>/dev/null || true
sudo install -m 755 "\$DEST/dist/toes" /usr/local/bin/toes
echo ">> Restarting toes"
sudo systemctl restart toes
echo ">> Done"
SCRIPT

View File

@ -11,11 +11,5 @@ source "$ROOT_DIR/scripts/config.sh"
# Run remote install on the target
ssh "$SSH_HOST" bash <<'SCRIPT'
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
curl -fsSL https://toes.dev/install | sh
SCRIPT

View File

@ -4,10 +4,11 @@
#
# 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
# 2. Suppresses login banner via .hushlogin
# 3. Sets an empty password on `cli` for passwordless SSH
# 4. Adds a Match block in sshd_config to allow empty passwords for `cli`
# 5. Adds /usr/local/bin/toes to /etc/shells
# 6. Restarts sshd
#
# Run as root on the toes machine.
# Usage: ssh cli@toes.local
@ -27,11 +28,16 @@ else
echo " cli user already exists"
fi
# 2. Set empty password
# 2. Suppress login banner (MOTD, last login, etc.)
touch /home/cli/.hushlogin
chown cli:cli /home/cli/.hushlogin 2>/dev/null || true
echo " Created .hushlogin"
# 3. Set empty password
passwd -d cli
echo " Set empty password on cli"
# 3. Add Match block for cli user in sshd_config
# 4. Add Match block for cli user in sshd_config
if ! grep -q 'Match User cli' "$SSHD_CONFIG"; then
cat >> "$SSHD_CONFIG" <<EOF
@ -44,7 +50,7 @@ else
echo " sshd_config already has Match User cli block"
fi
# 4. Ensure /usr/local/bin/toes is in /etc/shells
# 5. 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"
@ -58,7 +64,7 @@ if [ ! -f "$TOES_SHELL" ]; then
echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL"
fi
# 5. Restart sshd
# 6. Restart sshd
echo " Restarting sshd..."
systemctl restart sshd || service ssh restart || true

View File

@ -1,5 +1,5 @@
import type { LogLine } from '@types'
import color from 'kleur'
import color from 'ansis'
import { get, getSignal, handleError, makeUrl, post } from '../http'
import { resolveAppName } from '../name'

View File

@ -1,4 +1,4 @@
import color from 'kleur'
import color from 'ansis'
import { del, get, handleError, post } from '../http'
import { resolveAppName } from '../name'
@ -43,7 +43,7 @@ async function globalEnvSet(keyOrKeyValue: string, valueArg?: string) {
export async function envList(name: string | undefined, opts: { global?: boolean }) {
if (opts.global) {
const vars = await get<EnvVar[]>('/api/env')
console.log(color.bold().cyan('Global Environment Variables'))
console.log(color.bold.cyan('Global Environment Variables'))
console.log()
if (!vars || vars.length === 0) {
console.log(color.gray(' No global environment variables set'))
@ -68,7 +68,7 @@ export async function envList(name: string | undefined, opts: { global?: boolean
return
}
console.log(color.bold().cyan(`Environment Variables for ${appName}`))
console.log(color.bold.cyan(`Environment Variables for ${appName}`))
console.log()
const appKeys = new Set(vars.map(v => v.key))

View File

@ -16,3 +16,4 @@ export {
unshareApp,
} from './manage'
export { metricsApp } from './metrics'
export { perfToggle } from './perf'

View File

@ -1,6 +1,6 @@
import type { App } from '@types'
import { generateTemplates, type TemplateType } from '%templates'
import color from 'kleur'
import color from 'ansis'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { basename, join } from 'path'
import { buildAppUrl } from '@urls'

View File

@ -1,4 +1,4 @@
import color from 'kleur'
import color from 'ansis'
import { get } from '../http'
import { resolveAppName } from '../name'

17
src/cli/commands/perf.ts Normal file
View File

@ -0,0 +1,17 @@
import color from 'ansis'
import { get, post } from '../http'
export async function perfToggle(onOff?: string) {
if (onOff && !['on', 'off', 'status'].includes(onOff)) {
console.error('Usage: toes perf [on|off|status]')
process.exit(1)
}
const body = onOff && onOff !== 'status' ? { on: onOff === 'on' } : {}
const res = onOff === 'status'
? await get<{ perfTiming: boolean }>('/api/system/perf')
: await post<{ perfTiming: boolean }>('/api/system/perf', body)
if (res) {
const label = res.perfTiming ? color.green('on') : color.red('off')
console.log(`Perf timing ${onOff === 'status' ? 'is ' : ''}${label}`)
}
}

View File

@ -1,11 +1,21 @@
#!/usr/bin/env bun
process.env.FORCE_COLOR = '1'
import { program } from './setup'
const isCliUser = process.env.USER === 'cli'
const noArgs = process.argv.length <= 2
const isTTY = !!process.stdin.isTTY
if (isCliUser && noArgs && isTTY) {
// SSH login shell passes commands as: toes -c "command args"
// With shebang, argv is [bun, script, -c, cmd]; compiled, it's [toes, -c, cmd]
const cIndex = process.argv[1] === '-c' ? 1 : process.argv[2] === '-c' ? 2 : -1
const shellExec = cIndex !== -1 ? process.argv.slice(cIndex + 1).join(' ') : null
if (shellExec) {
const tokens = shellExec.split(/\s+/).filter(Boolean)
program.parse(['node', 'toes', ...tokens])
} else if (isCliUser && noArgs && isTTY) {
const { shell } = await import('./shell')
await shell()
} else {

View File

@ -1,6 +1,6 @@
import { program } from 'commander'
import color from 'kleur'
import color from 'ansis'
import pkg from '../../package.json'
import { SHA } from './sha'
@ -16,14 +16,15 @@ import {
infoApp,
listApps,
logApp,
metricsApp,
newApp,
openApp,
perfToggle,
renameApp,
restartApp,
rmApp,
shareApp,
startApp,
metricsApp,
stopApp,
unshareApp,
} from './commands'
@ -33,7 +34,7 @@ program
.version(`v${pkg.version}-${SHA}`, '-v, --version')
.addHelpText('beforeAll', (ctx) => {
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')
}
return ''
})
@ -53,17 +54,25 @@ program
// Apps
program
.command('list')
.command('status')
.helpGroup('Apps:')
.description('List all apps')
.description('Show status of all apps, or details for a specific app')
.argument('[name]', 'app name (uses current directory if omitted)')
.option('-t, --tools', 'show only tools')
.option('-a, --apps', 'show only apps (exclude tools)')
.action((name?: string, options?: { apps?: boolean; tools?: boolean }) => {
if (name) return infoApp(name)
return listApps(options ?? {})
})
program
.command('list', { hidden: true })
.option('-t, --tools', 'show only tools')
.option('-a, --apps', 'show only apps (exclude tools)')
.action(listApps)
program
.command('info')
.helpGroup('Apps:')
.description('Show info for an app')
.command('info', { hidden: true })
.argument('[name]', 'app name (uses current directory if omitted)')
.action(infoApp)
@ -224,6 +233,13 @@ env
.option('-g, --global', 'remove a global variable')
.action(envRm)
program
.command('perf')
.helpGroup('Config:')
.description('Toggle request timing for proxied app requests')
.argument('[on|off|status]', 'enable, disable, or check status (toggles if omitted)')
.action(perfToggle)
// Shell
program
@ -234,4 +250,19 @@ program
await shell()
})
// Hide and disable commands that don't work over SSH
if (process.env.USER === 'cli') {
const disabled = ['shell', 'get', 'open']
for (const name of disabled) {
const cmd = program.commands.find((c) => c.name() === name)
if (!cmd) continue
cmd.helpInformation = () => ''
;(cmd as any)._hidden = true
cmd.action(() => {
console.error(`"${name}" is not available over SSH`)
process.exit(1)
})
}
}
export { program }

View File

@ -2,7 +2,7 @@ import type { App } from '@types'
import * as readline from 'readline'
import color from 'kleur'
import color from 'ansis'
import { get, handleError, HOST, withSignal } from './http'
import { program } from './setup'
@ -66,7 +66,7 @@ function getCommandNames(): string[] {
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(color.bold.cyan(' \u{1F43E} Toes') + ` ${HOST}`)
console.log()
return
}
@ -78,7 +78,7 @@ async function printBanner(): Promise<void> {
const visibleApps = apps.filter(a => !a.tool)
console.log()
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
console.log(color.bold.cyan(' \u{1F43E} Toes') + ` ${HOST}`)
console.log()
// App status line

71
src/client/ansi.ts Normal file
View File

@ -0,0 +1,71 @@
const ESC = /\x1b\[([0-9;]*)m/g
const STYLES: Record<number, string> = {
1: 'font-weight:bold',
2: 'opacity:0.7',
30: 'color:#666',
31: 'color:#f87171',
32: 'color:#4ade80',
33: 'color:#facc15',
34: 'color:#60a5fa',
35: 'color:#c084fc',
36: 'color:#22d3ee',
37: 'color:#e5e5e5',
90: 'color:#999',
91: 'color:#fca5a5',
92: 'color:#86efac',
93: 'color:#fde047',
94: 'color:#93c5fd',
95: 'color:#d8b4fe',
96: 'color:#67e8f9',
97: 'color:#fff',
}
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
export const stripAnsi = (s: string) =>
s.replace(/\x1b\[[0-9;]*m/g, '')
export function ansiToHtml(text: string): string {
if (!text.includes('\x1b')) return escape(text)
ESC.lastIndex = 0
let result = ''
let last = 0
let open = false
const styles: string[] = []
let match: RegExpExecArray | null
while ((match = ESC.exec(text)) !== null) {
result += escape(text.slice(last, match.index))
last = match.index + match[0].length
const codes = match[1] ? match[1].split(';').map(Number) : [0]
for (const code of codes) {
if (code === 0) {
styles.length = 0
if (open) { result += '</span>'; open = false }
} else if (code === 39) {
const filtered = styles.filter(s => !s.startsWith('color:'))
styles.length = 0
styles.push(...filtered)
if (open) { result += '</span>'; open = false }
} else if (STYLES[code]) {
styles.push(STYLES[code])
}
}
if (styles.length) {
if (open) result += '</span>'
result += `<span style="${styles.join(';')}">`
open = true
}
}
result += escape(text.slice(last))
if (open) result += '</span>'
return result
}

View File

@ -4,7 +4,7 @@ export const getLogDates = (name: string): Promise<string[]> =>
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
export const getSystemInfo = (): Promise<{ version: string, sha: string }> =>
export const getSystemInfo = (): Promise<{ version: string, sha: string, uptime: number }> =>
fetch('/api/system/info').then(r => r.json())
export const shareApp = (name: string) =>

View File

@ -1,5 +1,6 @@
import { define } from '@because/forge'
import type { App, LogLine as LogLineType } from '../../shared/types'
import { ansiToHtml, stripAnsi } from '../ansi'
import { getLogDates, getLogsForDate } from '../api'
import { isNarrow } from '../state'
import { LogLine, LogsContainer, LogsHeader, LogTime, Section, SectionTitle } from '../styles'
@ -9,21 +10,28 @@ import { update } from '../update'
type LogsState = {
dates: string[]
historicalLogs: string[]
liveLogs: LogLineType[]
loadingDates: boolean
loadingLogs: boolean
searchFilter: string
selectedDate: string
}
const MAX_LOGS = 100
const logsState = new Map<string, LogsState>()
let currentApp: App | null = null
let _logSource: EventSource | null = null
let _logSourceApp: string | null = null
let _logRenderQueued = false
const getState = (appName: string): LogsState => {
if (!logsState.has(appName)) {
logsState.set(appName, {
dates: [],
historicalLogs: [],
liveLogs: [],
loadingDates: false,
loadingLogs: false,
searchFilter: '',
@ -33,6 +41,46 @@ const getState = (appName: string): LogsState => {
return logsState.get(appName)!
}
function connectLogStream(appName: string) {
if (_logSource && _logSourceApp === appName) return
disconnectLogStream()
_logSourceApp = appName
const state = getState(appName)
state.liveLogs = []
_logSource = new EventSource(`/api/apps/${appName}/logs/stream`)
_logSource.onmessage = e => {
try {
const line = JSON.parse(e.data) as LogLineType
state.liveLogs = [...state.liveLogs.slice(-(MAX_LOGS - 1)), line]
// Debounce log renders to avoid overwhelming the browser
if (!_logRenderQueued) {
_logRenderQueued = true
requestAnimationFrame(() => {
_logRenderQueued = false
updateLogsContent()
})
}
} catch {}
}
_logSource.onerror = () => {
if (_logSource?.readyState === EventSource.CLOSED) {
_logSource = null
// Reconnect after a brief delay
setTimeout(() => {
if (_logSourceApp === appName) connectLogStream(appName)
}, 2000)
}
}
}
export function disconnectLogStream() {
_logSource?.close()
_logSource = null
_logSourceApp = null
}
const LogsControls = define('LogsControls', {
display: 'flex',
alignItems: 'center',
@ -91,7 +139,7 @@ function LogsContent() {
const state = getState(currentApp.name)
const isLive = state.selectedDate === 'live'
const filteredLiveLogs = filterLogs(currentApp.logs ?? [], state.searchFilter, l => l.text)
const filteredLiveLogs = filterLogs(state.liveLogs, state.searchFilter, l => stripAnsi(l.text))
const filteredHistoricalLogs = filterLogs(state.historicalLogs, state.searchFilter, l => l)
return (
@ -107,7 +155,7 @@ function LogsContent() {
filteredLiveLogs.map((line, i) => (
<LogLine key={i}>
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
<span>{line.text}</span>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(line.text) }} />
</LogLine>
))
) : (
@ -126,7 +174,7 @@ function LogsContent() {
<span style={{ color: theme('colors-textFaintest'), marginRight: 12 }}>
{line.match(/^\[([^\]]+)\]/)?.[1]?.split('T')[1]?.slice(0, 8) ?? '--:--:--'}
</span>
<span>{line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')}</span>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')) }} />
</LogLine>
))
) : (
@ -200,6 +248,9 @@ export function LogsSection({ app }: { app: App }) {
currentApp = app
const state = getState(app.name)
// Connect to per-app log stream for live logs
connectLogStream(app.name)
// Load dates on first render
if (state.dates.length === 0 && !state.loadingDates) {
loadDates(app.name)

View File

@ -19,6 +19,20 @@ import {
type UpdateInfo = { available: boolean, current: string, latest: string, commits: string[] }
function formatUptime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
parts.push(`${secs}s`)
return parts.join(' ')
}
function pollUntilBack(onBack: () => void, onTimeout?: () => void) {
let elapsed = 0
const poll = setInterval(async () => {
@ -41,6 +55,7 @@ function pollUntilBack(onBack: () => void, onTimeout?: () => void) {
export function SettingsPage({ render }: { render: () => void }) {
const [version, setVersion] = useState('')
const [sha, setSha] = useState('')
const [uptime, setUptime] = useState(0)
const [themeChoice, setThemeChoice] = useState(localStorage.getItem('theme') || 'system')
const [restarting, setRestarting] = useState(false)
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
@ -51,9 +66,16 @@ export function SettingsPage({ render }: { render: () => void }) {
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
setUptime(info.uptime)
})
}, [])
// Tick uptime every second
useEffect(() => {
const interval = setInterval(() => setUptime(u => u + 1000), 1000)
return () => clearInterval(interval)
}, [])
const goBack = () => {
navigate('/')
}
@ -73,6 +95,7 @@ export function SettingsPage({ render }: { render: () => void }) {
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
setUptime(info.uptime)
})
}
@ -175,9 +198,14 @@ export function SettingsPage({ render }: { render: () => void }) {
</Section>
<Section>
<SectionTitle>Server</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 14 }}>
<span>Uptime: {formatUptime(uptime)}</span>
</div>
<div style={{ marginTop: 12 }}>
<Button variant="danger" onClick={handleRestart} disabled={restarting}>
{restarting ? 'Restarting...' : 'Restart Server'}
</Button>
</div>
</Section>
</MainContent>
</Main>

View File

@ -1,3 +1,4 @@
import { ansiToHtml, stripAnsi } from '../ansi'
import { isNarrow } from '../state'
import {
LogApp,
@ -58,16 +59,17 @@ function parseLogText(text: string): { method?: string, path?: string, status?:
}
function LogLineEntry({ log }: { log: UnifiedLogLine }) {
const parsed = parseLogText(log.text)
const parsed = parseLogText(stripAnsi(log.text))
const statusColor = getStatusColor(parsed.status)
const narrow = isNarrow || undefined
return (
<LogEntry narrow={narrow}>
<LogTimestamp narrow={narrow}>{formatTime(log.time)}</LogTimestamp>
<LogApp narrow={narrow}>{log.app}</LogApp>
<LogText style={statusColor ? { color: statusColor } : undefined}>
{log.text}
</LogText>
<LogText
style={statusColor ? { color: statusColor } : undefined}
dangerouslySetInnerHTML={{ __html: ansiToHtml(log.text) }}
/>
</LogEntry>
)
}
@ -112,6 +114,8 @@ export function scrollLogsToBottom() {
})
}
let _logsRenderQueued = false
export function initUnifiedLogs() {
if (_source) return
_source = new EventSource('/api/system/logs/stream')
@ -119,9 +123,21 @@ export function initUnifiedLogs() {
try {
const line = JSON.parse(e.data) as UnifiedLogLine
_logs = [..._logs.slice(-(MAX_LOGS - 1)), line]
if (!_logsRenderQueued) {
_logsRenderQueued = true
requestAnimationFrame(() => {
_logsRenderQueued = false
renderLogs()
})
}
} catch {}
}
_source.onerror = () => {
if (_source?.readyState === EventSource.CLOSED) {
_source = undefined
setTimeout(initUnifiedLogs, 2000)
}
}
}
function LogsTabsBar() {

View File

@ -146,16 +146,30 @@ function VitalsContent() {
)
}
let _vitalsRenderQueued = false
export function initVitals() {
if (_source) return
_source = new EventSource('/api/system/metrics/stream')
_source.onmessage = e => {
try {
_metrics = JSON.parse(e.data)
if (!_vitalsRenderQueued) {
_vitalsRenderQueued = true
requestAnimationFrame(() => {
_vitalsRenderQueued = false
update('#vitals', <VitalsContent />)
updateTooltips(_metrics.apps)
})
}
} catch {}
}
_source.onerror = () => {
if (_source?.readyState === EventSource.CLOSED) {
_source = undefined
setTimeout(initVitals, 2000)
}
}
}
export function Vitals() {

View File

@ -53,8 +53,21 @@ narrowQuery.addEventListener('change', e => {
// Initialize router (sets initial state from URL and renders)
initRouter(render)
// SSE connection
// SSE connection with reconnection handling
let renderQueued = false
const queueRender = () => {
if (renderQueued) return
renderQueued = true
requestAnimationFrame(() => {
renderQueued = false
render()
})
}
function connectSSE() {
const events = new EventSource('/api/apps/stream')
events.onmessage = e => {
setApps(JSON.parse(e.data))
@ -62,5 +75,16 @@ events.onmessage = e => {
navigate('/')
}
render()
queueRender()
}
events.onerror = () => {
// EventSource auto-reconnects, but close and retry if it enters CLOSED state
if (events.readyState === EventSource.CLOSED) {
events.close()
setTimeout(connectSSE, 2000)
}
}
}
connectSSE()

View File

@ -1,3 +1,4 @@
import { disconnectLogStream } from './components/LogsSection'
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
let _render: () => void
@ -41,9 +42,11 @@ function route() {
setCurrentView('dashboard')
} else if (path === '/settings') {
setSelectedApp(null)
disconnectLogStream()
setCurrentView('settings')
} else {
setSelectedApp(null)
disconnectLogStream()
const segment = path.slice(1)
setDashboardTab(segment || 'urls')
setCurrentView('dashboard')

View File

@ -1 +1 @@
export * from './server'
export * from './tools'

View File

@ -1,4 +1,4 @@
import { APPS_DIR, TOES_DIR, TOES_URL, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
import { APPS_DIR, SERVER_PORT, TOES_DIR, TOES_URL, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
import { buildAppUrl } from '@urls'
import { isTunnelsAvailable, shareApp, unshareApp } from '../tunnels'
import type { App as BackendApp } from '$apps'
@ -23,15 +23,13 @@ function convert(app: BackendApp): SharedApp {
return { ...rest, pid: proc?.pid }
}
// SSE: full app state snapshots for the dashboard UI (every state change)
// SSE: app state snapshots for the dashboard UI (every state change)
// Logs are excluded to keep payloads small — use /:app/logs/stream for live logs
// For discrete lifecycle events consumed by app processes, see /api/events/stream
router.sse('/stream', (send) => {
let queue = Promise.resolve()
const broadcast = () => {
const apps: SharedApp[] = allApps().map(app => ({
...convert(app),
logs: app.logs,
}))
const apps: SharedApp[] = allApps().map(convert)
queue = queue.then(() => send(apps))
}
@ -147,9 +145,9 @@ router.post('/', async c => {
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: tmpDir, stdout: 'ignore', stderr: 'ignore' }).exited
await run(['git', 'init'])
await run(['git', 'init', '-b', 'main'])
await run(['git', 'add', '.'])
await run(['git', 'commit', '-m', 'init'])
await run(['git', '-c', 'user.name=toes', '-c', 'user.email=toes@localhost', 'commit', '-m', 'init'])
await run(['git', 'remote', 'add', 'toes', gitUrl(name)])
const exitCode = await run(['git', 'push', 'toes', 'main'])
@ -196,12 +194,11 @@ router.post('/:app/start', c => {
return c.json({ ok: true })
})
router.post('/:app/restart', c => {
router.post('/:app/restart', async c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
stopApp(appName)
startApp(appName)
await restartApp(appName)
return c.json({ ok: true })
})
@ -407,7 +404,7 @@ router.post('/:app/tunnel', c => {
if (app.state !== 'running') return c.json({ ok: false, error: 'App must be running to enable tunnel' }, 400)
shareApp(appName, app.port ?? 0)
shareApp(appName, app.static ? SERVER_PORT : (app.port ?? 0))
return c.json({ ok: true })
})

View File

@ -12,7 +12,7 @@ router.sse('/stream', (send) => {
queue = queue.then(() => send(...args))
}
const unsub = onEvent(event => safeSend(event))
const heartbeat = setInterval(() => safeSend('', 'ping'), 60_000)
const heartbeat = setInterval(() => safeSend({ type: 'ping' }, 'ping'), 60_000)
return () => {
clearInterval(heartbeat)
unsub()

View File

@ -1,4 +1,4 @@
import { APPS_DIR, allApps, emit, registerApp, removeApp, restartApp, startApp } from '$apps'
import { APPS_DIR, allApps, emit, registerApp, reloadApp, removeApp, restartApp, startApp } from '$apps'
import { computeHash, generateManifest } from '../sync'
import { loadGitignore } from '@gitignore'
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
@ -115,25 +115,14 @@ router.post('/apps/:app/reload', async c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App name required' }, 400)
emit({ type: 'app:reload', app: appName })
// Register new app or restart existing
const app = allApps().find(a => a.name === appName)
if (!app) {
// New app - register it
registerApp(appName)
} else if (app.state === 'running') {
// Existing app - restart it
try {
await restartApp(appName)
await reloadApp(appName)
} catch (e) {
return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500)
}
} else if (app.state === 'stopped' || app.state === 'invalid') {
// App not running - try to start it
startApp(appName)
return c.json({ error: `Failed to reload app: ${e instanceof Error ? e.message : String(e)}` }, 500)
}
emit({ type: 'app:reload', app: appName })
return c.json({ ok: true })
})

View File

@ -1,4 +1,5 @@
import { allApps, APPS_DIR, onChange } from '$apps'
import { perf } from '../proxy'
import { onHostLog } from '../tui'
import { Hype } from '@because/hype'
import { cpus, freemem, platform, totalmem } from 'os'
@ -103,7 +104,7 @@ let _appDiskCache: Record<string, number> = {}
let _appDiskLastUpdate = 0
const DISK_CACHE_TTL = 30000
function getAppMetrics(): Record<string, AppMetrics> {
async function getAppMetrics(): Promise<Record<string, AppMetrics>> {
const apps = allApps()
const running = apps.filter(a => a.proc?.pid)
const result: Record<string, AppMetrics> = {}
@ -117,8 +118,10 @@ function getAppMetrics(): Record<string, AppMetrics> {
if (pidToName.size > 0) {
try {
const pids = [...pidToName.keys()].join(',')
const ps = Bun.spawnSync(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids])
for (const line of ps.stdout.toString().split('\n')) {
const proc = Bun.spawn(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids], { stdout: 'pipe', stderr: 'ignore' })
const output = await new Response(proc.stdout).text()
await proc.exited
for (const line of output.split('\n')) {
const parts = line.trim().split(/\s+/)
if (parts.length < 3) continue
const pid = parseInt(parts[0]!, 10)
@ -135,12 +138,21 @@ function getAppMetrics(): Record<string, AppMetrics> {
if (now - _appDiskLastUpdate > DISK_CACHE_TTL) {
_appDiskLastUpdate = now
_appDiskCache = {}
for (const app of apps) {
const duResults = await Promise.all(
apps.map(async app => {
try {
const du = Bun.spawnSync(['du', '-sk', join(APPS_DIR, app.name)])
const kb = parseInt(du.stdout.toString().trim().split('\t')[0]!, 10)
if (kb) _appDiskCache[app.name] = kb * 1024
} catch {}
const proc = Bun.spawn(['du', '-sk', join(APPS_DIR, app.name)], { stdout: 'pipe', stderr: 'ignore' })
const output = await new Response(proc.stdout).text()
await proc.exited
const kb = parseInt(output.trim().split('\t')[0]!, 10)
return { name: app.name, bytes: kb ? kb * 1024 : 0 }
} catch {
return { name: app.name, bytes: 0 }
}
})
)
for (const { name, bytes } of duResults) {
if (bytes) _appDiskCache[name] = bytes
}
}
@ -154,26 +166,30 @@ function getAppMetrics(): Record<string, AppMetrics> {
}
// Get current system metrics
router.get('/metrics', c => {
router.get('/metrics', async c => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: getAppMetrics(),
apps: await getAppMetrics(),
}
return c.json(metrics)
})
// SSE stream for real-time metrics (updates every 2s)
router.sse('/metrics/stream', (send) => {
let queue = Promise.resolve()
const sendMetrics = () => {
queue = queue.then(async () => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: getAppMetrics(),
apps: await getAppMetrics(),
}
send(metrics)
await send(metrics)
})
}
// Initial send
@ -187,12 +203,13 @@ router.sse('/metrics/stream', (send) => {
// System info
const projectRoot = join(import.meta.dir, '../../..')
const startedAt = Date.now()
const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf-8'))
const sha = Bun.spawnSync(['git', 'rev-parse', '--short', 'HEAD'], { cwd: projectRoot }).stdout.toString().trim() || 'unknown'
let isUpdating = false
router.get('/info', c => {
return c.json({ version: pkg.version, sha })
return c.json({ version: pkg.version, sha, uptime: Date.now() - startedAt })
})
// Get recent unified logs
@ -268,6 +285,16 @@ onChange(collectLogs)
// Subscribe to host-level log messages
onHostLog(pushHostLog)
// Perf timing toggle
router.get('/perf', c => c.json({ perfTiming: perf.timing }))
router.post('/perf', async c => {
const body = await c.req.json<{ on?: boolean }>().catch(() => ({}))
const on = body.on ?? !perf.timing
perf.timing = on
console.log(`[perf] timing ${on ? 'enabled' : 'disabled'}`)
return c.json({ perfTiming: on })
})
// Restart server (systemd brings it back)
router.post('/restart', c => {
setTimeout(() => process.exit(0), 100)

View File

@ -3,7 +3,7 @@ import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events'
import type { Subprocess } from 'bun'
import { DEFAULT_EMOJI, VALID_NAME } from '@types'
import { buildAppUrl, toSubdomain } from '@urls'
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, watch, writeFileSync } from 'fs'
import { LOCAL_HOST } from '%config'
import { join, resolve } from 'path'
import { loadAppEnv } from '../tools/env'
@ -18,8 +18,12 @@ export const TOES_DIR = process.env.TOES_DIR ?? join(process.env.DATA_DIR ?? '.'
const dataRoot = process.env.DATA_DIR ?? '.'
export const SERVER_PORT = Number(process.env.PORT || 3000)
const defaultHost = process.env.NODE_ENV === 'production' ? LOCAL_HOST : 'localhost'
export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}`
export const TOES_URL = process.env.TOES_URL ?? (process.env.NODE_ENV === 'production'
? `http://${defaultHost}`
: `http://${defaultHost}:${SERVER_PORT}`)
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
const HEALTH_CHECK_INTERVAL = 30000
@ -111,6 +115,7 @@ export async function initApps() {
setupShutdownHandlers()
rotateLogs()
discoverApps()
watchAppsDir()
runApps()
}
@ -157,12 +162,81 @@ export function registerApp(dir: string) {
if (_apps.has(dir)) return // Already registered
const { pkg, error } = loadApp(dir)
const state: AppState = error ? 'invalid' : 'stopped'
_apps.set(dir, buildApp(dir, pkg, state, error))
const isStatic = !!pkg.toes?.static
const state: AppState = error ? 'invalid' : (isStatic ? 'running' : 'stopped')
const app = buildApp(dir, pkg, state, error)
if (isStatic) app.started = Date.now()
_apps.set(dir, app)
update()
emit({ type: 'app:create', app: dir })
if (!error) {
if (!error && !isStatic) {
runApp(dir, getPort(dir))
} else if (isStatic) {
publishApp(dir)
openTunnelIfEnabled(dir, SERVER_PORT)
}
}
export async function reloadApp(dir: string) {
const app = _apps.get(dir)
if (!app) {
// New app — register it
registerApp(dir)
return
}
// Re-read config from disk
const { pkg, error } = loadApp(dir)
const wasStatic = app.static
const nowStatic = !!pkg.toes?.static
// Update cached metadata
app.icon = pkg.toes?.icon ?? DEFAULT_EMOJI
app.tool = pkg.toes?.tool
app.apps = pkg.toes?.apps
app.dashboard = pkg.toes?.dashboard
app.share = pkg.toes?.share
app.static = pkg.toes?.static
app.error = error
if (error) {
// App is now invalid
if (app.state === 'running' || app.state === 'starting') {
stopApp(dir)
}
app.state = 'invalid'
update()
return
}
if (nowStatic) {
// Stop process if transitioning from process-based to static
if (!wasStatic && (app.state === 'running' || app.state === 'starting')) {
stopApp(dir)
// Wait for stop
const maxWait = 10000
const poll = 100
let waited = 0
while (_apps.get(dir)?.state !== 'stopped' && waited < maxWait) {
await new Promise(r => setTimeout(r, poll))
waited += poll
}
}
// Mark as running (static apps are always "running")
app.state = 'running'
app.started = Date.now()
app.manuallyStopped = false
update()
publishApp(dir)
openTunnelIfEnabled(dir, SERVER_PORT)
return
}
// Process-based app — restart it
if (app.state === 'running' || app.state === 'starting') {
await restartApp(dir)
} else {
startApp(dir)
}
}
@ -225,6 +299,19 @@ export async function renameApp(oldName: string, newName: string): Promise<{ ok:
export function startApp(dir: string) {
const app = _apps.get(dir)
if (!app || (app.state !== 'stopped' && app.state !== 'invalid' && app.state !== 'error')) return
// Static apps don't need a process
if (app.static) {
app.state = 'running'
app.started = Date.now()
app.manuallyStopped = false
update()
emit({ type: 'app:start', app: dir })
publishApp(dir)
openTunnelIfEnabled(dir, SERVER_PORT)
return
}
if (!isApp(dir)) return
// Clear flags when explicitly starting
@ -238,6 +325,14 @@ export async function restartApp(dir: string): Promise<void> {
const app = _apps.get(dir)
if (!app) return
// Static apps just ensure running state
if (app.static) {
app.state = 'running'
app.started = Date.now()
update()
return
}
// Stop if running
if (app.state === 'running' || app.state === 'starting') {
stopApp(dir)
@ -265,6 +360,18 @@ export function stopApp(dir: string) {
const app = _apps.get(dir)
if (!app || app.state !== 'running') return
// Static apps just toggle state
if (app.static) {
app.state = 'stopped'
app.started = undefined
app.manuallyStopped = true
unpublishApp(dir)
closeTunnel(dir)
update()
emit({ type: 'app:stop', app: dir })
return
}
info(app, 'Stopping...')
app.state = 'stopping'
app.manuallyStopped = true
@ -304,6 +411,7 @@ const buildApp = (dir: string, pkg: any, state: AppState, error?: string): App =
apps: pkg.toes?.apps,
dashboard: pkg.toes?.dashboard,
share: pkg.toes?.share,
static: pkg.toes?.static,
})
const clearTimers = (app: App) => {
@ -338,27 +446,69 @@ const logFile = (appName: string, date: string = formatLogDate()) =>
const isApp = (dir: string): boolean =>
!loadApp(dir).error
let _updateTimer: Timer | undefined
const UPDATE_DEBOUNCE_MS = 100
export const update = () => {
setApps(allApps())
// Debounce SSE broadcasts to avoid overwhelming clients during rapid changes
if (_updateTimer) clearTimeout(_updateTimer)
_updateTimer = setTimeout(() => {
_updateTimer = undefined
_listeners.forEach(cb => cb())
}, UPDATE_DEBOUNCE_MS)
}
function allAppDirs() {
return readdirSync(APPS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory() && existsSync(join(APPS_DIR, e.name, 'package.json')))
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.map(e => e.name)
.sort()
}
function diffAppsDir() {
const known = new Set(_apps.keys())
const current = new Set(allAppDirs())
for (const dir of current) {
if (!known.has(dir)) {
hostLog(`Discovered new app: ${dir}`)
registerApp(dir)
}
}
for (const dir of known) {
if (!current.has(dir)) {
hostLog(`App directory removed: ${dir}`)
removeApp(dir)
}
}
}
function discoverApps() {
for (const dir of allAppDirs()) {
const { pkg, error } = loadApp(dir)
const state: AppState = error ? 'invalid' : 'stopped'
_apps.set(dir, buildApp(dir, pkg, state, error))
const isStatic = !!pkg.toes?.static
const state: AppState = error ? 'invalid' : (isStatic ? 'running' : 'stopped')
const app = buildApp(dir, pkg, state, error)
if (isStatic) {
app.started = Date.now()
publishApp(dir)
openTunnelIfEnabled(dir, SERVER_PORT)
}
_apps.set(dir, app)
}
update()
}
function watchAppsDir() {
let debounce: Timer | undefined
watch(APPS_DIR, (_event, _filename) => {
clearTimeout(debounce)
debounce = setTimeout(diffAppsDir, 500)
})
}
function ensureLogDir(appName: string): string {
const dir = logDir(appName)
if (!existsSync(dir)) {
@ -471,6 +621,7 @@ async function killStaleProcesses() {
const pids = new Set<number>()
// Find processes listening on our port range
try {
const lsof = Bun.spawnSync(['lsof', '-ti', `:${MIN_PORT - 1}-${MAX_PORT}`])
const lsofOutput = lsof.stdout.toString().trim()
if (lsofOutput) {
@ -478,8 +629,12 @@ async function killStaleProcesses() {
if (pid && pid !== process.pid) pids.add(pid)
}
}
} catch {
// lsof not available (e.g. minimal Linux installs)
}
// Find orphaned "bun run toes" child app processes
try {
const pgrep = Bun.spawnSync(['pgrep', '-f', 'bun run toes'])
const pgrepOutput = pgrep.stdout.toString().trim()
if (pgrepOutput) {
@ -487,6 +642,9 @@ async function killStaleProcesses() {
if (pid && pid !== process.pid) pids.add(pid)
}
}
} catch {
// pgrep not available
}
if (pids.size === 0) return
@ -532,6 +690,8 @@ function loadApp(dir: string): LoadResult {
if (json.scripts?.toes) {
return { pkg: json }
} else if (hasPublicDir(dir)) {
return { pkg: { ...json, toes: { ...json.toes, static: true } } }
} else {
return { pkg: json, error: 'Missing scripts.toes in package.json' }
}
@ -540,10 +700,17 @@ function loadApp(dir: string): LoadResult {
return { pkg: {}, error }
}
} catch (e) {
// No package.json — check for pub/ directory (static site)
if (hasPublicDir(dir)) {
return { pkg: { toes: { static: true, icon: '📄' } } }
}
return { pkg: {}, error: 'Missing package.json' }
}
}
const hasPublicDir = (dir: string): boolean =>
existsSync(join(APPS_DIR, dir, 'pub'))
function maybeResetBackoff(app: App) {
if (app.started && Date.now() - app.started >= STABLE_RUN_TIME) {

View File

@ -4,7 +4,12 @@ import { LOCAL_HOST } from '%config'
import { networkInterfaces } from 'os'
import { hostLog } from './tui'
const MAX_REPUBLISH_DELAY = 30_000
const REPUBLISH_BASE_DELAY = 1_000
const _killed = new Set<string>()
const _publishers = new Map<string, Subprocess>()
const _republishAttempts = new Map<string, number>()
const isEnabled = process.env.NODE_ENV === 'production' && process.platform === 'linux'
@ -51,10 +56,12 @@ export function publishApp(name: string) {
})
_publishers.set(name, proc)
_republishAttempts.delete(name)
hostLog(`mDNS: published ${host} -> ${ip}`)
proc.exited.then(() => {
_publishers.delete(name)
if (!_killed.delete(name)) republish(name)
})
} catch {
hostLog(`mDNS: failed to publish ${host}`)
@ -64,9 +71,11 @@ export function publishApp(name: string) {
export function unpublishApp(name: string) {
if (!isEnabled) return
_republishAttempts.delete(name)
const proc = _publishers.get(name)
if (!proc) return
_killed.add(name)
proc.kill()
_publishers.delete(name)
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
@ -75,9 +84,20 @@ export function unpublishApp(name: string) {
export function unpublishAll() {
if (!isEnabled) return
_republishAttempts.clear()
for (const [name, proc] of _publishers) {
_killed.add(name)
proc.kill()
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
}
_publishers.clear()
}
function republish(name: string) {
const attempts = _republishAttempts.get(name) ?? 0
const delay = Math.min(REPUBLISH_BASE_DELAY * 2 ** attempts, MAX_REPUBLISH_DELAY)
_republishAttempts.set(name, attempts + 1)
hostLog(`mDNS: ${toSubdomain(name)}.${LOCAL_HOST} exited unexpectedly, retrying in ${delay}ms`)
setTimeout(() => publishApp(name), delay)
}

View File

@ -1,5 +1,8 @@
import type { Server, ServerWebSocket } from 'bun'
import { getAppBySubdomain } from '$apps'
import { serveStatic } from '$static'
export const perf = { timing: false }
export type { WsData }
@ -10,6 +13,7 @@ interface WsData {
port: number
path: string
protocols: string[]
headers: Record<string, string>
}
export function extractSubdomain(host: string): string | null {
@ -36,7 +40,16 @@ export function extractSubdomain(host: string): string | null {
export async function proxySubdomain(subdomain: string, req: Request): Promise<Response> {
const app = getAppBySubdomain(subdomain)
if (!app || app.state !== 'running' || !app.port) {
if (!app || app.state !== 'running') {
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
}
// Static apps: serve from pub/ directory
if (app.static) {
return serveStatic(app.name, req)
}
if (!app.port) {
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
}
@ -47,18 +60,27 @@ export async function proxySubdomain(subdomain: string, req: Request): Promise<R
const headers = new Headers(req.headers)
headers.set('host', `localhost:${app.port}`)
if (!headers.has('x-app-url')) {
headers.set('x-app-url', app.tunnelUrl ?? `${url.protocol}//${subdomain}.${url.hostname}`)
}
headers.delete('connection')
headers.delete('content-length')
headers.delete('keep-alive')
headers.delete('transfer-encoding')
try {
return await fetch(target, {
const shouldTime = perf.timing
const start = shouldTime ? performance.now() : 0
const res = await fetch(target, {
method: req.method,
headers,
body,
redirect: 'manual',
})
if (shouldTime) {
const ms = (performance.now() - start).toFixed(1)
console.log(`[perf] ${req.method} ${subdomain}${url.pathname}${res.status} in ${ms}ms`)
}
return res
} catch (e) {
console.error(`Proxy error for ${subdomain}:`, e)
return new Response(`App "${subdomain}" is not responding`, { status: 502 })
@ -77,10 +99,20 @@ export function proxyWebSocket(subdomain: string, req: Request, server: Server<W
const protocolHeader = req.headers.get('sec-websocket-protocol')
const protocols = protocolHeader ? protocolHeader.split(',').map(p => p.trim()) : []
const headers: Record<string, string> = {}
if (protocolHeader) headers['sec-websocket-protocol'] = protocolHeader
// Collect headers to forward to the upstream app
const forwardHeaders: Record<string, string> = {}
for (const name of ['cookie', 'authorization', 'x-app-url']) {
const value = req.headers.get(name)
if (value) forwardHeaders[name] = value
}
if (!forwardHeaders['x-app-url']) {
forwardHeaders['x-app-url'] = app.tunnelUrl ?? `${url.protocol}//${subdomain}.${url.hostname}`
}
const ok = server.upgrade(req, { data: { port: app.port, path, protocols } as WsData, headers })
const upgradeHeaders: Record<string, string> = {}
if (protocolHeader) upgradeHeaders['sec-websocket-protocol'] = protocolHeader
const ok = server.upgrade(req, { data: { port: app.port, path, protocols, headers: forwardHeaders } as WsData, headers: upgradeHeaders })
if (ok) return undefined
return new Response('WebSocket upgrade failed', { status: 500 })
}
@ -88,7 +120,10 @@ export function proxyWebSocket(subdomain: string, req: Request, server: Server<W
export const websocket = {
open(ws: ServerWebSocket<WsData>) {
const { port, path } = ws.data
const upstream = new WebSocket(`ws://localhost:${port}${path}`, ws.data.protocols)
const upstream = new WebSocket(`ws://localhost:${port}${path}`, {
headers: { ...ws.data.headers, host: `localhost:${port}` },
protocols: ws.data.protocols,
})
upstream.binaryType = 'arraybuffer'
upstreams.set(ws, upstream)

92
src/server/static.ts Normal file
View File

@ -0,0 +1,92 @@
import { APPS_DIR } from '$apps'
import { existsSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
export async function serveStatic(appName: string, req: Request): Promise<Response> {
const url = new URL(req.url)
const pathname = decodeURIComponent(url.pathname)
const pubDir = join(APPS_DIR, appName, 'pub')
// Resolve the file path, preventing directory traversal
const filePath = join(pubDir, pathname)
if (!filePath.startsWith(pubDir)) {
return new Response('Forbidden', { status: 403 })
}
// Directory: try index.html, then file listing
if (existsSync(filePath) && statSync(filePath).isDirectory()) {
const indexPath = join(filePath, 'index.html')
if (existsSync(indexPath)) {
return new Response(Bun.file(indexPath))
}
return fileListing(appName, pathname, filePath)
}
// Exact file match
if (existsSync(filePath)) {
return new Response(Bun.file(filePath))
}
// Clean URLs: try .html extension
const htmlPath = filePath + '.html'
if (existsSync(htmlPath)) {
return new Response(Bun.file(htmlPath))
}
return new Response('Not Found', { status: 404 })
}
function fileListing(appName: string, pathname: string, dirPath: string): Response {
const trail = pathname.endsWith('/') ? pathname : pathname + '/'
const entries = readdirSync(dirPath, { withFileTypes: true })
.filter(e => !e.name.startsWith('.'))
.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1
if (!a.isDirectory() && b.isDirectory()) return 1
return a.name.localeCompare(b.name)
})
const rows = entries.map(e => {
const display = e.isDirectory() ? `${e.name}/` : e.name
const href = `${trail}${e.name}`
const stat = statSync(join(dirPath, e.name))
const size = e.isDirectory() ? '—' : formatSize(stat.size)
return ` <tr><td><a href="${href}">${display}</a></td><td>${size}</td></tr>`
}).join('\n')
const parent = pathname !== '/'
? ` <tr><td><a href="${trail.replace(/[^/]+\/$/, '')}">..</a></td><td></td></tr>\n`
: ''
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${appName} ${pathname}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.25rem; }
table { width: 100%; border-collapse: collapse; }
td { padding: 0.4rem 0.8rem; border-bottom: 1px solid #eee; }
td:last-child { text-align: right; color: #666; }
a { color: #0366d6; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>${appName}${pathname}</h1>
<table>
${parent}${rows}
</table>
</body>
</html>`
return new Response(html, { headers: { 'content-type': 'text/html' } })
}
const formatSize = (bytes: number): string =>
bytes < 1024 ? `${bytes} B`
: bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / (1024 * 1024)).toFixed(1)} MB`

View File

@ -177,6 +177,14 @@ function openTunnel(appName: string, port: number, subdomain?: string, isReconne
subdomain,
reconnect: false,
onRequest(req) {
const app = getApp(appName)
if (app?.tunnelUrl) req.headers['x-app-url'] = app.tunnelUrl
// Static apps are served by the main server via subdomain routing,
// so set the Host header so extractSubdomain() can identify the app
if (app?.static) req.headers['host'] = `${appName}.localhost`
},
onOpen(assignedSubdomain) {
hostLog(`Tunnel open: ${appName} -> ${assignedSubdomain}`)

View File

@ -32,6 +32,7 @@ export type App = {
apps?: boolean
dashboard?: boolean
share?: boolean
static?: boolean
tunnelEnabled?: boolean
tunnelUrl?: string
}

View File

@ -44,8 +44,10 @@ async function connect(url: string, signal: AbortSignal) {
for (const part of parts) {
const line = part.split('\n').find(l => l.startsWith('data:'))
if (!line) continue
const payload = line.slice(5).trim()
if (!payload) continue
try {
const event: ToesEvent = JSON.parse(line.slice(5).trim())
const event: ToesEvent = JSON.parse(payload)
_listeners.forEach(l => {
if (l.types.includes(event.type)) l.callback(event)
})

View File

@ -4,3 +4,4 @@ export { loadAppEnv } from './env'
export type { ToesEvent, ToesEventType } from './events'
export { on } from './events'
export { baseStyles, ToolScript } from './scripts.tsx'
export { appUrl } from './url'

2
src/tools/url.ts Normal file
View File

@ -0,0 +1,2 @@
export const appUrl = (req: Request): string =>
req.headers.get('x-app-url') ?? process.env.APP_URL!