338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
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 <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() {
|
|
if (!isApp()) {
|
|
console.error('Not a toes app. Use `toes get <app>` 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 <app>` 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<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 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)}...`)
|
|
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()
|
|
}
|
|
}
|