@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/PR.md +15 -0
  3. package/README.md +991 -0
  4. package/dist/command-executor.d.ts +25 -0
  5. package/dist/command-executor.js +32 -0
  6. package/dist/config-formatter.d.ts +17 -0
  7. package/dist/config-formatter.js +100 -0
  8. package/dist/config-normalizer.d.ts +6 -0
  9. package/dist/config-normalizer.js +136 -0
  10. package/dist/config-validator.d.ts +6 -0
  11. package/dist/config-validator.js +173 -0
  12. package/dist/config.d.ts +54 -0
  13. package/dist/config.js +27 -0
  14. package/dist/env.d.ts +39 -0
  15. package/dist/env.js +144 -0
  16. package/dist/file-reference-resolver.d.ts +20 -0
  17. package/dist/file-reference-resolver.js +135 -0
  18. package/dist/git-ops.d.ts +75 -0
  19. package/dist/git-ops.js +229 -0
  20. package/dist/index.d.ts +20 -0
  21. package/dist/index.js +167 -0
  22. package/dist/logger.d.ts +21 -0
  23. package/dist/logger.js +46 -0
  24. package/dist/merge.d.ts +47 -0
  25. package/dist/merge.js +196 -0
  26. package/dist/pr-creator.d.ts +40 -0
  27. package/dist/pr-creator.js +129 -0
  28. package/dist/repo-detector.d.ts +22 -0
  29. package/dist/repo-detector.js +98 -0
  30. package/dist/repository-processor.d.ts +47 -0
  31. package/dist/repository-processor.js +245 -0
  32. package/dist/retry-utils.d.ts +53 -0
  33. package/dist/retry-utils.js +143 -0
  34. package/dist/shell-utils.d.ts +8 -0
  35. package/dist/shell-utils.js +12 -0
  36. package/dist/strategies/azure-pr-strategy.d.ts +16 -0
  37. package/dist/strategies/azure-pr-strategy.js +221 -0
  38. package/dist/strategies/github-pr-strategy.d.ts +17 -0
  39. package/dist/strategies/github-pr-strategy.js +215 -0
  40. package/dist/strategies/index.d.ts +13 -0
  41. package/dist/strategies/index.js +22 -0
  42. package/dist/strategies/pr-strategy.d.ts +112 -0
  43. package/dist/strategies/pr-strategy.js +60 -0
  44. package/dist/workspace-utils.d.ts +5 -0
  45. package/dist/workspace-utils.js +10 -0
  46. 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,6 @@
1
+ import type { RawConfig, Config } from "./config.js";
2
+ /**
3
+ * Normalizes raw config into expanded, merged config.
4
+ * Pipeline: expand git arrays -> merge content -> interpolate env vars
5
+ */
6
+ export declare function normalizeConfig(raw: RawConfig): Config;
@@ -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,6 @@
1
+ import type { RawConfig } from "./config.js";
2
+ /**
3
+ * Validates raw config structure before normalization.
4
+ * @throws Error if validation fails
5
+ */
6
+ export declare function validateRawConfig(config: RawConfig): void;
@@ -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
+ }
@@ -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[];