@aspruyt/json-config-sync 3.2.0 → 3.4.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,8 +3,9 @@
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
- A CLI tool that syncs JSON or YAML configuration files across multiple GitHub and Azure DevOps repositories by creating pull requests. Output format is automatically detected from the target filename extension.
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
 
9
10
  ## Table of Contents
10
11
 
@@ -60,7 +61,9 @@ json-config-sync --config ./config.yaml
60
61
  ## Features
61
62
 
62
63
  - **Multi-File Sync** - Sync multiple config files in a single run
63
- - **JSON/YAML Output** - Automatically outputs JSON or YAML based on filename extension
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`)
66
+ - **Text Files** - Sync `.gitignore`, `.markdownlintignore`, etc. with string or lines array
64
67
  - **Content Inheritance** - Define base config once, override per-repo as needed
65
68
  - **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
66
69
  - **Environment Variables** - Use `${VAR}` syntax for dynamic values
@@ -167,13 +170,13 @@ repos: # List of repositories
167
170
 
168
171
  ### Per-File Fields
169
172
 
170
- | Field | Description | Required |
171
- | --------------- | ---------------------------------------------------------- | -------- |
172
- | `content` | Base config inherited by all repos (omit for empty file) | No |
173
- | `mergeStrategy` | Array merge strategy: `replace`, `append`, `prepend` | No |
174
- | `createOnly` | If `true`, only create file if it doesn't exist | No |
175
- | `header` | Comment line(s) at top of YAML files (string or array) | No |
176
- | `schemaUrl` | Adds `# yaml-language-server: $schema=<url>` to YAML files | No |
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 |
177
180
 
178
181
  ### Per-Repo Fields
179
182
 
@@ -450,6 +453,72 @@ scan:
450
453
 
451
454
  **Note:** `header` and `schemaUrl` only apply to YAML output files (`.yaml`, `.yml`). They are ignored for JSON files.
452
455
 
456
+ ### Text Files
457
+
458
+ Sync text files like `.gitignore`, `.markdownlintignore`, or `.env.example` using string or lines array content:
459
+
460
+ ```yaml
461
+ files:
462
+ # String content (multiline text)
463
+ .markdownlintignore:
464
+ createOnly: true
465
+ content: |-
466
+ # Claude Code generated files
467
+ .claude/
468
+
469
+ # Lines array with merge strategy
470
+ .gitignore:
471
+ mergeStrategy: append
472
+ content:
473
+ - "node_modules/"
474
+ - "dist/"
475
+
476
+ repos:
477
+ - git: git@github.com:org/repo.git
478
+ files:
479
+ .gitignore:
480
+ content:
481
+ - "coverage/" # Appended to base lines
482
+ ```
483
+
484
+ **Content Types:**
485
+
486
+ - **String content** (`content: |-`) - Direct text output with environment variable interpolation. Merging always replaces the base.
487
+ - **Lines array** (`content: ['line1', 'line2']`) - Each line joined with newlines. Supports merge strategies (`append`, `prepend`, `replace`).
488
+
489
+ **Validation:** JSON/YAML file extensions (`.json`, `.yaml`, `.yml`) require object content. Other extensions require string or string[] content.
490
+
491
+ ### Subdirectory Paths
492
+
493
+ Sync files to any subdirectory path - parent directories are created automatically:
494
+
495
+ ```yaml
496
+ files:
497
+ # GitHub Actions workflow
498
+ ".github/workflows/ci.yml":
499
+ content:
500
+ name: CI
501
+ on: [push, pull_request]
502
+ jobs:
503
+ build:
504
+ runs-on: ubuntu-latest
505
+ steps:
506
+ - uses: actions/checkout@v4
507
+
508
+ # Nested config directory
509
+ "config/settings/app.json":
510
+ content:
511
+ environment: production
512
+ debug: false
513
+
514
+ repos:
515
+ - git:
516
+ - git@github.com:org/frontend.git
517
+ - git@github.com:org/backend.git
518
+ ```
519
+
520
+ **Note:** Quote file paths containing `/` in YAML keys. Parent directories are created if they don't exist.
521
+
453
522
  ## Supported Git URL Formats
454
523
 
455
524
  ### GitHub
@@ -11,7 +11,7 @@ export interface ConvertOptions {
11
11
  */
12
12
  export declare function detectOutputFormat(fileName: string): OutputFormat;
13
13
  /**
14
- * Converts content object to string in the appropriate format.
15
- * Handles null content (empty files) and comments (YAML only).
14
+ * Converts content to string in the appropriate format.
15
+ * Handles null content (empty files), text content (string/string[]), and object content (JSON/YAML).
16
16
  */
17
- export declare function convertContentToString(content: Record<string, unknown> | null, fileName: string, options?: ConvertOptions): string;
17
+ export declare function convertContentToString(content: Record<string, unknown> | string | string[] | null, fileName: string, options?: ConvertOptions): string;
@@ -47,13 +47,13 @@ function buildCommentOnlyYaml(header, schemaUrl) {
47
47
  return lines.join("\n") + "\n";
48
48
  }
49
49
  /**
50
- * Converts content object to string in the appropriate format.
51
- * Handles null content (empty files) and comments (YAML only).
50
+ * Converts content to string in the appropriate format.
51
+ * Handles null content (empty files), text content (string/string[]), and object content (JSON/YAML).
52
52
  */
53
53
  export function convertContentToString(content, fileName, options) {
54
- const format = detectOutputFormat(fileName);
55
54
  // Handle empty file case
56
55
  if (content === null) {
56
+ const format = detectOutputFormat(fileName);
57
57
  if (format === "yaml" && options) {
58
58
  const commentOnly = buildCommentOnlyYaml(options.header, options.schemaUrl);
59
59
  if (commentOnly) {
@@ -62,6 +62,19 @@ export function convertContentToString(content, fileName, options) {
62
62
  }
63
63
  return "";
64
64
  }
65
+ // Handle string content (text file)
66
+ if (typeof content === "string") {
67
+ // Ensure trailing newline for text files
68
+ return content.endsWith("\n") ? content : content + "\n";
69
+ }
70
+ // Handle string[] content (text file with lines)
71
+ if (Array.isArray(content)) {
72
+ // Join lines with newlines and ensure trailing newline
73
+ const text = content.join("\n");
74
+ return text.length > 0 ? text + "\n" : "";
75
+ }
76
+ // Handle object content (JSON/YAML)
77
+ const format = detectOutputFormat(fileName);
65
78
  if (format === "yaml") {
66
79
  // Use Document API for YAML to support comments
67
80
  const doc = new Document(content);
@@ -75,5 +88,5 @@ export function convertContentToString(content, fileName, options) {
75
88
  return stringify(doc, { indent: 2 });
76
89
  }
77
90
  // JSON format - comments not supported, ignore header/schemaUrl
78
- return JSON.stringify(content, null, 2);
91
+ return JSON.stringify(content, null, 2) + "\n";
79
92
  }
@@ -1,5 +1,5 @@
1
- import { deepMerge, stripMergeDirectives, createMergeContext, } from "./merge.js";
2
- import { interpolateEnvVars } from "./env.js";
1
+ import { deepMerge, stripMergeDirectives, createMergeContext, isTextContent, mergeTextContent, } from "./merge.js";
2
+ import { interpolateContent } from "./env.js";
3
3
  /**
4
4
  * Normalizes header to array format.
5
5
  */
@@ -38,6 +38,10 @@ export function normalizeConfig(raw) {
38
38
  if (repoOverride.content === undefined) {
39
39
  mergedContent = null;
40
40
  }
41
+ else if (isTextContent(repoOverride.content)) {
42
+ // Text content: use as-is (no merge directives to strip)
43
+ mergedContent = structuredClone(repoOverride.content);
44
+ }
41
45
  else {
42
46
  mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
43
47
  }
@@ -45,7 +49,12 @@ export function normalizeConfig(raw) {
45
49
  else if (fileConfig.content === undefined) {
46
50
  // Root file has no content = empty file (unless repo provides content)
47
51
  if (repoOverride?.content) {
48
- mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
52
+ if (isTextContent(repoOverride.content)) {
53
+ mergedContent = structuredClone(repoOverride.content);
54
+ }
55
+ else {
56
+ mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
57
+ }
49
58
  }
50
59
  else {
51
60
  mergedContent = null;
@@ -56,14 +65,21 @@ export function normalizeConfig(raw) {
56
65
  mergedContent = structuredClone(fileConfig.content);
57
66
  }
58
67
  else {
59
- // Merge mode: deep merge file base + repo overlay
60
- const ctx = createMergeContext(fileStrategy);
61
- mergedContent = deepMerge(structuredClone(fileConfig.content), repoOverride.content, ctx);
62
- mergedContent = stripMergeDirectives(mergedContent);
68
+ // Merge mode: handle text vs object content
69
+ if (isTextContent(fileConfig.content)) {
70
+ // Text content merging
71
+ mergedContent = mergeTextContent(fileConfig.content, repoOverride.content, fileStrategy);
72
+ }
73
+ else {
74
+ // Object content: deep merge file base + repo overlay
75
+ const ctx = createMergeContext(fileStrategy);
76
+ mergedContent = deepMerge(structuredClone(fileConfig.content), repoOverride.content, ctx);
77
+ mergedContent = stripMergeDirectives(mergedContent);
78
+ }
63
79
  }
64
80
  // Step 4: Interpolate env vars (only if content exists)
65
81
  if (mergedContent !== null) {
66
- mergedContent = interpolateEnvVars(mergedContent, { strict: true });
82
+ mergedContent = interpolateContent(mergedContent, { strict: true });
67
83
  }
68
84
  // Resolve fields: per-repo overrides root level
69
85
  const createOnly = repoOverride?.createOnly ?? fileConfig.createOnly;
@@ -1,5 +1,26 @@
1
1
  import { isAbsolute } from "node:path";
2
2
  const VALID_STRATEGIES = ["replace", "append", "prepend"];
3
+ /**
4
+ * Check if content is text type (string or string[]).
5
+ */
6
+ function isTextContent(content) {
7
+ return (typeof content === "string" ||
8
+ (Array.isArray(content) &&
9
+ content.every((item) => typeof item === "string")));
10
+ }
11
+ /**
12
+ * Check if content is object type (for JSON/YAML output).
13
+ */
14
+ function isObjectContent(content) {
15
+ return (typeof content === "object" && content !== null && !Array.isArray(content));
16
+ }
17
+ /**
18
+ * Check if file extension is for structured output (JSON/YAML).
19
+ */
20
+ function isStructuredFileExtension(fileName) {
21
+ const ext = fileName.toLowerCase().split(".").pop();
22
+ return ext === "json" || ext === "yaml" || ext === "yml";
23
+ }
3
24
  /**
4
25
  * Validates raw config structure before normalization.
5
26
  * @throws Error if validation fails
@@ -19,11 +40,21 @@ export function validateRawConfig(config) {
19
40
  if (!fileConfig || typeof fileConfig !== "object") {
20
41
  throw new Error(`File '${fileName}' must have a configuration object`);
21
42
  }
22
- if (fileConfig.content !== undefined &&
23
- (typeof fileConfig.content !== "object" ||
24
- fileConfig.content === null ||
25
- Array.isArray(fileConfig.content))) {
26
- throw new Error(`File '${fileName}' content must be an object`);
43
+ // Validate content type
44
+ if (fileConfig.content !== undefined) {
45
+ const hasText = isTextContent(fileConfig.content);
46
+ const hasObject = isObjectContent(fileConfig.content);
47
+ if (!hasText && !hasObject) {
48
+ throw new Error(`File '${fileName}' content must be an object, string, or array of strings`);
49
+ }
50
+ // Validate content type matches file extension
51
+ const isStructured = isStructuredFileExtension(fileName);
52
+ if (isStructured && hasText) {
53
+ throw new Error(`File '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
54
+ }
55
+ if (!isStructured && hasObject) {
56
+ throw new Error(`File '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
57
+ }
27
58
  }
28
59
  if (fileConfig.mergeStrategy !== undefined &&
29
60
  !VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
@@ -75,11 +106,21 @@ export function validateRawConfig(config) {
75
106
  if (fileOverride.override && !fileOverride.content) {
76
107
  throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined`);
77
108
  }
78
- if (fileOverride.content !== undefined &&
79
- (typeof fileOverride.content !== "object" ||
80
- fileOverride.content === null ||
81
- Array.isArray(fileOverride.content))) {
82
- throw new Error(`Repo at index ${i}: file '${fileName}' content must be an object`);
109
+ // Validate content type
110
+ if (fileOverride.content !== undefined) {
111
+ const hasText = isTextContent(fileOverride.content);
112
+ const hasObject = isObjectContent(fileOverride.content);
113
+ if (!hasText && !hasObject) {
114
+ throw new Error(`Repo at index ${i}: file '${fileName}' content must be an object, string, or array of strings`);
115
+ }
116
+ // Validate content type matches file extension
117
+ const isStructured = isStructuredFileExtension(fileName);
118
+ if (isStructured && hasText) {
119
+ throw new Error(`Repo at index ${i}: file '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
120
+ }
121
+ if (!isStructured && hasObject) {
122
+ throw new Error(`Repo at index ${i}: file '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
123
+ }
83
124
  }
84
125
  if (fileOverride.createOnly !== undefined &&
85
126
  typeof fileOverride.createOnly !== "boolean") {
package/dist/config.d.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import type { ArrayMergeStrategy } from "./merge.js";
2
2
  export { convertContentToString } from "./config-formatter.js";
3
+ export type ContentValue = Record<string, unknown> | string | string[];
3
4
  export interface RawFileConfig {
4
- content?: Record<string, unknown>;
5
+ content?: ContentValue;
5
6
  mergeStrategy?: ArrayMergeStrategy;
6
7
  createOnly?: boolean;
7
8
  header?: string | string[];
8
9
  schemaUrl?: string;
9
10
  }
10
11
  export interface RawRepoFileOverride {
11
- content?: Record<string, unknown>;
12
+ content?: ContentValue;
12
13
  override?: boolean;
13
14
  createOnly?: boolean;
14
15
  header?: string | string[];
@@ -24,7 +25,7 @@ export interface RawConfig {
24
25
  }
25
26
  export interface FileContent {
26
27
  fileName: string;
27
- content: Record<string, unknown> | null;
28
+ content: ContentValue | null;
28
29
  createOnly?: boolean;
29
30
  header?: string[];
30
31
  schemaUrl?: string;
package/dist/env.d.ts CHANGED
@@ -22,3 +22,16 @@ export interface EnvInterpolationOptions {
22
22
  * @returns A new object with interpolated values
23
23
  */
24
24
  export declare function interpolateEnvVars(json: Record<string, unknown>, options?: EnvInterpolationOptions): Record<string, unknown>;
25
+ /**
26
+ * Interpolate environment variables in a string.
27
+ */
28
+ export declare function interpolateEnvVarsInString(value: string, options?: EnvInterpolationOptions): string;
29
+ /**
30
+ * Interpolate environment variables in an array of strings.
31
+ */
32
+ export declare function interpolateEnvVarsInLines(lines: string[], options?: EnvInterpolationOptions): string[];
33
+ /**
34
+ * Interpolate environment variables in content of any supported type.
35
+ * Handles objects, strings, and string arrays.
36
+ */
37
+ export declare function interpolateContent(content: Record<string, unknown> | string | string[], options?: EnvInterpolationOptions): Record<string, unknown> | string | string[];
package/dist/env.js CHANGED
@@ -86,3 +86,31 @@ function processValue(value, options) {
86
86
  export function interpolateEnvVars(json, options = DEFAULT_OPTIONS) {
87
87
  return processValue(json, options);
88
88
  }
89
+ // =============================================================================
90
+ // Text Content Interpolation
91
+ // =============================================================================
92
+ /**
93
+ * Interpolate environment variables in a string.
94
+ */
95
+ export function interpolateEnvVarsInString(value, options = DEFAULT_OPTIONS) {
96
+ return processString(value, options);
97
+ }
98
+ /**
99
+ * Interpolate environment variables in an array of strings.
100
+ */
101
+ export function interpolateEnvVarsInLines(lines, options = DEFAULT_OPTIONS) {
102
+ return lines.map((line) => processString(line, options));
103
+ }
104
+ /**
105
+ * Interpolate environment variables in content of any supported type.
106
+ * Handles objects, strings, and string arrays.
107
+ */
108
+ export function interpolateContent(content, options = DEFAULT_OPTIONS) {
109
+ if (typeof content === "string") {
110
+ return interpolateEnvVarsInString(content, options);
111
+ }
112
+ if (Array.isArray(content)) {
113
+ return interpolateEnvVarsInLines(content, options);
114
+ }
115
+ return interpolateEnvVars(content, options);
116
+ }
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/dist/merge.d.ts CHANGED
@@ -34,3 +34,14 @@ export declare function stripMergeDirectives(obj: Record<string, unknown>): Reco
34
34
  * Create a default merge context.
35
35
  */
36
36
  export declare function createMergeContext(defaultStrategy?: ArrayMergeStrategy): MergeContext;
37
+ /**
38
+ * Check if content is text type (string or string[]).
39
+ */
40
+ export declare function isTextContent(content: unknown): content is string | string[];
41
+ /**
42
+ * Merge two text content values.
43
+ * For strings: overlay replaces base entirely.
44
+ * For string arrays: applies merge strategy.
45
+ * For mixed types: overlay replaces base.
46
+ */
47
+ export declare function mergeTextContent(base: string | string[], overlay: string | string[], strategy?: ArrayMergeStrategy): string | string[];
package/dist/merge.js CHANGED
@@ -152,3 +152,45 @@ export function createMergeContext(defaultStrategy = "replace") {
152
152
  defaultArrayStrategy: defaultStrategy,
153
153
  };
154
154
  }
155
+ // =============================================================================
156
+ // Text Content Utilities
157
+ // =============================================================================
158
+ /**
159
+ * Check if content is text type (string or string[]).
160
+ */
161
+ export function isTextContent(content) {
162
+ return (typeof content === "string" ||
163
+ (Array.isArray(content) &&
164
+ content.every((item) => typeof item === "string")));
165
+ }
166
+ /**
167
+ * Merge two text content values.
168
+ * For strings: overlay replaces base entirely.
169
+ * For string arrays: applies merge strategy.
170
+ * For mixed types: overlay replaces base.
171
+ */
172
+ export function mergeTextContent(base, overlay, strategy = "replace") {
173
+ // If overlay is a string, it always replaces
174
+ if (typeof overlay === "string") {
175
+ return overlay;
176
+ }
177
+ // If overlay is an array
178
+ if (Array.isArray(overlay)) {
179
+ // If base is also an array, apply merge strategy
180
+ if (Array.isArray(base)) {
181
+ switch (strategy) {
182
+ case "append":
183
+ return [...base, ...overlay];
184
+ case "prepend":
185
+ return [...overlay, ...base];
186
+ case "replace":
187
+ default:
188
+ return overlay;
189
+ }
190
+ }
191
+ // Base is string, overlay is array - overlay replaces
192
+ return overlay;
193
+ }
194
+ // Fallback (shouldn't reach here with proper types)
195
+ return overlay;
196
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "3.2.0",
3
+ "version": "3.4.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",