@erdemtuna/doc-review 0.8.0 → 0.9.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.
package/src/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
- import http from "node:http";
4
3
  import path from "node:path";
5
4
  import { spawn } from "node:child_process";
6
5
  import { fileURLToPath } from "node:url";
@@ -15,6 +14,7 @@ import {
15
14
  } from "./paths.js";
16
15
  import { readServerLock } from "./server-lock.js";
17
16
  import { installSkills, shellQuote } from "./setup.js";
17
+ import { createDeadline, DEFAULT_POLL_SECONDS, isRecoverableTransportError, parseServerResponse, pollUntilDeadline, requestRaw } from "./poll-transport.js";
18
18
 
19
19
  const here = path.dirname(fileURLToPath(import.meta.url));
20
20
  const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
@@ -24,12 +24,16 @@ const HELP = `doc-review ${pkg.version}
24
24
  doc-review <file-or-localhost-url> Open a file or localhost page for review
25
25
  doc-review poll <target> Wait for feedback, print it as JSON (for agents)
26
26
  --ack <batch_id> Acknowledge that exact delivered batch, then keep waiting
27
- --timeout <secs> Exit with {"status":"timeout"} if nothing arrives
27
+ --timeout <secs> End-to-end cutoff; default 12 hours (43200 seconds)
28
28
  doc-review status <target> Report whether feedback is waiting, without blocking
29
29
  doc-review setup Teach Claude Code / Codex how to use doc-review
30
30
  doc-review setup --global ...for every project, not just this one
31
31
 
32
32
  Everything runs locally. No account, no cloud, no database.
33
+ Use Review / Changes in the browser for retained content history.
34
+ Plain HTML edits autosave. Self-contained file scripts run automatically with feedback-only edits.
35
+ Use More for optional script-disabled recovery. Comparison capture does not block sending feedback.
36
+ Acknowledgement handles feedback; browser result capture may complete later.
33
37
  `;
34
38
 
35
39
  // --------------------------------------------------------------- server glue
@@ -42,72 +46,64 @@ function readServerRecord() {
42
46
  }
43
47
  }
44
48
 
45
- function request(server, options, body) {
46
- const port = typeof server === "number" ? server : server.port;
47
- const token = typeof server === "number" ? "" : server.token || "";
48
- return new Promise((resolve, reject) => {
49
- const req = http.request(
50
- {
51
- host: "127.0.0.1",
52
- port,
53
- ...options,
54
- headers: { ...(token ? { "x-doc-review-token": token } : {}), ...(options.headers || {}) },
55
- },
56
- (res) => {
57
- let raw = "";
58
- res.setEncoding("utf8");
59
- res.on("data", (chunk) => {
60
- raw += chunk;
61
- });
62
- res.on("end", () => resolve({ status: res.statusCode, raw }));
63
- }
64
- );
65
- req.on("error", reject);
66
- if (options.timeout) req.setTimeout(options.timeout, () => req.destroy(new Error("timeout")));
67
- if (body) req.write(JSON.stringify(body));
68
- req.end();
69
- });
70
- }
49
+ const request = requestRaw;
71
50
 
72
- async function alive(server) {
51
+ async function alive(server, deadline) {
52
+ if (!server?.port || !server.instance_id) return false;
53
+ const lock = readServerLock();
54
+ if (lock?.pid !== server.pid || lock?.instance_id !== server.instance_id) return false;
73
55
  try {
74
- const lock = readServerLock();
75
- if (lock?.pid !== server.pid || lock?.instance_id !== server.instance_id) return false;
76
- const res = await request(server, { method: "GET", path: "/health", timeout: 1200 });
77
- if (res.status !== 200) return false;
78
- const health = JSON.parse(res.raw);
79
- return (
80
- serverProtocolMatches(health.protocol) &&
81
- health.pid === server.pid &&
82
- health.instance_id === server.instance_id
83
- );
84
- } catch {
85
- return false;
56
+ deadline?.check();
57
+ const res = await request(server, {
58
+ method: "GET", path: "/health", timeout: Math.min(1200, deadline?.remaining() ?? 1200),
59
+ }, undefined, { time: deadline?.time });
60
+ deadline?.check();
61
+ const health = parseServerResponse(res);
62
+ if (health.pid !== server.pid || health.instance_id !== server.instance_id) {
63
+ throw new Error("The doc-review server identity does not match its writer lock. End the review and restart the server.");
64
+ }
65
+ if (!serverProtocolMatches(health.protocol) || !serverProtocolMatches(server.protocol)) {
66
+ throw new Error(
67
+ `Incompatible live doc-review server (protocol ${health.protocol}; this CLI requires ${SERVER_PROTOCOL}). ` +
68
+ "End active reviews and stop/restart the old doc-review server before retrying. " +
69
+ "Its live writer lock and queued feedback have not been changed.",
70
+ );
71
+ }
72
+ return true;
73
+ } catch (err) {
74
+ if (isRecoverableTransportError(err)) return false;
75
+ throw err;
86
76
  }
87
77
  }
88
78
 
89
- async function ensureServer() {
79
+ async function ensureServer(deadline = createDeadline(20)) {
80
+ deadline.check();
90
81
  ensureStateDir();
91
82
  for (let launch = 0; launch < 3; launch += 1) {
92
83
  const saved = readServerRecord();
93
- if (serverProtocolMatches(saved?.protocol) && saved.port && saved.instance_id && (await alive(saved))) return saved;
84
+ if (await alive(saved, deadline)) return saved;
85
+ deadline.check();
94
86
 
95
87
  const child = spawn(process.execPath, [path.join(here, "server-entry.js")], {
96
88
  detached: true,
97
89
  stdio: "ignore",
98
90
  });
91
+ let launchError;
92
+ child.on("error", (err) => { launchError = err; });
99
93
  child.unref();
100
94
 
101
95
  for (let attempt = 0; attempt < 60; attempt += 1) {
102
- await new Promise((r) => setTimeout(r, 100));
96
+ await deadline.sleep(100);
97
+ if (launchError) throw launchError;
103
98
  const record = readServerRecord();
104
- // Same protocol gate as above: a still-running server from an older
105
- // version answers /health too, and must not be adopted here.
106
- if (serverProtocolMatches(record?.protocol) && record.port && record.instance_id && (await alive(record))) return record;
99
+ if (await alive(record, deadline)) return record;
100
+ if (child.exitCode !== null && child.exitCode !== 0) {
101
+ throw new Error("The local doc-review server failed to start. Check the state directory permissions and server startup diagnostics before retrying.");
102
+ }
107
103
  if (child.exitCode !== null && !readServerLock()) break;
108
104
  }
109
105
  }
110
- throw new Error("Could not start the local doc-review server.");
106
+ throw Object.assign(new Error("Could not start the local doc-review server yet."), { code: "SERVER_START_PENDING" });
111
107
  }
112
108
 
113
109
  function openBrowser(url) {
@@ -142,48 +138,6 @@ async function openCommand(input) {
142
138
  console.log(`\nWaiting for feedback? Run:\n doc-review poll ${shellQuote(target.value)}`);
143
139
  }
144
140
 
145
- /**
146
- * One long-poll attempt. Resolves { kind: "data", raw } when the server
147
- * answers, or { kind: "timeout" } when the caller's deadline passes first.
148
- */
149
- function pollOnce(server, target, ackId, timeoutMs) {
150
- const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
151
- return new Promise((resolve, reject) => {
152
- let done = false;
153
- const settle = (fn, value) => {
154
- if (done) return;
155
- done = true;
156
- clearTimeout(timer);
157
- fn(value);
158
- };
159
- const req = http.request(
160
- {
161
- host: "127.0.0.1",
162
- port: server.port,
163
- method: "GET",
164
- path: `/api/poll?${query}`,
165
- headers: { "x-doc-review-token": server.token || "" },
166
- },
167
- (res) => {
168
- let raw = "";
169
- res.setEncoding("utf8");
170
- res.on("data", (chunk) => {
171
- raw += chunk;
172
- });
173
- res.on("end", () => settle(resolve, { kind: "data", raw: raw.trim() }));
174
- }
175
- );
176
- const timer = timeoutMs
177
- ? setTimeout(() => {
178
- settle(resolve, { kind: "timeout" });
179
- req.destroy();
180
- }, timeoutMs)
181
- : null;
182
- req.on("error", (err) => settle(reject, err));
183
- req.end();
184
- });
185
- }
186
-
187
141
  /**
188
142
  * The consumer is an agent reading a pipe. process.exit() does not wait for
189
143
  * pending stdout writes, so a large payload could arrive truncated — always
@@ -193,50 +147,18 @@ function writeStdout(text) {
193
147
  return new Promise((resolve) => process.stdout.write(text, resolve));
194
148
  }
195
149
 
196
- function printTimeout(waitedSecs) {
197
- const payload = {
198
- status: "timeout",
199
- waited_seconds: waitedSecs,
200
- next_step:
201
- "No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
202
- };
203
- return writeStdout(`${JSON.stringify(payload, null, 2)}\n`);
204
- }
205
-
206
- async function pollCommand(input, { ackId = "", timeoutSecs = 0 } = {}) {
150
+ async function pollCommand(input, { ackId = "", timeoutSecs = DEFAULT_POLL_SECONDS } = {}) {
151
+ const deadline = createDeadline(timeoutSecs);
207
152
  const target = canonicalTarget(input).value;
208
- let server = await ensureServer();
209
153
 
210
154
  const label = /^https?:\/\//i.test(target) ? target : path.basename(target);
211
155
  process.stderr.write(`Waiting for feedback on ${label} — comment in the browser, then hit Send.\n`);
212
156
 
213
- const deadline = timeoutSecs ? Date.now() + timeoutSecs * 1000 : null;
214
- for (let attempt = 0; attempt < 3; attempt += 1) {
215
- const remaining = deadline ? deadline - Date.now() : 0;
216
- if (deadline && remaining <= 0) return printTimeout(timeoutSecs);
217
- let result;
218
- try {
219
- result = await pollOnce(server, target, ackId && attempt === 0 ? ackId : "", remaining);
220
- } catch (err) {
221
- process.stderr.write(`Lost the connection (${err.message}); retrying.\n`);
222
- server = await ensureServer();
223
- continue;
224
- }
225
- if (result.kind === "timeout") return printTimeout(timeoutSecs);
226
- if (!result.raw) {
227
- server = await ensureServer();
228
- continue;
229
- }
230
- try {
231
- const batch = JSON.parse(result.raw);
232
- await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
233
- return;
234
- } catch {
235
- process.stderr.write("Unexpected response from the doc-review server; retrying.\n");
236
- }
237
- }
238
- process.stderr.write("Gave up waiting for feedback.\n");
239
- process.exit(1);
157
+ const batch = await pollUntilDeadline({
158
+ target, ackId, deadline, discover: ensureServer,
159
+ diagnostic: (text) => process.stderr.write(text),
160
+ });
161
+ await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
240
162
  }
241
163
 
242
164
  /**
@@ -297,7 +219,7 @@ process.on("SIGINT", () => {
297
219
  });
298
220
 
299
221
  function parsePollArgs(rest) {
300
- const parsed = { file: "", ackId: "", timeoutSecs: 0 };
222
+ const parsed = { file: "", ackId: "", timeoutSecs: DEFAULT_POLL_SECONDS };
301
223
  let sawTimeout = false;
302
224
  for (let i = 0; i < rest.length; i += 1) {
303
225
  const arg = rest[i];
@@ -1,5 +1,92 @@
1
1
  const finite = (value) => typeof value === "number" && Number.isFinite(value);
2
2
 
3
+ export function groupCommentTargets(targets) {
4
+ const groups = new Map();
5
+ for (const [id, element] of targets) {
6
+ if (!element?.isConnected) continue;
7
+ if (!groups.has(element)) groups.set(element, []);
8
+ groups.get(element).push(id);
9
+ }
10
+ return groups;
11
+ }
12
+
13
+ export function nextCommentId(ids, activeId) {
14
+ return ids.length ? ids[(ids.indexOf(activeId) + 1) % ids.length] : null;
15
+ }
16
+
17
+ /** Retarget dwell is independent of initial intent; revisiting a candidate
18
+ * does not restart its clock. The caller owns corridor and selection policy. */
19
+ export function createHoverIntent(commit, {
20
+ initial = 150, retarget = 100, exit = 120,
21
+ schedule = setTimeout, unschedule = clearTimeout,
22
+ } = {}) {
23
+ let current = null;
24
+ let candidate = null;
25
+ let timer = null;
26
+ const cancel = () => {
27
+ unschedule(timer);
28
+ timer = null;
29
+ candidate = null;
30
+ };
31
+ return {
32
+ cancel,
33
+ reset() { cancel(); current = null; },
34
+ request(key, payload, immediate = false) {
35
+ if (key === current && !immediate) { cancel(); return; }
36
+ if (timer !== null && candidate === key && !immediate) return;
37
+ cancel();
38
+ candidate = key;
39
+ const apply = () => {
40
+ timer = null;
41
+ current = key;
42
+ candidate = null;
43
+ commit(payload);
44
+ };
45
+ if (immediate) apply();
46
+ else timer = schedule(apply, key === null ? exit : current === null ? initial : retarget);
47
+ },
48
+ };
49
+ }
50
+
51
+ export function normalizeSelectionRange(map, range) {
52
+ let first = null;
53
+ let last = null;
54
+ // Element offsets index children, not characters. Intersect the filtered text
55
+ // nodes so an exclusive endpoint before the next block never includes it.
56
+ for (const entry of map) {
57
+ const length = entry.node.nodeValue.length;
58
+ if (range.comparePoint(entry.node, length) < 0 || range.comparePoint(entry.node, 0) > 0) continue;
59
+ const from = entry.node === range.startContainer ? range.startOffset : 0;
60
+ const to = entry.node === range.endContainer ? range.endOffset : length;
61
+ if (to <= from) continue;
62
+ first ||= { entry, offset: from };
63
+ last = { entry, offset: to };
64
+ }
65
+ if (!first || !last) return null;
66
+ const normalized = range.cloneRange();
67
+ normalized.setStart(first.entry.node, first.offset);
68
+ normalized.setEnd(last.entry.node, last.offset);
69
+ return {
70
+ start: first.entry.start + first.offset,
71
+ end: last.entry.start + last.offset,
72
+ range: normalized,
73
+ };
74
+ }
75
+
76
+ export function sameRange(one, two) {
77
+ return !!one && !!two &&
78
+ one.startContainer === two.startContainer && one.startOffset === two.startOffset &&
79
+ one.endContainer === two.endContainer && one.endOffset === two.endOffset;
80
+ }
81
+
82
+ export function pointInCommentApproach(point, target, action) {
83
+ if (!target || !action || action.width <= 0 || action.height <= 0) return false;
84
+ return point.x >= Math.min(target.left, action.left) &&
85
+ point.x <= Math.max(target.right, action.right) &&
86
+ point.y >= Math.min(target.top, action.top) &&
87
+ point.y <= Math.max(target.bottom, action.bottom);
88
+ }
89
+
3
90
  export function sanitizeRect(rect) {
4
91
  if (!rect) return null;
5
92
  const values = ["left", "top", "right", "bottom", "width", "height"];