@aspruyt/json-config-sync 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anthony
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PR.md ADDED
@@ -0,0 +1,14 @@
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)*
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # json-config-sync
2
+
3
+ [![CI](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
4
+ [![Integration Test](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration.yml/badge.svg)](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration.yml)
5
+
6
+ A CLI tool that syncs JSON configuration files across multiple Git repositories by creating pull requests.
7
+
8
+ ## Features
9
+
10
+ - Reads configuration from a YAML file
11
+ - Supports both GitHub and Azure DevOps repositories
12
+ - Creates PRs automatically using `gh` CLI (GitHub) or `az` CLI (Azure DevOps)
13
+ - Continues processing if individual repos fail
14
+ - Supports dry-run mode for testing
15
+ - Progress logging with summary report
16
+
17
+ ## Installation
18
+
19
+ ### From npm
20
+
21
+ ```bash
22
+ npm install -g @aspruyt/json-config-sync
23
+ ```
24
+
25
+ ### From Source
26
+
27
+ ```bash
28
+ git clone https://github.com/anthony-spruyt/json-config-sync.git
29
+ cd json-config-sync
30
+ npm install
31
+ npm run build
32
+ ```
33
+
34
+ ### Using Dev Container
35
+
36
+ Open this repository in VS Code with the Dev Containers extension. The container includes all dependencies pre-installed and the project pre-built.
37
+
38
+ ## Prerequisites
39
+
40
+ ### GitHub Authentication
41
+
42
+ Before using with GitHub repositories, authenticate with the GitHub CLI:
43
+
44
+ ```bash
45
+ gh auth login
46
+ ```
47
+
48
+ ### Azure DevOps Authentication
49
+
50
+ Before using with Azure DevOps repositories, authenticate with the Azure CLI:
51
+
52
+ ```bash
53
+ az login
54
+ az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG project=YOUR_PROJECT
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```bash
60
+ # Basic usage
61
+ json-config-sync --config ./config.yaml
62
+
63
+ # Dry run (no changes made)
64
+ json-config-sync --config ./config.yaml --dry-run
65
+
66
+ # Custom work directory
67
+ json-config-sync --config ./config.yaml --work-dir ./my-temp
68
+ ```
69
+
70
+ ### Options
71
+
72
+ | Option | Alias | Description | Required |
73
+ |--------|-------|-------------|----------|
74
+ | `--config` | `-c` | Path to YAML config file | Yes |
75
+ | `--dry-run` | `-d` | Show what would be done without making changes | No |
76
+ | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
77
+
78
+ ## Configuration Format
79
+
80
+ Create a YAML file with the following structure:
81
+
82
+ ```yaml
83
+ fileName: my.config.json
84
+ repos:
85
+ - git: git@github.com:example-org/repo1.git
86
+ json:
87
+ setting1: value1
88
+ nested:
89
+ setting2: value2
90
+ - git: git@ssh.dev.azure.com:v3/example-org/project/repo2
91
+ json:
92
+ setting1: differentValue
93
+ setting3: value
94
+ ```
95
+
96
+ ### Fields
97
+
98
+ | Field | Description |
99
+ |-------|-------------|
100
+ | `fileName` | The name of the JSON file to create/update in each repo |
101
+ | `repos` | Array of repository configurations |
102
+ | `repos[].git` | Git URL of the repository (SSH or HTTPS) |
103
+ | `repos[].json` | The JSON content to write to the file |
104
+
105
+ ## Supported Git URL Formats
106
+
107
+ ### GitHub
108
+ - SSH: `git@github.com:owner/repo.git`
109
+ - HTTPS: `https://github.com/owner/repo.git`
110
+
111
+ ### Azure DevOps
112
+ - SSH: `git@ssh.dev.azure.com:v3/organization/project/repo`
113
+ - HTTPS: `https://dev.azure.com/organization/project/_git/repo`
114
+
115
+ ## Workflow
116
+
117
+ For each repository in the config, the tool:
118
+
119
+ 1. Cleans the temporary workspace
120
+ 2. Detects if repo is GitHub or Azure DevOps
121
+ 3. Clones the repository
122
+ 4. Creates/checks out branch `chore/sync-{sanitized-filename}`
123
+ 5. Generates the JSON file from config
124
+ 6. Checks for changes (skips if no changes)
125
+ 7. Commits and pushes changes
126
+ 8. Creates a pull request
127
+
128
+ ## Example
129
+
130
+ Given this config file (`config.yaml`):
131
+
132
+ ```yaml
133
+ fileName: my.config.json
134
+ repos:
135
+ - git: git@github.com:example-org/my-service.git
136
+ json:
137
+ environment: production
138
+ settings:
139
+ feature1: true
140
+ feature2: false
141
+ ```
142
+
143
+ Running:
144
+
145
+ ```bash
146
+ json-config-sync --config ./config.yaml
147
+ ```
148
+
149
+ Will:
150
+ 1. Clone `example-org/my-service`
151
+ 2. Create branch `chore/sync-my-config`
152
+ 3. Write `my.config.json` with the specified content
153
+ 4. Create a PR titled "chore: sync my.config.json"
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ # Run in development mode
159
+ npm run dev -- --config ./fixtures/test-repos-input.yaml --dry-run
160
+
161
+ # Run tests
162
+ npm test
163
+
164
+ # Build
165
+ npm run build
166
+ ```
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,10 @@
1
+ export interface RepoConfig {
2
+ git: string;
3
+ json: Record<string, unknown>;
4
+ }
5
+ export interface Config {
6
+ fileName: string;
7
+ repos: RepoConfig[];
8
+ }
9
+ export declare function loadConfig(filePath: string): Config;
10
+ export declare function convertJsonToString(json: Record<string, unknown>): string;
package/dist/config.js ADDED
@@ -0,0 +1,25 @@
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);
6
+ if (!config.fileName) {
7
+ throw new Error('Config missing required field: fileName');
8
+ }
9
+ if (!config.repos || !Array.isArray(config.repos)) {
10
+ throw new Error('Config missing required field: repos (must be an array)');
11
+ }
12
+ for (let i = 0; i < config.repos.length; i++) {
13
+ const repo = config.repos[i];
14
+ if (!repo.git) {
15
+ throw new Error(`Repo at index ${i} missing required field: git`);
16
+ }
17
+ if (!repo.json) {
18
+ throw new Error(`Repo at index ${i} missing required field: json`);
19
+ }
20
+ }
21
+ return config;
22
+ }
23
+ export function convertJsonToString(json) {
24
+ return JSON.stringify(json, null, 2);
25
+ }
@@ -0,0 +1,19 @@
1
+ export interface GitOpsOptions {
2
+ workDir: string;
3
+ dryRun?: boolean;
4
+ }
5
+ export declare class GitOps {
6
+ private workDir;
7
+ private dryRun;
8
+ constructor(options: GitOpsOptions);
9
+ private exec;
10
+ cleanWorkspace(): void;
11
+ clone(gitUrl: string): void;
12
+ createBranch(branchName: string): void;
13
+ writeFile(fileName: string, content: string): void;
14
+ hasChanges(): boolean;
15
+ commit(message: string): void;
16
+ push(branchName: string): void;
17
+ getDefaultBranch(): string;
18
+ }
19
+ export declare function sanitizeBranchName(fileName: string): string;
@@ -0,0 +1,96 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { rmSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ export class GitOps {
5
+ workDir;
6
+ dryRun;
7
+ constructor(options) {
8
+ this.workDir = options.workDir;
9
+ this.dryRun = options.dryRun ?? false;
10
+ }
11
+ exec(command, cwd) {
12
+ return execSync(command, {
13
+ cwd: cwd ?? this.workDir,
14
+ encoding: 'utf-8',
15
+ stdio: ['pipe', 'pipe', 'pipe'],
16
+ }).trim();
17
+ }
18
+ cleanWorkspace() {
19
+ if (existsSync(this.workDir)) {
20
+ rmSync(this.workDir, { recursive: true, force: true });
21
+ }
22
+ mkdirSync(this.workDir, { recursive: true });
23
+ }
24
+ clone(gitUrl) {
25
+ this.exec(`git clone "${gitUrl}" .`, this.workDir);
26
+ }
27
+ createBranch(branchName) {
28
+ try {
29
+ // Check if branch exists on remote
30
+ this.exec(`git fetch origin ${branchName}`, this.workDir);
31
+ this.exec(`git checkout ${branchName}`, this.workDir);
32
+ }
33
+ catch {
34
+ // Branch doesn't exist, create it
35
+ this.exec(`git checkout -b ${branchName}`, this.workDir);
36
+ }
37
+ }
38
+ writeFile(fileName, content) {
39
+ const filePath = join(this.workDir, fileName);
40
+ writeFileSync(filePath, content + '\n', 'utf-8');
41
+ }
42
+ hasChanges() {
43
+ const status = this.exec('git status --porcelain', this.workDir);
44
+ return status.length > 0;
45
+ }
46
+ commit(message) {
47
+ if (this.dryRun) {
48
+ return;
49
+ }
50
+ this.exec('git add -A', this.workDir);
51
+ this.exec(`git commit -m "${message}"`, this.workDir);
52
+ }
53
+ push(branchName) {
54
+ if (this.dryRun) {
55
+ return;
56
+ }
57
+ this.exec(`git push -u origin ${branchName}`, this.workDir);
58
+ }
59
+ getDefaultBranch() {
60
+ try {
61
+ // Try to get the default branch from remote
62
+ const remoteInfo = this.exec('git remote show origin', this.workDir);
63
+ const match = remoteInfo.match(/HEAD branch: (\S+)/);
64
+ if (match) {
65
+ return match[1];
66
+ }
67
+ }
68
+ catch {
69
+ // Fallback methods
70
+ }
71
+ // Try common default branch names
72
+ try {
73
+ this.exec('git rev-parse --verify origin/main', this.workDir);
74
+ return 'main';
75
+ }
76
+ catch {
77
+ // Try master
78
+ }
79
+ try {
80
+ this.exec('git rev-parse --verify origin/master', this.workDir);
81
+ return 'master';
82
+ }
83
+ catch {
84
+ // Default to main
85
+ }
86
+ return 'main';
87
+ }
88
+ }
89
+ export function sanitizeBranchName(fileName) {
90
+ return fileName
91
+ .toLowerCase()
92
+ .replace(/\.[^.]+$/, '') // Remove extension
93
+ .replace(/[^a-z0-9-]/g, '-') // Replace non-alphanumeric with dashes
94
+ .replace(/-+/g, '-') // Collapse multiple dashes
95
+ .replace(/^-|-$/g, ''); // Remove leading/trailing dashes
96
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,112 @@
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 { loadConfig, convertJsonToString } from './config.js';
6
+ import { parseGitUrl, getRepoDisplayName } from './repo-detector.js';
7
+ import { GitOps, sanitizeBranchName } from './git-ops.js';
8
+ import { createPR } from './pr-creator.js';
9
+ import { logger } from './logger.js';
10
+ program
11
+ .name('json-config-sync')
12
+ .description('Sync JSON configuration files across multiple repositories')
13
+ .version('1.0.0')
14
+ .requiredOption('-c, --config <path>', 'Path to YAML config file')
15
+ .option('-d, --dry-run', 'Show what would be done without making changes')
16
+ .option('-w, --work-dir <path>', 'Temporary directory for cloning', './tmp')
17
+ .parse();
18
+ const options = program.opts();
19
+ async function main() {
20
+ const configPath = resolve(options.config);
21
+ if (!existsSync(configPath)) {
22
+ console.error(`Config file not found: ${configPath}`);
23
+ process.exit(1);
24
+ }
25
+ console.log(`Loading config from: ${configPath}`);
26
+ if (options.dryRun) {
27
+ console.log('Running in DRY RUN mode - no changes will be made\n');
28
+ }
29
+ const config = loadConfig(configPath);
30
+ const branchName = `chore/sync-${sanitizeBranchName(config.fileName)}`;
31
+ logger.setTotal(config.repos.length);
32
+ console.log(`Found ${config.repos.length} repositories to process`);
33
+ console.log(`Target file: ${config.fileName}`);
34
+ console.log(`Branch: ${branchName}\n`);
35
+ for (let i = 0; i < config.repos.length; i++) {
36
+ const repoConfig = config.repos[i];
37
+ const current = i + 1;
38
+ let repoInfo;
39
+ try {
40
+ repoInfo = parseGitUrl(repoConfig.git);
41
+ }
42
+ catch (error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ logger.error(current, repoConfig.git, message);
45
+ continue;
46
+ }
47
+ const repoName = getRepoDisplayName(repoInfo);
48
+ const workDir = resolve(join(options.workDir ?? './tmp', `repo-${i}`));
49
+ try {
50
+ logger.progress(current, repoName, 'Processing...');
51
+ const gitOps = new GitOps({ workDir, dryRun: options.dryRun });
52
+ // Step 1: Clean workspace
53
+ logger.info('Cleaning workspace...');
54
+ gitOps.cleanWorkspace();
55
+ // Step 2: Clone repo
56
+ logger.info('Cloning repository...');
57
+ gitOps.clone(repoInfo.gitUrl);
58
+ // Step 3: Get default branch for PR base
59
+ const baseBranch = gitOps.getDefaultBranch();
60
+ logger.info(`Default branch: ${baseBranch}`);
61
+ // Step 4: Create/checkout branch
62
+ logger.info(`Switching to branch: ${branchName}`);
63
+ gitOps.createBranch(branchName);
64
+ // Step 5: Write JSON file
65
+ logger.info(`Writing ${config.fileName}...`);
66
+ const jsonContent = convertJsonToString(repoConfig.json);
67
+ gitOps.writeFile(config.fileName, jsonContent);
68
+ // Step 6: Check for changes
69
+ if (!gitOps.hasChanges()) {
70
+ logger.skip(current, repoName, 'No changes detected');
71
+ continue;
72
+ }
73
+ // Determine if creating or updating
74
+ const action = existsSync(join(workDir, config.fileName)) ? 'update' : 'create';
75
+ // Step 7: Commit
76
+ logger.info('Committing changes...');
77
+ gitOps.commit(`chore: sync ${config.fileName}`);
78
+ // Step 8: Push
79
+ logger.info('Pushing to remote...');
80
+ gitOps.push(branchName);
81
+ // Step 9: Create PR
82
+ logger.info('Creating pull request...');
83
+ const prResult = await createPR({
84
+ repoInfo,
85
+ branchName,
86
+ baseBranch,
87
+ fileName: config.fileName,
88
+ action,
89
+ workDir,
90
+ dryRun: options.dryRun,
91
+ });
92
+ if (prResult.success) {
93
+ logger.success(current, repoName, prResult.url ? `PR: ${prResult.url}` : prResult.message);
94
+ }
95
+ else {
96
+ logger.error(current, repoName, prResult.message);
97
+ }
98
+ }
99
+ catch (error) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ logger.error(current, repoName, message);
102
+ }
103
+ }
104
+ logger.summary();
105
+ if (logger.hasFailures()) {
106
+ process.exit(1);
107
+ }
108
+ }
109
+ main().catch((error) => {
110
+ console.error('Fatal error:', error);
111
+ process.exit(1);
112
+ });
@@ -0,0 +1,18 @@
1
+ export interface LoggerStats {
2
+ total: number;
3
+ succeeded: number;
4
+ failed: number;
5
+ skipped: number;
6
+ }
7
+ export declare class Logger {
8
+ private stats;
9
+ setTotal(total: number): void;
10
+ progress(current: number, repoName: string, message: string): void;
11
+ info(message: string): void;
12
+ success(current: number, repoName: string, message: string): void;
13
+ skip(current: number, repoName: string, reason: string): void;
14
+ error(current: number, repoName: string, error: string): void;
15
+ summary(): void;
16
+ hasFailures(): boolean;
17
+ }
18
+ export declare const logger: Logger;
package/dist/logger.js ADDED
@@ -0,0 +1,42 @@
1
+ import chalk from 'chalk';
2
+ export class Logger {
3
+ stats = {
4
+ total: 0,
5
+ succeeded: 0,
6
+ failed: 0,
7
+ skipped: 0,
8
+ };
9
+ setTotal(total) {
10
+ this.stats.total = total;
11
+ }
12
+ progress(current, repoName, message) {
13
+ console.log(chalk.blue(`[${current}/${this.stats.total}]`) + ` ${repoName}: ${message}`);
14
+ }
15
+ info(message) {
16
+ console.log(chalk.gray(` ${message}`));
17
+ }
18
+ success(current, repoName, message) {
19
+ this.stats.succeeded++;
20
+ console.log(chalk.green(`[${current}/${this.stats.total}] ✓`) + ` ${repoName}: ${message}`);
21
+ }
22
+ skip(current, repoName, reason) {
23
+ this.stats.skipped++;
24
+ console.log(chalk.yellow(`[${current}/${this.stats.total}] ⊘`) + ` ${repoName}: Skipped - ${reason}`);
25
+ }
26
+ error(current, repoName, error) {
27
+ this.stats.failed++;
28
+ console.log(chalk.red(`[${current}/${this.stats.total}] ✗`) + ` ${repoName}: ${error}`);
29
+ }
30
+ summary() {
31
+ console.log('');
32
+ console.log(chalk.bold('Summary:'));
33
+ console.log(` Total: ${this.stats.total}`);
34
+ console.log(chalk.green(` Succeeded: ${this.stats.succeeded}`));
35
+ console.log(chalk.yellow(` Skipped: ${this.stats.skipped}`));
36
+ console.log(chalk.red(` Failed: ${this.stats.failed}`));
37
+ }
38
+ hasFailures() {
39
+ return this.stats.failed > 0;
40
+ }
41
+ }
42
+ export const logger = new Logger();
@@ -0,0 +1,16 @@
1
+ import { RepoInfo } from './repo-detector.js';
2
+ export interface PROptions {
3
+ repoInfo: RepoInfo;
4
+ branchName: string;
5
+ baseBranch: string;
6
+ fileName: string;
7
+ action: 'create' | 'update';
8
+ workDir: string;
9
+ dryRun?: boolean;
10
+ }
11
+ export interface PRResult {
12
+ url?: string;
13
+ success: boolean;
14
+ message: string;
15
+ }
16
+ export declare function createPR(options: PROptions): Promise<PRResult>;
@@ -0,0 +1,139 @@
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
+ function escapeShellArg(arg) {
6
+ // Use single quotes and escape any single quotes within
7
+ return `'${arg.replace(/'/g, "'\\''")}'`;
8
+ }
9
+ function loadPRTemplate() {
10
+ // Try to find PR.md in the project root
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+ const templatePath = join(__dirname, '..', 'PR.md');
14
+ if (existsSync(templatePath)) {
15
+ return readFileSync(templatePath, 'utf-8');
16
+ }
17
+ // Fallback template
18
+ return `## Summary
19
+ Automated sync of \`{{FILE_NAME}}\` configuration file.
20
+
21
+ ## Changes
22
+ - {{ACTION}} \`{{FILE_NAME}}\` in repository root
23
+
24
+ ---
25
+ *This PR was automatically generated by json-config-sync*`;
26
+ }
27
+ function formatPRBody(fileName, action) {
28
+ const template = loadPRTemplate();
29
+ const actionText = action === 'create' ? 'Created' : 'Updated';
30
+ return template
31
+ .replace(/\{\{FILE_NAME\}\}/g, fileName)
32
+ .replace(/\{\{ACTION\}\}/g, actionText);
33
+ }
34
+ export async function createPR(options) {
35
+ const { repoInfo, branchName, baseBranch, fileName, action, workDir, dryRun } = options;
36
+ const title = `chore: sync ${fileName}`;
37
+ const body = formatPRBody(fileName, action);
38
+ if (dryRun) {
39
+ return {
40
+ success: true,
41
+ message: `[DRY RUN] Would create PR: "${title}"`,
42
+ };
43
+ }
44
+ try {
45
+ if (repoInfo.type === 'github') {
46
+ return await createGitHubPR({ title, body, branchName, baseBranch, workDir });
47
+ }
48
+ else {
49
+ return await createAzureDevOpsPR({
50
+ title,
51
+ body,
52
+ branchName,
53
+ baseBranch,
54
+ repoInfo,
55
+ workDir
56
+ });
57
+ }
58
+ }
59
+ catch (error) {
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ return {
62
+ success: false,
63
+ message: `Failed to create PR: ${message}`,
64
+ };
65
+ }
66
+ }
67
+ async function createGitHubPR(options) {
68
+ const { title, body, branchName, baseBranch, workDir } = options;
69
+ // Check if PR already exists
70
+ try {
71
+ const existingPR = execSync(`gh pr list --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`, { cwd: workDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
72
+ if (existingPR) {
73
+ return {
74
+ url: existingPR,
75
+ success: true,
76
+ message: `PR already exists: ${existingPR}`,
77
+ };
78
+ }
79
+ }
80
+ catch {
81
+ // No existing PR, continue to create
82
+ }
83
+ // Write body to temp file to avoid shell escaping issues
84
+ const bodyFile = join(workDir, '.pr-body.md');
85
+ writeFileSync(bodyFile, body, 'utf-8');
86
+ try {
87
+ 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();
88
+ // Extract URL from output
89
+ const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
90
+ return {
91
+ url: urlMatch?.[0] ?? result,
92
+ success: true,
93
+ message: 'PR created successfully',
94
+ };
95
+ }
96
+ finally {
97
+ // Clean up temp file
98
+ if (existsSync(bodyFile)) {
99
+ unlinkSync(bodyFile);
100
+ }
101
+ }
102
+ }
103
+ async function createAzureDevOpsPR(options) {
104
+ const { title, body, branchName, baseBranch, repoInfo, workDir } = options;
105
+ const orgUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? '')}`;
106
+ // Check if PR already exists
107
+ try {
108
+ 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();
109
+ if (existingPRs) {
110
+ const prUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? '')}/${encodeURIComponent(repoInfo.project ?? '')}/_git/${encodeURIComponent(repoInfo.repo)}/pullrequest/${existingPRs}`;
111
+ return {
112
+ url: prUrl,
113
+ success: true,
114
+ message: `PR already exists: ${prUrl}`,
115
+ };
116
+ }
117
+ }
118
+ catch {
119
+ // No existing PR, continue to create
120
+ }
121
+ // Write description to temp file to avoid shell escaping issues
122
+ const descFile = join(workDir, '.pr-description.md');
123
+ writeFileSync(descFile, body, 'utf-8');
124
+ try {
125
+ 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();
126
+ const prUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? '')}/${encodeURIComponent(repoInfo.project ?? '')}/_git/${encodeURIComponent(repoInfo.repo)}/pullrequest/${result}`;
127
+ return {
128
+ url: prUrl,
129
+ success: true,
130
+ message: 'PR created successfully',
131
+ };
132
+ }
133
+ finally {
134
+ // Clean up temp file
135
+ if (existsSync(descFile)) {
136
+ unlinkSync(descFile);
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,12 @@
1
+ export type RepoType = 'github' | 'azure-devops';
2
+ export interface RepoInfo {
3
+ type: RepoType;
4
+ gitUrl: string;
5
+ owner: string;
6
+ repo: string;
7
+ organization?: string;
8
+ project?: string;
9
+ }
10
+ export declare function detectRepoType(gitUrl: string): RepoType;
11
+ export declare function parseGitUrl(gitUrl: string): RepoInfo;
12
+ export declare function getRepoDisplayName(repoInfo: RepoInfo): string;
@@ -0,0 +1,75 @@
1
+ export function detectRepoType(gitUrl) {
2
+ // Use strict pattern matching to prevent URL substring attacks
3
+ // Check for Azure DevOps SSH format: git@ssh.dev.azure.com:v3/...
4
+ if (/^git@ssh\.dev\.azure\.com:v3\//.test(gitUrl)) {
5
+ return 'azure-devops';
6
+ }
7
+ // Check for Azure DevOps HTTPS format: https://dev.azure.com/...
8
+ if (/^https?:\/\/dev\.azure\.com\//.test(gitUrl)) {
9
+ return 'azure-devops';
10
+ }
11
+ return 'github';
12
+ }
13
+ export function parseGitUrl(gitUrl) {
14
+ const type = detectRepoType(gitUrl);
15
+ if (type === 'azure-devops') {
16
+ return parseAzureDevOpsUrl(gitUrl);
17
+ }
18
+ return parseGitHubUrl(gitUrl);
19
+ }
20
+ function parseGitHubUrl(gitUrl) {
21
+ // Handle SSH format: git@github.com:owner/repo.git
22
+ const sshMatch = gitUrl.match(/git@github\.com:([^/]+)\/([^.]+)(?:\.git)?/);
23
+ if (sshMatch) {
24
+ return {
25
+ type: 'github',
26
+ gitUrl,
27
+ owner: sshMatch[1],
28
+ repo: sshMatch[2],
29
+ };
30
+ }
31
+ // Handle HTTPS format: https://github.com/owner/repo.git
32
+ const httpsMatch = gitUrl.match(/https?:\/\/github\.com\/([^/]+)\/([^.]+)(?:\.git)?/);
33
+ if (httpsMatch) {
34
+ return {
35
+ type: 'github',
36
+ gitUrl,
37
+ owner: httpsMatch[1],
38
+ repo: httpsMatch[2],
39
+ };
40
+ }
41
+ throw new Error(`Unable to parse GitHub URL: ${gitUrl}`);
42
+ }
43
+ function parseAzureDevOpsUrl(gitUrl) {
44
+ // Handle SSH format: git@ssh.dev.azure.com:v3/organization/project/repo
45
+ const sshMatch = gitUrl.match(/git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^.]+)/);
46
+ if (sshMatch) {
47
+ return {
48
+ type: 'azure-devops',
49
+ gitUrl,
50
+ owner: sshMatch[1],
51
+ repo: sshMatch[3],
52
+ organization: sshMatch[1],
53
+ project: sshMatch[2],
54
+ };
55
+ }
56
+ // Handle HTTPS format: https://dev.azure.com/organization/project/_git/repo
57
+ const httpsMatch = gitUrl.match(/https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^.]+)/);
58
+ if (httpsMatch) {
59
+ return {
60
+ type: 'azure-devops',
61
+ gitUrl,
62
+ owner: httpsMatch[1],
63
+ repo: httpsMatch[3],
64
+ organization: httpsMatch[1],
65
+ project: httpsMatch[2],
66
+ };
67
+ }
68
+ throw new Error(`Unable to parse Azure DevOps URL: ${gitUrl}`);
69
+ }
70
+ export function getRepoDisplayName(repoInfo) {
71
+ if (repoInfo.type === 'azure-devops') {
72
+ return `${repoInfo.organization}/${repoInfo.project}/${repoInfo.repo}`;
73
+ }
74
+ return `${repoInfo.owner}/${repoInfo.repo}`;
75
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@aspruyt/json-config-sync",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool to sync JSON configuration files across multiple repositories",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "json-config-sync": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "PR.md"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/anthony-spruyt/json-config-sync.git"
17
+ },
18
+ "homepage": "https://github.com/anthony-spruyt/json-config-sync#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/anthony-spruyt/json-config-sync/issues"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "start": "node dist/index.js",
28
+ "dev": "ts-node src/index.ts",
29
+ "test": "node --import tsx --test src/config.test.ts",
30
+ "test:integration": "npm run build && node --import tsx --test src/integration.test.ts",
31
+ "prepublishOnly": "npm run build",
32
+ "release:patch": "npm version patch && git push --follow-tags",
33
+ "release:minor": "npm version minor && git push --follow-tags",
34
+ "release:major": "npm version major && git push --follow-tags"
35
+ },
36
+ "keywords": [
37
+ "config",
38
+ "sync",
39
+ "json",
40
+ "yaml",
41
+ "git",
42
+ "cli",
43
+ "github",
44
+ "azure-devops",
45
+ "pull-request"
46
+ ],
47
+ "author": "Anthony Spruyt",
48
+ "license": "MIT",
49
+ "dependencies": {
50
+ "chalk": "^5.3.0",
51
+ "commander": "^14.0.2",
52
+ "yaml": "^2.4.5"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^25.0.7",
56
+ "tsx": "^4.15.0",
57
+ "typescript": "^5.4.5"
58
+ }
59
+ }