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.
Files changed (79) hide show
  1. package/.vwt.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +444 -0
  4. package/dist/commands/doctor.d.ts +7 -0
  5. package/dist/commands/doctor.d.ts.map +1 -0
  6. package/dist/commands/doctor.js +198 -0
  7. package/dist/commands/doctor.js.map +1 -0
  8. package/dist/commands/init.d.ts +11 -0
  9. package/dist/commands/init.d.ts.map +1 -0
  10. package/dist/commands/init.js +90 -0
  11. package/dist/commands/init.js.map +1 -0
  12. package/dist/commands/list.d.ts +11 -0
  13. package/dist/commands/list.d.ts.map +1 -0
  14. package/dist/commands/list.js +106 -0
  15. package/dist/commands/list.js.map +1 -0
  16. package/dist/commands/open.d.ts +11 -0
  17. package/dist/commands/open.d.ts.map +1 -0
  18. package/dist/commands/open.js +52 -0
  19. package/dist/commands/open.js.map +1 -0
  20. package/dist/commands/prune.d.ts +7 -0
  21. package/dist/commands/prune.d.ts.map +1 -0
  22. package/dist/commands/prune.js +33 -0
  23. package/dist/commands/prune.js.map +1 -0
  24. package/dist/commands/rm.d.ts +13 -0
  25. package/dist/commands/rm.d.ts.map +1 -0
  26. package/dist/commands/rm.js +99 -0
  27. package/dist/commands/rm.js.map +1 -0
  28. package/dist/commands/spawn.d.ts +17 -0
  29. package/dist/commands/spawn.d.ts.map +1 -0
  30. package/dist/commands/spawn.js +127 -0
  31. package/dist/commands/spawn.js.map +1 -0
  32. package/dist/index.d.ts +8 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +101 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/lib/config.d.ts +44 -0
  37. package/dist/lib/config.d.ts.map +1 -0
  38. package/dist/lib/config.js +109 -0
  39. package/dist/lib/config.js.map +1 -0
  40. package/dist/lib/git.d.ts +73 -0
  41. package/dist/lib/git.d.ts.map +1 -0
  42. package/dist/lib/git.js +225 -0
  43. package/dist/lib/git.js.map +1 -0
  44. package/dist/lib/output.d.ts +33 -0
  45. package/dist/lib/output.d.ts.map +1 -0
  46. package/dist/lib/output.js +83 -0
  47. package/dist/lib/output.js.map +1 -0
  48. package/dist/lib/paths.d.ts +36 -0
  49. package/dist/lib/paths.d.ts.map +1 -0
  50. package/dist/lib/paths.js +84 -0
  51. package/dist/lib/paths.js.map +1 -0
  52. package/dist/lib/secrets.d.ts +50 -0
  53. package/dist/lib/secrets.d.ts.map +1 -0
  54. package/dist/lib/secrets.js +146 -0
  55. package/dist/lib/secrets.js.map +1 -0
  56. package/dist/lib/types.d.ts +63 -0
  57. package/dist/lib/types.d.ts.map +1 -0
  58. package/dist/lib/types.js +5 -0
  59. package/dist/lib/types.js.map +1 -0
  60. package/package.json +41 -0
  61. package/src/commands/doctor.ts +222 -0
  62. package/src/commands/init.ts +120 -0
  63. package/src/commands/list.ts +133 -0
  64. package/src/commands/open.ts +70 -0
  65. package/src/commands/prune.ts +36 -0
  66. package/src/commands/rm.ts +125 -0
  67. package/src/commands/spawn.ts +169 -0
  68. package/src/index.ts +112 -0
  69. package/src/lib/config.ts +120 -0
  70. package/src/lib/git.ts +243 -0
  71. package/src/lib/output.ts +102 -0
  72. package/src/lib/paths.ts +108 -0
  73. package/src/lib/secrets.ts +193 -0
  74. package/src/lib/types.ts +69 -0
  75. package/tests/config.test.ts +102 -0
  76. package/tests/paths.test.ts +155 -0
  77. package/tests/secrets.test.ts +150 -0
  78. package/tsconfig.json +20 -0
  79. package/vitest.config.ts +15 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Output utilities for VWT
3
+ *
4
+ * Provides consistent, colorful terminal output without external dependencies.
5
+ */
6
+
7
+ // ANSI color codes
8
+ const colors = {
9
+ reset: '\x1b[0m',
10
+ bold: '\x1b[1m',
11
+ dim: '\x1b[2m',
12
+
13
+ red: '\x1b[31m',
14
+ green: '\x1b[32m',
15
+ yellow: '\x1b[33m',
16
+ blue: '\x1b[34m',
17
+ magenta: '\x1b[35m',
18
+ cyan: '\x1b[36m',
19
+ white: '\x1b[37m',
20
+ gray: '\x1b[90m',
21
+ };
22
+
23
+ // Check if colors should be used
24
+ const useColors = process.stdout.isTTY && !process.env.NO_COLOR;
25
+
26
+ function colorize(text: string, ...codes: string[]): string {
27
+ if (!useColors) return text;
28
+ return codes.join('') + text + colors.reset;
29
+ }
30
+
31
+ export const fmt = {
32
+ bold: (text: string) => colorize(text, colors.bold),
33
+ dim: (text: string) => colorize(text, colors.dim),
34
+ red: (text: string) => colorize(text, colors.red),
35
+ green: (text: string) => colorize(text, colors.green),
36
+ yellow: (text: string) => colorize(text, colors.yellow),
37
+ blue: (text: string) => colorize(text, colors.blue),
38
+ magenta: (text: string) => colorize(text, colors.magenta),
39
+ cyan: (text: string) => colorize(text, colors.cyan),
40
+ gray: (text: string) => colorize(text, colors.gray),
41
+
42
+ success: (text: string) => colorize(text, colors.green),
43
+ error: (text: string) => colorize(text, colors.red),
44
+ warn: (text: string) => colorize(text, colors.yellow),
45
+ info: (text: string) => colorize(text, colors.cyan),
46
+ path: (text: string) => colorize(text, colors.blue),
47
+ branch: (text: string) => colorize(text, colors.magenta),
48
+ };
49
+
50
+ export function log(message: string): void {
51
+ console.log(message);
52
+ }
53
+
54
+ export function success(message: string): void {
55
+ console.log(fmt.green('✓') + ' ' + message);
56
+ }
57
+
58
+ export function error(message: string): void {
59
+ console.error(fmt.red('✗') + ' ' + message);
60
+ }
61
+
62
+ export function warn(message: string): void {
63
+ console.log(fmt.yellow('⚠') + ' ' + message);
64
+ }
65
+
66
+ export function info(message: string): void {
67
+ console.log(fmt.cyan('ℹ') + ' ' + message);
68
+ }
69
+
70
+ export function header(message: string): void {
71
+ console.log('\n' + fmt.bold(message));
72
+ }
73
+
74
+ export function list(items: string[], prefix = ' '): void {
75
+ for (const item of items) {
76
+ console.log(prefix + '• ' + item);
77
+ }
78
+ }
79
+
80
+ export function table(rows: string[][], columnWidths?: number[]): void {
81
+ if (rows.length === 0) return;
82
+
83
+ // Calculate column widths if not provided
84
+ const widths = columnWidths ?? rows[0].map((_, i) =>
85
+ Math.max(...rows.map(row => (row[i] ?? '').length))
86
+ );
87
+
88
+ for (const row of rows) {
89
+ const paddedCells = row.map((cell, i) =>
90
+ (cell ?? '').padEnd(widths[i] ?? 0)
91
+ );
92
+ console.log(' ' + paddedCells.join(' '));
93
+ }
94
+ }
95
+
96
+ export function json(data: unknown): void {
97
+ console.log(JSON.stringify(data, null, 2));
98
+ }
99
+
100
+ export function newline(): void {
101
+ console.log();
102
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Path utilities for VWT
3
+ */
4
+
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * Sanitize a branch name for use in filesystem paths
9
+ * - Replace slashes with double dashes
10
+ * - Remove or replace other problematic characters
11
+ */
12
+ export function sanitizeBranchName(branch: string): string {
13
+ return branch
14
+ .replace(/\//g, '--') // Replace slashes with double dashes
15
+ .replace(/[<>:"|?*\\]/g, '-') // Replace Windows-forbidden chars
16
+ .replace(/\s+/g, '-') // Replace whitespace
17
+ .replace(/^\.+/, '') // Remove leading dots
18
+ .replace(/\.+$/, '') // Remove trailing dots
19
+ .replace(/-+/g, '-') // Collapse multiple dashes
20
+ .replace(/^-+/, '') // Remove leading dashes
21
+ .replace(/-+$/, ''); // Remove trailing dashes
22
+ }
23
+
24
+ /**
25
+ * Calculate the default worktree path
26
+ */
27
+ export function getDefaultWorktreePath(
28
+ repoRoot: string,
29
+ branch: string,
30
+ options?: {
31
+ explicitPath?: string;
32
+ baseDir?: string;
33
+ repoName?: string;
34
+ }
35
+ ): string {
36
+ // Explicit path wins
37
+ if (options?.explicitPath) {
38
+ // If it's relative, resolve against CWD
39
+ if (!path.isAbsolute(options.explicitPath)) {
40
+ return path.resolve(options.explicitPath);
41
+ }
42
+ return options.explicitPath;
43
+ }
44
+
45
+ const sanitizedBranch = sanitizeBranchName(branch);
46
+
47
+ // Base directory specified
48
+ if (options?.baseDir) {
49
+ const repoName = options.repoName ?? path.basename(repoRoot);
50
+ const baseDir = path.isAbsolute(options.baseDir)
51
+ ? options.baseDir
52
+ : path.resolve(repoRoot, options.baseDir);
53
+ return path.join(baseDir, repoName, sanitizedBranch);
54
+ }
55
+
56
+ // Default: .worktrees/<branch> inside repo
57
+ return path.join(repoRoot, '.worktrees', sanitizedBranch);
58
+ }
59
+
60
+ /**
61
+ * Check if a path is inside the .worktrees directory
62
+ */
63
+ export function isInsideWorktreesDir(targetPath: string, repoRoot: string): boolean {
64
+ const worktreesDir = path.join(repoRoot, '.worktrees');
65
+ const normalizedTarget = path.normalize(targetPath);
66
+ const normalizedWorktrees = path.normalize(worktreesDir);
67
+ return normalizedTarget.startsWith(normalizedWorktrees);
68
+ }
69
+
70
+ /**
71
+ * Get relative path from repo root
72
+ */
73
+ export function getRelativePath(absolutePath: string, repoRoot: string): string {
74
+ return path.relative(repoRoot, absolutePath);
75
+ }
76
+
77
+ /**
78
+ * Resolve a branch-or-path argument to a worktree path
79
+ */
80
+ export function resolveBranchOrPath(
81
+ branchOrPath: string,
82
+ worktrees: Array<{ branch: string | null; path: string }>,
83
+ repoRoot: string
84
+ ): { path: string; branch: string | null } | null {
85
+ // First, try to match by branch name
86
+ const byBranch = worktrees.find((w) => w.branch === branchOrPath);
87
+ if (byBranch) {
88
+ return { path: byBranch.path, branch: byBranch.branch };
89
+ }
90
+
91
+ // Try to match by path (absolute or relative)
92
+ const absolutePath = path.isAbsolute(branchOrPath)
93
+ ? branchOrPath
94
+ : path.resolve(repoRoot, branchOrPath);
95
+
96
+ const byPath = worktrees.find((w) => path.normalize(w.path) === path.normalize(absolutePath));
97
+ if (byPath) {
98
+ return { path: byPath.path, branch: byPath.branch };
99
+ }
100
+
101
+ // Try partial path match (end of path)
102
+ const byPartialPath = worktrees.find((w) => w.path.endsWith(branchOrPath));
103
+ if (byPartialPath) {
104
+ return { path: byPartialPath.path, branch: byPartialPath.branch };
105
+ }
106
+
107
+ return null;
108
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Secret file handling for VWT
3
+ *
4
+ * This module ensures that only git-ignored files are ever copied or symlinked.
5
+ * It provides a safety layer to prevent accidentally exposing tracked files.
6
+ */
7
+
8
+ import { existsSync, copyFileSync, symlinkSync, mkdirSync, statSync, readdirSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { glob } from 'glob';
11
+ import { isTracked, isIgnored } from './git.js';
12
+ import type { SecretMode } from './types.js';
13
+
14
+ export interface FileCheckResult {
15
+ path: string;
16
+ exists: boolean;
17
+ isTracked: boolean;
18
+ isIgnored: boolean;
19
+ safe: boolean;
20
+ reason?: string;
21
+ }
22
+
23
+ /**
24
+ * Check if a file is safe to copy/symlink
25
+ * A file is safe if:
26
+ * 1. It exists
27
+ * 2. It is NOT tracked by git
28
+ * 3. It IS ignored by git
29
+ */
30
+ export function checkFileSafety(filePath: string, repoRoot: string): FileCheckResult {
31
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(repoRoot, filePath);
32
+ const relativePath = path.relative(repoRoot, absolutePath);
33
+
34
+ const result: FileCheckResult = {
35
+ path: relativePath,
36
+ exists: existsSync(absolutePath),
37
+ isTracked: false,
38
+ isIgnored: false,
39
+ safe: false,
40
+ };
41
+
42
+ if (!result.exists) {
43
+ result.reason = 'File does not exist';
44
+ return result;
45
+ }
46
+
47
+ result.isTracked = isTracked(relativePath, repoRoot);
48
+ if (result.isTracked) {
49
+ result.reason = 'File is tracked by git (would be committed)';
50
+ return result;
51
+ }
52
+
53
+ result.isIgnored = isIgnored(relativePath, repoRoot);
54
+ if (!result.isIgnored) {
55
+ result.reason = 'File is not ignored by git (could be accidentally committed)';
56
+ return result;
57
+ }
58
+
59
+ result.safe = true;
60
+ return result;
61
+ }
62
+
63
+ /**
64
+ * Expand glob patterns to actual file paths
65
+ */
66
+ export async function expandIncludePatterns(
67
+ patterns: string[],
68
+ repoRoot: string
69
+ ): Promise<string[]> {
70
+ const allFiles: Set<string> = new Set();
71
+
72
+ for (const pattern of patterns) {
73
+ try {
74
+ const matches = await glob(pattern, {
75
+ cwd: repoRoot,
76
+ dot: true,
77
+ nodir: true,
78
+ });
79
+ for (const match of matches) {
80
+ allFiles.add(match);
81
+ }
82
+ } catch {
83
+ // If glob fails, treat as literal path
84
+ if (existsSync(path.join(repoRoot, pattern))) {
85
+ allFiles.add(pattern);
86
+ }
87
+ }
88
+ }
89
+
90
+ return Array.from(allFiles).sort();
91
+ }
92
+
93
+ /**
94
+ * Get all safe files from include patterns
95
+ */
96
+ export async function getSafeFiles(
97
+ patterns: string[],
98
+ repoRoot: string
99
+ ): Promise<{ safe: FileCheckResult[]; unsafe: FileCheckResult[] }> {
100
+ const files = await expandIncludePatterns(patterns, repoRoot);
101
+ const safe: FileCheckResult[] = [];
102
+ const unsafe: FileCheckResult[] = [];
103
+
104
+ for (const file of files) {
105
+ const result = checkFileSafety(file, repoRoot);
106
+ if (result.safe) {
107
+ safe.push(result);
108
+ } else if (result.exists) {
109
+ // Only report unsafe if file actually exists
110
+ unsafe.push(result);
111
+ }
112
+ }
113
+
114
+ return { safe, unsafe };
115
+ }
116
+
117
+ /**
118
+ * Copy a file to the target directory, preserving relative path
119
+ */
120
+ export function copyFile(
121
+ relativePath: string,
122
+ sourceRoot: string,
123
+ targetRoot: string
124
+ ): void {
125
+ const sourcePath = path.join(sourceRoot, relativePath);
126
+ const targetPath = path.join(targetRoot, relativePath);
127
+
128
+ // Create parent directories if needed
129
+ const targetDir = path.dirname(targetPath);
130
+ if (!existsSync(targetDir)) {
131
+ mkdirSync(targetDir, { recursive: true });
132
+ }
133
+
134
+ copyFileSync(sourcePath, targetPath);
135
+ }
136
+
137
+ /**
138
+ * Create a symlink in the target directory pointing to source
139
+ */
140
+ export function createSymlink(
141
+ relativePath: string,
142
+ sourceRoot: string,
143
+ targetRoot: string
144
+ ): void {
145
+ const sourcePath = path.join(sourceRoot, relativePath);
146
+ const targetPath = path.join(targetRoot, relativePath);
147
+
148
+ // Create parent directories if needed
149
+ const targetDir = path.dirname(targetPath);
150
+ if (!existsSync(targetDir)) {
151
+ mkdirSync(targetDir, { recursive: true });
152
+ }
153
+
154
+ // Use absolute path for symlink target for reliability
155
+ symlinkSync(sourcePath, targetPath);
156
+ }
157
+
158
+ /**
159
+ * Process files according to mode (copy or symlink)
160
+ */
161
+ export async function processSecrets(
162
+ mode: SecretMode,
163
+ patterns: string[],
164
+ sourceRoot: string,
165
+ targetRoot: string
166
+ ): Promise<{ processed: string[]; skipped: FileCheckResult[] }> {
167
+ if (mode === 'none') {
168
+ return { processed: [], skipped: [] };
169
+ }
170
+
171
+ const { safe, unsafe } = await getSafeFiles(patterns, sourceRoot);
172
+ const processed: string[] = [];
173
+
174
+ for (const file of safe) {
175
+ try {
176
+ if (mode === 'copy') {
177
+ copyFile(file.path, sourceRoot, targetRoot);
178
+ } else if (mode === 'symlink') {
179
+ createSymlink(file.path, sourceRoot, targetRoot);
180
+ }
181
+ processed.push(file.path);
182
+ } catch (error) {
183
+ // Add to skipped with error reason
184
+ unsafe.push({
185
+ ...file,
186
+ safe: false,
187
+ reason: `Failed to ${mode}: ${(error as Error).message}`,
188
+ });
189
+ }
190
+ }
191
+
192
+ return { processed, skipped: unsafe };
193
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * VWT Configuration Types
3
+ */
4
+
5
+ export interface VwtConfig {
6
+ /** Base directory for worktrees (optional, defaults to .worktrees/) */
7
+ worktreeBaseDir?: string;
8
+ /** Default mode for secrets handling */
9
+ defaultMode: 'copy' | 'symlink' | 'none';
10
+ /** List of globs/paths to manage (e.g. [".env", ".env.*", "config/oauth*.json"]) */
11
+ include: string[];
12
+ /** Hook commands */
13
+ hooks?: {
14
+ postCreate?: string[];
15
+ };
16
+ /** Default agent command */
17
+ agentCommand?: string;
18
+ }
19
+
20
+ export interface VwtLocalConfig {
21
+ /** Machine-specific base directory override */
22
+ worktreeBaseDir?: string;
23
+ /** Additional include paths for this machine */
24
+ include?: string[];
25
+ /** Additional hooks for this machine */
26
+ hooks?: {
27
+ postCreate?: string[];
28
+ };
29
+ /** Override agent command */
30
+ agentCommand?: string;
31
+ }
32
+
33
+ export interface MergedConfig {
34
+ worktreeBaseDir?: string;
35
+ defaultMode: 'copy' | 'symlink' | 'none';
36
+ include: string[];
37
+ hooks: {
38
+ postCreate: string[];
39
+ };
40
+ agentCommand: string;
41
+ }
42
+
43
+ export interface WorktreeInfo {
44
+ worktree: string;
45
+ head: string;
46
+ branch?: string;
47
+ bare?: boolean;
48
+ detached?: boolean;
49
+ }
50
+
51
+ export interface WorktreeListItem {
52
+ branch: string | null;
53
+ path: string;
54
+ headShort: string;
55
+ dirty: boolean;
56
+ lastModified: Date | null;
57
+ }
58
+
59
+ export type SecretMode = 'copy' | 'symlink' | 'none';
60
+
61
+ export interface SpawnOptions {
62
+ branch: string;
63
+ isNew: boolean;
64
+ path?: string;
65
+ base?: string;
66
+ mode: SecretMode;
67
+ run?: string;
68
+ agent?: 'claude' | 'none';
69
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Unit tests for configuration management
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { mergeConfig } from '../src/lib/config.js';
7
+ import type { VwtConfig, VwtLocalConfig } from '../src/lib/types.js';
8
+
9
+ describe('mergeConfig', () => {
10
+ const baseConfig: VwtConfig = {
11
+ defaultMode: 'copy',
12
+ include: ['.env', '.env.local'],
13
+ hooks: {
14
+ postCreate: ['npm install'],
15
+ },
16
+ agentCommand: 'claude',
17
+ worktreeBaseDir: '/default/base',
18
+ };
19
+
20
+ it('returns defaults when both configs are null', () => {
21
+ const result = mergeConfig(null, null);
22
+ expect(result.defaultMode).toBe('copy');
23
+ expect(result.include).toContain('.env');
24
+ expect(result.agentCommand).toBe('claude');
25
+ });
26
+
27
+ it('uses base config values when no local config', () => {
28
+ const result = mergeConfig(baseConfig, null);
29
+ expect(result.defaultMode).toBe('copy');
30
+ expect(result.include).toEqual(['.env', '.env.local']);
31
+ expect(result.hooks.postCreate).toEqual(['npm install']);
32
+ expect(result.agentCommand).toBe('claude');
33
+ expect(result.worktreeBaseDir).toBe('/default/base');
34
+ });
35
+
36
+ it('merges include arrays from both configs', () => {
37
+ const localConfig: VwtLocalConfig = {
38
+ include: ['credentials.json', '.secrets'],
39
+ };
40
+ const result = mergeConfig(baseConfig, localConfig);
41
+ expect(result.include).toEqual(['.env', '.env.local', 'credentials.json', '.secrets']);
42
+ });
43
+
44
+ it('local worktreeBaseDir overrides base', () => {
45
+ const localConfig: VwtLocalConfig = {
46
+ worktreeBaseDir: '/custom/local/path',
47
+ };
48
+ const result = mergeConfig(baseConfig, localConfig);
49
+ expect(result.worktreeBaseDir).toBe('/custom/local/path');
50
+ });
51
+
52
+ it('local agentCommand overrides base', () => {
53
+ const localConfig: VwtLocalConfig = {
54
+ agentCommand: 'cursor',
55
+ };
56
+ const result = mergeConfig(baseConfig, localConfig);
57
+ expect(result.agentCommand).toBe('cursor');
58
+ });
59
+
60
+ it('merges postCreate hooks from both configs', () => {
61
+ const localConfig: VwtLocalConfig = {
62
+ hooks: {
63
+ postCreate: ['npm run dev', 'code .'],
64
+ },
65
+ };
66
+ const result = mergeConfig(baseConfig, localConfig);
67
+ expect(result.hooks.postCreate).toEqual(['npm install', 'npm run dev', 'code .']);
68
+ });
69
+
70
+ it('handles empty include arrays', () => {
71
+ const config: VwtConfig = {
72
+ defaultMode: 'none',
73
+ include: [],
74
+ };
75
+ const localConfig: VwtLocalConfig = {
76
+ include: [],
77
+ };
78
+ const result = mergeConfig(config, localConfig);
79
+ expect(result.include).toEqual([]);
80
+ });
81
+
82
+ it('handles missing hooks in configs', () => {
83
+ const config: VwtConfig = {
84
+ defaultMode: 'symlink',
85
+ include: ['.env'],
86
+ };
87
+ const result = mergeConfig(config, null);
88
+ expect(result.hooks.postCreate).toEqual([]);
89
+ });
90
+
91
+ it('preserves defaultMode from base config', () => {
92
+ const config: VwtConfig = {
93
+ defaultMode: 'symlink',
94
+ include: [],
95
+ };
96
+ const localConfig: VwtLocalConfig = {
97
+ include: ['.env'],
98
+ };
99
+ const result = mergeConfig(config, localConfig);
100
+ expect(result.defaultMode).toBe('symlink');
101
+ });
102
+ });