@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 CHANGED
@@ -3,6 +3,7 @@
3
3
  [![CI](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
5
5
  [![npm downloads](https://img.shields.io/npm/dw/@aspruyt/json-config-sync.svg)](https://www.npmjs.com/package/@aspruyt/json-config-sync)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 | Required |
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) | No |
175
- | `createOnly` | If `true`, only create file if it doesn't exist | No |
176
- | `header` | Comment line(s) at top of YAML files (string or array) | No |
177
- | `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 |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "3.3.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",