@aspruyt/json-config-sync 3.1.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,12 +60,15 @@ 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
67
68
  - **Merge Strategies** - Control how arrays merge (replace, append, prepend)
68
69
  - **Override Mode** - Skip merging entirely for specific repos
70
+ - **Empty Files** - Create files with no content (e.g., `.prettierignore`)
71
+ - **YAML Comments** - Add header comments and schema directives to YAML files
69
72
  - **GitHub & Azure DevOps** - Works with both platforms
70
73
  - **Dry-Run Mode** - Preview changes without creating PRs
71
74
  - **Error Resilience** - Continues processing if individual repos fail
@@ -165,11 +168,13 @@ repos: # List of repositories
165
168
 
166
169
  ### Per-File Fields
167
170
 
168
- | Field | Description | Required |
169
- | --------------- | ---------------------------------------------------- | -------- |
170
- | `content` | Base config inherited by all repos | Yes |
171
- | `mergeStrategy` | Array merge strategy: `replace`, `append`, `prepend` | No |
172
- | `createOnly` | If `true`, only create file if it doesn't exist | 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 |
173
178
 
174
179
  ### Per-Repo Fields
175
180
 
@@ -185,6 +190,8 @@ repos: # List of repositories
185
190
  | `content` | Content overlay merged onto file's base content | No |
186
191
  | `override` | If `true`, ignore base content and use only this repo's | No |
187
192
  | `createOnly` | Override root-level `createOnly` for this repo | No |
193
+ | `header` | Override root-level `header` for this repo | No |
194
+ | `schemaUrl` | Override root-level `schemaUrl` for this repo | No |
188
195
 
189
196
  **File Exclusion:** Set a file to `false` to exclude it from a specific repo:
190
197
 
@@ -398,6 +405,87 @@ repos:
398
405
  createOnly: false # Override: always sync this file
399
406
  ```
400
407
 
408
+ ### YAML Comments and Empty Files
409
+
410
+ Add schema directives and comments to YAML files, or create empty files:
411
+
412
+ ```yaml
413
+ files:
414
+ # YAML file with schema directive and header comment
415
+ trivy.yaml:
416
+ schemaUrl: https://trivy.dev/latest/docs/references/configuration/config-file/
417
+ header: "Trivy security scanner configuration"
418
+ content:
419
+ exit-code: 1
420
+ scan:
421
+ scanners:
422
+ - vuln
423
+
424
+ # Empty file (content omitted)
425
+ .prettierignore:
426
+ createOnly: true
427
+ # No content = empty file
428
+
429
+ # YAML with multi-line header
430
+ config.yaml:
431
+ header:
432
+ - "Auto-generated configuration"
433
+ - "Do not edit manually"
434
+ content:
435
+ version: 1
436
+
437
+ repos:
438
+ - git: git@github.com:org/repo.git
439
+ ```
440
+
441
+ **Output for trivy.yaml:**
442
+
443
+ ```yaml
444
+ # yaml-language-server: $schema=https://trivy.dev/latest/docs/references/configuration/config-file/
445
+ # Trivy security scanner configuration
446
+ exit-code: 1
447
+ scan:
448
+ scanners:
449
+ - vuln
450
+ ```
451
+
452
+ **Note:** `header` and `schemaUrl` only apply to YAML output files (`.yaml`, `.yml`). They are ignored for JSON files.
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
+
401
489
  ## Supported Git URL Formats
402
490
 
403
491
  ### GitHub
@@ -1,9 +1,17 @@
1
1
  export type OutputFormat = "json" | "yaml";
2
+ /**
3
+ * Options for content conversion.
4
+ */
5
+ export interface ConvertOptions {
6
+ header?: string[];
7
+ schemaUrl?: string;
8
+ }
2
9
  /**
3
10
  * Detects output format from file extension.
4
11
  */
5
12
  export declare function detectOutputFormat(fileName: string): OutputFormat;
6
13
  /**
7
- * Converts content object to string in the appropriate format.
14
+ * Converts content to string in the appropriate format.
15
+ * Handles null content (empty files), text content (string/string[]), and object content (JSON/YAML).
8
16
  */
9
- export declare function convertContentToString(content: Record<string, unknown>, fileName: string): string;
17
+ export declare function convertContentToString(content: Record<string, unknown> | string | string[] | null, fileName: string, options?: ConvertOptions): string;
@@ -1,4 +1,4 @@
1
- import { stringify } from "yaml";
1
+ import { Document, stringify } from "yaml";
2
2
  /**
3
3
  * Detects output format from file extension.
4
4
  */
@@ -10,12 +10,83 @@ export function detectOutputFormat(fileName) {
10
10
  return "json";
11
11
  }
12
12
  /**
13
- * Converts content object to string in the appropriate format.
13
+ * Builds header comment string from header lines and schemaUrl.
14
+ * Returns undefined if no comments to add.
15
+ * Each line gets a space prefix since yaml library adds # directly.
14
16
  */
15
- export function convertContentToString(content, fileName) {
17
+ function buildHeaderComment(header, schemaUrl) {
18
+ const lines = [];
19
+ // Add yaml-language-server schema directive first (if present)
20
+ if (schemaUrl) {
21
+ lines.push(` yaml-language-server: $schema=${schemaUrl}`);
22
+ }
23
+ // Add custom header lines (with space prefix for proper formatting)
24
+ if (header && header.length > 0) {
25
+ lines.push(...header.map((h) => ` ${h}`));
26
+ }
27
+ if (lines.length === 0)
28
+ return undefined;
29
+ // Join with newlines - the yaml library adds # prefix to each line
30
+ return lines.join("\n");
31
+ }
32
+ /**
33
+ * Builds comment-only output for empty YAML files with headers.
34
+ */
35
+ function buildCommentOnlyYaml(header, schemaUrl) {
36
+ const lines = [];
37
+ // Add yaml-language-server schema directive first (if present)
38
+ if (schemaUrl) {
39
+ lines.push(`# yaml-language-server: $schema=${schemaUrl}`);
40
+ }
41
+ // Add custom header lines
42
+ if (header && header.length > 0) {
43
+ lines.push(...header.map((h) => `# ${h}`));
44
+ }
45
+ if (lines.length === 0)
46
+ return undefined;
47
+ return lines.join("\n") + "\n";
48
+ }
49
+ /**
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
+ */
53
+ export function convertContentToString(content, fileName, options) {
54
+ // Handle empty file case
55
+ if (content === null) {
56
+ const format = detectOutputFormat(fileName);
57
+ if (format === "yaml" && options) {
58
+ const commentOnly = buildCommentOnlyYaml(options.header, options.schemaUrl);
59
+ if (commentOnly) {
60
+ return commentOnly;
61
+ }
62
+ }
63
+ return "";
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)
16
77
  const format = detectOutputFormat(fileName);
17
78
  if (format === "yaml") {
18
- return stringify(content, { indent: 2 });
79
+ // Use Document API for YAML to support comments
80
+ const doc = new Document(content);
81
+ // Add header comment if present
82
+ if (options) {
83
+ const headerComment = buildHeaderComment(options.header, options.schemaUrl);
84
+ if (headerComment) {
85
+ doc.commentBefore = headerComment;
86
+ }
87
+ }
88
+ return stringify(doc, { indent: 2 });
19
89
  }
20
- return JSON.stringify(content, null, 2);
90
+ // JSON format - comments not supported, ignore header/schemaUrl
91
+ return JSON.stringify(content, null, 2) + "\n";
21
92
  }
@@ -1,5 +1,15 @@
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
+ /**
4
+ * Normalizes header to array format.
5
+ */
6
+ function normalizeHeader(header) {
7
+ if (header === undefined)
8
+ return undefined;
9
+ if (typeof header === "string")
10
+ return [header];
11
+ return header;
12
+ }
3
13
  /**
4
14
  * Normalizes raw config into expanded, merged config.
5
15
  * Pipeline: expand git arrays -> merge content -> interpolate env vars
@@ -20,32 +30,67 @@ export function normalizeConfig(raw) {
20
30
  continue;
21
31
  }
22
32
  const fileConfig = raw.files[fileName];
23
- const baseContent = fileConfig.content ?? {};
24
33
  const fileStrategy = fileConfig.mergeStrategy ?? "replace";
25
34
  // Step 3: Compute merged content for this file
26
35
  let mergedContent;
27
36
  if (repoOverride?.override) {
28
- // Override mode: use only repo file content
29
- mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
37
+ // Override mode: use only repo file content (may be undefined for empty file)
38
+ if (repoOverride.content === undefined) {
39
+ mergedContent = null;
40
+ }
41
+ else if (isTextContent(repoOverride.content)) {
42
+ // Text content: use as-is (no merge directives to strip)
43
+ mergedContent = structuredClone(repoOverride.content);
44
+ }
45
+ else {
46
+ mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
47
+ }
48
+ }
49
+ else if (fileConfig.content === undefined) {
50
+ // Root file has no content = empty file (unless repo provides content)
51
+ if (repoOverride?.content) {
52
+ if (isTextContent(repoOverride.content)) {
53
+ mergedContent = structuredClone(repoOverride.content);
54
+ }
55
+ else {
56
+ mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
57
+ }
58
+ }
59
+ else {
60
+ mergedContent = null;
61
+ }
30
62
  }
31
63
  else if (!repoOverride?.content) {
32
64
  // No repo override: use file base content as-is
33
- mergedContent = structuredClone(baseContent);
65
+ mergedContent = structuredClone(fileConfig.content);
34
66
  }
35
67
  else {
36
- // Merge mode: deep merge file base + repo overlay
37
- const ctx = createMergeContext(fileStrategy);
38
- mergedContent = deepMerge(structuredClone(baseContent), repoOverride.content, ctx);
39
- 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
+ }
79
+ }
80
+ // Step 4: Interpolate env vars (only if content exists)
81
+ if (mergedContent !== null) {
82
+ mergedContent = interpolateContent(mergedContent, { strict: true });
40
83
  }
41
- // Step 4: Interpolate env vars
42
- mergedContent = interpolateEnvVars(mergedContent, { strict: true });
43
- // Resolve createOnly: per-repo overrides root level
84
+ // Resolve fields: per-repo overrides root level
44
85
  const createOnly = repoOverride?.createOnly ?? fileConfig.createOnly;
86
+ const header = normalizeHeader(repoOverride?.header ?? fileConfig.header);
87
+ const schemaUrl = repoOverride?.schemaUrl ?? fileConfig.schemaUrl;
45
88
  files.push({
46
89
  fileName,
47
90
  content: mergedContent,
48
91
  createOnly,
92
+ header,
93
+ schemaUrl,
49
94
  });
50
95
  }
51
96
  expandedRepos.push({
@@ -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)) {
@@ -33,6 +64,17 @@ export function validateRawConfig(config) {
33
64
  typeof fileConfig.createOnly !== "boolean") {
34
65
  throw new Error(`File '${fileName}' createOnly must be a boolean`);
35
66
  }
67
+ if (fileConfig.header !== undefined) {
68
+ if (typeof fileConfig.header !== "string" &&
69
+ (!Array.isArray(fileConfig.header) ||
70
+ !fileConfig.header.every((h) => typeof h === "string"))) {
71
+ throw new Error(`File '${fileName}' header must be a string or array of strings`);
72
+ }
73
+ }
74
+ if (fileConfig.schemaUrl !== undefined &&
75
+ typeof fileConfig.schemaUrl !== "string") {
76
+ throw new Error(`File '${fileName}' schemaUrl must be a string`);
77
+ }
36
78
  }
37
79
  if (!config.repos || !Array.isArray(config.repos)) {
38
80
  throw new Error("Config missing required field: repos (must be an array)");
@@ -64,16 +106,37 @@ export function validateRawConfig(config) {
64
106
  if (fileOverride.override && !fileOverride.content) {
65
107
  throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined`);
66
108
  }
67
- if (fileOverride.content !== undefined &&
68
- (typeof fileOverride.content !== "object" ||
69
- fileOverride.content === null ||
70
- Array.isArray(fileOverride.content))) {
71
- 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
+ }
72
124
  }
73
125
  if (fileOverride.createOnly !== undefined &&
74
126
  typeof fileOverride.createOnly !== "boolean") {
75
127
  throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' createOnly must be a boolean`);
76
128
  }
129
+ if (fileOverride.header !== undefined) {
130
+ if (typeof fileOverride.header !== "string" &&
131
+ (!Array.isArray(fileOverride.header) ||
132
+ !fileOverride.header.every((h) => typeof h === "string"))) {
133
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' header must be a string or array of strings`);
134
+ }
135
+ }
136
+ if (fileOverride.schemaUrl !== undefined &&
137
+ typeof fileOverride.schemaUrl !== "string") {
138
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' schemaUrl must be a string`);
139
+ }
77
140
  }
78
141
  }
79
142
  }
package/dist/config.d.ts CHANGED
@@ -1,14 +1,19 @@
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;
8
+ header?: string | string[];
9
+ schemaUrl?: string;
7
10
  }
8
11
  export interface RawRepoFileOverride {
9
- content?: Record<string, unknown>;
12
+ content?: ContentValue;
10
13
  override?: boolean;
11
14
  createOnly?: boolean;
15
+ header?: string | string[];
16
+ schemaUrl?: string;
12
17
  }
13
18
  export interface RawRepoConfig {
14
19
  git: string | string[];
@@ -20,8 +25,10 @@ export interface RawConfig {
20
25
  }
21
26
  export interface FileContent {
22
27
  fileName: string;
23
- content: Record<string, unknown>;
28
+ content: ContentValue | null;
24
29
  createOnly?: boolean;
30
+ header?: string[];
31
+ schemaUrl?: string;
25
32
  }
26
33
  export interface RepoConfig {
27
34
  git: 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
+ }
@@ -47,7 +47,10 @@ export class RepositoryProcessor {
47
47
  continue;
48
48
  }
49
49
  this.log.info(`Writing ${file.fileName}...`);
50
- const fileContent = convertContentToString(file.content, file.fileName);
50
+ const fileContent = convertContentToString(file.content, file.fileName, {
51
+ header: file.header,
52
+ schemaUrl: file.schemaUrl,
53
+ });
51
54
  // Determine action type (create vs update)
52
55
  const action = fileExists ? "update" : "create";
53
56
  if (dryRun) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "3.1.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",