1225 lines
35 KiB
TypeScript
1225 lines
35 KiB
TypeScript
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 <app>` 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<HistoryResponse>(`/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<string, string>()
|
|
if (remoteManifest) {
|
|
for (const file of toDelete) {
|
|
const info = remoteManifest.files[file]
|
|
if (info) remoteByHash.set(info.hash, file)
|
|
}
|
|
}
|
|
|
|
const renamedUploads = new Set<string>()
|
|
const renamedDeletes = new Set<string>()
|
|
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<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
|
|
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<ActivateResponse>(`/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<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(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<VersionsResponse | null> {
|
|
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<void> {
|
|
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<ManifestDiff | null> {
|
|
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<string, string>()
|
|
for (const file of remoteOnly) {
|
|
const hash = remoteManifest!.files[file]!.hash
|
|
remoteByHash.set(hash, file)
|
|
}
|
|
|
|
const matchedLocal = new Set<string>()
|
|
const matchedRemote = new Set<string>()
|
|
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
|
|
}
|
|
}
|
|
}
|