@karmaniverous/jeeves-server 3.4.2 → 3.5.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.
Files changed (127) hide show
  1. package/.tsbuildinfo +1 -1
  2. package/CHANGELOG.md +38 -1
  3. package/README.md +18 -17
  4. package/client/package.json +19 -19
  5. package/client/src/components/SearchModal.tsx +11 -1
  6. package/client/src/components/layout/Header.tsx +3 -3
  7. package/client/src/lib/api.ts +10 -5
  8. package/dist/client/assets/CodeEditor-Brh86AGF.js +1 -0
  9. package/dist/client/assets/CodeViewer-Cegj3cEn.js +1 -0
  10. package/dist/client/assets/dist-2YqVIvgv.js +2 -0
  11. package/dist/client/assets/dist-5vamY028.js +1 -0
  12. package/dist/client/assets/dist-6_auAGci.js +1 -0
  13. package/dist/client/assets/dist-B0kq1DQG.js +1 -0
  14. package/dist/client/assets/dist-B2SZD_eN.js +1 -0
  15. package/dist/client/assets/dist-B2t4dYA2.js +1 -0
  16. package/dist/client/assets/dist-B5gFYAn7.js +1 -0
  17. package/dist/client/assets/dist-BPy6CnYN.js +1 -0
  18. package/dist/client/assets/dist-CL6VCrQn.js +9 -0
  19. package/dist/client/assets/dist-CWsHar9N.js +1 -0
  20. package/dist/client/assets/dist-CnFc5Ssx.js +1 -0
  21. package/dist/client/assets/dist-DSgLBuTS.js +1 -0
  22. package/dist/client/assets/dist-DUcac0X_.js +7 -0
  23. package/dist/client/assets/dist-DcTcc-BG.js +6 -0
  24. package/dist/client/assets/dist-DvfTyWk_.js +1 -0
  25. package/dist/client/assets/dist-Dz1Ulpqa.js +1 -0
  26. package/dist/client/assets/dist-Kr-mUYW1.js +5 -0
  27. package/dist/client/assets/dist-OX4k3MMG.js +2 -0
  28. package/dist/client/assets/dist-qiU0qoeK.js +1 -0
  29. package/dist/client/assets/dist-ui4J6fvl.js +23 -0
  30. package/dist/client/assets/index-Dk_myGs4.css +2 -0
  31. package/dist/client/assets/index-DrBXupPz.js +62 -0
  32. package/dist/client/assets/theme-CPpIxvB0.js +2 -0
  33. package/dist/client/index.html +3 -2
  34. package/dist/src/cli/commands/config.test.js +5 -40
  35. package/dist/src/cli/index.js +9 -15
  36. package/dist/src/cli/start-server.js +16 -0
  37. package/dist/src/config/index.js +48 -37
  38. package/dist/src/config/loadConfig.test.js +27 -25
  39. package/dist/src/config/migration.js +60 -0
  40. package/dist/src/config/schema.js +4 -3
  41. package/dist/src/descriptor.js +46 -0
  42. package/dist/src/routes/api/diagramExport.js +101 -0
  43. package/dist/src/routes/api/diagramExport.test.js +134 -0
  44. package/dist/src/routes/api/events.js +13 -0
  45. package/dist/src/routes/api/export.js +6 -82
  46. package/dist/src/routes/api/index.js +4 -0
  47. package/dist/src/routes/api/search.js +9 -50
  48. package/dist/src/routes/api/sharing.js +40 -23
  49. package/dist/src/routes/api/sharing.test.js +52 -0
  50. package/dist/src/routes/auth.js +1 -1
  51. package/dist/src/routes/config.js +8 -2
  52. package/dist/src/routes/keys.js +4 -4
  53. package/dist/src/routes/path/index.js +1 -1
  54. package/dist/src/routes/status.js +15 -16
  55. package/dist/src/routes/status.test.js +13 -8
  56. package/dist/src/server.js +21 -16
  57. package/dist/src/services/markdown.js +2 -1
  58. package/dist/src/services/markdown.test.js +22 -0
  59. package/dist/src/util/packageVersion.js +7 -16
  60. package/dist/src/util/packageVersion.test.js +7 -0
  61. package/guides/api-integration.md +4 -0
  62. package/guides/deployment.md +11 -10
  63. package/guides/event-gateway.md +4 -0
  64. package/guides/exports.md +4 -0
  65. package/guides/index.md +1 -1
  66. package/guides/setup.md +17 -16
  67. package/guides/sharing.md +4 -0
  68. package/package.json +3 -3
  69. package/scripts/download-plantuml.js +0 -1
  70. package/src/cli/commands/config.test.ts +5 -45
  71. package/src/cli/index.ts +9 -16
  72. package/src/cli/start-server.ts +21 -0
  73. package/src/config/index.ts +56 -43
  74. package/src/config/loadConfig.test.ts +27 -29
  75. package/src/config/migration.ts +76 -0
  76. package/src/config/schema.ts +5 -4
  77. package/src/descriptor.ts +55 -0
  78. package/src/routes/api/diagramExport.test.ts +200 -0
  79. package/src/routes/api/diagramExport.ts +170 -0
  80. package/src/routes/api/events.ts +22 -0
  81. package/src/routes/api/export.ts +6 -131
  82. package/src/routes/api/index.ts +4 -0
  83. package/src/routes/api/search.ts +9 -63
  84. package/src/routes/api/sharing.test.ts +66 -0
  85. package/src/routes/api/sharing.ts +47 -23
  86. package/src/routes/auth.ts +1 -1
  87. package/src/routes/config.ts +15 -2
  88. package/src/routes/keys.ts +4 -4
  89. package/src/routes/path/index.ts +1 -1
  90. package/src/routes/status.test.ts +14 -8
  91. package/src/routes/status.ts +56 -62
  92. package/src/server.ts +29 -17
  93. package/src/services/markdown.test.ts +26 -0
  94. package/src/services/markdown.ts +2 -1
  95. package/src/util/packageVersion.test.ts +9 -0
  96. package/src/util/packageVersion.ts +11 -18
  97. package/src/util/platform.ts +1 -1
  98. package/dist/client/assets/CodeEditor-DQZZL5Rq.js +0 -1
  99. package/dist/client/assets/CodeViewer-ofJVD1Vn.js +0 -1
  100. package/dist/client/assets/index--MBieNJA.js +0 -1
  101. package/dist/client/assets/index-BENeXQI_.js +0 -1
  102. package/dist/client/assets/index-BbBpoOxz.js +0 -1
  103. package/dist/client/assets/index-BdV9g5AM.js +0 -6
  104. package/dist/client/assets/index-BjAilRri.js +0 -2
  105. package/dist/client/assets/index-BqbhWo2I.js +0 -3
  106. package/dist/client/assets/index-CVbycZ0H.js +0 -1
  107. package/dist/client/assets/index-Cs5oz2oJ.js +0 -5
  108. package/dist/client/assets/index-D-RC7ZS6.css +0 -1
  109. package/dist/client/assets/index-D8KZVveX.js +0 -1
  110. package/dist/client/assets/index-DC4HMHxY.js +0 -13
  111. package/dist/client/assets/index-DcY2RXqX.js +0 -1
  112. package/dist/client/assets/index-Duy-tZYV.js +0 -1
  113. package/dist/client/assets/index-Dw7rDFmE.js +0 -7
  114. package/dist/client/assets/index-FlCUvrjv.js +0 -2
  115. package/dist/client/assets/index-K6OVmfhg.js +0 -1
  116. package/dist/client/assets/index-MLwyFRN0.js +0 -1
  117. package/dist/client/assets/index-OpqBpSjn.js +0 -1
  118. package/dist/client/assets/index-SsHei0HE.js +0 -1
  119. package/dist/client/assets/index-jSGuHSeS.js +0 -62
  120. package/dist/client/assets/index-uQa2yckk.js +0 -1
  121. package/dist/client/assets/index-udkXoIER.js +0 -1
  122. package/dist/src/cli/commands/config.js +0 -105
  123. package/dist/src/cli/commands/service.js +0 -93
  124. package/dist/src/cli/commands/start.js +0 -24
  125. package/src/cli/commands/config.ts +0 -117
  126. package/src/cli/commands/service.ts +0 -129
  127. package/src/cli/commands/start.ts +0 -27
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Diagram export routes — standalone Mermaid and PlantUML file rendering.
3
+ *
4
+ * Extracted from export.ts to maintain single responsibility and stay within
5
+ * the 300 LOC limit. Both handlers share a common cache-then-render pattern
6
+ * via `serveDiagramExport()`.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ import type { FastifyPluginAsync, FastifyReply } from 'fastify';
15
+
16
+ import { getConfig } from '../../config/index.js';
17
+ import {
18
+ cacheDiagramBuffer,
19
+ getCachedDiagramBuffer,
20
+ } from '../../services/diagramCache.js';
21
+ import { renderMermaidToFile } from '../../services/mermaid.js';
22
+ import {
23
+ getPlantUmlFormats,
24
+ renderPlantUmlToBuffer,
25
+ } from '../../services/plantuml.js';
26
+ import { DIAGRAM_CONTENT_TYPES } from '../../util/fileDetection.js';
27
+ import { getRoots, type RootEntry, urlPathToFs } from '../../util/platform.js';
28
+
29
+ /** Diagram engine identifier for cache keys. */
30
+ type DiagramEngine = 'mermaid' | 'plantuml';
31
+
32
+ /** Resolve a URL path to a validated filesystem path, or send an error reply. */
33
+ function resolveExportPath(
34
+ reqPath: string | undefined,
35
+ roots: RootEntry[],
36
+ reply: FastifyReply,
37
+ ): string | null {
38
+ if (!reqPath) {
39
+ void reply.code(400).send({ error: 'Path required' });
40
+ return null;
41
+ }
42
+ const fsPath = urlPathToFs(reqPath, roots);
43
+ if (!fsPath) {
44
+ void reply.code(404).send({ error: 'Invalid path' });
45
+ return null;
46
+ }
47
+ return path.resolve(fsPath);
48
+ }
49
+
50
+ /** Send a diagram buffer with appropriate content-type and disposition. */
51
+ function sendDiagram(
52
+ reply: FastifyReply,
53
+ buffer: Buffer | Uint8Array,
54
+ format: string,
55
+ downloadName: string,
56
+ ): void {
57
+ void reply
58
+ .header(
59
+ 'Content-Type',
60
+ DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
61
+ )
62
+ .header('Content-Disposition', `attachment; filename="${downloadName}"`)
63
+ .send(buffer);
64
+ }
65
+
66
+ /**
67
+ * Serve a diagram export with cache-first semantics.
68
+ *
69
+ * Checks the diagram cache, renders on miss, caches the result, and sends
70
+ * the response. This eliminates the duplicated cache-then-render pattern
71
+ * between the Mermaid and PlantUML handlers.
72
+ */
73
+ async function serveDiagramExport(
74
+ reply: FastifyReply,
75
+ engine: DiagramEngine,
76
+ resolved: string,
77
+ ext: string,
78
+ format: string,
79
+ render: (resolved: string, format: string) => Promise<Buffer | null>,
80
+ ): Promise<void> {
81
+ const source = fs.readFileSync(resolved, 'utf8');
82
+ const baseName = path.basename(resolved, ext);
83
+ const downloadName = `${baseName}.${format}`;
84
+
85
+ const cached = getCachedDiagramBuffer(engine, source, format);
86
+ if (cached) {
87
+ sendDiagram(reply, cached, format, downloadName);
88
+ return;
89
+ }
90
+
91
+ const buffer = await render(resolved, format);
92
+ if (!buffer) {
93
+ void reply.code(500).send({
94
+ error: `${engine.charAt(0).toUpperCase() + engine.slice(1)} render failed`,
95
+ });
96
+ return;
97
+ }
98
+
99
+ cacheDiagramBuffer(engine, source, buffer, format);
100
+ sendDiagram(reply, buffer, format, downloadName);
101
+ }
102
+
103
+ // eslint-disable-next-line @typescript-eslint/require-await
104
+ export const diagramExportRoutes: FastifyPluginAsync = async (fastify) => {
105
+ const roots = getRoots(getConfig().roots);
106
+
107
+ // GET /api/mermaid-export/*
108
+ fastify.get<{ Params: { '*': string }; Querystring: { format?: string } }>(
109
+ '/api/mermaid-export/*',
110
+ async (request, reply) => {
111
+ const resolved = resolveExportPath(request.params['*'], roots, reply);
112
+ if (!resolved) return;
113
+
114
+ if (
115
+ !fs.existsSync(resolved) ||
116
+ !resolved.toLowerCase().endsWith('.mmd')
117
+ ) {
118
+ return reply.code(404).send({ error: 'Mermaid file not found' });
119
+ }
120
+
121
+ const mermaidFormats = ['svg', 'png', 'pdf'];
122
+ const format = mermaidFormats.includes(request.query.format ?? '')
123
+ ? request.query.format!
124
+ : 'svg';
125
+
126
+ await serveDiagramExport(
127
+ reply,
128
+ 'mermaid',
129
+ resolved,
130
+ '.mmd',
131
+ format,
132
+ async (filePath, fmt) => {
133
+ const outFile = await renderMermaidToFile(filePath, fmt);
134
+ return outFile ? fs.readFileSync(outFile) : null;
135
+ },
136
+ );
137
+ },
138
+ );
139
+
140
+ // GET /api/plantuml-export/*
141
+ fastify.get<{ Params: { '*': string }; Querystring: { format?: string } }>(
142
+ '/api/plantuml-export/*',
143
+ async (request, reply) => {
144
+ const resolved = resolveExportPath(request.params['*'], roots, reply);
145
+ if (!resolved) return;
146
+
147
+ const ext = path.extname(resolved).toLowerCase();
148
+ if (
149
+ !fs.existsSync(resolved) ||
150
+ !['.puml', '.plantuml', '.pu'].includes(ext)
151
+ ) {
152
+ return reply.code(404).send({ error: 'PlantUML file not found' });
153
+ }
154
+
155
+ const supported = getPlantUmlFormats();
156
+ const format = supported.includes(request.query.format ?? '')
157
+ ? request.query.format!
158
+ : 'svg';
159
+
160
+ await serveDiagramExport(
161
+ reply,
162
+ 'plantuml',
163
+ resolved,
164
+ ext,
165
+ format,
166
+ renderPlantUmlToBuffer,
167
+ );
168
+ },
169
+ );
170
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * GET /api/events — returns recent event log entries.
3
+ */
4
+
5
+ import type { FastifyPluginAsync } from 'fastify';
6
+
7
+ import { getRecentEvents } from '../../services/eventLog.js';
8
+
9
+ // eslint-disable-next-line @typescript-eslint/require-await
10
+ export const eventsRoutes: FastifyPluginAsync = async (fastify) => {
11
+ fastify.get<{ Querystring: { limit?: string } }>(
12
+ '/events',
13
+ // eslint-disable-next-line @typescript-eslint/require-await
14
+ async (request) => {
15
+ const limit = Math.min(
16
+ Math.max(parseInt(request.query.limit ?? '20', 10) || 20, 1),
17
+ 100,
18
+ );
19
+ return getRecentEvents(limit);
20
+ },
21
+ );
22
+ };
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Export API routes.
2
+ * Document and directory export routes.
3
3
  *
4
- * Handles: /api/export/*, /api/mermaid-export/*, /api/plantuml-export/*
4
+ * Handles: /api/export/* (PDF/DOCX/ZIP), /api/export-cache/* (cache clearing).
5
+ * Diagram export routes (Mermaid/PlantUML) are in diagramExport.ts.
6
+ *
7
+ * @packageDocumentation
5
8
  */
6
9
 
7
10
  import fs from 'node:fs';
@@ -11,10 +14,6 @@ import archiver from 'archiver';
11
14
  import type { FastifyPluginAsync } from 'fastify';
12
15
 
13
16
  import { getConfig } from '../../config/index.js';
14
- import {
15
- cacheDiagramBuffer,
16
- getCachedDiagramBuffer,
17
- } from '../../services/diagramCache.js';
18
17
  import { appendEvent } from '../../services/eventQueue.js';
19
18
  import { type ExportFormat, exportPage } from '../../services/export.js';
20
19
  import {
@@ -24,12 +23,6 @@ import {
24
23
  clearStandaloneDiagramCache,
25
24
  getCachedExport,
26
25
  } from '../../services/exportCache.js';
27
- import { renderMermaidToFile } from '../../services/mermaid.js';
28
- import {
29
- getPlantUmlFormats,
30
- renderPlantUmlToBuffer,
31
- } from '../../services/plantuml.js';
32
- import { DIAGRAM_CONTENT_TYPES } from '../../util/fileDetection.js';
33
26
  import { getDirSize, getRoots, urlPathToFs } from '../../util/platform.js';
34
27
 
35
28
  // eslint-disable-next-line @typescript-eslint/require-await
@@ -109,7 +102,7 @@ export const exportRoutes: FastifyPluginAsync = async (fastify) => {
109
102
  .code(500)
110
103
  .send({ error: 'Export unavailable — no internal key configured' });
111
104
 
112
- const exportUrl = `http://localhost:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
105
+ const exportUrl = `http://127.0.0.1:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
113
106
  const fileName = path.basename(resolved);
114
107
  const baseName = fileName.replace(/\.md$/i, '');
115
108
 
@@ -193,122 +186,4 @@ export const exportRoutes: FastifyPluginAsync = async (fastify) => {
193
186
  });
194
187
  },
195
188
  );
196
-
197
- // GET /api/mermaid-export/*
198
- fastify.get<{ Params: { '*': string }; Querystring: { format?: string } }>(
199
- '/api/mermaid-export/*',
200
- async (request, reply) => {
201
- const reqPath = request.params['*'];
202
- if (!reqPath) return reply.code(400).send({ error: 'Path required' });
203
-
204
- const mmdFsPath = urlPathToFs(reqPath, roots);
205
- if (!mmdFsPath) return reply.code(404).send({ error: 'Invalid path' });
206
- const resolved = path.resolve(mmdFsPath);
207
-
208
- if (
209
- !fs.existsSync(resolved) ||
210
- !resolved.toLowerCase().endsWith('.mmd')
211
- ) {
212
- return reply.code(404).send({ error: 'Mermaid file not found' });
213
- }
214
-
215
- const mermaidFormats = ['svg', 'png', 'pdf'];
216
- const format = mermaidFormats.includes(request.query.format ?? '')
217
- ? request.query.format!
218
- : 'svg';
219
- const source = fs.readFileSync(resolved, 'utf8');
220
-
221
- // Check cache
222
- const cachedBuffer = getCachedDiagramBuffer('mermaid', source, format);
223
- if (cachedBuffer) {
224
- const downloadName = `${path.basename(resolved, '.mmd')}.${format}`;
225
- return reply
226
- .header(
227
- 'Content-Type',
228
- DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
229
- )
230
- .header(
231
- 'Content-Disposition',
232
- `attachment; filename="${downloadName}"`,
233
- )
234
- .send(cachedBuffer);
235
- }
236
-
237
- const outFile = await renderMermaidToFile(resolved, format);
238
- if (!outFile)
239
- return reply.code(500).send({ error: 'Mermaid render failed' });
240
-
241
- const content = fs.readFileSync(outFile);
242
- cacheDiagramBuffer('mermaid', source, content, format);
243
- const downloadName = path.basename(outFile);
244
-
245
- return reply
246
- .header(
247
- 'Content-Type',
248
- DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
249
- )
250
- .header('Content-Disposition', `attachment; filename="${downloadName}"`)
251
- .send(content);
252
- },
253
- );
254
-
255
- // GET /api/plantuml-export/*
256
- fastify.get<{ Params: { '*': string }; Querystring: { format?: string } }>(
257
- '/api/plantuml-export/*',
258
- async (request, reply) => {
259
- const reqPath = request.params['*'];
260
- if (!reqPath) return reply.code(400).send({ error: 'Path required' });
261
-
262
- const pumlFsPath = urlPathToFs(reqPath, roots);
263
- if (!pumlFsPath) return reply.code(404).send({ error: 'Invalid path' });
264
- const resolved = path.resolve(pumlFsPath);
265
-
266
- const ext = path.extname(resolved).toLowerCase();
267
- if (
268
- !fs.existsSync(resolved) ||
269
- !['.puml', '.plantuml', '.pu'].includes(ext)
270
- ) {
271
- return reply.code(404).send({ error: 'PlantUML file not found' });
272
- }
273
-
274
- const supported = getPlantUmlFormats();
275
- const format = supported.includes(request.query.format ?? '')
276
- ? request.query.format!
277
- : 'svg';
278
- const source = fs.readFileSync(resolved, 'utf8');
279
-
280
- const cachedBuffer = getCachedDiagramBuffer('plantuml', source, format);
281
- if (cachedBuffer) {
282
- const baseName = path.basename(resolved, ext);
283
- return reply
284
- .header(
285
- 'Content-Type',
286
- DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
287
- )
288
- .header(
289
- 'Content-Disposition',
290
- `attachment; filename="${baseName}.${format}"`,
291
- )
292
- .send(cachedBuffer);
293
- }
294
-
295
- const buffer = await renderPlantUmlToBuffer(resolved, format);
296
- if (!buffer)
297
- return reply.code(500).send({ error: 'PlantUML render failed' });
298
-
299
- cacheDiagramBuffer('plantuml', source, buffer, format);
300
- const baseName = path.basename(resolved, ext);
301
-
302
- return reply
303
- .header(
304
- 'Content-Type',
305
- DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream',
306
- )
307
- .header(
308
- 'Content-Disposition',
309
- `attachment; filename="${baseName}.${format}"`,
310
- )
311
- .send(buffer);
312
- },
313
- );
314
189
  };
@@ -5,9 +5,11 @@
5
5
  import type { FastifyPluginAsync } from 'fastify';
6
6
 
7
7
  import { authStatusRoutes } from './auth-status.js';
8
+ import { diagramExportRoutes } from './diagramExport.js';
8
9
  import { diagramsRoutes } from './diagrams.js';
9
10
  import { directoryRoutes } from './directory.js';
10
11
  import { drivesRoutes } from './drives.js';
12
+ import { eventsRoutes } from './events.js';
11
13
  import { exportRoutes } from './export.js';
12
14
  import { fileContentRoutes } from './fileContent.js';
13
15
  import { linkInfoRoutes } from './linkInfo.js';
@@ -26,9 +28,11 @@ export const apiRoute: FastifyPluginAsync = async (fastify) => {
26
28
  await fastify.register(linkInfoRoutes);
27
29
  await fastify.register(rawRoutes);
28
30
  await fastify.register(exportRoutes);
31
+ await fastify.register(diagramExportRoutes);
29
32
  await fastify.register(diagramsRoutes);
30
33
  await fastify.register(runnerRoutes);
31
34
  await fastify.register(searchRoutes);
32
35
  await fastify.register(sharingRoutes);
33
36
  await fastify.register(authStatusRoutes);
37
+ await fastify.register(eventsRoutes);
34
38
  };
@@ -1,18 +1,20 @@
1
1
  /**
2
2
  * Search API route — proxies to jeeves-watcher for semantic search.
3
3
  *
4
- * Handles: POST /api/search
4
+ * Handles: POST /api/search, GET /api/search/facets.
5
5
  * Insider-only. Results filtered by insider's scope.
6
+ *
7
+ * @packageDocumentation
6
8
  */
7
9
 
8
10
  import { stat } from 'node:fs/promises';
9
- import path from 'node:path';
10
11
 
11
12
  import type { FastifyPluginAsync } from 'fastify';
12
13
  import picomatch from 'picomatch';
13
14
 
14
15
  import { getConfig } from '../../config/index.js';
15
16
  import type { NormalizedScopes } from '../../config/types.js';
17
+ import { fsPathToUrl, getRoots, urlPathToFs } from '../../util/platform.js';
16
18
 
17
19
  /** Check if a path passes the insider's scope rules. */
18
20
  function pathAllowedByScope(
@@ -69,62 +71,6 @@ interface GroupedResult {
69
71
  }>;
70
72
  }
71
73
 
72
- /**
73
- * Resolve a browse path back to a filesystem path using roots config.
74
- * Inverse of fsPathToBrowsePath.
75
- */
76
- function browsePathToFsPath(
77
- browsePath: string,
78
- roots: Record<string, string>,
79
- ): string | null {
80
- const parts = browsePath.split('/');
81
- const label = parts[0];
82
- const rest = parts.slice(1).join('/');
83
-
84
- // Check if label matches a root
85
- if (roots[label]) {
86
- return path.join(roots[label], rest);
87
- }
88
-
89
- // Windows drive letter: j/foo/bar → J:\foo\bar
90
- if (/^[a-zA-Z]$/.test(label)) {
91
- return `${label.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`;
92
- }
93
-
94
- return null;
95
- }
96
-
97
- /**
98
- * Convert an absolute filesystem path to a browse URL path.
99
- * Maps drive letters and root mounts back to the URL scheme.
100
- */
101
- function fsPathToBrowsePath(
102
- fsPath: string,
103
- roots: Record<string, string>,
104
- ): string | null {
105
- const normalized = fsPath.replace(/\\/g, '/');
106
-
107
- // Windows drive letter: j:/foo/bar → j/foo/bar
108
- const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
109
- if (driveMatch) {
110
- return `${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
111
- }
112
-
113
- // Linux roots: find matching root prefix
114
- for (const [label, rootPath] of Object.entries(roots)) {
115
- const normalizedRoot = rootPath.replace(/\\/g, '/').replace(/\/$/, '');
116
- if (normalized.startsWith(normalizedRoot + '/')) {
117
- const relative = normalized.slice(normalizedRoot.length + 1);
118
- return `${label}/${relative}`;
119
- }
120
- if (normalized === normalizedRoot) {
121
- return label;
122
- }
123
- }
124
-
125
- return null;
126
- }
127
-
128
74
  // eslint-disable-next-line @typescript-eslint/require-await
129
75
  export const searchRoutes: FastifyPluginAsync = async (fastify) => {
130
76
  fastify.post<{
@@ -150,7 +96,7 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
150
96
  }
151
97
 
152
98
  const insiderScopes = request.insiderScopes;
153
- const roots = config.roots ?? {};
99
+ const roots = getRoots(config.roots);
154
100
 
155
101
  // Over-fetch to account for scope filtering
156
102
  const fetchLimit = Math.min(limit * 5, 200);
@@ -177,11 +123,11 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
177
123
  const fp = r.payload.file_path;
178
124
  if (!fp) continue;
179
125
 
180
- const browsePath = fsPathToBrowsePath(fp, roots);
181
- if (!browsePath) continue;
126
+ const urlPath = fsPathToUrl(fp, roots);
127
+ // fsPathToUrl returns "/drive/path"; strip leading slash for browsePath
128
+ const browsePath = urlPath.replace(/^\//, '');
182
129
 
183
130
  // Check insider scope
184
- const urlPath = `/${browsePath}`;
185
131
  if (!pathAllowedByScope(urlPath, insiderScopes ?? null)) continue;
186
132
 
187
133
  permitted.push({ ...r, browsePath });
@@ -248,7 +194,7 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
248
194
  await Promise.all(
249
195
  grouped.map(async (g) => {
250
196
  g.chunks.sort((a, b) => a.index - b.index);
251
- const fsPath = browsePathToFsPath(g.browsePath, roots);
197
+ const fsPath = urlPathToFs(g.browsePath, roots);
252
198
  if (fsPath) {
253
199
  try {
254
200
  const s = await stat(fsPath);
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tests for sharing route helpers.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ import { describe, expect, it } from 'vitest';
8
+
9
+ // buildDeepShareUrl is not exported from the module, so we test it via
10
+ // the module's internal behavior. Since it's a pure function, we extract
11
+ // and test the URL construction logic directly.
12
+
13
+ /** Inline copy of buildDeepShareUrl for unit testing. */
14
+ function buildDeepShareUrl(
15
+ targetPath: string,
16
+ key: string,
17
+ params: { depth: number; dirs: boolean; stack: string; exp?: string },
18
+ ): string {
19
+ let url = `/browse${targetPath}?key=${key}&d=${String(params.depth)}&dirs=${params.dirs ? '1' : '0'}&s=${params.stack}`;
20
+ if (params.exp) url += `&exp=${params.exp}`;
21
+ return url;
22
+ }
23
+
24
+ describe('buildDeepShareUrl', () => {
25
+ it('builds a basic deep share URL without expiry', () => {
26
+ const url = buildDeepShareUrl('/j/docs/readme.md', 'abc123', {
27
+ depth: 2,
28
+ dirs: false,
29
+ stack: 'encoded-stack',
30
+ });
31
+ expect(url).toBe(
32
+ '/browse/j/docs/readme.md?key=abc123&d=2&dirs=0&s=encoded-stack',
33
+ );
34
+ });
35
+
36
+ it('includes expiry when provided', () => {
37
+ const url = buildDeepShareUrl('/j/docs/readme.md', 'abc123', {
38
+ depth: 1,
39
+ dirs: true,
40
+ stack: 'stk',
41
+ exp: '1700000000000',
42
+ });
43
+ expect(url).toBe(
44
+ '/browse/j/docs/readme.md?key=abc123&d=1&dirs=1&s=stk&exp=1700000000000',
45
+ );
46
+ });
47
+
48
+ it('handles zero depth', () => {
49
+ const url = buildDeepShareUrl('/j/file.md', 'key', {
50
+ depth: 0,
51
+ dirs: false,
52
+ stack: 's',
53
+ });
54
+ expect(url).toContain('d=0');
55
+ expect(url).toContain('dirs=0');
56
+ });
57
+
58
+ it('encodes dirs=1 when dirs is true', () => {
59
+ const url = buildDeepShareUrl('/j/dir', 'key', {
60
+ depth: 3,
61
+ dirs: true,
62
+ stack: 'stk',
63
+ });
64
+ expect(url).toContain('dirs=1');
65
+ });
66
+ });