|
| 1 | +# Architecture |
| 2 | + |
| 3 | +Orientation for anyone about to open `src/client.ts` and wonder why it looks |
| 4 | +like that. For setup and pull request mechanics, see |
| 5 | +[CONTRIBUTING.md](../CONTRIBUTING.md). |
| 6 | + |
| 7 | +## The premise: there is no Overleaf API |
| 8 | + |
| 9 | +Overleaf publishes no REST API for the free tier. olcli is not an API client — |
| 10 | +it **authenticates as a logged-in browser session and calls the same endpoints |
| 11 | +the web editor's own JavaScript calls.** |
| 12 | + |
| 13 | +Almost every design decision follows from that one fact: |
| 14 | + |
| 15 | +- Authentication is a session cookie, because that is what a browser holds. |
| 16 | +- Reading project data means parsing Overleaf's HTML, because that is where a |
| 17 | + server-rendered page puts it. |
| 18 | +- Write endpoints are whatever the web editor posts to. |
| 19 | +- Nothing is versioned or documented, so **anything here can break when |
| 20 | + Overleaf ships a redesign.** The layered fallbacks scattered through |
| 21 | + `client.ts` are not defensive habit; each one is a redesign that already |
| 22 | + happened. |
| 23 | + |
| 24 | +Requests carry a `User-Agent` of `olcli/<version>`. olcli does not pretend to |
| 25 | +be Chrome — the session is a real one belonging to the user running it. |
| 26 | + |
| 27 | +## Authentication |
| 28 | + |
| 29 | +Two ways in, both ending at the same place: |
| 30 | + |
| 31 | +| Entry point | What it does | |
| 32 | +|---|---| |
| 33 | +| `OverleafClient.fromSessionCookie()` | Takes a cookie the user copied from their browser | |
| 34 | +| `OverleafClient.fromPasswordLogin()` | Submits the login form (self-hosted instances without reCAPTCHA) | |
| 35 | + |
| 36 | +Both then need a second credential. Overleaf requires a **CSRF token** on every |
| 37 | +state-changing request, which is what stops another site using your cookie |
| 38 | +against it. The token is not secret — the web editor needs it in the page to |
| 39 | +make its own requests — so olcli fetches a page with the cookie and reads the |
| 40 | +token out of the HTML (`extractCsrfToken`). From then on every request carries |
| 41 | +both: |
| 42 | + |
| 43 | +``` |
| 44 | +Cookie: overleaf_session2=... |
| 45 | +X-Csrf-Token: ... |
| 46 | +``` |
| 47 | + |
| 48 | +`applySetCookieHeaders()` folds any `Set-Cookie` from each response back into |
| 49 | +the in-memory jar, so a session that rotates mid-run keeps working — the same |
| 50 | +bookkeeping a browser does. |
| 51 | + |
| 52 | +Credential *storage* lives in `src/config.ts`, deliberately apart from the |
| 53 | +client: env var, then `.olauth` in the current directory, then the global |
| 54 | +config file. The client itself never reads any of them. |
| 55 | + |
| 56 | +## Reading: HTML scraping, then Socket.IO |
| 57 | + |
| 58 | +Project data is server-rendered into `<meta name="ol-*">` tags, so |
| 59 | +`listProjects()` and `getProjectInfo()` parse the page with `cheerio`. Each has |
| 60 | +several fallbacks tried in order, because the tag names and shapes have changed |
| 61 | +more than once. |
| 62 | + |
| 63 | +The file tree is the awkward one. It used to live in `ol-project`; it no longer |
| 64 | +does. `getProjectFromSocket()` recovers it by **speaking Socket.IO 0.9 by |
| 65 | +hand** — handshake for a session id, `xhr-polling` for packets, decode the |
| 66 | +frames, answer the `2::` heartbeats, and pull the tree out of the |
| 67 | +`joinProjectResponse` event. |
| 68 | + |
| 69 | +This is the most fragile surface in the repository, and the least |
| 70 | +self-evident. It is also unavoidable: that payload is where the tree is now. |
| 71 | +Results are cached per project in `folderTreeCache` so a multi-file upload |
| 72 | +does not repeat the whole dance for every file. |
| 73 | + |
| 74 | +## Writing: upload replaces, it does not edit |
| 75 | + |
| 76 | +| Operation | Request | |
| 77 | +|---|---| |
| 78 | +| Read all files | `GET /project/<id>/download/zip` | |
| 79 | +| Write a file | `POST /project/<id>/upload?folder_id=<id>` (multipart, field `qqfile`) | |
| 80 | +| Delete | `DELETE /project/<id>/{doc,file,folder}/<entityId>` | |
| 81 | +| Rename an entity | `POST /project/<id>/<type>/<entityId>/rename` | |
| 82 | +| Rename the project | `POST /project/<id>/rename` | |
| 83 | +| Compile | `POST /project/<id>/compile` | |
| 84 | + |
| 85 | +**The most important thing to understand about writes:** typing in the Overleaf |
| 86 | +editor sends character-level operations over the collaboration socket — an |
| 87 | +operational transform stream that merges concurrent edits. `uploadFile()` does |
| 88 | +not do that. It posts a whole file to the upload endpoint, exactly as if you |
| 89 | +had dragged a same-named file into the web UI. |
| 90 | + |
| 91 | +So a `push` **overwrites**. It does not merge, and it cannot: there is no |
| 92 | +three-way merge to perform, only a file replacing a file. That is why |
| 93 | +`olcli diff` exists — previewing what a push will overwrite is the only |
| 94 | +protection against a collaborator's edit being replaced — and why `diff` |
| 95 | +fetches the remote fresh rather than comparing against the last pull. |
| 96 | + |
| 97 | +Reading the whole project is one request, not one per file: `downloadProject()` |
| 98 | +returns the entire project as a zip. `pull`, `sync` and `diff` all use it. |
| 99 | + |
| 100 | +## The transport |
| 101 | + |
| 102 | +Everything goes through one private method, `httpRequest()`, built on |
| 103 | +`node:http`/`node:https` rather than `fetch`. That is not preference: `fetch` |
| 104 | +validates response headers as Latin-1 and throws on a `Content-Disposition` |
| 105 | +carrying a non-ASCII project name, which made downloads fail for anyone with an |
| 106 | +accented title ([#2](https://github.com/aloth/olcli/issues/2)). It also handles |
| 107 | +redirects, timeouts, and serialising `FormData` into a multipart body. |
| 108 | + |
| 109 | +`--verbose` makes it log every request and response to stderr, which is the |
| 110 | +first thing to reach for when Overleaf changes something. |
| 111 | + |
| 112 | +## Module map |
| 113 | + |
| 114 | +Which files need an Overleaf account to exercise, and which do not. This is the |
| 115 | +main thing to know before adding a feature, because it decides where the logic |
| 116 | +should go. |
| 117 | + |
| 118 | +**Pure — data in, data out. No network, no filesystem, unit-tested:** |
| 119 | + |
| 120 | +| Module | Responsibility | |
| 121 | +|---|---| |
| 122 | +| `diff.ts` | Compare two file trees; render unified diffs | |
| 123 | +| `ignore.ts` | The three ignore layers and the `.pdf`-next-to-`.tex` rule | |
| 124 | +| `paths.ts` | Remote path normalisation; zip-slip containment | |
| 125 | +| `rename-plan.ts` | Plan bulk project renames before applying any | |
| 126 | +| `prompt.ts` | Keystroke handling for the password prompt | |
| 127 | +| `scan.ts` | Walk a local directory, applying ignore rules | |
| 128 | + |
| 129 | +**Talks to Overleaf:** |
| 130 | + |
| 131 | +| Module | Responsibility | |
| 132 | +|---|---| |
| 133 | +| `client.ts` | Every request. The browser-session model lives here | |
| 134 | +| `config.ts` | Credential resolution and storage | |
| 135 | + |
| 136 | +**Entry points, all thin over the two above:** |
| 137 | + |
| 138 | +| Module | Binary | |
| 139 | +|---|---| |
| 140 | +| `cli.ts` | `olcli` — argument parsing and terminal output | |
| 141 | +| `mcp.ts` | `olcli-mcp` — the same operations as MCP tools | |
| 142 | +| `remote-helper.ts` | `git-remote-overleaf` — `gitremote-helpers(7)` protocol | |
| 143 | +| `index.ts` | The programmatic API re-exported from the package root | |
| 144 | + |
| 145 | +New logic belongs in the pure column wherever it can go. That is why `scan.ts` |
| 146 | +exists at all: `push` and `sync` each carried their own copy of the same walk |
| 147 | +loop and had already drifted apart, and `diff` would have made a third. The |
| 148 | +same reasoning produced `rename-plan.ts` and `diff.ts`. |
| 149 | + |
| 150 | +`client.ts` request *construction* can also be tested without an account, by |
| 151 | +pointing the client at a local HTTP server that captures the outgoing request — |
| 152 | +see `test/client.test.ts`. |
| 153 | + |
| 154 | +## When Overleaf breaks it |
| 155 | + |
| 156 | +The usual failure is a redesign moving data somewhere else. Reliable order: |
| 157 | + |
| 158 | +1. `olcli --verbose <command>` — see the actual request and response. |
| 159 | +2. If a page parse returns nothing, fetch the page in a browser with devtools |
| 160 | + and look for the `ol-*` meta tag. Add a fallback; keep the existing ones, |
| 161 | + since self-hosted instances run older versions. |
| 162 | +3. If the file tree is what broke, suspect `getProjectFromSocket()` first. |
| 163 | +4. `olcli check` reports which credential source is in play, without printing |
| 164 | + any secret. |
0 commit comments