@aspruyt/json-config-sync 3.3.0 → 3.5.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/README.md +81 -7
- package/dist/config.js +5 -0
- package/dist/file-reference-resolver.d.ts +20 -0
- package/dist/file-reference-resolver.js +125 -0
- package/dist/git-ops.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
5
5
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
6
|
+
[](LICENSE)
|
|
6
7
|
|
|
7
8
|
A CLI tool that syncs JSON, YAML, or text configuration files across multiple GitHub and Azure DevOps repositories by creating pull requests. Output format is automatically detected from the target filename extension (`.json` → JSON, `.yaml`/`.yml` → YAML, other → text).
|
|
8
9
|
|
|
@@ -61,7 +62,9 @@ json-config-sync --config ./config.yaml
|
|
|
61
62
|
|
|
62
63
|
- **Multi-File Sync** - Sync multiple config files in a single run
|
|
63
64
|
- **Multi-Format Output** - JSON, YAML, or plain text based on filename extension
|
|
65
|
+
- **Subdirectory Support** - Sync files to any path (e.g., `.github/workflows/ci.yml`)
|
|
64
66
|
- **Text Files** - Sync `.gitignore`, `.markdownlintignore`, etc. with string or lines array
|
|
67
|
+
- **File References** - Use `@path/to/file` to load content from external template files
|
|
65
68
|
- **Content Inheritance** - Define base config once, override per-repo as needed
|
|
66
69
|
- **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
|
|
67
70
|
- **Environment Variables** - Use `${VAR}` syntax for dynamic values
|
|
@@ -168,13 +171,13 @@ repos: # List of repositories
|
|
|
168
171
|
|
|
169
172
|
### Per-File Fields
|
|
170
173
|
|
|
171
|
-
| Field | Description
|
|
172
|
-
| --------------- |
|
|
173
|
-
| `content` | Base config: object for JSON/YAML files, string or string[] for text files (omit for empty file) | No |
|
|
174
|
-
| `mergeStrategy` | Merge strategy: `replace`, `append`, `prepend` (for arrays and text lines)
|
|
175
|
-
| `createOnly` | If `true`, only create file if it doesn't exist
|
|
176
|
-
| `header` | Comment line(s) at top of YAML files (string or array)
|
|
177
|
-
| `schemaUrl` | Adds `# yaml-language-server: $schema=<url>` to YAML files
|
|
174
|
+
| Field | Description | Required |
|
|
175
|
+
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
|
176
|
+
| `content` | Base config: object for JSON/YAML files, string or string[] for text files, or `@path/to/file` to load from external template (omit for empty file) | No |
|
|
177
|
+
| `mergeStrategy` | Merge strategy: `replace`, `append`, `prepend` (for arrays and text lines) | No |
|
|
178
|
+
| `createOnly` | If `true`, only create file if it doesn't exist | No |
|
|
179
|
+
| `header` | Comment line(s) at top of YAML files (string or array) | No |
|
|
180
|
+
| `schemaUrl` | Adds `# yaml-language-server: $schema=<url>` to YAML files | No |
|
|
178
181
|
|
|
179
182
|
### Per-Repo Fields
|
|
180
183
|
|
|
@@ -486,6 +489,77 @@ repos:
|
|
|
486
489
|
|
|
487
490
|
**Validation:** JSON/YAML file extensions (`.json`, `.yaml`, `.yml`) require object content. Other extensions require string or string[] content.
|
|
488
491
|
|
|
492
|
+
### Subdirectory Paths
|
|
493
|
+
|
|
494
|
+
Sync files to any subdirectory path - parent directories are created automatically:
|
|
495
|
+
|
|
496
|
+
```yaml
|
|
497
|
+
files:
|
|
498
|
+
# GitHub Actions workflow
|
|
499
|
+
".github/workflows/ci.yml":
|
|
500
|
+
content:
|
|
501
|
+
name: CI
|
|
502
|
+
on: [push, pull_request]
|
|
503
|
+
jobs:
|
|
504
|
+
build:
|
|
505
|
+
runs-on: ubuntu-latest
|
|
506
|
+
steps:
|
|
507
|
+
- uses: actions/checkout@v4
|
|
508
|
+
|
|
509
|
+
# Nested config directory
|
|
510
|
+
"config/settings/app.json":
|
|
511
|
+
content:
|
|
512
|
+
environment: production
|
|
513
|
+
debug: false
|
|
514
|
+
|
|
515
|
+
repos:
|
|
516
|
+
- git:
|
|
517
|
+
- git@github.com:org/frontend.git
|
|
518
|
+
- git@github.com:org/backend.git
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
**Note:** Quote file paths containing `/` in YAML keys. Parent directories are created if they don't exist.
|
|
522
|
+
|
|
523
|
+
### File References
|
|
524
|
+
|
|
525
|
+
Instead of inline content, you can reference external template files using the `@path/to/file` syntax:
|
|
526
|
+
|
|
527
|
+
```yaml
|
|
528
|
+
files:
|
|
529
|
+
.prettierrc.json:
|
|
530
|
+
content: "@templates/prettierrc.json"
|
|
531
|
+
.eslintrc.yaml:
|
|
532
|
+
content: "@templates/eslintrc.yaml"
|
|
533
|
+
header: "Auto-generated - do not edit"
|
|
534
|
+
schemaUrl: "https://json.schemastore.org/eslintrc"
|
|
535
|
+
.gitignore:
|
|
536
|
+
content: "@templates/gitignore.txt"
|
|
537
|
+
|
|
538
|
+
repos:
|
|
539
|
+
- git: git@github.com:org/repo.git
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
**How it works:**
|
|
543
|
+
|
|
544
|
+
- File references start with `@` followed by a relative path
|
|
545
|
+
- Paths are resolved relative to the config file's directory
|
|
546
|
+
- JSON/YAML files are parsed as objects, other files as strings
|
|
547
|
+
- Metadata fields (`header`, `schemaUrl`, `createOnly`, `mergeStrategy`) remain in the config
|
|
548
|
+
- Per-repo overlays still work - they merge onto the resolved file content
|
|
549
|
+
|
|
550
|
+
**Example directory structure:**
|
|
551
|
+
|
|
552
|
+
```
|
|
553
|
+
config/
|
|
554
|
+
sync-config.yaml # content: "@templates/prettier.json"
|
|
555
|
+
templates/
|
|
556
|
+
prettier.json # Actual Prettier config
|
|
557
|
+
eslintrc.yaml # Actual ESLint config
|
|
558
|
+
gitignore.txt # Template .gitignore content
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
**Security:** File references are restricted to the config file's directory tree. Paths like `@../../../etc/passwd` or `@/etc/passwd` are blocked.
|
|
562
|
+
|
|
489
563
|
## Supported Git URL Formats
|
|
490
564
|
|
|
491
565
|
### GitHub
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
2
3
|
import { parse } from "yaml";
|
|
3
4
|
import { validateRawConfig } from "./config-validator.js";
|
|
4
5
|
import { normalizeConfig } from "./config-normalizer.js";
|
|
6
|
+
import { resolveFileReferencesInConfig } from "./file-reference-resolver.js";
|
|
5
7
|
// Re-export formatter functions for backwards compatibility
|
|
6
8
|
export { convertContentToString } from "./config-formatter.js";
|
|
7
9
|
// =============================================================================
|
|
@@ -9,6 +11,7 @@ export { convertContentToString } from "./config-formatter.js";
|
|
|
9
11
|
// =============================================================================
|
|
10
12
|
export function loadConfig(filePath) {
|
|
11
13
|
const content = readFileSync(filePath, "utf-8");
|
|
14
|
+
const configDir = dirname(filePath);
|
|
12
15
|
let rawConfig;
|
|
13
16
|
try {
|
|
14
17
|
rawConfig = parse(content);
|
|
@@ -17,6 +20,8 @@ export function loadConfig(filePath) {
|
|
|
17
20
|
const message = error instanceof Error ? error.message : String(error);
|
|
18
21
|
throw new Error(`Failed to parse YAML config at ${filePath}: ${message}`);
|
|
19
22
|
}
|
|
23
|
+
// Resolve file references before validation so content type checking works
|
|
24
|
+
rawConfig = resolveFileReferencesInConfig(rawConfig, { configDir });
|
|
20
25
|
validateRawConfig(rawConfig);
|
|
21
26
|
return normalizeConfig(rawConfig);
|
|
22
27
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ContentValue, RawConfig } from "./config.js";
|
|
2
|
+
export interface FileReferenceOptions {
|
|
3
|
+
configDir: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Check if a value is a file reference (string starting with @)
|
|
7
|
+
*/
|
|
8
|
+
export declare function isFileReference(value: unknown): value is string;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a file reference to its content.
|
|
11
|
+
* - JSON files are parsed as objects
|
|
12
|
+
* - YAML files are parsed as objects
|
|
13
|
+
* - Other files are returned as strings
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveFileReference(reference: string, configDir: string): ContentValue;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve all file references in a raw config.
|
|
18
|
+
* Walks through files at root level and per-repo level.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveFileReferencesInConfig(raw: RawConfig, options: FileReferenceOptions): RawConfig;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve, isAbsolute, normalize, extname } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
/**
|
|
5
|
+
* Check if a value is a file reference (string starting with @)
|
|
6
|
+
*/
|
|
7
|
+
export function isFileReference(value) {
|
|
8
|
+
return typeof value === "string" && value.startsWith("@");
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a file reference to its content.
|
|
12
|
+
* - JSON files are parsed as objects
|
|
13
|
+
* - YAML files are parsed as objects
|
|
14
|
+
* - Other files are returned as strings
|
|
15
|
+
*/
|
|
16
|
+
export function resolveFileReference(reference, configDir) {
|
|
17
|
+
const relativePath = reference.slice(1); // Remove @ prefix
|
|
18
|
+
if (relativePath.length === 0) {
|
|
19
|
+
throw new Error(`Invalid file reference "${reference}": path is empty`);
|
|
20
|
+
}
|
|
21
|
+
// Security: block absolute paths
|
|
22
|
+
if (isAbsolute(relativePath)) {
|
|
23
|
+
throw new Error(`File reference "${reference}" uses absolute path. Use relative paths only.`);
|
|
24
|
+
}
|
|
25
|
+
const resolvedPath = resolve(configDir, relativePath);
|
|
26
|
+
const normalizedResolved = normalize(resolvedPath);
|
|
27
|
+
const normalizedConfigDir = normalize(configDir);
|
|
28
|
+
// Security: ensure path stays within config directory tree
|
|
29
|
+
// Use path separator to ensure we're checking directory boundaries
|
|
30
|
+
if (!normalizedResolved.startsWith(normalizedConfigDir + "/") &&
|
|
31
|
+
normalizedResolved !== normalizedConfigDir) {
|
|
32
|
+
throw new Error(`File reference "${reference}" escapes config directory. ` +
|
|
33
|
+
`References must be within "${configDir}".`);
|
|
34
|
+
}
|
|
35
|
+
// Load file
|
|
36
|
+
let content;
|
|
37
|
+
try {
|
|
38
|
+
content = readFileSync(resolvedPath, "utf-8");
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
42
|
+
throw new Error(`Failed to load file reference "${reference}": ${msg}`);
|
|
43
|
+
}
|
|
44
|
+
// Parse based on extension
|
|
45
|
+
const ext = extname(relativePath).toLowerCase();
|
|
46
|
+
if (ext === ".json") {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(content);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
52
|
+
throw new Error(`Invalid JSON in "${reference}": ${msg}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (ext === ".yaml" || ext === ".yml") {
|
|
56
|
+
try {
|
|
57
|
+
return parseYaml(content);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
61
|
+
throw new Error(`Invalid YAML in "${reference}": ${msg}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Text file - return as string
|
|
65
|
+
return content;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Recursively resolve file references in a content value.
|
|
69
|
+
* Only string values starting with @ are resolved.
|
|
70
|
+
*/
|
|
71
|
+
function resolveContentValue(value, configDir) {
|
|
72
|
+
if (value === undefined) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
// If it's a file reference, resolve it
|
|
76
|
+
if (isFileReference(value)) {
|
|
77
|
+
return resolveFileReference(value, configDir);
|
|
78
|
+
}
|
|
79
|
+
// Otherwise return as-is (objects, arrays, plain strings)
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve all file references in a raw config.
|
|
84
|
+
* Walks through files at root level and per-repo level.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveFileReferencesInConfig(raw, options) {
|
|
87
|
+
const { configDir } = options;
|
|
88
|
+
// Deep clone to avoid mutating input
|
|
89
|
+
const result = JSON.parse(JSON.stringify(raw));
|
|
90
|
+
// Resolve root-level file content
|
|
91
|
+
if (result.files) {
|
|
92
|
+
for (const [fileName, fileConfig] of Object.entries(result.files)) {
|
|
93
|
+
if (fileConfig &&
|
|
94
|
+
typeof fileConfig === "object" &&
|
|
95
|
+
"content" in fileConfig) {
|
|
96
|
+
const resolved = resolveContentValue(fileConfig.content, configDir);
|
|
97
|
+
if (resolved !== undefined) {
|
|
98
|
+
result.files[fileName] = { ...fileConfig, content: resolved };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Resolve per-repo file content
|
|
104
|
+
if (result.repos) {
|
|
105
|
+
for (const repo of result.repos) {
|
|
106
|
+
if (repo.files) {
|
|
107
|
+
for (const [fileName, fileOverride] of Object.entries(repo.files)) {
|
|
108
|
+
// Skip false (exclusion) entries
|
|
109
|
+
if (fileOverride === false) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (fileOverride &&
|
|
113
|
+
typeof fileOverride === "object" &&
|
|
114
|
+
"content" in fileOverride) {
|
|
115
|
+
const resolved = resolveContentValue(fileOverride.content, configDir);
|
|
116
|
+
if (resolved !== undefined) {
|
|
117
|
+
repo.files[fileName] = { ...fileOverride, content: resolved };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
package/dist/git-ops.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync, } from "node:fs";
|
|
2
|
-
import { join, resolve, relative, isAbsolute } from "node:path";
|
|
2
|
+
import { join, resolve, relative, isAbsolute, dirname } from "node:path";
|
|
3
3
|
import { escapeShellArg } from "./shell-utils.js";
|
|
4
4
|
import { defaultExecutor } from "./command-executor.js";
|
|
5
5
|
import { withRetry } from "./retry-utils.js";
|
|
@@ -97,6 +97,8 @@ export class GitOps {
|
|
|
97
97
|
return;
|
|
98
98
|
}
|
|
99
99
|
const filePath = this.validatePath(fileName);
|
|
100
|
+
// Create parent directories if they don't exist
|
|
101
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
100
102
|
// Normalize trailing newline - ensure exactly one
|
|
101
103
|
const normalized = content.endsWith("\n") ? content : content + "\n";
|
|
102
104
|
writeFileSync(filePath, normalized, "utf-8");
|
package/package.json
CHANGED