@aloth/olcli 0.15.3 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +140 -437
  2. package/dist/browser-auth.d.ts +144 -0
  3. package/dist/browser-auth.d.ts.map +1 -0
  4. package/dist/browser-auth.js +442 -0
  5. package/dist/browser-auth.js.map +1 -0
  6. package/dist/cli.js +429 -174
  7. package/dist/cli.js.map +1 -1
  8. package/dist/client.d.ts +20 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +103 -37
  11. package/dist/client.js.map +1 -1
  12. package/dist/config.d.ts +25 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +40 -7
  15. package/dist/config.js.map +1 -1
  16. package/dist/errors.d.ts +50 -0
  17. package/dist/errors.d.ts.map +1 -0
  18. package/dist/errors.js +115 -0
  19. package/dist/errors.js.map +1 -0
  20. package/dist/fast-export.d.ts +2 -0
  21. package/dist/fast-export.d.ts.map +1 -1
  22. package/dist/fast-export.js +5 -1
  23. package/dist/fast-export.js.map +1 -1
  24. package/dist/mcp-server.d.ts.map +1 -1
  25. package/dist/mcp-server.js +4 -1
  26. package/dist/mcp-server.js.map +1 -1
  27. package/dist/mcp.js +1 -1
  28. package/dist/mcp.js.map +1 -1
  29. package/dist/paths.d.ts +18 -0
  30. package/dist/paths.d.ts.map +1 -1
  31. package/dist/paths.js +45 -0
  32. package/dist/paths.js.map +1 -1
  33. package/dist/project-match.d.ts +37 -0
  34. package/dist/project-match.d.ts.map +1 -0
  35. package/dist/project-match.js +97 -0
  36. package/dist/project-match.js.map +1 -0
  37. package/dist/proxy.d.ts +52 -0
  38. package/dist/proxy.d.ts.map +1 -0
  39. package/dist/proxy.js +189 -0
  40. package/dist/proxy.js.map +1 -0
  41. package/dist/remote-helper.d.ts +3 -2
  42. package/dist/remote-helper.d.ts.map +1 -1
  43. package/dist/remote-helper.js +130 -37
  44. package/dist/remote-helper.js.map +1 -1
  45. package/dist/term.d.ts +25 -0
  46. package/dist/term.d.ts.map +1 -0
  47. package/dist/term.js +42 -0
  48. package/dist/term.js.map +1 -0
  49. package/package.json +9 -4
package/dist/client.js CHANGED
@@ -18,16 +18,28 @@ import * as cheerio from 'cheerio';
18
18
  import { readFileSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
- import * as https from 'node:https';
22
- import * as http from 'node:http';
21
+ import { routeRequest } from './proxy.js';
23
22
  import { RejectConflictError, buildUserDirectory, planRejection, renderRejectionDiff, describeAuthor, parseHistoryOtChanges, parseShareJsChanges } from './tracked-changes.js';
24
23
  import { planEntityMove } from './move-plan.js';
24
+ import { matchProject } from './project-match.js';
25
+ import { HttpStatusError, SessionExpiredError, httpError, isAuthStatus } from './errors.js';
25
26
  // Read version from package.json
26
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
28
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
28
29
  const USER_AGENT = `olcli/${pkg.version}`;
29
30
  const DEFAULT_BASE_URL = 'https://www.overleaf.com';
30
31
  const RESOURCE_PATH_FAILURE_HINT = ' (Perhaps the resource file you specified does not exist in the project?)';
32
+ /**
33
+ * True when a redirect from `fromUrl` points at Overleaf's login page. A
34
+ * request to /login itself is the password flow and is never treated as one.
35
+ */
36
+ export function isLoginRedirect(fromUrl, location) {
37
+ const from = new URL(fromUrl);
38
+ if (from.pathname === '/login')
39
+ return false;
40
+ const target = new URL(location, fromUrl);
41
+ return target.pathname === '/login' || target.pathname.endsWith('/login');
42
+ }
31
43
  export const DEFAULT_COMMENT_CONCURRENCY = 4;
32
44
  export const MAX_COMMENT_CONCURRENCY = 8;
33
45
  /**
@@ -51,6 +63,8 @@ export class OverleafClient {
51
63
  csrf;
52
64
  baseUrl;
53
65
  verbose = false;
66
+ /** Email of the signed-in account, when the bootstrap page named it. */
67
+ accountEmail;
54
68
  timeoutMs = 10000;
55
69
  // Cache per-project folder trees so repeated uploads in sync/upload calls
56
70
  // don't re-fetch the tree via Socket.IO on every file.
@@ -163,21 +177,23 @@ export class OverleafClient {
163
177
  expect: 'text'
164
178
  });
165
179
  if (!response.ok) {
166
- throw new Error(`Failed to fetch projects page: ${response.status}`);
180
+ throw httpError('Failed to fetch projects page', response.status);
167
181
  }
168
182
  bootstrapClient.applySetCookieHeaders(response.headers['set-cookie']);
169
183
  const html = response.body;
170
184
  const $ = cheerio.load(html);
171
185
  if (OverleafClient.isLoginPage($)) {
172
- throw new Error('Authentication required. Session may have expired.');
186
+ throw new SessionExpiredError('Overleaf answered with its login page');
173
187
  }
174
188
  const csrf = OverleafClient.extractCsrfToken($);
175
189
  if (!csrf) {
176
- throw new Error('Could not find CSRF token. Session may have expired.');
190
+ throw new SessionExpiredError('No CSRF token on the project page');
177
191
  }
178
192
  // Update cookies if the bootstrap request added anything
179
193
  const updatedCookies = bootstrapClient.cookies;
180
- return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl });
194
+ const client = new OverleafClient({ cookies: updatedCookies, csrf, baseUrl });
195
+ client.accountEmail = OverleafClient.extractAccountEmail($);
196
+ return client;
181
197
  }
182
198
  /**
183
199
  * Create client by submitting Overleaf's email/password login form.
@@ -189,7 +205,7 @@ export class OverleafClient {
189
205
  expect: 'text'
190
206
  });
191
207
  if (!loginPage.ok) {
192
- throw new Error(`Failed to fetch login page: ${loginPage.status}`);
208
+ throw new HttpStatusError('Failed to fetch login page', loginPage.status);
193
209
  }
194
210
  bootstrapClient.applySetCookieHeaders(loginPage.headers['set-cookie']);
195
211
  const loginHtml = loginPage.body;
@@ -223,7 +239,7 @@ export class OverleafClient {
223
239
  if (loginFailure) {
224
240
  throw new Error('Password login failed. Email or password may be incorrect.');
225
241
  }
226
- throw new Error(`Password login failed: ${loginResponse.status}`);
242
+ throw new HttpStatusError('Password login failed', loginResponse.status);
227
243
  }
228
244
  const projectUrl = new URL(loginResponse.headers.location || '/project', baseUrl).toString();
229
245
  const projectPage = await bootstrapClient.httpRequest(projectUrl, {
@@ -234,7 +250,7 @@ export class OverleafClient {
234
250
  expect: 'text'
235
251
  });
236
252
  if (!projectPage.ok) {
237
- throw new Error(`Failed to fetch projects page after login: ${projectPage.status}`);
253
+ throw httpError('Failed to fetch projects page after login', projectPage.status);
238
254
  }
239
255
  bootstrapClient.applySetCookieHeaders(projectPage.headers['set-cookie']);
240
256
  const projectHtml = projectPage.body;
@@ -246,7 +262,9 @@ export class OverleafClient {
246
262
  if (!projectCsrf) {
247
263
  throw new Error('Could not find CSRF token after password login.');
248
264
  }
249
- return new OverleafClient({ cookies: bootstrapClient.cookies, csrf: projectCsrf, baseUrl });
265
+ const client = new OverleafClient({ cookies: bootstrapClient.cookies, csrf: projectCsrf, baseUrl });
266
+ client.accountEmail = OverleafClient.extractAccountEmail($project) ?? email;
267
+ return client;
250
268
  }
251
269
  /**
252
270
  * Pull the CSRF token out of a rendered Overleaf page.
@@ -282,6 +300,28 @@ export class OverleafClient {
282
300
  }
283
301
  return csrf;
284
302
  }
303
+ /**
304
+ * The signed-in account's email from a rendered Overleaf page, when the
305
+ * page carries one. `ol-usersEmail` is on the project dashboard; `ol-user`
306
+ * (JSON) is on the editor and on older releases. Display only.
307
+ */
308
+ static extractAccountEmail($) {
309
+ const direct = $('meta[name="ol-usersEmail"]').attr('content');
310
+ if (direct)
311
+ return direct;
312
+ const user = $('meta[name="ol-user"]').attr('content');
313
+ if (user) {
314
+ try {
315
+ const parsed = JSON.parse(user);
316
+ if (typeof parsed?.email === 'string' && parsed.email)
317
+ return parsed.email;
318
+ }
319
+ catch {
320
+ // Not JSON on this release; no email to show.
321
+ }
322
+ }
323
+ return undefined;
324
+ }
285
325
  static isLoginPage($) {
286
326
  return $('form[name="loginForm"]').length > 0 || $('input[name="password"]').length > 0;
287
327
  }
@@ -349,12 +389,25 @@ export class OverleafClient {
349
389
  }
350
390
  const doRequest = (reqUrl, redirectsLeft) => {
351
391
  return new Promise((resolve, reject) => {
352
- const parsedUrl = new URL(reqUrl);
353
- const transport = parsedUrl.protocol === 'https:' ? https : http;
354
392
  const headers = this.normalizeHeaders({ ...extraHeaders, ...options.headers });
355
- const req = transport.request(reqUrl, { method, headers }, (res) => {
393
+ // HTTPS_PROXY / HTTP_PROXY / NO_PROXY: every request, the socket
394
+ // polling included, goes through here. See src/proxy.ts.
395
+ const route = routeRequest(reqUrl, headers);
396
+ if (route.proxy)
397
+ this.logVerbose(`${method} ${reqUrl} via proxy ${route.proxy.host}`);
398
+ const req = route.transport.request(route.url, { ...route.options, method }, (res) => {
356
399
  const status = res.statusCode || 0;
357
400
  const resHeaders = res.headers;
401
+ // Overleaf answers an expired session on any page with a redirect to
402
+ // /login. Following it would hand an HTML login form to a caller
403
+ // that expected JSON or a zip, and the user would see a parse error.
404
+ if (status >= 300 && status < 400 && res.headers.location
405
+ && isLoginRedirect(reqUrl, res.headers.location)) {
406
+ this.logVerbose(`${method} ${reqUrl} -> ${status} redirect to login page`);
407
+ res.resume();
408
+ reject(new SessionExpiredError(`HTTP ${status} redirect to the login page from ${new URL(reqUrl).pathname}`, status));
409
+ return;
410
+ }
358
411
  if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) {
359
412
  this.logVerbose(`${method} ${reqUrl} -> ${status} redirect -> ${res.headers.location}`);
360
413
  const redirectUrl = new URL(res.headers.location, reqUrl).toString();
@@ -416,7 +469,7 @@ export class OverleafClient {
416
469
  expect: 'text'
417
470
  });
418
471
  if (!response.ok) {
419
- throw new Error(`Failed to fetch projects: ${response.status}`);
472
+ throw httpError('Failed to fetch projects', response.status);
420
473
  }
421
474
  this.applySetCookieHeaders(response.headers['set-cookie']);
422
475
  const html = response.body;
@@ -489,6 +542,19 @@ export class OverleafClient {
489
542
  const projects = await this.listProjects();
490
543
  return projects.find(p => p.name === name);
491
544
  }
545
+ /**
546
+ * Find a project by ID or by what a user typed for its name: exact name,
547
+ * then case-insensitive, then a unique prefix. Throws ProjectNotFoundError
548
+ * (with "Did you mean" suggestions) or AmbiguousProjectError.
549
+ * `getProject` keeps its exact-match contract for library callers.
550
+ */
551
+ async findProject(query) {
552
+ const projects = await this.listProjects();
553
+ const byId = projects.find(p => p.id === query);
554
+ if (byId)
555
+ return byId;
556
+ return matchProject(projects, query);
557
+ }
492
558
  /**
493
559
  * Get project by ID
494
560
  */
@@ -518,7 +584,7 @@ export class OverleafClient {
518
584
  expect: 'json'
519
585
  });
520
586
  if (!response.ok) {
521
- throw new Error(`Failed to create project: ${response.status}`);
587
+ throw httpError('Failed to create project', response.status);
522
588
  }
523
589
  this.applySetCookieHeaders(response.headers['set-cookie']);
524
590
  const projectId = response.body?.project_id;
@@ -541,7 +607,7 @@ export class OverleafClient {
541
607
  expect: 'text'
542
608
  });
543
609
  if (!response.ok) {
544
- throw new Error(`Failed to fetch project info: ${response.status}`);
610
+ throw httpError('Failed to fetch project info', response.status);
545
611
  }
546
612
  this.applySetCookieHeaders(response.headers['set-cookie']);
547
613
  const html = response.body;
@@ -697,7 +763,7 @@ export class OverleafClient {
697
763
  timeoutMs
698
764
  });
699
765
  if (!response.ok) {
700
- throw new Error(`Download failed: ${response.status}`);
766
+ throw httpError('Download failed', response.status);
701
767
  }
702
768
  this.applySetCookieHeaders(response.headers['set-cookie']);
703
769
  return response.body;
@@ -722,7 +788,7 @@ export class OverleafClient {
722
788
  expect: 'json'
723
789
  });
724
790
  if (!response.ok) {
725
- throw new Error(`Failed to compile project: ${response.status}`);
791
+ throw httpError('Failed to compile project', response.status);
726
792
  }
727
793
  this.applySetCookieHeaders(response.headers['set-cookie']);
728
794
  const data = response.body;
@@ -770,7 +836,7 @@ export class OverleafClient {
770
836
  throw new Error('Folder already exists');
771
837
  }
772
838
  if (!response.ok) {
773
- throw new Error(`Failed to create folder: ${response.status}`);
839
+ throw httpError('Failed to create folder', response.status);
774
840
  }
775
841
  this.applySetCookieHeaders(response.headers['set-cookie']);
776
842
  const data = response.body;
@@ -894,9 +960,7 @@ export class OverleafClient {
894
960
  timeoutMs: 5000
895
961
  });
896
962
  if (!handshakeResponse.ok) {
897
- const error = new Error(`Failed to open project socket: ${handshakeResponse.status}`);
898
- error.status = handshakeResponse.status;
899
- throw error;
963
+ throw httpError('Failed to open project socket', handshakeResponse.status);
900
964
  }
901
965
  this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
902
966
  const sid = handshakeResponse.body.trim().split(':')[0];
@@ -932,7 +996,7 @@ export class OverleafClient {
932
996
  timeoutMs: 7000
933
997
  });
934
998
  if (!response.ok) {
935
- throw new Error(`Socket poll failed: ${response.status}`);
999
+ throw httpError('Socket poll failed', response.status);
936
1000
  }
937
1001
  this.applySetCookieHeaders(response.headers['set-cookie']);
938
1002
  const packets = this.decodeSocketIoPayload(response.body);
@@ -967,7 +1031,7 @@ export class OverleafClient {
967
1031
  timeoutMs: 5000
968
1032
  });
969
1033
  if (!response.ok) {
970
- throw new Error(`Socket post failed: ${response.status}`);
1034
+ throw httpError('Socket post failed', response.status);
971
1035
  }
972
1036
  this.applySetCookieHeaders(response.headers['set-cookie']);
973
1037
  }
@@ -1468,6 +1532,8 @@ export class OverleafClient {
1468
1532
  expect: 'text'
1469
1533
  });
1470
1534
  if (!response.ok) {
1535
+ if (isAuthStatus(response.status))
1536
+ throw httpError('Failed to upload file', response.status);
1471
1537
  const text = response.body;
1472
1538
  // Overleaf returns folder_not_found as HTTP 422 JSON.
1473
1539
  // Parse the body first so caller can trigger folder probing fallback.
@@ -1531,7 +1597,7 @@ export class OverleafClient {
1531
1597
  expect: 'text'
1532
1598
  });
1533
1599
  if (!response.ok) {
1534
- throw new Error(`Failed to delete entity: ${response.status}`);
1600
+ throw httpError('Failed to delete entity', response.status);
1535
1601
  }
1536
1602
  this.applySetCookieHeaders(response.headers['set-cookie']);
1537
1603
  }
@@ -1544,7 +1610,7 @@ export class OverleafClient {
1544
1610
  expect: 'json'
1545
1611
  });
1546
1612
  if (!response.ok) {
1547
- throw new Error(`Failed to get entities: ${response.status}`);
1613
+ throw httpError('Failed to get entities', response.status);
1548
1614
  }
1549
1615
  this.applySetCookieHeaders(response.headers['set-cookie']);
1550
1616
  const data = response.body;
@@ -1598,7 +1664,7 @@ export class OverleafClient {
1598
1664
  expect: fileType === 'doc' ? 'json' : 'buffer'
1599
1665
  });
1600
1666
  if (!response.ok) {
1601
- throw new Error(`Failed to download file: ${response.status}`);
1667
+ throw httpError('Failed to download file', response.status);
1602
1668
  }
1603
1669
  this.applySetCookieHeaders(response.headers['set-cookie']);
1604
1670
  if (fileType === 'doc') {
@@ -1622,7 +1688,7 @@ export class OverleafClient {
1622
1688
  expect: 'text'
1623
1689
  });
1624
1690
  if (!response.ok) {
1625
- throw new Error(`Failed to rename entity: ${response.status}`);
1691
+ throw httpError('Failed to rename entity', response.status);
1626
1692
  }
1627
1693
  this.applySetCookieHeaders(response.headers['set-cookie']);
1628
1694
  }
@@ -1645,7 +1711,7 @@ export class OverleafClient {
1645
1711
  expect: 'text'
1646
1712
  });
1647
1713
  if (!response.ok) {
1648
- throw new Error(`Failed to rename project: ${response.status}`);
1714
+ throw httpError('Failed to rename project', response.status);
1649
1715
  }
1650
1716
  this.applySetCookieHeaders(response.headers['set-cookie']);
1651
1717
  }
@@ -1672,7 +1738,7 @@ export class OverleafClient {
1672
1738
  });
1673
1739
  if (!response.ok) {
1674
1740
  const detail = typeof response.body === 'string' && response.body.trim() ? ` (${response.body.trim().slice(0, 200)})` : '';
1675
- throw new Error(`Failed to move entity: ${response.status}${detail}`);
1741
+ throw httpError('Failed to move entity', response.status, detail.trim());
1676
1742
  }
1677
1743
  this.applySetCookieHeaders(response.headers['set-cookie']);
1678
1744
  }
@@ -1798,7 +1864,7 @@ export class OverleafClient {
1798
1864
  expect: 'json'
1799
1865
  });
1800
1866
  if (!response.ok) {
1801
- throw new Error(`Failed to fetch comment threads: ${response.status}`);
1867
+ throw httpError('Failed to fetch comment threads', response.status);
1802
1868
  }
1803
1869
  this.applySetCookieHeaders(response.headers['set-cookie']);
1804
1870
  return response.body;
@@ -2032,7 +2098,7 @@ export class OverleafClient {
2032
2098
  expect: 'text'
2033
2099
  });
2034
2100
  if (!response.ok) {
2035
- throw new Error(`Failed to accept tracked changes in ${changes[0].path}: ${response.status}`);
2101
+ throw httpError(`Failed to accept tracked changes in ${changes[0].path}`, response.status);
2036
2102
  }
2037
2103
  this.applySetCookieHeaders(response.headers['set-cookie']);
2038
2104
  }
@@ -2179,7 +2245,7 @@ export class OverleafClient {
2179
2245
  expect: 'text'
2180
2246
  });
2181
2247
  if (!response.ok) {
2182
- throw new Error(`Failed to resolve comment: ${response.status}`);
2248
+ throw httpError('Failed to resolve comment', response.status);
2183
2249
  }
2184
2250
  this.applySetCookieHeaders(response.headers['set-cookie']);
2185
2251
  return comment;
@@ -2193,7 +2259,7 @@ export class OverleafClient {
2193
2259
  expect: 'text'
2194
2260
  });
2195
2261
  if (!response.ok) {
2196
- throw new Error(`Failed to reopen comment: ${response.status}`);
2262
+ throw httpError('Failed to reopen comment', response.status);
2197
2263
  }
2198
2264
  this.applySetCookieHeaders(response.headers['set-cookie']);
2199
2265
  return comment;
@@ -2207,7 +2273,7 @@ export class OverleafClient {
2207
2273
  expect: 'text'
2208
2274
  });
2209
2275
  if (!response.ok) {
2210
- throw new Error(`Failed to delete comment: ${response.status}`);
2276
+ throw httpError('Failed to delete comment', response.status);
2211
2277
  }
2212
2278
  this.applySetCookieHeaders(response.headers['set-cookie']);
2213
2279
  return comment;
@@ -2313,7 +2379,7 @@ export class OverleafClient {
2313
2379
  expect: 'text'
2314
2380
  });
2315
2381
  if (!response.ok) {
2316
- throw new Error(`Failed to post comment message: ${response.status}`);
2382
+ throw httpError('Failed to post comment message', response.status);
2317
2383
  }
2318
2384
  this.applySetCookieHeaders(response.headers['set-cookie']);
2319
2385
  if (!response.body)
@@ -2336,7 +2402,7 @@ export class OverleafClient {
2336
2402
  expect: 'json'
2337
2403
  });
2338
2404
  if (!response.ok) {
2339
- throw new Error(`Failed to compile project: ${response.status}`);
2405
+ throw httpError('Failed to compile project', response.status);
2340
2406
  }
2341
2407
  this.applySetCookieHeaders(response.headers['set-cookie']);
2342
2408
  const data = response.body;