cli4ai 1.2.0 → 1.2.2

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