toes diff and toes status
This commit is contained in:
parent
ebf3ffc3af
commit
28948b13b2
8
bun.lock
8
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=="],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(' ...'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user