@aspruyt/json-config-sync 2.1.1 → 3.1.0
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 +226 -118
- package/dist/config-normalizer.js +38 -21
- package/dist/config-validator.js +73 -25
- package/dist/config.d.ts +18 -7
- package/dist/index.js +37 -4
- package/dist/pr-creator.d.ts +13 -3
- package/dist/pr-creator.js +65 -12
- package/dist/repository-processor.d.ts +4 -1
- package/dist/repository-processor.js +68 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# json-config-sync
|
|
2
2
|
|
|
3
|
-
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
|
|
4
|
-
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration-test.yml)
|
|
3
|
+
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
|
|
5
4
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
6
5
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
7
6
|
|
|
@@ -36,14 +35,13 @@ gh auth login
|
|
|
36
35
|
|
|
37
36
|
# Create config.yaml
|
|
38
37
|
cat > config.yaml << 'EOF'
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
trailingComma: es5
|
|
38
|
+
files:
|
|
39
|
+
.prettierrc.json:
|
|
40
|
+
content:
|
|
41
|
+
semi: false
|
|
42
|
+
singleQuote: true
|
|
43
|
+
tabWidth: 2
|
|
44
|
+
trailingComma: es5
|
|
47
45
|
|
|
48
46
|
repos:
|
|
49
47
|
# Multiple repos can share the same config
|
|
@@ -61,6 +59,7 @@ json-config-sync --config ./config.yaml
|
|
|
61
59
|
|
|
62
60
|
## Features
|
|
63
61
|
|
|
62
|
+
- **Multi-File Sync** - Sync multiple config files in a single run
|
|
64
63
|
- **JSON/YAML Output** - Automatically outputs JSON or YAML based on filename extension
|
|
65
64
|
- **Content Inheritance** - Define base config once, override per-repo as needed
|
|
66
65
|
- **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
|
|
@@ -130,61 +129,86 @@ json-config-sync --config ./config.yaml --branch feature/update-eslint
|
|
|
130
129
|
|
|
131
130
|
### Options
|
|
132
131
|
|
|
133
|
-
| Option | Alias | Description
|
|
134
|
-
| ------------ | ----- |
|
|
135
|
-
| `--config` | `-c` | Path to YAML config file
|
|
136
|
-
| `--dry-run` | `-d` | Show what would be done without making changes
|
|
137
|
-
| `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`)
|
|
138
|
-
| `--retries` | `-r` | Number of retries for network operations (default: 3)
|
|
139
|
-
| `--branch` | `-b` | Override branch name (default: `chore/sync-
|
|
132
|
+
| Option | Alias | Description | Required |
|
|
133
|
+
| ------------ | ----- | ----------------------------------------------------- | -------- |
|
|
134
|
+
| `--config` | `-c` | Path to YAML config file | Yes |
|
|
135
|
+
| `--dry-run` | `-d` | Show what would be done without making changes | No |
|
|
136
|
+
| `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
|
|
137
|
+
| `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
|
|
138
|
+
| `--branch` | `-b` | Override branch name (default: `chore/sync-config`) | No |
|
|
140
139
|
|
|
141
140
|
## Configuration Format
|
|
142
141
|
|
|
143
142
|
### Basic Structure
|
|
144
143
|
|
|
145
144
|
```yaml
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
content: # Base config content
|
|
150
|
-
|
|
145
|
+
files:
|
|
146
|
+
my.config.json: # Target file (.json outputs JSON, .yaml/.yml outputs YAML)
|
|
147
|
+
mergeStrategy: replace # Array merge strategy for this file (optional)
|
|
148
|
+
content: # Base config content
|
|
149
|
+
key: value
|
|
151
150
|
|
|
152
151
|
repos: # List of repositories
|
|
153
152
|
- git: git@github.com:org/repo.git
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
files: # Per-repo file overrides (optional)
|
|
154
|
+
my.config.json:
|
|
155
|
+
content:
|
|
156
|
+
key: override
|
|
156
157
|
```
|
|
157
158
|
|
|
158
159
|
### Root-Level Fields
|
|
159
160
|
|
|
160
|
-
| Field
|
|
161
|
-
|
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `mergeStrategy` | Default array merge strategy: `replace`, `append`, `prepend` | No |
|
|
165
|
-
| `repos` | Array of repository configurations | Yes |
|
|
161
|
+
| Field | Description | Required |
|
|
162
|
+
| ------- | ---------------------------------- | -------- |
|
|
163
|
+
| `files` | Map of target filenames to configs | Yes |
|
|
164
|
+
| `repos` | Array of repository configurations | Yes |
|
|
166
165
|
|
|
167
|
-
|
|
166
|
+
### Per-File Fields
|
|
167
|
+
|
|
168
|
+
| Field | Description | Required |
|
|
169
|
+
| --------------- | ---------------------------------------------------- | -------- |
|
|
170
|
+
| `content` | Base config inherited by all repos | Yes |
|
|
171
|
+
| `mergeStrategy` | Array merge strategy: `replace`, `append`, `prepend` | No |
|
|
172
|
+
| `createOnly` | If `true`, only create file if it doesn't exist | No |
|
|
168
173
|
|
|
169
174
|
### Per-Repo Fields
|
|
170
175
|
|
|
171
|
-
| Field
|
|
172
|
-
|
|
|
173
|
-
| `git`
|
|
174
|
-
| `
|
|
175
|
-
|
|
176
|
+
| Field | Description | Required |
|
|
177
|
+
| ------- | ---------------------------------- | -------- |
|
|
178
|
+
| `git` | Git URL (string) or array of URLs | Yes |
|
|
179
|
+
| `files` | Per-repo file overrides (optional) | No |
|
|
180
|
+
|
|
181
|
+
### Per-Repo File Override Fields
|
|
176
182
|
|
|
177
|
-
|
|
183
|
+
| Field | Description | Required |
|
|
184
|
+
| ------------ | ------------------------------------------------------- | -------- |
|
|
185
|
+
| `content` | Content overlay merged onto file's base content | No |
|
|
186
|
+
| `override` | If `true`, ignore base content and use only this repo's | No |
|
|
187
|
+
| `createOnly` | Override root-level `createOnly` for this repo | No |
|
|
188
|
+
|
|
189
|
+
**File Exclusion:** Set a file to `false` to exclude it from a specific repo:
|
|
190
|
+
|
|
191
|
+
```yaml
|
|
192
|
+
repos:
|
|
193
|
+
- git: git@github.com:org/repo.git
|
|
194
|
+
files:
|
|
195
|
+
eslint.json: false # This repo won't receive eslint.json
|
|
196
|
+
```
|
|
178
197
|
|
|
179
198
|
### Environment Variables
|
|
180
199
|
|
|
181
200
|
Use `${VAR}` syntax in string values:
|
|
182
201
|
|
|
183
202
|
```yaml
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
203
|
+
files:
|
|
204
|
+
app.config.json:
|
|
205
|
+
content:
|
|
206
|
+
apiUrl: ${API_URL} # Required - errors if not set
|
|
207
|
+
environment: ${ENV:-development} # With default value
|
|
208
|
+
secretKey: ${SECRET:?Secret required} # Required with custom error message
|
|
209
|
+
|
|
210
|
+
repos:
|
|
211
|
+
- git: git@github.com:org/backend.git
|
|
188
212
|
```
|
|
189
213
|
|
|
190
214
|
### Merge Directives
|
|
@@ -192,66 +216,106 @@ content:
|
|
|
192
216
|
Control array merging with the `$arrayMerge` directive:
|
|
193
217
|
|
|
194
218
|
```yaml
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
219
|
+
files:
|
|
220
|
+
config.json:
|
|
221
|
+
content:
|
|
222
|
+
features:
|
|
223
|
+
- core
|
|
224
|
+
- monitoring
|
|
199
225
|
|
|
200
226
|
repos:
|
|
201
227
|
- git: git@github.com:org/repo.git
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
228
|
+
files:
|
|
229
|
+
config.json:
|
|
230
|
+
content:
|
|
231
|
+
features:
|
|
232
|
+
$arrayMerge: append # append | prepend | replace
|
|
233
|
+
values:
|
|
234
|
+
- custom-feature # Results in: [core, monitoring, custom-feature]
|
|
207
235
|
```
|
|
208
236
|
|
|
209
237
|
## Examples
|
|
210
238
|
|
|
239
|
+
### Multi-File Sync
|
|
240
|
+
|
|
241
|
+
Sync multiple configuration files to all repos:
|
|
242
|
+
|
|
243
|
+
```yaml
|
|
244
|
+
files:
|
|
245
|
+
.eslintrc.json:
|
|
246
|
+
content:
|
|
247
|
+
extends: ["@org/eslint-config"]
|
|
248
|
+
rules:
|
|
249
|
+
no-console: warn
|
|
250
|
+
|
|
251
|
+
.prettierrc.yaml:
|
|
252
|
+
content:
|
|
253
|
+
semi: false
|
|
254
|
+
singleQuote: true
|
|
255
|
+
|
|
256
|
+
tsconfig.json:
|
|
257
|
+
content:
|
|
258
|
+
compilerOptions:
|
|
259
|
+
strict: true
|
|
260
|
+
target: ES2022
|
|
261
|
+
|
|
262
|
+
repos:
|
|
263
|
+
- git:
|
|
264
|
+
- git@github.com:org/frontend.git
|
|
265
|
+
- git@github.com:org/backend.git
|
|
266
|
+
- git@github.com:org/shared-lib.git
|
|
267
|
+
```
|
|
268
|
+
|
|
211
269
|
### Shared Config Across Teams
|
|
212
270
|
|
|
213
271
|
Define common settings once, customize per team:
|
|
214
272
|
|
|
215
273
|
```yaml
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
content:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
274
|
+
files:
|
|
275
|
+
service.config.json:
|
|
276
|
+
content:
|
|
277
|
+
version: "2.0"
|
|
278
|
+
logging:
|
|
279
|
+
level: info
|
|
280
|
+
format: json
|
|
281
|
+
features:
|
|
282
|
+
- health-check
|
|
283
|
+
- metrics
|
|
226
284
|
|
|
227
285
|
repos:
|
|
228
286
|
# Platform team repos - add extra features
|
|
229
287
|
- git:
|
|
230
288
|
- git@github.com:org/api-gateway.git
|
|
231
289
|
- git@github.com:org/auth-service.git
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
290
|
+
files:
|
|
291
|
+
service.config.json:
|
|
292
|
+
content:
|
|
293
|
+
team: platform
|
|
294
|
+
features:
|
|
295
|
+
$arrayMerge: append
|
|
296
|
+
values:
|
|
297
|
+
- tracing
|
|
298
|
+
- rate-limiting
|
|
239
299
|
|
|
240
300
|
# Data team repos - different logging
|
|
241
301
|
- git:
|
|
242
302
|
- git@github.com:org/data-pipeline.git
|
|
243
303
|
- git@github.com:org/analytics.git
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
304
|
+
files:
|
|
305
|
+
service.config.json:
|
|
306
|
+
content:
|
|
307
|
+
team: data
|
|
308
|
+
logging:
|
|
309
|
+
level: debug
|
|
248
310
|
|
|
249
311
|
# Legacy service - completely different config
|
|
250
312
|
- git: git@github.com:org/legacy-api.git
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
313
|
+
files:
|
|
314
|
+
service.config.json:
|
|
315
|
+
override: true
|
|
316
|
+
content:
|
|
317
|
+
version: "1.0"
|
|
318
|
+
legacy: true
|
|
255
319
|
```
|
|
256
320
|
|
|
257
321
|
### Environment-Specific Values
|
|
@@ -259,40 +323,79 @@ repos:
|
|
|
259
323
|
Use environment variables for secrets and environment-specific values:
|
|
260
324
|
|
|
261
325
|
```yaml
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
content:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
326
|
+
files:
|
|
327
|
+
app.config.json:
|
|
328
|
+
content:
|
|
329
|
+
database:
|
|
330
|
+
host: ${DB_HOST:-localhost}
|
|
331
|
+
port: ${DB_PORT:-5432}
|
|
332
|
+
password: ${DB_PASSWORD:?Database password required}
|
|
269
333
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
334
|
+
api:
|
|
335
|
+
baseUrl: ${API_BASE_URL}
|
|
336
|
+
timeout: 30000
|
|
273
337
|
|
|
274
338
|
repos:
|
|
275
339
|
- git: git@github.com:org/backend.git
|
|
276
340
|
```
|
|
277
341
|
|
|
278
|
-
###
|
|
342
|
+
### Per-File Merge Strategies
|
|
279
343
|
|
|
280
|
-
|
|
344
|
+
Different files can use different array merge strategies:
|
|
281
345
|
|
|
282
346
|
```yaml
|
|
283
|
-
|
|
347
|
+
files:
|
|
348
|
+
eslint.config.json:
|
|
349
|
+
mergeStrategy: append # Extends will append
|
|
350
|
+
content:
|
|
351
|
+
extends: ["@company/base"]
|
|
284
352
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
353
|
+
tsconfig.json:
|
|
354
|
+
mergeStrategy: replace # Lib will replace entirely
|
|
355
|
+
content:
|
|
356
|
+
compilerOptions:
|
|
357
|
+
lib: ["ES2022"]
|
|
289
358
|
|
|
290
359
|
repos:
|
|
291
|
-
- git:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
360
|
+
- git: git@github.com:org/frontend.git
|
|
361
|
+
files:
|
|
362
|
+
eslint.config.json:
|
|
363
|
+
content:
|
|
364
|
+
extends: ["plugin:react/recommended"]
|
|
365
|
+
# Results in extends: ["@company/base", "plugin:react/recommended"]
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Create-Only Files (Defaults That Can Be Customized)
|
|
369
|
+
|
|
370
|
+
Some files should only be created once as defaults, allowing repos to maintain their own versions:
|
|
371
|
+
|
|
372
|
+
```yaml
|
|
373
|
+
files:
|
|
374
|
+
.trivyignore.yaml:
|
|
375
|
+
createOnly: true # Only create if doesn't exist
|
|
376
|
+
content:
|
|
377
|
+
vulnerabilities: []
|
|
378
|
+
|
|
379
|
+
.prettierignore:
|
|
380
|
+
createOnly: true
|
|
381
|
+
content:
|
|
382
|
+
patterns:
|
|
383
|
+
- "dist/"
|
|
384
|
+
- "node_modules/"
|
|
385
|
+
|
|
386
|
+
eslint.config.json:
|
|
387
|
+
content: # Always synced (no createOnly)
|
|
388
|
+
extends: ["@company/base"]
|
|
389
|
+
|
|
390
|
+
repos:
|
|
391
|
+
- git: git@github.com:org/repo.git
|
|
392
|
+
# .trivyignore.yaml and .prettierignore only created if missing
|
|
393
|
+
# eslint.config.json always updated
|
|
394
|
+
|
|
395
|
+
- git: git@github.com:org/special-repo.git
|
|
396
|
+
files:
|
|
397
|
+
.trivyignore.yaml:
|
|
398
|
+
createOnly: false # Override: always sync this file
|
|
296
399
|
```
|
|
297
400
|
|
|
298
401
|
## Supported Git URL Formats
|
|
@@ -312,17 +415,17 @@ repos:
|
|
|
312
415
|
```mermaid
|
|
313
416
|
flowchart TB
|
|
314
417
|
subgraph Input
|
|
315
|
-
YAML[/"YAML Config File<br/>
|
|
418
|
+
YAML[/"YAML Config File<br/>files{} + repos[]"/]
|
|
316
419
|
end
|
|
317
420
|
|
|
318
421
|
subgraph Normalization
|
|
319
|
-
EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content]
|
|
422
|
+
EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content<br/>for each file]
|
|
320
423
|
MERGE --> ENV[Interpolate env vars]
|
|
321
424
|
end
|
|
322
425
|
|
|
323
426
|
subgraph Processing["For Each Repository"]
|
|
324
|
-
CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>--branch or chore/sync-
|
|
325
|
-
BRANCH --> WRITE[Write Config
|
|
427
|
+
CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>--branch or chore/sync-config]
|
|
428
|
+
BRANCH --> WRITE[Write All Config Files<br/>JSON or YAML]
|
|
326
429
|
WRITE --> CHECK{Changes?}
|
|
327
430
|
CHECK -->|No| SKIP[Skip - No Changes]
|
|
328
431
|
CHECK -->|Yes| COMMIT[Commit Changes]
|
|
@@ -347,12 +450,12 @@ flowchart TB
|
|
|
347
450
|
For each repository in the config, the tool:
|
|
348
451
|
|
|
349
452
|
1. Expands git URL arrays into individual entries
|
|
350
|
-
2.
|
|
453
|
+
2. For each file, merges base content with per-repo overlay
|
|
351
454
|
3. Interpolates environment variables
|
|
352
455
|
4. Cleans the temporary workspace
|
|
353
456
|
5. Clones the repository
|
|
354
|
-
6. Creates/checks out branch (custom `--branch` or default `chore/sync-
|
|
355
|
-
7.
|
|
457
|
+
6. Creates/checks out branch (custom `--branch` or default `chore/sync-config`)
|
|
458
|
+
7. Writes all config files (JSON or YAML based on filename extension)
|
|
356
459
|
8. Checks for changes (skips if no changes)
|
|
357
460
|
9. Commits and pushes changes
|
|
358
461
|
10. Creates a pull request
|
|
@@ -417,22 +520,25 @@ steps:
|
|
|
417
520
|
```
|
|
418
521
|
[1/3] Processing example-org/repo1...
|
|
419
522
|
✓ Cloned repository
|
|
420
|
-
✓ Created branch chore/sync-
|
|
421
|
-
✓ Wrote
|
|
523
|
+
✓ Created branch chore/sync-config
|
|
524
|
+
✓ Wrote .eslintrc.json
|
|
525
|
+
✓ Wrote .prettierrc.yaml
|
|
422
526
|
✓ Committed changes
|
|
423
527
|
✓ Pushed to remote
|
|
424
528
|
✓ Created PR: https://github.com/example-org/repo1/pull/42
|
|
425
529
|
|
|
426
530
|
[2/3] Processing example-org/repo2...
|
|
427
531
|
✓ Cloned repository
|
|
428
|
-
✓ Checked out existing branch chore/sync-
|
|
429
|
-
✓ Wrote
|
|
532
|
+
✓ Checked out existing branch chore/sync-config
|
|
533
|
+
✓ Wrote .eslintrc.json
|
|
534
|
+
✓ Wrote .prettierrc.yaml
|
|
430
535
|
⊘ No changes detected, skipping
|
|
431
536
|
|
|
432
537
|
[3/3] Processing example-org/repo3...
|
|
433
538
|
✓ Cloned repository
|
|
434
|
-
✓ Created branch chore/sync-
|
|
435
|
-
✓ Wrote
|
|
539
|
+
✓ Created branch chore/sync-config
|
|
540
|
+
✓ Wrote .eslintrc.json
|
|
541
|
+
✓ Wrote .prettierrc.yaml
|
|
436
542
|
✓ Committed changes
|
|
437
543
|
✓ Pushed to remote
|
|
438
544
|
✓ PR already exists: https://github.com/example-org/repo3/pull/15
|
|
@@ -444,9 +550,9 @@ Summary: 2 succeeded, 1 skipped, 0 failed
|
|
|
444
550
|
|
|
445
551
|
The tool creates PRs with:
|
|
446
552
|
|
|
447
|
-
- **Title:** `chore: sync
|
|
448
|
-
- **Branch:** `chore/sync-
|
|
449
|
-
- **Body:** Describes the sync action and
|
|
553
|
+
- **Title:** `chore: sync config files` (or lists files if ≤3)
|
|
554
|
+
- **Branch:** `chore/sync-config` (or custom `--branch`)
|
|
555
|
+
- **Body:** Describes the sync action and lists changed files
|
|
450
556
|
|
|
451
557
|
## Troubleshooting
|
|
452
558
|
|
|
@@ -485,7 +591,7 @@ The tool automatically reuses existing branches. If you see unexpected behavior:
|
|
|
485
591
|
|
|
486
592
|
```bash
|
|
487
593
|
# Delete the remote branch to start fresh
|
|
488
|
-
git push origin --delete chore/sync-
|
|
594
|
+
git push origin --delete chore/sync-config
|
|
489
595
|
```
|
|
490
596
|
|
|
491
597
|
### Missing Environment Variables
|
|
@@ -535,9 +641,11 @@ For autocomplete and validation in VS Code, install the [YAML extension](https:/
|
|
|
535
641
|
|
|
536
642
|
```yaml
|
|
537
643
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/anthony-spruyt/json-config-sync/main/config-schema.json
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
644
|
+
files:
|
|
645
|
+
my.config.json:
|
|
646
|
+
content:
|
|
647
|
+
key: value
|
|
648
|
+
|
|
541
649
|
repos:
|
|
542
650
|
- git: git@github.com:org/repo.git
|
|
543
651
|
```
|
|
@@ -557,7 +665,7 @@ repos:
|
|
|
557
665
|
|
|
558
666
|
This enables:
|
|
559
667
|
|
|
560
|
-
- Autocomplete for `
|
|
668
|
+
- Autocomplete for `files`, `repos`, `content`, `mergeStrategy`, `git`, `override`
|
|
561
669
|
- Enum suggestions for `mergeStrategy` values (`replace`, `append`, `prepend`)
|
|
562
670
|
- Validation of required fields
|
|
563
671
|
- Hover documentation for each field
|
|
@@ -5,39 +5,56 @@ import { interpolateEnvVars } from "./env.js";
|
|
|
5
5
|
* Pipeline: expand git arrays -> merge content -> interpolate env vars
|
|
6
6
|
*/
|
|
7
7
|
export function normalizeConfig(raw) {
|
|
8
|
-
const baseContent = raw.content ?? {};
|
|
9
|
-
const defaultStrategy = raw.mergeStrategy ?? "replace";
|
|
10
8
|
const expandedRepos = [];
|
|
9
|
+
const fileNames = Object.keys(raw.files);
|
|
11
10
|
for (const rawRepo of raw.repos) {
|
|
12
11
|
// Step 1: Expand git arrays
|
|
13
12
|
const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
|
|
14
13
|
for (const gitUrl of gitUrls) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
const files = [];
|
|
15
|
+
// Step 2: Process each file definition
|
|
16
|
+
for (const fileName of fileNames) {
|
|
17
|
+
const repoOverride = rawRepo.files?.[fileName];
|
|
18
|
+
// Skip excluded files (set to false)
|
|
19
|
+
if (repoOverride === false) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const fileConfig = raw.files[fileName];
|
|
23
|
+
const baseContent = fileConfig.content ?? {};
|
|
24
|
+
const fileStrategy = fileConfig.mergeStrategy ?? "replace";
|
|
25
|
+
// Step 3: Compute merged content for this file
|
|
26
|
+
let mergedContent;
|
|
27
|
+
if (repoOverride?.override) {
|
|
28
|
+
// Override mode: use only repo file content
|
|
29
|
+
mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
|
|
30
|
+
}
|
|
31
|
+
else if (!repoOverride?.content) {
|
|
32
|
+
// No repo override: use file base content as-is
|
|
33
|
+
mergedContent = structuredClone(baseContent);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// Merge mode: deep merge file base + repo overlay
|
|
37
|
+
const ctx = createMergeContext(fileStrategy);
|
|
38
|
+
mergedContent = deepMerge(structuredClone(baseContent), repoOverride.content, ctx);
|
|
39
|
+
mergedContent = stripMergeDirectives(mergedContent);
|
|
40
|
+
}
|
|
41
|
+
// Step 4: Interpolate env vars
|
|
42
|
+
mergedContent = interpolateEnvVars(mergedContent, { strict: true });
|
|
43
|
+
// Resolve createOnly: per-repo overrides root level
|
|
44
|
+
const createOnly = repoOverride?.createOnly ?? fileConfig.createOnly;
|
|
45
|
+
files.push({
|
|
46
|
+
fileName,
|
|
47
|
+
content: mergedContent,
|
|
48
|
+
createOnly,
|
|
49
|
+
});
|
|
20
50
|
}
|
|
21
|
-
else if (!rawRepo.content) {
|
|
22
|
-
// No repo content: use root content as-is
|
|
23
|
-
mergedContent = structuredClone(baseContent);
|
|
24
|
-
}
|
|
25
|
-
else {
|
|
26
|
-
// Merge mode: deep merge base + overlay
|
|
27
|
-
const ctx = createMergeContext(defaultStrategy);
|
|
28
|
-
mergedContent = deepMerge(structuredClone(baseContent), rawRepo.content, ctx);
|
|
29
|
-
mergedContent = stripMergeDirectives(mergedContent);
|
|
30
|
-
}
|
|
31
|
-
// Step 3: Interpolate env vars
|
|
32
|
-
mergedContent = interpolateEnvVars(mergedContent, { strict: true });
|
|
33
51
|
expandedRepos.push({
|
|
34
52
|
git: gitUrl,
|
|
35
|
-
|
|
53
|
+
files,
|
|
36
54
|
});
|
|
37
55
|
}
|
|
38
56
|
}
|
|
39
57
|
return {
|
|
40
|
-
fileName: raw.fileName,
|
|
41
58
|
repos: expandedRepos,
|
|
42
59
|
};
|
|
43
60
|
}
|
package/dist/config-validator.js
CHANGED
|
@@ -1,35 +1,43 @@
|
|
|
1
1
|
import { isAbsolute } from "node:path";
|
|
2
|
+
const VALID_STRATEGIES = ["replace", "append", "prepend"];
|
|
2
3
|
/**
|
|
3
4
|
* Validates raw config structure before normalization.
|
|
4
5
|
* @throws Error if validation fails
|
|
5
6
|
*/
|
|
6
7
|
export function validateRawConfig(config) {
|
|
7
|
-
if (!config.
|
|
8
|
-
throw new Error("Config missing required field:
|
|
8
|
+
if (!config.files || typeof config.files !== "object") {
|
|
9
|
+
throw new Error("Config missing required field: files (must be an object)");
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
throw new Error(
|
|
11
|
+
const fileNames = Object.keys(config.files);
|
|
12
|
+
if (fileNames.length === 0) {
|
|
13
|
+
throw new Error("Config files object cannot be empty");
|
|
13
14
|
}
|
|
14
|
-
// Validate
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Validate each file definition
|
|
16
|
+
for (const fileName of fileNames) {
|
|
17
|
+
validateFileName(fileName);
|
|
18
|
+
const fileConfig = config.files[fileName];
|
|
19
|
+
if (!fileConfig || typeof fileConfig !== "object") {
|
|
20
|
+
throw new Error(`File '${fileName}' must have a configuration object`);
|
|
21
|
+
}
|
|
22
|
+
if (fileConfig.content !== undefined &&
|
|
23
|
+
(typeof fileConfig.content !== "object" ||
|
|
24
|
+
fileConfig.content === null ||
|
|
25
|
+
Array.isArray(fileConfig.content))) {
|
|
26
|
+
throw new Error(`File '${fileName}' content must be an object`);
|
|
27
|
+
}
|
|
28
|
+
if (fileConfig.mergeStrategy !== undefined &&
|
|
29
|
+
!VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
|
|
30
|
+
throw new Error(`File '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
|
|
31
|
+
}
|
|
32
|
+
if (fileConfig.createOnly !== undefined &&
|
|
33
|
+
typeof fileConfig.createOnly !== "boolean") {
|
|
34
|
+
throw new Error(`File '${fileName}' createOnly must be a boolean`);
|
|
35
|
+
}
|
|
17
36
|
}
|
|
18
37
|
if (!config.repos || !Array.isArray(config.repos)) {
|
|
19
38
|
throw new Error("Config missing required field: repos (must be an array)");
|
|
20
39
|
}
|
|
21
|
-
|
|
22
|
-
if (config.mergeStrategy !== undefined &&
|
|
23
|
-
!validStrategies.includes(config.mergeStrategy)) {
|
|
24
|
-
throw new Error(`Invalid mergeStrategy: ${config.mergeStrategy}. Must be one of: ${validStrategies.join(", ")}`);
|
|
25
|
-
}
|
|
26
|
-
if (config.content !== undefined &&
|
|
27
|
-
(typeof config.content !== "object" ||
|
|
28
|
-
config.content === null ||
|
|
29
|
-
Array.isArray(config.content))) {
|
|
30
|
-
throw new Error("Root content must be an object");
|
|
31
|
-
}
|
|
32
|
-
const hasRootContent = config.content !== undefined;
|
|
40
|
+
// Validate each repo
|
|
33
41
|
for (let i = 0; i < config.repos.length; i++) {
|
|
34
42
|
const repo = config.repos[i];
|
|
35
43
|
if (!repo.git) {
|
|
@@ -38,14 +46,54 @@ export function validateRawConfig(config) {
|
|
|
38
46
|
if (Array.isArray(repo.git) && repo.git.length === 0) {
|
|
39
47
|
throw new Error(`Repo at index ${i} has empty git array`);
|
|
40
48
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
// Validate per-repo file overrides
|
|
50
|
+
if (repo.files) {
|
|
51
|
+
if (typeof repo.files !== "object" || Array.isArray(repo.files)) {
|
|
52
|
+
throw new Error(`Repo at index ${i}: files must be an object`);
|
|
53
|
+
}
|
|
54
|
+
for (const fileName of Object.keys(repo.files)) {
|
|
55
|
+
// Ensure the file is defined at root level
|
|
56
|
+
if (!config.files[fileName]) {
|
|
57
|
+
throw new Error(`Repo at index ${i} references undefined file '${fileName}'. File must be defined in root 'files' object.`);
|
|
58
|
+
}
|
|
59
|
+
const fileOverride = repo.files[fileName];
|
|
60
|
+
// false means exclude this file for this repo - no further validation needed
|
|
61
|
+
if (fileOverride === false) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (fileOverride.override && !fileOverride.content) {
|
|
65
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined`);
|
|
66
|
+
}
|
|
67
|
+
if (fileOverride.content !== undefined &&
|
|
68
|
+
(typeof fileOverride.content !== "object" ||
|
|
69
|
+
fileOverride.content === null ||
|
|
70
|
+
Array.isArray(fileOverride.content))) {
|
|
71
|
+
throw new Error(`Repo at index ${i}: file '${fileName}' content must be an object`);
|
|
72
|
+
}
|
|
73
|
+
if (fileOverride.createOnly !== undefined &&
|
|
74
|
+
typeof fileOverride.createOnly !== "boolean") {
|
|
75
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' createOnly must be a boolean`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
46
78
|
}
|
|
47
79
|
}
|
|
48
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Validates a file name for security issues
|
|
83
|
+
*/
|
|
84
|
+
function validateFileName(fileName) {
|
|
85
|
+
if (!fileName || typeof fileName !== "string") {
|
|
86
|
+
throw new Error("File name must be a non-empty string");
|
|
87
|
+
}
|
|
88
|
+
// Validate fileName doesn't allow path traversal
|
|
89
|
+
if (fileName.includes("..") || isAbsolute(fileName)) {
|
|
90
|
+
throw new Error(`Invalid fileName '${fileName}': must be a relative path without '..' components`);
|
|
91
|
+
}
|
|
92
|
+
// Validate fileName doesn't contain control characters that could bypass shell escaping
|
|
93
|
+
if (/[\n\r\0]/.test(fileName)) {
|
|
94
|
+
throw new Error(`Invalid fileName '${fileName}': cannot contain newlines or null bytes`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
49
97
|
function getGitDisplayName(git) {
|
|
50
98
|
if (Array.isArray(git)) {
|
|
51
99
|
return git[0] || "unknown";
|
package/dist/config.d.ts
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
import type { ArrayMergeStrategy } from "./merge.js";
|
|
2
2
|
export { convertContentToString } from "./config-formatter.js";
|
|
3
|
-
export interface
|
|
4
|
-
|
|
3
|
+
export interface RawFileConfig {
|
|
4
|
+
content: Record<string, unknown>;
|
|
5
|
+
mergeStrategy?: ArrayMergeStrategy;
|
|
6
|
+
createOnly?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface RawRepoFileOverride {
|
|
5
9
|
content?: Record<string, unknown>;
|
|
6
10
|
override?: boolean;
|
|
11
|
+
createOnly?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface RawRepoConfig {
|
|
14
|
+
git: string | string[];
|
|
15
|
+
files?: Record<string, RawRepoFileOverride | false>;
|
|
7
16
|
}
|
|
8
17
|
export interface RawConfig {
|
|
9
|
-
|
|
10
|
-
content?: Record<string, unknown>;
|
|
11
|
-
mergeStrategy?: ArrayMergeStrategy;
|
|
18
|
+
files: Record<string, RawFileConfig>;
|
|
12
19
|
repos: RawRepoConfig[];
|
|
13
20
|
}
|
|
21
|
+
export interface FileContent {
|
|
22
|
+
fileName: string;
|
|
23
|
+
content: Record<string, unknown>;
|
|
24
|
+
createOnly?: boolean;
|
|
25
|
+
}
|
|
14
26
|
export interface RepoConfig {
|
|
15
27
|
git: string;
|
|
16
|
-
|
|
28
|
+
files: FileContent[];
|
|
17
29
|
}
|
|
18
30
|
export interface Config {
|
|
19
|
-
fileName: string;
|
|
20
31
|
repos: RepoConfig[];
|
|
21
32
|
}
|
|
22
33
|
export declare function loadConfig(filePath: string): Config;
|
package/dist/index.js
CHANGED
|
@@ -25,9 +25,42 @@ program
|
|
|
25
25
|
.option("-d, --dry-run", "Show what would be done without making changes")
|
|
26
26
|
.option("-w, --work-dir <path>", "Temporary directory for cloning", "./tmp")
|
|
27
27
|
.option("-r, --retries <number>", "Number of retries for network operations (0 to disable)", (v) => parseInt(v, 10), 3)
|
|
28
|
-
.option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename})")
|
|
28
|
+
.option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename} or chore/sync-config)")
|
|
29
29
|
.parse();
|
|
30
30
|
const options = program.opts();
|
|
31
|
+
/**
|
|
32
|
+
* Get unique file names from all repos in the config
|
|
33
|
+
*/
|
|
34
|
+
function getUniqueFileNames(config) {
|
|
35
|
+
const fileNames = new Set();
|
|
36
|
+
for (const repo of config.repos) {
|
|
37
|
+
for (const file of repo.files) {
|
|
38
|
+
fileNames.add(file.fileName);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Array.from(fileNames);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Generate default branch name based on files being synced
|
|
45
|
+
*/
|
|
46
|
+
function generateBranchName(fileNames) {
|
|
47
|
+
if (fileNames.length === 1) {
|
|
48
|
+
return `chore/sync-${sanitizeBranchName(fileNames[0])}`;
|
|
49
|
+
}
|
|
50
|
+
return "chore/sync-config";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Format file names for display
|
|
54
|
+
*/
|
|
55
|
+
function formatFileNames(fileNames) {
|
|
56
|
+
if (fileNames.length === 1) {
|
|
57
|
+
return fileNames[0];
|
|
58
|
+
}
|
|
59
|
+
if (fileNames.length <= 3) {
|
|
60
|
+
return fileNames.join(", ");
|
|
61
|
+
}
|
|
62
|
+
return `${fileNames.length} files`;
|
|
63
|
+
}
|
|
31
64
|
async function main() {
|
|
32
65
|
const configPath = resolve(options.config);
|
|
33
66
|
if (!existsSync(configPath)) {
|
|
@@ -39,17 +72,18 @@ async function main() {
|
|
|
39
72
|
console.log("Running in DRY RUN mode - no changes will be made\n");
|
|
40
73
|
}
|
|
41
74
|
const config = loadConfig(configPath);
|
|
75
|
+
const fileNames = getUniqueFileNames(config);
|
|
42
76
|
let branchName;
|
|
43
77
|
if (options.branch) {
|
|
44
78
|
validateBranchName(options.branch);
|
|
45
79
|
branchName = options.branch;
|
|
46
80
|
}
|
|
47
81
|
else {
|
|
48
|
-
branchName =
|
|
82
|
+
branchName = generateBranchName(fileNames);
|
|
49
83
|
}
|
|
50
84
|
logger.setTotal(config.repos.length);
|
|
51
85
|
console.log(`Found ${config.repos.length} repositories to process`);
|
|
52
|
-
console.log(`Target
|
|
86
|
+
console.log(`Target files: ${formatFileNames(fileNames)}`);
|
|
53
87
|
console.log(`Branch: ${branchName}\n`);
|
|
54
88
|
const processor = defaultProcessorFactory();
|
|
55
89
|
for (let i = 0; i < config.repos.length; i++) {
|
|
@@ -69,7 +103,6 @@ async function main() {
|
|
|
69
103
|
try {
|
|
70
104
|
logger.progress(current, repoName, "Processing...");
|
|
71
105
|
const result = await processor.process(repoConfig, repoInfo, {
|
|
72
|
-
fileName: config.fileName,
|
|
73
106
|
branchName,
|
|
74
107
|
workDir,
|
|
75
108
|
dryRun: options.dryRun,
|
package/dist/pr-creator.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { RepoInfo } from "./repo-detector.js";
|
|
2
2
|
export { escapeShellArg } from "./shell-utils.js";
|
|
3
|
+
export interface FileAction {
|
|
4
|
+
fileName: string;
|
|
5
|
+
action: "create" | "update" | "skip";
|
|
6
|
+
}
|
|
3
7
|
export interface PROptions {
|
|
4
8
|
repoInfo: RepoInfo;
|
|
5
9
|
branchName: string;
|
|
6
10
|
baseBranch: string;
|
|
7
|
-
|
|
8
|
-
action: "create" | "update";
|
|
11
|
+
files: FileAction[];
|
|
9
12
|
workDir: string;
|
|
10
13
|
dryRun?: boolean;
|
|
11
14
|
/** Number of retries for API operations (default: 3) */
|
|
@@ -16,5 +19,12 @@ export interface PRResult {
|
|
|
16
19
|
success: boolean;
|
|
17
20
|
message: string;
|
|
18
21
|
}
|
|
19
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Format PR body for multiple files
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatPRBody(files: FileAction[]): string;
|
|
26
|
+
/**
|
|
27
|
+
* Generate PR title based on files changed (excludes skipped files)
|
|
28
|
+
*/
|
|
29
|
+
export declare function formatPRTitle(files: FileAction[]): string;
|
|
20
30
|
export declare function createPR(options: PROptions): Promise<PRResult>;
|
package/dist/pr-creator.js
CHANGED
|
@@ -12,27 +12,80 @@ function loadPRTemplate() {
|
|
|
12
12
|
if (existsSync(templatePath)) {
|
|
13
13
|
return readFileSync(templatePath, "utf-8");
|
|
14
14
|
}
|
|
15
|
-
// Fallback template
|
|
15
|
+
// Fallback template for multi-file support
|
|
16
16
|
return `## Summary
|
|
17
|
-
Automated sync of
|
|
17
|
+
Automated sync of configuration files.
|
|
18
18
|
|
|
19
19
|
## Changes
|
|
20
|
-
|
|
20
|
+
{{FILE_CHANGES}}
|
|
21
|
+
|
|
22
|
+
## Source
|
|
23
|
+
Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
|
|
21
24
|
|
|
22
25
|
---
|
|
23
|
-
|
|
26
|
+
_This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)_`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Format file changes list, excluding skipped files
|
|
30
|
+
*/
|
|
31
|
+
function formatFileChanges(files) {
|
|
32
|
+
const changedFiles = files.filter((f) => f.action !== "skip");
|
|
33
|
+
return changedFiles
|
|
34
|
+
.map((f) => {
|
|
35
|
+
const actionText = f.action === "create" ? "Created" : "Updated";
|
|
36
|
+
return `- ${actionText} \`${f.fileName}\``;
|
|
37
|
+
})
|
|
38
|
+
.join("\n");
|
|
24
39
|
}
|
|
25
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Format PR body for multiple files
|
|
42
|
+
*/
|
|
43
|
+
export function formatPRBody(files) {
|
|
26
44
|
const template = loadPRTemplate();
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.replace(/\{\{
|
|
45
|
+
const fileChanges = formatFileChanges(files);
|
|
46
|
+
// Check if template supports multi-file format
|
|
47
|
+
if (template.includes("{{FILE_CHANGES}}")) {
|
|
48
|
+
return template.replace(/\{\{FILE_CHANGES\}\}/g, fileChanges);
|
|
49
|
+
}
|
|
50
|
+
// Legacy single-file template - adapt it for multiple files
|
|
51
|
+
const changedFiles = files.filter((f) => f.action !== "skip");
|
|
52
|
+
if (changedFiles.length === 1) {
|
|
53
|
+
const actionText = changedFiles[0].action === "create" ? "Created" : "Updated";
|
|
54
|
+
return template
|
|
55
|
+
.replace(/\{\{FILE_NAME\}\}/g, changedFiles[0].fileName)
|
|
56
|
+
.replace(/\{\{ACTION\}\}/g, actionText);
|
|
57
|
+
}
|
|
58
|
+
// Multiple files with legacy template - generate custom body
|
|
59
|
+
return `## Summary
|
|
60
|
+
Automated sync of configuration files.
|
|
61
|
+
|
|
62
|
+
## Changes
|
|
63
|
+
${fileChanges}
|
|
64
|
+
|
|
65
|
+
## Source
|
|
66
|
+
Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
_This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)_`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Generate PR title based on files changed (excludes skipped files)
|
|
73
|
+
*/
|
|
74
|
+
export function formatPRTitle(files) {
|
|
75
|
+
const changedFiles = files.filter((f) => f.action !== "skip");
|
|
76
|
+
if (changedFiles.length === 1) {
|
|
77
|
+
return `chore: sync ${changedFiles[0].fileName}`;
|
|
78
|
+
}
|
|
79
|
+
if (changedFiles.length <= 3) {
|
|
80
|
+
const fileNames = changedFiles.map((f) => f.fileName).join(", ");
|
|
81
|
+
return `chore: sync ${fileNames}`;
|
|
82
|
+
}
|
|
83
|
+
return `chore: sync ${changedFiles.length} config files`;
|
|
31
84
|
}
|
|
32
85
|
export async function createPR(options) {
|
|
33
|
-
const { repoInfo, branchName, baseBranch,
|
|
34
|
-
const title =
|
|
35
|
-
const body = formatPRBody(
|
|
86
|
+
const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries } = options;
|
|
87
|
+
const title = formatPRTitle(files);
|
|
88
|
+
const body = formatPRBody(files);
|
|
36
89
|
if (dryRun) {
|
|
37
90
|
return {
|
|
38
91
|
success: true,
|
|
@@ -3,7 +3,6 @@ import { RepoInfo } from "./repo-detector.js";
|
|
|
3
3
|
import { GitOps, GitOpsOptions } from "./git-ops.js";
|
|
4
4
|
import { ILogger } from "./logger.js";
|
|
5
5
|
export interface ProcessorOptions {
|
|
6
|
-
fileName: string;
|
|
7
6
|
branchName: string;
|
|
8
7
|
workDir: string;
|
|
9
8
|
dryRun?: boolean;
|
|
@@ -33,4 +32,8 @@ export declare class RepositoryProcessor {
|
|
|
33
32
|
*/
|
|
34
33
|
constructor(gitOpsFactory?: GitOpsFactory, log?: ILogger);
|
|
35
34
|
process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
|
|
35
|
+
/**
|
|
36
|
+
* Format commit message based on files changed (excludes skipped files)
|
|
37
|
+
*/
|
|
38
|
+
private formatCommitMessage;
|
|
36
39
|
}
|
|
@@ -20,7 +20,7 @@ export class RepositoryProcessor {
|
|
|
20
20
|
}
|
|
21
21
|
async process(repoConfig, repoInfo, options) {
|
|
22
22
|
const repoName = getRepoDisplayName(repoInfo);
|
|
23
|
-
const {
|
|
23
|
+
const { branchName, workDir, dryRun, retries } = options;
|
|
24
24
|
this.gitOps = this.gitOpsFactory({ workDir, dryRun, retries });
|
|
25
25
|
try {
|
|
26
26
|
// Step 1: Clean workspace
|
|
@@ -35,30 +35,59 @@ export class RepositoryProcessor {
|
|
|
35
35
|
// Step 4: Create/checkout branch
|
|
36
36
|
this.log.info(`Switching to branch: ${branchName}`);
|
|
37
37
|
await this.gitOps.createBranch(branchName);
|
|
38
|
-
// Step 5: Write config
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
38
|
+
// Step 5: Write all config files and track changes
|
|
39
|
+
const changedFiles = [];
|
|
40
|
+
for (const file of repoConfig.files) {
|
|
41
|
+
const filePath = join(workDir, file.fileName);
|
|
42
|
+
const fileExists = existsSync(filePath);
|
|
43
|
+
// Handle createOnly - skip if file already exists
|
|
44
|
+
if (file.createOnly && fileExists) {
|
|
45
|
+
this.log.info(`Skipping ${file.fileName} (createOnly: already exists)`);
|
|
46
|
+
changedFiles.push({ fileName: file.fileName, action: "skip" });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
this.log.info(`Writing ${file.fileName}...`);
|
|
50
|
+
const fileContent = convertContentToString(file.content, file.fileName);
|
|
51
|
+
// Determine action type (create vs update)
|
|
52
|
+
const action = fileExists ? "update" : "create";
|
|
53
|
+
if (dryRun) {
|
|
54
|
+
// In dry-run, check if file would change without writing
|
|
55
|
+
if (this.gitOps.wouldChange(file.fileName, fileContent)) {
|
|
56
|
+
changedFiles.push({ fileName: file.fileName, action });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// Write the file
|
|
61
|
+
this.gitOps.writeFile(file.fileName, fileContent);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Step 6: Check for changes (exclude skipped files)
|
|
65
|
+
let hasChanges;
|
|
51
66
|
if (dryRun) {
|
|
52
|
-
|
|
53
|
-
wouldHaveChanges = this.gitOps.wouldChange(fileName, fileContent);
|
|
67
|
+
hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
|
|
54
68
|
}
|
|
55
69
|
else {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
hasChanges = await this.gitOps.hasChanges();
|
|
71
|
+
// If there are changes, determine which files changed
|
|
72
|
+
if (hasChanges) {
|
|
73
|
+
// Rebuild the changed files list by checking git status
|
|
74
|
+
// Skip files that were already marked as skipped (createOnly)
|
|
75
|
+
const skippedFiles = new Set(changedFiles
|
|
76
|
+
.filter((f) => f.action === "skip")
|
|
77
|
+
.map((f) => f.fileName));
|
|
78
|
+
for (const file of repoConfig.files) {
|
|
79
|
+
if (skippedFiles.has(file.fileName)) {
|
|
80
|
+
continue; // Already tracked as skipped
|
|
81
|
+
}
|
|
82
|
+
const filePath = join(workDir, file.fileName);
|
|
83
|
+
const action = existsSync(filePath)
|
|
84
|
+
? "update"
|
|
85
|
+
: "create";
|
|
86
|
+
changedFiles.push({ fileName: file.fileName, action });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
60
89
|
}
|
|
61
|
-
if (!
|
|
90
|
+
if (!hasChanges) {
|
|
62
91
|
return {
|
|
63
92
|
success: true,
|
|
64
93
|
repoName,
|
|
@@ -68,7 +97,8 @@ export class RepositoryProcessor {
|
|
|
68
97
|
}
|
|
69
98
|
// Step 7: Commit
|
|
70
99
|
this.log.info("Committing changes...");
|
|
71
|
-
|
|
100
|
+
const commitMessage = this.formatCommitMessage(changedFiles);
|
|
101
|
+
await this.gitOps.commit(commitMessage);
|
|
72
102
|
// Step 8: Push
|
|
73
103
|
this.log.info("Pushing to remote...");
|
|
74
104
|
await this.gitOps.push(branchName);
|
|
@@ -78,8 +108,7 @@ export class RepositoryProcessor {
|
|
|
78
108
|
repoInfo,
|
|
79
109
|
branchName,
|
|
80
110
|
baseBranch,
|
|
81
|
-
|
|
82
|
-
action,
|
|
111
|
+
files: changedFiles,
|
|
83
112
|
workDir,
|
|
84
113
|
dryRun,
|
|
85
114
|
retries,
|
|
@@ -92,7 +121,7 @@ export class RepositoryProcessor {
|
|
|
92
121
|
};
|
|
93
122
|
}
|
|
94
123
|
finally {
|
|
95
|
-
// Always cleanup workspace on completion or failure
|
|
124
|
+
// Always cleanup workspace on completion or failure
|
|
96
125
|
if (this.gitOps) {
|
|
97
126
|
try {
|
|
98
127
|
this.gitOps.cleanWorkspace();
|
|
@@ -103,4 +132,18 @@ export class RepositoryProcessor {
|
|
|
103
132
|
}
|
|
104
133
|
}
|
|
105
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Format commit message based on files changed (excludes skipped files)
|
|
137
|
+
*/
|
|
138
|
+
formatCommitMessage(files) {
|
|
139
|
+
const changedFiles = files.filter((f) => f.action !== "skip");
|
|
140
|
+
if (changedFiles.length === 1) {
|
|
141
|
+
return `chore: sync ${changedFiles[0].fileName}`;
|
|
142
|
+
}
|
|
143
|
+
if (changedFiles.length <= 3) {
|
|
144
|
+
const fileNames = changedFiles.map((f) => f.fileName).join(", ");
|
|
145
|
+
return `chore: sync ${fileNames}`;
|
|
146
|
+
}
|
|
147
|
+
return `chore: sync ${changedFiles.length} config files`;
|
|
148
|
+
}
|
|
106
149
|
}
|
package/package.json
CHANGED