diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index d36c55a..c212892 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -13,4 +13,4 @@ export { stopApp, } from './manage' export { statsApp } from './stats' -export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync' +export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync' diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index b9685b4..197b4e5 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -24,6 +24,55 @@ interface ManifestDiff { remoteManifest: Manifest | null } +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[] + } + + 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 hasChanges = entry.added.length > 0 || entry.modified.length > 0 || entry.deleted.length > 0 + if (!hasChanges) { + console.log(color.gray(' No changes')) + } + + for (const file of entry.added) { + console.log(` ${color.green('+')} ${file}`) + } + for (const file of entry.modified) { + console.log(` ${color.yellow('~')} ${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...`) diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 135dc57..0c6e2ad 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -11,6 +11,7 @@ import { envRm, envSet, getApp, + historyApp, infoApp, listApps, logApp, @@ -258,6 +259,13 @@ program .argument('[name]', 'app name (uses current directory if omitted)') .action(versionsApp) +program + .command('history') + .helpGroup('Config:') + .description('Show file changes between versions') + .argument('[name]', 'app name (uses current directory if omitted)') + .action(historyApp) + program .command('rollback') .helpGroup('Config:') diff --git a/src/server/api/sync.ts b/src/server/api/sync.ts index 8c74045..9b31935 100644 --- a/src/server/api/sync.ts +++ b/src/server/api/sync.ts @@ -50,6 +50,68 @@ router.get('/apps/:app/versions', c => { return c.json({ versions, current: currentVersion }) }) +router.get('/apps/:app/history', c => { + const appName = c.req.param('app') + if (!appName) return c.json({ error: 'App not found' }, 404) + + const appDir = safePath(APPS_DIR, appName) + if (!appDir) return c.json({ error: 'Invalid path' }, 400) + if (!existsSync(appDir)) return c.json({ error: 'App not found' }, 404) + + const currentLink = join(appDir, 'current') + const currentVersion = existsSync(currentLink) + ? realpathSync(currentLink).split('/').pop() + : null + + const entries = readdirSync(appDir, { withFileTypes: true }) + const versions = entries + .filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name)) + .map(e => e.name) + .sort() + .reverse() // Newest first + + // Generate manifests for each version + const manifests = new Map>() + for (const version of versions) { + const versionPath = join(appDir, version) + const manifest = generateManifest(versionPath, appName) + manifests.set(version, manifest.files) + } + + // Diff consecutive pairs + const history = versions.map((version, i) => { + const current = version === currentVersion + const files = manifests.get(version)! + const olderVersion = versions[i + 1] + const olderFiles = olderVersion ? manifests.get(olderVersion)! : {} + + const added: string[] = [] + const modified: string[] = [] + const deleted: string[] = [] + + // Files in this version + for (const [path, info] of Object.entries(files)) { + const older = olderFiles[path] + if (!older) { + added.push(path) + } else if (older.hash !== info.hash) { + modified.push(path) + } + } + + // Files removed in this version + for (const path of Object.keys(olderFiles)) { + if (!files[path]) { + deleted.push(path) + } + } + + return { version, current, added: added.sort(), modified: modified.sort(), deleted: deleted.sort() } + }) + + return c.json({ history }) +}) + router.get('/apps/:app/manifest', c => { const appName = c.req.param('app') if (!appName) return c.json({ error: 'App not found' }, 404)