import type { Manifest } from '@types' import { loadGitignore } from '@gitignore' import { generateManifest, readSyncState, writeSyncState } from '%sync' import color from 'kleur' import { diffLines } from 'diff' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs' import { dirname, join } from 'path' import { del, download, get, getManifest, getSignal, handleError, makeUrl, post, put } from '../http' import { confirm, prompt } from '../prompts' import { getAppName, getAppPackage, isApp, resolveAppName } from '../name' const s = (n: number) => n === 1 ? '' : 's' function notAppError(): string { const pkg = getAppPackage() if (!pkg) return 'No package.json found. Use `toes get ` to grab one.' if (!pkg.scripts?.toes) return 'Missing scripts.toes in package.json. Use `toes new` to add it.' return 'Not a toes app' } interface Rename { from: string to: string } interface ManifestDiff { changed: string[] localOnly: string[] remoteOnly: string[] renamed: Rename[] localManifest: Manifest remoteManifest: Manifest | null remoteVersion: string | null serverChanged: boolean } export async function historyApp(name?: string) { const appName = resolveAppName(name) if (!appName) return type HistoryEntry = { version: string current: boolean added: string[] modified: string[] deleted: string[] renamed: string[] } type HistoryResponse = { history: HistoryEntry[] } const result = await get(`/api/sync/apps/${appName}/history`) if (!result) return if (result.history.length === 0) { console.log(`No versions found for ${color.bold(appName)}`) return } console.log(`History for ${color.bold(appName)}:\n`) for (const entry of result.history) { const date = formatVersion(entry.version) const label = entry.current ? ` ${color.green('→')} ${color.bold(entry.version)}` : ` ${entry.version}` const suffix = entry.current ? ` ${color.green('(current)')}` : '' console.log(`${label} ${color.gray(date)}${suffix}`) const renamed = entry.renamed ?? [] const hasChanges = entry.added.length > 0 || entry.modified.length > 0 || entry.deleted.length > 0 || renamed.length > 0 if (!hasChanges) { console.log(color.gray(' No changes')) } for (const rename of renamed) { console.log(` ${color.cyan('→')} ${rename}`) } for (const file of entry.added) { console.log(` ${color.green('+')} ${file}`) } for (const file of entry.modified) { console.log(` ${color.magenta('*')} ${file}`) } for (const file of entry.deleted) { console.log(` ${color.red('-')} ${file}`) } console.log() } } export async function getApp(name: string) { console.log(`Fetching ${color.bold(name)} from server...`) const result = await getManifest(name) if (!result || !result.exists || !result.manifest) { console.error(`App not found: ${name}`) return } const appPath = join(process.cwd(), name) if (existsSync(appPath)) { console.error(`Directory already exists: ${name}`) return } mkdirSync(appPath, { recursive: true }) const files = Object.keys(result.manifest.files) console.log(`Downloading ${files.length} file${s(files.length)}...`) for (const file of files) { const content = await download(`/api/sync/apps/${name}/files/${file}`) if (!content) { console.error(`Failed to download: ${file}`) continue } const fullPath = join(appPath, file) const dir = dirname(fullPath) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } writeFileSync(fullPath, content) } if (result.version) { writeSyncState(appPath, { version: result.version }) } console.log(color.green(`✓ Downloaded ${name}`)) } export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}) { if (!isApp()) { console.error(notAppError()) return } const appName = getAppName() const diff = await getManifestDiff(appName) if (diff === null) { return } const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff if (!remoteManifest) { const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`) if (!ok) return } // If server changed, abort unless --force (skip for new apps) if (remoteManifest && serverChanged && !options.force) { console.error('Cannot push: server has changed since last sync') console.error('\nRun `toes pull` first, or `toes push --force` to overwrite') return } // Files to upload: changed + localOnly const toUpload = [...changed, ...localOnly] // Files to delete on server: remoteOnly (local deletions when version matches, or forced) const toDelete = !serverChanged || options.force ? [...remoteOnly] : [] // Detect renames among upload/delete pairs (same hash, different path) const renames: Rename[] = [...renamed] const remoteByHash = new Map() if (remoteManifest) { for (const file of toDelete) { const info = remoteManifest.files[file] if (info) remoteByHash.set(info.hash, file) } } const renamedUploads = new Set() const renamedDeletes = new Set() for (const file of toUpload) { const hash = localManifest.files[file]?.hash if (!hash) continue const remoteFile = remoteByHash.get(hash) if (remoteFile && !renamedDeletes.has(remoteFile)) { renames.push({ from: remoteFile, to: file }) renamedUploads.add(file) renamedDeletes.add(remoteFile) } } if (toUpload.length === 0 && toDelete.length === 0) { if (!options.quiet) 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 const actualUploads = toUpload.filter(f => !renamedUploads.has(f)) const actualDeletes = toDelete.filter(f => !renamedDeletes.has(f)) if (renames.length > 0) { console.log(`Renaming ${renames.length} file${s(renames.length)}...`) for (const { from, to } of renames) { const content = readFileSync(join(process.cwd(), to)) const uploadOk = await put(`/api/sync/apps/${appName}/files/${to}?version=${version}`, content) const deleteOk = await del(`/api/sync/apps/${appName}/files/${from}?version=${version}`) if (uploadOk && deleteOk) { console.log(` ${color.cyan('→')} ${from} → ${to}`) } else { console.log(` ${color.red('✗')} ${from} → ${to} (failed)`) } } } if (actualUploads.length > 0) { console.log(`Uploading ${actualUploads.length} file${s(actualUploads.length)}...`) let failedUploads = 0 for (const file of actualUploads) { const content = readFileSync(join(process.cwd(), file)) 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(failedUploads)}. Deployment aborted.`) console.error(`Incomplete version ${version} left on server (not activated).`) return } } // 3. Delete files that no longer exist locally if (actualDeletes.length > 0) { console.log(`Deleting ${actualDeletes.length} file${s(actualDeletes.length)}...`) for (const file of actualDeletes) { const success = await del(`/api/sync/apps/${appName}/files/${file}?version=${version}`) if (success) { console.log(` ${color.red('-')} ${file}`) } else { console.log(` ${color.red('✗')} ${file} (failed)`) } } } // 4. 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 } // 5. Write sync version after successful push writeSyncState(process.cwd(), { version }) console.log(color.green(`✓ Deployed and activated version ${version}`)) } export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}) { if (!isApp()) { console.error(notAppError()) return } const appName = getAppName() const diff = await getManifestDiff(appName) if (diff === null) { return } const { changed, localOnly, remoteOnly, remoteManifest, remoteVersion, serverChanged } = diff if (!remoteManifest) { console.error('App not found on server') return } if (!serverChanged) { // Server hasn't changed — all diffs are local, nothing to pull if (!options.quiet) { if (changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0) { console.log('Server is up to date. You have local changes — use `toes push`.') } else { console.log('Already up to date') } } return } // Server changed — download diffs from remote const hasDiffs = changed.length > 0 || localOnly.length > 0 if (hasDiffs && !options.force) { console.error('Cannot pull: you have local changes that would be overwritten') for (const file of changed) { console.error(` ${color.magenta('*')} ${file}`) } for (const file of localOnly) { console.error(` ${color.green('+')} ${file} (local only)`) } console.error('\nUse `toes pull --force` to overwrite local changes') return } // Files to download: changed + remoteOnly const toDownload = [...changed, ...remoteOnly] // Files to delete locally: only when forcing const toDelete = options.force ? localOnly : [] if (toDownload.length === 0 && toDelete.length === 0) { // Server version changed but files are identical — just update stored version if (remoteVersion) { writeSyncState(process.cwd(), { version: remoteVersion }) } if (!options.quiet) console.log('Already up to date') return } console.log(`Pulling ${color.bold(appName)} from server...`) if (toDownload.length > 0) { console.log(`Downloading ${toDownload.length} file${s(toDownload.length)}...`) for (const file of toDownload) { const content = await download(`/api/sync/apps/${appName}/files/${file}`) if (!content) { console.log(` ${color.red('✗')} ${file}`) continue } const fullPath = join(process.cwd(), file) const dir = dirname(fullPath) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } writeFileSync(fullPath, content) console.log(` ${color.green('↓')} ${file}`) } } if (toDelete.length > 0) { console.log(`Deleting ${toDelete.length} local file${s(toDelete.length)}...`) for (const file of toDelete) { const fullPath = join(process.cwd(), file) if (existsSync(fullPath)) { unlinkSync(fullPath) console.log(` ${color.red('-')} ${file}`) } } } if (remoteVersion) { writeSyncState(process.cwd(), { version: remoteVersion }) } console.log(color.green('✓ Pull complete')) } export async function diffApp() { if (!isApp()) { console.error(notAppError()) return } const appName = getAppName() const diff = await getManifestDiff(appName) if (diff === null) { return } const { changed, localOnly, remoteOnly, renamed } = diff if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0 && renamed.length === 0) { return } // Show renames for (const { from, to } of renamed) { console.log(color.cyan('\nRenamed')) console.log(color.bold(`${from} → ${to}`)) console.log(color.gray('─'.repeat(60))) } // Fetch all changed files in parallel (skip binary files) const remoteContents = await Promise.all( changed.map(file => isBinary(file) ? null : download(`/api/sync/apps/${appName}/files/${file}`)) ) // Show diffs for changed files for (let i = 0; i < changed.length; i++) { const file = changed[i]! console.log(color.bold(`\n${file}`)) console.log(color.gray('─'.repeat(60))) if (isBinary(file)) { console.log(color.gray('Binary file changed')) continue } const remoteContent = remoteContents[i] const localContent = readFileSync(join(process.cwd(), file), 'utf-8') if (!remoteContent) { console.log(color.red(`Failed to fetch remote version of ${file}`)) continue } const remoteText = new TextDecoder().decode(remoteContent) showDiff(remoteText, localContent) } // Show local-only files for (const file of localOnly) { console.log(color.green('\nNew file (local only)')) console.log(color.bold(`${file}`)) console.log(color.gray('─'.repeat(60))) if (isBinary(file)) { console.log(color.gray('Binary file')) continue } const content = readFileSync(join(process.cwd(), file), 'utf-8') const lines = content.split('\n') for (let i = 0; i < Math.min(lines.length, 10); i++) { console.log(color.green(`+ ${lines[i]}`)) } if (lines.length > 10) { console.log(color.gray(`... ${lines.length - 10} more lines`)) } } // Fetch all remote-only files in parallel (skip binary files) const remoteOnlyContents = await Promise.all( remoteOnly.map(file => isBinary(file) ? null : download(`/api/sync/apps/${appName}/files/${file}`)) ) // Show remote-only files for (let i = 0; i < remoteOnly.length; i++) { const file = remoteOnly[i]! const content = remoteOnlyContents[i] console.log(color.bold(`\n${file}`)) console.log(color.gray('─'.repeat(60))) console.log(color.red('Remote only')) if (isBinary(file)) { console.log(color.gray('Binary file')) continue } if (content) { const text = new TextDecoder().decode(content) const lines = text.split('\n') for (let i = 0; i < Math.min(lines.length, 10); i++) { console.log(color.red(`- ${lines[i]}`)) } if (lines.length > 10) { console.log(color.gray(`... ${lines.length - 10} more lines`)) } } } console.log() } export async function statusApp() { if (!isApp()) { console.error(notAppError()) return } const appName = getAppName() const diff = await getManifestDiff(appName) if (diff === null) { return } const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff if (!remoteManifest) { console.log(color.yellow('App does not exist on server')) const localFileCount = Object.keys(localManifest.files).length console.log(`\nWould create new app with ${localFileCount} file${s(localFileCount)} on push\n`) return } const hasDiffs = changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0 || renamed.length > 0 if (!hasDiffs) { if (serverChanged) { // Files identical but version changed — update stored version silently const { remoteVersion } = diff if (remoteVersion) { writeSyncState(process.cwd(), { version: remoteVersion }) } } console.log(color.green('✓ In sync with server')) return } if (!serverChanged) { // Server hasn't moved — all diffs are local changes to push console.log(color.bold('Changes to push:')) for (const { from, to } of renamed) { console.log(` ${color.cyan('→')} ${from} → ${to}`) } for (const file of changed) { console.log(` ${color.magenta('*')} ${file}`) } for (const file of localOnly) { console.log(` ${color.green('+')} ${file}`) } for (const file of remoteOnly) { console.log(` ${color.red('-')} ${file}`) } console.log() } else { // Server changed — show diffs neutrally console.log(color.yellow('Server has changed since last sync\n')) console.log(color.bold('Differences:')) for (const { from, to } of renamed) { console.log(` ${color.cyan('→')} ${from} → ${to}`) } for (const file of changed) { console.log(` ${color.magenta('*')} ${file}`) } for (const file of localOnly) { console.log(` ${color.green('+')} ${file} (local only)`) } for (const file of remoteOnly) { console.log(` ${color.green('+')} ${file} (remote only)`) } console.log(`\nRun ${color.bold('toes pull')} to update, or ${color.bold('toes push --force')} to overwrite server`) console.log() } } export async function syncApp() { if (!isApp()) { console.error(notAppError()) return } const appName = getAppName() // Verify app exists on server const result = await getManifest(appName) if (result === null) return if (!result.exists) { console.error(`App ${color.bold(appName)} doesn't exist on server. Run ${color.bold('toes push')} first.`) return } console.log(`Syncing ${color.bold(appName)}...`) // Initial sync: pull remote changes, then push local await mergeSync(appName) const gitignore = loadGitignore(process.cwd()) // Watch local files with debounce → push let pushTimer: Timer | null = null const watcher = watch(process.cwd(), { recursive: true }, (_event, filename) => { if (!filename || gitignore.shouldExclude(filename)) return if (pushTimer) clearTimeout(pushTimer) pushTimer = setTimeout(() => pushApp({ quiet: true }), 500) }) // Connect to SSE for remote changes → pull const url = makeUrl(`/api/sync/apps/${appName}/watch`) let res: Response try { res = await fetch(url, { signal: getSignal() }) if (!res.ok) { console.error(`Failed to connect to server: ${res.status} ${res.statusText}`) watcher.close() return } } catch (error) { handleError(error) watcher.close() return } if (!res.body) { console.error('No response body from server') watcher.close() return } console.log(` Connected, watching for changes...`) const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' let pullTimer: Timer | null = null try { while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n\n') buffer = lines.pop() ?? '' for (const line of lines) { if (!line.startsWith('data: ')) continue if (pullTimer) clearTimeout(pullTimer) pullTimer = setTimeout(() => mergeSync(appName), 500) } } } finally { if (pushTimer) clearTimeout(pushTimer) if (pullTimer) clearTimeout(pullTimer) watcher.close() } } export async function versionsApp(name?: string) { const appName = resolveAppName(name) if (!appName) return const result = await getVersions(appName) if (!result) return if (result.versions.length === 0) { console.log(`No versions found for ${color.bold(appName)}`) return } console.log(`Versions for ${color.bold(appName)}:\n`) for (const version of result.versions) { const isCurrent = version === result.current const date = formatVersion(version) if (isCurrent) { console.log(` ${color.green('→')} ${color.bold(version)} ${color.gray(date)} ${color.green('(current)')}`) } else { console.log(` ${version} ${color.gray(date)}`) } } console.log() } export async function rollbackApp(name?: string, version?: string) { const appName = resolveAppName(name) if (!appName) return // Get available versions const result = await getVersions(appName) if (!result) return if (result.versions.length === 0) { console.error(`No versions found for ${color.bold(appName)}`) return } // Filter out current version for rollback candidates const candidates = result.versions.filter(v => v !== result.current) if (candidates.length === 0) { console.error('No previous versions to rollback to') return } let targetVersion: string | undefined = version if (!targetVersion) { // Show available versions and prompt console.log(`Available versions for ${color.bold(appName)}:\n`) for (let i = 0; i < candidates.length; i++) { const v = candidates[i]! const date = formatVersion(v) console.log(` ${color.cyan(String(i + 1))}. ${v} ${color.gray(date)}`) } console.log() const answer = await prompt('Enter version number or name: ') // Check if it's a number (index) or version name const index = parseInt(answer, 10) if (!isNaN(index) && index >= 1 && index <= candidates.length) { targetVersion = candidates[index - 1]! } else if (candidates.includes(answer)) { targetVersion = answer } else { console.error('Invalid selection') return } } // Validate version exists (handles both user-provided and selected versions) if (!targetVersion || !result.versions.includes(targetVersion)) { console.error(`Version ${color.bold(targetVersion ?? 'unknown')} not found`) console.error(`Available versions: ${result.versions.join(', ')}`) return } if (targetVersion === result.current) { console.log(`Version ${color.bold(targetVersion)} is already active`) return } const ok = await confirm(`Rollback ${color.bold(appName)} to version ${color.bold(targetVersion)}?`) if (!ok) return console.log(`Rolling back to ${color.bold(targetVersion)}...`) type ActivateResponse = { ok: boolean } const activateRes = await post(`/api/sync/apps/${appName}/activate?version=${targetVersion}`) if (!activateRes?.ok) { console.error('Failed to activate version') return } console.log(color.green(`✓ Rolled back to version ${targetVersion}`)) } const STASH_BASE = '/tmp/toes-stash' export async function stashApp() { if (!isApp()) { console.error(notAppError()) return } const appName = getAppName() const diff = await getManifestDiff(appName) if (diff === null) { return } const { changed, localOnly } = diff const toStash = [...changed, ...localOnly] if (toStash.length === 0) { console.log('No local changes to stash') return } const stashDir = join(STASH_BASE, appName) // Check if stash already exists if (existsSync(stashDir)) { console.error('Stash already exists. Use `toes stash-pop` first.') return } mkdirSync(stashDir, { recursive: true }) // Save stash metadata const metadata = { app: appName, timestamp: new Date().toISOString(), files: toStash, changed, localOnly, } writeFileSync(join(stashDir, 'metadata.json'), JSON.stringify(metadata, null, 2)) // Copy files to stash for (const file of toStash) { const srcPath = join(process.cwd(), file) const destPath = join(stashDir, 'files', file) const destDir = dirname(destPath) if (!existsSync(destDir)) { mkdirSync(destDir, { recursive: true }) } const content = readFileSync(srcPath) writeFileSync(destPath, content) console.log(` ${color.magenta('*')} ${file}`) } // Restore changed files from server if (changed.length > 0) { console.log(`\nRestoring ${changed.length} changed file${s(changed.length)} from server...`) for (const file of changed) { const content = await download(`/api/sync/apps/${appName}/files/${file}`) if (content) { writeFileSync(join(process.cwd(), file), content) } } } // Delete local-only files if (localOnly.length > 0) { console.log(`Removing ${localOnly.length} local-only file${s(localOnly.length)}...`) for (const file of localOnly) { unlinkSync(join(process.cwd(), file)) } } console.log(color.green(`\n✓ Stashed ${toStash.length} file${s(toStash.length)}`)) } export async function stashListApp() { if (!existsSync(STASH_BASE)) { console.log('No stashes') return } const entries = readdirSync(STASH_BASE, { withFileTypes: true }) const stashes = entries.filter(e => e.isDirectory()) if (stashes.length === 0) { console.log('No stashes') return } console.log('Stashes:\n') for (const stash of stashes) { const metadataPath = join(STASH_BASE, stash.name, 'metadata.json') if (existsSync(metadataPath)) { const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as { timestamp: string files: string[] } const date = new Date(metadata.timestamp).toLocaleString() console.log(` ${color.bold(stash.name)} ${color.gray(date)} ${color.gray(`(${metadata.files.length} file${s(metadata.files.length)})`)}`) } else { console.log(` ${color.bold(stash.name)} ${color.gray('(invalid)')}`) } } console.log() } export async function stashPopApp() { if (!isApp()) { console.error(notAppError()) return } const appName = getAppName() const stashDir = join(STASH_BASE, appName) if (!existsSync(stashDir)) { console.error(`No stash found for ${color.bold(appName)}`) return } // Read metadata const metadataPath = join(stashDir, 'metadata.json') if (!existsSync(metadataPath)) { console.error('Invalid stash: missing metadata') return } const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as { app: string timestamp: string files: string[] changed: string[] localOnly: string[] } console.log(`Restoring stash from ${new Date(metadata.timestamp).toLocaleString()}...\n`) // Restore files from stash for (const file of metadata.files) { const srcPath = join(stashDir, 'files', file) const destPath = join(process.cwd(), file) const destDir = dirname(destPath) if (!existsSync(srcPath)) { console.log(` ${color.red('✗')} ${file} (missing from stash)`) continue } if (!existsSync(destDir)) { mkdirSync(destDir, { recursive: true }) } const content = readFileSync(srcPath) writeFileSync(destPath, content) console.log(` ${color.green('←')} ${file}`) } // Remove stash directory rmSync(stashDir, { recursive: true }) console.log(color.green(`\n✓ Restored ${metadata.files.length} file${s(metadata.files.length)}`)) } export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) { if (!isApp()) { console.error(notAppError()) return } const appName = getAppName() const diff = await getManifestDiff(appName) if (diff === null) { return } const { localOnly } = diff if (localOnly.length === 0) { console.log('Nothing to clean') return } if (options.dryRun) { console.log('Would remove:') for (const file of localOnly) { console.log(` ${color.red('-')} ${file}`) } return } if (!options.force) { console.log('Files not on server:') for (const file of localOnly) { console.log(` ${color.red('-')} ${file}`) } console.log() const ok = await confirm(`Remove ${localOnly.length} file${s(localOnly.length)}?`) if (!ok) return } for (const file of localOnly) { const fullPath = join(process.cwd(), file) unlinkSync(fullPath) console.log(` ${color.red('-')} ${file}`) } console.log(color.green(`✓ Removed ${localOnly.length} file${s(localOnly.length)}`)) } interface VersionsResponse { current: string | null versions: string[] } async function getVersions(appName: string): Promise { try { const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`), { signal: getSignal() }) if (res.status === 404) { console.error(`App not found: ${appName}`) return null } if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return await res.json() } catch (error) { handleError(error) return null } } function formatVersion(version: string): string { // Parse YYYYMMDD-HHMMSS format const match = version.match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/) if (!match) return '' const date = new Date( parseInt(match[1]!, 10), parseInt(match[2]!, 10) - 1, parseInt(match[3]!, 10), parseInt(match[4]!, 10), parseInt(match[5]!, 10), parseInt(match[6]!, 10) ) return date.toLocaleString() } async function mergeSync(appName: string): Promise { const diff = await getManifestDiff(appName) if (!diff) return const { changed, remoteOnly, remoteManifest, serverChanged } = diff if (!remoteManifest) return if (serverChanged) { // Pull remote changes const toPull = [...changed, ...remoteOnly] if (toPull.length > 0) { for (const file of toPull) { const content = await download(`/api/sync/apps/${appName}/files/${file}`) if (!content) continue const fullPath = join(process.cwd(), file) const dir = dirname(fullPath) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) } writeFileSync(fullPath, content) console.log(` ${color.green('↓')} ${file}`) } } } // Push merged state to server await pushApp({ quiet: true }) } async function getManifestDiff(appName: string): Promise { const localManifest = generateManifest(process.cwd(), appName) const result = await getManifest(appName) if (result === null) { // Connection error - already printed return null } const remoteManifest = result.manifest ?? null const remoteVersion = result.version ?? null const syncState = readSyncState(process.cwd()) const serverChanged = !syncState || syncState.version !== remoteVersion const localFiles = new Set(Object.keys(localManifest.files)) const remoteFiles = new Set(Object.keys(remoteManifest?.files ?? {})) // Files that differ const changed: string[] = [] for (const file of localFiles) { if (remoteFiles.has(file)) { const local = localManifest.files[file]! const remote = remoteManifest!.files[file]! if (local.hash !== remote.hash) { changed.push(file) } } } // Files only in local const localOnly: string[] = [] for (const file of localFiles) { if (!remoteFiles.has(file)) { localOnly.push(file) } } // Files only in remote (filtered by local gitignore) const gitignore = loadGitignore(process.cwd()) const remoteOnly: string[] = [] for (const file of remoteFiles) { if (!localFiles.has(file) && !gitignore.shouldExclude(file)) { remoteOnly.push(file) } } // Detect renames: localOnly + remoteOnly files with matching hashes const renamed: Rename[] = [] const remoteByHash = new Map() for (const file of remoteOnly) { const hash = remoteManifest!.files[file]!.hash remoteByHash.set(hash, file) } const matchedLocal = new Set() const matchedRemote = new Set() for (const file of localOnly) { const hash = localManifest.files[file]!.hash const remoteFile = remoteByHash.get(hash) if (remoteFile && !matchedRemote.has(remoteFile)) { renamed.push({ from: remoteFile, to: file }) matchedLocal.add(file) matchedRemote.add(remoteFile) } } return { changed, localOnly: localOnly.filter(f => !matchedLocal.has(f)), remoteOnly: remoteOnly.filter(f => !matchedRemote.has(f)), renamed, localManifest, remoteManifest, remoteVersion, serverChanged, } } const BINARY_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif', '.heic', '.tiff', '.woff', '.woff2', '.ttf', '.eot', '.otf', '.mp3', '.mp4', '.wav', '.ogg', '.webm', '.avi', '.mov', '.pdf', '.zip', '.tar', '.gz', '.br', '.zst', '.wasm', '.exe', '.dll', '.so', '.dylib', ]) const isBinary = (filename: string) => { const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase() return BINARY_EXTENSIONS.has(ext) } function showDiff(remote: string, local: string) { const changes = diffLines(remote, local) let lineCount = 0 const maxLines = 50 const contextLines = 3 let remoteLine = 1 let localLine = 1 let needsHeader = true let hunkCount = 0 const printHeader = (_rStart: number, lStart: number) => { if (hunkCount > 0) console.log() if (lStart > 1) { console.log(color.cyan(`Line ${lStart}:`)) lineCount++ } needsHeader = false hunkCount++ } for (let i = 0; i < changes.length; i++) { const part = changes[i]! const lines = part.value.replace(/\n$/, '').split('\n') if (part.added) { if (needsHeader) printHeader(remoteLine, localLine) for (const line of lines) { if (lineCount >= maxLines) { console.log(color.gray('... diff truncated')) return } console.log(color.green(`+ ${line}`)) lineCount++ } localLine += lines.length } else if (part.removed) { if (needsHeader) printHeader(remoteLine, localLine) for (const line of lines) { if (lineCount >= maxLines) { console.log(color.gray('... diff truncated')) return } console.log(color.red(`- ${line}`)) lineCount++ } remoteLine += lines.length } else { // Context: show lines near changes const prevHasChange = i > 0 && (changes[i - 1]!.added || changes[i - 1]!.removed) const nextHasChange = i < changes.length - 1 && (changes[i + 1]!.added || changes[i + 1]!.removed) if (prevHasChange && nextHasChange && lines.length <= contextLines * 2) { // Small gap between changes - show all for (const line of lines) { if (lineCount >= maxLines) { console.log(color.gray('... diff truncated')) return } console.log(color.gray(` ${line}`)) lineCount++ } } else { // Show context before next change if (nextHasChange) { const start = Math.max(0, lines.length - contextLines) if (start > 0) { needsHeader = true } const headerLine = remoteLine + start const headerLocalLine = localLine + start if (needsHeader) printHeader(headerLine, headerLocalLine) for (let j = start; j < lines.length; j++) { if (lineCount >= maxLines) { console.log(color.gray('... diff truncated')) return } console.log(color.gray(` ${lines[j]}`)) lineCount++ } } // Show context after previous change if (prevHasChange && !nextHasChange) { const end = Math.min(lines.length, contextLines) for (let j = 0; j < end; j++) { if (lineCount >= maxLines) { console.log(color.gray('... diff truncated')) return } console.log(color.gray(` ${lines[j]}`)) lineCount++ } if (end < lines.length) { needsHeader = true } } } remoteLine += lines.length localLine += lines.length } } }