Compare commits

..

No commits in common. "048b0af34e0f55a05b2417c4820949f397e6fae2" and "4d176b18b863483defaf87b3a153e797d14d9e6b" have entirely different histories.

6 changed files with 52 additions and 250 deletions

View File

@ -8,9 +8,10 @@ Set it up, turn it on, and forget about the cloud.
1. Plug in and turn on your Toes computer. 1. Plug in and turn on your Toes computer.
2. Tell Toes about your WiFi by <using dark @probablycorey magick>. 2. Tell Toes about your WiFi by <using dark @probablycorey magick>.
3. Visit https://toes.local to get started! 3. Visit https://toes.local to get started!
## features ## features
- Hosts bun/hono/hype webapps - both SSR and SPA. - Hosts bun/hono/hype webapps - both SSR and SPA.
- `toes` CLI for pushing and pulling from your server. - `toes` CLI for pushing and pulling from your server.
- `toes` CLI for local dev mode. - `toes` CLI for local dev mode.

View File

@ -1,6 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1, "configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "toes", "name": "toes",
@ -23,11 +23,11 @@
"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=="],
"forge": ["forge@git+https://git.nose.space/defunkt/forge#debfd73ab22c50f66ccc93cb41164c234f78a920", { "peerDependencies": { "typescript": "^5" } }, "debfd73ab22c50f66ccc93cb41164c234f78a920"], "forge": ["forge@git+https://git.nose.space/defunkt/forge#9b6e1e91ec77d7e03589cac256d97fb9cd942184", { "peerDependencies": { "typescript": "^5" } }, "9b6e1e91ec77d7e03589cac256d97fb9cd942184"],
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
"hype": ["hype@git+https://git.nose.space/defunkt/hype#b9b4e205c9b04cb3897054db2940ff67ce7cfd5b", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "b9b4e205c9b04cb3897054db2940ff67ce7cfd5b"], "hype": ["hype@git+https://git.nose.space/defunkt/hype#7b9cade936c4897539d2ca14299d90f80deb6ebe", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "7b9cade936c4897539d2ca14299d90f80deb6ebe"],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],

View File

@ -1,6 +1,5 @@
import { define, Styles } from 'forge' import { define, Styles } from 'forge'
import { allApps } from '../server/apps' import { runningApps } from '../server/apps'
import type { AppState } from '../shared/types'
const Apps = define('Apps', { const Apps = define('Apps', {
margin: '0 auto', margin: '0 auto',
@ -10,7 +9,6 @@ const Apps = define('Apps', {
const color = '#00c0c9' const color = '#00c0c9'
const hoverColor = 'magenta' const hoverColor = 'magenta'
const Link = define({ const Link = define({
base: 'a', base: 'a',
color, color,
@ -24,118 +22,19 @@ const Link = define({
} }
}) })
const AppCard = define('AppCard', { const Timestamp = define({
marginBottom: 24, fontSize: 18
padding: 16,
border: '1px solid #333',
borderRadius: 8,
}) })
const AppHeader = define('AppHeader', {
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 8,
})
const AppName = define('AppName', {
fontSize: 20,
fontWeight: 'bold',
margin: 0,
})
const State = define('State', {
fontSize: 14,
padding: '2px 8px',
borderRadius: 4,
variants: {
status: {
invalid: { background: '#4a1c1c', color: '#f87171' },
stopped: { background: '#3a3a3a', color: '#9ca3af' },
starting: { background: '#3b3117', color: '#fbbf24' },
running: { background: '#14532d', color: '#4ade80' },
stopping: { background: '#3b3117', color: '#fbbf24' },
}
}
})
const Info = define('Info', {
fontSize: 14,
color: '#9ca3af',
margin: '4px 0',
})
const ActionBar = define('ActionBar', {
marginTop: 12,
display: 'flex',
gap: 8,
})
const Button = define({
base: 'button',
selectors: {
'form:has(>&)': {
display: 'inline'
}
},
render({ props, parts: { Root } }) {
if (!props.post)
return <Root>{props.children}</Root>
return (
<form method='post' action={props.post}>
<Root onClick="this.closest('form').submit()">{props.children}</Root>
</form>
)
}
})
const stateLabels: Record<AppState, string> = {
invalid: 'Invalid',
stopped: 'Stopped',
starting: 'Starting...',
running: 'Running',
stopping: 'Stopping...',
}
export default () => ( export default () => (
<Apps> <Apps>
<Styles /> <Styles />
<h1>🐾 Apps</h1> <h1>🐾 Running Apps</h1>
{allApps().map(app => ( {runningApps().map(app => (
<AppCard> <h2>
<AppHeader> <Link href={`http://localhost:${app.port}`}>{app.port}: {app.name}</Link>
<AppName> <Timestamp>Started: {new Date(app.started).toLocaleString()}</Timestamp>
{app.state === 'running' && app.port ? ( </h2>
<Link href={`http://localhost:${app.port}`}>{app.name}</Link>
) : (
app.name
)}
</AppName>
<State status={app.state}>{stateLabels[app.state]}</State>
</AppHeader>
{app.port ? <Info>Port: {app.port}</Info> : null}
{app.started && <Info>Started: {new Date(app.started).toLocaleString()}</Info>}
<ActionBar>
{app.state === 'stopped' && (
<Button post={`/apps/${app.name}/start`}>Start</Button>
)}
{app.state === 'running' && (
<>
<Button post={`/apps/${app.name}/stop`}>Stop</Button>
<Button post={`/apps/${app.name}/restart`}>Restart</Button>
</>
)}
{app.state === 'invalid' && (
<Info>Missing or invalid package.json</Info>
)}
</ActionBar>
</AppCard>
))} ))}
</Apps> </Apps>
) )

View File

@ -1,17 +1,17 @@
import type { Subprocess } from 'bun' import type { Subprocess } from 'bun'
import { existsSync, readdirSync, readFileSync, watch } from 'fs' import { existsSync, readdirSync, readFileSync, watch } from 'fs'
import { join } from 'path' import { join } from 'path'
import type { App as SharedApp, AppState } from '../shared/types'
export type { AppState } from '../shared/types'
const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps') const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps')
export type App = SharedApp & { type RunningApp = {
proc?: Subprocess name: string
port: number
started: number
proc: Subprocess
} }
const _apps = new Map<string, App>() const _runningApps = new Map<string, RunningApp>()
const err = (app: string, ...msg: string[]) => const err = (app: string, ...msg: string[]) =>
console.error('🐾', `${app}:`, ...msg) console.error('🐾', `${app}:`, ...msg)
@ -22,29 +22,17 @@ const info = (app: string, ...msg: string[]) =>
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 */ export const appNames = () => {
const 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)
.filter(isApp)
.sort() .sort()
} }
/** Returns names of valid apps (those with scripts.toes in package.json) */
export const appNames = () => allAppDirs().filter(isApp)
let NEXT_PORT = 3001 let NEXT_PORT = 3001
const getPort = () => NEXT_PORT++ const getPort = () => NEXT_PORT++
/** Discover all apps and set initial states */
const discoverApps = () => {
for (const dir of allAppDirs()) {
const state: AppState = isApp(dir) ? 'stopped' : 'invalid'
_apps.set(dir, { name: dir, state })
}
}
/** Start all valid apps */
export const runApps = () => { export const runApps = () => {
for (const dir of appNames()) { for (const dir of appNames()) {
const port = getPort() const port = getPort()
@ -82,13 +70,6 @@ const runApp = async (dir: string, port: number) => {
const pkg = loadApp(dir) const pkg = loadApp(dir)
if (!pkg.scripts?.toes) return if (!pkg.scripts?.toes) return
const app = _apps.get(dir)
if (!app) return
// Set state to starting
app.state = 'starting'
app.port = port
const cwd = join(APPS_DIR, dir) const cwd = join(APPS_DIR, dir)
const needsInstall = !existsSync(join(cwd, 'node_modules')) const needsInstall = !existsSync(join(cwd, 'node_modules'))
@ -105,12 +86,14 @@ const runApp = async (dir: string, port: number) => {
stderr: 'pipe', stderr: 'pipe',
}) })
// Set state to running _runningApps.set(dir, {
app.state = 'running' name: dir,
app.proc = proc port,
app.started = Date.now() proc,
started: Date.now()
})
const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => { const streamOutput = async (stream: ReadableStream<Uint8Array> | null, isErr: boolean) => {
if (!stream) return if (!stream) return
const reader = stream.getReader() const reader = stream.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
@ -119,13 +102,14 @@ const runApp = async (dir: string, port: number) => {
if (done) break if (done) break
const text = decoder.decode(value).trimEnd() const text = decoder.decode(value).trimEnd()
if (text) { if (text) {
//isErr ? err(dir, text) : info(dir, text)
log(dir, text) log(dir, text)
} }
} }
} }
streamOutput(proc.stdout) streamOutput(proc.stdout, false)
streamOutput(proc.stderr) streamOutput(proc.stderr, true)
// Handle process exit // Handle process exit
proc.exited.then(code => { proc.exited.then(code => {
@ -133,89 +117,43 @@ const runApp = async (dir: string, port: number) => {
err(dir, `Exited with code ${code}`) err(dir, `Exited with code ${code}`)
else else
info(dir, 'Stopped') info(dir, 'Stopped')
_runningApps.delete(dir)
// Reset to stopped state (or invalid if no longer valid)
app.state = isApp(dir) ? 'stopped' : 'invalid'
app.proc = undefined
app.port = undefined
app.started = undefined
}) })
} }
/** Returns all apps */ export const runningApps = (): RunningApp[] =>
export const allApps = (): App[] => Array.from(_runningApps.values())
Array.from(_apps.values()) .sort((a, b) => a.port - b.port)
.sort((a, b) => a.name.localeCompare(b.name))
/** Returns only running apps (for backwards compatibility) */ const stopApp = (dir: string) => {
export const runningApps = (): App[] => const app = _runningApps.get(dir)
allApps().filter(a => a.state === 'running') if (app) {
info(dir, 'Stopping...')
export const getApp = (dir: string): App | undefined => _apps.get(dir) app.proc.kill()
}
export const startApp = (dir: string) => {
const app = _apps.get(dir)
if (!app || app.state !== 'stopped') return
runApp(dir, getPort())
}
export const stopApp = (dir: string) => {
const app = _apps.get(dir)
if (!app || app.state !== 'running') return
info(dir, 'Stopping...')
app.state = 'stopping'
app.proc?.kill()
} }
const watchAppsDir = () => { const watchAppsDir = () => {
watch(APPS_DIR, { recursive: true }, (_event, filename) => { watch(APPS_DIR, { recursive: true }, (event, filename) => {
if (!filename) return if (!filename) return
// Only care about package.json changes
if (!filename.endsWith('package.json')) return
// Extract the app directory name from the path (e.g., "myapp/package.json" -> "myapp") // Extract the app directory name from the path (e.g., "myapp/package.json" -> "myapp")
const dir = filename.split('/')[0]! const dir = filename.split('/')[0]!
// Handle new directory appearing if (isApp(dir) && !_runningApps.has(dir)) {
if (!_apps.has(dir)) { const port = getPort()
const state: AppState = isApp(dir) ? 'stopped' : 'invalid' runApp(dir, port)
_apps.set(dir, { name: dir, state })
if (state === 'stopped') {
runApp(dir, getPort())
}
return
} }
const app = _apps.get(dir)! if (_runningApps.has(dir) && !isApp(dir))
stopApp(dir)
// Only care about package.json changes for existing apps
if (!filename.endsWith('package.json')) return
const valid = isApp(dir)
// App became valid - start it if stopped
if (valid && app.state === 'invalid') {
app.state = 'stopped'
runApp(dir, getPort())
}
// App became invalid - stop it if running
if (!valid && app.state === 'running') {
app.state = 'invalid'
app.proc?.kill()
}
// Update state if already stopped/invalid
if (!valid && app.state === 'stopped') {
app.state = 'invalid'
}
if (valid && app.state === 'invalid') {
app.state = 'stopped'
}
}) })
} }
export const initApps = () => { export const initApps = () => {
discoverApps()
runApps() runApps()
watchAppsDir() watchAppsDir()
} }

View File

@ -1,37 +1,9 @@
import { Hype } from 'hype' import { Hype } from 'hype'
import { initApps, startApp, stopApp } from './apps' import { initApps } from './apps'
const app = new Hype() const app = new Hype()
console.log('🐾 Toes!') console.log('🐾 Toes!')
initApps() initApps()
app.post('/apps/:app/start', c => {
const app = c.req.param('app')
if (!app) return render404(c)
startApp(app)
return c.redirect('/')
})
app.post('/apps/:app/restart', c => {
const app = c.req.param('app')
if (!app) return render404(c)
stopApp(app)
startApp(app)
return c.redirect('/')
})
app.post('/apps/:app/stop', c => {
const app = c.req.param('app')
if (!app) return render404(c)
stopApp(app)
return c.redirect('/')
})
const render404 = (c: any) =>
c.text('404 Not Found', { status: 404 })
export default app.defaults export default app.defaults

View File

@ -1,8 +0,0 @@
export type AppState = 'invalid' | 'stopped' | 'starting' | 'running' | 'stopping'
export type App = {
name: string
state: AppState
port?: number
started?: number
}