@peaske7/readit 0.1.5 → 0.1.6
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/docs/plans/2026-03-13-client-mode-design.md +86 -0
- package/docs/plans/2026-03-13-client-mode-plan.md +605 -0
- package/package.json +1 -1
- package/src/cli/index.ts +151 -12
- package/src/hooks/useDocument.ts +10 -0
- package/src/lib/utils.ts +11 -0
- package/src/server/index.ts +151 -34
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Client Mode: `readit open`
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
readit starts a long-running server per invocation. When used as a Claude Code PostToolUse hook, every Write/Edit on a `.md` file would spawn a new server instance. We need a way to send files to an already-running server.
|
|
6
|
+
|
|
7
|
+
## Design
|
|
8
|
+
|
|
9
|
+
### Overview
|
|
10
|
+
|
|
11
|
+
Add a `readit open <files...>` CLI command that hot-adds files to a running readit server via HTTP, or starts a new server if none exists. Uses a PID file for server discovery and the existing SSE infrastructure for browser notifications.
|
|
12
|
+
|
|
13
|
+
### 1. PID File (`~/.readit/server.json`)
|
|
14
|
+
|
|
15
|
+
**On server start** (in `startServer`):
|
|
16
|
+
```json
|
|
17
|
+
{ "port": 4567, "pid": 12345 }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**On shutdown** (SIGINT handler): delete the file.
|
|
21
|
+
|
|
22
|
+
**Client discovery:**
|
|
23
|
+
1. Read `~/.readit/server.json`
|
|
24
|
+
2. Verify PID is alive (`process.kill(pid, 0)`)
|
|
25
|
+
3. Confirm via `GET /api/health`
|
|
26
|
+
4. Any failure → treat as no server running
|
|
27
|
+
|
|
28
|
+
### 2. Server Endpoint: `POST /api/files`
|
|
29
|
+
|
|
30
|
+
Request body: `{ "path": "/absolute/path/to/file.md" }`
|
|
31
|
+
|
|
32
|
+
Behavior:
|
|
33
|
+
- **File already loaded** → re-read from disk, update `fileMap` content, broadcast SSE `{ type: "update", path }`
|
|
34
|
+
- **New file** → validate type, read content, add to `fileMap` + `fileOrder`, set up file watcher, broadcast SSE `{ type: "file-added", path, fileName, fileType }`
|
|
35
|
+
|
|
36
|
+
Response: `200 { path, fileName, type }`
|
|
37
|
+
|
|
38
|
+
### 3. Frontend: Handle `file-added` SSE Event
|
|
39
|
+
|
|
40
|
+
The Zustand store's SSE listener handles a new event type:
|
|
41
|
+
- `file-added` → add document to store, render new tab (lazy content fetch via existing pattern)
|
|
42
|
+
|
|
43
|
+
### 4. CLI Command: `readit open`
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
readit open <files...> # Add files to running server or start new one
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Logic:
|
|
50
|
+
1. Resolve and validate file paths (must exist, must be supported type)
|
|
51
|
+
2. Discover running server via PID file + health check
|
|
52
|
+
3. If server found → `POST /api/files` for each file
|
|
53
|
+
4. If no server → start server in foreground with the given files (same as default command)
|
|
54
|
+
|
|
55
|
+
### 5. Hook Configuration
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"PostToolUse": [
|
|
60
|
+
{
|
|
61
|
+
"matcher": "Write|Edit",
|
|
62
|
+
"hooks": [
|
|
63
|
+
{
|
|
64
|
+
"type": "command",
|
|
65
|
+
"command": "bash -c 'FILE=$(cat | jq -r \".tool_input.file_path\"); if [[ \"$FILE\" == *.md ]]; then bunx readit open \"$FILE\"; fi; exit 0'"
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Files to Modify
|
|
74
|
+
|
|
75
|
+
- `src/server/index.ts` — PID file write/cleanup, `POST /api/files` endpoint, file watcher setup for hot-added files
|
|
76
|
+
- `src/cli/index.ts` — new `open` subcommand with server discovery logic
|
|
77
|
+
- Frontend store — handle `file-added` SSE event type
|
|
78
|
+
|
|
79
|
+
## Verification
|
|
80
|
+
|
|
81
|
+
1. Start `readit test.md`
|
|
82
|
+
2. Run `readit open other.md` in another terminal
|
|
83
|
+
3. Confirm new tab appears in browser with `other.md`
|
|
84
|
+
4. Edit `other.md` on disk → confirm live reload works
|
|
85
|
+
5. Kill server, run `readit open test.md` → confirm new server starts
|
|
86
|
+
6. Run `readit open test.md` again (already loaded) → confirm content refreshes without duplicate tab
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
# Client Mode (`readit open`) Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add a `readit open` CLI command that hot-adds files to a running readit server, or starts a new one if none exists.
|
|
6
|
+
|
|
7
|
+
**Architecture:** PID file (`~/.readit/server.json`) for server discovery. New `POST /api/files` endpoint on the existing Bun.serve() for hot-adding files. Existing SSE infrastructure broadcasts `file-added` events to the frontend. Extract `getFileType` to shared lib to avoid duplication.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Bun, Commander.js, existing SSE, Zustand store
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Extract `getFileType` to shared lib
|
|
14
|
+
|
|
15
|
+
`getFileType` is currently defined in `src/cli/index.ts` and will be needed in both CLI and server code.
|
|
16
|
+
|
|
17
|
+
**Files:**
|
|
18
|
+
- Modify: `src/lib/utils.ts`
|
|
19
|
+
- Modify: `src/cli/index.ts`
|
|
20
|
+
|
|
21
|
+
**Step 1: Add `getFileType` to `src/lib/utils.ts`**
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import type { DocumentType } from "../types";
|
|
25
|
+
|
|
26
|
+
export function getFileType(filePath: string): DocumentType | null {
|
|
27
|
+
if (filePath.endsWith(".md") || filePath.endsWith(".markdown")) {
|
|
28
|
+
return "markdown";
|
|
29
|
+
}
|
|
30
|
+
if (filePath.endsWith(".html") || filePath.endsWith(".htm")) {
|
|
31
|
+
return "html";
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Step 2: Update `src/cli/index.ts` to import from shared lib**
|
|
38
|
+
|
|
39
|
+
Replace the local `getFileType` function with:
|
|
40
|
+
```ts
|
|
41
|
+
import { getFileType } from "../lib/utils.js";
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Remove the local `getFileType` function definition (lines 22-30).
|
|
45
|
+
|
|
46
|
+
**Step 3: Verify build passes**
|
|
47
|
+
|
|
48
|
+
Run: `bun run typecheck`
|
|
49
|
+
Expected: No errors
|
|
50
|
+
|
|
51
|
+
**Step 4: Commit**
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git add src/lib/utils.ts src/cli/index.ts
|
|
55
|
+
git commit -m "refactor: extract getFileType to shared lib"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### Task 2: Add PID file write/cleanup to server
|
|
61
|
+
|
|
62
|
+
**Files:**
|
|
63
|
+
- Modify: `src/server/index.ts` — write `~/.readit/server.json` on start, delete on shutdown
|
|
64
|
+
- Modify: `src/cli/index.ts` — clean up PID file in SIGINT handler
|
|
65
|
+
|
|
66
|
+
**Step 1: Add PID file helpers to `src/server/index.ts`**
|
|
67
|
+
|
|
68
|
+
Add near the top of the file, after the existing helpers:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const SERVER_INFO_PATH = path.join(os.homedir(), ".readit", "server.json");
|
|
72
|
+
|
|
73
|
+
async function writeServerInfo(port: number): Promise<void> {
|
|
74
|
+
await fs.mkdir(path.dirname(SERVER_INFO_PATH), { recursive: true });
|
|
75
|
+
await fs.writeFile(
|
|
76
|
+
SERVER_INFO_PATH,
|
|
77
|
+
JSON.stringify({ port, pid: process.pid }),
|
|
78
|
+
"utf-8",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function removeServerInfo(): Promise<void> {
|
|
83
|
+
try {
|
|
84
|
+
await fs.unlink(SERVER_INFO_PATH);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (!isErrnoException(err) || err.code !== "ENOENT") {
|
|
87
|
+
console.error("Failed to remove server info:", err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Step 2: Call `writeServerInfo` at end of `startServer`**
|
|
94
|
+
|
|
95
|
+
In the `startServer` function, after the server is successfully created, before the `return`:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
await writeServerInfo(actualPort);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Step 3: Export `removeServerInfo` and `SERVER_INFO_PATH`**
|
|
102
|
+
|
|
103
|
+
Export both so the CLI can use them in its SIGINT handler:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
export { SERVER_INFO_PATH, removeServerInfo };
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Step 4: Update SIGINT handler in `src/cli/index.ts`**
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { removeServerInfo } from "../server/index.js";
|
|
113
|
+
|
|
114
|
+
// In the SIGINT handler:
|
|
115
|
+
process.on("SIGINT", async () => {
|
|
116
|
+
console.log("\n\nShutting down...");
|
|
117
|
+
server.stop();
|
|
118
|
+
await removeServerInfo();
|
|
119
|
+
process.exit(0);
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Step 5: Verify build passes**
|
|
124
|
+
|
|
125
|
+
Run: `bun run typecheck`
|
|
126
|
+
Expected: No errors
|
|
127
|
+
|
|
128
|
+
**Step 6: Commit**
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
git add src/server/index.ts src/cli/index.ts
|
|
132
|
+
git commit -m "feat: write/cleanup PID file on server start/stop"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
### Task 3: Add `POST /api/files` endpoint to server
|
|
138
|
+
|
|
139
|
+
This endpoint hot-adds or refreshes files in a running server.
|
|
140
|
+
|
|
141
|
+
**Files:**
|
|
142
|
+
- Modify: `src/server/index.ts`
|
|
143
|
+
|
|
144
|
+
**Step 1: Extract watcher setup into a reusable function**
|
|
145
|
+
|
|
146
|
+
The file watcher logic (lines 686-719) needs to be callable for hot-added files too. Extract it within `createServer`:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
function watchFile(filePath: string): FSWatcher | null {
|
|
150
|
+
try {
|
|
151
|
+
const watcher = watch(filePath, async (eventType) => {
|
|
152
|
+
if (eventType !== "change") return;
|
|
153
|
+
|
|
154
|
+
const state = fileMap.get(filePath);
|
|
155
|
+
if (!state) return;
|
|
156
|
+
|
|
157
|
+
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
158
|
+
state.debounceTimer = setTimeout(async () => {
|
|
159
|
+
try {
|
|
160
|
+
const newContent = await fs.readFile(filePath, "utf-8");
|
|
161
|
+
if (newContent !== state.content) {
|
|
162
|
+
state.content = newContent;
|
|
163
|
+
console.log(`File changed: ${basename(filePath)}`);
|
|
164
|
+
|
|
165
|
+
const message = `data: ${JSON.stringify({ type: "update", path: filePath })}\n\n`;
|
|
166
|
+
for (const controller of sseClients) {
|
|
167
|
+
try {
|
|
168
|
+
controller.enqueue(message);
|
|
169
|
+
} catch {
|
|
170
|
+
sseClients.delete(controller);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error(`Failed to read updated file ${filePath}:`, err);
|
|
176
|
+
}
|
|
177
|
+
}, 100);
|
|
178
|
+
});
|
|
179
|
+
return watcher;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.warn(`File watching not available for ${filePath}:`, err);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Replace the inline watcher loop with:
|
|
188
|
+
```ts
|
|
189
|
+
for (const filePath of fileOrder) {
|
|
190
|
+
const watcher = watchFile(filePath);
|
|
191
|
+
if (watcher) watchers.push(watcher);
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Step 2: Add `POST /api/files` route**
|
|
196
|
+
|
|
197
|
+
Add inside the `fetch` handler in `createServer`, after the `/api/documents` route:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
if (pathname === "/api/files" && method === "POST") {
|
|
201
|
+
try {
|
|
202
|
+
const { path: requestedPath } = await req.json();
|
|
203
|
+
|
|
204
|
+
if (!requestedPath || typeof requestedPath !== "string") {
|
|
205
|
+
return errorResponse("Missing 'path' field", 400);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const filePath = path.resolve(requestedPath);
|
|
209
|
+
const fileType = getFileType(filePath);
|
|
210
|
+
|
|
211
|
+
if (!fileType) {
|
|
212
|
+
return errorResponse(
|
|
213
|
+
`Unsupported file type: ${filePath} (expected .md, .markdown, .html, or .htm)`,
|
|
214
|
+
400,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let content: string;
|
|
219
|
+
try {
|
|
220
|
+
content = await fs.readFile(filePath, "utf-8");
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
223
|
+
return errorResponse(`File not found: ${filePath}`, 404);
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const existingState = fileMap.get(filePath);
|
|
229
|
+
|
|
230
|
+
if (existingState) {
|
|
231
|
+
// File already loaded — refresh content
|
|
232
|
+
existingState.content = content;
|
|
233
|
+
const message = `data: ${JSON.stringify({ type: "update", path: filePath })}\n\n`;
|
|
234
|
+
for (const controller of sseClients) {
|
|
235
|
+
try {
|
|
236
|
+
controller.enqueue(message);
|
|
237
|
+
} catch {
|
|
238
|
+
sseClients.delete(controller);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// New file — add to server
|
|
243
|
+
fileMap.set(filePath, {
|
|
244
|
+
content,
|
|
245
|
+
type: fileType,
|
|
246
|
+
debounceTimer: null,
|
|
247
|
+
});
|
|
248
|
+
fileOrder.push(filePath);
|
|
249
|
+
|
|
250
|
+
// Set up file watcher for the new file
|
|
251
|
+
const watcher = watchFile(filePath);
|
|
252
|
+
if (watcher) watchers.push(watcher);
|
|
253
|
+
|
|
254
|
+
const message = `data: ${JSON.stringify({
|
|
255
|
+
type: "file-added",
|
|
256
|
+
path: filePath,
|
|
257
|
+
fileName: basename(filePath),
|
|
258
|
+
fileType,
|
|
259
|
+
})}\n\n`;
|
|
260
|
+
for (const controller of sseClients) {
|
|
261
|
+
try {
|
|
262
|
+
controller.enqueue(message);
|
|
263
|
+
} catch {
|
|
264
|
+
sseClients.delete(controller);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return json({
|
|
270
|
+
path: filePath,
|
|
271
|
+
fileName: basename(filePath),
|
|
272
|
+
type: fileType,
|
|
273
|
+
});
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error("Failed to add file:", err);
|
|
276
|
+
return errorResponse("Failed to add file", 500);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**Step 3: Add the `getFileType` import**
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
import { getFileType } from "../lib/utils.js";
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**Step 4: Verify build passes**
|
|
288
|
+
|
|
289
|
+
Run: `bun run typecheck`
|
|
290
|
+
Expected: No errors
|
|
291
|
+
|
|
292
|
+
**Step 5: Commit**
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
git add src/server/index.ts
|
|
296
|
+
git commit -m "feat: add POST /api/files endpoint for hot-adding files"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
### Task 4: Handle `file-added` SSE event in frontend
|
|
302
|
+
|
|
303
|
+
**Files:**
|
|
304
|
+
- Modify: `src/hooks/useDocument.ts`
|
|
305
|
+
|
|
306
|
+
**Step 1: Add `file-added` handling to SSE listener**
|
|
307
|
+
|
|
308
|
+
In the `eventSource.onmessage` handler (around line 90), add a branch for `file-added`:
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
eventSource.onmessage = async (e) => {
|
|
312
|
+
try {
|
|
313
|
+
const data = JSON.parse(e.data);
|
|
314
|
+
|
|
315
|
+
if (data.type === "file-added" && data.path) {
|
|
316
|
+
appStore.getState().openDocument({
|
|
317
|
+
content: "", // Lazy-loaded when tab activated
|
|
318
|
+
type: data.fileType,
|
|
319
|
+
filePath: data.path,
|
|
320
|
+
fileName: data.fileName,
|
|
321
|
+
clean: false,
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (data.type === "update" && data.path) {
|
|
327
|
+
// ... existing update logic unchanged
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// Ignore non-JSON messages ("connected", "ping")
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**Step 2: Verify build passes**
|
|
336
|
+
|
|
337
|
+
Run: `bun run typecheck`
|
|
338
|
+
Expected: No errors
|
|
339
|
+
|
|
340
|
+
**Step 3: Commit**
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
git add src/hooks/useDocument.ts
|
|
344
|
+
git commit -m "feat: handle file-added SSE event for hot-added documents"
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
### Task 5: Add `readit open` CLI command
|
|
350
|
+
|
|
351
|
+
**Files:**
|
|
352
|
+
- Modify: `src/cli/index.ts`
|
|
353
|
+
|
|
354
|
+
**Step 1: Add server discovery function**
|
|
355
|
+
|
|
356
|
+
Add after the imports:
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
import { readFileSync as readFileSyncNode } from "node:fs";
|
|
360
|
+
|
|
361
|
+
interface ServerInfo {
|
|
362
|
+
port: number;
|
|
363
|
+
pid: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function discoverServer(): Promise<ServerInfo | null> {
|
|
367
|
+
const serverInfoPath = join(os.homedir(), ".readit", "server.json");
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
const content = readFileSyncNode(serverInfoPath, "utf-8");
|
|
371
|
+
const info: ServerInfo = JSON.parse(content);
|
|
372
|
+
|
|
373
|
+
// Verify the process is alive
|
|
374
|
+
try {
|
|
375
|
+
process.kill(info.pid, 0);
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Verify health endpoint responds
|
|
381
|
+
try {
|
|
382
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/api/health`);
|
|
383
|
+
if (!res.ok) return null;
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return info;
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**Step 2: Add `open` subcommand**
|
|
396
|
+
|
|
397
|
+
Add before `program.parse()`:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
program
|
|
401
|
+
.command("open")
|
|
402
|
+
.argument("<files...>", "Markdown or HTML files to add to running server")
|
|
403
|
+
.description("Add files to a running readit server, or start a new one")
|
|
404
|
+
.option("-p, --port <number>", "Port for new server (if starting)", "4567")
|
|
405
|
+
.option("--host <address>", "Host for new server (if starting)", "127.0.0.1")
|
|
406
|
+
.action(
|
|
407
|
+
async (
|
|
408
|
+
fileArgs: string[],
|
|
409
|
+
options: { port: string; host: string },
|
|
410
|
+
) => {
|
|
411
|
+
// Resolve and validate files
|
|
412
|
+
const resolvedFiles: { path: string; type: DocumentType }[] = [];
|
|
413
|
+
for (const arg of fileArgs) {
|
|
414
|
+
const filePath = resolve(process.cwd(), arg);
|
|
415
|
+
|
|
416
|
+
if (!existsSync(filePath)) {
|
|
417
|
+
console.error(`error: not found: ${filePath}`);
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const type = getFileType(filePath);
|
|
422
|
+
if (!type) {
|
|
423
|
+
console.error(
|
|
424
|
+
`error: unsupported file type: ${arg} (expected .md, .markdown, .html, or .htm)`,
|
|
425
|
+
);
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
resolvedFiles.push({ path: filePath, type });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Try to find running server
|
|
433
|
+
const server = await discoverServer();
|
|
434
|
+
|
|
435
|
+
if (server) {
|
|
436
|
+
// Send files to running server
|
|
437
|
+
for (const file of resolvedFiles) {
|
|
438
|
+
try {
|
|
439
|
+
const res = await fetch(
|
|
440
|
+
`http://127.0.0.1:${server.port}/api/files`,
|
|
441
|
+
{
|
|
442
|
+
method: "POST",
|
|
443
|
+
headers: { "Content-Type": "application/json" },
|
|
444
|
+
body: JSON.stringify({ path: file.path }),
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
if (!res.ok) {
|
|
449
|
+
const data = await res.json();
|
|
450
|
+
console.error(
|
|
451
|
+
`error: failed to add ${file.path}: ${data.error}`,
|
|
452
|
+
);
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const data = await res.json();
|
|
457
|
+
console.log(`Added: ${data.fileName} (${data.type})`);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
console.error(
|
|
460
|
+
`error: failed to connect to server:`,
|
|
461
|
+
err instanceof Error ? err.message : err,
|
|
462
|
+
);
|
|
463
|
+
process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
console.log(
|
|
468
|
+
`\nServer: http://127.0.0.1:${server.port}`,
|
|
469
|
+
);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// No running server — start one
|
|
474
|
+
console.log("No running server found, starting new one...\n");
|
|
475
|
+
|
|
476
|
+
const files = resolvedFiles.map((f) => ({
|
|
477
|
+
content: readFileSync(f.path, "utf-8"),
|
|
478
|
+
type: f.type,
|
|
479
|
+
filePath: f.path,
|
|
480
|
+
}));
|
|
481
|
+
|
|
482
|
+
const preferredPort = Number.parseInt(options.port, 10);
|
|
483
|
+
try {
|
|
484
|
+
const { url, server: newServer } = await startServer({
|
|
485
|
+
files,
|
|
486
|
+
port: preferredPort,
|
|
487
|
+
host: options.host,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
|
|
491
|
+
console.log(`
|
|
492
|
+
readit - Document Review Tool
|
|
493
|
+
|
|
494
|
+
${files.length === 1 ? "File:" : "Files:"}
|
|
495
|
+
${fileList.join("\n")}
|
|
496
|
+
URL: ${url}
|
|
497
|
+
|
|
498
|
+
Server running. Close browser tab to stop.
|
|
499
|
+
Press Ctrl+C to force stop.
|
|
500
|
+
`);
|
|
501
|
+
|
|
502
|
+
open(url);
|
|
503
|
+
|
|
504
|
+
process.on("SIGINT", async () => {
|
|
505
|
+
console.log("\n\nShutting down...");
|
|
506
|
+
newServer.stop();
|
|
507
|
+
await removeServerInfo();
|
|
508
|
+
process.exit(0);
|
|
509
|
+
});
|
|
510
|
+
} catch (error) {
|
|
511
|
+
console.error(
|
|
512
|
+
"error: failed to start server:",
|
|
513
|
+
error instanceof Error ? error.message : error,
|
|
514
|
+
);
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
**Step 3: Add missing imports**
|
|
522
|
+
|
|
523
|
+
Make sure these imports are at the top of the file:
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
import { getFileType } from "../lib/utils.js";
|
|
527
|
+
import { removeServerInfo } from "../server/index.js";
|
|
528
|
+
import type { DocumentType } from "../types/index.js";
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**Step 4: Verify build passes**
|
|
532
|
+
|
|
533
|
+
Run: `bun run typecheck`
|
|
534
|
+
Expected: No errors
|
|
535
|
+
|
|
536
|
+
**Step 5: Commit**
|
|
537
|
+
|
|
538
|
+
```bash
|
|
539
|
+
git add src/cli/index.ts
|
|
540
|
+
git commit -m "feat: add readit open command for client mode"
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
### Task 6: Update the hook configuration
|
|
546
|
+
|
|
547
|
+
**Files:**
|
|
548
|
+
- Modify: `/Users/jay/.claude/settings.json`
|
|
549
|
+
|
|
550
|
+
**Step 1: Update the PostToolUse hook**
|
|
551
|
+
|
|
552
|
+
Change the command from `open -a Arto` to `bunx readit open`:
|
|
553
|
+
|
|
554
|
+
```json
|
|
555
|
+
"PostToolUse": [
|
|
556
|
+
{
|
|
557
|
+
"matcher": "Write|Edit",
|
|
558
|
+
"hooks": [
|
|
559
|
+
{
|
|
560
|
+
"type": "command",
|
|
561
|
+
"command": "bash -c 'FILE=$(cat | jq -r \".tool_input.file_path\"); if [[ \"$FILE\" == *.md ]]; then bunx readit open \"$FILE\"; fi; exit 0'"
|
|
562
|
+
}
|
|
563
|
+
]
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
**Step 2: Verify the hook fires correctly**
|
|
569
|
+
|
|
570
|
+
Write or edit any `.md` file and confirm the hook runs without error.
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
### Task 7: End-to-end verification
|
|
575
|
+
|
|
576
|
+
**Step 1: Build the project**
|
|
577
|
+
|
|
578
|
+
Run: `bun run build`
|
|
579
|
+
Expected: Successful build
|
|
580
|
+
|
|
581
|
+
**Step 2: Start server with a test file**
|
|
582
|
+
|
|
583
|
+
Run: `bunx readit test.md`
|
|
584
|
+
Expected: Server starts, browser opens
|
|
585
|
+
|
|
586
|
+
**Step 3: Open another file via client mode**
|
|
587
|
+
|
|
588
|
+
In another terminal:
|
|
589
|
+
Run: `bunx readit open README.md`
|
|
590
|
+
Expected: "Added: README.md (markdown)" printed, new tab appears in browser
|
|
591
|
+
|
|
592
|
+
**Step 4: Verify live reload for hot-added file**
|
|
593
|
+
|
|
594
|
+
Edit `README.md` on disk, confirm the browser updates automatically.
|
|
595
|
+
|
|
596
|
+
**Step 5: Verify reload for already-loaded file**
|
|
597
|
+
|
|
598
|
+
Run: `bunx readit open test.md`
|
|
599
|
+
Expected: Content refreshes, no duplicate tab
|
|
600
|
+
|
|
601
|
+
**Step 6: Verify auto-start when no server running**
|
|
602
|
+
|
|
603
|
+
Stop the server (Ctrl+C), then:
|
|
604
|
+
Run: `bunx readit open test.md`
|
|
605
|
+
Expected: New server starts with `test.md`
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -13,22 +13,13 @@ import { join, resolve } from "node:path";
|
|
|
13
13
|
import { Command } from "commander";
|
|
14
14
|
import open from "open";
|
|
15
15
|
import { getCommentPath, parseCommentFile } from "../lib/comment-storage.js";
|
|
16
|
+
import { getFileType } from "../lib/utils.js";
|
|
16
17
|
import type { FileEntry } from "../server/index.js";
|
|
17
|
-
import { startServer } from "../server/index.js";
|
|
18
|
+
import { removeServerInfo, startServer } from "../server/index.js";
|
|
18
19
|
import type { DocumentType } from "../types/index.js";
|
|
19
20
|
|
|
20
21
|
const program = new Command();
|
|
21
22
|
|
|
22
|
-
function getFileType(filePath: string): DocumentType | null {
|
|
23
|
-
if (filePath.endsWith(".md") || filePath.endsWith(".markdown")) {
|
|
24
|
-
return "markdown";
|
|
25
|
-
}
|
|
26
|
-
if (filePath.endsWith(".html") || filePath.endsWith(".htm")) {
|
|
27
|
-
return "html";
|
|
28
|
-
}
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
23
|
function isPermissionError(err: unknown): boolean {
|
|
33
24
|
return (
|
|
34
25
|
err instanceof Error &&
|
|
@@ -37,6 +28,39 @@ function isPermissionError(err: unknown): boolean {
|
|
|
37
28
|
);
|
|
38
29
|
}
|
|
39
30
|
|
|
31
|
+
interface ServerInfo {
|
|
32
|
+
port: number;
|
|
33
|
+
pid: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function discoverServer(): Promise<ServerInfo | null> {
|
|
37
|
+
const serverInfoPath = join(os.homedir(), ".readit", "server.json");
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const content = readFileSync(serverInfoPath, "utf-8");
|
|
41
|
+
const info: ServerInfo = JSON.parse(content);
|
|
42
|
+
|
|
43
|
+
// Verify the process is alive
|
|
44
|
+
try {
|
|
45
|
+
process.kill(info.pid, 0);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Verify health endpoint responds
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/api/health`);
|
|
53
|
+
if (!res.ok) return null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return info;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
40
64
|
/**
|
|
41
65
|
* Recursively find all .comments.md files in a directory.
|
|
42
66
|
*/
|
|
@@ -310,9 +334,124 @@ ${fileList.join("\n")}
|
|
|
310
334
|
}
|
|
311
335
|
|
|
312
336
|
// Graceful shutdown on Ctrl+C
|
|
313
|
-
process.on("SIGINT", () => {
|
|
337
|
+
process.on("SIGINT", async () => {
|
|
314
338
|
console.log("\n\nShutting down...");
|
|
315
339
|
server.stop();
|
|
340
|
+
await removeServerInfo();
|
|
341
|
+
process.exit(0);
|
|
342
|
+
});
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error(
|
|
345
|
+
"error: failed to start server:",
|
|
346
|
+
error instanceof Error ? error.message : error,
|
|
347
|
+
);
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Open command: add files to running server or start new one
|
|
354
|
+
program
|
|
355
|
+
.command("open")
|
|
356
|
+
.argument("<files...>", "Markdown or HTML files to add to running server")
|
|
357
|
+
.description("Add files to a running readit server, or start a new one")
|
|
358
|
+
.option("-p, --port <number>", "Port for new server (if starting)", "4567")
|
|
359
|
+
.option("--host <address>", "Host for new server (if starting)", "127.0.0.1")
|
|
360
|
+
.action(
|
|
361
|
+
async (fileArgs: string[], options: { port: string; host: string }) => {
|
|
362
|
+
// Resolve and validate files
|
|
363
|
+
const resolvedFiles: { path: string; type: DocumentType }[] = [];
|
|
364
|
+
for (const arg of fileArgs) {
|
|
365
|
+
const filePath = resolve(process.cwd(), arg);
|
|
366
|
+
|
|
367
|
+
if (!existsSync(filePath)) {
|
|
368
|
+
console.error(`error: not found: ${filePath}`);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const type = getFileType(filePath);
|
|
373
|
+
if (!type) {
|
|
374
|
+
console.error(
|
|
375
|
+
`error: unsupported file type: ${arg} (expected .md, .markdown, .html, or .htm)`,
|
|
376
|
+
);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
resolvedFiles.push({ path: filePath, type });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Try to find running server
|
|
384
|
+
const server = await discoverServer();
|
|
385
|
+
|
|
386
|
+
if (server) {
|
|
387
|
+
// Send files to running server
|
|
388
|
+
for (const file of resolvedFiles) {
|
|
389
|
+
try {
|
|
390
|
+
const res = await fetch(
|
|
391
|
+
`http://127.0.0.1:${server.port}/api/files`,
|
|
392
|
+
{
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: { "Content-Type": "application/json" },
|
|
395
|
+
body: JSON.stringify({ path: file.path }),
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
if (!res.ok) {
|
|
400
|
+
const data = await res.json();
|
|
401
|
+
console.error(`error: failed to add ${file.path}: ${data.error}`);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const data = await res.json();
|
|
406
|
+
console.log(`Added: ${data.fileName} (${data.type})`);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
console.error(
|
|
409
|
+
"error: failed to connect to server:",
|
|
410
|
+
err instanceof Error ? err.message : err,
|
|
411
|
+
);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
console.log(`\nServer: http://127.0.0.1:${server.port}`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// No running server — start one
|
|
421
|
+
console.log("No running server found, starting new one...\n");
|
|
422
|
+
|
|
423
|
+
const files = resolvedFiles.map((f) => ({
|
|
424
|
+
content: readFileSync(f.path, "utf-8"),
|
|
425
|
+
type: f.type,
|
|
426
|
+
filePath: f.path,
|
|
427
|
+
}));
|
|
428
|
+
|
|
429
|
+
const preferredPort = Number.parseInt(options.port, 10);
|
|
430
|
+
try {
|
|
431
|
+
const { url, server: newServer } = await startServer({
|
|
432
|
+
files,
|
|
433
|
+
port: preferredPort,
|
|
434
|
+
host: options.host,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
|
|
438
|
+
console.log(`
|
|
439
|
+
readit - Document Review Tool
|
|
440
|
+
|
|
441
|
+
${files.length === 1 ? "File:" : "Files:"}
|
|
442
|
+
${fileList.join("\n")}
|
|
443
|
+
URL: ${url}
|
|
444
|
+
|
|
445
|
+
Server running. Close browser tab to stop.
|
|
446
|
+
Press Ctrl+C to force stop.
|
|
447
|
+
`);
|
|
448
|
+
|
|
449
|
+
open(url);
|
|
450
|
+
|
|
451
|
+
process.on("SIGINT", async () => {
|
|
452
|
+
console.log("\n\nShutting down...");
|
|
453
|
+
newServer.stop();
|
|
454
|
+
await removeServerInfo();
|
|
316
455
|
process.exit(0);
|
|
317
456
|
});
|
|
318
457
|
} catch (error) {
|
package/src/hooks/useDocument.ts
CHANGED
|
@@ -90,6 +90,16 @@ export function useDocument(): UseDocumentResult {
|
|
|
90
90
|
eventSource.onmessage = async (e) => {
|
|
91
91
|
try {
|
|
92
92
|
const data = JSON.parse(e.data);
|
|
93
|
+
if (data.type === "file-added" && data.path) {
|
|
94
|
+
appStore.getState().openDocument({
|
|
95
|
+
content: "", // Lazy-loaded when tab activated
|
|
96
|
+
type: data.fileType,
|
|
97
|
+
filePath: data.path,
|
|
98
|
+
fileName: data.fileName,
|
|
99
|
+
clean: false,
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
93
103
|
if (data.type === "update" && data.path) {
|
|
94
104
|
// Only reload if content was previously loaded
|
|
95
105
|
const state = appStore.getState().documents.get(data.path);
|
package/src/lib/utils.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { type ClassValue, clsx } from "clsx";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { twMerge } from "tailwind-merge";
|
|
4
|
+
import type { DocumentType } from "../types";
|
|
5
|
+
|
|
6
|
+
export function getFileType(filePath: string): DocumentType | null {
|
|
7
|
+
if (filePath.endsWith(".md") || filePath.endsWith(".markdown")) {
|
|
8
|
+
return "markdown";
|
|
9
|
+
}
|
|
10
|
+
if (filePath.endsWith(".html") || filePath.endsWith(".htm")) {
|
|
11
|
+
return "html";
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
4
15
|
|
|
5
16
|
export function cn(...inputs: ReadonlyArray<ClassValue>) {
|
|
6
17
|
return twMerge(clsx(inputs));
|
package/src/server/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
serializeComments,
|
|
14
14
|
truncateSelection,
|
|
15
15
|
} from "../lib/comment-storage.js";
|
|
16
|
+
import { getFileType } from "../lib/utils.js";
|
|
16
17
|
import {
|
|
17
18
|
AnchorConfidences,
|
|
18
19
|
type Comment,
|
|
@@ -171,6 +172,33 @@ function isValidFontFamily(value: unknown): value is FontFamily {
|
|
|
171
172
|
return value === FontFamilies.SERIF || value === FontFamilies.SANS_SERIF;
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
// ─── PID file helpers ───────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export const SERVER_INFO_PATH = path.join(
|
|
178
|
+
os.homedir(),
|
|
179
|
+
".readit",
|
|
180
|
+
"server.json",
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
async function writeServerInfo(port: number): Promise<void> {
|
|
184
|
+
await fs.mkdir(path.dirname(SERVER_INFO_PATH), { recursive: true });
|
|
185
|
+
await fs.writeFile(
|
|
186
|
+
SERVER_INFO_PATH,
|
|
187
|
+
JSON.stringify({ port, pid: process.pid }),
|
|
188
|
+
"utf-8",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function removeServerInfo(): Promise<void> {
|
|
193
|
+
try {
|
|
194
|
+
await fs.unlink(SERVER_INFO_PATH);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (!isErrnoException(err) || err.code !== "ENOENT") {
|
|
197
|
+
console.error("Failed to remove server info:", err);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
174
202
|
// ─── Response helpers ───────────────────────────────────────────────
|
|
175
203
|
|
|
176
204
|
function json(data: unknown, status = 200): Response {
|
|
@@ -560,6 +588,43 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
560
588
|
const isDev = process.env.NODE_ENV === "development";
|
|
561
589
|
const distPath = import.meta.dir;
|
|
562
590
|
|
|
591
|
+
function watchFile(targetPath: string): FSWatcher | null {
|
|
592
|
+
try {
|
|
593
|
+
const watcher = watch(targetPath, async (eventType) => {
|
|
594
|
+
if (eventType !== "change") return;
|
|
595
|
+
|
|
596
|
+
const state = fileMap.get(targetPath);
|
|
597
|
+
if (!state) return;
|
|
598
|
+
|
|
599
|
+
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
600
|
+
state.debounceTimer = setTimeout(async () => {
|
|
601
|
+
try {
|
|
602
|
+
const newContent = await fs.readFile(targetPath, "utf-8");
|
|
603
|
+
if (newContent !== state.content) {
|
|
604
|
+
state.content = newContent;
|
|
605
|
+
console.log(`File changed: ${basename(targetPath)}`);
|
|
606
|
+
|
|
607
|
+
const message = `data: ${JSON.stringify({ type: "update", path: targetPath })}\n\n`;
|
|
608
|
+
for (const controller of sseClients) {
|
|
609
|
+
try {
|
|
610
|
+
controller.enqueue(message);
|
|
611
|
+
} catch {
|
|
612
|
+
sseClients.delete(controller);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
} catch (err) {
|
|
617
|
+
console.error(`Failed to read updated file ${targetPath}:`, err);
|
|
618
|
+
}
|
|
619
|
+
}, 100);
|
|
620
|
+
});
|
|
621
|
+
return watcher;
|
|
622
|
+
} catch (err) {
|
|
623
|
+
console.warn(`File watching not available for ${targetPath}:`, err);
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
563
628
|
const server = Bun.serve({
|
|
564
629
|
port: options.port,
|
|
565
630
|
hostname: options.host,
|
|
@@ -584,6 +649,87 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
584
649
|
return json({ files, clean: options.clean || false });
|
|
585
650
|
}
|
|
586
651
|
|
|
652
|
+
// Hot-add or refresh a file
|
|
653
|
+
if (pathname === "/api/files" && method === "POST") {
|
|
654
|
+
try {
|
|
655
|
+
const { path: requestedPath } = await req.json();
|
|
656
|
+
|
|
657
|
+
if (!requestedPath || typeof requestedPath !== "string") {
|
|
658
|
+
return errorResponse("Missing 'path' field", 400);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const filePath = path.resolve(requestedPath);
|
|
662
|
+
const fileType = getFileType(filePath);
|
|
663
|
+
|
|
664
|
+
if (!fileType) {
|
|
665
|
+
return errorResponse(
|
|
666
|
+
`Unsupported file type: ${filePath} (expected .md, .markdown, .html, or .htm)`,
|
|
667
|
+
400,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
let content: string;
|
|
672
|
+
try {
|
|
673
|
+
content = await fs.readFile(filePath, "utf-8");
|
|
674
|
+
} catch (err) {
|
|
675
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
676
|
+
return errorResponse(`File not found: ${filePath}`, 404);
|
|
677
|
+
}
|
|
678
|
+
throw err;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const existingState = fileMap.get(filePath);
|
|
682
|
+
|
|
683
|
+
if (existingState) {
|
|
684
|
+
// File already loaded — refresh content
|
|
685
|
+
existingState.content = content;
|
|
686
|
+
const message = `data: ${JSON.stringify({ type: "update", path: filePath })}\n\n`;
|
|
687
|
+
for (const controller of sseClients) {
|
|
688
|
+
try {
|
|
689
|
+
controller.enqueue(message);
|
|
690
|
+
} catch {
|
|
691
|
+
sseClients.delete(controller);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
} else {
|
|
695
|
+
// New file — add to server
|
|
696
|
+
fileMap.set(filePath, {
|
|
697
|
+
content,
|
|
698
|
+
type: fileType,
|
|
699
|
+
debounceTimer: null,
|
|
700
|
+
});
|
|
701
|
+
fileOrder.push(filePath);
|
|
702
|
+
|
|
703
|
+
// Set up file watcher for the new file
|
|
704
|
+
const watcher = watchFile(filePath);
|
|
705
|
+
if (watcher) watchers.push(watcher);
|
|
706
|
+
|
|
707
|
+
const message = `data: ${JSON.stringify({
|
|
708
|
+
type: "file-added",
|
|
709
|
+
path: filePath,
|
|
710
|
+
fileName: basename(filePath),
|
|
711
|
+
fileType,
|
|
712
|
+
})}\n\n`;
|
|
713
|
+
for (const controller of sseClients) {
|
|
714
|
+
try {
|
|
715
|
+
controller.enqueue(message);
|
|
716
|
+
} catch {
|
|
717
|
+
sseClients.delete(controller);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return json({
|
|
723
|
+
path: filePath,
|
|
724
|
+
fileName: basename(filePath),
|
|
725
|
+
type: fileType,
|
|
726
|
+
});
|
|
727
|
+
} catch (err) {
|
|
728
|
+
console.error("Failed to add file:", err);
|
|
729
|
+
return errorResponse("Failed to add file", 500);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
587
733
|
// Single document (backward compat + path-aware)
|
|
588
734
|
if (pathname === "/api/document" && method === "GET") {
|
|
589
735
|
const ctxOrRes = requireContext(url);
|
|
@@ -682,40 +828,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
|
|
|
682
828
|
// Set up per-file watchers after Bun.serve() succeeds to avoid
|
|
683
829
|
// leaking FSWatcher handles if the server fails to bind.
|
|
684
830
|
const watchers: FSWatcher[] = [];
|
|
685
|
-
for (const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
if (eventType !== "change") return;
|
|
689
|
-
|
|
690
|
-
const state = fileMap.get(filePath);
|
|
691
|
-
if (!state) return;
|
|
692
|
-
|
|
693
|
-
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
694
|
-
state.debounceTimer = setTimeout(async () => {
|
|
695
|
-
try {
|
|
696
|
-
const newContent = await fs.readFile(filePath, "utf-8");
|
|
697
|
-
if (newContent !== state.content) {
|
|
698
|
-
state.content = newContent;
|
|
699
|
-
console.log(`File changed: ${basename(filePath)}`);
|
|
700
|
-
|
|
701
|
-
const message = `data: ${JSON.stringify({ type: "update", path: filePath })}\n\n`;
|
|
702
|
-
for (const controller of sseClients) {
|
|
703
|
-
try {
|
|
704
|
-
controller.enqueue(message);
|
|
705
|
-
} catch {
|
|
706
|
-
sseClients.delete(controller);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
} catch (err) {
|
|
711
|
-
console.error(`Failed to read updated file ${filePath}:`, err);
|
|
712
|
-
}
|
|
713
|
-
}, 100);
|
|
714
|
-
});
|
|
715
|
-
watchers.push(watcher);
|
|
716
|
-
} catch (err) {
|
|
717
|
-
console.warn(`File watching not available for ${filePath}:`, err);
|
|
718
|
-
}
|
|
831
|
+
for (const fp of fileOrder) {
|
|
832
|
+
const watcher = watchFile(fp);
|
|
833
|
+
if (watcher) watchers.push(watcher);
|
|
719
834
|
}
|
|
720
835
|
|
|
721
836
|
return { server, watchers };
|
|
@@ -745,6 +860,8 @@ export async function startServer(
|
|
|
745
860
|
|
|
746
861
|
const actualPort = server.port ?? port;
|
|
747
862
|
|
|
863
|
+
await writeServerInfo(actualPort);
|
|
864
|
+
|
|
748
865
|
return {
|
|
749
866
|
port: actualPort,
|
|
750
867
|
url: `http://${displayHost}:${actualPort}`,
|