@diegopetrucci/pi-extensions 0.1.64 → 0.1.66
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/.pi-fleet-tested-version +1 -1
- package/README.md +2 -2
- package/extensions/agent-workflow-audit/.pi-fleet-tested-version +1 -1
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/.pi-fleet-tested-version +1 -1
- package/extensions/annotate-git-diff/README.md +3 -0
- package/extensions/annotate-git-diff/index.ts +49 -5
- package/extensions/annotate-git-diff/package.json +2 -1
- package/extensions/annotate-git-diff/quiet-glimpse.ts +24 -3
- package/extensions/annotate-git-diff/review-server.ts +379 -0
- package/extensions/annotate-git-diff/ui.ts +17 -41
- package/extensions/annotate-last-message/.pi-fleet-tested-version +1 -1
- package/extensions/annotate-last-message/index.ts +28 -2
- package/extensions/annotate-last-message/package.json +1 -1
- package/extensions/annotate-last-message/quiet-glimpse.ts +24 -3
- package/extensions/brrr/.pi-fleet-tested-version +1 -1
- package/extensions/brrr/package.json +1 -1
- package/extensions/claude-fast/.pi-fleet-tested-version +1 -1
- package/extensions/claude-fast/README.md +1 -1
- package/extensions/claude-fast/index.ts +2 -2
- package/extensions/claude-fast/package.json +1 -1
- package/extensions/code-reviewer/.pi-fleet-tested-version +1 -1
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/confirm-destructive/.pi-fleet-tested-version +1 -1
- package/extensions/confirm-destructive/package.json +1 -1
- package/extensions/context-cap/.pi-fleet-tested-version +1 -1
- package/extensions/context-cap/package.json +1 -1
- package/extensions/context-inspector/.pi-fleet-tested-version +1 -1
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/.pi-fleet-tested-version +1 -1
- package/extensions/contrarian/index.ts +79 -33
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dirty-repo-guard/.pi-fleet-tested-version +1 -1
- package/extensions/dirty-repo-guard/package.json +1 -1
- package/extensions/dynamic-context-pruning/.pi-fleet-tested-version +1 -1
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/.pi-fleet-tested-version +1 -1
- package/extensions/fast/README.md +2 -2
- package/extensions/fast/index.ts +2 -3
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/.pi-fleet-tested-version +1 -1
- package/extensions/git-footer/package.json +1 -1
- package/extensions/gnosis/.pi-fleet-tested-version +1 -1
- package/extensions/gnosis/package.json +1 -1
- package/extensions/illustrations-to-explain-things/.pi-fleet-tested-version +1 -1
- package/extensions/illustrations-to-explain-things/package.json +1 -1
- package/extensions/inline-bash/.pi-fleet-tested-version +1 -1
- package/extensions/inline-bash/package.json +1 -1
- package/extensions/librarian/.pi-fleet-tested-version +1 -1
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/.pi-fleet-tested-version +1 -1
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/notify/.pi-fleet-tested-version +1 -1
- package/extensions/notify/package.json +1 -1
- package/extensions/openai-fast/.pi-fleet-tested-version +1 -1
- package/extensions/openai-fast/README.md +2 -2
- package/extensions/openai-fast/index.ts +2 -3
- package/extensions/openai-fast/package.json +2 -2
- package/extensions/oracle/.pi-fleet-tested-version +1 -1
- package/extensions/oracle/index.ts +79 -33
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/.pi-fleet-tested-version +1 -1
- package/extensions/permission-gate/README.md +11 -3
- package/extensions/permission-gate/index.ts +70 -7
- package/extensions/permission-gate/package.json +4 -2
- package/extensions/permission-gate/powershell-safety.ts +1006 -0
- package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
- package/extensions/quiet-tools/package.json +1 -1
- package/extensions/review/.pi-fleet-tested-version +1 -1
- package/extensions/review/package.json +1 -1
- package/extensions/todo/.pi-fleet-tested-version +1 -1
- package/extensions/todo/package.json +1 -1
- package/extensions/triage-comments/.pi-fleet-tested-version +1 -1
- package/extensions/triage-comments/package.json +1 -1
- package/package.json +4 -4
package/.pi-fleet-tested-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.84.4
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ A collection of [pi](https://pi.dev) agent extensions I made. (Btw, [I am also b
|
|
|
39
39
|
|
|
40
40
|
- [`confirm-destructive`](./extensions/confirm-destructive): Confirms before destructive session actions like clear, switch, and fork.
|
|
41
41
|
- [`dirty-repo-guard`](./extensions/dirty-repo-guard): Prompts before new sessions, session switches, or forks when the current git repo has uncommitted changes.
|
|
42
|
-
- [`permission-gate`](./extensions/permission-gate): Prompts for confirmation before dangerous bash commands and protected `write`/`edit` targets like `.git`, `node_modules`, and secret-bearing `.env` files.
|
|
42
|
+
- [`permission-gate`](./extensions/permission-gate): Prompts for confirmation before dangerous bash/PowerShell commands and protected `write`/`edit` targets like `.git`, `node_modules`, and secret-bearing `.env` files.
|
|
43
43
|
|
|
44
44
|
### Quality of life
|
|
45
45
|
|
|
@@ -53,7 +53,7 @@ A collection of [pi](https://pi.dev) agent extensions I made. (Btw, [I am also b
|
|
|
53
53
|
### Deprecated
|
|
54
54
|
|
|
55
55
|
- [`claude-fast`](./extensions/claude-fast): Standalone-only provider-specific alternative that adds `/claude-fast` for supported Anthropic Claude Opus models. [Deprecated, please use [`fast`](https://github.com/diegopetrucci/pi-extensions/blob/main/extensions/fast) instead.]
|
|
56
|
-
- [`openai-fast`](./extensions/openai-fast): Standalone-only provider-specific alternative that adds `/fast` for ChatGPT-auth GPT-5.
|
|
56
|
+
- [`openai-fast`](./extensions/openai-fast): Standalone-only provider-specific alternative that adds `/fast` for ChatGPT-auth GPT-5.5 and GPT-5.6 Codex variants. [Deprecated, please use [`fast`](https://github.com/diegopetrucci/pi-extensions/blob/main/extensions/fast) instead.]
|
|
57
57
|
|
|
58
58
|
## Install
|
|
59
59
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.84.4
|
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.84.4
|
|
@@ -29,6 +29,8 @@ Run `/annotate-git-diff` inside a git repository. The command opens a native rev
|
|
|
29
29
|
|
|
30
30
|
Submitting feedback does not auto-apply code changes. The extension appends a structured prompt to the current editor buffer so you can send that feedback back to the active agent.
|
|
31
31
|
|
|
32
|
+
The review UI does not fetch assets from a CDN. While the window is open, the extension serves its packaged Monaco graph from an ephemeral, tokenized HTTP server bound only to `127.0.0.1`; closing, cancelling, submitting, startup failure, or Pi shutdown stops that server.
|
|
33
|
+
|
|
32
34
|
## Requirements
|
|
33
35
|
|
|
34
36
|
- Run inside a git repository.
|
|
@@ -41,3 +43,4 @@ Submitting feedback does not auto-apply code changes. The extension appends a st
|
|
|
41
43
|
- `Review failed: Not inside a git repository.` → change into a git repo and rerun `/annotate-git-diff`.
|
|
42
44
|
- `No reviewable files found.` → make or fetch reviewable changes, then rerun.
|
|
43
45
|
- `Review failed: Glimpse host not found ...` → the native window runtime is unavailable; reinstall/update the package and rerun from a machine/session that can open native windows.
|
|
46
|
+
- `Review failed: Unable to locate packaged ... runtime` → reinstall/update the package so its declared Monaco and Tailwind dependencies are present, then rerun.
|
|
@@ -6,6 +6,7 @@ import { readSystemClipboard, writeSystemClipboard } from "./clipboard.js";
|
|
|
6
6
|
import { getCommitFiles, getReviewWindowData, isWorkingTreeCommitSha, loadReviewFileContents } from "./git.js";
|
|
7
7
|
import { composeReviewPrompt } from "./prompt.js";
|
|
8
8
|
import { openQuietGlimpse, type QuietGlimpseWindow } from "./quiet-glimpse.js";
|
|
9
|
+
import { startReviewUiServer, type ReviewUiServer } from "./review-server.js";
|
|
9
10
|
import type {
|
|
10
11
|
ReviewCancelPayload,
|
|
11
12
|
ReviewClipboardReadPayload,
|
|
@@ -19,7 +20,6 @@ import type {
|
|
|
19
20
|
ReviewSubmitPayload,
|
|
20
21
|
ReviewWindowMessage,
|
|
21
22
|
} from "./types.js";
|
|
22
|
-
import { buildReviewHtml } from "./ui.js";
|
|
23
23
|
import { createRepoChangeWatcher, type RepoChangeWatcher } from "./watch.js";
|
|
24
24
|
|
|
25
25
|
function hasMessageType(value: unknown, type: ReviewWindowMessage["type"]): boolean {
|
|
@@ -69,7 +69,10 @@ function appendReviewPrompt(ctx: ExtensionCommandContext, prompt: string): void
|
|
|
69
69
|
|
|
70
70
|
export default function (pi: ExtensionAPI) {
|
|
71
71
|
let activeWindow: QuietGlimpseWindow | null = null;
|
|
72
|
+
let activeUiServer: ReviewUiServer | null = null;
|
|
72
73
|
let activeWatcher: RepoChangeWatcher | null = null;
|
|
74
|
+
let reviewOpening = false;
|
|
75
|
+
let reviewAttempt = 0;
|
|
73
76
|
const suppressedWindows = new WeakSet<QuietGlimpseWindow>();
|
|
74
77
|
|
|
75
78
|
function stopActiveWatcher(): void {
|
|
@@ -79,10 +82,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
function closeActiveWindow(options: { suppressResults?: boolean } = {}): void {
|
|
82
|
-
|
|
85
|
+
reviewAttempt += 1;
|
|
83
86
|
const windowToClose = activeWindow;
|
|
84
87
|
activeWindow = null;
|
|
88
|
+
activeUiServer?.dispose();
|
|
89
|
+
activeUiServer = null;
|
|
85
90
|
stopActiveWatcher();
|
|
91
|
+
if (windowToClose == null) return;
|
|
86
92
|
if (options.suppressResults) {
|
|
87
93
|
suppressedWindows.add(windowToClose);
|
|
88
94
|
}
|
|
@@ -99,26 +105,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
99
105
|
return;
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
if (activeWindow != null) {
|
|
108
|
+
if (reviewOpening || activeWindow != null || activeUiServer != null) {
|
|
103
109
|
ctx.ui.notify("A review window is already open.", "warning");
|
|
104
110
|
return;
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
reviewOpening = true;
|
|
114
|
+
const attempt = ++reviewAttempt;
|
|
107
115
|
try {
|
|
108
116
|
let reviewData = await getReviewWindowData(pi, ctx.cwd);
|
|
117
|
+
if (attempt !== reviewAttempt) return;
|
|
109
118
|
const { repoRoot } = reviewData;
|
|
110
119
|
if (reviewData.files.length === 0 && reviewData.commits.length === 0) {
|
|
111
120
|
ctx.ui.notify("No reviewable files found.", "info");
|
|
112
121
|
return;
|
|
113
122
|
}
|
|
114
123
|
|
|
115
|
-
const
|
|
116
|
-
|
|
124
|
+
const uiServer = await startReviewUiServer(reviewData);
|
|
125
|
+
if (attempt !== reviewAttempt) {
|
|
126
|
+
uiServer.dispose();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
activeUiServer = uiServer;
|
|
130
|
+
const window = await openQuietGlimpse(uiServer.html, {
|
|
117
131
|
width: 1680,
|
|
118
132
|
height: 1020,
|
|
119
133
|
title: "annotate-git-diff",
|
|
120
134
|
});
|
|
135
|
+
if (attempt !== reviewAttempt) {
|
|
136
|
+
suppressedWindows.add(window);
|
|
137
|
+
window.close();
|
|
138
|
+
uiServer.dispose();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
121
141
|
activeWindow = window;
|
|
142
|
+
if (uiServer.failure != null) throw uiServer.failure;
|
|
143
|
+
if (window.failure != null) throw window.failure;
|
|
144
|
+
if (window.closed) throw new Error("Glimpse closed while the review window was starting.");
|
|
122
145
|
|
|
123
146
|
const fileMap = new Map(reviewData.files.map((file) => [file.id, file]));
|
|
124
147
|
const commitFileCache = new Map<string, Promise<ReviewFile[]>>();
|
|
@@ -185,6 +208,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
208
|
(resolve, reject) => {
|
|
186
209
|
let settled = false;
|
|
187
210
|
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
211
|
+
let removeUiServerErrorListener = (): void => {};
|
|
212
|
+
|
|
213
|
+
const requestWindowClose = (): void => {
|
|
214
|
+
try {
|
|
215
|
+
window.close();
|
|
216
|
+
} catch {
|
|
217
|
+
// Ignore races when the native process has already exited.
|
|
218
|
+
}
|
|
219
|
+
};
|
|
188
220
|
|
|
189
221
|
const cleanup = (): void => {
|
|
190
222
|
if (closeTimer != null) {
|
|
@@ -194,10 +226,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
194
226
|
window.removeListener("message", onMessage);
|
|
195
227
|
window.removeListener("closed", onClosed);
|
|
196
228
|
window.removeListener("error", onError);
|
|
229
|
+
removeUiServerErrorListener();
|
|
230
|
+
removeUiServerErrorListener = (): void => {};
|
|
197
231
|
if (activeWindow === window) {
|
|
198
232
|
activeWindow = null;
|
|
199
233
|
stopActiveWatcher();
|
|
200
234
|
}
|
|
235
|
+
if (activeUiServer === uiServer) activeUiServer = null;
|
|
236
|
+
uiServer.dispose();
|
|
201
237
|
};
|
|
202
238
|
|
|
203
239
|
const settle = (value: ReviewSubmitPayload | ReviewCancelPayload | null): void => {
|
|
@@ -345,6 +381,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
345
381
|
return;
|
|
346
382
|
}
|
|
347
383
|
if (isSubmitPayload(message) || isCancelPayload(message)) {
|
|
384
|
+
requestWindowClose();
|
|
348
385
|
settle(message);
|
|
349
386
|
}
|
|
350
387
|
};
|
|
@@ -360,6 +397,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
360
397
|
const onError = (error: Error): void => {
|
|
361
398
|
if (settled) return;
|
|
362
399
|
settled = true;
|
|
400
|
+
requestWindowClose();
|
|
363
401
|
cleanup();
|
|
364
402
|
reject(error);
|
|
365
403
|
};
|
|
@@ -367,6 +405,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
367
405
|
window.on("message", onMessage);
|
|
368
406
|
window.on("closed", onClosed);
|
|
369
407
|
window.on("error", onError);
|
|
408
|
+
removeUiServerErrorListener = uiServer.onError(onError);
|
|
409
|
+
if (uiServer.failure != null) onError(uiServer.failure);
|
|
410
|
+
else if (window.failure != null) onError(window.failure);
|
|
411
|
+
else if (window.closed) onClosed();
|
|
370
412
|
},
|
|
371
413
|
);
|
|
372
414
|
|
|
@@ -396,6 +438,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
396
438
|
closeActiveWindow({ suppressResults: true });
|
|
397
439
|
const message = error instanceof Error ? error.message : String(error);
|
|
398
440
|
ctx.ui.notify(`Review failed: ${message}`, "error");
|
|
441
|
+
} finally {
|
|
442
|
+
reviewOpening = false;
|
|
399
443
|
}
|
|
400
444
|
}
|
|
401
445
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-annotate-git-diff",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "A standalone pi extension that adds /annotate-git-diff, a native Glimpse UI for annotating git diffs and sending feedback to the editor.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"git.ts",
|
|
23
23
|
"prompt.ts",
|
|
24
24
|
"quiet-glimpse.ts",
|
|
25
|
+
"review-server.ts",
|
|
25
26
|
"types.ts",
|
|
26
27
|
"ui.ts",
|
|
27
28
|
"watch.ts",
|
|
@@ -23,6 +23,8 @@ interface GlimpseProtocolMessage {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export interface QuietGlimpseWindow {
|
|
26
|
+
readonly closed: boolean;
|
|
27
|
+
readonly failure: Error | null;
|
|
26
28
|
on(event: "message", listener: (data: unknown) => void): this;
|
|
27
29
|
on(event: "closed", listener: () => void): this;
|
|
28
30
|
on(event: "error", listener: (error: Error) => void): this;
|
|
@@ -38,11 +40,15 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
|
|
|
38
40
|
#closed = false;
|
|
39
41
|
#pendingHTML: string | null;
|
|
40
42
|
#stderr = "";
|
|
43
|
+
#failure: Error | null = null;
|
|
41
44
|
|
|
42
45
|
constructor(proc: ChildProcessWithoutNullStreams, initialHTML: string) {
|
|
43
46
|
super();
|
|
44
47
|
this.#proc = proc;
|
|
45
48
|
this.#pendingHTML = initialHTML;
|
|
49
|
+
// EventEmitter treats an unobserved `error` as fatal. Keep a permanent
|
|
50
|
+
// fallback while callers attach and detach their lifecycle listeners.
|
|
51
|
+
this.on("error", () => {});
|
|
46
52
|
|
|
47
53
|
proc.stdin.on("error", () => {});
|
|
48
54
|
proc.stderr.on("data", (chunk) => {
|
|
@@ -55,7 +61,7 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
|
|
|
55
61
|
try {
|
|
56
62
|
message = JSON.parse(line) as GlimpseProtocolMessage;
|
|
57
63
|
} catch {
|
|
58
|
-
this
|
|
64
|
+
this.#reportError(new Error(`Malformed glimpse protocol line: ${line}`));
|
|
59
65
|
return;
|
|
60
66
|
}
|
|
61
67
|
|
|
@@ -77,16 +83,18 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
|
|
|
77
83
|
}
|
|
78
84
|
});
|
|
79
85
|
|
|
80
|
-
proc.on("error", (error) => this
|
|
86
|
+
proc.on("error", (error) => this.#reportError(error));
|
|
81
87
|
proc.on("exit", (code, signal) => {
|
|
82
88
|
const stderr = this.#stderr.trim();
|
|
83
89
|
const failed = signal != null || (code != null && code !== 0);
|
|
84
90
|
if (!this.#closed && failed) {
|
|
85
91
|
const message = stderr || `Glimpse process exited abnormally (code: ${code}, signal: ${signal}).`;
|
|
86
|
-
this
|
|
92
|
+
this.#reportError(new Error(message));
|
|
87
93
|
}
|
|
88
94
|
this.#markClosed();
|
|
89
95
|
});
|
|
96
|
+
// Spawn failures can emit `error` and then `close` without `exit`.
|
|
97
|
+
proc.on("close", () => this.#markClosed());
|
|
90
98
|
}
|
|
91
99
|
|
|
92
100
|
send(js: string): void {
|
|
@@ -97,6 +105,19 @@ class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow
|
|
|
97
105
|
this.#write({ type: "close" });
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
get closed(): boolean {
|
|
109
|
+
return this.#closed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get failure(): Error | null {
|
|
113
|
+
return this.#failure;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#reportError(error: Error): void {
|
|
117
|
+
this.#failure ??= error;
|
|
118
|
+
this.emit("error", error);
|
|
119
|
+
}
|
|
120
|
+
|
|
100
121
|
#markClosed(): void {
|
|
101
122
|
if (this.#closed) return;
|
|
102
123
|
this.#closed = true;
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { createReadStream, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
3
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { buildReviewHtml } from "./ui.js";
|
|
7
|
+
import type { ReviewWindowData } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const loopbackHost = "127.0.0.1";
|
|
11
|
+
|
|
12
|
+
export interface ReviewUiServer {
|
|
13
|
+
readonly failure: Error | null;
|
|
14
|
+
html: string;
|
|
15
|
+
url: string;
|
|
16
|
+
onError(listener: (error: Error) => void): () => void;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReviewUiAssetPaths {
|
|
21
|
+
tailwindBrowserPath: string;
|
|
22
|
+
monacoLoaderPath: string;
|
|
23
|
+
monacoVsDirectory: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface StartReviewUiServerOptions {
|
|
27
|
+
extensionDir?: string;
|
|
28
|
+
token?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parentDirectories(startPath: string): string[] {
|
|
32
|
+
const directories: string[] = [];
|
|
33
|
+
let current = resolve(startPath);
|
|
34
|
+
|
|
35
|
+
while (true) {
|
|
36
|
+
directories.push(current);
|
|
37
|
+
const parent = dirname(current);
|
|
38
|
+
if (parent === current) return directories;
|
|
39
|
+
current = parent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findInstalledAsset(extensionDir: string, dependencyPath: string, label: string, expected: "file" | "directory"): string {
|
|
44
|
+
const searched: string[] = [];
|
|
45
|
+
const starts = [extensionDir];
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const realExtensionDir = realpathSync(extensionDir);
|
|
49
|
+
if (realExtensionDir !== resolve(extensionDir)) starts.push(realExtensionDir);
|
|
50
|
+
} catch {
|
|
51
|
+
// The direct path below will produce the useful not-found diagnostic.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const start of starts) {
|
|
55
|
+
for (const ancestor of parentDirectories(start)) {
|
|
56
|
+
const candidate = join(ancestor, "node_modules", dependencyPath);
|
|
57
|
+
if (searched.includes(candidate)) continue;
|
|
58
|
+
searched.push(candidate);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const stats = statSync(candidate);
|
|
62
|
+
const matches = expected === "file" ? stats.isFile() : stats.isDirectory();
|
|
63
|
+
if (matches) return realpathSync(candidate);
|
|
64
|
+
} catch {
|
|
65
|
+
// Keep walking toward the package-manager installation root.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw new Error(`Unable to locate packaged ${label}. Searched: ${searched.join(", ")}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readDependencyVersion(packageRoot: string, packageName: string): string {
|
|
74
|
+
try {
|
|
75
|
+
const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
|
|
76
|
+
name?: unknown;
|
|
77
|
+
version?: unknown;
|
|
78
|
+
};
|
|
79
|
+
if (manifest.name !== packageName || typeof manifest.version !== "string") throw new Error("invalid manifest");
|
|
80
|
+
return manifest.version;
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(`Unable to validate packaged ${packageName} runtime at ${packageRoot}.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isCompatibleTailwindBrowserVersion(version: string): boolean {
|
|
87
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
88
|
+
if (match == null) return false;
|
|
89
|
+
const [, major, minor, patch] = match.map(Number);
|
|
90
|
+
return major === 4 && (minor > 3 || (minor === 3 && patch >= 2));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function resolveTailwindBrowserPath(extensionDir = moduleDir): string {
|
|
94
|
+
const browserPath = findInstalledAsset(
|
|
95
|
+
extensionDir,
|
|
96
|
+
join("@tailwindcss", "browser", "dist", "index.global.js"),
|
|
97
|
+
"@tailwindcss/browser runtime",
|
|
98
|
+
"file",
|
|
99
|
+
);
|
|
100
|
+
const version = readDependencyVersion(dirname(dirname(browserPath)), "@tailwindcss/browser");
|
|
101
|
+
if (!isCompatibleTailwindBrowserVersion(version)) {
|
|
102
|
+
throw new Error(`Incompatible packaged @tailwindcss/browser runtime ${version}; expected ^4.3.2.`);
|
|
103
|
+
}
|
|
104
|
+
return browserPath;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function resolveMonacoVsDirectory(extensionDir = moduleDir): string {
|
|
108
|
+
const vsDirectory = findInstalledAsset(extensionDir, join("monaco-editor", "min", "vs"), "monaco-editor runtime", "directory");
|
|
109
|
+
const version = readDependencyVersion(dirname(dirname(vsDirectory)), "monaco-editor");
|
|
110
|
+
if (version !== "0.52.2") {
|
|
111
|
+
throw new Error(`Incompatible packaged monaco-editor runtime ${version}; expected 0.52.2.`);
|
|
112
|
+
}
|
|
113
|
+
return vsDirectory;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function resolveReviewUiAssetPaths(extensionDir = moduleDir): ReviewUiAssetPaths {
|
|
117
|
+
const tailwindBrowserPath = resolveTailwindBrowserPath(extensionDir);
|
|
118
|
+
const monacoVsDirectory = resolveMonacoVsDirectory(extensionDir);
|
|
119
|
+
const monacoLoaderPath = join(monacoVsDirectory, "loader.js");
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
if (!statSync(monacoLoaderPath).isFile()) throw new Error("not a file");
|
|
123
|
+
} catch {
|
|
124
|
+
throw new Error(`Unable to locate packaged Monaco loader: ${monacoLoaderPath}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
tailwindBrowserPath,
|
|
129
|
+
monacoLoaderPath: realpathSync(monacoLoaderPath),
|
|
130
|
+
monacoVsDirectory,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function contentType(filePath: string): string {
|
|
135
|
+
switch (extname(filePath).toLowerCase()) {
|
|
136
|
+
case ".css":
|
|
137
|
+
return "text/css; charset=utf-8";
|
|
138
|
+
case ".html":
|
|
139
|
+
return "text/html; charset=utf-8";
|
|
140
|
+
case ".js":
|
|
141
|
+
case ".mjs":
|
|
142
|
+
return "text/javascript; charset=utf-8";
|
|
143
|
+
case ".json":
|
|
144
|
+
return "application/json; charset=utf-8";
|
|
145
|
+
case ".svg":
|
|
146
|
+
return "image/svg+xml";
|
|
147
|
+
case ".ttf":
|
|
148
|
+
return "font/ttf";
|
|
149
|
+
case ".wasm":
|
|
150
|
+
return "application/wasm";
|
|
151
|
+
default:
|
|
152
|
+
return "application/octet-stream";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setSecurityHeaders(response: ServerResponse): void {
|
|
157
|
+
response.setHeader("Cache-Control", "no-store");
|
|
158
|
+
response.setHeader(
|
|
159
|
+
"Content-Security-Policy",
|
|
160
|
+
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; worker-src 'self' blob:; connect-src 'self'",
|
|
161
|
+
);
|
|
162
|
+
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
163
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
164
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sendText(response: ServerResponse, statusCode: number, body: string): void {
|
|
168
|
+
response.statusCode = statusCode;
|
|
169
|
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
170
|
+
response.setHeader("Content-Length", Buffer.byteLength(body));
|
|
171
|
+
response.end(body);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isContainedPath(rootPath: string, candidatePath: string): boolean {
|
|
175
|
+
const relativePath = relative(rootPath, candidatePath);
|
|
176
|
+
return relativePath.length > 0 && !relativePath.startsWith(`..${sep}`) && relativePath !== ".." && !isAbsolute(relativePath);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function decodeAssetPath(encodedPath: string): string | null {
|
|
180
|
+
try {
|
|
181
|
+
const decoded = decodeURIComponent(encodedPath);
|
|
182
|
+
if (!decoded || decoded.includes("\0") || decoded.includes("\\")) return null;
|
|
183
|
+
const segments = decoded.split("/");
|
|
184
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return null;
|
|
185
|
+
return segments.join(sep);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function serveMonacoAsset(
|
|
192
|
+
request: IncomingMessage,
|
|
193
|
+
response: ServerResponse,
|
|
194
|
+
monacoVsDirectory: string,
|
|
195
|
+
encodedPath: string,
|
|
196
|
+
): void {
|
|
197
|
+
const relativePath = decodeAssetPath(encodedPath);
|
|
198
|
+
if (relativePath == null) {
|
|
199
|
+
sendText(response, 404, "Not found");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let assetPath: string;
|
|
204
|
+
let assetSize: number;
|
|
205
|
+
try {
|
|
206
|
+
const candidate = resolve(monacoVsDirectory, relativePath);
|
|
207
|
+
if (!isContainedPath(monacoVsDirectory, candidate)) throw new Error("outside asset root");
|
|
208
|
+
assetPath = realpathSync(candidate);
|
|
209
|
+
const stats = statSync(assetPath);
|
|
210
|
+
if (!isContainedPath(monacoVsDirectory, assetPath) || !stats.isFile()) throw new Error("not a file");
|
|
211
|
+
assetSize = stats.size;
|
|
212
|
+
} catch {
|
|
213
|
+
sendText(response, 404, "Not found");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
response.statusCode = 200;
|
|
218
|
+
response.setHeader("Content-Type", contentType(assetPath));
|
|
219
|
+
response.setHeader("Content-Length", assetSize);
|
|
220
|
+
if (request.method === "HEAD") {
|
|
221
|
+
response.end();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const stream = createReadStream(assetPath);
|
|
226
|
+
stream.on("error", () => {
|
|
227
|
+
if (!response.headersSent) sendText(response, 500, "Unable to read asset");
|
|
228
|
+
else response.destroy();
|
|
229
|
+
});
|
|
230
|
+
response.on("close", () => stream.destroy());
|
|
231
|
+
stream.pipe(response);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function redirectHtml(url: string): string {
|
|
235
|
+
const safeUrl = JSON.stringify(url).replace(/</g, "\\u003c");
|
|
236
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>annotate-git-diff</title></head><body><p>Loading review…</p><script>location.replace(${safeUrl});</script></body></html>`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function disposeServer(server: Server): void {
|
|
240
|
+
server.close();
|
|
241
|
+
server.closeAllConnections();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function startReviewUiServer(
|
|
245
|
+
data: ReviewWindowData,
|
|
246
|
+
options: StartReviewUiServerOptions = {},
|
|
247
|
+
): Promise<ReviewUiServer> {
|
|
248
|
+
const assetPaths = resolveReviewUiAssetPaths(options.extensionDir ?? moduleDir);
|
|
249
|
+
const token = options.token ?? randomBytes(24).toString("hex");
|
|
250
|
+
if (!/^[A-Za-z0-9_-]{24,128}$/.test(token)) throw new Error("Invalid review UI server token.");
|
|
251
|
+
|
|
252
|
+
const routeRoot = `/${token}/`;
|
|
253
|
+
const monacoRoutePrefix = `${routeRoot}vs/`;
|
|
254
|
+
let expectedHost = "";
|
|
255
|
+
let reviewHtml = "";
|
|
256
|
+
|
|
257
|
+
const server = createServer((request, response) => {
|
|
258
|
+
setSecurityHeaders(response);
|
|
259
|
+
if (request.headers.host !== expectedHost) {
|
|
260
|
+
sendText(response, 403, "Forbidden");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
264
|
+
response.setHeader("Allow", "GET, HEAD");
|
|
265
|
+
sendText(response, 405, "Method not allowed");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let pathname: string;
|
|
270
|
+
try {
|
|
271
|
+
pathname = new URL(request.url ?? "/", `http://${expectedHost}`).pathname;
|
|
272
|
+
} catch {
|
|
273
|
+
sendText(response, 400, "Bad request");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (pathname === routeRoot || pathname === `${routeRoot}index.html`) {
|
|
278
|
+
response.statusCode = 200;
|
|
279
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
280
|
+
response.setHeader("Content-Length", Buffer.byteLength(reviewHtml));
|
|
281
|
+
response.end(request.method === "HEAD" ? undefined : reviewHtml);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (pathname.startsWith(monacoRoutePrefix)) {
|
|
286
|
+
serveMonacoAsset(request, response, assetPaths.monacoVsDirectory, pathname.slice(monacoRoutePrefix.length));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
sendText(response, 404, "Not found");
|
|
291
|
+
});
|
|
292
|
+
// Keep Node's special EventEmitter `error` event non-fatal across disposal
|
|
293
|
+
// races; the scoped listener below still records and reports live failures.
|
|
294
|
+
server.on("error", () => {});
|
|
295
|
+
server.maxHeadersCount = 50;
|
|
296
|
+
server.headersTimeout = 5_000;
|
|
297
|
+
server.keepAliveTimeout = 1_000;
|
|
298
|
+
server.requestTimeout = 15_000;
|
|
299
|
+
server.on("clientError", (_error, socket) => socket.destroy());
|
|
300
|
+
const errorListeners = new Set<(error: Error) => void>();
|
|
301
|
+
let disposed = false;
|
|
302
|
+
let runtimeFailure: Error | null = null;
|
|
303
|
+
const onRuntimeServerError = (error: Error): void => {
|
|
304
|
+
if (disposed || runtimeFailure != null) return;
|
|
305
|
+
runtimeFailure = error;
|
|
306
|
+
disposeServer(server);
|
|
307
|
+
for (const listener of [...errorListeners]) {
|
|
308
|
+
try {
|
|
309
|
+
listener(error);
|
|
310
|
+
} catch {
|
|
311
|
+
// A lifecycle observer must not turn a server failure into an
|
|
312
|
+
// uncaught process-level exception.
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
await new Promise<void>((resolveListen, rejectListen) => {
|
|
319
|
+
const onError = (error: Error): void => {
|
|
320
|
+
server.off("listening", onListening);
|
|
321
|
+
rejectListen(error);
|
|
322
|
+
};
|
|
323
|
+
const onListening = (): void => {
|
|
324
|
+
server.off("error", onError);
|
|
325
|
+
resolveListen();
|
|
326
|
+
};
|
|
327
|
+
server.once("error", onError);
|
|
328
|
+
server.once("listening", onListening);
|
|
329
|
+
server.listen({ host: loopbackHost, port: 0, exclusive: true });
|
|
330
|
+
});
|
|
331
|
+
server.on("error", onRuntimeServerError);
|
|
332
|
+
|
|
333
|
+
const address = server.address();
|
|
334
|
+
if (address == null || typeof address === "string") throw new Error("Review UI server did not expose a TCP address.");
|
|
335
|
+
expectedHost = `${loopbackHost}:${address.port}`;
|
|
336
|
+
const url = `http://${expectedHost}${routeRoot}`;
|
|
337
|
+
reviewHtml = buildReviewHtml(data, {
|
|
338
|
+
tailwindBrowserJs: readFileSync(assetPaths.tailwindBrowserPath, "utf8"),
|
|
339
|
+
monacoLoaderJs: readFileSync(assetPaths.monacoLoaderPath, "utf8"),
|
|
340
|
+
monacoVsBaseUrl: `${url}vs`,
|
|
341
|
+
});
|
|
342
|
+
server.unref();
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
get failure() {
|
|
346
|
+
return runtimeFailure;
|
|
347
|
+
},
|
|
348
|
+
html: redirectHtml(url),
|
|
349
|
+
url,
|
|
350
|
+
onError(listener) {
|
|
351
|
+
if (runtimeFailure != null) {
|
|
352
|
+
let active = true;
|
|
353
|
+
queueMicrotask(() => {
|
|
354
|
+
if (active) listener(runtimeFailure as Error);
|
|
355
|
+
});
|
|
356
|
+
return () => {
|
|
357
|
+
active = false;
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (disposed) return () => {};
|
|
361
|
+
errorListeners.add(listener);
|
|
362
|
+
return () => errorListeners.delete(listener);
|
|
363
|
+
},
|
|
364
|
+
dispose() {
|
|
365
|
+
if (disposed) return;
|
|
366
|
+
disposed = true;
|
|
367
|
+
errorListeners.clear();
|
|
368
|
+
server.off("error", onRuntimeServerError);
|
|
369
|
+
disposeServer(server);
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
} catch (error) {
|
|
373
|
+
disposed = true;
|
|
374
|
+
errorListeners.clear();
|
|
375
|
+
server.off("error", onRuntimeServerError);
|
|
376
|
+
disposeServer(server);
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|