@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 +72 -0
- package/dist/config-normalizer.js +2 -0
- package/dist/config-validator.js +8 -0
- package/dist/config.d.ts +3 -0
- package/dist/env.d.ts +3 -1
- package/dist/env.js +30 -2
- package/dist/git-ops.d.ts +6 -0
- package/dist/git-ops.js +14 -0
- package/dist/repository-processor.js +26 -0
- package/package.json +1 -1
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
|
});
|
package/dist/config-validator.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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