@aspruyt/xfg 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PR.md CHANGED
@@ -1,10 +1,10 @@
1
1
  ## Summary
2
2
 
3
- Automated sync of configuration files.
3
+ Automated sync of configuration files to ${xfg:repo.fullName}.
4
4
 
5
5
  ## Changes
6
6
 
7
- {{FILE_CHANGES}}
7
+ ${xfg:pr.fileChanges}
8
8
 
9
9
  ## Source
10
10
 
package/README.md CHANGED
@@ -51,11 +51,12 @@ xfg --config ./config.yaml
51
51
  - **Content Inheritance** - Define base config once, override per-repo as needed
52
52
  - **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
53
53
  - **Environment Variables** - Use `${VAR}` syntax for dynamic values
54
+ - **Templating** - Use `${xfg:repo.name}` syntax for dynamic repo-specific content
54
55
  - **Merge Strategies** - Control how arrays merge (replace, append, prepend)
55
56
  - **Override Mode** - Skip merging entirely for specific repos
56
57
  - **Empty Files** - Create files with no content (e.g., `.prettierignore`)
57
58
  - **YAML Comments** - Add header comments and schema directives to YAML files
58
- - **Multi-Platform** - Works with GitHub, Azure DevOps, and GitLab (including self-hosted)
59
+ - **Multi-Platform** - Works with GitHub (including GitHub Enterprise Server), Azure DevOps, and GitLab (including self-hosted)
59
60
  - **Auto-Merge PRs** - Automatically merge PRs when checks pass, or force merge with admin privileges
60
61
  - **Dry-Run Mode** - Preview changes without creating PRs
61
62
  - **Error Resilience** - Continues processing if individual repos fail
@@ -115,6 +115,12 @@ export function normalizeConfig(raw) {
115
115
  const executable = repoOverride?.executable ?? fileConfig.executable;
116
116
  const header = normalizeHeader(repoOverride?.header ?? fileConfig.header);
117
117
  const schemaUrl = repoOverride?.schemaUrl ?? fileConfig.schemaUrl;
118
+ // Template: per-repo overrides root level
119
+ const template = repoOverride?.template ?? fileConfig.template;
120
+ // Vars: merge root + per-repo (per-repo takes precedence)
121
+ const vars = fileConfig.vars || repoOverride?.vars
122
+ ? { ...fileConfig.vars, ...repoOverride?.vars }
123
+ : undefined;
118
124
  files.push({
119
125
  fileName,
120
126
  content: mergedContent,
@@ -122,6 +128,8 @@ export function normalizeConfig(raw) {
122
128
  executable,
123
129
  header,
124
130
  schemaUrl,
131
+ template,
132
+ vars,
125
133
  });
126
134
  }
127
135
  // Merge PR options: per-repo overrides global
@@ -136,5 +144,6 @@ export function normalizeConfig(raw) {
136
144
  return {
137
145
  repos: expandedRepos,
138
146
  prTemplate: raw.prTemplate,
147
+ githubHosts: raw.githubHosts,
139
148
  };
140
149
  }
@@ -79,10 +79,44 @@ export function validateRawConfig(config) {
79
79
  typeof fileConfig.schemaUrl !== "string") {
80
80
  throw new Error(`File '${fileName}' schemaUrl must be a string`);
81
81
  }
82
+ if (fileConfig.template !== undefined &&
83
+ typeof fileConfig.template !== "boolean") {
84
+ throw new Error(`File '${fileName}' template must be a boolean`);
85
+ }
86
+ if (fileConfig.vars !== undefined) {
87
+ if (typeof fileConfig.vars !== "object" ||
88
+ fileConfig.vars === null ||
89
+ Array.isArray(fileConfig.vars)) {
90
+ throw new Error(`File '${fileName}' vars must be an object with string values`);
91
+ }
92
+ for (const [key, value] of Object.entries(fileConfig.vars)) {
93
+ if (typeof value !== "string") {
94
+ throw new Error(`File '${fileName}' vars.${key} must be a string`);
95
+ }
96
+ }
97
+ }
82
98
  }
83
99
  if (!config.repos || !Array.isArray(config.repos)) {
84
100
  throw new Error("Config missing required field: repos (must be an array)");
85
101
  }
102
+ // Validate githubHosts if provided
103
+ if (config.githubHosts !== undefined) {
104
+ if (!Array.isArray(config.githubHosts) ||
105
+ !config.githubHosts.every((h) => typeof h === "string")) {
106
+ throw new Error("githubHosts must be an array of strings");
107
+ }
108
+ for (const host of config.githubHosts) {
109
+ if (!host) {
110
+ throw new Error("githubHosts entries must be non-empty hostnames");
111
+ }
112
+ if (host.includes("://")) {
113
+ throw new Error(`githubHosts entries must be hostnames only, not URLs. Got: ${host}`);
114
+ }
115
+ if (host.includes("/")) {
116
+ throw new Error(`githubHosts entries must be hostnames only, not paths. Got: ${host}`);
117
+ }
118
+ }
119
+ }
86
120
  // Validate each repo
87
121
  for (let i = 0; i < config.repos.length; i++) {
88
122
  const repo = config.repos[i];
@@ -146,6 +180,22 @@ export function validateRawConfig(config) {
146
180
  typeof fileOverride.schemaUrl !== "string") {
147
181
  throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' schemaUrl must be a string`);
148
182
  }
183
+ if (fileOverride.template !== undefined &&
184
+ typeof fileOverride.template !== "boolean") {
185
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' template must be a boolean`);
186
+ }
187
+ if (fileOverride.vars !== undefined) {
188
+ if (typeof fileOverride.vars !== "object" ||
189
+ fileOverride.vars === null ||
190
+ Array.isArray(fileOverride.vars)) {
191
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' vars must be an object with string values`);
192
+ }
193
+ for (const [key, value] of Object.entries(fileOverride.vars)) {
194
+ if (typeof value !== "string") {
195
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' vars.${key} must be a string`);
196
+ }
197
+ }
198
+ }
149
199
  }
150
200
  }
151
201
  }
package/dist/config.d.ts CHANGED
@@ -16,6 +16,8 @@ export interface RawFileConfig {
16
16
  executable?: boolean;
17
17
  header?: string | string[];
18
18
  schemaUrl?: string;
19
+ template?: boolean;
20
+ vars?: Record<string, string>;
19
21
  }
20
22
  export interface RawRepoFileOverride {
21
23
  content?: ContentValue;
@@ -24,6 +26,8 @@ export interface RawRepoFileOverride {
24
26
  executable?: boolean;
25
27
  header?: string | string[];
26
28
  schemaUrl?: string;
29
+ template?: boolean;
30
+ vars?: Record<string, string>;
27
31
  }
28
32
  export interface RawRepoConfig {
29
33
  git: string | string[];
@@ -35,6 +39,7 @@ export interface RawConfig {
35
39
  repos: RawRepoConfig[];
36
40
  prOptions?: PRMergeOptions;
37
41
  prTemplate?: string;
42
+ githubHosts?: string[];
38
43
  }
39
44
  export interface FileContent {
40
45
  fileName: string;
@@ -43,6 +48,8 @@ export interface FileContent {
43
48
  executable?: boolean;
44
49
  header?: string[];
45
50
  schemaUrl?: string;
51
+ template?: boolean;
52
+ vars?: Record<string, string>;
46
53
  }
47
54
  export interface RepoConfig {
48
55
  git: string;
@@ -52,5 +59,6 @@ export interface RepoConfig {
52
59
  export interface Config {
53
60
  repos: RepoConfig[];
54
61
  prTemplate?: string;
62
+ githubHosts?: string[];
55
63
  }
56
64
  export declare function loadConfig(filePath: string): Config;
package/dist/env.js CHANGED
@@ -23,8 +23,10 @@ const ENV_VAR_REGEX = /\$\{([^}:]+)(?::([?-])([^}]*))?\}/g;
23
23
  * Regex to match escaped environment variable placeholders.
24
24
  * $${...} outputs literal ${...} without interpolation.
25
25
  * Example: $${VAR} -> ${VAR}, $${VAR:-default} -> ${VAR:-default}
26
+ *
27
+ * Note: Does NOT match $${xfg:...} patterns - those are handled by xfg templating.
26
28
  */
27
- const ESCAPED_VAR_REGEX = /\$\$\{([^}]+)\}/g;
29
+ const ESCAPED_VAR_REGEX = /\$\$\{((?!xfg:)[^}]+)\}/g;
28
30
  /**
29
31
  * Placeholder prefix for temporarily storing escaped sequences.
30
32
  * Uses null bytes which won't appear in normal content.
package/dist/index.js CHANGED
@@ -115,7 +115,9 @@ async function main() {
115
115
  const current = i + 1;
116
116
  let repoInfo;
117
117
  try {
118
- repoInfo = parseGitUrl(repoConfig.git);
118
+ repoInfo = parseGitUrl(repoConfig.git, {
119
+ githubHosts: config.githubHosts,
120
+ });
119
121
  }
120
122
  catch (error) {
121
123
  const message = error instanceof Error ? error.message : String(error);
@@ -23,9 +23,16 @@ export interface PRResult {
23
23
  message: string;
24
24
  }
25
25
  /**
26
- * Format PR body using template with {{FILE_CHANGES}} placeholder
26
+ * Format PR body using template with ${xfg:...} variables.
27
+ *
28
+ * Available PR-specific variables:
29
+ * - ${xfg:pr.fileChanges} - formatted list of changed files
30
+ * - ${xfg:pr.fileCount} - number of changed files
31
+ * - ${xfg:pr.title} - the PR title
32
+ *
33
+ * Plus all standard repo variables (repo.name, repo.owner, etc.)
27
34
  */
28
- export declare function formatPRBody(files: FileAction[], customTemplate?: string): string;
35
+ export declare function formatPRBody(files: FileAction[], repoInfo: RepoInfo, customTemplate?: string): string;
29
36
  /**
30
37
  * Generate PR title based on files changed (excludes skipped files)
31
38
  */
@@ -2,6 +2,7 @@ import { readFileSync, existsSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { getPRStrategy, } from "./strategies/index.js";
5
+ import { interpolateXfgContent } from "./xfg-template.js";
5
6
  // Re-export for backwards compatibility and testing
6
7
  export { escapeShellArg } from "./shell-utils.js";
7
8
  function loadDefaultTemplate() {
@@ -15,11 +16,11 @@ function loadDefaultTemplate() {
15
16
  // Fallback template
16
17
  return `## Summary
17
18
 
18
- Automated sync of configuration files.
19
+ Automated sync of configuration files to \${xfg:repo.fullName}.
19
20
 
20
21
  ## Changes
21
22
 
22
- {{FILE_CHANGES}}
23
+ \${xfg:pr.fileChanges}
23
24
 
24
25
  ## Source
25
26
 
@@ -42,12 +43,31 @@ function formatFileChanges(files) {
42
43
  .join("\n");
43
44
  }
44
45
  /**
45
- * Format PR body using template with {{FILE_CHANGES}} placeholder
46
+ * Format PR body using template with ${xfg:...} variables.
47
+ *
48
+ * Available PR-specific variables:
49
+ * - ${xfg:pr.fileChanges} - formatted list of changed files
50
+ * - ${xfg:pr.fileCount} - number of changed files
51
+ * - ${xfg:pr.title} - the PR title
52
+ *
53
+ * Plus all standard repo variables (repo.name, repo.owner, etc.)
46
54
  */
47
- export function formatPRBody(files, customTemplate) {
55
+ export function formatPRBody(files, repoInfo, customTemplate) {
48
56
  const template = customTemplate ?? loadDefaultTemplate();
49
57
  const fileChanges = formatFileChanges(files);
50
- return template.replace(/\{\{FILE_CHANGES\}\}/g, fileChanges);
58
+ const changedFiles = files.filter((f) => f.action !== "skip");
59
+ const title = formatPRTitle(files);
60
+ // Create context with PR-specific variables
61
+ const result = interpolateXfgContent(template, {
62
+ repoInfo,
63
+ fileName: "PR.md",
64
+ vars: {
65
+ "pr.fileChanges": fileChanges,
66
+ "pr.fileCount": String(changedFiles.length),
67
+ "pr.title": title,
68
+ },
69
+ });
70
+ return result;
51
71
  }
52
72
  /**
53
73
  * Generate PR title based on files changed (excludes skipped files)
@@ -66,7 +86,7 @@ export function formatPRTitle(files) {
66
86
  export async function createPR(options) {
67
87
  const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, } = options;
68
88
  const title = formatPRTitle(files);
69
- const body = formatPRBody(files, prTemplate);
89
+ const body = formatPRBody(files, repoInfo, prTemplate);
70
90
  if (dryRun) {
71
91
  return {
72
92
  success: true,
@@ -1,4 +1,7 @@
1
1
  export type RepoType = "github" | "azure-devops" | "gitlab";
2
+ export interface RepoDetectorContext {
3
+ githubHosts?: string[];
4
+ }
2
5
  interface BaseRepoInfo {
3
6
  gitUrl: string;
4
7
  repo: string;
@@ -6,6 +9,7 @@ interface BaseRepoInfo {
6
9
  export interface GitHubRepoInfo extends BaseRepoInfo {
7
10
  type: "github";
8
11
  owner: string;
12
+ host: string;
9
13
  }
10
14
  export interface AzureDevOpsRepoInfo extends BaseRepoInfo {
11
15
  type: "azure-devops";
@@ -23,7 +27,7 @@ export type RepoInfo = GitHubRepoInfo | AzureDevOpsRepoInfo | GitLabRepoInfo;
23
27
  export declare function isGitHubRepo(info: RepoInfo): info is GitHubRepoInfo;
24
28
  export declare function isAzureDevOpsRepo(info: RepoInfo): info is AzureDevOpsRepoInfo;
25
29
  export declare function isGitLabRepo(info: RepoInfo): info is GitLabRepoInfo;
26
- export declare function detectRepoType(gitUrl: string): RepoType;
27
- export declare function parseGitUrl(gitUrl: string): RepoInfo;
30
+ export declare function detectRepoType(gitUrl: string, context?: RepoDetectorContext): RepoType;
31
+ export declare function parseGitUrl(gitUrl: string, context?: RepoDetectorContext): RepoInfo;
28
32
  export declare function getRepoDisplayName(repoInfo: RepoInfo): string;
29
33
  export {};
@@ -8,6 +8,22 @@ export function isAzureDevOpsRepo(info) {
8
8
  export function isGitLabRepo(info) {
9
9
  return info.type === "gitlab";
10
10
  }
11
+ /**
12
+ * Extract hostname from a git URL.
13
+ */
14
+ function extractHostFromUrl(gitUrl) {
15
+ // SSH: git@hostname:path
16
+ const sshMatch = gitUrl.match(/^git@([^:]+):/);
17
+ if (sshMatch) {
18
+ return sshMatch[1];
19
+ }
20
+ // HTTPS: https://hostname/path
21
+ const httpsMatch = gitUrl.match(/^https?:\/\/([^/]+)/);
22
+ if (httpsMatch) {
23
+ return httpsMatch[1];
24
+ }
25
+ return null;
26
+ }
11
27
  /**
12
28
  * Valid URL patterns for supported repository types.
13
29
  */
@@ -41,8 +57,16 @@ function isGitLabStyleUrl(gitUrl) {
41
57
  }
42
58
  return false;
43
59
  }
44
- export function detectRepoType(gitUrl) {
45
- // Check for Azure DevOps formats first (most specific patterns)
60
+ export function detectRepoType(gitUrl, context) {
61
+ // Check for GitHub Enterprise hosts first (if configured)
62
+ if (context?.githubHosts?.length) {
63
+ const host = extractHostFromUrl(gitUrl)?.toLowerCase();
64
+ const normalizedHosts = context.githubHosts.map((h) => h.toLowerCase());
65
+ if (host && normalizedHosts.includes(host)) {
66
+ return "github";
67
+ }
68
+ }
69
+ // Check for Azure DevOps formats (most specific patterns)
46
70
  for (const pattern of AZURE_DEVOPS_URL_PATTERNS) {
47
71
  if (pattern.test(gitUrl)) {
48
72
  return "azure-devops";
@@ -67,37 +91,41 @@ export function detectRepoType(gitUrl) {
67
91
  // Throw for unrecognized URL formats
68
92
  throw new Error(`Unrecognized git URL format: ${gitUrl}. Supported formats: GitHub (git@github.com: or https://github.com/), Azure DevOps (git@ssh.dev.azure.com: or https://dev.azure.com/), and GitLab (git@gitlab.com: or https://gitlab.com/)`);
69
93
  }
70
- export function parseGitUrl(gitUrl) {
71
- const type = detectRepoType(gitUrl);
94
+ export function parseGitUrl(gitUrl, context) {
95
+ const type = detectRepoType(gitUrl, context);
72
96
  if (type === "azure-devops") {
73
97
  return parseAzureDevOpsUrl(gitUrl);
74
98
  }
75
99
  if (type === "gitlab") {
76
100
  return parseGitLabUrl(gitUrl);
77
101
  }
78
- return parseGitHubUrl(gitUrl);
102
+ // For GitHub, extract the host from the URL
103
+ const host = extractHostFromUrl(gitUrl) ?? "github.com";
104
+ return parseGitHubUrl(gitUrl, host);
79
105
  }
80
- function parseGitHubUrl(gitUrl) {
81
- // Handle SSH format: git@github.com:owner/repo.git
106
+ function parseGitHubUrl(gitUrl, host) {
107
+ // Handle SSH format: git@hostname:owner/repo.git
82
108
  // Use (.+?) with end anchor to handle repo names with dots (e.g., my.repo.git)
83
- const sshMatch = gitUrl.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
109
+ const sshMatch = gitUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
84
110
  if (sshMatch) {
85
111
  return {
86
112
  type: "github",
87
113
  gitUrl,
88
114
  owner: sshMatch[1],
89
115
  repo: sshMatch[2],
116
+ host,
90
117
  };
91
118
  }
92
- // Handle HTTPS format: https://github.com/owner/repo.git
119
+ // Handle HTTPS format: https://hostname/owner/repo.git
93
120
  // Use (.+?) with end anchor to handle repo names with dots
94
- const httpsMatch = gitUrl.match(/https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
121
+ const httpsMatch = gitUrl.match(/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/);
95
122
  if (httpsMatch) {
96
123
  return {
97
124
  type: "github",
98
125
  gitUrl,
99
126
  owner: httpsMatch[1],
100
127
  repo: httpsMatch[2],
128
+ host,
101
129
  };
102
130
  }
103
131
  throw new Error(`Unable to parse GitHub URL: ${gitUrl}`);
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { convertContentToString, } from "./config.js";
4
4
  import { getRepoDisplayName } from "./repo-detector.js";
5
+ import { interpolateXfgContent } from "./xfg-template.js";
5
6
  import { GitOps } from "./git-ops.js";
6
7
  import { createPR, mergePR } from "./pr-creator.js";
7
8
  import { logger } from "./logger.js";
@@ -97,7 +98,16 @@ export class RepositoryProcessor {
97
98
  }
98
99
  }
99
100
  this.log.info(`Writing ${file.fileName}...`);
100
- const fileContent = convertContentToString(file.content, file.fileName, {
101
+ // Apply xfg templating if enabled
102
+ let contentToWrite = file.content;
103
+ if (file.template && contentToWrite !== null) {
104
+ contentToWrite = interpolateXfgContent(contentToWrite, {
105
+ repoInfo,
106
+ fileName: file.fileName,
107
+ vars: file.vars,
108
+ }, { strict: true });
109
+ }
110
+ const fileContent = convertContentToString(contentToWrite, file.fileName, {
101
111
  header: file.header,
102
112
  schemaUrl: file.schemaUrl,
103
113
  });
@@ -5,13 +5,47 @@ import { isGitHubRepo } from "../repo-detector.js";
5
5
  import { BasePRStrategy, } from "./pr-strategy.js";
6
6
  import { logger } from "../logger.js";
7
7
  import { withRetry, isPermanentError } from "../retry-utils.js";
8
+ /**
9
+ * Get the repo flag value for gh CLI commands.
10
+ * Returns HOST/OWNER/REPO for GHE, OWNER/REPO for github.com.
11
+ */
12
+ function getRepoFlag(repoInfo) {
13
+ if (repoInfo.host && repoInfo.host !== "github.com") {
14
+ return `${repoInfo.host}/${repoInfo.owner}/${repoInfo.repo}`;
15
+ }
16
+ return `${repoInfo.owner}/${repoInfo.repo}`;
17
+ }
18
+ /**
19
+ * Get the hostname flag for gh api commands.
20
+ * Returns "--hostname HOST" for GHE, empty string for github.com.
21
+ */
22
+ function getHostnameFlag(repoInfo) {
23
+ if (repoInfo.host && repoInfo.host !== "github.com") {
24
+ return `--hostname ${escapeShellArg(repoInfo.host)}`;
25
+ }
26
+ return "";
27
+ }
28
+ /**
29
+ * Escape special regex characters in a string.
30
+ */
31
+ function escapeRegExp(str) {
32
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ }
34
+ /**
35
+ * Build regex to match PR URLs for the given host.
36
+ */
37
+ function buildPRUrlRegex(host) {
38
+ const escapedHost = escapeRegExp(host);
39
+ return new RegExp(`https://${escapedHost}/[\\w-]+/[\\w.-]+/pull/\\d+`);
40
+ }
8
41
  export class GitHubPRStrategy extends BasePRStrategy {
9
42
  async checkExistingPR(options) {
10
43
  const { repoInfo, branchName, workDir, retries = 3 } = options;
11
44
  if (!isGitHubRepo(repoInfo)) {
12
45
  throw new Error("Expected GitHub repository");
13
46
  }
14
- const command = `gh pr list --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
47
+ const repoFlag = getRepoFlag(repoInfo);
48
+ const command = `gh pr list --repo ${escapeShellArg(repoFlag)} --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
15
49
  try {
16
50
  const existingPR = await withRetry(() => this.executor.exec(command, workDir), { retries });
17
51
  return existingPR || null;
@@ -55,7 +89,8 @@ export class GitHubPRStrategy extends BasePRStrategy {
55
89
  throw new Error(`Could not extract PR number from URL: ${existingUrl}`);
56
90
  }
57
91
  // Close the PR and delete the branch
58
- const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --delete-branch`;
92
+ const repoFlag = getRepoFlag(repoInfo);
93
+ const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoFlag)} --delete-branch`;
59
94
  try {
60
95
  await withRetry(() => this.executor.exec(command, workDir), { retries });
61
96
  return true;
@@ -78,7 +113,9 @@ export class GitHubPRStrategy extends BasePRStrategy {
78
113
  try {
79
114
  const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
80
115
  // Extract URL from output - use strict regex for valid PR URLs only
81
- const urlMatch = result.match(/https:\/\/github\.com\/[\w-]+\/[\w.-]+\/pull\/\d+/);
116
+ const host = repoInfo.host || "github.com";
117
+ const urlRegex = buildPRUrlRegex(host);
118
+ const urlMatch = result.match(urlRegex);
82
119
  if (!urlMatch) {
83
120
  throw new Error(`Could not parse PR URL from output: ${result}`);
84
121
  }
@@ -104,7 +141,9 @@ export class GitHubPRStrategy extends BasePRStrategy {
104
141
  * Check if auto-merge is enabled on the repository.
105
142
  */
106
143
  async checkAutoMergeEnabled(repoInfo, workDir, retries = 3) {
107
- const command = `gh api repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
144
+ const hostnameFlag = getHostnameFlag(repoInfo);
145
+ const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
146
+ const command = `gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
108
147
  try {
109
148
  const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
110
149
  return result.trim() === "true";
@@ -143,19 +182,20 @@ export class GitHubPRStrategy extends BasePRStrategy {
143
182
  const deleteBranchFlag = config.deleteBranch ? "--delete-branch" : "";
144
183
  if (config.mode === "auto") {
145
184
  // Check if auto-merge is enabled on the repo
146
- // Extract owner/repo from PR URL
147
- const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
185
+ // Extract host/owner/repo from PR URL (supports both github.com and GHE)
186
+ const match = prUrl.match(/https:\/\/([^/]+)\/([^/]+)\/([^/]+)/);
148
187
  if (match) {
149
188
  const repoInfo = {
150
189
  type: "github",
151
190
  gitUrl: prUrl,
152
- owner: match[1],
153
- repo: match[2],
191
+ owner: match[2],
192
+ repo: match[3],
193
+ host: match[1],
154
194
  };
155
195
  const autoMergeEnabled = await this.checkAutoMergeEnabled(repoInfo, workDir, retries);
156
196
  if (!autoMergeEnabled) {
157
197
  logger.info(`Warning: Auto-merge not enabled for '${repoInfo.owner}/${repoInfo.repo}'. PR left open for manual review.`);
158
- logger.info(`To enable: gh repo edit ${repoInfo.owner}/${repoInfo.repo} --enable-auto-merge (requires admin)`);
198
+ logger.info(`To enable: gh repo edit ${getRepoFlag(repoInfo)} --enable-auto-merge (requires admin)`);
159
199
  return {
160
200
  success: true,
161
201
  message: `Auto-merge not enabled for repository. PR left open for manual review.`,
@@ -0,0 +1,43 @@
1
+ /**
2
+ * XFG template variable interpolation utilities.
3
+ * Supports ${xfg:variable} syntax for repo-specific content.
4
+ * Use $${xfg:variable} to escape and output literal ${xfg:variable}.
5
+ */
6
+ import type { RepoInfo } from "./repo-detector.js";
7
+ import type { ContentValue } from "./config.js";
8
+ export interface XfgTemplateContext {
9
+ /** Repository information from URL parsing */
10
+ repoInfo: RepoInfo;
11
+ /** Current file being processed */
12
+ fileName: string;
13
+ /** Custom variables defined in config */
14
+ vars?: Record<string, string>;
15
+ }
16
+ export interface XfgInterpolationOptions {
17
+ /**
18
+ * If true (default), throws an error when a variable is missing.
19
+ * If false, leaves the placeholder as-is.
20
+ */
21
+ strict: boolean;
22
+ }
23
+ /**
24
+ * Interpolate xfg template variables in content.
25
+ *
26
+ * Supports these syntaxes:
27
+ * - ${xfg:repo.name} - Repository name
28
+ * - ${xfg:repo.owner} - Repository owner
29
+ * - ${xfg:repo.fullName} - Full repository name (owner/repo)
30
+ * - ${xfg:repo.url} - Git URL
31
+ * - ${xfg:repo.platform} - Platform type (github, azure-devops, gitlab)
32
+ * - ${xfg:repo.host} - Host domain
33
+ * - ${xfg:file.name} - Current file name
34
+ * - ${xfg:date} - Current date (YYYY-MM-DD)
35
+ * - ${xfg:customVar} - Custom variable from vars config
36
+ * - $${xfg:var} - Escape: outputs literal ${xfg:var}
37
+ *
38
+ * @param content - The content to process (object, string, or string[])
39
+ * @param ctx - Template context with repo info and custom vars
40
+ * @param options - Interpolation options (default: strict mode)
41
+ * @returns Content with interpolated values
42
+ */
43
+ export declare function interpolateXfgContent(content: ContentValue, ctx: XfgTemplateContext, options?: XfgInterpolationOptions): ContentValue;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * XFG template variable interpolation utilities.
3
+ * Supports ${xfg:variable} syntax for repo-specific content.
4
+ * Use $${xfg:variable} to escape and output literal ${xfg:variable}.
5
+ */
6
+ const DEFAULT_OPTIONS = {
7
+ strict: true,
8
+ };
9
+ /**
10
+ * Regex to match xfg template variable placeholders.
11
+ * Captures the variable name including dot notation.
12
+ * Variable names can only contain: a-z, A-Z, 0-9, dots, and underscores.
13
+ *
14
+ * Examples:
15
+ * - ${xfg:repo.name} -> varName=repo.name
16
+ * - ${xfg:myVar} -> varName=myVar
17
+ */
18
+ const XFG_VAR_REGEX = /\$\{xfg:([a-zA-Z0-9._]+)\}/g;
19
+ /**
20
+ * Regex to match escaped xfg template variable placeholders.
21
+ * $${xfg:...} outputs literal ${xfg:...} without interpolation.
22
+ * Variable names can only contain: a-z, A-Z, 0-9, dots, and underscores.
23
+ */
24
+ const ESCAPED_XFG_VAR_REGEX = /\$\$\{xfg:([a-zA-Z0-9._]+)\}/g;
25
+ /**
26
+ * Placeholder prefix for temporarily storing escaped sequences.
27
+ * Uses null bytes which won't appear in normal content.
28
+ */
29
+ const ESCAPE_PLACEHOLDER = "\x00ESCAPED_XFG_VAR\x00";
30
+ /**
31
+ * Get the value of a built-in xfg variable.
32
+ * Returns undefined if the variable is not recognized.
33
+ */
34
+ function getBuiltinVar(varName, ctx) {
35
+ const { repoInfo, fileName } = ctx;
36
+ switch (varName) {
37
+ case "repo.name":
38
+ return repoInfo.repo;
39
+ case "repo.owner":
40
+ return repoInfo.owner;
41
+ case "repo.fullName":
42
+ if (repoInfo.type === "azure-devops") {
43
+ return `${repoInfo.organization}/${repoInfo.project}/${repoInfo.repo}`;
44
+ }
45
+ if (repoInfo.type === "gitlab") {
46
+ return `${repoInfo.namespace}/${repoInfo.repo}`;
47
+ }
48
+ return `${repoInfo.owner}/${repoInfo.repo}`;
49
+ case "repo.url":
50
+ return repoInfo.gitUrl;
51
+ case "repo.platform":
52
+ return repoInfo.type;
53
+ case "repo.host":
54
+ if (repoInfo.type === "github" || repoInfo.type === "gitlab") {
55
+ return repoInfo.host;
56
+ }
57
+ // Azure DevOps doesn't have a host field, use dev.azure.com
58
+ return "dev.azure.com";
59
+ case "file.name":
60
+ return fileName;
61
+ case "date":
62
+ return new Date().toISOString().split("T")[0];
63
+ default:
64
+ return undefined;
65
+ }
66
+ }
67
+ /**
68
+ * Check if a value is a plain object (not null, not array).
69
+ */
70
+ function isPlainObject(val) {
71
+ return typeof val === "object" && val !== null && !Array.isArray(val);
72
+ }
73
+ /**
74
+ * Process a single string value, replacing xfg template variable placeholders.
75
+ * Supports escaping with $${xfg:var} syntax to output literal ${xfg:var}.
76
+ */
77
+ function processString(value, ctx, options) {
78
+ // Phase 1: Replace escaped $${xfg:...} with placeholders
79
+ const escapedContent = [];
80
+ let processed = value.replace(ESCAPED_XFG_VAR_REGEX, (_match, content) => {
81
+ const index = escapedContent.length;
82
+ escapedContent.push(content);
83
+ return `${ESCAPE_PLACEHOLDER}${index}\x00`;
84
+ });
85
+ // Phase 2: Interpolate remaining ${xfg:...}
86
+ processed = processed.replace(XFG_VAR_REGEX, (match, varName) => {
87
+ // First check custom vars
88
+ if (ctx.vars && varName in ctx.vars) {
89
+ return ctx.vars[varName];
90
+ }
91
+ // Then check built-in vars
92
+ const builtinValue = getBuiltinVar(varName, ctx);
93
+ if (builtinValue !== undefined) {
94
+ return builtinValue;
95
+ }
96
+ // Unknown variable
97
+ if (options.strict) {
98
+ throw new Error(`Unknown xfg template variable: ${varName}`);
99
+ }
100
+ // Non-strict mode - leave placeholder as-is
101
+ return match;
102
+ });
103
+ // Phase 3: Restore escaped sequences as literal ${xfg:...}
104
+ processed = processed.replace(new RegExp(`${ESCAPE_PLACEHOLDER}(\\d+)\x00`, "g"), (_match, indexStr) => {
105
+ const index = parseInt(indexStr, 10);
106
+ return `\${xfg:${escapedContent[index]}}`;
107
+ });
108
+ return processed;
109
+ }
110
+ /**
111
+ * Recursively process a value, interpolating xfg template variables in strings.
112
+ */
113
+ function processValue(value, ctx, options) {
114
+ if (typeof value === "string") {
115
+ return processString(value, ctx, options);
116
+ }
117
+ if (Array.isArray(value)) {
118
+ return value.map((item) => processValue(item, ctx, options));
119
+ }
120
+ if (isPlainObject(value)) {
121
+ const result = {};
122
+ for (const [key, val] of Object.entries(value)) {
123
+ result[key] = processValue(val, ctx, options);
124
+ }
125
+ return result;
126
+ }
127
+ // For numbers, booleans, null - return as-is
128
+ return value;
129
+ }
130
+ /**
131
+ * Interpolate xfg template variables in content.
132
+ *
133
+ * Supports these syntaxes:
134
+ * - ${xfg:repo.name} - Repository name
135
+ * - ${xfg:repo.owner} - Repository owner
136
+ * - ${xfg:repo.fullName} - Full repository name (owner/repo)
137
+ * - ${xfg:repo.url} - Git URL
138
+ * - ${xfg:repo.platform} - Platform type (github, azure-devops, gitlab)
139
+ * - ${xfg:repo.host} - Host domain
140
+ * - ${xfg:file.name} - Current file name
141
+ * - ${xfg:date} - Current date (YYYY-MM-DD)
142
+ * - ${xfg:customVar} - Custom variable from vars config
143
+ * - $${xfg:var} - Escape: outputs literal ${xfg:var}
144
+ *
145
+ * @param content - The content to process (object, string, or string[])
146
+ * @param ctx - Template context with repo info and custom vars
147
+ * @param options - Interpolation options (default: strict mode)
148
+ * @returns Content with interpolated values
149
+ */
150
+ export function interpolateXfgContent(content, ctx, options = DEFAULT_OPTIONS) {
151
+ if (typeof content === "string") {
152
+ return processString(content, ctx, options);
153
+ }
154
+ if (Array.isArray(content)) {
155
+ return content.map((line) => processString(line, ctx, options));
156
+ }
157
+ return processValue(content, ctx, options);
158
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "CLI tool to sync JSON, JSON5, YAML, or text configuration files across multiple Git repositories by creating pull requests",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "build": "tsc",
27
27
  "start": "node dist/index.js",
28
28
  "dev": "ts-node src/index.ts",
29
- "test": "node --import tsx --test src/config.test.ts src/merge.test.ts src/env.test.ts src/repo-detector.test.ts src/pr-creator.test.ts src/git-ops.test.ts src/logger.test.ts src/workspace-utils.test.ts src/strategies/pr-strategy.test.ts src/strategies/github-pr-strategy.test.ts src/strategies/azure-pr-strategy.test.ts src/strategies/gitlab-pr-strategy.test.ts src/repository-processor.test.ts src/retry-utils.test.ts src/command-executor.test.ts src/shell-utils.test.ts src/index.test.ts src/config-formatter.test.ts src/config-validator.test.ts src/config-normalizer.test.ts src/diff-utils.test.ts",
29
+ "test": "node --import tsx --test src/config.test.ts src/merge.test.ts src/env.test.ts src/repo-detector.test.ts src/pr-creator.test.ts src/git-ops.test.ts src/logger.test.ts src/workspace-utils.test.ts src/strategies/pr-strategy.test.ts src/strategies/github-pr-strategy.test.ts src/strategies/azure-pr-strategy.test.ts src/strategies/gitlab-pr-strategy.test.ts src/repository-processor.test.ts src/retry-utils.test.ts src/command-executor.test.ts src/shell-utils.test.ts src/index.test.ts src/config-formatter.test.ts src/config-validator.test.ts src/config-normalizer.test.ts src/diff-utils.test.ts src/xfg-template.test.ts",
30
30
  "test:integration:github": "npm run build && node --import tsx --test src/integration-github.test.ts",
31
31
  "test:integration:ado": "npm run build && node --import tsx --test src/integration-ado.test.ts",
32
32
  "test:integration:gitlab": "npm run build && node --import tsx --test src/integration-gitlab.test.ts",