@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/PR.md CHANGED
@@ -1,14 +1,15 @@
1
- ## Summary
2
-
3
- Automated sync of `{{FILE_NAME}}` configuration file.
4
-
5
- ## Changes
6
-
7
- - {{ACTION}} `{{FILE_NAME}}` in repository root
8
-
9
- ## Source
10
-
11
- Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
12
-
13
- ---
14
- *This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)*
1
+ ## Summary
2
+
3
+ Automated sync of `{{FILE_NAME}}` configuration file.
4
+
5
+ ## Changes
6
+
7
+ - {{ACTION}} `{{FILE_NAME}}` in repository root
8
+
9
+ ## Source
10
+
11
+ Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
12
+
13
+ ---
14
+
15
+ _This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)_
package/README.md CHANGED
@@ -1,7 +1,6 @@
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)
5
4
  [![npm version](https://img.shields.io/npm/v/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
6
5
  [![npm downloads](https://img.shields.io/npm/dw/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
7
6
 
@@ -21,6 +20,7 @@ A CLI tool that syncs JSON or YAML configuration files across multiple GitHub an
21
20
  - [CI/CD Integration](#cicd-integration)
22
21
  - [Output Examples](#output-examples)
23
22
  - [Troubleshooting](#troubleshooting)
23
+ - [IDE Integration](#ide-integration)
24
24
  - [Development](#development)
25
25
  - [License](#license)
26
26
 
@@ -69,6 +69,7 @@ json-config-sync --config ./config.yaml
69
69
  - **GitHub & Azure DevOps** - Works with both platforms
70
70
  - **Dry-Run Mode** - Preview changes without creating PRs
71
71
  - **Error Resilience** - Continues processing if individual repos fail
72
+ - **Automatic Retries** - Retries transient network errors with exponential backoff
72
73
 
73
74
  ## Installation
74
75
 
@@ -121,51 +122,56 @@ json-config-sync --config ./config.yaml --dry-run
121
122
 
122
123
  # Custom work directory
123
124
  json-config-sync --config ./config.yaml --work-dir ./my-temp
125
+
126
+ # Custom branch name
127
+ json-config-sync --config ./config.yaml --branch feature/update-eslint
124
128
  ```
125
129
 
126
130
  ### Options
127
131
 
128
- | Option | Alias | Description | Required |
129
- | ------------ | ----- | -------------------------------------------------- | -------- |
130
- | `--config` | `-c` | Path to YAML config file | Yes |
131
- | `--dry-run` | `-d` | Show what would be done without making changes | No |
132
- | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
132
+ | Option | Alias | Description | Required |
133
+ | ------------ | ----- | ------------------------------------------------------- | -------- |
134
+ | `--config` | `-c` | Path to YAML config file | Yes |
135
+ | `--dry-run` | `-d` | Show what would be done without making changes | No |
136
+ | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
137
+ | `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
138
+ | `--branch` | `-b` | Override branch name (default: `chore/sync-{filename}`) | No |
133
139
 
134
140
  ## Configuration Format
135
141
 
136
142
  ### Basic Structure
137
143
 
138
144
  ```yaml
139
- fileName: my.config.json # Target file (.json outputs JSON, .yaml/.yml outputs YAML)
140
- mergeStrategy: replace # Default array merge strategy (optional)
145
+ fileName: my.config.json # Target file (.json outputs JSON, .yaml/.yml outputs YAML)
146
+ mergeStrategy: replace # Default array merge strategy (optional)
141
147
 
142
- content: # Base config content (optional)
148
+ content: # Base config content (optional)
143
149
  key: value
144
150
 
145
- repos: # List of repositories
151
+ repos: # List of repositories
146
152
  - git: git@github.com:org/repo.git
147
- content: # Per-repo overlay (optional if base content exists)
153
+ content: # Per-repo overlay (optional if base content exists)
148
154
  key: override
149
155
  ```
150
156
 
151
157
  ### Root-Level Fields
152
158
 
153
- | Field | Description | Required |
154
- | --------------- | --------------------------------------------------------------------- | -------- |
155
- | `fileName` | Target file name (`.json` → JSON output, `.yaml`/`.yml` → YAML output)| Yes |
156
- | `content` | Base config inherited by all repos | No* |
157
- | `mergeStrategy` | Default array merge strategy: `replace`, `append`, `prepend` | No |
158
- | `repos` | Array of repository configurations | Yes |
159
+ | Field | Description | Required |
160
+ | --------------- | ---------------------------------------------------------------------- | -------- |
161
+ | `fileName` | Target file name (`.json` → JSON output, `.yaml`/`.yml` → YAML output) | Yes |
162
+ | `content` | Base config inherited by all repos | No\* |
163
+ | `mergeStrategy` | Default array merge strategy: `replace`, `append`, `prepend` | No |
164
+ | `repos` | Array of repository configurations | Yes |
159
165
 
160
166
  \* Required if any repo entry omits the `content` field.
161
167
 
162
168
  ### Per-Repo Fields
163
169
 
164
- | Field | Description | Required |
165
- | ---------- | --------------------------------------------------------- | -------- |
166
- | `git` | Git URL (string) or array of URLs | Yes |
167
- | `content` | Content overlay merged onto base (optional if base exists)| No* |
168
- | `override` | If `true`, ignore base content and use only this repo's | No |
170
+ | Field | Description | Required |
171
+ | ---------- | ---------------------------------------------------------- | -------- |
172
+ | `git` | Git URL (string) or array of URLs | Yes |
173
+ | `content` | Content overlay merged onto base (optional if base exists) | No\* |
174
+ | `override` | If `true`, ignore base content and use only this repo's | No |
169
175
 
170
176
  \* Required if no root-level `content` is defined.
171
177
 
@@ -175,8 +181,8 @@ Use `${VAR}` syntax in string values:
175
181
 
176
182
  ```yaml
177
183
  content:
178
- apiUrl: ${API_URL} # Required - errors if not set
179
- environment: ${ENV:-development} # With default value
184
+ apiUrl: ${API_URL} # Required - errors if not set
185
+ environment: ${ENV:-development} # With default value
180
186
  secretKey: ${SECRET:?Secret required} # Required with custom error message
181
187
  ```
182
188
 
@@ -194,9 +200,9 @@ repos:
194
200
  - git: git@github.com:org/repo.git
195
201
  content:
196
202
  features:
197
- $arrayMerge: append # append | prepend | replace
203
+ $arrayMerge: append # append | prepend | replace
198
204
  values:
199
- - custom-feature # Results in: [core, monitoring, custom-feature]
205
+ - custom-feature # Results in: [core, monitoring, custom-feature]
200
206
  ```
201
207
 
202
208
  ## Examples
@@ -314,7 +320,7 @@ flowchart TB
314
320
  end
315
321
 
316
322
  subgraph Processing["For Each Repository"]
317
- CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>chore/sync-filename]
323
+ CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>--branch or chore/sync-filename]
318
324
  BRANCH --> WRITE[Write Config File<br/>JSON or YAML]
319
325
  WRITE --> CHECK{Changes?}
320
326
  CHECK -->|No| SKIP[Skip - No Changes]
@@ -344,7 +350,7 @@ For each repository in the config, the tool:
344
350
  3. Interpolates environment variables
345
351
  4. Cleans the temporary workspace
346
352
  5. Clones the repository
347
- 6. Creates/checks out branch `chore/sync-{sanitized-filename}`
353
+ 6. Creates/checks out branch (custom `--branch` or default `chore/sync-{sanitized-filename}`)
348
354
  7. Generates the config file (JSON or YAML based on filename extension)
349
355
  8. Checks for changes (skips if no changes)
350
356
  9. Commits and pushes changes
@@ -504,6 +510,57 @@ git config --global http.proxy http://proxy.example.com:8080
504
510
  git config --global https.proxy http://proxy.example.com:8080
505
511
  ```
506
512
 
513
+ ### Transient Network Errors
514
+
515
+ The tool automatically retries transient errors (timeouts, connection resets, rate limits) with exponential backoff. By default, it retries 3 times before failing.
516
+
517
+ ```bash
518
+ # Increase retries for unreliable networks
519
+ json-config-sync --config ./config.yaml --retries 5
520
+
521
+ # Disable retries
522
+ json-config-sync --config ./config.yaml --retries 0
523
+ ```
524
+
525
+ Permanent errors (authentication failures, permission denied, repository not found) are not retried.
526
+
527
+ ## IDE Integration
528
+
529
+ ### VS Code YAML Schema Support
530
+
531
+ 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:
532
+
533
+ **Option 1: Inline comment**
534
+
535
+ ```yaml
536
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/anthony-spruyt/json-config-sync/main/config-schema.json
537
+ fileName: my.config.json
538
+ content:
539
+ key: value
540
+ repos:
541
+ - git: git@github.com:org/repo.git
542
+ ```
543
+
544
+ **Option 2: VS Code settings** (`.vscode/settings.json`)
545
+
546
+ ```json
547
+ {
548
+ "yaml.schemas": {
549
+ "https://raw.githubusercontent.com/anthony-spruyt/json-config-sync/main/config-schema.json": [
550
+ "**/sync-config.yaml",
551
+ "**/config-sync.yaml"
552
+ ]
553
+ }
554
+ }
555
+ ```
556
+
557
+ This enables:
558
+
559
+ - Autocomplete for `fileName`, `mergeStrategy`, `repos`, `content`, `git`, `override`
560
+ - Enum suggestions for `mergeStrategy` values (`replace`, `append`, `prepend`)
561
+ - Validation of required fields
562
+ - Hover documentation for each field
563
+
507
564
  ## Development
508
565
 
509
566
  ```bash
@@ -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
- 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
- }
1
+ import { readFileSync } from "node:fs";
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
- 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';
11
+ const content = readFileSync(filePath, "utf-8");
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/env.js CHANGED
@@ -22,7 +22,7 @@ const ENV_VAR_REGEX = /\$\{([^}:]+)(?::([?-])([^}]*))?\}/g;
22
22
  * Check if a value is a plain object (not null, not array).
23
23
  */
24
24
  function isPlainObject(val) {
25
- return typeof val === 'object' && val !== null && !Array.isArray(val);
25
+ return typeof val === "object" && val !== null && !Array.isArray(val);
26
26
  }
27
27
  /**
28
28
  * Process a single string value, replacing environment variable placeholders.
@@ -35,11 +35,11 @@ function processString(value, options) {
35
35
  return envValue;
36
36
  }
37
37
  // Has default value (:-default)
38
- if (modifier === '-') {
39
- return defaultOrMsg ?? '';
38
+ if (modifier === "-") {
39
+ return defaultOrMsg ?? "";
40
40
  }
41
41
  // Required with message (:?message)
42
- if (modifier === '?') {
42
+ if (modifier === "?") {
43
43
  const message = defaultOrMsg || `is required`;
44
44
  throw new Error(`${varName}: ${message}`);
45
45
  }
@@ -55,7 +55,7 @@ function processString(value, options) {
55
55
  * Recursively process a value, interpolating environment variables in strings.
56
56
  */
57
57
  function processValue(value, options) {
58
- if (typeof value === 'string') {
58
+ if (typeof value === "string") {
59
59
  return processString(value, options);
60
60
  }
61
61
  if (Array.isArray(value)) {
package/dist/git-ops.d.ts CHANGED
@@ -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;