@aloth/olcli 0.15.2 → 0.16.0

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 (47) 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 +455 -177
  7. package/dist/cli.js.map +1 -1
  8. package/dist/client.d.ts +52 -1
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +199 -42
  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/mcp-server.d.ts.map +1 -1
  21. package/dist/mcp-server.js +27 -19
  22. package/dist/mcp-server.js.map +1 -1
  23. package/dist/mcp.js +1 -1
  24. package/dist/mcp.js.map +1 -1
  25. package/dist/move-plan.d.ts +75 -0
  26. package/dist/move-plan.d.ts.map +1 -0
  27. package/dist/move-plan.js +168 -0
  28. package/dist/move-plan.js.map +1 -0
  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.js +3 -2
  42. package/dist/remote-helper.js.map +1 -1
  43. package/dist/term.d.ts +25 -0
  44. package/dist/term.d.ts.map +1 -0
  45. package/dist/term.js +42 -0
  46. package/dist/term.js.map +1 -0
  47. package/package.json +9 -4
package/dist/client.js CHANGED
@@ -18,15 +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';
23
+ import { planEntityMove } from './move-plan.js';
24
+ import { matchProject } from './project-match.js';
25
+ import { HttpStatusError, SessionExpiredError, httpError, isAuthStatus } from './errors.js';
24
26
  // Read version from package.json
25
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
28
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
27
29
  const USER_AGENT = `olcli/${pkg.version}`;
28
30
  const DEFAULT_BASE_URL = 'https://www.overleaf.com';
29
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
+ }
30
43
  export const DEFAULT_COMMENT_CONCURRENCY = 4;
31
44
  export const MAX_COMMENT_CONCURRENCY = 8;
32
45
  /**
@@ -50,6 +63,8 @@ export class OverleafClient {
50
63
  csrf;
51
64
  baseUrl;
52
65
  verbose = false;
66
+ /** Email of the signed-in account, when the bootstrap page named it. */
67
+ accountEmail;
53
68
  timeoutMs = 10000;
54
69
  // Cache per-project folder trees so repeated uploads in sync/upload calls
55
70
  // don't re-fetch the tree via Socket.IO on every file.
@@ -162,21 +177,23 @@ export class OverleafClient {
162
177
  expect: 'text'
163
178
  });
164
179
  if (!response.ok) {
165
- throw new Error(`Failed to fetch projects page: ${response.status}`);
180
+ throw httpError('Failed to fetch projects page', response.status);
166
181
  }
167
182
  bootstrapClient.applySetCookieHeaders(response.headers['set-cookie']);
168
183
  const html = response.body;
169
184
  const $ = cheerio.load(html);
170
185
  if (OverleafClient.isLoginPage($)) {
171
- throw new Error('Authentication required. Session may have expired.');
186
+ throw new SessionExpiredError('Overleaf answered with its login page');
172
187
  }
173
188
  const csrf = OverleafClient.extractCsrfToken($);
174
189
  if (!csrf) {
175
- throw new Error('Could not find CSRF token. Session may have expired.');
190
+ throw new SessionExpiredError('No CSRF token on the project page');
176
191
  }
177
192
  // Update cookies if the bootstrap request added anything
178
193
  const updatedCookies = bootstrapClient.cookies;
179
- return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl });
194
+ const client = new OverleafClient({ cookies: updatedCookies, csrf, baseUrl });
195
+ client.accountEmail = OverleafClient.extractAccountEmail($);
196
+ return client;
180
197
  }
181
198
  /**
182
199
  * Create client by submitting Overleaf's email/password login form.
@@ -188,7 +205,7 @@ export class OverleafClient {
188
205
  expect: 'text'
189
206
  });
190
207
  if (!loginPage.ok) {
191
- throw new Error(`Failed to fetch login page: ${loginPage.status}`);
208
+ throw new HttpStatusError('Failed to fetch login page', loginPage.status);
192
209
  }
193
210
  bootstrapClient.applySetCookieHeaders(loginPage.headers['set-cookie']);
194
211
  const loginHtml = loginPage.body;
@@ -222,7 +239,7 @@ export class OverleafClient {
222
239
  if (loginFailure) {
223
240
  throw new Error('Password login failed. Email or password may be incorrect.');
224
241
  }
225
- throw new Error(`Password login failed: ${loginResponse.status}`);
242
+ throw new HttpStatusError('Password login failed', loginResponse.status);
226
243
  }
227
244
  const projectUrl = new URL(loginResponse.headers.location || '/project', baseUrl).toString();
228
245
  const projectPage = await bootstrapClient.httpRequest(projectUrl, {
@@ -233,7 +250,7 @@ export class OverleafClient {
233
250
  expect: 'text'
234
251
  });
235
252
  if (!projectPage.ok) {
236
- throw new Error(`Failed to fetch projects page after login: ${projectPage.status}`);
253
+ throw httpError('Failed to fetch projects page after login', projectPage.status);
237
254
  }
238
255
  bootstrapClient.applySetCookieHeaders(projectPage.headers['set-cookie']);
239
256
  const projectHtml = projectPage.body;
@@ -245,7 +262,9 @@ export class OverleafClient {
245
262
  if (!projectCsrf) {
246
263
  throw new Error('Could not find CSRF token after password login.');
247
264
  }
248
- 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;
249
268
  }
250
269
  /**
251
270
  * Pull the CSRF token out of a rendered Overleaf page.
@@ -281,6 +300,28 @@ export class OverleafClient {
281
300
  }
282
301
  return csrf;
283
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
+ }
284
325
  static isLoginPage($) {
285
326
  return $('form[name="loginForm"]').length > 0 || $('input[name="password"]').length > 0;
286
327
  }
@@ -348,12 +389,25 @@ export class OverleafClient {
348
389
  }
349
390
  const doRequest = (reqUrl, redirectsLeft) => {
350
391
  return new Promise((resolve, reject) => {
351
- const parsedUrl = new URL(reqUrl);
352
- const transport = parsedUrl.protocol === 'https:' ? https : http;
353
392
  const headers = this.normalizeHeaders({ ...extraHeaders, ...options.headers });
354
- 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) => {
355
399
  const status = res.statusCode || 0;
356
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
+ }
357
411
  if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) {
358
412
  this.logVerbose(`${method} ${reqUrl} -> ${status} redirect -> ${res.headers.location}`);
359
413
  const redirectUrl = new URL(res.headers.location, reqUrl).toString();
@@ -415,7 +469,7 @@ export class OverleafClient {
415
469
  expect: 'text'
416
470
  });
417
471
  if (!response.ok) {
418
- throw new Error(`Failed to fetch projects: ${response.status}`);
472
+ throw httpError('Failed to fetch projects', response.status);
419
473
  }
420
474
  this.applySetCookieHeaders(response.headers['set-cookie']);
421
475
  const html = response.body;
@@ -488,6 +542,19 @@ export class OverleafClient {
488
542
  const projects = await this.listProjects();
489
543
  return projects.find(p => p.name === name);
490
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
+ }
491
558
  /**
492
559
  * Get project by ID
493
560
  */
@@ -517,7 +584,7 @@ export class OverleafClient {
517
584
  expect: 'json'
518
585
  });
519
586
  if (!response.ok) {
520
- throw new Error(`Failed to create project: ${response.status}`);
587
+ throw httpError('Failed to create project', response.status);
521
588
  }
522
589
  this.applySetCookieHeaders(response.headers['set-cookie']);
523
590
  const projectId = response.body?.project_id;
@@ -540,7 +607,7 @@ export class OverleafClient {
540
607
  expect: 'text'
541
608
  });
542
609
  if (!response.ok) {
543
- throw new Error(`Failed to fetch project info: ${response.status}`);
610
+ throw httpError('Failed to fetch project info', response.status);
544
611
  }
545
612
  this.applySetCookieHeaders(response.headers['set-cookie']);
546
613
  const html = response.body;
@@ -696,7 +763,7 @@ export class OverleafClient {
696
763
  timeoutMs
697
764
  });
698
765
  if (!response.ok) {
699
- throw new Error(`Download failed: ${response.status}`);
766
+ throw httpError('Download failed', response.status);
700
767
  }
701
768
  this.applySetCookieHeaders(response.headers['set-cookie']);
702
769
  return response.body;
@@ -721,7 +788,7 @@ export class OverleafClient {
721
788
  expect: 'json'
722
789
  });
723
790
  if (!response.ok) {
724
- throw new Error(`Failed to compile project: ${response.status}`);
791
+ throw httpError('Failed to compile project', response.status);
725
792
  }
726
793
  this.applySetCookieHeaders(response.headers['set-cookie']);
727
794
  const data = response.body;
@@ -769,7 +836,7 @@ export class OverleafClient {
769
836
  throw new Error('Folder already exists');
770
837
  }
771
838
  if (!response.ok) {
772
- throw new Error(`Failed to create folder: ${response.status}`);
839
+ throw httpError('Failed to create folder', response.status);
773
840
  }
774
841
  this.applySetCookieHeaders(response.headers['set-cookie']);
775
842
  const data = response.body;
@@ -893,9 +960,7 @@ export class OverleafClient {
893
960
  timeoutMs: 5000
894
961
  });
895
962
  if (!handshakeResponse.ok) {
896
- const error = new Error(`Failed to open project socket: ${handshakeResponse.status}`);
897
- error.status = handshakeResponse.status;
898
- throw error;
963
+ throw httpError('Failed to open project socket', handshakeResponse.status);
899
964
  }
900
965
  this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
901
966
  const sid = handshakeResponse.body.trim().split(':')[0];
@@ -931,7 +996,7 @@ export class OverleafClient {
931
996
  timeoutMs: 7000
932
997
  });
933
998
  if (!response.ok) {
934
- throw new Error(`Socket poll failed: ${response.status}`);
999
+ throw httpError('Socket poll failed', response.status);
935
1000
  }
936
1001
  this.applySetCookieHeaders(response.headers['set-cookie']);
937
1002
  const packets = this.decodeSocketIoPayload(response.body);
@@ -966,7 +1031,7 @@ export class OverleafClient {
966
1031
  timeoutMs: 5000
967
1032
  });
968
1033
  if (!response.ok) {
969
- throw new Error(`Socket post failed: ${response.status}`);
1034
+ throw httpError('Socket post failed', response.status);
970
1035
  }
971
1036
  this.applySetCookieHeaders(response.headers['set-cookie']);
972
1037
  }
@@ -1467,6 +1532,8 @@ export class OverleafClient {
1467
1532
  expect: 'text'
1468
1533
  });
1469
1534
  if (!response.ok) {
1535
+ if (isAuthStatus(response.status))
1536
+ throw httpError('Failed to upload file', response.status);
1470
1537
  const text = response.body;
1471
1538
  // Overleaf returns folder_not_found as HTTP 422 JSON.
1472
1539
  // Parse the body first so caller can trigger folder probing fallback.
@@ -1530,7 +1597,7 @@ export class OverleafClient {
1530
1597
  expect: 'text'
1531
1598
  });
1532
1599
  if (!response.ok) {
1533
- throw new Error(`Failed to delete entity: ${response.status}`);
1600
+ throw httpError('Failed to delete entity', response.status);
1534
1601
  }
1535
1602
  this.applySetCookieHeaders(response.headers['set-cookie']);
1536
1603
  }
@@ -1543,7 +1610,7 @@ export class OverleafClient {
1543
1610
  expect: 'json'
1544
1611
  });
1545
1612
  if (!response.ok) {
1546
- throw new Error(`Failed to get entities: ${response.status}`);
1613
+ throw httpError('Failed to get entities', response.status);
1547
1614
  }
1548
1615
  this.applySetCookieHeaders(response.headers['set-cookie']);
1549
1616
  const data = response.body;
@@ -1597,7 +1664,7 @@ export class OverleafClient {
1597
1664
  expect: fileType === 'doc' ? 'json' : 'buffer'
1598
1665
  });
1599
1666
  if (!response.ok) {
1600
- throw new Error(`Failed to download file: ${response.status}`);
1667
+ throw httpError('Failed to download file', response.status);
1601
1668
  }
1602
1669
  this.applySetCookieHeaders(response.headers['set-cookie']);
1603
1670
  if (fileType === 'doc') {
@@ -1621,7 +1688,7 @@ export class OverleafClient {
1621
1688
  expect: 'text'
1622
1689
  });
1623
1690
  if (!response.ok) {
1624
- throw new Error(`Failed to rename entity: ${response.status}`);
1691
+ throw httpError('Failed to rename entity', response.status);
1625
1692
  }
1626
1693
  this.applySetCookieHeaders(response.headers['set-cookie']);
1627
1694
  }
@@ -1644,7 +1711,7 @@ export class OverleafClient {
1644
1711
  expect: 'text'
1645
1712
  });
1646
1713
  if (!response.ok) {
1647
- throw new Error(`Failed to rename project: ${response.status}`);
1714
+ throw httpError('Failed to rename project', response.status);
1648
1715
  }
1649
1716
  this.applySetCookieHeaders(response.headers['set-cookie']);
1650
1717
  }
@@ -1659,14 +1726,104 @@ export class OverleafClient {
1659
1726
  await this.deleteEntity(projectId, entity.id, entity.type);
1660
1727
  }
1661
1728
  /**
1662
- * Rename a file by path
1729
+ * Move a file, doc, or folder into another folder, keeping its name.
1730
+ * Overleaf answers 400 when the name is already taken in the target folder.
1663
1731
  */
1664
- async renameByPath(projectId, oldPath, newName) {
1665
- const entity = await this.findEntityByPath(projectId, oldPath);
1666
- if (!entity) {
1667
- throw new Error(`File not found: ${oldPath}`);
1732
+ async moveEntity(projectId, entityId, entityType, folderId) {
1733
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${entityType}/${entityId}/move`, {
1734
+ method: 'POST',
1735
+ headers: this.getHeaders(true),
1736
+ body: JSON.stringify({ folder_id: folderId }),
1737
+ expect: 'text'
1738
+ });
1739
+ if (!response.ok) {
1740
+ const detail = typeof response.body === 'string' && response.body.trim() ? ` (${response.body.trim().slice(0, 200)})` : '';
1741
+ throw httpError('Failed to move entity', response.status, detail.trim());
1742
+ }
1743
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1744
+ }
1745
+ /**
1746
+ * Work out what `rename|mv <source> <destination>` does, without writing.
1747
+ * Throws MoveRefusedError when the request is refused. See move-plan.ts.
1748
+ */
1749
+ async planMove(projectId, source, destination) {
1750
+ const projectInfo = await this.getProjectInfo(projectId);
1751
+ const root = projectInfo.rootFolder?.[0];
1752
+ if (!root?._id)
1753
+ throw new Error('Could not read the project file tree');
1754
+ return planEntityMove(root, source, destination);
1755
+ }
1756
+ /**
1757
+ * Carry out a plan from planMove. Missing folders are created right before
1758
+ * the move. If the second of two writes fails, the first one is undone so
1759
+ * the entity keeps its old path; folders created on the way stay.
1760
+ */
1761
+ async applyMovePlan(projectId, plan) {
1762
+ const { entity } = plan;
1763
+ const createdFolders = [];
1764
+ if (plan.noop)
1765
+ return { from: plan.from, to: plan.to, createdFolders };
1766
+ const ensureTargetFolder = async () => {
1767
+ if (plan.targetFolderId)
1768
+ return plan.targetFolderId;
1769
+ const known = { ...plan.knownFolders };
1770
+ for (const path of plan.createFolders) {
1771
+ const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
1772
+ const name = path.slice(path.lastIndexOf('/') + 1);
1773
+ known[path] = await this.createFolder(projectId, known[parentPath], name);
1774
+ createdFolders.push(path);
1775
+ }
1776
+ return known[plan.targetFolderPath];
1777
+ };
1778
+ const doRename = () => this.renameEntity(projectId, entity.id, entity.type, plan.newName);
1779
+ const doMove = async () => this.moveEntity(projectId, entity.id, entity.type, await ensureTargetFolder());
1780
+ try {
1781
+ if (!plan.move) {
1782
+ await doRename();
1783
+ }
1784
+ else if (!plan.rename) {
1785
+ await doMove();
1786
+ }
1787
+ else if (plan.order === 'rename-then-move') {
1788
+ await doRename();
1789
+ try {
1790
+ await doMove();
1791
+ }
1792
+ catch (error) {
1793
+ await this.renameEntity(projectId, entity.id, entity.type, entity.name).catch(() => undefined);
1794
+ throw error;
1795
+ }
1796
+ }
1797
+ else {
1798
+ await doMove();
1799
+ try {
1800
+ await doRename();
1801
+ }
1802
+ catch (error) {
1803
+ await this.moveEntity(projectId, entity.id, entity.type, plan.sourceFolderId).catch(() => undefined);
1804
+ throw error;
1805
+ }
1806
+ }
1807
+ }
1808
+ finally {
1809
+ this.invalidateFolderTree(projectId);
1668
1810
  }
1669
- await this.renameEntity(projectId, entity.id, entity.type, newName);
1811
+ return { from: plan.from, to: plan.to, createdFolders };
1812
+ }
1813
+ /**
1814
+ * Rename and/or move an entity by path. A destination without `/` renames
1815
+ * in place; with a folder part it moves (creating missing folders); ending
1816
+ * in `/` it moves and keeps the name; `/` alone is the project root.
1817
+ */
1818
+ async moveByPath(projectId, source, destination) {
1819
+ const plan = await this.planMove(projectId, source, destination);
1820
+ return this.applyMovePlan(projectId, plan);
1821
+ }
1822
+ /**
1823
+ * Rename a file by path. `newName` may be a path, see moveByPath.
1824
+ */
1825
+ async renameByPath(projectId, oldPath, newName) {
1826
+ await this.moveByPath(projectId, oldPath, newName);
1670
1827
  }
1671
1828
  /**
1672
1829
  * Download a file by path (uses zip as fallback if ID not available)
@@ -1707,7 +1864,7 @@ export class OverleafClient {
1707
1864
  expect: 'json'
1708
1865
  });
1709
1866
  if (!response.ok) {
1710
- throw new Error(`Failed to fetch comment threads: ${response.status}`);
1867
+ throw httpError('Failed to fetch comment threads', response.status);
1711
1868
  }
1712
1869
  this.applySetCookieHeaders(response.headers['set-cookie']);
1713
1870
  return response.body;
@@ -1941,7 +2098,7 @@ export class OverleafClient {
1941
2098
  expect: 'text'
1942
2099
  });
1943
2100
  if (!response.ok) {
1944
- 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);
1945
2102
  }
1946
2103
  this.applySetCookieHeaders(response.headers['set-cookie']);
1947
2104
  }
@@ -2088,7 +2245,7 @@ export class OverleafClient {
2088
2245
  expect: 'text'
2089
2246
  });
2090
2247
  if (!response.ok) {
2091
- throw new Error(`Failed to resolve comment: ${response.status}`);
2248
+ throw httpError('Failed to resolve comment', response.status);
2092
2249
  }
2093
2250
  this.applySetCookieHeaders(response.headers['set-cookie']);
2094
2251
  return comment;
@@ -2102,7 +2259,7 @@ export class OverleafClient {
2102
2259
  expect: 'text'
2103
2260
  });
2104
2261
  if (!response.ok) {
2105
- throw new Error(`Failed to reopen comment: ${response.status}`);
2262
+ throw httpError('Failed to reopen comment', response.status);
2106
2263
  }
2107
2264
  this.applySetCookieHeaders(response.headers['set-cookie']);
2108
2265
  return comment;
@@ -2116,7 +2273,7 @@ export class OverleafClient {
2116
2273
  expect: 'text'
2117
2274
  });
2118
2275
  if (!response.ok) {
2119
- throw new Error(`Failed to delete comment: ${response.status}`);
2276
+ throw httpError('Failed to delete comment', response.status);
2120
2277
  }
2121
2278
  this.applySetCookieHeaders(response.headers['set-cookie']);
2122
2279
  return comment;
@@ -2222,7 +2379,7 @@ export class OverleafClient {
2222
2379
  expect: 'text'
2223
2380
  });
2224
2381
  if (!response.ok) {
2225
- throw new Error(`Failed to post comment message: ${response.status}`);
2382
+ throw httpError('Failed to post comment message', response.status);
2226
2383
  }
2227
2384
  this.applySetCookieHeaders(response.headers['set-cookie']);
2228
2385
  if (!response.body)
@@ -2245,7 +2402,7 @@ export class OverleafClient {
2245
2402
  expect: 'json'
2246
2403
  });
2247
2404
  if (!response.ok) {
2248
- throw new Error(`Failed to compile project: ${response.status}`);
2405
+ throw httpError('Failed to compile project', response.status);
2249
2406
  }
2250
2407
  this.applySetCookieHeaders(response.headers['set-cookie']);
2251
2408
  const data = response.body;