pagedex 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import { McpServer, createMcpHandler, parseJSONRPCMessage } from '@modelcontextprotocol/server';
4
+ import { z } from 'zod';
5
+ import { deleteNote, editNote, getCachedVault, resolveSafe, writeNote } from './vault.js';
6
+ import { removeFileFromIndex, searchFts, upsertFileInIndex } from './search-index.js';
7
+
8
+ const sseSessions = new Map();
9
+ const encoder = new TextEncoder();
10
+
11
+ class WebStandardSSEServerTransport {
12
+ constructor(endpoint = '/mcp') {
13
+ this.endpoint = endpoint;
14
+ this.sessionId = randomUUID();
15
+ this._controller = null;
16
+ this.onmessage = undefined;
17
+ this.onclose = undefined;
18
+ this.onerror = undefined;
19
+ }
20
+
21
+ async start() {
22
+ // Stream is initialized in createResponse()
23
+ }
24
+
25
+ createResponse(signal) {
26
+ const stream = new ReadableStream({
27
+ start: (controller) => {
28
+ this._controller = controller;
29
+ const endpointUri = `${this.endpoint}?sessionId=${this.sessionId}`;
30
+ controller.enqueue(encoder.encode(`event: endpoint\ndata: ${endpointUri}\n\n`));
31
+ },
32
+ cancel: () => {
33
+ this.close();
34
+ }
35
+ });
36
+
37
+ signal?.addEventListener('abort', () => this.close(), { once: true });
38
+
39
+ return new Response(stream, {
40
+ status: 200,
41
+ headers: {
42
+ 'Content-Type': 'text/event-stream',
43
+ 'Cache-Control': 'no-cache, no-transform',
44
+ Connection: 'keep-alive',
45
+ 'X-Accel-Buffering': 'no'
46
+ }
47
+ });
48
+ }
49
+
50
+ async send(message) {
51
+ if (!this._controller) return;
52
+ try {
53
+ this._controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(message)}\n\n`));
54
+ } catch (err) {
55
+ this.onerror?.(err);
56
+ }
57
+ }
58
+
59
+ async handlePostMessage(req) {
60
+ let body;
61
+ try {
62
+ body = await req.json();
63
+ } catch (err) {
64
+ this.onerror?.(err);
65
+ return new Response('Invalid JSON', { status: 400 });
66
+ }
67
+ try {
68
+ const parsed = parseJSONRPCMessage(body);
69
+ this.onmessage?.(parsed);
70
+ } catch (err) {
71
+ this.onerror?.(err);
72
+ return new Response('Invalid JSON-RPC message', { status: 400 });
73
+ }
74
+ return new Response('Accepted', { status: 202 });
75
+ }
76
+
77
+ async close() {
78
+ if (!this._controller) return;
79
+ const ctrl = this._controller;
80
+ this._controller = null;
81
+ sseSessions.delete(this.sessionId);
82
+ try {
83
+ ctrl.close();
84
+ } catch {
85
+ // already closed
86
+ }
87
+ this.onclose?.();
88
+ }
89
+ }
90
+
91
+ function createVaultMcpServer() {
92
+ const server = new McpServer({
93
+ name: 'vault-mcp',
94
+ version: '0.1.0'
95
+ });
96
+
97
+ server.registerTool(
98
+ 'list_vault',
99
+ {
100
+ title: 'List Vault Directory',
101
+ description:
102
+ 'List immediate folders and files in a vault directory (like ls). Leave folder empty for root "/", or pass a folder path (e.g. "core", "backups/2026") to drill down.',
103
+ inputSchema: z.object({
104
+ folder: z.string().optional()
105
+ }),
106
+ outputSchema: z.object({
107
+ folder: z.string(),
108
+ folders: z.array(z.string()),
109
+ files: z.array(z.string())
110
+ })
111
+ },
112
+ async ({ folder }) => {
113
+ const { tree } = getCachedVault();
114
+ const cleanFolder = (folder || '').trim().replace(/^\/+|\/+$/g, '');
115
+
116
+ let nodes = tree;
117
+ if (cleanFolder) {
118
+ for (const segment of cleanFolder.split('/')) {
119
+ const dirNode = nodes.find((n) => n.type === 'dir' && n.name === segment);
120
+ if (!dirNode) {
121
+ return {
122
+ isError: true,
123
+ content: [{ type: 'text', text: `Folder not found: ${cleanFolder}` }]
124
+ };
125
+ }
126
+ nodes = dirNode.children || [];
127
+ }
128
+ }
129
+
130
+ const folders = nodes.filter((n) => n.type === 'dir').map((n) => `${n.path}/`);
131
+ const files = nodes.filter((n) => n.type === 'file').map((n) => n.path);
132
+ const lines = [
133
+ ...folders.map((d) => `[DIR] ${d}`),
134
+ ...files.map((f) => `[FILE] ${f}`)
135
+ ];
136
+
137
+ return {
138
+ structuredContent: {
139
+ folder: cleanFolder || '/',
140
+ folders,
141
+ files
142
+ },
143
+ content: [
144
+ {
145
+ type: 'text',
146
+ text: lines.length ? lines.join('\n') : '(empty directory)'
147
+ }
148
+ ]
149
+ };
150
+ }
151
+ );
152
+
153
+ server.registerTool(
154
+ 'read_note',
155
+ {
156
+ title: 'Read Note',
157
+ description: 'Read a file or note from the vault by relative path, including incoming backlinks.',
158
+ inputSchema: z.object({
159
+ path: z.string()
160
+ }),
161
+ outputSchema: z.object({
162
+ path: z.string(),
163
+ backlinks: z.array(z.string()),
164
+ content: z.string()
165
+ })
166
+ },
167
+ async ({ path: relPath }) => {
168
+ const full = resolveSafe(relPath);
169
+ if (!full || !fs.existsSync(full) || !fs.statSync(full).isFile()) {
170
+ return { isError: true, content: [{ type: 'text', text: `Not found: ${relPath}` }] };
171
+ }
172
+ const content = fs.readFileSync(full, 'utf8');
173
+ const { backlinks } = getCachedVault();
174
+ const bl = backlinks[relPath] || [];
175
+ return {
176
+ structuredContent: {
177
+ path: relPath,
178
+ backlinks: bl,
179
+ content
180
+ },
181
+ content: [
182
+ {
183
+ type: 'text',
184
+ text: `# ${relPath}\nBacklinks: ${bl.length ? bl.join(', ') : 'none'}\n\n${content}`
185
+ }
186
+ ]
187
+ };
188
+ }
189
+ );
190
+
191
+ server.registerTool(
192
+ 'search_vault',
193
+ {
194
+ title: 'Search Vault',
195
+ description: 'Search across vault filenames and note contents.',
196
+ inputSchema: z.object({
197
+ query: z.string(),
198
+ limit: z.number().int().min(1).max(50).optional()
199
+ }),
200
+ outputSchema: z.object({
201
+ query: z.string(),
202
+ count: z.number(),
203
+ results: z.array(
204
+ z.object({
205
+ path: z.string(),
206
+ mtimeMs: z.number(),
207
+ snippet: z.string()
208
+ })
209
+ )
210
+ })
211
+ },
212
+ async ({ query, limit }) => {
213
+ const hits = searchFts(query, limit ?? 20).map((h) => ({
214
+ path: h.path,
215
+ mtimeMs: h.mtimeMs,
216
+ snippet: (h.snippet || '').replace(/\s+/g, ' ').trim()
217
+ }));
218
+ const lines = hits.map((h) => (h.snippet ? `${h.path} — ${h.snippet}` : h.path));
219
+ return {
220
+ structuredContent: {
221
+ query,
222
+ count: hits.length,
223
+ results: hits
224
+ },
225
+ content: [
226
+ {
227
+ type: 'text',
228
+ text: lines.length ? lines.join('\n') : `No matches found for: ${query}`
229
+ }
230
+ ]
231
+ };
232
+ }
233
+ );
234
+
235
+ server.registerTool(
236
+ 'write_note',
237
+ {
238
+ title: 'Write or Edit Note',
239
+ description:
240
+ 'Create/overwrite/append a note (with content), or partially edit an existing note (with target + replacement).',
241
+ inputSchema: z.object({
242
+ path: z.string(),
243
+ content: z.string().optional(),
244
+ append: z.boolean().optional(),
245
+ target: z.string().optional(),
246
+ replacement: z.string().optional(),
247
+ replaceAll: z.boolean().optional()
248
+ }),
249
+ outputSchema: z.object({
250
+ ok: z.boolean(),
251
+ path: z.string(),
252
+ mode: z.enum(['write', 'append', 'edit']),
253
+ bytes: z.number().optional(),
254
+ replacements: z.number().optional()
255
+ })
256
+ },
257
+ async ({ path: relPath, content, append, target, replacement, replaceAll }) => {
258
+ if (typeof target === 'string' && typeof replacement === 'string') {
259
+ const result = editNote(relPath, target, replacement, { replaceAll: Boolean(replaceAll) });
260
+ if (result.error) {
261
+ return { isError: true, content: [{ type: 'text', text: result.error }] };
262
+ }
263
+ upsertFileInIndex(result.path);
264
+ return {
265
+ structuredContent: {
266
+ ok: true,
267
+ path: result.path,
268
+ mode: 'edit',
269
+ replacements: result.replacements
270
+ },
271
+ content: [
272
+ {
273
+ type: 'text',
274
+ text: `Edited ${result.path} (${result.replacements} replacement${result.replacements === 1 ? '' : 's'})`
275
+ }
276
+ ]
277
+ };
278
+ }
279
+ if (typeof content !== 'string') {
280
+ return { isError: true, content: [{ type: 'text', text: 'Provide either content or target + replacement' }] };
281
+ }
282
+ const result = writeNote(relPath, content, { append: Boolean(append) });
283
+ if (!result) {
284
+ return { isError: true, content: [{ type: 'text', text: `Invalid or protected path: ${relPath}` }] };
285
+ }
286
+ upsertFileInIndex(result.path);
287
+ return {
288
+ structuredContent: {
289
+ ok: true,
290
+ path: result.path,
291
+ mode: append ? 'append' : 'write',
292
+ bytes: result.bytes
293
+ },
294
+ content: [
295
+ {
296
+ type: 'text',
297
+ text: `${append ? 'Appended' : 'Wrote'} ${result.path} (${result.bytes} bytes)`
298
+ }
299
+ ]
300
+ };
301
+ }
302
+ );
303
+
304
+ server.registerTool(
305
+ 'delete_note',
306
+ {
307
+ title: 'Delete Note',
308
+ description: 'Delete a note/file from the vault by relative path.',
309
+ inputSchema: z.object({
310
+ path: z.string()
311
+ }),
312
+ outputSchema: z.object({
313
+ ok: z.boolean(),
314
+ path: z.string()
315
+ })
316
+ },
317
+ async ({ path: relPath }) => {
318
+ const result = deleteNote(relPath);
319
+ if (!result) {
320
+ return { isError: true, content: [{ type: 'text', text: `Not found or protected path: ${relPath}` }] };
321
+ }
322
+ removeFileFromIndex(result.path);
323
+ return {
324
+ structuredContent: {
325
+ ok: true,
326
+ path: result.path
327
+ },
328
+ content: [{ type: 'text', text: `Deleted ${result.path}` }]
329
+ };
330
+ }
331
+ );
332
+
333
+ return server;
334
+ }
335
+
336
+ const mcpHandler = createMcpHandler(createVaultMcpServer);
337
+
338
+ export async function handleMcpRequest(req) {
339
+ const url = new URL(req.url, 'http://localhost');
340
+ const sessionId = url.searchParams.get('sessionId');
341
+ const isStreamableHttpGet =
342
+ req.headers.has('mcp-protocol-version') || req.headers.has('mcp-session-id');
343
+
344
+ // 1. Legacy HTTP+SSE (2024-11-05): GET /mcp (without Streamable HTTP headers) establishes SSE stream
345
+ if (req.method === 'GET' && !sessionId && !isStreamableHttpGet) {
346
+ const transport = new WebStandardSSEServerTransport(url.pathname);
347
+ sseSessions.set(transport.sessionId, transport);
348
+ const server = createVaultMcpServer();
349
+ await server.connect(transport);
350
+ return transport.createResponse(req.signal);
351
+ }
352
+
353
+ // 2. Legacy HTTP+SSE: POST /mcp?sessionId=<uuid> delivers JSON-RPC message to active SSE session
354
+ if (req.method === 'POST' && sessionId) {
355
+ const transport = sseSessions.get(sessionId);
356
+ if (!transport) {
357
+ return new Response('Session not found', { status: 404 });
358
+ }
359
+ return transport.handlePostMessage(req);
360
+ }
361
+
362
+ // 3. Official MCP v2 handler: supports BOTH Modern (2026-07-28 server/discover) and Legacy Streamable HTTP (2025-11-25 initialize)
363
+ return mcpHandler.fetch(req);
364
+ }
@@ -0,0 +1,53 @@
1
+ import path from 'node:path';
2
+
3
+ export const VAULT_ROOT = path.resolve(
4
+ process.env.PAGEDEX_ROOT || process.env.VAULT_ROOT || process.cwd()
5
+ );
6
+
7
+ export const IGNORED = new Set([
8
+ '.git',
9
+ '.gitignore',
10
+ 'node_modules',
11
+ 'package.json',
12
+ 'pnpm-lock.yaml',
13
+ 'pnpm-workspace.yaml',
14
+ '.venv',
15
+ '__pycache__',
16
+ '.obsidian',
17
+ '.DS_Store',
18
+ 'dist',
19
+ '.vault-index.db',
20
+ '.vault-index.db-wal',
21
+ '.vault-index.db-shm',
22
+ '.pagedex-index.db',
23
+ '.pagedex-index.db-wal',
24
+ '.pagedex-index.db-shm'
25
+ ]);
26
+
27
+ export const IMAGE_MIME = {
28
+ '.png': 'image/png',
29
+ '.jpg': 'image/jpeg',
30
+ '.jpeg': 'image/jpeg',
31
+ '.gif': 'image/gif',
32
+ '.webp': 'image/webp',
33
+ '.svg': 'image/svg+xml',
34
+ '.ico': 'image/x-icon'
35
+ };
36
+
37
+ export function isImagePath(p) {
38
+ return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(p);
39
+ }
40
+
41
+ export function resolveSafe(relPath) {
42
+ const cleaned = String(relPath || '').replace(/^\/+/, '');
43
+ const resolved = path.resolve(VAULT_ROOT, cleaned);
44
+ if (resolved !== VAULT_ROOT && !resolved.startsWith(VAULT_ROOT + path.sep)) {
45
+ return null;
46
+ }
47
+ return resolved;
48
+ }
49
+
50
+ export function isProtectedPath(relPath) {
51
+ const parts = String(relPath || '').replace(/^\/+/, '').split('/');
52
+ return parts.some((p) => p === '.git' || p === 'node_modules' || p === 'dist');
53
+ }
@@ -0,0 +1,55 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { streamSSE } from 'hono/streaming';
4
+ import { IGNORED, VAULT_ROOT, invalidateVaultCache, updateVaultCacheFile } from '../vault.js';
5
+ import { upsertFileInIndex } from '../search-index.js';
6
+
7
+ const subscribers = new Set();
8
+ let watchTimer = null;
9
+ let lastChanged = '';
10
+
11
+ export function startVaultWatcher() {
12
+ try {
13
+ fs.watch(VAULT_ROOT, { recursive: true }, (_event, filename) => {
14
+ if (filename) {
15
+ const parts = filename.split(path.sep);
16
+ if (parts.some((p) => IGNORED.has(p))) return;
17
+ lastChanged = parts.join('/');
18
+ updateVaultCacheFile(lastChanged);
19
+ try {
20
+ upsertFileInIndex(lastChanged);
21
+ } catch {
22
+ // Ignore transient watch update error
23
+ }
24
+ } else {
25
+ invalidateVaultCache();
26
+ }
27
+ clearTimeout(watchTimer);
28
+ watchTimer = setTimeout(() => {
29
+ for (const send of subscribers) send(lastChanged);
30
+ }, 100);
31
+ });
32
+ } catch (err) {
33
+ console.warn('fs.watch warning:', err.message);
34
+ }
35
+ }
36
+
37
+ export function registerEventRoutes(app) {
38
+ app.get('/api/v1/events', (c) => {
39
+ return streamSSE(c, async (stream) => {
40
+ await stream.writeSSE({ data: JSON.stringify({ type: 'connected' }) });
41
+ const notify = (changedPath) => {
42
+ stream.writeSSE({ data: JSON.stringify({ type: 'reload', path: changedPath }) }).catch(() => {});
43
+ };
44
+ subscribers.add(notify);
45
+ const keepAlive = setInterval(() => {
46
+ stream.writeSSE({ data: JSON.stringify({ type: 'ping' }) }).catch(() => {});
47
+ }, 25000);
48
+ await new Promise((resolve) => {
49
+ c.req.raw.signal.addEventListener('abort', resolve, { once: true });
50
+ });
51
+ clearInterval(keepAlive);
52
+ subscribers.delete(notify);
53
+ });
54
+ });
55
+ }
@@ -0,0 +1,166 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ IMAGE_MIME,
5
+ VAULT_ROOT,
6
+ deleteNote,
7
+ editNote,
8
+ getCachedVault,
9
+ renameDir,
10
+ renameNote,
11
+ resolveSafe,
12
+ sortTree,
13
+ writeBinaryFile,
14
+ writeNote
15
+ } from '../vault.js';
16
+ import {
17
+ queryFiles,
18
+ removeFileFromIndex,
19
+ searchFts,
20
+ syncIndex,
21
+ upsertFileInIndex
22
+ } from '../search-index.js';
23
+
24
+ export function registerFileRoutes(app) {
25
+ app.get('/api/v1/health', (c) => c.json({ ok: true, root: VAULT_ROOT }));
26
+
27
+ app.get('/api/v1/tree', (c) => {
28
+ const rawLimit = Number(c.req.query('limit') || 10000);
29
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 10000) : 10000;
30
+ const sort = c.req.query('sort'); // 'mtime' | 'name' | undefined
31
+ const { tree, flatFiles, fileStats, sortedStats, backlinks } = getCachedVault();
32
+ const outTree = sort === 'mtime' ? sortTree(tree, 'mtime') : tree;
33
+ const outStats =
34
+ sort === 'name'
35
+ ? [...fileStats].sort((a, b) => a.path.localeCompare(b.path)).slice(0, limit)
36
+ : sortedStats.slice(0, limit);
37
+ return c.json({
38
+ tree: outTree,
39
+ flatFiles,
40
+ fileStats: outStats,
41
+ totalFiles: sortedStats.length,
42
+ truncated: sortedStats.length > limit,
43
+ backlinks
44
+ });
45
+ });
46
+
47
+ app.get('/api/v1/files', (c) => {
48
+ const sort = c.req.query('sort') === 'name' ? 'name' : 'mtime';
49
+ const offset = Number(c.req.query('offset') || 0);
50
+ const limit = Number(c.req.query('limit') || 100);
51
+ const q = c.req.query('q') || '';
52
+ return c.json(queryFiles({ sort, offset, limit, q }));
53
+ });
54
+
55
+ app.get('/api/v1/search', (c) => {
56
+ const q = c.req.query('q') || '';
57
+ const limit = Number(c.req.query('limit') || 50);
58
+ return c.json({ query: q, results: searchFts(q, limit) });
59
+ });
60
+
61
+ app.get('/api/v1/file', (c) => {
62
+ const rel = c.req.query('path') || '';
63
+ const full = resolveSafe(rel);
64
+ if (!full || !fs.existsSync(full) || !fs.statSync(full).isFile()) {
65
+ return c.text('Not found', 404);
66
+ }
67
+ const ext = path.extname(full).toLowerCase();
68
+ const isDownload = c.req.query('download') === '1';
69
+ const contentType = IMAGE_MIME[ext] || (isDownload ? 'application/octet-stream' : 'text/plain; charset=utf-8');
70
+ const buf = fs.readFileSync(full);
71
+ const headers = { 'Content-Type': contentType };
72
+ if (isDownload) {
73
+ const fileName = path.basename(full).replace(/"/g, '');
74
+ headers['Content-Disposition'] = `attachment; filename="${fileName}"`;
75
+ }
76
+ return new Response(buf, { status: 200, headers });
77
+ });
78
+
79
+ async function handleWriteOrEdit(c) {
80
+ const body = await c.req.json().catch(() => null);
81
+ if (!body || typeof body.path !== 'string') {
82
+ return c.json({ error: 'Expected JSON with path' }, 400);
83
+ }
84
+ if (typeof body.target === 'string' && typeof body.replacement === 'string') {
85
+ const result = editNote(body.path, body.target, body.replacement, {
86
+ replaceAll: Boolean(body.replaceAll)
87
+ });
88
+ if (result.error) return c.json(result, 400);
89
+ upsertFileInIndex(result.path);
90
+ return c.json(result);
91
+ }
92
+ if (typeof body.content === 'string') {
93
+ const result = writeNote(body.path, body.content, { append: Boolean(body.append) });
94
+ if (!result) return c.json({ error: 'Invalid or protected path' }, 400);
95
+ upsertFileInIndex(result.path);
96
+ return c.json(result);
97
+ }
98
+ return c.json({ error: 'Provide either { content } or { target, replacement }' }, 400);
99
+ }
100
+
101
+ app.put('/api/v1/file', handleWriteOrEdit);
102
+ app.post('/api/v1/file', handleWriteOrEdit);
103
+ app.patch('/api/v1/file', handleWriteOrEdit);
104
+
105
+ app.delete('/api/v1/file', (c) => {
106
+ const rel = c.req.query('path') || '';
107
+ const result = deleteNote(rel);
108
+ if (!result) return c.json({ error: 'Not found or protected path' }, 404);
109
+ removeFileFromIndex(result.path);
110
+ return c.json(result);
111
+ });
112
+
113
+ app.post('/api/v1/file/rename', async (c) => {
114
+ const body = await c.req.json().catch(() => null);
115
+ if (!body || typeof body.oldPath !== 'string' || typeof body.newPath !== 'string') {
116
+ return c.json({ error: 'Expected JSON { oldPath, newPath }' }, 400);
117
+ }
118
+ const result = renameNote(body.oldPath, body.newPath);
119
+ if (result.error) return c.json(result, 400);
120
+ removeFileFromIndex(result.oldPath);
121
+ upsertFileInIndex(result.newPath);
122
+ return c.json(result);
123
+ });
124
+
125
+ app.post('/api/v1/dir/rename', async (c) => {
126
+ const body = await c.req.json().catch(() => null);
127
+ if (!body || typeof body.oldPath !== 'string' || typeof body.newPath !== 'string') {
128
+ return c.json({ error: 'Expected JSON { oldPath, newPath }' }, 400);
129
+ }
130
+ const result = renameDir(body.oldPath, body.newPath);
131
+ if (result.error) return c.json(result, 400);
132
+ syncIndex();
133
+ return c.json(result);
134
+ });
135
+
136
+ app.post('/api/v1/file/upload', async (c) => {
137
+ const body = await c.req.parseBody({ all: true }).catch(() => null);
138
+ if (!body) {
139
+ return c.json({ error: 'Expected multipart/form-data' }, 400);
140
+ }
141
+ const dir = typeof body.dir === 'string' ? body.dir.trim().replace(/^\/+|\/+$/g, '') : '';
142
+ const rawFiles = body.files ?? body.file;
143
+ const list = (Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : []).filter(
144
+ (item) => item && typeof item === 'object' && typeof item.arrayBuffer === 'function' && item.name
145
+ );
146
+ if (list.length === 0) {
147
+ return c.json({ error: 'No files provided' }, 400);
148
+ }
149
+ const uploaded = [];
150
+ for (const file of list) {
151
+ const safeName = path.basename(String(file.name));
152
+ if (!safeName || safeName === '.' || safeName === '..') {
153
+ return c.json({ error: 'Invalid filename' }, 400);
154
+ }
155
+ const relPath = dir ? `${dir}/${safeName}` : safeName;
156
+ const buf = Buffer.from(await file.arrayBuffer());
157
+ const result = writeBinaryFile(relPath, buf);
158
+ if (!result) {
159
+ return c.json({ error: `Invalid or protected path: ${relPath}` }, 400);
160
+ }
161
+ upsertFileInIndex(result.path);
162
+ uploaded.push(result);
163
+ }
164
+ return c.json({ ok: true, uploaded });
165
+ });
166
+ }
@@ -0,0 +1,40 @@
1
+ import { discardGitChange, getGitChanges, getGitDiff, gitCommitAndPush } from '../vault.js';
2
+ import { upsertFileInIndex } from '../search-index.js';
3
+
4
+ export function registerGitRoutes(app) {
5
+ app.get('/api/v1/changes', (c) => {
6
+ return c.json(getGitChanges());
7
+ });
8
+
9
+ app.get('/api/v1/git/diff', (c) => {
10
+ const rel = c.req.query('path') || '';
11
+ const result = getGitDiff(rel);
12
+ if (result.error) return c.json(result, 400);
13
+ return c.json(result);
14
+ });
15
+
16
+ app.post('/api/v1/git/discard', async (c) => {
17
+ const body = await c.req.json().catch(() => null);
18
+ if (!body || typeof body.path !== 'string') {
19
+ return c.json({ error: 'Expected JSON { path, hunkIndex?, lineIndex? }' }, 400);
20
+ }
21
+ const result = discardGitChange(body);
22
+ if (result.error) return c.json(result, 400);
23
+ upsertFileInIndex(body.path);
24
+ return c.json(result);
25
+ });
26
+
27
+ app.post('/api/v1/git/commit', async (c) => {
28
+ const body = await c.req.json().catch(() => null);
29
+ if (!body || typeof body.message !== 'string' || !body.message.trim()) {
30
+ return c.json({ error: 'Commit message is required' }, 400);
31
+ }
32
+ const result = gitCommitAndPush({
33
+ message: body.message,
34
+ files: body.files,
35
+ push: Boolean(body.push)
36
+ });
37
+ if (result.error) return c.json(result, 400);
38
+ return c.json(result);
39
+ });
40
+ }