new claudes
This commit is contained in:
parent
2d7ec7d53a
commit
28e8d0db2c
578
CLAUDE.md
578
CLAUDE.md
|
|
@ -1,6 +1,4 @@
|
||||||
# Toes - Claude Code Guide
|
# Toes
|
||||||
|
|
||||||
## What It Is
|
|
||||||
|
|
||||||
Personal web appliance that auto-discovers and runs multiple web apps on your home network.
|
Personal web appliance that auto-discovers and runs multiple web apps on your home network.
|
||||||
|
|
||||||
|
|
@ -8,151 +6,139 @@ Personal web appliance that auto-discovers and runs multiple web apps on your ho
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. Host server scans `/apps` directory for valid apps
|
1. Server scans `APPS_DIR` for directories with a `package.json` containing a `scripts.toes` entry
|
||||||
2. Valid app = has `package.json` with `scripts.toes` entry
|
2. Each app is spawned as a child process with a unique port (3001-3100)
|
||||||
3. Each app spawned as child process with unique port (3001+)
|
3. Dashboard UI shows all apps with status, logs, and links via SSE
|
||||||
4. Dashboard UI shows all apps with current status, logs, and links
|
4. CLI communicates with the server over HTTP
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
Tools are special apps that appear as tabs in the dashboard rather than standalone entries in the sidebar. They integrate directly into the Toes UI and can interact with the currently selected app.
|
|
||||||
|
|
||||||
### Creating a Tool
|
|
||||||
|
|
||||||
Add `toes.tool` to your app's `package.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"toes": {
|
|
||||||
"icon": "🔧",
|
|
||||||
"tool": true
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"toes": "bun run --watch index.tsx"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### How Tools Work
|
|
||||||
|
|
||||||
- Tools run as regular apps (spawned process with unique port)
|
|
||||||
- Displayed as iframes overlaying the tab content area
|
|
||||||
- Receive `?app=<name>` query parameter for the currently selected app
|
|
||||||
- Iframes are cached per tool+app combination (never recreated once loaded)
|
|
||||||
- Tool state persists across tab switches
|
|
||||||
- **App paths**: When accessing app files, tools must use `APPS_DIR/<app>/current` (not just `APPS_DIR/<app>`) to resolve through the version symlink
|
|
||||||
|
|
||||||
### CLI Flags
|
|
||||||
|
|
||||||
```bash
|
|
||||||
toes list # Lists all apps including tools
|
|
||||||
toes list --tools # Lists tools only
|
|
||||||
toes list --apps # Lists regular apps only (excludes tools)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool vs App
|
|
||||||
|
|
||||||
| Aspect | Regular App | Tool |
|
|
||||||
|--------|-------------|------|
|
|
||||||
| `toes.tool` | absent/false | true |
|
|
||||||
| UI location | Sidebar | Tab bar |
|
|
||||||
| Rendering | New browser tab | Iframe in dashboard |
|
|
||||||
| Context | Standalone | Knows selected app via query param |
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Bun** runtime (not Node)
|
- **Bun** runtime (not Node)
|
||||||
- **Hype** (custom HTTP framework wrapping Hono) from git+https://git.nose.space/defunkt/hype
|
- **Hype** (custom HTTP framework wrapping Hono) from `@because/hype`
|
||||||
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge
|
- **Forge** (typed CSS-in-JS) from `@because/forge`
|
||||||
- **Commander** + **kleur** for CLI
|
- **Commander** + **kleur** for CLI
|
||||||
- TypeScript + Hono JSX
|
- TypeScript + Hono JSX
|
||||||
|
- Client renders with `hono/jsx/dom` (no build step, served directly)
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run --hot src/server/index.tsx # Dev mode with hot reload
|
bun run dev # Hot reload (deletes pub/client/index.js first)
|
||||||
|
bun run start # Production
|
||||||
|
bun run check # Type check
|
||||||
|
bun run test # Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## App Structure
|
## Project Structure
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Env vars are stored per-app in `TOES_DIR/env/`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
${DATA_DIR}/toes/env/
|
src/
|
||||||
clock.env # env vars for clock app
|
server/ # HTTP server and process management ($)
|
||||||
todo.env # env vars for todo app
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
`TOES_DIR` defaults to `${DATA_DIR}/toes`. Apps cannot access this directory directly.
|
Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
|
||||||
|
|
||||||
## Current State
|
### Server (`src/server/`)
|
||||||
|
|
||||||
### Infrastructure (Complete)
|
- `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).
|
||||||
- App discovery, spawn, watch, auto-restart with exponential backoff
|
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create), `DELETE /:name`, `PUT /:name/rename`, `PUT /:name/icon`.
|
||||||
- Health checks every 30s (3 failures trigger restart)
|
- `api/sync.ts` -- File sync protocol: manifest comparison, push/pull with hash-based diffing.
|
||||||
- Port pool (3001-3100), sticky allocation per app
|
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps.
|
||||||
- SSE streams for real-time app state and log updates
|
- `shell.tsx` -- Minimal HTML shell for the SPA.
|
||||||
- File sync protocol with hash-based manifests
|
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY).
|
||||||
|
|
||||||
### CLI
|
### Client (`src/client/`)
|
||||||
- Full management: `toes list|start|stop|restart|info|new|rename|delete|open`
|
|
||||||
- File sync: `toes push|pull|sync`
|
|
||||||
- Logs: `toes logs [-f] <app>`
|
|
||||||
|
|
||||||
Check `TODO.txt` for planned features
|
Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx` files directly.
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection.
|
||||||
|
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs.
|
||||||
|
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout).
|
||||||
|
- `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**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
|
||||||
|
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `stats`, `cron`
|
||||||
|
- **Sync**: `push`, `pull`, `status`, `diff`, `sync`, `clean`, `stash`
|
||||||
|
- **Config**: `config`, `env`, `versions`, `history`, `rollback`
|
||||||
|
|
||||||
|
### Shared (`src/shared/`)
|
||||||
|
|
||||||
|
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
|
||||||
|
|
||||||
|
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`
|
||||||
|
- `gitignore.ts` -- `.toesignore` pattern matching
|
||||||
|
|
||||||
|
### Lib (`src/lib/`)
|
||||||
|
|
||||||
|
Server-side code shared between CLI and server. Can use Node APIs.
|
||||||
|
|
||||||
|
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa)
|
||||||
|
- `sync.ts` -- Manifest generation, hash computation
|
||||||
|
|
||||||
|
### Tools Package (`src/tools/`)
|
||||||
|
|
||||||
|
The `@because/toes` package that apps/tools import. Published exports:
|
||||||
|
|
||||||
|
- `@because/toes` -- re-exports from server (`src/index.ts` -> `src/server/sync.ts`)
|
||||||
|
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`
|
||||||
|
|
||||||
|
### Pages (`src/pages/`)
|
||||||
|
|
||||||
|
Hype page routes. `index.tsx` renders the Shell.
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### App Lifecycle
|
||||||
|
|
||||||
|
States: `invalid` -> `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
|
||||||
|
|
||||||
|
- Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes`
|
||||||
|
- Spawn: `Bun.spawn()` with `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, plus per-app env vars
|
||||||
|
- Health checks: every 30s to `/ok`, 3 consecutive failures trigger restart
|
||||||
|
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), resets after 60s stable run
|
||||||
|
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
|
||||||
|
|
||||||
|
### 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: `/tool/:tool?app=foo` -> `http://host:toolPort/?app=foo`.
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
Apps live at `APPS_DIR/<name>/` with timestamped version directories and a `current` symlink. Push creates a new version; rollback moves the symlink.
|
||||||
|
|
||||||
|
### 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`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, `DATA_DIR`.
|
||||||
|
|
||||||
|
### SSE Streaming
|
||||||
|
|
||||||
|
`/api/apps/stream` pushes the full app list on every state change. Client reconnects automatically. The `onChange()` callback system in `apps.ts` notifies listeners.
|
||||||
|
|
||||||
## Coding Guidelines
|
## Coding Guidelines
|
||||||
|
|
||||||
|
|
@ -224,338 +210,6 @@ function start(app: App): void {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Guide to Writing Tools
|
## Writing Apps and Tools
|
||||||
|
|
||||||
This guide explains how to write a Toes tool.
|
See `docs/CLAUDE.md` for the guide to writing toes apps and tools.
|
||||||
|
|
||||||
A tool is just an app that is displayed in a tab and an iframe on each app's page. When the iframe is loaded, it's passed ?app=<app-name>.
|
|
||||||
|
|
||||||
### Minimal Tool Structure
|
|
||||||
|
|
||||||
A tool needs four files at minimum:
|
|
||||||
|
|
||||||
**.npmrc**
|
|
||||||
```
|
|
||||||
registry=https://npm.nose.space
|
|
||||||
```
|
|
||||||
|
|
||||||
**tsconfig.json**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["ESNext"],
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "Preserve",
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "hono/jsx",
|
|
||||||
"allowJs": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
"noUnusedLocals": false,
|
|
||||||
"noUnusedParameters": false,
|
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"$*": ["src/server/*"],
|
|
||||||
"#*": ["src/client/*"],
|
|
||||||
"@*": ["src/shared/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**package.json**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "my-tool",
|
|
||||||
"private": true,
|
|
||||||
"module": "index.tsx",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"toes": "bun run --watch index.tsx"
|
|
||||||
},
|
|
||||||
"toes": {
|
|
||||||
"tool": true, // can also be set to a string to control the tab's label
|
|
||||||
"icon": "🔧"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/hype": "*",
|
|
||||||
"@because/toes": "*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**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 APPS_DIR = process.env.APPS_DIR!
|
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
|
||||||
|
|
||||||
// Define styled components using Forge
|
|
||||||
const Container = define('Container', {
|
|
||||||
fontFamily: theme('fonts-sans'),
|
|
||||||
padding: '20px',
|
|
||||||
paddingTop: 0,
|
|
||||||
maxWidth: '800px',
|
|
||||||
margin: '0 auto',
|
|
||||||
color: theme('colors-text'),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Layout wrapper (every tool needs this pattern)
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve styles (required)
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Main route
|
|
||||||
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">
|
|
||||||
<h1>Tool for {appName}</h1>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required Imports
|
|
||||||
|
|
||||||
Every tool imports from three packages:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// HTTP helpers
|
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
|
|
||||||
// Structured CSS
|
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
|
||||||
|
|
||||||
// Tool helpers
|
|
||||||
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Tools have access to these environment variables:
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `APPS_DIR` | Path to the apps directory (e.g., `/home/toes/apps`) |
|
|
||||||
| `TOES_URL` | Base URL of the Toes server (e.g., `http://192.168.1.100:3000`) |
|
|
||||||
| `PORT` | Port assigned to this tool |
|
|
||||||
|
|
||||||
### Query Parameters
|
|
||||||
|
|
||||||
Tools receive `?app=<name>` when an app is selected. Add custom params as needed:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const appName = c.req.query('app') // Always present when app selected
|
|
||||||
const version = c.req.query('version') // Custom param (code tool)
|
|
||||||
const file = c.req.query('file') // Custom param (code tool)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Styling with Forge
|
|
||||||
|
|
||||||
Use `define()` to create styled components and `theme()` for design tokens:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const Button = define('Button', {
|
|
||||||
base: 'button', // HTML element (optional, defaults to 'div')
|
|
||||||
padding: '8px 16px',
|
|
||||||
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 List = define('List', {
|
|
||||||
listStyle: 'none',
|
|
||||||
padding: 0,
|
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
selectors: {
|
|
||||||
'& > li:last-child': { borderBottom: 'none' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Common theme values:
|
|
||||||
- `colors-text`, `colors-textMuted`, `colors-bg`, `colors-bgHover`, `colors-bgElement`, `colors-bgSubtle`
|
|
||||||
- `colors-border`, `colors-link`, `colors-primary`, `colors-error`
|
|
||||||
- `colors-statusRunning`, `colors-statusStopped`
|
|
||||||
- `fonts-sans`, `fonts-mono`
|
|
||||||
- `radius-md`
|
|
||||||
|
|
||||||
### ToolScript
|
|
||||||
|
|
||||||
Always include `<ToolScript />` in your layout's body. It handles:
|
|
||||||
1. Theme detection (light/dark mode based on system preference)
|
|
||||||
2. Iframe height communication with parent dashboard
|
|
||||||
|
|
||||||
### Accessing App Files
|
|
||||||
|
|
||||||
When accessing files in an app, always use the `current` symlink:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { join } from 'path'
|
|
||||||
import { readdir } from 'fs/promises'
|
|
||||||
|
|
||||||
const appPath = join(APPS_DIR, appName, 'current') // ✓ Correct
|
|
||||||
// NOT: join(APPS_DIR, appName) // ✗ Wrong
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linking Between Tools
|
|
||||||
|
|
||||||
Use `TOES_URL` to create links to other tools:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const TOES_URL = process.env.TOES_URL!
|
|
||||||
|
|
||||||
// Link from versions tool to code tool
|
|
||||||
<a href={`${TOES_URL}/tool/code?app=${appName}&version=${version}`}>
|
|
||||||
View Code
|
|
||||||
</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool Complexity Levels
|
|
||||||
|
|
||||||
**Simple (single file)** - `versions` tool
|
|
||||||
- Everything in `index.tsx`
|
|
||||||
- Good for straightforward display tools
|
|
||||||
|
|
||||||
**Medium (re-export)** - `code` tool
|
|
||||||
- `index.tsx` re-exports: `export { default } from './src/server'`
|
|
||||||
- Actual implementation in `src/server/index.tsx`
|
|
||||||
- Good when you want cleaner organization
|
|
||||||
|
|
||||||
**Complex (multiple modules)** - `cron` tool
|
|
||||||
- Main server in `index.tsx`
|
|
||||||
- Business logic in `lib/` directory
|
|
||||||
- Good for tools with significant logic (discovery, scheduling, state management)
|
|
||||||
|
|
||||||
### Common Patterns
|
|
||||||
|
|
||||||
**Error states:**
|
|
||||||
```tsx
|
|
||||||
const ErrorBox = define('ErrorBox', {
|
|
||||||
color: theme('colors-error'),
|
|
||||||
padding: '20px',
|
|
||||||
backgroundColor: theme('colors-bgElement'),
|
|
||||||
borderRadius: theme('radius-md'),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!appName) {
|
|
||||||
return c.html(
|
|
||||||
<Layout title="My Tool">
|
|
||||||
<ErrorBox>Please specify an app with ?app=<name></ErrorBox>
|
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Empty states:**
|
|
||||||
```tsx
|
|
||||||
const EmptyState = define('EmptyState', {
|
|
||||||
padding: '40px 20px',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: theme('colors-textMuted'),
|
|
||||||
})
|
|
||||||
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<EmptyState>No items found</EmptyState>
|
|
||||||
) : (
|
|
||||||
<List>...</List>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Form handling:**
|
|
||||||
```tsx
|
|
||||||
app.get('/new', async c => {
|
|
||||||
return c.html(<Layout><form method="post" action="/new">...</form></Layout>)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post('/new', async c => {
|
|
||||||
const body = await c.req.parseBody()
|
|
||||||
const name = body.name as string
|
|
||||||
// Process form...
|
|
||||||
return c.redirect('/')
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**File watching (for live updates):**
|
|
||||||
```tsx
|
|
||||||
import { watch } from 'fs'
|
|
||||||
|
|
||||||
let debounceTimer: Timer | null = null
|
|
||||||
|
|
||||||
watch(APPS_DIR, { recursive: true }, (_event, filename) => {
|
|
||||||
if (!filename?.includes('/target/')) return
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer)
|
|
||||||
debounceTimer = setTimeout(handleChange, 100)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Checklist for New Tools
|
|
||||||
|
|
||||||
1. [ ] `.npmrc` contains `registry=https://npm.nose.space`
|
|
||||||
2. [ ] `tsconfig.json` matches the exact config shown above (do not improvise)
|
|
||||||
3. [ ] `package.json` has `toes.tool: true` and `toes.icon`
|
|
||||||
4. [ ] `scripts.toes` uses `bun run --watch index.tsx`
|
|
||||||
5. [ ] Dependencies include `@because/forge`, `@because/hype`, `@because/toes`
|
|
||||||
6. [ ] Import `baseStyles`, `ToolScript`, `theme` from `@because/toes/tools`
|
|
||||||
7. [ ] Layout body includes `<ToolScript />`
|
|
||||||
8. [ ] Styles served at `/styles.css` with `baseStyles + stylesToCSS()`
|
|
||||||
9. [ ] Main route handles missing `?app` parameter gracefully
|
|
||||||
10. [ ] Uses `APPS_DIR/<app>/current` for file paths
|
|
||||||
11. [ ] Exports `app.defaults` as default export
|
|
||||||
|
|
|
||||||
295
docs/CLAUDE.md
Normal file
295
docs/CLAUDE.md
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
# Toes - Guide to Writing Apps
|
||||||
|
|
||||||
|
Toes discovers and runs web apps from an `apps/` directory, each on its own port. Apps are server-rendered TypeScript using **Hype** (wraps Hono) and **Forge** (CSS-in-JS). Runtime is **Bun**.
|
||||||
|
|
||||||
|
## App vs Tool
|
||||||
|
|
||||||
|
An **app** shows in the sidebar and opens in its own browser tab.
|
||||||
|
|
||||||
|
A **tool** renders as a tab inside the dashboard (in an iframe). It receives `?app=<name>` to know the selected app. The only code difference is `"toes": { "tool": true }` in package.json and some extra imports from `@because/toes`.
|
||||||
|
|
||||||
|
## Required Files
|
||||||
|
|
||||||
|
Every app needs `.npmrc`, `tsconfig.json`, `package.json`, and `index.tsx`.
|
||||||
|
|
||||||
|
**.npmrc** -- always this exact content:
|
||||||
|
```
|
||||||
|
registry=https://npm.nose.space
|
||||||
|
```
|
||||||
|
|
||||||
|
**tsconfig.json** -- use exactly, do not improvise:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$*": ["src/server/*"],
|
||||||
|
"#*": ["src/client/*"],
|
||||||
|
"@*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**package.json** for an app:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"private": true,
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": { "toes": "bun run --watch index.tsx" },
|
||||||
|
"toes": { "icon": "🖥️" },
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/hype": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": { "@types/bun": "latest" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For a **tool**, add `@because/toes` to dependencies and set `"tool": true` (or a string for a custom tab label like `"tool": ".env"`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"toes": { "icon": "🔧", "tool": true },
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/toes": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hype
|
||||||
|
|
||||||
|
Hype wraps Hono. It adds `app.defaults` (the Bun server export), `app.sse()` for server-sent events, and `Hype.router()` for sub-routers. Everything else is standard Hono.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
const app = new Hype()
|
||||||
|
|
||||||
|
app.get('/', c => c.html(<h1>Hello</h1>))
|
||||||
|
app.get('/ok', c => c.text('ok')) // Health check -- required
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
Constructor options: `prettyHTML` (default true, tools should set false), `layout` (default true), `logging` (default true).
|
||||||
|
|
||||||
|
**SSE** -- the one non-Hono addition:
|
||||||
|
```tsx
|
||||||
|
app.sse('/stream', (send, c) => {
|
||||||
|
send({ hello: 'world' })
|
||||||
|
const interval = setInterval(() => send({ time: Date.now() }), 1000)
|
||||||
|
return () => clearInterval(interval) // cleanup on disconnect
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sub-routers:**
|
||||||
|
```tsx
|
||||||
|
const api = Hype.router()
|
||||||
|
api.get('/items', c => c.json([]))
|
||||||
|
app.route('/api', api) // mounts at /api/items
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forge
|
||||||
|
|
||||||
|
Forge creates styled JSX components via `define()`. Properties use camelCase CSS. Numbers auto-convert to `px` (except `flex`, `opacity`, `zIndex`, `fontWeight`).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
|
|
||||||
|
const Box = define('Box', {
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: '6px',
|
||||||
|
})
|
||||||
|
// <Box>content</Box> renders <div class="Box">content</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**`base`** -- set the HTML element (default `div`):
|
||||||
|
```tsx
|
||||||
|
const Button = define('Button', { base: 'button', padding: '8px 16px' })
|
||||||
|
const Link = define('Link', { base: 'a', textDecoration: 'none' })
|
||||||
|
```
|
||||||
|
|
||||||
|
**`states`** -- pseudo-classes:
|
||||||
|
```tsx
|
||||||
|
const Item = define('Item', {
|
||||||
|
padding: 12,
|
||||||
|
states: {
|
||||||
|
':hover': { backgroundColor: '#eee' },
|
||||||
|
':last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**`selectors`** -- nested CSS (`&` = the component):
|
||||||
|
```tsx
|
||||||
|
const List = define('List', {
|
||||||
|
selectors: {
|
||||||
|
'& > li:last-child': { borderBottom: 'none' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**`variants`** -- conditional styles via props:
|
||||||
|
```tsx
|
||||||
|
const Button = define('Button', {
|
||||||
|
base: 'button',
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: { backgroundColor: '#2563eb', color: 'white' },
|
||||||
|
danger: { backgroundColor: '#dc2626', color: 'white' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// <Button variant="primary">Save</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Serving CSS** -- apps serve `stylesToCSS()` from a route. Tools prepend `baseStyles`:
|
||||||
|
```tsx
|
||||||
|
app.get('/styles.css', c =>
|
||||||
|
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' })
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme Tokens
|
||||||
|
|
||||||
|
Tools import `theme` from `@because/toes/tools`. It returns CSS variables that resolve per light/dark mode.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
||||||
|
|
||||||
|
const Container = define('Container', {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Available tokens:
|
||||||
|
|
||||||
|
| Token | Use for |
|
||||||
|
|-------|---------|
|
||||||
|
| `colors-bg`, `colors-bgSubtle`, `colors-bgElement`, `colors-bgHover` | Backgrounds |
|
||||||
|
| `colors-text`, `colors-textMuted`, `colors-textFaint` | Text |
|
||||||
|
| `colors-border` | Borders |
|
||||||
|
| `colors-link` | Links |
|
||||||
|
| `colors-primary`, `colors-primaryText` | Primary actions |
|
||||||
|
| `colors-error`, `colors-dangerBorder`, `colors-dangerText` | Errors/danger |
|
||||||
|
| `colors-success`, `colors-successBg` | Success states |
|
||||||
|
| `colors-statusRunning`, `colors-statusStopped` | Status indicators |
|
||||||
|
| `fonts-sans`, `fonts-mono` | Font stacks |
|
||||||
|
| `spacing-xs` (4), `spacing-sm` (8), `spacing-md` (12), `spacing-lg` (16), `spacing-xl` (24) | Spacing (px) |
|
||||||
|
| `radius-md` (6px) | Border radius |
|
||||||
|
|
||||||
|
## Writing a Tool
|
||||||
|
|
||||||
|
Tools need three extra things vs apps:
|
||||||
|
|
||||||
|
1. `<ToolScript />` in `<body>` (handles dark mode + iframe height communication)
|
||||||
|
2. `baseStyles` prepended to CSS output
|
||||||
|
3. Handle the `?app=` query param
|
||||||
|
|
||||||
|
```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 APPS_DIR = process.env.APPS_DIR!
|
||||||
|
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>)
|
||||||
|
// ... tool logic using join(APPS_DIR, appName, 'current') for file paths
|
||||||
|
return c.html(<Layout title="My Tool">...</Layout>)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment variables** available to tools: `APPS_DIR`, `TOES_URL` (base URL of Toes server), `PORT`, `TOES_DIR`.
|
||||||
|
|
||||||
|
**Accessing app files:** always use `join(APPS_DIR, appName, 'current')`.
|
||||||
|
|
||||||
|
**Calling the Toes API:** `fetch(\`${TOES_URL}/api/apps\`)`, `fetch(\`${TOES_URL}/api/apps/${name}\`)`.
|
||||||
|
|
||||||
|
**Linking between tools:** `<a href={\`${TOES_URL}/tool/code?app=${appName}\`}>View Code</a>`.
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
**Fire-and-forget with polling** -- for long-running ops, don't await in POST. Use `<meta http-equiv="refresh" content="2" />` to poll while running.
|
||||||
|
|
||||||
|
**Inline client JS** -- use `<script dangerouslySetInnerHTML={{ __html: script }} />`.
|
||||||
|
|
||||||
|
**Data persistence** -- use filesystem. `DATA_DIR` env var points to a per-app data directory.
|
||||||
|
|
||||||
|
## Cron Jobs
|
||||||
|
|
||||||
|
Place `.ts` files in an app's `cron/` directory:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const schedule = "day"
|
||||||
|
export default async function() {
|
||||||
|
console.log("Running at", new Date().toISOString())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Valid schedules: `"1 minute"`, `"5 minutes"`, `"15 minutes"`, `"30 minutes"`, `"hour"`, `"noon"`, `"midnight"`, `"day"`, `"week"`, `"sunday"` through `"saturday"`.
|
||||||
|
|
||||||
|
## Coding Guidelines
|
||||||
|
|
||||||
|
TS file organization order: imports, re-exports, const/lets, enums, interfaces, types, classes, functions, module init. Within each section, exports first (alphabetical), then non-exports (alphabetical).
|
||||||
|
|
||||||
|
Single-line functions: `const fn = () => {}`. Multi-line: `function name() {}`.
|
||||||
Loading…
Reference in New Issue
Block a user