spicy
This commit is contained in:
parent
91c6ee3675
commit
ebf3ffc3af
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
pub/client/index.js
|
||||
|
||||
# output
|
||||
out
|
||||
|
|
|
|||
194
ISSUES.md
Normal file
194
ISSUES.md
Normal 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
1
apps/basic/current
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
20260130-000000
|
||||
1
apps/clock/current
Symbolic link
1
apps/clock/current
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
20260130-000000
|
||||
1
apps/code/current
Symbolic link
1
apps/code/current
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
20260130-000000
|
||||
1
apps/profile/current
Symbolic link
1
apps/profile/current
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
20260130-000000
|
||||
1
apps/risk/current
Symbolic link
1
apps/risk/current
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
20260130-000000
|
||||
1
apps/truisms/current
Symbolic link
1
apps/truisms/current
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
20260130-000000
|
||||
|
|
@ -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<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) {
|
||||
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<ActivateResponse>(`/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() {
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ export async function post<T, B = unknown>(url: string, body?: B): Promise<T | u
|
|||
try {
|
||||
const res = await fetch(makeUrl(url), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
return await res.json()
|
||||
|
|
|
|||
|
|
@ -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 { 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 { Hype } from '@because/hype'
|
||||
|
||||
|
|
@ -12,8 +12,14 @@ interface FileChangeEvent {
|
|||
}
|
||||
|
||||
function safePath(base: string, ...parts: string[]): string | null {
|
||||
const resolved = join(base, ...parts)
|
||||
if (!resolved.startsWith(base + '/') && resolved !== base) return 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
|
||||
}
|
||||
|
||||
|
|
@ -25,8 +31,10 @@ router.get('/apps/:app/manifest', 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)
|
||||
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<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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
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)) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user