import type { App as SharedApp, AppState, LogLine } from '@types' import type { Subprocess } from 'bun' import { DEFAULT_EMOJI } from '@types' import { existsSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'fs' import { join } from 'path' export type { AppState } from '@types' export const APPS_DIR = join(process.env.DATA_DIR ?? '.', 'apps') const MAX_LOGS = 100 const _apps = new Map() const _listeners = new Set<() => void>() let NEXT_PORT = 3001 export type App = SharedApp & { proc?: Subprocess } type LoadResult = { pkg: any; error?: string } export const allApps = (): App[] => Array.from(_apps.values()) .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) return () => _listeners.delete(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 removeApp(dir: string) { const app = _apps.get(dir) if (!app) return if (app.state === 'running') app.proc?.kill() _apps.delete(dir) update() } export function stopApp(dir: string) { const app = _apps.get(dir) if (!app || app.state !== 'running') return info(app, '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 getPort = () => NEXT_PORT++ const info = (app: App, ...msg: string[]) => { console.log('🐾', `[${app.name}]`, ...msg) app.logs?.push({ time: Date.now(), text: msg.join(' ') }) } const isApp = (dir: string): boolean => !loadApp(dir).error const update = () => _listeners.forEach(cb => cb()) function allAppDirs() { return readdirSync(APPS_DIR, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) .sort() } function discoverApps() { for (const dir of allAppDirs()) { const { pkg, error } = loadApp(dir) const state: AppState = error ? 'invalid' : 'stopped' const icon = pkg.toes?.icon ?? DEFAULT_EMOJI _apps.set(dir, { name: dir, state, icon, error }) } } function isDir(path: string): boolean { try { return statSync(path).isDirectory() } catch { return false } } function loadApp(dir: string): LoadResult { try { const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8') try { const json = JSON.parse(file) if (json.scripts?.toes) { return { pkg: json } } else { return { pkg: json, error: 'Missing scripts.toes in package.json' } } } catch (e) { const error = `Invalid JSON in package.json: ${e instanceof Error ? e.message : String(e)}` return { pkg: {}, error } } } catch (e) { return { pkg: {}, error: 'Missing package.json' } } } async function runApp(dir: string, port: number) { const { pkg, error } = loadApp(dir) if (error) return const app = _apps.get(dir) if (!app) return // Set state to starting app.state = 'starting' app.port = port app.logs = [] update() const cwd = join(APPS_DIR, dir) const needsInstall = !existsSync(join(cwd, 'node_modules')) if (needsInstall) info(app, 'Installing dependencies...') const install = Bun.spawn(['bun', 'install'], { cwd, stdout: 'pipe', stderr: 'pipe' }) await install.exited info(app, `Starting on port ${port}...`) const proc = Bun.spawn(['bun', 'run', 'toes'], { cwd, env: { ...process.env, PORT: String(port), NO_AUTOPORT: 'true' }, stdout: 'pipe', stderr: 'pipe', }) // Set state to running app.state = 'running' app.proc = proc app.started = Date.now() update() const streamOutput = async (stream: ReadableStream | null) => { if (!stream) return const reader = stream.getReader() const decoder = new TextDecoder() while (true) { const { done, value } = await reader.read() if (done) break const chunk = decoder.decode(value) const lines = chunk.split('\n').map(l => l.trimEnd()).filter(Boolean) for (const text of lines) { info(app, text) app.logs = (app.logs ?? []).slice(-MAX_LOGS) } if (lines.length) update() } } streamOutput(proc.stdout) streamOutput(proc.stderr) // Handle process exit proc.exited.then(code => { if (code !== 0) app.logs?.push({ time: Date.now(), text: `Exited with code ${code}` }) else app.logs?.push({ time: Date.now(), text: 'Stopped' }) // 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 update() }) } function saveApp(dir: string, pkg: any) { const path = join(APPS_DIR, dir, 'package.json') writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n') } function watchAppsDir() { watch(APPS_DIR, { recursive: true }, (event, filename) => { if (!filename) return // Extract the app directory name from the path (e.g., "myapp/package.json" -> "myapp") const dir = filename.split('/')[0]! // Handle new directory appearing if (!_apps.has(dir)) { const { pkg, error } = loadApp(dir) const state: AppState = error ? 'invalid' : 'stopped' const icon = pkg.toes?.icon _apps.set(dir, { name: dir, state, icon, error }) update() if (!error) { runApp(dir, getPort()) } return } const app = _apps.get(dir)! // check if app was deleted if (!isDir(join(APPS_DIR, dir))) { _apps.delete(dir) update() return } // Only care about package.json changes for existing apps if (!filename.endsWith('package.json')) return const { pkg, error } = loadApp(dir) // Update icon and error from package.json app.icon = pkg.toes?.icon app.error = error // App became valid - start it if stopped if (!error && app.state === 'invalid') { app.state = 'stopped' runApp(dir, getPort()) } // App became invalid - stop it if running if (error && app.state === 'running') { app.state = 'invalid' app.proc?.kill() } // Update state if already stopped/invalid if (error && app.state === 'stopped') { app.state = 'invalid' update() } if (!error && app.state === 'invalid') { app.state = 'stopped' update() } }) }