diff options
| author | YurenHao0426 <blackhao0426@gmail.com> | 2026-09-13 04:05:38 +0000 |
|---|---|---|
| committer | YurenHao0426 <blackhao0426@gmail.com> | 2026-09-13 04:05:38 +0000 |
| commit | f3b2fc01082e754f7dfe73caa4f0a4f207a2adfb (patch) | |
| tree | f6083a5abddc3541ad7996f71695e559b4b41ece /src/cli | |
| parent | 34f22876ab9e15e9f14300a8c21cd7109800d1ab (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')
| -rw-r--r-- | src/cli/args.ts | 39 | ||||
| -rw-r--r-- | src/cli/diff.ts | 93 | ||||
| -rw-r--r-- | src/cli/fileTree.ts | 69 | ||||
| -rw-r--r-- | src/cli/localState.ts | 76 | ||||
| -rw-r--r-- | src/cli/logParser.ts | 99 | ||||
| -rw-r--r-- | src/cli/main.ts | 752 | ||||
| -rw-r--r-- | src/cli/overleafApi.ts | 518 | ||||
| -rw-r--r-- | src/cli/test.ts | 245 |
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() |
