repo-cloak-cli 1.0.1 → 1.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.
- package/medium.md +319 -0
- package/package.json +6 -2
- package/src/commands/pull.js +177 -37
- package/src/core/mapper.js +56 -0
- package/tests/anonymizer.test.js +127 -0
- package/tests/copier.test.js +94 -0
- package/tests/crypto.test.js +106 -0
- package/tests/mapper.test.js +166 -0
- package/tests/scanner.test.js +100 -0
package/medium.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Repo-Cloak: A CLI Tool for Safely Using AI Coding Assistants on Proprietary Codebases
|
|
2
|
+
|
|
3
|
+
The rise of AI-powered coding assistants like GitHub Copilot, Cursor, and Claude has fundamentally changed how developers write software. These tools can dramatically accelerate development, debug complex issues, and even architect entire systems. But for engineers working on proprietary or enterprise codebases, there is a persistent concern: *How do you leverage these powerful AI tools without exposing sensitive business logic, customer data, or confidential intellectual property?*
|
|
4
|
+
|
|
5
|
+
This article introduces **Repo-Cloak**, a command-line tool I built to address this exact challenge. It provides a secure, selective approach to sharing code with AI assistants while maintaining complete control over what information leaves your environment.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The Problem: AI Assistance vs Enterprise Security
|
|
10
|
+
|
|
11
|
+
Modern AI coding assistants require context to be effective. The more code they can see, the better their suggestions. But enterprise environments present unique challenges:
|
|
12
|
+
|
|
13
|
+
- **Proprietary Business Logic**: Core algorithms, pricing engines, and competitive differentiators must remain confidential
|
|
14
|
+
- **Customer Data References**: Database schemas, API endpoints, and configuration files often contain sensitive identifiers
|
|
15
|
+
- **Company-Specific Naming**: Project names, internal tools, and organizational structures reveal information about your infrastructure
|
|
16
|
+
- **Compliance Requirements**: Healthcare, finance, and government sectors have strict regulations about data handling
|
|
17
|
+
|
|
18
|
+
The traditional approach forces developers to choose between two suboptimal paths: either manually copy-paste sanitized code snippets (losing context and wasting time), or share entire repositories with AI tools (accepting security risks). Repo-Cloak offers a third path.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## The Solution: Selective Extraction with Intelligent Anonymization
|
|
23
|
+
|
|
24
|
+
Repo-Cloak operates on a simple but powerful principle: **extract only what you need, anonymize what you must protect, and maintain a reversible mapping for seamless integration**.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
28
|
+
│ ORIGINAL REPOSITORY │
|
|
29
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
30
|
+
│ │ XCorpAPI/ │ │ Payments/ │ │ Analytics/ │ │
|
|
31
|
+
│ │ auth.cs │ │ stripe.cs │ │ metrics.cs │ │
|
|
32
|
+
│ │ users.cs │ │ billing.cs │ │ reports.cs │ │
|
|
33
|
+
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
34
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
35
|
+
│
|
|
36
|
+
│ PULL (selective + anonymize)
|
|
37
|
+
▼
|
|
38
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
39
|
+
│ CLOAKED WORKSPACE │
|
|
40
|
+
│ ┌──────────────┐ │
|
|
41
|
+
│ │ AcmeAPI/ │ ← Folder names anonymized │
|
|
42
|
+
│ │ auth.cs │ ← Content: "XCorp" → "Acme" │
|
|
43
|
+
│ │ users.cs │ ← Safe to share with AI assistants │
|
|
44
|
+
│ └──────────────┘ │
|
|
45
|
+
│ │
|
|
46
|
+
│ .repo-cloak-map.json ← Encrypted mapping for restoration │
|
|
47
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
48
|
+
│
|
|
49
|
+
│ AI AGENT MAKES MODIFICATIONS
|
|
50
|
+
▼
|
|
51
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
52
|
+
│ PUSH (restore + de-anonymize) │
|
|
53
|
+
│ │
|
|
54
|
+
│ Modified files pushed back to original repository │
|
|
55
|
+
│ All anonymization reversed automatically │
|
|
56
|
+
│ "Acme" → "XCorp" in both content and file paths │
|
|
57
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## How It Works: A Technical Deep Dive
|
|
63
|
+
|
|
64
|
+
### 1. Interactive File Selection
|
|
65
|
+
|
|
66
|
+
Rather than extracting entire directories, Repo-Cloak provides an interactive file selector with hierarchical navigation. Developers can search, filter, and selectively choose exactly which files to extract.
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
? Select files to extract:
|
|
70
|
+
◉ 📁 src/Services
|
|
71
|
+
◉ 📄 BookService.cs
|
|
72
|
+
◯ 📄 PaymentService.cs ← Excluded: contains billing logic
|
|
73
|
+
◉ 📄 UserService.cs
|
|
74
|
+
◯ 📁 src/Infrastructure ← Entire folder excluded
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The selector supports pagination for large codebases, folder-level selection (selecting a folder automatically includes all children), and real-time filtering.
|
|
78
|
+
|
|
79
|
+
### 2. Intelligent Content Anonymization
|
|
80
|
+
|
|
81
|
+
The anonymization engine performs case-preserving replacements across all extracted content. This means your code remains syntactically valid and readable:
|
|
82
|
+
|
|
83
|
+
```csharp
|
|
84
|
+
// Original
|
|
85
|
+
namespace XCorp.Services {
|
|
86
|
+
public class XCorpBookManager : IXCorpService {
|
|
87
|
+
private const string XCORP_API_KEY = "...";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Anonymized (case preservation maintained)
|
|
92
|
+
namespace Acme.Services {
|
|
93
|
+
public class AcmeBookManager : IAcmeService {
|
|
94
|
+
private const string ACME_API_KEY = "...";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The engine handles:
|
|
100
|
+
- **PascalCase**: `XCorpService` becomes `AcmeService`
|
|
101
|
+
- **camelCase**: `xcorpClient` becomes `acmeClient`
|
|
102
|
+
- **SCREAMING_CASE**: `XCORP_KEY` becomes `ACME_KEY`
|
|
103
|
+
- **lowercase**: `xcorp` becomes `acme`
|
|
104
|
+
|
|
105
|
+
### 3. Path Anonymization
|
|
106
|
+
|
|
107
|
+
Beyond content, Repo-Cloak also anonymizes folder and file names. This prevents directory structures from revealing organizational patterns:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
Original: src/XCorpFrontEnd/XCorpComponents/XCorpButton.tsx
|
|
111
|
+
Cloaked: src/AcmeFrontEnd/AcmeComponents/AcmeButton.tsx
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 4. Encrypted Mapping with User-Specific Secrets
|
|
115
|
+
|
|
116
|
+
Here is where security becomes critical. The mapping file that tracks original-to-anonymized paths must itself be protected. Repo-Cloak uses AES-256-GCM encryption with a user-specific secret key:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
120
|
+
│ ENCRYPTION ARCHITECTURE │
|
|
121
|
+
├──────────────────────────────────────────────────────────────┤
|
|
122
|
+
│ │
|
|
123
|
+
│ ~/.repo-cloak/secret.key │
|
|
124
|
+
│ ├── Generated automatically on first use │
|
|
125
|
+
│ ├── 256-bit cryptographically random key │
|
|
126
|
+
│ ├── Stored with 0600 permissions (owner-only) │
|
|
127
|
+
│ └── Unique per user/machine │
|
|
128
|
+
│ │
|
|
129
|
+
│ .repo-cloak-map.json │
|
|
130
|
+
│ ├── Source paths: ENCRYPTED │
|
|
131
|
+
│ ├── Original keywords: ENCRYPTED │
|
|
132
|
+
│ ├── Replacement keywords: VISIBLE (safe values) │
|
|
133
|
+
│ └── Cloaked file paths: VISIBLE (already anonymized) │
|
|
134
|
+
│ │
|
|
135
|
+
└──────────────────────────────────────────────────────────────┘
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The mapping file can be safely committed or shared because:
|
|
139
|
+
- The encrypted fields cannot be decrypted without the user's secret key
|
|
140
|
+
- The visible fields contain only anonymized, non-sensitive values
|
|
141
|
+
- Even if an attacker obtains the mapping, they cannot determine original values
|
|
142
|
+
|
|
143
|
+
If a user loses their secret key (machine reinstall, key deletion), the tool prompts for manual keyword entry during restoration. Since developers know their own codebase, they can provide the original terms when needed.
|
|
144
|
+
|
|
145
|
+
### 5. Incremental Extraction Support
|
|
146
|
+
|
|
147
|
+
Real-world usage rarely involves a single extraction. As AI assistants request additional context, developers need to pull more files. Repo-Cloak handles this with intelligent merging:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
Pull #1: BookService.cs → Mapping tracks 1 file
|
|
151
|
+
Pull #2: UserService.cs → Mapping tracks 2 files (merged)
|
|
152
|
+
Pull #3: Same files again → Deduplication, no duplicates added
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Each pull is tracked in a history log:
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"pullHistory": [
|
|
159
|
+
{ "timestamp": "2024-01-15T10:30:00Z", "filesAdded": 5, "totalFiles": 5 },
|
|
160
|
+
{ "timestamp": "2024-01-15T14:22:00Z", "filesAdded": 3, "totalFiles": 8 }
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## The Workflow in Practice
|
|
168
|
+
|
|
169
|
+
### Step 1: Pull Files from Your Repository
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
repo-cloak pull
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The interactive interface guides you through:
|
|
176
|
+
1. Selecting a source directory (your project)
|
|
177
|
+
2. Choosing specific files via the tree selector
|
|
178
|
+
3. Defining keyword replacements (e.g., "XCorp" to "Acme")
|
|
179
|
+
4. Specifying an output directory
|
|
180
|
+
|
|
181
|
+
### Step 2: Work with AI Assistants
|
|
182
|
+
|
|
183
|
+
Open the cloaked workspace in your preferred AI-enabled IDE. The anonymized code is syntactically valid and maintains all structural relationships. AI assistants can:
|
|
184
|
+
- Analyze patterns and suggest improvements
|
|
185
|
+
- Debug issues with full context
|
|
186
|
+
- Generate new code that follows your conventions
|
|
187
|
+
- Refactor existing implementations
|
|
188
|
+
|
|
189
|
+
### Step 3: Push Changes Back
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
repo-cloak push
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The tool automatically:
|
|
196
|
+
1. Locates the mapping file in the cloaked directory
|
|
197
|
+
2. Decrypts the mapping using your secret key
|
|
198
|
+
3. Reverses all anonymization in both content and file paths
|
|
199
|
+
4. Copies modified files back to the original repository
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Security Considerations
|
|
204
|
+
|
|
205
|
+
### What Gets Protected
|
|
206
|
+
|
|
207
|
+
| Element | Protection Method |
|
|
208
|
+
|---------|------------------|
|
|
209
|
+
| File content keywords | Case-preserving replacement |
|
|
210
|
+
| Folder names | Path anonymization |
|
|
211
|
+
| File names | Path anonymization |
|
|
212
|
+
| Original keywords in mapping | AES-256-GCM encryption |
|
|
213
|
+
| Source directory path | AES-256-GCM encryption |
|
|
214
|
+
| Original file paths | AES-256-GCM encryption |
|
|
215
|
+
|
|
216
|
+
### What Remains Visible
|
|
217
|
+
|
|
218
|
+
| Element | Reason |
|
|
219
|
+
|---------|--------|
|
|
220
|
+
| Replacement keywords | Already anonymized, safe to expose |
|
|
221
|
+
| Cloaked file paths | Already anonymized, safe to expose |
|
|
222
|
+
| Code structure and logic | Required for AI assistance |
|
|
223
|
+
|
|
224
|
+
### Threat Model
|
|
225
|
+
|
|
226
|
+
Repo-Cloak protects against:
|
|
227
|
+
- **Accidental exposure**: AI tools cannot see original company names, project identifiers, or sensitive naming
|
|
228
|
+
- **Mapping file leakage**: Even if the mapping file is exposed, encryption prevents recovery of original values
|
|
229
|
+
- **Third-party logging**: Cloud-based AI services only receive anonymized content
|
|
230
|
+
|
|
231
|
+
Repo-Cloak does not protect against:
|
|
232
|
+
- **Logic inference**: Sufficiently advanced analysis might infer business purpose from code structure
|
|
233
|
+
- **Unique patterns**: Highly distinctive algorithms may be recognizable regardless of naming
|
|
234
|
+
- **Malicious insiders**: Users with the secret key have full access
|
|
235
|
+
|
|
236
|
+
### User Responsibility
|
|
237
|
+
|
|
238
|
+
Repo-Cloak is a tool that assists with anonymization, but it does not replace sound judgment. **The responsibility for selecting appropriate files lies entirely with the user.**
|
|
239
|
+
|
|
240
|
+
Before extracting any code, you should:
|
|
241
|
+
|
|
242
|
+
1. **Review your organization's policies**: Most companies have guidelines about sharing code with external tools or AI services. Ensure you understand and comply with these policies before using Repo-Cloak or any similar tool.
|
|
243
|
+
|
|
244
|
+
2. **Avoid proprietary algorithms**: Even with anonymized naming, core business logic, patented algorithms, or trade secrets should not be extracted. If an algorithm is proprietary, changing variable names does not make it safe to share.
|
|
245
|
+
|
|
246
|
+
3. **Verify file contents before extraction**: The selective file picker exists precisely so you can make informed decisions. Do not blindly select entire directories without understanding what they contain.
|
|
247
|
+
|
|
248
|
+
4. **Cross-check with your team**: When in doubt, consult with your security team, legal department, or engineering leadership. A quick conversation can prevent significant issues.
|
|
249
|
+
|
|
250
|
+
5. **Use the minimum necessary context**: Extract only what the AI assistant needs to help you. More files means more exposure, even if that exposure is anonymized.
|
|
251
|
+
|
|
252
|
+
This tool provides a layer of protection, but no tool can substitute for thoughtful decision-making about what code should or should not leave your environment.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Installation and Usage
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
# Install globally via npm
|
|
260
|
+
npm install -g repo-cloak-cli
|
|
261
|
+
|
|
262
|
+
# Run the interactive interface
|
|
263
|
+
repo-cloak
|
|
264
|
+
|
|
265
|
+
# Or use specific commands
|
|
266
|
+
repo-cloak pull --source ./my-project --dest ./cloaked-output
|
|
267
|
+
repo-cloak push --source ./cloaked-output --dest ./my-project
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Technical Architecture
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
repo-cloak/
|
|
276
|
+
├── bin/
|
|
277
|
+
│ └── repo-cloak.js # CLI entry point
|
|
278
|
+
├── src/
|
|
279
|
+
│ ├── commands/
|
|
280
|
+
│ │ ├── pull.js # Extraction and anonymization
|
|
281
|
+
│ │ └── push.js # Restoration and de-anonymization
|
|
282
|
+
│ ├── core/
|
|
283
|
+
│ │ ├── anonymizer.js # Case-preserving replacement engine
|
|
284
|
+
│ │ ├── copier.js # File operations with transformation
|
|
285
|
+
│ │ ├── crypto.js # AES-256-GCM encryption
|
|
286
|
+
│ │ ├── mapper.js # Mapping file management
|
|
287
|
+
│ │ └── scanner.js # File discovery and filtering
|
|
288
|
+
│ └── ui/
|
|
289
|
+
│ ├── fileSelector.js # Interactive tree selector
|
|
290
|
+
│ └── prompts.js # User input handling
|
|
291
|
+
└── tests/
|
|
292
|
+
├── anonymizer.test.js # 13 test cases
|
|
293
|
+
├── copier.test.js # 5 test cases
|
|
294
|
+
├── crypto.test.js # 9 test cases
|
|
295
|
+
├── mapper.test.js # 10 test cases
|
|
296
|
+
└── scanner.test.js # 8 test cases
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The project includes 45 unit tests covering all core functionality, ensuring reliability for production use.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Conclusion
|
|
304
|
+
|
|
305
|
+
The tension between leveraging AI tools and maintaining code security is real, but it does not have to be a binary choice. Repo-Cloak provides a practical middle ground: keep your proprietary information private while still benefiting from the productivity gains of modern AI coding assistants.
|
|
306
|
+
|
|
307
|
+
By implementing selective extraction, intelligent anonymization, and encrypted reversible mappings, developers can confidently use tools like Cursor, GitHub Copilot, or any AI coding assistant without exposing sensitive business logic or company-specific information.
|
|
308
|
+
|
|
309
|
+
The tool is open-source and available on npm. Contributions, feedback, and feature requests are welcome.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
**Repository**: [github.com/iamshz97/repo-cloak](https://github.com/iamshz97/repo-cloak)
|
|
314
|
+
|
|
315
|
+
**npm**: `npm install -g repo-cloak-cli`
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
*Shazni Shiraz is a software engineer focused on developer tooling and enterprise software architecture.*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "repo-cloak-cli",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
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": "
|
|
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
|
}
|
package/src/commands/pull.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pull Command
|
|
3
3
|
* Extract files and anonymize sensitive information
|
|
4
|
+
* Supports quick-add mode when pulling to existing cloaked directory
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import ora from 'ora';
|
|
7
8
|
import chalk from 'chalk';
|
|
9
|
+
import inquirer from 'inquirer';
|
|
8
10
|
import { existsSync, mkdirSync } from 'fs';
|
|
9
11
|
import { resolve, relative } from 'path';
|
|
10
12
|
|
|
@@ -13,29 +15,117 @@ import {
|
|
|
13
15
|
promptSourceDirectory,
|
|
14
16
|
promptDestinationDirectory,
|
|
15
17
|
promptKeywordReplacements,
|
|
16
|
-
showSummaryAndConfirm
|
|
18
|
+
showSummaryAndConfirm,
|
|
19
|
+
confirmAction
|
|
17
20
|
} from '../ui/prompts.js';
|
|
18
21
|
import { showSuccess, showError, showInfo } from '../ui/banner.js';
|
|
19
22
|
import { getAllFiles } from '../core/scanner.js';
|
|
20
23
|
import { copyFiles } from '../core/copier.js';
|
|
21
24
|
import { createAnonymizer } from '../core/anonymizer.js';
|
|
22
|
-
import { createMapping, saveMapping } from '../core/mapper.js';
|
|
25
|
+
import { createMapping, saveMapping, loadRawMapping, mergeMapping, hasMapping, decryptMapping } from '../core/mapper.js';
|
|
26
|
+
import { getOrCreateSecret, hasSecret, decryptReplacements } from '../core/crypto.js';
|
|
23
27
|
|
|
24
28
|
export async function pull(options = {}) {
|
|
25
29
|
try {
|
|
26
|
-
// Step 1: Get
|
|
27
|
-
|
|
28
|
-
? resolve(options.
|
|
29
|
-
: await
|
|
30
|
+
// Step 1: Get destination directory first (to check for existing mapping)
|
|
31
|
+
let destDir = options.dest
|
|
32
|
+
? resolve(options.dest)
|
|
33
|
+
: await promptDestinationDirectory();
|
|
34
|
+
|
|
35
|
+
let existingMapping = null;
|
|
36
|
+
let existingReplacements = [];
|
|
37
|
+
let sourceDir = null;
|
|
38
|
+
let isQuickAdd = false;
|
|
39
|
+
|
|
40
|
+
// Step 2: Check for existing mapping in destination
|
|
41
|
+
if (existsSync(destDir) && hasMapping(destDir)) {
|
|
42
|
+
existingMapping = loadRawMapping(destDir);
|
|
43
|
+
|
|
44
|
+
console.log(chalk.cyan('\n Existing cloaked directory detected'));
|
|
45
|
+
console.log(chalk.dim(` Created: ${existingMapping.timestamp}`));
|
|
46
|
+
console.log(chalk.dim(` Files: ${existingMapping.stats?.totalFiles || existingMapping.files?.length || 0}`));
|
|
47
|
+
console.log(chalk.dim(` Replacements: ${existingMapping.replacements?.length || 0}\n`));
|
|
48
|
+
|
|
49
|
+
// Ask if they want quick-add mode
|
|
50
|
+
const { mode } = await inquirer.prompt([
|
|
51
|
+
{
|
|
52
|
+
type: 'list',
|
|
53
|
+
name: 'mode',
|
|
54
|
+
message: 'What would you like to do?',
|
|
55
|
+
choices: [
|
|
56
|
+
{
|
|
57
|
+
name: 'Quick Add - Use existing replacements and add more files',
|
|
58
|
+
value: 'quick'
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'Add More Replacements - Add files with additional anonymization',
|
|
62
|
+
value: 'extend'
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'Fresh Start - Choose new destination',
|
|
66
|
+
value: 'fresh'
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
if (mode === 'fresh') {
|
|
73
|
+
// Get a new destination
|
|
74
|
+
destDir = await promptDestinationDirectory();
|
|
75
|
+
existingMapping = null;
|
|
76
|
+
} else {
|
|
77
|
+
isQuickAdd = mode === 'quick';
|
|
78
|
+
|
|
79
|
+
// Decrypt existing replacements
|
|
80
|
+
if (existingMapping.encrypted && hasSecret()) {
|
|
81
|
+
const secret = getOrCreateSecret();
|
|
82
|
+
try {
|
|
83
|
+
const decrypted = decryptReplacements(existingMapping.replacements || [], secret);
|
|
84
|
+
existingReplacements = decrypted.filter(r => !r.decryptFailed);
|
|
85
|
+
|
|
86
|
+
if (existingReplacements.length > 0) {
|
|
87
|
+
console.log(chalk.green(' Existing replacements loaded:\n'));
|
|
88
|
+
existingReplacements.forEach(r => {
|
|
89
|
+
console.log(chalk.dim(` "${r.original}" → "${r.replacement}"`));
|
|
90
|
+
});
|
|
91
|
+
console.log('');
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.log(chalk.yellow(' Could not decrypt existing replacements'));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Try to get original source path
|
|
99
|
+
if (existingMapping.encrypted && hasSecret()) {
|
|
100
|
+
const secret = getOrCreateSecret();
|
|
101
|
+
try {
|
|
102
|
+
const decrypted = decryptMapping(existingMapping, secret);
|
|
103
|
+
if (decrypted.source?.path && existsSync(decrypted.source.path)) {
|
|
104
|
+
sourceDir = decrypted.source.path;
|
|
105
|
+
console.log(chalk.dim(` Source: ${sourceDir}\n`));
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// Source path couldn't be decrypted, will prompt
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Step 3: Get source directory if not already determined
|
|
115
|
+
if (!sourceDir) {
|
|
116
|
+
sourceDir = options.source
|
|
117
|
+
? resolve(options.source)
|
|
118
|
+
: await promptSourceDirectory();
|
|
119
|
+
}
|
|
30
120
|
|
|
31
121
|
if (!existsSync(sourceDir)) {
|
|
32
122
|
showError(`Source directory does not exist: ${sourceDir}`);
|
|
33
123
|
return;
|
|
34
124
|
}
|
|
35
125
|
|
|
36
|
-
console.log(chalk.dim(
|
|
126
|
+
console.log(chalk.dim(` Source: ${sourceDir}\n`));
|
|
37
127
|
|
|
38
|
-
// Step
|
|
128
|
+
// Step 4: Select files
|
|
39
129
|
const selectedFiles = await selectFiles(sourceDir);
|
|
40
130
|
|
|
41
131
|
if (selectedFiles.length === 0) {
|
|
@@ -45,15 +135,40 @@ export async function pull(options = {}) {
|
|
|
45
135
|
|
|
46
136
|
console.log(chalk.green(`\n✓ Selected ${selectedFiles.length} files\n`));
|
|
47
137
|
|
|
48
|
-
// Step
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
138
|
+
// Step 5: Handle replacements based on mode
|
|
139
|
+
let replacements = [...existingReplacements];
|
|
140
|
+
|
|
141
|
+
if (isQuickAdd) {
|
|
142
|
+
// Quick add mode - just use existing replacements
|
|
143
|
+
if (replacements.length > 0) {
|
|
144
|
+
console.log(chalk.cyan(' Using existing replacements (quick-add mode)\n'));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Ask if they want to add more
|
|
148
|
+
const { addMore } = await inquirer.prompt([
|
|
149
|
+
{
|
|
150
|
+
type: 'confirm',
|
|
151
|
+
name: 'addMore',
|
|
152
|
+
message: 'Add additional replacements?',
|
|
153
|
+
default: false
|
|
154
|
+
}
|
|
155
|
+
]);
|
|
52
156
|
|
|
53
|
-
|
|
54
|
-
|
|
157
|
+
if (addMore) {
|
|
158
|
+
const additionalReplacements = await promptKeywordReplacements();
|
|
159
|
+
replacements = [...replacements, ...additionalReplacements];
|
|
160
|
+
}
|
|
161
|
+
} else if (existingMapping) {
|
|
162
|
+
// Extend mode - prompt for more replacements to add to existing
|
|
163
|
+
console.log(chalk.cyan('\n Add more replacements (existing will be preserved):\n'));
|
|
164
|
+
const additionalReplacements = await promptKeywordReplacements();
|
|
165
|
+
replacements = [...replacements, ...additionalReplacements];
|
|
166
|
+
} else {
|
|
167
|
+
// Fresh start - prompt for all replacements
|
|
168
|
+
replacements = await promptKeywordReplacements();
|
|
169
|
+
}
|
|
55
170
|
|
|
56
|
-
// Step
|
|
171
|
+
// Step 6: Confirm
|
|
57
172
|
const confirmed = await showSummaryAndConfirm(
|
|
58
173
|
selectedFiles.length,
|
|
59
174
|
destDir,
|
|
@@ -65,13 +180,13 @@ export async function pull(options = {}) {
|
|
|
65
180
|
return;
|
|
66
181
|
}
|
|
67
182
|
|
|
68
|
-
// Step
|
|
183
|
+
// Step 7: Create destination directory
|
|
69
184
|
if (!existsSync(destDir)) {
|
|
70
185
|
mkdirSync(destDir, { recursive: true });
|
|
71
186
|
console.log(chalk.dim(` Created directory: ${destDir}`));
|
|
72
187
|
}
|
|
73
188
|
|
|
74
|
-
// Step
|
|
189
|
+
// Step 8: Copy and anonymize files
|
|
75
190
|
const spinner = ora('Copying and anonymizing files...').start();
|
|
76
191
|
|
|
77
192
|
const anonymizer = createAnonymizer(replacements);
|
|
@@ -106,33 +221,58 @@ export async function pull(options = {}) {
|
|
|
106
221
|
});
|
|
107
222
|
}
|
|
108
223
|
|
|
109
|
-
// Step
|
|
110
|
-
const
|
|
111
|
-
sourceDir,
|
|
112
|
-
|
|
113
|
-
replacements
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
return {
|
|
123
|
-
original: originalPath,
|
|
124
|
-
cloaked: anonymizedPath
|
|
125
|
-
};
|
|
126
|
-
})
|
|
224
|
+
// Step 9: Prepare new file mappings
|
|
225
|
+
const newFiles = selectedFiles.map(f => {
|
|
226
|
+
const originalPath = relative(sourceDir, f);
|
|
227
|
+
let anonymizedPath = originalPath;
|
|
228
|
+
for (const { original, replacement } of replacements) {
|
|
229
|
+
const regex = new RegExp(original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
230
|
+
anonymizedPath = anonymizedPath.replace(regex, replacement);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
original: originalPath,
|
|
234
|
+
cloaked: anonymizedPath
|
|
235
|
+
};
|
|
127
236
|
});
|
|
128
237
|
|
|
238
|
+
// Step 10: Check for existing mapping and merge if found
|
|
239
|
+
let mapping;
|
|
240
|
+
let isIncremental = false;
|
|
241
|
+
|
|
242
|
+
if (existingMapping) {
|
|
243
|
+
// Merge with existing
|
|
244
|
+
mapping = mergeMapping(existingMapping, newFiles);
|
|
245
|
+
isIncremental = true;
|
|
246
|
+
console.log(chalk.cyan(` 🔄 Merged with existing mapping`));
|
|
247
|
+
} else {
|
|
248
|
+
// Create new mapping
|
|
249
|
+
mapping = createMapping({
|
|
250
|
+
sourceDir,
|
|
251
|
+
destDir,
|
|
252
|
+
replacements,
|
|
253
|
+
files: newFiles
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
129
257
|
const mapPath = saveMapping(destDir, mapping);
|
|
130
|
-
|
|
258
|
+
|
|
259
|
+
if (isIncremental) {
|
|
260
|
+
const history = mapping.pullHistory || [];
|
|
261
|
+
const lastPull = history[history.length - 1];
|
|
262
|
+
console.log(chalk.dim(` 📋 Mapping updated: ${lastPull?.filesAdded || 0} new files added (total: ${mapping.stats?.totalFiles})`));
|
|
263
|
+
} else {
|
|
264
|
+
console.log(chalk.dim(` 📋 Mapping saved: ${mapPath}`));
|
|
265
|
+
}
|
|
131
266
|
|
|
132
267
|
// Done!
|
|
133
268
|
showSuccess('Extraction complete!');
|
|
134
269
|
console.log(chalk.white(` 📂 Files extracted to: ${chalk.cyan.bold(destDir)}`));
|
|
135
|
-
|
|
270
|
+
|
|
271
|
+
if (isQuickAdd || isIncremental) {
|
|
272
|
+
console.log(chalk.dim(`\n Tip: Run again to add more files quickly\n`));
|
|
273
|
+
} else {
|
|
274
|
+
console.log(chalk.dim(`\n To restore later, run: ${chalk.white('repo-cloak push')}\n`));
|
|
275
|
+
}
|
|
136
276
|
|
|
137
277
|
} catch (error) {
|
|
138
278
|
showError(`Pull failed: ${error.message}`);
|
package/src/core/mapper.js
CHANGED
|
@@ -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
|
+
});
|