@aspruyt/xfg 1.3.1 → 1.5.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/PR.md CHANGED
@@ -1,10 +1,10 @@
1
1
  ## Summary
2
2
 
3
- Automated sync of `{{FILE_NAME}}` configuration file.
3
+ Automated sync of configuration files.
4
4
 
5
5
  ## Changes
6
6
 
7
- - {{ACTION}} `{{FILE_NAME}}` in repository root
7
+ {{FILE_CHANGES}}
8
8
 
9
9
  ## Source
10
10
 
package/README.md CHANGED
@@ -5,25 +5,9 @@
5
5
  [![npm downloads](https://img.shields.io/npm/dw/@aspruyt/xfg.svg)](https://www.npmjs.com/package/@aspruyt/xfg)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
 
8
- A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across multiple GitHub, Azure DevOps, and GitLab repositories by creating pull requests (or merge requests for GitLab). Output format is automatically detected from the target filename extension (`.json` → JSON, `.json5` → JSON5, `.yaml`/`.yml` → YAML, other → text).
8
+ A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across multiple GitHub, Azure DevOps, and GitLab repositories by creating pull requests.
9
9
 
10
- ## Table of Contents
11
-
12
- - [Quick Start](#quick-start)
13
- - [Features](#features)
14
- - [How It Works](#how-it-works)
15
- - [Installation](#installation)
16
- - [Prerequisites](#prerequisites)
17
- - [Usage](#usage)
18
- - [Configuration Format](#configuration-format)
19
- - [Examples](#examples)
20
- - [Supported Git URL Formats](#supported-git-url-formats)
21
- - [CI/CD Integration](#cicd-integration)
22
- - [Output Examples](#output-examples)
23
- - [Troubleshooting](#troubleshooting)
24
- - [IDE Integration](#ide-integration)
25
- - [Development](#development)
26
- - [License](#license)
10
+ **[Full Documentation](https://anthony-spruyt.github.io/xfg/)**
27
11
 
28
12
  ## Quick Start
29
13
 
@@ -45,7 +29,6 @@ files:
45
29
  trailingComma: es5
46
30
 
47
31
  repos:
48
- # Multiple repos can share the same config
49
32
  - git:
50
33
  - git@github.com:your-org/frontend-app.git
51
34
  - git@github.com:your-org/backend-api.git
@@ -61,7 +44,7 @@ xfg --config ./config.yaml
61
44
  ## Features
62
45
 
63
46
  - **Multi-File Sync** - Sync multiple config files in a single run
64
- - **Multi-Format Output** - JSON, YAML, or plain text based on filename extension
47
+ - **Multi-Format Output** - JSON, JSON5, YAML, or plain text based on filename extension
65
48
  - **Subdirectory Support** - Sync files to any path (e.g., `.github/workflows/ci.yaml`)
66
49
  - **Text Files** - Sync `.gitignore`, `.markdownlintignore`, etc. with string or lines array
67
50
  - **File References** - Use `@path/to/file` to load content from external template files
@@ -78,958 +61,13 @@ xfg --config ./config.yaml
78
61
  - **Error Resilience** - Continues processing if individual repos fail
79
62
  - **Automatic Retries** - Retries transient network errors with exponential backoff
80
63
 
81
- ## Installation
82
-
83
- ### From npm
84
-
85
- ```bash
86
- npm install -g @aspruyt/xfg
87
- ```
88
-
89
- ### From Source
90
-
91
- ```bash
92
- git clone https://github.com/anthony-spruyt/xfg.git
93
- cd xfg
94
- npm install
95
- npm run build
96
- ```
97
-
98
- ### Using Dev Container
99
-
100
- Open this repository in VS Code with the Dev Containers extension. The container includes all dependencies pre-installed and the project pre-built.
101
-
102
- ## Prerequisites
103
-
104
- ### GitHub Authentication
105
-
106
- Before using with GitHub repositories, authenticate with the GitHub CLI:
107
-
108
- ```bash
109
- gh auth login
110
- ```
111
-
112
- ### Azure DevOps Authentication
113
-
114
- Before using with Azure DevOps repositories, authenticate with the Azure CLI:
115
-
116
- ```bash
117
- az login
118
- az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG project=YOUR_PROJECT
119
- ```
120
-
121
- ### GitLab Authentication
122
-
123
- Before using with GitLab repositories, authenticate with the GitLab CLI:
124
-
125
- ```bash
126
- glab auth login
127
- ```
128
-
129
- For self-hosted GitLab instances:
130
-
131
- ```bash
132
- glab auth login --hostname gitlab.example.com
133
- ```
134
-
135
- ## Usage
136
-
137
- ```bash
138
- # Basic usage
139
- xfg --config ./config.yaml
140
-
141
- # Dry run (no changes made)
142
- xfg --config ./config.yaml --dry-run
143
-
144
- # Custom work directory
145
- xfg --config ./config.yaml --work-dir ./my-temp
146
-
147
- # Custom branch name
148
- xfg --config ./config.yaml --branch feature/update-eslint
149
- ```
150
-
151
- ### Options
152
-
153
- | Option | Alias | Description | Required |
154
- | ------------------ | ----- | ------------------------------------------------------------------------------ | -------- |
155
- | `--config` | `-c` | Path to YAML config file | Yes |
156
- | `--dry-run` | `-d` | Show what would be done without making changes | No |
157
- | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
158
- | `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
159
- | `--branch` | `-b` | Override branch name (default: `chore/sync-{filename}` or `chore/sync-config`) | No |
160
- | `--merge` | `-m` | PR merge mode: `manual`, `auto` (default), `force` (bypass checks) | No |
161
- | `--merge-strategy` | | Merge strategy: `merge`, `squash` (default), `rebase` | No |
162
- | `--delete-branch` | | Delete source branch after merge | No |
163
-
164
- ## Configuration Format
165
-
166
- ### Basic Structure
167
-
168
- ```yaml
169
- files:
170
- my.config.json: # Target file (.json outputs JSON, .yaml/.yml outputs YAML)
171
- mergeStrategy: replace # Array merge strategy for this file (optional)
172
- content: # Base config content
173
- key: value
174
-
175
- repos: # List of repositories
176
- - git: git@github.com:org/repo.git
177
- files: # Per-repo file overrides (optional)
178
- my.config.json:
179
- content:
180
- key: override
181
- ```
182
-
183
- ### Root-Level Fields
184
-
185
- | Field | Description | Required |
186
- | ----------- | ---------------------------------------------------- | -------- |
187
- | `files` | Map of target filenames to configs | Yes |
188
- | `repos` | Array of repository configurations | Yes |
189
- | `prOptions` | Global PR merge options (can be overridden per-repo) | No |
190
-
191
- ### Per-File Fields
192
-
193
- | Field | Description | Required |
194
- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
195
- | `content` | Base config: object for JSON/YAML files, string or string[] for text files, or `@path/to/file` to load from external template (omit for empty file) | No |
196
- | `mergeStrategy` | Merge strategy: `replace`, `append`, `prepend` (for arrays and text lines) | No |
197
- | `createOnly` | If `true`, only create file if it doesn't exist | No |
198
- | `executable` | Mark file as executable. `.sh` files are auto-executable unless set to `false`. Set to `true` for non-.sh files. | No |
199
- | `header` | Comment line(s) at top of YAML files (string or array) | No |
200
- | `schemaUrl` | Adds `# yaml-language-server: $schema=<url>` to YAML files | No |
201
-
202
- ### Per-Repo Fields
203
-
204
- | Field | Description | Required |
205
- | ----------- | -------------------------------------------- | -------- |
206
- | `git` | Git URL (string) or array of URLs | Yes |
207
- | `files` | Per-repo file overrides (optional) | No |
208
- | `prOptions` | Per-repo PR merge options (overrides global) | No |
209
-
210
- ### PR Options Fields
211
-
212
- | Field | Description | Default |
213
- | --------------- | --------------------------------------------------------------------------- | -------- |
214
- | `merge` | Merge mode: `manual` (leave open), `auto` (merge when checks pass), `force` | `auto` |
215
- | `mergeStrategy` | How to merge: `merge`, `squash`, `rebase` | `squash` |
216
- | `deleteBranch` | Delete source branch after merge | `true` |
217
- | `bypassReason` | Reason for bypassing policies (Azure DevOps only, required for `force`) | - |
218
-
219
- ### Per-Repo File Override Fields
220
-
221
- | Field | Description | Required |
222
- | ------------ | ------------------------------------------------------- | -------- |
223
- | `content` | Content overlay merged onto file's base content | No |
224
- | `override` | If `true`, ignore base content and use only this repo's | No |
225
- | `createOnly` | Override root-level `createOnly` for this repo | No |
226
- | `executable` | Override root-level `executable` for this repo | No |
227
- | `header` | Override root-level `header` for this repo | No |
228
- | `schemaUrl` | Override root-level `schemaUrl` for this repo | No |
229
-
230
- **File Exclusion:** Set a file to `false` to exclude it from a specific repo:
231
-
232
- ```yaml
233
- repos:
234
- - git: git@github.com:org/repo.git
235
- files:
236
- eslint.json: false # This repo won't receive eslint.json
237
- ```
238
-
239
- ### Environment Variables
240
-
241
- Use `${VAR}` syntax in string values:
242
-
243
- ```yaml
244
- files:
245
- app.config.json:
246
- content:
247
- apiUrl: ${API_URL} # Required - errors if not set
248
- environment: ${ENV:-development} # With default value
249
- secretKey: ${SECRET:?Secret required} # Required with custom error message
250
-
251
- repos:
252
- - git: git@github.com:org/backend.git
253
- ```
254
-
255
- #### Escaping Variable Syntax
256
-
257
- If your target file needs literal `${VAR}` syntax (e.g., for devcontainer.json, shell scripts, or other templating systems), use `$$` to escape:
258
-
259
- ```yaml
260
- files:
261
- .devcontainer/devcontainer.json:
262
- content:
263
- name: my-dev-container
264
- remoteEnv:
265
- # Escaped - outputs literal ${localWorkspaceFolder} for VS Code
266
- LOCAL_WORKSPACE_FOLDER: "$${localWorkspaceFolder}"
267
- CONTAINER_WORKSPACE: "$${containerWorkspaceFolder}"
268
- # Interpolated - replaced with actual env value
269
- API_KEY: "${API_KEY}"
270
- ```
271
-
272
- Output:
273
-
274
- ```json
275
- {
276
- "name": "my-dev-container",
277
- "remoteEnv": {
278
- "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}",
279
- "CONTAINER_WORKSPACE": "${containerWorkspaceFolder}",
280
- "API_KEY": "actual-api-key-value"
281
- }
282
- }
283
- ```
284
-
285
- This follows the same escape convention used by Docker Compose.
286
-
287
- ### Merge Directives
288
-
289
- Control array merging with the `$arrayMerge` directive:
290
-
291
- ```yaml
292
- files:
293
- config.json:
294
- content:
295
- features:
296
- - core
297
- - monitoring
298
-
299
- repos:
300
- - git: git@github.com:org/repo.git
301
- files:
302
- config.json:
303
- content:
304
- features:
305
- $arrayMerge: append # append | prepend | replace
306
- values:
307
- - custom-feature # Results in: [core, monitoring, custom-feature]
308
- ```
309
-
310
- ## Examples
311
-
312
- ### Multi-File Sync
313
-
314
- Sync multiple configuration files to all repos:
315
-
316
- ```yaml
317
- files:
318
- .eslintrc.json:
319
- content:
320
- extends: ["@org/eslint-config"]
321
- rules:
322
- no-console: warn
323
-
324
- .prettierrc.yaml:
325
- content:
326
- semi: false
327
- singleQuote: true
328
-
329
- tsconfig.json:
330
- content:
331
- compilerOptions:
332
- strict: true
333
- target: ES2022
334
-
335
- repos:
336
- - git:
337
- - git@github.com:org/frontend.git
338
- - git@github.com:org/backend.git
339
- - git@github.com:org/shared-lib.git
340
- ```
341
-
342
- ### Shared Config Across Teams
343
-
344
- Define common settings once, customize per team:
345
-
346
- ```yaml
347
- files:
348
- service.config.json:
349
- content:
350
- version: "2.0"
351
- logging:
352
- level: info
353
- format: json
354
- features:
355
- - health-check
356
- - metrics
357
-
358
- repos:
359
- # Platform team repos - add extra features
360
- - git:
361
- - git@github.com:org/api-gateway.git
362
- - git@github.com:org/auth-service.git
363
- files:
364
- service.config.json:
365
- content:
366
- team: platform
367
- features:
368
- $arrayMerge: append
369
- values:
370
- - tracing
371
- - rate-limiting
372
-
373
- # Data team repos - different logging
374
- - git:
375
- - git@github.com:org/data-pipeline.git
376
- - git@github.com:org/analytics.git
377
- files:
378
- service.config.json:
379
- content:
380
- team: data
381
- logging:
382
- level: debug
383
-
384
- # Legacy service - completely different config
385
- - git: git@github.com:org/legacy-api.git
386
- files:
387
- service.config.json:
388
- override: true
389
- content:
390
- version: "1.0"
391
- legacy: true
392
- ```
393
-
394
- ### Environment-Specific Values
395
-
396
- Use environment variables for secrets and environment-specific values:
397
-
398
- ```yaml
399
- files:
400
- app.config.json:
401
- content:
402
- database:
403
- host: ${DB_HOST:-localhost}
404
- port: ${DB_PORT:-5432}
405
- password: ${DB_PASSWORD:?Database password required}
406
-
407
- api:
408
- baseUrl: ${API_BASE_URL}
409
- timeout: 30000
410
-
411
- repos:
412
- - git: git@github.com:org/backend.git
413
- ```
414
-
415
- ### Per-File Merge Strategies
416
-
417
- Different files can use different array merge strategies:
418
-
419
- ```yaml
420
- files:
421
- eslint.config.json:
422
- mergeStrategy: append # Extends will append
423
- content:
424
- extends: ["@company/base"]
425
-
426
- tsconfig.json:
427
- mergeStrategy: replace # Lib will replace entirely
428
- content:
429
- compilerOptions:
430
- lib: ["ES2022"]
431
-
432
- repos:
433
- - git: git@github.com:org/frontend.git
434
- files:
435
- eslint.config.json:
436
- content:
437
- extends: ["plugin:react/recommended"]
438
- # Results in extends: ["@company/base", "plugin:react/recommended"]
439
- ```
440
-
441
- ### Create-Only Files (Defaults That Can Be Customized)
442
-
443
- Some files should only be created once as defaults, allowing repos to maintain their own versions:
444
-
445
- ```yaml
446
- files:
447
- .trivyignore.yaml:
448
- createOnly: true # Only create if doesn't exist
449
- content:
450
- vulnerabilities: []
451
-
452
- .prettierignore:
453
- createOnly: true
454
- content:
455
- patterns:
456
- - "dist/"
457
- - "node_modules/"
458
-
459
- eslint.config.json:
460
- content: # Always synced (no createOnly)
461
- extends: ["@company/base"]
462
-
463
- repos:
464
- - git: git@github.com:org/repo.git
465
- # .trivyignore.yaml and .prettierignore only created if missing
466
- # eslint.config.json always updated
467
-
468
- - git: git@github.com:org/special-repo.git
469
- files:
470
- .trivyignore.yaml:
471
- createOnly: false # Override: always sync this file
472
- ```
473
-
474
- ### YAML Comments and Empty Files
475
-
476
- Add schema directives and comments to YAML files, or create empty files:
477
-
478
- ```yaml
479
- files:
480
- # YAML file with schema directive and header comment
481
- trivy.yaml:
482
- schemaUrl: https://trivy.dev/latest/docs/references/configuration/config-file/
483
- header: "Trivy security scanner configuration"
484
- content:
485
- exit-code: 1
486
- scan:
487
- scanners:
488
- - vuln
489
-
490
- # Empty file (content omitted)
491
- .prettierignore:
492
- createOnly: true
493
- # No content = empty file
494
-
495
- # YAML with multi-line header
496
- config.yaml:
497
- header:
498
- - "Auto-generated configuration"
499
- - "Do not edit manually"
500
- content:
501
- version: 1
502
-
503
- repos:
504
- - git: git@github.com:org/repo.git
505
- ```
506
-
507
- **Output for trivy.yaml:**
508
-
509
- ```yaml
510
- # yaml-language-server: $schema=https://trivy.dev/latest/docs/references/configuration/config-file/
511
- # Trivy security scanner configuration
512
- exit-code: 1
513
- scan:
514
- scanners:
515
- - vuln
516
- ```
517
-
518
- **Note:** `header` and `schemaUrl` only apply to YAML output files (`.yaml`, `.yml`). They are ignored for JSON files.
519
-
520
- ### Text Files
521
-
522
- Sync text files like `.gitignore`, `.markdownlintignore`, or `.env.example` using string or lines array content:
523
-
524
- ```yaml
525
- files:
526
- # String content (multiline text)
527
- .markdownlintignore:
528
- createOnly: true
529
- content: |-
530
- # Claude Code generated files
531
- .claude/
532
-
533
- # Lines array with merge strategy
534
- .gitignore:
535
- mergeStrategy: append
536
- content:
537
- - "node_modules/"
538
- - "dist/"
539
-
540
- repos:
541
- - git: git@github.com:org/repo.git
542
- files:
543
- .gitignore:
544
- content:
545
- - "coverage/" # Appended to base lines
546
- ```
64
+ ## Documentation
547
65
 
548
- **Content Types:**
66
+ Visit **[anthony-spruyt.github.io/xfg](https://anthony-spruyt.github.io/xfg/)** for:
549
67
 
550
- - **String content** (`content: |-`) - Direct text output with environment variable interpolation. Merging always replaces the base.
551
- - **Lines array** (`content: ['line1', 'line2']`) - Each line joined with newlines. Supports merge strategies (`append`, `prepend`, `replace`).
552
-
553
- **Validation:** JSON/JSON5/YAML file extensions (`.json`, `.json5`, `.yaml`, `.yml`) require object content. Other extensions require string or string[] content.
554
-
555
- ### Executable Files
556
-
557
- Shell scripts (`.sh` files) are automatically marked as executable using `git update-index --add --chmod=+x`. You can control this behavior:
558
-
559
- ```yaml
560
- files:
561
- # .sh files are auto-executable (no config needed)
562
- deploy.sh:
563
- content: |-
564
- #!/bin/bash
565
- echo "Deploying..."
566
-
567
- # Disable auto-executable for a specific .sh file
568
- template.sh:
569
- executable: false
570
- content: "# This is just a template"
571
-
572
- # Make a non-.sh file executable
573
- run:
574
- executable: true
575
- content: |-
576
- #!/usr/bin/env python3
577
- print("Hello")
578
-
579
- repos:
580
- - git: git@github.com:org/repo.git
581
- files:
582
- # Override executable per-repo
583
- deploy.sh:
584
- executable: false # Disable for this repo
585
- ```
586
-
587
- **Behavior:**
588
-
589
- - `.sh` files: Automatically executable unless `executable: false`
590
- - Other files: Not executable unless `executable: true`
591
- - Per-repo settings override root-level settings
592
-
593
- ### Subdirectory Paths
594
-
595
- Sync files to any subdirectory path - parent directories are created automatically:
596
-
597
- ```yaml
598
- files:
599
- # GitHub Actions workflow
600
- ".github/workflows/ci.yaml":
601
- content:
602
- name: CI
603
- on: [push, pull_request]
604
- jobs:
605
- build:
606
- runs-on: ubuntu-latest
607
- steps:
608
- - uses: actions/checkout@v4
609
-
610
- # Nested config directory
611
- "config/settings/app.json":
612
- content:
613
- environment: production
614
- debug: false
615
-
616
- repos:
617
- - git:
618
- - git@github.com:org/frontend.git
619
- - git@github.com:org/backend.git
620
- ```
621
-
622
- **Note:** Quote file paths containing `/` in YAML keys. Parent directories are created if they don't exist.
623
-
624
- ### File References
625
-
626
- Instead of inline content, you can reference external template files using the `@path/to/file` syntax:
627
-
628
- ```yaml
629
- files:
630
- .prettierrc.json:
631
- content: "@templates/prettierrc.json"
632
- .eslintrc.yaml:
633
- content: "@templates/eslintrc.yaml"
634
- header: "Auto-generated - do not edit"
635
- schemaUrl: "https://json.schemastore.org/eslintrc"
636
- .gitignore:
637
- content: "@templates/gitignore.txt"
638
-
639
- repos:
640
- - git: git@github.com:org/repo.git
641
- ```
642
-
643
- **How it works:**
644
-
645
- - File references start with `@` followed by a relative path
646
- - Paths are resolved relative to the config file's directory
647
- - JSON/JSON5/YAML files are parsed as objects, other files as strings
648
- - Metadata fields (`header`, `schemaUrl`, `createOnly`, `mergeStrategy`) remain in the config
649
- - Per-repo overlays still work - they merge onto the resolved file content
650
-
651
- **Example directory structure:**
652
-
653
- ```
654
- config/
655
- sync-config.yaml # content: "@templates/prettier.json"
656
- templates/
657
- prettier.json # Actual Prettier config
658
- eslintrc.yaml # Actual ESLint config
659
- gitignore.txt # Template .gitignore content
660
- ```
661
-
662
- **Security:** File references are restricted to the config file's directory tree. Paths like `@../../../etc/passwd` or `@/etc/passwd` are blocked.
663
-
664
- ### Auto-Merge PRs
665
-
666
- By default, xfg enables auto-merge on PRs when checks pass. You can override this behavior per-repo or via CLI flags:
667
-
668
- ```yaml
669
- files:
670
- .prettierrc.json:
671
- content:
672
- semi: false
673
- singleQuote: true
674
-
675
- # Default behavior (auto-merge with squash, delete branch) - no prOptions needed
676
- repos:
677
- # These repos use defaults (auto-merge with squash, delete branch)
678
- - git:
679
- - git@github.com:org/frontend.git
680
- - git@github.com:org/backend.git
681
-
682
- # This repo overrides to manual (leave PR open for review)
683
- - git: git@github.com:org/needs-review.git
684
- prOptions:
685
- merge: manual
686
-
687
- # This repo overrides to force merge (bypass required reviews)
688
- - git: git@github.com:org/internal-tool.git
689
- prOptions:
690
- merge: force
691
- bypassReason: "Automated config sync" # Azure DevOps only
692
- ```
693
-
694
- **Merge Modes:**
695
-
696
- | Mode | GitHub Behavior | Azure DevOps Behavior | GitLab Behavior |
697
- | -------- | ---------------------------------------------------- | -------------------------------------- | ------------------------------------------ |
698
- | `manual` | Leave PR open for review | Leave PR open for review | Leave MR open for review |
699
- | `auto` | Enable auto-merge (requires repo setup, **default**) | Enable auto-complete (**default**) | Merge when pipeline succeeds (**default**) |
700
- | `force` | Merge with `--admin` (bypass checks) | Bypass policies with `--bypass-policy` | Merge immediately |
701
-
702
- **GitHub Auto-Merge Note:** The `auto` mode requires auto-merge to be enabled in the repository settings. If not enabled, the tool will warn and leave the PR open for manual review. Enable it with:
703
-
704
- ```bash
705
- gh repo edit org/repo --enable-auto-merge
706
- ```
707
-
708
- **CLI Override:** You can override config file settings with CLI flags:
709
-
710
- ```bash
711
- # Disable auto-merge, leave PRs open for review
712
- xfg --config ./config.yaml --merge manual
713
-
714
- # Force merge all PRs (useful for urgent updates)
715
- xfg --config ./config.yaml --merge force
716
- ```
717
-
718
- ## Supported Git URL Formats
719
-
720
- ### GitHub
721
-
722
- - SSH: `git@github.com:owner/repo.git`
723
- - HTTPS: `https://github.com/owner/repo.git`
724
-
725
- ### Azure DevOps
726
-
727
- - SSH: `git@ssh.dev.azure.com:v3/organization/project/repo`
728
- - HTTPS: `https://dev.azure.com/organization/project/_git/repo`
729
-
730
- ### GitLab
731
-
732
- - SSH: `git@gitlab.com:owner/repo.git`
733
- - HTTPS: `https://gitlab.com/owner/repo.git`
734
- - Nested groups: `git@gitlab.com:org/group/subgroup/repo.git`
735
- - Self-hosted SSH: `git@gitlab.example.com:owner/repo.git`
736
- - Self-hosted HTTPS: `https://gitlab.example.com/owner/repo.git`
737
-
738
- ## How It Works
739
-
740
- ```mermaid
741
- flowchart TB
742
- subgraph Input
743
- YAML[/"YAML Config File<br/>files{} + repos[]"/]
744
- end
745
-
746
- subgraph Normalization
747
- EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content<br/>for each file]
748
- MERGE --> ENV[Interpolate env vars]
749
- end
750
-
751
- subgraph Processing["For Each Repository"]
752
- CLONE[Clone Repo] --> DETECT_BRANCH[Detect Default Branch]
753
- DETECT_BRANCH --> CLOSE_PR[Close Existing PR<br/>if exists]
754
- CLOSE_PR --> BRANCH[Create Fresh Branch]
755
- BRANCH --> WRITE[Write Config Files]
756
- WRITE --> CHECK{Changes?}
757
- CHECK -->|No| SKIP[Skip - No Changes]
758
- CHECK -->|Yes| COMMIT[Commit & Push]
759
- end
760
-
761
- subgraph Platform["PR Creation"]
762
- COMMIT --> PR_DETECT{Platform?}
763
- PR_DETECT -->|GitHub| GH_PR[Create PR via gh CLI]
764
- PR_DETECT -->|Azure DevOps| AZ_PR[Create PR via az CLI]
765
- PR_DETECT -->|GitLab| GL_PR[Create MR via glab CLI]
766
- GH_PR --> PR_CREATED[PR/MR Created]
767
- AZ_PR --> PR_CREATED
768
- GL_PR --> PR_CREATED
769
- end
770
-
771
- subgraph AutoMerge["Auto-Merge (default)"]
772
- PR_CREATED --> MERGE_MODE{Merge Mode?}
773
- MERGE_MODE -->|manual| OPEN[Leave PR Open]
774
- MERGE_MODE -->|auto| AUTO[Enable Auto-Merge]
775
- MERGE_MODE -->|force| FORCE[Bypass & Merge]
776
- end
777
-
778
- YAML --> EXPAND
779
- ENV --> CLONE
780
- ```
781
-
782
- For each repository in the config, the tool:
783
-
784
- 1. Expands git URL arrays into individual entries
785
- 2. For each file, merges base content with per-repo overlay
786
- 3. Interpolates environment variables
787
- 4. Cleans the temporary workspace
788
- 5. Clones the repository
789
- 6. Detects the default branch (main/master)
790
- 7. Closes any existing PR on the branch and deletes the remote branch (fresh start)
791
- 8. Creates a fresh branch from the default branch
792
- 9. Writes all config files (JSON, JSON5, YAML, or text based on filename extension)
793
- 10. Checks for changes (skips if no changes)
794
- 11. Commits and pushes changes
795
- 12. Creates a pull request
796
- 13. Handles auto-merge based on configuration (auto by default)
797
-
798
- ## CI/CD Integration
799
-
800
- ### GitHub Actions
801
-
802
- ```yaml
803
- name: Sync Configs
804
- on:
805
- push:
806
- branches: [main]
807
- paths: ["config.yaml"]
808
-
809
- jobs:
810
- sync:
811
- runs-on: ubuntu-latest
812
- steps:
813
- - uses: actions/checkout@v4
814
- - uses: actions/setup-node@v4
815
- with:
816
- node-version: "20"
817
- - run: npm install -g @aspruyt/xfg
818
- - run: xfg --config ./config.yaml
819
- env:
820
- GH_TOKEN: ${{ secrets.GH_PAT }}
821
- ```
822
-
823
- > **Note:** `GH_PAT` must be a Personal Access Token with `repo` scope to create PRs in target repositories.
824
-
825
- ### Azure Pipelines
826
-
827
- ```yaml
828
- trigger:
829
- branches:
830
- include: [main]
831
- paths:
832
- include: ["config.yaml"]
833
-
834
- pool:
835
- vmImage: "ubuntu-latest"
836
-
837
- steps:
838
- - task: NodeTool@0
839
- inputs:
840
- versionSpec: "20.x"
841
- - script: npm install -g @aspruyt/xfg
842
- displayName: "Install xfg"
843
- - script: xfg --config ./config.yaml
844
- displayName: "Sync configs"
845
- env:
846
- AZURE_DEVOPS_EXT_PAT: $(System.AccessToken)
847
- ```
848
-
849
- > **Note:** Ensure the build service account has permission to create PRs in target repositories.
850
-
851
- ## Output Examples
852
-
853
- ### Console Output
854
-
855
- ```
856
- [1/3] Processing example-org/repo1...
857
- ✓ Cloned repository
858
- ✓ Created branch chore/sync-config
859
- ✓ Wrote .eslintrc.json
860
- ✓ Wrote .prettierrc.yaml
861
- ✓ Committed changes
862
- ✓ Pushed to remote
863
- ✓ Created PR: https://github.com/example-org/repo1/pull/42
864
-
865
- [2/3] Processing example-org/repo2...
866
- ✓ Cloned repository
867
- ✓ Checked out existing branch chore/sync-config
868
- ✓ Wrote .eslintrc.json
869
- ✓ Wrote .prettierrc.yaml
870
- ⊘ No changes detected, skipping
871
-
872
- [3/3] Processing example-org/repo3...
873
- ✓ Cloned repository
874
- ✓ Created branch chore/sync-config
875
- ✓ Wrote .eslintrc.json
876
- ✓ Wrote .prettierrc.yaml
877
- ✓ Committed changes
878
- ✓ Pushed to remote
879
- ✓ PR already exists: https://github.com/example-org/repo3/pull/15
880
-
881
- Summary: 2 succeeded, 1 skipped, 0 failed
882
- ```
883
-
884
- ### Created PR
885
-
886
- The tool creates PRs with:
887
-
888
- - **Title:** `chore: sync config files` (or lists files if ≤3)
889
- - **Branch:** `chore/sync-config` (or custom `--branch`)
890
- - **Body:** Describes the sync action and lists changed files
891
-
892
- ## Troubleshooting
893
-
894
- ### Authentication Errors
895
-
896
- **GitHub:**
897
-
898
- ```bash
899
- # Check authentication status
900
- gh auth status
901
-
902
- # Re-authenticate if needed
903
- gh auth login
904
- ```
905
-
906
- **Azure DevOps:**
907
-
908
- ```bash
909
- # Check authentication status
910
- az account show
911
-
912
- # Re-authenticate if needed
913
- az login
914
- az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG
915
- ```
916
-
917
- **GitLab:**
918
-
919
- ```bash
920
- # Check authentication status
921
- glab auth status
922
-
923
- # Re-authenticate if needed
924
- glab auth login
925
-
926
- # For self-hosted instances
927
- glab auth login --hostname gitlab.example.com
928
- ```
929
-
930
- ### Permission Denied
931
-
932
- - Ensure your token has write access to the target repositories
933
- - For GitHub, the token needs `repo` scope
934
- - For Azure DevOps, ensure the user/service account has "Contribute to pull requests" permission
935
- - For GitLab, ensure the user has at least "Developer" role on the project
936
-
937
- ### Branch Already Exists
938
-
939
- The tool uses a fresh-start approach: it closes any existing PR on the branch and deletes the branch before creating a new one. This ensures each sync attempt is isolated and avoids merge conflicts from stale branches.
940
-
941
- If you see issues with stale branches:
942
-
943
- ```bash
944
- # Manually delete the remote branch if needed
945
- git push origin --delete chore/sync-config
946
- ```
947
-
948
- ### Missing Environment Variables
949
-
950
- If you see "Missing required environment variable" errors:
951
-
952
- ```bash
953
- # Set the variable before running
954
- export MY_VAR=value
955
- xfg --config ./config.yaml
956
-
957
- # Or use default values in config
958
- # ${MY_VAR:-default-value}
959
- ```
960
-
961
- ### Network/Proxy Issues
962
-
963
- If cloning fails behind a corporate proxy:
964
-
965
- ```bash
966
- # Configure git proxy
967
- git config --global http.proxy http://proxy.example.com:8080
968
- git config --global https.proxy http://proxy.example.com:8080
969
- ```
970
-
971
- ### Transient Network Errors
972
-
973
- The tool automatically retries transient errors (timeouts, connection resets, rate limits) with exponential backoff. By default, it retries 3 times before failing.
974
-
975
- ```bash
976
- # Increase retries for unreliable networks
977
- xfg --config ./config.yaml --retries 5
978
-
979
- # Disable retries
980
- xfg --config ./config.yaml --retries 0
981
- ```
982
-
983
- Permanent errors (authentication failures, permission denied, repository not found) are not retried.
984
-
985
- ## IDE Integration
986
-
987
- ### VS Code YAML Schema Support
988
-
989
- For autocomplete and validation in VS Code, install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) and add a schema reference to your config file:
990
-
991
- **Option 1: Inline comment**
992
-
993
- ```yaml
994
- # yaml-language-server: $schema=https://raw.githubusercontent.com/anthony-spruyt/xfg/main/config-schema.json
995
- files:
996
- my.config.json:
997
- content:
998
- key: value
999
-
1000
- repos:
1001
- - git: git@github.com:org/repo.git
1002
- ```
1003
-
1004
- **Option 2: VS Code settings** (`.vscode/settings.json`)
1005
-
1006
- ```json
1007
- {
1008
- "yaml.schemas": {
1009
- "https://raw.githubusercontent.com/anthony-spruyt/xfg/main/config-schema.json": [
1010
- "**/sync-config.yaml",
1011
- "**/config-sync.yaml"
1012
- ]
1013
- }
1014
- }
1015
- ```
1016
-
1017
- This enables:
1018
-
1019
- - Autocomplete for `files`, `repos`, `content`, `mergeStrategy`, `git`, `override`
1020
- - Enum suggestions for `mergeStrategy` values (`replace`, `append`, `prepend`)
1021
- - Validation of required fields
1022
- - Hover documentation for each field
1023
-
1024
- ## Development
1025
-
1026
- ```bash
1027
- # Run in development mode
1028
- npm run dev -- --config ./fixtures/test-repos-input.yaml --dry-run
1029
-
1030
- # Run tests
1031
- npm test
1032
-
1033
- # Build
1034
- npm run build
1035
- ```
68
+ - [Getting Started](https://anthony-spruyt.github.io/xfg/getting-started/) - Installation and prerequisites
69
+ - [Configuration](https://anthony-spruyt.github.io/xfg/configuration/) - Full configuration reference
70
+ - [Examples](https://anthony-spruyt.github.io/xfg/examples/) - Real-world usage examples
71
+ - [Platforms](https://anthony-spruyt.github.io/xfg/platforms/) - GitHub, Azure DevOps, GitLab setup
72
+ - [CI/CD Integration](https://anthony-spruyt.github.io/xfg/ci-cd/) - GitHub Actions, Azure Pipelines
73
+ - [Troubleshooting](https://anthony-spruyt.github.io/xfg/troubleshooting/)
@@ -135,5 +135,6 @@ export function normalizeConfig(raw) {
135
135
  }
136
136
  return {
137
137
  repos: expandedRepos,
138
+ prTemplate: raw.prTemplate,
138
139
  };
139
140
  }
@@ -108,7 +108,8 @@ export function validateRawConfig(config) {
108
108
  continue;
109
109
  }
110
110
  if (fileOverride.override && !fileOverride.content) {
111
- throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined`);
111
+ throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined. ` +
112
+ `Use content: "" for an empty text file override, or content: {} for an empty JSON/YAML override.`);
112
113
  }
113
114
  // Validate content type
114
115
  if (fileOverride.content !== undefined) {
package/dist/config.d.ts CHANGED
@@ -34,6 +34,7 @@ export interface RawConfig {
34
34
  files: Record<string, RawFileConfig>;
35
35
  repos: RawRepoConfig[];
36
36
  prOptions?: PRMergeOptions;
37
+ prTemplate?: string;
37
38
  }
38
39
  export interface FileContent {
39
40
  fileName: string;
@@ -50,5 +51,6 @@ export interface RepoConfig {
50
51
  }
51
52
  export interface Config {
52
53
  repos: RepoConfig[];
54
+ prTemplate?: string;
53
55
  }
54
56
  export declare function loadConfig(filePath: string): Config;
@@ -99,6 +99,14 @@ export function resolveFileReferencesInConfig(raw, options) {
99
99
  const { configDir } = options;
100
100
  // Deep clone to avoid mutating input
101
101
  const result = JSON.parse(JSON.stringify(raw));
102
+ // Resolve prTemplate file reference
103
+ if (result.prTemplate && isFileReference(result.prTemplate)) {
104
+ const resolved = resolveFileReference(result.prTemplate, configDir);
105
+ if (typeof resolved !== "string") {
106
+ throw new Error(`prTemplate file reference "${result.prTemplate}" must resolve to a text file, not JSON/YAML`);
107
+ }
108
+ result.prTemplate = resolved;
109
+ }
102
110
  // Resolve root-level file content
103
111
  if (result.files) {
104
112
  for (const [fileName, fileConfig] of Object.entries(result.files)) {
package/dist/index.js CHANGED
@@ -131,6 +131,7 @@ async function main() {
131
131
  workDir,
132
132
  dryRun: options.dryRun,
133
133
  retries: options.retries,
134
+ prTemplate: config.prTemplate,
134
135
  });
135
136
  if (result.skipped) {
136
137
  logger.skip(current, repoName, result.message);
@@ -14,6 +14,8 @@ export interface PROptions {
14
14
  dryRun?: boolean;
15
15
  /** Number of retries for API operations (default: 3) */
16
16
  retries?: number;
17
+ /** Custom PR body template */
18
+ prTemplate?: string;
17
19
  }
18
20
  export interface PRResult {
19
21
  url?: string;
@@ -21,9 +23,9 @@ export interface PRResult {
21
23
  message: string;
22
24
  }
23
25
  /**
24
- * Format PR body for multiple files
26
+ * Format PR body using template with {{FILE_CHANGES}} placeholder
25
27
  */
26
- export declare function formatPRBody(files: FileAction[]): string;
28
+ export declare function formatPRBody(files: FileAction[], customTemplate?: string): string;
27
29
  /**
28
30
  * Generate PR title based on files changed (excludes skipped files)
29
31
  */
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { getPRStrategy, } from "./strategies/index.js";
5
5
  // Re-export for backwards compatibility and testing
6
6
  export { escapeShellArg } from "./shell-utils.js";
7
- function loadPRTemplate() {
7
+ function loadDefaultTemplate() {
8
8
  // Try to find PR.md in the project root
9
9
  const __filename = fileURLToPath(import.meta.url);
10
10
  const __dirname = dirname(__filename);
@@ -12,17 +12,21 @@ function loadPRTemplate() {
12
12
  if (existsSync(templatePath)) {
13
13
  return readFileSync(templatePath, "utf-8");
14
14
  }
15
- // Fallback template for multi-file support
15
+ // Fallback template
16
16
  return `## Summary
17
+
17
18
  Automated sync of configuration files.
18
19
 
19
20
  ## Changes
21
+
20
22
  {{FILE_CHANGES}}
21
23
 
22
24
  ## Source
25
+
23
26
  Configuration synced using [xfg](https://github.com/anthony-spruyt/xfg).
24
27
 
25
28
  ---
29
+
26
30
  _This PR was automatically generated by [xfg](https://github.com/anthony-spruyt/xfg)_`;
27
31
  }
28
32
  /**
@@ -38,35 +42,12 @@ function formatFileChanges(files) {
38
42
  .join("\n");
39
43
  }
40
44
  /**
41
- * Format PR body for multiple files
45
+ * Format PR body using template with {{FILE_CHANGES}} placeholder
42
46
  */
43
- export function formatPRBody(files) {
44
- const template = loadPRTemplate();
47
+ export function formatPRBody(files, customTemplate) {
48
+ const template = customTemplate ?? loadDefaultTemplate();
45
49
  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 [xfg](https://github.com/anthony-spruyt/xfg).
67
-
68
- ---
69
- _This PR was automatically generated by [xfg](https://github.com/anthony-spruyt/xfg)_`;
50
+ return template.replace(/\{\{FILE_CHANGES\}\}/g, fileChanges);
70
51
  }
71
52
  /**
72
53
  * Generate PR title based on files changed (excludes skipped files)
@@ -83,9 +64,9 @@ export function formatPRTitle(files) {
83
64
  return `chore: sync ${changedFiles.length} config files`;
84
65
  }
85
66
  export async function createPR(options) {
86
- const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries } = options;
67
+ const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, } = options;
87
68
  const title = formatPRTitle(files);
88
- const body = formatPRBody(files);
69
+ const body = formatPRBody(files, prTemplate);
89
70
  if (dryRun) {
90
71
  return {
91
72
  success: true,
@@ -11,6 +11,8 @@ export interface ProcessorOptions {
11
11
  retries?: number;
12
12
  /** Command executor for shell commands (for testing) */
13
13
  executor?: CommandExecutor;
14
+ /** Custom PR body template */
15
+ prTemplate?: string;
14
16
  }
15
17
  /**
16
18
  * Factory function type for creating GitOps instances.
@@ -37,7 +37,7 @@ export class RepositoryProcessor {
37
37
  }
38
38
  async process(repoConfig, repoInfo, options) {
39
39
  const repoName = getRepoDisplayName(repoInfo);
40
- const { branchName, workDir, dryRun, retries } = options;
40
+ const { branchName, workDir, dryRun, retries, prTemplate } = options;
41
41
  const executor = options.executor ?? defaultExecutor;
42
42
  this.gitOps = this.gitOpsFactory({ workDir, dryRun, retries });
43
43
  try {
@@ -207,6 +207,7 @@ export class RepositoryProcessor {
207
207
  workDir,
208
208
  dryRun,
209
209
  retries,
210
+ prTemplate,
210
211
  });
211
212
  // Step 10: Handle merge options if configured
212
213
  const mergeMode = repoConfig.prOptions?.merge ?? "auto";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "1.3.1",
4
- "description": "CLI tool to sync JSON or YAML configuration files across multiple GitHub, Azure DevOps, and GitLab repositories",
3
+ "version": "1.5.0",
4
+ "description": "CLI tool to sync JSON, JSON5, YAML, or text configuration files across multiple Git repositories by creating pull requests",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "type": "git",
16
16
  "url": "git+https://github.com/anthony-spruyt/xfg.git"
17
17
  },
18
- "homepage": "https://github.com/anthony-spruyt/xfg#readme",
18
+ "homepage": "https://anthony-spruyt.github.io/xfg/",
19
19
  "bugs": {
20
20
  "url": "https://github.com/anthony-spruyt/xfg/issues"
21
21
  },
@@ -36,7 +36,9 @@
36
36
  "config",
37
37
  "sync",
38
38
  "json",
39
+ "json5",
39
40
  "yaml",
41
+ "text",
40
42
  "git",
41
43
  "cli",
42
44
  "github",