@plannotator/pi-extension 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  import { createServer, type IncomingMessage, type Server } from "node:http";
10
10
  import { execSync } from "node:child_process";
11
11
  import os from "node:os";
12
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
13
+ import { join, basename } from "node:path";
12
14
 
13
15
  // ── Helpers ──────────────────────────────────────────────────────────────
14
16
 
@@ -72,6 +74,181 @@ export function openBrowser(url: string): void {
72
74
  }
73
75
  }
74
76
 
77
+ // ── Version History (Node-compatible, duplicated from packages/server) ──
78
+
79
+ function sanitizeTag(name: string): string | null {
80
+ if (!name || typeof name !== "string") return null;
81
+ const sanitized = name
82
+ .toLowerCase()
83
+ .trim()
84
+ .replace(/[\s_]+/g, "-")
85
+ .replace(/[^a-z0-9-]/g, "")
86
+ .replace(/-+/g, "-")
87
+ .replace(/^-|-$/g, "")
88
+ .slice(0, 30);
89
+ return sanitized.length >= 2 ? sanitized : null;
90
+ }
91
+
92
+ function extractFirstHeading(markdown: string): string | null {
93
+ const match = markdown.match(/^#\s+(.+)$/m);
94
+ if (!match) return null;
95
+ return match[1].trim();
96
+ }
97
+
98
+ function generateSlug(plan: string): string {
99
+ const date = new Date().toISOString().split("T")[0];
100
+ const heading = extractFirstHeading(plan);
101
+ const slug = heading ? sanitizeTag(heading) : null;
102
+ return slug ? `${slug}-${date}` : `plan-${date}`;
103
+ }
104
+
105
+ function detectProjectName(): string {
106
+ try {
107
+ const toplevel = execSync("git rev-parse --show-toplevel", {
108
+ encoding: "utf-8",
109
+ stdio: ["pipe", "pipe", "pipe"],
110
+ }).trim();
111
+ const name = basename(toplevel);
112
+ return sanitizeTag(name) ?? "_unknown";
113
+ } catch {
114
+ // Not a git repo — fall back to cwd
115
+ }
116
+ try {
117
+ const name = basename(process.cwd());
118
+ return sanitizeTag(name) ?? "_unknown";
119
+ } catch {
120
+ return "_unknown";
121
+ }
122
+ }
123
+
124
+ function getHistoryDir(project: string, slug: string): string {
125
+ const historyDir = join(os.homedir(), ".plannotator", "history", project, slug);
126
+ mkdirSync(historyDir, { recursive: true });
127
+ return historyDir;
128
+ }
129
+
130
+ function getNextVersionNumber(historyDir: string): number {
131
+ try {
132
+ const entries = readdirSync(historyDir);
133
+ let max = 0;
134
+ for (const entry of entries) {
135
+ const match = entry.match(/^(\d+)\.md$/);
136
+ if (match) {
137
+ const num = parseInt(match[1], 10);
138
+ if (num > max) max = num;
139
+ }
140
+ }
141
+ return max + 1;
142
+ } catch {
143
+ return 1;
144
+ }
145
+ }
146
+
147
+ function saveToHistory(
148
+ project: string,
149
+ slug: string,
150
+ plan: string,
151
+ ): { version: number; path: string; isNew: boolean } {
152
+ const historyDir = getHistoryDir(project, slug);
153
+ const nextVersion = getNextVersionNumber(historyDir);
154
+ if (nextVersion > 1) {
155
+ const latestPath = join(historyDir, `${String(nextVersion - 1).padStart(3, "0")}.md`);
156
+ try {
157
+ const existing = readFileSync(latestPath, "utf-8");
158
+ if (existing === plan) {
159
+ return { version: nextVersion - 1, path: latestPath, isNew: false };
160
+ }
161
+ } catch { /* proceed with saving */ }
162
+ }
163
+ const fileName = `${String(nextVersion).padStart(3, "0")}.md`;
164
+ const filePath = join(historyDir, fileName);
165
+ writeFileSync(filePath, plan, "utf-8");
166
+ return { version: nextVersion, path: filePath, isNew: true };
167
+ }
168
+
169
+ function getPlanVersion(
170
+ project: string,
171
+ slug: string,
172
+ version: number,
173
+ ): string | null {
174
+ const historyDir = join(os.homedir(), ".plannotator", "history", project, slug);
175
+ const fileName = `${String(version).padStart(3, "0")}.md`;
176
+ const filePath = join(historyDir, fileName);
177
+ try {
178
+ return readFileSync(filePath, "utf-8");
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ function getVersionCount(project: string, slug: string): number {
185
+ const historyDir = join(os.homedir(), ".plannotator", "history", project, slug);
186
+ try {
187
+ const entries = readdirSync(historyDir);
188
+ return entries.filter((e) => /^\d+\.md$/.test(e)).length;
189
+ } catch {
190
+ return 0;
191
+ }
192
+ }
193
+
194
+ function listVersions(
195
+ project: string,
196
+ slug: string,
197
+ ): Array<{ version: number; timestamp: string }> {
198
+ const historyDir = join(os.homedir(), ".plannotator", "history", project, slug);
199
+ try {
200
+ const entries = readdirSync(historyDir);
201
+ const versions: Array<{ version: number; timestamp: string }> = [];
202
+ for (const entry of entries) {
203
+ const match = entry.match(/^(\d+)\.md$/);
204
+ if (match) {
205
+ const version = parseInt(match[1], 10);
206
+ const filePath = join(historyDir, entry);
207
+ try {
208
+ const stat = statSync(filePath);
209
+ versions.push({ version, timestamp: stat.mtime.toISOString() });
210
+ } catch {
211
+ versions.push({ version, timestamp: "" });
212
+ }
213
+ }
214
+ }
215
+ return versions.sort((a, b) => a.version - b.version);
216
+ } catch {
217
+ return [];
218
+ }
219
+ }
220
+
221
+ function listProjectPlans(
222
+ project: string,
223
+ ): Array<{ slug: string; versions: number; lastModified: string }> {
224
+ const projectDir = join(os.homedir(), ".plannotator", "history", project);
225
+ try {
226
+ const entries = readdirSync(projectDir, { withFileTypes: true });
227
+ const plans: Array<{ slug: string; versions: number; lastModified: string }> = [];
228
+ for (const entry of entries) {
229
+ if (!entry.isDirectory()) continue;
230
+ const slugDir = join(projectDir, entry.name);
231
+ const files = readdirSync(slugDir).filter((f) => /^\d+\.md$/.test(f));
232
+ if (files.length === 0) continue;
233
+ let latest = 0;
234
+ for (const file of files) {
235
+ try {
236
+ const mtime = statSync(join(slugDir, file)).mtime.getTime();
237
+ if (mtime > latest) latest = mtime;
238
+ } catch { /* skip */ }
239
+ }
240
+ plans.push({
241
+ slug: entry.name,
242
+ versions: files.length,
243
+ lastModified: latest ? new Date(latest).toISOString() : "",
244
+ });
245
+ }
246
+ return plans.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
247
+ } catch {
248
+ return [];
249
+ }
250
+ }
251
+
75
252
  // ── Plan Review Server ──────────────────────────────────────────────────
76
253
 
77
254
  export interface PlanServerResult {
@@ -86,6 +263,20 @@ export function startPlanReviewServer(options: {
86
263
  htmlContent: string;
87
264
  origin?: string;
88
265
  }): PlanServerResult {
266
+ // Version history
267
+ const slug = generateSlug(options.plan);
268
+ const project = detectProjectName();
269
+ const historyResult = saveToHistory(project, slug, options.plan);
270
+ const previousPlan =
271
+ historyResult.version > 1
272
+ ? getPlanVersion(project, slug, historyResult.version - 1)
273
+ : null;
274
+ const versionInfo = {
275
+ version: historyResult.version,
276
+ totalVersions: getVersionCount(project, slug),
277
+ project,
278
+ };
279
+
89
280
  let resolveDecision!: (result: { approved: boolean; feedback?: string }) => void;
90
281
  const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>((r) => {
91
282
  resolveDecision = r;
@@ -94,8 +285,29 @@ export function startPlanReviewServer(options: {
94
285
  const server = createServer(async (req, res) => {
95
286
  const url = new URL(req.url!, `http://localhost`);
96
287
 
97
- if (url.pathname === "/api/plan") {
98
- json(res, { plan: options.plan, origin: options.origin ?? "pi" });
288
+ if (url.pathname === "/api/plan/version") {
289
+ const vParam = url.searchParams.get("v");
290
+ if (!vParam) {
291
+ json(res, { error: "Missing v parameter" }, 400);
292
+ return;
293
+ }
294
+ const v = parseInt(vParam, 10);
295
+ if (isNaN(v) || v < 1) {
296
+ json(res, { error: "Invalid version number" }, 400);
297
+ return;
298
+ }
299
+ const content = getPlanVersion(project, slug, v);
300
+ if (content === null) {
301
+ json(res, { error: "Version not found" }, 404);
302
+ return;
303
+ }
304
+ json(res, { plan: content, version: v });
305
+ } else if (url.pathname === "/api/plan/versions") {
306
+ json(res, { project, slug, versions: listVersions(project, slug) });
307
+ } else if (url.pathname === "/api/plan/history") {
308
+ json(res, { project, plans: listProjectPlans(project) });
309
+ } else if (url.pathname === "/api/plan") {
310
+ json(res, { plan: options.plan, origin: options.origin ?? "pi", previousPlan, versionInfo });
99
311
  } else if (url.pathname === "/api/approve" && req.method === "POST") {
100
312
  const body = await parseBody(req);
101
313
  resolveDecision({ approved: true, feedback: body.feedback as string | undefined });