@aspruyt/json-config-sync 2.0.1 → 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.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
- import { execSync } from 'node:child_process';
2
- import { rmSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
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";
4
5
  export class GitOps {
5
6
  workDir;
6
7
  dryRun;
@@ -11,8 +12,8 @@ export class GitOps {
11
12
  exec(command, cwd) {
12
13
  return execSync(command, {
13
14
  cwd: cwd ?? this.workDir,
14
- encoding: 'utf-8',
15
- stdio: ['pipe', 'pipe', 'pipe'],
15
+ encoding: "utf-8",
16
+ stdio: ["pipe", "pipe", "pipe"],
16
17
  }).trim();
17
18
  }
18
19
  cleanWorkspace() {
@@ -22,47 +23,70 @@ 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
+ }
43
+ const filePath = join(this.workDir, fileName);
44
+ writeFileSync(filePath, content + "\n", "utf-8");
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) {
39
51
  const filePath = join(this.workDir, fileName);
40
- writeFileSync(filePath, content + '\n', 'utf-8');
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
+ }
41
65
  }
42
66
  hasChanges() {
43
- const status = this.exec('git status --porcelain', this.workDir);
67
+ const status = this.exec("git status --porcelain", this.workDir);
44
68
  return status.length > 0;
45
69
  }
46
70
  commit(message) {
47
71
  if (this.dryRun) {
48
72
  return;
49
73
  }
50
- this.exec('git add -A', this.workDir);
51
- this.exec(`git commit -m "${message}"`, this.workDir);
74
+ this.exec("git add -A", 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 {
61
85
  // Try to get the default branch from remote
62
- const remoteInfo = this.exec('git remote show origin', this.workDir);
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 {
@@ -70,27 +94,27 @@ export class GitOps {
70
94
  }
71
95
  // Try common default branch names
72
96
  try {
73
- this.exec('git rev-parse --verify origin/main', this.workDir);
74
- return 'main';
97
+ this.exec("git rev-parse --verify origin/main", this.workDir);
98
+ return { branch: "main", method: "origin/main exists" };
75
99
  }
76
100
  catch {
77
101
  // Try master
78
102
  }
79
103
  try {
80
- this.exec('git rev-parse --verify origin/master', this.workDir);
81
- return 'master';
104
+ this.exec("git rev-parse --verify origin/master", this.workDir);
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) {
90
114
  return fileName
91
115
  .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
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
96
120
  }
package/dist/index.js CHANGED
@@ -1,19 +1,29 @@
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 { loadConfig, convertContentToString } 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';
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
+ /**
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
- .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')
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")
17
27
  .parse();
18
28
  const options = program.opts();
19
29
  async function main() {
@@ -24,7 +34,7 @@ async function main() {
24
34
  }
25
35
  console.log(`Loading config from: ${configPath}`);
26
36
  if (options.dryRun) {
27
- 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");
28
38
  }
29
39
  const config = loadConfig(configPath);
30
40
  const branchName = `chore/sync-${sanitizeBranchName(config.fileName)}`;
@@ -45,41 +55,50 @@ 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
- logger.progress(current, repoName, 'Processing...');
60
+ logger.progress(current, repoName, "Processing...");
51
61
  const gitOps = new GitOps({ workDir, dryRun: options.dryRun });
52
62
  // Step 1: Clean workspace
53
- logger.info('Cleaning workspace...');
63
+ logger.info("Cleaning workspace...");
54
64
  gitOps.cleanWorkspace();
55
65
  // Step 2: Clone repo
56
- logger.info('Cloning repository...');
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))
76
+ ? "update"
77
+ : "create";
64
78
  // Step 5: Write config file
65
79
  logger.info(`Writing ${config.fileName}...`);
66
80
  const fileContent = convertContentToString(repoConfig.content, config.fileName);
67
- gitOps.writeFile(config.fileName, fileContent);
68
81
  // Step 6: Check for changes
69
- if (!gitOps.hasChanges()) {
70
- logger.skip(current, repoName, 'No changes detected');
82
+ // In dry-run mode, compare content directly since we don't write the file
83
+ // In normal mode, write the file first then check git status
84
+ const wouldHaveChanges = options.dryRun
85
+ ? gitOps.wouldChange(config.fileName, fileContent)
86
+ : (() => {
87
+ gitOps.writeFile(config.fileName, fileContent);
88
+ return gitOps.hasChanges();
89
+ })();
90
+ if (!wouldHaveChanges) {
91
+ logger.skip(current, repoName, "No changes detected");
71
92
  continue;
72
93
  }
73
- // Determine if creating or updating
74
- const action = existsSync(join(workDir, config.fileName)) ? 'update' : 'create';
75
94
  // Step 7: Commit
76
- logger.info('Committing changes...');
95
+ logger.info("Committing changes...");
77
96
  gitOps.commit(`chore: sync ${config.fileName}`);
78
97
  // Step 8: Push
79
- logger.info('Pushing to remote...');
98
+ logger.info("Pushing to remote...");
80
99
  gitOps.push(branchName);
81
100
  // Step 9: Create PR
82
- logger.info('Creating pull request...');
101
+ logger.info("Creating pull request...");
83
102
  const prResult = await createPR({
84
103
  repoInfo,
85
104
  branchName,
@@ -107,6 +126,6 @@ async function main() {
107
126
  }
108
127
  }
109
128
  main().catch((error) => {
110
- console.error('Fatal error:', error);
129
+ console.error("Fatal error:", error);
111
130
  process.exit(1);
112
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,10 +1,11 @@
1
- import { RepoInfo } from './repo-detector.js';
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;
5
6
  baseBranch: string;
6
7
  fileName: string;
7
- action: 'create' | 'update';
8
+ action: "create" | "update";
8
9
  workDir: string;
9
10
  dryRun?: boolean;
10
11
  }
@@ -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>;
@@ -1,18 +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
- function escapeShellArg(arg) {
6
- // Use single quotes and escape any single quotes within
7
- return `'${arg.replace(/'/g, "'\\''")}'`;
8
- }
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
+ // 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);
12
11
  const __dirname = dirname(__filename);
13
- const templatePath = join(__dirname, '..', 'PR.md');
12
+ const templatePath = join(__dirname, "..", "PR.md");
14
13
  if (existsSync(templatePath)) {
15
- return readFileSync(templatePath, 'utf-8');
14
+ return readFileSync(templatePath, "utf-8");
16
15
  }
17
16
  // Fallback template
18
17
  return `## Summary
@@ -24,15 +23,15 @@ 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
- const actionText = action === 'create' ? 'Created' : 'Updated';
28
+ const actionText = action === "create" ? "Created" : "Updated";
30
29
  return template
31
30
  .replace(/\{\{FILE_NAME\}\}/g, fileName)
32
31
  .replace(/\{\{ACTION\}\}/g, actionText);
33
32
  }
34
33
  export async function createPR(options) {
35
- const { repoInfo, branchName, baseBranch, fileName, action, workDir, dryRun } = options;
34
+ const { repoInfo, branchName, baseBranch, fileName, action, workDir, dryRun, } = options;
36
35
  const title = `chore: sync ${fileName}`;
37
36
  const body = formatPRBody(fileName, action);
38
37
  if (dryRun) {
@@ -42,8 +41,14 @@ export async function createPR(options) {
42
41
  };
43
42
  }
44
43
  try {
45
- if (repoInfo.type === 'github') {
46
- 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
+ });
47
52
  }
48
53
  else {
49
54
  return await createAzureDevOpsPR({
@@ -52,7 +57,7 @@ export async function createPR(options) {
52
57
  branchName,
53
58
  baseBranch,
54
59
  repoInfo,
55
- workDir
60
+ workDir,
56
61
  });
57
62
  }
58
63
  }
@@ -68,7 +73,7 @@ async function createGitHubPR(options) {
68
73
  const { title, body, branchName, baseBranch, workDir } = options;
69
74
  // Check if PR already exists
70
75
  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();
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();
72
77
  if (existingPR) {
73
78
  return {
74
79
  url: existingPR,
@@ -81,16 +86,16 @@ async function createGitHubPR(options) {
81
86
  // No existing PR, continue to create
82
87
  }
83
88
  // Write body to temp file to avoid shell escaping issues
84
- const bodyFile = join(workDir, '.pr-body.md');
85
- writeFileSync(bodyFile, body, 'utf-8');
89
+ const bodyFile = join(workDir, ".pr-body.md");
90
+ writeFileSync(bodyFile, body, "utf-8");
86
91
  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();
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();
88
93
  // Extract URL from output
89
94
  const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
90
95
  return {
91
96
  url: urlMatch?.[0] ?? result,
92
97
  success: true,
93
- message: 'PR created successfully',
98
+ message: "PR created successfully",
94
99
  };
95
100
  }
96
101
  finally {
@@ -102,12 +107,12 @@ async function createGitHubPR(options) {
102
107
  }
103
108
  async function createAzureDevOpsPR(options) {
104
109
  const { title, body, branchName, baseBranch, repoInfo, workDir } = options;
105
- const orgUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? '')}`;
110
+ const orgUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ?? "")}`;
106
111
  // Check if PR already exists
107
112
  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();
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();
109
114
  if (existingPRs) {
110
- 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}`;
111
116
  return {
112
117
  url: prUrl,
113
118
  success: true,
@@ -119,15 +124,15 @@ async function createAzureDevOpsPR(options) {
119
124
  // No existing PR, continue to create
120
125
  }
121
126
  // Write description to temp file to avoid shell escaping issues
122
- const descFile = join(workDir, '.pr-description.md');
123
- writeFileSync(descFile, body, 'utf-8');
127
+ const descFile = join(workDir, ".pr-description.md");
128
+ writeFileSync(descFile, body, "utf-8");
124
129
  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}`;
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}`;
127
132
  return {
128
133
  url: prUrl,
129
134
  success: true,
130
- message: 'PR created successfully',
135
+ message: "PR created successfully",
131
136
  };
132
137
  }
133
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;
@@ -1,38 +1,40 @@
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)) {
5
- return 'azure-devops';
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
+ 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);
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
- type: 'github',
26
+ type: "github",
26
27
  gitUrl,
27
28
  owner: sshMatch[1],
28
29
  repo: sshMatch[2],
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
- type: 'github',
37
+ type: "github",
36
38
  gitUrl,
37
39
  owner: httpsMatch[1],
38
40
  repo: httpsMatch[2],
@@ -42,10 +44,11 @@ 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
- type: 'azure-devops',
51
+ type: "azure-devops",
49
52
  gitUrl,
50
53
  owner: sshMatch[1],
51
54
  repo: sshMatch[3],
@@ -54,10 +57,11 @@ 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
- type: 'azure-devops',
64
+ type: "azure-devops",
61
65
  gitUrl,
62
66
  owner: httpsMatch[1],
63
67
  repo: httpsMatch[3],
@@ -68,7 +72,7 @@ function parseAzureDevOpsUrl(gitUrl) {
68
72
  throw new Error(`Unable to parse Azure DevOps URL: ${gitUrl}`);
69
73
  }
70
74
  export function getRepoDisplayName(repoInfo) {
71
- if (repoInfo.type === 'azure-devops') {
75
+ if (repoInfo.type === "azure-devops") {
72
76
  return `${repoInfo.organization}/${repoInfo.project}/${repoInfo.repo}`;
73
77
  }
74
78
  return `${repoInfo.owner}/${repoInfo.repo}`;
@@ -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.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",
@@ -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",