@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.
Files changed (82) hide show
  1. package/.tsbuildinfo +1 -1
  2. package/CHANGELOG.md +33 -1
  3. package/client/src/components/DirectoryRow.tsx +5 -1
  4. package/client/src/components/FileContentView.tsx +7 -0
  5. package/client/src/components/MarkdownView.tsx +63 -22
  6. package/client/src/components/TocSection.tsx +74 -0
  7. package/client/src/components/renderableUtils.ts +2 -2
  8. package/client/src/components/tocUtils.ts +65 -0
  9. package/client/src/index.css +36 -0
  10. package/client/src/lib/api.ts +2 -1
  11. package/client/src/lucide.d.ts +15 -0
  12. package/dist/client/assets/{CodeViewer-Cegj3cEn.js → CodeViewer-D5fJ1Z6_.js} +1 -1
  13. package/dist/client/assets/{index-DrBXupPz.js → index-BXy6kgl7.js} +2 -2
  14. package/dist/client/assets/index-Ch0vkF39.css +2 -0
  15. package/dist/client/index.html +2 -2
  16. package/dist/src/cli/index.js +2 -1
  17. package/dist/src/cli/start-server.js +12 -2
  18. package/dist/src/config/index.js +16 -2
  19. package/dist/src/config/loadConfig.test.js +66 -1
  20. package/dist/src/config/resolve.js +0 -4
  21. package/dist/src/config/resolve.test.js +0 -2
  22. package/dist/src/config/schema.js +7 -21
  23. package/dist/src/config/substituteEnvVars.js +2 -0
  24. package/dist/src/descriptor.js +9 -2
  25. package/dist/src/routes/api/auth-status.js +1 -1
  26. package/dist/src/routes/api/directory.js +46 -24
  27. package/dist/src/routes/api/directory.test.js +65 -0
  28. package/dist/src/routes/api/export.js +5 -1
  29. package/dist/src/routes/api/export.test.js +46 -0
  30. package/dist/src/routes/api/fileContent.js +26 -4
  31. package/dist/src/routes/api/runner.js +2 -3
  32. package/dist/src/routes/api/runner.test.js +29 -0
  33. package/dist/src/routes/api/search.js +4 -9
  34. package/dist/src/routes/api/search.test.js +28 -0
  35. package/dist/src/routes/config.test.js +0 -1
  36. package/dist/src/routes/event.js +1 -1
  37. package/dist/src/routes/status.js +4 -4
  38. package/dist/src/routes/status.test.js +10 -4
  39. package/dist/src/server.js +4 -2
  40. package/dist/src/services/csv.js +114 -0
  41. package/dist/src/services/csv.test.js +107 -0
  42. package/dist/src/services/markdown.js +21 -1
  43. package/dist/src/services/markdown.test.js +43 -0
  44. package/dist/src/util/packageVersion.js +3 -13
  45. package/guides/deployment.md +1 -1
  46. package/guides/setup.md +14 -10
  47. package/knip.json +2 -1
  48. package/package.json +18 -16
  49. package/src/cli/index.ts +3 -1
  50. package/src/cli/start-server.ts +17 -3
  51. package/src/config/index.ts +22 -2
  52. package/src/config/loadConfig.test.ts +77 -1
  53. package/src/config/resolve.test.ts +0 -2
  54. package/src/config/resolve.ts +0 -4
  55. package/src/config/schema.ts +8 -21
  56. package/src/config/substituteEnvVars.ts +2 -0
  57. package/src/config/types.ts +0 -4
  58. package/src/descriptor.ts +9 -1
  59. package/src/routes/api/auth-status.ts +1 -1
  60. package/src/routes/api/directory.test.ts +77 -0
  61. package/src/routes/api/directory.ts +59 -22
  62. package/src/routes/api/export.test.ts +56 -0
  63. package/src/routes/api/export.ts +5 -1
  64. package/src/routes/api/fileContent.ts +27 -3
  65. package/src/routes/api/runner.test.ts +39 -0
  66. package/src/routes/api/runner.ts +2 -5
  67. package/src/routes/api/search.test.ts +36 -0
  68. package/src/routes/api/search.ts +4 -9
  69. package/src/routes/config.test.ts +0 -1
  70. package/src/routes/event.test.ts +4 -4
  71. package/src/routes/event.ts +11 -6
  72. package/src/routes/status.test.ts +13 -4
  73. package/src/routes/status.ts +4 -4
  74. package/src/server.ts +4 -2
  75. package/src/services/csv.test.ts +127 -0
  76. package/src/services/csv.ts +115 -0
  77. package/src/services/markdown.test.ts +54 -0
  78. package/src/services/markdown.ts +21 -1
  79. package/src/types/puppeteer-core.d.ts +16 -0
  80. package/src/util/packageVersion.ts +3 -18
  81. package/dist/client/assets/index-Dk_myGs4.css +0 -2
  82. package/src/types/jsonmap.d.ts +0 -10
package/guides/setup.md CHANGED
@@ -6,7 +6,7 @@ title: "Setup & Configuration"
6
6
 
7
7
  ## Prerequisites
8
8
 
9
- - **Node.js** ≥ 20
9
+ - **Node.js** ≥ 22
10
10
  - **Chrome or Chromium** — required for PDF export (Puppeteer uses it headlessly)
11
11
 
12
12
  ## Installation
@@ -64,9 +64,7 @@ Legacy paths (`jeeves-server.config.json`) are auto-migrated to the new conventi
64
64
  "scopes": ["/event"]
65
65
  }
66
66
  },
67
- "events": {},
68
- "watcherUrl": "http://localhost:1936",
69
- "runnerUrl": "http://127.0.0.1:1937"
67
+ "events": {}
70
68
  }
71
69
  ```
72
70
 
@@ -285,18 +283,22 @@ See the [Event Gateway](event-gateway.md) guide for full configuration and usage
285
283
 
286
284
  ### jeeves-watcher (Semantic Search)
287
285
 
288
- When `watcherUrl` is configured, the server proxies semantic search queries to [jeeves-watcher](https://github.com/karmaniverous/jeeves-watcher) and provides a search UI in the header, with filter facets and scope-aware result filtering.
286
+ The server proxies semantic search queries to [jeeves-watcher](https://github.com/karmaniverous/jeeves-watcher) and provides a search UI in the header, with filter facets and scope-aware result filtering. The watcher URL is resolved automatically via core service discovery (default port 1936).
287
+
288
+ To override the default URL, configure it in `{configRoot}/jeeves-core/config.json`:
289
289
 
290
290
  ```json
291
- { "watcherUrl": "http://localhost:1936" }
291
+ { "services": { "watcher": { "url": "http://custom-host:1936" } } }
292
292
  ```
293
293
 
294
294
  ### jeeves-runner (Process Dashboard)
295
295
 
296
- When `runnerUrl` is configured, the server proxies runner API calls for the process dashboard UI.
296
+ The server proxies runner API calls for the process dashboard UI. The runner URL is resolved automatically via core service discovery (default port 1937).
297
+
298
+ To override the default URL, configure it in `{configRoot}/jeeves-core/config.json`:
297
299
 
298
300
  ```json
299
- { "runnerUrl": "http://127.0.0.1:1937" }
301
+ { "services": { "runner": { "url": "http://custom-host:1937" } } }
300
302
  ```
301
303
 
302
304
  ### PlantUML
@@ -336,8 +338,10 @@ If `plantuml` is omitted entirely, only the community server is used.
336
338
  | `eventLogPurgeMs` | number | `2592000000` | Event log retention (default: 30 days) |
337
339
  | `maxZipSizeMb` | number | `100` | Max directory size for ZIP export |
338
340
  | `roots` | object | — | Linux filesystem roots (ignored on Windows) |
339
- | `watcherUrl` | string | — | jeeves-watcher API URL for semantic search |
340
- | `runnerUrl` | string | — | jeeves-runner API URL for process dashboard |
341
+ | ~~`watcherUrl`~~ | | — | **Deprecated in v3.6.0.** Use core service discovery. |
342
+ | ~~`runnerUrl`~~ | | — | **Deprecated in v3.6.0.** Use core service discovery. |
343
+ | ~~`host`~~ | — | — | **Deprecated in v3.6.0.** Use `getBindAddress()` via core. |
344
+ | ~~`metaUrl`~~ | — | — | **Deprecated in v3.6.0.** Use core service discovery. |
341
345
  | `plantuml` | object | — | PlantUML rendering config |
342
346
  | `diagramCachePath` | string | `.diagram-cache` | Cached rendered diagram directory |
343
347
  | `outsiderPolicy` | scopes | — | Global outsider sharing constraints |
package/knip.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "$schema": "https://unpkg.com/knip@latest/schema.json",
3
3
  "entry": [
4
- "src/cli/index.ts"
4
+ "src/cli/index.ts",
5
+ "src/cli/start-server.ts"
5
6
  ],
6
7
  "project": [
7
8
  "src/**/*.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karmaniverous/jeeves-server",
3
- "version": "3.5.2",
3
+ "version": "3.6.1",
4
4
  "description": "Secure file browser, markdown viewer, and webhook gateway with PDF/DOCX export and expiring share links",
5
5
  "keywords": [
6
6
  "fastify",
@@ -48,19 +48,18 @@
48
48
  "dependencies": {
49
49
  "@fastify/cookie": "^11.0.2",
50
50
  "@fastify/static": "^8.3.0",
51
- "@karmaniverous/jeeves": "^0.4.6",
52
- "@karmaniverous/jsonmap": "^0.3.1",
51
+ "@karmaniverous/jeeves": "^0.5.3",
52
+ "@karmaniverous/jsonmap": "^2.1.1",
53
53
  "@mermaid-js/mermaid-cli": "^11.12.0",
54
- "@turbodocx/html-to-docx": "^1.1.0",
55
- "ajv": "^8.17.1",
54
+ "@turbodocx/html-to-docx": "^1.20.1",
55
+ "ajv": "^8.18.0",
56
56
  "archiver": "^7.0.1",
57
57
  "cheerio": "^1.2.0",
58
- "fastify": "^5.2.3",
58
+ "fastify": "^5.8.4",
59
59
  "lz-string": "^1.5.0",
60
- "marked": "^17.0.1",
60
+ "marked": "^17.0.5",
61
61
  "mime-types": "^3.0.2",
62
- "package-directory": "^8.2.0",
63
- "picomatch": "^4.0.3",
62
+ "picomatch": "^4.0.4",
64
63
  "plantuml-encoder": "^1.4.0",
65
64
  "puppeteer": "^23.11.1",
66
65
  "puppeteer-core": "^23.11.1",
@@ -68,18 +67,18 @@
68
67
  "zod": "^4.3.6"
69
68
  },
70
69
  "devDependencies": {
71
- "@dotenvx/dotenvx": "^1.54.1",
70
+ "@dotenvx/dotenvx": "^1.59.1",
72
71
  "@types/archiver": "^7.0.0",
73
72
  "@types/mime-types": "^3.0.1",
74
- "@types/node": "^22.0.0",
75
- "@types/picomatch": "^4.0.2",
76
- "@vitest/coverage-v8": "^4.0.16",
73
+ "@types/node": "^25.5.2",
74
+ "@types/picomatch": "^4.0.3",
75
+ "@vitest/coverage-v8": "^4.1.2",
77
76
  "auto-changelog": "^2.5.0",
78
77
  "cross-env": "^10.1.0",
79
- "happy-dom": "^20.0.11",
78
+ "happy-dom": "^20.8.9",
80
79
  "release-it": "^19.2.4",
81
- "rimraf": "^6.0.1",
82
- "vitest": "^4.0.16"
80
+ "rimraf": "^6.1.3",
81
+ "vitest": "^4.1.2"
83
82
  },
84
83
  "auto-changelog": {
85
84
  "output": "CHANGELOG.md",
@@ -119,6 +118,9 @@
119
118
  "publish": true
120
119
  }
121
120
  },
121
+ "engines": {
122
+ "node": ">=22"
123
+ },
122
124
  "bin": {
123
125
  "jeeves-server": "./dist/src/cli/index.js"
124
126
  }
package/src/cli/index.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  * @packageDocumentation
10
10
  */
11
11
 
12
- import { createServiceCli } from '@karmaniverous/jeeves';
12
+ import { checkNodeVersion, createServiceCli } from '@karmaniverous/jeeves';
13
+
14
+ checkNodeVersion();
13
15
 
14
16
  import { serverDescriptor } from '../descriptor.js';
15
17
 
@@ -9,12 +9,26 @@
9
9
  * The CLI's `start` command uses this same logic in-process.
10
10
  */
11
11
 
12
+ import { checkNodeVersion, init } from '@karmaniverous/jeeves';
13
+
14
+ checkNodeVersion();
15
+
12
16
  import { initConfig } from '../config/index.js';
13
17
 
14
- const configIndex = process.argv.indexOf('--config');
15
- const configPath =
16
- configIndex !== -1 ? process.argv[configIndex + 1] : undefined;
18
+ function parseArg(name: string): string | undefined {
19
+ const idx = process.argv.indexOf(name);
20
+ return idx !== -1 && process.argv[idx + 1]
21
+ ? process.argv[idx + 1]
22
+ : undefined;
23
+ }
24
+
25
+ const configPath = parseArg('--config');
26
+ const workspace =
27
+ parseArg('--workspace') ?? process.env['JEEVES_WORKSPACE'] ?? '.';
28
+ const configRoot =
29
+ parseArg('--config-root') ?? process.env['JEEVES_CONFIG_ROOT'] ?? './config';
17
30
 
31
+ init({ workspacePath: workspace, configRoot });
18
32
  initConfig(configPath);
19
33
 
20
34
  // Dynamic import to ensure config is initialized before server modules load
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
12
12
 
13
13
  import { migrateConfigPath } from './migration.js';
14
14
  import { buildRuntimeConfig } from './resolve.js';
15
- import { jeevesConfigSchema } from './schema.js';
15
+ import { DEPRECATED_CONFIG_PROPS, jeevesConfigSchema } from './schema.js';
16
16
  import { substituteEnvVars } from './substituteEnvVars.js';
17
17
  import type { RuntimeConfig } from './types.js';
18
18
 
@@ -58,7 +58,27 @@ export function loadConfig(configPath?: string): RuntimeConfig {
58
58
  );
59
59
  }
60
60
 
61
- const substituted = substituteEnvVars(rawConfig);
61
+ // Strip deprecated v3.6.0 properties before validation
62
+ const cleanedConfig: Record<string, unknown> = {};
63
+ for (const [key, value] of Object.entries(rawConfig)) {
64
+ if (
65
+ DEPRECATED_CONFIG_PROPS.includes(
66
+ key as (typeof DEPRECATED_CONFIG_PROPS)[number],
67
+ )
68
+ ) {
69
+ console.warn(
70
+ `[jeeves-server] Deprecated config property "${key}" ignored. ` +
71
+ `Companion service URLs are now resolved via core config ` +
72
+ `({configRoot}/jeeves-core/config.json services.{name}.url). ` +
73
+ `Bind address is resolved via getBindAddress(). ` +
74
+ `Remove "${key}" from ${resolvedPath} to silence this warning.`,
75
+ );
76
+ } else {
77
+ cleanedConfig[key] = value;
78
+ }
79
+ }
80
+
81
+ const substituted = substituteEnvVars(cleanedConfig);
62
82
 
63
83
  const parseResult = jeevesConfigSchema.safeParse(substituted);
64
84
  if (!parseResult.success) {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
 
5
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
6
 
7
7
  import { clearConfig, getConfig, initConfig, loadConfig } from './index.js';
8
8
 
@@ -154,6 +154,82 @@ describe('loadConfig', () => {
154
154
  });
155
155
  });
156
156
 
157
+ describe('deprecated config property migration', () => {
158
+ let tmpDir: string;
159
+
160
+ beforeEach(() => {
161
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jeeves-config-'));
162
+ clearConfig();
163
+ });
164
+
165
+ afterEach(() => {
166
+ fs.rmSync(tmpDir, { recursive: true, force: true });
167
+ clearConfig();
168
+ });
169
+
170
+ it('loads config with deprecated host, watcherUrl, runnerUrl, metaUrl', () => {
171
+ const configWithDeprecated = {
172
+ ...VALID_CONFIG,
173
+ host: '0.0.0.0',
174
+ watcherUrl: 'http://localhost:1936',
175
+ runnerUrl: 'http://localhost:1937',
176
+ metaUrl: 'http://localhost:1938',
177
+ };
178
+ const configPath = writeConfig(tmpDir, configWithDeprecated);
179
+ const config = loadConfig(configPath);
180
+ expect(config.port).toBe(9999);
181
+ });
182
+
183
+ it('logs deprecation warnings for each deprecated property', () => {
184
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
185
+ try {
186
+ const configWithDeprecated = {
187
+ ...VALID_CONFIG,
188
+ host: '0.0.0.0',
189
+ watcherUrl: 'http://localhost:1936',
190
+ runnerUrl: 'http://localhost:1937',
191
+ metaUrl: 'http://localhost:1938',
192
+ };
193
+ const configPath = writeConfig(tmpDir, configWithDeprecated);
194
+ loadConfig(configPath);
195
+
196
+ const deprecatedKeys = ['host', 'watcherUrl', 'runnerUrl', 'metaUrl'];
197
+ for (const key of deprecatedKeys) {
198
+ expect(
199
+ warnSpy.mock.calls.some(
200
+ (call) =>
201
+ typeof call[0] === 'string' && call[0].includes(`"${key}"`),
202
+ ),
203
+ ).toBe(true);
204
+ }
205
+ expect(warnSpy).toHaveBeenCalledTimes(4);
206
+ } finally {
207
+ warnSpy.mockRestore();
208
+ }
209
+ });
210
+
211
+ it('strips deprecated properties from the resulting RuntimeConfig', () => {
212
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
213
+ try {
214
+ const configWithDeprecated = {
215
+ ...VALID_CONFIG,
216
+ host: '0.0.0.0',
217
+ watcherUrl: 'http://localhost:1936',
218
+ };
219
+ const configPath = writeConfig(tmpDir, configWithDeprecated);
220
+ const config = loadConfig(configPath);
221
+
222
+ const configAsRecord = config as unknown as Record<string, unknown>;
223
+ expect(configAsRecord['host']).toBeUndefined();
224
+ expect(configAsRecord['watcherUrl']).toBeUndefined();
225
+ expect(configAsRecord['runnerUrl']).toBeUndefined();
226
+ expect(configAsRecord['metaUrl']).toBeUndefined();
227
+ } finally {
228
+ warnSpy.mockRestore();
229
+ }
230
+ });
231
+ });
232
+
157
233
  describe('config singleton', () => {
158
234
  let tmpDir: string;
159
235
 
@@ -236,8 +236,6 @@ describe('buildRuntimeConfig', () => {
236
236
  it('constructs correct path fields', () => {
237
237
  const config = {
238
238
  port: 1934,
239
- host: '0.0.0.0',
240
- metaUrl: 'http://127.0.0.1:1938',
241
239
  eventTimeoutMs: 30000,
242
240
  eventLogPurgeMs: 2592000000,
243
241
  maxZipSizeMb: 100,
@@ -250,7 +250,6 @@ export function buildRuntimeConfig(
250
250
 
251
251
  return {
252
252
  port: config.port,
253
- host: config.host,
254
253
  eventTimeoutMs: config.eventTimeoutMs,
255
254
  eventLogPurgeMs: config.eventLogPurgeMs,
256
255
  maxZipSizeMb: config.maxZipSizeMb,
@@ -284,9 +283,6 @@ export function buildRuntimeConfig(
284
283
  googleAuth: config.auth.google ?? null,
285
284
  sessionSecret: config.auth.sessionSecret ?? null,
286
285
  internalInsiderKey: deriveInternalKey(resolvedKeys),
287
- runnerUrl: config.runnerUrl,
288
- watcherUrl: config.watcherUrl,
289
- metaUrl: config.metaUrl,
290
286
  diagramCachePath: config.diagramCachePath,
291
287
  configPath,
292
288
  eventsLog: path.join(rootDir, 'logs', 'webhook-events.jsonl'),
@@ -1,6 +1,14 @@
1
1
  import { SERVER_PORT } from '@karmaniverous/jeeves';
2
2
  import { z } from 'zod';
3
3
 
4
+ /** Config properties deprecated in v3.6.0 (now resolved via core services). */
5
+ export const DEPRECATED_CONFIG_PROPS = [
6
+ 'host',
7
+ 'runnerUrl',
8
+ 'watcherUrl',
9
+ 'metaUrl',
10
+ ] as const;
11
+
4
12
  /** Supported authentication methods */
5
13
  export const authModeSchema = z.enum(['google', 'keys']);
6
14
 
@@ -101,12 +109,6 @@ function getScopeRefs(scopes: unknown): string[] {
101
109
  export const jeevesConfigSchema = z
102
110
  .object({
103
111
  port: z.number().int().positive().default(SERVER_PORT),
104
- /**
105
- * Network interface to bind the server to.
106
- * Default: '0.0.0.0' (all interfaces — required for external access by insiders, share links, etc.)
107
- * Set to '127.0.0.1' to restrict to loopback only.
108
- */
109
- host: z.string().min(1).default('0.0.0.0'),
110
112
  chromePath: z.string().min(1),
111
113
  auth: authSchema,
112
114
  /** Named scope definitions, referenced by insiders/keys/outsiderPolicy */
@@ -125,11 +127,6 @@ export const jeevesConfigSchema = z
125
127
  * Default: \{ root: '/' \}
126
128
  */
127
129
  roots: z.record(z.string(), z.string()).optional(),
128
- /**
129
- * URL of the jeeves-runner API for process dashboard proxy.
130
- * Default: 'http://127.0.0.1:1937'
131
- */
132
- runnerUrl: z.url().optional(),
133
130
  /** @deprecated Mermaid is now bundled. This field is ignored but kept for backward compatibility. */
134
131
  mermaidCliPath: z.string().optional(),
135
132
  /**
@@ -152,16 +149,6 @@ export const jeevesConfigSchema = z
152
149
  * Defaults to `.diagram-cache` in the server working directory.
153
150
  */
154
151
  diagramCachePath: z.string().optional(),
155
- /**
156
- * URL of the jeeves-watcher API for semantic search.
157
- * When set, the search UI appears in the header. Example: 'http://127.0.0.1:1936'
158
- */
159
- watcherUrl: z.url().optional(),
160
- /**
161
- * URL of the jeeves-meta API for synthesis engine health checks.
162
- * Default: 'http://127.0.0.1:1938'
163
- */
164
- metaUrl: z.url().default('http://127.0.0.1:1938'),
165
152
  /**
166
153
  * Global outsider policy â€" constrains which paths are eligible for outsider sharing.
167
154
  * Uses the same allow/deny model as insider scopes.
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Deep-walks config objects and replaces `${VAR_NAME}` patterns with environment variable values.
5
5
  * Ported from jeeves-watcher — candidate for hoisting to shared `@karmaniverous/jeeves-config`.
6
+ *
7
+ * // TODO: replace with core utility when hoisted (see jeeves-core issue for substituteEnvVars hoist)
6
8
  */
7
9
 
8
10
  const ENV_PATTERN = /\$\{([^}]+)\}/g;
@@ -50,7 +50,6 @@ export interface ResolvedInsider {
50
50
  */
51
51
  export interface RuntimeConfig {
52
52
  port: number;
53
- host: string;
54
53
  eventTimeoutMs: number;
55
54
  eventLogPurgeMs: number;
56
55
  maxZipSizeMb: number;
@@ -63,9 +62,6 @@ export interface RuntimeConfig {
63
62
  servers: string[];
64
63
  };
65
64
  diagramCachePath?: string;
66
- runnerUrl?: string;
67
- watcherUrl?: string;
68
- metaUrl?: string;
69
65
  outsiderPolicy: NormalizedScopes | null;
70
66
  events: JeevesConfig['events'];
71
67
  authModes: AuthMode[];
package/src/descriptor.ts CHANGED
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
 
10
10
  import {
11
+ init,
11
12
  jeevesComponentDescriptorSchema,
12
13
  SERVER_PORT,
13
14
  } from '@karmaniverous/jeeves';
@@ -38,11 +39,18 @@ export const serverDescriptor = jeevesComponentDescriptorSchema.parse({
38
39
  default: 'CHANGE_ME_defaultKey',
39
40
  },
40
41
  }),
41
- onConfigApply: async () => {
42
+ onConfigApply: async (config: Record<string, unknown> | undefined) => {
43
+ void config; // config argument reserved for future use
42
44
  const { resetConfig } = await import('./config/index.js');
43
45
  resetConfig();
44
46
  },
45
47
  run: async (configPath: string) => {
48
+ // Ensure core init() runs before server modules that depend on
49
+ // getServiceUrl() / getBindAddress()
50
+ init({
51
+ workspacePath: process.env['JEEVES_WORKSPACE'] ?? '.',
52
+ configRoot: process.env['JEEVES_CONFIG_ROOT'] ?? './config',
53
+ });
46
54
  const { initConfig } = await import('./config/index.js');
47
55
  initConfig(configPath);
48
56
  await import('./server.js');
@@ -50,7 +50,7 @@ export const authStatusRoutes: FastifyPluginAsync = async (fastify) => {
50
50
  picture,
51
51
  isInsider: !!insider?.seed,
52
52
  keyCreatedAt: insider?.keyCreatedAt ?? null,
53
- searchEnabled: !!config.watcherUrl,
53
+ searchEnabled: true,
54
54
  });
55
55
  }
56
56
 
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+
7
+ import { mapDirectoryEntry } from './directory.js';
8
+
9
+ describe('directory entry mapping', () => {
10
+ let tmpDir: string;
11
+
12
+ beforeEach(() => {
13
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jeeves-dir-'));
14
+ });
15
+
16
+ afterEach(() => {
17
+ fs.rmSync(tmpDir, { recursive: true, force: true });
18
+ });
19
+
20
+ it('returns itemCount for directory entries', async () => {
21
+ const subDir = path.join(tmpDir, 'sub');
22
+ fs.mkdirSync(subDir);
23
+ fs.writeFileSync(path.join(subDir, 'a.txt'), 'a');
24
+ fs.writeFileSync(path.join(subDir, 'b.txt'), 'b');
25
+
26
+ const entries = fs.readdirSync(tmpDir, { withFileTypes: true });
27
+ const dirEntry = entries.find((e) => e.name === 'sub');
28
+ expect(dirEntry).toBeDefined();
29
+ const mapped = await mapDirectoryEntry(dirEntry!, tmpDir);
30
+ expect(mapped.type).toBe('directory');
31
+ expect(mapped.itemCount).toBe(2);
32
+ });
33
+
34
+ it('returns null itemCount for file entries', async () => {
35
+ fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'content');
36
+ const entries = fs.readdirSync(tmpDir, { withFileTypes: true });
37
+ const fileEntry = entries.find((e) => e.name === 'file.txt');
38
+ expect(fileEntry).toBeDefined();
39
+ const mapped = await mapDirectoryEntry(fileEntry!, tmpDir);
40
+ expect(mapped.type).toBe('file');
41
+ expect(mapped.itemCount).toBeNull();
42
+ expect(mapped.size).toBeGreaterThan(0);
43
+ });
44
+
45
+ it('counts mixed contents (files + subdirectories)', async () => {
46
+ const subDir = path.join(tmpDir, 'mixed');
47
+ fs.mkdirSync(subDir);
48
+ fs.writeFileSync(path.join(subDir, 'file1.txt'), 'a');
49
+ fs.writeFileSync(path.join(subDir, 'file2.txt'), 'b');
50
+ fs.mkdirSync(path.join(subDir, 'nested'));
51
+
52
+ const entries = fs.readdirSync(tmpDir, { withFileTypes: true });
53
+ const dirEntry = entries.find((e) => e.name === 'mixed');
54
+ expect(dirEntry).toBeDefined();
55
+ const mapped = await mapDirectoryEntry(dirEntry!, tmpDir);
56
+ expect(mapped.itemCount).toBe(3);
57
+ });
58
+
59
+ it('returns null itemCount when directory read fails', async () => {
60
+ const fakeDirent = {
61
+ name: 'nonexistent',
62
+ isDirectory: () => true,
63
+ isFile: () => false,
64
+ isBlockDevice: () => false,
65
+ isCharacterDevice: () => false,
66
+ isFIFO: () => false,
67
+ isSocket: () => false,
68
+ isSymbolicLink: () => false,
69
+ parentPath: tmpDir,
70
+ path: tmpDir,
71
+ } as fs.Dirent;
72
+
73
+ const mapped = await mapDirectoryEntry(fakeDirent, tmpDir);
74
+ expect(mapped.itemCount).toBeNull();
75
+ expect(mapped.mtime).toBeNull();
76
+ });
77
+ });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import fs from 'node:fs';
8
+ import fsp from 'node:fs/promises';
8
9
  import path from 'node:path';
9
10
 
10
11
  import type { FastifyPluginAsync } from 'fastify';
@@ -23,6 +24,57 @@ import {
23
24
  urlPathToFs,
24
25
  } from '../../util/platform.js';
25
26
 
27
+ /** Result shape returned by {@link mapDirectoryEntry}. */
28
+ export interface DirectoryEntryInfo {
29
+ name: string;
30
+ type: string;
31
+ ext: string;
32
+ size: number | null;
33
+ mtime: string | null;
34
+ itemCount: number | null;
35
+ }
36
+
37
+ /**
38
+ * Map a single directory entry to its API representation.
39
+ *
40
+ * Uses async fs operations so the event loop is not blocked
41
+ * when listing large directories.
42
+ */
43
+ export async function mapDirectoryEntry(
44
+ entry: fs.Dirent,
45
+ parentDir: string,
46
+ ): Promise<DirectoryEntryInfo> {
47
+ const entryPath = path.join(parentDir, entry.name);
48
+ let size: number | null = null;
49
+ let mtime: string | null = null;
50
+ let itemCount: number | null = null;
51
+ const ext = path.extname(entry.name).toLowerCase();
52
+ try {
53
+ const entryStats = await fsp.stat(entryPath);
54
+ mtime = entryStats.mtime.toISOString().split('T')[0];
55
+ if (entry.isDirectory()) {
56
+ try {
57
+ const children = await fsp.readdir(entryPath);
58
+ itemCount = children.length;
59
+ } catch {
60
+ /* permission denied, etc. */
61
+ }
62
+ } else {
63
+ size = entryStats.size;
64
+ }
65
+ } catch {
66
+ /* ignore */
67
+ }
68
+ return {
69
+ name: entry.name,
70
+ type: entry.isDirectory() ? 'directory' : 'file',
71
+ ext,
72
+ size,
73
+ mtime,
74
+ itemCount,
75
+ };
76
+ }
77
+
26
78
  // eslint-disable-next-line @typescript-eslint/require-await
27
79
  export const directoryRoutes: FastifyPluginAsync = async (fastify) => {
28
80
  const roots = getRoots(getConfig().roots);
@@ -41,7 +93,7 @@ export const directoryRoutes: FastifyPluginAsync = async (fastify) => {
41
93
  return reply.code(404).send({ error: 'Not found', path: resolved });
42
94
  }
43
95
 
44
- const stats = fs.statSync(resolved);
96
+ const stats = await fsp.stat(resolved);
45
97
  if (!stats.isDirectory()) {
46
98
  const ext = path.extname(resolved).toLowerCase();
47
99
  return reply.send({
@@ -56,7 +108,9 @@ export const directoryRoutes: FastifyPluginAsync = async (fastify) => {
56
108
  const isInsider = request.accessMode === 'insider';
57
109
  const insiderScopes = request.insiderScopes ?? null;
58
110
 
59
- const allEntries = fs.readdirSync(resolved, { withFileTypes: true });
111
+ const allEntries = await fsp.readdir(resolved, {
112
+ withFileTypes: true,
113
+ });
60
114
 
61
115
  const entries = insiderScopes
62
116
  ? allEntries.filter((entry) => {
@@ -82,26 +136,9 @@ export const directoryRoutes: FastifyPluginAsync = async (fastify) => {
82
136
  return a.name.localeCompare(b.name);
83
137
  });
84
138
 
85
- const result = sorted.map((entry) => {
86
- const entryPath = path.join(resolved, entry.name);
87
- let size: number | null = null;
88
- let mtime: string | null = null;
89
- const ext = path.extname(entry.name).toLowerCase();
90
- try {
91
- const entryStats = fs.statSync(entryPath);
92
- mtime = entryStats.mtime.toISOString().split('T')[0];
93
- if (!entry.isDirectory()) size = entryStats.size;
94
- } catch {
95
- /* ignore */
96
- }
97
- return {
98
- name: entry.name,
99
- type: entry.isDirectory() ? 'directory' : 'file',
100
- ext,
101
- size,
102
- mtime,
103
- };
104
- });
139
+ const result = await Promise.all(
140
+ sorted.map((entry) => mapDirectoryEntry(entry, resolved)),
141
+ );
105
142
 
106
143
  const breadcrumbs = breadcrumbParts(resolved, roots);
107
144
  const matchedPath = request.authMatchedPath ?? null;