cli4ai 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Global cli4ai configuration (~/.cli4ai/)
3
+ */
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, lstatSync, realpathSync } 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 = resolve(homedir(), '.cli4ai');
73
+ export const CONFIG_FILE = resolve(CLI4AI_HOME, 'config.json');
74
+ export const PACKAGES_DIR = resolve(CLI4AI_HOME, 'packages');
75
+ export const CACHE_DIR = resolve(CLI4AI_HOME, 'cache');
76
+ export const ROUTINES_DIR = resolve(CLI4AI_HOME, 'routines');
77
+ export const CREDENTIALS_FILE = resolve(CLI4AI_HOME, 'credentials.json');
78
+
79
+ // Local project paths
80
+ export const LOCAL_DIR = '.cli4ai';
81
+ export const LOCAL_PACKAGES_DIR = join(LOCAL_DIR, 'packages');
82
+ export const LOCAL_ROUTINES_DIR = join(LOCAL_DIR, 'routines');
83
+
84
+ // ═══════════════════════════════════════════════════════════════════════════
85
+ // TYPES
86
+ // ═══════════════════════════════════════════════════════════════════════════
87
+
88
+ export interface Config {
89
+ // Registry configuration
90
+ registry: string;
91
+ localRegistries: string[];
92
+
93
+ // Runtime defaults
94
+ defaultRuntime: 'bun' | 'node';
95
+
96
+ // MCP defaults
97
+ mcp: {
98
+ transport: 'stdio' | 'http';
99
+ port: number;
100
+ };
101
+
102
+ // Telemetry (future)
103
+ telemetry: boolean;
104
+ }
105
+
106
+ export interface InstalledPackage {
107
+ name: string;
108
+ version: string;
109
+ path: string;
110
+ source: 'local' | 'registry';
111
+ installedAt: string;
112
+ }
113
+
114
+ // ═══════════════════════════════════════════════════════════════════════════
115
+ // DEFAULT CONFIG
116
+ // ═══════════════════════════════════════════════════════════════════════════
117
+
118
+ export const DEFAULT_CONFIG: Config = {
119
+ registry: 'https://registry.cliforai.com',
120
+ localRegistries: [],
121
+ defaultRuntime: 'bun',
122
+ mcp: {
123
+ transport: 'stdio',
124
+ port: 3100
125
+ },
126
+ telemetry: false
127
+ };
128
+
129
+ // ═══════════════════════════════════════════════════════════════════════════
130
+ // INITIALIZATION
131
+ // ═══════════════════════════════════════════════════════════════════════════
132
+
133
+ /**
134
+ * Ensure ~/.cli4ai directory exists with required subdirectories
135
+ */
136
+ export function ensureCli4aiHome(): void {
137
+ const dirs = [CLI4AI_HOME, PACKAGES_DIR, CACHE_DIR, ROUTINES_DIR];
138
+
139
+ for (const dir of dirs) {
140
+ if (!existsSync(dir)) {
141
+ mkdirSync(dir, { recursive: true });
142
+ }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Ensure local .cli4ai directory exists
148
+ */
149
+ export function ensureLocalDir(projectDir: string): void {
150
+ const localDir = resolve(projectDir, LOCAL_DIR);
151
+ const packagesDir = resolve(projectDir, LOCAL_PACKAGES_DIR);
152
+ const routinesDir = resolve(projectDir, LOCAL_ROUTINES_DIR);
153
+
154
+ if (!existsSync(localDir)) {
155
+ mkdirSync(localDir, { recursive: true });
156
+ }
157
+ if (!existsSync(packagesDir)) {
158
+ mkdirSync(packagesDir, { recursive: true });
159
+ }
160
+ if (!existsSync(routinesDir)) {
161
+ mkdirSync(routinesDir, { recursive: true });
162
+ }
163
+ }
164
+
165
+ // ═══════════════════════════════════════════════════════════════════════════
166
+ // CONFIG FILE
167
+ // ═══════════════════════════════════════════════════════════════════════════
168
+
169
+ /**
170
+ * Deep merge two objects, where source values override target values
171
+ */
172
+ function deepMerge(target: Config, source: Partial<Config>): Config {
173
+ const result: Config = { ...target };
174
+
175
+ // Handle each key explicitly to maintain type safety
176
+ if (source.registry !== undefined) {
177
+ result.registry = source.registry;
178
+ }
179
+ if (source.localRegistries !== undefined) {
180
+ result.localRegistries = source.localRegistries;
181
+ }
182
+ if (source.defaultRuntime !== undefined) {
183
+ result.defaultRuntime = source.defaultRuntime;
184
+ }
185
+ if (source.telemetry !== undefined) {
186
+ result.telemetry = source.telemetry;
187
+ }
188
+ // Deep merge mcp config
189
+ if (source.mcp !== undefined) {
190
+ result.mcp = {
191
+ transport: source.mcp.transport ?? target.mcp.transport,
192
+ port: source.mcp.port ?? target.mcp.port
193
+ };
194
+ }
195
+
196
+ return result;
197
+ }
198
+
199
+ /**
200
+ * Load global config, creating defaults if needed
201
+ */
202
+ export function loadConfig(): Config {
203
+ ensureCli4aiHome();
204
+
205
+ if (!existsSync(CONFIG_FILE)) {
206
+ saveConfig(DEFAULT_CONFIG);
207
+ return DEFAULT_CONFIG;
208
+ }
209
+
210
+ try {
211
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
212
+ const data = JSON.parse(content);
213
+ // Deep merge with defaults to handle missing nested fields (e.g., mcp.port)
214
+ return deepMerge(DEFAULT_CONFIG, data);
215
+ } catch {
216
+ log(`Warning: Invalid config file, using defaults`);
217
+ return DEFAULT_CONFIG;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Save global config
223
+ */
224
+ export function saveConfig(config: Config): void {
225
+ ensureCli4aiHome();
226
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
227
+ }
228
+
229
+ /**
230
+ * Get a config value
231
+ */
232
+ export function getConfigValue<K extends keyof Config>(key: K): Config[K] {
233
+ const config = loadConfig();
234
+ return config[key];
235
+ }
236
+
237
+ /**
238
+ * Set a config value
239
+ */
240
+ export function setConfigValue<K extends keyof Config>(key: K, value: Config[K]): void {
241
+ const config = loadConfig();
242
+ config[key] = value;
243
+ saveConfig(config);
244
+ }
245
+
246
+ // ═══════════════════════════════════════════════════════════════════════════
247
+ // LOCAL REGISTRIES
248
+ // ═══════════════════════════════════════════════════════════════════════════
249
+
250
+ /**
251
+ * Directories that should not be used as registries
252
+ * to prevent accidental exposure of sensitive system files.
253
+ */
254
+ const UNSAFE_REGISTRY_PATHS = [
255
+ '/etc',
256
+ '/usr',
257
+ '/bin',
258
+ '/sbin',
259
+ '/var',
260
+ '/sys',
261
+ '/proc',
262
+ '/dev',
263
+ '/boot',
264
+ '/root',
265
+ '/lib',
266
+ '/lib64',
267
+ '/System', // macOS
268
+ '/Library', // macOS
269
+ '/private/etc', // macOS
270
+ '/private/var', // macOS
271
+ 'C:\\Windows', // Windows
272
+ 'C:\\Program Files', // Windows
273
+ ];
274
+
275
+ /**
276
+ * Validate that a registry path is safe to use.
277
+ */
278
+ function isRegistryPathSafe(registryPath: string): boolean {
279
+ const normalizedPath = normalize(registryPath);
280
+
281
+ // Check against unsafe paths
282
+ for (const unsafePath of UNSAFE_REGISTRY_PATHS) {
283
+ const normalizedUnsafe = normalize(unsafePath);
284
+ if (normalizedPath === normalizedUnsafe ||
285
+ normalizedPath.startsWith(normalizedUnsafe + '/') ||
286
+ normalizedPath.startsWith(normalizedUnsafe + '\\')) {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ return true;
292
+ }
293
+
294
+ /**
295
+ * Add a local registry path
296
+ */
297
+ export function addLocalRegistry(path: string): void {
298
+ const config = loadConfig();
299
+ const absolutePath = resolve(path);
300
+
301
+ // SECURITY: Validate registry path is not in sensitive system directories
302
+ if (!isRegistryPathSafe(absolutePath)) {
303
+ outputError('INVALID_INPUT', `Registry path is in a protected system directory: ${absolutePath}`, {
304
+ hint: 'Use a path in your home directory or a project directory'
305
+ });
306
+ }
307
+
308
+ if (!existsSync(absolutePath)) {
309
+ outputError('NOT_FOUND', `Directory does not exist: ${absolutePath}`);
310
+ }
311
+
312
+ // SECURITY: Validate symlink target if it's a symlink
313
+ const safePath = validateSymlinkTarget(absolutePath);
314
+ if (!safePath) {
315
+ outputError('INVALID_INPUT', `Registry path points to an unsafe location: ${absolutePath}`, {
316
+ hint: 'Symlinks must point to directories within safe locations'
317
+ });
318
+ }
319
+
320
+ if (!config.localRegistries.includes(safePath)) {
321
+ config.localRegistries.push(safePath);
322
+ saveConfig(config);
323
+ log(`Added local registry: ${safePath}`);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Remove a local registry path
329
+ */
330
+ export function removeLocalRegistry(path: string): void {
331
+ const config = loadConfig();
332
+ const absolutePath = resolve(path);
333
+ const index = config.localRegistries.indexOf(absolutePath);
334
+
335
+ if (index !== -1) {
336
+ config.localRegistries.splice(index, 1);
337
+ saveConfig(config);
338
+ log(`Removed local registry: ${absolutePath}`);
339
+ }
340
+ }
341
+
342
+ // ═══════════════════════════════════════════════════════════════════════════
343
+ // INSTALLED PACKAGES TRACKING
344
+ // ═══════════════════════════════════════════════════════════════════════════
345
+
346
+ /**
347
+ * Get list of globally installed packages
348
+ */
349
+ export function getGlobalPackages(): InstalledPackage[] {
350
+ ensureCli4aiHome();
351
+
352
+ if (!existsSync(PACKAGES_DIR)) {
353
+ return [];
354
+ }
355
+
356
+ const packages: InstalledPackage[] = [];
357
+
358
+ for (const entry of readdirSync(PACKAGES_DIR, { withFileTypes: true })) {
359
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
360
+
361
+ const pkgPath = resolve(PACKAGES_DIR, entry.name);
362
+
363
+ // SECURITY: Validate symlink targets
364
+ const safePath = validateSymlinkTarget(pkgPath);
365
+ if (!safePath) continue;
366
+
367
+ const manifestPath = resolve(safePath, 'cli4ai.json');
368
+
369
+ if (existsSync(manifestPath)) {
370
+ try {
371
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
372
+ packages.push({
373
+ name: manifest.name,
374
+ version: manifest.version,
375
+ path: safePath,
376
+ source: 'local', // TODO: track actual source
377
+ installedAt: new Date().toISOString()
378
+ });
379
+ } catch {
380
+ // Skip invalid packages
381
+ }
382
+ }
383
+ }
384
+
385
+ return packages;
386
+ }
387
+
388
+ /**
389
+ * Get list of locally installed packages (in project)
390
+ */
391
+ export function getLocalPackages(projectDir: string): InstalledPackage[] {
392
+ const packagesDir = resolve(projectDir, LOCAL_PACKAGES_DIR);
393
+
394
+ if (!existsSync(packagesDir)) {
395
+ return [];
396
+ }
397
+
398
+ const packages: InstalledPackage[] = [];
399
+
400
+ for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
401
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
402
+
403
+ const pkgPath = resolve(packagesDir, entry.name);
404
+
405
+ // SECURITY: Validate symlink targets
406
+ const safePath = validateSymlinkTarget(pkgPath);
407
+ if (!safePath) continue;
408
+
409
+ const manifestPath = resolve(safePath, 'cli4ai.json');
410
+
411
+ if (existsSync(manifestPath)) {
412
+ try {
413
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
414
+ packages.push({
415
+ name: manifest.name,
416
+ version: manifest.version,
417
+ path: safePath,
418
+ source: 'local',
419
+ installedAt: new Date().toISOString()
420
+ });
421
+ } catch {
422
+ // Skip invalid packages
423
+ }
424
+ }
425
+ }
426
+
427
+ return packages;
428
+ }
429
+
430
+ // Cache npm global dir to avoid repeated lookups
431
+ let cachedNpmGlobalDir: string | null | undefined = undefined;
432
+
433
+ /**
434
+ * Get npm global packages directory
435
+ */
436
+ function getNpmGlobalDir(): string | null {
437
+ if (cachedNpmGlobalDir !== undefined) return cachedNpmGlobalDir;
438
+
439
+ // Try common locations first (faster than calling npm)
440
+ const commonPaths = [
441
+ resolve(homedir(), '.npm-global', 'lib', 'node_modules'), // Custom npm prefix
442
+ '/usr/local/lib/node_modules', // macOS/Linux default
443
+ '/usr/lib/node_modules', // Some Linux distros
444
+ resolve(homedir(), '.nvm', 'versions', 'node'), // nvm (check later)
445
+ ];
446
+
447
+ for (const p of commonPaths) {
448
+ if (existsSync(p)) {
449
+ cachedNpmGlobalDir = p;
450
+ return p;
451
+ }
452
+ }
453
+
454
+ // Fall back to npm config (with timeout)
455
+ try {
456
+ const { execSync } = require('child_process');
457
+ const prefix = execSync('npm config get prefix', {
458
+ encoding: 'utf-8',
459
+ timeout: 3000, // 3 second timeout
460
+ stdio: ['pipe', 'pipe', 'pipe']
461
+ }).trim();
462
+ // On Unix: prefix/lib/node_modules, on Windows: prefix/node_modules
463
+ const libPath = resolve(prefix, 'lib', 'node_modules');
464
+ if (existsSync(libPath)) {
465
+ cachedNpmGlobalDir = libPath;
466
+ return libPath;
467
+ }
468
+ const winPath = resolve(prefix, 'node_modules');
469
+ if (existsSync(winPath)) {
470
+ cachedNpmGlobalDir = winPath;
471
+ return winPath;
472
+ }
473
+ } catch {
474
+ // npm command failed or timed out
475
+ }
476
+
477
+ cachedNpmGlobalDir = null;
478
+ return null;
479
+ }
480
+
481
+ /**
482
+ * Get bun global packages directory
483
+ */
484
+ function getBunGlobalDir(): string | null {
485
+ // Bun installs global packages to ~/.bun/install/global/node_modules
486
+ const bunGlobalDir = resolve(homedir(), '.bun', 'install', 'global', 'node_modules');
487
+ if (existsSync(bunGlobalDir)) return bunGlobalDir;
488
+ return null;
489
+ }
490
+
491
+ /**
492
+ * Get packages from a global node_modules/@cli4ai directory
493
+ */
494
+ function getPackagesFromGlobalDir(globalDir: string): InstalledPackage[] {
495
+ const cli4aiDir = resolve(globalDir, '@cli4ai');
496
+ if (!existsSync(cli4aiDir)) return [];
497
+
498
+ const packages: InstalledPackage[] = [];
499
+
500
+ try {
501
+ for (const entry of readdirSync(cli4aiDir, { withFileTypes: true })) {
502
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
503
+ if (entry.name === 'lib') continue; // Skip @cli4ai/lib
504
+
505
+ const pkgPath = resolve(cli4aiDir, entry.name);
506
+
507
+ // SECURITY: Validate symlink targets
508
+ const safePath = validateSymlinkTarget(pkgPath);
509
+ if (!safePath) continue;
510
+
511
+ const pkgJsonPath = resolve(safePath, 'package.json');
512
+ const cli4aiJsonPath = resolve(safePath, 'cli4ai.json');
513
+
514
+ // Try cli4ai.json first, then package.json
515
+ if (existsSync(cli4aiJsonPath)) {
516
+ try {
517
+ const manifest = JSON.parse(readFileSync(cli4aiJsonPath, 'utf-8'));
518
+ packages.push({
519
+ name: entry.name,
520
+ version: manifest.version,
521
+ path: safePath,
522
+ source: 'registry',
523
+ installedAt: new Date().toISOString()
524
+ });
525
+ continue;
526
+ } catch {}
527
+ }
528
+
529
+ if (existsSync(pkgJsonPath)) {
530
+ try {
531
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
532
+ packages.push({
533
+ name: entry.name,
534
+ version: pkgJson.version,
535
+ path: safePath,
536
+ source: 'registry',
537
+ installedAt: new Date().toISOString()
538
+ });
539
+ } catch {}
540
+ }
541
+ }
542
+ } catch {}
543
+
544
+ return packages;
545
+ }
546
+
547
+ /**
548
+ * Get all global @cli4ai packages (from both npm and bun)
549
+ */
550
+ export function getNpmGlobalPackages(): InstalledPackage[] {
551
+ const packages: InstalledPackage[] = [];
552
+ const seen = new Set<string>();
553
+
554
+ // Check npm global first (this is where we install)
555
+ const npmGlobalDir = getNpmGlobalDir();
556
+ if (npmGlobalDir) {
557
+ for (const pkg of getPackagesFromGlobalDir(npmGlobalDir)) {
558
+ if (!seen.has(pkg.name)) {
559
+ seen.add(pkg.name);
560
+ packages.push(pkg);
561
+ }
562
+ }
563
+ }
564
+
565
+ // Check bun global (for backwards compat with old installs)
566
+ const bunGlobalDir = getBunGlobalDir();
567
+ if (bunGlobalDir) {
568
+ for (const pkg of getPackagesFromGlobalDir(bunGlobalDir)) {
569
+ if (!seen.has(pkg.name)) {
570
+ seen.add(pkg.name);
571
+ packages.push(pkg);
572
+ }
573
+ }
574
+ }
575
+
576
+ return packages;
577
+ }
578
+
579
+ /**
580
+ * Try to find a package in a global directory
581
+ */
582
+ function findPackageInGlobalDir(globalDir: string, name: string): InstalledPackage | null {
583
+ const scopedPath = resolve(globalDir, '@cli4ai', name);
584
+ if (!existsSync(scopedPath)) return null;
585
+
586
+ const manifestPath = resolve(scopedPath, 'cli4ai.json');
587
+ if (existsSync(manifestPath)) {
588
+ try {
589
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
590
+ return {
591
+ name: manifest.name || name,
592
+ version: manifest.version,
593
+ path: scopedPath,
594
+ source: 'registry',
595
+ installedAt: new Date().toISOString()
596
+ };
597
+ } catch {}
598
+ }
599
+
600
+ // Even without cli4ai.json, try package.json
601
+ const pkgJsonPath = resolve(scopedPath, 'package.json');
602
+ if (existsSync(pkgJsonPath)) {
603
+ try {
604
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
605
+ return {
606
+ name: name,
607
+ version: pkgJson.version,
608
+ path: scopedPath,
609
+ source: 'registry',
610
+ installedAt: new Date().toISOString()
611
+ };
612
+ } catch {}
613
+ }
614
+
615
+ return null;
616
+ }
617
+
618
+ /**
619
+ * Find a package by name (checks local, global cli4ai, bun global, then npm global)
620
+ */
621
+ export function findPackage(name: string, projectDir?: string): InstalledPackage | null {
622
+ // Check local packages first
623
+ if (projectDir) {
624
+ const localPkgs = getLocalPackages(projectDir);
625
+ const local = localPkgs.find(p => p.name === name);
626
+ if (local) return local;
627
+ }
628
+
629
+ // Check cli4ai global packages
630
+ const globalPkgs = getGlobalPackages();
631
+ const globalPkg = globalPkgs.find(p => p.name === name);
632
+ if (globalPkg) return globalPkg;
633
+
634
+ // Check npm global packages first (this is where we install)
635
+ const npmGlobalDir = getNpmGlobalDir();
636
+ if (npmGlobalDir) {
637
+ const pkg = findPackageInGlobalDir(npmGlobalDir, name);
638
+ if (pkg) return pkg;
639
+ }
640
+
641
+ // Check bun global packages (for backwards compat)
642
+ const bunGlobalDir = getBunGlobalDir();
643
+ if (bunGlobalDir) {
644
+ const pkg = findPackageInGlobalDir(bunGlobalDir, name);
645
+ if (pkg) return pkg;
646
+ }
647
+
648
+ return null;
649
+ }