@erdemtuna/doc-review 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -5
- package/package.json +3 -2
- package/src/SKILL.md +54 -7
- package/src/anchor-text.js +45 -0
- package/src/atomic-write.js +53 -0
- package/src/chrome-client.js +1272 -252
- package/src/chrome-session.js +70 -0
- package/src/chrome.css +278 -329
- package/src/chrome.html +81 -35
- package/src/cli.js +58 -132
- package/src/comment-anchor.js +22 -0
- package/src/comment-target.js +77 -0
- package/src/edit-limits.js +23 -0
- package/src/icons.js +251 -0
- package/src/paths.js +5 -1
- package/src/poll-transport.js +222 -0
- package/src/positioning.js +107 -0
- package/src/review-mode.js +59 -0
- package/src/sdk.js +792 -69
- package/src/serialize.js +0 -14
- package/src/server.js +39 -9
- package/src/setup-guidance.js +128 -0
- package/src/setup.js +41 -15
- package/src/state.js +37 -21
package/src/chrome.html
CHANGED
|
@@ -8,6 +8,27 @@
|
|
|
8
8
|
</head>
|
|
9
9
|
<body data-session="__SESSION_ID__" data-token="__TOKEN__">
|
|
10
10
|
<div class="app">
|
|
11
|
+
<header class="toolbar" aria-label="Review controls">
|
|
12
|
+
<div class="toolbar-product" aria-hidden="true"></div>
|
|
13
|
+
<div class="mode-control">
|
|
14
|
+
<button type="button" id="modeButton" class="toolbar-btn" aria-haspopup="menu" aria-expanded="false">
|
|
15
|
+
<span id="modeIcon"></span><span id="modeLabel">View</span><span id="modeChevron"></span>
|
|
16
|
+
</button>
|
|
17
|
+
<div id="modeMenu" class="mode-menu" role="menu" hidden>
|
|
18
|
+
<button type="button" role="menuitemradio" aria-checked="true" data-mode="view">
|
|
19
|
+
<span data-icon="eye"></span><span><strong>View</strong><small>Editing off, comments enabled</small></span><span class="menu-check"></span>
|
|
20
|
+
</button>
|
|
21
|
+
<button type="button" role="menuitemradio" aria-checked="false" data-mode="edit">
|
|
22
|
+
<span data-icon="pencil"></span><span><strong>Edit</strong><small>Direct editing on</small></span><span class="menu-check"></span>
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="toolbar-actions">
|
|
27
|
+
<button type="button" id="commentsButton" class="toolbar-btn" aria-controls="drawer" aria-label="Comments" aria-expanded="false">
|
|
28
|
+
<span data-icon="messages"></span><span class="toolbar-label">Comments</span><span id="toolbarCount" class="count-badge">0</span>
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
</header>
|
|
11
32
|
<main class="stage">
|
|
12
33
|
<iframe
|
|
13
34
|
id="frame"
|
|
@@ -15,34 +36,50 @@
|
|
|
15
36
|
sandbox="allow-scripts allow-forms allow-modals"></iframe>
|
|
16
37
|
</main>
|
|
17
38
|
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
<span class="section">Feedback</span>
|
|
24
|
-
<span class="dim" id="count">0</span>
|
|
25
|
-
<button type="button" id="theme" class="icon-btn" title="Switch chrome to dark" aria-label="Switch chrome to dark">☾</button>
|
|
39
|
+
<section id="compose" class="contextual-compose" role="dialog" aria-modal="false" aria-labelledby="composeTitle" hidden>
|
|
40
|
+
<div class="compose-head">
|
|
41
|
+
<div>
|
|
42
|
+
<span class="chip" id="composeKind">Selection</span>
|
|
43
|
+
<strong id="composeTitle">Add comment</strong>
|
|
26
44
|
</div>
|
|
45
|
+
<button type="button" id="composeClose" class="icon-btn" title="Cancel comment" aria-label="Cancel comment"></button>
|
|
46
|
+
</div>
|
|
47
|
+
<div id="composeDirection" class="compose-direction" hidden>
|
|
48
|
+
<span id="composeDirectionText"></span>
|
|
49
|
+
<button type="button" id="composeReveal" class="link-action">Back to selection</button>
|
|
50
|
+
</div>
|
|
51
|
+
<p class="quote" id="composeQuote"></p>
|
|
52
|
+
<textarea id="composeText" rows="3" placeholder="What should change?" aria-label="Comment" aria-describedby="composeHelp"></textarea>
|
|
53
|
+
<p id="composeHelp" class="compose-help"><span>Enter to comment</span><span>Shift+Enter for new line</span></p>
|
|
54
|
+
<p id="composeError" class="compose-error" role="alert" hidden></p>
|
|
55
|
+
<div class="compose-actions">
|
|
56
|
+
<button type="button" id="composeAdd" class="btn-primary"><span data-icon="messageSquarePlus"></span><span id="composeAddLabel">Comment</span></button>
|
|
57
|
+
<button type="button" id="composeCancel" class="btn-ghost">Cancel</button>
|
|
58
|
+
</div>
|
|
59
|
+
</section>
|
|
27
60
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<textarea id="composeText" rows="3" placeholder="What should change?"></textarea>
|
|
36
|
-
<div class="compose-actions">
|
|
37
|
-
<button type="button" id="composeAdd" class="btn-primary">Add comment <span class="key">⏎</span></button>
|
|
38
|
-
<button type="button" id="composeCancel" class="btn-ghost">Cancel</button>
|
|
39
|
-
</div>
|
|
40
|
-
</div>
|
|
61
|
+
<article id="alignedCard" class="aligned-card" hidden></article>
|
|
62
|
+
|
|
63
|
+
<aside class="drawer" id="drawer" aria-label="Review feedback" aria-hidden="true">
|
|
64
|
+
<div class="drawer-head">
|
|
65
|
+
<div>
|
|
66
|
+
<span class="section">Review</span>
|
|
67
|
+
<h2 id="drawerTitle">Comments</h2>
|
|
41
68
|
</div>
|
|
69
|
+
<button type="button" id="theme" class="icon-btn" title="Switch chrome to dark" aria-label="Switch chrome to dark"></button>
|
|
70
|
+
<button type="button" id="drawerClose" class="icon-btn" title="Close review drawer" aria-label="Close review drawer"></button>
|
|
71
|
+
</div>
|
|
72
|
+
<div id="commentsSection" class="drawer-inventory" tabindex="-1" aria-labelledby="inventoryHeading">
|
|
73
|
+
<section>
|
|
74
|
+
<div class="rail-head">
|
|
75
|
+
<span class="section" id="inventoryHeading">Comments</span>
|
|
76
|
+
<span class="dim" id="count">0</span>
|
|
77
|
+
</div>
|
|
42
78
|
|
|
43
|
-
|
|
79
|
+
<div id="cards" class="cards"></div>
|
|
44
80
|
|
|
45
|
-
|
|
81
|
+
<div id="empty" class="empty">Select text or focus an element, then use the comment button</div>
|
|
82
|
+
</section>
|
|
46
83
|
|
|
47
84
|
<section id="othersBox" hidden>
|
|
48
85
|
<div class="rail-head tight">
|
|
@@ -61,21 +98,30 @@
|
|
|
61
98
|
<div class="save-line" id="saveLine"><span class="dot"></span><span id="saveText"></span></div>
|
|
62
99
|
<div class="edit-list" id="editList"></div>
|
|
63
100
|
</section>
|
|
64
|
-
</div>
|
|
65
101
|
|
|
66
|
-
<div class="rail-foot">
|
|
67
|
-
<textarea id="note" rows="2" placeholder="Overall note…"></textarea>
|
|
68
|
-
<button type="button" id="send" class="send" disabled>Nothing to send yet</button>
|
|
69
|
-
<div class="agent-line" id="agentLine" hidden><span class="dot amber"></span><span id="agentText"></span></div>
|
|
70
|
-
<div class="handoff" id="handoff" hidden>
|
|
71
|
-
<p class="handoff-title">Sent — but no agent is listening yet.</p>
|
|
72
|
-
<p class="handoff-body">Paste this into your agent — Claude Code, Codex, or Cursor:</p>
|
|
73
|
-
<div class="handoff-cmd"><code id="handoffCmd"></code></div>
|
|
74
|
-
<button type="button" id="handoffCopy" class="btn-ghost">Copy prompt</button>
|
|
75
|
-
</div>
|
|
76
|
-
<button type="button" id="endReview" class="end-review" title="Stop this review and release the waiting agent">End review</button>
|
|
77
102
|
</div>
|
|
103
|
+
<footer id="sendSection" class="drawer-footer send-section" tabindex="-1" aria-label="Send feedback">
|
|
104
|
+
<div class="send-primary">
|
|
105
|
+
<div class="rail-head tight">
|
|
106
|
+
<span class="section">Send feedback</span>
|
|
107
|
+
</div>
|
|
108
|
+
<textarea id="note" rows="2" placeholder="Overall note…"></textarea>
|
|
109
|
+
<button type="button" id="send" class="send" disabled>Nothing to send yet</button>
|
|
110
|
+
<div class="agent-line" id="agentLine" hidden><span class="dot amber"></span><span id="agentText"></span></div>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="send-secondary">
|
|
113
|
+
<div class="handoff" id="handoff" hidden>
|
|
114
|
+
<p class="handoff-title">Sent — but no agent is listening yet.</p>
|
|
115
|
+
<p class="handoff-body">Paste this into your agent — Claude Code, Codex, or Cursor:</p>
|
|
116
|
+
<div class="handoff-cmd"><code id="handoffCmd"></code></div>
|
|
117
|
+
<button type="button" id="handoffCopy" class="btn-ghost">Copy prompt</button>
|
|
118
|
+
</div>
|
|
119
|
+
<button type="button" id="endReview" class="end-review" title="Stop this review and release the waiting agent">End review</button>
|
|
120
|
+
</div>
|
|
121
|
+
</footer>
|
|
78
122
|
</aside>
|
|
123
|
+
<div id="drawerBackdrop" class="drawer-backdrop" hidden></div>
|
|
124
|
+
<div id="liveRegion" class="sr-only" role="status" aria-live="polite"></div>
|
|
79
125
|
</div>
|
|
80
126
|
|
|
81
127
|
<script type="module" src="/chrome.js"></script>
|
package/src/cli.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
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";
|
|
7
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
canonicalTarget,
|
|
8
|
+
ensureStateDir,
|
|
9
|
+
SERVER_PROTOCOL,
|
|
10
|
+
serverPath,
|
|
11
|
+
serverProtocolMatches,
|
|
12
|
+
statePath,
|
|
13
|
+
targetKey,
|
|
14
|
+
} from "./paths.js";
|
|
8
15
|
import { readServerLock } from "./server-lock.js";
|
|
9
16
|
import { installSkills, shellQuote } from "./setup.js";
|
|
17
|
+
import { createDeadline, DEFAULT_POLL_SECONDS, isRecoverableTransportError, parseServerResponse, pollUntilDeadline, requestRaw } from "./poll-transport.js";
|
|
10
18
|
|
|
11
19
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
20
|
const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
|
|
@@ -16,7 +24,7 @@ const HELP = `doc-review ${pkg.version}
|
|
|
16
24
|
doc-review <file-or-localhost-url> Open a file or localhost page for review
|
|
17
25
|
doc-review poll <target> Wait for feedback, print it as JSON (for agents)
|
|
18
26
|
--ack <batch_id> Acknowledge that exact delivered batch, then keep waiting
|
|
19
|
-
--timeout <secs>
|
|
27
|
+
--timeout <secs> End-to-end cutoff; default 12 hours (43200 seconds)
|
|
20
28
|
doc-review status <target> Report whether feedback is waiting, without blocking
|
|
21
29
|
doc-review setup Teach Claude Code / Codex how to use doc-review
|
|
22
30
|
doc-review setup --global ...for every project, not just this one
|
|
@@ -34,72 +42,64 @@ function readServerRecord() {
|
|
|
34
42
|
}
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
const port = typeof server === "number" ? server : server.port;
|
|
39
|
-
const token = typeof server === "number" ? "" : server.token || "";
|
|
40
|
-
return new Promise((resolve, reject) => {
|
|
41
|
-
const req = http.request(
|
|
42
|
-
{
|
|
43
|
-
host: "127.0.0.1",
|
|
44
|
-
port,
|
|
45
|
-
...options,
|
|
46
|
-
headers: { ...(token ? { "x-doc-review-token": token } : {}), ...(options.headers || {}) },
|
|
47
|
-
},
|
|
48
|
-
(res) => {
|
|
49
|
-
let raw = "";
|
|
50
|
-
res.setEncoding("utf8");
|
|
51
|
-
res.on("data", (chunk) => {
|
|
52
|
-
raw += chunk;
|
|
53
|
-
});
|
|
54
|
-
res.on("end", () => resolve({ status: res.statusCode, raw }));
|
|
55
|
-
}
|
|
56
|
-
);
|
|
57
|
-
req.on("error", reject);
|
|
58
|
-
if (options.timeout) req.setTimeout(options.timeout, () => req.destroy(new Error("timeout")));
|
|
59
|
-
if (body) req.write(JSON.stringify(body));
|
|
60
|
-
req.end();
|
|
61
|
-
});
|
|
62
|
-
}
|
|
45
|
+
const request = requestRaw;
|
|
63
46
|
|
|
64
|
-
async function alive(server) {
|
|
47
|
+
async function alive(server, deadline) {
|
|
48
|
+
if (!server?.port || !server.instance_id) return false;
|
|
49
|
+
const lock = readServerLock();
|
|
50
|
+
if (lock?.pid !== server.pid || lock?.instance_id !== server.instance_id) return false;
|
|
65
51
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
|
|
52
|
+
deadline?.check();
|
|
53
|
+
const res = await request(server, {
|
|
54
|
+
method: "GET", path: "/health", timeout: Math.min(1200, deadline?.remaining() ?? 1200),
|
|
55
|
+
}, undefined, { time: deadline?.time });
|
|
56
|
+
deadline?.check();
|
|
57
|
+
const health = parseServerResponse(res);
|
|
58
|
+
if (health.pid !== server.pid || health.instance_id !== server.instance_id) {
|
|
59
|
+
throw new Error("The doc-review server identity does not match its writer lock. End the review and restart the server.");
|
|
60
|
+
}
|
|
61
|
+
if (!serverProtocolMatches(health.protocol) || !serverProtocolMatches(server.protocol)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Incompatible live doc-review server (protocol ${health.protocol}; this CLI requires ${SERVER_PROTOCOL}). ` +
|
|
64
|
+
"End active reviews and stop/restart the old doc-review server before retrying. " +
|
|
65
|
+
"Its live writer lock and queued feedback have not been changed.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (isRecoverableTransportError(err)) return false;
|
|
71
|
+
throw err;
|
|
78
72
|
}
|
|
79
73
|
}
|
|
80
74
|
|
|
81
|
-
async function ensureServer() {
|
|
75
|
+
async function ensureServer(deadline = createDeadline(20)) {
|
|
76
|
+
deadline.check();
|
|
82
77
|
ensureStateDir();
|
|
83
78
|
for (let launch = 0; launch < 3; launch += 1) {
|
|
84
79
|
const saved = readServerRecord();
|
|
85
|
-
if (
|
|
80
|
+
if (await alive(saved, deadline)) return saved;
|
|
81
|
+
deadline.check();
|
|
86
82
|
|
|
87
83
|
const child = spawn(process.execPath, [path.join(here, "server-entry.js")], {
|
|
88
84
|
detached: true,
|
|
89
85
|
stdio: "ignore",
|
|
90
86
|
});
|
|
87
|
+
let launchError;
|
|
88
|
+
child.on("error", (err) => { launchError = err; });
|
|
91
89
|
child.unref();
|
|
92
90
|
|
|
93
91
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
94
|
-
await
|
|
92
|
+
await deadline.sleep(100);
|
|
93
|
+
if (launchError) throw launchError;
|
|
95
94
|
const record = readServerRecord();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
if (await alive(record, deadline)) return record;
|
|
96
|
+
if (child.exitCode !== null && child.exitCode !== 0) {
|
|
97
|
+
throw new Error("The local doc-review server failed to start. Check the state directory permissions and server startup diagnostics before retrying.");
|
|
98
|
+
}
|
|
99
99
|
if (child.exitCode !== null && !readServerLock()) break;
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
-
throw new Error("Could not start the local doc-review server.");
|
|
102
|
+
throw Object.assign(new Error("Could not start the local doc-review server yet."), { code: "SERVER_START_PENDING" });
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function openBrowser(url) {
|
|
@@ -134,48 +134,6 @@ async function openCommand(input) {
|
|
|
134
134
|
console.log(`\nWaiting for feedback? Run:\n doc-review poll ${shellQuote(target.value)}`);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/**
|
|
138
|
-
* One long-poll attempt. Resolves { kind: "data", raw } when the server
|
|
139
|
-
* answers, or { kind: "timeout" } when the caller's deadline passes first.
|
|
140
|
-
*/
|
|
141
|
-
function pollOnce(server, target, ackId, timeoutMs) {
|
|
142
|
-
const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
|
|
143
|
-
return new Promise((resolve, reject) => {
|
|
144
|
-
let done = false;
|
|
145
|
-
const settle = (fn, value) => {
|
|
146
|
-
if (done) return;
|
|
147
|
-
done = true;
|
|
148
|
-
clearTimeout(timer);
|
|
149
|
-
fn(value);
|
|
150
|
-
};
|
|
151
|
-
const req = http.request(
|
|
152
|
-
{
|
|
153
|
-
host: "127.0.0.1",
|
|
154
|
-
port: server.port,
|
|
155
|
-
method: "GET",
|
|
156
|
-
path: `/api/poll?${query}`,
|
|
157
|
-
headers: { "x-doc-review-token": server.token || "" },
|
|
158
|
-
},
|
|
159
|
-
(res) => {
|
|
160
|
-
let raw = "";
|
|
161
|
-
res.setEncoding("utf8");
|
|
162
|
-
res.on("data", (chunk) => {
|
|
163
|
-
raw += chunk;
|
|
164
|
-
});
|
|
165
|
-
res.on("end", () => settle(resolve, { kind: "data", raw: raw.trim() }));
|
|
166
|
-
}
|
|
167
|
-
);
|
|
168
|
-
const timer = timeoutMs
|
|
169
|
-
? setTimeout(() => {
|
|
170
|
-
settle(resolve, { kind: "timeout" });
|
|
171
|
-
req.destroy();
|
|
172
|
-
}, timeoutMs)
|
|
173
|
-
: null;
|
|
174
|
-
req.on("error", (err) => settle(reject, err));
|
|
175
|
-
req.end();
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
137
|
/**
|
|
180
138
|
* The consumer is an agent reading a pipe. process.exit() does not wait for
|
|
181
139
|
* pending stdout writes, so a large payload could arrive truncated — always
|
|
@@ -185,50 +143,18 @@ function writeStdout(text) {
|
|
|
185
143
|
return new Promise((resolve) => process.stdout.write(text, resolve));
|
|
186
144
|
}
|
|
187
145
|
|
|
188
|
-
function
|
|
189
|
-
const
|
|
190
|
-
status: "timeout",
|
|
191
|
-
waited_seconds: waitedSecs,
|
|
192
|
-
next_step:
|
|
193
|
-
"No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
|
|
194
|
-
};
|
|
195
|
-
return writeStdout(`${JSON.stringify(payload, null, 2)}\n`);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function pollCommand(input, { ackId = "", timeoutSecs = 0 } = {}) {
|
|
146
|
+
async function pollCommand(input, { ackId = "", timeoutSecs = DEFAULT_POLL_SECONDS } = {}) {
|
|
147
|
+
const deadline = createDeadline(timeoutSecs);
|
|
199
148
|
const target = canonicalTarget(input).value;
|
|
200
|
-
let server = await ensureServer();
|
|
201
149
|
|
|
202
150
|
const label = /^https?:\/\//i.test(target) ? target : path.basename(target);
|
|
203
151
|
process.stderr.write(`Waiting for feedback on ${label} — comment in the browser, then hit Send.\n`);
|
|
204
152
|
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
result = await pollOnce(server, target, ackId && attempt === 0 ? ackId : "", remaining);
|
|
212
|
-
} catch (err) {
|
|
213
|
-
process.stderr.write(`Lost the connection (${err.message}); retrying.\n`);
|
|
214
|
-
server = await ensureServer();
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
if (result.kind === "timeout") return printTimeout(timeoutSecs);
|
|
218
|
-
if (!result.raw) {
|
|
219
|
-
server = await ensureServer();
|
|
220
|
-
continue;
|
|
221
|
-
}
|
|
222
|
-
try {
|
|
223
|
-
const batch = JSON.parse(result.raw);
|
|
224
|
-
await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
|
|
225
|
-
return;
|
|
226
|
-
} catch {
|
|
227
|
-
process.stderr.write("Unexpected response from the doc-review server; retrying.\n");
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
process.stderr.write("Gave up waiting for feedback.\n");
|
|
231
|
-
process.exit(1);
|
|
153
|
+
const batch = await pollUntilDeadline({
|
|
154
|
+
target, ackId, deadline, discover: ensureServer,
|
|
155
|
+
diagnostic: (text) => process.stderr.write(text),
|
|
156
|
+
});
|
|
157
|
+
await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
|
|
232
158
|
}
|
|
233
159
|
|
|
234
160
|
/**
|
|
@@ -239,7 +165,7 @@ async function pollCommand(input, { ackId = "", timeoutSecs = 0 } = {}) {
|
|
|
239
165
|
async function statusCommand(input) {
|
|
240
166
|
const target = canonicalTarget(input).value;
|
|
241
167
|
const saved = readServerRecord();
|
|
242
|
-
if (saved?.protocol
|
|
168
|
+
if (serverProtocolMatches(saved?.protocol) && saved.port && saved.instance_id && (await alive(saved))) {
|
|
243
169
|
const res = await request(saved, { method: "GET", path: `/api/status?target=${encodeURIComponent(target)}` });
|
|
244
170
|
if (res.status === 200) {
|
|
245
171
|
process.stdout.write(`${JSON.stringify(JSON.parse(res.raw), null, 2)}\n`);
|
|
@@ -289,7 +215,7 @@ process.on("SIGINT", () => {
|
|
|
289
215
|
});
|
|
290
216
|
|
|
291
217
|
function parsePollArgs(rest) {
|
|
292
|
-
const parsed = { file: "", ackId: "", timeoutSecs:
|
|
218
|
+
const parsed = { file: "", ackId: "", timeoutSecs: DEFAULT_POLL_SECONDS };
|
|
293
219
|
let sawTimeout = false;
|
|
294
220
|
for (let i = 0; i < rest.length; i += 1) {
|
|
295
221
|
const arg = rest[i];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const text = (value, limit) => typeof value === "string" ? value.slice(0, limit) : "";
|
|
2
|
+
|
|
3
|
+
export function normalizeCommentAnchor(kind, anchor) {
|
|
4
|
+
if (!anchor || typeof anchor !== "object" || Array.isArray(anchor)) return null;
|
|
5
|
+
if (kind === "element") {
|
|
6
|
+
const selector = text(anchor.selector, 2000);
|
|
7
|
+
if (!selector) return null;
|
|
8
|
+
return {
|
|
9
|
+
selector,
|
|
10
|
+
...(text(anchor.label, 500) ? { label: text(anchor.label, 500) } : {}),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const quote = text(anchor.quote, 4000);
|
|
15
|
+
if (!quote) return null;
|
|
16
|
+
return {
|
|
17
|
+
quote,
|
|
18
|
+
...(text(anchor.prefix, 1000) ? { prefix: text(anchor.prefix, 1000) } : {}),
|
|
19
|
+
...(text(anchor.suffix, 1000) ? { suffix: text(anchor.suffix, 1000) } : {}),
|
|
20
|
+
...(text(anchor.selector, 2000) ? { selector: text(anchor.selector, 2000) } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
2
|
+
|
|
3
|
+
export function sanitizeRect(rect) {
|
|
4
|
+
if (!rect) return null;
|
|
5
|
+
const values = ["left", "top", "right", "bottom", "width", "height"];
|
|
6
|
+
if (!values.every((key) => finite(rect[key]))) return null;
|
|
7
|
+
if (rect.width < 0 || rect.height < 0 || rect.right < rect.left || rect.bottom < rect.top) return null;
|
|
8
|
+
return {
|
|
9
|
+
left: rect.left,
|
|
10
|
+
top: rect.top,
|
|
11
|
+
right: rect.right,
|
|
12
|
+
bottom: rect.bottom,
|
|
13
|
+
width: rect.width,
|
|
14
|
+
height: rect.height,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sanitizeClientRect(rect, viewport) {
|
|
19
|
+
const safe = sanitizeRect(rect);
|
|
20
|
+
if (!safe || !viewport || !finite(viewport.width) || !finite(viewport.height)) return null;
|
|
21
|
+
const { left: rawLeft, top: rawTop, right: rawRight, bottom: rawBottom } = safe;
|
|
22
|
+
if (rawRight <= 0 || rawBottom <= 0 || rawLeft >= viewport.width || rawTop >= viewport.height) return null;
|
|
23
|
+
const left = Math.max(0, Math.min(viewport.width, rawLeft));
|
|
24
|
+
const top = Math.max(0, Math.min(viewport.height, rawTop));
|
|
25
|
+
const right = Math.max(left, Math.min(viewport.width, rawRight));
|
|
26
|
+
const bottom = Math.max(top, Math.min(viewport.height, rawBottom));
|
|
27
|
+
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function sanitizeClientRects(rects, viewport, limit = 64) {
|
|
31
|
+
if (!Array.isArray(rects)) return [];
|
|
32
|
+
return rects.slice(0, limit).map((rect) => sanitizeClientRect(rect, viewport)).filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function sanitizeClipRect(rect, viewport) {
|
|
36
|
+
const safe = sanitizeClientRect(rect, viewport);
|
|
37
|
+
return safe && safe.width > 0 && safe.height > 0 ? safe : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sanitizeRelation(value) {
|
|
41
|
+
return ["visible", "above", "below", "unavailable"].includes(value) ? value : "unavailable";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function targetMessage(
|
|
45
|
+
{ kind, quote, anchor, rects, generation, relation, clip, horizontal },
|
|
46
|
+
viewport
|
|
47
|
+
) {
|
|
48
|
+
const safeRects = sanitizeClientRects(rects, viewport);
|
|
49
|
+
const safeClip = sanitizeClipRect(clip, viewport);
|
|
50
|
+
if (!Number.isSafeInteger(generation) || generation < 1) return null;
|
|
51
|
+
const safeRelation = sanitizeRelation(relation);
|
|
52
|
+
if (!safeClip || (safeRelation === "visible" && !safeRects.length)) return null;
|
|
53
|
+
return {
|
|
54
|
+
kind: kind === "element" ? "element" : "selection",
|
|
55
|
+
quote: String(quote || ""),
|
|
56
|
+
anchor: anchor || null,
|
|
57
|
+
rects: safeRects,
|
|
58
|
+
generation,
|
|
59
|
+
relation: safeRelation,
|
|
60
|
+
clip: safeClip,
|
|
61
|
+
horizontal: finite(horizontal) ? Math.max(safeClip.left, Math.min(safeClip.right, horizontal)) : null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function acceptsTargetGeometry(currentGeneration, incoming) {
|
|
66
|
+
return !!incoming && Number.isSafeInteger(incoming.generation) && incoming.generation === currentGeneration;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function acceptedOpenGeneration(
|
|
70
|
+
{ pendingGeneration = null, retargetGeneration = null },
|
|
71
|
+
{ accepted, requestedGeneration }
|
|
72
|
+
) {
|
|
73
|
+
if (!accepted) return pendingGeneration;
|
|
74
|
+
if (requestedGeneration === retargetGeneration) return retargetGeneration;
|
|
75
|
+
if (requestedGeneration === pendingGeneration) return pendingGeneration;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const MAX_EDIT_CHARACTERS = 200_000;
|
|
2
|
+
|
|
3
|
+
const EDIT_FIELDS = ["before", "after", "before_html", "after_html", "moved_after", "moved_before"];
|
|
4
|
+
|
|
5
|
+
export function limitEditFields(input) {
|
|
6
|
+
const fields = {};
|
|
7
|
+
const truncatedFields = [];
|
|
8
|
+
for (const field of EDIT_FIELDS) {
|
|
9
|
+
const value = input[field];
|
|
10
|
+
if (typeof value !== "string") continue;
|
|
11
|
+
let end = 0;
|
|
12
|
+
let count = 0;
|
|
13
|
+
// Count Unicode code points, not UTF-16 halves of an astral character.
|
|
14
|
+
for (const character of value) {
|
|
15
|
+
if (count === MAX_EDIT_CHARACTERS) break;
|
|
16
|
+
end += character.length;
|
|
17
|
+
count += 1;
|
|
18
|
+
}
|
|
19
|
+
fields[field] = value.slice(0, end);
|
|
20
|
+
if (end < value.length) truncatedFields.push(field);
|
|
21
|
+
}
|
|
22
|
+
return { fields, truncated: truncatedFields.length > 0, truncated_fields: truncatedFields };
|
|
23
|
+
}
|