@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 +205 -86
- package/dist/config.d.ts +14 -2
- package/dist/config.js +81 -9
- package/dist/env.d.ts +24 -0
- package/dist/env.js +88 -0
- package/dist/index.js +4 -4
- package/dist/merge.d.ts +27 -0
- package/dist/merge.js +147 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
6
6
|
[](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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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 -->
|
|
336
|
+
YAML --> EXPAND
|
|
337
|
+
ENV --> CLONE
|
|
206
338
|
```
|
|
207
339
|
|
|
208
340
|
For each repository in the config, the tool:
|
|
209
341
|
|
|
210
|
-
1.
|
|
211
|
-
2.
|
|
212
|
-
3.
|
|
213
|
-
4.
|
|
214
|
-
5.
|
|
215
|
-
6.
|
|
216
|
-
7.
|
|
217
|
-
8.
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
18
|
-
throw new Error(`Repo at index ${i} missing required field:
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
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,
|
|
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
|
|
64
|
+
// Step 5: Write config file
|
|
65
65
|
logger.info(`Writing ${config.fileName}...`);
|
|
66
|
-
const
|
|
67
|
-
gitOps.writeFile(config.fileName,
|
|
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');
|
package/dist/merge.d.ts
ADDED
|
@@ -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": "
|
|
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",
|