@aspruyt/xfg 4.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +1 -2
  2. package/dist/cli/index.d.ts +1 -2
  3. package/dist/cli/index.js +0 -1
  4. package/dist/cli/program.js +7 -2
  5. package/dist/cli/{settings/results-collector.d.ts → results-collector.d.ts} +1 -1
  6. package/dist/cli/{settings/results-collector.js → results-collector.js} +2 -1
  7. package/dist/cli/settings-report-builder.d.ts +1 -3
  8. package/dist/cli/sync-command.d.ts +2 -24
  9. package/dist/cli/sync-command.js +295 -301
  10. package/dist/cli/types.d.ts +60 -40
  11. package/dist/cli/types.js +1 -12
  12. package/dist/config/errors.d.ts +9 -0
  13. package/dist/config/errors.js +11 -0
  14. package/dist/config/file-reference-resolver.d.ts +2 -1
  15. package/dist/config/file-reference-resolver.js +10 -8
  16. package/dist/config/formatter.d.ts +3 -2
  17. package/dist/config/index.d.ts +4 -6
  18. package/dist/config/index.js +4 -8
  19. package/dist/config/loader.js +4 -2
  20. package/dist/config/merge.d.ts +0 -9
  21. package/dist/config/merge.js +2 -7
  22. package/dist/config/normalizer.d.ts +4 -0
  23. package/dist/config/normalizer.js +61 -110
  24. package/dist/config/types.d.ts +15 -19
  25. package/dist/config/types.js +1 -1
  26. package/dist/config/validator.d.ts +0 -4
  27. package/dist/config/validator.js +286 -363
  28. package/dist/config/validators/file-validator.d.ts +2 -8
  29. package/dist/config/validators/file-validator.js +6 -17
  30. package/dist/config/validators/index.d.ts +3 -3
  31. package/dist/config/validators/index.js +3 -3
  32. package/dist/config/validators/repo-settings-validator.d.ts +0 -6
  33. package/dist/config/validators/repo-settings-validator.js +9 -9
  34. package/dist/config/validators/ruleset-validator.d.ts +0 -14
  35. package/dist/config/validators/ruleset-validator.js +28 -28
  36. package/dist/lifecycle/ado-migration-source.js +2 -1
  37. package/dist/lifecycle/github-lifecycle-provider.d.ts +6 -5
  38. package/dist/lifecycle/github-lifecycle-provider.js +79 -90
  39. package/dist/lifecycle/index.d.ts +2 -6
  40. package/dist/lifecycle/index.js +0 -4
  41. package/dist/lifecycle/lifecycle-formatter.d.ts +2 -1
  42. package/dist/lifecycle/lifecycle-formatter.js +4 -0
  43. package/dist/lifecycle/lifecycle-helpers.d.ts +3 -2
  44. package/dist/lifecycle/repo-lifecycle-manager.js +4 -11
  45. package/dist/lifecycle/types.d.ts +0 -8
  46. package/dist/output/github-summary.d.ts +5 -0
  47. package/dist/output/github-summary.js +9 -2
  48. package/dist/output/index.d.ts +2 -2
  49. package/dist/output/index.js +1 -1
  50. package/dist/output/lifecycle-report.js +5 -23
  51. package/dist/output/settings-report.d.ts +14 -3
  52. package/dist/output/settings-report.js +137 -197
  53. package/dist/output/summary-utils.d.ts +1 -1
  54. package/dist/output/summary-utils.js +2 -1
  55. package/dist/output/sync-report.js +5 -8
  56. package/dist/output/unified-summary.d.ts +2 -1
  57. package/dist/output/unified-summary.js +71 -133
  58. package/dist/settings/base-processor.d.ts +67 -0
  59. package/dist/settings/base-processor.js +91 -0
  60. package/dist/settings/index.d.ts +4 -3
  61. package/dist/settings/index.js +3 -3
  62. package/dist/settings/labels/converter.d.ts +2 -1
  63. package/dist/settings/labels/github-labels-strategy.d.ts +9 -18
  64. package/dist/settings/labels/github-labels-strategy.js +17 -73
  65. package/dist/settings/labels/index.d.ts +2 -6
  66. package/dist/settings/labels/index.js +1 -9
  67. package/dist/settings/labels/processor.d.ts +6 -30
  68. package/dist/settings/labels/processor.js +62 -152
  69. package/dist/settings/labels/types.d.ts +5 -8
  70. package/dist/settings/repo-settings/formatter.d.ts +2 -2
  71. package/dist/settings/repo-settings/formatter.js +6 -6
  72. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +11 -12
  73. package/dist/settings/repo-settings/github-repo-settings-strategy.js +32 -79
  74. package/dist/settings/repo-settings/index.d.ts +2 -5
  75. package/dist/settings/repo-settings/index.js +1 -9
  76. package/dist/settings/repo-settings/processor.d.ts +6 -27
  77. package/dist/settings/repo-settings/processor.js +51 -104
  78. package/dist/settings/repo-settings/types.d.ts +7 -9
  79. package/dist/settings/rulesets/diff-algorithm.d.ts +0 -4
  80. package/dist/settings/rulesets/diff-algorithm.js +1 -10
  81. package/dist/settings/rulesets/diff.d.ts +1 -1
  82. package/dist/settings/rulesets/diff.js +2 -21
  83. package/dist/settings/rulesets/formatter.d.ts +1 -3
  84. package/dist/settings/rulesets/formatter.js +1 -8
  85. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +11 -51
  86. package/dist/settings/rulesets/github-ruleset-strategy.js +24 -85
  87. package/dist/settings/rulesets/index.d.ts +3 -6
  88. package/dist/settings/rulesets/index.js +5 -9
  89. package/dist/settings/rulesets/processor.d.ts +8 -33
  90. package/dist/settings/rulesets/processor.js +58 -151
  91. package/dist/settings/rulesets/types.d.ts +35 -6
  92. package/dist/shared/command-executor.d.ts +2 -22
  93. package/dist/shared/command-executor.js +8 -7
  94. package/dist/shared/env.d.ts +0 -8
  95. package/dist/shared/env.js +14 -70
  96. package/dist/shared/file-status.d.ts +2 -0
  97. package/dist/shared/file-status.js +13 -0
  98. package/dist/shared/gh-api-utils.d.ts +46 -0
  99. package/dist/shared/gh-api-utils.js +107 -0
  100. package/dist/shared/index.d.ts +5 -5
  101. package/dist/shared/index.js +3 -3
  102. package/dist/shared/interpolation-engine.d.ts +31 -0
  103. package/dist/shared/interpolation-engine.js +50 -0
  104. package/dist/shared/logger.d.ts +3 -7
  105. package/dist/shared/logger.js +4 -1
  106. package/dist/shared/repo-detector.d.ts +17 -2
  107. package/dist/shared/repo-detector.js +27 -0
  108. package/dist/shared/retry-utils.d.ts +9 -17
  109. package/dist/shared/retry-utils.js +22 -28
  110. package/dist/shared/sanitize-utils.d.ts +0 -7
  111. package/dist/shared/sanitize-utils.js +0 -7
  112. package/dist/shared/shell-utils.d.ts +1 -0
  113. package/dist/shared/shell-utils.js +3 -0
  114. package/dist/shared/string-utils.d.ts +4 -0
  115. package/dist/shared/string-utils.js +6 -0
  116. package/dist/shared/type-guards.d.ts +17 -0
  117. package/dist/shared/type-guards.js +26 -0
  118. package/dist/shared/workspace-utils.d.ts +0 -4
  119. package/dist/shared/workspace-utils.js +0 -4
  120. package/dist/{sync → shared}/xfg-template.d.ts +3 -2
  121. package/dist/{sync → shared}/xfg-template.js +13 -54
  122. package/dist/sync/auth-options-builder.d.ts +4 -5
  123. package/dist/sync/auth-options-builder.js +15 -26
  124. package/dist/sync/branch-manager.d.ts +5 -0
  125. package/dist/sync/branch-manager.js +12 -10
  126. package/dist/sync/commit-push-manager.d.ts +1 -1
  127. package/dist/sync/commit-push-manager.js +22 -18
  128. package/dist/sync/diff-utils.d.ts +4 -9
  129. package/dist/sync/diff-utils.js +2 -19
  130. package/dist/sync/file-sync-orchestrator.js +9 -8
  131. package/dist/sync/file-writer.d.ts +2 -1
  132. package/dist/sync/file-writer.js +3 -6
  133. package/dist/sync/index.d.ts +2 -15
  134. package/dist/sync/index.js +0 -19
  135. package/dist/sync/manifest-manager.d.ts +4 -0
  136. package/dist/sync/manifest-manager.js +5 -1
  137. package/dist/sync/manifest.d.ts +10 -41
  138. package/dist/sync/manifest.js +11 -56
  139. package/dist/sync/pr-merge-handler.d.ts +2 -6
  140. package/dist/sync/pr-merge-handler.js +6 -3
  141. package/dist/sync/repository-processor.d.ts +1 -2
  142. package/dist/sync/repository-processor.js +20 -12
  143. package/dist/sync/repository-session.js +5 -14
  144. package/dist/sync/sync-workflow.js +31 -38
  145. package/dist/sync/types.d.ts +43 -178
  146. package/dist/vcs/authenticated-git-ops.d.ts +27 -70
  147. package/dist/vcs/authenticated-git-ops.js +70 -96
  148. package/dist/vcs/azure-pr-strategy.d.ts +6 -4
  149. package/dist/vcs/azure-pr-strategy.js +34 -82
  150. package/dist/vcs/branch-utils.d.ts +6 -0
  151. package/dist/vcs/branch-utils.js +29 -0
  152. package/dist/vcs/commit-strategy-selector.d.ts +5 -0
  153. package/dist/vcs/commit-strategy-selector.js +10 -0
  154. package/dist/vcs/git-commit-strategy.js +1 -2
  155. package/dist/vcs/git-ops.d.ts +15 -59
  156. package/dist/vcs/git-ops.js +46 -110
  157. package/dist/vcs/github-app-token-manager.d.ts +0 -6
  158. package/dist/vcs/github-app-token-manager.js +5 -12
  159. package/dist/vcs/github-pr-strategy.d.ts +5 -5
  160. package/dist/vcs/github-pr-strategy.js +44 -122
  161. package/dist/vcs/gitlab-pr-strategy.d.ts +6 -4
  162. package/dist/vcs/gitlab-pr-strategy.js +39 -87
  163. package/dist/vcs/graphql-commit-strategy.d.ts +3 -4
  164. package/dist/vcs/graphql-commit-strategy.js +31 -63
  165. package/dist/vcs/index.d.ts +3 -16
  166. package/dist/vcs/index.js +2 -33
  167. package/dist/vcs/pr-creator.d.ts +9 -9
  168. package/dist/vcs/pr-creator.js +11 -10
  169. package/dist/vcs/pr-strategy-factory.d.ts +5 -0
  170. package/dist/vcs/pr-strategy-factory.js +17 -0
  171. package/dist/vcs/pr-strategy.d.ts +13 -26
  172. package/dist/vcs/pr-strategy.js +20 -25
  173. package/dist/vcs/types.d.ts +87 -21
  174. package/package.json +2 -1
@@ -1,8 +1,10 @@
1
1
  import { escapeShellArg } from "../shared/shell-utils.js";
2
2
  import { defaultExecutor, } from "../shared/command-executor.js";
3
3
  import { withRetry, DEFAULT_PERMANENT_ERROR_PATTERNS, } from "../shared/retry-utils.js";
4
- import { isGitHubRepo, } from "../shared/repo-detector.js";
4
+ import { assertGitHubRepo, } from "../shared/repo-detector.js";
5
5
  import { logger } from "../shared/logger.js";
6
+ import { toErrorMessage } from "../shared/type-guards.js";
7
+ import { buildTokenEnv, getHostnameFlag } from "../shared/gh-api-utils.js";
6
8
  /**
7
9
  * Error messages that indicate "repo not found" vs actual errors.
8
10
  */
@@ -15,25 +17,21 @@ const REPO_NOT_FOUND_PATTERNS = [
15
17
  * Check if an error indicates repo not found (vs network/auth error).
16
18
  */
17
19
  function isRepoNotFoundError(error) {
18
- const message = error instanceof Error
19
- ? error.message + (error.stderr ?? "")
20
- : String(error);
20
+ const message = toErrorMessage(error) +
21
+ ((error instanceof Error
22
+ ? error.stderr
23
+ : undefined) ?? "");
21
24
  return REPO_NOT_FOUND_PATTERNS.some((pattern) => message.includes(pattern));
22
25
  }
23
- /**
24
- * Get the hostname flag for gh commands.
25
- * Returns "--hostname HOST" for GHE, empty string for github.com.
26
- */
27
- function getHostnameFlag(repoInfo) {
28
- if (repoInfo.host && repoInfo.host !== "github.com") {
29
- return `--hostname ${escapeShellArg(repoInfo.host)}`;
30
- }
31
- return "";
32
- }
33
26
  /**
34
27
  * Default timeout for waiting for fork readiness (60 seconds).
35
28
  */
36
29
  const FORK_READY_TIMEOUT_MS = 60_000;
30
+ /**
31
+ * After repo creation, GitHub may return 404 due to eventual consistency.
32
+ * Exclude 404/not-found from permanent errors so withRetry retries them.
33
+ */
34
+ const POST_CREATE_PERMANENT_PATTERNS = DEFAULT_PERMANENT_ERROR_PATTERNS.filter((p) => !p.test("404 Not Found"));
37
35
  /**
38
36
  * Interval between fork readiness checks (2 seconds).
39
37
  */
@@ -58,49 +56,47 @@ export class GitHubLifecycleProvider {
58
56
  * Uses gh api to query the user/org endpoint.
59
57
  */
60
58
  async isOrganization(owner, repoInfo, token) {
61
- const tokenPrefix = this.buildTokenPrefix(token);
62
- const hostnameFlag = getHostnameFlag(repoInfo);
63
- const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
64
- const command = `${tokenPrefix}gh api ${hostnamePart}users/${escapeShellArg(owner)}`;
59
+ const { tokenEnv, prefix } = this.buildGhApiPrefix(repoInfo, token);
60
+ const command = `${prefix}users/${escapeShellArg(owner)}`;
65
61
  try {
66
- const stdout = await withRetry(() => this.executor.exec(command, this.cwd), { retries: this.retries });
62
+ const stdout = await withRetry(() => this.executor.exec(command, this.cwd, { env: tokenEnv }), { retries: this.retries });
67
63
  const data = JSON.parse(stdout);
68
64
  return data.type === "Organization";
69
65
  }
70
66
  catch (error) {
71
67
  // If we can't determine, assume it's an org (safer - uses --org flag).
72
68
  // This may cause fork to fail with a misleading error for personal accounts.
73
- const errMsg = error instanceof Error ? error.message : String(error);
69
+ const errMsg = toErrorMessage(error);
74
70
  logger.debug(`Could not determine if '${owner}' is an organization, defaulting to org behavior: ${errMsg}`);
75
- logger.info(`Warning: Could not verify if '${owner}' is an organization or user account. ` +
71
+ logger.warn(`Could not verify if '${owner}' is an organization or user account. ` +
76
72
  `If fork fails, check your authentication (gh auth status) and ensure the ` +
77
73
  `target owner is correct.`);
78
74
  return true;
79
75
  }
80
76
  }
81
77
  assertGitHub(repoInfo) {
82
- if (!isGitHubRepo(repoInfo)) {
83
- throw new Error(`GitHubLifecycleProvider requires GitHub repo, got: ${repoInfo.type}`);
84
- }
78
+ assertGitHubRepo(repoInfo, "GitHubLifecycleProvider");
85
79
  }
86
80
  /**
87
- * Build GH_TOKEN prefix for gh CLI commands.
88
- * Returns "GH_TOKEN=<escaped_token> " when token is provided, "" otherwise.
89
- * Token is escaped via escapeShellArg to prevent injection.
81
+ * Builds the common gh API command prefix parts for a given repo.
82
+ * Returns tokenEnv for exec options, and the command prefix string
83
+ * (e.g., "gh api --hostname host repos/owner/repo").
90
84
  */
91
- buildTokenPrefix(token) {
92
- return token ? `GH_TOKEN=${escapeShellArg(token)} ` : "";
85
+ buildGhApiPrefix(repoInfo, token) {
86
+ const tokenEnv = buildTokenEnv(token);
87
+ const hostnameFlag = getHostnameFlag(repoInfo);
88
+ const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
89
+ const apiPath = `repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
90
+ return { tokenEnv, prefix: `gh api ${hostnamePart}`, apiPath };
93
91
  }
94
92
  async exists(repoInfo, token) {
95
93
  this.assertGitHub(repoInfo);
96
- const tokenPrefix = this.buildTokenPrefix(token);
97
- const hostnameFlag = getHostnameFlag(repoInfo);
98
- const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
99
- const command = `${tokenPrefix}gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
94
+ const { tokenEnv, prefix, apiPath } = this.buildGhApiPrefix(repoInfo, token);
95
+ const command = `${prefix}${apiPath}`;
100
96
  try {
101
97
  // Note: withRetry already classifies 404/not-found as permanent errors,
102
98
  // so retries are aborted immediately for non-existent repos.
103
- await withRetry(() => this.executor.exec(command, this.cwd), {
99
+ await withRetry(() => this.executor.exec(command, this.cwd, { env: tokenEnv }), {
104
100
  retries: this.retries,
105
101
  });
106
102
  return true;
@@ -116,9 +112,9 @@ export class GitHubLifecycleProvider {
116
112
  }
117
113
  async create(repoInfo, settings, token) {
118
114
  this.assertGitHub(repoInfo);
119
- const tokenPrefix = this.buildTokenPrefix(token);
115
+ const tokenEnv = buildTokenEnv(token);
120
116
  const parts = [
121
- `${tokenPrefix}gh repo create`,
117
+ "gh repo create",
122
118
  escapeShellArg(`${repoInfo.owner}/${repoInfo.repo}`),
123
119
  ];
124
120
  // Visibility flag (default to private for safety)
@@ -146,28 +142,24 @@ export class GitHubLifecycleProvider {
146
142
  // This avoids empty repos where HEAD doesn't resolve.
147
143
  parts.push("--add-readme");
148
144
  const command = parts.join(" ");
149
- await withRetry(() => this.executor.exec(command, this.cwd), {
145
+ await withRetry(() => this.executor.exec(command, this.cwd, { env: tokenEnv }), {
150
146
  retries: this.retries,
151
147
  });
152
148
  // Rename default branch if requested and it differs from what GitHub created.
153
149
  if (settings?.defaultBranch) {
154
- const tokenPrefix = this.buildTokenPrefix(token);
155
- const hostnameFlag = getHostnameFlag(repoInfo);
156
- const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
157
- const apiPath = `repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
158
- // After repo creation, GitHub may return 404 due to eventual consistency.
159
- // Exclude 404/not-found from permanent errors so withRetry retries them.
160
- const postCreatePermanentPatterns = DEFAULT_PERMANENT_ERROR_PATTERNS.filter((p) => !p.test("404 Not Found"));
150
+ const { tokenEnv: branchTokenEnv, prefix, apiPath, } = this.buildGhApiPrefix(repoInfo, token);
161
151
  // Detect the actual default branch name
162
- const actualBranch = (await withRetry(() => this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath} --jq '.default_branch'`, this.cwd), {
152
+ const actualBranch = (await withRetry(() => this.executor.exec(`${prefix}${apiPath} --jq '.default_branch'`, this.cwd, { env: branchTokenEnv }), {
163
153
  retries: this.retries,
164
- permanentErrorPatterns: postCreatePermanentPatterns,
154
+ permanentErrorPatterns: POST_CREATE_PERMANENT_PATTERNS,
165
155
  })).trim();
166
156
  if (actualBranch !== settings.defaultBranch) {
167
157
  await this.renameBranch(repoInfo, actualBranch, settings.defaultBranch, token);
168
158
  // Wait for the rename to propagate — GitHub's API may still report
169
159
  // the old default branch for a few seconds after the rename call.
170
- await this.waitForDefaultBranch(repoInfo, settings.defaultBranch, token);
160
+ await this.waitForDefaultBranch(repoInfo, settings.defaultBranch, {
161
+ token,
162
+ });
171
163
  }
172
164
  }
173
165
  // Delete the README so xfg sync starts from a clean state.
@@ -183,12 +175,12 @@ export class GitHubLifecycleProvider {
183
175
  }
184
176
  // Determine if target owner is an organization or user
185
177
  const isOrg = await this.isOrganization(target.owner, target, token);
186
- const tokenPrefix = this.buildTokenPrefix(token);
178
+ const tokenEnv = buildTokenEnv(token);
187
179
  // Build fork command
188
180
  // For orgs: gh repo fork <upstream> --org <target-org> --fork-name <name> --clone=false
189
181
  // For users: gh repo fork <upstream> --fork-name <name> --clone=false
190
182
  const parts = [
191
- `${tokenPrefix}gh repo fork`,
183
+ "gh repo fork",
192
184
  escapeShellArg(`${upstream.owner}/${upstream.repo}`),
193
185
  ];
194
186
  if (isOrg) {
@@ -196,11 +188,15 @@ export class GitHubLifecycleProvider {
196
188
  }
197
189
  parts.push("--fork-name", escapeShellArg(target.repo), "--clone=false");
198
190
  const forkCommand = parts.join(" ");
199
- await withRetry(() => this.executor.exec(forkCommand, this.cwd), {
191
+ await withRetry(() => this.executor.exec(forkCommand, this.cwd, { env: tokenEnv }), {
200
192
  retries: this.retries,
201
193
  });
202
194
  // GitHub forks are async - wait for the fork to be ready for git operations
203
- await this.waitForForkReady(target, this.forkReadyTimeoutMs, this.forkPollIntervalMs, token);
195
+ await this.waitForForkReady(target, {
196
+ timeoutMs: this.forkReadyTimeoutMs,
197
+ pollMs: this.forkPollIntervalMs,
198
+ token,
199
+ });
204
200
  // Apply settings after fork (visibility, description, etc.)
205
201
  if (settings?.visibility || settings?.description) {
206
202
  await this.applyRepoSettings(target, settings, token);
@@ -210,7 +206,10 @@ export class GitHubLifecycleProvider {
210
206
  * Wait for a forked repo to become available via the GitHub API.
211
207
  * GitHub forks are created asynchronously; polls exists() with a timeout.
212
208
  */
213
- async waitForForkReady(repoInfo, timeoutMs = FORK_READY_TIMEOUT_MS, intervalMs = FORK_POLL_INTERVAL_MS, token) {
209
+ async waitForForkReady(repoInfo, options) {
210
+ const timeoutMs = options?.timeoutMs ?? FORK_READY_TIMEOUT_MS;
211
+ const intervalMs = options?.pollMs ?? FORK_POLL_INTERVAL_MS;
212
+ const token = options?.token;
214
213
  const deadline = Date.now() + timeoutMs;
215
214
  while (Date.now() < deadline) {
216
215
  try {
@@ -219,8 +218,8 @@ export class GitHubLifecycleProvider {
219
218
  return;
220
219
  }
221
220
  }
222
- catch {
223
- // Ignore transient errors during polling
221
+ catch (error) {
222
+ logger.debug(`Polling fork readiness: ${toErrorMessage(error)}`);
224
223
  }
225
224
  const remaining = deadline - Date.now();
226
225
  if (remaining <= 0)
@@ -234,9 +233,9 @@ export class GitHubLifecycleProvider {
234
233
  * Apply settings to an existing repo using gh repo edit.
235
234
  */
236
235
  async applyRepoSettings(repoInfo, settings, token) {
237
- const tokenPrefix = this.buildTokenPrefix(token);
236
+ const tokenEnv = buildTokenEnv(token);
238
237
  const parts = [
239
- `${tokenPrefix}gh repo edit`,
238
+ "gh repo edit",
240
239
  escapeShellArg(`${repoInfo.owner}/${repoInfo.repo}`),
241
240
  ];
242
241
  if (settings.visibility) {
@@ -246,20 +245,20 @@ export class GitHubLifecycleProvider {
246
245
  parts.push("--description", escapeShellArg(settings.description));
247
246
  }
248
247
  const command = parts.join(" ");
249
- await withRetry(() => this.executor.exec(command, this.cwd), {
248
+ await withRetry(() => this.executor.exec(command, this.cwd, { env: tokenEnv }), {
250
249
  retries: this.retries,
251
250
  });
252
251
  }
253
252
  async receiveMigration(repoInfo, sourceDir, settings, token) {
254
253
  this.assertGitHub(repoInfo);
255
- const tokenPrefix = this.buildTokenPrefix(token);
254
+ const tokenEnv = buildTokenEnv(token);
256
255
  // Remove existing "origin" remote if present (e.g., from git clone --mirror).
257
256
  // gh repo create --source --push needs to set its own origin remote.
258
257
  try {
259
258
  await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} remote remove origin`, this.cwd);
260
259
  }
261
- catch {
262
- // No origin remote nothing to remove
260
+ catch (error) {
261
+ logger.debug(`Cleanup: remote remove origin skipped - ${toErrorMessage(error)}`);
263
262
  }
264
263
  // Remove all non-standard refs that GitHub rejects on push.
265
264
  // Mirror clones include ALL refs from the source, but GitHub only
@@ -276,8 +275,8 @@ export class GitHubLifecycleProvider {
276
275
  }
277
276
  }
278
277
  }
279
- catch {
280
- // No refs to remove — ignore
278
+ catch (error) {
279
+ logger.debug(`Cleanup: ref cleanup skipped - ${toErrorMessage(error)}`);
281
280
  }
282
281
  // Rename default branch in mirror clone if requested.
283
282
  if (settings?.defaultBranch) {
@@ -297,7 +296,7 @@ export class GitHubLifecycleProvider {
297
296
  // For bare repos (from git clone --mirror), --push mirrors all refs.
298
297
  // This uses gh CLI authentication, avoiding raw git auth issues with GHE.
299
298
  const parts = [
300
- `${tokenPrefix}gh repo create`,
299
+ "gh repo create",
301
300
  escapeShellArg(`${repoInfo.owner}/${repoInfo.repo}`),
302
301
  "--source",
303
302
  escapeShellArg(sourceDir),
@@ -325,7 +324,7 @@ export class GitHubLifecycleProvider {
325
324
  parts.push("--disable-wiki");
326
325
  }
327
326
  const command = parts.join(" ");
328
- await withRetry(() => this.executor.exec(command, this.cwd), {
327
+ await withRetry(() => this.executor.exec(command, this.cwd, { env: tokenEnv }), {
329
328
  retries: this.retries,
330
329
  });
331
330
  }
@@ -334,12 +333,9 @@ export class GitHubLifecycleProvider {
334
333
  * GitHub automatically updates the default branch pointer.
335
334
  */
336
335
  async renameBranch(repoInfo, current, desired, token) {
337
- const renameTokenPrefix = this.buildTokenPrefix(token);
338
- const hostnameFlag = getHostnameFlag(repoInfo);
339
- const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
340
- const apiPath = `repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
341
- await withRetry(() => this.executor.exec(`${renameTokenPrefix}gh api ${hostnamePart}${apiPath}/branches/${escapeShellArg(current)}/rename ` +
342
- `--method POST -f new_name=${escapeShellArg(desired)}`, this.cwd), {
336
+ const { tokenEnv, prefix, apiPath } = this.buildGhApiPrefix(repoInfo, token);
337
+ await withRetry(() => this.executor.exec(`${prefix}${apiPath}/branches/${escapeShellArg(current)}/rename ` +
338
+ `--method POST -f new_name=${escapeShellArg(desired)}`, this.cwd, { env: tokenEnv }), {
343
339
  retries: this.retries,
344
340
  });
345
341
  }
@@ -351,21 +347,20 @@ export class GitHubLifecycleProvider {
351
347
  * The command arguments are constructed from trusted RepoInfo values
352
348
  * (validated during config parsing), not user input.
353
349
  */
354
- async waitForDefaultBranch(repoInfo, expectedBranch, token, timeoutMs = 15000, pollMs = 1000) {
355
- const tokenPrefix = this.buildTokenPrefix(token);
356
- const hostnameFlag = getHostnameFlag(repoInfo);
357
- const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
358
- const apiPath = `repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
350
+ async waitForDefaultBranch(repoInfo, expectedBranch, options) {
351
+ const timeoutMs = options?.timeoutMs ?? 15000;
352
+ const pollMs = options?.pollMs ?? 1000;
353
+ const { tokenEnv, prefix, apiPath } = this.buildGhApiPrefix(repoInfo, options?.token);
359
354
  const startTime = Date.now();
360
355
  while (Date.now() - startTime < timeoutMs) {
361
356
  try {
362
- const branch = (await this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath} --jq '.default_branch'`, this.cwd)).trim();
357
+ const branch = (await this.executor.exec(`${prefix}${apiPath} --jq '.default_branch'`, this.cwd, { env: tokenEnv })).trim();
363
358
  if (branch === expectedBranch) {
364
359
  return;
365
360
  }
366
361
  }
367
- catch {
368
- // API call failed, continue polling
362
+ catch (error) {
363
+ logger.debug(`Polling default branch: ${toErrorMessage(error)}`);
369
364
  }
370
365
  await new Promise((resolve) => setTimeout(resolve, pollMs));
371
366
  }
@@ -377,24 +372,18 @@ export class GitHubLifecycleProvider {
377
372
  * commit) but no files, so xfg sync starts from a clean state.
378
373
  */
379
374
  async deleteReadme(repoInfo, token) {
380
- const tokenPrefix = this.buildTokenPrefix(token);
381
- const hostnameFlag = getHostnameFlag(repoInfo);
382
- const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
383
- const apiPath = `repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
384
- // After repo creation, GitHub may return 404 due to eventual consistency.
385
- // Exclude 404/not-found from permanent errors so withRetry retries them.
386
- const postCreatePermanentPatterns = DEFAULT_PERMANENT_ERROR_PATTERNS.filter((p) => !p.test("404 Not Found"));
375
+ const { tokenEnv, prefix, apiPath } = this.buildGhApiPrefix(repoInfo, token);
387
376
  // Get the SHA of the README.md created by --add-readme
388
- const fileInfo = await withRetry(() => this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath}/contents/README.md --jq '.sha'`, this.cwd), {
377
+ const fileInfo = await withRetry(() => this.executor.exec(`${prefix}${apiPath}/contents/README.md --jq '.sha'`, this.cwd, { env: tokenEnv }), {
389
378
  retries: this.retries,
390
- permanentErrorPatterns: postCreatePermanentPatterns,
379
+ permanentErrorPatterns: POST_CREATE_PERMANENT_PATTERNS,
391
380
  });
392
381
  const sha = fileInfo.trim();
393
382
  // Delete the README.md to leave the repo clean
394
- await withRetry(() => this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath}/contents/README.md ` +
395
- `--method DELETE -f message='Remove initialization file' -f sha=${escapeShellArg(sha)}`, this.cwd), {
383
+ await withRetry(() => this.executor.exec(`${prefix}${apiPath}/contents/README.md ` +
384
+ `--method DELETE -f message='Remove initialization file' -f sha=${escapeShellArg(sha)}`, this.cwd, { env: tokenEnv }), {
396
385
  retries: this.retries,
397
- permanentErrorPatterns: postCreatePermanentPatterns,
386
+ permanentErrorPatterns: POST_CREATE_PERMANENT_PATTERNS,
398
387
  });
399
388
  }
400
389
  }
@@ -1,7 +1,3 @@
1
- export type { LifecyclePlatform, LifecycleResult, LifecycleOptions, CreateRepoSettings, IRepoLifecycleProvider, IMigrationSource, IRepoLifecycleFactory, IRepoLifecycleManager, } from "./types.js";
2
- export { GitHubLifecycleProvider, type GitHubLifecycleProviderOptions, } from "./github-lifecycle-provider.js";
3
- export { AdoMigrationSource } from "./ado-migration-source.js";
4
- export { RepoLifecycleFactory } from "./repo-lifecycle-factory.js";
1
+ export type { IRepoLifecycleManager } from "./types.js";
5
2
  export { RepoLifecycleManager } from "./repo-lifecycle-manager.js";
6
- export { formatLifecycleAction, type FormatOptions, } from "./lifecycle-formatter.js";
7
- export { runLifecycleCheck, toCreateRepoSettings, type LifecycleCheckOptions, type LifecycleCheckResult, } from "./lifecycle-helpers.js";
3
+ export { runLifecycleCheck, toCreateRepoSettings, } from "./lifecycle-helpers.js";
@@ -1,6 +1,2 @@
1
- export { GitHubLifecycleProvider, } from "./github-lifecycle-provider.js";
2
- export { AdoMigrationSource } from "./ado-migration-source.js";
3
- export { RepoLifecycleFactory } from "./repo-lifecycle-factory.js";
4
1
  export { RepoLifecycleManager } from "./repo-lifecycle-manager.js";
5
- export { formatLifecycleAction, } from "./lifecycle-formatter.js";
6
2
  export { runLifecycleCheck, toCreateRepoSettings, } from "./lifecycle-helpers.js";
@@ -1,5 +1,5 @@
1
1
  import type { LifecycleResult } from "./types.js";
2
- export interface FormatOptions {
2
+ interface FormatOptions {
3
3
  upstream?: string;
4
4
  source?: string;
5
5
  settings?: {
@@ -12,3 +12,4 @@ export interface FormatOptions {
12
12
  * Returns empty array if action is "existed" (no output needed).
13
13
  */
14
14
  export declare function formatLifecycleAction(result: LifecycleResult, options?: FormatOptions): string[];
15
+ export {};
@@ -20,6 +20,10 @@ export function formatLifecycleAction(result, options) {
20
20
  case "migrated":
21
21
  lines.push(chalk.green(`+ MIGRATE ${options?.source ?? "source"} -> ${repoDisplay}`));
22
22
  break;
23
+ default: {
24
+ const _exhaustive = result.action;
25
+ throw new Error(`Unknown lifecycle action: ${_exhaustive}`);
26
+ }
23
27
  }
24
28
  // Add settings details if provided
25
29
  if (options?.settings) {
@@ -1,7 +1,7 @@
1
1
  import type { RepoConfig, GitHubRepoSettings } from "../config/types.js";
2
2
  import type { RepoInfo } from "../shared/repo-detector.js";
3
3
  import type { IRepoLifecycleManager, CreateRepoSettings, LifecycleResult } from "./types.js";
4
- export interface LifecycleCheckOptions {
4
+ interface LifecycleCheckOptions {
5
5
  dryRun: boolean;
6
6
  /** Base work directory (combined with repoIndex to compute full path). */
7
7
  workDir?: string;
@@ -16,7 +16,7 @@ export interface LifecycleCheckOptions {
16
16
  * Extracts only the fields relevant for repo creation.
17
17
  */
18
18
  export declare function toCreateRepoSettings(repo: GitHubRepoSettings | undefined): CreateRepoSettings | undefined;
19
- export interface LifecycleCheckResult {
19
+ interface LifecycleCheckResult {
20
20
  lifecycleResult: LifecycleResult;
21
21
  outputLines: string[];
22
22
  }
@@ -25,3 +25,4 @@ export interface LifecycleCheckResult {
25
25
  * Returns the lifecycle result and formatted output lines.
26
26
  */
27
27
  export declare function runLifecycleCheck(repoConfig: RepoConfig, repoInfo: RepoInfo, repoIndex: number, options: LifecycleCheckOptions, lifecycleManager: IRepoLifecycleManager, repoSettings?: GitHubRepoSettings): Promise<LifecycleCheckResult>;
28
+ export {};
@@ -2,6 +2,7 @@ import { join } from "node:path";
2
2
  import { rm } from "node:fs/promises";
3
3
  import { parseGitUrl } from "../shared/repo-detector.js";
4
4
  import { logger } from "../shared/logger.js";
5
+ import { safeCleanup } from "../shared/type-guards.js";
5
6
  import { RepoLifecycleFactory } from "./repo-lifecycle-factory.js";
6
7
  /**
7
8
  * Orchestrates repo lifecycle operations before sync.
@@ -21,11 +22,11 @@ export class RepoLifecycleManager {
21
22
  if (repoConfig.upstream || repoConfig.source) {
22
23
  throw error;
23
24
  }
24
- // Platform doesn't support lifecycle operations yet - skip silently
25
+ // Platform doesn't support lifecycle operations yet - log and skip
26
+ logger.debug(`Lifecycle: skipping unsupported platform "${repoInfo.type}"`);
25
27
  return { repoInfo, action: "existed" };
26
28
  }
27
29
  const { token } = options;
28
- // Check if repo exists
29
30
  const exists = await provider.exists(repoInfo, token);
30
31
  if (exists) {
31
32
  // Repo exists - nothing to do (ignore upstream/source)
@@ -72,7 +73,6 @@ export class RepoLifecycleManager {
72
73
  if (!provider.fork) {
73
74
  throw new Error(`Platform '${repoInfo.type}' does not support forking`);
74
75
  }
75
- // Parse upstream URL to get repo info
76
76
  const upstreamInfo = parseGitUrl(repoConfig.upstream, {
77
77
  githubHosts: options.githubHosts,
78
78
  });
@@ -110,14 +110,7 @@ export class RepoLifecycleManager {
110
110
  };
111
111
  }
112
112
  finally {
113
- // Clean up migration source directory
114
- try {
115
- await rm(sourceDir, { recursive: true, force: true });
116
- }
117
- catch (cleanupError) {
118
- // Log cleanup errors at debug level for troubleshooting
119
- logger.debug(`Failed to clean up migration source directory ${sourceDir}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
120
- }
113
+ await safeCleanup(() => rm(sourceDir, { recursive: true, force: true }), `failed to remove ${sourceDir}`, logger);
121
114
  }
122
115
  }
123
116
  /**
@@ -8,22 +8,16 @@ export type LifecyclePlatform = "github" | "azure-devops" | "gitlab";
8
8
  * Result of a lifecycle operation.
9
9
  */
10
10
  export interface LifecycleResult {
11
- /** The repo info (may be updated) */
12
11
  repoInfo: RepoInfo;
13
- /** What action was taken */
14
12
  action: "existed" | "created" | "forked" | "migrated";
15
- /** True if skipped due to dry-run */
16
13
  skipped?: boolean;
17
14
  }
18
15
  /**
19
16
  * Options for lifecycle operations.
20
17
  */
21
18
  export interface LifecycleOptions {
22
- /** Dry-run mode - don't make changes */
23
19
  dryRun: boolean;
24
- /** Working directory for git operations */
25
20
  workDir: string;
26
- /** GitHub Enterprise hostnames for URL detection */
27
21
  githubHosts?: string[];
28
22
  /** Auth token (GitHub App installation token or PAT) for gh CLI commands */
29
23
  token?: string;
@@ -44,7 +38,6 @@ export interface CreateRepoSettings {
44
38
  * Implementations handle create/fork/receive for a specific platform.
45
39
  */
46
40
  export interface IRepoLifecycleProvider {
47
- /** Platform this provider handles */
48
41
  readonly platform: LifecyclePlatform;
49
42
  /**
50
43
  * Check if a repository exists on this platform.
@@ -70,7 +63,6 @@ export interface IRepoLifecycleProvider {
70
63
  * Implementations handle cloning from a source platform.
71
64
  */
72
65
  export interface IMigrationSource {
73
- /** Platform this source handles */
74
66
  readonly platform: LifecyclePlatform;
75
67
  /**
76
68
  * Clone repository with all refs for migration.
@@ -46,4 +46,9 @@ export interface SummaryData {
46
46
  }
47
47
  export declare function formatSummary(data: SummaryData): string;
48
48
  export declare function isGitHubActions(): boolean;
49
+ /**
50
+ * Append markdown content to GITHUB_STEP_SUMMARY.
51
+ * No-op outside GitHub Actions.
52
+ */
53
+ export declare function writeGitHubStepSummary(markdown: string): void;
49
54
  export declare function writeSummary(data: SummaryData): void;
@@ -224,10 +224,17 @@ export function formatSummary(data) {
224
224
  export function isGitHubActions() {
225
225
  return !!process.env.GITHUB_STEP_SUMMARY;
226
226
  }
227
- export function writeSummary(data) {
227
+ /**
228
+ * Append markdown content to GITHUB_STEP_SUMMARY.
229
+ * No-op outside GitHub Actions.
230
+ */
231
+ export function writeGitHubStepSummary(markdown) {
228
232
  const summaryPath = process.env.GITHUB_STEP_SUMMARY;
229
233
  if (!summaryPath)
230
234
  return;
231
- const markdown = formatSummary(data);
232
235
  appendFileSync(summaryPath, "\n" + markdown + "\n");
233
236
  }
237
+ export function writeSummary(data) {
238
+ const markdown = formatSummary(data);
239
+ writeGitHubStepSummary(markdown);
240
+ }
@@ -1,5 +1,5 @@
1
1
  export { formatSyncReportCLI, formatSyncReportMarkdown, writeSyncReportSummary, type SyncReport, type RepoFileChanges, type FileChange, } from "./sync-report.js";
2
- export { formatSettingsReportCLI, formatSettingsReportMarkdown, writeSettingsReportSummary, formatValuePlain, formatRulesetConfigPlain, type SettingsReport, type RepoChanges, type RulesetChange, type SettingChange, } from "./settings-report.js";
3
- export { formatUnifiedSummaryMarkdown, writeUnifiedSummary, type UnifiedSummaryInput, } from "./unified-summary.js";
2
+ export { formatSettingsReportCLI, formatSettingsReportMarkdown, writeSettingsReportSummary, type SettingsReport, type RepoChanges, type RulesetChange, type SettingChange, } from "./settings-report.js";
3
+ export { formatUnifiedSummaryMarkdown, writeUnifiedSummary, } from "./unified-summary.js";
4
4
  export { formatSummary, isGitHubActions, writeSummary, type MergeOutcome, type FileChanges, type RulesetPlanDetail, type RepoSettingsPlanDetail, type RepoResult, type SummaryData, } from "./github-summary.js";
5
5
  export { getMergeOutcome, toFileChanges, buildRepoResult, buildErrorResult, } from "./summary-utils.js";
@@ -1,7 +1,7 @@
1
1
  // Sync report (repo-grouped file changes)
2
2
  export { formatSyncReportCLI, formatSyncReportMarkdown, writeSyncReportSummary, } from "./sync-report.js";
3
3
  // Settings report (repo-grouped settings changes)
4
- export { formatSettingsReportCLI, formatSettingsReportMarkdown, writeSettingsReportSummary, formatValuePlain, formatRulesetConfigPlain, } from "./settings-report.js";
4
+ export { formatSettingsReportCLI, formatSettingsReportMarkdown, writeSettingsReportSummary, } from "./settings-report.js";
5
5
  // Unified summary (lifecycle + sync + settings in one diff block)
6
6
  export { formatUnifiedSummaryMarkdown, writeUnifiedSummary, } from "./unified-summary.js";
7
7
  // GitHub Actions summary
@@ -1,9 +1,6 @@
1
1
  // src/output/lifecycle-report.ts
2
- import { appendFileSync } from "node:fs";
3
2
  import chalk from "chalk";
4
- // =============================================================================
5
- // Builder
6
- // =============================================================================
3
+ import { writeGitHubStepSummary } from "./github-summary.js";
7
4
  export function buildLifecycleReport(results) {
8
5
  const actions = [];
9
6
  const totals = { created: 0, forked: 0, migrated: 0, existed: 0 };
@@ -19,10 +16,7 @@ export function buildLifecycleReport(results) {
19
16
  }
20
17
  return { actions, totals };
21
18
  }
22
- // =============================================================================
23
- // Helpers
24
- // =============================================================================
25
- function formatSummary(totals) {
19
+ function formatLifecycleSummary(totals) {
26
20
  const total = totals.created + totals.forked + totals.migrated;
27
21
  if (total === 0) {
28
22
  return "No changes";
@@ -43,9 +37,6 @@ function formatSummary(totals) {
43
37
  export function hasLifecycleChanges(report) {
44
38
  return report.actions.some((a) => a.action !== "existed");
45
39
  }
46
- // =============================================================================
47
- // CLI Formatter
48
- // =============================================================================
49
40
  export function formatLifecycleReportCLI(report) {
50
41
  if (!hasLifecycleChanges(report)) {
51
42
  return [];
@@ -76,12 +67,9 @@ export function formatLifecycleReportCLI(report) {
76
67
  }
77
68
  lines.push("");
78
69
  // Summary
79
- lines.push(formatSummary(report.totals));
70
+ lines.push(formatLifecycleSummary(report.totals));
80
71
  return lines;
81
72
  }
82
- // =============================================================================
83
- // Markdown Formatter
84
- // =============================================================================
85
73
  export function formatLifecycleReportMarkdown(report, dryRun) {
86
74
  if (!hasLifecycleChanges(report)) {
87
75
  return "";
@@ -129,18 +117,12 @@ export function formatLifecycleReportMarkdown(report, dryRun) {
129
117
  lines.push("");
130
118
  }
131
119
  // Summary
132
- lines.push(`**${formatSummary(report.totals)}**`);
120
+ lines.push(`**${formatLifecycleSummary(report.totals)}**`);
133
121
  return lines.join("\n");
134
122
  }
135
- // =============================================================================
136
- // File Writer
137
- // =============================================================================
138
123
  export function writeLifecycleReportSummary(report, dryRun) {
139
- const summaryPath = process.env.GITHUB_STEP_SUMMARY;
140
- if (!summaryPath)
141
- return;
142
124
  const markdown = formatLifecycleReportMarkdown(report, dryRun);
143
125
  if (!markdown)
144
126
  return;
145
- appendFileSync(summaryPath, "\n" + markdown + "\n");
127
+ writeGitHubStepSummary(markdown);
146
128
  }