@karmaniverous/jeeves-server 3.4.2 → 3.5.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 (127) hide show
  1. package/.tsbuildinfo +1 -1
  2. package/CHANGELOG.md +47 -1
  3. package/README.md +18 -17
  4. package/client/package.json +20 -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 +6 -41
  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 +51 -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 +6 -46
  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 +60 -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
@@ -2,15 +2,15 @@
2
2
  * @packageDocumentation
3
3
  *
4
4
  * Config loading and singleton management.
5
- * Loads config via cosmiconfig, validates with Zod, applies env var substitution,
5
+ * Loads config from a JSON file, validates with Zod, applies env var substitution,
6
6
  * resolves runtime types via resolve.ts, and exposes getConfig()/resetConfig().
7
7
  */
8
8
 
9
+ import fs from 'node:fs';
9
10
  import path from 'node:path';
10
11
  import { fileURLToPath } from 'node:url';
11
12
 
12
- import { cosmiconfig } from 'cosmiconfig';
13
-
13
+ import { migrateConfigPath } from './migration.js';
14
14
  import { buildRuntimeConfig } from './resolve.js';
15
15
  import { jeevesConfigSchema } from './schema.js';
16
16
  import { substituteEnvVars } from './substituteEnvVars.js';
@@ -19,59 +19,72 @@ import type { RuntimeConfig } from './types.js';
19
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
20
  const rootDir = path.resolve(__dirname, '../../..');
21
21
 
22
- const MODULE_NAME = 'jeeves-server';
23
-
24
22
  /**
25
- * Load and validate jeeves-server configuration via cosmiconfig.
26
- *
27
- * Searches for `jeeves-server.config.{json,yaml,yml,js,ts,cjs,mjs}`
28
- * or `.jeeves-serverrc` in the package root and parent directories.
23
+ * Load and validate jeeves-server configuration from a JSON file.
29
24
  *
30
25
  * @param configPath - Optional explicit path to a config file.
31
26
  * @returns Resolved runtime configuration.
32
27
  */
33
- export async function loadConfig(configPath?: string): Promise<RuntimeConfig> {
34
- const explorer = cosmiconfig(MODULE_NAME, {
35
- searchPlaces: [
36
- 'package.json',
37
- `.${MODULE_NAME}rc`,
38
- `.${MODULE_NAME}rc.json`,
39
- `.${MODULE_NAME}rc.yaml`,
40
- `.${MODULE_NAME}rc.yml`,
41
- `${MODULE_NAME}.config.json`,
42
- `${MODULE_NAME}.config.yaml`,
43
- `${MODULE_NAME}.config.yml`,
44
- `${MODULE_NAME}.config.js`,
45
- `${MODULE_NAME}.config.ts`,
46
- `${MODULE_NAME}.config.mjs`,
47
- `${MODULE_NAME}.config.cjs`,
48
- ],
49
- });
50
-
51
- const result = configPath
52
- ? await explorer.load(configPath)
53
- : await explorer.search(rootDir);
54
-
55
- if (!result || result.isEmpty) {
28
+ export function loadConfig(configPath?: string): RuntimeConfig {
29
+ const resolvedPath = configPath
30
+ ? migrateConfigPath(configPath)
31
+ : findDefaultConfig();
32
+
33
+ if (!fs.existsSync(resolvedPath)) {
34
+ throw new Error(
35
+ `Configuration file not found: ${resolvedPath}\n` +
36
+ `Create a jeeves-server config.json file or pass --config <path>.`,
37
+ );
38
+ }
39
+
40
+ // Reject non-JSON config files
41
+ const ext = path.extname(resolvedPath).toLowerCase();
42
+ if (ext && ext !== '.json') {
43
+ throw new Error(
44
+ `Unsupported config file format: ${ext}\n` +
45
+ `Only JSON configuration files are supported. ` +
46
+ `Please convert your config to JSON format.`,
47
+ );
48
+ }
49
+
50
+ const rawContent = fs.readFileSync(resolvedPath, 'utf8');
51
+ let rawConfig: Record<string, unknown>;
52
+ try {
53
+ rawConfig = JSON.parse(rawContent) as Record<string, unknown>;
54
+ } catch (err) {
56
55
  throw new Error(
57
- `No jeeves-server configuration found. Create a jeeves-server.config.json (or .yaml) file.\n` +
58
- `Searched from: ${rootDir}`,
56
+ `Failed to parse config file ${resolvedPath}: ${err instanceof Error ? err.message : String(err)}`,
57
+ { cause: err },
59
58
  );
60
59
  }
61
60
 
62
- const substituted = substituteEnvVars(
63
- result.config as Record<string, unknown>,
64
- );
61
+ const substituted = substituteEnvVars(rawConfig);
65
62
 
66
63
  const parseResult = jeevesConfigSchema.safeParse(substituted);
67
64
  if (!parseResult.success) {
68
65
  const issues = parseResult.error.issues
69
66
  .map((i) => ` - ${i.path.join('.')}: ${i.message}`)
70
67
  .join('\n');
71
- throw new Error(`Invalid configuration in ${result.filepath}:\n${issues}`);
68
+ throw new Error(`Invalid configuration in ${resolvedPath}:\n${issues}`);
72
69
  }
73
70
 
74
- return buildRuntimeConfig(parseResult.data, rootDir, result.filepath);
71
+ return buildRuntimeConfig(parseResult.data, rootDir, resolvedPath);
72
+ }
73
+
74
+ /**
75
+ * Find the default config file in the package root directory.
76
+ */
77
+ function findDefaultConfig(): string {
78
+ // Try new convention first
79
+ const newPath = path.join(rootDir, 'jeeves-server', 'config.json');
80
+ if (fs.existsSync(newPath)) return newPath;
81
+
82
+ // Fall back to old convention
83
+ const oldPath = path.join(rootDir, 'jeeves-server.config.json');
84
+ if (fs.existsSync(oldPath)) return oldPath;
85
+
86
+ // Return new path for error message
87
+ return newPath;
75
88
  }
76
89
 
77
90
  let configInstance: RuntimeConfig | null = null;
@@ -101,9 +114,9 @@ export function getConfig(): RuntimeConfig {
101
114
  * Initialize the config singleton. Must be called once at startup.
102
115
  * @param configPath - Optional explicit path to a config file.
103
116
  */
104
- export async function initConfig(configPath?: string): Promise<RuntimeConfig> {
117
+ export function initConfig(configPath?: string): RuntimeConfig {
105
118
  lastConfigPath = configPath;
106
- configInstance = await loadConfig(configPath);
119
+ configInstance = loadConfig(configPath);
107
120
  return configInstance;
108
121
  }
109
122
 
@@ -111,8 +124,8 @@ export async function initConfig(configPath?: string): Promise<RuntimeConfig> {
111
124
  * Reload the config singleton from the last-used config path.
112
125
  * Call after mutating state that affects resolved config (e.g., key rotation).
113
126
  */
114
- export async function resetConfig(): Promise<void> {
115
- configInstance = await loadConfig(lastConfigPath);
127
+ export function resetConfig(): void {
128
+ configInstance = loadConfig(lastConfigPath);
116
129
  }
117
130
 
118
131
  /**
@@ -36,28 +36,26 @@ describe('loadConfig', () => {
36
36
  clearConfig();
37
37
  });
38
38
 
39
- it('loads a valid JSON config file', async () => {
39
+ it('loads a valid JSON config file', () => {
40
40
  const configPath = writeConfig(tmpDir, VALID_CONFIG);
41
- const config = await loadConfig(configPath);
41
+ const config = loadConfig(configPath);
42
42
  expect(config.port).toBe(9999);
43
43
  expect(config.chromePath).toBe('/usr/bin/chromium');
44
- expect(config.configPath).toBe(configPath);
44
+ // Migration moves jeeves-server.config.json → jeeves-server/config.json
45
+ const expectedPath = path.join(tmpDir, 'jeeves-server', 'config.json');
46
+ expect(config.configPath).toBe(expectedPath);
45
47
  });
46
48
 
47
- it('throws on missing config', async () => {
48
- await expect(
49
- loadConfig(path.join(tmpDir, 'nonexistent.json')),
50
- ).rejects.toThrow();
49
+ it('throws on missing config', () => {
50
+ expect(() => loadConfig(path.join(tmpDir, 'nonexistent.json'))).toThrow();
51
51
  });
52
52
 
53
- it('throws on invalid config (missing auth)', async () => {
53
+ it('throws on invalid config (missing auth)', () => {
54
54
  const configPath = writeConfig(tmpDir, { port: 1234 });
55
- await expect(loadConfig(configPath)).rejects.toThrow(
56
- 'Invalid configuration',
57
- );
55
+ expect(() => loadConfig(configPath)).toThrow('Invalid configuration');
58
56
  });
59
57
 
60
- it('applies env var substitution', async () => {
58
+ it('applies env var substitution', () => {
61
59
  const original = process.env['TEST_CHROME_PATH'];
62
60
  process.env['TEST_CHROME_PATH'] = '/custom/chrome';
63
61
  try {
@@ -65,7 +63,7 @@ describe('loadConfig', () => {
65
63
  ...VALID_CONFIG,
66
64
  chromePath: '${TEST_CHROME_PATH}',
67
65
  });
68
- const config = await loadConfig(configPath);
66
+ const config = loadConfig(configPath);
69
67
  expect(config.chromePath).toBe('/custom/chrome');
70
68
  } finally {
71
69
  if (original === undefined) delete process.env['TEST_CHROME_PATH'];
@@ -73,15 +71,15 @@ describe('loadConfig', () => {
73
71
  }
74
72
  });
75
73
 
76
- it('applies default port when omitted', async () => {
74
+ it('applies default port when omitted', () => {
77
75
  const noPort = { ...VALID_CONFIG };
78
76
  delete (noPort as Record<string, unknown>).port;
79
77
  const configPath = writeConfig(tmpDir, noPort);
80
- const config = await loadConfig(configPath);
78
+ const config = loadConfig(configPath);
81
79
  expect(config.port).toBe(1934);
82
80
  });
83
81
 
84
- it('rejects _plugin key with scopes', async () => {
82
+ it('rejects _plugin key with scopes', () => {
85
83
  const configPath = writeConfig(tmpDir, {
86
84
  ...VALID_CONFIG,
87
85
  keys: {
@@ -89,12 +87,12 @@ describe('loadConfig', () => {
89
87
  _plugin: { key: 'c'.repeat(64), scopes: ['/restricted'] },
90
88
  },
91
89
  });
92
- await expect(loadConfig(configPath)).rejects.toThrow(
90
+ expect(() => loadConfig(configPath)).toThrow(
93
91
  '_plugin key must not have scopes',
94
92
  );
95
93
  });
96
94
 
97
- it('rejects _internal key with scopes', async () => {
95
+ it('rejects _internal key with scopes', () => {
98
96
  const configPath = writeConfig(tmpDir, {
99
97
  ...VALID_CONFIG,
100
98
  keys: {
@@ -102,12 +100,12 @@ describe('loadConfig', () => {
102
100
  _internal: { key: 'b'.repeat(64), scopes: ['/restricted'] },
103
101
  },
104
102
  });
105
- await expect(loadConfig(configPath)).rejects.toThrow(
103
+ expect(() => loadConfig(configPath)).toThrow(
106
104
  '_internal key must not have scopes',
107
105
  );
108
106
  });
109
107
 
110
- it('accepts _plugin key without scopes', async () => {
108
+ it('accepts _plugin key without scopes', () => {
111
109
  const configPath = writeConfig(tmpDir, {
112
110
  ...VALID_CONFIG,
113
111
  keys: {
@@ -115,13 +113,13 @@ describe('loadConfig', () => {
115
113
  _plugin: 'c'.repeat(64),
116
114
  },
117
115
  });
118
- const config = await loadConfig(configPath);
116
+ const config = loadConfig(configPath);
119
117
  expect(config.resolvedKeys.find((k) => k.name === '_plugin')?.seed).toBe(
120
118
  'c'.repeat(64),
121
119
  );
122
120
  });
123
121
 
124
- it('rejects undefined named scope references', async () => {
122
+ it('rejects undefined named scope references', () => {
125
123
  const configPath = writeConfig(tmpDir, {
126
124
  ...VALID_CONFIG,
127
125
  scopes: { restricted: { allow: ['/**'], deny: ['/secret'] } },
@@ -131,12 +129,12 @@ describe('loadConfig', () => {
131
129
  },
132
130
  });
133
131
 
134
- await expect(loadConfig(configPath)).rejects.toThrow(
132
+ expect(() => loadConfig(configPath)).toThrow(
135
133
  'Scope "missing" is not defined',
136
134
  );
137
135
  });
138
136
 
139
- it('does not treat path globs as named scope references', async () => {
137
+ it('does not treat path globs as named scope references', () => {
140
138
  const configPath = writeConfig(tmpDir, {
141
139
  ...VALID_CONFIG,
142
140
  insiders: {
@@ -144,7 +142,7 @@ describe('loadConfig', () => {
144
142
  },
145
143
  });
146
144
 
147
- const config = await loadConfig(configPath);
145
+ const config = loadConfig(configPath);
148
146
  expect(
149
147
  config.resolvedInsiders.find((i) => i.email === 'a@example.com')?.scopes,
150
148
  ).toEqual({
@@ -173,16 +171,16 @@ describe('config singleton', () => {
173
171
  expect(() => getConfig()).toThrow('Config not initialized');
174
172
  });
175
173
 
176
- it('initConfig populates getConfig', async () => {
174
+ it('initConfig populates getConfig', () => {
177
175
  const configPath = writeConfig(tmpDir, VALID_CONFIG);
178
- await initConfig(configPath);
176
+ initConfig(configPath);
179
177
  const config = getConfig();
180
178
  expect(config.port).toBe(9999);
181
179
  });
182
180
 
183
- it('clearConfig clears the singleton', async () => {
181
+ it('clearConfig clears the singleton', () => {
184
182
  const configPath = writeConfig(tmpDir, VALID_CONFIG);
185
- await initConfig(configPath);
183
+ initConfig(configPath);
186
184
  clearConfig();
187
185
  expect(() => getConfig()).toThrow('Config not initialized');
188
186
  });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Config path migration — handles old → new config path convention.
3
+ *
4
+ * Old convention: `<configDir>/jeeves-server.config.json`
5
+ * New convention: `<configDir>/jeeves-server/config.json`
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const NON_JSON_EXTENSIONS = new Set([
12
+ '.ts',
13
+ '.yaml',
14
+ '.yml',
15
+ '.toml',
16
+ '.mjs',
17
+ '.cjs',
18
+ '.js',
19
+ ]);
20
+
21
+ /**
22
+ * Migrate config path from old convention to new convention if needed.
23
+ *
24
+ * If the passed path matches the old convention (jeeves-server.config.json)
25
+ * and the new path does not exist, migrates the file. If non-JSON config
26
+ * is found, rejects with a clear error.
27
+ *
28
+ * @param configPath - The config path passed via --config CLI flag.
29
+ * @returns The resolved config path (may be the new path after migration).
30
+ */
31
+ export function migrateConfigPath(configPath: string): string {
32
+ const ext = path.extname(configPath).toLowerCase();
33
+
34
+ // Reject non-JSON config files
35
+ if (NON_JSON_EXTENSIONS.has(ext)) {
36
+ throw new Error(
37
+ `Unsupported config file format: ${ext}\n` +
38
+ `Only JSON configuration files are supported. ` +
39
+ `Please convert your config to JSON format.`,
40
+ );
41
+ }
42
+
43
+ const basename = path.basename(configPath);
44
+ const configDir = path.dirname(configPath);
45
+
46
+ // Check if this matches the old convention
47
+ if (basename !== 'jeeves-server.config.json') {
48
+ return configPath;
49
+ }
50
+
51
+ const newDir = path.join(configDir, 'jeeves-server');
52
+ const newPath = path.join(newDir, 'config.json');
53
+
54
+ // If new path already exists, use it
55
+ if (fs.existsSync(newPath)) {
56
+ console.log(
57
+ `[config-migration] Using new config path: ${newPath} ` +
58
+ `(old path ${configPath} is superseded)`,
59
+ );
60
+ return newPath;
61
+ }
62
+
63
+ // If old path exists, migrate it
64
+ if (fs.existsSync(configPath)) {
65
+ console.log(
66
+ `[config-migration] Migrating config: ${configPath} → ${newPath}`,
67
+ );
68
+ fs.mkdirSync(newDir, { recursive: true });
69
+ fs.renameSync(configPath, newPath);
70
+ console.log(`[config-migration] Migration complete.`);
71
+ return newPath;
72
+ }
73
+
74
+ // Neither exists — return the passed path for downstream error handling
75
+ return configPath;
76
+ }
@@ -1,4 +1,5 @@
1
- import { z } from 'zod';
1
+ import { SERVER_PORT } from '@karmaniverous/jeeves';
2
+ import { z } from 'zod';
2
3
 
3
4
  /** Supported authentication methods */
4
5
  export const authModeSchema = z.enum(['google', 'keys']);
@@ -99,7 +100,7 @@ function getScopeRefs(scopes: unknown): string[] {
99
100
  /** Top-level Jeeves Server configuration */
100
101
  export const jeevesConfigSchema = z
101
102
  .object({
102
- port: z.number().int().positive().default(1934),
103
+ port: z.number().int().positive().default(SERVER_PORT),
103
104
  /**
104
105
  * Network interface to bind the server to.
105
106
  * Default: '0.0.0.0' (all interfaces — required for external access by insiders, share links, etc.)
@@ -126,7 +127,7 @@ export const jeevesConfigSchema = z
126
127
  roots: z.record(z.string(), z.string()).optional(),
127
128
  /**
128
129
  * URL of the jeeves-runner API for process dashboard proxy.
129
- * Default: 'http://127.0.0.1:3100'
130
+ * Default: 'http://127.0.0.1:1937'
130
131
  */
131
132
  runnerUrl: z.url().optional(),
132
133
  /** @deprecated Mermaid is now bundled. This field is ignored but kept for backward compatibility. */
@@ -153,7 +154,7 @@ export const jeevesConfigSchema = z
153
154
  diagramCachePath: z.string().optional(),
154
155
  /**
155
156
  * URL of the jeeves-watcher API for semantic search.
156
- * When set, the search UI appears in the header. Example: 'http://localhost:3458'
157
+ * When set, the search UI appears in the header. Example: 'http://127.0.0.1:1936'
157
158
  */
158
159
  watcherUrl: z.url().optional(),
159
160
  /**
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Server-side JeevesComponentDescriptor for the jeeves-server component.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ import {
11
+ jeevesComponentDescriptorSchema,
12
+ SERVER_PORT,
13
+ } from '@karmaniverous/jeeves';
14
+
15
+ import { jeevesConfigSchema } from './config/schema.js';
16
+ import { packageVersion } from './util/packageVersion.js';
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+
20
+ /** Absolute path to the start-server entry point (resolves correctly from any cwd). */
21
+ const startServerPath = path.resolve(__dirname, 'cli', 'start-server.js');
22
+
23
+ export const serverDescriptor = jeevesComponentDescriptorSchema.parse({
24
+ name: 'server',
25
+ version: packageVersion,
26
+ servicePackage: '@karmaniverous/jeeves-server',
27
+ pluginPackage: '@karmaniverous/jeeves-server-openclaw',
28
+ defaultPort: SERVER_PORT,
29
+ configSchema: jeevesConfigSchema,
30
+ configFileName: 'config.json',
31
+ initTemplate: () => ({
32
+ chromePath: 'CHANGE_ME_chromePath',
33
+ auth: {
34
+ modes: ['keys'],
35
+ sessionSecret: 'CHANGE_ME_sessionSecret',
36
+ },
37
+ keys: {
38
+ default: 'CHANGE_ME_defaultKey',
39
+ },
40
+ }),
41
+ onConfigApply: async () => {
42
+ const { resetConfig } = await import('./config/index.js');
43
+ resetConfig();
44
+ },
45
+ run: async (configPath: string) => {
46
+ const { initConfig } = await import('./config/index.js');
47
+ initConfig(configPath);
48
+ await import('./server.js');
49
+ },
50
+ startCommand: (configPath: string) => [
51
+ 'node',
52
+ startServerPath,
53
+ '--config',
54
+ configPath,
55
+ ],
56
+ sectionId: 'Server',
57
+ refreshIntervalSeconds: 61,
58
+ generateToolsContent: () => '',
59
+ dependencies: { hard: [], soft: ['watcher', 'runner', 'meta'] },
60
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Tests for diagram export route handlers.
3
+ *
4
+ * Validates the cache-first rendering pipeline shared by Mermaid and PlantUML
5
+ * handlers, and the path resolution + error handling for both routes.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
15
+
16
+ // Mock config before importing the module under test
17
+ const tmpDir = path.join(tmpdir(), `diagramExport-test-${String(Date.now())}`);
18
+
19
+ vi.mock('../../config/index.js', () => ({
20
+ getConfig: () => ({
21
+ roots: {},
22
+ }),
23
+ }));
24
+
25
+ vi.mock('../../services/diagramCache.js', () => {
26
+ let cache: Record<string, Buffer> = {};
27
+ return {
28
+ getCachedDiagramBuffer: (
29
+ engine: string,
30
+ source: string,
31
+ format: string,
32
+ ) => {
33
+ const key = `${engine}:${source}:${format}`;
34
+ return cache[key] ?? null;
35
+ },
36
+ cacheDiagramBuffer: (
37
+ engine: string,
38
+ source: string,
39
+ buffer: Buffer,
40
+ format: string,
41
+ ) => {
42
+ cache[`${engine}:${source}:${format}`] = buffer;
43
+ },
44
+ _reset: () => {
45
+ cache = {};
46
+ },
47
+ };
48
+ });
49
+
50
+ vi.mock('../../services/mermaid.js', () => ({
51
+ renderMermaidToFile: vi.fn(),
52
+ }));
53
+
54
+ vi.mock('../../services/plantuml.js', () => ({
55
+ getPlantUmlFormats: () => ['svg', 'png'],
56
+ renderPlantUmlToBuffer: vi.fn(),
57
+ }));
58
+
59
+ const { diagramExportRoutes } = await import('./diagramExport.js');
60
+ const { renderMermaidToFile } = await import('../../services/mermaid.js');
61
+ const { renderPlantUmlToBuffer } = await import('../../services/plantuml.js');
62
+
63
+ /** Minimal Fastify reply mock that captures sent data. */
64
+ function createReplyMock() {
65
+ const headers: Record<string, string> = {};
66
+ let sentData: unknown = null;
67
+ let statusCode = 200;
68
+ const reply = {
69
+ code: (c: number) => {
70
+ statusCode = c;
71
+ return reply;
72
+ },
73
+ header: (key: string, value: string) => {
74
+ headers[key.toLowerCase()] = value;
75
+ return reply;
76
+ },
77
+ send: (data: unknown) => {
78
+ sentData = data;
79
+ return reply;
80
+ },
81
+ get statusCode() {
82
+ return statusCode;
83
+ },
84
+ get headers() {
85
+ return headers;
86
+ },
87
+ get sentData() {
88
+ return sentData;
89
+ },
90
+ };
91
+ return reply;
92
+ }
93
+
94
+ describe('diagramExportRoutes', () => {
95
+ const routes: Record<
96
+ string,
97
+ (
98
+ req: { params: Record<string, string>; query: Record<string, string> },
99
+ reply: ReturnType<typeof createReplyMock>,
100
+ ) => Promise<void>
101
+ > = {};
102
+
103
+ beforeEach(async () => {
104
+ fs.mkdirSync(tmpDir, { recursive: true });
105
+
106
+ const fakeFastify = {
107
+ get: (
108
+ routePath: string,
109
+ handler: (req: unknown, reply: unknown) => Promise<void>,
110
+ ) => {
111
+ routes[routePath] = handler as (typeof routes)[string];
112
+ },
113
+ };
114
+
115
+ await diagramExportRoutes(fakeFastify as never, {});
116
+ });
117
+
118
+ afterEach(() => {
119
+ fs.rmSync(tmpDir, { recursive: true, force: true });
120
+ vi.restoreAllMocks();
121
+ });
122
+
123
+ it('returns 400 when no path is provided for mermaid export', async () => {
124
+ const reply = createReplyMock();
125
+ await routes['/api/mermaid-export/*'](
126
+ { params: { '*': '' }, query: {} },
127
+ reply,
128
+ );
129
+ expect(reply.statusCode).toBe(400);
130
+ });
131
+
132
+ it('returns 404 for non-existent mermaid file', async () => {
133
+ const reply = createReplyMock();
134
+ await routes['/api/mermaid-export/*'](
135
+ { params: { '*': 'nonexistent.mmd' }, query: {} },
136
+ reply,
137
+ );
138
+ expect(reply.statusCode).toBe(404);
139
+ });
140
+
141
+ it('returns 400 when no path is provided for plantuml export', async () => {
142
+ const reply = createReplyMock();
143
+ await routes['/api/plantuml-export/*'](
144
+ { params: { '*': '' }, query: {} },
145
+ reply,
146
+ );
147
+ expect(reply.statusCode).toBe(400);
148
+ });
149
+
150
+ it('returns 404 for non-existent plantuml file', async () => {
151
+ const reply = createReplyMock();
152
+ await routes['/api/plantuml-export/*'](
153
+ { params: { '*': 'nonexistent.puml' }, query: {} },
154
+ reply,
155
+ );
156
+ expect(reply.statusCode).toBe(404);
157
+ });
158
+
159
+ it('sends 500 when mermaid render returns null', async () => {
160
+ // Create a real .mmd file
161
+ const mmdPath = path.join(tmpDir, 'test.mmd');
162
+ fs.writeFileSync(mmdPath, 'graph TD; A-->B');
163
+
164
+ // Mock urlPathToFs to resolve our temp path
165
+ const platformMod = await import('../../util/platform.js');
166
+ vi.spyOn(platformMod, 'urlPathToFs').mockReturnValue(mmdPath);
167
+
168
+ vi.mocked(renderMermaidToFile).mockResolvedValue(null);
169
+
170
+ const reply = createReplyMock();
171
+ await routes['/api/mermaid-export/*'](
172
+ { params: { '*': 'test.mmd' }, query: { format: 'svg' } },
173
+ reply,
174
+ );
175
+ expect(reply.statusCode).toBe(500);
176
+ expect((reply.sentData as Record<string, string>).error).toContain(
177
+ 'render failed',
178
+ );
179
+ });
180
+
181
+ it('sends 500 when plantuml render returns null', async () => {
182
+ const pumlPath = path.join(tmpDir, 'test.puml');
183
+ fs.writeFileSync(pumlPath, '@startuml\nA -> B\n@enduml');
184
+
185
+ const platformMod = await import('../../util/platform.js');
186
+ vi.spyOn(platformMod, 'urlPathToFs').mockReturnValue(pumlPath);
187
+
188
+ vi.mocked(renderPlantUmlToBuffer).mockResolvedValue(null);
189
+
190
+ const reply = createReplyMock();
191
+ await routes['/api/plantuml-export/*'](
192
+ { params: { '*': 'test.puml' }, query: { format: 'svg' } },
193
+ reply,
194
+ );
195
+ expect(reply.statusCode).toBe(500);
196
+ expect((reply.sentData as Record<string, string>).error).toContain(
197
+ 'render failed',
198
+ );
199
+ });
200
+ });