summaryrefslogtreecommitdiff
path: root/src/cli/diff.ts
diff options
context:
space:
mode:
authorYurenHao0426 <blackhao0426@gmail.com>2026-09-13 04:05:38 +0000
committerYurenHao0426 <blackhao0426@gmail.com>2026-09-13 04:05:38 +0000
commitf3b2fc01082e754f7dfe73caa4f0a4f207a2adfb (patch)
treef6083a5abddc3541ad7996f71695e559b4b41ece /src/cli/diff.ts
parent34f22876ab9e15e9f14300a8c21cd7109800d1ab (diff)
Add headless CLI for Overleaf project management (lattex-cli)
New src/cli/ module providing a headless CLI for AI agents to work with Overleaf projects from a server with no display. Commands: auth, projects, clone, pull, status, push, compile. Key design decisions: - Uses Node.js https module (no Electron dependency), same approach as the existing MCP server in src/mcp/lattex.mjs - Reuses overleafProtocol.ts for Socket.IO v0.9 parsing (pure, no Electron) - Separate esbuild bundle (out/cli/lattex-cli.mjs) — does not touch the Electron app build - 64 tests for arg parsing, file tree walking, diff logic, and log parsing Files: - src/cli/main.ts — CLI entry point with all commands - src/cli/args.ts — argument parser - src/cli/overleafApi.ts — Overleaf API client using https + ws - src/cli/fileTree.ts — project root folder walker - src/cli/localState.ts — per-clone state (.lattex-cli.json) and auth storage - src/cli/diff.ts — local vs remote diff logic - src/cli/logParser.ts — LaTeX compile log parser - src/cli/test.ts — unit tests - tsconfig.cli.json — TypeScript config for CLI Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src/cli/diff.ts')
-rw-r--r--src/cli/diff.ts93
1 files changed, 93 insertions, 0 deletions
diff --git a/src/cli/diff.ts b/src/cli/diff.ts
new file mode 100644
index 0000000..e00c8c0
--- /dev/null
+++ b/src/cli/diff.ts
@@ -0,0 +1,93 @@
+// Diff logic for comparing local files against the last-pulled snapshot
+
+import { join, relative, sep } from 'path'
+import { readFile, readdir, stat } from 'fs/promises'
+import { createHash } from 'crypto'
+
+export type ChangeType = 'added' | 'modified' | 'deleted'
+
+export interface FileChange {
+ path: string // relative path
+ type: ChangeType
+ isText: boolean
+}
+
+// Text extensions matching Overleaf's set (from fileSyncBridge.ts)
+const TEXT_EXTENSIONS = new Set([
+ 'tex', 'latex', 'sty', 'cls', 'bst', 'bib', 'bibtex', 'txt', 'tikz',
+ 'mtx', 'rtex', 'md', 'asy', 'lbx', 'bbx', 'cbx', 'm', 'lco', 'dtx',
+ 'ins', 'ist', 'def', 'clo', 'ldf', 'rmd', 'qmd', 'lua', 'py', 'gv',
+ 'mf', 'yml', 'yaml', 'lhs', 'lean', 'lean4', 'hs', 'mk', 'xmpdata',
+ 'cfg', 'rnw', 'ltx', 'inc',
+ 'fd', 'r', 'sh', 'json', 'xml', 'csv', 'tsv', 'html', 'css', 'js',
+ 'ts', 'c', 'cpp', 'h', 'hpp', 'java', 'rb', 'pl'
+])
+
+const EDITABLE_FILENAMES = new Set(['latexmkrc', '.latexmkrc', 'makefile', 'gnumakefile'])
+
+export function isTextFile(relPath: string): boolean {
+ const name = relPath.split('/').pop()?.toLowerCase() || ''
+ if (EDITABLE_FILENAMES.has(name)) return true
+ const ext = name.split('.').pop() || ''
+ return TEXT_EXTENSIONS.has(ext)
+}
+
+/** Hash file contents with sha256 */
+export async function hashFile(absPath: string): Promise<string> {
+ const data = await readFile(absPath)
+ return createHash('sha256').update(data).digest('hex')
+}
+
+/** Walk a directory, returning relative paths (forward slashes, no leading /) */
+export async function walkDir(dir: string, base?: string): Promise<string[]> {
+ const root = base || dir
+ const results: string[] = []
+ try {
+ const entries = await readdir(dir, { withFileTypes: true })
+ for (const entry of entries) {
+ // Skip the state file and hidden files
+ if (entry.name === '.lattex-cli.json') continue
+ if (entry.name.startsWith('.')) continue
+ const full = join(dir, entry.name)
+ if (entry.isDirectory()) {
+ results.push(...await walkDir(full, root))
+ } else {
+ results.push(relative(root, full).split(sep).join('/'))
+ }
+ }
+ } catch { /* directory may not exist */ }
+ return results
+}
+
+/** Compute diff between local files and stored hashes from last pull */
+export async function computeDiff(
+ dir: string,
+ storedHashes: Record<string, string>,
+ knownPaths: Set<string>
+): Promise<FileChange[]> {
+ const changes: FileChange[] = []
+ const localFiles = await walkDir(dir)
+ const localSet = new Set(localFiles)
+
+ // Check for modified and added files
+ for (const relPath of localFiles) {
+ const absPath = join(dir, relPath)
+ const hash = await hashFile(absPath)
+ const storedHash = storedHashes[relPath]
+
+ if (!storedHash && !knownPaths.has(relPath)) {
+ changes.push({ path: relPath, type: 'added', isText: isTextFile(relPath) })
+ } else if (storedHash && hash !== storedHash) {
+ changes.push({ path: relPath, type: 'modified', isText: isTextFile(relPath) })
+ }
+ }
+
+ // Check for deleted files
+ for (const relPath of Object.keys(storedHashes)) {
+ if (!localSet.has(relPath)) {
+ changes.push({ path: relPath, type: 'deleted', isText: isTextFile(relPath) })
+ }
+ }
+
+ return changes
+}