@aspruyt/json-config-sync 3.1.0 → 3.3.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 +95 -7
- package/dist/config-formatter.d.ts +10 -2
- package/dist/config-formatter.js +76 -5
- package/dist/config-normalizer.js +58 -13
- package/dist/config-validator.js +73 -10
- package/dist/config.d.ts +10 -3
- package/dist/env.d.ts +13 -0
- package/dist/env.js +28 -0
- package/dist/merge.d.ts +11 -0
- package/dist/merge.js +42 -0
- package/dist/repository-processor.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
5
5
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
6
6
|
|
|
7
|
-
A CLI tool that syncs JSON or
|
|
7
|
+
A CLI tool that syncs JSON, YAML, or text configuration files across multiple GitHub and Azure DevOps repositories by creating pull requests. Output format is automatically detected from the target filename extension (`.json` → JSON, `.yaml`/`.yml` → YAML, other → text).
|
|
8
8
|
|
|
9
9
|
## Table of Contents
|
|
10
10
|
|
|
@@ -60,12 +60,15 @@ json-config-sync --config ./config.yaml
|
|
|
60
60
|
## Features
|
|
61
61
|
|
|
62
62
|
- **Multi-File Sync** - Sync multiple config files in a single run
|
|
63
|
-
- **
|
|
63
|
+
- **Multi-Format Output** - JSON, YAML, or plain text based on filename extension
|
|
64
|
+
- **Text Files** - Sync `.gitignore`, `.markdownlintignore`, etc. with string or lines array
|
|
64
65
|
- **Content Inheritance** - Define base config once, override per-repo as needed
|
|
65
66
|
- **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
|
|
66
67
|
- **Environment Variables** - Use `${VAR}` syntax for dynamic values
|
|
67
68
|
- **Merge Strategies** - Control how arrays merge (replace, append, prepend)
|
|
68
69
|
- **Override Mode** - Skip merging entirely for specific repos
|
|
70
|
+
- **Empty Files** - Create files with no content (e.g., `.prettierignore`)
|
|
71
|
+
- **YAML Comments** - Add header comments and schema directives to YAML files
|
|
69
72
|
- **GitHub & Azure DevOps** - Works with both platforms
|
|
70
73
|
- **Dry-Run Mode** - Preview changes without creating PRs
|
|
71
74
|
- **Error Resilience** - Continues processing if individual repos fail
|
|
@@ -165,11 +168,13 @@ repos: # List of repositories
|
|
|
165
168
|
|
|
166
169
|
### Per-File Fields
|
|
167
170
|
|
|
168
|
-
| Field | Description
|
|
169
|
-
| --------------- |
|
|
170
|
-
| `content` | Base config
|
|
171
|
-
| `mergeStrategy` |
|
|
172
|
-
| `createOnly` | If `true`, only create file if it doesn't exist
|
|
171
|
+
| Field | Description | Required |
|
|
172
|
+
| --------------- | ------------------------------------------------------------------------------------------------ | -------- |
|
|
173
|
+
| `content` | Base config: object for JSON/YAML files, string or string[] for text files (omit for empty file) | No |
|
|
174
|
+
| `mergeStrategy` | Merge strategy: `replace`, `append`, `prepend` (for arrays and text lines) | No |
|
|
175
|
+
| `createOnly` | If `true`, only create file if it doesn't exist | No |
|
|
176
|
+
| `header` | Comment line(s) at top of YAML files (string or array) | No |
|
|
177
|
+
| `schemaUrl` | Adds `# yaml-language-server: $schema=<url>` to YAML files | No |
|
|
173
178
|
|
|
174
179
|
### Per-Repo Fields
|
|
175
180
|
|
|
@@ -185,6 +190,8 @@ repos: # List of repositories
|
|
|
185
190
|
| `content` | Content overlay merged onto file's base content | No |
|
|
186
191
|
| `override` | If `true`, ignore base content and use only this repo's | No |
|
|
187
192
|
| `createOnly` | Override root-level `createOnly` for this repo | No |
|
|
193
|
+
| `header` | Override root-level `header` for this repo | No |
|
|
194
|
+
| `schemaUrl` | Override root-level `schemaUrl` for this repo | No |
|
|
188
195
|
|
|
189
196
|
**File Exclusion:** Set a file to `false` to exclude it from a specific repo:
|
|
190
197
|
|
|
@@ -398,6 +405,87 @@ repos:
|
|
|
398
405
|
createOnly: false # Override: always sync this file
|
|
399
406
|
```
|
|
400
407
|
|
|
408
|
+
### YAML Comments and Empty Files
|
|
409
|
+
|
|
410
|
+
Add schema directives and comments to YAML files, or create empty files:
|
|
411
|
+
|
|
412
|
+
```yaml
|
|
413
|
+
files:
|
|
414
|
+
# YAML file with schema directive and header comment
|
|
415
|
+
trivy.yaml:
|
|
416
|
+
schemaUrl: https://trivy.dev/latest/docs/references/configuration/config-file/
|
|
417
|
+
header: "Trivy security scanner configuration"
|
|
418
|
+
content:
|
|
419
|
+
exit-code: 1
|
|
420
|
+
scan:
|
|
421
|
+
scanners:
|
|
422
|
+
- vuln
|
|
423
|
+
|
|
424
|
+
# Empty file (content omitted)
|
|
425
|
+
.prettierignore:
|
|
426
|
+
createOnly: true
|
|
427
|
+
# No content = empty file
|
|
428
|
+
|
|
429
|
+
# YAML with multi-line header
|
|
430
|
+
config.yaml:
|
|
431
|
+
header:
|
|
432
|
+
- "Auto-generated configuration"
|
|
433
|
+
- "Do not edit manually"
|
|
434
|
+
content:
|
|
435
|
+
version: 1
|
|
436
|
+
|
|
437
|
+
repos:
|
|
438
|
+
- git: git@github.com:org/repo.git
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**Output for trivy.yaml:**
|
|
442
|
+
|
|
443
|
+
```yaml
|
|
444
|
+
# yaml-language-server: $schema=https://trivy.dev/latest/docs/references/configuration/config-file/
|
|
445
|
+
# Trivy security scanner configuration
|
|
446
|
+
exit-code: 1
|
|
447
|
+
scan:
|
|
448
|
+
scanners:
|
|
449
|
+
- vuln
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
**Note:** `header` and `schemaUrl` only apply to YAML output files (`.yaml`, `.yml`). They are ignored for JSON files.
|
|
453
|
+
|
|
454
|
+
### Text Files
|
|
455
|
+
|
|
456
|
+
Sync text files like `.gitignore`, `.markdownlintignore`, or `.env.example` using string or lines array content:
|
|
457
|
+
|
|
458
|
+
```yaml
|
|
459
|
+
files:
|
|
460
|
+
# String content (multiline text)
|
|
461
|
+
.markdownlintignore:
|
|
462
|
+
createOnly: true
|
|
463
|
+
content: |-
|
|
464
|
+
# Claude Code generated files
|
|
465
|
+
.claude/
|
|
466
|
+
|
|
467
|
+
# Lines array with merge strategy
|
|
468
|
+
.gitignore:
|
|
469
|
+
mergeStrategy: append
|
|
470
|
+
content:
|
|
471
|
+
- "node_modules/"
|
|
472
|
+
- "dist/"
|
|
473
|
+
|
|
474
|
+
repos:
|
|
475
|
+
- git: git@github.com:org/repo.git
|
|
476
|
+
files:
|
|
477
|
+
.gitignore:
|
|
478
|
+
content:
|
|
479
|
+
- "coverage/" # Appended to base lines
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
**Content Types:**
|
|
483
|
+
|
|
484
|
+
- **String content** (`content: |-`) - Direct text output with environment variable interpolation. Merging always replaces the base.
|
|
485
|
+
- **Lines array** (`content: ['line1', 'line2']`) - Each line joined with newlines. Supports merge strategies (`append`, `prepend`, `replace`).
|
|
486
|
+
|
|
487
|
+
**Validation:** JSON/YAML file extensions (`.json`, `.yaml`, `.yml`) require object content. Other extensions require string or string[] content.
|
|
488
|
+
|
|
401
489
|
## Supported Git URL Formats
|
|
402
490
|
|
|
403
491
|
### GitHub
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
export type OutputFormat = "json" | "yaml";
|
|
2
|
+
/**
|
|
3
|
+
* Options for content conversion.
|
|
4
|
+
*/
|
|
5
|
+
export interface ConvertOptions {
|
|
6
|
+
header?: string[];
|
|
7
|
+
schemaUrl?: string;
|
|
8
|
+
}
|
|
2
9
|
/**
|
|
3
10
|
* Detects output format from file extension.
|
|
4
11
|
*/
|
|
5
12
|
export declare function detectOutputFormat(fileName: string): OutputFormat;
|
|
6
13
|
/**
|
|
7
|
-
* Converts content
|
|
14
|
+
* Converts content to string in the appropriate format.
|
|
15
|
+
* Handles null content (empty files), text content (string/string[]), and object content (JSON/YAML).
|
|
8
16
|
*/
|
|
9
|
-
export declare function convertContentToString(content: Record<string, unknown
|
|
17
|
+
export declare function convertContentToString(content: Record<string, unknown> | string | string[] | null, fileName: string, options?: ConvertOptions): string;
|
package/dist/config-formatter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { stringify } from "yaml";
|
|
1
|
+
import { Document, stringify } from "yaml";
|
|
2
2
|
/**
|
|
3
3
|
* Detects output format from file extension.
|
|
4
4
|
*/
|
|
@@ -10,12 +10,83 @@ export function detectOutputFormat(fileName) {
|
|
|
10
10
|
return "json";
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
13
|
+
* Builds header comment string from header lines and schemaUrl.
|
|
14
|
+
* Returns undefined if no comments to add.
|
|
15
|
+
* Each line gets a space prefix since yaml library adds # directly.
|
|
14
16
|
*/
|
|
15
|
-
|
|
17
|
+
function buildHeaderComment(header, schemaUrl) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
// Add yaml-language-server schema directive first (if present)
|
|
20
|
+
if (schemaUrl) {
|
|
21
|
+
lines.push(` yaml-language-server: $schema=${schemaUrl}`);
|
|
22
|
+
}
|
|
23
|
+
// Add custom header lines (with space prefix for proper formatting)
|
|
24
|
+
if (header && header.length > 0) {
|
|
25
|
+
lines.push(...header.map((h) => ` ${h}`));
|
|
26
|
+
}
|
|
27
|
+
if (lines.length === 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
// Join with newlines - the yaml library adds # prefix to each line
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Builds comment-only output for empty YAML files with headers.
|
|
34
|
+
*/
|
|
35
|
+
function buildCommentOnlyYaml(header, schemaUrl) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
// Add yaml-language-server schema directive first (if present)
|
|
38
|
+
if (schemaUrl) {
|
|
39
|
+
lines.push(`# yaml-language-server: $schema=${schemaUrl}`);
|
|
40
|
+
}
|
|
41
|
+
// Add custom header lines
|
|
42
|
+
if (header && header.length > 0) {
|
|
43
|
+
lines.push(...header.map((h) => `# ${h}`));
|
|
44
|
+
}
|
|
45
|
+
if (lines.length === 0)
|
|
46
|
+
return undefined;
|
|
47
|
+
return lines.join("\n") + "\n";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Converts content to string in the appropriate format.
|
|
51
|
+
* Handles null content (empty files), text content (string/string[]), and object content (JSON/YAML).
|
|
52
|
+
*/
|
|
53
|
+
export function convertContentToString(content, fileName, options) {
|
|
54
|
+
// Handle empty file case
|
|
55
|
+
if (content === null) {
|
|
56
|
+
const format = detectOutputFormat(fileName);
|
|
57
|
+
if (format === "yaml" && options) {
|
|
58
|
+
const commentOnly = buildCommentOnlyYaml(options.header, options.schemaUrl);
|
|
59
|
+
if (commentOnly) {
|
|
60
|
+
return commentOnly;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
// Handle string content (text file)
|
|
66
|
+
if (typeof content === "string") {
|
|
67
|
+
// Ensure trailing newline for text files
|
|
68
|
+
return content.endsWith("\n") ? content : content + "\n";
|
|
69
|
+
}
|
|
70
|
+
// Handle string[] content (text file with lines)
|
|
71
|
+
if (Array.isArray(content)) {
|
|
72
|
+
// Join lines with newlines and ensure trailing newline
|
|
73
|
+
const text = content.join("\n");
|
|
74
|
+
return text.length > 0 ? text + "\n" : "";
|
|
75
|
+
}
|
|
76
|
+
// Handle object content (JSON/YAML)
|
|
16
77
|
const format = detectOutputFormat(fileName);
|
|
17
78
|
if (format === "yaml") {
|
|
18
|
-
|
|
79
|
+
// Use Document API for YAML to support comments
|
|
80
|
+
const doc = new Document(content);
|
|
81
|
+
// Add header comment if present
|
|
82
|
+
if (options) {
|
|
83
|
+
const headerComment = buildHeaderComment(options.header, options.schemaUrl);
|
|
84
|
+
if (headerComment) {
|
|
85
|
+
doc.commentBefore = headerComment;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return stringify(doc, { indent: 2 });
|
|
19
89
|
}
|
|
20
|
-
|
|
90
|
+
// JSON format - comments not supported, ignore header/schemaUrl
|
|
91
|
+
return JSON.stringify(content, null, 2) + "\n";
|
|
21
92
|
}
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import { deepMerge, stripMergeDirectives, createMergeContext, } from "./merge.js";
|
|
2
|
-
import {
|
|
1
|
+
import { deepMerge, stripMergeDirectives, createMergeContext, isTextContent, mergeTextContent, } from "./merge.js";
|
|
2
|
+
import { interpolateContent } from "./env.js";
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes header to array format.
|
|
5
|
+
*/
|
|
6
|
+
function normalizeHeader(header) {
|
|
7
|
+
if (header === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
if (typeof header === "string")
|
|
10
|
+
return [header];
|
|
11
|
+
return header;
|
|
12
|
+
}
|
|
3
13
|
/**
|
|
4
14
|
* Normalizes raw config into expanded, merged config.
|
|
5
15
|
* Pipeline: expand git arrays -> merge content -> interpolate env vars
|
|
@@ -20,32 +30,67 @@ export function normalizeConfig(raw) {
|
|
|
20
30
|
continue;
|
|
21
31
|
}
|
|
22
32
|
const fileConfig = raw.files[fileName];
|
|
23
|
-
const baseContent = fileConfig.content ?? {};
|
|
24
33
|
const fileStrategy = fileConfig.mergeStrategy ?? "replace";
|
|
25
34
|
// Step 3: Compute merged content for this file
|
|
26
35
|
let mergedContent;
|
|
27
36
|
if (repoOverride?.override) {
|
|
28
|
-
// Override mode: use only repo file content
|
|
29
|
-
|
|
37
|
+
// Override mode: use only repo file content (may be undefined for empty file)
|
|
38
|
+
if (repoOverride.content === undefined) {
|
|
39
|
+
mergedContent = null;
|
|
40
|
+
}
|
|
41
|
+
else if (isTextContent(repoOverride.content)) {
|
|
42
|
+
// Text content: use as-is (no merge directives to strip)
|
|
43
|
+
mergedContent = structuredClone(repoOverride.content);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else if (fileConfig.content === undefined) {
|
|
50
|
+
// Root file has no content = empty file (unless repo provides content)
|
|
51
|
+
if (repoOverride?.content) {
|
|
52
|
+
if (isTextContent(repoOverride.content)) {
|
|
53
|
+
mergedContent = structuredClone(repoOverride.content);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
mergedContent = null;
|
|
61
|
+
}
|
|
30
62
|
}
|
|
31
63
|
else if (!repoOverride?.content) {
|
|
32
64
|
// No repo override: use file base content as-is
|
|
33
|
-
mergedContent = structuredClone(
|
|
65
|
+
mergedContent = structuredClone(fileConfig.content);
|
|
34
66
|
}
|
|
35
67
|
else {
|
|
36
|
-
// Merge mode:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
68
|
+
// Merge mode: handle text vs object content
|
|
69
|
+
if (isTextContent(fileConfig.content)) {
|
|
70
|
+
// Text content merging
|
|
71
|
+
mergedContent = mergeTextContent(fileConfig.content, repoOverride.content, fileStrategy);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// Object content: deep merge file base + repo overlay
|
|
75
|
+
const ctx = createMergeContext(fileStrategy);
|
|
76
|
+
mergedContent = deepMerge(structuredClone(fileConfig.content), repoOverride.content, ctx);
|
|
77
|
+
mergedContent = stripMergeDirectives(mergedContent);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Step 4: Interpolate env vars (only if content exists)
|
|
81
|
+
if (mergedContent !== null) {
|
|
82
|
+
mergedContent = interpolateContent(mergedContent, { strict: true });
|
|
40
83
|
}
|
|
41
|
-
//
|
|
42
|
-
mergedContent = interpolateEnvVars(mergedContent, { strict: true });
|
|
43
|
-
// Resolve createOnly: per-repo overrides root level
|
|
84
|
+
// Resolve fields: per-repo overrides root level
|
|
44
85
|
const createOnly = repoOverride?.createOnly ?? fileConfig.createOnly;
|
|
86
|
+
const header = normalizeHeader(repoOverride?.header ?? fileConfig.header);
|
|
87
|
+
const schemaUrl = repoOverride?.schemaUrl ?? fileConfig.schemaUrl;
|
|
45
88
|
files.push({
|
|
46
89
|
fileName,
|
|
47
90
|
content: mergedContent,
|
|
48
91
|
createOnly,
|
|
92
|
+
header,
|
|
93
|
+
schemaUrl,
|
|
49
94
|
});
|
|
50
95
|
}
|
|
51
96
|
expandedRepos.push({
|
package/dist/config-validator.js
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import { isAbsolute } from "node:path";
|
|
2
2
|
const VALID_STRATEGIES = ["replace", "append", "prepend"];
|
|
3
|
+
/**
|
|
4
|
+
* Check if content is text type (string or string[]).
|
|
5
|
+
*/
|
|
6
|
+
function isTextContent(content) {
|
|
7
|
+
return (typeof content === "string" ||
|
|
8
|
+
(Array.isArray(content) &&
|
|
9
|
+
content.every((item) => typeof item === "string")));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Check if content is object type (for JSON/YAML output).
|
|
13
|
+
*/
|
|
14
|
+
function isObjectContent(content) {
|
|
15
|
+
return (typeof content === "object" && content !== null && !Array.isArray(content));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Check if file extension is for structured output (JSON/YAML).
|
|
19
|
+
*/
|
|
20
|
+
function isStructuredFileExtension(fileName) {
|
|
21
|
+
const ext = fileName.toLowerCase().split(".").pop();
|
|
22
|
+
return ext === "json" || ext === "yaml" || ext === "yml";
|
|
23
|
+
}
|
|
3
24
|
/**
|
|
4
25
|
* Validates raw config structure before normalization.
|
|
5
26
|
* @throws Error if validation fails
|
|
@@ -19,11 +40,21 @@ export function validateRawConfig(config) {
|
|
|
19
40
|
if (!fileConfig || typeof fileConfig !== "object") {
|
|
20
41
|
throw new Error(`File '${fileName}' must have a configuration object`);
|
|
21
42
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
43
|
+
// Validate content type
|
|
44
|
+
if (fileConfig.content !== undefined) {
|
|
45
|
+
const hasText = isTextContent(fileConfig.content);
|
|
46
|
+
const hasObject = isObjectContent(fileConfig.content);
|
|
47
|
+
if (!hasText && !hasObject) {
|
|
48
|
+
throw new Error(`File '${fileName}' content must be an object, string, or array of strings`);
|
|
49
|
+
}
|
|
50
|
+
// Validate content type matches file extension
|
|
51
|
+
const isStructured = isStructuredFileExtension(fileName);
|
|
52
|
+
if (isStructured && hasText) {
|
|
53
|
+
throw new Error(`File '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
|
|
54
|
+
}
|
|
55
|
+
if (!isStructured && hasObject) {
|
|
56
|
+
throw new Error(`File '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
|
|
57
|
+
}
|
|
27
58
|
}
|
|
28
59
|
if (fileConfig.mergeStrategy !== undefined &&
|
|
29
60
|
!VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
|
|
@@ -33,6 +64,17 @@ export function validateRawConfig(config) {
|
|
|
33
64
|
typeof fileConfig.createOnly !== "boolean") {
|
|
34
65
|
throw new Error(`File '${fileName}' createOnly must be a boolean`);
|
|
35
66
|
}
|
|
67
|
+
if (fileConfig.header !== undefined) {
|
|
68
|
+
if (typeof fileConfig.header !== "string" &&
|
|
69
|
+
(!Array.isArray(fileConfig.header) ||
|
|
70
|
+
!fileConfig.header.every((h) => typeof h === "string"))) {
|
|
71
|
+
throw new Error(`File '${fileName}' header must be a string or array of strings`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (fileConfig.schemaUrl !== undefined &&
|
|
75
|
+
typeof fileConfig.schemaUrl !== "string") {
|
|
76
|
+
throw new Error(`File '${fileName}' schemaUrl must be a string`);
|
|
77
|
+
}
|
|
36
78
|
}
|
|
37
79
|
if (!config.repos || !Array.isArray(config.repos)) {
|
|
38
80
|
throw new Error("Config missing required field: repos (must be an array)");
|
|
@@ -64,16 +106,37 @@ export function validateRawConfig(config) {
|
|
|
64
106
|
if (fileOverride.override && !fileOverride.content) {
|
|
65
107
|
throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined`);
|
|
66
108
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
109
|
+
// Validate content type
|
|
110
|
+
if (fileOverride.content !== undefined) {
|
|
111
|
+
const hasText = isTextContent(fileOverride.content);
|
|
112
|
+
const hasObject = isObjectContent(fileOverride.content);
|
|
113
|
+
if (!hasText && !hasObject) {
|
|
114
|
+
throw new Error(`Repo at index ${i}: file '${fileName}' content must be an object, string, or array of strings`);
|
|
115
|
+
}
|
|
116
|
+
// Validate content type matches file extension
|
|
117
|
+
const isStructured = isStructuredFileExtension(fileName);
|
|
118
|
+
if (isStructured && hasText) {
|
|
119
|
+
throw new Error(`Repo at index ${i}: file '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
|
|
120
|
+
}
|
|
121
|
+
if (!isStructured && hasObject) {
|
|
122
|
+
throw new Error(`Repo at index ${i}: file '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
|
|
123
|
+
}
|
|
72
124
|
}
|
|
73
125
|
if (fileOverride.createOnly !== undefined &&
|
|
74
126
|
typeof fileOverride.createOnly !== "boolean") {
|
|
75
127
|
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' createOnly must be a boolean`);
|
|
76
128
|
}
|
|
129
|
+
if (fileOverride.header !== undefined) {
|
|
130
|
+
if (typeof fileOverride.header !== "string" &&
|
|
131
|
+
(!Array.isArray(fileOverride.header) ||
|
|
132
|
+
!fileOverride.header.every((h) => typeof h === "string"))) {
|
|
133
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' header must be a string or array of strings`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (fileOverride.schemaUrl !== undefined &&
|
|
137
|
+
typeof fileOverride.schemaUrl !== "string") {
|
|
138
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' schemaUrl must be a string`);
|
|
139
|
+
}
|
|
77
140
|
}
|
|
78
141
|
}
|
|
79
142
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { ArrayMergeStrategy } from "./merge.js";
|
|
2
2
|
export { convertContentToString } from "./config-formatter.js";
|
|
3
|
+
export type ContentValue = Record<string, unknown> | string | string[];
|
|
3
4
|
export interface RawFileConfig {
|
|
4
|
-
content
|
|
5
|
+
content?: ContentValue;
|
|
5
6
|
mergeStrategy?: ArrayMergeStrategy;
|
|
6
7
|
createOnly?: boolean;
|
|
8
|
+
header?: string | string[];
|
|
9
|
+
schemaUrl?: string;
|
|
7
10
|
}
|
|
8
11
|
export interface RawRepoFileOverride {
|
|
9
|
-
content?:
|
|
12
|
+
content?: ContentValue;
|
|
10
13
|
override?: boolean;
|
|
11
14
|
createOnly?: boolean;
|
|
15
|
+
header?: string | string[];
|
|
16
|
+
schemaUrl?: string;
|
|
12
17
|
}
|
|
13
18
|
export interface RawRepoConfig {
|
|
14
19
|
git: string | string[];
|
|
@@ -20,8 +25,10 @@ export interface RawConfig {
|
|
|
20
25
|
}
|
|
21
26
|
export interface FileContent {
|
|
22
27
|
fileName: string;
|
|
23
|
-
content:
|
|
28
|
+
content: ContentValue | null;
|
|
24
29
|
createOnly?: boolean;
|
|
30
|
+
header?: string[];
|
|
31
|
+
schemaUrl?: string;
|
|
25
32
|
}
|
|
26
33
|
export interface RepoConfig {
|
|
27
34
|
git: string;
|
package/dist/env.d.ts
CHANGED
|
@@ -22,3 +22,16 @@ export interface EnvInterpolationOptions {
|
|
|
22
22
|
* @returns A new object with interpolated values
|
|
23
23
|
*/
|
|
24
24
|
export declare function interpolateEnvVars(json: Record<string, unknown>, options?: EnvInterpolationOptions): Record<string, unknown>;
|
|
25
|
+
/**
|
|
26
|
+
* Interpolate environment variables in a string.
|
|
27
|
+
*/
|
|
28
|
+
export declare function interpolateEnvVarsInString(value: string, options?: EnvInterpolationOptions): string;
|
|
29
|
+
/**
|
|
30
|
+
* Interpolate environment variables in an array of strings.
|
|
31
|
+
*/
|
|
32
|
+
export declare function interpolateEnvVarsInLines(lines: string[], options?: EnvInterpolationOptions): string[];
|
|
33
|
+
/**
|
|
34
|
+
* Interpolate environment variables in content of any supported type.
|
|
35
|
+
* Handles objects, strings, and string arrays.
|
|
36
|
+
*/
|
|
37
|
+
export declare function interpolateContent(content: Record<string, unknown> | string | string[], options?: EnvInterpolationOptions): Record<string, unknown> | string | string[];
|
package/dist/env.js
CHANGED
|
@@ -86,3 +86,31 @@ function processValue(value, options) {
|
|
|
86
86
|
export function interpolateEnvVars(json, options = DEFAULT_OPTIONS) {
|
|
87
87
|
return processValue(json, options);
|
|
88
88
|
}
|
|
89
|
+
// =============================================================================
|
|
90
|
+
// Text Content Interpolation
|
|
91
|
+
// =============================================================================
|
|
92
|
+
/**
|
|
93
|
+
* Interpolate environment variables in a string.
|
|
94
|
+
*/
|
|
95
|
+
export function interpolateEnvVarsInString(value, options = DEFAULT_OPTIONS) {
|
|
96
|
+
return processString(value, options);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Interpolate environment variables in an array of strings.
|
|
100
|
+
*/
|
|
101
|
+
export function interpolateEnvVarsInLines(lines, options = DEFAULT_OPTIONS) {
|
|
102
|
+
return lines.map((line) => processString(line, options));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Interpolate environment variables in content of any supported type.
|
|
106
|
+
* Handles objects, strings, and string arrays.
|
|
107
|
+
*/
|
|
108
|
+
export function interpolateContent(content, options = DEFAULT_OPTIONS) {
|
|
109
|
+
if (typeof content === "string") {
|
|
110
|
+
return interpolateEnvVarsInString(content, options);
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(content)) {
|
|
113
|
+
return interpolateEnvVarsInLines(content, options);
|
|
114
|
+
}
|
|
115
|
+
return interpolateEnvVars(content, options);
|
|
116
|
+
}
|
package/dist/merge.d.ts
CHANGED
|
@@ -34,3 +34,14 @@ export declare function stripMergeDirectives(obj: Record<string, unknown>): Reco
|
|
|
34
34
|
* Create a default merge context.
|
|
35
35
|
*/
|
|
36
36
|
export declare function createMergeContext(defaultStrategy?: ArrayMergeStrategy): MergeContext;
|
|
37
|
+
/**
|
|
38
|
+
* Check if content is text type (string or string[]).
|
|
39
|
+
*/
|
|
40
|
+
export declare function isTextContent(content: unknown): content is string | string[];
|
|
41
|
+
/**
|
|
42
|
+
* Merge two text content values.
|
|
43
|
+
* For strings: overlay replaces base entirely.
|
|
44
|
+
* For string arrays: applies merge strategy.
|
|
45
|
+
* For mixed types: overlay replaces base.
|
|
46
|
+
*/
|
|
47
|
+
export declare function mergeTextContent(base: string | string[], overlay: string | string[], strategy?: ArrayMergeStrategy): string | string[];
|
package/dist/merge.js
CHANGED
|
@@ -152,3 +152,45 @@ export function createMergeContext(defaultStrategy = "replace") {
|
|
|
152
152
|
defaultArrayStrategy: defaultStrategy,
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
|
+
// =============================================================================
|
|
156
|
+
// Text Content Utilities
|
|
157
|
+
// =============================================================================
|
|
158
|
+
/**
|
|
159
|
+
* Check if content is text type (string or string[]).
|
|
160
|
+
*/
|
|
161
|
+
export function isTextContent(content) {
|
|
162
|
+
return (typeof content === "string" ||
|
|
163
|
+
(Array.isArray(content) &&
|
|
164
|
+
content.every((item) => typeof item === "string")));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Merge two text content values.
|
|
168
|
+
* For strings: overlay replaces base entirely.
|
|
169
|
+
* For string arrays: applies merge strategy.
|
|
170
|
+
* For mixed types: overlay replaces base.
|
|
171
|
+
*/
|
|
172
|
+
export function mergeTextContent(base, overlay, strategy = "replace") {
|
|
173
|
+
// If overlay is a string, it always replaces
|
|
174
|
+
if (typeof overlay === "string") {
|
|
175
|
+
return overlay;
|
|
176
|
+
}
|
|
177
|
+
// If overlay is an array
|
|
178
|
+
if (Array.isArray(overlay)) {
|
|
179
|
+
// If base is also an array, apply merge strategy
|
|
180
|
+
if (Array.isArray(base)) {
|
|
181
|
+
switch (strategy) {
|
|
182
|
+
case "append":
|
|
183
|
+
return [...base, ...overlay];
|
|
184
|
+
case "prepend":
|
|
185
|
+
return [...overlay, ...base];
|
|
186
|
+
case "replace":
|
|
187
|
+
default:
|
|
188
|
+
return overlay;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Base is string, overlay is array - overlay replaces
|
|
192
|
+
return overlay;
|
|
193
|
+
}
|
|
194
|
+
// Fallback (shouldn't reach here with proper types)
|
|
195
|
+
return overlay;
|
|
196
|
+
}
|
|
@@ -47,7 +47,10 @@ export class RepositoryProcessor {
|
|
|
47
47
|
continue;
|
|
48
48
|
}
|
|
49
49
|
this.log.info(`Writing ${file.fileName}...`);
|
|
50
|
-
const fileContent = convertContentToString(file.content, file.fileName
|
|
50
|
+
const fileContent = convertContentToString(file.content, file.fileName, {
|
|
51
|
+
header: file.header,
|
|
52
|
+
schemaUrl: file.schemaUrl,
|
|
53
|
+
});
|
|
51
54
|
// Determine action type (create vs update)
|
|
52
55
|
const action = fileExists ? "update" : "create";
|
|
53
56
|
if (dryRun) {
|
package/package.json
CHANGED