cli4ai 1.2.0 → 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.
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 +459 -0
  8. package/dist/commands/browse.d.ts +4 -0
  9. package/dist/commands/browse.js +379 -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 +122 -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 +159 -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
@@ -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
- }