@aspruyt/json-config-sync 1.0.1 → 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,7 +5,7 @@
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 GitHub and Azure DevOps 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
9
 
10
10
  ## Table of Contents
11
11
 
@@ -16,6 +16,7 @@ A CLI tool that syncs JSON configuration files across multiple GitHub and Azure
16
16
  - [Prerequisites](#prerequisites)
17
17
  - [Usage](#usage)
18
18
  - [Configuration Format](#configuration-format)
19
+ - [Examples](#examples)
19
20
  - [Supported Git URL Formats](#supported-git-url-formats)
20
21
  - [CI/CD Integration](#cicd-integration)
21
22
  - [Output Examples](#output-examples)
@@ -35,44 +36,39 @@ gh auth login
35
36
  # Create config.yaml
36
37
  cat > config.yaml << 'EOF'
37
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
+
38
47
  repos:
39
- - git: git@github.com:your-org/frontend-app.git
40
- json:
41
- semi: false
42
- singleQuote: true
43
- tabWidth: 2
44
- trailingComma: es5
45
- - git: git@github.com:your-org/backend-api.git
46
- json:
47
- semi: false
48
- singleQuote: true
49
- tabWidth: 2
50
- trailingComma: es5
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
51
53
  EOF
52
54
 
53
55
  # Run
54
56
  json-config-sync --config ./config.yaml
55
57
  ```
56
58
 
57
- **Result:** PRs are created in both repos with `.prettierrc.json`:
58
-
59
- ```json
60
- {
61
- "semi": false,
62
- "singleQuote": true,
63
- "tabWidth": 2,
64
- "trailingComma": "es5"
65
- }
66
- ```
59
+ **Result:** PRs are created in all three repos with identical `.prettierrc.json` files.
67
60
 
68
61
  ## Features
69
62
 
70
- - Reads configuration from a YAML file
71
- - Supports both GitHub and Azure DevOps repositories
72
- - Creates PRs automatically using `gh` CLI (GitHub) or `az` CLI (Azure DevOps)
73
- - Continues processing if individual repos fail
74
- - Supports dry-run mode for testing
75
- - 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
76
72
 
77
73
  ## Installation
78
74
 
@@ -137,30 +133,160 @@ json-config-sync --config ./config.yaml --work-dir ./my-temp
137
133
 
138
134
  ## Configuration Format
139
135
 
140
- 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:
141
175
 
142
176
  ```yaml
143
- fileName: my.config.json
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
+
144
193
  repos:
145
- - git: git@github.com:example-org/repo1.git
146
- json:
147
- setting1: value1
148
- nested:
149
- setting2: value2
150
- - git: git@ssh.dev.azure.com:v3/example-org/project/repo2
151
- json:
152
- setting1: differentValue
153
- setting3: value
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]
154
200
  ```
155
201
 
156
- ### Fields
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:
253
+
254
+ ```yaml
255
+ fileName: app.config.json
157
256
 
158
- | Field | Description |
159
- | -------------- | ------------------------------------------------------- |
160
- | `fileName` | The name of the JSON file to create/update in each repo |
161
- | `repos` | Array of repository configurations |
162
- | `repos[].git` | Git URL of the repository (SSH or HTTPS) |
163
- | `repos[].json` | The JSON content to write to the file |
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
+
267
+ repos:
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
+ ```
164
290
 
165
291
  ## Supported Git URL Formats
166
292
 
@@ -179,12 +305,17 @@ repos:
179
305
  ```mermaid
180
306
  flowchart TB
181
307
  subgraph Input
182
- YAML[/"YAML Config File<br/>fileName + repos[]"/]
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]
183
314
  end
184
315
 
185
316
  subgraph Processing["For Each Repository"]
186
317
  CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>chore/sync-filename]
187
- BRANCH --> WRITE[Write JSON File]
318
+ BRANCH --> WRITE[Write Config File<br/>JSON or YAML]
188
319
  WRITE --> CHECK{Changes?}
189
320
  CHECK -->|No| SKIP[Skip - No Changes]
190
321
  CHECK -->|Yes| COMMIT[Commit Changes]
@@ -202,47 +333,22 @@ flowchart TB
202
333
  AZ_PR --> AZ_URL[/"Azure DevOps PR URL"/]
203
334
  end
204
335
 
205
- YAML --> CLONE
336
+ YAML --> EXPAND
337
+ ENV --> CLONE
206
338
  ```
207
339
 
208
340
  For each repository in the config, the tool:
209
341
 
210
- 1. Cleans the temporary workspace
211
- 2. Detects if repo is GitHub or Azure DevOps
212
- 3. Clones the repository
213
- 4. Creates/checks out branch `chore/sync-{sanitized-filename}`
214
- 5. Generates the JSON file from config
215
- 6. Checks for changes (skips if no changes)
216
- 7. Commits and pushes changes
217
- 8. Creates a pull request
218
-
219
- ## Example
220
-
221
- Given this config file (`config.yaml`):
222
-
223
- ```yaml
224
- fileName: my.config.json
225
- repos:
226
- - git: git@github.com:example-org/my-service.git
227
- json:
228
- environment: production
229
- settings:
230
- feature1: true
231
- feature2: false
232
- ```
233
-
234
- Running:
235
-
236
- ```bash
237
- json-config-sync --config ./config.yaml
238
- ```
239
-
240
- Will:
241
-
242
- 1. Clone `example-org/my-service`
243
- 2. Create branch `chore/sync-my-config`
244
- 3. Write `my.config.json` with the specified content
245
- 4. Create a PR titled "chore: sync my.config.json"
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
246
352
 
247
353
  ## CI/CD Integration
248
354
 
@@ -375,6 +481,19 @@ The tool automatically reuses existing branches. If you see unexpected behavior:
375
481
  git push origin --delete chore/sync-my-config
376
482
  ```
377
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
491
+ json-config-sync --config ./config.yaml
492
+
493
+ # Or use default values in config
494
+ # ${MY_VAR:-default-value}
495
+ ```
496
+
378
497
  ### Network/Proxy Issues
379
498
 
380
499
  If cloning fails behind a corporate proxy:
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
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",
@@ -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",