import { APPS_DIR, allApps, registerApp, removeApp, restartApp, startApp } from '$apps' import { computeHash, generateManifest } from '../sync' import { loadGitignore } from '@gitignore' import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, watch, writeFileSync } from 'fs' import { dirname, join } from 'path' import { Hype } from '@because/hype' const MAX_VERSIONS = 5 interface FileChangeEvent { hash?: string path: string type: 'change' | 'delete' } function safePath(base: string, ...parts: string[]): string | null { // Resolve base to canonical path (follows symlinks) if it exists const canonicalBase = existsSync(base) ? realpathSync(base) : base const resolved = join(canonicalBase, ...parts) if (!resolved.startsWith(canonicalBase + '/') && resolved !== canonicalBase) { return null } return resolved } const router = Hype.router() router.get('/apps', c => c.json(allApps().map(a => a.name))) router.get('/apps/:app/versions', c => { const appName = c.req.param('app') if (!appName) return c.json({ error: 'App not found' }, 404) const appDir = safePath(APPS_DIR, appName) if (!appDir) return c.json({ error: 'Invalid path' }, 400) if (!existsSync(appDir)) return c.json({ error: 'App not found' }, 404) const currentLink = join(appDir, 'current') const currentVersion = existsSync(currentLink) ? realpathSync(currentLink).split('/').pop() : null const entries = readdirSync(appDir, { withFileTypes: true }) const versions = entries .filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name)) .map(e => e.name) .sort() .reverse() // Newest first return c.json({ versions, current: currentVersion }) }) router.get('/apps/:app/history', c => { const appName = c.req.param('app') if (!appName) return c.json({ error: 'App not found' }, 404) const appDir = safePath(APPS_DIR, appName) if (!appDir) return c.json({ error: 'Invalid path' }, 400) if (!existsSync(appDir)) return c.json({ error: 'App not found' }, 404) const currentLink = join(appDir, 'current') const currentVersion = existsSync(currentLink) ? realpathSync(currentLink).split('/').pop() : null const entries = readdirSync(appDir, { withFileTypes: true }) const versions = entries .filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name)) .map(e => e.name) .sort() .reverse() // Newest first // Generate manifests for each version const manifests = new Map>() for (const version of versions) { const versionPath = join(appDir, version) const manifest = generateManifest(versionPath, appName) manifests.set(version, manifest.files) } // Diff consecutive pairs const history = versions.map((version, i) => { const current = version === currentVersion const files = manifests.get(version)! const olderVersion = versions[i + 1] const olderFiles = olderVersion ? manifests.get(olderVersion)! : {} const added: string[] = [] const modified: string[] = [] const deleted: string[] = [] // Files in this version for (const [path, info] of Object.entries(files)) { const older = olderFiles[path] if (!older) { added.push(path) } else if (older.hash !== info.hash) { modified.push(path) } } // Files removed in this version for (const path of Object.keys(olderFiles)) { if (!files[path]) { deleted.push(path) } } // Detect renames: added + deleted files with matching hashes const renamed: string[] = [] const deletedByHash = new Map() for (const path of deleted) { deletedByHash.set(olderFiles[path]!.hash, path) } const matchedAdded = new Set() const matchedDeleted = new Set() for (const path of added) { const oldPath = deletedByHash.get(files[path]!.hash) if (oldPath && !matchedDeleted.has(oldPath)) { renamed.push(`${oldPath} → ${path}`) matchedAdded.add(path) matchedDeleted.add(oldPath) } } return { version, current, added: added.filter(f => !matchedAdded.has(f)).sort(), modified: modified.sort(), deleted: deleted.filter(f => !matchedDeleted.has(f)).sort(), renamed: renamed.sort(), } }) return c.json({ history }) }) router.get('/apps/:app/manifest', c => { const appName = c.req.param('app') if (!appName) return c.json({ error: 'App not found' }, 404) const appPath = join(APPS_DIR, appName, 'current') const safeAppPath = safePath(APPS_DIR, appName) if (!safeAppPath) return c.json({ error: 'Invalid path' }, 400) if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404) const version = realpathSync(appPath).split('/').pop() const manifest = generateManifest(appPath, appName) return c.json({ ...manifest, version }) }) router.get('/apps/:app/files/:path{.+}', c => { const appName = c.req.param('app') const filePath = c.req.param('path') if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) const basePath = join(APPS_DIR, appName, 'current') const fullPath = safePath(basePath, filePath) if (!fullPath) return c.json({ error: 'Invalid path' }, 400) if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404) const content = readFileSync(fullPath) return new Response(content, { headers: { 'Content-Type': 'application/octet-stream' }, }) }) router.put('/apps/:app/files/:path{.+}', async c => { const appName = c.req.param('app') const filePath = c.req.param('path') const version = c.req.query('version') // Optional version parameter if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) // Determine base path: specific version or current const basePath = version ? join(APPS_DIR, appName, version) : join(APPS_DIR, appName, 'current') const fullPath = safePath(basePath, filePath) if (!fullPath) return c.json({ error: 'Invalid path' }, 400) const dir = dirname(fullPath) // Ensure directory exists if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } const body = await c.req.arrayBuffer() writeFileSync(fullPath, new Uint8Array(body)) return c.json({ ok: true }) }) router.delete('/apps/:app', c => { const appName = c.req.param('app') if (!appName) return c.json({ error: 'App not found' }, 404) const appPath = safePath(APPS_DIR, appName) if (!appPath) return c.json({ error: 'Invalid path' }, 400) if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404) removeApp(appName) rmSync(appPath, { recursive: true }) return c.json({ ok: true }) }) router.delete('/apps/:app/files/:path{.+}', c => { const appName = c.req.param('app') const filePath = c.req.param('path') const version = c.req.query('version') if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) const basePath = version ? join(APPS_DIR, appName, version) : join(APPS_DIR, appName, 'current') const fullPath = safePath(basePath, filePath) if (!fullPath) return c.json({ error: 'Invalid path' }, 400) if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404) unlinkSync(fullPath) return c.json({ ok: true }) }) router.post('/apps/:app/deploy', c => { const appName = c.req.param('app') if (!appName) return c.json({ error: 'App name required' }, 400) const appDir = join(APPS_DIR, appName) // Generate timestamp: YYYYMMDD-HHMMSS format const now = new Date() const pad = (n: number) => String(n).padStart(2, '0') const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` const newVersion = join(appDir, timestamp) const currentLink = join(appDir, 'current') try { // 1. If current exists, copy it to new timestamp const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null if (currentReal) { cpSync(currentReal, newVersion, { recursive: true, filter: (src) => !src.split('/').includes('node_modules'), }) } else { // First deployment - create directory mkdirSync(newVersion, { recursive: true }) } return c.json({ ok: true, version: timestamp }) } catch (e) { return c.json({ error: `Failed to create deployment: ${e instanceof Error ? e.message : String(e)}` }, 500) } }) router.post('/apps/:app/activate', async c => { const appName = c.req.param('app') const version = c.req.query('version') if (!appName) return c.json({ error: 'App name required' }, 400) if (!version) return c.json({ error: 'Version required' }, 400) const appDir = join(APPS_DIR, appName) const versionDir = join(appDir, version) const currentLink = join(appDir, 'current') if (!existsSync(versionDir)) { return c.json({ error: 'Version not found' }, 404) } try { // Atomic symlink update const tempLink = join(appDir, '.current.tmp') // Remove temp link if it exists from previous failed attempt if (existsSync(tempLink)) { unlinkSync(tempLink) } // Create new symlink pointing to version directory (relative) symlinkSync(version, tempLink, 'dir') // Atomic rename over old symlink/directory renameSync(tempLink, currentLink) // Clean up old versions try { const entries = readdirSync(appDir, { withFileTypes: true }) // Get all timestamp directories (exclude current, .current.tmp, etc) const versionDirs = entries .filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name)) .map(e => e.name) .sort() .reverse() // Newest first // Keep most recent, delete the rest const toDelete = versionDirs.slice(MAX_VERSIONS) for (const dir of toDelete) { const dirPath = join(appDir, dir) rmSync(dirPath, { recursive: true, force: true }) console.log(`Cleaned up old version: ${dir}`) } // Delete node_modules from old kept versions (not the active one) const toKeep = versionDirs.slice(0, MAX_VERSIONS) for (const dir of toKeep) { if (dir === version) continue const nm = join(appDir, dir, 'node_modules') if (existsSync(nm)) { rmSync(nm, { recursive: true, force: true }) console.log(`Removed node_modules from old version: ${dir}`) } } } catch (e) { // Log but don't fail activation if cleanup fails console.error(`Failed to clean up old versions: ${e}`) } // Register new app or restart existing const app = allApps().find(a => a.name === appName) if (!app) { // New app - register it registerApp(appName) } else if (app.state === 'running') { // Existing app - restart it try { await restartApp(appName) } catch (e) { return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500) } } else if (app.state === 'stopped' || app.state === 'invalid') { // App not running (possibly due to error) - try to start it startApp(appName) } return c.json({ ok: true }) } catch (e) { return c.json({ error: `Failed to activate version: ${e instanceof Error ? e.message : String(e)}` }, 500) } }) router.sse('/apps/:app/watch', (send, c) => { const appName = c.req.param('app') const appPath = join(APPS_DIR, appName, 'current') const safeAppPath = safePath(APPS_DIR, appName) if (!safeAppPath || !existsSync(appPath)) return // Resolve to canonical path for consistent watch events const canonicalPath = realpathSync(appPath) const gitignore = loadGitignore(canonicalPath) let debounceTimer: Timer | null = null const pendingChanges = new Map() const watcher = watch(canonicalPath, { recursive: true }, (_event, filename) => { if (!filename || gitignore.shouldExclude(filename)) return const fullPath = join(canonicalPath, filename) const type = existsSync(fullPath) ? 'change' : 'delete' pendingChanges.set(filename, type) if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { for (const [path, changeType] of pendingChanges) { const evt: FileChangeEvent = { type: changeType, path } if (changeType === 'change') { try { const content = readFileSync(join(canonicalPath, path)) evt.hash = computeHash(content) } catch { continue // File was deleted between check and read } } send(evt) } pendingChanges.clear() }, 100) }) return () => { if (debounceTimer) clearTimeout(debounceTimer) watcher.close() } }) export default router