403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
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<string, Record<string, { hash: string }>>()
|
|
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<string, string>()
|
|
for (const path of deleted) {
|
|
deletedByHash.set(olderFiles[path]!.hash, path)
|
|
}
|
|
|
|
const matchedAdded = new Set<string>()
|
|
const matchedDeleted = new Set<string>()
|
|
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<string, 'change' | 'delete'>()
|
|
|
|
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
|