Compare commits

..

1 Commits

Author SHA1 Message Date
Chris Wanstrath
a47e5b8298 new sync 2026-01-30 17:02:53 -08:00
185 changed files with 2535 additions and 13543 deletions

10
.gitignore vendored
View File

@ -1,13 +1,6 @@
.sandlot/
.rev/
# dependencies (bun install)
node_modules
pub/client/index.js
toes/
# generated
src/lib/templates.data.ts
# output
out
@ -40,6 +33,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
# app symlinks (created on boot)
apps/*/current

254
CLAUDE.md
View File

@ -1,4 +1,6 @@
# Toes
# Toes - Claude Code Guide
## What It Is
Personal web appliance that auto-discovers and runs multiple web apps on your home network.
@ -6,188 +8,93 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
## How It Works
1. Server scans `APPS_DIR` for directories with a `package.json` containing a `scripts.toes` entry
2. Each app is spawned as a child process with a unique port (3001-3100)
3. Dashboard UI shows all apps with status, logs, and links via SSE
4. CLI communicates with the server over HTTP
1. Host server scans `/apps` directory for valid apps
2. Valid app = has `package.json` with `scripts.toes` entry
3. Each app spawned as child process with unique port (3001+)
4. Dashboard UI shows all apps with current status, logs, and links
## Key Files
### Server (`src/server/`)
- `apps.ts` - **The heart**: app discovery, process management, health checks, auto-restart
- `api/apps.ts` - REST API for app lifecycle (start/stop/restart, logs, icons, rename)
- `api/sync.ts` - File sync protocol (manifest, push/pull, watch)
- `index.tsx` - Entry point (minimal, initializes Hype)
- `shell.tsx` - HTML shell for web UI
### Client (`src/client/`)
- `components/` - Dashboard, Sidebar, AppDetail, Nav
- `modals/` - NewApp, RenameApp, DeleteApp dialogs
- `styles/` - Forge CSS-in-JS (themes, buttons, forms, layout)
- `state.ts` - Client state management
- `api.ts` - API client
### CLI (`src/cli/`)
- `commands/manage.ts` - list, start, stop, restart, info, new, rename, delete, open
- `commands/sync.ts` - push, pull, sync
- `commands/logs.ts` - log viewing with tail support
### Shared (`src/shared/`)
- Code shared between frontend (browser) and backend (server)
- `types.ts` - App, AppState, Manifest interfaces
- IMPORTANT: Cannot use filesystem or Node APIs (runs in browser)
### Lib (`src/lib/`)
- Code shared between CLI and server (server-side only)
- `templates.ts` - Template generation for new apps
- Can use filesystem and Node APIs (never runs in browser)
### Other
- `apps/*/package.json` - Must have `"toes": "bun run --watch index.tsx"` script
- `TODO.txt` - Task list
## Tech Stack
- **Bun** runtime (not Node)
- **Hype** (custom HTTP framework wrapping Hono) from `@because/hype`
- **Forge** (typed CSS-in-JS) from `@because/forge`
- **Commander** + **ansis** for CLI
- **Hype** (custom HTTP framework wrapping Hono) from git+https://git.nose.space/defunkt/hype
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge
- **Commander** + **kleur** for CLI
- TypeScript + Hono JSX
- Client renders with `hono/jsx/dom` (no build step, served directly)
## Running
```bash
bun run dev # Hot reload (rebuilds client bundle on change)
bun run start # Production (generates templates, then runs server)
bun run check # Type check
bun run test # Tests
bun run build # Build client JS bundle (pub/client/index.js)
bun run release # Build release tarball for the Pi
bun run --hot src/server/index.tsx # Dev mode with hot reload
```
## Building & Releasing
## App Structure
`bun run release` (runs `scripts/release.sh`) produces a self-contained tarball the Pi can install without building anything:
1. Client JS bundle → `pub/client/index.js`
2. Embedded templates → `src/lib/templates.data.ts` (generated from `templates/` by `scripts/embed-templates.ts`)
3. Pre-built bare git repos for bundled apps → `dist/repos/*.git` (built by `scripts/build-repos.sh`)
4. Cross-compiled CLI binary → `dist/toes` (linux-arm64, built by `scripts/build.ts`)
5. Everything staged and packed into `dist/toes-<version>.tar.gz`
The Pi installer (`install/install.sh`) downloads this tarball, runs `bun install` for the server and all bundled apps (in parallel), copies pre-built repos and CLI binary into place, and starts the systemd service. No git commands, no compilation on the Pi.
### Key scripts
- `scripts/build.sh` -- Builds client JS bundle only (used during dev)
- `scripts/build-repos.sh` -- Pre-builds bare git repos for bundled apps (excludes node_modules, snapshots, logs)
- `scripts/release.sh` -- Full release pipeline: client + templates + repos + CLI → tarball
- `scripts/build.ts` -- CLI binary compiler (current platform or `--all` for cross-compile)
- `scripts/embed-templates.ts` -- Generates `src/lib/templates.data.ts` from `templates/`
- `install/install.sh` -- Pi installer, downloads release tarball and sets everything up
- `scripts/remote-install.sh` -- Runs the installer on a remote Pi over SSH
## Project Structure
```
src/
server/ # HTTP server and process management ($)
client/ # Browser-side dashboard
shared/ # Types shared between server and client (@)
lib/ # Code shared between CLI and server (%)
cli/ # CLI tool
tools/ # @because/toes package exports
pages/ # Hype page routes
```tsx
// apps/example/index.tsx
import { Hype } from "@because/hype"
const app = new Hype()
app.get("/", (c) => c.html(<h1>Content</h1>))
export default app.defaults
```
Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
## Conventions
### Server (`src/server/`)
- Apps get `PORT` env var from host
- Each app is isolated process with own dependencies
- No path-based routing - apps run on separate ports
- `DATA_DIR` env controls where apps are discovered
- Path aliases: `$` → server, `@` → shared, `%` → lib
- `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management.
- `api/events.ts` -- SSE stream for discrete lifecycle events (`/stream`). Used by app processes (not the dashboard). Includes a 60s heartbeat ping.
- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app delete, app reload (triggered by git tool after deploy), file watch SSE.
- `api/system.ts` -- System info, metrics (CPU/RAM/disk per-app via `ps`/`du`), unified log aggregation, perf timing toggle, update check/apply, server restart.
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), on-demand CLI binary builds (`/dist/:file`), install script endpoint (`/install`), SPA catch-all routes, subdomain proxy (including WebSocket). Initializes apps.
- `mdns.ts` -- mDNS publishing via `avahi-publish` on Linux production. Publishes `<app>.hostname.local` A records pointing to local IP. Auto-republishes on unexpected exit with exponential backoff.
- `proxy.ts` -- Subdomain routing: extracts subdomain from `*.localhost` or `*.X.local`, proxies HTTP requests and WebSocket connections to the app's port. Sets `x-app-url` header. Optional perf timing.
- `shell.tsx` -- Minimal HTML shell for the SPA.
- `sync.ts` -- Re-exports `computeHash` and `generateManifest` from `%sync` (lib).
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY, plain logs otherwise). Debounced rendering at 50ms.
- `tunnels.ts` -- Public tunnel management via `@because/sneaker`. Persists tunnel config in `TOES_DIR/tunnels.json`. Auto-reconnects on drop with exponential backoff (max 10 attempts). Manages share/unshare lifecycle.
## Current State
### Client (`src/client/`)
### Infrastructure (Complete)
- App discovery, spawn, watch, auto-restart with exponential backoff
- Health checks every 30s (3 failures trigger restart)
- Port pool (3001-3100), sticky allocation per app
- SSE streams for real-time app state and log updates
- File sync protocol with hash-based manifests
Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx` files directly.
### CLI
- Full management: `toes list|start|stop|restart|info|new|rename|delete|open`
- File sync: `toes push|pull|sync`
- Logs: `toes logs [-f] <app>`
- `index.tsx` -- Entry point. Initializes rendering, SSE connection, theme, tool iframes.
- `state.ts` -- Mutable module-level state (`apps`, `selectedApp`, `sidebarCollapsed`, etc.) with localStorage persistence. Components import state directly.
- `api.ts` -- Fetch wrappers for server API calls.
- `tool-iframes.ts` -- Manages tool iframe lifecycle (caching, visibility, height communication).
- `update.tsx` -- SSE connection to `/api/apps/stream` for real-time state updates.
- `router.ts` -- Client-side router. Intercepts link clicks, handles popstate, maps URL paths to state (`/app/:name/:tab`, `/settings`, dashboard tabs).
- `ansi.ts` -- ANSI escape code handling for log rendering.
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection, DashboardLanding, SettingsPage, Vitals, UnifiedLogs, Urls, emoji-picker, modal.
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs.
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout, logs, misc).
- `themes/` -- Light/dark theme token definitions.
### CLI (`src/cli/`)
- `index.ts` -- Entry point (`#!/usr/bin/env bun`).
- `setup.ts` -- Commander program definition with all commands.
- `commands/` -- Command implementations.
- `http.ts` -- HTTP client for talking to the toes server.
- `name.ts` -- App name resolution (argument or current directory).
- `prompts.ts` -- Interactive prompts.
- `pager.ts` -- Pipe output through system pager.
CLI commands:
- **Apps**: `status` (list or info), `new`, `get`, `open`, `rename`, `rm`
- **Lifecycle**: `start`, `stop`, `restart`, `share`, `unshare`, `logs`, `metrics`, `cron` (list/log/status/run)
- **Config**: `env` (list/set/rm, per-app or `--global`), `perf` (toggle request timing)
- **Hidden**: `list`, `info`, `log`, `version`, `shell`
Some commands (`shell`, `get`, `open`) are disabled when running over SSH (`USER=cli`).
### Shared (`src/shared/`)
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`, `DEFAULT_EMOJI`, `VALID_NAME`
- `events.ts` -- `ToesEvent`, `ToesEventType`, `ToesEventInput` type definitions
- `urls.ts` -- `toSubdomain()` and `buildAppUrl()` for subdomain URL construction
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
### Lib (`src/lib/`)
Server-side code shared between CLI and server. Can use Node APIs.
- `config.ts` -- `HOSTNAME` and `LOCAL_HOST` (`hostname.local`) constants
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa), reads embedded templates from `templates.data.ts`
- `templates.data.ts` -- Generated file containing embedded template contents (built by `scripts/embed-templates.ts`)
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
### Tools Package (`src/tools/`)
The `@because/toes` package that apps/tools import. Published exports:
- `@because/toes` -- re-exports `computeHash`, `generateManifest`, `FileInfo`, `Manifest`, `VALID_NAME` (`src/index.ts` -> `src/tools/index.ts`)
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`, `on` (event subscription), `appUrl`, `VALID_NAME`
### Pages (`src/pages/`)
Hype page routes. `index.tsx` renders the Shell.
## Key Concepts
### App Lifecycle
States: `invalid` | `error` | `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
- Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes`
- Spawn: `Bun.spawn(['bun', 'run', 'toes'])` with `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR`, `DATA_ROOT`, `TOES_URL`, `TOES_DIR`, `NO_AUTOPORT`, plus per-app env vars from `loadAppEnv()`
- Startup: runs `bun install` first if `node_modules/` missing, then polls `/ok` every 500ms (30s timeout)
- Health checks: every 30s to `/ok` (5s timeout), 3 consecutive failures trigger restart
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), max 5 attempts, resets after 60s stable run. State becomes `error` after max attempts.
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
- On startup, kills stale processes on ports 3001-3100 and orphaned `bun run toes` processes
### Subdomain Proxy
Every app gets a subdomain: `<app>.localhost` (dev) or `<app>.hostname.local` (prod). The server's fetch handler (`index.tsx`) checks for subdomains first and proxies to the app's port. WebSocket connections are also proxied via Bun's `server.upgrade()` with upstream bridging. The `x-app-url` header is set so apps know their public URL.
### Tools vs Apps
Tools are apps with `"toes": { "tool": true }` in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server via subdomain: `/tool/:tool?app=foo` -> `http://<tool>.host/?app=foo`. Tool API calls can also be proxied: `/api/tools/:tool/*` -> `http://localhost:<toolPort>/*`.
The `apps` field in package.json controls whether a tool shows on app detail pages (`false` to hide). The `dashboard` field controls whether a tool shows on the dashboard landing page.
### Versioning
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
Per-app env files in `TOES_DIR/env/`:
- `_global.env` -- shared by all apps
- `<appname>.env` -- per-app overrides
The server sets these on each app process: `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR` (per-app at `TOES_DIR/<name>`), `DATA_ROOT`, `TOES_DIR`, `TOES_URL`, `NO_AUTOPORT`.
### SSE Streaming
Two SSE endpoints serve different consumers:
- `/api/apps/stream` -- Full app state snapshots on every change. Used by the dashboard UI. Driven by `onChange()` in `apps.ts`.
- `/api/events/stream` -- Discrete lifecycle events (`app:start`, `app:stop`, `app:reload`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`.
Check `TODO.txt` for planned features
## Coding Guidelines
@ -258,22 +165,3 @@ function start(app: App): void {
console.log(`Starting ${app.config.name}`)
}
```
## Install & Deployment
The install script (`install/install.sh`) is designed to run on a fresh Pi or as an updater:
1. Installs system packages (git, fish, avahi-utils, etc.) via apt
2. Installs Bun and grants `cap_net_bind_service`
3. Downloads and extracts the release tarball into `~/toes`
4. Runs `bun install` for the server
5. Copies bundled apps to `~/apps/` and runs `bun install` for each (in parallel)
6. Copies pre-built bare repos to `~/data/repos/` (for git-based versioning)
7. Installs the pre-built CLI binary to `/usr/local/bin/toes`
8. Sets up SSH access and the systemd service
The release tarball URL is configured as `RELEASE_URL` at the top of `install/install.sh`.
## Writing Apps and Tools
See `docs/GUIDE.md` for the guide to writing toes apps and tools.

194
ISSUES.md Normal file
View File

@ -0,0 +1,194 @@
# Issues - Versioned Deployment Implementation
## Critical Issues
### 1. Watch Filter Breaks File Change Detection
**Location**: `src/server/apps.ts:589-593`
**Problem**: The watch logic ignores all changes deeper than 2 levels:
```ts
// Ignore changes deeper than 2 levels (inside timestamp dirs)
if (parts.length > 2) return
// For versioned apps, only care about changes to "current" directory
if (parts.length === 2 && parts[1] !== 'current' && parts[1] !== 'package.json') return
```
Files inside `current/` are 3 levels deep: `appname/current/somefile.ts`
This **ignores all file changes** inside the current directory, breaking hot reload and auto-restart.
**Fix**:
```ts
// Ignore changes inside old timestamp dirs (but allow current/)
if (parts.length > 2 && parts[1] !== 'current') return
```
---
### 2. App Restart Race Condition
**Location**: `src/server/api/sync.ts:145-148`
**Problem**: Activation uses arbitrary 1 second delay without confirming stop completed:
```ts
// Restart app to use new version
const app = allApps().find(a => a.name === appName)
if (app?.state === 'running') {
stopApp(appName)
setTimeout(() => startApp(appName), 1000) // ⚠️ Arbitrary 1s delay
}
```
**Issues**:
- 1 second may not be enough for app to fully stop
- No confirmation that stop completed before start
- If app crashes during startup, activation still returns success
**Fix**: Make activation async and wait for stop to complete, or move restart logic to after symlink succeeds and poll for stop completion.
---
## Major Issues
### 3. No Version Cleanup
**Problem**: Old timestamp directories accumulate forever with no cleanup mechanism.
**Impact**: Disk space grows indefinitely as deployments pile up.
**Recommendation**: Add cleanup logic:
- Keep last N versions (e.g., 5-10)
- Delete versions older than X days
- Expose as `toes clean <app>` command or automatic post-activation
---
### 4. safePath Behavior Change
**Location**: `src/server/api/sync.ts:14-21`
**Problem**: Security model changed by resolving symlinks in base path:
```ts
const canonicalBase = existsSync(base) ? realpathSync(base) : base
```
Previously paths were checked against literal base, now against resolved base. This changes behavior if someone creates a symlink attack.
**Recommendation**: Document this intentional change, or keep original check and add separate symlink resolution only where needed.
---
### 5. Non-Atomic Deploy Copy
**Location**: `src/server/api/sync.ts:133-141`
**Problem**: Race condition possible during deploy:
```ts
if (existsSync(currentLink)) {
const currentReal = realpathSync(currentLink)
cpSync(currentReal, newVersion, { recursive: true })
}
```
If `current` changes between `existsSync` and `cpSync`, stale code might be copied.
**Impact**: Low probability for single-user system, but possible during concurrent deploys.
**Fix**: Read symlink once and reuse: `const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null`
---
### 6. Upload Error Handling Inconsistency
**Location**: `src/cli/commands/sync.ts:113-115`
**Problem**: Upload continues if a file fails but doesn't track failures:
```ts
if (success) {
console.log(` ${color.green('↑')} ${file}`)
} else {
console.log(` ${color.red('✗')} ${file}`)
// Continue even if one file fails
}
```
If any file fails to upload, deployment still activates with incomplete files.
**Fix**: Either:
- Abort entire deployment on first failure
- Track failures and warn/abort before activating
---
## Minor Issues
### 7. POST Body Check Too Loose
**Location**: `src/cli/http.ts:42-44`
**Problem**: Falsy values like `0`, `false`, or `""` would incorrectly skip body:
```ts
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
```
**Fix**:
```ts
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
```
---
### 8. Unconventional Timestamp Format
**Location**: `src/server/api/sync.ts:115-123`
**Problem**: Unusual array construction with separator as element:
```ts
const timestamp = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
'-', // Unusual to put separator as array element
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
].join('')
```
**Recommendation**: Use template literal:
```ts
const pad = (n: number) => String(n).padStart(2, '0')
const timestamp = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
```
Or ISO format: `now.toISOString().replace(/[:.]/g, '-').slice(0, -5)`
---
## Positive Points
✓ Atomic symlink swapping with temp file pattern is correct
✓ Clear 3-step deployment protocol (deploy, upload, activate)
✓ Proper use of `realpathSync` to resolve canonical paths for running apps
✓ Good separation of concerns in API routes
---
## Priority
1. **BLOCKER**: Fix watch filter (#1) - breaks hot reload
2. **HIGH**: Fix restart race condition (#2) - affects deployment reliability
3. **HIGH**: Add version cleanup (#3) - disk space concern
4. **MEDIUM**: Fix upload error handling (#6) - data integrity
5. **LOW**: Everything else

View File

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

51
TODO.txt Normal file
View File

@ -0,0 +1,51 @@
# toes
## server
[x] start toes server
[x] scans for apps/**/package.json, scripts.toes
[x] runs that for each, giving it a PORT
[x] has GET / page that shows all the running apps/status/port
[x] watch each app and restart it on update
[x] watches for and adds/removes apps
[ ] run on rpi on boot
[ ] found at http://toes.local
[ ] https?
[ ] apps are subdomains (verify if this works w/ chrome+safari)
[ ] if not: apps get ports but on the proper domain ^^
## apps
[x] truism
[x] big clock
[ ] shrimp repl(?)
[ ] dungeon party
## cli
[x] `toes --help`
[x] `toes --version`
[x] `toes list`
[x] `toes start <app>`
[x] `toes stop <app>`
[x] `toes restart <app>`
[x] `toes open <app>`
[x] `toes logs <app>`
[x] `toes logs -f <app>`
[x] `toes info <app>`
[x] `toes new`
[x] `toes pull`
[x] `toes push`
[x] `toes sync`
[x] `toes new --spa`
[x] `toes new --ssr`
[x] `toes new --bare`
[ ] needs to either check toes.local or take something like TOES_URL
## webui
[x] list projects
[x] start/stop/restart project
[x] create project
[ ] todo.txt
[ ] ...

View File

@ -0,0 +1,10 @@
import { Hype } from '@because/hype'
const app = new Hype
app.get('/', c => c.html(<h1>Hi there!</h1>))
const apps = () => {
}
export default app.defaults

View File

@ -1,6 +1,6 @@
{
"name": "git",
"module": "index.tsx",
"name": "basic",
"module": "src/index.ts",
"type": "module",
"private": true,
"scripts": {
@ -8,21 +8,14 @@
"start": "bun toes",
"dev": "bun run --hot index.tsx"
},
"toes": {
"tool": true,
"dashboard": true,
"share": true,
"icon": "🔀"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.2"
},
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "0.0.12"
"@because/hype": "^0.0.1"
}
}
}

1
apps/basic/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

View File

@ -45,8 +45,6 @@ app.get('/styles.css', c => c.text(stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
app.get('/ok', c => c.text('ok'))
app.get('/', c => c.html(
<html>
<head>

View File

@ -1,3 +0,0 @@
# clock TODO
[x] do something
[ ] do more things

1
apps/clock/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

View File

@ -5,16 +5,15 @@
"": {
"name": "code",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/howl": "^0.0.2",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.5",
"@because/forge": "*",
"@because/howl": "*",
"@because/hype": "*",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
"typescript": "^5.9.2",
},
},
},
@ -23,9 +22,7 @@
"@because/howl": ["@because/howl@0.0.2", "https://npm.nose.space/@because/howl/-/howl-0.0.2.tgz", { "dependencies": { "lucide-static": "^0.555.0" }, "peerDependencies": { "@because/forge": "*", "typescript": "^5" } }, "sha512-Z4okzEa282LKkBk9DQwEUU6FT+PeThfQ6iQAY41LIEjs8B2kfXRZnbWLs7tgpwCfYORxb0RO4Hr7KiyEqnfTvQ=="],
"@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=="],
"@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
"@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=="],
@ -33,10 +30,6 @@
"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=="],

View File

@ -1,5 +1,5 @@
{
"name": "env",
"name": "code",
"module": "index.tsx",
"type": "module",
"private": true,
@ -9,9 +9,8 @@
"dev": "bun run --hot index.tsx"
},
"toes": {
"tool": ".env",
"icon": "🔑",
"dashboard": true
"tool": true,
"icon": "💻"
},
"devDependencies": {
"@types/bun": "latest"
@ -20,8 +19,8 @@
"typescript": "^5.9.2"
},
"dependencies": {
"@because/forge": "*",
"@because/hype": "*",
"@because/toes": "*"
"@because/forge": "*",
"@because/howl": "*"
}
}

View File

@ -0,0 +1,327 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { readdir, stat } from 'fs/promises'
import { readFileSync } from 'fs'
import { join, extname, basename } from 'path'
const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false })
// Styles
const Container = define('CodeBrowserContainer', {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
padding: '20px',
maxWidth: '1200px',
margin: '0 auto',
})
const Header = define('Header', {
marginBottom: '20px',
paddingBottom: '10px',
borderBottom: '2px solid #333',
})
const Title = define('Title', {
margin: 0,
fontSize: '24px',
fontWeight: 'bold',
})
const AppName = define('AppName', {
color: '#666',
fontSize: '18px',
marginTop: '5px',
})
const FileList = define('FileList', {
listStyle: 'none',
padding: 0,
margin: '20px 0',
border: '1px solid #ddd',
borderRadius: '4px',
overflow: 'hidden',
})
const FileItem = define('FileItem', {
padding: '10px 15px',
borderBottom: '1px solid #eee',
states: {
':last-child': {
borderBottom: 'none',
},
':hover': {
backgroundColor: '#f5f5f5',
},
}
})
const FileLink = define('FileLink', {
base: 'a',
textDecoration: 'none',
color: '#0066cc',
display: 'flex',
alignItems: 'center',
gap: '8px',
states: {
':hover': {
textDecoration: 'underline',
},
}
})
const CodeBlock = define('CodeBlock', {
margin: '20px 0',
border: '1px solid #ddd',
borderRadius: '4px',
overflow: 'auto',
selectors: {
'& pre': {
margin: 0,
padding: '15px',
overflow: 'auto',
whiteSpace: 'pre',
},
'& pre code': {
whiteSpace: 'pre',
fontFamily: 'monospace',
},
},
})
const CodeHeader = define('CodeHeader', {
padding: '10px 15px',
backgroundColor: '#f5f5f5',
borderBottom: '1px solid #ddd',
fontWeight: 'bold',
fontSize: '14px',
})
const Error = define('Error', {
color: '#d32f2f',
padding: '20px',
backgroundColor: '#ffebee',
borderRadius: '4px',
margin: '20px 0',
})
const BackLink = define('BackLink', {
base: 'a',
textDecoration: 'none',
color: '#0066cc',
display: 'inline-flex',
alignItems: 'center',
gap: '5px',
marginBottom: '15px',
states: {
':hover': {
textDecoration: 'underline',
},
},
})
app.get('/styles.css', c => c.text(stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
async function listFiles(appPath: string, subPath: string = '') {
const fullPath = join(appPath, subPath)
const entries = await readdir(fullPath, { withFileTypes: true })
const items = await Promise.all(
entries.map(async entry => {
const itemPath = join(fullPath, entry.name)
const stats = await stat(itemPath)
return {
name: entry.name,
isDirectory: entry.isDirectory(),
path: subPath ? `${subPath}/${entry.name}` : entry.name,
}
})
)
return items.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
})
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function getLanguage(filename: string): string {
const ext = extname(filename).toLowerCase()
const langMap: Record<string, string> = {
'.js': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.json': 'json',
'.css': 'css',
'.html': 'html',
'.md': 'markdown',
'.sh': 'bash',
'.yml': 'yaml',
'.yaml': 'yaml',
}
return langMap[ext] || 'plaintext'
}
app.get('/', async c => {
const appName = c.req.query('app')
const filePath = c.req.query('file') || ''
if (!appName) {
return c.html(
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Code Browser</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<Container>
<Header>
<Title>Code Browser</Title>
</Header>
<Error>Please specify an app name with ?app=&lt;name&gt;</Error>
</Container>
</body>
</html>
)
}
const appPath = join(APPS_DIR, appName)
try {
await stat(appPath)
} catch {
return c.html(
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Code Browser</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<Container>
<Header>
<Title>Code Browser</Title>
</Header>
<Error>App "{appName}" not found</Error>
</Container>
</body>
</html>
)
}
const fullPath = join(appPath, filePath)
let stats
try {
stats = await stat(fullPath)
} catch {
return c.html(
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Code Browser</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<Container>
<Header>
<Title>Code Browser</Title>
</Header>
<Error>Path "{filePath}" not found in app "{appName}"</Error>
</Container>
</body>
</html>
)
}
if (stats.isFile()) {
const content = readFileSync(fullPath, 'utf-8')
const language = getLanguage(basename(fullPath))
const parentPath = filePath.split('/').slice(0, -1).join('/')
return c.html(
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{`${appName}/${filePath}`}</title>
<link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
</head>
<body>
<Container>
<Header>
<Title>Code Browser</Title>
<AppName>{appName}/{filePath}</AppName>
</Header>
<BackLink href={`/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`}>
Back
</BackLink>
<CodeBlock>
<CodeHeader>{basename(fullPath)}</CodeHeader>
<pre><code class={`language-${language}`}>{content}</code></pre>
</CodeBlock>
</Container>
<script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />
</body>
</html>
)
}
const files = await listFiles(appPath, filePath)
const parentPath = filePath.split('/').slice(0, -1).join('/')
return c.html(
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{`${appName}${filePath ? `/${filePath}` : ''}`}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<Container>
<Header>
<Title>Code Browser</Title>
<AppName>{appName}{filePath ? `/${filePath}` : ''}</AppName>
</Header>
{filePath && (
<BackLink href={`/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`}>
Back
</BackLink>
)}
<FileList>
{files.map(file => (
<FileItem>
<FileLink href={`/?app=${appName}&file=${file.path}`}>
{file.isDirectory ? '📁' : '📄'}
<span>{file.name}</span>
</FileLink>
</FileItem>
))}
</FileList>
</Container>
</body>
</html>
)
})
export default app.defaults

1
apps/code/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

View File

@ -1,706 +0,0 @@
import { Hype } from '@because/hype'
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, 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', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '1200px',
margin: '0 auto',
color: theme('colors-text'),
})
const FileList = define('FileList', {
listStyle: 'none',
padding: 0,
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
const FileItem = define('FileItem', {
padding: '10px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
states: {
':last-child': {
borderBottom: 'none',
},
':hover': {
backgroundColor: theme('colors-bgHover'),
},
}
})
const FileLink = define('FileLink', {
base: 'a',
textDecoration: 'none',
color: theme('colors-link'),
display: 'flex',
alignItems: 'center',
gap: '8px',
states: {
':hover': {
textDecoration: 'underline',
},
}
})
const FileIcon = define('FileIcon', {
base: 'svg',
width: '18px',
height: '18px',
flexShrink: 0,
fill: theme('colors-textMuted'),
})
const CodeBlock = define('CodeBlock', {
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflowX: 'auto',
selectors: {
'& pre': {
margin: 0,
padding: '15px',
whiteSpace: 'pre',
backgroundColor: theme('colors-bgSubtle'),
},
'& pre code': {
whiteSpace: 'pre',
fontFamily: theme('fonts-mono'),
},
},
})
const CodeHeader = define('CodeHeader', {
padding: '10px 15px',
backgroundColor: theme('colors-bgElement'),
borderBottom: `1px solid ${theme('colors-border')}`,
fontWeight: 'bold',
fontSize: '14px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
margin: '20px 0',
})
const Breadcrumb = define('Breadcrumb', {
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '14px',
marginBottom: '15px',
flexWrap: 'wrap',
})
const BreadcrumbLink = define('BreadcrumbLink', {
base: 'a',
textDecoration: 'none',
color: theme('colors-link'),
states: {
':hover': {
textDecoration: 'underline',
},
},
})
const BreadcrumbSeparator = define('BreadcrumbSeparator', {
color: theme('colors-textMuted'),
})
const BreadcrumbCurrent = define('BreadcrumbCurrent', {
color: theme('colors-text'),
fontWeight: 500,
})
const MediaContainer = define('MediaContainer', {
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
backgroundColor: theme('colors-bgSubtle'),
})
const MediaHeader = define('MediaHeader', {
padding: '10px 15px',
backgroundColor: theme('colors-bgElement'),
borderBottom: `1px solid ${theme('colors-border')}`,
fontWeight: 'bold',
fontSize: '14px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})
const MediaContent = define('MediaContent', {
padding: '20px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})
const ImagePreview = define('ImagePreview', {
base: 'img',
maxWidth: '100%',
maxHeight: '600px',
objectFit: 'contain',
})
const AudioPlayer = define('AudioPlayer', {
base: 'audio',
width: '100%',
maxWidth: '500px',
})
const VideoPlayer = define('VideoPlayer', {
base: 'video',
maxWidth: '100%',
maxHeight: '600px',
})
const DownloadButton = define('DownloadButton', {
base: 'a',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
backgroundColor: theme('colors-primary'),
color: 'white',
textDecoration: 'none',
borderRadius: theme('radius-md'),
fontSize: '14px',
states: {
':hover': {
opacity: 0.9,
},
},
})
const EditButton = define('EditButton', {
base: 'button',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
backgroundColor: theme('colors-bgElement'),
color: theme('colors-text'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
fontSize: '13px',
cursor: 'pointer',
states: {
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const EditLink = define('EditLink', {
base: 'a',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
backgroundColor: theme('colors-bgElement'),
color: theme('colors-text'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
fontSize: '13px',
cursor: 'pointer',
textDecoration: 'none',
states: {
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const FolderIcon = () => (
<FileIcon viewBox="0 0 24 24">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
</FileIcon>
)
const FileIconSvg = () => (
<FileIcon viewBox="0 0 24 24">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h6v6h6v10H6z" />
</FileIcon>
)
interface LayoutProps {
title: string
children: Child
highlight?: boolean
editable?: boolean
}
const fileMemoryScript = `
(function() {
var params = new URLSearchParams(window.location.search);
var app = params.get('app');
var file = params.get('file');
if (!app) return;
var key = 'code-app:' + app + ':file';
if (params.has('file')) {
if (file) localStorage.setItem(key, file);
else localStorage.removeItem(key);
} else {
var saved = localStorage.getItem(key);
if (saved) {
window.location.replace('/?app=' + encodeURIComponent(app) + '&file=' + encodeURIComponent(saved));
}
}
})();
`
function Layout({ title, children, highlight, editable }: 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" />
{highlight && !editable && (
<>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
</>
)}
{editable && (
<>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" media="(prefers-color-scheme: light)" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" media="(prefers-color-scheme: dark)" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-tsx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
</>
)}
</head>
<body>
<script dangerouslySetInnerHTML={{ __html: fileMemoryScript }} />
<ToolScript />
<Container>
{children}
</Container>
{highlight && !editable && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}
</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',
}))
app.get('/raw', async c => {
const appName = c.req.query('app')
const filePath = c.req.query('file')
if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400)
}
const fullPath = safePath(APPS_DIR, appName, filePath)
if (!fullPath) return c.text('Invalid path', 400)
const file = Bun.file(fullPath)
if (!await file.exists()) {
return c.text(`File not found: ${fullPath}`, 404)
}
return new Response(file)
})
app.post('/save', async c => {
const appName = c.req.query('app')
const filePath = c.req.query('file')
if (!appName || !filePath) {
return c.text('Missing app or file parameter', 400)
}
const fullPath = safePath(APPS_DIR, appName, filePath)
if (!fullPath) return c.text('Invalid path', 400)
const content = await c.req.text()
try {
await Bun.write(fullPath, content)
return c.text('OK')
} catch (err) {
return c.text(`Failed to save: ${err}`, 500)
}
})
async function listFiles(appPath: string, subPath: string = '') {
const fullPath = join(appPath, subPath)
const entries = await readdir(fullPath, { withFileTypes: true })
const items = entries.map(entry => ({
name: entry.name,
isDirectory: entry.isDirectory(),
path: subPath ? `${subPath}/${entry.name}` : entry.name,
}))
return items.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
})
}
interface BreadcrumbProps {
appName: string
filePath: string
}
function PathBreadcrumb({ appName, filePath }: BreadcrumbProps) {
const parts = filePath ? filePath.split('/').filter(Boolean) : []
const crumbs: { name: string; path: string }[] = []
let currentPath = ''
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part
crumbs.push({ name: part, path: currentPath })
}
return (
<Breadcrumb>
{crumbs.length > 0 ? (
<BreadcrumbLink href={`/?app=${appName}&file=`}>{appName}</BreadcrumbLink>
) : (
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
)}
{crumbs.map((crumb, i) => (
<>
<BreadcrumbSeparator>/</BreadcrumbSeparator>
{i === crumbs.length - 1 ? (
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
) : (
<BreadcrumbLink href={`/?app=${appName}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
)}
</>
))}
</Breadcrumb>
)
}
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp'])
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'])
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi'])
const BINARY_EXTS = new Set(['.pdf', '.zip', '.tar', '.gz', '.exe', '.dmg', '.woff', '.woff2', '.ttf', '.otf', '.eot'])
type FileType = 'text' | 'image' | 'audio' | 'video' | 'binary'
function getFileType(filename: string): FileType {
const ext = extname(filename).toLowerCase()
if (IMAGE_EXTS.has(ext)) return 'image'
if (AUDIO_EXTS.has(ext)) return 'audio'
if (VIDEO_EXTS.has(ext)) return 'video'
if (BINARY_EXTS.has(ext)) return 'binary'
return 'text'
}
function getLanguage(filename: string): string {
const ext = extname(filename).toLowerCase()
const langMap: Record<string, string> = {
'.js': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.json': 'json',
'.css': 'css',
'.html': 'html',
'.md': 'markdown',
'.sh': 'bash',
'.yml': 'yaml',
'.yaml': 'yaml',
}
return langMap[ext] || 'plaintext'
}
function getPrismLanguage(filename: string): string {
const ext = extname(filename).toLowerCase()
const langMap: Record<string, string> = {
'.js': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.json': 'json',
'.css': 'css',
'.html': 'html',
'.md': 'markdown',
'.sh': 'bash',
'.yml': 'yaml',
'.yaml': 'yaml',
'.py': 'python',
'.rb': 'ruby',
'.go': 'go',
'.rs': 'rust',
'.sql': 'sql',
}
return langMap[ext] || 'plaintext'
}
app.get('/', async c => {
const appName = c.req.query('app')
const filePath = c.req.query('file') || ''
if (!appName) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Please specify an app name with ?app=&lt;name&gt;</ErrorBox>
</Layout>
)
}
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}" not found</ErrorBox>
</Layout>
)
}
const fullPath = safePath(appPath, filePath)
if (!fullPath) {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Invalid file path</ErrorBox>
</Layout>
)
}
let fileStats
try {
fileStats = await stat(fullPath)
} catch {
return c.html(
<Layout title="Code Browser">
<ErrorBox>Path "{filePath}" not found</ErrorBox>
</Layout>
)
}
if (fileStats.isFile()) {
const filename = basename(fullPath)
const fileType = getFileType(filename)
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} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
<DownloadButton href={downloadUrl}>Download</DownloadButton>
</MediaHeader>
<MediaContent>
<ImagePreview src={rawUrl} alt={filename} />
</MediaContent>
</MediaContainer>
</Layout>
)
}
if (fileType === 'audio') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
<DownloadButton href={downloadUrl}>Download</DownloadButton>
</MediaHeader>
<MediaContent>
<AudioPlayer controls src={rawUrl} />
</MediaContent>
</MediaContainer>
</Layout>
)
}
if (fileType === 'video') {
return c.html(
<Layout title={`${appName}/${filePath}`}>
<PathBreadcrumb appName={appName} filePath={filePath} />
<MediaContainer>
<MediaHeader>
<span>{filename}</span>
<DownloadButton href={downloadUrl}>Download</DownloadButton>
</MediaHeader>
<MediaContent>
<VideoPlayer controls src={rawUrl} />
</MediaContent>
</MediaContainer>
</Layout>
)
}
if (fileType === 'binary') {
return c.redirect(downloadUrl)
}
// Text file - show with syntax highlighting
const content = readFileSync(fullPath, 'utf-8')
const language = getLanguage(filename)
const prismLang = getPrismLanguage(filename)
const edit = c.req.query('edit') === '1'
if (edit) {
const editorScript = `
import { CodeJar } from 'https://cdn.jsdelivr.net/npm/codejar@4.2.0/dist/codejar.js';
const editor = document.getElementById('editor');
const saveBtn = document.getElementById('save-btn');
const status = document.getElementById('save-status');
let dirty = false;
const highlight = (el) => {
Prism.highlightElement(el);
};
const jar = CodeJar(editor, highlight, { tab: ' ', addClosing: false });
// Initial highlight
highlight(editor);
jar.onUpdate(() => {
if (!dirty) {
dirty = true;
saveBtn.textContent = 'Save *';
}
});
saveBtn.onclick = async () => {
if (!dirty) return;
saveBtn.disabled = true;
status.textContent = 'Saving...';
try {
const res = await fetch('/save?app=${appName}&file=${filePath}', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: jar.toString()
});
if (!res.ok) throw new Error('Save failed');
dirty = false;
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
status.textContent = 'Saved!';
setTimeout(() => { status.textContent = ''; }, 2000);
} catch (err) {
saveBtn.disabled = false;
status.textContent = 'Error: ' + err.message;
}
};
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveBtn.click();
}
});
`
return c.html(
<Layout title={`${appName}/${filePath}`} editable>
<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}&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>
</CodeBlock>
<script type="module" dangerouslySetInnerHTML={{ __html: editorScript }} />
</Layout>
)
}
return c.html(
<Layout title={`${appName}/${filePath}`} highlight>
<PathBreadcrumb appName={appName} filePath={filePath} />
<CodeBlock>
<CodeHeader>
<span>{filename}</span>
<EditLink href={`/?app=${appName}&file=${filePath}&edit=1`}>Edit</EditLink>
</CodeHeader>
<pre><code class={`language-${language}`}>{content}</code></pre>
</CodeBlock>
</Layout>
)
}
const files = await listFiles(appPath, filePath)
return c.html(
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
<PathBreadcrumb appName={appName} filePath={filePath} />
<FileList>
{files.map(file => (
<FileItem>
<FileLink href={`/?app=${appName}&file=${file.path}`}>
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
<span>{file.name}</span>
</FileLink>
</FileItem>
))}
</FileList>
</Layout>
)
})
export default app.defaults

View File

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

View File

@ -1,49 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "cron",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.8",
"croner": "^9.1.0",
},
"devDependencies": {
"@types/bun": "latest",
},
},
},
"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.1", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.1.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-rN9hc13ofap+7SvfShJkTJQYBcViCiElyfb8FBMzP1SKIe8B71csZeLh+Ujye/5538ojWfM/5hRRPJ+Aa/0A+g=="],
"@because/toes": ["@because/toes@0.0.8", "https://npm.nose.space/@because/toes/-/toes-0.0.8.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.1", "commander": "^14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-ei4X+yX97dCRqAHSfsVnE4vAIAMkhG9v1WKW3whlo+BMm3TNdKuEv1o2PQpVfIChSGzO/05Y/YFbd/XdI7p/Kg=="],
"@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.2.2", "https://npm.nose.space/@types/node/-/node-25.2.2.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
"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=="],
"croner": ["croner@9.1.0", "https://npm.nose.space/croner/-/croner-9.1.0.tgz", {}, "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g=="],
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"hono": ["hono@4.11.9", "https://npm.nose.space/hono/-/hono-4.11.9.tgz", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unique-names-generator": ["unique-names-generator@4.7.1", "https://npm.nose.space/unique-names-generator/-/unique-names-generator-4.7.1.tgz", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
}
}

View File

@ -1,701 +0,0 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, on, ToolScript, theme } from '@because/toes/tools'
import { discoverCronJobs } from './lib/discovery'
import { scheduleJob, stopJob } from './lib/scheduler'
import { executeJob } from './lib/executor'
import { setJobs, setInvalidJobs, getJob, getAllJobs, getInvalidJobs, broadcast } from './lib/state'
import { SCHEDULES, type CronJob, type InvalidJob } from './lib/schedules'
import type { Child } from 'hono/jsx'
import { join } from 'path'
import { mkdir, writeFile } from 'fs/promises'
import { existsSync, watch } from 'fs'
const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false })
// Styles
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '900px',
margin: '0 auto',
color: theme('colors-text'),
})
const JobList = define('JobList', {
listStyle: 'none',
padding: 0,
margin: '20px 0',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
const JobItem = define('JobItem', {
padding: '12px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex',
alignItems: 'center',
gap: '15px',
states: {
':last-child': { borderBottom: 'none' },
':hover': { backgroundColor: theme('colors-bgHover') },
},
})
const StatusDot = define('StatusDot', {
width: '10px',
height: '10px',
borderRadius: '50%',
flexShrink: 0,
})
const JobName = define('JobName', {
fontFamily: theme('fonts-mono'),
fontSize: '14px',
flex: 1,
})
const Schedule = define('Schedule', {
fontSize: '13px',
color: theme('colors-textMuted'),
minWidth: '80px',
})
const Time = define('Time', {
fontSize: '12px',
color: theme('colors-textMuted'),
minWidth: '100px',
})
const RunButton = define('RunButton', {
base: 'button',
padding: '4px 10px',
marginTop: '10px',
fontSize: '12px',
backgroundColor: theme('colors-primary'),
color: 'white',
border: 'none',
borderRadius: theme('radius-md'),
cursor: 'pointer',
states: {
':hover': { opacity: 0.9 },
':disabled': { opacity: 0.5, cursor: 'not-allowed' },
},
})
const EmptyState = define('EmptyState', {
padding: '40px 20px',
textAlign: 'center',
color: theme('colors-textMuted'),
})
const InvalidItem = define('InvalidItem', {
padding: '12px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex',
alignItems: 'center',
gap: '15px',
opacity: 0.7,
states: {
':last-child': { borderBottom: 'none' },
},
})
const ErrorText = define('ErrorText', {
fontSize: '12px',
color: theme('colors-error'),
flex: 1,
})
const ActionRow = define('ActionRow', {
display: 'flex',
justifyContent: 'flex-end',
marginBottom: '15px',
})
const NewButton = define('NewButton', {
base: 'a',
padding: '8px 16px',
fontSize: '14px',
backgroundColor: theme('colors-primary'),
color: 'white',
border: 'none',
borderRadius: theme('radius-md'),
cursor: 'pointer',
textDecoration: 'none',
display: 'inline-block',
states: {
':hover': { opacity: 0.9 },
},
})
const buttonStyles = {
padding: '8px 16px',
fontSize: '14px',
backgroundColor: theme('colors-primary'),
color: 'white',
border: 'none',
borderRadius: theme('radius-md'),
cursor: 'pointer',
}
const Form = define('Form', {
display: 'flex',
flexDirection: 'column',
gap: '16px',
maxWidth: '400px',
})
const FormGroup = define('FormGroup', {
display: 'flex',
flexDirection: 'column',
gap: '6px',
})
const Label = define('Label', {
fontSize: '14px',
fontWeight: 500,
})
const inputStyles = {
padding: '8px 12px',
fontSize: '14px',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
backgroundColor: theme('colors-bg'),
color: theme('colors-text'),
}
const ButtonRow = define('ButtonRow', {
display: 'flex',
gap: '10px',
marginTop: '10px',
})
const CancelButton = define('CancelButton', {
base: 'a',
padding: '8px 16px',
fontSize: '14px',
backgroundColor: 'transparent',
color: theme('colors-text'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
cursor: 'pointer',
textDecoration: 'none',
states: {
':hover': { backgroundColor: theme('colors-bgHover') },
},
})
const BackLink = define('BackLink', {
base: 'a',
fontSize: '13px',
color: theme('colors-textMuted'),
textDecoration: 'none',
states: {
':hover': { color: theme('colors-text') },
},
})
const DetailHeader = define('DetailHeader', {
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '20px',
})
const DetailTitle = define('DetailTitle', {
base: 'h1',
fontFamily: theme('fonts-mono'),
fontSize: '18px',
fontWeight: 600,
margin: 0,
flex: 1,
})
const DetailMeta = define('DetailMeta', {
display: 'flex',
gap: '20px',
marginBottom: '20px',
fontSize: '13px',
color: theme('colors-textMuted'),
})
const MetaItem = define('MetaItem', {
display: 'flex',
gap: '6px',
})
const MetaLabel = define('MetaLabel', {
fontWeight: 500,
color: theme('colors-text'),
})
const OutputSection = define('OutputSection', {
marginTop: '20px',
})
const OutputLabel = define('OutputLabel', {
fontSize: '13px',
fontWeight: 500,
marginBottom: '8px',
})
const OutputBlock = define('OutputBlock', {
base: 'pre',
fontFamily: theme('fonts-mono'),
fontSize: '12px',
lineHeight: 1.5,
padding: '12px',
backgroundColor: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflowX: 'auto',
overflowY: 'auto',
maxHeight: '60vh',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
})
const ErrorBlock = define('ErrorBlock', {
base: 'pre',
fontFamily: theme('fonts-mono'),
fontSize: '12px',
lineHeight: 1.5,
padding: '12px',
backgroundColor: theme('colors-bgElement'),
border: `1px solid ${theme('colors-error')}`,
borderRadius: theme('radius-md'),
color: theme('colors-error'),
overflowX: 'auto',
overflowY: 'auto',
maxHeight: '60vh',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
})
const StatusBadge = define('StatusBadge', {
base: 'span',
fontSize: '12px',
padding: '2px 8px',
borderRadius: '9999px',
fontWeight: 500,
})
// Layout
function Layout({ title, children, refresh }: { title: string; children: Child; refresh?: boolean }) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{refresh && <meta http-equiv="refresh" content="2" />}
<title>{title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<ToolScript />
<Container>
{children}
</Container>
</body>
</html>
)
}
function formatRelative(ts?: number): string {
if (!ts) return '-'
const diff = Date.now() - ts
if (diff < 0) {
const mins = Math.round(-diff / 60000)
if (mins < 60) return `in ${mins}m`
const hours = Math.round(mins / 60)
if (hours < 24) return `in ${hours}h`
return `in ${Math.round(hours / 24)}d`
}
const mins = Math.round(diff / 60000)
if (mins < 60) return `${mins}m ago`
const hours = Math.round(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.round(hours / 24)}d ago`
}
function statusColor(job: CronJob): string {
if (job.state === 'running') return theme('colors-statusRunning')
if (job.state === 'disabled') return theme('colors-textMuted')
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return theme('colors-error')
return theme('colors-statusRunning')
}
// Routes
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
// JSON API
app.get('/api/jobs', c => {
const appFilter = c.req.query('app')
let jobs = getAllJobs()
if (appFilter) jobs = jobs.filter(j => j.app === appFilter)
jobs.sort((a, b) => a.id.localeCompare(b.id))
return c.json(jobs.map(j => ({
app: j.app,
name: j.name,
schedule: j.schedule,
state: j.state,
status: statusLabel(j),
lastRun: j.lastRun,
lastDuration: j.lastDuration,
lastExitCode: j.lastExitCode,
nextRun: j.nextRun,
})))
})
app.get('/api/jobs/:app/:name', c => {
const id = `${c.req.param('app')}:${c.req.param('name')}`
const job = getJob(id)
if (!job) return c.json({ error: 'Job not found' }, 404)
return c.json({
app: job.app,
name: job.name,
schedule: job.schedule,
state: job.state,
status: statusLabel(job),
lastRun: job.lastRun,
lastDuration: job.lastDuration,
lastExitCode: job.lastExitCode,
lastError: job.lastError,
lastOutput: job.lastOutput,
nextRun: job.nextRun,
})
})
app.post('/api/jobs/:app/:name/run', async c => {
const id = `${c.req.param('app')}:${c.req.param('name')}`
const job = getJob(id)
if (!job) return c.json({ error: 'Job not found' }, 404)
if (job.state === 'running') return c.json({ error: 'Job is already running' }, 409)
executeJob(job, broadcast)
return c.json({ ok: true, message: `Started ${id}` })
})
app.get('/', async c => {
const appFilter = c.req.query('app')
let jobs = getAllJobs()
let invalid = getInvalidJobs()
if (appFilter) {
jobs = jobs.filter(j => j.app === appFilter)
invalid = invalid.filter(j => j.app === appFilter)
}
jobs.sort((a, b) => a.id.localeCompare(b.id))
invalid.sort((a, b) => a.id.localeCompare(b.id))
const hasAny = jobs.length > 0 || invalid.length > 0
const anyRunning = jobs.some(j => j.state === 'running')
return c.html(
<Layout title="Cron Jobs" refresh={anyRunning}>
<ActionRow>
<NewButton href={`/new?app=${appFilter || ''}`}>New Job</NewButton>
</ActionRow>
{!hasAny ? (
<EmptyState>
No cron jobs found.
<br />
Create a cron/*.ts file in any app to get started.
</EmptyState>
) : (
<JobList>
{jobs.map(job => (
<JobItem>
<StatusDot style={{ backgroundColor: statusColor(job) }} />
<JobName>
<a href={`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`} style={{ color: 'inherit', textDecoration: 'none' }}>
{job.app}/{job.name}
</a>
</JobName>
<Schedule>{job.schedule}</Schedule>
<Time title="Last run">{formatRelative(job.lastRun)}</Time>
<Time title="Next run">{formatRelative(job.nextRun)}</Time>
<form method="post" action={`/run/${job.app}/${job.name}`}>
<RunButton type="submit" disabled={job.state === 'running'}>
{job.state === 'running' ? 'Running...' : 'Run Now'}
</RunButton>
</form>
</JobItem>
))}
{invalid.map(job => (
<InvalidItem>
<StatusDot style={{ backgroundColor: theme('colors-error') }} />
<JobName>{job.app}/{job.name}</JobName>
<ErrorText>{job.error}</ErrorText>
</InvalidItem>
))}
</JobList>
)}
</Layout>
)
})
function statusBadgeStyle(job: CronJob): Record<string, string> {
if (job.state === 'running') return { backgroundColor: theme('colors-statusRunning'), color: 'white' }
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return { backgroundColor: theme('colors-error'), color: 'white' }
return { backgroundColor: theme('colors-bgElement'), color: theme('colors-textMuted') }
}
function statusLabel(job: CronJob): string {
if (job.state === 'running') return 'running'
if (job.lastExitCode !== undefined && job.lastExitCode !== 0) return `exit ${job.lastExitCode}`
if (job.lastRun) return 'ok'
return 'idle'
}
function formatDuration(ms?: number): string {
if (!ms) return '-'
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${Math.round(ms / 1000)}s`
return `${Math.round(ms / 60000)}m`
}
app.get('/job/:app/:name', async c => {
const id = `${c.req.param('app')}:${c.req.param('name')}`
const job = getJob(id)
const appFilter = c.req.query('app')
const backUrl = appFilter ? `/?app=${appFilter}` : '/'
if (!job) {
return c.html(
<Layout title="Job Not Found">
<BackLink href={backUrl}>&#8592; Back</BackLink>
<EmptyState>Job not found: {id}</EmptyState>
</Layout>
)
}
return c.html(
<Layout title={`${job.app}/${job.name}`} refresh={job.state === 'running'}>
<BackLink href={backUrl}>&#8592; Back</BackLink>
<DetailHeader>
<StatusDot style={{ backgroundColor: statusColor(job) }} />
<DetailTitle>{job.app}/{job.name}</DetailTitle>
<StatusBadge style={statusBadgeStyle(job)}>{statusLabel(job)}</StatusBadge>
<form method="post" action={`/run/${job.app}/${job.name}?return=detail&app=${appFilter || ''}`}>
<RunButton type="submit" disabled={job.state === 'running'}>
{job.state === 'running' ? 'Running...' : 'Run Now'}
</RunButton>
</form>
</DetailHeader>
<DetailMeta>
<MetaItem><MetaLabel>Schedule</MetaLabel> {job.schedule}</MetaItem>
<MetaItem><MetaLabel>Last run</MetaLabel> {job.state === 'running' ? 'now' : formatRelative(job.lastRun)}</MetaItem>
<MetaItem><MetaLabel>Duration</MetaLabel> {job.state === 'running' ? formatDuration(Date.now() - job.lastRun!) : formatDuration(job.lastDuration)}</MetaItem>
<MetaItem><MetaLabel>Next run</MetaLabel> {formatRelative(job.nextRun)}</MetaItem>
</DetailMeta>
{job.lastError && (
<OutputSection>
<OutputLabel>Error</OutputLabel>
<ErrorBlock>{job.lastError}</ErrorBlock>
</OutputSection>
)}
{job.lastOutput ? (
<OutputSection>
<OutputLabel>Output</OutputLabel>
<OutputBlock id="output">{job.lastOutput}</OutputBlock>
</OutputSection>
) : job.state === 'running' ? (
<OutputSection>
<OutputLabel>Output</OutputLabel>
<OutputBlock id="output" style={{ color: theme('colors-textMuted') }}>Waiting for output...</OutputBlock>
</OutputSection>
) : job.lastRun && !job.lastError ? (
<OutputSection>
<EmptyState>No output</EmptyState>
</OutputSection>
) : null}
<script dangerouslySetInnerHTML={{ __html: `var o=document.getElementById('output');if(o)o.scrollTop=o.scrollHeight` }} />
</Layout>
)
})
app.get('/new', async c => {
const appName = c.req.query('app') || ''
return c.html(
<Layout title="New Cron Job">
<form method="post" action="/new">
<Form>
<input type="hidden" name="app" value={appName} />
<FormGroup>
<Label>Job Name</Label>
<input name="name" placeholder="my-job" required pattern="[a-z0-9\-]+" title="lowercase letters, numbers, and hyphens only" style={inputStyles} />
</FormGroup>
<FormGroup>
<Label>Run every</Label>
<select name="schedule" style={inputStyles}>
{SCHEDULES.map(s => (
<option value={s} selected={s === 'day'}>{s}</option>
))}
</select>
</FormGroup>
<ButtonRow>
<CancelButton href="/">Cancel</CancelButton>
<button type="submit" style={buttonStyles}>Create Job</button>
</ButtonRow>
</Form>
</form>
</Layout>
)
})
app.post('/new', async c => {
const body = await c.req.parseBody()
const appName = body.app as string
const name = body.name as string
const schedule = body.schedule as string
if (!appName || !name || !schedule) {
return c.redirect('/new?error=missing-fields')
}
// Validate name (lowercase, numbers, hyphens)
if (!/^[a-z0-9-]+$/.test(name)) {
return c.redirect('/new?error=invalid-name')
}
const cronDir = join(APPS_DIR, appName, 'cron')
const filePath = join(cronDir, `${name}.ts`)
// Check if file already exists
if (existsSync(filePath)) {
return c.redirect('/new?error=already-exists')
}
// Create cron directory if needed
if (!existsSync(cronDir)) {
await mkdir(cronDir, { recursive: true })
}
// Write the cron file
const content = `export const schedule = "${schedule}"
export default async function() {
console.log("${appName}/${name} executed at", new Date().toISOString())
}
`
await writeFile(filePath, content)
console.log(`[cron] Created ${appName}:${name}`)
// Trigger rediscovery
const { jobs, invalid } = await discoverCronJobs()
setJobs(jobs)
setInvalidJobs(invalid)
for (const job of jobs) {
if (job.id === `${appName}:${name}`) {
scheduleJob(job, broadcast)
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
}
}
return c.redirect('/')
})
app.post('/run/:app/:name', async c => {
const id = `${c.req.param('app')}:${c.req.param('name')}`
const job = getJob(id)
if (!job) {
return c.redirect('/?error=not-found')
}
// Fire-and-forget so the redirect happens immediately
executeJob(job, broadcast)
const returnTo = c.req.query('return')
const appFilter = c.req.query('app')
if (returnTo === 'detail') {
return c.redirect(`/job/${job.app}/${job.name}${appFilter ? `?app=${appFilter}` : ''}`)
}
return c.redirect(appFilter ? `/?app=${appFilter}` : '/')
})
// Initialize
async function init() {
const { jobs, invalid } = await discoverCronJobs()
setJobs(jobs)
setInvalidJobs(invalid)
console.log(`[cron] Discovered ${jobs.length} jobs, ${invalid.length} invalid`)
for (const job of jobs) {
scheduleJob(job, broadcast)
console.log(`[cron] Scheduled ${job.id}: ${job.schedule} (${job.cronExpr})`)
}
for (const job of invalid) {
console.log(`[cron] Invalid ${job.id}: ${job.error}`)
}
}
// Watch for cron file changes
let debounceTimer: Timer | null = null
async function rediscover() {
const { jobs, invalid } = await discoverCronJobs()
const existing = getAllJobs()
// Stop removed jobs
for (const old of existing) {
if (!jobs.find(j => j.id === old.id)) {
stopJob(old.id)
console.log(`[cron] Removed ${old.id}`)
}
}
// Add/update jobs
for (const job of jobs) {
const old = existing.find(j => j.id === job.id)
if (!old || old.cronExpr !== job.cronExpr) {
scheduleJob(job, broadcast)
console.log(`[cron] Updated ${job.id}: ${job.schedule}`)
} else {
// Preserve runtime state
job.state = old.state
job.lastRun = old.lastRun
job.lastDuration = old.lastDuration
job.lastExitCode = old.lastExitCode
job.lastError = old.lastError
job.lastOutput = old.lastOutput
job.nextRun = old.nextRun
}
}
setJobs(jobs)
setInvalidJobs(invalid)
}
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
if (!filename?.includes('/cron/') && !filename?.includes('\\cron\\')) return
if (!filename.endsWith('.ts')) return
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(rediscover, 100)
})
on(['app:reload', 'app:delete'], (event) => {
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
rediscover()
})
init()
export default app.defaults

View File

@ -1,81 +0,0 @@
import { readdir, readFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { isValidSchedule, toCronExpr, type CronJob, type InvalidJob, type Schedule } from './schedules'
const APPS_DIR = process.env.APPS_DIR!
const SCHEDULE_RE = /export\s+const\s+schedule\s*=\s*['"]([^'"]+)['"]/
export async function getApps(): Promise<string[]> {
const entries = await readdir(APPS_DIR, { withFileTypes: true })
const apps: string[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
if (existsSync(join(APPS_DIR, entry.name, 'package.json'))) {
apps.push(entry.name)
}
}
return apps.sort()
}
export type DiscoveryResult = {
jobs: CronJob[]
invalid: InvalidJob[]
}
export async function discoverCronJobs(): Promise<DiscoveryResult> {
const jobs: CronJob[] = []
const invalid: InvalidJob[] = []
const apps = await readdir(APPS_DIR, { withFileTypes: true })
for (const app of apps) {
if (!app.isDirectory()) continue
const cronDir = join(APPS_DIR, app.name, 'cron')
if (!existsSync(cronDir)) continue
const files = await readdir(cronDir)
for (const file of files) {
if (!file.endsWith('.ts')) continue
const filePath = join(cronDir, file)
const name = file.replace(/\.ts$/, '')
const id = `${app.name}:${name}`
try {
const source = await readFile(filePath, 'utf-8')
const match = source.match(SCHEDULE_RE)
if (!match) {
invalid.push({ id, app: app.name, name, file: filePath, error: 'Missing schedule export' })
continue
}
const schedule = match[1] as Schedule
if (!isValidSchedule(schedule)) {
invalid.push({ id, app: app.name, name, file: filePath, error: `Invalid schedule: "${match[1]}"` })
continue
}
jobs.push({
id,
app: app.name,
name,
file: filePath,
schedule,
cronExpr: toCronExpr(schedule),
state: 'idle',
})
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
invalid.push({ id, app: app.name, name, file: filePath, error: msg })
}
}
}
return { jobs, invalid }
}

View File

@ -1,96 +0,0 @@
import { join } from 'path'
import { loadAppEnv } from '@because/toes/tools'
import type { CronJob } from './schedules'
import { getNextRun } from './scheduler'
const APPS_DIR = process.env.APPS_DIR!
const TOES_DIR = process.env.TOES_DIR!
const TOES_URL = process.env.TOES_URL!
const RUNNER = join(import.meta.dir, 'runner.ts')
function forwardLog(app: string, text: string, stream: 'stdout' | 'stderr' = 'stdout') {
fetch(`${TOES_URL}/api/apps/${app}/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, stream }),
}).catch(() => {})
}
async function readStream(stream: ReadableStream<Uint8Array>, append: (text: string) => void) {
const reader = stream.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
append(decoder.decode(value, { stream: true }))
}
}
export async function executeJob(job: CronJob, onUpdate: () => void): Promise<void> {
if (job.state === 'disabled') return
job.state = 'running'
job.lastRun = Date.now()
job.lastOutput = undefined
job.lastError = undefined
job.lastExitCode = undefined
job.lastDuration = undefined
onUpdate()
const cwd = join(APPS_DIR, job.app)
forwardLog(job.app, `[cron] Running ${job.name}`)
try {
const proc = Bun.spawn(['bun', 'run', RUNNER, job.file], {
cwd,
env: { ...process.env, ...loadAppEnv(job.app), DATA_DIR: join(TOES_DIR, job.app) },
stdout: 'pipe',
stderr: 'pipe',
})
// Stream output incrementally into job fields
await Promise.all([
readStream(proc.stdout as ReadableStream<Uint8Array>, text => {
job.lastOutput = (job.lastOutput || '') + text
for (const line of text.split('\n').filter(Boolean)) {
forwardLog(job.app, `[cron:${job.name}] ${line}`)
}
}),
readStream(proc.stderr as ReadableStream<Uint8Array>, text => {
job.lastError = (job.lastError || '') + text
for (const line of text.split('\n').filter(Boolean)) {
forwardLog(job.app, `[cron:${job.name}] ${line}`, 'stderr')
}
}),
])
const code = await proc.exited
job.lastDuration = Date.now() - job.lastRun
job.lastExitCode = code
if (!job.lastError && code !== 0) job.lastError = 'Non-zero exit'
if (code === 0) job.lastError = undefined
if (!job.lastOutput) job.lastOutput = undefined
job.state = 'idle'
job.nextRun = getNextRun(job.id)
// Log result
const status = code === 0 ? 'ok' : `failed (code=${code})`
const summary = `[cron] ${job.name} finished: ${status} duration=${job.lastDuration}ms`
console.log(summary)
forwardLog(job.app, summary, code === 0 ? 'stdout' : 'stderr')
if (job.lastOutput) console.log(job.lastOutput)
if (job.lastError) console.error(job.lastError)
} catch (e) {
job.lastDuration = Date.now() - job.lastRun
job.lastExitCode = 1
job.lastError = e instanceof Error ? e.message : String(e)
job.state = 'idle'
console.error(`[cron] ${job.id} failed:`, e)
forwardLog(job.app, `[cron] ${job.name} failed: ${job.lastError}`, 'stderr')
}
onUpdate()
}

View File

@ -1,16 +0,0 @@
export {}
Error.stackTraceLimit = 50
const file = process.argv[2]!
const { default: fn } = await import(file)
try {
await fn()
} catch (e) {
if (e instanceof Error) {
console.error(e.stack || e.message)
} else {
console.error(e)
}
process.exit(1)
}

View File

@ -1,26 +0,0 @@
import { Cron } from 'croner'
import type { CronJob } from './schedules'
import { executeJob } from './executor'
const scheduled = new Map<string, Cron>()
export function scheduleJob(job: CronJob, onUpdate: () => void) {
// Stop existing if any
scheduled.get(job.id)?.stop()
const cron = new Cron(job.cronExpr, async () => {
await executeJob(job, onUpdate)
})
scheduled.set(job.id, cron)
job.nextRun = cron.nextRun()?.getTime()
}
export function stopJob(jobId: string) {
scheduled.get(jobId)?.stop()
scheduled.delete(jobId)
}
export function getNextRun(jobId: string): number | undefined {
return scheduled.get(jobId)?.nextRun()?.getTime()
}

View File

@ -1,119 +0,0 @@
export type Schedule =
| "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday"
| "week" | "day" | "midnight" | "noon" | "hour"
| "30 minutes" | "15 minutes" | "5 minutes" | "1 minute"
| "30minutes" | "15minutes" | "5minutes" | "1minute"
| 30 | 15 | 5 | 1
| (string & {}) // time strings like "7am", "7:30pm", "14:00"
export type CronJob = {
id: string // "appname:filename"
app: string
name: string // filename without .ts
file: string // full path
schedule: Schedule
cronExpr: string
state: 'idle' | 'running' | 'disabled'
lastRun?: number
lastDuration?: number
lastExitCode?: number
lastError?: string
lastOutput?: string
nextRun?: number
}
export type InvalidJob = {
id: string
app: string
name: string
file: string
error: string
}
export const SCHEDULES = [
'1 minute',
'5 minutes',
'15 minutes',
'30 minutes',
'hour',
'noon',
'midnight',
'day',
'week',
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
] as const
const SCHEDULE_MAP: Record<string, string> = {
sunday: '0 0 * * 0',
monday: '0 0 * * 1',
tuesday: '0 0 * * 2',
wednesday: '0 0 * * 3',
thursday: '0 0 * * 4',
friday: '0 0 * * 5',
saturday: '0 0 * * 6',
week: '0 0 * * 0',
day: '0 0 * * *',
midnight: '0 0 * * *',
noon: '0 12 * * *',
hour: '0 * * * *',
'30 minutes': '0,30 * * * *',
'15 minutes': '0,15,30,45 * * * *',
'5 minutes': '*/5 * * * *',
'1 minute': '* * * * *',
'30minutes': '0,30 * * * *',
'15minutes': '0,15,30,45 * * * *',
'5minutes': '*/5 * * * *',
'1minute': '* * * * *',
}
export function isValidSchedule(value: unknown): value is Schedule {
if (typeof value === 'number') {
return [1, 5, 15, 30].includes(value)
}
if (typeof value === 'string') {
return value in SCHEDULE_MAP || parseTime(value) !== null
}
return false
}
function parseTime(s: string): { hour: number, minute: number } | null {
// 12h: "7am", "7pm", "7:30am", "7:30pm", "12am", "12:00pm"
const m12 = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i)
if (m12) {
let hour = parseInt(m12[1]!)
const minute = m12[2] ? parseInt(m12[2]) : 0
const period = m12[3]!.toLowerCase()
if (hour < 1 || hour > 12 || minute > 59) return null
if (period === 'am' && hour === 12) hour = 0
else if (period === 'pm' && hour !== 12) hour += 12
return { hour, minute }
}
// 24h: "14:00", "0:00", "23:59"
const m24 = s.match(/^(\d{1,2}):(\d{2})$/)
if (m24) {
const hour = parseInt(m24[1]!)
const minute = parseInt(m24[2]!)
if (hour > 23 || minute > 59) return null
return { hour, minute }
}
return null
}
export function toCronExpr(schedule: Schedule): string {
if (typeof schedule === 'number') {
return SCHEDULE_MAP[`${schedule}minutes`]!
}
if (schedule in SCHEDULE_MAP) {
return SCHEDULE_MAP[schedule]!
}
const time = parseTime(schedule)!
return `${time.minute} ${time.hour} * * *`
}

View File

@ -1,39 +0,0 @@
import type { CronJob, InvalidJob } from './schedules'
const jobs = new Map<string, CronJob>()
const listeners = new Set<() => void>()
let invalidJobs: InvalidJob[] = []
export function setJobs(newJobs: CronJob[]) {
jobs.clear()
for (const job of newJobs) {
jobs.set(job.id, job)
}
broadcast()
}
export function setInvalidJobs(newInvalid: InvalidJob[]) {
invalidJobs = newInvalid
}
export function getJob(id: string): CronJob | undefined {
return jobs.get(id)
}
export function getAllJobs(): CronJob[] {
return Array.from(jobs.values())
}
export function getInvalidJobs(): InvalidJob[] {
return invalidJobs
}
export function broadcast() {
listeners.forEach(cb => cb())
}
export function onChange(cb: () => void): () => void {
listeners.add(cb)
return () => listeners.delete(cb)
}

View File

@ -1,22 +0,0 @@
{
"name": "cron",
"private": true,
"module": "index.tsx",
"type": "module",
"scripts": {
"toes": "bun run --watch index.tsx"
},
"toes": {
"tool": true,
"icon": "⏰"
},
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.18",
"croner": "^9.1.0"
},
"devDependencies": {
"@types/bun": "latest"
}
}

View File

@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun"],
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}

1
apps/env/.npmrc vendored
View File

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

47
apps/env/bun.lock vendored
View File

@ -1,47 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "env",
"dependencies": {
"@because/forge": "*",
"@because/hype": "*",
"@because/toes": "*",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.2",
},
},
},
"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.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
"@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.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
"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=="],
"@because/toes/@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
}
}

513
apps/env/index.tsx vendored
View File

@ -1,513 +0,0 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, initScript, theme } from '@because/toes/tools'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import type { Child } from 'hono/jsx'
const TOES_DIR = process.env.TOES_DIR ?? join(process.env.HOME!, '.toes')
const ENV_DIR = join(TOES_DIR, 'env')
const GLOBAL_ENV_PATH = join(ENV_DIR, '_global.env')
const app = new Hype({ prettyHTML: false })
const Badge = define('Badge', {
base: 'span',
fontSize: '11px',
padding: '2px 6px',
borderRadius: '3px',
backgroundColor: theme('colors-bgSubtle'),
color: theme('colors-textMuted'),
fontFamily: theme('fonts-sans'),
fontWeight: 'normal',
marginLeft: '8px',
})
const Button = define('Button', {
base: 'button',
padding: '6px 12px',
fontSize: '13px',
borderRadius: theme('radius-md'),
border: `1px solid ${theme('colors-border')}`,
backgroundColor: theme('colors-bgElement'),
color: theme('colors-text'),
cursor: 'pointer',
states: {
':hover': {
backgroundColor: theme('colors-bgHover'),
},
},
})
const Container = define('Container', {
fontFamily: theme('fonts-sans'),
padding: '20px',
paddingTop: 0,
maxWidth: '800px',
margin: '0 auto',
color: theme('colors-text'),
})
const DangerButton = define('DangerButton', {
base: 'button',
padding: '6px 12px',
fontSize: '13px',
borderRadius: theme('radius-md'),
border: 'none',
backgroundColor: theme('colors-error'),
color: 'white',
cursor: 'pointer',
states: {
':hover': {
opacity: 0.9,
},
},
})
const EmptyState = define('EmptyState', {
padding: '30px',
textAlign: 'center',
color: theme('colors-textMuted'),
backgroundColor: theme('colors-bgSubtle'),
borderRadius: theme('radius-md'),
})
const EnvActions = define('EnvActions', {
display: 'flex',
gap: '8px',
flexShrink: 0,
})
const EnvItem = define('EnvItem', {
padding: '12px 15px',
borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '10px',
states: {
':last-child': {
borderBottom: 'none',
},
},
})
const EnvKey = define('EnvKey', {
fontFamily: theme('fonts-mono'),
fontSize: '14px',
fontWeight: 'bold',
color: theme('colors-text'),
})
const EnvList = define('EnvList', {
listStyle: 'none',
padding: 0,
margin: 0,
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
overflow: 'hidden',
})
const EnvValue = define('EnvValue', {
fontFamily: theme('fonts-mono'),
fontSize: '14px',
color: theme('colors-textMuted'),
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})
const ErrorBox = define('ErrorBox', {
color: theme('colors-error'),
padding: '20px',
backgroundColor: theme('colors-bgElement'),
borderRadius: theme('radius-md'),
margin: '20px 0',
})
const Form = define('Form', {
base: 'form',
display: 'flex',
gap: '10px',
marginTop: '15px',
padding: '15px',
backgroundColor: theme('colors-bgSubtle'),
borderRadius: theme('radius-md'),
})
const Hint = define('Hint', {
fontSize: '12px',
color: theme('colors-textMuted'),
marginTop: '10px',
})
const Input = define('Input', {
base: 'input',
flex: 1,
padding: '8px 12px',
fontSize: '14px',
fontFamily: theme('fonts-mono'),
borderRadius: theme('radius-md'),
border: `1px solid ${theme('colors-border')}`,
backgroundColor: theme('colors-bg'),
color: theme('colors-text'),
states: {
':focus': {
outline: 'none',
borderColor: theme('colors-primary'),
},
},
})
const Tab = define('Tab', {
base: 'a',
padding: '8px 16px',
fontSize: '13px',
fontFamily: theme('fonts-sans'),
color: theme('colors-textMuted'),
textDecoration: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
states: {
':hover': {
color: theme('colors-text'),
},
},
})
const TabActive = define('TabActive', {
base: 'a',
padding: '8px 16px',
fontSize: '13px',
fontFamily: theme('fonts-sans'),
color: theme('colors-text'),
textDecoration: 'none',
borderBottom: `2px solid ${theme('colors-primary')}`,
fontWeight: 'bold',
cursor: 'default',
})
const TabBar = define('TabBar', {
display: 'flex',
gap: '4px',
borderBottom: `1px solid ${theme('colors-border')}`,
marginBottom: '15px',
})
interface EnvVar {
key: string
value: string
}
interface LayoutProps {
title: string
children: Child
}
const appEnvPath = (appName: string) =>
join(ENV_DIR, `${appName}.env`)
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>
<script dangerouslySetInnerHTML={{ __html: initScript }} />
<Container>
{children}
</Container>
<script dangerouslySetInnerHTML={{ __html: clientScript }} />
</body>
</html>
)
}
function ensureEnvDir() {
if (!existsSync(ENV_DIR)) {
mkdirSync(ENV_DIR, { recursive: true })
}
}
function parseEnvFile(path: string): EnvVar[] {
if (!existsSync(path)) return []
const content = readFileSync(path, 'utf-8')
const vars: EnvVar[] = []
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex === -1) continue
const key = trimmed.slice(0, eqIndex).trim()
let value = trimmed.slice(eqIndex + 1).trim()
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
if (key) vars.push({ key, value })
}
return vars
}
function writeEnvFile(path: string, vars: EnvVar[]) {
ensureEnvDir()
const content = vars.map(v => `${v.key}=${v.value}`).join('\n') + (vars.length ? '\n' : '')
writeFileSync(path, content)
}
const clientScript = `
document.querySelectorAll('[data-reveal]').forEach(btn => {
btn.addEventListener('click', () => {
const valueEl = btn.closest('[data-env-item]').querySelector('[data-value]');
const hidden = valueEl.dataset.hidden;
if (hidden) {
valueEl.textContent = hidden;
valueEl.dataset.hidden = '';
valueEl.style.whiteSpace = 'pre-wrap';
valueEl.style.wordBreak = 'break-all';
btn.textContent = 'Hide';
} else {
valueEl.dataset.hidden = valueEl.textContent;
valueEl.textContent = '••••••••';
valueEl.style.whiteSpace = 'nowrap';
valueEl.style.wordBreak = '';
btn.textContent = 'Reveal';
}
});
});
document.querySelectorAll('input[name="key"]').forEach(input => {
input.addEventListener('paste', e => {
const text = e.clipboardData?.getData('text') ?? '';
const eqIndex = text.indexOf('=');
if (eqIndex === -1) return;
e.preventDefault();
const key = text.slice(0, eqIndex).trim();
const value = text.slice(eqIndex + 1).trim();
input.value = key;
const valueInput = input.closest('form').querySelector('input[name="value"]');
if (valueInput) {
valueInput.value = value;
valueInput.focus();
}
});
});
`
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
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="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>
)
}
const tab = c.req.query('tab') === 'global' ? 'global' : 'app'
const appUrl = `/?app=${appName}`
const globalUrl = `/?app=${appName}&tab=global`
if (tab === 'global') {
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
return c.html(
<Layout title={`Env - Global`}>
<TabBar>
<Tab href={appUrl}>App</Tab>
<TabActive href={globalUrl}>Global</TabActive>
</TabBar>
{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?app=${appName}&key=${v.key}`} style="margin:0">
<DangerButton type="submit">Delete</DangerButton>
</form>
</EnvActions>
</EnvItem>
))}
</EnvList>
)}
<Form method="POST" action={`/set-global?app=${appName}`}>
<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>
)
}
const appVars = parseEnvFile(appEnvPath(appName))
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
const globalKeys = new Set(globalVars.map(v => v.key))
return c.html(
<Layout title={`Env - ${appName}`}>
<TabBar>
<TabActive href={appUrl}>App</TabActive>
<Tab href={globalUrl}>Global</Tab>
</TabBar>
{appVars.length === 0 && globalKeys.size === 0 ? (
<EmptyState>No environment variables</EmptyState>
) : (
<EnvList>
{appVars.map(v => (
<EnvItem data-env-item>
<EnvKey>
{v.key}
{globalKeys.has(v.key) && <Badge>overrides global</Badge>}
</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions>
<Button data-reveal>Reveal</Button>
<form method="post" action={`/delete?app=${appName}&key=${v.key}`} style="margin:0">
<DangerButton type="submit">Delete</DangerButton>
</form>
</EnvActions>
</EnvItem>
))}
{globalVars.filter(v => !appVars.some(a => a.key === v.key)).map(v => (
<EnvItem data-env-item>
<EnvKey>
{v.key}
<Badge>global</Badge>
</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions>
<Button data-reveal>Reveal</Button>
</EnvActions>
</EnvItem>
))}
</EnvList>
)}
<Form method="POST" action={`/set?app=${appName}`}>
<Input type="text" name="key" placeholder="KEY" required />
<Input type="text" name="value" placeholder="value" required />
<Button type="submit">Add</Button>
</Form>
</Layout>
)
})
app.post('/set', 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()
const value = String(body.value)
if (!key) return c.text('Missing key', 400)
const path = appEnvPath(appName)
const vars = parseEnvFile(path)
const existing = vars.findIndex(v => v.key === key)
if (existing >= 0) {
vars[existing]!.value = value
} else {
vars.push({ key, value })
}
writeEnvFile(path, vars)
return c.redirect(`/?app=${appName}`)
})
app.post('/delete', 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)
const path = appEnvPath(appName)
const vars = parseEnvFile(path).filter(v => v.key !== key)
writeEnvFile(path, vars)
return c.redirect(`/?app=${appName}`)
})
app.post('/set-global', async c => {
const appName = c.req.query('app')
const body = await c.req.parseBody()
const key = String(body.key).trim().toUpperCase()
const value = String(body.value)
if (!key) return c.text('Missing key', 400)
const vars = parseEnvFile(GLOBAL_ENV_PATH)
const existing = vars.findIndex(v => v.key === key)
if (existing >= 0) {
vars[existing]!.value = value
} else {
vars.push({ key, value })
}
writeEnvFile(GLOBAL_ENV_PATH, vars)
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 (!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(appName ? `/?app=${appName}&tab=global` : '/')
})
export default app.defaults

1
apps/file.txt Normal file
View File

@ -0,0 +1 @@
hi

View File

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

View File

@ -1,49 +0,0 @@
{
"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=="],
}
}

View File

@ -1,869 +0,0 @@
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}` }
}
// 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 })
// Auto-deploy bare repos that don't have a corresponding app directory
async function deployUndeployedRepos() {
const repos = await listRepos()
for (const name of repos) {
const appDir = join(APPS_DIR, name)
if (await dirExists(appDir)) continue
const bare = repoPath(name)
if (!(await hasCommits(bare))) continue
console.log(`Auto-deploying undeployed repo: ${name}`)
const result = await deploy(name)
if (result.ok) {
await activateApp(name)
} else {
console.error(`Auto-deploy failed for ${name}: ${result.error}`)
}
}
}
deployUndeployedRepos()
on('app:delete', async ({ app: name }) => {
const bare = repoPath(name)
if (await dirExists(bare)) await rm(bare, { recursive: true, force: true })
})
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,13 +0,0 @@
function switchTab(btn: HTMLButtonElement) {
const tabs = btn.parentElement!.querySelectorAll('button')
for (const tab of tabs) tab.classList.remove('active')
btn.classList.add('active')
const panels = btn.parentElement!.nextElementSibling!.children
for (const panel of panels) (panel as HTMLElement).style.display = 'none'
const target = document.getElementById(btn.dataset.tab!)
if (target) target.style.display = 'block'
}
Object.assign(window, { switchTab })

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "toes-app",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.1",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.2",
},
},
},
"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.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
"@types/bun": ["@types/bun@1.3.7", "https://npm.nose.space/@types/bun/-/bun-1.3.7.tgz", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
"@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.7", "https://npm.nose.space/bun-types/-/bun-types-1.3.7.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
"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

@ -0,0 +1,7 @@
import { Hype } from '@because/hype'
const app = new Hype
app.get('/', c => c.html(<h1>My Profile!!!</h1>))
export default app.defaults

View File

@ -1,27 +1,24 @@
{
"name": "code",
"module": "index.tsx",
"name": "profile",
"module": "src/index.ts",
"type": "module",
"private": true,
"toes": {
"icon": "👤"
},
"scripts": {
"toes": "bun run --watch index.tsx",
"start": "bun toes",
"dev": "bun run --hot index.tsx"
},
"toes": {
"tool": true,
"icon": "💻"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.2"
},
"dependencies": {
"@because/forge": "^0.0.1",
"@because/howl": "^0.0.2",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.5"
"@because/hype": "^0.0.1"
}
}
}

1
apps/profile/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

View File

@ -0,0 +1 @@
{}

1
apps/risk/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

View File

@ -3,41 +3,39 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "stats",
"name": "todo",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.5",
"@because/forge": "*",
"@because/howl": "*",
"@because/hype": "*",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.3",
"typescript": "^5.9.2",
},
},
},
"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/howl": ["@because/howl@0.0.2", "https://npm.nose.space/@because/howl/-/howl-0.0.2.tgz", { "dependencies": { "lucide-static": "^0.555.0" }, "peerDependencies": { "@because/forge": "*", "typescript": "^5" } }, "sha512-Z4okzEa282LKkBk9DQwEUU6FT+PeThfQ6iQAY41LIEjs8B2kfXRZnbWLs7tgpwCfYORxb0RO4Hr7KiyEqnfTvQ=="],
"@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=="],
"@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
"@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.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
"@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=="],
"lucide-static": ["lucide-static@0.555.0", "https://npm.nose.space/lucide-static/-/lucide-static-0.555.0.tgz", {}, "sha512-FMMaYYsEYsUA6xlEzIMoKEV3oGnxIIvAN+AtLmYXvlTJptJTveJjVBQwvtA/zZLrD6KLEu89G95dQYlhivw5jQ=="],
"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=="],

1
apps/todo/index.tsx Normal file
View File

@ -0,0 +1 @@
export { default } from './src/server'

View File

@ -1,5 +1,5 @@
{
"name": "metrics",
"name": "todo",
"module": "index.tsx",
"type": "module",
"private": true,
@ -10,18 +10,17 @@
},
"toes": {
"tool": true,
"icon": "📊",
"dashboard": true
"icon": "🖥️"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.2"
},
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.2",
"@because/toes": "^0.0.5"
"@because/hype": "*",
"@because/forge": "*",
"@because/howl": "*"
}
}

BIN
apps/todo/pub/img/bite1.png Normal file

Binary file not shown.

BIN
apps/todo/pub/img/bite2.png Normal file

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,36 @@
import { render, useState } from 'hono/jsx/dom'
import { define } from '@because/forge'
const Wrapper = define({
margin: '0 auto',
marginTop: 50,
width: '50vw',
border: '1px solid black',
padding: 24,
textAlign: 'center'
})
export default function App() {
const [count, setCount] = useState(0)
try {
return (
<Wrapper>
<h1>It works!</h1>
<h2>Count: {count}</h2>
<div>
<button onClick={() => setCount(c => c + 1)}>+</button>
&nbsp;
<button onClick={() => setCount(c => c && c - 1)}>-</button>
</div>
</Wrapper>
)
} catch (error) {
console.error('Render error:', error)
return <><h1>Error</h1><pre>{error instanceof Error ? error : new Error(String(error))}</pre></>
}
}
const root = document.getElementById('root')!
render(<App />, root)

View File

@ -0,0 +1,40 @@
section {
max-width: 500px;
margin: 0 auto;
text-align: center;
font-size: 200%;
}
h1 {
margin-top: 0;
}
.hype {
display: inline-block;
padding: 0.3rem 0.8rem;
background: linear-gradient(45deg,
#ff00ff 0%,
#00ffff 33%,
#ffff00 66%,
#ff00ff 100%);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
color: black;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-weight: 700;
border-radius: 4px;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}
ul {
list-style-type: none;
}

View File

@ -0,0 +1,31 @@
import { $ } from 'bun'
const GIT_HASH = process.env.RENDER_GIT_COMMIT?.slice(0, 7)
|| await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'unknown')
export default () => <>
<html lang="en">
<head>
<title>hype</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link href={`/css/main.css?${GIT_HASH}`} rel="stylesheet" />
<script dangerouslySetInnerHTML={{
__html: `
window.GIT_HASH = '${GIT_HASH}';
${(process.env.NODE_ENV !== 'production' || process.env.IS_PULL_REQUEST === 'true') ? 'window.DEBUG = true;' : ''}
`
}} />
</head>
<body>
<div id="viewport">
<main>
<div id="root" />
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
</main>
</div>
</body>
</html>
</>

View File

@ -0,0 +1,8 @@
import { Hype } from '@because/hype'
const app = new Hype({ layout: false })
// custom routes go here
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
export default app.defaults

View File

View File

@ -18,6 +18,12 @@
"noImplicitOverride": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": ["src/server/*"],
"#*": ["src/client/*"],
"@*": ["src/shared/*"]
}
}
}

View File

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

View File

@ -0,0 +1,38 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "toes-app",
"dependencies": {
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.1",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5.9.2",
},
},
},
"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.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
"@types/bun": ["@types/bun@1.3.7", "https://npm.nose.space/@types/bun/-/bun-1.3.7.tgz", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
"@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.7", "https://npm.nose.space/bun-types/-/bun-types-1.3.7.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
"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

@ -46,8 +46,6 @@ app.get("/", (c) => {
app.get("/txt", c => c.text(truism()))
app.get("/ok", c => c.text("ok"))
export default {
port: process.env.PORT || 3000,
fetch: app.fetch,

1
apps/truisms/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

View File

@ -3,51 +3,46 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "@because/toes",
"name": "toes",
"dependencies": {
"@because/forge": "^0.0.7",
"@because/hype": "^0.0.9",
"@because/sneaker": "^0.0.5",
"@because/toes": "^0.0.15",
"ansis": "^4.2.0",
"commander": "14.0.3",
"diff": "^8.0.4",
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.1",
"commander": "^14.0.2",
"diff": "^8.0.3",
"kleur": "^4.1.5",
},
"devDependencies": {
"@types/bun": "latest",
"@types/diff": "^8.0.0",
},
"peerDependencies": {
"typescript": "^5.9.2",
},
},
},
"packages": {
"@because/forge": ["@because/forge@0.0.7", "https://npm.nose.space/@because/forge/-/forge-0.0.7.tgz", {}, "sha512-vrpo9/l3YbpJikr4eGNBbXDoXa1q0TtretyXwlJys/5qWEuHJJ3F8sFQ8SEeqWq3j0k9LQfS1278YE6a9mpv6g=="],
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
"@because/hype": ["@because/hype@0.0.9", "https://npm.nose.space/@because/hype/-/hype-0.0.9.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" } }, "sha512-pCaGAP0d4JDkeuVDR+8x0AemaC/EjFNNBNHhC8eKcw7soQVoXG0kYJW4bIBlEBrY8pd2TBXmMEzRIcW4QnwXNw=="],
"@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
"@because/sneaker": ["@because/sneaker@0.0.5", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.5.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" } }, "sha512-GAvsh/i6N+KRV5mK/YosOMlZ3Lnm/y8kppQiKTZ5HlBDBnJfuxGJSp7aLyHZlHtSQi1jcGJWsx6NJ4RqCD7hNA=="],
"@because/toes": ["@because/toes@0.0.15", "https://npm.nose.space/@because/toes/-/toes-0.0.15.tgz", { "dependencies": { "@because/forge": "^0.0.7", "@because/hype": "^0.0.9", "@because/sneaker": "^0.0.5", "@because/toes": "^0.0.15", "ansis": "^4.2.0", "commander": "14.0.3", "diff": "^8.0.4" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-vHCMIx3w7AK1buWIKXTTWb2oxKCrOdXj0/p4+r+prjjfp1Q6RPmkfPFy1GVRZ4P1kVr8LO7PpDITCHNMr8cAlQ=="],
"@types/bun": ["@types/bun@1.3.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@types/node": ["@types/node@25.5.0", "https://npm.nose.space/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"ansis": ["ansis@4.2.0", "https://npm.nose.space/ansis/-/ansis-4.2.0.tgz", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"bun-types": ["bun-types@1.3.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"commander": ["commander@14.0.2", "https://npm.nose.space/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"diff": ["diff@8.0.4", "https://npm.nose.space/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"hono": ["hono@4.12.9", "https://npm.nose.space/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"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=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"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=="],
"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,84 +0,0 @@
# Apps
An app is an HTTP server that runs on its assigned port.
## minimum requirements
```
apps/<name>/
package.json
index.tsx
```
**package.json** must have `scripts.toes`:
```json
{
"name": "my-app",
"module": "index.tsx",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx"
},
"toes": {
"icon": "🎨"
},
"dependencies": {
"@because/hype": "*",
"@because/forge": "*"
}
}
```
**index.tsx** must export `app.defaults`:
```tsx
import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.html(<h1>Hello</h1>))
export default app.defaults
```
## environment
- `PORT` - your assigned port (3001-3100)
- `APPS_DIR` - path to `/apps` directory
- `DATA_DIR` - per-app data directory (`toes/<app-name>/`)
## health checks
Toes hits `GET /` every 30 seconds. Return 2xx or get restarted.
3 failures = restart with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s).
## lifecycle
`invalid` -> `stopped` -> `starting` -> `running` -> `stopping`
Apps auto-restart on crash. `bun install` runs before every start.
## cli
```bash
toes new my-app # create from template
toes list # show apps
toes start my-app # start
toes stop my-app # stop
toes restart my-app # restart
toes logs -f my-app # tail logs
toes open my-app # open in browser
```
## making a new app
```bash
toes new my-app --template=spa
```
- `ssr` - server-side rendered (default)
- `spa` - single page app (w/ hono/jsx)
- `bare` - minimal

View File

@ -1,81 +0,0 @@
# Cron
A cron job is a TypeScript file that runs on a schedule.
The cron tool discovers jobs from all apps and runs them automatically.
## creating a cron job
Add a file to `cron/` in any app:
```ts
// apps/my-app/cron/daily-cleanup.ts
export const schedule = "day"
export default async function() {
console.log("Running cleanup...")
}
```
That's it. The cron tool picks it up within 60 seconds.
## schedules
| value | when |
|-------|------|
| `1 minute` | every minute |
| `5 minutes` | every 5 minutes |
| `15 minutes` | every 15 minutes |
| `30 minutes` | every 30 minutes |
| `hour` | top of every hour |
| `noon` | 12:00 daily |
| `midnight` / `day` | 00:00 daily |
| `week` / `sunday` | 00:00 Sunday |
| `monday` - `saturday` | 00:00 that day |
Alternate forms work too: `"30minutes"`, `30`, `"30 minutes"`.
## environment
Jobs run with the app's working directory and inherit all env vars.
- `APPS_DIR` - path to `/apps` directory
## ui
The cron tool shows:
- Job name (`app/job`)
- Schedule
- Last run time
- Next run time
- Run Now button
Click **New Job** to create one from the UI.
## manual runs
Hit the **Run Now** button or POST to the tool:
```bash
curl -X POST http://localhost:3001/run/my-app/daily-cleanup
```
## job state
Jobs track:
- `idle` / `running` / `disabled`
- Last run timestamp
- Last duration
- Last exit code
- Last error (if any)
- Next scheduled run
## discovery
The cron tool:
1. Scans `APPS_DIR/*/cron/*.ts`
2. Imports each file to read `schedule`
3. Validates the schedule
4. Registers with croner
Re-scans every 60 seconds to pick up new/changed/removed jobs.

View File

@ -1,51 +0,0 @@
# Environment Variables
Store API keys and secrets outside your app code.
## Using env vars
Access them via `process.env`:
```tsx
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('Missing OPENAI_API_KEY')
```
Env vars are injected when your app starts. Changing them restarts the app automatically.
## Managing env vars
### CLI
```bash
toes env my-app # list env vars
toes env my-app set KEY value # set a var
toes env my-app set KEY=value # also works
toes env my-app rm KEY # remove a var
```
### Dashboard
The `.env` tool in the tab bar lets you view and edit vars for the selected app. Values are masked until you click Reveal.
## Format
Standard `.env` syntax:
```
OPENAI_API_KEY=sk-...
DATABASE_URL=postgres://localhost/mydb
DEBUG=true
```
Keys are uppercased automatically. Quotes around values are stripped.
## Built-in variables
These are set automatically by Toes — you don't need to configure them:
- `PORT` - assigned port (3001-3100)
- `APPS_DIR` - path to the `/apps` directory
- `DATA_DIR` - per-app data directory (`toes/<app-name>/`) for storing persistent data
- `TOES_URL` - base URL of the Toes server
- `TOES_DIR` - path to the toes config directory

View File

@ -1,915 +0,0 @@
# Toes User Guide
Toes is a personal web appliance that runs multiple web apps on your home network. Plug it in, turn it on, and forget about the cloud.
## Table of Contents
- [Quick Start](#quick-start)
- [Creating an App](#creating-an-app)
- [App Templates](#app-templates)
- [App Structure](#app-structure)
- [The Bare Minimum](#the-bare-minimum)
- [Using Hype](#using-hype)
- [Using Forge](#using-forge)
- [Creating a Tool](#creating-a-tool)
- [What's a Tool?](#whats-a-tool)
- [Tool Setup](#tool-setup)
- [Theme Tokens](#theme-tokens)
- [Accessing App Data](#accessing-app-data)
- [CLI Reference](#cli-reference)
- [App Management](#app-management)
- [Lifecycle](#lifecycle)
- [Deploying Code](#deploying-code)
- [Environment Variables](#environment-variables)
- [Cron Jobs](#cron-jobs-1)
- [Metrics](#metrics)
- [Sharing](#sharing)
- [Environment Variables](#environment-variables-1)
- [Health Checks](#health-checks)
- [Running over HTTP](#running-over-http)
- [App Lifecycle](#app-lifecycle)
- [Cron Jobs](#cron-jobs)
- [Data Persistence](#data-persistence)
---
## Quick Start
```bash
# Install the CLI
curl -fsSL http://toes.local/install | bash
# Create a new app (scaffolds, inits git, and pushes to server)
toes new my-app
# Enter the directory, install deps, and develop locally
cd my-app
bun install
bun dev
# Deploy changes (standard git)
git add . && git commit -m "my changes"
git push toes main
# Open in browser
toes open
```
Your app is now running at `http://my-app.toes.local`.
`toes new` automatically sets up a `toes` git remote pointing at the server. Pushing to it triggers a deploy.
---
## Creating an App
### App Templates
Toes ships with three templates. Pick one when creating an app:
```bash
toes new my-app # SSR (default)
toes new my-app --bare # Minimal
toes new my-app --spa # Single-page app
```
**SSR** — Server-side rendered with a pages directory. Best for most apps. Uses Hype's built-in layout and page routing.
**Bare** — Just an `index.tsx` with a single route. Good when you want to start from scratch.
**SPA** — Client-side rendering with `hono/jsx/dom`. Hype serves the HTML shell and static files; the browser handles routing and rendering.
### App Structure
A generated SSR app looks like this:
```
my-app/
.gitignore # Files to exclude from sync and deploy
.npmrc # Points to the private registry
package.json # Must have scripts.toes
tsconfig.json # TypeScript config
index.tsx # Entry point (re-exports from src/server)
src/
server/
index.tsx # Hype app with routes
pages/
index.tsx # Page components
```
### The Bare Minimum
Every app needs three things:
1. **`package.json`** with a `scripts.toes` entry
2. **`index.tsx`** that exports `app.defaults`
3. **A `GET /ok` route** that returns 200 (health check)
**package.json:**
```json
{
"name": "my-app",
"module": "index.tsx",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx"
},
"toes": {
"icon": "🎨"
},
"dependencies": {
"@because/hype": "*",
"@because/forge": "*"
}
}
```
The `scripts.toes` field is how Toes discovers your app. The `toes.icon` field sets the emoji shown in the dashboard.
**.npmrc:**
```
registry=https://npm.nose.space
```
Required for installing `@because/*` packages.
**index.tsx:**
```tsx
import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.html(<h1>Hello World</h1>))
app.get('/ok', c => c.text('ok'))
export default app.defaults
```
That's it. Push to the server and it runs.
### Using Hype
Hype wraps [Hono](https://hono.dev). Everything you know from Hono works here. Hype adds a few extras:
**Basic routing:**
```tsx
import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.html(<h1>Home</h1>))
app.get('/about', c => c.html(<h1>About</h1>))
app.post('/api/items', async c => {
const body = await c.req.json()
return c.json({ ok: true })
})
app.get('/ok', c => c.text('ok'))
export default app.defaults
```
**Sub-routers:**
```tsx
const api = Hype.router()
api.get('/items', c => c.json([]))
api.post('/items', async c => {
const body = await c.req.json()
return c.json({ ok: true })
})
app.route('/api', api) // mounts at /api/items
```
**Server-Sent Events:**
```tsx
app.sse('/stream', (send, c) => {
send({ hello: 'world' })
const interval = setInterval(() => send({ time: Date.now() }), 1000)
return () => clearInterval(interval) // cleanup on disconnect
})
```
**Constructor options:**
```tsx
const app = new Hype({
layout: true, // Wraps pages in an HTML layout (default: true)
prettyHTML: true, // Pretty-print HTML output (default: true)
logging: true, // Log requests to stdout (default: true)
})
```
### Using Forge
Forge is a CSS-in-JS library that creates styled JSX components. Define a component once, use it everywhere.
**Basic usage:**
```tsx
import { define, stylesToCSS } from '@because/forge'
const Box = define('Box', {
padding: 20,
borderRadius: '6px',
backgroundColor: '#f5f5f5',
})
// <Box>content</Box> renders <div class="Box">content</div>
```
Numbers auto-convert to `px` (except `flex`, `opacity`, `zIndex`, `fontWeight`).
**Set the HTML element:**
```tsx
const Button = define('Button', { base: 'button', padding: '8px 16px' })
const Link = define('Link', { base: 'a', textDecoration: 'none' })
const Input = define('Input', { base: 'input', padding: 8, border: '1px solid #ccc' })
```
**Pseudo-classes (`states`):**
```tsx
const Item = define('Item', {
padding: 12,
states: {
':hover': { backgroundColor: '#eee' },
':last-child': { borderBottom: 'none' },
},
})
```
**Nested selectors:**
```tsx
const List = define('List', {
selectors: {
'& > li:last-child': { borderBottom: 'none' },
},
})
```
**Variants:**
```tsx
const Button = define('Button', {
base: 'button',
padding: '8px 16px',
variants: {
variant: {
primary: { backgroundColor: '#2563eb', color: 'white' },
danger: { backgroundColor: '#dc2626', color: 'white' },
},
},
})
// <Button variant="primary">Save</Button>
```
**Serving CSS:**
Forge generates CSS at runtime. Serve it from a route:
```tsx
import { stylesToCSS } from '@because/forge'
app.get('/styles.css', c =>
c.text(stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
)
```
Then link it in your HTML:
```tsx
<link rel="stylesheet" href="/styles.css" />
```
---
## Creating a Tool
### What's a Tool?
A tool is an app that appears as a tab inside the Toes dashboard instead of in the sidebar. Tools render in an iframe and receive the currently selected app as a `?app=` query parameter. Good for things like a code editor, log viewer, env manager, or cron scheduler.
From the server's perspective, a tool is identical to an app — same lifecycle, same health checks, same port allocation. The only differences are in `package.json` and how you render.
### Tool Setup
A tool needs three extra things compared to a regular app:
1. Set `"tool": true` in `package.json`
2. Include `<ToolScript />` in the HTML body
3. Prepend `baseStyles` to CSS output
**package.json:**
```json
{
"name": "my-tool",
"module": "index.tsx",
"type": "module",
"private": true,
"scripts": {
"toes": "bun run --watch index.tsx"
},
"toes": {
"icon": "🔧",
"tool": true
},
"dependencies": {
"@because/forge": "*",
"@because/hype": "*",
"@because/toes": "*"
}
}
```
Set `"tool"` to `true` for a tab labeled with the app name, or to a string for a custom label (e.g., `"tool": ".env"`).
**index.tsx:**
```tsx
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import type { Child } from 'hono/jsx'
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'),
})
function Layout({ title, children }: { title: string; children: Child }) {
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' })
)
app.get('/', async c => {
const appName = c.req.query('app')
if (!appName) {
return c.html(<Layout title="My Tool"><p>No app selected</p></Layout>)
}
return c.html(
<Layout title="My Tool">
<h2>{appName}</h2>
<p>Tool content for {appName}</p>
</Layout>
)
})
export default app.defaults
```
Key points:
- `<ToolScript />` handles dark/light mode syncing and iframe height communication with the dashboard.
- `baseStyles` sets the body background to match the dashboard theme.
- `prettyHTML: false` is recommended for tools since their output is inside an iframe.
- The `?app=` query parameter tells you which app the user has selected in the sidebar.
### Theme Tokens
Tools should use theme tokens to match the dashboard's look. Import `theme` from `@because/toes/tools`:
```tsx
import { theme } from '@because/toes/tools'
const Card = define('Card', {
color: theme('colors-text'),
backgroundColor: theme('colors-bgElement'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
padding: theme('spacing-lg'),
})
```
Available tokens:
| Token | Description |
|-------|-------------|
| `colors-bg` | Page background |
| `colors-bgSubtle` | Subtle background |
| `colors-bgElement` | Element background (cards, inputs) |
| `colors-bgHover` | Hover background |
| `colors-text` | Primary text |
| `colors-textMuted` | Secondary text |
| `colors-textFaint` | Tertiary/disabled text |
| `colors-border` | Borders |
| `colors-link` | Link text |
| `colors-primary` | Primary action color |
| `colors-primaryText` | Text on primary color |
| `colors-error` | Error color |
| `colors-dangerBorder` | Danger state border |
| `colors-dangerText` | Danger text |
| `colors-success` | Success color |
| `colors-successBg` | Success background |
| `colors-statusRunning` | Running indicator |
| `colors-statusStopped` | Stopped indicator |
| `fonts-sans` | Sans-serif font stack |
| `fonts-mono` | Monospace font stack |
| `spacing-xs` | 4px |
| `spacing-sm` | 8px |
| `spacing-md` | 12px |
| `spacing-lg` | 16px |
| `spacing-xl` | 24px |
| `radius-md` | 6px |
### Accessing App Data
**Reading app files:**
```tsx
import { join } from 'path'
const APPS_DIR = process.env.APPS_DIR!
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)
// Read files from appPath...
})
```
**Calling the Toes API:**
```tsx
const TOES_URL = process.env.TOES_URL!
// List all apps
const apps = await fetch(`${TOES_URL}/api/apps`).then(r => r.json())
// Get a specific app
const app = await fetch(`${TOES_URL}/api/apps/${name}`).then(r => r.json())
```
**Linking between tools:**
```html
<a href="/tool/code?app=my-app&file=index.tsx">Edit in Code</a>
```
Tool URLs go through `/tool/:name` which redirects to the tool's subdomain with query params preserved.
**Listening to lifecycle events:**
```tsx
import { on } from '@because/toes/tools'
const unsub = on('app:start', event => {
console.log(`${event.app} started at ${event.time}`)
})
// Event types: 'app:start', 'app:stop', 'app:create', 'app:delete', 'app:activate'
```
---
## CLI Reference
The CLI connects to your Toes server over HTTP. By default it connects to `http://toes.local`. Set `TOES_URL` to point elsewhere, or set `DEV=1` to use `http://localhost:3000`.
Most commands accept an optional app name. If omitted, the CLI uses the current directory's `package.json` name.
### App Management
**`toes list`** — List all apps and their status.
```bash
toes list # Show apps and tools
toes list --apps # Apps only (exclude tools)
toes list --tools # Tools only
```
**`toes new [name]`** — Create a new app from a template.
```bash
toes new my-app # SSR template (default)
toes new my-app --bare # Minimal template
toes new my-app --spa # SPA template
```
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>`** — Clone an app from the server to your local machine.
```bash
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.
**`toes rm [name]`** — Permanently delete an app from the server. Requires typing a confirmation.
### Lifecycle
**`toes start [name]`** — Start a stopped app.
**`toes stop [name]`** — Stop a running app.
**`toes restart [name]`** — Stop and start an app.
**`toes logs [name]`** — View logs for an app.
```bash
toes logs my-app # Today's logs
toes logs my-app -f # Follow (tail) logs in real-time
toes logs my-app -d 2026-01-15 # Logs from a specific date
toes logs my-app -s 2d # Logs from the last 2 days
toes logs my-app -g error # Filter logs by pattern
toes logs my-app -f -g error # Follow and filter
```
Duration formats for `--since`: `1h` (hours), `2d` (days), `1w` (weeks), `1m` (months).
### Deploying Code
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
# Make changes, commit, and deploy
git add .
git commit -m "update homepage"
git push toes main
```
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
Use standard git commands for history, diffing, and rollback:
```bash
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
```
To clone an existing app from the server:
```bash
git clone http://git.toes.local/my-app
cd my-app
bun install
bun dev # Develop locally
```
### Environment Variables
**`toes env [name]`** — List environment variables for an app.
```bash
toes env my-app # List app vars
toes env -g # List global vars
```
**`toes env set [name] <KEY> [value]`** — Set a variable.
```bash
toes env set my-app API_KEY sk-123 # Set for an app
toes env set my-app API_KEY=sk-123 # KEY=value format also works
toes env set -g API_KEY sk-123 # Set globally (shared by all apps)
```
Setting a variable automatically restarts the app.
**`toes env rm [name] <KEY>`** — Remove a variable.
```bash
toes env rm my-app API_KEY # Remove from an app
toes env rm -g API_KEY # Remove global var
```
### Versioning
Every `git push toes main` creates a new deploy. Version history is managed through git.
```bash
git log --oneline # List deploys
git revert HEAD # Undo last change
git push toes main # Deploy the revert
```
### Cron Jobs
Cron commands talk to the cron tool running on your Toes server.
**`toes cron [app]`** — List all cron jobs, or jobs for a specific app.
**`toes cron status <app:name>`** — Show details for a specific job.
```bash
toes cron status my-app:backup
# my-app:backup ok
#
# Schedule: day
# State: idle
# Last run: 2h ago
# Duration: 3s
# Exit code: 0
# Next run: in 22h
```
**`toes cron run <app:name>`** — Trigger a job immediately.
```bash
toes cron run my-app:backup
```
**`toes cron log [target]`** — View cron logs.
```bash
toes cron log # All cron logs
toes cron log my-app # Cron logs for an app
toes cron log my-app:backup # Logs for a specific job
toes cron log -f # Follow logs
```
### Metrics
**`toes metrics [name]`** — Show CPU, memory, and disk usage.
```bash
toes metrics # All apps
toes metrics my-app # Single app
```
### Sharing
**`toes share [name]`** — Create a public tunnel to share an app over the internet.
```bash
toes share my-app
# ↗ Sharing my-app... https://myapp.toes.space
```
**`toes unshare [name]`** — Stop sharing an app.
Every request to your app includes an `x-app-url` header with the app's public-facing URL. When shared, this is the tunnel URL (e.g., `https://myapp.toes.space`). When not shared, it's the local URL (e.g., `http://myapp.toes.local`). This works whether the request arrives through the local proxy or through a tunnel.
Use `appUrl()` from `@because/toes/tools` to read it — never hardcode your app's URL:
```tsx
import { appUrl } from '@because/toes/tools'
app.get('/callback', c => {
const url = appUrl(c.req.raw)
// "https://myapp.toes.space" when shared, "http://myapp.toes.local" otherwise
return c.redirect(`${url}/done`)
})
```
---
## Environment Variables
Toes injects these variables into every app process automatically:
| Variable | Description |
|----------|-------------|
| `PORT` | Assigned port (3001-3100). Your app must listen on this port. |
| `APPS_DIR` | Path to the apps directory on the server. |
| `DATA_DIR` | Per-app data directory for persistent storage. |
| `TOES_URL` | Base URL of the Toes server (e.g., `http://toes.local`). |
| `TOES_DIR` | Path to the Toes config directory. |
| `APP_URL` | The app's local URL (e.g., `http://myapp.toes.local`). For the public URL that accounts for sharing, use `appUrl(req)` from `@because/toes` (see [Sharing](#sharing)). |
You can set custom variables per-app or globally. Global variables are inherited by all apps. Per-app variables override globals.
```bash
# Set per-app
toes env set my-app OPENAI_API_KEY sk-123
# Set globally (shared by all apps)
toes env set -g DATABASE_URL postgres://localhost/mydb
```
Access them in your app:
```tsx
const apiKey = process.env.OPENAI_API_KEY
```
---
## Health Checks
Toes checks `GET /ok` on every app every 30 seconds. Your app must return a 2xx response.
Three consecutive failures trigger an automatic restart with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s). After 5 restart failures, the app is marked as errored and restart is disabled.
The simplest health check:
```tsx
app.get('/ok', c => c.text('ok'))
```
---
## Running over HTTP
Toes serves apps over plain HTTP (`http://<app>.toes.local`), not HTTPS. This is fine for a home network appliance, but a few browser features assume HTTPS and will silently break if you're not aware of them.
> **Note:** `localhost` gets a special pass — browsers treat it as a secure context even over HTTP. But `.local` domains don't get that exemption, so these gotchas apply when accessing your apps at `<app>.toes.local` from another device.
### Cookies
If you set cookies with the `Secure` flag, browsers will silently ignore them — the cookie just won't be stored.
Don't do this:
```tsx
c.header('Set-Cookie', 'session=abc123; HttpOnly; Secure; SameSite=Lax')
```
Do this instead:
```tsx
c.header('Set-Cookie', 'session=abc123; HttpOnly; SameSite=Lax')
```
If you're using a cookie library, make sure `secure` is set to `false` (or omitted):
```tsx
import { setCookie } from 'hono/cookie'
setCookie(c, 'session', token, {
httpOnly: true,
sameSite: 'Lax',
secure: false, // toes apps run over HTTP
})
```
### Clipboard API
`navigator.clipboard.writeText()` and `navigator.clipboard.readText()` require a secure context. They'll throw on `.local` domains.
Use the legacy fallback instead:
```tsx
function copyToClipboard(text: string) {
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
```
### Service Workers
Service workers only register on HTTPS origins (plus `localhost`). If you're building a PWA or want offline caching, it won't work on `.local`. This is a hard browser restriction with no workaround.
### Web Push Notifications
The Push API and `Notification.requestPermission()` require a secure context. For notifications on the local network, consider polling or SSE instead:
```tsx
app.sse('/notifications', (send, c) => {
// push updates over SSE instead of Web Push
send({ title: 'New item', body: 'Something happened' })
return () => {}
})
```
### Geolocation & Camera/Mic
`navigator.geolocation` and `navigator.mediaDevices.getUserMedia()` require a secure context. These won't work on `.local` domains.
### Web Crypto
`crypto.subtle` (for hashing, encryption, key generation) requires a secure context. Use a library like `tweetnacl` if you need crypto in the browser, or do it server-side:
```tsx
// Server-side — works fine, no secure context needed
const hash = new Bun.CryptoHasher('sha256').update(data).digest('hex')
```
### What about `toes share`?
`toes share` tunnels your app through HTTPS, so all of the above works when accessed through the tunnel URL. But since your app should also work locally, don't rely on secure-context APIs unless you're okay with them only working when shared.
---
## App Lifecycle
Apps move through these states:
```
invalid → stopped → starting → running → stopping → stopped
error
```
- **invalid** — Missing `package.json` or `scripts.toes`. Fix the config and start manually.
- **stopped** — Not running. Start with `toes start` or the dashboard.
- **starting** — Process spawned, waiting for `/ok` to return 200. Times out after 30 seconds.
- **running** — Healthy and serving requests.
- **stopping** — SIGTERM sent, waiting for process to exit. Escalates to SIGKILL after 10 seconds.
- **error** — Crashed too many times. Start manually to retry.
On startup, `bun install` runs automatically before the app's `scripts.toes` command.
Apps are accessed via subdomain: `http://my-app.toes.local` or `http://my-app.localhost`. The Toes server proxies requests to the app's assigned port.
---
## Cron Jobs
Place TypeScript files in a `cron/` directory inside your app:
```ts
// cron/daily-cleanup.ts
export const schedule = "day"
export default async function() {
console.log("Running daily cleanup")
// Your job logic here
}
```
The cron tool auto-discovers jobs by scanning `cron/*.ts` in all apps. New jobs are picked up within 60 seconds.
### Schedules
| Value | When |
|-------|------|
| `1 minute` | Every minute |
| `5 minutes` | Every 5 minutes |
| `15 minutes` | Every 15 minutes |
| `30 minutes` | Every 30 minutes |
| `hour` | Top of every hour |
| `noon` | 12:00 daily |
| `midnight` / `day` | 00:00 daily |
| `week` / `sunday` | 00:00 Sunday |
| `monday` - `saturday` | 00:00 on that day |
Jobs inherit the app's working directory and all environment variables.
---
## Data Persistence
Use the filesystem for data storage. The `DATA_DIR` environment variable points to a per-app directory that persists across deployments and restarts:
```tsx
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
const DATA_DIR = process.env.DATA_DIR!
function loadData(): MyData {
const path = join(DATA_DIR, 'data.json')
if (!existsSync(path)) return { items: [] }
return JSON.parse(readFileSync(path, 'utf-8'))
}
function saveData(data: MyData) {
writeFileSync(join(DATA_DIR, 'data.json'), JSON.stringify(data, null, 2))
}
```
`DATA_DIR` is separate from your app's code directory, so pushes and rollbacks won't affect stored data.

View File

@ -1,149 +0,0 @@
# Tailscale
Connect your Toes appliance to your Tailscale network for secure access from anywhere.
Tailscale is pre-installed on the appliance but not configured. The user authenticates through the dashboard or CLI — no SSH required.
## how it works
1. User clicks "Connect to Tailscale" in the dashboard (or runs `toes tailscale connect`)
2. Toes runs `tailscale login` and captures the auth URL
3. Dashboard shows the URL and a QR code
4. User visits the URL and authenticates with Tailscale
5. Toes detects the connection, runs `tailscale serve --bg 80`
6. Appliance is now accessible at `https://<hostname>.<tailnet>.ts.net`
## dashboard
Settings area shows one of three states:
**Not connected:**
- "Connect to Tailscale" button
**Connecting:**
- Auth URL as a clickable link
- QR code for mobile
- Polls `tailscale status` until authenticated
**Connected:**
- Tailnet URL (clickable)
- Tailnet name
- Device hostname
- `tailscale serve` toggle
- "Disconnect" button
## cli
```bash
toes tailscale # show status
toes tailscale connect # start auth flow, print URL, wait
toes tailscale disconnect # log out of tailnet
toes tailscale serve # toggle tailscale serve on/off
```
### `toes tailscale`
```
Tailscale: connected
Tailnet: user@github
Hostname: toes.tail1234.ts.net
IP: 100.64.0.1
Serve: on (port 80)
```
Or when not connected:
```
Tailscale: not connected
Run `toes tailscale connect` to get started.
```
### `toes tailscale connect`
```
Visit this URL to authenticate:
https://login.tailscale.com/a/abc123
Waiting for authentication... done!
Connected to tailnet user@github
https://toes.tail1234.ts.net
```
## server api
All endpoints shell out to the `tailscale` CLI and parse output.
### `GET /api/tailscale`
Returns current status.
```json
{
"installed": true,
"connected": true,
"hostname": "toes",
"tailnetName": "user@github",
"url": "https://toes.tail1234.ts.net",
"ip": "100.64.0.1",
"serving": true
}
```
When not connected:
```json
{
"installed": true,
"connected": false
}
```
When tailscale isn't installed:
```json
{
"installed": false
}
```
### `POST /api/tailscale/connect`
Runs `tailscale login`. Returns the auth URL.
```json
{
"authUrl": "https://login.tailscale.com/a/abc123"
}
```
### `POST /api/tailscale/disconnect`
Runs `tailscale logout`.
### `POST /api/tailscale/serve`
Toggles `tailscale serve`. Body:
```json
{ "enabled": true }
```
## install
`scripts/install.sh` installs tailscale and enables the daemon, but does not authenticate:
```bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo systemctl enable tailscaled
```
## permissions
The `toes` user needs passwordless sudo for tailscale commands. Add to sudoers during install:
```
toes ALL=(ALL) NOPASSWD: /usr/bin/tailscale
```
This lets the server run `sudo tailscale login`, `sudo tailscale serve`, etc. without a password prompt.

View File

@ -1,82 +0,0 @@
# Tools
A tool is an app that appears as a tab in the dashboard instead of in the sidebar.
Tools know which app is selected and render in an iframe over the content area.
## making an app a tool
Add `toes.tool` to package.json:
```json
{
"toes": {
"tool": true,
"icon": "🔧"
},
"scripts": {
"toes": "bun run --watch index.tsx"
}
}
```
## getting the selected app
Query param `?app=<name>` tells you which app the user selected:
```tsx
app.get('/', c => {
const appName = c.req.query('app')
if (!appName) {
return c.html(<p>No app selected</p>)
}
// do something with appName
})
```
## environment
- `PORT` - your assigned port
- `APPS_DIR` - path to `/apps` directory
- `DATA_DIR` - per-app data directory (`toes/<tool-name>/`)
- `TOES_URL` - base URL of the Toes server
- `TOES_DIR` - path to the toes config directory
## accessing app files
```ts
const APPS_DIR = process.env.APPS_DIR!
const appPath = join(APPS_DIR, appName)
```
## linking to tools
Use `/tool/:name` URLs to link directly to tools with params:
```html
<a href="/tool/code?app=my-app">
View in Code
</a>
```
## resize iframe
```js
window.parent.postMessage({
type: 'resize-iframe',
height: 500
}, '*')
```
## iframe behavior
- iframes are cached per tool+app combination
- Never recreated once loaded
- State persists across tab switches
## cli
```bash
toes list --tools # list tools only
toes list --all # list apps and tools
```

View File

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

View File

@ -1,26 +0,0 @@
{
"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=="],
}
}

View File

@ -1,135 +0,0 @@
#!/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.
RELEASE_URL="https://toes.dev/release/latest.tar.gz"
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"
# ── Download ─────────────────────────────────────────────
info "Downloading toes"
mkdir -p "$DEST"
curl -fsSL "$RELEASE_URL" | tar xz --strip-components=1 -C "$DEST"
# ── Directories ──────────────────────────────────────────
mkdir -p "$APPS_DIR" "$DATA_DIR" "$DATA_DIR/toes"
# ── Dependencies ─────────────────────────────────────────
cd "$DEST"
info "Installing dependencies"
quiet bun install
# ── Bundled apps ─────────────────────────────────────────
REPOS_DIR="$DATA_DIR/repos"
mkdir -p "$REPOS_DIR"
info "Installing bundled apps"
pids=()
for app_dir in "$DEST"/apps/*/; do
app=$(basename "$app_dir")
[ -f "$app_dir/package.json" ] || continue
echo " $app"
(
cp -a "$app_dir" "$APPS_DIR/$app"
quiet bun install --frozen-lockfile --cwd "$APPS_DIR/$app" || quiet bun install --cwd "$APPS_DIR/$app"
) &
pids+=("$!")
done
for pid in "${pids[@]}"; do
wait "$pid" || fail "A bundled app failed to install."
done
# Copy pre-built bare repos for git-based versioning
cp -a "$DEST"/dist/repos/*.git "$REPOS_DIR/"
# ── CLI + SSH ────────────────────────────────────────────
info "Setting up SSH access"
sudo bash "$DEST/scripts/setup-ssh.sh"
info "Installing CLI"
sudo install -m 755 "$DEST/dist/toes" /usr/local/bin/toes
# ── Systemd ──────────────────────────────────────────────
info "Installing toes service"
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=$(grep '"version"' "$DEST/package.json" | head -1 | sed 's/.*"version": *"\(.*\)".*/\1/')
echo ""
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
echo " ${d}─────────────────────────────${r}"
echo ""
echo " Dashboard: ${c}http://$(hostname).local${r}"
echo " SSH CLI: ${c}ssh cli@$(hostname).local${r}"
echo ""
echo " ${d}Grab the CLI:${r}"
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"
echo ""

View File

@ -1,16 +0,0 @@
{
"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"
}
}

View File

@ -1,25 +0,0 @@
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" },
})
}
if (new URL(req.url).pathname === "/shout") {
return Response.redirect(
"https://git.nose.space/defunkt/go-shout/raw/branch/main/install.sh",
302
)
}
return new Response("404 Not Found", { status: 404 })
},
})
console.log(`Serving /install on :${Bun.env.PORT || 3000}`)

View File

@ -1,43 +0,0 @@
{
"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,52 +1,39 @@
{
"name": "@because/toes",
"version": "0.0.19",
"version": "0.0.3",
"description": "personal web appliance - turn it on and forget about the cloud",
"module": "src/index.ts",
"type": "module",
"files": [
"src"
],
"exports": {
".": "./src/index.ts",
"./tools": "./src/tools/index.ts"
},
"bin": {
"toes": "src/cli/index.ts"
},
"scripts": {
"check": "bun run templates && bunx tsc --noEmit",
"build": "./scripts/build.sh",
"release": "./scripts/release.sh",
"cli:build": "bun run scripts/build.ts",
"cli:build:all": "bun run scripts/build.ts --all",
"cli:install": "bun cli:build && sudo cp dist/toes /usr/local/bin",
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes",
"cli:uninstall": "sudo rm /usr/local/bin",
"debug": "DEBUG=1 bun run dev",
"dev": "bun run templates && rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
"remote:migrate": "bun run scripts/migrate.ts",
"remote:deploy": "./scripts/remote-deploy.sh",
"deploy": "./scripts/deploy.sh",
"dev": "bun run --hot src/server/index.tsx",
"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 templates && bun run src/server/index.tsx",
"templates": "bun run scripts/embed-templates.ts",
"start": "bun run src/server/index.tsx",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "latest",
"@types/diff": "^8.0.0"
},
"peerDependencies": {
"typescript": "^5.9.2"
},
"dependencies": {
"@because/forge": "^0.0.7",
"@because/hype": "^0.0.9",
"@because/sneaker": "^0.0.5",
"@because/toes": "^0.0.15",
"ansis": "^4.2.0",
"commander": "14.0.3",
"diff": "^8.0.4"
"@because/forge": "^0.0.1",
"@because/hype": "^0.0.1",
"commander": "^14.0.2",
"diff": "^8.0.3",
"kleur": "^4.1.5"
}
}
}

View File

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

View File

@ -1,5 +1,4 @@
#!/usr/bin/env bash
# Builds the client JS bundle
set -euo pipefail
echo ">> Building client bundle"
@ -16,4 +15,3 @@ bun build src/client/index.tsx \
echo ">> Client bundle created at pub/client/index.js"
ls -lh pub/client/index.js

View File

@ -1,5 +1,4 @@
#!/usr/bin/env bun
// Builds the self-contained CLI executable
// Usage: bun scripts/build.ts [--all | --target=<name>]
// No flags: builds for current platform (dist/toes)
// --all: builds for all targets (macos-arm64, macos-x64, linux-arm64, linux-x64)
@ -9,7 +8,6 @@ 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
@ -24,6 +22,52 @@ 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',
'--minify',
'--sourcemap=external',
'--outfile',
output,
], {
stdout: 'inherit',
stderr: 'inherit',
env: {
...process.env,
BUN_TARGET_OS: target.os,
BUN_TARGET_ARCH: target.arch,
},
})
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
@ -41,7 +85,6 @@ async function buildCurrent() {
'bun',
'--minify',
'--sourcemap=external',
`--define=__GIT_SHA__="${GIT_SHA}"`,
'--outfile',
output,
], {
@ -61,58 +104,6 @@ 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,10 +2,7 @@
# It isn't enough to modify this yet.
# You also need to manually update the toes.service file.
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}"
HOST="${HOST:-toes@toes.local}"
URL="${URL:-http://toes.local}"
DEST="${DEST:-~/.toes}"
APPS_DIR="${APPS_DIR:-~/apps}"

23
scripts/deploy.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -e
# Get absolute path of this script's directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
# Load config
source "$ROOT_DIR/scripts/config.sh"
# Make sure we're up-to-date
if [ -n "$(git status --porcelain)" ]; then
echo "=> You have unsaved (git) changes"
exit 1
fi
git push origin main
# SSH to target and update
ssh "$HOST" "cd $DEST && git pull origin main && bun run build && sudo systemctl restart toes.service"
echo "=> Deployed to $HOST"
echo "=> Visit $URL"

View File

@ -1,71 +0,0 @@
#!/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,9 +1,71 @@
#!/usr/bin/env bash
##
# installs toes on your Raspberry Pi
# delegates to the canonical installer at install/install.sh
# installs systemd files to keep toes running on your Raspberry Pi
set -euo pipefail
exec "$(dirname "$0")/../install/install.sh"
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
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
quiet curl -fsSL https://bun.sh/install | bash
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 apps directory"
mkdir -p ~/apps
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"
quiet mkdir -p ~/.config/labwc
cat > ~/.config/labwc/autostart <<'EOF'
chromium-browser --noerrdialogs --disable-infobars --kiosk http://localhost
EOF
echo ">> Done! Rebooting in 5 seconds..."
quiet systemctl status "$SERVICE_NAME" --no-pager -l
sleep 5
quiet sudo nohup reboot >/dev/null 2>&1 &
exit 0

View File

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

View File

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

View File

@ -9,7 +9,4 @@ ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
# Run remote install on the target
ssh "$SSH_HOST" bash <<'SCRIPT'
set -e
curl -fsSL https://toes.dev/install | sh
SCRIPT
ssh "$HOST" "git clone https://git.nose.space/defunkt/toes $DEST && cd $DEST && ./scripts/install.sh"

View File

@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/.."
source "$ROOT_DIR/scripts/config.sh"
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 "$SSH_HOST" "sudo systemctl restart toes.service"
ssh "$HOST" "sudo systemctl restart toes.service"

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