apigrip 0.6.2 → 0.6.7

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.
@@ -10,8 +10,8 @@
10
10
  if (t) document.documentElement.setAttribute('data-theme', t);
11
11
  } catch (e) {}
12
12
  </script>
13
- <script type="module" crossorigin src="/assets/index-Iwhkggco.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-CRa1JtiS.css">
13
+ <script type="module" crossorigin src="/assets/index-gg39Ts4w.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-BNWZuYOS.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getConfigDir, getProjectHash } from './env-resolver.js';
4
+
5
+ const MAX_ENTRIES = 50;
6
+
7
+ /**
8
+ * Get the history file path for a project.
9
+ */
10
+ function getHistoryFilePath(projectDir) {
11
+ const configDir = getConfigDir();
12
+ const hash = getProjectHash(projectDir);
13
+ return path.join(configDir, 'history', `${hash}.json`);
14
+ }
15
+
16
+ /**
17
+ * Read and parse the history file.
18
+ * Returns an empty object if the file doesn't exist or is corrupt.
19
+ */
20
+ function readHistoryFile(filePath) {
21
+ try {
22
+ if (!fs.existsSync(filePath)) {
23
+ return {};
24
+ }
25
+ const raw = fs.readFileSync(filePath, 'utf-8');
26
+ const parsed = JSON.parse(raw);
27
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
28
+ console.warn(`[history-store] Corrupt history file at ${filePath}, returning defaults`);
29
+ return {};
30
+ }
31
+ return parsed;
32
+ } catch (err) {
33
+ console.warn(`[history-store] Failed to read history file at ${filePath}: ${err.message}`);
34
+ return {};
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Write the full history object to the history file.
40
+ */
41
+ function writeHistoryFile(filePath, data) {
42
+ const dir = path.dirname(filePath);
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
45
+ }
46
+
47
+ /**
48
+ * Append a response to the history for a specific endpoint.
49
+ * Prepends to the array (newest first), caps at MAX_ENTRIES.
50
+ * @param {string} projectDir - Project directory path
51
+ * @param {string} method - HTTP method
52
+ * @param {string} endpointPath - Endpoint path
53
+ * @param {object} response - Response object to save
54
+ */
55
+ export function appendHistory(projectDir, method, endpointPath, response) {
56
+ const filePath = getHistoryFilePath(projectDir);
57
+ const all = readHistoryFile(filePath);
58
+ const key = `${method.toUpperCase()} ${endpointPath}`;
59
+
60
+ if (!Array.isArray(all[key])) {
61
+ all[key] = [];
62
+ }
63
+
64
+ const entry = {
65
+ ...response,
66
+ timestamp: new Date().toISOString(),
67
+ index: all[key].length > 0 ? (all[key][0].index || 0) + 1 : 0,
68
+ };
69
+
70
+ all[key].unshift(entry);
71
+
72
+ // Cap at MAX_ENTRIES
73
+ if (all[key].length > MAX_ENTRIES) {
74
+ all[key] = all[key].slice(0, MAX_ENTRIES);
75
+ }
76
+
77
+ writeHistoryFile(filePath, all);
78
+ }
79
+
80
+ /**
81
+ * Load history for a specific endpoint.
82
+ * @param {string} projectDir - Project directory path
83
+ * @param {string} method - HTTP method
84
+ * @param {string} endpointPath - Endpoint path
85
+ * @param {number} [limit] - Max entries to return (default: all)
86
+ * @returns {Array} Array of history entries (newest first)
87
+ */
88
+ export function loadHistory(projectDir, method, endpointPath, limit) {
89
+ const filePath = getHistoryFilePath(projectDir);
90
+ const all = readHistoryFile(filePath);
91
+ const key = `${method.toUpperCase()} ${endpointPath}`;
92
+ const entries = all[key] || [];
93
+ if (limit && limit > 0) {
94
+ return entries.slice(0, limit);
95
+ }
96
+ return entries;
97
+ }
98
+
99
+ /**
100
+ * Load a single history entry by index.
101
+ * @param {string} projectDir - Project directory path
102
+ * @param {string} method - HTTP method
103
+ * @param {string} endpointPath - Endpoint path
104
+ * @param {number} index - Index value of the entry
105
+ * @returns {object|null} The history entry or null
106
+ */
107
+ export function loadHistoryEntry(projectDir, method, endpointPath, index) {
108
+ const entries = loadHistory(projectDir, method, endpointPath);
109
+ return entries.find((e) => e.index === index) || null;
110
+ }
111
+
112
+ /**
113
+ * Clear history for a specific endpoint.
114
+ * @param {string} projectDir - Project directory path
115
+ * @param {string} method - HTTP method
116
+ * @param {string} endpointPath - Endpoint path
117
+ * @returns {boolean} True if history was cleared, false if none existed
118
+ */
119
+ export function clearHistory(projectDir, method, endpointPath) {
120
+ const filePath = getHistoryFilePath(projectDir);
121
+ const all = readHistoryFile(filePath);
122
+ const key = `${method.toUpperCase()} ${endpointPath}`;
123
+ if (key in all) {
124
+ delete all[key];
125
+ writeHistoryFile(filePath, all);
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /**
132
+ * Clear all history for a project.
133
+ * @param {string} projectDir - Project directory path
134
+ */
135
+ export function clearAllHistory(projectDir) {
136
+ const filePath = getHistoryFilePath(projectDir);
137
+ writeHistoryFile(filePath, {});
138
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * project-config.js — Load and apply .apigrip.json project config files.
3
+ *
4
+ * .apigrip.json is an optional, checked-in file that seeds environments
5
+ * and provides a spec path override. It never overwrites existing user config.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { loadEnvironments, saveEnvironments } from './env-resolver.js';
11
+
12
+ const CONFIG_FILENAME = '.apigrip.json';
13
+
14
+ /**
15
+ * Load and parse the .apigrip.json file from a project directory.
16
+ *
17
+ * @param {string} projectDir - Absolute path to the project directory
18
+ * @returns {{ spec?: string, active_environment?: string, environments?: object } | null}
19
+ * Parsed config object, or null if file is missing/invalid.
20
+ */
21
+ export function loadProjectConfig(projectDir) {
22
+ const configPath = path.join(projectDir, CONFIG_FILENAME);
23
+ try {
24
+ if (!fs.existsSync(configPath)) return null;
25
+ const raw = fs.readFileSync(configPath, 'utf-8');
26
+ const parsed = JSON.parse(raw);
27
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
28
+ return null;
29
+ }
30
+ return parsed;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Apply .apigrip.json config to the user's XDG environment store.
38
+ *
39
+ * Seed algorithm:
40
+ * - "base" in config: only seed if user's XDG base is empty (no keys)
41
+ * - named envs: only add environment names not already in user's XDG config
42
+ * - "active_environment": only apply if user has no active env set
43
+ *
44
+ * @param {string} projectDir - Absolute path to the project directory
45
+ * @returns {{ seeded: string[], active_applied: boolean }}
46
+ */
47
+ export function applyProjectConfig(projectDir) {
48
+ const result = { seeded: [], active_applied: false };
49
+
50
+ const config = loadProjectConfig(projectDir);
51
+ if (!config) return result;
52
+
53
+ const envData = loadEnvironments(projectDir);
54
+ let changed = false;
55
+
56
+ // Seed base environment if user's base is empty
57
+ if (config.environments?.base && typeof config.environments.base === 'object') {
58
+ if (Object.keys(envData.base).length === 0) {
59
+ envData.base = { ...config.environments.base };
60
+ result.seeded.push('base');
61
+ changed = true;
62
+ }
63
+ }
64
+
65
+ // Seed named environments (only add new names, never overwrite)
66
+ if (config.environments && typeof config.environments === 'object') {
67
+ for (const [name, vars] of Object.entries(config.environments)) {
68
+ if (name === 'base') continue;
69
+ if (!(name in envData.environments)) {
70
+ envData.environments[name] = { ...vars };
71
+ result.seeded.push(name);
72
+ changed = true;
73
+ }
74
+ }
75
+ }
76
+
77
+ // Apply active_environment if user has none set
78
+ if (config.active_environment && envData.active == null) {
79
+ // Only set if the environment actually exists (either already existed or was just seeded)
80
+ if (envData.environments[config.active_environment]) {
81
+ envData.active = config.active_environment;
82
+ result.active_applied = true;
83
+ changed = true;
84
+ }
85
+ }
86
+
87
+ if (changed) {
88
+ saveEnvironments(projectDir, envData);
89
+ }
90
+
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Get the spec path override from .apigrip.json.
96
+ *
97
+ * @param {string} projectDir - Absolute path to the project directory
98
+ * @returns {string | null} Absolute spec path if valid, or null
99
+ */
100
+ export function getSpecOverride(projectDir) {
101
+ const config = loadProjectConfig(projectDir);
102
+ if (!config?.spec || typeof config.spec !== 'string') return null;
103
+
104
+ const resolved = path.resolve(projectDir, config.spec);
105
+ if (!fs.existsSync(resolved)) return null;
106
+
107
+ return resolved;
108
+ }
@@ -1,5 +1,5 @@
1
1
  import { readdir, readFile, stat } from 'node:fs/promises';
2
- import { join, extname } from 'node:path';
2
+ import { join, resolve, extname } from 'node:path';
3
3
  import yaml from 'js-yaml';
4
4
 
5
5
  const EXCLUDED_DIRS = new Set([
@@ -57,6 +57,22 @@ function isExcludedDir(name) {
57
57
  * @returns {Promise<{specPath: string, specFile: string} | null>}
58
58
  */
59
59
  export async function discoverSpec(projectDir) {
60
+ // Phase 0: Check .apigrip.json for spec override
61
+ try {
62
+ const configPath = join(projectDir, '.apigrip.json');
63
+ const configRaw = await readFile(configPath, 'utf-8');
64
+ const config = JSON.parse(configRaw);
65
+ if (config && typeof config === 'object' && typeof config.spec === 'string') {
66
+ const specPath = resolve(projectDir, config.spec);
67
+ if (await isSpecFile(specPath)) {
68
+ const relative = specPath.slice(projectDir.length + 1);
69
+ return { specPath, specFile: relative };
70
+ }
71
+ }
72
+ } catch {
73
+ // No .apigrip.json or invalid — fall through to normal discovery
74
+ }
75
+
60
76
  // Phase 1: Check root priority files
61
77
  for (const fileName of ROOT_PRIORITY) {
62
78
  const filePath = join(projectDir, fileName);
package/lib/index.js CHANGED
@@ -84,10 +84,22 @@ import {
84
84
  import { loadPreferences, savePreferences } from '../core/preferences-store.js';
85
85
  import { getGitInfo } from '../core/git-info.js';
86
86
  import { saveLastResponse, loadLastResponse } from '../core/response-store.js';
87
+ import {
88
+ appendHistory,
89
+ loadHistory,
90
+ loadHistoryEntry,
91
+ clearHistory,
92
+ clearAllHistory,
93
+ } from '../core/history-store.js';
87
94
 
88
95
  export {
89
96
  saveLastResponse,
90
97
  loadLastResponse,
98
+ appendHistory,
99
+ loadHistory,
100
+ loadHistoryEntry,
101
+ clearHistory,
102
+ clearAllHistory,
91
103
  loadParams,
92
104
  saveParams,
93
105
  loadProjects,
@@ -98,6 +110,16 @@ export {
98
110
  getGitInfo,
99
111
  };
100
112
 
113
+ // ---- Project config -------------------------------------------------------
114
+
115
+ import {
116
+ loadProjectConfig,
117
+ applyProjectConfig,
118
+ getSpecOverride,
119
+ } from '../core/project-config.js';
120
+
121
+ export { loadProjectConfig, applyProjectConfig, getSpecOverride };
122
+
101
123
  // ---- High-level convenience -----------------------------------------------
102
124
 
103
125
  /**
@@ -288,6 +310,11 @@ export async function send(specOrPath, method, endpointPath, options = {}) {
288
310
  } catch {
289
311
  // Non-critical
290
312
  }
313
+ try {
314
+ appendHistory(projectDir, method, endpointPath, result);
315
+ } catch {
316
+ // Non-critical
317
+ }
291
318
  }
292
319
 
293
320
  return result;
package/mcp/server.js CHANGED
@@ -10,6 +10,7 @@ import { validateBody, validateResponse } from '../core/schema-validator.js';
10
10
  import { buildCurlArgs, buildUrl } from '../core/curl-builder.js';
11
11
  import { executeCurl } from '../core/curl-executor.js';
12
12
  import { saveLastResponse, loadLastResponse } from '../core/response-store.js';
13
+ import { appendHistory, loadHistory } from '../core/history-store.js';
13
14
 
14
15
  /**
15
16
  * Create and configure the MCP server.
@@ -224,6 +225,13 @@ export async function createMcpServer(state) {
224
225
  // Non-critical
225
226
  }
226
227
 
228
+ // Append to history
229
+ try {
230
+ appendHistory(state.projectDir, upperMethod, endpointPath, response);
231
+ } catch {
232
+ // Non-critical
233
+ }
234
+
227
235
  return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
228
236
  } catch (err) {
229
237
  return {
@@ -248,5 +256,18 @@ export async function createMcpServer(state) {
248
256
  }
249
257
  );
250
258
 
259
+ server.tool('get_history', 'Get request history for an endpoint',
260
+ { method: z.string(), path: z.string(), limit: z.number().optional() },
261
+ async ({ method, path: endpointPath, limit }) => {
262
+ const entries = loadHistory(state.projectDir, method.toUpperCase(), endpointPath, limit);
263
+ if (entries.length === 0) {
264
+ return {
265
+ content: [{ type: 'text', text: `No history for ${method} ${endpointPath}` }],
266
+ };
267
+ }
268
+ return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
269
+ }
270
+ );
271
+
251
272
  return server;
252
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apigrip",
3
- "version": "0.6.2",
3
+ "version": "0.6.7",
4
4
  "description": "A spec-first, read-only OpenAPI client for developers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.cjs",
@@ -30,8 +30,13 @@
30
30
  "build": "vite build --config client/vite.config.js",
31
31
  "test": "node --test $(find test -name '*.test.js')",
32
32
  "test:client": "vitest run --config client/vitest.config.js",
33
+ "test:e2e": "npx playwright test --config e2e/playwright.config.js",
34
+ "test:e2e:headed": "npx playwright test --config e2e/playwright.config.js --headed",
35
+ "test:e2e:ui": "npx playwright test --config e2e/playwright.config.js --ui",
33
36
  "prepare": "test -d client/src && npm run build || true",
34
- "prepublishOnly": "npm run build"
37
+ "prepublishOnly": "npm run build",
38
+ "electron:dev": "electron electron/main.js",
39
+ "electron:build": "npm run build && electron-builder"
35
40
  },
36
41
  "keywords": [
37
42
  "openapi",
@@ -61,14 +66,47 @@
61
66
  "yargs": "^18.0.0"
62
67
  },
63
68
  "devDependencies": {
69
+ "@playwright/test": "^1.58.2",
64
70
  "@testing-library/jest-dom": "^6.9.1",
65
71
  "@testing-library/react": "^16.3.2",
66
72
  "@testing-library/user-event": "^14.6.1",
67
73
  "@vitejs/plugin-react": "^5.1.4",
74
+ "@vitest/coverage-v8": "^4.0.18",
68
75
  "jsdom": "^28.1.0",
69
76
  "react": "^19.2.4",
70
77
  "react-dom": "^19.2.4",
78
+ "electron": "^35.1.2",
79
+ "electron-builder": "^26.0.12",
71
80
  "vite": "^7.3.1",
72
81
  "vitest": "^4.0.18"
82
+ },
83
+ "build": {
84
+ "appId": "com.apigrip.app",
85
+ "productName": "Apigrip",
86
+ "extraMetadata": {
87
+ "main": "electron/main.js"
88
+ },
89
+ "files": [
90
+ "cli/",
91
+ "core/",
92
+ "lib/",
93
+ "mcp/",
94
+ "server/",
95
+ "electron/",
96
+ "client/dist/",
97
+ "package.json"
98
+ ],
99
+ "mac": {
100
+ "target": "dmg"
101
+ },
102
+ "win": {
103
+ "target": "nsis"
104
+ },
105
+ "linux": {
106
+ "target": "AppImage"
107
+ },
108
+ "directories": {
109
+ "output": "dist-electron"
110
+ }
73
111
  }
74
112
  }
@@ -3,6 +3,7 @@ import { getGitInfo } from '../../core/git-info.js';
3
3
  import { parseSpec } from '../../core/spec-parser.js';
4
4
  import { discoverSpec } from '../../core/spec-discovery.js';
5
5
  import { addProject } from '../../core/projects-store.js';
6
+ import { applyProjectConfig } from '../../core/project-config.js';
6
7
  import { broadcast } from './events.js';
7
8
  import path from 'path';
8
9
  import fs from 'fs';
@@ -79,6 +80,9 @@ export function createProjectRoutes(state) {
79
80
  }
80
81
  }
81
82
 
83
+ // Apply .apigrip.json project config (seeds environments)
84
+ try { applyProjectConfig(newPath); } catch { /* non-critical */ }
85
+
82
86
  const git = await getGitInfo(state.projectDir);
83
87
  const projectInfo = {
84
88
  path: state.projectDir,
@@ -6,6 +6,7 @@ import { buildCurlArgs, buildUrl, shellQuote } from '../../core/curl-builder.js'
6
6
  import { executeCurl } from '../../core/curl-executor.js';
7
7
  import { loadEnvironments, resolveEnvironment, resolveAllParams } from '../../core/env-resolver.js';
8
8
  import { saveLastResponse, loadLastResponse } from '../../core/response-store.js';
9
+ import { appendHistory, loadHistory, loadHistoryEntry, clearHistory } from '../../core/history-store.js';
9
10
 
10
11
  export function createRequestRoutes(state) {
11
12
  const router = Router();
@@ -109,6 +110,13 @@ export function createRequestRoutes(state) {
109
110
  // Non-critical — don't fail the request if caching fails
110
111
  }
111
112
 
113
+ // Append to history
114
+ try {
115
+ appendHistory(state.projectDir, method, endpointPath, responseData);
116
+ } catch {
117
+ // Non-critical
118
+ }
119
+
112
120
  res.json(responseData);
113
121
  } catch (err) {
114
122
  res.status(502).json({
@@ -197,5 +205,47 @@ export function createRequestRoutes(state) {
197
205
  res.json(result);
198
206
  });
199
207
 
208
+ // GET /api/project/request/history — get history for an endpoint
209
+ router.get('/project/request/history', (req, res) => {
210
+ if (!state.projectDir) {
211
+ return res.status(404).json({ error: 'No active project' });
212
+ }
213
+ const { method, path: endpointPath, limit } = req.query;
214
+ if (!method || !endpointPath) {
215
+ return res.status(400).json({ error: 'Missing required query params: method, path' });
216
+ }
217
+ const entries = loadHistory(state.projectDir, method, endpointPath, limit ? Number(limit) : undefined);
218
+ res.json(entries);
219
+ });
220
+
221
+ // GET /api/project/request/history/entry — get single history entry by index
222
+ router.get('/project/request/history/entry', (req, res) => {
223
+ if (!state.projectDir) {
224
+ return res.status(404).json({ error: 'No active project' });
225
+ }
226
+ const { method, path: endpointPath, index } = req.query;
227
+ if (!method || !endpointPath || index == null) {
228
+ return res.status(400).json({ error: 'Missing required query params: method, path, index' });
229
+ }
230
+ const entry = loadHistoryEntry(state.projectDir, method, endpointPath, Number(index));
231
+ if (!entry) {
232
+ return res.status(404).json({ error: `No history entry with index ${index} for ${method} ${endpointPath}` });
233
+ }
234
+ res.json(entry);
235
+ });
236
+
237
+ // DELETE /api/project/request/history — clear history for an endpoint
238
+ router.delete('/project/request/history', (req, res) => {
239
+ if (!state.projectDir) {
240
+ return res.status(404).json({ error: 'No active project' });
241
+ }
242
+ const { method, path: endpointPath } = req.query;
243
+ if (!method || !endpointPath) {
244
+ return res.status(400).json({ error: 'Missing required query params: method, path' });
245
+ }
246
+ clearHistory(state.projectDir, method, endpointPath);
247
+ res.json({ cleared: true });
248
+ });
249
+
200
250
  return router;
201
251
  }
@@ -13,6 +13,7 @@ import path from 'node:path';
13
13
 
14
14
  import { parseSpec, extractEndpoints, getRefDeps } from '../core/spec-parser.js';
15
15
  import { getGitInfo } from '../core/git-info.js';
16
+ import { applyProjectConfig, getSpecOverride } from '../core/project-config.js';
16
17
 
17
18
  /**
18
19
  * Debounce a function. Returns a wrapper that delays invocation until
@@ -104,6 +105,9 @@ export async function createSpecWatcher(state, broadcast) {
104
105
  // If we can't resolve deps, just watch the main file
105
106
  }
106
107
 
108
+ // .apigrip.json config file
109
+ const configPath = path.join(state.projectDir, '.apigrip.json');
110
+
107
111
  // Git metadata files
108
112
  const gitDir = path.join(state.projectDir, '.git');
109
113
  const gitPaths = [
@@ -113,7 +117,7 @@ export async function createSpecWatcher(state, broadcast) {
113
117
  ];
114
118
 
115
119
  // Create watcher
116
- const watcher = chokidar.watch([...filesToWatch, ...gitPaths], {
120
+ const watcher = chokidar.watch([...filesToWatch, configPath, ...gitPaths], {
117
121
  ignoreInitial: true,
118
122
  awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 },
119
123
  });
@@ -162,6 +166,29 @@ export async function createSpecWatcher(state, broadcast) {
162
166
  }
163
167
  }, 100);
164
168
 
169
+ // Debounced config change handler (.apigrip.json)
170
+ const resolvedConfigPath = path.resolve(configPath);
171
+ const handleConfigChange = debounce(async () => {
172
+ try {
173
+ applyProjectConfig(state.projectDir);
174
+ } catch {
175
+ // ignore config apply errors
176
+ }
177
+
178
+ // Check if spec override changed
179
+ try {
180
+ const newSpecPath = getSpecOverride(state.projectDir);
181
+ if (newSpecPath && newSpecPath !== path.resolve(state.specPath)) {
182
+ state.specPath = newSpecPath;
183
+ specFiles.add(path.resolve(newSpecPath));
184
+ watcher.add(newSpecPath);
185
+ handleSpecChange();
186
+ }
187
+ } catch {
188
+ // ignore
189
+ }
190
+ }, 100);
191
+
165
192
  // Debounced git change handler
166
193
  const handleGitChange = debounce(async () => {
167
194
  try {
@@ -178,7 +205,9 @@ export async function createSpecWatcher(state, broadcast) {
178
205
 
179
206
  watcher.on('change', (changedPath) => {
180
207
  const resolved = path.resolve(changedPath);
181
- if (specFiles.has(resolved)) {
208
+ if (resolved === resolvedConfigPath) {
209
+ handleConfigChange();
210
+ } else if (specFiles.has(resolved)) {
182
211
  handleSpecChange();
183
212
  } else {
184
213
  // Assume it's a git-related file
@@ -189,7 +218,9 @@ export async function createSpecWatcher(state, broadcast) {
189
218
  // Also handle add events (new $ref files, etc.)
190
219
  watcher.on('add', (changedPath) => {
191
220
  const resolved = path.resolve(changedPath);
192
- if (specFiles.has(resolved)) {
221
+ if (resolved === resolvedConfigPath) {
222
+ handleConfigChange();
223
+ } else if (specFiles.has(resolved)) {
193
224
  handleSpecChange();
194
225
  } else {
195
226
  handleGitChange();
@@ -1 +0,0 @@
1
- :root{--bg: #282a36;--bg-secondary: #44475a;--bg-input: #1e1f29;--fg: #f8f8f2;--fg-muted: #6272a4;--fg-faint: #44475a;--border: #44475a;--accent: #bd93f9;--accent-hover: #caa8ff;--method-get: #50fa7b;--method-post: #bd93f9;--method-put: #ffb86c;--method-delete: #ff5555;--method-patch: #f1fa8c;--status-2xx: #50fa7b;--status-3xx: #f1fa8c;--status-4xx: #ffb86c;--status-5xx: #ff5555;--font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--radius: 6px;--transition: .15s ease}[data-theme=light]{--bg: #f8f8f2;--bg-secondary: #e8e8e2;--bg-input: #ffffff;--fg: #282a36;--fg-muted: #6272a4;--fg-faint: #c0c0c0;--border: #d0d0d0;--accent: #7c5cbf;--accent-hover: #6a4daa}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}body{background:var(--bg);color:var(--fg);font-family:var(--font-sans);font-size:14px;line-height:1.5;height:100vh;overflow:hidden}#root{display:flex;flex-direction:column;height:100%}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--bg-secondary);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--fg-muted)}*{scrollbar-width:thin;scrollbar-color:var(--bg-secondary) var(--bg)}::selection{background:var(--accent);color:var(--bg)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}h1{font-size:1.5rem;font-weight:700;line-height:1.3}h2{font-size:1.25rem;font-weight:600;line-height:1.3}h3{font-size:1rem;font-weight:600;line-height:1.4}h4{font-size:.875rem;font-weight:600;line-height:1.4}p{margin-bottom:.5em}code{font-family:var(--font-mono);font-size:.9em;background:var(--bg-secondary);padding:.15em .35em;border-radius:3px}pre{font-family:var(--font-mono);font-size:.85rem;line-height:1.5;white-space:pre-wrap;word-wrap:break-word}.app{display:flex;flex-direction:column;height:100%}.app__body{display:flex;flex:1;min-height:0;overflow:hidden}.app__sidebar{width:280px;min-width:200px;border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}.app__main{flex:1;display:flex;min-width:0;overflow:hidden}.app__request-panel{flex:1;min-width:0;overflow-y:auto;border-right:1px solid var(--border);padding:16px}.app__response-panel{flex:1;min-width:0;overflow-y:auto;padding:16px}.topbar{display:flex;align-items:center;gap:12px;padding:8px 16px;background:var(--bg-secondary);border-bottom:1px solid var(--border);min-height:44px;flex-shrink:0}.topbar__section{display:flex;align-items:center;gap:8px}.topbar__section--right{margin-left:auto}.topbar__label{font-size:12px;color:var(--fg-muted);text-transform:uppercase;letter-spacing:.05em}.topbar__select{background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:4px 8px;font-size:13px;font-family:var(--font-sans);cursor:pointer}.topbar__select:hover{border-color:var(--accent)}.topbar__btn{background:none;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:4px 10px;font-size:12px;cursor:pointer;transition:background var(--transition)}.topbar__btn:hover{background:var(--bg)}.topbar__git{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--fg-muted);font-family:var(--font-mono)}.topbar__git-branch{color:var(--accent)}.topbar__git-commit{color:var(--fg-muted)}.topbar__git-dirty{color:var(--method-put)}.topbar__toast{font-size:12px;color:var(--method-get);animation:topbar-toast-fade 3s ease forwards}@keyframes topbar-toast-fade{0%{opacity:1}70%{opacity:1}to{opacity:0}}.endpoint-list{display:flex;flex-direction:column;height:100%}.endpoint-list__search{padding:8px;border-bottom:1px solid var(--border)}.endpoint-list__search-input{width:100%;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-size:13px;font-family:var(--font-sans)}.endpoint-list__search-input::placeholder{color:var(--fg-muted)}.endpoint-list__toolbar{display:flex;align-items:center;justify-content:flex-end;padding:4px 8px;border-bottom:1px solid var(--border)}.endpoint-list__view-btn{background:none;border:none;color:var(--fg-muted);cursor:pointer;padding:2px 6px;font-size:12px}.endpoint-list__view-btn--active{color:var(--accent)}.endpoint-list__items{flex:1;overflow-y:auto;padding:4px 0}.endpoint-list__tag-header{padding:8px 12px 4px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-muted)}.endpoint-list__tree-folder{padding:4px 12px;font-size:12px;color:var(--fg-muted);cursor:pointer;display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none}.endpoint-list__tree-folder:hover{color:var(--fg)}.endpoint-list__tree-children{padding-left:16px}.endpoint-item{display:flex;align-items:center;gap:8px;padding:5px 12px;cursor:pointer;transition:background var(--transition);border-left:3px solid transparent}.endpoint-item:hover{background:var(--bg-secondary)}.endpoint-item--selected{background:var(--bg-secondary);border-left-color:var(--accent)}.endpoint-item__method{font-size:10px;font-weight:700;font-family:var(--font-mono);text-transform:uppercase;min-width:52px;text-align:center;padding:2px 6px;border-radius:3px;letter-spacing:.02em}.endpoint-item__method--get{color:var(--method-get);background:#50fa7b1a}.endpoint-item__method--post{color:var(--method-post);background:#bd93f91a}.endpoint-item__method--put{color:var(--method-put);background:#ffb86c1a}.endpoint-item__method--delete{color:var(--method-delete);background:#ff55551a}.endpoint-item__method--patch{color:var(--method-patch);background:#f1fa8c1a}.endpoint-item__path{font-family:var(--font-mono);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.endpoint-item__summary{font-size:11px;color:var(--fg-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:auto}.request-panel__section{margin-bottom:16px}.request-panel__section-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-muted);margin-bottom:8px}.request-panel__server-select{width:100%;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-family:var(--font-mono);font-size:13px}.request-panel__server-vars{display:flex;flex-direction:column;gap:6px;margin-top:8px}.request-panel__send-bar{display:flex;align-items:center;gap:8px;margin-top:16px;margin-bottom:24px}.request-panel__send-btn{background:var(--accent);color:var(--bg);border:none;border-radius:var(--radius);padding:8px 24px;font-size:14px;font-weight:600;cursor:pointer;transition:background var(--transition)}.request-panel__send-btn:hover{background:var(--accent-hover)}.request-panel__send-btn:disabled{opacity:.5;cursor:not-allowed}.request-panel__spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--fg-muted);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.param-form{display:flex;flex-direction:column;gap:10px}.param-form__field{display:flex;flex-direction:column;gap:3px}.param-form__label{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:600}.param-form__required{color:var(--method-delete)}.param-form__desc{font-size:11px;color:var(--fg-muted)}.param-form__input{background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-family:var(--font-mono);font-size:13px}.param-form__input:focus{border-color:var(--accent)}.param-form__resolved{font-size:11px;color:var(--fg-muted);font-family:var(--font-mono)}.hl-wrap{position:relative}.hl-backdrop{position:absolute;inset:0;padding:6px 10px;font-family:var(--font-mono);font-size:13px;line-height:normal;color:transparent;white-space:pre;overflow:hidden;pointer-events:none;border:1px solid transparent;border-radius:var(--radius)}.hl-input{background:transparent!important;position:relative;caret-color:var(--fg)}.hl-wrap .param-form__input{background:transparent}.hl-wrap:before{content:"";position:absolute;inset:0;background:var(--bg-input);border-radius:var(--radius);z-index:-1}.hl-var{background:color-mix(in srgb,var(--method-get) 20%,transparent);color:var(--method-get);border-radius:3px;padding:0 1px}.body-editor__toolbar{display:flex;align-items:center;gap:8px;margin-bottom:8px}.body-editor__content-type{background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:4px 8px;font-size:13px}.body-editor__sample-btn{background:none;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:4px 10px;font-size:12px;cursor:pointer}.body-editor__sample-btn:hover{border-color:var(--accent);color:var(--accent)}.body-editor__textarea{width:100%;min-height:200px;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:10px;font-family:var(--font-mono);font-size:13px;line-height:1.5;resize:vertical}.body-editor__kv-table{width:100%;border-collapse:collapse}.body-editor__kv-table th{text-align:left;font-size:11px;font-weight:600;color:var(--fg-muted);padding:4px 8px;border-bottom:1px solid var(--border)}.body-editor__kv-table td{padding:4px 8px}.body-editor__kv-input{width:100%;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:3px;padding:4px 8px;font-family:var(--font-mono);font-size:13px}.body-editor__kv-remove{background:none;border:none;color:var(--method-delete);cursor:pointer;font-size:16px;padding:2px 6px}.body-editor__kv-add{background:none;border:1px dashed var(--border);border-radius:var(--radius);color:var(--fg-muted);padding:4px 12px;font-size:12px;cursor:pointer;margin-top:6px}.body-editor__kv-add:hover{border-color:var(--accent);color:var(--accent)}.body-editor__validation{margin-top:6px;font-size:12px;display:flex;align-items:center;gap:4px}.body-editor__validation--valid{color:var(--method-get)}.body-editor__validation--invalid{color:var(--method-delete)}.body-editor__validation--none{color:var(--fg-muted)}.response-panel__empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--fg-muted);font-size:14px}.response-panel__loading{display:flex;align-items:center;justify-content:center;height:100%;gap:10px;color:var(--fg-muted)}.response-panel__header{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}.response-panel__status{font-weight:700;font-family:var(--font-mono);font-size:14px;padding:3px 10px;border-radius:var(--radius)}.response-panel__status--2xx{color:var(--status-2xx);background:#50fa7b1a}.response-panel__status--3xx{color:var(--status-3xx);background:#f1fa8c1a}.response-panel__status--4xx{color:var(--status-4xx);background:#ffb86c1a}.response-panel__status--5xx{color:var(--status-5xx);background:#ff55551a}.response-panel__meta{font-size:12px;color:var(--fg-muted)}.response-panel__tabs{display:flex;border-bottom:1px solid var(--border);margin-bottom:12px}.response-panel__tab{background:none;border:none;border-bottom:2px solid transparent;color:var(--fg-muted);padding:6px 16px;font-size:13px;cursor:pointer;transition:color var(--transition),border-color var(--transition)}.response-panel__tab:hover{color:var(--fg)}.response-panel__tab--active{color:var(--accent);border-bottom-color:var(--accent)}.body-view__toolbar{display:flex;align-items:center;gap:8px;margin-bottom:8px}.body-view__btn{background:none;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:3px 10px;font-size:12px;cursor:pointer}.body-view__btn:hover,.body-view__btn--active{border-color:var(--accent);color:var(--accent)}.body-view__pre{background:var(--bg-input);border-radius:var(--radius);padding:12px;overflow:auto;max-height:calc(100vh - 260px);font-size:13px;line-height:1.5}.body-view__pre code{background:none;padding:0;border-radius:0}.body-view__pre .hljs-attr{color:#50fa7b}.body-view__pre .hljs-string{color:#f1fa8c}.body-view__pre .hljs-number{color:#bd93f9}.body-view__pre .hljs-literal,.body-view__pre .hljs-keyword,.body-view__pre .hljs-tag{color:#ff79c6}.body-view__pre .hljs-name{color:#50fa7b}.body-view__pre .hljs-selector-tag{color:#ff79c6}.body-view__pre .hljs-punctuation{color:var(--fg)}.body-view__pre--wrap{white-space:pre-wrap;word-wrap:break-word}.body-view__pre--nowrap{white-space:pre}.body-view__filter-group{display:flex;align-items:center;flex:1;position:relative}.body-view__filter{width:100%;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:3px 26px 3px 10px;font-size:12px;font-family:inherit}.body-view__filter::placeholder{color:var(--fg-muted)}.body-view__filter:focus{outline:none;border-color:var(--accent)}.body-view__filter--active{border-color:var(--method-get)}.body-view__filter--error{border-color:var(--method-delete)}.body-view__filter-clear{position:absolute;right:4px;background:none;border:none;color:var(--fg-muted);cursor:pointer;font-size:12px;padding:2px 6px;line-height:1}.body-view__filter-clear:hover{color:var(--fg)}.body-view__filter-error{color:var(--method-delete);font-size:11px;margin-bottom:6px;padding:2px 0}.headers-view__table{width:100%;border-collapse:collapse}.headers-view__table th{text-align:left;font-size:11px;font-weight:600;color:var(--fg-muted);padding:6px 10px;border-bottom:1px solid var(--border)}.headers-view__table td{padding:5px 10px;font-family:var(--font-mono);font-size:13px;border-bottom:1px solid var(--fg-faint)}.headers-view__table td:first-child{color:var(--accent);white-space:nowrap}.headers-view__section-title{font-size:12px;font-weight:600;color:var(--fg-muted);margin:12px 0 6px;cursor:pointer;-webkit-user-select:none;user-select:none}.verbose-view{background:var(--bg-input);border-radius:var(--radius);padding:12px;overflow:auto;max-height:calc(100vh - 220px);font-family:var(--font-mono);font-size:13px;line-height:1.6;white-space:pre-wrap;word-wrap:break-word}.verbose-view__line--comment{color:#6272a4}.verbose-view__line--request{color:#8be9fd}.verbose-view__line--response{color:#50fa7b}.verbose-view__line--other{color:#f8f8f2}.curl-view{position:relative}.curl-view__pre{background:var(--bg-input);border-radius:var(--radius);padding:12px;font-family:var(--font-mono);font-size:13px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;overflow:auto;max-height:calc(100vh - 220px)}.curl-view__copy-btn{position:absolute;top:8px;right:8px}.env-editor{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center}.env-editor__backdrop{position:absolute;inset:0;background:#0009}.env-editor__dialog{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);width:640px;max-width:90vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 32px #0006}.env-editor__header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border)}.env-editor__title{font-size:16px;font-weight:600}.env-editor__close{background:none;border:none;color:var(--fg-muted);font-size:20px;cursor:pointer;padding:2px 6px}.env-editor__close:hover{color:var(--fg)}.env-editor__tabs{display:flex;border-bottom:1px solid var(--border);padding:0 16px;overflow-x:auto}.env-editor__tab{background:none;border:none;border-bottom:2px solid transparent;color:var(--fg-muted);padding:8px 14px;font-size:13px;cursor:pointer;white-space:nowrap}.env-editor__tab:hover{color:var(--fg)}.env-editor__tab--active{color:var(--accent);border-bottom-color:var(--accent)}.env-editor__body{flex:1;overflow-y:auto;padding:16px}.env-editor__new-env{display:flex;align-items:center;gap:8px;margin-bottom:12px}.env-editor__new-env-input{flex:1;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-size:13px}.env-editor__footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--border)}.env-editor__save-btn{background:var(--accent);color:var(--bg);border:none;border-radius:var(--radius);padding:6px 20px;font-size:13px;font-weight:600;cursor:pointer}.env-editor__save-btn:hover{background:var(--accent-hover)}.env-editor__delete-btn{background:none;border:1px solid var(--method-delete);border-radius:var(--radius);color:var(--method-delete);padding:6px 14px;font-size:12px;cursor:pointer}.env-editor__delete-btn:hover{background:#ff55551a}.command-palette{position:fixed;inset:0;z-index:2000;display:flex;justify-content:center;padding-top:15vh}.command-palette__backdrop{position:absolute;inset:0;background:#00000080}.command-palette__dialog{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);width:520px;max-width:90vw;max-height:60vh;display:flex;flex-direction:column;box-shadow:0 8px 32px #0006}.command-palette__input{background:var(--bg-input);color:var(--fg);border:none;border-bottom:1px solid var(--border);padding:14px 16px;font-size:15px;font-family:var(--font-sans);border-radius:var(--radius) var(--radius) 0 0}.command-palette__input::placeholder{color:var(--fg-muted)}.command-palette__results{flex:1;overflow-y:auto;padding:4px 0}.command-palette__section-title{padding:8px 16px 4px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-muted)}.command-palette__item{display:flex;align-items:center;gap:10px;padding:8px 16px;cursor:pointer;transition:background var(--transition)}.command-palette__item:hover,.command-palette__item--active{background:var(--bg-secondary)}.command-palette__item-label{font-size:14px}.command-palette__item-detail{font-size:12px;color:var(--fg-muted);margin-left:auto}.command-palette__item-shortcut{margin-left:auto;font-family:var(--font-mono);font-size:11px;color:var(--fg-muted);background:var(--bg-input);border:1px solid var(--border);border-radius:3px;padding:1px 6px;white-space:nowrap}.endpoint-doc{border:1px solid var(--border);border-radius:var(--radius);margin-bottom:16px}.endpoint-doc__toggle{display:flex;align-items:center;justify-content:space-between;width:100%;background:none;border:none;color:var(--fg);padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer;text-align:left}.endpoint-doc__toggle:hover{background:var(--bg-secondary)}.endpoint-doc__toggle-icon{color:var(--fg-muted);font-size:12px;transition:transform var(--transition)}.endpoint-doc__toggle-icon--expanded{transform:rotate(90deg)}.endpoint-doc__body{padding:0 24px 14px;font-size:13px;line-height:1.6}.endpoint-doc__deprecated{background:#ff55551a;color:var(--method-delete);padding:6px 10px;border-radius:var(--radius);font-size:12px;font-weight:600;margin-bottom:8px}.endpoint-doc__body h1,.endpoint-doc__body h2,.endpoint-doc__body h3{margin-top:.8em;margin-bottom:.4em}.endpoint-doc__body p{margin-bottom:.5em}.endpoint-doc__body code{font-size:.9em}.body-editor__schema{margin-top:8px}.body-editor__schema-toggle{display:flex;align-items:center;justify-content:space-between;width:100%;background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius);padding:6px 12px;color:var(--fg-muted);font-size:12px;cursor:pointer}.body-editor__schema-toggle:hover{color:var(--fg)}.body-editor__schema-body{padding:10px 12px;border:1px solid var(--border);border-top:none;border-radius:0 0 var(--radius) var(--radius);font-size:12px;font-family:var(--font-mono);max-height:300px;overflow-y:auto}.schema-view__prop{padding:3px 0;display:flex;align-items:baseline;flex-wrap:wrap;gap:6px}.schema-view__name{color:var(--accent);font-weight:600}.schema-view__type{color:var(--fg-muted);font-size:11px}.schema-view__required{color:var(--method-delete);font-size:10px;font-weight:600}.schema-view__desc{color:var(--fg-muted);font-style:italic;font-family:var(--font-sans);font-size:11px}.schema-view__enum{color:var(--fg-muted);font-size:10px}.validation-details{display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:13px}.validation-details__indicator--valid{color:var(--method-get)}.validation-details__indicator--invalid{color:var(--method-delete)}.validation-details__indicator--none{color:var(--fg-muted)}.validation-details__errors{margin-top:8px;font-size:12px}.validation-details__error{padding:6px 10px;border-left:3px solid var(--method-delete);background:#ff55550d;margin-bottom:4px;border-radius:0 var(--radius) var(--radius) 0}.validation-details__error-path{font-family:var(--font-mono);color:var(--accent);font-size:11px}.validation-details__error-msg{color:var(--fg)}.validation-details__error-detail{color:var(--fg-muted);font-size:11px}.copy-btn{background:none;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:3px 10px;font-size:12px;cursor:pointer}.copy-btn:hover{border-color:var(--accent);color:var(--accent)}[data-theme=light] .body-view__pre .hljs-attr{color:#1a8a3f}[data-theme=light] .body-view__pre .hljs-string{color:#b35c00}[data-theme=light] .body-view__pre .hljs-number{color:#7c3aed}[data-theme=light] .body-view__pre .hljs-literal,[data-theme=light] .body-view__pre .hljs-keyword,[data-theme=light] .body-view__pre .hljs-tag{color:#d6336c}[data-theme=light] .body-view__pre .hljs-name{color:#1a8a3f}[data-theme=light] .body-view__pre .hljs-selector-tag{color:#d6336c}[data-theme=light] .body-view__pre .hljs-punctuation{color:var(--fg)}.resize-handle{flex-shrink:0;background:var(--border);transition:background .15s}.resize-handle--horizontal{width:4px;cursor:col-resize}.resize-handle--vertical{height:4px;cursor:row-resize}.resize-handle:hover,.resize-handle:active{background:var(--accent)}