Compare commits

..

1 Commits

Author SHA1 Message Date
38f4b13d3e Update README.md 2026-03-10 09:52:38 -07:00
57 changed files with 447 additions and 1621 deletions

1
.gitignore vendored
View File

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

110
CLAUDE.md
View File

@ -16,43 +16,19 @@ 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** + **ansis** for CLI
- **Commander** + **kleur** for CLI
- TypeScript + Hono JSX
- Client renders with `hono/jsx/dom` (no build step, served directly)
## Running
```bash
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
bun run dev # Hot reload (deletes pub/client/index.js first)
bun run start # Production
bun run check # Type check
bun run test # Tests
```
## 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
```
@ -72,16 +48,10 @@ 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/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.
- `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.
- `shell.tsx` -- Minimal HTML shell for the SPA.
- `sync.ts` -- Re-exports `computeHash` and `generateManifest` from `%sync` (lib).
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY, plain logs otherwise). Debounced rendering at 50ms.
- `tunnels.ts` -- Public tunnel management via `@because/sneaker`. Persists tunnel config in `TOES_DIR/tunnels.json`. Auto-reconnects on drop with exponential backoff (max 10 attempts). Manages share/unshare lifecycle.
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY).
### Client (`src/client/`)
@ -92,11 +62,9 @@ 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.
- `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.
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection.
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs.
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout, logs, misc).
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout).
- `themes/` -- Light/dark theme token definitions.
### CLI (`src/cli/`)
@ -110,37 +78,30 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
- `pager.ts` -- Pipe output through system pager.
CLI commands:
- **Apps**: `status` (list or info), `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `share`, `unshare`, `logs`, `metrics`, `cron` (list/log/status/run)
- **Config**: `env` (list/set/rm, per-app or `--global`), `perf` (toggle request timing)
- **Hidden**: `list`, `info`, `log`, `version`, `shell`
Some commands (`shell`, `get`, `open`) are disabled when running over SSH (`USER=cli`).
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`, `share`, `unshare`
- **Config**: `env`
### Shared (`src/shared/`)
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`, `DEFAULT_EMOJI`, `VALID_NAME`
- `events.ts` -- `ToesEvent`, `ToesEventType`, `ToesEventInput` type definitions
- `urls.ts` -- `toSubdomain()` and `buildAppUrl()` for subdomain URL construction
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
### Lib (`src/lib/`)
Server-side code shared between CLI and server. Can use Node APIs.
- `config.ts` -- `HOSTNAME` and `LOCAL_HOST` (`hostname.local`) constants
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa), reads embedded templates from `templates.data.ts`
- `templates.data.ts` -- Generated file containing embedded template contents (built by `scripts/embed-templates.ts`)
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa)
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
### Tools Package (`src/tools/`)
The `@because/toes` package that apps/tools import. Published exports:
- `@because/toes` -- re-exports `computeHash`, `generateManifest`, `FileInfo`, `Manifest`, `VALID_NAME` (`src/index.ts` -> `src/tools/index.ts`)
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`, `on` (event subscription), `appUrl`, `VALID_NAME`
- `@because/toes` -- re-exports from server (`src/index.ts` -> `src/server/sync.ts`)
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`
### Pages (`src/pages/`)
@ -150,25 +111,17 @@ Hype page routes. `index.tsx` renders the Shell.
### App Lifecycle
States: `invalid` | `error` | `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
States: `invalid` -> `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
- Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes`
- Spawn: `Bun.spawn(['bun', 'run', 'toes'])` with `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR`, `DATA_ROOT`, `TOES_URL`, `TOES_DIR`, `NO_AUTOPORT`, plus per-app env vars from `loadAppEnv()`
- Startup: runs `bun install` first if `node_modules/` missing, then polls `/ok` every 500ms (30s timeout)
- Health checks: every 30s to `/ok` (5s timeout), 3 consecutive failures trigger restart
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), max 5 attempts, resets after 60s stable run. State becomes `error` after max attempts.
- 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
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
- On startup, kills stale processes on ports 3001-3100 and orphaned `bun run toes` processes
### Subdomain Proxy
Every app gets a subdomain: `<app>.localhost` (dev) or `<app>.hostname.local` (prod). The server's fetch handler (`index.tsx`) checks for subdomains first and proxies to the app's port. WebSocket connections are also proxied via Bun's `server.upgrade()` with upstream bridging. The `x-app-url` header is set so apps know their public URL.
### Tools vs Apps
Tools are apps with `"toes": { "tool": true }` in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server via subdomain: `/tool/:tool?app=foo` -> `http://<tool>.host/?app=foo`. Tool API calls can also be proxied: `/api/tools/:tool/*` -> `http://localhost:<toolPort>/*`.
The `apps` field in package.json controls whether a tool shows on app detail pages (`false` to hide). The `dashboard` field controls whether a tool shows on the dashboard landing page.
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`.
### Versioning
@ -180,14 +133,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`, `APP_URL`, `APPS_DIR`, `DATA_DIR` (per-app at `TOES_DIR/<name>`), `DATA_ROOT`, `TOES_DIR`, `TOES_URL`, `NO_AUTOPORT`.
The server sets these on each app process: `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, `DATA_DIR`.
### SSE Streaming
Two SSE endpoints serve different consumers:
- `/api/apps/stream` -- Full app state snapshots on every change. Used by the dashboard UI. Driven by `onChange()` in `apps.ts`.
- `/api/events/stream` -- Discrete lifecycle events (`app:start`, `app:stop`, `app:reload`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`.
- `/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`.
## Coding Guidelines
@ -259,21 +212,6 @@ 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,82 +1,56 @@
# 🐾 Toes
Personal web appliance you run on your home network.
Toes is a personal web appliance you run on your home network.
Plug it in, turn it on, and forget about the cloud.
## Development
## setup
```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
```
Toes runs on a Raspberry Pi. You'll need:
### Releasing
- A Raspberry Pi 5 running the latest Raspberry Pi OS
- A `toes` user with passwordless sudo
`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.
SSH into your Pi as the `toes` user and run:
```bash
curl -fsSL https://toes.dev/install | bash
```
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.
This will:
Dashboard: `http://<hostname>.local`
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
## Features
Once complete, visit `http://<hostname>.local` on your local network.
- 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
## features
## SSH CLI
- 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.
Manage your server from any machine on the network — no install required.
## cli configuration
```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.
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.18",
"@because/toes": "^0.0.8",
"croner": "^9.1.0"
},
"devDependencies": {

17
apps/env/index.tsx vendored
View File

@ -288,23 +288,6 @@ 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

@ -1 +0,0 @@
registry=https://npm.nose.space

View File

@ -7,7 +7,7 @@
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.12",
"@because/toes": "0.0.9",
},
"devDependencies": {
"@types/bun": "latest",
@ -22,15 +22,15 @@
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
"@because/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
"@because/sneaker": ["@because/sneaker@0.0.3", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.3.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-4cG8w/tYPGbDtLw89k1PiASJKfWUdd1NXv+GKad2d7Ckw3FpZ+dnN2+gR2ihs81dqAkNaZomo+9RznBju2WaOw=="],
"@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="],
"@because/toes": ["@because/toes@0.0.9", "https://npm.nose.space/@because/toes/-/toes-0.0.9.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.3", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-QkRTPLkPW8awH1DC0vqFR9oS3q+xOZ00eL6VuFElM1fatqHnLO8zKKJcyvy1pU8ZKS4Ev7F+OoyBzRuo6OTa/g=="],
"@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.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.3", "https://npm.nose.space/@types/node/-/node-25.3.3.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],

View File

@ -15,6 +15,26 @@ const TOES_URL = process.env.TOES_URL!
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')
const TOGGLE_SCRIPT = `
function toggleVisibility(btn) {
var repo = btn.dataset.repo;
var current = btn.dataset.visibility;
var next = current === 'public' ? 'private' : 'public';
btn.dataset.visibility = next;
btn.textContent = next;
btn.classList.toggle('public', next === 'public');
fetch('/api/visibility/' + encodeURIComponent(repo), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visibility: next })
}).catch(function() {
btn.dataset.visibility = current;
btn.textContent = current;
btn.classList.toggle('public', current === 'public');
});
}
`
const app = new Hype({ prettyHTML: false, layout: false })
const deployLocks = new Map<string, Promise<void>>()
@ -93,31 +113,6 @@ const RepoName = define('RepoName', {
color: theme('colors-text'),
})
const Tab = define('Tab', {
base: 'button',
padding: '6px 0',
background: 'none',
border: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
fontSize: '14px',
color: theme('colors-textMuted'),
states: {
':hover': { color: theme('colors-text') },
'.active': {
color: theme('colors-text'),
borderBottomColor: theme('colors-primary'),
fontWeight: '500',
},
},
})
const TabBar = define('TabBar', {
display: 'flex',
gap: '24px',
marginBottom: '20px',
})
const Toggle = define('Toggle', {
base: 'button',
display: 'inline-flex',
@ -161,8 +156,7 @@ interface LayoutProps {
interface RepoListPageProps {
baseUrl: string
external: boolean
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility; tool: boolean }>
tunnelUrl?: string
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility }>
}
type Visibility = 'public' | 'private'
@ -227,6 +221,24 @@ 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)
@ -531,56 +543,7 @@ function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
)
}
function RepoListItems({ baseUrl, external, repos, tunnelUrl }: {
baseUrl: string
external: boolean
repos: RepoListPageProps['repos']
tunnelUrl?: string
}) {
if (repos.length === 0) {
return <HelpText>No repositories yet.</HelpText>
}
return (
<RepoList>
{repos.map(({ name, commits, branch, visibility }) => (
<RepoItem>
<div>
<RepoName>{name}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{name}
</HelpText>
{!external && tunnelUrl && visibility === 'public' && (
<HelpText style="margin: 2px 0 0; font-size: 12px">
git clone {tunnelUrl}/{name}
</HelpText>
)}
</div>
<div style="display: flex; gap: 8px; align-items: center">
{!external && (
<Toggle
class={visibility === 'public' ? 'public' : ''}
data-repo={name}
data-visibility={visibility}
onclick="toggleVisibility(this)"
>
{visibility === 'public' ? 'public' : 'private'}
</Toggle>
)}
<Badge>{branch}</Badge>
{commits
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
: <Badge>empty</Badge>}
</div>
</RepoItem>
))}
</RepoList>
)
}
function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps) {
const appRepos = repos.filter(r => !r.tool)
const toolRepos = repos.filter(r => r.tool)
function RepoListPage({ baseUrl, external, repos }: RepoListPageProps) {
return (
<Layout title="Git">
{!external && (
@ -602,31 +565,38 @@ function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps
</>
)}
{repos.length > 0 && appRepos.length > 0 && toolRepos.length > 0 && (
{repos.length > 0 && (
<>
<Heading>Repositories</Heading>
<TabBar>
<Tab class="active" data-tab="tab-apps" onclick="switchTab(this)">Apps</Tab>
<Tab data-tab="tab-tools" onclick="switchTab(this)">Tools</Tab>
</TabBar>
<div>
<div id="tab-apps">
<RepoListItems baseUrl={baseUrl} external={external} repos={appRepos} tunnelUrl={tunnelUrl} />
</div>
<div id="tab-tools" style="display: none">
<RepoListItems baseUrl={baseUrl} external={external} repos={toolRepos} tunnelUrl={tunnelUrl} />
</div>
</div>
{!external && <script src="/client/toggle.js" />}
<script src="/client/tabs.js" />
</>
)}
{repos.length > 0 && (appRepos.length === 0 || toolRepos.length === 0) && (
<>
<Heading>Repositories</Heading>
<RepoListItems baseUrl={baseUrl} external={external} repos={repos} tunnelUrl={tunnelUrl} />
{!external && <script src="/client/toggle.js" />}
<RepoList>
{repos.map(({ name, commits, branch, visibility }) => (
<RepoItem>
<div>
<RepoName>{name}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{name}
</HelpText>
</div>
<div style="display: flex; gap: 8px; align-items: center">
{!external && (
<Toggle
class={visibility === 'public' ? 'public' : ''}
data-repo={name}
data-visibility={visibility}
onclick="toggleVisibility(this)"
>
{visibility === 'public' ? 'public' : 'private'}
</Toggle>
)}
<Badge>{branch}</Badge>
{commits
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
: <Badge>empty</Badge>}
</div>
</RepoItem>
))}
</RepoList>
{!external && <script src="/toggle.js" />}
</>
)}
@ -643,28 +613,6 @@ 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 })
@ -676,6 +624,9 @@ app.get('/styles.css', c =>
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
)
app.get('/toggle.js', c =>
c.text(TOGGLE_SCRIPT, 200, { 'Content-Type': 'application/javascript; charset=utf-8' }),
)
// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
@ -690,13 +641,8 @@ app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
return c.text('Invalid service', 400)
}
if (c.req.header('x-sneaker')) {
if (service === 'git-receive-pack') {
return c.text('Push access denied over sneaker', 403)
}
if (await getVisibility(repoParam) !== 'public') {
return c.text('Repository not found', 404)
}
if (service === 'git-receive-pack' && c.req.header('x-sneaker')) {
return c.text('Push access denied over sneaker', 403)
}
if (service === 'git-receive-pack') {
@ -720,10 +666,6 @@ app.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/git-upload-pack'], a
return c.text('Invalid repository name', 400)
}
if (c.req.header('x-sneaker') && await getVisibility(repoParam) !== 'public') {
return c.text('Repository not found', 404)
}
const bare = repoPath(repoParam)
if (!(await dirExists(bare))) {
return c.text('Repository not found', 404)
@ -806,9 +748,8 @@ app.post('/api/visibility/:repo', async c => {
app.get('/', async c => {
const appName = c.req.query('app')
const sneakerHost = c.req.header('x-sneaker')
const external = !!sneakerHost
const baseUrl = sneakerHost ? `https://${sneakerHost}` : APP_URL
const baseUrl = APP_URL
const external = !!c.req.header('x-sneaker')
// When viewing a specific app, only show that app's repo
if (appName) {
@ -823,19 +764,6 @@ app.get('/', async c => {
// No app selected — show all repos
const repos = await listRepos()
// Fetch all apps to determine which repos are tools
let toolSet = new Set<string>()
try {
const res = await fetch(`${TOES_URL}/api/apps`)
if (res.ok) {
const apps = await res.json() as Array<{ name: string; tool?: boolean | string }>
for (const a of apps) {
if (a.tool) toolSet.add(a.name)
}
}
} catch {}
const repoData = await Promise.all(repos.map(async name => {
const bare = repoPath(name)
const [commits, branch, visibility] = await Promise.all([
@ -843,7 +771,7 @@ app.get('/', async c => {
getDefaultBranch(bare),
getVisibility(name),
])
return { name, commits, branch, visibility, tool: toolSet.has(name) }
return { name, commits, branch, visibility }
}))
// Hide private repos from external (sneaker) requests
@ -851,19 +779,7 @@ app.get('/', async c => {
? repoData.filter(r => r.visibility === 'public')
: repoData
// Fetch tunnel URL for the git tool so we can show it for public repos
let tunnelUrl: string | undefined
if (!external) {
try {
const res = await fetch(`${TOES_URL}/api/apps/git`)
if (res.ok) {
const info = await res.json() as { tunnelUrl?: string }
tunnelUrl = info.tunnelUrl
}
} catch {}
}
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} tunnelUrl={tunnelUrl} />)
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} />)
})
export default app.defaults

View File

@ -11,7 +11,6 @@
"toes": {
"tool": true,
"dashboard": true,
"share": true,
"icon": "🔀"
},
"devDependencies": {
@ -23,6 +22,6 @@
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "0.0.12"
"@because/toes": "0.0.9"
}
}

View File

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

View File

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

View File

@ -5,48 +5,48 @@
"": {
"name": "@because/toes",
"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",
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.4",
"commander": "14.0.3",
"diff": "^8.0.4",
"diff": "^8.0.3",
"kleur": "^4.1.5",
},
"devDependencies": {
"@types/bun": "latest",
"@types/diff": "^8.0.0",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@because/forge": ["@because/forge@0.0.7", "https://npm.nose.space/@because/forge/-/forge-0.0.7.tgz", {}, "sha512-vrpo9/l3YbpJikr4eGNBbXDoXa1q0TtretyXwlJys/5qWEuHJJ3F8sFQ8SEeqWq3j0k9LQfS1278YE6a9mpv6g=="],
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
"@because/hype": ["@because/hype@0.0.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/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
"@because/sneaker": ["@because/sneaker@0.0.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/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
"@because/toes": ["@because/toes@0.0.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.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@types/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=="],
"@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=="],
"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=="],
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"diff": ["diff@8.0.4", "https://npm.nose.space/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"hono": ["hono@4.12.9", "https://npm.nose.space/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"hono": ["hono@4.11.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"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=="],
"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

@ -26,7 +26,6 @@ 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)
@ -682,25 +681,11 @@ toes metrics my-app # Single app
```bash
toes share my-app
# ↗ Sharing my-app... https://myapp.toes.space
# ↗ Sharing my-app... https://abc123.trycloudflare.com
```
**`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
@ -712,9 +697,8 @@ 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`). |
| `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local:3000`). |
| `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.
@ -748,92 +732,6 @@ 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:

View File

@ -1,81 +0,0 @@
# 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?

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

@ -8,27 +8,25 @@ set -euo pipefail
# Installs or updates toes on a Raspberry Pi.
# Must be run as the 'toes' user with passwordless sudo.
RELEASE_URL="https://toes.dev/release/latest.tar.gz"
REPO="https://git.nose.space/defunkt/toes"
DEST=~/toes
APPS_DIR=~/apps
DATA_DIR=~/data
# ── Helpers ──────────────────────────────────────────────
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
quiet() { "$@" > /dev/null 2>&1; }
info() { echo " ${d}>>${r} $1"; }
info() { echo ">> $1"; }
fail() { echo " ${y}ERROR:${r} $1" >&2; exit 1; }
fail() { echo "ERROR: $1" >&2; exit 1; }
# ── Preflight ────────────────────────────────────────────
echo ""
echo " ${d}╔══════════════════════════════════╗${r}"
echo " ${d}${r} ${b}🐾 toes${r} ${d}- personal web appliance ║${r}"
echo " ${d}╚══════════════════════════════════╝${r}"
echo " ╔══════════════════════════════════╗"
echo " ║ 🐾 toes - personal web appliance ║"
echo " ╚══════════════════════════════════╝"
echo ""
[ "$(whoami)" = "toes" ] || fail "Must be run as the 'toes' user."
@ -59,56 +57,59 @@ sudo ln -sf "$BUN" /usr/local/bin/bun
sudo setcap 'cap_net_bind_service=+ep' "$BUN"
# ── Download ─────────────────────────────────────────────
# ── Clone or pull ────────────────────────────────────────
info "Downloading toes"
mkdir -p "$DEST"
curl -fsSL "$RELEASE_URL" | tar xz --strip-components=1 -C "$DEST"
if [ -d "$DEST/.git" ]; then
info "Pulling latest toes"
git -C "$DEST" fetch origin main
git -C "$DEST" reset --hard origin/main
else
info "Cloning toes"
git clone "$REPO" "$DEST"
fi
# ── Directories ──────────────────────────────────────────
mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes"
# ── Dependencies ─────────────────────────────────────────
# ── Dependencies & build ─────────────────────────────────
cd "$DEST"
info "Installing dependencies"
quiet bun install
info "Building"
rm -rf "$DEST/dist"
quiet bun run build
# ── Bundled apps ─────────────────────────────────────────
REPOS_DIR="$DATA_DIR/repos"
mkdir -p "$REPOS_DIR"
info "Installing bundled apps"
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"
) &
pids+=("$!")
cp -a "$app_dir" "$APPS_DIR/$app"
quiet bun install --frozen-lockfile --cwd "$APPS_DIR/$app" || quiet bun install --cwd "$APPS_DIR/$app"
# Seed bare repo for git-based versioning
bare="$REPOS_DIR/$app.git"
quiet git -C "$APPS_DIR/$app" init -b main
quiet git -C "$APPS_DIR/$app" add -A
quiet git -C "$APPS_DIR/$app" -c user.name=toes -c user.email=toes@localhost commit -m "install"
if [ -d "$bare" ]; then
quiet git -C "$APPS_DIR/$app" push --force "$bare" main
else
quiet git clone --bare "$APPS_DIR/$app" "$bare"
quiet git -C "$bare" config http.receivepack true
fi
rm -rf "$APPS_DIR/$app/.git"
done
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"
@ -121,15 +122,12 @@ sudo systemctl restart toes
# ── Done ─────────────────────────────────────────────────
VERSION=$(grep '"version"' "$DEST/package.json" | head -1 | sed 's/.*"version": *"\(.*\)".*/\1/')
VERSION=$(git describe --tags --always 2>/dev/null || echo "unknown")
echo ""
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo " toes $VERSION is running!"
echo " http://$(hostname).local"
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}"
echo " Install the CLI on your local machine:"
echo " curl -fsSL http://$(hostname).local/install | bash"
echo ""

View File

@ -10,14 +10,6 @@ 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.19",
"version": "0.0.10",
"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,13 +40,15 @@
"@types/bun": "latest",
"@types/diff": "^8.0.0"
},
"peerDependencies": {
"typescript": "^5.9.3"
},
"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",
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.4",
"commander": "14.0.3",
"diff": "^8.0.4"
"diff": "^8.0.3",
"kleur": "^4.1.5"
}
}

View File

@ -1,36 +0,0 @@
#!/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,4 +16,3 @@ bun build src/client/index.tsx \
echo ">> Client bundle created at pub/client/index.js"
ls -lh pub/client/index.js

65
scripts/deploy.sh Executable file
View File

@ -0,0 +1,65 @@
#!/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
echo "=> Deployed to $SSH_HOST"
echo "=> Visit $URL"
echo ""
echo " Install the CLI on your local machine:"
echo " curl -fsSL $URL/install | bash"
echo ""

View File

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

View File

@ -1,66 +0,0 @@
#!/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,5 +11,11 @@ source "$ROOT_DIR/scripts/config.sh"
# Run remote install on the target
ssh "$SSH_HOST" bash <<'SCRIPT'
set -e
curl -fsSL https://toes.dev/install | sh
DEST="${DEST:-$HOME/toes}"
if [ -d "$DEST/.git" ]; then
cd "$DEST" && git pull
else
git clone https://git.nose.space/defunkt/toes "$DEST" && cd "$DEST"
fi
./scripts/install.sh
SCRIPT

View File

@ -4,11 +4,10 @@
#
# This script:
# 1. Creates a `cli` system user with /usr/local/bin/toes as shell
# 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
# 2. Sets an empty password on `cli` for passwordless SSH
# 3. Adds a Match block in sshd_config to allow empty passwords for `cli`
# 4. Adds /usr/local/bin/toes to /etc/shells
# 5. Restarts sshd
#
# Run as root on the toes machine.
# Usage: ssh cli@toes.local
@ -28,16 +27,11 @@ else
echo " cli user already exists"
fi
# 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
# 2. Set empty password
passwd -d cli
echo " Set empty password on cli"
# 4. Add Match block for cli user in sshd_config
# 3. Add Match block for cli user in sshd_config
if ! grep -q 'Match User cli' "$SSHD_CONFIG"; then
cat >> "$SSHD_CONFIG" <<EOF
@ -50,7 +44,7 @@ else
echo " sshd_config already has Match User cli block"
fi
# 5. Ensure /usr/local/bin/toes is in /etc/shells
# 4. Ensure /usr/local/bin/toes is in /etc/shells
if ! grep -q "^${TOES_SHELL}$" /etc/shells; then
echo "$TOES_SHELL" >> /etc/shells
echo " Added $TOES_SHELL to /etc/shells"
@ -64,7 +58,7 @@ if [ ! -f "$TOES_SHELL" ]; then
echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL"
fi
# 6. Restart sshd
# 5. 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 'ansis'
import color from 'kleur'
import { get, getSignal, handleError, makeUrl, post } from '../http'
import { resolveAppName } from '../name'

View File

@ -1,4 +1,4 @@
import color from 'ansis'
import color from 'kleur'
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,4 +16,3 @@ 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 'ansis'
import color from 'kleur'
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 'ansis'
import color from 'kleur'
import { get } from '../http'
import { resolveAppName } from '../name'

View File

@ -1,17 +0,0 @@
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,21 +1,11 @@
#!/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
// 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) {
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 'ansis'
import color from 'kleur'
import pkg from '../../package.json'
import { SHA } from './sha'
@ -16,15 +16,14 @@ import {
infoApp,
listApps,
logApp,
metricsApp,
newApp,
openApp,
perfToggle,
renameApp,
restartApp,
rmApp,
shareApp,
startApp,
metricsApp,
stopApp,
unshareApp,
} from './commands'
@ -34,7 +33,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 ''
})
@ -54,25 +53,17 @@ program
// Apps
program
.command('status')
.command('list')
.helpGroup('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 })
.description('List all apps')
.option('-t, --tools', 'show only tools')
.option('-a, --apps', 'show only apps (exclude tools)')
.action(listApps)
program
.command('info', { hidden: true })
.command('info')
.helpGroup('Apps:')
.description('Show info for an app')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(infoApp)
@ -233,13 +224,6 @@ 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
@ -250,19 +234,4 @@ 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 'ansis'
import color from 'kleur'
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

View File

@ -1,71 +0,0 @@
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, uptime: number }> =>
export const getSystemInfo = (): Promise<{ version: string, sha: string }> =>
fetch('/api/system/info').then(r => r.json())
export const shareApp = (name: string) =>

View File

@ -64,7 +64,7 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
</MainTitle>
<HeaderActions>
{(!app.tool || app.share) && (
{!app.tool && (
app.tunnelUrl
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
: app.tunnelEnabled

View File

@ -1,6 +1,5 @@
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'
@ -10,28 +9,21 @@ 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: '',
@ -41,46 +33,6 @@ 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',
@ -139,7 +91,7 @@ function LogsContent() {
const state = getState(currentApp.name)
const isLive = state.selectedDate === 'live'
const filteredLiveLogs = filterLogs(state.liveLogs, state.searchFilter, l => stripAnsi(l.text))
const filteredLiveLogs = filterLogs(currentApp.logs ?? [], state.searchFilter, l => l.text)
const filteredHistoricalLogs = filterLogs(state.historicalLogs, state.searchFilter, l => l)
return (
@ -155,7 +107,7 @@ function LogsContent() {
filteredLiveLogs.map((line, i) => (
<LogLine key={i}>
<LogTime>{new Date(line.time).toLocaleTimeString()}</LogTime>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(line.text) }} />
<span>{line.text}</span>
</LogLine>
))
) : (
@ -174,7 +126,7 @@ function LogsContent() {
<span style={{ color: theme('colors-textFaintest'), marginRight: 12 }}>
{line.match(/^\[([^\]]+)\]/)?.[1]?.split('T')[1]?.slice(0, 8) ?? '--:--:--'}
</span>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')) }} />
<span>{line.replace(/^\[[^\]]+\] \[[^\]]+\] /, '')}</span>
</LogLine>
))
) : (
@ -248,9 +200,6 @@ 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,20 +19,6 @@ 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 () => {
@ -55,7 +41,6 @@ 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)
@ -66,16 +51,9 @@ 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('/')
}
@ -95,7 +73,6 @@ export function SettingsPage({ render }: { render: () => void }) {
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
setUptime(info.uptime)
})
}
@ -198,14 +175,9 @@ 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>
<Button variant="danger" onClick={handleRestart} disabled={restarting}>
{restarting ? 'Restarting...' : 'Restart Server'}
</Button>
</Section>
</MainContent>
</Main>

View File

@ -1,4 +1,3 @@
import { ansiToHtml, stripAnsi } from '../ansi'
import { isNarrow } from '../state'
import {
LogApp,
@ -59,17 +58,16 @@ function parseLogText(text: string): { method?: string, path?: string, status?:
}
function LogLineEntry({ log }: { log: UnifiedLogLine }) {
const parsed = parseLogText(stripAnsi(log.text))
const parsed = parseLogText(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}
dangerouslySetInnerHTML={{ __html: ansiToHtml(log.text) }}
/>
<LogText style={statusColor ? { color: statusColor } : undefined}>
{log.text}
</LogText>
</LogEntry>
)
}
@ -114,8 +112,6 @@ export function scrollLogsToBottom() {
})
}
let _logsRenderQueued = false
export function initUnifiedLogs() {
if (_source) return
_source = new EventSource('/api/system/logs/stream')
@ -123,21 +119,9 @@ 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()
})
}
renderLogs()
} catch {}
}
_source.onerror = () => {
if (_source?.readyState === EventSource.CLOSED) {
_source = undefined
setTimeout(initUnifiedLogs, 2000)
}
}
}
function LogsTabsBar() {

View File

@ -146,30 +146,16 @@ 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)
})
}
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,38 +53,14 @@ narrowQuery.addEventListener('change', e => {
// Initialize router (sets initial state from URL and renders)
initRouter(render)
// SSE connection with reconnection handling
let renderQueued = false
// SSE connection
const events = new EventSource('/api/apps/stream')
events.onmessage = e => {
setApps(JSON.parse(e.data))
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))
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
navigate('/')
}
queueRender()
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
navigate('/')
}
events.onerror = () => {
// EventSource auto-reconnects, but close and retry if it enters CLOSED state
if (events.readyState === EventSource.CLOSED) {
events.close()
setTimeout(connectSSE, 2000)
}
}
render()
}
connectSSE()

View File

@ -1,4 +1,3 @@
import { disconnectLogStream } from './components/LogsSection'
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
let _render: () => void
@ -42,11 +41,9 @@ 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 './tools'
export * from './server'

View File

@ -1,4 +1,4 @@
import { APPS_DIR, SERVER_PORT, TOES_DIR, TOES_URL, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
import { APPS_DIR, 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,13 +23,16 @@ function convert(app: BackendApp): SharedApp {
return { ...rest, pid: proc?.pid }
}
// 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
// SSE: full app state snapshots for the dashboard UI (every state change)
// 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(convert)
const apps: SharedApp[] = allApps().map(({
name, state, icon, error, port, started, logs, tool, apps: apps_, dashboard, tunnelEnabled, tunnelUrl
}) => ({
name, state, icon, error, port, started, logs, tool, apps: apps_, dashboard, tunnelEnabled, tunnelUrl,
}))
queue = queue.then(() => send(apps))
}
@ -145,9 +148,9 @@ router.post('/', async c => {
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: tmpDir, stdout: 'ignore', stderr: 'ignore' }).exited
await run(['git', 'init', '-b', 'main'])
await run(['git', 'init'])
await run(['git', 'add', '.'])
await run(['git', '-c', 'user.name=toes', '-c', 'user.email=toes@localhost', 'commit', '-m', 'init'])
await run(['git', 'commit', '-m', 'init'])
await run(['git', 'remote', 'add', 'toes', gitUrl(name)])
const exitCode = await run(['git', 'push', 'toes', 'main'])
@ -194,11 +197,12 @@ router.post('/:app/start', c => {
return c.json({ ok: true })
})
router.post('/:app/restart', async c => {
router.post('/:app/restart', c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404)
await restartApp(appName)
stopApp(appName)
startApp(appName)
return c.json({ ok: true })
})
@ -404,7 +408,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.static ? SERVER_PORT : (app.port ?? 0))
shareApp(appName, 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({ type: 'ping' }, 'ping'), 60_000)
const heartbeat = setInterval(() => safeSend('', 'ping'), 60_000)
return () => {
clearInterval(heartbeat)
unsub()

View File

@ -1,4 +1,4 @@
import { APPS_DIR, allApps, emit, registerApp, reloadApp, removeApp, restartApp, startApp } from '$apps'
import { APPS_DIR, allApps, emit, registerApp, 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,14 +115,25 @@ router.post('/apps/:app/reload', async c => {
const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App name required' }, 400)
try {
await reloadApp(appName)
} catch (e) {
return c.json({ error: `Failed to reload app: ${e instanceof Error ? e.message : String(e)}` }, 500)
}
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)
} 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({ ok: true })
})

View File

@ -1,5 +1,4 @@
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'
@ -104,7 +103,7 @@ let _appDiskCache: Record<string, number> = {}
let _appDiskLastUpdate = 0
const DISK_CACHE_TTL = 30000
async function getAppMetrics(): Promise<Record<string, AppMetrics>> {
function getAppMetrics(): Record<string, AppMetrics> {
const apps = allApps()
const running = apps.filter(a => a.proc?.pid)
const result: Record<string, AppMetrics> = {}
@ -118,10 +117,8 @@ async function getAppMetrics(): Promise<Record<string, AppMetrics>> {
if (pidToName.size > 0) {
try {
const pids = [...pidToName.keys()].join(',')
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 ps = Bun.spawnSync(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids])
for (const line of ps.stdout.toString().split('\n')) {
const parts = line.trim().split(/\s+/)
if (parts.length < 3) continue
const pid = parseInt(parts[0]!, 10)
@ -138,21 +135,12 @@ async function getAppMetrics(): Promise<Record<string, AppMetrics>> {
if (now - _appDiskLastUpdate > DISK_CACHE_TTL) {
_appDiskLastUpdate = now
_appDiskCache = {}
const duResults = await Promise.all(
apps.map(async app => {
try {
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
for (const app of apps) {
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 {}
}
}
@ -166,30 +154,26 @@ async function getAppMetrics(): Promise<Record<string, AppMetrics>> {
}
// Get current system metrics
router.get('/metrics', async c => {
router.get('/metrics', c => {
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: await getAppMetrics(),
apps: 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: await getAppMetrics(),
}
await send(metrics)
})
const metrics: SystemMetrics = {
cpu: getCpuUsage(),
ram: getMemoryUsage(),
disk: getDiskUsage(),
apps: getAppMetrics(),
}
send(metrics)
}
// Initial send
@ -203,13 +187,12 @@ 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, uptime: Date.now() - startedAt })
return c.json({ version: pkg.version, sha })
})
// Get recent unified logs
@ -285,16 +268,6 @@ 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, watch, writeFileSync } from 'fs'
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'
import { LOCAL_HOST } from '%config'
import { join, resolve } from 'path'
import { loadAppEnv } from '../tools/env'
@ -18,12 +18,8 @@ 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 ?? (process.env.NODE_ENV === 'production'
? `http://${defaultHost}`
: `http://${defaultHost}:${SERVER_PORT}`)
export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}`
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
const HEALTH_CHECK_INTERVAL = 30000
@ -115,7 +111,6 @@ export async function initApps() {
setupShutdownHandlers()
rotateLogs()
discoverApps()
watchAppsDir()
runApps()
}
@ -162,81 +157,16 @@ export function registerApp(dir: string) {
if (_apps.has(dir)) return // Already registered
const { pkg, error } = loadApp(dir)
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)
const state: AppState = error ? 'invalid' : 'stopped'
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
const tool = pkg.toes?.tool
const apps = pkg.toes?.apps
const dashboard = pkg.toes?.dashboard
_apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard })
update()
emit({ type: 'app:create', app: dir })
if (!error && !isStatic) {
if (!error) {
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)
}
}
@ -299,19 +229,6 @@ 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
@ -325,14 +242,6 @@ 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)
@ -360,18 +269,6 @@ 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
@ -404,16 +301,6 @@ export function updateAppIcon(dir: string, icon: string) {
}
}
const buildApp = (dir: string, pkg: any, state: AppState, error?: string): App => ({
name: dir, state, error,
icon: pkg.toes?.icon ?? DEFAULT_EMOJI,
tool: pkg.toes?.tool,
apps: pkg.toes?.apps,
dashboard: pkg.toes?.dashboard,
share: pkg.toes?.share,
static: pkg.toes?.static,
})
const clearTimers = (app: App) => {
if (app.startupTimer) {
clearTimeout(app.startupTimer)
@ -446,69 +333,31 @@ 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)
_listeners.forEach(cb => cb())
}
function allAppDirs() {
return readdirSync(APPS_DIR, { withFileTypes: true })
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.filter(e => e.isDirectory() && existsSync(join(APPS_DIR, e.name, 'package.json')))
.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 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)
const state: AppState = error ? 'invalid' : 'stopped'
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
const tool = pkg.toes?.tool
const apps = pkg.toes?.apps
const dashboard = pkg.toes?.dashboard
_apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard })
}
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)) {
@ -621,29 +470,21 @@ 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) {
for (const pid of lsofOutput.split('\n').map(Number)) {
if (pid && pid !== process.pid) pids.add(pid)
}
const lsof = Bun.spawnSync(['lsof', '-ti', `:${MIN_PORT - 1}-${MAX_PORT}`])
const lsofOutput = lsof.stdout.toString().trim()
if (lsofOutput) {
for (const pid of lsofOutput.split('\n').map(Number)) {
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) {
for (const pid of pgrepOutput.split('\n').map(Number)) {
if (pid && pid !== process.pid) pids.add(pid)
}
const pgrep = Bun.spawnSync(['pgrep', '-f', 'bun run toes'])
const pgrepOutput = pgrep.stdout.toString().trim()
if (pgrepOutput) {
for (const pid of pgrepOutput.split('\n').map(Number)) {
if (pid && pid !== process.pid) pids.add(pid)
}
} catch {
// pgrep not available
}
if (pids.size === 0) return
@ -690,8 +531,6 @@ 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' }
}
@ -700,17 +539,10 @@ 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

@ -1,21 +1,19 @@
#!/usr/bin/env bash
set -e
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS" in
darwin) OS=macos ;;
linux) OS=linux ;;
*) echo "${y}Unsupported OS: $OS${r}" >&2; exit 1 ;;
*) echo "🐾 Unsupported OS: $OS" >&2; exit 1 ;;
esac
case "$ARCH" in
x86_64) ARCH=x64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) echo "${y}Unsupported arch: $ARCH${r}" >&2; exit 1 ;;
*) echo "🐾 Unsupported arch: $ARCH" >&2; exit 1 ;;
esac
BINARY="toes-${OS}-${ARCH}"
@ -30,18 +28,11 @@ else
fi
mkdir -p "$INSTALL_DIR"
echo ""
echo " ${b}🐾 toes cli${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " ${d}Fetching ${OS}/${ARCH}...${r}"
echo "🐾 Downloading toes CLI (${OS}/${ARCH})..."
curl -fsSL "$URL" -o "$INSTALL_DIR/toes"
chmod +x "$INSTALL_DIR/toes"
echo " ${g}Installed to${r} ${b}$INSTALL_DIR/toes${r}"
echo ""
echo "🐾 Installed toes to $INSTALL_DIR/toes"
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
echo " ${y}Add $INSTALL_DIR to your \$PATH, then:${r}"
echo "🐾 Add $INSTALL_DIR to your PATH to use toes globally"
fi
echo " Run ${c}toes${r} to get started."
echo ""

View File

@ -4,12 +4,7 @@ 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'
@ -56,12 +51,10 @@ 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}`)
@ -71,11 +64,9 @@ 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}`)
@ -84,20 +75,9 @@ 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,8 +1,5 @@
import type { Server, ServerWebSocket } from 'bun'
import { getAppBySubdomain } from '$apps'
import { serveStatic } from '$static'
export const perf = { timing: false }
export type { WsData }
@ -13,7 +10,6 @@ interface WsData {
port: number
path: string
protocols: string[]
headers: Record<string, string>
}
export function extractSubdomain(host: string): string | null {
@ -40,16 +36,7 @@ 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') {
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) {
if (!app || app.state !== 'running' || !app.port) {
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
}
@ -60,27 +47,18 @@ 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 {
const shouldTime = perf.timing
const start = shouldTime ? performance.now() : 0
const res = await fetch(target, {
return 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 })
@ -99,20 +77,10 @@ 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()) : []
// 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 headers: Record<string, string> = {}
if (protocolHeader) headers['sec-websocket-protocol'] = protocolHeader
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 })
const ok = server.upgrade(req, { data: { port: app.port, path, protocols } as WsData, headers })
if (ok) return undefined
return new Response('WebSocket upgrade failed', { status: 500 })
}
@ -120,10 +88,7 @@ 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}`, {
headers: { ...ws.data.headers, host: `localhost:${port}` },
protocols: ws.data.protocols,
})
const upstream = new WebSocket(`ws://localhost:${port}${path}`, ws.data.protocols)
upstream.binaryType = 'arraybuffer'
upstreams.set(ws, upstream)

View File

@ -1,92 +0,0 @@
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,14 +177,6 @@ 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

@ -31,8 +31,6 @@ export type App = {
tool?: boolean | string
apps?: boolean
dashboard?: boolean
share?: boolean
static?: boolean
tunnelEnabled?: boolean
tunnelUrl?: string
}

View File

@ -11,56 +11,36 @@ interface Listener {
const _listeners = new Set<Listener>()
let _abort: AbortController | undefined
let _source: EventSource | undefined
function ensureConnection() {
if (_abort) return
if (_source) return
const url = `${process.env.TOES_URL}/api/events/stream`
_abort = new AbortController()
connect(url, _abort.signal)
}
_source = new EventSource(url)
function closeConnection() {
if (_abort) {
_abort.abort()
_abort = undefined
_source.onerror = () => {
if (_source?.readyState === EventSource.CLOSED) {
console.warn('[toes] Event stream closed unexpectedly')
_source = undefined
}
}
_source.onmessage = (e) => {
try {
const event: ToesEvent = JSON.parse(e.data)
_listeners.forEach(l => {
if (l.types.includes(event.type)) l.callback(event)
})
} catch {
// Ignore malformed events
}
}
}
async function connect(url: string, signal: AbortSignal) {
while (!signal.aborted) {
try {
const res = await fetch(url, { signal })
if (!res.ok || !res.body) throw new Error(`SSE ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop()!
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(payload)
_listeners.forEach(l => {
if (l.types.includes(event.type)) l.callback(event)
})
} catch {
// Ignore malformed events
}
}
}
} catch (err) {
if (signal.aborted) return
console.warn('[toes] Event stream error, reconnecting...')
}
if (!signal.aborted) await new Promise(r => setTimeout(r, 2000))
function closeConnection() {
if (_source) {
_source.close()
_source = undefined
}
}

View File

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

View File

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