@nospt/plugin-dev-ai-hub-backend 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.d.ts +22 -0
- package/dist/database/TelemetryStore.cjs.js +84 -0
- package/dist/database/TelemetryStore.cjs.js.map +1 -0
- package/dist/plugin.cjs.js +24 -45
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/router.cjs.js +147 -202
- package/dist/router.cjs.js.map +1 -1
- package/dist/service/bodyResolver.cjs.js +56 -0
- package/dist/service/bodyResolver.cjs.js.map +1 -0
- package/dist/service/telemetryHash.cjs.js +14 -0
- package/dist/service/telemetryHash.cjs.js.map +1 -0
- package/dist/service/toResourceSummary.cjs.js +31 -0
- package/dist/service/toResourceSummary.cjs.js.map +1 -0
- package/migrations/001_init.js +13 -42
- package/migrations/002_install_count.js +7 -12
- package/migrations/003_install_paths.js +7 -14
- package/migrations/004_label.js +7 -12
- package/migrations/005_remove_unused_fields.js +6 -16
- package/migrations/006_resources_content.js +10 -13
- package/migrations/007_telemetry.js +28 -0
- package/migrations/008_drop_legacy_tables.js +24 -0
- package/migrations/009_telemetry_settings.js +25 -0
- package/package.json +18 -8
- package/.eslintrc.js +0 -1
- package/dev/index.ts +0 -8
- package/dist/database/AiAssetStore.cjs.js +0 -225
- package/dist/database/AiAssetStore.cjs.js.map +0 -1
- package/dist/service/AiAssetSyncService.cjs.js +0 -235
- package/dist/service/AiAssetSyncService.cjs.js.map +0 -1
- package/dist/service/AssetParser.cjs.js +0 -74
- package/dist/service/AssetParser.cjs.js.map +0 -1
- package/dist/service/McpServerService.cjs.js +0 -366
- package/dist/service/McpServerService.cjs.js.map +0 -1
- package/package.json-prepack +0 -71
- package/src/database/AiAssetStore.test.ts +0 -294
- package/src/database/AiAssetStore.ts +0 -256
- package/src/index.ts +0 -2
- package/src/plugin.ts +0 -84
- package/src/router.smoke.test.ts +0 -262
- package/src/router.ts +0 -338
- package/src/service/AiAssetSyncService.ts +0 -298
- package/src/service/AssetParser.test.ts +0 -329
- package/src/service/AssetParser.ts +0 -103
- package/src/service/McpServerService.ts +0 -464
- package/src/types.ts +0 -31
- package/tsconfig.json +0 -8
|
@@ -1,298 +0,0 @@
|
|
|
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
|
-
|
|
103
|
-
const yamlContent = (await file.content()).toString('utf-8');
|
|
104
|
-
const parsed = AssetParser.parseYaml(yamlContent, filePath);
|
|
105
|
-
|
|
106
|
-
if (!parsed) {
|
|
107
|
-
logger.warn(
|
|
108
|
-
`dev-ai-hub: skipping invalid YAML at ${filePath} (provider ${provider.id})`,
|
|
109
|
-
);
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
provider.filters?.types &&
|
|
115
|
-
!provider.filters.types.includes(parsed.meta.type)
|
|
116
|
-
) {
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
if (provider.filters?.tools) {
|
|
120
|
-
const hasMatchingTool = parsed.meta.tools.some(t =>
|
|
121
|
-
provider.filters!.tools!.includes(t),
|
|
122
|
-
);
|
|
123
|
-
if (!hasMatchingTool) continue;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Resolve the .md file:
|
|
127
|
-
// - explicit: yaml has a content: field → use that exact path
|
|
128
|
-
// - junction: find the single .md co-located in the same directory
|
|
129
|
-
const yamlDir = filePath.includes('/')
|
|
130
|
-
? filePath.split('/').slice(0, -1).join('/')
|
|
131
|
-
: '';
|
|
132
|
-
|
|
133
|
-
let resolvedMdPath: string;
|
|
134
|
-
let mdFile: { content(): Promise<Buffer>; path: string } | undefined;
|
|
135
|
-
|
|
136
|
-
if (parsed.mdPath) {
|
|
137
|
-
resolvedMdPath = normalizePath(parsed.mdPath);
|
|
138
|
-
mdFile = fileMap.get(resolvedMdPath);
|
|
139
|
-
if (!mdFile) {
|
|
140
|
-
logger.warn(
|
|
141
|
-
`dev-ai-hub: content file "${parsed.mdPath}" not found for ${filePath}, skipping`,
|
|
142
|
-
);
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
} else {
|
|
146
|
-
const mdCandidates = [...fileMap.keys()].filter(p => {
|
|
147
|
-
const dir = p.includes('/') ? p.split('/').slice(0, -1).join('/') : '';
|
|
148
|
-
return dir === yamlDir && p.endsWith('.md');
|
|
149
|
-
});
|
|
150
|
-
if (mdCandidates.length === 1) {
|
|
151
|
-
resolvedMdPath = mdCandidates[0];
|
|
152
|
-
mdFile = fileMap.get(resolvedMdPath)!;
|
|
153
|
-
} else if (mdCandidates.length > 1) {
|
|
154
|
-
logger.warn(
|
|
155
|
-
`dev-ai-hub: multiple .md files in "${yamlDir || '.'}" for ${filePath} — add a 'content' field to the YAML to specify which one, skipping`,
|
|
156
|
-
);
|
|
157
|
-
continue;
|
|
158
|
-
} else {
|
|
159
|
-
logger.warn(
|
|
160
|
-
`dev-ai-hub: no .md file found for ${filePath}, skipping`,
|
|
161
|
-
);
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const mdContent = (await mdFile.content()).toString('utf-8');
|
|
167
|
-
|
|
168
|
-
// For skills: read bundled resource files and store their content
|
|
169
|
-
let resourcesContent: Record<string, string> | undefined;
|
|
170
|
-
const resourcePaths = parsed.meta.resources;
|
|
171
|
-
if (resourcePaths && resourcePaths.length > 0) {
|
|
172
|
-
resourcesContent = {};
|
|
173
|
-
for (const resourcePath of resourcePaths) {
|
|
174
|
-
const fullPath = normalizePath(
|
|
175
|
-
yamlDir ? `${yamlDir}/${resourcePath}` : resourcePath,
|
|
176
|
-
);
|
|
177
|
-
const resourceFile = fileMap.get(fullPath);
|
|
178
|
-
if (resourceFile) {
|
|
179
|
-
resourcesContent[resourcePath] = (await resourceFile.content()).toString('utf-8');
|
|
180
|
-
} else {
|
|
181
|
-
logger.warn(
|
|
182
|
-
`dev-ai-hub: resource file not found: ${fullPath} (asset ${filePath})`,
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
if (Object.keys(resourcesContent).length === 0) resourcesContent = undefined;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const asset = AssetParser.buildAsset(
|
|
190
|
-
parsed,
|
|
191
|
-
mdContent,
|
|
192
|
-
provider.id,
|
|
193
|
-
provider.target,
|
|
194
|
-
provider.branch,
|
|
195
|
-
filePath,
|
|
196
|
-
resolvedMdPath,
|
|
197
|
-
resourcesContent,
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
await store.upsertAsset(asset);
|
|
201
|
-
syncedIds.push(asset.id);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
await store.deleteAssetsNotIn(provider.id, syncedIds);
|
|
205
|
-
|
|
206
|
-
await store.upsertSyncStatus({
|
|
207
|
-
providerId: provider.id,
|
|
208
|
-
lastSync: new Date().toISOString(),
|
|
209
|
-
status: 'idle',
|
|
210
|
-
assetCount: syncedIds.length,
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
logger.info(
|
|
214
|
-
`dev-ai-hub: sync complete for provider "${provider.id}" — ${syncedIds.length} assets`,
|
|
215
|
-
);
|
|
216
|
-
} catch (err) {
|
|
217
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
218
|
-
this.options.logger.error(
|
|
219
|
-
`dev-ai-hub: sync failed for provider "${provider.id}": ${message}`,
|
|
220
|
-
);
|
|
221
|
-
await store.upsertSyncStatus({
|
|
222
|
-
providerId: provider.id,
|
|
223
|
-
status: 'error',
|
|
224
|
-
error: message,
|
|
225
|
-
assetCount: 0,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
private async syncExternalProvider(provider: AiAssetProvider): Promise<void> {
|
|
231
|
-
const { logger, store } = this.options;
|
|
232
|
-
|
|
233
|
-
logger.info(`dev-ai-hub: syncing external provider "${provider.id}"`);
|
|
234
|
-
|
|
235
|
-
await store.upsertSyncStatus({
|
|
236
|
-
providerId: provider.id,
|
|
237
|
-
status: 'syncing',
|
|
238
|
-
assetCount: 0,
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
try {
|
|
242
|
-
const assets = await provider.getAssets();
|
|
243
|
-
const syncedIds: string[] = [];
|
|
244
|
-
|
|
245
|
-
for (const assetData of assets) {
|
|
246
|
-
const id = AssetParser.buildId(provider.id, assetData.yamlPath);
|
|
247
|
-
await store.upsertAsset({ ...assetData, id });
|
|
248
|
-
syncedIds.push(id);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
await store.deleteAssetsNotIn(provider.id, syncedIds);
|
|
252
|
-
|
|
253
|
-
await store.upsertSyncStatus({
|
|
254
|
-
providerId: provider.id,
|
|
255
|
-
lastSync: new Date().toISOString(),
|
|
256
|
-
status: 'idle',
|
|
257
|
-
assetCount: syncedIds.length,
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
logger.info(
|
|
261
|
-
`dev-ai-hub: external sync complete for "${provider.id}" — ${syncedIds.length} assets`,
|
|
262
|
-
);
|
|
263
|
-
} catch (err) {
|
|
264
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
265
|
-
logger.error(
|
|
266
|
-
`dev-ai-hub: external sync failed for "${provider.id}": ${message}`,
|
|
267
|
-
);
|
|
268
|
-
await store.upsertSyncStatus({
|
|
269
|
-
providerId: provider.id,
|
|
270
|
-
status: 'error',
|
|
271
|
-
error: message,
|
|
272
|
-
assetCount: 0,
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function normalizePath(p: string): string {
|
|
279
|
-
return p.replace(/\\/g, '/').replace(/^\//, '');
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function buildTreeUrl(provider: ProviderConfig): string {
|
|
283
|
-
const base = provider.target.replace(/\.git$/, '');
|
|
284
|
-
const { branch, type } = provider;
|
|
285
|
-
|
|
286
|
-
switch (type) {
|
|
287
|
-
case 'gitlab':
|
|
288
|
-
return `${base}/-/tree/${branch}/`;
|
|
289
|
-
case 'bitbucket':
|
|
290
|
-
return `${base}/src/${branch}/`;
|
|
291
|
-
case 'azure-devops':
|
|
292
|
-
return `${base}?version=GB${branch}`;
|
|
293
|
-
case 'github':
|
|
294
|
-
case 'git':
|
|
295
|
-
default:
|
|
296
|
-
return `${base}/tree/${branch}/`;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
import { AssetParser } from './AssetParser';
|
|
2
|
-
|
|
3
|
-
const VALID_YAML = `
|
|
4
|
-
name: My Instruction
|
|
5
|
-
description: A test instruction
|
|
6
|
-
type: instruction
|
|
7
|
-
tools:
|
|
8
|
-
- claude-code
|
|
9
|
-
tags:
|
|
10
|
-
- testing
|
|
11
|
-
author: Test Author
|
|
12
|
-
version: 1.2.3
|
|
13
|
-
`;
|
|
14
|
-
|
|
15
|
-
describe('AssetParser.parseYaml', () => {
|
|
16
|
-
it('parses a valid YAML envelope and returns metadata + mdPath', () => {
|
|
17
|
-
const result = AssetParser.parseYaml(VALID_YAML, 'instructions/my-instruction.yaml');
|
|
18
|
-
expect(result).not.toBeNull();
|
|
19
|
-
expect(result!.meta.name).toBe('My Instruction');
|
|
20
|
-
expect(result!.meta.description).toBe('A test instruction');
|
|
21
|
-
expect(result!.meta.type).toBe('instruction');
|
|
22
|
-
expect(result!.meta.tools).toEqual(['claude-code']);
|
|
23
|
-
expect(result!.meta.tags).toEqual(['testing']);
|
|
24
|
-
expect(result!.meta.author).toBe('Test Author');
|
|
25
|
-
expect(result!.meta.version).toBe('1.2.3');
|
|
26
|
-
expect(result!.yamlRaw).toBe(VALID_YAML);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('leaves mdPath undefined when no content field is set', () => {
|
|
30
|
-
const result = AssetParser.parseYaml(VALID_YAML, 'instructions/my-instruction.yaml');
|
|
31
|
-
expect(result!.mdPath).toBeUndefined();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('uses the content field to resolve mdPath when present', () => {
|
|
35
|
-
const yaml = `${VALID_YAML}content: custom-content.md\n`;
|
|
36
|
-
const result = AssetParser.parseYaml(yaml, 'instructions/my-instruction.yaml');
|
|
37
|
-
expect(result!.mdPath).toBe('instructions/custom-content.md');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('resolves content path relative to the yaml directory', () => {
|
|
41
|
-
const yaml = `${VALID_YAML}content: sub/custom.md\n`;
|
|
42
|
-
const result = AssetParser.parseYaml(yaml, 'agents/my-agent.yaml');
|
|
43
|
-
expect(result!.mdPath).toBe('agents/sub/custom.md');
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('returns null for invalid YAML syntax', () => {
|
|
47
|
-
expect(AssetParser.parseYaml(': bad: yaml: :', 'test.yaml')).toBeNull();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('returns null when required field "name" is missing', () => {
|
|
51
|
-
const yaml = `
|
|
52
|
-
description: A description
|
|
53
|
-
type: instruction
|
|
54
|
-
tools:
|
|
55
|
-
- claude-code
|
|
56
|
-
`;
|
|
57
|
-
expect(AssetParser.parseYaml(yaml, 'test.yaml')).toBeNull();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('returns null when required field "type" is missing', () => {
|
|
61
|
-
const yaml = `
|
|
62
|
-
name: Test
|
|
63
|
-
description: A description
|
|
64
|
-
tools:
|
|
65
|
-
- claude-code
|
|
66
|
-
`;
|
|
67
|
-
expect(AssetParser.parseYaml(yaml, 'test.yaml')).toBeNull();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('returns null when type is not a recognised enum value', () => {
|
|
71
|
-
const yaml = `
|
|
72
|
-
name: Test
|
|
73
|
-
description: A description
|
|
74
|
-
type: invalid-type
|
|
75
|
-
tools:
|
|
76
|
-
- claude-code
|
|
77
|
-
`;
|
|
78
|
-
expect(AssetParser.parseYaml(yaml, 'test.yaml')).toBeNull();
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('returns null when tools array is empty', () => {
|
|
82
|
-
const yaml = `
|
|
83
|
-
name: Test
|
|
84
|
-
description: A description
|
|
85
|
-
type: instruction
|
|
86
|
-
tools: []
|
|
87
|
-
`;
|
|
88
|
-
expect(AssetParser.parseYaml(yaml, 'test.yaml')).toBeNull();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('applies schema defaults for optional fields', () => {
|
|
92
|
-
const yaml = `
|
|
93
|
-
name: Minimal
|
|
94
|
-
description: Minimal description
|
|
95
|
-
type: agent
|
|
96
|
-
tools:
|
|
97
|
-
- claude-code
|
|
98
|
-
`;
|
|
99
|
-
const result = AssetParser.parseYaml(yaml, 'agents/minimal.yaml');
|
|
100
|
-
expect(result!.meta.author).toBe('Unknown');
|
|
101
|
-
expect(result!.meta.version).toBe('1.0.0');
|
|
102
|
-
expect(result!.meta.tags).toEqual([]);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('passes through unrecognised fields (passthrough schema)', () => {
|
|
106
|
-
const yaml = `${VALID_YAML}unknownField: some-value\n`;
|
|
107
|
-
const result = AssetParser.parseYaml(yaml, 'test.yaml');
|
|
108
|
-
expect(result).not.toBeNull();
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
describe('AssetParser.buildId', () => {
|
|
113
|
-
it('returns a base64url encoded string', () => {
|
|
114
|
-
const id = AssetParser.buildId('provider-1', 'instructions/my-asset.yaml');
|
|
115
|
-
expect(id).toBe(Buffer.from('provider-1:instructions/my-asset.yaml').toString('base64url'));
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('normalises backslashes to forward slashes', () => {
|
|
119
|
-
const id1 = AssetParser.buildId('p', 'instructions\\my-asset.yaml');
|
|
120
|
-
const id2 = AssetParser.buildId('p', 'instructions/my-asset.yaml');
|
|
121
|
-
expect(id1).toBe(id2);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('strips a leading slash', () => {
|
|
125
|
-
const id1 = AssetParser.buildId('p', '/instructions/my-asset.yaml');
|
|
126
|
-
const id2 = AssetParser.buildId('p', 'instructions/my-asset.yaml');
|
|
127
|
-
expect(id1).toBe(id2);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('is deterministic for the same inputs', () => {
|
|
131
|
-
const id1 = AssetParser.buildId('provider-1', 'path/to/asset.yaml');
|
|
132
|
-
const id2 = AssetParser.buildId('provider-1', 'path/to/asset.yaml');
|
|
133
|
-
expect(id1).toBe(id2);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('produces different IDs for different providers', () => {
|
|
137
|
-
const id1 = AssetParser.buildId('provider-1', 'path/asset.yaml');
|
|
138
|
-
const id2 = AssetParser.buildId('provider-2', 'path/asset.yaml');
|
|
139
|
-
expect(id1).not.toBe(id2);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('produces different IDs for different paths', () => {
|
|
143
|
-
const id1 = AssetParser.buildId('p', 'instructions/a.yaml');
|
|
144
|
-
const id2 = AssetParser.buildId('p', 'agents/a.yaml');
|
|
145
|
-
expect(id1).not.toBe(id2);
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
describe('AssetParser.buildAsset', () => {
|
|
150
|
-
const parsed = AssetParser.parseYaml(VALID_YAML, 'instructions/my-instruction.yaml')!;
|
|
151
|
-
|
|
152
|
-
it('builds a complete AiAssetInput with all expected fields', () => {
|
|
153
|
-
const asset = AssetParser.buildAsset(
|
|
154
|
-
parsed,
|
|
155
|
-
'# Content',
|
|
156
|
-
'my-provider',
|
|
157
|
-
'https://github.com/org/repo',
|
|
158
|
-
'main',
|
|
159
|
-
'instructions/my-instruction.yaml',
|
|
160
|
-
'instructions/my-instruction.md',
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
expect(asset.id).toBe(AssetParser.buildId('my-provider', 'instructions/my-instruction.yaml'));
|
|
164
|
-
expect(asset.name).toBe('My Instruction');
|
|
165
|
-
expect(asset.type).toBe('instruction');
|
|
166
|
-
expect(asset.tools).toEqual(['claude-code']);
|
|
167
|
-
expect(asset.tags).toEqual(['testing']);
|
|
168
|
-
expect(asset.content).toBe('# Content');
|
|
169
|
-
expect(asset.yamlRaw).toBe(VALID_YAML);
|
|
170
|
-
expect(asset.providerId).toBe('my-provider');
|
|
171
|
-
expect(asset.repoUrl).toBe('https://github.com/org/repo');
|
|
172
|
-
expect(asset.branch).toBe('main');
|
|
173
|
-
expect(asset.yamlPath).toBe('instructions/my-instruction.yaml');
|
|
174
|
-
expect(asset.mdPath).toBe('instructions/my-instruction.md');
|
|
175
|
-
expect(asset.author).toBe('Test Author');
|
|
176
|
-
expect(asset.version).toBe('1.2.3');
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('sets metadata.mcpServers when mcpServers is present in the YAML', () => {
|
|
180
|
-
const yaml = `${VALID_YAML}mcpServers:\n my-server:\n url: http://localhost\n`;
|
|
181
|
-
const parsedWithMcp = AssetParser.parseYaml(yaml, 'agents/agent.yaml')!;
|
|
182
|
-
const asset = AssetParser.buildAsset(parsedWithMcp, '', 'p', 'url', 'main', 'agents/agent.yaml', 'agents/agent.md');
|
|
183
|
-
expect(asset.metadata?.mcpServers).toEqual({ 'my-server': { url: 'http://localhost' } });
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('sets metadata.steps when steps is present in the YAML', () => {
|
|
187
|
-
const yaml = `${VALID_YAML}steps:\n - name: step1\n action: do-something\n`;
|
|
188
|
-
const parsedWithSteps = AssetParser.parseYaml(yaml, 'workflows/w.yaml')!;
|
|
189
|
-
const asset = AssetParser.buildAsset(parsedWithSteps, '', 'p', 'url', 'main', 'workflows/w.yaml', 'workflows/w.md');
|
|
190
|
-
expect(asset.metadata?.steps).toEqual([{ name: 'step1', action: 'do-something' }]);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('leaves metadata undefined when no extra fields are present', () => {
|
|
194
|
-
const asset = AssetParser.buildAsset(
|
|
195
|
-
parsed,
|
|
196
|
-
'',
|
|
197
|
-
'p',
|
|
198
|
-
'url',
|
|
199
|
-
'main',
|
|
200
|
-
'instructions/my-instruction.yaml',
|
|
201
|
-
'instructions/my-instruction.md',
|
|
202
|
-
);
|
|
203
|
-
expect(asset.metadata).toBeUndefined();
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('defaults tags to [] when not specified', () => {
|
|
207
|
-
const yaml = `
|
|
208
|
-
name: No Tags
|
|
209
|
-
description: A description
|
|
210
|
-
type: instruction
|
|
211
|
-
tools:
|
|
212
|
-
- claude-code
|
|
213
|
-
`;
|
|
214
|
-
const parsedNoTags = AssetParser.parseYaml(yaml, 'instructions/no-tags.yaml')!;
|
|
215
|
-
const asset = AssetParser.buildAsset(parsedNoTags, '', 'p', 'url', 'main', 'instructions/no-tags.yaml', 'instructions/no-tags.md');
|
|
216
|
-
expect(asset.tags).toEqual([]);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it('defaults author to "Unknown" when not specified', () => {
|
|
220
|
-
const yaml = `
|
|
221
|
-
name: No Author
|
|
222
|
-
description: A description
|
|
223
|
-
type: instruction
|
|
224
|
-
tools:
|
|
225
|
-
- claude-code
|
|
226
|
-
`;
|
|
227
|
-
const parsedNoAuthor = AssetParser.parseYaml(yaml, 'instructions/no-author.yaml')!;
|
|
228
|
-
const asset = AssetParser.buildAsset(parsedNoAuthor, '', 'p', 'url', 'main', 'instructions/no-author.yaml', 'instructions/no-author.md');
|
|
229
|
-
expect(asset.author).toBe('Unknown');
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
describe('AssetParser.parseYaml — schema-as-gatekeeper (arbitrary path discovery)', () => {
|
|
234
|
-
it('discovers a valid asset at an arbitrary custom directory', () => {
|
|
235
|
-
const result = AssetParser.parseYaml(VALID_YAML, 'ai-artifacts/my-agent.yaml');
|
|
236
|
-
expect(result).not.toBeNull();
|
|
237
|
-
expect(result!.meta.name).toBe('My Instruction');
|
|
238
|
-
expect(result!.mdPath).toBeUndefined();
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('discovers a valid asset at a deeply nested path', () => {
|
|
242
|
-
const result = AssetParser.parseYaml(VALID_YAML, 'team/ai-assets/copilot/my-agent.yaml');
|
|
243
|
-
expect(result).not.toBeNull();
|
|
244
|
-
expect(result!.mdPath).toBeUndefined();
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('discovers a valid asset at the repo root (no subdirectory)', () => {
|
|
248
|
-
const result = AssetParser.parseYaml(VALID_YAML, 'my-instruction.yaml');
|
|
249
|
-
expect(result).not.toBeNull();
|
|
250
|
-
expect(result!.mdPath).toBeUndefined();
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('rejects a GitHub Actions workflow YAML (missing required fields)', () => {
|
|
254
|
-
const ghActionsYaml = `
|
|
255
|
-
on:
|
|
256
|
-
push:
|
|
257
|
-
branches: [main]
|
|
258
|
-
jobs:
|
|
259
|
-
build:
|
|
260
|
-
runs-on: ubuntu-latest
|
|
261
|
-
steps:
|
|
262
|
-
- uses: actions/checkout@v4
|
|
263
|
-
`;
|
|
264
|
-
expect(AssetParser.parseYaml(ghActionsYaml, '.github/workflows/ci.yaml')).toBeNull();
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it('rejects a Helm values YAML (no name/type/tools)', () => {
|
|
268
|
-
const helmYaml = `
|
|
269
|
-
replicaCount: 2
|
|
270
|
-
image:
|
|
271
|
-
repository: nginx
|
|
272
|
-
tag: latest
|
|
273
|
-
service:
|
|
274
|
-
type: ClusterIP
|
|
275
|
-
port: 80
|
|
276
|
-
`;
|
|
277
|
-
expect(AssetParser.parseYaml(helmYaml, 'helm/values.yaml')).toBeNull();
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it('rejects a Kubernetes manifest YAML (apiVersion/kind fields, no asset fields)', () => {
|
|
281
|
-
const k8sYaml = `
|
|
282
|
-
apiVersion: apps/v1
|
|
283
|
-
kind: Deployment
|
|
284
|
-
metadata:
|
|
285
|
-
name: my-app
|
|
286
|
-
spec:
|
|
287
|
-
replicas: 1
|
|
288
|
-
`;
|
|
289
|
-
expect(AssetParser.parseYaml(k8sYaml, 'k8s/deployment.yaml')).toBeNull();
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('rejects a YAML with a valid-looking name but invalid type enum', () => {
|
|
293
|
-
const yaml = `
|
|
294
|
-
name: CI Pipeline
|
|
295
|
-
description: Continuous integration pipeline config
|
|
296
|
-
type: pipeline
|
|
297
|
-
tools:
|
|
298
|
-
- claude-code
|
|
299
|
-
`;
|
|
300
|
-
expect(AssetParser.parseYaml(yaml, 'ai-artifacts/pipeline.yaml')).toBeNull();
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
it('rejects a YAML with a valid-looking type but unrecognised tool', () => {
|
|
304
|
-
const yaml = `
|
|
305
|
-
name: My Agent
|
|
306
|
-
description: An agent config
|
|
307
|
-
type: agent
|
|
308
|
-
tools:
|
|
309
|
-
- unknown-tool
|
|
310
|
-
`;
|
|
311
|
-
expect(AssetParser.parseYaml(yaml, 'ai-artifacts/agent.yaml')).toBeNull();
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
it('buildAsset correctly sets yamlPath and mdPath for an arbitrary directory', () => {
|
|
315
|
-
const parsed = AssetParser.parseYaml(VALID_YAML, 'ai-artifacts/my-agent.yaml')!;
|
|
316
|
-
const asset = AssetParser.buildAsset(
|
|
317
|
-
parsed,
|
|
318
|
-
'# Content',
|
|
319
|
-
'my-provider',
|
|
320
|
-
'https://github.com/org/repo',
|
|
321
|
-
'main',
|
|
322
|
-
'ai-artifacts/my-agent.yaml',
|
|
323
|
-
'ai-artifacts/SKILL.md',
|
|
324
|
-
);
|
|
325
|
-
expect(asset.yamlPath).toBe('ai-artifacts/my-agent.yaml');
|
|
326
|
-
expect(asset.mdPath).toBe('ai-artifacts/SKILL.md');
|
|
327
|
-
expect(asset.id).toBe(AssetParser.buildId('my-provider', 'ai-artifacts/my-agent.yaml'));
|
|
328
|
-
});
|
|
329
|
-
});
|