Compare commits

..

141 Commits

Author SHA1 Message Date
21e300df90 Show tabs only when both app/tool repos exist 2026-03-09 00:15:57 -07:00
267e4e59f7 Add tabs to separate apps and tool repos 2026-03-09 00:14:58 -07:00
946cdb1794 Show tunnel URL for public repos in git clone hints 2026-03-09 00:08:33 -07:00
0e943bda2a Use sneaker host header as base URL 2026-03-08 23:49:23 -07:00
eef2fabd71 share 2026-03-08 23:43:30 -07:00
b410a74d15 Refactor app building and simplify gitUrl 2026-03-08 23:38:15 -07:00
d9533032bc Use git URL from tunnel if available 2026-03-08 23:26:12 -07:00
e0347444aa Add share field to app type and show share button 2026-03-08 23:17:35 -07:00
423c9588da gotcha 2026-03-08 23:06:48 -07:00
ecae0b4a5c 0.0.12 2026-03-08 23:03:16 -07:00
f16201114e Replace EventSource with fetch-based SSE with reconnect 2026-03-08 23:03:00 -07:00
758ad67fd4 ok 2026-03-08 23:01:17 -07:00
711a9db55e o that 2026-03-08 23:00:13 -07:00
5954959208 0.0.11 2026-03-08 22:59:10 -07:00
0aa375f037 Add ANSI color and styling to shell scripts 2026-03-08 22:18:53 -07:00
98c09dd843 npmrc 2026-03-08 22:05:29 -07:00
26189e9e4d Bump @because/toes to 0.0.10 2026-03-08 22:04:26 -07:00
d3b6d97bb6 Simplify app git seeding in install.sh 2026-03-05 20:28:00 -08:00
27860c5e32 Init bare git repos for apps on install 2026-03-05 13:09:25 -08:00
0a8287970d Merge branch 'check-updates' 2026-03-05 13:03:41 -08:00
d2339b8d44 fix update spawn error handling and reset flag 2026-03-05 13:03:35 -08:00
61c0c90695 Delegate update to install/install.sh 2026-03-05 07:58:27 -08:00
08e1df544a fun 2026-03-05 07:58:02 -08:00
e119aed205 just in case 2026-03-05 07:52:09 -08:00
ad8ef49439 Merge branch 'dots-in-project-name' 2026-03-05 07:46:28 -08:00
fafef70a33 Allow uppercase letters in VALID_NAME regex 2026-03-05 07:46:07 -08:00
0af360cef2 Centralize VALID_NAME regex into shared types 2026-03-05 07:45:19 -08:00
079c13e311 upgrade hype in templates 2026-03-05 07:44:07 -08:00
dfdd5c89b4 sha in toes cli 2026-03-05 07:43:59 -08:00
e7dd220106 Allow dots in app and repo names 2026-03-04 19:25:51 -08:00
c2264c42fc Merge branch 'cli-version' 2026-03-04 19:09:58 -08:00
0ae4e6e9b2 Embed git SHA at build time via define flag 2026-03-04 19:09:46 -08:00
f1fc4fcde8 Add error handling and timeout to system operations 2026-03-04 19:09:30 -08:00
dfb865e433 Add git SHA to CLI version string 2026-03-04 19:07:30 -08:00
f54cc401dc real settings 2026-03-04 19:03:29 -08:00
Chris Wanstrath
b152e0d3e8 Add CLI installation instructions to deploy output 2026-03-04 15:38:46 -08:00
da0a67c159 Add post-install success message with CLI hint 2026-03-04 11:56:47 -08:00
845479fa91 idempotent install script 2026-03-04 11:55:16 -08:00
f8c5890e07 Add WiFi config and system info endpoints 2026-03-04 11:39:37 -08:00
8f74f9daa0 Merge branch 'install' 2026-03-03 17:14:00 -08:00
327c7fd35d Merge branch 'new-app' 2026-03-03 17:10:43 -08:00
93d913f278 Use local git port when available for git URL 2026-03-03 17:10:36 -08:00
aee5bb1099 refactor install.sh to match update.sh 2026-03-03 13:19:01 -08:00
7274910a26 Merge branch 'update-sh' 2026-03-03 13:17:05 -08:00
c0571978d2 Add font-family to modal overlay 2026-03-03 13:16:45 -08:00
14ac2ae471 Add install/update script 2026-03-03 13:14:55 -08:00
abdfaf8402 Merge branch 'delete-repo' 2026-03-03 13:11:49 -08:00
8be9fd7912 Rename rerenderModal to renderModal 2026-03-03 13:11:46 -08:00
0aba9bde63 Refactor event stream to use EventSource API 2026-03-03 12:54:02 -08:00
577bec0d5c Simplify modal rendering with dedicated DOM root 2026-03-03 12:46:20 -08:00
002f0a64ef Add template embedding to start script 2026-03-03 12:36:17 -08:00
a4a08bfe65 Merge branch 'git-public-private' 2026-03-03 12:36:07 -08:00
0f197849b6 Use classList.toggle instead of className replace 2026-03-03 12:36:06 -08:00
d29ab8e37f 0.0.10 2026-03-03 12:21:26 -08:00
c81513b0ea Add repo visibility toggle to git app 2026-03-03 08:05:27 -08:00
732b9944d6 Deny git push over sneaker header 2026-03-02 21:42:21 -08:00
03b4634e8b update sneaker 2026-03-02 21:39:11 -08:00
7a0a9fc731 Add template embedding and generation script 2026-03-02 21:35:13 -08:00
6dc7ad8608 Replace scripts/install.sh with install.sh delegate 2026-03-02 09:41:05 -08:00
61ccce7d32 update readme 2026-03-02 09:19:09 -08:00
e17580c366 Fix path normalization in safePath function 2026-03-01 22:37:46 -08:00
c081785d37 Remove version param, add path traversal protection 2026-03-01 22:32:31 -08:00
6e5d665846 Merge branch 'fix-cron-directory' 2026-03-01 22:31:47 -08:00
30ed9b6466 Merge branch 'remove-versions-tool'
# Conflicts:
#	install/install.sh
2026-03-01 22:27:11 -08:00
affd06bdee Add git to bundled apps installation 2026-03-01 22:26:49 -08:00
09f421ecb9 Remove current symlink indirection from cron paths 2026-03-01 22:24:02 -08:00
2dfb6de2ff Add versions app and remove from install scripts 2026-03-01 22:24:01 -08:00
f5c5102fc8 Use git init --bare -b main directly 2026-03-01 22:16:25 -08:00
18c585e6a6 Buffer request body to fix large push deadlock 2026-03-01 22:12:57 -08:00
360f4cedcf Remove kiosk mode, old deploy symlinks 2026-03-01 21:55:05 -08:00
aa167f5e29 Make remote:install idempotent 2026-03-01 21:50:13 -08:00
c1f280a41e you too 2026-03-01 21:48:45 -08:00
30b0ac1fc3 Replace stats with metrics, improve version detection 2026-03-01 21:47:15 -08:00
f475e1791e Fix deploy script quoting 2026-03-01 21:43:32 -08:00
c24c0fac45 Refactor env vars: rename DATA_DIR to DATA_ROOT, add APP_URL 2026-03-01 21:21:23 -08:00
baa3712fa2 Add getApp command and gitUrl helper 2026-03-01 14:57:39 -08:00
c0b48c03da update docs 2026-03-01 14:38:24 -08:00
d0290433f2 suuuuure 2026-03-01 13:35:39 -08:00
56db56976b re-do the whole thing on git 2026-03-01 13:29:01 -08:00
9b150543b0 fix dependency 2026-03-01 10:27:43 -08:00
613898395c 0.0.9 2026-03-01 10:26:30 -08:00
71091f20a1 Add emojis to URLs and Logs tabs 2026-03-01 10:16:39 -08:00
64d5295fde Merge branch 'global-tools' 2026-03-01 10:16:01 -08:00
2b06d9afdf fix 2026-03-01 10:15:56 -08:00
82c8fc42da Add dashboard support for tool apps with iframe embedding 2026-03-01 10:10:49 -08:00
b99dd16343 Add dashboard view for global env vars 2026-03-01 09:57:13 -08:00
52cf99b56d Replace global with apps and dashboard app properties 2026-03-01 09:48:36 -08:00
310994b77c Merge branch 'remove-repo' 2026-03-01 09:46:53 -08:00
0efc25834c Delete git repo on app removal via app:delete event 2026-03-01 09:45:50 -08:00
13fa2b202a .git 2026-03-01 09:42:22 -08:00
65d4fe85cf dont need it 2026-03-01 09:41:20 -08:00
8fba4cccba Merge branch 'app-isolation' 2026-03-01 09:40:45 -08:00
fdc14a5021 fix race condition 2026-03-01 09:40:35 -08:00
5e21323b54 Refactor git UI into reusable page components 2026-03-01 09:39:02 -08:00
c7f8f09ba9 Add global field to filter tool tabs 2026-03-01 09:35:05 -08:00
be7a7bd35b Remove bare git repo on app deletion 2026-03-01 09:35:02 -08:00
35341600c1 Add app-specific git repo view with push instructions 2026-03-01 09:34:58 -08:00
2046af1407 center metrics 2026-03-01 09:29:48 -08:00
4b920a247d Support bare and non-.git URL patterns 2026-03-01 09:01:47 -08:00
7ee9163f76 Merge branch 'ssh-cli-auto' 2026-02-28 23:04:54 -08:00
f9b67c03bb Remove caret from commander version pin 2026-02-28 23:04:48 -08:00
dd5d9254c0 Merge branch 'git' 2026-02-28 22:53:26 -08:00
01f23ace16 Add git HTTP server tool for push-to-deploy
A toes tool that implements the git smart HTTP protocol, allowing
users to push repos via `git push` and clone via `git clone`. On
receive, it extracts the code into a timestamped APPS_DIR directory
and activates it through the toes sync API.
2026-02-28 22:49:45 -08:00
5f1de651eb Use AsyncLocalStorage for abort signal propagation 2026-02-28 22:48:38 -08:00
460d625f60 Simplify SSH access via dedicated cli user 2026-02-28 22:38:39 -08:00
3ad7145229 dumb 2026-02-28 20:27:21 -08:00
a87f0a9651 Add abort signals; rename guest to toes-cli 2026-02-28 13:34:14 -08:00
d2b0eb410f fix tool iframes 2026-02-28 12:58:21 -08:00
ffe1df22e6 domain 2026-02-28 08:29:55 -08:00
7f82a37c63 toes.dev 2026-02-28 08:12:38 -08:00
6055b9798d Replace quickstart with detailed setup docs 2026-02-28 08:04:23 -08:00
f7397dc060 install 2026-02-27 20:40:30 -08:00
d69dc6ae9d Merge branch 'ssh-install' 2026-02-27 19:35:11 -08:00
4853ee4f7a Skip bundled app install if already exists 2026-02-27 19:35:06 -08:00
Chris Wanstrath
74f9062a89 fix reconnect 2026-02-27 15:35:49 -08:00
Chris Wanstrath
55316027c0 heartbeat 2026-02-27 15:14:43 -08:00
cfba207077 no no no 2026-02-27 07:40:19 -08:00
702019279a no 2026-02-27 07:29:23 -08:00
141622f86f Add test123 app and support tunnelUrl in Urls 2026-02-27 07:28:58 -08:00
526678e87a Add active variant flex column styles 2026-02-27 07:26:15 -08:00
dc570cc6e9 Add SSH shell and NSS guest user support 2026-02-27 07:25:46 -08:00
d29e306e61 Merge branch 'mobile' 2026-02-26 20:37:52 -08:00
671f51ca0c Replace app selector modal with mobile sidebar state 2026-02-26 20:37:50 -08:00
604ac96b30 Add paw print emoji to install banner 2026-02-26 20:28:07 -08:00
d082af4e33 Add width 100% to active style 2026-02-26 20:21:51 -08:00
9bce15b871 Add flex layout to LogsSection container 2026-02-26 19:59:37 -08:00
7ab27f2767 Replace chevron with hamburger menu for app selector 2026-02-26 19:58:42 -08:00
45b1903e6b Use URL-based routing instead of local state 2026-02-26 19:43:18 -08:00
68274d8651 Intercept link clicks for client-side routing 2026-02-26 18:49:48 -08:00
98a1c1ad97 Add client-side router, use URLs for navigation 2026-02-26 11:40:50 -08:00
6d02f1db3f Make stopped tiles link to app page instead of nowhere 2026-02-26 07:28:05 -08:00
b0c5a11cde Add hostname setup and kiosk mode to install 2026-02-25 20:44:05 -08:00
029e349c5b add toes installer and install server 2026-02-25 20:38:47 -08:00
1a71656508 app tiles 2026-02-25 20:33:02 -08:00
363a82a845 Add icon span and conditional URL/name display 2026-02-25 19:58:01 -08:00
271bf018b8 Add tabbed dashboard with URLs/Logs/Metrics views 2026-02-25 19:55:19 -08:00
Chris Wanstrath
488c643342 mkdir-p 2026-02-25 16:08:39 -08:00
Chris Wanstrath
8fc54bd349 deploy to any host 2026-02-25 16:06:19 -08:00
Chris Wanstrath
3cbb25a82a yeah 2026-02-25 15:35:01 -08:00
87d0ff50c1 Centralize hostname config in shared module 2026-02-25 12:55:41 -08:00
0499060676 Use dynamic hostname instead of toes.local 2026-02-25 12:11:43 -08:00
137 changed files with 3015 additions and 3400 deletions

3
.gitignore vendored
View File

@ -5,6 +5,9 @@ node_modules
pub/client/index.js
toes/
# generated
src/lib/templates.data.ts
# output
out
dist

View File

@ -47,8 +47,8 @@ Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
### Server (`src/server/`)
- `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create), `DELETE /:name`, `PUT /:name/rename`, `PUT /:name/icon`.
- `api/sync.ts` -- File sync protocol: manifest comparison, push/pull with hash-based diffing.
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management.
- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app reload (triggered by git tool after deploy), file watch SSE.
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps.
- `shell.tsx` -- Minimal HTML shell for the SPA.
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY).
@ -79,23 +79,22 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
CLI commands:
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`
- **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash`
- **Config**: `config`, `env`, `versions`, `history`, `rollback`
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`, `share`, `unshare`
- **Config**: `env`
### Shared (`src/shared/`)
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`
- `gitignore.ts` -- `.toesignore` pattern matching
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
### Lib (`src/lib/`)
Server-side code shared between CLI and server. Can use Node APIs.
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa)
- `sync.ts` -- Manifest generation, hash computation
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
### Tools Package (`src/tools/`)
@ -126,7 +125,7 @@ Tools are apps with `"toes": { "tool": true }` in package.json. From the server'
### Versioning
Apps live at `APPS_DIR/<name>/` with timestamped version directories and a `current` symlink. Push creates a new version; rollback moves the symlink.
Apps use git for version control. Each app has a bare git repo at `DATA_DIR/repos/<name>.git`. Deploying is a `git push` to the server's git tool, which extracts HEAD into `APPS_DIR/<name>/` and reloads the app. History, diffing, and rollback use standard git commands.
### Environment Variables
@ -215,4 +214,4 @@ function start(app: App): void {
## Writing Apps and Tools
See `docs/CLAUDE.md` for the guide to writing toes apps and tools.
See `docs/GUIDE.md` for the guide to writing toes apps and tools.

View File

@ -1,22 +1,38 @@
# 🐾 Toes
Toes is a personal web server you run in your home.
Toes is a personal web appliance you run on your home network.
Plug it in, turn it on, and forget about the cloud.
## quickstart
## setup
1. Plug in and turn on your Toes computer.
2. Connect to the **Toes Setup** WiFi network (password: **toessetup**).
A setup page will appear — choose your home WiFi and enter its password.
3. Visit https://toes.local to get started!
Toes runs on a Raspberry Pi. You'll need:
- A Raspberry Pi 5 running the latest Raspberry Pi OS
- A `toes` user with passwordless sudo
SSH into your Pi as the `toes` user and run:
```bash
curl -fsSL https://toes.dev/install | bash
```
This will:
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
Once complete, visit `http://<hostname>.local` on your local network.
## features
- Hosts bun/hono/hype webapps - both SSR and SPA.
- `toes` CLI for pushing and pulling from your server.
- `toes` CLI for local dev mode.
- Effortlessly hosts bun/hype webapps - both SSR and SPA.
- `git push`, Heroku-style deploys
- https://toes.local web UI for managing your projects.
- Per-branch staging environments for Claude.
- `toes` CLI for managing your projects.
## cli configuration
@ -28,8 +44,6 @@ TOES_URL=http://192.168.1.50:3000 toes list # connect to IP
TOES_URL=http://mypi.local toes list # connect to hostname
```
set `NODE_ENV=production` to default to `toes.local:80`.
## fun stuff
- textOS (TODO, more?)

View File

@ -3,11 +3,18 @@ import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, stat } from 'fs/promises'
import { readFileSync } from 'fs'
import { join, extname, basename } from 'path'
import { join, resolve, extname, basename } from 'path'
import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR!
const safePath = (base: string, ...segments: string[]) => {
const norm = resolve(base)
const full = resolve(norm, ...segments)
if (!full.startsWith(norm + '/') && full !== norm) return null
return full
}
const app = new Hype({ prettyHTML: false })
const Container = define('Container', {
@ -257,21 +264,15 @@ const fileMemoryScript = `
var params = new URLSearchParams(window.location.search);
var app = params.get('app');
var file = params.get('file');
var version = params.get('version') || 'current';
if (!app) return;
var key = 'code-app:' + app + ':' + version + ':file';
var key = 'code-app:' + app + ':file';
if (params.has('file')) {
// Explicit file param (even empty) - save it
if (file) localStorage.setItem(key, file);
else localStorage.removeItem(key);
} else {
// No file param - restore saved location
var saved = localStorage.getItem(key);
if (saved) {
var url = '/?app=' + encodeURIComponent(app);
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
url += '&file=' + encodeURIComponent(saved);
window.location.replace(url);
window.location.replace('/?app=' + encodeURIComponent(app) + '&file=' + encodeURIComponent(saved));
}
}
})();
@ -327,14 +328,14 @@ app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
app.get('/raw', async c => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file')
if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400)
}
const fullPath = join(APPS_DIR, appName, version, filePath)
const fullPath = safePath(APPS_DIR, appName, filePath)
if (!fullPath) return c.text('Invalid path', 400)
const file = Bun.file(fullPath)
if (!await file.exists()) {
@ -346,14 +347,14 @@ app.get('/raw', async c => {
app.post('/save', async c => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file')
if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400)
}
const fullPath = join(APPS_DIR, appName, version, filePath)
const fullPath = safePath(APPS_DIR, appName, filePath)
if (!fullPath) return c.text('Invalid path', 400)
const content = await c.req.text()
try {
@ -385,10 +386,9 @@ async function listFiles(appPath: string, subPath: string = '') {
interface BreadcrumbProps {
appName: string
filePath: string
versionParam: string
}
function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
const parts = filePath ? filePath.split('/').filter(Boolean) : []
const crumbs: { name: string; path: string }[] = []
@ -401,7 +401,7 @@ function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
return (
<Breadcrumb>
{crumbs.length > 0 ? (
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=`}>{appName}</BreadcrumbLink>
<BreadcrumbLink href={`/?app=${appName}&file=`}>{appName}</BreadcrumbLink>
) : (
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
)}
@ -411,7 +411,7 @@ function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
{i === crumbs.length - 1 ? (
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
) : (
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
<BreadcrumbLink href={`/?app=${appName}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
)}
</>
))}
@ -479,7 +479,6 @@ function getPrismLanguage(filename: string): string {
app.get('/', async c => {
const appName = c.req.query('app')
const version = c.req.query('version') || 'current'
const filePath = c.req.query('file') || ''
if (!appName) {
@ -490,19 +489,34 @@ app.get('/', async c => {
)
}
const appPath = join(APPS_DIR, appName, version)
const appPath = safePath(APPS_DIR, appName)
if (!appPath) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Invalid app name</ErrorBox>
</Layout>
)
}
try {
await stat(appPath)
} catch {
return c.html(
<Layout title="Code Browser">
<ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
<ErrorBox>App "{appName}" not found</ErrorBox>
</Layout>
)
}
const fullPath = safePath(appPath, filePath)
if (!fullPath) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Invalid file path</ErrorBox>
</Layout>
)
}
const fullPath = join(appPath, filePath)
let fileStats
try {
@ -515,18 +529,16 @@ app.get('/', async c => {
)
}
const versionParam = version !== 'current' ? `&version=${version}` : ''
if (fileStats.isFile()) {
const filename = basename(fullPath)
const fileType = getFileType(filename)
const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
const rawUrl = `/raw?app=${appName}&file=${filePath}`
const downloadUrl = `${rawUrl}&download=1`
if (fileType === 'image') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
@ -543,7 +555,7 @@ app.get('/', async c => {
if (fileType === 'audio') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
@ -560,7 +572,7 @@ app.get('/', async c => {
if (fileType === 'video') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
@ -615,7 +627,7 @@ saveBtn.onclick = async () => {
status.textContent = 'Saving...';
try {
const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
const res = await fetch('/save?app=${appName}&file=${filePath}', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: jar.toString()
@ -641,14 +653,14 @@ document.addEventListener('keydown', (e) => {
`
return c.html(
<Layout title={`${appName}/${filePath}`} editable>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<CodeBlock>
<CodeHeader>
<span>{filename}</span>
<div style="display:flex;align-items:center;gap:8px">
<span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span>
<EditButton id="save-btn">Save</EditButton>
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}`}>Done</EditLink>
<EditLink href={`/?app=${appName}&file=${filePath}`}>Done</EditLink>
</div>
</CodeHeader>
<pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre>
@ -660,11 +672,11 @@ document.addEventListener('keydown', (e) => {
return c.html(
<Layout title={`${appName}/${filePath}`} highlight>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<CodeBlock>
<CodeHeader>
<span>{filename}</span>
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}&edit=1`}>Edit</EditLink>
<EditLink href={`/?app=${appName}&file=${filePath}&edit=1`}>Edit</EditLink>
</CodeHeader>
<pre><code class={`language-${language}`}>{content}</code></pre>
</CodeBlock>
@ -676,11 +688,11 @@ document.addEventListener('keydown', (e) => {
return c.html(
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
<PathBreadcrumb appName={appName} filePath={filePath} />
<FileList>
{files.map(file => (
<FileItem>
<FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
<FileLink href={`/?app=${appName}&file=${file.path}`}>
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
<span>{file.name}</span>
</FileLink>

View File

@ -15,7 +15,7 @@ const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false })
// Styles (follow versions tool pattern)
// Styles
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
@ -572,7 +572,7 @@ app.post('/new', async c => {
return c.redirect('/new?error=invalid-name')
}
const cronDir = join(APPS_DIR, appName, 'current', 'cron')
const cronDir = join(APPS_DIR, appName, 'cron')
const filePath = join(cronDir, `${name}.ts`)
// Check if file already exists
@ -691,7 +691,7 @@ watch(APPS_DIR, { recursive: true }, (_event, filename) => {
debounceTimer = setTimeout(rediscover, 100)
})
on(['app:activate', 'app:delete'], (event) => {
on(['app:reload', 'app:delete'], (event) => {
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
rediscover()
})

View File

@ -13,8 +13,7 @@ export async function getApps(): Promise<string[]> {
for (const entry of entries) {
if (!entry.isDirectory()) continue
// Check if it has a current symlink (valid app)
if (existsSync(join(APPS_DIR, entry.name, 'current'))) {
if (existsSync(join(APPS_DIR, entry.name, 'package.json'))) {
apps.push(entry.name)
}
}
@ -35,7 +34,7 @@ export async function discoverCronJobs(): Promise<DiscoveryResult> {
for (const app of apps) {
if (!app.isDirectory()) continue
const cronDir = join(APPS_DIR, app.name, 'current', 'cron')
const cronDir = join(APPS_DIR, app.name, 'cron')
if (!existsSync(cronDir)) continue
const files = await readdir(cronDir)

View File

@ -37,7 +37,7 @@ export async function executeJob(job: CronJob, onUpdate: () => void): Promise<vo
job.lastDuration = undefined
onUpdate()
const cwd = join(APPS_DIR, job.app, 'current')
const cwd = join(APPS_DIR, job.app)
forwardLog(job.app, `[cron] Running ${job.name}`)

View File

@ -300,9 +300,35 @@ app.get('/', async c => {
const appName = c.req.query('app')
if (!appName) {
// Dashboard view: global env vars only
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
return c.html(
<Layout title="Environment Variables">
<ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox>
<Layout title="Global Environment Variables">
{globalVars.length === 0 ? (
<EmptyState>No global environment variables</EmptyState>
) : (
<EnvList>
{globalVars.map(v => (
<EnvItem data-env-item>
<EnvKey>{v.key}</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions>
<Button data-reveal>Reveal</Button>
<form method="post" action={`/delete-global?key=${v.key}`} style="margin:0">
<DangerButton type="submit">Delete</DangerButton>
</form>
</EnvActions>
</EnvItem>
))}
</EnvList>
)}
<Form method="POST" action="/set-global">
<Input type="text" name="key" placeholder="KEY" required />
<Input type="text" name="value" placeholder="value" required />
<Button type="submit">Add</Button>
</Form>
<Hint>Global vars are available to all apps. Changes take effect on next app restart.</Hint>
</Layout>
)
}
@ -437,7 +463,6 @@ app.post('/delete', async c => {
app.post('/set-global', async c => {
const appName = c.req.query('app')
if (!appName) return c.text('Missing app', 400)
const body = await c.req.parseBody()
const key = String(body.key).trim().toUpperCase()
@ -455,17 +480,17 @@ app.post('/set-global', async c => {
}
writeEnvFile(GLOBAL_ENV_PATH, vars)
return c.redirect(`/?app=${appName}&tab=global`)
return c.redirect(appName ? `/?app=${appName}&tab=global` : '/')
})
app.post('/delete-global', async c => {
const appName = c.req.query('app')
const key = c.req.query('key')
if (!appName || !key) return c.text('Missing app or key', 400)
if (!key) return c.text('Missing key', 400)
const vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key)
writeEnvFile(GLOBAL_ENV_PATH, vars)
return c.redirect(`/?app=${appName}&tab=global`)
return c.redirect(appName ? `/?app=${appName}&tab=global` : '/')
})
export default app.defaults

View File

@ -10,7 +10,8 @@
},
"toes": {
"tool": ".env",
"icon": "🔑"
"icon": "🔑",
"dashboard": true
},
"devDependencies": {
"@types/bun": "latest"

49
apps/git/bun.lock Normal file
View File

@ -0,0 +1,49 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "git",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.12",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
"@because/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
"@because/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.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/node": ["@types/node@25.3.3", "https://npm.nose.space/@types/node/-/node-25.3.3.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"hono": ["hono@4.12.3", "https://npm.nose.space/hono/-/hono-4.12.3.tgz", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
}
}

865
apps/git/index.tsx Normal file
View File

@ -0,0 +1,865 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme, on, VALID_NAME } from '@because/toes/tools'
import { mkdirSync } from 'fs'
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
import { join, resolve } from 'path'
import type { Child } from 'hono/jsx'
const APP_URL = process.env.APP_URL!
const APPS_DIR = process.env.APPS_DIR!
const DATA_DIR = process.env.DATA_DIR!
const DATA_ROOT = process.env.DATA_ROOT!
const TOES_URL = process.env.TOES_URL!
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')
const app = new Hype({ prettyHTML: false, layout: false })
const deployLocks = new Map<string, Promise<void>>()
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const Badge = define('Badge', {
fontSize: '12px',
padding: '2px 8px',
borderRadius: theme('radius-md'),
backgroundColor: theme('colors-bgElement'),
color: theme('colors-textMuted'),
})
const CodeBlock = define('CodeBlock', {
base: 'pre',
backgroundColor: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
padding: theme('spacing-lg'),
fontFamily: theme('fonts-mono'),
fontSize: '13px',
overflowX: 'auto',
color: theme('colors-text'),
lineHeight: '1.5',
})
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
const Heading = define('Heading', {
base: 'h3',
margin: '24px 0 8px',
color: theme('colors-text'),
})
const HelpText = define('HelpText', {
color: theme('colors-textMuted'),
fontSize: '14px',
lineHeight: '1.6',
margin: '12px 0',
})
const RepoItem = define('RepoItem', {
padding: '12px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
states: {
':last-child': { borderBottom: 'none' },
':hover': { backgroundColor: theme('colors-bgHover') },
},
})
const RepoList = define('RepoList', {
listStyle: 'none',
padding: 0,
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
const RepoName = define('RepoName', {
fontFamily: theme('fonts-mono'),
fontSize: '15px',
fontWeight: 'bold',
color: theme('colors-text'),
})
const Tab = define('Tab', {
base: 'button',
padding: '6px 0',
background: 'none',
border: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
fontSize: '14px',
color: theme('colors-textMuted'),
states: {
':hover': { color: theme('colors-text') },
'.active': {
color: theme('colors-text'),
borderBottomColor: theme('colors-primary'),
fontWeight: '500',
},
},
})
const TabBar = define('TabBar', {
display: 'flex',
gap: '24px',
marginBottom: '20px',
})
const Toggle = define('Toggle', {
base: 'button',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '3px 10px',
borderRadius: theme('radius-md'),
border: `1px solid ${theme('colors-border')}`,
backgroundColor: theme('colors-bgElement'),
color: theme('colors-textMuted'),
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.15s ease',
states: {
':hover': { borderColor: theme('colors-textMuted') },
'.public': {
backgroundColor: theme('colors-statusRunning'),
color: 'white',
borderColor: 'transparent',
},
},
})
// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------
interface AppRepoProps {
appName: string
baseUrl: string
branch: string
exists: boolean
commits: boolean
}
interface LayoutProps {
title: string
children: Child
}
interface RepoListPageProps {
baseUrl: string
external: boolean
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility; tool: boolean }>
tunnelUrl?: string
}
type Visibility = 'public' | 'private'
// ---------------------------------------------------------------------------
// Functions
// ---------------------------------------------------------------------------
const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)
// resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal
const validRepoName = (name: string) =>
VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name)
async function activateApp(name: string): Promise<string | null> {
const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/reload`, {
method: 'POST',
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const msg = (body as Record<string, string>).error ?? `reload returned ${res.status}`
console.error(`Reload failed for ${name}:`, msg)
return msg
}
return null
}
async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }> {
const bare = repoPath(repoName)
if (!(await hasCommits(bare))) {
return { ok: false, error: 'No commits in repository' }
}
// Validate in a temp dir before touching the real app dir
const tmpDir = join(APPS_DIR, `.${repoName}-deploy-tmp`)
await rm(tmpDir, { recursive: true, force: true })
await mkdir(tmpDir, { recursive: true })
// Extract HEAD into the temp directory
const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
stdout: 'pipe',
stderr: 'pipe',
})
const tar = Bun.spawn(['tar', '-x', '-C', tmpDir], {
stdin: archive.stdout,
stdout: 'ignore',
stderr: 'pipe',
})
// Consume stderr concurrently to prevent pipe buffer from filling and blocking the process
const [archiveExit, tarExit, archiveErr, tarErr] = await Promise.all([
archive.exited,
tar.exited,
new Response(archive.stderr).text(),
new Response(tar.stderr).text(),
])
if (archiveExit !== 0 || tarExit !== 0) {
await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
}
// Verify package.json with scripts.toes exists
const pkgPath = join(tmpDir, 'package.json')
if (!(await Bun.file(pkgPath).exists())) {
await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'No package.json found in repository' }
}
try {
const pkg = JSON.parse(await Bun.file(pkgPath).text())
if (!pkg.scripts?.toes) {
await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'package.json missing scripts.toes entry' }
}
} catch {
await rm(tmpDir, { recursive: true, force: true })
return { ok: false, error: 'Invalid package.json' }
}
// Stop the app before swapping directories
await stopIfRunning(repoName)
// Validation passed — swap directories (reload endpoint handles restart)
const appDir = join(APPS_DIR, repoName)
await rm(appDir, { recursive: true, force: true })
await rename(tmpDir, appDir)
return { ok: true }
}
// Bun.file().exists() is for files only — it returns false for directories.
// Use stat() to check directory existence instead.
async function dirExists(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory()
} catch {
return false
}
}
async function ensureBareRepo(name: string): Promise<string> {
const bare = repoPath(name)
if (!(await dirExists(bare))) {
await mkdir(bare, { recursive: true })
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: bare }).exited
await run(['git', 'init', '--bare'])
await run(['git', 'symbolic-ref', 'HEAD', 'refs/heads/main'])
await run(['git', 'config', 'http.receivepack', 'true'])
}
return bare
}
function findLastFlush(data: Uint8Array): number {
for (let i = data.length - 4; i >= 0; i--) {
if (data[i] === 0x30 && data[i + 1] === 0x30 &&
data[i + 2] === 0x30 && data[i + 3] === 0x30) {
return i
}
}
return -1
}
async function getVisibility(repo: string): Promise<Visibility> {
const all = await loadVisibility()
return all[repo] ?? 'private'
}
async function getDefaultBranch(bare: string): Promise<string> {
const proc = Bun.spawn(['git', 'symbolic-ref', 'HEAD'], {
cwd: bare,
stdout: 'pipe',
// Ignore stderr to avoid filling the pipe buffer and blocking the process
stderr: 'ignore',
})
if ((await proc.exited) === 0) {
const ref = await new Response(proc.stdout).text()
return ref.trim().replace('refs/heads/', '')
}
return 'main'
}
async function gitRpc(
repo: string,
service: string,
body: Uint8Array | ReadableStream<Uint8Array> | null,
): Promise<Response> {
const bare = repoPath(repo)
const proc = Bun.spawn([service, '--stateless-rpc', bare], {
stdin: body ?? 'ignore',
stdout: 'pipe',
// Ignore stderr to avoid filling the pipe buffer and blocking the process
stderr: 'ignore',
})
return new Response(proc.stdout, {
headers: {
'Content-Type': `application/x-${service}-result`,
'Cache-Control': 'no-cache',
},
})
}
async function gitService(repo: string, service: string): Promise<Response | null> {
const bare = repoPath(repo)
if (!(await dirExists(bare))) return null
const proc = Bun.spawn([service, '--stateless-rpc', '--advertise-refs', bare], {
stdout: 'pipe',
// Ignore stderr to avoid filling the pipe buffer and blocking the process
stderr: 'ignore',
})
const stdout = new Uint8Array(await new Response(proc.stdout).arrayBuffer())
await proc.exited
const header = serviceHeader(service)
const body = new Uint8Array(header.length + stdout.byteLength)
body.set(header, 0)
body.set(stdout, header.length)
return new Response(body, {
headers: {
'Content-Type': `application/x-${service}-advertisement`,
'Cache-Control': 'no-cache',
},
})
}
function gitSidebandMessage(text: string): Uint8Array {
const encoder = new TextEncoder()
const lines = text.split('\n').filter(Boolean)
const parts: Uint8Array[] = []
for (const line of lines) {
const msg = `\x02remote: ${line}\n`
const hex = (4 + msg.length).toString(16).padStart(4, '0')
parts.push(encoder.encode(hex + msg))
}
const total = parts.reduce((sum, p) => sum + p.length, 0)
const out = new Uint8Array(total)
let offset = 0
for (const part of parts) {
out.set(part, offset)
offset += part.length
}
return out
}
async function hasCommits(bare: string): Promise<boolean> {
const proc = Bun.spawn(['git', 'rev-parse', 'HEAD'], {
cwd: bare,
// Only checking exit code; ignore stdout/stderr to avoid filling the pipe buffer
stdout: 'ignore',
stderr: 'ignore',
})
return (await proc.exited) === 0
}
function insertBeforeFlush(gitBody: Uint8Array, msg: Uint8Array): Uint8Array {
const pos = findLastFlush(gitBody)
if (pos === -1) {
const out = new Uint8Array(gitBody.length + msg.length)
out.set(gitBody, 0)
out.set(msg, gitBody.length)
return out
}
const out = new Uint8Array(gitBody.length + msg.length)
out.set(gitBody.subarray(0, pos), 0)
out.set(msg, pos)
out.set(gitBody.subarray(pos), pos + msg.length)
return out
}
async function loadVisibility(): Promise<Record<string, Visibility>> {
try {
const data = await readFile(VISIBILITY_PATH, 'utf-8')
return JSON.parse(data)
} catch {
return {}
}
}
function Layout({ title, children }: LayoutProps) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<ToolScript />
<Container>{children}</Container>
</body>
</html>
)
}
async function listRepos(): Promise<string[]> {
if (!(await dirExists(REPOS_DIR))) return []
const entries = await readdir(REPOS_DIR, { withFileTypes: true })
return entries
.filter(e => e.isDirectory() && e.name.endsWith('.git'))
.map(e => e.name.replace(/\.git$/, ''))
.sort()
}
function serviceHeader(service: string): Uint8Array {
const line = `# service=${service}\n`
const hex = (4 + line.length).toString(16).padStart(4, '0')
const header = `${hex}${line}0000`
return new TextEncoder().encode(header)
}
async function saveVisibility(repo: string, visibility: Visibility): Promise<void> {
const all = await loadVisibility()
all[repo] = visibility
await writeFile(VISIBILITY_PATH, JSON.stringify(all, null, 2))
}
async function stopIfRunning(name: string): Promise<void> {
const res = await fetch(`${TOES_URL}/api/apps/${name}`)
if (!res.ok) return
const app = await res.json() as { state: string }
if (app.state !== 'running' && app.state !== 'starting') return
await fetch(`${TOES_URL}/api/apps/${name}/stop`, { method: 'POST' })
const maxWait = 10000
const poll = 100
let waited = 0
while (waited < maxWait) {
await new Promise(r => setTimeout(r, poll))
waited += poll
const check = await fetch(`${TOES_URL}/api/apps/${name}`)
if (!check.ok) break
const { state } = await check.json() as { state: string }
if (state !== 'running' && state !== 'stopping' && state !== 'starting') break
}
}
async function withDeployLock<T>(repo: string, fn: () => Promise<T>): Promise<T> {
const prev = deployLocks.get(repo) ?? Promise.resolve()
const { promise: lock, resolve: release } = Promise.withResolvers<void>()
deployLocks.set(repo, lock)
await prev
try {
return await fn()
} finally {
release()
if (deployLocks.get(repo) === lock) deployLocks.delete(repo)
}
}
function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
return (
<Layout title={`Git - ${appName}`}>
{exists && commits ? (
<>
<Heading>Repository</Heading>
<RepoList>
<RepoItem>
<div>
<RepoName>{appName}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{appName}
</HelpText>
</div>
<div style="display: flex; gap: 8px; align-items: center">
<Badge>{branch}</Badge>
<Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
</div>
</RepoItem>
</RepoList>
<Heading>Push Changes</Heading>
<CodeBlock>{[
`git push toes ${branch}`,
'',
'# Or if remote not yet added:',
`git remote add toes ${baseUrl}/${appName}`,
`git push toes ${branch}`,
].join('\n')}</CodeBlock>
</>
) : exists ? (
<>
<Heading>Repository</Heading>
<RepoList>
<RepoItem>
<div>
<RepoName>{appName}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{appName}
</HelpText>
</div>
<Badge>empty</Badge>
</RepoItem>
</RepoList>
<Heading>Push to Deploy</Heading>
<CodeBlock>{[
`git remote add toes ${baseUrl}/${appName}`,
'git push toes main',
].join('\n')}</CodeBlock>
</>
) : (
<>
<Heading>Push to Deploy</Heading>
<HelpText>
No git repository for <strong>{appName}</strong> yet.
Push to create one and deploy.
</HelpText>
<CodeBlock>{[
`git remote add toes ${baseUrl}/${appName}`,
'git push toes main',
].join('\n')}</CodeBlock>
</>
)}
</Layout>
)
}
function RepoListItems({ baseUrl, external, repos, tunnelUrl }: {
baseUrl: string
external: boolean
repos: RepoListPageProps['repos']
tunnelUrl?: string
}) {
if (repos.length === 0) {
return <HelpText>No repositories yet.</HelpText>
}
return (
<RepoList>
{repos.map(({ name, commits, branch, visibility }) => (
<RepoItem>
<div>
<RepoName>{name}</RepoName>
<HelpText style="margin: 4px 0 0; font-size: 12px">
git clone {baseUrl}/{name}
</HelpText>
{!external && tunnelUrl && visibility === 'public' && (
<HelpText style="margin: 2px 0 0; font-size: 12px">
git clone {tunnelUrl}/{name}
</HelpText>
)}
</div>
<div style="display: flex; gap: 8px; align-items: center">
{!external && (
<Toggle
class={visibility === 'public' ? 'public' : ''}
data-repo={name}
data-visibility={visibility}
onclick="toggleVisibility(this)"
>
{visibility === 'public' ? 'public' : 'private'}
</Toggle>
)}
<Badge>{branch}</Badge>
{commits
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
: <Badge>empty</Badge>}
</div>
</RepoItem>
))}
</RepoList>
)
}
function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps) {
const appRepos = repos.filter(r => !r.tool)
const toolRepos = repos.filter(r => r.tool)
return (
<Layout title="Git">
{!external && (
<>
<Heading>Push to Deploy</Heading>
<HelpText>
Push a git repository to deploy it as a toes app.
The repo must contain a <code>package.json</code> with a <code>scripts.toes</code> entry.
</HelpText>
<CodeBlock>{[
'# Add this server as a remote and push',
`git remote add toes ${baseUrl}/<app-name>`,
'git push toes main',
'',
'# Or push an existing repo',
`git push ${baseUrl}/<app-name> main`,
].join('\n')}</CodeBlock>
</>
)}
{repos.length > 0 && appRepos.length > 0 && toolRepos.length > 0 && (
<>
<Heading>Repositories</Heading>
<TabBar>
<Tab class="active" data-tab="tab-apps" onclick="switchTab(this)">Apps</Tab>
<Tab data-tab="tab-tools" onclick="switchTab(this)">Tools</Tab>
</TabBar>
<div>
<div id="tab-apps">
<RepoListItems baseUrl={baseUrl} external={external} repos={appRepos} tunnelUrl={tunnelUrl} />
</div>
<div id="tab-tools" style="display: none">
<RepoListItems baseUrl={baseUrl} external={external} repos={toolRepos} tunnelUrl={tunnelUrl} />
</div>
</div>
{!external && <script src="/client/toggle.js" />}
<script src="/client/tabs.js" />
</>
)}
{repos.length > 0 && (appRepos.length === 0 || toolRepos.length === 0) && (
<>
<Heading>Repositories</Heading>
<RepoListItems baseUrl={baseUrl} external={external} repos={repos} tunnelUrl={tunnelUrl} />
{!external && <script src="/client/toggle.js" />}
</>
)}
{repos.length === 0 && (
<HelpText>No repositories yet. Push one to get started.</HelpText>
)}
</Layout>
)
}
// ---------------------------------------------------------------------------
// Module init
// ---------------------------------------------------------------------------
mkdirSync(REPOS_DIR, { recursive: true })
on('app:delete', async ({ app: name }) => {
const bare = repoPath(name)
if (await dirExists(bare)) await rm(bare, { recursive: true, force: true })
})
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c =>
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
)
// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
const repoParam = c.req.param('repo').replace(/\.git$/, '')
const service = c.req.query('service')
if (!validRepoName(repoParam)) {
return c.text('Invalid repository name', 400)
}
if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
return c.text('Invalid service', 400)
}
if (c.req.header('x-sneaker')) {
if (service === 'git-receive-pack') {
return c.text('Push access denied over sneaker', 403)
}
if (await getVisibility(repoParam) !== 'public') {
return c.text('Repository not found', 404)
}
}
if (service === 'git-receive-pack') {
await ensureBareRepo(repoParam)
}
const bare = repoPath(repoParam)
if (!(await dirExists(bare))) {
return c.text('Repository not found', 404)
}
const res = await gitService(repoParam, service)
return res ?? c.text('Repository not found', 404)
})
// POST /:repo[.git]/git-upload-pack
app.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/git-upload-pack'], async c => {
const repoParam = c.req.param('repo').replace(/\.git$/, '')
if (!validRepoName(repoParam)) {
return c.text('Invalid repository name', 400)
}
if (c.req.header('x-sneaker') && await getVisibility(repoParam) !== 'public') {
return c.text('Repository not found', 404)
}
const bare = repoPath(repoParam)
if (!(await dirExists(bare))) {
return c.text('Repository not found', 404)
}
return gitRpc(repoParam, 'git-upload-pack', c.req.raw.body)
})
// POST /:repo[.git]/git-receive-pack
app.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'], async c => {
if (c.req.header('x-sneaker')) {
return c.text('Push access denied over sneaker', 403)
}
const repoParam = c.req.param('repo').replace(/\.git$/, '')
if (!validRepoName(repoParam)) {
return c.text('Invalid repository name', 400)
}
await ensureBareRepo(repoParam)
// Buffer the request body before passing to git-receive-pack. Piping a live
// HTTP ReadableStream directly to subprocess stdin deadlocks on large pushes:
// the pipe buffer fills, stalling the stream reader, while git-receive-pack
// can't finish reading stdin to produce stdout — both sides block.
const body = new Uint8Array(await c.req.raw.arrayBuffer())
const response = await gitRpc(repoParam, 'git-receive-pack', body)
// Buffer the full response so we can inject sideband error messages before the
// final flush-pkt on deploy failure. The receive-pack response is just ref status
// lines (not pack data), so the buffer is small regardless of push size.
const gitBody = new Uint8Array(await response.arrayBuffer())
const deployError = await withDeployLock(repoParam, async () => {
try {
const result = await deploy(repoParam)
if (result.ok) {
const err = await activateApp(repoParam)
if (err) {
console.error(`Reload failed for ${repoParam}: ${err}`)
return `Deploy succeeded but reload failed: ${err}`
}
console.log(`Deployed ${repoParam}`)
return null
}
console.error(`Deploy failed for ${repoParam}: ${result.error}`)
return `Deploy failed: ${result.error}`
} catch (e) {
console.error(`Deploy error for ${repoParam}:`, e)
return `Deploy failed: ${e instanceof Error ? e.message : String(e)}`
}
})
const headers = {
'Content-Type': response.headers.get('Content-Type') ?? 'application/x-git-receive-pack-result',
'Cache-Control': 'no-cache',
}
if (deployError) {
return new Response(insertBeforeFlush(gitBody, gitSidebandMessage(deployError)), { headers })
}
return new Response(gitBody, { headers })
})
app.post('/api/visibility/:repo', async c => {
if (c.req.header('x-sneaker')) return c.json({ error: 'Forbidden' }, 403)
const repo = c.req.param('repo')
if (!validRepoName(repo)) return c.json({ error: 'Invalid repository name' }, 400)
const body = await c.req.json<{ visibility: string }>()
if (body.visibility !== 'public' && body.visibility !== 'private') {
return c.json({ error: 'Visibility must be "public" or "private"' }, 400)
}
await saveVisibility(repo, body.visibility)
return c.json({ ok: true })
})
app.get('/', async c => {
const appName = c.req.query('app')
const sneakerHost = c.req.header('x-sneaker')
const external = !!sneakerHost
const baseUrl = sneakerHost ? `https://${sneakerHost}` : APP_URL
// When viewing a specific app, only show that app's repo
if (appName) {
const bare = repoPath(appName)
const exists = await dirExists(bare)
const [commits, branch] = exists
? await Promise.all([hasCommits(bare), getDefaultBranch(bare)])
: [false, 'main']
return c.html(<AppRepo appName={appName} baseUrl={baseUrl} branch={branch} exists={exists} commits={commits} />)
}
// No app selected — show all repos
const repos = await listRepos()
// Fetch all apps to determine which repos are tools
let toolSet = new Set<string>()
try {
const res = await fetch(`${TOES_URL}/api/apps`)
if (res.ok) {
const apps = await res.json() as Array<{ name: string; tool?: boolean | string }>
for (const a of apps) {
if (a.tool) toolSet.add(a.name)
}
}
} catch {}
const repoData = await Promise.all(repos.map(async name => {
const bare = repoPath(name)
const [commits, branch, visibility] = await Promise.all([
hasCommits(bare),
getDefaultBranch(bare),
getVisibility(name),
])
return { name, commits, branch, visibility, tool: toolSet.has(name) }
}))
// Hide private repos from external (sneaker) requests
const filtered = external
? repoData.filter(r => r.visibility === 'public')
: repoData
// Fetch tunnel URL for the git tool so we can show it for public repos
let tunnelUrl: string | undefined
if (!external) {
try {
const res = await fetch(`${TOES_URL}/api/apps/git`)
if (res.ok) {
const info = await res.json() as { tunnelUrl?: string }
tunnelUrl = info.tunnelUrl
}
} catch {}
}
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} tunnelUrl={tunnelUrl} />)
})
export default app.defaults

View File

@ -1,5 +1,5 @@
{
"name": "versions",
"name": "git",
"module": "index.tsx",
"type": "module",
"private": true,
@ -10,7 +10,9 @@
},
"toes": {
"tool": true,
"icon": "📦"
"dashboard": true,
"share": true,
"icon": "🔀"
},
"devDependencies": {
"@types/bun": "latest"
@ -21,6 +23,6 @@
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.5"
"@because/toes": "0.0.12"
}
}

View File

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

View File

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

View File

@ -55,6 +55,12 @@ interface ProcessMetrics {
rss: number
}
interface SystemMetrics {
cpu: number
ram: { used: number, total: number, percent: number }
disk: { used: number, total: number, percent: number }
}
// ============================================================================
// Process Metrics Collection
// ============================================================================
@ -402,6 +408,40 @@ const ChartWrapper = define('ChartWrapper', {
height: '150px',
})
const GaugeLabel = define('GaugeLabel', {
fontSize: '13px',
fontWeight: 600,
color: theme('colors-textMuted'),
textTransform: 'uppercase',
letterSpacing: '0.5px',
})
const GaugeValueText = define('GaugeValueText', {
textAlign: 'center',
fontSize: '20px',
fontWeight: 'bold',
marginTop: '-4px',
color: theme('colors-text'),
})
const GaugesCard = define('GaugesCard', {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
background: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
padding: '24px',
})
const GaugesGrid = define('GaugesGrid', {
display: 'flex',
justifyContent: 'center',
gap: '40px',
padding: '20px 0',
})
const NoDataMessage = define('NoDataMessage', {
display: 'flex',
alignItems: 'center',
@ -435,6 +475,14 @@ function formatRss(kb?: number): string {
return `${(kb / 1024 / 1024).toFixed(2)} GB`
}
async function fetchSystemMetrics(): Promise<SystemMetrics> {
try {
const res = await fetch(`${TOES_URL}/api/system/metrics`)
if (res.ok) return await res.json() as SystemMetrics
} catch {}
return { cpu: 0, ram: { used: 0, total: 0, percent: 0 }, disk: { used: 0, total: 0, percent: 0 } }
}
function getStatusColor(state: string): string {
switch (state) {
case 'running':
@ -477,6 +525,107 @@ function Layout({ title, children }: LayoutProps) {
)
}
// ============================================================================
// Gauge Rendering
// ============================================================================
const G_SEGMENTS = 19
const G_START = -225
const G_SWEEP = 270
const G_CX = 60
const G_CY = 60
const G_R = 44
const G_GAP = 3
const G_SW = 8
const G_NL = 38
const gToRad = (deg: number) => (deg * Math.PI) / 180
const gSegColor = (i: number): string => {
const t = i / (G_SEGMENTS - 1)
if (t < 0.4) return '#4caf50'
if (t < 0.6) return '#8bc34a'
if (t < 0.75) return '#ffc107'
if (t < 0.9) return '#ff9800'
return '#f44336'
}
function renderGauge(value: number, id: string) {
const segSweep = G_SWEEP / G_SEGMENTS
const active = Math.round((value / 100) * G_SEGMENTS)
const innerR = G_R - G_SW / 2
const outerR = G_R + G_SW / 2
const segments = []
for (let i = 0; i < G_SEGMENTS; i++) {
const s = G_START + i * segSweep + G_GAP / 2
const e = G_START + (i + 1) * segSweep - G_GAP / 2
const x1 = G_CX + outerR * Math.cos(gToRad(s))
const y1 = G_CY + outerR * Math.sin(gToRad(s))
const x2 = G_CX + outerR * Math.cos(gToRad(e))
const y2 = G_CY + outerR * Math.sin(gToRad(e))
const x3 = G_CX + innerR * Math.cos(gToRad(e))
const y3 = G_CY + innerR * Math.sin(gToRad(e))
const x4 = G_CX + innerR * Math.cos(gToRad(s))
const y4 = G_CY + innerR * Math.sin(gToRad(s))
segments.push(
<path
key={i}
data-segment={i}
d={`M ${x1} ${y1} A ${outerR} ${outerR} 0 0 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 0 0 ${x4} ${y4} Z`}
fill={i < active ? gSegColor(i) : 'var(--colors-border)'}
/>
)
}
const angle = G_START + (value / 100) * G_SWEEP
const nx = G_CX + G_NL * Math.cos(gToRad(angle))
const ny = G_CY + G_NL * Math.sin(gToRad(angle))
const pa = angle + 90
const bw = 3
const bx1 = G_CX + bw * Math.cos(gToRad(pa))
const by1 = G_CY + bw * Math.sin(gToRad(pa))
const bx2 = G_CX - bw * Math.cos(gToRad(pa))
const by2 = G_CY - bw * Math.sin(gToRad(pa))
return (
<GaugesCard>
<GaugeLabel>{id}</GaugeLabel>
<svg id={`gauge-${id}`} viewBox="10 10 100 55" width="140" height="80" style="overflow: visible">
{segments}
<polygon data-needle points={`${nx},${ny} ${bx1},${by1} ${bx2},${by2}`} fill="var(--colors-text)" />
<circle cx={G_CX} cy={G_CY} r="4" fill="var(--colors-textMuted)" />
</svg>
<GaugeValueText id={`value-${id}`}>{value}%</GaugeValueText>
</GaugesCard>
)
}
const gaugeScript = `
(function() {
var S=19,ST=-225,SW=270,CX=60,CY=60,R=44,W=8,NL=38,GAP=3;
var iR=R-W/2, oR=R+W/2;
function rad(d){return d*Math.PI/180}
function sc(i){var t=i/(S-1);return t<.4?'#4caf50':t<.6?'#8bc34a':t<.75?'#ffc107':t<.9?'#ff9800':'#f44336'}
function upd(id,v){
var svg=document.getElementById('gauge-'+id);if(!svg)return;
var a=Math.round((v/100)*S);
svg.querySelectorAll('[data-segment]').forEach(function(p,i){p.setAttribute('fill',i<a?sc(i):'var(--colors-border)')});
var ang=ST+(v/100)*SW,nx=CX+NL*Math.cos(rad(ang)),ny=CY+NL*Math.sin(rad(ang));
var pa=ang+90,bw=3;
var bx1=CX+bw*Math.cos(rad(pa)),by1=CY+bw*Math.sin(rad(pa));
var bx2=CX-bw*Math.cos(rad(pa)),by2=CY-bw*Math.sin(rad(pa));
var n=svg.querySelector('[data-needle]');if(n)n.setAttribute('points',nx+','+ny+' '+bx1+','+by1+' '+bx2+','+by2);
var el=document.getElementById('value-'+id);if(el)el.textContent=v+'%';
}
setInterval(function(){
fetch('/api/system').then(function(r){return r.json()}).then(function(m){
upd('cpu',m.cpu);upd('ram',m.ram.percent);upd('disk',m.disk.percent);
}).catch(function(){});
},2000);
})();
`
// ============================================================================
// App
// ============================================================================
@ -510,6 +659,11 @@ app.get('/api/data-history/:name', c => {
return c.json(history)
})
app.get('/api/system', async c => {
const metrics = await fetchSystemMetrics()
return c.json(metrics)
})
app.get('/api/history/:name', c => {
const name = c.req.param('name')
const history = getHistory(name)
@ -956,67 +1110,17 @@ app.get('/', async c => {
)
}
// All apps view
const metrics = await getAppMetrics()
// Sort: running first, then by name
metrics.sort((a, b) => {
if (a.state === 'running' && b.state !== 'running') return -1
if (a.state !== 'running' && b.state === 'running') return 1
return a.name.localeCompare(b.name)
})
if (metrics.length === 0) {
return c.html(
<Layout title="Metrics">
<EmptyState>No apps found</EmptyState>
</Layout>
)
}
const running = metrics.filter(s => s.state === 'running')
const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0)
const totalData = metrics.reduce((sum, s) => sum + (s.dataSize ?? 0), 0)
// Dashboard view: system metrics gauges
const sys = await fetchSystemMetrics()
return c.html(
<Layout title="Metrics">
<Table>
<thead>
<tr>
<Th>Name</Th>
<Th>State</Th>
<ThRight>PID</ThRight>
<ThRight>CPU</ThRight>
<ThRight>MEM</ThRight>
<ThRight>RSS</ThRight>
<ThRight>Data</ThRight>
</tr>
</thead>
<tbody>
{metrics.map(s => (
<Tr>
<Td>
{s.name}
{s.tool && <ToolBadge>[tool]</ToolBadge>}
</Td>
<Td>
<StatusBadge style={`color: ${getStatusColor(s.state)}`}>
{s.state}
</StatusBadge>
</Td>
<TdRight>{s.pid ?? '-'}</TdRight>
<TdRight>{formatPercent(s.cpu)}</TdRight>
<TdRight>{formatPercent(s.memory)}</TdRight>
<TdRight>{formatRss(s.rss)}</TdRight>
<TdRight>{formatBytes(s.dataSize)}</TdRight>
</Tr>
))}
</tbody>
</Table>
<Summary>
{running.length} running &middot; {formatPercent(totalCpu)} CPU &middot; {formatRss(totalRss)} RSS &middot; {formatBytes(totalData)} data
</Summary>
<GaugesGrid>
{renderGauge(sys.cpu, 'cpu')}
{renderGauge(sys.ram.percent, 'ram')}
{renderGauge(sys.disk.percent, 'disk')}
</GaugesGrid>
<script dangerouslySetInnerHTML={{ __html: gaugeScript }} />
</Layout>
)
})

View File

@ -10,7 +10,8 @@
},
"toes": {
"tool": true,
"icon": "📊"
"icon": "📊",
"dashboard": true
},
"devDependencies": {
"@types/bun": "latest"

View File

@ -1,45 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "versions",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.5",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
"@because/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/toes": ["@because/toes@0.0.5", "https://npm.nose.space/@because/toes/-/toes-0.0.5.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-YM1VuR1sym7m7pFcaiqnjg6eJUyhJYUH2ROBb+xi+HEXajq46ZL8KDyyCtz7WiHTfrbxcEWGjqyj20a7UppcJg=="],
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

View File

@ -1,177 +0,0 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, readlink, stat } from 'fs/promises'
import { join } from 'path'
import type { Child } from 'hono/jsx'
const APPS_DIR = process.env.APPS_DIR!
const TOES_URL = process.env.TOES_URL!
const app = new Hype({ prettyHTML: false })
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
const VersionList = define('VersionList', {
listStyle: 'none',
padding: 0,
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
const VersionItem = define('VersionItem', {
padding: '12px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
states: {
':last-child': {
borderBottom: 'none',
},
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const VersionLink = define('VersionLink', {
base: 'a',
textDecoration: 'none',
color: theme('colors-link'),
fontFamily: theme('fonts-mono'),
fontSize: '15px',
cursor: 'pointer',
states: {
':hover': {
textDecoration: 'underline',
},
},
})
const Badge = define('Badge', {
fontSize: '12px',
padding: '2px 8px',
borderRadius: theme('radius-md'),
backgroundColor: theme('colors-bgElement'),
color: theme('colors-statusRunning'),
fontWeight: 'bold',
})
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
margin: '20px 0',
})
interface LayoutProps {
title: string
children: Child
}
function Layout({ title, children }: LayoutProps) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<ToolScript />
<Container>
{children}
</Container>
</body>
</html>
)
}
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
async function getVersions(appPath: string): Promise<{ name: string; isCurrent: boolean }[]> {
const entries = await readdir(appPath, { withFileTypes: true })
let currentTarget = ''
try {
currentTarget = await readlink(join(appPath, 'current'))
} catch { }
return entries
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
.map(e => ({ name: e.name, isCurrent: e.name === currentTarget }))
.sort((a, b) => b.name.localeCompare(a.name))
}
function formatTimestamp(ts: string): string {
return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(9, 11)}:${ts.slice(11, 13)}:${ts.slice(13, 15)}`
}
app.get('/', async c => {
const appName = c.req.query('app')
if (!appName) {
return c.html(
<Layout title="Versions">
<ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox>
</Layout>
)
}
const appPath = join(APPS_DIR, appName)
try {
await stat(appPath)
} catch {
return c.html(
<Layout title="Versions">
<ErrorBox>App "{appName}" not found</ErrorBox>
</Layout>
)
}
const versions = await getVersions(appPath)
if (versions.length === 0) {
return c.html(
<Layout title="Versions">
<ErrorBox>No versions found</ErrorBox>
</Layout>
)
}
return c.html(
<Layout title="Versions">
<VersionList>
{versions.map(v => (
<VersionItem>
<VersionLink
href={`${TOES_URL}/tool/code?app=${appName}&version=${v.name}`}
>
{formatTimestamp(v.name)}
</VersionLink>
{v.isCurrent && <Badge>current</Badge>}
</VersionItem>
))}
</VersionList>
</Layout>
)
})
export default app.defaults

View File

@ -7,8 +7,9 @@
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.3",
"commander": "^14.0.3",
"@because/sneaker": "^0.0.4",
"@because/toes": "^0.0.12",
"commander": "14.0.3",
"diff": "^8.0.3",
"kleur": "^4.1.5",
},
@ -26,15 +27,17 @@
"@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.3", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.3.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-4cG8w/tYPGbDtLw89k1PiASJKfWUdd1NXv+GKad2d7Ckw3FpZ+dnN2+gR2ihs81dqAkNaZomo+9RznBju2WaOw=="],
"@because/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=="],
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@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.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@types/node": ["@types/node@25.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=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],

45
color-preview.sh Executable file
View File

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

View File

@ -6,10 +6,8 @@ An app is an HTTP server that runs on its assigned port.
```
apps/<name>/
<timestamp>/ # YYYYMMDD-HHMMSS
package.json
index.tsx
current -> <timestamp> # symlink to active version
```
**package.json** must have `scripts.toes`:

View File

@ -9,7 +9,7 @@ The cron tool discovers jobs from all apps and runs them automatically.
Add a file to `cron/` in any app:
```ts
// apps/my-app/current/cron/daily-cleanup.ts
// apps/my-app/cron/daily-cleanup.ts
export const schedule = "day"
export default async function() {
@ -73,7 +73,7 @@ Jobs track:
## discovery
The cron tool:
1. Scans `APPS_DIR/*/current/cron/*.ts`
1. Scans `APPS_DIR/*/cron/*.ts`
2. Imports each file to read `schedule`
3. Validates the schedule
4. Registers with croner

View File

@ -19,13 +19,11 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
- [CLI Reference](#cli-reference)
- [App Management](#app-management)
- [Lifecycle](#lifecycle)
- [Syncing Code](#syncing-code)
- [Deploying Code](#deploying-code)
- [Environment Variables](#environment-variables)
- [Versioning](#versioning)
- [Cron Jobs](#cron-jobs-1)
- [Metrics](#metrics)
- [Sharing](#sharing)
- [Configuration](#configuration)
- [Environment Variables](#environment-variables-1)
- [Health Checks](#health-checks)
- [App Lifecycle](#app-lifecycle)
@ -40,7 +38,7 @@ Toes is a personal web appliance that runs multiple web apps on your home networ
# Install the CLI
curl -fsSL http://toes.local/install | bash
# Create a new app
# Create a new app (scaffolds, inits git, and pushes to server)
toes new my-app
# Enter the directory, install deps, and develop locally
@ -48,8 +46,9 @@ cd my-app
bun install
bun dev
# Push to the server
toes push
# Deploy changes (standard git)
git add . && git commit -m "my changes"
git push toes main
# Open in browser
toes open
@ -57,7 +56,7 @@ toes open
Your app is now running at `http://my-app.toes.local`.
> **Tip:** Add `.toes` to your `.gitignore`. This file tracks local sync state and shouldn't be committed.
`toes new` automatically sets up a `toes` git remote pointing at the server. Pushing to it triggers a deploy.
---
@ -85,8 +84,8 @@ A generated SSR app looks like this:
```
my-app/
.gitignore # Files to exclude from sync and deploy
.npmrc # Points to the private registry
.toesignore # Files to exclude from sync (like .gitignore)
package.json # Must have scripts.toes
tsconfig.json # TypeScript config
index.tsx # Entry point (re-exports from src/server)
@ -457,13 +456,11 @@ app.get('/', c => {
const appName = c.req.query('app')
if (!appName) return c.html(<p>No app selected</p>)
const appPath = join(APPS_DIR, appName, 'current')
const appPath = join(APPS_DIR, appName)
// Read files from appPath...
})
```
Always go through the `current` symlink — never access version directories directly.
**Calling the Toes API:**
```tsx
@ -522,19 +519,21 @@ toes new my-app --bare # Minimal template
toes new my-app --spa # SPA template
```
Creates the app locally, then pushes it to the server. If run without a name, scaffolds the current directory.
Scaffolds the app locally, initializes a git repo with a `toes` remote pointing at the server, and pushes. The git push triggers a deploy. If run without a name, scaffolds the current directory.
**`toes info [name]`** — Show details for an app (state, URL, port, PID, uptime).
**`toes get <name>`** — Download an app from the server to your local machine.
**`toes get <name>`** — Clone an app from the server to your local machine.
```bash
toes get my-app # Creates ./my-app/ with all files
toes get my-app # Clones into ./my-app/
cd my-app
bun install
bun dev # Develop locally
```
The clone comes with a `toes` remote already configured, so `git push toes main` deploys.
**`toes open [name]`** — Open a running app in your browser.
**`toes rename [name] <new-name>`** — Rename an app. Requires typing a confirmation.
@ -562,54 +561,38 @@ toes logs my-app -f -g error # Follow and filter
Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months).
### Syncing Code
### Deploying Code
Toes uses a manifest-based sync protocol. Each file is tracked by SHA-256 hash. The server stores versioned snapshots with timestamps.
**`toes push`** — Push local changes to the server.
Toes uses git for deployments. Each app has a `toes` remote that points to the server's git tool. Pushing to it extracts the latest commit and deploys it.
```bash
toes push # Push changes (fails if server changed)
toes push --force # Overwrite server changes
# Make changes, commit, and deploy
git add .
git commit -m "update homepage"
git push toes main
```
Creates a new version on the server, uploads changed files, deletes removed files, then activates the new version. The app auto-restarts.
The git push triggers the server to:
1. Store the commit in a bare repo at `DATA_DIR/repos/<name>.git`
2. Extract HEAD into the app directory
3. Run `bun install` and restart the app
**`toes pull`** — Pull changes from the server.
Use standard git commands for history, diffing, and rollback:
```bash
toes pull # Pull changes (fails if you have local changes)
toes pull --force # Overwrite local changes
git log # View deploy history
git diff HEAD~1 # See what changed
git revert HEAD # Undo last deploy
git push toes main # Deploy the revert
```
**`toes status`** — Show what would be pushed or pulled.
To clone an existing app from the server:
```bash
toes status
# Changes to push:
# * index.tsx
# + new-file.ts
# - removed-file.ts
```
**`toes diff`** — Show a line-by-line diff of changed files.
**`toes sync`** — Watch for changes and sync bidirectionally in real-time. Useful during development when editing on the server.
**`toes clean`** — Remove local files that don't exist on the server.
```bash
toes clean # Interactive confirmation
toes clean --force # No confirmation
toes clean --dry-run # Show what would be removed
```
**`toes stash`** — Stash local changes (like `git stash`).
```bash
toes stash # Save local changes
toes stash pop # Restore stashed changes
toes stash list # List all stashes
git clone http://git.toes.local/my-app
cd my-app
bun install
bun dev # Develop locally
```
### Environment Variables
@ -640,26 +623,12 @@ toes env rm -g API_KEY # Remove global var
### Versioning
Every push creates a timestamped version. The server keeps the last 5 versions.
**`toes versions [name]`** — List deployed versions.
Every `git push toes main` creates a new deploy. Version history is managed through git.
```bash
toes versions my-app
# Versions for my-app:
#
# → 20260219-143022 2/19/2026, 2:30:22 PM (current)
# 20260218-091500 2/18/2026, 9:15:00 AM
# 20260217-160845 2/17/2026, 4:08:45 PM
```
**`toes history [name]`** — Show file changes between versions.
**`toes rollback [name]`** — Rollback to a previous version.
```bash
toes rollback my-app # Interactive version picker
toes rollback my-app -v 20260218-091500 # Rollback to specific version
git log --oneline # List deploys
git revert HEAD # Undo last change
git push toes main # Deploy the revert
```
### Cron Jobs
@ -717,10 +686,6 @@ toes share my-app
**`toes unshare [name]`** — Stop sharing an app.
### Configuration
**`toes config`** — Show the current server URL and sync state.
---
## Environment Variables

View File

@ -44,21 +44,17 @@ app.get('/', c => {
## accessing app files
Always go through the `current` symlink:
```ts
const APPS_DIR = process.env.APPS_DIR!
const appPath = join(APPS_DIR, appName, 'current')
const appPath = join(APPS_DIR, appName)
```
Not `APPS_DIR/appName` directly.
## linking to tools
Use `/tool/:name` URLs to link directly to tools with params:
```html
<a href="/tool/code?app=my-app&version=20260130-000000">
<a href="/tool/code?app=my-app">
View in Code
</a>
```

26
install/bun.lock Normal file
View File

@ -0,0 +1,26 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "toes-install",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.2", "https://npm.nose.space/@types/node/-/node-25.3.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"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.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

137
install/install.sh Normal file
View File

@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail
##
# toes installer
# Usage: curl -fsSL https://toes.dev/install | sh
#
# Installs or updates toes on a Raspberry Pi.
# Must be run as the 'toes' user with passwordless sudo.
REPO="https://git.nose.space/defunkt/toes"
DEST=~/toes
APPS_DIR=~/apps
DATA_DIR=~/data
# ── Helpers ──────────────────────────────────────────────
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
quiet() { "$@" > /dev/null 2>&1; }
info() { echo " ${d}>>${r} $1"; }
fail() { echo " ${y}ERROR:${r} $1" >&2; exit 1; }
# ── Preflight ────────────────────────────────────────────
echo ""
echo " ${d}╔══════════════════════════════════╗${r}"
echo " ${d}${r} ${b}🐾 toes${r} ${d}- personal web appliance ║${r}"
echo " ${d}╚══════════════════════════════════╝${r}"
echo ""
[ "$(whoami)" = "toes" ] || fail "Must be run as the 'toes' user."
sudo -n true 2>/dev/null || fail "Requires passwordless sudo."
# ── System packages ──────────────────────────────────────
info "Updating system packages"
quiet sudo apt-get update
quiet sudo apt-get install -y git libcap2-bin avahi-utils fish unzip
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
info "Setting fish as default shell"
quiet sudo chsh -s /usr/bin/fish toes
fi
# ── Bun ──────────────────────────────────────────────────
BUN="$HOME/.bun/bin/bun"
if [ ! -x "$BUN" ]; then
info "Installing bun"
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
[ -x "$BUN" ] || fail "bun installation failed."
fi
sudo ln -sf "$BUN" /usr/local/bin/bun
sudo setcap 'cap_net_bind_service=+ep' "$BUN"
# ── Clone or pull ────────────────────────────────────────
if [ -d "$DEST/.git" ]; then
info "Pulling latest toes"
git -C "$DEST" fetch origin main
git -C "$DEST" reset --hard origin/main
else
info "Cloning toes"
git clone "$REPO" "$DEST"
fi
# ── Directories ──────────────────────────────────────────
mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes"
# ── Dependencies & build ─────────────────────────────────
cd "$DEST"
info "Installing dependencies"
quiet bun install
info "Building"
rm -rf "$DEST/dist"
quiet bun run build
# ── Bundled apps ─────────────────────────────────────────
REPOS_DIR="$DATA_DIR/repos"
mkdir -p "$REPOS_DIR"
info "Installing bundled apps"
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
echo " $app"
cp -a "$app_dir" "$APPS_DIR/$app"
quiet bun install --frozen-lockfile --cwd "$APPS_DIR/$app" || quiet bun install --cwd "$APPS_DIR/$app"
# Seed bare repo for git-based versioning
bare="$REPOS_DIR/$app.git"
quiet git -C "$APPS_DIR/$app" init -b main
quiet git -C "$APPS_DIR/$app" add -A
quiet git -C "$APPS_DIR/$app" -c user.name=toes -c user.email=toes@localhost commit -m "install"
if [ -d "$bare" ]; then
quiet git -C "$APPS_DIR/$app" push --force "$bare" main
else
quiet git clone --bare "$APPS_DIR/$app" "$bare"
quiet git -C "$bare" config http.receivepack true
fi
rm -rf "$APPS_DIR/$app/.git"
done
# ── Systemd ──────────────────────────────────────────────
info "Installing toes service"
sudo install -m 644 "$DEST/scripts/toes.service" /etc/systemd/system/toes.service
sudo systemctl daemon-reload
sudo systemctl enable toes
info "Restarting toes"
sudo systemctl restart toes
# ── Done ─────────────────────────────────────────────────
VERSION=$(git describe --tags --always 2>/dev/null || echo "unknown")
echo ""
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://$(hostname).local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"
echo ""

16
install/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "toes-install",
"version": "0.0.1",
"description": "install toes",
"module": "server.ts",
"type": "module",
"scripts": {
"start": "bun run server.ts"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.3"
}
}

17
install/server.ts Normal file
View File

@ -0,0 +1,17 @@
import { resolve } from "path"
const script = await Bun.file(resolve(import.meta.dir, "install.sh")).text()
Bun.serve({
port: parseInt(process.env.PORT || "3000"),
fetch(req) {
if (new URL(req.url).pathname === "/install") {
return new Response(script, {
headers: { "content-type": "text/plain" },
})
}
return new Response("404 Not Found", { status: 404 })
},
})
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)

43
install/tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"exclude": ["apps", "templates"],
"compilerOptions": {
// Environment setup & latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": [
"./src/server/*"
],
"@*": [
"./src/shared/*"
],
"%*": [
"./src/lib/*"
]
}
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@because/toes",
"version": "0.0.8",
"version": "0.0.12",
"description": "personal web appliance - turn it on and forget about the cloud",
"module": "src/index.ts",
"type": "module",
@ -15,7 +15,7 @@
"toes": "src/cli/index.ts"
},
"scripts": {
"check": "bunx tsc --noEmit",
"check": "bun run templates && bunx tsc --noEmit",
"build": "./scripts/build.sh",
"cli:build": "bun run scripts/build.ts",
"cli:build:all": "bun run scripts/build.ts --all",
@ -24,14 +24,16 @@
"cli:uninstall": "sudo rm /usr/local/bin",
"deploy": "./scripts/deploy.sh",
"debug": "DEBUG=1 bun run dev",
"dev": "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:install": "./scripts/remote-install.sh",
"remote:logs": "./scripts/remote-logs.sh",
"remote:restart": "./scripts/remote-restart.sh",
"remote:start": "./scripts/remote-start.sh",
"remote:stop": "./scripts/remote-stop.sh",
"start": "bun run src/server/index.tsx",
"start": "bun run templates && bun run src/server/index.tsx",
"templates": "bun run scripts/embed-templates.ts",
"test": "bun test"
},
"devDependencies": {
@ -44,8 +46,9 @@
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/sneaker": "^0.0.3",
"commander": "^14.0.3",
"@because/sneaker": "^0.0.4",
"@because/toes": "^0.0.12",
"commander": "14.0.3",
"diff": "^8.0.3",
"kleur": "^4.1.5"
}

View File

@ -9,6 +9,7 @@ import { join } from 'path'
const DIST_DIR = join(import.meta.dir, '..', 'dist')
const ENTRY_POINT = join(import.meta.dir, '..', 'src', 'cli', 'index.ts')
const GIT_SHA = Bun.spawnSync(['git', 'rev-parse', '--short', 'HEAD']).stdout.toString().trim() || 'unknown'
interface BuildTarget {
arch: string
@ -23,47 +24,6 @@ const TARGETS: BuildTarget[] = [
{ os: 'linux', arch: 'x64', name: 'toes-linux-x64' },
]
// Ensure dist directory exists
if (!existsSync(DIST_DIR)) {
mkdirSync(DIST_DIR, { recursive: true })
}
// Parse command line args
const args = process.argv.slice(2)
const buildAll = args.includes('--all')
const targetArg = args.find(arg => arg.startsWith('--target='))?.split('=')[1]
async function buildTarget(target: BuildTarget) {
console.log(`Building ${target.name}...`)
const output = join(DIST_DIR, target.name)
const proc = Bun.spawn([
'bun',
'build',
ENTRY_POINT,
'--compile',
'--target',
`bun-${target.os}-${target.arch}`,
'--minify',
'--sourcemap=external',
'--outfile',
output,
], {
stdout: 'inherit',
stderr: 'inherit',
})
const exitCode = await proc.exited
if (exitCode === 0) {
console.log(`✓ Built ${target.name}`)
} else {
console.error(`✗ Failed to build ${target.name}`)
process.exit(exitCode)
}
}
async function buildCurrent() {
const platform = process.platform
const arch = process.arch
@ -81,6 +41,7 @@ async function buildCurrent() {
'bun',
'--minify',
'--sourcemap=external',
`--define=__GIT_SHA__="${GIT_SHA}"`,
'--outfile',
output,
], {
@ -100,6 +61,58 @@ async function buildCurrent() {
}
}
async function buildTarget(target: BuildTarget) {
console.log(`Building ${target.name}...`)
const output = join(DIST_DIR, target.name)
const proc = Bun.spawn([
'bun',
'build',
ENTRY_POINT,
'--compile',
'--target',
`bun-${target.os}-${target.arch}`,
'--minify',
'--sourcemap=external',
`--define=__GIT_SHA__="${GIT_SHA}"`,
'--outfile',
output,
], {
stdout: 'inherit',
stderr: 'inherit',
})
const exitCode = await proc.exited
if (exitCode === 0) {
console.log(`✓ Built ${target.name}`)
} else {
console.error(`✗ Failed to build ${target.name}`)
process.exit(exitCode)
}
}
// Embed template files before compiling
const embedProc = Bun.spawn(['bun', 'run', join(import.meta.dir, 'embed-templates.ts')], {
stdout: 'inherit',
stderr: 'inherit',
})
if (await embedProc.exited !== 0) {
console.error('✗ Failed to embed templates')
process.exit(1)
}
// Ensure dist directory exists
if (!existsSync(DIST_DIR)) {
mkdirSync(DIST_DIR, { recursive: true })
}
// Parse command line args
const args = process.argv.slice(2)
const buildAll = args.includes('--all')
const targetArg = args.find(arg => arg.startsWith('--target='))?.split('=')[1]
// Main build logic
if (buildAll) {
console.log('Building for all targets...\n')

View File

@ -2,8 +2,10 @@
# It isn't enough to modify this yet.
# You also need to manually update the toes.service file.
HOST="${HOST:-toes@toes.local}"
URL="${URL:-http://toes.local}"
DEST="${DEST:-~/toes}"
DATA_DIR="${DATA_DIR:-~/data}"
APPS_DIR="${APPS_DIR:-~/apps}"
TOES_USER="${TOES_USER:-toes}"
HOST="${HOST:-toes.local}"
SSH_HOST="$TOES_USER@$HOST"
URL="${URL:-http://$HOST}"
DEST="${DEST:-$HOME/toes}"
DATA_DIR="${DATA_DIR:-$HOME/data}"
APPS_DIR="${APPS_DIR:-$HOME/apps}"

View File

@ -11,29 +11,60 @@ source "$ROOT_DIR/scripts/config.sh"
git push origin main
# SSH to target: pull, build, sync apps, restart
ssh "$HOST" DEST="$DEST" APPS_DIR="$APPS_DIR" bash <<'SCRIPT'
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")
for version_dir in "$app_dir"*/; do
[ -d "$version_dir" ] || continue
version=$(basename "$version_dir")
[ -f "$version_dir/package.json" ] || continue
target="$APPS_DIR/$app/$version"
[ -f "$app_dir/package.json" ] || continue
target="$APPS_DIR/$app"
mkdir -p "$target"
cp -a "$version_dir"/. "$target"/
rm -f "$APPS_DIR/$app/current"
echo " $app/$version"
cp -a "$app_dir"/. "$target"/
echo " $app"
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
done
echo "=> Initializing bare repos..."
mkdir -p "$REPOS_DIR"
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
bare="$REPOS_DIR/$app.git"
if [ ! -d "$bare" ]; then
git init --bare -b main "$bare" > /dev/null
git -C "$bare" config http.receivepack true
fi
tmp=$(mktemp -d)
cp -a "$app_dir"/. "$tmp"/
git -C "$tmp" init -b main > /dev/null 2>&1
git -C "$tmp" add -A > /dev/null
git -C "$tmp" -c user.name=toes -c user.email=toes@localhost commit -m "deploy" > /dev/null 2>&1
git -C "$tmp" push --force "$bare" main > /dev/null 2>&1
rm -rf "$tmp"
echo " $app"
done
sudo systemctl restart toes.service
SCRIPT
echo "=> Deployed to $HOST"
echo "=> Visit $URL"
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' r=$'\033[0m'
echo ""
echo " ${b}${g}🐾 Deployed${r} to ${b}$SSH_HOST${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}$URL${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL $URL/install | bash${r}"
echo ""

View File

@ -0,0 +1,71 @@
#!/usr/bin/env bun
// Generates src/lib/templates.data.ts with embedded template file contents.
// Run: bun run templates
import { readdirSync, readFileSync, statSync } from 'fs'
import { extname, join, relative } from 'path'
const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.svg'])
const TEMPLATES_DIR = join(import.meta.dir, '..', 'templates')
const binary: Record<string, Record<string, string>> = {}
const shared: Record<string, string> = {}
const templates: Record<string, Record<string, string>> = {}
const isBinary = (path: string) =>
BINARY_EXTENSIONS.has(extname(path))
function readDir(dir: string): string[] {
const files: string[] = []
for (const entry of readdirSync(dir)) {
const path = join(dir, entry)
if (statSync(path).isDirectory()) {
files.push(...readDir(path))
} else {
files.push(path)
}
}
return files.sort()
}
// First pass: collect shared files (root level)
for (const entry of readdirSync(TEMPLATES_DIR).sort()) {
const path = join(TEMPLATES_DIR, entry)
if (!statSync(path).isDirectory()) {
shared[entry] = readFileSync(path, 'utf-8')
}
}
// Second pass: build template maps with shared files folded in
for (const entry of readdirSync(TEMPLATES_DIR).sort()) {
const path = join(TEMPLATES_DIR, entry)
if (statSync(path).isDirectory()) {
templates[entry] = { ...shared }
binary[entry] = {}
for (const filePath of readDir(path)) {
const filename = relative(path, filePath)
if (isBinary(filePath)) {
binary[entry]![filename] = readFileSync(filePath).toString('base64')
} else {
templates[entry]![filename] = readFileSync(filePath, 'utf-8')
}
}
if (Object.keys(binary[entry]!).length === 0) {
delete binary[entry]
}
}
}
// Generate TypeScript module
const lines: string[] = [
'// Auto-generated by scripts/embed-templates.ts',
'// Run `bun run templates` to regenerate',
'',
`export const TEMPLATES: Record<string, Record<string, string>> = ${JSON.stringify(templates, null, 2)}`,
'',
`export const BINARY: Record<string, Record<string, string>> = ${JSON.stringify(binary, null, 2)}`,
'',
]
const outPath = join(import.meta.dir, '..', 'src', 'lib', 'templates.data.ts')
await Bun.write(outPath, lines.join('\n'))
console.log(`✓ Embedded templates → ${relative(join(import.meta.dir, '..'), outPath)}`)

View File

@ -1,126 +1,9 @@
#!/usr/bin/env bash
##
# installs systemd files to keep toes running on your Raspberry Pi
# installs toes on your Raspberry Pi
# delegates to the canonical installer at install/install.sh
set -euo pipefail
quiet() { "$@" > /dev/null 2>&1; }
SYSTEMD_DIR="/etc/systemd/system"
SERVICE_NAME="toes"
SERVICE_FILE="$(dirname "$0")/${SERVICE_NAME}.service"
SYSTEMD_PATH="${SYSTEMD_DIR}/${SERVICE_NAME}.service"
BUN_SYMLINK="/usr/local/bin/bun"
BUN_REAL="$HOME/.bun/bin/bun"
echo ">> Updating system libraries"
quiet sudo apt-get update
quiet sudo apt-get install -y libcap2-bin
quiet sudo apt-get install -y avahi-utils
quiet sudo apt-get install -y dnsmasq
quiet sudo apt-get install -y fish
echo ">> Setting fish as default shell for toes user"
if [ "$(getent passwd toes | cut -d: -f7)" != "/usr/bin/fish" ]; then
quiet sudo chsh -s /usr/bin/fish toes
echo "Default shell changed to fish"
else
echo "fish already set as default shell"
fi
echo ">> Ensuring bun is available in /usr/local/bin"
if [ ! -x "$BUN_SYMLINK" ]; then
if [ -x "$BUN_REAL" ]; then
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
else
echo ">> Installing bun at $BUN_REAL"
quiet sudo apt install unzip
curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1
if [ ! -x "$BUN_REAL" ]; then
echo "ERROR: bun installation failed - $BUN_REAL not found"
exit 1
fi
quiet sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
fi
else
echo "bun already available at $BUN_SYMLINK"
fi
echo ">> Setting CAP_NET_BIND_SERVICE on $BUN_REAL"
quiet sudo setcap 'cap_net_bind_service=+ep' "$BUN_REAL"
quiet /usr/sbin/getcap "$BUN_REAL" || true
echo ">> Creating data and apps directories"
mkdir -p ~/data
mkdir -p ~/apps
echo ">> Installing bundled apps"
BUNDLED_APPS="clock code cron env stats versions"
for app in $BUNDLED_APPS; do
if [ -d "apps/$app" ]; then
echo " Installing $app..."
cp -r "apps/$app" ~/apps/
version_dir=$(ls -1 ~/apps/$app | grep -E '^[0-9]{8}-[0-9]{6}$' | sort -r | head -1)
if [ -n "$version_dir" ]; then
ln -sfn "$version_dir" ~/apps/$app/current
if ! (cd ~/apps/$app/current && bun install --frozen-lockfile) > /dev/null 2>&1; then
echo " WARNING: bun install failed for $app, trying without lockfile..."
(cd ~/apps/$app/current && bun install) > /dev/null 2>&1 || echo " ERROR: bun install failed for $app"
fi
else
echo " WARNING: no version directory found for $app, skipping"
fi
fi
done
echo ">> Installing dependencies"
bun install
echo ">> Building client bundle"
bun run build
echo ">> Installing toes service"
quiet sudo install -m 644 -o root -g root "$SERVICE_FILE" "$SYSTEMD_PATH"
echo ">> Reloading systemd daemon"
quiet sudo systemctl daemon-reload
echo ">> Enabling $SERVICE_NAME to start at boot"
quiet sudo systemctl enable "$SERVICE_NAME"
echo ">> Starting (or restarting) $SERVICE_NAME"
quiet sudo systemctl restart "$SERVICE_NAME"
echo ">> Enabling kiosk mode"
sudo raspi-config nonint do_boot_behaviour B4
# labwc (older RPi OS / manual installs)
mkdir -p ~/.config/labwc
cat > ~/.config/labwc/autostart <<'EOF'
chromium --noerrdialogs --disable-infobars --kiosk http://localhost
EOF
# Wayfire (RPi OS Bookworm default)
WAYFIRE_CONFIG="$HOME/.config/wayfire.ini"
if [ -f "$WAYFIRE_CONFIG" ]; then
# Remove existing chromium autostart if present
sed -i '/^chromium = /d' "$WAYFIRE_CONFIG"
# Add to existing [autostart] section or create it
if grep -q '^\[autostart\]' "$WAYFIRE_CONFIG"; then
sed -i '/^\[autostart\]/a chromium = chromium --noerrdialogs --disable-infobars --kiosk http://localhost' "$WAYFIRE_CONFIG"
else
cat >> "$WAYFIRE_CONFIG" <<'EOF'
[autostart]
chromium = chromium --noerrdialogs --disable-infobars --kiosk http://localhost
EOF
fi
fi
echo ">> Done! Rebooting in 5 seconds..."
systemctl status "$SERVICE_NAME" --no-pager -l || true
sleep 5
sudo reboot
exec "$(dirname "$0")/../install/install.sh"

View File

@ -9,4 +9,13 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
# Run remote install on the target
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"
ssh "$SSH_HOST" bash <<'SCRIPT'
set -e
DEST="${DEST:-$HOME/toes}"
if [ -d "$DEST/.git" ]; then
cd "$DEST" && git pull
else
git clone https://git.nose.space/defunkt/toes "$DEST" && cd "$DEST"
fi
./scripts/install.sh
SCRIPT

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "journalctl -u toes -n 100"
ssh "$SSH_HOST" "journalctl -u toes -n 100"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "sudo systemctl restart toes.service"
ssh "$SSH_HOST" "sudo systemctl restart toes.service"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "sudo systemctl start toes.service"
ssh "$SSH_HOST" "sudo systemctl start toes.service"

View File

@ -6,4 +6,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
ssh "$HOST" "sudo systemctl stop toes.service"
ssh "$SSH_HOST" "sudo systemctl stop toes.service"

65
scripts/setup-ssh.sh Executable file
View File

@ -0,0 +1,65 @@
#!/bin/bash
#
# setup-ssh.sh - Configure SSH for the toes CLI user
#
# This script:
# 1. Creates a `cli` system user with /usr/local/bin/toes as shell
# 2. Sets an empty password on `cli` for passwordless SSH
# 3. Adds a Match block in sshd_config to allow empty passwords for `cli`
# 4. Adds /usr/local/bin/toes to /etc/shells
# 5. Restarts sshd
#
# Run as root on the toes machine.
# Usage: ssh cli@toes.local
set -euo pipefail
TOES_SHELL="/usr/local/bin/toes"
SSHD_CONFIG="/etc/ssh/sshd_config"
echo "==> Setting up SSH CLI user for toes"
# 1. Create cli system user
if ! id cli &>/dev/null; then
useradd --system --home-dir /home/cli --shell "$TOES_SHELL" --create-home cli
echo " Created cli user"
else
echo " cli user already exists"
fi
# 2. Set empty password
passwd -d cli
echo " Set empty password on cli"
# 3. Add Match block for cli user in sshd_config
if ! grep -q 'Match User cli' "$SSHD_CONFIG"; then
cat >> "$SSHD_CONFIG" <<EOF
# toes CLI: allow passwordless SSH for the cli user
Match User cli
PermitEmptyPasswords yes
EOF
echo " Added Match User cli block to sshd_config"
else
echo " sshd_config already has Match User cli block"
fi
# 4. Ensure /usr/local/bin/toes is in /etc/shells
if ! grep -q "^${TOES_SHELL}$" /etc/shells; then
echo "$TOES_SHELL" >> /etc/shells
echo " Added $TOES_SHELL to /etc/shells"
else
echo " $TOES_SHELL already in /etc/shells"
fi
# Warn if toes binary doesn't exist yet
if [ ! -f "$TOES_SHELL" ]; then
echo " WARNING: $TOES_SHELL does not exist yet"
echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL"
fi
# 5. Restart sshd
echo " Restarting sshd..."
systemctl restart sshd || service ssh restart || true
echo "==> Done. Connect with: ssh cli@toes.local"

View File

@ -1,15 +0,0 @@
# dnsmasq config for Toes WiFi setup captive portal
# Redirect ALL DNS queries to the hotspot gateway IP
# Only listen on the hotspot interface
interface=wlan0
bind-interfaces
# Resolve everything to our IP (captive portal)
address=/#/10.42.0.1
# Don't use /etc/resolv.conf
no-resolv
# Don't read /etc/hosts
no-hosts

View File

@ -1,6 +1,6 @@
import type { LogLine } from '@types'
import color from 'kleur'
import { get, handleError, makeUrl, post } from '../http'
import { get, getSignal, handleError, makeUrl, post } from '../http'
import { resolveAppName } from '../name'
interface CronJobSummary {
@ -195,7 +195,7 @@ const printCronLog = (line: LogLine) =>
async function tailCronLogs(app: string, grep?: string) {
try {
const url = makeUrl(`/api/apps/${app}/logs/stream`)
const res = await fetch(url)
const res = await fetch(url, { signal: getSignal() })
if (!res.ok) {
console.error(`App not found: ${app}`)
return

View File

@ -2,7 +2,7 @@ export { cronList, cronLog, cronRun, cronStatus } from './cron'
export { envList, envRm, envSet } from './env'
export { logApp } from './logs'
export {
configShow,
getApp,
infoApp,
listApps,
newApp,
@ -16,4 +16,3 @@ export {
unshareApp,
} from './manage'
export { metricsApp } from './metrics'
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'

View File

@ -1,5 +1,5 @@
import type { LogLine } from '@types'
import { get, handleError, makeUrl } from '../http'
import { get, getSignal, handleError, makeUrl } from '../http'
import { resolveAppName } from '../name'
interface LogOptions {
@ -120,7 +120,7 @@ export async function logApp(arg: string | undefined, options: LogOptions) {
export async function tailLogs(name: string, grep?: string) {
try {
const url = makeUrl(`/api/apps/${name}/logs/stream`)
const res = await fetch(url)
const res = await fetch(url, { signal: getSignal() })
if (!res.ok) {
console.error(`App not found: ${name}`)
return

View File

@ -1,14 +1,12 @@
import type { App } from '@types'
import { generateTemplates, type TemplateType } from '%templates'
import { readSyncState } from '%sync'
import color from 'kleur'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { basename, join } from 'path'
import { buildAppUrl } from '@urls'
import { del, get, getManifest, HOST, post } from '../http'
import { del, get, getManifest, gitUrl, HOST, post } from '../http'
import { confirm, prompt } from '../prompts'
import { resolveAppName } from '../name'
import { pushApp } from './sync'
export const STATE_ICONS: Record<string, string> = {
error: color.red('●'),
@ -36,15 +34,6 @@ async function waitForState(name: string, target: string, timeout: number): Prom
return app?.state
}
export async function configShow() {
console.log(`Host: ${color.bold(HOST)}`)
const syncState = readSyncState(process.cwd())
if (syncState) {
console.log(`Version: ${color.bold(syncState.version)}`)
}
}
export async function infoApp(arg?: string) {
const name = resolveAppName(arg)
if (!name) return
@ -184,17 +173,42 @@ export async function newApp(name: string | undefined, options: NewAppOptions) {
writeFileSync(join(appPath, filename), content)
}
process.chdir(appPath)
await pushApp()
// Initialize git repo and push to server (git push creates the app via the git tool)
const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: appPath, stdout: 'ignore', stderr: 'ignore' }).exited
await run(['git', 'init'])
await run(['git', 'add', '.'])
await run(['git', 'commit', '-m', 'init'])
await run(['git', 'remote', 'add', 'toes', gitUrl(appName)])
await run(['git', 'push', 'toes', 'main'])
console.log(color.green(`✓ Created ${appName}`))
console.log()
console.log('Next steps:')
if (name) {
console.log(` cd ${name}`)
console.log(`\n cd ${name}`)
}
console.log(' bun install')
console.log(' bun dev')
}
export async function getApp(name: string, directory?: string) {
const target = directory ?? name
if (existsSync(target)) {
console.error(`Directory already exists: ${target}`)
return
}
const url = gitUrl(name)
const args = ['git', 'clone', url]
if (directory) args.push(directory)
const proc = Bun.spawn(args, { stdout: 'inherit', stderr: 'inherit' })
const exitCode = await proc.exited
if (exitCode !== 0) {
console.error(color.red(`Failed to clone ${name}`))
return
}
console.log(color.green(`✓ Cloned ${name}`))
console.log(`\n cd ${target}\n bun install`)
}
export async function openApp(arg?: string) {

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,9 @@
import type { Manifest } from '@types'
import { buildAppUrl } from '@urls'
import { AsyncLocalStorage } from 'node:async_hooks'
const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local'
const signalStore = new AsyncLocalStorage<AbortSignal>()
const normalizeUrl = (url: string) =>
url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}`
@ -18,6 +21,14 @@ export const HOST = process.env.TOES_URL
? normalizeUrl(process.env.TOES_URL)
: DEFAULT_HOST
export const gitUrl = (name: string) => `${buildAppUrl('git', HOST)}/${name}`
export const getSignal = () => signalStore.getStore()
export function withSignal<T>(signal: AbortSignal, fn: () => T): T {
return signalStore.run(signal, fn)
}
export function makeUrl(path: string): string {
return `${HOST}${path}`
}
@ -37,7 +48,7 @@ export function handleError(error: unknown): void {
export async function get<T>(url: string): Promise<T | undefined> {
try {
const res = await fetch(makeUrl(url))
const res = await fetch(makeUrl(url), { signal: getSignal() })
if (!res.ok) {
const text = await res.text()
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
@ -49,14 +60,13 @@ export async function get<T>(url: string): Promise<T | undefined> {
}
}
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> {
try {
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() })
if (res.status === 404) return { exists: false }
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const data = await res.json()
const { version, ...manifest } = data
return { exists: true, manifest, version }
const manifest = await res.json()
return { exists: true, manifest }
} catch (error) {
handleError(error)
return null
@ -69,6 +79,7 @@ export async function post<T, B = unknown>(url: string, body?: B): Promise<T | u
method: 'POST',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: getSignal(),
})
if (!res.ok) {
const text = await res.text()
@ -86,6 +97,7 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
const res = await fetch(makeUrl(url), {
method: 'PUT',
body: body as BodyInit,
signal: getSignal(),
})
if (!res.ok) {
const text = await res.text()
@ -102,7 +114,7 @@ export async function put(url: string, body: Buffer | Uint8Array): Promise<boole
export async function download(url: string): Promise<Buffer | undefined> {
try {
const fullUrl = makeUrl(url)
const res = await fetch(fullUrl)
const res = await fetch(fullUrl, { signal: getSignal() })
if (!res.ok) {
const text = await res.text()
const msg = tryParseError(text) ?? `${res.status} ${res.statusText}`
@ -118,6 +130,7 @@ export async function del(url: string): Promise<boolean> {
try {
const res = await fetch(makeUrl(url), {
method: 'DELETE',
signal: getSignal(),
})
if (!res.ok) {
const text = await res.text()

View File

@ -1,4 +1,13 @@
#!/usr/bin/env bun
import { program } from './setup'
const isCliUser = process.env.USER === 'cli'
const noArgs = process.argv.length <= 2
const isTTY = !!process.stdin.isTTY
if (isCliUser && noArgs && isTTY) {
const { shell } = await import('./shell')
await shell()
} else {
program.parse()
}

View File

@ -3,47 +3,34 @@ import { program } from 'commander'
import color from 'kleur'
import pkg from '../../package.json'
import { withPager } from './pager'
import { SHA } from './sha'
import {
cleanApp,
configShow,
cronList,
cronLog,
cronRun,
cronStatus,
diffApp,
envList,
envRm,
envSet,
getApp,
historyApp,
infoApp,
listApps,
logApp,
newApp,
openApp,
pullApp,
pushApp,
renameApp,
restartApp,
rmApp,
rollbackApp,
stashApp,
stashListApp,
stashPopApp,
shareApp,
startApp,
metricsApp,
shareApp,
statusApp,
stopApp,
syncApp,
unshareApp,
versionsApp,
} from './commands'
program
.name('toes')
.version(`v${pkg.version}`, '-v, --version')
.version(`v${pkg.version}-${SHA}`, '-v, --version')
.addHelpText('beforeAll', (ctx) => {
if (ctx.command === program) {
return color.bold().cyan('🐾 Toes') + color.gray(' - personal web appliance\n')
@ -93,8 +80,9 @@ program
program
.command('get')
.helpGroup('Apps:')
.description('Download an app from server')
.description('Clone an app from the server')
.argument('<name>', 'app name')
.argument('[directory]', 'target directory (defaults to app name)')
.action(getApp)
program
@ -209,72 +197,8 @@ cron
.argument('<job>', 'job identifier (app:name)')
.action(cronRun)
// Sync
program
.command('push')
.helpGroup('Sync:')
.description('Push local changes to server')
.option('-f, --force', 'overwrite remote changes')
.action(pushApp)
program
.command('pull')
.helpGroup('Sync:')
.description('Pull changes from server')
.option('-f, --force', 'overwrite local changes')
.action(pullApp)
program
.command('status')
.helpGroup('Sync:')
.description('Show what would be pushed/pulled')
.action(statusApp)
program
.command('diff')
.helpGroup('Sync:')
.description('Show diff of changed files')
.action(() => withPager(diffApp))
program
.command('sync')
.helpGroup('Sync:')
.description('Watch and sync changes bidirectionally')
.action(syncApp)
program
.command('clean')
.helpGroup('Sync:')
.description('Remove local files not on server')
.option('-f, --force', 'skip confirmation')
.option('-n, --dry-run', 'show what would be removed')
.action(cleanApp)
const stash = program
.command('stash')
.helpGroup('Sync:')
.description('Stash local changes')
.action(stashApp)
stash
.command('pop')
.description('Restore stashed changes')
.action(stashPopApp)
stash
.command('list')
.description('List all stashes')
.action(stashListApp)
// Config
program
.command('config')
.helpGroup('Config:')
.description('Show current host configuration')
.action(configShow)
const env = program
.command('env')
.helpGroup('Config:')
@ -300,26 +224,14 @@ env
.option('-g, --global', 'remove a global variable')
.action(envRm)
program
.command('versions')
.helpGroup('Config:')
.description('List deployed versions')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(versionsApp)
// Shell
program
.command('history')
.helpGroup('Config:')
.description('Show file changes between versions')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(historyApp)
program
.command('rollback')
.helpGroup('Config:')
.description('Rollback to a previous version')
.argument('[name]', 'app name (uses current directory if omitted)')
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
.action((name, options) => rollbackApp(name, options.version))
.command('shell')
.description('Interactive shell')
.action(async () => {
const { shell } = await import('./shell')
await shell()
})
export { program }

3
src/cli/sha.ts Normal file
View File

@ -0,0 +1,3 @@
declare var __GIT_SHA__: string | undefined
export const SHA: string = typeof __GIT_SHA__ !== 'undefined' ? __GIT_SHA__ : 'dev'

227
src/cli/shell.ts Normal file
View File

@ -0,0 +1,227 @@
import type { App } from '@types'
import * as readline from 'readline'
import color from 'kleur'
import { get, handleError, HOST, withSignal } from './http'
import { program } from './setup'
import { STATE_ICONS } from './commands/manage'
let appNamesCache: string[] = []
let appNamesCacheTime = 0
const APP_CACHE_TTL = 5000
function tokenize(input: string): string[] {
const tokens: string[] = []
let current = ''
let quote: string | null = null
for (const ch of input) {
if (quote) {
if (ch === quote) {
quote = null
} else {
current += ch
}
} else if (ch === '"' || ch === "'") {
quote = ch
} else if (ch === ' ' || ch === '\t') {
if (current) {
tokens.push(current)
current = ''
}
} else {
current += ch
}
}
if (current) tokens.push(current)
return tokens
}
async function fetchAppNames(): Promise<string[]> {
const now = Date.now()
if (appNamesCache.length > 0 && now - appNamesCacheTime < APP_CACHE_TTL) {
return appNamesCache
}
try {
const apps = await get<App[]>('/api/apps')
if (apps) {
appNamesCache = apps.map(a => a.name)
appNamesCacheTime = now
}
} catch {
// use stale cache
}
return appNamesCache
}
function getCommandNames(): string[] {
return program.commands
.filter((cmd) => !(cmd as any)._hidden)
.map((cmd) => cmd.name())
}
async function printBanner(): Promise<void> {
const apps = await get<App[]>('/api/apps')
if (!apps) {
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
console.log()
return
}
// Cache app names from banner fetch
appNamesCache = apps.map(a => a.name)
appNamesCacheTime = Date.now()
const visibleApps = apps.filter(a => !a.tool)
console.log()
console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`)
console.log()
// App status line
const parts = visibleApps.map(a => {
const icon = STATE_ICONS[a.state] ?? '\u25CB'
return `${icon} ${a.name}`
})
if (parts.length > 0) {
console.log(' ' + parts.join(' '))
console.log()
}
const running = visibleApps.filter(a => a.state === 'running').length
const stopped = visibleApps.filter(a => a.state !== 'running').length
const summary = []
if (running) summary.push(`${running} running`)
if (stopped) summary.push(`${stopped} stopped`)
if (summary.length > 0) {
console.log(color.gray(` ${summary.join(', ')} \u2014 type "help" for commands`))
} else {
console.log(color.gray(' no apps \u2014 type "help" for commands'))
}
console.log()
}
export async function shell(): Promise<void> {
await printBanner()
// Configure Commander to throw instead of exiting
program.exitOverride()
program.configureOutput({
writeOut: (str: string) => process.stdout.write(str),
writeErr: (str: string) => process.stderr.write(str),
})
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: color.cyan('toes> '),
completer: (line: string, callback: (err: null, result: [string[], string]) => void) => {
const tokens = tokenize(line)
const trailing = line.endsWith(' ')
if (tokens.length === 0 || (tokens.length === 1 && !trailing)) {
// Complete command names
const partial = tokens[0] ?? ''
const commands = getCommandNames()
const hits = commands.filter(c => c.startsWith(partial))
callback(null, [hits, partial])
} else {
// Complete app names
const partial = trailing ? '' : (tokens[tokens.length - 1] ?? '')
const names = appNamesCache
const hits = names.filter(n => n.startsWith(partial))
callback(null, [hits, partial])
}
},
})
// Refresh app names cache in background for tab completion
fetchAppNames()
let activeAbort: AbortController | null = null
rl.on('SIGINT', () => {
if (activeAbort) {
activeAbort.abort()
activeAbort = null
console.log()
rl.prompt()
} else {
// Clear current line
rl.write(null, { ctrl: true, name: 'u' })
console.log()
rl.prompt()
}
})
rl.prompt()
for await (const line of rl) {
const input = line.trim()
if (!input) {
rl.prompt()
continue
}
if (input === 'exit' || input === 'quit') {
break
}
if (input === 'clear') {
console.clear()
rl.prompt()
continue
}
if (input === 'help') {
program.outputHelp()
rl.prompt()
continue
}
const tokens = tokenize(input)
// Set up AbortController for this command
activeAbort = new AbortController()
const signal = activeAbort.signal
// Pause readline so commands can use their own prompts
rl.pause()
try {
await withSignal(signal, () => program.parseAsync(['node', 'toes', ...tokens]))
} catch (err: unknown) {
// Commander throws on exitOverride — suppress help/version exits
if (err && typeof err === 'object' && 'code' in err) {
const code = (err as { code: string }).code
if (code === 'commander.helpDisplayed' || code === 'commander.version') {
// Already printed, just continue
} else if (code === 'commander.unknownCommand') {
console.error(`Unknown command: ${tokens[0]}`)
} else {
// Other Commander errors (missing arg, etc.)
// Commander already printed the error message
}
} else if (signal.aborted) {
// Command was cancelled by Ctrl+C
} else {
handleError(err)
}
} finally {
activeAbort = null
}
// Refresh app names cache after commands that might change state
fetchAppNames()
rl.resume()
rl.prompt()
}
rl.close()
console.log()
}

View File

@ -1,32 +1,29 @@
import type { ConnectResult, WifiNetwork, WifiStatus } from '../shared/types'
export const connectToWifi = (ssid: string, password?: string): Promise<ConnectResult> =>
fetch('/api/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, password }),
}).then(r => r.json())
export const getLogDates = (name: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs/dates`).then(r => r.json())
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
export const getWifiStatus = (): Promise<WifiStatus & { setupMode: boolean, url: string }> =>
fetch('/api/wifi/status').then(r => r.json())
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
export const scanWifiNetworks = (): Promise<WifiNetwork[]> =>
fetch('/api/wifi/scan').then(r => r.json())
export const getSystemInfo = (): Promise<{ version: string, sha: string }> =>
fetch('/api/system/info').then(r => r.json())
export const shareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
export const unshareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
export const applyUpdate = () =>
fetch('/api/system/update', { method: 'POST' }).then(r => { if (!r.ok) throw new Error('update failed'); return r.json() })
export const checkForUpdate = (): Promise<{ available: boolean, current: string, latest: string, commits: string[] }> =>
fetch('/api/system/update').then(r => { if (!r.ok) throw new Error('check failed'); return r.json() })
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
export const restartServer = () =>
fetch('/api/system/restart', { method: 'POST' }).then(r => { if (!r.ok) throw new Error('restart failed'); return r.json() })
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
export const stopApp = (name: string) => fetch(`/api/apps/${name}/stop`, { method: 'POST' })
export const unshareApp = (name: string) =>
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })

View File

@ -2,13 +2,14 @@ import { define } from '@because/forge'
import type { App } from '../../shared/types'
import { buildAppUrl } from '../../shared/urls'
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, getSelectedTab, isNarrow } from '../state'
import { openDeleteAppModal, openRenameAppModal } from '../modals'
import { apps, getSelectedTab, isNarrow, setMobileSidebar } from '../state'
import {
ActionBar,
AppSelectorChevron,
Button,
ClickableAppName,
HamburgerButton,
HamburgerLine,
HeaderActions,
InfoLabel,
InfoRow,
@ -44,25 +45,26 @@ const OpenEmojiPicker = define('OpenEmojiPicker', {
})
export function AppDetail({ app, render }: { app: App, render: () => void }) {
// Find all tools
const tools = apps.filter(a => a.tool)
// Find tools that show on app pages (apps !== false)
const tools = apps.filter(a => a.tool && a.apps !== false)
const selectedTab = getSelectedTab(app.name)
return (
<Main>
<MainHeader>
<MainTitle>
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
&nbsp;
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
{isNarrow && (
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
</AppSelectorChevron>
<HamburgerButton onClick={() => { setMobileSidebar(true); render() }} title="Show apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)}
<OpenEmojiPicker app={app} render={render}>{app.icon}</OpenEmojiPicker>
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
</MainTitle>
<HeaderActions>
{!app.tool && (
{(!app.tool || app.share) && (
app.tunnelUrl
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
: app.tunnelEnabled

View File

@ -2,7 +2,6 @@ import type { CSSProperties } from 'hono/jsx'
import {
apps,
selectedApp,
setSelectedApp,
setSidebarSection,
sidebarSection,
} from '../state'
@ -17,19 +16,13 @@ import {
interface AppSelectorProps {
render: () => void
onSelect?: () => void
onDashboard?: () => void
collapsed?: boolean
large?: boolean
switcherStyle?: CSSProperties
listStyle?: CSSProperties
}
export function AppSelector({ render, onSelect, onDashboard, collapsed, switcherStyle, listStyle }: AppSelectorProps) {
const selectApp = (name: string) => {
setSelectedApp(name)
onSelect?.()
render()
}
export function AppSelector({ render, onSelect, collapsed, large, switcherStyle, listStyle }: AppSelectorProps) {
const switchSection = (section: 'apps' | 'tools') => {
setSidebarSection(section)
render()
@ -43,18 +36,18 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
<>
{!collapsed && toolApps.length > 0 && (
<SectionSwitcher style={switcherStyle}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}>
<SectionTab active={sidebarSection === 'apps' ? true : undefined} large={large || undefined} onClick={() => switchSection('apps')}>
Apps
</SectionTab>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
<SectionTab active={sidebarSection === 'tools' ? true : undefined} large={large || undefined} onClick={() => switchSection('tools')}>
Tools
</SectionTab>
</SectionSwitcher>
)}
<AppList style={listStyle}>
{collapsed && onDashboard && (
{collapsed && (
<AppItem
onClick={onDashboard}
href="/"
selected={!selectedApp ? true : undefined}
style={{ justifyContent: 'center', padding: '10px 12px' }}
title="Toes"
@ -65,7 +58,9 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
{activeApps.map(app => (
<AppItem
key={app.name}
onClick={() => selectApp(app.name)}
href={`/app/${app.name}`}
onClick={onSelect}
large={large || undefined}
selected={app.name === selectedApp ? true : undefined}
style={collapsed ? { justifyContent: 'center', padding: '10px 12px' } : undefined}
title={collapsed ? app.name : undefined}
@ -74,7 +69,7 @@ export function AppSelector({ render, onSelect, onDashboard, collapsed, switcher
<span style={{ fontSize: 18 }}>{app.icon}</span>
) : (
<>
<span style={{ fontSize: 14 }}>{app.icon}</span>
<span style={{ fontSize: large ? 20 : 14 }}>{app.icon}</span>
{app.name}
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
</>

View File

@ -1,13 +1,47 @@
import { Styles } from '@because/forge'
import { apps, currentView, isNarrow, selectedApp, setupMode } from '../state'
import { Layout } from '../styles'
import { openNewAppModal } from '../modals'
import { apps, currentView, isNarrow, mobileSidebar, selectedApp, setMobileSidebar } from '../state'
import {
HamburgerButton,
HamburgerLine,
Layout,
Main,
MainContent as MainContentContainer,
MainHeader,
MainTitle,
NewAppButton,
} from '../styles'
import { AppDetail } from './AppDetail'
import { AppSelector } from './AppSelector'
import { DashboardLanding } from './DashboardLanding'
import { Modal } from './modal'
import { SettingsPage } from './SettingsPage'
import { Sidebar } from './Sidebar'
function MobileSidebar({ render }: { render: () => void }) {
return (
<Main>
<MainHeader>
<MainTitle>
<HamburgerButton onClick={() => { setMobileSidebar(false); render() }} title="Hide apps">
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
<a href="/" style={{ textDecoration: 'none', color: 'inherit' }}>🐾 Toes</a>
</MainTitle>
</MainHeader>
<MainContentContainer>
<AppSelector render={render} large />
<div style={{ padding: '12px 16px' }}>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>
</div>
</MainContentContainer>
</Main>
)
}
function MainContent({ render }: { render: () => void }) {
if (isNarrow && mobileSidebar) return <MobileSidebar render={render} />
const selected = apps.find(a => a.name === selectedApp)
if (selected) return <AppDetail app={selected} render={render} />
if (currentView === 'settings') return <SettingsPage render={render} />
@ -18,9 +52,8 @@ export function Dashboard({ render }: { render: () => void }) {
return (
<Layout>
<Styles />
{!isNarrow && !setupMode && <Sidebar render={render} />}
{!isNarrow && <Sidebar render={render} />}
<MainContent render={render} />
<Modal />
</Layout>
)
}

View File

@ -1,36 +1,44 @@
import { useEffect } from 'hono/jsx'
import { openAppSelectorModal } from '../modals'
import { apps, isNarrow, setCurrentView, setSelectedApp } from '../state'
import { navigate } from '../router'
import { apps, dashboardTab, isNarrow, setMobileSidebar } from '../state'
import {
AppSelectorChevron,
HamburgerButton,
HamburgerLine,
DashboardContainer,
DashboardHeader,
DashboardTitle,
Section,
SettingsGear,
StatusDot,
StatusDotLink,
StatusDotsRow,
Tab,
TabBar,
TabContent,
} from '../styles'
import { update } from '../update'
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
import { Vitals, initVitals } from './Vitals'
let activeTooltip: string | null = null
import { theme } from '../themes'
import { UnifiedLogs, initUnifiedLogs, scrollLogsToBottom } from './UnifiedLogs'
import { Urls } from './Urls'
import { initVitals } from './Vitals'
export function DashboardLanding({ render }: { render: () => void }) {
useEffect(() => {
initUnifiedLogs()
initVitals()
if (dashboardTab === 'logs') scrollLogsToBottom()
}, [])
const narrow = isNarrow || undefined
const dashboardTools = apps.filter(a => a.tool && a.dashboard)
const openSettings = () => {
setSelectedApp(null)
setCurrentView('settings')
render()
navigate('/settings')
}
const switchTab = (tab: string) => {
navigate(tab === 'urls' ? '/' : `/${tab}`)
if (tab === 'logs') scrollLogsToBottom()
}
const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1))
return (
<DashboardContainer narrow={narrow} relative>
<SettingsGear
@ -40,43 +48,68 @@ export function DashboardLanding({ render }: { render: () => void }) {
>
</SettingsGear>
{isNarrow && (
<HamburgerButton
onClick={() => { setMobileSidebar(true); render() }}
title="Show apps"
style={{ position: 'absolute', top: 16, left: 16 }}
>
<HamburgerLine />
<HamburgerLine />
<HamburgerLine />
</HamburgerButton>
)}
<DashboardHeader>
<DashboardTitle narrow={narrow}>
🐾 Toes
{isNarrow && (
<AppSelectorChevron onClick={() => openAppSelectorModal(render)}>
</AppSelectorChevron>
)}
</DashboardTitle>
</DashboardHeader>
<StatusDotsRow>
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
<StatusDotLink
key={app.name}
data-tooltip={app.name}
tooltipVisible={activeTooltip === app.name || undefined}
onClick={(e: Event) => {
e.preventDefault()
if (isNarrow && activeTooltip !== app.name) {
activeTooltip = app.name
render()
return
}
activeTooltip = null
setSelectedApp(app.name)
update()
}}
<TabBar centered>
<Tab active={dashboardTab === 'urls' || undefined} onClick={() => switchTab('urls')}>🔗 URLs</Tab>
<Tab active={dashboardTab === 'logs' || undefined} onClick={() => switchTab('logs')}>📋 Logs</Tab>
{dashboardTools.map(tool => {
const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name
return (
<Tab
key={tool.name}
active={dashboardTab === tool.name || undefined}
onClick={() => switchTab(tool.name)}
>
<StatusDot state={app.state} data-app={app.name} />
</StatusDotLink>
))}
</StatusDotsRow>
{tool.icon} {titlecase(toolName)}
</Tab>
)
})}
</TabBar>
<Vitals />
<TabContent active={dashboardTab === 'urls' || undefined}>
<Urls render={render} />
</TabContent>
<TabContent active={dashboardTab === 'logs' || undefined}>
<UnifiedLogs />
</TabContent>
{dashboardTools.map(tool => {
const isSelected = dashboardTab === tool.name
return (
<TabContent key={tool.name} active={isSelected || undefined}>
<Section>
{tool.state !== 'running' && (
<p style={{ color: theme('colors-textFaint') }}>
Tool is {tool.state}
</p>
)}
{tool.state === 'running' && (
<div
data-tool-target={isSelected ? tool.name : undefined}
style={{ width: '100%', height: '600px' }}
/>
)}
</Section>
</TabContent>
)
})}
</DashboardContainer>
)
}

View File

@ -1,5 +1,6 @@
import type { App } from '../../shared/types'
import { apps, getSelectedTab, setSelectedTab } from '../state'
import { navigate } from '../router'
import { apps, getSelectedTab } from '../state'
import { Tab, TabBar } from '../styles'
import { resetToolIframe } from '../tool-iframes'
@ -12,12 +13,11 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
resetToolIframe(tab, app.name)
return
}
setSelectedTab(app.name, tab)
render()
navigate(tab === 'overview' ? `/app/${app.name}` : `/app/${app.name}/${tab}`)
}
// Find all tools
const tools = apps.filter(a => a.tool)
// Find tools that show on app pages (apps !== false)
const tools = apps.filter(a => a.tool && a.apps !== false)
const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1))
return (

View File

@ -1,306 +1,184 @@
import { useEffect, useState } from 'hono/jsx'
import { connectToWifi, getWifiStatus, scanWifiNetworks } from '../api'
import { setCurrentView, setupMode } from '../state'
import { applyUpdate, checkForUpdate, getSystemInfo, restartServer } from '../api'
import { setTheme } from '../index'
import { navigate } from '../router'
import {
Button,
DashboardInstallCmd,
ErrorBox,
FormActions,
FormField,
FormInput,
FormLabel,
FormSelect,
HeaderActions,
InfoLabel,
InfoRow,
InfoValue,
Main,
MainContent,
MainHeader,
MainTitle,
NetworkItem,
NetworkListWrap,
NetworkMeta,
NetworkName,
Section,
SectionTitle,
SignalBarSegment,
SignalBarsWrap,
Spinner,
SpinnerWrap,
SuccessCheck,
WifiColumn,
} from '../styles'
import { theme } from '../themes'
import type { WifiNetwork } from '../../shared/types'
type WifiStep = 'status' | 'scanning' | 'networks' | 'password' | 'connecting' | 'success'
type UpdateInfo = { available: boolean, current: string, latest: string, commits: string[] }
function signalBars(signal: number) {
const level = signal > 75 ? 4 : signal > 50 ? 3 : signal > 25 ? 2 : 1
return (
<SignalBarsWrap>
{[1, 2, 3, 4].map(i => (
<SignalBarSegment
key={i}
level={i <= level ? 'active' : 'inactive'}
style={{ height: 3 + i * 3 }}
/>
))}
</SignalBarsWrap>
)
function pollUntilBack(onBack: () => void, onTimeout?: () => void) {
let elapsed = 0
const poll = setInterval(async () => {
elapsed += 2000
if (elapsed > 60000) {
clearInterval(poll)
onTimeout?.()
return
}
function NetworkList({ networks, onSelect }: { networks: WifiNetwork[], onSelect: (net: WifiNetwork) => void }) {
if (networks.length === 0) {
return (
<SpinnerWrap>
No networks found. Try scanning again.
</SpinnerWrap>
)
try {
const res = await fetch('/api/system/info')
if (res.ok) {
clearInterval(poll)
onBack()
}
return (
<NetworkListWrap>
{networks.map(net => (
<NetworkItem key={net.ssid} onClick={() => onSelect(net)}>
<NetworkName>{net.ssid}</NetworkName>
<NetworkMeta>
{net.security && net.security !== '' && net.security !== '--' && <span style={{ fontSize: 12 }}>🔒</span>}
{signalBars(net.signal)}
</NetworkMeta>
</NetworkItem>
))}
</NetworkListWrap>
)
} catch {}
}, 2000)
}
export function SettingsPage({ render }: { render: () => void }) {
const [step, setStep] = useState<WifiStep>('status')
const [connected, setConnected] = useState(false)
const [currentSsid, setCurrentSsid] = useState('')
const [currentIp, setCurrentIp] = useState('')
const [networks, setNetworks] = useState<WifiNetwork[]>([])
const [selectedNetwork, setSelectedNetwork] = useState<WifiNetwork | null>(null)
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [successSsid, setSuccessSsid] = useState('')
const [successIp, setSuccessIp] = useState('')
const [serverUrl, setServerUrl] = useState('')
const fetchStatus = () => {
getWifiStatus().then(status => {
setConnected(status.connected)
setCurrentSsid(status.ssid)
setCurrentIp(status.ip)
if (status.url) setServerUrl(status.url)
}).catch(() => {})
}
const [version, setVersion] = useState('')
const [sha, setSha] = useState('')
const [themeChoice, setThemeChoice] = useState(localStorage.getItem('theme') || 'system')
const [restarting, setRestarting] = useState(false)
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(false)
useEffect(() => {
fetchStatus()
if (setupMode) doScan()
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
})
}, [])
const goBack = () => {
setCurrentView('dashboard')
render()
navigate('/')
}
const doScan = () => {
setStep('scanning')
setError('')
scanWifiNetworks()
.then(nets => {
setNetworks(nets)
setStep('networks')
})
.catch(() => {
setError('Failed to scan networks')
setStep('networks')
})
}
const handleSelectNetwork = (net: WifiNetwork) => {
setSelectedNetwork(net)
setPassword('')
setError('')
const needsPassword = net.security && net.security !== '' && net.security !== '--'
if (needsPassword) {
setStep('password')
const handleThemeChange = (e: Event) => {
const value = (e.target as HTMLSelectElement).value
setThemeChoice(value)
if (value === 'system') {
localStorage.removeItem('theme')
} else {
doConnect(net.ssid)
localStorage.setItem('theme', value)
}
setTheme()
}
const doConnect = (ssid: string, pw?: string) => {
setStep('connecting')
setError('')
connectToWifi(ssid, pw)
.then(result => {
if (result.ok) {
setSuccessSsid(result.ssid || ssid)
setSuccessIp(result.ip || '')
setStep('success')
fetchStatus()
} else {
setError(result.error || 'Connection failed. Check your password and try again.')
setStep('password')
}
})
.catch(() => {
setError('Connection failed. Please try again.')
setStep('password')
const refreshSystemInfo = () => {
getSystemInfo().then(info => {
setVersion(info.version)
setSha(info.sha)
})
}
const handleConnect = (e: Event) => {
e.preventDefault()
if (!selectedNetwork) return
doConnect(selectedNetwork.ssid, password || undefined)
const handleRestart = () => {
if (!confirm('Are you sure you want to restart the server?')) return
setRestarting(true)
restartServer().catch(() => {})
pollUntilBack(
() => { setRestarting(false); refreshSystemInfo() },
() => { setRestarting(false) },
)
}
const title = setupMode ? 'WiFi Setup' : 'Settings'
const handleCheckUpdate = async () => {
setChecking(true)
try {
const info = await checkForUpdate()
setUpdateInfo(info)
} catch {
setUpdateInfo(null)
}
setChecking(false)
}
const handleApplyUpdate = async () => {
if (!confirm('This will update and restart the server. Continue?')) return
setUpdating(true)
try {
await applyUpdate()
pollUntilBack(
() => { setUpdating(false); refreshSystemInfo(); setUpdateInfo(null) },
() => { setUpdating(false) },
)
} catch {
setUpdating(false)
}
}
return (
<Main>
<MainHeader centered>
<MainTitle>{title}</MainTitle>
{!setupMode && (
<MainTitle>Settings</MainTitle>
<HeaderActions>
<Button onClick={goBack}>Back</Button>
</HeaderActions>
)}
</MainHeader>
<MainContent centered>
<Section>
<SectionTitle>WiFi</SectionTitle>
{/* Status display */}
{step === 'status' && (
<WifiColumn>
<div>
<InfoRow>
<InfoLabel>Status</InfoLabel>
<InfoValue style={{ color: connected ? theme('colors-statusRunning') : theme('colors-error'), fontWeight: 500 }}>
{connected ? 'Connected' : 'Disconnected'}
</InfoValue>
</InfoRow>
{connected && currentSsid && (
<InfoRow>
<InfoLabel>Network</InfoLabel>
<InfoValue>{currentSsid}</InfoValue>
</InfoRow>
)}
{connected && currentIp && (
<InfoRow>
<InfoLabel>IP</InfoLabel>
<InfoValue style={{ fontFamily: theme('fonts-mono') }}>{currentIp}</InfoValue>
</InfoRow>
)}
</div>
<FormActions>
<Button onClick={doScan}>Scan Networks</Button>
</FormActions>
</WifiColumn>
)}
{/* Scanning spinner */}
{step === 'scanning' && (
<SpinnerWrap>
<Spinner />
<p style={{ color: theme('colors-textMuted') }}>Scanning for networks...</p>
</SpinnerWrap>
)}
{/* Network list */}
{step === 'networks' && (
<WifiColumn style={{ gap: 12 }}>
<NetworkList networks={networks} onSelect={handleSelectNetwork} />
{error && <ErrorBox>{error}</ErrorBox>}
<FormActions>
{!setupMode && <Button onClick={() => setStep('status')}>Back</Button>}
<Button onClick={doScan}>Rescan</Button>
</FormActions>
</WifiColumn>
)}
{/* Password entry */}
{step === 'password' && (
<WifiColumn>
<SectionTitle>Theme</SectionTitle>
<FormField>
<FormLabel>Network</FormLabel>
<div style={{ fontWeight: 500, fontSize: 16 }}>{selectedNetwork?.ssid}</div>
<FormLabel>Appearance</FormLabel>
<FormSelect onChange={handleThemeChange}>
<option value="system" selected={themeChoice === 'system'}>System</option>
<option value="light" selected={themeChoice === 'light'}>Light</option>
<option value="dark" selected={themeChoice === 'dark'}>Dark</option>
</FormSelect>
</FormField>
<form onSubmit={handleConnect} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField>
<FormLabel>Password</FormLabel>
<FormInput
type="password"
value={password}
onInput={(e: Event) => setPassword((e.target as HTMLInputElement).value)}
placeholder="Enter WiFi password"
autofocus
/>
</FormField>
{error && <ErrorBox>{error}</ErrorBox>}
<FormActions>
<Button onClick={() => { setError(''); setStep('networks') }}>Back</Button>
<Button variant="primary" type="submit">Connect</Button>
</FormActions>
</form>
</WifiColumn>
)}
{/* Connecting spinner */}
{step === 'connecting' && (
<SpinnerWrap>
<Spinner />
<p style={{ color: theme('colors-textMuted') }}>Connecting to <strong>{selectedNetwork?.ssid}</strong>...</p>
</SpinnerWrap>
)}
{/* Success */}
{step === 'success' && (
<WifiColumn style={{ textAlign: 'center' }}>
<SuccessCheck></SuccessCheck>
<h3 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Connected!</h3>
<p style={{ color: theme('colors-textMuted'), marginBottom: 16 }}>
Connected to <strong>{successSsid}</strong>
{successIp && <span> ({successIp})</span>}
</p>
{setupMode ? (
<div style={{
marginTop: 20,
padding: 16,
background: 'var(--colors-bgSubtle)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--colors-border)',
}}>
<p style={{ marginBottom: 8 }}>Reconnect your device to <strong>{successSsid}</strong> and visit:</p>
<a
href={serverUrl}
style={{ color: theme('colors-statusRunning'), fontSize: 18, fontWeight: 600, textDecoration: 'none' }}
>
{serverUrl}
</a>
</div>
) : (
<FormActions>
<Button onClick={() => { fetchStatus(); setStep('status') }}>Done</Button>
</FormActions>
)}
</WifiColumn>
)}
</Section>
{!setupMode && (
<Section>
<SectionTitle>About</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 14 }}>
<span>Version: {version}</span>
<span>SHA: <a href={`https://git.nose.space/defunkt/toes/commit/${sha}`} target="_blank">{sha}</a></span>
</div>
</Section>
<Section>
<SectionTitle>Install CLI</SectionTitle>
<DashboardInstallCmd>
curl -fsSL {location.origin}/install | bash
</DashboardInstallCmd>
</Section>
<Section>
<SectionTitle>Update</SectionTitle>
{updating ? (
<div style={{ fontSize: 14 }}>Updating... server will restart shortly.</div>
) : updateInfo?.available ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 14 }}>
{updateInfo.commits.length} update{updateInfo.commits.length !== 1 ? 's' : ''} available ({updateInfo.current} {updateInfo.latest})
</div>
<div style={{ fontSize: 13, color: 'var(--colors-textMuted)', display: 'flex', flexDirection: 'column', gap: 2 }}>
{updateInfo.commits.map(c => <div>{c}</div>)}
</div>
<div>
<Button variant="primary" onClick={handleApplyUpdate}>Update & Restart</Button>
</div>
</div>
) : updateInfo ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14 }}>Up to date</span>
<Button onClick={handleCheckUpdate} disabled={checking}>
{checking ? 'Checking...' : 'Check Again'}
</Button>
</div>
) : (
<Button onClick={handleCheckUpdate} disabled={checking}>
{checking ? 'Checking...' : 'Check for Updates'}
</Button>
)}
</Section>
<Section>
<SectionTitle>Server</SectionTitle>
<Button variant="danger" onClick={handleRestart} disabled={restarting}>
{restarting ? 'Restarting...' : 'Restart Server'}
</Button>
</Section>
</MainContent>
</Main>
)

View File

@ -1,7 +1,5 @@
import { openNewAppModal } from '../modals'
import {
setCurrentView,
setSelectedApp,
setSidebarCollapsed,
sidebarCollapsed,
} from '../state'
@ -17,12 +15,6 @@ import {
import { AppSelector } from './AppSelector'
export function Sidebar({ render }: { render: () => void }) {
const goToDashboard = () => {
setSelectedApp(null)
setCurrentView('dashboard')
render()
}
const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed)
render()
@ -40,7 +32,7 @@ export function Sidebar({ render }: { render: () => void }) {
</div>
) : (
<Logo>
<LogoLink onClick={goToDashboard} title="Go to dashboard">
<LogoLink href="/" title="Go to dashboard">
🐾 Toes
</LogoLink>
<HamburgerButton onClick={toggleSidebar} title="Hide sidebar">
@ -50,7 +42,7 @@ export function Sidebar({ render }: { render: () => void }) {
</HamburgerButton>
</Logo>
)}
<AppSelector render={render} collapsed={sidebarCollapsed} onDashboard={goToDashboard} />
<AppSelector render={render} collapsed={sidebarCollapsed} />
{!sidebarCollapsed && (
<SidebarFooter>
<NewAppButton onClick={openNewAppModal}>+ New App</NewAppButton>

View File

@ -105,6 +105,13 @@ function renderLogs() {
})
}
export function scrollLogsToBottom() {
requestAnimationFrame(() => {
const el = document.getElementById('unified-logs-body')
if (el) el.scrollTop = el.scrollHeight
})
}
export function initUnifiedLogs() {
if (_source) return
_source = new EventSource('/api/system/logs/stream')

View File

@ -0,0 +1,48 @@
import { buildAppUrl } from '../../shared/urls'
import { navigate } from '../router'
import { apps, isNarrow } from '../state'
import {
EmptyState,
Tile,
TileGrid,
TileIcon,
TileName,
TileStatus,
} from '../styles'
export function Urls({ render }: { render: () => void }) {
const nonTools = apps.filter(a => !a.tool)
if (nonTools.length === 0) {
return <EmptyState>No apps installed</EmptyState>
}
return (
<TileGrid narrow={isNarrow || undefined}>
{nonTools.map(app => {
const url = app.tunnelUrl || buildAppUrl(app.name, location.origin)
const running = app.state === 'running'
const appPage = `/app/${app.name}`
const openAppPage = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
navigate(appPage)
}
return (
<Tile
key={app.name}
href={running ? url : appPage}
target={running ? '_blank' : undefined}
narrow={isNarrow || undefined}
>
<TileStatus state={app.state} onClick={openAppPage} />
<TileIcon>{app.icon}</TileIcon>
<TileName>{app.name}</TileName>
</Tile>
)
})}
</TileGrid>
)
}

View File

@ -1,19 +1,21 @@
import type { Child } from 'hono/jsx'
import { render } from 'hono/jsx/dom'
import { define } from '@because/forge'
import { theme } from '../themes'
let modalTitle: string | null = null
let modalContent: (() => Child) | null = null
let renderFn: (() => void) | null = null
export const initModal = (render: () => void) => {
renderFn = render
const root = document.getElementById('modal')!
const renderModal = () => {
render(<Modal />, root)
}
export const openModal = (title: string, content: () => Child) => {
modalTitle = title
modalContent = content
renderFn?.()
renderModal()
requestAnimationFrame(() => {
document.querySelector<HTMLInputElement>('[data-modal-body] input')?.focus()
})
@ -22,12 +24,10 @@ export const openModal = (title: string, content: () => Child) => {
export const closeModal = () => {
modalTitle = null
modalContent = null
renderFn?.()
renderModal()
}
export const rerenderModal = () => {
renderFn?.()
}
export { renderModal }
// ESC key handler
document.addEventListener('keydown', (e) => {
@ -45,6 +45,7 @@ const ModalBackdrop = define('ModalBackdrop', {
justifyContent: 'center',
paddingTop: '20vh',
zIndex: 1000,
fontFamily: theme('fonts-sans'),
})
const ModalBox = define('ModalBox', {

View File

@ -1,8 +1,7 @@
import { render as renderApp } from 'hono/jsx/dom'
import { Dashboard } from './components'
import { getWifiStatus } from './api'
import { apps, getSelectedTab, selectedApp, setApps, setCurrentView, setIsNarrow, setSelectedApp, setSetupMode } from './state'
import { initModal } from './components/modal'
import { initRouter, navigate } from './router'
import { apps, dashboardTab, getSelectedTab, selectedApp, setApps, setIsNarrow } from './state'
import { initToolIframes, updateToolIframes } from './tool-iframes'
import { initUpdate } from './update'
@ -10,23 +9,32 @@ const render = () => {
renderApp(<Dashboard render={render} />, document.getElementById('app')!)
// Update tool iframes after DOM settles
requestAnimationFrame(() => {
const tools = apps.filter(a => a.tool)
if (selectedApp) {
const tools = apps.filter(a => a.tool && a.apps !== false)
updateToolIframes(getSelectedTab(selectedApp), tools, selectedApp)
} else {
const tools = apps.filter(a => a.tool && a.dashboard)
updateToolIframes(dashboardTab, tools, null)
}
})
}
// Initialize render functions
initModal(render)
initUpdate(render)
initToolIframes()
// Set theme based on system preference
const setTheme = () => {
// Set theme based on localStorage preference or system preference
export const setTheme = () => {
const stored = localStorage.getItem('theme')
if (stored === 'light' || stored === 'dark') {
document.documentElement.setAttribute('data-theme', stored)
return
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
}
// Listen for system theme changes
// Listen for system theme changes (only applies when using system theme)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
setTheme()
render()
@ -42,22 +50,16 @@ narrowQuery.addEventListener('change', e => {
render()
})
// Check WiFi setup mode on load
getWifiStatus().then(status => {
if (status.setupMode) {
setSetupMode(true)
setCurrentView('settings')
render()
}
}).catch(() => {})
// Initialize router (sets initial state from URL and renders)
initRouter(render)
// SSE connection for app state
// SSE connection
const events = new EventSource('/api/apps/stream')
events.onmessage = e => {
setApps(JSON.parse(e.data))
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
setSelectedApp(null)
navigate('/')
}
render()

View File

@ -1,17 +0,0 @@
import { closeModal, openModal } from '../components/modal'
import { AppSelector } from '../components/AppSelector'
let renderFn: () => void
export function openAppSelectorModal(render: () => void) {
renderFn = render
openModal('Select App', () => (
<AppSelector
render={renderFn}
onSelect={closeModal}
switcherStyle={{ padding: '0 0 12px', marginLeft: -20, marginRight: -20, paddingLeft: 20, paddingRight: 20, marginBottom: 8 }}
listStyle={{ maxHeight: 300, overflow: 'auto' }}
/>
))
}

Some files were not shown because too many files have changed in this diff Show More