@aspruyt/xfg 3.2.0 → 3.4.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.
@@ -1,4 +1,5 @@
1
1
  import { execSync } from "node:child_process";
2
+ import { sanitizeCredentials } from "./sanitize-utils.js";
2
3
  /**
3
4
  * Default implementation that uses Node.js child_process.execSync.
4
5
  * Note: Commands are escaped using escapeShellArg before being passed here.
@@ -18,9 +19,17 @@ export class ShellCommandExecutor {
18
19
  if (execError.stderr && typeof execError.stderr !== "string") {
19
20
  execError.stderr = execError.stderr.toString();
20
21
  }
21
- // Include stderr in error message for better debugging
22
+ // Sanitize credentials from stderr before including in error
23
+ if (execError.stderr) {
24
+ execError.stderr = sanitizeCredentials(execError.stderr);
25
+ }
26
+ // Include sanitized stderr in error message for better debugging
22
27
  if (execError.stderr && execError.message) {
23
- execError.message = `${execError.message}\n${execError.stderr}`;
28
+ execError.message =
29
+ sanitizeCredentials(execError.message) + "\n" + execError.stderr;
30
+ }
31
+ else if (execError.message) {
32
+ execError.message = sanitizeCredentials(execError.message);
24
33
  }
25
34
  throw error;
26
35
  }
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { program, Command } from "commander";
3
3
  import { resolve, join, dirname } from "node:path";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
+ import chalk from "chalk";
6
7
  import { loadRawConfig, normalizeConfig, } from "./config.js";
7
8
  import { validateForSync, validateForSettings } from "./config-validator.js";
8
9
  // Get version from package.json
@@ -208,19 +209,20 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
208
209
  return;
209
210
  }
210
211
  console.log(`Found ${reposWithRulesets.length} repositories with rulesets\n`);
212
+ logger.setTotal(reposWithRulesets.length);
211
213
  const processor = processorFactory();
212
214
  const repoProcessor = repoProcessorFactory();
213
215
  const results = [];
214
216
  let successCount = 0;
215
217
  let failCount = 0;
216
218
  let skipCount = 0;
217
- for (let i = 0; i < config.repos.length; i++) {
218
- const repoConfig = config.repos[i];
219
- // Skip repos without rulesets
220
- if (!repoConfig.settings?.rulesets ||
221
- Object.keys(repoConfig.settings.rulesets).length === 0) {
222
- continue;
223
- }
219
+ // Tracking for multi-repo summary in dry-run mode
220
+ let totalCreates = 0;
221
+ let totalUpdates = 0;
222
+ let totalDeletes = 0;
223
+ let reposWithChanges = 0;
224
+ for (let i = 0; i < reposWithRulesets.length; i++) {
225
+ const repoConfig = reposWithRulesets[i];
224
226
  let repoInfo;
225
227
  try {
226
228
  repoInfo = parseGitUrl(repoConfig.git, {
@@ -252,6 +254,27 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
252
254
  managedRulesets,
253
255
  noDelete: options.noDelete,
254
256
  });
257
+ // Display plan output for dry-run mode
258
+ if (options.dryRun && result.planOutput) {
259
+ if (result.planOutput.lines.length > 0) {
260
+ logger.rulesetPlan(repoName, result.planOutput.lines, {
261
+ creates: result.planOutput.creates,
262
+ updates: result.planOutput.updates,
263
+ deletes: result.planOutput.deletes,
264
+ unchanged: result.planOutput.unchanged,
265
+ });
266
+ }
267
+ // Accumulate totals for multi-repo summary
268
+ totalCreates += result.planOutput.creates;
269
+ totalUpdates += result.planOutput.updates;
270
+ totalDeletes += result.planOutput.deletes;
271
+ if (result.planOutput.creates +
272
+ result.planOutput.updates +
273
+ result.planOutput.deletes >
274
+ 0) {
275
+ reposWithChanges++;
276
+ }
277
+ }
255
278
  if (result.skipped) {
256
279
  logger.skip(i + 1, repoName, result.message);
257
280
  skipCount++;
@@ -296,6 +319,19 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
296
319
  failCount++;
297
320
  }
298
321
  }
322
+ // Multi-repo summary for dry-run mode
323
+ if (options.dryRun && reposWithChanges > 0) {
324
+ console.log("");
325
+ console.log(chalk.gray("─".repeat(40)));
326
+ const totalParts = [];
327
+ if (totalCreates > 0)
328
+ totalParts.push(chalk.green(`${totalCreates} to create`));
329
+ if (totalUpdates > 0)
330
+ totalParts.push(chalk.yellow(`${totalUpdates} to update`));
331
+ if (totalDeletes > 0)
332
+ totalParts.push(chalk.red(`${totalDeletes} to delete`));
333
+ console.log(chalk.bold(`Total: ${totalParts.join(", ")} across ${reposWithChanges} ${reposWithChanges === 1 ? "repository" : "repositories"}`));
334
+ }
299
335
  // Summary
300
336
  console.log("\n" + "=".repeat(50));
301
337
  console.log(`Completed: ${successCount} succeeded, ${skipCount} skipped, ${failCount} failed`);
package/dist/logger.d.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import { FileStatus } from "./diff-utils.js";
2
+ export interface RulesetPlanCounts {
3
+ creates: number;
4
+ updates: number;
5
+ deletes: number;
6
+ unchanged: number;
7
+ }
2
8
  export interface ILogger {
3
9
  info(message: string): void;
4
10
  fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
5
11
  diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
12
+ rulesetPlan(repoName: string, planLines: string[], counts: RulesetPlanCounts): void;
6
13
  setTotal(total: number): void;
7
14
  progress(current: number, repoName: string, message: string): void;
8
15
  success(current: number, repoName: string, message: string): void;
@@ -34,6 +41,10 @@ export declare class Logger implements ILogger {
34
41
  * Display summary statistics for dry-run diff.
35
42
  */
36
43
  diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
44
+ /**
45
+ * Display ruleset plan output for dry-run mode.
46
+ */
47
+ rulesetPlan(repoName: string, planLines: string[], counts: RulesetPlanCounts): void;
37
48
  summary(): void;
38
49
  hasFailures(): boolean;
39
50
  }
package/dist/logger.js CHANGED
@@ -62,6 +62,29 @@ export class Logger {
62
62
  console.log(chalk.gray(` Summary: ${parts.join(", ")}`));
63
63
  }
64
64
  }
65
+ /**
66
+ * Display ruleset plan output for dry-run mode.
67
+ */
68
+ rulesetPlan(repoName, planLines, counts) {
69
+ console.log("");
70
+ console.log(chalk.bold(`Repository: ${repoName}`));
71
+ for (const line of planLines) {
72
+ console.log(line);
73
+ }
74
+ // Summary line
75
+ const parts = [];
76
+ if (counts.creates > 0)
77
+ parts.push(chalk.green(`${counts.creates} to create`));
78
+ if (counts.updates > 0)
79
+ parts.push(chalk.yellow(`${counts.updates} to update`));
80
+ if (counts.deletes > 0)
81
+ parts.push(chalk.red(`${counts.deletes} to delete`));
82
+ const unchangedPart = counts.unchanged > 0
83
+ ? chalk.gray(` (${counts.unchanged} unchanged)`)
84
+ : "";
85
+ const summaryLine = parts.length > 0 ? parts.join(", ") + unchangedPart : "No changes";
86
+ console.log(chalk.gray(`Plan: ${summaryLine}`));
87
+ }
65
88
  summary() {
66
89
  console.log("");
67
90
  console.log(chalk.bold("Summary:"));
@@ -1,5 +1,6 @@
1
1
  import pRetry, { AbortError } from "p-retry";
2
2
  import { logger } from "./logger.js";
3
+ import { sanitizeCredentials } from "./sanitize-utils.js";
3
4
  /**
4
5
  * Default patterns indicating permanent errors that should NOT be retried.
5
6
  * These typically indicate configuration issues, auth failures, or invalid resources.
@@ -121,7 +122,7 @@ export async function withRetry(fn, options) {
121
122
  onFailedAttempt: (context) => {
122
123
  // Only log if this isn't the last attempt
123
124
  if (context.retriesLeft > 0) {
124
- const msg = context.error.message || "Unknown error";
125
+ const msg = sanitizeCredentials(context.error.message) || "Unknown error";
125
126
  logger.info(`Attempt ${context.attemptNumber}/${retries + 1} failed: ${msg}. Retrying...`);
126
127
  options?.onRetry?.(context.error, context.attemptNumber);
127
128
  }
@@ -0,0 +1,27 @@
1
+ import type { RulesetChange } from "./ruleset-diff.js";
2
+ export type DiffAction = "add" | "change" | "remove";
3
+ export interface PropertyDiff {
4
+ path: string[];
5
+ action: DiffAction;
6
+ oldValue?: unknown;
7
+ newValue?: unknown;
8
+ }
9
+ export interface RulesetPlanResult {
10
+ lines: string[];
11
+ creates: number;
12
+ updates: number;
13
+ deletes: number;
14
+ unchanged: number;
15
+ }
16
+ /**
17
+ * Recursively compute property-level diffs between two objects.
18
+ */
19
+ export declare function computePropertyDiffs(current: Record<string, unknown>, desired: Record<string, unknown>, parentPath?: string[]): PropertyDiff[];
20
+ /**
21
+ * Format property diffs as an indented tree structure.
22
+ */
23
+ export declare function formatPropertyTree(diffs: PropertyDiff[]): string[];
24
+ /**
25
+ * Format ruleset changes as a Terraform-style plan.
26
+ */
27
+ export declare function formatRulesetPlan(changes: RulesetChange[]): RulesetPlanResult;
@@ -0,0 +1,314 @@
1
+ // src/ruleset-plan-formatter.ts
2
+ import chalk from "chalk";
3
+ // =============================================================================
4
+ // Property Diff Algorithm
5
+ // =============================================================================
6
+ /**
7
+ * Recursively compute property-level diffs between two objects.
8
+ */
9
+ export function computePropertyDiffs(current, desired, parentPath = []) {
10
+ const diffs = [];
11
+ const allKeys = new Set([...Object.keys(current), ...Object.keys(desired)]);
12
+ for (const key of allKeys) {
13
+ const path = [...parentPath, key];
14
+ const currentVal = current[key];
15
+ const desiredVal = desired[key];
16
+ if (!(key in current)) {
17
+ // Added property
18
+ diffs.push({ path, action: "add", newValue: desiredVal });
19
+ }
20
+ else if (!(key in desired)) {
21
+ // Removed property
22
+ diffs.push({ path, action: "remove", oldValue: currentVal });
23
+ }
24
+ else if (!deepEqual(currentVal, desiredVal)) {
25
+ // Changed property
26
+ if (isObject(currentVal) && isObject(desiredVal)) {
27
+ // Recurse into nested objects
28
+ diffs.push(...computePropertyDiffs(currentVal, desiredVal, path));
29
+ }
30
+ else {
31
+ diffs.push({
32
+ path,
33
+ action: "change",
34
+ oldValue: currentVal,
35
+ newValue: desiredVal,
36
+ });
37
+ }
38
+ }
39
+ // Unchanged properties are not included
40
+ }
41
+ return diffs;
42
+ }
43
+ // =============================================================================
44
+ // Helpers
45
+ // =============================================================================
46
+ function isObject(val) {
47
+ return val !== null && typeof val === "object" && !Array.isArray(val);
48
+ }
49
+ function deepEqual(a, b) {
50
+ if (a === b)
51
+ return true;
52
+ if (a === null || b === null || a === undefined || b === undefined)
53
+ return a === b;
54
+ if (typeof a !== typeof b)
55
+ return false;
56
+ if (Array.isArray(a) && Array.isArray(b)) {
57
+ if (a.length !== b.length)
58
+ return false;
59
+ return a.every((val, i) => deepEqual(val, b[i]));
60
+ }
61
+ if (isObject(a) && isObject(b)) {
62
+ const keysA = Object.keys(a);
63
+ const keysB = Object.keys(b);
64
+ if (keysA.length !== keysB.length)
65
+ return false;
66
+ return keysA.every((key) => deepEqual(a[key], b[key]));
67
+ }
68
+ return false;
69
+ }
70
+ /**
71
+ * Build a tree structure from flat property diffs.
72
+ */
73
+ function buildTree(diffs) {
74
+ const root = { name: "", children: new Map() };
75
+ for (const diff of diffs) {
76
+ let current = root;
77
+ for (let i = 0; i < diff.path.length; i++) {
78
+ const segment = diff.path[i];
79
+ const isLast = i === diff.path.length - 1;
80
+ if (!current.children.has(segment)) {
81
+ current.children.set(segment, {
82
+ name: segment,
83
+ children: new Map(),
84
+ });
85
+ }
86
+ const child = current.children.get(segment);
87
+ if (isLast) {
88
+ child.action = diff.action;
89
+ child.oldValue = diff.oldValue;
90
+ child.newValue = diff.newValue;
91
+ }
92
+ else {
93
+ // Intermediate node - mark as change if any child changes
94
+ if (!child.action) {
95
+ child.action = "change";
96
+ }
97
+ }
98
+ current = child;
99
+ }
100
+ }
101
+ return root;
102
+ }
103
+ /**
104
+ * Format a value for display.
105
+ */
106
+ function formatValue(val) {
107
+ if (val === null)
108
+ return "null";
109
+ if (val === undefined)
110
+ return "undefined";
111
+ if (typeof val === "string")
112
+ return `"${val}"`;
113
+ if (Array.isArray(val)) {
114
+ if (val.length <= 3) {
115
+ return `[${val.map(formatValue).join(", ")}]`;
116
+ }
117
+ return `[${val.slice(0, 3).map(formatValue).join(", ")}, ... (${val.length - 3} more)]`;
118
+ }
119
+ if (typeof val === "object") {
120
+ return "{...}";
121
+ }
122
+ return String(val);
123
+ }
124
+ /**
125
+ * Get the symbol and color for an action.
126
+ */
127
+ function getActionStyle(action) {
128
+ switch (action) {
129
+ case "add":
130
+ return { symbol: "+", color: chalk.green };
131
+ case "remove":
132
+ return { symbol: "-", color: chalk.red };
133
+ case "change":
134
+ return { symbol: "~", color: chalk.yellow };
135
+ }
136
+ }
137
+ /**
138
+ * Recursively render tree nodes to formatted lines.
139
+ */
140
+ function renderTree(node, indent = 0) {
141
+ const lines = [];
142
+ const indentStr = " ".repeat(indent);
143
+ for (const [, child] of node.children) {
144
+ const style = child.action
145
+ ? getActionStyle(child.action)
146
+ : { symbol: " ", color: chalk.gray };
147
+ const hasChildren = child.children.size > 0;
148
+ if (hasChildren) {
149
+ // Intermediate node
150
+ lines.push(style.color(`${indentStr}${style.symbol} ${child.name}:`));
151
+ lines.push(...renderTree(child, indent + 1));
152
+ }
153
+ else {
154
+ // Leaf node with value
155
+ let valuePart = "";
156
+ if (child.action === "change") {
157
+ valuePart = `: ${formatValue(child.oldValue)} → ${formatValue(child.newValue)}`;
158
+ }
159
+ else if (child.action === "add") {
160
+ valuePart = `: ${formatValue(child.newValue)}`;
161
+ }
162
+ else if (child.action === "remove") {
163
+ valuePart = ` (was: ${formatValue(child.oldValue)})`;
164
+ }
165
+ lines.push(style.color(`${indentStr}${style.symbol} ${child.name}${valuePart}`));
166
+ }
167
+ }
168
+ return lines;
169
+ }
170
+ /**
171
+ * Format property diffs as an indented tree structure.
172
+ */
173
+ export function formatPropertyTree(diffs) {
174
+ if (diffs.length === 0) {
175
+ return [];
176
+ }
177
+ const tree = buildTree(diffs);
178
+ return renderTree(tree);
179
+ }
180
+ // =============================================================================
181
+ // Ruleset Plan Formatter
182
+ // =============================================================================
183
+ /**
184
+ * Normalize a GitHubRuleset or Ruleset for comparison.
185
+ * Converts to snake_case and removes metadata fields.
186
+ */
187
+ function normalizeForDiff(obj) {
188
+ const result = {};
189
+ const ignoreFields = new Set(["id", "name", "source_type", "source"]);
190
+ for (const [key, value] of Object.entries(obj)) {
191
+ if (ignoreFields.has(key) || value === undefined)
192
+ continue;
193
+ // Convert camelCase to snake_case for consistency
194
+ const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
195
+ result[snakeKey] = normalizeNestedValue(value);
196
+ }
197
+ return result;
198
+ }
199
+ function normalizeNestedValue(value) {
200
+ if (value === null || value === undefined)
201
+ return value;
202
+ if (Array.isArray(value))
203
+ return value.map(normalizeNestedValue);
204
+ if (typeof value === "object") {
205
+ const obj = value;
206
+ const result = {};
207
+ for (const [key, val] of Object.entries(obj)) {
208
+ const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
209
+ result[snakeKey] = normalizeNestedValue(val);
210
+ }
211
+ return result;
212
+ }
213
+ return value;
214
+ }
215
+ /**
216
+ * Format a full ruleset config as tree lines (for create action).
217
+ */
218
+ function formatFullConfig(ruleset, indent = 2) {
219
+ const lines = [];
220
+ const style = getActionStyle("add");
221
+ function renderValue(key, value, currentIndent) {
222
+ const pad = " ".repeat(currentIndent);
223
+ if (value === null || value === undefined)
224
+ return;
225
+ if (Array.isArray(value)) {
226
+ if (value.length === 0) {
227
+ lines.push(style.color(`${pad}+ ${key}: []`));
228
+ }
229
+ else if (value.every((v) => typeof v !== "object")) {
230
+ lines.push(style.color(`${pad}+ ${key}: ${formatValue(value)}`));
231
+ }
232
+ else {
233
+ lines.push(style.color(`${pad}+ ${key}:`));
234
+ for (const item of value) {
235
+ if (typeof item === "object" && item !== null) {
236
+ lines.push(style.color(`${pad} - ${JSON.stringify(item)}`));
237
+ }
238
+ else {
239
+ lines.push(style.color(`${pad} - ${formatValue(item)}`));
240
+ }
241
+ }
242
+ }
243
+ }
244
+ else if (typeof value === "object") {
245
+ lines.push(style.color(`${pad}+ ${key}:`));
246
+ for (const [k, v] of Object.entries(value)) {
247
+ renderValue(k, v, currentIndent + 1);
248
+ }
249
+ }
250
+ else {
251
+ lines.push(style.color(`${pad}+ ${key}: ${formatValue(value)}`));
252
+ }
253
+ }
254
+ for (const [key, value] of Object.entries(ruleset)) {
255
+ renderValue(key, value, indent);
256
+ }
257
+ return lines;
258
+ }
259
+ /**
260
+ * Format ruleset changes as a Terraform-style plan.
261
+ */
262
+ export function formatRulesetPlan(changes) {
263
+ const lines = [];
264
+ let creates = 0;
265
+ let updates = 0;
266
+ let deletes = 0;
267
+ let unchanged = 0;
268
+ // Group by action type
269
+ const createChanges = changes.filter((c) => c.action === "create");
270
+ const updateChanges = changes.filter((c) => c.action === "update");
271
+ const deleteChanges = changes.filter((c) => c.action === "delete");
272
+ const unchangedChanges = changes.filter((c) => c.action === "unchanged");
273
+ creates = createChanges.length;
274
+ updates = updateChanges.length;
275
+ deletes = deleteChanges.length;
276
+ unchanged = unchangedChanges.length;
277
+ // Format creates
278
+ if (createChanges.length > 0) {
279
+ lines.push(chalk.bold(" Create:"));
280
+ for (const change of createChanges) {
281
+ lines.push(chalk.green(` + ruleset "${change.name}"`));
282
+ if (change.desired) {
283
+ lines.push(...formatFullConfig(change.desired, 2));
284
+ }
285
+ lines.push(""); // Blank line between rulesets
286
+ }
287
+ }
288
+ // Format updates
289
+ if (updateChanges.length > 0) {
290
+ lines.push(chalk.bold(" Update:"));
291
+ for (const change of updateChanges) {
292
+ lines.push(chalk.yellow(` ~ ruleset "${change.name}"`));
293
+ if (change.current && change.desired) {
294
+ const currentNorm = normalizeForDiff(change.current);
295
+ const desiredNorm = normalizeForDiff(change.desired);
296
+ const diffs = computePropertyDiffs(currentNorm, desiredNorm);
297
+ const treeLines = formatPropertyTree(diffs);
298
+ for (const line of treeLines) {
299
+ lines.push(` ${line}`);
300
+ }
301
+ }
302
+ lines.push(""); // Blank line between rulesets
303
+ }
304
+ }
305
+ // Format deletes
306
+ if (deleteChanges.length > 0) {
307
+ lines.push(chalk.bold(" Delete:"));
308
+ for (const change of deleteChanges) {
309
+ lines.push(chalk.red(` - ruleset "${change.name}"`));
310
+ }
311
+ lines.push(""); // Blank line after deletes
312
+ }
313
+ return { lines, creates, updates, deletes, unchanged };
314
+ }
@@ -1,6 +1,7 @@
1
1
  import type { RepoConfig } from "./config.js";
2
2
  import type { RepoInfo } from "./repo-detector.js";
3
3
  import { GitHubRulesetStrategy } from "./strategies/github-ruleset-strategy.js";
4
+ import { RulesetPlanResult } from "./ruleset-plan-formatter.js";
4
5
  export interface IRulesetProcessor {
5
6
  process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: RulesetProcessorOptions): Promise<RulesetProcessorResult>;
6
7
  }
@@ -26,6 +27,7 @@ export interface RulesetProcessorResult {
26
27
  manifestUpdate?: {
27
28
  rulesets: string[];
28
29
  };
30
+ planOutput?: RulesetPlanResult;
29
31
  }
30
32
  /**
31
33
  * Processes ruleset configuration for a repository.
@@ -1,6 +1,7 @@
1
1
  import { isGitHubRepo, getRepoDisplayName } from "./repo-detector.js";
2
2
  import { GitHubRulesetStrategy } from "./strategies/github-ruleset-strategy.js";
3
3
  import { diffRulesets } from "./ruleset-diff.js";
4
+ import { formatRulesetPlan, } from "./ruleset-plan-formatter.js";
4
5
  // =============================================================================
5
6
  // Processor Implementation
6
7
  // =============================================================================
@@ -60,12 +61,14 @@ export class RulesetProcessor {
60
61
  // Dry run mode - report planned changes without applying
61
62
  if (dryRun) {
62
63
  const summary = this.formatChangeSummary(changeCounts);
64
+ const planOutput = formatRulesetPlan(changes);
63
65
  return {
64
66
  success: true,
65
67
  repoName,
66
68
  message: `[DRY RUN] ${summary}`,
67
69
  dryRun: true,
68
70
  changes: changeCounts,
71
+ planOutput,
69
72
  manifestUpdate: this.computeManifestUpdate(desiredRulesets, deleteOrphaned),
70
73
  };
71
74
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Sanitizes credentials from error messages and logs.
3
+ * Replaces sensitive tokens/passwords with '***' to prevent leakage.
4
+ *
5
+ * @param message The message that may contain credentials
6
+ * @returns The sanitized message with credentials replaced by '***'
7
+ */
8
+ export declare function sanitizeCredentials(message: string | undefined | null): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Sanitizes credentials from error messages and logs.
3
+ * Replaces sensitive tokens/passwords with '***' to prevent leakage.
4
+ *
5
+ * @param message The message that may contain credentials
6
+ * @returns The sanitized message with credentials replaced by '***'
7
+ */
8
+ export function sanitizeCredentials(message) {
9
+ if (!message) {
10
+ return "";
11
+ }
12
+ let result = message;
13
+ // Handle URL credentials (most common case)
14
+ // Replace password portion in https://user:password@host patterns
15
+ result = result.replace(/(https:\/\/[^:]+:)([^@]+)(@)/g, "$1***$3");
16
+ // Handle Authorization headers
17
+ result = result.replace(/(Authorization:\s*Bearer\s+)(\S+)/gi, "$1***");
18
+ result = result.replace(/(Authorization:\s*Basic\s+)(\S+)/gi, "$1***");
19
+ return result;
20
+ }
@@ -5,6 +5,7 @@ import { isAzureDevOpsRepo } 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
+ import { sanitizeCredentials } from "../sanitize-utils.js";
8
9
  export class AzurePRStrategy extends BasePRStrategy {
9
10
  constructor(executor) {
10
11
  super(executor);
@@ -35,7 +36,7 @@ export class AzurePRStrategy extends BasePRStrategy {
35
36
  }
36
37
  const stderr = error.stderr ?? "";
37
38
  if (stderr && !stderr.includes("does not exist")) {
38
- logger.info(`Debug: Azure PR check failed - ${stderr.trim()}`);
39
+ logger.info(`Debug: Azure PR check failed - ${sanitizeCredentials(stderr).trim()}`);
39
40
  }
40
41
  }
41
42
  return null;
@@ -5,6 +5,7 @@ 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
+ import { sanitizeCredentials } from "../sanitize-utils.js";
8
9
  /**
9
10
  * Get the repo flag value for gh CLI commands.
10
11
  * Returns HOST/OWNER/REPO for GHE, OWNER/REPO for github.com.
@@ -66,7 +67,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
66
67
  // Log unexpected errors for debugging (expected: empty result means no PR)
67
68
  const stderr = error.stderr ?? "";
68
69
  if (stderr && !stderr.includes("no pull requests match")) {
69
- logger.info(`Debug: GitHub PR check failed - ${stderr.trim()}`);
70
+ logger.info(`Debug: GitHub PR check failed - ${sanitizeCredentials(stderr).trim()}`);
70
71
  }
71
72
  }
72
73
  return null;
@@ -5,6 +5,7 @@ import { isGitLabRepo } 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
+ import { sanitizeCredentials } from "../sanitize-utils.js";
8
9
  export class GitLabPRStrategy extends BasePRStrategy {
9
10
  constructor(executor) {
10
11
  super(executor);
@@ -89,7 +90,7 @@ export class GitLabPRStrategy extends BasePRStrategy {
89
90
  // Log unexpected errors for debugging
90
91
  const stderr = error.stderr ?? "";
91
92
  if (stderr && !stderr.includes("no merge requests")) {
92
- logger.info(`Debug: GitLab MR check failed - ${stderr.trim()}`);
93
+ logger.info(`Debug: GitLab MR check failed - ${sanitizeCredentials(stderr).trim()}`);
93
94
  }
94
95
  }
95
96
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "CLI tool for repository-as-code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",