@aspruyt/json-config-sync 2.0.3 → 2.1.1

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # json-config-sync
2
2
 
3
3
  [![CI](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
4
- [![Integration Test](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration.yml/badge.svg)](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration.yml)
4
+ [![Integration Test](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration-test.yml/badge.svg?branch=main)](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration-test.yml)
5
5
  [![npm version](https://img.shields.io/npm/v/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
6
6
  [![npm downloads](https://img.shields.io/npm/dw/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
7
7
 
@@ -70,6 +70,7 @@ json-config-sync --config ./config.yaml
70
70
  - **GitHub & Azure DevOps** - Works with both platforms
71
71
  - **Dry-Run Mode** - Preview changes without creating PRs
72
72
  - **Error Resilience** - Continues processing if individual repos fail
73
+ - **Automatic Retries** - Retries transient network errors with exponential backoff
73
74
 
74
75
  ## Installation
75
76
 
@@ -122,15 +123,20 @@ json-config-sync --config ./config.yaml --dry-run
122
123
 
123
124
  # Custom work directory
124
125
  json-config-sync --config ./config.yaml --work-dir ./my-temp
126
+
127
+ # Custom branch name
128
+ json-config-sync --config ./config.yaml --branch feature/update-eslint
125
129
  ```
126
130
 
127
131
  ### Options
128
132
 
129
- | Option | Alias | Description | Required |
130
- | ------------ | ----- | -------------------------------------------------- | -------- |
131
- | `--config` | `-c` | Path to YAML config file | Yes |
132
- | `--dry-run` | `-d` | Show what would be done without making changes | No |
133
- | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
133
+ | Option | Alias | Description | Required |
134
+ | ------------ | ----- | ------------------------------------------------------- | -------- |
135
+ | `--config` | `-c` | Path to YAML config file | Yes |
136
+ | `--dry-run` | `-d` | Show what would be done without making changes | No |
137
+ | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
138
+ | `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
139
+ | `--branch` | `-b` | Override branch name (default: `chore/sync-{filename}`) | No |
134
140
 
135
141
  ## Configuration Format
136
142
 
@@ -315,7 +321,7 @@ flowchart TB
315
321
  end
316
322
 
317
323
  subgraph Processing["For Each Repository"]
318
- CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>chore/sync-filename]
324
+ CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>--branch or chore/sync-filename]
319
325
  BRANCH --> WRITE[Write Config File<br/>JSON or YAML]
320
326
  WRITE --> CHECK{Changes?}
321
327
  CHECK -->|No| SKIP[Skip - No Changes]
@@ -345,7 +351,7 @@ For each repository in the config, the tool:
345
351
  3. Interpolates environment variables
346
352
  4. Cleans the temporary workspace
347
353
  5. Clones the repository
348
- 6. Creates/checks out branch `chore/sync-{sanitized-filename}`
354
+ 6. Creates/checks out branch (custom `--branch` or default `chore/sync-{sanitized-filename}`)
349
355
  7. Generates the config file (JSON or YAML based on filename extension)
350
356
  8. Checks for changes (skips if no changes)
351
357
  9. Commits and pushes changes
@@ -505,6 +511,20 @@ git config --global http.proxy http://proxy.example.com:8080
505
511
  git config --global https.proxy http://proxy.example.com:8080
506
512
  ```
507
513
 
514
+ ### Transient Network Errors
515
+
516
+ The tool automatically retries transient errors (timeouts, connection resets, rate limits) with exponential backoff. By default, it retries 3 times before failing.
517
+
518
+ ```bash
519
+ # Increase retries for unreliable networks
520
+ json-config-sync --config ./config.yaml --retries 5
521
+
522
+ # Disable retries
523
+ json-config-sync --config ./config.yaml --retries 0
524
+ ```
525
+
526
+ Permanent errors (authentication failures, permission denied, repository not found) are not retried.
527
+
508
528
  ## IDE Integration
509
529
 
510
530
  ### VS Code YAML Schema Support
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Interface for executing shell commands.
3
+ * Enables dependency injection for testing and alternative implementations.
4
+ */
5
+ export interface CommandExecutor {
6
+ /**
7
+ * Execute a shell command and return the output.
8
+ * @param command The command to execute
9
+ * @param cwd The working directory for the command
10
+ * @returns Promise resolving to the trimmed stdout output
11
+ * @throws Error if the command fails
12
+ */
13
+ exec(command: string, cwd: string): Promise<string>;
14
+ }
15
+ /**
16
+ * Default implementation that uses Node.js child_process.execSync.
17
+ * Note: Commands are escaped using escapeShellArg before being passed here.
18
+ */
19
+ export declare class ShellCommandExecutor implements CommandExecutor {
20
+ exec(command: string, cwd: string): Promise<string>;
21
+ }
22
+ /**
23
+ * Default executor instance for production use.
24
+ */
25
+ export declare const defaultExecutor: CommandExecutor;
@@ -0,0 +1,28 @@
1
+ import { execSync } from "node:child_process";
2
+ /**
3
+ * Default implementation that uses Node.js child_process.execSync.
4
+ * Note: Commands are escaped using escapeShellArg before being passed here.
5
+ */
6
+ export class ShellCommandExecutor {
7
+ async exec(command, cwd) {
8
+ try {
9
+ return execSync(command, {
10
+ cwd,
11
+ encoding: "utf-8",
12
+ stdio: ["pipe", "pipe", "pipe"],
13
+ }).trim();
14
+ }
15
+ catch (error) {
16
+ // Ensure stderr is always a string for consistent error handling
17
+ const execError = error;
18
+ if (execError.stderr && typeof execError.stderr !== "string") {
19
+ execError.stderr = execError.stderr.toString();
20
+ }
21
+ throw error;
22
+ }
23
+ }
24
+ }
25
+ /**
26
+ * Default executor instance for production use.
27
+ */
28
+ export const defaultExecutor = new ShellCommandExecutor();
@@ -0,0 +1,9 @@
1
+ export type OutputFormat = "json" | "yaml";
2
+ /**
3
+ * Detects output format from file extension.
4
+ */
5
+ export declare function detectOutputFormat(fileName: string): OutputFormat;
6
+ /**
7
+ * Converts content object to string in the appropriate format.
8
+ */
9
+ export declare function convertContentToString(content: Record<string, unknown>, fileName: string): string;
@@ -0,0 +1,21 @@
1
+ import { stringify } from "yaml";
2
+ /**
3
+ * Detects output format from file extension.
4
+ */
5
+ export function detectOutputFormat(fileName) {
6
+ const ext = fileName.toLowerCase().split(".").pop();
7
+ if (ext === "yaml" || ext === "yml") {
8
+ return "yaml";
9
+ }
10
+ return "json";
11
+ }
12
+ /**
13
+ * Converts content object to string in the appropriate format.
14
+ */
15
+ export function convertContentToString(content, fileName) {
16
+ const format = detectOutputFormat(fileName);
17
+ if (format === "yaml") {
18
+ return stringify(content, { indent: 2 });
19
+ }
20
+ return JSON.stringify(content, null, 2);
21
+ }
@@ -0,0 +1,6 @@
1
+ import type { RawConfig, Config } from "./config.js";
2
+ /**
3
+ * Normalizes raw config into expanded, merged config.
4
+ * Pipeline: expand git arrays -> merge content -> interpolate env vars
5
+ */
6
+ export declare function normalizeConfig(raw: RawConfig): Config;
@@ -0,0 +1,43 @@
1
+ import { deepMerge, stripMergeDirectives, createMergeContext, } from "./merge.js";
2
+ import { interpolateEnvVars } from "./env.js";
3
+ /**
4
+ * Normalizes raw config into expanded, merged config.
5
+ * Pipeline: expand git arrays -> merge content -> interpolate env vars
6
+ */
7
+ export function normalizeConfig(raw) {
8
+ const baseContent = raw.content ?? {};
9
+ const defaultStrategy = raw.mergeStrategy ?? "replace";
10
+ const expandedRepos = [];
11
+ for (const rawRepo of raw.repos) {
12
+ // Step 1: Expand git arrays
13
+ const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
14
+ for (const gitUrl of gitUrls) {
15
+ // Step 2: Compute merged content
16
+ let mergedContent;
17
+ if (rawRepo.override) {
18
+ // Override mode: use only repo content
19
+ mergedContent = stripMergeDirectives(structuredClone(rawRepo.content));
20
+ }
21
+ else if (!rawRepo.content) {
22
+ // No repo content: use root content as-is
23
+ mergedContent = structuredClone(baseContent);
24
+ }
25
+ else {
26
+ // Merge mode: deep merge base + overlay
27
+ const ctx = createMergeContext(defaultStrategy);
28
+ mergedContent = deepMerge(structuredClone(baseContent), rawRepo.content, ctx);
29
+ mergedContent = stripMergeDirectives(mergedContent);
30
+ }
31
+ // Step 3: Interpolate env vars
32
+ mergedContent = interpolateEnvVars(mergedContent, { strict: true });
33
+ expandedRepos.push({
34
+ git: gitUrl,
35
+ content: mergedContent,
36
+ });
37
+ }
38
+ }
39
+ return {
40
+ fileName: raw.fileName,
41
+ repos: expandedRepos,
42
+ };
43
+ }
@@ -0,0 +1,6 @@
1
+ import type { RawConfig } from "./config.js";
2
+ /**
3
+ * Validates raw config structure before normalization.
4
+ * @throws Error if validation fails
5
+ */
6
+ export declare function validateRawConfig(config: RawConfig): void;
@@ -0,0 +1,54 @@
1
+ import { isAbsolute } from "node:path";
2
+ /**
3
+ * Validates raw config structure before normalization.
4
+ * @throws Error if validation fails
5
+ */
6
+ export function validateRawConfig(config) {
7
+ if (!config.fileName) {
8
+ throw new Error("Config missing required field: fileName");
9
+ }
10
+ // Validate fileName doesn't allow path traversal
11
+ if (config.fileName.includes("..") || isAbsolute(config.fileName)) {
12
+ throw new Error(`Invalid fileName: must be a relative path without '..' components`);
13
+ }
14
+ // Validate fileName doesn't contain control characters that could bypass shell escaping
15
+ if (/[\n\r\0]/.test(config.fileName)) {
16
+ throw new Error(`Invalid fileName: cannot contain newlines or null bytes`);
17
+ }
18
+ if (!config.repos || !Array.isArray(config.repos)) {
19
+ throw new Error("Config missing required field: repos (must be an array)");
20
+ }
21
+ const validStrategies = ["replace", "append", "prepend"];
22
+ if (config.mergeStrategy !== undefined &&
23
+ !validStrategies.includes(config.mergeStrategy)) {
24
+ throw new Error(`Invalid mergeStrategy: ${config.mergeStrategy}. Must be one of: ${validStrategies.join(", ")}`);
25
+ }
26
+ if (config.content !== undefined &&
27
+ (typeof config.content !== "object" ||
28
+ config.content === null ||
29
+ Array.isArray(config.content))) {
30
+ throw new Error("Root content must be an object");
31
+ }
32
+ const hasRootContent = config.content !== undefined;
33
+ for (let i = 0; i < config.repos.length; i++) {
34
+ const repo = config.repos[i];
35
+ if (!repo.git) {
36
+ throw new Error(`Repo at index ${i} missing required field: git`);
37
+ }
38
+ if (Array.isArray(repo.git) && repo.git.length === 0) {
39
+ throw new Error(`Repo at index ${i} has empty git array`);
40
+ }
41
+ if (!hasRootContent && !repo.content) {
42
+ throw new Error(`Repo at index ${i} missing required field: content (no root-level content defined)`);
43
+ }
44
+ if (repo.override && !repo.content) {
45
+ throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true but no content defined`);
46
+ }
47
+ }
48
+ }
49
+ function getGitDisplayName(git) {
50
+ if (Array.isArray(git)) {
51
+ return git[0] || "unknown";
52
+ }
53
+ return git;
54
+ }
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { type ArrayMergeStrategy } from "./merge.js";
1
+ import type { ArrayMergeStrategy } from "./merge.js";
2
+ export { convertContentToString } from "./config-formatter.js";
2
3
  export interface RawRepoConfig {
3
4
  git: string | string[];
4
5
  content?: Record<string, unknown>;
@@ -19,4 +20,3 @@ export interface Config {
19
20
  repos: RepoConfig[];
20
21
  }
21
22
  export declare function loadConfig(filePath: string): Config;
22
- export declare function convertContentToString(content: Record<string, unknown>, fileName: string): string;
package/dist/config.js CHANGED
@@ -1,97 +1,22 @@
1
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
- // =============================================================================
6
- // Validation
7
- // =============================================================================
8
- function validateRawConfig(config) {
9
- if (!config.fileName) {
10
- throw new Error("Config missing required field: fileName");
11
- }
12
- if (!config.repos || !Array.isArray(config.repos)) {
13
- throw new Error("Config missing required field: repos (must be an array)");
14
- }
15
- const hasRootContent = config.content !== undefined;
16
- for (let i = 0; i < config.repos.length; i++) {
17
- const repo = config.repos[i];
18
- if (!repo.git) {
19
- throw new Error(`Repo at index ${i} missing required field: git`);
20
- }
21
- if (!hasRootContent && !repo.content) {
22
- throw new Error(`Repo at index ${i} missing required field: content (no root-level content defined)`);
23
- }
24
- if (repo.override && !repo.content) {
25
- throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true but no content defined`);
26
- }
27
- }
28
- }
29
- function getGitDisplayName(git) {
30
- if (Array.isArray(git)) {
31
- return git[0] || "unknown";
32
- }
33
- return git;
34
- }
35
- // =============================================================================
36
- // Normalization Pipeline
37
- // =============================================================================
38
- function normalizeConfig(raw) {
39
- const baseContent = raw.content ?? {};
40
- const defaultStrategy = raw.mergeStrategy ?? "replace";
41
- const expandedRepos = [];
42
- for (const rawRepo of raw.repos) {
43
- // Step 1: Expand git arrays
44
- const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
45
- for (const gitUrl of gitUrls) {
46
- // Step 2: Compute merged content
47
- let mergedContent;
48
- if (rawRepo.override) {
49
- // Override mode: use only repo content
50
- mergedContent = stripMergeDirectives(structuredClone(rawRepo.content));
51
- }
52
- else if (!rawRepo.content) {
53
- // No repo content: use root content as-is
54
- mergedContent = structuredClone(baseContent);
55
- }
56
- else {
57
- // Merge mode: deep merge base + overlay
58
- const ctx = createMergeContext(defaultStrategy);
59
- mergedContent = deepMerge(structuredClone(baseContent), rawRepo.content, ctx);
60
- mergedContent = stripMergeDirectives(mergedContent);
61
- }
62
- // Step 3: Interpolate env vars
63
- mergedContent = interpolateEnvVars(mergedContent, { strict: true });
64
- expandedRepos.push({
65
- git: gitUrl,
66
- content: mergedContent,
67
- });
68
- }
69
- }
70
- return {
71
- fileName: raw.fileName,
72
- repos: expandedRepos,
73
- };
74
- }
2
+ import { parse } from "yaml";
3
+ import { validateRawConfig } from "./config-validator.js";
4
+ import { normalizeConfig } from "./config-normalizer.js";
5
+ // Re-export formatter functions for backwards compatibility
6
+ export { convertContentToString } from "./config-formatter.js";
75
7
  // =============================================================================
76
8
  // Public API
77
9
  // =============================================================================
78
10
  export function loadConfig(filePath) {
79
11
  const content = readFileSync(filePath, "utf-8");
80
- const rawConfig = parse(content);
81
- validateRawConfig(rawConfig);
82
- return normalizeConfig(rawConfig);
83
- }
84
- function detectOutputFormat(fileName) {
85
- const ext = fileName.toLowerCase().split(".").pop();
86
- if (ext === "yaml" || ext === "yml") {
87
- return "yaml";
12
+ let rawConfig;
13
+ try {
14
+ rawConfig = parse(content);
88
15
  }
89
- return "json";
90
- }
91
- export function convertContentToString(content, fileName) {
92
- const format = detectOutputFormat(fileName);
93
- if (format === "yaml") {
94
- return stringify(content, { indent: 2 });
16
+ catch (error) {
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ throw new Error(`Failed to parse YAML config at ${filePath}: ${message}`);
95
19
  }
96
- return JSON.stringify(content, null, 2);
20
+ validateRawConfig(rawConfig);
21
+ return normalizeConfig(rawConfig);
97
22
  }
package/dist/git-ops.d.ts CHANGED
@@ -1,27 +1,49 @@
1
+ import { CommandExecutor } from "./command-executor.js";
1
2
  export interface GitOpsOptions {
2
3
  workDir: string;
3
4
  dryRun?: boolean;
5
+ executor?: CommandExecutor;
6
+ /** Number of retries for network operations (default: 3) */
7
+ retries?: number;
4
8
  }
5
9
  export declare class GitOps {
6
10
  private workDir;
7
11
  private dryRun;
12
+ private executor;
13
+ private retries;
8
14
  constructor(options: GitOpsOptions);
9
15
  private exec;
16
+ /**
17
+ * Run a command with retry logic for transient failures.
18
+ * Used for network operations like clone, fetch, push.
19
+ */
20
+ private execWithRetry;
21
+ /**
22
+ * Validates that a file path doesn't escape the workspace directory.
23
+ * @returns The resolved absolute file path
24
+ * @throws Error if path traversal is detected
25
+ */
26
+ private validatePath;
10
27
  cleanWorkspace(): void;
11
- clone(gitUrl: string): void;
12
- createBranch(branchName: string): void;
28
+ clone(gitUrl: string): Promise<void>;
29
+ createBranch(branchName: string): Promise<void>;
13
30
  writeFile(fileName: string, content: string): void;
14
31
  /**
15
32
  * Checks if writing the given content would result in changes.
16
33
  * Works in both normal and dry-run modes by comparing content directly.
17
34
  */
18
35
  wouldChange(fileName: string, content: string): boolean;
19
- hasChanges(): boolean;
20
- commit(message: string): void;
21
- push(branchName: string): void;
22
- getDefaultBranch(): {
36
+ hasChanges(): Promise<boolean>;
37
+ commit(message: string): Promise<void>;
38
+ push(branchName: string): Promise<void>;
39
+ getDefaultBranch(): Promise<{
23
40
  branch: string;
24
41
  method: string;
25
- };
42
+ }>;
26
43
  }
27
44
  export declare function sanitizeBranchName(fileName: string): string;
45
+ /**
46
+ * Validates a user-provided branch name against git's naming rules.
47
+ * @throws Error if the branch name is invalid
48
+ */
49
+ export declare function validateBranchName(branchName: string): void;