@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/README.md +8 -8
- package/dist/commands/apply-patches.d.ts +2 -2
- package/dist/commands/apply-patches.d.ts.map +1 -1
- package/dist/commands/apply-patches.js +99 -19
- package/dist/commands/apply-patches.js.map +1 -1
- package/dist/commands/new-app-feature.d.ts.map +1 -1
- package/dist/commands/new-app-feature.js +5 -12
- package/dist/commands/new-app-feature.js.map +1 -1
- package/dist/commands/watch-patches.d.ts +1 -3
- package/dist/commands/watch-patches.d.ts.map +1 -1
- package/dist/commands/watch-patches.js +9 -47
- package/dist/commands/watch-patches.js.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/commands/apply-patches.ts +109 -21
- package/src/commands/new-app-feature.ts +5 -12
- package/src/commands/watch-patches.ts +9 -52
- package/src/index.ts +7 -7
- package/test/e2e/watch-patches.spec.ts +49 -59
- package/test/helpers/cli-runner.ts +5 -9
- package/test/unit/index.spec.ts +11 -14
- package/vitest.config.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sfdc-webapps/cli",
|
|
3
|
-
"version": "1.0.
|
|
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": "
|
|
33
|
-
"@vitest/ui": "^
|
|
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": "^
|
|
38
|
+
"vitest": "^4.0.17"
|
|
38
39
|
}
|
|
39
40
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cpSync, existsSync, rmSync, readdirSync, statSync, mkdirSync,
|
|
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 (
|
|
23
|
+
if (entry.isDirectory()) {
|
|
25
24
|
files.push(...discoverFiles(fullPath, baseDir));
|
|
26
|
-
} else if (
|
|
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
|
-
|
|
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.
|
|
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;
|
|
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.
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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('--
|
|
31
|
-
.action(async (featurePath: string, appPath: string, targetDir: string, options: { skipDependencyChanges?: 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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
132
|
-
|
|
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
|
-
|
|
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(
|
|
150
|
-
expect(existsSync(
|
|
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
|
|
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
|
|
174
|
-
|
|
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
|
-
|
|
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;
|
|
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.
|
|
68
|
-
args.push('--
|
|
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
|
});
|