@aspruyt/xfg 3.8.0 → 3.8.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.
package/README.md CHANGED
@@ -76,7 +76,7 @@ settings:
76
76
  enforcement: active
77
77
  conditions:
78
78
  refName:
79
- include: ["refs/heads/main"]
79
+ include: [refs/heads/main]
80
80
  exclude: []
81
81
  rules:
82
82
  - type: pull_request
@@ -56,9 +56,9 @@ const syncCommand = new Command("sync")
56
56
  });
57
57
  addSharedOptions(syncCommand);
58
58
  program.addCommand(syncCommand);
59
- // Settings command (ruleset management)
59
+ // Settings command (repository settings and rulesets)
60
60
  const settingsCommand = new Command("settings")
61
- .description("Manage GitHub Rulesets for repositories")
61
+ .description("Manage GitHub repository settings and rulesets")
62
62
  .action((opts) => {
63
63
  runSettings(opts).catch((error) => {
64
64
  console.error("Fatal error:", error);
@@ -9,58 +9,42 @@ import { generateWorkspaceName } from "../shared/workspace-utils.js";
9
9
  import { buildErrorResult } from "../output/summary-utils.js";
10
10
  import { getManagedRulesets } from "../sync/manifest.js";
11
11
  import { formatSettingsReportCLI, writeSettingsReportSummary, } from "../output/settings-report.js";
12
- import { buildSettingsReport } from "./settings-report-builder.js";
12
+ import { buildSettingsReport, } from "./settings-report-builder.js";
13
13
  import { defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, } from "./types.js";
14
14
  /**
15
- * Run the settings command - manages GitHub Rulesets and repo settings.
15
+ * Collects processing results for the SettingsReport.
16
+ * Provides a centralized way to track results across rulesets and repo settings.
16
17
  */
17
- export async function runSettings(options, processorFactory = defaultRulesetProcessorFactory, repoProcessorFactory = defaultProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory) {
18
- const configPath = resolve(options.config);
19
- if (!existsSync(configPath)) {
20
- console.error(`Config file not found: ${configPath}`);
21
- process.exit(1);
22
- }
23
- console.log(`Loading config from: ${configPath}`);
24
- if (options.dryRun) {
25
- console.log("Running in DRY RUN mode - no changes will be made\n");
26
- }
27
- const rawConfig = loadRawConfig(configPath);
28
- try {
29
- validateForSettings(rawConfig);
30
- }
31
- catch (error) {
32
- console.error(error instanceof Error ? error.message : String(error));
33
- process.exit(1);
34
- }
35
- const config = normalizeConfig(rawConfig);
36
- const reposWithRulesets = config.repos.filter((r) => r.settings?.rulesets && Object.keys(r.settings.rulesets).length > 0);
37
- const reposWithRepoSettings = config.repos.filter((r) => r.settings?.repo && Object.keys(r.settings.repo).length > 0);
38
- if (reposWithRulesets.length === 0 && reposWithRepoSettings.length === 0) {
39
- console.log("No settings configured. Add settings.rulesets or settings.repo to your config.");
40
- return;
41
- }
42
- if (reposWithRulesets.length > 0) {
43
- console.log(`Found ${reposWithRulesets.length} repositories with rulesets`);
44
- }
45
- if (reposWithRepoSettings.length > 0) {
46
- console.log(`Found ${reposWithRepoSettings.length} repositories with repo settings`);
47
- }
48
- console.log("");
49
- logger.setTotal(reposWithRulesets.length + reposWithRepoSettings.length);
50
- const processor = processorFactory();
51
- const repoProcessor = repoProcessorFactory();
52
- const results = [];
53
- const processingResults = [];
54
- function getOrCreateResult(repoName) {
55
- let result = processingResults.find((r) => r.repoName === repoName);
18
+ class ResultsCollector {
19
+ results = [];
20
+ getOrCreate(repoName) {
21
+ let result = this.results.find((r) => r.repoName === repoName);
56
22
  if (!result) {
57
23
  result = { repoName };
58
- processingResults.push(result);
24
+ this.results.push(result);
59
25
  }
60
26
  return result;
61
27
  }
62
- for (let i = 0; i < reposWithRulesets.length; i++) {
63
- const repoConfig = reposWithRulesets[i];
28
+ appendError(repoName, error) {
29
+ const existing = this.getOrCreate(repoName);
30
+ const errorMsg = error instanceof Error ? error.message : String(error);
31
+ if (existing.error) {
32
+ existing.error += `; ${errorMsg}`;
33
+ }
34
+ else {
35
+ existing.error = errorMsg;
36
+ }
37
+ }
38
+ getAll() {
39
+ return this.results;
40
+ }
41
+ }
42
+ /**
43
+ * Process rulesets for all configured repositories.
44
+ */
45
+ async function processRulesets(repos, config, options, processor, repoProcessor, results, collector) {
46
+ for (let i = 0; i < repos.length; i++) {
47
+ const repoConfig = repos[i];
64
48
  let repoInfo;
65
49
  try {
66
50
  repoInfo = parseGitUrl(repoConfig.git, {
@@ -70,14 +54,12 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
70
54
  catch (error) {
71
55
  logger.error(i + 1, repoConfig.git, String(error));
72
56
  results.push(buildErrorResult(repoConfig.git, error));
73
- getOrCreateResult(repoConfig.git).error =
74
- error instanceof Error ? error.message : String(error);
57
+ collector.appendError(repoConfig.git, error);
75
58
  continue;
76
59
  }
77
60
  const repoName = getRepoDisplayName(repoInfo);
78
61
  if (!isGitHubRepo(repoInfo)) {
79
62
  logger.skip(i + 1, repoName, "GitHub Rulesets only supported for GitHub repos");
80
- // Skipped repos don't appear in the report
81
63
  continue;
82
64
  }
83
65
  const managedRulesets = getManagedRulesets(null, config.id);
@@ -130,101 +112,133 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
130
112
  message: result.message,
131
113
  rulesetPlanDetails: result.planOutput?.entries,
132
114
  });
133
- // Collect result for SettingsReport
134
115
  if (!result.skipped) {
135
- getOrCreateResult(repoName).rulesetResult = result;
116
+ collector.getOrCreate(repoName).rulesetResult = result;
136
117
  }
137
118
  }
138
119
  catch (error) {
139
120
  logger.error(i + 1, repoName, String(error));
140
121
  results.push(buildErrorResult(repoName, error));
141
- const existingResult = getOrCreateResult(repoName);
142
- const errorMsg = error instanceof Error ? error.message : String(error);
143
- if (existingResult.error) {
144
- existingResult.error += `; ${errorMsg}`;
145
- }
146
- else {
147
- existingResult.error = errorMsg;
148
- }
122
+ collector.appendError(repoName, error);
149
123
  }
150
124
  }
151
- if (reposWithRepoSettings.length > 0) {
152
- const repoSettingsProcessor = repoSettingsProcessorFactory();
153
- console.log(`\nProcessing repo settings for ${reposWithRepoSettings.length} repositories\n`);
154
- for (let i = 0; i < reposWithRepoSettings.length; i++) {
155
- const repoConfig = reposWithRepoSettings[i];
156
- let repoInfo;
157
- try {
158
- repoInfo = parseGitUrl(repoConfig.git, {
159
- githubHosts: config.githubHosts,
160
- });
161
- }
162
- catch (error) {
163
- console.error(`Failed to parse ${repoConfig.git}: ${error}`);
164
- getOrCreateResult(repoConfig.git).error =
165
- error instanceof Error ? error.message : String(error);
166
- continue;
167
- }
168
- const repoName = getRepoDisplayName(repoInfo);
169
- try {
170
- const result = await repoSettingsProcessor.process(repoConfig, repoInfo, {
171
- dryRun: options.dryRun,
172
- });
173
- if (result.planOutput && result.planOutput.lines.length > 0) {
174
- console.log(`\n ${chalk.bold(repoName)}:`);
175
- console.log(" Repo Settings:");
176
- for (const line of result.planOutput.lines) {
177
- console.log(line);
178
- }
179
- if (result.warnings && result.warnings.length > 0) {
180
- for (const warning of result.warnings) {
181
- console.log(chalk.yellow(` ⚠️ Warning: ${warning}`));
182
- }
183
- }
184
- }
185
- if (result.skipped) {
186
- // Silent skip
187
- }
188
- else if (result.success) {
189
- console.log(chalk.green(` ✓ ${repoName}: ${result.message}`));
190
- }
191
- else {
192
- console.log(chalk.red(` ✗ ${repoName}: ${result.message}`));
125
+ }
126
+ /**
127
+ * Process repo settings for all configured repositories.
128
+ */
129
+ async function processRepoSettings(repos, config, options, processorFactory, results, collector) {
130
+ if (repos.length === 0) {
131
+ return;
132
+ }
133
+ const processor = processorFactory();
134
+ console.log(`\nProcessing repo settings for ${repos.length} repositories\n`);
135
+ for (let i = 0; i < repos.length; i++) {
136
+ const repoConfig = repos[i];
137
+ let repoInfo;
138
+ try {
139
+ repoInfo = parseGitUrl(repoConfig.git, {
140
+ githubHosts: config.githubHosts,
141
+ });
142
+ }
143
+ catch (error) {
144
+ console.error(`Failed to parse ${repoConfig.git}: ${error}`);
145
+ collector.appendError(repoConfig.git, error);
146
+ continue;
147
+ }
148
+ const repoName = getRepoDisplayName(repoInfo);
149
+ try {
150
+ const result = await processor.process(repoConfig, repoInfo, {
151
+ dryRun: options.dryRun,
152
+ });
153
+ if (result.planOutput && result.planOutput.lines.length > 0) {
154
+ console.log(`\n ${chalk.bold(repoName)}:`);
155
+ console.log(" Repo Settings:");
156
+ for (const line of result.planOutput.lines) {
157
+ console.log(line);
193
158
  }
194
- if (!result.skipped) {
195
- const existing = results.find((r) => r.repoName === repoName);
196
- if (existing) {
197
- existing.repoSettingsPlanDetails = result.planOutput?.entries;
198
- }
199
- else {
200
- results.push({
201
- repoName,
202
- status: result.success ? "succeeded" : "failed",
203
- message: result.message,
204
- repoSettingsPlanDetails: result.planOutput?.entries,
205
- });
159
+ if (result.warnings && result.warnings.length > 0) {
160
+ for (const warning of result.warnings) {
161
+ console.log(chalk.yellow(` ⚠️ Warning: ${warning}`));
206
162
  }
207
163
  }
208
- // Collect result for SettingsReport
209
- if (!result.skipped) {
210
- getOrCreateResult(repoName).settingsResult = result;
211
- }
212
164
  }
213
- catch (error) {
214
- console.error(` ✗ ${repoName}: ${error}`);
215
- const existingResult = getOrCreateResult(repoName);
216
- const errorMsg = error instanceof Error ? error.message : String(error);
217
- if (existingResult.error) {
218
- existingResult.error += `; ${errorMsg}`;
165
+ if (result.skipped) {
166
+ // Silent skip
167
+ }
168
+ else if (result.success) {
169
+ console.log(chalk.green(` ✓ ${repoName}: ${result.message}`));
170
+ }
171
+ else {
172
+ console.log(chalk.red(` ✗ ${repoName}: ${result.message}`));
173
+ }
174
+ if (!result.skipped) {
175
+ const existing = results.find((r) => r.repoName === repoName);
176
+ if (existing) {
177
+ existing.repoSettingsPlanDetails = result.planOutput?.entries;
219
178
  }
220
179
  else {
221
- existingResult.error = errorMsg;
180
+ results.push({
181
+ repoName,
182
+ status: result.success ? "succeeded" : "failed",
183
+ message: result.message,
184
+ repoSettingsPlanDetails: result.planOutput?.entries,
185
+ });
222
186
  }
223
187
  }
188
+ if (!result.skipped) {
189
+ collector.getOrCreate(repoName).settingsResult = result;
190
+ }
191
+ }
192
+ catch (error) {
193
+ console.error(` ✗ ${repoName}: ${error}`);
194
+ collector.appendError(repoName, error);
224
195
  }
225
196
  }
197
+ }
198
+ /**
199
+ * Run the settings command - manages GitHub Rulesets and repo settings.
200
+ */
201
+ export async function runSettings(options, processorFactory = defaultRulesetProcessorFactory, repoProcessorFactory = defaultProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory) {
202
+ const configPath = resolve(options.config);
203
+ if (!existsSync(configPath)) {
204
+ console.error(`Config file not found: ${configPath}`);
205
+ process.exit(1);
206
+ }
207
+ console.log(`Loading config from: ${configPath}`);
208
+ if (options.dryRun) {
209
+ console.log("Running in DRY RUN mode - no changes will be made\n");
210
+ }
211
+ const rawConfig = loadRawConfig(configPath);
212
+ try {
213
+ validateForSettings(rawConfig);
214
+ }
215
+ catch (error) {
216
+ console.error(error instanceof Error ? error.message : String(error));
217
+ process.exit(1);
218
+ }
219
+ const config = normalizeConfig(rawConfig);
220
+ const reposWithRulesets = config.repos.filter((r) => r.settings?.rulesets && Object.keys(r.settings.rulesets).length > 0);
221
+ const reposWithRepoSettings = config.repos.filter((r) => r.settings?.repo && Object.keys(r.settings.repo).length > 0);
222
+ if (reposWithRulesets.length === 0 && reposWithRepoSettings.length === 0) {
223
+ console.log("No settings configured. Add settings.rulesets or settings.repo to your config.");
224
+ return;
225
+ }
226
+ if (reposWithRulesets.length > 0) {
227
+ console.log(`Found ${reposWithRulesets.length} repositories with rulesets`);
228
+ }
229
+ if (reposWithRepoSettings.length > 0) {
230
+ console.log(`Found ${reposWithRepoSettings.length} repositories with repo settings`);
231
+ }
232
+ console.log("");
233
+ logger.setTotal(reposWithRulesets.length + reposWithRepoSettings.length);
234
+ const processor = processorFactory();
235
+ const repoProcessor = repoProcessorFactory();
236
+ const results = [];
237
+ const collector = new ResultsCollector();
238
+ await processRulesets(reposWithRulesets, config, options, processor, repoProcessor, results, collector);
239
+ await processRepoSettings(reposWithRepoSettings, config, options, repoSettingsProcessorFactory, results, collector);
226
240
  console.log("");
227
- const report = buildSettingsReport(processingResults);
241
+ const report = buildSettingsReport(collector.getAll());
228
242
  const lines = formatSettingsReportCLI(report);
229
243
  for (const line of lines) {
230
244
  console.log(line);
@@ -1,7 +1,11 @@
1
1
  import type { SettingsReport } from "../output/settings-report.js";
2
2
  import type { RepoSettingsPlanEntry } from "../settings/repo-settings/formatter.js";
3
3
  import type { RulesetPlanEntry } from "../settings/rulesets/formatter.js";
4
- interface ProcessorResults {
4
+ /**
5
+ * Result from processing a repository's settings and rulesets.
6
+ * Used to collect results during settings command execution.
7
+ */
8
+ export interface ProcessorResults {
5
9
  repoName: string;
6
10
  settingsResult?: {
7
11
  planOutput?: {
@@ -16,4 +20,3 @@ interface ProcessorResults {
16
20
  error?: string;
17
21
  }
18
22
  export declare function buildSettingsReport(results: ProcessorResults[]): SettingsReport;
19
- export {};
@@ -0,0 +1,12 @@
1
+ import type { RepoConfig } from "../config/types.js";
2
+ import type { RepoInfo } from "../shared/repo-detector.js";
3
+ import type { IWorkStrategy, WorkResult, SessionContext, ProcessorOptions, IFileSyncOrchestrator } from "./types.js";
4
+ /**
5
+ * Strategy that performs full file synchronization.
6
+ * Wraps FileSyncOrchestrator to fit the IWorkStrategy interface.
7
+ */
8
+ export declare class FileSyncStrategy implements IWorkStrategy {
9
+ private readonly fileSyncOrchestrator;
10
+ constructor(fileSyncOrchestrator: IFileSyncOrchestrator);
11
+ execute(repoConfig: RepoConfig, repoInfo: RepoInfo, session: SessionContext, options: ProcessorOptions): Promise<WorkResult | null>;
12
+ }
@@ -0,0 +1,30 @@
1
+ import { formatCommitMessage } from "./commit-message.js";
2
+ /**
3
+ * Strategy that performs full file synchronization.
4
+ * Wraps FileSyncOrchestrator to fit the IWorkStrategy interface.
5
+ */
6
+ export class FileSyncStrategy {
7
+ fileSyncOrchestrator;
8
+ constructor(fileSyncOrchestrator) {
9
+ this.fileSyncOrchestrator = fileSyncOrchestrator;
10
+ }
11
+ async execute(repoConfig, repoInfo, session, options) {
12
+ const { fileChanges, diffStats, changedFiles, hasChanges } = await this.fileSyncOrchestrator.sync(repoConfig, repoInfo, session, options);
13
+ if (!hasChanges) {
14
+ return null;
15
+ }
16
+ const fileChangeDetails = changedFiles
17
+ .filter((f) => f.action !== "skip")
18
+ .map((f) => ({
19
+ path: f.fileName,
20
+ action: f.action,
21
+ }));
22
+ return {
23
+ fileChanges,
24
+ changedFiles,
25
+ diffStats,
26
+ commitMessage: formatCommitMessage(changedFiles),
27
+ fileChangeDetails,
28
+ };
29
+ }
30
+ }
@@ -7,7 +7,10 @@ export { CommitPushManager } from "./commit-push-manager.js";
7
7
  export { formatCommitMessage } from "./commit-message.js";
8
8
  export { FileSyncOrchestrator } from "./file-sync-orchestrator.js";
9
9
  export { PRMergeHandler } from "./pr-merge-handler.js";
10
- export type { IFileWriter, FileWriteContext, FileWriterDeps, FileWriteAllResult, FileWriteResult, IManifestManager, OrphanProcessResult, OrphanDeleteOptions, OrphanDeleteDeps, IBranchManager, BranchSetupOptions, IAuthOptionsBuilder, AuthResult, IRepositorySession, SessionOptions, SessionContext, ICommitPushManager, CommitPushOptions, CommitPushResult, GitOpsFactory, IRepositoryProcessor, ProcessorOptions, ProcessorResult, FileChangeDetail, IFileSyncOrchestrator, FileSyncResult, IPRMergeHandler, PRHandlerOptions, } from "./types.js";
10
+ export { FileSyncStrategy } from "./file-sync-strategy.js";
11
+ export { ManifestStrategy, type ManifestUpdateParams, } from "./manifest-strategy.js";
12
+ export { SyncWorkflow } from "./sync-workflow.js";
13
+ export type { IFileWriter, FileWriteContext, FileWriterDeps, FileWriteAllResult, FileWriteResult, IManifestManager, OrphanProcessResult, OrphanDeleteOptions, OrphanDeleteDeps, IBranchManager, BranchSetupOptions, IAuthOptionsBuilder, AuthResult, IRepositorySession, SessionOptions, SessionContext, ICommitPushManager, CommitPushOptions, CommitPushResult, GitOpsFactory, IRepositoryProcessor, ProcessorOptions, ProcessorResult, FileChangeDetail, IFileSyncOrchestrator, FileSyncResult, IPRMergeHandler, PRHandlerOptions, WorkResult, IWorkStrategy, ISyncWorkflow, } from "./types.js";
11
14
  export { RepositoryProcessor } from "./repository-processor.js";
12
15
  export { createEmptyManifest, loadManifest, saveManifest, getManagedFiles, getManagedRulesets, updateManifest, updateManifestRulesets, MANIFEST_FILENAME, type XfgManifest, type XfgManifestConfigEntry, } from "./manifest.js";
13
16
  export { getFileStatus, formatStatusBadge, formatDiffLine, generateDiff, createDiffStats, incrementDiffStats, type FileStatus, type DiffStats, } from "./diff-utils.js";
@@ -7,6 +7,10 @@ export { CommitPushManager } from "./commit-push-manager.js";
7
7
  export { formatCommitMessage } from "./commit-message.js";
8
8
  export { FileSyncOrchestrator } from "./file-sync-orchestrator.js";
9
9
  export { PRMergeHandler } from "./pr-merge-handler.js";
10
+ // Strategy pattern components
11
+ export { FileSyncStrategy } from "./file-sync-strategy.js";
12
+ export { ManifestStrategy, } from "./manifest-strategy.js";
13
+ export { SyncWorkflow } from "./sync-workflow.js";
10
14
  // Repository processor
11
15
  export { RepositoryProcessor } from "./repository-processor.js";
12
16
  // Manifest handling
@@ -0,0 +1,20 @@
1
+ import type { RepoConfig } from "../config/types.js";
2
+ import type { RepoInfo } from "../shared/repo-detector.js";
3
+ import type { ILogger } from "../shared/logger.js";
4
+ import type { IWorkStrategy, WorkResult, SessionContext, ProcessorOptions } from "./types.js";
5
+ /**
6
+ * Parameters for manifest-only updates
7
+ */
8
+ export interface ManifestUpdateParams {
9
+ rulesets: string[];
10
+ }
11
+ /**
12
+ * Strategy that only updates the manifest with ruleset tracking.
13
+ * Used by updateManifestOnly() for settings command ruleset sync.
14
+ */
15
+ export declare class ManifestStrategy implements IWorkStrategy {
16
+ private readonly params;
17
+ private readonly log;
18
+ constructor(params: ManifestUpdateParams, log: ILogger);
19
+ execute(_repoConfig: RepoConfig, _repoInfo: RepoInfo, _session: SessionContext, options: ProcessorOptions): Promise<WorkResult | null>;
20
+ }
@@ -0,0 +1,47 @@
1
+ import { loadManifest, saveManifest, updateManifestRulesets, MANIFEST_FILENAME, } from "./manifest.js";
2
+ /**
3
+ * Strategy that only updates the manifest with ruleset tracking.
4
+ * Used by updateManifestOnly() for settings command ruleset sync.
5
+ */
6
+ export class ManifestStrategy {
7
+ params;
8
+ log;
9
+ constructor(params, log) {
10
+ this.params = params;
11
+ this.log = log;
12
+ }
13
+ async execute(_repoConfig, _repoInfo, _session, options) {
14
+ const { workDir, dryRun, configId } = options;
15
+ // Load and update manifest
16
+ const existingManifest = loadManifest(workDir);
17
+ const rulesetsWithDeleteOrphaned = new Map(this.params.rulesets.map((name) => [name, true]));
18
+ const { manifest: newManifest } = updateManifestRulesets(existingManifest, configId, rulesetsWithDeleteOrphaned);
19
+ // Check if changed
20
+ const existingConfigs = existingManifest?.configs ?? {};
21
+ if (JSON.stringify(existingConfigs) === JSON.stringify(newManifest.configs)) {
22
+ return null;
23
+ }
24
+ if (dryRun) {
25
+ this.log.info(`Would update ${MANIFEST_FILENAME} with rulesets`);
26
+ }
27
+ saveManifest(workDir, newManifest);
28
+ const fileChanges = new Map([
29
+ [
30
+ MANIFEST_FILENAME,
31
+ {
32
+ fileName: MANIFEST_FILENAME,
33
+ content: JSON.stringify(newManifest, null, 2) + "\n",
34
+ action: "update",
35
+ },
36
+ ],
37
+ ]);
38
+ return {
39
+ fileChanges,
40
+ changedFiles: [
41
+ { fileName: MANIFEST_FILENAME, action: "update" },
42
+ ],
43
+ commitMessage: "chore: update manifest with ruleset tracking",
44
+ fileChangeDetails: [{ path: MANIFEST_FILENAME, action: "update" }],
45
+ };
46
+ }
47
+ }
@@ -1,18 +1,15 @@
1
- import { RepoConfig } from "../config/index.js";
2
- import { RepoInfo } from "../shared/repo-detector.js";
1
+ import type { RepoConfig } from "../config/index.js";
2
+ import type { RepoInfo } from "../shared/repo-detector.js";
3
3
  import { ILogger } from "../shared/logger.js";
4
- import { type IFileWriter, type IManifestManager, type IBranchManager, type IAuthOptionsBuilder, type IRepositorySession, type ICommitPushManager, type IFileSyncOrchestrator, type IPRMergeHandler, type IRepositoryProcessor, type GitOpsFactory, type ProcessorOptions, type ProcessorResult } from "./index.js";
4
+ import { type IFileWriter, type IManifestManager, type IBranchManager, type IAuthOptionsBuilder, type IRepositorySession, type ICommitPushManager, type IFileSyncOrchestrator, type IPRMergeHandler, type ISyncWorkflow, type IRepositoryProcessor, type GitOpsFactory, type ProcessorOptions, type ProcessorResult } from "./index.js";
5
+ /**
6
+ * Thin facade that delegates to SyncWorkflow with appropriate strategy.
7
+ * process() uses FileSyncStrategy, updateManifestOnly() uses ManifestStrategy.
8
+ */
5
9
  export declare class RepositoryProcessor implements IRepositoryProcessor {
6
- private readonly gitOpsFactory;
7
- private readonly log;
8
- private readonly authOptionsBuilder;
9
- private readonly repositorySession;
10
- private readonly commitPushManager;
11
- private readonly fileWriter;
12
- private readonly manifestManager;
13
- private readonly branchManager;
10
+ private readonly syncWorkflow;
14
11
  private readonly fileSyncOrchestrator;
15
- private readonly prMergeHandler;
12
+ private readonly log;
16
13
  constructor(gitOpsFactory?: GitOpsFactory, log?: ILogger, components?: {
17
14
  fileWriter?: IFileWriter;
18
15
  manifestManager?: IManifestManager;
@@ -22,6 +19,7 @@ export declare class RepositoryProcessor implements IRepositoryProcessor {
22
19
  commitPushManager?: ICommitPushManager;
23
20
  fileSyncOrchestrator?: IFileSyncOrchestrator;
24
21
  prMergeHandler?: IPRMergeHandler;
22
+ syncWorkflow?: ISyncWorkflow;
25
23
  });
26
24
  process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
27
25
  updateManifestOnly(repoInfo: RepoInfo, repoConfig: RepoConfig, options: ProcessorOptions, manifestUpdate: {
@@ -1,298 +1,77 @@
1
- import { getRepoDisplayName } from "../shared/repo-detector.js";
2
1
  import { GitOps } from "../vcs/git-ops.js";
3
2
  import { AuthenticatedGitOps } from "../vcs/authenticated-git-ops.js";
4
3
  import { logger } from "../shared/logger.js";
5
4
  import { hasGitHubAppCredentials } from "../vcs/index.js";
6
- import { defaultExecutor } from "../shared/command-executor.js";
7
- import { loadManifest, saveManifest, updateManifestRulesets, MANIFEST_FILENAME, } from "./manifest.js";
8
5
  import { GitHubAppTokenManager } from "../vcs/github-app-token-manager.js";
9
- import { formatCommitMessage } from "./commit-message.js";
10
- import { FileWriter, ManifestManager, BranchManager, AuthOptionsBuilder, RepositorySession, CommitPushManager, FileSyncOrchestrator, PRMergeHandler, } from "./index.js";
11
- function mapToFileChangeDetails(changedFiles) {
12
- return changedFiles
13
- .filter((f) => f.action !== "skip")
14
- .map((f) => ({
15
- path: f.fileName,
16
- action: f.action,
17
- }));
18
- }
6
+ import { FileWriter, ManifestManager, BranchManager, AuthOptionsBuilder, RepositorySession, CommitPushManager, FileSyncOrchestrator, PRMergeHandler, FileSyncStrategy, ManifestStrategy, SyncWorkflow, loadManifest, updateManifestRulesets, MANIFEST_FILENAME, } from "./index.js";
7
+ import { getRepoDisplayName } from "../shared/repo-detector.js";
8
+ /**
9
+ * Thin facade that delegates to SyncWorkflow with appropriate strategy.
10
+ * process() uses FileSyncStrategy, updateManifestOnly() uses ManifestStrategy.
11
+ */
19
12
  export class RepositoryProcessor {
20
- gitOpsFactory;
21
- log;
22
- authOptionsBuilder;
23
- repositorySession;
24
- commitPushManager;
25
- fileWriter;
26
- manifestManager;
27
- branchManager;
13
+ syncWorkflow;
28
14
  fileSyncOrchestrator;
29
- prMergeHandler;
15
+ log;
30
16
  constructor(gitOpsFactory, log, components) {
31
17
  const factory = gitOpsFactory ??
32
18
  ((opts, auth) => new AuthenticatedGitOps(new GitOps(opts), auth));
33
19
  const logInstance = log ?? logger;
34
- this.gitOpsFactory = factory;
35
20
  this.log = logInstance;
36
- this.fileWriter = components?.fileWriter ?? new FileWriter();
37
- this.manifestManager = components?.manifestManager ?? new ManifestManager();
38
- this.branchManager = components?.branchManager ?? new BranchManager();
39
21
  // Initialize token manager for auth builder
40
22
  const tokenManager = hasGitHubAppCredentials()
41
23
  ? new GitHubAppTokenManager(process.env.XFG_GITHUB_APP_ID, process.env.XFG_GITHUB_APP_PRIVATE_KEY)
42
24
  : null;
43
- this.authOptionsBuilder =
44
- components?.authOptionsBuilder ??
45
- new AuthOptionsBuilder(tokenManager, logInstance);
46
- this.repositorySession =
47
- components?.repositorySession ??
48
- new RepositorySession(factory, logInstance);
49
- this.commitPushManager =
50
- components?.commitPushManager ?? new CommitPushManager(logInstance);
25
+ const fileWriter = components?.fileWriter ?? new FileWriter();
26
+ const manifestManager = components?.manifestManager ?? new ManifestManager();
27
+ const branchManager = components?.branchManager ?? new BranchManager();
28
+ const authOptionsBuilder = components?.authOptionsBuilder ??
29
+ new AuthOptionsBuilder(tokenManager, logInstance);
30
+ const repositorySession = components?.repositorySession ??
31
+ new RepositorySession(factory, logInstance);
32
+ const commitPushManager = components?.commitPushManager ?? new CommitPushManager(logInstance);
33
+ const prMergeHandler = components?.prMergeHandler ?? new PRMergeHandler(logInstance);
51
34
  this.fileSyncOrchestrator =
52
35
  components?.fileSyncOrchestrator ??
53
- new FileSyncOrchestrator(this.fileWriter, this.manifestManager, logInstance);
54
- this.prMergeHandler =
55
- components?.prMergeHandler ?? new PRMergeHandler(logInstance);
36
+ new FileSyncOrchestrator(fileWriter, manifestManager, logInstance);
37
+ this.syncWorkflow =
38
+ components?.syncWorkflow ??
39
+ new SyncWorkflow(authOptionsBuilder, repositorySession, branchManager, commitPushManager, prMergeHandler, logInstance);
56
40
  }
57
41
  async process(repoConfig, repoInfo, options) {
58
- const repoName = getRepoDisplayName(repoInfo);
59
- const { branchName, workDir, dryRun, prTemplate } = options;
60
- const retries = options.retries ?? 3;
61
- const executor = options.executor ?? defaultExecutor;
62
- // Resolve auth
63
- const authResult = await this.authOptionsBuilder.resolve(repoInfo, repoName);
64
- if (authResult.skipResult) {
65
- return authResult.skipResult;
66
- }
67
- // Determine merge mode
68
- const mergeMode = repoConfig.prOptions?.merge ?? "auto";
69
- const isDirectMode = mergeMode === "direct";
70
- if (isDirectMode && repoConfig.prOptions?.mergeStrategy) {
71
- this.log.info(`Warning: mergeStrategy '${repoConfig.prOptions.mergeStrategy}' is ignored in direct mode`);
72
- }
73
- let session = null;
74
- try {
75
- // Setup workspace
76
- session = await this.repositorySession.setup(repoInfo, {
77
- workDir,
78
- dryRun: dryRun ?? false,
79
- retries,
80
- authOptions: authResult.authOptions,
81
- });
82
- // Setup branch
83
- await this.branchManager.setupBranch({
84
- repoInfo,
85
- branchName,
86
- baseBranch: session.baseBranch,
87
- workDir,
88
- isDirectMode,
89
- dryRun: dryRun ?? false,
90
- retries,
91
- token: authResult.token,
92
- gitOps: session.gitOps,
93
- log: this.log,
94
- executor,
95
- });
96
- // Process files and manifest
97
- const { fileChanges, diffStats, changedFiles, hasChanges } = await this.fileSyncOrchestrator.sync(repoConfig, repoInfo, session, options);
98
- // Map file changes for reporting
99
- const fileChangeDetails = mapToFileChangeDetails(changedFiles);
100
- if (!hasChanges) {
101
- return {
102
- success: true,
103
- repoName,
104
- message: "No changes detected",
105
- skipped: true,
106
- diffStats,
107
- fileChanges: fileChangeDetails,
108
- };
109
- }
110
- // Commit and push
111
- const commitMessage = formatCommitMessage(changedFiles);
112
- const pushBranch = isDirectMode ? session.baseBranch : branchName;
113
- const commitResult = await this.commitPushManager.commitAndPush({
114
- repoInfo,
115
- gitOps: session.gitOps,
116
- workDir,
117
- fileChanges,
118
- commitMessage,
119
- pushBranch,
120
- isDirectMode,
121
- dryRun: dryRun ?? false,
122
- retries,
123
- token: authResult.token,
124
- executor,
125
- }, repoName);
126
- if (!commitResult.success && commitResult.errorResult) {
127
- return commitResult.errorResult;
128
- }
129
- if (commitResult.skipped) {
130
- return {
131
- success: true,
132
- repoName,
133
- message: "No changes detected after staging",
134
- skipped: true,
135
- diffStats,
136
- fileChanges: fileChangeDetails,
137
- };
138
- }
139
- // Direct mode: no PR
140
- if (isDirectMode) {
141
- this.log.info(`Changes pushed directly to ${session.baseBranch}`);
142
- return {
143
- success: true,
144
- repoName,
145
- message: `Pushed directly to ${session.baseBranch}`,
146
- diffStats,
147
- fileChanges: fileChangeDetails,
148
- };
149
- }
150
- // Create and merge PR
151
- return await this.prMergeHandler.createAndMerge(repoInfo, repoConfig, {
152
- branchName,
153
- baseBranch: session.baseBranch,
154
- workDir,
155
- dryRun: dryRun ?? false,
156
- retries,
157
- prTemplate,
158
- token: authResult.token,
159
- executor,
160
- }, changedFiles, repoName, diffStats, fileChangeDetails);
161
- }
162
- finally {
163
- try {
164
- session?.cleanup();
165
- }
166
- catch {
167
- // Ignore cleanup errors - best effort
168
- }
169
- }
42
+ const strategy = new FileSyncStrategy(this.fileSyncOrchestrator);
43
+ return this.syncWorkflow.execute(repoConfig, repoInfo, options, strategy);
170
44
  }
171
45
  async updateManifestOnly(repoInfo, repoConfig, options, manifestUpdate) {
172
46
  const repoName = getRepoDisplayName(repoInfo);
173
- const { branchName, workDir, dryRun } = options;
174
- const retries = options.retries ?? 3;
175
- const executor = options.executor ?? defaultExecutor;
176
- // Resolve auth
177
- const authResult = await this.authOptionsBuilder.resolve(repoInfo, repoName);
178
- if (authResult.skipResult) {
179
- return authResult.skipResult;
180
- }
181
- const mergeMode = repoConfig.prOptions?.merge ?? "auto";
182
- const isDirectMode = mergeMode === "direct";
183
- let session = null;
184
- try {
185
- // Setup workspace
186
- session = await this.repositorySession.setup(repoInfo, {
187
- workDir,
188
- dryRun: dryRun ?? false,
189
- retries,
190
- authOptions: authResult.authOptions,
191
- });
192
- // Load and update manifest
193
- const existingManifest = loadManifest(workDir);
194
- const rulesetsWithDeleteOrphaned = new Map(manifestUpdate.rulesets.map((name) => [name, true]));
195
- const { manifest: newManifest } = updateManifestRulesets(existingManifest, options.configId, rulesetsWithDeleteOrphaned);
196
- // Check if changed
197
- const existingConfigs = existingManifest?.configs ?? {};
198
- if (JSON.stringify(existingConfigs) === JSON.stringify(newManifest.configs)) {
199
- return {
200
- success: true,
201
- repoName,
202
- message: "No manifest changes detected",
203
- skipped: true,
204
- };
205
- }
206
- // Prepare manifest file change details for reporting
207
- const manifestFileChange = [
208
- { path: MANIFEST_FILENAME, action: "update" },
209
- ];
210
- if (dryRun) {
211
- this.log.info(`Would update ${MANIFEST_FILENAME} with rulesets`);
212
- return {
213
- success: true,
214
- repoName,
215
- message: "Would update manifest (dry-run)",
216
- fileChanges: manifestFileChange,
217
- };
218
- }
219
- // Setup branch and commit
220
- await this.branchManager.setupBranch({
221
- repoInfo,
222
- branchName,
223
- baseBranch: session.baseBranch,
224
- workDir,
225
- isDirectMode,
226
- dryRun: false,
227
- retries,
228
- token: authResult.token,
229
- gitOps: session.gitOps,
230
- log: this.log,
231
- executor,
232
- });
233
- saveManifest(workDir, newManifest);
234
- const fileChanges = new Map([
235
- [
236
- MANIFEST_FILENAME,
237
- {
238
- fileName: MANIFEST_FILENAME,
239
- content: JSON.stringify(newManifest, null, 2) + "\n",
240
- action: "update",
241
- },
242
- ],
243
- ]);
244
- const pushBranch = isDirectMode ? session.baseBranch : branchName;
245
- const commitResult = await this.commitPushManager.commitAndPush({
246
- repoInfo,
247
- gitOps: session.gitOps,
248
- workDir,
249
- fileChanges,
250
- commitMessage: "chore: update manifest with ruleset tracking",
251
- pushBranch,
252
- isDirectMode,
253
- dryRun: false,
254
- retries,
255
- token: authResult.token,
256
- executor,
257
- }, repoName);
258
- if (!commitResult.success && commitResult.errorResult) {
259
- return commitResult.errorResult;
260
- }
261
- if (commitResult.skipped) {
262
- return {
263
- success: true,
264
- repoName,
265
- message: "No changes detected after staging",
266
- skipped: true,
267
- fileChanges: manifestFileChange,
268
- };
269
- }
270
- if (isDirectMode) {
271
- return {
272
- success: true,
273
- repoName,
274
- message: `Manifest updated directly on ${session.baseBranch}`,
275
- fileChanges: manifestFileChange,
276
- };
277
- }
278
- // Create and merge PR
279
- return await this.prMergeHandler.createAndMerge(repoInfo, repoConfig, {
280
- branchName,
281
- baseBranch: session.baseBranch,
282
- workDir,
283
- dryRun: false,
284
- retries,
285
- token: authResult.token,
286
- executor,
287
- }, [{ fileName: MANIFEST_FILENAME, action: "update" }], repoName, undefined, manifestFileChange);
47
+ const { workDir, dryRun } = options;
48
+ // Pre-check manifest changes (preserves original early-return behavior)
49
+ const existingManifest = loadManifest(workDir);
50
+ const rulesetsWithDeleteOrphaned = new Map(manifestUpdate.rulesets.map((name) => [name, true]));
51
+ const { manifest: newManifest } = updateManifestRulesets(existingManifest, options.configId, rulesetsWithDeleteOrphaned);
52
+ const existingConfigs = existingManifest?.configs ?? {};
53
+ if (JSON.stringify(existingConfigs) === JSON.stringify(newManifest.configs)) {
54
+ return {
55
+ success: true,
56
+ repoName,
57
+ message: "No manifest changes detected",
58
+ skipped: true,
59
+ };
288
60
  }
289
- finally {
290
- try {
291
- session?.cleanup();
292
- }
293
- catch {
294
- // Ignore cleanup errors - best effort
295
- }
61
+ const manifestFileChange = [
62
+ { path: MANIFEST_FILENAME, action: "update" },
63
+ ];
64
+ if (dryRun) {
65
+ this.log.info(`Would update ${MANIFEST_FILENAME} with rulesets`);
66
+ return {
67
+ success: true,
68
+ repoName,
69
+ message: "Would update manifest (dry-run)",
70
+ fileChanges: manifestFileChange,
71
+ };
296
72
  }
73
+ // Delegate to workflow for actual commit/push/PR
74
+ const strategy = new ManifestStrategy(manifestUpdate, this.log);
75
+ return this.syncWorkflow.execute(repoConfig, repoInfo, options, strategy);
297
76
  }
298
77
  }
@@ -0,0 +1,18 @@
1
+ import type { RepoConfig } from "../config/types.js";
2
+ import { RepoInfo } from "../shared/repo-detector.js";
3
+ import type { ILogger } from "../shared/logger.js";
4
+ import type { ISyncWorkflow, IWorkStrategy, IAuthOptionsBuilder, IRepositorySession, IBranchManager, ICommitPushManager, IPRMergeHandler, ProcessorOptions, ProcessorResult } from "./types.js";
5
+ /**
6
+ * Orchestrates the common sync workflow steps.
7
+ * Used by RepositoryProcessor with different strategies for file sync vs manifest.
8
+ */
9
+ export declare class SyncWorkflow implements ISyncWorkflow {
10
+ private readonly authOptionsBuilder;
11
+ private readonly repositorySession;
12
+ private readonly branchManager;
13
+ private readonly commitPushManager;
14
+ private readonly prMergeHandler;
15
+ private readonly log;
16
+ constructor(authOptionsBuilder: IAuthOptionsBuilder, repositorySession: IRepositorySession, branchManager: IBranchManager, commitPushManager: ICommitPushManager, prMergeHandler: IPRMergeHandler, log: ILogger);
17
+ execute(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions, workStrategy: IWorkStrategy): Promise<ProcessorResult>;
18
+ }
@@ -0,0 +1,134 @@
1
+ import { getRepoDisplayName } from "../shared/repo-detector.js";
2
+ import { defaultExecutor } from "../shared/command-executor.js";
3
+ /**
4
+ * Orchestrates the common sync workflow steps.
5
+ * Used by RepositoryProcessor with different strategies for file sync vs manifest.
6
+ */
7
+ export class SyncWorkflow {
8
+ authOptionsBuilder;
9
+ repositorySession;
10
+ branchManager;
11
+ commitPushManager;
12
+ prMergeHandler;
13
+ log;
14
+ constructor(authOptionsBuilder, repositorySession, branchManager, commitPushManager, prMergeHandler, log) {
15
+ this.authOptionsBuilder = authOptionsBuilder;
16
+ this.repositorySession = repositorySession;
17
+ this.branchManager = branchManager;
18
+ this.commitPushManager = commitPushManager;
19
+ this.prMergeHandler = prMergeHandler;
20
+ this.log = log;
21
+ }
22
+ async execute(repoConfig, repoInfo, options, workStrategy) {
23
+ const repoName = getRepoDisplayName(repoInfo);
24
+ const { branchName, workDir, dryRun } = options;
25
+ const retries = options.retries ?? 3;
26
+ const executor = options.executor ?? defaultExecutor;
27
+ // Step 1: Resolve auth
28
+ const authResult = await this.authOptionsBuilder.resolve(repoInfo, repoName);
29
+ if (authResult.skipResult) {
30
+ return authResult.skipResult;
31
+ }
32
+ // Step 2: Determine merge mode
33
+ const mergeMode = repoConfig.prOptions?.merge ?? "auto";
34
+ const isDirectMode = mergeMode === "direct";
35
+ // Warn if mergeStrategy is set but ignored in direct mode
36
+ if (isDirectMode && repoConfig.prOptions?.mergeStrategy) {
37
+ this.log.info(`Warning: mergeStrategy '${repoConfig.prOptions.mergeStrategy}' is ignored in direct mode`);
38
+ }
39
+ let session = null;
40
+ try {
41
+ // Step 3: Setup session
42
+ session = await this.repositorySession.setup(repoInfo, {
43
+ workDir,
44
+ dryRun: dryRun ?? false,
45
+ retries,
46
+ authOptions: authResult.authOptions,
47
+ });
48
+ // Step 4: Setup branch
49
+ await this.branchManager.setupBranch({
50
+ repoInfo,
51
+ branchName,
52
+ baseBranch: session.baseBranch,
53
+ workDir,
54
+ isDirectMode,
55
+ dryRun: dryRun ?? false,
56
+ retries,
57
+ token: authResult.token,
58
+ gitOps: session.gitOps,
59
+ log: this.log,
60
+ executor,
61
+ });
62
+ // Step 5: Execute work strategy
63
+ const workResult = await workStrategy.execute(repoConfig, repoInfo, session, options);
64
+ // Step 6: No changes - skip
65
+ if (!workResult) {
66
+ return {
67
+ success: true,
68
+ repoName,
69
+ message: "No changes detected",
70
+ skipped: true,
71
+ };
72
+ }
73
+ // Step 7: Commit and push
74
+ const pushBranch = isDirectMode ? session.baseBranch : branchName;
75
+ const commitResult = await this.commitPushManager.commitAndPush({
76
+ repoInfo,
77
+ gitOps: session.gitOps,
78
+ workDir,
79
+ fileChanges: workResult.fileChanges,
80
+ commitMessage: workResult.commitMessage,
81
+ pushBranch,
82
+ isDirectMode,
83
+ dryRun: dryRun ?? false,
84
+ retries,
85
+ token: authResult.token,
86
+ executor,
87
+ }, repoName);
88
+ // Step 8: Handle commit errors
89
+ if (!commitResult.success && commitResult.errorResult) {
90
+ return commitResult.errorResult;
91
+ }
92
+ if (commitResult.skipped) {
93
+ return {
94
+ success: true,
95
+ repoName,
96
+ message: "No changes detected after staging",
97
+ skipped: true,
98
+ diffStats: workResult.diffStats,
99
+ fileChanges: workResult.fileChangeDetails,
100
+ };
101
+ }
102
+ // Step 9: Direct mode - done
103
+ if (isDirectMode) {
104
+ this.log.info(`Changes pushed directly to ${session.baseBranch}`);
105
+ return {
106
+ success: true,
107
+ repoName,
108
+ message: `Pushed directly to ${session.baseBranch}`,
109
+ diffStats: workResult.diffStats,
110
+ fileChanges: workResult.fileChangeDetails,
111
+ };
112
+ }
113
+ // Step 10: Create and merge PR
114
+ return await this.prMergeHandler.createAndMerge(repoInfo, repoConfig, {
115
+ branchName,
116
+ baseBranch: session.baseBranch,
117
+ workDir,
118
+ dryRun: dryRun ?? false,
119
+ retries,
120
+ prTemplate: options.prTemplate,
121
+ token: authResult.token,
122
+ executor,
123
+ }, workResult.changedFiles, repoName, workResult.diffStats, workResult.fileChangeDetails);
124
+ }
125
+ finally {
126
+ try {
127
+ session?.cleanup();
128
+ }
129
+ catch {
130
+ // Ignore cleanup errors - best effort
131
+ }
132
+ }
133
+ }
134
+ }
@@ -302,3 +302,38 @@ export interface IPRMergeHandler {
302
302
  */
303
303
  createAndMerge(repoInfo: RepoInfo, repoConfig: RepoConfig, options: PRHandlerOptions, changedFiles: FileAction[], repoName: string, diffStats?: DiffStats, fileChanges?: FileChangeDetail[]): Promise<ProcessorResult>;
304
304
  }
305
+ /**
306
+ * Result of executing work within a sync workflow
307
+ */
308
+ export interface WorkResult {
309
+ /** File changes to commit */
310
+ fileChanges: Map<string, FileWriteResult>;
311
+ /** Changed files for PR body */
312
+ changedFiles: FileAction[];
313
+ /** Diff statistics for reporting */
314
+ diffStats?: DiffStats;
315
+ /** Human-readable commit message */
316
+ commitMessage: string;
317
+ /** File change details for result reporting */
318
+ fileChangeDetails: FileChangeDetail[];
319
+ }
320
+ /**
321
+ * Strategy for executing work within the sync workflow.
322
+ * Implementations define what changes to make (files vs manifest).
323
+ */
324
+ export interface IWorkStrategy {
325
+ /**
326
+ * Execute work and return changes to commit.
327
+ * Return null if no changes detected (workflow will skip).
328
+ */
329
+ execute(repoConfig: RepoConfig, repoInfo: RepoInfo, session: SessionContext, options: ProcessorOptions): Promise<WorkResult | null>;
330
+ }
331
+ /**
332
+ * Orchestrates the common sync workflow steps.
333
+ */
334
+ export interface ISyncWorkflow {
335
+ /**
336
+ * Execute workflow: auth → session → branch → work → commit → PR
337
+ */
338
+ execute(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions, workStrategy: IWorkStrategy): Promise<ProcessorResult>;
339
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.8.0",
4
- "description": "CLI tool for repository-as-code",
3
+ "version": "3.8.2",
4
+ "description": "CLI tool for repository-as-code: sync files and manage settings across GitHub, Azure DevOps, and GitLab",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {