Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

51 changed files with 386 additions and 1361 deletions

1
.gitignore vendored
View File

@ -1,5 +1,4 @@
.sandlot/ .sandlot/
.rev/
# dependencies (bun install) # dependencies (bun install)
node_modules 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) - **Bun** runtime (not Node)
- **Hype** (custom HTTP framework wrapping Hono) from `@because/hype` - **Hype** (custom HTTP framework wrapping Hono) from `@because/hype`
- **Forge** (typed CSS-in-JS) from `@because/forge` - **Forge** (typed CSS-in-JS) from `@because/forge`
- **Commander** + **ansis** for CLI - **Commander** + **kleur** for CLI
- TypeScript + Hono JSX - TypeScript + Hono JSX
- Client renders with `hono/jsx/dom` (no build step, served directly) - Client renders with `hono/jsx/dom` (no build step, served directly)
## Running ## Running
```bash ```bash
bun run dev # Hot reload (rebuilds client bundle on change) bun run dev # Hot reload (deletes pub/client/index.js first)
bun run start # Production (generates templates, then runs server) bun run start # Production
bun run check # Type check bun run check # Type check
bun run test # Tests bun run test # Tests
bun run build # Build client JS bundle (pub/client/index.js)
bun run release # Build release tarball for the Pi
``` ```
## Building & Releasing
`bun run release` (runs `scripts/release.sh`) produces a self-contained tarball the Pi can install without building anything:
1. Client JS bundle → `pub/client/index.js`
2. Embedded templates → `src/lib/templates.data.ts` (generated from `templates/` by `scripts/embed-templates.ts`)
3. Pre-built bare git repos for bundled apps → `dist/repos/*.git` (built by `scripts/build-repos.sh`)
4. Cross-compiled CLI binary → `dist/toes` (linux-arm64, built by `scripts/build.ts`)
5. Everything staged and packed into `dist/toes-<version>.tar.gz`
The Pi installer (`install/install.sh`) downloads this tarball, runs `bun install` for the server and all bundled apps (in parallel), copies pre-built repos and CLI binary into place, and starts the systemd service. No git commands, no compilation on the Pi.
### Key scripts
- `scripts/build.sh` -- Builds client JS bundle only (used during dev)
- `scripts/build-repos.sh` -- Pre-builds bare git repos for bundled apps (excludes node_modules, snapshots, logs)
- `scripts/release.sh` -- Full release pipeline: client + templates + repos + CLI → tarball
- `scripts/build.ts` -- CLI binary compiler (current platform or `--all` for cross-compile)
- `scripts/embed-templates.ts` -- Generates `src/lib/templates.data.ts` from `templates/`
- `install/install.sh` -- Pi installer, downloads release tarball and sets everything up
- `scripts/remote-install.sh` -- Runs the installer on a remote Pi over SSH
## Project Structure ## 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). - `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management. - `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create 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 reload (triggered by git tool after deploy), file watch SSE.
- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app delete, app reload (triggered by git tool after deploy), file watch SSE. - `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps.
- `api/system.ts` -- System info, metrics (CPU/RAM/disk per-app via `ps`/`du`), unified log aggregation, perf timing toggle, update check/apply, server restart.
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), on-demand CLI binary builds (`/dist/:file`), install script endpoint (`/install`), SPA catch-all routes, subdomain proxy (including WebSocket). Initializes apps.
- `mdns.ts` -- mDNS publishing via `avahi-publish` on Linux production. Publishes `<app>.hostname.local` A records pointing to local IP. Auto-republishes on unexpected exit with exponential backoff.
- `proxy.ts` -- Subdomain routing: extracts subdomain from `*.localhost` or `*.X.local`, proxies HTTP requests and WebSocket connections to the app's port. Sets `x-app-url` header. Optional perf timing.
- `shell.tsx` -- Minimal HTML shell for the SPA. - `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).
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY, plain logs otherwise). Debounced rendering at 50ms.
- `tunnels.ts` -- Public tunnel management via `@because/sneaker`. Persists tunnel config in `TOES_DIR/tunnels.json`. Auto-reconnects on drop with exponential backoff (max 10 attempts). Manages share/unshare lifecycle.
### Client (`src/client/`) ### Client (`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. - `api.ts` -- Fetch wrappers for server API calls.
- `tool-iframes.ts` -- Manages tool iframe lifecycle (caching, visibility, height communication). - `tool-iframes.ts` -- Manages tool iframe lifecycle (caching, visibility, height communication).
- `update.tsx` -- SSE connection to `/api/apps/stream` for real-time state updates. - `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). - `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection.
- `ansi.ts` -- ANSI escape code handling for log rendering.
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection, DashboardLanding, SettingsPage, Vitals, UnifiedLogs, Urls, emoji-picker, modal.
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs. - `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. - `themes/` -- Light/dark theme token definitions.
### CLI (`src/cli/`) ### 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. - `pager.ts` -- Pipe output through system pager.
CLI commands: CLI commands:
- **Apps**: `status` (list or info), `new`, `get`, `open`, `rename`, `rm` - **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `share`, `unshare`, `logs`, `metrics`, `cron` (list/log/status/run) - **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`, `share`, `unshare`
- **Config**: `env` (list/set/rm, per-app or `--global`), `perf` (toggle request timing) - **Config**: `env`
- **Hidden**: `list`, `info`, `log`, `version`, `shell`
Some commands (`shell`, `get`, `open`) are disabled when running over SSH (`USER=cli`).
### Shared (`src/shared/`) ### Shared (`src/shared/`)
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser). Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`, `DEFAULT_EMOJI`, `VALID_NAME` - `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`
- `events.ts` -- `ToesEvent`, `ToesEventType`, `ToesEventInput` type definitions
- `urls.ts` -- `toSubdomain()` and `buildAppUrl()` for subdomain URL construction
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers) - `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
### Lib (`src/lib/`) ### Lib (`src/lib/`)
Server-side code shared between CLI and server. Can use Node APIs. Server-side code shared between CLI and server. Can use Node APIs.
- `config.ts` -- `HOSTNAME` and `LOCAL_HOST` (`hostname.local`) constants - `templates.ts` -- Template generation for `toes new` (bare, ssr, spa)
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa), reads embedded templates from `templates.data.ts`
- `templates.data.ts` -- Generated file containing embedded template contents (built by `scripts/embed-templates.ts`)
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools) - `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
### Tools Package (`src/tools/`) ### Tools Package (`src/tools/`)
The `@because/toes` package that apps/tools import. Published exports: 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` -- re-exports from server (`src/index.ts` -> `src/server/sync.ts`)
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`, `on` (event subscription), `appUrl`, `VALID_NAME` - `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`
### Pages (`src/pages/`) ### Pages (`src/pages/`)
@ -150,25 +111,17 @@ Hype page routes. `index.tsx` renders the Shell.
### App Lifecycle ### 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` - 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()` - Spawn: `Bun.spawn()` with `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, plus per-app env vars
- Startup: runs `bun install` first if `node_modules/` missing, then polls `/ok` every 500ms (30s timeout) - Health checks: every 30s to `/ok`, 3 consecutive failures trigger restart
- Health checks: every 30s to `/ok` (5s timeout), 3 consecutive failures trigger restart - Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), resets after 60s stable run
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), max 5 attempts, resets after 60s stable run. State becomes `error` after max attempts.
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL - 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 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>/*`. 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`.
The `apps` field in package.json controls whether a tool shows on app detail pages (`false` to hide). The `dashboard` field controls whether a tool shows on the dashboard landing page.
### Versioning ### Versioning
@ -180,14 +133,14 @@ Per-app env files in `TOES_DIR/env/`:
- `_global.env` -- shared by all apps - `_global.env` -- shared by all apps
- `<appname>.env` -- per-app overrides - `<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 ### SSE Streaming
Two SSE endpoints serve different consumers: 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/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 ## 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 ## Writing Apps and Tools
See `docs/GUIDE.md` for the guide to writing toes apps and tools. See `docs/GUIDE.md` for the guide to writing toes apps and tools.

View File

@ -1,82 +1,55 @@
# 🐾 Toes # 🐾 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. Plug it in, turn it on, and forget about the cloud.
## Development ## setup
```bash Toes runs on a Raspberry Pi. You'll need:
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
```
### 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: SSH into your Pi as the `toes` user and run:
1. Client JS bundle (`pub/client/index.js`)
2. Embedded templates (`src/lib/templates.data.ts`)
3. Pre-built bare git repos for bundled apps (`dist/repos/`)
4. Cross-compiled CLI binary for linux-arm64 (`dist/toes`)
Output: `dist/toes-<version>.tar.gz`
The Pi does zero building — it untars, runs `bun install`, and starts. Upload the tarball to wherever `RELEASE_URL` in `install/install.sh` points (currently `https://toes.dev/release/latest.tar.gz`).
### Scripts
| Script | What it does |
|--------|-------------|
| `scripts/build.sh` | Builds the client JS bundle into `pub/client/index.js` |
| `scripts/build-repos.sh` | Pre-builds bare git repos for bundled apps in `dist/repos/` |
| `scripts/release.sh` | Full release: client + templates + repos + CLI → tarball |
| `scripts/build.ts` | Builds the CLI binary (current platform or cross-compile) |
| `scripts/embed-templates.ts` | Generates `src/lib/templates.data.ts` from `templates/` |
| `scripts/setup-ssh.sh` | Configures SSH access for the `cli` user on the Pi |
| `scripts/remote-install.sh` | Runs the installer on a remote Pi over SSH |
## Setup
Toes runs on a Raspberry Pi 5 with a `toes` user and passwordless sudo.
```bash ```bash
curl -fsSL https://toes.dev/install | 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) ## features
- `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
## 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 by default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
ssh cli@toes.local # interactive shell with tab completion
ssh cli@toes.local list # run a single command
ssh cli@toes.local logs fog # stream logs for an app
```
## CLI Configuration
By default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
```bash ```bash
toes config # show current host toes config # show current host
TOES_URL=http://192.168.1.50:3000 toes list # connect to IP TOES_URL=http://192.168.1.50:3000 toes list # connect to IP
TOES_URL=http://mypi.local toes list # connect to hostname TOES_URL=http://mypi.local toes list # connect to hostname
``` ```
## 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": { "dependencies": {
"@because/forge": "^0.0.1", "@because/forge": "^0.0.1",
"@because/hype": "^0.0.2", "@because/hype": "^0.0.2",
"@because/toes": "^0.0.18", "@because/toes": "^0.0.8",
"croner": "^9.1.0" "croner": "^9.1.0"
}, },
"devDependencies": { "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')) app.get('/ok', c => c.text('ok'))

View File

@ -227,6 +227,24 @@ async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }
return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` } 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 // Stop the app before swapping directories
await stopIfRunning(repoName) await stopIfRunning(repoName)
@ -643,28 +661,6 @@ function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps
mkdirSync(REPOS_DIR, { recursive: true }) 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 }) => { on('app:delete', async ({ app: name }) => {
const bare = repoPath(name) const bare = repoPath(name)
if (await dirExists(bare)) await rm(bare, { recursive: true, force: true }) if (await dirExists(bare)) await rm(bare, { recursive: true, force: true })

View File

@ -5,48 +5,51 @@
"": { "": {
"name": "@because/toes", "name": "@because/toes",
"dependencies": { "dependencies": {
"@because/forge": "^0.0.7", "@because/forge": "^0.0.1",
"@because/hype": "^0.0.9", "@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.5", "@because/sneaker": "^0.0.4",
"@because/toes": "^0.0.15", "@because/toes": "^0.0.12",
"ansis": "^4.2.0",
"commander": "14.0.3", "commander": "14.0.3",
"diff": "^8.0.4", "diff": "^8.0.3",
"kleur": "^4.1.5",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/diff": "^8.0.0", "@types/diff": "^8.0.0",
}, },
"peerDependencies": {
"typescript": "^5.9.3",
},
}, },
}, },
"packages": { "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=="], "@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="],
"@types/bun": ["@types/bun@1.3.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/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.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.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "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=="], "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=="], "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=="],
} }

45
color-preview.sh Executable file
View File

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

View File

@ -26,7 +26,6 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
- [Sharing](#sharing) - [Sharing](#sharing)
- [Environment Variables](#environment-variables-1) - [Environment Variables](#environment-variables-1)
- [Health Checks](#health-checks) - [Health Checks](#health-checks)
- [Running over HTTP](#running-over-http)
- [App Lifecycle](#app-lifecycle) - [App Lifecycle](#app-lifecycle)
- [Cron Jobs](#cron-jobs) - [Cron Jobs](#cron-jobs)
- [Data Persistence](#data-persistence) - [Data Persistence](#data-persistence)
@ -682,25 +681,11 @@ toes metrics my-app # Single app
```bash ```bash
toes share my-app 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. **`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 ## 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. | | `PORT` | Assigned port (3001-3100). Your app must listen on this port. |
| `APPS_DIR` | Path to the apps directory on the server. | | `APPS_DIR` | Path to the apps directory on the server. |
| `DATA_DIR` | Per-app data directory for persistent storage. | | `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. | | `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. 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 ## App Lifecycle
Apps move through these states: 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?

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

@ -8,7 +8,7 @@ set -euo pipefail
# Installs or updates toes on a Raspberry Pi. # Installs or updates toes on a Raspberry Pi.
# Must be run as the 'toes' user with passwordless sudo. # 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 DEST=~/toes
APPS_DIR=~/apps APPS_DIR=~/apps
DATA_DIR=~/data DATA_DIR=~/data
@ -59,56 +59,59 @@ sudo ln -sf "$BUN" /usr/local/bin/bun
sudo setcap 'cap_net_bind_service=+ep' "$BUN" sudo setcap 'cap_net_bind_service=+ep' "$BUN"
# ── Download ───────────────────────────────────────────── # ── Clone or pull ────────────────────────────────────────
info "Downloading toes" if [ -d "$DEST/.git" ]; then
mkdir -p "$DEST" info "Pulling latest toes"
curl -fsSL "$RELEASE_URL" | tar xz --strip-components=1 -C "$DEST" git -C "$DEST" fetch origin main
git -C "$DEST" reset --hard origin/main
else
info "Cloning toes"
git clone "$REPO" "$DEST"
fi
# ── Directories ────────────────────────────────────────── # ── Directories ──────────────────────────────────────────
mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes" mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes"
# ── Dependencies ───────────────────────────────────────── # ── Dependencies & build ─────────────────────────────────
cd "$DEST" cd "$DEST"
info "Installing dependencies" info "Installing dependencies"
quiet bun install quiet bun install
info "Building"
rm -rf "$DEST/dist"
quiet bun run build
# ── Bundled apps ───────────────────────────────────────── # ── Bundled apps ─────────────────────────────────────────
REPOS_DIR="$DATA_DIR/repos" REPOS_DIR="$DATA_DIR/repos"
mkdir -p "$REPOS_DIR" mkdir -p "$REPOS_DIR"
info "Installing bundled apps" info "Installing bundled apps"
pids=()
for app_dir in "$DEST"/apps/*/; do for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir") app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue [ -f "$app_dir/package.json" ] || continue
echo " $app" echo " $app"
( cp -a "$app_dir" "$APPS_DIR/$app"
cp -a "$app_dir" "$APPS_DIR/$app" quiet bun install --frozen-lockfile --cwd "$APPS_DIR/$app" || quiet bun install --cwd "$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
pids+=("$!") 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 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 ────────────────────────────────────────────── # ── Systemd ──────────────────────────────────────────────
info "Installing toes service" info "Installing toes service"
@ -121,14 +124,13 @@ sudo systemctl restart toes
# ── Done ───────────────────────────────────────────────── # ── 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 ""
echo " ${b}${g}🐾 toes $VERSION is up!${r}" echo " ${b}${g}🐾 toes $VERSION is up!${r}"
echo " ${d}─────────────────────────────${r}" echo " ${d}─────────────────────────────${r}"
echo "" echo ""
echo " Dashboard: ${c}http://$(hostname).local${r}" echo " Dashboard: ${c}http://$(hostname).local${r}"
echo " SSH CLI: ${c}ssh cli@$(hostname).local${r}"
echo "" echo ""
echo " ${d}Grab the CLI:${r}" echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}" echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"

View File

@ -10,14 +10,6 @@ Bun.serve({
headers: { "content-type": "text/plain" }, 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 }) return new Response("404 Not Found", { status: 404 })
}, },
}) })

View File

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

70
scripts/deploy.sh Executable file
View File

@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -e
# Get absolute path of this script's directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
# Load config
source "$ROOT_DIR/scripts/config.sh"
git push origin main
# SSH to target: pull, build, sync apps, restart
ssh "$SSH_HOST" bash <<'SCRIPT'
set -e
DEST="${DEST:-$HOME/toes}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"
DATA_DIR="${DATA_DIR:-$HOME/data}"
REPOS_DIR="$DATA_DIR/repos"
cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build
echo "=> Syncing default apps..."
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
target="$APPS_DIR/$app"
mkdir -p "$target"
cp -a "$app_dir"/. "$target"/
echo " $app"
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
done
echo "=> Initializing bare repos..."
mkdir -p "$REPOS_DIR"
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
bare="$REPOS_DIR/$app.git"
if [ ! -d "$bare" ]; then
git init --bare -b main "$bare" > /dev/null
git -C "$bare" config http.receivepack true
fi
tmp=$(mktemp -d)
cp -a "$app_dir"/. "$tmp"/
git -C "$tmp" init -b main > /dev/null 2>&1
git -C "$tmp" add -A > /dev/null
git -C "$tmp" -c user.name=toes -c user.email=toes@localhost commit -m "deploy" > /dev/null 2>&1
git -C "$tmp" push --force "$bare" main > /dev/null 2>&1
rm -rf "$tmp"
echo " $app"
done
sudo systemctl restart toes.service
SCRIPT
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' r=$'\033[0m'
echo ""
echo " ${b}${g}🐾 Deployed${r} to ${b}$SSH_HOST${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}$URL${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL $URL/install | bash${r}"
echo ""

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 # Run remote install on the target
ssh "$SSH_HOST" bash <<'SCRIPT' ssh "$SSH_HOST" bash <<'SCRIPT'
set -e 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 SCRIPT

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { program } from 'commander' import { program } from 'commander'
import color from 'ansis' import color from 'kleur'
import pkg from '../../package.json' import pkg from '../../package.json'
import { SHA } from './sha' import { SHA } from './sha'
@ -16,15 +16,14 @@ import {
infoApp, infoApp,
listApps, listApps,
logApp, logApp,
metricsApp,
newApp, newApp,
openApp, openApp,
perfToggle,
renameApp, renameApp,
restartApp, restartApp,
rmApp, rmApp,
shareApp, shareApp,
startApp, startApp,
metricsApp,
stopApp, stopApp,
unshareApp, unshareApp,
} from './commands' } from './commands'
@ -34,7 +33,7 @@ program
.version(`v${pkg.version}-${SHA}`, '-v, --version') .version(`v${pkg.version}-${SHA}`, '-v, --version')
.addHelpText('beforeAll', (ctx) => { .addHelpText('beforeAll', (ctx) => {
if (ctx.command === program) { if (ctx.command === program) {
return color.bold.cyan('🐾 Toes') + color.gray(' - personal web appliance\n') return color.bold().cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
} }
return '' return ''
}) })
@ -54,25 +53,17 @@ program
// Apps // Apps
program program
.command('status') .command('list')
.helpGroup('Apps:') .helpGroup('Apps:')
.description('Show status of all apps, or details for a specific app') .description('List all apps')
.argument('[name]', 'app name (uses current directory if omitted)')
.option('-t, --tools', 'show only tools')
.option('-a, --apps', 'show only apps (exclude tools)')
.action((name?: string, options?: { apps?: boolean; tools?: boolean }) => {
if (name) return infoApp(name)
return listApps(options ?? {})
})
program
.command('list', { hidden: true })
.option('-t, --tools', 'show only tools') .option('-t, --tools', 'show only tools')
.option('-a, --apps', 'show only apps (exclude tools)') .option('-a, --apps', 'show only apps (exclude tools)')
.action(listApps) .action(listApps)
program program
.command('info', { hidden: true }) .command('info')
.helpGroup('Apps:')
.description('Show info for an app')
.argument('[name]', 'app name (uses current directory if omitted)') .argument('[name]', 'app name (uses current directory if omitted)')
.action(infoApp) .action(infoApp)
@ -233,13 +224,6 @@ env
.option('-g, --global', 'remove a global variable') .option('-g, --global', 'remove a global variable')
.action(envRm) .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 // Shell
program program
@ -250,19 +234,4 @@ program
await shell() 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 } export { program }

View File

@ -2,7 +2,7 @@ import type { App } from '@types'
import * as readline from 'readline' import * as readline from 'readline'
import color from 'ansis' import color from 'kleur'
import { get, handleError, HOST, withSignal } from './http' import { get, handleError, HOST, withSignal } from './http'
import { program } from './setup' import { program } from './setup'
@ -66,7 +66,7 @@ function getCommandNames(): string[] {
async function printBanner(): Promise<void> { async function printBanner(): Promise<void> {
const apps = await get<App[]>('/api/apps') const apps = await get<App[]>('/api/apps')
if (!apps) { if (!apps) {
console.log(color.bold.cyan(' \u{1F43E} Toes') + ` ${HOST}`) console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
console.log() console.log()
return return
} }
@ -78,7 +78,7 @@ async function printBanner(): Promise<void> {
const visibleApps = apps.filter(a => !a.tool) const visibleApps = apps.filter(a => !a.tool)
console.log() console.log()
console.log(color.bold.cyan(' \u{1F43E} Toes') + ` ${HOST}`) console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
console.log() console.log()
// App status line // 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[]> => export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json()) fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
export const getSystemInfo = (): Promise<{ version: string, sha: string, uptime: number }> => export const getSystemInfo = (): Promise<{ version: string, sha: string }> =>
fetch('/api/system/info').then(r => r.json()) fetch('/api/system/info').then(r => r.json())
export const shareApp = (name: string) => export const shareApp = (name: string) =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { disconnectLogStream } from './components/LogsSection'
import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state' import { setCurrentView, setDashboardTab, setMobileSidebar, setSelectedApp, setSelectedTab } from './state'
let _render: () => void let _render: () => void
@ -42,11 +41,9 @@ function route() {
setCurrentView('dashboard') setCurrentView('dashboard')
} else if (path === '/settings') { } else if (path === '/settings') {
setSelectedApp(null) setSelectedApp(null)
disconnectLogStream()
setCurrentView('settings') setCurrentView('settings')
} else { } else {
setSelectedApp(null) setSelectedApp(null)
disconnectLogStream()
const segment = path.slice(1) const segment = path.slice(1)
setDashboardTab(segment || 'urls') setDashboardTab(segment || 'urls')
setCurrentView('dashboard') 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 { buildAppUrl } from '@urls'
import { isTunnelsAvailable, shareApp, unshareApp } from '../tunnels' import { isTunnelsAvailable, shareApp, unshareApp } from '../tunnels'
import type { App as BackendApp } from '$apps' import type { App as BackendApp } from '$apps'
@ -23,13 +23,15 @@ function convert(app: BackendApp): SharedApp {
return { ...rest, pid: proc?.pid } return { ...rest, pid: proc?.pid }
} }
// SSE: app state snapshots for the dashboard UI (every state change) // SSE: full app state snapshots for the dashboard UI (every state change)
// Logs are excluded to keep payloads small — use /:app/logs/stream for live logs
// For discrete lifecycle events consumed by app processes, see /api/events/stream // For discrete lifecycle events consumed by app processes, see /api/events/stream
router.sse('/stream', (send) => { router.sse('/stream', (send) => {
let queue = Promise.resolve() let queue = Promise.resolve()
const broadcast = () => { const broadcast = () => {
const apps: SharedApp[] = allApps().map(convert) const apps: SharedApp[] = allApps().map(app => ({
...convert(app),
logs: app.logs,
}))
queue = queue.then(() => send(apps)) queue = queue.then(() => send(apps))
} }
@ -145,9 +147,9 @@ router.post('/', async c => {
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: tmpDir, stdout: 'ignore', stderr: 'ignore' }).exited 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', '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)]) await run(['git', 'remote', 'add', 'toes', gitUrl(name)])
const exitCode = await run(['git', 'push', 'toes', 'main']) const exitCode = await run(['git', 'push', 'toes', 'main'])
@ -194,11 +196,12 @@ router.post('/:app/start', c => {
return c.json({ ok: true }) return c.json({ ok: true })
}) })
router.post('/:app/restart', async c => { router.post('/:app/restart', c => {
const appName = c.req.param('app') const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404) if (!appName) return c.json({ error: 'App not found' }, 404)
await restartApp(appName) stopApp(appName)
startApp(appName)
return c.json({ ok: true }) return c.json({ ok: true })
}) })
@ -404,7 +407,7 @@ router.post('/:app/tunnel', c => {
if (app.state !== 'running') return c.json({ ok: false, error: 'App must be running to enable tunnel' }, 400) 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 }) return c.json({ ok: true })
}) })

View File

@ -12,7 +12,7 @@ router.sse('/stream', (send) => {
queue = queue.then(() => send(...args)) queue = queue.then(() => send(...args))
} }
const unsub = onEvent(event => safeSend(event)) const unsub = onEvent(event => safeSend(event))
const heartbeat = setInterval(() => safeSend({ type: 'ping' }, 'ping'), 60_000) const heartbeat = setInterval(() => safeSend('', 'ping'), 60_000)
return () => { return () => {
clearInterval(heartbeat) clearInterval(heartbeat)
unsub() 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 { computeHash, generateManifest } from '../sync'
import { loadGitignore } from '@gitignore' import { loadGitignore } from '@gitignore'
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs' 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') const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App name required' }, 400) 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 }) 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 }) return c.json({ ok: true })
}) })

View File

@ -1,5 +1,4 @@
import { allApps, APPS_DIR, onChange } from '$apps' import { allApps, APPS_DIR, onChange } from '$apps'
import { perf } from '../proxy'
import { onHostLog } from '../tui' import { onHostLog } from '../tui'
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { cpus, freemem, platform, totalmem } from 'os' import { cpus, freemem, platform, totalmem } from 'os'
@ -104,7 +103,7 @@ let _appDiskCache: Record<string, number> = {}
let _appDiskLastUpdate = 0 let _appDiskLastUpdate = 0
const DISK_CACHE_TTL = 30000 const DISK_CACHE_TTL = 30000
async function getAppMetrics(): Promise<Record<string, AppMetrics>> { function getAppMetrics(): Record<string, AppMetrics> {
const apps = allApps() const apps = allApps()
const running = apps.filter(a => a.proc?.pid) const running = apps.filter(a => a.proc?.pid)
const result: Record<string, AppMetrics> = {} const result: Record<string, AppMetrics> = {}
@ -118,10 +117,8 @@ async function getAppMetrics(): Promise<Record<string, AppMetrics>> {
if (pidToName.size > 0) { if (pidToName.size > 0) {
try { try {
const pids = [...pidToName.keys()].join(',') const pids = [...pidToName.keys()].join(',')
const proc = Bun.spawn(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids], { stdout: 'pipe', stderr: 'ignore' }) const ps = Bun.spawnSync(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids])
const output = await new Response(proc.stdout).text() for (const line of ps.stdout.toString().split('\n')) {
await proc.exited
for (const line of output.split('\n')) {
const parts = line.trim().split(/\s+/) const parts = line.trim().split(/\s+/)
if (parts.length < 3) continue if (parts.length < 3) continue
const pid = parseInt(parts[0]!, 10) const pid = parseInt(parts[0]!, 10)
@ -138,21 +135,12 @@ async function getAppMetrics(): Promise<Record<string, AppMetrics>> {
if (now - _appDiskLastUpdate > DISK_CACHE_TTL) { if (now - _appDiskLastUpdate > DISK_CACHE_TTL) {
_appDiskLastUpdate = now _appDiskLastUpdate = now
_appDiskCache = {} _appDiskCache = {}
const duResults = await Promise.all( for (const app of apps) {
apps.map(async app => { try {
try { const du = Bun.spawnSync(['du', '-sk', join(APPS_DIR, app.name)])
const proc = Bun.spawn(['du', '-sk', join(APPS_DIR, app.name)], { stdout: 'pipe', stderr: 'ignore' }) const kb = parseInt(du.stdout.toString().trim().split('\t')[0]!, 10)
const output = await new Response(proc.stdout).text() if (kb) _appDiskCache[app.name] = kb * 1024
await proc.exited } catch {}
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
} }
} }
@ -166,30 +154,26 @@ async function getAppMetrics(): Promise<Record<string, AppMetrics>> {
} }
// Get current system metrics // Get current system metrics
router.get('/metrics', async c => { router.get('/metrics', c => {
const metrics: SystemMetrics = { const metrics: SystemMetrics = {
cpu: getCpuUsage(), cpu: getCpuUsage(),
ram: getMemoryUsage(), ram: getMemoryUsage(),
disk: getDiskUsage(), disk: getDiskUsage(),
apps: await getAppMetrics(), apps: getAppMetrics(),
} }
return c.json(metrics) return c.json(metrics)
}) })
// SSE stream for real-time metrics (updates every 2s) // SSE stream for real-time metrics (updates every 2s)
router.sse('/metrics/stream', (send) => { router.sse('/metrics/stream', (send) => {
let queue = Promise.resolve()
const sendMetrics = () => { const sendMetrics = () => {
queue = queue.then(async () => { const metrics: SystemMetrics = {
const metrics: SystemMetrics = { cpu: getCpuUsage(),
cpu: getCpuUsage(), ram: getMemoryUsage(),
ram: getMemoryUsage(), disk: getDiskUsage(),
disk: getDiskUsage(), apps: getAppMetrics(),
apps: await getAppMetrics(), }
} send(metrics)
await send(metrics)
})
} }
// Initial send // Initial send
@ -203,13 +187,12 @@ router.sse('/metrics/stream', (send) => {
// System info // System info
const projectRoot = join(import.meta.dir, '../../..') const projectRoot = join(import.meta.dir, '../../..')
const startedAt = Date.now()
const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf-8')) 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' const sha = Bun.spawnSync(['git', 'rev-parse', '--short', 'HEAD'], { cwd: projectRoot }).stdout.toString().trim() || 'unknown'
let isUpdating = false let isUpdating = false
router.get('/info', c => { 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 // Get recent unified logs
@ -285,16 +268,6 @@ onChange(collectLogs)
// Subscribe to host-level log messages // Subscribe to host-level log messages
onHostLog(pushHostLog) 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) // Restart server (systemd brings it back)
router.post('/restart', c => { router.post('/restart', c => {
setTimeout(() => process.exit(0), 100) 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 type { Subprocess } from 'bun'
import { DEFAULT_EMOJI, VALID_NAME } from '@types' import { DEFAULT_EMOJI, VALID_NAME } from '@types'
import { buildAppUrl, toSubdomain } from '@urls' 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 { LOCAL_HOST } from '%config'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import { loadAppEnv } from '../tools/env' 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 ?? '.' 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' const defaultHost = process.env.NODE_ENV === 'production' ? LOCAL_HOST : 'localhost'
export const TOES_URL = process.env.TOES_URL ?? (process.env.NODE_ENV === 'production' export const TOES_URL = process.env.TOES_URL ?? `http://${defaultHost}:${process.env.PORT || 3000}`
? `http://${defaultHost}`
: `http://${defaultHost}:${SERVER_PORT}`)
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3 const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
const HEALTH_CHECK_INTERVAL = 30000 const HEALTH_CHECK_INTERVAL = 30000
@ -115,7 +111,6 @@ export async function initApps() {
setupShutdownHandlers() setupShutdownHandlers()
rotateLogs() rotateLogs()
discoverApps() discoverApps()
watchAppsDir()
runApps() runApps()
} }
@ -162,81 +157,12 @@ export function registerApp(dir: string) {
if (_apps.has(dir)) return // Already registered if (_apps.has(dir)) return // Already registered
const { pkg, error } = loadApp(dir) const { pkg, error } = loadApp(dir)
const isStatic = !!pkg.toes?.static const state: AppState = error ? 'invalid' : 'stopped'
const state: AppState = error ? 'invalid' : (isStatic ? 'running' : 'stopped') _apps.set(dir, buildApp(dir, pkg, state, error))
const app = buildApp(dir, pkg, state, error)
if (isStatic) app.started = Date.now()
_apps.set(dir, app)
update() update()
emit({ type: 'app:create', app: dir }) emit({ type: 'app:create', app: dir })
if (!error && !isStatic) { if (!error) {
runApp(dir, getPort(dir)) 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 +225,6 @@ export async function renameApp(oldName: string, newName: string): Promise<{ ok:
export function startApp(dir: string) { export function startApp(dir: string) {
const app = _apps.get(dir) const app = _apps.get(dir)
if (!app || (app.state !== 'stopped' && app.state !== 'invalid' && app.state !== 'error')) return 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 if (!isApp(dir)) return
// Clear flags when explicitly starting // Clear flags when explicitly starting
@ -325,14 +238,6 @@ export async function restartApp(dir: string): Promise<void> {
const app = _apps.get(dir) const app = _apps.get(dir)
if (!app) return if (!app) return
// Static apps just ensure running state
if (app.static) {
app.state = 'running'
app.started = Date.now()
update()
return
}
// Stop if running // Stop if running
if (app.state === 'running' || app.state === 'starting') { if (app.state === 'running' || app.state === 'starting') {
stopApp(dir) stopApp(dir)
@ -360,18 +265,6 @@ export function stopApp(dir: string) {
const app = _apps.get(dir) const app = _apps.get(dir)
if (!app || app.state !== 'running') return 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...') info(app, 'Stopping...')
app.state = 'stopping' app.state = 'stopping'
app.manuallyStopped = true app.manuallyStopped = true
@ -411,7 +304,6 @@ const buildApp = (dir: string, pkg: any, state: AppState, error?: string): App =
apps: pkg.toes?.apps, apps: pkg.toes?.apps,
dashboard: pkg.toes?.dashboard, dashboard: pkg.toes?.dashboard,
share: pkg.toes?.share, share: pkg.toes?.share,
static: pkg.toes?.static,
}) })
const clearTimers = (app: App) => { const clearTimers = (app: App) => {
@ -446,69 +338,27 @@ const logFile = (appName: string, date: string = formatLogDate()) =>
const isApp = (dir: string): boolean => const isApp = (dir: string): boolean =>
!loadApp(dir).error !loadApp(dir).error
let _updateTimer: Timer | undefined
const UPDATE_DEBOUNCE_MS = 100
export const update = () => { export const update = () => {
setApps(allApps()) setApps(allApps())
// Debounce SSE broadcasts to avoid overwhelming clients during rapid changes _listeners.forEach(cb => cb())
if (_updateTimer) clearTimeout(_updateTimer)
_updateTimer = setTimeout(() => {
_updateTimer = undefined
_listeners.forEach(cb => cb())
}, UPDATE_DEBOUNCE_MS)
} }
function allAppDirs() { function allAppDirs() {
return readdirSync(APPS_DIR, { withFileTypes: true }) 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) .map(e => e.name)
.sort() .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() { function discoverApps() {
for (const dir of allAppDirs()) { for (const dir of allAppDirs()) {
const { pkg, error } = loadApp(dir) const { pkg, error } = loadApp(dir)
const isStatic = !!pkg.toes?.static const state: AppState = error ? 'invalid' : 'stopped'
const state: AppState = error ? 'invalid' : (isStatic ? 'running' : 'stopped') _apps.set(dir, buildApp(dir, pkg, state, error))
const app = buildApp(dir, pkg, state, error)
if (isStatic) {
app.started = Date.now()
publishApp(dir)
openTunnelIfEnabled(dir, SERVER_PORT)
}
_apps.set(dir, app)
} }
update() update()
} }
function watchAppsDir() {
let debounce: Timer | undefined
watch(APPS_DIR, (_event, _filename) => {
clearTimeout(debounce)
debounce = setTimeout(diffAppsDir, 500)
})
}
function ensureLogDir(appName: string): string { function ensureLogDir(appName: string): string {
const dir = logDir(appName) const dir = logDir(appName)
if (!existsSync(dir)) { if (!existsSync(dir)) {
@ -621,29 +471,21 @@ async function killStaleProcesses() {
const pids = new Set<number>() const pids = new Set<number>()
// Find processes listening on our port range // Find processes listening on our port range
try { const lsof = Bun.spawnSync(['lsof', '-ti', `:${MIN_PORT - 1}-${MAX_PORT}`])
const lsof = Bun.spawnSync(['lsof', '-ti', `:${MIN_PORT - 1}-${MAX_PORT}`]) const lsofOutput = lsof.stdout.toString().trim()
const lsofOutput = lsof.stdout.toString().trim() if (lsofOutput) {
if (lsofOutput) { for (const pid of lsofOutput.split('\n').map(Number)) {
for (const pid of lsofOutput.split('\n').map(Number)) { if (pid && pid !== process.pid) pids.add(pid)
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 // Find orphaned "bun run toes" child app processes
try { const pgrep = Bun.spawnSync(['pgrep', '-f', 'bun run toes'])
const pgrep = Bun.spawnSync(['pgrep', '-f', 'bun run toes']) const pgrepOutput = pgrep.stdout.toString().trim()
const pgrepOutput = pgrep.stdout.toString().trim() if (pgrepOutput) {
if (pgrepOutput) { for (const pid of pgrepOutput.split('\n').map(Number)) {
for (const pid of pgrepOutput.split('\n').map(Number)) { if (pid && pid !== process.pid) pids.add(pid)
if (pid && pid !== process.pid) pids.add(pid)
}
} }
} catch {
// pgrep not available
} }
if (pids.size === 0) return if (pids.size === 0) return
@ -690,8 +532,6 @@ function loadApp(dir: string): LoadResult {
if (json.scripts?.toes) { if (json.scripts?.toes) {
return { pkg: json } return { pkg: json }
} else if (hasPublicDir(dir)) {
return { pkg: { ...json, toes: { ...json.toes, static: true } } }
} else { } else {
return { pkg: json, error: 'Missing scripts.toes in package.json' } return { pkg: json, error: 'Missing scripts.toes in package.json' }
} }
@ -700,17 +540,10 @@ function loadApp(dir: string): LoadResult {
return { pkg: {}, error } return { pkg: {}, error }
} }
} catch (e) { } 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' } return { pkg: {}, error: 'Missing package.json' }
} }
} }
const hasPublicDir = (dir: string): boolean =>
existsSync(join(APPS_DIR, dir, 'pub'))
function maybeResetBackoff(app: App) { function maybeResetBackoff(app: App) {
if (app.started && Date.now() - app.started >= STABLE_RUN_TIME) { if (app.started && Date.now() - app.started >= STABLE_RUN_TIME) {

View File

@ -4,12 +4,7 @@ import { LOCAL_HOST } from '%config'
import { networkInterfaces } from 'os' import { networkInterfaces } from 'os'
import { hostLog } from './tui' 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 _publishers = new Map<string, Subprocess>()
const _republishAttempts = new Map<string, number>()
const isEnabled = process.env.NODE_ENV === 'production' && process.platform === 'linux' const isEnabled = process.env.NODE_ENV === 'production' && process.platform === 'linux'
@ -56,12 +51,10 @@ export function publishApp(name: string) {
}) })
_publishers.set(name, proc) _publishers.set(name, proc)
_republishAttempts.delete(name)
hostLog(`mDNS: published ${host} -> ${ip}`) hostLog(`mDNS: published ${host} -> ${ip}`)
proc.exited.then(() => { proc.exited.then(() => {
_publishers.delete(name) _publishers.delete(name)
if (!_killed.delete(name)) republish(name)
}) })
} catch { } catch {
hostLog(`mDNS: failed to publish ${host}`) hostLog(`mDNS: failed to publish ${host}`)
@ -71,11 +64,9 @@ export function publishApp(name: string) {
export function unpublishApp(name: string) { export function unpublishApp(name: string) {
if (!isEnabled) return if (!isEnabled) return
_republishAttempts.delete(name)
const proc = _publishers.get(name) const proc = _publishers.get(name)
if (!proc) return if (!proc) return
_killed.add(name)
proc.kill() proc.kill()
_publishers.delete(name) _publishers.delete(name)
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`) hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
@ -84,20 +75,9 @@ export function unpublishApp(name: string) {
export function unpublishAll() { export function unpublishAll() {
if (!isEnabled) return if (!isEnabled) return
_republishAttempts.clear()
for (const [name, proc] of _publishers) { for (const [name, proc] of _publishers) {
_killed.add(name)
proc.kill() proc.kill()
hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`) hostLog(`mDNS: unpublished ${toSubdomain(name)}.${LOCAL_HOST}`)
} }
_publishers.clear() _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 type { Server, ServerWebSocket } from 'bun'
import { getAppBySubdomain } from '$apps' import { getAppBySubdomain } from '$apps'
import { serveStatic } from '$static'
export const perf = { timing: false }
export type { WsData } export type { WsData }
@ -13,7 +10,6 @@ interface WsData {
port: number port: number
path: string path: string
protocols: string[] protocols: string[]
headers: Record<string, string>
} }
export function extractSubdomain(host: string): string | null { 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> { export async function proxySubdomain(subdomain: string, req: Request): Promise<Response> {
const app = getAppBySubdomain(subdomain) const app = getAppBySubdomain(subdomain)
if (!app || app.state !== 'running') { if (!app || app.state !== 'running' || !app.port) {
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
}
// Static apps: serve from pub/ directory
if (app.static) {
return serveStatic(app.name, req)
}
if (!app.port) {
return new Response(`App "${subdomain}" not found or not running`, { status: 502 }) 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) const headers = new Headers(req.headers)
headers.set('host', `localhost:${app.port}`) 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('connection')
headers.delete('content-length')
headers.delete('keep-alive') headers.delete('keep-alive')
headers.delete('transfer-encoding') headers.delete('transfer-encoding')
try { try {
const shouldTime = perf.timing return await fetch(target, {
const start = shouldTime ? performance.now() : 0
const res = await fetch(target, {
method: req.method, method: req.method,
headers, headers,
body, body,
redirect: 'manual', 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) { } catch (e) {
console.error(`Proxy error for ${subdomain}:`, e) console.error(`Proxy error for ${subdomain}:`, e)
return new Response(`App "${subdomain}" is not responding`, { status: 502 }) 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 protocolHeader = req.headers.get('sec-websocket-protocol')
const protocols = protocolHeader ? protocolHeader.split(',').map(p => p.trim()) : [] const protocols = protocolHeader ? protocolHeader.split(',').map(p => p.trim()) : []
// Collect headers to forward to the upstream app const headers: Record<string, string> = {}
const forwardHeaders: Record<string, string> = {} if (protocolHeader) headers['sec-websocket-protocol'] = protocolHeader
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 upgradeHeaders: Record<string, string> = {} const ok = server.upgrade(req, { data: { port: app.port, path, protocols } as WsData, headers })
if (protocolHeader) upgradeHeaders['sec-websocket-protocol'] = protocolHeader
const ok = server.upgrade(req, { data: { port: app.port, path, protocols, headers: forwardHeaders } as WsData, headers: upgradeHeaders })
if (ok) return undefined if (ok) return undefined
return new Response('WebSocket upgrade failed', { status: 500 }) 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 = { export const websocket = {
open(ws: ServerWebSocket<WsData>) { open(ws: ServerWebSocket<WsData>) {
const { port, path } = ws.data const { port, path } = ws.data
const upstream = new WebSocket(`ws://localhost:${port}${path}`, { const upstream = new WebSocket(`ws://localhost:${port}${path}`, ws.data.protocols)
headers: { ...ws.data.headers, host: `localhost:${port}` },
protocols: ws.data.protocols,
})
upstream.binaryType = 'arraybuffer' upstream.binaryType = 'arraybuffer'
upstreams.set(ws, upstream) 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, subdomain,
reconnect: false, 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) { onOpen(assignedSubdomain) {
hostLog(`Tunnel open: ${appName} -> ${assignedSubdomain}`) hostLog(`Tunnel open: ${appName} -> ${assignedSubdomain}`)

View File

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

View File

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

View File

@ -4,4 +4,3 @@ export { loadAppEnv } from './env'
export type { ToesEvent, ToesEventType } from './events' export type { ToesEvent, ToesEventType } from './events'
export { on } from './events' export { on } from './events'
export { baseStyles, ToolScript } from './scripts.tsx' 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!