@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
package/dist/env.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
const DEFAULT_OPTIONS = {
|
|
7
|
+
strict: true,
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Regex to match environment variable placeholders.
|
|
11
|
+
* Captures:
|
|
12
|
+
* - Group 1: Variable name
|
|
13
|
+
* - Group 2: Modifier (- for default, ? for required with message)
|
|
14
|
+
* - Group 3: Default value or error message
|
|
15
|
+
*
|
|
16
|
+
* Examples:
|
|
17
|
+
* - ${VAR} -> varName=VAR, modifier=undefined, value=undefined
|
|
18
|
+
* - ${VAR:-default} -> varName=VAR, modifier=-, value=default
|
|
19
|
+
* - ${VAR:?message} -> varName=VAR, modifier=?, value=message
|
|
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";
|
|
33
|
+
/**
|
|
34
|
+
* Check if a value is a plain object (not null, not array).
|
|
35
|
+
*/
|
|
36
|
+
function isPlainObject(val) {
|
|
37
|
+
return typeof val === "object" && val !== null && !Array.isArray(val);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Process a single string value, replacing environment variable placeholders.
|
|
41
|
+
* Supports escaping with $${VAR} syntax to output literal ${VAR}.
|
|
42
|
+
*/
|
|
43
|
+
function processString(value, options) {
|
|
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) => {
|
|
53
|
+
const envValue = process.env[varName];
|
|
54
|
+
// Variable exists - use its value
|
|
55
|
+
if (envValue !== undefined) {
|
|
56
|
+
return envValue;
|
|
57
|
+
}
|
|
58
|
+
// Has default value (:-default)
|
|
59
|
+
if (modifier === "-") {
|
|
60
|
+
return defaultOrMsg ?? "";
|
|
61
|
+
}
|
|
62
|
+
// Required with message (:?message)
|
|
63
|
+
if (modifier === "?") {
|
|
64
|
+
const message = defaultOrMsg || `is required`;
|
|
65
|
+
throw new Error(`${varName}: ${message}`);
|
|
66
|
+
}
|
|
67
|
+
// No modifier - check strictness
|
|
68
|
+
if (options.strict) {
|
|
69
|
+
throw new Error(`Missing required environment variable: ${varName}`);
|
|
70
|
+
}
|
|
71
|
+
// Non-strict mode - leave placeholder as-is
|
|
72
|
+
return match;
|
|
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;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Recursively process a value, interpolating environment variables in strings.
|
|
83
|
+
*/
|
|
84
|
+
function processValue(value, options) {
|
|
85
|
+
if (typeof value === "string") {
|
|
86
|
+
return processString(value, options);
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(value)) {
|
|
89
|
+
return value.map((item) => processValue(item, options));
|
|
90
|
+
}
|
|
91
|
+
if (isPlainObject(value)) {
|
|
92
|
+
const result = {};
|
|
93
|
+
for (const [key, val] of Object.entries(value)) {
|
|
94
|
+
result[key] = processValue(val, options);
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
// For numbers, booleans, null - return as-is
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Interpolate environment variables in a JSON object.
|
|
103
|
+
*
|
|
104
|
+
* Supports these syntaxes:
|
|
105
|
+
* - ${VAR} - Replace with env value, error if missing (in strict mode)
|
|
106
|
+
* - ${VAR:-default} - Replace with env value, or use default if missing
|
|
107
|
+
* - ${VAR:?message} - Replace with env value, or throw error with message if missing
|
|
108
|
+
* - $${VAR} - Escape: outputs literal ${VAR} without interpolation
|
|
109
|
+
*
|
|
110
|
+
* @param json - The JSON object to process
|
|
111
|
+
* @param options - Interpolation options (default: strict mode)
|
|
112
|
+
* @returns A new object with interpolated values
|
|
113
|
+
*/
|
|
114
|
+
export function interpolateEnvVars(json, options = DEFAULT_OPTIONS) {
|
|
115
|
+
return processValue(json, options);
|
|
116
|
+
}
|
|
117
|
+
// =============================================================================
|
|
118
|
+
// Text Content Interpolation
|
|
119
|
+
// =============================================================================
|
|
120
|
+
/**
|
|
121
|
+
* Interpolate environment variables in a string.
|
|
122
|
+
*/
|
|
123
|
+
export function interpolateEnvVarsInString(value, options = DEFAULT_OPTIONS) {
|
|
124
|
+
return processString(value, options);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Interpolate environment variables in an array of strings.
|
|
128
|
+
*/
|
|
129
|
+
export function interpolateEnvVarsInLines(lines, options = DEFAULT_OPTIONS) {
|
|
130
|
+
return lines.map((line) => processString(line, options));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Interpolate environment variables in content of any supported type.
|
|
134
|
+
* Handles objects, strings, and string arrays.
|
|
135
|
+
*/
|
|
136
|
+
export function interpolateContent(content, options = DEFAULT_OPTIONS) {
|
|
137
|
+
if (typeof content === "string") {
|
|
138
|
+
return interpolateEnvVarsInString(content, options);
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(content)) {
|
|
141
|
+
return interpolateEnvVarsInLines(content, options);
|
|
142
|
+
}
|
|
143
|
+
return interpolateEnvVars(content, options);
|
|
144
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ContentValue, RawConfig } from "./config.js";
|
|
2
|
+
export interface FileReferenceOptions {
|
|
3
|
+
configDir: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Check if a value is a file reference (string starting with @)
|
|
7
|
+
*/
|
|
8
|
+
export declare function isFileReference(value: unknown): value is string;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a file reference to its content.
|
|
11
|
+
* - JSON files are parsed as objects
|
|
12
|
+
* - YAML files are parsed as objects
|
|
13
|
+
* - Other files are returned as strings
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveFileReference(reference: string, configDir: string): ContentValue;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve all file references in a raw config.
|
|
18
|
+
* Walks through files at root level and per-repo level.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveFileReferencesInConfig(raw: RawConfig, options: FileReferenceOptions): RawConfig;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve, isAbsolute, normalize, extname } from "node:path";
|
|
3
|
+
import JSON5 from "json5";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
/**
|
|
6
|
+
* Check if a value is a file reference (string starting with @)
|
|
7
|
+
*/
|
|
8
|
+
export function isFileReference(value) {
|
|
9
|
+
return typeof value === "string" && value.startsWith("@");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a file reference to its content.
|
|
13
|
+
* - JSON files are parsed as objects
|
|
14
|
+
* - YAML files are parsed as objects
|
|
15
|
+
* - Other files are returned as strings
|
|
16
|
+
*/
|
|
17
|
+
export function resolveFileReference(reference, configDir) {
|
|
18
|
+
const relativePath = reference.slice(1); // Remove @ prefix
|
|
19
|
+
if (relativePath.length === 0) {
|
|
20
|
+
throw new Error(`Invalid file reference "${reference}": path is empty`);
|
|
21
|
+
}
|
|
22
|
+
// Security: block absolute paths
|
|
23
|
+
if (isAbsolute(relativePath)) {
|
|
24
|
+
throw new Error(`File reference "${reference}" uses absolute path. Use relative paths only.`);
|
|
25
|
+
}
|
|
26
|
+
const resolvedPath = resolve(configDir, relativePath);
|
|
27
|
+
const normalizedResolved = normalize(resolvedPath);
|
|
28
|
+
const normalizedConfigDir = normalize(configDir);
|
|
29
|
+
// Security: ensure path stays within config directory tree
|
|
30
|
+
// Use path separator to ensure we're checking directory boundaries
|
|
31
|
+
if (!normalizedResolved.startsWith(normalizedConfigDir + "/") &&
|
|
32
|
+
normalizedResolved !== normalizedConfigDir) {
|
|
33
|
+
throw new Error(`File reference "${reference}" escapes config directory. ` +
|
|
34
|
+
`References must be within "${configDir}".`);
|
|
35
|
+
}
|
|
36
|
+
// Load file
|
|
37
|
+
let content;
|
|
38
|
+
try {
|
|
39
|
+
content = readFileSync(resolvedPath, "utf-8");
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
43
|
+
throw new Error(`Failed to load file reference "${reference}": ${msg}`);
|
|
44
|
+
}
|
|
45
|
+
// Parse based on extension
|
|
46
|
+
const ext = extname(relativePath).toLowerCase();
|
|
47
|
+
if (ext === ".json") {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(content);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
53
|
+
throw new Error(`Invalid JSON in "${reference}": ${msg}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (ext === ".json5") {
|
|
57
|
+
try {
|
|
58
|
+
return JSON5.parse(content);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
62
|
+
throw new Error(`Invalid JSON5 in "${reference}": ${msg}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (ext === ".yaml" || ext === ".yml") {
|
|
66
|
+
try {
|
|
67
|
+
return parseYaml(content);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
71
|
+
throw new Error(`Invalid YAML in "${reference}": ${msg}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Text file - return as string
|
|
75
|
+
return content;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Recursively resolve file references in a content value.
|
|
79
|
+
* Only string values starting with @ are resolved.
|
|
80
|
+
*/
|
|
81
|
+
function resolveContentValue(value, configDir) {
|
|
82
|
+
if (value === undefined) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
// If it's a file reference, resolve it
|
|
86
|
+
if (isFileReference(value)) {
|
|
87
|
+
return resolveFileReference(value, configDir);
|
|
88
|
+
}
|
|
89
|
+
// Otherwise return as-is (objects, arrays, plain strings)
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve all file references in a raw config.
|
|
94
|
+
* Walks through files at root level and per-repo level.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveFileReferencesInConfig(raw, options) {
|
|
97
|
+
const { configDir } = options;
|
|
98
|
+
// Deep clone to avoid mutating input
|
|
99
|
+
const result = JSON.parse(JSON.stringify(raw));
|
|
100
|
+
// Resolve root-level file content
|
|
101
|
+
if (result.files) {
|
|
102
|
+
for (const [fileName, fileConfig] of Object.entries(result.files)) {
|
|
103
|
+
if (fileConfig &&
|
|
104
|
+
typeof fileConfig === "object" &&
|
|
105
|
+
"content" in fileConfig) {
|
|
106
|
+
const resolved = resolveContentValue(fileConfig.content, configDir);
|
|
107
|
+
if (resolved !== undefined) {
|
|
108
|
+
result.files[fileName] = { ...fileConfig, content: resolved };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Resolve per-repo file content
|
|
114
|
+
if (result.repos) {
|
|
115
|
+
for (const repo of result.repos) {
|
|
116
|
+
if (repo.files) {
|
|
117
|
+
for (const [fileName, fileOverride] of Object.entries(repo.files)) {
|
|
118
|
+
// Skip false (exclusion) entries
|
|
119
|
+
if (fileOverride === false) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (fileOverride &&
|
|
123
|
+
typeof fileOverride === "object" &&
|
|
124
|
+
"content" in fileOverride) {
|
|
125
|
+
const resolved = resolveContentValue(fileOverride.content, configDir);
|
|
126
|
+
if (resolved !== undefined) {
|
|
127
|
+
repo.files[fileName] = { ...fileOverride, content: resolved };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { CommandExecutor } from "./command-executor.js";
|
|
2
|
+
export interface GitOpsOptions {
|
|
3
|
+
workDir: string;
|
|
4
|
+
dryRun?: boolean;
|
|
5
|
+
executor?: CommandExecutor;
|
|
6
|
+
/** Number of retries for network operations (default: 3) */
|
|
7
|
+
retries?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class GitOps {
|
|
10
|
+
private workDir;
|
|
11
|
+
private dryRun;
|
|
12
|
+
private executor;
|
|
13
|
+
private retries;
|
|
14
|
+
constructor(options: GitOpsOptions);
|
|
15
|
+
private exec;
|
|
16
|
+
/**
|
|
17
|
+
* Run a command with retry logic for transient failures.
|
|
18
|
+
* Used for network operations like clone, fetch, push.
|
|
19
|
+
*/
|
|
20
|
+
private execWithRetry;
|
|
21
|
+
/**
|
|
22
|
+
* Validates that a file path doesn't escape the workspace directory.
|
|
23
|
+
* @returns The resolved absolute file path
|
|
24
|
+
* @throws Error if path traversal is detected
|
|
25
|
+
*/
|
|
26
|
+
private validatePath;
|
|
27
|
+
cleanWorkspace(): void;
|
|
28
|
+
clone(gitUrl: string): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Create a new branch from the current HEAD.
|
|
31
|
+
* Always creates fresh - existing branches should be cleaned up beforehand
|
|
32
|
+
* by closing any existing PRs (which deletes the remote branch).
|
|
33
|
+
*/
|
|
34
|
+
createBranch(branchName: string): Promise<void>;
|
|
35
|
+
writeFile(fileName: string, content: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* Marks a file as executable in git using update-index --chmod=+x.
|
|
38
|
+
* This modifies the file mode in git's index, not the filesystem.
|
|
39
|
+
* @param fileName - The file path relative to the work directory
|
|
40
|
+
*/
|
|
41
|
+
setExecutable(fileName: string): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Checks if writing the given content would result in changes.
|
|
44
|
+
* Works in both normal and dry-run modes by comparing content directly.
|
|
45
|
+
*/
|
|
46
|
+
wouldChange(fileName: string, content: string): boolean;
|
|
47
|
+
hasChanges(): Promise<boolean>;
|
|
48
|
+
/**
|
|
49
|
+
* Check if there are staged changes ready to commit.
|
|
50
|
+
* Uses `git diff --cached --quiet` which exits with 1 if there are staged changes.
|
|
51
|
+
*/
|
|
52
|
+
hasStagedChanges(): Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Check if a file exists on a specific branch.
|
|
55
|
+
* Used for createOnly checks against the base branch (not the working directory).
|
|
56
|
+
*/
|
|
57
|
+
fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
|
|
58
|
+
/**
|
|
59
|
+
* Stage all changes and commit with the given message.
|
|
60
|
+
* Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
|
|
61
|
+
* @returns true if a commit was made, false if there were no staged changes
|
|
62
|
+
*/
|
|
63
|
+
commit(message: string): Promise<boolean>;
|
|
64
|
+
push(branchName: string): Promise<void>;
|
|
65
|
+
getDefaultBranch(): Promise<{
|
|
66
|
+
branch: string;
|
|
67
|
+
method: string;
|
|
68
|
+
}>;
|
|
69
|
+
}
|
|
70
|
+
export declare function sanitizeBranchName(fileName: string): string;
|
|
71
|
+
/**
|
|
72
|
+
* Validates a user-provided branch name against git's naming rules.
|
|
73
|
+
* @throws Error if the branch name is invalid
|
|
74
|
+
*/
|
|
75
|
+
export declare function validateBranchName(branchName: string): void;
|
package/dist/git-ops.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync, } from "node:fs";
|
|
2
|
+
import { join, resolve, relative, isAbsolute, dirname } from "node:path";
|
|
3
|
+
import { escapeShellArg } from "./shell-utils.js";
|
|
4
|
+
import { defaultExecutor } from "./command-executor.js";
|
|
5
|
+
import { withRetry } from "./retry-utils.js";
|
|
6
|
+
import { logger } from "./logger.js";
|
|
7
|
+
export class GitOps {
|
|
8
|
+
workDir;
|
|
9
|
+
dryRun;
|
|
10
|
+
executor;
|
|
11
|
+
retries;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.workDir = options.workDir;
|
|
14
|
+
this.dryRun = options.dryRun ?? false;
|
|
15
|
+
this.executor = options.executor ?? defaultExecutor;
|
|
16
|
+
this.retries = options.retries ?? 3;
|
|
17
|
+
}
|
|
18
|
+
async exec(command, cwd) {
|
|
19
|
+
return this.executor.exec(command, cwd ?? this.workDir);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Run a command with retry logic for transient failures.
|
|
23
|
+
* Used for network operations like clone, fetch, push.
|
|
24
|
+
*/
|
|
25
|
+
async execWithRetry(command, cwd) {
|
|
26
|
+
return withRetry(() => this.exec(command, cwd), {
|
|
27
|
+
retries: this.retries,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Validates that a file path doesn't escape the workspace directory.
|
|
32
|
+
* @returns The resolved absolute file path
|
|
33
|
+
* @throws Error if path traversal is detected
|
|
34
|
+
*/
|
|
35
|
+
validatePath(fileName) {
|
|
36
|
+
const filePath = join(this.workDir, fileName);
|
|
37
|
+
const resolvedPath = resolve(filePath);
|
|
38
|
+
const resolvedWorkDir = resolve(this.workDir);
|
|
39
|
+
const relativePath = relative(resolvedWorkDir, resolvedPath);
|
|
40
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
41
|
+
throw new Error(`Path traversal detected: ${fileName}`);
|
|
42
|
+
}
|
|
43
|
+
return filePath;
|
|
44
|
+
}
|
|
45
|
+
cleanWorkspace() {
|
|
46
|
+
if (existsSync(this.workDir)) {
|
|
47
|
+
rmSync(this.workDir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
mkdirSync(this.workDir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
async clone(gitUrl) {
|
|
52
|
+
await this.execWithRetry(`git clone ${escapeShellArg(gitUrl)} .`, this.workDir);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create a new branch from the current HEAD.
|
|
56
|
+
* Always creates fresh - existing branches should be cleaned up beforehand
|
|
57
|
+
* by closing any existing PRs (which deletes the remote branch).
|
|
58
|
+
*/
|
|
59
|
+
async createBranch(branchName) {
|
|
60
|
+
try {
|
|
61
|
+
await this.exec(`git checkout -b ${escapeShellArg(branchName)}`, this.workDir);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
throw new Error(`Failed to create branch '${branchName}': ${message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
writeFile(fileName, content) {
|
|
69
|
+
if (this.dryRun) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const filePath = this.validatePath(fileName);
|
|
73
|
+
// Create parent directories if they don't exist
|
|
74
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
75
|
+
// Normalize trailing newline - ensure exactly one
|
|
76
|
+
const normalized = content.endsWith("\n") ? content : content + "\n";
|
|
77
|
+
writeFileSync(filePath, normalized, "utf-8");
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Marks a file as executable in git using update-index --chmod=+x.
|
|
81
|
+
* This modifies the file mode in git's index, not the filesystem.
|
|
82
|
+
* @param fileName - The file path relative to the work directory
|
|
83
|
+
*/
|
|
84
|
+
async setExecutable(fileName) {
|
|
85
|
+
if (this.dryRun) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const filePath = this.validatePath(fileName);
|
|
89
|
+
// Use relative path from workDir for git command
|
|
90
|
+
const relativePath = relative(this.workDir, filePath);
|
|
91
|
+
await this.exec(`git update-index --add --chmod=+x ${escapeShellArg(relativePath)}`, this.workDir);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Checks if writing the given content would result in changes.
|
|
95
|
+
* Works in both normal and dry-run modes by comparing content directly.
|
|
96
|
+
*/
|
|
97
|
+
wouldChange(fileName, content) {
|
|
98
|
+
const filePath = this.validatePath(fileName);
|
|
99
|
+
// Normalize trailing newline - ensure exactly one
|
|
100
|
+
const newContent = content.endsWith("\n") ? content : content + "\n";
|
|
101
|
+
if (!existsSync(filePath)) {
|
|
102
|
+
// File doesn't exist, so writing it would be a change
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const existingContent = readFileSync(filePath, "utf-8");
|
|
107
|
+
return existingContent !== newContent;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// If we can't read the file, assume it would change
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async hasChanges() {
|
|
115
|
+
const status = await this.exec("git status --porcelain", this.workDir);
|
|
116
|
+
return status.length > 0;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Check if there are staged changes ready to commit.
|
|
120
|
+
* Uses `git diff --cached --quiet` which exits with 1 if there are staged changes.
|
|
121
|
+
*/
|
|
122
|
+
async hasStagedChanges() {
|
|
123
|
+
try {
|
|
124
|
+
await this.exec("git diff --cached --quiet", this.workDir);
|
|
125
|
+
return false; // Exit code 0 = no staged changes
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return true; // Exit code 1 = there are staged changes
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Check if a file exists on a specific branch.
|
|
133
|
+
* Used for createOnly checks against the base branch (not the working directory).
|
|
134
|
+
*/
|
|
135
|
+
async fileExistsOnBranch(fileName, branch) {
|
|
136
|
+
try {
|
|
137
|
+
await this.exec(`git show ${escapeShellArg(branch)}:${escapeShellArg(fileName)}`, this.workDir);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Stage all changes and commit with the given message.
|
|
146
|
+
* Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
|
|
147
|
+
* @returns true if a commit was made, false if there were no staged changes
|
|
148
|
+
*/
|
|
149
|
+
async commit(message) {
|
|
150
|
+
if (this.dryRun) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
await this.exec("git add -A", this.workDir);
|
|
154
|
+
// Check if there are actually staged changes after git add
|
|
155
|
+
if (!(await this.hasStagedChanges())) {
|
|
156
|
+
return false; // No changes to commit
|
|
157
|
+
}
|
|
158
|
+
// Use --no-verify to skip pre-commit hooks
|
|
159
|
+
await this.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, this.workDir);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
async push(branchName) {
|
|
163
|
+
if (this.dryRun) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
await this.execWithRetry(`git push -u origin ${escapeShellArg(branchName)}`, this.workDir);
|
|
167
|
+
}
|
|
168
|
+
async getDefaultBranch() {
|
|
169
|
+
try {
|
|
170
|
+
// Try to get the default branch from remote (network operation with retry)
|
|
171
|
+
const remoteInfo = await this.execWithRetry("git remote show origin", this.workDir);
|
|
172
|
+
const match = remoteInfo.match(/HEAD branch: (\S+)/);
|
|
173
|
+
if (match) {
|
|
174
|
+
return { branch: match[1], method: "remote HEAD" };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
179
|
+
logger.info(`Debug: git remote show origin failed - ${msg}`);
|
|
180
|
+
}
|
|
181
|
+
// Try common default branch names (local operations, no retry needed)
|
|
182
|
+
try {
|
|
183
|
+
await this.exec("git rev-parse --verify origin/main", this.workDir);
|
|
184
|
+
return { branch: "main", method: "origin/main exists" };
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
188
|
+
logger.info(`Debug: origin/main check failed - ${msg}`);
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await this.exec("git rev-parse --verify origin/master", this.workDir);
|
|
192
|
+
return { branch: "master", method: "origin/master exists" };
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
196
|
+
logger.info(`Debug: origin/master check failed - ${msg}`);
|
|
197
|
+
}
|
|
198
|
+
return { branch: "main", method: "fallback default" };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function sanitizeBranchName(fileName) {
|
|
202
|
+
return fileName
|
|
203
|
+
.toLowerCase()
|
|
204
|
+
.replace(/\.[^.]+$/, "") // Remove extension
|
|
205
|
+
.replace(/[^a-z0-9-]/g, "-") // Replace non-alphanumeric with dashes
|
|
206
|
+
.replace(/-+/g, "-") // Collapse multiple dashes
|
|
207
|
+
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Validates a user-provided branch name against git's naming rules.
|
|
211
|
+
* @throws Error if the branch name is invalid
|
|
212
|
+
*/
|
|
213
|
+
export function validateBranchName(branchName) {
|
|
214
|
+
if (!branchName || branchName.trim() === "") {
|
|
215
|
+
throw new Error("Branch name cannot be empty");
|
|
216
|
+
}
|
|
217
|
+
if (branchName.startsWith(".") || branchName.startsWith("-")) {
|
|
218
|
+
throw new Error('Branch name cannot start with "." or "-"');
|
|
219
|
+
}
|
|
220
|
+
// Git disallows: space, ~, ^, :, ?, *, [, \, and consecutive dots (..)
|
|
221
|
+
if (/[\s~^:?*\[\\]/.test(branchName) || branchName.includes("..")) {
|
|
222
|
+
throw new Error("Branch name contains invalid characters");
|
|
223
|
+
}
|
|
224
|
+
if (branchName.endsWith("/") ||
|
|
225
|
+
branchName.endsWith(".lock") ||
|
|
226
|
+
branchName.endsWith(".")) {
|
|
227
|
+
throw new Error("Branch name has invalid ending");
|
|
228
|
+
}
|
|
229
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ProcessorResult } from "./repository-processor.js";
|
|
3
|
+
import { RepoConfig } from "./config.js";
|
|
4
|
+
import { RepoInfo } from "./repo-detector.js";
|
|
5
|
+
import { ProcessorOptions } from "./repository-processor.js";
|
|
6
|
+
/**
|
|
7
|
+
* Processor interface for dependency injection in tests.
|
|
8
|
+
*/
|
|
9
|
+
export interface IRepositoryProcessor {
|
|
10
|
+
process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Factory function type for creating processors.
|
|
14
|
+
* Allows dependency injection for testing.
|
|
15
|
+
*/
|
|
16
|
+
export type ProcessorFactory = () => IRepositoryProcessor;
|
|
17
|
+
/**
|
|
18
|
+
* Default factory that creates a real RepositoryProcessor.
|
|
19
|
+
*/
|
|
20
|
+
export declare const defaultProcessorFactory: ProcessorFactory;
|