@aspruyt/json-config-sync 2.1.0 → 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,6 +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)
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)
4
4
  [![npm version](https://img.shields.io/npm/v/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
5
5
  [![npm downloads](https://img.shields.io/npm/dw/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
6
6
 
@@ -35,14 +35,13 @@ gh auth login
35
35
 
36
36
  # Create config.yaml
37
37
  cat > config.yaml << 'EOF'
38
- fileName: .prettierrc.json
39
-
40
- # Base configuration inherited by all repos
41
- content:
42
- semi: false
43
- singleQuote: true
44
- tabWidth: 2
45
- trailingComma: es5
38
+ files:
39
+ .prettierrc.json:
40
+ content:
41
+ semi: false
42
+ singleQuote: true
43
+ tabWidth: 2
44
+ trailingComma: es5
46
45
 
47
46
  repos:
48
47
  # Multiple repos can share the same config
@@ -60,6 +59,7 @@ json-config-sync --config ./config.yaml
60
59
 
61
60
  ## Features
62
61
 
62
+ - **Multi-File Sync** - Sync multiple config files in a single run
63
63
  - **JSON/YAML Output** - Automatically outputs JSON or YAML based on filename extension
64
64
  - **Content Inheritance** - Define base config once, override per-repo as needed
65
65
  - **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
@@ -129,61 +129,84 @@ json-config-sync --config ./config.yaml --branch feature/update-eslint
129
129
 
130
130
  ### Options
131
131
 
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-{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 |
139
139
 
140
140
  ## Configuration Format
141
141
 
142
142
  ### Basic Structure
143
143
 
144
144
  ```yaml
145
- fileName: my.config.json # Target file (.json outputs JSON, .yaml/.yml outputs YAML)
146
- mergeStrategy: replace # Default array merge strategy (optional)
147
-
148
- content: # Base config content (optional)
149
- 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
150
150
 
151
151
  repos: # List of repositories
152
152
  - git: git@github.com:org/repo.git
153
- content: # Per-repo overlay (optional if base content exists)
154
- key: override
153
+ files: # Per-repo file overrides (optional)
154
+ my.config.json:
155
+ content:
156
+ key: override
155
157
  ```
156
158
 
157
159
  ### Root-Level Fields
158
160
 
159
- | Field | Description | Required |
160
- | --------------- | ---------------------------------------------------------------------- | -------- |
161
- | `fileName` | Target file name (`.json` JSON output, `.yaml`/`.yml` → YAML output) | Yes |
162
- | `content` | Base config inherited by all repos | No\* |
163
- | `mergeStrategy` | Default array merge strategy: `replace`, `append`, `prepend` | No |
164
- | `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
165
167
 
166
- \* 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 |
167
172
 
168
173
  ### Per-Repo Fields
169
174
 
170
- | Field | Description | Required |
171
- | ---------- | ---------------------------------------------------------- | -------- |
172
- | `git` | Git URL (string) or array of URLs | Yes |
173
- | `content` | Content overlay merged onto base (optional if base exists) | No\* |
174
- | `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:
175
188
 
176
- \* 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
+ ```
177
195
 
178
196
  ### Environment Variables
179
197
 
180
198
  Use `${VAR}` syntax in string values:
181
199
 
182
200
  ```yaml
183
- content:
184
- apiUrl: ${API_URL} # Required - errors if not set
185
- environment: ${ENV:-development} # With default value
186
- 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
187
210
  ```
188
211
 
189
212
  ### Merge Directives
@@ -191,66 +214,106 @@ content:
191
214
  Control array merging with the `$arrayMerge` directive:
192
215
 
193
216
  ```yaml
194
- content:
195
- features:
196
- - core
197
- - monitoring
217
+ files:
218
+ config.json:
219
+ content:
220
+ features:
221
+ - core
222
+ - monitoring
198
223
 
199
224
  repos:
200
225
  - git: git@github.com:org/repo.git
201
- content:
202
- features:
203
- $arrayMerge: append # append | prepend | replace
204
- values:
205
- - 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]
206
233
  ```
207
234
 
208
235
  ## Examples
209
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
+
210
267
  ### Shared Config Across Teams
211
268
 
212
269
  Define common settings once, customize per team:
213
270
 
214
271
  ```yaml
215
- fileName: service.config.json
216
-
217
- content:
218
- version: "2.0"
219
- logging:
220
- level: info
221
- format: json
222
- features:
223
- - health-check
224
- - 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
225
282
 
226
283
  repos:
227
284
  # Platform team repos - add extra features
228
285
  - git:
229
286
  - git@github.com:org/api-gateway.git
230
287
  - git@github.com:org/auth-service.git
231
- content:
232
- team: platform
233
- features:
234
- $arrayMerge: append
235
- values:
236
- - tracing
237
- - 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
238
297
 
239
298
  # Data team repos - different logging
240
299
  - git:
241
300
  - git@github.com:org/data-pipeline.git
242
301
  - git@github.com:org/analytics.git
243
- content:
244
- team: data
245
- logging:
246
- level: debug
302
+ files:
303
+ service.config.json:
304
+ content:
305
+ team: data
306
+ logging:
307
+ level: debug
247
308
 
248
309
  # Legacy service - completely different config
249
310
  - git: git@github.com:org/legacy-api.git
250
- override: true
251
- content:
252
- version: "1.0"
253
- legacy: true
311
+ files:
312
+ service.config.json:
313
+ override: true
314
+ content:
315
+ version: "1.0"
316
+ legacy: true
254
317
  ```
255
318
 
256
319
  ### Environment-Specific Values
@@ -258,40 +321,46 @@ repos:
258
321
  Use environment variables for secrets and environment-specific values:
259
322
 
260
323
  ```yaml
261
- fileName: app.config.json
262
-
263
- content:
264
- database:
265
- host: ${DB_HOST:-localhost}
266
- port: ${DB_PORT:-5432}
267
- 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}
268
331
 
269
- api:
270
- baseUrl: ${API_BASE_URL}
271
- timeout: 30000
332
+ api:
333
+ baseUrl: ${API_BASE_URL}
334
+ timeout: 30000
272
335
 
273
336
  repos:
274
337
  - git: git@github.com:org/backend.git
275
338
  ```
276
339
 
277
- ### Simple Multi-Repo Sync
340
+ ### Per-File Merge Strategies
278
341
 
279
- When all repos need identical config:
342
+ Different files can use different array merge strategies:
280
343
 
281
344
  ```yaml
282
- fileName: .eslintrc.json
345
+ files:
346
+ eslint.config.json:
347
+ mergeStrategy: append # Extends will append
348
+ content:
349
+ extends: ["@company/base"]
283
350
 
284
- content:
285
- extends: ["@org/eslint-config"]
286
- rules:
287
- no-console: warn
351
+ tsconfig.json:
352
+ mergeStrategy: replace # Lib will replace entirely
353
+ content:
354
+ compilerOptions:
355
+ lib: ["ES2022"]
288
356
 
289
357
  repos:
290
- - git:
291
- - git@github.com:org/frontend.git
292
- - git@github.com:org/backend.git
293
- - git@github.com:org/shared-lib.git
294
- - 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"]
295
364
  ```
296
365
 
297
366
  ## Supported Git URL Formats
@@ -311,17 +380,17 @@ repos:
311
380
  ```mermaid
312
381
  flowchart TB
313
382
  subgraph Input
314
- YAML[/"YAML Config File<br/>fileName + content + repos[]"/]
383
+ YAML[/"YAML Config File<br/>files{} + repos[]"/]
315
384
  end
316
385
 
317
386
  subgraph Normalization
318
- EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content]
387
+ EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content<br/>for each file]
319
388
  MERGE --> ENV[Interpolate env vars]
320
389
  end
321
390
 
322
391
  subgraph Processing["For Each Repository"]
323
- CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>--branch or chore/sync-filename]
324
- 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]
325
394
  WRITE --> CHECK{Changes?}
326
395
  CHECK -->|No| SKIP[Skip - No Changes]
327
396
  CHECK -->|Yes| COMMIT[Commit Changes]
@@ -346,12 +415,12 @@ flowchart TB
346
415
  For each repository in the config, the tool:
347
416
 
348
417
  1. Expands git URL arrays into individual entries
349
- 2. Merges base content with per-repo overlay
418
+ 2. For each file, merges base content with per-repo overlay
350
419
  3. Interpolates environment variables
351
420
  4. Cleans the temporary workspace
352
421
  5. Clones the repository
353
- 6. Creates/checks out branch (custom `--branch` or default `chore/sync-{sanitized-filename}`)
354
- 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)
355
424
  8. Checks for changes (skips if no changes)
356
425
  9. Commits and pushes changes
357
426
  10. Creates a pull request
@@ -416,22 +485,25 @@ steps:
416
485
  ```
417
486
  [1/3] Processing example-org/repo1...
418
487
  ✓ Cloned repository
419
- ✓ Created branch chore/sync-my-config
420
- ✓ Wrote my.config.json
488
+ ✓ Created branch chore/sync-config
489
+ ✓ Wrote .eslintrc.json
490
+ ✓ Wrote .prettierrc.yaml
421
491
  ✓ Committed changes
422
492
  ✓ Pushed to remote
423
493
  ✓ Created PR: https://github.com/example-org/repo1/pull/42
424
494
 
425
495
  [2/3] Processing example-org/repo2...
426
496
  ✓ Cloned repository
427
- ✓ Checked out existing branch chore/sync-my-config
428
- ✓ Wrote my.config.json
497
+ ✓ Checked out existing branch chore/sync-config
498
+ ✓ Wrote .eslintrc.json
499
+ ✓ Wrote .prettierrc.yaml
429
500
  ⊘ No changes detected, skipping
430
501
 
431
502
  [3/3] Processing example-org/repo3...
432
503
  ✓ Cloned repository
433
- ✓ Created branch chore/sync-my-config
434
- ✓ Wrote my.config.json
504
+ ✓ Created branch chore/sync-config
505
+ ✓ Wrote .eslintrc.json
506
+ ✓ Wrote .prettierrc.yaml
435
507
  ✓ Committed changes
436
508
  ✓ Pushed to remote
437
509
  ✓ PR already exists: https://github.com/example-org/repo3/pull/15
@@ -443,9 +515,9 @@ Summary: 2 succeeded, 1 skipped, 0 failed
443
515
 
444
516
  The tool creates PRs with:
445
517
 
446
- - **Title:** `chore: sync {fileName}`
447
- - **Branch:** `chore/sync-{sanitized-filename}`
448
- - **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
449
521
 
450
522
  ## Troubleshooting
451
523
 
@@ -484,7 +556,7 @@ The tool automatically reuses existing branches. If you see unexpected behavior:
484
556
 
485
557
  ```bash
486
558
  # Delete the remote branch to start fresh
487
- git push origin --delete chore/sync-my-config
559
+ git push origin --delete chore/sync-config
488
560
  ```
489
561
 
490
562
  ### Missing Environment Variables
@@ -534,9 +606,11 @@ For autocomplete and validation in VS Code, install the [YAML extension](https:/
534
606
 
535
607
  ```yaml
536
608
  # yaml-language-server: $schema=https://raw.githubusercontent.com/anthony-spruyt/json-config-sync/main/config-schema.json
537
- fileName: my.config.json
538
- content:
539
- key: value
609
+ files:
610
+ my.config.json:
611
+ content:
612
+ key: value
613
+
540
614
  repos:
541
615
  - git: git@github.com:org/repo.git
542
616
  ```
@@ -556,7 +630,7 @@ repos:
556
630
 
557
631
  This enables:
558
632
 
559
- - Autocomplete for `fileName`, `mergeStrategy`, `repos`, `content`, `git`, `override`
633
+ - Autocomplete for `files`, `repos`, `content`, `mergeStrategy`, `git`, `override`
560
634
  - Enum suggestions for `mergeStrategy` values (`replace`, `append`, `prepend`)
561
635
  - Validation of required fields
562
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.0",
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",