@aspruyt/xfg 6.2.0 → 6.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.
Files changed (52) hide show
  1. package/dist/cli/branch-utils.d.ts +1 -5
  2. package/dist/cli/branch-utils.js +1 -22
  3. package/dist/cli/program.js +41 -3
  4. package/dist/cli/repo-sync-runner.js +7 -2
  5. package/dist/cli/secrets-command.d.ts +25 -0
  6. package/dist/cli/secrets-command.js +75 -0
  7. package/dist/cli/settings-factories.d.ts +2 -1
  8. package/dist/cli/settings-factories.js +6 -1
  9. package/dist/cli/settings-report-builder.d.ts +6 -1
  10. package/dist/cli/settings-report-builder.js +21 -2
  11. package/dist/cli/settings-runner.js +7 -0
  12. package/dist/cli/types.d.ts +4 -2
  13. package/dist/config/index.d.ts +2 -2
  14. package/dist/config/index.js +1 -1
  15. package/dist/config/loader.js +46 -17
  16. package/dist/config/normalizer.js +86 -1
  17. package/dist/config/types.d.ts +21 -0
  18. package/dist/config/validator.d.ts +4 -0
  19. package/dist/config/validator.js +178 -5
  20. package/dist/config/validators/group-validator.js +7 -0
  21. package/dist/config/validators/repo-entry-validator.js +7 -0
  22. package/dist/output/settings-report.d.ts +11 -0
  23. package/dist/output/settings-report.js +24 -0
  24. package/dist/secrets/encryption.d.ts +9 -0
  25. package/dist/secrets/encryption.js +29 -0
  26. package/dist/secrets/github-secrets-strategy.d.ts +17 -0
  27. package/dist/secrets/github-secrets-strategy.js +38 -0
  28. package/dist/secrets/index.d.ts +5 -0
  29. package/dist/secrets/index.js +3 -0
  30. package/dist/secrets/processor.d.ts +31 -0
  31. package/dist/secrets/processor.js +115 -0
  32. package/dist/secrets/types.d.ts +21 -0
  33. package/dist/secrets/types.js +1 -0
  34. package/dist/settings/index.d.ts +1 -0
  35. package/dist/settings/index.js +2 -0
  36. package/dist/settings/variables/diff.d.ts +10 -0
  37. package/dist/settings/variables/diff.js +39 -0
  38. package/dist/settings/variables/formatter.d.ts +16 -0
  39. package/dist/settings/variables/formatter.js +70 -0
  40. package/dist/settings/variables/github-variables-strategy.d.ts +17 -0
  41. package/dist/settings/variables/github-variables-strategy.js +40 -0
  42. package/dist/settings/variables/index.d.ts +4 -0
  43. package/dist/settings/variables/index.js +2 -0
  44. package/dist/settings/variables/processor.d.ts +19 -0
  45. package/dist/settings/variables/processor.js +60 -0
  46. package/dist/settings/variables/types.d.ts +18 -0
  47. package/dist/settings/variables/types.js +1 -0
  48. package/dist/shared/branch-validation.d.ts +2 -0
  49. package/dist/shared/branch-validation.js +19 -0
  50. package/dist/shared/env-resolver.d.ts +16 -0
  51. package/dist/shared/env-resolver.js +33 -0
  52. package/package.json +3 -1
@@ -1,6 +1,2 @@
1
+ export { validateBranchName } from "../shared/branch-validation.js";
1
2
  export declare function sanitizeBranchName(fileName: string): string;
2
- /**
3
- * Validates a user-provided branch name against git's naming rules.
4
- * @throws ValidationError if the branch name is invalid
5
- */
6
- export declare function validateBranchName(branchName: string): void;
@@ -1,4 +1,4 @@
1
- import { ValidationError } from "../shared/errors.js";
1
+ export { validateBranchName } from "../shared/branch-validation.js";
2
2
  export function sanitizeBranchName(fileName) {
3
3
  return fileName
4
4
  .toLowerCase()
@@ -7,24 +7,3 @@ export function sanitizeBranchName(fileName) {
7
7
  .replace(/-+/g, "-") // Collapse multiple dashes
8
8
  .replace(/^-|-$/g, ""); // Remove leading/trailing dashes
9
9
  }
10
- /**
11
- * Validates a user-provided branch name against git's naming rules.
12
- * @throws ValidationError if the branch name is invalid
13
- */
14
- export function validateBranchName(branchName) {
15
- if (!branchName || branchName.trim() === "") {
16
- throw new ValidationError("Branch name cannot be empty");
17
- }
18
- if (branchName.startsWith(".") || branchName.startsWith("-")) {
19
- throw new ValidationError('Branch name cannot start with "." or "-"');
20
- }
21
- // Git disallows: space, ~, ^, :, ?, *, [, \, and consecutive dots (..)
22
- if (/[\s~^:?*[\\]/.test(branchName) || branchName.includes("..")) {
23
- throw new ValidationError("Branch name contains invalid characters");
24
- }
25
- if (branchName.endsWith("/") ||
26
- branchName.endsWith(".lock") ||
27
- branchName.endsWith(".")) {
28
- throw new ValidationError("Branch name has invalid ending");
29
- }
30
- }
@@ -1,9 +1,10 @@
1
- import { program, Command } from "commander";
1
+ import { program, Command, InvalidArgumentError } from "commander";
2
2
  import { dirname, join } from "node:path";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { ValidationError } from "../shared/errors.js";
6
6
  import { runSync } from "./sync-command.js";
7
+ import { runSecretsSync } from "./secrets-command.js";
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = dirname(__filename);
9
10
  function getVersion() {
@@ -26,7 +27,12 @@ function addSharedOptions(cmd) {
26
27
  .requiredOption("-c, --config <path>", "Path to YAML config file")
27
28
  .option("-d, --dry-run", "Show what would be done without making changes")
28
29
  .option("-w, --work-dir <path>", "Temporary directory for cloning", "./tmp")
29
- .option("-r, --retries <number>", "Number of retries for network operations (0 to disable)", (v) => parseInt(v, 10), 3)
30
+ .option("-r, --retries <number>", "Number of retries for network operations (0 to disable)", (v) => {
31
+ const n = parseInt(v, 10);
32
+ if (isNaN(n) || n < 0)
33
+ throw new InvalidArgumentError("Must be a non-negative number.");
34
+ return n;
35
+ }, 3)
30
36
  .option("--no-delete", "Skip deletion of orphaned resources even if deleteOrphaned is configured");
31
37
  }
32
38
  // =============================================================================
@@ -61,7 +67,11 @@ const syncCommand = new Command("sync")
61
67
  .option("--delete-branch", "Delete source branch after merge")
62
68
  .action(async (opts) => {
63
69
  try {
64
- await runSync(opts);
70
+ const options = {
71
+ ...opts,
72
+ noDelete: opts.delete === false,
73
+ };
74
+ await runSync(options);
65
75
  }
66
76
  catch (error) {
67
77
  console.error("Fatal error:", error);
@@ -70,4 +80,32 @@ const syncCommand = new Command("sync")
70
80
  });
71
81
  addSharedOptions(syncCommand);
72
82
  program.addCommand(syncCommand);
83
+ const secretsCommand = new Command("secrets").description("Manage repository secrets");
84
+ const secretsSyncCommand = new Command("sync")
85
+ .description("Sync secrets to target repositories")
86
+ .requiredOption("-c, --config <path>", "Path to xfg config file")
87
+ .option("-d, --dry-run", "Show what would be done without making changes")
88
+ .option("--no-delete", "Skip deletion of orphaned secrets")
89
+ .option("-w, --work-dir <path>", "Temporary directory for cloning", "./tmp")
90
+ .option("-r, --retries <number>", "Number of retries for network operations (0 to disable)", (value) => {
91
+ const n = parseInt(value, 10);
92
+ if (isNaN(n) || n < 0)
93
+ throw new InvalidArgumentError("Must be a non-negative number.");
94
+ return n;
95
+ }, 3)
96
+ .action(async (opts) => {
97
+ try {
98
+ const options = {
99
+ ...opts,
100
+ noDelete: opts.delete === false,
101
+ };
102
+ await runSecretsSync(options);
103
+ }
104
+ catch (error) {
105
+ console.error("Fatal error:", error);
106
+ return process.exit(1);
107
+ }
108
+ });
109
+ secretsCommand.addCommand(secretsSyncCommand);
110
+ program.addCommand(secretsCommand);
73
111
  export { program, getVersion };
@@ -56,8 +56,9 @@ async function runFileSyncPhase(repo, ctx) {
56
56
  const repoNumber = repo.index + 1;
57
57
  try {
58
58
  ctx.logger.progress(repoNumber, repo.repoName, "Processing...");
59
+ const branchName = repo.repoConfig.prOptions?.branch ?? ctx.branchName;
59
60
  const result = await ctx.processor.process(repo.repoConfig, repo.repoInfo, {
60
- branchName: ctx.branchName,
61
+ branchName,
61
62
  workDir: repo.workDir,
62
63
  configId: ctx.config.id,
63
64
  dryRun: ctx.options.dryRun,
@@ -95,12 +96,16 @@ async function runFileSyncPhase(repo, ctx) {
95
96
  export async function runSingleRepo(repoConfig, index, ctx) {
96
97
  const { config, options, logger } = ctx;
97
98
  const repoNumber = index + 1;
98
- const effectivePrOptions = options.merge || options.mergeStrategy || options.deleteBranch
99
+ const effectivePrOptions = options.merge ||
100
+ options.mergeStrategy ||
101
+ options.deleteBranch ||
102
+ options.branch
99
103
  ? {
100
104
  ...repoConfig.prOptions,
101
105
  merge: options.merge ?? repoConfig.prOptions?.merge,
102
106
  mergeStrategy: options.mergeStrategy ?? repoConfig.prOptions?.mergeStrategy,
103
107
  deleteBranch: options.deleteBranch ?? repoConfig.prOptions?.deleteBranch,
108
+ branch: options.branch ?? repoConfig.prOptions?.branch,
104
109
  }
105
110
  : repoConfig.prOptions;
106
111
  const effectiveRepoConfig = { ...repoConfig, prOptions: effectivePrOptions };
@@ -0,0 +1,25 @@
1
+ import type { SecretsProcessorResult } from "../secrets/processor.js";
2
+ import type { SecretConfig, Config } from "../config/index.js";
3
+ import type { RepoInfo } from "../repo/index.js";
4
+ type SecretsConfig = Record<string, SecretConfig | boolean> & {
5
+ deleteOrphaned?: boolean;
6
+ };
7
+ export interface ISecretsProcessorAdapter {
8
+ process(secretsConfig: SecretsConfig, repoInfo: RepoInfo, options: {
9
+ dryRun?: boolean;
10
+ token?: string;
11
+ noDelete?: boolean;
12
+ }): Promise<SecretsProcessorResult>;
13
+ }
14
+ export interface SecretsSyncDependencies {
15
+ processorFactory?: (config: Config, cwd: string, retries: number) => ISecretsProcessorAdapter;
16
+ }
17
+ export interface SecretsSyncOptions {
18
+ config: string;
19
+ dryRun?: boolean;
20
+ noDelete?: boolean;
21
+ workDir?: string;
22
+ retries?: number;
23
+ }
24
+ export declare function runSecretsSync(options: SecretsSyncOptions, deps?: SecretsSyncDependencies): Promise<void>;
25
+ export {};
@@ -0,0 +1,75 @@
1
+ import { loadRawConfig } from "../config/index.js";
2
+ import { normalizeConfig } from "../config/normalizer.js";
3
+ import { validateRawConfig, validateSecretsConfig, validateVariableSecretOverlaps, } from "../config/validator.js";
4
+ import { SecretsProcessor, GitHubSecretsStrategy, SodiumEncryptor, } from "../secrets/index.js";
5
+ import { EnvResolver } from "../shared/env-resolver.js";
6
+ import { ProcessExecutor } from "../shared/command-executor.js";
7
+ import { parseGitUrl } from "../repo/index.js";
8
+ import { Logger } from "../shared/logger.js";
9
+ import { toErrorMessage } from "../shared/type-guards.js";
10
+ function createDefaultProcessor(_config, cwd, retries) {
11
+ const executor = new ProcessExecutor(process.env);
12
+ const encryptor = new SodiumEncryptor();
13
+ const envResolver = new EnvResolver(process.env);
14
+ const strategy = new GitHubSecretsStrategy(executor, {
15
+ cwd,
16
+ retries,
17
+ });
18
+ return new SecretsProcessor(strategy, encryptor, envResolver);
19
+ }
20
+ export async function runSecretsSync(options, deps = {}) {
21
+ const logger = new Logger(!!(process.env.DEBUG || process.env.XFG_DEBUG));
22
+ const { config: configPath, dryRun, workDir, retries, noDelete } = options;
23
+ const cwd = workDir ?? "./tmp";
24
+ const rawConfig = loadRawConfig(configPath);
25
+ validateRawConfig(rawConfig);
26
+ validateSecretsConfig(rawConfig);
27
+ validateVariableSecretOverlaps(rawConfig);
28
+ const config = normalizeConfig(rawConfig, process.env);
29
+ if (!config.secrets) {
30
+ logger.info("No secrets configured. Nothing to do.");
31
+ return;
32
+ }
33
+ const { deleteOrphaned, ...secretEntries } = config.secrets;
34
+ const secretNames = Object.keys(secretEntries).filter((k) => typeof secretEntries[k] !== "boolean");
35
+ if (secretNames.length === 0 && !deleteOrphaned) {
36
+ logger.info("No secrets configured. Nothing to do.");
37
+ return;
38
+ }
39
+ const processorFactory = deps.processorFactory ?? createDefaultProcessor;
40
+ const processor = processorFactory(config, cwd, retries ?? 3);
41
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
42
+ let hasErrors = false;
43
+ logger.setTotal(config.repos.length);
44
+ for (let i = 0; i < config.repos.length; i++) {
45
+ const repoConfig = config.repos[i];
46
+ const repoName = repoConfig.git;
47
+ try {
48
+ const repoInfo = parseGitUrl(repoConfig.git, {
49
+ githubHosts: config.githubHosts,
50
+ });
51
+ const result = await processor.process(config.secrets, repoInfo, {
52
+ dryRun,
53
+ token,
54
+ noDelete,
55
+ });
56
+ if (result.skipped) {
57
+ logger.skip(i + 1, repoName, result.message);
58
+ }
59
+ else if (result.success) {
60
+ logger.success(i + 1, repoName, `Secrets: ${result.message}`);
61
+ }
62
+ else {
63
+ logger.error(i + 1, repoName, `Secrets: ${result.message}`);
64
+ hasErrors = true;
65
+ }
66
+ }
67
+ catch (error) {
68
+ logger.error(i + 1, repoName, `Secrets: ${toErrorMessage(error)}`);
69
+ hasErrors = true;
70
+ }
71
+ }
72
+ if (hasErrors) {
73
+ throw new Error("One or more repositories failed secrets sync.");
74
+ }
75
+ }
@@ -1,7 +1,8 @@
1
1
  import type { ProcessExecutor } from "../shared/command-executor.js";
2
- import type { RulesetProcessorFactory, RepoSettingsProcessorFactory, LabelsProcessorFactory, CodeScanningProcessorFactory, SettingsProcessorFactories } from "./types.js";
2
+ import type { RulesetProcessorFactory, RepoSettingsProcessorFactory, LabelsProcessorFactory, CodeScanningProcessorFactory, VariablesProcessorFactory, SettingsProcessorFactories } from "./types.js";
3
3
  export declare function createDefaultRulesetProcessorFactory(executor: ProcessExecutor): RulesetProcessorFactory;
4
4
  export declare function createDefaultRepoSettingsProcessorFactory(executor: ProcessExecutor): RepoSettingsProcessorFactory;
5
5
  export declare function createDefaultLabelsProcessorFactory(executor: ProcessExecutor): LabelsProcessorFactory;
6
6
  export declare function createDefaultCodeScanningProcessorFactory(executor: ProcessExecutor): CodeScanningProcessorFactory;
7
+ export declare function createDefaultVariablesProcessorFactory(executor: ProcessExecutor): VariablesProcessorFactory;
7
8
  export declare function createDefaultFactories(executor: ProcessExecutor, overrides?: Partial<SettingsProcessorFactories>): SettingsProcessorFactories;
@@ -1,4 +1,4 @@
1
- import { RulesetProcessor, RepoSettingsProcessor, LabelsProcessor, CodeScanningProcessor, GitHubRulesetStrategy, GitHubRepoSettingsStrategy, GitHubLabelsStrategy, GitHubCodeScanningStrategy, } from "../settings/index.js";
1
+ import { RulesetProcessor, RepoSettingsProcessor, LabelsProcessor, CodeScanningProcessor, VariablesProcessor, GitHubRulesetStrategy, GitHubRepoSettingsStrategy, GitHubLabelsStrategy, GitHubCodeScanningStrategy, GitHubVariablesStrategy, } from "../settings/index.js";
2
2
  import { GitHubRepoMetadataProvider } from "../repo/index.js";
3
3
  export function createDefaultRulesetProcessorFactory(executor) {
4
4
  const cwd = process.cwd();
@@ -16,6 +16,10 @@ export function createDefaultCodeScanningProcessorFactory(executor) {
16
16
  const cwd = process.cwd();
17
17
  return () => new CodeScanningProcessor(new GitHubCodeScanningStrategy(executor, { cwd }), new GitHubRepoMetadataProvider(executor, { cwd }));
18
18
  }
19
+ export function createDefaultVariablesProcessorFactory(executor) {
20
+ const cwd = process.cwd();
21
+ return () => new VariablesProcessor(new GitHubVariablesStrategy(executor, { cwd }));
22
+ }
19
23
  export function createDefaultFactories(executor, overrides) {
20
24
  return {
21
25
  rulesets: overrides?.rulesets ?? createDefaultRulesetProcessorFactory(executor),
@@ -23,5 +27,6 @@ export function createDefaultFactories(executor, overrides) {
23
27
  repo: overrides?.repo ?? createDefaultRepoSettingsProcessorFactory(executor),
24
28
  codeScanning: overrides?.codeScanning ??
25
29
  createDefaultCodeScanningProcessorFactory(executor),
30
+ variables: overrides?.variables ?? createDefaultVariablesProcessorFactory(executor),
26
31
  };
27
32
  }
@@ -1,5 +1,5 @@
1
1
  import type { SettingsReport } from "../output/index.js";
2
- import { type RepoSettingsPlanEntry, type RulesetPlanEntry, type LabelsPlanEntry, type CodeScanningPlanEntry } from "../settings/index.js";
2
+ import { type RepoSettingsPlanEntry, type RulesetPlanEntry, type LabelsPlanEntry, type CodeScanningPlanEntry, type VariablesPlanEntry } from "../settings/index.js";
3
3
  /**
4
4
  * Result from processing a repository's settings and rulesets.
5
5
  * Used to collect results during settings command execution.
@@ -26,6 +26,11 @@ export interface ProcessorResults {
26
26
  entries?: CodeScanningPlanEntry[];
27
27
  };
28
28
  };
29
+ variablesResult?: {
30
+ planOutput?: {
31
+ entries?: VariablesPlanEntry[];
32
+ };
33
+ };
29
34
  error?: string;
30
35
  }
31
36
  export declare function buildSettingsReport(results: ProcessorResults[]): SettingsReport;
@@ -5,6 +5,7 @@ export function buildSettingsReport(results) {
5
5
  settings: { create: 0, update: 0 },
6
6
  rulesets: { create: 0, update: 0, delete: 0 },
7
7
  labels: { create: 0, update: 0, delete: 0 },
8
+ variables: { create: 0, update: 0, delete: 0 },
8
9
  };
9
10
  for (const result of results) {
10
11
  const repoChanges = {
@@ -12,12 +13,12 @@ export function buildSettingsReport(results) {
12
13
  settings: [],
13
14
  rulesets: [],
14
15
  labels: [],
16
+ variables: [],
15
17
  };
16
18
  if (result.settingsResult?.planOutput?.entries) {
17
19
  for (const entry of result.settingsResult.planOutput.entries) {
18
- if (entry.oldValue === undefined && entry.newValue === undefined) {
20
+ if (!isActiveAction(entry))
19
21
  continue;
20
- }
21
22
  repoChanges.settings.push({
22
23
  name: entry.property,
23
24
  action: entry.action,
@@ -28,6 +29,8 @@ export function buildSettingsReport(results) {
28
29
  }
29
30
  if (result.codeScanningResult?.planOutput?.entries) {
30
31
  for (const entry of result.codeScanningResult.planOutput.entries) {
32
+ if (!isActiveAction(entry))
33
+ continue;
31
34
  repoChanges.settings.push({
32
35
  name: `codeScanning.${entry.property}`,
33
36
  action: entry.action,
@@ -74,6 +77,22 @@ export function buildSettingsReport(results) {
74
77
  totals.labels.update += counts.update;
75
78
  totals.labels.delete += counts.delete;
76
79
  }
80
+ if (result.variablesResult?.planOutput?.entries) {
81
+ for (const entry of result.variablesResult.planOutput.entries) {
82
+ if (!isActiveAction(entry))
83
+ continue;
84
+ repoChanges.variables.push({
85
+ name: entry.name,
86
+ action: entry.action,
87
+ oldValue: entry.oldValue,
88
+ newValue: entry.newValue,
89
+ });
90
+ }
91
+ const counts = countActions(repoChanges.variables);
92
+ totals.variables.create += counts.create;
93
+ totals.variables.update += counts.update;
94
+ totals.variables.delete += counts.delete;
95
+ }
77
96
  if (result.error) {
78
97
  repoChanges.error = result.error;
79
98
  }
@@ -65,6 +65,13 @@ function buildSettingsDescriptors(ctx) {
65
65
  e.codeScanningResult = r;
66
66
  }),
67
67
  },
68
+ {
69
+ key: "variables",
70
+ label: "Variables",
71
+ run: () => runAndStoreResult(factories.variables, repoConfig, repoInfo, sharedOpts, repoName, settingsCollector, (e, r) => {
72
+ e.variablesResult = r;
73
+ }),
74
+ },
68
75
  ];
69
76
  }
70
77
  export async function applyRepoSettings(ctx) {
@@ -1,7 +1,7 @@
1
1
  import type { MergeMode, MergeStrategy, RepoConfig } from "../config/index.js";
2
2
  import type { IRepoLifecycleManager } from "../lifecycle/index.js";
3
3
  import type { IRepositoryProcessor, FileChangeDetail } from "../sync/index.js";
4
- import type { ISettingsProcessor, IRulesetProcessor, IRepoSettingsProcessor, ILabelsProcessor, ICodeScanningProcessor, BaseProcessorResult } from "../settings/index.js";
4
+ import type { ISettingsProcessor, IRulesetProcessor, IRepoSettingsProcessor, ILabelsProcessor, ICodeScanningProcessor, IVariablesProcessor, BaseProcessorResult } from "../settings/index.js";
5
5
  import type { RepoInfo } from "../repo/index.js";
6
6
  import type { ResultsCollector } from "./results-collector.js";
7
7
  import type { Logger } from "../shared/logger.js";
@@ -11,12 +11,14 @@ export type RulesetProcessorFactory = SettingsProcessorFactory<IRulesetProcessor
11
11
  export type RepoSettingsProcessorFactory = SettingsProcessorFactory<IRepoSettingsProcessor>;
12
12
  export type LabelsProcessorFactory = SettingsProcessorFactory<ILabelsProcessor>;
13
13
  export type CodeScanningProcessorFactory = SettingsProcessorFactory<ICodeScanningProcessor>;
14
- export type SettingsKind = "rulesets" | "labels" | "repo" | "codeScanning";
14
+ export type VariablesProcessorFactory = SettingsProcessorFactory<IVariablesProcessor>;
15
+ export type SettingsKind = "rulesets" | "labels" | "repo" | "codeScanning" | "variables";
15
16
  export interface SettingsProcessorFactories {
16
17
  rulesets: RulesetProcessorFactory;
17
18
  labels: LabelsProcessorFactory;
18
19
  repo: RepoSettingsProcessorFactory;
19
20
  codeScanning: CodeScanningProcessorFactory;
21
+ variables: VariablesProcessorFactory;
20
22
  }
21
23
  /**
22
24
  * Dependencies for the sync command (dependency injection).
@@ -1,5 +1,5 @@
1
- export type { PRMergeOptions, MergeMode, MergeStrategy, BypassActor, StatusCheckConfig, CodeScanningTool, PullRequestRuleParameters, RulesetRule, Ruleset, GitHubRepoSettings, RepoVisibility, SquashMergeCommitTitle, SquashMergeCommitMessage, MergeCommitTitle, MergeCommitMessage, Label, CodeScanningSettings, CodeScanningState, CodeScanningQuerySuite, CodeScanningLanguage, RepoSettings, RawFileConfig, RawRepoFileOverride, RawGroupConfig, RawRepoSettings, RawRepoConfig, RawConfig, RawConditionalGroupWhen, RawConditionalGroupConfig, RepoConfig, Config, FileContent, ContentValue, } from "./types.js";
1
+ export type { PRMergeOptions, MergeMode, MergeStrategy, BypassActor, StatusCheckConfig, CodeScanningTool, PullRequestRuleParameters, RulesetRule, Ruleset, GitHubRepoSettings, RepoVisibility, SquashMergeCommitTitle, SquashMergeCommitMessage, MergeCommitTitle, MergeCommitMessage, Label, CodeScanningSettings, CodeScanningState, CodeScanningQuerySuite, CodeScanningLanguage, RepoSettings, RawFileConfig, RawRepoFileOverride, RawGroupConfig, SecretConfig, RawRootSettings, RawRepoSettings, RawRepoConfig, RawConfig, RawConditionalGroupWhen, RawConditionalGroupConfig, RepoConfig, Config, FileContent, ContentValue, } from "./types.js";
2
2
  export { RULESET_COMPARABLE_FIELDS } from "./types.js";
3
3
  export { loadRawConfig, loadConfig, normalizeConfig } from "./loader.js";
4
4
  export { convertContentToString } from "./formatter.js";
5
- export { validateForSync } from "./validator.js";
5
+ export { validateForSync, validateSecretsConfig } from "./validator.js";
@@ -5,4 +5,4 @@ export { loadRawConfig, loadConfig, normalizeConfig } from "./loader.js";
5
5
  // Config formatting
6
6
  export { convertContentToString } from "./formatter.js";
7
7
  // Config validation
8
- export { validateForSync } from "./validator.js";
8
+ export { validateForSync, validateSecretsConfig } from "./validator.js";
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, statSync, readdirSync } from "node:fs";
2
- import { dirname, join, extname } from "node:path";
2
+ import { dirname, join, extname, relative } from "node:path";
3
3
  import { parse } from "yaml";
4
4
  import { validateRawConfig } from "./validator.js";
5
5
  import { normalizeConfig as normalizeConfigInternal } from "./normalizer.js";
@@ -51,49 +51,78 @@ function loadRawConfigFromFile(filePath) {
51
51
  validateRawConfig(rawConfig);
52
52
  return rawConfig;
53
53
  }
54
- function loadRawConfigFromDirectory(dirPath) {
54
+ const MAX_CONFIG_DEPTH = 10;
55
+ function collectYamlFiles(rootDir, currentDir, depth) {
56
+ if (depth > MAX_CONFIG_DEPTH) {
57
+ /* c8 ignore next -- rootDir === currentDir impossible at depth > MAX_CONFIG_DEPTH */
58
+ const rel = relative(rootDir, currentDir) || ".";
59
+ throw new ValidationError(`Config directory nesting exceeds maximum depth of ${MAX_CONFIG_DEPTH} at ${rel}`);
60
+ }
55
61
  let entries;
56
62
  try {
57
- entries = readdirSync(dirPath, { withFileTypes: true });
63
+ entries = readdirSync(currentDir, { withFileTypes: true });
58
64
  }
59
65
  catch (error) {
60
- throw new ValidationError(`Failed to read config directory ${dirPath}: ${toErrorMessage(error)}`, { cause: error });
66
+ const displayPath = relative(rootDir, currentDir) || currentDir;
67
+ throw new ValidationError(`Failed to read config directory ${displayPath}: ${toErrorMessage(error)}`, { cause: error });
61
68
  }
62
- const yamlFiles = entries
63
- .filter((entry) => entry.isFile() &&
64
- [".yaml", ".yml"].includes(extname(entry.name).toLowerCase()))
65
- .map((entry) => entry.name)
66
- .sort();
69
+ const files = [];
70
+ const subdirs = [];
71
+ for (const entry of entries) {
72
+ if (entry.name.startsWith(".")) {
73
+ continue;
74
+ }
75
+ const ext = extname(entry.name).toLowerCase();
76
+ const isYaml = ext === ".yaml" || ext === ".yml";
77
+ if ((entry.isFile() || entry.isSymbolicLink()) && isYaml) {
78
+ files.push({
79
+ relativePath: relative(rootDir, join(currentDir, entry.name)),
80
+ absolutePath: join(currentDir, entry.name),
81
+ });
82
+ }
83
+ else if (entry.isDirectory()) {
84
+ subdirs.push(entry.name);
85
+ }
86
+ }
87
+ files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
88
+ subdirs.sort((a, b) => a.localeCompare(b));
89
+ const result = [...files];
90
+ for (const subdir of subdirs) {
91
+ result.push(...collectYamlFiles(rootDir, join(currentDir, subdir), depth + 1));
92
+ }
93
+ return result;
94
+ }
95
+ function loadRawConfigFromDirectory(dirPath) {
96
+ const yamlFiles = collectYamlFiles(dirPath, dirPath, 0);
67
97
  if (yamlFiles.length === 0) {
68
98
  throw new ValidationError(`No .yaml or .yml files found in directory: ${dirPath}`);
69
99
  }
70
- const fragments = yamlFiles.map((fileName) => {
71
- const filePath = join(dirPath, fileName);
100
+ const fragments = yamlFiles.map(({ relativePath, absolutePath }) => {
72
101
  let content;
73
102
  try {
74
- content = readFileSync(filePath, "utf-8");
103
+ content = readFileSync(absolutePath, "utf-8");
75
104
  }
76
105
  catch (error) {
77
- throw new ValidationError(`Failed to read config file ${filePath}: ${toErrorMessage(error)}`, { cause: error });
106
+ throw new ValidationError(`Failed to read config file ${relativePath}: ${toErrorMessage(error)}`, { cause: error });
78
107
  }
79
- const configDir = dirname(filePath);
108
+ const configDir = dirname(absolutePath);
80
109
  let config;
81
110
  try {
82
111
  config = parse(content);
83
112
  }
84
113
  catch (error) {
85
114
  const message = toErrorMessage(error);
86
- throw new ValidationError(`Failed to parse YAML config at ${filePath}: ${message}`, { cause: error });
115
+ throw new ValidationError(`Failed to parse YAML config at ${relativePath}: ${message}`, { cause: error });
87
116
  }
88
117
  if (!config || typeof config !== "object") {
89
- throw new ValidationError(`Config file ${fileName} is empty or invalid — expected a YAML mapping`);
118
+ throw new ValidationError(`Config file ${relativePath} is empty or invalid — expected a YAML mapping`);
90
119
  }
91
120
  // Safe cast: resolveFileReferencesInConfig only accesses optional fields
92
121
  // (files, groups, etc.), so fragments missing id/repos work correctly.
93
122
  config = resolveFileReferencesInConfig(config, {
94
123
  configDir,
95
124
  });
96
- return { fileName, config };
125
+ return { fileName: relativePath, config };
97
126
  });
98
127
  const merged = mergeConfigFragments(fragments);
99
128
  validateRawConfig(merged);
@@ -127,6 +127,23 @@ function mergeLabels(rootLabels, repoLabels) {
127
127
  }
128
128
  return Object.keys(result).length > 0 ? result : undefined;
129
129
  }
130
+ // GitHub treats variable names case-insensitively; overlay keys win over base keys with same name but different casing.
131
+ function mergeVariablesCaseInsensitive(base, overlay) {
132
+ const overlayUpper = new Map();
133
+ for (const key of Object.keys(overlay)) {
134
+ overlayUpper.set(key.toUpperCase(), key);
135
+ }
136
+ const result = {};
137
+ for (const [key, value] of Object.entries(base)) {
138
+ if (!overlayUpper.has(key.toUpperCase())) {
139
+ result[key] = value;
140
+ }
141
+ }
142
+ for (const [key, value] of Object.entries(overlay)) {
143
+ result[key] = value;
144
+ }
145
+ return result;
146
+ }
130
147
  /**
131
148
  * Merges settings: per-repo settings deep merge with root settings.
132
149
  * Returns undefined if no settings are defined.
@@ -165,7 +182,9 @@ export function mergeSettings(root, perRepo) {
165
182
  }
166
183
  }
167
184
  // deleteOrphaned: per-repo overrides root
168
- const deleteOrphaned = perRepo?.deleteOrphaned ?? root?.deleteOrphaned;
185
+ const deleteOrphaned = perRepo?.deleteOrphaned !== undefined
186
+ ? perRepo.deleteOrphaned
187
+ : root?.deleteOrphaned;
169
188
  if (deleteOrphaned !== undefined) {
170
189
  result.deleteOrphaned = deleteOrphaned;
171
190
  }
@@ -209,6 +228,37 @@ export function mergeSettings(root, perRepo) {
209
228
  }
210
229
  }
211
230
  }
231
+ // Variables merging — deleteOrphaned is a peer key (like secrets' deleteOrphaned)
232
+ if (root?.variables || perRepo?.variables) {
233
+ const rootVars = root?.variables ?? {};
234
+ const repoVars = perRepo?.variables ?? {};
235
+ const rootDeleteOrphaned = rootVars
236
+ .deleteOrphaned;
237
+ const repoDeleteOrphaned = repoVars
238
+ .deleteOrphaned;
239
+ const effectiveDeleteOrphaned = repoDeleteOrphaned !== undefined
240
+ ? repoDeleteOrphaned
241
+ : rootDeleteOrphaned;
242
+ const inherit = repoVars.inherit;
243
+ if (inherit === false) {
244
+ const { inherit: _, deleteOrphaned: _d, ...rest } = repoVars;
245
+ result.variables = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== false));
246
+ }
247
+ else {
248
+ const combined = mergeVariablesCaseInsensitive(rootVars, repoVars);
249
+ const { inherit: _, deleteOrphaned: _d, ...rest } = combined;
250
+ result.variables = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== false));
251
+ }
252
+ if (effectiveDeleteOrphaned !== undefined) {
253
+ result.variables.deleteOrphaned =
254
+ effectiveDeleteOrphaned;
255
+ }
256
+ // Only delete if no variable entries remain (deleteOrphaned alone is not actionable)
257
+ const { deleteOrphaned: _check, ...varEntries } = result.variables;
258
+ if (Object.keys(varEntries).length === 0 && !effectiveDeleteOrphaned) {
259
+ delete result.variables;
260
+ }
261
+ }
212
262
  return Object.keys(result).length > 0 ? result : undefined;
213
263
  }
214
264
  /**
@@ -348,6 +398,40 @@ function mergeRawSettings(base, overlay) {
348
398
  result.codeScanning = structuredClone(overlay.codeScanning);
349
399
  }
350
400
  }
401
+ // Variables: simple string values with false opt-outs (mergeNamedEntries won't work for strings)
402
+ if (overlay.variables) {
403
+ const overlayVars = overlay.variables;
404
+ const inherit = overlayVars.inherit !== false;
405
+ const baseVars = inherit
406
+ ? { ...(result.variables ?? {}) }
407
+ : {};
408
+ // Build overlay entries (excluding meta keys)
409
+ const overlayEntries = {};
410
+ for (const [name, entry] of Object.entries(overlay.variables)) {
411
+ if (name === "inherit" || name === "deleteOrphaned")
412
+ continue;
413
+ overlayEntries[name] = entry;
414
+ }
415
+ // Case-insensitive merge: overlay keys replace base keys with same name
416
+ const merged = mergeVariablesCaseInsensitive(baseVars, overlayEntries);
417
+ // Apply false opt-outs
418
+ const cleaned = {};
419
+ for (const [name, value] of Object.entries(merged)) {
420
+ if (name === "inherit" || name === "deleteOrphaned")
421
+ continue;
422
+ if (value !== false) {
423
+ cleaned[name] = value;
424
+ }
425
+ }
426
+ const baseDelete = baseVars.deleteOrphaned;
427
+ const effectiveDelete = overlayVars.deleteOrphaned !== undefined
428
+ ? overlayVars.deleteOrphaned
429
+ : baseDelete;
430
+ if (effectiveDelete !== undefined) {
431
+ cleaned.deleteOrphaned = effectiveDelete;
432
+ }
433
+ result.variables = cleaned;
434
+ }
351
435
  // deleteOrphaned: overlay wins
352
436
  if (overlay.deleteOrphaned !== undefined) {
353
437
  result.deleteOrphaned = overlay.deleteOrphaned;
@@ -537,5 +621,6 @@ export function normalizeConfig(raw, env) {
537
621
  githubHosts: raw.githubHosts,
538
622
  deleteOrphaned: raw.deleteOrphaned,
539
623
  settings: normalizedRootSettings,
624
+ secrets: raw.secrets,
540
625
  };
541
626
  }