cluttry 1.0.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/.vwt.json +12 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/dist/commands/doctor.d.ts +7 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +198 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +90 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/list.d.ts +11 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +106 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/open.d.ts +11 -0
- package/dist/commands/open.d.ts.map +1 -0
- package/dist/commands/open.js +52 -0
- package/dist/commands/open.js.map +1 -0
- package/dist/commands/prune.d.ts +7 -0
- package/dist/commands/prune.d.ts.map +1 -0
- package/dist/commands/prune.js +33 -0
- package/dist/commands/prune.js.map +1 -0
- package/dist/commands/rm.d.ts +13 -0
- package/dist/commands/rm.d.ts.map +1 -0
- package/dist/commands/rm.js +99 -0
- package/dist/commands/rm.js.map +1 -0
- package/dist/commands/spawn.d.ts +17 -0
- package/dist/commands/spawn.d.ts.map +1 -0
- package/dist/commands/spawn.js +127 -0
- package/dist/commands/spawn.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/config.d.ts +44 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +109 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/git.d.ts +73 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +225 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/output.d.ts +33 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +83 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/paths.d.ts +36 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/paths.js +84 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/secrets.d.ts +50 -0
- package/dist/lib/secrets.d.ts.map +1 -0
- package/dist/lib/secrets.js +146 -0
- package/dist/lib/secrets.js.map +1 -0
- package/dist/lib/types.d.ts +63 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +5 -0
- package/dist/lib/types.js.map +1 -0
- package/package.json +41 -0
- package/src/commands/doctor.ts +222 -0
- package/src/commands/init.ts +120 -0
- package/src/commands/list.ts +133 -0
- package/src/commands/open.ts +70 -0
- package/src/commands/prune.ts +36 -0
- package/src/commands/rm.ts +125 -0
- package/src/commands/spawn.ts +169 -0
- package/src/index.ts +112 -0
- package/src/lib/config.ts +120 -0
- package/src/lib/git.ts +243 -0
- package/src/lib/output.ts +102 -0
- package/src/lib/paths.ts +108 -0
- package/src/lib/secrets.ts +193 -0
- package/src/lib/types.ts +69 -0
- package/tests/config.test.ts +102 -0
- package/tests/paths.test.ts +155 -0
- package/tests/secrets.test.ts +150 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for path utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { sanitizeBranchName, getDefaultWorktreePath, resolveBranchOrPath } from '../src/lib/paths.js';
|
|
7
|
+
|
|
8
|
+
describe('sanitizeBranchName', () => {
|
|
9
|
+
it('replaces forward slashes with dashes', () => {
|
|
10
|
+
// Slashes become dashes (collapsed from double-dashes)
|
|
11
|
+
expect(sanitizeBranchName('feature/add-login')).toBe('feature-add-login');
|
|
12
|
+
expect(sanitizeBranchName('fix/bug/123')).toBe('fix-bug-123');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('replaces Windows-forbidden characters', () => {
|
|
16
|
+
expect(sanitizeBranchName('test<>:"|?*\\')).toBe('test');
|
|
17
|
+
expect(sanitizeBranchName('branch:name')).toBe('branch-name');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('replaces whitespace with dashes', () => {
|
|
21
|
+
expect(sanitizeBranchName('my branch name')).toBe('my-branch-name');
|
|
22
|
+
expect(sanitizeBranchName('tabs\there')).toBe('tabs-here');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('removes leading and trailing dots', () => {
|
|
26
|
+
expect(sanitizeBranchName('.hidden')).toBe('hidden');
|
|
27
|
+
expect(sanitizeBranchName('trailing.')).toBe('trailing');
|
|
28
|
+
expect(sanitizeBranchName('...dots...')).toBe('dots');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('collapses multiple dashes', () => {
|
|
32
|
+
expect(sanitizeBranchName('a--b---c')).toBe('a-b-c');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('removes leading and trailing dashes', () => {
|
|
36
|
+
expect(sanitizeBranchName('-start')).toBe('start');
|
|
37
|
+
expect(sanitizeBranchName('end-')).toBe('end');
|
|
38
|
+
expect(sanitizeBranchName('---middle---')).toBe('middle');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('handles complex branch names', () => {
|
|
42
|
+
expect(sanitizeBranchName('feature/user-auth/oauth2.0')).toBe('feature-user-auth-oauth2.0');
|
|
43
|
+
// Note: # is allowed in branch names and preserved
|
|
44
|
+
expect(sanitizeBranchName('fix/issue#123')).toBe('fix-issue#123');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('handles simple branch names unchanged', () => {
|
|
48
|
+
expect(sanitizeBranchName('main')).toBe('main');
|
|
49
|
+
expect(sanitizeBranchName('develop')).toBe('develop');
|
|
50
|
+
expect(sanitizeBranchName('my-feature')).toBe('my-feature');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('getDefaultWorktreePath', () => {
|
|
55
|
+
const repoRoot = '/home/user/myrepo';
|
|
56
|
+
|
|
57
|
+
it('returns explicit path when provided', () => {
|
|
58
|
+
const result = getDefaultWorktreePath(repoRoot, 'feature', {
|
|
59
|
+
explicitPath: '/custom/path',
|
|
60
|
+
});
|
|
61
|
+
expect(result).toBe('/custom/path');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('resolves relative explicit path against CWD', () => {
|
|
65
|
+
const result = getDefaultWorktreePath(repoRoot, 'feature', {
|
|
66
|
+
explicitPath: './relative/path',
|
|
67
|
+
});
|
|
68
|
+
expect(result).toContain('relative/path');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('uses base directory with repo name and sanitized branch', () => {
|
|
72
|
+
const result = getDefaultWorktreePath(repoRoot, 'feature/test', {
|
|
73
|
+
baseDir: '/worktrees',
|
|
74
|
+
repoName: 'myrepo',
|
|
75
|
+
});
|
|
76
|
+
expect(result).toBe('/worktrees/myrepo/feature-test');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('derives repo name from repoRoot if not provided', () => {
|
|
80
|
+
const result = getDefaultWorktreePath(repoRoot, 'main', {
|
|
81
|
+
baseDir: '/worktrees',
|
|
82
|
+
});
|
|
83
|
+
expect(result).toBe('/worktrees/myrepo/main');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('resolves relative base directory against repo root', () => {
|
|
87
|
+
const result = getDefaultWorktreePath(repoRoot, 'feature', {
|
|
88
|
+
baseDir: '../sibling',
|
|
89
|
+
repoName: 'myrepo',
|
|
90
|
+
});
|
|
91
|
+
expect(result).toContain('myrepo/feature');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('defaults to .worktrees/<branch> inside repo', () => {
|
|
95
|
+
const result = getDefaultWorktreePath(repoRoot, 'my-branch');
|
|
96
|
+
expect(result).toBe('/home/user/myrepo/.worktrees/my-branch');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('sanitizes branch names in default path', () => {
|
|
100
|
+
const result = getDefaultWorktreePath(repoRoot, 'feature/login');
|
|
101
|
+
expect(result).toBe('/home/user/myrepo/.worktrees/feature-login');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('resolveBranchOrPath', () => {
|
|
106
|
+
const repoRoot = '/home/user/myrepo';
|
|
107
|
+
const worktrees = [
|
|
108
|
+
{ branch: 'main', path: '/home/user/myrepo' },
|
|
109
|
+
{ branch: 'feature-login', path: '/home/user/myrepo/.worktrees/feature-login' },
|
|
110
|
+
{ branch: null, path: '/home/user/myrepo/.worktrees/detached' },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
it('finds worktree by exact branch name', () => {
|
|
114
|
+
const result = resolveBranchOrPath('feature-login', worktrees, repoRoot);
|
|
115
|
+
expect(result).toEqual({
|
|
116
|
+
branch: 'feature-login',
|
|
117
|
+
path: '/home/user/myrepo/.worktrees/feature-login',
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('finds worktree by absolute path', () => {
|
|
122
|
+
const result = resolveBranchOrPath('/home/user/myrepo/.worktrees/feature-login', worktrees, repoRoot);
|
|
123
|
+
expect(result).toEqual({
|
|
124
|
+
branch: 'feature-login',
|
|
125
|
+
path: '/home/user/myrepo/.worktrees/feature-login',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('finds worktree by partial path suffix', () => {
|
|
130
|
+
const result = resolveBranchOrPath('feature-login', worktrees, repoRoot);
|
|
131
|
+
expect(result).not.toBeNull();
|
|
132
|
+
expect(result?.branch).toBe('feature-login');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns null when no match found', () => {
|
|
136
|
+
const result = resolveBranchOrPath('nonexistent', worktrees, repoRoot);
|
|
137
|
+
expect(result).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('handles detached worktrees', () => {
|
|
141
|
+
const result = resolveBranchOrPath('/home/user/myrepo/.worktrees/detached', worktrees, repoRoot);
|
|
142
|
+
expect(result).toEqual({
|
|
143
|
+
branch: null,
|
|
144
|
+
path: '/home/user/myrepo/.worktrees/detached',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('handles main worktree', () => {
|
|
149
|
+
const result = resolveBranchOrPath('main', worktrees, repoRoot);
|
|
150
|
+
expect(result).toEqual({
|
|
151
|
+
branch: 'main',
|
|
152
|
+
path: '/home/user/myrepo',
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for secrets/file safety module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { checkFileSafety } from '../src/lib/secrets.js';
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as git from '../src/lib/git.js';
|
|
9
|
+
|
|
10
|
+
// Mock the modules
|
|
11
|
+
vi.mock('node:fs', () => ({
|
|
12
|
+
existsSync: vi.fn(),
|
|
13
|
+
copyFileSync: vi.fn(),
|
|
14
|
+
symlinkSync: vi.fn(),
|
|
15
|
+
mkdirSync: vi.fn(),
|
|
16
|
+
statSync: vi.fn(),
|
|
17
|
+
readdirSync: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('../src/lib/git.js', () => ({
|
|
21
|
+
isTracked: vi.fn(),
|
|
22
|
+
isIgnored: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe('checkFileSafety', () => {
|
|
26
|
+
const repoRoot = '/home/user/myrepo';
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.resetAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns unsafe result when file does not exist', () => {
|
|
37
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
38
|
+
|
|
39
|
+
const result = checkFileSafety('.env', repoRoot);
|
|
40
|
+
|
|
41
|
+
expect(result.safe).toBe(false);
|
|
42
|
+
expect(result.exists).toBe(false);
|
|
43
|
+
expect(result.reason).toBe('File does not exist');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns unsafe result when file is tracked by git', () => {
|
|
47
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
48
|
+
vi.mocked(git.isTracked).mockReturnValue(true);
|
|
49
|
+
|
|
50
|
+
const result = checkFileSafety('.env', repoRoot);
|
|
51
|
+
|
|
52
|
+
expect(result.safe).toBe(false);
|
|
53
|
+
expect(result.exists).toBe(true);
|
|
54
|
+
expect(result.isTracked).toBe(true);
|
|
55
|
+
expect(result.reason).toBe('File is tracked by git (would be committed)');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns unsafe result when file is not ignored by git', () => {
|
|
59
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
60
|
+
vi.mocked(git.isTracked).mockReturnValue(false);
|
|
61
|
+
vi.mocked(git.isIgnored).mockReturnValue(false);
|
|
62
|
+
|
|
63
|
+
const result = checkFileSafety('.env', repoRoot);
|
|
64
|
+
|
|
65
|
+
expect(result.safe).toBe(false);
|
|
66
|
+
expect(result.exists).toBe(true);
|
|
67
|
+
expect(result.isTracked).toBe(false);
|
|
68
|
+
expect(result.isIgnored).toBe(false);
|
|
69
|
+
expect(result.reason).toBe('File is not ignored by git (could be accidentally committed)');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns safe result when file exists, is not tracked, and is ignored', () => {
|
|
73
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
74
|
+
vi.mocked(git.isTracked).mockReturnValue(false);
|
|
75
|
+
vi.mocked(git.isIgnored).mockReturnValue(true);
|
|
76
|
+
|
|
77
|
+
const result = checkFileSafety('.env', repoRoot);
|
|
78
|
+
|
|
79
|
+
expect(result.safe).toBe(true);
|
|
80
|
+
expect(result.exists).toBe(true);
|
|
81
|
+
expect(result.isTracked).toBe(false);
|
|
82
|
+
expect(result.isIgnored).toBe(true);
|
|
83
|
+
expect(result.reason).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('handles absolute paths correctly', () => {
|
|
87
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
88
|
+
vi.mocked(git.isTracked).mockReturnValue(false);
|
|
89
|
+
vi.mocked(git.isIgnored).mockReturnValue(true);
|
|
90
|
+
|
|
91
|
+
const result = checkFileSafety('/home/user/myrepo/.env', repoRoot);
|
|
92
|
+
|
|
93
|
+
expect(result.path).toBe('.env');
|
|
94
|
+
expect(result.safe).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('handles nested paths correctly', () => {
|
|
98
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
99
|
+
vi.mocked(git.isTracked).mockReturnValue(false);
|
|
100
|
+
vi.mocked(git.isIgnored).mockReturnValue(true);
|
|
101
|
+
|
|
102
|
+
const result = checkFileSafety('config/secrets/oauth.json', repoRoot);
|
|
103
|
+
|
|
104
|
+
expect(result.path).toBe('config/secrets/oauth.json');
|
|
105
|
+
expect(result.safe).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('safety enforcement rules', () => {
|
|
110
|
+
const repoRoot = '/home/user/myrepo';
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
vi.resetAllMocks();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('NEVER allows tracked files to be copied/symlinked', () => {
|
|
117
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
118
|
+
vi.mocked(git.isTracked).mockReturnValue(true);
|
|
119
|
+
vi.mocked(git.isIgnored).mockReturnValue(false); // Even if somehow ignored
|
|
120
|
+
|
|
121
|
+
const result = checkFileSafety('src/config.ts', repoRoot);
|
|
122
|
+
|
|
123
|
+
expect(result.safe).toBe(false);
|
|
124
|
+
expect(result.reason).toContain('tracked');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('ONLY allows files that are explicitly ignored', () => {
|
|
128
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
129
|
+
vi.mocked(git.isTracked).mockReturnValue(false);
|
|
130
|
+
vi.mocked(git.isIgnored).mockReturnValue(false);
|
|
131
|
+
|
|
132
|
+
const result = checkFileSafety('random-file.txt', repoRoot);
|
|
133
|
+
|
|
134
|
+
expect(result.safe).toBe(false);
|
|
135
|
+
expect(result.reason).toContain('not ignored');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('correctly identifies safe secret files', () => {
|
|
139
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
140
|
+
vi.mocked(git.isTracked).mockReturnValue(false);
|
|
141
|
+
vi.mocked(git.isIgnored).mockReturnValue(true);
|
|
142
|
+
|
|
143
|
+
const secretFiles = ['.env', '.env.local', 'config/oauth.json', '.secrets/api-key'];
|
|
144
|
+
|
|
145
|
+
for (const file of secretFiles) {
|
|
146
|
+
const result = checkFileSafety(file, repoRoot);
|
|
147
|
+
expect(result.safe).toBe(true);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"declarationMap": true,
|
|
16
|
+
"sourceMap": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
20
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
include: ['tests/**/*.test.ts'],
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
reporter: ['text', 'json', 'html'],
|
|
11
|
+
include: ['src/**/*.ts'],
|
|
12
|
+
exclude: ['src/index.ts'],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
});
|