new sync
This commit is contained in:
parent
1da7e77f00
commit
a47e5b8298
|
|
@ -306,7 +306,6 @@ export async function diffApp() {
|
||||||
console.log()
|
console.log()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function statusApp() {
|
export async function statusApp() {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
|
|
@ -365,13 +364,29 @@ export async function statusApp() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncApp() {
|
export async function syncApp(options?: { rollback?: boolean }) {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const appName = getAppName()
|
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<RollbackResponse>(`/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 gitignore = loadGitignore(process.cwd())
|
||||||
const localHashes = new Map<string, string>()
|
const localHashes = new Map<string, string>()
|
||||||
|
|
||||||
|
|
@ -382,6 +397,7 @@ export async function syncApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Syncing ${color.bold(appName)}...`)
|
console.log(`Syncing ${color.bold(appName)}...`)
|
||||||
|
console.log(color.gray(`Checkpoint created - run 'toes sync --rollback' to undo changes`))
|
||||||
|
|
||||||
// Watch local files
|
// Watch local files
|
||||||
const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => {
|
const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => {
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ program
|
||||||
program
|
program
|
||||||
.command('sync')
|
.command('sync')
|
||||||
.description('Watch and sync changes bidirectionally')
|
.description('Watch and sync changes bidirectionally')
|
||||||
|
.option('-r, --rollback', 'rollback to checkpoint before sync started')
|
||||||
.action(syncApp)
|
.action(syncApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,13 @@ router.post('/apps/:app/activate', async c => {
|
||||||
rmSync(dirPath, { recursive: true, force: true })
|
rmSync(dirPath, { recursive: true, force: true })
|
||||||
console.log(`Cleaned up old version: ${dir}`)
|
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) {
|
} catch (e) {
|
||||||
// Log but don't fail activation if cleanup fails
|
// Log but don't fail activation if cleanup fails
|
||||||
console.error(`Failed to clean up old versions: ${e}`)
|
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) => {
|
router.sse('/apps/:app/watch', (send, c) => {
|
||||||
const appName = c.req.param('app')
|
const appName = c.req.param('app')
|
||||||
|
|
||||||
|
|
@ -224,17 +290,32 @@ router.sse('/apps/:app/watch', (send, c) => {
|
||||||
const safeAppPath = safePath(APPS_DIR, appName)
|
const safeAppPath = safePath(APPS_DIR, appName)
|
||||||
if (!safeAppPath || !existsSync(appPath)) return
|
if (!safeAppPath || !existsSync(appPath)) return
|
||||||
|
|
||||||
// Resolve to canonical path for consistent watch events
|
const appDir = join(APPS_DIR, appName)
|
||||||
const canonicalPath = realpathSync(appPath)
|
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
|
let debounceTimer: Timer | null = null
|
||||||
const pendingChanges = new Map<string, 'change' | 'delete'>()
|
const pendingChanges = new Map<string, 'change' | 'delete'>()
|
||||||
|
|
||||||
const watcher = watch(canonicalPath, { recursive: true }, (_event, filename) => {
|
const watcher = watch(appPath, { recursive: true }, (_event, filename) => {
|
||||||
if (!filename || gitignore.shouldExclude(filename)) return
|
if (!filename || gitignore.shouldExclude(filename)) return
|
||||||
|
|
||||||
const fullPath = join(canonicalPath, filename)
|
const fullPath = join(appPath, filename)
|
||||||
const type = existsSync(fullPath) ? 'change' : 'delete'
|
const type = existsSync(fullPath) ? 'change' : 'delete'
|
||||||
pendingChanges.set(filename, type)
|
pendingChanges.set(filename, type)
|
||||||
|
|
||||||
|
|
@ -244,7 +325,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(canonicalPath, path))
|
const content = readFileSync(join(appPath, 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user