@aspruyt/xfg 2.2.1 → 3.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.
@@ -0,0 +1,49 @@
1
+ import type { GitHubRepoInfo } from "./repo-detector.js";
2
+ /** Duration to cache tokens (45 minutes in milliseconds) */
3
+ export declare const TOKEN_CACHE_DURATION_MS: number;
4
+ /**
5
+ * Manages GitHub App authentication tokens for multiple organizations.
6
+ * Handles JWT generation, installation discovery, and token caching.
7
+ */
8
+ export declare class GitHubAppTokenManager {
9
+ private readonly appId;
10
+ private readonly privateKey;
11
+ /** Map of "apiHost:owner" -> installation ID */
12
+ private installations;
13
+ /** Set of API hosts that have been discovered */
14
+ private discoveredHosts;
15
+ /** Map of "apiHost:owner" -> cached token */
16
+ private tokenCache;
17
+ constructor(appId: string, privateKey: string);
18
+ /**
19
+ * Generates a JWT for GitHub App authentication.
20
+ * The JWT is signed with RS256 and valid for 10 minutes.
21
+ */
22
+ generateJWT(): string;
23
+ /**
24
+ * Discovers all installations for this GitHub App on the given API host.
25
+ * Stores installations in an internal map for later lookup.
26
+ */
27
+ discoverInstallations(apiHost: string): Promise<void>;
28
+ /**
29
+ * Gets the installation ID for a given owner on the specified API host.
30
+ * Returns undefined if no installation is found.
31
+ */
32
+ getInstallationId(apiHost: string, owner: string): number | undefined;
33
+ /**
34
+ * Gets an installation access token for the given owner.
35
+ * Returns null if no installation is found for the owner.
36
+ * Tokens are cached for 45 minutes.
37
+ */
38
+ getTokenForOwner(apiHost: string, owner: string): Promise<string | null>;
39
+ /**
40
+ * Gets an installation access token for a repository.
41
+ * Automatically discovers installations if not already done for the host.
42
+ * Derives the API host from the repository host.
43
+ */
44
+ getTokenForRepo(repoInfo: GitHubRepoInfo): Promise<string | null>;
45
+ /**
46
+ * FOR TESTING ONLY: Manually expire a cached token.
47
+ */
48
+ _expireCacheForTesting(apiHost: string, owner: string): void;
49
+ }
@@ -0,0 +1,173 @@
1
+ import { createSign } from "node:crypto";
2
+ import { withRetry } from "./retry-utils.js";
3
+ /** Duration to cache tokens (45 minutes in milliseconds) */
4
+ export const TOKEN_CACHE_DURATION_MS = 45 * 60 * 1000;
5
+ /**
6
+ * Manages GitHub App authentication tokens for multiple organizations.
7
+ * Handles JWT generation, installation discovery, and token caching.
8
+ */
9
+ export class GitHubAppTokenManager {
10
+ appId;
11
+ privateKey;
12
+ /** Map of "apiHost:owner" -> installation ID */
13
+ installations = new Map();
14
+ /** Set of API hosts that have been discovered */
15
+ discoveredHosts = new Set();
16
+ /** Map of "apiHost:owner" -> cached token */
17
+ tokenCache = new Map();
18
+ constructor(appId, privateKey) {
19
+ this.appId = appId;
20
+ this.privateKey = privateKey;
21
+ }
22
+ /**
23
+ * Generates a JWT for GitHub App authentication.
24
+ * The JWT is signed with RS256 and valid for 10 minutes.
25
+ */
26
+ generateJWT() {
27
+ const now = Math.floor(Date.now() / 1000);
28
+ const header = {
29
+ alg: "RS256",
30
+ typ: "JWT",
31
+ };
32
+ const payload = {
33
+ iat: now - 60, // Issued 60 seconds ago to account for clock drift
34
+ exp: now + 600, // Expires in 10 minutes
35
+ iss: this.appId,
36
+ };
37
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
38
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
39
+ const signatureInput = `${encodedHeader}.${encodedPayload}`;
40
+ const sign = createSign("RSA-SHA256");
41
+ sign.update(signatureInput);
42
+ const signature = sign.sign(this.privateKey);
43
+ const encodedSignature = base64UrlEncode(signature);
44
+ return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
45
+ }
46
+ /**
47
+ * Discovers all installations for this GitHub App on the given API host.
48
+ * Stores installations in an internal map for later lookup.
49
+ */
50
+ async discoverInstallations(apiHost) {
51
+ const url = `https://${apiHost}/app/installations`;
52
+ const jwt = this.generateJWT();
53
+ const response = await withRetry(async () => {
54
+ const res = await fetch(url, {
55
+ method: "GET",
56
+ headers: {
57
+ Authorization: `Bearer ${jwt}`,
58
+ Accept: "application/vnd.github+json",
59
+ "X-GitHub-Api-Version": "2022-11-28",
60
+ },
61
+ });
62
+ if (!res.ok) {
63
+ const status = res.status;
64
+ // Throw error with status code for retry logic
65
+ const error = new Error(`GitHub API error: ${status}`);
66
+ throw error;
67
+ }
68
+ return res;
69
+ });
70
+ const installations = (await response.json());
71
+ for (const installation of installations) {
72
+ const key = `${apiHost}:${installation.account.login}`;
73
+ this.installations.set(key, installation.id);
74
+ }
75
+ this.discoveredHosts.add(apiHost);
76
+ }
77
+ /**
78
+ * Gets the installation ID for a given owner on the specified API host.
79
+ * Returns undefined if no installation is found.
80
+ */
81
+ getInstallationId(apiHost, owner) {
82
+ const key = `${apiHost}:${owner}`;
83
+ return this.installations.get(key);
84
+ }
85
+ /**
86
+ * Gets an installation access token for the given owner.
87
+ * Returns null if no installation is found for the owner.
88
+ * Tokens are cached for 45 minutes.
89
+ */
90
+ async getTokenForOwner(apiHost, owner) {
91
+ const installationId = this.getInstallationId(apiHost, owner);
92
+ if (installationId === undefined) {
93
+ return null;
94
+ }
95
+ const cacheKey = `${apiHost}:${owner}`;
96
+ // Check cache
97
+ const cached = this.tokenCache.get(cacheKey);
98
+ if (cached && cached.expiresAt > Date.now()) {
99
+ return cached.token;
100
+ }
101
+ // Fetch new token
102
+ const url = `https://${apiHost}/app/installations/${installationId}/access_tokens`;
103
+ const jwt = this.generateJWT();
104
+ const response = await withRetry(async () => {
105
+ const res = await fetch(url, {
106
+ method: "POST",
107
+ headers: {
108
+ Authorization: `Bearer ${jwt}`,
109
+ Accept: "application/vnd.github+json",
110
+ "X-GitHub-Api-Version": "2022-11-28",
111
+ },
112
+ });
113
+ if (!res.ok) {
114
+ const status = res.status;
115
+ const error = new Error(`GitHub API error: ${status}`);
116
+ throw error;
117
+ }
118
+ return res;
119
+ });
120
+ const tokenResponse = (await response.json());
121
+ // Cache the token
122
+ this.tokenCache.set(cacheKey, {
123
+ token: tokenResponse.token,
124
+ expiresAt: Date.now() + TOKEN_CACHE_DURATION_MS,
125
+ });
126
+ return tokenResponse.token;
127
+ }
128
+ /**
129
+ * Gets an installation access token for a repository.
130
+ * Automatically discovers installations if not already done for the host.
131
+ * Derives the API host from the repository host.
132
+ */
133
+ async getTokenForRepo(repoInfo) {
134
+ const apiHost = deriveApiHost(repoInfo.host);
135
+ // Auto-discover if needed
136
+ if (!this.discoveredHosts.has(apiHost)) {
137
+ await this.discoverInstallations(apiHost);
138
+ }
139
+ return this.getTokenForOwner(apiHost, repoInfo.owner);
140
+ }
141
+ /**
142
+ * FOR TESTING ONLY: Manually expire a cached token.
143
+ */
144
+ _expireCacheForTesting(apiHost, owner) {
145
+ const cacheKey = `${apiHost}:${owner}`;
146
+ const cached = this.tokenCache.get(cacheKey);
147
+ if (cached) {
148
+ cached.expiresAt = 0;
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * Encodes data as base64url (no padding).
154
+ */
155
+ function base64UrlEncode(data) {
156
+ const buffer = typeof data === "string" ? Buffer.from(data) : data;
157
+ return buffer
158
+ .toString("base64")
159
+ .replace(/\+/g, "-")
160
+ .replace(/\//g, "_")
161
+ .replace(/=+$/, "");
162
+ }
163
+ /**
164
+ * Derives the GitHub API host from a repository host.
165
+ * - github.com -> api.github.com
166
+ * - ghe.example.com -> ghe.example.com/api/v3
167
+ */
168
+ function deriveApiHost(host) {
169
+ if (host === "github.com") {
170
+ return "api.github.com";
171
+ }
172
+ return `${host}/api/v3`;
173
+ }
@@ -19,6 +19,8 @@ export interface PROptions {
19
19
  prTemplate?: string;
20
20
  /** Optional command executor for shell commands (for testing) */
21
21
  executor?: CommandExecutor;
22
+ /** GitHub App installation token for authentication */
23
+ token?: string;
22
24
  }
23
25
  export interface PRResult {
24
26
  url?: string;
@@ -50,5 +52,7 @@ export interface MergePROptions {
50
52
  retries?: number;
51
53
  /** Optional command executor for shell commands (for testing) */
52
54
  executor?: CommandExecutor;
55
+ /** GitHub App installation token for authentication */
56
+ token?: string;
53
57
  }
54
58
  export declare function mergePR(options: MergePROptions): Promise<MergeResult>;
@@ -97,7 +97,7 @@ export function formatPRTitle(files) {
97
97
  return `chore: sync ${changedFiles.length} config files`;
98
98
  }
99
99
  export async function createPR(options) {
100
- const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, executor, } = options;
100
+ const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, executor, token, } = options;
101
101
  const title = formatPRTitle(files);
102
102
  const body = formatPRBody(files, repoInfo, prTemplate);
103
103
  if (dryRun) {
@@ -116,10 +116,11 @@ export async function createPR(options) {
116
116
  baseBranch,
117
117
  workDir,
118
118
  retries,
119
+ token,
119
120
  });
120
121
  }
121
122
  export async function mergePR(options) {
122
- const { repoInfo, prUrl, mergeConfig, workDir, dryRun, retries, executor } = options;
123
+ const { repoInfo, prUrl, mergeConfig, workDir, dryRun, retries, executor, token, } = options;
123
124
  if (dryRun) {
124
125
  const modeText = mergeConfig.mode === "force"
125
126
  ? "force merge"
@@ -139,5 +140,6 @@ export async function mergePR(options) {
139
140
  config: mergeConfig,
140
141
  workDir,
141
142
  retries,
143
+ token,
142
144
  });
143
145
  }
@@ -43,6 +43,7 @@ export declare class RepositoryProcessor {
43
43
  private readonly log;
44
44
  private retries;
45
45
  private executor;
46
+ private readonly tokenManager;
46
47
  /**
47
48
  * Creates a new RepositoryProcessor.
48
49
  * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
@@ -1,15 +1,16 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { convertContentToString, } from "./config.js";
4
- import { getRepoDisplayName } from "./repo-detector.js";
4
+ import { getRepoDisplayName, isGitHubRepo, } from "./repo-detector.js";
5
5
  import { interpolateXfgContent } from "./xfg-template.js";
6
6
  import { GitOps } from "./git-ops.js";
7
7
  import { createPR, mergePR } from "./pr-creator.js";
8
8
  import { logger } from "./logger.js";
9
- import { getPRStrategy, getCommitStrategy } from "./strategies/index.js";
9
+ import { getPRStrategy, getCommitStrategy, hasGitHubAppCredentials, } from "./strategies/index.js";
10
10
  import { defaultExecutor } from "./command-executor.js";
11
11
  import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
12
12
  import { loadManifest, saveManifest, updateManifest, MANIFEST_FILENAME, } from "./manifest.js";
13
+ import { GitHubAppTokenManager } from "./github-app-token-manager.js";
13
14
  /**
14
15
  * Determines if a file should be marked as executable.
15
16
  * .sh files are auto-executable unless explicit executable: false is set.
@@ -30,6 +31,7 @@ export class RepositoryProcessor {
30
31
  log;
31
32
  retries = 3;
32
33
  executor = defaultExecutor;
34
+ tokenManager;
33
35
  /**
34
36
  * Creates a new RepositoryProcessor.
35
37
  * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
@@ -38,12 +40,40 @@ export class RepositoryProcessor {
38
40
  constructor(gitOpsFactory, log) {
39
41
  this.gitOpsFactory = gitOpsFactory ?? ((opts) => new GitOps(opts));
40
42
  this.log = log ?? logger;
43
+ // Initialize GitHub App token manager if credentials are configured
44
+ if (hasGitHubAppCredentials()) {
45
+ this.tokenManager = new GitHubAppTokenManager(process.env.XFG_GITHUB_APP_ID, process.env.XFG_GITHUB_APP_PRIVATE_KEY);
46
+ }
47
+ else {
48
+ this.tokenManager = null;
49
+ }
41
50
  }
42
51
  async process(repoConfig, repoInfo, options) {
43
52
  const repoName = getRepoDisplayName(repoInfo);
44
53
  const { branchName, workDir, dryRun, prTemplate } = options;
45
54
  this.retries = options.retries ?? 3;
46
55
  this.executor = options.executor ?? defaultExecutor;
56
+ // For GitHub repos with token manager, get installation token
57
+ let token;
58
+ if (this.tokenManager && isGitHubRepo(repoInfo)) {
59
+ try {
60
+ const tokenResult = await this.tokenManager.getTokenForRepo(repoInfo);
61
+ if (tokenResult === null) {
62
+ // No installation found for this owner - skip the repo
63
+ return {
64
+ success: true,
65
+ repoName,
66
+ message: `No GitHub App installation found for ${repoInfo.owner}`,
67
+ skipped: true,
68
+ };
69
+ }
70
+ token = tokenResult;
71
+ }
72
+ catch (error) {
73
+ // Token retrieval failed - continue without token and let auth fail naturally
74
+ this.log.info(`Warning: Failed to get GitHub App token: ${error instanceof Error ? error.message : String(error)}`);
75
+ }
76
+ }
47
77
  this.gitOps = this.gitOpsFactory({
48
78
  workDir,
49
79
  dryRun,
@@ -78,6 +108,7 @@ export class RepositoryProcessor {
78
108
  baseBranch,
79
109
  workDir,
80
110
  retries: this.retries,
111
+ token,
81
112
  });
82
113
  if (closed) {
83
114
  this.log.info("Closed existing PR and deleted branch for fresh sync");
@@ -299,6 +330,7 @@ export class RepositoryProcessor {
299
330
  retries: this.retries,
300
331
  // Use force push (--force-with-lease) for PR branches, not for direct mode
301
332
  force: !isDirectMode,
333
+ token,
302
334
  });
303
335
  this.log.info(`Committed: ${commitResult.sha} (verified: ${commitResult.verified})`);
304
336
  }
@@ -341,6 +373,7 @@ export class RepositoryProcessor {
341
373
  retries: this.retries,
342
374
  prTemplate,
343
375
  executor: this.executor,
376
+ token,
344
377
  });
345
378
  // Step 10: Handle merge options if configured
346
379
  let mergeResult;
@@ -360,6 +393,7 @@ export class RepositoryProcessor {
360
393
  dryRun,
361
394
  retries: this.retries,
362
395
  executor: this.executor,
396
+ token,
363
397
  });
364
398
  mergeResult = {
365
399
  merged: result.merged ?? false,
@@ -1,11 +1,17 @@
1
1
  import { RepoInfo } from "../repo-detector.js";
2
2
  import { CommitStrategy } from "./commit-strategy.js";
3
3
  import { CommandExecutor } from "../command-executor.js";
4
+ /**
5
+ * Checks if GitHub App credentials are configured via environment variables.
6
+ * Both XFG_GITHUB_APP_ID and XFG_GITHUB_APP_PRIVATE_KEY must be set.
7
+ */
8
+ export declare function hasGitHubAppCredentials(): boolean;
4
9
  /**
5
10
  * Factory function to get the appropriate commit strategy for a repository.
6
11
  *
7
- * For GitHub repositories with GH_INSTALLATION_TOKEN set, returns GraphQLCommitStrategy
8
- * which creates verified commits via the GitHub GraphQL API.
12
+ * For GitHub repositories with GitHub App credentials (XFG_GITHUB_APP_ID and
13
+ * XFG_GITHUB_APP_PRIVATE_KEY), returns GraphQLCommitStrategy which creates
14
+ * verified commits via the GitHub GraphQL API.
9
15
  *
10
16
  * For all other cases (GitHub with PAT, Azure DevOps, GitLab), returns GitCommitStrategy
11
17
  * which uses standard git commands.
@@ -1,11 +1,19 @@
1
1
  import { isGitHubRepo } from "../repo-detector.js";
2
2
  import { GitCommitStrategy } from "./git-commit-strategy.js";
3
3
  import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
4
+ /**
5
+ * Checks if GitHub App credentials are configured via environment variables.
6
+ * Both XFG_GITHUB_APP_ID and XFG_GITHUB_APP_PRIVATE_KEY must be set.
7
+ */
8
+ export function hasGitHubAppCredentials() {
9
+ return !!(process.env.XFG_GITHUB_APP_ID && process.env.XFG_GITHUB_APP_PRIVATE_KEY);
10
+ }
4
11
  /**
5
12
  * Factory function to get the appropriate commit strategy for a repository.
6
13
  *
7
- * For GitHub repositories with GH_INSTALLATION_TOKEN set, returns GraphQLCommitStrategy
8
- * which creates verified commits via the GitHub GraphQL API.
14
+ * For GitHub repositories with GitHub App credentials (XFG_GITHUB_APP_ID and
15
+ * XFG_GITHUB_APP_PRIVATE_KEY), returns GraphQLCommitStrategy which creates
16
+ * verified commits via the GitHub GraphQL API.
9
17
  *
10
18
  * For all other cases (GitHub with PAT, Azure DevOps, GitLab), returns GitCommitStrategy
11
19
  * which uses standard git commands.
@@ -14,7 +22,7 @@ import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
14
22
  * @param executor - Optional command executor for shell commands
15
23
  */
16
24
  export function getCommitStrategy(repoInfo, executor) {
17
- if (isGitHubRepo(repoInfo) && process.env.GH_INSTALLATION_TOKEN) {
25
+ if (isGitHubRepo(repoInfo) && hasGitHubAppCredentials()) {
18
26
  return new GraphQLCommitStrategy(executor);
19
27
  }
20
28
  return new GitCommitStrategy(executor);
@@ -12,6 +12,8 @@ export interface CommitOptions {
12
12
  retries?: number;
13
13
  /** Use force push (--force-with-lease). Default: true for PR branches, false for direct push to main. */
14
14
  force?: boolean;
15
+ /** GitHub App installation token for authentication (used by GraphQLCommitStrategy) */
16
+ token?: string;
15
17
  }
16
18
  export interface CommitResult {
17
19
  sha: string;
@@ -8,7 +8,7 @@ export declare class GitHubPRStrategy extends BasePRStrategy {
8
8
  /**
9
9
  * Check if auto-merge is enabled on the repository.
10
10
  */
11
- checkAutoMergeEnabled(repoInfo: GitHubRepoInfo, workDir: string, retries?: number): Promise<boolean>;
11
+ checkAutoMergeEnabled(repoInfo: GitHubRepoInfo, workDir: string, retries?: number, token?: string): Promise<boolean>;
12
12
  /**
13
13
  * Build merge strategy flag for gh pr merge command.
14
14
  */
@@ -38,14 +38,21 @@ function buildPRUrlRegex(host) {
38
38
  const escapedHost = escapeRegExp(host);
39
39
  return new RegExp(`https://${escapedHost}/[\\w-]+/[\\w.-]+/pull/\\d+`);
40
40
  }
41
+ /**
42
+ * Build the GH_TOKEN environment prefix for commands if a token is provided.
43
+ */
44
+ function buildTokenPrefix(token) {
45
+ return token ? `GH_TOKEN=${token} ` : "";
46
+ }
41
47
  export class GitHubPRStrategy extends BasePRStrategy {
42
48
  async checkExistingPR(options) {
43
- const { repoInfo, branchName, workDir, retries = 3 } = options;
49
+ const { repoInfo, branchName, workDir, retries = 3, token } = options;
44
50
  if (!isGitHubRepo(repoInfo)) {
45
51
  throw new Error("Expected GitHub repository");
46
52
  }
47
53
  const repoFlag = getRepoFlag(repoInfo);
48
- const command = `gh pr list --repo ${escapeShellArg(repoFlag)} --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
54
+ const tokenPrefix = buildTokenPrefix(token);
55
+ const command = `${tokenPrefix}gh pr list --repo ${escapeShellArg(repoFlag)} --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
49
56
  try {
50
57
  const existingPR = await withRetry(() => this.executor.exec(command, workDir), { retries });
51
58
  return existingPR || null;
@@ -66,11 +73,11 @@ export class GitHubPRStrategy extends BasePRStrategy {
66
73
  }
67
74
  }
68
75
  async closeExistingPR(options) {
69
- const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
76
+ const { repoInfo, branchName, baseBranch, workDir, retries = 3, token, } = options;
70
77
  if (!isGitHubRepo(repoInfo)) {
71
78
  throw new Error("Expected GitHub repository");
72
79
  }
73
- // First check if there's an existing PR
80
+ // First check if there's an existing PR (pass token through)
74
81
  const existingUrl = await this.checkExistingPR({
75
82
  repoInfo,
76
83
  branchName,
@@ -79,6 +86,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
79
86
  retries,
80
87
  title: "", // Not used for check
81
88
  body: "", // Not used for check
89
+ token,
82
90
  });
83
91
  if (!existingUrl) {
84
92
  return false;
@@ -89,8 +97,10 @@ export class GitHubPRStrategy extends BasePRStrategy {
89
97
  throw new Error(`Could not extract PR number from URL: ${existingUrl}`);
90
98
  }
91
99
  // Close the PR and delete the branch
100
+ // Token is passed via GH_TOKEN env prefix for gh CLI authentication
92
101
  const repoFlag = getRepoFlag(repoInfo);
93
- const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoFlag)} --delete-branch`;
102
+ const tokenPrefix = buildTokenPrefix(token);
103
+ const command = `${tokenPrefix}gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoFlag)} --delete-branch`;
94
104
  try {
95
105
  await withRetry(() => this.executor.exec(command, workDir), { retries });
96
106
  return true;
@@ -102,14 +112,16 @@ export class GitHubPRStrategy extends BasePRStrategy {
102
112
  }
103
113
  }
104
114
  async create(options) {
105
- const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
115
+ const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, token, } = options;
106
116
  if (!isGitHubRepo(repoInfo)) {
107
117
  throw new Error("Expected GitHub repository");
108
118
  }
109
119
  // Write body to temp file to avoid shell escaping issues
110
120
  const bodyFile = join(workDir, this.bodyFilePath);
111
121
  writeFileSync(bodyFile, body, "utf-8");
112
- const command = `gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
122
+ // Token is passed via GH_TOKEN env prefix for gh CLI authentication
123
+ const tokenPrefix = buildTokenPrefix(token);
124
+ const command = `${tokenPrefix}gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
113
125
  try {
114
126
  const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
115
127
  // Extract URL from output - use strict regex for valid PR URLs only
@@ -140,10 +152,12 @@ export class GitHubPRStrategy extends BasePRStrategy {
140
152
  /**
141
153
  * Check if auto-merge is enabled on the repository.
142
154
  */
143
- async checkAutoMergeEnabled(repoInfo, workDir, retries = 3) {
155
+ async checkAutoMergeEnabled(repoInfo, workDir, retries = 3, token) {
144
156
  const hostnameFlag = getHostnameFlag(repoInfo);
145
157
  const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
146
- const command = `gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
158
+ // Token is passed via GH_TOKEN env prefix for gh CLI authentication
159
+ const tokenPrefix = buildTokenPrefix(token);
160
+ const command = `${tokenPrefix}gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
147
161
  try {
148
162
  const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
149
163
  return result.trim() === "true";
@@ -169,7 +183,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
169
183
  }
170
184
  }
171
185
  async merge(options) {
172
- const { prUrl, config, workDir, retries = 3 } = options;
186
+ const { prUrl, config, workDir, retries = 3, token } = options;
173
187
  // Manual mode: do nothing
174
188
  if (config.mode === "manual") {
175
189
  return {
@@ -180,6 +194,8 @@ export class GitHubPRStrategy extends BasePRStrategy {
180
194
  }
181
195
  const strategyFlag = this.getMergeStrategyFlag(config.strategy);
182
196
  const deleteBranchFlag = config.deleteBranch ? "--delete-branch" : "";
197
+ // Token is passed via GH_TOKEN env prefix for gh CLI authentication
198
+ const tokenPrefix = buildTokenPrefix(token);
183
199
  if (config.mode === "auto") {
184
200
  // Check if auto-merge is enabled on the repo
185
201
  // Extract host/owner/repo from PR URL (supports both github.com and GHE)
@@ -192,7 +208,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
192
208
  repo: match[3],
193
209
  host: match[1],
194
210
  };
195
- const autoMergeEnabled = await this.checkAutoMergeEnabled(repoInfo, workDir, retries);
211
+ const autoMergeEnabled = await this.checkAutoMergeEnabled(repoInfo, workDir, retries, token);
196
212
  if (!autoMergeEnabled) {
197
213
  logger.info(`Warning: Auto-merge not enabled for '${repoInfo.owner}/${repoInfo.repo}'. PR left open for manual review.`);
198
214
  logger.info(`To enable: gh repo edit ${getRepoFlag(repoInfo)} --enable-auto-merge (requires admin)`);
@@ -205,7 +221,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
205
221
  }
206
222
  }
207
223
  // Enable auto-merge
208
- const command = `gh pr merge ${escapeShellArg(prUrl)} --auto ${strategyFlag} ${deleteBranchFlag}`.trim();
224
+ const command = `${tokenPrefix}gh pr merge ${escapeShellArg(prUrl)} --auto ${strategyFlag} ${deleteBranchFlag}`.trim();
209
225
  try {
210
226
  await withRetry(() => this.executor.exec(command, workDir), {
211
227
  retries,
@@ -228,7 +244,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
228
244
  }
229
245
  if (config.mode === "force") {
230
246
  // Force merge using admin privileges
231
- const command = `gh pr merge ${escapeShellArg(prUrl)} --admin ${strategyFlag} ${deleteBranchFlag}`.trim();
247
+ const command = `${tokenPrefix}gh pr merge ${escapeShellArg(prUrl)} --admin ${strategyFlag} ${deleteBranchFlag}`.trim();
232
248
  try {
233
249
  await withRetry(() => this.executor.exec(command, workDir), {
234
250
  retries,
@@ -49,7 +49,7 @@ export class GraphQLCommitStrategy {
49
49
  * @throws Error if repo is not GitHub, payload exceeds 50MB, or API fails
50
50
  */
51
51
  async commit(options) {
52
- const { repoInfo, branchName, message, fileChanges, workDir, retries = 3, } = options;
52
+ const { repoInfo, branchName, message, fileChanges, workDir, retries = 3, token, } = options;
53
53
  // Validate this is a GitHub repo
54
54
  if (!isGitHubRepo(repoInfo)) {
55
55
  throw new Error(`GraphQL commit strategy requires GitHub repositories. Got: ${repoInfo.type}`);
@@ -83,7 +83,7 @@ export class GraphQLCommitStrategy {
83
83
  // Get the remote HEAD SHA for this branch (not local HEAD)
84
84
  const headSha = await this.executor.exec(`git rev-parse origin/${branchName}`, workDir);
85
85
  // Build and execute the GraphQL mutation
86
- const result = await this.executeGraphQLMutation(githubInfo, branchName, message, headSha.trim(), additions, deletions, workDir);
86
+ const result = await this.executeGraphQLMutation(githubInfo, branchName, message, headSha.trim(), additions, deletions, workDir, token);
87
87
  return result;
88
88
  }
89
89
  catch (error) {
@@ -103,7 +103,7 @@ export class GraphQLCommitStrategy {
103
103
  /**
104
104
  * Execute the createCommitOnBranch GraphQL mutation.
105
105
  */
106
- async executeGraphQLMutation(repoInfo, branchName, message, expectedHeadOid, additions, deletions, workDir) {
106
+ async executeGraphQLMutation(repoInfo, branchName, message, expectedHeadOid, additions, deletions, workDir, token) {
107
107
  const repositoryNameWithOwner = `${repoInfo.owner}/${repoInfo.repo}`;
108
108
  // Build file additions with base64 encoding
109
109
  const fileAdditions = additions.map((fc) => ({
@@ -149,13 +149,10 @@ export class GraphQLCommitStrategy {
149
149
  const hostnameArg = repoInfo.host !== "github.com"
150
150
  ? `--hostname ${escapeShellArg(repoInfo.host)}`
151
151
  : "";
152
- // Use GH_INSTALLATION_TOKEN explicitly for authentication (issue #268)
152
+ // Use token parameter for authentication when provided
153
153
  // This ensures the GitHub App is used as the commit author, not github-actions[bot]
154
154
  // The token is passed via Authorization header rather than relying on GH_TOKEN env var
155
- const installationToken = process.env.GH_INSTALLATION_TOKEN;
156
- const authArg = installationToken
157
- ? `-H "Authorization: token ${installationToken}"`
158
- : "";
155
+ const authArg = token ? `-H "Authorization: token ${token}"` : "";
159
156
  const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${authArg} ${hostnameArg} --input -`;
160
157
  const response = await this.executor.exec(command, workDir);
161
158
  // Parse the response
@@ -9,7 +9,7 @@ export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
9
9
  export type { CommitStrategy, CommitOptions, CommitResult, FileChange, } from "./commit-strategy.js";
10
10
  export { GitCommitStrategy } from "./git-commit-strategy.js";
11
11
  export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
12
- export { getCommitStrategy } from "./commit-strategy-selector.js";
12
+ export { getCommitStrategy, hasGitHubAppCredentials, } from "./commit-strategy-selector.js";
13
13
  /**
14
14
  * Factory function to get the appropriate PR strategy for a repository.
15
15
  * @param repoInfo - Repository information
@@ -8,7 +8,7 @@ export { AzurePRStrategy } from "./azure-pr-strategy.js";
8
8
  export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
9
9
  export { GitCommitStrategy } from "./git-commit-strategy.js";
10
10
  export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
11
- export { getCommitStrategy } from "./commit-strategy-selector.js";
11
+ export { getCommitStrategy, hasGitHubAppCredentials, } from "./commit-strategy-selector.js";
12
12
  /**
13
13
  * Factory function to get the appropriate PR strategy for a repository.
14
14
  * @param repoInfo - Repository information
@@ -23,12 +23,16 @@ export interface PRStrategyOptions {
23
23
  workDir: string;
24
24
  /** Number of retries for API operations (default: 3) */
25
25
  retries?: number;
26
+ /** GitHub App installation token for authentication */
27
+ token?: string;
26
28
  }
27
29
  export interface MergeOptions {
28
30
  prUrl: string;
29
31
  config: PRMergeConfig;
30
32
  workDir: string;
31
33
  retries?: number;
34
+ /** GitHub App installation token for authentication */
35
+ token?: string;
32
36
  }
33
37
  /**
34
38
  * Options for closing an existing PR.
@@ -39,6 +43,8 @@ export interface CloseExistingPROptions {
39
43
  baseBranch: string;
40
44
  workDir: string;
41
45
  retries?: number;
46
+ /** GitHub App installation token for authentication */
47
+ token?: string;
42
48
  }
43
49
  /**
44
50
  * Interface for PR creation strategies (platform-specific implementations).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "2.2.1",
3
+ "version": "3.0.0",
4
4
  "description": "CLI tool to sync JSON, JSON5, YAML, or text configuration files across multiple Git repositories via pull requests or direct push",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",