@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 +24 -8
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +20 -0
- package/dist/config-normalizer.d.ts +6 -1
- package/dist/config-normalizer.js +46 -1
- package/dist/config-validator.d.ts +20 -1
- package/dist/config-validator.js +300 -7
- package/dist/config.d.ts +221 -1
- package/dist/config.js +12 -3
- package/dist/index.d.ts +37 -0
- package/dist/index.js +215 -35
- package/dist/manifest.d.ts +32 -4
- package/dist/manifest.js +122 -11
- package/dist/repository-processor.d.ts +14 -0
- package/dist/repository-processor.js +196 -21
- package/dist/ruleset-diff.d.ts +26 -0
- package/dist/ruleset-diff.js +231 -0
- package/dist/ruleset-processor.d.ts +47 -0
- package/dist/ruleset-processor.js +145 -0
- package/dist/strategies/github-ruleset-strategy.d.ts +84 -0
- package/dist/strategies/github-ruleset-strategy.js +210 -0
- package/dist/strategies/graphql-commit-strategy.d.ts +6 -2
- package/dist/strategies/graphql-commit-strategy.js +25 -14
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](https://anthony-spruyt.github.io/xfg/)
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
|
|
11
|
-
A CLI tool
|
|
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@
|
|
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
|
-
#
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
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;
|
package/dist/config-validator.js
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|