@aspruyt/json-config-sync 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PR.md +15 -14
- package/README.md +85 -28
- package/dist/command-executor.d.ts +25 -0
- package/dist/command-executor.js +28 -0
- package/dist/config-formatter.d.ts +9 -0
- package/dist/config-formatter.js +21 -0
- package/dist/config-normalizer.d.ts +6 -0
- package/dist/config-normalizer.js +43 -0
- package/dist/config-validator.d.ts +6 -0
- package/dist/config-validator.js +54 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +15 -90
- package/dist/env.js +5 -5
- package/dist/git-ops.d.ts +29 -7
- package/dist/git-ops.js +134 -51
- package/dist/index.d.ts +19 -1
- package/dist/index.js +46 -74
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +11 -7
- package/dist/merge.d.ts +10 -1
- package/dist/merge.js +30 -23
- package/dist/pr-creator.d.ts +6 -4
- package/dist/pr-creator.js +20 -105
- package/dist/repo-detector.d.ts +16 -6
- package/dist/repo-detector.js +33 -14
- package/dist/repository-processor.d.ts +36 -0
- package/dist/repository-processor.js +106 -0
- package/dist/retry-utils.d.ts +53 -0
- package/dist/retry-utils.js +143 -0
- package/dist/strategies/azure-pr-strategy.d.ts +10 -0
- package/dist/strategies/azure-pr-strategy.js +78 -0
- package/dist/strategies/github-pr-strategy.d.ts +6 -0
- package/dist/strategies/github-pr-strategy.js +65 -0
- package/dist/strategies/index.d.ts +12 -0
- package/dist/strategies/index.js +22 -0
- package/dist/strategies/pr-strategy.d.ts +70 -0
- package/dist/strategies/pr-strategy.js +60 -0
- package/dist/workspace-utils.d.ts +5 -0
- package/dist/workspace-utils.js +10 -0
- package/package.json +3 -2
package/dist/git-ops.js
CHANGED
|
@@ -1,20 +1,61 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync, } from "node:fs";
|
|
2
|
+
import { join, resolve, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { escapeShellArg } from "./shell-utils.js";
|
|
4
|
+
import { defaultExecutor } from "./command-executor.js";
|
|
5
|
+
import { withRetry } from "./retry-utils.js";
|
|
6
|
+
import { logger } from "./logger.js";
|
|
7
|
+
/**
|
|
8
|
+
* Patterns indicating a git branch does not exist.
|
|
9
|
+
* Used to distinguish "branch not found" from other errors.
|
|
10
|
+
*/
|
|
11
|
+
const BRANCH_NOT_FOUND_PATTERNS = [
|
|
12
|
+
"couldn't find remote ref",
|
|
13
|
+
"pathspec",
|
|
14
|
+
"did not match any",
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Checks if an error message indicates a branch was not found.
|
|
18
|
+
*/
|
|
19
|
+
function isBranchNotFoundError(message) {
|
|
20
|
+
return BRANCH_NOT_FOUND_PATTERNS.some((pattern) => message.includes(pattern));
|
|
21
|
+
}
|
|
5
22
|
export class GitOps {
|
|
6
23
|
workDir;
|
|
7
24
|
dryRun;
|
|
25
|
+
executor;
|
|
26
|
+
retries;
|
|
8
27
|
constructor(options) {
|
|
9
28
|
this.workDir = options.workDir;
|
|
10
29
|
this.dryRun = options.dryRun ?? false;
|
|
30
|
+
this.executor = options.executor ?? defaultExecutor;
|
|
31
|
+
this.retries = options.retries ?? 3;
|
|
32
|
+
}
|
|
33
|
+
async exec(command, cwd) {
|
|
34
|
+
return this.executor.exec(command, cwd ?? this.workDir);
|
|
11
35
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Run a command with retry logic for transient failures.
|
|
38
|
+
* Used for network operations like clone, fetch, push.
|
|
39
|
+
*/
|
|
40
|
+
async execWithRetry(command, cwd) {
|
|
41
|
+
return withRetry(() => this.exec(command, cwd), {
|
|
42
|
+
retries: this.retries,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validates that a file path doesn't escape the workspace directory.
|
|
47
|
+
* @returns The resolved absolute file path
|
|
48
|
+
* @throws Error if path traversal is detected
|
|
49
|
+
*/
|
|
50
|
+
validatePath(fileName) {
|
|
51
|
+
const filePath = join(this.workDir, fileName);
|
|
52
|
+
const resolvedPath = resolve(filePath);
|
|
53
|
+
const resolvedWorkDir = resolve(this.workDir);
|
|
54
|
+
const relativePath = relative(resolvedWorkDir, resolvedPath);
|
|
55
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
56
|
+
throw new Error(`Path traversal detected: ${fileName}`);
|
|
57
|
+
}
|
|
58
|
+
return filePath;
|
|
18
59
|
}
|
|
19
60
|
cleanWorkspace() {
|
|
20
61
|
if (existsSync(this.workDir)) {
|
|
@@ -22,40 +63,58 @@ export class GitOps {
|
|
|
22
63
|
}
|
|
23
64
|
mkdirSync(this.workDir, { recursive: true });
|
|
24
65
|
}
|
|
25
|
-
clone(gitUrl) {
|
|
26
|
-
this.
|
|
66
|
+
async clone(gitUrl) {
|
|
67
|
+
await this.execWithRetry(`git clone ${escapeShellArg(gitUrl)} .`, this.workDir);
|
|
27
68
|
}
|
|
28
|
-
createBranch(branchName) {
|
|
69
|
+
async createBranch(branchName) {
|
|
29
70
|
try {
|
|
30
|
-
// Check if branch exists on remote
|
|
31
|
-
this.
|
|
32
|
-
|
|
71
|
+
// Check if branch exists on remote (network operation with retry)
|
|
72
|
+
await this.execWithRetry(`git fetch origin ${escapeShellArg(branchName)}`, this.workDir);
|
|
73
|
+
// Ensure clean workspace before checkout (defensive - handles edge cases)
|
|
74
|
+
await this.exec("git reset --hard HEAD", this.workDir);
|
|
75
|
+
await this.exec("git clean -fd", this.workDir);
|
|
76
|
+
await this.execWithRetry(`git checkout ${escapeShellArg(branchName)}`, this.workDir);
|
|
77
|
+
return;
|
|
33
78
|
}
|
|
34
|
-
catch {
|
|
35
|
-
//
|
|
36
|
-
|
|
79
|
+
catch (error) {
|
|
80
|
+
// Only proceed to create branch if error indicates branch doesn't exist
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
+
if (!isBranchNotFoundError(message)) {
|
|
83
|
+
throw new Error(`Failed to fetch/checkout branch '${branchName}': ${message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Branch doesn't exist on remote, create it locally
|
|
87
|
+
try {
|
|
88
|
+
await this.exec(`git checkout -b ${escapeShellArg(branchName)}`, this.workDir);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
throw new Error(`Failed to create branch '${branchName}': ${message}`);
|
|
37
93
|
}
|
|
38
94
|
}
|
|
39
95
|
writeFile(fileName, content) {
|
|
40
96
|
if (this.dryRun) {
|
|
41
97
|
return;
|
|
42
98
|
}
|
|
43
|
-
const filePath =
|
|
44
|
-
|
|
99
|
+
const filePath = this.validatePath(fileName);
|
|
100
|
+
// Normalize trailing newline - ensure exactly one
|
|
101
|
+
const normalized = content.endsWith("\n") ? content : content + "\n";
|
|
102
|
+
writeFileSync(filePath, normalized, "utf-8");
|
|
45
103
|
}
|
|
46
104
|
/**
|
|
47
105
|
* Checks if writing the given content would result in changes.
|
|
48
106
|
* Works in both normal and dry-run modes by comparing content directly.
|
|
49
107
|
*/
|
|
50
108
|
wouldChange(fileName, content) {
|
|
51
|
-
const filePath =
|
|
52
|
-
|
|
109
|
+
const filePath = this.validatePath(fileName);
|
|
110
|
+
// Normalize trailing newline - ensure exactly one
|
|
111
|
+
const newContent = content.endsWith("\n") ? content : content + "\n";
|
|
53
112
|
if (!existsSync(filePath)) {
|
|
54
113
|
// File doesn't exist, so writing it would be a change
|
|
55
114
|
return true;
|
|
56
115
|
}
|
|
57
116
|
try {
|
|
58
|
-
const existingContent = readFileSync(filePath,
|
|
117
|
+
const existingContent = readFileSync(filePath, "utf-8");
|
|
59
118
|
return existingContent !== newContent;
|
|
60
119
|
}
|
|
61
120
|
catch {
|
|
@@ -63,58 +122,82 @@ export class GitOps {
|
|
|
63
122
|
return true;
|
|
64
123
|
}
|
|
65
124
|
}
|
|
66
|
-
hasChanges() {
|
|
67
|
-
const status = this.exec(
|
|
125
|
+
async hasChanges() {
|
|
126
|
+
const status = await this.exec("git status --porcelain", this.workDir);
|
|
68
127
|
return status.length > 0;
|
|
69
128
|
}
|
|
70
|
-
commit(message) {
|
|
129
|
+
async commit(message) {
|
|
71
130
|
if (this.dryRun) {
|
|
72
131
|
return;
|
|
73
132
|
}
|
|
74
|
-
this.exec(
|
|
75
|
-
this.exec(`git commit -m ${escapeShellArg(message)}`, this.workDir);
|
|
133
|
+
await this.exec("git add -A", this.workDir);
|
|
134
|
+
await this.exec(`git commit -m ${escapeShellArg(message)}`, this.workDir);
|
|
76
135
|
}
|
|
77
|
-
push(branchName) {
|
|
136
|
+
async push(branchName) {
|
|
78
137
|
if (this.dryRun) {
|
|
79
138
|
return;
|
|
80
139
|
}
|
|
81
|
-
this.
|
|
140
|
+
await this.execWithRetry(`git push -u origin ${escapeShellArg(branchName)}`, this.workDir);
|
|
82
141
|
}
|
|
83
|
-
getDefaultBranch() {
|
|
142
|
+
async getDefaultBranch() {
|
|
84
143
|
try {
|
|
85
|
-
// Try to get the default branch from remote
|
|
86
|
-
const remoteInfo = this.
|
|
144
|
+
// Try to get the default branch from remote (network operation with retry)
|
|
145
|
+
const remoteInfo = await this.execWithRetry("git remote show origin", this.workDir);
|
|
87
146
|
const match = remoteInfo.match(/HEAD branch: (\S+)/);
|
|
88
147
|
if (match) {
|
|
89
|
-
return { branch: match[1], method:
|
|
148
|
+
return { branch: match[1], method: "remote HEAD" };
|
|
90
149
|
}
|
|
91
150
|
}
|
|
92
|
-
catch {
|
|
93
|
-
|
|
151
|
+
catch (error) {
|
|
152
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
153
|
+
logger.info(`Debug: git remote show origin failed - ${msg}`);
|
|
94
154
|
}
|
|
95
|
-
// Try common default branch names
|
|
155
|
+
// Try common default branch names (local operations, no retry needed)
|
|
96
156
|
try {
|
|
97
|
-
this.exec(
|
|
98
|
-
return { branch:
|
|
157
|
+
await this.exec("git rev-parse --verify origin/main", this.workDir);
|
|
158
|
+
return { branch: "main", method: "origin/main exists" };
|
|
99
159
|
}
|
|
100
|
-
catch {
|
|
101
|
-
|
|
160
|
+
catch (error) {
|
|
161
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
162
|
+
logger.info(`Debug: origin/main check failed - ${msg}`);
|
|
102
163
|
}
|
|
103
164
|
try {
|
|
104
|
-
this.exec(
|
|
105
|
-
return { branch:
|
|
165
|
+
await this.exec("git rev-parse --verify origin/master", this.workDir);
|
|
166
|
+
return { branch: "master", method: "origin/master exists" };
|
|
106
167
|
}
|
|
107
|
-
catch {
|
|
108
|
-
|
|
168
|
+
catch (error) {
|
|
169
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
170
|
+
logger.info(`Debug: origin/master check failed - ${msg}`);
|
|
109
171
|
}
|
|
110
|
-
return { branch:
|
|
172
|
+
return { branch: "main", method: "fallback default" };
|
|
111
173
|
}
|
|
112
174
|
}
|
|
113
175
|
export function sanitizeBranchName(fileName) {
|
|
114
176
|
return fileName
|
|
115
177
|
.toLowerCase()
|
|
116
|
-
.replace(/\.[^.]+$/,
|
|
117
|
-
.replace(/[^a-z0-9-]/g,
|
|
118
|
-
.replace(/-+/g,
|
|
119
|
-
.replace(/^-|-$/g,
|
|
178
|
+
.replace(/\.[^.]+$/, "") // Remove extension
|
|
179
|
+
.replace(/[^a-z0-9-]/g, "-") // Replace non-alphanumeric with dashes
|
|
180
|
+
.replace(/-+/g, "-") // Collapse multiple dashes
|
|
181
|
+
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Validates a user-provided branch name against git's naming rules.
|
|
185
|
+
* @throws Error if the branch name is invalid
|
|
186
|
+
*/
|
|
187
|
+
export function validateBranchName(branchName) {
|
|
188
|
+
if (!branchName || branchName.trim() === "") {
|
|
189
|
+
throw new Error("Branch name cannot be empty");
|
|
190
|
+
}
|
|
191
|
+
if (branchName.startsWith(".") || branchName.startsWith("-")) {
|
|
192
|
+
throw new Error('Branch name cannot start with "." or "-"');
|
|
193
|
+
}
|
|
194
|
+
// Git disallows: space, ~, ^, :, ?, *, [, \, and consecutive dots (..)
|
|
195
|
+
if (/[\s~^:?*\[\\]/.test(branchName) || branchName.includes("..")) {
|
|
196
|
+
throw new Error("Branch name contains invalid characters");
|
|
197
|
+
}
|
|
198
|
+
if (branchName.endsWith("/") ||
|
|
199
|
+
branchName.endsWith(".lock") ||
|
|
200
|
+
branchName.endsWith(".")) {
|
|
201
|
+
throw new Error("Branch name has invalid ending");
|
|
202
|
+
}
|
|
120
203
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import { ProcessorResult } from "./repository-processor.js";
|
|
3
|
+
import { RepoConfig } from "./config.js";
|
|
4
|
+
import { RepoInfo } from "./repo-detector.js";
|
|
5
|
+
import { ProcessorOptions } from "./repository-processor.js";
|
|
6
|
+
/**
|
|
7
|
+
* Processor interface for dependency injection in tests.
|
|
8
|
+
*/
|
|
9
|
+
export interface IRepositoryProcessor {
|
|
10
|
+
process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Factory function type for creating processors.
|
|
14
|
+
* Allows dependency injection for testing.
|
|
15
|
+
*/
|
|
16
|
+
export type ProcessorFactory = () => IRepositoryProcessor;
|
|
17
|
+
/**
|
|
18
|
+
* Default factory that creates a real RepositoryProcessor.
|
|
19
|
+
*/
|
|
20
|
+
export declare const defaultProcessorFactory: ProcessorFactory;
|
package/dist/index.js
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { program } from
|
|
3
|
-
import { resolve, join } from
|
|
4
|
-
import { existsSync } from
|
|
5
|
-
import {
|
|
6
|
-
import { loadConfig
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
import { program } from "commander";
|
|
3
|
+
import { resolve, join, dirname } from "node:path";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
// Get version from package.json
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
const packageJson = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
11
|
+
import { parseGitUrl, getRepoDisplayName } from "./repo-detector.js";
|
|
12
|
+
import { sanitizeBranchName, validateBranchName } from "./git-ops.js";
|
|
13
|
+
import { logger } from "./logger.js";
|
|
14
|
+
import { generateWorkspaceName } from "./workspace-utils.js";
|
|
15
|
+
import { RepositoryProcessor, } from "./repository-processor.js";
|
|
11
16
|
/**
|
|
12
|
-
*
|
|
13
|
-
* when multiple CLI instances run concurrently.
|
|
17
|
+
* Default factory that creates a real RepositoryProcessor.
|
|
14
18
|
*/
|
|
15
|
-
|
|
16
|
-
const timestamp = Date.now();
|
|
17
|
-
const uuid = randomUUID().slice(0, 8);
|
|
18
|
-
return `repo-${timestamp}-${index}-${uuid}`;
|
|
19
|
-
}
|
|
19
|
+
export const defaultProcessorFactory = () => new RepositoryProcessor();
|
|
20
20
|
program
|
|
21
|
-
.name(
|
|
22
|
-
.description(
|
|
23
|
-
.version(
|
|
24
|
-
.requiredOption(
|
|
25
|
-
.option(
|
|
26
|
-
.option(
|
|
21
|
+
.name("json-config-sync")
|
|
22
|
+
.description("Sync JSON configuration files across multiple repositories")
|
|
23
|
+
.version(packageJson.version)
|
|
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")
|
|
27
|
+
.option("-r, --retries <number>", "Number of retries for network operations (0 to disable)", (v) => parseInt(v, 10), 3)
|
|
28
|
+
.option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename})")
|
|
27
29
|
.parse();
|
|
28
30
|
const options = program.opts();
|
|
29
31
|
async function main() {
|
|
@@ -34,14 +36,22 @@ async function main() {
|
|
|
34
36
|
}
|
|
35
37
|
console.log(`Loading config from: ${configPath}`);
|
|
36
38
|
if (options.dryRun) {
|
|
37
|
-
console.log(
|
|
39
|
+
console.log("Running in DRY RUN mode - no changes will be made\n");
|
|
38
40
|
}
|
|
39
41
|
const config = loadConfig(configPath);
|
|
40
|
-
|
|
42
|
+
let branchName;
|
|
43
|
+
if (options.branch) {
|
|
44
|
+
validateBranchName(options.branch);
|
|
45
|
+
branchName = options.branch;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
branchName = `chore/sync-${sanitizeBranchName(config.fileName)}`;
|
|
49
|
+
}
|
|
41
50
|
logger.setTotal(config.repos.length);
|
|
42
51
|
console.log(`Found ${config.repos.length} repositories to process`);
|
|
43
52
|
console.log(`Target file: ${config.fileName}`);
|
|
44
53
|
console.log(`Branch: ${branchName}\n`);
|
|
54
|
+
const processor = defaultProcessorFactory();
|
|
45
55
|
for (let i = 0; i < config.repos.length; i++) {
|
|
46
56
|
const repoConfig = config.repos[i];
|
|
47
57
|
const current = i + 1;
|
|
@@ -55,62 +65,24 @@ async function main() {
|
|
|
55
65
|
continue;
|
|
56
66
|
}
|
|
57
67
|
const repoName = getRepoDisplayName(repoInfo);
|
|
58
|
-
const workDir = resolve(join(options.workDir ??
|
|
68
|
+
const workDir = resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(i)));
|
|
59
69
|
try {
|
|
60
|
-
logger.progress(current, repoName,
|
|
61
|
-
const
|
|
62
|
-
// Step 1: Clean workspace
|
|
63
|
-
logger.info('Cleaning workspace...');
|
|
64
|
-
gitOps.cleanWorkspace();
|
|
65
|
-
// Step 2: Clone repo
|
|
66
|
-
logger.info('Cloning repository...');
|
|
67
|
-
gitOps.clone(repoInfo.gitUrl);
|
|
68
|
-
// Step 3: Get default branch for PR base
|
|
69
|
-
const { branch: baseBranch, method: detectionMethod } = gitOps.getDefaultBranch();
|
|
70
|
-
logger.info(`Default branch: ${baseBranch} (detected via ${detectionMethod})`);
|
|
71
|
-
// Step 4: Create/checkout branch
|
|
72
|
-
logger.info(`Switching to branch: ${branchName}`);
|
|
73
|
-
gitOps.createBranch(branchName);
|
|
74
|
-
// Determine if creating or updating (check BEFORE writing)
|
|
75
|
-
const action = existsSync(join(workDir, config.fileName)) ? 'update' : 'create';
|
|
76
|
-
// Step 5: Write config file
|
|
77
|
-
logger.info(`Writing ${config.fileName}...`);
|
|
78
|
-
const fileContent = convertContentToString(repoConfig.content, config.fileName);
|
|
79
|
-
// Step 6: Check for changes
|
|
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) {
|
|
89
|
-
logger.skip(current, repoName, 'No changes detected');
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
// Step 7: Commit
|
|
93
|
-
logger.info('Committing changes...');
|
|
94
|
-
gitOps.commit(`chore: sync ${config.fileName}`);
|
|
95
|
-
// Step 8: Push
|
|
96
|
-
logger.info('Pushing to remote...');
|
|
97
|
-
gitOps.push(branchName);
|
|
98
|
-
// Step 9: Create PR
|
|
99
|
-
logger.info('Creating pull request...');
|
|
100
|
-
const prResult = await createPR({
|
|
101
|
-
repoInfo,
|
|
102
|
-
branchName,
|
|
103
|
-
baseBranch,
|
|
70
|
+
logger.progress(current, repoName, "Processing...");
|
|
71
|
+
const result = await processor.process(repoConfig, repoInfo, {
|
|
104
72
|
fileName: config.fileName,
|
|
105
|
-
|
|
73
|
+
branchName,
|
|
106
74
|
workDir,
|
|
107
75
|
dryRun: options.dryRun,
|
|
76
|
+
retries: options.retries,
|
|
108
77
|
});
|
|
109
|
-
if (
|
|
110
|
-
logger.
|
|
78
|
+
if (result.skipped) {
|
|
79
|
+
logger.skip(current, repoName, result.message);
|
|
80
|
+
}
|
|
81
|
+
else if (result.success) {
|
|
82
|
+
logger.success(current, repoName, result.prUrl ? `PR: ${result.prUrl}` : result.message);
|
|
111
83
|
}
|
|
112
84
|
else {
|
|
113
|
-
logger.error(current, repoName,
|
|
85
|
+
logger.error(current, repoName, result.message);
|
|
114
86
|
}
|
|
115
87
|
}
|
|
116
88
|
catch (error) {
|
|
@@ -124,6 +96,6 @@ async function main() {
|
|
|
124
96
|
}
|
|
125
97
|
}
|
|
126
98
|
main().catch((error) => {
|
|
127
|
-
console.error(
|
|
99
|
+
console.error("Fatal error:", error);
|
|
128
100
|
process.exit(1);
|
|
129
101
|
});
|
package/dist/logger.d.ts
CHANGED
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,16 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Handler function type for array merge strategies.
|
|
8
|
+
*/
|
|
9
|
+
export type ArrayMergeHandler = (base: unknown[], overlay: unknown[]) => unknown[];
|
|
10
|
+
/**
|
|
11
|
+
* Strategy map for array merge operations.
|
|
12
|
+
* Extensible: add new strategies by adding to this map.
|
|
13
|
+
*/
|
|
14
|
+
export declare const arrayMergeStrategies: Map<ArrayMergeStrategy, ArrayMergeHandler>;
|
|
6
15
|
export interface MergeContext {
|
|
7
16
|
arrayStrategies: Map<string, ArrayMergeStrategy>;
|
|
8
17
|
defaultArrayStrategy: ArrayMergeStrategy;
|
package/dist/merge.js
CHANGED
|
@@ -2,26 +2,31 @@
|
|
|
2
2
|
* Deep merge utilities for JSON configuration objects.
|
|
3
3
|
* Supports configurable array merge strategies via $arrayMerge directive.
|
|
4
4
|
*/
|
|
5
|
+
/**
|
|
6
|
+
* Strategy map for array merge operations.
|
|
7
|
+
* Extensible: add new strategies by adding to this map.
|
|
8
|
+
*/
|
|
9
|
+
export const arrayMergeStrategies = new Map([
|
|
10
|
+
["replace", (_base, overlay) => overlay],
|
|
11
|
+
["append", (base, overlay) => [...base, ...overlay]],
|
|
12
|
+
["prepend", (base, overlay) => [...overlay, ...base]],
|
|
13
|
+
]);
|
|
5
14
|
/**
|
|
6
15
|
* Check if a value is a plain object (not null, not array).
|
|
7
16
|
*/
|
|
8
17
|
function isPlainObject(val) {
|
|
9
|
-
return typeof val ===
|
|
18
|
+
return typeof val === "object" && val !== null && !Array.isArray(val);
|
|
10
19
|
}
|
|
11
20
|
/**
|
|
12
21
|
* Merge two arrays based on the specified strategy.
|
|
13
22
|
*/
|
|
14
23
|
function mergeArrays(base, overlay, strategy) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
case 'append':
|
|
19
|
-
return [...base, ...overlay];
|
|
20
|
-
case 'prepend':
|
|
21
|
-
return [...overlay, ...base];
|
|
22
|
-
default:
|
|
23
|
-
return overlay;
|
|
24
|
+
const handler = arrayMergeStrategies.get(strategy);
|
|
25
|
+
if (handler) {
|
|
26
|
+
return handler(base, overlay);
|
|
24
27
|
}
|
|
28
|
+
// Fallback to replace for unknown strategies
|
|
29
|
+
return overlay;
|
|
25
30
|
}
|
|
26
31
|
/**
|
|
27
32
|
* Extract array values from an overlay object that uses the directive syntax:
|
|
@@ -33,7 +38,7 @@ function extractArrayFromOverlay(overlay) {
|
|
|
33
38
|
if (Array.isArray(overlay)) {
|
|
34
39
|
return overlay;
|
|
35
40
|
}
|
|
36
|
-
if (isPlainObject(overlay) &&
|
|
41
|
+
if (isPlainObject(overlay) && "values" in overlay) {
|
|
37
42
|
const values = overlay.values;
|
|
38
43
|
if (Array.isArray(values)) {
|
|
39
44
|
return values;
|
|
@@ -45,11 +50,11 @@ function extractArrayFromOverlay(overlay) {
|
|
|
45
50
|
* Get merge strategy from an overlay object's $arrayMerge directive.
|
|
46
51
|
*/
|
|
47
52
|
function getStrategyFromOverlay(overlay) {
|
|
48
|
-
if (isPlainObject(overlay) &&
|
|
53
|
+
if (isPlainObject(overlay) && "$arrayMerge" in overlay) {
|
|
49
54
|
const strategy = overlay.$arrayMerge;
|
|
50
|
-
if (strategy ===
|
|
51
|
-
strategy ===
|
|
52
|
-
strategy ===
|
|
55
|
+
if (strategy === "replace" ||
|
|
56
|
+
strategy === "append" ||
|
|
57
|
+
strategy === "prepend") {
|
|
53
58
|
return strategy;
|
|
54
59
|
}
|
|
55
60
|
}
|
|
@@ -63,18 +68,18 @@ function getStrategyFromOverlay(overlay) {
|
|
|
63
68
|
* @param ctx - Merge context with array strategies
|
|
64
69
|
* @param path - Current path for strategy lookup (internal)
|
|
65
70
|
*/
|
|
66
|
-
export function deepMerge(base, overlay, ctx, path =
|
|
71
|
+
export function deepMerge(base, overlay, ctx, path = "") {
|
|
67
72
|
const result = { ...base };
|
|
68
73
|
// Check for $arrayMerge directive at this level (applies to child arrays)
|
|
69
74
|
const levelStrategy = getStrategyFromOverlay(overlay);
|
|
70
75
|
for (const [key, overlayValue] of Object.entries(overlay)) {
|
|
71
76
|
// Skip directive keys in output
|
|
72
|
-
if (key.startsWith(
|
|
77
|
+
if (key.startsWith("$"))
|
|
73
78
|
continue;
|
|
74
79
|
const currentPath = path ? `${path}.${key}` : key;
|
|
75
80
|
const baseValue = base[key];
|
|
76
81
|
// If overlay is an object with $arrayMerge directive for an array field
|
|
77
|
-
if (isPlainObject(overlayValue) &&
|
|
82
|
+
if (isPlainObject(overlayValue) && "$arrayMerge" in overlayValue) {
|
|
78
83
|
const strategy = getStrategyFromOverlay(overlayValue);
|
|
79
84
|
const overlayArray = extractArrayFromOverlay(overlayValue);
|
|
80
85
|
if (strategy && overlayArray && Array.isArray(baseValue)) {
|
|
@@ -94,13 +99,15 @@ export function deepMerge(base, overlay, ctx, path = '') {
|
|
|
94
99
|
// Both are plain objects - recurse
|
|
95
100
|
if (isPlainObject(baseValue) && isPlainObject(overlayValue)) {
|
|
96
101
|
// Extract $arrayMerge for child paths if present
|
|
97
|
-
if (
|
|
102
|
+
if ("$arrayMerge" in overlayValue) {
|
|
98
103
|
const childStrategy = getStrategyFromOverlay(overlayValue);
|
|
99
104
|
if (childStrategy) {
|
|
100
105
|
// Apply to all immediate child arrays
|
|
101
106
|
for (const childKey of Object.keys(overlayValue)) {
|
|
102
|
-
if (!childKey.startsWith(
|
|
103
|
-
const childPath = currentPath
|
|
107
|
+
if (!childKey.startsWith("$")) {
|
|
108
|
+
const childPath = currentPath
|
|
109
|
+
? `${currentPath}.${childKey}`
|
|
110
|
+
: childKey;
|
|
104
111
|
ctx.arrayStrategies.set(childPath, childStrategy);
|
|
105
112
|
}
|
|
106
113
|
}
|
|
@@ -122,7 +129,7 @@ export function stripMergeDirectives(obj) {
|
|
|
122
129
|
const result = {};
|
|
123
130
|
for (const [key, value] of Object.entries(obj)) {
|
|
124
131
|
// Skip all $-prefixed keys (reserved for directives)
|
|
125
|
-
if (key.startsWith(
|
|
132
|
+
if (key.startsWith("$"))
|
|
126
133
|
continue;
|
|
127
134
|
if (isPlainObject(value)) {
|
|
128
135
|
result[key] = stripMergeDirectives(value);
|
|
@@ -139,7 +146,7 @@ export function stripMergeDirectives(obj) {
|
|
|
139
146
|
/**
|
|
140
147
|
* Create a default merge context.
|
|
141
148
|
*/
|
|
142
|
-
export function createMergeContext(defaultStrategy =
|
|
149
|
+
export function createMergeContext(defaultStrategy = "replace") {
|
|
143
150
|
return {
|
|
144
151
|
arrayStrategies: new Map(),
|
|
145
152
|
defaultArrayStrategy: defaultStrategy,
|
package/dist/pr-creator.d.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import { RepoInfo } from
|
|
2
|
-
export { escapeShellArg } from
|
|
1
|
+
import { RepoInfo } from "./repo-detector.js";
|
|
2
|
+
export { escapeShellArg } from "./shell-utils.js";
|
|
3
3
|
export interface PROptions {
|
|
4
4
|
repoInfo: RepoInfo;
|
|
5
5
|
branchName: string;
|
|
6
6
|
baseBranch: string;
|
|
7
7
|
fileName: string;
|
|
8
|
-
action:
|
|
8
|
+
action: "create" | "update";
|
|
9
9
|
workDir: string;
|
|
10
10
|
dryRun?: boolean;
|
|
11
|
+
/** Number of retries for API operations (default: 3) */
|
|
12
|
+
retries?: number;
|
|
11
13
|
}
|
|
12
14
|
export interface PRResult {
|
|
13
15
|
url?: string;
|
|
14
16
|
success: boolean;
|
|
15
17
|
message: string;
|
|
16
18
|
}
|
|
17
|
-
export declare function formatPRBody(fileName: string, action:
|
|
19
|
+
export declare function formatPRBody(fileName: string, action: "create" | "update"): string;
|
|
18
20
|
export declare function createPR(options: PROptions): Promise<PRResult>;
|