import type { Manifest } from '@types' import { loadGitignore } from '@gitignore' 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, post, put } from '../http' import { confirm } from '../prompts' import { getAppName, isApp } from '../name' export async function getApp(name: string) { console.log(`Fetching ${color.bold(name)} from server...`) const manifest: Manifest | undefined = await get(`/api/sync/apps/${name}/manifest`) if (!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(manifest.files) console.log(`Downloading ${files.length} files...`) 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) } console.log(color.green(`✓ Downloaded ${name}`)) } export async function pushApp() { if (!isApp()) { console.error('Not a toes app. Use `toes get ` to grab one.') return } const appName = getAppName() const localManifest = generateManifest(process.cwd(), appName) const result = await getManifest(appName) if (result === null) { // Connection error - already printed return } if (!result.exists) { const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`) if (!ok) return } const localFiles = new Set(Object.keys(localManifest.files)) const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {})) // Files to upload (new or changed) const toUpload: string[] = [] for (const file of localFiles) { const local = localManifest.files[file]! const remote = result.manifest?.files[file] if (!remote || local.hash !== remote.hash) { toUpload.push(file) } } // Note: We don't delete files in versioned deployments - new version is separate directory 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(`/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}?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 } } // 3. 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 } console.log(color.green(`✓ Deployed and activated version ${version}`)) } export async function pullApp() { if (!isApp()) { console.error('Not a toes app. Use `toes get ` to grab one.') return } const appName = getAppName() const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`) if (!remoteManifest) { console.error('App not found on server') return } const localManifest = generateManifest(process.cwd(), appName) const localFiles = new Set(Object.keys(localManifest.files)) const remoteFiles = new Set(Object.keys(remoteManifest.files)) // Files to download (new or changed) const toDownload: string[] = [] for (const file of remoteFiles) { const remote = remoteManifest.files[file]! const local = localManifest.files[file] if (!local || remote.hash !== local.hash) { toDownload.push(file) } } // Files to delete (in local but not remote) const toDelete: string[] = [] for (const file of localFiles) { if (!remoteFiles.has(file)) { toDelete.push(file) } } if (toDownload.length === 0 && toDelete.length === 0) { console.log('Already up to date') return } console.log(`Pulling ${color.bold(appName)} from server...`) if (toDownload.length > 0) { console.log(`Downloading ${toDownload.length} files...`) 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 files...`) for (const file of toDelete) { const fullPath = join(process.cwd(), file) unlinkSync(fullPath) console.log(` ${color.red('✗')} ${file}`) } } console.log(color.green('✓ Pull complete')) } 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() // Initialize local hashes const manifest = generateManifest(process.cwd(), appName) for (const [path, info] of Object.entries(manifest.files)) { localHashes.set(path, info.hash) } 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) => { if (!filename || gitignore.shouldExclude(filename)) return const fullPath = join(process.cwd(), filename) if (existsSync(fullPath) && statSync(fullPath).isFile()) { const content = readFileSync(fullPath) const hash = computeHash(content) if (localHashes.get(filename) !== hash) { localHashes.set(filename, hash) await put(`/api/sync/apps/${appName}/files/${filename}`, content) console.log(` ${color.green('↑')} ${filename}`) } } else if (!existsSync(fullPath)) { localHashes.delete(filename) await del(`/api/sync/apps/${appName}/files/${filename}`) console.log(` ${color.red('✗')} ${filename}`) } }) // Connect to SSE for remote changes const url = makeUrl(`/api/sync/apps/${appName}/watch`) let res: Response try { res = await fetch(url) 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 to server, watching for changes...`) const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' 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 const event = JSON.parse(line.slice(6)) as { type: 'change' | 'delete', path: string, hash?: string } if (event.type === 'change') { // Skip if we already have this version (handles echo from our own changes) if (localHashes.get(event.path) === event.hash) continue const content = await download(`/api/sync/apps/${appName}/files/${event.path}`) if (content) { const fullPath = join(process.cwd(), event.path) mkdirSync(dirname(fullPath), { recursive: true }) writeFileSync(fullPath, content) localHashes.set(event.path, event.hash!) console.log(` ${color.green('↓')} ${event.path}`) } } else if (event.type === 'delete') { const fullPath = join(process.cwd(), event.path) if (existsSync(fullPath)) { unlinkSync(fullPath) localHashes.delete(event.path) console.log(` ${color.red('✗')} ${event.path} (remote)`) } } } } } finally { watcher.close() } }