@karmaniverous/jeeves-server 3.5.2 → 3.6.1
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/.tsbuildinfo +1 -1
- package/CHANGELOG.md +33 -1
- package/client/src/components/DirectoryRow.tsx +5 -1
- package/client/src/components/FileContentView.tsx +7 -0
- package/client/src/components/MarkdownView.tsx +63 -22
- package/client/src/components/TocSection.tsx +74 -0
- package/client/src/components/renderableUtils.ts +2 -2
- package/client/src/components/tocUtils.ts +65 -0
- package/client/src/index.css +36 -0
- package/client/src/lib/api.ts +2 -1
- package/client/src/lucide.d.ts +15 -0
- package/dist/client/assets/{CodeViewer-Cegj3cEn.js → CodeViewer-D5fJ1Z6_.js} +1 -1
- package/dist/client/assets/{index-DrBXupPz.js → index-BXy6kgl7.js} +2 -2
- package/dist/client/assets/index-Ch0vkF39.css +2 -0
- package/dist/client/index.html +2 -2
- package/dist/src/cli/index.js +2 -1
- package/dist/src/cli/start-server.js +12 -2
- package/dist/src/config/index.js +16 -2
- package/dist/src/config/loadConfig.test.js +66 -1
- package/dist/src/config/resolve.js +0 -4
- package/dist/src/config/resolve.test.js +0 -2
- package/dist/src/config/schema.js +7 -21
- package/dist/src/config/substituteEnvVars.js +2 -0
- package/dist/src/descriptor.js +9 -2
- package/dist/src/routes/api/auth-status.js +1 -1
- package/dist/src/routes/api/directory.js +46 -24
- package/dist/src/routes/api/directory.test.js +65 -0
- package/dist/src/routes/api/export.js +5 -1
- package/dist/src/routes/api/export.test.js +46 -0
- package/dist/src/routes/api/fileContent.js +26 -4
- package/dist/src/routes/api/runner.js +2 -3
- package/dist/src/routes/api/runner.test.js +29 -0
- package/dist/src/routes/api/search.js +4 -9
- package/dist/src/routes/api/search.test.js +28 -0
- package/dist/src/routes/config.test.js +0 -1
- package/dist/src/routes/event.js +1 -1
- package/dist/src/routes/status.js +4 -4
- package/dist/src/routes/status.test.js +10 -4
- package/dist/src/server.js +4 -2
- package/dist/src/services/csv.js +114 -0
- package/dist/src/services/csv.test.js +107 -0
- package/dist/src/services/markdown.js +21 -1
- package/dist/src/services/markdown.test.js +43 -0
- package/dist/src/util/packageVersion.js +3 -13
- package/guides/deployment.md +1 -1
- package/guides/setup.md +14 -10
- package/knip.json +2 -1
- package/package.json +18 -16
- package/src/cli/index.ts +3 -1
- package/src/cli/start-server.ts +17 -3
- package/src/config/index.ts +22 -2
- package/src/config/loadConfig.test.ts +77 -1
- package/src/config/resolve.test.ts +0 -2
- package/src/config/resolve.ts +0 -4
- package/src/config/schema.ts +8 -21
- package/src/config/substituteEnvVars.ts +2 -0
- package/src/config/types.ts +0 -4
- package/src/descriptor.ts +9 -1
- package/src/routes/api/auth-status.ts +1 -1
- package/src/routes/api/directory.test.ts +77 -0
- package/src/routes/api/directory.ts +59 -22
- package/src/routes/api/export.test.ts +56 -0
- package/src/routes/api/export.ts +5 -1
- package/src/routes/api/fileContent.ts +27 -3
- package/src/routes/api/runner.test.ts +39 -0
- package/src/routes/api/runner.ts +2 -5
- package/src/routes/api/search.test.ts +36 -0
- package/src/routes/api/search.ts +4 -9
- package/src/routes/config.test.ts +0 -1
- package/src/routes/event.test.ts +4 -4
- package/src/routes/event.ts +11 -6
- package/src/routes/status.test.ts +13 -4
- package/src/routes/status.ts +4 -4
- package/src/server.ts +4 -2
- package/src/services/csv.test.ts +127 -0
- package/src/services/csv.ts +115 -0
- package/src/services/markdown.test.ts +54 -0
- package/src/services/markdown.ts +21 -1
- package/src/types/puppeteer-core.d.ts +16 -0
- package/src/util/packageVersion.ts +3 -18
- package/dist/client/assets/index-Dk_myGs4.css +0 -2
- package/src/types/jsonmap.d.ts +0 -10
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('@karmaniverous/jeeves', () => ({
|
|
4
|
+
getBindAddress: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
const { getBindAddress } = await import('@karmaniverous/jeeves');
|
|
8
|
+
const mockedGetBindAddress = vi.mocked(getBindAddress);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Replicate the bind-address resolution logic from export.ts:
|
|
12
|
+
* 0.0.0.0 (all interfaces) is not a valid request target, so fall back to loopback.
|
|
13
|
+
*/
|
|
14
|
+
function resolveRenderHost(bindAddr: string): string {
|
|
15
|
+
return bindAddr === '0.0.0.0' ? '127.0.0.1' : bindAddr;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('export render host resolution', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('resolves 0.0.0.0 to 127.0.0.1 for Chrome render URL', () => {
|
|
24
|
+
mockedGetBindAddress.mockReturnValue('0.0.0.0');
|
|
25
|
+
const bindAddr = getBindAddress('server');
|
|
26
|
+
const renderHost = resolveRenderHost(bindAddr);
|
|
27
|
+
expect(renderHost).toBe('127.0.0.1');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('preserves a specific bind address for Chrome render URL', () => {
|
|
31
|
+
mockedGetBindAddress.mockReturnValue('192.168.1.5');
|
|
32
|
+
const bindAddr = getBindAddress('server');
|
|
33
|
+
const renderHost = resolveRenderHost(bindAddr);
|
|
34
|
+
expect(renderHost).toBe('192.168.1.5');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('preserves loopback address as-is', () => {
|
|
38
|
+
mockedGetBindAddress.mockReturnValue('127.0.0.1');
|
|
39
|
+
const bindAddr = getBindAddress('server');
|
|
40
|
+
const renderHost = resolveRenderHost(bindAddr);
|
|
41
|
+
expect(renderHost).toBe('127.0.0.1');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('constructs a valid export URL with resolved host and port', () => {
|
|
45
|
+
mockedGetBindAddress.mockReturnValue('0.0.0.0');
|
|
46
|
+
const bindAddr = getBindAddress('server');
|
|
47
|
+
const renderHost = resolveRenderHost(bindAddr);
|
|
48
|
+
const port = 1934;
|
|
49
|
+
const reqPath = 'c/docs/readme.md';
|
|
50
|
+
const key = 'test-key';
|
|
51
|
+
const exportUrl = `http://${renderHost}:${String(port)}/browse/${reqPath}?key=${key}&render_diagrams=1&plain_code=1`;
|
|
52
|
+
expect(exportUrl).toBe(
|
|
53
|
+
'http://127.0.0.1:1934/browse/c/docs/readme.md?key=test-key&render_diagrams=1&plain_code=1',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/routes/api/export.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
13
|
+
import { getBindAddress } from '@karmaniverous/jeeves';
|
|
13
14
|
import archiver from 'archiver';
|
|
14
15
|
import type { FastifyPluginAsync } from 'fastify';
|
|
15
16
|
|
|
@@ -102,7 +103,10 @@ export const exportRoutes: FastifyPluginAsync = async (fastify) => {
|
|
|
102
103
|
.code(500)
|
|
103
104
|
.send({ error: 'Export unavailable — no internal key configured' });
|
|
104
105
|
|
|
105
|
-
const
|
|
106
|
+
const bindAddr = getBindAddress('server');
|
|
107
|
+
// Use loopback if bound to all interfaces (0.0.0.0 is not a valid request target)
|
|
108
|
+
const renderHost = bindAddr === '0.0.0.0' ? '127.0.0.1' : bindAddr;
|
|
109
|
+
const exportUrl = `http://${renderHost}:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
|
|
106
110
|
const fileName = path.basename(resolved);
|
|
107
111
|
const baseName = fileName.replace(/\.md$/i, '');
|
|
108
112
|
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
|
|
10
|
+
import { getServiceUrl } from '@karmaniverous/jeeves';
|
|
10
11
|
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
|
11
12
|
|
|
12
13
|
import { getConfig } from '../../config/index.js';
|
|
14
|
+
import { csvToHtmlTable } from '../../services/csv.js';
|
|
13
15
|
import { rewriteLinksForDeepShare } from '../../services/deepShareLinks.js';
|
|
14
16
|
import { getOrRenderDiagram } from '../../services/diagramCache.js';
|
|
15
17
|
import {
|
|
@@ -142,6 +144,29 @@ export const fileContentRoutes: FastifyPluginAsync = async (fastify) => {
|
|
|
142
144
|
});
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
// CSV
|
|
148
|
+
if (ext === '.csv') {
|
|
149
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
150
|
+
if (rawOnly) {
|
|
151
|
+
return reply.send({
|
|
152
|
+
type: 'text',
|
|
153
|
+
content,
|
|
154
|
+
fileName,
|
|
155
|
+
breadcrumbs,
|
|
156
|
+
isInsider,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const html = csvToHtmlTable(content);
|
|
160
|
+
return reply.send({
|
|
161
|
+
type: 'csv',
|
|
162
|
+
content,
|
|
163
|
+
html,
|
|
164
|
+
fileName,
|
|
165
|
+
breadcrumbs,
|
|
166
|
+
isInsider,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
145
170
|
// Text files
|
|
146
171
|
const buffer = fs.readFileSync(resolved);
|
|
147
172
|
if (looksLikeText(buffer)) {
|
|
@@ -259,11 +284,10 @@ interface WatcherRenderResponse {
|
|
|
259
284
|
async function tryWatcherRender(
|
|
260
285
|
fsPath: string,
|
|
261
286
|
): Promise<WatcherRenderResponse | null> {
|
|
262
|
-
const
|
|
263
|
-
if (!config.watcherUrl) return null;
|
|
287
|
+
const watcherUrl = getServiceUrl('watcher');
|
|
264
288
|
|
|
265
289
|
try {
|
|
266
|
-
const res = await fetch(`${
|
|
290
|
+
const res = await fetch(`${watcherUrl}/render`, {
|
|
267
291
|
method: 'POST',
|
|
268
292
|
headers: { 'Content-Type': 'application/json' },
|
|
269
293
|
body: JSON.stringify({
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('@karmaniverous/jeeves', () => ({
|
|
4
|
+
getServiceUrl: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
const { getServiceUrl } = await import('@karmaniverous/jeeves');
|
|
8
|
+
const mockedGetServiceUrl = vi.mocked(getServiceUrl);
|
|
9
|
+
|
|
10
|
+
describe('runner URL resolution', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('resolves runner URL via core getServiceUrl', () => {
|
|
16
|
+
mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1937');
|
|
17
|
+
const url = getServiceUrl('runner');
|
|
18
|
+
expect(url).toBe('http://127.0.0.1:1937');
|
|
19
|
+
expect(mockedGetServiceUrl).toHaveBeenCalledWith('runner');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('uses the URL from core, not a hardcoded default', () => {
|
|
23
|
+
mockedGetServiceUrl.mockReturnValue('http://10.0.0.5:9999');
|
|
24
|
+
const url = getServiceUrl('runner');
|
|
25
|
+
expect(url).toBe('http://10.0.0.5:9999');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('constructs proxy paths correctly from the resolved URL', () => {
|
|
29
|
+
mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1937');
|
|
30
|
+
const base = getServiceUrl('runner');
|
|
31
|
+
const jobId = 'my-job';
|
|
32
|
+
expect(`${base}/jobs/${encodeURIComponent(jobId)}`).toBe(
|
|
33
|
+
'http://127.0.0.1:1937/jobs/my-job',
|
|
34
|
+
);
|
|
35
|
+
expect(`${base}/jobs/${encodeURIComponent(jobId)}/runs?limit=20`).toBe(
|
|
36
|
+
'http://127.0.0.1:1937/jobs/my-job/runs?limit=20',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/routes/api/runner.ts
CHANGED
|
@@ -5,14 +5,11 @@
|
|
|
5
5
|
* so jeeves-server acts as an authenticated gateway.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { getServiceUrl } from '@karmaniverous/jeeves';
|
|
8
9
|
import type { FastifyPluginAsync, FastifyReply } from 'fastify';
|
|
9
10
|
|
|
10
|
-
import { getConfig } from '../../config/index.js';
|
|
11
|
-
|
|
12
|
-
const DEFAULT_RUNNER_URL = 'http://127.0.0.1:3100';
|
|
13
|
-
|
|
14
11
|
function getRunnerUrl(): string {
|
|
15
|
-
return
|
|
12
|
+
return getServiceUrl('runner');
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
/** Proxy a request to the runner and send the response via reply. */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('@karmaniverous/jeeves', () => ({
|
|
4
|
+
getServiceUrl: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
const { getServiceUrl } = await import('@karmaniverous/jeeves');
|
|
8
|
+
const mockedGetServiceUrl = vi.mocked(getServiceUrl);
|
|
9
|
+
|
|
10
|
+
describe('search watcher URL resolution', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('resolves watcher URL via core getServiceUrl', () => {
|
|
16
|
+
mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1936');
|
|
17
|
+
const url = getServiceUrl('watcher');
|
|
18
|
+
expect(url).toBe('http://127.0.0.1:1936');
|
|
19
|
+
expect(mockedGetServiceUrl).toHaveBeenCalledWith('watcher');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('uses core-resolved URL, not a hardcoded default', () => {
|
|
23
|
+
mockedGetServiceUrl.mockReturnValue('http://10.0.0.5:8888');
|
|
24
|
+
const url = getServiceUrl('watcher');
|
|
25
|
+
expect(url).toBe('http://10.0.0.5:8888');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('constructs search endpoint from resolved watcher URL', () => {
|
|
29
|
+
mockedGetServiceUrl.mockReturnValue('http://127.0.0.1:1936');
|
|
30
|
+
const watcherUrl = getServiceUrl('watcher');
|
|
31
|
+
expect(`${watcherUrl}/search`).toBe('http://127.0.0.1:1936/search');
|
|
32
|
+
expect(`${watcherUrl}/search/facets`).toBe(
|
|
33
|
+
'http://127.0.0.1:1936/search/facets',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/routes/api/search.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { stat } from 'node:fs/promises';
|
|
11
11
|
|
|
12
|
+
import { getServiceUrl } from '@karmaniverous/jeeves';
|
|
12
13
|
import type { FastifyPluginAsync } from 'fastify';
|
|
13
14
|
import picomatch from 'picomatch';
|
|
14
15
|
|
|
@@ -86,9 +87,7 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
const config = getConfig();
|
|
89
|
-
|
|
90
|
-
return reply.code(501).send({ error: 'Search not configured' });
|
|
91
|
-
}
|
|
90
|
+
const watcherUrl = getServiceUrl('watcher');
|
|
92
91
|
|
|
93
92
|
const { query, limit = 20, filter } = request.body;
|
|
94
93
|
if (!query || typeof query !== 'string') {
|
|
@@ -102,7 +101,7 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
|
|
|
102
101
|
const fetchLimit = Math.min(limit * 5, 200);
|
|
103
102
|
|
|
104
103
|
try {
|
|
105
|
-
const watcherRes = await fetch(`${
|
|
104
|
+
const watcherRes = await fetch(`${watcherUrl}/search`, {
|
|
106
105
|
method: 'POST',
|
|
107
106
|
headers: { 'Content-Type': 'application/json' },
|
|
108
107
|
body: JSON.stringify({ query, limit: fetchLimit, filter }),
|
|
@@ -244,10 +243,7 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
|
|
|
244
243
|
return reply.code(403).send({ error: 'Insider access required' });
|
|
245
244
|
}
|
|
246
245
|
|
|
247
|
-
const
|
|
248
|
-
if (!config.watcherUrl) {
|
|
249
|
-
return reply.code(501).send({ error: 'Search not configured' });
|
|
250
|
-
}
|
|
246
|
+
const watcherUrl = getServiceUrl('watcher');
|
|
251
247
|
|
|
252
248
|
// Return cached if fresh
|
|
253
249
|
if (
|
|
@@ -260,7 +256,6 @@ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
|
|
|
260
256
|
try {
|
|
261
257
|
// Guard against cache stampede: reuse in-flight fetch
|
|
262
258
|
if (!facetsFetchPromise) {
|
|
263
|
-
const watcherUrl = config.watcherUrl;
|
|
264
259
|
facetsFetchPromise = (async () => {
|
|
265
260
|
const watcherRes = await fetch(`${watcherUrl}/search/facets`, {
|
|
266
261
|
signal: AbortSignal.timeout(15000),
|
package/src/routes/event.test.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Event route tests
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { JsonMap } from '@karmaniverous/jsonmap';
|
|
5
|
+
import { JsonMap, type JsonMapLib } from '@karmaniverous/jsonmap';
|
|
6
6
|
import Ajv from 'ajv';
|
|
7
7
|
import * as _ from 'radash';
|
|
8
8
|
import { describe, expect, it } from 'vitest';
|
|
@@ -96,7 +96,7 @@ describe('event route', () => {
|
|
|
96
96
|
},
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
-
const mapper = new JsonMap(map, { _: _ as
|
|
99
|
+
const mapper = new JsonMap(map, { _: _ as unknown as JsonMapLib });
|
|
100
100
|
const result = (await mapper.transform(body)) as Record<string, unknown>;
|
|
101
101
|
|
|
102
102
|
expect(result.pageId).toBe('abc123');
|
|
@@ -117,7 +117,7 @@ describe('event route', () => {
|
|
|
117
117
|
},
|
|
118
118
|
};
|
|
119
119
|
|
|
120
|
-
const mapper = new JsonMap(map, { _: _ as
|
|
120
|
+
const mapper = new JsonMap(map, { _: _ as unknown as JsonMapLib });
|
|
121
121
|
const result = (await mapper.transform(body)) as Record<string, unknown>;
|
|
122
122
|
|
|
123
123
|
expect(result.pageId).toBeUndefined();
|
|
@@ -165,7 +165,7 @@ describe('event route', () => {
|
|
|
165
165
|
},
|
|
166
166
|
};
|
|
167
167
|
|
|
168
|
-
const mapper = new JsonMap(map, { _: _ as
|
|
168
|
+
const mapper = new JsonMap(map, { _: _ as unknown as JsonMapLib });
|
|
169
169
|
const result = (await mapper.transform(body)) as Record<string, unknown>;
|
|
170
170
|
|
|
171
171
|
expect(result.authorName).toBe('Alice');
|
package/src/routes/event.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* Event Gateway webhook endpoint
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type Json,
|
|
7
|
+
JsonMap,
|
|
8
|
+
type JsonMapLib,
|
|
9
|
+
type JsonMapMap,
|
|
10
|
+
} from '@karmaniverous/jsonmap';
|
|
6
11
|
import Ajv from 'ajv';
|
|
7
12
|
import type { FastifyPluginAsync } from 'fastify';
|
|
8
13
|
import * as _ from 'radash';
|
|
@@ -24,7 +29,7 @@ interface EventRequest {
|
|
|
24
29
|
*/
|
|
25
30
|
function matchEvent(
|
|
26
31
|
body: Record<string, unknown>,
|
|
27
|
-
): { name: string; cmd: string; map?:
|
|
32
|
+
): { name: string; cmd: string; map?: JsonMapMap; timeoutMs: number } | null {
|
|
28
33
|
const { events, eventTimeoutMs } = getConfig();
|
|
29
34
|
|
|
30
35
|
for (const [name, eventConfig] of Object.entries(events)) {
|
|
@@ -34,7 +39,7 @@ function matchEvent(
|
|
|
34
39
|
return {
|
|
35
40
|
name,
|
|
36
41
|
cmd: eventConfig.cmd,
|
|
37
|
-
map: eventConfig.map,
|
|
42
|
+
map: eventConfig.map as JsonMapMap | undefined,
|
|
38
43
|
timeoutMs: eventConfig.timeoutMs ?? eventTimeoutMs,
|
|
39
44
|
};
|
|
40
45
|
}
|
|
@@ -48,13 +53,13 @@ function matchEvent(
|
|
|
48
53
|
*/
|
|
49
54
|
async function transformBody(
|
|
50
55
|
body: Record<string, unknown>,
|
|
51
|
-
map?:
|
|
56
|
+
map?: JsonMapMap,
|
|
52
57
|
): Promise<Record<string, unknown>> {
|
|
53
58
|
if (!map) return body;
|
|
54
59
|
|
|
55
60
|
// JsonMap with radash available as $.lib._
|
|
56
|
-
const mapper = new JsonMap(map, { _: _ as
|
|
57
|
-
const result = await mapper.transform(body);
|
|
61
|
+
const mapper = new JsonMap(map, { _: _ as unknown as JsonMapLib });
|
|
62
|
+
const result = await mapper.transform(body as unknown as Json);
|
|
58
63
|
return result as Record<string, unknown>;
|
|
59
64
|
}
|
|
60
65
|
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
|
+
vi.mock('@karmaniverous/jeeves', async (importOriginal) => {
|
|
4
|
+
const mod = await importOriginal<Record<string, unknown>>();
|
|
5
|
+
return {
|
|
6
|
+
...mod,
|
|
7
|
+
getServiceUrl: (name: string) =>
|
|
8
|
+
`http://127.0.0.1:${name === 'watcher' ? '1936' : name === 'runner' ? '1937' : '1938'}`,
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Mock fetch so service health checks don't make real HTTP calls
|
|
13
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('not reachable'));
|
|
14
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
15
|
+
|
|
3
16
|
// Mock config
|
|
4
17
|
const mockConfig = {
|
|
5
18
|
port: 1934,
|
|
6
|
-
host: '0.0.0.0',
|
|
7
19
|
chromePath: '/usr/bin/chromium',
|
|
8
20
|
authModes: ['keys'],
|
|
9
21
|
resolvedInsiders: [{ email: 'a@b.com' }, { email: 'c@d.com' }],
|
|
@@ -17,9 +29,6 @@ const mockConfig = {
|
|
|
17
29
|
jarPath: '/tools/plantuml.jar',
|
|
18
30
|
servers: ['https://plantuml.com/plantuml'],
|
|
19
31
|
},
|
|
20
|
-
watcherUrl: null,
|
|
21
|
-
runnerUrl: null,
|
|
22
|
-
metaUrl: null,
|
|
23
32
|
exportFormats: ['pdf', 'docx', 'zip'],
|
|
24
33
|
};
|
|
25
34
|
|
package/src/routes/status.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* with server-specific details nested under `health`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { createStatusHandler } from '@karmaniverous/jeeves';
|
|
8
|
+
import { createStatusHandler, getServiceUrl } from '@karmaniverous/jeeves';
|
|
9
9
|
import type { FastifyPluginAsync } from 'fastify';
|
|
10
10
|
|
|
11
11
|
import { getConfig } from '../config/index.js';
|
|
@@ -41,9 +41,9 @@ const handleStatus = createStatusHandler({
|
|
|
41
41
|
const config = getConfig();
|
|
42
42
|
|
|
43
43
|
const [watcher, runner, meta] = await Promise.all([
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
checkService(getServiceUrl('watcher')),
|
|
45
|
+
checkService(getServiceUrl('runner')),
|
|
46
|
+
checkService(getServiceUrl('meta')),
|
|
47
47
|
]);
|
|
48
48
|
|
|
49
49
|
return {
|
package/src/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
|
|
10
10
|
import cookie from '@fastify/cookie';
|
|
11
11
|
import fastifyStatic from '@fastify/static';
|
|
12
|
+
import { getBindAddress } from '@karmaniverous/jeeves';
|
|
12
13
|
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
|
13
14
|
|
|
14
15
|
import { getConfig, initConfig, isConfigInitialized } from './config/index.js';
|
|
@@ -97,9 +98,10 @@ async function start() {
|
|
|
97
98
|
// Start queue processor
|
|
98
99
|
startQueueProcessor();
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
const bindAddress = getBindAddress('server');
|
|
102
|
+
await fastify.listen({ port: config.port, host: bindAddress });
|
|
101
103
|
console.log(
|
|
102
|
-
`Jeeves server listening on ${
|
|
104
|
+
`Jeeves server listening on ${bindAddress}:${String(config.port)}`,
|
|
103
105
|
);
|
|
104
106
|
console.log(`Endpoints:`);
|
|
105
107
|
console.log(` GET /browse/* - File browser SPA`);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { csvToHtmlTable, parseCsvRows } from './csv.js';
|
|
4
|
+
|
|
5
|
+
describe('parseCsvRows', () => {
|
|
6
|
+
it('parses simple CSV', () => {
|
|
7
|
+
const rows = parseCsvRows('a,b,c\n1,2,3\n');
|
|
8
|
+
expect(rows).toEqual([
|
|
9
|
+
['a', 'b', 'c'],
|
|
10
|
+
['1', '2', '3'],
|
|
11
|
+
]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('handles quoted fields with embedded commas', () => {
|
|
15
|
+
const rows = parseCsvRows('name,desc\n"Smith, John","has, commas"\n');
|
|
16
|
+
expect(rows).toEqual([
|
|
17
|
+
['name', 'desc'],
|
|
18
|
+
['Smith, John', 'has, commas'],
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('handles escaped quotes ("")', () => {
|
|
23
|
+
const rows = parseCsvRows('a\n"he said ""hello"""\n');
|
|
24
|
+
expect(rows).toEqual([['a'], ['he said "hello"']]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('handles empty fields', () => {
|
|
28
|
+
const rows = parseCsvRows('a,,c\n,,\n');
|
|
29
|
+
expect(rows).toEqual([
|
|
30
|
+
['a', '', 'c'],
|
|
31
|
+
['', '', ''],
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('handles trailing newlines', () => {
|
|
36
|
+
const rows = parseCsvRows('a,b\n1,2\n\n');
|
|
37
|
+
expect(rows).toEqual([
|
|
38
|
+
['a', 'b'],
|
|
39
|
+
['1', '2'],
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('handles single row (headers only)', () => {
|
|
44
|
+
const rows = parseCsvRows('name,age,city\n');
|
|
45
|
+
expect(rows).toEqual([['name', 'age', 'city']]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns empty array for empty input', () => {
|
|
49
|
+
const rows = parseCsvRows('');
|
|
50
|
+
expect(rows).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles CRLF line endings', () => {
|
|
54
|
+
const rows = parseCsvRows('a,b\r\n1,2\r\n');
|
|
55
|
+
expect(rows).toEqual([
|
|
56
|
+
['a', 'b'],
|
|
57
|
+
['1', '2'],
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('handles quoted fields with embedded newlines', () => {
|
|
62
|
+
const rows = parseCsvRows('a,b\n"line1\nline2",val\n');
|
|
63
|
+
expect(rows).toEqual([
|
|
64
|
+
['a', 'b'],
|
|
65
|
+
['line1\nline2', 'val'],
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('csvToHtmlTable', () => {
|
|
71
|
+
it('renders a table with headers and data', () => {
|
|
72
|
+
const html = csvToHtmlTable('Name,Age\nAlice,30\nBob,25\n');
|
|
73
|
+
expect(html).toContain('<thead>');
|
|
74
|
+
expect(html).toContain('<th>Name</th>');
|
|
75
|
+
expect(html).toContain('<th>Age</th>');
|
|
76
|
+
expect(html).toContain('<td>Alice</td>');
|
|
77
|
+
expect(html).toContain('<td>30</td>');
|
|
78
|
+
expect(html).toContain('<td>Bob</td>');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns empty paragraph for empty input', () => {
|
|
82
|
+
const html = csvToHtmlTable('');
|
|
83
|
+
expect(html).toBe('<p>Empty CSV</p>');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('escapes HTML in field values', () => {
|
|
87
|
+
const html = csvToHtmlTable('a\n<script>alert("xss")</script>\n');
|
|
88
|
+
expect(html).not.toContain('<script>');
|
|
89
|
+
expect(html).toContain('<script>');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('produces a table with csv-table class for client styling', () => {
|
|
93
|
+
const html = csvToHtmlTable('Name,Age\nAlice,30\n');
|
|
94
|
+
expect(html).toMatch(/<table class="csv-table">/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('normalizes rows with fewer or more columns than header', () => {
|
|
98
|
+
// Row with fewer columns gets padded, row with more gets truncated
|
|
99
|
+
const csv = 'a,b,c\n1\n4,5,6,7\n';
|
|
100
|
+
const html = csvToHtmlTable(csv);
|
|
101
|
+
// First data row: 1 field → padded to 3 columns
|
|
102
|
+
expect(html).toContain('<tr><td>1</td><td></td><td></td></tr>');
|
|
103
|
+
// Second data row: 4 fields → truncated to 3 columns (no "7")
|
|
104
|
+
expect(html).toContain('<tr><td>4</td><td>5</td><td>6</td></tr>');
|
|
105
|
+
expect(html).not.toContain('<td>7</td>');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('handles a large CSV (>100 rows) without issues', () => {
|
|
109
|
+
const header = 'id,name,value\n';
|
|
110
|
+
const rows = Array.from(
|
|
111
|
+
{ length: 150 },
|
|
112
|
+
(_, i) => `${String(i)},item${String(i)},${String(i * 10)}\n`,
|
|
113
|
+
).join('');
|
|
114
|
+
const csv = header + rows;
|
|
115
|
+
|
|
116
|
+
const html = csvToHtmlTable(csv);
|
|
117
|
+
expect(html).toContain('csv-table');
|
|
118
|
+
expect(html).toContain('<thead>');
|
|
119
|
+
// Verify first and last data rows are present
|
|
120
|
+
expect(html).toContain('<td>0</td>');
|
|
121
|
+
expect(html).toContain('<td>149</td>');
|
|
122
|
+
// Count tbody rows
|
|
123
|
+
const trMatches = html.match(/<tr>/g);
|
|
124
|
+
// 1 header row + 150 data rows = 151
|
|
125
|
+
expect(trMatches).toHaveLength(151);
|
|
126
|
+
});
|
|
127
|
+
});
|