repo-cloak-cli 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repo-cloak-cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "🎭 Selectively extract and anonymize files from repositories. Perfect for sharing code with AI agents without exposing proprietary details.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "scripts": {
12
12
  "start": "node bin/repo-cloak.js",
13
- "test": "node --test",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
14
15
  "lint": "eslint src/"
15
16
  },
16
17
  "keywords": [
@@ -43,5 +44,8 @@
43
44
  "glob": "^10.3.10",
44
45
  "inquirer": "^9.2.12",
45
46
  "ora": "^8.0.1"
47
+ },
48
+ "devDependencies": {
49
+ "vitest": "^4.0.18"
46
50
  }
47
51
  }
@@ -19,7 +19,7 @@ import { showSuccess, showError, showInfo } from '../ui/banner.js';
19
19
  import { getAllFiles } from '../core/scanner.js';
20
20
  import { copyFiles } from '../core/copier.js';
21
21
  import { createAnonymizer } from '../core/anonymizer.js';
22
- import { createMapping, saveMapping } from '../core/mapper.js';
22
+ import { createMapping, saveMapping, loadRawMapping, mergeMapping, hasMapping } from '../core/mapper.js';
23
23
 
24
24
  export async function pull(options = {}) {
25
25
  try {
@@ -106,28 +106,49 @@ export async function pull(options = {}) {
106
106
  });
107
107
  }
108
108
 
109
- // Step 8: Save mapping file with original->anonymized path tracking
110
- const mapping = createMapping({
111
- sourceDir,
112
- destDir,
113
- replacements,
114
- files: selectedFiles.map(f => {
115
- const originalPath = relative(sourceDir, f);
116
- // Apply same anonymization logic used in copier
117
- let anonymizedPath = originalPath;
118
- for (const { original, replacement } of replacements) {
119
- const regex = new RegExp(original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
120
- anonymizedPath = anonymizedPath.replace(regex, replacement);
121
- }
122
- return {
123
- original: originalPath,
124
- cloaked: anonymizedPath
125
- };
126
- })
109
+ // Step 8: Prepare new file mappings
110
+ const newFiles = selectedFiles.map(f => {
111
+ const originalPath = relative(sourceDir, f);
112
+ let anonymizedPath = originalPath;
113
+ for (const { original, replacement } of replacements) {
114
+ const regex = new RegExp(original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
115
+ anonymizedPath = anonymizedPath.replace(regex, replacement);
116
+ }
117
+ return {
118
+ original: originalPath,
119
+ cloaked: anonymizedPath
120
+ };
127
121
  });
128
122
 
123
+ // Step 9: Check for existing mapping and merge if found
124
+ const existingMapping = loadRawMapping(destDir);
125
+ let mapping;
126
+ let isIncremental = false;
127
+
128
+ if (existingMapping) {
129
+ // Merge with existing
130
+ mapping = mergeMapping(existingMapping, newFiles);
131
+ isIncremental = true;
132
+ console.log(chalk.cyan(` 🔄 Merged with existing mapping (incremental pull)`));
133
+ } else {
134
+ // Create new mapping
135
+ mapping = createMapping({
136
+ sourceDir,
137
+ destDir,
138
+ replacements,
139
+ files: newFiles
140
+ });
141
+ }
142
+
129
143
  const mapPath = saveMapping(destDir, mapping);
130
- console.log(chalk.dim(` 📋 Mapping saved: ${mapPath}`));
144
+
145
+ if (isIncremental) {
146
+ const history = mapping.pullHistory || [];
147
+ const lastPull = history[history.length - 1];
148
+ console.log(chalk.dim(` 📋 Mapping updated: ${lastPull?.filesAdded || 0} new files added (total: ${mapping.stats?.totalFiles})`));
149
+ } else {
150
+ console.log(chalk.dim(` 📋 Mapping saved: ${mapPath}`));
151
+ }
131
152
 
132
153
  // Done!
133
154
  showSuccess('Extraction complete!');
@@ -177,3 +177,59 @@ export function updateMapping(destDir, updates) {
177
177
  writeFileSync(mapPath, JSON.stringify(updated, null, 2), 'utf-8');
178
178
  return updated;
179
179
  }
180
+
181
+ /**
182
+ * Load existing raw mapping (without decryption) for merging
183
+ */
184
+ export function loadRawMapping(cloakedDir) {
185
+ const mapPath = join(cloakedDir, MAP_FILENAME);
186
+
187
+ if (!existsSync(mapPath)) {
188
+ return null;
189
+ }
190
+
191
+ try {
192
+ const content = readFileSync(mapPath, 'utf-8');
193
+ return JSON.parse(content);
194
+ } catch (error) {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Merge new files into existing mapping (for incremental pulls)
201
+ */
202
+ export function mergeMapping(existingMapping, newFiles) {
203
+ // Get existing file paths for deduplication
204
+ const existingPaths = new Set(
205
+ (existingMapping.files || []).map(f => f.cloaked)
206
+ );
207
+
208
+ // Filter out files that already exist (avoid duplicates)
209
+ const uniqueNewFiles = newFiles.filter(f => !existingPaths.has(f.cloaked));
210
+
211
+ // Merge files
212
+ const mergedFiles = [
213
+ ...(existingMapping.files || []),
214
+ ...uniqueNewFiles
215
+ ];
216
+
217
+ // Track pull history
218
+ const pullHistory = existingMapping.pullHistory || [];
219
+ pullHistory.push({
220
+ timestamp: new Date().toISOString(),
221
+ filesAdded: uniqueNewFiles.length,
222
+ totalFiles: mergedFiles.length
223
+ });
224
+
225
+ return {
226
+ ...existingMapping,
227
+ files: mergedFiles,
228
+ pullHistory,
229
+ stats: {
230
+ ...existingMapping.stats,
231
+ totalFiles: mergedFiles.length
232
+ },
233
+ updatedAt: new Date().toISOString()
234
+ };
235
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Anonymizer Tests
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { createAnonymizer, createDeanonymizer } from '../src/core/anonymizer.js';
7
+
8
+ describe('Anonymizer', () => {
9
+ describe('createAnonymizer', () => {
10
+ it('should replace exact matches', () => {
11
+ const anonymizer = createAnonymizer([
12
+ { original: 'Cuviva', replacement: 'ABCCompany' }
13
+ ]);
14
+
15
+ // Title case input -> Title case output (first letter upper, rest lower)
16
+ expect(anonymizer('Hello Cuviva world')).toBe('Hello Abccompany world');
17
+ });
18
+
19
+ it('should handle all uppercase', () => {
20
+ const anonymizer = createAnonymizer([
21
+ { original: 'Cuviva', replacement: 'ABCCompany' }
22
+ ]);
23
+
24
+ expect(anonymizer('CUVIVA is great')).toBe('ABCCOMPANY is great');
25
+ });
26
+
27
+ it('should handle all lowercase', () => {
28
+ const anonymizer = createAnonymizer([
29
+ { original: 'Cuviva', replacement: 'ABCCompany' }
30
+ ]);
31
+
32
+ expect(anonymizer('cuviva is lower')).toBe('abccompany is lower');
33
+ });
34
+
35
+ it('should handle multiple replacements', () => {
36
+ const anonymizer = createAnonymizer([
37
+ { original: 'Cuviva', replacement: 'ABCCompany' },
38
+ { original: 'Frontend', replacement: 'Client' }
39
+ ]);
40
+
41
+ // Both are Title case -> first upper + rest lower
42
+ expect(anonymizer('Cuviva Frontend API')).toBe('Abccompany Client API');
43
+ });
44
+
45
+ it('should handle empty replacements', () => {
46
+ const anonymizer = createAnonymizer([]);
47
+ expect(anonymizer('Hello world')).toBe('Hello world');
48
+ });
49
+
50
+ it('should handle special regex characters in original', () => {
51
+ const anonymizer = createAnonymizer([
52
+ { original: 'test.value', replacement: 'replaced' }
53
+ ]);
54
+
55
+ // Title case preservation
56
+ expect(anonymizer('This is Test.value here')).toBe('This is Replaced here');
57
+ });
58
+
59
+ it('should handle null or undefined replacements', () => {
60
+ const anonymizer = createAnonymizer(null);
61
+ expect(anonymizer('Hello world')).toBe('Hello world');
62
+ });
63
+ });
64
+
65
+ describe('createDeanonymizer', () => {
66
+ it('should reverse the anonymization', () => {
67
+ const replacements = [
68
+ { original: 'Cuviva', replacement: 'ABCCompany' }
69
+ ];
70
+
71
+ const deanonymizer = createDeanonymizer(replacements);
72
+
73
+ // ABCCompany (Title case) -> Cuviva (Title case: first upper + rest lower)
74
+ expect(deanonymizer('Hello ABCCompany world')).toBe('Hello Cuviva world');
75
+ });
76
+
77
+ it('should handle multiple replacements in reverse', () => {
78
+ const replacements = [
79
+ { original: 'Cuviva', replacement: 'ABCCompany' },
80
+ { original: 'API', replacement: 'Service' }
81
+ ];
82
+
83
+ const deanonymizer = createDeanonymizer(replacements);
84
+
85
+ // Title case -> Title case for both
86
+ expect(deanonymizer('ABCCompany Service')).toBe('Cuviva Api');
87
+ });
88
+
89
+ it('should handle uppercase in reverse', () => {
90
+ const replacements = [
91
+ { original: 'Cuviva', replacement: 'ABCCompany' }
92
+ ];
93
+
94
+ const deanonymizer = createDeanonymizer(replacements);
95
+
96
+ expect(deanonymizer('ABCCOMPANY')).toBe('CUVIVA');
97
+ });
98
+
99
+ it('should handle lowercase in reverse', () => {
100
+ const replacements = [
101
+ { original: 'Cuviva', replacement: 'ABCCompany' }
102
+ ];
103
+
104
+ const deanonymizer = createDeanonymizer(replacements);
105
+
106
+ expect(deanonymizer('abccompany')).toBe('cuviva');
107
+ });
108
+ });
109
+ });
110
+
111
+ describe('Case transformation', () => {
112
+ it('should preserve all uppercase', () => {
113
+ const anonymizer = createAnonymizer([
114
+ { original: 'SECRET', replacement: 'PUBLIC' }
115
+ ]);
116
+
117
+ expect(anonymizer('This is SECRET data')).toBe('This is PUBLIC data');
118
+ });
119
+
120
+ it('should preserve all lowercase', () => {
121
+ const anonymizer = createAnonymizer([
122
+ { original: 'secret', replacement: 'public' }
123
+ ]);
124
+
125
+ expect(anonymizer('this is secret data')).toBe('this is public data');
126
+ });
127
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Copier Tests
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { copyFile, copyFileWithTransform } from '../src/core/copier.js';
7
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { tmpdir } from 'os';
10
+
11
+ describe('Copier Module', () => {
12
+ let testDir;
13
+ let sourceDir;
14
+ let destDir;
15
+
16
+ beforeEach(() => {
17
+ testDir = join(tmpdir(), `repo-cloak-copier-test-${Date.now()}`);
18
+ sourceDir = join(testDir, 'source');
19
+ destDir = join(testDir, 'dest');
20
+ mkdirSync(sourceDir, { recursive: true });
21
+ mkdirSync(destDir, { recursive: true });
22
+ });
23
+
24
+ afterEach(() => {
25
+ if (existsSync(testDir)) {
26
+ rmSync(testDir, { recursive: true, force: true });
27
+ }
28
+ });
29
+
30
+ describe('copyFile', () => {
31
+ it('should copy a file to destination', () => {
32
+ const sourceFile = join(sourceDir, 'test.txt');
33
+ const destFile = join(destDir, 'test.txt');
34
+
35
+ writeFileSync(sourceFile, 'Hello World');
36
+ copyFile(sourceFile, destFile);
37
+
38
+ expect(existsSync(destFile)).toBe(true);
39
+ expect(readFileSync(destFile, 'utf-8')).toBe('Hello World');
40
+ });
41
+
42
+ it('should create nested directories', () => {
43
+ const sourceFile = join(sourceDir, 'test.txt');
44
+ const destFile = join(destDir, 'nested', 'deep', 'test.txt');
45
+
46
+ writeFileSync(sourceFile, 'Content');
47
+ copyFile(sourceFile, destFile);
48
+
49
+ expect(existsSync(destFile)).toBe(true);
50
+ });
51
+ });
52
+
53
+ describe('copyFileWithTransform', () => {
54
+ it('should transform content during copy', () => {
55
+ const sourceFile = join(sourceDir, 'code.js');
56
+ const destFile = join(destDir, 'code.js');
57
+
58
+ writeFileSync(sourceFile, 'const company = "Cuviva";');
59
+
60
+ const transform = (content) => content.replace(/Cuviva/g, 'ABCCompany');
61
+ const result = copyFileWithTransform(sourceFile, destFile, transform);
62
+
63
+ expect(result.transformed).toBe(true);
64
+ expect(readFileSync(destFile, 'utf-8')).toBe('const company = "ABCCompany";');
65
+ });
66
+
67
+ it('should report no transformation when content unchanged', () => {
68
+ const sourceFile = join(sourceDir, 'code.js');
69
+ const destFile = join(destDir, 'code.js');
70
+
71
+ writeFileSync(sourceFile, 'const x = 1;');
72
+
73
+ const transform = (content) => content.replace(/Cuviva/g, 'ABCCompany');
74
+ const result = copyFileWithTransform(sourceFile, destFile, transform);
75
+
76
+ expect(result.transformed).toBe(false);
77
+ });
78
+
79
+ it('should handle binary files by copying as-is', () => {
80
+ const sourceFile = join(sourceDir, 'image.png');
81
+ const destFile = join(destDir, 'image.png');
82
+
83
+ // Create a simple binary-like file
84
+ const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x00]);
85
+ writeFileSync(sourceFile, buffer);
86
+
87
+ const transform = (content) => content.replace(/test/g, 'replaced');
88
+ const result = copyFileWithTransform(sourceFile, destFile, transform);
89
+
90
+ expect(result.transformed).toBe(false);
91
+ expect(existsSync(destFile)).toBe(true);
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Crypto Tests
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { encrypt, decrypt, encryptReplacements, decryptReplacements } from '../src/core/crypto.js';
7
+ import { existsSync, rmSync, mkdirSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { homedir } from 'os';
10
+
11
+ describe('Crypto Module', () => {
12
+ const testSecret = 'test-secret-key-for-unit-tests-1234567890';
13
+
14
+ describe('encrypt/decrypt', () => {
15
+ it('should encrypt and decrypt a string', () => {
16
+ const original = 'Hello World';
17
+ const encrypted = encrypt(original, testSecret);
18
+ const decrypted = decrypt(encrypted, testSecret);
19
+
20
+ expect(decrypted).toBe(original);
21
+ });
22
+
23
+ it('should produce different encrypted output each time (random IV)', () => {
24
+ const original = 'Same input';
25
+ const encrypted1 = encrypt(original, testSecret);
26
+ const encrypted2 = encrypt(original, testSecret);
27
+
28
+ expect(encrypted1).not.toBe(encrypted2);
29
+ });
30
+
31
+ it('should return null for wrong secret', () => {
32
+ const original = 'Secret message';
33
+ const encrypted = encrypt(original, testSecret);
34
+ const decrypted = decrypt(encrypted, 'wrong-secret');
35
+
36
+ expect(decrypted).toBeNull();
37
+ });
38
+
39
+ it('should handle special characters', () => {
40
+ const original = 'Special: @#$%^&*()_+ 日本語 🎭';
41
+ const encrypted = encrypt(original, testSecret);
42
+ const decrypted = decrypt(encrypted, testSecret);
43
+
44
+ expect(decrypted).toBe(original);
45
+ });
46
+
47
+ it('should handle empty string or return null for empty', () => {
48
+ const original = '';
49
+ const encrypted = encrypt(original, testSecret);
50
+ const decrypted = decrypt(encrypted, testSecret);
51
+
52
+ // Empty string may return empty or null depending on cipher
53
+ expect(decrypted === '' || decrypted === null).toBe(true);
54
+ });
55
+
56
+ it('should handle long strings', () => {
57
+ const original = 'A'.repeat(10000);
58
+ const encrypted = encrypt(original, testSecret);
59
+ const decrypted = decrypt(encrypted, testSecret);
60
+
61
+ expect(decrypted).toBe(original);
62
+ });
63
+ });
64
+
65
+ describe('encryptReplacements/decryptReplacements', () => {
66
+ it('should encrypt only the original field', () => {
67
+ const replacements = [
68
+ { original: 'Cuviva', replacement: 'ABCCompany' },
69
+ { original: 'Secret', replacement: 'Public' }
70
+ ];
71
+
72
+ const encrypted = encryptReplacements(replacements, testSecret);
73
+
74
+ // Replacement should still be visible
75
+ expect(encrypted[0].replacement).toBe('ABCCompany');
76
+ expect(encrypted[1].replacement).toBe('Public');
77
+
78
+ // Original should be encrypted (contains colons from format)
79
+ expect(encrypted[0].original).toContain(':');
80
+ expect(encrypted[0].encrypted).toBe(true);
81
+ });
82
+
83
+ it('should decrypt back to original', () => {
84
+ const replacements = [
85
+ { original: 'Cuviva', replacement: 'ABCCompany' }
86
+ ];
87
+
88
+ const encrypted = encryptReplacements(replacements, testSecret);
89
+ const decrypted = decryptReplacements(encrypted, testSecret);
90
+
91
+ expect(decrypted[0].original).toBe('Cuviva');
92
+ expect(decrypted[0].replacement).toBe('ABCCompany');
93
+ });
94
+
95
+ it('should mark failed decryptions', () => {
96
+ const replacements = [
97
+ { original: 'Test', replacement: 'Demo' }
98
+ ];
99
+
100
+ const encrypted = encryptReplacements(replacements, testSecret);
101
+ const decrypted = decryptReplacements(encrypted, 'wrong-secret');
102
+
103
+ expect(decrypted[0].decryptFailed).toBe(true);
104
+ });
105
+ });
106
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Mapper Tests
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import {
7
+ createMapping,
8
+ saveMapping,
9
+ loadRawMapping,
10
+ mergeMapping,
11
+ hasMapping
12
+ } from '../src/core/mapper.js';
13
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { tmpdir } from 'os';
16
+
17
+ describe('Mapper Module', () => {
18
+ let testDir;
19
+
20
+ beforeEach(() => {
21
+ testDir = join(tmpdir(), `repo-cloak-test-${Date.now()}`);
22
+ mkdirSync(testDir, { recursive: true });
23
+ });
24
+
25
+ afterEach(() => {
26
+ if (existsSync(testDir)) {
27
+ rmSync(testDir, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ describe('createMapping', () => {
32
+ it('should create a valid mapping object', () => {
33
+ const mapping = createMapping({
34
+ sourceDir: '/path/to/source',
35
+ destDir: '/path/to/dest',
36
+ replacements: [{ original: 'Test', replacement: 'Demo' }],
37
+ files: [{ original: 'file.js', cloaked: 'file.js' }]
38
+ });
39
+
40
+ expect(mapping.version).toBe('1.1.0');
41
+ expect(mapping.tool).toBe('repo-cloak');
42
+ expect(mapping.encrypted).toBe(true);
43
+ expect(mapping.files).toHaveLength(1);
44
+ expect(mapping.stats.totalFiles).toBe(1);
45
+ });
46
+
47
+ it('should encrypt sensitive data', () => {
48
+ const mapping = createMapping({
49
+ sourceDir: '/secret/path',
50
+ destDir: '/dest/path',
51
+ replacements: [{ original: 'SecretWord', replacement: 'Public' }],
52
+ files: [{ original: 'secret.js', cloaked: 'public.js' }]
53
+ });
54
+
55
+ // Source path should be encrypted (contains :)
56
+ expect(mapping.source.path).toContain(':');
57
+ // Replacements original should be encrypted
58
+ expect(mapping.replacements[0].original).toContain(':');
59
+ expect(mapping.replacements[0].encrypted).toBe(true);
60
+ });
61
+ });
62
+
63
+ describe('saveMapping / loadRawMapping', () => {
64
+ it('should save and load mapping file', () => {
65
+ const mapping = {
66
+ version: '1.0.0',
67
+ files: [{ original: 'a.js', cloaked: 'b.js' }]
68
+ };
69
+
70
+ saveMapping(testDir, mapping);
71
+
72
+ expect(hasMapping(testDir)).toBe(true);
73
+
74
+ const loaded = loadRawMapping(testDir);
75
+ expect(loaded.version).toBe('1.0.0');
76
+ expect(loaded.files).toHaveLength(1);
77
+ });
78
+
79
+ it('should return null for non-existent mapping', () => {
80
+ const loaded = loadRawMapping('/non/existent/path');
81
+ expect(loaded).toBeNull();
82
+ });
83
+ });
84
+
85
+ describe('hasMapping', () => {
86
+ it('should return true when mapping exists', () => {
87
+ writeFileSync(join(testDir, '.repo-cloak-map.json'), '{}');
88
+ expect(hasMapping(testDir)).toBe(true);
89
+ });
90
+
91
+ it('should return false when mapping does not exist', () => {
92
+ expect(hasMapping(testDir)).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe('mergeMapping', () => {
97
+ it('should merge new files with existing mapping', () => {
98
+ const existing = {
99
+ version: '1.0.0',
100
+ files: [
101
+ { original: 'a.js', cloaked: 'a.js' },
102
+ { original: 'b.js', cloaked: 'b.js' }
103
+ ],
104
+ stats: { totalFiles: 2 }
105
+ };
106
+
107
+ const newFiles = [
108
+ { original: 'c.js', cloaked: 'c.js' },
109
+ { original: 'd.js', cloaked: 'd.js' }
110
+ ];
111
+
112
+ const merged = mergeMapping(existing, newFiles);
113
+
114
+ expect(merged.files).toHaveLength(4);
115
+ expect(merged.stats.totalFiles).toBe(4);
116
+ });
117
+
118
+ it('should avoid duplicate files', () => {
119
+ const existing = {
120
+ version: '1.0.0',
121
+ files: [
122
+ { original: 'a.js', cloaked: 'a.js' },
123
+ { original: 'b.js', cloaked: 'b.js' }
124
+ ],
125
+ stats: { totalFiles: 2 }
126
+ };
127
+
128
+ const newFiles = [
129
+ { original: 'b.js', cloaked: 'b.js' }, // Duplicate
130
+ { original: 'c.js', cloaked: 'c.js' } // New
131
+ ];
132
+
133
+ const merged = mergeMapping(existing, newFiles);
134
+
135
+ expect(merged.files).toHaveLength(3); // Not 4
136
+ expect(merged.stats.totalFiles).toBe(3);
137
+ });
138
+
139
+ it('should track pull history', () => {
140
+ const existing = {
141
+ version: '1.0.0',
142
+ files: [],
143
+ stats: { totalFiles: 0 }
144
+ };
145
+
146
+ const merged = mergeMapping(existing, [{ original: 'a.js', cloaked: 'a.js' }]);
147
+
148
+ expect(merged.pullHistory).toHaveLength(1);
149
+ expect(merged.pullHistory[0].filesAdded).toBe(1);
150
+ expect(merged.pullHistory[0].timestamp).toBeDefined();
151
+ });
152
+
153
+ it('should append to existing pull history', () => {
154
+ const existing = {
155
+ version: '1.0.0',
156
+ files: [{ original: 'a.js', cloaked: 'a.js' }],
157
+ stats: { totalFiles: 1 },
158
+ pullHistory: [{ timestamp: '2024-01-01', filesAdded: 1, totalFiles: 1 }]
159
+ };
160
+
161
+ const merged = mergeMapping(existing, [{ original: 'b.js', cloaked: 'b.js' }]);
162
+
163
+ expect(merged.pullHistory).toHaveLength(2);
164
+ });
165
+ });
166
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Scanner Tests
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { getAllFiles, isBinaryFile } from '../src/core/scanner.js';
7
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { tmpdir } from 'os';
10
+
11
+ describe('Scanner Module', () => {
12
+ let testDir;
13
+
14
+ beforeEach(() => {
15
+ testDir = join(tmpdir(), `repo-cloak-scanner-test-${Date.now()}`);
16
+ mkdirSync(testDir, { recursive: true });
17
+ });
18
+
19
+ afterEach(() => {
20
+ if (existsSync(testDir)) {
21
+ rmSync(testDir, { recursive: true, force: true });
22
+ }
23
+ });
24
+
25
+ describe('getAllFiles', () => {
26
+ it('should find all files in directory', () => {
27
+ writeFileSync(join(testDir, 'a.js'), 'content');
28
+ writeFileSync(join(testDir, 'b.js'), 'content');
29
+
30
+ const files = getAllFiles(testDir);
31
+
32
+ expect(files).toHaveLength(2);
33
+ });
34
+
35
+ it('should find files in nested directories', () => {
36
+ const nestedDir = join(testDir, 'src', 'components');
37
+ mkdirSync(nestedDir, { recursive: true });
38
+
39
+ writeFileSync(join(testDir, 'index.js'), 'content');
40
+ writeFileSync(join(nestedDir, 'Button.js'), 'content');
41
+
42
+ const files = getAllFiles(testDir);
43
+
44
+ expect(files).toHaveLength(2);
45
+ });
46
+
47
+ it('should ignore node_modules', () => {
48
+ const nodeModules = join(testDir, 'node_modules', 'package');
49
+ mkdirSync(nodeModules, { recursive: true });
50
+
51
+ writeFileSync(join(testDir, 'index.js'), 'content');
52
+ writeFileSync(join(nodeModules, 'index.js'), 'content');
53
+
54
+ const files = getAllFiles(testDir);
55
+
56
+ expect(files).toHaveLength(1);
57
+ });
58
+
59
+ it('should ignore .git directories', () => {
60
+ const gitDir = join(testDir, '.git', 'objects');
61
+ mkdirSync(gitDir, { recursive: true });
62
+
63
+ writeFileSync(join(testDir, 'index.js'), 'content');
64
+ writeFileSync(join(gitDir, 'abc123'), 'content');
65
+
66
+ const files = getAllFiles(testDir);
67
+
68
+ expect(files).toHaveLength(1);
69
+ });
70
+
71
+ it('should return empty array for empty directory', () => {
72
+ const files = getAllFiles(testDir);
73
+ expect(files).toHaveLength(0);
74
+ });
75
+ });
76
+
77
+ describe('isBinaryFile', () => {
78
+ it('should detect common binary extensions', () => {
79
+ expect(isBinaryFile('image.png')).toBe(true);
80
+ expect(isBinaryFile('image.jpg')).toBe(true);
81
+ expect(isBinaryFile('archive.zip')).toBe(true);
82
+ expect(isBinaryFile('doc.pdf')).toBe(true);
83
+ expect(isBinaryFile('lib.dll')).toBe(true);
84
+ expect(isBinaryFile('app.exe')).toBe(true);
85
+ });
86
+
87
+ it('should detect text files', () => {
88
+ expect(isBinaryFile('code.js')).toBe(false);
89
+ expect(isBinaryFile('style.css')).toBe(false);
90
+ expect(isBinaryFile('data.json')).toBe(false);
91
+ expect(isBinaryFile('README.md')).toBe(false);
92
+ expect(isBinaryFile('code.ts')).toBe(false);
93
+ });
94
+
95
+ it('should handle paths with directories', () => {
96
+ expect(isBinaryFile('/path/to/image.png')).toBe(true);
97
+ expect(isBinaryFile('src/components/Button.js')).toBe(false);
98
+ });
99
+ });
100
+ });