@aspruyt/json-config-sync 2.0.0 → 2.0.2
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/dist/git-ops.d.ts +9 -1
- package/dist/git-ops.js +35 -11
- package/dist/index.js +24 -7
- package/dist/pr-creator.d.ts +2 -0
- package/dist/pr-creator.js +4 -5
- package/dist/repo-detector.js +11 -7
- package/dist/shell-utils.d.ts +8 -0
- package/dist/shell-utils.js +12 -0
- package/package.json +4 -7
package/dist/git-ops.d.ts
CHANGED
|
@@ -11,9 +11,17 @@ export declare class GitOps {
|
|
|
11
11
|
clone(gitUrl: string): void;
|
|
12
12
|
createBranch(branchName: string): void;
|
|
13
13
|
writeFile(fileName: string, content: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* Checks if writing the given content would result in changes.
|
|
16
|
+
* Works in both normal and dry-run modes by comparing content directly.
|
|
17
|
+
*/
|
|
18
|
+
wouldChange(fileName: string, content: string): boolean;
|
|
14
19
|
hasChanges(): boolean;
|
|
15
20
|
commit(message: string): void;
|
|
16
21
|
push(branchName: string): void;
|
|
17
|
-
getDefaultBranch():
|
|
22
|
+
getDefaultBranch(): {
|
|
23
|
+
branch: string;
|
|
24
|
+
method: string;
|
|
25
|
+
};
|
|
18
26
|
}
|
|
19
27
|
export declare function sanitizeBranchName(fileName: string): string;
|
package/dist/git-ops.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
import { rmSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { escapeShellArg } from './shell-utils.js';
|
|
4
5
|
export class GitOps {
|
|
5
6
|
workDir;
|
|
6
7
|
dryRun;
|
|
@@ -22,23 +23,46 @@ export class GitOps {
|
|
|
22
23
|
mkdirSync(this.workDir, { recursive: true });
|
|
23
24
|
}
|
|
24
25
|
clone(gitUrl) {
|
|
25
|
-
this.exec(`git clone
|
|
26
|
+
this.exec(`git clone ${escapeShellArg(gitUrl)} .`, this.workDir);
|
|
26
27
|
}
|
|
27
28
|
createBranch(branchName) {
|
|
28
29
|
try {
|
|
29
30
|
// Check if branch exists on remote
|
|
30
|
-
this.exec(`git fetch origin ${branchName}`, this.workDir);
|
|
31
|
-
this.exec(`git checkout ${branchName}`, this.workDir);
|
|
31
|
+
this.exec(`git fetch origin ${escapeShellArg(branchName)}`, this.workDir);
|
|
32
|
+
this.exec(`git checkout ${escapeShellArg(branchName)}`, this.workDir);
|
|
32
33
|
}
|
|
33
34
|
catch {
|
|
34
35
|
// Branch doesn't exist, create it
|
|
35
|
-
this.exec(`git checkout -b ${branchName}`, this.workDir);
|
|
36
|
+
this.exec(`git checkout -b ${escapeShellArg(branchName)}`, this.workDir);
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
writeFile(fileName, content) {
|
|
40
|
+
if (this.dryRun) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
39
43
|
const filePath = join(this.workDir, fileName);
|
|
40
44
|
writeFileSync(filePath, content + '\n', 'utf-8');
|
|
41
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Checks if writing the given content would result in changes.
|
|
48
|
+
* Works in both normal and dry-run modes by comparing content directly.
|
|
49
|
+
*/
|
|
50
|
+
wouldChange(fileName, content) {
|
|
51
|
+
const filePath = join(this.workDir, fileName);
|
|
52
|
+
const newContent = content + '\n';
|
|
53
|
+
if (!existsSync(filePath)) {
|
|
54
|
+
// File doesn't exist, so writing it would be a change
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const existingContent = readFileSync(filePath, 'utf-8');
|
|
59
|
+
return existingContent !== newContent;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// If we can't read the file, assume it would change
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
42
66
|
hasChanges() {
|
|
43
67
|
const status = this.exec('git status --porcelain', this.workDir);
|
|
44
68
|
return status.length > 0;
|
|
@@ -48,13 +72,13 @@ export class GitOps {
|
|
|
48
72
|
return;
|
|
49
73
|
}
|
|
50
74
|
this.exec('git add -A', this.workDir);
|
|
51
|
-
this.exec(`git commit -m
|
|
75
|
+
this.exec(`git commit -m ${escapeShellArg(message)}`, this.workDir);
|
|
52
76
|
}
|
|
53
77
|
push(branchName) {
|
|
54
78
|
if (this.dryRun) {
|
|
55
79
|
return;
|
|
56
80
|
}
|
|
57
|
-
this.exec(`git push -u origin ${branchName}`, this.workDir);
|
|
81
|
+
this.exec(`git push -u origin ${escapeShellArg(branchName)}`, this.workDir);
|
|
58
82
|
}
|
|
59
83
|
getDefaultBranch() {
|
|
60
84
|
try {
|
|
@@ -62,7 +86,7 @@ export class GitOps {
|
|
|
62
86
|
const remoteInfo = this.exec('git remote show origin', this.workDir);
|
|
63
87
|
const match = remoteInfo.match(/HEAD branch: (\S+)/);
|
|
64
88
|
if (match) {
|
|
65
|
-
return match[1];
|
|
89
|
+
return { branch: match[1], method: 'remote HEAD' };
|
|
66
90
|
}
|
|
67
91
|
}
|
|
68
92
|
catch {
|
|
@@ -71,19 +95,19 @@ export class GitOps {
|
|
|
71
95
|
// Try common default branch names
|
|
72
96
|
try {
|
|
73
97
|
this.exec('git rev-parse --verify origin/main', this.workDir);
|
|
74
|
-
return 'main';
|
|
98
|
+
return { branch: 'main', method: 'origin/main exists' };
|
|
75
99
|
}
|
|
76
100
|
catch {
|
|
77
101
|
// Try master
|
|
78
102
|
}
|
|
79
103
|
try {
|
|
80
104
|
this.exec('git rev-parse --verify origin/master', this.workDir);
|
|
81
|
-
return 'master';
|
|
105
|
+
return { branch: 'master', method: 'origin/master exists' };
|
|
82
106
|
}
|
|
83
107
|
catch {
|
|
84
108
|
// Default to main
|
|
85
109
|
}
|
|
86
|
-
return 'main';
|
|
110
|
+
return { branch: 'main', method: 'fallback default' };
|
|
87
111
|
}
|
|
88
112
|
}
|
|
89
113
|
export function sanitizeBranchName(fileName) {
|
package/dist/index.js
CHANGED
|
@@ -2,11 +2,21 @@
|
|
|
2
2
|
import { program } from 'commander';
|
|
3
3
|
import { resolve, join } from 'node:path';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
5
6
|
import { loadConfig, convertContentToString } from './config.js';
|
|
6
7
|
import { parseGitUrl, getRepoDisplayName } from './repo-detector.js';
|
|
7
8
|
import { GitOps, sanitizeBranchName } from './git-ops.js';
|
|
8
9
|
import { createPR } from './pr-creator.js';
|
|
9
10
|
import { logger } from './logger.js';
|
|
11
|
+
/**
|
|
12
|
+
* Generates a unique workspace directory name to avoid collisions
|
|
13
|
+
* when multiple CLI instances run concurrently.
|
|
14
|
+
*/
|
|
15
|
+
function generateWorkspaceName(index) {
|
|
16
|
+
const timestamp = Date.now();
|
|
17
|
+
const uuid = randomUUID().slice(0, 8);
|
|
18
|
+
return `repo-${timestamp}-${index}-${uuid}`;
|
|
19
|
+
}
|
|
10
20
|
program
|
|
11
21
|
.name('json-config-sync')
|
|
12
22
|
.description('Sync JSON configuration files across multiple repositories')
|
|
@@ -45,7 +55,7 @@ async function main() {
|
|
|
45
55
|
continue;
|
|
46
56
|
}
|
|
47
57
|
const repoName = getRepoDisplayName(repoInfo);
|
|
48
|
-
const workDir = resolve(join(options.workDir ?? './tmp',
|
|
58
|
+
const workDir = resolve(join(options.workDir ?? './tmp', generateWorkspaceName(i)));
|
|
49
59
|
try {
|
|
50
60
|
logger.progress(current, repoName, 'Processing...');
|
|
51
61
|
const gitOps = new GitOps({ workDir, dryRun: options.dryRun });
|
|
@@ -56,22 +66,29 @@ async function main() {
|
|
|
56
66
|
logger.info('Cloning repository...');
|
|
57
67
|
gitOps.clone(repoInfo.gitUrl);
|
|
58
68
|
// Step 3: Get default branch for PR base
|
|
59
|
-
const baseBranch = gitOps.getDefaultBranch();
|
|
60
|
-
logger.info(`Default branch: ${baseBranch}`);
|
|
69
|
+
const { branch: baseBranch, method: detectionMethod } = gitOps.getDefaultBranch();
|
|
70
|
+
logger.info(`Default branch: ${baseBranch} (detected via ${detectionMethod})`);
|
|
61
71
|
// Step 4: Create/checkout branch
|
|
62
72
|
logger.info(`Switching to branch: ${branchName}`);
|
|
63
73
|
gitOps.createBranch(branchName);
|
|
74
|
+
// Determine if creating or updating (check BEFORE writing)
|
|
75
|
+
const action = existsSync(join(workDir, config.fileName)) ? 'update' : 'create';
|
|
64
76
|
// Step 5: Write config file
|
|
65
77
|
logger.info(`Writing ${config.fileName}...`);
|
|
66
78
|
const fileContent = convertContentToString(repoConfig.content, config.fileName);
|
|
67
|
-
gitOps.writeFile(config.fileName, fileContent);
|
|
68
79
|
// Step 6: Check for changes
|
|
69
|
-
|
|
80
|
+
// In dry-run mode, compare content directly since we don't write the file
|
|
81
|
+
// In normal mode, write the file first then check git status
|
|
82
|
+
const wouldHaveChanges = options.dryRun
|
|
83
|
+
? gitOps.wouldChange(config.fileName, fileContent)
|
|
84
|
+
: (() => {
|
|
85
|
+
gitOps.writeFile(config.fileName, fileContent);
|
|
86
|
+
return gitOps.hasChanges();
|
|
87
|
+
})();
|
|
88
|
+
if (!wouldHaveChanges) {
|
|
70
89
|
logger.skip(current, repoName, 'No changes detected');
|
|
71
90
|
continue;
|
|
72
91
|
}
|
|
73
|
-
// Determine if creating or updating
|
|
74
|
-
const action = existsSync(join(workDir, config.fileName)) ? 'update' : 'create';
|
|
75
92
|
// Step 7: Commit
|
|
76
93
|
logger.info('Committing changes...');
|
|
77
94
|
gitOps.commit(`chore: sync ${config.fileName}`);
|
package/dist/pr-creator.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RepoInfo } from './repo-detector.js';
|
|
2
|
+
export { escapeShellArg } from './shell-utils.js';
|
|
2
3
|
export interface PROptions {
|
|
3
4
|
repoInfo: RepoInfo;
|
|
4
5
|
branchName: string;
|
|
@@ -13,4 +14,5 @@ export interface PRResult {
|
|
|
13
14
|
success: boolean;
|
|
14
15
|
message: string;
|
|
15
16
|
}
|
|
17
|
+
export declare function formatPRBody(fileName: string, action: 'create' | 'update'): string;
|
|
16
18
|
export declare function createPR(options: PROptions): Promise<PRResult>;
|
package/dist/pr-creator.js
CHANGED
|
@@ -2,10 +2,9 @@ import { execSync } from 'node:child_process';
|
|
|
2
2
|
import { readFileSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
5
|
+
import { escapeShellArg } from './shell-utils.js';
|
|
6
|
+
// Re-export for backwards compatibility and testing
|
|
7
|
+
export { escapeShellArg } from './shell-utils.js';
|
|
9
8
|
function loadPRTemplate() {
|
|
10
9
|
// Try to find PR.md in the project root
|
|
11
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -24,7 +23,7 @@ Automated sync of \`{{FILE_NAME}}\` configuration file.
|
|
|
24
23
|
---
|
|
25
24
|
*This PR was automatically generated by json-config-sync*`;
|
|
26
25
|
}
|
|
27
|
-
function formatPRBody(fileName, action) {
|
|
26
|
+
export function formatPRBody(fileName, action) {
|
|
28
27
|
const template = loadPRTemplate();
|
|
29
28
|
const actionText = action === 'create' ? 'Created' : 'Updated';
|
|
30
29
|
return template
|
package/dist/repo-detector.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export function detectRepoType(gitUrl) {
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
if (/^git@ssh\.dev\.azure\.com
|
|
2
|
+
// Check for Azure DevOps SSH format: git@ssh.dev.azure.com:...
|
|
3
|
+
// Use broader pattern to catch malformed Azure URLs
|
|
4
|
+
if (/^git@ssh\.dev\.azure\.com:/.test(gitUrl)) {
|
|
5
5
|
return 'azure-devops';
|
|
6
6
|
}
|
|
7
7
|
// Check for Azure DevOps HTTPS format: https://dev.azure.com/...
|
|
@@ -19,7 +19,8 @@ export function parseGitUrl(gitUrl) {
|
|
|
19
19
|
}
|
|
20
20
|
function parseGitHubUrl(gitUrl) {
|
|
21
21
|
// Handle SSH format: git@github.com:owner/repo.git
|
|
22
|
-
|
|
22
|
+
// Use (.+?) with end anchor to handle repo names with dots (e.g., my.repo.git)
|
|
23
|
+
const sshMatch = gitUrl.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
23
24
|
if (sshMatch) {
|
|
24
25
|
return {
|
|
25
26
|
type: 'github',
|
|
@@ -29,7 +30,8 @@ function parseGitHubUrl(gitUrl) {
|
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
// Handle HTTPS format: https://github.com/owner/repo.git
|
|
32
|
-
|
|
33
|
+
// Use (.+?) with end anchor to handle repo names with dots
|
|
34
|
+
const httpsMatch = gitUrl.match(/https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
33
35
|
if (httpsMatch) {
|
|
34
36
|
return {
|
|
35
37
|
type: 'github',
|
|
@@ -42,7 +44,8 @@ function parseGitHubUrl(gitUrl) {
|
|
|
42
44
|
}
|
|
43
45
|
function parseAzureDevOpsUrl(gitUrl) {
|
|
44
46
|
// Handle SSH format: git@ssh.dev.azure.com:v3/organization/project/repo
|
|
45
|
-
|
|
47
|
+
// Use (.+?) with end anchor to handle repo names with dots
|
|
48
|
+
const sshMatch = gitUrl.match(/git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
46
49
|
if (sshMatch) {
|
|
47
50
|
return {
|
|
48
51
|
type: 'azure-devops',
|
|
@@ -54,7 +57,8 @@ function parseAzureDevOpsUrl(gitUrl) {
|
|
|
54
57
|
};
|
|
55
58
|
}
|
|
56
59
|
// Handle HTTPS format: https://dev.azure.com/organization/project/_git/repo
|
|
57
|
-
|
|
60
|
+
// Use (.+?) with end anchor to handle repo names with dots
|
|
61
|
+
const httpsMatch = gitUrl.match(/https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/(.+?)(?:\.git)?$/);
|
|
58
62
|
if (httpsMatch) {
|
|
59
63
|
return {
|
|
60
64
|
type: 'azure-devops',
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escapes a string for safe use as a shell argument.
|
|
3
|
+
* Uses single quotes and escapes any single quotes within the string.
|
|
4
|
+
*
|
|
5
|
+
* @param arg - The string to escape
|
|
6
|
+
* @returns The escaped string wrapped in single quotes
|
|
7
|
+
*/
|
|
8
|
+
export declare function escapeShellArg(arg: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escapes a string for safe use as a shell argument.
|
|
3
|
+
* Uses single quotes and escapes any single quotes within the string.
|
|
4
|
+
*
|
|
5
|
+
* @param arg - The string to escape
|
|
6
|
+
* @returns The escaped string wrapped in single quotes
|
|
7
|
+
*/
|
|
8
|
+
export function escapeShellArg(arg) {
|
|
9
|
+
// Use single quotes and escape any single quotes within
|
|
10
|
+
// 'string' -> quote ends, escaped quote, quote starts again
|
|
11
|
+
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspruyt/json-config-sync",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "CLI tool to sync JSON configuration files across multiple GitHub and Azure DevOps repositories",
|
|
3
|
+
"version": "2.0.2",
|
|
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",
|
|
7
7
|
"bin": {
|
|
@@ -26,12 +26,9 @@
|
|
|
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 src/merge.test.ts src/env.test.ts",
|
|
29
|
+
"test": "node --import tsx --test src/config.test.ts src/merge.test.ts src/env.test.ts src/repo-detector.test.ts src/pr-creator.test.ts",
|
|
30
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"
|
|
31
|
+
"prepublishOnly": "npm run build"
|
|
35
32
|
},
|
|
36
33
|
"keywords": [
|
|
37
34
|
"config",
|