@nospt/plugin-dev-ai-hub-backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.eslintrc.js +1 -0
  2. package/dev/index.ts +8 -0
  3. package/dist/database/AiAssetStore.cjs.js +224 -0
  4. package/dist/database/AiAssetStore.cjs.js.map +1 -0
  5. package/dist/index.cjs.js +11 -0
  6. package/dist/index.cjs.js.map +1 -0
  7. package/dist/index.d.ts +5 -0
  8. package/dist/plugin.cjs.js +70 -0
  9. package/dist/plugin.cjs.js.map +1 -0
  10. package/dist/router.cjs.js +247 -0
  11. package/dist/router.cjs.js.map +1 -0
  12. package/dist/service/AiAssetSyncService.cjs.js +210 -0
  13. package/dist/service/AiAssetSyncService.cjs.js.map +1 -0
  14. package/dist/service/AssetParser.cjs.js +91 -0
  15. package/dist/service/AssetParser.cjs.js.map +1 -0
  16. package/dist/service/McpServerService.cjs.js +366 -0
  17. package/dist/service/McpServerService.cjs.js.map +1 -0
  18. package/migrations/001_init.js +47 -0
  19. package/migrations/002_install_count.js +17 -0
  20. package/migrations/003_install_paths.js +19 -0
  21. package/migrations/004_label.js +17 -0
  22. package/migrations/005_remove_unused_fields.js +22 -0
  23. package/migrations/006_resources_content.js +16 -0
  24. package/package.json +72 -0
  25. package/package.json-prepack +64 -0
  26. package/src/database/AiAssetStore.test.ts +294 -0
  27. package/src/database/AiAssetStore.ts +256 -0
  28. package/src/index.ts +2 -0
  29. package/src/plugin.ts +84 -0
  30. package/src/router.ts +338 -0
  31. package/src/service/AiAssetSyncService.ts +267 -0
  32. package/src/service/AssetParser.test.ts +261 -0
  33. package/src/service/AssetParser.ts +117 -0
  34. package/src/service/McpServerService.ts +464 -0
  35. package/src/types.ts +31 -0
  36. package/tsconfig.json +8 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,84 @@
1
+ import {
2
+ coreServices,
3
+ createBackendPlugin,
4
+ } from '@backstage/backend-plugin-api';
5
+ import {
6
+ devAiHubProviderExtensionPoint,
7
+ type AiAssetProvider,
8
+ } from '@nospt/plugin-dev-ai-hub-node';
9
+ import { AiAssetStore } from './database/AiAssetStore';
10
+ import { AiAssetSyncService } from './service/AiAssetSyncService';
11
+ import { createRouter } from './router';
12
+ import type { ProviderConfig } from './types';
13
+
14
+ export const devAiHubPlugin = createBackendPlugin({
15
+ pluginId: 'dev-ai-hub',
16
+ register(env) {
17
+ const externalProviders: AiAssetProvider[] = [];
18
+
19
+ env.registerExtensionPoint(devAiHubProviderExtensionPoint, {
20
+ addProvider(provider) {
21
+ externalProviders.push(provider);
22
+ },
23
+ });
24
+
25
+ env.registerInit({
26
+ deps: {
27
+ config: coreServices.rootConfig,
28
+ logger: coreServices.logger,
29
+ database: coreServices.database,
30
+ scheduler: coreServices.scheduler,
31
+ httpRouter: coreServices.httpRouter,
32
+ urlReader: coreServices.urlReader,
33
+ },
34
+ async init({ config, logger, database, scheduler, httpRouter, urlReader }) {
35
+ const store = await AiAssetStore.create({ database });
36
+
37
+ const providers: ProviderConfig[] = (
38
+ config.getOptionalConfigArray('devAiHub.providers') ?? []
39
+ ).map(p => ({
40
+ id: p.getString('id'),
41
+ type: p.getString('type') as ProviderConfig['type'],
42
+ target: p.getString('target'),
43
+ branch: p.getOptionalString('branch') ?? 'main',
44
+ schedule: {
45
+ frequency: {
46
+ minutes: p.getOptionalNumber('schedule.frequency.minutes'),
47
+ hours: p.getOptionalNumber('schedule.frequency.hours'),
48
+ },
49
+ timeout: {
50
+ minutes: p.getOptionalNumber('schedule.timeout.minutes'),
51
+ hours: p.getOptionalNumber('schedule.timeout.hours'),
52
+ },
53
+ },
54
+ filters: p.has('filters')
55
+ ? {
56
+ tools: p.getOptionalStringArray('filters.tools'),
57
+ types: p.getOptionalStringArray('filters.types'),
58
+ }
59
+ : undefined,
60
+ }));
61
+
62
+ const syncService = new AiAssetSyncService({
63
+ logger,
64
+ store,
65
+ scheduler,
66
+ urlReader,
67
+ providers,
68
+ externalProviders,
69
+ });
70
+
71
+ await syncService.start();
72
+
73
+ const router = createRouter({ logger, store, syncService, providers });
74
+
75
+ httpRouter.use(router);
76
+
77
+ httpRouter.addAuthPolicy({ path: '/assets', allow: 'unauthenticated' });
78
+ httpRouter.addAuthPolicy({ path: '/providers', allow: 'unauthenticated' });
79
+ httpRouter.addAuthPolicy({ path: '/stats', allow: 'unauthenticated' });
80
+ httpRouter.addAuthPolicy({ path: '/mcp', allow: 'unauthenticated' });
81
+ },
82
+ });
83
+ },
84
+ });
package/src/router.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { randomUUID } from 'crypto';
2
+ import express from 'express';
3
+ import archiver from 'archiver';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import type { LoggerService } from '@backstage/backend-plugin-api';
6
+ import type { AiAssetStore } from './database/AiAssetStore';
7
+ import type { AiAssetSyncService } from './service/AiAssetSyncService';
8
+ import { createMcpServer } from './service/McpServerService';
9
+ import type { ProviderConfig, AssetListFilter } from './types';
10
+ import type { AssetType } from '@nospt/plugin-dev-ai-hub-common';
11
+
12
+ interface RouterOptions {
13
+ logger: LoggerService;
14
+ store: AiAssetStore;
15
+ syncService: AiAssetSyncService;
16
+ providers: ProviderConfig[];
17
+ }
18
+
19
+ export function createRouter(options: RouterOptions): express.Router {
20
+ const { store, syncService, providers } = options;
21
+ const router = express.Router();
22
+
23
+ /** Active MCP sessions: sessionId → transport */
24
+ const mcpSessions = new Map<string, StreamableHTTPServerTransport>();
25
+
26
+ router.use(express.json());
27
+
28
+ // ── Assets ────────────────────────────────────────────────────────────────
29
+
30
+ router.get('/assets', async (req, res) => {
31
+ try {
32
+ const filter: AssetListFilter = {
33
+ type: req.query.type as AssetType | undefined,
34
+ tool: req.query.tool as string | undefined,
35
+ providerId: req.query.provider as string | undefined,
36
+ search: req.query.search as string | undefined,
37
+ tags: req.query.tags
38
+ ? (req.query.tags as string).split(',').filter(Boolean)
39
+ : undefined,
40
+ page: req.query.page ? Number(req.query.page) : 1,
41
+ pageSize: req.query.pageSize ? Number(req.query.pageSize) : 20,
42
+ };
43
+
44
+ const { items, totalCount } = await store.listAssets(filter);
45
+
46
+ res.json({
47
+ items,
48
+ totalCount,
49
+ page: filter.page,
50
+ pageSize: filter.pageSize,
51
+ });
52
+ } catch (err) {
53
+ options.logger.error('GET /assets failed', err as Error);
54
+ res.status(500).json({ error: 'Internal server error' });
55
+ }
56
+ });
57
+
58
+ router.get('/assets/:id', async (req, res) => {
59
+ try {
60
+ const asset = await store.getAsset(req.params.id);
61
+ if (!asset) return res.status(404).json({ error: 'Asset not found' });
62
+ return res.json(asset);
63
+ } catch (err) {
64
+ options.logger.error('GET /assets/:id failed', err as Error);
65
+ return res.status(500).json({ error: 'Internal server error' });
66
+ }
67
+ });
68
+
69
+ /** Returns the pure markdown content of the asset */
70
+ router.get('/assets/:id/raw', async (req, res) => {
71
+ try {
72
+ const asset = await store.getAsset(req.params.id);
73
+ if (!asset) return res.status(404).json({ error: 'Asset not found' });
74
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
75
+ return res.send(asset.content);
76
+ } catch (err) {
77
+ options.logger.error('GET /assets/:id/raw failed', err as Error);
78
+ return res.status(500).json({ error: 'Internal server error' });
79
+ }
80
+ });
81
+
82
+ /**
83
+ * For skills: returns a zip with the markdown + metadata about bundled resources.
84
+ * For other types: returns the markdown as a text file.
85
+ */
86
+ router.get('/assets/:id/download', async (req, res) => {
87
+ try {
88
+ const asset = await store.getAsset(req.params.id);
89
+ if (!asset) return res.status(404).json({ error: 'Asset not found' });
90
+
91
+ const filename = `${asset.name.replace(/\s+/g, '-').toLowerCase()}`;
92
+
93
+ if (asset.type === 'skill') {
94
+ res.setHeader('Content-Type', 'application/zip');
95
+ res.setHeader(
96
+ 'Content-Disposition',
97
+ `attachment; filename="${filename}.zip"`,
98
+ );
99
+ const archive = archiver('zip');
100
+ archive.pipe(res);
101
+ archive.append(asset.content, { name: 'SKILL.md' });
102
+ if (asset.resourcesContent) {
103
+ for (const [resourcePath, resourceContent] of Object.entries(asset.resourcesContent)) {
104
+ archive.append(resourceContent, { name: resourcePath });
105
+ }
106
+ }
107
+ await archive.finalize();
108
+ } else {
109
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
110
+ res.setHeader(
111
+ 'Content-Disposition',
112
+ `attachment; filename="${filename}.md"`,
113
+ );
114
+ return res.send(asset.content);
115
+ }
116
+
117
+ return undefined;
118
+ } catch (err) {
119
+ options.logger.error('GET /assets/:id/download failed', err as Error);
120
+ return res.status(500).json({ error: 'Internal server error' });
121
+ }
122
+ });
123
+
124
+ router.post('/assets/:id/track-install', async (req, res) => {
125
+ try {
126
+ const asset = await store.getAsset(req.params.id);
127
+ if (!asset) return res.status(404).json({ error: 'Asset not found' });
128
+ await store.incrementInstallCount(req.params.id);
129
+ return res.status(204).send();
130
+ } catch (err) {
131
+ options.logger.error('POST /assets/:id/track-install failed', err as Error);
132
+ return res.status(500).json({ error: 'Internal server error' });
133
+ }
134
+ });
135
+
136
+ // ── Providers ─────────────────────────────────────────────────────────────
137
+
138
+ router.get('/providers', async (_req, res) => {
139
+ try {
140
+ const statuses = await store.getAllSyncStatuses();
141
+ const statusMap = new Map(statuses.map(s => [s.providerId, s]));
142
+
143
+ const result = providers.map(p => {
144
+ const status = statusMap.get(p.id);
145
+ return {
146
+ id: p.id,
147
+ type: p.type,
148
+ target: p.target,
149
+ branch: p.branch,
150
+ lastSync: status?.lastSync,
151
+ lastCommit: status?.lastCommit,
152
+ status: status?.status ?? 'idle',
153
+ error: status?.error,
154
+ assetCount: status?.assetCount ?? 0,
155
+ };
156
+ });
157
+
158
+ res.json(result);
159
+ } catch (err) {
160
+ options.logger.error('GET /providers failed', err as Error);
161
+ res.status(500).json({ error: 'Internal server error' });
162
+ }
163
+ });
164
+
165
+ router.get('/providers/:id/status', async (req, res) => {
166
+ try {
167
+ const provider = providers.find(p => p.id === req.params.id);
168
+ if (!provider) return res.status(404).json({ error: 'Provider not found' });
169
+
170
+ const status = await store.getSyncStatus(req.params.id);
171
+ return res.json(
172
+ status ?? {
173
+ providerId: req.params.id,
174
+ status: 'idle',
175
+ assetCount: 0,
176
+ },
177
+ );
178
+ } catch (err) {
179
+ options.logger.error('GET /providers/:id/status failed', err as Error);
180
+ return res.status(500).json({ error: 'Internal server error' });
181
+ }
182
+ });
183
+
184
+ router.post('/providers/:id/sync', async (req, res) => {
185
+ try {
186
+ const provider = providers.find(p => p.id === req.params.id);
187
+ if (!provider) return res.status(404).json({ error: 'Provider not found' });
188
+
189
+ // Fire and forget — respond immediately, sync runs in background
190
+ setImmediate(() => syncService.syncProvider(provider));
191
+
192
+ return res.json({ message: 'Sync triggered', providerId: provider.id });
193
+ } catch (err) {
194
+ options.logger.error('POST /providers/:id/sync failed', err as Error);
195
+ return res.status(500).json({ error: 'Internal server error' });
196
+ }
197
+ });
198
+
199
+ // ── Stats ─────────────────────────────────────────────────────────────────
200
+
201
+ router.get('/stats', async (_req, res) => {
202
+ try {
203
+ const stats = await store.getStats();
204
+ res.json(stats);
205
+ } catch (err) {
206
+ options.logger.error('GET /stats failed', err as Error);
207
+ res.status(500).json({ error: 'Internal server error' });
208
+ }
209
+ });
210
+
211
+ // ── MCP — StreamableHTTP transport ────────────────────────────────────────
212
+ //
213
+ // Clients connect to /mcp?tool=<ai-tool-name> using the MCP HTTP protocol.
214
+ // The `tool` query parameter filters assets to those compatible with that
215
+ // AI tool. Each session is stateful: the tool filter is captured at
216
+ // initialization time and reused for the session lifetime.
217
+ //
218
+ // Client configuration example (.mcp.json / mcp_settings.json):
219
+ //
220
+ // {
221
+ // "mcpServers": {
222
+ // "dev-ai-hub": {
223
+ // "type": "http",
224
+ // "url": "http://<backstage-host>:7007/api/dev-ai-hub/mcp?tool=claude-code"
225
+ // }
226
+ // }
227
+ // }
228
+ //
229
+ // Set AI_HUB_TOOL in your environment and substitute it into the URL, or
230
+ // hard-code the tool name per workspace.
231
+
232
+ /**
233
+ * POST /mcp — Initialize a new MCP session or handle an existing one.
234
+ * On first request (no mcp-session-id header), a new session is created
235
+ * and its ID is returned in the response header.
236
+ */
237
+ router.post('/mcp', async (req, res) => {
238
+ try {
239
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
240
+
241
+ if (sessionId) {
242
+ const transport = mcpSessions.get(sessionId);
243
+ if (!transport) {
244
+ res.status(404).json({ error: 'MCP session not found' });
245
+ return;
246
+ }
247
+ await transport.handleRequest(req, res, req.body);
248
+ return;
249
+ }
250
+
251
+ // New session — extract filters from query params or headers
252
+ const toolFilter =
253
+ (req.query.tool as string | undefined) ??
254
+ (req.headers['x-ai-hub-tool'] as string | undefined) ??
255
+ '';
256
+ const providerFilter =
257
+ (req.query.provider as string | undefined) ??
258
+ (req.headers['x-ai-hub-provider'] as string | undefined) ??
259
+ '';
260
+ // proactive defaults to false; pass ?proactive=true to enable check_for_assets + suggest_assets
261
+ const proactiveEnabled =
262
+ ((req.query.proactive as string | undefined) ??
263
+ (req.headers['x-ai-hub-proactive'] as string | undefined) ??
264
+ 'false') === 'true';
265
+
266
+ const newSessionId = randomUUID();
267
+ const transport = new StreamableHTTPServerTransport({
268
+ sessionIdGenerator: () => newSessionId,
269
+ onsessioninitialized: id => {
270
+ mcpSessions.set(id, transport);
271
+ options.logger.debug(
272
+ `dev-ai-hub MCP: session ${id} opened (tool="${toolFilter || 'all'}", provider="${providerFilter || 'all'}", proactive=${proactiveEnabled})`,
273
+ );
274
+ },
275
+ });
276
+
277
+ transport.onclose = () => {
278
+ mcpSessions.delete(newSessionId);
279
+ options.logger.debug(`dev-ai-hub MCP: session ${newSessionId} closed`);
280
+ };
281
+
282
+ const server = createMcpServer(store, toolFilter, providerFilter, providers, proactiveEnabled);
283
+ await server.connect(transport);
284
+
285
+ await transport.handleRequest(req, res, req.body);
286
+ } catch (err) {
287
+ options.logger.error('POST /mcp failed', err as Error);
288
+ if (!res.headersSent) res.status(500).json({ error: 'Internal server error' });
289
+ }
290
+ });
291
+
292
+ /**
293
+ * GET /mcp — SSE stream for server-to-client notifications on an existing session.
294
+ */
295
+ router.get('/mcp', async (req, res) => {
296
+ try {
297
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
298
+ if (!sessionId) {
299
+ res.status(400).json({ error: 'mcp-session-id header required' });
300
+ return;
301
+ }
302
+ const transport = mcpSessions.get(sessionId);
303
+ if (!transport) {
304
+ res.status(404).json({ error: 'MCP session not found' });
305
+ return;
306
+ }
307
+ await transport.handleRequest(req, res);
308
+ } catch (err) {
309
+ options.logger.error('GET /mcp failed', err as Error);
310
+ if (!res.headersSent) res.status(500).json({ error: 'Internal server error' });
311
+ }
312
+ });
313
+
314
+ /**
315
+ * DELETE /mcp — Terminate an existing MCP session.
316
+ */
317
+ router.delete('/mcp', async (req, res) => {
318
+ try {
319
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
320
+ if (!sessionId) {
321
+ res.status(400).json({ error: 'mcp-session-id header required' });
322
+ return;
323
+ }
324
+ const transport = mcpSessions.get(sessionId);
325
+ if (!transport) {
326
+ res.status(404).json({ error: 'MCP session not found' });
327
+ return;
328
+ }
329
+ await transport.handleRequest(req, res);
330
+ mcpSessions.delete(sessionId);
331
+ } catch (err) {
332
+ options.logger.error('DELETE /mcp failed', err as Error);
333
+ if (!res.headersSent) res.status(500).json({ error: 'Internal server error' });
334
+ }
335
+ });
336
+
337
+ return router;
338
+ }
@@ -0,0 +1,267 @@
1
+ import type {
2
+ LoggerService,
3
+ SchedulerService,
4
+ UrlReaderService,
5
+ } from '@backstage/backend-plugin-api';
6
+ import type { AiAssetStore } from '../database/AiAssetStore';
7
+ import type { ProviderConfig } from '../types';
8
+ import type { AiAssetProvider } from '@nospt/plugin-dev-ai-hub-node';
9
+ import { AssetParser } from './AssetParser';
10
+
11
+ interface Options {
12
+ logger: LoggerService;
13
+ store: AiAssetStore;
14
+ scheduler: SchedulerService;
15
+ urlReader: UrlReaderService;
16
+ providers: ProviderConfig[];
17
+ externalProviders: AiAssetProvider[];
18
+ }
19
+
20
+ export class AiAssetSyncService {
21
+ constructor(private readonly options: Options) {}
22
+
23
+ async start(): Promise<void> {
24
+ const { scheduler, logger, providers, externalProviders } = this.options;
25
+
26
+ if (providers.length === 0 && externalProviders.length === 0) {
27
+ logger.warn(
28
+ 'dev-ai-hub: no providers configured under devAiHub.providers',
29
+ );
30
+ return;
31
+ }
32
+
33
+ for (const provider of providers) {
34
+ const { frequency, timeout } = provider.schedule;
35
+ const frequencyDuration = frequency.minutes
36
+ ? { minutes: frequency.minutes }
37
+ : { hours: frequency.hours ?? 1 };
38
+ const timeoutDuration = timeout.minutes
39
+ ? { minutes: timeout.minutes }
40
+ : { hours: timeout.hours ?? 1 };
41
+
42
+ await scheduler.scheduleTask({
43
+ id: `dev-ai-hub-sync-${provider.id}`,
44
+ frequency: frequencyDuration,
45
+ timeout: timeoutDuration,
46
+ initialDelay: { seconds: 15 },
47
+ fn: async () => {
48
+ await this.syncProvider(provider);
49
+ },
50
+ });
51
+
52
+ logger.info(
53
+ `dev-ai-hub: scheduled sync for provider "${provider.id}" every ${JSON.stringify(frequencyDuration)}`,
54
+ );
55
+ }
56
+
57
+ for (const extProvider of externalProviders) {
58
+ await scheduler.scheduleTask({
59
+ id: `dev-ai-hub-sync-external-${extProvider.id}`,
60
+ frequency: { minutes: 60 },
61
+ timeout: { minutes: 5 },
62
+ initialDelay: { seconds: 15 },
63
+ fn: async () => {
64
+ await this.syncExternalProvider(extProvider);
65
+ },
66
+ });
67
+
68
+ logger.info(
69
+ `dev-ai-hub: scheduled sync for external provider "${extProvider.id}"`,
70
+ );
71
+ }
72
+ }
73
+
74
+ async syncProvider(provider: ProviderConfig): Promise<void> {
75
+ const { logger, urlReader, store } = this.options;
76
+
77
+ logger.info(`dev-ai-hub: starting sync for provider "${provider.id}"`);
78
+
79
+ await store.upsertSyncStatus({
80
+ providerId: provider.id,
81
+ status: 'syncing',
82
+ assetCount: 0,
83
+ });
84
+
85
+ try {
86
+ const treeUrl = buildTreeUrl(provider);
87
+ const tree = await urlReader.readTree(treeUrl);
88
+ const files = await tree.files();
89
+
90
+ const fileMap = new Map<
91
+ string,
92
+ { content(): Promise<Buffer>; path: string }
93
+ >();
94
+ for (const file of files) {
95
+ fileMap.set(normalizePath(file.path), file);
96
+ }
97
+
98
+ const syncedIds: string[] = [];
99
+
100
+ for (const [filePath, file] of fileMap) {
101
+ if (!filePath.endsWith('.yaml')) continue;
102
+ if (!AssetParser.isAssetFile(filePath)) continue;
103
+
104
+ const yamlContent = (await file.content()).toString('utf-8');
105
+ const parsed = AssetParser.parseYaml(yamlContent, filePath);
106
+
107
+ if (!parsed) {
108
+ logger.warn(
109
+ `dev-ai-hub: skipping invalid YAML at ${filePath} (provider ${provider.id})`,
110
+ );
111
+ continue;
112
+ }
113
+
114
+ if (
115
+ provider.filters?.types &&
116
+ !provider.filters.types.includes(parsed.meta.type)
117
+ ) {
118
+ continue;
119
+ }
120
+ if (provider.filters?.tools) {
121
+ const hasMatchingTool = parsed.meta.tools.some(t =>
122
+ provider.filters!.tools!.includes(t),
123
+ );
124
+ if (!hasMatchingTool) continue;
125
+ }
126
+
127
+ const mdFile = fileMap.get(normalizePath(parsed.mdPath));
128
+ if (!mdFile) {
129
+ logger.warn(
130
+ `dev-ai-hub: .md not found for ${filePath} (expected ${parsed.mdPath}), skipping`,
131
+ );
132
+ continue;
133
+ }
134
+
135
+ const mdContent = (await mdFile.content()).toString('utf-8');
136
+
137
+ // For skills: read bundled resource files and store their content
138
+ let resourcesContent: Record<string, string> | undefined;
139
+ const resourcePaths = parsed.meta.resources;
140
+ if (resourcePaths && resourcePaths.length > 0) {
141
+ resourcesContent = {};
142
+ const yamlDir = filePath.split('/').slice(0, -1).join('/');
143
+ for (const resourcePath of resourcePaths) {
144
+ const fullPath = normalizePath(
145
+ yamlDir ? `${yamlDir}/${resourcePath}` : resourcePath,
146
+ );
147
+ const resourceFile = fileMap.get(fullPath);
148
+ if (resourceFile) {
149
+ resourcesContent[resourcePath] = (await resourceFile.content()).toString('utf-8');
150
+ } else {
151
+ logger.warn(
152
+ `dev-ai-hub: resource file not found: ${fullPath} (asset ${filePath})`,
153
+ );
154
+ }
155
+ }
156
+ if (Object.keys(resourcesContent).length === 0) resourcesContent = undefined;
157
+ }
158
+
159
+ const asset = AssetParser.buildAsset(
160
+ parsed,
161
+ mdContent,
162
+ provider.id,
163
+ provider.target,
164
+ provider.branch,
165
+ filePath,
166
+ resourcesContent,
167
+ );
168
+
169
+ await store.upsertAsset(asset);
170
+ syncedIds.push(asset.id);
171
+ }
172
+
173
+ await store.deleteAssetsNotIn(provider.id, syncedIds);
174
+
175
+ await store.upsertSyncStatus({
176
+ providerId: provider.id,
177
+ lastSync: new Date().toISOString(),
178
+ status: 'idle',
179
+ assetCount: syncedIds.length,
180
+ });
181
+
182
+ logger.info(
183
+ `dev-ai-hub: sync complete for provider "${provider.id}" — ${syncedIds.length} assets`,
184
+ );
185
+ } catch (err) {
186
+ const message = err instanceof Error ? err.message : String(err);
187
+ this.options.logger.error(
188
+ `dev-ai-hub: sync failed for provider "${provider.id}": ${message}`,
189
+ );
190
+ await store.upsertSyncStatus({
191
+ providerId: provider.id,
192
+ status: 'error',
193
+ error: message,
194
+ assetCount: 0,
195
+ });
196
+ }
197
+ }
198
+
199
+ private async syncExternalProvider(provider: AiAssetProvider): Promise<void> {
200
+ const { logger, store } = this.options;
201
+
202
+ logger.info(`dev-ai-hub: syncing external provider "${provider.id}"`);
203
+
204
+ await store.upsertSyncStatus({
205
+ providerId: provider.id,
206
+ status: 'syncing',
207
+ assetCount: 0,
208
+ });
209
+
210
+ try {
211
+ const assets = await provider.getAssets();
212
+ const syncedIds: string[] = [];
213
+
214
+ for (const assetData of assets) {
215
+ const id = AssetParser.buildId(provider.id, assetData.yamlPath);
216
+ await store.upsertAsset({ ...assetData, id });
217
+ syncedIds.push(id);
218
+ }
219
+
220
+ await store.deleteAssetsNotIn(provider.id, syncedIds);
221
+
222
+ await store.upsertSyncStatus({
223
+ providerId: provider.id,
224
+ lastSync: new Date().toISOString(),
225
+ status: 'idle',
226
+ assetCount: syncedIds.length,
227
+ });
228
+
229
+ logger.info(
230
+ `dev-ai-hub: external sync complete for "${provider.id}" — ${syncedIds.length} assets`,
231
+ );
232
+ } catch (err) {
233
+ const message = err instanceof Error ? err.message : String(err);
234
+ logger.error(
235
+ `dev-ai-hub: external sync failed for "${provider.id}": ${message}`,
236
+ );
237
+ await store.upsertSyncStatus({
238
+ providerId: provider.id,
239
+ status: 'error',
240
+ error: message,
241
+ assetCount: 0,
242
+ });
243
+ }
244
+ }
245
+ }
246
+
247
+ function normalizePath(p: string): string {
248
+ return p.replace(/\\/g, '/').replace(/^\//, '');
249
+ }
250
+
251
+ function buildTreeUrl(provider: ProviderConfig): string {
252
+ const base = provider.target.replace(/\.git$/, '');
253
+ const { branch, type } = provider;
254
+
255
+ switch (type) {
256
+ case 'gitlab':
257
+ return `${base}/-/tree/${branch}/`;
258
+ case 'bitbucket':
259
+ return `${base}/src/${branch}/`;
260
+ case 'azure-devops':
261
+ return `${base}?version=GB${branch}`;
262
+ case 'github':
263
+ case 'git':
264
+ default:
265
+ return `${base}/tree/${branch}/`;
266
+ }
267
+ }