imcp 0.0.14 → 0.0.16

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 (136) hide show
  1. package/dist/core/ConfigurationProvider.d.ts +1 -0
  2. package/dist/core/ConfigurationProvider.js +15 -0
  3. package/dist/core/InstallationService.js +2 -7
  4. package/dist/core/MCPManager.d.ts +11 -2
  5. package/dist/core/MCPManager.js +24 -1
  6. package/dist/core/RequirementService.js +2 -8
  7. package/dist/core/installers/clients/BaseClientInstaller.d.ts +51 -0
  8. package/dist/core/installers/clients/BaseClientInstaller.js +160 -0
  9. package/dist/core/installers/clients/ClientInstaller.d.ts +16 -9
  10. package/dist/core/installers/clients/ClientInstaller.js +80 -527
  11. package/dist/core/installers/clients/ClientInstallerFactory.d.ts +20 -0
  12. package/dist/core/installers/clients/ClientInstallerFactory.js +37 -0
  13. package/dist/core/installers/clients/ClineInstaller.d.ts +18 -0
  14. package/dist/core/installers/clients/ClineInstaller.js +124 -0
  15. package/dist/core/installers/clients/GithubCopilotInstaller.d.ts +34 -0
  16. package/dist/core/installers/clients/GithubCopilotInstaller.js +162 -0
  17. package/dist/core/installers/clients/MSRooCodeInstaller.d.ts +15 -0
  18. package/dist/core/installers/clients/MSRooCodeInstaller.js +122 -0
  19. package/dist/core/installers/requirements/BaseInstaller.d.ts +11 -34
  20. package/dist/core/installers/requirements/BaseInstaller.js +5 -116
  21. package/dist/core/installers/requirements/CommandInstaller.d.ts +6 -1
  22. package/dist/core/installers/requirements/CommandInstaller.js +7 -0
  23. package/dist/core/installers/requirements/GeneralInstaller.d.ts +6 -1
  24. package/dist/core/installers/requirements/GeneralInstaller.js +9 -4
  25. package/dist/core/installers/requirements/NpmInstaller.d.ts +46 -7
  26. package/dist/core/installers/requirements/NpmInstaller.js +150 -58
  27. package/dist/core/installers/requirements/PipInstaller.d.ts +9 -0
  28. package/dist/core/installers/requirements/PipInstaller.js +66 -28
  29. package/dist/core/onboard/FeedOnboardService.d.ts +50 -13
  30. package/dist/core/onboard/FeedOnboardService.js +263 -88
  31. package/dist/core/onboard/OnboardProcessor.d.ts +79 -0
  32. package/dist/core/onboard/OnboardProcessor.js +290 -0
  33. package/dist/core/onboard/OnboardStatus.d.ts +49 -0
  34. package/dist/core/onboard/OnboardStatus.js +10 -0
  35. package/dist/core/onboard/OnboardStatusManager.d.ts +57 -0
  36. package/dist/core/onboard/OnboardStatusManager.js +176 -0
  37. package/dist/core/types.d.ts +4 -5
  38. package/dist/core/validators/FeedValidator.d.ts +8 -1
  39. package/dist/core/validators/FeedValidator.js +60 -7
  40. package/dist/core/validators/IServerValidator.d.ts +19 -0
  41. package/dist/core/validators/IServerValidator.js +2 -0
  42. package/dist/core/validators/SSEServerValidator.d.ts +15 -0
  43. package/dist/core/validators/SSEServerValidator.js +39 -0
  44. package/dist/core/validators/ServerValidatorFactory.d.ts +24 -0
  45. package/dist/core/validators/ServerValidatorFactory.js +45 -0
  46. package/dist/core/validators/StdioServerValidator.d.ts +46 -0
  47. package/dist/core/validators/StdioServerValidator.js +229 -0
  48. package/dist/services/InstallRequestValidator.d.ts +1 -1
  49. package/dist/services/ServerService.d.ts +9 -6
  50. package/dist/services/ServerService.js +18 -7
  51. package/dist/utils/adoUtils.d.ts +29 -0
  52. package/dist/utils/adoUtils.js +252 -0
  53. package/dist/utils/clientUtils.d.ts +0 -7
  54. package/dist/utils/clientUtils.js +0 -42
  55. package/dist/utils/githubUtils.d.ts +10 -0
  56. package/dist/utils/githubUtils.js +22 -0
  57. package/dist/utils/macroExpressionUtils.d.ts +38 -0
  58. package/dist/utils/macroExpressionUtils.js +116 -0
  59. package/dist/utils/osUtils.d.ts +4 -20
  60. package/dist/utils/osUtils.js +78 -23
  61. package/dist/web/contract/serverContract.d.ts +3 -1
  62. package/dist/web/public/css/notifications.css +48 -17
  63. package/dist/web/public/css/onboard.css +66 -3
  64. package/dist/web/public/index.html +84 -16
  65. package/dist/web/public/js/api.js +3 -6
  66. package/dist/web/public/js/flights/flights.js +127 -0
  67. package/dist/web/public/js/modal/installation.js +5 -5
  68. package/dist/web/public/js/modal/modalSetup.js +3 -2
  69. package/dist/web/public/js/notifications.js +66 -27
  70. package/dist/web/public/js/onboard/ONBOARDING_PAGE_DESIGN.md +338 -0
  71. package/dist/web/public/js/onboard/formProcessor.js +810 -255
  72. package/dist/web/public/js/onboard/index.js +328 -85
  73. package/dist/web/public/js/onboard/publishHandler.js +132 -0
  74. package/dist/web/public/js/onboard/state.js +61 -17
  75. package/dist/web/public/js/onboard/templates.js +217 -249
  76. package/dist/web/public/js/onboard/uiHandlers.js +679 -117
  77. package/dist/web/public/js/onboard/validationHandlers.js +378 -0
  78. package/dist/web/public/js/serverCategoryList.js +15 -2
  79. package/dist/web/public/onboard.html +191 -45
  80. package/dist/web/public/styles.css +91 -1
  81. package/dist/web/server.d.ts +0 -10
  82. package/dist/web/server.js +131 -22
  83. package/package.json +2 -2
  84. package/src/core/ConfigurationProvider.ts +15 -0
  85. package/src/core/InstallationService.ts +2 -7
  86. package/src/core/MCPManager.ts +26 -1
  87. package/src/core/RequirementService.ts +2 -9
  88. package/src/core/installers/clients/BaseClientInstaller.ts +196 -0
  89. package/src/core/installers/clients/ClientInstaller.ts +97 -608
  90. package/src/core/installers/clients/ClientInstallerFactory.ts +43 -0
  91. package/src/core/installers/clients/ClineInstaller.ts +135 -0
  92. package/src/core/installers/clients/GithubCopilotInstaller.ts +179 -0
  93. package/src/core/installers/clients/MSRooCodeInstaller.ts +133 -0
  94. package/src/core/installers/requirements/BaseInstaller.ts +13 -136
  95. package/src/core/installers/requirements/CommandInstaller.ts +9 -1
  96. package/src/core/installers/requirements/GeneralInstaller.ts +11 -4
  97. package/src/core/installers/requirements/NpmInstaller.ts +178 -61
  98. package/src/core/installers/requirements/PipInstaller.ts +68 -29
  99. package/src/core/onboard/FeedOnboardService.ts +346 -0
  100. package/src/core/onboard/OnboardProcessor.ts +305 -0
  101. package/src/core/onboard/OnboardStatus.ts +55 -0
  102. package/src/core/onboard/OnboardStatusManager.ts +188 -0
  103. package/src/core/types.ts +4 -5
  104. package/src/core/validators/FeedValidator.ts +79 -0
  105. package/src/core/validators/IServerValidator.ts +21 -0
  106. package/src/core/validators/SSEServerValidator.ts +43 -0
  107. package/src/core/validators/ServerValidatorFactory.ts +51 -0
  108. package/src/core/validators/StdioServerValidator.ts +259 -0
  109. package/src/services/InstallRequestValidator.ts +1 -1
  110. package/src/services/ServerService.ts +22 -7
  111. package/src/utils/adoUtils.ts +291 -0
  112. package/src/utils/clientUtils.ts +0 -44
  113. package/src/utils/githubUtils.ts +24 -0
  114. package/src/utils/macroExpressionUtils.ts +121 -0
  115. package/src/utils/osUtils.ts +89 -24
  116. package/src/web/contract/serverContract.ts +74 -0
  117. package/src/web/public/css/notifications.css +48 -17
  118. package/src/web/public/css/onboard.css +107 -0
  119. package/src/web/public/index.html +84 -16
  120. package/src/web/public/js/api.js +3 -6
  121. package/src/web/public/js/flights/flights.js +127 -0
  122. package/src/web/public/js/modal/installation.js +5 -5
  123. package/src/web/public/js/modal/modalSetup.js +3 -2
  124. package/src/web/public/js/notifications.js +66 -27
  125. package/src/web/public/js/onboard/ONBOARDING_PAGE_DESIGN.md +338 -0
  126. package/src/web/public/js/onboard/formProcessor.js +864 -0
  127. package/src/web/public/js/onboard/index.js +374 -0
  128. package/src/web/public/js/onboard/publishHandler.js +132 -0
  129. package/src/web/public/js/onboard/state.js +76 -0
  130. package/src/web/public/js/onboard/templates.js +343 -0
  131. package/src/web/public/js/onboard/uiHandlers.js +758 -0
  132. package/src/web/public/js/onboard/validationHandlers.js +378 -0
  133. package/src/web/public/js/serverCategoryList.js +15 -2
  134. package/src/web/public/onboard.html +296 -0
  135. package/src/web/public/styles.css +91 -1
  136. package/src/web/server.ts +167 -58
@@ -0,0 +1,346 @@
1
+ import { FeedConfiguration, McpConfig } from '../types.js';
2
+ import { configProvider, ConfigurationProvider } from '../ConfigurationProvider.js';
3
+ import { feedValidator } from '../validators/FeedValidator.js';
4
+ import { Logger } from '../../utils/logger.js';
5
+ import { OnboardStatus, OnboardingProcessStatus, OperationStatus, OperationType, ValidationOperationResult } from './OnboardStatus.js';
6
+ import { onboardStatusManager } from './OnboardStatusManager.js';
7
+ import { onboardProcessor } from './OnboardProcessor.js';
8
+
9
+ const NON_COMPLETED_ONBOARDING_STATUSES: OnboardingProcessStatus[] = [
10
+ OnboardingProcessStatus.PENDING,
11
+ OnboardingProcessStatus.VALIDATING,
12
+ OnboardingProcessStatus.VALIDATED,
13
+ OnboardingProcessStatus.PR_CREATING,
14
+ ];
15
+
16
+ /**
17
+ * Service for handling feed onboarding operations
18
+ */
19
+ export class FeedOnboardService {
20
+ // tempDir and repoDir will be generated per onboarding process
21
+
22
+ constructor() {
23
+ // No shared tempDir or repoDir at instance level
24
+ }
25
+
26
+ /**
27
+ * Onboard a new feed configuration
28
+ * @param config Feed configuration to onboard
29
+ */
30
+ public async onboardFeed(config: FeedConfiguration, forExistingCategory?: boolean): Promise<OperationStatus & { feedConfiguration?: FeedConfiguration }> {
31
+ // Perform static validation first
32
+ await this.validateStaticConfig(config, forExistingCategory);
33
+
34
+ const operationStatus = await this._initiateOperation(config, 'FULL_ONBOARDING', forExistingCategory);
35
+
36
+ return { ...operationStatus, feedConfiguration: config };
37
+ }
38
+
39
+ /**
40
+ * Validate a feed configuration without performing full onboarding
41
+ * @param config Feed configuration to validate
42
+ * @returns Operation status indicating the result of the validation initiation
43
+ */
44
+ public async validateFeed(config: FeedConfiguration, forExistingCategory?: boolean): Promise<OperationStatus & { feedConfiguration?: FeedConfiguration }> {
45
+ // Perform static validation first
46
+ await this.validateStaticConfig(config, forExistingCategory);
47
+
48
+ const operationStatus = await this._initiateOperation(config, 'VALIDATION_ONLY', forExistingCategory);
49
+
50
+ return { ...operationStatus, feedConfiguration: config };
51
+ }
52
+
53
+ /**
54
+ * Initiates either a full onboarding or a validation-only operation.
55
+ * It checks for existing non-completed operations for the same feed and operation type.
56
+ * If an existing operation is found, its status is returned. Otherwise, a new operation is created and started.
57
+ * @param config The feed configuration.
58
+ * @param operationType The type of operation to initiate (FULL_ONBOARDING or VALIDATION_ONLY).
59
+ * @returns A promise that resolves to the operation status.
60
+ */
61
+ private async _initiateOperation(config: FeedConfiguration, operationType: OperationType, forExistingCategory?: boolean): Promise<OperationStatus> {
62
+ let existingOperation = await this._findExistingNonCompletedOperation(config.name, operationType);
63
+
64
+ if (existingOperation) {
65
+ // OnboardStatus has a lastUpdated field (ISO string)
66
+ // This field is updated by OnboardStatusManager.updateStatus
67
+ const fiveMinutesInMs = 5 * 60 * 1000;
68
+ const lastUpdateTimestamp = existingOperation.lastUpdated ? new Date(existingOperation.lastUpdated).getTime() : 0;
69
+ const currentTime = new Date().getTime();
70
+
71
+ if (lastUpdateTimestamp > 0 && (currentTime - lastUpdateTimestamp) > fiveMinutesInMs) {
72
+ Logger.log(`WARNING: [${existingOperation.onboardingId}] Found stale ${operationType} operation for feed: ${config.name} (last updated at: ${existingOperation.lastUpdated}). Proceeding to create a new operation.`);
73
+
74
+ existingOperation = undefined; // Treat as no existing operation for starting a new one
75
+ } else {
76
+ Logger.log(`[${existingOperation.onboardingId}] Found existing non-completed ${operationType} operation for feed: ${config.name}. Returning its status.`);
77
+ return {
78
+ onboardingId: existingOperation.onboardingId,
79
+ status: existingOperation.status,
80
+ message: `An ${operationType} process for this feed (${existingOperation.onboardingId}) is already in status: ${existingOperation.status}. Current step: ${existingOperation.currentStep || 'N/A'}`,
81
+ lastQueried: new Date().toISOString(),
82
+ };
83
+ }
84
+ }
85
+
86
+ // serverName is removed
87
+ const initialStatus = await onboardStatusManager.createInitialStatus(config.name, operationType);
88
+ const onboardingId = initialStatus.onboardingId;
89
+
90
+ await onboardStatusManager.saveFeedConfiguration(onboardingId, config);
91
+
92
+ if (operationType === 'FULL_ONBOARDING') {
93
+ this.processFullOnboarding(onboardingId, config).catch(async (error) => {
94
+ Logger.error(`[${onboardingId}] Full feed onboarding process failed:`, error);
95
+ await onboardStatusManager.updateStatus(onboardingId, {
96
+ status: OnboardingProcessStatus.FAILED,
97
+ errorMessage: error instanceof Error ? error.message : String(error),
98
+ currentStep: 'Failed during full onboarding process',
99
+ prInfo: undefined,
100
+ });
101
+ });
102
+ } else if (operationType === 'VALIDATION_ONLY') {
103
+ this.processValidationOnly(onboardingId, config).catch(async (error) => {
104
+ Logger.error(`[${onboardingId}] Feed validation process failed:`, error);
105
+ await onboardStatusManager.updateStatus(onboardingId, {
106
+ status: OnboardingProcessStatus.FAILED,
107
+ errorMessage: error instanceof Error ? error.message : String(error),
108
+ currentStep: 'Failed during validation process',
109
+ validationStatus: (error as any).validationStatus || { isValid: false, message: error instanceof Error ? error.message : String(error) },
110
+ prInfo: undefined,
111
+ });
112
+ });
113
+ }
114
+
115
+ return {
116
+ onboardingId,
117
+ status: OnboardingProcessStatus.PENDING,
118
+ message: `New ${operationType} process started with ID: ${onboardingId}.`,
119
+ lastQueried: new Date().toISOString(),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Finds an existing non-completed operation for a given feed name, server name, and operation type.
125
+ * @param feedName The name of the feed.
126
+ * @param operationType The type of operation.
127
+ * @returns A promise that resolves to the OnboardStatus of the existing operation, or undefined if not found.
128
+ */
129
+ private async _findExistingNonCompletedOperation(
130
+ feedName: string,
131
+ operationType: OperationType
132
+ ): Promise<OnboardStatus | undefined> {
133
+ const allStatuses = await onboardStatusManager.getAllStatuses();
134
+ return Object.values(allStatuses).find(
135
+ (status) =>
136
+ status.feedName === feedName &&
137
+ // status.serverName === serverName && // serverName is removed
138
+ status.operationType === operationType &&
139
+ NON_COMPLETED_ONBOARDING_STATUSES.includes(status.status)
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Performs static validation of the feed configuration.
145
+ * @param config The feed configuration to validate.
146
+ * @param forExistingCategory Whether this is for an existing category.
147
+ * @throws Error if validation fails.
148
+ */
149
+ private async validateStaticConfig(config: FeedConfiguration, forExistingCategory?: boolean): Promise<void> {
150
+ // --- Start of moved and enhanced static validation ---
151
+ if (!config || typeof config !== 'object') { // Check if config itself is a valid object
152
+ throw new Error('Invalid configuration: Input must be a valid object.');
153
+ }
154
+ if (typeof config.name !== 'string' || !config.name.trim()) {
155
+ throw new Error('Invalid configuration: "name" must be a non-empty string.');
156
+ }
157
+ if (!Array.isArray(config.requirements)) {
158
+ throw new Error('Invalid configuration: "requirements" must be an array.');
159
+ }
160
+ if (!Array.isArray(config.mcpServers)) {
161
+ throw new Error('Invalid configuration: "mcpServers" must be an array.');
162
+ }
163
+ // --- End of moved static validation ---
164
+
165
+ const existingConfig = await configProvider.getFeedConfiguration(config.name);
166
+
167
+ if (forExistingCategory) {
168
+ if (!existingConfig) {
169
+ throw new Error(`Cannot update non-existent category: ${config.name}. Please ensure the category name is correct or create it as a new category.`);
170
+ }
171
+ // Original logic from existing validateStaticConfig:
172
+ // This implies that for an existing category, the payload must define mcpServers,
173
+ // and at least one of them must be new.
174
+ // If config.mcpServers is empty, newServersInPayload.length will be 0, leading to an error.
175
+ const existingServerNames = new Set(existingConfig.mcpServers.map((s: McpConfig) => s.name));
176
+ const newServersInPayload = config.mcpServers.filter((s: McpConfig) => !existingServerNames.has(s.name));
177
+
178
+ if (newServersInPayload.length === 0) {
179
+ // This error triggers if config.mcpServers is empty, or if all servers in config.mcpServers already exist.
180
+ throw new Error('No new servers provided for the existing category. Ensure the "mcpServers" list in your payload contains at least one server not already in this category, or that the list is not empty if you intend to add servers.');
181
+ }
182
+ } else { // For new categories
183
+ if (existingConfig) {
184
+ throw new Error(`Category "${config.name}" already exists. To modify an existing category, please indicate that it is for an existing category.`);
185
+ }
186
+ if (!config.mcpServers || config.mcpServers.length === 0) {
187
+ throw new Error('Server configuration ("mcpServers") must be provided and non-empty for new categories.');
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Validates the feed configuration.
194
+ * Updates the onboarding status to VALIDATING, then to VALIDATED or FAILED based on the validation result.
195
+ * @param onboardingId The ID of the onboarding process.
196
+ * @param config The feed configuration to validate.
197
+ * @throws Error if validation fails, to be caught by the calling process.
198
+ */
199
+ private async _validateFeedConfiguration(onboardingId: string, config: FeedConfiguration): Promise<void> {
200
+ await onboardStatusManager.updateStatus(onboardingId, { status: OnboardingProcessStatus.VALIDATING, currentStep: 'Validating feed configuration' });
201
+ let overallValidationStatus: ValidationOperationResult['validationStatus'] = { isValid: true, serverResults: [] };
202
+ try {
203
+ feedValidator.validate(config);
204
+
205
+ // Then, validate each MCP server
206
+ for (const server of config.mcpServers) {
207
+ try {
208
+ await feedValidator.validateServer(server, config);
209
+ overallValidationStatus.serverResults?.push({ serverName: server.name, isValid: true });
210
+ await onboardStatusManager.updateStatus(onboardingId, {
211
+ currentStep: `Validated server: ${server.name}`,
212
+ validationStatus: overallValidationStatus // Update with intermediate results
213
+ });
214
+ } catch (serverValidationError: any) {
215
+ overallValidationStatus.isValid = false;
216
+ overallValidationStatus.serverResults?.push({
217
+ serverName: server.name,
218
+ isValid: false,
219
+ message: serverValidationError.message || String(serverValidationError)
220
+ });
221
+ // Update status immediately upon first server validation failure
222
+ await onboardStatusManager.updateStatus(onboardingId, {
223
+ status: OnboardingProcessStatus.FAILED,
224
+ currentStep: `Validation failed for server: ${server.name}`,
225
+ errorMessage: `Server ${server.name}: ${serverValidationError.message || String(serverValidationError)}`,
226
+ validationStatus: overallValidationStatus
227
+ });
228
+ throw serverValidationError; // Propagate the error to fail the whole validation
229
+ }
230
+ }
231
+
232
+ // If all servers validated successfully
233
+ await onboardStatusManager.updateStatus(onboardingId, {
234
+ status: OnboardingProcessStatus.VALIDATED,
235
+ currentStep: 'All servers in feed configuration validated successfully',
236
+ validationStatus: overallValidationStatus
237
+ });
238
+ } catch (error) {
239
+ const validationError = error as any; // Assuming error might have validation details
240
+ // This block will now primarily catch errors from the main feedValidator.validate(config)
241
+ // or if a server validation error was re-thrown and not caught by the loop's catch.
242
+ await onboardStatusManager.updateStatus(onboardingId, {
243
+ status: OnboardingProcessStatus.FAILED,
244
+ currentStep: 'Feed configuration validation failed',
245
+ errorMessage: validationError.message || String(error),
246
+ validationStatus: { // Ensure this reflects the actual state, potentially including partial server results
247
+ isValid: false,
248
+ message: validationError.message || String(error),
249
+ serverResults: overallValidationStatus.serverResults // Include any server results gathered so far
250
+ }
251
+ });
252
+ throw error; // Re-throw to be caught by the calling process
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Processes a validation-only operation for a feed configuration.
258
+ * It validates the configuration and updates the status to SUCCEEDED if validation is successful.
259
+ * If validation fails, the error is handled by _validateFeedConfiguration and the calling _initiateOperation method.
260
+ * @param onboardingId The ID of the onboarding process.
261
+ * @param config The feed configuration.
262
+ */
263
+ private async processValidationOnly(onboardingId: string, config: FeedConfiguration, forExistingCategory?: boolean): Promise<void> {
264
+ try {
265
+ await this._validateFeedConfiguration(onboardingId, config);
266
+ // _validateFeedConfiguration now handles setting VALIDATED or FAILED.
267
+ // If it completes without throwing, it means validation was successful.
268
+ const finalStatus = await onboardStatusManager.getStatus(onboardingId);
269
+ const result: ValidationOperationResult = {
270
+ validationStatus: finalStatus?.validationStatus || { isValid: true, serverResults: [] }, // Get the detailed status
271
+ feedConfiguration: config
272
+ };
273
+ await onboardStatusManager.updateStatus(onboardingId, {
274
+ status: OnboardingProcessStatus.SUCCEEDED, // Mark as SUCCEEDED for VALIDATION_ONLY
275
+ currentStep: 'Feed configuration validated successfully',
276
+ errorMessage: undefined,
277
+ prInfo: undefined,
278
+ result: result
279
+ });
280
+ Logger.log(`[${onboardingId}] Successfully validated feed: ${config.name}`);
281
+ } catch (error) {
282
+ // Error handling is done within _validateFeedConfiguration and by the caller of processValidationOnly
283
+ Logger.error(`[${onboardingId}] Feed validation process failed for ${config.name}:`, error);
284
+ // No need to re-throw here as the caller's catch block will handle it.
285
+ }
286
+ }
287
+
288
+
289
+ /**
290
+ * Processes the full onboarding for a feed configuration.
291
+ * This includes validation, forking/cloning the repository, saving the config, and creating a pull request.
292
+ * Manages status updates throughout the process and handles cleanup of temporary directories.
293
+ * @param onboardingId The ID of the onboarding process.
294
+ * @param config The feed configuration.
295
+ * @throws Error if any step of the full onboarding process fails.
296
+ */
297
+ private async processFullOnboarding(onboardingId: string, config: FeedConfiguration, forExistingCategory?: boolean): Promise<void> {
298
+ const { tempDir, repoDir } = onboardProcessor.createDirectories(onboardingId);
299
+ try {
300
+ await this._validateFeedConfiguration(onboardingId, config);
301
+
302
+ await onboardStatusManager.updateStatus(onboardingId, { status: OnboardingProcessStatus.PR_CREATING, currentStep: 'Forking repository' });
303
+ await onboardProcessor.forkRepo(onboardingId, repoDir);
304
+
305
+ await onboardStatusManager.updateStatus(onboardingId, { status: OnboardingProcessStatus.PR_CREATING, currentStep: 'Cloning repository' });
306
+ await onboardProcessor.cloneRepo(onboardingId, tempDir, repoDir);
307
+
308
+ await onboardStatusManager.updateStatus(onboardingId, { status: OnboardingProcessStatus.PR_CREATING, currentStep: 'Setting up branch' });
309
+ const branchName = await onboardProcessor.setupBranch(onboardingId, config, repoDir);
310
+
311
+ await onboardStatusManager.updateStatus(onboardingId, { status: OnboardingProcessStatus.PR_CREATING, currentStep: 'Saving feed configuration to repository' });
312
+ await onboardProcessor.saveFeedConfigToRepo(onboardingId, config, repoDir);
313
+
314
+ await onboardStatusManager.updateStatus(onboardingId, { status: OnboardingProcessStatus.PR_CREATING, currentStep: 'Creating pull request' });
315
+ const prInfo = await onboardProcessor.createPullRequest(onboardingId, config, repoDir, branchName);
316
+
317
+ await onboardStatusManager.updateStatus(onboardingId, {
318
+ status: OnboardingProcessStatus.SUCCEEDED,
319
+ currentStep: 'Successfully onboarded',
320
+ errorMessage: undefined,
321
+ prInfo: prInfo,
322
+ result: prInfo.url
323
+ });
324
+ Logger.log(`[${onboardingId}] Successfully onboarded feed: ${config.name}`);
325
+ } catch (error) {
326
+ Logger.error(`[${onboardingId}] Full feed onboarding failed:`, error);
327
+ const errorMessage = error instanceof Error ? error.message : String(error);
328
+ let currentStep = 'Failed during full onboarding process';
329
+ if (error instanceof Error && 'step' in error) {
330
+ currentStep = `Failed at step: ${error.step}`;
331
+ } else if ((await onboardStatusManager.getStatus(onboardingId))?.currentStep?.includes('validation failed')) {
332
+ throw error;
333
+ }
334
+
335
+ await onboardStatusManager.updateStatus(onboardingId, {
336
+ status: OnboardingProcessStatus.FAILED,
337
+ errorMessage: errorMessage,
338
+ currentStep: currentStep,
339
+ });
340
+ throw error;
341
+ }
342
+ }
343
+ }
344
+
345
+ // Export singleton instance
346
+ export const feedOnboardService = new FeedOnboardService();
@@ -0,0 +1,305 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import { GITHUB_REPO, SETTINGS_DIR } from '../constants.js';
6
+ import { FeedConfiguration } from '../types.js';
7
+ import { Logger } from '../../utils/logger.js';
8
+
9
+ const execAsync = promisify(exec);
10
+
11
+ /**
12
+ * Class responsible for handling GitHub operations during the onboarding process
13
+ */
14
+ export class OnboardProcessor {
15
+ /**
16
+ * Forks the GitHub repository specified in GITHUB_REPO constants.
17
+ * Uses the `gh repo fork` command.
18
+ * @param onboardingId The ID of the onboarding process (for logging).
19
+ * @param repoDir The directory of the repository (currently unused but kept for consistency).
20
+ * @throws An error if the forking process fails, with a 'step' property set to 'forkRepo'.
21
+ */
22
+ public async forkRepo(onboardingId: string, repoDir: string): Promise<void> {
23
+ try {
24
+ // Using --remote=true ensures that if the fork already exists, 'origin' remote is set to it.
25
+ // If the fork doesn't exist, it creates it and sets 'origin' remote.
26
+ await execAsync(`gh repo fork ${GITHUB_REPO.repoName} --clone=false --remote=true`);
27
+ Logger.debug(`[${onboardingId}] Successfully ensured repository is forked and remote 'origin' is set.`);
28
+ } catch (error) {
29
+ const errorMessage = error instanceof Error ? error.message : String(error);
30
+ Logger.error(`[${onboardingId}] Error during forkRepo: ${errorMessage}`);
31
+ throw Object.assign(new Error(`Failed to fork repository: ${errorMessage}`), { step: 'forkRepo' });
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Clones the user's fork of the repository into a temporary directory.
37
+ * It first removes any existing repository directory for the current onboarding process.
38
+ * Then, it retrieves the GitHub username and constructs the repository URL to clone.
39
+ * @param onboardingId The ID of the onboarding process (for logging).
40
+ * @param tempDir The base temporary directory for the onboarding process.
41
+ * @param repoDir The specific directory within tempDir where the repository will be cloned.
42
+ * @throws An error if cloning fails, with a 'step' property set to 'cloneRepo'.
43
+ */
44
+ public async cloneRepo(onboardingId: string, tempDir: string, repoDir: string): Promise<void> {
45
+ try {
46
+ await fs.mkdir(tempDir, { recursive: true });
47
+
48
+ let repoExistsAndIsValid = false;
49
+ const originalCwd = process.cwd();
50
+
51
+ try {
52
+ const gitDirStat = await fs.stat(path.join(repoDir, '.git'));
53
+ if (gitDirStat.isDirectory()) {
54
+ process.chdir(repoDir);
55
+ const { stdout: remoteUrlStdout } = await execAsync(`git config --get remote.origin.url`);
56
+ const remoteUrl = remoteUrlStdout.trim();
57
+ process.chdir(originalCwd);
58
+
59
+ const username = await this.getGitHubUsername();
60
+ const expectedRepoName = GITHUB_REPO.repoName.split('/')[1];
61
+
62
+ if (remoteUrl.includes(username) && remoteUrl.includes(expectedRepoName)) {
63
+ Logger.debug(`[${onboardingId}] Repository directory ${repoDir} already exists, is a valid git repository, and has the correct remote origin. Skipping clone.`);
64
+ repoExistsAndIsValid = true;
65
+ } else {
66
+ Logger.log(`WARNING: [${onboardingId}] Repository directory ${repoDir} exists and is a git repository, but remote 'origin' URL (${remoteUrl}) does not match expected user/repo. Will re-clone.`);
67
+ }
68
+ }
69
+ } catch (checkError: any) {
70
+ Logger.debug(`[${onboardingId}] Repository directory ${repoDir} check failed or is not the correct repository. Proceeding with clone. Error: ${checkError.message || checkError.code}`);
71
+ if (process.cwd() !== originalCwd) {
72
+ process.chdir(originalCwd);
73
+ }
74
+ }
75
+
76
+ if (!repoExistsAndIsValid) {
77
+ await fs.rm(repoDir, { recursive: true, force: true });
78
+ Logger.debug(`[${onboardingId}] Cleaned up existing directory (if any) at ${repoDir}.`);
79
+
80
+ const username = await this.getGitHubUsername();
81
+ const repoUrl = `https://github.com/${username}/${GITHUB_REPO.repoName.split('/')[1]}.git`;
82
+
83
+ await execAsync(`git clone ${repoUrl} ${repoDir}`);
84
+ Logger.debug(`[${onboardingId}] Successfully cloned repository to ${repoDir}`);
85
+ }
86
+ } catch (error) {
87
+ const errorMessage = error instanceof Error ? error.message : String(error);
88
+ Logger.error(`[${onboardingId}] Error during cloneRepo: ${errorMessage}`);
89
+ throw Object.assign(new Error(`Failed to clone repository: ${errorMessage}`), { step: 'cloneRepo' });
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Retrieves the GitHub username of the authenticated user using the `gh api user` command.
95
+ * @returns A promise that resolves to the GitHub username as a string.
96
+ * @throws An error if fetching the username fails.
97
+ */
98
+ private async getGitHubUsername(): Promise<string> {
99
+ try {
100
+ const { stdout } = await execAsync('gh api user -q .login');
101
+ return stdout.trim();
102
+ } catch (error) {
103
+ throw new Error(`Failed to get GitHub username: ${error instanceof Error ? error.message : String(error)}`);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Saves the feed configuration as a JSON file into the specified path within the cloned repository.
109
+ * The file will be named `config.name.json`.
110
+ * @param onboardingId The ID of the onboarding process.
111
+ * @param config The feed configuration to save.
112
+ * @param repoDir The root directory of the cloned repository.
113
+ * @throws An error if saving the configuration fails, with a 'step' property set to 'saveFeedConfigToRepo'.
114
+ */
115
+ public async saveFeedConfigToRepo(onboardingId: string, config: FeedConfiguration, repoDir: string): Promise<void> {
116
+ try {
117
+ const feedsDir = path.join(repoDir, GITHUB_REPO.feedsPath);
118
+ await fs.mkdir(feedsDir, { recursive: true });
119
+
120
+ const configPath = path.join(feedsDir, `${config.name}.json`);
121
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
122
+ Logger.debug(`[${onboardingId}] Saved feed configuration to ${configPath}`);
123
+ } catch (error) {
124
+ const errorMessage = error instanceof Error ? error.message : String(error);
125
+ Logger.error(`[${onboardingId}] Error during saveFeedConfigToRepo: ${errorMessage}`);
126
+ throw Object.assign(new Error(`Failed to save feed configuration to repo: ${errorMessage}`), { step: 'saveFeedConfigToRepo' });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Creates a pull request on GitHub for the newly added feed configuration.
132
+ * @param onboardingId The ID of the onboarding process.
133
+ * @param config The feed configuration, used for branch name and PR details.
134
+ * @param repoDir The root directory of the cloned repository.
135
+ * @returns A promise that resolves to an object containing the URL of the created PR and the branch name.
136
+ * @throws An error if any step of the PR creation process fails.
137
+ */
138
+ /**
139
+ * Sets up a clean branch for the feed configuration changes.
140
+ * @param onboardingId The ID of the onboarding process.
141
+ * @param config The feed configuration.
142
+ * @param repoDir The root directory of the cloned repository.
143
+ * @returns The name of the created branch.
144
+ * @throws An error if branch setup fails.
145
+ */
146
+ public async setupBranch(onboardingId: string, config: FeedConfiguration, repoDir: string): Promise<string> {
147
+ try {
148
+ process.chdir(repoDir);
149
+
150
+ try {
151
+ await execAsync(`git remote get-url upstream`);
152
+ await execAsync(`git remote set-url upstream ${GITHUB_REPO.url}`);
153
+ Logger.debug(`[${onboardingId}] Updated 'upstream' remote URL to ${GITHUB_REPO.url}`);
154
+ } catch (remoteError) {
155
+ await execAsync(`git remote add upstream ${GITHUB_REPO.url}`);
156
+ Logger.debug(`[${onboardingId}] Added 'upstream' remote with URL ${GITHUB_REPO.url}`);
157
+ }
158
+
159
+ await execAsync('git fetch upstream');
160
+ Logger.debug(`[${onboardingId}] Fetched from upstream.`);
161
+
162
+ try {
163
+ await execAsync('git checkout main');
164
+ Logger.debug(`[${onboardingId}] Switched to existing local branch 'main'.`);
165
+ } catch (checkoutMainError: any) {
166
+ const errStdErr = checkoutMainError.stderr || '';
167
+ if (errStdErr.includes('did not match any file(s) known to git') ||
168
+ errStdErr.includes('is not a commit and a branch') ||
169
+ errStdErr.includes('pathspec \'main\' did not match any file(s) known to git')) {
170
+ Logger.debug(`[${onboardingId}] Local branch 'main' not found or invalid. Attempting to create it from 'upstream/main'.`);
171
+ try {
172
+ await execAsync('git checkout -b main upstream/main');
173
+ Logger.debug(`[${onboardingId}] Created and switched to local branch 'main' from 'upstream/main'.`);
174
+ } catch (createMainError: any) {
175
+ const errMsg = createMainError instanceof Error ? createMainError.message : String(createMainError);
176
+ Logger.error(`[${onboardingId}] Failed to create local branch 'main' from 'upstream/main': ${errMsg}. Stderr: ${createMainError.stderr}`);
177
+ throw Object.assign(new Error(`Failed to create local branch 'main': ${errMsg}`), { step: 'setupBranch.setupMainBranch' });
178
+ }
179
+ } else {
180
+ const errMsg = checkoutMainError instanceof Error ? checkoutMainError.message : String(checkoutMainError);
181
+ Logger.error(`[${onboardingId}] Failed to checkout local branch 'main': ${errMsg}. Stderr: ${checkoutMainError.stderr}`);
182
+ throw Object.assign(new Error(`Failed to checkout local branch 'main': ${errMsg}`), { step: 'setupBranch.checkoutMainBranch' });
183
+ }
184
+ }
185
+
186
+ await execAsync('git reset --hard upstream/main');
187
+ Logger.debug(`[${onboardingId}] Local 'main' branch is now hard reset to 'upstream/main'.`);
188
+
189
+ const branchName = `feed/${config.name}`;
190
+ try {
191
+ await execAsync(`git branch -D ${branchName}`);
192
+ Logger.debug(`[${onboardingId}] Deleted local branch '${branchName}' as it may have existed.`);
193
+ } catch (deleteError: any) {
194
+ const errStdErrDelete = deleteError.stderr || '';
195
+ if (errStdErrDelete.includes('not found') || errStdErrDelete.includes('not found.')) {
196
+ Logger.debug(`[${onboardingId}] Local branch '${branchName}' did not exist, no need to delete.`);
197
+ } else {
198
+ const errorMessage = deleteError instanceof Error ? deleteError.message : String(deleteError);
199
+ Logger.error(`[${onboardingId}] Failed to delete local branch '${branchName}': ${errorMessage}. Stderr: ${deleteError.stderr}`);
200
+ throw Object.assign(new Error(`Failed to delete local branch '${branchName}': ${errorMessage}`), { step: 'setupBranch.deleteFeatureBranch' });
201
+ }
202
+ }
203
+
204
+ await execAsync(`git checkout -b ${branchName} upstream/main`);
205
+ Logger.debug(`[${onboardingId}] Created and checked out new branch '${branchName}' from 'upstream/main'.`);
206
+
207
+ return branchName;
208
+ } catch (error) {
209
+ const errorMessage = error instanceof Error ? error.message : String(error);
210
+ Logger.error(`[${onboardingId}] Error during branch setup: ${errorMessage}`);
211
+ throw Object.assign(new Error(`Failed to setup branch: ${errorMessage}`), { step: 'setupBranch' });
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Creates a pull request for the feed configuration changes.
217
+ * @param onboardingId The ID of the onboarding process.
218
+ * @param config The feed configuration.
219
+ * @param repoDir The root directory of the cloned repository.
220
+ * @param branchName The name of the branch to create the PR from.
221
+ * @returns Object containing the PR URL and branch name.
222
+ * @throws An error if PR creation fails.
223
+ */
224
+ public async createPullRequest(onboardingId: string, config: FeedConfiguration, repoDir: string, branchName: string): Promise<{ url: string; branchName: string }> {
225
+ try {
226
+ process.chdir(repoDir);
227
+
228
+ // Stage and commit changes
229
+ await execAsync('git add .');
230
+ await execAsync(`git commit --allow-empty -m "Add feed configuration for ${config.name}"`);
231
+ Logger.debug(`[${onboardingId}] Committed changes (or allowed empty commit) for ${config.name}.`);
232
+
233
+ // Push to origin
234
+ await execAsync(`git push -u -f origin ${branchName}`);
235
+ Logger.debug(`[${onboardingId}] Pushed branch ${branchName} to origin. Verifying remote branch...`);
236
+
237
+ try {
238
+ const { stdout: remoteBranchCheck } = await execAsync(`git ls-remote origin refs/heads/${branchName}`);
239
+ if (!remoteBranchCheck.includes(`refs/heads/${branchName}`)) {
240
+ throw new Error(`Branch ${branchName} not found on remote 'origin' (fork) after push. Output of ls-remote: ${remoteBranchCheck}`);
241
+ }
242
+ Logger.debug(`[${onboardingId}] Branch ${branchName} confirmed on remote 'origin' (fork).`);
243
+ } catch (verifyError) {
244
+ const errorMessage = verifyError instanceof Error ? verifyError.message : String(verifyError);
245
+ Logger.error(`[${onboardingId}] Failed to verify branch ${branchName} on remote 'origin' (fork) after push: ${errorMessage}`);
246
+ throw new Error(`Verification of branch ${branchName} on remote 'origin' (fork) failed: ${errorMessage}`);
247
+ }
248
+
249
+ const title = `Add feed configuration for ${config.name}`;
250
+ const body = `Add new feed configuration:\n\n- Name: ${config.name}\n- Display Name: ${config.displayName}\n- Description: ${config.description}`;
251
+ const username = await this.getGitHubUsername();
252
+ const explicitHeadRef = `${username}:${branchName}`;
253
+
254
+ const prCheckCommand = `gh pr list --repo ${GITHUB_REPO.repoName} --head "${explicitHeadRef}" --state open --json url --limit 1`;
255
+ Logger.debug(`[${onboardingId}] Checking for existing PR with command: ${prCheckCommand}`);
256
+ const { stdout: existingPrJson } = await execAsync(prCheckCommand);
257
+
258
+ let prUrlToReturn: string;
259
+
260
+ const createPRCommand = `gh pr create --repo ${GITHUB_REPO.repoName} --base main --head ${explicitHeadRef} --title "${title}" --body "${body}"`
261
+
262
+ if (existingPrJson && existingPrJson.trim() !== '[]' && existingPrJson.trim() !== '') {
263
+ try {
264
+ const prs = JSON.parse(existingPrJson);
265
+ if (prs.length > 0 && prs[0].url) {
266
+ prUrlToReturn = prs[0].url;
267
+ Logger.log(`[${onboardingId}] Found existing open PR for ${explicitHeadRef} on ${GITHUB_REPO.repoName}: ${prUrlToReturn}`);
268
+ } else {
269
+ Logger.debug(`[${onboardingId}] No existing PR found or unable to parse PR list JSON. Proceeding to create PR.`);
270
+ const { stdout: prUrl } = await execAsync(createPRCommand);
271
+ prUrlToReturn = prUrl.trim();
272
+ }
273
+ } catch (parseError) {
274
+ Logger.log(`WARNING: [${onboardingId}] Could not parse existing PR JSON: ${existingPrJson}. Proceeding to create new PR. Error: ${parseError}`);
275
+ const { stdout: prUrl } = await execAsync(createPRCommand);
276
+ prUrlToReturn = prUrl.trim();
277
+ }
278
+ } else {
279
+ Logger.debug(`[${onboardingId}] No existing PR found. Proceeding to create PR.`);
280
+ const { stdout: prUrl } = await execAsync(createPRCommand);
281
+ prUrlToReturn = prUrl.trim();
282
+ }
283
+
284
+ Logger.debug(`[${onboardingId}] Pull request operation completed. PR URL: ${prUrlToReturn}`);
285
+ return { url: prUrlToReturn, branchName };
286
+ } catch (error) {
287
+ const errorMessage = error instanceof Error ? error.message : String(error);
288
+ Logger.error(`[${onboardingId}] Error during createPullRequest: ${errorMessage}`);
289
+ throw Object.assign(new Error(`Failed to create pull request: ${errorMessage}`), { step: 'createPullRequest' });
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Creates necessary directories for the onboarding process
295
+ * @param onboardingId The ID of the onboarding process
296
+ * @returns Object containing tempDir and repoDir paths
297
+ */
298
+ public createDirectories(onboardingId: string): { tempDir: string; repoDir: string } {
299
+ const tempDir = path.join(SETTINGS_DIR, 'onboard', 'temp', onboardingId);
300
+ const repoDir = path.join(tempDir, 'imcp-feed');
301
+ return { tempDir, repoDir };
302
+ }
303
+ }
304
+
305
+ export const onboardProcessor = new OnboardProcessor();