Compare commits

...

8 Commits

Author SHA1 Message Date
b1fc698b9a PID ideas 2026-02-04 09:52:19 -08:00
a3f36a0c98 tsconfig.json 2026-02-04 09:51:58 -08:00
b2d7c72fee bun check 2026-02-04 09:51:35 -08:00
2ef00c9d53 /ok 2026-02-04 09:51:29 -08:00
067fd12e94 toolscript 2026-02-04 09:50:32 -08:00
0d572b4b5d share pids, proxy api 2026-02-04 08:54:35 -08:00
913f8f34d7 0.0.5 2026-02-04 08:51:30 -08:00
11caa8fe19 tools guide 2026-02-04 08:39:00 -08:00
20 changed files with 877 additions and 11 deletions

336
CLAUDE.md
View File

@ -223,3 +223,339 @@ function start(app: App): void {
console.log(`Starting ${app.config.name}`)
}
```
## Guide to Writing Tools
This guide explains how to write a Toes tool.
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=&lt;name&gt;</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

374
PID.md Normal file
View File

@ -0,0 +1,374 @@
# PID File Tracking for Robust Process Management
## Problem Statement
When the Toes host process crashes unexpectedly (OOM, SIGKILL, power loss, kernel panic), child app processes continue running as orphans. On restart, Toes has no knowledge of these processes:
- **Port conflicts**: Orphans hold ports, new instances fail to bind
- **Resource waste**: Zombie processes consume memory/CPU
- **State confusion**: App appears "stopped" but is actually running
- **Data corruption**: Multiple instances may write to same files
Currently, Toes only handles graceful shutdown (SIGTERM/SIGINT). There's no recovery mechanism for ungraceful termination.
## Proposed Solution: PID File Tracking
### Design
Store PID files in `TOES_DIR/pids/`:
```
${TOES_DIR}/pids/
clock.pid # Contains: 12345
todo.pid # Contains: 12389
weather.pid # Contains: 12402
```
### Lifecycle
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ App Start │────▶│ Write PID │────▶│ Running │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐ │
│ Delete PID │◀──────────┘
└─────────────┘ App Exit
```
On host startup:
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Host Init │────▶│ Scan PIDs │────▶│Kill Orphans │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐
│Clean Stale │
│ PID Files │
└─────────────┘
```
### Implementation
#### 1. PID Directory Setup
```typescript
const PIDS_DIR = join(TOES_DIR, 'pids')
function ensurePidsDir() {
if (!existsSync(PIDS_DIR)) {
mkdirSync(PIDS_DIR, { recursive: true })
}
}
```
#### 2. Write PID on Start
In `runApp()`, after spawning:
```typescript
const proc = Bun.spawn(['bun', 'run', 'toes'], { ... })
app.proc = proc
// Write PID file
const pidFile = join(PIDS_DIR, `${dir}.pid`)
writeFileSync(pidFile, String(proc.pid))
```
#### 3. Delete PID on Exit
In the `proc.exited.then()` handler:
```typescript
proc.exited.then(code => {
// Remove PID file
const pidFile = join(PIDS_DIR, `${dir}.pid`)
if (existsSync(pidFile)) {
unlinkSync(pidFile)
}
// ... existing cleanup
})
```
#### 4. Orphan Cleanup on Startup
New function called during `initApps()`:
```typescript
function cleanupOrphanProcesses() {
ensurePidsDir()
for (const file of readdirSync(PIDS_DIR)) {
if (!file.endsWith('.pid')) continue
const appName = file.replace('.pid', '')
const pidFile = join(PIDS_DIR, file)
const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10)
if (isNaN(pid)) {
// Invalid PID file, remove it
unlinkSync(pidFile)
hostLog(`Removed invalid PID file: ${file}`)
continue
}
if (isProcessRunning(pid)) {
// Orphan found - kill it
hostLog(`Found orphan process for ${appName} (PID ${pid}), terminating...`)
try {
process.kill(pid, 'SIGTERM')
// Give it 5 seconds, then SIGKILL
setTimeout(() => {
if (isProcessRunning(pid)) {
hostLog(`Orphan ${appName} (PID ${pid}) didn't terminate, sending SIGKILL`)
process.kill(pid, 'SIGKILL')
}
}, 5000)
} catch (e) {
// Process may have exited between check and kill
hostLog(`Failed to kill orphan ${appName}: ${e}`)
}
}
// Remove stale PID file
unlinkSync(pidFile)
}
}
function isProcessRunning(pid: number): boolean {
try {
// Sending signal 0 checks if process exists without killing it
process.kill(pid, 0)
return true
} catch {
return false
}
}
```
#### 5. Integration Point
Update `initApps()`:
```typescript
export function initApps() {
initPortPool()
setupShutdownHandlers()
cleanupOrphanProcesses() // <-- Add here, before discovery
rotateLogs()
createAppSymlinks()
discoverApps()
runApps()
}
```
### Edge Cases
| Scenario | Handling |
|----------|----------|
| PID reused by OS | Check if process command matches expected pattern before killing |
| PID file corrupted | Delete invalid files, log warning |
| Multiple Toes instances | Use file locking or instance ID in PID path |
| App renamed while running | Old PID file orphaned; cleanup handles it |
| Permission denied on kill | Log error, continue with other orphans |
### Enhanced: Validate Process Identity
To avoid killing an unrelated process that reused the PID:
```typescript
function isOurProcess(pid: number, appName: string): boolean {
try {
// On macOS/Linux, check /proc or use ps
const result = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'args='])
const cmd = new TextDecoder().decode(result.stdout).trim()
// Check if it looks like a Toes app process
return cmd.includes('bun') && cmd.includes('toes')
} catch {
return false
}
}
```
---
## Related Recommendations
### 1. Store Port in PID File
Extend PID files to include port for faster recovery:
```
# clock.pid
12345
3001
```
Or use JSON:
```json
{"pid": 12345, "port": 3001, "started": 1706900000000}
```
This allows Toes to reclaim the exact port on restart, avoiding port shuffling.
### 2. Circuit Breaker for Crash Loops
Add crash tracking to prevent infinite restart loops:
```typescript
interface CrashRecord {
timestamp: number
exitCode: number
}
// Store in TOES_DIR/crashes/<app>.json
const CRASH_WINDOW = 3600000 // 1 hour
const MAX_CRASHES = 10
function recordCrash(appName: string, exitCode: number) {
const file = join(TOES_DIR, 'crashes', `${appName}.json`)
const crashes: CrashRecord[] = existsSync(file)
? JSON.parse(readFileSync(file, 'utf-8'))
: []
// Add new crash
crashes.push({ timestamp: Date.now(), exitCode })
// Prune old crashes
const cutoff = Date.now() - CRASH_WINDOW
const recent = crashes.filter(c => c.timestamp > cutoff)
writeFileSync(file, JSON.stringify(recent))
return recent.length
}
function shouldCircuitBreak(appName: string): boolean {
const file = join(TOES_DIR, 'crashes', `${appName}.json`)
if (!existsSync(file)) return false
const crashes: CrashRecord[] = JSON.parse(readFileSync(file, 'utf-8'))
const cutoff = Date.now() - CRASH_WINDOW
const recent = crashes.filter(c => c.timestamp > cutoff)
return recent.length >= MAX_CRASHES
}
```
### 3. Track Restart Timer for Cancellation
Store scheduled restart timers on the app object:
```typescript
export type App = SharedApp & {
// ... existing fields
restartTimer?: Timer // <-- Add this
}
```
Update `scheduleRestart()`:
```typescript
function scheduleRestart(app: App, dir: string) {
// Cancel any existing scheduled restart
if (app.restartTimer) {
clearTimeout(app.restartTimer)
}
// ... existing delay calculation ...
app.restartTimer = setTimeout(() => {
app.restartTimer = undefined
// ... existing restart logic
}, delay)
}
```
Update `clearTimers()`:
```typescript
const clearTimers = (app: App) => {
// ... existing timer cleanup ...
if (app.restartTimer) {
clearTimeout(app.restartTimer)
app.restartTimer = undefined
}
}
```
### 4. Exit Code Classification
```typescript
function classifyExit(code: number | null): 'restart' | 'invalid' | 'stop' {
if (code === null) return 'restart' // Killed by signal
if (code === 0) return 'stop' // Clean exit
if (code === 2) return 'invalid' // Bad arguments/config
if (code >= 128) {
// Killed by signal (128 + signal number)
const signal = code - 128
if (signal === 9) return 'restart' // SIGKILL (OOM?)
if (signal === 15) return 'stop' // SIGTERM (intentional)
}
return 'restart' // Default: try again
}
```
### 5. Install Timeout
Wrap `bun install` with a timeout:
```typescript
async function installWithTimeout(cwd: string, timeout = 60000): Promise<boolean> {
const install = Bun.spawn(['bun', 'install'], {
cwd,
stdout: 'pipe',
stderr: 'pipe'
})
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
install.kill()
reject(new Error('Install timeout'))
}, timeout)
})
try {
await Promise.race([install.exited, timeoutPromise])
return install.exitCode === 0
} catch (e) {
return false
}
}
```
---
## Implementation Priority
| Change | Effort | Impact | Priority |
|--------|--------|--------|----------|
| PID file tracking | Medium | High | **1** |
| Orphan cleanup on startup | Medium | High | **1** |
| Track restart timer | Low | Medium | **2** |
| Install timeout | Low | Medium | **2** |
| Circuit breaker | Medium | Medium | **3** |
| Exit code classification | Low | Low | **4** |
| Process identity validation | Medium | Low | **5** |
---
## Testing Checklist
- [ ] Host crashes while apps running → orphans cleaned on restart
- [ ] App crashes → PID file removed, restart scheduled
- [ ] App stopped manually → PID file removed, no restart
- [ ] Stale PID file (process gone) → file cleaned up
- [ ] PID reused by unrelated process → not killed (with identity check)
- [ ] Multiple rapid restarts → circuit breaker triggers
- [ ] Rename app while running → handled gracefully
- [ ] `bun install` hangs → times out, app marked failed

View File

@ -3,6 +3,7 @@ import { Hype } from '@because/hype'
const app = new Hype
app.get('/', c => c.html(<h1>Hi there!</h1>))
app.get('/ok', c => c.text('ok'))
const apps = () => {
}

View File

@ -45,6 +45,8 @@ 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

@ -319,6 +319,8 @@ function Layout({ title, children, highlight, editable }: LayoutProps) {
)
}
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))

View File

@ -217,6 +217,8 @@ function statusColor(job: CronJob): string {
}
// 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',
}))

View File

@ -248,6 +248,8 @@ function appEnvPath(appName: string): string {
return join(ENV_DIR, `${appName}.env`)
}
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))

View File

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

View File

@ -1,6 +1,6 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, initScript, theme } from '@because/toes/tools'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { join } from 'path'
@ -190,6 +190,8 @@ function serializeTodo(todo: ParsedTodo): string {
return lines.join('\n') + '\n'
}
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + todoStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
@ -209,7 +211,7 @@ app.get('/', async c => {
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<script dangerouslySetInnerHTML={{ __html: initScript }} />
<ToolScript />
<Container>
<Header>
<Title>TODO</Title>
@ -242,7 +244,7 @@ app.get('/', async c => {
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<script dangerouslySetInnerHTML={{ __html: initScript }} />
<ToolScript />
<Container>
<Header>
<div>

View File

@ -1,6 +1,6 @@
import { Hype } from '@because/hype'
import { define, stylesToCSS } from '@because/forge'
import { baseStyles, initScript, theme } from '@because/toes/tools'
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
import { readdir, readlink, stat } from 'fs/promises'
import { join } from 'path'
import type { Child } from 'hono/jsx'
@ -109,7 +109,7 @@ function Layout({ title, subtitle, children }: LayoutProps) {
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<script dangerouslySetInnerHTML={{ __html: initScript }} />
<ToolScript />
<Container>
<Header>
<Title>Versions</Title>
@ -121,6 +121,8 @@ function Layout({ title, subtitle, children }: LayoutProps) {
)
}
app.get('/ok', c => c.text('ok'))
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))

View File

@ -1,6 +1,6 @@
{
"name": "@because/toes",
"version": "0.0.4",
"version": "0.0.5",
"description": "personal web appliance - turn it on and forget about the cloud",
"module": "src/index.ts",
"type": "module",
@ -15,6 +15,7 @@
"toes": "src/cli/index.ts"
},
"scripts": {
"check": "bunx tsc --noEmit",
"build": "./scripts/build.sh",
"cli:build": "bun run scripts/build.ts",
"cli:build:all": "bun run scripts/build.ts --all",

View File

@ -12,4 +12,5 @@ export {
startApp,
stopApp,
} from './manage'
export { statsApp } from './stats'
export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'

103
src/cli/commands/stats.ts Normal file
View File

@ -0,0 +1,103 @@
import color from 'kleur'
import { get } from '../http'
import { resolveAppName } from '../name'
interface AppStats {
name: string
state: string
port?: number
pid?: number
cpu?: number
memory?: number
rss?: number
tool?: boolean
}
function formatRss(kb?: number): string {
if (kb === undefined) return '-'
if (kb < 1024) return `${kb} KB`
if (kb < 1024 * 1024) return `${(kb / 1024).toFixed(1)} MB`
return `${(kb / 1024 / 1024).toFixed(2)} GB`
}
function formatPercent(value?: number): string {
if (value === undefined) return '-'
return `${value.toFixed(1)}%`
}
function pad(str: string, len: number, right = false): string {
if (right) return str.padStart(len)
return str.padEnd(len)
}
export async function statsApp(arg?: string) {
// If arg is provided, show stats for that app only
if (arg) {
const name = resolveAppName(arg)
if (!name) return
const stats: AppStats | undefined = await get(`/api/tools/stats/api/stats/${name}`)
if (!stats) {
console.error(`App not found: ${name}`)
return
}
console.log(`${color.bold(stats.name)} ${stats.tool ? color.gray('[tool]') : ''}`)
console.log(` State: ${stats.state}`)
if (stats.pid) console.log(` PID: ${stats.pid}`)
if (stats.port) console.log(` Port: ${stats.port}`)
if (stats.cpu !== undefined) console.log(` CPU: ${formatPercent(stats.cpu)}`)
if (stats.memory !== undefined) console.log(` Memory: ${formatPercent(stats.memory)}`)
if (stats.rss !== undefined) console.log(` RSS: ${formatRss(stats.rss)}`)
return
}
// Show stats for all apps
const stats: AppStats[] | undefined = await get('/api/tools/stats/api/stats')
if (!stats || stats.length === 0) {
console.log('No apps found')
return
}
// Sort: running first, then by name
stats.sort((a, b) => {
if (a.state === 'running' && b.state !== 'running') return -1
if (a.state !== 'running' && b.state === 'running') return 1
return a.name.localeCompare(b.name)
})
// Calculate column widths
const nameWidth = Math.max(4, ...stats.map(s => s.name.length + (s.tool ? 7 : 0)))
const stateWidth = Math.max(5, ...stats.map(s => s.state.length))
// Header
console.log(
color.gray(
`${pad('NAME', nameWidth)} ${pad('STATE', stateWidth)} ${pad('PID', 7, true)} ${pad('CPU', 7, true)} ${pad('MEM', 7, true)} ${pad('RSS', 10, true)}`
)
)
// Rows
for (const s of stats) {
const name = s.tool ? `${s.name} ${color.gray('[tool]')}` : s.name
const stateColor = s.state === 'running' ? color.green : s.state === 'invalid' ? color.red : color.gray
const state = stateColor(s.state)
const pid = s.pid ? String(s.pid) : '-'
const cpu = formatPercent(s.cpu)
const mem = formatPercent(s.memory)
const rss = formatRss(s.rss)
console.log(
`${pad(name, nameWidth)} ${pad(state, stateWidth)} ${pad(pid, 7, true)} ${pad(cpu, 7, true)} ${pad(mem, 7, true)} ${pad(rss, 10, true)}`
)
}
// Summary
const running = stats.filter(s => s.state === 'running')
const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0)
console.log()
console.log(color.gray(`${running.length} running, ${formatPercent(totalCpu)} CPU, ${formatRss(totalRss)} total`))
}

View File

@ -24,6 +24,7 @@ import {
stashListApp,
stashPopApp,
startApp,
statsApp,
statusApp,
stopApp,
syncApp,
@ -109,6 +110,12 @@ program
.option('-g, --grep <pattern>', 'filter logs by pattern')
.action(logApp)
program
.command('stats')
.description('Show CPU and memory stats for apps')
.argument('[name]', 'app name (uses current directory if omitted)')
.action(statsApp)
program
.command('open')
.description('Open an app in browser')

View File

@ -16,10 +16,8 @@ const router = Hype.router()
// BackendApp -> SharedApp
function convert(app: BackendApp): SharedApp {
const clone = { ...app }
delete clone.proc
delete clone.logs
return clone
const { proc, logs, ...rest } = app
return { ...rest, pid: proc?.pid }
}
// SSE endpoint for real-time app state updates

View File

@ -715,7 +715,7 @@ function startHealthChecks(app: App, port: number) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT)
const response = await fetch(`http://localhost:${port}/`, {
const response = await fetch(`http://localhost:${port}/ok`, {
signal: controller.signal,
})

View File

@ -22,6 +22,35 @@ app.get('/tool/:tool', c => {
return c.redirect(url)
})
// Tool API proxy: /api/tools/:tool/* -> proxy to tool port
app.all('/api/tools/:tool/:path{.+}', async c => {
const toolName = c.req.param('tool')
const tool = allApps().find(a => a.tool && a.name === toolName)
if (!tool || tool.state !== 'running' || !tool.port) {
return c.json({ error: `Tool "${toolName}" not found or not running` }, 404)
}
const subPath = '/' + c.req.param('path')
// Build target URL
const params = new URLSearchParams(c.req.query()).toString()
const targetUrl = params
? `http://localhost:${tool.port}${subPath}?${params}`
: `http://localhost:${tool.port}${subPath}`
// Proxy the request
const response = await fetch(targetUrl, {
method: c.req.method,
headers: c.req.raw.headers,
body: c.req.method !== 'GET' && c.req.method !== 'HEAD' ? c.req.raw.body : undefined,
})
return new Response(response.body, {
status: response.status,
headers: response.headers,
})
})
initApps()
export default {

View File

@ -23,6 +23,7 @@ export type App = {
state: AppState
icon: string
error?: string
pid?: number
port?: number
started?: number
logs?: LogLine[]

View File

@ -3,5 +3,6 @@ import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.text('$$APP_NAME$$'))
app.get('/ok', c => c.text('ok'))
export default app.defaults

View File

@ -4,5 +4,6 @@ const app = new Hype({ layout: false })
// custom routes go here
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
app.get('/ok', c => c.text('ok'))
export default app.defaults