repo-cloak-cli 1.3.2 → 1.3.4
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/DEVELOPMENT.md +74 -0
- package/LINKEDIN.md +14 -0
- package/MEDIUM.md +335 -0
- package/README.md +65 -23
- package/SLIDES-TO-GENERATE-BY-NOTEBOOK-LLM.md +52 -0
- package/package.json +6 -5
- package/src/cli.js +2 -0
- package/src/commands/pull.js +305 -125
- package/src/core/agents-template.js +37 -0
- package/src/core/anonymizer.js +14 -4
- package/src/core/git.js +68 -0
- package/src/core/path-cache.js +131 -0
- package/src/core/secrets.js +169 -0
- package/src/ui/banner.js +32 -33
- package/src/ui/prompts.js +139 -34
- package/src/ui/treeCheckboxSelector.js +253 -0
- package/test-tree.js +2 -0
- package/tests/anonymizer.test.js +17 -17
- package/tests/copier.test.js +3 -3
- package/tests/crypto.test.js +3 -3
- package/tests/git.test.js +59 -1
- package/tests/path-cache.test.js +164 -0
- package/tests/secrets.test.js +93 -0
- package/.github/workflows/release.yml +0 -92
- package/src/ui/fileSelector.js +0 -256
package/tests/git.test.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
-
import { isGitRepo, getChangedFiles } from '../src/core/git.js';
|
|
6
|
+
import { isGitRepo, getChangedFiles, getRecentCommits, getFilesChangedInCommits } from '../src/core/git.js';
|
|
7
7
|
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
8
8
|
import { join } from 'path';
|
|
9
9
|
import { tmpdir } from 'os';
|
|
@@ -100,4 +100,62 @@ describe('Git Module', () => {
|
|
|
100
100
|
expect(files).not.toContain('old.txt');
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
describe('getRecentCommits', () => {
|
|
105
|
+
it('should return empty array for empty repo', async () => {
|
|
106
|
+
initGitRepo(testDir);
|
|
107
|
+
const commits = await getRecentCommits(testDir, 10);
|
|
108
|
+
expect(commits).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should return recent commits', async () => {
|
|
112
|
+
initGitRepo(testDir);
|
|
113
|
+
writeFileSync(join(testDir, 'test1.txt'), 'test1');
|
|
114
|
+
execSync('git add test1.txt', { cwd: testDir });
|
|
115
|
+
execSync('git commit -m "first commit"', { cwd: testDir });
|
|
116
|
+
|
|
117
|
+
writeFileSync(join(testDir, 'test2.txt'), 'test2');
|
|
118
|
+
execSync('git add test2.txt', { cwd: testDir });
|
|
119
|
+
execSync('git commit -m "second commit"', { cwd: testDir });
|
|
120
|
+
|
|
121
|
+
const commits = await getRecentCommits(testDir, 10);
|
|
122
|
+
expect(commits).toHaveLength(2);
|
|
123
|
+
expect(commits[0].message).toBe('second commit');
|
|
124
|
+
expect(commits[0].hash.length).toBeGreaterThan(0);
|
|
125
|
+
expect(commits[1].message).toBe('first commit');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('getFilesChangedInCommits', () => {
|
|
130
|
+
it('should return empty array if no commits provided', async () => {
|
|
131
|
+
initGitRepo(testDir);
|
|
132
|
+
const files = await getFilesChangedInCommits(testDir, []);
|
|
133
|
+
expect(files).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should return files changed in specific commits', async () => {
|
|
137
|
+
initGitRepo(testDir);
|
|
138
|
+
writeFileSync(join(testDir, 'test1.txt'), 'test1');
|
|
139
|
+
execSync('git add test1.txt', { cwd: testDir });
|
|
140
|
+
execSync('git commit -m "first commit"', { cwd: testDir });
|
|
141
|
+
|
|
142
|
+
const firstCommitHash = execSync('git rev-parse HEAD', { cwd: testDir }).toString().trim();
|
|
143
|
+
|
|
144
|
+
writeFileSync(join(testDir, 'test2.txt'), 'test2');
|
|
145
|
+
execSync('git add test2.txt', { cwd: testDir });
|
|
146
|
+
execSync('git commit -m "second commit"', { cwd: testDir });
|
|
147
|
+
|
|
148
|
+
const secondCommitHash = execSync('git rev-parse HEAD', { cwd: testDir }).toString().trim();
|
|
149
|
+
|
|
150
|
+
const filesFirst = await getFilesChangedInCommits(testDir, [firstCommitHash]);
|
|
151
|
+
expect(filesFirst).toEqual(['test1.txt']);
|
|
152
|
+
|
|
153
|
+
const filesSecond = await getFilesChangedInCommits(testDir, [secondCommitHash]);
|
|
154
|
+
expect(filesSecond).toEqual(['test2.txt']);
|
|
155
|
+
|
|
156
|
+
const bothFiles = await getFilesChangedInCommits(testDir, [firstCommitHash, secondCommitHash]);
|
|
157
|
+
expect(bothFiles.sort()).toEqual(['test1.txt', 'test2.txt']);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
103
160
|
});
|
|
161
|
+
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Cache Module Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
6
|
+
import { existsSync, rmSync, mkdirSync } from 'fs';
|
|
7
|
+
|
|
8
|
+
// Use createRequire inside vi.hoisted so we can call path/os before ES imports are resolved
|
|
9
|
+
const { testConfigDir } = vi.hoisted(() => {
|
|
10
|
+
// eslint-disable-next-line no-undef
|
|
11
|
+
const os = require('os');
|
|
12
|
+
// eslint-disable-next-line no-undef
|
|
13
|
+
const path = require('path');
|
|
14
|
+
return {
|
|
15
|
+
testConfigDir: path.join(os.tmpdir(), `repo-cloak-path-cache-test-${Date.now()}`)
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
vi.mock('../src/core/crypto.js', () => ({
|
|
20
|
+
hasSecret: () => true,
|
|
21
|
+
getOrCreateSecret: () => 'test-secret-key',
|
|
22
|
+
encrypt: (text) => `enc:${text}`,
|
|
23
|
+
decrypt: (text) => (text.startsWith('enc:') ? text.slice(4) : null)
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('../src/core/path-cache.js', async () => {
|
|
27
|
+
const { hasSecret, getOrCreateSecret, encrypt, decrypt } = await import('../src/core/crypto.js');
|
|
28
|
+
const { existsSync: fsExists, readFileSync, writeFileSync, mkdirSync: fsMkdir } = await import('fs');
|
|
29
|
+
const path = await import('path');
|
|
30
|
+
|
|
31
|
+
const CACHE_FILE = path.join(testConfigDir, 'path-cache.json');
|
|
32
|
+
const MAX_PATHS = 10;
|
|
33
|
+
|
|
34
|
+
function loadRawCache() {
|
|
35
|
+
try {
|
|
36
|
+
if (!fsExists(CACHE_FILE)) return { sources: [], destinations: [] };
|
|
37
|
+
const parsed = JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
|
|
38
|
+
return {
|
|
39
|
+
sources: Array.isArray(parsed.sources) ? parsed.sources : [],
|
|
40
|
+
destinations: Array.isArray(parsed.destinations) ? parsed.destinations : []
|
|
41
|
+
};
|
|
42
|
+
} catch { return { sources: [], destinations: [] }; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function saveRawCache(cache) {
|
|
46
|
+
if (!fsExists(testConfigDir)) fsMkdir(testConfigDir, { recursive: true });
|
|
47
|
+
writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decryptPaths(encrypted, secret) {
|
|
51
|
+
return encrypted.map(e => { try { return decrypt(e, secret); } catch { return null; } }).filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
getSourcePaths: () => {
|
|
56
|
+
if (!hasSecret()) return [];
|
|
57
|
+
return decryptPaths(loadRawCache().sources, getOrCreateSecret());
|
|
58
|
+
},
|
|
59
|
+
getDestPaths: () => {
|
|
60
|
+
if (!hasSecret()) return [];
|
|
61
|
+
return decryptPaths(loadRawCache().destinations, getOrCreateSecret());
|
|
62
|
+
},
|
|
63
|
+
addSourcePath: (p) => {
|
|
64
|
+
const secret = getOrCreateSecret();
|
|
65
|
+
const cache = loadRawCache();
|
|
66
|
+
const existing = decryptPaths(cache.sources, secret);
|
|
67
|
+
const deduped = [p, ...existing.filter(x => x !== p)].slice(0, MAX_PATHS);
|
|
68
|
+
cache.sources = deduped.map(x => encrypt(x, secret));
|
|
69
|
+
saveRawCache(cache);
|
|
70
|
+
},
|
|
71
|
+
addDestPath: (p) => {
|
|
72
|
+
const secret = getOrCreateSecret();
|
|
73
|
+
const cache = loadRawCache();
|
|
74
|
+
const existing = decryptPaths(cache.destinations, secret);
|
|
75
|
+
const deduped = [p, ...existing.filter(x => x !== p)].slice(0, MAX_PATHS);
|
|
76
|
+
cache.destinations = deduped.map(x => encrypt(x, secret));
|
|
77
|
+
saveRawCache(cache);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
import { getSourcePaths, getDestPaths, addSourcePath, addDestPath } from '../src/core/path-cache.js';
|
|
83
|
+
|
|
84
|
+
describe('Path Cache Module', () => {
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
if (existsSync(testConfigDir)) rmSync(testConfigDir, { recursive: true, force: true });
|
|
87
|
+
mkdirSync(testConfigDir, { recursive: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
if (existsSync(testConfigDir)) rmSync(testConfigDir, { recursive: true, force: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('getSourcePaths', () => {
|
|
95
|
+
it('returns empty array when no cache exists', () => {
|
|
96
|
+
expect(getSourcePaths()).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns previously added source paths', () => {
|
|
100
|
+
addSourcePath('/path/to/repo');
|
|
101
|
+
expect(getSourcePaths()).toContain('/path/to/repo');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('getDestPaths', () => {
|
|
106
|
+
it('returns empty array when no cache exists', () => {
|
|
107
|
+
expect(getDestPaths()).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns previously added destination paths', () => {
|
|
111
|
+
addDestPath('/path/to/output');
|
|
112
|
+
expect(getDestPaths()).toContain('/path/to/output');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('addSourcePath', () => {
|
|
117
|
+
it('adds multiple paths and returns most recent first', () => {
|
|
118
|
+
addSourcePath('/first');
|
|
119
|
+
addSourcePath('/second');
|
|
120
|
+
addSourcePath('/third');
|
|
121
|
+
|
|
122
|
+
const paths = getSourcePaths();
|
|
123
|
+
expect(paths[0]).toBe('/third');
|
|
124
|
+
expect(paths[1]).toBe('/second');
|
|
125
|
+
expect(paths[2]).toBe('/first');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('deduplicates paths, moving existing path to front', () => {
|
|
129
|
+
addSourcePath('/a');
|
|
130
|
+
addSourcePath('/b');
|
|
131
|
+
addSourcePath('/a');
|
|
132
|
+
|
|
133
|
+
const paths = getSourcePaths();
|
|
134
|
+
expect(paths[0]).toBe('/a');
|
|
135
|
+
expect(paths.filter(p => p === '/a')).toHaveLength(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('caps at MAX_PATHS (10) entries', () => {
|
|
139
|
+
for (let i = 0; i < 15; i++) addSourcePath(`/path/${i}`);
|
|
140
|
+
expect(getSourcePaths().length).toBeLessThanOrEqual(10);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('addDestPath', () => {
|
|
145
|
+
it('adds multiple destination paths and returns most recent first', () => {
|
|
146
|
+
addDestPath('/out/a');
|
|
147
|
+
addDestPath('/out/b');
|
|
148
|
+
|
|
149
|
+
const paths = getDestPaths();
|
|
150
|
+
expect(paths[0]).toBe('/out/b');
|
|
151
|
+
expect(paths[1]).toBe('/out/a');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('deduplicates destination paths', () => {
|
|
155
|
+
addDestPath('/out/x');
|
|
156
|
+
addDestPath('/out/y');
|
|
157
|
+
addDestPath('/out/x');
|
|
158
|
+
|
|
159
|
+
const paths = getDestPaths();
|
|
160
|
+
expect(paths[0]).toBe('/out/x');
|
|
161
|
+
expect(paths.filter(p => p === '/out/x')).toHaveLength(1);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets Detection Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { scanFileForSecrets, scanFilesForSecrets } from '../src/core/secrets.js';
|
|
7
|
+
import { existsSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { tmpdir } from 'os';
|
|
10
|
+
|
|
11
|
+
describe('Secrets Module', () => {
|
|
12
|
+
let testDir;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
testDir = join(tmpdir(), `repo-cloak-secrets-test-${Date.now()}`);
|
|
16
|
+
mkdirSync(testDir, { recursive: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
if (existsSync(testDir)) {
|
|
21
|
+
try {
|
|
22
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
23
|
+
} catch (e) {
|
|
24
|
+
// Ignore cleanup errors
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('scanFileForSecrets', () => {
|
|
30
|
+
it('should detect AWS Access Keys', () => {
|
|
31
|
+
const filePath = join(testDir, 'aws.env');
|
|
32
|
+
writeFileSync(filePath, 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\n');
|
|
33
|
+
|
|
34
|
+
const findings = scanFileForSecrets(filePath);
|
|
35
|
+
expect(findings).toHaveLength(1);
|
|
36
|
+
expect(findings[0].type).toBe('AWS Access Key ID');
|
|
37
|
+
expect(findings[0].line).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should detect generic API keys', () => {
|
|
41
|
+
const filePath = join(testDir, 'config.js');
|
|
42
|
+
writeFileSync(filePath, `
|
|
43
|
+
const config = {
|
|
44
|
+
api_key: "abc123DEF456ghi789jkl012mno",
|
|
45
|
+
port: 8080
|
|
46
|
+
};
|
|
47
|
+
`);
|
|
48
|
+
|
|
49
|
+
const findings = scanFileForSecrets(filePath);
|
|
50
|
+
expect(findings.length).toBeGreaterThan(0);
|
|
51
|
+
expect(findings[0].type).toBe('Generic API Key / Token');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should not detect harmless text', () => {
|
|
55
|
+
const filePath = join(testDir, 'readme.md');
|
|
56
|
+
writeFileSync(filePath, 'This is a normal file without secrets.\nAPI keys are good.\n');
|
|
57
|
+
|
|
58
|
+
const findings = scanFileForSecrets(filePath);
|
|
59
|
+
expect(findings).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should detect multiple secrets in one file', () => {
|
|
63
|
+
const filePath = join(testDir, 'keys.txt');
|
|
64
|
+
writeFileSync(filePath, 'AKIAIOSFODNN7EXAMPLE\nghp_123456789012345678901234567890123456\n');
|
|
65
|
+
|
|
66
|
+
const findings = scanFileForSecrets(filePath);
|
|
67
|
+
expect(findings).toHaveLength(2);
|
|
68
|
+
expect(findings.find(f => f.type === 'AWS Access Key ID')).toBeDefined();
|
|
69
|
+
expect(findings.find(f => f.type === 'GitHub Token')).toBeDefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should ignore binary files', () => {
|
|
73
|
+
const filePath = join(testDir, 'image.png');
|
|
74
|
+
writeFileSync(filePath, 'AKIAIOSFODNN7EXAMPLE'); // Test content but .png extension
|
|
75
|
+
|
|
76
|
+
const findings = scanFileForSecrets(filePath);
|
|
77
|
+
expect(findings).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('scanFilesForSecrets', async () => {
|
|
82
|
+
it('should aggregate findings from multiple files', async () => {
|
|
83
|
+
const file1 = join(testDir, 'f1.txt');
|
|
84
|
+
writeFileSync(file1, 'AKIAIOSFODNN7EXAMPLE');
|
|
85
|
+
|
|
86
|
+
const file2 = join(testDir, 'f2.txt');
|
|
87
|
+
writeFileSync(file2, 'ghp_123456789012345678901234567890123456');
|
|
88
|
+
|
|
89
|
+
const findings = await scanFilesForSecrets([file1, file2]);
|
|
90
|
+
expect(findings).toHaveLength(2);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
name: Release & Publish
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
# Manual trigger with version bump
|
|
5
|
-
workflow_dispatch:
|
|
6
|
-
inputs:
|
|
7
|
-
version_type:
|
|
8
|
-
description: 'Version bump type'
|
|
9
|
-
required: true
|
|
10
|
-
default: 'patch'
|
|
11
|
-
type: choice
|
|
12
|
-
options:
|
|
13
|
-
- patch
|
|
14
|
-
- minor
|
|
15
|
-
- major
|
|
16
|
-
|
|
17
|
-
# Auto-publish on push (only if version changed)
|
|
18
|
-
push:
|
|
19
|
-
branches:
|
|
20
|
-
- main
|
|
21
|
-
- master
|
|
22
|
-
paths:
|
|
23
|
-
- 'package.json'
|
|
24
|
-
- 'src/**'
|
|
25
|
-
- 'bin/**'
|
|
26
|
-
|
|
27
|
-
jobs:
|
|
28
|
-
release:
|
|
29
|
-
runs-on: ubuntu-latest
|
|
30
|
-
|
|
31
|
-
steps:
|
|
32
|
-
- name: 🎭 Checkout repository
|
|
33
|
-
uses: actions/checkout@v4
|
|
34
|
-
with:
|
|
35
|
-
token: ${{ secrets.GITHUB_TOKEN }}
|
|
36
|
-
fetch-depth: 0
|
|
37
|
-
|
|
38
|
-
- name: 📦 Setup Node.js
|
|
39
|
-
uses: actions/setup-node@v4
|
|
40
|
-
with:
|
|
41
|
-
node-version: '20'
|
|
42
|
-
registry-url: 'https://registry.npmjs.org'
|
|
43
|
-
|
|
44
|
-
- name: 🔧 Configure Git
|
|
45
|
-
run: |
|
|
46
|
-
git config user.name "github-actions[bot]"
|
|
47
|
-
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
48
|
-
|
|
49
|
-
- name: 📥 Install dependencies
|
|
50
|
-
run: npm ci
|
|
51
|
-
|
|
52
|
-
- name: 📝 Bump version (manual trigger only)
|
|
53
|
-
if: github.event_name == 'workflow_dispatch'
|
|
54
|
-
run: |
|
|
55
|
-
npm version ${{ inputs.version_type }} -m "chore: release v%s"
|
|
56
|
-
git push --follow-tags
|
|
57
|
-
|
|
58
|
-
- name: 🔍 Check if version exists on npm
|
|
59
|
-
id: check_version
|
|
60
|
-
run: |
|
|
61
|
-
PACKAGE_NAME=$(node -p "require('./package.json').name")
|
|
62
|
-
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
|
63
|
-
|
|
64
|
-
# Check if this version already exists on npm
|
|
65
|
-
NPM_VERSION=$(npm view $PACKAGE_NAME version 2>/dev/null || echo "0.0.0")
|
|
66
|
-
|
|
67
|
-
echo "Local version: $LOCAL_VERSION"
|
|
68
|
-
echo "npm version: $NPM_VERSION"
|
|
69
|
-
|
|
70
|
-
if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then
|
|
71
|
-
echo "should_publish=false" >> $GITHUB_OUTPUT
|
|
72
|
-
echo "⏭️ Version $LOCAL_VERSION already exists on npm, skipping publish"
|
|
73
|
-
else
|
|
74
|
-
echo "should_publish=true" >> $GITHUB_OUTPUT
|
|
75
|
-
echo "✅ Version $LOCAL_VERSION is new, will publish"
|
|
76
|
-
fi
|
|
77
|
-
|
|
78
|
-
- name: 🚀 Publish to npm
|
|
79
|
-
if: steps.check_version.outputs.should_publish == 'true'
|
|
80
|
-
run: |
|
|
81
|
-
npm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }}
|
|
82
|
-
npm publish --access public
|
|
83
|
-
env:
|
|
84
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
85
|
-
|
|
86
|
-
- name: ✅ Done
|
|
87
|
-
run: |
|
|
88
|
-
if [ "${{ steps.check_version.outputs.should_publish }}" = "true" ]; then
|
|
89
|
-
echo "🎉 Published successfully!"
|
|
90
|
-
else
|
|
91
|
-
echo "ℹ️ No publish needed - version unchanged"
|
|
92
|
-
fi
|
package/src/ui/fileSelector.js
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Interactive File Selector
|
|
3
|
-
* Hierarchical tree view with pagination
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import inquirer from 'inquirer';
|
|
7
|
-
import chalk from 'chalk';
|
|
8
|
-
import { readdirSync } from 'fs';
|
|
9
|
-
import { join, relative, sep } from 'path';
|
|
10
|
-
|
|
11
|
-
// Directories to always ignore
|
|
12
|
-
const IGNORE_DIRS = new Set([
|
|
13
|
-
'node_modules', '.git', '.svn', '.hg', '.DS_Store', 'Thumbs.db',
|
|
14
|
-
'.idea', '.vscode', '__pycache__', '.pytest_cache', 'dist', 'build',
|
|
15
|
-
'.next', '.nuxt', 'coverage', '.nyc_output', '.repo-cloak-map.json',
|
|
16
|
-
'obj', 'bin', 'packages', '.vs', 'TestResults'
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
|
-
const PAGE_SIZE = 50;
|
|
20
|
-
|
|
21
|
-
function shouldIgnore(name) {
|
|
22
|
-
return IGNORE_DIRS.has(name) || name.startsWith('.');
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function buildFileIndex(baseDir, maxDepth = 8) {
|
|
26
|
-
const files = [];
|
|
27
|
-
|
|
28
|
-
function scan(dir, depth = 0) {
|
|
29
|
-
if (depth > maxDepth) return;
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
33
|
-
|
|
34
|
-
entries.sort((a, b) => {
|
|
35
|
-
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
36
|
-
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
37
|
-
return a.name.localeCompare(b.name);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
for (const entry of entries) {
|
|
41
|
-
if (shouldIgnore(entry.name)) continue;
|
|
42
|
-
|
|
43
|
-
const fullPath = join(dir, entry.name);
|
|
44
|
-
const relativePath = relative(baseDir, fullPath);
|
|
45
|
-
|
|
46
|
-
files.push({
|
|
47
|
-
name: entry.name,
|
|
48
|
-
path: fullPath,
|
|
49
|
-
relativePath,
|
|
50
|
-
isDirectory: entry.isDirectory(),
|
|
51
|
-
depth
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
if (entry.isDirectory()) {
|
|
55
|
-
scan(fullPath, depth + 1);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
} catch (error) { }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
scan(baseDir);
|
|
62
|
-
return files;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function getFilesInDirectory(dir) {
|
|
66
|
-
const files = [];
|
|
67
|
-
|
|
68
|
-
function collect(currentDir) {
|
|
69
|
-
try {
|
|
70
|
-
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
71
|
-
for (const entry of entries) {
|
|
72
|
-
if (shouldIgnore(entry.name)) continue;
|
|
73
|
-
const fullPath = join(currentDir, entry.name);
|
|
74
|
-
if (entry.isDirectory()) {
|
|
75
|
-
collect(fullPath);
|
|
76
|
-
} else {
|
|
77
|
-
files.push(fullPath);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
} catch (error) { }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
collect(dir);
|
|
84
|
-
return files;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function formatTreeItem(item) {
|
|
88
|
-
const indent = ' '.repeat(item.depth);
|
|
89
|
-
const icon = item.isDirectory ? '📁' : '📄';
|
|
90
|
-
const name = item.isDirectory
|
|
91
|
-
? chalk.blue.bold(item.name)
|
|
92
|
-
: chalk.white(item.name);
|
|
93
|
-
return `${indent}${icon} ${name}`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Show paginated results with Load More option
|
|
98
|
-
*/
|
|
99
|
-
async function showPaginatedResults(filtered, selectedPaths, page = 0) {
|
|
100
|
-
const start = page * PAGE_SIZE;
|
|
101
|
-
const end = Math.min(start + PAGE_SIZE, filtered.length);
|
|
102
|
-
const pageItems = filtered.slice(start, end);
|
|
103
|
-
const hasMore = end < filtered.length;
|
|
104
|
-
const remaining = filtered.length - end;
|
|
105
|
-
|
|
106
|
-
// Build choices
|
|
107
|
-
const choices = pageItems.map(f => {
|
|
108
|
-
let isChecked = false;
|
|
109
|
-
if (f.isDirectory) {
|
|
110
|
-
const childFiles = getFilesInDirectory(f.path);
|
|
111
|
-
isChecked = childFiles.length > 0 && childFiles.every(fp => selectedPaths.has(fp));
|
|
112
|
-
} else {
|
|
113
|
-
isChecked = selectedPaths.has(f.path);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
name: formatTreeItem(f),
|
|
118
|
-
value: f,
|
|
119
|
-
checked: isChecked,
|
|
120
|
-
short: f.relativePath
|
|
121
|
-
};
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// Add separator and load more option if there are more results
|
|
125
|
-
if (hasMore) {
|
|
126
|
-
choices.push(new inquirer.Separator(chalk.dim('─────────────────────────────')));
|
|
127
|
-
choices.push({
|
|
128
|
-
name: chalk.cyan(`⏬ Load next ${Math.min(PAGE_SIZE, remaining)} items (${remaining} remaining)`),
|
|
129
|
-
value: '__LOAD_MORE__',
|
|
130
|
-
short: 'Load more'
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const pageInfo = `Page ${page + 1} (${start + 1}-${end} of ${filtered.length})`;
|
|
135
|
-
|
|
136
|
-
const { picked } = await inquirer.prompt([
|
|
137
|
-
{
|
|
138
|
-
type: 'checkbox',
|
|
139
|
-
name: 'picked',
|
|
140
|
-
message: `${pageInfo}:`,
|
|
141
|
-
choices,
|
|
142
|
-
pageSize: 25,
|
|
143
|
-
loop: false
|
|
144
|
-
}
|
|
145
|
-
]);
|
|
146
|
-
|
|
147
|
-
return { picked, pageItems, hasMore };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export async function selectFiles(sourceDir) {
|
|
151
|
-
console.log(chalk.cyan('\n📂 Scanning directory...'));
|
|
152
|
-
|
|
153
|
-
const fileIndex = buildFileIndex(sourceDir);
|
|
154
|
-
console.log(chalk.dim(` Found ${fileIndex.length} items\n`));
|
|
155
|
-
|
|
156
|
-
if (fileIndex.length === 0) {
|
|
157
|
-
console.log(chalk.yellow(' No files found.'));
|
|
158
|
-
return [];
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const selectedPaths = new Set();
|
|
162
|
-
let continueLoop = true;
|
|
163
|
-
|
|
164
|
-
console.log(chalk.cyan('🔍 File Selection'));
|
|
165
|
-
console.log(chalk.dim(' • Type to filter → Space to tick → Enter to confirm'));
|
|
166
|
-
console.log(chalk.dim(' • Select "Load more" to see next batch'));
|
|
167
|
-
console.log(chalk.dim(' • Empty search = done\n'));
|
|
168
|
-
|
|
169
|
-
while (continueLoop) {
|
|
170
|
-
if (selectedPaths.size > 0) {
|
|
171
|
-
console.log(chalk.green(`\n 📦 ${selectedPaths.size} file(s) selected`));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const { searchTerm } = await inquirer.prompt([
|
|
175
|
-
{
|
|
176
|
-
type: 'input',
|
|
177
|
-
name: 'searchTerm',
|
|
178
|
-
message: 'Filter (empty = done):',
|
|
179
|
-
prefix: '🔎'
|
|
180
|
-
}
|
|
181
|
-
]);
|
|
182
|
-
|
|
183
|
-
if (!searchTerm.trim()) {
|
|
184
|
-
if (selectedPaths.size === 0) {
|
|
185
|
-
const { confirmExit } = await inquirer.prompt([
|
|
186
|
-
{ type: 'confirm', name: 'confirmExit', message: 'No files selected. Exit?', default: false }
|
|
187
|
-
]);
|
|
188
|
-
if (confirmExit) continueLoop = false;
|
|
189
|
-
} else {
|
|
190
|
-
continueLoop = false;
|
|
191
|
-
}
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Filter by search term
|
|
196
|
-
const query = searchTerm.toLowerCase();
|
|
197
|
-
const filtered = fileIndex.filter(f =>
|
|
198
|
-
f.relativePath.toLowerCase().includes(query) ||
|
|
199
|
-
f.name.toLowerCase().includes(query)
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
if (filtered.length === 0) {
|
|
203
|
-
console.log(chalk.yellow(' No matches found.'));
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
console.log(chalk.dim(` Found ${filtered.length} matches`));
|
|
208
|
-
|
|
209
|
-
// Pagination loop
|
|
210
|
-
let page = 0;
|
|
211
|
-
let continuePaging = true;
|
|
212
|
-
|
|
213
|
-
while (continuePaging) {
|
|
214
|
-
const { picked, pageItems, hasMore } = await showPaginatedResults(filtered, selectedPaths, page);
|
|
215
|
-
|
|
216
|
-
// Check if user selected "Load More"
|
|
217
|
-
const loadMore = picked.find(p => p === '__LOAD_MORE__');
|
|
218
|
-
const actualPicks = picked.filter(p => p !== '__LOAD_MORE__');
|
|
219
|
-
|
|
220
|
-
// Process deselections for this page
|
|
221
|
-
const pickedPaths = new Set(actualPicks.map(p => p.path));
|
|
222
|
-
for (const f of pageItems) {
|
|
223
|
-
if (!pickedPaths.has(f.path)) {
|
|
224
|
-
if (f.isDirectory) {
|
|
225
|
-
const childFiles = getFilesInDirectory(f.path);
|
|
226
|
-
childFiles.forEach(fp => selectedPaths.delete(fp));
|
|
227
|
-
} else {
|
|
228
|
-
selectedPaths.delete(f.path);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Process selections
|
|
234
|
-
for (const f of actualPicks) {
|
|
235
|
-
if (f.isDirectory) {
|
|
236
|
-
const childFiles = getFilesInDirectory(f.path);
|
|
237
|
-
childFiles.forEach(fp => selectedPaths.add(fp));
|
|
238
|
-
console.log(chalk.green(` + 📁 ${f.relativePath}/ (${childFiles.length} files)`));
|
|
239
|
-
} else {
|
|
240
|
-
selectedPaths.add(f.path);
|
|
241
|
-
console.log(chalk.green(` + 📄 ${f.relativePath}`));
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Handle load more or exit pagination
|
|
246
|
-
if (loadMore && hasMore) {
|
|
247
|
-
page++;
|
|
248
|
-
} else {
|
|
249
|
-
continuePaging = false;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
console.log(chalk.green(`\n✓ Total: ${selectedPaths.size} files selected\n`));
|
|
255
|
-
return Array.from(selectedPaths);
|
|
256
|
-
}
|