@sfdc-webapps/cli 1.0.2 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sfdc-webapps/cli",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "CLI tool for applying feature patches to base apps",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "build": "tsc",
14
+ "clean": "rm -rf dist",
14
15
  "dev": "tsx src/index.ts",
15
16
  "test": "vitest run",
16
17
  "test:ui": "vitest run --ui",
@@ -29,11 +30,11 @@
29
30
  "@types/fs-extra": "^11.0.4",
30
31
  "@types/node": "^24.10.1",
31
32
  "@types/react-dom": "^19.2.3",
32
- "@vitest/coverage-v8": "2.1.9",
33
- "@vitest/ui": "^2.1.8",
33
+ "@vitest/coverage-v8": "4.0.17",
34
+ "@vitest/ui": "^4.0.17",
34
35
  "react-dom": "^19.2.1",
35
36
  "tsx": "^4.19.2",
36
37
  "typescript": "~5.9.3",
37
- "vitest": "^2.1.8"
38
+ "vitest": "^4.0.17"
38
39
  }
39
40
  }
@@ -1,4 +1,4 @@
1
- import { cpSync, existsSync, rmSync, readdirSync, statSync, mkdirSync, unlinkSync } from 'fs';
1
+ import { cpSync, existsSync, rmSync, readdirSync, statSync, mkdirSync, readFileSync } from 'fs';
2
2
  import { join, relative, basename, dirname, isAbsolute } from 'path';
3
3
  import { tmpdir } from 'os';
4
4
  import { randomBytes } from 'crypto';
@@ -15,15 +15,14 @@ import * as logger from '../utils/logger.js';
15
15
  */
16
16
  function discoverFiles(dir: string, baseDir: string = dir): string[] {
17
17
  const files: string[] = [];
18
- const entries = readdirSync(dir);
18
+ const entries = readdirSync(dir, { withFileTypes: true });
19
19
 
20
20
  for (const entry of entries) {
21
- const fullPath = join(dir, entry);
22
- const stat = statSync(fullPath);
21
+ const fullPath = join(dir, entry.name);
23
22
 
24
- if (stat.isDirectory()) {
23
+ if (entry.isDirectory()) {
25
24
  files.push(...discoverFiles(fullPath, baseDir));
26
- } else if (stat.isFile()) {
25
+ } else if (entry.isFile()) {
27
26
  // Get relative path from baseDir
28
27
  files.push(relative(baseDir, fullPath));
29
28
  }
@@ -32,6 +31,94 @@ function discoverFiles(dir: string, baseDir: string = dir): string[] {
32
31
  return files;
33
32
  }
34
33
 
34
+ /**
35
+ * Reset target directory to match base app state (excluding node_modules).
36
+ * This syncs the target directory with the base app before applying features.
37
+ */
38
+ function resetTargetToBaseApp(targetDir: string, baseAppPath: string, targetAppName: string): void {
39
+ const targetAppDir = join(targetDir, 'digitalExperiences/webApplications', targetAppName);
40
+ const sourceWebAppPath = getWebApplicationPath(baseAppPath);
41
+
42
+ // Helper to check if path contains node_modules
43
+ const isNodeModulesPath = (path: string): boolean => {
44
+ return path.split('/').includes('node_modules');
45
+ };
46
+
47
+ // Step 1: Remove files/directories from target that don't exist in base app
48
+ const removeExtraFiles = (targetPath: string, sourcePath: string) => {
49
+ if (!existsSync(targetPath)) return;
50
+
51
+ const entries = readdirSync(targetPath, { withFileTypes: true });
52
+ for (const entry of entries) {
53
+ const targetEntryPath = join(targetPath, entry.name);
54
+ const sourceEntryPath = join(sourcePath, entry.name);
55
+ const relativeFromTarget = relative(targetAppDir, targetEntryPath);
56
+
57
+ // Skip node_modules
58
+ if (isNodeModulesPath(relativeFromTarget)) {
59
+ continue;
60
+ }
61
+
62
+ if (!existsSync(sourceEntryPath)) {
63
+ // Doesn't exist in base app, remove it
64
+ rmSync(targetEntryPath, { recursive: true, force: true });
65
+ const suffix = entry.isDirectory() ? '/' : '';
66
+ logger.info(`Removed: ${relativeFromTarget}${suffix}`);
67
+ } else if (entry.isDirectory()) {
68
+ // Recurse into directory
69
+ removeExtraFiles(targetEntryPath, sourceEntryPath);
70
+ }
71
+ }
72
+ };
73
+
74
+ // Step 2: Copy/update files from base app to target
75
+ const syncFiles = (sourcePath: string, targetPath: string) => {
76
+ if (!existsSync(sourcePath)) return;
77
+
78
+ const entries = readdirSync(sourcePath, { withFileTypes: true });
79
+ for (const entry of entries) {
80
+ const sourceEntryPath = join(sourcePath, entry.name);
81
+ const targetEntryPath = join(targetPath, entry.name);
82
+ const relativeFromTarget = relative(targetAppDir, targetEntryPath);
83
+
84
+ // Skip node_modules
85
+ if (isNodeModulesPath(relativeFromTarget)) {
86
+ continue;
87
+ }
88
+
89
+ if (entry.isDirectory()) {
90
+ // Ensure directory exists in target
91
+ if (!existsSync(targetEntryPath)) {
92
+ mkdirSync(targetEntryPath, { recursive: true });
93
+ }
94
+ // Recurse into directory
95
+ syncFiles(sourceEntryPath, targetEntryPath);
96
+ } else if (entry.isFile()) {
97
+ // Copy file if it doesn't exist or is different
98
+ let shouldCopy = false;
99
+ if (!existsSync(targetEntryPath)) {
100
+ shouldCopy = true;
101
+ } else {
102
+ // Compare file contents
103
+ const sourceContent = readFileSync(sourceEntryPath);
104
+ const targetContent = readFileSync(targetEntryPath);
105
+ shouldCopy = !sourceContent.equals(targetContent);
106
+ }
107
+
108
+ if (shouldCopy) {
109
+ cpSync(sourceEntryPath, targetEntryPath);
110
+ logger.info(`Synced: ${relativeFromTarget}`);
111
+ }
112
+ }
113
+ }
114
+ };
115
+
116
+ logger.info('Resetting target directory to base app state...');
117
+ removeExtraFiles(targetAppDir, sourceWebAppPath);
118
+ syncFiles(sourceWebAppPath, targetAppDir);
119
+ logger.success('Target directory reset complete');
120
+ }
121
+
35
122
  const DELETE_PREFIX = '__delete__';
36
123
  const INHERIT_PREFIX = '__inherit__';
37
124
  const PREPEND_PREFIX = '__prepend__';
@@ -418,7 +505,7 @@ async function applySingleFeature(
418
505
  } finally {
419
506
  // Clean up temp file
420
507
  if (tempFilePath && existsSync(tempFilePath)) {
421
- unlinkSync(tempFilePath);
508
+ rmSync(tempFilePath, { force: true });
422
509
  }
423
510
  }
424
511
  }
@@ -433,7 +520,7 @@ async function applySingleFeature(
433
520
  * @param options - Configuration options
434
521
  * @param options.targetDirName - Required. Target directory where feature will be applied
435
522
  * @param options.skipDependencyChanges - Optional. Skip npm dependency installation
436
- * @param options.clean - Optional. Clean target directory before applying
523
+ * @param options.reset - Optional. Reset target directory to base app state before applying (preserves node_modules)
437
524
  *
438
525
  * @example
439
526
  * await applyPatchesCommand(
@@ -445,7 +532,7 @@ async function applySingleFeature(
445
532
  export async function applyPatchesCommand(
446
533
  featurePath: string,
447
534
  appPath: string,
448
- options: { targetDirName: string; skipDependencyChanges?: boolean; clean?: boolean; }
535
+ options: { targetDirName: string; skipDependencyChanges?: boolean; reset?: boolean; }
449
536
  ): Promise<void> {
450
537
  try {
451
538
  logger.heading(`Applying patches: ${featurePath} → ${options.targetDirName}`);
@@ -466,25 +553,26 @@ export async function applyPatchesCommand(
466
553
  ? options.targetDirName
467
554
  : join(getMonorepoRoot(), options.targetDirName);
468
555
 
556
+ // Calculate target app directory path
557
+ const targetAppDir = join(resolvedTargetDir, 'digitalExperiences/webApplications', targetAppName);
558
+
469
559
  if (existsSync(resolvedTargetDir)) {
470
- if (options.clean) {
471
- logger.info(`Cleaning existing directory ${options.targetDirName}...`);
472
- rmSync(resolvedTargetDir, { recursive: true, force: true });
473
- logger.success('Directory cleaned');
560
+ if (options.reset) {
561
+ // Reset target to base app state (preserving node_modules)
562
+ resetTargetToBaseApp(resolvedTargetDir, baseAppPath, targetAppName);
474
563
  }
475
564
  } else {
476
565
  logger.info(`Creating target directory ${options.targetDirName}...`);
477
566
  mkdirSync(resolvedTargetDir, { recursive: true });
478
567
  logger.success('Target directory created');
479
- }
480
568
 
481
- // Copy base app to target directory with nested structure
482
- logger.info(`Copying base app to ${options.targetDirName}...`);
483
- const targetAppDir = join(resolvedTargetDir, 'digitalExperiences/webApplications', targetAppName);
484
- const sourceWebAppPath = getWebApplicationPath(baseAppPath);
485
- mkdirSync(dirname(targetAppDir), { recursive: true });
486
- cpSync(sourceWebAppPath, targetAppDir, { recursive: true });
487
- logger.success('Base app copied');
569
+ // Copy base app to target directory with nested structure
570
+ logger.info(`Copying base app to ${options.targetDirName}...`);
571
+ const sourceWebAppPath = getWebApplicationPath(baseAppPath);
572
+ mkdirSync(dirname(targetAppDir), { recursive: true });
573
+ cpSync(sourceWebAppPath, targetAppDir, { recursive: true });
574
+ logger.success('Base app copied');
575
+ }
488
576
 
489
577
  const targetDir = resolvedTargetDir;
490
578
 
@@ -70,20 +70,13 @@ export async function newAppOrFeatureCommand(appOrFeatureName: string, appOrFeat
70
70
  // Update scripts to reference new feature name
71
71
  // Replace both "base-feature" and any existing "feature-*" patterns
72
72
  const regexPattern = new RegExp(`base-${templateName}`, 'g');
73
- if (packageJson.scripts) {
74
- if (packageJson.scripts.dev) {
75
- packageJson.scripts.dev = packageJson.scripts.dev
73
+ const commandsToUpdate = ['dev', 'build', 'watch'];
74
+ commandsToUpdate.forEach(command => {
75
+ if (packageJson.scripts[command]) {
76
+ packageJson.scripts[command] = packageJson.scripts[command]
76
77
  .replace(regexPattern, fullFeatureName);
77
78
  }
78
- if (packageJson.scripts['test-patch']) {
79
- packageJson.scripts['test-patch'] = packageJson.scripts['test-patch']
80
- .replace(regexPattern, fullFeatureName);
81
- }
82
- if (packageJson.scripts.watch) {
83
- packageJson.scripts.watch = packageJson.scripts.watch
84
- .replace(regexPattern, fullFeatureName);
85
- }
86
- }
79
+ });
87
80
 
88
81
  writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
89
82
  logger.success('package.json updated');
@@ -1,17 +1,14 @@
1
1
  import { watch, type ChangeEvent } from 'turbowatch';
2
- import { join, relative, basename } from 'path';
3
- import { existsSync, cpSync, rmSync } from 'fs';
2
+ import { join } from 'path';
4
3
  import { applyPatchesCommand } from './apply-patches.js';
5
4
  import { validateAndResolveFeaturePath, validateAndResolveAppPath } from '../utils/validation.js';
6
- import { getMonorepoRoot, translatePathToBaseApp } from '../utils/paths.js';
7
5
  import { loadFeature } from '../core/patch-loader.js';
8
6
  import * as logger from '../utils/logger.js';
9
7
 
10
8
  export async function watchPatchesCommand(
11
9
  featurePath: string,
12
10
  appPath: string,
13
- targetDirName: string,
14
- options: { clean?: boolean } = {}
11
+ targetDirName: string
15
12
  ): Promise<{ shutdown: () => Promise<void> }> {
16
13
  try {
17
14
  logger.heading(`Starting watch mode for ${featurePath}...`);
@@ -23,11 +20,12 @@ export async function watchPatchesCommand(
23
20
  logger.success('Validation passed');
24
21
 
25
22
  // Step 2: Apply feature initially (with dependencies)
23
+ // Always reset to ensure clean state (preserves node_modules)
26
24
  logger.info('Applying feature initially...');
27
25
  await applyPatchesCommand(featurePath, appPath, {
28
26
  targetDirName: targetDirName,
29
27
  skipDependencyChanges: false,
30
- clean: options.clean
28
+ reset: true
31
29
  });
32
30
  logger.success('Initial feature applied successfully\n');
33
31
 
@@ -35,11 +33,6 @@ export async function watchPatchesCommand(
35
33
  const { templateDir } = await loadFeature(featureDir);
36
34
  const watchPath = join(featureDir, templateDir);
37
35
 
38
- // Resolve base app and target directory paths
39
- const baseAppPath = validateAndResolveAppPath(appPath);
40
- const monorepoRoot = getMonorepoRoot();
41
- const targetDir = targetDirName ? join(monorepoRoot, targetDirName) : baseAppPath;
42
-
43
36
  logger.info(`👁 Watching ${watchPath} for changes...`);
44
37
  logger.info(' Press Ctrl+C to stop watching\n');
45
38
 
@@ -51,10 +44,11 @@ export async function watchPatchesCommand(
51
44
  logger.info('⟳ Re-applying feature...\n');
52
45
 
53
46
  const startTime = Date.now();
47
+ // Reset on file changes to remove stale files (preserves node_modules)
54
48
  await applyPatchesCommand(featurePath, appPath, {
55
49
  targetDirName: targetDirName,
56
50
  skipDependencyChanges: true,
57
- clean: options.clean
51
+ reset: true
58
52
  });
59
53
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
60
54
 
@@ -80,42 +74,6 @@ export async function watchPatchesCommand(
80
74
  }
81
75
  };
82
76
 
83
- // Handle file deletion - restore base app file if it exists, otherwise delete
84
- const handleDeletion = async (deletedPath: string) => {
85
- try {
86
- const relativePath = deletedPath.replace(watchPath + '/', '');
87
- logger.info(`🗑 Deletion detected in ${relativePath}`);
88
-
89
- // Calculate paths
90
- const relativeFromFeature = relative(watchPath, deletedPath);
91
- const featureName = basename(featurePath);
92
-
93
- // Translate the path to the base app structure
94
- const translatedPath = translatePathToBaseApp(relativeFromFeature, featureName, baseAppPath);
95
- const targetFilePath = join(targetDir, translatedPath);
96
- const baseFilePath = join(baseAppPath, translatedPath);
97
-
98
- // Check if file exists in base app
99
- if (existsSync(baseFilePath)) {
100
- // Restore base app version
101
- cpSync(baseFilePath, targetFilePath);
102
- logger.success(`✓ Restored base app version of ${translatedPath}`);
103
- } else {
104
- // No base version, delete from target
105
- if (existsSync(targetFilePath)) {
106
- rmSync(targetFilePath);
107
- logger.success(`✓ Deleted ${translatedPath}`);
108
- }
109
- }
110
-
111
- logger.info('👁 Watching for changes...\n');
112
- } catch (err) {
113
- logger.error(`\n✗ Failed to handle deletion: ${err instanceof Error ? err.message : String(err)}`);
114
- logger.info(' Will retry on next file change');
115
- logger.info('👁 Watching for changes...\n');
116
- }
117
- };
118
-
119
77
  // Set up turbowatch watcher
120
78
  const controller = await watch({
121
79
  project: watchPath,
@@ -127,12 +85,11 @@ export async function watchPatchesCommand(
127
85
  expression: ['anyof', ['match', '*.tsx', 'basename'], ['match', '*.ts', 'basename'], ['match', '*.css', 'basename']],
128
86
  name: 'source-files',
129
87
  onChange: async ({ files }: ChangeEvent) => {
88
+ //Reset will handle all changes (additions, modifications, deletions)
89
+ // We just need to trigger a single re-apply when any change is detected
130
90
  for (const file of files) {
131
- // Check if file still exists to detect deletions
132
- if (!existsSync(file.name)) {
133
- await handleDeletion(file.name);
134
- }
135
91
  await applyPatches(file.name);
92
+ break; // Only apply once per batch of changes
136
93
  }
137
94
  }
138
95
  }
package/src/index.ts CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { Command } from 'commander';
4
4
  import { applyPatchesCommand } from './commands/apply-patches.js';
5
- import { watchPatchesCommand } from './commands/watch-patches.js';
6
5
  import { readFileSync } from 'fs';
7
6
  import { join, dirname } from 'path';
8
7
  import { fileURLToPath } from 'url';
@@ -27,13 +26,13 @@ program
27
26
  .argument('<app-path>', 'Path to the app (e.g., packages/base-react-app or my-app)')
28
27
  .argument('<target-dir>', 'Target directory to copy app to before applying patches')
29
28
  .option('--skip-dependency-changes', 'Skip installing dependencies from package.json')
30
- .option('--clean', 'Remove target directory if it exists before applying patches')
31
- .action(async (featurePath: string, appPath: string, targetDir: string, options: { skipDependencyChanges?: boolean; clean?: boolean }) => {
29
+ .option('--reset', 'Reset target directory to base app state before applying patches (preserves node_modules)')
30
+ .action(async (featurePath: string, appPath: string, targetDir: string, options: { skipDependencyChanges?: boolean; reset?: boolean }) => {
32
31
  try {
33
32
  await applyPatchesCommand(featurePath, appPath, {
34
33
  targetDirName: targetDir,
35
34
  skipDependencyChanges: options.skipDependencyChanges,
36
- clean: options.clean
35
+ reset: options.reset
37
36
  });
38
37
  process.exit(0);
39
38
  } catch (err) {
@@ -47,10 +46,11 @@ program
47
46
  .argument('<feature-path>', 'Path to the feature (e.g., packages/feature-navigation-menu)')
48
47
  .argument('<app-path>', 'Path to the app (e.g., packages/base-react-app or my-app)')
49
48
  .argument('<target-dir>', 'Target directory to apply patches to')
50
- .option('--clean', 'Remove target directory if it exists before applying patches')
51
- .action(async (featurePath: string, appPath: string, targetDir: string, options: { clean?: boolean }) => {
49
+ .action(async (featurePath: string, appPath: string, targetDir: string) => {
52
50
  try {
53
- await watchPatchesCommand(featurePath, appPath, targetDir, options);
51
+ // Dynamic import to avoid loading turbowatch when not needed
52
+ const { watchPatchesCommand } = await import('./commands/watch-patches.js');
53
+ await watchPatchesCommand(featurePath, appPath, targetDir);
54
54
  } catch (err) {
55
55
  process.exit(1);
56
56
  }
@@ -40,10 +40,15 @@ describe('watch-patches E2E', () => {
40
40
  watchProcess = watcher.process;
41
41
 
42
42
  // Wait for initial application (longer timeout for spawned process)
43
- await new Promise(resolve => setTimeout(resolve, 1500));
43
+ // Use retry loop to handle timing variations
44
+ const targetFile = join(targetDir, 'digitalExperiences/webApplications/watch-basic/src/placeholder.tsx');
45
+ let retries = 0;
46
+ while (!existsSync(targetFile) && retries < 20) {
47
+ await new Promise(resolve => setTimeout(resolve, 250));
48
+ retries++;
49
+ }
44
50
 
45
51
  // Verify initial application
46
- const targetFile = join(targetDir, 'digitalExperiences/webApplications/watch-basic/src/placeholder.tsx');
47
52
  expect(existsSync(targetFile)).toBe(true);
48
53
  const initialContent = readFileSync(targetFile, 'utf-8');
49
54
 
@@ -81,12 +86,16 @@ describe('watch-patches E2E', () => {
81
86
  const watcher = spawnWatchPatches(featureDir, baseAppDir, targetDir, { clean: false });
82
87
  watchProcess = watcher.process;
83
88
 
84
- // Wait for initial application (longer timeout for spawned process)
85
- await new Promise(resolve => setTimeout(resolve, 1500));
86
-
87
89
  const featureFile = join(featureDir, 'template/digitalExperiences/webApplications/watch-debounce/src/placeholder.tsx');
88
90
  const targetFile = join(targetDir, 'digitalExperiences/webApplications/watch-debounce/src/placeholder.tsx');
89
91
 
92
+ // Wait for initial application with retry
93
+ let retries = 0;
94
+ while (!existsSync(targetFile) && retries < 20) {
95
+ await new Promise(resolve => setTimeout(resolve, 250));
96
+ retries++;
97
+ }
98
+
90
99
  // Track how many times the file changed
91
100
  let changeCount = 0;
92
101
  const originalContent = readFileSync(targetFile, 'utf-8');
@@ -124,12 +133,17 @@ describe('watch-patches E2E', () => {
124
133
  const featureDir = join(tempDir, 'watch-multiple-feature');
125
134
  copyFixture('watch/watch-multiple-files', featureDir);
126
135
 
127
- // Start watch mode via CLI
128
- const watcher = spawnWatchPatches(featureDir, baseAppDir, targetDir, { clean: false });
136
+ // Start watch mode via CLI (always resets to base app state, preserving node_modules)
137
+ const watcher = spawnWatchPatches(featureDir, baseAppDir, targetDir);
129
138
  watchProcess = watcher.process;
130
139
 
131
- // Wait for initial application (longer timeout for spawned process)
132
- await new Promise(resolve => setTimeout(resolve, 1500));
140
+ // Wait for initial application with retry - check for a known file from the fixture
141
+ const initialFile = join(targetDir, 'digitalExperiences/webApplications/watch-multiple-files/src/placeholder.tsx');
142
+ let initRetries = 0;
143
+ while (!existsSync(initialFile) && initRetries < 20) {
144
+ await new Promise(resolve => setTimeout(resolve, 250));
145
+ initRetries++;
146
+ }
133
147
 
134
148
  // Modify multiple files
135
149
  const componentsDir = join(featureDir, 'template/digitalExperiences/webApplications/watch-multiple-files/src/components');
@@ -142,12 +156,18 @@ describe('watch-patches E2E', () => {
142
156
  await new Promise(resolve => setTimeout(resolve, 100));
143
157
  writeFileSync(join(stylesDir, 'theme.css'), '/* Theme styles */');
144
158
 
145
- // Wait for changes to be applied
146
- await new Promise(resolve => setTimeout(resolve, 1000));
159
+ // Wait for changes to be applied with retry
160
+ const headerFile = join(targetDir, 'digitalExperiences/webApplications/watch-multiple-files/src/components/Header.tsx');
161
+ const themeFile = join(targetDir, 'digitalExperiences/webApplications/watch-multiple-files/src/styles/theme.css');
162
+ let retries = 0;
163
+ while ((!existsSync(headerFile) || !existsSync(themeFile)) && retries < 20) {
164
+ await new Promise(resolve => setTimeout(resolve, 250));
165
+ retries++;
166
+ }
147
167
 
148
168
  // Verify both files were applied
149
- expect(existsSync(join(targetDir, 'digitalExperiences/webApplications/watch-multiple-files/src/components/Header.tsx'))).toBe(true);
150
- expect(existsSync(join(targetDir, 'digitalExperiences/webApplications/watch-multiple-files/src/styles/theme.css'))).toBe(true);
169
+ expect(existsSync(headerFile)).toBe(true);
170
+ expect(existsSync(themeFile)).toBe(true);
151
171
 
152
172
  // Stop watch mode
153
173
  if (watchProcess) {
@@ -166,23 +186,32 @@ describe('watch-patches E2E', () => {
166
186
  const featureDir = join(tempDir, 'watch-add-delete-feature');
167
187
  copyFixture('watch/watch-add-delete-files', featureDir);
168
188
 
169
- // Start watch mode via CLI
170
- const watcher = spawnWatchPatches(featureDir, baseAppDir, targetDir, { clean: false });
189
+ // Start watch mode via CLI (always resets to base app state, preserving node_modules)
190
+ const watcher = spawnWatchPatches(featureDir, baseAppDir, targetDir);
171
191
  watchProcess = watcher.process;
172
192
 
173
- // Wait for initial application (longer timeout for spawned process)
174
- await new Promise(resolve => setTimeout(resolve, 1500));
193
+ // Wait for initial application with retry
194
+ const initialFile = join(targetDir, 'digitalExperiences/webApplications/watch-add-delete-files/src/placeholder.tsx');
195
+ let initRetries = 0;
196
+ while (!existsSync(initialFile) && initRetries < 20) {
197
+ await new Promise(resolve => setTimeout(resolve, 250));
198
+ initRetries++;
199
+ }
175
200
 
176
201
  // Add a new file to the feature
177
202
  const srcDir = join(featureDir, 'template/digitalExperiences/webApplications/watch-add-delete-files/src');
178
203
  const newFile = join(srcDir, 'NewComponent.tsx');
179
204
  writeFileSync(newFile, '// New component added during watch');
180
205
 
181
- // Wait for detection and application
182
- await new Promise(resolve => setTimeout(resolve, 1000));
206
+ // Wait for detection and application with retry
207
+ const targetNewFile = join(targetDir, 'digitalExperiences/webApplications/watch-add-delete-files/src/NewComponent.tsx');
208
+ let retries = 0;
209
+ while (!existsSync(targetNewFile) && retries < 20) {
210
+ await new Promise(resolve => setTimeout(resolve, 250));
211
+ retries++;
212
+ }
183
213
 
184
214
  // Verify new file was applied
185
- const targetNewFile = join(targetDir, 'digitalExperiences/webApplications/watch-add-delete-files/src/NewComponent.tsx');
186
215
  expect(existsSync(targetNewFile)).toBe(true);
187
216
  expect(readFileSync(targetNewFile, 'utf-8')).toContain('New component added during watch');
188
217
 
@@ -194,44 +223,5 @@ describe('watch-patches E2E', () => {
194
223
  }
195
224
  }, 10000);
196
225
 
197
- it('should handle file deletions during watch', async () => {
198
- // Setup
199
- const baseAppDir = copyFixture('base-app', join(tempDir, 'base-app'));
200
- const targetDir = join(tempDir, 'watch-delete-target');
201
- const featureDir = join(tempDir, 'watch-delete-feature');
202
- copyFixture('watch/watch-add-delete-files', featureDir);
203
-
204
- // Start watch mode with clean: true to properly handle deletions via CLI
205
- const watcher = spawnWatchPatches(featureDir, baseAppDir, targetDir, { clean: true });
206
- watchProcess = watcher.process;
207
-
208
- // Wait for initial application (longer timeout for spawned process)
209
- await new Promise(resolve => setTimeout(resolve, 1500));
210
-
211
- // Verify initial file exists
212
- const initialFile = join(featureDir, 'template/digitalExperiences/webApplications/watch-add-delete-files/src/initial.tsx');
213
- const targetInitialFile = join(targetDir, 'digitalExperiences/webApplications/watch-add-delete-files/src/initial.tsx');
214
- expect(existsSync(targetInitialFile)).toBe(true);
215
-
216
- // Delete a file from the feature
217
- if (existsSync(initialFile)) {
218
- unlinkSync(initialFile);
219
- }
220
-
221
- // Wait for detection and handling
222
- await new Promise(resolve => setTimeout(resolve, 1000));
223
-
224
- // After deletion, the file should be restored from base app or removed
225
- // (depending on whether it exists in base app)
226
- // For this test, since it's a feature-specific file, it should be removed
227
- expect(existsSync(targetInitialFile)).toBe(false);
228
-
229
- // Stop watch mode
230
- if (watchProcess) {
231
- watchProcess.kill('SIGTERM');
232
- await new Promise(resolve => setTimeout(resolve, 100));
233
- watchProcess = null;
234
- }
235
- }, 10000);
236
226
  });
237
227
  });
@@ -56,7 +56,7 @@ export async function runApplyPatches(
56
56
  featurePath: string,
57
57
  appPath: string,
58
58
  targetDir: string,
59
- options: { skipDependencyChanges?: boolean; clean?: boolean } = {}
59
+ options: { skipDependencyChanges?: boolean; reset?: boolean } = {}
60
60
  ): Promise<CliResult> {
61
61
  const args = ['apply-patches', featurePath, appPath, targetDir];
62
62
 
@@ -64,8 +64,8 @@ export async function runApplyPatches(
64
64
  args.push('--skip-dependency-changes');
65
65
  }
66
66
 
67
- if (options.clean) {
68
- args.push('--clean');
67
+ if (options.reset) {
68
+ args.push('--reset');
69
69
  }
70
70
 
71
71
  return runCli(args);
@@ -74,19 +74,15 @@ export async function runApplyPatches(
74
74
  /**
75
75
  * Runs watch-patches command via CLI
76
76
  * Note: For watch mode, caller must handle terminating the process
77
+ * Watch mode always resets to base app state on each change (preserving node_modules)
77
78
  */
78
79
  export function spawnWatchPatches(
79
80
  featurePath: string,
80
81
  appPath: string,
81
- targetDir: string,
82
- options: { clean?: boolean } = {}
82
+ targetDir: string
83
83
  ): { process: ReturnType<typeof spawn>; kill: () => void } {
84
84
  const args = ['watch-patches', featurePath, appPath, targetDir];
85
85
 
86
- if (options.clean) {
87
- args.push('--clean');
88
- }
89
-
90
86
  const child = spawn('tsx', [CLI_PATH, ...args], {
91
87
  env: { ...process.env, FORCE_COLOR: '0' }
92
88
  });