@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 +15 -14
- package/README.md +58 -20
- package/dist/config.d.ts +1 -1
- package/dist/config.js +14 -14
- package/dist/env.js +5 -5
- package/dist/git-ops.d.ts +9 -1
- package/dist/git-ops.js +49 -25
- package/dist/index.js +49 -30
- package/dist/logger.js +11 -7
- package/dist/merge.d.ts +1 -1
- package/dist/merge.js +19 -17
- package/dist/pr-creator.d.ts +4 -2
- package/dist/pr-creator.js +34 -29
- package/dist/repo-detector.d.ts +1 -1
- package/dist/repo-detector.js +20 -16
- package/dist/shell-utils.d.ts +8 -0
- package/dist/shell-utils.js +12 -0
- package/package.json +3 -6
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
|
-
|
|
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
|
|
140
|
-
mergeStrategy: replace
|
|
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:
|
|
143
|
+
content: # Base config content (optional)
|
|
143
144
|
key: value
|
|
144
145
|
|
|
145
|
-
repos:
|
|
146
|
+
repos: # List of repositories
|
|
146
147
|
- git: git@github.com:org/repo.git
|
|
147
|
-
content:
|
|
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
|
|
154
|
-
| --------------- |
|
|
155
|
-
| `fileName` | Target file name (`.json` → JSON output, `.yaml`/`.yml` → YAML output)| Yes |
|
|
156
|
-
| `content` | Base config inherited by all repos
|
|
157
|
-
| `mergeStrategy` | Default array merge strategy: `replace`, `append`, `prepend`
|
|
158
|
-
| `repos` | Array of repository configurations
|
|
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
|
|
165
|
-
| ---------- |
|
|
166
|
-
| `git` | Git URL (string) or array of URLs
|
|
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
|
|
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}
|
|
179
|
-
environment: ${ENV:-development}
|
|
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
|
|
198
|
+
$arrayMerge: append # append | prepend | replace
|
|
198
199
|
values:
|
|
199
|
-
- 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
package/dist/config.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { readFileSync } from
|
|
2
|
-
import { parse, stringify } from
|
|
3
|
-
import { deepMerge, stripMergeDirectives, createMergeContext, } from
|
|
4
|
-
import { interpolateEnvVars } from
|
|
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(
|
|
10
|
+
throw new Error("Config missing required field: fileName");
|
|
11
11
|
}
|
|
12
12
|
if (!config.repos || !Array.isArray(config.repos)) {
|
|
13
|
-
throw new Error(
|
|
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] ||
|
|
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 ??
|
|
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,
|
|
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(
|
|
86
|
-
if (ext ===
|
|
87
|
-
return
|
|
85
|
+
const ext = fileName.toLowerCase().split(".").pop();
|
|
86
|
+
if (ext === "yaml" || ext === "yml") {
|
|
87
|
+
return "yaml";
|
|
88
88
|
}
|
|
89
|
-
return
|
|
89
|
+
return "json";
|
|
90
90
|
}
|
|
91
91
|
export function convertContentToString(content, fileName) {
|
|
92
92
|
const format = detectOutputFormat(fileName);
|
|
93
|
-
if (format ===
|
|
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 ===
|
|
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 ===
|
|
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():
|
|
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
|
|
2
|
-
import { rmSync, existsSync, mkdirSync, writeFileSync } from
|
|
3
|
-
import { join } from
|
|
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:
|
|
15
|
-
stdio: [
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
51
|
-
this.exec(`git commit -m
|
|
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(
|
|
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(
|
|
74
|
-
return
|
|
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(
|
|
81
|
-
return
|
|
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
|
|
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(/\.[^.]+$/,
|
|
93
|
-
.replace(/[^a-z0-9-]/g,
|
|
94
|
-
.replace(/-+/g,
|
|
95
|
-
.replace(/^-|-$/g,
|
|
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
|
|
3
|
-
import { resolve, join } from
|
|
4
|
-
import { existsSync } from
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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(
|
|
12
|
-
.description(
|
|
13
|
-
.version(
|
|
14
|
-
.requiredOption(
|
|
15
|
-
.option(
|
|
16
|
-
.option(
|
|
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(
|
|
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 ??
|
|
58
|
+
const workDir = resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(i)));
|
|
49
59
|
try {
|
|
50
|
-
logger.progress(current, repoName,
|
|
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(
|
|
63
|
+
logger.info("Cleaning workspace...");
|
|
54
64
|
gitOps.cleanWorkspace();
|
|
55
65
|
// Step 2: Clone repo
|
|
56
|
-
logger.info(
|
|
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
|
-
|
|
70
|
-
|
|
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(
|
|
95
|
+
logger.info("Committing changes...");
|
|
77
96
|
gitOps.commit(`chore: sync ${config.fileName}`);
|
|
78
97
|
// Step 8: Push
|
|
79
|
-
logger.info(
|
|
98
|
+
logger.info("Pushing to remote...");
|
|
80
99
|
gitOps.push(branchName);
|
|
81
100
|
// Step 9: Create PR
|
|
82
|
-
logger.info(
|
|
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(
|
|
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
|
|
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}]`) +
|
|
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}] ✓`) +
|
|
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}] ⊘`) +
|
|
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}] ✗`) +
|
|
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(
|
|
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 =
|
|
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 ===
|
|
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
|
|
16
|
+
case "replace":
|
|
17
17
|
return overlay;
|
|
18
|
-
case
|
|
18
|
+
case "append":
|
|
19
19
|
return [...base, ...overlay];
|
|
20
|
-
case
|
|
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) &&
|
|
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) &&
|
|
48
|
+
if (isPlainObject(overlay) && "$arrayMerge" in overlay) {
|
|
49
49
|
const strategy = overlay.$arrayMerge;
|
|
50
|
-
if (strategy ===
|
|
51
|
-
strategy ===
|
|
52
|
-
strategy ===
|
|
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) &&
|
|
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 (
|
|
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
|
|
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 =
|
|
144
|
+
export function createMergeContext(defaultStrategy = "replace") {
|
|
143
145
|
return {
|
|
144
146
|
arrayStrategies: new Map(),
|
|
145
147
|
defaultArrayStrategy: defaultStrategy,
|
package/dist/pr-creator.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { RepoInfo } from
|
|
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:
|
|
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>;
|
package/dist/pr-creator.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
import { execSync } from
|
|
2
|
-
import { readFileSync, existsSync, writeFileSync, unlinkSync } from
|
|
3
|
-
import { join, dirname } from
|
|
4
|
-
import { fileURLToPath } from
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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,
|
|
12
|
+
const templatePath = join(__dirname, "..", "PR.md");
|
|
14
13
|
if (existsSync(templatePath)) {
|
|
15
|
-
return readFileSync(templatePath,
|
|
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 ===
|
|
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 ===
|
|
46
|
-
return await createGitHubPR({
|
|
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:
|
|
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,
|
|
85
|
-
writeFileSync(bodyFile, body,
|
|
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:
|
|
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:
|
|
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 ??
|
|
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 ??
|
|
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,
|
|
123
|
-
writeFileSync(descFile, body,
|
|
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 ??
|
|
126
|
-
const prUrl = `https://dev.azure.com/${encodeURIComponent(repoInfo.organization ??
|
|
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:
|
|
135
|
+
message: "PR created successfully",
|
|
131
136
|
};
|
|
132
137
|
}
|
|
133
138
|
finally {
|
package/dist/repo-detector.d.ts
CHANGED
package/dist/repo-detector.js
CHANGED
|
@@ -1,38 +1,40 @@
|
|
|
1
1
|
export function detectRepoType(gitUrl) {
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
if (/^git@ssh\.dev\.azure\.com
|
|
5
|
-
return
|
|
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
|
|
9
|
+
return "azure-devops";
|
|
10
10
|
}
|
|
11
|
-
return
|
|
11
|
+
return "github";
|
|
12
12
|
}
|
|
13
13
|
export function parseGitUrl(gitUrl) {
|
|
14
14
|
const type = detectRepoType(gitUrl);
|
|
15
|
-
if (type ===
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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 ===
|
|
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.
|
|
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",
|