@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.
Files changed (40) hide show
  1. package/PR.md +15 -14
  2. package/README.md +85 -28
  3. package/dist/command-executor.d.ts +25 -0
  4. package/dist/command-executor.js +28 -0
  5. package/dist/config-formatter.d.ts +9 -0
  6. package/dist/config-formatter.js +21 -0
  7. package/dist/config-normalizer.d.ts +6 -0
  8. package/dist/config-normalizer.js +43 -0
  9. package/dist/config-validator.d.ts +6 -0
  10. package/dist/config-validator.js +54 -0
  11. package/dist/config.d.ts +2 -2
  12. package/dist/config.js +15 -90
  13. package/dist/env.js +5 -5
  14. package/dist/git-ops.d.ts +29 -7
  15. package/dist/git-ops.js +134 -51
  16. package/dist/index.d.ts +19 -1
  17. package/dist/index.js +46 -74
  18. package/dist/logger.d.ts +3 -0
  19. package/dist/logger.js +11 -7
  20. package/dist/merge.d.ts +10 -1
  21. package/dist/merge.js +30 -23
  22. package/dist/pr-creator.d.ts +6 -4
  23. package/dist/pr-creator.js +20 -105
  24. package/dist/repo-detector.d.ts +16 -6
  25. package/dist/repo-detector.js +33 -14
  26. package/dist/repository-processor.d.ts +36 -0
  27. package/dist/repository-processor.js +106 -0
  28. package/dist/retry-utils.d.ts +53 -0
  29. package/dist/retry-utils.js +143 -0
  30. package/dist/strategies/azure-pr-strategy.d.ts +10 -0
  31. package/dist/strategies/azure-pr-strategy.js +78 -0
  32. package/dist/strategies/github-pr-strategy.d.ts +6 -0
  33. package/dist/strategies/github-pr-strategy.js +65 -0
  34. package/dist/strategies/index.d.ts +12 -0
  35. package/dist/strategies/index.js +22 -0
  36. package/dist/strategies/pr-strategy.d.ts +70 -0
  37. package/dist/strategies/pr-strategy.js +60 -0
  38. package/dist/workspace-utils.d.ts +5 -0
  39. package/dist/workspace-utils.js +10 -0
  40. package/package.json +3 -2
package/dist/git-ops.js CHANGED
@@ -1,20 +1,61 @@
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';
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
- exec(command, cwd) {
13
- return execSync(command, {
14
- cwd: cwd ?? this.workDir,
15
- encoding: 'utf-8',
16
- stdio: ['pipe', 'pipe', 'pipe'],
17
- }).trim();
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.exec(`git clone ${escapeShellArg(gitUrl)} .`, this.workDir);
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.exec(`git fetch origin ${escapeShellArg(branchName)}`, this.workDir);
32
- this.exec(`git checkout ${escapeShellArg(branchName)}`, this.workDir);
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
- // Branch doesn't exist, create it
36
- this.exec(`git checkout -b ${escapeShellArg(branchName)}`, this.workDir);
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 = join(this.workDir, fileName);
44
- writeFileSync(filePath, content + '\n', 'utf-8');
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 = join(this.workDir, fileName);
52
- const newContent = content + '\n';
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, 'utf-8');
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('git status --porcelain', this.workDir);
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('git add -A', this.workDir);
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.exec(`git push -u origin ${escapeShellArg(branchName)}`, this.workDir);
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.exec('git remote show origin', this.workDir);
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: 'remote HEAD' };
148
+ return { branch: match[1], method: "remote HEAD" };
90
149
  }
91
150
  }
92
- catch {
93
- // Fallback methods
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('git rev-parse --verify origin/main', this.workDir);
98
- return { branch: 'main', method: 'origin/main exists' };
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
- // Try master
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('git rev-parse --verify origin/master', this.workDir);
105
- return { branch: 'master', method: 'origin/master exists' };
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
- // Default to main
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: 'main', method: 'fallback default' };
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(/\.[^.]+$/, '') // 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
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
- export {};
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 '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';
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
- * Generates a unique workspace directory name to avoid collisions
13
- * when multiple CLI instances run concurrently.
17
+ * Default factory that creates a real RepositoryProcessor.
14
18
  */
15
- function generateWorkspaceName(index) {
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('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')
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('Running in DRY RUN mode - no changes will be made\n');
39
+ console.log("Running in DRY RUN mode - no changes will be made\n");
38
40
  }
39
41
  const config = loadConfig(configPath);
40
- const branchName = `chore/sync-${sanitizeBranchName(config.fileName)}`;
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 ?? './tmp', generateWorkspaceName(i)));
68
+ const workDir = resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(i)));
59
69
  try {
60
- logger.progress(current, repoName, 'Processing...');
61
- const gitOps = new GitOps({ workDir, dryRun: options.dryRun });
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
- action,
73
+ branchName,
106
74
  workDir,
107
75
  dryRun: options.dryRun,
76
+ retries: options.retries,
108
77
  });
109
- if (prResult.success) {
110
- logger.success(current, repoName, prResult.url ? `PR: ${prResult.url}` : prResult.message);
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, prResult.message);
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('Fatal error:', error);
99
+ console.error("Fatal error:", error);
128
100
  process.exit(1);
129
101
  });
package/dist/logger.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export interface ILogger {
2
+ info(message: string): void;
3
+ }
1
4
  export interface LoggerStats {
2
5
  total: number;
3
6
  succeeded: number;
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,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 = 'replace' | 'append' | 'prepend';
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 === 'object' && val !== null && !Array.isArray(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
- switch (strategy) {
16
- case 'replace':
17
- return overlay;
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) && 'values' in 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) && '$arrayMerge' in overlay) {
53
+ if (isPlainObject(overlay) && "$arrayMerge" in overlay) {
49
54
  const strategy = overlay.$arrayMerge;
50
- if (strategy === 'replace' ||
51
- strategy === 'append' ||
52
- strategy === 'prepend') {
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) && '$arrayMerge' in 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 ('$arrayMerge' in overlayValue) {
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 ? `${currentPath}.${childKey}` : childKey;
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 = 'replace') {
149
+ export function createMergeContext(defaultStrategy = "replace") {
143
150
  return {
144
151
  arrayStrategies: new Map(),
145
152
  defaultArrayStrategy: defaultStrategy,
@@ -1,18 +1,20 @@
1
- import { RepoInfo } from './repo-detector.js';
2
- export { escapeShellArg } from './shell-utils.js';
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: 'create' | 'update';
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: 'create' | 'update'): string;
19
+ export declare function formatPRBody(fileName: string, action: "create" | "update"): string;
18
20
  export declare function createPR(options: PROptions): Promise<PRResult>;