Compare commits
2 Commits
4d176b18b8
...
048b0af34e
| Author | SHA1 | Date | |
|---|---|---|---|
| 048b0af34e | |||
| 04de9541b7 |
|
|
@ -8,10 +8,9 @@ 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.
|
||||||
|
|
|
||||||
6
bun.lock
6
bun.lock
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 0,
|
"configVersion": 1,
|
||||||
"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#9b6e1e91ec77d7e03589cac256d97fb9cd942184", { "peerDependencies": { "typescript": "^5" } }, "9b6e1e91ec77d7e03589cac256d97fb9cd942184"],
|
"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=="],
|
||||||
|
|
||||||
"hype": ["hype@git+https://git.nose.space/defunkt/hype#7b9cade936c4897539d2ca14299d90f80deb6ebe", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "7b9cade936c4897539d2ca14299d90f80deb6ebe"],
|
"hype": ["hype@git+https://git.nose.space/defunkt/hype#b9b4e205c9b04cb3897054db2940ff67ce7cfd5b", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "b9b4e205c9b04cb3897054db2940ff67ce7cfd5b"],
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { define, Styles } from 'forge'
|
import { define, Styles } from 'forge'
|
||||||
import { runningApps } from '../server/apps'
|
import { allApps } from '../server/apps'
|
||||||
|
import type { AppState } from '../shared/types'
|
||||||
|
|
||||||
const Apps = define('Apps', {
|
const Apps = define('Apps', {
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
|
|
@ -9,6 +10,7 @@ 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,
|
||||||
|
|
@ -22,19 +24,118 @@ const Link = define({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const Timestamp = define({
|
const AppCard = define('AppCard', {
|
||||||
fontSize: 18
|
marginBottom: 24,
|
||||||
|
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>🐾 Running Apps</h1>
|
<h1>🐾 Apps</h1>
|
||||||
{runningApps().map(app => (
|
{allApps().map(app => (
|
||||||
<h2>
|
<AppCard>
|
||||||
<Link href={`http://localhost:${app.port}`}>{app.port}: {app.name}</Link>
|
<AppHeader>
|
||||||
<Timestamp>Started: {new Date(app.started).toLocaleString()}</Timestamp>
|
<AppName>
|
||||||
</h2>
|
{app.state === 'running' && app.port ? (
|
||||||
|
<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>
|
||||||
)
|
)
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
type RunningApp = {
|
export type App = SharedApp & {
|
||||||
name: string
|
proc?: Subprocess
|
||||||
port: number
|
|
||||||
started: number
|
|
||||||
proc: Subprocess
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const _runningApps = new Map<string, RunningApp>()
|
const _apps = new Map<string, App>()
|
||||||
|
|
||||||
const err = (app: string, ...msg: string[]) =>
|
const err = (app: string, ...msg: string[]) =>
|
||||||
console.error('🐾', `${app}:`, ...msg)
|
console.error('🐾', `${app}:`, ...msg)
|
||||||
|
|
@ -22,17 +22,29 @@ 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)
|
||||||
|
|
||||||
export const appNames = () => {
|
/** Returns all directory names in APPS_DIR */
|
||||||
|
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()
|
||||||
|
|
@ -70,6 +82,13 @@ 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'))
|
||||||
|
|
@ -86,14 +105,12 @@ const runApp = async (dir: string, port: number) => {
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
|
|
||||||
_runningApps.set(dir, {
|
// Set state to running
|
||||||
name: dir,
|
app.state = 'running'
|
||||||
port,
|
app.proc = proc
|
||||||
proc,
|
app.started = Date.now()
|
||||||
started: Date.now()
|
|
||||||
})
|
|
||||||
|
|
||||||
const streamOutput = async (stream: ReadableStream<Uint8Array> | null, isErr: boolean) => {
|
const streamOutput = async (stream: ReadableStream<Uint8Array> | null) => {
|
||||||
if (!stream) return
|
if (!stream) return
|
||||||
const reader = stream.getReader()
|
const reader = stream.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
|
|
@ -102,14 +119,13 @@ 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, false)
|
streamOutput(proc.stdout)
|
||||||
streamOutput(proc.stderr, true)
|
streamOutput(proc.stderr)
|
||||||
|
|
||||||
// Handle process exit
|
// Handle process exit
|
||||||
proc.exited.then(code => {
|
proc.exited.then(code => {
|
||||||
|
|
@ -117,43 +133,89 @@ 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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runningApps = (): RunningApp[] =>
|
/** Returns all apps */
|
||||||
Array.from(_runningApps.values())
|
export const allApps = (): App[] =>
|
||||||
.sort((a, b) => a.port - b.port)
|
Array.from(_apps.values())
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
const stopApp = (dir: string) => {
|
/** Returns only running apps (for backwards compatibility) */
|
||||||
const app = _runningApps.get(dir)
|
export const runningApps = (): App[] =>
|
||||||
if (app) {
|
allApps().filter(a => a.state === 'running')
|
||||||
info(dir, 'Stopping...')
|
|
||||||
app.proc.kill()
|
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
|
||||||
|
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]!
|
||||||
|
|
||||||
if (isApp(dir) && !_runningApps.has(dir)) {
|
// Handle new directory appearing
|
||||||
const port = getPort()
|
if (!_apps.has(dir)) {
|
||||||
runApp(dir, port)
|
const state: AppState = isApp(dir) ? 'stopped' : 'invalid'
|
||||||
|
_apps.set(dir, { name: dir, state })
|
||||||
|
if (state === 'stopped') {
|
||||||
|
runApp(dir, getPort())
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_runningApps.has(dir) && !isApp(dir))
|
const app = _apps.get(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()
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,37 @@
|
||||||
import { Hype } from 'hype'
|
import { Hype } from 'hype'
|
||||||
import { initApps } from './apps'
|
import { initApps, startApp, stopApp } 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
|
||||||
|
|
|
||||||
8
src/shared/types.ts
Normal file
8
src/shared/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export type AppState = 'invalid' | 'stopped' | 'starting' | 'running' | 'stopping'
|
||||||
|
|
||||||
|
export type App = {
|
||||||
|
name: string
|
||||||
|
state: AppState
|
||||||
|
port?: number
|
||||||
|
started?: number
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user