@aspruyt/json-config-sync 3.4.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 CHANGED
@@ -64,6 +64,7 @@ json-config-sync --config ./config.yaml
64
64
  - **Multi-Format Output** - JSON, YAML, or plain text based on filename extension
65
65
  - **Subdirectory Support** - Sync files to any path (e.g., `.github/workflows/ci.yml`)
66
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
67
68
  - **Content Inheritance** - Define base config once, override per-repo as needed
68
69
  - **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
69
70
  - **Environment Variables** - Use `${VAR}` syntax for dynamic values
@@ -170,13 +171,13 @@ repos: # List of repositories
170
171
 
171
172
  ### Per-File Fields
172
173
 
173
- | Field | Description | Required |
174
- | --------------- | ------------------------------------------------------------------------------------------------ | -------- |
175
- | `content` | Base config: object for JSON/YAML files, string or string[] for text files (omit for empty file) | No |
176
- | `mergeStrategy` | Merge strategy: `replace`, `append`, `prepend` (for arrays and text lines) | No |
177
- | `createOnly` | If `true`, only create file if it doesn't exist | No |
178
- | `header` | Comment line(s) at top of YAML files (string or array) | No |
179
- | `schemaUrl` | Adds `# yaml-language-server: $schema=<url>` to YAML files | No |
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 |
180
181
 
181
182
  ### Per-Repo Fields
182
183
 
@@ -519,6 +520,46 @@ repos:
519
520
 
520
521
  **Note:** Quote file paths containing `/` in YAML keys. Parent directories are created if they don't exist.
521
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
+
522
563
  ## Supported Git URL Formats
523
564
 
524
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "CLI tool to sync JSON or YAML configuration files across multiple GitHub and Azure DevOps repositories",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",