@aspruyt/json-config-sync 2.1.1 → 3.0.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,84 @@ 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 |
165
+
166
+ ### Per-File Fields
166
167
 
167
- \* Required if any repo entry omits the `content` field.
168
+ | Field | Description | Required |
169
+ | --------------- | ---------------------------------------------------- | -------- |
170
+ | `content` | Base config inherited by all repos | Yes |
171
+ | `mergeStrategy` | Array merge strategy: `replace`, `append`, `prepend` | No |
168
172
 
169
173
  ### Per-Repo Fields
170
174
 
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 |
175
+ | Field | Description | Required |
176
+ | ------- | ---------------------------------- | -------- |
177
+ | `git` | Git URL (string) or array of URLs | Yes |
178
+ | `files` | Per-repo file overrides (optional) | No |
179
+
180
+ ### Per-Repo File Override Fields
181
+
182
+ | Field | Description | Required |
183
+ | ---------- | ------------------------------------------------------- | -------- |
184
+ | `content` | Content overlay merged onto file's base content | No |
185
+ | `override` | If `true`, ignore base content and use only this repo's | No |
186
+
187
+ **File Exclusion:** Set a file to `false` to exclude it from a specific repo:
176
188
 
177
- \* Required if no root-level `content` is defined.
189
+ ```yaml
190
+ repos:
191
+ - git: git@github.com:org/repo.git
192
+ files:
193
+ eslint.json: false # This repo won't receive eslint.json
194
+ ```
178
195
 
179
196
  ### Environment Variables
180
197
 
181
198
  Use `${VAR}` syntax in string values:
182
199
 
183
200
  ```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
201
+ files:
202
+ app.config.json:
203
+ content:
204
+ apiUrl: ${API_URL} # Required - errors if not set
205
+ environment: ${ENV:-development} # With default value
206
+ secretKey: ${SECRET:?Secret required} # Required with custom error message
207
+
208
+ repos:
209
+ - git: git@github.com:org/backend.git
188
210
  ```
189
211
 
190
212
  ### Merge Directives
@@ -192,66 +214,106 @@ content:
192
214
  Control array merging with the `$arrayMerge` directive:
193
215
 
194
216
  ```yaml
195
- content:
196
- features:
197
- - core
198
- - monitoring
217
+ files:
218
+ config.json:
219
+ content:
220
+ features:
221
+ - core
222
+ - monitoring
199
223
 
200
224
  repos:
201
225
  - 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]
226
+ files:
227
+ config.json:
228
+ content:
229
+ features:
230
+ $arrayMerge: append # append | prepend | replace
231
+ values:
232
+ - custom-feature # Results in: [core, monitoring, custom-feature]
207
233
  ```
208
234
 
209
235
  ## Examples
210
236
 
237
+ ### Multi-File Sync
238
+
239
+ Sync multiple configuration files to all repos:
240
+
241
+ ```yaml
242
+ files:
243
+ .eslintrc.json:
244
+ content:
245
+ extends: ["@org/eslint-config"]
246
+ rules:
247
+ no-console: warn
248
+
249
+ .prettierrc.yaml:
250
+ content:
251
+ semi: false
252
+ singleQuote: true
253
+
254
+ tsconfig.json:
255
+ content:
256
+ compilerOptions:
257
+ strict: true
258
+ target: ES2022
259
+
260
+ repos:
261
+ - git:
262
+ - git@github.com:org/frontend.git
263
+ - git@github.com:org/backend.git
264
+ - git@github.com:org/shared-lib.git
265
+ ```
266
+
211
267
  ### Shared Config Across Teams
212
268
 
213
269
  Define common settings once, customize per team:
214
270
 
215
271
  ```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
272
+ files:
273
+ service.config.json:
274
+ content:
275
+ version: "2.0"
276
+ logging:
277
+ level: info
278
+ format: json
279
+ features:
280
+ - health-check
281
+ - metrics
226
282
 
227
283
  repos:
228
284
  # Platform team repos - add extra features
229
285
  - git:
230
286
  - git@github.com:org/api-gateway.git
231
287
  - git@github.com:org/auth-service.git
232
- content:
233
- team: platform
234
- features:
235
- $arrayMerge: append
236
- values:
237
- - tracing
238
- - rate-limiting
288
+ files:
289
+ service.config.json:
290
+ content:
291
+ team: platform
292
+ features:
293
+ $arrayMerge: append
294
+ values:
295
+ - tracing
296
+ - rate-limiting
239
297
 
240
298
  # Data team repos - different logging
241
299
  - git:
242
300
  - git@github.com:org/data-pipeline.git
243
301
  - git@github.com:org/analytics.git
244
- content:
245
- team: data
246
- logging:
247
- level: debug
302
+ files:
303
+ service.config.json:
304
+ content:
305
+ team: data
306
+ logging:
307
+ level: debug
248
308
 
249
309
  # Legacy service - completely different config
250
310
  - git: git@github.com:org/legacy-api.git
251
- override: true
252
- content:
253
- version: "1.0"
254
- legacy: true
311
+ files:
312
+ service.config.json:
313
+ override: true
314
+ content:
315
+ version: "1.0"
316
+ legacy: true
255
317
  ```
256
318
 
257
319
  ### Environment-Specific Values
@@ -259,40 +321,46 @@ repos:
259
321
  Use environment variables for secrets and environment-specific values:
260
322
 
261
323
  ```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}
324
+ files:
325
+ app.config.json:
326
+ content:
327
+ database:
328
+ host: ${DB_HOST:-localhost}
329
+ port: ${DB_PORT:-5432}
330
+ password: ${DB_PASSWORD:?Database password required}
269
331
 
270
- api:
271
- baseUrl: ${API_BASE_URL}
272
- timeout: 30000
332
+ api:
333
+ baseUrl: ${API_BASE_URL}
334
+ timeout: 30000
273
335
 
274
336
  repos:
275
337
  - git: git@github.com:org/backend.git
276
338
  ```
277
339
 
278
- ### Simple Multi-Repo Sync
340
+ ### Per-File Merge Strategies
279
341
 
280
- When all repos need identical config:
342
+ Different files can use different array merge strategies:
281
343
 
282
344
  ```yaml
283
- fileName: .eslintrc.json
345
+ files:
346
+ eslint.config.json:
347
+ mergeStrategy: append # Extends will append
348
+ content:
349
+ extends: ["@company/base"]
284
350
 
285
- content:
286
- extends: ["@org/eslint-config"]
287
- rules:
288
- no-console: warn
351
+ tsconfig.json:
352
+ mergeStrategy: replace # Lib will replace entirely
353
+ content:
354
+ compilerOptions:
355
+ lib: ["ES2022"]
289
356
 
290
357
  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
358
+ - git: git@github.com:org/frontend.git
359
+ files:
360
+ eslint.config.json:
361
+ content:
362
+ extends: ["plugin:react/recommended"]
363
+ # Results in extends: ["@company/base", "plugin:react/recommended"]
296
364
  ```
297
365
 
298
366
  ## Supported Git URL Formats
@@ -312,17 +380,17 @@ repos:
312
380
  ```mermaid
313
381
  flowchart TB
314
382
  subgraph Input
315
- YAML[/"YAML Config File<br/>fileName + content + repos[]"/]
383
+ YAML[/"YAML Config File<br/>files{} + repos[]"/]
316
384
  end
317
385
 
318
386
  subgraph Normalization
319
- EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content]
387
+ EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content<br/>for each file]
320
388
  MERGE --> ENV[Interpolate env vars]
321
389
  end
322
390
 
323
391
  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]
392
+ CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>--branch or chore/sync-config]
393
+ BRANCH --> WRITE[Write All Config Files<br/>JSON or YAML]
326
394
  WRITE --> CHECK{Changes?}
327
395
  CHECK -->|No| SKIP[Skip - No Changes]
328
396
  CHECK -->|Yes| COMMIT[Commit Changes]
@@ -347,12 +415,12 @@ flowchart TB
347
415
  For each repository in the config, the tool:
348
416
 
349
417
  1. Expands git URL arrays into individual entries
350
- 2. Merges base content with per-repo overlay
418
+ 2. For each file, merges base content with per-repo overlay
351
419
  3. Interpolates environment variables
352
420
  4. Cleans the temporary workspace
353
421
  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)
422
+ 6. Creates/checks out branch (custom `--branch` or default `chore/sync-config`)
423
+ 7. Writes all config files (JSON or YAML based on filename extension)
356
424
  8. Checks for changes (skips if no changes)
357
425
  9. Commits and pushes changes
358
426
  10. Creates a pull request
@@ -417,22 +485,25 @@ steps:
417
485
  ```
418
486
  [1/3] Processing example-org/repo1...
419
487
  ✓ Cloned repository
420
- ✓ Created branch chore/sync-my-config
421
- ✓ Wrote my.config.json
488
+ ✓ Created branch chore/sync-config
489
+ ✓ Wrote .eslintrc.json
490
+ ✓ Wrote .prettierrc.yaml
422
491
  ✓ Committed changes
423
492
  ✓ Pushed to remote
424
493
  ✓ Created PR: https://github.com/example-org/repo1/pull/42
425
494
 
426
495
  [2/3] Processing example-org/repo2...
427
496
  ✓ Cloned repository
428
- ✓ Checked out existing branch chore/sync-my-config
429
- ✓ Wrote my.config.json
497
+ ✓ Checked out existing branch chore/sync-config
498
+ ✓ Wrote .eslintrc.json
499
+ ✓ Wrote .prettierrc.yaml
430
500
  ⊘ No changes detected, skipping
431
501
 
432
502
  [3/3] Processing example-org/repo3...
433
503
  ✓ Cloned repository
434
- ✓ Created branch chore/sync-my-config
435
- ✓ Wrote my.config.json
504
+ ✓ Created branch chore/sync-config
505
+ ✓ Wrote .eslintrc.json
506
+ ✓ Wrote .prettierrc.yaml
436
507
  ✓ Committed changes
437
508
  ✓ Pushed to remote
438
509
  ✓ PR already exists: https://github.com/example-org/repo3/pull/15
@@ -444,9 +515,9 @@ Summary: 2 succeeded, 1 skipped, 0 failed
444
515
 
445
516
  The tool creates PRs with:
446
517
 
447
- - **Title:** `chore: sync {fileName}`
448
- - **Branch:** `chore/sync-{sanitized-filename}`
449
- - **Body:** Describes the sync action and links to documentation
518
+ - **Title:** `chore: sync config files` (or lists files if ≤3)
519
+ - **Branch:** `chore/sync-config` (or custom `--branch`)
520
+ - **Body:** Describes the sync action and lists changed files
450
521
 
451
522
  ## Troubleshooting
452
523
 
@@ -485,7 +556,7 @@ The tool automatically reuses existing branches. If you see unexpected behavior:
485
556
 
486
557
  ```bash
487
558
  # Delete the remote branch to start fresh
488
- git push origin --delete chore/sync-my-config
559
+ git push origin --delete chore/sync-config
489
560
  ```
490
561
 
491
562
  ### Missing Environment Variables
@@ -535,9 +606,11 @@ For autocomplete and validation in VS Code, install the [YAML extension](https:/
535
606
 
536
607
  ```yaml
537
608
  # 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
609
+ files:
610
+ my.config.json:
611
+ content:
612
+ key: value
613
+
541
614
  repos:
542
615
  - git: git@github.com:org/repo.git
543
616
  ```
@@ -557,7 +630,7 @@ repos:
557
630
 
558
631
  This enables:
559
632
 
560
- - Autocomplete for `fileName`, `mergeStrategy`, `repos`, `content`, `git`, `override`
633
+ - Autocomplete for `files`, `repos`, `content`, `mergeStrategy`, `git`, `override`
561
634
  - Enum suggestions for `mergeStrategy` values (`replace`, `append`, `prepend`)
562
635
  - Validation of required fields
563
636
  - Hover documentation for each field
@@ -5,39 +5,53 @@ 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
+ files.push({
44
+ fileName,
45
+ content: mergedContent,
46
+ });
20
47
  }
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
48
  expandedRepos.push({
34
49
  git: gitUrl,
35
- content: mergedContent,
50
+ files,
36
51
  });
37
52
  }
38
53
  }
39
54
  return {
40
- fileName: raw.fileName,
41
55
  repos: expandedRepos,
42
56
  };
43
57
  }
@@ -1,35 +1,39 @@
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
+ }
17
32
  }
18
33
  if (!config.repos || !Array.isArray(config.repos)) {
19
34
  throw new Error("Config missing required field: repos (must be an array)");
20
35
  }
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;
36
+ // Validate each repo
33
37
  for (let i = 0; i < config.repos.length; i++) {
34
38
  const repo = config.repos[i];
35
39
  if (!repo.git) {
@@ -38,14 +42,50 @@ export function validateRawConfig(config) {
38
42
  if (Array.isArray(repo.git) && repo.git.length === 0) {
39
43
  throw new Error(`Repo at index ${i} has empty git array`);
40
44
  }
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`);
45
+ // Validate per-repo file overrides
46
+ if (repo.files) {
47
+ if (typeof repo.files !== "object" || Array.isArray(repo.files)) {
48
+ throw new Error(`Repo at index ${i}: files must be an object`);
49
+ }
50
+ for (const fileName of Object.keys(repo.files)) {
51
+ // Ensure the file is defined at root level
52
+ if (!config.files[fileName]) {
53
+ throw new Error(`Repo at index ${i} references undefined file '${fileName}'. File must be defined in root 'files' object.`);
54
+ }
55
+ const fileOverride = repo.files[fileName];
56
+ // false means exclude this file for this repo - no further validation needed
57
+ if (fileOverride === false) {
58
+ continue;
59
+ }
60
+ if (fileOverride.override && !fileOverride.content) {
61
+ throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined`);
62
+ }
63
+ if (fileOverride.content !== undefined &&
64
+ (typeof fileOverride.content !== "object" ||
65
+ fileOverride.content === null ||
66
+ Array.isArray(fileOverride.content))) {
67
+ throw new Error(`Repo at index ${i}: file '${fileName}' content must be an object`);
68
+ }
69
+ }
46
70
  }
47
71
  }
48
72
  }
73
+ /**
74
+ * Validates a file name for security issues
75
+ */
76
+ function validateFileName(fileName) {
77
+ if (!fileName || typeof fileName !== "string") {
78
+ throw new Error("File name must be a non-empty string");
79
+ }
80
+ // Validate fileName doesn't allow path traversal
81
+ if (fileName.includes("..") || isAbsolute(fileName)) {
82
+ throw new Error(`Invalid fileName '${fileName}': must be a relative path without '..' components`);
83
+ }
84
+ // Validate fileName doesn't contain control characters that could bypass shell escaping
85
+ if (/[\n\r\0]/.test(fileName)) {
86
+ throw new Error(`Invalid fileName '${fileName}': cannot contain newlines or null bytes`);
87
+ }
88
+ }
49
89
  function getGitDisplayName(git) {
50
90
  if (Array.isArray(git)) {
51
91
  return git[0] || "unknown";
package/dist/config.d.ts CHANGED
@@ -1,22 +1,30 @@
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
+ }
7
+ export interface RawRepoFileOverride {
5
8
  content?: Record<string, unknown>;
6
9
  override?: boolean;
7
10
  }
11
+ export interface RawRepoConfig {
12
+ git: string | string[];
13
+ files?: Record<string, RawRepoFileOverride | false>;
14
+ }
8
15
  export interface RawConfig {
9
- fileName: string;
10
- content?: Record<string, unknown>;
11
- mergeStrategy?: ArrayMergeStrategy;
16
+ files: Record<string, RawFileConfig>;
12
17
  repos: RawRepoConfig[];
13
18
  }
19
+ export interface FileContent {
20
+ fileName: string;
21
+ content: Record<string, unknown>;
22
+ }
14
23
  export interface RepoConfig {
15
24
  git: string;
16
- content: Record<string, unknown>;
25
+ files: FileContent[];
17
26
  }
18
27
  export interface Config {
19
- fileName: string;
20
28
  repos: RepoConfig[];
21
29
  }
22
30
  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";
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
28
+ */
29
+ export declare function formatPRTitle(files: FileAction[]): string;
20
30
  export declare function createPR(options: PROptions): Promise<PRResult>;
@@ -12,27 +12,78 @@ 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)_`;
24
27
  }
25
- export function formatPRBody(fileName, action) {
28
+ /**
29
+ * Format PR body for multiple files
30
+ */
31
+ export function formatPRBody(files) {
26
32
  const template = loadPRTemplate();
27
- const actionText = action === "create" ? "Created" : "Updated";
28
- return template
29
- .replace(/\{\{FILE_NAME\}\}/g, fileName)
30
- .replace(/\{\{ACTION\}\}/g, actionText);
33
+ // Check if template supports multi-file format
34
+ if (template.includes("{{FILE_CHANGES}}")) {
35
+ // Multi-file template
36
+ const fileChanges = files
37
+ .map((f) => {
38
+ const actionText = f.action === "create" ? "Created" : "Updated";
39
+ return `- ${actionText} \`${f.fileName}\``;
40
+ })
41
+ .join("\n");
42
+ return template.replace(/\{\{FILE_CHANGES\}\}/g, fileChanges);
43
+ }
44
+ // Legacy single-file template - adapt it for multiple files
45
+ if (files.length === 1) {
46
+ const actionText = files[0].action === "create" ? "Created" : "Updated";
47
+ return template
48
+ .replace(/\{\{FILE_NAME\}\}/g, files[0].fileName)
49
+ .replace(/\{\{ACTION\}\}/g, actionText);
50
+ }
51
+ // Multiple files with legacy template - generate custom body
52
+ const fileChanges = files
53
+ .map((f) => {
54
+ const actionText = f.action === "create" ? "Created" : "Updated";
55
+ return `- ${actionText} \`${f.fileName}\``;
56
+ })
57
+ .join("\n");
58
+ return `## Summary
59
+ Automated sync of configuration files.
60
+
61
+ ## Changes
62
+ ${fileChanges}
63
+
64
+ ## Source
65
+ Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
66
+
67
+ ---
68
+ _This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)_`;
69
+ }
70
+ /**
71
+ * Generate PR title based on files changed
72
+ */
73
+ export function formatPRTitle(files) {
74
+ if (files.length === 1) {
75
+ return `chore: sync ${files[0].fileName}`;
76
+ }
77
+ if (files.length <= 3) {
78
+ const fileNames = files.map((f) => f.fileName).join(", ");
79
+ return `chore: sync ${fileNames}`;
80
+ }
81
+ return `chore: sync ${files.length} config files`;
31
82
  }
32
83
  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);
84
+ const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries } = options;
85
+ const title = formatPRTitle(files);
86
+ const body = formatPRBody(files);
36
87
  if (dryRun) {
37
88
  return {
38
89
  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
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,52 @@ 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
+ this.log.info(`Writing ${file.fileName}...`);
42
+ const fileContent = convertContentToString(file.content, file.fileName);
43
+ const filePath = join(workDir, file.fileName);
44
+ // Determine action type (create vs update)
45
+ const action = existsSync(filePath)
46
+ ? "update"
47
+ : "create";
48
+ if (dryRun) {
49
+ // In dry-run, check if file would change without writing
50
+ if (this.gitOps.wouldChange(file.fileName, fileContent)) {
51
+ changedFiles.push({ fileName: file.fileName, action });
52
+ }
53
+ }
54
+ else {
55
+ // Write the file
56
+ this.gitOps.writeFile(file.fileName, fileContent);
57
+ }
58
+ }
59
+ // Step 6: Check for changes
60
+ let hasChanges;
51
61
  if (dryRun) {
52
- action = existsSync(filePath) ? "update" : "create";
53
- wouldHaveChanges = this.gitOps.wouldChange(fileName, fileContent);
62
+ hasChanges = changedFiles.length > 0;
54
63
  }
55
64
  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();
65
+ hasChanges = await this.gitOps.hasChanges();
66
+ // If there are changes, determine which files changed
67
+ if (hasChanges) {
68
+ // Rebuild the changed files list by checking git status
69
+ // For simplicity, we include all files with their detected actions
70
+ for (const file of repoConfig.files) {
71
+ const filePath = join(workDir, file.fileName);
72
+ // We check if file existed before writing (action was determined above)
73
+ // Since we don't have pre-write state, we'll mark all files that are in the commit
74
+ // A more accurate approach would track this before writing, but for now
75
+ // we'll assume all files are being synced and include them all
76
+ const action = existsSync(filePath)
77
+ ? "update"
78
+ : "create";
79
+ changedFiles.push({ fileName: file.fileName, action });
80
+ }
81
+ }
60
82
  }
61
- if (!wouldHaveChanges) {
83
+ if (!hasChanges) {
62
84
  return {
63
85
  success: true,
64
86
  repoName,
@@ -68,7 +90,8 @@ export class RepositoryProcessor {
68
90
  }
69
91
  // Step 7: Commit
70
92
  this.log.info("Committing changes...");
71
- await this.gitOps.commit(`chore: sync ${fileName}`);
93
+ const commitMessage = this.formatCommitMessage(changedFiles);
94
+ await this.gitOps.commit(commitMessage);
72
95
  // Step 8: Push
73
96
  this.log.info("Pushing to remote...");
74
97
  await this.gitOps.push(branchName);
@@ -78,8 +101,7 @@ export class RepositoryProcessor {
78
101
  repoInfo,
79
102
  branchName,
80
103
  baseBranch,
81
- fileName,
82
- action,
104
+ files: changedFiles,
83
105
  workDir,
84
106
  dryRun,
85
107
  retries,
@@ -92,7 +114,7 @@ export class RepositoryProcessor {
92
114
  };
93
115
  }
94
116
  finally {
95
- // Always cleanup workspace on completion or failure (Improvement 3)
117
+ // Always cleanup workspace on completion or failure
96
118
  if (this.gitOps) {
97
119
  try {
98
120
  this.gitOps.cleanWorkspace();
@@ -103,4 +125,17 @@ export class RepositoryProcessor {
103
125
  }
104
126
  }
105
127
  }
128
+ /**
129
+ * Format commit message based on files changed
130
+ */
131
+ formatCommitMessage(files) {
132
+ if (files.length === 1) {
133
+ return `chore: sync ${files[0].fileName}`;
134
+ }
135
+ if (files.length <= 3) {
136
+ const fileNames = files.map((f) => f.fileName).join(", ");
137
+ return `chore: sync ${fileNames}`;
138
+ }
139
+ return `chore: sync ${files.length} config files`;
140
+ }
106
141
  }
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.0.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",