@aspruyt/xfg 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  [![docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://anthony-spruyt.github.io/xfg/)
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
10
10
 
11
- A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across multiple GitHub, Azure DevOps, and GitLab repositories. By default, changes are made via pull requests, but you can also push directly to the default branch.
11
+ A CLI tool for repository-as-code. Sync files and manage settings across GitHub, Azure DevOps, and GitLab.
12
12
 
13
13
  **[Full Documentation](https://anthony-spruyt.github.io/xfg/)**
14
14
 
@@ -29,8 +29,9 @@ jobs:
29
29
  runs-on: ubuntu-latest
30
30
  steps:
31
31
  - uses: actions/checkout@v4
32
- - uses: anthony-spruyt/xfg@v1
32
+ - uses: anthony-spruyt/xfg@v3
33
33
  with:
34
+ command: sync
34
35
  config: ./sync-config.yaml
35
36
  github-token: ${{ secrets.GH_PAT }} # PAT with repo scope for cross-repo access
36
37
  ```
@@ -44,31 +45,46 @@ npm install -g @aspruyt/xfg
44
45
  # Authenticate (GitHub)
45
46
  gh auth login
46
47
 
47
- # Run
48
- xfg --config ./config.yaml
48
+ # Sync files across repos
49
+ xfg sync --config ./config.yaml
50
+
51
+ # Apply repository settings
52
+ xfg settings --config ./config.yaml
49
53
  ```
50
54
 
51
55
  ### Example Config
52
56
 
53
57
  ```yaml
54
58
  # sync-config.yaml
55
- id: my-org-prettier-config
59
+ id: my-org-config
56
60
  files:
57
61
  .prettierrc.json:
58
62
  content:
59
63
  semi: false
60
64
  singleQuote: true
61
65
  tabWidth: 2
62
- trailingComma: es5
66
+
67
+ settings:
68
+ rulesets:
69
+ main-protection:
70
+ target: branch
71
+ enforcement: active
72
+ conditions:
73
+ refName:
74
+ include: ["refs/heads/main"]
75
+ exclude: []
76
+ rules:
77
+ - type: pull_request
78
+ parameters:
79
+ requiredApprovingReviewCount: 1
63
80
 
64
81
  repos:
65
82
  - git:
66
83
  - git@github.com:your-org/frontend-app.git
67
84
  - git@github.com:your-org/backend-api.git
68
- - git@github.com:your-org/shared-lib.git
69
85
  ```
70
86
 
71
- **Result:** PRs are created in all three repos with identical `.prettierrc.json` files.
87
+ **Result:** PRs are created with `.prettierrc.json` files, and repos get branch protection rules.
72
88
 
73
89
  ## Documentation
74
90
 
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { program } from "./index.js";
3
+ // Handle backwards compatibility: if no subcommand is provided, default to sync
4
+ // This maintains compatibility with existing usage like `xfg -c config.yaml`
5
+ const args = process.argv.slice(2);
6
+ const subcommands = ["sync", "settings", "help"];
7
+ const versionFlags = ["-V", "--version"];
8
+ // Check if the first argument is a subcommand or version flag
9
+ const firstArg = args[0];
10
+ const isSubcommand = firstArg && subcommands.includes(firstArg);
11
+ const isVersionFlag = firstArg && versionFlags.includes(firstArg);
12
+ if (isSubcommand || isVersionFlag) {
13
+ // Explicit subcommand or version flag - parse normally
14
+ program.parse();
15
+ }
16
+ else {
17
+ // No subcommand - prepend 'sync' for backwards compatibility
18
+ // This handles: `xfg -c config.yaml`, `xfg --help`, `xfg` (no args)
19
+ program.parse(["node", "xfg", "sync", ...args]);
20
+ }
@@ -1,4 +1,9 @@
1
- import type { RawConfig, Config } from "./config.js";
1
+ import type { RawConfig, Config, RepoSettings, RawRepoSettings } from "./config.js";
2
+ /**
3
+ * Merges settings: per-repo settings deep merge with root settings.
4
+ * Returns undefined if no settings are defined.
5
+ */
6
+ export declare function mergeSettings(root: RawRepoSettings | undefined, perRepo: RawRepoSettings | undefined): RepoSettings | undefined;
2
7
  /**
3
8
  * Normalizes raw config into expanded, merged config.
4
9
  * Pipeline: expand git arrays -> merge content -> interpolate env vars
@@ -36,13 +36,54 @@ function mergePROptions(global, perRepo) {
36
36
  result.bypassReason = bypassReason;
37
37
  return Object.keys(result).length > 0 ? result : undefined;
38
38
  }
39
+ /**
40
+ * Deep merges two rulesets: per-repo values override root values.
41
+ */
42
+ function mergeRuleset(root, perRepo) {
43
+ if (!root)
44
+ return structuredClone(perRepo ?? {});
45
+ if (!perRepo)
46
+ return structuredClone(root);
47
+ // Deep merge using the existing merge utility with replace strategy
48
+ const ctx = createMergeContext("replace");
49
+ const merged = deepMerge(structuredClone(root), perRepo, ctx);
50
+ return merged;
51
+ }
52
+ /**
53
+ * Merges settings: per-repo settings deep merge with root settings.
54
+ * Returns undefined if no settings are defined.
55
+ */
56
+ export function mergeSettings(root, perRepo) {
57
+ if (!root && !perRepo)
58
+ return undefined;
59
+ const result = {};
60
+ // Merge rulesets by name - each ruleset is deep merged
61
+ const rootRulesets = root?.rulesets ?? {};
62
+ const repoRulesets = perRepo?.rulesets ?? {};
63
+ const allRulesetNames = new Set([
64
+ ...Object.keys(rootRulesets),
65
+ ...Object.keys(repoRulesets),
66
+ ]);
67
+ if (allRulesetNames.size > 0) {
68
+ result.rulesets = {};
69
+ for (const name of allRulesetNames) {
70
+ result.rulesets[name] = mergeRuleset(rootRulesets[name], repoRulesets[name]);
71
+ }
72
+ }
73
+ // deleteOrphaned: per-repo overrides root
74
+ const deleteOrphaned = perRepo?.deleteOrphaned ?? root?.deleteOrphaned;
75
+ if (deleteOrphaned !== undefined) {
76
+ result.deleteOrphaned = deleteOrphaned;
77
+ }
78
+ return Object.keys(result).length > 0 ? result : undefined;
79
+ }
39
80
  /**
40
81
  * Normalizes raw config into expanded, merged config.
41
82
  * Pipeline: expand git arrays -> merge content -> interpolate env vars
42
83
  */
43
84
  export function normalizeConfig(raw) {
44
85
  const expandedRepos = [];
45
- const fileNames = Object.keys(raw.files);
86
+ const fileNames = raw.files ? Object.keys(raw.files) : [];
46
87
  for (const rawRepo of raw.repos) {
47
88
  // Step 1: Expand git arrays
48
89
  const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
@@ -139,10 +180,13 @@ export function normalizeConfig(raw) {
139
180
  }
140
181
  // Merge PR options: per-repo overrides global
141
182
  const prOptions = mergePROptions(raw.prOptions, rawRepo.prOptions);
183
+ // Merge settings: per-repo deep merges with root settings
184
+ const settings = mergeSettings(raw.settings, rawRepo.settings);
142
185
  expandedRepos.push({
143
186
  git: gitUrl,
144
187
  files,
145
188
  prOptions,
189
+ settings,
146
190
  });
147
191
  }
148
192
  }
@@ -152,5 +196,6 @@ export function normalizeConfig(raw) {
152
196
  prTemplate: raw.prTemplate,
153
197
  githubHosts: raw.githubHosts,
154
198
  deleteOrphaned: raw.deleteOrphaned,
199
+ settings: raw.settings,
155
200
  };
156
201
  }
@@ -1,6 +1,25 @@
1
- import type { RawConfig } from "./config.js";
1
+ import type { RawConfig, RawRepoSettings } from "./config.js";
2
2
  /**
3
3
  * Validates raw config structure before normalization.
4
4
  * @throws Error if validation fails
5
5
  */
6
6
  export declare function validateRawConfig(config: RawConfig): void;
7
+ /**
8
+ * Validates settings object containing rulesets.
9
+ */
10
+ export declare function validateSettings(settings: unknown, context: string): void;
11
+ /**
12
+ * Validates that config is suitable for the sync command.
13
+ * @throws Error if files section is missing or empty
14
+ */
15
+ export declare function validateForSync(config: RawConfig): void;
16
+ /**
17
+ * Checks if settings contain actionable configuration.
18
+ * Currently only rulesets, but extensible for future settings features.
19
+ */
20
+ export declare function hasActionableSettings(settings: RawRepoSettings | undefined): boolean;
21
+ /**
22
+ * Validates that config is suitable for the settings command.
23
+ * @throws Error if no settings are defined or no actionable settings exist
24
+ */
25
+ export declare function validateForSettings(config: RawConfig): void;
@@ -39,13 +39,16 @@ export function validateRawConfig(config) {
39
39
  if (config.id.length > CONFIG_ID_MAX_LENGTH) {
40
40
  throw new Error(`Config 'id' exceeds maximum length of ${CONFIG_ID_MAX_LENGTH} characters`);
41
41
  }
42
- if (!config.files || typeof config.files !== "object") {
43
- throw new Error("Config missing required field: files (must be an object)");
44
- }
45
- const fileNames = Object.keys(config.files);
46
- if (fileNames.length === 0) {
47
- throw new Error("Config files object cannot be empty");
42
+ // Validate at least one of files or settings exists
43
+ const hasFiles = config.files &&
44
+ typeof config.files === "object" &&
45
+ Object.keys(config.files).length > 0;
46
+ const hasSettings = config.settings && typeof config.settings === "object";
47
+ if (!hasFiles && !hasSettings) {
48
+ throw new Error("Config requires at least one of: 'files' or 'settings'. " +
49
+ "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
48
50
  }
51
+ const fileNames = hasFiles ? Object.keys(config.files) : [];
49
52
  // Validate each file definition
50
53
  for (const fileName of fileNames) {
51
54
  validateFileName(fileName);
@@ -121,6 +124,10 @@ export function validateRawConfig(config) {
121
124
  if (!config.repos || !Array.isArray(config.repos)) {
122
125
  throw new Error("Config missing required field: repos (must be an array)");
123
126
  }
127
+ // Validate root settings
128
+ if (config.settings !== undefined) {
129
+ validateSettings(config.settings, "Root");
130
+ }
124
131
  // Validate githubHosts if provided
125
132
  if (config.githubHosts !== undefined) {
126
133
  if (!Array.isArray(config.githubHosts) ||
@@ -155,7 +162,7 @@ export function validateRawConfig(config) {
155
162
  }
156
163
  for (const fileName of Object.keys(repo.files)) {
157
164
  // Ensure the file is defined at root level
158
- if (!config.files[fileName]) {
165
+ if (!config.files || !config.files[fileName]) {
159
166
  throw new Error(`Repo at index ${i} references undefined file '${fileName}'. File must be defined in root 'files' object.`);
160
167
  }
161
168
  const fileOverride = repo.files[fileName];
@@ -224,6 +231,10 @@ export function validateRawConfig(config) {
224
231
  }
225
232
  }
226
233
  }
234
+ // Validate per-repo settings
235
+ if (repo.settings !== undefined) {
236
+ validateSettings(repo.settings, `Repo ${getGitDisplayName(repo.git)}`);
237
+ }
227
238
  }
228
239
  }
229
240
  /**
@@ -248,3 +259,285 @@ function getGitDisplayName(git) {
248
259
  }
249
260
  return git;
250
261
  }
262
+ // =============================================================================
263
+ // Ruleset Validation
264
+ // =============================================================================
265
+ const VALID_RULESET_TARGETS = ["branch", "tag"];
266
+ const VALID_ENFORCEMENT_LEVELS = ["active", "disabled", "evaluate"];
267
+ const VALID_ACTOR_TYPES = ["Team", "User", "Integration"];
268
+ const VALID_BYPASS_MODES = ["always", "pull_request"];
269
+ const VALID_PATTERN_OPERATORS = [
270
+ "starts_with",
271
+ "ends_with",
272
+ "contains",
273
+ "regex",
274
+ ];
275
+ const VALID_MERGE_METHODS = ["merge", "squash", "rebase"];
276
+ const VALID_ALERTS_THRESHOLDS = [
277
+ "none",
278
+ "errors",
279
+ "errors_and_warnings",
280
+ "all",
281
+ ];
282
+ const VALID_SECURITY_THRESHOLDS = [
283
+ "none",
284
+ "critical",
285
+ "high_or_higher",
286
+ "medium_or_higher",
287
+ "all",
288
+ ];
289
+ const VALID_RULE_TYPES = [
290
+ "pull_request",
291
+ "required_status_checks",
292
+ "required_signatures",
293
+ "required_linear_history",
294
+ "non_fast_forward",
295
+ "creation",
296
+ "update",
297
+ "deletion",
298
+ "required_deployments",
299
+ "code_scanning",
300
+ "code_quality",
301
+ "workflows",
302
+ "commit_author_email_pattern",
303
+ "commit_message_pattern",
304
+ "committer_email_pattern",
305
+ "branch_name_pattern",
306
+ "tag_name_pattern",
307
+ "file_path_restriction",
308
+ "file_extension_restriction",
309
+ "max_file_path_length",
310
+ "max_file_size",
311
+ ];
312
+ /**
313
+ * Validates a single ruleset rule.
314
+ */
315
+ function validateRule(rule, context) {
316
+ if (typeof rule !== "object" || rule === null || Array.isArray(rule)) {
317
+ throw new Error(`${context}: rule must be an object`);
318
+ }
319
+ const r = rule;
320
+ if (!r.type || typeof r.type !== "string") {
321
+ throw new Error(`${context}: rule must have a 'type' string field`);
322
+ }
323
+ if (!VALID_RULE_TYPES.includes(r.type)) {
324
+ throw new Error(`${context}: invalid rule type '${r.type}'. Must be one of: ${VALID_RULE_TYPES.join(", ")}`);
325
+ }
326
+ // Validate parameters based on rule type
327
+ if (r.parameters !== undefined) {
328
+ if (typeof r.parameters !== "object" ||
329
+ r.parameters === null ||
330
+ Array.isArray(r.parameters)) {
331
+ throw new Error(`${context}: rule parameters must be an object`);
332
+ }
333
+ const params = r.parameters;
334
+ // Validate pattern rule parameters
335
+ if (r.type.toString().endsWith("_pattern")) {
336
+ if (params.operator !== undefined &&
337
+ !VALID_PATTERN_OPERATORS.includes(params.operator)) {
338
+ throw new Error(`${context}: pattern rule operator must be one of: ${VALID_PATTERN_OPERATORS.join(", ")}`);
339
+ }
340
+ if (params.pattern !== undefined && typeof params.pattern !== "string") {
341
+ throw new Error(`${context}: pattern rule pattern must be a string`);
342
+ }
343
+ }
344
+ // Validate pull_request parameters
345
+ if (r.type === "pull_request") {
346
+ if (params.requiredApprovingReviewCount !== undefined) {
347
+ const count = params.requiredApprovingReviewCount;
348
+ if (typeof count !== "number" ||
349
+ !Number.isInteger(count) ||
350
+ count < 0 ||
351
+ count > 10) {
352
+ throw new Error(`${context}: requiredApprovingReviewCount must be an integer between 0 and 10`);
353
+ }
354
+ }
355
+ if (params.allowedMergeMethods !== undefined) {
356
+ if (!Array.isArray(params.allowedMergeMethods)) {
357
+ throw new Error(`${context}: allowedMergeMethods must be an array`);
358
+ }
359
+ for (const method of params.allowedMergeMethods) {
360
+ if (!VALID_MERGE_METHODS.includes(method)) {
361
+ throw new Error(`${context}: allowedMergeMethods values must be one of: ${VALID_MERGE_METHODS.join(", ")}`);
362
+ }
363
+ }
364
+ }
365
+ }
366
+ // Validate code_scanning parameters
367
+ if (r.type === "code_scanning" && params.codeScanningTools !== undefined) {
368
+ if (!Array.isArray(params.codeScanningTools)) {
369
+ throw new Error(`${context}: codeScanningTools must be an array`);
370
+ }
371
+ for (const tool of params.codeScanningTools) {
372
+ if (typeof tool !== "object" || tool === null) {
373
+ throw new Error(`${context}: each codeScanningTool must be an object`);
374
+ }
375
+ const t = tool;
376
+ if (t.alertsThreshold !== undefined &&
377
+ !VALID_ALERTS_THRESHOLDS.includes(t.alertsThreshold)) {
378
+ throw new Error(`${context}: alertsThreshold must be one of: ${VALID_ALERTS_THRESHOLDS.join(", ")}`);
379
+ }
380
+ if (t.securityAlertsThreshold !== undefined &&
381
+ !VALID_SECURITY_THRESHOLDS.includes(t.securityAlertsThreshold)) {
382
+ throw new Error(`${context}: securityAlertsThreshold must be one of: ${VALID_SECURITY_THRESHOLDS.join(", ")}`);
383
+ }
384
+ }
385
+ }
386
+ }
387
+ }
388
+ /**
389
+ * Validates a single ruleset.
390
+ */
391
+ function validateRuleset(ruleset, name, context) {
392
+ if (typeof ruleset !== "object" ||
393
+ ruleset === null ||
394
+ Array.isArray(ruleset)) {
395
+ throw new Error(`${context}: ruleset '${name}' must be an object`);
396
+ }
397
+ const rs = ruleset;
398
+ if (rs.target !== undefined &&
399
+ !VALID_RULESET_TARGETS.includes(rs.target)) {
400
+ throw new Error(`${context}: ruleset '${name}' target must be one of: ${VALID_RULESET_TARGETS.join(", ")}`);
401
+ }
402
+ if (rs.enforcement !== undefined &&
403
+ !VALID_ENFORCEMENT_LEVELS.includes(rs.enforcement)) {
404
+ throw new Error(`${context}: ruleset '${name}' enforcement must be one of: ${VALID_ENFORCEMENT_LEVELS.join(", ")}`);
405
+ }
406
+ // Validate bypassActors
407
+ if (rs.bypassActors !== undefined) {
408
+ if (!Array.isArray(rs.bypassActors)) {
409
+ throw new Error(`${context}: ruleset '${name}' bypassActors must be an array`);
410
+ }
411
+ for (let i = 0; i < rs.bypassActors.length; i++) {
412
+ const actor = rs.bypassActors[i];
413
+ if (typeof actor !== "object" || actor === null) {
414
+ throw new Error(`${context}: ruleset '${name}' bypassActors[${i}] must be an object`);
415
+ }
416
+ if (typeof actor.actorId !== "number") {
417
+ throw new Error(`${context}: ruleset '${name}' bypassActors[${i}].actorId must be a number`);
418
+ }
419
+ if (!VALID_ACTOR_TYPES.includes(actor.actorType)) {
420
+ throw new Error(`${context}: ruleset '${name}' bypassActors[${i}].actorType must be one of: ${VALID_ACTOR_TYPES.join(", ")}`);
421
+ }
422
+ if (actor.bypassMode !== undefined &&
423
+ !VALID_BYPASS_MODES.includes(actor.bypassMode)) {
424
+ throw new Error(`${context}: ruleset '${name}' bypassActors[${i}].bypassMode must be one of: ${VALID_BYPASS_MODES.join(", ")}`);
425
+ }
426
+ }
427
+ }
428
+ // Validate conditions
429
+ if (rs.conditions !== undefined) {
430
+ if (typeof rs.conditions !== "object" ||
431
+ rs.conditions === null ||
432
+ Array.isArray(rs.conditions)) {
433
+ throw new Error(`${context}: ruleset '${name}' conditions must be an object`);
434
+ }
435
+ const conditions = rs.conditions;
436
+ if (conditions.refName !== undefined) {
437
+ const refName = conditions.refName;
438
+ if (typeof refName !== "object" ||
439
+ refName === null ||
440
+ Array.isArray(refName)) {
441
+ throw new Error(`${context}: ruleset '${name}' conditions.refName must be an object`);
442
+ }
443
+ if (refName.include !== undefined &&
444
+ (!Array.isArray(refName.include) ||
445
+ !refName.include.every((s) => typeof s === "string"))) {
446
+ throw new Error(`${context}: ruleset '${name}' conditions.refName.include must be an array of strings`);
447
+ }
448
+ if (refName.exclude !== undefined &&
449
+ (!Array.isArray(refName.exclude) ||
450
+ !refName.exclude.every((s) => typeof s === "string"))) {
451
+ throw new Error(`${context}: ruleset '${name}' conditions.refName.exclude must be an array of strings`);
452
+ }
453
+ }
454
+ }
455
+ // Validate rules array
456
+ if (rs.rules !== undefined) {
457
+ if (!Array.isArray(rs.rules)) {
458
+ throw new Error(`${context}: ruleset '${name}' rules must be an array`);
459
+ }
460
+ for (let i = 0; i < rs.rules.length; i++) {
461
+ validateRule(rs.rules[i], `${context}: ruleset '${name}' rules[${i}]`);
462
+ }
463
+ }
464
+ }
465
+ /**
466
+ * Validates settings object containing rulesets.
467
+ */
468
+ export function validateSettings(settings, context) {
469
+ if (typeof settings !== "object" ||
470
+ settings === null ||
471
+ Array.isArray(settings)) {
472
+ throw new Error(`${context}: settings must be an object`);
473
+ }
474
+ const s = settings;
475
+ if (s.rulesets !== undefined) {
476
+ if (typeof s.rulesets !== "object" ||
477
+ s.rulesets === null ||
478
+ Array.isArray(s.rulesets)) {
479
+ throw new Error(`${context}: rulesets must be an object`);
480
+ }
481
+ const rulesets = s.rulesets;
482
+ for (const [name, ruleset] of Object.entries(rulesets)) {
483
+ validateRuleset(ruleset, name, context);
484
+ }
485
+ }
486
+ if (s.deleteOrphaned !== undefined && typeof s.deleteOrphaned !== "boolean") {
487
+ throw new Error(`${context}: settings.deleteOrphaned must be a boolean`);
488
+ }
489
+ }
490
+ // =============================================================================
491
+ // Command-Specific Validators
492
+ // =============================================================================
493
+ /**
494
+ * Validates that config is suitable for the sync command.
495
+ * @throws Error if files section is missing or empty
496
+ */
497
+ export function validateForSync(config) {
498
+ if (!config.files) {
499
+ throw new Error("The 'sync' command requires a 'files' section with at least one file defined. " +
500
+ "To manage repository settings instead, use 'xfg settings'.");
501
+ }
502
+ const fileNames = Object.keys(config.files);
503
+ if (fileNames.length === 0) {
504
+ throw new Error("The 'sync' command requires a 'files' section with at least one file defined. " +
505
+ "To manage repository settings instead, use 'xfg settings'.");
506
+ }
507
+ }
508
+ /**
509
+ * Checks if settings contain actionable configuration.
510
+ * Currently only rulesets, but extensible for future settings features.
511
+ */
512
+ export function hasActionableSettings(settings) {
513
+ if (!settings)
514
+ return false;
515
+ // Check for rulesets
516
+ if (settings.rulesets && Object.keys(settings.rulesets).length > 0) {
517
+ return true;
518
+ }
519
+ // Future: check for repoConfig, creation, etc.
520
+ // if (settings.repoConfig) return true;
521
+ return false;
522
+ }
523
+ /**
524
+ * Validates that config is suitable for the settings command.
525
+ * @throws Error if no settings are defined or no actionable settings exist
526
+ */
527
+ export function validateForSettings(config) {
528
+ // Check if settings exist at root or in any repo
529
+ const hasRootSettings = config.settings !== undefined;
530
+ const hasRepoSettings = config.repos.some((repo) => repo.settings !== undefined);
531
+ if (!hasRootSettings && !hasRepoSettings) {
532
+ throw new Error("The 'settings' command requires a 'settings' section at root level or " +
533
+ "in at least one repo. To sync files instead, use 'xfg sync'.");
534
+ }
535
+ // Check if there's at least one actionable setting
536
+ const rootActionable = hasActionableSettings(config.settings);
537
+ const repoActionable = config.repos.some((repo) => hasActionableSettings(repo.settings));
538
+ if (!rootActionable && !repoActionable) {
539
+ throw new Error("No actionable settings configured. Currently supported: rulesets. " +
540
+ "To sync files instead, use 'xfg sync'. " +
541
+ "See docs: https://anthony-spruyt.github.io/xfg/settings");
542
+ }
543
+ }