@sfdc-webapps/cli
v1.0.6
Published
CLI tool for applying feature patches to base apps
Readme
Feature Patch CLI
CLI tool for applying feature patches to apps in the webapps-templates monorepo.
Installation
Build the CLI from the monorepo root:
yarn build --workspace=@sfdc-webapps/cliUsage
Apply patches
# Apply patches to a target directory (base app is used as reference only)
yarn apply-patches <feature-path> <app-path> <target-dir>
# Examples:
# Apply navigation-menu patches to my-app (using base-react-app as reference)
yarn apply-patches packages/feature-navigation-menu packages/base-react-app my-app
# Skip dependency installation
yarn apply-patches packages/feature-navigation-menu packages/base-react-app my-app -- --skip-dependency-changes
# Reset target directory to base app state before applying (preserves node_modules)
yarn apply-patches packages/feature-navigation-menu packages/base-react-app my-app -- --resetDirect usage
# From monorepo root
node packages/cli/dist/index.js <feature-path> <app-path> <target-dir>
# With flags
node packages/cli/dist/index.js <feature-path> <app-path> my-app --skip-dependency-changes --resetCreate a new feature
Create a new feature package from the base-feature template:
# Create a new feature (will be prefixed with "feature-")
yarn new-feature <feature-name>
# Examples:
yarn new-feature navigation # Creates packages/feature-navigation
yarn new-feature user-dashboard # Creates packages/feature-user-dashboard
yarn new-feature api-client # Creates packages/feature-api-clientDirect usage
# From monorepo root
node packages/cli/dist/index.js new-feature <feature-name>Feature Name Requirements
- Format: Must be in kebab-case (lowercase with hyphens)
- Characters: Only alphanumeric characters and hyphens allowed
- Cannot: Start or end with hyphens, have consecutive hyphens
- Reserved: Cannot be "base" or "cli"
- Auto-prefix: Automatically prefixed with "feature-" (e.g.,
nav-menu→feature-nav-menu)
Examples:
- ✅
navigation→feature-navigation - ✅
user-auth→feature-user-auth - ✅
dashboard-v2→feature-dashboard-v2 - ❌
Navigation(uppercase) - ❌
user_auth(underscore) - ❌
--menu(starts with hyphen) - ❌
nav--menu(consecutive hyphens)
What the command does
The new-feature command:
- Validates the feature name format
- Checks that the feature doesn't already exist
- Copies the base-feature template to
packages/feature-{name}/ - Renames the template directory from
base-featureto{name} - Updates
package.jsonwith the new feature name - Updates
tsconfig.app.jsonwith the new directory path - Creates a ready-to-use feature package
The created feature will have:
- ✅ Correct
package.jsonconfiguration - ✅ TypeScript configuration
- ✅ Feature structure following conventions
- ✅ Ready for customization and development
Next steps after creation
cd packages/feature-{name}
# 1. Customize template files in template/ directory
# 2. Update feature.ts with feature configuration
# 3. Test with: yarn apply-patches packages/feature-{name} packages/base-react-app test-appOptions
<feature-path>: Path to the feature directory (can be relative to monorepo root or absolute). The feature must contain a feature.ts file.<app-path>: Path to the base app directory (can be relative to monorepo root or absolute). Used as a reference for file inheritance and validation. The base app remains unchanged.<target-dir>: Required. Target directory where the feature will be applied. Can be a relative or absolute path. The CLI will create this directory and apply all features to it.--skip-dependency-changes: Skip installing dependencies from package.json. Only file changes will be applied.--reset: Reset target directory to base app state before applying patches. Syncs the target with the base app by removing extra files, updating changed files, and adding new files (preserves node_modules).
What it does
The CLI tool:
- Validates that the specified feature path exists and contains a feature.ts file, and the base app path is valid
- Prepares target directory: Creates the target directory (or resets it to base app state if
--resetflag is used, preserving node_modules) - Resolves dependencies: Recursively resolves all feature dependencies, detecting circular dependencies and building an ordered list where dependencies are applied before dependent features
- Loads the feature definitions from each
feature.tsfile in dependency order - Discovers files from each feature's template directory (defaults to
template, configurable viatemplateDirin feature.ts) - Validates paths: For each feature, ensures:
- No conflicting paths exist (e.g., both
routes.tsxand__delete__routes.tsx, or__prepend__global.cssand__append__global.css) - Files marked with
__delete__,__inherit__,__prepend__, or__append__exist in the base app
- No conflicting paths exist (e.g., both
- Applies file changes from each feature's template directory in dependency order:
- Delete operations: Removes files/directories marked with
__delete__prefix from the target app - Inherit operations: Skips files marked with
__inherit__(inherited from base app) - Prepend operations: Adds content from feature file before the base file's content (for files marked with
__prepend__) - Append operations: Adds content from feature file after the base file's content (for files marked with
__append__) - Import path fixing: Automatically removes
__inherit__prefix from import statements in JS/TS files - File changes: Copies each file from the template directory to the target
- Route merging: Intelligently merges route files (
routes.tsx), accumulating routes from all features
- Delete operations: Removes files/directories marked with
- Aggregates and installs dependencies: Collects all NPM dependencies from all features and installs them in a single
yarncommand (unless--skip-dependency-changesis used). Detects and errors on version conflicts.
Examples
Example: Apply feature patches to create a new app
$ yarn apply-patches packages/feature-navigation-menu packages/base-react-app my-app
Applying patches: packages/feature-navigation-menu → my-app
ℹ Validating paths...
✓ Validation passed
ℹ Creating target directory my-app...
✓ Target directory created
Resolving Dependencies
ℹ No dependencies to resolve
Applying: packages/feature-navigation-menu
ℹ Discovering files...
ℹ Found 6 file(s)
ℹ Validating paths...
✓ Paths validated
✓ Added digitalExperiences/webApplications/feature-navigation-menu/src/navigationMenu.tsx
✓ Added digitalExperiences/webApplications/feature-navigation-menu/src/appLayout.tsx
✓ Added digitalExperiences/webApplications/feature-navigation-menu/src/routes.tsx
✓ Added digitalExperiences/webApplications/feature-navigation-menu/src/router-utils.tsx
✓ Added digitalExperiences/webApplications/feature-navigation-menu/src/about.tsx
✓ Added digitalExperiences/webApplications/feature-navigation-menu/src/new.tsx
Installing dependencies
ℹ Installing dependencies...
[yarn output]
✓ Dependencies installed
✓ Success
✓ Created: /path/to/monorepo/my-appCreating Features
Quick Start: Use the CLI to create a new feature from the template:
yarn new-feature your-feature-nameThis creates a new feature at packages/feature-your-feature-name/ with all the necessary configuration files. Then customize the template files in the template/ directory.
For manual setup or advanced configuration, see below...
Features are defined in your feature's feature.ts file and must be exported as a default export. The default export can be either a single feature object or an array of features. Import types from packages/cli/src/types.js.
Feature Structure
packages/feature-my-feature/
├── feature.ts # Feature configuration
├── package.json # NPM dependencies for development
└── template/ # Template files (default directory name)
├── webApp/ # Web application files (mapped to digitalExperiences/webApplications/<feature-name>/)
│ └── src/
│ ├── routes.tsx
│ ├── component1.tsx
│ └── component2.tsx
└── classes/ # SFDX metadata (placed at root level in dist)
└── MyClass.clsNote: The CLI handles two types of files differently:
- Web Application files (under
webApp/): Automatically mapped todigitalExperiences/webApplications/<feature-name>/ - SFDX metadata files (like
classes/,triggers/,objects/, etc.): Placed at root level in the output directory
This structure ensures proper organization for both digital experience applications and Salesforce metadata.
Feature Configuration
The feature configuration file specifies:
templateDir: Directory containing template files (defaults totemplate)webAppName: Name of the web application (defaults to the feature name extracted from the directory). This is used for constructing the default route path.routeFilePath: Path to the routes file for merging (defaults todigitalExperiences/webApplications/<webAppName>/src/routes.tsx)packageJson: NPM dependencies to install in the target appdependencies: Array of other features this feature depends on (applied first)
Basic Feature Example:
import type { Feature } from '../cli/src/types.js';
const feature: Feature = {
// All fields are optional with sensible defaults
templateDir: 'template', // Optional, defaults to 'template'
webAppName: 'my-feature', // Optional, defaults to feature directory name
// routeFilePath defaults to 'digitalExperiences/webApplications/<webAppName>/src/routes.tsx'
packageJson: {
dependencies: {
'react-router': '^7.10.1',
},
},
};
export default feature;Feature with Dependencies:
import type { Feature } from '../cli/src/types.js';
const feature: Feature = {
// This feature depends on navigation-menu feature
// navigation-menu will be applied first, then this feature
dependencies: ['packages/feature-navigation-menu'],
packageJson: {
dependencies: {
'some-package': '^1.0.0',
},
},
};
export default feature;Custom Configuration:
import type { Feature } from '../cli/src/types.js';
const feature: Feature = {
templateDir: 'src', // Use 'src' instead of 'template'
webAppName: 'custom-app-name', // Override default app name
routeFilePath: 'custom/path/to/routes.tsx', // Custom route file path
packageJson: {
dependencies: {
'react-router': '^7.10.1',
},
},
};
export default feature;Notes:
templateDir: All files in this directory will be discovered and applied to the target appwebAppName: Used to construct the default route path and organize files. Defaults to the feature directory name (e.g.,feature-navigation-menu→feature-navigation-menu)routeFilePath: Must be a path relative totemplateDir. If not specified, defaults todigitalExperiences/webApplications/<webAppName>/src/routes.tsx
Path Mappings
Path mappings allow features to use simplified directory structures that are automatically transformed to the full Salesforce Digital Experience structure. This makes feature templates easier to create and maintain by removing repetitive nested directory paths.
Default Behavior (Enabled by Default)
By default, all features automatically get the webApp mapping, which transforms web application files into the proper nested structure. For example, in feature-navigation-menu:
template/webApp/src/app.tsx → dist/digitalExperiences/webApplications/feature-navigation-menu/src/app.tsxThis simplifies feature templates by removing the repetitive nested directory structure.
Important: Only files under webApp/ get the nested structure. SFDX metadata types (like classes/, triggers/, objects/, lwc/, etc.) are placed at root level:
feature-navigation-menu/template/
├── webApp/
│ └── src/
│ └── app.tsx → dist/digitalExperiences/webApplications/feature-navigation-menu/src/app.tsx
└── classes/
└── NavMenu.cls → dist/classes/NavMenu.cls (root level)Using the Default Mapping
Simply organize your template files under webApp/ for web application code, and at the root level for SFDX metadata:
// feature.ts - No configuration needed
export default {};template/
├── webApp/ # Web application files (automatically mapped)
│ └── src/
│ ├── routes.tsx
│ ├── app.tsx
│ └── components/
│ └── Header.tsx
└── classes/ # SFDX metadata (placed at root level)
└── MyClass.clsResult:
- Web app files go to
dist/digitalExperiences/webApplications/feature-name/src/ - SFDX metadata stays at root:
dist/classes/MyClass.cls
Disabling Path Mappings (Opt-Out)
Use full paths when you need precise control or for backwards compatibility:
// feature.ts
export default {
pathMappings: {
enabled: false // Disable automatic mapping
}
};template/
└── digitalExperiences/ # Use full structure
└── webApplications/
└── <feature-name>/
└── src/
└── routes.tsxCustom Path Mappings
Define custom mappings for non-standard structures:
// feature.ts
export default {
pathMappings: {
mappings: [
{
from: 'web',
to: 'digitalExperiences/webApplications/custom-app-name'
}
]
}
};template/
└── web/ # Custom prefix
└── src/
└── app.tsxResult: Maps web/src/app.tsx → digitalExperiences/webApplications/custom-app-name/src/app.tsx
Multiple Mappings
You can define multiple mappings in one feature:
export default {
pathMappings: {
mappings: [
{ from: 'webApp', to: 'digitalExperiences/webApplications/my-app' },
{ from: 'shared', to: 'digitalExperiences/shared-resources' }
]
}
};Mixed Path Formats
You can mix web application files, SFDX metadata, and full paths in the same feature:
template/
├── webApp/ # Mapped to digitalExperiences/webApplications/<feature-name>/
│ └── src/
│ └── app.tsx
├── classes/ # SFDX metadata (placed at root)
│ └── MyClass.cls
├── triggers/ # SFDX metadata (placed at root)
│ └── MyTrigger.trigger
└── digitalExperiences/ # Full paths (passed through)
└── siteAssets/
└── logo.pngAll formats work together seamlessly:
webApp/files →digitalExperiences/webApplications/<feature-name>/- SFDX metadata (
classes/,triggers/,objects/,lwc/, etc.) → Root level - Full paths (already containing
digitalExperiences/) → Used as-is
Path Mapping Examples
Example 1: Default mapping (recommended)
export default {}; // That's it!Example 2: Opt-out for backwards compatibility
export default {
pathMappings: { enabled: false }
};Example 3: Custom app name
export default {
pathMappings: {
mappings: [
{ from: 'webApp', to: 'digitalExperiences/webApplications/custom-name' }
]
}
};Example 4: Completely custom structure
export default {
pathMappings: {
mappings: [
{ from: 'src', to: 'app/sources' },
{ from: 'assets', to: 'public/static' }
]
}
};How Path Mappings Work
- Discovery: CLI discovers all files in your template directory
- Mapping: Each file path is checked against mapping rules (first match wins)
- Transformation: Matching prefix is replaced with target prefix
- Pass-Through: Paths that don't match any mapping are used as-is
- Application: Transformed paths are used for file operations
This ensures:
- ✅ Backwards compatibility (old features still work)
- ✅ Simplified templates (new features are easier to create)
- ✅ Flexibility (custom mappings for special cases)
- ✅ No breaking changes (opt-in for existing features, opt-out available)
Feature Dependencies
Features can depend on other features. Dependencies are automatically resolved and applied in the correct order.
How Dependencies Work
- Declaration: Specify dependencies in your
feature.tsfile - Resolution: CLI recursively resolves all dependencies (including nested dependencies)
- Ordering: Dependencies are always applied before the feature that depends on them
- Circular Detection: CLI detects and prevents circular dependencies
- File Layering: Files from dependencies can be overridden by dependent features (main feature wins)
- Route Accumulation: Routes from all features are merged together
Dependency Resolution Order
When you apply a feature with dependencies:
Feature A depends on Feature B
Feature B depends on Feature C
Application order: C → B → A (dependencies first)The CLI builds a complete dependency graph and applies features in topological order.
Example: Building on Navigation Features
// packages/feature-navigation-menu/feature.ts
import type { Feature } from '../cli/src/types.js';
const feature: Feature = {
// Navigation menu has no dependencies
};
export default feature;// packages/feature-admin-dashboard/feature.ts
import type { Feature } from '../cli/src/types.js';
const feature: Feature = {
// Admin dashboard builds on top of navigation menu
dependencies: ['packages/feature-navigation-menu'],
};
export default feature;When you apply feature-admin-dashboard:
- CLI resolves
feature-navigation-menuas a dependency - Applies
feature-navigation-menufirst (navigation menu files and routes) - Applies
feature-admin-dashboardsecond (dashboard files and routes) - Result: App has both navigation menu and admin dashboard
Nested Dependencies
Dependencies can have their own dependencies. The CLI resolves them recursively:
Feature App depends on Feature Dashboard
Feature Dashboard depends on Feature Navigation
Feature Navigation depends on Feature Auth
Application order: Auth → Navigation → Dashboard → AppCircular Dependency Detection
The CLI detects and prevents circular dependencies:
// Feature A depends on B
// Feature B depends on C
// Feature C depends on A
// CLI will error:
// "Circular dependency detected:
// packages/feature-a → packages/feature-b → packages/feature-c → packages/feature-a"Fix: Remove one of the dependencies to break the cycle.
Main Feature Wins
When multiple features modify the same file:
- Dependencies applied first: Their files are written to the target
- Main feature applied last: Its files overwrite dependency files
- Result: Main feature can customize/override dependency behavior
Example:
feature-navigation-menu provides: src/appLayout.tsx
feature-custom-app also provides: src/appLayout.tsx
When applying feature-custom-app:
1. navigation-menu's appLayout.tsx is applied
2. custom-app's appLayout.tsx overwrites it
3. Final result: custom-app's version is usedRoute Merging with Dependencies
Routes from all features are merged together:
Base App Routes:
export const routes = [
{
path: '/',
children: [
{ index: true, element: <Home /> }
]
}
];navigation-menu Routes:
export const routes = [
{
path: '/',
children: [
{ path: 'about', element: <About /> },
{ path: 'contact', element: <Contact /> }
]
}
];Final Merged Routes:
export const routes = [
{
path: '/',
children: [
{ index: true, element: <Home /> }, // From base
{ path: 'about', element: <About /> }, // From navigation-menu
{ path: 'contact', element: <Contact /> } // From navigation-menu
]
}
];Routes accumulate across all features, preserving routes from base app and all dependencies.
Dependency Paths
Dependency paths can be:
- Relative to monorepo root:
'packages/feature-navigation-menu' - Absolute paths:
'/absolute/path/to/feature'
The CLI normalizes and resolves all paths consistently.
Diamond Dependencies
When multiple features depend on the same feature:
Feature A depends on Feature C
Feature B depends on Feature C
Feature Main depends on A and B
Dependency graph:
Main
/ \
A B
\ /
CResolution: Feature C is applied once (not duplicated).
Application order: C → A → B → Main
Watch Mode with Dependencies
When using watch mode, dependencies are:
- Applied once on initial startup with reset to ensure clean state
- Re-applied when template files change (always resets to base app state, preserving node_modules)
- Not watched for changes (only the main feature is watched)
This is efficient for development: edit your main feature while keeping dependencies stable. The reset behavior ensures stale files are removed while preserving built artifacts in node_modules.
File Application
The CLI discovers all files in your feature's template directory and applies them to the target app:
- Standard Files: Copied directly to the target, creating directories as needed
- Delete Markers: Files/directories prefixed with
__delete__mark files for deletion from the target app - Inherit Markers: Files prefixed with
__inherit__are kept in the feature for type safety but not copied (inherited from base app) - Prepend Markers: Files prefixed with
__prepend__add their content before the base file's content - Append Markers: Files prefixed with
__append__add their content after the base file's content - Route Files (
routes.tsx): Intelligently merged with existing routes to preserve base app routes
Deleting Files and Directories
Features can delete files or directories from the target app by using the __delete__ prefix in the template directory.
How It Works
- Create a file with the
__delete__prefix in your feature's template directory - The prefix can appear anywhere in the path
- When the feature is applied, the corresponding file/directory will be deleted from the target app
- The content of the delete marker file is ignored (can be empty or contain comments)
Examples
Delete a single file:
template/
└── src/
└── __delete__routes.tsx # Deletes src/routes.tsx from target appDelete a directory:
template/
└── src/
└── __delete__pages/ # Deletes src/pages/ directory from target app
└── .gitkeep # Placeholder file (content ignored)Delete from nested path:
template/
└── src/
└── components/
└── __delete__Footer.tsx # Deletes src/components/Footer.tsxDelete a parent directory:
template/
└── __delete__src/
└── legacy/ # Deletes src/legacy/ directory from target app
└── .gitkeepValidation
The CLI validates that you don't have conflicting paths:
- ❌ Invalid: Having both
routes.tsxand__delete__routes.tsxin the same template - ✅ Valid: Having only
__delete__routes.tsx(to delete) or onlyroutes.tsx(to add/update)
If a conflict is detected, the CLI will throw an error:
Path conflict detected: "src/routes.tsx" appears multiple times in the template.
This can happen when both a file and its __delete__ marker exist.
Please remove one of them.Use Cases
- Remove obsolete files: Delete deprecated components or utilities that the feature replaces
- Clean up after refactoring: Remove files that are no longer needed with the new feature
- Remove base app scaffolding: Delete placeholder files from the base app that the feature supersedes
For example, the vibe-coding-starter feature deletes the base app's routes.tsx because it provides a single-page app in index.tsx instead.
Inheriting Files from Base App
Features can maintain type-safe references to base app files without copying them using the __inherit__ prefix. This is useful when your feature code needs to import and reference files from the base app while keeping TypeScript/IDE support in your feature directory.
How It Works
- Create a file with the
__inherit__prefix in your feature's template directory - Copy the base app file's contents to the
__inherit__file (for type checking and IDE support) - When the feature is applied, the file is NOT copied to the target (the base app's file is used)
- The feature gets type safety and autocomplete for the inherited file
Why Use __inherit__?
Problem: You want to import a base app file in your feature code, but:
- If you don't have the file in your feature, TypeScript shows errors and IDE autocomplete doesn't work
- If you copy the file to your feature, it will overwrite the base app file when applied
Solution: Use __inherit__ to keep the file in your feature for development, but skip it during application.
Automatic Import Path Fixing
Important: The CLI automatically fixes import paths in your feature files!
When you import from __inherit__ files in your feature code:
// In your feature's template
import { routes } from './__inherit__routes';
import AppLayout from './__inherit__appLayout';The CLI automatically removes the __inherit__ prefix when applying the feature:
// In the target app after applying
import { routes } from './routes';
import AppLayout from './appLayout';Supported patterns:
import ... from './__inherit__file'→import ... from './file'export ... from './__inherit__file'→export ... from './file'require('./__inherit__file')→require('./file')import('./__inherit__file')→import('./file')- Works with single quotes (
'), double quotes ("), and backticks (`) - Works with relative paths (
../,./)
Only processes JavaScript/TypeScript files:
.js,.jsx,.ts,.tsx,.mjs,.cjs- Other files (
.md,.json, etc.) are not processed
This means you can freely import from __inherit__ files in your feature code, and the imports will "just work" when the feature is applied!
Examples
Inherit routes for type safety:
// Feature structure
template/
└── src/
├── __inherit__routes.tsx // Copy of base app routes for types
└── index.tsx // Can import routes safely
// In your feature's index.tsx
import { routes } from './routes'; // TypeScript works!
// When applied:
// - routes.tsx from base app is used (not overwritten)
// - Your index.tsx can still import itInherit shared layout:
template/
└── src/
├── __inherit__appLayout.tsx # Copy from base for types
└── pages/
└── MyPage.tsx # Can import appLayout safelyValidation
The CLI validates __inherit__ files:
No conflicts: You cannot have both
routes.tsxand__inherit__routes.tsxin the same template- ❌ Invalid: Both
routes.tsxand__inherit__routes.tsx - ✅ Valid: Only
__inherit__routes.tsx
- ❌ Invalid: Both
Base file must exist: The file must exist in the base app
- ❌ Invalid:
__inherit__nonexistent.tsxwhen file doesn't exist in base app - ✅ Valid:
__inherit__routes.tsxwhenroutes.tsxexists in base app
- ❌ Invalid:
If validation fails, you'll see clear error messages:
Validation error: Cannot inherit file that doesn't exist!
File marked for inheritance: src/routes.tsx
Expected location in base app: /path/to/base/src/routes.tsx
The file doesn't exist in the base app.Use Cases
- Type-safe imports: Import base app files in your feature with full TypeScript support
- Shared layouts: Reference base app layouts without overwriting them
- Route definitions: Import base routes to extend or reference them
- Shared utilities: Reference base app utility functions with autocomplete
Complete Example
// Base app has: src/routes.tsx, src/appLayout.tsx
// Feature template structure:
template/
└── src/
├── __inherit__routes.tsx # Copy from base (for types only)
├── __inherit__appLayout.tsx # Copy from base (for types only)
└── pages/
└── Dashboard.tsx # New page that imports both
// In template/src/pages/Dashboard.tsx (during development):
import { routes } from '../__inherit__routes'; // Import from __inherit__ file
import AppLayout from '../__inherit__appLayout'; // TypeScript works!
export default function Dashboard() {
return <AppLayout>Dashboard using {routes.length} routes</AppLayout>;
}
// When applied, Dashboard.tsx is automatically transformed to:
import { routes } from '../routes'; // __inherit__ removed!
import AppLayout from '../appLayout'; // __inherit__ removed!
export default function Dashboard() {
return <AppLayout>Dashboard using {routes.length} routes</AppLayout>;
}
// Final result in target app:
// - src/routes.tsx: inherited from base (not overwritten) ✓
// - src/appLayout.tsx: inherited from base (not overwritten) ✓
// - src/pages/Dashboard.tsx: added from feature with fixed imports ✓Prepending and Appending to Base Files
Features can add content to the beginning (__prepend__) or end (__append__) of existing base app files. This is useful for adding styles, imports, or configuration to base files without completely replacing them.
How It Works
- Prepend: Content from the feature file is added before the base file's content
- Append: Content from the feature file is added after the base file's content
- The prefix can appear anywhere in the path
- Import paths with
__inherit__are automatically fixed in the content
Examples
Append CSS to global styles:
template/
└── src/
└── styles/
└── __append__global.css # Adds content after base global.css/* Feature's __append__global.css */
:root {
--feature-color: #066afe;
--feature-background: #ffffff;
}
.feature-specific {
color: var(--feature-color);
}Result in target app's global.css:
/* Base app's existing content */
@import "tailwindcss";
body {
margin: 0;
}
/* Content appended from feature */
:root {
--feature-color: #066afe;
--feature-background: #ffffff;
}
.feature-specific {
color: var(--feature-color);
}Prepend imports to a TypeScript file:
template/
└── src/
└── __prepend__index.tsx # Adds imports before base index.tsx/* Feature's __prepend__index.tsx */
import { initializeFeature } from './feature-init';
initializeFeature();Result in target app's index.tsx:
/* Content prepended from feature */
import { initializeFeature } from './feature-init';
initializeFeature();
/* Base app's existing content */
import React from 'react';
import ReactDOM from 'react-dom';
// ... rest of base fileValidation
The CLI validates prepend/append operations:
No conflicts: You cannot target the same file with multiple operations
- ❌ Invalid: Both
__prepend__global.cssand__append__global.css - ❌ Invalid: Both
global.cssand__append__global.css - ✅ Valid: Only
__append__global.css
- ❌ Invalid: Both
Base file must exist: The target file must exist in the base app
- ❌ Invalid:
__append__nonexistent.csswhen file doesn't exist - ✅ Valid:
__append__global.csswhenglobal.cssexists in base app
- ❌ Invalid:
If validation fails, you'll see clear error messages:
Path conflict detected!
The following paths resolve to the same target file:
1. src/styles/__append__global.css (append)
2. src/styles/__prepend__global.css (prepend)
→ Both target: src/styles/global.css
You cannot have multiple files targeting the same path.Automatic Import Fixing
When prepending or appending TypeScript/JavaScript files, import paths with __inherit__ are automatically fixed:
// In feature's __prepend__index.tsx
import { routes } from './__inherit__routes';
// After prepending to target app
import { routes } from './routes'; // __inherit__ removed!This works the same as regular file operations (see "Automatic Import Path Fixing" above).
Use Cases
Prepend:
- Add initialization code at the start of entry files
- Add imports before existing code
- Add type declarations or interfaces
Append:
- Add CSS variables and styles to global stylesheets
- Add routes or configuration entries
- Extend existing files with additional functionality
- Add utility functions or helpers
Complete Example
// Base app has: src/styles/global.css
// with Tailwind configuration
// Feature adds Salesforce Design Tokens by appending
// Feature structure:
template/
└── src/
└── styles/
└── __append__global.css
// Feature's __append__global.css:
:root {
/* Salesforce Design Tokens */
--electric-blue-50: #066afe;
--constant-white: #ffffff;
}
button {
background-color: var(--electric-blue-50);
color: var(--constant-white);
}
// Final result in target app's global.css:
@import "tailwindcss"; // ← Base content
body {
@apply antialiased; // ← Base content
}
:root {
/* Salesforce Design Tokens */
--electric-blue-50: #066afe; // ← Appended content
--constant-white: #ffffff; // ← Appended content
}
button {
background-color: var(--electric-blue-50); // ← Appended content
color: var(--constant-white); // ← Appended content
}Route Merging Strategy
The merge change type with routes strategy intelligently combines route definitions from features with base app routes.
How It Works
Replace-Matching with Deep Children Merge:
Top-Level Routes:
- Routes with the same path → Merge their children arrays
- Routes with different paths → Add feature route to result
Children Array Merging (when parent paths match):
- Index routes (
index: true) → Feature replaces base if both exist - Named routes (
path: 'about') → Feature replaces base if paths match - New routes → Added to children array
- Base routes not in feature → Preserved
- Index routes (
Route Deletion:
- Routes with path starting with
__delete__→ Remove matching route from result - Example:
path: '__delete__new'removes the route withpath: 'new' - Throws error if route to delete doesn't exist
- Routes with path starting with
Recursion:
- Applies same logic to nested children arrays
Example
Base App Routes:
export const routes: RouteObject[] = [
{
path: '/',
element: <AppLayout />,
children: [
{
index: true,
element: <Home />,
handle: { showInNavigation: true, label: 'Home' }
}
]
}
]Feature Routes (in template/digitalExperiences/webApplications/<feature-name>/src/routes.tsx):
export const routes: RouteObject[] = [
{
path: '/',
element: <AppLayout />,
children: [
{
path: 'about',
element: <About />,
handle: { showInNavigation: true, label: 'About' }
},
{
path: 'contact',
element: <Contact />,
handle: { showInNavigation: false }
}
]
}
]Merged Result (after applying feature):
export const routes: RouteObject[] = [
{
path: '/',
element: <AppLayout />, // Uses feature's element
children: [
{
index: true,
element: <Home />, // ✓ Preserved from base
handle: { showInNavigation: true, label: 'Home' }
},
{
path: 'about',
element: <About />, // ✓ Added from feature
handle: { showInNavigation: true, label: 'About' }
},
{
path: 'contact',
element: <Contact />, // ✓ Added from feature
handle: { showInNavigation: false }
}
]
}
]Key Benefits
- Preserves existing routes: Base app's Home route stays intact
- Adds new routes: Feature routes (About, Contact) are added
- No duplication: Routes with matching paths are replaced, not duplicated
- Deep merging: Works with nested route structures
- Multiple features: Apply multiple route-adding features sequentially
Route merging happens automatically for routes.tsx files.
Deleting Routes
Features can delete routes from the base app or previously applied features by using the __delete__ prefix in the route path. This is useful when a feature needs to remove routes that were added by dependencies or the base app.
How It Works
- Add a route with
path: '__delete__<route-name>'in your feature's routes file - The route with the matching path (without the prefix) will be removed during merging
- If the route doesn't exist, an error will be thrown
Example
Base/Previous Routes:
export const routes: RouteObject[] = [
{
path: '/',
element: <AppLayout />,
children: [
{ index: true, element: <Home /> },
{ path: 'about', element: <About /> },
{ path: 'new', element: <New /> }
]
}
]Feature Routes (deleting 'new'):
export const routes: RouteObject[] = [
{
path: '/',
children: [
{
path: '__delete__new',
element: <></> // Element value is ignored for deletion markers
}
]
}
]Merged Result:
export const routes: RouteObject[] = [
{
path: '/',
element: <AppLayout />,
children: [
{ index: true, element: <Home /> }, // ✓ Preserved
{ path: 'about', element: <About /> } // ✓ Preserved
// 'new' route deleted ✓
]
}
]Validation
- Error if route doesn't exist: The CLI will throw an error if you attempt to delete a route that doesn't exist in the current routes
- This prevents silent failures and ensures routes are being deleted as expected
Use Cases
- Remove dependency routes: Delete routes added by feature dependencies that aren't needed
- Clean up base routes: Remove placeholder or example routes from the base app
- Override parent features: Child features can remove routes added by parent features they depend on
For example, a feature that provides a single-page app might delete all routes from the base app to start fresh.
Automatic Import Cleanup for Deleted Files
When using route deletion (or file deletion with __delete__ prefix), imports from deleted files are automatically removed during route merging. This prevents broken import references in the final merged code.
How It Works
- During route merging, after all imports are merged from the feature file
- The import merger scans all import statements in the target file
- Any imports with
__delete__in the module specifier are automatically removed - This ensures deleted components don't leave broken import references
Example
Feature Routes File:
import type { RouteObject } from "react-router";
import AppLayout from "./__inherit__appLayout";
import Home from ".";
import New from "./__delete__new"; // Import from deleted file
export const routes: RouteObject[] = [
{
path: '/',
element: <AppLayout />,
children: [
{
index: true,
element: <Home />,
handle: { showInNavigation: true, label: 'Home' }
},
{
path: '__delete__new', // Delete the 'new' route
element: <New />,
}
]
}
]Merged Result:
import type { RouteObject } from "react-router";
import AppLayout from "./appLayout";
import Home from ".";
// ✓ Import from ./__delete__new automatically removed
export const routes: RouteObject[] = [
{
path: '/',
element: <AppLayout />,
children: [
{
index: true,
element: <Home />,
handle: { showInNavigation: true, label: 'Home' }
}
// ✓ 'new' route deleted
]
}
]Key Benefits
- No broken imports: Automatically removes imports from deleted files
- Clean merged code: Final output doesn't reference non-existent files
- Works with file deletion: Applies to any file marked with
__delete__prefix - Seamless integration: Happens automatically during route merging, no manual cleanup needed
Testing
The CLI has a comprehensive test suite using Vitest, including E2E tests with gold files and unit tests for critical utilities.
Test Coverage:
- ✅ 12 E2E test scenarios covering all major CLI workflows
- ✅ 46+ unit tests covering route merging, import merging, and file operations
- ✅ Gold file comparison for E2E validation
- ✅ No skipped tests - all functionality fully tested
Running Tests
# Run all tests
npm test
# Run with UI
yarn test:ui
# Run E2E tests only
yarn test:e2e
# Run unit tests only
yarn test:unit
# Run with coverage
yarn test:coverageTest Structure
packages/cli/
├── test/
│ ├── e2e/ # End-to-end tests
│ │ ├── fixtures/ # Test fixtures (base apps, features)
│ │ ├── gold/ # Expected outputs for E2E tests
│ │ └── apply-patches.spec.ts
│ ├── unit/ # Unit tests
│ │ ├── route-merger.spec.ts
│ │ ├── import-merger.spec.ts
│ │ └── file-operations.spec.ts
│ └── helpers/ # Test utilities
│ ├── compare-directories.ts
│ ├── create-temp-dir.ts
│ └── fixtures.tsE2E Tests
E2E tests verify complete CLI workflows using gold files (expected outputs). Tests cover:
- Simple feature application (adding routes/files)
- File deletion with
__delete__prefix - Route deletion and import cleanup
- Feature dependency resolution
- Complex operations (
__inherit__,__prepend__,__append__) - Error handling and validation
Unit Tests
Unit tests focus on individual modules:
- route-merger: Route merging logic and deletion
- import-merger: Import statement merging, deduplication, type imports, and formatting
- file-operations: File deletion, prepending, and appending
Updating Gold Files
When intentionally changing CLI behavior, update gold files:
UPDATE_GOLD=1 npm test⚠️ Warning: Only update gold files after verifying the new output is correct!
Creating New Tests
E2E Test Example:
it('should apply a simple feature correctly', async () => {
const outputDir = copyFixture('base-app', join(tempDir, 'output'));
const featurePath = getFixturePath('feature-simple');
await applyPatchesCommand(featurePath, outputDir, {
skipDependencyChanges: true
});
const goldDir = getGoldPath('simple-apply');
const differences = compareOrUpdate(outputDir, goldDir);
expect(differences).toEqual([]);
});Unit Test Example:
it('should merge routes correctly', () => {
const project = new Project({ useInMemoryFileSystem: true });
const targetFile = project.createSourceFile('target.tsx', `...`);
const featureFile = project.createSourceFile('feature.tsx', `...`);
const result = mergeRoutes('feature.tsx', 'target.tsx', project);
expect(result).toContain('expected content');
});Development
# Build the CLI
yarn build
# Run without building (development)
yarn dev -- <feature> <base-app>