@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/PR.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
## Summary
|
|
2
|
-
|
|
3
|
-
Automated sync of `{{FILE_NAME}}` configuration file.
|
|
4
|
-
|
|
5
|
-
## Changes
|
|
6
|
-
|
|
7
|
-
- {{ACTION}} `{{FILE_NAME}}` in repository root
|
|
8
|
-
|
|
9
|
-
## Source
|
|
10
|
-
|
|
11
|
-
Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
1
|
+
## Summary
|
|
2
|
+
|
|
3
|
+
Automated sync of `{{FILE_NAME}}` configuration file.
|
|
4
|
+
|
|
5
|
+
## Changes
|
|
6
|
+
|
|
7
|
+
- {{ACTION}} `{{FILE_NAME}}` in repository root
|
|
8
|
+
|
|
9
|
+
## Source
|
|
10
|
+
|
|
11
|
+
Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
_This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)_
|
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# json-config-sync
|
|
2
2
|
|
|
3
3
|
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
|
|
4
|
-
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration.yml)
|
|
5
4
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
6
5
|
[](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
|
|
129
|
-
| ------------ | ----- |
|
|
130
|
-
| `--config` | `-c` | Path to YAML config file
|
|
131
|
-
| `--dry-run` | `-d` | Show what would be done without making changes
|
|
132
|
-
| `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`)
|
|
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
|
|
140
|
-
mergeStrategy: replace
|
|
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:
|
|
148
|
+
content: # Base config content (optional)
|
|
143
149
|
key: value
|
|
144
150
|
|
|
145
|
-
repos:
|
|
151
|
+
repos: # List of repositories
|
|
146
152
|
- git: git@github.com:org/repo.git
|
|
147
|
-
content:
|
|
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
|
|
154
|
-
| --------------- |
|
|
155
|
-
| `fileName` | Target file name (`.json` → JSON output, `.yaml`/`.yml` → YAML output)| Yes |
|
|
156
|
-
| `content` | Base config inherited by all repos
|
|
157
|
-
| `mergeStrategy` | Default array merge strategy: `replace`, `append`, `prepend`
|
|
158
|
-
| `repos` | Array of repository configurations
|
|
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
|
|
165
|
-
| ---------- |
|
|
166
|
-
| `git` | Git URL (string) or array of URLs
|
|
167
|
-
| `content` | Content overlay merged onto base (optional if base exists)| No
|
|
168
|
-
| `override` | If `true`, ignore base content and use only this repo's
|
|
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}
|
|
179
|
-
environment: ${ENV:-development}
|
|
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
|
|
203
|
+
$arrayMerge: append # append | prepend | replace
|
|
198
204
|
values:
|
|
199
|
-
- 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
|
|
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,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,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 {
|
|
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
|
|
2
|
-
import { parse
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
//
|
|
6
|
-
|
|
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,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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 ===
|
|
25
|
+
return typeof val === "object" && val !== null && !Array.isArray(val);
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* Process a single string value, replacing environment variable placeholders.
|
|
@@ -35,11 +35,11 @@ function processString(value, options) {
|
|
|
35
35
|
return envValue;
|
|
36
36
|
}
|
|
37
37
|
// Has default value (:-default)
|
|
38
|
-
if (modifier ===
|
|
39
|
-
return defaultOrMsg ??
|
|
38
|
+
if (modifier === "-") {
|
|
39
|
+
return defaultOrMsg ?? "";
|
|
40
40
|
}
|
|
41
41
|
// Required with message (:?message)
|
|
42
|
-
if (modifier ===
|
|
42
|
+
if (modifier === "?") {
|
|
43
43
|
const message = defaultOrMsg || `is required`;
|
|
44
44
|
throw new Error(`${varName}: ${message}`);
|
|
45
45
|
}
|
|
@@ -55,7 +55,7 @@ function processString(value, options) {
|
|
|
55
55
|
* Recursively process a value, interpolating environment variables in strings.
|
|
56
56
|
*/
|
|
57
57
|
function processValue(value, options) {
|
|
58
|
-
if (typeof value ===
|
|
58
|
+
if (typeof value === "string") {
|
|
59
59
|
return processString(value, options);
|
|
60
60
|
}
|
|
61
61
|
if (Array.isArray(value)) {
|
package/dist/git-ops.d.ts
CHANGED
|
@@ -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;
|