@aspruyt/json-config-sync 3.4.0 → 3.6.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
@@ -64,6 +64,7 @@ json-config-sync --config ./config.yaml
64
64
  - **Multi-Format Output** - JSON, YAML, or plain text based on filename extension
65
65
  - **Subdirectory Support** - Sync files to any path (e.g., `.github/workflows/ci.yml`)
66
66
  - **Text Files** - Sync `.gitignore`, `.markdownlintignore`, etc. with string or lines array
67
+ - **File References** - Use `@path/to/file` to load content from external template files
67
68
  - **Content Inheritance** - Define base config once, override per-repo as needed
68
69
  - **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
69
70
  - **Environment Variables** - Use `${VAR}` syntax for dynamic values
@@ -170,13 +171,14 @@ repos: # List of repositories
170
171
 
171
172
  ### Per-File Fields
172
173
 
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 |
174
+ | Field | Description | Required |
175
+ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
176
+ | `content` | Base config: object for JSON/YAML files, string or string[] for text files, or `@path/to/file` to load from external template (omit for empty file) | No |
177
+ | `mergeStrategy` | Merge strategy: `replace`, `append`, `prepend` (for arrays and text lines) | No |
178
+ | `createOnly` | If `true`, only create file if it doesn't exist | No |
179
+ | `executable` | Mark file as executable. `.sh` files are auto-executable unless set to `false`. Set to `true` for non-.sh files. | No |
180
+ | `header` | Comment line(s) at top of YAML files (string or array) | No |
181
+ | `schemaUrl` | Adds `# yaml-language-server: $schema=<url>` to YAML files | No |
180
182
 
181
183
  ### Per-Repo Fields
182
184
 
@@ -192,6 +194,7 @@ repos: # List of repositories
192
194
  | `content` | Content overlay merged onto file's base content | No |
193
195
  | `override` | If `true`, ignore base content and use only this repo's | No |
194
196
  | `createOnly` | Override root-level `createOnly` for this repo | No |
197
+ | `executable` | Override root-level `executable` for this repo | No |
195
198
  | `header` | Override root-level `header` for this repo | No |
196
199
  | `schemaUrl` | Override root-level `schemaUrl` for this repo | No |
197
200
 
@@ -488,6 +491,44 @@ repos:
488
491
 
489
492
  **Validation:** JSON/YAML file extensions (`.json`, `.yaml`, `.yml`) require object content. Other extensions require string or string[] content.
490
493
 
494
+ ### Executable Files
495
+
496
+ Shell scripts (`.sh` files) are automatically marked as executable using `git update-index --chmod=+x`. You can control this behavior:
497
+
498
+ ```yaml
499
+ files:
500
+ # .sh files are auto-executable (no config needed)
501
+ deploy.sh:
502
+ content: |-
503
+ #!/bin/bash
504
+ echo "Deploying..."
505
+
506
+ # Disable auto-executable for a specific .sh file
507
+ template.sh:
508
+ executable: false
509
+ content: "# This is just a template"
510
+
511
+ # Make a non-.sh file executable
512
+ run:
513
+ executable: true
514
+ content: |-
515
+ #!/usr/bin/env python3
516
+ print("Hello")
517
+
518
+ repos:
519
+ - git: git@github.com:org/repo.git
520
+ files:
521
+ # Override executable per-repo
522
+ deploy.sh:
523
+ executable: false # Disable for this repo
524
+ ```
525
+
526
+ **Behavior:**
527
+
528
+ - `.sh` files: Automatically executable unless `executable: false`
529
+ - Other files: Not executable unless `executable: true`
530
+ - Per-repo settings override root-level settings
531
+
491
532
  ### Subdirectory Paths
492
533
 
493
534
  Sync files to any subdirectory path - parent directories are created automatically:
@@ -519,6 +560,46 @@ repos:
519
560
 
520
561
  **Note:** Quote file paths containing `/` in YAML keys. Parent directories are created if they don't exist.
521
562
 
563
+ ### File References
564
+
565
+ Instead of inline content, you can reference external template files using the `@path/to/file` syntax:
566
+
567
+ ```yaml
568
+ files:
569
+ .prettierrc.json:
570
+ content: "@templates/prettierrc.json"
571
+ .eslintrc.yaml:
572
+ content: "@templates/eslintrc.yaml"
573
+ header: "Auto-generated - do not edit"
574
+ schemaUrl: "https://json.schemastore.org/eslintrc"
575
+ .gitignore:
576
+ content: "@templates/gitignore.txt"
577
+
578
+ repos:
579
+ - git: git@github.com:org/repo.git
580
+ ```
581
+
582
+ **How it works:**
583
+
584
+ - File references start with `@` followed by a relative path
585
+ - Paths are resolved relative to the config file's directory
586
+ - JSON/YAML files are parsed as objects, other files as strings
587
+ - Metadata fields (`header`, `schemaUrl`, `createOnly`, `mergeStrategy`) remain in the config
588
+ - Per-repo overlays still work - they merge onto the resolved file content
589
+
590
+ **Example directory structure:**
591
+
592
+ ```
593
+ config/
594
+ sync-config.yaml # content: "@templates/prettier.json"
595
+ templates/
596
+ prettier.json # Actual Prettier config
597
+ eslintrc.yaml # Actual ESLint config
598
+ gitignore.txt # Template .gitignore content
599
+ ```
600
+
601
+ **Security:** File references are restricted to the config file's directory tree. Paths like `@../../../etc/passwd` or `@/etc/passwd` are blocked.
602
+
522
603
  ## Supported Git URL Formats
523
604
 
524
605
  ### GitHub
@@ -83,12 +83,14 @@ export function normalizeConfig(raw) {
83
83
  }
84
84
  // Resolve fields: per-repo overrides root level
85
85
  const createOnly = repoOverride?.createOnly ?? fileConfig.createOnly;
86
+ const executable = repoOverride?.executable ?? fileConfig.executable;
86
87
  const header = normalizeHeader(repoOverride?.header ?? fileConfig.header);
87
88
  const schemaUrl = repoOverride?.schemaUrl ?? fileConfig.schemaUrl;
88
89
  files.push({
89
90
  fileName,
90
91
  content: mergedContent,
91
92
  createOnly,
93
+ executable,
92
94
  header,
93
95
  schemaUrl,
94
96
  });
@@ -64,6 +64,10 @@ export function validateRawConfig(config) {
64
64
  typeof fileConfig.createOnly !== "boolean") {
65
65
  throw new Error(`File '${fileName}' createOnly must be a boolean`);
66
66
  }
67
+ if (fileConfig.executable !== undefined &&
68
+ typeof fileConfig.executable !== "boolean") {
69
+ throw new Error(`File '${fileName}' executable must be a boolean`);
70
+ }
67
71
  if (fileConfig.header !== undefined) {
68
72
  if (typeof fileConfig.header !== "string" &&
69
73
  (!Array.isArray(fileConfig.header) ||
@@ -126,6 +130,10 @@ export function validateRawConfig(config) {
126
130
  typeof fileOverride.createOnly !== "boolean") {
127
131
  throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' createOnly must be a boolean`);
128
132
  }
133
+ if (fileOverride.executable !== undefined &&
134
+ typeof fileOverride.executable !== "boolean") {
135
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' executable must be a boolean`);
136
+ }
129
137
  if (fileOverride.header !== undefined) {
130
138
  if (typeof fileOverride.header !== "string" &&
131
139
  (!Array.isArray(fileOverride.header) ||
package/dist/config.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface RawFileConfig {
5
5
  content?: ContentValue;
6
6
  mergeStrategy?: ArrayMergeStrategy;
7
7
  createOnly?: boolean;
8
+ executable?: boolean;
8
9
  header?: string | string[];
9
10
  schemaUrl?: string;
10
11
  }
@@ -12,6 +13,7 @@ export interface RawRepoFileOverride {
12
13
  content?: ContentValue;
13
14
  override?: boolean;
14
15
  createOnly?: boolean;
16
+ executable?: boolean;
15
17
  header?: string | string[];
16
18
  schemaUrl?: string;
17
19
  }
@@ -27,6 +29,7 @@ export interface FileContent {
27
29
  fileName: string;
28
30
  content: ContentValue | null;
29
31
  createOnly?: boolean;
32
+ executable?: boolean;
30
33
  header?: string[];
31
34
  schemaUrl?: string;
32
35
  }
package/dist/config.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
2
3
  import { parse } from "yaml";
3
4
  import { validateRawConfig } from "./config-validator.js";
4
5
  import { normalizeConfig } from "./config-normalizer.js";
6
+ import { resolveFileReferencesInConfig } from "./file-reference-resolver.js";
5
7
  // Re-export formatter functions for backwards compatibility
6
8
  export { convertContentToString } from "./config-formatter.js";
7
9
  // =============================================================================
@@ -9,6 +11,7 @@ export { convertContentToString } from "./config-formatter.js";
9
11
  // =============================================================================
10
12
  export function loadConfig(filePath) {
11
13
  const content = readFileSync(filePath, "utf-8");
14
+ const configDir = dirname(filePath);
12
15
  let rawConfig;
13
16
  try {
14
17
  rawConfig = parse(content);
@@ -17,6 +20,8 @@ export function loadConfig(filePath) {
17
20
  const message = error instanceof Error ? error.message : String(error);
18
21
  throw new Error(`Failed to parse YAML config at ${filePath}: ${message}`);
19
22
  }
23
+ // Resolve file references before validation so content type checking works
24
+ rawConfig = resolveFileReferencesInConfig(rawConfig, { configDir });
20
25
  validateRawConfig(rawConfig);
21
26
  return normalizeConfig(rawConfig);
22
27
  }
@@ -0,0 +1,20 @@
1
+ import type { ContentValue, RawConfig } from "./config.js";
2
+ export interface FileReferenceOptions {
3
+ configDir: string;
4
+ }
5
+ /**
6
+ * Check if a value is a file reference (string starting with @)
7
+ */
8
+ export declare function isFileReference(value: unknown): value is string;
9
+ /**
10
+ * Resolve a file reference to its content.
11
+ * - JSON files are parsed as objects
12
+ * - YAML files are parsed as objects
13
+ * - Other files are returned as strings
14
+ */
15
+ export declare function resolveFileReference(reference: string, configDir: string): ContentValue;
16
+ /**
17
+ * Resolve all file references in a raw config.
18
+ * Walks through files at root level and per-repo level.
19
+ */
20
+ export declare function resolveFileReferencesInConfig(raw: RawConfig, options: FileReferenceOptions): RawConfig;
@@ -0,0 +1,125 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve, isAbsolute, normalize, extname } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ /**
5
+ * Check if a value is a file reference (string starting with @)
6
+ */
7
+ export function isFileReference(value) {
8
+ return typeof value === "string" && value.startsWith("@");
9
+ }
10
+ /**
11
+ * Resolve a file reference to its content.
12
+ * - JSON files are parsed as objects
13
+ * - YAML files are parsed as objects
14
+ * - Other files are returned as strings
15
+ */
16
+ export function resolveFileReference(reference, configDir) {
17
+ const relativePath = reference.slice(1); // Remove @ prefix
18
+ if (relativePath.length === 0) {
19
+ throw new Error(`Invalid file reference "${reference}": path is empty`);
20
+ }
21
+ // Security: block absolute paths
22
+ if (isAbsolute(relativePath)) {
23
+ throw new Error(`File reference "${reference}" uses absolute path. Use relative paths only.`);
24
+ }
25
+ const resolvedPath = resolve(configDir, relativePath);
26
+ const normalizedResolved = normalize(resolvedPath);
27
+ const normalizedConfigDir = normalize(configDir);
28
+ // Security: ensure path stays within config directory tree
29
+ // Use path separator to ensure we're checking directory boundaries
30
+ if (!normalizedResolved.startsWith(normalizedConfigDir + "/") &&
31
+ normalizedResolved !== normalizedConfigDir) {
32
+ throw new Error(`File reference "${reference}" escapes config directory. ` +
33
+ `References must be within "${configDir}".`);
34
+ }
35
+ // Load file
36
+ let content;
37
+ try {
38
+ content = readFileSync(resolvedPath, "utf-8");
39
+ }
40
+ catch (error) {
41
+ const msg = error instanceof Error ? error.message : String(error);
42
+ throw new Error(`Failed to load file reference "${reference}": ${msg}`);
43
+ }
44
+ // Parse based on extension
45
+ const ext = extname(relativePath).toLowerCase();
46
+ if (ext === ".json") {
47
+ try {
48
+ return JSON.parse(content);
49
+ }
50
+ catch (error) {
51
+ const msg = error instanceof Error ? error.message : String(error);
52
+ throw new Error(`Invalid JSON in "${reference}": ${msg}`);
53
+ }
54
+ }
55
+ if (ext === ".yaml" || ext === ".yml") {
56
+ try {
57
+ return parseYaml(content);
58
+ }
59
+ catch (error) {
60
+ const msg = error instanceof Error ? error.message : String(error);
61
+ throw new Error(`Invalid YAML in "${reference}": ${msg}`);
62
+ }
63
+ }
64
+ // Text file - return as string
65
+ return content;
66
+ }
67
+ /**
68
+ * Recursively resolve file references in a content value.
69
+ * Only string values starting with @ are resolved.
70
+ */
71
+ function resolveContentValue(value, configDir) {
72
+ if (value === undefined) {
73
+ return undefined;
74
+ }
75
+ // If it's a file reference, resolve it
76
+ if (isFileReference(value)) {
77
+ return resolveFileReference(value, configDir);
78
+ }
79
+ // Otherwise return as-is (objects, arrays, plain strings)
80
+ return value;
81
+ }
82
+ /**
83
+ * Resolve all file references in a raw config.
84
+ * Walks through files at root level and per-repo level.
85
+ */
86
+ export function resolveFileReferencesInConfig(raw, options) {
87
+ const { configDir } = options;
88
+ // Deep clone to avoid mutating input
89
+ const result = JSON.parse(JSON.stringify(raw));
90
+ // Resolve root-level file content
91
+ if (result.files) {
92
+ for (const [fileName, fileConfig] of Object.entries(result.files)) {
93
+ if (fileConfig &&
94
+ typeof fileConfig === "object" &&
95
+ "content" in fileConfig) {
96
+ const resolved = resolveContentValue(fileConfig.content, configDir);
97
+ if (resolved !== undefined) {
98
+ result.files[fileName] = { ...fileConfig, content: resolved };
99
+ }
100
+ }
101
+ }
102
+ }
103
+ // Resolve per-repo file content
104
+ if (result.repos) {
105
+ for (const repo of result.repos) {
106
+ if (repo.files) {
107
+ for (const [fileName, fileOverride] of Object.entries(repo.files)) {
108
+ // Skip false (exclusion) entries
109
+ if (fileOverride === false) {
110
+ continue;
111
+ }
112
+ if (fileOverride &&
113
+ typeof fileOverride === "object" &&
114
+ "content" in fileOverride) {
115
+ const resolved = resolveContentValue(fileOverride.content, configDir);
116
+ if (resolved !== undefined) {
117
+ repo.files[fileName] = { ...fileOverride, content: resolved };
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ return result;
125
+ }
package/dist/git-ops.d.ts CHANGED
@@ -28,6 +28,12 @@ export declare class GitOps {
28
28
  clone(gitUrl: string): Promise<void>;
29
29
  createBranch(branchName: string): Promise<void>;
30
30
  writeFile(fileName: string, content: string): void;
31
+ /**
32
+ * Marks a file as executable in git using update-index --chmod=+x.
33
+ * This modifies the file mode in git's index, not the filesystem.
34
+ * @param fileName - The file path relative to the work directory
35
+ */
36
+ setExecutable(fileName: string): Promise<void>;
31
37
  /**
32
38
  * Checks if writing the given content would result in changes.
33
39
  * Works in both normal and dry-run modes by comparing content directly.
package/dist/git-ops.js CHANGED
@@ -103,6 +103,20 @@ export class GitOps {
103
103
  const normalized = content.endsWith("\n") ? content : content + "\n";
104
104
  writeFileSync(filePath, normalized, "utf-8");
105
105
  }
106
+ /**
107
+ * Marks a file as executable in git using update-index --chmod=+x.
108
+ * This modifies the file mode in git's index, not the filesystem.
109
+ * @param fileName - The file path relative to the work directory
110
+ */
111
+ async setExecutable(fileName) {
112
+ if (this.dryRun) {
113
+ return;
114
+ }
115
+ const filePath = this.validatePath(fileName);
116
+ // Use relative path from workDir for git command
117
+ const relativePath = relative(this.workDir, filePath);
118
+ await this.exec(`git update-index --chmod=+x ${escapeShellArg(relativePath)}`, this.workDir);
119
+ }
106
120
  /**
107
121
  * Checks if writing the given content would result in changes.
108
122
  * Works in both normal and dry-run modes by comparing content directly.
@@ -5,6 +5,20 @@ import { getRepoDisplayName } from "./repo-detector.js";
5
5
  import { GitOps } from "./git-ops.js";
6
6
  import { createPR } from "./pr-creator.js";
7
7
  import { logger } from "./logger.js";
8
+ /**
9
+ * Determines if a file should be marked as executable.
10
+ * .sh files are auto-executable unless explicit executable: false is set.
11
+ * Non-.sh files are executable only if executable: true is explicitly set.
12
+ */
13
+ function shouldBeExecutable(file) {
14
+ const isShellScript = file.fileName.endsWith(".sh");
15
+ if (file.executable !== undefined) {
16
+ // Explicit setting takes precedence
17
+ return file.executable;
18
+ }
19
+ // Default: .sh files are executable, others are not
20
+ return isShellScript;
21
+ }
8
22
  export class RepositoryProcessor {
9
23
  gitOps = null;
10
24
  gitOpsFactory;
@@ -64,6 +78,18 @@ export class RepositoryProcessor {
64
78
  this.gitOps.writeFile(file.fileName, fileContent);
65
79
  }
66
80
  }
81
+ // Step 5b: Set executable permission for files that need it
82
+ const skippedFileNames = new Set(changedFiles.filter((f) => f.action === "skip").map((f) => f.fileName));
83
+ for (const file of repoConfig.files) {
84
+ // Skip files that were excluded (createOnly + exists)
85
+ if (skippedFileNames.has(file.fileName)) {
86
+ continue;
87
+ }
88
+ if (shouldBeExecutable(file)) {
89
+ this.log.info(`Setting executable: ${file.fileName}`);
90
+ await this.gitOps.setExecutable(file.fileName);
91
+ }
92
+ }
67
93
  // Step 6: Check for changes (exclude skipped files)
68
94
  let hasChanges;
69
95
  if (dryRun) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "3.4.0",
3
+ "version": "3.6.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",