@commentary-dev/cli 0.1.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/LICENSE +22 -0
- package/README.md +303 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1660 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1660 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command, Option } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands.ts
|
|
7
|
+
import fs4 from "fs/promises";
|
|
8
|
+
import path5 from "path";
|
|
9
|
+
import chokidar from "chokidar";
|
|
10
|
+
import open from "open";
|
|
11
|
+
|
|
12
|
+
// src/errors.ts
|
|
13
|
+
var CliError = class extends Error {
|
|
14
|
+
exitCode;
|
|
15
|
+
constructor(message, exitCode2 = 1) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "CliError";
|
|
18
|
+
this.exitCode = exitCode2;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var ExitCode = {
|
|
22
|
+
Ok: 0,
|
|
23
|
+
General: 1,
|
|
24
|
+
Usage: 2,
|
|
25
|
+
Auth: 3,
|
|
26
|
+
Network: 4,
|
|
27
|
+
Api: 5,
|
|
28
|
+
Safety: 6,
|
|
29
|
+
Timeout: 124
|
|
30
|
+
};
|
|
31
|
+
function toErrorMessage(error) {
|
|
32
|
+
return error instanceof Error ? error.message : String(error);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/sse.ts
|
|
36
|
+
var SseParser = class {
|
|
37
|
+
buffer = "";
|
|
38
|
+
id;
|
|
39
|
+
event;
|
|
40
|
+
dataLines = [];
|
|
41
|
+
retry;
|
|
42
|
+
feed(chunk) {
|
|
43
|
+
this.buffer += chunk;
|
|
44
|
+
const messages = [];
|
|
45
|
+
while (true) {
|
|
46
|
+
const boundary = this.findBoundary();
|
|
47
|
+
if (!boundary) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const raw = this.buffer.slice(0, boundary.index);
|
|
51
|
+
this.buffer = this.buffer.slice(boundary.index + boundary.length);
|
|
52
|
+
const message = this.consumeBlock(raw);
|
|
53
|
+
if (message) {
|
|
54
|
+
messages.push(message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return messages;
|
|
58
|
+
}
|
|
59
|
+
flush() {
|
|
60
|
+
if (!this.buffer.trim()) {
|
|
61
|
+
this.buffer = "";
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const message = this.consumeBlock(this.buffer);
|
|
65
|
+
this.buffer = "";
|
|
66
|
+
return message ? [message] : [];
|
|
67
|
+
}
|
|
68
|
+
findBoundary() {
|
|
69
|
+
const candidates = [
|
|
70
|
+
{ index: this.buffer.indexOf("\r\n\r\n"), length: 4 },
|
|
71
|
+
{ index: this.buffer.indexOf("\n\n"), length: 2 },
|
|
72
|
+
{ index: this.buffer.indexOf("\r\r"), length: 2 }
|
|
73
|
+
].filter((candidate) => candidate.index >= 0);
|
|
74
|
+
return candidates.sort((a, b) => a.index - b.index)[0] ?? null;
|
|
75
|
+
}
|
|
76
|
+
consumeBlock(raw) {
|
|
77
|
+
for (const line of raw.split(/\r\n|\r|\n/)) {
|
|
78
|
+
this.consumeLine(line);
|
|
79
|
+
}
|
|
80
|
+
if (this.dataLines.length === 0) {
|
|
81
|
+
this.event = void 0;
|
|
82
|
+
this.dataLines = [];
|
|
83
|
+
this.retry = void 0;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const message = {
|
|
87
|
+
id: this.id,
|
|
88
|
+
event: this.event,
|
|
89
|
+
data: this.dataLines.join("\n"),
|
|
90
|
+
retry: this.retry
|
|
91
|
+
};
|
|
92
|
+
this.event = void 0;
|
|
93
|
+
this.dataLines = [];
|
|
94
|
+
this.retry = void 0;
|
|
95
|
+
return message;
|
|
96
|
+
}
|
|
97
|
+
consumeLine(line) {
|
|
98
|
+
if (!line || line.startsWith(":")) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const separator = line.indexOf(":");
|
|
102
|
+
const field = separator >= 0 ? line.slice(0, separator) : line;
|
|
103
|
+
const value = separator >= 0 ? line.slice(separator + 1).replace(/^ /, "") : "";
|
|
104
|
+
if (field === "id") {
|
|
105
|
+
this.id = value;
|
|
106
|
+
} else if (field === "event") {
|
|
107
|
+
this.event = value;
|
|
108
|
+
} else if (field === "data") {
|
|
109
|
+
this.dataLines.push(value);
|
|
110
|
+
} else if (field === "retry") {
|
|
111
|
+
const retry = Number(value);
|
|
112
|
+
if (Number.isInteger(retry) && retry >= 0) {
|
|
113
|
+
this.retry = retry;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// src/api-client.ts
|
|
120
|
+
var CommentaryApiClient = class {
|
|
121
|
+
baseUrl;
|
|
122
|
+
token;
|
|
123
|
+
fetchImpl;
|
|
124
|
+
constructor(options) {
|
|
125
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
126
|
+
this.token = options.token ?? null;
|
|
127
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
128
|
+
}
|
|
129
|
+
async getOAuthMetadata() {
|
|
130
|
+
return this.rawJson("/.well-known/oauth-authorization-server", { auth: false });
|
|
131
|
+
}
|
|
132
|
+
async requestDeviceCode(input) {
|
|
133
|
+
const metadata = await this.getOAuthMetadata();
|
|
134
|
+
return this.rawJson(metadata.device_authorization_endpoint, {
|
|
135
|
+
auth: false,
|
|
136
|
+
method: "POST",
|
|
137
|
+
body: {
|
|
138
|
+
client_id: input.clientId,
|
|
139
|
+
client_name: input.clientName,
|
|
140
|
+
scope: input.scope,
|
|
141
|
+
resource: input.resource
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async exchangeDeviceCode(input) {
|
|
146
|
+
const metadata = await this.getOAuthMetadata();
|
|
147
|
+
return this.rawJson(metadata.token_endpoint, {
|
|
148
|
+
auth: false,
|
|
149
|
+
method: "POST",
|
|
150
|
+
body: {
|
|
151
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
152
|
+
device_code: input.deviceCode,
|
|
153
|
+
resource: input.resource
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
async listDraftReviews() {
|
|
158
|
+
return this.request("/api/v1/draft-reviews");
|
|
159
|
+
}
|
|
160
|
+
async createDraftReview(input) {
|
|
161
|
+
return this.request("/api/v1/draft-reviews", {
|
|
162
|
+
method: "POST",
|
|
163
|
+
body: {
|
|
164
|
+
title: input.title,
|
|
165
|
+
description: input.description,
|
|
166
|
+
sourceType: "cli",
|
|
167
|
+
files: input.files.map((file) => ({
|
|
168
|
+
path: file.path,
|
|
169
|
+
content: file.content,
|
|
170
|
+
contentType: file.contentType
|
|
171
|
+
}))
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async getDraftReview(sessionId) {
|
|
176
|
+
return this.request(
|
|
177
|
+
`/api/v1/draft-reviews/${encodeURIComponent(sessionId)}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
async createRevision(input) {
|
|
181
|
+
return this.request(
|
|
182
|
+
`/api/v1/draft-reviews/${encodeURIComponent(input.sessionId)}/revisions`,
|
|
183
|
+
{
|
|
184
|
+
method: "POST",
|
|
185
|
+
body: {
|
|
186
|
+
summary: input.summary,
|
|
187
|
+
files: input.files.map((file) => ({
|
|
188
|
+
fileId: file.fileId,
|
|
189
|
+
path: file.path,
|
|
190
|
+
content: file.content,
|
|
191
|
+
contentType: file.contentType
|
|
192
|
+
}))
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
async listRevisions(sessionId) {
|
|
198
|
+
return this.request(
|
|
199
|
+
`/api/v1/draft-reviews/${encodeURIComponent(sessionId)}/revisions`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
async listComments(input) {
|
|
203
|
+
const params = new URLSearchParams();
|
|
204
|
+
if (input.status) {
|
|
205
|
+
params.set("status", input.status);
|
|
206
|
+
}
|
|
207
|
+
if (input.filePath) {
|
|
208
|
+
params.set("filePath", input.filePath);
|
|
209
|
+
}
|
|
210
|
+
if (input.fileId) {
|
|
211
|
+
params.set("fileId", input.fileId);
|
|
212
|
+
}
|
|
213
|
+
const suffix = params.size ? `?${params}` : "";
|
|
214
|
+
return this.request(
|
|
215
|
+
`/api/v1/draft-reviews/${encodeURIComponent(input.sessionId)}/comments${suffix}`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
async replyToComment(input) {
|
|
219
|
+
return this.request(
|
|
220
|
+
`/api/v1/draft-reviews/${encodeURIComponent(input.sessionId)}/comments/${encodeURIComponent(input.threadId)}/replies`,
|
|
221
|
+
{
|
|
222
|
+
method: "POST",
|
|
223
|
+
body: { bodyMarkdown: input.bodyMarkdown }
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
async updateCommentStatus(input) {
|
|
228
|
+
return this.request(
|
|
229
|
+
`/api/v1/draft-reviews/${encodeURIComponent(input.sessionId)}/comments/${encodeURIComponent(input.threadId)}/status`,
|
|
230
|
+
{
|
|
231
|
+
method: "POST",
|
|
232
|
+
body: { status: input.status }
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
async getFileContent(input) {
|
|
237
|
+
return this.rawText(
|
|
238
|
+
`/api/v1/draft-reviews/${encodeURIComponent(input.sessionId)}/files/${encodeURIComponent(input.fileId)}/content`,
|
|
239
|
+
{ auth: true }
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
async *streamDraftReviewEvents(input) {
|
|
243
|
+
const params = new URLSearchParams();
|
|
244
|
+
if (input.cursor) {
|
|
245
|
+
params.set("cursor", input.cursor);
|
|
246
|
+
}
|
|
247
|
+
if (input.once) {
|
|
248
|
+
params.set("once", "1");
|
|
249
|
+
}
|
|
250
|
+
const suffix = params.size ? `?${params}` : "";
|
|
251
|
+
const response = await this.doFetch(
|
|
252
|
+
`/api/v1/draft-reviews/${encodeURIComponent(input.sessionId)}/events${suffix}`,
|
|
253
|
+
{ auth: true, accept: "text/event-stream", signal: input.signal }
|
|
254
|
+
);
|
|
255
|
+
if (!response.ok) {
|
|
256
|
+
await this.throwApiError(response);
|
|
257
|
+
}
|
|
258
|
+
if (!response.body) {
|
|
259
|
+
throw new CliError("Commentary event stream did not include a response body.", ExitCode.Api);
|
|
260
|
+
}
|
|
261
|
+
const reader = response.body.getReader();
|
|
262
|
+
const decoder = new TextDecoder();
|
|
263
|
+
const parser = new SseParser();
|
|
264
|
+
let completed = false;
|
|
265
|
+
try {
|
|
266
|
+
while (true) {
|
|
267
|
+
const { done, value } = await reader.read();
|
|
268
|
+
completed = done;
|
|
269
|
+
const messages = done ? parser.flush() : parser.feed(decoder.decode(value, { stream: true }));
|
|
270
|
+
for (const message of messages) {
|
|
271
|
+
if (message.event && message.event !== "draft-review") {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!message.data) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
yield JSON.parse(message.data);
|
|
278
|
+
}
|
|
279
|
+
if (done) {
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (input.signal?.aborted) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
throw new CliError(
|
|
288
|
+
error instanceof Error ? error.message : "Commentary event stream failed.",
|
|
289
|
+
ExitCode.Network
|
|
290
|
+
);
|
|
291
|
+
} finally {
|
|
292
|
+
if (!completed) {
|
|
293
|
+
await reader.cancel().catch(() => void 0);
|
|
294
|
+
}
|
|
295
|
+
reader.releaseLock();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async request(pathOrUrl, init) {
|
|
299
|
+
return this.rawJson(pathOrUrl, { ...init, auth: true });
|
|
300
|
+
}
|
|
301
|
+
async rawText(pathOrUrl, init) {
|
|
302
|
+
const response = await this.doFetch(pathOrUrl, init);
|
|
303
|
+
if (!response.ok) {
|
|
304
|
+
await this.throwApiError(response);
|
|
305
|
+
}
|
|
306
|
+
return response.text();
|
|
307
|
+
}
|
|
308
|
+
async rawJson(pathOrUrl, init) {
|
|
309
|
+
const response = await this.doFetch(pathOrUrl, init);
|
|
310
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
311
|
+
const payload = contentType.includes("application/json") ? await response.json() : null;
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
this.throwPayloadError(response.status, payload);
|
|
314
|
+
}
|
|
315
|
+
return payload;
|
|
316
|
+
}
|
|
317
|
+
async doFetch(pathOrUrl, init) {
|
|
318
|
+
const url = pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://") ? pathOrUrl : `${this.baseUrl}${pathOrUrl}`;
|
|
319
|
+
const headers = {
|
|
320
|
+
accept: init.accept ?? "application/json"
|
|
321
|
+
};
|
|
322
|
+
let body;
|
|
323
|
+
if (init.body !== void 0) {
|
|
324
|
+
headers["content-type"] = "application/json";
|
|
325
|
+
body = JSON.stringify(init.body);
|
|
326
|
+
}
|
|
327
|
+
if (init.auth) {
|
|
328
|
+
if (!this.token) {
|
|
329
|
+
throw new CliError(
|
|
330
|
+
"Authentication is required. Run commentary login or set COMMENTARY_TOKEN.",
|
|
331
|
+
ExitCode.Auth
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
headers.authorization = `Bearer ${this.token}`;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const requestInit = {
|
|
338
|
+
method: init.method ?? "GET",
|
|
339
|
+
headers
|
|
340
|
+
};
|
|
341
|
+
if (init.signal) {
|
|
342
|
+
requestInit.signal = init.signal;
|
|
343
|
+
}
|
|
344
|
+
if (body !== void 0) {
|
|
345
|
+
requestInit.body = body;
|
|
346
|
+
}
|
|
347
|
+
return await this.fetchImpl(url, requestInit);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
throw new CliError(
|
|
350
|
+
error instanceof Error ? error.message : "Network request failed.",
|
|
351
|
+
ExitCode.Network
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async throwApiError(response) {
|
|
356
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
357
|
+
if (contentType.includes("application/json")) {
|
|
358
|
+
this.throwPayloadError(response.status, await response.json());
|
|
359
|
+
}
|
|
360
|
+
throw new CliError(
|
|
361
|
+
`Commentary API returned ${response.status}.`,
|
|
362
|
+
response.status === 401 ? ExitCode.Auth : ExitCode.Api
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
throwPayloadError(status, payload) {
|
|
366
|
+
const body = payload && typeof payload === "object" ? payload : {};
|
|
367
|
+
const message = typeof body.error === "string" ? body.error : typeof body.error_description === "string" ? body.error_description : `Commentary API returned ${status}.`;
|
|
368
|
+
throw new CliError(message, status === 401 || status === 403 ? ExitCode.Auth : ExitCode.Api);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// src/constants.ts
|
|
373
|
+
var DEFAULT_BASE_URL = "https://commentary.dev";
|
|
374
|
+
var SESSION_FILE = ".commentary/session.json";
|
|
375
|
+
var PACKAGE_NAME = "@commentary-dev/cli";
|
|
376
|
+
var CLIENT_ID = "commentary-cli";
|
|
377
|
+
var CLIENT_NAME = "Commentary CLI";
|
|
378
|
+
var REQUIRED_SCOPES = [
|
|
379
|
+
"commentary.review.read",
|
|
380
|
+
"commentary.comments.read",
|
|
381
|
+
"commentary.comments.write",
|
|
382
|
+
"commentary.comments.status"
|
|
383
|
+
];
|
|
384
|
+
var SUPPORTED_EXTENSIONS = [".md", ".markdown", ".mdx", ".html", ".htm", ".txt"];
|
|
385
|
+
var DEFAULT_IGNORES = [
|
|
386
|
+
"**/.git/**",
|
|
387
|
+
"**/node_modules/**",
|
|
388
|
+
"**/dist/**",
|
|
389
|
+
"**/build/**",
|
|
390
|
+
"**/.next/**",
|
|
391
|
+
"**/.nuxt/**",
|
|
392
|
+
"**/coverage/**",
|
|
393
|
+
"**/.commentary/**",
|
|
394
|
+
"**/.DS_Store"
|
|
395
|
+
];
|
|
396
|
+
var DRAFT_REVIEW_MAX_FILES = 20;
|
|
397
|
+
var DRAFT_REVIEW_MAX_FILE_BYTES = 512 * 1024;
|
|
398
|
+
var DRAFT_REVIEW_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
|
|
399
|
+
|
|
400
|
+
// src/config.ts
|
|
401
|
+
import fs from "fs/promises";
|
|
402
|
+
import os from "os";
|
|
403
|
+
import path from "path";
|
|
404
|
+
function configDir() {
|
|
405
|
+
if (process.env.COMMENTARY_CONFIG_DIR) {
|
|
406
|
+
return process.env.COMMENTARY_CONFIG_DIR;
|
|
407
|
+
}
|
|
408
|
+
if (process.platform === "win32" && process.env.APPDATA) {
|
|
409
|
+
return path.join(process.env.APPDATA, "commentary");
|
|
410
|
+
}
|
|
411
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
412
|
+
return path.join(process.env.XDG_CONFIG_HOME, "commentary");
|
|
413
|
+
}
|
|
414
|
+
return path.join(os.homedir(), ".config", "commentary");
|
|
415
|
+
}
|
|
416
|
+
function configPath() {
|
|
417
|
+
return path.join(configDir(), "config.json");
|
|
418
|
+
}
|
|
419
|
+
async function readConfig() {
|
|
420
|
+
try {
|
|
421
|
+
return JSON.parse(await fs.readFile(configPath(), "utf8"));
|
|
422
|
+
} catch {
|
|
423
|
+
return {};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
async function writeConfig(config) {
|
|
427
|
+
await fs.mkdir(configDir(), { recursive: true, mode: 448 });
|
|
428
|
+
await fs.writeFile(configPath(), `${JSON.stringify(config, null, 2)}
|
|
429
|
+
`, {
|
|
430
|
+
encoding: "utf8",
|
|
431
|
+
mode: 384
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
function normalizeBaseUrl(baseUrl) {
|
|
435
|
+
const value = baseUrl?.trim() || process.env.COMMENTARY_BASE_URL || DEFAULT_BASE_URL;
|
|
436
|
+
const url = new URL(value);
|
|
437
|
+
url.hash = "";
|
|
438
|
+
url.search = "";
|
|
439
|
+
return url.toString().replace(/\/$/, "");
|
|
440
|
+
}
|
|
441
|
+
async function getStoredToken(baseUrl) {
|
|
442
|
+
const config = await readConfig();
|
|
443
|
+
return config.tokens?.[normalizeBaseUrl(baseUrl)] ?? null;
|
|
444
|
+
}
|
|
445
|
+
async function setStoredToken(baseUrl, token) {
|
|
446
|
+
const config = await readConfig();
|
|
447
|
+
config.tokens ??= {};
|
|
448
|
+
config.tokens[normalizeBaseUrl(baseUrl)] = token;
|
|
449
|
+
await writeConfig(config);
|
|
450
|
+
}
|
|
451
|
+
async function removeStoredToken(baseUrl) {
|
|
452
|
+
const config = await readConfig();
|
|
453
|
+
if (config.tokens) {
|
|
454
|
+
delete config.tokens[normalizeBaseUrl(baseUrl)];
|
|
455
|
+
}
|
|
456
|
+
await writeConfig(config);
|
|
457
|
+
}
|
|
458
|
+
async function resolveToken(input) {
|
|
459
|
+
if (input.token?.trim()) {
|
|
460
|
+
return input.token.trim();
|
|
461
|
+
}
|
|
462
|
+
if (process.env.COMMENTARY_TOKEN?.trim()) {
|
|
463
|
+
return process.env.COMMENTARY_TOKEN.trim();
|
|
464
|
+
}
|
|
465
|
+
const stored = await getStoredToken(input.baseUrl);
|
|
466
|
+
return stored?.accessToken ?? null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/content.ts
|
|
470
|
+
import path2 from "path";
|
|
471
|
+
var MARKDOWN_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".markdown", ".mdx"]);
|
|
472
|
+
var HTML_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
|
|
473
|
+
var PLAIN_EXTENSIONS = /* @__PURE__ */ new Set([".txt"]);
|
|
474
|
+
var SUPPORTED_EXTENSIONS2 = /* @__PURE__ */ new Set([
|
|
475
|
+
...MARKDOWN_EXTENSIONS,
|
|
476
|
+
...HTML_EXTENSIONS,
|
|
477
|
+
...PLAIN_EXTENSIONS
|
|
478
|
+
]);
|
|
479
|
+
function normalizeSlashes(value) {
|
|
480
|
+
return value.replaceAll("\\", "/");
|
|
481
|
+
}
|
|
482
|
+
function normalizeReviewPath(filePath) {
|
|
483
|
+
const normalized = normalizeSlashes(filePath).trim().replace(/^\/+/, "");
|
|
484
|
+
if (!normalized) {
|
|
485
|
+
throw new CliError("Draft review file path is required.", ExitCode.Usage);
|
|
486
|
+
}
|
|
487
|
+
if (/^[a-z]:\//iu.test(normalized) || filePath.trim().startsWith("/") || filePath.trim().startsWith("\\")) {
|
|
488
|
+
throw new CliError("Draft review file paths must be relative.", ExitCode.Usage);
|
|
489
|
+
}
|
|
490
|
+
if (normalized.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
|
|
491
|
+
throw new CliError(
|
|
492
|
+
"Draft review file paths must not contain empty, current, or parent directory segments.",
|
|
493
|
+
ExitCode.Usage
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
return normalized;
|
|
497
|
+
}
|
|
498
|
+
function getPathExtension(filePath) {
|
|
499
|
+
return path2.posix.extname(normalizeSlashes(filePath)).toLowerCase();
|
|
500
|
+
}
|
|
501
|
+
function isSupportedPath(filePath) {
|
|
502
|
+
return SUPPORTED_EXTENSIONS2.has(getPathExtension(filePath));
|
|
503
|
+
}
|
|
504
|
+
function isLikelyHtml(content) {
|
|
505
|
+
const trimmed = content.trimStart().toLowerCase();
|
|
506
|
+
return trimmed.startsWith("<!doctype html") || trimmed.startsWith("<html") || /<(head|body|main|article|section|div|p|h[1-6]|table|ul|ol)\b[^>]*>[\s\S]*<\/\1>/iu.test(
|
|
507
|
+
content
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
function contentTypeFromPath(filePath) {
|
|
511
|
+
const extension = getPathExtension(filePath);
|
|
512
|
+
if (MARKDOWN_EXTENSIONS.has(extension)) {
|
|
513
|
+
return "markdown";
|
|
514
|
+
}
|
|
515
|
+
if (HTML_EXTENSIONS.has(extension)) {
|
|
516
|
+
return "html";
|
|
517
|
+
}
|
|
518
|
+
if (PLAIN_EXTENSIONS.has(extension)) {
|
|
519
|
+
return "plain_text";
|
|
520
|
+
}
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
function detectContentType(input) {
|
|
524
|
+
const requested = input.requested ?? "auto";
|
|
525
|
+
if (requested !== "auto") {
|
|
526
|
+
if (!["markdown", "html", "plain_text"].includes(requested)) {
|
|
527
|
+
throw new CliError("Choose a supported draft content type.", ExitCode.Usage);
|
|
528
|
+
}
|
|
529
|
+
return requested;
|
|
530
|
+
}
|
|
531
|
+
const fromPath = contentTypeFromPath(input.filePath);
|
|
532
|
+
if (fromPath) {
|
|
533
|
+
return fromPath;
|
|
534
|
+
}
|
|
535
|
+
return isLikelyHtml(input.content) ? "html" : "markdown";
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/files.ts
|
|
539
|
+
import fs2 from "fs/promises";
|
|
540
|
+
import path3 from "path";
|
|
541
|
+
import fg from "fast-glob";
|
|
542
|
+
|
|
543
|
+
// src/hash.ts
|
|
544
|
+
import { createHash } from "crypto";
|
|
545
|
+
function contentHash(content) {
|
|
546
|
+
return createHash("sha256").update(content).digest("hex");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/files.ts
|
|
550
|
+
function toPosixRelative(root, absolutePath) {
|
|
551
|
+
const relative = path3.relative(root, absolutePath);
|
|
552
|
+
if (!relative || relative.startsWith("..") || path3.isAbsolute(relative)) {
|
|
553
|
+
throw new CliError(`Path is outside the review root: ${absolutePath}`, ExitCode.Usage);
|
|
554
|
+
}
|
|
555
|
+
return normalizeReviewPath(relative);
|
|
556
|
+
}
|
|
557
|
+
async function readUtf8File(absolutePath) {
|
|
558
|
+
const bytes = await fs2.readFile(absolutePath);
|
|
559
|
+
const content = bytes.toString("utf8");
|
|
560
|
+
if (content.includes("\0")) {
|
|
561
|
+
throw new CliError(
|
|
562
|
+
`File must be UTF-8 text, not binary content: ${absolutePath}`,
|
|
563
|
+
ExitCode.Usage
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
return content;
|
|
567
|
+
}
|
|
568
|
+
async function collectPathEntries(inputPaths, options) {
|
|
569
|
+
const root = path3.resolve(options.root);
|
|
570
|
+
const entries = /* @__PURE__ */ new Set();
|
|
571
|
+
const includeExtensions = SUPPORTED_EXTENSIONS.map((extension) => extension.replace(".", ""));
|
|
572
|
+
for (const inputPath of inputPaths) {
|
|
573
|
+
const absolute = path3.resolve(root, inputPath);
|
|
574
|
+
let stat;
|
|
575
|
+
try {
|
|
576
|
+
stat = await fs2.stat(absolute);
|
|
577
|
+
} catch {
|
|
578
|
+
throw new CliError(`Path does not exist: ${inputPath}`, ExitCode.Usage);
|
|
579
|
+
}
|
|
580
|
+
if (stat.isDirectory()) {
|
|
581
|
+
const relativeDir = normalizeSlashes(path3.relative(root, absolute)) || ".";
|
|
582
|
+
const patterns = options.include?.length ? options.include.map((pattern) => normalizeSlashes(path3.posix.join(relativeDir, pattern))) : [`${relativeDir === "." ? "" : `${relativeDir}/`}**/*.{${includeExtensions.join(",")}}`];
|
|
583
|
+
const matches = await fg(patterns, {
|
|
584
|
+
cwd: root,
|
|
585
|
+
onlyFiles: true,
|
|
586
|
+
dot: false,
|
|
587
|
+
ignore: [...DEFAULT_IGNORES, ...options.exclude ?? []]
|
|
588
|
+
});
|
|
589
|
+
matches.forEach((match) => entries.add(path3.resolve(root, match)));
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (stat.isFile()) {
|
|
593
|
+
if (!isSupportedPath(absolute)) {
|
|
594
|
+
throw new CliError(`Unsupported file type: ${inputPath}`, ExitCode.Usage);
|
|
595
|
+
}
|
|
596
|
+
entries.add(absolute);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return [...entries].sort((left, right) => left.localeCompare(right));
|
|
600
|
+
}
|
|
601
|
+
async function collectFiles(inputPaths, options) {
|
|
602
|
+
if (inputPaths.length === 0) {
|
|
603
|
+
throw new CliError("At least one path is required.", ExitCode.Usage);
|
|
604
|
+
}
|
|
605
|
+
const root = path3.resolve(options.root);
|
|
606
|
+
const absolutePaths = await collectPathEntries(inputPaths, options);
|
|
607
|
+
if (absolutePaths.length === 0) {
|
|
608
|
+
throw new CliError("No supported files were found.", ExitCode.Usage);
|
|
609
|
+
}
|
|
610
|
+
if (absolutePaths.length > DRAFT_REVIEW_MAX_FILES) {
|
|
611
|
+
throw new CliError(
|
|
612
|
+
`Draft reviews support up to ${DRAFT_REVIEW_MAX_FILES} files per revision.`,
|
|
613
|
+
ExitCode.Usage
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
let totalBytes = 0;
|
|
617
|
+
const collected = [];
|
|
618
|
+
for (const absolutePath of absolutePaths) {
|
|
619
|
+
const content = await readUtf8File(absolutePath);
|
|
620
|
+
const sizeBytes = Buffer.byteLength(content);
|
|
621
|
+
if (sizeBytes > DRAFT_REVIEW_MAX_FILE_BYTES) {
|
|
622
|
+
throw new CliError(
|
|
623
|
+
`Draft review file exceeds ${DRAFT_REVIEW_MAX_FILE_BYTES} bytes: ${absolutePath}`,
|
|
624
|
+
ExitCode.Usage
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
totalBytes += sizeBytes;
|
|
628
|
+
if (totalBytes > DRAFT_REVIEW_MAX_TOTAL_BYTES) {
|
|
629
|
+
throw new CliError(
|
|
630
|
+
`Draft review revisions support up to ${DRAFT_REVIEW_MAX_TOTAL_BYTES} total bytes.`,
|
|
631
|
+
ExitCode.Usage
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
const reviewPath = toPosixRelative(root, absolutePath);
|
|
635
|
+
const contentType = detectContentType({
|
|
636
|
+
filePath: reviewPath,
|
|
637
|
+
content,
|
|
638
|
+
requested: options.requestedContentType
|
|
639
|
+
});
|
|
640
|
+
collected.push({
|
|
641
|
+
absolutePath,
|
|
642
|
+
path: reviewPath,
|
|
643
|
+
content,
|
|
644
|
+
contentType,
|
|
645
|
+
contentHash: contentHash(content),
|
|
646
|
+
sizeBytes
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
return collected;
|
|
650
|
+
}
|
|
651
|
+
async function readTrackedFiles(root, trackedFiles) {
|
|
652
|
+
const collected = [];
|
|
653
|
+
for (const tracked of trackedFiles) {
|
|
654
|
+
const absolutePath = path3.resolve(root, tracked.path);
|
|
655
|
+
const content = await readUtf8File(absolutePath);
|
|
656
|
+
const sizeBytes = Buffer.byteLength(content);
|
|
657
|
+
collected.push({
|
|
658
|
+
absolutePath,
|
|
659
|
+
path: tracked.path,
|
|
660
|
+
fileId: tracked.fileId,
|
|
661
|
+
content,
|
|
662
|
+
contentType: tracked.contentType,
|
|
663
|
+
contentHash: contentHash(content),
|
|
664
|
+
sizeBytes
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
return collected;
|
|
668
|
+
}
|
|
669
|
+
function toTrackedFiles(files, apiFiles) {
|
|
670
|
+
const apiByPath = new Map(apiFiles?.map((file) => [file.path, file]) ?? []);
|
|
671
|
+
return files.map((file) => {
|
|
672
|
+
const apiFile = apiByPath.get(file.path);
|
|
673
|
+
return {
|
|
674
|
+
path: file.path,
|
|
675
|
+
fileId: apiFile?.fileId ?? file.fileId,
|
|
676
|
+
contentType: file.contentType,
|
|
677
|
+
contentHash: apiFile?.contentHash ?? file.contentHash,
|
|
678
|
+
sizeBytes: apiFile?.sizeBytes ?? file.sizeBytes
|
|
679
|
+
};
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// src/output.ts
|
|
684
|
+
function writeJson(stdout, value) {
|
|
685
|
+
stdout.write(`${JSON.stringify(value, null, 2)}
|
|
686
|
+
`);
|
|
687
|
+
}
|
|
688
|
+
function writeText(stdout, value) {
|
|
689
|
+
stdout.write(`${value.trimEnd()}
|
|
690
|
+
`);
|
|
691
|
+
}
|
|
692
|
+
function formatReviewCreated(input) {
|
|
693
|
+
return [
|
|
694
|
+
"Created Commentary review",
|
|
695
|
+
"",
|
|
696
|
+
`Title: ${input.draftReview.title}`,
|
|
697
|
+
`Files: ${input.fileCount}`,
|
|
698
|
+
`Session: ${input.draftReview.id}`,
|
|
699
|
+
`URL: ${input.draftReview.reviewUrl}`,
|
|
700
|
+
"",
|
|
701
|
+
`Saved local session metadata to ${input.sessionFilePath}`
|
|
702
|
+
].join("\n");
|
|
703
|
+
}
|
|
704
|
+
function formatRevision(input) {
|
|
705
|
+
return [
|
|
706
|
+
input.noOp ? "No changes to sync" : "Synced Commentary review",
|
|
707
|
+
"",
|
|
708
|
+
`Session: ${input.metadata.reviewSessionId}`,
|
|
709
|
+
`Revision: ${input.revision.revisionNumber}`,
|
|
710
|
+
`Files uploaded: ${input.uploaded}`,
|
|
711
|
+
`URL: ${input.metadata.reviewUrl}`
|
|
712
|
+
].join("\n");
|
|
713
|
+
}
|
|
714
|
+
function commentBody(thread) {
|
|
715
|
+
const first = thread.comments[0];
|
|
716
|
+
return first?.bodyMarkdown ?? first?.body ?? "";
|
|
717
|
+
}
|
|
718
|
+
function commentAuthor(thread) {
|
|
719
|
+
const first = thread.comments[0];
|
|
720
|
+
return first?.authorLogin ?? first?.author ?? "Unknown";
|
|
721
|
+
}
|
|
722
|
+
function formatCommentsText(threads) {
|
|
723
|
+
if (threads.length === 0) {
|
|
724
|
+
return "No comments found.";
|
|
725
|
+
}
|
|
726
|
+
return threads.map(
|
|
727
|
+
(thread) => [
|
|
728
|
+
`[${thread.id}] ${thread.filePath}`,
|
|
729
|
+
`Status: ${thread.status}`,
|
|
730
|
+
thread.selectedText ? `Anchor: "${thread.selectedText}"` : null,
|
|
731
|
+
`${commentAuthor(thread)}: ${commentBody(thread)}`
|
|
732
|
+
].filter(Boolean).join("\n")
|
|
733
|
+
).join("\n\n");
|
|
734
|
+
}
|
|
735
|
+
function formatCommentsMarkdown(input) {
|
|
736
|
+
const lines = [
|
|
737
|
+
"# Commentary Review Comments",
|
|
738
|
+
"",
|
|
739
|
+
`Session: ${input.session.reviewSessionId}`,
|
|
740
|
+
`URL: ${input.session.reviewUrl}`,
|
|
741
|
+
""
|
|
742
|
+
];
|
|
743
|
+
if (input.threads.length === 0) {
|
|
744
|
+
lines.push("No comments found.");
|
|
745
|
+
return lines.join("\n");
|
|
746
|
+
}
|
|
747
|
+
for (const thread of input.threads) {
|
|
748
|
+
lines.push(`## Comment ${thread.id}`, "");
|
|
749
|
+
lines.push(`File: ${thread.filePath}`);
|
|
750
|
+
lines.push(`Status: ${thread.status}`);
|
|
751
|
+
if (thread.selectedText) {
|
|
752
|
+
lines.push(`Anchor: "${thread.selectedText}"`);
|
|
753
|
+
}
|
|
754
|
+
if (thread.sourceLineStart) {
|
|
755
|
+
lines.push(
|
|
756
|
+
`Lines: ${thread.sourceLineStart}${thread.sourceLineEnd && thread.sourceLineEnd !== thread.sourceLineStart ? `-${thread.sourceLineEnd}` : ""}`
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
lines.push("", "User comment:", `> ${commentBody(thread).replace(/\n/g, "\n> ")}`, "");
|
|
760
|
+
const replies = thread.comments.slice(1);
|
|
761
|
+
if (replies.length > 0) {
|
|
762
|
+
lines.push("Replies:");
|
|
763
|
+
replies.forEach((reply) => {
|
|
764
|
+
lines.push(
|
|
765
|
+
`- ${reply.authorLogin ?? reply.author ?? "Unknown"}: ${reply.bodyMarkdown ?? reply.body ?? ""}`
|
|
766
|
+
);
|
|
767
|
+
});
|
|
768
|
+
lines.push("");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return lines.join("\n").trimEnd();
|
|
772
|
+
}
|
|
773
|
+
function formatWaitCommentText(event) {
|
|
774
|
+
if (!event.thread) {
|
|
775
|
+
return `Received ${event.type} (${event.id}).`;
|
|
776
|
+
}
|
|
777
|
+
const thread = event.thread;
|
|
778
|
+
const body = commentBody(thread);
|
|
779
|
+
return [
|
|
780
|
+
`[${thread.id}] ${thread.filePath}`,
|
|
781
|
+
`Event: ${event.type}`,
|
|
782
|
+
`Status: ${thread.status}`,
|
|
783
|
+
thread.selectedText ? `Anchor: "${thread.selectedText}"` : null,
|
|
784
|
+
`${commentAuthor(thread)}: ${body}`
|
|
785
|
+
].filter(Boolean).join("\n");
|
|
786
|
+
}
|
|
787
|
+
function formatWaitCommentMarkdown(input) {
|
|
788
|
+
const lines = [
|
|
789
|
+
"# Commentary Comment",
|
|
790
|
+
"",
|
|
791
|
+
`Session: ${input.session.reviewSessionId}`,
|
|
792
|
+
`URL: ${input.session.reviewUrl}`,
|
|
793
|
+
`Event: ${input.event.type}`,
|
|
794
|
+
`Cursor: ${input.event.id}`,
|
|
795
|
+
""
|
|
796
|
+
];
|
|
797
|
+
if (!input.event.thread) {
|
|
798
|
+
lines.push("No thread payload was available for this event.");
|
|
799
|
+
return lines.join("\n");
|
|
800
|
+
}
|
|
801
|
+
lines.push(formatCommentsMarkdown({ session: input.session, threads: [input.event.thread] }));
|
|
802
|
+
return lines.join("\n").trimEnd();
|
|
803
|
+
}
|
|
804
|
+
function publicSessionJson(metadata) {
|
|
805
|
+
return {
|
|
806
|
+
reviewSessionId: metadata.reviewSessionId,
|
|
807
|
+
reviewUrl: metadata.reviewUrl,
|
|
808
|
+
baseUrl: metadata.baseUrl,
|
|
809
|
+
rootPath: metadata.rootPath,
|
|
810
|
+
trackedFiles: metadata.trackedFiles,
|
|
811
|
+
createdAt: metadata.createdAt,
|
|
812
|
+
lastSyncedAt: metadata.lastSyncedAt,
|
|
813
|
+
lastKnownRevision: metadata.lastKnownRevision
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/session.ts
|
|
818
|
+
import fs3 from "fs/promises";
|
|
819
|
+
import path4 from "path";
|
|
820
|
+
async function pathExists(filePath) {
|
|
821
|
+
try {
|
|
822
|
+
await fs3.access(filePath);
|
|
823
|
+
return true;
|
|
824
|
+
} catch {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
async function findSessionFile(startDir, explicitPath) {
|
|
829
|
+
if (explicitPath) {
|
|
830
|
+
return path4.resolve(startDir, explicitPath);
|
|
831
|
+
}
|
|
832
|
+
let current = path4.resolve(startDir);
|
|
833
|
+
while (true) {
|
|
834
|
+
const candidate = path4.join(current, SESSION_FILE);
|
|
835
|
+
if (await pathExists(candidate)) {
|
|
836
|
+
return candidate;
|
|
837
|
+
}
|
|
838
|
+
const parent = path4.dirname(current);
|
|
839
|
+
if (parent === current) {
|
|
840
|
+
return path4.resolve(startDir, SESSION_FILE);
|
|
841
|
+
}
|
|
842
|
+
current = parent;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
async function loadSessionMetadata(startDir, explicitPath) {
|
|
846
|
+
const filePath = await findSessionFile(startDir, explicitPath);
|
|
847
|
+
let raw;
|
|
848
|
+
try {
|
|
849
|
+
raw = await fs3.readFile(filePath, "utf8");
|
|
850
|
+
} catch {
|
|
851
|
+
throw new CliError(
|
|
852
|
+
`No Commentary session metadata found at ${filePath}. Run commentary review first.`,
|
|
853
|
+
ExitCode.Usage
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
const metadata = JSON.parse(raw);
|
|
857
|
+
return {
|
|
858
|
+
filePath,
|
|
859
|
+
metadata
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
async function saveSessionMetadata(filePath, metadata) {
|
|
863
|
+
await fs3.mkdir(path4.dirname(filePath), { recursive: true });
|
|
864
|
+
await fs3.writeFile(filePath, `${JSON.stringify(metadata, null, 2)}
|
|
865
|
+
`, {
|
|
866
|
+
encoding: "utf8",
|
|
867
|
+
mode: 420
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
function sessionRoot(sessionFilePath, metadata) {
|
|
871
|
+
return path4.resolve(path4.dirname(sessionFilePath), metadata.rootPath);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// src/commands.ts
|
|
875
|
+
function nowIso() {
|
|
876
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
877
|
+
}
|
|
878
|
+
async function makeClient(runtime, options) {
|
|
879
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
880
|
+
const token = await resolveToken({ baseUrl, token: options.token });
|
|
881
|
+
return new CommentaryApiClient({ baseUrl, token, fetchImpl: runtime.fetchImpl });
|
|
882
|
+
}
|
|
883
|
+
function apiFilesFromRevision(revision) {
|
|
884
|
+
return revision?.files.map((file) => ({
|
|
885
|
+
fileId: file.fileId,
|
|
886
|
+
path: file.path,
|
|
887
|
+
contentHash: file.contentHash,
|
|
888
|
+
sizeBytes: file.sizeBytes,
|
|
889
|
+
contentType: file.contentType
|
|
890
|
+
}));
|
|
891
|
+
}
|
|
892
|
+
function changedFiles(current, tracked) {
|
|
893
|
+
const trackedByPath = new Map(tracked.map((file) => [file.path, file]));
|
|
894
|
+
return current.filter((file) => {
|
|
895
|
+
const previous = trackedByPath.get(file.path);
|
|
896
|
+
return !previous || previous.contentHash !== file.contentHash || previous.contentType !== file.contentType;
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
async function loadSession(runtime, options) {
|
|
900
|
+
return loadSessionMetadata(runtime.cwd, options.sessionFile);
|
|
901
|
+
}
|
|
902
|
+
function shouldOpen(runtime, noOpen) {
|
|
903
|
+
return !noOpen && runtime.isTty !== false && !process.env.CI;
|
|
904
|
+
}
|
|
905
|
+
function placeholderSessionMetadata(input) {
|
|
906
|
+
return {
|
|
907
|
+
version: 1,
|
|
908
|
+
reviewSessionId: input.sessionId,
|
|
909
|
+
reviewUrl: `${input.baseUrl}/review/draft/${encodeURIComponent(input.sessionId)}`,
|
|
910
|
+
baseUrl: input.baseUrl,
|
|
911
|
+
rootPath: ".",
|
|
912
|
+
trackedFiles: [],
|
|
913
|
+
source: [],
|
|
914
|
+
createdAt: "",
|
|
915
|
+
lastSyncedAt: "",
|
|
916
|
+
lastKnownRevision: null
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
async function openOrPrint(runtime, url, noOpen) {
|
|
920
|
+
if (!shouldOpen(runtime, noOpen)) {
|
|
921
|
+
writeText(runtime.stdout, url);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
try {
|
|
925
|
+
await open(url);
|
|
926
|
+
} catch {
|
|
927
|
+
writeText(runtime.stdout, url);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
function parseDurationMs(value, defaultMs) {
|
|
931
|
+
const raw = value?.trim();
|
|
932
|
+
if (!raw) {
|
|
933
|
+
return defaultMs;
|
|
934
|
+
}
|
|
935
|
+
if (raw === "0") {
|
|
936
|
+
return 0;
|
|
937
|
+
}
|
|
938
|
+
const match = raw.match(/^(\d+)(ms|s|m|h)?$/i);
|
|
939
|
+
if (!match) {
|
|
940
|
+
throw new CliError(
|
|
941
|
+
"Duration must be a number with optional ms, s, m, or h suffix.",
|
|
942
|
+
ExitCode.Usage
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
const amount = Number(match[1]);
|
|
946
|
+
const unit = match[2]?.toLowerCase() ?? "ms";
|
|
947
|
+
const multiplier = unit === "h" ? 36e5 : unit === "m" ? 6e4 : unit === "s" ? 1e3 : 1;
|
|
948
|
+
return amount * multiplier;
|
|
949
|
+
}
|
|
950
|
+
function delay(ms, signal) {
|
|
951
|
+
return new Promise((resolve) => {
|
|
952
|
+
if (signal.aborted) {
|
|
953
|
+
resolve();
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const timeout = setTimeout(resolve, ms);
|
|
957
|
+
signal.addEventListener(
|
|
958
|
+
"abort",
|
|
959
|
+
() => {
|
|
960
|
+
clearTimeout(timeout);
|
|
961
|
+
resolve();
|
|
962
|
+
},
|
|
963
|
+
{ once: true }
|
|
964
|
+
);
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
function payloadString(event, key) {
|
|
968
|
+
const value = event.payload[key];
|
|
969
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
970
|
+
}
|
|
971
|
+
function isWaitCommentMatch(input) {
|
|
972
|
+
const allowed = input.event.type === "comment.created" || input.includeReplies === true && input.event.type === "reply.created";
|
|
973
|
+
if (!allowed) {
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
if (!input.filePath) {
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
return (input.event.thread?.filePath ?? payloadString(input.event, "filePath")) === input.filePath;
|
|
980
|
+
}
|
|
981
|
+
async function loginCommand(runtime, options) {
|
|
982
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
983
|
+
if (options.token?.trim()) {
|
|
984
|
+
await setStoredToken(baseUrl, { accessToken: options.token.trim() });
|
|
985
|
+
if (options.json) {
|
|
986
|
+
writeJson(runtime.stdout, { ok: true, baseUrl });
|
|
987
|
+
} else {
|
|
988
|
+
writeText(runtime.stdout, `Stored Commentary token for ${baseUrl}.`);
|
|
989
|
+
}
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const client = new CommentaryApiClient({ baseUrl, fetchImpl: runtime.fetchImpl });
|
|
993
|
+
const resource = `${baseUrl}/api`;
|
|
994
|
+
const device = await client.requestDeviceCode({
|
|
995
|
+
clientId: CLIENT_ID,
|
|
996
|
+
clientName: CLIENT_NAME,
|
|
997
|
+
scope: REQUIRED_SCOPES.join(" "),
|
|
998
|
+
resource
|
|
999
|
+
});
|
|
1000
|
+
if (options.json) {
|
|
1001
|
+
writeJson(runtime.stdout, {
|
|
1002
|
+
verificationUri: device.verification_uri,
|
|
1003
|
+
verificationUriComplete: device.verification_uri_complete,
|
|
1004
|
+
userCode: device.user_code,
|
|
1005
|
+
expiresIn: device.expires_in
|
|
1006
|
+
});
|
|
1007
|
+
} else {
|
|
1008
|
+
writeText(
|
|
1009
|
+
runtime.stdout,
|
|
1010
|
+
[
|
|
1011
|
+
"Connect Commentary",
|
|
1012
|
+
"",
|
|
1013
|
+
`Open: ${device.verification_uri_complete}`,
|
|
1014
|
+
`Code: ${device.user_code}`,
|
|
1015
|
+
"",
|
|
1016
|
+
"Waiting for approval..."
|
|
1017
|
+
].join("\n")
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
if (shouldOpen(runtime, options.noOpen)) {
|
|
1021
|
+
await open(device.verification_uri_complete).catch(() => void 0);
|
|
1022
|
+
}
|
|
1023
|
+
const startedAt = Date.now();
|
|
1024
|
+
const intervalMs = Math.max(1, device.interval) * 1e3;
|
|
1025
|
+
while (Date.now() - startedAt < device.expires_in * 1e3) {
|
|
1026
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1027
|
+
try {
|
|
1028
|
+
const token = await client.exchangeDeviceCode({ deviceCode: device.device_code, resource });
|
|
1029
|
+
await setStoredToken(baseUrl, {
|
|
1030
|
+
accessToken: token.access_token,
|
|
1031
|
+
refreshToken: token.refresh_token,
|
|
1032
|
+
expiresAt: new Date(Date.now() + token.expires_in * 1e3).toISOString()
|
|
1033
|
+
});
|
|
1034
|
+
if (!options.json) {
|
|
1035
|
+
writeText(runtime.stdout, `Logged in to ${baseUrl}.`);
|
|
1036
|
+
}
|
|
1037
|
+
return;
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
if (error instanceof CliError && error.message === "authorization_pending") {
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
throw error;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
throw new CliError("Device authorization expired.", ExitCode.Auth);
|
|
1046
|
+
}
|
|
1047
|
+
async function logoutCommand(runtime, options) {
|
|
1048
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
1049
|
+
await removeStoredToken(baseUrl);
|
|
1050
|
+
if (options.json) {
|
|
1051
|
+
writeJson(runtime.stdout, { ok: true, baseUrl });
|
|
1052
|
+
} else {
|
|
1053
|
+
writeText(runtime.stdout, `Removed Commentary token for ${baseUrl}.`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
async function whoamiCommand(runtime, options) {
|
|
1057
|
+
const client = await makeClient(runtime, options);
|
|
1058
|
+
const result = await client.listDraftReviews();
|
|
1059
|
+
const payload = {
|
|
1060
|
+
ok: true,
|
|
1061
|
+
baseUrl: client.baseUrl,
|
|
1062
|
+
token: "valid",
|
|
1063
|
+
accessibleDraftReviews: result.draftReviews.length
|
|
1064
|
+
};
|
|
1065
|
+
if (options.json) {
|
|
1066
|
+
writeJson(runtime.stdout, payload);
|
|
1067
|
+
} else {
|
|
1068
|
+
writeText(
|
|
1069
|
+
runtime.stdout,
|
|
1070
|
+
`Authenticated for ${client.baseUrl}. Accessible draft reviews: ${result.draftReviews.length}`
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
async function reviewCommand(runtime, paths, options) {
|
|
1075
|
+
const root = path5.resolve(runtime.cwd, options.root ?? ".");
|
|
1076
|
+
const files = await collectFiles(paths, {
|
|
1077
|
+
root,
|
|
1078
|
+
include: options.include,
|
|
1079
|
+
exclude: options.exclude,
|
|
1080
|
+
requestedContentType: options.contentType ?? "auto"
|
|
1081
|
+
});
|
|
1082
|
+
const title = options.title?.trim() || (files.length === 1 ? path5.basename(files[0].path, path5.extname(files[0].path)) : path5.basename(root)) || "Commentary review";
|
|
1083
|
+
const client = await makeClient(runtime, options);
|
|
1084
|
+
const result = await client.createDraftReview({
|
|
1085
|
+
title,
|
|
1086
|
+
description: options.description ?? null,
|
|
1087
|
+
files
|
|
1088
|
+
});
|
|
1089
|
+
const sessionFilePath = await findSessionFile(root, options.sessionFile ?? SESSION_FILE);
|
|
1090
|
+
const now = nowIso();
|
|
1091
|
+
const metadata = {
|
|
1092
|
+
version: 1,
|
|
1093
|
+
reviewSessionId: result.sessionId,
|
|
1094
|
+
reviewUrl: result.reviewUrl,
|
|
1095
|
+
baseUrl: client.baseUrl,
|
|
1096
|
+
rootPath: path5.relative(path5.dirname(sessionFilePath), root) || ".",
|
|
1097
|
+
trackedFiles: toTrackedFiles(files, apiFilesFromRevision(result.draftReview.latestRevision)),
|
|
1098
|
+
source: ["review", ...paths],
|
|
1099
|
+
createdAt: now,
|
|
1100
|
+
lastSyncedAt: now,
|
|
1101
|
+
lastKnownRevision: result.draftReview.latestRevision?.revisionNumber ?? null
|
|
1102
|
+
};
|
|
1103
|
+
await saveSessionMetadata(sessionFilePath, metadata);
|
|
1104
|
+
if (options.json) {
|
|
1105
|
+
writeJson(runtime.stdout, {
|
|
1106
|
+
ok: true,
|
|
1107
|
+
draftReview: result.draftReview,
|
|
1108
|
+
sessionFilePath,
|
|
1109
|
+
session: publicSessionJson(metadata)
|
|
1110
|
+
});
|
|
1111
|
+
} else {
|
|
1112
|
+
writeText(
|
|
1113
|
+
runtime.stdout,
|
|
1114
|
+
formatReviewCreated({
|
|
1115
|
+
draftReview: result.draftReview,
|
|
1116
|
+
sessionFilePath,
|
|
1117
|
+
fileCount: files.length
|
|
1118
|
+
})
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
if (!options.noOpen) {
|
|
1122
|
+
await openOrPrint(runtime, result.reviewUrl, options.noOpen);
|
|
1123
|
+
}
|
|
1124
|
+
if (options.watch) {
|
|
1125
|
+
await watchCommand(runtime, { ...options, sessionFile: sessionFilePath });
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
async function syncCommand(runtime, options) {
|
|
1129
|
+
const loaded = await loadSession(runtime, options);
|
|
1130
|
+
const root = sessionRoot(loaded.filePath, loaded.metadata);
|
|
1131
|
+
const current = await readTrackedFiles(root, loaded.metadata.trackedFiles);
|
|
1132
|
+
const changed = changedFiles(current, loaded.metadata.trackedFiles);
|
|
1133
|
+
if (options.dryRun) {
|
|
1134
|
+
const payload = {
|
|
1135
|
+
ok: true,
|
|
1136
|
+
changedFiles: changed.map((file) => file.path),
|
|
1137
|
+
fileCount: current.length
|
|
1138
|
+
};
|
|
1139
|
+
if (options.json) {
|
|
1140
|
+
writeJson(runtime.stdout, payload);
|
|
1141
|
+
} else {
|
|
1142
|
+
writeText(
|
|
1143
|
+
runtime.stdout,
|
|
1144
|
+
payload.changedFiles.length ? payload.changedFiles.join("\n") : "No local changes."
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
if (!options.all && changed.length === 0) {
|
|
1150
|
+
const revision = {
|
|
1151
|
+
id: "",
|
|
1152
|
+
revisionNumber: loaded.metadata.lastKnownRevision ?? 0,
|
|
1153
|
+
files: []
|
|
1154
|
+
};
|
|
1155
|
+
if (options.json) {
|
|
1156
|
+
writeJson(runtime.stdout, {
|
|
1157
|
+
ok: true,
|
|
1158
|
+
noOp: true,
|
|
1159
|
+
session: publicSessionJson(loaded.metadata)
|
|
1160
|
+
});
|
|
1161
|
+
} else {
|
|
1162
|
+
writeText(
|
|
1163
|
+
runtime.stdout,
|
|
1164
|
+
formatRevision({ metadata: loaded.metadata, revision, uploaded: 0, noOp: true })
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const client = await makeClient(runtime, { ...options, baseUrl: loaded.metadata.baseUrl });
|
|
1170
|
+
const result = await client.createRevision({
|
|
1171
|
+
sessionId: loaded.metadata.reviewSessionId,
|
|
1172
|
+
summary: options.message ?? null,
|
|
1173
|
+
files: current
|
|
1174
|
+
});
|
|
1175
|
+
const nextMetadata = {
|
|
1176
|
+
...loaded.metadata,
|
|
1177
|
+
trackedFiles: toTrackedFiles(current, apiFilesFromRevision(result.revision)),
|
|
1178
|
+
lastSyncedAt: nowIso(),
|
|
1179
|
+
lastKnownRevision: result.revision.revisionNumber
|
|
1180
|
+
};
|
|
1181
|
+
await saveSessionMetadata(loaded.filePath, nextMetadata);
|
|
1182
|
+
if (options.json) {
|
|
1183
|
+
writeJson(runtime.stdout, {
|
|
1184
|
+
ok: true,
|
|
1185
|
+
revision: result.revision,
|
|
1186
|
+
noOp: Boolean(result.noOp),
|
|
1187
|
+
session: publicSessionJson(nextMetadata)
|
|
1188
|
+
});
|
|
1189
|
+
} else {
|
|
1190
|
+
writeText(
|
|
1191
|
+
runtime.stdout,
|
|
1192
|
+
formatRevision({
|
|
1193
|
+
metadata: nextMetadata,
|
|
1194
|
+
revision: result.revision,
|
|
1195
|
+
uploaded: current.length,
|
|
1196
|
+
noOp: result.noOp
|
|
1197
|
+
})
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
async function watchCommand(runtime, options) {
|
|
1202
|
+
const loaded = await loadSession(runtime, options);
|
|
1203
|
+
const root = sessionRoot(loaded.filePath, loaded.metadata);
|
|
1204
|
+
const debounceMs = Number(options.debounce ?? 1500);
|
|
1205
|
+
let timer = null;
|
|
1206
|
+
let syncing = false;
|
|
1207
|
+
const watcher = chokidar.watch(
|
|
1208
|
+
loaded.metadata.trackedFiles.map((file) => path5.resolve(root, file.path)),
|
|
1209
|
+
{
|
|
1210
|
+
ignoreInitial: true,
|
|
1211
|
+
ignored: /(^|[/\\])(\.git|node_modules|dist|build|\.next|\.commentary)([/\\]|$)|(~$|\.tmp$|\.swp$)/
|
|
1212
|
+
}
|
|
1213
|
+
);
|
|
1214
|
+
const runSync = () => {
|
|
1215
|
+
if (timer) {
|
|
1216
|
+
clearTimeout(timer);
|
|
1217
|
+
}
|
|
1218
|
+
timer = setTimeout(() => {
|
|
1219
|
+
if (syncing) {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
syncing = true;
|
|
1223
|
+
syncCommand(runtime, { ...options, message: options.message ?? "Watch sync" }).catch(
|
|
1224
|
+
(error) => runtime.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
1225
|
+
`)
|
|
1226
|
+
).finally(() => {
|
|
1227
|
+
syncing = false;
|
|
1228
|
+
});
|
|
1229
|
+
}, debounceMs);
|
|
1230
|
+
};
|
|
1231
|
+
watcher.on("add", runSync).on("change", runSync).on("unlink", runSync);
|
|
1232
|
+
writeText(
|
|
1233
|
+
runtime.stdout,
|
|
1234
|
+
`Watching ${loaded.metadata.trackedFiles.length} file(s). Press Ctrl+C to stop.`
|
|
1235
|
+
);
|
|
1236
|
+
await new Promise((resolve) => {
|
|
1237
|
+
const close = () => {
|
|
1238
|
+
void watcher.close().finally(resolve);
|
|
1239
|
+
};
|
|
1240
|
+
process.once("SIGINT", close);
|
|
1241
|
+
process.once("SIGTERM", close);
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
async function commentsCommand(runtime, options) {
|
|
1245
|
+
const loaded = options.session ? null : await loadSession(runtime, options);
|
|
1246
|
+
const metadata = loaded?.metadata;
|
|
1247
|
+
const sessionId = options.session ?? metadata?.reviewSessionId;
|
|
1248
|
+
if (!sessionId) {
|
|
1249
|
+
throw new CliError("A session id is required.", ExitCode.Usage);
|
|
1250
|
+
}
|
|
1251
|
+
const baseUrl = metadata?.baseUrl ?? normalizeBaseUrl(options.baseUrl);
|
|
1252
|
+
const client = await makeClient(runtime, { ...options, baseUrl });
|
|
1253
|
+
const status = options.all ? void 0 : options.resolved ? "resolved" : "open";
|
|
1254
|
+
const result = await client.listComments({
|
|
1255
|
+
sessionId,
|
|
1256
|
+
status,
|
|
1257
|
+
filePath: options.file ? normalizeReviewPath(options.file) : void 0
|
|
1258
|
+
});
|
|
1259
|
+
const format = options.json ? "json" : options.format ?? "text";
|
|
1260
|
+
if (format === "json") {
|
|
1261
|
+
writeJson(runtime.stdout, { ok: true, threads: result.threads });
|
|
1262
|
+
} else if (format === "markdown") {
|
|
1263
|
+
writeText(
|
|
1264
|
+
runtime.stdout,
|
|
1265
|
+
formatCommentsMarkdown({
|
|
1266
|
+
session: metadata ?? {
|
|
1267
|
+
version: 1,
|
|
1268
|
+
reviewSessionId: sessionId,
|
|
1269
|
+
reviewUrl: `${baseUrl}/review/draft/${encodeURIComponent(sessionId)}`,
|
|
1270
|
+
baseUrl,
|
|
1271
|
+
rootPath: ".",
|
|
1272
|
+
trackedFiles: [],
|
|
1273
|
+
source: [],
|
|
1274
|
+
createdAt: "",
|
|
1275
|
+
lastSyncedAt: "",
|
|
1276
|
+
lastKnownRevision: null
|
|
1277
|
+
},
|
|
1278
|
+
threads: result.threads
|
|
1279
|
+
})
|
|
1280
|
+
);
|
|
1281
|
+
} else {
|
|
1282
|
+
writeText(runtime.stdout, formatCommentsText(result.threads));
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
async function waitCommentCommand(runtime, options) {
|
|
1286
|
+
const loaded = options.session ? null : await loadSession(runtime, options);
|
|
1287
|
+
const sessionId = options.session ?? loaded?.metadata.reviewSessionId;
|
|
1288
|
+
if (!sessionId) {
|
|
1289
|
+
throw new CliError("A session id is required.", ExitCode.Usage);
|
|
1290
|
+
}
|
|
1291
|
+
const baseUrl = loaded?.metadata.baseUrl ?? normalizeBaseUrl(options.baseUrl);
|
|
1292
|
+
const session = loaded?.metadata ?? placeholderSessionMetadata({ sessionId, baseUrl });
|
|
1293
|
+
const client = await makeClient(runtime, { ...options, baseUrl });
|
|
1294
|
+
const filePath = options.file ? normalizeReviewPath(options.file) : void 0;
|
|
1295
|
+
const timeoutMs = parseDurationMs(options.timeout, 30 * 60 * 1e3);
|
|
1296
|
+
const abortController = new AbortController();
|
|
1297
|
+
let timedOut = false;
|
|
1298
|
+
let cursor = options.cursor ?? (options.from === "beginning" ? void 0 : "latest");
|
|
1299
|
+
let reconnectDelayMs = 1e3;
|
|
1300
|
+
const timeout = timeoutMs > 0 ? setTimeout(() => {
|
|
1301
|
+
timedOut = true;
|
|
1302
|
+
abortController.abort();
|
|
1303
|
+
}, timeoutMs) : null;
|
|
1304
|
+
try {
|
|
1305
|
+
while (!abortController.signal.aborted) {
|
|
1306
|
+
try {
|
|
1307
|
+
for await (const event of client.streamDraftReviewEvents({
|
|
1308
|
+
sessionId,
|
|
1309
|
+
cursor,
|
|
1310
|
+
signal: abortController.signal
|
|
1311
|
+
})) {
|
|
1312
|
+
cursor = event.id;
|
|
1313
|
+
reconnectDelayMs = 1e3;
|
|
1314
|
+
if (event.type === "draft.deleted") {
|
|
1315
|
+
throw new CliError("Draft review was deleted before a comment arrived.", ExitCode.Api);
|
|
1316
|
+
}
|
|
1317
|
+
if (!isWaitCommentMatch({ event, includeReplies: options.includeReplies, filePath })) {
|
|
1318
|
+
continue;
|
|
1319
|
+
}
|
|
1320
|
+
const format = options.json ? "json" : options.format ?? "markdown";
|
|
1321
|
+
if (format === "json") {
|
|
1322
|
+
writeJson(runtime.stdout, { ok: true, event });
|
|
1323
|
+
} else if (format === "text") {
|
|
1324
|
+
writeText(runtime.stdout, formatWaitCommentText(event));
|
|
1325
|
+
} else {
|
|
1326
|
+
writeText(runtime.stdout, formatWaitCommentMarkdown({ session, event }));
|
|
1327
|
+
}
|
|
1328
|
+
abortController.abort();
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
if (abortController.signal.aborted) {
|
|
1333
|
+
break;
|
|
1334
|
+
}
|
|
1335
|
+
if (error instanceof CliError && error.exitCode !== ExitCode.Network) {
|
|
1336
|
+
throw error;
|
|
1337
|
+
}
|
|
1338
|
+
if (options.verbose) {
|
|
1339
|
+
runtime.stderr.write(
|
|
1340
|
+
`Event stream disconnected; reconnecting in ${reconnectDelayMs}ms.
|
|
1341
|
+
`
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
await delay(reconnectDelayMs, abortController.signal);
|
|
1346
|
+
reconnectDelayMs = Math.min(reconnectDelayMs * 2, 1e4);
|
|
1347
|
+
}
|
|
1348
|
+
} finally {
|
|
1349
|
+
if (timeout) {
|
|
1350
|
+
clearTimeout(timeout);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
throw new CliError(
|
|
1354
|
+
timedOut ? "Timed out waiting for a draft review comment." : "Stopped waiting for a draft review comment.",
|
|
1355
|
+
timedOut ? ExitCode.Timeout : ExitCode.General
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
async function replyCommand(runtime, threadId, message, options) {
|
|
1359
|
+
const loaded = options.session ? null : await loadSession(runtime, options);
|
|
1360
|
+
const sessionId = options.session ?? loaded?.metadata.reviewSessionId;
|
|
1361
|
+
if (!sessionId) {
|
|
1362
|
+
throw new CliError("A session id is required.", ExitCode.Usage);
|
|
1363
|
+
}
|
|
1364
|
+
const client = await makeClient(runtime, {
|
|
1365
|
+
...options,
|
|
1366
|
+
baseUrl: loaded?.metadata.baseUrl ?? options.baseUrl
|
|
1367
|
+
});
|
|
1368
|
+
const result = await client.replyToComment({ sessionId, threadId, bodyMarkdown: message });
|
|
1369
|
+
if (options.json) {
|
|
1370
|
+
writeJson(runtime.stdout, { ok: true, thread: result.thread });
|
|
1371
|
+
} else {
|
|
1372
|
+
writeText(runtime.stdout, `Replied to ${threadId}.`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
async function resolveCommand(runtime, threadId, options) {
|
|
1376
|
+
const loaded = options.session ? null : await loadSession(runtime, options);
|
|
1377
|
+
const sessionId = options.session ?? loaded?.metadata.reviewSessionId;
|
|
1378
|
+
if (!sessionId) {
|
|
1379
|
+
throw new CliError("A session id is required.", ExitCode.Usage);
|
|
1380
|
+
}
|
|
1381
|
+
const client = await makeClient(runtime, {
|
|
1382
|
+
...options,
|
|
1383
|
+
baseUrl: loaded?.metadata.baseUrl ?? options.baseUrl
|
|
1384
|
+
});
|
|
1385
|
+
if (options.message?.trim()) {
|
|
1386
|
+
await client.replyToComment({ sessionId, threadId, bodyMarkdown: options.message.trim() });
|
|
1387
|
+
}
|
|
1388
|
+
const result = await client.updateCommentStatus({ sessionId, threadId, status: "resolved" });
|
|
1389
|
+
if (options.json) {
|
|
1390
|
+
writeJson(runtime.stdout, { ok: true, thread: result.thread });
|
|
1391
|
+
} else {
|
|
1392
|
+
writeText(runtime.stdout, `Resolved ${threadId}.`);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
async function pullCommand(runtime, options) {
|
|
1396
|
+
const loaded = await loadSession(runtime, options);
|
|
1397
|
+
const root = sessionRoot(loaded.filePath, loaded.metadata);
|
|
1398
|
+
const client = await makeClient(runtime, { ...options, baseUrl: loaded.metadata.baseUrl });
|
|
1399
|
+
const session = await client.getDraftReview(loaded.metadata.reviewSessionId);
|
|
1400
|
+
const writes = [];
|
|
1401
|
+
for (const file of session.draftReview.files) {
|
|
1402
|
+
const content = await client.getFileContent({
|
|
1403
|
+
sessionId: loaded.metadata.reviewSessionId,
|
|
1404
|
+
fileId: file.id
|
|
1405
|
+
});
|
|
1406
|
+
const targetRoot = options.output ? path5.resolve(runtime.cwd, options.output) : root;
|
|
1407
|
+
const target = path5.resolve(targetRoot, file.path);
|
|
1408
|
+
let current = null;
|
|
1409
|
+
try {
|
|
1410
|
+
current = await fs4.readFile(target, "utf8");
|
|
1411
|
+
} catch {
|
|
1412
|
+
current = null;
|
|
1413
|
+
}
|
|
1414
|
+
writes.push({ path: file.path, target, content, changed: current !== content });
|
|
1415
|
+
}
|
|
1416
|
+
if (options.dryRun) {
|
|
1417
|
+
const changed = writes.filter((write) => write.changed).map((write) => write.path);
|
|
1418
|
+
if (options.json) {
|
|
1419
|
+
writeJson(runtime.stdout, { ok: true, changedFiles: changed });
|
|
1420
|
+
} else {
|
|
1421
|
+
writeText(runtime.stdout, changed.length ? changed.join("\n") : "No remote changes.");
|
|
1422
|
+
}
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const changedWrites = writes.filter((write) => write.changed);
|
|
1426
|
+
if (changedWrites.length > 0 && !options.yes && !options.output) {
|
|
1427
|
+
throw new CliError(
|
|
1428
|
+
"Pull would overwrite local files. Rerun with --yes, --backup, or --output <dir>.",
|
|
1429
|
+
ExitCode.Safety
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
for (const write of writes) {
|
|
1433
|
+
if (!write.changed) {
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
await fs4.mkdir(path5.dirname(write.target), { recursive: true });
|
|
1437
|
+
if (options.backup) {
|
|
1438
|
+
try {
|
|
1439
|
+
await fs4.copyFile(write.target, `${write.target}.bak`);
|
|
1440
|
+
} catch {
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
await fs4.writeFile(write.target, write.content, "utf8");
|
|
1444
|
+
}
|
|
1445
|
+
const nextTracked = loaded.metadata.trackedFiles.map((tracked) => {
|
|
1446
|
+
const write = writes.find((candidate) => candidate.path === tracked.path);
|
|
1447
|
+
return write ? {
|
|
1448
|
+
...tracked,
|
|
1449
|
+
contentHash: contentHash(write.content),
|
|
1450
|
+
sizeBytes: Buffer.byteLength(write.content)
|
|
1451
|
+
} : tracked;
|
|
1452
|
+
});
|
|
1453
|
+
await saveSessionMetadata(loaded.filePath, { ...loaded.metadata, trackedFiles: nextTracked });
|
|
1454
|
+
if (options.json) {
|
|
1455
|
+
writeJson(runtime.stdout, { ok: true, filesWritten: changedWrites.length });
|
|
1456
|
+
} else {
|
|
1457
|
+
writeText(runtime.stdout, `Pulled ${changedWrites.length} file(s).`);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
async function openCommand(runtime, options) {
|
|
1461
|
+
if (options.session) {
|
|
1462
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
1463
|
+
await openOrPrint(runtime, `${baseUrl}/review/draft/${encodeURIComponent(options.session)}`);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
const loaded = await loadSession(runtime, options);
|
|
1467
|
+
await openOrPrint(runtime, loaded.metadata.reviewUrl);
|
|
1468
|
+
}
|
|
1469
|
+
async function statusCommand(runtime, options) {
|
|
1470
|
+
const loaded = await loadSession(runtime, options);
|
|
1471
|
+
const root = sessionRoot(loaded.filePath, loaded.metadata);
|
|
1472
|
+
const current = await readTrackedFiles(root, loaded.metadata.trackedFiles);
|
|
1473
|
+
const changed = changedFiles(current, loaded.metadata.trackedFiles).map((file) => file.path);
|
|
1474
|
+
let openComments = null;
|
|
1475
|
+
try {
|
|
1476
|
+
const client = await makeClient(runtime, { ...options, baseUrl: loaded.metadata.baseUrl });
|
|
1477
|
+
openComments = (await client.listComments({ sessionId: loaded.metadata.reviewSessionId, status: "open" })).threads.length;
|
|
1478
|
+
} catch {
|
|
1479
|
+
openComments = null;
|
|
1480
|
+
}
|
|
1481
|
+
const payload = { ...publicSessionJson(loaded.metadata), changedFiles: changed, openComments };
|
|
1482
|
+
if (options.json) {
|
|
1483
|
+
writeJson(runtime.stdout, payload);
|
|
1484
|
+
} else {
|
|
1485
|
+
writeText(
|
|
1486
|
+
runtime.stdout,
|
|
1487
|
+
[
|
|
1488
|
+
`Session: ${loaded.metadata.reviewSessionId}`,
|
|
1489
|
+
`URL: ${loaded.metadata.reviewUrl}`,
|
|
1490
|
+
`Base URL: ${loaded.metadata.baseUrl}`,
|
|
1491
|
+
`Tracked files: ${loaded.metadata.trackedFiles.length}`,
|
|
1492
|
+
`Last revision: ${loaded.metadata.lastKnownRevision ?? "unknown"}`,
|
|
1493
|
+
`Changed files: ${changed.length ? changed.join(", ") : "none"}`,
|
|
1494
|
+
`Open comments: ${openComments ?? "unknown"}`
|
|
1495
|
+
].join("\n")
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
async function sessionsCommand(runtime, options) {
|
|
1500
|
+
const client = await makeClient(runtime, options);
|
|
1501
|
+
const result = await client.listDraftReviews();
|
|
1502
|
+
if (options.json) {
|
|
1503
|
+
writeJson(runtime.stdout, result);
|
|
1504
|
+
} else {
|
|
1505
|
+
writeText(
|
|
1506
|
+
runtime.stdout,
|
|
1507
|
+
result.draftReviews.map((draft) => `${draft.id} ${draft.title} ${draft.reviewUrl}`).join("\n") || "No draft reviews found."
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
async function revisionsCommand(runtime, options) {
|
|
1512
|
+
const loaded = options.session ? null : await loadSession(runtime, options);
|
|
1513
|
+
const sessionId = options.session ?? loaded?.metadata.reviewSessionId;
|
|
1514
|
+
if (!sessionId) {
|
|
1515
|
+
throw new CliError("A session id is required.", ExitCode.Usage);
|
|
1516
|
+
}
|
|
1517
|
+
const client = await makeClient(runtime, {
|
|
1518
|
+
...options,
|
|
1519
|
+
baseUrl: loaded?.metadata.baseUrl ?? options.baseUrl
|
|
1520
|
+
});
|
|
1521
|
+
const result = await client.listRevisions(sessionId);
|
|
1522
|
+
if (options.json) {
|
|
1523
|
+
writeJson(runtime.stdout, result);
|
|
1524
|
+
} else {
|
|
1525
|
+
writeText(
|
|
1526
|
+
runtime.stdout,
|
|
1527
|
+
result.revisions.map(
|
|
1528
|
+
(revision) => `#${revision.revisionNumber} ${revision.summary ?? ""} ${revision.createdAt ?? ""}`
|
|
1529
|
+
).join("\n") || "No revisions found."
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// src/cli.ts
|
|
1535
|
+
function collectOption(value, previous = []) {
|
|
1536
|
+
previous.push(value);
|
|
1537
|
+
return previous;
|
|
1538
|
+
}
|
|
1539
|
+
function runtimeFromOptions(options) {
|
|
1540
|
+
return {
|
|
1541
|
+
cwd: options?.cwd ?? process.cwd(),
|
|
1542
|
+
stdout: options?.stdout ?? process.stdout,
|
|
1543
|
+
stderr: options?.stderr ?? process.stderr,
|
|
1544
|
+
fetchImpl: options?.fetchImpl,
|
|
1545
|
+
isTty: options?.isTty ?? process.stdout.isTTY
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
function globalOptions(command) {
|
|
1549
|
+
return command.optsWithGlobals();
|
|
1550
|
+
}
|
|
1551
|
+
function wrap(runtime, action) {
|
|
1552
|
+
return async function wrapped() {
|
|
1553
|
+
await action(globalOptions(this));
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
function commandOptionsWithNoOpen(command) {
|
|
1557
|
+
const options = command.opts();
|
|
1558
|
+
return {
|
|
1559
|
+
...globalOptions(command),
|
|
1560
|
+
...options,
|
|
1561
|
+
noOpen: options.open === false
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
function buildProgram(runtime) {
|
|
1565
|
+
const program = new Command();
|
|
1566
|
+
program.name("commentary").description("Create and manage Commentary draft review sessions from local files.").version("0.1.0").showHelpAfterError().exitOverride();
|
|
1567
|
+
program.option("--base-url <url>", "Commentary base URL").option("--token <token>", "Commentary API token").option("--json", "Print JSON output").option("--verbose", "Print verbose diagnostics").option("--quiet", "Suppress non-essential output").option("--no-color", "Disable color output").option("--session-file <path>", "Path to project session metadata");
|
|
1568
|
+
program.command("login").description("Authenticate with Commentary.").option("--token <token>", "Store an existing API token").option("--no-open", "Do not open the browser during device login").action(async function() {
|
|
1569
|
+
await loginCommand(runtime, commandOptionsWithNoOpen(this));
|
|
1570
|
+
});
|
|
1571
|
+
program.command("logout").description("Remove the stored Commentary token for the selected base URL.").action(wrap(runtime, (options) => logoutCommand(runtime, options)));
|
|
1572
|
+
program.command("whoami").description("Validate the configured Commentary token.").action(wrap(runtime, (options) => whoamiCommand(runtime, options)));
|
|
1573
|
+
program.command("review").description("Create a draft review from files or folders.").argument("<paths...>").option("--title <title>", "Review title").option("--description <description>", "Review description").addOption(
|
|
1574
|
+
new Option("--content-type <type>", "Content type").choices(["auto", "markdown", "html", "plain_text"]).default("auto")
|
|
1575
|
+
).option("--watch", "Watch files and sync after creating the review").option("--no-open", "Do not open the browser").option("--root <path>", "Root for relative review paths").option("--include <glob>", "Include glob for directory review", collectOption).option("--exclude <glob>", "Exclude glob for directory review", collectOption).action(async function(paths) {
|
|
1576
|
+
await reviewCommand(runtime, paths, commandOptionsWithNoOpen(this));
|
|
1577
|
+
});
|
|
1578
|
+
const sync = program.command("sync").description("Upload current tracked files as a new revision.").option("--message <summary>", "Revision summary").option("--all", "Upload even when hashes have not changed").option("--dry-run", "Print pending changes without uploading").action(async function() {
|
|
1579
|
+
await syncCommand(runtime, { ...globalOptions(this), ...this.opts() });
|
|
1580
|
+
});
|
|
1581
|
+
program.command("revision").description("Alias for sync.").option("--message <summary>", "Revision summary").option("--all", "Upload even when hashes have not changed").option("--dry-run", "Print pending changes without uploading").action(async function() {
|
|
1582
|
+
await syncCommand(runtime, { ...globalOptions(this), ...this.opts() });
|
|
1583
|
+
});
|
|
1584
|
+
program.command("watch").description("Watch tracked files and sync revisions.").option("--debounce <ms>", "Debounce in milliseconds", "1500").option("--message <summary>", "Revision summary").action(async function() {
|
|
1585
|
+
await watchCommand(runtime, { ...globalOptions(this), ...this.opts() });
|
|
1586
|
+
});
|
|
1587
|
+
program.command("comments").description("List draft review comments.").addOption(
|
|
1588
|
+
new Option("--format <format>", "Output format").choices(["text", "markdown", "json"]).default("text")
|
|
1589
|
+
).option("--open", "Show open comments").option("--resolved", "Show resolved comments").option("--all", "Show all comments").option("--file <path>", "Filter by file path").option("--session <id>", "Explicit session id").action(async function() {
|
|
1590
|
+
await commentsCommand(runtime, { ...globalOptions(this), ...this.opts() });
|
|
1591
|
+
});
|
|
1592
|
+
program.command("wait-comment").description("Wait for the next draft review comment event.").option("--session <id>", "Explicit session id").option("--file <path>", "Filter by file path").option("--include-replies", "Also return reply.created events").option("--cursor <id>", "Resume after a specific live event cursor").addOption(
|
|
1593
|
+
new Option("--from <position>", "Where to start when no cursor is provided").choices(["beginning", "latest"]).default("latest")
|
|
1594
|
+
).option("--timeout <duration>", "Maximum wait time, e.g. 30m, 10s, or 0", "30m").addOption(
|
|
1595
|
+
new Option("--format <format>", "Output format").choices(["text", "markdown", "json"]).default("markdown")
|
|
1596
|
+
).action(async function() {
|
|
1597
|
+
await waitCommentCommand(runtime, { ...globalOptions(this), ...this.opts() });
|
|
1598
|
+
});
|
|
1599
|
+
program.command("reply").description("Reply to a comment thread.").argument("<comment-id>").argument("<message>").option("--session <id>", "Explicit session id").action(async function(threadId, message) {
|
|
1600
|
+
await replyCommand(runtime, threadId, message, { ...globalOptions(this), ...this.opts() });
|
|
1601
|
+
});
|
|
1602
|
+
program.command("resolve").description("Resolve a comment thread.").argument("<comment-id>").option("--session <id>", "Explicit session id").option("--message <message>", "Reply before resolving").action(async function(threadId) {
|
|
1603
|
+
await resolveCommand(runtime, threadId, { ...globalOptions(this), ...this.opts() });
|
|
1604
|
+
});
|
|
1605
|
+
program.command("pull").description("Download latest reviewed files safely.").option("--dry-run", "Show what would change").option("--yes", "Overwrite changed files").option("--backup", "Create .bak files before overwrite").option("--output <dir>", "Write files to a separate directory").action(async function() {
|
|
1606
|
+
await pullCommand(runtime, { ...globalOptions(this), ...this.opts() });
|
|
1607
|
+
});
|
|
1608
|
+
program.command("open").description("Open the linked review in a browser, or print the URL in headless output.").option("--session <id>", "Explicit session id").action(async function() {
|
|
1609
|
+
await openCommand(runtime, { ...globalOptions(this), ...this.opts() });
|
|
1610
|
+
});
|
|
1611
|
+
program.command("status").description("Show current linked review status.").action(wrap(runtime, (options) => statusCommand(runtime, options)));
|
|
1612
|
+
program.command("sessions").description("List draft review sessions for the authenticated account.").action(wrap(runtime, (options) => sessionsCommand(runtime, options)));
|
|
1613
|
+
program.command("revisions").description("List revisions for the linked or specified draft review.").option("--session <id>", "Explicit session id").action(async function() {
|
|
1614
|
+
await revisionsCommand(runtime, { ...globalOptions(this), ...this.opts() });
|
|
1615
|
+
});
|
|
1616
|
+
sync.alias("upload");
|
|
1617
|
+
program.addHelpText(
|
|
1618
|
+
"after",
|
|
1619
|
+
`
|
|
1620
|
+
Examples:
|
|
1621
|
+
npx ${PACKAGE_NAME} review ./docs/spec.md
|
|
1622
|
+
commentary comments --format markdown --open
|
|
1623
|
+
commentary wait-comment --json
|
|
1624
|
+
`
|
|
1625
|
+
);
|
|
1626
|
+
return program;
|
|
1627
|
+
}
|
|
1628
|
+
async function runCli(argv = process.argv.slice(2), options) {
|
|
1629
|
+
const runtime = runtimeFromOptions(options);
|
|
1630
|
+
const program = buildProgram(runtime);
|
|
1631
|
+
try {
|
|
1632
|
+
await program.parseAsync(argv, { from: "user" });
|
|
1633
|
+
return ExitCode.Ok;
|
|
1634
|
+
} catch (error) {
|
|
1635
|
+
const commanderError = error;
|
|
1636
|
+
if (commanderError.code === "commander.helpDisplayed" || commanderError.code === "commander.version") {
|
|
1637
|
+
return ExitCode.Ok;
|
|
1638
|
+
}
|
|
1639
|
+
if (commanderError.code?.startsWith("commander.")) {
|
|
1640
|
+
runtime.stderr.write(`${commanderError.message ?? "Invalid command."}
|
|
1641
|
+
`);
|
|
1642
|
+
return commanderError.exitCode ?? ExitCode.Usage;
|
|
1643
|
+
}
|
|
1644
|
+
if (error instanceof CliError) {
|
|
1645
|
+
runtime.stderr.write(`${error.message}
|
|
1646
|
+
`);
|
|
1647
|
+
return error.exitCode;
|
|
1648
|
+
}
|
|
1649
|
+
runtime.stderr.write(`${toErrorMessage(error)}
|
|
1650
|
+
`);
|
|
1651
|
+
return ExitCode.General;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// src/index.ts
|
|
1656
|
+
var exitCode = await runCli();
|
|
1657
|
+
if (exitCode !== 0) {
|
|
1658
|
+
process.exitCode = exitCode;
|
|
1659
|
+
}
|
|
1660
|
+
//# sourceMappingURL=index.js.map
|