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 +160 -1
- package/dist/ZodError.js +7 -1
- package/dist/configFile.d.ts +2 -2
- package/dist/configMerger.js +37 -7
- package/dist/fileDiff.js +10 -2
- package/dist/fileLoader.js +34 -5
- package/dist/fileUpdater.js +6 -1
- package/dist/htmlReporter.js +13 -2
- package/dist/stopRulesValidator.js +5 -0
- package/dist/utils/collisionDetector.js +6 -1
- package/dist/utils/filenameTransformer.js +31 -7
- package/dist/utils/transformer.js +6 -1
- package/dist/yamlFormatter.js +6 -1
- package/package.json +1 -4
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# HelmEnvDelta
|
|
1
|
+
# HelmEnvDelta v1.1.0
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/helm-env-delta)
|
|
4
4
|
[](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(', ')}`;
|
package/dist/configFile.d.ts
CHANGED
|
@@ -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;
|
package/dist/configMerger.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/fileLoader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/fileUpdater.js
CHANGED
|
@@ -84,11 +84,16 @@ const mergeYamlContent = (destinationContent, processedSourceContent, filePath)
|
|
|
84
84
|
destinationParsed = yaml_1.default.parse(destinationContent);
|
|
85
85
|
}
|
|
86
86
|
catch (error) {
|
|
87
|
-
|
|
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 {
|
package/dist/htmlReporter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/yamlFormatter.js
CHANGED
|
@@ -47,11 +47,16 @@ const formatYaml = (content, filePath, outputFormat) => {
|
|
|
47
47
|
return result;
|
|
48
48
|
}
|
|
49
49
|
catch (error) {
|
|
50
|
-
|
|
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.
|
|
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",
|