From c14ee2a2993585b16343e3d3df3b18bed64283ae Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 16 Apr 2026 18:02:50 +0200 Subject: [PATCH 01/20] Switched getProjectInfo to using websockets --- src/client.ts | 206 +++++++++++++++++++++++++++++--------------------- 1 file changed, 119 insertions(+), 87 deletions(-) diff --git a/src/client.ts b/src/client.ts index a7c14f3..d04abe9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -100,7 +100,7 @@ export class OverleafClient { static async fromSessionCookie( sessionCookie: string, baseUrl: string = DEFAULT_BASE_URL, - cookieName: string = 'overleaf_session2' + cookieName: string = 'overleaf_session2' ): Promise { const cookies: Record = { [cookieName]: sessionCookie @@ -110,7 +110,7 @@ export class OverleafClient { const response = await fetch(`${baseUrl}/project`, { headers: { 'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '), - 'User-Agent': USER_AGENT + 'User-Agent': USER_AGENT } }); @@ -259,16 +259,16 @@ export class OverleafClient { // Filter out archived and trashed return projectsData - .filter((p: any) => !p.archived && !p.trashed) - .map((p: any) => ({ - id: p.id || p._id, - name: p.name, - lastUpdated: p.lastUpdated, - lastUpdatedBy: p.lastUpdatedBy, - owner: p.owner, - archived: p.archived, - trashed: p.trashed - })); + .filter((p: any) => !p.archived && !p.trashed) + .map((p: any) => ({ + id: p.id || p._id, + name: p.name, + lastUpdated: p.lastUpdated, + lastUpdatedBy: p.lastUpdatedBy, + owner: p.owner, + archived: p.archived, + trashed: p.trashed + })); } /** @@ -288,56 +288,88 @@ export class OverleafClient { } /** - * Get detailed project info including file tree + * Get detailed project info including file tree (via WebSocket) */ async getProjectInfo(projectId: string): Promise { - const response = await fetch(`${this.projectUrl()}/${projectId}`, { - headers: this.getHeaders() - }); + let sid: string | null = null; - if (!response.ok) { - throw new Error(`Failed to fetch project info: ${response.status}`); - } + try { + // 1. Initiate Socket.io Handshake + const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, { + headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT } + }, 5000); - const html = await response.text(); - const $ = cheerio.load(html); + if (!handshakeResponse.ok) throw new Error(`Socket handshake failed: ${handshakeResponse.status}`); + this.applySetCookieHeaders(handshakeResponse.headers); - // Look for project data in meta tags - let projectInfo: ProjectInfo | undefined; + const handshakeBody = (await handshakeResponse.text()).trim(); + sid = handshakeBody.split(':')[0]; + if (!sid) throw new Error('Could not parse socket session ID'); - // Try ol-project meta tag - const projectMeta = $('meta[name="ol-project"]').attr('content'); - if (projectMeta) { - try { - projectInfo = JSON.parse(projectMeta); - } catch (e) { - // Continue - } - } + // 2. Poll the socket for the project data + const pollUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - // Try to find in other meta tags - if (!projectInfo) { - const metas = $('meta[content]').toArray(); - for (const meta of metas) { - const content = $(meta).attr('content') || ''; - if (content.includes('rootFolder')) { - try { - projectInfo = JSON.parse(content); - break; - } catch (e) { - // Continue + for (let attempt = 0; attempt < 3; attempt++) { + const pollResponse = await this.fetchWithTimeout(pollUrl, { + headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT } + }, 5000); + + if (!pollResponse.ok) throw new Error(`Socket poll failed: ${pollResponse.status}`); + this.applySetCookieHeaders(pollResponse.headers); + + const payload = await pollResponse.text(); + const packets = this.decodeSocketIoPayload(payload); + + for (const packet of packets) { + // Look for the main event packet + if (packet.startsWith('5:::')) { + try { + const payloadJson = JSON.parse(packet.slice(4)); + if (payloadJson?.name === 'joinProjectResponse') { + const projectData = payloadJson?.args?.[0]?.project; + + if (projectData) { + // Map the socket data to the strict TypeScript ProjectInfo interface + return { + _id: projectData._id, + name: projectData.name, + rootDoc_id: projectData.rootDoc_id, + rootFolder: projectData.rootFolder + }; + } + } + } catch (e) { } + } + + // Reply to heartbeat + if (packet.startsWith('2::')) { + await this.fetchWithTimeout(pollUrl, { + method: 'POST', + headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, + body: '2::' + }, 5000); } } } + } finally { + // 3. Cleanly disconnect the socket + if (sid) { + try { + const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + await this.fetchWithTimeout(disconnectUrl, { + method: 'POST', + headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, + body: '0::' + }, 5000); + } catch { /* ignore */ } + } } - if (!projectInfo) { - throw new Error('Could not parse project info'); - } - - return projectInfo; + throw new Error('Could not parse project info from WebSocket'); } + /** * Download a URL as a Buffer using Node.js http/https modules. * @@ -579,7 +611,7 @@ export class OverleafClient { if (!sid) return null; const buildPollUrl = () => - `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; let discoveredRootFolderId: string | null = null; @@ -674,7 +706,7 @@ export class OverleafClient { if (!sid) return null; const buildPollUrl = () => - `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; for (let attempt = 0; attempt < 3; attempt++) { const pollResponse = await this.fetchWithTimeout(buildPollUrl(), { @@ -810,13 +842,13 @@ export class OverleafClient { */ async probeRootFolderId(projectId: string): Promise { const candidates: string[] = []; - + // Method 1: Try projectId - 1 (most common) candidates.push(this.computeRootFolderId(projectId)); - + const prefix = projectId.slice(0, 16); const suffix = parseInt(projectId.slice(16), 16); - + // Method 2: Try a wide range around the project ID // Some projects have root folder created with different offsets for (let i = 2; i <= 50; i++) { @@ -1031,7 +1063,7 @@ export class OverleafClient { const projectInfo = await this.getProjectInfo(projectId); const normalizedTarget = targetPath.replace(/^\//, ''); - function searchFolder(folder: FolderEntry, currentPath: string): { id: string; type: 'doc' | 'file' | 'folder'; name: string } | null { + function searchFolder(folder: FolderEntry, currentPath: string): { id: string; type: 'doc' | 'file' | 'folder'; name: string } | null { // Check docs for (const doc of folder.docs || []) { const docPath = currentPath ? `${currentPath}/${doc.name}` : doc.name; @@ -1139,39 +1171,39 @@ export class OverleafClient { async downloadByPath(projectId: string, path: string): Promise { const normalizedPath = path.replace(/^\//, ''); - // First check if file exists - const entities = await this.getEntities(projectId); - const entityExists = entities.find(e => - e.path.replace(/^\//, '') === normalizedPath || - e.path === `/${normalizedPath}` - ); - - if (!entityExists) { - throw new Error(`File not found: ${path}`); - } - - // Try to find entity with ID for direct download - try { - const entity = await this.findEntityByPath(projectId, path); - if (entity && entity.type !== 'folder') { - return await this.downloadFile(projectId, entity.id, entity.type); - } - } catch (e) { - // Fall through to zip method - } - - // Fallback: download zip and extract the file - const zipBuffer = await this.downloadProject(projectId); - const AdmZip = (await import('adm-zip')).default; - const zip = new AdmZip(zipBuffer); - - for (const entry of zip.getEntries()) { - if (entry.entryName === normalizedPath || entry.entryName === path) { - return entry.getData(); - } - } - - throw new Error(`File not found in archive: ${path}`); + // First check if file exists + const entities = await this.getEntities(projectId); + const entityExists = entities.find(e => + e.path.replace(/^\//, '') === normalizedPath || + e.path === `/${normalizedPath}` + ); + + if (!entityExists) { + throw new Error(`File not found: ${path}`); + } + + // Try to find entity with ID for direct download + try { + const entity = await this.findEntityByPath(projectId, path); + if (entity && entity.type !== 'folder') { + return await this.downloadFile(projectId, entity.id, entity.type); + } + } catch (e) { + // Fall through to zip method + } + + // Fallback: download zip and extract the file + const zipBuffer = await this.downloadProject(projectId); + const AdmZip = (await import('adm-zip')).default; + const zip = new AdmZip(zipBuffer); + + for (const entry of zip.getEntries()) { + if (entry.entryName === normalizedPath || entry.entryName === path) { + return entry.getData(); + } + } + + throw new Error(`File not found in archive: ${path}`); } /** From 0a0f5d4b95b07beccaf0a7336d12b4270f15f289 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 16 Apr 2026 18:03:32 +0200 Subject: [PATCH 02/20] The tests are ran using the local file --- test/e2e.sh | 97 +++++++++++++++++++++++++++-------------------------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/test/e2e.sh b/test/e2e.sh index d7101ba..eb501e0 100755 --- a/test/e2e.sh +++ b/test/e2e.sh @@ -18,6 +18,7 @@ TESTS_PASSED=0 TESTS_FAILED=0 CLEANUP_FILES=() CLEANUP_REMOTE_FILES=() +EXE="$(pwd)/dist/cli.js" # Test project name (override with OLCLI_E2E_PROJECT_NAME) PROJECT_NAME="${OLCLI_E2E_PROJECT_NAME:-olcli test}" @@ -58,16 +59,16 @@ run_test() { local name="$1" local cmd="$2" local expect_success="${3:-true}" - + TESTS_RUN=$((TESTS_RUN + 1)) - + echo -n " Testing: $name ... " - + local output local exit_code - + output=$(eval "$cmd" 2>&1) && exit_code=0 || exit_code=$? - + if [ "$expect_success" = "true" ]; then if [ $exit_code -eq 0 ]; then echo -e "${GREEN}✓${NC}" @@ -103,16 +104,16 @@ run_test_with_output() { local name="$1" local cmd="$2" local expected_pattern="$3" - + TESTS_RUN=$((TESTS_RUN + 1)) - + echo -n " Testing: $name ... " - + local output local exit_code - + output=$(eval "$cmd" 2>&1) && exit_code=0 || exit_code=$? - + if [ $exit_code -eq 0 ] && echo "$output" | grep -qE "$expected_pattern"; then echo -e "${GREEN}✓${NC}" TESTS_PASSED=$((TESTS_PASSED + 1)) @@ -132,18 +133,18 @@ run_test_with_output() { # Cleanup function cleanup() { log_section "Cleanup" - + # Remove local temp files if [ -d "$TEST_DIR" ]; then log_info "Removing temp directory: $TEST_DIR" rm -rf "$TEST_DIR" fi - + # Remove remote test files (best effort) for file in "${CLEANUP_REMOTE_FILES[@]}"; do log_info "Note: Test file '$file' may remain on Overleaf (delete manually if needed)" done - + # Summary echo "" log_section "Test Results" @@ -152,7 +153,7 @@ cleanup() { echo -e " ${GREEN}Passed:${NC} $TESTS_PASSED" echo -e " ${RED}Failed:${NC} $TESTS_FAILED" echo "" - + if [ $TESTS_FAILED -eq 0 ]; then log_success "All tests passed! 🎉" exit 0 @@ -178,12 +179,12 @@ log_info "Test directory: $TEST_DIR" log_info "Project: $PROJECT_NAME" # Verify olcli is available -if ! command -v olcli &> /dev/null; then +if ! command -v $EXE &> /dev/null; then log_fail "olcli command not found. Run 'npm link' first." exit 1 fi -log_info "olcli version: $(olcli --version)" +log_info "olcli version: $($EXE --version)" ####################################### # Test: Authentication @@ -192,11 +193,11 @@ log_info "olcli version: $(olcli --version)" log_section "Authentication Tests" run_test_with_output "whoami returns user info" \ - "olcli whoami" \ + "$EXE whoami" \ "(Logged in as|Email:|Authenticated)" run_test "check shows config info" \ - "olcli check" + "$EXE check" ####################################### # Test: Project Listing @@ -205,17 +206,17 @@ run_test "check shows config info" \ log_section "Project Listing Tests" run_test "list shows target project" \ - "olcli list | grep -F \"$PROJECT_NAME\"" + "$EXE list | grep -F \"$PROJECT_NAME\"" run_test_with_output "list --json returns valid JSON" \ - "olcli list --json | jq -e 'type == \"array\"'" \ + "$EXE list --json | jq -e 'type == \"array\"'" \ "true" # Get project ID for later tests log_info "Waiting 5s before API calls to avoid rate limiting..." sleep 5 -PROJECT_ID=$(olcli list --json | jq -r --arg project_name "$PROJECT_NAME" '.[] | select(.name == $project_name) | .id') +PROJECT_ID=$($EXE list --json | jq -r --arg project_name "$PROJECT_NAME" '.[] | select(.name == $project_name) | .id') if [ -z "$PROJECT_ID" ]; then log_fail "Could not find '$PROJECT_NAME' project. Please create it on Overleaf first." exit 1 @@ -231,15 +232,15 @@ log_info "Using project ID directly to minimize API calls" log_section "Project Info Tests" run_test_with_output "info by name" \ - "olcli info '$PROJECT_NAME'" \ + "$EXE info '$PROJECT_NAME'" \ "(Project:|Files:)" run_test_with_output "info by ID" \ - "olcli info '$PROJECT_ID'" \ + "$EXE info '$PROJECT_ID'" \ "(Project:|Files:)" run_test_with_output "info --json returns valid JSON" \ - "olcli info '$PROJECT_ID' --json | jq -e '.project.id'" \ + "$EXE info '$PROJECT_ID' --json | jq -e '.project.id'" \ "$PROJECT_ID" ####################################### @@ -254,7 +255,7 @@ echo "$TEST_CONTENT" > "$TEST_FILE" CLEANUP_REMOTE_FILES+=("${TEST_ID}.txt") run_test "upload file to project" \ - "olcli upload '$TEST_FILE' '$PROJECT_ID'" + "$EXE upload '$TEST_FILE' '$PROJECT_ID'" # Create file in subfolder test TEST_FILE2="$TEST_DIR/${TEST_ID}_2.txt" @@ -262,7 +263,7 @@ echo "Second test file - $TEST_CONTENT" > "$TEST_FILE2" CLEANUP_REMOTE_FILES+=("${TEST_ID}_2.txt") run_test "upload second file" \ - "olcli upload '$TEST_FILE2' '$PROJECT_ID'" + "$EXE upload '$TEST_FILE2' '$PROJECT_ID'" ####################################### # Test: File Download (single file) @@ -273,7 +274,7 @@ log_section "File Download Tests" DOWNLOAD_FILE="$TEST_DIR/downloaded_${TEST_ID}.txt" run_test "download single file" \ - "olcli download '${TEST_ID}.txt' '$PROJECT_ID' -o '$DOWNLOAD_FILE'" + "$EXE download '${TEST_ID}.txt' '$PROJECT_ID' -o '$DOWNLOAD_FILE'" # Verify content matches TESTS_RUN=$((TESTS_RUN + 1)) @@ -299,7 +300,7 @@ sleep 1 # Rate limit # Download second uploaded file (project-agnostic check) DOWNLOAD_FILE2="$TEST_DIR/downloaded_${TEST_ID}_2.txt" run_test "download second uploaded file" \ - "olcli download '${TEST_ID}_2.txt' '$PROJECT_ID' -o '$DOWNLOAD_FILE2'" + "$EXE download '${TEST_ID}_2.txt' '$PROJECT_ID' -o '$DOWNLOAD_FILE2'" run_test_with_output "second downloaded content matches marker" \ "grep -F \"Second test file - $TEST_CONTENT\" '$DOWNLOAD_FILE2'" \ @@ -314,7 +315,7 @@ log_section "Zip Archive Tests" ZIP_FILE="$TEST_DIR/project.zip" run_test "download project as zip" \ - "olcli zip '$PROJECT_ID' -o '$ZIP_FILE'" + "$EXE zip '$PROJECT_ID' -o '$ZIP_FILE'" TESTS_RUN=$((TESTS_RUN + 1)) echo -n " Testing: zip file is valid ... " @@ -346,7 +347,7 @@ fi log_section "Compile Tests" run_test_with_output "compile project" \ - "olcli compile '$PROJECT_ID'" \ + "$EXE compile '$PROJECT_ID'" \ "(success|failure|Compiled)" ####################################### @@ -360,7 +361,7 @@ PDF_FILE="$TEST_DIR/output.pdf" # Note: This may fail if compilation fails TESTS_RUN=$((TESTS_RUN + 1)) echo -n " Testing: download PDF ... " -if olcli pdf "$PROJECT_ID" -o "$PDF_FILE" 2>&1; then +if $EXE pdf "$PROJECT_ID" -o "$PDF_FILE" 2>&1; then if [ -f "$PDF_FILE" ] && [ -s "$PDF_FILE" ]; then # Check PDF magic bytes if head -c 4 "$PDF_FILE" | grep -q "%PDF"; then @@ -390,13 +391,13 @@ sleep 1 # Rate limit log_section "Output Files Tests" run_test_with_output "output --list shows files" \ - "olcli output --list --project '$PROJECT_ID'" \ + "$EXE output --list --project '$PROJECT_ID'" \ "(log|aux|pdf)" # Download log file LOG_FILE="$TEST_DIR/output.log" run_test "download log output" \ - "olcli output log -o '$LOG_FILE' --project '$PROJECT_ID'" + "$EXE output log -o '$LOG_FILE' --project '$PROJECT_ID'" TESTS_RUN=$((TESTS_RUN + 1)) echo -n " Testing: log file has content ... " @@ -414,7 +415,7 @@ sleep 1 # Rate limit BBL_FILE="$TEST_DIR/output.bbl" TESTS_RUN=$((TESTS_RUN + 1)) echo -n " Testing: download bbl output (optional) ... " -if olcli output bbl -o "$BBL_FILE" --project "$PROJECT_ID" > /dev/null 2>&1; then +if $EXE output bbl -o "$BBL_FILE" --project "$PROJECT_ID" > /dev/null 2>&1; then if [ -f "$BBL_FILE" ] && [ -s "$BBL_FILE" ]; then echo -e "${GREEN}✓${NC}" TESTS_PASSED=$((TESTS_PASSED + 1)) @@ -438,7 +439,7 @@ PULL_DIR="$TEST_DIR/pulled_project" mkdir -p "$PULL_DIR" run_test "pull project to directory" \ - "olcli pull '$PROJECT_ID' '$PULL_DIR' --force" + "$EXE pull '$PROJECT_ID' '$PULL_DIR' --force" TESTS_RUN=$((TESTS_RUN + 1)) echo -n " Testing: .olcli.json created ... " @@ -489,16 +490,16 @@ sleep 1 touch "$PUSH_TEST_FILE" run_test "push --dry-run shows changes" \ - "cd '$PULL_DIR' && olcli push --dry-run" + "cd '$PULL_DIR' && $EXE push --dry-run" run_test "push uploads changes" \ - "cd '$PULL_DIR' && olcli push --all" + "cd '$PULL_DIR' && $EXE push --all" # Verify by downloading VERIFY_FILE="$TEST_DIR/verify_push.txt" sleep 2 # Give Overleaf a moment run_test "download pushed file" \ - "olcli download '${TEST_ID}_push.txt' '$PROJECT_ID' -o '$VERIFY_FILE'" + "$EXE download '${TEST_ID}_push.txt' '$PROJECT_ID' -o '$VERIFY_FILE'" TESTS_RUN=$((TESTS_RUN + 1)) echo -n " Testing: pushed content matches ... " @@ -534,13 +535,13 @@ if [ -f "$PULL_DIR/.olcli.json" ]; then fi run_test "push recovers from stale rootFolderId" \ - "cd '$PULL_DIR' && olcli push" + "cd '$PULL_DIR' && $EXE push" # Verify recovery upload by downloading the new file VERIFY_RECOVER_FILE="$TEST_DIR/verify_push_recover.txt" sleep 2 # Give Overleaf a moment run_test "download recovered push file" \ - "olcli download '${TEST_ID}_push_recover.txt' '$PROJECT_ID' -o '$VERIFY_RECOVER_FILE'" + "$EXE download '${TEST_ID}_push_recover.txt' '$PROJECT_ID' -o '$VERIFY_RECOVER_FILE'" TESTS_RUN=$((TESTS_RUN + 1)) echo -n " Testing: recovered push content matches ... " @@ -573,7 +574,7 @@ mkdir -p "$SYNC_DIR" # Initial pull run_test "sync (initial pull)" \ - "olcli pull '$PROJECT_ID' '$SYNC_DIR' --force" + "$EXE pull '$PROJECT_ID' '$SYNC_DIR' --force" # Create local file SYNC_TEST_FILE="$SYNC_DIR/${TEST_ID}_sync.txt" @@ -582,13 +583,13 @@ echo "$SYNC_CONTENT" > "$SYNC_TEST_FILE" CLEANUP_REMOTE_FILES+=("${TEST_ID}_sync.txt") run_test "sync bidirectional" \ - "cd '$SYNC_DIR' && olcli sync" + "cd '$SYNC_DIR' && $EXE sync" # Verify upload SYNC_VERIFY="$TEST_DIR/verify_sync.txt" sleep 2 run_test "verify synced file exists" \ - "olcli download '${TEST_ID}_sync.txt' '$PROJECT_ID' -o '$SYNC_VERIFY'" + "$EXE download '${TEST_ID}_sync.txt' '$PROJECT_ID' -o '$SYNC_VERIFY'" # NOTE: delete and rename commands are disabled in olcli (require Socket.IO) # Delete test files manually via Overleaf web UI @@ -600,11 +601,11 @@ run_test "verify synced file exists" \ log_section "Error Handling Tests" run_test "download nonexistent file fails gracefully" \ - "olcli download 'nonexistent_file_xyz.tex' '$PROJECT_ID'" \ + "$EXE download 'nonexistent_file_xyz.tex' '$PROJECT_ID'" \ false run_test "info for nonexistent project fails gracefully" \ - "olcli info 'project_that_does_not_exist_xyz'" \ + "$EXE info 'project_that_does_not_exist_xyz'" \ false ####################################### @@ -615,7 +616,7 @@ log_section "Edge Case Tests" # Project by ID run_test "commands work with project ID" \ - "olcli info '$PROJECT_ID'" + "$EXE info '$PROJECT_ID'" # Special characters in filename (safe ones only) SPECIAL_FILE="$TEST_DIR/test-file_123.txt" @@ -623,10 +624,10 @@ echo "special filename test" > "$SPECIAL_FILE" CLEANUP_REMOTE_FILES+=("test-file_123.txt") run_test "upload file with dashes and underscores" \ - "olcli upload '$SPECIAL_FILE' '$PROJECT_ID'" + "$EXE upload '$SPECIAL_FILE' '$PROJECT_ID'" run_test "download file with dashes and underscores" \ - "olcli download 'test-file_123.txt' '$PROJECT_ID' -o '$TEST_DIR/dl_special.txt'" + "$EXE download 'test-file_123.txt' '$PROJECT_ID' -o '$TEST_DIR/dl_special.txt'" ####################################### # Cleanup Note From a8ff74a2a2e12a2aeab8476f1e2c906111cdb76c Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 16 Apr 2026 18:10:11 +0200 Subject: [PATCH 03/20] making the file executable if it is not. --- test/e2e.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/e2e.sh b/test/e2e.sh index eb501e0..b3f0560 100755 --- a/test/e2e.sh +++ b/test/e2e.sh @@ -20,6 +20,17 @@ CLEANUP_FILES=() CLEANUP_REMOTE_FILES=() EXE="$(pwd)/dist/cli.js" +if test -f $EXE; then + if ! [[ -x "$EXE" ]] + then + chmod +x $EXE + fi +else + echo "Binary file does not exist, compile first." + exit +fi + + # Test project name (override with OLCLI_E2E_PROJECT_NAME) PROJECT_NAME="${OLCLI_E2E_PROJECT_NAME:-olcli test}" From b081402cfd1db0d7b882a34f4b4e393ac0e8ba26 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 16 Apr 2026 18:11:40 +0200 Subject: [PATCH 04/20] First attempt at file deletion, works but can deleted files created remotly. --- src/cli.ts | 1557 +++++++++++++++++++++++++++------------------------- 1 file changed, 799 insertions(+), 758 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 473a13b..f60af2c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,11 +35,11 @@ import { const program = new Command(); program - .name('olcli') - .description('Overleaf CLI - interact with Overleaf projects from the command line') - .version(VERSION) - .option('--base-url ', 'Overleaf instance base URL (overrides OVERLEAF_BASE_URL and config)') - .option('--cookie-name ', 'Session cookie name (default: overleaf_session2, use overleaf.sid for older instances)'); +.name('olcli') +.description('Overleaf CLI - interact with Overleaf projects from the command line') +.version(VERSION) +.option('--base-url ', 'Overleaf instance base URL (overrides OVERLEAF_BASE_URL and config)') +.option('--cookie-name ', 'Session cookie name (default: overleaf_session2, use overleaf.sid for older instances)'); /** * Helper to get authenticated client @@ -78,7 +78,7 @@ async function resolveProject( // Trust the ID, use a placeholder name (will be overwritten on next list) return { id: projectArg, name: projectArg }; } - + // Otherwise, look up by name let proj = await client.getProject(projectArg); if (!proj) { @@ -105,164 +105,164 @@ async function resolveProject( // ───────────────────────────────────────────────────────────────────────────── program - .command('auth') - .description('Authenticate with Overleaf using session cookie') - .option('--cookie ', 'Session cookie (overleaf_session2 value)') - .option('--save-local', 'Save to .olauth in current directory') - .action(async (options) => { - if (!options.cookie) { - console.log(chalk.yellow('To authenticate, provide your session cookie:')); - console.log(); - console.log('1. Log into overleaf.com in your browser'); - console.log('2. Open Developer Tools (F12) → Application → Cookies'); - console.log('3. Find the cookie named "overleaf_session2"'); - console.log('4. Copy its value and run:'); - console.log(); - console.log(chalk.cyan(' olcli auth --cookie "your_session_cookie_value"')); - console.log(); - console.log('Or set OVERLEAF_SESSION environment variable'); - return; - } - - const spinner = ora('Verifying session...').start(); - try { - const baseUrl = (program.opts().baseUrl as string | undefined) || getBaseUrl(); - const cookieName = (program.opts().cookieName as string | undefined) || getSessionCookieName(); - const client = await OverleafClient.fromSessionCookie(options.cookie, baseUrl, cookieName); - const projects = await client.listProjects(); +.command('auth') +.description('Authenticate with Overleaf using session cookie') +.option('--cookie ', 'Session cookie (overleaf_session2 value)') +.option('--save-local', 'Save to .olauth in current directory') +.action(async (options) => { + if (!options.cookie) { + console.log(chalk.yellow('To authenticate, provide your session cookie:')); + console.log(); + console.log('1. Log into overleaf.com in your browser'); + console.log('2. Open Developer Tools (F12) → Application → Cookies'); + console.log('3. Find the cookie named "overleaf_session2"'); + console.log('4. Copy its value and run:'); + console.log(); + console.log(chalk.cyan(' olcli auth --cookie "your_session_cookie_value"')); + console.log(); + console.log('Or set OVERLEAF_SESSION environment variable'); + return; + } - setSessionCookie(options.cookie); + const spinner = ora('Verifying session...').start(); + try { + const baseUrl = (program.opts().baseUrl as string | undefined) || getBaseUrl(); + const cookieName = (program.opts().cookieName as string | undefined) || getSessionCookieName(); + const client = await OverleafClient.fromSessionCookie(options.cookie, baseUrl, cookieName); + const projects = await client.listProjects(); - if (options.saveLocal) { - saveOlAuth(options.cookie); - spinner.succeed(`Authenticated! Found ${projects.length} projects. Saved to .olauth`); - } else { - spinner.succeed(`Authenticated! Found ${projects.length} projects.`); - } + setSessionCookie(options.cookie); - console.log(chalk.dim(`Config saved to: ${getConfigPath()}`)); - } catch (error: any) { - spinner.fail(`Authentication failed: ${error.message}`); - process.exit(1); + if (options.saveLocal) { + saveOlAuth(options.cookie); + spinner.succeed(`Authenticated! Found ${projects.length} projects. Saved to .olauth`); + } else { + spinner.succeed(`Authenticated! Found ${projects.length} projects.`); } - }); + + console.log(chalk.dim(`Config saved to: ${getConfigPath()}`)); + } catch (error: any) { + spinner.fail(`Authentication failed: ${error.message}`); + process.exit(1); + } +}); program - .command('whoami') - .description('Show current authentication status') - .action(async () => { - const cookie = getSessionCookie(); - if (!cookie) { - console.log(chalk.yellow('Not authenticated')); - return; - } +.command('whoami') +.description('Show current authentication status') +.action(async () => { + const cookie = getSessionCookie(); + if (!cookie) { + console.log(chalk.yellow('Not authenticated')); + return; + } - const spinner = ora('Checking session...').start(); - try { - const baseUrl = (program.opts().baseUrl as string | undefined) || getBaseUrl(); - const cookieName = (program.opts().cookieName as string | undefined) || getSessionCookieName(); - const client = await OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); - const projects = await client.listProjects(); - spinner.succeed(`Authenticated with access to ${projects.length} projects`); - } catch (error: any) { - spinner.fail(`Session invalid: ${error.message}`); - } - }); + const spinner = ora('Checking session...').start(); + try { + const baseUrl = (program.opts().baseUrl as string | undefined) || getBaseUrl(); + const cookieName = (program.opts().cookieName as string | undefined) || getSessionCookieName(); + const client = await OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); + const projects = await client.listProjects(); + spinner.succeed(`Authenticated with access to ${projects.length} projects`); + } catch (error: any) { + spinner.fail(`Session invalid: ${error.message}`); + } +}); program - .command('logout') - .description('Clear stored credentials') - .action(() => { - clearConfig(); - console.log(chalk.green('Credentials cleared')); - }); +.command('logout') +.description('Clear stored credentials') +.action(() => { + clearConfig(); + console.log(chalk.green('Credentials cleared')); +}); // ───────────────────────────────────────────────────────────────────────────── // PROJECT COMMANDS // ───────────────────────────────────────────────────────────────────────────── program - .command('list') - .alias('ls') - .description('List all projects') - .option('--json', 'Output as JSON') - .option('-n, --limit ', 'Limit number of results', parseInt) - .option('--cookie ', 'Session cookie override') - .action(async (options) => { - const spinner = ora('Fetching projects...').start(); - try { - const client = await getClient(options.cookie); - let projects = await client.listProjects(); - - if (options.limit) { - projects = projects.slice(0, options.limit); - } +.command('list') +.alias('ls') +.description('List all projects') +.option('--json', 'Output as JSON') +.option('-n, --limit ', 'Limit number of results', parseInt) +.option('--cookie ', 'Session cookie override') +.action(async (options) => { + const spinner = ora('Fetching projects...').start(); + try { + const client = await getClient(options.cookie); + let projects = await client.listProjects(); + + if (options.limit) { + projects = projects.slice(0, options.limit); + } - spinner.stop(); + spinner.stop(); - if (options.json) { - console.log(JSON.stringify(projects, null, 2)); - return; - } + if (options.json) { + console.log(JSON.stringify(projects, null, 2)); + return; + } - if (projects.length === 0) { - console.log(chalk.yellow('No projects found')); - return; - } + if (projects.length === 0) { + console.log(chalk.yellow('No projects found')); + return; + } - console.log(chalk.bold(`Found ${projects.length} project(s):\n`)); - for (const p of projects) { - const date = new Date(p.lastUpdated).toLocaleDateString(); - console.log(` ${chalk.cyan(p.id)} - ${chalk.bold(p.name)}`); - console.log(` ${chalk.dim(`Last updated: ${date}`)}`); - } - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - process.exit(1); + console.log(chalk.bold(`Found ${projects.length} project(s):\n`)); + for (const p of projects) { + const date = new Date(p.lastUpdated).toLocaleDateString(); + console.log(` ${chalk.cyan(p.id)} - ${chalk.bold(p.name)}`); + console.log(` ${chalk.dim(`Last updated: ${date}`)}`); } - }); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); program - .command('info [project]') - .description('Show project details (by name or ID)') - .option('--json', 'Output as JSON') - .option('--cookie ', 'Session cookie override') - .action(async (project, options) => { - const spinner = ora('Fetching project info...').start(); - try { - const client = await getClient(options.cookie); - const proj = await resolveProject(client, project); - - // Get entities (works without parsing HTML) - const entities = await client.getEntities(proj.id); - spinner.stop(); +.command('info [project]') +.description('Show project details (by name or ID)') +.option('--json', 'Output as JSON') +.option('--cookie ', 'Session cookie override') +.action(async (project, options) => { + const spinner = ora('Fetching project info...').start(); + try { + const client = await getClient(options.cookie); + const proj = await resolveProject(client, project); + + // Get entities (works without parsing HTML) + const entities = await client.getEntities(proj.id); + spinner.stop(); + + if (options.json) { + console.log(JSON.stringify({ project: proj, entities }, null, 2)); + return; + } - if (options.json) { - console.log(JSON.stringify({ project: proj, entities }, null, 2)); - return; - } + console.log(chalk.bold(`Project: ${proj.name}`)); + console.log(` ID: ${chalk.cyan(proj.id)}`); + console.log(); - console.log(chalk.bold(`Project: ${proj.name}`)); - console.log(` ID: ${chalk.cyan(proj.id)}`); - console.log(); + // Print file list grouped by folder + console.log(chalk.bold('Files:')); - // Print file list grouped by folder - console.log(chalk.bold('Files:')); - - // Sort entities by path for nice display - const sorted = entities.sort((a, b) => a.path.localeCompare(b.path)); - - for (const entity of sorted) { - const icon = entity.type === 'doc' ? '📄' : '📎'; - console.log(` ${icon} ${entity.path}`); - } + // Sort entities by path for nice display + const sorted = entities.sort((a, b) => a.path.localeCompare(b.path)); - setLastProject(proj.id); - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - process.exit(1); + for (const entity of sorted) { + const icon = entity.type === 'doc' ? '📄' : '📎'; + console.log(` ${icon} ${entity.path}`); } - }); + + setLastProject(proj.id); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); function printFolder(folder: any, indent: string): void { // Print subfolders @@ -287,186 +287,186 @@ function printFolder(folder: any, indent: string): void { // ───────────────────────────────────────────────────────────────────────────── program - .command('download [project]') - .description('Download a single file from project') - .option('-o, --output ', 'Output path (default: same as file name)') - .option('--cookie ', 'Session cookie override') - .action(async (file, project, options) => { - const spinner = ora('Downloading file...').start(); - try { - const client = await getClient(options.cookie); - const proj = await resolveProject(client, project); - - const content = await client.downloadByPath(proj.id, file); - const outputPath = options.output || basename(file); - - writeFileSync(outputPath, content); - spinner.succeed(`Downloaded: ${outputPath} (${(content.length / 1024).toFixed(1)} KB)`); - - setLastProject(proj.id); - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - process.exit(1); - } - }); +.command('download [project]') +.description('Download a single file from project') +.option('-o, --output ', 'Output path (default: same as file name)') +.option('--cookie ', 'Session cookie override') +.action(async (file, project, options) => { + const spinner = ora('Downloading file...').start(); + try { + const client = await getClient(options.cookie); + const proj = await resolveProject(client, project); + + const content = await client.downloadByPath(proj.id, file); + const outputPath = options.output || basename(file); + + writeFileSync(outputPath, content); + spinner.succeed(`Downloaded: ${outputPath} (${(content.length / 1024).toFixed(1)} KB)`); + + setLastProject(proj.id); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); program - .command('zip [project]') - .description('Download project as zip archive') - .option('-o, --output ', 'Output path (default: .zip)') - .option('--cookie ', 'Session cookie override') - .action(async (project, options) => { - const spinner = ora('Downloading project...').start(); - try { - const client = await getClient(options.cookie); - const proj = await resolveProject(client, project); - - const zip = await client.downloadProject(proj.id); - const outputPath = options.output || `${proj.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.zip`; - - writeFileSync(outputPath, zip); - spinner.succeed(`Downloaded: ${outputPath} (${(zip.length / 1024).toFixed(1)} KB)`); - - setLastProject(proj.id); - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - process.exit(1); - } - }); +.command('zip [project]') +.description('Download project as zip archive') +.option('-o, --output ', 'Output path (default: .zip)') +.option('--cookie ', 'Session cookie override') +.action(async (project, options) => { + const spinner = ora('Downloading project...').start(); + try { + const client = await getClient(options.cookie); + const proj = await resolveProject(client, project); + + const zip = await client.downloadProject(proj.id); + const outputPath = options.output || `${proj.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.zip`; + + writeFileSync(outputPath, zip); + spinner.succeed(`Downloaded: ${outputPath} (${(zip.length / 1024).toFixed(1)} KB)`); + + setLastProject(proj.id); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); program - .command('pdf [project]') - .description('Compile and download PDF') - .option('-o, --output ', 'Output path (default: .pdf)') - .option('--cookie ', 'Session cookie override') - .action(async (project, options) => { - const spinner = ora('Compiling project...').start(); - try { - const client = await getClient(options.cookie); - const proj = await resolveProject(client, project); - - spinner.text = 'Compiling...'; - const pdf = await client.downloadPdf(proj.id); - const outputPath = options.output || `${proj.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.pdf`; - - writeFileSync(outputPath, pdf); - spinner.succeed(`Downloaded PDF: ${outputPath} (${(pdf.length / 1024).toFixed(1)} KB)`); - - setLastProject(proj.id); - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - process.exit(1); - } - }); +.command('pdf [project]') +.description('Compile and download PDF') +.option('-o, --output ', 'Output path (default: .pdf)') +.option('--cookie ', 'Session cookie override') +.action(async (project, options) => { + const spinner = ora('Compiling project...').start(); + try { + const client = await getClient(options.cookie); + const proj = await resolveProject(client, project); + + spinner.text = 'Compiling...'; + const pdf = await client.downloadPdf(proj.id); + const outputPath = options.output || `${proj.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.pdf`; + + writeFileSync(outputPath, pdf); + spinner.succeed(`Downloaded PDF: ${outputPath} (${(pdf.length / 1024).toFixed(1)} KB)`); + + setLastProject(proj.id); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); program - .command('output [type]') - .description('Download compile output files (bbl, log, aux, etc.)') - .option('-o, --output ', 'Output path') - .option('--list', 'List available output files') - .option('--project ', 'Project name or ID') - .option('--cookie ', 'Session cookie override') - .action(async (type, options) => { - const spinner = ora('Compiling project...').start(); - try { - const client = await getClient(options.cookie); - - // If type looks like a project name (contains spaces or is in project list), treat it as project - let actualType = type; - let projectArg = options.project; - - if (type && !projectArg && !['bbl', 'log', 'aux', 'blg', 'pdf', 'out', 'fls', 'fdb_latexmk', 'stderr', 'pdfxref', 'chktex'].includes(type)) { - // Type might actually be a project name - const projects = await client.listProjects(); - const matchedProject = projects.find(p => p.name === type || p.id === type); - if (matchedProject) { - projectArg = type; - actualType = undefined; - } +.command('output [type]') +.description('Download compile output files (bbl, log, aux, etc.)') +.option('-o, --output ', 'Output path') +.option('--list', 'List available output files') +.option('--project ', 'Project name or ID') +.option('--cookie ', 'Session cookie override') +.action(async (type, options) => { + const spinner = ora('Compiling project...').start(); + try { + const client = await getClient(options.cookie); + + // If type looks like a project name (contains spaces or is in project list), treat it as project + let actualType = type; + let projectArg = options.project; + + if (type && !projectArg && !['bbl', 'log', 'aux', 'blg', 'pdf', 'out', 'fls', 'fdb_latexmk', 'stderr', 'pdfxref', 'chktex'].includes(type)) { + // Type might actually be a project name + const projects = await client.listProjects(); + const matchedProject = projects.find(p => p.name === type || p.id === type); + if (matchedProject) { + projectArg = type; + actualType = undefined; } + } - const proj = await resolveProject(client, projectArg); - const result = await client.compileWithOutputs(proj.id); + const proj = await resolveProject(client, projectArg); + const result = await client.compileWithOutputs(proj.id); - if (result.status !== 'success') { - spinner.warn(`Compilation ${result.status}, but output files may still be available`); - } + if (result.status !== 'success') { + spinner.warn(`Compilation ${result.status}, but output files may still be available`); + } - if (options.list || !actualType) { - spinner.stop(); - console.log(chalk.bold('Available output files:')); - for (const file of result.outputFiles) { - console.log(` ${chalk.cyan(file.type.padEnd(12))} ${file.path}`); - } - console.log(); - console.log(chalk.dim('Usage: olcli output ')); - console.log(chalk.dim('Example: olcli output bbl')); - return; + if (options.list || !actualType) { + spinner.stop(); + console.log(chalk.bold('Available output files:')); + for (const file of result.outputFiles) { + console.log(` ${chalk.cyan(file.type.padEnd(12))} ${file.path}`); } + console.log(); + console.log(chalk.dim('Usage: olcli output ')); + console.log(chalk.dim('Example: olcli output bbl')); + return; + } - // Find matching output file - const outputFile = result.outputFiles.find(f => f.type === actualType || f.path.endsWith(`.${actualType}`)); - if (!outputFile) { - spinner.fail(`Output file not found: ${actualType}`); - console.log(chalk.dim('Use --list to see available files')); - process.exit(1); - } + // Find matching output file + const outputFile = result.outputFiles.find(f => f.type === actualType || f.path.endsWith(`.${actualType}`)); + if (!outputFile) { + spinner.fail(`Output file not found: ${actualType}`); + console.log(chalk.dim('Use --list to see available files')); + process.exit(1); + } - spinner.text = `Downloading ${outputFile.path}...`; - const content = await client.downloadOutputFile(outputFile.url); - const outputPath = options.output || outputFile.path.replace('output.', ''); + spinner.text = `Downloading ${outputFile.path}...`; + const content = await client.downloadOutputFile(outputFile.url); + const outputPath = options.output || outputFile.path.replace('output.', ''); - writeFileSync(outputPath, content); - spinner.succeed(`Downloaded: ${outputPath} (${(content.length / 1024).toFixed(1)} KB)`); + writeFileSync(outputPath, content); + spinner.succeed(`Downloaded: ${outputPath} (${(content.length / 1024).toFixed(1)} KB)`); - setLastProject(proj.id); - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - process.exit(1); - } - }); + setLastProject(proj.id); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); // ───────────────────────────────────────────────────────────────────────────── // UPLOAD COMMANDS // ───────────────────────────────────────────────────────────────────────────── program - .command('upload [project]') - .description('Upload a file to a project') - .option('--folder ', 'Target folder ID (default: root)') - .option('--cookie ', 'Session cookie override') - .action(async (file, project, options) => { - const spinner = ora('Uploading file...').start(); - try { - const client = await getClient(options.cookie); - const proj = await resolveProject(client, project); - - if (!existsSync(file)) { - spinner.fail(`File not found: ${file}`); - process.exit(1); - } - - const content = readFileSync(file); - const fileName = basename(file); +.command('upload [project]') +.description('Upload a file to a project') +.option('--folder ', 'Target folder ID (default: root)') +.option('--cookie ', 'Session cookie override') +.action(async (file, project, options) => { + const spinner = ora('Uploading file...').start(); + try { + const client = await getClient(options.cookie); + const proj = await resolveProject(client, project); + + if (!existsSync(file)) { + spinner.fail(`File not found: ${file}`); + process.exit(1); + } - // Pass folder ID or null for root folder (client will compute it) - const folderId = options.folder || null; + const content = readFileSync(file); + const fileName = basename(file); - const result = await client.uploadFile(proj.id, folderId, fileName, content); + // Pass folder ID or null for root folder (client will compute it) + const folderId = options.folder || null; - if (result.success) { - spinner.succeed(`Uploaded: ${fileName} → "${proj.name}"`); - } else { - spinner.fail(`Upload failed for: ${fileName}`); - process.exit(1); - } + const result = await client.uploadFile(proj.id, folderId, fileName, content); - setLastProject(proj.id); - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); + if (result.success) { + spinner.succeed(`Uploaded: ${fileName} → "${proj.name}"`); + } else { + spinner.fail(`Upload failed for: ${fileName}`); process.exit(1); } - }); + + setLastProject(proj.id); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); // NOTE: delete and rename commands are disabled - they require entity IDs // which are not exposed via the current Overleaf API without Socket.IO. @@ -515,586 +515,627 @@ program // ───────────────────────────────────────────────────────────────────────────── program - .command('compile [project]') - .description('Compile a project (trigger PDF generation)') - .option('--cookie ', 'Session cookie override') - .action(async (project, options) => { - const spinner = ora('Compiling...').start(); - try { - const client = await getClient(options.cookie); - const proj = await resolveProject(client, project); - - const result = await client.compileProject(proj.id); - spinner.succeed(`Compiled "${proj.name}"`); - console.log(chalk.dim(`PDF URL: ${result.pdfUrl}`)); - - setLastProject(proj.id); - } catch (error: any) { - spinner.fail(`Compilation failed: ${error.message}`); - process.exit(1); - } - }); +.command('compile [project]') +.description('Compile a project (trigger PDF generation)') +.option('--cookie ', 'Session cookie override') +.action(async (project, options) => { + const spinner = ora('Compiling...').start(); + try { + const client = await getClient(options.cookie); + const proj = await resolveProject(client, project); + + const result = await client.compileProject(proj.id); + spinner.succeed(`Compiled "${proj.name}"`); + console.log(chalk.dim(`PDF URL: ${result.pdfUrl}`)); + + setLastProject(proj.id); + } catch (error: any) { + spinner.fail(`Compilation failed: ${error.message}`); + process.exit(1); + } +}); // ───────────────────────────────────────────────────────────────────────────── // SYNC COMMANDS // ───────────────────────────────────────────────────────────────────────────── program - .command('pull [project] [dir]') - .description('Download project files to local directory') - .option('--force', 'Overwrite local files even if newer') - .option('--cookie ', 'Session cookie override') - .action(async (project, dir, options) => { - let targetDir = dir || '.'; - let projectId: string | undefined; - let projectName: string | undefined; - - // Check for existing .olcli.json if no project specified - const metaPath = join(targetDir, '.olcli.json'); - if (!project && existsSync(metaPath)) { - const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); - projectId = meta.projectId; - projectName = meta.projectName; - } else if (!project) { - console.error(chalk.red('No project specified.')); - console.error('Usage: olcli pull [dir]'); - console.error('Or run from a directory with .olcli.json'); - process.exit(1); - } +.command('pull [project] [dir]') +.description('Download project files to local directory') +.option('--force', 'Overwrite local files even if newer') +.option('--cookie ', 'Session cookie override') +.action(async (project, dir, options) => { + let targetDir = dir || '.'; + let projectId: string | undefined; + let projectName: string | undefined; + + // Check for existing .olcli.json if no project specified + const metaPath = join(targetDir, '.olcli.json'); + if (!project && existsSync(metaPath)) { + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); + projectId = meta.projectId; + projectName = meta.projectName; + } else if (!project) { + console.error(chalk.red('No project specified.')); + console.error('Usage: olcli pull [dir]'); + console.error('Or run from a directory with .olcli.json'); + process.exit(1); + } - const spinner = ora('Fetching project...').start(); - try { - const client = await getClient(options.cookie); + const spinner = ora('Fetching project...').start(); + try { + const client = await getClient(options.cookie); - // Resolve project if needed - if (!projectId) { - let proj = await client.getProjectById(project!); - if (!proj) { - proj = await client.getProject(project!); - } - if (!proj) { - spinner.fail(`Project not found: ${project}`); - process.exit(1); - } - projectId = proj.id; - projectName = proj.name; - // Default directory is project name (sanitized) if not specified - if (!dir) { - targetDir = proj.name.replace(/[^a-zA-Z0-9-_]/g, '_'); - } + // Resolve project if needed + if (!projectId) { + let proj = await client.getProjectById(project!); + if (!proj) { + proj = await client.getProject(project!); } + if (!proj) { + spinner.fail(`Project not found: ${project}`); + process.exit(1); + } + projectId = proj.id; + projectName = proj.name; + // Default directory is project name (sanitized) if not specified + if (!dir) { + targetDir = proj.name.replace(/[^a-zA-Z0-9-_]/g, '_'); + } + } - spinner.text = 'Downloading project...'; - const zipBuffer = await client.downloadProject(projectId); + spinner.text = 'Downloading project...'; + const zipBuffer = await client.downloadProject(projectId); - // Extract zip - spinner.text = 'Extracting files...'; - const AdmZip = (await import('adm-zip')).default; - const zip = new AdmZip(zipBuffer); + // Extract zip + spinner.text = 'Extracting files...'; + const AdmZip = (await import('adm-zip')).default; + const zip = new AdmZip(zipBuffer); - // Create target directory - if (!existsSync(targetDir)) { - mkdirSync(targetDir, { recursive: true }); - } + // Create target directory + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } - // Get local file modification times for safety check - const { statSync } = await import('node:fs'); - const localMetaPath = join(targetDir, '.olcli.json'); - let lastPull: Date | undefined; - if (existsSync(localMetaPath)) { - const meta = JSON.parse(readFileSync(localMetaPath, 'utf-8')); - lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined; - } + // Get local file modification times for safety check + const { statSync } = await import('node:fs'); + const localMetaPath = join(targetDir, '.olcli.json'); + let lastPull: Date | undefined; + if (existsSync(localMetaPath)) { + const meta = JSON.parse(readFileSync(localMetaPath, 'utf-8')); + lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined; + } - // Extract files with safety check - const entries = zip.getEntries(); - let fileCount = 0; - let skippedCount = 0; - const skippedFiles: string[] = []; + // Extract files with safety check + const entries = zip.getEntries(); + let fileCount = 0; + let skippedCount = 0; + const skippedFiles: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory) { - const filePath = join(targetDir, entry.entryName); - const fileDir = dirname(filePath); - - // Check if local file exists and is newer than last pull - if (!options.force && existsSync(filePath) && lastPull) { - try { - const stats = statSync(filePath); - if (stats.mtime > lastPull) { - // Local file is newer - skip unless --force - skippedCount++; - skippedFiles.push(entry.entryName); - continue; - } - } catch (e) { - // File doesn't exist or can't stat, proceed with download - } - } + for (const entry of entries) { + if (!entry.isDirectory) { + const filePath = join(targetDir, entry.entryName); + const fileDir = dirname(filePath); - if (!existsSync(fileDir)) { - mkdirSync(fileDir, { recursive: true }); + // Check if local file exists and is newer than last pull + if (!options.force && existsSync(filePath) && lastPull) { + try { + const stats = statSync(filePath); + if (stats.mtime > lastPull) { + // Local file is newer - skip unless --force + skippedCount++; + skippedFiles.push(entry.entryName); + continue; + } + } catch (e) { + // File doesn't exist or can't stat, proceed with download } - writeFileSync(filePath, entry.getData()); - fileCount++; } - } - // Save project metadata - writeFileSync(join(targetDir, '.olcli.json'), JSON.stringify({ - projectId, - projectName, - lastPull: new Date().toISOString() - }, null, 2)); - - if (skippedCount > 0) { - spinner.warn(`Downloaded ${fileCount} files, skipped ${skippedCount} locally modified files`); - console.log(chalk.yellow(' Skipped (local is newer):')); - for (const f of skippedFiles.slice(0, 5)) { - console.log(chalk.dim(` ${f}`)); - } - if (skippedFiles.length > 5) { - console.log(chalk.dim(` ... and ${skippedFiles.length - 5} more`)); + if (!existsSync(fileDir)) { + mkdirSync(fileDir, { recursive: true }); } - console.log(chalk.dim(' Use --force to overwrite')); - } else { - spinner.succeed(`Downloaded ${fileCount} files to ${targetDir}/`); + writeFileSync(filePath, entry.getData()); + fileCount++; } + } - setLastProject(projectId); - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - process.exit(1); + // Save project metadata + writeFileSync(join(targetDir, '.olcli.json'), JSON.stringify({ + projectId, + projectName, + lastPull: new Date().toISOString() + }, null, 2)); + + if (skippedCount > 0) { + spinner.warn(`Downloaded ${fileCount} files, skipped ${skippedCount} locally modified files`); + console.log(chalk.yellow(' Skipped (local is newer):')); + for (const f of skippedFiles.slice(0, 5)) { + console.log(chalk.dim(` ${f}`)); + } + if (skippedFiles.length > 5) { + console.log(chalk.dim(` ... and ${skippedFiles.length - 5} more`)); + } + console.log(chalk.dim(' Use --force to overwrite')); + } else { + spinner.succeed(`Downloaded ${fileCount} files to ${targetDir}/`); } - }); + + setLastProject(projectId); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); + program - .command('push [dir]') - .description('Upload local changes to Overleaf project') - .option('--project ', 'Project name or ID (overrides .olcli.json)') - .option('--all', 'Upload all files (not just changed)') - .option('--dry-run', 'Show what would be uploaded without uploading') - .option('--probe-folder', 'Probe for correct folder ID (use if uploads fail with folder_not_found)') - .option('--cookie ', 'Session cookie override') - .action(async (dir, options) => { - const targetDir = dir || '.'; - const metaPath = join(targetDir, '.olcli.json'); - - // Check for project metadata - let projectId: string | undefined; - let projectName: string | undefined; - let lastPull: Date | undefined; - let rootFolderId: string | undefined; +.command('push [dir]') +.description('Upload local changes to Overleaf project') +.option('--project ', 'Project name or ID (overrides .olcli.json)') +.option('--all', 'Upload all files (not just changed)') +.option('--dry-run', 'Show what would be uploaded/deleted without changing anything') +.option('--probe-folder', 'Probe for correct folder ID') +.option('--cookie ', 'Session cookie override') +.action(async (dir, options) => { + const targetDir = dir || '.'; + const metaPath = join(targetDir, '.olcli.json'); + + // Check for project metadata + let projectId: string | undefined; + let projectName: string | undefined; + let lastPull: Date | undefined; + let rootFolderId: string | undefined; - if (existsSync(metaPath)) { - const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); - projectId = meta.projectId; - projectName = meta.projectName; - lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined; - rootFolderId = meta.rootFolderId; - } + if (existsSync(metaPath)) { + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); + projectId = meta.projectId; + projectName = meta.projectName; + lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined; + rootFolderId = meta.rootFolderId; + } - if (options.project) { - // Override with command line option - projectId = undefined; - projectName = options.project; - } + if (options.project) { + projectId = undefined; + projectName = options.project; + } - if (!projectId && !projectName) { - console.error(chalk.red('No project specified.')); - console.error('Either run from a directory with .olcli.json or use --project'); - process.exit(1); - } + if (!projectId && !projectName) { + console.error(chalk.red('No project specified.')); + console.error('Either run from a directory with .olcli.json or use --project'); + process.exit(1); + } - const spinner = ora('Connecting...').start(); - try { - const client = await getClient(options.cookie); + const spinner = ora('Connecting...').start(); + try { + const client = await getClient(options.cookie); - // Resolve project if needed - if (!projectId) { - let proj = await client.getProjectById(projectName!); - if (!proj) { - proj = await client.getProject(projectName!); - } - if (!proj) { - spinner.fail(`Project not found: ${projectName}`); - process.exit(1); - } - projectId = proj.id; - projectName = proj.name; + // Resolve project if needed + if (!projectId) { + let proj = await client.getProjectById(projectName!); + if (!proj) { + proj = await client.getProject(projectName!); + } + if (!proj) { + spinner.fail(`Project not found: ${projectName}`); + process.exit(1); } + projectId = proj.id; + projectName = proj.name; + } - spinner.text = 'Scanning files...'; + spinner.text = 'Scanning files...'; - // Get list of files to upload - const { readdirSync, statSync } = await import('node:fs'); + // Get list of files to upload + const { readdirSync, statSync } = await import('node:fs'); - const filesToUpload: { path: string; relativePath: string }[] = []; + const filesToUpload: { path: string; relativePath: string }[] = []; + const allLocalPaths = new Set(); - function scanDir(currentDir: string, relativeBase: string = '') { - const entries = readdirSync(currentDir, { withFileTypes: true }); - for (const entry of entries) { - // Skip hidden files and .olcli.json - if (entry.name.startsWith('.')) continue; + function scanDir(currentDir: string, relativeBase: string = '') { + const entries = readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + // Skip hidden files and .olcli.json + if (entry.name.startsWith('.')) continue; - const fullPath = join(currentDir, entry.name); - const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; + const fullPath = join(currentDir, entry.name); + const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; - if (!entry.isDirectory() && entry.name === 'output.pdf') continue; + if (!entry.isDirectory() && entry.name === 'output.pdf') continue; - if (entry.isDirectory()) { - scanDir(fullPath, relativePath); + if (entry.isDirectory()) { + scanDir(fullPath, relativePath); + } else { + allLocalPaths.add(relativePath); + // Check if file is newer than last pull (unless --all) + if (options.all || !lastPull) { + filesToUpload.push({ path: fullPath, relativePath }); } else { - // Check if file is newer than last pull (unless --all) - if (options.all || !lastPull) { + const stats = statSync(fullPath); + if (stats.mtime > lastPull) { filesToUpload.push({ path: fullPath, relativePath }); - } else { - const stats = statSync(fullPath); - if (stats.mtime > lastPull) { - filesToUpload.push({ path: fullPath, relativePath }); - } } } } } + } - scanDir(targetDir); + scanDir(targetDir); - if (filesToUpload.length === 0) { - spinner.info('No files to upload'); - return; - } + // ========================================== + // THE DELETION LOGIC + // ========================================== + const filesToDelete: { id: string; type: 'doc' | 'file' | 'folder' ; path: string }[] = []; - if (options.dryRun) { - spinner.stop(); - console.log(chalk.bold(`Would upload ${filesToUpload.length} file(s) to "${projectName}":`)); - for (const f of filesToUpload) { - console.log(` ${chalk.cyan(f.relativePath)}`); - } - return; - } + const projectInfo = await client.getProjectInfo(projectId); + if (projectInfo && projectInfo.rootFolder && projectInfo.rootFolder[0]) { - // If --probe-folder is set, or if we don't have a cached rootFolderId, try probing - if (options.probeFolder && !rootFolderId) { - spinner.text = 'Probing for correct folder ID...'; - rootFolderId = await client.probeRootFolderId(projectId!) ?? undefined; - if (rootFolderId) { - // Save the discovered folder ID - if (existsSync(metaPath)) { - const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); - meta.rootFolderId = rootFolderId; - writeFileSync(metaPath, JSON.stringify(meta, null, 2)); - } - spinner.succeed(`Found root folder ID: ${rootFolderId}`); - spinner.start(`Uploading ${filesToUpload.length} file(s)...`); - } else { - spinner.fail('Could not find valid root folder ID'); - console.log(chalk.yellow('Try manually specifying rootFolderId in .olcli.json')); - process.exit(1); + // Helper function to flatten Overleaf's nested tree + function flattenRemoteTree(folder: any, currentPath: string = '') { + // Text files + for (const doc of folder.docs || []) { + const docPath = currentPath ? `${currentPath}/${doc.name}` : doc.name; + if (!allLocalPaths.has(docPath)) filesToDelete.push({ id: doc._id, type: 'doc', path: docPath }); + } + // Binary files (images, pdfs) + for (const file of folder.fileRefs || []) { + const filePath = currentPath ? `${currentPath}/${file.name}` : file.name; + if (!allLocalPaths.has(filePath)) filesToDelete.push({ id: file._id, type: 'file', path: filePath }); + } + // Subfolders + for (const sub of folder.folders || []) { + const subPath = currentPath ? `${currentPath}/${sub.name}` : sub.name; + flattenRemoteTree(sub, subPath); } } - // Fetch folder tree once so uploads go into correct subfolders - spinner.text = 'Resolving folder structure...'; - let folderTree = await client.getFolderTreeFromSocket(projectId!); - if (!folderTree) { - // Fallback: build minimal tree with just root - const resolvedRootId = rootFolderId || await client.getRootFolderId(projectId!); - folderTree = { '': resolvedRootId }; - } + flattenRemoteTree(projectInfo.rootFolder[0]); + } - spinner.text = `Uploading ${filesToUpload.length} file(s)...`; + // Early out + if (filesToUpload.length === 0 && filesToDelete.length === 0){ + spinner.succeed('No local changes to upload.'); + return; + } - let uploaded = 0; - let failed = 0; - let folderNotFoundCount = 0; + // Handle Dry Run + if (options.dryRun) { + spinner.stop(); + console.log(chalk.bold(`Would upload ${filesToUpload.length} file(s):`)); + filesToUpload.forEach(f => console.log(` ${chalk.green('+ ' + f.relativePath)}`)); - for (const file of filesToUpload) { - try { - const content = readFileSync(file.path); - await client.uploadFile(projectId!, rootFolderId || null, file.relativePath, content, folderTree); - uploaded++; - spinner.text = `Uploading... (${uploaded}/${filesToUpload.length})`; - } catch (error: any) { - console.error(chalk.yellow(`\n Warning: Failed to upload ${file.relativePath}: ${error.message}`)); - failed++; - if (error.message.includes('folder_not_found')) { - folderNotFoundCount++; - } + console.log(chalk.bold(`Would delete ${filesToDelete.length} remote file(s):`)); + filesToDelete.forEach(f => console.log(` ${chalk.red('- ' + f.path)}`)); + return; + } + + let deleted = 0; + let failed = 0; + let folderNotFoundCount = 0; + + // Execute Deletions + spinner.text = `Deleting ${filesToDelete.length} orphan files...`; + for (const file of filesToDelete) { + try { + await client.deleteEntity(projectId!, file.id, file.type); + deleted++; + spinner.text = `Deleting... (${deleted}/${filesToDelete.length})`; + } catch (error: any) { + console.error(chalk.yellow(`\nWarning: Failed to delete ${file.path}: ${error.message}`)); + failed++; + if (error.message.includes('folder_not_found')) { + folderNotFoundCount++; } } + } + // ========================================== - // Update last push time - if (existsSync(metaPath)) { - const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); - meta.lastPush = new Date().toISOString(); - writeFileSync(metaPath, JSON.stringify(meta, null, 2)); - } - if (failed > 0) { - spinner.warn(`Uploaded ${uploaded} file(s), ${failed} failed`); - if (folderNotFoundCount > 0 && !rootFolderId) { - console.log(chalk.yellow(' Tip: Try running with --probe-folder to find the correct folder ID')); + // Fetch folder tree once so uploads go into correct subfolders + spinner.text = 'Resolving folder structure...'; + let folderTree = await client.getFolderTreeFromSocket(projectId!); + if (!folderTree) { + // Fallback: build minimal tree with just root + const resolvedRootId = rootFolderId || await client.getRootFolderId(projectId!); + folderTree = { '': resolvedRootId }; + } + + spinner.text = `Uploading ${filesToUpload.length} file(s)...`; + + let uploaded = 0; + + for (const file of filesToUpload) { + try { + const content = readFileSync(file.path); + await client.uploadFile(projectId!, rootFolderId || null, file.relativePath, content, folderTree); + uploaded++; + spinner.text = `Uploading... (${uploaded}/${filesToUpload.length})`; + } catch (error: any) { + console.error(chalk.yellow(`\n Warning: Failed to upload ${file.relativePath}: ${error.message}`)); + failed++; + if (error.message.includes('folder_not_found')) { + folderNotFoundCount++; } - } else { - spinner.succeed(`Uploaded ${uploaded} file(s) to "${projectName}"`); } - - setLastProject(projectId!); - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - process.exit(1); } - }); - -program - .command('sync [dir]') - .description('Pull then push (bidirectional sync)') - .option('--project ', 'Project name or ID') - .option('--verbose', 'Show detailed file operations') - .option('--cookie ', 'Session cookie override') - .action(async (dir, options) => { - const targetDir = dir || '.'; - - // Check if this is an existing project directory - const metaPath = join(targetDir, '.olcli.json'); - let projectId: string | undefined; - let projectName: string | undefined; + // Update last push time if (existsSync(metaPath)) { const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); - projectId = meta.projectId; - projectName = meta.projectName; + meta.lastPush = new Date().toISOString(); + writeFileSync(metaPath, JSON.stringify(meta, null, 2)); } - if (options.project) { - projectName = options.project; - projectId = undefined; + if (failed > 0) { + spinner.warn(`Uploaded ${uploaded} file(s), deleted ${deleted} and ${failed} failed`); + if (folderNotFoundCount > 0 && !rootFolderId) { + console.log(chalk.yellow(' Tip: Try running with --probe-folder to find the correct folder ID')); + } + } else { + if(deleted ==0) { + spinner.succeed(`Uploaded ${uploaded} file(s) to "${projectName}"`); + }else if(uploaded ==0){ + spinner.succeed(`Deleted ${deleted} file(s) from "${projectName}"`); + }else{ + spinner.succeed(`Uploaded ${uploaded} file(s) to and deleted ${deleted} file(s)`); + } } - if (!projectId && !projectName) { - console.error(chalk.red('No project specified.')); - console.error('Either run from a directory with .olcli.json or use --project'); - process.exit(1); - } + setLastProject(projectId!); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); - const spinner = ora('Connecting...').start(); - try { - const client = await getClient(options.cookie); +program +.command('sync [dir]') +.description('Pull then push (bidirectional sync)') +.option('--project ', 'Project name or ID') +.option('--verbose', 'Show detailed file operations') +.option('--cookie ', 'Session cookie override') +.action(async (dir, options) => { + const targetDir = dir || '.'; + + // Check if this is an existing project directory + const metaPath = join(targetDir, '.olcli.json'); + let projectId: string | undefined; + let projectName: string | undefined; - // Resolve project - if (!projectId) { - let proj = await client.getProjectById(projectName!); - if (!proj) { - proj = await client.getProject(projectName!); - } - if (!proj) { - spinner.fail(`Project not found: ${projectName}`); - process.exit(1); - } - projectId = proj.id; - projectName = proj.name; - } + if (existsSync(metaPath)) { + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); + projectId = meta.projectId; + projectName = meta.projectName; + } - // Step 1: Download current state - spinner.text = 'Downloading project...'; - const zipBuffer = await client.downloadProject(projectId); + if (options.project) { + projectName = options.project; + projectId = undefined; + } - const AdmZip = (await import('adm-zip')).default; - const zip = new AdmZip(zipBuffer); + if (!projectId && !projectName) { + console.error(chalk.red('No project specified.')); + console.error('Either run from a directory with .olcli.json or use --project'); + process.exit(1); + } - // Create target directory - if (!existsSync(targetDir)) { - mkdirSync(targetDir, { recursive: true }); - } + const spinner = ora('Connecting...').start(); + try { + const client = await getClient(options.cookie); - // Track local modifications - const localFiles = new Map(); - const { readdirSync, statSync } = await import('node:fs'); - - function scanLocalFiles(currentDir: string, relativeBase: string = '') { - if (!existsSync(currentDir)) return; - const entries = readdirSync(currentDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name.startsWith('.')) continue; - const fullPath = join(currentDir, entry.name); - const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; - if (entry.isDirectory()) { - scanLocalFiles(fullPath, relativePath); - } else { - const stats = statSync(fullPath); - localFiles.set(relativePath, { - mtime: stats.mtime, - content: readFileSync(fullPath) - }); - } - } + // Resolve project + if (!projectId) { + let proj = await client.getProjectById(projectName!); + if (!proj) { + proj = await client.getProject(projectName!); } - - // Read local files before overwriting - if (existsSync(metaPath)) { - scanLocalFiles(targetDir); + if (!proj) { + spinner.fail(`Project not found: ${projectName}`); + process.exit(1); } + projectId = proj.id; + projectName = proj.name; + } + + // Step 1: Download current state + spinner.text = 'Downloading project...'; + const zipBuffer = await client.downloadProject(projectId); + + const AdmZip = (await import('adm-zip')).default; + const zip = new AdmZip(zipBuffer); + + // Create target directory + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } + + // Track local modifications + const localFiles = new Map(); + const { readdirSync, statSync } = await import('node:fs'); - // Extract remote files - const remoteFiles = new Map(); - for (const entry of zip.getEntries()) { - if (!entry.isDirectory) { - remoteFiles.set(entry.entryName, entry.getData()); + function scanLocalFiles(currentDir: string, relativeBase: string = '') { + if (!existsSync(currentDir)) return; + const entries = readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const fullPath = join(currentDir, entry.name); + const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + scanLocalFiles(fullPath, relativePath); + } else { + const stats = statSync(fullPath); + localFiles.set(relativePath, { + mtime: stats.mtime, + content: readFileSync(fullPath) + }); } } + } + + // Read local files before overwriting + if (existsSync(metaPath)) { + scanLocalFiles(targetDir); + } - // Merge: local changes take precedence for files modified after last pull - let lastPull: Date | undefined; - if (existsSync(metaPath)) { - const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); - lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined; + // Extract remote files + const remoteFiles = new Map(); + for (const entry of zip.getEntries()) { + if (!entry.isDirectory) { + remoteFiles.set(entry.entryName, entry.getData()); } + } - const filesToUpload: { path: string; content: Buffer }[] = []; - const filesUpdatedLocally: string[] = []; - const filesKeptLocal: string[] = []; - const filesNewLocal: string[] = []; + // Merge: local changes take precedence for files modified after last pull + let lastPull: Date | undefined; + if (existsSync(metaPath)) { + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); + lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined; + } - spinner.text = 'Comparing files...'; + const filesToUpload: { path: string; content: Buffer }[] = []; + const filesUpdatedLocally: string[] = []; + const filesKeptLocal: string[] = []; + const filesNewLocal: string[] = []; - // Write remote files, but preserve local modifications - for (const [path, remoteContent] of remoteFiles) { - const filePath = join(targetDir, path); - const fileDir = dirname(filePath); - if (!existsSync(fileDir)) { - mkdirSync(fileDir, { recursive: true }); - } + spinner.text = 'Comparing files...'; - const localFile = localFiles.get(path); - if (localFile && lastPull && localFile.mtime > lastPull) { - // Local file was modified after last pull - keep local, queue for upload if different - if (!localFile.content.equals(remoteContent)) { - filesToUpload.push({ path, content: localFile.content }); - filesKeptLocal.push(path); - } - // Don't overwrite local file - } else { - // Write remote version - writeFileSync(filePath, remoteContent); - filesUpdatedLocally.push(path); - } + // Write remote files, but preserve local modifications + for (const [path, remoteContent] of remoteFiles) { + const filePath = join(targetDir, path); + const fileDir = dirname(filePath); + if (!existsSync(fileDir)) { + mkdirSync(fileDir, { recursive: true }); } - // Check for new local files (not in remote) - for (const [path, localFile] of localFiles) { - if (path === 'output.pdf' || path.endsWith('/output.pdf')) { - continue; - } - if (!remoteFiles.has(path)) { + const localFile = localFiles.get(path); + if (localFile && lastPull && localFile.mtime > lastPull) { + // Local file was modified after last pull - keep local, queue for upload if different + if (!localFile.content.equals(remoteContent)) { filesToUpload.push({ path, content: localFile.content }); - filesNewLocal.push(path); + filesKeptLocal.push(path); } + // Don't overwrite local file + } else { + // Write remote version + writeFileSync(filePath, remoteContent); + filesUpdatedLocally.push(path); } + } - // Upload local changes - if (filesToUpload.length > 0) { - spinner.text = `Uploading ${filesToUpload.length} local change(s)...`; - for (const file of filesToUpload) { - await client.uploadFile(projectId, null, file.path, file.content); - } + // Check for new local files (not in remote) + for (const [path, localFile] of localFiles) { + if (path === 'output.pdf' || path.endsWith('/output.pdf')) { + continue; } + if (!remoteFiles.has(path)) { + filesToUpload.push({ path, content: localFile.content }); + filesNewLocal.push(path); + } + } - // Update metadata - writeFileSync(metaPath, JSON.stringify({ - projectId, - projectName, - lastPull: new Date().toISOString(), - lastSync: new Date().toISOString() - }, null, 2)); - - spinner.succeed(`Synced "${projectName}"`); - - // Summary - console.log(chalk.dim(` ↓ ${filesUpdatedLocally.length} pulled from remote`)); - console.log(chalk.dim(` ↑ ${filesToUpload.length} pushed to remote`)); - - if (options.verbose) { - if (filesKeptLocal.length > 0) { - console.log(chalk.yellow('\n Local changes pushed (local was newer):')); - for (const f of filesKeptLocal) { - console.log(chalk.dim(` ${f}`)); - } + // Upload local changes + if (filesToUpload.length > 0) { + spinner.text = `Uploading ${filesToUpload.length} local change(s)...`; + for (const file of filesToUpload) { + await client.uploadFile(projectId, null, file.path, file.content); + } + } + + // Update metadata + writeFileSync(metaPath, JSON.stringify({ + projectId, + projectName, + lastPull: new Date().toISOString(), + lastSync: new Date().toISOString() + }, null, 2)); + + spinner.succeed(`Synced "${projectName}"`); + + // Summary + console.log(chalk.dim(` ↓ ${filesUpdatedLocally.length} pulled from remote`)); + console.log(chalk.dim(` ↑ ${filesToUpload.length} pushed to remote`)); + + if (options.verbose) { + if (filesKeptLocal.length > 0) { + console.log(chalk.yellow('\n Local changes pushed (local was newer):')); + for (const f of filesKeptLocal) { + console.log(chalk.dim(` ${f}`)); } - if (filesNewLocal.length > 0) { - console.log(chalk.green('\n New local files pushed:')); - for (const f of filesNewLocal) { - console.log(chalk.dim(` ${f}`)); - } + } + if (filesNewLocal.length > 0) { + console.log(chalk.green('\n New local files pushed:')); + for (const f of filesNewLocal) { + console.log(chalk.dim(` ${f}`)); } } - - setLastProject(projectId); - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - process.exit(1); } - }); + + setLastProject(projectId); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); // ───────────────────────────────────────────────────────────────────────────── // HELP // ───────────────────────────────────────────────────────────────────────────── const configCmd = program - .command('config') - .description('Manage olcli configuration'); +.command('config') +.description('Manage olcli configuration'); configCmd - .command('set-url ') - .description('Set the Overleaf instance base URL') - .action((url: string) => { - setBaseUrl(url); - console.log(chalk.green(`Base URL set to: ${url}`)); - }); +.command('set-url ') +.description('Set the Overleaf instance base URL') +.action((url: string) => { + setBaseUrl(url); + console.log(chalk.green(`Base URL set to: ${url}`)); +}); configCmd - .command('get-url') - .description('Get the current Overleaf instance base URL') - .action(() => { - console.log(getBaseUrl()); - }); +.command('get-url') +.description('Get the current Overleaf instance base URL') +.action(() => { + console.log(getBaseUrl()); +}); configCmd - .command('set-cookie-name ') - .description('Set the session cookie name (e.g. overleaf.sid for older instances)') - .action((name: string) => { - setSessionCookieName(name); - console.log(chalk.green(`Session cookie name set to: ${name}`)); - }); +.command('set-cookie-name ') +.description('Set the session cookie name (e.g. overleaf.sid for older instances)') +.action((name: string) => { + setSessionCookieName(name); + console.log(chalk.green(`Session cookie name set to: ${name}`)); +}); configCmd - .command('get-cookie-name') - .description('Get the current session cookie name') - .action(() => { - console.log(getSessionCookieName()); - }); +.command('get-cookie-name') +.description('Get the current session cookie name') +.action(() => { + console.log(getSessionCookieName()); +}); program - .command('check') - .description('Show credential sources and config path') - .action(() => { - console.log(chalk.bold('Configuration:')); - console.log(` Config file: ${getConfigPath()}`); - console.log(); - - console.log(chalk.bold('Credential sources (in order):')); - console.log(' 1. OVERLEAF_SESSION environment variable'); - console.log(' 2. .olauth file in current directory'); - console.log(' 3. Global config file'); - console.log(); - - const cookie = getSessionCookie(); - if (cookie) { - console.log(chalk.green('✓ Session cookie found')); - console.log(chalk.dim(` Value: ${cookie.substring(0, 20)}...`)); - } else { - console.log(chalk.yellow('✗ No session cookie found')); - } - }); +.command('check') +.description('Show credential sources and config path') +.action(() => { + console.log(chalk.bold('Configuration:')); + console.log(` Config file: ${getConfigPath()}`); + console.log(); + + console.log(chalk.bold('Credential sources (in order):')); + console.log(' 1. OVERLEAF_SESSION environment variable'); + console.log(' 2. .olauth file in current directory'); + console.log(' 3. Global config file'); + console.log(); + + const cookie = getSessionCookie(); + if (cookie) { + console.log(chalk.green('✓ Session cookie found')); + console.log(chalk.dim(` Value: ${cookie.substring(0, 20)}...`)); + } else { + console.log(chalk.yellow('✗ No session cookie found')); + } +}); program.parse(process.argv); From ceb1861ff97f540a68f7055ee4c7f60c1f678073 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 16 Apr 2026 18:20:02 +0200 Subject: [PATCH 05/20] Ground work for true git integration --- package.json | 3 ++- src/git-helper.js | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/git-helper.js diff --git a/package.json b/package.json index 4ecec00..4b10b9e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Command-line interface for Overleaf — Sync, manage, and compile LaTeX projects from your terminal", "type": "module", "bin": { - "olcli": "dist/cli.js" + "olcli": "dist/cli.js", + "git-remote-overleaf": "dist/git-helper.js" }, "scripts": { "build": "tsc", diff --git a/src/git-helper.js b/src/git-helper.js new file mode 100644 index 0000000..934bbfc --- /dev/null +++ b/src/git-helper.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import readline from 'node:readline'; + +// Git passes the remote name and URL as arguments: +// e.g., process.argv = ['node', 'git-remote-overleaf', 'origin', 'overleaf::123456'] +const remoteName = process.argv[2]; +const url = process.argv[3]; +const projectId = url.split('::')[1]; // Extracts "123456" + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, +}); + +rl.on('line', async line => { + if (line === 'capabilities') { + console.log('fetch'); + console.log('push'); + console.log(''); // Empty line means end of response + } else if (line === 'list') { + // Return a dummy hash for now, representing the current Overleaf state + console.log('0000000000000000000000000000000000000000 refs/heads/master'); + console.log('@refs/heads/master HEAD'); + console.log(''); + } else if (line.startsWith('fetch')) { + // 1. Download the Overleaf ZIP using olcli's API client + // 2. Unzip it into a temporary folder + // 3. Feed the files into Git + console.log(''); + } else if (line.startsWith('push')) { + // 1. Read the local Git files + // 2. Upload them to Overleaf using your olcli push logic + console.log('ok refs/heads/master'); // Tell Git it succeeded + console.log(''); + } else if (line === '') { + // Empty line from Git means "I'm done, you can exit" + process.exit(0); + } +}); From aa5a1209dcfc1e2e15930a3e414667ff37bff8aa Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Fri, 17 Apr 2026 14:55:03 +0200 Subject: [PATCH 06/20] not working yet, moved getClient to client.ts --- package-lock.json | 13 ++--- package.json | 2 +- src/cli.ts | 18 +------ src/client.ts | 30 +++++++++++ src/git-helper.js | 40 --------------- src/git-helper.ts | 127 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 64 deletions(-) delete mode 100644 src/git-helper.js create mode 100644 src/git-helper.ts diff --git a/package-lock.json b/package-lock.json index 8d69441..86ad654 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "olcli", + "name": "@aloth/olcli", "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "olcli", + "name": "@aloth/olcli", "version": "0.1.7", "license": "MIT", "dependencies": { @@ -18,11 +18,12 @@ "tough-cookie": "^4.1.4" }, "bin": { + "git-remote-overleaf": "dist/git-helper.js", "olcli": "dist/cli.js" }, "devDependencies": { "@types/adm-zip": "^0.5.7", - "@types/node": "^22.0.0", + "@types/node": "^22.19.17", "@types/tough-cookie": "^4.0.5", "tsx": "^4.7.0", "typescript": "^5.4.0" @@ -484,9 +485,9 @@ } }, "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4b10b9e..dc11431 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.7", - "@types/node": "^22.0.0", + "@types/node": "^22.19.17", "@types/tough-cookie": "^4.0.5", "tsx": "^4.7.0", "typescript": "^5.4.0" diff --git a/src/cli.ts b/src/cli.ts index f60af2c..b0a5db0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import ora from 'ora'; import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; import { join, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { OverleafClient } from './client.js'; +import { OverleafClient, getClient } from './client.js'; // Read version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -41,22 +41,6 @@ program .option('--base-url ', 'Overleaf instance base URL (overrides OVERLEAF_BASE_URL and config)') .option('--cookie-name ', 'Session cookie name (default: overleaf_session2, use overleaf.sid for older instances)'); -/** - * Helper to get authenticated client - */ -async function getClient(cookieOpt?: string, baseUrlOpt?: string): Promise { - const cookie = cookieOpt || getSessionCookie(); - if (!cookie) { - console.error(chalk.red('No session cookie found.')); - console.error('Set one with: olcli auth --cookie '); - console.error('Or set OVERLEAF_SESSION environment variable'); - console.error('Or create .olauth file in current directory'); - process.exit(1); - } - const baseUrl = baseUrlOpt || (program.opts().baseUrl as string | undefined) || getBaseUrl(); - const cookieName = (program.opts().cookieName as string | undefined) || getSessionCookieName(); - return OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); -} /** * Resolve project from argument or .olcli.json in current directory diff --git a/src/client.ts b/src/client.ts index d04abe9..504219b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -10,6 +10,19 @@ import { CookieJar, Cookie } from 'tough-cookie'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + getSessionCookie, + setSessionCookie, + getLastProject, + setLastProject, + getConfigPath, + saveOlAuth, + clearConfig, + getBaseUrl, + setBaseUrl, + getSessionCookieName, + setSessionCookieName +} from './config.js'; // Read version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -59,6 +72,23 @@ export interface Credentials { baseUrl?: string; } +/** + * Helper to get authenticated client + */ +export async function getClient(cookieOpt?: string, baseUrlOpt?: string): Promise { + const cookie = cookieOpt || getSessionCookie(); + if (!cookie) { + console.error('No session cookie found.'); + console.error('Set one with: olcli auth --cookie '); + console.error('Or set OVERLEAF_SESSION environment variable'); + console.error('Or create .olauth file in current directory'); + process.exit(1); + } + const baseUrl = baseUrlOpt || getBaseUrl(); + const cookieName = getSessionCookieName(); + return OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); +} + export class OverleafClient { private cookies: Record; private csrf: string; diff --git a/src/git-helper.js b/src/git-helper.js deleted file mode 100644 index 934bbfc..0000000 --- a/src/git-helper.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import readline from 'node:readline'; - -// Git passes the remote name and URL as arguments: -// e.g., process.argv = ['node', 'git-remote-overleaf', 'origin', 'overleaf::123456'] -const remoteName = process.argv[2]; -const url = process.argv[3]; -const projectId = url.split('::')[1]; // Extracts "123456" - -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false, -}); - -rl.on('line', async line => { - if (line === 'capabilities') { - console.log('fetch'); - console.log('push'); - console.log(''); // Empty line means end of response - } else if (line === 'list') { - // Return a dummy hash for now, representing the current Overleaf state - console.log('0000000000000000000000000000000000000000 refs/heads/master'); - console.log('@refs/heads/master HEAD'); - console.log(''); - } else if (line.startsWith('fetch')) { - // 1. Download the Overleaf ZIP using olcli's API client - // 2. Unzip it into a temporary folder - // 3. Feed the files into Git - console.log(''); - } else if (line.startsWith('push')) { - // 1. Read the local Git files - // 2. Upload them to Overleaf using your olcli push logic - console.log('ok refs/heads/master'); // Tell Git it succeeded - console.log(''); - } else if (line === '') { - // Empty line from Git means "I'm done, you can exit" - process.exit(0); - } -}); diff --git a/src/git-helper.ts b/src/git-helper.ts new file mode 100644 index 0000000..97e1712 --- /dev/null +++ b/src/git-helper.ts @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import * as readline from 'node:readline'; +import { mkdtempSync, rmSync, statSync, createReadStream, writeFileSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import AdmZip from 'adm-zip'; + +import { getClient } from './client.js'; + +const remoteName = process.argv[2]; +const url = process.argv[3]; + +const projectId = url;//TODO Handles real urls + +process.argv = [process.argv[0], process.argv[1]]; + + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false +}); + +// A flag to track if we are in the middle of a batch command +let isImporting = false; + +// Using an async loop ensures we process one line fully before reading the next! +for await (const line of rl) { + console.error(`[DEBUG] Git asked: ${line}`); + + if (line === 'capabilities') { + console.log('import'); + // FIX: Tell Git exactly how our branch maps to its branch + console.log('refspec HEAD:refs/heads/main'); + console.log(''); + } + + else if (line === 'list') { + console.log(`? refs/heads/main`); + console.log(`@refs/heads/main HEAD`); + console.log(''); + } + + else if (line.startsWith('import')) { + isImporting = true; + let tempDir = ''; + try { + const client = await getClient(); + + console.error(`[olcli] Fetching project from Overleaf...`); + const zipBuffer = await client.downloadProject(projectId); + + tempDir = mkdtempSync(join(tmpdir(), 'overleaf-sync-')); + const zipPath = join(tempDir, 'project.zip'); + const extractDir = join(tempDir, 'extracted'); + + writeFileSync(zipPath, zipBuffer); + const zip = new AdmZip(zipPath); + zip.extractAllTo(extractDir, true); + + function getFilesToImport(dir: string, fileList: string[] = []) { + const items = readdirSync(dir, { withFileTypes: true }); + for (const item of items) { + const fullPath = join(dir, item.name); + if (item.isDirectory()) { + getFilesToImport(fullPath, fileList); + } else { + fileList.push(fullPath); + } + } + return fileList; + } + + const files = getFilesToImport(extractDir); + + const timestamp = Math.floor(Date.now() / 1000); + const commitMsg = "Sync from Overleaf\n"; + + process.stdout.write(`commit refs/heads/main\n`); + process.stdout.write(`committer Overleaf Sync ${timestamp} +0000\n`); + process.stdout.write(`data ${Buffer.byteLength(commitMsg, 'utf8')}\n`); + process.stdout.write(commitMsg); + + for (const filePath of files) { + const repoPath = relative(extractDir, filePath).replace(/\\/g, '/'); + const fileSize = statSync(filePath).size; + + process.stdout.write(`M 100644 inline "${repoPath.replace(/"/g, '\\"')}"\n`); + process.stdout.write(`data ${fileSize}\n`); + + await new Promise((resolve, reject) => { + const stream = createReadStream(filePath); + stream.on('data', chunk => process.stdout.write(chunk)); + stream.on('end', () => { + process.stdout.write(`\n`); + resolve(); + }); + stream.on('error', reject); + }); + } + + process.stdout.write(`done\n`); + // Note: We do NOT print a blank console.log('') here. + // Git will send a blank line to finish the batch, and we handle it below! + + } catch (error: any) { + console.error(`[olcli] Error fetching from Overleaf: ${error.message}`); + process.exit(1); + } finally { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + } + } + + else if (line === '') { + if (isImporting) { + // Git sent the blank line to finish the import batch. + // We reply with a blank line to say "Batch successfully fulfilled!" + console.log(''); + isImporting = false; + } else { + // A blank line outside of a batch means Git is saying Goodbye. + process.exit(0); + } + } +} From 37de0c5bad857294575f765bfcd46d2bf613b131 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Tue, 21 Apr 2026 18:45:09 +0200 Subject: [PATCH 07/20] Changed the if else sequence to switch and made a bunch of other, working on cloning, bugged --- src/git-helper.ts | 214 +++++++++++++++++++++++++++++++--------------- 1 file changed, 146 insertions(+), 68 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 97e1712..34b8810 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -1,19 +1,19 @@ #!/usr/bin/env node import * as readline from 'node:readline'; -import { mkdtempSync, rmSync, statSync, createReadStream, writeFileSync, readdirSync } from 'node:fs'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, relative } from 'node:path'; import AdmZip from 'adm-zip'; +import { execSync } from 'node:child_process'; -import { getClient } from './client.js'; - -const remoteName = process.argv[2]; +// Hide the arguments so Commander doesn't panic const url = process.argv[3]; - -const projectId = url;//TODO Handles real urls - +//TODO add url support +const projectId = url; process.argv = [process.argv[0], process.argv[1]]; +// Dynamically import the client +const { getClient } = await import('./client.js'); const rl = readline.createInterface({ input: process.stdin, @@ -21,33 +21,88 @@ const rl = readline.createInterface({ terminal: false }); -// A flag to track if we are in the middle of a batch command -let isImporting = false; - -// Using an async loop ensures we process one line fully before reading the next! for await (const line of rl) { + // Uncomment to see the exact Git conversation! console.error(`[DEBUG] Git asked: ${line}`); + let argv = line.split(' '); - if (line === 'capabilities') { - console.log('import'); - // FIX: Tell Git exactly how our branch maps to its branch + switch (argv[0]){ + case "capabilities" : + console.log('import'); console.log('refspec HEAD:refs/heads/main'); + console.log('option'); + console.log('list'); + console.log('push'); + //console.log('fetch'); console.log(''); + break; + case "option": + runOption(argv); + break; + case "list": + runList(argv); + break; + case "push": + runPush(argv); + break; + case "fetch": + runFetch(argv); + break; + case "import": + await runImport(argv); + break; + case "": + process.exit(0); + break; } +} - else if (line === 'list') { - console.log(`? refs/heads/main`); - console.log(`@refs/heads/main HEAD`); - console.log(''); - } - - else if (line.startsWith('import')) { - isImporting = true; - let tempDir = ''; - try { - const client = await getClient(); - - console.error(`[olcli] Fetching project from Overleaf...`); +function runOption(argv: string[]): void { + //console.log("TODO: " + argv) + console.log("unsupported") +} +function runList(argv: string[]): void { + // The '?' tells Git to trust the fast-import stream to create the hash + console.log(`? refs/heads/main`); + console.log(`@refs/heads/main HEAD`); + console.log(''); +} +function runPush(argv: string[]): void { + console.log("TODO: " + argv) +} +function runFetch(argv: string[]): void { + console.log("TODO: " + argv) +} +async function runImport(argv: string[]){ + let tempDir = ''; + try { + const client = await getClient(); + + let projInfo = await client.getProjectById(projectId); + if (!projInfo) projInfo = await client.getProject(projectId); + if (!projInfo) { + console.error(`\n[olcli] Error: Could not find project '${projectId}'`); + process.exit(1); + } + const refToUpdate = argv[1] || 'refs/heads/main'; + const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); + const localTime = getLocalCommitTime(refToUpdate); + const hasLocalHistory = localTime > 0; + + if (overleafTime === localTime) { + console.error(`[olcli] Project '${projInfo.name}' already up to date...`); + + const localHash = getLocalCommitHash(refToUpdate); + + // Tell fast-import to just point the branch to the existing commit! + process.stdout.write(`feature done\n`); + process.stdout.write(`reset ${refToUpdate}\n`); + process.stdout.write(`from ${localHash}\n`); + process.stdout.write(`done\n`, () => { + console.log(''); // Finish the batch + }); + }else{ + console.error(`[olcli] Fetching project '${projInfo.name}'...`); const zipBuffer = await client.downloadProject(projectId); tempDir = mkdtempSync(join(tmpdir(), 'overleaf-sync-')); @@ -72,56 +127,79 @@ for await (const line of rl) { } const files = getFilesToImport(extractDir); - - const timestamp = Math.floor(Date.now() / 1000); + //const timestamp = Math.floor(Date.now() / 1000); + const timestamp = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); const commitMsg = "Sync from Overleaf\n"; - process.stdout.write(`commit refs/heads/main\n`); - process.stdout.write(`committer Overleaf Sync ${timestamp} +0000\n`); - process.stdout.write(`data ${Buffer.byteLength(commitMsg, 'utf8')}\n`); - process.stdout.write(commitMsg); + // --- START FAST-IMPORT STREAM --- - for (const filePath of files) { - const repoPath = relative(extractDir, filePath).replace(/\\/g, '/'); - const fileSize = statSync(filePath).size; - - process.stdout.write(`M 100644 inline "${repoPath.replace(/"/g, '\\"')}"\n`); - process.stdout.write(`data ${fileSize}\n`); - - await new Promise((resolve, reject) => { - const stream = createReadStream(filePath); - stream.on('data', chunk => process.stdout.write(chunk)); - stream.on('end', () => { - process.stdout.write(`\n`); - resolve(); - }); - stream.on('error', reject); - }); + // FIX 1: Dynamically use the exact ref Git requested! + + let streamData = ''; + // FIX 2: Explicitly reset the branch to accept our new commit + streamData += `reset ${refToUpdate}\n`; + streamData += `commit ${refToUpdate}\n`; + // FIX 3: Add the mandatory mark and author fields + streamData += `mark :1\n`; + streamData += `author Overleaf Sync ${timestamp} +0000\n`; + streamData += `committer Overleaf Sync ${timestamp} +0000\n`; + streamData += `data ${Buffer.byteLength(commitMsg, 'utf8')}\n`; + streamData += commitMsg; + + if (hasLocalHistory) { + streamData += `from ${refToUpdate}^0\n`; } - process.stdout.write(`done\n`); - // Note: We do NOT print a blank console.log('') here. - // Git will send a blank line to finish the batch, and we handle it below! + process.stdout.write(streamData); - } catch (error: any) { - console.error(`[olcli] Error fetching from Overleaf: ${error.message}`); - process.exit(1); - } finally { - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); + for (const filePath of files) { + let repoPath = relative(extractDir, filePath).replace(/\\/g, '/'); + + // FIX 4: Strip any accidental leading slashes or dots that crash fast-import + repoPath = repoPath.replace(/^\/+/, '').replace(/^\.\//, ''); + + const formattedPath = repoPath.includes(' ') ? `"${repoPath}"` : repoPath; + const content = readFileSync(filePath); + + process.stdout.write(`M 100644 inline ${formattedPath}\n`); + process.stdout.write(`data ${content.length}\n`); + process.stdout.write(content); + process.stdout.write(`\n`); } + + // FIX 5: Use a callback to guarantee Node.js flushes the pipe + // before we tell Git the batch is done. This prevents race conditions! + process.stdout.write(`done\n`, () => { + console.log(''); // Tell Git the batch is complete! + }); + + // --- END FAST-IMPORT STREAM --- } - } - else if (line === '') { - if (isImporting) { - // Git sent the blank line to finish the import batch. - // We reply with a blank line to say "Batch successfully fulfilled!" - console.log(''); - isImporting = false; - } else { - // A blank line outside of a batch means Git is saying Goodbye. - process.exit(0); + } catch (error: any) { + console.error(`\n[olcli] Error fetching from Overleaf: ${error.message}`); + process.exit(1); + } finally { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); } } } + +function getLocalCommitTime(ref: string): number { + try { + // If successful, returns the timestamp + const out = execSync(`git log -1 --format=%ct ${ref}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }); + return parseInt(out.trim(), 10); + } catch { + // If it fails (e.g. fresh clone), return 0 + return 0; + } +} +function getLocalCommitHash(ref: string): string { + try { + return execSync(`git rev-parse ${ref}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); + } catch { + return ''; + } +} From 4bf42a6c4df85f137e1f8c3c805cc4ffae0c5902 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 23 Apr 2026 17:39:28 +0200 Subject: [PATCH 08/20] Finished preliminary support, including clone, pull and push. Ignoring .gitignore when pushing file to overleaf. --- src/client.ts | 27 +++++++++ src/git-helper.ts | 150 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 161 insertions(+), 16 deletions(-) diff --git a/src/client.ts b/src/client.ts index 504219b..749d858 100644 --- a/src/client.ts +++ b/src/client.ts @@ -301,6 +301,33 @@ export class OverleafClient { })); } + /** + * Apply a Label to the current overleaf state + */ + /*async applyOverleafLabel(projectId: string, message: string) { + try { + // Wait a brief moment (e.g., 1000ms) to ensure Overleaf's backend has finished + // processing the file uploads before we stamp the label on the timeline. + await new Promise(resolve => setTimeout(resolve, 1000)); + + // client.fetch automatically attaches the CSRF token and Cookie! + const response = await this.fetch(`/project/${projectId}/labels`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ comment: message }) // 'comment' is the label text + }); + + if (!response.ok) { + console.error(`[olcli] Warning: Failed to apply label '${message}' (Status: ${response.status})`); + } + } catch (err) { + console.error(`[olcli] Warning: Failed to apply label '${message}'`); + } + }*/ + /** * Get project by name */ diff --git a/src/git-helper.ts b/src/git-helper.ts index 34b8810..44862f2 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -10,7 +10,6 @@ import { execSync } from 'node:child_process'; const url = process.argv[3]; //TODO add url support const projectId = url; -process.argv = [process.argv[0], process.argv[1]]; // Dynamically import the client const { getClient } = await import('./client.js'); @@ -21,6 +20,9 @@ const rl = readline.createInterface({ terminal: false }); +let pendingImportRef = ''; +let pendingPushRef = ''; + for await (const line of rl) { // Uncomment to see the exact Git conversation! console.error(`[DEBUG] Git asked: ${line}`); @@ -29,11 +31,11 @@ for await (const line of rl) { switch (argv[0]){ case "capabilities" : console.log('import'); - console.log('refspec HEAD:refs/heads/main'); + //console.log('refspec HEAD:refs/heads/main'); + console.log('refspec refs/heads/*:refs/heads/*'); // <-- MUST BE EXACTLY THIS console.log('option'); console.log('list'); console.log('push'); - //console.log('fetch'); console.log(''); break; case "option": @@ -43,16 +45,28 @@ for await (const line of rl) { runList(argv); break; case "push": - runPush(argv); - break; - case "fetch": - runFetch(argv); + // argv[1] looks like "refs/heads/main:refs/heads/main" + // We split by ':' and take the second half (the destination) + pendingPushRef = argv[1].split(':')[1]; + //runPush(argv); break; case "import": - await runImport(argv); + // Git is asking for an import. Save it, but wait for the blank line! + pendingImportRef = argv[1]; + //await runImport(pendingImportRef); break; + case "": + // Git sent the blank line ("Over"). Now it is our turn to talk! + if (pendingImportRef !== '') { + await runImport(pendingImportRef); + pendingImportRef = ''; // Reset for the next conversation + } else if (pendingPushRef !== '') { + await runPush(pendingPushRef); // <-- Call your new push function! + pendingPushRef = ''; + } else { process.exit(0); + } break; } } @@ -67,13 +81,114 @@ function runList(argv: string[]): void { console.log(`@refs/heads/main HEAD`); console.log(''); } -function runPush(argv: string[]): void { - console.log("TODO: " + argv) -} -function runFetch(argv: string[]): void { - console.log("TODO: " + argv) +async function runPush(refToUpdate: string){//TODO Check if push is necessary + let tempDir = ''; + try { + const client = await getClient(); + + let projInfo = await client.getProjectById(projectId); + if (!projInfo) projInfo = await client.getProject(projectId); + if (!projInfo) { + console.error(`\n[olcli] Error: Could not find project '${projectId}'`); + process.exit(1); + } + const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); + const localTime = getLocalCommitTime(refToUpdate); + if (overleafTime > localTime ){ //Checking for newer version online + console.log(`error ${refToUpdate} Remote has newer changes. Please pull first.`); + console.log(''); + //return; + }else{ + + // Create a fast lookup dictionary: { "chapters/intro.tex" => { id: "123", type: "doc" } } + const remoteFiles = new Map(); + + const projectInfo = await client.getProjectInfo(projectId); + if (projectInfo && projectInfo.rootFolder && projectInfo.rootFolder[0]) { + function buildFileMap(folder: any, currentPath: string = '') { + for (const doc of folder.docs || []) { + remoteFiles.set(currentPath ? `${currentPath}/${doc.name}` : doc.name, { id: doc._id, type: 'doc' }); + } + for (const file of folder.fileRefs || []) { + remoteFiles.set(currentPath ? `${currentPath}/${file.name}` : file.name, { id: file._id, type: 'file' }); + } + for (const sub of folder.folders || []) { + const subPath = currentPath ? `${currentPath}/${sub.name}` : sub.name; + remoteFiles.set(subPath, { id: sub._id, type: 'folder' }); + buildFileMap(sub, subPath); + } + } + buildFileMap(projectInfo.rootFolder[0]); + } + + let folderTree = await client.getFolderTreeFromSocket(projectId); + if (!folderTree) folderTree = {}; + + const remoteName = process.argv[2]; // e.g., 'origin' + const branchName = refToUpdate.split('/').pop(); // e.g., 'main' + const trackingRef = `refs/remotes/${remoteName}/${branchName}`; + + const commits = execSync(`git rev-list --reverse ${trackingRef}..HEAD`, { encoding: 'utf8' }).trim().split('\n'); + + for (const hash of commits) { + // 1. Get the commit message (Subject line only) + const commitMsg = execSync(`git show -s --format=%s ${hash}`, { encoding: 'utf8' }).trim(); + //console.error(`[olcli] Pushing commit: ${commitMsg}`); + + // 2. Get files added/modified in THIS commit + const uploadStr = execSync(`git diff-tree --no-commit-id --name-only --diff-filter=ACMR -r ${hash}`, { encoding: 'utf8' }).trim(); + const filesToUpload = uploadStr ? uploadStr.split('\n') : []; + + // 3. Get files deleted in THIS commit + const deleteStr = execSync(`git diff-tree --no-commit-id --name-only --diff-filter=D -r ${hash}`, { encoding: 'utf8' }).trim(); + const filesToDelete = deleteStr ? deleteStr.split('\n') : []; + //console.error(hash,filesToUpload, filesToDelete); + + // 4. Upload the files + for (const file of filesToUpload) { + // CRUCIAL: Get the file content exactly as it was in THIS commit! + // Using execSync with `{ encoding: 'buffer' }` safely handles binary files like PDFs/PNGs + if ( file !== ".gitignore") { + try { + const content = execSync(`git show ${hash}:"${file}"`, { encoding: 'buffer' }); + await client.uploadFile(projectId!, null, file, content, folderTree); + //spinner.text = `Uploading... (${uploaded}/${filesToUpload.length})`; + } catch (error: any) { + console.log(`error ${refToUpdate} Failed to upload ${file}: ${error.message}`); + } + } + } + + // 5. Delete the files + for (const file of filesToDelete) { + const entity = remoteFiles.get(file); + if (!entity) { + console.log(`error ${refToUpdate} Failed to delete ${file}: Does not exist remotely`); + }else{ + try { + await client.deleteEntity(projectId!, entity.id, entity.type); + } catch (error: any) { + console.log(`error ${refToUpdate} Failed to delete ${file}: ${error.message}`); + } + } + } + + // 6. Apply the Overleaf Label! + //await client.applyOverleafLabel(projectId, commitMsg); + } + + console.log(`ok ${refToUpdate}`); + //console.log(`error ${refToUpdate} Testing stuff`); + console.log(''); + } + + } catch (error: any) { + console.log(`error ${refToUpdate} Push failed: ${error.message}`); + console.log(''); + } } -async function runImport(argv: string[]){ + +async function runImport(refToUpdate: string){ let tempDir = ''; try { const client = await getClient(); @@ -84,7 +199,6 @@ async function runImport(argv: string[]){ console.error(`\n[olcli] Error: Could not find project '${projectId}'`); process.exit(1); } - const refToUpdate = argv[1] || 'refs/heads/main'; const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); const localTime = getLocalCommitTime(refToUpdate); const hasLocalHistory = localTime > 0; @@ -137,7 +251,8 @@ async function runImport(argv: string[]){ let streamData = ''; // FIX 2: Explicitly reset the branch to accept our new commit - streamData += `reset ${refToUpdate}\n`; + //streamData += `feature done\n`; // <-- MUST BE HERE! + //streamData += `reset ${refToUpdate}\n`; streamData += `commit ${refToUpdate}\n`; // FIX 3: Add the mandatory mark and author fields streamData += `mark :1\n`; @@ -150,6 +265,9 @@ async function runImport(argv: string[]){ streamData += `from ${refToUpdate}^0\n`; } + + // DEBUG: Print the header to the terminal so we can see if it's formatted perfectly! + //console.error(`\n[DEBUG STREAM]\n${streamData}`); process.stdout.write(streamData); for (const filePath of files) { From 2d28030c739aa20296bceff61c3c3bbe9fc7846a Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 23 Apr 2026 18:28:04 +0200 Subject: [PATCH 09/20] Debugging when using double remotes --- src/git-helper.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 44862f2..a54c9fd 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -94,7 +94,8 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary } const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); const localTime = getLocalCommitTime(refToUpdate); - if (overleafTime > localTime ){ //Checking for newer version online + + if (overleafTime > localTime ){ //Checking for newer version online //TODO add is up-to-date check console.log(`error ${refToUpdate} Remote has newer changes. Please pull first.`); console.log(''); //return; @@ -136,6 +137,7 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary //console.error(`[olcli] Pushing commit: ${commitMsg}`); // 2. Get files added/modified in THIS commit + console.error(hash) const uploadStr = execSync(`git diff-tree --no-commit-id --name-only --diff-filter=ACMR -r ${hash}`, { encoding: 'utf8' }).trim(); const filesToUpload = uploadStr ? uploadStr.split('\n') : []; From 33d6db6924eb4e08f401143524885d72cd7a4650 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Fri, 24 Apr 2026 10:13:01 +0200 Subject: [PATCH 10/20] Fixed issues like: pushtime mismatch and sane new commits checks --- src/git-helper.ts | 113 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 102 insertions(+), 11 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index a54c9fd..1531e3f 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -76,12 +76,47 @@ function runOption(argv: string[]): void { console.log("unsupported") } function runList(argv: string[]): void { - // The '?' tells Git to trust the fast-import stream to create the hash - console.log(`? refs/heads/main`); + let hash = '?'; + try { + const remoteName = process.argv[2]; + // Ask Git what the last known commit of the remote was + hash = execSync(`git rev-parse refs/remotes/${remoteName}/main`, { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8' + }).trim(); + } catch { + // If it fails (e.g., very first clone), we fall back to '?' + hash = '?'; + } + + console.log(`${hash} refs/heads/main`); console.log(`@refs/heads/main HEAD`); console.log(''); } + async function runPush(refToUpdate: string){//TODO Check if push is necessary + + const remoteName = process.argv[2]; // e.g., 'origin' + const branchName = refToUpdate.split('/').pop(); // e.g., 'main' + const trackingRef = `refs/remotes/${remoteName}/${branchName}`; + + let commitsStr = ''; + try { + // Find commits that exist locally but haven't been pushed to the remote + commitsStr = execSync(`git rev-list --reverse ${trackingRef}..${refToUpdate}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); + } catch (e) { + // If trackingRef doesn't exist (e.g., very first push), grab all local commits + commitsStr = execSync(`git rev-list --reverse ${refToUpdate}`, { encoding: 'utf8' }).trim(); + } + + if (!commitsStr) { + //console.error(`[olcli] Everything up-to-date.`); + console.log(`ok ${refToUpdate}`); + console.log(''); + return; // EXIT EARLY! No API calls made. + } + const commits = commitsStr.split('\n'); + let tempDir = ''; try { const client = await getClient(); @@ -89,11 +124,12 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary let projInfo = await client.getProjectById(projectId); if (!projInfo) projInfo = await client.getProject(projectId); if (!projInfo) { - console.error(`\n[olcli] Error: Could not find project '${projectId}'`); - process.exit(1); + console.log(`error ${refToUpdate} Could not find project : ${projectId}`); + //process.exit(1); + return; } const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); - const localTime = getLocalCommitTime(refToUpdate); + const localTime = getLastSyncTime(); if (overleafTime > localTime ){ //Checking for newer version online //TODO add is up-to-date check console.log(`error ${refToUpdate} Remote has newer changes. Please pull first.`); @@ -125,11 +161,7 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary let folderTree = await client.getFolderTreeFromSocket(projectId); if (!folderTree) folderTree = {}; - const remoteName = process.argv[2]; // e.g., 'origin' - const branchName = refToUpdate.split('/').pop(); // e.g., 'main' - const trackingRef = `refs/remotes/${remoteName}/${branchName}`; - - const commits = execSync(`git rev-list --reverse ${trackingRef}..HEAD`, { encoding: 'utf8' }).trim().split('\n'); + //const commits = execSync(`git rev-list --reverse ${trackingRef}..HEAD`, { encoding: 'utf8' }).trim().split('\n'); for (const hash of commits) { // 1. Get the commit message (Subject line only) @@ -174,11 +206,53 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary } } } + //Cleaning up subfolders + if(filesToDelete.length > 0) { + // 1. Get all entries [path, entity], filter only the folders + const folderEntries = Array.from(remoteFiles.entries()) + .filter(([path, entity]) => entity.type === 'folder'); + + // 2. Sort by path length descending (deepest folders first!) + folderEntries.sort(([pathA], [pathB]) => pathB.split('/').length - pathA.split('/').length); + + // 3. Process them bottom-up + for (const [folderPath, entity] of folderEntries) { + const folderPrefix = folderPath + '/'; + + // Check if ANY key left in the map starts with this folder's path + if (! Array.from(remoteFiles.keys()).some( + key => key.startsWith(folderPrefix) + )) { + //console.error(` -> Deleting empty remote folder: ${folderPath}...`); + + try { + await client.deleteEntity(projectId, entity.id, 'folder'); + // Remove it from the Map so its parent knows it is gone! + remoteFiles.delete(folderPath); + } catch (e) { + console.log(`error ${refToUpdate} Failed to delete folder ${folderPath}`); + } + } + } + } // 6. Apply the Overleaf Label! //await client.applyOverleafLabel(projectId, commitMsg); } + // After your push loops finish: + // 1. Fetch the new project info to get Overleaf's newly updated timestamp + let projInfo = await client.getProjectById(projectId); + if (!projInfo) projInfo = await client.getProject(projectId); + if (!projInfo) { + console.log(`error ${refToUpdate} Could not find project : ${projectId}`); + return; + } + const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); + + // 2. Save it to Git config! + setLastSyncTime(overleafTime); + console.log(`ok ${refToUpdate}`); //console.log(`error ${refToUpdate} Testing stuff`); console.log(''); @@ -202,7 +276,8 @@ async function runImport(refToUpdate: string){ process.exit(1); } const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); - const localTime = getLocalCommitTime(refToUpdate); + const localTime = getLastSyncTime(); + console.error(localTime, overleafTime) const hasLocalHistory = localTime > 0; if (overleafTime === localTime) { @@ -293,7 +368,9 @@ async function runImport(refToUpdate: string){ console.log(''); // Tell Git the batch is complete! }); + // --- END FAST-IMPORT STREAM --- + setLastSyncTime(overleafTime); } } catch (error: any) { @@ -323,3 +400,17 @@ function getLocalCommitHash(ref: string): string { return ''; } } +function getLastSyncTime(): number { + try { + // Reads the custom value from .git/config + const out = execSync(`git config overleaf.lastsync`, { encoding: 'utf8' }); + return parseInt(out.trim(), 10); + } catch { + return 0; // Returns 0 if we've never synced before + } +} + +function setLastSyncTime(timestamp: number) { + // Saves the value into .git/config + execSync(`git config overleaf.lastsync ${timestamp}`); +} From 505924398623bdab6b59a3cd5c006cdc72ef3bec Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Fri, 24 Apr 2026 10:17:06 +0200 Subject: [PATCH 11/20] Removed old debug lines (commented) --- src/git-helper.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 1531e3f..98c2ae3 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -169,7 +169,7 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary //console.error(`[olcli] Pushing commit: ${commitMsg}`); // 2. Get files added/modified in THIS commit - console.error(hash) + //console.error(hash) const uploadStr = execSync(`git diff-tree --no-commit-id --name-only --diff-filter=ACMR -r ${hash}`, { encoding: 'utf8' }).trim(); const filesToUpload = uploadStr ? uploadStr.split('\n') : []; @@ -277,11 +277,11 @@ async function runImport(refToUpdate: string){ } const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); const localTime = getLastSyncTime(); - console.error(localTime, overleafTime) + //console.error(localTime, overleafTime) const hasLocalHistory = localTime > 0; if (overleafTime === localTime) { - console.error(`[olcli] Project '${projInfo.name}' already up to date...`); + //console.error(`[olcli] Project '${projInfo.name}' already up to date...`); const localHash = getLocalCommitHash(refToUpdate); @@ -293,7 +293,7 @@ async function runImport(refToUpdate: string){ console.log(''); // Finish the batch }); }else{ - console.error(`[olcli] Fetching project '${projInfo.name}'...`); + //console.error(`[olcli] Fetching project '${projInfo.name}'...`); const zipBuffer = await client.downloadProject(projectId); tempDir = mkdtempSync(join(tmpdir(), 'overleaf-sync-')); From 86cd490543ccee164262f293aa1fb482a085c8a8 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Fri, 24 Apr 2026 10:41:49 +0200 Subject: [PATCH 12/20] Start of the work on different projects Urls --- src/git-helper.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 98c2ae3..726cf88 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -7,9 +7,11 @@ import AdmZip from 'adm-zip'; import { execSync } from 'node:child_process'; // Hide the arguments so Commander doesn't panic -const url = process.argv[3]; +const url = process.argv[3].split('/'); //TODO add url support -const projectId = url; + +const projectId = url[url.length -1]; +const baseUrl = url[0]+"//"+url[2]; // Dynamically import the client const { getClient } = await import('./client.js'); From 2d5f1b22753299777bb5ca2144bcec3da68fbc8d Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Fri, 24 Apr 2026 11:44:40 +0200 Subject: [PATCH 13/20] Removed unecessary comments and sanitized a bit --- src/git-helper.ts | 142 ++++++++++++++-------------------------------- 1 file changed, 43 insertions(+), 99 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 726cf88..0cb1558 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -6,14 +6,11 @@ import { join, relative } from 'node:path'; import AdmZip from 'adm-zip'; import { execSync } from 'node:child_process'; -// Hide the arguments so Commander doesn't panic const url = process.argv[3].split('/'); -//TODO add url support const projectId = url[url.length -1]; const baseUrl = url[0]+"//"+url[2]; -// Dynamically import the client const { getClient } = await import('./client.js'); const rl = readline.createInterface({ @@ -26,15 +23,13 @@ let pendingImportRef = ''; let pendingPushRef = ''; for await (const line of rl) { - // Uncomment to see the exact Git conversation! - console.error(`[DEBUG] Git asked: ${line}`); + //console.error(`[DEBUG] Git asked: ${line}`); let argv = line.split(' '); switch (argv[0]){ case "capabilities" : console.log('import'); - //console.log('refspec HEAD:refs/heads/main'); - console.log('refspec refs/heads/*:refs/heads/*'); // <-- MUST BE EXACTLY THIS + console.log('refspec refs/heads/*:refs/heads/*'); console.log('option'); console.log('list'); console.log('push'); @@ -47,24 +42,18 @@ for await (const line of rl) { runList(argv); break; case "push": - // argv[1] looks like "refs/heads/main:refs/heads/main" - // We split by ':' and take the second half (the destination) pendingPushRef = argv[1].split(':')[1]; - //runPush(argv); break; case "import": - // Git is asking for an import. Save it, but wait for the blank line! pendingImportRef = argv[1]; - //await runImport(pendingImportRef); break; case "": - // Git sent the blank line ("Over"). Now it is our turn to talk! if (pendingImportRef !== '') { await runImport(pendingImportRef); - pendingImportRef = ''; // Reset for the next conversation + pendingImportRef = ''; } else if (pendingPushRef !== '') { - await runPush(pendingPushRef); // <-- Call your new push function! + await runPush(pendingPushRef); pendingPushRef = ''; } else { process.exit(0); @@ -73,21 +62,24 @@ for await (const line of rl) { } } -function runOption(argv: string[]): void { - //console.log("TODO: " + argv) + /* + * Function handling the option request from git-remote-helper + */ +function runOption(argv: string[]): void {//TODO: Actually handle options console.log("unsupported") } +/* + * Function handling the list request from git-remote-helper + */ function runList(argv: string[]): void { let hash = '?'; try { const remoteName = process.argv[2]; - // Ask Git what the last known commit of the remote was hash = execSync(`git rev-parse refs/remotes/${remoteName}/main`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); } catch { - // If it fails (e.g., very first clone), we fall back to '?' hash = '?'; } @@ -96,26 +88,26 @@ function runList(argv: string[]): void { console.log(''); } -async function runPush(refToUpdate: string){//TODO Check if push is necessary +/* + * Function handling the push request from git-remote-helper + */ +async function runPush(refToUpdate: string){ - const remoteName = process.argv[2]; // e.g., 'origin' - const branchName = refToUpdate.split('/').pop(); // e.g., 'main' + const remoteName = process.argv[2]; + const branchName = refToUpdate.split('/').pop(); const trackingRef = `refs/remotes/${remoteName}/${branchName}`; let commitsStr = ''; try { - // Find commits that exist locally but haven't been pushed to the remote commitsStr = execSync(`git rev-list --reverse ${trackingRef}..${refToUpdate}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); } catch (e) { - // If trackingRef doesn't exist (e.g., very first push), grab all local commits commitsStr = execSync(`git rev-list --reverse ${refToUpdate}`, { encoding: 'utf8' }).trim(); } if (!commitsStr) { - //console.error(`[olcli] Everything up-to-date.`); console.log(`ok ${refToUpdate}`); console.log(''); - return; // EXIT EARLY! No API calls made. + return; } const commits = commitsStr.split('\n'); @@ -123,26 +115,24 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary try { const client = await getClient(); - let projInfo = await client.getProjectById(projectId); - if (!projInfo) projInfo = await client.getProject(projectId); - if (!projInfo) { + let project = await client.getProjectById(projectId); + if (!project) project = await client.getProject(projectId); + if (!project) { console.log(`error ${refToUpdate} Could not find project : ${projectId}`); - //process.exit(1); return; } - const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); + const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); const localTime = getLastSyncTime(); - if (overleafTime > localTime ){ //Checking for newer version online //TODO add is up-to-date check + if (overleafTime > localTime ){ console.log(`error ${refToUpdate} Remote has newer changes. Please pull first.`); console.log(''); - //return; + return; }else{ - // Create a fast lookup dictionary: { "chapters/intro.tex" => { id: "123", type: "doc" } } const remoteFiles = new Map(); - const projectInfo = await client.getProjectInfo(projectId); + if (projectInfo && projectInfo.rootFolder && projectInfo.rootFolder[0]) { function buildFileMap(folder: any, currentPath: string = '') { for (const doc of folder.docs || []) { @@ -163,39 +153,26 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary let folderTree = await client.getFolderTreeFromSocket(projectId); if (!folderTree) folderTree = {}; - //const commits = execSync(`git rev-list --reverse ${trackingRef}..HEAD`, { encoding: 'utf8' }).trim().split('\n'); - for (const hash of commits) { - // 1. Get the commit message (Subject line only) const commitMsg = execSync(`git show -s --format=%s ${hash}`, { encoding: 'utf8' }).trim(); - //console.error(`[olcli] Pushing commit: ${commitMsg}`); - // 2. Get files added/modified in THIS commit - //console.error(hash) const uploadStr = execSync(`git diff-tree --no-commit-id --name-only --diff-filter=ACMR -r ${hash}`, { encoding: 'utf8' }).trim(); const filesToUpload = uploadStr ? uploadStr.split('\n') : []; - // 3. Get files deleted in THIS commit const deleteStr = execSync(`git diff-tree --no-commit-id --name-only --diff-filter=D -r ${hash}`, { encoding: 'utf8' }).trim(); const filesToDelete = deleteStr ? deleteStr.split('\n') : []; - //console.error(hash,filesToUpload, filesToDelete); - // 4. Upload the files for (const file of filesToUpload) { - // CRUCIAL: Get the file content exactly as it was in THIS commit! - // Using execSync with `{ encoding: 'buffer' }` safely handles binary files like PDFs/PNGs if ( file !== ".gitignore") { try { const content = execSync(`git show ${hash}:"${file}"`, { encoding: 'buffer' }); await client.uploadFile(projectId!, null, file, content, folderTree); - //spinner.text = `Uploading... (${uploaded}/${filesToUpload.length})`; } catch (error: any) { console.log(`error ${refToUpdate} Failed to upload ${file}: ${error.message}`); } } } - // 5. Delete the files for (const file of filesToDelete) { const entity = remoteFiles.get(file); if (!entity) { @@ -208,16 +185,12 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary } } } - //Cleaning up subfolders if(filesToDelete.length > 0) { - // 1. Get all entries [path, entity], filter only the folders const folderEntries = Array.from(remoteFiles.entries()) .filter(([path, entity]) => entity.type === 'folder'); - // 2. Sort by path length descending (deepest folders first!) folderEntries.sort(([pathA], [pathB]) => pathB.split('/').length - pathA.split('/').length); - // 3. Process them bottom-up for (const [folderPath, entity] of folderEntries) { const folderPrefix = folderPath + '/'; @@ -225,11 +198,9 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary if (! Array.from(remoteFiles.keys()).some( key => key.startsWith(folderPrefix) )) { - //console.error(` -> Deleting empty remote folder: ${folderPath}...`); try { await client.deleteEntity(projectId, entity.id, 'folder'); - // Remove it from the Map so its parent knows it is gone! remoteFiles.delete(folderPath); } catch (e) { console.log(`error ${refToUpdate} Failed to delete folder ${folderPath}`); @@ -238,25 +209,21 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary } } - // 6. Apply the Overleaf Label! //await client.applyOverleafLabel(projectId, commitMsg); } - // After your push loops finish: - // 1. Fetch the new project info to get Overleaf's newly updated timestamp - let projInfo = await client.getProjectById(projectId); - if (!projInfo) projInfo = await client.getProject(projectId); - if (!projInfo) { + // Getting new last updated time from overleaf + let project = await client.getProjectById(projectId); + if (!project) project = await client.getProject(projectId); + if (!project) { console.log(`error ${refToUpdate} Could not find project : ${projectId}`); return; } - const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); + const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); - // 2. Save it to Git config! setLastSyncTime(overleafTime); console.log(`ok ${refToUpdate}`); - //console.log(`error ${refToUpdate} Testing stuff`); console.log(''); } @@ -266,36 +233,37 @@ async function runPush(refToUpdate: string){//TODO Check if push is necessary } } +/* + * Function handling the import request from git-remote-helper + */ async function runImport(refToUpdate: string){ let tempDir = ''; try { const client = await getClient(); - let projInfo = await client.getProjectById(projectId); - if (!projInfo) projInfo = await client.getProject(projectId); - if (!projInfo) { + let project = await client.getProjectById(projectId); + if (!project) project = await client.getProject(projectId); + if (!project) { console.error(`\n[olcli] Error: Could not find project '${projectId}'`); process.exit(1); } - const overleafTime = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); + const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); const localTime = getLastSyncTime(); - //console.error(localTime, overleafTime) const hasLocalHistory = localTime > 0; + //Checking if pulling is necessary if (overleafTime === localTime) { - //console.error(`[olcli] Project '${projInfo.name}' already up to date...`); const localHash = getLocalCommitHash(refToUpdate); - // Tell fast-import to just point the branch to the existing commit! process.stdout.write(`feature done\n`); process.stdout.write(`reset ${refToUpdate}\n`); process.stdout.write(`from ${localHash}\n`); process.stdout.write(`done\n`, () => { - console.log(''); // Finish the batch + console.log(''); }); }else{ - //console.error(`[olcli] Fetching project '${projInfo.name}'...`); + //Downloading the zip file const zipBuffer = await client.downloadProject(projectId); tempDir = mkdtempSync(join(tmpdir(), 'overleaf-sync-')); @@ -320,20 +288,13 @@ async function runImport(refToUpdate: string){ } const files = getFilesToImport(extractDir); - //const timestamp = Math.floor(Date.now() / 1000); - const timestamp = Math.floor(new Date(projInfo.lastUpdated).getTime() / 1000); + const timestamp = overleafTime; const commitMsg = "Sync from Overleaf\n"; - // --- START FAST-IMPORT STREAM --- - // FIX 1: Dynamically use the exact ref Git requested! let streamData = ''; - // FIX 2: Explicitly reset the branch to accept our new commit - //streamData += `feature done\n`; // <-- MUST BE HERE! - //streamData += `reset ${refToUpdate}\n`; streamData += `commit ${refToUpdate}\n`; - // FIX 3: Add the mandatory mark and author fields streamData += `mark :1\n`; streamData += `author Overleaf Sync ${timestamp} +0000\n`; streamData += `committer Overleaf Sync ${timestamp} +0000\n`; @@ -345,14 +306,11 @@ async function runImport(refToUpdate: string){ } - // DEBUG: Print the header to the terminal so we can see if it's formatted perfectly! - //console.error(`\n[DEBUG STREAM]\n${streamData}`); process.stdout.write(streamData); for (const filePath of files) { let repoPath = relative(extractDir, filePath).replace(/\\/g, '/'); - // FIX 4: Strip any accidental leading slashes or dots that crash fast-import repoPath = repoPath.replace(/^\/+/, '').replace(/^\.\//, ''); const formattedPath = repoPath.includes(' ') ? `"${repoPath}"` : repoPath; @@ -364,14 +322,12 @@ async function runImport(refToUpdate: string){ process.stdout.write(`\n`); } - // FIX 5: Use a callback to guarantee Node.js flushes the pipe - // before we tell Git the batch is done. This prevents race conditions! process.stdout.write(`done\n`, () => { - console.log(''); // Tell Git the batch is complete! + console.log(''); }); - // --- END FAST-IMPORT STREAM --- + //Setting the time locally setLastSyncTime(overleafTime); } @@ -385,16 +341,6 @@ async function runImport(refToUpdate: string){ } } -function getLocalCommitTime(ref: string): number { - try { - // If successful, returns the timestamp - const out = execSync(`git log -1 --format=%ct ${ref}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }); - return parseInt(out.trim(), 10); - } catch { - // If it fails (e.g. fresh clone), return 0 - return 0; - } -} function getLocalCommitHash(ref: string): string { try { return execSync(`git rev-parse ${ref}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); @@ -404,7 +350,6 @@ function getLocalCommitHash(ref: string): string { } function getLastSyncTime(): number { try { - // Reads the custom value from .git/config const out = execSync(`git config overleaf.lastsync`, { encoding: 'utf8' }); return parseInt(out.trim(), 10); } catch { @@ -413,6 +358,5 @@ function getLastSyncTime(): number { } function setLastSyncTime(timestamp: number) { - // Saves the value into .git/config execSync(`git config overleaf.lastsync ${timestamp}`); } From 703e261bff08cd11fa5a74bbe968a1c65bdeead6 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Fri, 24 Apr 2026 14:00:36 +0200 Subject: [PATCH 14/20] start of the work on commit as label --- src/client.ts | 43 ++++++++++++++++++++----------------------- src/git-helper.ts | 24 +++++++++++++++++------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/client.ts b/src/client.ts index 749d858..4b27c48 100644 --- a/src/client.ts +++ b/src/client.ts @@ -304,29 +304,26 @@ export class OverleafClient { /** * Apply a Label to the current overleaf state */ - /*async applyOverleafLabel(projectId: string, message: string) { - try { - // Wait a brief moment (e.g., 1000ms) to ensure Overleaf's backend has finished - // processing the file uploads before we stamp the label on the timeline. - await new Promise(resolve => setTimeout(resolve, 1000)); - - // client.fetch automatically attaches the CSRF token and Cookie! - const response = await this.fetch(`/project/${projectId}/labels`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: JSON.stringify({ comment: message }) // 'comment' is the label text - }); - - if (!response.ok) { - console.error(`[olcli] Warning: Failed to apply label '${message}' (Status: ${response.status})`); - } - } catch (err) { - console.error(`[olcli] Warning: Failed to apply label '${message}'`); - } - }*/ + /* + async applyOverleafLabel(projectId: string, message: string, version: number): Promise { + await new Promise(resolve => setTimeout(resolve, 100)); + + const url = `${this.baseUrl}/project/${projectId}/labels`; + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(true), + body: JSON.stringify({ + comment: message, + version: version + }) + }); + + if (!response.ok) { + throw new Error(`Failed to create label: ${response.status}`); + } + } + */ /** * Get project by name diff --git a/src/git-helper.ts b/src/git-helper.ts index 0cb1558..9a951ee 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -62,12 +62,12 @@ for await (const line of rl) { } } - /* - * Function handling the option request from git-remote-helper - */ -function runOption(argv: string[]): void {//TODO: Actually handle options - console.log("unsupported") -} +/* + * Function handling the option request from git-remote-helper + */ + function runOption(argv: string[]): void {//TODO: Actually handle options + console.log("unsupported") + } /* * Function handling the list request from git-remote-helper */ @@ -209,7 +209,17 @@ async function runPush(refToUpdate: string){ } } - //await client.applyOverleafLabel(projectId, commitMsg); + /* + try { + + const project = await client.getProjectInfo(projectId); + + await client.applyOverleafLabel(projectId, commitMsg, project.version || 0); + + } catch (err: any) { + console.error(` -> Warning: Failed to apply label '${commitMsg}'`); + } + */ } // Getting new last updated time from overleaf From 5163170cacd21879e0afe88d3478058e8dc78049 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Fri, 24 Apr 2026 15:43:47 +0200 Subject: [PATCH 15/20] Fixed merging error --- src/client.ts | 98 +++++++++++++++++---------------------------------- 1 file changed, 32 insertions(+), 66 deletions(-) diff --git a/src/client.ts b/src/client.ts index 6936166..8aeb099 100644 --- a/src/client.ts +++ b/src/client.ts @@ -140,7 +140,7 @@ export class OverleafClient { // Fetch CSRF token from project page const initialHeaders: Record = { 'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '), - 'User-Agent': USER_AGENT + 'User-Agent': USER_AGENT }; const bootstrapClient = new OverleafClient({ cookies, csrf: 'bootstrap', baseUrl }); const response = await bootstrapClient.httpRequest(`${baseUrl}/project`, { @@ -413,7 +413,7 @@ export class OverleafClient { } /** - * Get detailed project info including file tree (via WebSocket) + * Get detailed project info including file tree */ async getProjectInfo(projectId: string): Promise { const response = await this.httpRequest(`${this.projectUrl()}/${projectId}`, { @@ -421,84 +421,50 @@ export class OverleafClient { expect: 'text' }); - try { - // 1. Initiate Socket.io Handshake - const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, { - headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT } - }, 5000); + if (!response.ok) { + throw new Error(`Failed to fetch project info: ${response.status}`); + } this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); const html = response.body as string; const $ = cheerio.load(html); - const handshakeBody = (await handshakeResponse.text()).trim(); - sid = handshakeBody.split(':')[0]; - if (!sid) throw new Error('Could not parse socket session ID'); - - // 2. Poll the socket for the project data - const pollUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - - for (let attempt = 0; attempt < 3; attempt++) { - const pollResponse = await this.fetchWithTimeout(pollUrl, { - headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT } - }, 5000); - - if (!pollResponse.ok) throw new Error(`Socket poll failed: ${pollResponse.status}`); - this.applySetCookieHeaders(pollResponse.headers); + // Look for project data in meta tags + let projectInfo: ProjectInfo | undefined; - const payload = await pollResponse.text(); - const packets = this.decodeSocketIoPayload(payload); - - for (const packet of packets) { - // Look for the main event packet - if (packet.startsWith('5:::')) { - try { - const payloadJson = JSON.parse(packet.slice(4)); - if (payloadJson?.name === 'joinProjectResponse') { - const projectData = payloadJson?.args?.[0]?.project; - - if (projectData) { - // Map the socket data to the strict TypeScript ProjectInfo interface - return { - _id: projectData._id, - name: projectData.name, - rootDoc_id: projectData.rootDoc_id, - rootFolder: projectData.rootFolder - }; - } - } - } catch (e) { } - } + // Try ol-project meta tag + const projectMeta = $('meta[name="ol-project"]').attr('content'); + if (projectMeta) { + try { + projectInfo = JSON.parse(projectMeta); + } catch (e) { + // Continue + } + } - // Reply to heartbeat - if (packet.startsWith('2::')) { - await this.fetchWithTimeout(pollUrl, { - method: 'POST', - headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '2::' - }, 5000); + // Try to find in other meta tags + if (!projectInfo) { + const metas = $('meta[content]').toArray(); + for (const meta of metas) { + const content = $(meta).attr('content') || ''; + if (content.includes('rootFolder')) { + try { + projectInfo = JSON.parse(content); + break; + } catch (e) { + // Continue } } } - } finally { - // 3. Cleanly disconnect the socket - if (sid) { - try { - const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - await this.fetchWithTimeout(disconnectUrl, { - method: 'POST', - headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '0::' - }, 5000); - } catch { /* ignore */ } - } } - throw new Error('Could not parse project info from WebSocket'); - } + if (!projectInfo) { + throw new Error('Could not parse project info'); + } + return projectInfo; + } /** * Download a URL as a Buffer using Node.js http/https modules. From 239153c9cd45cec774a49f02a1d5b311317eee62 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Fri, 24 Apr 2026 19:53:40 +0200 Subject: [PATCH 16/20] Removed whatever was done in 0.1.8 because it is messing up getting projectInfo, required for deleting files --- src/client.ts | 467 +++++++++++++++++++++----------------------------- 1 file changed, 200 insertions(+), 267 deletions(-) diff --git a/src/client.ts b/src/client.ts index 8aeb099..4b27c48 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,6 +6,7 @@ */ import * as cheerio from 'cheerio'; +import { CookieJar, Cookie } from 'tough-cookie'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -22,8 +23,6 @@ import { getSessionCookieName, setSessionCookieName } from './config.js'; -import * as https from 'node:https'; -import * as http from 'node:http'; // Read version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -138,23 +137,27 @@ export class OverleafClient { }; // Fetch CSRF token from project page - const initialHeaders: Record = { - 'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '), - 'User-Agent': USER_AGENT - }; - const bootstrapClient = new OverleafClient({ cookies, csrf: 'bootstrap', baseUrl }); - const response = await bootstrapClient.httpRequest(`${baseUrl}/project`, { - headers: initialHeaders, - expect: 'text' + const response = await fetch(`${baseUrl}/project`, { + headers: { + 'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '), + 'User-Agent': USER_AGENT + } }); if (!response.ok) { - throw new Error(`Failed to fetch projects page: ${response.status}`); + throw new Error(`Failed to fetch projects page: ${response.status} ${response.statusText}`); } - bootstrapClient.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + // Capture any new cookies from response + const setCookieHeaders = response.headers.getSetCookie?.() || []; + for (const setCookie of setCookieHeaders) { + const match = setCookie.match(/^([^=]+)=([^;]+)/); + if (match) { + cookies[match[1]] = match[2]; + } + } - const html = response.body as string; + const html = await response.text(); const $ = cheerio.load(html); // Try multiple methods to find CSRF token (based on PR #66, #82) @@ -185,15 +188,33 @@ export class OverleafClient { throw new Error('Could not find CSRF token. Session may have expired.'); } - // Update cookies if the bootstrap request added anything - const updatedCookies = bootstrapClient.cookies; - return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl }); + return new OverleafClient({ cookies, csrf, baseUrl }); } private getCookieHeader(): string { return Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; '); } + private applySetCookieHeaders(headers: Headers): void { + const setCookieHeaders = headers.getSetCookie?.() || []; + for (const setCookie of setCookieHeaders) { + const match = setCookie.match(/^([^=]+)=([^;]+)/); + if (match) { + this.cookies[match[1]] = match[2]; + } + } + } + + private async fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + } + private getHeaders(includeContentType = false): Record { const headers: Record = { 'Cookie': this.getCookieHeader(), @@ -206,111 +227,19 @@ export class OverleafClient { return headers; } - private normalizeHeaders(headers?: Record): Record { - const normalized: Record = {}; - if (!headers) return normalized; - for (const [key, value] of Object.entries(headers)) { - if (typeof value === 'string') { - normalized[key] = value; - } - } - return normalized; - } - - private applySetCookieHeaders(setCookie: string[] | undefined): void { - if (!setCookie) return; - for (const setCookieHeader of setCookie) { - const match = setCookieHeader.match(/^([^=]+)=([^;]+)/); - if (match) { - this.cookies[match[1]] = match[2]; - } - } - } - - private async httpRequest(url: string, options: { - method?: string; - headers?: Record; - body?: string | Buffer; - timeoutMs?: number; - maxRedirects?: number; - expect?: 'text' | 'json' | 'buffer'; - } = {}): Promise<{ status: number; ok: boolean; headers: Record; body: string | Buffer | any }> { - const method = options.method || 'GET'; - const timeoutMs = options.timeoutMs ?? 10000; - const maxRedirects = options.maxRedirects ?? 5; - const expect = options.expect ?? 'text'; - - const doRequest = (reqUrl: string, redirectsLeft: number): Promise<{ status: number; ok: boolean; headers: Record; body: string | Buffer | any }> => { - return new Promise((resolve, reject) => { - const parsedUrl = new URL(reqUrl); - const transport = parsedUrl.protocol === 'https:' ? https : http; - const headers = this.normalizeHeaders(options.headers); - - const req = transport.request(reqUrl, { method, headers }, (res) => { - const status = res.statusCode || 0; - const resHeaders = res.headers as Record; - - if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) { - const redirectUrl = new URL(res.headers.location, reqUrl).toString(); - res.resume(); - doRequest(redirectUrl, redirectsLeft - 1).then(resolve, reject); - return; - } - - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - const buffer = Buffer.concat(chunks); - let body: any = buffer; - if (expect === 'text') { - body = buffer.toString('utf-8'); - } else if (expect === 'json') { - try { - body = JSON.parse(buffer.toString('utf-8')); - } catch (e) { - return reject(new Error(`Failed to parse JSON response from ${reqUrl}`)); - } - } - resolve({ status, ok: status >= 200 && status < 300, headers: resHeaders, body }); - }); - res.on('error', reject); - }); - - req.on('error', reject); - - if (timeoutMs) { - req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`Request timeout after ${timeoutMs}ms`)); - }); - } - - if (options.body) { - req.write(options.body); - } - - req.end(); - }); - }; - - return doRequest(url, maxRedirects); - } - /** * Get all projects (not archived, not trashed) */ async listProjects(): Promise { - const response = await this.httpRequest(this.projectUrl(), { - headers: this.getHeaders(), - expect: 'text' + const response = await fetch(this.projectUrl(), { + headers: this.getHeaders() }); if (!response.ok) { throw new Error(`Failed to fetch projects: ${response.status}`); } - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - - const html = response.body as string; + const html = await response.text(); const $ = cheerio.load(html); // Try new Overleaf structure first (PR #82) @@ -413,59 +342,88 @@ export class OverleafClient { } /** - * Get detailed project info including file tree + * Get detailed project info including file tree (via WebSocket) */ async getProjectInfo(projectId: string): Promise { - const response = await this.httpRequest(`${this.projectUrl()}/${projectId}`, { - headers: this.getHeaders(), - expect: 'text' - }); + let sid: string | null = null; - if (!response.ok) { - throw new Error(`Failed to fetch project info: ${response.status}`); - } + try { + // 1. Initiate Socket.io Handshake + const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, { + headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT } + }, 5000); - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + if (!handshakeResponse.ok) throw new Error(`Socket handshake failed: ${handshakeResponse.status}`); + this.applySetCookieHeaders(handshakeResponse.headers); - const html = response.body as string; - const $ = cheerio.load(html); + const handshakeBody = (await handshakeResponse.text()).trim(); + sid = handshakeBody.split(':')[0]; + if (!sid) throw new Error('Could not parse socket session ID'); - // Look for project data in meta tags - let projectInfo: ProjectInfo | undefined; + // 2. Poll the socket for the project data + const pollUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - // Try ol-project meta tag - const projectMeta = $('meta[name="ol-project"]').attr('content'); - if (projectMeta) { - try { - projectInfo = JSON.parse(projectMeta); - } catch (e) { - // Continue - } - } + for (let attempt = 0; attempt < 3; attempt++) { + const pollResponse = await this.fetchWithTimeout(pollUrl, { + headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT } + }, 5000); - // Try to find in other meta tags - if (!projectInfo) { - const metas = $('meta[content]').toArray(); - for (const meta of metas) { - const content = $(meta).attr('content') || ''; - if (content.includes('rootFolder')) { - try { - projectInfo = JSON.parse(content); - break; - } catch (e) { - // Continue + if (!pollResponse.ok) throw new Error(`Socket poll failed: ${pollResponse.status}`); + this.applySetCookieHeaders(pollResponse.headers); + + const payload = await pollResponse.text(); + const packets = this.decodeSocketIoPayload(payload); + + for (const packet of packets) { + // Look for the main event packet + if (packet.startsWith('5:::')) { + try { + const payloadJson = JSON.parse(packet.slice(4)); + if (payloadJson?.name === 'joinProjectResponse') { + const projectData = payloadJson?.args?.[0]?.project; + + if (projectData) { + // Map the socket data to the strict TypeScript ProjectInfo interface + return { + _id: projectData._id, + name: projectData.name, + rootDoc_id: projectData.rootDoc_id, + rootFolder: projectData.rootFolder + }; + } + } + } catch (e) { } + } + + // Reply to heartbeat + if (packet.startsWith('2::')) { + await this.fetchWithTimeout(pollUrl, { + method: 'POST', + headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, + body: '2::' + }, 5000); } } } + } finally { + // 3. Cleanly disconnect the socket + if (sid) { + try { + const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + await this.fetchWithTimeout(disconnectUrl, { + method: 'POST', + headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, + body: '0::' + }, 5000); + } catch { /* ignore */ } + } } - if (!projectInfo) { - throw new Error('Could not parse project info'); - } - - return projectInfo; + throw new Error('Could not parse project info from WebSocket'); } + /** * Download a URL as a Buffer using Node.js http/https modules. * @@ -474,18 +432,39 @@ export class OverleafClient { * project names). See: https://github.com/aloth/olcli/issues/2 */ private async downloadBuffer(url: string): Promise { - const response = await this.httpRequest(url, { - headers: this.getHeaders(), - expect: 'buffer' - }); + const { default: https } = await import('node:https'); + const { default: http } = await import('node:http'); - if (!response.ok) { - throw new Error(`Download failed: ${response.status}`); - } + const doRequest = (reqUrl: string): Promise => { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(reqUrl); + const transport = parsedUrl.protocol === 'https:' ? https : http; - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + const req = transport.get(reqUrl, { + headers: this.getHeaders(), + }, (res) => { + // Follow redirects + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + const redirectUrl = new URL(res.headers.location, reqUrl).toString(); + doRequest(redirectUrl).then(resolve, reject); + return; + } - return response.body as Buffer; + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`Download failed: ${res.statusCode}`)); + return; + } + + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks))); + res.on('error', reject); + }); + req.on('error', reject); + }); + }; + + return doRequest(url); } /** @@ -502,7 +481,7 @@ export class OverleafClient { * Compile project and get PDF */ async compileProject(projectId: string): Promise<{ pdfUrl: string; logs: string[] }> { - const response = await this.httpRequest(this.compileUrl(projectId), { + const response = await fetch(this.compileUrl(projectId), { method: 'POST', headers: this.getHeaders(true), body: JSON.stringify({ @@ -510,17 +489,14 @@ export class OverleafClient { draft: false, check: 'silent', incrementalCompilesEnabled: true - }), - expect: 'json' + }) }); if (!response.ok) { throw new Error(`Failed to compile project: ${response.status}`); } - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - - const data = response.body as any; + const data = await response.json() as any; if (data.status !== 'success') { throw new Error(`Compilation failed: ${data.status}`); @@ -549,14 +525,13 @@ export class OverleafClient { * Create a folder in a project */ async createFolder(projectId: string, parentFolderId: string, name: string): Promise { - const response = await this.httpRequest(this.folderUrl(projectId), { + const response = await fetch(this.folderUrl(projectId), { method: 'POST', headers: this.getHeaders(true), body: JSON.stringify({ parent_folder_id: parentFolderId, name - }), - expect: 'json' + }) }); if (response.status === 400) { @@ -568,9 +543,7 @@ export class OverleafClient { throw new Error(`Failed to create folder: ${response.status}`); } - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - - const data = response.body as any; + const data = await response.json() as any; return data._id; } @@ -677,19 +650,17 @@ export class OverleafClient { try { const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - const handshakeResponse = await this.httpRequest(handshakeUrl, { + const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, { headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT - }, - expect: 'text', - timeoutMs: 5000 - }); + } + }, 5000); if (!handshakeResponse.ok) return null; - this.applySetCookieHeaders(handshakeResponse.headers['set-cookie'] as string[] | undefined); + this.applySetCookieHeaders(handshakeResponse.headers); - const handshakeBody = (handshakeResponse.body as string).trim(); + const handshakeBody = (await handshakeResponse.text()).trim(); sid = handshakeBody.split(':')[0]; if (!sid) return null; @@ -700,19 +671,17 @@ export class OverleafClient { // poll a few frames, first is usually connect ack, next includes joinProjectResponse for (let attempt = 0; attempt < 3; attempt++) { - const pollResponse = await this.httpRequest(buildPollUrl(), { + const pollResponse = await this.fetchWithTimeout(buildPollUrl(), { headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT - }, - expect: 'text', - timeoutMs: 5000 - }); + } + }, 5000); if (!pollResponse.ok) return null; - this.applySetCookieHeaders(pollResponse.headers['set-cookie'] as string[] | undefined); + this.applySetCookieHeaders(pollResponse.headers); - const payload = pollResponse.body as string; + const payload = await pollResponse.text(); const packets = this.decodeSocketIoPayload(payload); for (const packet of packets) { @@ -724,18 +693,16 @@ export class OverleafClient { if (packet.startsWith('2::')) { //reply to heartbeat to keep polling transport alive - const heartbeatResponse = await this.httpRequest(buildPollUrl(), { + const heartbeatResponse = await this.fetchWithTimeout(buildPollUrl(), { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '2::', - expect: 'text', - timeoutMs: 5000 - }); - this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie'] as string[] | undefined); + body: '2::' + }, 5000); + this.applySetCookieHeaders(heartbeatResponse.headers); } } @@ -750,18 +717,16 @@ export class OverleafClient { try { const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - const disconnectResponse = await this.httpRequest(disconnectUrl, { + const disconnectResponse = await this.fetchWithTimeout(disconnectUrl, { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '0::', - expect: 'text', - timeoutMs: 5000 - }); - this.applySetCookieHeaders(disconnectResponse.headers['set-cookie'] as string[] | undefined); + body: '0::' + }, 5000); + this.applySetCookieHeaders(disconnectResponse.headers); } catch { // Ignore cleanup failures. } @@ -780,19 +745,17 @@ export class OverleafClient { try { const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - const handshakeResponse = await this.httpRequest(handshakeUrl, { + const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, { headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT - }, - expect: 'text', - timeoutMs: 5000 - }); + } + }, 5000); if (!handshakeResponse.ok) return null; - this.applySetCookieHeaders(handshakeResponse.headers['set-cookie'] as string[] | undefined); + this.applySetCookieHeaders(handshakeResponse.headers); - const handshakeBody = (handshakeResponse.body as string).trim(); + const handshakeBody = (await handshakeResponse.text()).trim(); sid = handshakeBody.split(':')[0]; if (!sid) return null; @@ -800,19 +763,17 @@ export class OverleafClient { `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; for (let attempt = 0; attempt < 3; attempt++) { - const pollResponse = await this.httpRequest(buildPollUrl(), { + const pollResponse = await this.fetchWithTimeout(buildPollUrl(), { headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT - }, - expect: 'text', - timeoutMs: 5000 - }); + } + }, 5000); if (!pollResponse.ok) return null; - this.applySetCookieHeaders(pollResponse.headers['set-cookie'] as string[] | undefined); + this.applySetCookieHeaders(pollResponse.headers); - const payload = pollResponse.body as string; + const payload = await pollResponse.text(); const packets = this.decodeSocketIoPayload(payload); for (const packet of packets) { @@ -820,18 +781,16 @@ export class OverleafClient { if (folderTree) return folderTree; if (packet.startsWith('2::')) { - const heartbeatResponse = await this.httpRequest(buildPollUrl(), { + const heartbeatResponse = await this.fetchWithTimeout(buildPollUrl(), { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '2::', - expect: 'text', - timeoutMs: 5000 - }); - this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie'] as string[] | undefined); + body: '2::' + }, 5000); + this.applySetCookieHeaders(heartbeatResponse.headers); } } } @@ -842,17 +801,15 @@ export class OverleafClient { try { const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - await this.httpRequest(disconnectUrl, { + await this.fetchWithTimeout(disconnectUrl, { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '0::', - expect: 'text', - timeoutMs: 5000 - }); + body: '0::' + }, 5000); } catch { // Ignore cleanup failures. } @@ -968,24 +925,17 @@ export class OverleafClient { formData.append('type', 'text/plain'); formData.append('qqfile', new Blob(['probe']), testFileName); - const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${folderId}`, { + const response = await fetch(`${this.uploadUrl(projectId)}?folder_id=${folderId}`, { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'X-Csrf-Token': this.csrf }, - body: formData as unknown as Buffer, - expect: 'json' + body: formData }); - if (!response.ok) { - continue; - } - - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - - const data = response.body as any; + const data = await response.json() as any; if (data.success !== false && data.entity_id) { // Success! Delete the probe file and return this folder ID try { @@ -1052,19 +1002,18 @@ export class OverleafClient { formData.append('type', mimeType); formData.append('qqfile', new Blob([content]), baseName); - const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${encodeURIComponent(fid)}`, { + const response = await fetch(`${this.uploadUrl(projectId)}?folder_id=${encodeURIComponent(fid)}`, { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'X-Csrf-Token': this.csrf }, - body: formData as unknown as Buffer, - expect: 'text' + body: formData }); if (!response.ok) { - const text = response.body as string; + const text = await response.text(); // Overleaf returns folder_not_found as HTTP 422 JSON. // Parse the body first so caller can trigger folder probing fallback. try { @@ -1078,9 +1027,7 @@ export class OverleafClient { return { success: false, error: `${response.status} - ${text}` }; } - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - - const data = JSON.parse(response.body as string) as any; + const data = await response.json() as any; if (data.success === false && data.error === 'folder_not_found') { return { success: false, error: 'folder_not_found' }; } @@ -1133,35 +1080,29 @@ export class OverleafClient { ): Promise { const url = this.deleteUrl(projectId, entityType, entityId); - const response = await this.httpRequest(url, { + const response = await fetch(url, { method: 'DELETE', - headers: this.getHeaders(), - expect: 'text' + headers: this.getHeaders() }); if (!response.ok) { throw new Error(`Failed to delete entity: ${response.status}`); } - - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); } /** * Get list of entities (files/docs) with paths */ async getEntities(projectId: string): Promise<{ path: string; type: 'doc' | 'file' }[]> { - const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/entities`, { - headers: this.getHeaders(), - expect: 'json' + const response = await fetch(`${this.baseUrl}/project/${projectId}/entities`, { + headers: this.getHeaders() }); if (!response.ok) { throw new Error(`Failed to get entities: ${response.status}`); } - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - - const data = response.body as any; + const data = await response.json() as any; return data.entities || []; } @@ -1217,24 +1158,22 @@ export class OverleafClient { */ async downloadFile(projectId: string, fileId: string, fileType: 'doc' | 'file'): Promise { const endpoint = fileType === 'doc' ? 'doc' : 'file'; - const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${endpoint}/${fileId}`, { - headers: this.getHeaders(), - expect: fileType === 'doc' ? 'json' : 'buffer' + const response = await fetch(`${this.baseUrl}/project/${projectId}/${endpoint}/${fileId}`, { + headers: this.getHeaders() }); if (!response.ok) { throw new Error(`Failed to download file: ${response.status}`); } - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - if (fileType === 'doc') { // Docs return JSON with lines array - const data = response.body as any; + const data = await response.json() as any; const content = (data.lines || []).join('\n'); return Buffer.from(content, 'utf-8'); } else { - return response.body as Buffer; + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); } } @@ -1247,18 +1186,15 @@ export class OverleafClient { entityType: 'doc' | 'file' | 'folder', newName: string ): Promise { - const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${entityType}/${entityId}/rename`, { + const response = await fetch(`${this.baseUrl}/project/${projectId}/${entityType}/${entityId}/rename`, { method: 'POST', headers: this.getHeaders(true), - body: JSON.stringify({ name: newName }), - expect: 'text' + body: JSON.stringify({ name: newName }) }); if (!response.ok) { throw new Error(`Failed to rename entity: ${response.status}`); } - - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); } /** @@ -1332,7 +1268,7 @@ export class OverleafClient { pdfUrl?: string; outputFiles: { path: string; type: string; url: string }[]; }> { - const response = await this.httpRequest(this.compileUrl(projectId), { + const response = await fetch(this.compileUrl(projectId), { method: 'POST', headers: this.getHeaders(true), body: JSON.stringify({ @@ -1340,17 +1276,14 @@ export class OverleafClient { draft: false, check: 'silent', incrementalCompilesEnabled: true - }), - expect: 'json' + }) }); if (!response.ok) { throw new Error(`Failed to compile project: ${response.status}`); } - this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - - const data = response.body as any; + const data = await response.json() as any; const pdfFile = data.outputFiles?.find((f: any) => f.type === 'pdf'); return { From b7490fd5c12330df8d11fcb5b7392f8a7a6d025d Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Fri, 24 Apr 2026 20:13:10 +0200 Subject: [PATCH 17/20] Force pulling when importing, will change to checking client for newer changes --- src/git-helper.ts | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 9a951ee..b55a17c 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -62,28 +62,33 @@ for await (const line of rl) { } } -/* - * Function handling the option request from git-remote-helper - */ - function runOption(argv: string[]): void {//TODO: Actually handle options - console.log("unsupported") - } + /* + * Function handling the option request from git-remote-helper + */ +function runOption(argv: string[]): void {//TODO: Actually handle options + console.log("unsupported") +} /* * Function handling the list request from git-remote-helper */ function runList(argv: string[]): void { - let hash = '?'; - try { - const remoteName = process.argv[2]; - hash = execSync(`git rev-parse refs/remotes/${remoteName}/main`, { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8' - }).trim(); - } catch { - hash = '?'; + const isPushing = argv.includes('for-push'); + + if (isPushing) { + try { + const remoteName = process.argv[2]; + const hash = execSync(`git rev-parse refs/remotes/${remoteName}/main`, { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8' + }).trim(); + console.log(`${hash} refs/heads/main`); + } catch { + console.log(`? refs/heads/main`); + } + } else { + console.log(`? refs/heads/main`); } - console.log(`${hash} refs/heads/main`); console.log(`@refs/heads/main HEAD`); console.log(''); } From 4b755016a48405473024522534ede7d32432bf3c Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Fri, 24 Apr 2026 21:21:55 +0200 Subject: [PATCH 18/20] Worked on commits as labels --- src/client.ts | 43 ++++++++++++++++++++++--------------------- src/git-helper.ts | 11 ++++++----- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/client.ts b/src/client.ts index 4b27c48..41530c4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -46,6 +46,7 @@ export interface ProjectInfo { name: string; rootDoc_id?: string; rootFolder: FolderEntry[]; + version: number; } export interface FolderEntry { @@ -304,26 +305,24 @@ export class OverleafClient { /** * Apply a Label to the current overleaf state */ - /* - async applyOverleafLabel(projectId: string, message: string, version: number): Promise { - await new Promise(resolve => setTimeout(resolve, 100)); - - const url = `${this.baseUrl}/project/${projectId}/labels`; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(true), - body: JSON.stringify({ - comment: message, - version: version - }) - }); - - if (!response.ok) { - throw new Error(`Failed to create label: ${response.status}`); - } - } - */ + async applyOverleafLabel(projectId: string, message: string, version: number): Promise { + await new Promise(resolve => setTimeout(resolve, 100)); + + const url = `${this.baseUrl}/project/${projectId}/labels`; + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(true), + body: JSON.stringify({ + comment: message, + version: version + }) + }); + + if (!response.ok) { + throw new Error(`Failed to create label: ${response.status}`); + } + } /** * Get project by name @@ -385,11 +384,13 @@ export class OverleafClient { if (projectData) { // Map the socket data to the strict TypeScript ProjectInfo interface + //console.error(projectData.version); return { _id: projectData._id, name: projectData.name, rootDoc_id: projectData.rootDoc_id, - rootFolder: projectData.rootFolder + rootFolder: projectData.rootFolder, + version: parseInt(projectData.version) // <-- Add this line! }; } } diff --git a/src/git-helper.ts b/src/git-helper.ts index b55a17c..8e355d1 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -219,12 +219,13 @@ async function runPush(refToUpdate: string){ const project = await client.getProjectInfo(projectId); - await client.applyOverleafLabel(projectId, commitMsg, project.version || 0); + //console.error(project.version); + await client.applyOverleafLabel(projectId, commitMsg, project.version || 0); - } catch (err: any) { - console.error(` -> Warning: Failed to apply label '${commitMsg}'`); - } - */ + } catch (err: any) { + console.error(` -> Warning: Failed to apply label '${commitMsg}'`); + } + */ } // Getting new last updated time from overleaf From 0e23d51521ca9fdd6afa9a27305311c85c620398 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Mon, 27 Apr 2026 17:53:54 +0200 Subject: [PATCH 19/20] Used version 0.3.0 as a base for new client.ts, http requests --- src/client.ts | 547 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 359 insertions(+), 188 deletions(-) diff --git a/src/client.ts b/src/client.ts index 41530c4..9104fb5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,10 +6,11 @@ */ import * as cheerio from 'cheerio'; -import { CookieJar, Cookie } from 'tough-cookie'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import * as https from 'node:https'; +import * as http from 'node:http'; import { getSessionCookie, setSessionCookie, @@ -46,7 +47,6 @@ export interface ProjectInfo { name: string; rootDoc_id?: string; rootFolder: FolderEntry[]; - version: number; } export interface FolderEntry { @@ -138,27 +138,23 @@ export class OverleafClient { }; // Fetch CSRF token from project page - const response = await fetch(`${baseUrl}/project`, { - headers: { - 'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '), - 'User-Agent': USER_AGENT - } + const initialHeaders: Record = { + 'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '), + 'User-Agent': USER_AGENT + }; + const bootstrapClient = new OverleafClient({ cookies, csrf: 'bootstrap', baseUrl }); + const response = await bootstrapClient.httpRequest(`${baseUrl}/project`, { + headers: initialHeaders, + expect: 'text' }); if (!response.ok) { - throw new Error(`Failed to fetch projects page: ${response.status} ${response.statusText}`); + throw new Error(`Failed to fetch projects page: ${response.status}`); } - // Capture any new cookies from response - const setCookieHeaders = response.headers.getSetCookie?.() || []; - for (const setCookie of setCookieHeaders) { - const match = setCookie.match(/^([^=]+)=([^;]+)/); - if (match) { - cookies[match[1]] = match[2]; - } - } + bootstrapClient.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - const html = await response.text(); + const html = response.body as string; const $ = cheerio.load(html); // Try multiple methods to find CSRF token (based on PR #66, #82) @@ -189,33 +185,15 @@ export class OverleafClient { throw new Error('Could not find CSRF token. Session may have expired.'); } - return new OverleafClient({ cookies, csrf, baseUrl }); + // Update cookies if the bootstrap request added anything + const updatedCookies = bootstrapClient.cookies; + return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl }); } private getCookieHeader(): string { return Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; '); } - private applySetCookieHeaders(headers: Headers): void { - const setCookieHeaders = headers.getSetCookie?.() || []; - for (const setCookie of setCookieHeaders) { - const match = setCookie.match(/^([^=]+)=([^;]+)/); - if (match) { - this.cookies[match[1]] = match[2]; - } - } - } - - private async fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timeout); - } - } - private getHeaders(includeContentType = false): Record { const headers: Record = { 'Cookie': this.getCookieHeader(), @@ -228,19 +206,127 @@ export class OverleafClient { return headers; } + private normalizeHeaders(headers?: Record): Record { + const normalized: Record = {}; + if (!headers) return normalized; + for (const [key, value] of Object.entries(headers)) { + if (typeof value === 'string') { + normalized[key] = value; + } + } + return normalized; + } + + private applySetCookieHeaders(setCookie: string[] | undefined): void { + if (!setCookie) return; + for (const setCookieHeader of setCookie) { + const match = setCookieHeader.match(/^([^=]+)=([^;]+)/); + if (match) { + this.cookies[match[1]] = match[2]; + } + } + } + + private async httpRequest(url: string, options: { + method?: string; + headers?: Record; + body?: string | Buffer | FormData; + timeoutMs?: number; + maxRedirects?: number; + expect?: 'text' | 'json' | 'buffer'; + } = {}): Promise<{ status: number; ok: boolean; headers: Record; body: string | Buffer | any }> { + const method = options.method || 'GET'; + const timeoutMs = options.timeoutMs ?? 10000; + const maxRedirects = options.maxRedirects ?? 5; + const expect = options.expect ?? 'text'; + + // Normalize FormData bodies into a multipart Buffer + headers using Node's + // built-in Web Fetch primitives. Keeps every code path on httpRequest + // (no fetch() reintroduction) while properly serializing multipart uploads. + let bodyBuffer: string | Buffer | undefined; + let extraHeaders: Record = {}; + if (options.body instanceof FormData) { + const req = new Request('http://x/', { method: 'POST', body: options.body }); + const arrayBuf = await req.arrayBuffer(); + bodyBuffer = Buffer.from(arrayBuf); + const ct = req.headers.get('content-type'); + if (ct) extraHeaders['Content-Type'] = ct; + extraHeaders['Content-Length'] = String(bodyBuffer.length); + } else if (options.body !== undefined) { + bodyBuffer = options.body as string | Buffer; + } + + const doRequest = (reqUrl: string, redirectsLeft: number): Promise<{ status: number; ok: boolean; headers: Record; body: string | Buffer | any }> => { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(reqUrl); + const transport = parsedUrl.protocol === 'https:' ? https : http; + const headers = this.normalizeHeaders({ ...extraHeaders, ...options.headers }); + + const req = transport.request(reqUrl, { method, headers }, (res) => { + const status = res.statusCode || 0; + const resHeaders = res.headers as Record; + + if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) { + const redirectUrl = new URL(res.headers.location, reqUrl).toString(); + res.resume(); + doRequest(redirectUrl, redirectsLeft - 1).then(resolve, reject); + return; + } + + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const buffer = Buffer.concat(chunks); + let body: any = buffer; + if (expect === 'text') { + body = buffer.toString('utf-8'); + } else if (expect === 'json') { + try { + body = JSON.parse(buffer.toString('utf-8')); + } catch (e) { + return reject(new Error(`Failed to parse JSON response from ${reqUrl}`)); + } + } + resolve({ status, ok: status >= 200 && status < 300, headers: resHeaders, body }); + }); + res.on('error', reject); + }); + + req.on('error', reject); + + if (timeoutMs) { + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Request timeout after ${timeoutMs}ms`)); + }); + } + + if (bodyBuffer !== undefined) { + req.write(bodyBuffer); + } + + req.end(); + }); + }; + + return doRequest(url, maxRedirects); + } + /** * Get all projects (not archived, not trashed) */ async listProjects(): Promise { - const response = await fetch(this.projectUrl(), { - headers: this.getHeaders() + const response = await this.httpRequest(this.projectUrl(), { + headers: this.getHeaders(), + expect: 'text' }); if (!response.ok) { throw new Error(`Failed to fetch projects: ${response.status}`); } - const html = await response.text(); + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + + const html = response.body as string; const $ = cheerio.load(html); // Try new Overleaf structure first (PR #82) @@ -341,90 +427,150 @@ export class OverleafClient { } /** - * Get detailed project info including file tree (via WebSocket) + * Get detailed project info including file tree */ async getProjectInfo(projectId: string): Promise { - let sid: string | null = null; + const response = await this.httpRequest(`${this.projectUrl()}/${projectId}`, { + headers: this.getHeaders(), + expect: 'text' + }); - try { - // 1. Initiate Socket.io Handshake - const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, { - headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT } - }, 5000); + if (!response.ok) { + throw new Error(`Failed to fetch project info: ${response.status}`); + } - if (!handshakeResponse.ok) throw new Error(`Socket handshake failed: ${handshakeResponse.status}`); - this.applySetCookieHeaders(handshakeResponse.headers); + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - const handshakeBody = (await handshakeResponse.text()).trim(); - sid = handshakeBody.split(':')[0]; - if (!sid) throw new Error('Could not parse socket session ID'); + const html = response.body as string; + const $ = cheerio.load(html); - // 2. Poll the socket for the project data - const pollUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + // Look for project data in meta tags + let projectInfo: ProjectInfo | undefined; - for (let attempt = 0; attempt < 3; attempt++) { - const pollResponse = await this.fetchWithTimeout(pollUrl, { - headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT } - }, 5000); + // Try ol-project meta tag + const projectMeta = $('meta[name="ol-project"]').attr('content'); + if (projectMeta) { + try { + projectInfo = JSON.parse(projectMeta); + } catch (e) { + // Continue + } + } - if (!pollResponse.ok) throw new Error(`Socket poll failed: ${pollResponse.status}`); - this.applySetCookieHeaders(pollResponse.headers); + // Try to find in other meta tags + if (!projectInfo) { + const metas = $('meta[content]').toArray(); + for (const meta of metas) { + const content = $(meta).attr('content') || ''; + if (content.includes('rootFolder')) { + try { + projectInfo = JSON.parse(content); + break; + } catch (e) { + // Continue + } + } + } + } - const payload = await pollResponse.text(); - const packets = this.decodeSocketIoPayload(payload); + // Fallback: Overleaf no longer ships the project tree in meta tags. + // Use the Socket.IO joinProjectResponse payload (same source used for + // root folder discovery) to retrieve the full project info. + if (!projectInfo) { + const socketProject = await this.getProjectFromSocket(projectId); + if (socketProject) { + projectInfo = socketProject as ProjectInfo; + } + } + + if (!projectInfo) { + throw new Error('Could not parse project info'); + } + return projectInfo; + } + + /** + * Fetch the full project object via the collaboration socket. + * Returns the `project` field of the joinProjectResponse, which contains + * the rootFolder tree and other metadata that used to live in ol-project. + */ + private async getProjectFromSocket(projectId: string): Promise { + let sid: string | null = null; + try { + const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + const handshakeResponse = await this.httpRequest(handshakeUrl, { + headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT }, + expect: 'text', + timeoutMs: 5000 + }); + if (!handshakeResponse.ok) return null; + this.applySetCookieHeaders(handshakeResponse.headers['set-cookie'] as string[] | undefined); + const handshakeBody = (handshakeResponse.body as string).trim(); + sid = handshakeBody.split(':')[0]; + if (!sid) return null; + + const buildPollUrl = () => + `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; + + for (let attempt = 0; attempt < 6; attempt++) { + const pollResponse = await this.httpRequest(buildPollUrl(), { + headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT }, + expect: 'text', + timeoutMs: 5000 + }); + if (!pollResponse.ok) return null; + this.applySetCookieHeaders(pollResponse.headers['set-cookie'] as string[] | undefined); + const packets = this.decodeSocketIoPayload(pollResponse.body as string); for (const packet of packets) { - // Look for the main event packet if (packet.startsWith('5:::')) { try { - const payloadJson = JSON.parse(packet.slice(4)); - if (payloadJson?.name === 'joinProjectResponse') { - const projectData = payloadJson?.args?.[0]?.project; - - if (projectData) { - // Map the socket data to the strict TypeScript ProjectInfo interface - //console.error(projectData.version); - return { - _id: projectData._id, - name: projectData.name, - rootDoc_id: projectData.rootDoc_id, - rootFolder: projectData.rootFolder, - version: parseInt(projectData.version) // <-- Add this line! - }; - } + const payload = JSON.parse(packet.slice(4)); + if (payload?.name === 'joinProjectResponse' && payload?.args?.[0]?.project) { + return payload.args[0].project; } - } catch (e) { } + } catch { /* ignore */ } } - - // Reply to heartbeat if (packet.startsWith('2::')) { - await this.fetchWithTimeout(pollUrl, { + const heartbeatResponse = await this.httpRequest(buildPollUrl(), { method: 'POST', - headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '2::' - }, 5000); + headers: { + 'Cookie': this.getCookieHeader(), + 'User-Agent': USER_AGENT, + 'Content-Type': 'text/plain;charset=UTF-8' + }, + body: '2::', + expect: 'text', + timeoutMs: 5000 + }); + this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie'] as string[] | undefined); } } } + } catch { + // fall through } finally { - // 3. Cleanly disconnect the socket if (sid) { try { const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - await this.fetchWithTimeout(disconnectUrl, { + const disconnectResponse = await this.httpRequest(disconnectUrl, { method: 'POST', - headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '0::' - }, 5000); + headers: { + 'Cookie': this.getCookieHeader(), + 'User-Agent': USER_AGENT, + 'Content-Type': 'text/plain;charset=UTF-8' + }, + body: '0::', + expect: 'text', + timeoutMs: 5000 + }); + this.applySetCookieHeaders(disconnectResponse.headers['set-cookie'] as string[] | undefined); } catch { /* ignore */ } } } - - throw new Error('Could not parse project info from WebSocket'); + return null; } - /** * Download a URL as a Buffer using Node.js http/https modules. * @@ -433,39 +579,18 @@ export class OverleafClient { * project names). See: https://github.com/aloth/olcli/issues/2 */ private async downloadBuffer(url: string): Promise { - const { default: https } = await import('node:https'); - const { default: http } = await import('node:http'); - - const doRequest = (reqUrl: string): Promise => { - return new Promise((resolve, reject) => { - const parsedUrl = new URL(reqUrl); - const transport = parsedUrl.protocol === 'https:' ? https : http; - - const req = transport.get(reqUrl, { - headers: this.getHeaders(), - }, (res) => { - // Follow redirects - if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - const redirectUrl = new URL(res.headers.location, reqUrl).toString(); - doRequest(redirectUrl).then(resolve, reject); - return; - } + const response = await this.httpRequest(url, { + headers: this.getHeaders(), + expect: 'buffer' + }); - if (res.statusCode && res.statusCode >= 400) { - reject(new Error(`Download failed: ${res.statusCode}`)); - return; - } + if (!response.ok) { + throw new Error(`Download failed: ${response.status}`); + } - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => resolve(Buffer.concat(chunks))); - res.on('error', reject); - }); - req.on('error', reject); - }); - }; + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); - return doRequest(url); + return response.body as Buffer; } /** @@ -482,7 +607,7 @@ export class OverleafClient { * Compile project and get PDF */ async compileProject(projectId: string): Promise<{ pdfUrl: string; logs: string[] }> { - const response = await fetch(this.compileUrl(projectId), { + const response = await this.httpRequest(this.compileUrl(projectId), { method: 'POST', headers: this.getHeaders(true), body: JSON.stringify({ @@ -490,14 +615,17 @@ export class OverleafClient { draft: false, check: 'silent', incrementalCompilesEnabled: true - }) + }), + expect: 'json' }); if (!response.ok) { throw new Error(`Failed to compile project: ${response.status}`); } - const data = await response.json() as any; + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + + const data = response.body as any; if (data.status !== 'success') { throw new Error(`Compilation failed: ${data.status}`); @@ -526,13 +654,14 @@ export class OverleafClient { * Create a folder in a project */ async createFolder(projectId: string, parentFolderId: string, name: string): Promise { - const response = await fetch(this.folderUrl(projectId), { + const response = await this.httpRequest(this.folderUrl(projectId), { method: 'POST', headers: this.getHeaders(true), body: JSON.stringify({ parent_folder_id: parentFolderId, name - }) + }), + expect: 'json' }); if (response.status === 400) { @@ -544,7 +673,9 @@ export class OverleafClient { throw new Error(`Failed to create folder: ${response.status}`); } - const data = await response.json() as any; + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + + const data = response.body as any; return data._id; } @@ -651,17 +782,19 @@ export class OverleafClient { try { const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, { + const handshakeResponse = await this.httpRequest(handshakeUrl, { headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT - } - }, 5000); + }, + expect: 'text', + timeoutMs: 5000 + }); if (!handshakeResponse.ok) return null; - this.applySetCookieHeaders(handshakeResponse.headers); + this.applySetCookieHeaders(handshakeResponse.headers['set-cookie'] as string[] | undefined); - const handshakeBody = (await handshakeResponse.text()).trim(); + const handshakeBody = (handshakeResponse.body as string).trim(); sid = handshakeBody.split(':')[0]; if (!sid) return null; @@ -672,17 +805,19 @@ export class OverleafClient { // poll a few frames, first is usually connect ack, next includes joinProjectResponse for (let attempt = 0; attempt < 3; attempt++) { - const pollResponse = await this.fetchWithTimeout(buildPollUrl(), { + const pollResponse = await this.httpRequest(buildPollUrl(), { headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT - } - }, 5000); + }, + expect: 'text', + timeoutMs: 5000 + }); if (!pollResponse.ok) return null; - this.applySetCookieHeaders(pollResponse.headers); + this.applySetCookieHeaders(pollResponse.headers['set-cookie'] as string[] | undefined); - const payload = await pollResponse.text(); + const payload = pollResponse.body as string; const packets = this.decodeSocketIoPayload(payload); for (const packet of packets) { @@ -694,16 +829,18 @@ export class OverleafClient { if (packet.startsWith('2::')) { //reply to heartbeat to keep polling transport alive - const heartbeatResponse = await this.fetchWithTimeout(buildPollUrl(), { + const heartbeatResponse = await this.httpRequest(buildPollUrl(), { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '2::' - }, 5000); - this.applySetCookieHeaders(heartbeatResponse.headers); + body: '2::', + expect: 'text', + timeoutMs: 5000 + }); + this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie'] as string[] | undefined); } } @@ -718,16 +855,18 @@ export class OverleafClient { try { const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - const disconnectResponse = await this.fetchWithTimeout(disconnectUrl, { + const disconnectResponse = await this.httpRequest(disconnectUrl, { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '0::' - }, 5000); - this.applySetCookieHeaders(disconnectResponse.headers); + body: '0::', + expect: 'text', + timeoutMs: 5000 + }); + this.applySetCookieHeaders(disconnectResponse.headers['set-cookie'] as string[] | undefined); } catch { // Ignore cleanup failures. } @@ -746,17 +885,19 @@ export class OverleafClient { try { const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, { + const handshakeResponse = await this.httpRequest(handshakeUrl, { headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT - } - }, 5000); + }, + expect: 'text', + timeoutMs: 5000 + }); if (!handshakeResponse.ok) return null; - this.applySetCookieHeaders(handshakeResponse.headers); + this.applySetCookieHeaders(handshakeResponse.headers['set-cookie'] as string[] | undefined); - const handshakeBody = (await handshakeResponse.text()).trim(); + const handshakeBody = (handshakeResponse.body as string).trim(); sid = handshakeBody.split(':')[0]; if (!sid) return null; @@ -764,17 +905,19 @@ export class OverleafClient { `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; for (let attempt = 0; attempt < 3; attempt++) { - const pollResponse = await this.fetchWithTimeout(buildPollUrl(), { + const pollResponse = await this.httpRequest(buildPollUrl(), { headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT - } - }, 5000); + }, + expect: 'text', + timeoutMs: 5000 + }); if (!pollResponse.ok) return null; - this.applySetCookieHeaders(pollResponse.headers); + this.applySetCookieHeaders(pollResponse.headers['set-cookie'] as string[] | undefined); - const payload = await pollResponse.text(); + const payload = pollResponse.body as string; const packets = this.decodeSocketIoPayload(payload); for (const packet of packets) { @@ -782,16 +925,18 @@ export class OverleafClient { if (folderTree) return folderTree; if (packet.startsWith('2::')) { - const heartbeatResponse = await this.fetchWithTimeout(buildPollUrl(), { + const heartbeatResponse = await this.httpRequest(buildPollUrl(), { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '2::' - }, 5000); - this.applySetCookieHeaders(heartbeatResponse.headers); + body: '2::', + expect: 'text', + timeoutMs: 5000 + }); + this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie'] as string[] | undefined); } } } @@ -802,15 +947,17 @@ export class OverleafClient { try { const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`; - await this.fetchWithTimeout(disconnectUrl, { + await this.httpRequest(disconnectUrl, { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'Content-Type': 'text/plain;charset=UTF-8' }, - body: '0::' - }, 5000); + body: '0::', + expect: 'text', + timeoutMs: 5000 + }); } catch { // Ignore cleanup failures. } @@ -926,17 +1073,24 @@ export class OverleafClient { formData.append('type', 'text/plain'); formData.append('qqfile', new Blob(['probe']), testFileName); - const response = await fetch(`${this.uploadUrl(projectId)}?folder_id=${folderId}`, { + const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${folderId}`, { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'X-Csrf-Token': this.csrf }, - body: formData + body: formData as unknown as Buffer, + expect: 'json' }); - const data = await response.json() as any; + if (!response.ok) { + continue; + } + + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + + const data = response.body as any; if (data.success !== false && data.entity_id) { // Success! Delete the probe file and return this folder ID try { @@ -1003,18 +1157,19 @@ export class OverleafClient { formData.append('type', mimeType); formData.append('qqfile', new Blob([content]), baseName); - const response = await fetch(`${this.uploadUrl(projectId)}?folder_id=${encodeURIComponent(fid)}`, { + const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${encodeURIComponent(fid)}`, { method: 'POST', headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT, 'X-Csrf-Token': this.csrf }, - body: formData + body: formData as unknown as Buffer, + expect: 'text' }); if (!response.ok) { - const text = await response.text(); + const text = response.body as string; // Overleaf returns folder_not_found as HTTP 422 JSON. // Parse the body first so caller can trigger folder probing fallback. try { @@ -1028,7 +1183,9 @@ export class OverleafClient { return { success: false, error: `${response.status} - ${text}` }; } - const data = await response.json() as any; + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + + const data = JSON.parse(response.body as string) as any; if (data.success === false && data.error === 'folder_not_found') { return { success: false, error: 'folder_not_found' }; } @@ -1081,29 +1238,35 @@ export class OverleafClient { ): Promise { const url = this.deleteUrl(projectId, entityType, entityId); - const response = await fetch(url, { + const response = await this.httpRequest(url, { method: 'DELETE', - headers: this.getHeaders() + headers: this.getHeaders(), + expect: 'text' }); if (!response.ok) { throw new Error(`Failed to delete entity: ${response.status}`); } + + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); } /** * Get list of entities (files/docs) with paths */ async getEntities(projectId: string): Promise<{ path: string; type: 'doc' | 'file' }[]> { - const response = await fetch(`${this.baseUrl}/project/${projectId}/entities`, { - headers: this.getHeaders() + const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/entities`, { + headers: this.getHeaders(), + expect: 'json' }); if (!response.ok) { throw new Error(`Failed to get entities: ${response.status}`); } - const data = await response.json() as any; + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + + const data = response.body as any; return data.entities || []; } @@ -1159,22 +1322,24 @@ export class OverleafClient { */ async downloadFile(projectId: string, fileId: string, fileType: 'doc' | 'file'): Promise { const endpoint = fileType === 'doc' ? 'doc' : 'file'; - const response = await fetch(`${this.baseUrl}/project/${projectId}/${endpoint}/${fileId}`, { - headers: this.getHeaders() + const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${endpoint}/${fileId}`, { + headers: this.getHeaders(), + expect: fileType === 'doc' ? 'json' : 'buffer' }); if (!response.ok) { throw new Error(`Failed to download file: ${response.status}`); } + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + if (fileType === 'doc') { // Docs return JSON with lines array - const data = await response.json() as any; + const data = response.body as any; const content = (data.lines || []).join('\n'); return Buffer.from(content, 'utf-8'); } else { - const arrayBuffer = await response.arrayBuffer(); - return Buffer.from(arrayBuffer); + return response.body as Buffer; } } @@ -1187,15 +1352,18 @@ export class OverleafClient { entityType: 'doc' | 'file' | 'folder', newName: string ): Promise { - const response = await fetch(`${this.baseUrl}/project/${projectId}/${entityType}/${entityId}/rename`, { + const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${entityType}/${entityId}/rename`, { method: 'POST', headers: this.getHeaders(true), - body: JSON.stringify({ name: newName }) + body: JSON.stringify({ name: newName }), + expect: 'text' }); if (!response.ok) { throw new Error(`Failed to rename entity: ${response.status}`); } + + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); } /** @@ -1269,7 +1437,7 @@ export class OverleafClient { pdfUrl?: string; outputFiles: { path: string; type: string; url: string }[]; }> { - const response = await fetch(this.compileUrl(projectId), { + const response = await this.httpRequest(this.compileUrl(projectId), { method: 'POST', headers: this.getHeaders(true), body: JSON.stringify({ @@ -1277,14 +1445,17 @@ export class OverleafClient { draft: false, check: 'silent', incrementalCompilesEnabled: true - }) + }), + expect: 'json' }); if (!response.ok) { throw new Error(`Failed to compile project: ${response.status}`); } - const data = await response.json() as any; + this.applySetCookieHeaders(response.headers['set-cookie'] as string[] | undefined); + + const data = response.body as any; const pdfFile = data.outputFiles?.find((f: any) => f.type === 'pdf'); return { From f3c9d69ae3a5ddf28d5e72207a951b12deba424e Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Mon, 27 Apr 2026 17:54:01 +0200 Subject: [PATCH 20/20] Started fixing issues with time and synchronization (not done yet) Changed architecture to use a class rather than functions, clearer code --- src/git-helper.ts | 597 +++++++++++++++++++++++----------------------- 1 file changed, 300 insertions(+), 297 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 8e355d1..8b29f13 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -5,374 +5,377 @@ import { tmpdir } from 'node:os'; import { join, relative } from 'node:path'; import AdmZip from 'adm-zip'; import { execSync } from 'node:child_process'; +import { OverleafClient } from './client.js'; -const url = process.argv[3].split('/'); +const { getClient } = await import('./client.js'); -const projectId = url[url.length -1]; -const baseUrl = url[0]+"//"+url[2]; -const { getClient } = await import('./client.js'); +async function main() { -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false -}); - -let pendingImportRef = ''; -let pendingPushRef = ''; - -for await (const line of rl) { - //console.error(`[DEBUG] Git asked: ${line}`); - let argv = line.split(' '); - - switch (argv[0]){ - case "capabilities" : - console.log('import'); - console.log('refspec refs/heads/*:refs/heads/*'); - console.log('option'); - console.log('list'); - console.log('push'); - console.log(''); - break; - case "option": - runOption(argv); - break; - case "list": - runList(argv); - break; - case "push": - pendingPushRef = argv[1].split(':')[1]; - break; - case "import": - pendingImportRef = argv[1]; - break; - - case "": - if (pendingImportRef !== '') { - await runImport(pendingImportRef); - pendingImportRef = ''; - } else if (pendingPushRef !== '') { - await runPush(pendingPushRef); - pendingPushRef = ''; - } else { - process.exit(0); - } - break; - } -} + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); - /* - * Function handling the option request from git-remote-helper - */ -function runOption(argv: string[]): void {//TODO: Actually handle options - console.log("unsupported") -} -/* - * Function handling the list request from git-remote-helper - */ -function runList(argv: string[]): void { - const isPushing = argv.includes('for-push'); + let pendingImportRef = ''; + let pendingPushRef = ''; - if (isPushing) { - try { - const remoteName = process.argv[2]; - const hash = execSync(`git rev-parse refs/remotes/${remoteName}/main`, { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8' - }).trim(); - console.log(`${hash} refs/heads/main`); - } catch { - console.log(`? refs/heads/main`); + const parser = new GitProtocol(process.argv[3], process.argv[2]); + + for await (const line of rl) { + console.error(`[DEBUG] Git asked: ${line}`); + let argv = line.split(' '); + + switch (argv[0]){ + case "capabilities" : + console.log('import'); + //console.error(process.argv); + console.log('refspec refs/heads/*:refs/heads/*'); + //console.log(`refspec refs/heads/*:refs/remotes/${process.argv[2]}/*`); + console.log('option'); + console.log('list'); + console.log('push'); + console.log(''); + break; + case "option": + parser.runOption(argv); + break; + case "list": + parser.runList(argv); + break; + case "push": + pendingPushRef = argv[1].split(':')[1]; + break; + case "import": + pendingImportRef = argv[1]; + break; + + case "": + if (pendingImportRef !== '') { + await parser.runImport(pendingImportRef); + pendingImportRef = ''; + } else if (pendingPushRef !== '') { + await parser.runPush(pendingPushRef); + pendingPushRef = ''; + } else { + process.exit(0); + } + break; } - } else { - console.log(`? refs/heads/main`); } - - console.log(`@refs/heads/main HEAD`); - console.log(''); } -/* - * Function handling the push request from git-remote-helper - */ -async function runPush(refToUpdate: string){ - const remoteName = process.argv[2]; - const branchName = refToUpdate.split('/').pop(); - const trackingRef = `refs/remotes/${remoteName}/${branchName}`; +class GitProtocol { + private remote: string; + private trackingRef: string;//const trackingRef = `refs/remotes/${remoteName}/${branchName}`; + private baseUrl: string; + private projectId: string; + private client?: OverleafClient; + + constructor(url: string, remote: string){ + this.remote = remote; + const urlT = url.split('/'); + this.projectId = urlT[urlT.length -1]; + this.baseUrl = urlT[0]+"//"+urlT[2]; + this.trackingRef = `refs/remotes/${remote}/main`; + + } - let commitsStr = ''; - try { - commitsStr = execSync(`git rev-list --reverse ${trackingRef}..${refToUpdate}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); - } catch (e) { - commitsStr = execSync(`git rev-list --reverse ${refToUpdate}`, { encoding: 'utf8' }).trim(); + /* + * Method handling the option request from git-remote-helper + */ + public runOption(argv: string[]): void {//TODO: Actually handle options + console.log("unsupported"); } + /* + * Method handling the list request from git-remote-helper + */ + public runList(argv: string[]): void { + const isPushing = argv.includes('for-push'); + + if (isPushing) { + try { + const hash = this.getLocalCommitHash(this.trackingRef); + console.log(`${hash} refs/heads/main`); + } catch { + console.log(`? refs/heads/main`); + } + } else { + console.log(`? refs/heads/main`); + } - if (!commitsStr) { - console.log(`ok ${refToUpdate}`); + console.log(`@refs/heads/main HEAD`); console.log(''); - return; } - const commits = commitsStr.split('\n'); - let tempDir = ''; - try { - const client = await getClient(); + /* + * Method handling the push request from git-remote-helper + */ + public async runPush(refToUpdate: string){ - let project = await client.getProjectById(projectId); - if (!project) project = await client.getProject(projectId); - if (!project) { - console.log(`error ${refToUpdate} Could not find project : ${projectId}`); - return; + let commitsStr = ''; + try { + commitsStr = execSync(`git rev-list --reverse ${this.trackingRef}..${refToUpdate}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); + } catch (e) { + commitsStr = execSync(`git rev-list --reverse ${refToUpdate}`, { encoding: 'utf8' }).trim(); } - const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); - const localTime = getLastSyncTime(); - if (overleafTime > localTime ){ - console.log(`error ${refToUpdate} Remote has newer changes. Please pull first.`); + if (!commitsStr) { + console.log(`ok ${refToUpdate}`); console.log(''); return; - }else{ + } + const commits = commitsStr.split('\n'); - const remoteFiles = new Map(); - const projectInfo = await client.getProjectInfo(projectId); + let tempDir = ''; + try { + if(!this.client) this.client = await getClient(); - if (projectInfo && projectInfo.rootFolder && projectInfo.rootFolder[0]) { - function buildFileMap(folder: any, currentPath: string = '') { - for (const doc of folder.docs || []) { - remoteFiles.set(currentPath ? `${currentPath}/${doc.name}` : doc.name, { id: doc._id, type: 'doc' }); - } - for (const file of folder.fileRefs || []) { - remoteFiles.set(currentPath ? `${currentPath}/${file.name}` : file.name, { id: file._id, type: 'file' }); - } - for (const sub of folder.folders || []) { - const subPath = currentPath ? `${currentPath}/${sub.name}` : sub.name; - remoteFiles.set(subPath, { id: sub._id, type: 'folder' }); - buildFileMap(sub, subPath); - } - } - buildFileMap(projectInfo.rootFolder[0]); + let project = await this.client.getProjectById(this.projectId); + if (!project) { + console.log(`error ${refToUpdate} Could not find project : ${this.projectId}`); + return; } - let folderTree = await client.getFolderTreeFromSocket(projectId); - if (!folderTree) folderTree = {}; - - for (const hash of commits) { - const commitMsg = execSync(`git show -s --format=%s ${hash}`, { encoding: 'utf8' }).trim(); + const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); + const localTime = this.getLocalCommitTime(refToUpdate); - const uploadStr = execSync(`git diff-tree --no-commit-id --name-only --diff-filter=ACMR -r ${hash}`, { encoding: 'utf8' }).trim(); - const filesToUpload = uploadStr ? uploadStr.split('\n') : []; + if (overleafTime > localTime ){ + console.log(`error ${refToUpdate} Remote has newer changes. Please pull first.`); + console.log(''); + return; + }else{ - const deleteStr = execSync(`git diff-tree --no-commit-id --name-only --diff-filter=D -r ${hash}`, { encoding: 'utf8' }).trim(); - const filesToDelete = deleteStr ? deleteStr.split('\n') : []; + const remoteFiles = new Map(); + const projectInfo = await this.client.getProjectInfo(this.projectId); - for (const file of filesToUpload) { - if ( file !== ".gitignore") { - try { - const content = execSync(`git show ${hash}:"${file}"`, { encoding: 'buffer' }); - await client.uploadFile(projectId!, null, file, content, folderTree); - } catch (error: any) { - console.log(`error ${refToUpdate} Failed to upload ${file}: ${error.message}`); + if (projectInfo && projectInfo.rootFolder && projectInfo.rootFolder[0]) { + function buildFileMap(folder: any, currentPath: string = '') { + for (const doc of folder.docs || []) { + remoteFiles.set(currentPath ? `${currentPath}/${doc.name}` : doc.name, { id: doc._id, type: 'doc' }); } - } - } - - for (const file of filesToDelete) { - const entity = remoteFiles.get(file); - if (!entity) { - console.log(`error ${refToUpdate} Failed to delete ${file}: Does not exist remotely`); - }else{ - try { - await client.deleteEntity(projectId!, entity.id, entity.type); - } catch (error: any) { - console.log(`error ${refToUpdate} Failed to delete ${file}: ${error.message}`); + for (const file of folder.fileRefs || []) { + remoteFiles.set(currentPath ? `${currentPath}/${file.name}` : file.name, { id: file._id, type: 'file' }); + } + for (const sub of folder.folders || []) { + const subPath = currentPath ? `${currentPath}/${sub.name}` : sub.name; + remoteFiles.set(subPath, { id: sub._id, type: 'folder' }); + buildFileMap(sub, subPath); } } + buildFileMap(projectInfo.rootFolder[0]); } - if(filesToDelete.length > 0) { - const folderEntries = Array.from(remoteFiles.entries()) - .filter(([path, entity]) => entity.type === 'folder'); - folderEntries.sort(([pathA], [pathB]) => pathB.split('/').length - pathA.split('/').length); + let folderTree = await this.client.getFolderTreeFromSocket(this.projectId); + if (!folderTree) folderTree = {}; - for (const [folderPath, entity] of folderEntries) { - const folderPrefix = folderPath + '/'; + for (const hash of commits) { + const commitMsg = execSync(`git show -s --format=%s ${hash}`, { encoding: 'utf8' }).trim(); - // Check if ANY key left in the map starts with this folder's path - if (! Array.from(remoteFiles.keys()).some( - key => key.startsWith(folderPrefix) - )) { + const uploadStr = execSync(`git diff-tree --no-commit-id --name-only --diff-filter=ACMR -r ${hash}`, { encoding: 'utf8' }).trim(); + const filesToUpload = uploadStr ? uploadStr.split('\n') : []; + const deleteStr = execSync(`git diff-tree --no-commit-id --name-only --diff-filter=D -r ${hash}`, { encoding: 'utf8' }).trim(); + const filesToDelete = deleteStr ? deleteStr.split('\n') : []; + + for (const file of filesToUpload) { + if ( file !== ".gitignore") { try { - await client.deleteEntity(projectId, entity.id, 'folder'); - remoteFiles.delete(folderPath); - } catch (e) { - console.log(`error ${refToUpdate} Failed to delete folder ${folderPath}`); + const content = execSync(`git show ${hash}:"${file}"`, { encoding: 'buffer' }); + await this.client.uploadFile(this.projectId!, null, file, content, folderTree); + } catch (error: any) { + console.log(`error ${refToUpdate} Failed to upload ${file}: ${error.message}`); + } + } + } + + for (const file of filesToDelete) { + const entity = remoteFiles.get(file); + if (!entity) { + console.log(`error ${refToUpdate} Failed to delete ${file}: Does not exist remotely`); + }else{ + try { + await this.client.deleteEntity(this.projectId!, entity.id, entity.type); + } catch (error: any) { + console.log(`error ${refToUpdate} Failed to delete ${file}: ${error.message}`); + } + } + } + if(filesToDelete.length > 0) { + const folderEntries = Array.from(remoteFiles.entries()) + .filter(([path, entity]) => entity.type === 'folder'); + + folderEntries.sort(([pathA], [pathB]) => pathB.split('/').length - pathA.split('/').length); + + for (const [folderPath, entity] of folderEntries) { + const folderPrefix = folderPath + '/'; + + // Check if ANY key left in the map starts with this folder's path + if (! Array.from(remoteFiles.keys()).some( + key => key.startsWith(folderPrefix) + )) { + + try { + await this.client.deleteEntity(this.projectId, entity.id, 'folder'); + remoteFiles.delete(folderPath); + } catch (e) { + console.log(`error ${refToUpdate} Failed to delete folder ${folderPath}`); + } } } } - } - /* - try { + /* + try { - const project = await client.getProjectInfo(projectId); + const project = await this.client.getProjectInfo(this.projectId); - //console.error(project.version); - await client.applyOverleafLabel(projectId, commitMsg, project.version || 0); + //console.error(project.version); + await this.client.applyOverleafLabel(this.projectId, commitMsg, project.version || 0); - } catch (err: any) { - console.error(` -> Warning: Failed to apply label '${commitMsg}'`); + } catch (err: any) { + console.error(` -> Warning: Failed to apply label '${commitMsg}'`); + } + */ } - */ - } - // Getting new last updated time from overleaf - let project = await client.getProjectById(projectId); - if (!project) project = await client.getProject(projectId); - if (!project) { - console.log(`error ${refToUpdate} Could not find project : ${projectId}`); - return; + console.log(`ok ${refToUpdate}`); + console.log(''); } - const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); - - setLastSyncTime(overleafTime); - console.log(`ok ${refToUpdate}`); + } catch (error: any) { + console.log(`error ${refToUpdate} Push failed: ${error.message}`); console.log(''); } - - } catch (error: any) { - console.log(`error ${refToUpdate} Push failed: ${error.message}`); - console.log(''); } -} - -/* - * Function handling the import request from git-remote-helper - */ -async function runImport(refToUpdate: string){ - let tempDir = ''; - try { - const client = await getClient(); - - let project = await client.getProjectById(projectId); - if (!project) project = await client.getProject(projectId); - if (!project) { - console.error(`\n[olcli] Error: Could not find project '${projectId}'`); - process.exit(1); - } - const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); - const localTime = getLastSyncTime(); - const hasLocalHistory = localTime > 0; - - //Checking if pulling is necessary - if (overleafTime === localTime) { + /* + * Method handling the import request from git-remote-helper + */ + public async runImport(refToUpdate: string){ + let tempDir = ''; + try { + if(!this.client) this.client = await getClient(); - const localHash = getLocalCommitHash(refToUpdate); + //this.branch = refToUpdate.split('/').pop() || 'main'; + //const trackingRef = `refs/remotes/${process.argv[2]}/${branchName}`; - process.stdout.write(`feature done\n`); - process.stdout.write(`reset ${refToUpdate}\n`); - process.stdout.write(`from ${localHash}\n`); - process.stdout.write(`done\n`, () => { - console.log(''); - }); - }else{ - //Downloading the zip file - const zipBuffer = await client.downloadProject(projectId); - - tempDir = mkdtempSync(join(tmpdir(), 'overleaf-sync-')); - const zipPath = join(tempDir, 'project.zip'); - const extractDir = join(tempDir, 'extracted'); - - writeFileSync(zipPath, zipBuffer); - const zip = new AdmZip(zipPath); - zip.extractAllTo(extractDir, true); - - function getFilesToImport(dir: string, fileList: string[] = []) { - const items = readdirSync(dir, { withFileTypes: true }); - for (const item of items) { - const fullPath = join(dir, item.name); - if (item.isDirectory()) { - getFilesToImport(fullPath, fileList); - } else { - fileList.push(fullPath); + let project = await this.client.getProjectById(this.projectId); + if (!project) { + console.error(`\n[olcli] Error: Could not find project '${this.projectId}'`); + process.exit(1); + } + const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); + const localTime = this.getLocalCommitTime(this.trackingRef); + const hasLocalHistory = localTime > 0; + + console.error(overleafTime, localTime); + + //Checking if pulling is necessary + if (overleafTime === localTime) { + + const localHash = this.getLocalCommitHash(this.trackingRef); + + process.stdout.write(`feature done\n`); + process.stdout.write(`reset ${refToUpdate}\n`); + process.stdout.write(`from ${localHash}\n`); + process.stdout.write(`done\n`, () => { + console.log(''); + }); + + }else{ + //Downloading the zip file + const zipBuffer = await this.client.downloadProject(this.projectId); + + tempDir = mkdtempSync(join(tmpdir(), 'overleaf-sync-')); + const zipPath = join(tempDir, 'project.zip'); + const extractDir = join(tempDir, 'extracted'); + + writeFileSync(zipPath, zipBuffer); + const zip = new AdmZip(zipPath); + zip.extractAllTo(extractDir, true); + + function getFilesToImport(dir: string, fileList: string[] = []) { + const items = readdirSync(dir, { withFileTypes: true }); + for (const item of items) { + const fullPath = join(dir, item.name); + if (item.isDirectory()) { + getFilesToImport(fullPath, fileList); + } else { + fileList.push(fullPath); + } } + return fileList; } - return fileList; - } - - const files = getFilesToImport(extractDir); - const timestamp = overleafTime; - const commitMsg = "Sync from Overleaf\n"; + const files = getFilesToImport(extractDir); + const timestamp = overleafTime; + const commitMsg = "Sync from Overleaf\n"; + + let streamData = ''; + //streamData += `feature done\n`; + streamData += `commit ${refToUpdate}\n`; + streamData += `mark :1\n`; + streamData += `author Overleaf Sync ${timestamp} +0000\n`; + streamData += `committer Overleaf Sync ${timestamp} +0000\n`; + streamData += `data ${Buffer.byteLength(commitMsg, 'utf8')}\n`; + streamData += commitMsg; + + const parentHash = this.getLocalCommitHash(this.trackingRef); + if (parentHash) { + console.error(parentHash); + streamData += `from ${parentHash}\n`; + } - let streamData = ''; - streamData += `commit ${refToUpdate}\n`; - streamData += `mark :1\n`; - streamData += `author Overleaf Sync ${timestamp} +0000\n`; - streamData += `committer Overleaf Sync ${timestamp} +0000\n`; - streamData += `data ${Buffer.byteLength(commitMsg, 'utf8')}\n`; - streamData += commitMsg; - - if (hasLocalHistory) { - streamData += `from ${refToUpdate}^0\n`; - } - + process.stdout.write(streamData); - process.stdout.write(streamData); + for (const filePath of files) { + let repoPath = relative(extractDir, filePath).replace(/\\/g, '/'); - for (const filePath of files) { - let repoPath = relative(extractDir, filePath).replace(/\\/g, '/'); + repoPath = repoPath.replace(/^\/+/, '').replace(/^\.\//, ''); - repoPath = repoPath.replace(/^\/+/, '').replace(/^\.\//, ''); + const formattedPath = repoPath.includes(' ') ? `"${repoPath}"` : repoPath; + const content = readFileSync(filePath); - const formattedPath = repoPath.includes(' ') ? `"${repoPath}"` : repoPath; - const content = readFileSync(filePath); + process.stdout.write(`M 100644 inline ${formattedPath}\n`); + process.stdout.write(`data ${content.length}\n`); + process.stdout.write(content); + process.stdout.write(`\n`); + } - process.stdout.write(`M 100644 inline ${formattedPath}\n`); - process.stdout.write(`data ${content.length}\n`); - process.stdout.write(content); - process.stdout.write(`\n`); + process.stdout.write(`done\n`, () => { + console.log(''); + }); } - process.stdout.write(`done\n`, () => { - console.log(''); - }); - - - //Setting the time locally - setLastSyncTime(overleafTime); + } catch (error: any) { + console.error(`\n[olcli] Error fetching from Overleaf: ${error.message}`); + process.exit(1); + } finally { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } } + } - } catch (error: any) { - console.error(`\n[olcli] Error fetching from Overleaf: ${error.message}`); - process.exit(1); - } finally { - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); + private getLocalCommitHash(ref: string): string { + try { + return execSync(`git rev-parse ${ref}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); + } catch { + return ''; } } -} -function getLocalCommitHash(ref: string): string { - try { - return execSync(`git rev-parse ${ref}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); - } catch { - return ''; - } -} -function getLastSyncTime(): number { - try { - const out = execSync(`git config overleaf.lastsync`, { encoding: 'utf8' }); - return parseInt(out.trim(), 10); - } catch { - return 0; // Returns 0 if we've never synced before + private getLocalCommitTime(ref: string): number { + try { + return parseInt(execSync(`git log -1 --format=%ct ${ref}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(), 10); + } catch { + return 0; + } } -} -function setLastSyncTime(timestamp: number) { - execSync(`git config overleaf.lastsync ${timestamp}`); } + +main();