@aspruyt/json-config-sync 3.5.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
@@ -176,6 +176,7 @@ repos: # List of repositories
176
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
177
  | `mergeStrategy` | Merge strategy: `replace`, `append`, `prepend` (for arrays and text lines) | No |
178
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 |
179
180
  | `header` | Comment line(s) at top of YAML files (string or array) | No |
180
181
  | `schemaUrl` | Adds `# yaml-language-server: $schema=<url>` to YAML files | No |
181
182
 
@@ -193,6 +194,7 @@ repos: # List of repositories
193
194
  | `content` | Content overlay merged onto file's base content | No |
194
195
  | `override` | If `true`, ignore base content and use only this repo's | No |
195
196
  | `createOnly` | Override root-level `createOnly` for this repo | No |
197
+ | `executable` | Override root-level `executable` for this repo | No |
196
198
  | `header` | Override root-level `header` for this repo | No |
197
199
  | `schemaUrl` | Override root-level `schemaUrl` for this repo | No |
198
200
 
@@ -489,6 +491,44 @@ repos:
489
491
 
490
492
  **Validation:** JSON/YAML file extensions (`.json`, `.yaml`, `.yml`) require object content. Other extensions require string or string[] content.
491
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
+
492
532
  ### Subdirectory Paths
493
533
 
494
534
  Sync files to any subdirectory path - parent directories are created automatically:
@@ -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/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.5.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",