@xelth/eck-snapshot 6.5.1 → 6.6.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.
@@ -1,1081 +1,1084 @@
1
- import fs from 'fs/promises';
2
- import path from 'path';
3
- import { execa } from 'execa';
4
- import ignore from 'ignore';
5
- import { detectProjectType, getProjectSpecificFiltering, getAllDetectedTypes } from './projectDetector.js';
6
- import { getProfile, loadSetupConfig } from '../config.js';
7
- import micromatch from 'micromatch';
8
- import { minimatch } from 'minimatch';
9
-
10
- /**
11
- * Scanner for detecting and redacting secrets (API keys, tokens)
12
- */
13
- export const SecretScanner = {
14
- patterns: [
15
- // Service-specific patterns
16
- { name: 'GitHub Token', regex: /gh[pous]_[a-zA-Z0-9]{36}/g },
17
- { name: 'AWS Access Key', regex: /(?:AKIA|ASIA)[0-9A-Z]{16}/g },
18
- { name: 'OpenAI API Key', regex: /sk-[a-zA-Z0-9]{32,}/g },
19
- { name: 'Stripe Secret Key', regex: /sk_live_[0-9a-zA-Z]{24}/g },
20
- { name: 'Google API Key', regex: /AIza[0-9A-Za-z\-_]{35}/g },
21
- { name: 'Slack Token', regex: /xox[baprs]-[0-9a-zA-Z\-]{10,}/g },
22
- { name: 'NPM Token', regex: /npm_[a-zA-Z0-9]{36}/g },
23
- { name: 'Private Key', regex: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g },
24
- // Generic high-entropy patterns near sensitive keywords
25
- {
26
- name: 'Generic Secret',
27
- regex: /(?:api[_-]?key|secret|password|token|auth|pwd|credential)\s*[:=]\s*["']([a-zA-Z0-9\-_.]{16,})["']/gi
28
- }
29
- ],
30
-
31
- /**
32
- * Calculates Shannon Entropy of a string
33
- */
34
- calculateEntropy(str) {
35
- const len = str.length;
36
- const frequencies = Array.from(str).reduce((freq, c) => {
37
- freq[c] = (freq[c] || 0) + 1;
38
- return freq;
39
- }, {});
40
- return Object.values(frequencies).reduce((sum, f) => {
41
- const p = f / len;
42
- return sum - (p * Math.log2(p));
43
- }, 0);
44
- },
45
-
46
- /**
47
- * Scans content and replaces detected secrets with a placeholder
48
- * @param {string} content - File content to scan
49
- * @param {string} filePath - Path for logging context
50
- * @returns {{content: string, found: string[]}} Redacted content and list of found secret types
51
- */
52
- redact(content, filePath) {
53
- let redactedContent = content;
54
- const foundSecrets = [];
55
-
56
- for (const pattern of this.patterns) {
57
- // Reset regex lastIndex for global patterns
58
- pattern.regex.lastIndex = 0;
59
-
60
- const matches = [...content.matchAll(pattern.regex)];
61
- if (matches.length > 0) {
62
- for (const match of matches) {
63
- // For generic pattern, use captured group; for specific patterns, use full match
64
- const secretValue = match[1] || match[0];
65
- const placeholder = `[REDACTED_${pattern.name.replace(/\s+/g, '_').toUpperCase()}]`;
66
- redactedContent = redactedContent.replace(secretValue, placeholder);
67
- foundSecrets.push(pattern.name);
68
- }
69
- }
70
- }
71
-
72
- // Second pass: Shannon Entropy check for arbitrary hardcoded secrets
73
- // Look for long strings assigned to variable names that might be keys
74
- const entropyRegex = /(?:const|let|var|set|export|define)\s+([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)\s*=\s*["']([a-zA-Z0-9+/=_-]{20,128})["']/gi;
75
- const entropyMatches = [...redactedContent.matchAll(entropyRegex)];
76
-
77
- for (const match of entropyMatches) {
78
- const secretValue = match[2];
79
- // Check entropy - random base64 usually has entropy > 4.5
80
- if (this.calculateEntropy(secretValue) > 4.5 && !secretValue.includes('REDACTED')) {
81
- const placeholder = `[REDACTED_HIGH_ENTROPY_SECRET]`;
82
- redactedContent = redactedContent.replace(secretValue, placeholder);
83
- foundSecrets.push('High Entropy Secret');
84
- }
85
- }
86
-
87
- return {
88
- content: redactedContent,
89
- found: [...new Set(foundSecrets)]
90
- };
91
- }
92
- };
93
-
94
- export function parseSize(sizeStr) {
95
- const units = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
96
- const match = sizeStr.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
97
- if (!match) throw new Error(`Invalid size format: ${sizeStr}`);
98
- const [, size, unit = 'B'] = match;
99
- return Math.floor(parseFloat(size) * units[unit.toUpperCase()]);
100
- }
101
-
102
- export function formatSize(bytes) {
103
- const units = ['B', 'KB', 'MB', 'GB'];
104
- let size = bytes;
105
- let unitIndex = 0;
106
- while (size >= 1024 && unitIndex < units.length - 1) {
107
- size /= 1024;
108
- unitIndex++;
109
- }
110
- return `${size.toFixed(1)} ${units[unitIndex]}`;
111
- }
112
-
113
- export function matchesPattern(filePath, patterns) {
114
- const fileName = path.basename(filePath);
115
- return patterns.some(pattern => {
116
- const regexPattern = '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$';
117
- try {
118
- const regex = new RegExp(regexPattern);
119
- return regex.test(fileName);
120
- } catch (e) {
121
- console.warn(`⚠️ Invalid regex pattern in config: "${pattern}"`);
122
- return false;
123
- }
124
- });
125
- }
126
-
127
- /**
128
- * Checks if a file matches confidential patterns using minimatch
129
- * @param {string} fileName - The file name to check
130
- * @param {array} patterns - Array of glob patterns to match against
131
- * @returns {boolean} True if the file matches any pattern
132
- */
133
- function matchesConfidentialPattern(fileName, patterns) {
134
- return patterns.some(pattern => minimatch(fileName, pattern, { nocase: true }));
135
- }
136
-
137
- /**
138
- * Applies smart filtering for files within the .eck directory.
139
- * Includes documentation files while excluding confidential files.
140
- * @param {string} fileName - The file name to check
141
- * @param {object} eckConfig - The eckDirectoryFiltering config object
142
- * @returns {object} { include: boolean, isConfidential: boolean }
143
- */
144
- export function applyEckDirectoryFiltering(fileName, eckConfig) {
145
- if (!eckConfig || !eckConfig.enabled) {
146
- return { include: false, isConfidential: false }; // .eck filtering disabled, exclude all
147
- }
148
-
149
- const { confidentialPatterns = [], alwaysIncludePatterns = [] } = eckConfig;
150
-
151
- // First check if file matches confidential patterns
152
- const isConfidential = matchesConfidentialPattern(fileName, confidentialPatterns);
153
- if (isConfidential) {
154
- return { include: false, isConfidential: true };
155
- }
156
-
157
- // Check if file matches always-include patterns
158
- if (matchesPattern(fileName, alwaysIncludePatterns)) {
159
- return { include: true, isConfidential: false };
160
- }
161
-
162
- // Default: exclude files not in the include list
163
- return { include: false, isConfidential: false };
164
- }
165
-
166
- export async function checkGitRepository(repoPath) {
167
- try {
168
- await execa('git', ['rev-parse', '--git-dir'], { cwd: repoPath });
169
- return true;
170
- } catch (error) {
171
- return false;
172
- }
173
- }
174
-
175
- export async function scanDirectoryRecursively(dirPath, config, relativeTo = dirPath, projectTypes = null, trackConfidential = false) {
176
- const files = [];
177
- const confidentialFiles = [];
178
-
179
- // Get project-specific filtering for ALL detected types (polyglot monorepo support)
180
- if (!projectTypes) {
181
- const detection = await detectProjectType(relativeTo);
182
- projectTypes = getAllDetectedTypes(detection);
183
- }
184
-
185
- const projectSpecific = await getProjectSpecificFiltering(projectTypes);
186
-
187
- // Merge project-specific filters with global config
188
- const effectiveConfig = {
189
- ...config,
190
- dirsToIgnore: [...(config.dirsToIgnore || []), ...(projectSpecific.dirsToIgnore || [])],
191
- filesToIgnore: [...(config.filesToIgnore || []), ...(projectSpecific.filesToIgnore || [])],
192
- extensionsToIgnore: [...(config.extensionsToIgnore || []), ...(projectSpecific.extensionsToIgnore || [])]
193
- };
194
-
195
- try {
196
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
197
-
198
- for (const entry of entries) {
199
- const fullPath = path.join(dirPath, entry.name);
200
- const relativePath = path.relative(relativeTo, fullPath).replace(/\\/g, '/');
201
-
202
- // --- GLOBAL HARD IGNORES (Zero-Config Safety) ---
203
- // Explicitly skip heavy/system directories and lockfiles everywhere
204
- if (entry.isDirectory()) {
205
- if (entry.name === 'node_modules' ||
206
- entry.name === '.git' ||
207
- entry.name === '.idea' ||
208
- entry.name === '.vscode' ||
209
- entry.name === '.gradle' ||
210
- entry.name === 'build' ||
211
- entry.name === '__pycache__') {
212
- continue;
213
- }
214
- } else {
215
- if (entry.name === 'package-lock.json' ||
216
- entry.name === 'yarn.lock' ||
217
- entry.name === 'pnpm-lock.yaml' ||
218
- entry.name === 'go.sum') {
219
- continue;
220
- }
221
- }
222
- // -----------------------------------------------
223
-
224
- // Special handling for .eck directory - never ignore it when tracking confidential files
225
- const isEckDirectory = entry.name === '.eck' && entry.isDirectory();
226
- const isInsideEck = relativePath.startsWith('.eck/');
227
-
228
- if (effectiveConfig.dirsToIgnore.some(dir =>
229
- entry.name === dir.replace('/', '') ||
230
- relativePath.startsWith(dir)
231
- ) && !isEckDirectory && !isInsideEck) {
232
- continue;
233
- }
234
-
235
- if (!effectiveConfig.includeHidden && entry.name.startsWith('.') && !isEckDirectory && !isInsideEck) {
236
- continue;
237
- }
238
-
239
- if (entry.isDirectory()) {
240
- const subResult = await scanDirectoryRecursively(fullPath, effectiveConfig, relativeTo, projectTypes, trackConfidential);
241
- if (trackConfidential) {
242
- files.push(...subResult.files);
243
- confidentialFiles.push(...subResult.confidentialFiles);
244
- } else {
245
- files.push(...subResult);
246
- }
247
- } else {
248
- // Apply smart filtering for files inside .eck directory
249
- if (isInsideEck) {
250
- const eckConfig = effectiveConfig.eckDirectoryFiltering;
251
- const filterResult = applyEckDirectoryFiltering(entry.name, eckConfig);
252
-
253
- if (trackConfidential && filterResult.isConfidential) {
254
- confidentialFiles.push(relativePath);
255
- } else if (filterResult.include) {
256
- files.push(relativePath);
257
- }
258
- } else {
259
- // Normal filtering for non-.eck files
260
- if (effectiveConfig.extensionsToIgnore.includes(path.extname(entry.name)) ||
261
- matchesPattern(relativePath, effectiveConfig.filesToIgnore)) {
262
- continue;
263
- }
264
- files.push(relativePath);
265
- }
266
- }
267
- }
268
- } catch (error) {
269
- console.warn(`⚠️ Warning: Could not read directory: ${dirPath} - ${error.message}`);
270
- }
271
-
272
- return trackConfidential ? { files, confidentialFiles } : files;
273
- }
274
-
275
- export async function loadGitignore(repoPath) {
276
- const ig = ignore();
277
-
278
- try {
279
- const gitignoreContent = await fs.readFile(path.join(repoPath, '.gitignore'), 'utf-8');
280
- ig.add(gitignoreContent);
281
- } catch {
282
- console.log('ℹ️ No .gitignore file found or could not be read');
283
- }
284
-
285
- try {
286
- const eckignoreContent = await fs.readFile(path.join(repoPath, '.eckignore'), 'utf-8');
287
- ig.add(eckignoreContent);
288
- } catch {
289
- // .eckignore is optional, silently skip if missing
290
- }
291
-
292
- return ig;
293
- }
294
-
295
- export async function readFileWithSizeCheck(filePath, maxFileSize) {
296
- try {
297
- const stats = await fs.stat(filePath);
298
- if (stats.size > maxFileSize) {
299
- throw new Error(`File too large: ${formatSize(stats.size)}`);
300
- }
301
- return await fs.readFile(filePath, 'utf-8');
302
- } catch (error) {
303
- if (error.message.includes('too large')) throw error;
304
- throw new Error(`Could not read file: ${error.message}`);
305
- }
306
- }
307
-
308
- export async function generateDirectoryTree(dir, prefix = '', allFiles, depth = 0, maxDepth = 10, config) {
309
- if (depth > maxDepth) return '';
310
-
311
- try {
312
- const entries = await fs.readdir(dir, { withFileTypes: true });
313
- const sortedEntries = entries.sort((a, b) => {
314
- if (a.isDirectory() && !b.isDirectory()) return -1;
315
- if (!a.isDirectory() && b.isDirectory()) return 1;
316
- return a.name.localeCompare(b.name);
317
- });
318
-
319
- let tree = '';
320
- const validEntries = [];
321
-
322
- for (const entry of sortedEntries) {
323
- // --- GLOBAL HARD IGNORES ---
324
- if (entry.isDirectory() && (
325
- entry.name === 'node_modules' ||
326
- entry.name === '.git' ||
327
- entry.name === '.idea' ||
328
- entry.name === '.vscode' ||
329
- entry.name === '.gradle' ||
330
- entry.name === 'build' ||
331
- entry.name === '__pycache__'
332
- )) continue;
333
- if (!entry.isDirectory() && (
334
- entry.name === 'package-lock.json' ||
335
- entry.name === 'yarn.lock' ||
336
- entry.name === 'pnpm-lock.yaml' ||
337
- entry.name === 'go.sum'
338
- )) continue;
339
- // ---------------------------
340
-
341
- // Skip hidden directories and files (starting with '.')
342
- // EXCEPT: Allow .eck to be visible
343
- if (entry.name.startsWith('.') && entry.name !== '.eck') {
344
- continue;
345
- }
346
- // Only skip directories (not files) and use exact name match to avoid
347
- // substring false positives (e.g., "build/" hiding "build.gradle.kts")
348
- if (entry.isDirectory() && config.dirsToIgnore.some(d => entry.name === d.replace('/', ''))) continue;
349
- const fullPath = path.join(dir, entry.name);
350
- const relativePath = path.relative(process.cwd(), fullPath).replace(/\\/g, '/');
351
-
352
- // FORCE VISIBILITY for .eck files in the tree
353
- // Even if they are gitignored (not in allFiles), we want the Architect to see they exist
354
- const isInsideEck = relativePath.startsWith('.eck/') || relativePath === '.eck';
355
-
356
- if (entry.isDirectory() || allFiles.includes(relativePath) || isInsideEck) {
357
- validEntries.push({ entry, fullPath, relativePath });
358
- }
359
- }
360
-
361
- for (let i = 0; i < validEntries.length; i++) {
362
- const { entry, fullPath, relativePath } = validEntries[i];
363
- const isLast = i === validEntries.length - 1;
364
-
365
- const connector = isLast ? '└── ' : '├── ';
366
- const nextPrefix = prefix + (isLast ? ' ' : '│ ');
367
-
368
- if (entry.isDirectory()) {
369
- tree += `${prefix}${connector}${entry.name}/\n`;
370
-
371
- // RECURSION CONTROL:
372
- // If we are currently inside .eck, do NOT recurse deeper into subdirectories (like snapshots, logs).
373
- // We want to see that 'snapshots/' exists, but not list its contents.
374
- const isInsideEckRoot = path.basename(dir) === '.eck';
375
-
376
- if (!isInsideEckRoot) {
377
- tree += await generateDirectoryTree(fullPath, nextPrefix, allFiles, depth + 1, maxDepth, config);
378
- }
379
- } else {
380
- tree += `${prefix}${connector}${entry.name}\n`;
381
- }
382
- }
383
-
384
- return tree;
385
- } catch (error) {
386
- console.warn(`⚠️ Warning: Could not read directory: ${dir}`);
387
- return '';
388
- }
389
- }
390
-
391
- export function parseSnapshotContent(content) {
392
- const files = [];
393
- const fileRegex = /--- File: \/(.+) ---/g;
394
- const sections = content.split(fileRegex);
395
-
396
- for (let i = 1; i < sections.length; i += 2) {
397
- const filePath = sections[i].trim();
398
- let fileContent = sections[i + 1] || '';
399
-
400
- if (fileContent.startsWith('\n\n')) {
401
- fileContent = fileContent.substring(2);
402
- }
403
- if (fileContent.endsWith('\n\n')) {
404
- fileContent = fileContent.substring(0, fileContent.length - 2);
405
- }
406
-
407
- files.push({ path: filePath, content: fileContent });
408
- }
409
-
410
- return files;
411
- }
412
-
413
- export function filterFilesToRestore(files, options) {
414
- let filtered = files;
415
-
416
- if (options.include) {
417
- const includePatterns = Array.isArray(options.include) ?
418
- options.include : [options.include];
419
- filtered = filtered.filter(file =>
420
- includePatterns.some(pattern => {
421
- const regex = new RegExp(pattern.replace(/\*/g, '.*'));
422
- return regex.test(file.path);
423
- })
424
- );
425
- }
426
-
427
- if (options.exclude) {
428
- const excludePatterns = Array.isArray(options.exclude) ?
429
- options.exclude : [options.exclude];
430
- filtered = filtered.filter(file =>
431
- !excludePatterns.some(pattern => {
432
- const regex = new RegExp(pattern.replace(/\*/g, '.*'));
433
- return regex.test(file.path);
434
- })
435
- );
436
- }
437
-
438
- return filtered;
439
- }
440
-
441
- export function validateFilePaths(files, targetDir) {
442
- const invalidFiles = [];
443
-
444
- for (const file of files) {
445
- const normalizedPath = path.normalize(file.path);
446
- if (normalizedPath.includes('..') ||
447
- normalizedPath.startsWith('/') ||
448
- normalizedPath.includes('\0') ||
449
- /[<>:"|?*]/.test(normalizedPath)) {
450
- invalidFiles.push(file.path);
451
- }
452
- }
453
-
454
- return invalidFiles;
455
- }
456
-
457
- export async function loadConfig(configPath) {
458
- const { DEFAULT_CONFIG } = await import('../config.js');
459
- let config = { ...DEFAULT_CONFIG };
460
-
461
- if (configPath) {
462
- try {
463
- const configModule = await import(path.resolve(configPath));
464
- config = { ...config, ...configModule.default };
465
- console.log(`✅ Configuration loaded from: ${configPath}`);
466
- } catch (error) {
467
- console.warn(`⚠️ Warning: Could not load config file: ${configPath}`);
468
- }
469
- } else {
470
- const possibleConfigs = [
471
- '.ecksnapshot.config.js',
472
- '.ecksnapshot.config.mjs',
473
- 'ecksnapshot.config.js'
474
- ];
475
-
476
- for (const configFile of possibleConfigs) {
477
- try {
478
- await fs.access(configFile);
479
- const configModule = await import(path.resolve(configFile));
480
- config = { ...config, ...configModule.default };
481
- console.log(`✅ Configuration loaded from: ${configFile}`);
482
- break;
483
- } catch {
484
- // Config file doesn't exist, continue
485
- }
486
- }
487
- }
488
-
489
- return config;
490
- }
491
-
492
- export function generateTimestamp() {
493
- const now = new Date();
494
- const YY = String(now.getFullYear()).slice(-2);
495
- const MM = String(now.getMonth() + 1).padStart(2, '0');
496
- const DD = String(now.getDate()).padStart(2, '0');
497
- const hh = String(now.getHours()).padStart(2, '0');
498
- const mm = String(now.getMinutes()).padStart(2, '0');
499
- // Compact format: YY-MM-DD_HH-mm (no seconds)
500
- return `${YY}-${MM}-${DD}_${hh}-${mm}`;
501
- }
502
-
503
- /**
504
- * Generates a short repo name with capitalized first 3 and last 2 characters
505
- * Example: "Snapshot" -> "SnaOt", "MyProject" -> "MyPrjt"
506
- * @param {string} repoName - The repository name
507
- * @returns {string} Shortened repo name (Start3 + End2)
508
- */
509
- export function getShortRepoName(repoName) {
510
- if (!repoName) return '';
511
- if (repoName.length <= 5) {
512
- return repoName.toUpperCase();
513
- }
514
- const start = repoName.substring(0, 3);
515
- const end = repoName.substring(repoName.length - 2);
516
- return (start + end).toUpperCase();
517
- }
518
-
519
- /**
520
- * Displays project detection information in a user-friendly format
521
- * @param {object} detection - Project detection result
522
- */
523
- export function displayProjectInfo(detection) {
524
- console.log('\n🔍 Project Detection Results:');
525
- console.log(` Type: ${detection.type} (confidence: ${(detection.confidence * 100).toFixed(0)}%)`);
526
-
527
- if (detection.details) {
528
- const details = detection.details;
529
-
530
- switch (detection.type) {
531
- case 'android':
532
- console.log(` Language: ${details.language || 'unknown'}`);
533
- if (details.packageName) {
534
- console.log(` Package: ${details.packageName}`);
535
- }
536
- if (details.sourceDirs && details.sourceDirs.length > 0) {
537
- console.log(` Source dirs: ${details.sourceDirs.join(', ')}`);
538
- }
539
- if (details.libFiles && details.libFiles.length > 0) {
540
- console.log(` Libraries: ${details.libFiles.length} .aar/.jar files`);
541
- }
542
- break;
543
-
544
- case 'nodejs':
545
- if (details.name) {
546
- console.log(` Package: ${details.name}@${details.version || '?'}`);
547
- }
548
- if (details.framework) {
549
- console.log(` Framework: ${details.framework}`);
550
- }
551
- if (details.hasTypescript) {
552
- console.log(` TypeScript: enabled`);
553
- }
554
- break;
555
-
556
- case 'nodejs-monorepo':
557
- if (details.name) {
558
- console.log(` Project: ${details.name}@${details.version || '?'}`);
559
- }
560
- if (details.monorepoTool) {
561
- console.log(` Monorepo tool: ${details.monorepoTool}`);
562
- }
563
- if (details.workspaceCount) {
564
- console.log(` Workspaces: ${details.workspaceCount}`);
565
- }
566
- if (details.framework) {
567
- console.log(` Framework: ${details.framework}`);
568
- }
569
- break;
570
-
571
- case 'python-poetry':
572
- case 'python-pip':
573
- case 'python-conda':
574
- if (details.name) {
575
- console.log(` Project: ${details.name}@${details.version || '?'}`);
576
- }
577
- if (details.packageManager) {
578
- console.log(` Package manager: ${details.packageManager}`);
579
- }
580
- if (details.dependencies) {
581
- console.log(` Dependencies: ${details.dependencies}`);
582
- }
583
- if (details.hasVirtualEnv) {
584
- console.log(` Virtual environment: detected`);
585
- }
586
- break;
587
-
588
- case 'django':
589
- if (details.name) {
590
- console.log(` Project: ${details.name}`);
591
- }
592
- console.log(` Framework: Django`);
593
- if (details.djangoApps && details.djangoApps.length > 0) {
594
- console.log(` Django apps: ${details.djangoApps.join(', ')}`);
595
- }
596
- if (details.hasVirtualEnv) {
597
- console.log(` Virtual environment: detected`);
598
- }
599
- break;
600
-
601
- case 'flask':
602
- if (details.name) {
603
- console.log(` Project: ${details.name}`);
604
- }
605
- console.log(` Framework: Flask`);
606
- if (details.hasVirtualEnv) {
607
- console.log(` Virtual environment: detected`);
608
- }
609
- break;
610
-
611
- case 'rust':
612
- if (details.name) {
613
- console.log(` Package: ${details.name}@${details.version || '?'}`);
614
- }
615
- if (details.edition) {
616
- console.log(` Rust edition: ${details.edition}`);
617
- }
618
- if (details.isWorkspace) {
619
- console.log(` Cargo workspace: detected`);
620
- }
621
- break;
622
-
623
- case 'go':
624
- if (details.module) {
625
- console.log(` Module: ${details.module}`);
626
- }
627
- if (details.goVersion) {
628
- console.log(` Go version: ${details.goVersion}`);
629
- }
630
- break;
631
-
632
- case 'dotnet':
633
- if (details.language) {
634
- console.log(` Language: ${details.language}`);
635
- }
636
- if (details.projectFiles && details.projectFiles.length > 0) {
637
- console.log(` Project files: ${details.projectFiles.join(', ')}`);
638
- }
639
- if (details.hasSolution) {
640
- console.log(` Solution: detected`);
641
- }
642
- break;
643
-
644
- case 'flutter':
645
- if (details.name) {
646
- console.log(` App: ${details.name}@${details.version || '?'}`);
647
- }
648
- break;
649
-
650
- case 'react-native':
651
- if (details.name) {
652
- console.log(` App: ${details.name}@${details.version || '?'}`);
653
- }
654
- if (details.reactNativeVersion) {
655
- console.log(` React Native: ${details.reactNativeVersion}`);
656
- }
657
- break;
658
- }
659
- }
660
-
661
- if (detection.allDetections && detection.allDetections.length > 1) {
662
- const otherTypes = detection.allDetections.slice(1).map(d => d.type).join(', ');
663
- console.log(` Polyglot filtering: applying rules for [${detection.type}, ${otherTypes}]`);
664
- }
665
-
666
- console.log('');
667
- }
668
-
669
- /**
670
- * Parses YAML-like content from ENVIRONMENT.md
671
- * @param {string} content - The raw content of ENVIRONMENT.md
672
- * @returns {object} Parsed key-value pairs
673
- */
674
- function parseEnvironmentYaml(content) {
675
- const result = {};
676
- const lines = content.split('\n');
677
-
678
- for (const line of lines) {
679
- const trimmed = line.trim();
680
- if (trimmed && !trimmed.startsWith('#') && trimmed.includes(':')) {
681
- const [key, ...valueParts] = trimmed.split(':');
682
- const value = valueParts.join(':').trim();
683
-
684
- // Remove quotes if present
685
- const cleanValue = value.replace(/^["']|["']$/g, '');
686
- result[key.trim()] = cleanValue;
687
- }
688
- }
689
-
690
- return result;
691
- }
692
-
693
- /**
694
- * Loads and processes the .eck directory manifest
695
- * @param {string} repoPath - Path to the repository
696
- * @returns {Promise<object|null>} The eck manifest object or null if no .eck directory
697
- */
698
- export async function loadProjectEckManifest(repoPath) {
699
- const eckDir = path.join(repoPath, '.eck');
700
-
701
- try {
702
- const eckStats = await fs.stat(eckDir);
703
- if (!eckStats.isDirectory()) {
704
- return null;
705
- }
706
-
707
- console.log('📋 Found .eck directory - dynamically loading project manifest...');
708
-
709
- const manifest = {
710
- environment: {},
711
- context: '',
712
- operations: '',
713
- journal: '',
714
- roadmap: '',
715
- techDebt: '',
716
- dynamicFiles: {}
717
- };
718
-
719
- const entries = await fs.readdir(eckDir, { withFileTypes: true });
720
-
721
- for (const entry of entries) {
722
- if (!entry.isFile() || !entry.name.endsWith('.md')) {
723
- continue;
724
- }
725
-
726
- // Ignore secret/credential files AND internal tool files
727
- const lower = entry.name.toLowerCase();
728
- if (
729
- lower.includes('secret') ||
730
- lower.includes('credential') ||
731
- lower.includes('server_access') ||
732
- entry.name === 'profile_generation_guide.md'
733
- ) {
734
- continue;
735
- }
736
-
737
- const filePath = path.join(eckDir, entry.name);
738
- try {
739
- const content = await fs.readFile(filePath, 'utf-8');
740
- const cleanContent = content.trim();
741
-
742
- // Map well-known files to their dedicated manifest keys
743
- switch (entry.name) {
744
- case 'ENVIRONMENT.md':
745
- try {
746
- manifest.environment = parseEnvironmentYaml(cleanContent);
747
- } catch (e) {
748
- manifest.dynamicFiles[entry.name] = cleanContent;
749
- }
750
- break;
751
- case 'CONTEXT.md':
752
- manifest.context = cleanContent;
753
- break;
754
- case 'OPERATIONS.md':
755
- manifest.operations = cleanContent;
756
- break;
757
- case 'JOURNAL.md':
758
- manifest.journal = cleanContent;
759
- break;
760
- case 'ROADMAP.md':
761
- manifest.roadmap = cleanContent;
762
- break;
763
- case 'TECH_DEBT.md':
764
- manifest.techDebt = cleanContent;
765
- break;
766
- default:
767
- // All other .md files (ARCHITECTURE, RUNTIME_STATE, DEPLOY_CHECKLIST, etc.)
768
- manifest.dynamicFiles[entry.name] = cleanContent;
769
- break;
770
- }
771
- console.log(` ✅ Loaded ${entry.name}`);
772
- } catch (error) {
773
- console.log(` ⚠️ ${entry.name} not found or unreadable`);
774
- }
775
- }
776
-
777
- return manifest;
778
- } catch (error) {
779
- // .eck directory doesn't exist - that's normal
780
- return null;
781
- }
782
- }
783
-
784
- /**
785
- * Ensures that 'snapshots/' is added to the target project's .gitignore file
786
- * @param {string} repoPath - Path to the repository
787
- */
788
- export async function ensureSnapshotsInGitignore(repoPath) {
789
- const gitignorePath = path.join(repoPath, '.gitignore');
790
- const entryToAdd = '.eck/';
791
- const comment = '# Added by eck-snapshot to ignore metadata directory';
792
-
793
- try {
794
- // Check if the repo is a Git repository first
795
- const isGitRepo = await checkGitRepository(repoPath);
796
- if (!isGitRepo) {
797
- // Not a Git repo, skip .gitignore modification
798
- return;
799
- }
800
-
801
- let gitignoreContent = '';
802
- let fileExists = true;
803
-
804
- // Try to read existing .gitignore file
805
- try {
806
- gitignoreContent = await fs.readFile(gitignorePath, 'utf-8');
807
- } catch (error) {
808
- // File doesn't exist, we'll create it
809
- fileExists = false;
810
- gitignoreContent = '';
811
- }
812
-
813
- // Check if 'snapshots/' is already in the file
814
- const lines = gitignoreContent.split('\n');
815
- const hasSnapshotsEntry = lines.some(line => line.trim() === entryToAdd);
816
-
817
- if (!hasSnapshotsEntry) {
818
- // Add the entry
819
- let newContent = gitignoreContent;
820
-
821
- // If file exists and doesn't end with newline, add one
822
- if (fileExists && gitignoreContent && !gitignoreContent.endsWith('\n')) {
823
- newContent += '\n';
824
- }
825
-
826
- // Add comment and entry
827
- if (fileExists && gitignoreContent) {
828
- newContent += '\n';
829
- }
830
- newContent += comment + '\n' + entryToAdd + '\n';
831
-
832
- await fs.writeFile(gitignorePath, newContent);
833
- console.log(`✅ Added '${entryToAdd}' to .gitignore`);
834
- }
835
- } catch (error) {
836
- // Silently fail - don't break the snapshot process if gitignore update fails
837
- console.warn(`⚠️ Warning: Could not update .gitignore: ${error.message}`);
838
- }
839
- }
840
-
841
- // Helper function to determine if a string is a glob pattern
842
- function isGlob(str) {
843
- return str.includes('*') || str.includes('?') || str.includes('{');
844
- }
845
-
846
- /**
847
- * Applies advanced profile filtering (multi-profile, exclusion, and ad-hoc globs) to a file list.
848
- */
849
- export async function applyProfileFilter(allFiles, profileString, repoPath) {
850
- const profileParts = profileString.split(',').map(p => p.trim()).filter(Boolean);
851
-
852
- const includeGlobs = [];
853
- const excludeGlobs = [];
854
- const includeNames = [];
855
- const excludeNames = [];
856
-
857
- // Step 1: Differentiate between profile names and ad-hoc glob patterns
858
- for (const part of profileParts) {
859
- const isNegative = part.startsWith('-');
860
- const pattern = isNegative ? part.substring(1) : part;
861
-
862
- if (isGlob(pattern)) {
863
- if (isNegative) {
864
- excludeGlobs.push(pattern);
865
- } else {
866
- includeGlobs.push(pattern);
867
- }
868
- } else {
869
- if (isNegative) {
870
- excludeNames.push(pattern);
871
- } else {
872
- includeNames.push(pattern);
873
- }
874
- }
875
- }
876
-
877
- let workingFiles = [];
878
- let finalIncludes = [...includeGlobs];
879
- let finalExcludes = [...excludeGlobs];
880
-
881
- // Step 2: Load patterns from specified profile names
882
- const allProfileNames = [...new Set([...includeNames, ...excludeNames])];
883
- const profiles = new Map();
884
- const notFoundProfiles = [];
885
-
886
- for (const name of allProfileNames) {
887
- const profile = await getProfile(name, repoPath);
888
- if (profile) {
889
- profiles.set(name, profile);
890
- } else {
891
- // This is an ad-hoc glob, not a profile, so no warning is needed.
892
- if (!isGlob(name)) {
893
- notFoundProfiles.push(name);
894
- console.warn(`⚠️ Warning: Profile '${name}' not found and will be skipped.`);
895
- }
896
- }
897
- }
898
-
899
- for (const name of includeNames) {
900
- if (profiles.has(name)) {
901
- finalIncludes.push(...(profiles.get(name).include || []));
902
- finalExcludes.push(...(profiles.get(name).exclude || []));
903
- }
904
- }
905
- for (const name of excludeNames) {
906
- if (profiles.has(name)) {
907
- finalExcludes.push(...(profiles.get(name).include || []));
908
- }
909
- }
910
-
911
- // Step 3: Apply the filtering logic
912
- if (finalIncludes.length > 0) {
913
- workingFiles = micromatch(allFiles, finalIncludes);
914
- } else if (includeNames.length > 0 && includeGlobs.length === 0) {
915
- workingFiles = [];
916
- } else {
917
- workingFiles = allFiles;
918
- }
919
-
920
- if (finalExcludes.length > 0) {
921
- workingFiles = micromatch.not(workingFiles, finalExcludes);
922
- }
923
-
924
- return {
925
- files: workingFiles,
926
- notFoundProfiles,
927
- foundProfiles: Array.from(profiles.keys())
928
- };
929
- }
930
-
931
- /**
932
- * Automatically initializes the .eck manifest directory, attempting dynamic generation via Claude.
933
- * @param {string} projectPath - Path to the project
934
- */
935
- export async function initializeEckManifest(projectPath) {
936
- const eckDir = path.join(projectPath, '.eck');
937
-
938
- let setupConfig = null;
939
- try {
940
- setupConfig = await loadSetupConfig();
941
- } catch (error) {
942
- // If setup config fails to load, continue with defaults
943
- }
944
-
945
- try {
946
- // Check if .eck directory already exists and has all required files
947
- let needsInitialization = false;
948
- try {
949
- const eckStats = await fs.stat(eckDir);
950
- if (eckStats.isDirectory()) {
951
- // Directory exists, check if all required files are present
952
- const requiredFiles = ['ENVIRONMENT.md', 'CONTEXT.md', 'OPERATIONS.md', 'JOURNAL.md'];
953
- for (const fileName of requiredFiles) {
954
- try {
955
- await fs.stat(path.join(eckDir, fileName));
956
- } catch (error) {
957
- console.log(` ℹ️ Missing ${fileName}, initialization needed`);
958
- needsInitialization = true;
959
- break;
960
- }
961
- }
962
- if (!needsInitialization) {
963
- // All files exist, no need to initialize
964
- return;
965
- }
966
- }
967
- } catch (error) {
968
- // Directory doesn't exist, we'll create it
969
- needsInitialization = true;
970
- }
971
-
972
- // Create .eck directory
973
- await fs.mkdir(eckDir, { recursive: true });
974
- console.log('📋 Initializing .eck manifest directory...');
975
-
976
- // Gather basic project info for stub templates
977
- let staticFacts = {};
978
- try {
979
- staticFacts = await detectProjectType(projectPath);
980
- } catch (e) {
981
- // Non-critical
982
- }
983
-
984
- const projName = setupConfig?.projectContext?.name || path.basename(projectPath) || 'project';
985
- const projType = setupConfig?.projectContext?.type || staticFacts.type || 'unknown';
986
- const projStack = (setupConfig?.projectContext?.architecture?.stack?.length > 0)
987
- ? setupConfig.projectContext.architecture.stack.join(', ')
988
- : 'TBD';
989
-
990
- // Static stub templates AI agents fill these in via their AGENTS.md/CLAUDE.md instructions
991
- const templateConfigs = {
992
- 'ENVIRONMENT.md': {
993
- content: `project_type: ${projType}\nagent_id: local_dev\n`
994
- },
995
- 'CONTEXT.md': {
996
- content: `# [STUB: CONTEXT.md]\n\nProject: ${projName} | Type: ${projType} | Stack: ${projStack}\n\nAI Agent: please update this file based on your analysis of the codebase.\n`
997
- },
998
- 'OPERATIONS.md': {
999
- content: `# [STUB: OPERATIONS.md]\n\nAI Agent: please update with actual setup, run, and test commands.\n\n## Setup\n${staticFacts.type === 'nodejs' ? '\\`\\`\\`bash\\nnpm install\\n\\`\\`\\`' : 'TBD'}\n`
1000
- },
1001
- 'ROADMAP.md': {
1002
- content: `# [STUB: ROADMAP.md]\n\nAI Agent: please set a real roadmap based on project goals.\n`
1003
- },
1004
- 'TECH_DEBT.md': {
1005
- content: `# [STUB: TECH_DEBT.md]\n\nAI Agent: please scan for TODOs/FIXMEs or structural issues and list them here.\n`
1006
- },
1007
- 'DEPLOY_CHECKLIST.md': {
1008
- content: `# [STUB: DEPLOY_CHECKLIST.md]\n\n- [ ] Verify all tests pass\n- [ ] Build assets\n- [ ] Check environment variables\n\nAI Agent: please update with actual deploy steps.\n`
1009
- },
1010
- 'RUNTIME_STATE.md': {
1011
- content: `# [STUB: RUNTIME_STATE.md]\n\nAI Agent: check actual runtime state (ports, processes, env vars) and update this file.\n\n- **Server:** TBD\n- **Services:** TBD\n`
1012
- },
1013
- 'JOURNAL.md': {
1014
- content: `# Development Journal\n\n## Recent Changes\n---\ntype: feat\nscope: project\nsummary: Initial manifest generated (PENDING REVIEW)\ndate: ${new Date().toISOString().split('T')[0]}\n---\n- NOTICE: Some .eck files are STUBS. They need manual or AI-assisted verification.\n`
1015
- }
1016
- };
1017
-
1018
- // Create each template file (only if it doesn't exist)
1019
- for (const [fileName, config] of Object.entries(templateConfigs)) {
1020
- const filePath = path.join(eckDir, fileName);
1021
-
1022
- // Skip if file already exists
1023
- try {
1024
- await fs.stat(filePath);
1025
- console.log(` ✅ ${fileName} already exists, skipping`);
1026
- continue;
1027
- } catch (error) {
1028
- // File doesn't exist, create it
1029
- }
1030
-
1031
- await fs.writeFile(filePath, config.content);
1032
- console.log(` ✅ Created ${fileName} (stub template)`);
1033
- }
1034
-
1035
- console.log('📋 .eck manifest initialized! Edit the files to provide project-specific context.');
1036
-
1037
- } catch (error) {
1038
- // Silently fail - don't break the snapshot process if manifest initialization fails
1039
- console.warn(`⚠️ Warning: Could not initialize .eck manifest: ${error.message}`);
1040
- }
1041
- }
1042
-
1043
- // Global hard-ignore patterns (shared between git-based and scan-based file collection)
1044
- const GLOBAL_HARD_IGNORE_DIRS = ['node_modules', '.git', '.idea', '.vscode'];
1045
- const GLOBAL_HARD_IGNORE_FILES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'go.sum'];
1046
-
1047
- function isHiddenPath(filePath) {
1048
- const parts = filePath.split('/');
1049
- return parts.some(part => part.startsWith('.') && part !== '.eck');
1050
- }
1051
-
1052
- /**
1053
- * Gets project files using git ls-files (preferred) or scanDirectoryRecursively as fallback.
1054
- * Applies global hard-ignore filters and project-specific filtering.
1055
- */
1056
- export async function getProjectFiles(projectPath, config) {
1057
- const isGitRepo = await checkGitRepository(projectPath);
1058
- if (isGitRepo) {
1059
- const { stdout } = await execa('git', ['ls-files'], { cwd: projectPath });
1060
- const gitFiles = stdout.split('\n').filter(Boolean);
1061
-
1062
- const dirsToIgnore = [...GLOBAL_HARD_IGNORE_DIRS, ...(config.dirsToIgnore || []).map(d => d.replace(/\/$/, ''))];
1063
- const filesToIgnore = [...GLOBAL_HARD_IGNORE_FILES, ...(config.filesToIgnore || [])];
1064
- const extensionsToIgnore = config.extensionsToIgnore || [];
1065
-
1066
- const filteredFiles = gitFiles.filter(file => {
1067
- if (isHiddenPath(file)) return false;
1068
- const fileName = file.split('/').pop();
1069
- const fileExt = path.extname(fileName);
1070
- const pathParts = file.split('/');
1071
- for (let i = 0; i < pathParts.length - 1; i++) {
1072
- if (dirsToIgnore.includes(pathParts[i])) return false;
1073
- }
1074
- if (filesToIgnore.includes(fileName)) return false;
1075
- if (fileExt && extensionsToIgnore.includes(fileExt)) return false;
1076
- return true;
1077
- });
1078
- return filteredFiles;
1079
- }
1080
- return scanDirectoryRecursively(projectPath, config);
1081
- }
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { execa } from 'execa';
4
+
5
+ /**
6
+ * Safely extracts metadata headers from Large ML models without loading them into memory.
7
+ */
8
+ export async function readMlModelMetadata(filePath) {
9
+ let fileHandle;
10
+ try {
11
+ fileHandle = await fs.open(filePath, 'r');
12
+ const stats = await fileHandle.stat();
13
+ const readSize = Math.min(stats.size, 4096); // Read only the first 4KB
14
+ const buffer = Buffer.alloc(readSize);
15
+ await fileHandle.read(buffer, 0, readSize, 0);
16
+
17
+ let text = buffer.toString('utf8');
18
+ // Extract printable characters, keeping JSON structure intact, remove binary gibberish
19
+ let cleanText = text.replace(/[^\x20-\x7E\n\r\t"{}\[\]:,]/g, '').trim();
20
+
21
+ if (cleanText.length > 2000) {
22
+ cleanText = cleanText.substring(0, 2000) + '\n...';
23
+ }
24
+
25
+ return `[ML MODEL METADATA EXTRACTED]\nSize: ${formatSize(stats.size)}\n\nHeader Preview:\n${cleanText}\n\n[... BINARY DATA TRUNCATED ...]`;
26
+ } catch (error) {
27
+ return `[ML MODEL - Could not extract metadata: ${error.message}]`;
28
+ } finally {
29
+ if (fileHandle) await fileHandle.close();
30
+ }
31
+ }
32
+ import ignore from 'ignore';
33
+ import { detectProjectType, getProjectSpecificFiltering, getAllDetectedTypes } from './projectDetector.js';
34
+ import { getProfile, loadSetupConfig } from '../config.js';
35
+ import micromatch from 'micromatch';
36
+ import { minimatch } from 'minimatch';
37
+
38
+ // Global hard-ignore patterns (shared between git-based and scan-based file collection)
39
+ const GLOBAL_HARD_IGNORE_DIRS = ['node_modules', '.git', '.idea', '.vscode', '.gradle', 'build', '__pycache__'];
40
+ const GLOBAL_HARD_IGNORE_FILES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'go.sum'];
41
+
42
+ /**
43
+ * Scanner for detecting and redacting secrets (API keys, tokens)
44
+ */
45
+ export const SecretScanner = {
46
+ patterns: [
47
+ // Service-specific patterns
48
+ { name: 'GitHub Token', regex: /gh[pous]_[a-zA-Z0-9]{36}/g },
49
+ { name: 'AWS Access Key', regex: /(?:AKIA|ASIA)[0-9A-Z]{16}/g },
50
+ { name: 'OpenAI API Key', regex: /sk-[a-zA-Z0-9]{32,}/g },
51
+ { name: 'Stripe Secret Key', regex: /sk_live_[0-9a-zA-Z]{24}/g },
52
+ { name: 'Google API Key', regex: /AIza[0-9A-Za-z\-_]{35}/g },
53
+ { name: 'Slack Token', regex: /xox[baprs]-[0-9a-zA-Z\-]{10,}/g },
54
+ { name: 'NPM Token', regex: /npm_[a-zA-Z0-9]{36}/g },
55
+ { name: 'Private Key', regex: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g },
56
+ // Generic high-entropy patterns near sensitive keywords
57
+ {
58
+ name: 'Generic Secret',
59
+ regex: /(?:api[_-]?key|secret|password|token|auth|pwd|credential)\s*[:=]\s*["']([a-zA-Z0-9\-_.]{16,})["']/gi
60
+ }
61
+ ],
62
+
63
+ /**
64
+ * Calculates Shannon Entropy of a string
65
+ */
66
+ calculateEntropy(str) {
67
+ const len = str.length;
68
+ const frequencies = Array.from(str).reduce((freq, c) => {
69
+ freq[c] = (freq[c] || 0) + 1;
70
+ return freq;
71
+ }, {});
72
+ return Object.values(frequencies).reduce((sum, f) => {
73
+ const p = f / len;
74
+ return sum - (p * Math.log2(p));
75
+ }, 0);
76
+ },
77
+
78
+ /**
79
+ * Scans content and replaces detected secrets with a placeholder
80
+ * @param {string} content - File content to scan
81
+ * @param {string} filePath - Path for logging context
82
+ * @returns {{content: string, found: string[]}} Redacted content and list of found secret types
83
+ */
84
+ redact(content, filePath) {
85
+ let redactedContent = content;
86
+ const foundSecrets = [];
87
+
88
+ for (const pattern of this.patterns) {
89
+ // Reset regex lastIndex for global patterns
90
+ pattern.regex.lastIndex = 0;
91
+
92
+ const matches = [...content.matchAll(pattern.regex)];
93
+ if (matches.length > 0) {
94
+ for (const match of matches) {
95
+ // For generic pattern, use captured group; for specific patterns, use full match
96
+ const secretValue = match[1] || match[0];
97
+ const placeholder = `[REDACTED_${pattern.name.replace(/\s+/g, '_').toUpperCase()}]`;
98
+ redactedContent = redactedContent.replace(secretValue, placeholder);
99
+ foundSecrets.push(pattern.name);
100
+ }
101
+ }
102
+ }
103
+
104
+ // Second pass: Shannon Entropy check for arbitrary hardcoded secrets
105
+ // Look for long strings assigned to variable names that might be keys
106
+ const entropyRegex = /(?:const|let|var|set|export|define)\s+([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)\s*=\s*["']([a-zA-Z0-9+/=_-]{20,128})["']/gi;
107
+ const entropyMatches = [...redactedContent.matchAll(entropyRegex)];
108
+
109
+ for (const match of entropyMatches) {
110
+ const secretValue = match[2];
111
+ // Check entropy - random base64 usually has entropy > 4.5
112
+ if (this.calculateEntropy(secretValue) > 4.5 && !secretValue.includes('REDACTED')) {
113
+ const placeholder = `[REDACTED_HIGH_ENTROPY_SECRET]`;
114
+ redactedContent = redactedContent.replace(secretValue, placeholder);
115
+ foundSecrets.push('High Entropy Secret');
116
+ }
117
+ }
118
+
119
+ return {
120
+ content: redactedContent,
121
+ found: [...new Set(foundSecrets)]
122
+ };
123
+ }
124
+ };
125
+
126
+ export function parseSize(sizeStr) {
127
+ const units = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
128
+ const match = sizeStr.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
129
+ if (!match) throw new Error(`Invalid size format: ${sizeStr}`);
130
+ const [, size, unit = 'B'] = match;
131
+ return Math.floor(parseFloat(size) * units[unit.toUpperCase()]);
132
+ }
133
+
134
+ export function formatSize(bytes) {
135
+ const units = ['B', 'KB', 'MB', 'GB'];
136
+ let size = bytes;
137
+ let unitIndex = 0;
138
+ while (size >= 1024 && unitIndex < units.length - 1) {
139
+ size /= 1024;
140
+ unitIndex++;
141
+ }
142
+ return `${size.toFixed(1)} ${units[unitIndex]}`;
143
+ }
144
+
145
+ export function matchesPattern(filePath, patterns) {
146
+ const fileName = path.basename(filePath);
147
+ return patterns.some(pattern => {
148
+ const regexPattern = '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$';
149
+ try {
150
+ const regex = new RegExp(regexPattern);
151
+ return regex.test(fileName);
152
+ } catch (e) {
153
+ console.warn(`⚠️ Invalid regex pattern in config: "${pattern}"`);
154
+ return false;
155
+ }
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Checks if a file matches confidential patterns using minimatch
161
+ * @param {string} fileName - The file name to check
162
+ * @param {array} patterns - Array of glob patterns to match against
163
+ * @returns {boolean} True if the file matches any pattern
164
+ */
165
+ function matchesConfidentialPattern(fileName, patterns) {
166
+ return patterns.some(pattern => minimatch(fileName, pattern, { nocase: true }));
167
+ }
168
+
169
+ /**
170
+ * Applies smart filtering for files within the .eck directory.
171
+ * Includes documentation files while excluding confidential files.
172
+ * @param {string} fileName - The file name to check
173
+ * @param {object} eckConfig - The eckDirectoryFiltering config object
174
+ * @returns {object} { include: boolean, isConfidential: boolean }
175
+ */
176
+ export function applyEckDirectoryFiltering(fileName, eckConfig) {
177
+ if (!eckConfig || !eckConfig.enabled) {
178
+ return { include: false, isConfidential: false }; // .eck filtering disabled, exclude all
179
+ }
180
+
181
+ const { confidentialPatterns = [], alwaysIncludePatterns = [] } = eckConfig;
182
+
183
+ // First check if file matches confidential patterns
184
+ const isConfidential = matchesConfidentialPattern(fileName, confidentialPatterns);
185
+ if (isConfidential) {
186
+ return { include: false, isConfidential: true };
187
+ }
188
+
189
+ // Check if file matches always-include patterns
190
+ if (matchesPattern(fileName, alwaysIncludePatterns)) {
191
+ return { include: true, isConfidential: false };
192
+ }
193
+
194
+ // Default: exclude files not in the include list
195
+ return { include: false, isConfidential: false };
196
+ }
197
+
198
+ export async function checkGitRepository(repoPath) {
199
+ try {
200
+ await execa('git', ['rev-parse', '--git-dir'], { cwd: repoPath });
201
+ return true;
202
+ } catch (error) {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ export async function scanDirectoryRecursively(dirPath, config, relativeTo = dirPath, projectTypes = null, trackConfidential = false) {
208
+ const files = [];
209
+ const confidentialFiles = [];
210
+
211
+ // Get project-specific filtering for ALL detected types (polyglot monorepo support)
212
+ if (!projectTypes) {
213
+ const detection = await detectProjectType(relativeTo);
214
+ projectTypes = getAllDetectedTypes(detection);
215
+ }
216
+
217
+ const projectSpecific = await getProjectSpecificFiltering(projectTypes);
218
+
219
+ // Merge project-specific filters with global config
220
+ const effectiveConfig = {
221
+ ...config,
222
+ dirsToIgnore: [...(config.dirsToIgnore || []), ...(projectSpecific.dirsToIgnore || [])],
223
+ filesToIgnore: [...(config.filesToIgnore || []), ...(projectSpecific.filesToIgnore || [])],
224
+ extensionsToIgnore: [...(config.extensionsToIgnore || []), ...(projectSpecific.extensionsToIgnore || [])]
225
+ };
226
+
227
+ try {
228
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
229
+
230
+ for (const entry of entries) {
231
+ const fullPath = path.join(dirPath, entry.name);
232
+ const relativePath = path.relative(relativeTo, fullPath).replace(/\\/g, '/');
233
+
234
+ // --- GLOBAL HARD IGNORES (Zero-Config Safety) ---
235
+ if (entry.isDirectory() && GLOBAL_HARD_IGNORE_DIRS.includes(entry.name)) {
236
+ continue;
237
+ } else if (!entry.isDirectory() && GLOBAL_HARD_IGNORE_FILES.includes(entry.name)) {
238
+ continue;
239
+ }
240
+ // -----------------------------------------------
241
+
242
+ // Special handling for .eck directory - never ignore it when tracking confidential files
243
+ const isEckDirectory = entry.name === '.eck' && entry.isDirectory();
244
+ const isInsideEck = relativePath.startsWith('.eck/');
245
+
246
+ if (effectiveConfig.dirsToIgnore.some(dir =>
247
+ entry.name === dir.replace('/', '') ||
248
+ relativePath.startsWith(dir)
249
+ ) && !isEckDirectory && !isInsideEck) {
250
+ continue;
251
+ }
252
+
253
+ if (!effectiveConfig.includeHidden && entry.name.startsWith('.') && !isEckDirectory && !isInsideEck) {
254
+ continue;
255
+ }
256
+
257
+ if (entry.isDirectory()) {
258
+ const subResult = await scanDirectoryRecursively(fullPath, effectiveConfig, relativeTo, projectTypes, trackConfidential);
259
+ if (trackConfidential) {
260
+ files.push(...subResult.files);
261
+ confidentialFiles.push(...subResult.confidentialFiles);
262
+ } else {
263
+ files.push(...subResult);
264
+ }
265
+ } else {
266
+ // Apply smart filtering for files inside .eck directory
267
+ if (isInsideEck) {
268
+ const eckConfig = effectiveConfig.eckDirectoryFiltering;
269
+ const filterResult = applyEckDirectoryFiltering(entry.name, eckConfig);
270
+
271
+ if (trackConfidential && filterResult.isConfidential) {
272
+ confidentialFiles.push(relativePath);
273
+ } else if (filterResult.include) {
274
+ files.push(relativePath);
275
+ }
276
+ } else {
277
+ // Normal filtering for non-.eck files
278
+ if (effectiveConfig.extensionsToIgnore.includes(path.extname(entry.name)) ||
279
+ matchesPattern(relativePath, effectiveConfig.filesToIgnore)) {
280
+ continue;
281
+ }
282
+ files.push(relativePath);
283
+ }
284
+ }
285
+ }
286
+ } catch (error) {
287
+ console.warn(`⚠️ Warning: Could not read directory: ${dirPath} - ${error.message}`);
288
+ }
289
+
290
+ return trackConfidential ? { files, confidentialFiles } : files;
291
+ }
292
+
293
+ export async function loadGitignore(repoPath) {
294
+ const ig = ignore();
295
+
296
+ try {
297
+ const gitignoreContent = await fs.readFile(path.join(repoPath, '.gitignore'), 'utf-8');
298
+ ig.add(gitignoreContent);
299
+ } catch {
300
+ console.log('ℹ️ No .gitignore file found or could not be read');
301
+ }
302
+
303
+ try {
304
+ const eckignoreContent = await fs.readFile(path.join(repoPath, '.eckignore'), 'utf-8');
305
+ ig.add(eckignoreContent);
306
+ } catch {
307
+ // .eckignore is optional, silently skip if missing
308
+ }
309
+
310
+ return ig;
311
+ }
312
+
313
+ export async function readFileWithSizeCheck(filePath, maxFileSize) {
314
+ try {
315
+ const stats = await fs.stat(filePath);
316
+ if (stats.size > maxFileSize) {
317
+ throw new Error(`File too large: ${formatSize(stats.size)}`);
318
+ }
319
+ return await fs.readFile(filePath, 'utf-8');
320
+ } catch (error) {
321
+ if (error.message.includes('too large')) throw error;
322
+ throw new Error(`Could not read file: ${error.message}`);
323
+ }
324
+ }
325
+
326
+ export async function generateDirectoryTree(dir, prefix = '', allFiles, depth = 0, maxDepth = 10, config) {
327
+ if (depth > maxDepth) return '';
328
+
329
+ try {
330
+ const entries = await fs.readdir(dir, { withFileTypes: true });
331
+ const sortedEntries = entries.sort((a, b) => {
332
+ if (a.isDirectory() && !b.isDirectory()) return -1;
333
+ if (!a.isDirectory() && b.isDirectory()) return 1;
334
+ return a.name.localeCompare(b.name);
335
+ });
336
+
337
+ let tree = '';
338
+ const validEntries = [];
339
+
340
+ for (const entry of sortedEntries) {
341
+ // --- GLOBAL HARD IGNORES ---
342
+ if (entry.isDirectory() && GLOBAL_HARD_IGNORE_DIRS.includes(entry.name)) continue;
343
+ if (!entry.isDirectory() && GLOBAL_HARD_IGNORE_FILES.includes(entry.name)) continue;
344
+ // ---------------------------
345
+
346
+ // Skip hidden directories and files (starting with '.')
347
+ // EXCEPT: Allow .eck to be visible
348
+ if (entry.name.startsWith('.') && entry.name !== '.eck') {
349
+ continue;
350
+ }
351
+ // Only skip directories (not files) and use exact name match to avoid
352
+ // substring false positives (e.g., "build/" hiding "build.gradle.kts")
353
+ if (entry.isDirectory() && config.dirsToIgnore.some(d => entry.name === d.replace('/', ''))) continue;
354
+ const fullPath = path.join(dir, entry.name);
355
+ const relativePath = path.relative(process.cwd(), fullPath).replace(/\\/g, '/');
356
+
357
+ // FORCE VISIBILITY for .eck files in the tree
358
+ // Even if they are gitignored (not in allFiles), we want the Architect to see they exist
359
+ const isInsideEck = relativePath.startsWith('.eck/') || relativePath === '.eck';
360
+
361
+ if (entry.isDirectory() || allFiles.includes(relativePath) || isInsideEck) {
362
+ validEntries.push({ entry, fullPath, relativePath });
363
+ }
364
+ }
365
+
366
+ for (let i = 0; i < validEntries.length; i++) {
367
+ const { entry, fullPath, relativePath } = validEntries[i];
368
+ const isLast = i === validEntries.length - 1;
369
+
370
+ const connector = isLast ? '└── ' : '├── ';
371
+ const nextPrefix = prefix + (isLast ? ' ' : '│ ');
372
+
373
+ if (entry.isDirectory()) {
374
+ tree += `${prefix}${connector}${entry.name}/\n`;
375
+
376
+ // RECURSION CONTROL:
377
+ // If we are currently inside .eck, do NOT recurse deeper into subdirectories (like snapshots, logs).
378
+ // We want to see that 'snapshots/' exists, but not list its contents.
379
+ const isInsideEckRoot = path.basename(dir) === '.eck';
380
+
381
+ if (!isInsideEckRoot) {
382
+ tree += await generateDirectoryTree(fullPath, nextPrefix, allFiles, depth + 1, maxDepth, config);
383
+ }
384
+ } else {
385
+ tree += `${prefix}${connector}${entry.name}\n`;
386
+ }
387
+ }
388
+
389
+ return tree;
390
+ } catch (error) {
391
+ console.warn(`⚠️ Warning: Could not read directory: ${dir}`);
392
+ return '';
393
+ }
394
+ }
395
+
396
+ export function parseSnapshotContent(content) {
397
+ const files = [];
398
+ const fileRegex = /--- File: \/(.+) ---/g;
399
+ const sections = content.split(fileRegex);
400
+
401
+ for (let i = 1; i < sections.length; i += 2) {
402
+ const filePath = sections[i].trim();
403
+ let fileContent = sections[i + 1] || '';
404
+
405
+ if (fileContent.startsWith('\n\n')) {
406
+ fileContent = fileContent.substring(2);
407
+ }
408
+ if (fileContent.endsWith('\n\n')) {
409
+ fileContent = fileContent.substring(0, fileContent.length - 2);
410
+ }
411
+
412
+ files.push({ path: filePath, content: fileContent });
413
+ }
414
+
415
+ return files;
416
+ }
417
+
418
+ export function filterFilesToRestore(files, options) {
419
+ let filtered = files;
420
+
421
+ if (options.include) {
422
+ const includePatterns = Array.isArray(options.include) ?
423
+ options.include : [options.include];
424
+ filtered = filtered.filter(file =>
425
+ includePatterns.some(pattern => {
426
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
427
+ return regex.test(file.path);
428
+ })
429
+ );
430
+ }
431
+
432
+ if (options.exclude) {
433
+ const excludePatterns = Array.isArray(options.exclude) ?
434
+ options.exclude : [options.exclude];
435
+ filtered = filtered.filter(file =>
436
+ !excludePatterns.some(pattern => {
437
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
438
+ return regex.test(file.path);
439
+ })
440
+ );
441
+ }
442
+
443
+ return filtered;
444
+ }
445
+
446
+ export function validateFilePaths(files, targetDir) {
447
+ const invalidFiles = [];
448
+
449
+ for (const file of files) {
450
+ const normalizedPath = path.normalize(file.path);
451
+ if (normalizedPath.includes('..') ||
452
+ normalizedPath.startsWith('/') ||
453
+ normalizedPath.includes('\0') ||
454
+ /[<>:"|?*]/.test(normalizedPath)) {
455
+ invalidFiles.push(file.path);
456
+ }
457
+ }
458
+
459
+ return invalidFiles;
460
+ }
461
+
462
+ export async function loadConfig(configPath) {
463
+ const { DEFAULT_CONFIG } = await import('../config.js');
464
+ let config = { ...DEFAULT_CONFIG };
465
+
466
+ if (configPath) {
467
+ try {
468
+ const configModule = await import(path.resolve(configPath));
469
+ config = { ...config, ...configModule.default };
470
+ console.log(`✅ Configuration loaded from: ${configPath}`);
471
+ } catch (error) {
472
+ console.warn(`⚠️ Warning: Could not load config file: ${configPath}`);
473
+ }
474
+ } else {
475
+ const possibleConfigs = [
476
+ '.ecksnapshot.config.js',
477
+ '.ecksnapshot.config.mjs',
478
+ 'ecksnapshot.config.js'
479
+ ];
480
+
481
+ for (const configFile of possibleConfigs) {
482
+ try {
483
+ await fs.access(configFile);
484
+ const configModule = await import(path.resolve(configFile));
485
+ config = { ...config, ...configModule.default };
486
+ console.log(`✅ Configuration loaded from: ${configFile}`);
487
+ break;
488
+ } catch {
489
+ // Config file doesn't exist, continue
490
+ }
491
+ }
492
+ }
493
+
494
+ return config;
495
+ }
496
+
497
+ export function generateTimestamp() {
498
+ const now = new Date();
499
+ const YY = String(now.getFullYear()).slice(-2);
500
+ const MM = String(now.getMonth() + 1).padStart(2, '0');
501
+ const DD = String(now.getDate()).padStart(2, '0');
502
+ const hh = String(now.getHours()).padStart(2, '0');
503
+ const mm = String(now.getMinutes()).padStart(2, '0');
504
+ // Compact format: YY-MM-DD_HH-mm (no seconds)
505
+ return `${YY}-${MM}-${DD}_${hh}-${mm}`;
506
+ }
507
+
508
+ /**
509
+ * Generates a short repo name with capitalized first 3 and last 2 characters
510
+ * Example: "Snapshot" -> "SnaOt", "MyProject" -> "MyPrjt"
511
+ * @param {string} repoName - The repository name
512
+ * @returns {string} Shortened repo name (Start3 + End2)
513
+ */
514
+ export function getShortRepoName(repoName) {
515
+ if (!repoName) return '';
516
+ if (repoName.length <= 5) {
517
+ return repoName.toUpperCase();
518
+ }
519
+ const start = repoName.substring(0, 3);
520
+ const end = repoName.substring(repoName.length - 2);
521
+ return (start + end).toUpperCase();
522
+ }
523
+
524
+ /**
525
+ * Displays project detection information in a user-friendly format
526
+ * @param {object} detection - Project detection result
527
+ */
528
+ export function displayProjectInfo(detection) {
529
+ console.log('\n🔍 Project Detection Results:');
530
+ console.log(` Type: ${detection.type} (confidence: ${(detection.confidence * 100).toFixed(0)}%)`);
531
+
532
+ if (detection.details) {
533
+ const details = detection.details;
534
+
535
+ switch (detection.type) {
536
+ case 'android':
537
+ console.log(` Language: ${details.language || 'unknown'}`);
538
+ if (details.packageName) {
539
+ console.log(` Package: ${details.packageName}`);
540
+ }
541
+ if (details.sourceDirs && details.sourceDirs.length > 0) {
542
+ console.log(` Source dirs: ${details.sourceDirs.join(', ')}`);
543
+ }
544
+ if (details.libFiles && details.libFiles.length > 0) {
545
+ console.log(` Libraries: ${details.libFiles.length} .aar/.jar files`);
546
+ }
547
+ break;
548
+
549
+ case 'nodejs':
550
+ if (details.name) {
551
+ console.log(` Package: ${details.name}@${details.version || '?'}`);
552
+ }
553
+ if (details.framework) {
554
+ console.log(` Framework: ${details.framework}`);
555
+ }
556
+ if (details.hasTypescript) {
557
+ console.log(` TypeScript: enabled`);
558
+ }
559
+ break;
560
+
561
+ case 'nodejs-monorepo':
562
+ if (details.name) {
563
+ console.log(` Project: ${details.name}@${details.version || '?'}`);
564
+ }
565
+ if (details.monorepoTool) {
566
+ console.log(` Monorepo tool: ${details.monorepoTool}`);
567
+ }
568
+ if (details.workspaceCount) {
569
+ console.log(` Workspaces: ${details.workspaceCount}`);
570
+ }
571
+ if (details.framework) {
572
+ console.log(` Framework: ${details.framework}`);
573
+ }
574
+ break;
575
+
576
+ case 'python-poetry':
577
+ case 'python-pip':
578
+ case 'python-conda':
579
+ if (details.name) {
580
+ console.log(` Project: ${details.name}@${details.version || '?'}`);
581
+ }
582
+ if (details.packageManager) {
583
+ console.log(` Package manager: ${details.packageManager}`);
584
+ }
585
+ if (details.dependencies) {
586
+ console.log(` Dependencies: ${details.dependencies}`);
587
+ }
588
+ if (details.hasVirtualEnv) {
589
+ console.log(` Virtual environment: detected`);
590
+ }
591
+ break;
592
+
593
+ case 'django':
594
+ if (details.name) {
595
+ console.log(` Project: ${details.name}`);
596
+ }
597
+ console.log(` Framework: Django`);
598
+ if (details.djangoApps && details.djangoApps.length > 0) {
599
+ console.log(` Django apps: ${details.djangoApps.join(', ')}`);
600
+ }
601
+ if (details.hasVirtualEnv) {
602
+ console.log(` Virtual environment: detected`);
603
+ }
604
+ break;
605
+
606
+ case 'flask':
607
+ if (details.name) {
608
+ console.log(` Project: ${details.name}`);
609
+ }
610
+ console.log(` Framework: Flask`);
611
+ if (details.hasVirtualEnv) {
612
+ console.log(` Virtual environment: detected`);
613
+ }
614
+ break;
615
+
616
+ case 'rust':
617
+ if (details.name) {
618
+ console.log(` Package: ${details.name}@${details.version || '?'}`);
619
+ }
620
+ if (details.edition) {
621
+ console.log(` Rust edition: ${details.edition}`);
622
+ }
623
+ if (details.isWorkspace) {
624
+ console.log(` Cargo workspace: detected`);
625
+ }
626
+ break;
627
+
628
+ case 'go':
629
+ if (details.module) {
630
+ console.log(` Module: ${details.module}`);
631
+ }
632
+ if (details.goVersion) {
633
+ console.log(` Go version: ${details.goVersion}`);
634
+ }
635
+ break;
636
+
637
+ case 'dotnet':
638
+ if (details.language) {
639
+ console.log(` Language: ${details.language}`);
640
+ }
641
+ if (details.projectFiles && details.projectFiles.length > 0) {
642
+ console.log(` Project files: ${details.projectFiles.join(', ')}`);
643
+ }
644
+ if (details.hasSolution) {
645
+ console.log(` Solution: detected`);
646
+ }
647
+ break;
648
+
649
+ case 'flutter':
650
+ if (details.name) {
651
+ console.log(` App: ${details.name}@${details.version || '?'}`);
652
+ }
653
+ break;
654
+
655
+ case 'react-native':
656
+ if (details.name) {
657
+ console.log(` App: ${details.name}@${details.version || '?'}`);
658
+ }
659
+ if (details.reactNativeVersion) {
660
+ console.log(` React Native: ${details.reactNativeVersion}`);
661
+ }
662
+ break;
663
+ }
664
+ }
665
+
666
+ if (detection.allDetections && detection.allDetections.length > 1) {
667
+ const otherTypes = detection.allDetections.slice(1).map(d => d.type).join(', ');
668
+ console.log(` Polyglot filtering: applying rules for [${detection.type}, ${otherTypes}]`);
669
+ }
670
+
671
+ console.log('');
672
+ }
673
+
674
+ /**
675
+ * Parses YAML-like content from ENVIRONMENT.md
676
+ * @param {string} content - The raw content of ENVIRONMENT.md
677
+ * @returns {object} Parsed key-value pairs
678
+ */
679
+ function parseEnvironmentYaml(content) {
680
+ const result = {};
681
+ const lines = content.split('\n');
682
+
683
+ for (const line of lines) {
684
+ const trimmed = line.trim();
685
+ if (trimmed && !trimmed.startsWith('#') && trimmed.includes(':')) {
686
+ const [key, ...valueParts] = trimmed.split(':');
687
+ const value = valueParts.join(':').trim();
688
+
689
+ // Remove quotes if present
690
+ const cleanValue = value.replace(/^["']|["']$/g, '');
691
+ result[key.trim()] = cleanValue;
692
+ }
693
+ }
694
+
695
+ return result;
696
+ }
697
+
698
+ /**
699
+ * Loads and processes the .eck directory manifest
700
+ * @param {string} repoPath - Path to the repository
701
+ * @returns {Promise<object|null>} The eck manifest object or null if no .eck directory
702
+ */
703
+ export async function loadProjectEckManifest(repoPath) {
704
+ const eckDir = path.join(repoPath, '.eck');
705
+
706
+ try {
707
+ const eckStats = await fs.stat(eckDir);
708
+ if (!eckStats.isDirectory()) {
709
+ return null;
710
+ }
711
+
712
+ console.log('📋 Found .eck directory - dynamically loading project manifest...');
713
+
714
+ const manifest = {
715
+ environment: {},
716
+ context: '',
717
+ operations: '',
718
+ journal: '',
719
+ roadmap: '',
720
+ techDebt: '',
721
+ dynamicFiles: {}
722
+ };
723
+
724
+ const entries = await fs.readdir(eckDir, { withFileTypes: true });
725
+
726
+ for (const entry of entries) {
727
+ if (!entry.isFile() || !entry.name.endsWith('.md')) {
728
+ continue;
729
+ }
730
+
731
+ // Ignore secret/credential files AND internal tool files
732
+ const lower = entry.name.toLowerCase();
733
+ if (
734
+ lower.includes('secret') ||
735
+ lower.includes('credential') ||
736
+ lower.includes('server_access') ||
737
+ entry.name === 'profile_generation_guide.md'
738
+ ) {
739
+ continue;
740
+ }
741
+
742
+ const filePath = path.join(eckDir, entry.name);
743
+ try {
744
+ const content = await fs.readFile(filePath, 'utf-8');
745
+ const cleanContent = content.trim();
746
+
747
+ // Map well-known files to their dedicated manifest keys
748
+ switch (entry.name) {
749
+ case 'ENVIRONMENT.md':
750
+ try {
751
+ manifest.environment = parseEnvironmentYaml(cleanContent);
752
+ } catch (e) {
753
+ manifest.dynamicFiles[entry.name] = cleanContent;
754
+ }
755
+ break;
756
+ case 'CONTEXT.md':
757
+ manifest.context = cleanContent;
758
+ break;
759
+ case 'OPERATIONS.md':
760
+ manifest.operations = cleanContent;
761
+ break;
762
+ case 'JOURNAL.md':
763
+ manifest.journal = cleanContent;
764
+ break;
765
+ case 'ROADMAP.md':
766
+ manifest.roadmap = cleanContent;
767
+ break;
768
+ case 'TECH_DEBT.md':
769
+ manifest.techDebt = cleanContent;
770
+ break;
771
+ default:
772
+ // All other .md files (ARCHITECTURE, RUNTIME_STATE, DEPLOY_CHECKLIST, etc.)
773
+ manifest.dynamicFiles[entry.name] = cleanContent;
774
+ break;
775
+ }
776
+ console.log(` ✅ Loaded ${entry.name}`);
777
+ } catch (error) {
778
+ console.log(` ⚠️ ${entry.name} not found or unreadable`);
779
+ }
780
+ }
781
+
782
+ return manifest;
783
+ } catch (error) {
784
+ // .eck directory doesn't exist - that's normal
785
+ return null;
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Ensures that 'snapshots/' is added to the target project's .gitignore file
791
+ * @param {string} repoPath - Path to the repository
792
+ */
793
+ export async function ensureSnapshotsInGitignore(repoPath) {
794
+ const gitignorePath = path.join(repoPath, '.gitignore');
795
+ const entryToAdd = '.eck/';
796
+ const comment = '# Added by eck-snapshot to ignore metadata directory';
797
+
798
+ try {
799
+ // Check if the repo is a Git repository first
800
+ const isGitRepo = await checkGitRepository(repoPath);
801
+ if (!isGitRepo) {
802
+ // Not a Git repo, skip .gitignore modification
803
+ return;
804
+ }
805
+
806
+ let gitignoreContent = '';
807
+ let fileExists = true;
808
+
809
+ // Try to read existing .gitignore file
810
+ try {
811
+ gitignoreContent = await fs.readFile(gitignorePath, 'utf-8');
812
+ } catch (error) {
813
+ // File doesn't exist, we'll create it
814
+ fileExists = false;
815
+ gitignoreContent = '';
816
+ }
817
+
818
+ // Check if 'snapshots/' is already in the file
819
+ const lines = gitignoreContent.split('\n');
820
+ const hasSnapshotsEntry = lines.some(line => line.trim() === entryToAdd);
821
+
822
+ if (!hasSnapshotsEntry) {
823
+ // Add the entry
824
+ let newContent = gitignoreContent;
825
+
826
+ // If file exists and doesn't end with newline, add one
827
+ if (fileExists && gitignoreContent && !gitignoreContent.endsWith('\n')) {
828
+ newContent += '\n';
829
+ }
830
+
831
+ // Add comment and entry
832
+ if (fileExists && gitignoreContent) {
833
+ newContent += '\n';
834
+ }
835
+ newContent += comment + '\n' + entryToAdd + '\n';
836
+
837
+ await fs.writeFile(gitignorePath, newContent);
838
+ console.log(`✅ Added '${entryToAdd}' to .gitignore`);
839
+ }
840
+ } catch (error) {
841
+ // Silently fail - don't break the snapshot process if gitignore update fails
842
+ console.warn(`⚠️ Warning: Could not update .gitignore: ${error.message}`);
843
+ }
844
+ }
845
+
846
+ // Helper function to determine if a string is a glob pattern
847
+ function isGlob(str) {
848
+ return str.includes('*') || str.includes('?') || str.includes('{');
849
+ }
850
+
851
+ /**
852
+ * Applies advanced profile filtering (multi-profile, exclusion, and ad-hoc globs) to a file list.
853
+ */
854
+ export async function applyProfileFilter(allFiles, profileString, repoPath) {
855
+ const profileParts = profileString.split(',').map(p => p.trim()).filter(Boolean);
856
+
857
+ const includeGlobs = [];
858
+ const excludeGlobs = [];
859
+ const includeNames = [];
860
+ const excludeNames = [];
861
+
862
+ // Step 1: Differentiate between profile names and ad-hoc glob patterns
863
+ for (const part of profileParts) {
864
+ const isNegative = part.startsWith('-');
865
+ const pattern = isNegative ? part.substring(1) : part;
866
+
867
+ if (isGlob(pattern)) {
868
+ if (isNegative) {
869
+ excludeGlobs.push(pattern);
870
+ } else {
871
+ includeGlobs.push(pattern);
872
+ }
873
+ } else {
874
+ if (isNegative) {
875
+ excludeNames.push(pattern);
876
+ } else {
877
+ includeNames.push(pattern);
878
+ }
879
+ }
880
+ }
881
+
882
+ let workingFiles = [];
883
+ let finalIncludes = [...includeGlobs];
884
+ let finalExcludes = [...excludeGlobs];
885
+
886
+ // Step 2: Load patterns from specified profile names
887
+ const allProfileNames = [...new Set([...includeNames, ...excludeNames])];
888
+ const profiles = new Map();
889
+ const notFoundProfiles = [];
890
+
891
+ for (const name of allProfileNames) {
892
+ const profile = await getProfile(name, repoPath);
893
+ if (profile) {
894
+ profiles.set(name, profile);
895
+ } else {
896
+ // This is an ad-hoc glob, not a profile, so no warning is needed.
897
+ if (!isGlob(name)) {
898
+ notFoundProfiles.push(name);
899
+ console.warn(`⚠️ Warning: Profile '${name}' not found and will be skipped.`);
900
+ }
901
+ }
902
+ }
903
+
904
+ for (const name of includeNames) {
905
+ if (profiles.has(name)) {
906
+ finalIncludes.push(...(profiles.get(name).include || []));
907
+ finalExcludes.push(...(profiles.get(name).exclude || []));
908
+ }
909
+ }
910
+ for (const name of excludeNames) {
911
+ if (profiles.has(name)) {
912
+ finalExcludes.push(...(profiles.get(name).include || []));
913
+ }
914
+ }
915
+
916
+ // Step 3: Apply the filtering logic
917
+ if (finalIncludes.length > 0) {
918
+ workingFiles = micromatch(allFiles, finalIncludes);
919
+ } else if (includeNames.length > 0 && includeGlobs.length === 0) {
920
+ workingFiles = [];
921
+ } else {
922
+ workingFiles = allFiles;
923
+ }
924
+
925
+ if (finalExcludes.length > 0) {
926
+ workingFiles = micromatch.not(workingFiles, finalExcludes);
927
+ }
928
+
929
+ return {
930
+ files: workingFiles,
931
+ notFoundProfiles,
932
+ foundProfiles: Array.from(profiles.keys())
933
+ };
934
+ }
935
+
936
+ /**
937
+ * Automatically initializes the .eck manifest directory, attempting dynamic generation via Claude.
938
+ * @param {string} projectPath - Path to the project
939
+ */
940
+ export async function initializeEckManifest(projectPath) {
941
+ const eckDir = path.join(projectPath, '.eck');
942
+
943
+ let setupConfig = null;
944
+ try {
945
+ setupConfig = await loadSetupConfig();
946
+ } catch (error) {
947
+ // If setup config fails to load, continue with defaults
948
+ }
949
+
950
+ try {
951
+ // Check if .eck directory already exists and has all required files
952
+ let needsInitialization = false;
953
+ try {
954
+ const eckStats = await fs.stat(eckDir);
955
+ if (eckStats.isDirectory()) {
956
+ // Directory exists, check if all required files are present
957
+ const requiredFiles = ['ENVIRONMENT.md', 'CONTEXT.md', 'OPERATIONS.md', 'JOURNAL.md'];
958
+ for (const fileName of requiredFiles) {
959
+ try {
960
+ await fs.stat(path.join(eckDir, fileName));
961
+ } catch (error) {
962
+ console.log(` ℹ️ Missing ${fileName}, initialization needed`);
963
+ needsInitialization = true;
964
+ break;
965
+ }
966
+ }
967
+ if (!needsInitialization) {
968
+ // All files exist, no need to initialize
969
+ return;
970
+ }
971
+ }
972
+ } catch (error) {
973
+ // Directory doesn't exist, we'll create it
974
+ needsInitialization = true;
975
+ }
976
+
977
+ // Create .eck directory
978
+ await fs.mkdir(eckDir, { recursive: true });
979
+ console.log('📋 Initializing .eck manifest directory...');
980
+
981
+ // Gather basic project info for stub templates
982
+ let staticFacts = {};
983
+ try {
984
+ staticFacts = await detectProjectType(projectPath);
985
+ } catch (e) {
986
+ // Non-critical
987
+ }
988
+
989
+ const projName = setupConfig?.projectContext?.name || path.basename(projectPath) || 'project';
990
+ const projType = setupConfig?.projectContext?.type || staticFacts.type || 'unknown';
991
+ const projStack = (setupConfig?.projectContext?.architecture?.stack?.length > 0)
992
+ ? setupConfig.projectContext.architecture.stack.join(', ')
993
+ : 'TBD';
994
+
995
+ // Static stub templates — AI agents fill these in via their AGENTS.md/CLAUDE.md instructions
996
+ const templateConfigs = {
997
+ 'ENVIRONMENT.md': {
998
+ content: `project_type: ${projType}\nagent_id: local_dev\n`
999
+ },
1000
+ 'CONTEXT.md': {
1001
+ content: `# [STUB: CONTEXT.md]\n\nProject: ${projName} | Type: ${projType} | Stack: ${projStack}\n\nAI Agent: please update this file based on your analysis of the codebase.\n`
1002
+ },
1003
+ 'OPERATIONS.md': {
1004
+ content: `# [STUB: OPERATIONS.md]\n\nAI Agent: please update with actual setup, run, and test commands.\n\n## Setup\n${staticFacts.type === 'nodejs' ? '\\`\\`\\`bash\\nnpm install\\n\\`\\`\\`' : 'TBD'}\n`
1005
+ },
1006
+ 'ROADMAP.md': {
1007
+ content: `# [STUB: ROADMAP.md]\n\nAI Agent: please set a real roadmap based on project goals.\n`
1008
+ },
1009
+ 'TECH_DEBT.md': {
1010
+ content: `# [STUB: TECH_DEBT.md]\n\nAI Agent: please scan for TODOs/FIXMEs or structural issues and list them here.\n`
1011
+ },
1012
+ 'DEPLOY_CHECKLIST.md': {
1013
+ content: `# [STUB: DEPLOY_CHECKLIST.md]\n\n- [ ] Verify all tests pass\n- [ ] Build assets\n- [ ] Check environment variables\n\nAI Agent: please update with actual deploy steps.\n`
1014
+ },
1015
+ 'RUNTIME_STATE.md': {
1016
+ content: `# [STUB: RUNTIME_STATE.md]\n\nAI Agent: check actual runtime state (ports, processes, env vars) and update this file.\n\n- **Server:** TBD\n- **Services:** TBD\n`
1017
+ },
1018
+ 'JOURNAL.md': {
1019
+ content: `# Development Journal\n\n## Recent Changes\n---\ntype: feat\nscope: project\nsummary: Initial manifest generated (PENDING REVIEW)\ndate: ${new Date().toISOString().split('T')[0]}\n---\n- NOTICE: Some .eck files are STUBS. They need manual or AI-assisted verification.\n`
1020
+ }
1021
+ };
1022
+
1023
+ // Create each template file (only if it doesn't exist)
1024
+ for (const [fileName, config] of Object.entries(templateConfigs)) {
1025
+ const filePath = path.join(eckDir, fileName);
1026
+
1027
+ // Skip if file already exists
1028
+ try {
1029
+ await fs.stat(filePath);
1030
+ console.log(` ✅ ${fileName} already exists, skipping`);
1031
+ continue;
1032
+ } catch (error) {
1033
+ // File doesn't exist, create it
1034
+ }
1035
+
1036
+ await fs.writeFile(filePath, config.content);
1037
+ console.log(` ✅ Created ${fileName} (stub template)`);
1038
+ }
1039
+
1040
+ console.log('📋 .eck manifest initialized! Edit the files to provide project-specific context.');
1041
+
1042
+ } catch (error) {
1043
+ // Silently fail - don't break the snapshot process if manifest initialization fails
1044
+ console.warn(`⚠️ Warning: Could not initialize .eck manifest: ${error.message}`);
1045
+ }
1046
+ }
1047
+
1048
+
1049
+
1050
+ function isHiddenPath(filePath) {
1051
+ const parts = filePath.split('/');
1052
+ return parts.some(part => part.startsWith('.') && part !== '.eck');
1053
+ }
1054
+
1055
+ /**
1056
+ * Gets project files using git ls-files (preferred) or scanDirectoryRecursively as fallback.
1057
+ * Applies global hard-ignore filters and project-specific filtering.
1058
+ */
1059
+ export async function getProjectFiles(projectPath, config) {
1060
+ const isGitRepo = await checkGitRepository(projectPath);
1061
+ if (isGitRepo) {
1062
+ const { stdout } = await execa('git', ['ls-files'], { cwd: projectPath });
1063
+ const gitFiles = stdout.split('\n').filter(Boolean);
1064
+
1065
+ const dirsToIgnore = [...GLOBAL_HARD_IGNORE_DIRS, ...(config.dirsToIgnore || []).map(d => d.replace(/\/$/, ''))];
1066
+ const filesToIgnore = [...GLOBAL_HARD_IGNORE_FILES, ...(config.filesToIgnore || [])];
1067
+ const extensionsToIgnore = config.extensionsToIgnore || [];
1068
+
1069
+ const filteredFiles = gitFiles.filter(file => {
1070
+ if (isHiddenPath(file)) return false;
1071
+ const fileName = file.split('/').pop();
1072
+ const fileExt = path.extname(fileName);
1073
+ const pathParts = file.split('/');
1074
+ for (let i = 0; i < pathParts.length - 1; i++) {
1075
+ if (dirsToIgnore.includes(pathParts[i])) return false;
1076
+ }
1077
+ if (filesToIgnore.includes(fileName)) return false;
1078
+ if (fileExt && extensionsToIgnore.includes(fileExt)) return false;
1079
+ return true;
1080
+ });
1081
+ return filteredFiles;
1082
+ }
1083
+ return scanDirectoryRecursively(projectPath, config);
1084
+ }