overleaf-review 0.3.0 → 0.4.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 (3) hide show
  1. package/README.md +94 -15
  2. package/dist/cli.js +3204 -371
  3. package/package.json +4 -2
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/commands/pull.ts
4
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
5
- import { join as join3 } from "path";
4
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
5
+ import { join as join4 } from "path";
6
6
 
7
7
  // src/config.ts
8
8
  import "dotenv/config";
@@ -33,7 +33,7 @@ function saveCredentials(creds) {
33
33
  }
34
34
 
35
35
  // src/lib/project-config.ts
36
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
36
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, renameSync, unlinkSync } from "fs";
37
37
  import { join as join2 } from "path";
38
38
  var REPO_CONFIG_PATH = join2(".overleaf", "config.json");
39
39
  function loadProjectConfig() {
@@ -45,7 +45,17 @@ function loadProjectConfig() {
45
45
  }
46
46
  function saveProjectConfig(cfg) {
47
47
  mkdirSync2(".overleaf", { recursive: true });
48
- writeFileSync2(REPO_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
48
+ const temp = `${REPO_CONFIG_PATH}.tmp-${process.pid}-${Date.now()}`;
49
+ try {
50
+ writeFileSync2(temp, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
51
+ renameSync(temp, REPO_CONFIG_PATH);
52
+ } catch (error) {
53
+ try {
54
+ unlinkSync(temp);
55
+ } catch {
56
+ }
57
+ throw error;
58
+ }
49
59
  return REPO_CONFIG_PATH;
50
60
  }
51
61
 
@@ -131,6 +141,15 @@ var OverleafSocket = class {
131
141
  const list = this.handlers.get(event) ?? [];
132
142
  list.push(handler);
133
143
  this.handlers.set(event, list);
144
+ return () => this.off(event, handler);
145
+ }
146
+ /** Remove a previously registered server-pushed event handler. */
147
+ off(event, handler) {
148
+ const list = this.handlers.get(event);
149
+ if (!list) return;
150
+ const next = list.filter((candidate) => candidate !== handler);
151
+ if (next.length) this.handlers.set(event, next);
152
+ else this.handlers.delete(event);
134
153
  }
135
154
  async connect(projectId) {
136
155
  const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${projectId}&t=${Date.now()}`;
@@ -149,8 +168,8 @@ var OverleafSocket = class {
149
168
  const wsUrl = `${this.baseUrl.replace(/^http/, "ws")}/socket.io/1/websocket/${sid}`;
150
169
  if (this.debug) console.log(`[socket] transports=${transports}; ws=${wsUrl}`);
151
170
  this.ws = new WebSocket(wsUrl, { headers: this.browserHeaders });
152
- await new Promise((resolve2, reject2) => {
153
- this.ws.once("open", () => resolve2());
171
+ await new Promise((resolve3, reject2) => {
172
+ this.ws.once("open", () => resolve3());
154
173
  this.ws.once("error", reject2);
155
174
  this.ws.once("unexpected-response", (_req, response) => {
156
175
  let errBody = "";
@@ -175,14 +194,14 @@ var OverleafSocket = class {
175
194
  /** Emit an event and resolve with the server's ack payload (array of args). */
176
195
  emit(name, args, timeoutMs = 15e3) {
177
196
  const id = this.ackId++;
178
- return new Promise((resolve2, reject2) => {
197
+ return new Promise((resolve3, reject2) => {
179
198
  const timer = setTimeout(() => {
180
199
  this.pendingAcks.delete(id);
181
200
  reject2(new Error(`emit('${name}') timed out after ${timeoutMs}ms with no ack`));
182
201
  }, timeoutMs);
183
202
  this.pendingAcks.set(id, (payload) => {
184
203
  clearTimeout(timer);
185
- resolve2(payload);
204
+ resolve3(payload);
186
205
  });
187
206
  this.send(`5:${id}+::${JSON.stringify({ name, args })}`);
188
207
  });
@@ -243,7 +262,7 @@ function collectDocs(rootFolder) {
243
262
  }
244
263
  async function openProject(opts = {}) {
245
264
  const socket = new OverleafSocket(config.baseUrl, config.cookie, { debug: opts.debug ?? false });
246
- const pushed = new Promise((resolve2) => socket.on("joinProjectResponse", resolve2));
265
+ const pushed = new Promise((resolve3) => socket.on("joinProjectResponse", resolve3));
247
266
  await socket.connect(config.projectId);
248
267
  const first = await Promise.race([
249
268
  pushed,
@@ -266,6 +285,41 @@ async function joinDoc(socket, docId) {
266
285
  if (err) throw new Error(`joinDoc error: ${JSON.stringify(err)}`);
267
286
  return { version, lines: lines ?? [], ranges: ranges ?? {} };
268
287
  }
288
+ async function applyOtUpdateAndWait(socket, docId, update, timeoutMs = 3e4) {
289
+ let timer;
290
+ let stopApplied = () => {
291
+ };
292
+ let stopError = () => {
293
+ };
294
+ const applied = new Promise((resolve3, reject2) => {
295
+ stopApplied = socket.on("otUpdateApplied", (args) => {
296
+ const event = args[0];
297
+ if (event?.doc === docId && Number.isSafeInteger(event.v) && event.v >= update.v && !Object.prototype.hasOwnProperty.call(event, "op")) {
298
+ resolve3();
299
+ }
300
+ });
301
+ stopError = socket.on("otUpdateError", (args) => {
302
+ const metadata = args[1];
303
+ if (metadata?.doc_id && metadata.doc_id !== docId) return;
304
+ reject2(new Error(`Overleaf failed to apply the OT update: ${JSON.stringify(args)}`));
305
+ });
306
+ timer = setTimeout(
307
+ () => reject2(new Error(`OT update for ${docId} was queued but not confirmed within ${timeoutMs}ms`)),
308
+ timeoutMs
309
+ );
310
+ });
311
+ void applied.catch(() => {
312
+ });
313
+ try {
314
+ const ack = await socket.emit("applyOtUpdate", [docId, update], timeoutMs);
315
+ if (ack?.[0]) throw new Error(`Overleaf rejected the OT update: ${JSON.stringify(ack[0])}`);
316
+ await applied;
317
+ } finally {
318
+ if (timer) clearTimeout(timer);
319
+ stopApplied();
320
+ stopError();
321
+ }
322
+ }
269
323
 
270
324
  // src/lib/rest.ts
271
325
  var UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
@@ -290,7 +344,111 @@ async function getThreads() {
290
344
  if (!res.ok) throw new Error(`getThreads ${res.status}: ${(await res.text()).slice(0, 200)}`);
291
345
  return res.json();
292
346
  }
293
- async function postThreadMessage(threadId, content, csrf) {
347
+ var RestRequestError = class extends Error {
348
+ constructor(message, status, responseBody) {
349
+ super(message);
350
+ this.status = status;
351
+ this.responseBody = responseBody;
352
+ this.name = "RestRequestError";
353
+ }
354
+ status;
355
+ responseBody;
356
+ };
357
+ function threadMessages(thread) {
358
+ if (!thread || typeof thread !== "object") return [];
359
+ const messages = thread.messages;
360
+ return Array.isArray(messages) ? messages.filter((message) => Boolean(message && typeof message === "object")) : [];
361
+ }
362
+ function threadMessageId(message) {
363
+ const value = message?.id ?? message?._id;
364
+ return typeof value === "string" && value ? value : void 0;
365
+ }
366
+ function timestampMs(message) {
367
+ const raw = message.timestamp ?? message.createdAt ?? message.created_at;
368
+ if (typeof raw === "number" && Number.isFinite(raw)) {
369
+ return raw < 1e11 ? raw * 1e3 : raw;
370
+ }
371
+ if (typeof raw === "string") {
372
+ const numeric = Number(raw);
373
+ if (Number.isFinite(numeric) && raw.trim()) {
374
+ return numeric < 1e11 ? numeric * 1e3 : numeric;
375
+ }
376
+ const parsed = Date.parse(raw);
377
+ if (Number.isFinite(parsed)) return parsed;
378
+ }
379
+ return void 0;
380
+ }
381
+ function findRecentIdenticalMessage(thread, content, nowMs = Date.now(), windowMs = 5 * 60 * 1e3) {
382
+ const earliest = nowMs - Math.max(0, windowMs);
383
+ return threadMessages(thread).filter((message) => {
384
+ if (message.content !== content) return false;
385
+ const timestamp = timestampMs(message);
386
+ return timestamp !== void 0 && timestamp >= earliest && timestamp <= nowMs + 6e4;
387
+ }).sort((a, b) => (timestampMs(b) ?? 0) - (timestampMs(a) ?? 0))[0];
388
+ }
389
+ function findNewIdenticalMessage(beforeThread, afterThread, content, returnedMessageId) {
390
+ const after = threadMessages(afterThread);
391
+ if (returnedMessageId) {
392
+ return after.find(
393
+ (message) => threadMessageId(message) === returnedMessageId && message.content === content
394
+ );
395
+ }
396
+ const beforeIds = new Set(
397
+ threadMessages(beforeThread).map(threadMessageId).filter((id) => Boolean(id))
398
+ );
399
+ const candidates = [...after].reverse().filter((message) => {
400
+ if (message.content !== content) return false;
401
+ const id = threadMessageId(message);
402
+ return Boolean(id && !beforeIds.has(id));
403
+ });
404
+ return candidates.length === 1 ? candidates[0] : void 0;
405
+ }
406
+ function extractPostedMessageId(value) {
407
+ if (!value || typeof value !== "object") return void 0;
408
+ const object = value;
409
+ for (const key of ["message_id", "messageId"]) {
410
+ if (typeof object[key] === "string" && object[key]) return object[key];
411
+ }
412
+ for (const key of ["message", "data"]) {
413
+ const nested = object[key];
414
+ if (nested && typeof nested === "object") {
415
+ const nestedId = threadMessageId(nested) ?? extractPostedMessageId(nested);
416
+ if (nestedId) return nestedId;
417
+ }
418
+ }
419
+ return threadMessageId(object);
420
+ }
421
+ async function observePostedThreadMessage(threadId, beforeThread, content, returnedMessageId, options = {}) {
422
+ const timeoutMs = Math.max(0, options.timeoutMs ?? 5e3);
423
+ const intervalMs = Math.max(10, options.intervalMs ?? 250);
424
+ const deadline = Date.now() + timeoutMs;
425
+ let attempts = 0;
426
+ let lastThread;
427
+ let lastError;
428
+ do {
429
+ attempts += 1;
430
+ try {
431
+ lastThread = (await getThreads())[threadId];
432
+ const message = findNewIdenticalMessage(
433
+ beforeThread,
434
+ lastThread,
435
+ content,
436
+ returnedMessageId
437
+ );
438
+ if (message) return { message, thread: lastThread, attempts };
439
+ } catch (error) {
440
+ lastError = error instanceof Error ? error.message : String(error);
441
+ }
442
+ if (Date.now() >= deadline) break;
443
+ await new Promise((resolve3) => setTimeout(resolve3, Math.min(intervalMs, deadline - Date.now())));
444
+ } while (Date.now() <= deadline);
445
+ return {
446
+ thread: lastThread,
447
+ attempts,
448
+ ...lastError ? { lastError } : {}
449
+ };
450
+ }
451
+ async function postThreadMessageDetailed(threadId, content, csrf) {
294
452
  const res = await fetch(
295
453
  `${config.baseUrl}/project/${config.projectId}/thread/${threadId}/messages`,
296
454
  {
@@ -299,10 +457,27 @@ async function postThreadMessage(threadId, content, csrf) {
299
457
  body: JSON.stringify({ content })
300
458
  }
301
459
  );
460
+ const rawBody = await res.text();
302
461
  if (!res.ok) {
303
- throw new Error(`postThreadMessage ${res.status}: ${(await res.text()).slice(0, 300)}`);
462
+ throw new RestRequestError(
463
+ `postThreadMessage ${res.status}: ${rawBody.slice(0, 300)}`,
464
+ res.status,
465
+ rawBody.slice(0, 1e3)
466
+ );
304
467
  }
305
- return res.status;
468
+ let responseBody;
469
+ if (rawBody) {
470
+ try {
471
+ responseBody = JSON.parse(rawBody);
472
+ } catch {
473
+ responseBody = rawBody.slice(0, 1e3);
474
+ }
475
+ }
476
+ return {
477
+ status: res.status,
478
+ messageId: extractPostedMessageId(responseBody),
479
+ ...responseBody === void 0 ? {} : { responseBody }
480
+ };
306
481
  }
307
482
  async function setThreadResolved(docId, threadId, reopen, csrf) {
308
483
  const action = reopen ? "reopen" : "resolve";
@@ -390,6 +565,156 @@ function lineContext(lines, line, radius = 1) {
390
565
  return out.join("\n");
391
566
  }
392
567
 
568
+ // src/lib/submission-lock.ts
569
+ import { createHash, randomUUID } from "crypto";
570
+ import {
571
+ closeSync,
572
+ existsSync as existsSync2,
573
+ fsyncSync,
574
+ mkdirSync as mkdirSync3,
575
+ openSync,
576
+ readFileSync as readFileSync3,
577
+ realpathSync as realpathSync2,
578
+ unlinkSync as unlinkSync2,
579
+ writeFileSync as writeFileSync3
580
+ } from "fs";
581
+ import { tmpdir } from "os";
582
+ import { dirname as dirname2, join as join3 } from "path";
583
+
584
+ // src/lib/workspace-path.ts
585
+ import { existsSync, realpathSync } from "fs";
586
+ import { dirname, isAbsolute, relative, resolve, sep } from "path";
587
+ function isOutside(root, candidate) {
588
+ const rel = relative(root, candidate);
589
+ return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
590
+ }
591
+ function workspaceRelativePath(input, root = process.cwd()) {
592
+ if (typeof input !== "string" || !input.length) throw new Error("workspace path is empty");
593
+ if (isAbsolute(input) || /^[\\/]/.test(input) || /^[a-zA-Z]:[\\/]/.test(input)) {
594
+ throw new Error(`Absolute paths are not allowed: ${input}`);
595
+ }
596
+ const portable = input.replace(/\\/g, "/");
597
+ if (portable.split("/").includes("..")) {
598
+ throw new Error(`Parent path traversal is not allowed: ${input}`);
599
+ }
600
+ const target = resolve(root, ...portable.split("/"));
601
+ const absoluteRoot = resolve(root);
602
+ if (isOutside(absoluteRoot, target) || target === absoluteRoot) {
603
+ throw new Error(`Path must identify a file inside the working tree: ${input}`);
604
+ }
605
+ return relative(absoluteRoot, target).split(sep).join("/");
606
+ }
607
+ function workspaceReadPath(input, root = process.cwd()) {
608
+ const rel = workspaceRelativePath(input, root);
609
+ const realRoot = realpathSync(root);
610
+ const lexicalTarget = resolve(root, ...rel.split("/"));
611
+ const realTarget = realpathSync(lexicalTarget);
612
+ if (isOutside(realRoot, realTarget) || realTarget === realRoot) {
613
+ throw new Error(`Path resolves outside the working tree: ${input}`);
614
+ }
615
+ return lexicalTarget;
616
+ }
617
+ function workspaceWritePath(input, root = process.cwd()) {
618
+ const rel = workspaceRelativePath(input, root);
619
+ const lexicalTarget = resolve(root, ...rel.split("/"));
620
+ const realRoot = realpathSync(root);
621
+ if (existsSync(lexicalTarget)) {
622
+ const target = realpathSync(lexicalTarget);
623
+ if (isOutside(realRoot, target) || target === realRoot) {
624
+ throw new Error(`Path resolves outside the working tree: ${input}`);
625
+ }
626
+ return lexicalTarget;
627
+ }
628
+ let ancestor = dirname(lexicalTarget);
629
+ while (!existsSync(ancestor)) {
630
+ const parent = dirname(ancestor);
631
+ if (parent === ancestor) throw new Error(`Cannot resolve a safe parent for ${input}`);
632
+ ancestor = parent;
633
+ }
634
+ const realAncestor = realpathSync(ancestor);
635
+ if (isOutside(realRoot, realAncestor)) {
636
+ throw new Error(`Path has an ancestor outside the working tree: ${input}`);
637
+ }
638
+ return lexicalTarget;
639
+ }
640
+
641
+ // src/lib/submission-lock.ts
642
+ var MUTATION_LOCK_PATH = join3(".overleaf", "mutation.lock");
643
+ function mutationLockPath(root = process.cwd()) {
644
+ const key = createHash("sha256").update(realpathSync2(root), "utf8").digest("hex").slice(0, 32);
645
+ return join3(tmpdir(), `overleaf-review-${key}.lock`);
646
+ }
647
+ function acquireMutationLock(projectId, options = {}) {
648
+ const root = options.root ?? process.cwd();
649
+ const path = options.path ? workspaceWritePath(options.path, root) : mutationLockPath(root);
650
+ const displayPath = options.path ?? path;
651
+ mkdirSync3(dirname2(path), { recursive: true });
652
+ const token = randomUUID();
653
+ let fd;
654
+ try {
655
+ fd = openSync(path, "wx", 384);
656
+ } catch (error) {
657
+ if (error.code !== "EEXIST") throw error;
658
+ let owner = "";
659
+ try {
660
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
661
+ const details = [
662
+ typeof parsed.pid === "number" ? `pid ${parsed.pid}` : void 0,
663
+ typeof parsed.startedAt === "string" ? `since ${parsed.startedAt}` : void 0,
664
+ typeof parsed.projectId === "string" ? `project ${parsed.projectId}` : void 0
665
+ ].filter(Boolean);
666
+ if (details.length) owner = ` (${details.join(", ")})`;
667
+ } catch {
668
+ }
669
+ throw new Error(
670
+ `Another overleaf-review mutation holds ${displayPath}${owner}. Wait for it to finish. If its process crashed, inspect any relevant receipt and live project before removing the lock manually.`,
671
+ { cause: error }
672
+ );
673
+ }
674
+ try {
675
+ writeFileSync3(
676
+ fd,
677
+ `${JSON.stringify(
678
+ {
679
+ token,
680
+ pid: process.pid,
681
+ projectId,
682
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
683
+ },
684
+ null,
685
+ 2
686
+ )}
687
+ `,
688
+ "utf8"
689
+ );
690
+ fsyncSync(fd);
691
+ } catch (error) {
692
+ closeSync(fd);
693
+ if (existsSync2(path)) unlinkSync2(path);
694
+ throw error;
695
+ }
696
+ closeSync(fd);
697
+ let released = false;
698
+ return {
699
+ path,
700
+ token,
701
+ release() {
702
+ if (released) return;
703
+ try {
704
+ const current = JSON.parse(readFileSync3(path, "utf8"));
705
+ if (current.token === token) unlinkSync2(path);
706
+ released = true;
707
+ } catch (error) {
708
+ if (error.code === "ENOENT") {
709
+ released = true;
710
+ return;
711
+ }
712
+ throw error;
713
+ }
714
+ }
715
+ };
716
+ }
717
+
393
718
  // src/commands/pull.ts
394
719
  function buildMemberMap(project) {
395
720
  const map = {};
@@ -401,60 +726,71 @@ function buildMemberMap(project) {
401
726
  for (const m of project?.members ?? []) add(m);
402
727
  return map;
403
728
  }
404
- async function pull(outDir = ".overleaf") {
405
- const { socket, project, docs } = await openProject();
406
- const members = buildMemberMap(project);
407
- const threads = await getThreads();
408
- const comments = [];
409
- const changes = [];
410
- for (const doc of docs) {
411
- const state = await joinDoc(socket, doc._id);
412
- for (const c of state.ranges.comments ?? []) {
413
- const line = offsetToLine(state.lines, c.op.p);
414
- const thread = threads[c.op.t] ?? {};
415
- comments.push({
416
- doc: doc.path,
417
- threadId: c.op.t,
418
- anchor: c.op.c,
419
- line: line + 1,
420
- context: lineContext(state.lines, line),
421
- resolved: Boolean(thread.resolved),
422
- messages: (thread.messages ?? []).map((m) => ({
423
- id: m.id,
424
- author: m.user?.first_name ?? members[m.user_id] ?? m.user_id,
425
- email: m.user?.email,
426
- content: m.content,
427
- timestamp: m.timestamp
428
- }))
429
- });
729
+ async function pull(outDir = ".overleaf", options = {}) {
730
+ let mutationLock;
731
+ let opened;
732
+ try {
733
+ if (options.acquireLock !== false) mutationLock = acquireMutationLock(config.projectId);
734
+ opened = await openProject();
735
+ const { socket, project, docs } = opened;
736
+ const members = buildMemberMap(project);
737
+ const threads = await getThreads();
738
+ const comments = [];
739
+ const changes = [];
740
+ for (const doc of docs) {
741
+ const state = await joinDoc(socket, doc._id);
742
+ for (const c of state.ranges.comments ?? []) {
743
+ const line = offsetToLine(state.lines, c.op.p);
744
+ const thread = threads[c.op.t] ?? {};
745
+ comments.push({
746
+ doc: doc.path,
747
+ threadId: c.op.t,
748
+ anchor: c.op.c,
749
+ line: line + 1,
750
+ context: lineContext(state.lines, line),
751
+ resolved: Boolean(thread.resolved),
752
+ messages: (thread.messages ?? []).map((m) => ({
753
+ id: m.id,
754
+ author: m.user?.first_name ?? members[m.user_id] ?? m.user_id,
755
+ email: m.user?.email,
756
+ content: m.content,
757
+ timestamp: m.timestamp
758
+ }))
759
+ });
760
+ }
761
+ for (const ch of state.ranges.changes ?? []) {
762
+ const isInsert = typeof ch.op.i === "string";
763
+ const line = offsetToLine(state.lines, ch.op.p);
764
+ changes.push({
765
+ doc: doc.path,
766
+ id: ch.id,
767
+ type: isInsert ? "insert" : "delete",
768
+ text: isInsert ? ch.op.i : ch.op.d,
769
+ line: line + 1,
770
+ context: lineContext(state.lines, line),
771
+ author: members[ch.metadata?.user_id] ?? ch.metadata?.user_id ?? "unknown",
772
+ ts: ch.metadata?.ts
773
+ });
774
+ }
430
775
  }
431
- for (const ch of state.ranges.changes ?? []) {
432
- const isInsert = typeof ch.op.i === "string";
433
- const line = offsetToLine(state.lines, ch.op.p);
434
- changes.push({
435
- doc: doc.path,
436
- id: ch.id,
437
- type: isInsert ? "insert" : "delete",
438
- text: isInsert ? ch.op.i : ch.op.d,
439
- line: line + 1,
440
- context: lineContext(state.lines, line),
441
- author: members[ch.metadata?.user_id] ?? ch.metadata?.user_id ?? "unknown",
442
- ts: ch.metadata?.ts
443
- });
776
+ const data = {
777
+ project: project?.name ?? "(unknown)",
778
+ projectId: config.projectId,
779
+ pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
780
+ comments,
781
+ changes
782
+ };
783
+ mkdirSync4(outDir, { recursive: true });
784
+ writeFileSync4(join4(outDir, "reviews.json"), JSON.stringify(data, null, 2) + "\n");
785
+ writeFileSync4(join4(outDir, "reviews.md"), renderMarkdown(data));
786
+ return data;
787
+ } finally {
788
+ try {
789
+ opened?.socket.close();
790
+ } finally {
791
+ mutationLock?.release();
444
792
  }
445
793
  }
446
- socket.close();
447
- const data = {
448
- project: project?.name ?? "(unknown)",
449
- projectId: config.projectId,
450
- pulledAt: (/* @__PURE__ */ new Date()).toISOString(),
451
- comments,
452
- changes
453
- };
454
- mkdirSync3(outDir, { recursive: true });
455
- writeFileSync3(join3(outDir, "reviews.json"), JSON.stringify(data, null, 2) + "\n");
456
- writeFileSync3(join3(outDir, "reviews.md"), renderMarkdown(data));
457
- return data;
458
794
  }
459
795
  function renderMarkdown(d) {
460
796
  const L = [];
@@ -505,343 +841,2740 @@ function renderMarkdown(d) {
505
841
  }
506
842
 
507
843
  // src/commands/push.ts
508
- import { readFileSync as readFileSync3, readdirSync } from "fs";
509
- import { relative, resolve as resolvePath, sep } from "path";
510
- import { randomBytes } from "crypto";
844
+ import {
845
+ mkdirSync as mkdirSync7,
846
+ readFileSync as readFileSync6,
847
+ readdirSync as readdirSync2,
848
+ renameSync as renameSync4,
849
+ statSync,
850
+ unlinkSync as unlinkSync5,
851
+ writeFileSync as writeFileSync7
852
+ } from "fs";
853
+ import { createHash as createHash3 } from "crypto";
854
+ import { dirname as dirname5, relative as relative2, resolve as resolvePath, sep as sep2 } from "path";
511
855
  import { diffWordsWithSpace } from "diff";
512
- function buildOps(remote, local) {
513
- const ops = [];
514
- let p = 0;
515
- for (const part of diffWordsWithSpace(remote, local)) {
516
- if (part.added) {
517
- ops.push({ p, i: part.value });
518
- p += part.value.length;
519
- } else if (part.removed) {
520
- ops.push({ p, d: part.value });
521
- } else {
522
- p += part.value.length;
523
- }
856
+
857
+ // src/lib/document-match.ts
858
+ var AmbiguousDocumentError = class extends Error {
859
+ constructor(identifier, matches) {
860
+ super(
861
+ `Document name "${identifier}" is ambiguous (${matches.map((doc) => doc.path).join(", ")}); use the exact project path.`
862
+ );
863
+ this.name = "AmbiguousDocumentError";
524
864
  }
525
- return ops;
526
- }
527
- function preview(op) {
528
- const kind = op.i != null ? "insert" : "delete";
529
- const text = (op.i ?? op.d ?? "").replace(/\n/g, "\u23CE");
530
- const clip = text.length > 60 ? text.slice(0, 60) + "\u2026" : text;
531
- return ` ${kind.padEnd(6)} @ ${String(op.p).padStart(5)} "${clip}"`;
865
+ };
866
+ function matchDocument(identifier, docs) {
867
+ const exact = docs.filter((doc) => doc.path === identifier);
868
+ if (exact.length > 1) throw new AmbiguousDocumentError(identifier, exact);
869
+ if (exact.length === 1) return exact[0];
870
+ if (identifier.includes("/") || identifier.includes("\\")) return void 0;
871
+ const basename3 = identifier.replace(/\\/g, "/").split("/").pop() ?? identifier;
872
+ const matches = docs.filter((doc) => doc.name === basename3);
873
+ if (matches.length > 1) throw new AmbiguousDocumentError(identifier, matches);
874
+ return matches[0];
532
875
  }
533
- function toOverleafPath(file) {
534
- return relative(process.cwd(), resolvePath(file)).split(sep).join("/");
876
+
877
+ // src/lib/receipts.ts
878
+ import {
879
+ closeSync as closeSync2,
880
+ existsSync as existsSync3,
881
+ fsyncSync as fsyncSync2,
882
+ mkdirSync as mkdirSync5,
883
+ openSync as openSync2,
884
+ readFileSync as readFileSync4,
885
+ readdirSync,
886
+ renameSync as renameSync2,
887
+ unlinkSync as unlinkSync3,
888
+ writeFileSync as writeFileSync5
889
+ } from "fs";
890
+ import { randomUUID as randomUUID2 } from "crypto";
891
+ import { basename, dirname as dirname3, join as join5 } from "path";
892
+ var RECEIPT_SCHEMA_VERSION = 1;
893
+ var DEFAULT_RECEIPTS_DIR = join5(".overleaf", "receipts");
894
+ function safeFilenamePart(value) {
895
+ const safe = value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
896
+ return safe || "operation";
535
897
  }
536
- var IGNORE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".overleaf", "tmp", "dist"]);
537
- function discoverLocalTex(dir = process.cwd(), acc = []) {
538
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
539
- if (entry.name.startsWith(".") || IGNORE_DIRS.has(entry.name)) continue;
540
- const full = resolvePath(dir, entry.name);
541
- if (entry.isDirectory()) discoverLocalTex(full, acc);
542
- else if (entry.name.endsWith(".tex")) acc.push(toOverleafPath(full));
898
+ function writeJsonAtomic(path, value) {
899
+ const targetDir = dirname3(path);
900
+ mkdirSync5(targetDir, { recursive: true });
901
+ const tempPath = join5(
902
+ targetDir,
903
+ `.${basename(path)}.${process.pid}.${randomUUID2()}.tmp`
904
+ );
905
+ let fd;
906
+ try {
907
+ fd = openSync2(tempPath, "wx", 384);
908
+ writeFileSync5(fd, `${JSON.stringify(value, null, 2)}
909
+ `, "utf8");
910
+ fsyncSync2(fd);
911
+ closeSync2(fd);
912
+ fd = void 0;
913
+ renameSync2(tempPath, path);
914
+ let dirFd;
915
+ try {
916
+ dirFd = openSync2(targetDir, "r");
917
+ fsyncSync2(dirFd);
918
+ } catch {
919
+ } finally {
920
+ if (dirFd !== void 0) closeSync2(dirFd);
921
+ }
922
+ } finally {
923
+ if (fd !== void 0) closeSync2(fd);
924
+ if (existsSync3(tempPath)) unlinkSync3(tempPath);
543
925
  }
544
- return acc;
545
926
  }
546
- function pickDoc(file, docName, docs, rootDocId, allowRootFallback) {
547
- if (docName) return docs.find((d) => d.path === docName || d.name === docName);
548
- const rel = toOverleafPath(file);
549
- const base = rel.split("/").pop();
550
- return docs.find((d) => d.path === rel) ?? docs.find((d) => d.name === base) ?? (allowRootFallback ? docs.find((d) => d._id === rootDocId) ?? docs[0] : void 0);
927
+ function beginReceipt(operation, details, options = {}) {
928
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
929
+ const operationId = options.operationId ?? randomUUID2();
930
+ const receipt = {
931
+ schemaVersion: RECEIPT_SCHEMA_VERSION,
932
+ operationId,
933
+ operation,
934
+ status: "started",
935
+ startedAt: now,
936
+ updatedAt: now,
937
+ details
938
+ };
939
+ const dir = options.receiptsDir ?? DEFAULT_RECEIPTS_DIR;
940
+ const timestamp = now.replace(/[:.]/g, "-");
941
+ const path = join5(dir, `${timestamp}-${safeFilenamePart(operation)}-${operationId}.json`);
942
+ writeJsonAtomic(path, receipt);
943
+ return { path, receipt };
551
944
  }
552
- async function push(opts) {
553
- const { socket, project, docs } = await openProject();
554
- const files = opts.file ? [opts.file] : discoverLocalTex();
555
- if (!files.length) {
556
- console.log("No local .tex files found to push.");
557
- socket.close();
558
- return;
945
+ function updateReceipt(handle, status, details, options = {}) {
946
+ const receipt = {
947
+ ...handle.receipt,
948
+ status,
949
+ updatedAt: (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
950
+ details: { ...handle.receipt.details, ...details }
951
+ };
952
+ writeJsonAtomic(handle.path, receipt);
953
+ return { path: handle.path, receipt };
954
+ }
955
+ function readReceipts(receiptsDir = DEFAULT_RECEIPTS_DIR) {
956
+ let names;
957
+ try {
958
+ names = readdirSync(receiptsDir).filter((name) => name.endsWith(".json"));
959
+ } catch {
960
+ return [];
559
961
  }
560
- const plans = [];
561
- for (const file of files) {
562
- let local;
962
+ const receipts = [];
963
+ for (const name of names) {
964
+ const path = join5(receiptsDir, name);
563
965
  try {
564
- local = readFileSync3(file, "utf8");
966
+ const receipt = JSON.parse(readFileSync4(path, "utf8"));
967
+ if (receipt?.schemaVersion === RECEIPT_SCHEMA_VERSION && typeof receipt.operationId === "string" && typeof receipt.operation === "string" && typeof receipt.updatedAt === "string" && receipt.details && typeof receipt.details === "object") {
968
+ receipts.push({ path, receipt });
969
+ }
565
970
  } catch {
566
- console.log(`- ${file}: cannot read, skipped`);
567
- continue;
568
- }
569
- const doc = pickDoc(file, opts.docName, docs, project.rootDoc_id, Boolean(opts.file));
570
- if (!doc) {
571
- console.log(`- ${file}: no matching Overleaf doc, skipped`);
572
- continue;
573
971
  }
574
- const state = await joinDoc(socket, doc._id);
575
- const ops = buildOps(state.lines.join("\n"), local);
576
- if (ops.length) plans.push({ file, doc, ops, version: state.version });
577
972
  }
578
- if (!plans.length) {
579
- console.log("Nothing to push \u2014 local files already match Overleaf.");
580
- socket.close();
581
- return;
973
+ return receipts.sort((a, b) => b.receipt.updatedAt.localeCompare(a.receipt.updatedAt));
974
+ }
975
+
976
+ // src/lib/sync-state.ts
977
+ import { createHash as createHash2 } from "crypto";
978
+ import {
979
+ mkdirSync as mkdirSync6,
980
+ readFileSync as readFileSync5,
981
+ renameSync as renameSync3,
982
+ unlinkSync as unlinkSync4,
983
+ writeFileSync as writeFileSync6
984
+ } from "fs";
985
+ import { dirname as dirname4, join as join6 } from "path";
986
+ var BASE_STATE_SCHEMA_VERSION = 1;
987
+ var BASE_STATE_PATH = join6(".overleaf", "base.json");
988
+ function sha256(text) {
989
+ return createHash2("sha256").update(text, "utf8").digest("hex");
990
+ }
991
+ function canonicalize(value) {
992
+ if (Array.isArray(value)) return value.map(canonicalize);
993
+ if (value && typeof value === "object") {
994
+ const out = {};
995
+ for (const key of Object.keys(value).sort()) {
996
+ const item = value[key];
997
+ if (item !== void 0) out[key] = canonicalize(item);
998
+ }
999
+ return out;
582
1000
  }
583
- console.log(
584
- opts.direct ? "Mode: DIRECT \u2014 plain edits (not marked as suggestions)" : "Mode: SUGGESTIONS \u2014 tracked changes for co-authors to accept/reject"
1001
+ return value;
1002
+ }
1003
+ function stableJson(value) {
1004
+ return JSON.stringify(canonicalize(value));
1005
+ }
1006
+ function sortedRanges(values) {
1007
+ return (values ?? []).map(canonicalize).sort((a, b) => {
1008
+ const left = stableJson(a);
1009
+ const right = stableJson(b);
1010
+ return left < right ? -1 : left > right ? 1 : 0;
1011
+ });
1012
+ }
1013
+ function fingerprintRanges(ranges) {
1014
+ return sha256(
1015
+ stableJson({
1016
+ comments: sortedRanges(ranges.comments),
1017
+ changes: sortedRanges(ranges.changes)
1018
+ })
585
1019
  );
586
- for (const pl of plans) {
587
- const ins = pl.ops.filter((o) => o.i != null).length;
588
- const del = pl.ops.filter((o) => o.d != null).length;
589
- console.log(`
590
- ${pl.file} \u2192 ${pl.doc.path} (v${pl.version}): ${pl.ops.length} op(s), ${ins} ins / ${del} del`);
591
- for (const op of pl.ops.slice(0, 12)) console.log(preview(op));
592
- if (pl.ops.length > 12) console.log(` \u2026 and ${pl.ops.length - 12} more`);
593
- }
594
- if (opts.dryRun) {
595
- console.log("\n(dry run \u2014 nothing sent to Overleaf)");
596
- socket.close();
597
- return;
1020
+ }
1021
+ function assertBaseState(value, path) {
1022
+ if (!value || typeof value !== "object") throw new Error(`Invalid base state in ${path}`);
1023
+ const state = value;
1024
+ if (state.schemaVersion !== BASE_STATE_SCHEMA_VERSION || typeof state.projectId !== "string" || !state.documents || typeof state.documents !== "object") {
1025
+ throw new Error(
1026
+ `Unsupported or invalid base state in ${path}; run fetch to create a new synchronization base.`
1027
+ );
598
1028
  }
599
- socket.on("otUpdateError", (a) => console.log("!! otUpdateError:", JSON.stringify(a)));
600
- for (const pl of plans) {
601
- const meta = opts.direct ? {} : { tc: randomBytes(12).toString("hex") };
602
- const update = { doc: pl.doc._id, op: pl.ops, v: pl.version, meta };
603
- const ack = await socket.emit("applyOtUpdate", [pl.doc._id, update], 2e4);
604
- if (ack?.[0]) {
605
- socket.close();
606
- throw new Error(`Overleaf rejected ${pl.file}: ${JSON.stringify(ack[0])}`);
1029
+ for (const [docId, raw] of Object.entries(state.documents)) {
1030
+ const doc = raw;
1031
+ if (doc.docId !== docId || typeof doc.path !== "string" || typeof doc.text !== "string" || typeof doc.hash !== "string" || doc.hash !== sha256(doc.text)) {
1032
+ throw new Error(`Invalid document ${docId} in ${path}`);
607
1033
  }
608
1034
  }
609
- let allMatch = true;
610
- for (const pl of plans) {
611
- const after = await joinDoc(socket, pl.doc._id);
612
- if (after.lines.join("\n") !== readFileSync3(pl.file, "utf8")) allMatch = false;
613
- }
614
- socket.close();
615
- const totalOps = plans.reduce((n, p) => n + p.ops.length, 0);
616
- const mode = opts.direct ? "direct edit(s)" : "tracked suggestion(s)";
617
- console.log(
618
- `
619
- \u2705 Pushed ${totalOps} ${mode} across ${plans.length} file(s) \u2014 verified match: ${allMatch ? "yes" : "\u26A0\uFE0F NO, inspect"}`
620
- );
621
1035
  }
622
-
623
- // src/commands/fetch.ts
624
- import { writeFileSync as writeFileSync4, readFileSync as readFileSync4, mkdirSync as mkdirSync4, existsSync } from "fs";
625
- import { dirname } from "path";
626
- async function fetchDocs(opts) {
627
- const { socket, docs } = await openProject();
628
- const targets = opts.file ? docs.filter((d) => d.path === opts.file || d.name === opts.file) : docs;
629
- if (!targets.length) {
630
- console.log(`No matching doc for "${opts.file}".`);
631
- socket.close();
632
- return;
1036
+ function loadBaseState(path = BASE_STATE_PATH) {
1037
+ let raw;
1038
+ try {
1039
+ raw = readFileSync5(path, "utf8");
1040
+ } catch (error) {
1041
+ if (error.code === "ENOENT") return void 0;
1042
+ throw error;
633
1043
  }
634
- let changed = 0;
635
- for (const doc of targets) {
636
- const state = await joinDoc(socket, doc._id);
637
- const remote = state.lines.join("\n");
638
- const local = existsSync(doc.path) ? readFileSync4(doc.path, "utf8") : null;
639
- if (local === remote) continue;
640
- changed++;
641
- const delta = local === null ? "(new file)" : `${local.length} \u2192 ${remote.length} chars`;
642
- console.log(` ${doc.path} ${delta}`);
643
- if (!opts.dryRun) {
644
- mkdirSync4(dirname(doc.path), { recursive: true });
645
- writeFileSync4(doc.path, remote);
646
- }
1044
+ let parsed;
1045
+ try {
1046
+ parsed = JSON.parse(raw);
1047
+ } catch {
1048
+ throw new Error(`Invalid JSON in ${path}; run fetch to recreate the synchronization base.`);
647
1049
  }
648
- socket.close();
649
- if (!changed) {
650
- console.log("Already up to date \u2014 local files match Overleaf.");
651
- return;
1050
+ assertBaseState(parsed, path);
1051
+ return parsed;
1052
+ }
1053
+ function saveBaseState(state, path = BASE_STATE_PATH) {
1054
+ mkdirSync6(dirname4(path), { recursive: true });
1055
+ const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
1056
+ try {
1057
+ writeFileSync6(temp, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
1058
+ renameSync3(temp, path);
1059
+ } catch (error) {
1060
+ try {
1061
+ unlinkSync4(temp);
1062
+ } catch {
1063
+ }
1064
+ throw error;
652
1065
  }
653
- console.log(
654
- opts.dryRun ? `
655
- (dry run \u2014 ${changed} local file(s) would be overwritten)` : `
656
- \u2705 Fetched ${changed} file(s) from Overleaf.`
657
- );
1066
+ }
1067
+ function mergeBaseDocuments(projectId, documents, path = BASE_STATE_PATH) {
1068
+ const previous = loadBaseState(path);
1069
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1070
+ const state = {
1071
+ schemaVersion: BASE_STATE_SCHEMA_VERSION,
1072
+ projectId,
1073
+ updatedAt: now,
1074
+ documents: previous?.projectId === projectId ? { ...previous.documents } : {}
1075
+ };
1076
+ for (const doc of documents) state.documents[doc.docId] = doc;
1077
+ saveBaseState(state, path);
1078
+ return state;
658
1079
  }
659
1080
 
660
- // src/commands/upload.ts
661
- import { readFileSync as readFileSync5 } from "fs";
662
- import { basename } from "path";
663
- function findFolder(folder, wanted, prefix = "") {
664
- for (const f of folder?.folders ?? []) {
665
- const path = prefix ? `${prefix}/${f.name}` : f.name;
666
- if (f.name === wanted || path === wanted) return f;
667
- const deeper = findFolder(f, wanted, path);
668
- if (deeper) return deeper;
1081
+ // src/lib/three-way.ts
1082
+ import { diffChars } from "diff";
1083
+ function textEdits(base, target) {
1084
+ const edits = [];
1085
+ let basePos = 0;
1086
+ let pending;
1087
+ const flush = () => {
1088
+ if (!pending) return;
1089
+ if (pending.start !== pending.end || pending.text.length) edits.push(pending);
1090
+ pending = void 0;
1091
+ };
1092
+ for (const part of diffChars(base, target)) {
1093
+ if (!part.added && !part.removed) {
1094
+ flush();
1095
+ basePos += part.value.length;
1096
+ continue;
1097
+ }
1098
+ pending ??= { start: basePos, end: basePos, text: "" };
1099
+ if (part.removed) {
1100
+ pending.end += part.value.length;
1101
+ basePos += part.value.length;
1102
+ } else {
1103
+ pending.text += part.value;
1104
+ }
669
1105
  }
670
- return void 0;
1106
+ flush();
1107
+ return edits;
671
1108
  }
672
- async function upload(paths, folderName) {
673
- const { socket, project } = await openProject();
674
- socket.close();
675
- const root = project?.rootFolder?.[0];
676
- if (!root?._id) throw new Error("could not resolve the project root folder");
677
- let folderId = root._id;
678
- if (folderName) {
679
- const found = findFolder(root, folderName);
680
- if (!found) throw new Error(`folder not found in project: ${folderName}`);
681
- folderId = found._id;
682
- }
683
- const csrf = await getCsrfToken();
684
- for (const path of paths) {
685
- const bytes = readFileSync5(path);
686
- const res = await uploadFile(folderId, basename(path), bytes, csrf);
687
- console.log(`\u2705 Uploaded ${path} \u2192 ${res.entity_type} ${res.entity_id}`);
1109
+ function sameEdit(a, b) {
1110
+ return a.start === b.start && a.end === b.end && a.text === b.text;
1111
+ }
1112
+ function reverseCodePoints(text) {
1113
+ return Array.from(text).reverse().join("");
1114
+ }
1115
+ function ambiguousEditAnchors(base, target) {
1116
+ const forward = textEdits(base, target);
1117
+ const reverse = textEdits(reverseCodePoints(base), reverseCodePoints(target)).map((edit) => ({
1118
+ start: base.length - edit.end,
1119
+ end: base.length - edit.start,
1120
+ text: reverseCodePoints(edit.text)
1121
+ })).sort((a, b) => a.start - b.start || a.end - b.end);
1122
+ const ambiguities = [];
1123
+ const count = Math.max(forward.length, reverse.length);
1124
+ for (let index = 0; index < count; index++) {
1125
+ const forwardEdit = forward[index] ?? reverse[index];
1126
+ const reverseEdit = reverse[index] ?? forward[index];
1127
+ if (sameEdit(forwardEdit, reverseEdit)) continue;
1128
+ ambiguities.push({
1129
+ forward: forwardEdit,
1130
+ reverse: reverseEdit,
1131
+ envelopeStart: Math.min(forwardEdit.start, reverseEdit.start),
1132
+ envelopeEnd: Math.max(
1133
+ forwardEdit.start,
1134
+ forwardEdit.end,
1135
+ reverseEdit.start,
1136
+ reverseEdit.end
1137
+ )
1138
+ });
688
1139
  }
1140
+ return ambiguities;
689
1141
  }
690
-
691
- // src/commands/comment.ts
692
- import { randomBytes as randomBytes2 } from "crypto";
693
- async function comment(opts) {
694
- const { socket, project, docs } = await openProject();
695
- const doc = opts.docName ? docs.find((d) => d.path === opts.docName || d.name === opts.docName) : docs.find((d) => d._id === project.rootDoc_id) ?? docs[0];
696
- if (!doc) {
697
- socket.close();
698
- throw new Error(`doc not found: ${opts.docName ?? "(root)"}`);
699
- }
700
- const state = await joinDoc(socket, doc._id);
701
- const flat = state.lines.join("\n");
702
- const nth = Math.max(1, opts.occurrence ?? 1);
703
- let p = -1;
704
- let from = 0;
705
- for (let i = 0; i < nth; i++) {
706
- p = flat.indexOf(opts.anchor, from);
707
- if (p < 0) break;
708
- from = p + 1;
709
- }
710
- if (p < 0) {
711
- socket.close();
712
- throw new Error(`anchor text not found in ${doc.name}: "${opts.anchor}"`);
713
- }
714
- const threadId = randomBytes2(12).toString("hex");
715
- const update = {
716
- doc: doc._id,
717
- op: [{ p, c: opts.anchor, t: threadId }],
718
- v: state.version,
719
- meta: {}
720
- };
721
- const ack = await socket.emit("applyOtUpdate", [doc._id, update], 15e3);
722
- socket.close();
723
- if (ack?.[0]) throw new Error(`Overleaf rejected the comment op: ${JSON.stringify(ack[0])}`);
724
- const csrf = await getCsrfToken();
725
- await postThreadMessage(threadId, opts.message, csrf);
726
- console.log(`\u2705 Commented on "${opts.anchor}" in ${doc.name} (thread ${threadId})`);
1142
+ function editTouchesEnvelope(edit, start, end) {
1143
+ if (edit.start === edit.end) return edit.start >= start && edit.start <= end;
1144
+ return edit.start <= end && edit.end >= start;
727
1145
  }
728
-
729
- // src/commands/reply.ts
730
- async function reply(threadId, message) {
731
- const csrf = await getCsrfToken();
732
- await postThreadMessage(threadId, message, csrf);
733
- console.log(`\u2705 Replied to thread ${threadId}`);
1146
+ function editsConflict(a, b) {
1147
+ const aInsert = a.start === a.end;
1148
+ const bInsert = b.start === b.end;
1149
+ if (aInsert && bInsert) return a.start === b.start;
1150
+ if (aInsert) return a.start > b.start && a.start < b.end;
1151
+ if (bInsert) return b.start > a.start && b.start < a.end;
1152
+ return a.start < b.end && b.start < a.end;
734
1153
  }
735
-
736
- // src/commands/resolve.ts
737
- async function resolve(threadId, reopen = false) {
738
- const { socket, docs } = await openProject();
739
- let docId;
740
- for (const doc of docs) {
741
- const state = await joinDoc(socket, doc._id);
742
- if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
743
- docId = doc._id;
744
- break;
1154
+ function mapBasePosition(position, liveEdits, includeInsertionAtPosition) {
1155
+ let mapped = position;
1156
+ for (const edit of liveEdits) {
1157
+ if (edit.start === edit.end) {
1158
+ if (edit.start < position || includeInsertionAtPosition && edit.start === position) {
1159
+ mapped += edit.text.length;
1160
+ }
1161
+ continue;
745
1162
  }
1163
+ if (edit.end <= position) mapped += edit.text.length - (edit.end - edit.start);
746
1164
  }
747
- socket.close();
748
- if (!docId) {
749
- throw new Error(
750
- `thread ${threadId} not found in any doc's active comments (already resolved threads may not be locatable this way)`
751
- );
1165
+ return mapped;
1166
+ }
1167
+ function applyEdits(source, edits) {
1168
+ let result = source;
1169
+ const ordered = edits.map((edit, index) => ({ edit, index })).sort(
1170
+ (a, b) => b.edit.start - a.edit.start || b.edit.end - a.edit.end || b.index - a.index
1171
+ );
1172
+ for (const { edit } of ordered) {
1173
+ result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
752
1174
  }
753
- const csrf = await getCsrfToken();
754
- await setThreadResolved(docId, threadId, reopen, csrf);
755
- console.log(`\u2705 Thread ${threadId} ${reopen ? "reopened" : "resolved"}`);
1175
+ return result;
756
1176
  }
757
-
758
- // src/commands/delete-comment.ts
759
- async function deleteComment(threadId) {
760
- const { socket, docs } = await openProject();
761
- let docId;
762
- for (const doc of docs) {
763
- const state = await joinDoc(socket, doc._id);
764
- if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
765
- docId = doc._id;
766
- break;
1177
+ function threeWayMerge(base, local, live) {
1178
+ const localEdits = textEdits(base, local);
1179
+ const liveEdits = textEdits(base, live);
1180
+ const ambiguousAnchors = ambiguousEditAnchors(base, local);
1181
+ const conflicts = [];
1182
+ const alreadyAppliedLocalEdits = [];
1183
+ const toApply = [];
1184
+ for (const localEdit of localEdits) {
1185
+ if (liveEdits.some((liveEdit) => sameEdit(localEdit, liveEdit))) {
1186
+ alreadyAppliedLocalEdits.push(localEdit);
1187
+ continue;
1188
+ }
1189
+ const ambiguity = ambiguousAnchors.find((candidate) => sameEdit(candidate.forward, localEdit));
1190
+ if (ambiguity) {
1191
+ const touching = liveEdits.filter(
1192
+ (liveEdit) => editTouchesEnvelope(liveEdit, ambiguity.envelopeStart, ambiguity.envelopeEnd)
1193
+ );
1194
+ if (touching.length) {
1195
+ for (const liveEdit of touching) {
1196
+ conflicts.push({ local: localEdit, live: liveEdit, reason: "ambiguous-local-anchor" });
1197
+ }
1198
+ continue;
1199
+ }
1200
+ }
1201
+ const overlapping = liveEdits.filter((liveEdit) => editsConflict(localEdit, liveEdit));
1202
+ if (overlapping.length) {
1203
+ for (const liveEdit of overlapping) {
1204
+ conflicts.push({ local: localEdit, live: liveEdit, reason: "overlapping-edits" });
1205
+ }
1206
+ continue;
1207
+ }
1208
+ if (localEdit.start === localEdit.end) {
1209
+ const point = mapBasePosition(localEdit.start, liveEdits, false);
1210
+ toApply.push({ start: point, end: point, text: localEdit.text });
1211
+ } else {
1212
+ const start = mapBasePosition(localEdit.start, liveEdits, true);
1213
+ const end = mapBasePosition(localEdit.end, liveEdits, false);
1214
+ toApply.push({ start, end, text: localEdit.text });
767
1215
  }
768
1216
  }
769
- socket.close();
770
- if (!docId) throw new Error(`thread ${threadId} not found in any doc's comments`);
771
- const csrf = await getCsrfToken();
772
- await deleteThread(docId, threadId, csrf);
773
- console.log(`\u2705 Deleted comment thread ${threadId}`);
1217
+ return {
1218
+ text: conflicts.length ? void 0 : applyEdits(live, toApply),
1219
+ localEdits,
1220
+ liveEdits,
1221
+ appliedLocalEdits: toApply,
1222
+ alreadyAppliedLocalEdits,
1223
+ conflicts
1224
+ };
774
1225
  }
775
1226
 
776
- // src/commands/delete-message.ts
777
- async function deleteThreadMessage(messageId, threadId) {
778
- const csrf = await getCsrfToken();
779
- let tid = threadId;
780
- if (!tid) {
781
- const threads = await getThreads();
782
- for (const [t, v] of Object.entries(threads)) {
783
- if (v.messages?.some((m) => m.id === messageId)) {
784
- tid = t;
785
- break;
1227
+ // src/lib/tracked-overlap.ts
1228
+ function spanOverlapsEdit(start, end, edit) {
1229
+ const editIsPoint = edit.start === edit.end;
1230
+ const rangeIsPoint = start === end;
1231
+ if (editIsPoint && rangeIsPoint) return edit.start === start;
1232
+ if (editIsPoint) return edit.start > start && edit.start < end;
1233
+ if (rangeIsPoint) return start >= edit.start && start < edit.end;
1234
+ return edit.start < end && start < edit.end;
1235
+ }
1236
+ function overlapsEdit(change, edit) {
1237
+ const p = change.op?.p;
1238
+ if (typeof p !== "number") return false;
1239
+ const inserted = typeof change.op?.i === "string" ? change.op.i : void 0;
1240
+ const changeStart = p;
1241
+ const changeEnd = p + (inserted?.length ?? 0);
1242
+ return spanOverlapsEdit(changeStart, changeEnd, edit);
1243
+ }
1244
+ function findTrackedChangeOverlaps(changes, proposedEdits) {
1245
+ const out = [];
1246
+ for (const change of changes ?? []) {
1247
+ for (const proposedEdit of proposedEdits) {
1248
+ if (overlapsEdit(change, proposedEdit)) {
1249
+ out.push({ changeId: change.id ?? "(unknown)", change, proposedEdit });
786
1250
  }
787
1251
  }
788
1252
  }
789
- if (!tid) throw new Error(`message ${messageId} not found in any thread`);
790
- await deleteMessage(tid, messageId, csrf);
791
- console.log(`\u2705 Deleted message ${messageId} from thread ${tid}`);
1253
+ return out;
1254
+ }
1255
+ function findCommentOverlaps(comments, proposedEdits) {
1256
+ const out = [];
1257
+ for (const comment2 of comments ?? []) {
1258
+ const p = comment2.op?.p;
1259
+ const anchor = comment2.op?.c;
1260
+ if (typeof p !== "number" || typeof anchor !== "string") continue;
1261
+ for (const proposedEdit of proposedEdits) {
1262
+ if (spanOverlapsEdit(p, p + anchor.length, proposedEdit)) {
1263
+ out.push({
1264
+ threadId: comment2.op?.t ?? comment2.id ?? "(unknown)",
1265
+ position: p,
1266
+ anchor,
1267
+ comment: comment2,
1268
+ proposedEdit
1269
+ });
1270
+ }
1271
+ }
1272
+ }
1273
+ return out;
1274
+ }
1275
+
1276
+ // src/lib/tracked-changes.ts
1277
+ import { randomBytes } from "crypto";
1278
+ var TRACKED_CHANGE_SEED_BYTES = 9;
1279
+ function createTrackedChangeSeed(bytes = randomBytes) {
1280
+ const entropy = bytes(TRACKED_CHANGE_SEED_BYTES);
1281
+ if (entropy.byteLength !== TRACKED_CHANGE_SEED_BYTES) {
1282
+ throw new Error(
1283
+ `tracked-change seed source returned ${entropy.byteLength} bytes; expected ${TRACKED_CHANGE_SEED_BYTES}`
1284
+ );
1285
+ }
1286
+ return Buffer.from(entropy).toString("hex");
1287
+ }
1288
+ var TrackedChangeMutationError = class extends Error {
1289
+ constructor(message, result, cause) {
1290
+ super(message, cause === void 0 ? void 0 : { cause });
1291
+ this.result = result;
1292
+ this.name = "TrackedChangeMutationError";
1293
+ }
1294
+ result;
1295
+ };
1296
+ function uniqueChangeIds(changeIds) {
1297
+ return [...new Set(changeIds)];
1298
+ }
1299
+ function changeIdsInRanges(ranges) {
1300
+ return [...new Set(ranges.map((range) => range.id))];
1301
+ }
1302
+ function remainingChangeIds(ranges, requestedIds) {
1303
+ const present = new Set(changeIdsInRanges(ranges));
1304
+ return uniqueChangeIds(requestedIds).filter((id) => present.has(id));
1305
+ }
1306
+ function inverseOf(range) {
1307
+ const { p, i, d } = range.op ?? {};
1308
+ if (!Number.isSafeInteger(p) || p < 0) {
1309
+ throw new Error(`tracked change ${range.id} has an invalid position: ${String(p)}`);
1310
+ }
1311
+ if (typeof i === "string" && d === void 0) return { p, d: i, u: true };
1312
+ if (typeof d === "string" && i === void 0) return { p, i: d, u: true };
1313
+ throw new Error(`tracked change ${range.id} does not contain exactly one insert/delete op`);
1314
+ }
1315
+ function applyUndo(text, op, changeId) {
1316
+ if (op.p > text.length) {
1317
+ throw new Error(
1318
+ `tracked change ${changeId} starts at ${op.p}, beyond document length ${text.length}`
1319
+ );
1320
+ }
1321
+ if ("d" in op) {
1322
+ const actual = text.slice(op.p, op.p + op.d.length);
1323
+ if (actual !== op.d) {
1324
+ throw new Error(
1325
+ `tracked insertion ${changeId} no longer matches document text at ${op.p}: expected ${JSON.stringify(op.d)}, found ${JSON.stringify(actual)}`
1326
+ );
1327
+ }
1328
+ return text.slice(0, op.p) + text.slice(op.p + op.d.length);
1329
+ }
1330
+ return text.slice(0, op.p) + op.i + text.slice(op.p);
1331
+ }
1332
+ function buildRejectionPlan(currentText, ranges, requestedIds) {
1333
+ const requested = new Set(uniqueChangeIds(requestedIds));
1334
+ const fragments = ranges.filter((range) => requested.has(range.id));
1335
+ fragments.sort((a, b) => b.op.p - a.op.p);
1336
+ const operations = [];
1337
+ let expectedText = currentText;
1338
+ for (const range of fragments) {
1339
+ const inverse = inverseOf(range);
1340
+ expectedText = applyUndo(expectedText, inverse, range.id);
1341
+ operations.push(inverse);
1342
+ }
1343
+ return {
1344
+ changeIds: changeIdsInRanges(fragments),
1345
+ fragmentCount: fragments.length,
1346
+ operations,
1347
+ expectedText
1348
+ };
1349
+ }
1350
+
1351
+ // src/lib/snapshots.ts
1352
+ import { join as join7 } from "path";
1353
+ var SNAPSHOTS_DIR = join7(".overleaf", "snapshots");
1354
+ function snapshotTimestamp(date = /* @__PURE__ */ new Date()) {
1355
+ return date.toISOString().replace(/[:.]/g, "-");
1356
+ }
1357
+ function snapshotRelativePath(timestamp, projectPath, root = process.cwd()) {
1358
+ if (!/^[0-9TZ-]+$/.test(timestamp)) throw new Error(`Invalid snapshot timestamp: ${timestamp}`);
1359
+ const safeProjectPath = workspaceRelativePath(projectPath, root);
1360
+ return workspaceRelativePath(join7(SNAPSHOTS_DIR, timestamp, safeProjectPath), root);
1361
+ }
1362
+
1363
+ // src/commands/push.ts
1364
+ var PUSH_PLAN_SCHEMA_VERSION = 2;
1365
+ var PUSH_PLAN_KIND = "overleaf-review-push-plan";
1366
+ var PushSubmissionError = class extends Error {
1367
+ constructor(message, receiptPath, status, documents, cause) {
1368
+ super(message, cause === void 0 ? void 0 : { cause });
1369
+ this.receiptPath = receiptPath;
1370
+ this.status = status;
1371
+ this.documents = documents;
1372
+ this.name = "PushSubmissionError";
1373
+ }
1374
+ receiptPath;
1375
+ status;
1376
+ documents;
1377
+ };
1378
+ var PushPlanningError = class extends Error {
1379
+ constructor(message, conflicts = [], overlaps = []) {
1380
+ super(message);
1381
+ this.conflicts = conflicts;
1382
+ this.overlaps = overlaps;
1383
+ this.name = "PushPlanningError";
1384
+ }
1385
+ conflicts;
1386
+ overlaps;
1387
+ };
1388
+ var PushPlanValidationError = class extends Error {
1389
+ constructor(message) {
1390
+ super(message);
1391
+ this.name = "PushPlanValidationError";
1392
+ }
1393
+ };
1394
+ function validatePushOptions(opts) {
1395
+ if (opts.docName && !opts.file) {
1396
+ throw new Error("--doc requires --file; bulk pushes cannot map multiple local files to one document.");
1397
+ }
1398
+ }
1399
+ function buildOps(source, target) {
1400
+ const ops = [];
1401
+ let p = 0;
1402
+ for (const part of diffWordsWithSpace(source, target)) {
1403
+ if (part.added) {
1404
+ ops.push({ p, i: part.value });
1405
+ p += part.value.length;
1406
+ } else if (part.removed) {
1407
+ ops.push({ p, d: part.value });
1408
+ } else {
1409
+ p += part.value.length;
1410
+ }
1411
+ }
1412
+ const rebuilt = applyOps(source, ops);
1413
+ if (rebuilt !== target) {
1414
+ throw new Error("internal error: generated OT operations do not reconstruct target text");
1415
+ }
1416
+ return ops;
1417
+ }
1418
+ function buildOperationFootprint(source, target) {
1419
+ const edits = [];
1420
+ let sourcePos = 0;
1421
+ let pending;
1422
+ const flush = () => {
1423
+ if (!pending) return;
1424
+ if (pending.start !== pending.end || pending.text.length) edits.push(pending);
1425
+ pending = void 0;
1426
+ };
1427
+ for (const part of diffWordsWithSpace(source, target)) {
1428
+ if (!part.added && !part.removed) {
1429
+ flush();
1430
+ sourcePos += part.value.length;
1431
+ continue;
1432
+ }
1433
+ pending ??= { start: sourcePos, end: sourcePos, text: "" };
1434
+ if (part.removed) {
1435
+ pending.end += part.value.length;
1436
+ sourcePos += part.value.length;
1437
+ } else {
1438
+ pending.text += part.value;
1439
+ }
1440
+ }
1441
+ flush();
1442
+ return edits;
1443
+ }
1444
+ function applyOps(source, ops) {
1445
+ let text = source;
1446
+ for (const op of ops) {
1447
+ if (!Number.isSafeInteger(op.p) || op.p < 0 || op.p > text.length) {
1448
+ throw new Error(`invalid operation position ${String(op.p)} for ${text.length}-character text`);
1449
+ }
1450
+ const hasInsert = typeof op.i === "string";
1451
+ const hasDelete = typeof op.d === "string";
1452
+ if (hasInsert === hasDelete) throw new Error("operation must contain exactly one of i or d");
1453
+ if (hasInsert) {
1454
+ text = text.slice(0, op.p) + op.i + text.slice(op.p);
1455
+ } else {
1456
+ const deletion = op.d;
1457
+ const actual = text.slice(op.p, op.p + deletion.length);
1458
+ if (actual !== deletion) {
1459
+ throw new Error(
1460
+ `delete operation mismatch at ${op.p}: expected ${JSON.stringify(deletion)}, found ${JSON.stringify(actual)}`
1461
+ );
1462
+ }
1463
+ text = text.slice(0, op.p) + text.slice(op.p + deletion.length);
1464
+ }
1465
+ }
1466
+ return text;
1467
+ }
1468
+ function preview(op) {
1469
+ const kind = op.i != null ? "insert" : "delete";
1470
+ const text = (op.i ?? op.d ?? "").replace(/\n/g, "\u23CE");
1471
+ const clip = text.length > 60 ? text.slice(0, 60) + "\u2026" : text;
1472
+ return ` ${kind.padEnd(6)} @ ${String(op.p).padStart(5)} "${clip}"`;
1473
+ }
1474
+ function toOverleafPath(file) {
1475
+ return workspaceRelativePath(file);
1476
+ }
1477
+ var IGNORE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".overleaf", "tmp", "dist"]);
1478
+ function discoverLocalTex(dir = process.cwd(), acc = []) {
1479
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
1480
+ if (entry.name.startsWith(".") || IGNORE_DIRS.has(entry.name)) continue;
1481
+ const full = resolvePath(dir, entry.name);
1482
+ if (entry.isDirectory()) discoverLocalTex(full, acc);
1483
+ else if (entry.name.endsWith(".tex")) {
1484
+ acc.push(relative2(process.cwd(), full).split(sep2).join("/"));
1485
+ }
1486
+ }
1487
+ return acc;
1488
+ }
1489
+ function pickDoc(file, docName, docs) {
1490
+ return matchDocument(docName?.replace(/\\/g, "/") ?? toOverleafPath(file), docs);
1491
+ }
1492
+ function formatConflicts(conflicts) {
1493
+ return conflicts.flatMap(
1494
+ ({ docPath, conflicts: items }) => items.map(
1495
+ ({ local, live, reason }) => reason === "ambiguous-local-anchor" ? `${docPath}: repeated-text anchor for local [${local.start},${local.end}) is ambiguous and its envelope is touched by live [${live.start},${live.end}); refresh and reapply this edit explicitly` : `${docPath}: local [${local.start},${local.end}) overlaps live [${live.start},${live.end})`
1496
+ )
1497
+ ).join("\n ");
1498
+ }
1499
+ function serializeOverlaps(overlaps) {
1500
+ return overlaps.map(({ changeId, proposedEdit, change }) => ({
1501
+ changeId,
1502
+ proposedEdit,
1503
+ trackedOp: {
1504
+ p: change.op?.p,
1505
+ i: change.op?.i,
1506
+ d: change.op?.d
1507
+ }
1508
+ })).sort((a, b) => {
1509
+ const left = stableJson(a);
1510
+ const right = stableJson(b);
1511
+ return left < right ? -1 : left > right ? 1 : 0;
1512
+ });
1513
+ }
1514
+ function serializeActiveTrackedRanges(changes) {
1515
+ const ranges = (changes ?? []).map((change, index) => {
1516
+ const id = change?.id;
1517
+ const p = change?.op?.p;
1518
+ const hasInsert = typeof change?.op?.i === "string";
1519
+ const hasDelete = typeof change?.op?.d === "string";
1520
+ if (typeof id !== "string" || !Number.isSafeInteger(p) || p < 0 || hasInsert === hasDelete) {
1521
+ throw new Error(`Overleaf returned an invalid tracked range at index ${index}`);
1522
+ }
1523
+ return {
1524
+ id,
1525
+ op: {
1526
+ p,
1527
+ ...hasInsert ? { i: change.op.i } : { d: change.op.d }
1528
+ },
1529
+ ...change.metadata && typeof change.metadata === "object" ? { metadata: change.metadata } : {}
1530
+ };
1531
+ });
1532
+ return ranges.sort((a, b) => {
1533
+ const left = stableJson(a);
1534
+ const right = stableJson(b);
1535
+ return left < right ? -1 : left > right ? 1 : 0;
1536
+ });
1537
+ }
1538
+ function serializeCommentOverlaps(overlaps) {
1539
+ return overlaps.map(({ threadId, position, anchor, proposedEdit }) => ({
1540
+ threadId,
1541
+ position,
1542
+ anchor,
1543
+ proposedEdit
1544
+ })).sort((a, b) => {
1545
+ const left = stableJson(a);
1546
+ const right = stableJson(b);
1547
+ return left < right ? -1 : left > right ? 1 : 0;
1548
+ });
1549
+ }
1550
+ async function createPlan(opts = {}) {
1551
+ validatePushOptions(opts);
1552
+ if (opts.plan) throw new Error("createPlan does not accept an existing plan");
1553
+ const basePath = opts.basePath ?? BASE_STATE_PATH;
1554
+ const baseState = loadBaseState(basePath);
1555
+ if (baseState && baseState.projectId !== config.projectId && !opts.unsafeNoBase) {
1556
+ throw new PushPlanningError(
1557
+ `Saved base belongs to project ${baseState.projectId}, not ${config.projectId}; run fetch first.`
1558
+ );
1559
+ }
1560
+ const { socket, project, docs } = await openProject();
1561
+ try {
1562
+ const projectId = String(project?._id ?? config.projectId);
1563
+ if (projectId !== config.projectId) {
1564
+ throw new PushPlanningError(
1565
+ `Connected project id ${projectId} does not match configured project ${config.projectId}.`
1566
+ );
1567
+ }
1568
+ const files = opts.file ? [workspaceRelativePath(opts.file)] : discoverLocalTex();
1569
+ if (!files.length) {
1570
+ const emptyPlan = {
1571
+ kind: PUSH_PLAN_KIND,
1572
+ schemaVersion: PUSH_PLAN_SCHEMA_VERSION,
1573
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1574
+ projectId,
1575
+ projectName: String(project?.name ?? "(unknown)"),
1576
+ direct: Boolean(opts.direct),
1577
+ unsafeNoBase: Boolean(opts.unsafeNoBase),
1578
+ allowOverlap: Boolean(opts.allowOverlap),
1579
+ documents: []
1580
+ };
1581
+ if (opts.planOut) writePushPlan(emptyPlan, opts.planOut);
1582
+ return emptyPlan;
1583
+ }
1584
+ const documents = [];
1585
+ const conflicts = [];
1586
+ const blockedOverlaps = [];
1587
+ for (const file of files) {
1588
+ let local;
1589
+ try {
1590
+ local = readFileSync6(workspaceReadPath(file), "utf8");
1591
+ } catch (error) {
1592
+ throw new Error(`Cannot safely read ${file}: ${error.message}`);
1593
+ }
1594
+ const doc = pickDoc(file, opts.docName, docs);
1595
+ if (!doc) {
1596
+ throw new Error(
1597
+ `No Overleaf document matches ${file}; push it with --file and --doc using the exact project path.`
1598
+ );
1599
+ }
1600
+ const state = await joinDoc(socket, doc._id);
1601
+ const live = state.lines.join("\n");
1602
+ const savedBase = baseState?.projectId === projectId ? baseState.documents[doc._id] : void 0;
1603
+ if (!savedBase && !opts.unsafeNoBase) {
1604
+ throw new PushPlanningError(
1605
+ `No saved synchronization base for ${doc.path}. Run fetch first, or explicitly use \`--unsafe-no-base\` to request legacy two-way behavior.`
1606
+ );
1607
+ }
1608
+ const base = savedBase?.text ?? live;
1609
+ const merge = threeWayMerge(base, local, live);
1610
+ if (merge.conflicts.length) {
1611
+ conflicts.push({ localPath: file, docPath: doc.path, conflicts: merge.conflicts });
1612
+ continue;
1613
+ }
1614
+ const expected = merge.text;
1615
+ const ops = buildOps(live, expected);
1616
+ if (!ops.length) continue;
1617
+ const proposedEdits = buildOperationFootprint(live, expected);
1618
+ const activeTrackedRanges = serializeActiveTrackedRanges(state.ranges.changes);
1619
+ const overlaps = serializeOverlaps(
1620
+ findTrackedChangeOverlaps(state.ranges.changes, proposedEdits)
1621
+ );
1622
+ const commentOverlaps = serializeCommentOverlaps(
1623
+ findCommentOverlaps(state.ranges.comments, proposedEdits)
1624
+ );
1625
+ if (overlaps.length && !opts.allowOverlap) {
1626
+ blockedOverlaps.push({ localPath: file, docPath: doc.path, overlaps });
1627
+ continue;
1628
+ }
1629
+ documents.push({
1630
+ localPath: toOverleafPath(file),
1631
+ docId: doc._id,
1632
+ docPath: doc.path,
1633
+ baseSource: savedBase ? "saved" : "live-unsafe",
1634
+ baseHash: sha256(base),
1635
+ localHash: sha256(local),
1636
+ liveHash: sha256(live),
1637
+ liveVersion: state.version,
1638
+ rangeFingerprint: fingerprintRanges(state.ranges),
1639
+ ops,
1640
+ expectedHash: sha256(expected),
1641
+ tcSeed: opts.direct ? null : createTrackedChangeSeed(),
1642
+ activeTrackedRanges,
1643
+ trackedChangeOverlaps: overlaps,
1644
+ commentOverlaps
1645
+ });
1646
+ }
1647
+ if (conflicts.length || blockedOverlaps.length) {
1648
+ const parts = [];
1649
+ if (conflicts.length) parts.push(`Concurrent edit conflicts:
1650
+ ${formatConflicts(conflicts)}`);
1651
+ if (blockedOverlaps.length) {
1652
+ const lines = blockedOverlaps.flatMap(
1653
+ ({ docPath, overlaps }) => overlaps.map(
1654
+ ({ changeId, proposedEdit }) => `${docPath}: proposed [${proposedEdit.start},${proposedEdit.end}) overlaps change ${changeId}`
1655
+ )
1656
+ );
1657
+ parts.push(
1658
+ `Active tracked-change overlaps:
1659
+ ${lines.join("\n ")}
1660
+ Re-plan with --allow-overlap only after inspecting these changes.`
1661
+ );
1662
+ }
1663
+ throw new PushPlanningError(parts.join("\n\n"), conflicts, blockedOverlaps);
1664
+ }
1665
+ const plan = {
1666
+ kind: PUSH_PLAN_KIND,
1667
+ schemaVersion: PUSH_PLAN_SCHEMA_VERSION,
1668
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1669
+ projectId,
1670
+ projectName: String(project?.name ?? "(unknown)"),
1671
+ direct: Boolean(opts.direct),
1672
+ unsafeNoBase: documents.some((doc) => doc.baseSource === "live-unsafe"),
1673
+ allowOverlap: Boolean(opts.allowOverlap),
1674
+ documents
1675
+ };
1676
+ if (opts.planOut) writePushPlan(plan, opts.planOut);
1677
+ return plan;
1678
+ } finally {
1679
+ socket.close();
1680
+ }
1681
+ }
1682
+ function assertHash(value, field) {
1683
+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) {
1684
+ throw new PushPlanValidationError(`Invalid ${field} in push plan`);
1685
+ }
1686
+ }
1687
+ function validatePushPlan(value) {
1688
+ if (!value || typeof value !== "object") throw new PushPlanValidationError("Push plan is not an object");
1689
+ const plan = value;
1690
+ if (plan.kind !== PUSH_PLAN_KIND || plan.schemaVersion !== PUSH_PLAN_SCHEMA_VERSION) {
1691
+ throw new PushPlanValidationError("Unsupported push-plan kind or schema version");
1692
+ }
1693
+ if (typeof plan.projectId !== "string" || typeof plan.projectName !== "string" || typeof plan.createdAt !== "string" || typeof plan.direct !== "boolean" || typeof plan.unsafeNoBase !== "boolean" || typeof plan.allowOverlap !== "boolean" || !Array.isArray(plan.documents)) {
1694
+ throw new PushPlanValidationError("Push plan is missing required fields");
1695
+ }
1696
+ const seen = /* @__PURE__ */ new Set();
1697
+ const seenLocalPaths = /* @__PURE__ */ new Set();
1698
+ for (const doc of plan.documents) {
1699
+ if (!doc || typeof doc.localPath !== "string" || typeof doc.docId !== "string" || typeof doc.docPath !== "string" || doc.baseSource !== "saved" && doc.baseSource !== "live-unsafe" || !Number.isSafeInteger(doc.liveVersion) || doc.liveVersion < 0 || !Array.isArray(doc.ops) || doc.ops.length === 0 || !Array.isArray(doc.activeTrackedRanges) || !Array.isArray(doc.trackedChangeOverlaps) || !Array.isArray(doc.commentOverlaps)) {
1700
+ throw new PushPlanValidationError("Push plan contains an invalid document");
1701
+ }
1702
+ try {
1703
+ if (workspaceRelativePath(doc.localPath) !== doc.localPath || workspaceRelativePath(doc.docPath) !== doc.docPath) {
1704
+ throw new Error("path is not normalized");
1705
+ }
1706
+ } catch (error) {
1707
+ throw new PushPlanValidationError(
1708
+ `Unsafe or invalid path in push plan: ${error.message}`
1709
+ );
1710
+ }
1711
+ if (seen.has(doc.docId)) throw new PushPlanValidationError(`Duplicate document ${doc.docId} in plan`);
1712
+ seen.add(doc.docId);
1713
+ if (seenLocalPaths.has(doc.localPath)) {
1714
+ throw new PushPlanValidationError(`Duplicate local path ${doc.localPath} in plan`);
1715
+ }
1716
+ seenLocalPaths.add(doc.localPath);
1717
+ assertHash(doc.baseHash, "baseHash");
1718
+ assertHash(doc.localHash, "localHash");
1719
+ assertHash(doc.liveHash, "liveHash");
1720
+ assertHash(doc.rangeFingerprint, "rangeFingerprint");
1721
+ assertHash(doc.expectedHash, "expectedHash");
1722
+ for (const op of doc.ops) {
1723
+ if (!op || !Number.isSafeInteger(op.p) || op.p < 0 || typeof op.i === "string" === (typeof op.d === "string") || typeof (op.i ?? op.d) === "string" && (op.i ?? op.d).length === 0) {
1724
+ throw new PushPlanValidationError(`Invalid operation in ${doc.docPath}`);
1725
+ }
1726
+ }
1727
+ for (const range of doc.activeTrackedRanges) {
1728
+ if (!range || typeof range.id !== "string" || !Number.isSafeInteger(range.op?.p) || range.op.p < 0 || typeof range.op.i === "string" === (typeof range.op.d === "string") || range.metadata !== void 0 && (!range.metadata || typeof range.metadata !== "object")) {
1729
+ throw new PushPlanValidationError(`Invalid active tracked range in ${doc.docPath}`);
1730
+ }
1731
+ }
1732
+ for (const overlap of doc.trackedChangeOverlaps) {
1733
+ if (!overlap || typeof overlap.changeId !== "string" || !validTextEdit(overlap.proposedEdit) || !Number.isSafeInteger(overlap.trackedOp?.p)) {
1734
+ throw new PushPlanValidationError(`Invalid tracked-change overlap in ${doc.docPath}`);
1735
+ }
1736
+ }
1737
+ for (const overlap of doc.commentOverlaps) {
1738
+ if (!overlap || typeof overlap.threadId !== "string" || !Number.isSafeInteger(overlap.position) || overlap.position < 0 || typeof overlap.anchor !== "string" || !validTextEdit(overlap.proposedEdit)) {
1739
+ throw new PushPlanValidationError(`Invalid comment overlap in ${doc.docPath}`);
1740
+ }
1741
+ }
1742
+ if (plan.direct) {
1743
+ if (doc.tcSeed !== null) throw new PushPlanValidationError("Direct plan must not contain tcSeed");
1744
+ } else if (typeof doc.tcSeed !== "string" || !/^[0-9a-f]{18}$/.test(doc.tcSeed)) {
1745
+ throw new PushPlanValidationError(`Invalid tracked-change seed in ${doc.docPath}`);
1746
+ }
1747
+ if (!plan.allowOverlap && doc.trackedChangeOverlaps.length) {
1748
+ throw new PushPlanValidationError("Plan contains blocked tracked-change overlaps");
1749
+ }
1750
+ if (doc.baseSource === "live-unsafe" && doc.baseHash !== doc.liveHash) {
1751
+ throw new PushPlanValidationError(`Unsafe base must equal planned live text in ${doc.docPath}`);
1752
+ }
1753
+ }
1754
+ return plan;
1755
+ }
1756
+ function validTextEdit(value) {
1757
+ if (!value || typeof value !== "object") return false;
1758
+ const edit = value;
1759
+ return Boolean(
1760
+ Number.isSafeInteger(edit.start) && Number.isSafeInteger(edit.end) && edit.start >= 0 && edit.end >= edit.start && typeof edit.text === "string"
1761
+ );
1762
+ }
1763
+ function readPushPlan(path, workspaceRoot = process.cwd()) {
1764
+ let parsed;
1765
+ try {
1766
+ parsed = JSON.parse(readFileSync6(workspaceReadPath(path, workspaceRoot), "utf8"));
1767
+ } catch (error) {
1768
+ throw new PushPlanValidationError(`Cannot read push plan ${path}: ${error.message}`);
1769
+ }
1770
+ return validatePushPlan(parsed);
1771
+ }
1772
+ function writePushPlan(plan, path, workspaceRoot = process.cwd()) {
1773
+ validatePushPlan(plan);
1774
+ const target = workspaceWritePath(path, workspaceRoot);
1775
+ mkdirSync7(dirname5(target), { recursive: true });
1776
+ const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
1777
+ try {
1778
+ writeFileSync7(temp, JSON.stringify(plan, null, 2) + "\n", { mode: 384 });
1779
+ renameSync4(temp, target);
1780
+ } catch (error) {
1781
+ try {
1782
+ unlinkSync5(temp);
1783
+ } catch {
1784
+ }
1785
+ throw error;
1786
+ }
1787
+ }
1788
+ function trackedChangeIdsForSeed(seed, count) {
1789
+ return Array.from(
1790
+ { length: count },
1791
+ (_, index) => `${seed}${(index + 1).toString(16).padStart(6, "0")}`
1792
+ );
1793
+ }
1794
+ function errorMessage(error) {
1795
+ return error instanceof Error ? error.message : String(error);
1796
+ }
1797
+ function overleafSnapshotHash(text) {
1798
+ return createHash3("sha1").update(`blob ${text.length}\0`, "utf8").update(text, "utf8").digest("hex");
1799
+ }
1800
+ function validatePlannedIntent(planned, base, local, live) {
1801
+ if (sha256(base) !== planned.baseHash) {
1802
+ throw new PushPlanValidationError(`Synchronization base changed for ${planned.docPath}.`);
1803
+ }
1804
+ if (sha256(local) !== planned.localHash) {
1805
+ throw new PushPlanValidationError(`${planned.localPath} changed after planning.`);
1806
+ }
1807
+ if (sha256(live) !== planned.liveHash) {
1808
+ throw new PushPlanValidationError(`${planned.docPath} text changed after planning.`);
1809
+ }
1810
+ const merge = threeWayMerge(base, local, live);
1811
+ if (merge.conflicts.length || merge.text === void 0) {
1812
+ throw new PushPlanValidationError(
1813
+ `${planned.docPath} no longer has the conflict-free intent recorded by the plan.`
1814
+ );
1815
+ }
1816
+ const expected = merge.text;
1817
+ if (stableJson(buildOps(live, expected)) !== stableJson(planned.ops) || sha256(expected) !== planned.expectedHash) {
1818
+ throw new PushPlanValidationError(
1819
+ `Operations in ${planned.docPath} do not match its saved Base\u2192Local intent.`
1820
+ );
1821
+ }
1822
+ return expected;
1823
+ }
1824
+ function validateReviewBinding(planned, state, live, expected, allowOverlap) {
1825
+ if (state.version !== planned.liveVersion) {
1826
+ throw new PushPlanValidationError(
1827
+ `${planned.docPath} version changed from ${planned.liveVersion} to ${state.version}; create a new plan.`
1828
+ );
1829
+ }
1830
+ if (fingerprintRanges(state.ranges) !== planned.rangeFingerprint) {
1831
+ throw new PushPlanValidationError(
1832
+ `${planned.docPath} comments or tracked ranges changed after planning; create a new plan.`
1833
+ );
1834
+ }
1835
+ const footprint = buildOperationFootprint(live, expected);
1836
+ const activeTrackedRanges = serializeActiveTrackedRanges(state.ranges.changes);
1837
+ const trackedOverlaps = serializeOverlaps(
1838
+ findTrackedChangeOverlaps(state.ranges.changes, footprint)
1839
+ );
1840
+ const commentOverlaps = serializeCommentOverlaps(
1841
+ findCommentOverlaps(state.ranges.comments, footprint)
1842
+ );
1843
+ if (stableJson(activeTrackedRanges) !== stableJson(planned.activeTrackedRanges)) {
1844
+ throw new PushPlanValidationError(
1845
+ `Active tracked-range data is invalid for ${planned.docPath}; create a new plan.`
1846
+ );
1847
+ }
1848
+ if (stableJson(trackedOverlaps) !== stableJson(planned.trackedChangeOverlaps)) {
1849
+ throw new PushPlanValidationError(
1850
+ `Tracked-change overlap data is invalid for ${planned.docPath}; create a new plan.`
1851
+ );
1852
+ }
1853
+ if (trackedOverlaps.length && !allowOverlap) {
1854
+ throw new PushPlanValidationError(
1855
+ `${planned.docPath} operations overlap active tracked changes; create a plan with --allow-overlap only after inspecting them.`
1856
+ );
1857
+ }
1858
+ if (stableJson(commentOverlaps) !== stableJson(planned.commentOverlaps)) {
1859
+ throw new PushPlanValidationError(
1860
+ `Comment-overlap data is invalid for ${planned.docPath}; create a new plan.`
1861
+ );
1862
+ }
1863
+ }
1864
+ function baseTextForPlan(plan, planned, live, basePath) {
1865
+ if (planned.baseSource === "live-unsafe") {
1866
+ if (!plan.unsafeNoBase) {
1867
+ throw new PushPlanValidationError(`${planned.docPath} uses an unauthorized unsafe base.`);
1868
+ }
1869
+ return live;
1870
+ }
1871
+ const state = loadBaseState(basePath);
1872
+ const saved = state?.projectId === plan.projectId ? state.documents[planned.docId] : void 0;
1873
+ if (!saved || saved.hash !== planned.baseHash || sha256(saved.text) !== planned.baseHash) {
1874
+ throw new PushPlanValidationError(
1875
+ `Synchronization base for ${planned.docPath} changed after planning; create a new plan.`
1876
+ );
1877
+ }
1878
+ return saved.text;
1879
+ }
1880
+ async function bindPlanDocument(plan, planned, socket, basePath) {
1881
+ let local;
1882
+ try {
1883
+ local = readFileSync6(workspaceReadPath(planned.localPath), "utf8");
1884
+ } catch (error) {
1885
+ throw new PushPlanValidationError(
1886
+ `Cannot read planned local file ${planned.localPath}: ${errorMessage(error)}`
1887
+ );
1888
+ }
1889
+ const state = await joinDoc(socket, planned.docId);
1890
+ const live = state.lines.join("\n");
1891
+ const base = baseTextForPlan(plan, planned, live, basePath);
1892
+ const expected = validatePlannedIntent(planned, base, local, live);
1893
+ validateReviewBinding(planned, state, live, expected, plan.allowOverlap);
1894
+ return { plan: planned, state, expected };
1895
+ }
1896
+ function verifiedTrackedIds(plan, planned, after) {
1897
+ if (plan.direct) return [];
1898
+ const actualIds = new Set((after.ranges.changes ?? []).map((change) => String(change.id)));
1899
+ if (planned.trackedChangeOverlaps.length) {
1900
+ const ids = [...actualIds].filter(
1901
+ (id) => new RegExp(`^${planned.tcSeed}[0-9a-f]{6}$`).test(id)
1902
+ );
1903
+ if (!ids.length) {
1904
+ throw new Error(
1905
+ `Verification failed for ${planned.docPath}: no tracked ranges with seed ${planned.tcSeed} were created.`
1906
+ );
1907
+ }
1908
+ return ids;
1909
+ }
1910
+ const expectedIds = trackedChangeIdsForSeed(planned.tcSeed, planned.ops.length);
1911
+ const missing = expectedIds.filter((id) => !actualIds.has(id));
1912
+ if (missing.length) {
1913
+ throw new Error(
1914
+ `Verification failed for ${planned.docPath}: tracked ranges were not created for ${missing.join(", ")}.`
1915
+ );
1916
+ }
1917
+ return expectedIds;
1918
+ }
1919
+ function definitelyRejectedApply(error) {
1920
+ const message = errorMessage(error);
1921
+ return message.includes("Overleaf rejected the OT update") || message.includes("Overleaf failed to apply the OT update");
1922
+ }
1923
+ function initialReceiptDocuments(plan) {
1924
+ return plan.documents.map((doc) => ({
1925
+ docId: doc.docId,
1926
+ docPath: doc.docPath,
1927
+ localPath: doc.localPath,
1928
+ opCount: doc.ops.length,
1929
+ expectedHash: doc.expectedHash,
1930
+ status: "pending",
1931
+ mutationAttempted: false
1932
+ }));
1933
+ }
1934
+ function pushReceiptNeedsQuarantine(receipt) {
1935
+ if (receipt.status === "ambiguous") return true;
1936
+ if (receipt.status !== "in_progress") return false;
1937
+ const documents = receipt.details?.documents;
1938
+ return Array.isArray(documents) && documents.some(
1939
+ (document) => Boolean(document) && typeof document === "object" && (document.mutationAttempted === true || typeof document.mutationStartedAt === "string")
1940
+ );
1941
+ }
1942
+ function receiptDocumentIds(receipt) {
1943
+ const documents = receipt.details?.documents;
1944
+ if (!Array.isArray(documents)) return [];
1945
+ return documents.flatMap((document) => {
1946
+ if (!document || typeof document !== "object") return [];
1947
+ const docId = document.docId;
1948
+ return typeof docId === "string" ? [docId] : [];
1949
+ });
1950
+ }
1951
+ function quarantinedPlanDocuments(plan, receipts) {
1952
+ const plannedIds = new Set(plan.documents.map((document) => document.docId));
1953
+ const disposition = /* @__PURE__ */ new Map();
1954
+ const newestFirst = [...receipts].sort(
1955
+ (a, b) => String(b.receipt.updatedAt ?? "").localeCompare(String(a.receipt.updatedAt ?? ""))
1956
+ );
1957
+ for (const { receipt } of newestFirst) {
1958
+ if (receipt.operation !== "push" || receipt.details?.projectId !== plan.projectId) {
1959
+ continue;
1960
+ }
1961
+ const reconciles = receipt.status === "succeeded" && receipt.details.acknowledgedAmbiguousRetry === true;
1962
+ const quarantines = pushReceiptNeedsQuarantine(receipt);
1963
+ if (!reconciles && !quarantines) continue;
1964
+ for (const docId of receiptDocumentIds(receipt)) {
1965
+ if (plannedIds.has(docId) && !disposition.has(docId)) {
1966
+ disposition.set(docId, reconciles ? "reconciled" : "quarantined");
1967
+ }
1968
+ }
1969
+ }
1970
+ return plan.documents.filter((document) => disposition.get(document.docId) === "quarantined").map((document) => document.docPath);
1971
+ }
1972
+ function synchronizeLocalAfterRemote(localPath, plannedLocalHash, remoteText, workspaceRoot = process.cwd()) {
1973
+ const localFile = workspaceReadPath(localPath, workspaceRoot);
1974
+ const currentLocal = readFileSync6(localFile, "utf8");
1975
+ if (sha256(currentLocal) !== plannedLocalHash) {
1976
+ throw new Error(`${localPath} changed while the plan was being submitted; local file left untouched.`);
1977
+ }
1978
+ if (currentLocal === remoteText) return {};
1979
+ const timestamp = `${snapshotTimestamp()}-${process.pid}`;
1980
+ const snapshotPath = snapshotRelativePath(timestamp, localPath, workspaceRoot);
1981
+ const snapshotFile = workspaceWritePath(snapshotPath, workspaceRoot);
1982
+ mkdirSync7(dirname5(snapshotFile), { recursive: true });
1983
+ writeFileSync7(snapshotFile, currentLocal, { flag: "wx", mode: 384 });
1984
+ const target = workspaceWritePath(localPath, workspaceRoot);
1985
+ const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
1986
+ try {
1987
+ writeFileSync7(temp, remoteText, { mode: statSync(localFile).mode & 511 });
1988
+ if (sha256(readFileSync6(localFile, "utf8")) !== plannedLocalHash) {
1989
+ throw new Error(`${localPath} changed while the plan was being submitted; local file left untouched.`);
1990
+ }
1991
+ renameSync4(temp, target);
1992
+ } catch (error) {
1993
+ try {
1994
+ unlinkSync5(temp);
1995
+ } catch {
1996
+ }
1997
+ throw error;
1998
+ }
1999
+ return { snapshotPath };
2000
+ }
2001
+ async function submitPlan(planOrPath, opts = {}) {
2002
+ const plan = typeof planOrPath === "string" ? readPushPlan(planOrPath) : validatePushPlan(planOrPath);
2003
+ const totalOps = plan.documents.reduce((sum, doc) => sum + doc.ops.length, 0);
2004
+ const planHash = sha256(stableJson(plan));
2005
+ const receiptDocuments = initialReceiptDocuments(plan);
2006
+ let receipt = beginReceipt(
2007
+ "push",
2008
+ {
2009
+ projectId: plan.projectId,
2010
+ direct: plan.direct,
2011
+ planCreatedAt: plan.createdAt,
2012
+ planHash,
2013
+ totalOps,
2014
+ phase: "preflight",
2015
+ plan,
2016
+ documents: receiptDocuments
2017
+ },
2018
+ { receiptsDir: opts.receiptsDir }
2019
+ );
2020
+ const completed = [];
2021
+ const basePath = opts.basePath ?? BASE_STATE_PATH;
2022
+ let opened;
2023
+ let mutationLock;
2024
+ let unknownMutationOutcome = false;
2025
+ let failureStatus;
2026
+ try {
2027
+ if (plan.projectId !== config.projectId) {
2028
+ throw new PushPlanValidationError(
2029
+ `Plan is for project ${plan.projectId}, but this repository is linked to ${config.projectId}.`
2030
+ );
2031
+ }
2032
+ mutationLock = acquireMutationLock(plan.projectId);
2033
+ const priorReceipts = readReceipts(opts.receiptsDir);
2034
+ const quarantinedDocuments = quarantinedPlanDocuments(plan, priorReceipts);
2035
+ if (quarantinedDocuments.length && !opts.allowAmbiguousRetry) {
2036
+ throw new PushPlanValidationError(
2037
+ `A prior push has an unresolved outcome for: ${quarantinedDocuments.join(", ")}. Wait for delayed updates, inspect Overleaf and its receipt, then create a fresh plan. Only after manual reconciliation may you submit with --acknowledge-ambiguous.`
2038
+ );
2039
+ }
2040
+ if (!plan.documents.length) {
2041
+ receipt = updateReceipt(receipt, "skipped", {
2042
+ phase: "complete",
2043
+ outcome: "empty_plan",
2044
+ documents: receiptDocuments
2045
+ });
2046
+ return {
2047
+ projectId: plan.projectId,
2048
+ direct: plan.direct,
2049
+ totalOps,
2050
+ documents: completed,
2051
+ receiptPath: receipt.path
2052
+ };
2053
+ }
2054
+ opened = await openProject();
2055
+ const { socket, project, docs } = opened;
2056
+ const connectedProjectId = String(project?._id ?? config.projectId);
2057
+ if (connectedProjectId !== plan.projectId) {
2058
+ throw new PushPlanValidationError(
2059
+ `Connected project ${connectedProjectId} does not match plan ${plan.projectId}.`
2060
+ );
2061
+ }
2062
+ const docsById = new Map(docs.map((doc) => [doc._id, doc]));
2063
+ for (const planned of plan.documents) {
2064
+ const doc = docsById.get(planned.docId);
2065
+ if (!doc || doc.path !== planned.docPath) {
2066
+ throw new PushPlanValidationError(
2067
+ `Document ${planned.docPath} (${planned.docId}) no longer exists at its planned path.`
2068
+ );
2069
+ }
2070
+ }
2071
+ for (const planned of plan.documents) {
2072
+ await bindPlanDocument(plan, planned, socket, basePath);
2073
+ }
2074
+ receipt = updateReceipt(receipt, "in_progress", {
2075
+ phase: "applying",
2076
+ preflightVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2077
+ ...opts.allowAmbiguousRetry && quarantinedDocuments.length ? {
2078
+ acknowledgedAmbiguousRetry: true,
2079
+ reconciledAmbiguousDocuments: quarantinedDocuments
2080
+ } : {},
2081
+ documents: receiptDocuments
2082
+ });
2083
+ for (let index = 0; index < plan.documents.length; index++) {
2084
+ const planned = plan.documents[index];
2085
+ const { state, expected } = await bindPlanDocument(
2086
+ plan,
2087
+ planned,
2088
+ socket,
2089
+ basePath
2090
+ );
2091
+ receiptDocuments[index] = {
2092
+ ...receiptDocuments[index],
2093
+ status: "applying",
2094
+ mutationAttempted: true,
2095
+ mutationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
2096
+ };
2097
+ receipt = updateReceipt(receipt, "in_progress", {
2098
+ phase: "applying",
2099
+ currentDocument: planned.docPath,
2100
+ documents: receiptDocuments
2101
+ });
2102
+ unknownMutationOutcome = true;
2103
+ let applyError;
2104
+ try {
2105
+ await applyOtUpdateAndWait(socket, planned.docId, {
2106
+ doc: planned.docId,
2107
+ op: planned.ops,
2108
+ v: state.version,
2109
+ meta: plan.direct ? {} : { tc: planned.tcSeed },
2110
+ hash: overleafSnapshotHash(expected)
2111
+ });
2112
+ } catch (error) {
2113
+ applyError = error;
2114
+ }
2115
+ let after;
2116
+ try {
2117
+ after = await joinDoc(socket, planned.docId);
2118
+ } catch (readbackError) {
2119
+ const definitelyRejected = Boolean(applyError && definitelyRejectedApply(applyError));
2120
+ failureStatus = definitelyRejected ? "failed" : "ambiguous";
2121
+ unknownMutationOutcome = !definitelyRejected;
2122
+ receiptDocuments[index] = {
2123
+ ...receiptDocuments[index],
2124
+ status: failureStatus,
2125
+ ...applyError ? { transportError: errorMessage(applyError) } : {},
2126
+ error: `Readback failed: ${errorMessage(readbackError)}`
2127
+ };
2128
+ throw new Error(
2129
+ `${planned.docPath} could not be verified after its update: ${errorMessage(readbackError)}`
2130
+ );
2131
+ }
2132
+ const afterText = after.lines.join("\n");
2133
+ if (afterText !== expected || sha256(afterText) !== planned.expectedHash) {
2134
+ const definitelyRejected = Boolean(applyError && definitelyRejectedApply(applyError));
2135
+ failureStatus = definitelyRejected ? "failed" : "ambiguous";
2136
+ unknownMutationOutcome = !definitelyRejected;
2137
+ receiptDocuments[index] = {
2138
+ ...receiptDocuments[index],
2139
+ status: failureStatus,
2140
+ afterVersion: after.version,
2141
+ ...applyError ? { transportError: errorMessage(applyError) } : {},
2142
+ error: "Overleaf text does not match the planned result"
2143
+ };
2144
+ throw new Error(
2145
+ `Verification failed for ${planned.docPath}: Overleaf text does not match the planned result.`
2146
+ );
2147
+ }
2148
+ let trackedChangeIds;
2149
+ try {
2150
+ trackedChangeIds = verifiedTrackedIds(plan, planned, after);
2151
+ } catch (error) {
2152
+ const ambiguousTransport = Boolean(applyError && !definitelyRejectedApply(applyError));
2153
+ unknownMutationOutcome = ambiguousTransport;
2154
+ failureStatus = ambiguousTransport ? "ambiguous" : "failed";
2155
+ receiptDocuments[index] = {
2156
+ ...receiptDocuments[index],
2157
+ status: failureStatus,
2158
+ afterVersion: after.version,
2159
+ ...applyError ? { transportError: errorMessage(applyError) } : {},
2160
+ error: errorMessage(error)
2161
+ };
2162
+ throw error;
2163
+ }
2164
+ unknownMutationOutcome = false;
2165
+ try {
2166
+ const localSync = synchronizeLocalAfterRemote(
2167
+ planned.localPath,
2168
+ planned.localHash,
2169
+ afterText
2170
+ );
2171
+ if (localSync.snapshotPath) {
2172
+ receiptDocuments[index] = {
2173
+ ...receiptDocuments[index],
2174
+ localSnapshotPath: localSync.snapshotPath
2175
+ };
2176
+ }
2177
+ const base = {
2178
+ docId: planned.docId,
2179
+ path: planned.docPath,
2180
+ text: afterText,
2181
+ hash: planned.expectedHash,
2182
+ version: after.version,
2183
+ rangeFingerprint: fingerprintRanges(after.ranges),
2184
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
2185
+ };
2186
+ mergeBaseDocuments(plan.projectId, [base], basePath);
2187
+ } catch (error) {
2188
+ failureStatus = "failed";
2189
+ receiptDocuments[index] = {
2190
+ ...receiptDocuments[index],
2191
+ status: "remote_verified_local_failed",
2192
+ afterVersion: after.version,
2193
+ trackedChangeIds,
2194
+ ...applyError ? { transportError: errorMessage(applyError) } : {},
2195
+ error: errorMessage(error)
2196
+ };
2197
+ throw new Error(
2198
+ `${planned.docPath} was verified on Overleaf, but local synchronization failed: ` + errorMessage(error)
2199
+ );
2200
+ }
2201
+ const result = {
2202
+ docId: planned.docId,
2203
+ docPath: planned.docPath,
2204
+ version: after.version,
2205
+ hash: planned.expectedHash,
2206
+ trackedChangeIds
2207
+ };
2208
+ completed.push(result);
2209
+ receiptDocuments[index] = {
2210
+ ...receiptDocuments[index],
2211
+ status: "verified",
2212
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2213
+ afterVersion: after.version,
2214
+ trackedChangeIds,
2215
+ ...applyError ? { transportError: errorMessage(applyError) } : {}
2216
+ };
2217
+ receipt = updateReceipt(receipt, "in_progress", {
2218
+ phase: "applying",
2219
+ completedDocuments: completed.map((doc) => doc.docPath),
2220
+ documents: receiptDocuments
2221
+ });
2222
+ }
2223
+ receipt = updateReceipt(receipt, "succeeded", {
2224
+ phase: "complete",
2225
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2226
+ completedDocuments: completed.map((doc) => doc.docPath),
2227
+ documents: receiptDocuments
2228
+ });
2229
+ return {
2230
+ projectId: plan.projectId,
2231
+ direct: plan.direct,
2232
+ totalOps,
2233
+ documents: completed,
2234
+ receiptPath: receipt.path
2235
+ };
2236
+ } catch (error) {
2237
+ const status = failureStatus ?? (unknownMutationOutcome ? "ambiguous" : "failed");
2238
+ receipt = updateReceipt(receipt, status, {
2239
+ phase: status === "ambiguous" ? "mutation_outcome_unknown" : "failed",
2240
+ failedAt: (/* @__PURE__ */ new Date()).toISOString(),
2241
+ error: errorMessage(error),
2242
+ completedDocuments: completed.map((doc) => doc.docPath),
2243
+ documents: receiptDocuments
2244
+ });
2245
+ throw new PushSubmissionError(
2246
+ `${errorMessage(error)} Audit receipt: ${receipt.path}`,
2247
+ receipt.path,
2248
+ status,
2249
+ receiptDocuments,
2250
+ error
2251
+ );
2252
+ } finally {
2253
+ try {
2254
+ opened?.socket.close();
2255
+ } finally {
2256
+ mutationLock?.release();
2257
+ }
2258
+ }
2259
+ }
2260
+ function printPlan(plan) {
2261
+ console.log(
2262
+ plan.direct ? "Mode: DIRECT \u2014 plain edits (not marked as suggestions)" : "Mode: SUGGESTIONS \u2014 tracked changes for co-authors to accept/reject"
2263
+ );
2264
+ for (const doc of plan.documents) {
2265
+ const ins = doc.ops.filter((op) => op.i != null).length;
2266
+ const del = doc.ops.filter((op) => op.d != null).length;
2267
+ console.log(
2268
+ `
2269
+ ${doc.localPath} \u2192 ${doc.docPath} (v${doc.liveVersion}): ${doc.ops.length} op(s), ${ins} ins / ${del} del`
2270
+ );
2271
+ for (const op of doc.ops.slice(0, 12)) console.log(preview(op));
2272
+ if (doc.ops.length > 12) console.log(` \u2026 and ${doc.ops.length - 12} more`);
2273
+ for (const overlap of doc.commentOverlaps) {
2274
+ console.log(
2275
+ ` \u2139\uFE0F touches comment ${overlap.threadId} @ ${overlap.position}: ${JSON.stringify(overlap.anchor)}`
2276
+ );
2277
+ }
2278
+ }
2279
+ }
2280
+ async function push(opts) {
2281
+ validatePushOptions(opts);
2282
+ if (opts.plan) {
2283
+ const result2 = await submitPlan(opts.plan, {
2284
+ basePath: opts.basePath,
2285
+ receiptsDir: opts.receiptsDir,
2286
+ allowAmbiguousRetry: opts.allowAmbiguousRetry
2287
+ });
2288
+ console.log(
2289
+ `\u2705 Submitted and verified ${result2.totalOps} ${result2.direct ? "direct edit(s)" : "tracked suggestion(s)"} across ${result2.documents.length} file(s).`
2290
+ );
2291
+ console.log(`Audit receipt: ${result2.receiptPath}`);
2292
+ return;
2293
+ }
2294
+ const plan = await createPlan(opts);
2295
+ if (!plan.documents.length) {
2296
+ console.log("Nothing to push \u2014 no unapplied local edits were found.");
2297
+ if (opts.planOut) console.log(`Saved empty plan to ${opts.planOut}.`);
2298
+ return;
2299
+ }
2300
+ printPlan(plan);
2301
+ if (opts.planOut) {
2302
+ console.log(`
2303
+ Saved binding plan to ${opts.planOut}; nothing sent to Overleaf.`);
2304
+ return;
2305
+ }
2306
+ if (opts.dryRun) {
2307
+ console.log("\n(dry run \u2014 nothing sent to Overleaf)");
2308
+ return;
2309
+ }
2310
+ const result = await submitPlan(plan, {
2311
+ basePath: opts.basePath,
2312
+ receiptsDir: opts.receiptsDir,
2313
+ allowAmbiguousRetry: opts.allowAmbiguousRetry
2314
+ });
2315
+ console.log(
2316
+ `
2317
+ \u2705 Pushed and verified ${result.totalOps} ${result.direct ? "direct edit(s)" : "tracked suggestion(s)"} across ${result.documents.length} file(s).`
2318
+ );
2319
+ console.log(`Audit receipt: ${result.receiptPath}`);
2320
+ }
2321
+
2322
+ // src/commands/fetch.ts
2323
+ import { writeFileSync as writeFileSync8, readFileSync as readFileSync7, mkdirSync as mkdirSync8, existsSync as existsSync4 } from "fs";
2324
+ import { dirname as dirname6 } from "path";
2325
+ async function fetchDocs(opts) {
2326
+ const { socket, project, docs } = await openProject();
2327
+ let mutationLock;
2328
+ try {
2329
+ if (!opts.dryRun && opts.acquireLock !== false) {
2330
+ mutationLock = acquireMutationLock(config.projectId);
2331
+ }
2332
+ const projectId = String(project?._id ?? config.projectId);
2333
+ if (projectId !== config.projectId) {
2334
+ throw new Error(
2335
+ `Connected project id ${projectId} does not match configured project ${config.projectId}.`
2336
+ );
2337
+ }
2338
+ const requested = opts.file?.replace(/\\/g, "/");
2339
+ const match = requested ? matchDocument(requested, docs) : void 0;
2340
+ const targets = requested ? match ? [match] : [] : docs;
2341
+ if (!targets.length) {
2342
+ if (requested) {
2343
+ throw new Error(
2344
+ `No matching document for "${opts.file}"; use its exact Overleaf project path.`
2345
+ );
2346
+ }
2347
+ console.log("No Overleaf documents found.");
2348
+ return;
2349
+ }
2350
+ const localTargets = targets.map((doc) => ({
2351
+ doc,
2352
+ localPath: workspaceWritePath(doc.path)
2353
+ }));
2354
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
2355
+ const entries = [];
2356
+ for (const { doc, localPath } of localTargets) {
2357
+ const state = await joinDoc(socket, doc._id);
2358
+ const remote = state.lines.join("\n");
2359
+ const local = existsSync4(localPath) ? readFileSync7(localPath, "utf8") : null;
2360
+ entries.push({
2361
+ doc,
2362
+ localPath,
2363
+ remote,
2364
+ local,
2365
+ base: {
2366
+ docId: doc._id,
2367
+ path: doc.path,
2368
+ text: remote,
2369
+ hash: sha256(remote),
2370
+ version: state.version,
2371
+ rangeFingerprint: fingerprintRanges(state.ranges),
2372
+ fetchedAt
2373
+ }
2374
+ });
2375
+ }
2376
+ const changedEntries = entries.filter((entry) => entry.local !== entry.remote);
2377
+ for (const { doc, local, remote } of changedEntries) {
2378
+ const delta = local === null ? "(new file)" : `${local.length} \u2192 ${remote.length} chars`;
2379
+ console.log(` ${doc.path} ${delta}`);
2380
+ }
2381
+ let snapshotRoot;
2382
+ if (!opts.dryRun) {
2383
+ for (const entry of changedEntries) {
2384
+ if (entry.local === null) continue;
2385
+ const current = readFileSync7(entry.localPath, "utf8");
2386
+ if (current !== entry.local) {
2387
+ throw new Error(`${entry.doc.path} changed while fetch was reading the project; retry fetch.`);
2388
+ }
2389
+ }
2390
+ const timestamp = snapshotTimestamp();
2391
+ const snapshotTargets = changedEntries.filter((entry) => entry.local !== null).map((entry) => ({
2392
+ entry,
2393
+ snapshotPath: workspaceWritePath(snapshotRelativePath(timestamp, entry.doc.path))
2394
+ }));
2395
+ for (const { entry, snapshotPath } of snapshotTargets) {
2396
+ mkdirSync8(dirname6(snapshotPath), { recursive: true });
2397
+ writeFileSync8(snapshotPath, entry.local, { mode: 384 });
2398
+ }
2399
+ if (snapshotTargets.length) {
2400
+ snapshotRoot = `${SNAPSHOTS_DIR}/${timestamp}`;
2401
+ }
2402
+ for (const { localPath, remote } of changedEntries) {
2403
+ mkdirSync8(dirname6(localPath), { recursive: true });
2404
+ writeFileSync8(localPath, remote);
2405
+ }
2406
+ }
2407
+ if (!opts.dryRun) mergeBaseDocuments(projectId, entries.map((entry) => entry.base));
2408
+ if (!changedEntries.length) {
2409
+ console.log(
2410
+ opts.dryRun ? "Already up to date \u2014 local files match Overleaf." : `Already up to date \u2014 refreshed synchronization base for ${entries.length} file(s).`
2411
+ );
2412
+ return;
2413
+ }
2414
+ if (snapshotRoot) console.log(`Recoverable local snapshot: ${snapshotRoot}`);
2415
+ console.log(
2416
+ opts.dryRun ? `
2417
+ (dry run \u2014 ${changedEntries.length} local file(s) would be overwritten; base unchanged)` : `
2418
+ \u2705 Fetched ${changedEntries.length} file(s) from Overleaf and saved their synchronization base.`
2419
+ );
2420
+ } finally {
2421
+ try {
2422
+ socket.close();
2423
+ } finally {
2424
+ mutationLock?.release();
2425
+ }
2426
+ }
2427
+ }
2428
+
2429
+ // src/commands/upload.ts
2430
+ import { readFileSync as readFileSync8 } from "fs";
2431
+ import { basename as basename2 } from "path";
2432
+ function findFolder(folder, wanted, prefix = "") {
2433
+ for (const f of folder?.folders ?? []) {
2434
+ const path = prefix ? `${prefix}/${f.name}` : f.name;
2435
+ if (f.name === wanted || path === wanted) return f;
2436
+ const deeper = findFolder(f, wanted, path);
2437
+ if (deeper) return deeper;
2438
+ }
2439
+ return void 0;
2440
+ }
2441
+ async function upload(paths, folderName) {
2442
+ const mutationLock = acquireMutationLock(config.projectId);
2443
+ let socket;
2444
+ try {
2445
+ const opened = await openProject();
2446
+ socket = opened.socket;
2447
+ const { project } = opened;
2448
+ const root = project?.rootFolder?.[0];
2449
+ if (!root?._id) throw new Error("could not resolve the project root folder");
2450
+ let folderId = root._id;
2451
+ if (folderName) {
2452
+ const found = findFolder(root, folderName);
2453
+ if (!found) throw new Error(`folder not found in project: ${folderName}`);
2454
+ folderId = found._id;
2455
+ }
2456
+ const csrf = await getCsrfToken();
2457
+ for (const path of paths) {
2458
+ const bytes = readFileSync8(path);
2459
+ const res = await uploadFile(folderId, basename2(path), bytes, csrf);
2460
+ console.log(`\u2705 Uploaded ${path} \u2192 ${res.entity_type} ${res.entity_id}`);
2461
+ }
2462
+ } finally {
2463
+ try {
2464
+ socket?.close();
2465
+ } finally {
2466
+ mutationLock.release();
2467
+ }
2468
+ }
2469
+ }
2470
+
2471
+ // src/commands/comment.ts
2472
+ import { createHash as createHash4, randomBytes as randomBytes2 } from "crypto";
2473
+ var DEFAULT_DUPLICATE_WINDOW_MS = 5 * 60 * 1e3;
2474
+ function isRecentAttempt(prior, nowMs, windowMs) {
2475
+ const updatedAt = prior.updatedAt ? Date.parse(prior.updatedAt) : Number.NaN;
2476
+ return Number.isFinite(updatedAt) && updatedAt >= nowMs - Math.max(0, windowMs) && updatedAt <= nowMs + 6e4;
2477
+ }
2478
+ function shouldQuarantineCommentAnchorRetry(prior, nowMs, windowMs) {
2479
+ return (prior.status === "ambiguous" || prior.status === "in_progress" && Boolean(prior.anchorAttemptedAt)) && isRecentAttempt(prior, nowMs, windowMs);
2480
+ }
2481
+ function shouldQuarantineCommentRetry(prior, nowMs, windowMs) {
2482
+ if (!prior.postAttemptedAt || prior.status !== "ambiguous" && prior.status !== "in_progress") {
2483
+ return false;
2484
+ }
2485
+ return isRecentAttempt(prior, nowMs, windowMs);
2486
+ }
2487
+ function findCommentRangeByThreadId(ranges, threadId) {
2488
+ if (!Array.isArray(ranges)) return void 0;
2489
+ return ranges.find(
2490
+ (range) => Boolean(range && typeof range === "object" && range.op?.t === threadId)
2491
+ );
2492
+ }
2493
+ function findCommentRangesAt(ranges, position, anchor) {
2494
+ if (!Array.isArray(ranges)) return [];
2495
+ return ranges.filter(
2496
+ (range) => Boolean(
2497
+ range && typeof range === "object" && range.op?.p === position && range.op?.c === anchor
2498
+ )
2499
+ );
2500
+ }
2501
+ function commentRangeAnchorsText(range, text, anchor) {
2502
+ const position = range?.op?.p;
2503
+ return Number.isSafeInteger(position) && position >= 0 && range?.op?.c === anchor && text.slice(position, position + anchor.length) === anchor;
2504
+ }
2505
+ function commentIntentHash(intent) {
2506
+ const canonical = [
2507
+ intent.projectId,
2508
+ intent.docId,
2509
+ intent.anchor,
2510
+ intent.occurrence,
2511
+ intent.message
2512
+ ];
2513
+ return createHash4("sha256").update(JSON.stringify(canonical)).digest("hex");
2514
+ }
2515
+ function rangeThreadId(range) {
2516
+ const value = range?.op?.t;
2517
+ return typeof value === "string" && value ? value : void 0;
2518
+ }
2519
+ function rangeSnapshot(range) {
2520
+ return range ? {
2521
+ present: true,
2522
+ position: range.op?.p,
2523
+ anchor: range.op?.c,
2524
+ threadId: range.op?.t
2525
+ } : { present: false };
2526
+ }
2527
+ function threadSnapshot(thread) {
2528
+ const messages = threadMessages(thread);
2529
+ return {
2530
+ exists: Boolean(thread),
2531
+ messageCount: messages.length,
2532
+ messageIds: messages.map(threadMessageId).filter((id) => Boolean(id))
2533
+ };
2534
+ }
2535
+ function errorMessage2(error) {
2536
+ return error instanceof Error ? error.message : String(error);
2537
+ }
2538
+ function priorThreadIdForIntent(intentHash, receiptsDir) {
2539
+ const prior = readReceipts(receiptsDir).find(
2540
+ ({ receipt }) => receipt.operation === "comment" && receipt.details.intentHash === intentHash
2541
+ );
2542
+ const threadId = prior?.receipt.details.threadId;
2543
+ const anchorAttemptedAt = prior?.receipt.details.anchorAttemptedAt;
2544
+ const postAttemptedAt = prior?.receipt.details.postAttemptedAt;
2545
+ return {
2546
+ ...typeof threadId === "string" ? { threadId } : {},
2547
+ ...prior ? { receiptPath: prior.path } : {},
2548
+ ...prior ? { status: prior.receipt.status, updatedAt: prior.receipt.updatedAt } : {},
2549
+ ...typeof anchorAttemptedAt === "string" ? { anchorAttemptedAt } : {},
2550
+ ...typeof postAttemptedAt === "string" ? { postAttemptedAt } : {}
2551
+ };
2552
+ }
2553
+ async function commentWithResult(opts) {
2554
+ const { socket, project, docs } = await openProject();
2555
+ let mutationLock;
2556
+ let receipt;
2557
+ try {
2558
+ mutationLock = acquireMutationLock(config.projectId);
2559
+ const doc = opts.docName ? matchDocument(opts.docName.replace(/\\/g, "/"), docs) : docs.find((d) => d._id === project.rootDoc_id) ?? docs[0];
2560
+ if (!doc) throw new Error(`doc not found: ${opts.docName ?? "(root)"}`);
2561
+ const state = await joinDoc(socket, doc._id);
2562
+ const flat = state.lines.join("\n");
2563
+ const nth = Math.max(1, opts.occurrence ?? 1);
2564
+ let p = -1;
2565
+ let from = 0;
2566
+ for (let i = 0; i < nth; i++) {
2567
+ p = flat.indexOf(opts.anchor, from);
2568
+ if (p < 0) break;
2569
+ from = p + 1;
2570
+ }
2571
+ if (p < 0) {
2572
+ throw new Error(`anchor text not found in ${doc.name}: "${opts.anchor}"`);
2573
+ }
2574
+ const duplicateWindowMs = Math.max(
2575
+ 0,
2576
+ opts.duplicateWindowMs ?? DEFAULT_DUPLICATE_WINDOW_MS
2577
+ );
2578
+ const intentHash = commentIntentHash({
2579
+ projectId: config.projectId,
2580
+ docId: doc._id,
2581
+ anchor: opts.anchor,
2582
+ occurrence: nth,
2583
+ message: opts.message
2584
+ });
2585
+ const prior = priorThreadIdForIntent(intentHash, opts.receiptsDir);
2586
+ const threads = await getThreads();
2587
+ const commentRanges = state.ranges.comments ?? [];
2588
+ let duplicateRange;
2589
+ if (!opts.force) {
2590
+ const candidates = findCommentRangesAt(commentRanges, p, opts.anchor).filter(
2591
+ (range) => commentRangeAnchorsText(range, flat, opts.anchor)
2592
+ );
2593
+ const priorRange2 = prior.threadId ? findCommentRangeByThreadId(commentRanges, prior.threadId) : void 0;
2594
+ if (priorRange2 && !candidates.includes(priorRange2)) candidates.push(priorRange2);
2595
+ duplicateRange = candidates.find((range) => {
2596
+ const candidateThreadId = rangeThreadId(range);
2597
+ return Boolean(
2598
+ candidateThreadId && findRecentIdenticalMessage(
2599
+ threads[candidateThreadId],
2600
+ opts.message,
2601
+ Date.now(),
2602
+ duplicateWindowMs
2603
+ )
2604
+ );
2605
+ });
2606
+ }
2607
+ if (duplicateRange) {
2608
+ const threadId2 = rangeThreadId(duplicateRange);
2609
+ const duplicateMessage = findRecentIdenticalMessage(
2610
+ threads[threadId2],
2611
+ opts.message,
2612
+ Date.now(),
2613
+ duplicateWindowMs
2614
+ );
2615
+ const messageId = threadMessageId(duplicateMessage);
2616
+ receipt = beginReceipt(
2617
+ "comment",
2618
+ {
2619
+ projectId: config.projectId,
2620
+ docId: doc._id,
2621
+ doc: doc.path,
2622
+ threadId: threadId2,
2623
+ message: opts.message,
2624
+ anchor: opts.anchor,
2625
+ occurrence: nth,
2626
+ position: p,
2627
+ intentHash,
2628
+ force: false,
2629
+ phase: "preflight"
2630
+ },
2631
+ { receiptsDir: opts.receiptsDir }
2632
+ );
2633
+ receipt = updateReceipt(receipt, "skipped", {
2634
+ phase: "complete",
2635
+ outcome: "recent_identical_comment",
2636
+ anchorRange: rangeSnapshot(duplicateRange),
2637
+ ...messageId ? { messageId } : {},
2638
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
2639
+ });
2640
+ return {
2641
+ doc: doc.path,
2642
+ threadId: threadId2,
2643
+ anchorCreated: false,
2644
+ messagePosted: false,
2645
+ duplicate: true,
2646
+ ...messageId ? { messageId } : {},
2647
+ receiptPath: receipt.path
2648
+ };
2649
+ }
2650
+ const priorRangeCandidate = !opts.force && prior.threadId ? findCommentRangeByThreadId(commentRanges, prior.threadId) : void 0;
2651
+ const priorRange = commentRangeAnchorsText(priorRangeCandidate, flat, opts.anchor) ? priorRangeCandidate : void 0;
2652
+ const now = Date.now();
2653
+ if (!opts.force && !priorRange && shouldQuarantineCommentAnchorRetry(prior, now, duplicateWindowMs)) {
2654
+ throw new Error(
2655
+ `A recent comment attempt has an ambiguous anchor outcome (${prior.receiptPath}). Inspect Overleaf before retrying, or use force: true explicitly`
2656
+ );
2657
+ }
2658
+ if (!opts.force && priorRange && shouldQuarantineCommentRetry(prior, now, duplicateWindowMs)) {
2659
+ throw new Error(
2660
+ `A recent comment message attempt has an unverified outcome (${prior.receiptPath}). Inspect the thread before retrying, or use force: true explicitly`
2661
+ );
2662
+ }
2663
+ const canResume = Boolean(
2664
+ priorRange && prior.threadId && threadMessages(threads[prior.threadId]).length === 0
2665
+ );
2666
+ const threadId = canResume ? prior.threadId : randomBytes2(12).toString("hex");
2667
+ let anchorCreated = false;
2668
+ let beforeThread = threads[threadId];
2669
+ receipt = beginReceipt(
2670
+ "comment",
2671
+ {
2672
+ projectId: config.projectId,
2673
+ docId: doc._id,
2674
+ doc: doc.path,
2675
+ threadId,
2676
+ message: opts.message,
2677
+ anchor: opts.anchor,
2678
+ occurrence: nth,
2679
+ position: p,
2680
+ sourceVersion: state.version,
2681
+ intentHash,
2682
+ force: opts.force ?? false,
2683
+ phase: "preflight",
2684
+ beforeThread: threadSnapshot(beforeThread),
2685
+ ...canResume && prior.receiptPath ? { resumedFromReceipt: prior.receiptPath } : {}
2686
+ },
2687
+ { receiptsDir: opts.receiptsDir }
2688
+ );
2689
+ let verifiedRange = canResume ? priorRange : void 0;
2690
+ if (!verifiedRange) {
2691
+ receipt = updateReceipt(receipt, "in_progress", {
2692
+ phase: "creating_anchor",
2693
+ anchorAttemptedAt: (/* @__PURE__ */ new Date()).toISOString()
2694
+ });
2695
+ const update = {
2696
+ doc: doc._id,
2697
+ op: [{ p, c: opts.anchor, t: threadId }],
2698
+ v: state.version,
2699
+ meta: {}
2700
+ };
2701
+ let anchorError;
2702
+ try {
2703
+ await applyOtUpdateAndWait(socket, doc._id, update);
2704
+ } catch (error) {
2705
+ anchorError = error;
2706
+ }
2707
+ let afterAnchorText;
2708
+ try {
2709
+ const afterAnchor = await joinDoc(socket, doc._id);
2710
+ afterAnchorText = afterAnchor.lines.join("\n");
2711
+ verifiedRange = findCommentRangeByThreadId(afterAnchor.ranges.comments, threadId);
2712
+ } catch (readbackError) {
2713
+ receipt = updateReceipt(receipt, "ambiguous", {
2714
+ phase: "anchor_outcome_unknown",
2715
+ ...anchorError ? { transportError: errorMessage2(anchorError) } : {},
2716
+ verificationError: errorMessage2(readbackError)
2717
+ });
2718
+ throw new Error(
2719
+ `Comment anchor outcome is ambiguous; inspect ${receipt.path} before retrying`
2720
+ );
2721
+ }
2722
+ if (!commentRangeAnchorsText(verifiedRange, afterAnchorText ?? "", opts.anchor)) {
2723
+ receipt = updateReceipt(receipt, anchorError ? "ambiguous" : "failed", {
2724
+ phase: anchorError ? "anchor_outcome_unknown" : "anchor_unverified",
2725
+ ...anchorError ? { transportError: errorMessage2(anchorError) } : {},
2726
+ anchorRange: rangeSnapshot(verifiedRange)
2727
+ });
2728
+ throw new Error(
2729
+ `Comment anchor was not visible on readback; inspect ${receipt.path} before retrying`
2730
+ );
2731
+ }
2732
+ anchorCreated = true;
2733
+ receipt = updateReceipt(receipt, "in_progress", {
2734
+ phase: "anchor_verified",
2735
+ anchorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2736
+ anchorRange: rangeSnapshot(verifiedRange),
2737
+ ...anchorError ? { transportError: errorMessage2(anchorError) } : {}
2738
+ });
2739
+ } else {
2740
+ receipt = updateReceipt(receipt, "in_progress", {
2741
+ phase: "anchor_verified",
2742
+ outcome: "resumed_anchor_only_operation",
2743
+ anchorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2744
+ anchorRange: rangeSnapshot(verifiedRange)
2745
+ });
2746
+ }
2747
+ let postError;
2748
+ let postAttempted = false;
2749
+ let responseStatus;
2750
+ let returnedMessageId;
2751
+ try {
2752
+ const csrf = await getCsrfToken();
2753
+ receipt = updateReceipt(receipt, "in_progress", {
2754
+ phase: "posting_message",
2755
+ postAttemptedAt: (/* @__PURE__ */ new Date()).toISOString()
2756
+ });
2757
+ postAttempted = true;
2758
+ const response = await postThreadMessageDetailed(threadId, opts.message, csrf);
2759
+ responseStatus = response.status;
2760
+ returnedMessageId = response.messageId;
2761
+ receipt = updateReceipt(receipt, "in_progress", {
2762
+ phase: "verifying_message",
2763
+ responseStatus,
2764
+ ...returnedMessageId ? { returnedMessageId } : {},
2765
+ ...response.responseBody === void 0 ? {} : { responseBody: response.responseBody }
2766
+ });
2767
+ } catch (error) {
2768
+ postError = error;
2769
+ }
2770
+ const observed = postAttempted ? await observePostedThreadMessage(
2771
+ threadId,
2772
+ beforeThread,
2773
+ opts.message,
2774
+ returnedMessageId,
2775
+ {
2776
+ timeoutMs: opts.verificationTimeoutMs,
2777
+ intervalMs: opts.verificationIntervalMs
2778
+ }
2779
+ ) : { attempts: 0, thread: beforeThread };
2780
+ const observedMessageId = threadMessageId(observed.message);
2781
+ if (!observed.message) {
2782
+ const definitelyRejected = !postAttempted || postError instanceof RestRequestError && postError.status < 500;
2783
+ receipt = updateReceipt(receipt, definitelyRejected ? "failed" : "ambiguous", {
2784
+ phase: !postAttempted ? "message_preflight_failed" : definitelyRejected ? "message_rejected" : "message_outcome_unknown",
2785
+ anchorRange: rangeSnapshot(verifiedRange),
2786
+ ...postError ? { error: errorMessage2(postError) } : {},
2787
+ ...postError instanceof RestRequestError ? { responseStatus: postError.status, responseBody: postError.responseBody } : responseStatus ? { responseStatus } : {},
2788
+ verificationAttempts: observed.attempts,
2789
+ ...observed.lastError ? { verificationError: observed.lastError } : {},
2790
+ afterThread: threadSnapshot(observed.thread)
2791
+ });
2792
+ const outcome = !postAttempted ? "could not be attempted" : definitelyRejected ? "was rejected" : "has an ambiguous outcome";
2793
+ throw new Error(
2794
+ `Comment message ${outcome}; the anchor may remain. Inspect ${receipt.path} before retrying`
2795
+ );
2796
+ }
2797
+ let finalRange;
2798
+ let finalStateText;
2799
+ try {
2800
+ const finalState = await joinDoc(socket, doc._id);
2801
+ finalStateText = finalState.lines.join("\n");
2802
+ finalRange = findCommentRangeByThreadId(finalState.ranges.comments, threadId);
2803
+ } catch (error) {
2804
+ receipt = updateReceipt(receipt, "ambiguous", {
2805
+ phase: "final_anchor_verification_failed",
2806
+ messageId: observedMessageId,
2807
+ messageVerifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2808
+ verificationError: errorMessage2(error)
2809
+ });
2810
+ throw new Error(
2811
+ `Comment message exists, but its anchor could not be rechecked. Inspect ${receipt.path}`
2812
+ );
2813
+ }
2814
+ if (!finalRange || !commentRangeAnchorsText(finalRange, finalStateText ?? "", opts.anchor)) {
2815
+ receipt = updateReceipt(receipt, "failed", {
2816
+ phase: "partial_message_without_anchor",
2817
+ ...observedMessageId ? { messageId: observedMessageId } : {},
2818
+ finalAnchorRange: rangeSnapshot(finalRange)
2819
+ });
2820
+ throw new Error(
2821
+ `Comment message exists but anchor ${threadId} is missing; see ${receipt.path}`
2822
+ );
2823
+ }
2824
+ receipt = updateReceipt(receipt, "succeeded", {
2825
+ phase: "complete",
2826
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2827
+ verificationAttempts: observed.attempts,
2828
+ finalAnchorRange: rangeSnapshot(finalRange),
2829
+ afterThread: threadSnapshot(observed.thread),
2830
+ ...postError ? { transportError: errorMessage2(postError) } : {},
2831
+ ...observedMessageId ? { messageId: observedMessageId } : {}
2832
+ });
2833
+ return {
2834
+ doc: doc.path,
2835
+ threadId,
2836
+ anchorCreated,
2837
+ messagePosted: true,
2838
+ duplicate: false,
2839
+ ...observedMessageId ? { messageId: observedMessageId } : {},
2840
+ receiptPath: receipt.path
2841
+ };
2842
+ } catch (error) {
2843
+ if (receipt && receipt.receipt.status !== "failed" && receipt.receipt.status !== "ambiguous" && receipt.receipt.status !== "skipped" && receipt.receipt.status !== "succeeded") {
2844
+ receipt = updateReceipt(receipt, "failed", {
2845
+ phase: "command_failed",
2846
+ error: errorMessage2(error)
2847
+ });
2848
+ }
2849
+ throw error;
2850
+ } finally {
2851
+ try {
2852
+ socket.close();
2853
+ } finally {
2854
+ mutationLock?.release();
2855
+ }
2856
+ }
2857
+ }
2858
+ async function comment(opts) {
2859
+ const result = await commentWithResult(opts);
2860
+ if (result.duplicate) {
2861
+ console.log(
2862
+ `\u21AA\uFE0F Skipped duplicate comment on "${opts.anchor}" (thread ${result.threadId})`
2863
+ );
2864
+ } else {
2865
+ console.log(
2866
+ `\u2705 Commented on "${opts.anchor}" in ${result.doc} (thread ${result.threadId}` + (result.messageId ? `, message ${result.messageId}` : "") + ")"
2867
+ );
2868
+ }
2869
+ }
2870
+
2871
+ // src/commands/reply.ts
2872
+ var DEFAULT_DUPLICATE_WINDOW_MS2 = 5 * 60 * 1e3;
2873
+ function errorMessage3(error) {
2874
+ return error instanceof Error ? error.message : String(error);
2875
+ }
2876
+ function threadSnapshot2(thread) {
2877
+ const messages = threadMessages(thread);
2878
+ return {
2879
+ exists: Boolean(thread),
2880
+ messageCount: messages.length,
2881
+ messageIds: messages.map(threadMessageId).filter((id) => Boolean(id))
2882
+ };
2883
+ }
2884
+ function recentUnverifiedReply(projectId, threadId, message, windowMs, receiptsDir) {
2885
+ const now = Date.now();
2886
+ const earliest = now - windowMs;
2887
+ const prior = readReceipts(receiptsDir).find(({ receipt }) => {
2888
+ const updatedAt = Date.parse(receipt.updatedAt);
2889
+ return receipt.operation === "reply" && (receipt.status === "ambiguous" || receipt.status === "in_progress" && typeof receipt.details.postAttemptedAt === "string") && receipt.details.projectId === projectId && receipt.details.threadId === threadId && receipt.details.message === message && Number.isFinite(updatedAt) && updatedAt >= earliest && updatedAt <= now + 6e4;
2890
+ });
2891
+ return prior ? { path: prior.path, updatedAt: prior.receipt.updatedAt } : void 0;
2892
+ }
2893
+ async function replyWithResult(threadId, message, options = {}) {
2894
+ const duplicateWindowMs = Math.max(
2895
+ 0,
2896
+ options.duplicateWindowMs ?? DEFAULT_DUPLICATE_WINDOW_MS2
2897
+ );
2898
+ let receipt = beginReceipt(
2899
+ "reply",
2900
+ {
2901
+ projectId: config.projectId,
2902
+ threadId,
2903
+ message,
2904
+ force: options.force ?? false,
2905
+ duplicateWindowMs,
2906
+ phase: "preflight"
2907
+ },
2908
+ { receiptsDir: options.receiptsDir }
2909
+ );
2910
+ let beforeThread;
2911
+ let postAttempted = false;
2912
+ let returnedMessageId;
2913
+ let responseStatus;
2914
+ let mutationLock;
2915
+ try {
2916
+ mutationLock = acquireMutationLock(config.projectId);
2917
+ const threads = await getThreads();
2918
+ beforeThread = threads[threadId];
2919
+ receipt = updateReceipt(receipt, "in_progress", {
2920
+ preflightAt: (/* @__PURE__ */ new Date()).toISOString(),
2921
+ before: threadSnapshot2(beforeThread)
2922
+ });
2923
+ if (!beforeThread) throw new Error(`thread ${threadId} not found`);
2924
+ const duplicate = !options.force ? findRecentIdenticalMessage(beforeThread, message, Date.now(), duplicateWindowMs) : void 0;
2925
+ if (duplicate) {
2926
+ const messageId = threadMessageId(duplicate);
2927
+ receipt = updateReceipt(receipt, "skipped", {
2928
+ phase: "complete",
2929
+ outcome: "recent_identical_message",
2930
+ ...messageId ? { messageId } : {},
2931
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
2932
+ });
2933
+ return {
2934
+ threadId,
2935
+ posted: false,
2936
+ duplicate: true,
2937
+ ...messageId ? { messageId } : {},
2938
+ receiptPath: receipt.path
2939
+ };
2940
+ }
2941
+ const priorUnverified = !options.force ? recentUnverifiedReply(
2942
+ config.projectId,
2943
+ threadId,
2944
+ message,
2945
+ duplicateWindowMs,
2946
+ options.receiptsDir
2947
+ ) : void 0;
2948
+ if (priorUnverified) {
2949
+ receipt = updateReceipt(receipt, "skipped", {
2950
+ phase: "blocked_by_prior_ambiguity",
2951
+ outcome: "retry_not_sent",
2952
+ priorReceipt: priorUnverified.path,
2953
+ priorUpdatedAt: priorUnverified.updatedAt
2954
+ });
2955
+ throw new Error(
2956
+ `A recent reply attempt has an unverified outcome (${priorUnverified.path}). Inspect the thread before retrying, or use force: true explicitly`
2957
+ );
2958
+ }
2959
+ const csrf = await getCsrfToken();
2960
+ postAttempted = true;
2961
+ receipt = updateReceipt(receipt, "in_progress", {
2962
+ phase: "posting_message",
2963
+ postAttemptedAt: (/* @__PURE__ */ new Date()).toISOString()
2964
+ });
2965
+ const response = await postThreadMessageDetailed(threadId, message, csrf);
2966
+ responseStatus = response.status;
2967
+ returnedMessageId = response.messageId;
2968
+ receipt = updateReceipt(receipt, "in_progress", {
2969
+ phase: "verifying_message",
2970
+ responseStatus,
2971
+ ...returnedMessageId ? { returnedMessageId } : {},
2972
+ ...response.responseBody === void 0 ? {} : { responseBody: response.responseBody }
2973
+ });
2974
+ const observed = await observePostedThreadMessage(
2975
+ threadId,
2976
+ beforeThread,
2977
+ message,
2978
+ returnedMessageId,
2979
+ {
2980
+ timeoutMs: options.verificationTimeoutMs,
2981
+ intervalMs: options.verificationIntervalMs
2982
+ }
2983
+ );
2984
+ const observedMessageId = threadMessageId(observed.message);
2985
+ if (!observed.message) {
2986
+ receipt = updateReceipt(receipt, "ambiguous", {
2987
+ phase: "message_unverified",
2988
+ verificationAttempts: observed.attempts,
2989
+ ...observed.lastError ? { verificationError: observed.lastError } : {},
2990
+ after: threadSnapshot2(observed.thread)
2991
+ });
2992
+ throw new Error(
2993
+ `Overleaf accepted the reply request, but the message was not visible on readback. Outcome is ambiguous; inspect ${receipt.path} before retrying`
2994
+ );
2995
+ }
2996
+ receipt = updateReceipt(receipt, "succeeded", {
2997
+ phase: "complete",
2998
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
2999
+ verificationAttempts: observed.attempts,
3000
+ after: threadSnapshot2(observed.thread),
3001
+ ...observedMessageId ? { messageId: observedMessageId } : {}
3002
+ });
3003
+ return {
3004
+ threadId,
3005
+ posted: true,
3006
+ duplicate: false,
3007
+ ...observedMessageId ? { messageId: observedMessageId } : {},
3008
+ receiptPath: receipt.path
3009
+ };
3010
+ } catch (error) {
3011
+ if (postAttempted && receipt.receipt.status !== "ambiguous") {
3012
+ const observed = await observePostedThreadMessage(
3013
+ threadId,
3014
+ beforeThread,
3015
+ message,
3016
+ returnedMessageId,
3017
+ {
3018
+ timeoutMs: options.verificationTimeoutMs,
3019
+ intervalMs: options.verificationIntervalMs
3020
+ }
3021
+ );
3022
+ const observedMessageId = threadMessageId(observed.message);
3023
+ if (observed.message) {
3024
+ receipt = updateReceipt(receipt, "succeeded", {
3025
+ phase: "complete",
3026
+ outcome: "verified_after_transport_error",
3027
+ transportError: errorMessage3(error),
3028
+ ...responseStatus ? { responseStatus } : {},
3029
+ verificationAttempts: observed.attempts,
3030
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
3031
+ after: threadSnapshot2(observed.thread),
3032
+ ...observedMessageId ? { messageId: observedMessageId } : {}
3033
+ });
3034
+ return {
3035
+ threadId,
3036
+ posted: true,
3037
+ duplicate: false,
3038
+ ...observedMessageId ? { messageId: observedMessageId } : {},
3039
+ receiptPath: receipt.path
3040
+ };
3041
+ }
3042
+ const definitelyRejected = error instanceof RestRequestError && error.status < 500;
3043
+ receipt = updateReceipt(receipt, definitelyRejected ? "failed" : "ambiguous", {
3044
+ phase: definitelyRejected ? "message_rejected" : "message_outcome_unknown",
3045
+ error: errorMessage3(error),
3046
+ ...error instanceof RestRequestError ? { responseStatus: error.status, responseBody: error.responseBody } : {},
3047
+ verificationAttempts: observed.attempts,
3048
+ ...observed.lastError ? { verificationError: observed.lastError } : {},
3049
+ after: threadSnapshot2(observed.thread)
3050
+ });
3051
+ const qualification = definitelyRejected ? "failed" : "has an ambiguous outcome";
3052
+ throw new Error(
3053
+ `Reply to thread ${threadId} ${qualification}: ${errorMessage3(error)}. Receipt: ${receipt.path}`
3054
+ );
3055
+ }
3056
+ if (receipt.receipt.status !== "ambiguous" && receipt.receipt.status !== "failed" && receipt.receipt.status !== "skipped" && receipt.receipt.status !== "succeeded") {
3057
+ receipt = updateReceipt(receipt, "failed", {
3058
+ phase: "preflight_failed",
3059
+ error: errorMessage3(error)
3060
+ });
3061
+ }
3062
+ throw error;
3063
+ } finally {
3064
+ mutationLock?.release();
3065
+ }
3066
+ }
3067
+ async function reply(threadId, message, options = {}) {
3068
+ const result = await replyWithResult(threadId, message, options);
3069
+ if (result.duplicate) {
3070
+ console.log(
3071
+ `\u21AA\uFE0F Skipped duplicate reply in thread ${threadId}` + (result.messageId ? ` (message ${result.messageId})` : "")
3072
+ );
3073
+ } else {
3074
+ console.log(
3075
+ `\u2705 Replied to thread ${threadId}` + (result.messageId ? ` (message ${result.messageId})` : "")
3076
+ );
3077
+ }
3078
+ }
3079
+
3080
+ // src/commands/resolve.ts
3081
+ async function resolve2(threadId, reopen = false) {
3082
+ const mutationLock = acquireMutationLock(config.projectId);
3083
+ let socket;
3084
+ try {
3085
+ const opened = await openProject();
3086
+ socket = opened.socket;
3087
+ const { docs } = opened;
3088
+ let docId;
3089
+ for (const doc of docs) {
3090
+ const state = await joinDoc(socket, doc._id);
3091
+ if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
3092
+ docId = doc._id;
3093
+ break;
3094
+ }
3095
+ }
3096
+ if (!docId) {
3097
+ throw new Error(
3098
+ `thread ${threadId} not found in any doc's active comments (already resolved threads may not be locatable this way)`
3099
+ );
3100
+ }
3101
+ const csrf = await getCsrfToken();
3102
+ await setThreadResolved(docId, threadId, reopen, csrf);
3103
+ console.log(`\u2705 Thread ${threadId} ${reopen ? "reopened" : "resolved"}`);
3104
+ } finally {
3105
+ try {
3106
+ socket?.close();
3107
+ } finally {
3108
+ mutationLock.release();
3109
+ }
3110
+ }
3111
+ }
3112
+
3113
+ // src/commands/delete-comment.ts
3114
+ async function deleteComment(threadId) {
3115
+ const mutationLock = acquireMutationLock(config.projectId);
3116
+ let socket;
3117
+ try {
3118
+ const opened = await openProject();
3119
+ socket = opened.socket;
3120
+ const { docs } = opened;
3121
+ let docId;
3122
+ for (const doc of docs) {
3123
+ const state = await joinDoc(socket, doc._id);
3124
+ if ((state.ranges.comments ?? []).some((c) => c.op?.t === threadId)) {
3125
+ docId = doc._id;
3126
+ break;
3127
+ }
3128
+ }
3129
+ if (!docId) throw new Error(`thread ${threadId} not found in any doc's comments`);
3130
+ const csrf = await getCsrfToken();
3131
+ await deleteThread(docId, threadId, csrf);
3132
+ console.log(`\u2705 Deleted comment thread ${threadId}`);
3133
+ } finally {
3134
+ try {
3135
+ socket?.close();
3136
+ } finally {
3137
+ mutationLock.release();
3138
+ }
3139
+ }
3140
+ }
3141
+
3142
+ // src/commands/delete-message.ts
3143
+ async function deleteThreadMessage(messageId, threadId) {
3144
+ const mutationLock = acquireMutationLock(config.projectId);
3145
+ try {
3146
+ const csrf = await getCsrfToken();
3147
+ let tid = threadId;
3148
+ if (!tid) {
3149
+ const threads = await getThreads();
3150
+ for (const [t, v] of Object.entries(threads)) {
3151
+ if (v.messages?.some((m) => m.id === messageId)) {
3152
+ tid = t;
3153
+ break;
3154
+ }
3155
+ }
3156
+ }
3157
+ if (!tid) throw new Error(`message ${messageId} not found in any thread`);
3158
+ await deleteMessage(tid, messageId, csrf);
3159
+ console.log(`\u2705 Deleted message ${messageId} from thread ${tid}`);
3160
+ } finally {
3161
+ mutationLock.release();
3162
+ }
3163
+ }
3164
+
3165
+ // src/commands/accept.ts
3166
+ var VERIFY_ATTEMPTS = 6;
3167
+ var VERIFY_INTERVAL_MS = 200;
3168
+ function changesFrom(state) {
3169
+ return state.ranges.changes ?? [];
3170
+ }
3171
+ async function verifyAcceptedDocument(socket, docId, changeIds) {
3172
+ let last;
3173
+ let lastError;
3174
+ for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt++) {
3175
+ try {
3176
+ last = await joinDoc(socket, docId);
3177
+ lastError = void 0;
3178
+ if (!remainingChangeIds(changesFrom(last), changeIds).length) return last;
3179
+ } catch (error) {
3180
+ lastError = error;
3181
+ }
3182
+ if (attempt + 1 < VERIFY_ATTEMPTS) {
3183
+ await new Promise((resolve3) => setTimeout(resolve3, VERIFY_INTERVAL_MS));
3184
+ }
3185
+ }
3186
+ if (!last) throw lastError ?? new Error(`could not read back document ${docId}`);
3187
+ return last;
3188
+ }
3189
+ function errorMessage4(error) {
3190
+ return error instanceof Error ? error.message : String(error);
792
3191
  }
793
-
794
- // src/commands/accept.ts
795
3192
  async function accept(changeIds) {
3193
+ const requestedIds = uniqueChangeIds(changeIds);
3194
+ if (!requestedIds.length) throw new Error("no tracked change ids requested");
796
3195
  const { socket, docs } = await openProject();
797
- const byDoc = /* @__PURE__ */ new Map();
798
- for (const doc of docs) {
799
- const state = await joinDoc(socket, doc._id);
800
- const here = (state.ranges.changes ?? []).filter((c) => changeIds.includes(c.id)).map((c) => c.id);
801
- if (here.length) byDoc.set(doc._id, here);
802
- }
803
- socket.close();
804
- const found = [...byDoc.values()].flat();
805
- const missing = changeIds.filter((id) => !found.includes(id));
806
- if (missing.length) console.log(`\u26A0\uFE0F not found (already accepted/rejected?): ${missing.join(", ")}`);
807
- if (!found.length) throw new Error("no matching tracked changes found");
808
- const csrf = await getCsrfToken();
809
- for (const [docId, ids] of byDoc) await acceptChanges(docId, ids, csrf);
810
- console.log(`\u2705 Accepted ${found.length} tracked change(s)`);
3196
+ let mutationLock;
3197
+ const found = /* @__PURE__ */ new Set();
3198
+ const locations = /* @__PURE__ */ new Map();
3199
+ const verifiedDocs = /* @__PURE__ */ new Set();
3200
+ const result = {
3201
+ action: "accept",
3202
+ requestedIds,
3203
+ foundIds: [],
3204
+ missingIds: [],
3205
+ attemptedIds: [],
3206
+ verifiedAbsentIds: [],
3207
+ documents: [],
3208
+ verified: false
3209
+ };
3210
+ const refreshVerifiedIds = () => {
3211
+ const verified = new Set(result.missingIds);
3212
+ for (const id of result.foundIds) {
3213
+ const everyKnownLocationVerified = [...locations.values()].filter((location) => location.ids.includes(id)).every((location) => verifiedDocs.has(location.doc._id));
3214
+ if (everyKnownLocationVerified) verified.add(id);
3215
+ }
3216
+ result.verifiedAbsentIds = requestedIds.filter((id) => verified.has(id));
3217
+ result.verified = result.verifiedAbsentIds.length === requestedIds.length;
3218
+ };
3219
+ try {
3220
+ mutationLock = acquireMutationLock(config.projectId);
3221
+ for (const doc of docs) {
3222
+ const state = await joinDoc(socket, doc._id);
3223
+ const ids = remainingChangeIds(changesFrom(state), requestedIds);
3224
+ if (!ids.length) continue;
3225
+ locations.set(doc._id, { doc, ids });
3226
+ for (const id of ids) found.add(id);
3227
+ }
3228
+ result.foundIds = requestedIds.filter((id) => found.has(id));
3229
+ result.missingIds = requestedIds.filter((id) => !found.has(id));
3230
+ refreshVerifiedIds();
3231
+ if (result.missingIds.length) {
3232
+ console.log(
3233
+ `\u26A0\uFE0F not found (already accepted/rejected?): ${result.missingIds.join(", ")}`
3234
+ );
3235
+ }
3236
+ if (!result.foundIds.length) {
3237
+ throw new Error("no matching tracked changes found");
3238
+ }
3239
+ let csrf;
3240
+ try {
3241
+ csrf = await getCsrfToken();
3242
+ } catch (error) {
3243
+ throw new TrackedChangeMutationError(
3244
+ `Could not obtain a CSRF token before accepting tracked changes: ${errorMessage4(error)}`,
3245
+ result,
3246
+ error
3247
+ );
3248
+ }
3249
+ for (const { doc, ids } of locations.values()) {
3250
+ let before;
3251
+ try {
3252
+ before = await joinDoc(socket, doc._id);
3253
+ } catch (error) {
3254
+ const outcome2 = {
3255
+ docId: doc._id,
3256
+ docPath: doc.path,
3257
+ requestedIds: ids,
3258
+ attemptedIds: [],
3259
+ fragmentCount: 0,
3260
+ status: "failed",
3261
+ remainingIds: ids,
3262
+ error: errorMessage4(error)
3263
+ };
3264
+ result.documents.push(outcome2);
3265
+ refreshVerifiedIds();
3266
+ throw new TrackedChangeMutationError(
3267
+ `Could not read ${doc.path} before accepting tracked changes`,
3268
+ result,
3269
+ error
3270
+ );
3271
+ }
3272
+ const beforeChanges = changesFrom(before);
3273
+ const presentIds = remainingChangeIds(beforeChanges, ids);
3274
+ const fragmentCount = beforeChanges.filter((range) => presentIds.includes(range.id)).length;
3275
+ if (!presentIds.length) {
3276
+ result.documents.push({
3277
+ docId: doc._id,
3278
+ docPath: doc.path,
3279
+ requestedIds: ids,
3280
+ attemptedIds: [],
3281
+ fragmentCount: 0,
3282
+ beforeVersion: before.version,
3283
+ afterVersion: before.version,
3284
+ status: "already-absent",
3285
+ remainingIds: []
3286
+ });
3287
+ verifiedDocs.add(doc._id);
3288
+ refreshVerifiedIds();
3289
+ continue;
3290
+ }
3291
+ const outcome = {
3292
+ docId: doc._id,
3293
+ docPath: doc.path,
3294
+ requestedIds: ids,
3295
+ attemptedIds: presentIds,
3296
+ fragmentCount,
3297
+ beforeVersion: before.version,
3298
+ status: "failed",
3299
+ remainingIds: presentIds
3300
+ };
3301
+ result.documents.push(outcome);
3302
+ result.attemptedIds = uniqueChangeIds([...result.attemptedIds, ...presentIds]);
3303
+ let mutationError;
3304
+ try {
3305
+ await acceptChanges(doc._id, presentIds, csrf);
3306
+ } catch (error) {
3307
+ mutationError = error;
3308
+ }
3309
+ let after;
3310
+ let readbackError;
3311
+ try {
3312
+ after = await verifyAcceptedDocument(socket, doc._id, ids);
3313
+ } catch (error) {
3314
+ readbackError = error;
3315
+ }
3316
+ if (after) {
3317
+ outcome.afterVersion = after.version;
3318
+ outcome.remainingIds = remainingChangeIds(changesFrom(after), ids);
3319
+ }
3320
+ if (after && !outcome.remainingIds.length) {
3321
+ outcome.status = "verified";
3322
+ if (mutationError) outcome.error = `transport warning: ${errorMessage4(mutationError)}`;
3323
+ verifiedDocs.add(doc._id);
3324
+ refreshVerifiedIds();
3325
+ continue;
3326
+ }
3327
+ const reasons = [];
3328
+ if (mutationError) reasons.push(errorMessage4(mutationError));
3329
+ if (readbackError) reasons.push(`readback failed: ${errorMessage4(readbackError)}`);
3330
+ if (outcome.remainingIds.length) {
3331
+ reasons.push(`change ids still present: ${outcome.remainingIds.join(", ")}`);
3332
+ }
3333
+ outcome.error = reasons.join("; ") || "acceptance could not be verified";
3334
+ refreshVerifiedIds();
3335
+ throw new TrackedChangeMutationError(
3336
+ `Accepted changes in ${doc.path} could not be verified: ${outcome.error}`,
3337
+ result,
3338
+ mutationError ?? readbackError
3339
+ );
3340
+ }
3341
+ refreshVerifiedIds();
3342
+ if (!result.verified) {
3343
+ throw new TrackedChangeMutationError(
3344
+ `Acceptance incomplete; unverified ids: ${requestedIds.filter((id) => !result.verifiedAbsentIds.includes(id)).join(", ")}`,
3345
+ result
3346
+ );
3347
+ }
3348
+ console.log(
3349
+ `\u2705 Accepted ${result.foundIds.length} tracked change(s); verified by readback`
3350
+ );
3351
+ return result;
3352
+ } finally {
3353
+ try {
3354
+ socket.close();
3355
+ } finally {
3356
+ mutationLock?.release();
3357
+ }
3358
+ }
811
3359
  }
812
3360
 
813
3361
  // src/commands/reject.ts
3362
+ var VERIFY_ATTEMPTS2 = 6;
3363
+ var VERIFY_INTERVAL_MS2 = 200;
3364
+ function changesFrom2(state) {
3365
+ return state.ranges.changes ?? [];
3366
+ }
3367
+ async function verifyRejectedDocument(socket, docId, changeIds, expectedText) {
3368
+ let last;
3369
+ let lastError;
3370
+ for (let attempt = 0; attempt < VERIFY_ATTEMPTS2; attempt++) {
3371
+ try {
3372
+ last = await joinDoc(socket, docId);
3373
+ lastError = void 0;
3374
+ const remaining = remainingChangeIds(changesFrom2(last), changeIds);
3375
+ if (!remaining.length && last.lines.join("\n") === expectedText) return last;
3376
+ } catch (error) {
3377
+ lastError = error;
3378
+ }
3379
+ if (attempt + 1 < VERIFY_ATTEMPTS2) {
3380
+ await new Promise((resolve3) => setTimeout(resolve3, VERIFY_INTERVAL_MS2));
3381
+ }
3382
+ }
3383
+ if (!last) throw lastError ?? new Error(`could not read back document ${docId}`);
3384
+ return last;
3385
+ }
3386
+ function errorMessage5(error) {
3387
+ return error instanceof Error ? error.message : String(error);
3388
+ }
814
3389
  async function reject(changeIds) {
3390
+ const requestedIds = uniqueChangeIds(changeIds);
3391
+ if (!requestedIds.length) throw new Error("no tracked change ids requested");
815
3392
  const { socket, docs } = await openProject();
816
- const docOf = /* @__PURE__ */ new Map();
817
- for (const doc of docs) {
818
- const state = await joinDoc(socket, doc._id);
819
- for (const c of state.ranges.changes ?? []) {
820
- if (changeIds.includes(c.id)) docOf.set(c.id, doc._id);
3393
+ let mutationLock;
3394
+ const found = /* @__PURE__ */ new Set();
3395
+ const locations = /* @__PURE__ */ new Map();
3396
+ const verifiedDocs = /* @__PURE__ */ new Set();
3397
+ const result = {
3398
+ action: "reject",
3399
+ requestedIds,
3400
+ foundIds: [],
3401
+ missingIds: [],
3402
+ attemptedIds: [],
3403
+ verifiedAbsentIds: [],
3404
+ documents: [],
3405
+ verified: false
3406
+ };
3407
+ const refreshVerifiedIds = () => {
3408
+ const verified = new Set(result.missingIds);
3409
+ for (const id of result.foundIds) {
3410
+ const everyKnownLocationVerified = [...locations.values()].filter((location) => location.ids.includes(id)).every((location) => verifiedDocs.has(location.doc._id));
3411
+ if (everyKnownLocationVerified) verified.add(id);
821
3412
  }
822
- }
823
- let rejected = 0;
824
- for (const id of changeIds) {
825
- const docId = docOf.get(id);
826
- if (!docId) {
827
- console.log(`\u26A0\uFE0F not found (already accepted/rejected?): ${id}`);
828
- continue;
3413
+ result.verifiedAbsentIds = requestedIds.filter((id) => verified.has(id));
3414
+ result.verified = result.verifiedAbsentIds.length === requestedIds.length;
3415
+ };
3416
+ try {
3417
+ mutationLock = acquireMutationLock(config.projectId);
3418
+ for (const doc of docs) {
3419
+ const state = await joinDoc(socket, doc._id);
3420
+ const ids = remainingChangeIds(changesFrom2(state), requestedIds);
3421
+ if (!ids.length) continue;
3422
+ locations.set(doc._id, { doc, ids });
3423
+ for (const id of ids) found.add(id);
3424
+ }
3425
+ result.foundIds = requestedIds.filter((id) => found.has(id));
3426
+ result.missingIds = requestedIds.filter((id) => !found.has(id));
3427
+ refreshVerifiedIds();
3428
+ if (result.missingIds.length) {
3429
+ console.log(
3430
+ `\u26A0\uFE0F not found (already accepted/rejected?): ${result.missingIds.join(", ")}`
3431
+ );
3432
+ }
3433
+ if (!result.foundIds.length) {
3434
+ throw new Error("no matching tracked changes found");
3435
+ }
3436
+ for (const { doc, ids } of locations.values()) {
3437
+ let before;
3438
+ try {
3439
+ before = await joinDoc(socket, doc._id);
3440
+ } catch (error) {
3441
+ const outcome2 = {
3442
+ docId: doc._id,
3443
+ docPath: doc.path,
3444
+ requestedIds: ids,
3445
+ attemptedIds: [],
3446
+ fragmentCount: 0,
3447
+ status: "failed",
3448
+ remainingIds: ids,
3449
+ error: errorMessage5(error)
3450
+ };
3451
+ result.documents.push(outcome2);
3452
+ refreshVerifiedIds();
3453
+ throw new TrackedChangeMutationError(
3454
+ `Could not read ${doc.path} before rejecting tracked changes`,
3455
+ result,
3456
+ error
3457
+ );
3458
+ }
3459
+ const presentIds = remainingChangeIds(changesFrom2(before), ids);
3460
+ if (!presentIds.length) {
3461
+ result.documents.push({
3462
+ docId: doc._id,
3463
+ docPath: doc.path,
3464
+ requestedIds: ids,
3465
+ attemptedIds: [],
3466
+ fragmentCount: 0,
3467
+ beforeVersion: before.version,
3468
+ afterVersion: before.version,
3469
+ status: "already-absent",
3470
+ textVerified: true,
3471
+ remainingIds: []
3472
+ });
3473
+ verifiedDocs.add(doc._id);
3474
+ refreshVerifiedIds();
3475
+ continue;
3476
+ }
3477
+ let plan;
3478
+ try {
3479
+ plan = buildRejectionPlan(before.lines.join("\n"), changesFrom2(before), presentIds);
3480
+ } catch (error) {
3481
+ const outcome2 = {
3482
+ docId: doc._id,
3483
+ docPath: doc.path,
3484
+ requestedIds: ids,
3485
+ attemptedIds: [],
3486
+ fragmentCount: 0,
3487
+ beforeVersion: before.version,
3488
+ status: "failed",
3489
+ remainingIds: presentIds,
3490
+ error: errorMessage5(error)
3491
+ };
3492
+ result.documents.push(outcome2);
3493
+ refreshVerifiedIds();
3494
+ throw new TrackedChangeMutationError(
3495
+ `Could not safely plan rejection in ${doc.path}: ${errorMessage5(error)}`,
3496
+ result,
3497
+ error
3498
+ );
3499
+ }
3500
+ const outcome = {
3501
+ docId: doc._id,
3502
+ docPath: doc.path,
3503
+ requestedIds: ids,
3504
+ attemptedIds: presentIds,
3505
+ fragmentCount: plan.fragmentCount,
3506
+ beforeVersion: before.version,
3507
+ status: "failed",
3508
+ textVerified: false,
3509
+ remainingIds: presentIds
3510
+ };
3511
+ result.documents.push(outcome);
3512
+ result.attemptedIds = uniqueChangeIds([...result.attemptedIds, ...presentIds]);
3513
+ let mutationError;
3514
+ try {
3515
+ await applyOtUpdateAndWait(socket, doc._id, {
3516
+ doc: doc._id,
3517
+ op: plan.operations,
3518
+ v: before.version,
3519
+ meta: {}
3520
+ });
3521
+ } catch (error) {
3522
+ mutationError = error;
3523
+ }
3524
+ let after;
3525
+ let readbackError;
3526
+ try {
3527
+ after = await verifyRejectedDocument(socket, doc._id, presentIds, plan.expectedText);
3528
+ } catch (error) {
3529
+ readbackError = error;
3530
+ }
3531
+ if (after) {
3532
+ outcome.afterVersion = after.version;
3533
+ outcome.remainingIds = remainingChangeIds(changesFrom2(after), presentIds);
3534
+ outcome.textVerified = after.lines.join("\n") === plan.expectedText;
3535
+ }
3536
+ if (after && !outcome.remainingIds.length && outcome.textVerified) {
3537
+ outcome.status = "verified";
3538
+ if (mutationError) outcome.error = `transport warning: ${errorMessage5(mutationError)}`;
3539
+ verifiedDocs.add(doc._id);
3540
+ refreshVerifiedIds();
3541
+ continue;
3542
+ }
3543
+ const reasons = [];
3544
+ if (mutationError) reasons.push(errorMessage5(mutationError));
3545
+ if (readbackError) reasons.push(`readback failed: ${errorMessage5(readbackError)}`);
3546
+ if (outcome.remainingIds.length) {
3547
+ reasons.push(`change ids still present: ${outcome.remainingIds.join(", ")}`);
3548
+ }
3549
+ if (after && !outcome.textVerified) {
3550
+ reasons.push("final document text did not match the planned rejection");
3551
+ }
3552
+ outcome.error = reasons.join("; ") || "rejection could not be verified";
3553
+ refreshVerifiedIds();
3554
+ throw new TrackedChangeMutationError(
3555
+ `Rejected changes in ${doc.path} could not be verified: ${outcome.error}`,
3556
+ result,
3557
+ mutationError ?? readbackError
3558
+ );
829
3559
  }
830
- const state = await joinDoc(socket, docId);
831
- const c = (state.ranges.changes ?? []).find((x) => x.id === id);
832
- if (!c) continue;
833
- const op = typeof c.op?.i === "string" ? { p: c.op.p, d: c.op.i } : { p: c.op.p, i: c.op.d };
834
- const update = { doc: docId, op: [op], v: state.version, meta: {} };
835
- const ack = await socket.emit("applyOtUpdate", [docId, update], 15e3);
836
- if (ack?.[0]) {
3560
+ refreshVerifiedIds();
3561
+ if (!result.verified) {
3562
+ throw new TrackedChangeMutationError(
3563
+ `Rejection incomplete; unverified ids: ${requestedIds.filter((id) => !result.verifiedAbsentIds.includes(id)).join(", ")}`,
3564
+ result
3565
+ );
3566
+ }
3567
+ console.log(
3568
+ `\u2705 Rejected ${result.foundIds.length} tracked change(s) (${result.documents.reduce((count, doc) => count + doc.fragmentCount, 0)} range fragment(s)); verified by readback`
3569
+ );
3570
+ return result;
3571
+ } finally {
3572
+ try {
837
3573
  socket.close();
838
- throw new Error(`Overleaf rejected the inverse op for ${id}: ${JSON.stringify(ack[0])}`);
3574
+ } finally {
3575
+ mutationLock?.release();
839
3576
  }
840
- rejected++;
841
3577
  }
842
- socket.close();
843
- if (!rejected) throw new Error("no matching tracked changes found");
844
- console.log(`\u2705 Rejected ${rejected} tracked change(s)`);
845
3578
  }
846
3579
 
847
3580
  // src/commands/login.ts
@@ -893,20 +3626,27 @@ async function captureCookieViaBrowser(baseUrl) {
893
3626
 
894
3627
  // src/commands/link.ts
895
3628
  function link(projectId, baseUrl) {
896
- const path = saveProjectConfig({ projectId, ...baseUrl ? { baseUrl } : {} });
897
- console.log(`\u2705 Linked this repo to Overleaf project ${projectId} \u2192 ${path}`);
898
- console.log(" (safe to commit \u2014 it contains no secrets.)");
3629
+ const mutationLock = acquireMutationLock(projectId);
3630
+ try {
3631
+ const path = saveProjectConfig({ projectId, ...baseUrl ? { baseUrl } : {} });
3632
+ console.log(`\u2705 Linked this repo to Overleaf project ${projectId} \u2192 ${path}`);
3633
+ console.log(" (safe to commit \u2014 it contains no secrets.)");
3634
+ } finally {
3635
+ mutationLock.release();
3636
+ }
899
3637
  }
900
3638
 
901
3639
  // src/cli.ts
902
3640
  function getFlag(name) {
903
3641
  const i = process.argv.indexOf(`--${name}`);
904
- return i >= 0 ? process.argv[i + 1] : void 0;
3642
+ const value = i >= 0 ? process.argv[i + 1] : void 0;
3643
+ return value && !value.startsWith("--") ? value : void 0;
905
3644
  }
906
3645
  function getAll(name) {
907
3646
  const out = [];
908
3647
  process.argv.forEach((a, i) => {
909
- if (a === `--${name}` && process.argv[i + 1]) out.push(process.argv[i + 1]);
3648
+ const value = process.argv[i + 1];
3649
+ if (a === `--${name}` && value && !value.startsWith("--")) out.push(value);
910
3650
  });
911
3651
  return out.flatMap((v) => v.split(",")).map((s) => s.trim()).filter(Boolean);
912
3652
  }
@@ -917,22 +3657,82 @@ function usage() {
917
3657
  console.log(" link --project <id> Link this repo to an Overleaf project\n");
918
3658
  console.log("Read:");
919
3659
  console.log(" pull [--out <dir>] Comments + tracked changes \u2192 sidecar\n");
920
- console.log("Content (replaces the git bridge \u2014 review-safe):");
921
- console.log(" fetch [--file <f>] [--dry-run] Overleaf text \u2192 local files (read-only)");
3660
+ console.log("Safe review workflow:");
3661
+ console.log(" review start [--file <f>] [--out <dir>] Fetch text/base, then pull review data");
3662
+ console.log(" review plan --out <plan.json> [options] Save a complete binding push plan");
3663
+ console.log(" review submit --plan <plan.json> Validate, apply, and verify that plan");
3664
+ console.log(" [--acknowledge-ambiguous] Continue only after manual reconciliation\n");
3665
+ console.log("Content (replaces the git bridge):");
3666
+ console.log(" fetch [--file <f>] [--dry-run] Overleaf text \u2192 local files + saved base");
922
3667
  console.log(" upload <path\u2026> [--folder <name>] Upload figures / new files to Overleaf\n");
923
3668
  console.log("Comments:");
924
- console.log(" comment --anchor <text> --message <text> [--doc <name>] [--nth <n>]");
925
- console.log(" reply --thread <id> --message <text> Reply to an existing thread");
3669
+ console.log(" comment --anchor <text> --message <text> [--doc <name>] [--nth <n>] [--force]");
3670
+ console.log(" reply --thread <id> --message <text> [--force]");
926
3671
  console.log(" resolve --thread <id> [--reopen] Resolve/reopen a thread");
927
3672
  console.log(" delete-comment --thread <id> Delete a whole thread");
928
3673
  console.log(" delete-message --message-id <id> Delete a single message\n");
929
3674
  console.log("Tracked changes:");
930
3675
  console.log(" push [--file <f>] [--doc <name>] [--direct] [--dry-run]");
3676
+ console.log(" [--plan-out <plan.json> | --plan <plan.json>] [--allow-overlap]");
3677
+ console.log(" [--acknowledge-ambiguous] (after manually reconciling a prior uncertain push)");
3678
+ console.log(" [--unsafe-no-base] (legacy escape hatch; disables three-way protection)");
931
3679
  console.log(" Send local edits as tracked suggestions (--direct = plain edits)");
932
3680
  console.log(" accept --change <id> [--change <id> \u2026] Accept collaborators\u2019 changes");
933
3681
  console.log(" reject --change <id> [--change <id> \u2026] Reject collaborators\u2019 changes");
934
3682
  console.log("\n(thread/change ids come from `pull`; --change accepts comma-separated lists too)");
935
3683
  }
3684
+ async function pullAndReport(out, options = {}) {
3685
+ const data = await pull(out, options);
3686
+ console.log(
3687
+ `Pulled ${data.comments.length} comment(s) and ${data.changes.length} tracked change(s) from "${data.project}" \u2192 ${out}/`
3688
+ );
3689
+ }
3690
+ function pushOptions() {
3691
+ return {
3692
+ file: getFlag("file"),
3693
+ docName: getFlag("doc"),
3694
+ direct: process.argv.includes("--direct"),
3695
+ dryRun: process.argv.includes("--dry-run"),
3696
+ unsafeNoBase: process.argv.includes("--unsafe-no-base"),
3697
+ allowOverlap: process.argv.includes("--allow-overlap"),
3698
+ planOut: getFlag("plan-out"),
3699
+ plan: getFlag("plan"),
3700
+ allowAmbiguousRetry: process.argv.includes("--acknowledge-ambiguous")
3701
+ };
3702
+ }
3703
+ async function mutateTrackedChanges(action, ids, mutate) {
3704
+ let receipt = beginReceipt(action, {
3705
+ projectId: config.projectId,
3706
+ requestedIds: ids,
3707
+ phase: "preflight"
3708
+ });
3709
+ try {
3710
+ receipt = updateReceipt(receipt, "in_progress", {
3711
+ phase: "mutating",
3712
+ mutationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
3713
+ });
3714
+ const result = await mutate(ids);
3715
+ receipt = updateReceipt(receipt, "succeeded", {
3716
+ phase: "complete",
3717
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
3718
+ result
3719
+ });
3720
+ console.log(`Audit receipt: ${receipt.path}`);
3721
+ } catch (error) {
3722
+ const result = error instanceof TrackedChangeMutationError ? error.result : void 0;
3723
+ const outcomeUnknown = Boolean(result?.attemptedIds.length && !result.verified);
3724
+ receipt = updateReceipt(receipt, outcomeUnknown ? "ambiguous" : "failed", {
3725
+ phase: outcomeUnknown ? "outcome_unknown" : "failed",
3726
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
3727
+ error: error instanceof Error ? error.message : String(error),
3728
+ ...result ? { result } : {}
3729
+ });
3730
+ throw new Error(
3731
+ `${error instanceof Error ? error.message : String(error)}. Audit receipt: ${receipt.path}`,
3732
+ { cause: error }
3733
+ );
3734
+ }
3735
+ }
936
3736
  async function main() {
937
3737
  const cmd = process.argv[2];
938
3738
  switch (cmd) {
@@ -951,20 +3751,43 @@ async function main() {
951
3751
  }
952
3752
  case "pull": {
953
3753
  const out = getFlag("out") ?? ".overleaf";
954
- const data = await pull(out);
955
- console.log(
956
- `Pulled ${data.comments.length} comment(s) and ${data.changes.length} tracked change(s) from "${data.project}" \u2192 ${out}/`
957
- );
3754
+ await pullAndReport(out);
958
3755
  break;
959
3756
  }
960
3757
  case "push":
961
- await push({
962
- file: getFlag("file"),
963
- docName: getFlag("doc"),
964
- direct: process.argv.includes("--direct"),
965
- dryRun: process.argv.includes("--dry-run")
966
- });
3758
+ await push(pushOptions());
3759
+ break;
3760
+ case "review": {
3761
+ const reviewCommand = process.argv[3];
3762
+ if (process.argv.includes("--through")) {
3763
+ fail("Section-scoped --through is not implemented; no review action was performed.");
3764
+ }
3765
+ if (reviewCommand === "start") {
3766
+ const file = getFlag("file") ?? getFlag("doc");
3767
+ const mutationLock = acquireMutationLock(config.projectId);
3768
+ try {
3769
+ await fetchDocs({ file, dryRun: false, acquireLock: false });
3770
+ await pullAndReport(getFlag("out") ?? ".overleaf", { acquireLock: false });
3771
+ } finally {
3772
+ mutationLock.release();
3773
+ }
3774
+ } else if (reviewCommand === "plan") {
3775
+ const out = getFlag("out");
3776
+ if (!out) fail("review plan requires --out <plan.json>");
3777
+ await push({ ...pushOptions(), plan: void 0, planOut: out, dryRun: true });
3778
+ } else if (reviewCommand === "submit") {
3779
+ const plan = getFlag("plan");
3780
+ if (!plan) fail("review submit requires --plan <plan.json>");
3781
+ await push({
3782
+ plan,
3783
+ allowAmbiguousRetry: process.argv.includes("--acknowledge-ambiguous")
3784
+ });
3785
+ } else {
3786
+ usage();
3787
+ fail("review requires start, plan, or submit");
3788
+ }
967
3789
  break;
3790
+ }
968
3791
  case "fetch":
969
3792
  await fetchDocs({ file: getFlag("file"), dryRun: process.argv.includes("--dry-run") });
970
3793
  break;
@@ -988,20 +3811,30 @@ async function main() {
988
3811
  const message = getFlag("message");
989
3812
  if (!anchor || !message) fail("comment requires --anchor <text> and --message <text>");
990
3813
  const nthRaw = getFlag("nth");
991
- await comment({ docName: getFlag("doc"), anchor, message, occurrence: nthRaw ? Number(nthRaw) : void 0 });
3814
+ const occurrence = nthRaw === void 0 ? void 0 : Number(nthRaw);
3815
+ if (occurrence !== void 0 && (!Number.isSafeInteger(occurrence) || occurrence < 1)) {
3816
+ fail("comment --nth must be a positive integer");
3817
+ }
3818
+ await comment({
3819
+ docName: getFlag("doc"),
3820
+ anchor,
3821
+ message,
3822
+ occurrence,
3823
+ force: process.argv.includes("--force")
3824
+ });
992
3825
  break;
993
3826
  }
994
3827
  case "reply": {
995
3828
  const thread = getFlag("thread");
996
3829
  const message = getFlag("message");
997
3830
  if (!thread || !message) fail("reply requires --thread <id> and --message <text>");
998
- await reply(thread, message);
3831
+ await reply(thread, message, { force: process.argv.includes("--force") });
999
3832
  break;
1000
3833
  }
1001
3834
  case "resolve": {
1002
3835
  const thread = getFlag("thread");
1003
3836
  if (!thread) fail("resolve requires --thread <id>");
1004
- await resolve(thread, process.argv.includes("--reopen"));
3837
+ await resolve2(thread, process.argv.includes("--reopen"));
1005
3838
  break;
1006
3839
  }
1007
3840
  case "delete-comment": {
@@ -1019,13 +3852,13 @@ async function main() {
1019
3852
  case "accept": {
1020
3853
  const ids = getAll("change");
1021
3854
  if (!ids.length) fail("accept requires --change <id>");
1022
- await accept(ids);
3855
+ await mutateTrackedChanges("accept", ids, accept);
1023
3856
  break;
1024
3857
  }
1025
3858
  case "reject": {
1026
3859
  const ids = getAll("change");
1027
3860
  if (!ids.length) fail("reject requires --change <id>");
1028
- await reject(ids);
3861
+ await mutateTrackedChanges("reject", ids, reject);
1029
3862
  break;
1030
3863
  }
1031
3864
  default: