From ebf3ffc3af01232aca5e50de0dee4a76a8395e42 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath <2+defunkt@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:16:59 -0800 Subject: [PATCH] spicy --- .gitignore | 1 + ISSUES.md | 194 ++++++++++++++++++ apps/basic/{ => 20260130-000000}/bun.lock | 0 apps/basic/{ => 20260130-000000}/index.tsx | 0 apps/basic/{ => 20260130-000000}/package.json | 0 .../basic/{ => 20260130-000000}/tsconfig.json | 0 apps/basic/current | 1 + apps/clock/{ => 20260130-000000}/bun.lock | 0 apps/clock/{ => 20260130-000000}/index.tsx | 0 apps/clock/{ => 20260130-000000}/package.json | 0 .../{ => 20260130-000000}/pub/digital.ttf | Bin .../clock/{ => 20260130-000000}/tsconfig.json | 0 apps/clock/current | 1 + apps/code/{ => 20260130-000000}/bun.lock | 0 apps/code/{ => 20260130-000000}/index.tsx | 0 apps/code/{ => 20260130-000000}/package.json | 0 .../{ => 20260130-000000}/src/pages/index.tsx | 0 .../src/server/index.tsx | 0 apps/code/{ => 20260130-000000}/tsconfig.json | 0 apps/code/current | 1 + apps/profile/{ => 20260130-000000}/bun.lock | 0 apps/profile/{ => 20260130-000000}/index.tsx | 0 .../{ => 20260130-000000}/package.json | 0 .../{ => 20260130-000000}/tsconfig.json | 0 apps/profile/current | 1 + apps/risk/{ => 20260130-000000}/package.json | 0 apps/risk/current | 1 + apps/truisms/{ => 20260130-000000}/README.md | 0 apps/truisms/{ => 20260130-000000}/bun.lock | 0 apps/truisms/{ => 20260130-000000}/index.ts | 0 .../{ => 20260130-000000}/package.json | 0 .../{ => 20260130-000000}/tsconfig.json | 0 apps/truisms/current | 1 + src/cli/commands/sync.ts | 53 +++-- src/cli/http.ts | 4 +- src/server/api/sync.ts | 154 ++++++++++++-- src/server/apps.ts | 50 ++++- 37 files changed, 417 insertions(+), 45 deletions(-) create mode 100644 ISSUES.md rename apps/basic/{ => 20260130-000000}/bun.lock (100%) rename apps/basic/{ => 20260130-000000}/index.tsx (100%) rename apps/basic/{ => 20260130-000000}/package.json (100%) rename apps/basic/{ => 20260130-000000}/tsconfig.json (100%) create mode 120000 apps/basic/current rename apps/clock/{ => 20260130-000000}/bun.lock (100%) rename apps/clock/{ => 20260130-000000}/index.tsx (100%) rename apps/clock/{ => 20260130-000000}/package.json (100%) rename apps/clock/{ => 20260130-000000}/pub/digital.ttf (100%) rename apps/clock/{ => 20260130-000000}/tsconfig.json (100%) create mode 120000 apps/clock/current rename apps/code/{ => 20260130-000000}/bun.lock (100%) rename apps/code/{ => 20260130-000000}/index.tsx (100%) rename apps/code/{ => 20260130-000000}/package.json (100%) rename apps/code/{ => 20260130-000000}/src/pages/index.tsx (100%) rename apps/code/{ => 20260130-000000}/src/server/index.tsx (100%) rename apps/code/{ => 20260130-000000}/tsconfig.json (100%) create mode 120000 apps/code/current rename apps/profile/{ => 20260130-000000}/bun.lock (100%) rename apps/profile/{ => 20260130-000000}/index.tsx (100%) rename apps/profile/{ => 20260130-000000}/package.json (100%) rename apps/profile/{ => 20260130-000000}/tsconfig.json (100%) create mode 120000 apps/profile/current rename apps/risk/{ => 20260130-000000}/package.json (100%) create mode 120000 apps/risk/current rename apps/truisms/{ => 20260130-000000}/README.md (100%) rename apps/truisms/{ => 20260130-000000}/bun.lock (100%) rename apps/truisms/{ => 20260130-000000}/index.ts (100%) rename apps/truisms/{ => 20260130-000000}/package.json (100%) rename apps/truisms/{ => 20260130-000000}/tsconfig.json (100%) create mode 120000 apps/truisms/current diff --git a/.gitignore b/.gitignore index a14702c..e6dfd5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # dependencies (bun install) node_modules +pub/client/index.js # output out diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..089b3fd --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,194 @@ +# Issues - Versioned Deployment Implementation + +## Critical Issues + +### 1. Watch Filter Breaks File Change Detection + +**Location**: `src/server/apps.ts:589-593` + +**Problem**: The watch logic ignores all changes deeper than 2 levels: + +```ts +// Ignore changes deeper than 2 levels (inside timestamp dirs) +if (parts.length > 2) return + +// For versioned apps, only care about changes to "current" directory +if (parts.length === 2 && parts[1] !== 'current' && parts[1] !== 'package.json') return +``` + +Files inside `current/` are 3 levels deep: `appname/current/somefile.ts` + +This **ignores all file changes** inside the current directory, breaking hot reload and auto-restart. + +**Fix**: +```ts +// Ignore changes inside old timestamp dirs (but allow current/) +if (parts.length > 2 && parts[1] !== 'current') return +``` + +--- + +### 2. App Restart Race Condition + +**Location**: `src/server/api/sync.ts:145-148` + +**Problem**: Activation uses arbitrary 1 second delay without confirming stop completed: + +```ts +// Restart app to use new version +const app = allApps().find(a => a.name === appName) +if (app?.state === 'running') { + stopApp(appName) + setTimeout(() => startApp(appName), 1000) // ⚠️ Arbitrary 1s delay +} +``` + +**Issues**: +- 1 second may not be enough for app to fully stop +- No confirmation that stop completed before start +- If app crashes during startup, activation still returns success + +**Fix**: Make activation async and wait for stop to complete, or move restart logic to after symlink succeeds and poll for stop completion. + +--- + +## Major Issues + +### 3. No Version Cleanup + +**Problem**: Old timestamp directories accumulate forever with no cleanup mechanism. + +**Impact**: Disk space grows indefinitely as deployments pile up. + +**Recommendation**: Add cleanup logic: +- Keep last N versions (e.g., 5-10) +- Delete versions older than X days +- Expose as `toes clean ` command or automatic post-activation + +--- + +### 4. safePath Behavior Change + +**Location**: `src/server/api/sync.ts:14-21` + +**Problem**: Security model changed by resolving symlinks in base path: + +```ts +const canonicalBase = existsSync(base) ? realpathSync(base) : base +``` + +Previously paths were checked against literal base, now against resolved base. This changes behavior if someone creates a symlink attack. + +**Recommendation**: Document this intentional change, or keep original check and add separate symlink resolution only where needed. + +--- + +### 5. Non-Atomic Deploy Copy + +**Location**: `src/server/api/sync.ts:133-141` + +**Problem**: Race condition possible during deploy: + +```ts +if (existsSync(currentLink)) { + const currentReal = realpathSync(currentLink) + cpSync(currentReal, newVersion, { recursive: true }) +} +``` + +If `current` changes between `existsSync` and `cpSync`, stale code might be copied. + +**Impact**: Low probability for single-user system, but possible during concurrent deploys. + +**Fix**: Read symlink once and reuse: `const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null` + +--- + +### 6. Upload Error Handling Inconsistency + +**Location**: `src/cli/commands/sync.ts:113-115` + +**Problem**: Upload continues if a file fails but doesn't track failures: + +```ts +if (success) { + console.log(` ${color.green('↑')} ${file}`) +} else { + console.log(` ${color.red('✗')} ${file}`) + // Continue even if one file fails +} +``` + +If any file fails to upload, deployment still activates with incomplete files. + +**Fix**: Either: +- Abort entire deployment on first failure +- Track failures and warn/abort before activating + +--- + +## Minor Issues + +### 7. POST Body Check Too Loose + +**Location**: `src/cli/http.ts:42-44` + +**Problem**: Falsy values like `0`, `false`, or `""` would incorrectly skip body: + +```ts +headers: body ? { 'Content-Type': 'application/json' } : undefined, +body: body ? JSON.stringify(body) : undefined, +``` + +**Fix**: +```ts +headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, +body: body !== undefined ? JSON.stringify(body) : undefined, +``` + +--- + +### 8. Unconventional Timestamp Format + +**Location**: `src/server/api/sync.ts:115-123` + +**Problem**: Unusual array construction with separator as element: + +```ts +const timestamp = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + '-', // Unusual to put separator as array element + String(now.getHours()).padStart(2, '0'), + String(now.getMinutes()).padStart(2, '0'), + String(now.getSeconds()).padStart(2, '0'), +].join('') +``` + +**Recommendation**: Use template literal: +```ts +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())}` +``` + +Or ISO format: `now.toISOString().replace(/[:.]/g, '-').slice(0, -5)` + +--- + +## Positive Points + +✓ Atomic symlink swapping with temp file pattern is correct +✓ Clear 3-step deployment protocol (deploy, upload, activate) +✓ Proper use of `realpathSync` to resolve canonical paths for running apps +✓ Good separation of concerns in API routes + +--- + +## Priority + +1. **BLOCKER**: Fix watch filter (#1) - breaks hot reload +2. **HIGH**: Fix restart race condition (#2) - affects deployment reliability +3. **HIGH**: Add version cleanup (#3) - disk space concern +4. **MEDIUM**: Fix upload error handling (#6) - data integrity +5. **LOW**: Everything else diff --git a/apps/basic/bun.lock b/apps/basic/20260130-000000/bun.lock similarity index 100% rename from apps/basic/bun.lock rename to apps/basic/20260130-000000/bun.lock diff --git a/apps/basic/index.tsx b/apps/basic/20260130-000000/index.tsx similarity index 100% rename from apps/basic/index.tsx rename to apps/basic/20260130-000000/index.tsx diff --git a/apps/basic/package.json b/apps/basic/20260130-000000/package.json similarity index 100% rename from apps/basic/package.json rename to apps/basic/20260130-000000/package.json diff --git a/apps/basic/tsconfig.json b/apps/basic/20260130-000000/tsconfig.json similarity index 100% rename from apps/basic/tsconfig.json rename to apps/basic/20260130-000000/tsconfig.json diff --git a/apps/basic/current b/apps/basic/current new file mode 120000 index 0000000..1a45961 --- /dev/null +++ b/apps/basic/current @@ -0,0 +1 @@ +20260130-000000 \ No newline at end of file diff --git a/apps/clock/bun.lock b/apps/clock/20260130-000000/bun.lock similarity index 100% rename from apps/clock/bun.lock rename to apps/clock/20260130-000000/bun.lock diff --git a/apps/clock/index.tsx b/apps/clock/20260130-000000/index.tsx similarity index 100% rename from apps/clock/index.tsx rename to apps/clock/20260130-000000/index.tsx diff --git a/apps/clock/package.json b/apps/clock/20260130-000000/package.json similarity index 100% rename from apps/clock/package.json rename to apps/clock/20260130-000000/package.json diff --git a/apps/clock/pub/digital.ttf b/apps/clock/20260130-000000/pub/digital.ttf similarity index 100% rename from apps/clock/pub/digital.ttf rename to apps/clock/20260130-000000/pub/digital.ttf diff --git a/apps/clock/tsconfig.json b/apps/clock/20260130-000000/tsconfig.json similarity index 100% rename from apps/clock/tsconfig.json rename to apps/clock/20260130-000000/tsconfig.json diff --git a/apps/clock/current b/apps/clock/current new file mode 120000 index 0000000..1a45961 --- /dev/null +++ b/apps/clock/current @@ -0,0 +1 @@ +20260130-000000 \ No newline at end of file diff --git a/apps/code/bun.lock b/apps/code/20260130-000000/bun.lock similarity index 100% rename from apps/code/bun.lock rename to apps/code/20260130-000000/bun.lock diff --git a/apps/code/index.tsx b/apps/code/20260130-000000/index.tsx similarity index 100% rename from apps/code/index.tsx rename to apps/code/20260130-000000/index.tsx diff --git a/apps/code/package.json b/apps/code/20260130-000000/package.json similarity index 100% rename from apps/code/package.json rename to apps/code/20260130-000000/package.json diff --git a/apps/code/src/pages/index.tsx b/apps/code/20260130-000000/src/pages/index.tsx similarity index 100% rename from apps/code/src/pages/index.tsx rename to apps/code/20260130-000000/src/pages/index.tsx diff --git a/apps/code/src/server/index.tsx b/apps/code/20260130-000000/src/server/index.tsx similarity index 100% rename from apps/code/src/server/index.tsx rename to apps/code/20260130-000000/src/server/index.tsx diff --git a/apps/code/tsconfig.json b/apps/code/20260130-000000/tsconfig.json similarity index 100% rename from apps/code/tsconfig.json rename to apps/code/20260130-000000/tsconfig.json diff --git a/apps/code/current b/apps/code/current new file mode 120000 index 0000000..1a45961 --- /dev/null +++ b/apps/code/current @@ -0,0 +1 @@ +20260130-000000 \ No newline at end of file diff --git a/apps/profile/bun.lock b/apps/profile/20260130-000000/bun.lock similarity index 100% rename from apps/profile/bun.lock rename to apps/profile/20260130-000000/bun.lock diff --git a/apps/profile/index.tsx b/apps/profile/20260130-000000/index.tsx similarity index 100% rename from apps/profile/index.tsx rename to apps/profile/20260130-000000/index.tsx diff --git a/apps/profile/package.json b/apps/profile/20260130-000000/package.json similarity index 100% rename from apps/profile/package.json rename to apps/profile/20260130-000000/package.json diff --git a/apps/profile/tsconfig.json b/apps/profile/20260130-000000/tsconfig.json similarity index 100% rename from apps/profile/tsconfig.json rename to apps/profile/20260130-000000/tsconfig.json diff --git a/apps/profile/current b/apps/profile/current new file mode 120000 index 0000000..1a45961 --- /dev/null +++ b/apps/profile/current @@ -0,0 +1 @@ +20260130-000000 \ No newline at end of file diff --git a/apps/risk/package.json b/apps/risk/20260130-000000/package.json similarity index 100% rename from apps/risk/package.json rename to apps/risk/20260130-000000/package.json diff --git a/apps/risk/current b/apps/risk/current new file mode 120000 index 0000000..1a45961 --- /dev/null +++ b/apps/risk/current @@ -0,0 +1 @@ +20260130-000000 \ No newline at end of file diff --git a/apps/truisms/README.md b/apps/truisms/20260130-000000/README.md similarity index 100% rename from apps/truisms/README.md rename to apps/truisms/20260130-000000/README.md diff --git a/apps/truisms/bun.lock b/apps/truisms/20260130-000000/bun.lock similarity index 100% rename from apps/truisms/bun.lock rename to apps/truisms/20260130-000000/bun.lock diff --git a/apps/truisms/index.ts b/apps/truisms/20260130-000000/index.ts similarity index 100% rename from apps/truisms/index.ts rename to apps/truisms/20260130-000000/index.ts diff --git a/apps/truisms/package.json b/apps/truisms/20260130-000000/package.json similarity index 100% rename from apps/truisms/package.json rename to apps/truisms/20260130-000000/package.json diff --git a/apps/truisms/tsconfig.json b/apps/truisms/20260130-000000/tsconfig.json similarity index 100% rename from apps/truisms/tsconfig.json rename to apps/truisms/20260130-000000/tsconfig.json diff --git a/apps/truisms/current b/apps/truisms/current new file mode 120000 index 0000000..1a45961 --- /dev/null +++ b/apps/truisms/current @@ -0,0 +1 @@ +20260130-000000 \ No newline at end of file diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 219332c..9838e19 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -4,7 +4,7 @@ import { computeHash, generateManifest } from '%sync' import color from 'kleur' import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs' import { dirname, join } from 'path' -import { del, download, get, getManifest, handleError, makeUrl, put } from '../http' +import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http' import { confirm } from '../prompts' import { getAppName, isApp } from '../name' @@ -82,47 +82,58 @@ export async function pushApp() { } } - // Files to delete (in remote but not local) - const toDelete: string[] = [] - for (const file of remoteFiles) { - if (!localFiles.has(file)) { - toDelete.push(file) - } - } + // Note: We don't delete files in versioned deployments - new version is separate directory - if (toUpload.length === 0 && toDelete.length === 0) { + if (toUpload.length === 0) { console.log('Already up to date') return } console.log(`Pushing ${color.bold(appName)} to server...`) + // 1. Request new deployment version + type DeployResponse = { ok: boolean, version: string } + const deployRes = await post(`/api/sync/apps/${appName}/deploy`) + if (!deployRes?.ok) { + console.error('Failed to start deployment') + return + } + + const version = deployRes.version + console.log(`Deploying version ${color.bold(version)}...`) + + // 2. Upload changed files to new version if (toUpload.length > 0) { console.log(`Uploading ${toUpload.length} files...`) + let failedUploads = 0 + for (const file of toUpload) { const content = readFileSync(join(process.cwd(), file)) - const success = await put(`/api/sync/apps/${appName}/files/${file}`, content) + const success = await put(`/api/sync/apps/${appName}/files/${file}?version=${version}`, content) if (success) { console.log(` ${color.green('↑')} ${file}`) } else { console.log(` ${color.red('✗')} ${file}`) + failedUploads++ } } + + if (failedUploads > 0) { + console.error(`Failed to upload ${failedUploads} file(s). Deployment aborted.`) + console.error(`Incomplete version ${version} left on server (not activated).`) + return + } } - if (toDelete.length > 0) { - console.log(`Deleting ${toDelete.length} files on server...`) - for (const file of toDelete) { - const success = await del(`/api/sync/apps/${appName}/files/${file}`) - if (success) { - console.log(` ${color.red('✗')} ${file}`) - } else { - console.log(` ${color.red('Failed to delete')} ${file}`) - } - } + // 3. Activate new version (updates symlink and restarts app) + type ActivateResponse = { ok: boolean } + const activateRes = await post(`/api/sync/apps/${appName}/activate?version=${version}`) + if (!activateRes?.ok) { + console.error('Failed to activate new version') + return } - console.log(color.green('✓ Push complete')) + console.log(color.green(`✓ Deployed and activated version ${version}`)) } export async function pullApp() { diff --git a/src/cli/http.ts b/src/cli/http.ts index 6f5be60..b681b29 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -40,8 +40,8 @@ export async function post(url: string, body?: B): Promise { 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) + 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 manifest = generateManifest(appPath, appName) @@ -39,7 +47,9 @@ router.get('/apps/:app/files/:path{.+}', c => { if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) - const fullPath = safePath(APPS_DIR, appName, filePath) + 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) @@ -52,10 +62,16 @@ router.get('/apps/:app/files/:path{.+}', c => { 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) - const fullPath = safePath(APPS_DIR, appName, filePath) + // 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) @@ -90,7 +106,9 @@ router.delete('/apps/:app/files/:path{.+}', c => { if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) - const fullPath = safePath(APPS_DIR, appName, filePath) + 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) @@ -98,19 +116,125 @@ router.delete('/apps/:app/files/:path{.+}', c => { 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 }) + } 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 (keep 5 most recent) + 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 5 most recent, delete the rest + const toDelete = versionDirs.slice(5) + for (const dir of toDelete) { + const dirPath = join(appDir, dir) + rmSync(dirPath, { recursive: true, force: true }) + console.log(`Cleaned up old version: ${dir}`) + } + } catch (e) { + // Log but don't fail activation if cleanup fails + console.error(`Failed to clean up old versions: ${e}`) + } + + // Restart app to use new version + const app = allApps().find(a => a.name === appName) + if (app?.state === 'running') { + try { + await restartApp(appName) + } catch (e) { + return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500) + } + } + + 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 = safePath(APPS_DIR, appName) - if (!appPath || !existsSync(appPath)) return - const gitignore = loadGitignore(appPath) + 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(appPath, { recursive: true }, (_event, filename) => { + const watcher = watch(canonicalPath, { recursive: true }, (_event, filename) => { if (!filename || gitignore.shouldExclude(filename)) return - const fullPath = join(appPath, filename) + const fullPath = join(canonicalPath, filename) const type = existsSync(fullPath) ? 'change' : 'delete' pendingChanges.set(filename, type) @@ -120,7 +244,7 @@ router.sse('/apps/:app/watch', (send, c) => { const evt: FileChangeEvent = { type: changeType, path } if (changeType === 'change') { try { - const content = readFileSync(join(appPath, path)) + const content = readFileSync(join(canonicalPath, path)) evt.hash = computeHash(content) } catch { continue // File was deleted between check and read diff --git a/src/server/apps.ts b/src/server/apps.ts index 27b4293..15eba09 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -1,7 +1,7 @@ import type { App as SharedApp, AppState } from '@types' import type { Subprocess } from 'bun' import { DEFAULT_EMOJI } from '@types' -import { existsSync, readdirSync, readFileSync, renameSync, statSync, watch, writeFileSync } from 'fs' +import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, watch, writeFileSync } from 'fs' import { join, resolve } from 'path' export type { AppState } from '@types' @@ -149,6 +149,33 @@ export function startApp(dir: string) { runApp(dir, getPort(dir)) } +export async function restartApp(dir: string): Promise { + const app = _apps.get(dir) + if (!app) return + + // Stop if running + if (app.state === 'running' || app.state === 'starting') { + stopApp(dir) + + // Poll until stopped (with timeout) + const maxWait = 10000 // 10 seconds + const pollInterval = 100 + let waited = 0 + + while (app.state !== 'stopped' && waited < maxWait) { + await new Promise(resolve => setTimeout(resolve, pollInterval)) + waited += pollInterval + } + + if (app.state !== 'stopped') { + throw new Error(`App ${dir} failed to stop after ${maxWait}ms`) + } + } + + // Start the app + startApp(dir) +} + export function stopApp(dir: string) { const app = _apps.get(dir) if (!app || app.state !== 'running') return @@ -206,7 +233,7 @@ const update = () => _listeners.forEach(cb => cb()) function allAppDirs() { return readdirSync(APPS_DIR, { withFileTypes: true }) - .filter(e => e.isDirectory()) + .filter(e => e.isDirectory() && existsSync(join(APPS_DIR, e.name, 'current'))) .map(e => e.name) .sort() } @@ -335,7 +362,8 @@ function isDir(path: string): boolean { function loadApp(dir: string): LoadResult { try { - const file = readFileSync(join(APPS_DIR, dir, 'package.json'), 'utf-8') + const pkgPath = join(APPS_DIR, dir, 'current', 'package.json') + const file = readFileSync(pkgPath, 'utf-8') try { const json = JSON.parse(file) @@ -391,7 +419,9 @@ async function runApp(dir: string, port: number) { } }, STARTUP_TIMEOUT) - const cwd = join(APPS_DIR, dir) + // Resolve symlink to actual timestamp directory + const currentLink = join(APPS_DIR, dir, 'current') + const cwd = realpathSync(currentLink) const needsInstall = !existsSync(join(cwd, 'node_modules')) if (needsInstall) info(app, 'Installing dependencies...') @@ -474,7 +504,7 @@ async function runApp(dir: string, port: number) { } function saveApp(dir: string, pkg: any) { - const path = join(APPS_DIR, dir, 'package.json') + const path = join(APPS_DIR, dir, 'current', 'package.json') writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n') } @@ -566,8 +596,14 @@ 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]! + const parts = filename.split('/') + const dir = parts[0]! + + // Ignore changes inside old timestamp dirs (but allow current/) + if (parts.length > 2 && parts[1] !== 'current') return + + // For versioned apps, only care about changes to "current" directory + if (parts.length === 2 && parts[1] !== 'current' && parts[1] !== 'package.json') return // Handle new directory appearing if (!_apps.has(dir)) {