@aspruyt/json-config-sync 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PR.md CHANGED
@@ -1,14 +1,15 @@
1
- ## Summary
2
-
3
- Automated sync of `{{FILE_NAME}}` configuration file.
4
-
5
- ## Changes
6
-
7
- - {{ACTION}} `{{FILE_NAME}}` in repository root
8
-
9
- ## Source
10
-
11
- Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
12
-
13
- ---
14
- *This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)*
1
+ ## Summary
2
+
3
+ Automated sync of `{{FILE_NAME}}` configuration file.
4
+
5
+ ## Changes
6
+
7
+ - {{ACTION}} `{{FILE_NAME}}` in repository root
8
+
9
+ ## Source
10
+
11
+ Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
12
+
13
+ ---
14
+
15
+ _This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)_
package/README.md CHANGED
@@ -21,6 +21,7 @@ A CLI tool that syncs JSON or YAML configuration files across multiple GitHub an
21
21
  - [CI/CD Integration](#cicd-integration)
22
22
  - [Output Examples](#output-examples)
23
23
  - [Troubleshooting](#troubleshooting)
24
+ - [IDE Integration](#ide-integration)
24
25
  - [Development](#development)
25
26
  - [License](#license)
26
27
 
@@ -136,36 +137,36 @@ json-config-sync --config ./config.yaml --work-dir ./my-temp
136
137
  ### Basic Structure
137
138
 
138
139
  ```yaml
139
- fileName: my.config.json # Target file (.json outputs JSON, .yaml/.yml outputs YAML)
140
- mergeStrategy: replace # Default array merge strategy (optional)
140
+ fileName: my.config.json # Target file (.json outputs JSON, .yaml/.yml outputs YAML)
141
+ mergeStrategy: replace # Default array merge strategy (optional)
141
142
 
142
- content: # Base config content (optional)
143
+ content: # Base config content (optional)
143
144
  key: value
144
145
 
145
- repos: # List of repositories
146
+ repos: # List of repositories
146
147
  - git: git@github.com:org/repo.git
147
- content: # Per-repo overlay (optional if base content exists)
148
+ content: # Per-repo overlay (optional if base content exists)
148
149
  key: override
149
150
  ```
150
151
 
151
152
  ### Root-Level Fields
152
153
 
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 |
154
+ | Field | Description | Required |
155
+ | --------------- | ---------------------------------------------------------------------- | -------- |
156
+ | `fileName` | Target file name (`.json` → JSON output, `.yaml`/`.yml` → YAML output) | Yes |
157
+ | `content` | Base config inherited by all repos | No\* |
158
+ | `mergeStrategy` | Default array merge strategy: `replace`, `append`, `prepend` | No |
159
+ | `repos` | Array of repository configurations | Yes |
159
160
 
160
161
  \* Required if any repo entry omits the `content` field.
161
162
 
162
163
  ### Per-Repo Fields
163
164
 
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 |
165
+ | Field | Description | Required |
166
+ | ---------- | ---------------------------------------------------------- | -------- |
167
+ | `git` | Git URL (string) or array of URLs | Yes |
168
+ | `content` | Content overlay merged onto base (optional if base exists) | No\* |
169
+ | `override` | If `true`, ignore base content and use only this repo's | No |
169
170
 
170
171
  \* Required if no root-level `content` is defined.
171
172
 
@@ -175,8 +176,8 @@ Use `${VAR}` syntax in string values:
175
176
 
176
177
  ```yaml
177
178
  content:
178
- apiUrl: ${API_URL} # Required - errors if not set
179
- environment: ${ENV:-development} # With default value
179
+ apiUrl: ${API_URL} # Required - errors if not set
180
+ environment: ${ENV:-development} # With default value
180
181
  secretKey: ${SECRET:?Secret required} # Required with custom error message
181
182
  ```
182
183
 
@@ -194,9 +195,9 @@ repos:
194
195
  - git: git@github.com:org/repo.git
195
196
  content:
196
197
  features:
197
- $arrayMerge: append # append | prepend | replace
198
+ $arrayMerge: append # append | prepend | replace
198
199
  values:
199
- - custom-feature # Results in: [core, monitoring, custom-feature]
200
+ - custom-feature # Results in: [core, monitoring, custom-feature]
200
201
  ```
201
202
 
202
203
  ## Examples
@@ -504,6 +505,43 @@ git config --global http.proxy http://proxy.example.com:8080
504
505
  git config --global https.proxy http://proxy.example.com:8080
505
506
  ```
506
507
 
508
+ ## IDE Integration
509
+
510
+ ### VS Code YAML Schema Support
511
+
512
+ For autocomplete and validation in VS Code, install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) and add a schema reference to your config file:
513
+
514
+ **Option 1: Inline comment**
515
+
516
+ ```yaml
517
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/anthony-spruyt/json-config-sync/main/config-schema.json
518
+ fileName: my.config.json
519
+ content:
520
+ key: value
521
+ repos:
522
+ - git: git@github.com:org/repo.git
523
+ ```
524
+
525
+ **Option 2: VS Code settings** (`.vscode/settings.json`)
526
+
527
+ ```json
528
+ {
529
+ "yaml.schemas": {
530
+ "https://raw.githubusercontent.com/anthony-spruyt/json-config-sync/main/config-schema.json": [
531
+ "**/sync-config.yaml",
532
+ "**/config-sync.yaml"
533
+ ]
534
+ }
535
+ }
536
+ ```
537
+
538
+ This enables:
539
+
540
+ - Autocomplete for `fileName`, `mergeStrategy`, `repos`, `content`, `git`, `override`
541
+ - Enum suggestions for `mergeStrategy` values (`replace`, `append`, `prepend`)
542
+ - Validation of required fields
543
+ - Hover documentation for each field
544
+
507
545
  ## Development
508
546
 
509
547
  ```bash
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ArrayMergeStrategy } from './merge.js';
1
+ import { type ArrayMergeStrategy } from "./merge.js";
2
2
  export interface RawRepoConfig {
3
3
  git: string | string[];
4
4
  content?: Record<string, unknown>;
package/dist/config.js CHANGED
@@ -1,16 +1,16 @@
1
- import { readFileSync } from 'node:fs';
2
- import { parse, stringify } from 'yaml';
3
- import { deepMerge, stripMergeDirectives, createMergeContext, } from './merge.js';
4
- import { interpolateEnvVars } from './env.js';
1
+ import { readFileSync } from "node:fs";
2
+ import { parse, stringify } from "yaml";
3
+ import { deepMerge, stripMergeDirectives, createMergeContext, } from "./merge.js";
4
+ import { interpolateEnvVars } from "./env.js";
5
5
  // =============================================================================
6
6
  // Validation
7
7
  // =============================================================================
8
8
  function validateRawConfig(config) {
9
9
  if (!config.fileName) {
10
- throw new Error('Config missing required field: fileName');
10
+ throw new Error("Config missing required field: fileName");
11
11
  }
12
12
  if (!config.repos || !Array.isArray(config.repos)) {
13
- throw new Error('Config missing required field: repos (must be an array)');
13
+ throw new Error("Config missing required field: repos (must be an array)");
14
14
  }
15
15
  const hasRootContent = config.content !== undefined;
16
16
  for (let i = 0; i < config.repos.length; i++) {
@@ -28,7 +28,7 @@ function validateRawConfig(config) {
28
28
  }
29
29
  function getGitDisplayName(git) {
30
30
  if (Array.isArray(git)) {
31
- return git[0] || 'unknown';
31
+ return git[0] || "unknown";
32
32
  }
33
33
  return git;
34
34
  }
@@ -37,7 +37,7 @@ function getGitDisplayName(git) {
37
37
  // =============================================================================
38
38
  function normalizeConfig(raw) {
39
39
  const baseContent = raw.content ?? {};
40
- const defaultStrategy = raw.mergeStrategy ?? 'replace';
40
+ const defaultStrategy = raw.mergeStrategy ?? "replace";
41
41
  const expandedRepos = [];
42
42
  for (const rawRepo of raw.repos) {
43
43
  // Step 1: Expand git arrays
@@ -76,21 +76,21 @@ function normalizeConfig(raw) {
76
76
  // Public API
77
77
  // =============================================================================
78
78
  export function loadConfig(filePath) {
79
- const content = readFileSync(filePath, 'utf-8');
79
+ const content = readFileSync(filePath, "utf-8");
80
80
  const rawConfig = parse(content);
81
81
  validateRawConfig(rawConfig);
82
82
  return normalizeConfig(rawConfig);
83
83
  }
84
84
  function detectOutputFormat(fileName) {
85
- const ext = fileName.toLowerCase().split('.').pop();
86
- if (ext === 'yaml' || ext === 'yml') {
87
- return 'yaml';
85
+ const ext = fileName.toLowerCase().split(".").pop();
86
+ if (ext === "yaml" || ext === "yml") {
87
+ return "yaml";
88
88
  }
89
- return 'json';
89
+ return "json";
90
90
  }
91
91
  export function convertContentToString(content, fileName) {
92
92
  const format = detectOutputFormat(fileName);
93
- if (format === 'yaml') {
93
+ if (format === "yaml") {
94
94
  return stringify(content, { indent: 2 });
95
95
  }
96
96
  return JSON.stringify(content, null, 2);
package/dist/env.js CHANGED
@@ -22,7 +22,7 @@ const ENV_VAR_REGEX = /\$\{([^}:]+)(?::([?-])([^}]*))?\}/g;
22
22
  * Check if a value is a plain object (not null, not array).
23
23
  */
24
24
  function isPlainObject(val) {
25
- return typeof val === 'object' && val !== null && !Array.isArray(val);
25
+ return typeof val === "object" && val !== null && !Array.isArray(val);
26
26
  }
27
27
  /**
28
28
  * Process a single string value, replacing environment variable placeholders.
@@ -35,11 +35,11 @@ function processString(value, options) {
35
35
  return envValue;
36
36
  }
37
37
  // Has default value (:-default)
38
- if (modifier === '-') {
39
- return defaultOrMsg ?? '';
38
+ if (modifier === "-") {
39
+ return defaultOrMsg ?? "";
40
40
  }
41
41
  // Required with message (:?message)
42
- if (modifier === '?') {
42
+ if (modifier === "?") {
43
43
  const message = defaultOrMsg || `is required`;
44
44
  throw new Error(`${varName}: ${message}`);
45
45
  }
@@ -55,7 +55,7 @@ function processString(value, options) {
55
55
  * Recursively process a value, interpolating environment variables in strings.
56
56
  */
57
57
  function processValue(value, options) {
58
- if (typeof value === 'string') {
58
+ if (typeof value === "string") {
59
59
  return processString(value, options);
60
60
  }
61
61
  if (Array.isArray(value)) {
package/dist/git-ops.js CHANGED
@@ -1,7 +1,7 @@
1
- import { execSync } from 'node:child_process';
2
- import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { escapeShellArg } from './shell-utils.js';
1
+ import { execSync } from "node:child_process";
2
+ import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { escapeShellArg } from "./shell-utils.js";
5
5
  export class GitOps {
6
6
  workDir;
7
7
  dryRun;
@@ -12,8 +12,8 @@ export class GitOps {
12
12
  exec(command, cwd) {
13
13
  return execSync(command, {
14
14
  cwd: cwd ?? this.workDir,
15
- encoding: 'utf-8',
16
- stdio: ['pipe', 'pipe', 'pipe'],
15
+ encoding: "utf-8",
16
+ stdio: ["pipe", "pipe", "pipe"],
17
17
  }).trim();
18
18
  }
19
19
  cleanWorkspace() {
@@ -41,7 +41,7 @@ export class GitOps {
41
41
  return;
42
42
  }
43
43
  const filePath = join(this.workDir, fileName);
44
- writeFileSync(filePath, content + '\n', 'utf-8');
44
+ writeFileSync(filePath, content + "\n", "utf-8");
45
45
  }
46
46
  /**
47
47
  * Checks if writing the given content would result in changes.
@@ -49,13 +49,13 @@ export class GitOps {
49
49
  */
50
50
  wouldChange(fileName, content) {
51
51
  const filePath = join(this.workDir, fileName);
52
- const newContent = content + '\n';
52
+ const newContent = content + "\n";
53
53
  if (!existsSync(filePath)) {
54
54
  // File doesn't exist, so writing it would be a change
55
55
  return true;
56
56
  }
57
57
  try {
58
- const existingContent = readFileSync(filePath, 'utf-8');
58
+ const existingContent = readFileSync(filePath, "utf-8");
59
59
  return existingContent !== newContent;
60
60
  }
61
61
  catch {
@@ -64,14 +64,14 @@ export class GitOps {
64
64
  }
65
65
  }
66
66
  hasChanges() {
67
- const status = this.exec('git status --porcelain', this.workDir);
67
+ const status = this.exec("git status --porcelain", this.workDir);
68
68
  return status.length > 0;
69
69
  }
70
70
  commit(message) {
71
71
  if (this.dryRun) {
72
72
  return;
73
73
  }
74
- this.exec('git add -A', this.workDir);
74
+ this.exec("git add -A", this.workDir);
75
75
  this.exec(`git commit -m ${escapeShellArg(message)}`, this.workDir);
76
76
  }
77
77
  push(branchName) {
@@ -83,10 +83,10 @@ export class GitOps {
83
83
  getDefaultBranch() {
84
84
  try {
85
85
  // Try to get the default branch from remote
86
- const remoteInfo = this.exec('git remote show origin', this.workDir);
86
+ const remoteInfo = this.exec("git remote show origin", this.workDir);
87
87
  const match = remoteInfo.match(/HEAD branch: (\S+)/);
88
88
  if (match) {
89
- return { branch: match[1], method: 'remote HEAD' };
89
+ return { branch: match[1], method: "remote HEAD" };
90
90
  }
91
91
  }
92
92
  catch {
@@ -94,27 +94,27 @@ export class GitOps {
94
94
  }
95
95
  // Try common default branch names
96
96
  try {
97
- this.exec('git rev-parse --verify origin/main', this.workDir);
98
- return { branch: 'main', method: 'origin/main exists' };
97
+ this.exec("git rev-parse --verify origin/main", this.workDir);
98
+ return { branch: "main", method: "origin/main exists" };
99
99
  }
100
100
  catch {
101
101
  // Try master
102
102
  }
103
103
  try {
104
- this.exec('git rev-parse --verify origin/master', this.workDir);
105
- return { branch: 'master', method: 'origin/master exists' };
104
+ this.exec("git rev-parse --verify origin/master", this.workDir);
105
+ return { branch: "master", method: "origin/master exists" };
106
106
  }
107
107
  catch {
108
108
  // Default to main
109
109
  }
110
- return { branch: 'main', method: 'fallback default' };
110
+ return { branch: "main", method: "fallback default" };
111
111
  }
112
112
  }
113
113
  export function sanitizeBranchName(fileName) {
114
114
  return fileName
115
115
  .toLowerCase()
116
- .replace(/\.[^.]+$/, '') // Remove extension
117
- .replace(/[^a-z0-9-]/g, '-') // Replace non-alphanumeric with dashes
118
- .replace(/-+/g, '-') // Collapse multiple dashes
119
- .replace(/^-|-$/g, ''); // Remove leading/trailing dashes
116
+ .replace(/\.[^.]+$/, "") // Remove extension
117
+ .replace(/[^a-z0-9-]/g, "-") // Replace non-alphanumeric with dashes
118
+ .replace(/-+/g, "-") // Collapse multiple dashes
119
+ .replace(/^-|-$/g, ""); // Remove leading/trailing dashes
120
120
  }
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { program } from 'commander';
3
- import { resolve, join } from 'node:path';
4
- import { existsSync } from 'node:fs';
5
- import { randomUUID } from 'node:crypto';
6
- import { loadConfig, convertContentToString } from './config.js';
7
- import { parseGitUrl, getRepoDisplayName } from './repo-detector.js';
8
- import { GitOps, sanitizeBranchName } from './git-ops.js';
9
- import { createPR } from './pr-creator.js';
10
- import { logger } from './logger.js';
2
+ import { program } from "commander";
3
+ import { resolve, join } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
6
+ import { loadConfig, convertContentToString } from "./config.js";
7
+ import { parseGitUrl, getRepoDisplayName } from "./repo-detector.js";
8
+ import { GitOps, sanitizeBranchName } from "./git-ops.js";
9
+ import { createPR } from "./pr-creator.js";
10
+ import { logger } from "./logger.js";
11
11
  /**
12
12
  * Generates a unique workspace directory name to avoid collisions
13
13
  * when multiple CLI instances run concurrently.
@@ -18,12 +18,12 @@ function generateWorkspaceName(index) {
18
18
  return `repo-${timestamp}-${index}-${uuid}`;
19
19
  }
20
20
  program
21
- .name('json-config-sync')
22
- .description('Sync JSON configuration files across multiple repositories')
23
- .version('1.0.0')
24
- .requiredOption('-c, --config <path>', 'Path to YAML config file')
25
- .option('-d, --dry-run', 'Show what would be done without making changes')
26
- .option('-w, --work-dir <path>', 'Temporary directory for cloning', './tmp')
21
+ .name("json-config-sync")
22
+ .description("Sync JSON configuration files across multiple repositories")
23
+ .version("1.0.0")
24
+ .requiredOption("-c, --config <path>", "Path to YAML config file")
25
+ .option("-d, --dry-run", "Show what would be done without making changes")
26
+ .option("-w, --work-dir <path>", "Temporary directory for cloning", "./tmp")
27
27
  .parse();
28
28
  const options = program.opts();
29
29
  async function main() {
@@ -34,7 +34,7 @@ async function main() {
34
34
  }
35
35
  console.log(`Loading config from: ${configPath}`);
36
36
  if (options.dryRun) {
37
- console.log('Running in DRY RUN mode - no changes will be made\n');
37
+ console.log("Running in DRY RUN mode - no changes will be made\n");
38
38
  }
39
39
  const config = loadConfig(configPath);
40
40
  const branchName = `chore/sync-${sanitizeBranchName(config.fileName)}`;
@@ -55,15 +55,15 @@ async function main() {
55
55
  continue;
56
56
  }
57
57
  const repoName = getRepoDisplayName(repoInfo);
58
- const workDir = resolve(join(options.workDir ?? './tmp', generateWorkspaceName(i)));
58
+ const workDir = resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(i)));
59
59
  try {
60
- logger.progress(current, repoName, 'Processing...');
60
+ logger.progress(current, repoName, "Processing...");
61
61
  const gitOps = new GitOps({ workDir, dryRun: options.dryRun });
62
62
  // Step 1: Clean workspace
63
- logger.info('Cleaning workspace...');
63
+ logger.info("Cleaning workspace...");
64
64
  gitOps.cleanWorkspace();
65
65
  // Step 2: Clone repo
66
- logger.info('Cloning repository...');
66
+ logger.info("Cloning repository...");
67
67
  gitOps.clone(repoInfo.gitUrl);
68
68
  // Step 3: Get default branch for PR base
69
69
  const { branch: baseBranch, method: detectionMethod } = gitOps.getDefaultBranch();
@@ -72,7 +72,9 @@ async function main() {
72
72
  logger.info(`Switching to branch: ${branchName}`);
73
73
  gitOps.createBranch(branchName);
74
74
  // Determine if creating or updating (check BEFORE writing)
75
- const action = existsSync(join(workDir, config.fileName)) ? 'update' : 'create';
75
+ const action = existsSync(join(workDir, config.fileName))
76
+ ? "update"
77
+ : "create";
76
78
  // Step 5: Write config file
77
79
  logger.info(`Writing ${config.fileName}...`);
78
80
  const fileContent = convertContentToString(repoConfig.content, config.fileName);
@@ -86,17 +88,17 @@ async function main() {
86
88
  return gitOps.hasChanges();
87
89
  })();
88
90
  if (!wouldHaveChanges) {
89
- logger.skip(current, repoName, 'No changes detected');
91
+ logger.skip(current, repoName, "No changes detected");
90
92
  continue;
91
93
  }
92
94
  // Step 7: Commit
93
- logger.info('Committing changes...');
95
+ logger.info("Committing changes...");
94
96
  gitOps.commit(`chore: sync ${config.fileName}`);
95
97
  // Step 8: Push
96
- logger.info('Pushing to remote...');
98
+ logger.info("Pushing to remote...");
97
99
  gitOps.push(branchName);
98
100
  // Step 9: Create PR
99
- logger.info('Creating pull request...');
101
+ logger.info("Creating pull request...");
100
102
  const prResult = await createPR({
101
103
  repoInfo,
102
104
  branchName,
@@ -124,6 +126,6 @@ async function main() {
124
126
  }
125
127
  }
126
128
  main().catch((error) => {
127
- console.error('Fatal error:', error);
129
+ console.error("Fatal error:", error);
128
130
  process.exit(1);
129
131
  });
package/dist/logger.js CHANGED
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import chalk from "chalk";
2
2
  export class Logger {
3
3
  stats = {
4
4
  total: 0,
@@ -10,26 +10,30 @@ export class Logger {
10
10
  this.stats.total = total;
11
11
  }
12
12
  progress(current, repoName, message) {
13
- console.log(chalk.blue(`[${current}/${this.stats.total}]`) + ` ${repoName}: ${message}`);
13
+ console.log(chalk.blue(`[${current}/${this.stats.total}]`) +
14
+ ` ${repoName}: ${message}`);
14
15
  }
15
16
  info(message) {
16
17
  console.log(chalk.gray(` ${message}`));
17
18
  }
18
19
  success(current, repoName, message) {
19
20
  this.stats.succeeded++;
20
- console.log(chalk.green(`[${current}/${this.stats.total}] ✓`) + ` ${repoName}: ${message}`);
21
+ console.log(chalk.green(`[${current}/${this.stats.total}] ✓`) +
22
+ ` ${repoName}: ${message}`);
21
23
  }
22
24
  skip(current, repoName, reason) {
23
25
  this.stats.skipped++;
24
- console.log(chalk.yellow(`[${current}/${this.stats.total}] ⊘`) + ` ${repoName}: Skipped - ${reason}`);
26
+ console.log(chalk.yellow(`[${current}/${this.stats.total}] ⊘`) +
27
+ ` ${repoName}: Skipped - ${reason}`);
25
28
  }
26
29
  error(current, repoName, error) {
27
30
  this.stats.failed++;
28
- console.log(chalk.red(`[${current}/${this.stats.total}] ✗`) + ` ${repoName}: ${error}`);
31
+ console.log(chalk.red(`[${current}/${this.stats.total}] ✗`) +
32
+ ` ${repoName}: ${error}`);
29
33
  }
30
34
  summary() {
31
- console.log('');
32
- console.log(chalk.bold('Summary:'));
35
+ console.log("");
36
+ console.log(chalk.bold("Summary:"));
33
37
  console.log(` Total: ${this.stats.total}`);
34
38
  console.log(chalk.green(` Succeeded: ${this.stats.succeeded}`));
35
39
  console.log(chalk.yellow(` Skipped: ${this.stats.skipped}`));
package/dist/merge.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Deep merge utilities for JSON configuration objects.
3
3
  * Supports configurable array merge strategies via $arrayMerge directive.
4
4
  */
5
- export type ArrayMergeStrategy = 'replace' | 'append' | 'prepend';
5
+ export type ArrayMergeStrategy = "replace" | "append" | "prepend";
6
6
  export interface MergeContext {
7
7
  arrayStrategies: Map<string, ArrayMergeStrategy>;
8
8
  defaultArrayStrategy: ArrayMergeStrategy;
package/dist/merge.js CHANGED
@@ -6,18 +6,18 @@
6
6
  * Check if a value is a plain object (not null, not array).
7
7
  */
8
8
  function isPlainObject(val) {
9
- return typeof val === 'object' && val !== null && !Array.isArray(val);
9
+ return typeof val === "object" && val !== null && !Array.isArray(val);
10
10
  }
11
11
  /**
12
12
  * Merge two arrays based on the specified strategy.
13
13
  */
14
14
  function mergeArrays(base, overlay, strategy) {
15
15
  switch (strategy) {
16
- case 'replace':
16
+ case "replace":
17
17
  return overlay;
18
- case 'append':
18
+ case "append":
19
19
  return [...base, ...overlay];
20
- case 'prepend':
20
+ case "prepend":
21
21
  return [...overlay, ...base];
22
22
  default:
23
23
  return overlay;
@@ -33,7 +33,7 @@ function extractArrayFromOverlay(overlay) {
33
33
  if (Array.isArray(overlay)) {
34
34
  return overlay;
35
35
  }
36
- if (isPlainObject(overlay) && 'values' in overlay) {
36
+ if (isPlainObject(overlay) && "values" in overlay) {
37
37
  const values = overlay.values;
38
38
  if (Array.isArray(values)) {
39
39
  return values;
@@ -45,11 +45,11 @@ function extractArrayFromOverlay(overlay) {
45
45
  * Get merge strategy from an overlay object's $arrayMerge directive.
46
46
  */
47
47
  function getStrategyFromOverlay(overlay) {
48
- if (isPlainObject(overlay) && '$arrayMerge' in overlay) {
48
+ if (isPlainObject(overlay) && "$arrayMerge" in overlay) {
49
49
  const strategy = overlay.$arrayMerge;
50
- if (strategy === 'replace' ||
51
- strategy === 'append' ||
52
- strategy === 'prepend') {
50
+ if (strategy === "replace" ||
51
+ strategy === "append" ||
52
+ strategy === "prepend") {
53
53
  return strategy;
54
54
  }
55
55
  }
@@ -63,18 +63,18 @@ function getStrategyFromOverlay(overlay) {
63
63
  * @param ctx - Merge context with array strategies
64
64
  * @param path - Current path for strategy lookup (internal)
65
65
  */
66
- export function deepMerge(base, overlay, ctx, path = '') {
66
+ export function deepMerge(base, overlay, ctx, path = "") {
67
67
  const result = { ...base };
68
68
  // Check for $arrayMerge directive at this level (applies to child arrays)
69
69
  const levelStrategy = getStrategyFromOverlay(overlay);
70
70
  for (const [key, overlayValue] of Object.entries(overlay)) {
71
71
  // Skip directive keys in output
72
- if (key.startsWith('$'))
72
+ if (key.startsWith("$"))
73
73
  continue;
74
74
  const currentPath = path ? `${path}.${key}` : key;
75
75
  const baseValue = base[key];
76
76
  // If overlay is an object with $arrayMerge directive for an array field
77
- if (isPlainObject(overlayValue) && '$arrayMerge' in overlayValue) {
77
+ if (isPlainObject(overlayValue) && "$arrayMerge" in overlayValue) {
78
78
  const strategy = getStrategyFromOverlay(overlayValue);
79
79
  const overlayArray = extractArrayFromOverlay(overlayValue);
80
80
  if (strategy && overlayArray && Array.isArray(baseValue)) {
@@ -94,13 +94,15 @@ export function deepMerge(base, overlay, ctx, path = '') {
94
94
  // Both are plain objects - recurse
95
95
  if (isPlainObject(baseValue) && isPlainObject(overlayValue)) {
96
96
  // Extract $arrayMerge for child paths if present
97
- if ('$arrayMerge' in overlayValue) {
97
+ if ("$arrayMerge" in overlayValue) {
98
98
  const childStrategy = getStrategyFromOverlay(overlayValue);
99
99
  if (childStrategy) {
100
100
  // Apply to all immediate child arrays
101
101
  for (const childKey of Object.keys(overlayValue)) {
102
- if (!childKey.startsWith('$')) {
103
- const childPath = currentPath ? `${currentPath}.${childKey}` : childKey;
102
+ if (!childKey.startsWith("$")) {
103
+ const childPath = currentPath
104
+ ? `${currentPath}.${childKey}`
105
+ : childKey;
104
106
  ctx.arrayStrategies.set(childPath, childStrategy);
105
107
  }
106
108
  }
@@ -122,7 +124,7 @@ export function stripMergeDirectives(obj) {
122
124
  const result = {};
123
125
  for (const [key, value] of Object.entries(obj)) {
124
126
  // Skip all $-prefixed keys (reserved for directives)
125
- if (key.startsWith('$'))
127
+ if (key.startsWith("$"))
126
128
  continue;
127
129
  if (isPlainObject(value)) {
128
130
  result[key] = stripMergeDirectives(value);
@@ -139,7 +141,7 @@ export function stripMergeDirectives(obj) {
139
141
  /**
140
142
  * Create a default merge context.
141
143
  */
142
- export function createMergeContext(defaultStrategy = 'replace') {
144
+ export function createMergeContext(defaultStrategy = "replace") {
143
145
  return {
144
146
  arrayStrategies: new Map(),
145
147
  defaultArrayStrategy: defaultStrategy,
@@ -1,11 +1,11 @@
1
- import { RepoInfo } from './repo-detector.js';
2
- export { escapeShellArg } from './shell-utils.js';
1
+ import { RepoInfo } from "./repo-detector.js";
2
+ export { escapeShellArg } from "./shell-utils.js";
3
3
  export interface PROptions {
4
4
  repoInfo: RepoInfo;
5
5
  branchName: string;
6
6
  baseBranch: string;
7
7
  fileName: string;
8
- action: 'create' | 'update';
8
+ action: "create" | "update";
9
9
  workDir: string;
10
10
  dryRun?: boolean;
11
11
  }
@@ -14,5 +14,5 @@ export interface PRResult {
14
14
  success: boolean;
15
15
  message: string;
16
16
  }
17
- export declare function formatPRBody(fileName: string, action: 'create' | 'update'): string;
17
+ export declare function formatPRBody(fileName: string, action: "create" | "update"): string;
18
18
  export declare function createPR(options: PROptions): Promise<PRResult>;
@@ -1,17 +1,17 @@
1
- import { execSync } from 'node:child_process';
2
- import { readFileSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
3
- import { join, dirname } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { escapeShellArg } from './shell-utils.js';
1
+ import { execSync } from "node:child_process";
2
+ import { readFileSync, existsSync, writeFileSync, unlinkSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { escapeShellArg } from "./shell-utils.js";
6
6
  // Re-export for backwards compatibility and testing
7
- export { escapeShellArg } from './shell-utils.js';
7
+ export { escapeShellArg } from "./shell-utils.js";
8
8
  function loadPRTemplate() {
9
9
  // Try to find PR.md in the project root
10
10
  const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = dirname(__filename);
12
- const templatePath = join(__dirname, '..', 'PR.md');
12
+ const templatePath = join(__dirname, "..", "PR.md");
13
13
  if (existsSync(templatePath)) {
14
- return readFileSync(templatePath, 'utf-8');
14
+ return readFileSync(templatePath, "utf-8");
15
15
  }
16
16
  // Fallback template
17
17
  return `## Summary
@@ -25,13 +25,13 @@ Automated sync of \`{{FILE_NAME}}\` configuration file.
25
25
  }
26
26
  export function formatPRBody(fileName, action) {
27
27
  const template = loadPRTemplate();
28
- const actionText = action === 'create' ? 'Created' : 'Updated';
28
+ const actionText = action === "create" ? "Created" : "Updated";
29
29
  return template
30
30
  .replace(/\{\{FILE_NAME\}\}/g, fileName)
31
31
  .replace(/\{\{ACTION\}\}/g, actionText);
32
32
  }
33
33
  export async function createPR(options) {
34
- const { repoInfo, branchName, baseBranch, fileName, action, workDir, dryRun } = options;
34
+ const { repoInfo, branchName, baseBranch, fileName, action, workDir, dryRun, } = options;
35
35
  const title = `chore: sync ${fileName}`;
36
36
  const body = formatPRBody(fileName, action);
37
37
  if (dryRun) {
@@ -41,8 +41,14 @@ export async function createPR(options) {
41
41
  };
42
42
  }
43
43
  try {
44
- if (repoInfo.type === 'github') {
45
- return await createGitHubPR({ title, body, branchName, baseBranch, workDir });
44
+ if (repoInfo.type === "github") {
45
+ return await createGitHubPR({
46
+ title,
47
+ body,
48
+ branchName,
49
+ baseBranch,
50
+ workDir,
51
+ });
46
52
  }
47
53
  else {
48
54
  return await createAzureDevOpsPR({
@@ -51,7 +57,7 @@ export async function createPR(options) {
51
57
  branchName,
52
58
  baseBranch,
53
59
  repoInfo,
54
- workDir
60
+ workDir,
55
61
  });
56
62
  }
57
63
  }
@@ -67,7 +73,7 @@ async function createGitHubPR(options) {
67
73
  const { title, body, branchName, baseBranch, workDir } = options;
68
74
  // Check if PR already exists
69
75
  try {
70
- const existingPR = execSync(`gh pr list --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`, { cwd: workDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
76
+ const existingPR = execSync(`gh pr list --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`, { cwd: workDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
71
77
  if (existingPR) {
72
78
  return {
73
79
  url: existingPR,
@@ -80,16 +86,16 @@ async function createGitHubPR(options) {
80
86
  // No existing PR, continue to create
81
87
  }
82
88
  // Write body to temp file to avoid shell escaping issues
83
- const bodyFile = join(workDir, '.pr-body.md');
84
- writeFileSync(bodyFile, body, 'utf-8');
89
+ const bodyFile = join(workDir, ".pr-body.md");
90
+ writeFileSync(bodyFile, body, "utf-8");
85
91
  try {
86
- const result = execSync(`gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`, { cwd: workDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
92
+ const result = execSync(`gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`, { cwd: workDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
87
93
  // Extract URL from output
88
94
  const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
89
95
  return {
90
96
  url: urlMatch?.[0] ?? result,
91
97
  success: true,
92
- message: 'PR created successfully',
98
+ message: "PR created successfully",
93
99
  };
94
100
  }
95
101
  finally {
@@ -101,12 +107,12 @@ async function createGitHubPR(options) {
101
107
  }
102
108
  async function createAzureDevOpsPR(options) {
103
109
  const { title, body, branchName, baseBranch, repoInfo, workDir } = options;
104
- const orgUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? '')}`;
110
+ const orgUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? "")}`;
105
111
  // Check if PR already exists
106
112
  try {
107
- const existingPRs = execSync(`az repos pr list --repository ${escapeShellArg(repoInfo.repo)} --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(repoInfo.project ?? '')} --query "[0].pullRequestId" -o tsv`, { cwd: workDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
113
+ const existingPRs = execSync(`az repos pr list --repository ${escapeShellArg(repoInfo.repo)} --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(repoInfo.project ?? "")} --query "[0].pullRequestId" -o tsv`, { cwd: workDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
108
114
  if (existingPRs) {
109
- const prUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? '')}/${encodeURIComponent(repoInfo.project ?? '')}/_git/${encodeURIComponent(repoInfo.repo)}/pullrequest/${existingPRs}`;
115
+ const prUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? "")}/${encodeURIComponent(repoInfo.project ?? "")}/_git/${encodeURIComponent(repoInfo.repo)}/pullrequest/${existingPRs}`;
110
116
  return {
111
117
  url: prUrl,
112
118
  success: true,
@@ -118,15 +124,15 @@ async function createAzureDevOpsPR(options) {
118
124
  // No existing PR, continue to create
119
125
  }
120
126
  // Write description to temp file to avoid shell escaping issues
121
- const descFile = join(workDir, '.pr-description.md');
122
- writeFileSync(descFile, body, 'utf-8');
127
+ const descFile = join(workDir, ".pr-description.md");
128
+ writeFileSync(descFile, body, "utf-8");
123
129
  try {
124
- const result = execSync(`az repos pr create --repository ${escapeShellArg(repoInfo.repo)} --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --title ${escapeShellArg(title)} --description @${escapeShellArg(descFile)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(repoInfo.project ?? '')} --query "pullRequestId" -o tsv`, { cwd: workDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
125
- const prUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? '')}/${encodeURIComponent(repoInfo.project ?? '')}/_git/${encodeURIComponent(repoInfo.repo)}/pullrequest/${result}`;
130
+ const result = execSync(`az repos pr create --repository ${escapeShellArg(repoInfo.repo)} --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --title ${escapeShellArg(title)} --description @${escapeShellArg(descFile)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(repoInfo.project ?? "")} --query "pullRequestId" -o tsv`, { cwd: workDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
131
+ const prUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? "")}/${encodeURIComponent(repoInfo.project ?? "")}/_git/${encodeURIComponent(repoInfo.repo)}/pullrequest/${result}`;
126
132
  return {
127
133
  url: prUrl,
128
134
  success: true,
129
- message: 'PR created successfully',
135
+ message: "PR created successfully",
130
136
  };
131
137
  }
132
138
  finally {
@@ -1,4 +1,4 @@
1
- export type RepoType = 'github' | 'azure-devops';
1
+ export type RepoType = "github" | "azure-devops";
2
2
  export interface RepoInfo {
3
3
  type: RepoType;
4
4
  gitUrl: string;
@@ -2,17 +2,17 @@ export function detectRepoType(gitUrl) {
2
2
  // Check for Azure DevOps SSH format: git@ssh.dev.azure.com:...
3
3
  // Use broader pattern to catch malformed Azure URLs
4
4
  if (/^git@ssh\.dev\.azure\.com:/.test(gitUrl)) {
5
- return 'azure-devops';
5
+ return "azure-devops";
6
6
  }
7
7
  // Check for Azure DevOps HTTPS format: https://dev.azure.com/...
8
8
  if (/^https?:\/\/dev\.azure\.com\//.test(gitUrl)) {
9
- return 'azure-devops';
9
+ return "azure-devops";
10
10
  }
11
- return 'github';
11
+ return "github";
12
12
  }
13
13
  export function parseGitUrl(gitUrl) {
14
14
  const type = detectRepoType(gitUrl);
15
- if (type === 'azure-devops') {
15
+ if (type === "azure-devops") {
16
16
  return parseAzureDevOpsUrl(gitUrl);
17
17
  }
18
18
  return parseGitHubUrl(gitUrl);
@@ -23,7 +23,7 @@ function parseGitHubUrl(gitUrl) {
23
23
  const sshMatch = gitUrl.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
24
24
  if (sshMatch) {
25
25
  return {
26
- type: 'github',
26
+ type: "github",
27
27
  gitUrl,
28
28
  owner: sshMatch[1],
29
29
  repo: sshMatch[2],
@@ -34,7 +34,7 @@ function parseGitHubUrl(gitUrl) {
34
34
  const httpsMatch = gitUrl.match(/https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
35
35
  if (httpsMatch) {
36
36
  return {
37
- type: 'github',
37
+ type: "github",
38
38
  gitUrl,
39
39
  owner: httpsMatch[1],
40
40
  repo: httpsMatch[2],
@@ -48,7 +48,7 @@ function parseAzureDevOpsUrl(gitUrl) {
48
48
  const sshMatch = gitUrl.match(/git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/);
49
49
  if (sshMatch) {
50
50
  return {
51
- type: 'azure-devops',
51
+ type: "azure-devops",
52
52
  gitUrl,
53
53
  owner: sshMatch[1],
54
54
  repo: sshMatch[3],
@@ -61,7 +61,7 @@ function parseAzureDevOpsUrl(gitUrl) {
61
61
  const httpsMatch = gitUrl.match(/https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/(.+?)(?:\.git)?$/);
62
62
  if (httpsMatch) {
63
63
  return {
64
- type: 'azure-devops',
64
+ type: "azure-devops",
65
65
  gitUrl,
66
66
  owner: httpsMatch[1],
67
67
  repo: httpsMatch[3],
@@ -72,7 +72,7 @@ function parseAzureDevOpsUrl(gitUrl) {
72
72
  throw new Error(`Unable to parse Azure DevOps URL: ${gitUrl}`);
73
73
  }
74
74
  export function getRepoDisplayName(repoInfo) {
75
- if (repoInfo.type === 'azure-devops') {
75
+ if (repoInfo.type === "azure-devops") {
76
76
  return `${repoInfo.organization}/${repoInfo.project}/${repoInfo.repo}`;
77
77
  }
78
78
  return `${repoInfo.owner}/${repoInfo.repo}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "CLI tool to sync JSON or YAML configuration files across multiple GitHub and Azure DevOps repositories",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",