@aspruyt/json-config-sync 3.0.0 → 3.1.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
@@ -169,6 +169,7 @@ repos: # List of repositories
169
169
  | --------------- | ---------------------------------------------------- | -------- |
170
170
  | `content` | Base config inherited by all repos | Yes |
171
171
  | `mergeStrategy` | Array merge strategy: `replace`, `append`, `prepend` | No |
172
+ | `createOnly` | If `true`, only create file if it doesn't exist | No |
172
173
 
173
174
  ### Per-Repo Fields
174
175
 
@@ -179,10 +180,11 @@ repos: # List of repositories
179
180
 
180
181
  ### Per-Repo File Override Fields
181
182
 
182
- | Field | Description | Required |
183
- | ---------- | ------------------------------------------------------- | -------- |
184
- | `content` | Content overlay merged onto file's base content | No |
185
- | `override` | If `true`, ignore base content and use only this repo's | No |
183
+ | Field | Description | Required |
184
+ | ------------ | ------------------------------------------------------- | -------- |
185
+ | `content` | Content overlay merged onto file's base content | No |
186
+ | `override` | If `true`, ignore base content and use only this repo's | No |
187
+ | `createOnly` | Override root-level `createOnly` for this repo | No |
186
188
 
187
189
  **File Exclusion:** Set a file to `false` to exclude it from a specific repo:
188
190
 
@@ -363,6 +365,39 @@ repos:
363
365
  # Results in extends: ["@company/base", "plugin:react/recommended"]
364
366
  ```
365
367
 
368
+ ### Create-Only Files (Defaults That Can Be Customized)
369
+
370
+ Some files should only be created once as defaults, allowing repos to maintain their own versions:
371
+
372
+ ```yaml
373
+ files:
374
+ .trivyignore.yaml:
375
+ createOnly: true # Only create if doesn't exist
376
+ content:
377
+ vulnerabilities: []
378
+
379
+ .prettierignore:
380
+ createOnly: true
381
+ content:
382
+ patterns:
383
+ - "dist/"
384
+ - "node_modules/"
385
+
386
+ eslint.config.json:
387
+ content: # Always synced (no createOnly)
388
+ extends: ["@company/base"]
389
+
390
+ repos:
391
+ - git: git@github.com:org/repo.git
392
+ # .trivyignore.yaml and .prettierignore only created if missing
393
+ # eslint.config.json always updated
394
+
395
+ - git: git@github.com:org/special-repo.git
396
+ files:
397
+ .trivyignore.yaml:
398
+ createOnly: false # Override: always sync this file
399
+ ```
400
+
366
401
  ## Supported Git URL Formats
367
402
 
368
403
  ### GitHub
@@ -40,9 +40,12 @@ export function normalizeConfig(raw) {
40
40
  }
41
41
  // Step 4: Interpolate env vars
42
42
  mergedContent = interpolateEnvVars(mergedContent, { strict: true });
43
+ // Resolve createOnly: per-repo overrides root level
44
+ const createOnly = repoOverride?.createOnly ?? fileConfig.createOnly;
43
45
  files.push({
44
46
  fileName,
45
47
  content: mergedContent,
48
+ createOnly,
46
49
  });
47
50
  }
48
51
  expandedRepos.push({
@@ -29,6 +29,10 @@ export function validateRawConfig(config) {
29
29
  !VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
30
30
  throw new Error(`File '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
31
31
  }
32
+ if (fileConfig.createOnly !== undefined &&
33
+ typeof fileConfig.createOnly !== "boolean") {
34
+ throw new Error(`File '${fileName}' createOnly must be a boolean`);
35
+ }
32
36
  }
33
37
  if (!config.repos || !Array.isArray(config.repos)) {
34
38
  throw new Error("Config missing required field: repos (must be an array)");
@@ -66,6 +70,10 @@ export function validateRawConfig(config) {
66
70
  Array.isArray(fileOverride.content))) {
67
71
  throw new Error(`Repo at index ${i}: file '${fileName}' content must be an object`);
68
72
  }
73
+ if (fileOverride.createOnly !== undefined &&
74
+ typeof fileOverride.createOnly !== "boolean") {
75
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' createOnly must be a boolean`);
76
+ }
69
77
  }
70
78
  }
71
79
  }
package/dist/config.d.ts CHANGED
@@ -3,10 +3,12 @@ export { convertContentToString } from "./config-formatter.js";
3
3
  export interface RawFileConfig {
4
4
  content: Record<string, unknown>;
5
5
  mergeStrategy?: ArrayMergeStrategy;
6
+ createOnly?: boolean;
6
7
  }
7
8
  export interface RawRepoFileOverride {
8
9
  content?: Record<string, unknown>;
9
10
  override?: boolean;
11
+ createOnly?: boolean;
10
12
  }
11
13
  export interface RawRepoConfig {
12
14
  git: string | string[];
@@ -19,6 +21,7 @@ export interface RawConfig {
19
21
  export interface FileContent {
20
22
  fileName: string;
21
23
  content: Record<string, unknown>;
24
+ createOnly?: boolean;
22
25
  }
23
26
  export interface RepoConfig {
24
27
  git: string;
@@ -2,7 +2,7 @@ import { RepoInfo } from "./repo-detector.js";
2
2
  export { escapeShellArg } from "./shell-utils.js";
3
3
  export interface FileAction {
4
4
  fileName: string;
5
- action: "create" | "update";
5
+ action: "create" | "update" | "skip";
6
6
  }
7
7
  export interface PROptions {
8
8
  repoInfo: RepoInfo;
@@ -24,7 +24,7 @@ export interface PRResult {
24
24
  */
25
25
  export declare function formatPRBody(files: FileAction[]): string;
26
26
  /**
27
- * Generate PR title based on files changed
27
+ * Generate PR title based on files changed (excludes skipped files)
28
28
  */
29
29
  export declare function formatPRTitle(files: FileAction[]): string;
30
30
  export declare function createPR(options: PROptions): Promise<PRResult>;
@@ -25,36 +25,37 @@ Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/
25
25
  ---
26
26
  _This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)_`;
27
27
  }
28
+ /**
29
+ * Format file changes list, excluding skipped files
30
+ */
31
+ function formatFileChanges(files) {
32
+ const changedFiles = files.filter((f) => f.action !== "skip");
33
+ return changedFiles
34
+ .map((f) => {
35
+ const actionText = f.action === "create" ? "Created" : "Updated";
36
+ return `- ${actionText} \`${f.fileName}\``;
37
+ })
38
+ .join("\n");
39
+ }
28
40
  /**
29
41
  * Format PR body for multiple files
30
42
  */
31
43
  export function formatPRBody(files) {
32
44
  const template = loadPRTemplate();
45
+ const fileChanges = formatFileChanges(files);
33
46
  // Check if template supports multi-file format
34
47
  if (template.includes("{{FILE_CHANGES}}")) {
35
- // Multi-file template
36
- const fileChanges = files
37
- .map((f) => {
38
- const actionText = f.action === "create" ? "Created" : "Updated";
39
- return `- ${actionText} \`${f.fileName}\``;
40
- })
41
- .join("\n");
42
48
  return template.replace(/\{\{FILE_CHANGES\}\}/g, fileChanges);
43
49
  }
44
50
  // Legacy single-file template - adapt it for multiple files
45
- if (files.length === 1) {
46
- const actionText = files[0].action === "create" ? "Created" : "Updated";
51
+ const changedFiles = files.filter((f) => f.action !== "skip");
52
+ if (changedFiles.length === 1) {
53
+ const actionText = changedFiles[0].action === "create" ? "Created" : "Updated";
47
54
  return template
48
- .replace(/\{\{FILE_NAME\}\}/g, files[0].fileName)
55
+ .replace(/\{\{FILE_NAME\}\}/g, changedFiles[0].fileName)
49
56
  .replace(/\{\{ACTION\}\}/g, actionText);
50
57
  }
51
58
  // Multiple files with legacy template - generate custom body
52
- const fileChanges = files
53
- .map((f) => {
54
- const actionText = f.action === "create" ? "Created" : "Updated";
55
- return `- ${actionText} \`${f.fileName}\``;
56
- })
57
- .join("\n");
58
59
  return `## Summary
59
60
  Automated sync of configuration files.
60
61
 
@@ -68,17 +69,18 @@ Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/
68
69
  _This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)_`;
69
70
  }
70
71
  /**
71
- * Generate PR title based on files changed
72
+ * Generate PR title based on files changed (excludes skipped files)
72
73
  */
73
74
  export function formatPRTitle(files) {
74
- if (files.length === 1) {
75
- return `chore: sync ${files[0].fileName}`;
75
+ const changedFiles = files.filter((f) => f.action !== "skip");
76
+ if (changedFiles.length === 1) {
77
+ return `chore: sync ${changedFiles[0].fileName}`;
76
78
  }
77
- if (files.length <= 3) {
78
- const fileNames = files.map((f) => f.fileName).join(", ");
79
+ if (changedFiles.length <= 3) {
80
+ const fileNames = changedFiles.map((f) => f.fileName).join(", ");
79
81
  return `chore: sync ${fileNames}`;
80
82
  }
81
- return `chore: sync ${files.length} config files`;
83
+ return `chore: sync ${changedFiles.length} config files`;
82
84
  }
83
85
  export async function createPR(options) {
84
86
  const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries } = options;
@@ -33,7 +33,7 @@ export declare class RepositoryProcessor {
33
33
  constructor(gitOpsFactory?: GitOpsFactory, log?: ILogger);
34
34
  process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
35
35
  /**
36
- * Format commit message based on files changed
36
+ * Format commit message based on files changed (excludes skipped files)
37
37
  */
38
38
  private formatCommitMessage;
39
39
  }
@@ -38,13 +38,18 @@ export class RepositoryProcessor {
38
38
  // Step 5: Write all config files and track changes
39
39
  const changedFiles = [];
40
40
  for (const file of repoConfig.files) {
41
+ const filePath = join(workDir, file.fileName);
42
+ const fileExists = existsSync(filePath);
43
+ // Handle createOnly - skip if file already exists
44
+ if (file.createOnly && fileExists) {
45
+ this.log.info(`Skipping ${file.fileName} (createOnly: already exists)`);
46
+ changedFiles.push({ fileName: file.fileName, action: "skip" });
47
+ continue;
48
+ }
41
49
  this.log.info(`Writing ${file.fileName}...`);
42
50
  const fileContent = convertContentToString(file.content, file.fileName);
43
- const filePath = join(workDir, file.fileName);
44
51
  // Determine action type (create vs update)
45
- const action = existsSync(filePath)
46
- ? "update"
47
- : "create";
52
+ const action = fileExists ? "update" : "create";
48
53
  if (dryRun) {
49
54
  // In dry-run, check if file would change without writing
50
55
  if (this.gitOps.wouldChange(file.fileName, fileContent)) {
@@ -56,23 +61,25 @@ export class RepositoryProcessor {
56
61
  this.gitOps.writeFile(file.fileName, fileContent);
57
62
  }
58
63
  }
59
- // Step 6: Check for changes
64
+ // Step 6: Check for changes (exclude skipped files)
60
65
  let hasChanges;
61
66
  if (dryRun) {
62
- hasChanges = changedFiles.length > 0;
67
+ hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
63
68
  }
64
69
  else {
65
70
  hasChanges = await this.gitOps.hasChanges();
66
71
  // If there are changes, determine which files changed
67
72
  if (hasChanges) {
68
73
  // Rebuild the changed files list by checking git status
69
- // For simplicity, we include all files with their detected actions
74
+ // Skip files that were already marked as skipped (createOnly)
75
+ const skippedFiles = new Set(changedFiles
76
+ .filter((f) => f.action === "skip")
77
+ .map((f) => f.fileName));
70
78
  for (const file of repoConfig.files) {
79
+ if (skippedFiles.has(file.fileName)) {
80
+ continue; // Already tracked as skipped
81
+ }
71
82
  const filePath = join(workDir, file.fileName);
72
- // We check if file existed before writing (action was determined above)
73
- // Since we don't have pre-write state, we'll mark all files that are in the commit
74
- // A more accurate approach would track this before writing, but for now
75
- // we'll assume all files are being synced and include them all
76
83
  const action = existsSync(filePath)
77
84
  ? "update"
78
85
  : "create";
@@ -126,16 +133,17 @@ export class RepositoryProcessor {
126
133
  }
127
134
  }
128
135
  /**
129
- * Format commit message based on files changed
136
+ * Format commit message based on files changed (excludes skipped files)
130
137
  */
131
138
  formatCommitMessage(files) {
132
- if (files.length === 1) {
133
- return `chore: sync ${files[0].fileName}`;
139
+ const changedFiles = files.filter((f) => f.action !== "skip");
140
+ if (changedFiles.length === 1) {
141
+ return `chore: sync ${changedFiles[0].fileName}`;
134
142
  }
135
- if (files.length <= 3) {
136
- const fileNames = files.map((f) => f.fileName).join(", ");
143
+ if (changedFiles.length <= 3) {
144
+ const fileNames = changedFiles.map((f) => f.fileName).join(", ");
137
145
  return `chore: sync ${fileNames}`;
138
146
  }
139
- return `chore: sync ${files.length} config files`;
147
+ return `chore: sync ${changedFiles.length} config files`;
140
148
  }
141
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "3.0.0",
3
+ "version": "3.1.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",