@xelth/eck-snapshot 2.1.0 → 2.2.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.
Files changed (3) hide show
  1. package/README.md +92 -7
  2. package/index.js +168 -62
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,23 +1,27 @@
1
1
  # eck-snapshot
2
2
 
3
- [](https://www.google.com/search?q=https://www.npmjs.com/package/%40xelth/eck-snapshot)
4
- [](https://www.google.com/search?q=https://github.com/xelth-com/eckSnapshot/blob/main/LICENSE)
3
+ [![npm version](https://badge.fury.io/js/%40xelth%2Feck-snapshot.svg)](https://www.npmjs.com/package/@xelth/eck-snapshot)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/xelth-com/eckSnapshot/blob/main/LICENSE)
5
5
 
6
6
  A CLI tool to create and restore single-file text snapshots of a Git repository. It generates a single `.txt` file containing the directory structure and the content of all text-based files, which is ideal for providing context to Large Language Models (LLMs).
7
7
 
8
8
  ## Why eck-snapshot?
9
9
 
10
- When working with LLMs like Claude or GPT-4, you often need to provide the full context of your project. Manually copying and pasting dozens of files is tedious and inefficient. This tool automates the process by creating a single, comprehensive file that you can easily feed into an LLM. And with the new `restore` feature, you can instantly recreate a project structure from a snapshot.
10
+ When working with Large Language Models (LLMs), providing the full context of your project is crucial for getting accurate results. Manually copying and pasting dozens of files is tedious and inefficient.
11
+
12
+ eck-snapshot automates this by generating a single, comprehensive text file of your entire repository. This is particularly effective with models that support large context windows (like Google's Gemini), as it often allows the entire project snapshot to be analyzed at once—a task that can be challenging with smaller context windows.
11
13
 
12
14
  ## Key Features
13
15
 
14
16
  * **Git Integration**: Automatically includes all files tracked by Git.
15
17
  * **Intelligent Ignoring**: Respects `.gitignore` rules and has its own configurable ignore lists for files, extensions, and directories.
16
- * **Restore from Snapshot**: The new `restore` command allows you to recreate files and folders from a snapshot file.
18
+ * **Advanced Restore**: Powerful `restore` command with filtering, dry-run mode, and parallel processing.
17
19
  * **Directory Tree**: Generates a clean, readable tree of the repository structure at the top of the snapshot.
20
+ * **Multiple Formats**: Supports both plain text and JSON output formats.
18
21
  * **Configurable**: Customize behavior using an `.ecksnapshot.config.js` file.
19
22
  * **Progress and Stats**: Provides a progress bar and a detailed summary of what was included and skipped.
20
23
  * **Compression**: Supports gzipped (`.gz`) snapshots for smaller file sizes.
24
+ * **Security**: Built-in path validation to prevent directory traversal attacks during restore.
21
25
 
22
26
  ## Demo
23
27
 
@@ -95,16 +99,37 @@ eck-snapshot /path/to/your/other/project
95
99
 
96
100
  # Save the snapshot to a different directory and exclude the tree view
97
101
  eck-snapshot --output ./backups --no-tree
102
+
103
+ # Create a compressed JSON snapshot
104
+ eck-snapshot --format json --compress
105
+
106
+ # Include hidden files and set custom size limits
107
+ eck-snapshot --include-hidden --max-file-size 5MB --max-total-size 50MB
98
108
  ```
99
109
 
100
110
  ### Restoring from a Snapshot
101
111
 
102
112
  ```bash
103
- # Restore files from a snapshot into the current directory
113
+ # Basic restore to current directory
104
114
  eck-snapshot restore ./snapshots/project_snapshot_...txt
105
115
 
106
- # Restore into a new directory without a confirmation prompt
116
+ # Restore to a specific directory without confirmation
107
117
  eck-snapshot restore snapshot.txt ./restored-project --force
118
+
119
+ # Preview what would be restored (dry run)
120
+ eck-snapshot restore snapshot.txt --dry-run
121
+
122
+ # Restore only specific files using patterns
123
+ eck-snapshot restore snapshot.txt --include "*.js" "*.json"
124
+
125
+ # Restore everything except certain files
126
+ eck-snapshot restore snapshot.txt --exclude "*.log" "node_modules/*"
127
+
128
+ # Restore with custom concurrency and verbose output
129
+ eck-snapshot restore snapshot.txt --concurrency 20 --verbose
130
+
131
+ # Restore compressed snapshots
132
+ eck-snapshot restore project_snapshot.txt.gz ./restored
108
133
  ```
109
134
 
110
135
  ## Configuration
@@ -131,11 +156,71 @@ export default {
131
156
  '.git/',
132
157
  'dist/',
133
158
  ],
134
- // Maximum size for individual files
159
+ // Size and performance settings
135
160
  maxFileSize: '10MB',
161
+ maxTotalSize: '100MB',
162
+ maxDepth: 10,
163
+ concurrency: 10
136
164
  };
137
165
  ```
138
166
 
167
+ ## Advanced Features
168
+
169
+ ### Restore Command Options
170
+
171
+ The restore command offers powerful filtering and control options:
172
+
173
+ - **`--dry-run`**: Preview what files would be restored without actually writing them
174
+ - **`--include <patterns>`**: Only restore files matching the specified patterns (supports wildcards)
175
+ - **`--exclude <patterns>`**: Skip files matching the specified patterns (supports wildcards)
176
+ - **`--concurrency <number>`**: Control how many files are processed simultaneously (default: 10)
177
+ - **`--force`**: Skip confirmation prompts and overwrite existing files
178
+ - **`--verbose`**: Show detailed information about each file being processed
179
+
180
+ ### Supported Formats
181
+
182
+ - **Plain Text** (`.txt`): Human-readable format, ideal for LLM context
183
+ - **JSON** (`.json`): Structured format with metadata and statistics
184
+ - **Compressed** (`.gz`): Any format can be gzipped for smaller file sizes
185
+
186
+ ### Security Features
187
+
188
+ - **Path Validation**: Prevents directory traversal attacks during restore operations
189
+ - **File Sanitization**: Validates file paths and names for security
190
+ - **Confirmation Prompts**: Requires user confirmation before overwriting files (unless `--force` is used)
191
+
192
+ ## Command Reference
193
+
194
+ ### Snapshot Command
195
+ ```bash
196
+ eck-snapshot [options] [repoPath]
197
+ ```
198
+
199
+ **Options:**
200
+ - `-o, --output <dir>`: Output directory for snapshots
201
+ - `--no-tree`: Exclude directory tree from output
202
+ - `-v, --verbose`: Show detailed processing information
203
+ - `--max-file-size <size>`: Maximum individual file size (e.g., 10MB)
204
+ - `--max-total-size <size>`: Maximum total snapshot size (e.g., 100MB)
205
+ - `--max-depth <number>`: Maximum directory depth for tree generation
206
+ - `--config <path>`: Path to custom configuration file
207
+ - `--compress`: Create gzipped output
208
+ - `--include-hidden`: Include hidden files (starting with .)
209
+ - `--format <type>`: Output format: txt or json
210
+
211
+ ### Restore Command
212
+ ```bash
213
+ eck-snapshot restore [options] <snapshot_file> [target_directory]
214
+ ```
215
+
216
+ **Options:**
217
+ - `-f, --force`: Force overwrite without confirmation
218
+ - `-v, --verbose`: Show detailed processing information
219
+ - `--dry-run`: Preview without actually writing files
220
+ - `--include <patterns>`: Include only matching files
221
+ - `--exclude <patterns>`: Exclude matching files
222
+ - `--concurrency <number>`: Number of concurrent operations
223
+
139
224
  ## License
140
225
 
141
226
  This project is licensed under the MIT License.
package/index.js CHANGED
@@ -509,115 +509,175 @@ async function createRepoSnapshot(repoPath, options) {
509
509
  }
510
510
  }
511
511
 
512
- // NEW: Restore Snapshot Function
513
512
  async function restoreSnapshot(snapshotFile, targetDir, options) {
514
513
  const absoluteSnapshotPath = path.resolve(snapshotFile);
515
514
  const absoluteTargetDir = path.resolve(targetDir);
516
515
 
517
- console.log(`⚙️ Starting restore from snapshot: ${absoluteSnapshotPath}`);
518
- console.log(`🗂️ Target directory: ${absoluteTargetDir}`);
516
+ console.log(`🔄 Starting restore from snapshot: ${absoluteSnapshotPath}`);
517
+ console.log(`📁 Target directory: ${absoluteTargetDir}`);
519
518
 
520
519
  try {
521
520
  let rawContent;
522
- // Check if the file is compressed
523
521
  if (snapshotFile.endsWith('.gz')) {
524
- const compressedBuffer = await fs.readFile(absoluteSnapshotPath);
525
- rawContent = (await gunzip(compressedBuffer)).toString('utf-8');
526
- console.log('✅ Decompressed gzipped snapshot.');
522
+ const compressedBuffer = await fs.readFile(absoluteSnapshotPath);
523
+ rawContent = (await gunzip(compressedBuffer)).toString('utf-8');
524
+ console.log('✅ Decompressed gzipped snapshot');
527
525
  } else {
528
- rawContent = await fs.readFile(absoluteSnapshotPath, 'utf-8');
526
+ rawContent = await fs.readFile(absoluteSnapshotPath, 'utf-8');
529
527
  }
530
528
 
531
- // Check if the content is JSON
532
529
  let filesToRestore;
533
530
  try {
534
- const jsonData = JSON.parse(rawContent);
535
- if (jsonData.content) {
536
- console.log('📄 Detected JSON format, extracting content.');
537
- filesToRestore = parseSnapshotContent(jsonData.content);
538
- } else {
539
- throw new Error('JSON format detected, but no "content" key found.');
540
- }
531
+ const jsonData = JSON.parse(rawContent);
532
+ if (jsonData.content) {
533
+ console.log('📄 Detected JSON format, extracting content');
534
+ filesToRestore = parseSnapshotContent(jsonData.content);
535
+ } else {
536
+ throw new Error('JSON format detected, but no "content" key found');
537
+ }
541
538
  } catch (e) {
542
- // Not a JSON file, or not the format we expect. Treat as plain text.
543
- console.log('📄 Treating snapshot as plain text format.');
544
- filesToRestore = parseSnapshotContent(rawContent);
539
+ console.log('📄 Treating snapshot as plain text format');
540
+ filesToRestore = parseSnapshotContent(rawContent);
545
541
  }
546
542
 
547
543
  if (filesToRestore.length === 0) {
548
- console.warn('⚠️ No files found to restore in the snapshot.');
544
+ console.warn('⚠️ No files found to restore in the snapshot');
549
545
  return;
550
546
  }
551
547
 
552
- console.log(`📊 Found ${filesToRestore.length} files in the snapshot.`);
548
+ // Apply filters if specified
549
+ if (options.include || options.exclude) {
550
+ filesToRestore = filterFilesToRestore(filesToRestore, options);
551
+ if (filesToRestore.length === 0) {
552
+ console.warn('⚠️ No files remaining after applying filters');
553
+ return;
554
+ }
555
+ }
556
+
557
+ // Validate file paths for security
558
+ const invalidFiles = validateFilePaths(filesToRestore, absoluteTargetDir);
559
+ if (invalidFiles.length > 0) {
560
+ console.error('❌ Invalid file paths detected (potential directory traversal):');
561
+ invalidFiles.forEach(file => console.error(` ${file}`));
562
+ process.exit(1);
563
+ }
564
+
565
+ console.log(`📊 Found ${filesToRestore.length} files to restore`);
566
+
567
+ if (options.dryRun) {
568
+ console.log('\n🔍 Dry run mode - files that would be restored:');
569
+ filesToRestore.forEach(file => {
570
+ const fullPath = path.join(absoluteTargetDir, file.path);
571
+ console.log(` ${fullPath}`);
572
+ });
573
+ return;
574
+ }
553
575
 
554
- // Confirmation prompt
555
576
  if (!options.force) {
556
- const { confirm } = await inquirer.prompt([
557
- {
558
- type: 'confirm',
559
- name: 'confirm',
560
- message: `You are about to write ${filesToRestore.length} files to ${absoluteTargetDir}. Existing files will be overwritten. Are you sure you want to continue?`,
561
- default: false,
562
- },
563
- ]);
577
+ const { confirm } = await inquirer.prompt([{
578
+ type: 'confirm',
579
+ name: 'confirm',
580
+ message: `You are about to write ${filesToRestore.length} files to ${absoluteTargetDir}. Existing files will be overwritten. Continue?`,
581
+ default: false
582
+ }]);
564
583
 
565
584
  if (!confirm) {
566
- console.log('🚫 Restore operation cancelled by user.');
585
+ console.log('🚫 Restore operation cancelled by user');
567
586
  return;
568
587
  }
569
588
  }
570
589
 
571
- // Create target directory if it doesn't exist
572
590
  await fs.mkdir(absoluteTargetDir, { recursive: true });
573
591
 
574
- // Restore files
575
- const progressBar = new SingleBar({
576
- format: 'Restoring |{bar}| {percentage}% | {value}/{total} files',
577
- barCompleteChar: '\u2588',
578
- barIncompleteChar: '\u2591',
579
- hideCursor: true
592
+ const stats = {
593
+ totalFiles: filesToRestore.length,
594
+ restoredFiles: 0,
595
+ failedFiles: 0,
596
+ errors: []
597
+ };
598
+
599
+ const progressBar = options.verbose ? null : new SingleBar({
600
+ format: 'Restoring |{bar}| {percentage}% | {value}/{total} files',
601
+ barCompleteChar: '\u2588',
602
+ barIncompleteChar: '\u2591',
603
+ hideCursor: true
580
604
  }, Presets.shades_classic);
581
605
 
582
- progressBar.start(filesToRestore.length, 0);
606
+ if (progressBar) progressBar.start(filesToRestore.length, 0);
583
607
 
584
- for (const file of filesToRestore) {
585
- const fullPath = path.join(absoluteTargetDir, file.path);
586
- const dir = path.dirname(fullPath);
608
+ const limit = pLimit(options.concurrency || 10);
609
+ const filePromises = filesToRestore.map((file, index) =>
610
+ limit(async () => {
611
+ try {
612
+ const fullPath = path.join(absoluteTargetDir, file.path);
613
+ const dir = path.dirname(fullPath);
587
614
 
588
- // Create directory for the file
589
- await fs.mkdir(dir, { recursive: true });
590
- // Write the file content
591
- await fs.writeFile(fullPath, file.content);
592
- progressBar.increment();
593
- }
615
+ await fs.mkdir(dir, { recursive: true });
616
+ await fs.writeFile(fullPath, file.content, 'utf-8');
617
+
618
+ stats.restoredFiles++;
619
+
620
+ if (progressBar) {
621
+ progressBar.update(index + 1);
622
+ } else if (options.verbose) {
623
+ console.log(`✅ Restored: ${file.path}`);
624
+ }
625
+
626
+ return { success: true, file: file.path };
627
+ } catch (error) {
628
+ stats.failedFiles++;
629
+ stats.errors.push({ file: file.path, error: error.message });
630
+
631
+ if (options.verbose) {
632
+ console.log(`❌ Failed to restore: ${file.path} - ${error.message}`);
633
+ }
634
+
635
+ return { success: false, file: file.path, error: error.message };
636
+ }
637
+ })
638
+ );
594
639
 
595
- progressBar.stop();
596
- console.log(`\n🎉 Restore complete! ${filesToRestore.length} files have been written to ${absoluteTargetDir}.`);
640
+ await Promise.allSettled(filePromises);
641
+ if (progressBar) progressBar.stop();
642
+
643
+ console.log('\n📊 Restore Summary');
644
+ console.log('='.repeat(50));
645
+ console.log(`🎉 Restore completed!`);
646
+ console.log(`✅ Successfully restored: ${stats.restoredFiles} files`);
647
+ if (stats.failedFiles > 0) {
648
+ console.log(`❌ Failed to restore: ${stats.failedFiles} files`);
649
+ if (stats.errors.length > 0) {
650
+ console.log('\n⚠️ Errors encountered:');
651
+ stats.errors.slice(0, 5).forEach(({ file, error }) => {
652
+ console.log(` ${file}: ${error}`);
653
+ });
654
+ if (stats.errors.length > 5) {
655
+ console.log(` ... and ${stats.errors.length - 5} more errors`);
656
+ }
657
+ }
658
+ }
659
+ console.log(`📁 Target directory: ${absoluteTargetDir}`);
660
+ console.log('='.repeat(50));
597
661
 
598
662
  } catch (error) {
599
663
  console.error('\n❌ An error occurred during restore:');
600
664
  console.error(error.message);
601
665
  if (options.verbose) {
602
- console.error(error.stack);
666
+ console.error(error.stack);
603
667
  }
604
668
  process.exit(1);
605
669
  }
606
670
  }
607
671
 
608
- // NEW: Snapshot Parser Function
609
672
  function parseSnapshotContent(content) {
610
673
  const files = [];
611
674
  const fileRegex = /--- File: \/(.+) ---/g;
612
675
  const sections = content.split(fileRegex);
613
676
 
614
- // sections will be [ '...everything before first match...', 'path1', 'content1', 'path2', 'content2', ...]
615
- // We start at index 1 because index 0 is anything before the first delimiter (like the directory tree)
616
677
  for (let i = 1; i < sections.length; i += 2) {
617
678
  const filePath = sections[i].trim();
618
679
  let fileContent = sections[i + 1] || '';
619
680
 
620
- // Remove the trailing newline that the split might leave
621
681
  if (fileContent.startsWith('\n\n')) {
622
682
  fileContent = fileContent.substring(2);
623
683
  }
@@ -631,6 +691,49 @@ function parseSnapshotContent(content) {
631
691
  return files;
632
692
  }
633
693
 
694
+ function filterFilesToRestore(files, options) {
695
+ let filtered = files;
696
+
697
+ if (options.include) {
698
+ const includePatterns = Array.isArray(options.include) ? options.include : [options.include];
699
+ filtered = filtered.filter(file =>
700
+ includePatterns.some(pattern => {
701
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
702
+ return regex.test(file.path);
703
+ })
704
+ );
705
+ }
706
+
707
+ if (options.exclude) {
708
+ const excludePatterns = Array.isArray(options.exclude) ? options.exclude : [options.exclude];
709
+ filtered = filtered.filter(file =>
710
+ !excludePatterns.some(pattern => {
711
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
712
+ return regex.test(file.path);
713
+ })
714
+ );
715
+ }
716
+
717
+ return filtered;
718
+ }
719
+
720
+ function validateFilePaths(files, targetDir) {
721
+ const invalidFiles = [];
722
+
723
+ for (const file of files) {
724
+ const normalizedPath = path.normalize(file.path);
725
+
726
+ if (normalizedPath.includes('..') ||
727
+ normalizedPath.startsWith('/') ||
728
+ normalizedPath.includes('\0') ||
729
+ /[<>:"|?*]/.test(normalizedPath)) {
730
+ invalidFiles.push(file.path);
731
+ }
732
+ }
733
+
734
+ return invalidFiles;
735
+ }
736
+
634
737
 
635
738
  // --- CLI SETUP ---
636
739
  const program = new Command();
@@ -657,14 +760,17 @@ program
657
760
  .option('--format <type>', 'Output format: txt, json', 'txt')
658
761
  .action((repoPath, options) => createRepoSnapshot(repoPath, options));
659
762
 
660
- // NEW: Restore command (Corrected)
661
763
  program
662
- .command('restore') // Убираем аргументы отсюда
663
- .description('Restore files and directories from a snapshot file.')
664
- .argument('<snapshot_file>', 'Path to the snapshot file (.txt, .json, or .gz).') // Аргументы определяем здесь
665
- .argument('[target_directory]', 'Directory to restore the files into.', process.cwd())
666
- .option('-f, --force', 'Force overwrite of existing files without confirmation.')
667
- .option('-v, --verbose', 'Show detailed processing information.')
764
+ .command('restore')
765
+ .description('Restore files and directories from a snapshot file')
766
+ .argument('<snapshot_file>', 'Path to the snapshot file (.txt, .json, or .gz)')
767
+ .argument('[target_directory]', 'Directory to restore the files into', process.cwd())
768
+ .option('-f, --force', 'Force overwrite of existing files without confirmation')
769
+ .option('-v, --verbose', 'Show detailed processing information')
770
+ .option('--dry-run', 'Show what would be restored without actually writing files')
771
+ .option('--include <patterns...>', 'Include only files matching these patterns (supports wildcards)')
772
+ .option('--exclude <patterns...>', 'Exclude files matching these patterns (supports wildcards)')
773
+ .option('--concurrency <number>', 'Number of concurrent file operations', (val) => parseInt(val), 10)
668
774
  .action((snapshotFile, targetDir, options) => restoreSnapshot(snapshotFile, targetDir, options));
669
775
 
670
776
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xelth/eck-snapshot",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "A CLI tool to create and restore single-file text snapshots of a Git repository.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -31,4 +31,4 @@
31
31
  "p-limit": "^5.0.0",
32
32
  "inquirer": "^9.2.20"
33
33
  }
34
- }
34
+ }