Compare commits
7 Commits
2c8fff85f4
...
830cd4e45d
| Author | SHA1 | Date | |
|---|---|---|---|
| 830cd4e45d | |||
| 0f5fd50fec | |||
| 84a341ebf9 | |||
| e1b53fc54d | |||
| 7763dc6314 | |||
| 95d4348560 | |||
| 2d544e9bd3 |
82
CLAUDE.md
82
CLAUDE.md
|
|
@ -1,15 +1,18 @@
|
||||||
# Toes - Claude Code Guide
|
# Toes - Claude Code Guide
|
||||||
|
|
||||||
## What It Is
|
## What It Is
|
||||||
|
|
||||||
Personal web server framework that auto-discovers and runs multiple web apps on your home network. "Set it up, turn it on, forget about the cloud."
|
Personal web server framework that auto-discovers and runs multiple web apps on your home network. "Set it up, turn it on, forget about the cloud."
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. Host server scans `/apps` directory for valid apps
|
1. Host server scans `/apps` directory for valid apps
|
||||||
2. Valid app = has `package.json` with `scripts.toes` entry
|
2. Valid app = has `package.json` with `scripts.toes` entry
|
||||||
3. Each app spawned as child process with unique port (3001+)
|
3. Each app spawned as child process with unique port (3001+)
|
||||||
4. Dashboard UI shows all apps with current status, logs, and links
|
4. Dashboard UI shows all apps with current status, logs, and links
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
- `src/server/apps.ts` - **The heart**: app discovery, process management, lifecycle
|
- `src/server/apps.ts` - **The heart**: app discovery, process management, lifecycle
|
||||||
- `src/server/index.tsx` - Entry point (minimal, just initializes Hype)
|
- `src/server/index.tsx` - Entry point (minimal, just initializes Hype)
|
||||||
- `src/pages/index.tsx` - Dashboard UI
|
- `src/pages/index.tsx` - Dashboard UI
|
||||||
|
|
@ -17,32 +20,107 @@ Personal web server framework that auto-discovers and runs multiple web apps on
|
||||||
- `TODO.md` - User-maintained task list (read this!)
|
- `TODO.md` - User-maintained task list (read this!)
|
||||||
|
|
||||||
## 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 git+https://git.nose.space/defunkt/hype
|
||||||
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge
|
- **Forge** (typed CSS-in-JS) from git+https://git.nose.space/defunkt/forge
|
||||||
- TypeScript + Hono JSX
|
- TypeScript + Hono JSX
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run --hot src/server/index.tsx # Dev mode with hot reload
|
bun run --hot src/server/index.tsx # Dev mode with hot reload
|
||||||
```
|
```
|
||||||
|
|
||||||
## App Structure
|
## App Structure
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// apps/example/index.tsx
|
// apps/example/index.tsx
|
||||||
import { Hype } from 'hype'
|
import { Hype } from "hype"
|
||||||
const app = new Hype()
|
const app = new Hype()
|
||||||
app.get('/', c => c.html(<h1>Content</h1>))
|
app.get("/", (c) => c.html(<h1>Content</h1>))
|
||||||
export default app.defaults
|
export default app.defaults
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Apps get `PORT` env var from host
|
- Apps get `PORT` env var from host
|
||||||
- Each app is isolated process with own dependencies
|
- Each app is isolated process with own dependencies
|
||||||
- No path-based routing - apps run on separate ports
|
- No path-based routing - apps run on separate ports
|
||||||
- `DATA_DIR` env controls where apps are discovered
|
- `DATA_DIR` env controls where apps are discovered
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- Core infrastructure: ✓ Complete (discovery, spawn, watch, ports, UI)
|
- Core infrastructure: ✓ Complete (discovery, spawn, watch, ports, UI)
|
||||||
- Apps: basic, profile (working); risk, tictactoe (empty)
|
- Apps: basic, profile (working); risk, tictactoe (empty)
|
||||||
- Check TODO.md for planned features
|
- Check TODO.md for planned features
|
||||||
|
|
||||||
|
## Coding Guidelines
|
||||||
|
|
||||||
|
TS files should be organized in the following way:
|
||||||
|
|
||||||
|
- imports
|
||||||
|
- re-exports
|
||||||
|
- const/lets
|
||||||
|
- enums
|
||||||
|
- interfaces
|
||||||
|
- types
|
||||||
|
- classes
|
||||||
|
- functions
|
||||||
|
- module init (top level function calls)
|
||||||
|
|
||||||
|
In each section, put the `export`s first, in alphabetical order.
|
||||||
|
|
||||||
|
Then, after the `export`s (if there were any), put everything else,
|
||||||
|
also in alphabetical order.
|
||||||
|
|
||||||
|
For single-line functions, use `const fn = () => {}` and put them in the
|
||||||
|
"functions" section of the file.
|
||||||
|
|
||||||
|
All other functions use the `function blah(){}` format.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { code } from "coders"
|
||||||
|
import { something } from "somewhere"
|
||||||
|
|
||||||
|
export type { SomeType }
|
||||||
|
|
||||||
|
const RETRY_TIMES = 5
|
||||||
|
const WIDTH = 480
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
Stopped,
|
||||||
|
Starting,
|
||||||
|
Running,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler = (req: Request) => Response
|
||||||
|
|
||||||
|
class App {
|
||||||
|
config: Config
|
||||||
|
|
||||||
|
constructor(config: Config) {
|
||||||
|
this.config = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isApp = (name: string) =>
|
||||||
|
apps.has(name)
|
||||||
|
|
||||||
|
function createApp(name: string): App {
|
||||||
|
const app = new App({ name, port: 3000 })
|
||||||
|
apps.set(name, app)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(app: App): void {
|
||||||
|
console.log(`Starting ${app.config.name}`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
12
TODO.txt
12
TODO.txt
|
|
@ -18,9 +18,15 @@
|
||||||
|
|
||||||
## cli
|
## cli
|
||||||
|
|
||||||
[ ] `toes --help`
|
[x] `toes --help`
|
||||||
[ ] `toes --version`
|
[x] `toes --version`
|
||||||
[ ] `toes list`
|
[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>`
|
||||||
[ ] `toes new`
|
[ ] `toes new`
|
||||||
[ ] `toes pull`
|
[ ] `toes pull`
|
||||||
[ ] `toes push`
|
[ ] `toes push`
|
||||||
|
|
|
||||||
4
bun.lock
4
bun.lock
|
|
@ -5,8 +5,10 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "toes",
|
"name": "toes",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"commander": "^14.0.2",
|
||||||
"forge": "git+https://git.nose.space/defunkt/forge",
|
"forge": "git+https://git.nose.space/defunkt/forge",
|
||||||
"hype": "git+https://git.nose.space/defunkt/hype",
|
"hype": "git+https://git.nose.space/defunkt/hype",
|
||||||
|
"kleur": "^4.1.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|
@ -23,6 +25,8 @@
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
|
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
|
||||||
|
|
||||||
|
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
||||||
|
|
||||||
"forge": ["forge@git+https://git.nose.space/defunkt/forge#debfd73ab22c50f66ccc93cb41164c234f78a920", { "peerDependencies": { "typescript": "^5" } }, "debfd73ab22c50f66ccc93cb41164c234f78a920"],
|
"forge": ["forge@git+https://git.nose.space/defunkt/forge#debfd73ab22c50f66ccc93cb41164c234f78a920", { "peerDependencies": { "typescript": "^5" } }, "debfd73ab22c50f66ccc93cb41164c234f78a920"],
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"commander": "^14.0.2",
|
||||||
|
"forge": "git+https://git.nose.space/defunkt/forge",
|
||||||
"hype": "git+https://git.nose.space/defunkt/hype",
|
"hype": "git+https://git.nose.space/defunkt/hype",
|
||||||
"forge": "git+https://git.nose.space/defunkt/forge"
|
"kleur": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
225
src/cli/index.ts
Normal file
225
src/cli/index.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import { program } from 'commander'
|
||||||
|
import { join } from 'path'
|
||||||
|
import type { App, LogLine } from '@types'
|
||||||
|
import color from 'kleur'
|
||||||
|
import { APPS_DIR } from '$apps'
|
||||||
|
|
||||||
|
const HOST = `http://localhost:${process.env.PORT ?? 3000}`
|
||||||
|
|
||||||
|
const STATE_ICONS: Record<string, string> = {
|
||||||
|
running: color.green('●'),
|
||||||
|
starting: color.yellow('◎'),
|
||||||
|
stopped: color.gray('◯'),
|
||||||
|
invalid: color.red('◌'),
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get<T>(url: string): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(join(HOST, url))
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
|
return await res.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post<T, B = unknown>(url: string, body?: B): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(join(HOST, url), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
|
return await res.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function infoApp(name: string) {
|
||||||
|
const app: App | undefined = await get(`/api/apps/${name}`)
|
||||||
|
if (!app) {
|
||||||
|
console.error(`App not found: ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = STATE_ICONS[app.state] ?? '◯'
|
||||||
|
console.log(`${icon} ${color.bold(app.name)}`)
|
||||||
|
console.log(` State: ${app.state}`)
|
||||||
|
if (app.port) {
|
||||||
|
console.log(` Port: ${app.port}`)
|
||||||
|
console.log(` URL: http://localhost:${app.port}`)
|
||||||
|
}
|
||||||
|
if (app.started) {
|
||||||
|
const uptime = Date.now() - app.started
|
||||||
|
const seconds = Math.floor(uptime / 1000) % 60
|
||||||
|
const minutes = Math.floor(uptime / 60000) % 60
|
||||||
|
const hours = Math.floor(uptime / 3600000)
|
||||||
|
const parts = []
|
||||||
|
if (hours) parts.push(`${hours}h`)
|
||||||
|
if (minutes) parts.push(`${minutes}m`)
|
||||||
|
parts.push(`${seconds}s`)
|
||||||
|
console.log(` Uptime: ${parts.join(' ')}`)
|
||||||
|
}
|
||||||
|
if (app.error) console.log(` Error: ${color.red(app.error)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listApps() {
|
||||||
|
const apps: App[] | undefined = await get('/api/apps')
|
||||||
|
if (!apps) return
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
console.log(`${STATE_ICONS[app.state] ?? '◯'} ${app.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startApp = async (app: string) => {
|
||||||
|
await post(`/api/apps/${app}/start`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopApp = async (app: string) => {
|
||||||
|
await post(`/api/apps/${app}/stop`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restartApp = async (app: string) => {
|
||||||
|
await post(`/api/apps/${app}/restart`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const printLog = (line: LogLine) =>
|
||||||
|
console.log(`${new Date(line.time).toLocaleTimeString()} ${line.text}`)
|
||||||
|
|
||||||
|
async function logApp(name: string, options: { follow?: boolean }) {
|
||||||
|
if (options.follow) {
|
||||||
|
await tailLogs(name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs: LogLine[] | undefined = await get(`/api/apps/${name}/logs`)
|
||||||
|
if (!logs) {
|
||||||
|
console.error(`App not found: ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (logs.length === 0) {
|
||||||
|
console.log('No logs yet')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const line of logs) {
|
||||||
|
printLog(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tailLogs(name: string) {
|
||||||
|
const url = join(HOST, `/api/apps/${name}/logs/stream`)
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`App not found: ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.body) return
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n\n')
|
||||||
|
buffer = lines.pop() ?? ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = JSON.parse(line.slice(6)) as LogLine
|
||||||
|
printLog(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openApp(name: string) {
|
||||||
|
const app: App | undefined = await get(`/api/apps/${name}`)
|
||||||
|
if (!app) {
|
||||||
|
console.error(`App not found: ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (app.state !== 'running') {
|
||||||
|
console.error(`App is not running: ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = `http://localhost:${app.port}`
|
||||||
|
console.log(`Opening ${url}`)
|
||||||
|
Bun.spawn(['open', url])
|
||||||
|
}
|
||||||
|
|
||||||
|
program
|
||||||
|
.name('toes')
|
||||||
|
.version('0.0.1', '-v, --version')
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('info')
|
||||||
|
.description('Show info for an app')
|
||||||
|
.argument('<name>', 'app name')
|
||||||
|
.action(infoApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('list')
|
||||||
|
.description('List all apps')
|
||||||
|
.action(listApps)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('start')
|
||||||
|
.description('Start an app')
|
||||||
|
.argument('<name>', 'app name')
|
||||||
|
.action(startApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('stop')
|
||||||
|
.description('Stop an app')
|
||||||
|
.argument('<name>', 'app name')
|
||||||
|
.action(stopApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('restart')
|
||||||
|
.description('Restart an app')
|
||||||
|
.argument('<name>', 'app name')
|
||||||
|
.action(restartApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('logs')
|
||||||
|
.description('Show logs for an app')
|
||||||
|
.argument('<name>', 'app name')
|
||||||
|
.option('-f, --follow', 'follow log output')
|
||||||
|
.action(logApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('log', { hidden: true })
|
||||||
|
.argument('<name>', 'app name')
|
||||||
|
.option('-f, --follow', 'follow log output')
|
||||||
|
.action(logApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('open')
|
||||||
|
.description('Open an app in browser')
|
||||||
|
.argument('<name>', 'app name')
|
||||||
|
.action(openApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('new')
|
||||||
|
.description('Create a new app')
|
||||||
|
.argument('<name>', 'app name')
|
||||||
|
.action(name => {
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('push')
|
||||||
|
.description('Push app to server')
|
||||||
|
.option('-f, --force', 'force overwrite')
|
||||||
|
.action(options => {
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
program.parse()
|
||||||
|
|
@ -1,52 +1,99 @@
|
||||||
|
import type { App as SharedApp, AppState, LogLine } from '@types'
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import {
|
import { existsSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'fs'
|
||||||
existsSync, readdirSync, readFileSync, writeFileSync,
|
|
||||||
statSync, watch
|
|
||||||
} from 'fs'
|
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import type { App as SharedApp, AppState, LogLine } from '../shared/types'
|
|
||||||
|
|
||||||
export type { AppState } from '../shared/types'
|
export type { AppState } from '@types'
|
||||||
|
|
||||||
|
export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
|
||||||
|
|
||||||
const DEFAULT_EMOJI = '🖥️'
|
const DEFAULT_EMOJI = '🖥️'
|
||||||
const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
|
|
||||||
const MAX_LOGS = 100
|
const MAX_LOGS = 100
|
||||||
|
const _apps = new Map<string, App>()
|
||||||
|
const _listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
let NEXT_PORT = 3001
|
||||||
|
|
||||||
export type App = SharedApp & {
|
export type App = SharedApp & {
|
||||||
proc?: Subprocess
|
proc?: Subprocess
|
||||||
}
|
}
|
||||||
|
|
||||||
const _apps = new Map<string, App>()
|
type LoadResult = { pkg: any; error?: string }
|
||||||
|
|
||||||
// Change notification system
|
export const allApps = (): App[] =>
|
||||||
const _listeners = new Set<() => void>()
|
Array.from(_apps.values())
|
||||||
export const onChange = (cb: () => void) => {
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
export const getApp = (dir: string): App | undefined =>
|
||||||
|
_apps.get(dir)
|
||||||
|
|
||||||
|
export const runApps = () =>
|
||||||
|
allAppDirs().filter(isApp).forEach(startApp)
|
||||||
|
|
||||||
|
export const runningApps = (): App[] =>
|
||||||
|
allApps().filter(a => a.state === 'running')
|
||||||
|
|
||||||
|
export function initApps() {
|
||||||
|
discoverApps()
|
||||||
|
runApps()
|
||||||
|
watchAppsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onChange(cb: () => void) {
|
||||||
_listeners.add(cb)
|
_listeners.add(cb)
|
||||||
return () => _listeners.delete(cb)
|
return () => _listeners.delete(cb)
|
||||||
}
|
}
|
||||||
const update = () => _listeners.forEach(cb => cb())
|
|
||||||
|
export function startApp(dir: string) {
|
||||||
|
const app = _apps.get(dir)
|
||||||
|
if (!app || app.state !== 'stopped') return
|
||||||
|
if (!isApp(dir)) return
|
||||||
|
runApp(dir, getPort())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopApp(dir: string) {
|
||||||
|
const app = _apps.get(dir)
|
||||||
|
if (!app || app.state !== 'running') return
|
||||||
|
|
||||||
|
info(dir, 'Stopping...')
|
||||||
|
app.state = 'stopping'
|
||||||
|
update()
|
||||||
|
app.proc?.kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAppIcon(dir: string, icon: string) {
|
||||||
|
const { pkg, error } = loadApp(dir)
|
||||||
|
if (error) throw new Error(error)
|
||||||
|
|
||||||
|
pkg.toes ??= {}
|
||||||
|
pkg.toes.icon = icon
|
||||||
|
saveApp(dir, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
const err = (app: string, ...msg: string[]) =>
|
const err = (app: string, ...msg: string[]) =>
|
||||||
console.error('🐾', `${app}:`, ...msg)
|
console.error('🐾', `${app}:`, ...msg)
|
||||||
|
|
||||||
|
const getPort = () => NEXT_PORT++
|
||||||
|
|
||||||
const info = (app: string, ...msg: string[]) =>
|
const info = (app: string, ...msg: string[]) =>
|
||||||
console.log('🐾', `${app}:`, ...msg)
|
console.log('🐾', `${app}:`, ...msg)
|
||||||
|
|
||||||
|
const isApp = (dir: string): boolean =>
|
||||||
|
!loadApp(dir).error
|
||||||
|
|
||||||
const log = (app: string, ...msg: string[]) =>
|
const log = (app: string, ...msg: string[]) =>
|
||||||
console.log(`<${app}>`, ...msg)
|
console.log(`<${app}>`, ...msg)
|
||||||
|
|
||||||
/** Returns all directory names in APPS_DIR */
|
const update = () => _listeners.forEach(cb => cb())
|
||||||
const allAppDirs = () => {
|
|
||||||
|
function allAppDirs() {
|
||||||
return readdirSync(APPS_DIR, { withFileTypes: true })
|
return readdirSync(APPS_DIR, { withFileTypes: true })
|
||||||
.filter(e => e.isDirectory())
|
.filter(e => e.isDirectory())
|
||||||
.map(e => e.name)
|
.map(e => e.name)
|
||||||
.sort()
|
.sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
let NEXT_PORT = 3001
|
function discoverApps() {
|
||||||
const getPort = () => NEXT_PORT++
|
|
||||||
|
|
||||||
const discoverApps = () => {
|
|
||||||
for (const dir of allAppDirs()) {
|
for (const dir of allAppDirs()) {
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
const state: AppState = error ? 'invalid' : 'stopped'
|
const state: AppState = error ? 'invalid' : 'stopped'
|
||||||
|
|
@ -55,12 +102,15 @@ const discoverApps = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runApps = () =>
|
function isDir(path: string): boolean {
|
||||||
allAppDirs().filter(isApp).forEach(startApp)
|
try {
|
||||||
|
return statSync(path).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type LoadResult = { pkg: any; error?: string }
|
function loadApp(dir: string): LoadResult {
|
||||||
|
|
||||||
const loadApp = (dir: string): LoadResult => {
|
|
||||||
try {
|
try {
|
||||||
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
|
const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8')
|
||||||
|
|
||||||
|
|
@ -86,24 +136,7 @@ const loadApp = (dir: string): LoadResult => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveApp = (dir: string, pkg: any) => {
|
async function runApp(dir: string, port: number) {
|
||||||
const path = join(APPS_DIR, dir, 'package.json')
|
|
||||||
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateAppIcon = (dir: string, icon: string) => {
|
|
||||||
const { pkg, error } = loadApp(dir)
|
|
||||||
if (error) throw new Error(error)
|
|
||||||
|
|
||||||
pkg.toes ??= {}
|
|
||||||
pkg.toes.icon = icon
|
|
||||||
saveApp(dir, pkg)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isApp = (dir: string): boolean =>
|
|
||||||
!loadApp(dir).error
|
|
||||||
|
|
||||||
const runApp = async (dir: string, port: number) => {
|
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
if (error) return
|
if (error) return
|
||||||
|
|
||||||
|
|
@ -175,35 +208,12 @@ const runApp = async (dir: string, port: number) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns all apps */
|
function saveApp(dir: string, pkg: any) {
|
||||||
export const allApps = (): App[] =>
|
const path = join(APPS_DIR, dir, 'package.json')
|
||||||
Array.from(_apps.values())
|
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
|
|
||||||
/** Returns only running apps (for backwards compatibility) */
|
|
||||||
export const runningApps = (): App[] =>
|
|
||||||
allApps().filter(a => a.state === 'running')
|
|
||||||
|
|
||||||
export const getApp = (dir: string): App | undefined => _apps.get(dir)
|
|
||||||
|
|
||||||
export const startApp = (dir: string) => {
|
|
||||||
const app = _apps.get(dir)
|
|
||||||
if (!app || app.state !== 'stopped') return
|
|
||||||
if (!isApp(dir)) return
|
|
||||||
runApp(dir, getPort())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stopApp = (dir: string) => {
|
function watchAppsDir() {
|
||||||
const app = _apps.get(dir)
|
|
||||||
if (!app || app.state !== 'running') return
|
|
||||||
|
|
||||||
info(dir, 'Stopping...')
|
|
||||||
app.state = 'stopping'
|
|
||||||
update()
|
|
||||||
app.proc?.kill()
|
|
||||||
}
|
|
||||||
|
|
||||||
const watchAppsDir = () => {
|
|
||||||
watch(APPS_DIR, { recursive: true }, (event, filename) => {
|
watch(APPS_DIR, { recursive: true }, (event, filename) => {
|
||||||
if (!filename) return
|
if (!filename) return
|
||||||
|
|
||||||
|
|
@ -265,17 +275,3 @@ const watchAppsDir = () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDir(path: string): boolean {
|
|
||||||
try {
|
|
||||||
return statSync(path).isDirectory()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const initApps = () => {
|
|
||||||
discoverApps()
|
|
||||||
runApps()
|
|
||||||
watchAppsDir()
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
|
import { allApps, initApps, onChange, startApp, stopApp, updateAppIcon } from '$apps'
|
||||||
|
import type { App as SharedApp } from '@types'
|
||||||
|
import type { App as BackendApp } from '$apps'
|
||||||
import { Hype } from 'hype'
|
import { Hype } from 'hype'
|
||||||
import { allApps, initApps, onChange, startApp, stopApp, updateAppIcon } from './apps'
|
|
||||||
import type { App as SharedApp } from '../shared/types'
|
// BackendApp -> SharedApp
|
||||||
|
function convert(app: BackendApp): SharedApp {
|
||||||
|
const clone = { ...app }
|
||||||
|
delete clone.proc
|
||||||
|
delete clone.logs
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hype({ layout: false })
|
const app = new Hype({ layout: false })
|
||||||
|
|
||||||
console.log('🐾 Toes!')
|
|
||||||
initApps()
|
|
||||||
|
|
||||||
// SSE endpoint for real-time app state updates
|
// SSE endpoint for real-time app state updates
|
||||||
app.get('/api/apps/stream', c => {
|
app.get('/api/apps/stream', c => {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
|
|
@ -50,14 +56,53 @@ app.get('/api/apps/stream', c => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/apps', c => {
|
app.get('/api/apps', c =>
|
||||||
const apps = allApps().map(app => {
|
c.json(allApps().map(convert))
|
||||||
const clone = { ...app }
|
)
|
||||||
delete clone.proc
|
|
||||||
delete clone.logs
|
app.get('/api/apps/:app', c => {
|
||||||
return clone
|
const appName = c.req.param('app')
|
||||||
})
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
||||||
return c.json(apps)
|
|
||||||
|
const app = allApps().find(a => a.name === appName)
|
||||||
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
||||||
|
|
||||||
|
return c.json(convert(app))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/apps/:app/logs', c => {
|
||||||
|
const appName = c.req.param('app')
|
||||||
|
if (!appName) return c.json({ error: 'App not found' }, 404)
|
||||||
|
|
||||||
|
const app = allApps().find(a => a.name === appName)
|
||||||
|
if (!app) return c.json({ error: 'App not found' }, 404)
|
||||||
|
|
||||||
|
return c.json(app.logs ?? [])
|
||||||
|
})
|
||||||
|
|
||||||
|
app.sse('/api/apps/:app/logs/stream', (send, c) => {
|
||||||
|
const appName = c.req.param('app')
|
||||||
|
const targetApp = allApps().find(a => a.name === appName)
|
||||||
|
if (!targetApp) return
|
||||||
|
|
||||||
|
let lastLogCount = 0
|
||||||
|
|
||||||
|
const sendNewLogs = () => {
|
||||||
|
const currentApp = allApps().find(a => a.name === appName)
|
||||||
|
if (!currentApp) return
|
||||||
|
|
||||||
|
const logs = currentApp.logs ?? []
|
||||||
|
const newLogs = logs.slice(lastLogCount)
|
||||||
|
lastLogCount = logs.length
|
||||||
|
|
||||||
|
for (const line of newLogs) {
|
||||||
|
send(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendNewLogs()
|
||||||
|
const unsub = onChange(sendNewLogs)
|
||||||
|
return () => unsub()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/apps/:app/start', c => {
|
app.post('/api/apps/:app/start', c => {
|
||||||
|
|
@ -98,4 +143,7 @@ app.post('/api/apps/:app/icon', c => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('🐾 Toes!')
|
||||||
|
initApps()
|
||||||
|
|
||||||
export default app.defaults
|
export default app.defaults
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,39 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext", "DOM"],
|
"lib": [
|
||||||
|
"ESNext",
|
||||||
|
"DOM"
|
||||||
|
],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "hono/jsx",
|
"jsxImportSource": "hono/jsx",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|
||||||
// Bundler mode
|
// Bundler mode
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
// Best practices
|
// Best practices
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$*": [
|
||||||
|
"./src/server/*"
|
||||||
|
],
|
||||||
|
"@*": [
|
||||||
|
"./src/shared/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user