helm-env-delta 1.1.0 → 1.1.2

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 CHANGED
@@ -1,4 +1,4 @@
1
- # HelmEnvDelta
1
+ # HelmEnvDelta v1.1.0
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/helm-env-delta.svg)](https://www.npmjs.com/package/helm-env-delta)
4
4
  [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC)
@@ -13,6 +13,7 @@ HelmEnvDelta (`helm-env-delta` or `hed`) is a CLI tool that safely synchronizes
13
13
  ## Table of Contents
14
14
 
15
15
  - [Why HelmEnvDelta?](#why-helmenvdelta)
16
+ - [Adopting HelmEnvDelta in Existing GitOps Workflows](#adopting-helmenvdelta-in-existing-gitops-workflows)
16
17
  - [Key Features](#key-features)
17
18
  - [Installation](#installation)
18
19
  - [Quick Start](#quick-start)
@@ -59,6 +60,164 @@ Managing multiple Kubernetes/Helm environments in GitOps workflows presents seve
59
60
 
60
61
  ---
61
62
 
63
+ ## Adopting HelmEnvDelta in Existing GitOps Workflows
64
+
65
+ If you're currently managing multiple environments manually in a GitOps workflow, HelmEnvDelta can be seamlessly integrated into your existing processes without disrupting your current setup.
66
+
67
+ ### Before HelmEnvDelta: Manual Environment Sync
68
+
69
+ Many teams start with manual synchronization between environments:
70
+
71
+ 1. **Manual File Copying**: Copy YAML files from UAT to Production manually
72
+ 2. **Find & Replace in IDE**: Use editor search/replace to update environment-specific values
73
+ 3. **Visual Diff Review**: Compare files side-by-side to ensure correctness
74
+ 4. **Manual Git Commits**: Stage, commit, and push changes individually
75
+ 5. **Hope for the Best**: Cross fingers that no environment-specific values were accidentally overwritten
76
+
77
+ This process works but is:
78
+
79
+ - **Time-consuming**: 15-30 minutes per sync depending on complexity
80
+ - **Error-prone**: Easy to miss files or make incorrect replacements
81
+ - **Inconsistent**: Different team members may format YAML differently
82
+ - **Unvalidated**: No automated checks for dangerous changes
83
+ - **Difficult to audit**: Hard to track what changed and why
84
+
85
+ ### After HelmEnvDelta: Automated Sync
86
+
87
+ With HelmEnvDelta, the same workflow becomes:
88
+
89
+ ```bash
90
+ # 1. Preview changes (5 seconds)
91
+ helm-env-delta --config config.yaml --dry-run --diff
92
+
93
+ # 2. Review in browser (visual confirmation)
94
+ helm-env-delta --config config.yaml --diff-html
95
+
96
+ # 3. Execute sync (2 seconds)
97
+ helm-env-delta --config config.yaml
98
+
99
+ # 4. Commit (standard git workflow)
100
+ git add . && git commit -m "Sync UAT to Prod" && git push
101
+ ```
102
+
103
+ **Benefits:**
104
+
105
+ - **Faster**: Reduces sync time from 15-30 minutes to under 1 minute
106
+ - **Safer**: Stop rules prevent dangerous changes (version downgrades, scaling violations)
107
+ - **Consistent**: Enforces uniform YAML formatting across all files
108
+ - **Auditable**: Clear diff reports show exactly what changed
109
+ - **Repeatable**: Same configuration produces same results every time
110
+
111
+ ### Migration Path: Start Small
112
+
113
+ You don't need to configure everything at once. Start with a minimal configuration and expand gradually:
114
+
115
+ **Phase 1: Basic Sync (Day 1)**
116
+
117
+ ```yaml
118
+ source: './uat'
119
+ destination: './prod'
120
+ transforms:
121
+ '**/*.yaml':
122
+ content:
123
+ - find: "-uat\\b"
124
+ replace: '-prod'
125
+ ```
126
+
127
+ Run `--dry-run --diff` to see what would change. This gives you confidence without modifying any files.
128
+
129
+ **Phase 2: Add Path Filtering (Week 1)**
130
+
131
+ ```yaml
132
+ skipPath:
133
+ '**/*.yaml':
134
+ - 'metadata.namespace'
135
+ - 'spec.replicas'
136
+ ```
137
+
138
+ Identify fields that should never sync (namespaces, replica counts, resource limits) based on your environment differences.
139
+
140
+ **Phase 3: Add Safety Rules (Week 2)**
141
+
142
+ ```yaml
143
+ stopRules:
144
+ '**/*.yaml':
145
+ - type: 'semverMajorUpgrade'
146
+ path: 'image.tag'
147
+ - type: 'numeric'
148
+ path: 'replicaCount'
149
+ min: 2
150
+ max: 10
151
+ ```
152
+
153
+ Add validation rules to catch dangerous changes before they reach production.
154
+
155
+ **Phase 4: Enforce Formatting (Week 3)**
156
+
157
+ ```yaml
158
+ outputFormat:
159
+ indent: 2
160
+ keySeparator: true
161
+ keyOrders:
162
+ 'apps/*.yaml':
163
+ - 'apiVersion'
164
+ - 'kind'
165
+ - 'metadata'
166
+ - 'spec'
167
+ ```
168
+
169
+ Standardize YAML formatting to reduce diff noise in git.
170
+
171
+ ### Gradual Adoption Strategy
172
+
173
+ 1. **Start Read-Only**: Use `--dry-run` exclusively for the first week to build confidence
174
+ 2. **Single Environment First**: Test with a non-critical environment pair (Dev → QA)
175
+ 3. **Expand Configuration**: Add skipPath rules as you discover environment-specific fields
176
+ 4. **Add Safety Nets**: Configure stop rules based on incidents you want to prevent
177
+ 5. **Full Adoption**: Roll out to all environment pairs once validated
178
+
179
+ ### Validating Your Configuration
180
+
181
+ Before fully adopting HelmEnvDelta, validate your configuration captures all environment differences:
182
+
183
+ ```bash
184
+ # 1. Run dry-run with diff
185
+ helm-env-delta --config config.yaml --dry-run --diff
186
+
187
+ # 2. Review changes carefully
188
+ # Look for fields that should be skipped but aren't
189
+
190
+ # 3. Generate JSON report for detailed analysis
191
+ helm-env-delta --config config.yaml --dry-run --diff-json > report.json
192
+
193
+ # 4. Check specific fields
194
+ cat report.json | jq '.files.changed[].changes[] | select(.path | contains("namespace"))'
195
+ ```
196
+
197
+ If you see fields changing that shouldn't (like production namespaces or replica counts), add them to `skipPath`.
198
+
199
+ ### Coexistence with Manual Processes
200
+
201
+ HelmEnvDelta doesn't replace your entire workflow. It complements it:
202
+
203
+ - **Still use git**: HelmEnvDelta syncs files, you commit them
204
+ - **Still review PRs**: Generate HTML diffs for team review
205
+ - **Still use ArgoCD/Flux**: HelmEnvDelta updates files, your GitOps tool deploys them
206
+ - **Still have manual override**: Use `--force` when you need to bypass safety rules
207
+
208
+ ### Real-World Adoption Example
209
+
210
+ A typical adoption timeline for a team managing 20+ microservices across 3 environments:
211
+
212
+ - **Week 1**: Install tool, create basic config, run dry-run on one service
213
+ - **Week 2**: Expand to 5 services, add skipPath rules, first real sync
214
+ - **Week 3**: Add stop rules after catching a version downgrade bug
215
+ - **Week 4**: Standardize YAML formatting across all environments
216
+ - **Month 2**: Full adoption for all services, integrated into CI/CD
217
+ - **Result**: Sync time reduced from 2 hours/week to 10 minutes/week, zero production incidents from sync errors
218
+
219
+ ---
220
+
62
221
  ## Key Features
63
222
 
64
223
  ```mermaid
package/dist/ZodError.js CHANGED
@@ -16,8 +16,14 @@ class ZodValidationError extends Error {
16
16
  const errors = zodError.issues.map((error) => {
17
17
  const path = error.path.length > 0 ? error.path.join('.') : 'root';
18
18
  let message = ` - ${path}: ${error.message}`;
19
- if (error.code === 'invalid_type' && 'expected' in error && 'received' in error)
19
+ if (error.code === 'invalid_type' && 'expected' in error && 'received' in error) {
20
20
  message += ` (expected ${error.expected}, got ${error.received})`;
21
+ if (path.includes('transforms') && error.expected === 'object' && error.received === 'array') {
22
+ message += '\n BREAKING CHANGE: Transform format changed in v2.0.0';
23
+ message += '\n OLD: transforms: { "*.yaml": [{ find: "uat", replace: "prod" }] }';
24
+ message += '\n NEW: transforms: { "*.yaml": { content: [{ find: "uat", replace: "prod" }] } }';
25
+ }
26
+ }
21
27
  if (error.code === 'unrecognized_keys' && 'keys' in error) {
22
28
  const keys = error.keys;
23
29
  message += `\n Unknown fields: ${keys.join(', ')}`;
@@ -107,8 +107,6 @@ declare const baseConfigSchema: z.ZodObject<{
107
107
  }, z.core.$strip>;
108
108
  declare const finalConfigSchema: z.ZodObject<{
109
109
  source: z.ZodNonOptional<z.ZodOptional<z.ZodString>>;
110
- destination: z.ZodNonOptional<z.ZodOptional<z.ZodString>>;
111
- skipPath: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
112
110
  transforms: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
113
111
  content: z.ZodOptional<z.ZodArray<z.ZodObject<{
114
112
  find: z.ZodString;
@@ -119,6 +117,8 @@ declare const finalConfigSchema: z.ZodObject<{
119
117
  replace: z.ZodString;
120
118
  }, z.core.$strip>>>;
121
119
  }, z.core.$strip>>>;
120
+ destination: z.ZodNonOptional<z.ZodOptional<z.ZodString>>;
121
+ skipPath: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
122
122
  stopRules: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
123
123
  type: z.ZodLiteral<"semverMajorUpgrade">;
124
124
  path: z.ZodString;
@@ -134,20 +134,31 @@ const mergePerFileRecords = (parent, child) => {
134
134
  return merged;
135
135
  };
136
136
  const resolveConfigWithExtends = (configPath, visited = new Set(), depth = 0) => {
137
- if (depth > MAX_EXTENDS_DEPTH)
138
- throw new ConfigMergerError('Extends chain exceeds maximum depth of 5', {
137
+ if (depth > MAX_EXTENDS_DEPTH) {
138
+ const depthError = new ConfigMergerError('Extends chain exceeds maximum depth of 5', {
139
139
  code: 'MAX_DEPTH_EXCEEDED',
140
140
  path: configPath,
141
141
  depth
142
142
  });
143
+ depthError.message += '\n\n Hint: Simplify your config inheritance:';
144
+ depthError.message += '\n - Maximum depth is 5 levels';
145
+ depthError.message += `\n - Current depth: ${depth}`;
146
+ depthError.message += '\n - Consider consolidating base configs';
147
+ throw depthError;
148
+ }
143
149
  const absolutePath = node_path_1.default.resolve(configPath);
144
150
  if (visited.has(absolutePath)) {
145
151
  const chain = [...visited, absolutePath].map((filePath) => filePath.split('/').pop()).join(' → ');
146
- throw new ConfigMergerError('Circular dependency detected in extends chain', {
152
+ const circularError = new ConfigMergerError('Circular dependency detected in extends chain', {
147
153
  code: 'CIRCULAR_DEPENDENCY',
148
154
  path: absolutePath,
149
155
  chain
150
156
  });
157
+ circularError.message += '\n\n Hint: Break the circular reference in your extends chain:';
158
+ circularError.message += '\n - Review the chain shown above';
159
+ circularError.message += '\n - Remove one of the extends references';
160
+ circularError.message += '\n - Consider flattening configs into a single file';
161
+ throw circularError;
151
162
  }
152
163
  const visitedWithCurrent = new Set(visited);
153
164
  visitedWithCurrent.add(absolutePath);
@@ -156,12 +167,25 @@ const resolveConfigWithExtends = (configPath, visited = new Set(), depth = 0) =>
156
167
  configContent = (0, node_fs_1.readFileSync)(absolutePath, 'utf8');
157
168
  }
158
169
  catch (error) {
159
- if (error instanceof Error && 'code' in error)
160
- throw new ConfigMergerError(`Failed to read config file`, {
170
+ if (error instanceof Error && 'code' in error) {
171
+ const readError = new ConfigMergerError(`Failed to read config file`, {
161
172
  code: error.code,
162
173
  path: absolutePath,
163
174
  cause: error
164
175
  });
176
+ const errorCode = error.code;
177
+ if (errorCode === 'ENOENT') {
178
+ readError.message += '\n\n Hint: Config file not found:';
179
+ readError.message += '\n - Check the file path is correct';
180
+ readError.message += '\n - Use absolute path or path relative to current directory';
181
+ }
182
+ else if (errorCode === 'EACCES') {
183
+ readError.message += '\n\n Hint: Permission denied:';
184
+ readError.message += `\n - Check file permissions: ls -la ${absolutePath}`;
185
+ readError.message += `\n - Fix permissions: chmod 644 ${absolutePath}`;
186
+ }
187
+ throw readError;
188
+ }
165
189
  throw new ConfigMergerError('Failed to read config file', {
166
190
  path: absolutePath,
167
191
  cause: error
@@ -186,14 +210,20 @@ const resolveConfigWithExtends = (configPath, visited = new Set(), depth = 0) =>
186
210
  (0, node_fs_1.readFileSync)(parentPath, 'utf8');
187
211
  }
188
212
  catch (error) {
189
- if (error instanceof Error && 'code' in error)
190
- throw new ConfigMergerError('Extended config file not found', {
213
+ if (error instanceof Error && 'code' in error) {
214
+ const extendsError = new ConfigMergerError('Extended config file not found', {
191
215
  code: 'INVALID_EXTENDS_PATH',
192
216
  path: absolutePath,
193
217
  extends: config.extends,
194
218
  resolved: parentPath,
195
219
  cause: error
196
220
  });
221
+ extendsError.message += '\n\n Hint: Cannot find extended config file:';
222
+ extendsError.message += '\n - Check the extends path in your config';
223
+ extendsError.message += `\n - Path is resolved relative to: ${node_path_1.default.dirname(absolutePath)}`;
224
+ extendsError.message += '\n - Use paths relative to the config file location';
225
+ throw extendsError;
226
+ }
197
227
  throw new ConfigMergerError('Extended config file not found', {
198
228
  code: 'INVALID_EXTENDS_PATH',
199
229
  path: absolutePath,
package/dist/fileDiff.js CHANGED
@@ -85,21 +85,29 @@ const processYamlFile = (filePath, sourceContent, destinationContent, skipPath,
85
85
  sourceParsed = yaml_1.default.parse(sourceContent);
86
86
  }
87
87
  catch (error) {
88
- throw new FileDiffError('Failed to parse source YAML file', {
88
+ const parseError = new FileDiffError('Failed to parse source YAML file', {
89
89
  code: 'YAML_PARSE_ERROR',
90
90
  path: filePath,
91
91
  cause: error instanceof Error ? error : undefined
92
92
  });
93
+ parseError.message += '\n\n Hint: YAML syntax error in source file:';
94
+ parseError.message += '\n - Validate at: https://www.yamllint.com/';
95
+ parseError.message += '\n - Common issues: incorrect indentation, missing quotes, invalid characters';
96
+ throw parseError;
93
97
  }
94
98
  try {
95
99
  destinationParsed = yaml_1.default.parse(destinationContent);
96
100
  }
97
101
  catch (error) {
98
- throw new FileDiffError('Failed to parse destination YAML file', {
102
+ const parseError = new FileDiffError('Failed to parse destination YAML file', {
99
103
  code: 'YAML_PARSE_ERROR',
100
104
  path: filePath,
101
105
  cause: error instanceof Error ? error : undefined
102
106
  });
107
+ parseError.message += '\n\n Hint: YAML syntax error in destination file:';
108
+ parseError.message += '\n - Validate at: https://www.yamllint.com/';
109
+ parseError.message += '\n - Common issues: incorrect indentation, missing quotes, invalid characters';
110
+ throw parseError;
103
111
  }
104
112
  const sourceTransformed = (0, transformer_1.applyTransforms)(sourceParsed, filePath, transforms);
105
113
  const pathsToSkip = (0, exports.getSkipPathsForFile)(filePath, skipPath);
@@ -31,19 +31,40 @@ const validateAndResolveBaseDirectory = async (baseDirectory) => {
31
31
  const absolutePath = node_path_1.default.isAbsolute(baseDirectory) ? baseDirectory : node_path_1.default.resolve(process.cwd(), baseDirectory);
32
32
  try {
33
33
  const stats = await (0, promises_1.stat)(absolutePath);
34
- if (!stats.isDirectory())
35
- throw new FileLoaderError('Base path is not a directory', { code: 'ENOTDIR', path: absolutePath });
34
+ if (!stats.isDirectory()) {
35
+ const notDirectoryError = new FileLoaderError('Base path is not a directory', {
36
+ code: 'ENOTDIR',
37
+ path: absolutePath
38
+ });
39
+ notDirectoryError.message += '\n\n Hint: The source path must be a directory, not a file:';
40
+ notDirectoryError.message += `\n - Current path: ${absolutePath}`;
41
+ notDirectoryError.message += '\n - Check your config source/destination paths';
42
+ notDirectoryError.message += '\n - Use the parent directory instead';
43
+ throw notDirectoryError;
44
+ }
36
45
  return absolutePath;
37
46
  }
38
47
  catch (error) {
39
48
  if ((0, exports.isFileLoaderError)(error))
40
49
  throw error;
41
50
  const nodeError = error;
42
- throw new FileLoaderError('Failed to access base directory', {
51
+ const accessError = new FileLoaderError('Failed to access base directory', {
43
52
  code: nodeError.code,
44
53
  path: absolutePath,
45
54
  cause: nodeError
46
55
  });
56
+ if (nodeError.code === 'ENOENT') {
57
+ accessError.message += '\n\n Hint: Directory not found:';
58
+ accessError.message += '\n - Check your config source/destination paths';
59
+ accessError.message += `\n - Verify directory exists: ls -la ${node_path_1.default.dirname(absolutePath)}`;
60
+ accessError.message += '\n - Use absolute paths to avoid ambiguity';
61
+ }
62
+ else if (nodeError.code === 'EACCES') {
63
+ accessError.message += '\n\n Hint: Permission denied:';
64
+ accessError.message += `\n - Check directory permissions: ls -la ${absolutePath}`;
65
+ accessError.message += `\n - Fix permissions: chmod 755 ${absolutePath}`;
66
+ }
67
+ throw accessError;
47
68
  }
48
69
  };
49
70
  const findMatchingFiles = async (baseDirectory, includePatterns, excludePatterns, transforms) => {
@@ -92,8 +113,16 @@ const readFilesIntoMap = async (baseDirectory, absoluteFilePaths) => {
92
113
  const readPromises = absoluteFilePaths.map(async (absolutePath) => {
93
114
  try {
94
115
  const content = await (0, promises_1.readFile)(absolutePath, 'utf8');
95
- if (content.includes('\0'))
96
- throw new FileLoaderError('Binary file detected', { code: 'ENOTSUP', path: absolutePath });
116
+ if (content.includes('\0')) {
117
+ const binaryError = new FileLoaderError('Binary file detected', {
118
+ code: 'ENOTSUP',
119
+ path: absolutePath
120
+ });
121
+ binaryError.message += '\n\n Hint: Binary files cannot be processed. Add to exclude:';
122
+ binaryError.message += "\n exclude: ['**/*.{png,jpg,pdf,zip,tar,gz,bin}']";
123
+ binaryError.message += '\n Or exclude this specific file pattern';
124
+ throw binaryError;
125
+ }
97
126
  const relativePath = node_path_1.default.relative(baseDirectory, absolutePath);
98
127
  return { relativePath, content };
99
128
  }
@@ -84,11 +84,16 @@ const mergeYamlContent = (destinationContent, processedSourceContent, filePath)
84
84
  destinationParsed = yaml_1.default.parse(destinationContent);
85
85
  }
86
86
  catch (error) {
87
- throw new FileUpdaterError('Failed to parse destination YAML for merge', {
87
+ const parseError = new FileUpdaterError('Failed to parse destination YAML for merge', {
88
88
  code: 'YAML_PARSE_ERROR',
89
89
  path: filePath,
90
90
  cause: error instanceof Error ? error : undefined
91
91
  });
92
+ parseError.message += '\n\n Hint: YAML syntax error in destination file:';
93
+ parseError.message += '\n - Validate at: https://www.yamllint.com/';
94
+ parseError.message += '\n - Common issues: incorrect indentation, missing quotes';
95
+ parseError.message += '\n - Try --skip-format flag if formatting is the issue';
96
+ throw parseError;
92
97
  }
93
98
  let merged;
94
99
  try {
@@ -552,11 +552,16 @@ const writeHtmlFile = async (htmlContent, outputPath) => {
552
552
  await (0, promises_1.writeFile)(outputPath, htmlContent, 'utf8');
553
553
  }
554
554
  catch (error) {
555
- throw new HtmlReporterError('Failed to write HTML report file', {
555
+ const writeError = new HtmlReporterError('Failed to write HTML report file', {
556
556
  code: 'WRITE_FAILED',
557
557
  path: outputPath,
558
558
  cause: error
559
559
  });
560
+ writeError.message += '\n\n Hint: Cannot write HTML report:';
561
+ writeError.message += '\n - Check temp directory permissions';
562
+ writeError.message += '\n - Use --diff for console output instead';
563
+ writeError.message += '\n - Or --diff-json to pipe to jq';
564
+ throw writeError;
560
565
  }
561
566
  };
562
567
  const openInBrowser = async (filePath) => {
@@ -567,11 +572,17 @@ const openInBrowser = async (filePath) => {
567
572
  await open(absolutePath);
568
573
  }
569
574
  catch (error) {
570
- throw new HtmlReporterError('Failed to open report in browser', {
575
+ const openError = new HtmlReporterError('Failed to open report in browser', {
571
576
  code: 'BROWSER_OPEN_FAILED',
572
577
  path: filePath,
573
578
  cause: error
574
579
  });
580
+ const absolutePath = node_path_1.default.resolve(filePath);
581
+ openError.message += '\n\n Hint: Open the report manually:';
582
+ openError.message += `\n - File location: ${absolutePath}`;
583
+ openError.message += `\n - macOS: open ${absolutePath}`;
584
+ openError.message += `\n - Linux: xdg-open ${absolutePath}`;
585
+ throw openError;
575
586
  }
576
587
  };
577
588
  const generateHtmlReport = async (diffResult, formattedFiles, config, dryRun) => {
@@ -12,6 +12,11 @@ const StopRulesValidatorErrorClass = (0, errors_1.createErrorClass)('Stop Rules
12
12
  fullMessage += `\n Violations (${options['violations'].length}):`;
13
13
  for (const v of options['violations'])
14
14
  fullMessage += `\n - ${v.file}:${v.path} (${v.rule.type})`;
15
+ fullMessage += '\n\n Hint: Review stop rule violations carefully:';
16
+ fullMessage += '\n - Preview changes: --dry-run --diff';
17
+ fullMessage += '\n - Override if safe: --force (use with caution!)';
18
+ fullMessage += '\n - Semver violations indicate major version changes';
19
+ fullMessage += '\n - Numeric violations may indicate unsafe scaling';
15
20
  }
16
21
  if (options.cause)
17
22
  fullMessage += `\n Cause: ${options.cause.message}`;
@@ -33,9 +33,14 @@ const validateNoCollisions = (collisions) => {
33
33
  const collisionDetails = collisions
34
34
  .map((collision) => ` ${collision.transformedName}: [${collision.originalPaths.join(', ')}]`)
35
35
  .join('\n');
36
- throw new CollisionDetectorError(`Multiple files transform to the same path:\n${collisionDetails}`, {
36
+ const collisionError = new CollisionDetectorError(`Multiple files transform to the same path:\n${collisionDetails}`, {
37
37
  code: 'DUPLICATE_TRANSFORMED_NAME',
38
38
  details: JSON.stringify(collisions)
39
39
  });
40
+ collisionError.message += '\n\n Hint: Make your filename transforms more specific:';
41
+ collisionError.message += "\n - Use more specific patterns: { find: 'app-uat\\.', replace: 'app-prod.' }";
42
+ collisionError.message += "\n - Or match on path: { find: 'services/uat/', replace: 'services/prod/' }";
43
+ collisionError.message += '\n - Check the collision details above for conflicting files';
44
+ throw collisionError;
40
45
  };
41
46
  exports.validateNoCollisions = validateNoCollisions;
@@ -25,32 +25,56 @@ const applySequentialTransforms = (value, rules) => {
25
25
  result = result.replace(regex, rule.replace);
26
26
  }
27
27
  catch (error) {
28
- throw new FilenameTransformerError('Failed to apply regex transformation', {
28
+ const regexError = new FilenameTransformerError('Failed to apply regex transformation', {
29
29
  code: 'REGEX_ERROR',
30
30
  cause: error instanceof Error ? error : undefined
31
31
  });
32
+ regexError.message += `\n\n Hint: The regex pattern failed to compile. Check for:`;
33
+ regexError.message += `\n - Balanced parentheses: (group)`;
34
+ regexError.message += `\n - Escaped special chars: \\. \\[ \\]`;
35
+ regexError.message += `\n - Valid capture groups: $1, $2, etc.`;
36
+ regexError.message += `\n Pattern: ${rule.find}`;
37
+ throw regexError;
32
38
  }
33
39
  return result;
34
40
  };
35
41
  const validateTransformedPath = (transformedPath, originalPath) => {
36
- if (!transformedPath || transformedPath.trim() === '')
37
- throw new FilenameTransformerError('Transformed path is empty', {
42
+ if (!transformedPath || transformedPath.trim() === '') {
43
+ const emptyError = new FilenameTransformerError('Transformed path is empty', {
38
44
  code: 'EMPTY_FILENAME',
39
45
  path: originalPath
40
46
  });
41
- if (transformedPath.startsWith('/') || transformedPath.includes('..'))
42
- throw new FilenameTransformerError('Path transform attempted traversal outside source/destination', {
47
+ emptyError.message += '\n\n Hint: The transform produced an empty path. Check your regex pattern:';
48
+ emptyError.message += "\n - Ensure 'replace' value is not empty";
49
+ emptyError.message += '\n - Verify pattern matches expected part of filename';
50
+ throw emptyError;
51
+ }
52
+ if (transformedPath.startsWith('/') || transformedPath.includes('..')) {
53
+ const traversalError = new FilenameTransformerError('Path transform attempted traversal outside source/destination', {
43
54
  code: 'PATH_TRAVERSAL',
44
55
  path: originalPath,
45
56
  details: `Transformed path: ${transformedPath}`
46
57
  });
58
+ traversalError.message += '\n\n Hint: Filename transforms must not navigate outside source/destination:';
59
+ traversalError.message += "\n BAD: { find: 'file\\.yaml', replace: '../file.yaml' } (contains ..)";
60
+ traversalError.message += "\n BAD: { find: 'file', replace: '/etc/passwd' } (absolute path)";
61
+ traversalError.message += "\n GOOD: { find: 'envs/uat/', replace: 'envs/prod/' }";
62
+ traversalError.message += '\n This is a security feature to prevent accidental overwrites.';
63
+ throw traversalError;
64
+ }
47
65
  const invalidChars = /[\u0000-\u001F"*:<>?|]/;
48
- if (invalidChars.test(transformedPath))
49
- throw new FilenameTransformerError('Transformed path contains invalid characters', {
66
+ if (invalidChars.test(transformedPath)) {
67
+ const invalidError = new FilenameTransformerError('Transformed path contains invalid characters', {
50
68
  code: 'INVALID_FILENAME',
51
69
  path: originalPath,
52
70
  details: `Transformed path: ${transformedPath}`
53
71
  });
72
+ invalidError.message += '\n\n Hint: The transformed path contains characters not allowed in filenames:';
73
+ invalidError.message += '\n - Remove control characters (\\x00-\\x1F)';
74
+ invalidError.message += '\n - Avoid: " * : < > ? |';
75
+ invalidError.message += '\n - Check your regex replacement string';
76
+ throw invalidError;
77
+ }
54
78
  };
55
79
  const getFilenameTransformsForFile = (filePath, transforms) => {
56
80
  if (!transforms)
@@ -50,11 +50,16 @@ const applyTransforms = (data, filePath, transforms) => {
50
50
  return transformValueRecursive(data, matchedRules);
51
51
  }
52
52
  catch (error) {
53
- throw new TransformerError('Failed to apply transformations', {
53
+ const transformError = new TransformerError('Failed to apply transformations', {
54
54
  code: 'TRANSFORM_APPLICATION_ERROR',
55
55
  path: filePath,
56
56
  cause: error instanceof Error ? error : new Error(String(error))
57
57
  });
58
+ transformError.message += '\n\n Hint: Common regex issues:';
59
+ transformError.message += '\n - Unescaped special chars: use \\. for dots, \\[ for brackets';
60
+ transformError.message += '\n - Invalid capture groups: ensure balanced parentheses';
61
+ transformError.message += '\n - Test your pattern: https://regex101.com/';
62
+ throw transformError;
58
63
  }
59
64
  };
60
65
  exports.applyTransforms = applyTransforms;
@@ -47,11 +47,16 @@ const formatYaml = (content, filePath, outputFormat) => {
47
47
  return result;
48
48
  }
49
49
  catch (error) {
50
- throw new YamlFormatterError('Failed to format YAML', {
50
+ const formatError = new YamlFormatterError('Failed to format YAML', {
51
51
  code: 'YAML_FORMAT_ERROR',
52
52
  path: filePath,
53
53
  cause: error instanceof Error ? error : undefined
54
54
  });
55
+ formatError.message += '\n\n Hint: Formatting failed. Options:';
56
+ formatError.message += '\n - Skip formatting: --skip-format';
57
+ formatError.message += '\n - Check keyOrders patterns match YAML structure';
58
+ formatError.message += '\n - Verify arraySort paths exist in files';
59
+ throw formatError;
55
60
  }
56
61
  };
57
62
  exports.formatYaml = formatYaml;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helm-env-delta",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "HelmEnvDelta – environment-aware YAML delta and sync for GitOps",
5
5
  "author": "BCsabaEngine",
6
6
  "license": "ISC",
@@ -71,9 +71,7 @@
71
71
  "eslint-config-prettier": "^10.1.8",
72
72
  "eslint-plugin-simple-import-sort": "^12.1.1",
73
73
  "eslint-plugin-unicorn": "^62.0.0",
74
- "nodemon": "^3.1.11",
75
74
  "prettier": "^3.7.4",
76
- "ts-node": "^10.9.2",
77
75
  "tsx": "^4.21.0",
78
76
  "typescript": "^5.9.3",
79
77
  "vitest": "^4.0.16"
@@ -83,7 +81,6 @@
83
81
  "commander": "^14.0.2",
84
82
  "diff": "^8.0.2",
85
83
  "diff2html": "^3.4.52",
86
- "handlebars": "^4.7.8",
87
84
  "open": "^11.0.0",
88
85
  "picomatch": "^4.0.3",
89
86
  "tinyglobby": "^0.2.15",