toes diff and toes status

This commit is contained in:
Chris Wanstrath 2026-01-30 16:58:04 -08:00
parent ebf3ffc3af
commit 28948b13b2
5 changed files with 328 additions and 26 deletions

View File

@ -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=="],

View File

@ -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"
}
}

View File

@ -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'

View File

@ -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 <app>` 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 <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.')
@ -318,3 +470,132 @@ export async function syncApp() {
watcher.close()
}
}
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(' ...'))
}
}
}
}
}
}

View File

@ -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')