diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 1a739ab..a56981b 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -1,6 +1,6 @@ import type { Manifest } from '@types' import { loadGitignore } from '@gitignore' -import { generateManifest } from '%sync' +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' @@ -22,12 +22,18 @@ interface Rename { } interface ManifestDiff { - changed: string[] + localChanged: string[] + remoteChanged: string[] localOnly: string[] remoteOnly: string[] + localDeleted: string[] + remoteDeleted: string[] + conflicts: string[] renamed: Rename[] localManifest: Manifest remoteManifest: Manifest | null + baseManifest: Manifest | null + remoteVersion: string | null } export async function historyApp(name?: string) { @@ -87,8 +93,8 @@ export async function historyApp(name?: string) { export async function getApp(name: string) { console.log(`Fetching ${color.bold(name)} from server...`) - const manifest: Manifest | undefined = await get(`/api/sync/apps/${name}/manifest`) - if (!manifest) { + const result = await getManifest(name) + if (!result || !result.exists || !result.manifest) { console.error(`App not found: ${name}`) return } @@ -101,7 +107,7 @@ export async function getApp(name: string) { mkdirSync(appPath, { recursive: true }) - const files = Object.keys(manifest.files) + const files = Object.keys(result.manifest.files) console.log(`Downloading ${files.length} files...`) for (const file of files) { @@ -121,64 +127,75 @@ export async function getApp(name: string) { writeFileSync(fullPath, content) } + // Write sync state so future status/push/pull has a baseline + if (result.version) { + writeSyncState(appPath, { version: result.version, manifest: result.manifest }) + } + console.log(color.green(`✓ Downloaded ${name}`)) } -export async function pushApp(options: { quiet?: boolean } = {}) { +export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}) { if (!isApp()) { console.error(notAppError()) return } const appName = getAppName() + const diff = await getManifestDiff(appName) - const localManifest = generateManifest(process.cwd(), appName) - const result = await getManifest(appName) - - if (result === null) { - // Connection error - already printed + if (diff === null) { return } - if (!result.exists) { + const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, renamed, localManifest, remoteManifest } = diff + + if (!remoteManifest) { const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`) if (!ok) return } - const localFiles = new Set(Object.keys(localManifest.files)) - const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {})) - - // Files to upload (new or changed) - const toUpload: string[] = [] - for (const file of localFiles) { - const local = localManifest.files[file]! - const remote = result.manifest?.files[file] - if (!remote || local.hash !== remote.hash) { - toUpload.push(file) + // Abort if there are unpulled remote changes or conflicts (unless --force) + const hasRemoteChanges = remoteChanged.length > 0 || remoteOnly.length > 0 || remoteDeleted.length > 0 + if (!options.force && (hasRemoteChanges || conflicts.length > 0)) { + console.error('Cannot push: server has changes you haven\'t pulled') + for (const file of remoteChanged) { + console.error(` ${color.yellow('~')} ${file} (changed on server)`) } + for (const file of remoteOnly) { + console.error(` ${color.green('+')} ${file} (new on server)`) + } + for (const file of remoteDeleted) { + console.error(` ${color.red('-')} ${file} (deleted on server)`) + } + for (const file of conflicts) { + console.error(` ${color.red('!')} ${file} (conflict)`) + } + console.error('\nRun `toes pull` first, or `toes push --force` to overwrite') + return } - // Files to delete (exist on server but not locally, respecting gitignore) - const gitignore = loadGitignore(process.cwd()) - const toDelete: string[] = [] - for (const file of remoteFiles) { - if (!localFiles.has(file) && !gitignore.shouldExclude(file)) { - toDelete.push(file) - } - } + // Files to upload: locally changed/added + conflicts (when forcing) + const toUpload = [...localChanged, ...localOnly, ...(options.force ? conflicts : [])] + // Files to delete on server: locally deleted + remote-only when forcing + const toDelete = [...localDeleted, ...(options.force ? remoteOnly : [])] // Detect renames among upload/delete pairs (same hash, different path) - const renames: Rename[] = [] + const renames: Rename[] = [...renamed] + const serverManifest = remoteManifest const remoteByHash = new Map() - for (const file of toDelete) { - const hash = result.manifest!.files[file]!.hash - remoteByHash.set(hash, file) + if (serverManifest) { + for (const file of toDelete) { + const info = serverManifest.files[file] + if (info) remoteByHash.set(info.hash, file) + } } const renamedUploads = new Set() const renamedDeletes = new Set() for (const file of toUpload) { - const hash = localManifest.files[file]!.hash + 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 }) @@ -266,6 +283,10 @@ export async function pushApp(options: { quiet?: boolean } = {}) { return } + // 5. Write sync state after successful push + const newManifest = generateManifest(process.cwd(), appName) + writeSyncState(process.cwd(), { version, manifest: newManifest }) + console.log(color.green(`✓ Deployed and activated version ${version}`)) } @@ -282,7 +303,7 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {} return } - const { changed, localOnly, remoteOnly, remoteManifest } = diff + const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, remoteManifest, remoteVersion } = diff if (!remoteManifest) { console.error('App not found on server') @@ -290,19 +311,35 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {} } // Check for local changes that would be overwritten - const wouldOverwrite = changed.length > 0 || localOnly.length > 0 - if (wouldOverwrite && !options.force) { + const hasLocalChanges = localChanged.length > 0 || localOnly.length > 0 || localDeleted.length > 0 || conflicts.length > 0 + if (hasLocalChanges && !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') + for (const file of localChanged) { + console.error(` ${color.yellow('~')} ${file}`) + } + for (const file of localOnly) { + console.error(` ${color.green('+')} ${file} (local only)`) + } + for (const file of localDeleted) { + console.error(` ${color.red('-')} ${file} (deleted locally)`) + } + for (const file of conflicts) { + console.error(` ${color.red('!')} ${file} (conflict)`) + } + console.error('\nUse `toes pull --force` to overwrite local changes') return } - // Files to download: changed + remoteOnly - const toDownload = [...changed, ...remoteOnly] + // Files to download: remote changed + remote only + conflicts (when forcing) + const toDownload = [...remoteChanged, ...remoteOnly, ...(options.force ? [...localChanged, ...conflicts] : [])] - // Files to delete: localOnly - const toDelete = localOnly + // Files to delete locally: remote deleted + local only (when forcing) + const toDelete = [...remoteDeleted, ...(options.force ? localOnly : [])] + + // Restore locally deleted files from remote + if (options.force && localDeleted.length > 0) { + toDownload.push(...localDeleted) + } if (toDownload.length === 0 && toDelete.length === 0) { if (!options.quiet) console.log('Already up to date') @@ -336,11 +373,19 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {} console.log(`Deleting ${toDelete.length} local files...`) for (const file of toDelete) { const fullPath = join(process.cwd(), file) - unlinkSync(fullPath) - console.log(` ${color.red('✗')} ${file}`) + if (existsSync(fullPath)) { + unlinkSync(fullPath) + console.log(` ${color.red('✗')} ${file}`) + } } } + // Write sync state after successful pull + if (remoteVersion) { + const newManifest = generateManifest(process.cwd(), appName) + writeSyncState(process.cwd(), { version: remoteVersion, manifest: newManifest }) + } + console.log(color.green('✓ Pull complete')) } @@ -357,10 +402,14 @@ export async function diffApp() { return } - const { changed, localOnly, remoteOnly, renamed } = diff + const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, renamed } = diff - if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0 && renamed.length === 0) { - // console.log(color.green('✓ No differences')) + const hasChanges = localChanged.length > 0 || remoteChanged.length > 0 || + localOnly.length > 0 || remoteOnly.length > 0 || + localDeleted.length > 0 || remoteDeleted.length > 0 || + conflicts.length > 0 || renamed.length > 0 + + if (!hasChanges) { return } @@ -371,27 +420,73 @@ export async function diffApp() { console.log(color.gray('─'.repeat(60))) } - // Fetch all changed files in parallel + // Fetch remote content for changed/conflict files + const changedFiles = [...localChanged, ...remoteChanged, ...conflicts] const remoteContents = await Promise.all( - changed.map(file => download(`/api/sync/apps/${appName}/files/${file}`)) + changedFiles.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') + // Show diffs for locally changed files + if (localChanged.length > 0) { + for (let i = 0; i < localChanged.length; i++) { + const file = localChanged[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 + if (!remoteContent) { + console.log(color.red(`Failed to fetch remote version of ${file}`)) + continue + } + + const remoteText = new TextDecoder().decode(remoteContent) + + console.log(color.green('\nLocal change')) + console.log(color.bold(`${file}`)) + console.log(color.gray('─'.repeat(60))) + showDiff(remoteText, localContent) } + } - const remoteText = new TextDecoder().decode(remoteContent) + // Show diffs for remotely changed files + if (remoteChanged.length > 0) { + for (let i = 0; i < remoteChanged.length; i++) { + const file = remoteChanged[i]! + const remoteContent = remoteContents[localChanged.length + i] + const localContent = readFileSync(join(process.cwd(), file), 'utf-8') - console.log(color.bold(`\n${file}`)) - console.log(color.gray('─'.repeat(60))) - showDiff(remoteText, localContent) + if (!remoteContent) { + console.log(color.red(`Failed to fetch remote version of ${file}`)) + continue + } + + const remoteText = new TextDecoder().decode(remoteContent) + + console.log(color.yellow('\nRemote change')) + console.log(color.bold(`${file}`)) + console.log(color.gray('─'.repeat(60))) + showDiff(localContent, remoteText) + } + } + + // Show diffs for conflicts + if (conflicts.length > 0) { + for (let i = 0; i < conflicts.length; i++) { + const file = conflicts[i]! + const remoteContent = remoteContents[localChanged.length + remoteChanged.length + 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.red('\nConflict (changed on both sides)')) + console.log(color.bold(`${file}`)) + console.log(color.gray('─'.repeat(60))) + showDiff(remoteText, localContent) + } } // Show local-only files @@ -419,14 +514,14 @@ export async function diffApp() { const file = remoteOnly[i]! const content = remoteOnlyContents[i] - console.log(color.bold(`\n${file}`)) + console.log(color.yellow('\nNew file (remote only)')) + console.log(color.bold(`${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]}`)) + console.log(color.green(`+ ${lines[i]}`)) } if (lines.length > 10) { console.log(color.gray(`... ${lines.length - 10} more lines`)) @@ -434,6 +529,20 @@ export async function diffApp() { } } + // Show locally deleted files + for (const file of localDeleted) { + console.log(color.red('\nDeleted locally')) + console.log(color.bold(`${file}`)) + console.log(color.gray('─'.repeat(60))) + } + + // Show remotely deleted files + for (const file of remoteDeleted) { + console.log(color.red('\nDeleted on server')) + console.log(color.bold(`${file}`)) + console.log(color.gray('─'.repeat(60))) + } + console.log() } @@ -451,16 +560,7 @@ export async function statusApp() { return } - const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest } = diff - - // toPush = changed + localOnly (new or modified locally) - const toPush = [...changed, ...localOnly] - - // Local changes block pull - const hasLocalChanges = toPush.length > 0 || renamed.length > 0 - - // Display status - // console.log(`Status for ${color.bold(appName)}:\n`) + const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, renamed, localManifest, remoteManifest, baseManifest } = diff if (!remoteManifest) { console.log(color.yellow('App does not exist on server')) @@ -469,32 +569,56 @@ export async function statusApp() { return } + const toPush = [...localChanged, ...localOnly, ...localDeleted] + const toPull = [...remoteChanged, ...remoteOnly, ...remoteDeleted] + // Push status - if (toPush.length > 0 || remoteOnly.length > 0 || renamed.length > 0) { + if (toPush.length > 0 || renamed.length > 0) { console.log(color.bold('Changes to push:')) for (const { from, to } of renamed) { console.log(` ${color.cyan('→')} ${from} → ${to}`) } - for (const file of toPush) { + for (const file of localChanged) { console.log(` ${color.green('↑')} ${file}`) } - for (const file of remoteOnly) { - console.log(` ${color.red('✗')} ${file}`) + for (const file of localOnly) { + console.log(` ${color.green('+')} ${file}`) + } + for (const file of localDeleted) { + console.log(` ${color.red('-')} ${file}`) } console.log() } - // Pull status (only show if no local changes blocking) - if (!hasLocalChanges && remoteOnly.length > 0) { + // Pull status + if (toPull.length > 0) { console.log(color.bold('Changes to pull:')) - for (const file of remoteOnly) { + for (const file of remoteChanged) { console.log(` ${color.green('↓')} ${file}`) } + for (const file of remoteOnly) { + console.log(` ${color.green('+')} ${file}`) + } + for (const file of remoteDeleted) { + console.log(` ${color.red('-')} ${file}`) + } + console.log() + } + + // Conflicts + if (conflicts.length > 0) { + console.log(color.bold(color.red('Conflicts:'))) + for (const file of conflicts) { + console.log(` ${color.red('!')} ${file}`) + } + if (!baseManifest) { + console.log(color.gray('\n No sync baseline. Re-download with `toes get` to establish one.')) + } console.log() } // Summary - if (toPush.length === 0 && remoteOnly.length === 0 && renamed.length === 0) { + if (toPush.length === 0 && toPull.length === 0 && conflicts.length === 0 && renamed.length === 0) { console.log(color.green('✓ In sync with server')) } } @@ -517,7 +641,7 @@ export async function syncApp() { console.log(`Syncing ${color.bold(appName)}...`) - // Initial sync: merge based on mtime, then push merged state + // Initial sync: merge using three-way diff, then push merged state await mergeSync(appName) const gitignore = loadGitignore(process.cwd()) @@ -702,8 +826,8 @@ export async function stashApp() { return } - const { changed, localOnly } = diff - const toStash = [...changed, ...localOnly] + const { localChanged, localOnly } = diff + const toStash = [...localChanged, ...localOnly] if (toStash.length === 0) { console.log('No local changes to stash') @@ -725,7 +849,7 @@ export async function stashApp() { app: appName, timestamp: new Date().toISOString(), files: toStash, - changed, + changed: localChanged, localOnly, } writeFileSync(join(stashDir, 'metadata.json'), JSON.stringify(metadata, null, 2)) @@ -746,9 +870,9 @@ export async function stashApp() { } // Restore changed files from server - if (changed.length > 0) { - console.log(`\nRestoring ${changed.length} changed files from server...`) - for (const file of changed) { + if (localChanged.length > 0) { + console.log(`\nRestoring ${localChanged.length} changed files from server...`) + for (const file of localChanged) { const content = await download(`/api/sync/apps/${appName}/files/${file}`) if (content) { writeFileSync(join(process.cwd(), file), content) @@ -943,20 +1067,12 @@ async function mergeSync(appName: string): Promise { const diff = await getManifestDiff(appName) if (!diff) return - const { changed, remoteOnly, localManifest, remoteManifest } = diff + const { remoteChanged, remoteOnly, remoteDeleted, remoteManifest } = diff if (!remoteManifest) return - // Determine which changed files to pull (remote is newer) - const toPull: string[] = [...remoteOnly] - for (const file of changed) { - const localMtime = new Date(localManifest.files[file]!.mtime).getTime() - const remoteMtime = new Date(remoteManifest.files[file]!.mtime).getTime() - if (remoteMtime > localMtime) { - toPull.push(file) - } - } + // Pull remote changes + const toPull = [...remoteChanged, ...remoteOnly] - // Pull remote-newer and remote-only files if (toPull.length > 0) { for (const file of toPull) { const content = await download(`/api/sync/apps/${appName}/files/${file}`) @@ -973,6 +1089,15 @@ async function mergeSync(appName: string): Promise { } } + // Delete files that were deleted on remote + for (const file of remoteDeleted) { + const fullPath = join(process.cwd(), file) + if (existsSync(fullPath)) { + unlinkSync(fullPath) + console.log(` ${color.red('✗')} ${file}`) + } + } + // Push merged state to server await pushApp({ quiet: true }) } @@ -986,35 +1111,82 @@ async function getManifestDiff(appName: string): Promise { return null } + const remoteManifest = result.manifest ?? null + const remoteVersion = result.version ?? null + const syncState = readSyncState(process.cwd()) + const baseManifest = syncState?.manifest ?? null + const localFiles = new Set(Object.keys(localManifest.files)) - const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {})) + const remoteFiles = new Set(Object.keys(remoteManifest?.files ?? {})) + const baseFiles = new Set(Object.keys(baseManifest?.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) - } - } - } + // Collect all unique paths + const allPaths = new Set([...localFiles, ...remoteFiles, ...baseFiles]) - // 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 localChanged: string[] = [] + const remoteChanged: string[] = [] + const localOnly: string[] = [] const remoteOnly: string[] = [] - for (const file of remoteFiles) { - if (!localFiles.has(file) && !gitignore.shouldExclude(file)) { - remoteOnly.push(file) + const localDeleted: string[] = [] + const remoteDeleted: string[] = [] + const conflicts: string[] = [] + + for (const path of allPaths) { + if (gitignore.shouldExclude(path)) continue + + const inLocal = localFiles.has(path) + const inRemote = remoteFiles.has(path) + const inBase = baseFiles.has(path) + + const localHash = inLocal ? localManifest.files[path]!.hash : null + const remoteHash = inRemote ? remoteManifest!.files[path]!.hash : null + const baseHash = inBase ? baseManifest!.files[path]!.hash : null + + if (baseManifest) { + // Three-way diff against baseline + const localChanged_ = localHash !== baseHash + const remoteChanged_ = remoteHash !== baseHash + + if (localHash === remoteHash) { + // Both sides agree - no diff to report + continue + } + + if (!localChanged_ && remoteChanged_) { + // Only remote changed + if (inRemote && !inBase) { + remoteOnly.push(path) + } else if (!inRemote && inBase) { + remoteDeleted.push(path) + } else { + remoteChanged.push(path) + } + } else if (localChanged_ && !remoteChanged_) { + // Only local changed + if (inLocal && !inBase) { + localOnly.push(path) + } else if (!inLocal && inBase) { + localDeleted.push(path) + } else { + localChanged.push(path) + } + } else { + // Both changed to different values = conflict + conflicts.push(path) + } + } else { + // No baseline: fall back to two-way comparison + if (inLocal && inRemote) { + if (localHash !== remoteHash) { + conflicts.push(path) + } + } else if (inLocal && !inRemote) { + localOnly.push(path) + } else if (!inLocal && inRemote) { + remoteOnly.push(path) + } } } @@ -1022,7 +1194,7 @@ async function getManifestDiff(appName: string): Promise { const renamed: Rename[] = [] const remoteByHash = new Map() for (const file of remoteOnly) { - const hash = result.manifest!.files[file]!.hash + const hash = remoteManifest!.files[file]!.hash remoteByHash.set(hash, file) } @@ -1039,12 +1211,18 @@ async function getManifestDiff(appName: string): Promise { } return { - changed, + localChanged, + remoteChanged, localOnly: localOnly.filter(f => !matchedLocal.has(f)), remoteOnly: remoteOnly.filter(f => !matchedRemote.has(f)), + localDeleted, + remoteDeleted, + conflicts, renamed, localManifest, - remoteManifest: result.manifest ?? null, + remoteManifest, + baseManifest, + remoteVersion, } } diff --git a/src/cli/http.ts b/src/cli/http.ts index c879258..bb52ce5 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -55,12 +55,14 @@ export async function get(url: string): Promise { } } -export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> { +export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> { try { const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`)) if (res.status === 404) return { exists: false } if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - return { exists: true, manifest: await res.json() } + const data = await res.json() + const { version, ...manifest } = data + return { exists: true, manifest, version } } catch (error) { handleError(error) return null diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 960ce52..58d1e70 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -199,6 +199,7 @@ program .command('push') .helpGroup('Sync:') .description('Push local changes to server') + .option('-f, --force', 'overwrite remote changes') .action(pushApp) program diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 50e634a..22363d7 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -3,9 +3,28 @@ export type { FileInfo, Manifest } from '@types' import type { FileInfo, Manifest } from '@types' import { loadGitignore } from '@gitignore' import { createHash } from 'crypto' -import { readdirSync, readFileSync, statSync } from 'fs' +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs' import { join, relative } from 'path' +export interface SyncState { + version: string + manifest: Manifest +} + +export function readSyncState(appPath: string): SyncState | null { + const filePath = join(appPath, '.toes') + if (!existsSync(filePath)) return null + try { + return JSON.parse(readFileSync(filePath, 'utf-8')) + } catch { + return null + } +} + +export function writeSyncState(appPath: string, state: SyncState): void { + writeFileSync(join(appPath, '.toes'), JSON.stringify(state, null, 2)) +} + export function computeHash(content: Buffer | string): string { return createHash('sha256').update(content).digest('hex') } diff --git a/src/server/api/sync.ts b/src/server/api/sync.ts index c3fa421..2689503 100644 --- a/src/server/api/sync.ts +++ b/src/server/api/sync.ts @@ -147,8 +147,9 @@ router.get('/apps/:app/manifest', c => { if (!safeAppPath) return c.json({ error: 'Invalid path' }, 400) if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404) + const version = realpathSync(appPath).split('/').pop() const manifest = generateManifest(appPath, appName) - return c.json(manifest) + return c.json({ ...manifest, version }) }) router.get('/apps/:app/files/:path{.+}', c => { diff --git a/src/shared/gitignore.ts b/src/shared/gitignore.ts index 142b52c..1f5bc5c 100644 --- a/src/shared/gitignore.ts +++ b/src/shared/gitignore.ts @@ -5,6 +5,7 @@ const ALWAYS_EXCLUDE = [ 'node_modules', '.DS_Store', '.git', + '.toes', '*~', ]