import type { Subprocess } from 'bun' import { existsSync, readdirSync, readFileSync, watch } from 'fs' 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') export type App = SharedApp & { proc?: Subprocess } const _apps = new Map() const err = (app: string, ...msg: string[]) => console.error('🐾', `${app}:`, ...msg) const info = (app: string, ...msg: string[]) => console.log('🐾', `${app}:`, ...msg) const log = (app: string, ...msg: string[]) => console.log(`<${app}>`, ...msg) /** Returns all directory names in APPS_DIR */ const allAppDirs = () => { return readdirSync(APPS_DIR, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) .sort() } /** Returns names of valid apps (those with scripts.toes in package.json) */ export const appNames = () => allAppDirs().filter(isApp) let NEXT_PORT = 3001 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 = () => { for (const dir of appNames()) { const port = getPort() runApp(dir, port) } } const isApp = (dir: string): boolean => Object.values(loadApp(dir)).length > 0 const loadApp = (dir: string) => { try { const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8') try { const json = JSON.parse(file) if (json.scripts?.toes) { return json } else { err(dir, 'No `bun toes` script in package.json') return {} } } catch (e) { err(dir, 'Invalid JSON in package.json:', e instanceof Error ? e.message : String(e)) return {} } } catch (e) { err(dir, 'No package.json') return {} } } const runApp = async (dir: string, port: number) => { const pkg = loadApp(dir) 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 needsInstall = !existsSync(join(cwd, 'node_modules')) if (needsInstall) info(dir, 'Installing dependencies...') const install = Bun.spawn(['bun', 'install'], { cwd, stdout: 'pipe', stderr: 'pipe' }) await install.exited info(dir, `Starting on port ${port}...`) const proc = Bun.spawn(['bun', 'run', 'toes'], { cwd, env: { ...process.env, PORT: String(port) }, stdout: 'pipe', stderr: 'pipe', }) // Set state to running app.state = 'running' app.proc = proc app.started = Date.now() 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 text = decoder.decode(value).trimEnd() if (text) { log(dir, text) } } } streamOutput(proc.stdout) streamOutput(proc.stderr) // Handle process exit proc.exited.then(code => { if (code !== 0) err(dir, `Exited with code ${code}`) else info(dir, 'Stopped') // Reset to stopped state (or invalid if no longer valid) app.state = isApp(dir) ? 'stopped' : 'invalid' app.proc = undefined app.started = undefined }) } /** Returns all apps */ export const allApps = (): App[] => Array.from(_apps.values()) .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 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 = () => { 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 state: AppState = isApp(dir) ? 'stopped' : 'invalid' _apps.set(dir, { name: dir, state }) if (state === 'stopped') { runApp(dir, getPort()) } return } const app = _apps.get(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 = () => { discoverApps() runApps() watchAppsDir() }