@aspruyt/json-config-sync 3.1.0 → 3.2.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
@@ -66,6 +66,8 @@ json-config-sync --config ./config.yaml
66
66
  - **Environment Variables** - Use `${VAR}` syntax for dynamic values
67
67
  - **Merge Strategies** - Control how arrays merge (replace, append, prepend)
68
68
  - **Override Mode** - Skip merging entirely for specific repos
69
+ - **Empty Files** - Create files with no content (e.g., `.prettierignore`)
70
+ - **YAML Comments** - Add header comments and schema directives to YAML files
69
71
  - **GitHub & Azure DevOps** - Works with both platforms
70
72
  - **Dry-Run Mode** - Preview changes without creating PRs
71
73
  - **Error Resilience** - Continues processing if individual repos fail
@@ -165,11 +167,13 @@ repos: # List of repositories
165
167
 
166
168
  ### Per-File Fields
167
169
 
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 |
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
177
 
174
178
  ### Per-Repo Fields
175
179
 
@@ -185,6 +189,8 @@ repos: # List of repositories
185
189
  | `content` | Content overlay merged onto file's base content | No |
186
190
  | `override` | If `true`, ignore base content and use only this repo's | No |
187
191
  | `createOnly` | Override root-level `createOnly` for this repo | No |
192
+ | `header` | Override root-level `header` for this repo | No |
193
+ | `schemaUrl` | Override root-level `schemaUrl` for this repo | No |
188
194
 
189
195
  **File Exclusion:** Set a file to `false` to exclude it from a specific repo:
190
196
 
@@ -398,6 +404,52 @@ repos:
398
404
  createOnly: false # Override: always sync this file
399
405
  ```
400
406
 
407
+ ### YAML Comments and Empty Files
408
+
409
+ Add schema directives and comments to YAML files, or create empty files:
410
+
411
+ ```yaml
412
+ files:
413
+ # YAML file with schema directive and header comment
414
+ trivy.yaml:
415
+ schemaUrl: https://trivy.dev/latest/docs/references/configuration/config-file/
416
+ header: "Trivy security scanner configuration"
417
+ content:
418
+ exit-code: 1
419
+ scan:
420
+ scanners:
421
+ - vuln
422
+
423
+ # Empty file (content omitted)
424
+ .prettierignore:
425
+ createOnly: true
426
+ # No content = empty file
427
+
428
+ # YAML with multi-line header
429
+ config.yaml:
430
+ header:
431
+ - "Auto-generated configuration"
432
+ - "Do not edit manually"
433
+ content:
434
+ version: 1
435
+
436
+ repos:
437
+ - git: git@github.com:org/repo.git
438
+ ```
439
+
440
+ **Output for trivy.yaml:**
441
+
442
+ ```yaml
443
+ # yaml-language-server: $schema=https://trivy.dev/latest/docs/references/configuration/config-file/
444
+ # Trivy security scanner configuration
445
+ exit-code: 1
446
+ scan:
447
+ scanners:
448
+ - vuln
449
+ ```
450
+
451
+ **Note:** `header` and `schemaUrl` only apply to YAML output files (`.yaml`, `.yml`). They are ignored for JSON files.
452
+
401
453
  ## Supported Git URL Formats
402
454
 
403
455
  ### 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
14
  * Converts content object to string in the appropriate format.
15
+ * Handles null content (empty files) and comments (YAML only).
8
16
  */
9
- export declare function convertContentToString(content: Record<string, unknown>, fileName: string): string;
17
+ export declare function convertContentToString(content: Record<string, unknown> | 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
  */
@@ -9,13 +9,71 @@ export function detectOutputFormat(fileName) {
9
9
  }
10
10
  return "json";
11
11
  }
12
+ /**
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.
16
+ */
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
+ }
12
49
  /**
13
50
  * Converts content object to string in the appropriate format.
51
+ * Handles null content (empty files) and comments (YAML only).
14
52
  */
15
- export function convertContentToString(content, fileName) {
53
+ export function convertContentToString(content, fileName, options) {
16
54
  const format = detectOutputFormat(fileName);
55
+ // Handle empty file case
56
+ if (content === null) {
57
+ if (format === "yaml" && options) {
58
+ const commentOnly = buildCommentOnlyYaml(options.header, options.schemaUrl);
59
+ if (commentOnly) {
60
+ return commentOnly;
61
+ }
62
+ }
63
+ return "";
64
+ }
17
65
  if (format === "yaml") {
18
- return stringify(content, { indent: 2 });
66
+ // Use Document API for YAML to support comments
67
+ const doc = new Document(content);
68
+ // Add header comment if present
69
+ if (options) {
70
+ const headerComment = buildHeaderComment(options.header, options.schemaUrl);
71
+ if (headerComment) {
72
+ doc.commentBefore = headerComment;
73
+ }
74
+ }
75
+ return stringify(doc, { indent: 2 });
19
76
  }
77
+ // JSON format - comments not supported, ignore header/schemaUrl
20
78
  return JSON.stringify(content, null, 2);
21
79
  }
@@ -1,5 +1,15 @@
1
1
  import { deepMerge, stripMergeDirectives, createMergeContext, } from "./merge.js";
2
2
  import { interpolateEnvVars } 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,51 @@ 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 {
42
+ mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
43
+ }
44
+ }
45
+ else if (fileConfig.content === undefined) {
46
+ // Root file has no content = empty file (unless repo provides content)
47
+ if (repoOverride?.content) {
48
+ mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
49
+ }
50
+ else {
51
+ mergedContent = null;
52
+ }
30
53
  }
31
54
  else if (!repoOverride?.content) {
32
55
  // No repo override: use file base content as-is
33
- mergedContent = structuredClone(baseContent);
56
+ mergedContent = structuredClone(fileConfig.content);
34
57
  }
35
58
  else {
36
59
  // Merge mode: deep merge file base + repo overlay
37
60
  const ctx = createMergeContext(fileStrategy);
38
- mergedContent = deepMerge(structuredClone(baseContent), repoOverride.content, ctx);
61
+ mergedContent = deepMerge(structuredClone(fileConfig.content), repoOverride.content, ctx);
39
62
  mergedContent = stripMergeDirectives(mergedContent);
40
63
  }
41
- // Step 4: Interpolate env vars
42
- mergedContent = interpolateEnvVars(mergedContent, { strict: true });
43
- // Resolve createOnly: per-repo overrides root level
64
+ // Step 4: Interpolate env vars (only if content exists)
65
+ if (mergedContent !== null) {
66
+ mergedContent = interpolateEnvVars(mergedContent, { strict: true });
67
+ }
68
+ // Resolve fields: per-repo overrides root level
44
69
  const createOnly = repoOverride?.createOnly ?? fileConfig.createOnly;
70
+ const header = normalizeHeader(repoOverride?.header ?? fileConfig.header);
71
+ const schemaUrl = repoOverride?.schemaUrl ?? fileConfig.schemaUrl;
45
72
  files.push({
46
73
  fileName,
47
74
  content: mergedContent,
48
75
  createOnly,
76
+ header,
77
+ schemaUrl,
49
78
  });
50
79
  }
51
80
  expandedRepos.push({
@@ -33,6 +33,17 @@ export function validateRawConfig(config) {
33
33
  typeof fileConfig.createOnly !== "boolean") {
34
34
  throw new Error(`File '${fileName}' createOnly must be a boolean`);
35
35
  }
36
+ if (fileConfig.header !== undefined) {
37
+ if (typeof fileConfig.header !== "string" &&
38
+ (!Array.isArray(fileConfig.header) ||
39
+ !fileConfig.header.every((h) => typeof h === "string"))) {
40
+ throw new Error(`File '${fileName}' header must be a string or array of strings`);
41
+ }
42
+ }
43
+ if (fileConfig.schemaUrl !== undefined &&
44
+ typeof fileConfig.schemaUrl !== "string") {
45
+ throw new Error(`File '${fileName}' schemaUrl must be a string`);
46
+ }
36
47
  }
37
48
  if (!config.repos || !Array.isArray(config.repos)) {
38
49
  throw new Error("Config missing required field: repos (must be an array)");
@@ -74,6 +85,17 @@ export function validateRawConfig(config) {
74
85
  typeof fileOverride.createOnly !== "boolean") {
75
86
  throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' createOnly must be a boolean`);
76
87
  }
88
+ if (fileOverride.header !== undefined) {
89
+ if (typeof fileOverride.header !== "string" &&
90
+ (!Array.isArray(fileOverride.header) ||
91
+ !fileOverride.header.every((h) => typeof h === "string"))) {
92
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' header must be a string or array of strings`);
93
+ }
94
+ }
95
+ if (fileOverride.schemaUrl !== undefined &&
96
+ typeof fileOverride.schemaUrl !== "string") {
97
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' schemaUrl must be a string`);
98
+ }
77
99
  }
78
100
  }
79
101
  }
package/dist/config.d.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  import type { ArrayMergeStrategy } from "./merge.js";
2
2
  export { convertContentToString } from "./config-formatter.js";
3
3
  export interface RawFileConfig {
4
- content: Record<string, unknown>;
4
+ content?: Record<string, unknown>;
5
5
  mergeStrategy?: ArrayMergeStrategy;
6
6
  createOnly?: boolean;
7
+ header?: string | string[];
8
+ schemaUrl?: string;
7
9
  }
8
10
  export interface RawRepoFileOverride {
9
11
  content?: Record<string, unknown>;
10
12
  override?: boolean;
11
13
  createOnly?: boolean;
14
+ header?: string | string[];
15
+ schemaUrl?: string;
12
16
  }
13
17
  export interface RawRepoConfig {
14
18
  git: string | string[];
@@ -20,8 +24,10 @@ export interface RawConfig {
20
24
  }
21
25
  export interface FileContent {
22
26
  fileName: string;
23
- content: Record<string, unknown>;
27
+ content: Record<string, unknown> | null;
24
28
  createOnly?: boolean;
29
+ header?: string[];
30
+ schemaUrl?: string;
25
31
  }
26
32
  export interface RepoConfig {
27
33
  git: string;
@@ -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.2.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",