diff --git a/bun.lock b/bun.lock index 2466f91..59dbec1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,15 @@ "": { "name": "toes", "dependencies": { - "@because/forge": "^0.0.1.", + "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", + "diff": "^8.0.3", "kleur": "^4.1.5", }, "devDependencies": { "@types/bun": "latest", + "@types/diff": "^8.0.0", }, "peerDependencies": { "typescript": "^5.9.2", @@ -25,12 +27,16 @@ "@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], + "@types/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], "bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], "commander": ["commander@14.0.2", "https://npm.nose.space/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], "kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], diff --git a/package.json b/package.json index 3382d5e..b894696 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,17 @@ "test": "bun test" }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "@types/diff": "^8.0.0" }, "peerDependencies": { "typescript": "^5.9.2" }, "dependencies": { - "commander": "^14.0.2", "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", + "commander": "^14.0.2", + "diff": "^8.0.3", "kleur": "^4.1.5" } } \ No newline at end of file diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index b5cb9d2..d844467 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -10,4 +10,4 @@ export { startApp, stopApp, } from './manage' -export { getApp, pullApp, pushApp, syncApp } from './sync' +export { diffApp, getApp, pullApp, pushApp, statusApp, syncApp } from './sync' diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 9838e19..3631f5d 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -2,12 +2,21 @@ 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, 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' +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...`) @@ -136,42 +145,40 @@ export async function pushApp() { console.log(color.green(`✓ Deployed and activated version ${version}`)) } -export async function pullApp() { +export async function pullApp(options: { force?: boolean } = {}) { if (!isApp()) { console.error('Not a toes app. Use `toes get ` to grab one.') return } const appName = getAppName() + const diff = await getManifestDiff(appName) + + if (diff === null) { + return + } + + const { changed, localOnly, remoteOnly, remoteManifest } = diff - 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) - } + // 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 delete (in local but not remote) - const toDelete: string[] = [] - for (const file of localFiles) { - if (!remoteFiles.has(file)) { - toDelete.push(file) - } - } + // 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') @@ -213,6 +220,151 @@ export async function pullApp() { console.log(color.green('✓ Pull complete')) } +export async function diffApp() { + if (!isApp()) { + console.error('Not a toes app. Use `toes get ` 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 ` 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 ` to grab one.') @@ -318,3 +470,132 @@ export async function syncApp() { watcher.close() } } + +async function getManifestDiff(appName: string): Promise { + 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(' ...')) + } + } + } + } + } +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 500be21..0fcc722 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'fs' import color from 'kleur' import { + diffApp, getApp, infoApp, listApps, @@ -15,6 +16,7 @@ import { restartApp, rmApp, startApp, + statusApp, stopApp, syncApp, } from './commands' @@ -114,8 +116,19 @@ program program .command('pull') .description('Pull changes from server') + .option('-f, --force', 'overwrite local changes') .action(pullApp) +program + .command('status') + .description('Show what would be pushed/pulled') + .action(statusApp) + +program + .command('diff') + .description('Show diff of changed files') + .action(diffApp) + program .command('sync') .description('Watch and sync changes bidirectionally')