@aspruyt/xfg 3.1.5 → 3.3.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.
@@ -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
  }
@@ -60,14 +60,30 @@ export function mergeSettings(root, perRepo) {
60
60
  // Merge rulesets by name - each ruleset is deep merged
61
61
  const rootRulesets = root?.rulesets ?? {};
62
62
  const repoRulesets = perRepo?.rulesets ?? {};
63
+ // Check if repo opts out of all inherited rulesets
64
+ const inheritRulesets = repoRulesets?.inherit !== false;
63
65
  const allRulesetNames = new Set([
64
- ...Object.keys(rootRulesets),
65
- ...Object.keys(repoRulesets),
66
+ ...Object.keys(rootRulesets).filter((name) => name !== "inherit"),
67
+ ...Object.keys(repoRulesets).filter((name) => name !== "inherit"),
66
68
  ]);
67
69
  if (allRulesetNames.size > 0) {
68
70
  result.rulesets = {};
69
71
  for (const name of allRulesetNames) {
70
- result.rulesets[name] = mergeRuleset(rootRulesets[name], repoRulesets[name]);
72
+ const rootRuleset = rootRulesets[name];
73
+ const repoRuleset = repoRulesets[name];
74
+ // Skip if repo explicitly opts out of this ruleset
75
+ if (repoRuleset === false) {
76
+ continue;
77
+ }
78
+ // Skip root rulesets if inherit: false (unless repo has override)
79
+ if (!inheritRulesets && !repoRuleset && rootRuleset) {
80
+ continue;
81
+ }
82
+ result.rulesets[name] = mergeRuleset(rootRuleset, repoRuleset);
83
+ }
84
+ // Clean up empty rulesets object
85
+ if (Object.keys(result.rulesets).length === 0) {
86
+ delete result.rulesets;
71
87
  }
72
88
  }
73
89
  // deleteOrphaned: per-repo overrides root
@@ -89,13 +105,23 @@ export function normalizeConfig(raw) {
89
105
  const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
90
106
  for (const gitUrl of gitUrls) {
91
107
  const files = [];
108
+ // Check if repo opts out of all inherited files
109
+ const inheritFiles = rawRepo.files?.inherit !==
110
+ false;
92
111
  // Step 2: Process each file definition
93
112
  for (const fileName of fileNames) {
113
+ // Skip reserved key
114
+ if (fileName === "inherit")
115
+ continue;
94
116
  const repoOverride = rawRepo.files?.[fileName];
95
117
  // Skip excluded files (set to false)
96
118
  if (repoOverride === false) {
97
119
  continue;
98
120
  }
121
+ // Skip if inherit: false and no repo-specific override
122
+ if (!inheritFiles && !repoOverride) {
123
+ continue;
124
+ }
99
125
  const fileConfig = raw.files[fileName];
100
126
  const fileStrategy = fileConfig.mergeStrategy ?? "replace";
101
127
  // Step 3: Compute merged content for this file
@@ -190,12 +216,34 @@ export function normalizeConfig(raw) {
190
216
  });
191
217
  }
192
218
  }
219
+ // Normalize root settings (filter out inherit key if present)
220
+ let normalizedRootSettings;
221
+ if (raw.settings) {
222
+ normalizedRootSettings = {};
223
+ if (raw.settings.rulesets) {
224
+ const filteredRulesets = {};
225
+ for (const [name, ruleset] of Object.entries(raw.settings.rulesets)) {
226
+ if (name === "inherit" || ruleset === false)
227
+ continue;
228
+ filteredRulesets[name] = ruleset;
229
+ }
230
+ if (Object.keys(filteredRulesets).length > 0) {
231
+ normalizedRootSettings.rulesets = filteredRulesets;
232
+ }
233
+ }
234
+ if (raw.settings.deleteOrphaned !== undefined) {
235
+ normalizedRootSettings.deleteOrphaned = raw.settings.deleteOrphaned;
236
+ }
237
+ if (Object.keys(normalizedRootSettings).length === 0) {
238
+ normalizedRootSettings = undefined;
239
+ }
240
+ }
193
241
  return {
194
242
  id: raw.id,
195
243
  repos: expandedRepos,
196
244
  prTemplate: raw.prTemplate,
197
245
  githubHosts: raw.githubHosts,
198
246
  deleteOrphaned: raw.deleteOrphaned,
199
- settings: raw.settings,
247
+ settings: normalizedRootSettings,
200
248
  };
201
249
  }
@@ -7,7 +7,7 @@ export declare function validateRawConfig(config: RawConfig): void;
7
7
  /**
8
8
  * Validates settings object containing rulesets.
9
9
  */
10
- export declare function validateSettings(settings: unknown, context: string): void;
10
+ export declare function validateSettings(settings: unknown, context: string, rootRulesetNames?: string[]): void;
11
11
  /**
12
12
  * Validates that config is suitable for the sync command.
13
13
  * @throws Error if files section is missing or empty
@@ -49,6 +49,10 @@ export function validateRawConfig(config) {
49
49
  "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
50
50
  }
51
51
  const fileNames = hasFiles ? Object.keys(config.files) : [];
52
+ // Check for reserved key 'inherit' at root files level
53
+ if (hasFiles && "inherit" in config.files) {
54
+ throw new Error("'inherit' is a reserved key and cannot be used as a filename");
55
+ }
52
56
  // Validate each file definition
53
57
  for (const fileName of fileNames) {
54
58
  validateFileName(fileName);
@@ -127,6 +131,10 @@ export function validateRawConfig(config) {
127
131
  // Validate root settings
128
132
  if (config.settings !== undefined) {
129
133
  validateSettings(config.settings, "Root");
134
+ // Check for reserved key 'inherit' at root rulesets level
135
+ if (config.settings.rulesets && "inherit" in config.settings.rulesets) {
136
+ throw new Error("'inherit' is a reserved key and cannot be used as a ruleset name");
137
+ }
130
138
  }
131
139
  // Validate githubHosts if provided
132
140
  if (config.githubHosts !== undefined) {
@@ -161,6 +169,14 @@ export function validateRawConfig(config) {
161
169
  throw new Error(`Repo at index ${i}: files must be an object`);
162
170
  }
163
171
  for (const fileName of Object.keys(repo.files)) {
172
+ // Skip reserved key 'inherit'
173
+ if (fileName === "inherit") {
174
+ const inheritValue = repo.files.inherit;
175
+ if (typeof inheritValue !== "boolean") {
176
+ throw new Error(`Repo at index ${i}: files.inherit must be a boolean`);
177
+ }
178
+ continue;
179
+ }
164
180
  // Ensure the file is defined at root level
165
181
  if (!config.files || !config.files[fileName]) {
166
182
  throw new Error(`Repo at index ${i} references undefined file '${fileName}'. File must be defined in root 'files' object.`);
@@ -233,7 +249,10 @@ export function validateRawConfig(config) {
233
249
  }
234
250
  // Validate per-repo settings
235
251
  if (repo.settings !== undefined) {
236
- validateSettings(repo.settings, `Repo ${getGitDisplayName(repo.git)}`);
252
+ const rootRulesetNames = config.settings?.rulesets
253
+ ? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
254
+ : [];
255
+ validateSettings(repo.settings, `Repo ${getGitDisplayName(repo.git)}`, rootRulesetNames);
237
256
  }
238
257
  }
239
258
  }
@@ -465,7 +484,7 @@ function validateRuleset(ruleset, name, context) {
465
484
  /**
466
485
  * Validates settings object containing rulesets.
467
486
  */
468
- export function validateSettings(settings, context) {
487
+ export function validateSettings(settings, context, rootRulesetNames) {
469
488
  if (typeof settings !== "object" ||
470
489
  settings === null ||
471
490
  Array.isArray(settings)) {
@@ -480,6 +499,16 @@ export function validateSettings(settings, context) {
480
499
  }
481
500
  const rulesets = s.rulesets;
482
501
  for (const [name, ruleset] of Object.entries(rulesets)) {
502
+ // Skip reserved key
503
+ if (name === "inherit")
504
+ continue;
505
+ // Check for opt-out of non-existent root ruleset
506
+ if (ruleset === false) {
507
+ if (rootRulesetNames && !rootRulesetNames.includes(name)) {
508
+ throw new Error(`${context}: Cannot opt out of '${name}' - not defined in root settings.rulesets`);
509
+ }
510
+ continue; // Skip further validation for false entries
511
+ }
483
512
  validateRuleset(ruleset, name, context);
484
513
  }
485
514
  }
package/dist/config.d.ts CHANGED
@@ -239,12 +239,16 @@ export interface RawRepoFileOverride {
239
239
  deleteOrphaned?: boolean;
240
240
  }
241
241
  export interface RawRepoSettings {
242
- rulesets?: Record<string, Ruleset>;
242
+ rulesets?: Record<string, Ruleset | false> & {
243
+ inherit?: boolean;
244
+ };
243
245
  deleteOrphaned?: boolean;
244
246
  }
245
247
  export interface RawRepoConfig {
246
248
  git: string | string[];
247
- files?: Record<string, RawRepoFileOverride | false>;
249
+ files?: Record<string, RawRepoFileOverride | false> & {
250
+ inherit?: boolean;
251
+ };
248
252
  prOptions?: PRMergeOptions;
249
253
  settings?: RawRepoSettings;
250
254
  }
package/dist/index.js CHANGED
@@ -208,19 +208,15 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
208
208
  return;
209
209
  }
210
210
  console.log(`Found ${reposWithRulesets.length} repositories with rulesets\n`);
211
+ logger.setTotal(reposWithRulesets.length);
211
212
  const processor = processorFactory();
212
213
  const repoProcessor = repoProcessorFactory();
213
214
  const results = [];
214
215
  let successCount = 0;
215
216
  let failCount = 0;
216
217
  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
- }
218
+ for (let i = 0; i < reposWithRulesets.length; i++) {
219
+ const repoConfig = reposWithRulesets[i];
224
220
  let repoInfo;
225
221
  try {
226
222
  repoInfo = parseGitUrl(repoConfig.git, {
@@ -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,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.1.5",
3
+ "version": "3.3.2",
4
4
  "description": "CLI tool for repository-as-code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",