@aspruyt/json-config-sync 3.5.0 → 3.7.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
 
@@ -221,6 +223,38 @@ repos:
221
223
  - git: git@github.com:org/backend.git
222
224
  ```
223
225
 
226
+ #### Escaping Variable Syntax
227
+
228
+ If your target file needs literal `${VAR}` syntax (e.g., for devcontainer.json, shell scripts, or other templating systems), use `$$` to escape:
229
+
230
+ ```yaml
231
+ files:
232
+ .devcontainer/devcontainer.json:
233
+ content:
234
+ name: my-dev-container
235
+ remoteEnv:
236
+ # Escaped - outputs literal ${localWorkspaceFolder} for VS Code
237
+ LOCAL_WORKSPACE_FOLDER: "$${localWorkspaceFolder}"
238
+ CONTAINER_WORKSPACE: "$${containerWorkspaceFolder}"
239
+ # Interpolated - replaced with actual env value
240
+ API_KEY: "${API_KEY}"
241
+ ```
242
+
243
+ Output:
244
+
245
+ ```json
246
+ {
247
+ "name": "my-dev-container",
248
+ "remoteEnv": {
249
+ "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}",
250
+ "CONTAINER_WORKSPACE": "${containerWorkspaceFolder}",
251
+ "API_KEY": "actual-api-key-value"
252
+ }
253
+ }
254
+ ```
255
+
256
+ This follows the same escape convention used by Docker Compose.
257
+
224
258
  ### Merge Directives
225
259
 
226
260
  Control array merging with the `$arrayMerge` directive:
@@ -489,6 +523,44 @@ repos:
489
523
 
490
524
  **Validation:** JSON/YAML file extensions (`.json`, `.yaml`, `.yml`) require object content. Other extensions require string or string[] content.
491
525
 
526
+ ### Executable Files
527
+
528
+ Shell scripts (`.sh` files) are automatically marked as executable using `git update-index --chmod=+x`. You can control this behavior:
529
+
530
+ ```yaml
531
+ files:
532
+ # .sh files are auto-executable (no config needed)
533
+ deploy.sh:
534
+ content: |-
535
+ #!/bin/bash
536
+ echo "Deploying..."
537
+
538
+ # Disable auto-executable for a specific .sh file
539
+ template.sh:
540
+ executable: false
541
+ content: "# This is just a template"
542
+
543
+ # Make a non-.sh file executable
544
+ run:
545
+ executable: true
546
+ content: |-
547
+ #!/usr/bin/env python3
548
+ print("Hello")
549
+
550
+ repos:
551
+ - git: git@github.com:org/repo.git
552
+ files:
553
+ # Override executable per-repo
554
+ deploy.sh:
555
+ executable: false # Disable for this repo
556
+ ```
557
+
558
+ **Behavior:**
559
+
560
+ - `.sh` files: Automatically executable unless `executable: false`
561
+ - Other files: Not executable unless `executable: true`
562
+ - Per-repo settings override root-level settings
563
+
492
564
  ### Subdirectory Paths
493
565
 
494
566
  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/env.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Environment variable interpolation utilities.
3
3
  * Supports ${VAR}, ${VAR:-default}, and ${VAR:?message} syntax.
4
+ * Use $${VAR} to escape and output literal ${VAR}.
4
5
  */
5
6
  export interface EnvInterpolationOptions {
6
7
  /**
@@ -12,10 +13,11 @@ export interface EnvInterpolationOptions {
12
13
  /**
13
14
  * Interpolate environment variables in a JSON object.
14
15
  *
15
- * Supports three syntaxes:
16
+ * Supports these syntaxes:
16
17
  * - ${VAR} - Replace with env value, error if missing (in strict mode)
17
18
  * - ${VAR:-default} - Replace with env value, or use default if missing
18
19
  * - ${VAR:?message} - Replace with env value, or throw error with message if missing
20
+ * - $${VAR} - Escape: outputs literal ${VAR} without interpolation
19
21
  *
20
22
  * @param json - The JSON object to process
21
23
  * @param options - Interpolation options (default: strict mode)
package/dist/env.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Environment variable interpolation utilities.
3
3
  * Supports ${VAR}, ${VAR:-default}, and ${VAR:?message} syntax.
4
+ * Use $${VAR} to escape and output literal ${VAR}.
4
5
  */
5
6
  const DEFAULT_OPTIONS = {
6
7
  strict: true,
@@ -18,6 +19,17 @@ const DEFAULT_OPTIONS = {
18
19
  * - ${VAR:?message} -> varName=VAR, modifier=?, value=message
19
20
  */
20
21
  const ENV_VAR_REGEX = /\$\{([^}:]+)(?::([?-])([^}]*))?\}/g;
22
+ /**
23
+ * Regex to match escaped environment variable placeholders.
24
+ * $${...} outputs literal ${...} without interpolation.
25
+ * Example: $${VAR} -> ${VAR}, $${VAR:-default} -> ${VAR:-default}
26
+ */
27
+ const ESCAPED_VAR_REGEX = /\$\$\{([^}]+)\}/g;
28
+ /**
29
+ * Placeholder prefix for temporarily storing escaped sequences.
30
+ * Uses null bytes which won't appear in normal content.
31
+ */
32
+ const ESCAPE_PLACEHOLDER = "\x00ESCAPED_VAR\x00";
21
33
  /**
22
34
  * Check if a value is a plain object (not null, not array).
23
35
  */
@@ -26,9 +38,18 @@ function isPlainObject(val) {
26
38
  }
27
39
  /**
28
40
  * Process a single string value, replacing environment variable placeholders.
41
+ * Supports escaping with $${VAR} syntax to output literal ${VAR}.
29
42
  */
30
43
  function processString(value, options) {
31
- return value.replace(ENV_VAR_REGEX, (match, varName, modifier, defaultOrMsg) => {
44
+ // Phase 1: Replace escaped $${...} with placeholders
45
+ const escapedContent = [];
46
+ let processed = value.replace(ESCAPED_VAR_REGEX, (_match, content) => {
47
+ const index = escapedContent.length;
48
+ escapedContent.push(content);
49
+ return `${ESCAPE_PLACEHOLDER}${index}\x00`;
50
+ });
51
+ // Phase 2: Interpolate remaining ${...}
52
+ processed = processed.replace(ENV_VAR_REGEX, (match, varName, modifier, defaultOrMsg) => {
32
53
  const envValue = process.env[varName];
33
54
  // Variable exists - use its value
34
55
  if (envValue !== undefined) {
@@ -50,6 +71,12 @@ function processString(value, options) {
50
71
  // Non-strict mode - leave placeholder as-is
51
72
  return match;
52
73
  });
74
+ // Phase 3: Restore escaped sequences as literal ${...}
75
+ processed = processed.replace(new RegExp(`${ESCAPE_PLACEHOLDER}(\\d+)\x00`, "g"), (_match, indexStr) => {
76
+ const index = parseInt(indexStr, 10);
77
+ return `\${${escapedContent[index]}}`;
78
+ });
79
+ return processed;
53
80
  }
54
81
  /**
55
82
  * Recursively process a value, interpolating environment variables in strings.
@@ -74,10 +101,11 @@ function processValue(value, options) {
74
101
  /**
75
102
  * Interpolate environment variables in a JSON object.
76
103
  *
77
- * Supports three syntaxes:
104
+ * Supports these syntaxes:
78
105
  * - ${VAR} - Replace with env value, error if missing (in strict mode)
79
106
  * - ${VAR:-default} - Replace with env value, or use default if missing
80
107
  * - ${VAR:?message} - Replace with env value, or throw error with message if missing
108
+ * - $${VAR} - Escape: outputs literal ${VAR} without interpolation
81
109
  *
82
110
  * @param json - The JSON object to process
83
111
  * @param options - Interpolation options (default: strict mode)
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.7.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",