summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cli/args.ts39
-rw-r--r--src/cli/diff.ts93
-rw-r--r--src/cli/fileTree.ts69
-rw-r--r--src/cli/localState.ts76
-rw-r--r--src/cli/logParser.ts99
-rw-r--r--src/cli/main.ts752
-rw-r--r--src/cli/overleafApi.ts518
-rw-r--r--src/cli/test.ts245
8 files changed, 1891 insertions, 0 deletions
diff --git a/src/cli/args.ts b/src/cli/args.ts
new file mode 100644
index 0000000..0f7a767
--- /dev/null
+++ b/src/cli/args.ts
@@ -0,0 +1,39 @@
+// CLI argument parser
+
+export interface ParsedArgs {
+ command: string
+ positional: string[]
+ flags: Record<string, string | boolean>
+}
+
+export function parseArgs(argv: string[]): ParsedArgs {
+ const args = argv.slice(2)
+ const command = args[0] || ''
+ const positional: string[] = []
+ const flags: Record<string, string | boolean> = {}
+
+ for (let i = 1; i < args.length; i++) {
+ const arg = args[i]
+ if (arg.startsWith('--')) {
+ const eqIdx = arg.indexOf('=')
+ if (eqIdx !== -1) {
+ flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1)
+ } else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
+ const key = arg.slice(2)
+ // Boolean flags that never take a value
+ const boolFlags = new Set(['json', 'dry-run', 'delete', 'force', 'help'])
+ if (boolFlags.has(key)) {
+ flags[key] = true
+ } else {
+ flags[key] = args[++i]
+ }
+ } else {
+ flags[arg.slice(2)] = true
+ }
+ } else {
+ positional.push(arg)
+ }
+ }
+
+ return { command, positional, flags }
+}
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
+}
diff --git a/src/cli/fileTree.ts b/src/cli/fileTree.ts
new file mode 100644
index 0000000..736b1e1
--- /dev/null
+++ b/src/cli/fileTree.ts
@@ -0,0 +1,69 @@
+// Walk Overleaf project root folder into flat maps (same logic as main/index.ts walkRootFolder)
+
+export interface FileTreeEntry {
+ name: string
+ path: string // relative path, forward slashes
+ isDir: boolean
+ docId?: string // text docs
+ fileRefId?: string // binary files
+}
+
+export interface FileTreeResult {
+ entries: FileTreeEntry[]
+ docPathMap: Record<string, string> // docId → relPath
+ pathDocMap: Record<string, string> // relPath → docId
+ fileRefs: Array<{ id: string; path: string }>
+ folderMap: Record<string, string> // folderId → relPath ('' for root)
+ pathFolderMap: Record<string, string> // relPath → folderId
+ rootFolderId: string
+}
+
+interface FolderLike {
+ _id: string
+ name: string
+ docs?: Array<{ _id: string; name: string }>
+ fileRefs?: Array<{ _id: string; name: string }>
+ folders?: FolderLike[]
+}
+
+export function walkRootFolder(rootFolder: FolderLike[]): FileTreeResult {
+ const docPathMap: Record<string, string> = {}
+ const pathDocMap: Record<string, string> = {}
+ const fileRefs: Array<{ id: string; path: string }> = []
+ const folderMap: Record<string, string> = {}
+ const pathFolderMap: Record<string, string> = {}
+ const entries: FileTreeEntry[] = []
+ const rootFolderId = rootFolder[0]?._id || ''
+
+ function walk(f: FolderLike, prefix: string): void {
+ // Register this folder
+ const folderPath = prefix ? prefix.slice(0, -1) : '' // remove trailing /
+ folderMap[f._id] = folderPath
+ pathFolderMap[folderPath] = f._id
+
+ for (const doc of f.docs || []) {
+ const relPath = prefix + doc.name
+ docPathMap[doc._id] = relPath
+ pathDocMap[relPath] = doc._id
+ entries.push({ name: doc.name, path: relPath, isDir: false, docId: doc._id })
+ }
+
+ for (const ref of f.fileRefs || []) {
+ const relPath = prefix + ref.name
+ fileRefs.push({ id: ref._id, path: relPath })
+ entries.push({ name: ref.name, path: relPath, isDir: false, fileRefId: ref._id })
+ }
+
+ for (const sub of f.folders || []) {
+ const subPrefix = prefix + sub.name + '/'
+ entries.push({ name: sub.name, path: subPrefix.slice(0, -1), isDir: true })
+ walk(sub, subPrefix)
+ }
+ }
+
+ if (rootFolder[0]) {
+ walk(rootFolder[0], '')
+ }
+
+ return { entries, docPathMap, pathDocMap, fileRefs, folderMap, pathFolderMap, rootFolderId }
+}
diff --git a/src/cli/localState.ts b/src/cli/localState.ts
new file mode 100644
index 0000000..73a1060
--- /dev/null
+++ b/src/cli/localState.ts
@@ -0,0 +1,76 @@
+// Manages per-clone state stored in <dir>/.lattex-cli.json
+// and the global auth credential at ~/.config/lattex/auth.json
+
+import { join } from 'path'
+import { readFile, writeFile, mkdir, chmod } from 'fs/promises'
+import { existsSync } from 'fs'
+import { homedir } from 'os'
+
+const CONFIG_DIR = join(homedir(), '.config', 'lattex')
+const AUTH_PATH = join(CONFIG_DIR, 'auth.json')
+const STATE_FILE = '.lattex-cli.json'
+
+// ── Global auth ──
+
+export async function saveCookie(cookie: string): Promise<void> {
+ await mkdir(CONFIG_DIR, { recursive: true })
+ await writeFile(AUTH_PATH, JSON.stringify({ cookie }), { mode: 0o600 })
+ // Ensure directory and file are user-only
+ await chmod(CONFIG_DIR, 0o700).catch(() => {})
+ await chmod(AUTH_PATH, 0o600).catch(() => {})
+}
+
+export async function loadCookie(): Promise<string | null> {
+ try {
+ const data = JSON.parse(await readFile(AUTH_PATH, 'utf-8'))
+ return data.cookie || null
+ } catch {
+ return null
+ }
+}
+
+// ── Per-clone state ──
+
+export interface CloneState {
+ projectId: string
+ projectName: string
+ /** docId → relPath */
+ docPathMap: Record<string, string>
+ /** relPath → docId */
+ pathDocMap: Record<string, string>
+ /** fileRefId → relPath */
+ fileRefPathMap: Record<string, string>
+ /** relPath → fileRefId */
+ pathFileRefMap: Record<string, string>
+ /** folderId → relDirPath */
+ folderMap: Record<string, string>
+ /** relDirPath → folderId */
+ pathFolderMap: Record<string, string>
+ rootFolderId: string
+ rootDocId: string
+ /** ISO timestamp of last pull */
+ lastPull: string
+ /** sha256 hash of each file at last pull (relPath → hash) */
+ fileHashes: Record<string, string>
+}
+
+export function statePath(dir: string): string {
+ return join(dir, STATE_FILE)
+}
+
+export async function loadState(dir: string): Promise<CloneState | null> {
+ const p = statePath(dir)
+ try {
+ return JSON.parse(await readFile(p, 'utf-8'))
+ } catch {
+ return null
+ }
+}
+
+export async function saveState(dir: string, state: CloneState): Promise<void> {
+ await writeFile(statePath(dir), JSON.stringify(state, null, 2))
+}
+
+export function hasState(dir: string): boolean {
+ return existsSync(statePath(dir))
+}
diff --git a/src/cli/logParser.ts b/src/cli/logParser.ts
new file mode 100644
index 0000000..d59d7df
--- /dev/null
+++ b/src/cli/logParser.ts
@@ -0,0 +1,99 @@
+// LaTeX compile log parser (extracted from src/mcp/lattex.mjs)
+
+export interface LogEntry {
+ level: 'error' | 'warning'
+ message: string
+ file?: string
+ line?: number
+}
+
+export function parseCompileLog(raw: string): LogEntry[] {
+ const entries: LogEntry[] = []
+ const lines = raw.split('\n')
+
+ for (let i = 0; i < lines.length; i++) {
+ const ln = lines[i]
+
+ if (/^!/.test(ln) || /LaTeX Error:/.test(ln)) {
+ let msg = ln.replace(/^!\s*/, '')
+ while (i + 1 < lines.length && lines[i + 1] && !lines[i + 1].startsWith('l.') && !lines[i + 1].startsWith('!')) {
+ i++
+ if (lines[i].trim()) msg += ' ' + lines[i].trim()
+ }
+ let lineNum: number | undefined
+ if (i + 1 < lines.length && /^l\.(\d+)/.test(lines[i + 1])) {
+ i++
+ lineNum = parseInt(lines[i].match(/^l\.(\d+)/)![1])
+ }
+ entries.push({ level: 'error', message: msg.trim(), line: lineNum })
+ continue
+ }
+
+ const fileLineErr = ln.match(/^\.\/(.+?):(\d+):\s*(.+)/)
+ if (fileLineErr) {
+ const msg = fileLineErr[3]
+ const isWarning = /warning/i.test(msg)
+ entries.push({
+ level: isWarning ? 'warning' : 'error',
+ message: msg,
+ file: fileLineErr[1],
+ line: parseInt(fileLineErr[2])
+ })
+ continue
+ }
+
+ const pkgWarn = ln.match(/Package (\S+) Warning:\s*(.*)/)
+ if (pkgWarn) {
+ let msg = `[${pkgWarn[1]}] ${pkgWarn[2]}`
+ let warnLine: number | undefined
+ while (i + 1 < lines.length && /^\(/.test(lines[i + 1])) {
+ i++
+ msg += ' ' + lines[i].replace(/^\([^)]*\)\s*/, '').trim()
+ const lineMatch = lines[i].match(/on input line (\d+)/)
+ if (lineMatch) warnLine = parseInt(lineMatch[1])
+ }
+ if (!warnLine) {
+ const lineMatch = msg.match(/on input line (\d+)/)
+ if (lineMatch) warnLine = parseInt(lineMatch[1])
+ }
+ entries.push({ level: 'warning', message: msg.trim(), line: warnLine })
+ continue
+ }
+
+ const latexWarn = ln.match(/LaTeX Warning:\s*(.*)/)
+ if (latexWarn) {
+ let msg = latexWarn[1]
+ while (i + 1 < lines.length && lines[i + 1] && !lines[i + 1].match(/^[(!.]/) && lines[i + 1].startsWith(' ')) {
+ i++
+ msg += ' ' + lines[i].trim()
+ }
+ const lineMatch = msg.match(/on input line (\d+)/)
+ entries.push({ level: 'warning', message: msg.trim(), line: lineMatch ? parseInt(lineMatch[1]) : undefined })
+ continue
+ }
+
+ if (/^(Overfull|Underfull)/.test(ln)) {
+ const paraMatch = ln.match(/at lines (\d+)--(\d+)/) || ln.match(/in paragraph at lines (\d+)--(\d+)/)
+ entries.push({ level: 'warning', message: ln.trim(), line: paraMatch ? parseInt(paraMatch[1]) : undefined })
+ continue
+ }
+
+ if (/File .* not found/.test(ln)) {
+ entries.push({ level: 'error', message: ln.trim() })
+ }
+ }
+
+ // Deduplicate
+ const seen = new Set<string>()
+ return entries.filter((e) => {
+ const key = `${e.level}:${e.message}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
+export function formatEntry(e: LogEntry): string {
+ const loc = [e.file, e.line].filter(Boolean).join(':')
+ return `[${e.level.toUpperCase()}]${loc ? ` ${loc}:` : ''} ${e.message}`
+}
diff --git a/src/cli/main.ts b/src/cli/main.ts
new file mode 100644
index 0000000..1fa9fce
--- /dev/null
+++ b/src/cli/main.ts
@@ -0,0 +1,752 @@
+#!/usr/bin/env node
+// lattex-cli: headless CLI for Overleaf project management
+// Designed for AI agents — short output, JSON mode, meaningful exit codes
+
+import { join, resolve, basename, dirname } from 'path'
+import { readFile, writeFile, mkdir, unlink } from 'fs/promises'
+import { existsSync } from 'fs'
+import { createHash } from 'crypto'
+import https from 'https'
+import { OverleafApi } from './overleafApi'
+import { walkRootFolder, type FileTreeResult } from './fileTree'
+import { saveCookie, loadCookie, loadState, saveState, hasState, type CloneState } from './localState'
+import { computeDiff, hashFile, walkDir, isTextFile, type FileChange } from './diff'
+import { parseCompileLog, formatEntry } from './logParser'
+import { parseArgs } from './args'
+
+// ── Exit codes ──
+const EXIT_OK = 0
+const EXIT_ERROR = 1
+const EXIT_AUTH = 2
+const EXIT_CONFLICT = 3
+const EXIT_USAGE = 64
+
+// ── Output helpers ──
+let jsonMode = false
+
+function out(text: string): void {
+ process.stdout.write(text + '\n')
+}
+
+function err(text: string): void {
+ process.stderr.write(text + '\n')
+}
+
+function jsonOut(data: unknown): void {
+ out(JSON.stringify(data, null, 2))
+}
+
+function exitWith(code: number, message?: string): never {
+ if (message) {
+ if (jsonMode) {
+ jsonOut({ error: message })
+ } else {
+ err(message)
+ }
+ }
+ process.exit(code)
+}
+
+// ── Auth ──
+
+async function resolveAuth(flags: Record<string, string | boolean>): Promise<string> {
+ // 1. --cookie flag
+ if (typeof flags.cookie === 'string' && flags.cookie) {
+ return flags.cookie
+ }
+ // 2. LATTEX_COOKIE env
+ if (process.env.LATTEX_COOKIE) {
+ return process.env.LATTEX_COOKIE
+ }
+ // 3. --from-cdp (Chrome DevTools Protocol)
+ if (typeof flags['from-cdp'] === 'string' && flags['from-cdp']) {
+ return await getCookieFromCDP(flags['from-cdp'])
+ }
+ // 4. Stored cookie
+ const stored = await loadCookie()
+ if (stored) return stored
+
+ exitWith(EXIT_AUTH, 'No auth found. Use: lattex-cli auth --cookie "..." or set LATTEX_COOKIE')
+}
+
+async function getCookieFromCDP(endpoint: string): Promise<string> {
+ // Fetch cookies from a Chromium browser via CDP
+ const url = endpoint.replace(/\/$/, '')
+
+ // First get the websocket debugger URL
+ const targetsUrl = `${url}/json`
+ const targets: any[] = await new Promise((resolve, reject) => {
+ const mod = targetsUrl.startsWith('https') ? https : require('http')
+ mod.get(targetsUrl, (res: any) => {
+ let body = ''
+ res.on('data', (chunk: string) => { body += chunk })
+ res.on('end', () => {
+ try { resolve(JSON.parse(body)) } catch { reject(new Error('Invalid CDP response')) }
+ })
+ }).on('error', reject)
+ })
+
+ // Find an Overleaf page or use the first target
+ const target = targets.find((t: any) =>
+ t.url?.includes('overleaf.com')
+ ) || targets[0]
+
+ if (!target?.webSocketDebuggerUrl) {
+ throw new Error('No debuggable target found')
+ }
+
+ // Connect via WebSocket to get cookies
+ const { default: WebSocket } = await import('ws')
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(target.webSocketDebuggerUrl)
+ const timeout = setTimeout(() => { ws.close(); reject(new Error('CDP timeout')) }, 10000)
+
+ ws.on('open', () => {
+ ws.send(JSON.stringify({
+ id: 1,
+ method: 'Network.getCookies',
+ params: { urls: ['https://www.overleaf.com'] }
+ }))
+ })
+
+ ws.on('message', (data: Buffer) => {
+ clearTimeout(timeout)
+ try {
+ const msg = JSON.parse(data.toString())
+ if (msg.id === 1 && msg.result?.cookies) {
+ const cookies = msg.result.cookies
+ .filter((c: any) => c.domain?.includes('overleaf.com'))
+ .map((c: any) => `${c.name}=${c.value}`)
+ .join('; ')
+ ws.close()
+ if (!cookies) reject(new Error('No Overleaf cookies found in browser'))
+ else resolve(cookies)
+ }
+ } catch (e) {
+ ws.close()
+ reject(e)
+ }
+ })
+
+ ws.on('error', (e) => { clearTimeout(timeout); reject(e) })
+ })
+}
+
+// ── Commands ──
+
+async function cmdAuth(flags: Record<string, string | boolean>): Promise<void> {
+ let cookie: string
+
+ if (typeof flags.cookie === 'string') {
+ cookie = flags.cookie
+ } else if (process.env.LATTEX_COOKIE) {
+ cookie = process.env.LATTEX_COOKIE
+ } else if (typeof flags['from-cdp'] === 'string') {
+ cookie = await getCookieFromCDP(flags['from-cdp'])
+ } else {
+ exitWith(EXIT_USAGE, 'Usage: lattex-cli auth --cookie "..." | --from-cdp URL | env LATTEX_COOKIE')
+ }
+
+ const api = new OverleafApi(cookie!)
+ const valid = await api.verifySession()
+ if (!valid) {
+ exitWith(EXIT_AUTH, 'Session cookie is invalid or expired')
+ }
+
+ await saveCookie(cookie!)
+
+ if (jsonMode) {
+ jsonOut({ ok: true })
+ } else {
+ out('Auth saved to ~/.config/lattex/auth.json')
+ }
+}
+
+async function cmdProjects(flags: Record<string, string | boolean>): Promise<void> {
+ const cookie = await resolveAuth(flags)
+ const api = new OverleafApi(cookie)
+
+ const projects = await api.listProjects()
+ if (jsonMode) {
+ jsonOut(projects.map(p => ({
+ id: p.id,
+ name: p.name,
+ lastUpdated: p.lastUpdated,
+ accessLevel: p.accessLevel
+ })))
+ } else {
+ if (projects.length === 0) {
+ out('No projects found.')
+ return
+ }
+ for (const p of projects) {
+ const date = p.lastUpdated ? new Date(p.lastUpdated).toISOString().slice(0, 10) : ''
+ out(`${p.id} ${p.name} ${date} ${p.accessLevel}`)
+ }
+ }
+}
+
+async function cmdClone(positional: string[], flags: Record<string, string | boolean>): Promise<void> {
+ const projectRef = positional[0]
+ const targetDir = positional[1]
+ if (!projectRef || !targetDir) {
+ exitWith(EXIT_USAGE, 'Usage: lattex-cli clone <project-id|name> <dir>')
+ }
+
+ const cookie = await resolveAuth(flags)
+ const api = new OverleafApi(cookie)
+ const dir = resolve(targetDir)
+
+ // Resolve project ID from name if needed
+ let projectId = projectRef
+ if (!projectRef.match(/^[0-9a-f]{24}$/)) {
+ const projects = await api.listProjects()
+ const match = projects.find(p => p.name === projectRef)
+ if (!match) {
+ exitWith(EXIT_ERROR, `Project not found: ${projectRef}`)
+ }
+ projectId = match!.id
+ }
+
+ // Get project data via WebSocket
+ err(`Connecting to project ${projectId}...`)
+ const projectData = await api.getProjectData(projectId)
+ const tree = walkRootFolder(projectData.project.rootFolder)
+
+ // Create directory
+ await mkdir(dir, { recursive: true })
+
+ // Download all docs
+ const fileHashes: Record<string, string> = {}
+ let docCount = 0
+ let fileCount = 0
+
+ for (const [docId, relPath] of Object.entries(tree.docPathMap)) {
+ err(` doc: ${relPath}`)
+ const doc = await api.getDocContent(projectId, docId)
+ const content = doc.lines.join('\n')
+ const absPath = join(dir, relPath)
+ await mkdir(dirname(absPath), { recursive: true })
+ await writeFile(absPath, content, 'utf-8')
+ fileHashes[relPath] = createHash('sha256').update(content).digest('hex')
+ docCount++
+ }
+
+ // Download all binary files
+ for (const ref of tree.fileRefs) {
+ err(` file: ${ref.path}`)
+ const data = await api.downloadFile(projectId, ref.id)
+ const absPath = join(dir, ref.path)
+ await mkdir(dirname(absPath), { recursive: true })
+ await writeFile(absPath, data)
+ fileHashes[ref.path] = createHash('sha256').update(data).digest('hex')
+ fileCount++
+ }
+
+ // Build reverse maps
+ const fileRefPathMap: Record<string, string> = {}
+ const pathFileRefMap: Record<string, string> = {}
+ for (const ref of tree.fileRefs) {
+ fileRefPathMap[ref.id] = ref.path
+ pathFileRefMap[ref.path] = ref.id
+ }
+
+ // Save state
+ const state: CloneState = {
+ projectId,
+ projectName: projectData.project.name,
+ docPathMap: tree.docPathMap,
+ pathDocMap: tree.pathDocMap,
+ fileRefPathMap,
+ pathFileRefMap,
+ folderMap: tree.folderMap,
+ pathFolderMap: tree.pathFolderMap,
+ rootFolderId: tree.rootFolderId,
+ rootDocId: projectData.project.rootDoc_id,
+ lastPull: new Date().toISOString(),
+ fileHashes
+ }
+ await saveState(dir, state)
+
+ if (jsonMode) {
+ jsonOut({ ok: true, projectId, projectName: projectData.project.name, docs: docCount, files: fileCount })
+ } else {
+ out(`Cloned "${projectData.project.name}" → ${dir} (${docCount} docs, ${fileCount} files)`)
+ }
+}
+
+async function cmdPull(positional: string[], flags: Record<string, string | boolean>): Promise<void> {
+ const dir = resolve(positional[0] || '.')
+ const state = await loadState(dir)
+ if (!state) exitWith(EXIT_ERROR, `Not a lattex clone: ${dir}`)
+
+ const cookie = await resolveAuth(flags)
+ const api = new OverleafApi(cookie)
+
+ err(`Pulling project ${state!.projectName}...`)
+ const projectData = await api.getProjectData(state!.projectId)
+ const tree = walkRootFolder(projectData.project.rootFolder)
+
+ const fileHashes: Record<string, string> = {}
+ let updated = 0
+
+ // Update/create docs
+ for (const [docId, relPath] of Object.entries(tree.docPathMap)) {
+ const doc = await api.getDocContent(state!.projectId, docId)
+ const content = doc.lines.join('\n')
+ const newHash = createHash('sha256').update(content).digest('hex')
+
+ if (newHash !== state!.fileHashes[relPath]) {
+ err(` updated: ${relPath}`)
+ const absPath = join(dir, relPath)
+ await mkdir(dirname(absPath), { recursive: true })
+ await writeFile(absPath, content, 'utf-8')
+ updated++
+ }
+ fileHashes[relPath] = newHash
+ }
+
+ // Update/create binary files
+ const fileRefPathMap: Record<string, string> = {}
+ const pathFileRefMap: Record<string, string> = {}
+ for (const ref of tree.fileRefs) {
+ fileRefPathMap[ref.id] = ref.path
+ pathFileRefMap[ref.path] = ref.id
+
+ const data = await api.downloadFile(state!.projectId, ref.id)
+ const newHash = createHash('sha256').update(data).digest('hex')
+
+ if (newHash !== state!.fileHashes[ref.path]) {
+ err(` updated: ${ref.path}`)
+ const absPath = join(dir, ref.path)
+ await mkdir(dirname(absPath), { recursive: true })
+ await writeFile(absPath, data)
+ updated++
+ }
+ fileHashes[ref.path] = newHash
+ }
+
+ // Remove local files that no longer exist remotely
+ const remotePaths = new Set([
+ ...Object.values(tree.docPathMap),
+ ...tree.fileRefs.map(r => r.path)
+ ])
+ for (const relPath of Object.keys(state!.fileHashes)) {
+ if (!remotePaths.has(relPath)) {
+ const absPath = join(dir, relPath)
+ try { await unlink(absPath) } catch { /* ok */ }
+ err(` removed: ${relPath}`)
+ updated++
+ }
+ }
+
+ // Update state
+ const newState: CloneState = {
+ ...state!,
+ docPathMap: tree.docPathMap,
+ pathDocMap: tree.pathDocMap,
+ fileRefPathMap,
+ pathFileRefMap,
+ folderMap: tree.folderMap,
+ pathFolderMap: tree.pathFolderMap,
+ rootFolderId: tree.rootFolderId,
+ rootDocId: projectData.project.rootDoc_id,
+ projectName: projectData.project.name,
+ lastPull: new Date().toISOString(),
+ fileHashes
+ }
+ await saveState(dir, newState)
+
+ if (jsonMode) {
+ jsonOut({ ok: true, updated })
+ } else {
+ out(`Pull complete: ${updated} file(s) updated`)
+ }
+}
+
+async function cmdStatus(positional: string[], flags: Record<string, string | boolean>): Promise<void> {
+ const dir = resolve(positional[0] || '.')
+ const state = await loadState(dir)
+ if (!state) exitWith(EXIT_ERROR, `Not a lattex clone: ${dir}`)
+
+ const knownPaths = new Set([
+ ...Object.values(state!.docPathMap),
+ ...Object.values(state!.fileRefPathMap)
+ ])
+
+ const changes = await computeDiff(dir, state!.fileHashes, knownPaths)
+
+ if (jsonMode) {
+ jsonOut({
+ projectId: state!.projectId,
+ projectName: state!.projectName,
+ lastPull: state!.lastPull,
+ changes: changes.map(c => ({ path: c.path, type: c.type }))
+ })
+ } else {
+ out(`Project: ${state!.projectName} (${state!.projectId})`)
+ out(`Last pull: ${state!.lastPull}`)
+ if (changes.length === 0) {
+ out('No local changes.')
+ } else {
+ out(`${changes.length} change(s):`)
+ for (const c of changes) {
+ const prefix = c.type === 'added' ? 'A' : c.type === 'modified' ? 'M' : 'D'
+ out(` ${prefix} ${c.path}`)
+ }
+ }
+ }
+}
+
+async function cmdPush(positional: string[], flags: Record<string, string | boolean>): Promise<void> {
+ const dir = resolve(positional[0] || '.')
+ const state = await loadState(dir)
+ if (!state) exitWith(EXIT_ERROR, `Not a lattex clone: ${dir}`)
+
+ const dryRun = !!flags['dry-run']
+ const allowDelete = !!flags['delete']
+ const force = !!flags['force']
+
+ const cookie = await resolveAuth(flags)
+ const api = new OverleafApi(cookie)
+
+ // Check for remote changes (unless --force)
+ if (!force) {
+ err('Checking for remote changes...')
+ const projectData = await api.getProjectData(state!.projectId)
+ const remoteTree = walkRootFolder(projectData.project.rootFolder)
+
+ // Quick check: compare doc/file counts and paths
+ const remotePaths = new Set([
+ ...Object.values(remoteTree.docPathMap),
+ ...remoteTree.fileRefs.map(r => r.path)
+ ])
+ const localKnownPaths = new Set([
+ ...Object.values(state!.docPathMap),
+ ...Object.values(state!.fileRefPathMap)
+ ])
+
+ // If remote has files we don't know about, it has changed
+ for (const rp of remotePaths) {
+ if (!localKnownPaths.has(rp) && !state!.fileHashes[rp]) {
+ exitWith(EXIT_CONFLICT, `Remote has changed (new file: ${rp}). Pull first, or use --force.`)
+ }
+ }
+ }
+
+ // Compute local changes
+ const knownPaths = new Set([
+ ...Object.values(state!.docPathMap),
+ ...Object.values(state!.fileRefPathMap)
+ ])
+ const changes = await computeDiff(dir, state!.fileHashes, knownPaths)
+
+ if (changes.length === 0) {
+ if (jsonMode) jsonOut({ ok: true, pushed: 0 })
+ else out('Nothing to push.')
+ return
+ }
+
+ // Filter out deletes unless --delete
+ const toProcess = changes.filter(c => {
+ if (c.type === 'deleted' && !allowDelete) {
+ err(` skip delete: ${c.path} (use --delete to remove remote files)`)
+ return false
+ }
+ return true
+ })
+
+ if (dryRun) {
+ if (jsonMode) {
+ jsonOut({ dryRun: true, changes: toProcess.map(c => ({ path: c.path, type: c.type })) })
+ } else {
+ out(`Dry run — ${toProcess.length} change(s) would be pushed:`)
+ for (const c of toProcess) {
+ const prefix = c.type === 'added' ? 'A' : c.type === 'modified' ? 'M' : 'D'
+ out(` ${prefix} ${c.path}`)
+ }
+ }
+ return
+ }
+
+ // Ensure CSRF token
+ await api.refreshCsrf()
+
+ let pushed = 0
+ for (const change of toProcess) {
+ const relPath = change.path
+ const absPath = join(dir, relPath)
+
+ if (change.type === 'deleted') {
+ // Delete from remote
+ const docId = state!.pathDocMap[relPath]
+ if (docId) {
+ err(` delete doc: ${relPath}`)
+ await api.deleteEntity(state!.projectId, 'doc', docId)
+ delete state!.docPathMap[docId]
+ delete state!.pathDocMap[relPath]
+ }
+ const fileRefId = state!.pathFileRefMap[relPath]
+ if (fileRefId) {
+ err(` delete file: ${relPath}`)
+ await api.deleteEntity(state!.projectId, 'file', fileRefId)
+ delete state!.fileRefPathMap[fileRefId]
+ delete state!.pathFileRefMap[relPath]
+ }
+ delete state!.fileHashes[relPath]
+ pushed++
+ continue
+ }
+
+ if (change.type === 'added') {
+ // Ensure parent folder exists
+ const parentDir = dirname(relPath)
+ const folderId = await ensureFolder(api, state!, parentDir === '.' ? '' : parentDir)
+
+ if (change.isText) {
+ err(` create doc: ${relPath}`)
+ const content = await readFile(absPath, 'utf-8')
+ const docId = await api.createDoc(state!.projectId, folderId, basename(relPath))
+
+ // Set content via WebSocket OT
+ const doc = await api.getDocContent(state!.projectId, docId)
+ const serverContent = doc.lines.join('\n')
+ if (content !== serverContent) {
+ // Upload as a replacement — use the upload API which handles both new and existing
+ const fileData = Buffer.from(content, 'utf-8')
+ await api.uploadFile(state!.projectId, folderId, basename(relPath), fileData, 'text/plain')
+ }
+
+ state!.docPathMap[docId] = relPath
+ state!.pathDocMap[relPath] = docId
+ state!.fileHashes[relPath] = createHash('sha256').update(content).digest('hex')
+ } else {
+ err(` upload file: ${relPath}`)
+ const data = await readFile(absPath)
+ const ext = basename(relPath).split('.').pop()?.toLowerCase() || ''
+ const mimeMap: Record<string, string> = {
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
+ svg: 'image/svg+xml', pdf: 'application/pdf', eps: 'application/postscript',
+ zip: 'application/zip'
+ }
+ const result = await api.uploadFile(state!.projectId, folderId, basename(relPath), data, mimeMap[ext] || 'application/octet-stream')
+ if (result.error) {
+ err(` ERROR: ${result.error}`)
+ continue
+ }
+ if (result.entityId) {
+ state!.fileRefPathMap[result.entityId] = relPath
+ state!.pathFileRefMap[relPath] = result.entityId
+ }
+ state!.fileHashes[relPath] = createHash('sha256').update(data).digest('hex')
+ }
+ pushed++
+ continue
+ }
+
+ if (change.type === 'modified') {
+ if (change.isText) {
+ // For text docs, upload via the upload API (replaces content)
+ const docId = state!.pathDocMap[relPath]
+ const parentDir = dirname(relPath)
+ const folderId = state!.pathFolderMap[parentDir === '.' ? '' : parentDir] || state!.rootFolderId
+
+ err(` update doc: ${relPath}`)
+ const content = await readFile(absPath, 'utf-8')
+ const fileData = Buffer.from(content, 'utf-8')
+ await api.uploadFile(state!.projectId, folderId, basename(relPath), fileData, 'text/plain')
+ state!.fileHashes[relPath] = createHash('sha256').update(content).digest('hex')
+ } else {
+ // Binary file — upload replaces
+ const parentDir = dirname(relPath)
+ const folderId = state!.pathFolderMap[parentDir === '.' ? '' : parentDir] || state!.rootFolderId
+
+ err(` update file: ${relPath}`)
+ const data = await readFile(absPath)
+ const ext = basename(relPath).split('.').pop()?.toLowerCase() || ''
+ const mimeMap: Record<string, string> = {
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
+ pdf: 'application/pdf', eps: 'application/postscript'
+ }
+ await api.uploadFile(state!.projectId, folderId, basename(relPath), data, mimeMap[ext] || 'application/octet-stream')
+ state!.fileHashes[relPath] = createHash('sha256').update(data).digest('hex')
+ }
+ pushed++
+ }
+ }
+
+ await saveState(dir, state!)
+
+ if (jsonMode) {
+ jsonOut({ ok: true, pushed })
+ } else {
+ out(`Pushed ${pushed} change(s)`)
+ }
+}
+
+async function ensureFolder(api: OverleafApi, state: CloneState, dirPath: string): Promise<string> {
+ if (!dirPath || dirPath === '.') return state.rootFolderId
+
+ const existing = state.pathFolderMap[dirPath]
+ if (existing) return existing
+
+ // Create parent first
+ const parts = dirPath.split('/')
+ const parentDir = parts.slice(0, -1).join('/')
+ const parentId = await ensureFolder(api, state, parentDir || '')
+
+ const name = parts[parts.length - 1]
+ const folderId = await api.createFolder(state.projectId, parentId, name)
+ state.folderMap[folderId] = dirPath
+ state.pathFolderMap[dirPath] = folderId
+ return folderId
+}
+
+async function cmdCompile(positional: string[], flags: Record<string, string | boolean>): Promise<void> {
+ const dir = resolve(positional[0] || '.')
+ const state = await loadState(dir)
+ if (!state) exitWith(EXIT_ERROR, `Not a lattex clone: ${dir}`)
+
+ const cookie = await resolveAuth(flags)
+ const api = new OverleafApi(cookie)
+ const outFile = typeof flags.out === 'string' ? flags.out : undefined
+
+ err('Compiling...')
+
+ // Compile on Overleaf server
+ const compileResult = await api.compile(state!.projectId, state!.rootDocId)
+
+ // Fetch log
+ let logText = ''
+ const logFile = compileResult.outputFiles.find(f => f.path === 'output.log')
+ if (logFile) {
+ const logUrl = buildOutputUrl(logFile, compileResult)
+ logText = await api.fetchText(logUrl)
+ }
+
+ // Download PDF
+ const pdfFile = compileResult.outputFiles.find(f => f.path === 'output.pdf')
+ if (pdfFile && compileResult.status === 'success') {
+ const pdfUrl = buildOutputUrl(pdfFile, compileResult)
+ const pdfData = await api.downloadOutputFile(pdfFile, compileResult)
+ const pdfPath = outFile || join(dir, 'output.pdf')
+ await writeFile(pdfPath, pdfData)
+ err(` PDF saved: ${pdfPath}`)
+ }
+
+ // Parse log
+ const entries = parseCompileLog(logText)
+ const errors = entries.filter(e => e.level === 'error')
+ const warnings = entries.filter(e => e.level === 'warning')
+
+ if (jsonMode) {
+ jsonOut({
+ status: compileResult.status,
+ errors: errors.map(e => ({ message: e.message, file: e.file, line: e.line })),
+ warnings: warnings.length,
+ pdfPath: pdfFile ? (outFile || join(dir, 'output.pdf')) : null
+ })
+ } else {
+ if (compileResult.status === 'success') {
+ out(`Compile OK${warnings.length ? ` (${warnings.length} warning(s))` : ''}`)
+ } else {
+ out(`Compile FAILED (${compileResult.status})`)
+ }
+ if (errors.length > 0) {
+ for (const e of errors.slice(0, 10)) {
+ out(` ${formatEntry(e)}`)
+ }
+ if (errors.length > 10) out(` ... and ${errors.length - 10} more`)
+ }
+ if (warnings.length > 0 && warnings.length <= 5) {
+ for (const w of warnings) {
+ out(` ${formatEntry(w)}`)
+ }
+ }
+ }
+
+ process.exit(compileResult.status === 'success' ? EXIT_OK : EXIT_ERROR)
+}
+
+function buildOutputUrl(
+ file: { url: string; build?: string },
+ data: { pdfDownloadDomain?: string; compileGroup?: string; clsiServerId?: string }
+): string {
+ const params = new URLSearchParams()
+ if (data.compileGroup) params.set('compileGroup', data.compileGroup)
+ if (data.clsiServerId) params.set('clsiserverid', data.clsiServerId)
+ const base = (file.build && data.pdfDownloadDomain)
+ ? `${data.pdfDownloadDomain}${file.url}`
+ : `https://www.overleaf.com${file.url}`
+ return `${params.toString() ? `${base}?${params}` : base}`
+}
+
+// ── Main ──
+
+const HELP = `lattex-cli — headless Overleaf client for AI agents
+
+Commands:
+ auth Store Overleaf session cookie
+ --cookie "..." Cookie string
+ --from-cdp URL Read from Chromium DevTools Protocol
+ env LATTEX_COOKIE Alternative to --cookie
+
+ projects [--json] List Overleaf projects
+
+ clone <id|name> <dir> Download project to local directory
+ pull <dir> Update local dir from Overleaf
+ status <dir> Show local changes
+
+ push <dir> Upload local changes to Overleaf
+ --dry-run Show what would be pushed
+ --delete Allow deleting remote files
+ --force Push even if remote changed
+
+ compile <dir> Trigger Overleaf compile, download PDF
+ --out file.pdf Save PDF to specific path
+
+Global:
+ --json JSON output on all commands
+ --help Show this help`
+
+async function main(): Promise<void> {
+ const parsed = parseArgs(process.argv)
+ jsonMode = !!parsed.flags.json
+
+ if (parsed.flags.help || parsed.command === 'help' || !parsed.command) {
+ out(HELP)
+ process.exit(parsed.command ? EXIT_OK : EXIT_USAGE)
+ }
+
+ try {
+ switch (parsed.command) {
+ case 'auth':
+ await cmdAuth(parsed.flags)
+ break
+ case 'projects':
+ await cmdProjects(parsed.flags)
+ break
+ case 'clone':
+ await cmdClone(parsed.positional, parsed.flags)
+ break
+ case 'pull':
+ await cmdPull(parsed.positional, parsed.flags)
+ break
+ case 'status':
+ await cmdStatus(parsed.positional, parsed.flags)
+ break
+ case 'push':
+ await cmdPush(parsed.positional, parsed.flags)
+ break
+ case 'compile':
+ await cmdCompile(parsed.positional, parsed.flags)
+ break
+ default:
+ exitWith(EXIT_USAGE, `Unknown command: ${parsed.command}. Run lattex-cli --help`)
+ }
+ } catch (e: any) {
+ exitWith(EXIT_ERROR, e.message || String(e))
+ }
+}
+
+main()
diff --git a/src/cli/overleafApi.ts b/src/cli/overleafApi.ts
new file mode 100644
index 0000000..6301638
--- /dev/null
+++ b/src/cli/overleafApi.ts
@@ -0,0 +1,518 @@
+// Headless Overleaf API client using Node.js https (no Electron dependency)
+import https from 'https'
+import http from 'http'
+import { URL } from 'url'
+
+export interface OverleafProject {
+ id: string
+ name: string
+ owner: { _id: string; first_name: string; last_name: string; email: string }
+ lastUpdated: string
+ accessLevel: string
+}
+
+export interface OverleafApiResult {
+ ok: boolean
+ status: number
+ data: unknown
+ setCookies: string[]
+}
+
+export class OverleafApi {
+ private cookie: string
+ private csrfToken: string = ''
+
+ constructor(cookie: string) {
+ this.cookie = cookie
+ }
+
+ getCookie(): string {
+ return this.cookie
+ }
+
+ getCsrfToken(): string {
+ return this.csrfToken
+ }
+
+ /** Fetch CSRF token from the Overleaf project page HTML */
+ async refreshCsrf(): Promise<void> {
+ const result = await this.request('GET', '/project', { raw: true })
+ if (!result.ok || typeof result.data !== 'string') {
+ throw new Error(`Failed to fetch CSRF token: HTTP ${result.status}`)
+ }
+ const m = (result.data as string).match(/ol-csrfToken[^>]*content="([^"]+)"/)
+ if (!m) {
+ throw new Error('CSRF token not found in page — session may be expired')
+ }
+ this.csrfToken = m[1]
+ // Also merge any set-cookie from that response
+ this.mergeCookies(result.setCookies)
+ }
+
+ /** Verify that the stored cookie is valid */
+ async verifySession(): Promise<boolean> {
+ const result = await this.request('GET', '/user/projects')
+ return result.ok && typeof result.data === 'object' && result.data !== null
+ }
+
+ /** List all projects */
+ async listProjects(): Promise<OverleafProject[]> {
+ const result = await this.request('GET', '/user/projects')
+ if (!result.ok) {
+ throw new Error(`Failed to list projects: HTTP ${result.status}`)
+ }
+ const data = result.data as { projects?: unknown[] }
+ if (!data.projects || !Array.isArray(data.projects)) {
+ throw new Error('Unexpected response format from /user/projects')
+ }
+ return data.projects.map((p: any) => ({
+ id: p._id || p.id,
+ name: p.name,
+ owner: p.owner || { _id: '', first_name: '', last_name: '', email: '' },
+ lastUpdated: p.lastUpdated || '',
+ accessLevel: p.accessLevel || p.privileges || ''
+ }))
+ }
+
+ /** Get project metadata by connecting via Socket.IO handshake + joinProject.
+ * Returns full file tree. Uses the WebSocket protocol from overleafSocket.ts
+ * but reimplemented with plain Node.js WebSocket. */
+ async getProjectData(projectId: string): Promise<{
+ project: {
+ _id: string
+ name: string
+ rootDoc_id: string
+ rootFolder: any[]
+ owner: any
+ }
+ publicId: string
+ permissionsLevel: string
+ }> {
+ // Use overleafSocket-compatible handshake + ws
+ const { default: WebSocket } = await import('ws')
+ const { parseSocketMessage, encodeEvent } = await import('../main/overleafProtocol')
+
+ // Step 1: HTTP handshake to get SID
+ const hsResult = await this.httpGet(
+ `https://www.overleaf.com/socket.io/1/?t=${Date.now()}&projectId=${projectId}`
+ )
+ if (!hsResult.ok) {
+ throw new Error(`Socket handshake failed: HTTP ${hsResult.status}`)
+ }
+ const sid = (hsResult.data as string).split(':')[0]
+ if (!sid) throw new Error('No SID in handshake response')
+
+ // Merge handshake cookies
+ this.mergeCookies(hsResult.setCookies)
+
+ // Step 2: WebSocket connection
+ return new Promise((resolve, reject) => {
+ const wsUrl = `wss://www.overleaf.com/socket.io/1/websocket/${sid}`
+ const ws = new WebSocket(wsUrl, { headers: { Cookie: this.cookie } })
+
+ const timeout = setTimeout(() => {
+ ws.close()
+ reject(new Error('WebSocket connection timeout'))
+ }, 30000)
+
+ let waitingForJoinResponse = false
+ const handleJoinResponse = (args: unknown[]) => {
+ for (const arg of args) {
+ if (arg && typeof arg === 'object' && 'project' in (arg as object)) {
+ clearTimeout(timeout)
+ ws.close()
+ resolve(arg as any)
+ return
+ }
+ }
+ clearTimeout(timeout)
+ ws.close()
+ reject(new Error('No project data in joinProject response'))
+ }
+
+ ws.on('message', (data: Buffer) => {
+ const raw = data.toString()
+ const msg = parseSocketMessage(raw)
+ if (!msg) return
+
+ switch (msg.type) {
+ case 'connect':
+ // Send joinProject
+ ws.send(encodeEvent('joinProject', [{ project_id: projectId }]))
+ waitingForJoinResponse = true
+ break
+ case 'heartbeat':
+ ws.send('2::')
+ break
+ case 'event':
+ if (msg.name === 'joinProjectResponse' && waitingForJoinResponse) {
+ handleJoinResponse(msg.args || [])
+ }
+ break
+ }
+ })
+
+ ws.on('error', (err) => {
+ clearTimeout(timeout)
+ reject(err)
+ })
+ })
+ }
+
+ /** Join a doc and get its content via WebSocket */
+ async getDocContent(projectId: string, docId: string): Promise<{
+ lines: string[]
+ version: number
+ }> {
+ const { default: WebSocket } = await import('ws')
+ const {
+ parseSocketMessage,
+ encodeEvent,
+ encodeEventWithAck,
+ encodeHeartbeat
+ } = await import('../main/overleafProtocol')
+
+ // Handshake
+ const hsResult = await this.httpGet(
+ `https://www.overleaf.com/socket.io/1/?t=${Date.now()}&projectId=${projectId}`
+ )
+ if (!hsResult.ok) throw new Error(`Handshake failed: HTTP ${hsResult.status}`)
+ const sid = (hsResult.data as string).split(':')[0]
+ if (!sid) throw new Error('No SID in handshake')
+ this.mergeCookies(hsResult.setCookies)
+
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(
+ `wss://www.overleaf.com/socket.io/1/websocket/${sid}`,
+ { headers: { Cookie: this.cookie } }
+ )
+ const timeout = setTimeout(() => { ws.close(); reject(new Error('Timeout')) }, 30000)
+ let joinedProject = false
+ let ackId = 0
+
+ ws.on('message', (data: Buffer) => {
+ const raw = data.toString()
+ const msg = parseSocketMessage(raw)
+ if (!msg) return
+
+ if (msg.type === 'connect') {
+ ws.send(encodeEvent('joinProject', [{ project_id: projectId }]))
+ } else if (msg.type === 'heartbeat') {
+ ws.send(encodeHeartbeat())
+ } else if (msg.type === 'event' && msg.name === 'joinProjectResponse') {
+ joinedProject = true
+ ackId++
+ ws.send(encodeEventWithAck(ackId, 'joinDoc', [docId, { encodeRanges: true }]))
+ } else if (msg.type === 'ack' && joinedProject) {
+ clearTimeout(timeout)
+ const result = msg.data as unknown[]
+ const err = result[0]
+ if (err) { ws.close(); reject(new Error(`joinDoc failed: ${JSON.stringify(err)}`)); return }
+ const rawLines = (result[1] as string[]) || []
+ const lines = rawLines.map(line => {
+ try { return decodeURIComponent(escape(line)) } catch { return line }
+ })
+ const version = (result[2] as number) || 0
+ ws.close()
+ resolve({ lines, version })
+ }
+ })
+
+ ws.on('error', (err) => { clearTimeout(timeout); reject(err) })
+ })
+ }
+
+ /** Download a binary file from Overleaf */
+ async downloadFile(projectId: string, fileRefId: string): Promise<Buffer> {
+ return new Promise((resolve, reject) => {
+ const url = `https://www.overleaf.com/project/${projectId}/file/${fileRefId}`
+ const req = https.request(url, {
+ method: 'GET',
+ headers: {
+ Cookie: this.cookie,
+ 'User-Agent': 'Mozilla/5.0'
+ }
+ }, (res) => {
+ if (res.statusCode === 301 || res.statusCode === 302) {
+ // Follow redirect
+ const location = res.headers.location
+ if (location) {
+ this.fetchBinaryUrl(location).then(resolve, reject)
+ return
+ }
+ }
+ const chunks: Buffer[] = []
+ res.on('data', (chunk) => chunks.push(chunk as Buffer))
+ res.on('end', () => resolve(Buffer.concat(chunks)))
+ })
+ req.on('error', reject)
+ req.end()
+ })
+ }
+
+ private fetchBinaryUrl(url: string): Promise<Buffer> {
+ return new Promise((resolve, reject) => {
+ const parsed = new URL(url)
+ const mod = parsed.protocol === 'https:' ? https : http
+ mod.get(url, (res) => {
+ const chunks: Buffer[] = []
+ res.on('data', (chunk: Buffer) => chunks.push(chunk))
+ res.on('end', () => resolve(Buffer.concat(chunks)))
+ }).on('error', reject)
+ })
+ }
+
+ /** Upload a file to Overleaf (multipart form) */
+ async uploadFile(
+ projectId: string,
+ folderId: string,
+ fileName: string,
+ fileData: Buffer,
+ mimeType: string = 'application/octet-stream'
+ ): Promise<{ entityId?: string; error?: string }> {
+ if (!this.csrfToken) await this.refreshCsrf()
+
+ const boundary = '----FormBoundary' + Math.random().toString(36).slice(2)
+ const parts: Buffer[] = []
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="name"\r\n\r\n${fileName}\r\n`))
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="type"\r\n\r\n${mimeType}\r\n`))
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="qqfile"; filename="${fileName}"\r\nContent-Type: ${mimeType}\r\n\r\n`))
+ parts.push(fileData)
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`))
+ const body = Buffer.concat(parts)
+
+ return new Promise((resolve, reject) => {
+ const req = https.request({
+ hostname: 'www.overleaf.com',
+ path: `/project/${projectId}/upload?folder_id=${folderId}`,
+ method: 'POST',
+ headers: {
+ Cookie: this.cookie,
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
+ 'User-Agent': 'Mozilla/5.0',
+ Accept: 'application/json',
+ 'x-csrf-token': this.csrfToken
+ }
+ }, (res) => {
+ let body = ''
+ res.on('data', (chunk: Buffer) => { body += chunk.toString() })
+ res.on('end', () => {
+ try {
+ const data = JSON.parse(body)
+ if (data.success !== false && !data.error) {
+ const entityId = data.entity_id || data.entityId || data.fileRef?._id || data.file?._id
+ resolve({ entityId })
+ } else {
+ resolve({ error: data.error || 'Upload failed' })
+ }
+ } catch {
+ resolve({ error: `HTTP ${res.statusCode}: ${body.slice(0, 200)}` })
+ }
+ })
+ })
+ req.on('error', reject)
+ req.write(body)
+ req.end()
+ })
+ }
+
+ /** Create a text doc on Overleaf */
+ async createDoc(projectId: string, folderId: string, name: string): Promise<string> {
+ const result = await this.requestWithCsrf('POST', `/project/${projectId}/doc`, {
+ name,
+ parent_folder_id: folderId
+ })
+ if (!result.ok || !(result.data as any)?._id) {
+ throw new Error(`Create doc failed: HTTP ${result.status}`)
+ }
+ return (result.data as any)._id
+ }
+
+ /** Create a folder on Overleaf */
+ async createFolder(projectId: string, parentFolderId: string, name: string): Promise<string> {
+ const result = await this.requestWithCsrf('POST', `/project/${projectId}/folder`, {
+ name,
+ parent_folder_id: parentFolderId
+ })
+ if (!result.ok || !(result.data as any)?._id) {
+ throw new Error(`Create folder failed: HTTP ${result.status}`)
+ }
+ return (result.data as any)._id
+ }
+
+ /** Delete an entity */
+ async deleteEntity(projectId: string, entityType: 'doc' | 'file' | 'folder', entityId: string): Promise<void> {
+ const result = await this.requestWithCsrf('DELETE', `/project/${projectId}/${entityType}/${entityId}`)
+ if (!result.ok) {
+ throw new Error(`Delete ${entityType} failed: HTTP ${result.status}`)
+ }
+ }
+
+ /** Flush project (ensure OT changes are saved to database) */
+ async flushProject(projectId: string): Promise<void> {
+ await this.requestWithCsrf('POST', `/project/${projectId}/flush`)
+ }
+
+ /** Trigger Overleaf server-side compile */
+ async compile(projectId: string, rootDocId?: string): Promise<{
+ status: string
+ outputFiles: Array<{ path: string; url: string; type: string; build?: string }>
+ compileGroup?: string
+ clsiServerId?: string
+ pdfDownloadDomain?: string
+ }> {
+ await this.flushProject(projectId)
+
+ const body: any = {
+ check: 'silent',
+ draft: false,
+ incrementalCompilesEnabled: true,
+ rootDoc_id: rootDocId || null,
+ stopOnFirstError: false
+ }
+
+ const result = await this.requestWithCsrf(
+ 'POST',
+ `/project/${projectId}/compile?auto_compile=false`,
+ body
+ )
+ if (!result.ok) {
+ throw new Error(`Compile request failed: HTTP ${result.status}`)
+ }
+
+ const data = result.data as any
+ return {
+ status: data.status || 'unknown',
+ outputFiles: data.outputFiles || [],
+ compileGroup: data.compileGroup,
+ clsiServerId: data.clsiServerId,
+ pdfDownloadDomain: data.pdfDownloadDomain
+ }
+ }
+
+ /** Download an output file from the compile result */
+ async downloadOutputFile(
+ file: { url: string; build?: string },
+ compileData: { pdfDownloadDomain?: string; compileGroup?: string; clsiServerId?: string }
+ ): Promise<Buffer> {
+ const params = new URLSearchParams()
+ if (compileData.compileGroup) params.set('compileGroup', compileData.compileGroup)
+ if (compileData.clsiServerId) params.set('clsiserverid', compileData.clsiServerId)
+ const base = (file.build && compileData.pdfDownloadDomain)
+ ? `${compileData.pdfDownloadDomain}${file.url}`
+ : `https://www.overleaf.com${file.url}`
+ const url = `${base}?${params}`
+
+ return this.fetchBinaryUrl(url)
+ }
+
+ /** Fetch text from a URL (for compile logs) */
+ async fetchText(url: string): Promise<string> {
+ const buf = await this.fetchBinaryUrl(url)
+ return buf.toString('utf-8')
+ }
+
+ // ── Private helpers ──
+
+ private async requestWithCsrf(method: string, path: string, body?: object): Promise<OverleafApiResult> {
+ if (!this.csrfToken) await this.refreshCsrf()
+ const result = await this.request(method, path, { body })
+ if (result.status === 403) {
+ await this.refreshCsrf()
+ return this.request(method, path, { body })
+ }
+ return result
+ }
+
+ private request(
+ method: string,
+ path: string,
+ options: { body?: object; raw?: boolean } = {}
+ ): Promise<OverleafApiResult> {
+ return new Promise((resolve) => {
+ const headers: Record<string, string> = {
+ Cookie: this.cookie,
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
+ }
+ if (!options.raw) headers.Accept = 'application/json'
+ if (options.body) headers['Content-Type'] = 'application/json'
+ if (this.csrfToken && method !== 'GET') headers['x-csrf-token'] = this.csrfToken
+
+ const req = https.request({
+ hostname: 'www.overleaf.com',
+ path,
+ method,
+ headers
+ }, (res) => {
+ const setCookies: string[] = []
+ const rawSc = res.headers['set-cookie']
+ if (rawSc) setCookies.push(...(Array.isArray(rawSc) ? rawSc : [rawSc]))
+
+ let body = ''
+ res.on('data', (chunk) => { body += chunk.toString() })
+ res.on('end', () => {
+ let data: unknown = body
+ if (!options.raw) {
+ try { data = JSON.parse(body) } catch { /* not json */ }
+ }
+ resolve({
+ ok: (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300,
+ status: res.statusCode || 0,
+ data,
+ setCookies
+ })
+ })
+ })
+
+ req.on('error', (err) => {
+ resolve({ ok: false, status: 0, data: err.message, setCookies: [] })
+ })
+
+ if (options.body) req.write(JSON.stringify(options.body))
+ req.end()
+ })
+ }
+
+ private httpGet(url: string): Promise<OverleafApiResult> {
+ return new Promise((resolve) => {
+ const parsed = new URL(url)
+ const req = https.request({
+ hostname: parsed.hostname,
+ path: parsed.pathname + parsed.search,
+ method: 'GET',
+ headers: {
+ Cookie: this.cookie,
+ 'User-Agent': 'Mozilla/5.0'
+ }
+ }, (res) => {
+ const setCookies: string[] = []
+ const rawSc = res.headers['set-cookie']
+ if (rawSc) setCookies.push(...(Array.isArray(rawSc) ? rawSc : [rawSc]))
+
+ let body = ''
+ res.on('data', (chunk) => { body += chunk.toString() })
+ res.on('end', () => {
+ resolve({
+ ok: (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300,
+ status: res.statusCode || 0,
+ data: body,
+ setCookies
+ })
+ })
+ })
+ req.on('error', (err) => {
+ resolve({ ok: false, status: 0, data: err.message, setCookies: [] })
+ })
+ req.end()
+ })
+ }
+
+ private mergeCookies(setCookies: string[]): void {
+ for (const sc of setCookies) {
+ const part = sc.split(';')[0]
+ if (part && !this.cookie.includes(part)) {
+ this.cookie += '; ' + part
+ }
+ }
+ }
+}
diff --git a/src/cli/test.ts b/src/cli/test.ts
new file mode 100644
index 0000000..e76182e
--- /dev/null
+++ b/src/cli/test.ts
@@ -0,0 +1,245 @@
+// Tests for CLI arg parsing and diff logic
+import { parseArgs } from './args'
+import { walkRootFolder } from './fileTree'
+import { join } from 'path'
+import { mkdtemp, writeFile, mkdir, rm } from 'fs/promises'
+import { tmpdir } from 'os'
+import { computeDiff, hashFile, isTextFile } from './diff'
+import { parseCompileLog } from './logParser'
+
+let passed = 0
+let failed = 0
+
+function assert(condition: boolean, message: string): void {
+ if (condition) {
+ passed++
+ } else {
+ failed++
+ console.error(`FAIL: ${message}`)
+ }
+}
+
+function eq<T>(actual: T, expected: T, message: string): void {
+ if (JSON.stringify(actual) === JSON.stringify(expected)) {
+ passed++
+ } else {
+ failed++
+ console.error(`FAIL: ${message}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`)
+ }
+}
+
+// ── parseArgs tests ──
+
+function testParseArgs(): void {
+ console.log('--- parseArgs ---')
+
+ // Basic command
+ const r1 = parseArgs(['node', 'cli', 'projects'])
+ eq(r1.command, 'projects', 'basic command')
+ eq(r1.positional.length, 0, 'no positionals')
+
+ // Command with positionals
+ const r2 = parseArgs(['node', 'cli', 'clone', 'abc123', '/tmp/mydir'])
+ eq(r2.command, 'clone', 'clone command')
+ eq(r2.positional, ['abc123', '/tmp/mydir'], 'clone positionals')
+
+ // Flags
+ const r3 = parseArgs(['node', 'cli', 'projects', '--json'])
+ eq(r3.command, 'projects', 'projects command')
+ eq(r3.flags.json, true, 'json flag is boolean')
+
+ // Flag with value
+ const r4 = parseArgs(['node', 'cli', 'auth', '--cookie', 'session=abc'])
+ eq(r4.command, 'auth', 'auth command')
+ eq(r4.flags.cookie, 'session=abc', 'cookie flag value')
+
+ // Flag with = syntax
+ const r5 = parseArgs(['node', 'cli', 'compile', '--out=/tmp/paper.pdf'])
+ eq(r5.flags.out, '/tmp/paper.pdf', 'flag with = syntax')
+
+ // Multiple flags
+ const r6 = parseArgs(['node', 'cli', 'push', '.', '--dry-run', '--force', '--json'])
+ eq(r6.command, 'push', 'push command')
+ eq(r6.flags['dry-run'], true, 'dry-run flag')
+ eq(r6.flags.force, true, 'force flag')
+ eq(r6.flags.json, true, 'json flag with push')
+ eq(r6.positional, ['.'], 'push positional')
+
+ // Empty args
+ const r7 = parseArgs(['node', 'cli'])
+ eq(r7.command, '', 'empty command')
+
+ // --delete flag
+ const r8 = parseArgs(['node', 'cli', 'push', '/tmp/project', '--delete'])
+ eq(r8.flags.delete, true, 'delete flag')
+ eq(r8.positional, ['/tmp/project'], 'push dir positional')
+}
+
+// ── walkRootFolder tests ──
+
+function testWalkRootFolder(): void {
+ console.log('--- walkRootFolder ---')
+
+ const rootFolder = [{
+ _id: 'root123',
+ name: 'rootFolder',
+ docs: [
+ { _id: 'doc1', name: 'main.tex' },
+ { _id: 'doc2', name: 'refs.bib' }
+ ],
+ fileRefs: [
+ { _id: 'file1', name: 'figure.png' }
+ ],
+ folders: [{
+ _id: 'folder1',
+ name: 'sections',
+ docs: [
+ { _id: 'doc3', name: 'intro.tex' },
+ { _id: 'doc4', name: 'method.tex' }
+ ],
+ fileRefs: [],
+ folders: [{
+ _id: 'folder2',
+ name: 'appendix',
+ docs: [{ _id: 'doc5', name: 'proofs.tex' }],
+ fileRefs: [{ _id: 'file2', name: 'table.csv' }],
+ folders: []
+ }]
+ }]
+ }]
+
+ const result = walkRootFolder(rootFolder)
+
+ eq(result.rootFolderId, 'root123', 'root folder ID')
+ eq(result.docPathMap['doc1'], 'main.tex', 'root doc path')
+ eq(result.docPathMap['doc3'], 'sections/intro.tex', 'nested doc path')
+ eq(result.docPathMap['doc5'], 'sections/appendix/proofs.tex', 'deeply nested doc path')
+ eq(result.pathDocMap['main.tex'], 'doc1', 'reverse doc map')
+ eq(result.pathDocMap['sections/method.tex'], 'doc4', 'reverse nested doc map')
+ eq(result.fileRefs.length, 2, 'file refs count')
+ eq(result.fileRefs[0], { id: 'file1', path: 'figure.png' }, 'file ref at root')
+ eq(result.fileRefs[1], { id: 'file2', path: 'sections/appendix/table.csv' }, 'nested file ref')
+ eq(result.folderMap['root123'], '', 'root folder path')
+ eq(result.folderMap['folder1'], 'sections', 'subfolder path')
+ eq(result.folderMap['folder2'], 'sections/appendix', 'deep subfolder path')
+ eq(result.pathFolderMap[''], 'root123', 'reverse root folder')
+ eq(result.pathFolderMap['sections'], 'folder1', 'reverse subfolder')
+ // 5 docs + 2 fileRefs + 2 folders = 9 entries
+ assert(result.entries.length === 9, `entries count: ${result.entries.length} (expected 9)`)
+}
+
+// ── isTextFile tests ──
+
+function testIsTextFile(): void {
+ console.log('--- isTextFile ---')
+ assert(isTextFile('main.tex'), 'main.tex is text')
+ assert(isTextFile('refs.bib'), 'refs.bib is text')
+ assert(isTextFile('style.sty'), 'style.sty is text')
+ assert(isTextFile('class.cls'), 'class.cls is text')
+ assert(isTextFile('Makefile'), 'Makefile is text')
+ assert(isTextFile('latexmkrc'), 'latexmkrc is text')
+ assert(isTextFile('script.py'), 'script.py is text')
+ assert(!isTextFile('figure.png'), 'figure.png is not text')
+ assert(!isTextFile('photo.jpg'), 'photo.jpg is not text')
+ assert(!isTextFile('archive.zip'), 'archive.zip is not text')
+}
+
+// ── computeDiff tests ──
+
+async function testComputeDiff(): Promise<void> {
+ console.log('--- computeDiff ---')
+
+ const tmpDir = await mkdtemp(join(tmpdir(), 'lattex-test-'))
+
+ try {
+ // Create some files
+ await writeFile(join(tmpDir, 'main.tex'), '\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}')
+ await writeFile(join(tmpDir, 'refs.bib'), '@article{test, title={Test}}')
+ await mkdir(join(tmpDir, 'sections'), { recursive: true })
+ await writeFile(join(tmpDir, 'sections', 'intro.tex'), '\\section{Intro}\nContent here.')
+
+ // Hash the initial state
+ const hashes: Record<string, string> = {}
+ hashes['main.tex'] = await hashFile(join(tmpDir, 'main.tex'))
+ hashes['refs.bib'] = await hashFile(join(tmpDir, 'refs.bib'))
+ hashes['sections/intro.tex'] = await hashFile(join(tmpDir, 'sections', 'intro.tex'))
+
+ const known = new Set(['main.tex', 'refs.bib', 'sections/intro.tex'])
+
+ // No changes
+ const diff1 = await computeDiff(tmpDir, hashes, known)
+ eq(diff1.length, 0, 'no changes initially')
+
+ // Modify a file
+ await writeFile(join(tmpDir, 'main.tex'), '\\documentclass{article}\n\\begin{document}\nModified!\n\\end{document}')
+ const diff2 = await computeDiff(tmpDir, hashes, known)
+ eq(diff2.length, 1, 'one modified file')
+ eq(diff2[0].type, 'modified', 'change type is modified')
+ eq(diff2[0].path, 'main.tex', 'modified file path')
+
+ // Add a new file
+ await writeFile(join(tmpDir, 'new.tex'), '\\section{New}')
+ const diff3 = await computeDiff(tmpDir, hashes, known)
+ assert(diff3.length === 2, `two changes: ${diff3.length}`)
+ const added = diff3.find(c => c.type === 'added')
+ assert(!!added, 'has added change')
+ eq(added?.path, 'new.tex', 'added file path')
+ assert(added?.isText === true, 'added file is text')
+
+ // Test deleted file detection
+ const hashesWithExtra = { ...hashes, 'deleted.tex': 'fakehash' }
+ const diff4 = await computeDiff(tmpDir, hashesWithExtra, new Set([...known, 'deleted.tex']))
+ const deleted = diff4.find(c => c.type === 'deleted')
+ assert(!!deleted, 'has deleted change')
+ eq(deleted?.path, 'deleted.tex', 'deleted file path')
+
+ } finally {
+ await rm(tmpDir, { recursive: true, force: true })
+ }
+}
+
+// ── parseCompileLog tests ──
+
+function testParseCompileLog(): void {
+ console.log('--- parseCompileLog ---')
+
+ const log1 = `! LaTeX Error: File \`nonexistent.sty' not found.
+l.3 \\usepackage{nonexistent}`
+ const entries1 = parseCompileLog(log1)
+ assert(entries1.length >= 1, 'parse error entry')
+ eq(entries1[0].level, 'error', 'error level')
+ assert(entries1[0].line === 3, 'error line number')
+
+ const log2 = `./main.tex:15: Undefined control sequence.`
+ const entries2 = parseCompileLog(log2)
+ assert(entries2.length >= 1, 'parse file:line:error')
+ eq(entries2[0].file, 'main.tex', 'error file')
+ eq(entries2[0].line, 15, 'error line from file:line format')
+
+ const log3 = `LaTeX Warning: Reference \`fig:missing' on page 3 undefined on input line 42.`
+ const entries3 = parseCompileLog(log3)
+ assert(entries3.length >= 1, 'parse LaTeX warning')
+ eq(entries3[0].level, 'warning', 'warning level')
+ eq(entries3[0].line, 42, 'warning line')
+
+ const log4 = `Overfull \\hbox (10.5pt too wide) in paragraph at lines 20--25`
+ const entries4 = parseCompileLog(log4)
+ assert(entries4.length >= 1, 'parse overfull warning')
+ eq(entries4[0].level, 'warning', 'overfull is warning')
+ eq(entries4[0].line, 20, 'overfull line')
+}
+
+// ── Run all tests ──
+
+async function runTests(): Promise<void> {
+ testParseArgs()
+ testWalkRootFolder()
+ testIsTextFile()
+ await testComputeDiff()
+ testParseCompileLog()
+
+ console.log(`\n${passed} passed, ${failed} failed`)
+ process.exit(failed > 0 ? 1 : 0)
+}
+
+runTests()