@plannotator/pi-extension 0.8.3

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/server.ts ADDED
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Node-compatible servers for Plannotator Pi extension.
3
+ *
4
+ * Pi loads extensions via jiti (Node.js), so we can't use Bun.serve().
5
+ * These are lightweight node:http servers implementing just the routes
6
+ * each UI needs — plan review, code review, and markdown annotation.
7
+ */
8
+
9
+ import { createServer, type IncomingMessage, type Server } from "node:http";
10
+ import { execSync } from "node:child_process";
11
+ import os from "node:os";
12
+
13
+ // ── Helpers ──────────────────────────────────────────────────────────────
14
+
15
+ function parseBody(req: IncomingMessage): Promise<Record<string, unknown>> {
16
+ return new Promise((resolve) => {
17
+ let data = "";
18
+ req.on("data", (chunk: string) => (data += chunk));
19
+ req.on("end", () => {
20
+ try {
21
+ resolve(JSON.parse(data));
22
+ } catch {
23
+ resolve({});
24
+ }
25
+ });
26
+ });
27
+ }
28
+
29
+ function json(res: import("node:http").ServerResponse, data: unknown, status = 200): void {
30
+ res.writeHead(status, { "Content-Type": "application/json" });
31
+ res.end(JSON.stringify(data));
32
+ }
33
+
34
+ function html(res: import("node:http").ServerResponse, content: string): void {
35
+ res.writeHead(200, { "Content-Type": "text/html" });
36
+ res.end(content);
37
+ }
38
+
39
+ function listenOnRandomPort(server: Server): number {
40
+ server.listen(0);
41
+ const addr = server.address() as { port: number };
42
+ return addr.port;
43
+ }
44
+
45
+ /**
46
+ * Open URL in system browser (Node-compatible, no Bun $ dependency).
47
+ * Honors PLANNOTATOR_BROWSER and BROWSER env vars, matching packages/server/browser.ts.
48
+ */
49
+ export function openBrowser(url: string): void {
50
+ try {
51
+ const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER;
52
+ const platform = process.platform;
53
+ const wsl = platform === "linux" && os.release().toLowerCase().includes("microsoft");
54
+
55
+ if (browser) {
56
+ if (process.env.PLANNOTATOR_BROWSER && platform === "darwin") {
57
+ execSync(`open -a ${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: "ignore" });
58
+ } else if (platform === "win32" || wsl) {
59
+ execSync(`cmd.exe /c start "" ${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: "ignore" });
60
+ } else {
61
+ execSync(`${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: "ignore" });
62
+ }
63
+ } else if (platform === "win32" || wsl) {
64
+ execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, { stdio: "ignore" });
65
+ } else if (platform === "darwin") {
66
+ execSync(`open ${JSON.stringify(url)}`, { stdio: "ignore" });
67
+ } else {
68
+ execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: "ignore" });
69
+ }
70
+ } catch {
71
+ // Silently fail
72
+ }
73
+ }
74
+
75
+ // ── Plan Review Server ──────────────────────────────────────────────────
76
+
77
+ export interface PlanServerResult {
78
+ port: number;
79
+ url: string;
80
+ waitForDecision: () => Promise<{ approved: boolean; feedback?: string }>;
81
+ stop: () => void;
82
+ }
83
+
84
+ export function startPlanReviewServer(options: {
85
+ plan: string;
86
+ htmlContent: string;
87
+ origin?: string;
88
+ }): PlanServerResult {
89
+ let resolveDecision!: (result: { approved: boolean; feedback?: string }) => void;
90
+ const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>((r) => {
91
+ resolveDecision = r;
92
+ });
93
+
94
+ const server = createServer(async (req, res) => {
95
+ const url = new URL(req.url!, `http://localhost`);
96
+
97
+ if (url.pathname === "/api/plan") {
98
+ json(res, { plan: options.plan, origin: options.origin ?? "pi" });
99
+ } else if (url.pathname === "/api/approve" && req.method === "POST") {
100
+ const body = await parseBody(req);
101
+ resolveDecision({ approved: true, feedback: body.feedback as string | undefined });
102
+ json(res, { ok: true });
103
+ } else if (url.pathname === "/api/deny" && req.method === "POST") {
104
+ const body = await parseBody(req);
105
+ resolveDecision({ approved: false, feedback: (body.feedback as string) || "Plan rejected" });
106
+ json(res, { ok: true });
107
+ } else {
108
+ html(res, options.htmlContent);
109
+ }
110
+ });
111
+
112
+ const port = listenOnRandomPort(server);
113
+
114
+ return {
115
+ port,
116
+ url: `http://localhost:${port}`,
117
+ waitForDecision: () => decisionPromise,
118
+ stop: () => server.close(),
119
+ };
120
+ }
121
+
122
+ // ── Code Review Server ──────────────────────────────────────────────────
123
+
124
+ export type DiffType = "uncommitted" | "staged" | "unstaged" | "last-commit" | "branch";
125
+
126
+ export interface DiffOption {
127
+ id: DiffType | "separator";
128
+ label: string;
129
+ }
130
+
131
+ export interface GitContext {
132
+ currentBranch: string;
133
+ defaultBranch: string;
134
+ diffOptions: DiffOption[];
135
+ }
136
+
137
+ export interface ReviewServerResult {
138
+ port: number;
139
+ url: string;
140
+ waitForDecision: () => Promise<{ feedback: string }>;
141
+ stop: () => void;
142
+ }
143
+
144
+ /** Run a git command and return stdout (empty string on error). */
145
+ function git(cmd: string): string {
146
+ try {
147
+ return execSync(`git ${cmd}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
148
+ } catch {
149
+ return "";
150
+ }
151
+ }
152
+
153
+ export function getGitContext(): GitContext {
154
+ const currentBranch = git("rev-parse --abbrev-ref HEAD") || "HEAD";
155
+
156
+ let defaultBranch = "";
157
+ const symRef = git("symbolic-ref refs/remotes/origin/HEAD");
158
+ if (symRef) {
159
+ defaultBranch = symRef.replace("refs/remotes/origin/", "");
160
+ }
161
+ if (!defaultBranch) {
162
+ const hasMain = git("show-ref --verify refs/heads/main");
163
+ defaultBranch = hasMain ? "main" : "master";
164
+ }
165
+
166
+ const diffOptions: DiffOption[] = [
167
+ { id: "uncommitted", label: "Uncommitted changes" },
168
+ { id: "last-commit", label: "Last commit" },
169
+ ];
170
+ if (currentBranch !== defaultBranch) {
171
+ diffOptions.push({ id: "branch", label: `vs ${defaultBranch}` });
172
+ }
173
+
174
+ return { currentBranch, defaultBranch, diffOptions };
175
+ }
176
+
177
+ export function runGitDiff(diffType: DiffType, defaultBranch = "main"): { patch: string; label: string } {
178
+ switch (diffType) {
179
+ case "uncommitted":
180
+ return { patch: git("diff HEAD"), label: "Uncommitted changes" };
181
+ case "staged":
182
+ return { patch: git("diff --staged"), label: "Staged changes" };
183
+ case "unstaged":
184
+ return { patch: git("diff"), label: "Unstaged changes" };
185
+ case "last-commit":
186
+ return { patch: git("diff HEAD~1..HEAD"), label: "Last commit" };
187
+ case "branch":
188
+ return { patch: git(`diff ${defaultBranch}..HEAD`), label: `Changes vs ${defaultBranch}` };
189
+ default:
190
+ return { patch: "", label: "Unknown diff type" };
191
+ }
192
+ }
193
+
194
+ export function startReviewServer(options: {
195
+ rawPatch: string;
196
+ gitRef: string;
197
+ htmlContent: string;
198
+ origin?: string;
199
+ diffType?: DiffType;
200
+ gitContext?: GitContext;
201
+ }): ReviewServerResult {
202
+ let currentPatch = options.rawPatch;
203
+ let currentGitRef = options.gitRef;
204
+ let currentDiffType: DiffType = options.diffType || "uncommitted";
205
+
206
+ let resolveDecision!: (result: { feedback: string }) => void;
207
+ const decisionPromise = new Promise<{ feedback: string }>((r) => {
208
+ resolveDecision = r;
209
+ });
210
+
211
+ const server = createServer(async (req, res) => {
212
+ const url = new URL(req.url!, `http://localhost`);
213
+
214
+ if (url.pathname === "/api/diff" && req.method === "GET") {
215
+ json(res, {
216
+ rawPatch: currentPatch,
217
+ gitRef: currentGitRef,
218
+ origin: options.origin ?? "pi",
219
+ diffType: currentDiffType,
220
+ gitContext: options.gitContext,
221
+ });
222
+ } else if (url.pathname === "/api/diff/switch" && req.method === "POST") {
223
+ const body = await parseBody(req);
224
+ const newType = body.diffType as DiffType;
225
+ if (!newType) {
226
+ json(res, { error: "Missing diffType" }, 400);
227
+ return;
228
+ }
229
+ const defaultBranch = options.gitContext?.defaultBranch || "main";
230
+ const result = runGitDiff(newType, defaultBranch);
231
+ currentPatch = result.patch;
232
+ currentGitRef = result.label;
233
+ currentDiffType = newType;
234
+ json(res, { rawPatch: currentPatch, gitRef: currentGitRef, diffType: currentDiffType });
235
+ } else if (url.pathname === "/api/feedback" && req.method === "POST") {
236
+ const body = await parseBody(req);
237
+ resolveDecision({ feedback: (body.feedback as string) || "" });
238
+ json(res, { ok: true });
239
+ } else {
240
+ html(res, options.htmlContent);
241
+ }
242
+ });
243
+
244
+ const port = listenOnRandomPort(server);
245
+
246
+ return {
247
+ port,
248
+ url: `http://localhost:${port}`,
249
+ waitForDecision: () => decisionPromise,
250
+ stop: () => server.close(),
251
+ };
252
+ }
253
+
254
+ // ── Annotate Server ─────────────────────────────────────────────────────
255
+
256
+ export interface AnnotateServerResult {
257
+ port: number;
258
+ url: string;
259
+ waitForDecision: () => Promise<{ feedback: string }>;
260
+ stop: () => void;
261
+ }
262
+
263
+ export function startAnnotateServer(options: {
264
+ markdown: string;
265
+ filePath: string;
266
+ htmlContent: string;
267
+ origin?: string;
268
+ }): AnnotateServerResult {
269
+ let resolveDecision!: (result: { feedback: string }) => void;
270
+ const decisionPromise = new Promise<{ feedback: string }>((r) => {
271
+ resolveDecision = r;
272
+ });
273
+
274
+ const server = createServer(async (req, res) => {
275
+ const url = new URL(req.url!, `http://localhost`);
276
+
277
+ if (url.pathname === "/api/plan" && req.method === "GET") {
278
+ json(res, {
279
+ plan: options.markdown,
280
+ origin: options.origin ?? "pi",
281
+ mode: "annotate",
282
+ filePath: options.filePath,
283
+ });
284
+ } else if (url.pathname === "/api/feedback" && req.method === "POST") {
285
+ const body = await parseBody(req);
286
+ resolveDecision({ feedback: (body.feedback as string) || "" });
287
+ json(res, { ok: true });
288
+ } else {
289
+ html(res, options.htmlContent);
290
+ }
291
+ });
292
+
293
+ const port = listenOnRandomPort(server);
294
+
295
+ return {
296
+ port,
297
+ url: `http://localhost:${port}`,
298
+ waitForDecision: () => decisionPromise,
299
+ stop: () => server.close(),
300
+ };
301
+ }
package/utils.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Plannotator Pi extension utilities.
3
+ *
4
+ * Inlined versions of bash safety checks and checklist parsing.
5
+ * (No access to pi-mono's plan-mode/utils at runtime.)
6
+ */
7
+
8
+ // ── Bash Safety ──────────────────────────────────────────────────────────
9
+
10
+ const DESTRUCTIVE_PATTERNS = [
11
+ /\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i, /\bmkdir\b/i,
12
+ /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i, /\bchgrp\b/i, /\bln\b/i,
13
+ /\btee\b/i, /\btruncate\b/i, /\bdd\b/i, /\bshred\b/i,
14
+ /(^|[^<])>(?!>)/, />>/,
15
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
16
+ /\byarn\s+(add|remove|install|publish)/i,
17
+ /\bpnpm\s+(add|remove|install|publish)/i,
18
+ /\bpip\s+(install|uninstall)/i,
19
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
20
+ /\bbrew\s+(install|uninstall|upgrade)/i,
21
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
22
+ /\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i, /\bkillall\b/i,
23
+ /\breboot\b/i, /\bshutdown\b/i,
24
+ /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
25
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
26
+ /\b(vim?|nano|emacs|code|subl)\b/i,
27
+ ];
28
+
29
+ const SAFE_PATTERNS = [
30
+ /^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/,
31
+ /^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*echo\b/,
32
+ /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/, /^\s*uniq\b/, /^\s*diff\b/,
33
+ /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/, /^\s*tree\b/,
34
+ /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/, /^\s*env\b/,
35
+ /^\s*printenv\b/, /^\s*uname\b/, /^\s*whoami\b/, /^\s*id\b/,
36
+ /^\s*date\b/, /^\s*cal\b/, /^\s*uptime\b/, /^\s*ps\b/,
37
+ /^\s*top\b/, /^\s*htop\b/, /^\s*free\b/,
38
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
39
+ /^\s*git\s+ls-/i,
40
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
41
+ /^\s*yarn\s+(list|info|why|audit)/i,
42
+ /^\s*node\s+--version/i, /^\s*python\s+--version/i,
43
+ /^\s*curl\s/i, /^\s*wget\s+-O\s*-/i,
44
+ /^\s*jq\b/, /^\s*sed\s+-n/i, /^\s*awk\b/,
45
+ /^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*exa\b/,
46
+ ];
47
+
48
+ export function isSafeCommand(command: string): boolean {
49
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
50
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
51
+ return !isDestructive && isSafe;
52
+ }
53
+
54
+ // ── Checklist Parsing ────────────────────────────────────────────────────
55
+
56
+ export interface ChecklistItem {
57
+ /** 1-based step number, compatible with markCompletedSteps/extractDoneSteps. */
58
+ step: number;
59
+ text: string;
60
+ completed: boolean;
61
+ }
62
+
63
+ /**
64
+ * Parse standard markdown checkboxes from file content.
65
+ *
66
+ * Matches lines like:
67
+ * - [ ] Step description
68
+ * - [x] Completed step
69
+ * * [ ] Alternative bullet
70
+ */
71
+ export function parseChecklist(content: string): ChecklistItem[] {
72
+ const items: ChecklistItem[] = [];
73
+ const pattern = /^[-*]\s*\[([ xX])\]\s+(.+)$/gm;
74
+
75
+ for (const match of content.matchAll(pattern)) {
76
+ const completed = match[1] !== " ";
77
+ const text = match[2].trim();
78
+ if (text.length > 0) {
79
+ items.push({ step: items.length + 1, text, completed });
80
+ }
81
+ }
82
+ return items;
83
+ }
84
+
85
+ // ── Progress Tracking ────────────────────────────────────────────────────
86
+
87
+ export function extractDoneSteps(message: string): number[] {
88
+ const steps: number[] = [];
89
+ for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
90
+ const step = Number(match[1]);
91
+ if (Number.isFinite(step)) steps.push(step);
92
+ }
93
+ return steps;
94
+ }
95
+
96
+ export function markCompletedSteps(text: string, items: ChecklistItem[]): number {
97
+ const doneSteps = extractDoneSteps(text);
98
+ for (const step of doneSteps) {
99
+ const item = items.find((t) => t.step === step);
100
+ if (item) item.completed = true;
101
+ }
102
+ return doneSteps.length;
103
+ }