@aspruyt/xfg 1.0.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/LICENSE +21 -0
- package/PR.md +15 -0
- package/README.md +991 -0
- package/dist/command-executor.d.ts +25 -0
- package/dist/command-executor.js +32 -0
- package/dist/config-formatter.d.ts +17 -0
- package/dist/config-formatter.js +100 -0
- package/dist/config-normalizer.d.ts +6 -0
- package/dist/config-normalizer.js +136 -0
- package/dist/config-validator.d.ts +6 -0
- package/dist/config-validator.js +173 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.js +27 -0
- package/dist/env.d.ts +39 -0
- package/dist/env.js +144 -0
- package/dist/file-reference-resolver.d.ts +20 -0
- package/dist/file-reference-resolver.js +135 -0
- package/dist/git-ops.d.ts +75 -0
- package/dist/git-ops.js +229 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +167 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.js +46 -0
- package/dist/merge.d.ts +47 -0
- package/dist/merge.js +196 -0
- package/dist/pr-creator.d.ts +40 -0
- package/dist/pr-creator.js +129 -0
- package/dist/repo-detector.d.ts +22 -0
- package/dist/repo-detector.js +98 -0
- package/dist/repository-processor.d.ts +47 -0
- package/dist/repository-processor.js +245 -0
- package/dist/retry-utils.d.ts +53 -0
- package/dist/retry-utils.js +143 -0
- package/dist/shell-utils.d.ts +8 -0
- package/dist/shell-utils.js +12 -0
- package/dist/strategies/azure-pr-strategy.d.ts +16 -0
- package/dist/strategies/azure-pr-strategy.js +221 -0
- package/dist/strategies/github-pr-strategy.d.ts +17 -0
- package/dist/strategies/github-pr-strategy.js +215 -0
- package/dist/strategies/index.d.ts +13 -0
- package/dist/strategies/index.js +22 -0
- package/dist/strategies/pr-strategy.d.ts +112 -0
- package/dist/strategies/pr-strategy.js +60 -0
- package/dist/workspace-utils.d.ts +5 -0
- package/dist/workspace-utils.js +10 -0
- package/package.json +58 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interface for executing shell commands.
|
|
3
|
+
* Enables dependency injection for testing and alternative implementations.
|
|
4
|
+
*/
|
|
5
|
+
export interface CommandExecutor {
|
|
6
|
+
/**
|
|
7
|
+
* Execute a shell command and return the output.
|
|
8
|
+
* @param command The command to execute
|
|
9
|
+
* @param cwd The working directory for the command
|
|
10
|
+
* @returns Promise resolving to the trimmed stdout output
|
|
11
|
+
* @throws Error if the command fails
|
|
12
|
+
*/
|
|
13
|
+
exec(command: string, cwd: string): Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Default implementation that uses Node.js child_process.execSync.
|
|
17
|
+
* Note: Commands are escaped using escapeShellArg before being passed here.
|
|
18
|
+
*/
|
|
19
|
+
export declare class ShellCommandExecutor implements CommandExecutor {
|
|
20
|
+
exec(command: string, cwd: string): Promise<string>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Default executor instance for production use.
|
|
24
|
+
*/
|
|
25
|
+
export declare const defaultExecutor: CommandExecutor;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Default implementation that uses Node.js child_process.execSync.
|
|
4
|
+
* Note: Commands are escaped using escapeShellArg before being passed here.
|
|
5
|
+
*/
|
|
6
|
+
export class ShellCommandExecutor {
|
|
7
|
+
async exec(command, cwd) {
|
|
8
|
+
try {
|
|
9
|
+
return execSync(command, {
|
|
10
|
+
cwd,
|
|
11
|
+
encoding: "utf-8",
|
|
12
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
13
|
+
}).trim();
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
// Ensure stderr is always a string for consistent error handling
|
|
17
|
+
const execError = error;
|
|
18
|
+
if (execError.stderr && typeof execError.stderr !== "string") {
|
|
19
|
+
execError.stderr = execError.stderr.toString();
|
|
20
|
+
}
|
|
21
|
+
// Include stderr in error message for better debugging
|
|
22
|
+
if (execError.stderr && execError.message) {
|
|
23
|
+
execError.message = `${execError.message}\n${execError.stderr}`;
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Default executor instance for production use.
|
|
31
|
+
*/
|
|
32
|
+
export const defaultExecutor = new ShellCommandExecutor();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type OutputFormat = "json" | "json5" | "yaml";
|
|
2
|
+
/**
|
|
3
|
+
* Options for content conversion.
|
|
4
|
+
*/
|
|
5
|
+
export interface ConvertOptions {
|
|
6
|
+
header?: string[];
|
|
7
|
+
schemaUrl?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Detects output format from file extension.
|
|
11
|
+
*/
|
|
12
|
+
export declare function detectOutputFormat(fileName: string): OutputFormat;
|
|
13
|
+
/**
|
|
14
|
+
* Converts content to string in the appropriate format.
|
|
15
|
+
* Handles null content (empty files), text content (string/string[]), and object content (JSON/YAML).
|
|
16
|
+
*/
|
|
17
|
+
export declare function convertContentToString(content: Record<string, unknown> | string | string[] | null, fileName: string, options?: ConvertOptions): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Document, stringify } from "yaml";
|
|
2
|
+
/**
|
|
3
|
+
* Detects output format from file extension.
|
|
4
|
+
*/
|
|
5
|
+
export function detectOutputFormat(fileName) {
|
|
6
|
+
const ext = fileName.toLowerCase().split(".").pop();
|
|
7
|
+
if (ext === "yaml" || ext === "yml") {
|
|
8
|
+
return "yaml";
|
|
9
|
+
}
|
|
10
|
+
if (ext === "json5") {
|
|
11
|
+
return "json5";
|
|
12
|
+
}
|
|
13
|
+
return "json";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Builds header comment string from header lines and schemaUrl.
|
|
17
|
+
* Returns undefined if no comments to add.
|
|
18
|
+
* Each line gets a space prefix since yaml library adds # directly.
|
|
19
|
+
*/
|
|
20
|
+
function buildHeaderComment(header, schemaUrl) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
// Add yaml-language-server schema directive first (if present)
|
|
23
|
+
if (schemaUrl) {
|
|
24
|
+
lines.push(` yaml-language-server: $schema=${schemaUrl}`);
|
|
25
|
+
}
|
|
26
|
+
// Add custom header lines (with space prefix for proper formatting)
|
|
27
|
+
if (header && header.length > 0) {
|
|
28
|
+
lines.push(...header.map((h) => ` ${h}`));
|
|
29
|
+
}
|
|
30
|
+
if (lines.length === 0)
|
|
31
|
+
return undefined;
|
|
32
|
+
// Join with newlines - the yaml library adds # prefix to each line
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Builds comment-only output for empty YAML files with headers.
|
|
37
|
+
*/
|
|
38
|
+
function buildCommentOnlyYaml(header, schemaUrl) {
|
|
39
|
+
const lines = [];
|
|
40
|
+
// Add yaml-language-server schema directive first (if present)
|
|
41
|
+
if (schemaUrl) {
|
|
42
|
+
lines.push(`# yaml-language-server: $schema=${schemaUrl}`);
|
|
43
|
+
}
|
|
44
|
+
// Add custom header lines
|
|
45
|
+
if (header && header.length > 0) {
|
|
46
|
+
lines.push(...header.map((h) => `# ${h}`));
|
|
47
|
+
}
|
|
48
|
+
if (lines.length === 0)
|
|
49
|
+
return undefined;
|
|
50
|
+
return lines.join("\n") + "\n";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Converts content to string in the appropriate format.
|
|
54
|
+
* Handles null content (empty files), text content (string/string[]), and object content (JSON/YAML).
|
|
55
|
+
*/
|
|
56
|
+
export function convertContentToString(content, fileName, options) {
|
|
57
|
+
// Handle empty file case
|
|
58
|
+
if (content === null) {
|
|
59
|
+
const format = detectOutputFormat(fileName);
|
|
60
|
+
if (format === "yaml" && options) {
|
|
61
|
+
const commentOnly = buildCommentOnlyYaml(options.header, options.schemaUrl);
|
|
62
|
+
if (commentOnly) {
|
|
63
|
+
return commentOnly;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
// Handle string content (text file)
|
|
69
|
+
if (typeof content === "string") {
|
|
70
|
+
// Ensure trailing newline for text files
|
|
71
|
+
return content.endsWith("\n") ? content : content + "\n";
|
|
72
|
+
}
|
|
73
|
+
// Handle string[] content (text file with lines)
|
|
74
|
+
if (Array.isArray(content)) {
|
|
75
|
+
// Join lines with newlines and ensure trailing newline
|
|
76
|
+
const text = content.join("\n");
|
|
77
|
+
return text.length > 0 ? text + "\n" : "";
|
|
78
|
+
}
|
|
79
|
+
// Handle object content (JSON/YAML)
|
|
80
|
+
const format = detectOutputFormat(fileName);
|
|
81
|
+
if (format === "yaml") {
|
|
82
|
+
// Use Document API for YAML to support comments
|
|
83
|
+
const doc = new Document(content);
|
|
84
|
+
// Add header comment if present
|
|
85
|
+
if (options) {
|
|
86
|
+
const headerComment = buildHeaderComment(options.header, options.schemaUrl);
|
|
87
|
+
if (headerComment) {
|
|
88
|
+
doc.commentBefore = headerComment;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return stringify(doc, { indent: 2 });
|
|
92
|
+
}
|
|
93
|
+
if (format === "json5") {
|
|
94
|
+
// JSON5 format - output standard JSON (which is valid JSON5)
|
|
95
|
+
// Using JSON.stringify for standard JSON output that's compatible everywhere
|
|
96
|
+
return JSON.stringify(content, null, 2) + "\n";
|
|
97
|
+
}
|
|
98
|
+
// JSON format - comments not supported, ignore header/schemaUrl
|
|
99
|
+
return JSON.stringify(content, null, 2) + "\n";
|
|
100
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Merges PR options: per-repo overrides global defaults.
|
|
15
|
+
* Returns undefined if no options are set.
|
|
16
|
+
*/
|
|
17
|
+
function mergePROptions(global, perRepo) {
|
|
18
|
+
if (!global && !perRepo)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (!global)
|
|
21
|
+
return perRepo;
|
|
22
|
+
if (!perRepo)
|
|
23
|
+
return global;
|
|
24
|
+
const result = {};
|
|
25
|
+
const merge = perRepo.merge ?? global.merge;
|
|
26
|
+
const mergeStrategy = perRepo.mergeStrategy ?? global.mergeStrategy;
|
|
27
|
+
const deleteBranch = perRepo.deleteBranch ?? global.deleteBranch;
|
|
28
|
+
const bypassReason = perRepo.bypassReason ?? global.bypassReason;
|
|
29
|
+
if (merge !== undefined)
|
|
30
|
+
result.merge = merge;
|
|
31
|
+
if (mergeStrategy !== undefined)
|
|
32
|
+
result.mergeStrategy = mergeStrategy;
|
|
33
|
+
if (deleteBranch !== undefined)
|
|
34
|
+
result.deleteBranch = deleteBranch;
|
|
35
|
+
if (bypassReason !== undefined)
|
|
36
|
+
result.bypassReason = bypassReason;
|
|
37
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Normalizes raw config into expanded, merged config.
|
|
41
|
+
* Pipeline: expand git arrays -> merge content -> interpolate env vars
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeConfig(raw) {
|
|
44
|
+
const expandedRepos = [];
|
|
45
|
+
const fileNames = Object.keys(raw.files);
|
|
46
|
+
for (const rawRepo of raw.repos) {
|
|
47
|
+
// Step 1: Expand git arrays
|
|
48
|
+
const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
|
|
49
|
+
for (const gitUrl of gitUrls) {
|
|
50
|
+
const files = [];
|
|
51
|
+
// Step 2: Process each file definition
|
|
52
|
+
for (const fileName of fileNames) {
|
|
53
|
+
const repoOverride = rawRepo.files?.[fileName];
|
|
54
|
+
// Skip excluded files (set to false)
|
|
55
|
+
if (repoOverride === false) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const fileConfig = raw.files[fileName];
|
|
59
|
+
const fileStrategy = fileConfig.mergeStrategy ?? "replace";
|
|
60
|
+
// Step 3: Compute merged content for this file
|
|
61
|
+
let mergedContent;
|
|
62
|
+
if (repoOverride?.override) {
|
|
63
|
+
// Override mode: use only repo file content (may be undefined for empty file)
|
|
64
|
+
if (repoOverride.content === undefined) {
|
|
65
|
+
mergedContent = null;
|
|
66
|
+
}
|
|
67
|
+
else if (isTextContent(repoOverride.content)) {
|
|
68
|
+
// Text content: use as-is (no merge directives to strip)
|
|
69
|
+
mergedContent = structuredClone(repoOverride.content);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (fileConfig.content === undefined) {
|
|
76
|
+
// Root file has no content = empty file (unless repo provides content)
|
|
77
|
+
if (repoOverride?.content) {
|
|
78
|
+
if (isTextContent(repoOverride.content)) {
|
|
79
|
+
mergedContent = structuredClone(repoOverride.content);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
mergedContent = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (!repoOverride?.content) {
|
|
90
|
+
// No repo override: use file base content as-is
|
|
91
|
+
mergedContent = structuredClone(fileConfig.content);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// Merge mode: handle text vs object content
|
|
95
|
+
if (isTextContent(fileConfig.content)) {
|
|
96
|
+
// Text content merging
|
|
97
|
+
mergedContent = mergeTextContent(fileConfig.content, repoOverride.content, fileStrategy);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Object content: deep merge file base + repo overlay
|
|
101
|
+
const ctx = createMergeContext(fileStrategy);
|
|
102
|
+
mergedContent = deepMerge(structuredClone(fileConfig.content), repoOverride.content, ctx);
|
|
103
|
+
mergedContent = stripMergeDirectives(mergedContent);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Step 4: Interpolate env vars (only if content exists)
|
|
107
|
+
if (mergedContent !== null) {
|
|
108
|
+
mergedContent = interpolateContent(mergedContent, { strict: true });
|
|
109
|
+
}
|
|
110
|
+
// Resolve fields: per-repo overrides root level
|
|
111
|
+
const createOnly = repoOverride?.createOnly ?? fileConfig.createOnly;
|
|
112
|
+
const executable = repoOverride?.executable ?? fileConfig.executable;
|
|
113
|
+
const header = normalizeHeader(repoOverride?.header ?? fileConfig.header);
|
|
114
|
+
const schemaUrl = repoOverride?.schemaUrl ?? fileConfig.schemaUrl;
|
|
115
|
+
files.push({
|
|
116
|
+
fileName,
|
|
117
|
+
content: mergedContent,
|
|
118
|
+
createOnly,
|
|
119
|
+
executable,
|
|
120
|
+
header,
|
|
121
|
+
schemaUrl,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Merge PR options: per-repo overrides global
|
|
125
|
+
const prOptions = mergePROptions(raw.prOptions, rawRepo.prOptions);
|
|
126
|
+
expandedRepos.push({
|
|
127
|
+
git: gitUrl,
|
|
128
|
+
files,
|
|
129
|
+
prOptions,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
repos: expandedRepos,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
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 === "json5" || ext === "yaml" || ext === "yml";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Validates raw config structure before normalization.
|
|
26
|
+
* @throws Error if validation fails
|
|
27
|
+
*/
|
|
28
|
+
export function validateRawConfig(config) {
|
|
29
|
+
if (!config.files || typeof config.files !== "object") {
|
|
30
|
+
throw new Error("Config missing required field: files (must be an object)");
|
|
31
|
+
}
|
|
32
|
+
const fileNames = Object.keys(config.files);
|
|
33
|
+
if (fileNames.length === 0) {
|
|
34
|
+
throw new Error("Config files object cannot be empty");
|
|
35
|
+
}
|
|
36
|
+
// Validate each file definition
|
|
37
|
+
for (const fileName of fileNames) {
|
|
38
|
+
validateFileName(fileName);
|
|
39
|
+
const fileConfig = config.files[fileName];
|
|
40
|
+
if (!fileConfig || typeof fileConfig !== "object") {
|
|
41
|
+
throw new Error(`File '${fileName}' must have a configuration object`);
|
|
42
|
+
}
|
|
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
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (fileConfig.mergeStrategy !== undefined &&
|
|
60
|
+
!VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
|
|
61
|
+
throw new Error(`File '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
|
|
62
|
+
}
|
|
63
|
+
if (fileConfig.createOnly !== undefined &&
|
|
64
|
+
typeof fileConfig.createOnly !== "boolean") {
|
|
65
|
+
throw new Error(`File '${fileName}' createOnly must be a boolean`);
|
|
66
|
+
}
|
|
67
|
+
if (fileConfig.executable !== undefined &&
|
|
68
|
+
typeof fileConfig.executable !== "boolean") {
|
|
69
|
+
throw new Error(`File '${fileName}' executable must be a boolean`);
|
|
70
|
+
}
|
|
71
|
+
if (fileConfig.header !== undefined) {
|
|
72
|
+
if (typeof fileConfig.header !== "string" &&
|
|
73
|
+
(!Array.isArray(fileConfig.header) ||
|
|
74
|
+
!fileConfig.header.every((h) => typeof h === "string"))) {
|
|
75
|
+
throw new Error(`File '${fileName}' header must be a string or array of strings`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (fileConfig.schemaUrl !== undefined &&
|
|
79
|
+
typeof fileConfig.schemaUrl !== "string") {
|
|
80
|
+
throw new Error(`File '${fileName}' schemaUrl must be a string`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!config.repos || !Array.isArray(config.repos)) {
|
|
84
|
+
throw new Error("Config missing required field: repos (must be an array)");
|
|
85
|
+
}
|
|
86
|
+
// Validate each repo
|
|
87
|
+
for (let i = 0; i < config.repos.length; i++) {
|
|
88
|
+
const repo = config.repos[i];
|
|
89
|
+
if (!repo.git) {
|
|
90
|
+
throw new Error(`Repo at index ${i} missing required field: git`);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(repo.git) && repo.git.length === 0) {
|
|
93
|
+
throw new Error(`Repo at index ${i} has empty git array`);
|
|
94
|
+
}
|
|
95
|
+
// Validate per-repo file overrides
|
|
96
|
+
if (repo.files) {
|
|
97
|
+
if (typeof repo.files !== "object" || Array.isArray(repo.files)) {
|
|
98
|
+
throw new Error(`Repo at index ${i}: files must be an object`);
|
|
99
|
+
}
|
|
100
|
+
for (const fileName of Object.keys(repo.files)) {
|
|
101
|
+
// Ensure the file is defined at root level
|
|
102
|
+
if (!config.files[fileName]) {
|
|
103
|
+
throw new Error(`Repo at index ${i} references undefined file '${fileName}'. File must be defined in root 'files' object.`);
|
|
104
|
+
}
|
|
105
|
+
const fileOverride = repo.files[fileName];
|
|
106
|
+
// false means exclude this file for this repo - no further validation needed
|
|
107
|
+
if (fileOverride === false) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (fileOverride.override && !fileOverride.content) {
|
|
111
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined`);
|
|
112
|
+
}
|
|
113
|
+
// Validate content type
|
|
114
|
+
if (fileOverride.content !== undefined) {
|
|
115
|
+
const hasText = isTextContent(fileOverride.content);
|
|
116
|
+
const hasObject = isObjectContent(fileOverride.content);
|
|
117
|
+
if (!hasText && !hasObject) {
|
|
118
|
+
throw new Error(`Repo at index ${i}: file '${fileName}' content must be an object, string, or array of strings`);
|
|
119
|
+
}
|
|
120
|
+
// Validate content type matches file extension
|
|
121
|
+
const isStructured = isStructuredFileExtension(fileName);
|
|
122
|
+
if (isStructured && hasText) {
|
|
123
|
+
throw new Error(`Repo at index ${i}: file '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
|
|
124
|
+
}
|
|
125
|
+
if (!isStructured && hasObject) {
|
|
126
|
+
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.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (fileOverride.createOnly !== undefined &&
|
|
130
|
+
typeof fileOverride.createOnly !== "boolean") {
|
|
131
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' createOnly must be a boolean`);
|
|
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
|
+
}
|
|
137
|
+
if (fileOverride.header !== undefined) {
|
|
138
|
+
if (typeof fileOverride.header !== "string" &&
|
|
139
|
+
(!Array.isArray(fileOverride.header) ||
|
|
140
|
+
!fileOverride.header.every((h) => typeof h === "string"))) {
|
|
141
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' header must be a string or array of strings`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (fileOverride.schemaUrl !== undefined &&
|
|
145
|
+
typeof fileOverride.schemaUrl !== "string") {
|
|
146
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' schemaUrl must be a string`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Validates a file name for security issues
|
|
154
|
+
*/
|
|
155
|
+
function validateFileName(fileName) {
|
|
156
|
+
if (!fileName || typeof fileName !== "string") {
|
|
157
|
+
throw new Error("File name must be a non-empty string");
|
|
158
|
+
}
|
|
159
|
+
// Validate fileName doesn't allow path traversal
|
|
160
|
+
if (fileName.includes("..") || isAbsolute(fileName)) {
|
|
161
|
+
throw new Error(`Invalid fileName '${fileName}': must be a relative path without '..' components`);
|
|
162
|
+
}
|
|
163
|
+
// Validate fileName doesn't contain control characters that could bypass shell escaping
|
|
164
|
+
if (/[\n\r\0]/.test(fileName)) {
|
|
165
|
+
throw new Error(`Invalid fileName '${fileName}': cannot contain newlines or null bytes`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function getGitDisplayName(git) {
|
|
169
|
+
if (Array.isArray(git)) {
|
|
170
|
+
return git[0] || "unknown";
|
|
171
|
+
}
|
|
172
|
+
return git;
|
|
173
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ArrayMergeStrategy } from "./merge.js";
|
|
2
|
+
export { convertContentToString } from "./config-formatter.js";
|
|
3
|
+
export type MergeMode = "manual" | "auto" | "force";
|
|
4
|
+
export type MergeStrategy = "merge" | "squash" | "rebase";
|
|
5
|
+
export interface PRMergeOptions {
|
|
6
|
+
merge?: MergeMode;
|
|
7
|
+
mergeStrategy?: MergeStrategy;
|
|
8
|
+
deleteBranch?: boolean;
|
|
9
|
+
bypassReason?: string;
|
|
10
|
+
}
|
|
11
|
+
export type ContentValue = Record<string, unknown> | string | string[];
|
|
12
|
+
export interface RawFileConfig {
|
|
13
|
+
content?: ContentValue;
|
|
14
|
+
mergeStrategy?: ArrayMergeStrategy;
|
|
15
|
+
createOnly?: boolean;
|
|
16
|
+
executable?: boolean;
|
|
17
|
+
header?: string | string[];
|
|
18
|
+
schemaUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface RawRepoFileOverride {
|
|
21
|
+
content?: ContentValue;
|
|
22
|
+
override?: boolean;
|
|
23
|
+
createOnly?: boolean;
|
|
24
|
+
executable?: boolean;
|
|
25
|
+
header?: string | string[];
|
|
26
|
+
schemaUrl?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface RawRepoConfig {
|
|
29
|
+
git: string | string[];
|
|
30
|
+
files?: Record<string, RawRepoFileOverride | false>;
|
|
31
|
+
prOptions?: PRMergeOptions;
|
|
32
|
+
}
|
|
33
|
+
export interface RawConfig {
|
|
34
|
+
files: Record<string, RawFileConfig>;
|
|
35
|
+
repos: RawRepoConfig[];
|
|
36
|
+
prOptions?: PRMergeOptions;
|
|
37
|
+
}
|
|
38
|
+
export interface FileContent {
|
|
39
|
+
fileName: string;
|
|
40
|
+
content: ContentValue | null;
|
|
41
|
+
createOnly?: boolean;
|
|
42
|
+
executable?: boolean;
|
|
43
|
+
header?: string[];
|
|
44
|
+
schemaUrl?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface RepoConfig {
|
|
47
|
+
git: string;
|
|
48
|
+
files: FileContent[];
|
|
49
|
+
prOptions?: PRMergeOptions;
|
|
50
|
+
}
|
|
51
|
+
export interface Config {
|
|
52
|
+
repos: RepoConfig[];
|
|
53
|
+
}
|
|
54
|
+
export declare function loadConfig(filePath: string): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { validateRawConfig } from "./config-validator.js";
|
|
5
|
+
import { normalizeConfig } from "./config-normalizer.js";
|
|
6
|
+
import { resolveFileReferencesInConfig } from "./file-reference-resolver.js";
|
|
7
|
+
// Re-export formatter functions for backwards compatibility
|
|
8
|
+
export { convertContentToString } from "./config-formatter.js";
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// Public API
|
|
11
|
+
// =============================================================================
|
|
12
|
+
export function loadConfig(filePath) {
|
|
13
|
+
const content = readFileSync(filePath, "utf-8");
|
|
14
|
+
const configDir = dirname(filePath);
|
|
15
|
+
let rawConfig;
|
|
16
|
+
try {
|
|
17
|
+
rawConfig = parse(content);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
21
|
+
throw new Error(`Failed to parse YAML config at ${filePath}: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
// Resolve file references before validation so content type checking works
|
|
24
|
+
rawConfig = resolveFileReferencesInConfig(rawConfig, { configDir });
|
|
25
|
+
validateRawConfig(rawConfig);
|
|
26
|
+
return normalizeConfig(rawConfig);
|
|
27
|
+
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variable interpolation utilities.
|
|
3
|
+
* Supports ${VAR}, ${VAR:-default}, and ${VAR:?message} syntax.
|
|
4
|
+
* Use $${VAR} to escape and output literal ${VAR}.
|
|
5
|
+
*/
|
|
6
|
+
export interface EnvInterpolationOptions {
|
|
7
|
+
/**
|
|
8
|
+
* If true (default), throws an error when a variable is missing
|
|
9
|
+
* and has no default value. If false, leaves the placeholder as-is.
|
|
10
|
+
*/
|
|
11
|
+
strict: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Interpolate environment variables in a JSON object.
|
|
15
|
+
*
|
|
16
|
+
* Supports these syntaxes:
|
|
17
|
+
* - ${VAR} - Replace with env value, error if missing (in strict mode)
|
|
18
|
+
* - ${VAR:-default} - Replace with env value, or use default if missing
|
|
19
|
+
* - ${VAR:?message} - Replace with env value, or throw error with message if missing
|
|
20
|
+
* - $${VAR} - Escape: outputs literal ${VAR} without interpolation
|
|
21
|
+
*
|
|
22
|
+
* @param json - The JSON object to process
|
|
23
|
+
* @param options - Interpolation options (default: strict mode)
|
|
24
|
+
* @returns A new object with interpolated values
|
|
25
|
+
*/
|
|
26
|
+
export declare function interpolateEnvVars(json: Record<string, unknown>, options?: EnvInterpolationOptions): Record<string, unknown>;
|
|
27
|
+
/**
|
|
28
|
+
* Interpolate environment variables in a string.
|
|
29
|
+
*/
|
|
30
|
+
export declare function interpolateEnvVarsInString(value: string, options?: EnvInterpolationOptions): string;
|
|
31
|
+
/**
|
|
32
|
+
* Interpolate environment variables in an array of strings.
|
|
33
|
+
*/
|
|
34
|
+
export declare function interpolateEnvVarsInLines(lines: string[], options?: EnvInterpolationOptions): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Interpolate environment variables in content of any supported type.
|
|
37
|
+
* Handles objects, strings, and string arrays.
|
|
38
|
+
*/
|
|
39
|
+
export declare function interpolateContent(content: Record<string, unknown> | string | string[], options?: EnvInterpolationOptions): Record<string, unknown> | string | string[];
|