cli4ai 1.1.5 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/bin.d.ts +6 -0
- package/dist/bin.js +105 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +335 -0
- package/dist/commands/add.d.ts +11 -0
- package/dist/commands/add.js +459 -0
- package/dist/commands/browse.d.ts +4 -0
- package/dist/commands/browse.js +379 -0
- package/dist/commands/config.d.ts +10 -0
- package/dist/commands/config.js +121 -0
- package/dist/commands/info.d.ts +9 -0
- package/dist/commands/info.js +122 -0
- package/dist/commands/init.d.ts +10 -0
- package/dist/commands/init.js +458 -0
- package/dist/commands/list.d.ts +10 -0
- package/dist/commands/list.js +76 -0
- package/dist/commands/mcp-config.d.ts +10 -0
- package/dist/commands/mcp-config.js +49 -0
- package/dist/commands/remotes.d.ts +22 -0
- package/dist/commands/remotes.js +196 -0
- package/dist/commands/remove.d.ts +8 -0
- package/dist/commands/remove.js +61 -0
- package/dist/commands/routines.d.ts +29 -0
- package/dist/commands/routines.js +363 -0
- package/dist/commands/run.d.ts +12 -0
- package/dist/commands/run.js +104 -0
- package/dist/commands/scheduler.d.ts +27 -0
- package/dist/commands/scheduler.js +350 -0
- package/dist/commands/search.d.ts +9 -0
- package/dist/commands/search.js +159 -0
- package/dist/commands/secrets.d.ts +28 -0
- package/dist/commands/secrets.js +236 -0
- package/dist/commands/serve.d.ts +13 -0
- package/dist/commands/serve.js +49 -0
- package/dist/commands/start.d.ts +8 -0
- package/dist/commands/start.js +27 -0
- package/dist/commands/update.d.ts +17 -0
- package/dist/commands/update.js +210 -0
- package/dist/core/config.d.ts +91 -0
- package/dist/core/config.js +738 -0
- package/dist/core/execute.d.ts +51 -0
- package/dist/core/execute.js +475 -0
- package/dist/core/link.d.ts +39 -0
- package/dist/core/link.js +214 -0
- package/dist/core/lockfile.d.ts +63 -0
- package/dist/core/lockfile.js +140 -0
- package/dist/core/manifest.d.ts +96 -0
- package/dist/core/manifest.js +224 -0
- package/dist/core/registry.d.ts +74 -0
- package/dist/core/registry.js +116 -0
- package/dist/core/remote-client.d.ts +98 -0
- package/dist/core/remote-client.js +252 -0
- package/dist/core/remotes.d.ts +88 -0
- package/dist/core/remotes.js +206 -0
- package/dist/core/routine-engine.d.ts +124 -0
- package/dist/core/routine-engine.js +699 -0
- package/dist/core/routines.d.ts +36 -0
- package/dist/core/routines.js +132 -0
- package/dist/core/scheduler-daemon.d.ts +10 -0
- package/dist/core/scheduler-daemon.js +77 -0
- package/dist/core/scheduler.d.ts +131 -0
- package/dist/core/scheduler.js +492 -0
- package/dist/core/secrets.d.ts +48 -0
- package/dist/core/secrets.js +384 -0
- package/dist/lib/cli.d.ts +84 -0
- package/dist/lib/cli.js +216 -0
- package/dist/mcp/adapter.d.ts +35 -0
- package/dist/mcp/adapter.js +94 -0
- package/dist/mcp/config-gen.d.ts +31 -0
- package/dist/mcp/config-gen.js +75 -0
- package/dist/mcp/server.d.ts +41 -0
- package/dist/mcp/server.js +296 -0
- package/dist/server/service.d.ts +85 -0
- package/dist/server/service.js +304 -0
- package/package.json +6 -3
- package/src/bin.ts +0 -118
- package/src/cli.ts +0 -409
- package/src/commands/add.ts +0 -562
- package/src/commands/browse.ts +0 -449
- package/src/commands/config.ts +0 -154
- package/src/commands/info.ts +0 -102
- package/src/commands/init.ts +0 -514
- package/src/commands/list.ts +0 -72
- package/src/commands/mcp-config.ts +0 -69
- package/src/commands/remotes.ts +0 -253
- package/src/commands/remove.ts +0 -78
- package/src/commands/routines.ts +0 -427
- package/src/commands/run.ts +0 -127
- package/src/commands/scheduler.ts +0 -438
- package/src/commands/search.ts +0 -148
- package/src/commands/secrets.ts +0 -292
- package/src/commands/serve.ts +0 -66
- package/src/commands/start.ts +0 -40
- package/src/commands/update.ts +0 -252
- package/src/core/config.ts +0 -845
- package/src/core/execute.ts +0 -569
- package/src/core/link.ts +0 -246
- package/src/core/lockfile.ts +0 -187
- package/src/core/manifest.ts +0 -327
- package/src/core/registry.ts +0 -165
- package/src/core/remote-client.ts +0 -419
- package/src/core/remotes.ts +0 -268
- package/src/core/routine-engine.ts +0 -895
- package/src/core/routines.ts +0 -171
- package/src/core/scheduler-daemon.ts +0 -94
- package/src/core/scheduler.ts +0 -606
- package/src/core/secrets.ts +0 -430
- package/src/lib/cli.ts +0 -261
- package/src/mcp/adapter.ts +0 -131
- package/src/mcp/config-gen.ts +0 -106
- package/src/mcp/server.ts +0 -365
- package/src/server/service.ts +0 -434
package/src/core/config.ts
DELETED
|
@@ -1,845 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Global cli4ai configuration (~/.cli4ai/)
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, lstatSync, realpathSync, openSync, closeSync, unlinkSync, renameSync } from 'fs';
|
|
6
|
-
import { resolve, join, normalize } from 'path';
|
|
7
|
-
import { homedir } from 'os';
|
|
8
|
-
import { outputError, log } from '../lib/cli.js';
|
|
9
|
-
|
|
10
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
11
|
-
// SECURITY: Symlink validation
|
|
12
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Safe directories that symlinks are allowed to point to.
|
|
16
|
-
* This prevents symlink attacks pointing to sensitive system directories.
|
|
17
|
-
*/
|
|
18
|
-
const SAFE_SYMLINK_PREFIXES = [
|
|
19
|
-
homedir(), // User's home directory
|
|
20
|
-
'/tmp', // Temp directory
|
|
21
|
-
'/var/tmp', // Var temp
|
|
22
|
-
'/private/tmp', // macOS temp
|
|
23
|
-
process.cwd(), // Current working directory
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Validate that a symlink target is within safe boundaries.
|
|
28
|
-
* Returns the resolved real path if safe, null if unsafe.
|
|
29
|
-
*/
|
|
30
|
-
function validateSymlinkTarget(symlinkPath: string): string | null {
|
|
31
|
-
try {
|
|
32
|
-
const stat = lstatSync(symlinkPath);
|
|
33
|
-
if (!stat.isSymbolicLink()) {
|
|
34
|
-
// Not a symlink, return the path as-is
|
|
35
|
-
return symlinkPath;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Resolve the real path (follows all symlinks)
|
|
39
|
-
const realPath = realpathSync(symlinkPath);
|
|
40
|
-
const normalizedRealPath = normalize(realPath);
|
|
41
|
-
|
|
42
|
-
// Check if the real path is within a safe prefix
|
|
43
|
-
const isSafe = SAFE_SYMLINK_PREFIXES.some(prefix => {
|
|
44
|
-
const normalizedPrefix = normalize(prefix);
|
|
45
|
-
return normalizedRealPath.startsWith(normalizedPrefix + '/') ||
|
|
46
|
-
normalizedRealPath === normalizedPrefix;
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
if (!isSafe) {
|
|
50
|
-
console.error(`Warning: Symlink ${symlinkPath} points to ${realPath} which is outside safe directories. Skipping.`);
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return realPath;
|
|
55
|
-
} catch (err) {
|
|
56
|
-
// Broken symlink or permission error
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Check if a path is safe to use (not a malicious symlink)
|
|
63
|
-
*/
|
|
64
|
-
export function isPathSafe(path: string): boolean {
|
|
65
|
-
return validateSymlinkTarget(path) !== null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
69
|
-
// PATHS
|
|
70
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
71
|
-
|
|
72
|
-
export const CLI4AI_HOME = process.env.CLI4AI_HOME
|
|
73
|
-
? resolve(process.env.CLI4AI_HOME)
|
|
74
|
-
: resolve(homedir(), '.cli4ai');
|
|
75
|
-
export const CONFIG_FILE = resolve(CLI4AI_HOME, 'config.json');
|
|
76
|
-
export const PACKAGES_DIR = resolve(CLI4AI_HOME, 'packages');
|
|
77
|
-
export const CACHE_DIR = resolve(CLI4AI_HOME, 'cache');
|
|
78
|
-
export const ROUTINES_DIR = resolve(CLI4AI_HOME, 'routines');
|
|
79
|
-
export const SCHEDULER_DIR = resolve(CLI4AI_HOME, 'scheduler');
|
|
80
|
-
export const CREDENTIALS_FILE = resolve(CLI4AI_HOME, 'credentials.json');
|
|
81
|
-
const CONFIG_LOCK_FILE = CONFIG_FILE + '.lock';
|
|
82
|
-
const CONFIG_TMP_FILE = CONFIG_FILE + '.tmp';
|
|
83
|
-
|
|
84
|
-
// Local project paths
|
|
85
|
-
export const LOCAL_DIR = '.cli4ai';
|
|
86
|
-
export const LOCAL_PACKAGES_DIR = join(LOCAL_DIR, 'packages');
|
|
87
|
-
export const LOCAL_ROUTINES_DIR = join(LOCAL_DIR, 'routines');
|
|
88
|
-
|
|
89
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
90
|
-
// TYPES
|
|
91
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
92
|
-
|
|
93
|
-
export interface Config {
|
|
94
|
-
// Registry configuration
|
|
95
|
-
registry: string;
|
|
96
|
-
localRegistries: string[];
|
|
97
|
-
|
|
98
|
-
// Runtime defaults
|
|
99
|
-
defaultRuntime: 'node';
|
|
100
|
-
|
|
101
|
-
// MCP defaults
|
|
102
|
-
mcp: {
|
|
103
|
-
transport: 'stdio' | 'http';
|
|
104
|
-
port: number;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
// Audit logging configuration
|
|
108
|
-
audit: {
|
|
109
|
-
/** Enable audit logging for MCP tool calls */
|
|
110
|
-
enabled: boolean;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
// Telemetry (future)
|
|
114
|
-
telemetry: boolean;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export interface InstalledPackage {
|
|
118
|
-
name: string;
|
|
119
|
-
version: string;
|
|
120
|
-
path: string;
|
|
121
|
-
source: 'local' | 'registry';
|
|
122
|
-
installedAt: string;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
126
|
-
// DEFAULT CONFIG
|
|
127
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
128
|
-
|
|
129
|
-
export const DEFAULT_CONFIG: Config = {
|
|
130
|
-
registry: 'https://registry.cli4ai.com',
|
|
131
|
-
localRegistries: [],
|
|
132
|
-
defaultRuntime: 'node',
|
|
133
|
-
mcp: {
|
|
134
|
-
transport: 'stdio',
|
|
135
|
-
port: 3100
|
|
136
|
-
},
|
|
137
|
-
audit: {
|
|
138
|
-
enabled: true
|
|
139
|
-
},
|
|
140
|
-
telemetry: false
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
144
|
-
// INITIALIZATION
|
|
145
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Ensure ~/.cli4ai directory exists with required subdirectories
|
|
149
|
-
*/
|
|
150
|
-
export function ensureCli4aiHome(): void {
|
|
151
|
-
const dirs = [CLI4AI_HOME, PACKAGES_DIR, CACHE_DIR, ROUTINES_DIR, SCHEDULER_DIR];
|
|
152
|
-
|
|
153
|
-
for (const dir of dirs) {
|
|
154
|
-
if (!existsSync(dir)) {
|
|
155
|
-
mkdirSync(dir, { recursive: true });
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Ensure local .cli4ai directory exists
|
|
162
|
-
*/
|
|
163
|
-
export function ensureLocalDir(projectDir: string): void {
|
|
164
|
-
const localDir = resolve(projectDir, LOCAL_DIR);
|
|
165
|
-
const packagesDir = resolve(projectDir, LOCAL_PACKAGES_DIR);
|
|
166
|
-
const routinesDir = resolve(projectDir, LOCAL_ROUTINES_DIR);
|
|
167
|
-
|
|
168
|
-
if (!existsSync(localDir)) {
|
|
169
|
-
mkdirSync(localDir, { recursive: true });
|
|
170
|
-
}
|
|
171
|
-
if (!existsSync(packagesDir)) {
|
|
172
|
-
mkdirSync(packagesDir, { recursive: true });
|
|
173
|
-
}
|
|
174
|
-
if (!existsSync(routinesDir)) {
|
|
175
|
-
mkdirSync(routinesDir, { recursive: true });
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
180
|
-
// CONFIG FILE
|
|
181
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Deep merge two objects, where source values override target values
|
|
185
|
-
*/
|
|
186
|
-
function deepMerge(target: Config, source: Partial<Config>): Config {
|
|
187
|
-
const result: Config = { ...target };
|
|
188
|
-
|
|
189
|
-
// Handle each key explicitly to maintain type safety
|
|
190
|
-
if (source.registry !== undefined) {
|
|
191
|
-
result.registry = source.registry;
|
|
192
|
-
}
|
|
193
|
-
if (source.localRegistries !== undefined) {
|
|
194
|
-
result.localRegistries = source.localRegistries;
|
|
195
|
-
}
|
|
196
|
-
if (source.defaultRuntime !== undefined) {
|
|
197
|
-
result.defaultRuntime = source.defaultRuntime;
|
|
198
|
-
}
|
|
199
|
-
if (source.telemetry !== undefined) {
|
|
200
|
-
result.telemetry = source.telemetry;
|
|
201
|
-
}
|
|
202
|
-
// Deep merge mcp config
|
|
203
|
-
if (source.mcp !== undefined) {
|
|
204
|
-
result.mcp = {
|
|
205
|
-
transport: source.mcp.transport ?? target.mcp.transport,
|
|
206
|
-
port: source.mcp.port ?? target.mcp.port
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
// Deep merge audit config
|
|
210
|
-
if (source.audit !== undefined) {
|
|
211
|
-
result.audit = {
|
|
212
|
-
enabled: source.audit.enabled ?? target.audit.enabled
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return result;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Load global config, creating defaults if needed
|
|
221
|
-
*/
|
|
222
|
-
export function loadConfig(): Config {
|
|
223
|
-
return withConfigLock(() => loadConfigUnlocked());
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Save global config
|
|
228
|
-
*/
|
|
229
|
-
export function saveConfig(config: Config): void {
|
|
230
|
-
withConfigLock(() => saveConfigUnlocked(config));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Update config with a read-modify-write lock to avoid cross-process races.
|
|
235
|
-
*/
|
|
236
|
-
export function updateConfig(mutator: (config: Config) => Config): Config {
|
|
237
|
-
return withConfigLock(() => {
|
|
238
|
-
const current = loadConfigUnlocked();
|
|
239
|
-
const next = mutator(current);
|
|
240
|
-
saveConfigUnlocked(next);
|
|
241
|
-
return next;
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Get a config value
|
|
247
|
-
*/
|
|
248
|
-
export function getConfigValue<K extends keyof Config>(key: K): Config[K] {
|
|
249
|
-
const config = loadConfig();
|
|
250
|
-
return config[key];
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Set a config value
|
|
255
|
-
*/
|
|
256
|
-
export function setConfigValue<K extends keyof Config>(key: K, value: Config[K]): void {
|
|
257
|
-
updateConfig((config) => {
|
|
258
|
-
config[key] = value;
|
|
259
|
-
return config;
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
264
|
-
// LOCAL REGISTRIES
|
|
265
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Directories that should not be used as registries
|
|
269
|
-
* to prevent accidental exposure of sensitive system files.
|
|
270
|
-
*/
|
|
271
|
-
const UNSAFE_REGISTRY_PATHS = [
|
|
272
|
-
'/etc',
|
|
273
|
-
'/usr',
|
|
274
|
-
'/bin',
|
|
275
|
-
'/sbin',
|
|
276
|
-
'/var',
|
|
277
|
-
'/sys',
|
|
278
|
-
'/proc',
|
|
279
|
-
'/dev',
|
|
280
|
-
'/boot',
|
|
281
|
-
'/root',
|
|
282
|
-
'/lib',
|
|
283
|
-
'/lib64',
|
|
284
|
-
'/System', // macOS
|
|
285
|
-
'/Library', // macOS
|
|
286
|
-
'/private/etc', // macOS
|
|
287
|
-
'/private/var', // macOS
|
|
288
|
-
'C:\\Windows', // Windows
|
|
289
|
-
'C:\\Program Files', // Windows
|
|
290
|
-
];
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Validate that a registry path is safe to use.
|
|
294
|
-
*/
|
|
295
|
-
function isRegistryPathSafe(registryPath: string): boolean {
|
|
296
|
-
const normalizedPath = normalize(registryPath);
|
|
297
|
-
|
|
298
|
-
// Check against unsafe paths
|
|
299
|
-
for (const unsafePath of UNSAFE_REGISTRY_PATHS) {
|
|
300
|
-
const normalizedUnsafe = normalize(unsafePath);
|
|
301
|
-
if (normalizedPath === normalizedUnsafe ||
|
|
302
|
-
normalizedPath.startsWith(normalizedUnsafe + '/') ||
|
|
303
|
-
normalizedPath.startsWith(normalizedUnsafe + '\\')) {
|
|
304
|
-
return false;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
return true;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Add a local registry path
|
|
313
|
-
*/
|
|
314
|
-
export function addLocalRegistry(path: string): void {
|
|
315
|
-
const absolutePath = resolve(path);
|
|
316
|
-
|
|
317
|
-
// SECURITY: Validate registry path is not in sensitive system directories
|
|
318
|
-
if (!isRegistryPathSafe(absolutePath)) {
|
|
319
|
-
outputError('INVALID_INPUT', `Registry path is in a protected system directory: ${absolutePath}`, {
|
|
320
|
-
hint: 'Use a path in your home directory or a project directory'
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (!existsSync(absolutePath)) {
|
|
325
|
-
outputError('NOT_FOUND', `Directory does not exist: ${absolutePath}`);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// SECURITY: Validate symlink target if it's a symlink
|
|
329
|
-
const safePath = validateSymlinkTarget(absolutePath);
|
|
330
|
-
if (!safePath) {
|
|
331
|
-
outputError('INVALID_INPUT', `Registry path points to an unsafe location: ${absolutePath}`, {
|
|
332
|
-
hint: 'Symlinks must point to directories within safe locations'
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
let added = false;
|
|
337
|
-
updateConfig((config) => {
|
|
338
|
-
if (!config.localRegistries.includes(safePath)) {
|
|
339
|
-
config.localRegistries.push(safePath);
|
|
340
|
-
added = true;
|
|
341
|
-
}
|
|
342
|
-
return config;
|
|
343
|
-
});
|
|
344
|
-
if (added) log(`Added local registry: ${safePath}`);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Remove a local registry path
|
|
349
|
-
*/
|
|
350
|
-
export function removeLocalRegistry(path: string): void {
|
|
351
|
-
const absolutePath = resolve(path);
|
|
352
|
-
|
|
353
|
-
// SECURITY: Validate symlink target consistently with addLocalRegistry
|
|
354
|
-
const safePath = validateSymlinkTarget(absolutePath);
|
|
355
|
-
if (!safePath) {
|
|
356
|
-
outputError('INVALID_INPUT', `Registry path points to an unsafe location: ${absolutePath}`, {
|
|
357
|
-
hint: 'Symlinks must point to directories within safe locations'
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
let removed = false;
|
|
362
|
-
updateConfig((config) => {
|
|
363
|
-
const index = config.localRegistries.indexOf(safePath);
|
|
364
|
-
if (index !== -1) {
|
|
365
|
-
config.localRegistries.splice(index, 1);
|
|
366
|
-
removed = true;
|
|
367
|
-
}
|
|
368
|
-
return config;
|
|
369
|
-
});
|
|
370
|
-
if (removed) log(`Removed local registry: ${safePath}`);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function sleepSync(ms: number): void {
|
|
374
|
-
if (ms <= 0) return;
|
|
375
|
-
try {
|
|
376
|
-
const buf = new SharedArrayBuffer(4);
|
|
377
|
-
const view = new Int32Array(buf);
|
|
378
|
-
Atomics.wait(view, 0, 0, ms);
|
|
379
|
-
} catch {
|
|
380
|
-
const end = Date.now() + ms;
|
|
381
|
-
while (Date.now() < end) {
|
|
382
|
-
// busy wait (best effort)
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function isPidRunning(pid: number): boolean {
|
|
388
|
-
try {
|
|
389
|
-
process.kill(pid, 0);
|
|
390
|
-
return true;
|
|
391
|
-
} catch {
|
|
392
|
-
return false;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function isLockStale(lockPath: string, staleMs: number): boolean {
|
|
397
|
-
try {
|
|
398
|
-
const stat = lstatSync(lockPath);
|
|
399
|
-
if (Date.now() - stat.mtimeMs > staleMs) return true;
|
|
400
|
-
} catch {
|
|
401
|
-
return false;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
try {
|
|
405
|
-
const raw = readFileSync(lockPath, 'utf-8');
|
|
406
|
-
const parsed = JSON.parse(raw) as { pid?: unknown; createdAt?: unknown };
|
|
407
|
-
const pid = typeof parsed.pid === 'number' ? parsed.pid : null;
|
|
408
|
-
const createdAtMs =
|
|
409
|
-
typeof parsed.createdAt === 'number'
|
|
410
|
-
? parsed.createdAt
|
|
411
|
-
: typeof parsed.createdAt === 'string'
|
|
412
|
-
? Date.parse(parsed.createdAt)
|
|
413
|
-
: NaN;
|
|
414
|
-
|
|
415
|
-
if (pid !== null && !isPidRunning(pid)) return true;
|
|
416
|
-
if (Number.isFinite(createdAtMs) && Date.now() - createdAtMs > staleMs) return true;
|
|
417
|
-
} catch {
|
|
418
|
-
// ignore parse errors (fall back to mtime check above)
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
return false;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function acquireLock(lockPath: string, timeoutMs: number, staleMs: number): number {
|
|
425
|
-
const start = Date.now();
|
|
426
|
-
while (true) {
|
|
427
|
-
try {
|
|
428
|
-
const fd = openSync(lockPath, 'wx');
|
|
429
|
-
try {
|
|
430
|
-
writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + '\n');
|
|
431
|
-
} catch {
|
|
432
|
-
// best effort
|
|
433
|
-
}
|
|
434
|
-
return fd;
|
|
435
|
-
} catch (err) {
|
|
436
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
437
|
-
if (code !== 'EEXIST') {
|
|
438
|
-
throw err;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
if (isLockStale(lockPath, staleMs)) {
|
|
442
|
-
try {
|
|
443
|
-
unlinkSync(lockPath);
|
|
444
|
-
continue;
|
|
445
|
-
} catch {
|
|
446
|
-
// fall through to wait
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (Date.now() - start > timeoutMs) {
|
|
451
|
-
throw new Error(`Timed out waiting for config lock: ${lockPath}`);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
sleepSync(25);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function releaseLock(lockPath: string, fd: number): void {
|
|
460
|
-
try {
|
|
461
|
-
closeSync(fd);
|
|
462
|
-
} catch {
|
|
463
|
-
// ignore
|
|
464
|
-
}
|
|
465
|
-
try {
|
|
466
|
-
unlinkSync(lockPath);
|
|
467
|
-
} catch {
|
|
468
|
-
// ignore
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
let configLockDepth = 0;
|
|
473
|
-
let configLockFd: number | null = null;
|
|
474
|
-
|
|
475
|
-
function withConfigLock<T>(fn: () => T): T {
|
|
476
|
-
const timeoutMs = 2000;
|
|
477
|
-
const staleMs = 30000;
|
|
478
|
-
|
|
479
|
-
if (configLockDepth > 0) {
|
|
480
|
-
configLockDepth++;
|
|
481
|
-
try {
|
|
482
|
-
return fn();
|
|
483
|
-
} finally {
|
|
484
|
-
configLockDepth--;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
configLockDepth = 1;
|
|
489
|
-
const fd = acquireLock(CONFIG_LOCK_FILE, timeoutMs, staleMs);
|
|
490
|
-
configLockFd = fd;
|
|
491
|
-
|
|
492
|
-
try {
|
|
493
|
-
return fn();
|
|
494
|
-
} finally {
|
|
495
|
-
configLockFd = null;
|
|
496
|
-
configLockDepth = 0;
|
|
497
|
-
releaseLock(CONFIG_LOCK_FILE, fd);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function loadConfigUnlocked(): Config {
|
|
502
|
-
ensureCli4aiHome();
|
|
503
|
-
|
|
504
|
-
if (!existsSync(CONFIG_FILE)) {
|
|
505
|
-
saveConfigUnlocked(DEFAULT_CONFIG);
|
|
506
|
-
return DEFAULT_CONFIG;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
try {
|
|
510
|
-
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
|
511
|
-
const data = JSON.parse(content);
|
|
512
|
-
// Deep merge with defaults to handle missing nested fields (e.g., mcp.port)
|
|
513
|
-
return deepMerge(DEFAULT_CONFIG, data);
|
|
514
|
-
} catch {
|
|
515
|
-
log(`Warning: Invalid config file, using defaults`);
|
|
516
|
-
return DEFAULT_CONFIG;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function saveConfigUnlocked(config: Config): void {
|
|
521
|
-
ensureCli4aiHome();
|
|
522
|
-
const content = JSON.stringify(config, null, 2) + '\n';
|
|
523
|
-
writeFileSync(CONFIG_TMP_FILE, content);
|
|
524
|
-
renameSync(CONFIG_TMP_FILE, CONFIG_FILE);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
528
|
-
// INSTALLED PACKAGES TRACKING
|
|
529
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* Get list of globally installed packages
|
|
533
|
-
*/
|
|
534
|
-
export function getGlobalPackages(): InstalledPackage[] {
|
|
535
|
-
ensureCli4aiHome();
|
|
536
|
-
|
|
537
|
-
if (!existsSync(PACKAGES_DIR)) {
|
|
538
|
-
return [];
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
const packages: InstalledPackage[] = [];
|
|
542
|
-
|
|
543
|
-
for (const entry of readdirSync(PACKAGES_DIR, { withFileTypes: true })) {
|
|
544
|
-
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
545
|
-
|
|
546
|
-
const pkgPath = resolve(PACKAGES_DIR, entry.name);
|
|
547
|
-
|
|
548
|
-
// SECURITY: Validate symlink targets
|
|
549
|
-
const safePath = validateSymlinkTarget(pkgPath);
|
|
550
|
-
if (!safePath) continue;
|
|
551
|
-
|
|
552
|
-
const manifestPath = resolve(safePath, 'cli4ai.json');
|
|
553
|
-
|
|
554
|
-
if (existsSync(manifestPath)) {
|
|
555
|
-
try {
|
|
556
|
-
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
557
|
-
packages.push({
|
|
558
|
-
name: manifest.name,
|
|
559
|
-
version: manifest.version,
|
|
560
|
-
path: safePath,
|
|
561
|
-
source: 'registry',
|
|
562
|
-
installedAt: new Date().toISOString()
|
|
563
|
-
});
|
|
564
|
-
} catch {
|
|
565
|
-
// Skip invalid packages
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
return packages;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/**
|
|
574
|
-
* Get list of locally installed packages (in project)
|
|
575
|
-
*/
|
|
576
|
-
export function getLocalPackages(projectDir: string): InstalledPackage[] {
|
|
577
|
-
const packagesDir = resolve(projectDir, LOCAL_PACKAGES_DIR);
|
|
578
|
-
|
|
579
|
-
if (!existsSync(packagesDir)) {
|
|
580
|
-
return [];
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
const packages: InstalledPackage[] = [];
|
|
584
|
-
|
|
585
|
-
for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
|
|
586
|
-
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
587
|
-
|
|
588
|
-
const pkgPath = resolve(packagesDir, entry.name);
|
|
589
|
-
|
|
590
|
-
// SECURITY: Validate symlink targets
|
|
591
|
-
const safePath = validateSymlinkTarget(pkgPath);
|
|
592
|
-
if (!safePath) continue;
|
|
593
|
-
|
|
594
|
-
const manifestPath = resolve(safePath, 'cli4ai.json');
|
|
595
|
-
|
|
596
|
-
if (existsSync(manifestPath)) {
|
|
597
|
-
try {
|
|
598
|
-
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
599
|
-
packages.push({
|
|
600
|
-
name: manifest.name,
|
|
601
|
-
version: manifest.version,
|
|
602
|
-
path: safePath,
|
|
603
|
-
source: 'local',
|
|
604
|
-
installedAt: new Date().toISOString()
|
|
605
|
-
});
|
|
606
|
-
} catch {
|
|
607
|
-
// Skip invalid packages
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
return packages;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Cache npm global dir to avoid repeated lookups
|
|
616
|
-
let cachedNpmGlobalDir: string | null | undefined = undefined;
|
|
617
|
-
|
|
618
|
-
/**
|
|
619
|
-
* Get npm global packages directory
|
|
620
|
-
*/
|
|
621
|
-
function getNpmGlobalDir(): string | null {
|
|
622
|
-
if (cachedNpmGlobalDir !== undefined) return cachedNpmGlobalDir;
|
|
623
|
-
|
|
624
|
-
// Try common locations first (faster than calling npm)
|
|
625
|
-
const commonPaths = [
|
|
626
|
-
resolve(homedir(), '.npm-global', 'lib', 'node_modules'), // Custom npm prefix
|
|
627
|
-
'/usr/local/lib/node_modules', // macOS/Linux default
|
|
628
|
-
'/usr/lib/node_modules', // Some Linux distros
|
|
629
|
-
resolve(homedir(), '.nvm', 'versions', 'node'), // nvm (check later)
|
|
630
|
-
];
|
|
631
|
-
|
|
632
|
-
for (const p of commonPaths) {
|
|
633
|
-
if (existsSync(p)) {
|
|
634
|
-
cachedNpmGlobalDir = p;
|
|
635
|
-
return p;
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// Fall back to npm config (with timeout)
|
|
640
|
-
try {
|
|
641
|
-
const { execSync } = require('child_process');
|
|
642
|
-
const prefix = execSync('npm config get prefix', {
|
|
643
|
-
encoding: 'utf-8',
|
|
644
|
-
timeout: 3000, // 3 second timeout
|
|
645
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
646
|
-
}).trim();
|
|
647
|
-
// On Unix: prefix/lib/node_modules, on Windows: prefix/node_modules
|
|
648
|
-
const libPath = resolve(prefix, 'lib', 'node_modules');
|
|
649
|
-
if (existsSync(libPath)) {
|
|
650
|
-
cachedNpmGlobalDir = libPath;
|
|
651
|
-
return libPath;
|
|
652
|
-
}
|
|
653
|
-
const winPath = resolve(prefix, 'node_modules');
|
|
654
|
-
if (existsSync(winPath)) {
|
|
655
|
-
cachedNpmGlobalDir = winPath;
|
|
656
|
-
return winPath;
|
|
657
|
-
}
|
|
658
|
-
} catch {
|
|
659
|
-
// npm command failed or timed out
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
cachedNpmGlobalDir = null;
|
|
663
|
-
return null;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* Get bun global packages directory
|
|
668
|
-
*/
|
|
669
|
-
function getBunGlobalDir(): string | null {
|
|
670
|
-
// Bun installs global packages to ~/.bun/install/global/node_modules
|
|
671
|
-
const bunGlobalDir = resolve(homedir(), '.bun', 'install', 'global', 'node_modules');
|
|
672
|
-
if (existsSync(bunGlobalDir)) return bunGlobalDir;
|
|
673
|
-
return null;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Get packages from a global node_modules/@cli4ai directory
|
|
678
|
-
*/
|
|
679
|
-
function getPackagesFromGlobalDir(globalDir: string): InstalledPackage[] {
|
|
680
|
-
const cli4aiDir = resolve(globalDir, '@cli4ai');
|
|
681
|
-
if (!existsSync(cli4aiDir)) return [];
|
|
682
|
-
|
|
683
|
-
const packages: InstalledPackage[] = [];
|
|
684
|
-
|
|
685
|
-
try {
|
|
686
|
-
for (const entry of readdirSync(cli4aiDir, { withFileTypes: true })) {
|
|
687
|
-
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
688
|
-
if (entry.name === 'lib') continue; // Skip @cli4ai/lib
|
|
689
|
-
|
|
690
|
-
const pkgPath = resolve(cli4aiDir, entry.name);
|
|
691
|
-
|
|
692
|
-
// SECURITY: Validate symlink targets
|
|
693
|
-
const safePath = validateSymlinkTarget(pkgPath);
|
|
694
|
-
if (!safePath) continue;
|
|
695
|
-
|
|
696
|
-
const pkgJsonPath = resolve(safePath, 'package.json');
|
|
697
|
-
const cli4aiJsonPath = resolve(safePath, 'cli4ai.json');
|
|
698
|
-
|
|
699
|
-
// Try cli4ai.json first, then package.json
|
|
700
|
-
if (existsSync(cli4aiJsonPath)) {
|
|
701
|
-
try {
|
|
702
|
-
const manifest = JSON.parse(readFileSync(cli4aiJsonPath, 'utf-8'));
|
|
703
|
-
packages.push({
|
|
704
|
-
name: entry.name,
|
|
705
|
-
version: manifest.version,
|
|
706
|
-
path: safePath,
|
|
707
|
-
source: 'registry',
|
|
708
|
-
installedAt: new Date().toISOString()
|
|
709
|
-
});
|
|
710
|
-
continue;
|
|
711
|
-
} catch {}
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
if (existsSync(pkgJsonPath)) {
|
|
715
|
-
try {
|
|
716
|
-
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
717
|
-
packages.push({
|
|
718
|
-
name: entry.name,
|
|
719
|
-
version: pkgJson.version,
|
|
720
|
-
path: safePath,
|
|
721
|
-
source: 'registry',
|
|
722
|
-
installedAt: new Date().toISOString()
|
|
723
|
-
});
|
|
724
|
-
} catch {}
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
} catch {}
|
|
728
|
-
|
|
729
|
-
return packages;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/**
|
|
733
|
-
* Get all global @cli4ai packages (from both npm and bun)
|
|
734
|
-
*/
|
|
735
|
-
export function getNpmGlobalPackages(): InstalledPackage[] {
|
|
736
|
-
const packages: InstalledPackage[] = [];
|
|
737
|
-
const seen = new Set<string>();
|
|
738
|
-
|
|
739
|
-
// Check npm global first (this is where we install)
|
|
740
|
-
const npmGlobalDir = getNpmGlobalDir();
|
|
741
|
-
if (npmGlobalDir) {
|
|
742
|
-
for (const pkg of getPackagesFromGlobalDir(npmGlobalDir)) {
|
|
743
|
-
if (!seen.has(pkg.name)) {
|
|
744
|
-
seen.add(pkg.name);
|
|
745
|
-
packages.push(pkg);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// Check bun global (for backwards compat with old installs)
|
|
751
|
-
const bunGlobalDir = getBunGlobalDir();
|
|
752
|
-
if (bunGlobalDir) {
|
|
753
|
-
for (const pkg of getPackagesFromGlobalDir(bunGlobalDir)) {
|
|
754
|
-
if (!seen.has(pkg.name)) {
|
|
755
|
-
seen.add(pkg.name);
|
|
756
|
-
packages.push(pkg);
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
return packages;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
/**
|
|
765
|
-
* Try to find a package in a global directory
|
|
766
|
-
*/
|
|
767
|
-
function findPackageInGlobalDir(globalDir: string, name: string): InstalledPackage | null {
|
|
768
|
-
// SECURITY: Validate name to prevent path traversal
|
|
769
|
-
if (name.includes('..') || name.includes('/') || name.includes('\\') || name.startsWith('.')) {
|
|
770
|
-
return null;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
const scopedPath = resolve(globalDir, '@cli4ai', name);
|
|
774
|
-
|
|
775
|
-
// SECURITY: Verify resolved path is under globalDir
|
|
776
|
-
if (!scopedPath.startsWith(resolve(globalDir))) {
|
|
777
|
-
return null;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
if (!existsSync(scopedPath)) return null;
|
|
781
|
-
|
|
782
|
-
const manifestPath = resolve(scopedPath, 'cli4ai.json');
|
|
783
|
-
if (existsSync(manifestPath)) {
|
|
784
|
-
try {
|
|
785
|
-
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
786
|
-
return {
|
|
787
|
-
name: manifest.name || name,
|
|
788
|
-
version: manifest.version,
|
|
789
|
-
path: scopedPath,
|
|
790
|
-
source: 'registry',
|
|
791
|
-
installedAt: new Date().toISOString()
|
|
792
|
-
};
|
|
793
|
-
} catch {}
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
// Even without cli4ai.json, try package.json
|
|
797
|
-
const pkgJsonPath = resolve(scopedPath, 'package.json');
|
|
798
|
-
if (existsSync(pkgJsonPath)) {
|
|
799
|
-
try {
|
|
800
|
-
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
801
|
-
return {
|
|
802
|
-
name: name,
|
|
803
|
-
version: pkgJson.version,
|
|
804
|
-
path: scopedPath,
|
|
805
|
-
source: 'registry',
|
|
806
|
-
installedAt: new Date().toISOString()
|
|
807
|
-
};
|
|
808
|
-
} catch {}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
return null;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/**
|
|
815
|
-
* Find a package by name (checks local, global cli4ai, bun global, then npm global)
|
|
816
|
-
*/
|
|
817
|
-
export function findPackage(name: string, projectDir?: string): InstalledPackage | null {
|
|
818
|
-
// Check local packages first
|
|
819
|
-
if (projectDir) {
|
|
820
|
-
const localPkgs = getLocalPackages(projectDir);
|
|
821
|
-
const local = localPkgs.find(p => p.name === name);
|
|
822
|
-
if (local) return local;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
// Check cli4ai global packages
|
|
826
|
-
const globalPkgs = getGlobalPackages();
|
|
827
|
-
const globalPkg = globalPkgs.find(p => p.name === name);
|
|
828
|
-
if (globalPkg) return globalPkg;
|
|
829
|
-
|
|
830
|
-
// Check npm global packages first (this is where we install)
|
|
831
|
-
const npmGlobalDir = getNpmGlobalDir();
|
|
832
|
-
if (npmGlobalDir) {
|
|
833
|
-
const pkg = findPackageInGlobalDir(npmGlobalDir, name);
|
|
834
|
-
if (pkg) return pkg;
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// Check bun global packages (for backwards compat)
|
|
838
|
-
const bunGlobalDir = getBunGlobalDir();
|
|
839
|
-
if (bunGlobalDir) {
|
|
840
|
-
const pkg = findPackageInGlobalDir(bunGlobalDir, name);
|
|
841
|
-
if (pkg) return pkg;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
return null;
|
|
845
|
-
}
|