toes/src/cli/commands/sync.ts

960 lines
27 KiB
TypeScript

import type { Manifest } from '@types'
import { loadGitignore } from '@gitignore'
import { computeHash, generateManifest } from '%sync'
import color from 'kleur'
import { diffLines } from 'diff'
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
import { confirm, prompt } from '../prompts'
import { getAppName, isApp, resolveAppName } from '../name'
interface ManifestDiff {
changed: string[]
localOnly: string[]
remoteOnly: string[]
localManifest: Manifest
remoteManifest: Manifest | null
}
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 <app>` 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<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}?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<ActivateResponse>(`/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(options: { force?: boolean } = {}) {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = getAppName()
const diff = await getManifestDiff(appName)
if (diff === null) {
return
}
const { changed, localOnly, remoteOnly, remoteManifest } = diff
if (!remoteManifest) {
console.error('App not found on server')
return
}
// Check for local changes that would be overwritten
const wouldOverwrite = changed.length > 0 || localOnly.length > 0
if (wouldOverwrite && !options.force) {
console.error('Cannot pull: you have local changes that would be overwritten')
console.error(' Use `toes status` and `toes diff` to see differences')
console.error(' Use `toes pull --force` to overwrite local changes')
return
}
// Files to download: changed + remoteOnly
const toDownload = [...changed, ...remoteOnly]
// Files to delete: localOnly
const toDelete = localOnly
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 diffApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = getAppName()
const diff = await getManifestDiff(appName)
if (diff === null) {
return
}
const { changed, localOnly, remoteOnly } = diff
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) {
// console.log(color.green('✓ No differences'))
return
}
// Fetch all changed files in parallel
const remoteContents = await Promise.all(
changed.map(file => download(`/api/sync/apps/${appName}/files/${file}`))
)
// Show diffs for changed files
for (let i = 0; i < changed.length; i++) {
const file = changed[i]!
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)
console.log(color.bold(`\n${file}`))
console.log(color.gray('─'.repeat(60)))
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)))
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
const remoteOnlyContents = await Promise.all(
remoteOnly.map(file => 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 (would be deleted on push)'))
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('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = getAppName()
const diff = await getManifestDiff(appName)
if (diff === null) {
return
}
const { changed, localOnly, remoteOnly, localManifest, remoteManifest } = diff
// toPush = changed + localOnly (new or modified locally)
const toPush = [...changed, ...localOnly]
// Local changes block pull
const hasLocalChanges = toPush.length > 0
// Display status
// console.log(`Status for ${color.bold(appName)}:\n`)
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} files on push\n`)
return
}
// Push status
if (toPush.length > 0 || remoteOnly.length > 0) {
console.log(color.bold('Changes to push:'))
for (const file of toPush) {
console.log(` ${color.green('↑')} ${file}`)
}
for (const file of remoteOnly) {
console.log(` ${color.red('✗')} ${file}`)
}
console.log()
}
// Pull status (only show if no local changes blocking)
if (!hasLocalChanges && remoteOnly.length > 0) {
console.log(color.bold('Changes to pull:'))
for (const file of remoteOnly) {
console.log(` ${color.green('↓')} ${file}`)
}
console.log()
}
// Summary
if (toPush.length === 0 && remoteOnly.length === 0) {
console.log(color.green('✓ In sync with server'))
}
}
export async function syncApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
return
}
const appName = getAppName()
const gitignore = loadGitignore(process.cwd())
const localHashes = new Map<string, string>()
// 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)}...`)
// 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()
}
}
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<ActivateResponse>(`/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('Not a toes app. Use `toes get <app>` to grab one.')
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.yellow('→')} ${file}`)
}
// Restore changed files from server
if (changed.length > 0) {
console.log(`\nRestoring ${changed.length} changed files 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 files...`)
for (const file of localOnly) {
unlinkSync(join(process.cwd(), file))
}
}
console.log(color.green(`\n✓ Stashed ${toStash.length} file(s)`))
}
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} files)`)}`)
} else {
console.log(` ${color.bold(stash.name)} ${color.gray('(invalid)')}`)
}
}
console.log()
}
export async function stashPopApp() {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
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)`))
}
export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) {
if (!isApp()) {
console.error('Not a toes app. Use `toes get <app>` to grab one.')
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)?`)
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)`))
}
interface VersionsResponse {
current: string | null
versions: string[]
}
async function getVersions(appName: string): Promise<VersionsResponse | null> {
try {
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`))
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 getManifestDiff(appName: string): Promise<ManifestDiff | null> {
const localManifest = generateManifest(process.cwd(), appName)
const result = await getManifest(appName)
if (result === null) {
// Connection error - already printed
return null
}
const localFiles = new Set(Object.keys(localManifest.files))
const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {}))
// Files that differ
const changed: string[] = []
for (const file of localFiles) {
if (remoteFiles.has(file)) {
const local = localManifest.files[file]!
const remote = result.manifest!.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
const remoteOnly: string[] = []
for (const file of remoteFiles) {
if (!localFiles.has(file)) {
remoteOnly.push(file)
}
}
return {
changed,
localOnly,
remoteOnly,
localManifest,
remoteManifest: result.manifest ?? null,
}
}
function showDiff(remote: string, local: string) {
const changes = diffLines(remote, local)
let lineCount = 0
const maxLines = 50
const contextLines = 3
for (let i = 0; i < changes.length; i++) {
const part = changes[i]!
const lines = part.value.replace(/\n$/, '').split('\n')
if (part.added) {
for (const line of lines) {
if (lineCount >= maxLines) {
console.log(color.gray('... diff truncated'))
return
}
console.log(color.green(`+ ${line}`))
lineCount++
}
} else if (part.removed) {
for (const line of lines) {
if (lineCount >= maxLines) {
console.log(color.gray('... diff truncated'))
return
}
console.log(color.red(`- ${line}`))
lineCount++
}
} 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) {
console.log(color.gray(' ...'))
lineCount++
}
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) {
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 && !nextHasChange) {
console.log(color.gray(' ...'))
}
}
}
}
}
}