This commit is contained in:
Chris Wanstrath 2026-01-30 16:16:59 -08:00
parent 91c6ee3675
commit ebf3ffc3af
37 changed files with 417 additions and 45 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
# dependencies (bun install) # dependencies (bun install)
node_modules node_modules
pub/client/index.js
# output # output
out out

194
ISSUES.md Normal file
View File

@ -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 <app>` 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

1
apps/basic/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

1
apps/clock/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

1
apps/code/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

1
apps/profile/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

1
apps/risk/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

1
apps/truisms/current Symbolic link
View File

@ -0,0 +1 @@
20260130-000000

View File

@ -4,7 +4,7 @@ import { computeHash, generateManifest } from '%sync'
import color from 'kleur' import color from 'kleur'
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs' import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
import { dirname, join } from 'path' 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 { confirm } from '../prompts'
import { getAppName, isApp } from '../name' import { getAppName, isApp } from '../name'
@ -82,47 +82,58 @@ export async function pushApp() {
} }
} }
// Files to delete (in remote but not local) // Note: We don't delete files in versioned deployments - new version is separate directory
const toDelete: string[] = []
for (const file of remoteFiles) {
if (!localFiles.has(file)) {
toDelete.push(file)
}
}
if (toUpload.length === 0 && toDelete.length === 0) { if (toUpload.length === 0) {
console.log('Already up to date') console.log('Already up to date')
return return
} }
console.log(`Pushing ${color.bold(appName)} to server...`) console.log(`Pushing ${color.bold(appName)} to server...`)
// 1. Request new deployment version
type DeployResponse = { ok: boolean, version: string }
const deployRes = await post<DeployResponse>(`/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) { if (toUpload.length > 0) {
console.log(`Uploading ${toUpload.length} files...`) console.log(`Uploading ${toUpload.length} files...`)
let failedUploads = 0
for (const file of toUpload) { for (const file of toUpload) {
const content = readFileSync(join(process.cwd(), file)) 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) { if (success) {
console.log(` ${color.green('↑')} ${file}`) console.log(` ${color.green('↑')} ${file}`)
} else { } else {
console.log(` ${color.red('✗')} ${file}`) console.log(` ${color.red('✗')} ${file}`)
} failedUploads++
} }
} }
if (toDelete.length > 0) { if (failedUploads > 0) {
console.log(`Deleting ${toDelete.length} files on server...`) console.error(`Failed to upload ${failedUploads} file(s). Deployment aborted.`)
for (const file of toDelete) { console.error(`Incomplete version ${version} left on server (not activated).`)
const success = await del(`/api/sync/apps/${appName}/files/${file}`) return
if (success) {
console.log(` ${color.red('✗')} ${file}`)
} else {
console.log(` ${color.red('Failed to delete')} ${file}`)
}
} }
} }
console.log(color.green('✓ Push complete')) // 3. Activate new version (updates symlink and restarts app)
type ActivateResponse = { ok: boolean }
const activateRes = await post<ActivateResponse>(`/api/sync/apps/${appName}/activate?version=${version}`)
if (!activateRes?.ok) {
console.error('Failed to activate new version')
return
}
console.log(color.green(`✓ Deployed and activated version ${version}`))
} }
export async function pullApp() { export async function pullApp() {

View File

@ -40,8 +40,8 @@ export async function post<T, B = unknown>(url: string, body?: B): Promise<T | u
try { try {
const res = await fetch(makeUrl(url), { const res = await fetch(makeUrl(url), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: JSON.stringify(body), body: body !== undefined ? JSON.stringify(body) : undefined,
}) })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return await res.json() return await res.json()

View File

@ -1,7 +1,7 @@
import { APPS_DIR, allApps, removeApp } from '$apps' import { APPS_DIR, allApps, removeApp, restartApp, startApp, stopApp } from '$apps'
import { computeHash, generateManifest } from '../sync' import { computeHash, generateManifest } from '../sync'
import { loadGitignore } from '@gitignore' import { loadGitignore } from '@gitignore'
import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs' import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, watch, writeFileSync } from 'fs'
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
@ -12,8 +12,14 @@ interface FileChangeEvent {
} }
function safePath(base: string, ...parts: string[]): string | null { function safePath(base: string, ...parts: string[]): string | null {
const resolved = join(base, ...parts) // Resolve base to canonical path (follows symlinks) if it exists
if (!resolved.startsWith(base + '/') && resolved !== base) return null const canonicalBase = existsSync(base) ? realpathSync(base) : base
const resolved = join(canonicalBase, ...parts)
if (!resolved.startsWith(canonicalBase + '/') && resolved !== canonicalBase) {
return null
}
return resolved return resolved
} }
@ -25,8 +31,10 @@ router.get('/apps/:app/manifest', c => {
const appName = c.req.param('app') const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404) if (!appName) return c.json({ error: 'App not found' }, 404)
const appPath = safePath(APPS_DIR, appName) const appPath = join(APPS_DIR, appName, 'current')
if (!appPath) return c.json({ error: 'Invalid path' }, 400)
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) if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404)
const manifest = generateManifest(appPath, appName) 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) 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 (!fullPath) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404) 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 => { router.put('/apps/:app/files/:path{.+}', async c => {
const appName = c.req.param('app') const appName = c.req.param('app')
const filePath = c.req.param('path') 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) 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) if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
const dir = dirname(fullPath) 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) 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 (!fullPath) return c.json({ error: 'Invalid path' }, 400)
if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404) 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 }) 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) => { router.sse('/apps/:app/watch', (send, c) => {
const appName = c.req.param('app') 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 let debounceTimer: Timer | null = null
const pendingChanges = new Map<string, 'change' | 'delete'>() const pendingChanges = new Map<string, 'change' | 'delete'>()
const watcher = watch(appPath, { recursive: true }, (_event, filename) => { const watcher = watch(canonicalPath, { recursive: true }, (_event, filename) => {
if (!filename || gitignore.shouldExclude(filename)) return if (!filename || gitignore.shouldExclude(filename)) return
const fullPath = join(appPath, filename) const fullPath = join(canonicalPath, filename)
const type = existsSync(fullPath) ? 'change' : 'delete' const type = existsSync(fullPath) ? 'change' : 'delete'
pendingChanges.set(filename, type) pendingChanges.set(filename, type)
@ -120,7 +244,7 @@ router.sse('/apps/:app/watch', (send, c) => {
const evt: FileChangeEvent = { type: changeType, path } const evt: FileChangeEvent = { type: changeType, path }
if (changeType === 'change') { if (changeType === 'change') {
try { try {
const content = readFileSync(join(appPath, path)) const content = readFileSync(join(canonicalPath, path))
evt.hash = computeHash(content) evt.hash = computeHash(content)
} catch { } catch {
continue // File was deleted between check and read continue // File was deleted between check and read

View File

@ -1,7 +1,7 @@
import type { App as SharedApp, AppState } from '@types' import type { App as SharedApp, AppState } from '@types'
import type { Subprocess } from 'bun' import type { Subprocess } from 'bun'
import { DEFAULT_EMOJI } from '@types' 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' import { join, resolve } from 'path'
export type { AppState } from '@types' export type { AppState } from '@types'
@ -149,6 +149,33 @@ export function startApp(dir: string) {
runApp(dir, getPort(dir)) runApp(dir, getPort(dir))
} }
export async function restartApp(dir: string): Promise<void> {
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) { export function stopApp(dir: string) {
const app = _apps.get(dir) const app = _apps.get(dir)
if (!app || app.state !== 'running') return if (!app || app.state !== 'running') return
@ -206,7 +233,7 @@ const update = () => _listeners.forEach(cb => cb())
function allAppDirs() { function allAppDirs() {
return readdirSync(APPS_DIR, { withFileTypes: true }) 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) .map(e => e.name)
.sort() .sort()
} }
@ -335,7 +362,8 @@ function isDir(path: string): boolean {
function loadApp(dir: string): LoadResult { function loadApp(dir: string): LoadResult {
try { 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 { try {
const json = JSON.parse(file) const json = JSON.parse(file)
@ -391,7 +419,9 @@ async function runApp(dir: string, port: number) {
} }
}, STARTUP_TIMEOUT) }, 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')) const needsInstall = !existsSync(join(cwd, 'node_modules'))
if (needsInstall) info(app, 'Installing dependencies...') if (needsInstall) info(app, 'Installing dependencies...')
@ -474,7 +504,7 @@ async function runApp(dir: string, port: number) {
} }
function saveApp(dir: string, pkg: any) { 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') writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
} }
@ -566,8 +596,14 @@ function watchAppsDir() {
watch(APPS_DIR, { recursive: true }, (_event, filename) => { watch(APPS_DIR, { recursive: true }, (_event, filename) => {
if (!filename) return if (!filename) return
// Extract the app directory name from the path (e.g., "myapp/package.json" -> "myapp") const parts = filename.split('/')
const dir = filename.split('/')[0]! 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 // Handle new directory appearing
if (!_apps.has(dir)) { if (!_apps.has(dir)) {