@aspruyt/json-config-sync 2.0.1 → 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 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(): string;
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 "${gitUrl}" .`, this.workDir);
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 "${message}"`, this.workDir);
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', `repo-${i}`));
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
- if (!gitOps.hasChanges()) {
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}`);
@@ -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>;
@@ -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
- function escapeShellArg(arg) {
6
- // Use single quotes and escape any single quotes within
7
- return `'${arg.replace(/'/g, "'\\''")}'`;
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
@@ -1,7 +1,7 @@
1
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)) {
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
- const sshMatch = gitUrl.match(/git@github\.com:([^/]+)\/([^.]+)(?:\.git)?/);
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
- const httpsMatch = gitUrl.match(/https?:\/\/github\.com\/([^/]+)\/([^.]+)(?:\.git)?/);
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
- const sshMatch = gitUrl.match(/git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^.]+)/);
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
- const httpsMatch = gitUrl.match(/https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^.]+)/);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
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",
@@ -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",