repo-cloak-cli 1.2.4 → 1.3.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.
@@ -1,352 +1,582 @@
1
- /**
2
- * Pull Command
3
- * Extract files and anonymize sensitive information
4
- * Supports quick-add mode when pulling to existing cloaked directory
5
- */
6
-
7
- import ora from 'ora';
8
- import chalk from 'chalk';
9
- import inquirer from 'inquirer';
10
- import { existsSync, mkdirSync } from 'fs';
11
- import { resolve, relative } from 'path';
12
-
13
- import { selectFiles } from '../ui/fileSelector.js';
14
- import {
15
- promptSourceDirectory,
16
- promptDestinationDirectory,
17
- promptKeywordReplacements,
18
- showSummaryAndConfirm,
19
- confirmAction
20
- } from '../ui/prompts.js';
21
- import { showSuccess, showError, showInfo } from '../ui/banner.js';
22
- import { getAllFiles } from '../core/scanner.js';
23
- import { copyFiles } from '../core/copier.js';
24
- import { createAnonymizer } from '../core/anonymizer.js';
25
- import { createMapping, saveMapping, loadRawMapping, mergeMapping, hasMapping, decryptMapping } from '../core/mapper.js';
26
- import { getOrCreateSecret, hasSecret, decryptReplacements } from '../core/crypto.js';
27
-
28
- export async function pull(options = {}) {
29
- try {
30
- let destDir = null;
31
- let existingMapping = null;
32
- let existingReplacements = [];
33
- let sourceDir = null;
34
- let isQuickAdd = false;
35
-
36
- // Step 1: Check if current directory is already a cloaked directory
37
- const currentDir = process.cwd();
38
-
39
- if (hasMapping(currentDir) && !options.dest) {
40
- // Running from inside an existing cloaked directory - auto-detect!
41
- destDir = currentDir;
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
- } else {
113
- // Not running from a cloaked directory - ask for destination
114
- destDir = options.dest
115
- ? resolve(options.dest)
116
- : await promptDestinationDirectory();
117
-
118
- // Check if the chosen destination has an existing mapping
119
- if (existsSync(destDir) && hasMapping(destDir)) {
120
- existingMapping = loadRawMapping(destDir);
121
-
122
- console.log(chalk.cyan('\n Existing cloaked directory detected'));
123
- console.log(chalk.dim(` Created: ${existingMapping.timestamp}`));
124
- console.log(chalk.dim(` Files: ${existingMapping.stats?.totalFiles || existingMapping.files?.length || 0}`));
125
- console.log(chalk.dim(` Replacements: ${existingMapping.replacements?.length || 0}\n`));
126
-
127
- // Ask if they want quick-add mode
128
- const { mode } = await inquirer.prompt([
129
- {
130
- type: 'list',
131
- name: 'mode',
132
- message: 'What would you like to do?',
133
- choices: [
134
- { name: 'Quick Add - Use existing replacements and add more files', value: 'quick' },
135
- { name: 'Add More Replacements - Add files with additional anonymization', value: 'extend' },
136
- { name: 'Fresh Start - Choose new destination', value: 'fresh' }
137
- ]
138
- }
139
- ]);
140
-
141
- if (mode === 'fresh') {
142
- destDir = await promptDestinationDirectory();
143
- existingMapping = null;
144
- } else {
145
- isQuickAdd = mode === 'quick';
146
-
147
- // Decrypt existing replacements
148
- if (existingMapping.encrypted && hasSecret()) {
149
- const secret = getOrCreateSecret();
150
- try {
151
- const decrypted = decryptReplacements(existingMapping.replacements || [], secret);
152
- existingReplacements = decrypted.filter(r => !r.decryptFailed);
153
-
154
- if (existingReplacements.length > 0) {
155
- console.log(chalk.green(' Existing replacements loaded:\n'));
156
- existingReplacements.forEach(r => {
157
- console.log(chalk.dim(` "${r.original}" "${r.replacement}"`));
158
- });
159
- console.log('');
160
- }
161
- } catch (err) {
162
- console.log(chalk.yellow(' Could not decrypt existing replacements'));
163
- }
164
- }
165
-
166
- // Try to get original source path
167
- if (existingMapping.encrypted && hasSecret()) {
168
- const secret = getOrCreateSecret();
169
- try {
170
- const decrypted = decryptMapping(existingMapping, secret);
171
- if (decrypted.source?.path && existsSync(decrypted.source.path)) {
172
- sourceDir = decrypted.source.path;
173
- console.log(chalk.dim(` Source: ${sourceDir}\n`));
174
- }
175
- } catch (err) {
176
- // Source path couldn't be decrypted, will prompt
177
- }
178
- }
179
- }
180
- }
181
- }
182
-
183
- // Step 3: Get source directory if not already determined
184
- if (!sourceDir) {
185
- sourceDir = options.source
186
- ? resolve(options.source)
187
- : await promptSourceDirectory();
188
- }
189
-
190
- if (!existsSync(sourceDir)) {
191
- showError(`Source directory does not exist: ${sourceDir}`);
192
- return;
193
- }
194
-
195
- console.log(chalk.dim(` Source: ${sourceDir}\n`));
196
-
197
- // Step 4: Select files
198
- const selectedFiles = await selectFiles(sourceDir);
199
-
200
- if (selectedFiles.length === 0) {
201
- showError('No files selected. Aborting.');
202
- return;
203
- }
204
-
205
- console.log(chalk.green(`\n✓ Selected ${selectedFiles.length} files\n`));
206
-
207
- // Step 5: Handle replacements based on mode
208
- let replacements = [...existingReplacements];
209
-
210
- if (isQuickAdd) {
211
- // Quick add mode - just use existing replacements
212
- if (replacements.length > 0) {
213
- console.log(chalk.cyan(' Using existing replacements (quick-add mode)\n'));
214
- }
215
-
216
- // Ask if they want to add more
217
- const { addMore } = await inquirer.prompt([
218
- {
219
- type: 'confirm',
220
- name: 'addMore',
221
- message: 'Add additional replacements?',
222
- default: false
223
- }
224
- ]);
225
-
226
- if (addMore) {
227
- const additionalReplacements = await promptKeywordReplacements();
228
- replacements = [...replacements, ...additionalReplacements];
229
- }
230
- } else if (existingMapping) {
231
- // Extend mode - prompt for more replacements to add to existing
232
- console.log(chalk.cyan('\n Add more replacements (existing will be preserved):\n'));
233
- const additionalReplacements = await promptKeywordReplacements();
234
- replacements = [...replacements, ...additionalReplacements];
235
- } else {
236
- // Fresh start - prompt for all replacements
237
- replacements = await promptKeywordReplacements();
238
- }
239
-
240
- // Step 6: Confirm
241
- const confirmed = await showSummaryAndConfirm(
242
- selectedFiles.length,
243
- destDir,
244
- replacements
245
- );
246
-
247
- if (!confirmed) {
248
- showInfo('Operation cancelled.');
249
- return;
250
- }
251
-
252
- // Step 7: Create destination directory
253
- if (!existsSync(destDir)) {
254
- mkdirSync(destDir, { recursive: true });
255
- console.log(chalk.dim(` Created directory: ${destDir}`));
256
- }
257
-
258
- // Step 8: Copy and anonymize files
259
- const spinner = ora('Copying and anonymizing files...').start();
260
-
261
- const anonymizer = createAnonymizer(replacements);
262
- let lastFile = '';
263
-
264
- const results = await copyFiles(
265
- selectedFiles,
266
- sourceDir,
267
- destDir,
268
- anonymizer,
269
- (current, total, file) => {
270
- lastFile = file;
271
- spinner.text = `Copying files... ${current}/${total} - ${file}`;
272
- },
273
- replacements // Pass replacements for path anonymization
274
- );
275
-
276
- spinner.succeed(`Copied ${results.copied} files`);
277
-
278
- if (results.pathsRenamed > 0) {
279
- console.log(chalk.cyan(` 📁 ${results.pathsRenamed} paths renamed`));
280
- }
281
-
282
- if (results.transformed > 0) {
283
- console.log(chalk.cyan(` 📝 ${results.transformed} files had content replaced`));
284
- }
285
-
286
- if (results.errors.length > 0) {
287
- console.log(chalk.yellow(` ⚠️ ${results.errors.length} files had errors`));
288
- results.errors.forEach(e => {
289
- console.log(chalk.dim(` - ${e.file}: ${e.error}`));
290
- });
291
- }
292
-
293
- // Step 9: Prepare new file mappings
294
- const newFiles = selectedFiles.map(f => {
295
- const originalPath = relative(sourceDir, f);
296
- let anonymizedPath = originalPath;
297
- for (const { original, replacement } of replacements) {
298
- const regex = new RegExp(original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
299
- anonymizedPath = anonymizedPath.replace(regex, replacement);
300
- }
301
- return {
302
- original: originalPath,
303
- cloaked: anonymizedPath
304
- };
305
- });
306
-
307
- // Step 10: Check for existing mapping and merge if found
308
- let mapping;
309
- let isIncremental = false;
310
-
311
- if (existingMapping) {
312
- // Merge with existing
313
- mapping = mergeMapping(existingMapping, newFiles);
314
- isIncremental = true;
315
- console.log(chalk.cyan(` 🔄 Merged with existing mapping`));
316
- } else {
317
- // Create new mapping
318
- mapping = createMapping({
319
- sourceDir,
320
- destDir,
321
- replacements,
322
- files: newFiles
323
- });
324
- }
325
-
326
- const mapPath = saveMapping(destDir, mapping);
327
-
328
- if (isIncremental) {
329
- const history = mapping.pullHistory || [];
330
- const lastPull = history[history.length - 1];
331
- console.log(chalk.dim(` 📋 Mapping updated: ${lastPull?.filesAdded || 0} new files added (total: ${mapping.stats?.totalFiles})`));
332
- } else {
333
- console.log(chalk.dim(` 📋 Mapping saved: ${mapPath}`));
334
- }
335
-
336
- // Done!
337
- showSuccess('Extraction complete!');
338
- console.log(chalk.white(` 📂 Files extracted to: ${chalk.cyan.bold(destDir)}`));
339
-
340
- if (isQuickAdd || isIncremental) {
341
- console.log(chalk.dim(`\n Tip: Run again to add more files quickly\n`));
342
- } else {
343
- console.log(chalk.dim(`\n To restore later, run: ${chalk.white('repo-cloak push')}\n`));
344
- }
345
-
346
- } catch (error) {
347
- showError(`Pull failed: ${error.message}`);
348
- if (process.env.DEBUG) {
349
- console.error(error);
350
- }
351
- }
352
- }
1
+ /**
2
+ * Pull Command
3
+ * Extract files and anonymize sensitive information
4
+ * Supports quick-add mode when pulling to existing cloaked directory
5
+ */
6
+
7
+ import ora from 'ora';
8
+ import chalk from 'chalk';
9
+ import inquirer from 'inquirer';
10
+ import { existsSync, mkdirSync } from 'fs';
11
+ import { resolve, relative } from 'path';
12
+
13
+ import { selectFiles } from '../ui/fileSelector.js';
14
+ import {
15
+ promptSourceDirectory,
16
+ promptDestinationDirectory,
17
+ promptKeywordReplacements,
18
+ showSummaryAndConfirm,
19
+ confirmAction
20
+ } from '../ui/prompts.js';
21
+ import { showSuccess, showError, showInfo } from '../ui/banner.js';
22
+ import { getAllFiles } from '../core/scanner.js';
23
+
24
+ import { copyFiles } from '../core/copier.js';
25
+ import { createAnonymizer } from '../core/anonymizer.js';
26
+ import { createMapping, saveMapping, loadRawMapping, mergeMapping, hasMapping, decryptMapping } from '../core/mapper.js';
27
+ import { getOrCreateSecret, hasSecret, decryptReplacements } from '../core/crypto.js';
28
+ import { isGitRepo, getChangedFiles } from '../core/git.js';
29
+
30
+ export async function pull(options = {}) {
31
+ try {
32
+ let destDir = null;
33
+ let existingMapping = null;
34
+ let existingReplacements = [];
35
+ let sourceDir = null;
36
+ let isQuickAdd = false;
37
+
38
+ // Step 1: Check if current directory is already a cloaked directory
39
+ const currentDir = process.cwd();
40
+
41
+ if (hasMapping(currentDir) && !options.dest) {
42
+ // Running from inside an existing cloaked directory - auto-detect!
43
+ destDir = currentDir;
44
+ existingMapping = loadRawMapping(destDir);
45
+
46
+ if (!options.force) {
47
+ console.log(chalk.cyan('\n Existing cloaked directory detected'));
48
+ console.log(chalk.dim(` Created: ${existingMapping.timestamp}`));
49
+ console.log(chalk.dim(` Files: ${existingMapping.stats?.totalFiles || existingMapping.files?.length || 0}`));
50
+ console.log(chalk.dim(` Replacements: ${existingMapping.replacements?.length || 0}\n`));
51
+ }
52
+
53
+ if (options.force) {
54
+ isQuickAdd = true;
55
+ console.log(chalk.cyan('\n Restoring missing/outdated files from source...'));
56
+ } else {
57
+ // Ask if they want quick-add mode
58
+ const { mode } = await inquirer.prompt([
59
+ {
60
+ type: 'list',
61
+ name: 'mode',
62
+ message: 'What would you like to do?',
63
+ choices: [
64
+ {
65
+ name: 'Quick Add - Use existing replacements and add more files',
66
+ value: 'quick'
67
+ },
68
+ {
69
+ name: 'Add More Replacements - Add files with additional anonymization',
70
+ value: 'extend'
71
+ },
72
+ {
73
+ name: 'Fresh Start - Choose new destination',
74
+ value: 'fresh'
75
+ }
76
+ ]
77
+ }
78
+ ]);
79
+
80
+ if (mode === 'fresh') {
81
+ // Get a new destination
82
+ destDir = await promptDestinationDirectory();
83
+ existingMapping = null;
84
+ } else {
85
+ isQuickAdd = mode === 'quick';
86
+ }
87
+ }
88
+
89
+ if (existingMapping) {
90
+ // Decrypt existing replacements
91
+ if (existingMapping.encrypted && hasSecret()) {
92
+ const secret = getOrCreateSecret();
93
+ try {
94
+ const decrypted = decryptReplacements(existingMapping.replacements || [], secret);
95
+ existingReplacements = decrypted.filter(r => !r.decryptFailed);
96
+
97
+ if (existingReplacements.length > 0 && !options.force) {
98
+ console.log(chalk.green(' Existing replacements loaded:\n'));
99
+ existingReplacements.forEach(r => {
100
+ console.log(chalk.dim(` "${r.original}" "${r.replacement}"`));
101
+ });
102
+ console.log('');
103
+ }
104
+ } catch (err) {
105
+ console.log(chalk.yellow(' Could not decrypt existing replacements'));
106
+ }
107
+ } else if (!existingMapping.encrypted && existingMapping.replacements) {
108
+ // Not encrypted - use directly
109
+ existingReplacements = existingMapping.replacements;
110
+
111
+ if (existingReplacements.length > 0 && !options.force) {
112
+ console.log(chalk.green(' Existing replacements loaded:\n'));
113
+ existingReplacements.forEach(r => {
114
+ console.log(chalk.dim(` "${r.original}" "${r.replacement}"`));
115
+ });
116
+ console.log('');
117
+ }
118
+ }
119
+
120
+ // Try to get original source path
121
+ if (existingMapping.encrypted && hasSecret()) {
122
+ const secret = getOrCreateSecret();
123
+ try {
124
+ const decrypted = decryptMapping(existingMapping, secret);
125
+ if (decrypted.source?.path && existsSync(decrypted.source.path)) {
126
+ sourceDir = decrypted.source.path;
127
+ if (!options.force) {
128
+ console.log(chalk.dim(` Source: ${sourceDir}\n`));
129
+ }
130
+ }
131
+ } catch (err) {
132
+ // Source path couldn't be decrypted, will prompt
133
+ }
134
+ } else if (!existingMapping.encrypted && existingMapping.source?.path) {
135
+ // Not encrypted - use directly
136
+ if (existsSync(existingMapping.source.path)) {
137
+ sourceDir = existingMapping.source.path;
138
+ if (!options.force) {
139
+ console.log(chalk.dim(` Source: ${sourceDir}\n`));
140
+ }
141
+ }
142
+ }
143
+ }
144
+ } else {
145
+ // Not running from a cloaked directory - ask for destination
146
+ if (options.force) {
147
+ showError('Force flag can only be used within an existing cloaked directory.');
148
+ return;
149
+ }
150
+
151
+ destDir = options.dest
152
+ ? resolve(options.dest)
153
+ : await promptDestinationDirectory();
154
+
155
+ // Check if the chosen destination has an existing mapping
156
+ if (existsSync(destDir) && hasMapping(destDir)) {
157
+ existingMapping = loadRawMapping(destDir);
158
+
159
+ if (!options.force) {
160
+ console.log(chalk.cyan('\n Existing cloaked directory detected'));
161
+ console.log(chalk.dim(` Created: ${existingMapping.timestamp}`));
162
+ console.log(chalk.dim(` Files: ${existingMapping.stats?.totalFiles || existingMapping.files?.length || 0}`));
163
+ console.log(chalk.dim(` Replacements: ${existingMapping.replacements?.length || 0}\n`));
164
+ }
165
+
166
+ if (options.force) {
167
+ isQuickAdd = true;
168
+ console.log(chalk.cyan('\n Restoring missing/outdated files from source...'));
169
+ } else {
170
+ // Ask if they want quick-add mode
171
+ const { mode } = await inquirer.prompt([
172
+ {
173
+ type: 'list',
174
+ name: 'mode',
175
+ message: 'What would you like to do?',
176
+ choices: [
177
+ { name: 'Quick Add - Use existing replacements and add more files', value: 'quick' },
178
+ { name: 'Add More Replacements - Add files with additional anonymization', value: 'extend' },
179
+ { name: 'Fresh Start - Choose new destination', value: 'fresh' }
180
+ ]
181
+ }
182
+ ]);
183
+
184
+ if (mode === 'fresh') {
185
+ destDir = await promptDestinationDirectory();
186
+ existingMapping = null;
187
+ } else {
188
+ isQuickAdd = mode === 'quick';
189
+ }
190
+ }
191
+
192
+ if (existingMapping) {
193
+ // Decrypt existing replacements
194
+ if (existingMapping.encrypted && hasSecret()) {
195
+ const secret = getOrCreateSecret();
196
+ try {
197
+ const decrypted = decryptReplacements(existingMapping.replacements || [], secret);
198
+ existingReplacements = decrypted.filter(r => !r.decryptFailed);
199
+
200
+ if (existingReplacements.length > 0 && !options.force) {
201
+ console.log(chalk.green(' Existing replacements loaded:\n'));
202
+ existingReplacements.forEach(r => {
203
+ console.log(chalk.dim(` "${r.original}" → "${r.replacement}"`));
204
+ });
205
+ console.log('');
206
+ }
207
+ } catch (err) {
208
+ console.log(chalk.yellow(' Could not decrypt existing replacements'));
209
+ }
210
+ } else if (!existingMapping.encrypted && existingMapping.replacements) {
211
+ // Not encrypted - use directly
212
+ existingReplacements = existingMapping.replacements;
213
+ if (existingReplacements.length > 0 && !options.force) {
214
+ console.log(chalk.green(' Existing replacements loaded:\n'));
215
+ existingReplacements.forEach(r => {
216
+ console.log(chalk.dim(` "${r.original}" "${r.replacement}"`));
217
+ });
218
+ console.log('');
219
+ }
220
+ }
221
+
222
+
223
+
224
+ // Try to get original source path
225
+ if (existingMapping.encrypted && hasSecret()) {
226
+ const secret = getOrCreateSecret();
227
+ try {
228
+ const decrypted = decryptMapping(existingMapping, secret);
229
+ if (decrypted.source?.path && existsSync(decrypted.source.path)) {
230
+ sourceDir = decrypted.source.path;
231
+ if (!options.force) {
232
+ console.log(chalk.dim(` Source: ${sourceDir}\n`));
233
+ }
234
+ }
235
+ } catch (err) {
236
+ // Source path couldn't be decrypted, will prompt
237
+ }
238
+ } else if (!existingMapping.encrypted && existingMapping.source?.path) {
239
+ if (existsSync(existingMapping.source.path)) {
240
+ sourceDir = existingMapping.source.path;
241
+ if (!options.force) {
242
+ console.log(chalk.dim(` Source: ${sourceDir}\n`));
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+ }
249
+
250
+ // ...
251
+
252
+ // Step 3: Get source directory if not already determined
253
+ if (!sourceDir) {
254
+ sourceDir = options.source
255
+ ? resolve(options.source)
256
+ : await promptSourceDirectory();
257
+ }
258
+
259
+ if (!existsSync(sourceDir)) {
260
+ showError(`Source directory does not exist: ${sourceDir}`);
261
+ return;
262
+ }
263
+
264
+ if (!options.force) {
265
+ console.log(chalk.dim(` Source: ${sourceDir}\n`));
266
+ }
267
+
268
+ // Step 4: Select files (Check for Git integration first)
269
+ let selectedFiles = [];
270
+ let useGitFiles = false;
271
+
272
+ if (options.force) {
273
+ // In force mode, we just re-pull all files that are in the mapping
274
+ if (existingMapping && existingMapping.files) {
275
+ // We need to reconstruct the absolute paths to the source files
276
+ // The mapping stores 'original' as relative path to sourceDir
277
+ // But we need to check if we can decrypt them
278
+ let filesToPull = [];
279
+
280
+ if (existingMapping.encrypted) {
281
+ // We already decrypted the files list earlier if possible
282
+ // But let's be sure we have the decrypted file list
283
+ if (existingMapping.files[0]?.original.iv) {
284
+ // Still encrypted - we need the secret
285
+ const secret = getOrCreateSecret();
286
+ try {
287
+ const decryptedFiles = existingMapping.files.map(f => ({
288
+ original: decrypt(f.original, secret),
289
+ cloaked: f.cloaked
290
+ }));
291
+ filesToPull = decryptedFiles;
292
+ } catch (e) {
293
+ showError('Could not decrypt file list. Cannot force pull.');
294
+ return;
295
+ }
296
+ } else {
297
+ // Already decrypted or never encrypted
298
+ filesToPull = existingMapping.files;
299
+ }
300
+ } else {
301
+ filesToPull = existingMapping.files;
302
+ }
303
+
304
+ selectedFiles = filesToPull.map(f => resolve(sourceDir, f.original));
305
+
306
+ // NEW: Also scan local directory for files that might exist here but not in mapping
307
+ try {
308
+ const localFiles = getAllFiles(destDir);
309
+ // Create reverse replacements for deanonymizing paths
310
+ const reverseReplacements = existingReplacements.map(r => ({
311
+ original: r.replacement,
312
+ replacement: r.original
313
+ }));
314
+
315
+ // Helper to reverse anonymize path
316
+ const reverseAnonymize = (path) => {
317
+ let result = path;
318
+ for (const { original, replacement } of reverseReplacements) {
319
+ const regex = new RegExp(original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
320
+ result = result.replace(regex, replacement);
321
+ }
322
+ return result;
323
+ };
324
+
325
+ let addedCount = 0;
326
+ localFiles.forEach(file => {
327
+ // Get path relative to destDir
328
+ const relativeDestPath = relative(destDir, file.absolutePath);
329
+
330
+ // Reverse anonymize to get potential source path
331
+ const relativeSourcePath = reverseAnonymize(relativeDestPath);
332
+ const absoluteSourcePath = resolve(sourceDir, relativeSourcePath);
333
+
334
+ // Check if this file exists in source
335
+ if (existsSync(absoluteSourcePath)) {
336
+ // Add to selectedFiles if not already there
337
+ if (!selectedFiles.includes(absoluteSourcePath)) {
338
+ selectedFiles.push(absoluteSourcePath);
339
+ addedCount++;
340
+ }
341
+ }
342
+ });
343
+
344
+ if (addedCount > 0) {
345
+ console.log(chalk.cyan(` Found ${addedCount} additional local files to sync from source.`));
346
+ }
347
+ } catch (err) {
348
+ // Ignore scanning errors
349
+ }
350
+
351
+ if (selectedFiles.length > 0) {
352
+ console.log(chalk.cyan(` Force pulling ${selectedFiles.length} files...`));
353
+ } else {
354
+ showError('No files found to pull (checked mapping and local directory).');
355
+ return;
356
+ }
357
+ } else {
358
+ showError('No files found in existing mapping.');
359
+ return;
360
+ }
361
+ } else if (isGitRepo(sourceDir)) {
362
+ const { useGit } = await inquirer.prompt([
363
+ {
364
+ type: 'confirm',
365
+ name: 'useGit',
366
+ message: 'Git repository detected. Do you want to select changed/added files?',
367
+ default: false
368
+ }
369
+ ]);
370
+
371
+ if (useGit) {
372
+ const spinner = ora('Scanning changed files...').start();
373
+ const gitFiles = await getChangedFiles(sourceDir);
374
+ spinner.stop();
375
+
376
+ if (gitFiles.length === 0) {
377
+ console.log(chalk.yellow(' No changed or added files found in Git status.'));
378
+ const { fallback } = await inquirer.prompt([
379
+ {
380
+ type: 'confirm',
381
+ name: 'fallback',
382
+ message: 'Do you want to manually select files instead?',
383
+ default: true
384
+ }
385
+ ]);
386
+
387
+ if (!fallback) {
388
+ return;
389
+ }
390
+ } else {
391
+ // Filter to absolute paths and exist check
392
+ const validGitFiles = gitFiles
393
+ .map(f => resolve(sourceDir, f))
394
+ .filter(f => existsSync(f));
395
+
396
+ if (validGitFiles.length > 0) {
397
+ console.log(chalk.green(` Found ${validGitFiles.length} changed files.`));
398
+
399
+ // Let user confirm/deselect git files
400
+ const { confirmGitFiles } = await inquirer.prompt([
401
+ {
402
+ type: 'checkbox',
403
+ name: 'confirmGitFiles',
404
+ message: 'Select changed files to extract:',
405
+ choices: validGitFiles.map(f => ({
406
+ name: relative(sourceDir, f),
407
+ value: f,
408
+ checked: true
409
+ }))
410
+ }
411
+ ]);
412
+
413
+ selectedFiles = confirmGitFiles;
414
+ useGitFiles = true;
415
+ }
416
+ }
417
+ }
418
+ }
419
+
420
+ if (!options.force && (!useGitFiles || selectedFiles.length === 0)) {
421
+ selectedFiles = await selectFiles(sourceDir);
422
+ }
423
+
424
+ if (selectedFiles.length === 0) {
425
+ showError('No files selected. Aborting.');
426
+ return;
427
+ }
428
+
429
+ if (!options.force) {
430
+ console.log(chalk.green(`\n✓ Selected ${selectedFiles.length} files\n`));
431
+ }
432
+
433
+ // Step 5: Handle replacements based on mode
434
+ let replacements = [...existingReplacements];
435
+
436
+ if (!options.force) {
437
+ if (isQuickAdd) {
438
+ // Quick add mode - just use existing replacements
439
+ if (replacements.length > 0) {
440
+ console.log(chalk.cyan(' Using existing replacements (quick-add mode)\n'));
441
+ }
442
+
443
+ // Ask if they want to add more
444
+ const { addMore } = await inquirer.prompt([
445
+ {
446
+ type: 'confirm',
447
+ name: 'addMore',
448
+ message: 'Add additional replacements?',
449
+ default: false
450
+ }
451
+ ]);
452
+
453
+ if (addMore) {
454
+ const additionalReplacements = await promptKeywordReplacements();
455
+ replacements = [...replacements, ...additionalReplacements];
456
+ }
457
+ } else if (existingMapping) {
458
+ // Extend mode - prompt for more replacements to add to existing
459
+ console.log(chalk.cyan('\n Add more replacements (existing will be preserved):\n'));
460
+ const additionalReplacements = await promptKeywordReplacements();
461
+ replacements = [...replacements, ...additionalReplacements];
462
+ } else {
463
+ // Fresh start - prompt for all replacements
464
+ replacements = await promptKeywordReplacements();
465
+ }
466
+ }
467
+
468
+ // Step 6: Confirm
469
+ if (!options.force) {
470
+ const confirmed = await showSummaryAndConfirm(
471
+ selectedFiles.length,
472
+ destDir,
473
+ replacements
474
+ );
475
+
476
+ if (!confirmed) {
477
+ showInfo('Operation cancelled.');
478
+ return;
479
+ }
480
+ }
481
+
482
+ // Step 7: Create destination directory
483
+ if (!existsSync(destDir)) {
484
+ mkdirSync(destDir, { recursive: true });
485
+ console.log(chalk.dim(` Created directory: ${destDir}`));
486
+ }
487
+
488
+ // Step 8: Copy and anonymize files
489
+ const spinner = ora('Copying and anonymizing files...').start();
490
+
491
+ const anonymizer = createAnonymizer(replacements);
492
+ let lastFile = '';
493
+
494
+ const results = await copyFiles(
495
+ selectedFiles,
496
+ sourceDir,
497
+ destDir,
498
+ anonymizer,
499
+ (current, total, file) => {
500
+ lastFile = file;
501
+ spinner.text = `Copying files... ${current}/${total} - ${file}`;
502
+ },
503
+ replacements // Pass replacements for path anonymization
504
+ );
505
+
506
+ spinner.succeed(`Copied ${results.copied} files`);
507
+
508
+ if (results.pathsRenamed > 0) {
509
+ console.log(chalk.cyan(` 📁 ${results.pathsRenamed} paths renamed`));
510
+ }
511
+
512
+ if (results.transformed > 0) {
513
+ console.log(chalk.cyan(` 📝 ${results.transformed} files had content replaced`));
514
+ }
515
+
516
+ if (results.errors.length > 0) {
517
+ console.log(chalk.yellow(` ⚠️ ${results.errors.length} files had errors`));
518
+ results.errors.forEach(e => {
519
+ console.log(chalk.dim(` - ${e.file}: ${e.error}`));
520
+ });
521
+ }
522
+
523
+ // Step 9: Prepare new file mappings
524
+ const newFiles = selectedFiles.map(f => {
525
+ const originalPath = relative(sourceDir, f);
526
+ let anonymizedPath = originalPath;
527
+ for (const { original, replacement } of replacements) {
528
+ const regex = new RegExp(original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
529
+ anonymizedPath = anonymizedPath.replace(regex, replacement);
530
+ }
531
+ return {
532
+ original: originalPath,
533
+ cloaked: anonymizedPath
534
+ };
535
+ });
536
+
537
+ // Step 10: Check for existing mapping and merge if found
538
+ let mapping;
539
+ let isIncremental = false;
540
+
541
+ if (existingMapping) {
542
+ // Merge with existing
543
+ mapping = mergeMapping(existingMapping, newFiles);
544
+ isIncremental = true;
545
+ console.log(chalk.cyan(` 🔄 Merged with existing mapping`));
546
+ } else {
547
+ // Create new mapping
548
+ mapping = createMapping({
549
+ sourceDir,
550
+ destDir,
551
+ replacements,
552
+ files: newFiles
553
+ });
554
+ }
555
+
556
+ const mapPath = saveMapping(destDir, mapping);
557
+
558
+ if (isIncremental) {
559
+ const history = mapping.pullHistory || [];
560
+ const lastPull = history[history.length - 1];
561
+ console.log(chalk.dim(` 📋 Mapping updated: ${lastPull?.filesAdded || 0} new files added (total: ${mapping.stats?.totalFiles})`));
562
+ } else {
563
+ console.log(chalk.dim(` 📋 Mapping saved: ${mapPath}`));
564
+ }
565
+
566
+ // Done!
567
+ showSuccess('Extraction complete!');
568
+ console.log(chalk.white(` 📂 Files extracted to: ${chalk.cyan.bold(destDir)}`));
569
+
570
+ if (isQuickAdd || isIncremental) {
571
+ console.log(chalk.dim(`\n Tip: Run again to add more files quickly\n`));
572
+ } else {
573
+ console.log(chalk.dim(`\n To restore later, run: ${chalk.white('repo-cloak push')}\n`));
574
+ }
575
+
576
+ } catch (error) {
577
+ showError(`Pull failed: ${error.message}`);
578
+ if (process.env.DEBUG) {
579
+ console.error(error);
580
+ }
581
+ }
582
+ }