diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 3631f5d..8ab35a9 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -306,7 +306,6 @@ export async function diffApp() { console.log() } - export async function statusApp() { if (!isApp()) { console.error('Not a toes app. Use `toes get ` to grab one.') @@ -365,13 +364,29 @@ export async function statusApp() { } } -export async function syncApp() { +export async function syncApp(options?: { rollback?: boolean }) { if (!isApp()) { console.error('Not a toes app. Use `toes get ` to grab one.') return } const appName = getAppName() + + // Handle rollback + if (options?.rollback) { + console.log(`Rolling back ${color.bold(appName)} to sync checkpoint...`) + type RollbackResponse = { ok: boolean, version?: string, error?: string } + const result = await post(`/api/sync/apps/${appName}/sync/rollback`) + + if (!result?.ok) { + console.error(result?.error || 'Failed to rollback') + return + } + + console.log(color.green(`✓ Rolled back to checkpoint (version ${result.version})`)) + return + } + const gitignore = loadGitignore(process.cwd()) const localHashes = new Map() @@ -382,6 +397,7 @@ export async function syncApp() { } console.log(`Syncing ${color.bold(appName)}...`) + console.log(color.gray(`Checkpoint created - run 'toes sync --rollback' to undo changes`)) // Watch local files const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => { diff --git a/src/cli/setup.ts b/src/cli/setup.ts index f22fbed..a7f16e0 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -138,6 +138,7 @@ program program .command('sync') .description('Watch and sync changes bidirectionally') + .option('-r, --rollback', 'rollback to checkpoint before sync started') .action(syncApp) program diff --git a/src/server/api/sync.ts b/src/server/api/sync.ts index 3d985c9..3108d2b 100644 --- a/src/server/api/sync.ts +++ b/src/server/api/sync.ts @@ -195,6 +195,13 @@ router.post('/apps/:app/activate', async c => { rmSync(dirPath, { recursive: true, force: true }) console.log(`Cleaned up old version: ${dir}`) } + + // Remove sync checkpoint - new deployment is now source of truth + const checkpointPath = join(appDir, '.sync-checkpoint') + if (existsSync(checkpointPath)) { + rmSync(checkpointPath, { recursive: true, force: true }) + console.log(`Removed sync checkpoint after successful deployment`) + } } catch (e) { // Log but don't fail activation if cleanup fails console.error(`Failed to clean up old versions: ${e}`) @@ -216,6 +223,65 @@ router.post('/apps/:app/activate', async c => { } }) +router.post('/apps/:app/sync/rollback', async c => { + const appName = c.req.param('app') + if (!appName) return c.json({ error: 'App name required' }, 400) + + const appDir = join(APPS_DIR, appName) + const checkpointPath = join(appDir, '.sync-checkpoint') + const currentLink = join(appDir, 'current') + + if (!existsSync(checkpointPath)) { + return c.json({ error: 'No sync checkpoint found' }, 404) + } + + try { + // Get current version name for cleanup + const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null + const currentVersion = currentReal ? currentReal.split('/').pop() : null + + // Generate timestamp for rollback version + 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())}` + + // Copy checkpoint to new timestamped version + const newVersion = join(appDir, timestamp) + cpSync(checkpointPath, newVersion, { recursive: true }) + + // Atomic symlink update + const tempLink = join(appDir, '.current.tmp') + if (existsSync(tempLink)) { + unlinkSync(tempLink) + } + symlinkSync(timestamp, tempLink, 'dir') + renameSync(tempLink, currentLink) + + // Clean up the broken version if it's a timestamp dir (not named 'current') + if (currentVersion && /^\d{8}-\d{6}$/.test(currentVersion)) { + const brokenVersion = join(appDir, currentVersion) + if (existsSync(brokenVersion)) { + rmSync(brokenVersion, { recursive: true, force: true }) + console.log(`Removed broken version: ${currentVersion}`) + } + } + + // Restart app with rolled-back version + const app = allApps().find(a => a.name === appName) + if (app?.state === 'running') { + try { + await restartApp(appName) + } catch (e) { + return c.json({ error: `Rolled back but failed to restart: ${e instanceof Error ? e.message : String(e)}` }, 500) + } + } + + return c.json({ ok: true, version: timestamp }) + } catch (e) { + return c.json({ error: `Failed to rollback: ${e instanceof Error ? e.message : String(e)}` }, 500) + } +}) + router.sse('/apps/:app/watch', (send, c) => { const appName = c.req.param('app') @@ -224,17 +290,32 @@ router.sse('/apps/:app/watch', (send, c) => { const safeAppPath = safePath(APPS_DIR, appName) if (!safeAppPath || !existsSync(appPath)) return - // Resolve to canonical path for consistent watch events - const canonicalPath = realpathSync(appPath) + const appDir = join(APPS_DIR, appName) + const checkpointPath = join(appDir, '.sync-checkpoint') + const currentReal = realpathSync(appPath) - const gitignore = loadGitignore(canonicalPath) + // Create checkpoint snapshot for rollback + try { + // Remove old checkpoint if exists + if (existsSync(checkpointPath)) { + rmSync(checkpointPath, { recursive: true, force: true }) + } + // Copy current version to checkpoint + cpSync(currentReal, checkpointPath, { recursive: true }) + console.log(`Created sync checkpoint for ${appName}`) + } catch (e) { + console.error(`Failed to create sync checkpoint: ${e}`) + // Continue anyway - checkpoint is optional safety feature + } + + const gitignore = loadGitignore(currentReal) let debounceTimer: Timer | null = null const pendingChanges = new Map() - const watcher = watch(canonicalPath, { recursive: true }, (_event, filename) => { + const watcher = watch(appPath, { recursive: true }, (_event, filename) => { if (!filename || gitignore.shouldExclude(filename)) return - const fullPath = join(canonicalPath, filename) + const fullPath = join(appPath, filename) const type = existsSync(fullPath) ? 'change' : 'delete' pendingChanges.set(filename, type) @@ -244,7 +325,7 @@ router.sse('/apps/:app/watch', (send, c) => { const evt: FileChangeEvent = { type: changeType, path } if (changeType === 'change') { try { - const content = readFileSync(join(canonicalPath, path)) + const content = readFileSync(join(appPath, path)) evt.hash = computeHash(content) } catch { continue // File was deleted between check and read