apigrip 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +240 -0
- package/cli/commands/curl.js +11 -0
- package/cli/commands/env.js +174 -0
- package/cli/commands/last.js +63 -0
- package/cli/commands/list.js +35 -0
- package/cli/commands/mcp.js +25 -0
- package/cli/commands/projects.js +50 -0
- package/cli/commands/send.js +189 -0
- package/cli/commands/serve.js +46 -0
- package/cli/index.js +109 -0
- package/cli/output.js +168 -0
- package/cli/resolve-project.js +43 -0
- package/client/dist/assets/index-CtHBIuEv.js +75 -0
- package/client/dist/assets/index-kzeRjfI8.css +1 -0
- package/client/dist/index.html +19 -0
- package/core/curl-builder.js +218 -0
- package/core/curl-executor.js +370 -0
- package/core/env-resolver.js +244 -0
- package/core/git-info.js +41 -0
- package/core/params-store.js +94 -0
- package/core/preferences-store.js +150 -0
- package/core/projects-store.js +173 -0
- package/core/response-store.js +121 -0
- package/core/schema-validator.js +196 -0
- package/core/spec-discovery.js +109 -0
- package/core/spec-parser.js +172 -0
- package/lib/index.cjs +16 -0
- package/lib/index.js +294 -0
- package/mcp/server.js +257 -0
- package/package.json +70 -0
- package/server/index.js +53 -0
- package/server/routes/browse.js +61 -0
- package/server/routes/environments.js +92 -0
- package/server/routes/events.js +40 -0
- package/server/routes/params.js +38 -0
- package/server/routes/preferences.js +27 -0
- package/server/routes/project.js +94 -0
- package/server/routes/projects.js +51 -0
- package/server/routes/requests.js +192 -0
- package/server/routes/spec.js +92 -0
- package/server/spec-watcher.js +236 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env-resolver.js - Environment loading, merging, and variable resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_ENV_CONFIG = () => ({
|
|
11
|
+
base: {},
|
|
12
|
+
environments: {},
|
|
13
|
+
active: null,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the config directory for apigrip.
|
|
18
|
+
* Uses $XDG_CONFIG_HOME/apigrip/ if set,
|
|
19
|
+
* otherwise ~/.config/apigrip/.
|
|
20
|
+
*
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function getConfigDir() {
|
|
24
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
25
|
+
const base = xdg || path.join(os.homedir(), '.config');
|
|
26
|
+
return path.join(base, 'apigrip');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get a project hash: SHA-256 of the resolved absolute path, truncated to 16 hex chars.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} projectDir
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function getProjectHash(projectDir) {
|
|
36
|
+
let resolved;
|
|
37
|
+
try {
|
|
38
|
+
resolved = fs.realpathSync(path.resolve(projectDir));
|
|
39
|
+
} catch {
|
|
40
|
+
// Directory may have been deleted — fall back to path.resolve()
|
|
41
|
+
resolved = path.resolve(projectDir);
|
|
42
|
+
}
|
|
43
|
+
const hash = createHash('sha256').update(resolved).digest('hex');
|
|
44
|
+
return hash.slice(0, 16);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the environments file path for a project.
|
|
49
|
+
*/
|
|
50
|
+
function getEnvFilePath(projectDir) {
|
|
51
|
+
const configDir = getConfigDir();
|
|
52
|
+
const hash = getProjectHash(projectDir);
|
|
53
|
+
return path.join(configDir, 'environments', `${hash}.json`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read and parse the environments file.
|
|
58
|
+
*/
|
|
59
|
+
function readEnvFile(filePath) {
|
|
60
|
+
try {
|
|
61
|
+
if (!fs.existsSync(filePath)) {
|
|
62
|
+
return DEFAULT_ENV_CONFIG();
|
|
63
|
+
}
|
|
64
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
67
|
+
console.warn(`[env-resolver] Corrupt environments file at ${filePath}, returning defaults`);
|
|
68
|
+
return DEFAULT_ENV_CONFIG();
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
base: parsed.base || {},
|
|
72
|
+
environments: parsed.environments || {},
|
|
73
|
+
active: parsed.active != null ? parsed.active : null,
|
|
74
|
+
};
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.warn(`[env-resolver] Failed to load environments from ${filePath}: ${err.message}`);
|
|
77
|
+
return DEFAULT_ENV_CONFIG();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write the environments config to the file.
|
|
83
|
+
*/
|
|
84
|
+
function writeEnvFile(filePath, config) {
|
|
85
|
+
const dir = path.dirname(filePath);
|
|
86
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
87
|
+
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load environments for a project from disk.
|
|
92
|
+
* Returns default structure if file doesn't exist or is corrupt JSON.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} projectDir
|
|
95
|
+
* @returns {{ base: object, environments: object, active: string|null }}
|
|
96
|
+
*/
|
|
97
|
+
export function loadEnvironments(projectDir) {
|
|
98
|
+
const filePath = getEnvFilePath(projectDir);
|
|
99
|
+
return readEnvFile(filePath);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Save environments for a project to disk.
|
|
104
|
+
* Creates the directory structure if needed.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} projectDir
|
|
107
|
+
* @param {{ base: object, environments: object, active: string|null }} data
|
|
108
|
+
*/
|
|
109
|
+
export function saveEnvironments(projectDir, data) {
|
|
110
|
+
const filePath = getEnvFilePath(projectDir);
|
|
111
|
+
writeEnvFile(filePath, data);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the active environment by merging base + the active named environment.
|
|
116
|
+
* Named environment values override base values.
|
|
117
|
+
*
|
|
118
|
+
* @param {{ base: object, environments: object, active: string|null }} envData
|
|
119
|
+
* @returns {object} - Merged key-value pairs
|
|
120
|
+
*/
|
|
121
|
+
export function resolveEnvironment(envData) {
|
|
122
|
+
const base = envData.base || {};
|
|
123
|
+
const active = envData.active;
|
|
124
|
+
const environments = envData.environments || {};
|
|
125
|
+
|
|
126
|
+
if (!active || !environments[active]) {
|
|
127
|
+
return { ...base };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { ...base, ...environments[active] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Replace {{ variable_name }} patterns in a string with values from the environment.
|
|
135
|
+
* Unresolved variables are left as literal text.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} str - String potentially containing {{ var }} patterns
|
|
138
|
+
* @param {object} env - Key-value environment map
|
|
139
|
+
* @returns {string} - String with resolved variables
|
|
140
|
+
*/
|
|
141
|
+
export function resolveVariables(str, env) {
|
|
142
|
+
if (typeof str !== 'string') return str;
|
|
143
|
+
if (!env) return str;
|
|
144
|
+
return str.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (match, varName) => {
|
|
145
|
+
if (varName in env) {
|
|
146
|
+
return String(env[varName]);
|
|
147
|
+
}
|
|
148
|
+
return match; // Leave unresolved as literal text
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve all string values in a params object against the environment.
|
|
154
|
+
* Recursively processes nested objects and arrays.
|
|
155
|
+
*
|
|
156
|
+
* @param {*} params - Parameters with potential {{ var }} values
|
|
157
|
+
* @param {object} env - Key-value environment map
|
|
158
|
+
* @returns {*} - Resolved params
|
|
159
|
+
*/
|
|
160
|
+
export function resolveAllParams(params, env) {
|
|
161
|
+
if (params === null || params === undefined) return params;
|
|
162
|
+
if (typeof params === 'string') return resolveVariables(params, env);
|
|
163
|
+
if (Array.isArray(params)) {
|
|
164
|
+
return params.map(item => resolveAllParams(item, env));
|
|
165
|
+
}
|
|
166
|
+
if (typeof params === 'object') {
|
|
167
|
+
const result = {};
|
|
168
|
+
for (const [key, value] of Object.entries(params)) {
|
|
169
|
+
result[key] = resolveAllParams(value, env);
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
return params;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Additional helpers used by server routes ---
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Replace the base environment entirely.
|
|
180
|
+
* @param {string} projectDir
|
|
181
|
+
* @param {object} base - New base environment
|
|
182
|
+
* @returns {object} The new base environment
|
|
183
|
+
*/
|
|
184
|
+
export function updateBase(projectDir, base) {
|
|
185
|
+
const filePath = getEnvFilePath(projectDir);
|
|
186
|
+
const config = readEnvFile(filePath);
|
|
187
|
+
config.base = base;
|
|
188
|
+
writeEnvFile(filePath, config);
|
|
189
|
+
return config.base;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Set the active environment name.
|
|
194
|
+
* @param {string} projectDir
|
|
195
|
+
* @param {string|null} name - Environment name or null to deactivate
|
|
196
|
+
* @returns {{ active: string|null }}
|
|
197
|
+
* @throws {Error} If the named environment does not exist
|
|
198
|
+
*/
|
|
199
|
+
export function setActive(projectDir, name) {
|
|
200
|
+
const filePath = getEnvFilePath(projectDir);
|
|
201
|
+
const config = readEnvFile(filePath);
|
|
202
|
+
if (name !== null && !config.environments[name]) {
|
|
203
|
+
throw new Error(`Environment not found: ${name}`);
|
|
204
|
+
}
|
|
205
|
+
config.active = name;
|
|
206
|
+
writeEnvFile(filePath, config);
|
|
207
|
+
return { active: config.active };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Create or update a named environment.
|
|
212
|
+
* @param {string} projectDir
|
|
213
|
+
* @param {string} name - Environment name
|
|
214
|
+
* @param {object} vars - Key-value pairs
|
|
215
|
+
* @returns {object} The saved environment
|
|
216
|
+
*/
|
|
217
|
+
export function updateNamedEnv(projectDir, name, vars) {
|
|
218
|
+
const filePath = getEnvFilePath(projectDir);
|
|
219
|
+
const config = readEnvFile(filePath);
|
|
220
|
+
config.environments[name] = vars;
|
|
221
|
+
writeEnvFile(filePath, config);
|
|
222
|
+
return vars;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Delete a named environment.
|
|
227
|
+
* @param {string} projectDir
|
|
228
|
+
* @param {string} name - Environment name
|
|
229
|
+
* @returns {{ deleted: string }}
|
|
230
|
+
* @throws {Error} If not found
|
|
231
|
+
*/
|
|
232
|
+
export function deleteNamedEnv(projectDir, name) {
|
|
233
|
+
const filePath = getEnvFilePath(projectDir);
|
|
234
|
+
const config = readEnvFile(filePath);
|
|
235
|
+
if (!config.environments[name]) {
|
|
236
|
+
throw new Error(`Environment not found: ${name}`);
|
|
237
|
+
}
|
|
238
|
+
delete config.environments[name];
|
|
239
|
+
if (config.active === name) {
|
|
240
|
+
config.active = null;
|
|
241
|
+
}
|
|
242
|
+
writeEnvFile(filePath, config);
|
|
243
|
+
return { deleted: name };
|
|
244
|
+
}
|
package/core/git-info.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Execute a git command and return trimmed stdout.
|
|
5
|
+
* Rejects if the command fails.
|
|
6
|
+
*/
|
|
7
|
+
function gitExec(args, cwd) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
execFile('git', args, { cwd }, (error, stdout, stderr) => {
|
|
10
|
+
if (error) {
|
|
11
|
+
reject(error);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
resolve(stdout.trim());
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get git info for a project directory.
|
|
21
|
+
* @param {string} projectDir - Path to the project directory
|
|
22
|
+
* @returns {Promise<{branch: string, commit: string, dirty: boolean} | null>}
|
|
23
|
+
* Returns null if not a git repo or git commands fail.
|
|
24
|
+
*/
|
|
25
|
+
export async function getGitInfo(projectDir) {
|
|
26
|
+
try {
|
|
27
|
+
const [branch, commit, status] = await Promise.all([
|
|
28
|
+
gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], projectDir),
|
|
29
|
+
gitExec(['rev-parse', '--short', 'HEAD'], projectDir),
|
|
30
|
+
gitExec(['status', '--porcelain'], projectDir),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
branch,
|
|
35
|
+
commit,
|
|
36
|
+
dirty: status.length > 0,
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getConfigDir, getProjectHash } from './env-resolver.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PARAMS = () => ({
|
|
6
|
+
path: {},
|
|
7
|
+
query: {},
|
|
8
|
+
headers: {},
|
|
9
|
+
body: null,
|
|
10
|
+
content_type: null,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the params file path for a project.
|
|
15
|
+
*/
|
|
16
|
+
function getParamsFilePath(projectDir) {
|
|
17
|
+
const configDir = getConfigDir();
|
|
18
|
+
const hash = getProjectHash(projectDir);
|
|
19
|
+
return path.join(configDir, 'params', `${hash}.json`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read and parse the params file, returning the full object.
|
|
24
|
+
* Returns an empty object if the file doesn't exist or is corrupt.
|
|
25
|
+
*/
|
|
26
|
+
function readParamsFile(filePath) {
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(filePath)) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
34
|
+
console.warn(`[params-store] Corrupt params file at ${filePath}, returning defaults`);
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.warn(`[params-store] Failed to read params file at ${filePath}: ${err.message}`);
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write the full params object to the params file.
|
|
46
|
+
*/
|
|
47
|
+
function writeParamsFile(filePath, data) {
|
|
48
|
+
const dir = path.dirname(filePath);
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load params for a specific endpoint.
|
|
55
|
+
* @param {string} projectDir - Project directory path
|
|
56
|
+
* @param {string} method - HTTP method (e.g., "GET")
|
|
57
|
+
* @param {string} endpointPath - Endpoint path (e.g., "/users/{id}")
|
|
58
|
+
* @returns {{ path: object, query: object, headers: object, body: any, content_type: string|null }}
|
|
59
|
+
*/
|
|
60
|
+
export function loadParams(projectDir, method, endpointPath) {
|
|
61
|
+
const filePath = getParamsFilePath(projectDir);
|
|
62
|
+
const allParams = readParamsFile(filePath);
|
|
63
|
+
const key = `${method.toUpperCase()} ${endpointPath}`;
|
|
64
|
+
const stored = allParams[key];
|
|
65
|
+
if (stored && typeof stored === 'object') {
|
|
66
|
+
return { ...DEFAULT_PARAMS(), ...stored };
|
|
67
|
+
}
|
|
68
|
+
return DEFAULT_PARAMS();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Save params for a specific endpoint.
|
|
73
|
+
* @param {string} projectDir - Project directory path
|
|
74
|
+
* @param {string} method - HTTP method
|
|
75
|
+
* @param {string} endpointPath - Endpoint path
|
|
76
|
+
* @param {object} params - Params to save
|
|
77
|
+
*/
|
|
78
|
+
export function saveParams(projectDir, method, endpointPath, params) {
|
|
79
|
+
const filePath = getParamsFilePath(projectDir);
|
|
80
|
+
const allParams = readParamsFile(filePath);
|
|
81
|
+
const key = `${method.toUpperCase()} ${endpointPath}`;
|
|
82
|
+
allParams[key] = params;
|
|
83
|
+
writeParamsFile(filePath, allParams);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load all params for a project.
|
|
88
|
+
* @param {string} projectDir - Project directory path
|
|
89
|
+
* @returns {object} Full params object keyed by "METHOD /path"
|
|
90
|
+
*/
|
|
91
|
+
export function loadAllParams(projectDir) {
|
|
92
|
+
const filePath = getParamsFilePath(projectDir);
|
|
93
|
+
return readParamsFile(filePath);
|
|
94
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getConfigDir, getProjectHash } from './env-resolver.js';
|
|
4
|
+
|
|
5
|
+
const GLOBAL_DEFAULTS = () => ({
|
|
6
|
+
theme: 'dark',
|
|
7
|
+
view_mode: 'tag',
|
|
8
|
+
response_wrap: false,
|
|
9
|
+
last_active_project: null,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const PROJECT_DEFAULTS = () => ({
|
|
13
|
+
selected_server: 0,
|
|
14
|
+
server_variables: {},
|
|
15
|
+
doc_collapsed: false,
|
|
16
|
+
selected_endpoint: null,
|
|
17
|
+
tree_collapse_state: {},
|
|
18
|
+
response_tab: 'body',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the global preferences file path.
|
|
23
|
+
*/
|
|
24
|
+
function getGlobalPrefsPath() {
|
|
25
|
+
return path.join(getConfigDir(), 'preferences.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the per-project preferences file path.
|
|
30
|
+
*/
|
|
31
|
+
function getProjectPrefsPath(projectDir) {
|
|
32
|
+
const hash = getProjectHash(projectDir);
|
|
33
|
+
return path.join(getConfigDir(), 'preferences', `${hash}.json`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Safely read and parse a JSON file.
|
|
38
|
+
* Returns null if the file doesn't exist.
|
|
39
|
+
* Returns null and logs a warning if the file is corrupt.
|
|
40
|
+
*/
|
|
41
|
+
function readJsonFile(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.existsSync(filePath)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
49
|
+
console.warn(`[preferences-store] Corrupt file at ${filePath}, resetting to defaults`);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return parsed;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.warn(`[preferences-store] Failed to read ${filePath}: ${err.message}, resetting to defaults`);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write a JSON object to a file, creating directories as needed.
|
|
61
|
+
*/
|
|
62
|
+
function writeJsonFile(filePath, data) {
|
|
63
|
+
const dir = path.dirname(filePath);
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load global and per-project preferences.
|
|
70
|
+
* Missing or corrupt files fall back to defaults.
|
|
71
|
+
* @param {string} projectDir - Project directory path
|
|
72
|
+
* @returns {{ global: object, project: object }}
|
|
73
|
+
*/
|
|
74
|
+
export function loadPreferences(projectDir) {
|
|
75
|
+
const globalPath = getGlobalPrefsPath();
|
|
76
|
+
const projectPath = getProjectPrefsPath(projectDir);
|
|
77
|
+
|
|
78
|
+
const globalStored = readJsonFile(globalPath);
|
|
79
|
+
const projectStored = readJsonFile(projectPath);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
global: { ...GLOBAL_DEFAULTS(), ...(globalStored || {}) },
|
|
83
|
+
project: { ...PROJECT_DEFAULTS(), ...(projectStored || {}) },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Save preferences with merge (PATCH) semantics.
|
|
89
|
+
* Only updates provided keys; existing values are preserved.
|
|
90
|
+
* @param {string} projectDir - Project directory path
|
|
91
|
+
* @param {object} patch - Object with optional `global` and/or `project` keys
|
|
92
|
+
* @returns {{ global: object, project: object }}
|
|
93
|
+
*/
|
|
94
|
+
export function savePreferences(projectDir, patch) {
|
|
95
|
+
const globalPath = getGlobalPrefsPath();
|
|
96
|
+
const projectPath = getProjectPrefsPath(projectDir);
|
|
97
|
+
|
|
98
|
+
// Load current values
|
|
99
|
+
const currentGlobal = readJsonFile(globalPath) || {};
|
|
100
|
+
const currentProject = readJsonFile(projectPath) || {};
|
|
101
|
+
|
|
102
|
+
// Merge patches
|
|
103
|
+
if (patch.global && typeof patch.global === 'object') {
|
|
104
|
+
Object.assign(currentGlobal, patch.global);
|
|
105
|
+
}
|
|
106
|
+
if (patch.project && typeof patch.project === 'object') {
|
|
107
|
+
Object.assign(currentProject, patch.project);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Write back
|
|
111
|
+
writeJsonFile(globalPath, currentGlobal);
|
|
112
|
+
writeJsonFile(projectPath, currentProject);
|
|
113
|
+
|
|
114
|
+
// Return with defaults applied
|
|
115
|
+
return {
|
|
116
|
+
global: { ...GLOBAL_DEFAULTS(), ...currentGlobal },
|
|
117
|
+
project: { ...PROJECT_DEFAULTS(), ...currentProject },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Load global preferences only (no project context needed).
|
|
123
|
+
* @returns {{ global: object, project: null }}
|
|
124
|
+
*/
|
|
125
|
+
export function loadGlobalPreferences() {
|
|
126
|
+
const globalPath = getGlobalPrefsPath();
|
|
127
|
+
const globalStored = readJsonFile(globalPath);
|
|
128
|
+
return {
|
|
129
|
+
global: { ...GLOBAL_DEFAULTS(), ...(globalStored || {}) },
|
|
130
|
+
project: null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Save global preferences only (no project context needed).
|
|
136
|
+
* @param {object} patch - Object with `global` key
|
|
137
|
+
* @returns {{ global: object, project: null }}
|
|
138
|
+
*/
|
|
139
|
+
export function saveGlobalPreferences(patch) {
|
|
140
|
+
const globalPath = getGlobalPrefsPath();
|
|
141
|
+
const currentGlobal = readJsonFile(globalPath) || {};
|
|
142
|
+
if (patch.global && typeof patch.global === 'object') {
|
|
143
|
+
Object.assign(currentGlobal, patch.global);
|
|
144
|
+
}
|
|
145
|
+
writeJsonFile(globalPath, currentGlobal);
|
|
146
|
+
return {
|
|
147
|
+
global: { ...GLOBAL_DEFAULTS(), ...currentGlobal },
|
|
148
|
+
project: null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getConfigDir } from './env-resolver.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the projects.json file path.
|
|
7
|
+
*/
|
|
8
|
+
function getProjectsFilePath() {
|
|
9
|
+
return path.join(getConfigDir(), 'projects.json');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read and parse the projects file.
|
|
14
|
+
* Returns an empty array if the file doesn't exist or is corrupt.
|
|
15
|
+
*/
|
|
16
|
+
function readProjectsFile() {
|
|
17
|
+
const filePath = getProjectsFilePath();
|
|
18
|
+
try {
|
|
19
|
+
if (!fs.existsSync(filePath)) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (!Array.isArray(parsed)) {
|
|
25
|
+
console.warn(`[projects-store] Corrupt projects file at ${filePath}, returning empty array`);
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.warn(`[projects-store] Failed to read projects file: ${err.message}`);
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write the projects array to the projects file.
|
|
37
|
+
*/
|
|
38
|
+
function writeProjectsFile(projects) {
|
|
39
|
+
const filePath = getProjectsFilePath();
|
|
40
|
+
const dir = path.dirname(filePath);
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
42
|
+
fs.writeFileSync(filePath, JSON.stringify(projects, null, 2), 'utf-8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve a project path to an absolute, real path.
|
|
47
|
+
*/
|
|
48
|
+
function resolvePath(projectPath) {
|
|
49
|
+
const resolved = path.resolve(projectPath);
|
|
50
|
+
try {
|
|
51
|
+
return fs.realpathSync(resolved);
|
|
52
|
+
} catch {
|
|
53
|
+
// If realpathSync fails (e.g., path doesn't exist), return the resolved path
|
|
54
|
+
return resolved;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Enrich a stored project record with the derived name.
|
|
60
|
+
*/
|
|
61
|
+
function enrichProject(record) {
|
|
62
|
+
return {
|
|
63
|
+
path: record.path,
|
|
64
|
+
name: path.basename(record.path),
|
|
65
|
+
spec: record.spec || null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load all bookmarked projects.
|
|
71
|
+
* @returns {Array<{path: string, name: string, spec: string|null}>}
|
|
72
|
+
*/
|
|
73
|
+
export function loadProjects() {
|
|
74
|
+
const records = readProjectsFile();
|
|
75
|
+
return records.map(enrichProject);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Add a project bookmark.
|
|
80
|
+
* @param {string} projectPath - Directory path to bookmark
|
|
81
|
+
* @param {string|null} [specOverride] - Optional spec file path override
|
|
82
|
+
* @returns {{ path: string, name: string, spec: string|null }}
|
|
83
|
+
* @throws {Error} If the directory doesn't exist or is already bookmarked
|
|
84
|
+
*/
|
|
85
|
+
export function addProject(projectPath, specOverride = null) {
|
|
86
|
+
// Validate directory exists
|
|
87
|
+
const resolvedPath = resolvePath(projectPath);
|
|
88
|
+
try {
|
|
89
|
+
const stat = fs.statSync(resolvedPath);
|
|
90
|
+
if (!stat.isDirectory()) {
|
|
91
|
+
throw new Error(`Not a directory: ${resolvedPath}`);
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err.code === 'ENOENT') {
|
|
95
|
+
throw new Error(`Directory does not exist: ${resolvedPath}`);
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check for duplicates
|
|
101
|
+
const records = readProjectsFile();
|
|
102
|
+
const existing = records.find((r) => resolvePath(r.path) === resolvedPath);
|
|
103
|
+
if (existing) {
|
|
104
|
+
throw new Error(`Project already bookmarked: ${resolvedPath}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Add the project
|
|
108
|
+
const record = { path: resolvedPath };
|
|
109
|
+
if (specOverride) {
|
|
110
|
+
record.spec = specOverride;
|
|
111
|
+
}
|
|
112
|
+
records.push(record);
|
|
113
|
+
writeProjectsFile(records);
|
|
114
|
+
|
|
115
|
+
return enrichProject(record);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Remove a project bookmark.
|
|
120
|
+
* @param {string} projectPath - Directory path to remove
|
|
121
|
+
* @returns {{ deleted: string }}
|
|
122
|
+
* @throws {Error} If the project is not bookmarked
|
|
123
|
+
*/
|
|
124
|
+
export function removeProject(projectPath) {
|
|
125
|
+
const resolvedPath = resolvePath(projectPath);
|
|
126
|
+
const records = readProjectsFile();
|
|
127
|
+
const index = records.findIndex((r) => resolvePath(r.path) === resolvedPath);
|
|
128
|
+
|
|
129
|
+
if (index === -1) {
|
|
130
|
+
throw new Error(`Project not found: ${resolvedPath}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
records.splice(index, 1);
|
|
134
|
+
writeProjectsFile(records);
|
|
135
|
+
|
|
136
|
+
return { deleted: resolvedPath };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Find a project by exact path match.
|
|
141
|
+
* @param {string} projectPath - Directory path to find
|
|
142
|
+
* @returns {{ path: string, name: string, spec: string|null } | null}
|
|
143
|
+
*/
|
|
144
|
+
export function findProject(projectPath) {
|
|
145
|
+
const resolvedPath = resolvePath(projectPath);
|
|
146
|
+
const records = readProjectsFile();
|
|
147
|
+
const found = records.find((r) => resolvePath(r.path) === resolvedPath);
|
|
148
|
+
return found ? enrichProject(found) : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Find a bookmarked project that contains the given directory.
|
|
153
|
+
* Checks if dir equals or is a subdirectory of any bookmarked project.
|
|
154
|
+
* When multiple projects match, returns the deepest (most specific) one.
|
|
155
|
+
* @param {string} dir - Directory to check
|
|
156
|
+
* @returns {{ path: string, name: string, spec: string|null } | null}
|
|
157
|
+
*/
|
|
158
|
+
export function findProjectForDir(dir) {
|
|
159
|
+
const resolvedDir = resolvePath(dir);
|
|
160
|
+
const records = readProjectsFile();
|
|
161
|
+
let best = null;
|
|
162
|
+
let bestLen = 0;
|
|
163
|
+
for (const record of records) {
|
|
164
|
+
const projectPath = resolvePath(record.path);
|
|
165
|
+
if (resolvedDir === projectPath || resolvedDir.startsWith(projectPath + path.sep)) {
|
|
166
|
+
if (projectPath.length > bestLen) {
|
|
167
|
+
best = record;
|
|
168
|
+
bestLen = projectPath.length;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return best ? enrichProject(best) : null;
|
|
173
|
+
}
|