Compare commits
No commits in common. "main" and "main" have entirely different histories.
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
||||||
.sandlot/
|
.sandlot/
|
||||||
.rev/
|
|
||||||
|
|
||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
|
|
|
||||||
110
CLAUDE.md
110
CLAUDE.md
|
|
@ -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.
|
||||||
|
|
|
||||||
87
README.md
87
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
17
apps/env/index.tsx
vendored
|
|
@ -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'))
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
39
bun.lock
39
bun.lock
|
|
@ -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
45
color-preview.sh
Executable 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 ""
|
||||||
106
docs/GUIDE.md
106
docs/GUIDE.md
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
60
install/install.sh
Executable file → Normal 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}"
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
21
package.json
21
package.json
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/"
|
|
||||||
|
|
@ -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
70
scripts/deploy.sh
Executable 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 ""
|
||||||
|
|
@ -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)"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,3 @@ export {
|
||||||
unshareApp,
|
unshareApp,
|
||||||
} from './manage'
|
} from './manage'
|
||||||
export { metricsApp } from './metrics'
|
export { metricsApp } from './metrics'
|
||||||
export { perfToggle } from './perf'
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export * from './tools'
|
export * from './server'
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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'
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export const appUrl = (req: Request): string =>
|
|
||||||
req.headers.get('x-app-url') ?? process.env.APP_URL!
|
|
||||||
Loading…
Reference in New Issue
Block a user