From c14ee2a2993585b16343e3d3df3b18bed64283ae Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 16 Apr 2026 18:02:50 +0200 Subject: [PATCH 01/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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(); From e045b66ef3f745abac400b5204405d13d020d2fc Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Mon, 4 May 2026 16:22:52 +0200 Subject: [PATCH 21/35] Regenerated the code using Gemini and git-remote-hg as template, works with pull, push, merging, more features to come --- src/git-helper.ts | 538 +++++++++++++++++++++------------------------- 1 file changed, 249 insertions(+), 289 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 8b29f13..3e3208e 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -7,357 +7,260 @@ import AdmZip from 'adm-zip'; import { execSync } from 'node:child_process'; import { OverleafClient } from './client.js'; -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 = ''; +const remoteName = process.argv[2] || 'origin'; +const url = process.argv[3]; - 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; - } - } -} +const urlT = url.split('/'); +const projectId = urlT[urlT.length -1]; +const { getClient } = await import('./client.js'); -class GitProtocol { +class GitRemoteHelper { private remote: string; - private trackingRef: string;//const trackingRef = `refs/remotes/${remoteName}/${branchName}`; - private baseUrl: string; private projectId: string; + private prefix: string; private client?: OverleafClient; - constructor(url: string, remote: string){ + constructor(remote: string, projectId: 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`; + this.projectId = projectId; + this.prefix = `refs/overleaf/${remote}`; + } + + public async initClient() { + if (!this.client) { + this.client = await getClient(); + } + return this.client; + } + public runCapabilities() { + console.log('import'); + console.log('push'); + console.log(`refspec refs/heads/*:${this.prefix}/*`); + console.log('option'); + console.log('list'); + console.log(''); } - /* - * Method handling the option request from git-remote-helper - */ - public runOption(argv: string[]): void {//TODO: Actually handle options - console.log("unsupported"); + public runOption(argv: string[]) {//TODO: Implement the options + console.log('ok'); } - /* - * Method handling the list request from git-remote-helper - */ - public runList(argv: string[]): void { - const isPushing = argv.includes('for-push'); + public runList(argv: string[]) { + const isPushing = argv.includes('for-push'); if (isPushing) { - try { - const hash = this.getLocalCommitHash(this.trackingRef); + const hash = this.getLocalCommitHash(`${this.prefix}/main`); + if (hash) { console.log(`${hash} refs/heads/main`); - } catch { + } else { console.log(`? refs/heads/main`); } } else { console.log(`? refs/heads/main`); } - console.log(`@refs/heads/main HEAD`); console.log(''); } - /* - * Method handling the push request from git-remote-helper - */ - public async runPush(refToUpdate: string){ - - 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(); - } - - if (!commitsStr) { - console.log(`ok ${refToUpdate}`); - console.log(''); - return; - } - const commits = commitsStr.split('\n'); - + public async runImport(refsToImport: string[]) { let tempDir = ''; try { - if(!this.client) this.client = await getClient(); + const client = await this.initClient(); - let project = await this.client.getProjectById(this.projectId); + let project = await client.getProjectById(this.projectId); + if (!project) project = await client.getProject(this.projectId); if (!project) { - console.log(`error ${refToUpdate} Could not find project : ${this.projectId}`); - return; + console.error(`\n[olcli] Error: Could not find project '${this.projectId}'`); + process.exit(1); } + const requestedRef = refsToImport[0] || 'refs/heads/main'; + const privateRef = requestedRef.replace('refs/heads/', `${this.prefix}/`); + const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); - const localTime = this.getLocalCommitTime(refToUpdate); + const lastSyncTime = this.getLastSyncTime(); - if (overleafTime > localTime ){ - console.log(`error ${refToUpdate} Remote has newer changes. Please pull first.`); - console.log(''); + if (lastSyncTime > 0 && overleafTime === lastSyncTime) { + const localHash = this.getLocalCommitHash(privateRef); + process.stdout.write(`feature done\n`); + process.stdout.write(`reset ${privateRef}\n`); + process.stdout.write(`from ${localHash}\n`); + process.stdout.write(`done\n`, () => console.log('')); return; - }else{ - - const remoteFiles = new Map(); - const projectInfo = await this.client.getProjectInfo(this.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 this.client.getFolderTreeFromSocket(this.projectId); - if (!folderTree) folderTree = {}; - - for (const hash of commits) { - const commitMsg = execSync(`git show -s --format=%s ${hash}`, { encoding: 'utf8' }).trim(); - - 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 { - 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 { + } - const project = await this.client.getProjectInfo(this.projectId); + //TODO: Add with logs (options) + //console.error(`[olcli] Fetching project '${project.name}'...`); + const zipBuffer = await 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); + + const files = this.getFilesRecursively(extractDir); + const commitMsg = "Sync from Overleaf\n"; + + let streamData = ''; + streamData += `feature done\n`; + streamData += `commit ${privateRef}\n`; + streamData += `mark :1\n`; + streamData += `author Overleaf Sync ${overleafTime} +0000\n`; + streamData += `committer Overleaf Sync ${overleafTime} +0000\n`; + streamData += `data ${Buffer.byteLength(commitMsg, 'utf8')}\n`; + streamData += commitMsg; + + const parentHash = this.getLocalCommitHash(privateRef); + if (parentHash) { + streamData += `from ${parentHash}\n`; + } - //console.error(project.version); - await this.client.applyOverleafLabel(this.projectId, commitMsg, project.version || 0); + process.stdout.write(streamData); - } catch (err: any) { - console.error(` -> Warning: Failed to apply label '${commitMsg}'`); - } - */ - } + for (const filePath of files) { + let repoPath = relative(extractDir, filePath).replace(/\\/g, '/'); + repoPath = repoPath.replace(/^\/+/, '').replace(/^\.\//, ''); + const formattedPath = repoPath.includes(' ') ? `"${repoPath}"` : repoPath; + const content = readFileSync(filePath); - console.log(`ok ${refToUpdate}`); - console.log(''); + 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(''); + this.setLastSyncTime(overleafTime)}); + } catch (error: any) { - console.log(`error ${refToUpdate} Push failed: ${error.message}`); - console.log(''); + console.error(`\n[olcli] Error importing from Overleaf: ${error.message}`); + process.exit(1); + } finally { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); } } - /* - * Method handling the import request from git-remote-helper - */ - public async runImport(refToUpdate: string){ - let tempDir = ''; - try { - if(!this.client) this.client = await getClient(); - //this.branch = refToUpdate.split('/').pop() || 'main'; - //const trackingRef = `refs/remotes/${process.argv[2]}/${branchName}`; + public async runPush(refsToPush: string[]) { + const [localRef, remoteRef] = refsToPush[0].split(':'); + const privateRef = remoteRef.replace('refs/heads/', `${this.prefix}/`); - let project = await this.client.getProjectById(this.projectId); + try { + const client = await this.initClient(); + let project = await client.getProjectById(this.projectId); + if (!project) project = await client.getProject(this.projectId); if (!project) { - console.error(`\n[olcli] Error: Could not find project '${this.projectId}'`); - process.exit(1); + console.error(`error ${remoteRef} Project not found`); + return; } + const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); - const localTime = this.getLocalCommitTime(this.trackingRef); - const hasLocalHistory = localTime > 0; + const lastSyncTime = this.getLastSyncTime(); + console.error(overleafTime, lastSyncTime); - console.error(overleafTime, localTime); + if (lastSyncTime > 0 && overleafTime > lastSyncTime) { + console.log(`error ${remoteRef} Remote has newer changes. Please pull first.`); + console.log(''); + return; + } - //Checking if pulling is necessary - if (overleafTime === localTime) { + let commitsStr = ''; + try { + commitsStr = execSync(`git rev-list --reverse ${privateRef}..${localRef}`, { stdio:['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(); + } catch { + commitsStr = execSync(`git rev-list --reverse ${localRef}`, { encoding: 'utf8' }).trim(); + } - const localHash = this.getLocalCommitHash(this.trackingRef); + if (!commitsStr) { + console.log(`ok ${remoteRef}`); + console.log(''); + return; + } - 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); - } + const commits = commitsStr.split('\n'); + + const remoteFiles = new Map(); + const projectInfo = await client.getProjectInfo(this.projectId); + if (projectInfo?.rootFolder?.[0]) { + const 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); } - return fileList; - } - - 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`; - } - + }; + buildFileMap(projectInfo.rootFolder[0]); + } - process.stdout.write(streamData); + let folderTree = await client.getFolderTreeFromSocket(this.projectId) || {}; - for (const filePath of files) { - let repoPath = relative(extractDir, filePath).replace(/\\/g, '/'); + for (const hash of commits) { + const commitMsg = execSync(`git show -s --format=%s ${hash}`, { encoding: 'utf8' }).trim(); + 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') :[]; - repoPath = repoPath.replace(/^\/+/, '').replace(/^\.\//, ''); + for (const file of filesToUpload) { + if (file === ".gitignore") continue; + const content = execSync(`git show ${hash}:"${file}"`, { encoding: 'buffer' }); + await client.uploadFile(this.projectId, null, file, content, folderTree); + } - const formattedPath = repoPath.includes(' ') ? `"${repoPath}"` : repoPath; - const content = readFileSync(filePath); + for (const file of filesToDelete) { + const entity = remoteFiles.get(file); + if (entity) { + await client.deleteEntity(this.projectId, entity.id, entity.type); + remoteFiles.delete(file); + } + } + } - process.stdout.write(`M 100644 inline ${formattedPath}\n`); - process.stdout.write(`data ${content.length}\n`); - process.stdout.write(content); - process.stdout.write(`\n`); + const folderEntries = Array.from(remoteFiles.entries()).filter(([_, e]) => e.type === 'folder'); + folderEntries.sort(([pathA], [pathB]) => pathB.length - pathA.length); + for (const[folderPath, entity] of folderEntries) { + const hasChildren = Array.from(remoteFiles.keys()).some(k => k.startsWith(folderPath + '/')); + if (!hasChildren) { + try { + await client.deleteEntity(this.projectId, entity.id, 'folder'); + remoteFiles.delete(folderPath); + } catch {} } + } - process.stdout.write(`done\n`, () => { - console.log(''); - }); + const updatedProject = await client.getProjectById(this.projectId); + if (updatedProject) { + const newOverleafTime = Math.floor(new Date(updatedProject.lastUpdated).getTime() / 1000); + this.setLastSyncTime(newOverleafTime); } + execSync(`git update-ref ${privateRef} ${localRef}`); + + console.log(`ok ${remoteRef}`); + console.log(''); + } 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 }); + console.log(`error ${remoteRef} Push failed: ${error.message}`); + console.log(''); + } + } + + private getFilesRecursively(dir: string, fileList: string[] =[]) { + const items = readdirSync(dir, { withFileTypes: true }); + for (const item of items) { + const fullPath = join(dir, item.name); + if (item.isDirectory()) { + this.getFilesRecursively(fullPath, fileList); + } else { + fileList.push(fullPath); } } + return fileList; } private getLocalCommitHash(ref: string): string { @@ -368,14 +271,71 @@ class GitProtocol { } } - private getLocalCommitTime(ref: string): number { + private getLastSyncTime(): number { try { - return parseInt(execSync(`git log -1 --format=%ct ${ref}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(), 10); + return parseInt(execSync(`git config overleaf.${this.projectId}.lastsync`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim(), 10); } catch { return 0; } } + private setLastSyncTime(time: number): void { + execSync(`git config overleaf.${this.projectId}.lastsync ${time}`); + } +} + +async function main() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + const helper = new GitRemoteHelper(remoteName, projectId); + + let pendingImports: string[] = []; + let pendingPushes: string[] =[]; + + for await (const line of rl) { + if (line === '') { + if (pendingImports.length > 0) { + await helper.runImport(pendingImports); + pendingImports =[]; + } else if (pendingPushes.length > 0) { + await helper.runPush(pendingPushes); + pendingPushes =[]; + } else { + process.exit(0); + } + continue; + } + + const [cmd, ...args] = line.split(' '); + + switch (cmd) { + case 'capabilities': + helper.runCapabilities(); + break; + case 'option': + helper.runOption(args); + break; + case 'list': + helper.runList(args); + break; + case 'import': + pendingImports.push(args[0]); + break; + case 'push': + pendingPushes.push(args[0]); + break; + case 'fetch': + console.error('Fetch not supported. Use import.'); + process.exit(1); + break; + default: + console.error(`[olcli] Unknown command: ${line}`); + } + } } main(); From 0873e53074fffd3fb170b4d77cd1115f1948619b Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Tue, 5 May 2026 10:30:50 +0200 Subject: [PATCH 22/35] Added method to force the server to make a savestate to be downloaded --- src/client.ts | 11 +++++++++++ src/git-helper.ts | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index 9104fb5..d2afdcb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -410,6 +410,17 @@ export class OverleafClient { } } + /** + * Forces Overleaf to flush real-time edits to the database by pinging the download endpoint + */ + async forceSave(projectId: string): Promise { + const url = this.downloadUrl(projectId); + await fetch(url, { + method: 'HEAD', // Only fetches headers, 0 bytes of data! + headers: this.getHeaders() + }); + } + /** * Get project by name */ diff --git a/src/git-helper.ts b/src/git-helper.ts index 3e3208e..06ae649 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -68,8 +68,8 @@ class GitRemoteHelper { try { const client = await this.initClient(); + await client.forceSave(this.projectId); //Force save state online let project = await client.getProjectById(this.projectId); - if (!project) project = await client.getProject(this.projectId); if (!project) { console.error(`\n[olcli] Error: Could not find project '${this.projectId}'`); process.exit(1); @@ -150,8 +150,8 @@ class GitRemoteHelper { try { const client = await this.initClient(); + await client.forceSave(this.projectId); let project = await client.getProjectById(this.projectId); - if (!project) project = await client.getProject(this.projectId); if (!project) { console.error(`error ${remoteRef} Project not found`); return; From 866f104aaa6cb7fe753057a6b162ba7276881f71 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Tue, 5 May 2026 10:50:22 +0200 Subject: [PATCH 23/35] Added the authors and commiters if they are known --- src/git-helper.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 06ae649..7e94e70 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -109,8 +109,13 @@ class GitRemoteHelper { streamData += `feature done\n`; streamData += `commit ${privateRef}\n`; streamData += `mark :1\n`; - streamData += `author Overleaf Sync ${overleafTime} +0000\n`; - streamData += `committer Overleaf Sync ${overleafTime} +0000\n`; + if(!project.lastUpdatedBy){ + streamData += `author Overleaf Sync ${overleafTime} +0000\n`; + streamData += `committer Overleaf Sync ${overleafTime} +0000\n`; + }else{ + streamData += `author ${project.lastUpdatedBy.firstName} ${project.lastUpdatedBy.lastName} <${project.lastUpdatedBy.email}> ${overleafTime} +0000\n`; + streamData += `committer ${project.lastUpdatedBy.firstName} ${project.lastUpdatedBy.lastName} <${project.lastUpdatedBy.email}> ${overleafTime} +0000\n`; + } streamData += `data ${Buffer.byteLength(commitMsg, 'utf8')}\n`; streamData += commitMsg; From d8dcfb20624681a70b1783ff34b2a8ab32a0af41 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Tue, 5 May 2026 11:03:44 +0200 Subject: [PATCH 24/35] Fixed project's structure + added fetch first --- src/client.ts | 4 ++-- src/git-helper.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index d2afdcb..913f39e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -36,8 +36,8 @@ export interface Project { id: string; name: string; lastUpdated: string; - lastUpdatedBy?: string; - owner?: { email: string; firstName?: string; lastName?: string }; + lastUpdatedBy?: {id?:string; email: string; firstName?: string; lastName?: string }; + owner?: {id?:string; email: string; firstName?: string; lastName?: string }; archived?: boolean; trashed?: boolean; } diff --git a/src/git-helper.ts b/src/git-helper.ts index 7e94e70..596e273 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -164,10 +164,9 @@ class GitRemoteHelper { const overleafTime = Math.floor(new Date(project.lastUpdated).getTime() / 1000); const lastSyncTime = this.getLastSyncTime(); - console.error(overleafTime, lastSyncTime); if (lastSyncTime > 0 && overleafTime > lastSyncTime) { - console.log(`error ${remoteRef} Remote has newer changes. Please pull first.`); + console.log(`error ${remoteRef} fetch first`); console.log(''); return; } From 167f9907b267e1c1efd7a213151cc53b6ecb3705 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Tue, 5 May 2026 11:50:19 +0200 Subject: [PATCH 25/35] Added methods to potentially recreate a commit tree from history --- src/client.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/client.ts b/src/client.ts index 913f39e..6e37ea9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -388,6 +388,43 @@ export class OverleafClient { })); } + /** + * Fetch the all the version changes from history for a project + */ + async getUpdates(projectId: string): Promise { + const url = `${this.baseUrl}/project/${projectId}/updates`; + const response = await fetch(url, { headers: this.getHeaders(true) }); + if (!response.ok) return[]; + + const data = await response.json() as any; + return data.updates ||[]; + } + + /** + * Fetch the history labels (Saved Versions) for a project + */ + async getLabels(projectId: string): Promise { + const url = `${this.baseUrl}/project/${projectId}/labels`; + const response = await fetch(url, { + headers: this.getHeaders(true) + }); + + if (!response.ok) { + return[]; // Return empty array if no labels exist or endpoint fails + } + + const data = await response.json() as any; + return data ||[]; + } + + /** + * Download the project ZIP at a specific historical version + */ + async downloadHistoricalZip(projectId: string, version: number): Promise { + const url = `${this.baseUrl}/project/${projectId}/version/${version}/zip`; + return this.downloadBuffer(url); + } + /** * Apply a Label to the current overleaf state */ From 3180832c79ab9a7e569f6d8ece56dd7a5a9340ef Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Tue, 5 May 2026 12:01:06 +0200 Subject: [PATCH 26/35] Added back the baseURL --- src/git-helper.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 596e273..1409e63 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -7,24 +7,25 @@ import AdmZip from 'adm-zip'; import { execSync } from 'node:child_process'; import { OverleafClient } from './client.js'; -const remoteName = process.argv[2] || 'origin'; +const remote = process.argv[2] || 'origin'; const url = process.argv[3]; -const urlT = url.split('/'); -const projectId = urlT[urlT.length -1]; const { getClient } = await import('./client.js'); class GitRemoteHelper { private remote: string; private projectId: string; + private baseUrl: string; private prefix: string; private client?: OverleafClient; - constructor(remote: string, projectId: string) { + constructor(remote: string, url: string) { this.remote = remote; - this.projectId = projectId; this.prefix = `refs/overleaf/${remote}`; + const urlT = url.split('/'); + this.projectId = urlT[urlT.length -1]; + this.baseUrl = urlT[0] + "//" + urlT[2]; } public async initClient() { @@ -295,7 +296,7 @@ async function main() { terminal: false }); - const helper = new GitRemoteHelper(remoteName, projectId); + const helper = new GitRemoteHelper(remote, url); let pendingImports: string[] = []; let pendingPushes: string[] =[]; From df9db7af2d4ad695103456bffc67ac730d1eb7c9 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 7 May 2026 11:39:13 +0200 Subject: [PATCH 27/35] Changed cookie session architecture Added browser pop up for login with auto cookie retrieval + auto detect outdated cookies. Added new static method in client for client creation using the new baseUrl, cookie architecture. getClient is still in use in cli and has been moved back to cli.ts. --- package-lock.json | 1189 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + src/cli.ts | 32 +- src/client.ts | 123 ++++- src/config.ts | 15 +- src/git-helper.ts | 6 +- 6 files changed, 1326 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d5ac08..4a5e841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "commander": "^12.1.0", "conf": "^13.0.0", "ora": "^8.0.1", + "puppeteer": "^24.42.0", "tough-cookie": "^4.1.4" }, "bin": { @@ -32,6 +33,29 @@ "node": ">=18" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -474,6 +498,33 @@ "node": ">=18" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@types/adm-zip": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", @@ -488,7 +539,7 @@ "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, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -501,6 +552,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/adm-zip": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", @@ -510,6 +571,15 @@ "node": ">=12.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -555,6 +625,39 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/atomically": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.0.tgz", @@ -565,12 +668,144 @@ "when-exit": "^2.1.4" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", + "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", + "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -625,6 +860,19 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -652,6 +900,79 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -684,6 +1005,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -712,6 +1068,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/debounce-fn": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", @@ -727,6 +1092,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1595872", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz", + "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==", + "license": "BSD-3-Clause" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -816,6 +1218,15 @@ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -840,6 +1251,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -882,12 +1302,108 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -904,7 +1420,16 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fsevents": { + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", @@ -919,6 +1444,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -931,6 +1465,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.13.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", @@ -944,6 +1493,20 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -975,6 +1538,32 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -987,6 +1576,46 @@ "node": ">=0.10.0" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -1011,6 +1640,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1023,6 +1676,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", @@ -1051,6 +1710,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -1063,6 +1731,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -1075,6 +1764,15 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -1113,6 +1811,68 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -1162,6 +1922,52 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -1174,6 +1980,16 @@ "url": "https://github.com/sponsors/lupomontero" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -1183,12 +1999,60 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "24.42.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.42.0.tgz", + "integrity": "sha512-94MoPfFp2eY3eYIMdINkez4IOP5TMHntlZbVx06fHlQTtiQiYgaY0L2Zzfod8PVUkPqP7m3Qlre2v8YS8cudPA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1595872", + "puppeteer-core": "24.42.0", + "typed-query-selector": "^2.12.1" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.42.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.42.0.tgz", + "integrity": "sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1595872", + "typed-query-selector": "^2.12.1", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1204,6 +2068,15 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1237,9 +2110,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1260,6 +2133,54 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", + "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -1272,6 +2193,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -1319,6 +2251,50 @@ "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -1334,6 +2310,12 @@ "node": ">=6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1366,11 +2348,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1405,7 +2393,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -1427,6 +2415,12 @@ "requires-port": "^1.0.0" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "license": "Apache-2.0" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -1454,6 +2448,187 @@ "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 251b7c0..d08d25f 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "commander": "^12.1.0", "conf": "^13.0.0", "ora": "^8.0.1", + "puppeteer": "^24.42.0", "tough-cookie": "^4.1.4" }, "devDependencies": { diff --git a/src/cli.ts b/src/cli.ts index b0a5db0..d0ffe70 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,15 +12,15 @@ 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, getClient } from './client.js'; +import { OverleafClient } from './client.js'; // Read version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')); const VERSION = pkg.version; import { - getSessionCookie, - setSessionCookie, + getSession, + setSession, getLastProject, setLastProject, getConfigPath, @@ -34,6 +34,24 @@ import { const program = new Command(); +/** + * Helper to get authenticated client + */ +async function getClient(baseUrlOpt?: string): Promise { + const baseUrl = baseUrlOpt || getBaseUrl(); + const cookie = getSession(baseUrl); + 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 cookieName = getSessionCookieName(); + return OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); +} + + program .name('olcli') .description('Overleaf CLI - interact with Overleaf projects from the command line') @@ -115,7 +133,7 @@ program const client = await OverleafClient.fromSessionCookie(options.cookie, baseUrl, cookieName); const projects = await client.listProjects(); - setSessionCookie(options.cookie); + setSession(baseUrl, options.cookie); if (options.saveLocal) { saveOlAuth(options.cookie); @@ -135,7 +153,8 @@ program .command('whoami') .description('Show current authentication status') .action(async () => { - const cookie = getSessionCookie(); + const baseUrl = (program.opts().baseUrl as string | undefined) || getBaseUrl(); + const cookie = getSession(baseUrl); if (!cookie) { console.log(chalk.yellow('Not authenticated')); return; @@ -1113,7 +1132,8 @@ program console.log(' 3. Global config file'); console.log(); - const cookie = getSessionCookie(); + const baseUrl = (program.opts().baseUrl as string | undefined) || getBaseUrl(); + const cookie = getSession(baseUrl); if (cookie) { console.log(chalk.green('✓ Session cookie found')); console.log(chalk.dim(` Value: ${cookie.substring(0, 20)}...`)); diff --git a/src/client.ts b/src/client.ts index 6e37ea9..3c4ee30 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,8 +12,8 @@ import { fileURLToPath } from 'node:url'; import * as https from 'node:https'; import * as http from 'node:http'; import { - getSessionCookie, - setSessionCookie, + getSession, + setSession, getLastProject, setLastProject, getConfigPath, @@ -24,6 +24,7 @@ import { getSessionCookieName, setSessionCookieName } from './config.js'; +import puppeteer from 'puppeteer'; // Read version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -73,23 +74,6 @@ 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; @@ -125,6 +109,24 @@ export class OverleafClient { return `${this.baseUrl}/project/${projectId}/compile?enable_pdf_caching=true`; } + /** + * Create client with a url + */ + static async fromUrl(baseUrl: string): Promise { + let cookie = getSession(baseUrl); + if(!cookie){ + cookie = await OverleafClient.loginViaBrowser(baseUrl); + setSession(baseUrl, cookie); + } + + if(!cookie){ + process.exit(1); + } + + const cookieName = getSessionCookieName(); + return OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); + } + /** * Create client from session cookie string */ @@ -187,7 +189,19 @@ export class OverleafClient { // Update cookies if the bootstrap request added anything const updatedCookies = bootstrapClient.cookies; - return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl }); + const client = new OverleafClient({ cookies: updatedCookies, csrf, baseUrl }); + if(await client.verifySession()){ + return client; + }else{ + console.error("Cookie out of date, updating..."); + const newCookie = await OverleafClient.loginViaBrowser(baseUrl); + setSession(baseUrl, newCookie); + + if(!newCookie){ + process.exit(1); + } + return OverleafClient.fromSessionCookie(newCookie, baseUrl, cookieName); + } } private getCookieHeader(): string { @@ -311,6 +325,28 @@ export class OverleafClient { return doRequest(url, maxRedirects); } + /** + * Checks if the current session cookie is still valid + */ + async verifySession(): Promise { + try { + const response = await fetch(`${this.baseUrl}/project`, { + headers: this.getHeaders(), + redirect: 'follow' // Automatically follow redirects + }); + + // If Overleaf redirected us to the login page, the cookie is expired! + if (response.url.includes('/login')) { + return false; + } + + return response.ok; + } catch (error) { + // If the network fails entirely, assume the session check failed + return false; + } + } + /** * Get all projects (not archived, not trashed) */ @@ -388,6 +424,53 @@ export class OverleafClient { })); } + static async loginViaBrowser(baseUrl: string): Promise { + console.error(`\n🌐 Opening browser to log into ${baseUrl}...`); + console.error(`Please log in. The window will close automatically when finished.\n`); + + // 1. Launch a visible browser window + const browser = await puppeteer.launch({ + headless: false, + defaultViewport: null, + args:[ + `--app=${baseUrl}/login`, // Opens directly to the login page without tabs! + '--window-size=800,800' + ] + }); + + // 2. Use the single existing page instead of creating a new one + const page = (await browser.pages())[0]; + + try { + // 1. Wait for the user to reach the dashboard + await page.waitForFunction( + "window.location.pathname.startsWith('/project')", + { timeout: 300000 } + ); + + // 2. Poll the Cookie Jar until the REAL authenticated cookie arrives! + let sessionCookie; + + // Check every 500ms for up to 10 seconds + while (!sessionCookie) { + //for (let i = 0; i < 20; i++) { + const cookies = await page.cookies(baseUrl); + + sessionCookie = cookies.find(c => (c.name === 'overleaf_session2' || c.name === 'sharelatex_session') && !c.value.startsWith('s%3Ac%3A1%3A')); + + // Wait half a second before checking again + await new Promise(resolve => setTimeout(resolve, 500)); + } + await browser.close(); + return sessionCookie.value; + + + } catch (err: any) { + await browser.close().catch(() => {}); // Ensure browser closes + throw new Error(`Login aborted or timed out: ${err.message}`); + } + } + /** * Fetch the all the version changes from history for a project */ diff --git a/src/config.ts b/src/config.ts index 07bb053..f653934 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ import { join } from 'node:path'; import { homedir } from 'node:os'; interface OlcliConfig { + sessions?: Record; sessionCookie?: string; csrf?: string; lastProject?: string; @@ -18,6 +19,7 @@ interface OlcliConfig { const config = new Conf({ projectName: 'olcli', schema: { + sessions: {}, sessionCookie: { type: 'string' }, csrf: { type: 'string' }, lastProject: { type: 'string' }, @@ -42,7 +44,7 @@ export function setSessionCookieName(name: string): void { config.set('sessionCookieName', name); } -export function getSessionCookie(): string | undefined { +export function getSession(baseUrl: string): string | undefined { // Check environment variable first if (process.env.OVERLEAF_SESSION) { return process.env.OVERLEAF_SESSION; @@ -69,10 +71,17 @@ export function getSessionCookie(): string | undefined { } // Check global config - return config.get('sessionCookie'); + //const normalizedUrl = baseUrl.replace(/\/$/, ''); + const sessions = config.get('sessions') || {}; + return sessions[baseUrl || 'https://www.overleaf.com']; + //return config.get('sessionCookie'); } -export function setSessionCookie(cookie: string): void { +export function setSession(baseUrl:string, cookie: string): void { + const sessions = config.get('sessions') || {}; + + sessions[baseUrl] = cookie; + config.set('sessions', sessions); config.set('sessionCookie', cookie); } diff --git a/src/git-helper.ts b/src/git-helper.ts index 1409e63..38f2897 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -11,8 +11,6 @@ const remote = process.argv[2] || 'origin'; const url = process.argv[3]; -const { getClient } = await import('./client.js'); - class GitRemoteHelper { private remote: string; private projectId: string; @@ -28,9 +26,9 @@ class GitRemoteHelper { this.baseUrl = urlT[0] + "//" + urlT[2]; } - public async initClient() { + public async initClient(): Promise { if (!this.client) { - this.client = await getClient(); + this.client = await OverleafClient.fromUrl(this.baseUrl); } return this.client; } From 99e3fccb96bb088c647edc26faa0aec1990ab143 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 7 May 2026 13:21:27 +0200 Subject: [PATCH 28/35] Added local compiles with `olcli watch` + auto .gitignore with the log files. --- src/cli.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++++ src/git-helper.ts | 9 ++++++++ 2 files changed, 68 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index d0ffe70..9d870c1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ 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 { spawn } from 'node:child_process'; // Read version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -34,6 +35,54 @@ import { const program = new Command(); +/** + * Helper to watch the files and auto compile them + */ +async function runWatch(mainFile: string, useDocker: boolean) { + if (!existsSync(mainFile)) { + console.error(chalk.red(`\n❌ Error: Could not find main document '${mainFile}'`)); + process.exit(1); + } + const latexmkArgs = [ + '-pdf', // Compile to PDF + '-interaction=nonstopmode', // Don't pause terminal on syntax errors + '-synctex=1', // Enable SyncTeX (Click PDF to jump to code) + '-file-line-error', // Format errors nicely + '-auxdir=.aux', // Put all temp/aux files in a hidden .aux folder! + '-outdir=.build', // Keep the final PDF in the root folder! + '-pvc', // MAGIC FLAG: Preview Continuously (Watch mode!) + mainFile + ]; + + console.log(chalk.cyan(`\nStarting local Overleaf compiler environment...`)); + console.log(chalk.gray(`Watching for file changes. Press Ctrl+C to stop.\n`)); + + let command = 'latexmk'; + let args = latexmkArgs; + + if (useDocker) { + command = 'docker'; + args =[ + 'run', '--rm', '-it', + '-v', `${process.cwd()}:/workdir`, // Mount current directory + '-w', '/workdir', // Set working directory + 'texlive/texlive:latest', // The official image Overleaf uses + 'latexmk', ...latexmkArgs + ]; + } + + const compiler = spawn(command, args, { stdio: 'inherit' }); + + compiler.on('error', (err: any) => { + if (err.code === 'ENOENT') { + console.error(chalk.red(`\n❌ Error: '${command}' is not installed on your system.`)); + } else { + console.error(chalk.red(`\nCompiler error: ${err.message}`)); + } + process.exit(1); + }); +} + /** * Helper to get authenticated client */ @@ -1142,4 +1191,14 @@ program } }); +program +.command('watch [mainFile]') +.description('Locally compile and watch LaTeX files on change') +.option('--docker', 'Use the official TeX Live Docker image (100% Overleaf identical)') +.action((mainFile, options) => { + // Default to main.tex if no file is provided + const targetFile = mainFile || 'main.tex'; + runWatch(targetFile, options.docker); +}); + program.parse(process.argv); diff --git a/src/git-helper.ts b/src/git-helper.ts index 38f2897..6bbbeee 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -102,6 +102,15 @@ class GitRemoteHelper { zip.extractAllTo(extractDir, true); const files = this.getFilesRecursively(extractDir); + + const gitignorePath = join(extractDir, '.gitignore'); + + // Add the hidden .aux folder to it + writeFileSync(gitignorePath, '.aux/\n'); + writeFileSync(gitignorePath, '.build/\n'); + + files.push(gitignorePath); + const commitMsg = "Sync from Overleaf\n"; let streamData = ''; From 29450044e01adc88285bfea06ee3087de69f210f Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Thu, 7 May 2026 13:32:35 +0200 Subject: [PATCH 29/35] Changed the way the gitignore file is created + temporary removal of verifySession, as it is not working as intended --- src/client.ts | 3 ++- src/git-helper.ts | 14 ++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/client.ts b/src/client.ts index 3c4ee30..2522d0e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -190,7 +190,8 @@ export class OverleafClient { // Update cookies if the bootstrap request added anything const updatedCookies = bootstrapClient.cookies; const client = new OverleafClient({ cookies: updatedCookies, csrf, baseUrl }); - if(await client.verifySession()){ + if(true){ + //if(await client.verifySession()){ return client; }else{ console.error("Cookie out of date, updating..."); diff --git a/src/git-helper.ts b/src/git-helper.ts index 6bbbeee..2815847 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -90,7 +90,6 @@ class GitRemoteHelper { } //TODO: Add with logs (options) - //console.error(`[olcli] Fetching project '${project.name}'...`); const zipBuffer = await client.downloadProject(this.projectId); tempDir = mkdtempSync(join(tmpdir(), 'overleaf-sync-')); @@ -103,13 +102,16 @@ class GitRemoteHelper { const files = this.getFilesRecursively(extractDir); - const gitignorePath = join(extractDir, '.gitignore'); + if (!files.some(f => f.endsWith('.gitignore'))) { + // Create the .gitignore file on disk inside the extracted directory + const gitignorePath = join(extractDir, '.gitignore'); - // Add the hidden .aux folder to it - writeFileSync(gitignorePath, '.aux/\n'); - writeFileSync(gitignorePath, '.build/\n'); + // Add the hidden .aux folder to it + writeFileSync(gitignorePath, '.aux/\n.build/\n'); - files.push(gitignorePath); + // Add it to our array so fast-import picks it up! + files.push(gitignorePath); + } const commitMsg = "Sync from Overleaf\n"; From 417f1075a825ea0b25ca704aa0eaced7f661ecf0 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Sun, 10 May 2026 16:47:44 +0200 Subject: [PATCH 30/35] Do not override .gitignore if not cloning --- src/git-helper.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/git-helper.ts b/src/git-helper.ts index 2815847..3a91ccf 100644 --- a/src/git-helper.ts +++ b/src/git-helper.ts @@ -102,7 +102,9 @@ class GitRemoteHelper { const files = this.getFilesRecursively(extractDir); - if (!files.some(f => f.endsWith('.gitignore'))) { + const parentHash = this.getLocalCommitHash(privateRef); + + if (!files.some(f => f.endsWith('.gitignore')) && !parentHash) { // Create the .gitignore file on disk inside the extracted directory const gitignorePath = join(extractDir, '.gitignore'); @@ -129,7 +131,6 @@ class GitRemoteHelper { streamData += `data ${Buffer.byteLength(commitMsg, 'utf8')}\n`; streamData += commitMsg; - const parentHash = this.getLocalCommitHash(privateRef); if (parentHash) { streamData += `from ${parentHash}\n`; } From 191476326a6254df8ce98e356f0e6d2adb468d26 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Sun, 10 May 2026 17:00:59 +0200 Subject: [PATCH 31/35] Added a bunch of packages to hopefully try to get google oauth working --- package-lock.json | 538 +++++++++++++++++++++++++++++++++++++++++++--- package.json | 4 +- src/client.ts | 9 +- 3 files changed, 525 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a5e841..8e521a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "commander": "^12.1.0", "conf": "^13.0.0", "ora": "^8.0.1", - "puppeteer": "^24.42.0", + "puppeteer": "^24.43.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", "tough-cookie": "^4.1.4" }, "bin": { @@ -499,9 +501,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", - "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.1.tgz", + "integrity": "sha512-zmS4RTK9fbrc++WlAJhxYbfz3IjDeOmkK/CwwbLmk7ydfS9e2CiEeRJHEPvjDVElO/bwXbidwGA37Bsm6LzCnQ==", "license": "Apache-2.0", "dependencies": { "debug": "^4.4.3", @@ -535,6 +537,21 @@ "@types/node": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -646,6 +663,15 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -682,6 +708,12 @@ } } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/bare-events": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", @@ -788,6 +820,16 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -955,6 +997,22 @@ "node": ">=8" } }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "license": "MIT", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -982,6 +1040,12 @@ "node": ">=18" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/conf": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/conf/-/conf-13.1.0.tgz", @@ -1109,6 +1173,15 @@ } } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -1124,9 +1197,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1595872", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz", - "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==", + "version": "0.0.1608973", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", + "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", "license": "BSD-3-Clause" }, "node_modules/dom-serializer": { @@ -1429,6 +1502,56 @@ "pend": "~1.2.0" } }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1507,6 +1630,33 @@ "node": ">= 14" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -1592,6 +1742,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -1607,6 +1774,21 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1628,6 +1810,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -1640,6 +1834,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1676,6 +1879,48 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -1719,6 +1964,20 @@ "node": ">=12" } }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -1731,12 +1990,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "license": "MIT", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1922,6 +2215,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -2000,18 +2302,18 @@ } }, "node_modules/puppeteer": { - "version": "24.42.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.42.0.tgz", - "integrity": "sha512-94MoPfFp2eY3eYIMdINkez4IOP5TMHntlZbVx06fHlQTtiQiYgaY0L2Zzfod8PVUkPqP7m3Qlre2v8YS8cudPA==", + "version": "24.43.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.43.0.tgz", + "integrity": "sha512-DRnMFz+J3s4lFUQcjqKl0/7h0jzlCZuUFU9lNjtKrnMl5WI1RwCaIItpHVu9empuPyUreYueN0sUW3/pnfdqsg==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.13.0", + "@puppeteer/browsers": "2.13.1", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1595872", - "puppeteer-core": "24.42.0", - "typed-query-selector": "^2.12.1" + "devtools-protocol": "0.0.1608973", + "puppeteer-core": "24.43.0", + "typed-query-selector": "^2.12.2" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" @@ -2021,23 +2323,159 @@ } }, "node_modules/puppeteer-core": { - "version": "24.42.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.42.0.tgz", - "integrity": "sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg==", + "version": "24.43.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.0.tgz", + "integrity": "sha512-cCRNXsUlhyPoKDz6+TiSpfZpRS3mD6Y1YFKhkdr6ik6TMfuJb7fAtXq9ThUFc4sphxObDk3BuAvdxc1Y6YOnqQ==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.13.0", + "@puppeteer/browsers": "2.13.1", "chromium-bidi": "14.0.0", "debug": "^4.4.3", - "devtools-protocol": "0.0.1595872", - "typed-query-selector": "^2.12.1", + "devtools-protocol": "0.0.1608973", + "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.1", - "ws": "^8.19.0" + "ws": "^8.20.0" }, "engines": { "node": ">=18" } }, + "node_modules/puppeteer-extra": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", + "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "@types/puppeteer": "*", + "puppeteer": "*", + "puppeteer-core": "*" + }, + "peerDependenciesMeta": { + "@types/puppeteer": { + "optional": true + }, + "puppeteer": { + "optional": true + }, + "puppeteer-core": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -2103,6 +2541,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -2121,6 +2575,42 @@ "node": ">=10" } }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2144,9 +2634,9 @@ } }, "node_modules/socks": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", - "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "license": "MIT", "dependencies": { "ip-address": "^10.1.1", diff --git a/package.json b/package.json index d08d25f..33d1a3c 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "commander": "^12.1.0", "conf": "^13.0.0", "ora": "^8.0.1", - "puppeteer": "^24.42.0", + "puppeteer": "^24.43.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", "tough-cookie": "^4.1.4" }, "devDependencies": { diff --git a/src/client.ts b/src/client.ts index 2522d0e..3c979f7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -24,7 +24,14 @@ import { getSessionCookieName, setSessionCookieName } from './config.js'; -import puppeteer from 'puppeteer'; + +import vanillaPuppeteer from 'puppeteer'; +import { addExtra } from 'puppeteer-extra'; +import StealthPlugin from 'puppeteer-extra-plugin-stealth'; + +const puppeteer = addExtra(vanillaPuppeteer as any); + +puppeteer.use(StealthPlugin()); // Read version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); From e82e25e2eb80b8129c5938ca4e8007bb5905918e Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Mon, 11 May 2026 12:12:42 +0200 Subject: [PATCH 32/35] Updated the tests --- test/e2e.sh | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/e2e.sh b/test/e2e.sh index b3f0560..16c9b71 100755 --- a/test/e2e.sh +++ b/test/e2e.sh @@ -19,6 +19,7 @@ TESTS_FAILED=0 CLEANUP_FILES=() CLEANUP_REMOTE_FILES=() EXE="$(pwd)/dist/cli.js" +GIT="git" if test -f $EXE; then if ! [[ -x "$EXE" ]] @@ -36,6 +37,7 @@ PROJECT_NAME="${OLCLI_E2E_PROJECT_NAME:-olcli test}" # Temporary directory for test files TEST_DIR=$(mktemp -d) +TEST_GIT_DIR=$(mktemp -d) trap cleanup EXIT ####################################### @@ -640,6 +642,39 @@ run_test "upload file with dashes and underscores" \ run_test "download file with dashes and underscores" \ "$EXE download 'test-file_123.txt' '$PROJECT_ID' -o '$TEST_DIR/dl_special.txt'" +####################################### +# Test: Git Integration +####################################### + +log_section "Git commands testing (local only)" + + +# Cloning +run_test "Cloning the test dir" \ + "$GIT clone overleaf::https://overleaf.com/project/$PROJECT_ID $TEST_GIT_DIR" +cd $TEST_GIT_DIR + + +# Create test file with unique content +TEST_FILE="$TEST_GIT_DIR/${TEST_ID}git.txt" +echo "$TEST_CONTENT" > "$TEST_FILE" +CLEANUP_REMOTE_FILES+=("${TEST_ID}git.txt") + +# Adding a first file +run_test "Cloning the test dir" \ + "$GIT add $TEST_FILE ; $GIT commit -m 'Added file1' ; git push" + + +# Create file in subfolder test +mkdir $TEST_GIT_DIR/subfolder +TEST_FILE2="$TEST_GIT_DIR/subfolder/${TEST_ID}_2.txt" +echo "Second test file - $TEST_CONTENT" > "$TEST_FILE2" +CLEANUP_REMOTE_FILES+=("${TEST_ID}_git2.txt") + +# Adding a second file +run_test "Cloning the test dir" \ + "$GIT add $TEST_FILE2 ; $GIT commit -m 'Added file2 in subfolder' ; git push" + ####################################### # Cleanup Note ####################################### From ac5bfa8fcadec74f0b628db3181a0e4fb1256cc2 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Mon, 11 May 2026 14:46:53 +0200 Subject: [PATCH 33/35] Removed all the features previously added as the git integration handles it all much better. --- src/cli.ts | 465 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 303 insertions(+), 162 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 9d870c1..57bebe0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,13 @@ import { join, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import { OverleafClient } from './client.js'; import { spawn } from 'node:child_process'; +import { + loadIgnore, + shouldIgnore, + buildTexSiblingSet, + DEFAULT_IGNORE_PATTERNS, + type IgnoreContext, +} from './ignore.js'; // Read version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -35,6 +42,13 @@ 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)'); + /** * Helper to watch the files and auto compile them */ @@ -90,25 +104,16 @@ async function getClient(baseUrlOpt?: string): Promise { const baseUrl = baseUrlOpt || getBaseUrl(); const cookie = getSession(baseUrl); if (!cookie) { - console.error('No session cookie found.'); + 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 cookieName = getSessionCookieName(); + const cookieName = (program.opts().cookieName as string | undefined) || getSessionCookieName(); return OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); } - -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)'); - - /** * Resolve project from argument or .olcli.json in current directory */ @@ -211,7 +216,6 @@ program 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(); @@ -520,47 +524,49 @@ program } }); -// NOTE: delete and rename commands are disabled - they require entity IDs -// which are not exposed via the current Overleaf API without Socket.IO. -// Use the Overleaf web UI for these operations. -// -// program -// .command('delete [project]') -// .alias('rm') -// .description('Delete a file from a project') -// .option('--cookie ', 'Session cookie override') -// .action(async (file, project, options) => { -// const spinner = ora('Deleting file...').start(); -// try { -// const client = await getClient(options.cookie); -// const proj = await resolveProject(client, project); -// await client.deleteByPath(proj.id, file); -// spinner.succeed(`Deleted: ${file}`); -// setLastProject(proj.id); -// } catch (error: any) { -// spinner.fail(`Failed: ${error.message}`); -// process.exit(1); -// } -// }); -// -// program -// .command('rename [project]') -// .alias('mv') -// .description('Rename a file in a project') -// .option('--cookie ', 'Session cookie override') -// .action(async (oldname, newname, project, options) => { -// const spinner = ora('Renaming file...').start(); -// try { -// const client = await getClient(options.cookie); -// const proj = await resolveProject(client, project); -// await client.renameByPath(proj.id, oldname, newname); -// spinner.succeed(`Renamed: ${oldname} → ${newname}`); -// setLastProject(proj.id); -// } catch (error: any) { -// spinner.fail(`Failed: ${error.message}`); -// process.exit(1); -// } -// }); +// ───────────────────────────────────────────────────────────────────────────── +// DELETE / RENAME COMMANDS +// ───────────────────────────────────────────────────────────────────────────── +// Use deleteByPath / renameByPath which resolve a path to an entity id via +// /project//entities, then call the documented delete/rename endpoints. + +program +.command('delete [project]') +.alias('rm') +.description('Delete a file or folder from a project') +.option('--cookie ', 'Session cookie override') +.action(async (file, project, options) => { + const spinner = ora('Deleting file...').start(); + try { + const client = await getClient(options.cookie); + const proj = await resolveProject(client, project); + await client.deleteByPath(proj.id, file); + spinner.succeed(`Deleted: ${file}`); + setLastProject(proj.id); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); + +program +.command('rename [project]') +.alias('mv') +.description('Rename a file or folder in a project') +.option('--cookie ', 'Session cookie override') +.action(async (oldname, newname, project, options) => { + const spinner = ora('Renaming file...').start(); + try { + const client = await getClient(options.cookie); + const proj = await resolveProject(client, project); + await client.renameByPath(proj.id, oldname, newname); + spinner.succeed(`Renamed: ${oldname} → ${newname}`); + setLastProject(proj.id); + } catch (error: any) { + spinner.fail(`Failed: ${error.message}`); + process.exit(1); + } +}); // ───────────────────────────────────────────────────────────────────────────── // COMPILE COMMAND @@ -692,11 +698,16 @@ program } } - // Save project metadata + // Save project metadata (with manifest of remote files for sync deletion tracking) + const remoteManifest: string[] = []; + for (const e of entries) { + if (!e.isDirectory) remoteManifest.push(e.entryName); + } writeFileSync(join(targetDir, '.olcli.json'), JSON.stringify({ projectId, projectName, - lastPull: new Date().toISOString() + lastPull: new Date().toISOString(), + remoteManifest }, null, 2)); if (skippedCount > 0) { @@ -720,14 +731,16 @@ program } }); - 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/deleted without changing anything') -.option('--probe-folder', 'Probe for correct folder ID') +.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('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)') +.option('--no-ignore', 'Disable all ignore filtering (escape hatch — uploads everything)') +.option('--show-ignored', 'Print files skipped by ignore rules') .option('--cookie ', 'Session cookie override') .action(async (dir, options) => { const targetDir = dir || '.'; @@ -748,6 +761,7 @@ program } if (options.project) { + // Override with command line option projectId = undefined; projectName = options.project; } @@ -778,110 +792,100 @@ program spinner.text = 'Scanning files...'; + // Build ignore context (defaults + .olignore + .olignore.local) + const ignoreCtx = loadIgnore(targetDir, { + noDefaults: options.defaultIgnore === false, + disableAll: options.ignore === false, + }); + // Get list of files to upload const { readdirSync, statSync } = await import('node:fs'); const filesToUpload: { path: string; relativePath: string }[] = []; - const allLocalPaths = new Set(); + const filesIgnored: string[] = []; 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; - - if (!entry.isDirectory() && entry.name === 'output.pdf') continue; - - 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 }); + // Pre-compute sibling .tex set for the PDF special rule. + const texSiblings = buildTexSiblingSet( + entries.filter((e) => !e.isDirectory()).map((e) => e.name), + ); + for (const entry of entries) { + // Skip hidden files and .olcli.json (always — predates ignore subsystem) + if (entry.name.startsWith('.')) continue; + + const fullPath = join(currentDir, entry.name); + const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + // Test directory ignore (gitignore semantics: trailing slash matches dir) + if (shouldIgnore(`${relativePath}/`, ignoreCtx)) { + filesIgnored.push(`${relativePath}/`); + continue; + } + scanDir(fullPath, relativePath); } else { - const stats = statSync(fullPath); - if (stats.mtime > lastPull) { + if (shouldIgnore(relativePath, ignoreCtx, texSiblings)) { + filesIgnored.push(relativePath); + continue; + } + // Check if file is newer than last pull (unless --all) + if (options.all || !lastPull) { filesToUpload.push({ path: fullPath, relativePath }); + } else { + const stats = statSync(fullPath); + if (stats.mtime > lastPull) { + filesToUpload.push({ path: fullPath, relativePath }); + } } } } - } } scanDir(targetDir); - // ========================================== - // THE DELETION LOGIC - // ========================================== - const filesToDelete: { id: string; type: 'doc' | 'file' | 'folder' ; path: string }[] = []; - - const projectInfo = await client.getProjectInfo(projectId); - if (projectInfo && projectInfo.rootFolder && projectInfo.rootFolder[0]) { - - // 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); - } + if (options.showIgnored && filesIgnored.length > 0) { + spinner.stop(); + console.log(chalk.bold(chalk.dim(`Ignored ${filesIgnored.length} file(s)/dir(s):`))); + for (const p of filesIgnored) { + console.log(chalk.dim(` ${p}`)); } - - flattenRemoteTree(projectInfo.rootFolder[0]); + spinner.start('Scanning files...'); } - // Early out - if (filesToUpload.length === 0 && filesToDelete.length === 0){ - spinner.succeed('No local changes to upload.'); + if (filesToUpload.length === 0) { + spinner.info('No files to upload'); return; } - // 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)}`)); - - console.log(chalk.bold(`Would delete ${filesToDelete.length} remote file(s):`)); - filesToDelete.forEach(f => console.log(` ${chalk.red('- ' + f.path)}`)); + console.log(chalk.bold(`Would upload ${filesToUpload.length} file(s) to "${projectName}":`)); + for (const f of filesToUpload) { + console.log(` ${chalk.cyan(f.relativePath)}`); + } 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++; + // 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); } } - // ========================================== - // Fetch folder tree once so uploads go into correct subfolders spinner.text = 'Resolving folder structure...'; @@ -895,6 +899,8 @@ program spinner.text = `Uploading ${filesToUpload.length} file(s)...`; let uploaded = 0; + let failed = 0; + let folderNotFoundCount = 0; for (const file of filesToUpload) { try { @@ -919,18 +925,12 @@ program } if (failed > 0) { - spinner.warn(`Uploaded ${uploaded} file(s), deleted ${deleted} and ${failed} failed`); + 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')); } } 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)`); - } + spinner.succeed(`Uploaded ${uploaded} file(s) to "${projectName}"`); } setLastProject(projectId!); @@ -942,9 +942,14 @@ program program .command('sync [dir]') -.description('Pull then push (bidirectional sync)') +.description('Pull then push (bidirectional sync, propagates local deletions)') .option('--project ', 'Project name or ID') .option('--verbose', 'Show detailed file operations') +.option('--no-delete', 'Do not propagate local deletions to the remote (safer)') +.option('--dry-run', 'Show what would change without applying') +.option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)') +.option('--no-ignore', 'Disable all ignore filtering (escape hatch — uploads everything)') +.option('--show-ignored', 'Print files skipped by ignore rules') .option('--cookie ', 'Session cookie override') .action(async (dir, options) => { const targetDir = dir || '.'; @@ -1001,27 +1006,45 @@ program mkdirSync(targetDir, { recursive: true }); } + // Build ignore context (defaults + .olignore + .olignore.local) + const ignoreCtx = loadIgnore(targetDir, { + noDefaults: options.defaultIgnore === false, + disableAll: options.ignore === false, + }); + // Track local modifications const localFiles = new Map(); + const filesIgnored: string[] = []; 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) - }); + const texSiblings = buildTexSiblingSet( + entries.filter((e) => !e.isDirectory()).map((e) => e.name), + ); + 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()) { + if (shouldIgnore(`${relativePath}/`, ignoreCtx)) { + filesIgnored.push(`${relativePath}/`); + continue; + } + scanLocalFiles(fullPath, relativePath); + } else { + if (shouldIgnore(relativePath, ignoreCtx, texSiblings)) { + filesIgnored.push(relativePath); + continue; + } + const stats = statSync(fullPath); + localFiles.set(relativePath, { + mtime: stats.mtime, + content: readFileSync(fullPath) + }); + } } - } } // Read local files before overwriting @@ -1029,6 +1052,15 @@ program scanLocalFiles(targetDir); } + if (options.showIgnored && filesIgnored.length > 0) { + spinner.stop(); + console.log(chalk.bold(chalk.dim(`Ignored ${filesIgnored.length} local file(s)/dir(s):`))); + for (const p of filesIgnored) { + console.log(chalk.dim(` ${p}`)); + } + spinner.start(); + } + // Extract remote files const remoteFiles = new Map(); for (const entry of zip.getEntries()) { @@ -1039,15 +1071,57 @@ program // Merge: local changes take precedence for files modified after last pull let lastPull: Date | undefined; + let previousManifest: string[] = []; if (existsSync(metaPath)) { const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); lastPull = meta.lastPull ? new Date(meta.lastPull) : undefined; + if (Array.isArray(meta.remoteManifest)) { + previousManifest = meta.remoteManifest as string[]; + } } const filesToUpload: { path: string; content: Buffer }[] = []; const filesUpdatedLocally: string[] = []; const filesKeptLocal: string[] = []; const filesNewLocal: string[] = []; + const filesDeletedRemote: string[] = []; + const filesDeleteSkipped: { path: string; reason: string }[] = []; + + // Detect locally-deleted files: present in previous manifest, missing locally, + // still present on the remote. Propagate the deletion to the remote BEFORE + // we write remote contents back over the working tree (otherwise the file + // would be silently restored — the bug reported in #7). + // Conflict policy: if the project has no previous manifest yet (first sync), + // we cannot distinguish "never existed locally" from "deleted locally", so + // skip deletion propagation on the very first sync. + if (options.delete !== false && previousManifest.length > 0 && existsSync(metaPath)) { + const locallyDeleted: string[] = []; + for (const path of previousManifest) { + if (path === 'output.pdf' || path.endsWith('/output.pdf')) continue; + if (!localFiles.has(path) && remoteFiles.has(path)) { + locallyDeleted.push(path); + } + } + + if (locallyDeleted.length > 0) { + spinner.text = `Propagating ${locallyDeleted.length} local deletion(s) to remote...`; + for (const path of locallyDeleted) { + if (options.dryRun) { + filesDeletedRemote.push(path); + remoteFiles.delete(path); + continue; + } + try { + await client.deleteByPath(projectId, path); + filesDeletedRemote.push(path); + // Drop from remoteFiles so we don't re-extract it below + remoteFiles.delete(path); + } catch (err: any) { + filesDeleteSkipped.push({ path, reason: err.message || String(err) }); + } + } + } + } spinner.text = 'Comparing files...'; @@ -1086,28 +1160,58 @@ program } // Upload local changes - if (filesToUpload.length > 0) { + if (filesToUpload.length > 0 && !options.dryRun) { spinner.text = `Uploading ${filesToUpload.length} local change(s)...`; for (const file of filesToUpload) { await client.uploadFile(projectId, null, file.path, file.content); } } + // Refresh manifest of remote files post-sync (deletions out, new uploads in) + const newManifest = new Set(remoteFiles.keys()); + for (const f of filesToUpload) newManifest.add(f.path); + for (const p of filesDeletedRemote) newManifest.delete(p); + // Update metadata - writeFileSync(metaPath, JSON.stringify({ - projectId, - projectName, - lastPull: new Date().toISOString(), - lastSync: new Date().toISOString() - }, null, 2)); + if (!options.dryRun) { + writeFileSync(metaPath, JSON.stringify({ + projectId, + projectName, + lastPull: new Date().toISOString(), + lastSync: new Date().toISOString(), + remoteManifest: Array.from(newManifest).sort() + }, null, 2)); + } - spinner.succeed(`Synced "${projectName}"`); + if (options.dryRun) { + spinner.succeed(`Dry-run sync "${projectName}" (no changes applied)`); + } else { + spinner.succeed(`Synced "${projectName}"`); + } // Summary console.log(chalk.dim(` ↓ ${filesUpdatedLocally.length} pulled from remote`)); console.log(chalk.dim(` ↑ ${filesToUpload.length} pushed to remote`)); + if (filesDeletedRemote.length > 0) { + console.log(chalk.dim(` ✖ ${filesDeletedRemote.length} deleted on remote`)); + } + if (filesDeleteSkipped.length > 0) { + console.log(chalk.yellow(` ⚠ ${filesDeleteSkipped.length} deletion(s) failed (kept remote)`)); + } if (options.verbose) { + if (filesDeletedRemote.length > 0) { + console.log(chalk.red('\n Deleted on remote (matched local deletion):')); + for (const f of filesDeletedRemote) { + console.log(chalk.dim(` ${f}`)); + } + } + if (filesDeleteSkipped.length > 0) { + console.log(chalk.yellow('\n Deletion skipped (will retry on next sync):')); + for (const { path, reason } of filesDeleteSkipped) { + console.log(chalk.dim(` ${path} — ${reason}`)); + } + } if (filesKeptLocal.length > 0) { console.log(chalk.yellow('\n Local changes pushed (local was newer):')); for (const f of filesKeptLocal) { @@ -1167,6 +1271,43 @@ configCmd console.log(getSessionCookieName()); }); +program +.command('ignored [dir]') +.description('Show ignore patterns currently in effect for a project directory') +.option('--no-default-ignore', 'Exclude built-in defaults from the listing') +.option('--no-ignore', 'Show what --no-ignore would do (lists nothing)') +.action((dir, options) => { + const targetDir = dir || '.'; + const ctx = loadIgnore(targetDir, { + noDefaults: options.defaultIgnore === false, + disableAll: options.ignore === false, + }); + if (!ctx.enabled) { + console.log(chalk.yellow('Ignore filtering is disabled (--no-ignore).')); + console.log(chalk.dim('Every local file would be uploaded.')); + return; + } + if (ctx.sources.length === 0) { + console.log(chalk.yellow('No ignore patterns active.')); + console.log(chalk.dim('Built-in defaults are disabled and no .olignore file was found.')); + return; + } + console.log(chalk.bold(`Ignore patterns in effect for ${targetDir}:`)); + console.log(chalk.dim('(later sources override earlier ones; ! prefix negates)')); + for (const src of ctx.sources) { + console.log(); + console.log(chalk.cyan(`── ${src.label} (${src.patterns.length}) ──`)); + for (const p of src.patterns) { + console.log(` ${p}`); + } + } + console.log(); + console.log(chalk.dim(`Total: ${ctx.patterns.length} pattern(s)`)); + if (ctx.defaultsEnabled) { + console.log(chalk.dim('Note: *.pdf is also ignored when a same-named *.tex/.ltx exists in the same folder.')); + } +}); + program .command('check') .description('Show credential sources and config path') From 7eb94b526a699b8f90d07a4f716c57f9c9941dd6 Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Mon, 11 May 2026 14:54:40 +0200 Subject: [PATCH 34/35] Updated packages --- package-lock.json | 28 +++++++++++++++------------- package.json | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index b97c5f5..b3a0061 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aloth/olcli", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aloth/olcli", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", @@ -27,7 +27,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.7", - "@types/node": "^22.19.17", + "@types/node": "^22.19.18", "@types/tough-cookie": "^4.0.5", "tsx": "^4.7.0", "typescript": "^5.4.0" @@ -554,9 +554,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "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==", + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1727,6 +1727,15 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -1797,13 +1806,6 @@ "license": "MIT", "engines": { "node": ">=8" - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" } }, "node_modules/is-interactive": { diff --git a/package.json b/package.json index 1e7ed91..c36a777 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.7", - "@types/node": "^22.19.17", + "@types/node": "^22.19.18", "@types/tough-cookie": "^4.0.5", "tsx": "^4.7.0", "typescript": "^5.4.0" From cacab35196f0578cc6a5d3ac66fc612704297a5e Mon Sep 17 00:00:00 2001 From: Valentin BARBAZA Date: Mon, 11 May 2026 17:41:09 +0200 Subject: [PATCH 35/35] an argument was missing to run, among others, inkscape to convert svgs --- src/cli.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli.ts b/src/cli.ts index 57bebe0..8597f7a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -65,6 +65,7 @@ async function runWatch(mainFile: string, useDocker: boolean) { '-auxdir=.aux', // Put all temp/aux files in a hidden .aux folder! '-outdir=.build', // Keep the final PDF in the root folder! '-pvc', // MAGIC FLAG: Preview Continuously (Watch mode!) + '-shell-escape', mainFile ];