@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 CHANGED
@@ -1,7 +1,6 @@
1
1
  # json-config-sync
2
2
 
3
- [![CI](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
4
- [![Integration Test](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration-test.yml/badge.svg?branch=main)](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration-test.yml)
3
+ [![CI](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
5
4
  [![npm version](https://img.shields.io/npm/v/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
6
5
  [![npm downloads](https://img.shields.io/npm/dw/@aspruyt/json-config-sync.svg)](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
- fileName: .prettierrc.json
40
-
41
- # Base configuration inherited by all repos
42
- content:
43
- semi: false
44
- singleQuote: true
45
- tabWidth: 2
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 | Required |
134
- | ------------ | ----- | ------------------------------------------------------- | -------- |
135
- | `--config` | `-c` | Path to YAML config file | Yes |
136
- | `--dry-run` | `-d` | Show what would be done without making changes | No |
137
- | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
138
- | `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
139
- | `--branch` | `-b` | Override branch name (default: `chore/sync-{filename}`) | No |
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
- fileName: my.config.json # Target file (.json outputs JSON, .yaml/.yml outputs YAML)
147
- mergeStrategy: replace # Default array merge strategy (optional)
148
-
149
- content: # Base config content (optional)
150
- key: value
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
- content: # Per-repo overlay (optional if base content exists)
155
- key: override
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 | Description | Required |
161
- | --------------- | ---------------------------------------------------------------------- | -------- |
162
- | `fileName` | Target file name (`.json` JSON output, `.yaml`/`.yml` → YAML output) | Yes |
163
- | `content` | Base config inherited by all repos | No\* |
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
- \* Required if any repo entry omits the `content` field.
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 | Description | Required |
172
- | ---------- | ---------------------------------------------------------- | -------- |
173
- | `git` | Git URL (string) or array of URLs | Yes |
174
- | `content` | Content overlay merged onto base (optional if base exists) | No\* |
175
- | `override` | If `true`, ignore base content and use only this repo's | No |
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
- \* Required if no root-level `content` is defined.
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
- content:
185
- apiUrl: ${API_URL} # Required - errors if not set
186
- environment: ${ENV:-development} # With default value
187
- secretKey: ${SECRET:?Secret required} # Required with custom error message
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
- content:
196
- features:
197
- - core
198
- - monitoring
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
- content:
203
- features:
204
- $arrayMerge: append # append | prepend | replace
205
- values:
206
- - custom-feature # Results in: [core, monitoring, custom-feature]
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
- fileName: service.config.json
217
-
218
- content:
219
- version: "2.0"
220
- logging:
221
- level: info
222
- format: json
223
- features:
224
- - health-check
225
- - metrics
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
- content:
233
- team: platform
234
- features:
235
- $arrayMerge: append
236
- values:
237
- - tracing
238
- - rate-limiting
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
- content:
245
- team: data
246
- logging:
247
- level: debug
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
- override: true
252
- content:
253
- version: "1.0"
254
- legacy: true
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
- fileName: app.config.json
263
-
264
- content:
265
- database:
266
- host: ${DB_HOST:-localhost}
267
- port: ${DB_PORT:-5432}
268
- password: ${DB_PASSWORD:?Database password required}
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
- api:
271
- baseUrl: ${API_BASE_URL}
272
- timeout: 30000
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
- ### Simple Multi-Repo Sync
342
+ ### Per-File Merge Strategies
279
343
 
280
- When all repos need identical config:
344
+ Different files can use different array merge strategies:
281
345
 
282
346
  ```yaml
283
- fileName: .eslintrc.json
347
+ files:
348
+ eslint.config.json:
349
+ mergeStrategy: append # Extends will append
350
+ content:
351
+ extends: ["@company/base"]
284
352
 
285
- content:
286
- extends: ["@org/eslint-config"]
287
- rules:
288
- no-console: warn
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
- - git@github.com:org/frontend.git
293
- - git@github.com:org/backend.git
294
- - git@github.com:org/shared-lib.git
295
- - git@github.com:org/cli-tool.git
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/>fileName + content + repos[]"/]
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-filename]
325
- BRANCH --> WRITE[Write Config File<br/>JSON or YAML]
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. Merges base content with per-repo overlay
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-{sanitized-filename}`)
355
- 7. Generates the config file (JSON or YAML based on filename extension)
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-my-config
421
- ✓ Wrote my.config.json
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-my-config
429
- ✓ Wrote my.config.json
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-my-config
435
- ✓ Wrote my.config.json
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 {fileName}`
448
- - **Branch:** `chore/sync-{sanitized-filename}`
449
- - **Body:** Describes the sync action and links to documentation
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-my-config
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
- fileName: my.config.json
539
- content:
540
- key: value
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 `fileName`, `mergeStrategy`, `repos`, `content`, `git`, `override`
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
- // Step 2: Compute merged content
16
- let mergedContent;
17
- if (rawRepo.override) {
18
- // Override mode: use only repo content
19
- mergedContent = stripMergeDirectives(structuredClone(rawRepo.content));
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
- content: mergedContent,
53
+ files,
36
54
  });
37
55
  }
38
56
  }
39
57
  return {
40
- fileName: raw.fileName,
41
58
  repos: expandedRepos,
42
59
  };
43
60
  }
@@ -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.fileName) {
8
- throw new Error("Config missing required field: fileName");
8
+ if (!config.files || typeof config.files !== "object") {
9
+ throw new Error("Config missing required field: files (must be an object)");
9
10
  }
10
- // Validate fileName doesn't allow path traversal
11
- if (config.fileName.includes("..") || isAbsolute(config.fileName)) {
12
- throw new Error(`Invalid fileName: must be a relative path without '..' components`);
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 fileName doesn't contain control characters that could bypass shell escaping
15
- if (/[\n\r\0]/.test(config.fileName)) {
16
- throw new Error(`Invalid fileName: cannot contain newlines or null bytes`);
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
- const validStrategies = ["replace", "append", "prepend"];
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
- if (!hasRootContent && !repo.content) {
42
- throw new Error(`Repo at index ${i} missing required field: content (no root-level content defined)`);
43
- }
44
- if (repo.override && !repo.content) {
45
- throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true but no content defined`);
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 RawRepoConfig {
4
- git: string | string[];
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
- fileName: string;
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
- content: Record<string, unknown>;
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 = `chore/sync-${sanitizeBranchName(config.fileName)}`;
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 file: ${config.fileName}`);
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,
@@ -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
- fileName: string;
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
- export declare function formatPRBody(fileName: string, action: "create" | "update"): string;
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>;
@@ -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 \`{{FILE_NAME}}\` configuration file.
17
+ Automated sync of configuration files.
18
18
 
19
19
  ## Changes
20
- - {{ACTION}} \`{{FILE_NAME}}\` in repository root
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
- *This PR was automatically generated by json-config-sync*`;
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
- export function formatPRBody(fileName, action) {
40
+ /**
41
+ * Format PR body for multiple files
42
+ */
43
+ export function formatPRBody(files) {
26
44
  const template = loadPRTemplate();
27
- const actionText = action === "create" ? "Created" : "Updated";
28
- return template
29
- .replace(/\{\{FILE_NAME\}\}/g, fileName)
30
- .replace(/\{\{ACTION\}\}/g, actionText);
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, fileName, action, workDir, dryRun, retries, } = options;
34
- const title = `chore: sync ${fileName}`;
35
- const body = formatPRBody(fileName, action);
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 { fileName, branchName, workDir, dryRun, retries } = options;
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 file
39
- this.log.info(`Writing ${fileName}...`);
40
- const fileContent = convertContentToString(repoConfig.content, fileName);
41
- // Step 6: Check for changes and determine action
42
- // NOTE: This is NOT a race condition. We intentionally:
43
- // 1. Capture action type (create/update) BEFORE writing - for PR title
44
- // 2. Check git status AFTER writing - to detect actual content changes
45
- // The action type is cosmetic for the PR; hasChanges() determines whether to proceed.
46
- // If file exists with identical content: action="update", hasChanges=false -> skip (correct)
47
- // If file doesn't exist: action="create", hasChanges=true -> proceed (correct)
48
- const filePath = join(workDir, fileName);
49
- let action;
50
- let wouldHaveChanges;
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
- action = existsSync(filePath) ? "update" : "create";
53
- wouldHaveChanges = this.gitOps.wouldChange(fileName, fileContent);
67
+ hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
54
68
  }
55
69
  else {
56
- // Capture action and write atomically (in same sync block)
57
- action = existsSync(filePath) ? "update" : "create";
58
- this.gitOps.writeFile(fileName, fileContent);
59
- wouldHaveChanges = await this.gitOps.hasChanges();
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 (!wouldHaveChanges) {
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
- await this.gitOps.commit(`chore: sync ${fileName}`);
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
- fileName,
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 (Improvement 3)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "2.1.1",
3
+ "version": "3.1.0",
4
4
  "description": "CLI tool to sync JSON or YAML configuration files across multiple GitHub and Azure DevOps repositories",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",