@aspruyt/json-config-sync 1.0.0 → 2.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
@@ -5,16 +5,70 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
6
6
  [![npm downloads](https://img.shields.io/npm/dw/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
7
7
 
8
- A CLI tool that syncs JSON configuration files across multiple Git repositories by creating pull requests.
8
+ A CLI tool that syncs JSON or YAML configuration files across multiple GitHub and Azure DevOps repositories by creating pull requests. Output format is automatically detected from the target filename extension.
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
+ - [Development](#development)
25
+ - [License](#license)
26
+
27
+ ## Quick Start
28
+
29
+ ```bash
30
+ # Install
31
+ npm install -g @aspruyt/json-config-sync
32
+
33
+ # Authenticate (GitHub)
34
+ gh auth login
35
+
36
+ # Create config.yaml
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
46
+
47
+ repos:
48
+ # Multiple repos can share the same config
49
+ - git:
50
+ - git@github.com:your-org/frontend-app.git
51
+ - git@github.com:your-org/backend-api.git
52
+ - git@github.com:your-org/shared-lib.git
53
+ EOF
54
+
55
+ # Run
56
+ json-config-sync --config ./config.yaml
57
+ ```
58
+
59
+ **Result:** PRs are created in all three repos with identical `.prettierrc.json` files.
9
60
 
10
61
  ## Features
11
62
 
12
- - Reads configuration from a YAML file
13
- - Supports both GitHub and Azure DevOps repositories
14
- - Creates PRs automatically using `gh` CLI (GitHub) or `az` CLI (Azure DevOps)
15
- - Continues processing if individual repos fail
16
- - Supports dry-run mode for testing
17
- - Progress logging with summary report
63
+ - **JSON/YAML Output** - Automatically outputs JSON or YAML based on filename extension
64
+ - **Content Inheritance** - Define base config once, override per-repo as needed
65
+ - **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
66
+ - **Environment Variables** - Use `${VAR}` syntax for dynamic values
67
+ - **Merge Strategies** - Control how arrays merge (replace, append, prepend)
68
+ - **Override Mode** - Skip merging entirely for specific repos
69
+ - **GitHub & Azure DevOps** - Works with both platforms
70
+ - **Dry-Run Mode** - Preview changes without creating PRs
71
+ - **Error Resilience** - Continues processing if individual repos fail
18
72
 
19
73
  ## Installation
20
74
 
@@ -71,88 +125,384 @@ json-config-sync --config ./config.yaml --work-dir ./my-temp
71
125
 
72
126
  ### Options
73
127
 
74
- | Option | Alias | Description | Required |
75
- |--------|-------|-------------|----------|
76
- | `--config` | `-c` | Path to YAML config file | Yes |
77
- | `--dry-run` | `-d` | Show what would be done without making changes | No |
78
- | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
128
+ | Option | Alias | Description | Required |
129
+ | ------------ | ----- | -------------------------------------------------- | -------- |
130
+ | `--config` | `-c` | Path to YAML config file | Yes |
131
+ | `--dry-run` | `-d` | Show what would be done without making changes | No |
132
+ | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
79
133
 
80
134
  ## Configuration Format
81
135
 
82
- Create a YAML file with the following structure:
136
+ ### Basic Structure
137
+
138
+ ```yaml
139
+ fileName: my.config.json # Target file (.json outputs JSON, .yaml/.yml outputs YAML)
140
+ mergeStrategy: replace # Default array merge strategy (optional)
141
+
142
+ content: # Base config content (optional)
143
+ key: value
144
+
145
+ repos: # List of repositories
146
+ - git: git@github.com:org/repo.git
147
+ content: # Per-repo overlay (optional if base content exists)
148
+ key: override
149
+ ```
150
+
151
+ ### Root-Level Fields
152
+
153
+ | Field | Description | Required |
154
+ | --------------- | --------------------------------------------------------------------- | -------- |
155
+ | `fileName` | Target file name (`.json` → JSON output, `.yaml`/`.yml` → YAML output)| Yes |
156
+ | `content` | Base config inherited by all repos | No* |
157
+ | `mergeStrategy` | Default array merge strategy: `replace`, `append`, `prepend` | No |
158
+ | `repos` | Array of repository configurations | Yes |
159
+
160
+ \* Required if any repo entry omits the `content` field.
161
+
162
+ ### Per-Repo Fields
163
+
164
+ | Field | Description | Required |
165
+ | ---------- | --------------------------------------------------------- | -------- |
166
+ | `git` | Git URL (string) or array of URLs | Yes |
167
+ | `content` | Content overlay merged onto base (optional if base exists)| No* |
168
+ | `override` | If `true`, ignore base content and use only this repo's | No |
169
+
170
+ \* Required if no root-level `content` is defined.
171
+
172
+ ### Environment Variables
173
+
174
+ Use `${VAR}` syntax in string values:
175
+
176
+ ```yaml
177
+ content:
178
+ apiUrl: ${API_URL} # Required - errors if not set
179
+ environment: ${ENV:-development} # With default value
180
+ secretKey: ${SECRET:?Secret required} # Required with custom error message
181
+ ```
182
+
183
+ ### Merge Directives
184
+
185
+ Control array merging with the `$arrayMerge` directive:
186
+
187
+ ```yaml
188
+ content:
189
+ features:
190
+ - core
191
+ - monitoring
192
+
193
+ repos:
194
+ - git: git@github.com:org/repo.git
195
+ content:
196
+ features:
197
+ $arrayMerge: append # append | prepend | replace
198
+ values:
199
+ - custom-feature # Results in: [core, monitoring, custom-feature]
200
+ ```
201
+
202
+ ## Examples
203
+
204
+ ### Shared Config Across Teams
205
+
206
+ Define common settings once, customize per team:
207
+
208
+ ```yaml
209
+ fileName: service.config.json
210
+
211
+ content:
212
+ version: "2.0"
213
+ logging:
214
+ level: info
215
+ format: json
216
+ features:
217
+ - health-check
218
+ - metrics
219
+
220
+ repos:
221
+ # Platform team repos - add extra features
222
+ - git:
223
+ - git@github.com:org/api-gateway.git
224
+ - git@github.com:org/auth-service.git
225
+ content:
226
+ team: platform
227
+ features:
228
+ $arrayMerge: append
229
+ values:
230
+ - tracing
231
+ - rate-limiting
232
+
233
+ # Data team repos - different logging
234
+ - git:
235
+ - git@github.com:org/data-pipeline.git
236
+ - git@github.com:org/analytics.git
237
+ content:
238
+ team: data
239
+ logging:
240
+ level: debug
241
+
242
+ # Legacy service - completely different config
243
+ - git: git@github.com:org/legacy-api.git
244
+ override: true
245
+ content:
246
+ version: "1.0"
247
+ legacy: true
248
+ ```
249
+
250
+ ### Environment-Specific Values
251
+
252
+ Use environment variables for secrets and environment-specific values:
83
253
 
84
254
  ```yaml
85
- fileName: my.config.json
255
+ fileName: app.config.json
256
+
257
+ content:
258
+ database:
259
+ host: ${DB_HOST:-localhost}
260
+ port: ${DB_PORT:-5432}
261
+ password: ${DB_PASSWORD:?Database password required}
262
+
263
+ api:
264
+ baseUrl: ${API_BASE_URL}
265
+ timeout: 30000
266
+
86
267
  repos:
87
- - git: git@github.com:example-org/repo1.git
88
- json:
89
- setting1: value1
90
- nested:
91
- setting2: value2
92
- - git: git@ssh.dev.azure.com:v3/example-org/project/repo2
93
- json:
94
- setting1: differentValue
95
- setting3: value
96
- ```
97
-
98
- ### Fields
99
-
100
- | Field | Description |
101
- |-------|-------------|
102
- | `fileName` | The name of the JSON file to create/update in each repo |
103
- | `repos` | Array of repository configurations |
104
- | `repos[].git` | Git URL of the repository (SSH or HTTPS) |
105
- | `repos[].json` | The JSON content to write to the file |
268
+ - git: git@github.com:org/backend.git
269
+ ```
270
+
271
+ ### Simple Multi-Repo Sync
272
+
273
+ When all repos need identical config:
274
+
275
+ ```yaml
276
+ fileName: .eslintrc.json
277
+
278
+ content:
279
+ extends: ["@org/eslint-config"]
280
+ rules:
281
+ no-console: warn
282
+
283
+ repos:
284
+ - git:
285
+ - git@github.com:org/frontend.git
286
+ - git@github.com:org/backend.git
287
+ - git@github.com:org/shared-lib.git
288
+ - git@github.com:org/cli-tool.git
289
+ ```
106
290
 
107
291
  ## Supported Git URL Formats
108
292
 
109
293
  ### GitHub
294
+
110
295
  - SSH: `git@github.com:owner/repo.git`
111
296
  - HTTPS: `https://github.com/owner/repo.git`
112
297
 
113
298
  ### Azure DevOps
299
+
114
300
  - SSH: `git@ssh.dev.azure.com:v3/organization/project/repo`
115
301
  - HTTPS: `https://dev.azure.com/organization/project/_git/repo`
116
302
 
117
- ## Workflow
303
+ ## How It Works
304
+
305
+ ```mermaid
306
+ flowchart TB
307
+ subgraph Input
308
+ YAML[/"YAML Config File<br/>fileName + content + repos[]"/]
309
+ end
310
+
311
+ subgraph Normalization
312
+ EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content]
313
+ MERGE --> ENV[Interpolate env vars]
314
+ end
315
+
316
+ subgraph Processing["For Each Repository"]
317
+ CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>chore/sync-filename]
318
+ BRANCH --> WRITE[Write Config File<br/>JSON or YAML]
319
+ WRITE --> CHECK{Changes?}
320
+ CHECK -->|No| SKIP[Skip - No Changes]
321
+ CHECK -->|Yes| COMMIT[Commit Changes]
322
+ COMMIT --> PUSH[Push to Remote]
323
+ end
324
+
325
+ subgraph Platform["Platform Detection"]
326
+ PUSH --> DETECT{GitHub or<br/>Azure DevOps?}
327
+ end
328
+
329
+ subgraph Output
330
+ DETECT -->|GitHub| GH_PR[Create PR via gh CLI]
331
+ DETECT -->|Azure DevOps| AZ_PR[Create PR via az CLI]
332
+ GH_PR --> GH_URL[/"GitHub PR URL"/]
333
+ AZ_PR --> AZ_URL[/"Azure DevOps PR URL"/]
334
+ end
335
+
336
+ YAML --> EXPAND
337
+ ENV --> CLONE
338
+ ```
118
339
 
119
340
  For each repository in the config, the tool:
120
341
 
121
- 1. Cleans the temporary workspace
122
- 2. Detects if repo is GitHub or Azure DevOps
123
- 3. Clones the repository
124
- 4. Creates/checks out branch `chore/sync-{sanitized-filename}`
125
- 5. Generates the JSON file from config
126
- 6. Checks for changes (skips if no changes)
127
- 7. Commits and pushes changes
128
- 8. Creates a pull request
342
+ 1. Expands git URL arrays into individual entries
343
+ 2. Merges base content with per-repo overlay
344
+ 3. Interpolates environment variables
345
+ 4. Cleans the temporary workspace
346
+ 5. Clones the repository
347
+ 6. Creates/checks out branch `chore/sync-{sanitized-filename}`
348
+ 7. Generates the config file (JSON or YAML based on filename extension)
349
+ 8. Checks for changes (skips if no changes)
350
+ 9. Commits and pushes changes
351
+ 10. Creates a pull request
129
352
 
130
- ## Example
353
+ ## CI/CD Integration
131
354
 
132
- Given this config file (`config.yaml`):
355
+ ### GitHub Actions
133
356
 
134
357
  ```yaml
135
- fileName: my.config.json
136
- repos:
137
- - git: git@github.com:example-org/my-service.git
138
- json:
139
- environment: production
140
- settings:
141
- feature1: true
142
- feature2: false
358
+ name: Sync Configs
359
+ on:
360
+ push:
361
+ branches: [main]
362
+ paths: ["config.yaml"]
363
+
364
+ jobs:
365
+ sync:
366
+ runs-on: ubuntu-latest
367
+ steps:
368
+ - uses: actions/checkout@v4
369
+ - uses: actions/setup-node@v4
370
+ with:
371
+ node-version: "20"
372
+ - run: npm install -g @aspruyt/json-config-sync
373
+ - run: json-config-sync --config ./config.yaml
374
+ env:
375
+ GH_TOKEN: ${{ secrets.GH_PAT }}
376
+ ```
377
+
378
+ > **Note:** `GH_PAT` must be a Personal Access Token with `repo` scope to create PRs in target repositories.
379
+
380
+ ### Azure Pipelines
381
+
382
+ ```yaml
383
+ trigger:
384
+ branches:
385
+ include: [main]
386
+ paths:
387
+ include: ["config.yaml"]
388
+
389
+ pool:
390
+ vmImage: "ubuntu-latest"
391
+
392
+ steps:
393
+ - task: NodeTool@0
394
+ inputs:
395
+ versionSpec: "20.x"
396
+ - script: npm install -g @aspruyt/json-config-sync
397
+ displayName: "Install json-config-sync"
398
+ - script: json-config-sync --config ./config.yaml
399
+ displayName: "Sync configs"
400
+ env:
401
+ AZURE_DEVOPS_EXT_PAT: $(System.AccessToken)
402
+ ```
403
+
404
+ > **Note:** Ensure the build service account has permission to create PRs in target repositories.
405
+
406
+ ## Output Examples
407
+
408
+ ### Console Output
409
+
143
410
  ```
411
+ [1/3] Processing example-org/repo1...
412
+ ✓ Cloned repository
413
+ ✓ Created branch chore/sync-my-config
414
+ ✓ Wrote my.config.json
415
+ ✓ Committed changes
416
+ ✓ Pushed to remote
417
+ ✓ Created PR: https://github.com/example-org/repo1/pull/42
418
+
419
+ [2/3] Processing example-org/repo2...
420
+ ✓ Cloned repository
421
+ ✓ Checked out existing branch chore/sync-my-config
422
+ ✓ Wrote my.config.json
423
+ ⊘ No changes detected, skipping
424
+
425
+ [3/3] Processing example-org/repo3...
426
+ ✓ Cloned repository
427
+ ✓ Created branch chore/sync-my-config
428
+ ✓ Wrote my.config.json
429
+ ✓ Committed changes
430
+ ✓ Pushed to remote
431
+ ✓ PR already exists: https://github.com/example-org/repo3/pull/15
432
+
433
+ Summary: 2 succeeded, 1 skipped, 0 failed
434
+ ```
435
+
436
+ ### Created PR
437
+
438
+ The tool creates PRs with:
439
+
440
+ - **Title:** `chore: sync {fileName}`
441
+ - **Branch:** `chore/sync-{sanitized-filename}`
442
+ - **Body:** Describes the sync action and links to documentation
443
+
444
+ ## Troubleshooting
445
+
446
+ ### Authentication Errors
144
447
 
145
- Running:
448
+ **GitHub:**
146
449
 
147
450
  ```bash
451
+ # Check authentication status
452
+ gh auth status
453
+
454
+ # Re-authenticate if needed
455
+ gh auth login
456
+ ```
457
+
458
+ **Azure DevOps:**
459
+
460
+ ```bash
461
+ # Check authentication status
462
+ az account show
463
+
464
+ # Re-authenticate if needed
465
+ az login
466
+ az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG
467
+ ```
468
+
469
+ ### Permission Denied
470
+
471
+ - Ensure your token has write access to the target repositories
472
+ - For GitHub, the token needs `repo` scope
473
+ - For Azure DevOps, ensure the user/service account has "Contribute to pull requests" permission
474
+
475
+ ### Branch Already Exists
476
+
477
+ The tool automatically reuses existing branches. If you see unexpected behavior:
478
+
479
+ ```bash
480
+ # Delete the remote branch to start fresh
481
+ git push origin --delete chore/sync-my-config
482
+ ```
483
+
484
+ ### Missing Environment Variables
485
+
486
+ If you see "Missing required environment variable" errors:
487
+
488
+ ```bash
489
+ # Set the variable before running
490
+ export MY_VAR=value
148
491
  json-config-sync --config ./config.yaml
492
+
493
+ # Or use default values in config
494
+ # ${MY_VAR:-default-value}
149
495
  ```
150
496
 
151
- Will:
152
- 1. Clone `example-org/my-service`
153
- 2. Create branch `chore/sync-my-config`
154
- 3. Write `my.config.json` with the specified content
155
- 4. Create a PR titled "chore: sync my.config.json"
497
+ ### Network/Proxy Issues
498
+
499
+ If cloning fails behind a corporate proxy:
500
+
501
+ ```bash
502
+ # Configure git proxy
503
+ git config --global http.proxy http://proxy.example.com:8080
504
+ git config --global https.proxy http://proxy.example.com:8080
505
+ ```
156
506
 
157
507
  ## Development
158
508
 
package/dist/config.d.ts CHANGED
@@ -1,10 +1,22 @@
1
+ import { type ArrayMergeStrategy } from './merge.js';
2
+ export interface RawRepoConfig {
3
+ git: string | string[];
4
+ content?: Record<string, unknown>;
5
+ override?: boolean;
6
+ }
7
+ export interface RawConfig {
8
+ fileName: string;
9
+ content?: Record<string, unknown>;
10
+ mergeStrategy?: ArrayMergeStrategy;
11
+ repos: RawRepoConfig[];
12
+ }
1
13
  export interface RepoConfig {
2
14
  git: string;
3
- json: Record<string, unknown>;
15
+ content: Record<string, unknown>;
4
16
  }
5
17
  export interface Config {
6
18
  fileName: string;
7
19
  repos: RepoConfig[];
8
20
  }
9
21
  export declare function loadConfig(filePath: string): Config;
10
- export declare function convertJsonToString(json: Record<string, unknown>): string;
22
+ export declare function convertContentToString(content: Record<string, unknown>, fileName: string): string;
package/dist/config.js CHANGED
@@ -1,25 +1,97 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { parse } from 'yaml';
3
- export function loadConfig(filePath) {
4
- const content = readFileSync(filePath, 'utf-8');
5
- const config = parse(content);
2
+ import { parse, stringify } from 'yaml';
3
+ import { deepMerge, stripMergeDirectives, createMergeContext, } from './merge.js';
4
+ import { interpolateEnvVars } from './env.js';
5
+ // =============================================================================
6
+ // Validation
7
+ // =============================================================================
8
+ function validateRawConfig(config) {
6
9
  if (!config.fileName) {
7
10
  throw new Error('Config missing required field: fileName');
8
11
  }
9
12
  if (!config.repos || !Array.isArray(config.repos)) {
10
13
  throw new Error('Config missing required field: repos (must be an array)');
11
14
  }
15
+ const hasRootContent = config.content !== undefined;
12
16
  for (let i = 0; i < config.repos.length; i++) {
13
17
  const repo = config.repos[i];
14
18
  if (!repo.git) {
15
19
  throw new Error(`Repo at index ${i} missing required field: git`);
16
20
  }
17
- if (!repo.json) {
18
- throw new Error(`Repo at index ${i} missing required field: json`);
21
+ if (!hasRootContent && !repo.content) {
22
+ throw new Error(`Repo at index ${i} missing required field: content (no root-level content defined)`);
23
+ }
24
+ if (repo.override && !repo.content) {
25
+ throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true but no content defined`);
26
+ }
27
+ }
28
+ }
29
+ function getGitDisplayName(git) {
30
+ if (Array.isArray(git)) {
31
+ return git[0] || 'unknown';
32
+ }
33
+ return git;
34
+ }
35
+ // =============================================================================
36
+ // Normalization Pipeline
37
+ // =============================================================================
38
+ function normalizeConfig(raw) {
39
+ const baseContent = raw.content ?? {};
40
+ const defaultStrategy = raw.mergeStrategy ?? 'replace';
41
+ const expandedRepos = [];
42
+ for (const rawRepo of raw.repos) {
43
+ // Step 1: Expand git arrays
44
+ const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
45
+ for (const gitUrl of gitUrls) {
46
+ // Step 2: Compute merged content
47
+ let mergedContent;
48
+ if (rawRepo.override) {
49
+ // Override mode: use only repo content
50
+ mergedContent = stripMergeDirectives(structuredClone(rawRepo.content));
51
+ }
52
+ else if (!rawRepo.content) {
53
+ // No repo content: use root content as-is
54
+ mergedContent = structuredClone(baseContent);
55
+ }
56
+ else {
57
+ // Merge mode: deep merge base + overlay
58
+ const ctx = createMergeContext(defaultStrategy);
59
+ mergedContent = deepMerge(structuredClone(baseContent), rawRepo.content, ctx);
60
+ mergedContent = stripMergeDirectives(mergedContent);
61
+ }
62
+ // Step 3: Interpolate env vars
63
+ mergedContent = interpolateEnvVars(mergedContent, { strict: true });
64
+ expandedRepos.push({
65
+ git: gitUrl,
66
+ content: mergedContent,
67
+ });
19
68
  }
20
69
  }
21
- return config;
70
+ return {
71
+ fileName: raw.fileName,
72
+ repos: expandedRepos,
73
+ };
74
+ }
75
+ // =============================================================================
76
+ // Public API
77
+ // =============================================================================
78
+ export function loadConfig(filePath) {
79
+ const content = readFileSync(filePath, 'utf-8');
80
+ const rawConfig = parse(content);
81
+ validateRawConfig(rawConfig);
82
+ return normalizeConfig(rawConfig);
22
83
  }
23
- export function convertJsonToString(json) {
24
- return JSON.stringify(json, null, 2);
84
+ function detectOutputFormat(fileName) {
85
+ const ext = fileName.toLowerCase().split('.').pop();
86
+ if (ext === 'yaml' || ext === 'yml') {
87
+ return 'yaml';
88
+ }
89
+ return 'json';
90
+ }
91
+ export function convertContentToString(content, fileName) {
92
+ const format = detectOutputFormat(fileName);
93
+ if (format === 'yaml') {
94
+ return stringify(content, { indent: 2 });
95
+ }
96
+ return JSON.stringify(content, null, 2);
25
97
  }
package/dist/env.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Environment variable interpolation utilities.
3
+ * Supports ${VAR}, ${VAR:-default}, and ${VAR:?message} syntax.
4
+ */
5
+ export interface EnvInterpolationOptions {
6
+ /**
7
+ * If true (default), throws an error when a variable is missing
8
+ * and has no default value. If false, leaves the placeholder as-is.
9
+ */
10
+ strict: boolean;
11
+ }
12
+ /**
13
+ * Interpolate environment variables in a JSON object.
14
+ *
15
+ * Supports three syntaxes:
16
+ * - ${VAR} - Replace with env value, error if missing (in strict mode)
17
+ * - ${VAR:-default} - Replace with env value, or use default if missing
18
+ * - ${VAR:?message} - Replace with env value, or throw error with message if missing
19
+ *
20
+ * @param json - The JSON object to process
21
+ * @param options - Interpolation options (default: strict mode)
22
+ * @returns A new object with interpolated values
23
+ */
24
+ export declare function interpolateEnvVars(json: Record<string, unknown>, options?: EnvInterpolationOptions): Record<string, unknown>;
package/dist/env.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Environment variable interpolation utilities.
3
+ * Supports ${VAR}, ${VAR:-default}, and ${VAR:?message} syntax.
4
+ */
5
+ const DEFAULT_OPTIONS = {
6
+ strict: true,
7
+ };
8
+ /**
9
+ * Regex to match environment variable placeholders.
10
+ * Captures:
11
+ * - Group 1: Variable name
12
+ * - Group 2: Modifier (- for default, ? for required with message)
13
+ * - Group 3: Default value or error message
14
+ *
15
+ * Examples:
16
+ * - ${VAR} -> varName=VAR, modifier=undefined, value=undefined
17
+ * - ${VAR:-default} -> varName=VAR, modifier=-, value=default
18
+ * - ${VAR:?message} -> varName=VAR, modifier=?, value=message
19
+ */
20
+ const ENV_VAR_REGEX = /\$\{([^}:]+)(?::([?-])([^}]*))?\}/g;
21
+ /**
22
+ * Check if a value is a plain object (not null, not array).
23
+ */
24
+ function isPlainObject(val) {
25
+ return typeof val === 'object' && val !== null && !Array.isArray(val);
26
+ }
27
+ /**
28
+ * Process a single string value, replacing environment variable placeholders.
29
+ */
30
+ function processString(value, options) {
31
+ return value.replace(ENV_VAR_REGEX, (match, varName, modifier, defaultOrMsg) => {
32
+ const envValue = process.env[varName];
33
+ // Variable exists - use its value
34
+ if (envValue !== undefined) {
35
+ return envValue;
36
+ }
37
+ // Has default value (:-default)
38
+ if (modifier === '-') {
39
+ return defaultOrMsg ?? '';
40
+ }
41
+ // Required with message (:?message)
42
+ if (modifier === '?') {
43
+ const message = defaultOrMsg || `is required`;
44
+ throw new Error(`${varName}: ${message}`);
45
+ }
46
+ // No modifier - check strictness
47
+ if (options.strict) {
48
+ throw new Error(`Missing required environment variable: ${varName}`);
49
+ }
50
+ // Non-strict mode - leave placeholder as-is
51
+ return match;
52
+ });
53
+ }
54
+ /**
55
+ * Recursively process a value, interpolating environment variables in strings.
56
+ */
57
+ function processValue(value, options) {
58
+ if (typeof value === 'string') {
59
+ return processString(value, options);
60
+ }
61
+ if (Array.isArray(value)) {
62
+ return value.map((item) => processValue(item, options));
63
+ }
64
+ if (isPlainObject(value)) {
65
+ const result = {};
66
+ for (const [key, val] of Object.entries(value)) {
67
+ result[key] = processValue(val, options);
68
+ }
69
+ return result;
70
+ }
71
+ // For numbers, booleans, null - return as-is
72
+ return value;
73
+ }
74
+ /**
75
+ * Interpolate environment variables in a JSON object.
76
+ *
77
+ * Supports three syntaxes:
78
+ * - ${VAR} - Replace with env value, error if missing (in strict mode)
79
+ * - ${VAR:-default} - Replace with env value, or use default if missing
80
+ * - ${VAR:?message} - Replace with env value, or throw error with message if missing
81
+ *
82
+ * @param json - The JSON object to process
83
+ * @param options - Interpolation options (default: strict mode)
84
+ * @returns A new object with interpolated values
85
+ */
86
+ export function interpolateEnvVars(json, options = DEFAULT_OPTIONS) {
87
+ return processValue(json, options);
88
+ }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { program } from 'commander';
3
3
  import { resolve, join } from 'node:path';
4
4
  import { existsSync } from 'node:fs';
5
- import { loadConfig, convertJsonToString } from './config.js';
5
+ import { loadConfig, convertContentToString } from './config.js';
6
6
  import { parseGitUrl, getRepoDisplayName } from './repo-detector.js';
7
7
  import { GitOps, sanitizeBranchName } from './git-ops.js';
8
8
  import { createPR } from './pr-creator.js';
@@ -61,10 +61,10 @@ async function main() {
61
61
  // Step 4: Create/checkout branch
62
62
  logger.info(`Switching to branch: ${branchName}`);
63
63
  gitOps.createBranch(branchName);
64
- // Step 5: Write JSON file
64
+ // Step 5: Write config file
65
65
  logger.info(`Writing ${config.fileName}...`);
66
- const jsonContent = convertJsonToString(repoConfig.json);
67
- gitOps.writeFile(config.fileName, jsonContent);
66
+ const fileContent = convertContentToString(repoConfig.content, config.fileName);
67
+ gitOps.writeFile(config.fileName, fileContent);
68
68
  // Step 6: Check for changes
69
69
  if (!gitOps.hasChanges()) {
70
70
  logger.skip(current, repoName, 'No changes detected');
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Deep merge utilities for JSON configuration objects.
3
+ * Supports configurable array merge strategies via $arrayMerge directive.
4
+ */
5
+ export type ArrayMergeStrategy = 'replace' | 'append' | 'prepend';
6
+ export interface MergeContext {
7
+ arrayStrategies: Map<string, ArrayMergeStrategy>;
8
+ defaultArrayStrategy: ArrayMergeStrategy;
9
+ }
10
+ /**
11
+ * Deep merge two objects with configurable array handling.
12
+ *
13
+ * @param base - The base object
14
+ * @param overlay - The overlay object (values override base)
15
+ * @param ctx - Merge context with array strategies
16
+ * @param path - Current path for strategy lookup (internal)
17
+ */
18
+ export declare function deepMerge(base: Record<string, unknown>, overlay: Record<string, unknown>, ctx: MergeContext, path?: string): Record<string, unknown>;
19
+ /**
20
+ * Strip merge directive keys ($arrayMerge, $override, etc.) from an object.
21
+ * Works recursively on nested objects and arrays.
22
+ */
23
+ export declare function stripMergeDirectives(obj: Record<string, unknown>): Record<string, unknown>;
24
+ /**
25
+ * Create a default merge context.
26
+ */
27
+ export declare function createMergeContext(defaultStrategy?: ArrayMergeStrategy): MergeContext;
package/dist/merge.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Deep merge utilities for JSON configuration objects.
3
+ * Supports configurable array merge strategies via $arrayMerge directive.
4
+ */
5
+ /**
6
+ * Check if a value is a plain object (not null, not array).
7
+ */
8
+ function isPlainObject(val) {
9
+ return typeof val === 'object' && val !== null && !Array.isArray(val);
10
+ }
11
+ /**
12
+ * Merge two arrays based on the specified strategy.
13
+ */
14
+ function mergeArrays(base, overlay, strategy) {
15
+ switch (strategy) {
16
+ case 'replace':
17
+ return overlay;
18
+ case 'append':
19
+ return [...base, ...overlay];
20
+ case 'prepend':
21
+ return [...overlay, ...base];
22
+ default:
23
+ return overlay;
24
+ }
25
+ }
26
+ /**
27
+ * Extract array values from an overlay object that uses the directive syntax:
28
+ * { $arrayMerge: 'append', values: [1, 2, 3] }
29
+ *
30
+ * Or just return the array if it's already an array.
31
+ */
32
+ function extractArrayFromOverlay(overlay) {
33
+ if (Array.isArray(overlay)) {
34
+ return overlay;
35
+ }
36
+ if (isPlainObject(overlay) && 'values' in overlay) {
37
+ const values = overlay.values;
38
+ if (Array.isArray(values)) {
39
+ return values;
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+ /**
45
+ * Get merge strategy from an overlay object's $arrayMerge directive.
46
+ */
47
+ function getStrategyFromOverlay(overlay) {
48
+ if (isPlainObject(overlay) && '$arrayMerge' in overlay) {
49
+ const strategy = overlay.$arrayMerge;
50
+ if (strategy === 'replace' ||
51
+ strategy === 'append' ||
52
+ strategy === 'prepend') {
53
+ return strategy;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ /**
59
+ * Deep merge two objects with configurable array handling.
60
+ *
61
+ * @param base - The base object
62
+ * @param overlay - The overlay object (values override base)
63
+ * @param ctx - Merge context with array strategies
64
+ * @param path - Current path for strategy lookup (internal)
65
+ */
66
+ export function deepMerge(base, overlay, ctx, path = '') {
67
+ const result = { ...base };
68
+ // Check for $arrayMerge directive at this level (applies to child arrays)
69
+ const levelStrategy = getStrategyFromOverlay(overlay);
70
+ for (const [key, overlayValue] of Object.entries(overlay)) {
71
+ // Skip directive keys in output
72
+ if (key.startsWith('$'))
73
+ continue;
74
+ const currentPath = path ? `${path}.${key}` : key;
75
+ const baseValue = base[key];
76
+ // If overlay is an object with $arrayMerge directive for an array field
77
+ if (isPlainObject(overlayValue) && '$arrayMerge' in overlayValue) {
78
+ const strategy = getStrategyFromOverlay(overlayValue);
79
+ const overlayArray = extractArrayFromOverlay(overlayValue);
80
+ if (strategy && overlayArray && Array.isArray(baseValue)) {
81
+ result[key] = mergeArrays(baseValue, overlayArray, strategy);
82
+ continue;
83
+ }
84
+ }
85
+ // Both are arrays - apply strategy
86
+ if (Array.isArray(baseValue) && Array.isArray(overlayValue)) {
87
+ // Check for level-specific strategy, then path-specific, then default
88
+ const strategy = levelStrategy ??
89
+ ctx.arrayStrategies.get(currentPath) ??
90
+ ctx.defaultArrayStrategy;
91
+ result[key] = mergeArrays(baseValue, overlayValue, strategy);
92
+ continue;
93
+ }
94
+ // Both are plain objects - recurse
95
+ if (isPlainObject(baseValue) && isPlainObject(overlayValue)) {
96
+ // Extract $arrayMerge for child paths if present
97
+ if ('$arrayMerge' in overlayValue) {
98
+ const childStrategy = getStrategyFromOverlay(overlayValue);
99
+ if (childStrategy) {
100
+ // Apply to all immediate child arrays
101
+ for (const childKey of Object.keys(overlayValue)) {
102
+ if (!childKey.startsWith('$')) {
103
+ const childPath = currentPath ? `${currentPath}.${childKey}` : childKey;
104
+ ctx.arrayStrategies.set(childPath, childStrategy);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ result[key] = deepMerge(baseValue, overlayValue, ctx, currentPath);
110
+ continue;
111
+ }
112
+ // Otherwise, overlay wins (including null values)
113
+ result[key] = overlayValue;
114
+ }
115
+ return result;
116
+ }
117
+ /**
118
+ * Strip merge directive keys ($arrayMerge, $override, etc.) from an object.
119
+ * Works recursively on nested objects and arrays.
120
+ */
121
+ export function stripMergeDirectives(obj) {
122
+ const result = {};
123
+ for (const [key, value] of Object.entries(obj)) {
124
+ // Skip all $-prefixed keys (reserved for directives)
125
+ if (key.startsWith('$'))
126
+ continue;
127
+ if (isPlainObject(value)) {
128
+ result[key] = stripMergeDirectives(value);
129
+ }
130
+ else if (Array.isArray(value)) {
131
+ result[key] = value.map((item) => isPlainObject(item) ? stripMergeDirectives(item) : item);
132
+ }
133
+ else {
134
+ result[key] = value;
135
+ }
136
+ }
137
+ return result;
138
+ }
139
+ /**
140
+ * Create a default merge context.
141
+ */
142
+ export function createMergeContext(defaultStrategy = 'replace') {
143
+ return {
144
+ arrayStrategies: new Map(),
145
+ defaultArrayStrategy: defaultStrategy,
146
+ };
147
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "1.0.0",
4
- "description": "CLI tool to sync JSON configuration files across multiple repositories",
3
+ "version": "2.0.0",
4
+ "description": "CLI tool to sync JSON configuration files across multiple GitHub and Azure DevOps repositories",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "build": "tsc",
27
27
  "start": "node dist/index.js",
28
28
  "dev": "ts-node src/index.ts",
29
- "test": "node --import tsx --test src/config.test.ts",
29
+ "test": "node --import tsx --test src/config.test.ts src/merge.test.ts src/env.test.ts",
30
30
  "test:integration": "npm run build && node --import tsx --test src/integration.test.ts",
31
31
  "prepublishOnly": "npm run build",
32
32
  "release:patch": "npm version patch && git push --follow-tags",