@aspruyt/json-config-sync 3.2.0 → 3.3.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
@@ -4,7 +4,7 @@
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
6
 
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.
7
+ 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
8
 
9
9
  ## Table of Contents
10
10
 
@@ -60,7 +60,8 @@ json-config-sync --config ./config.yaml
60
60
  ## Features
61
61
 
62
62
  - **Multi-File Sync** - Sync multiple config files in a single run
63
- - **JSON/YAML Output** - Automatically outputs JSON or YAML based on filename extension
63
+ - **Multi-Format Output** - JSON, YAML, or plain text based on filename extension
64
+ - **Text Files** - Sync `.gitignore`, `.markdownlintignore`, etc. with string or lines array
64
65
  - **Content Inheritance** - Define base config once, override per-repo as needed
65
66
  - **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
66
67
  - **Environment Variables** - Use `${VAR}` syntax for dynamic values
@@ -167,13 +168,13 @@ repos: # List of repositories
167
168
 
168
169
  ### Per-File Fields
169
170
 
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 |
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 |
177
178
 
178
179
  ### Per-Repo Fields
179
180
 
@@ -450,6 +451,41 @@ scan:
450
451
 
451
452
  **Note:** `header` and `schemaUrl` only apply to YAML output files (`.yaml`, `.yml`). They are ignored for JSON files.
452
453
 
454
+ ### Text Files
455
+
456
+ Sync text files like `.gitignore`, `.markdownlintignore`, or `.env.example` using string or lines array content:
457
+
458
+ ```yaml
459
+ files:
460
+ # String content (multiline text)
461
+ .markdownlintignore:
462
+ createOnly: true
463
+ content: |-
464
+ # Claude Code generated files
465
+ .claude/
466
+
467
+ # Lines array with merge strategy
468
+ .gitignore:
469
+ mergeStrategy: append
470
+ content:
471
+ - "node_modules/"
472
+ - "dist/"
473
+
474
+ repos:
475
+ - git: git@github.com:org/repo.git
476
+ files:
477
+ .gitignore:
478
+ content:
479
+ - "coverage/" # Appended to base lines
480
+ ```
481
+
482
+ **Content Types:**
483
+
484
+ - **String content** (`content: |-`) - Direct text output with environment variable interpolation. Merging always replaces the base.
485
+ - **Lines array** (`content: ['line1', 'line2']`) - Each line joined with newlines. Supports merge strategies (`append`, `prepend`, `replace`).
486
+
487
+ **Validation:** JSON/YAML file extensions (`.json`, `.yaml`, `.yml`) require object content. Other extensions require string or string[] content.
488
+
453
489
  ## Supported Git URL Formats
454
490
 
455
491
  ### 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/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.3.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",