cap-copilot-sdk 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.
- package/cds-plugin.js +67 -0
- package/package.json +12 -0
- package/src/ProjectScanner.js +373 -0
- package/src/Registrar.js +74 -0
- package/src/SchemaExtractor.js +266 -0
- package/src/index.js +139 -0
package/cds-plugin.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Resolve @sap/cds from the project root (process.cwd()) to get the same
|
|
3
|
+
// singleton instance that cds watch uses — not a duplicate from this package's location.
|
|
4
|
+
const cdsPath = require.resolve('@sap/cds', { paths: [process.cwd()] });
|
|
5
|
+
const cds = require(cdsPath);
|
|
6
|
+
const { extract, extractFromServices } = require('./src/SchemaExtractor');
|
|
7
|
+
const { scan } = require('./src/ProjectScanner');
|
|
8
|
+
const { register } = require('./src/Registrar');
|
|
9
|
+
|
|
10
|
+
const cfg = cds.env.requires?.['btp-copilot'] ?? {};
|
|
11
|
+
const {
|
|
12
|
+
backendUrl = process.env.BTP_COPILOT_URL,
|
|
13
|
+
appId = process.env.BTP_COPILOT_APP_ID,
|
|
14
|
+
appName,
|
|
15
|
+
serviceUrl,
|
|
16
|
+
token = process.env.BTP_COPILOT_TOKEN,
|
|
17
|
+
includeProjectFiles = true,
|
|
18
|
+
includeHandlers = true,
|
|
19
|
+
includeViews = true,
|
|
20
|
+
} = cfg;
|
|
21
|
+
|
|
22
|
+
const log = (...args) => console.log('\x1b[36m[btp-copilot]\x1b[0m', ...args);
|
|
23
|
+
const warn = (...args) => console.warn('\x1b[33m[btp-copilot]\x1b[0m', ...args);
|
|
24
|
+
const err = (...args) => console.error('\x1b[31m[btp-copilot]\x1b[0m', ...args);
|
|
25
|
+
|
|
26
|
+
if (!backendUrl || !appId) {
|
|
27
|
+
log('No backendUrl/appId in cds.requires["btp-copilot"] — skipping registration.');
|
|
28
|
+
} else if (!/^[a-zA-Z0-9_-]+$/.test(appId)) {
|
|
29
|
+
warn(`appId "${appId}" contains invalid characters. Use only letters, digits, _ and -.`);
|
|
30
|
+
} else {
|
|
31
|
+
cds.on('served', async (services) => {
|
|
32
|
+
try {
|
|
33
|
+
log(`Collecting context for app "${appId}"…`);
|
|
34
|
+
const allDocs = [];
|
|
35
|
+
|
|
36
|
+
const schemaDocs = extract(cds, { serviceUrl });
|
|
37
|
+
allDocs.push(...schemaDocs);
|
|
38
|
+
log(` + ${schemaDocs.length} schema documents`);
|
|
39
|
+
|
|
40
|
+
const serviceList = Array.isArray(services) ? services : Object.values(services ?? {});
|
|
41
|
+
const runtimeDocs = extractFromServices(serviceList);
|
|
42
|
+
allDocs.push(...runtimeDocs);
|
|
43
|
+
if (runtimeDocs.length) log(` + ${runtimeDocs.length} runtime service documents`);
|
|
44
|
+
|
|
45
|
+
if (includeProjectFiles) {
|
|
46
|
+
const projectRoot = cds.env.root ?? process.cwd();
|
|
47
|
+
const fileDocs = scan(projectRoot, { includeHandlers, includeViews });
|
|
48
|
+
allDocs.push(...fileDocs);
|
|
49
|
+
log(` + ${fileDocs.length} project file documents`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!allDocs.length) { log('No documents to register.'); return; }
|
|
53
|
+
|
|
54
|
+
const toSend = Math.min(allDocs.length, 50);
|
|
55
|
+
log(`Registering ${toSend} of ${allDocs.length} documents with backend…`);
|
|
56
|
+
|
|
57
|
+
const ok = await register({ backendUrl, appId, appName: appName ?? appId, serviceUrl, documents: allDocs, token });
|
|
58
|
+
if (ok) {
|
|
59
|
+
log(`\x1b[32m✔ App "${appId}" registered — chatbot is now context-aware!\x1b[0m`);
|
|
60
|
+
} else {
|
|
61
|
+
warn(`✘ Registration failed. Backend: ${backendUrl} — start your chatbot backend to enable AI answers.`);
|
|
62
|
+
}
|
|
63
|
+
} catch (e) {
|
|
64
|
+
err('Registration error —', e.message);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cap-copilot-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CDS plugin: auto-extracts entity schemas, relationships, project structure and registers with BTP Copilot backend",
|
|
5
|
+
"main": "cds-plugin.js",
|
|
6
|
+
"files": ["src", "cds-plugin.js"],
|
|
7
|
+
"keywords": ["sap", "cap", "cds", "btp", "ai", "chatbot", "plugin"],
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@sap/cds": ">=7.0.0"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ProjectScanner — reads the CAP project folder structure and source files,
|
|
4
|
+
* then produces RAG-ready documents for the BTP Copilot backend.
|
|
5
|
+
*
|
|
6
|
+
* What it reads (relative to the project root):
|
|
7
|
+
* db/ — CDS schema files (.cds)
|
|
8
|
+
* srv/ — CDS service definitions (.cds) and handlers (.js / .ts)
|
|
9
|
+
* app/ — Fiori XML views (.xml), manifests (manifest.json),
|
|
10
|
+
* i18n files (i18n.properties), annotations (.cds / .xml)
|
|
11
|
+
* package.json — App name, description, version, cds config
|
|
12
|
+
* .cdsrc.json — Additional CDS configuration
|
|
13
|
+
*
|
|
14
|
+
* Each file is turned into one plain-text document.
|
|
15
|
+
* Large files are trimmed to MAX_LINES lines so the backend's 50-doc limit is
|
|
16
|
+
* respected without losing critical schema information.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
|
|
22
|
+
const MAX_LINES = 300; // max content lines per document
|
|
23
|
+
const MAX_HANDLER_LINES = 150; // handlers are trimmed more aggressively
|
|
24
|
+
const SKIP_DIRS = new Set([
|
|
25
|
+
"node_modules",
|
|
26
|
+
".git",
|
|
27
|
+
"dist",
|
|
28
|
+
"build",
|
|
29
|
+
"@types",
|
|
30
|
+
"gen",
|
|
31
|
+
"mta_archives",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} projectRoot Absolute path to the CAP project root
|
|
36
|
+
* @param {object} [opts]
|
|
37
|
+
* @param {boolean} [opts.includeHandlers=true] Include srv/*.js/ts handler content
|
|
38
|
+
* @param {boolean} [opts.includeViews=true] Include app XML view content
|
|
39
|
+
* @returns {{ title: string, content: string }[]}
|
|
40
|
+
*/
|
|
41
|
+
function scan(projectRoot, opts = {}) {
|
|
42
|
+
const { includeHandlers = true, includeViews = true } = opts;
|
|
43
|
+
|
|
44
|
+
const docs = [];
|
|
45
|
+
|
|
46
|
+
// ── 1. Folder structure overview ───────────────────────────────────────────
|
|
47
|
+
const tree = _buildTree(projectRoot, 3);
|
|
48
|
+
if (tree.length > 1) {
|
|
49
|
+
docs.push({
|
|
50
|
+
title: "Project folder structure",
|
|
51
|
+
content: ["Project root: " + projectRoot, ...tree].join("\n"),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── 2. package.json ────────────────────────────────────────────────────────
|
|
56
|
+
const pkgDoc = _readPackageJson(projectRoot);
|
|
57
|
+
if (pkgDoc) docs.push(pkgDoc);
|
|
58
|
+
|
|
59
|
+
// ── 3. db/ — schema CDS files ─────────────────────────────────────────────
|
|
60
|
+
const dbDir = path.join(projectRoot, "db");
|
|
61
|
+
if (_isDir(dbDir)) {
|
|
62
|
+
const cdsFiles = _findFiles(dbDir, [".cds"], 2);
|
|
63
|
+
for (const filePath of cdsFiles) {
|
|
64
|
+
const rel = path.relative(projectRoot, filePath).replace(/\\/g, "/");
|
|
65
|
+
const content = _readTrimmed(filePath, MAX_LINES);
|
|
66
|
+
if (content) {
|
|
67
|
+
docs.push({ title: `Schema file: ${rel}`, content });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── 4. srv/ — service definitions and handlers ────────────────────────────
|
|
73
|
+
const srvDir = path.join(projectRoot, "srv");
|
|
74
|
+
if (_isDir(srvDir)) {
|
|
75
|
+
// CDS service definitions
|
|
76
|
+
const cdsFiles = _findFiles(srvDir, [".cds"], 2);
|
|
77
|
+
for (const filePath of cdsFiles) {
|
|
78
|
+
const rel = path.relative(projectRoot, filePath).replace(/\\/g, "/");
|
|
79
|
+
const content = _readTrimmed(filePath, MAX_LINES);
|
|
80
|
+
if (content) {
|
|
81
|
+
docs.push({ title: `Service definition: ${rel}`, content });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Handlers (JS / TS)
|
|
86
|
+
if (includeHandlers) {
|
|
87
|
+
const handlerFiles = _findFiles(srvDir, [".js", ".ts", ".mjs"], 2);
|
|
88
|
+
for (const filePath of handlerFiles) {
|
|
89
|
+
const rel = path.relative(projectRoot, filePath).replace(/\\/g, "/");
|
|
90
|
+
const content = _readTrimmed(filePath, MAX_HANDLER_LINES);
|
|
91
|
+
if (content) {
|
|
92
|
+
docs.push({ title: `Service handler: ${rel}`, content });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── 5. app/ — Fiori UI files ───────────────────────────────────────────────
|
|
99
|
+
const appDir = path.join(projectRoot, "app");
|
|
100
|
+
if (_isDir(appDir)) {
|
|
101
|
+
// manifest.json — Fiori app metadata
|
|
102
|
+
const manifestFiles = _findFilesByName(appDir, "manifest.json", 3);
|
|
103
|
+
for (const filePath of manifestFiles) {
|
|
104
|
+
const rel = path.relative(projectRoot, filePath).replace(/\\/g, "/");
|
|
105
|
+
const doc = _readManifest(filePath, rel);
|
|
106
|
+
if (doc) docs.push(doc);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Annotation CDS files
|
|
110
|
+
const annotationCds = _findFiles(appDir, [".cds"], 3);
|
|
111
|
+
for (const filePath of annotationCds) {
|
|
112
|
+
const rel = path.relative(projectRoot, filePath).replace(/\\/g, "/");
|
|
113
|
+
const content = _readTrimmed(filePath, MAX_LINES);
|
|
114
|
+
if (content) {
|
|
115
|
+
docs.push({ title: `UI annotation: ${rel}`, content });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// XML views (abridged — just the first MAX_LINES lines)
|
|
120
|
+
if (includeViews) {
|
|
121
|
+
const xmlFiles = _findFiles(appDir, [".xml"], 4).filter(
|
|
122
|
+
(f) => !f.includes("node_modules"),
|
|
123
|
+
);
|
|
124
|
+
for (const filePath of xmlFiles.slice(0, 10)) {
|
|
125
|
+
// cap at 10 XML files
|
|
126
|
+
const rel = path.relative(projectRoot, filePath).replace(/\\/g, "/");
|
|
127
|
+
const content = _readTrimmed(filePath, MAX_LINES);
|
|
128
|
+
if (content) {
|
|
129
|
+
docs.push({ title: `UI view: ${rel}`, content });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// i18n / properties files (list of labels)
|
|
135
|
+
const i18nFiles = _findFilesByName(appDir, "i18n.properties", 3).concat(
|
|
136
|
+
_findFilesByName(appDir, "i18n_en.properties", 3),
|
|
137
|
+
);
|
|
138
|
+
for (const filePath of i18nFiles.slice(0, 3)) {
|
|
139
|
+
const rel = path.relative(projectRoot, filePath).replace(/\\/g, "/");
|
|
140
|
+
const content = _readTrimmed(filePath, 100);
|
|
141
|
+
if (content) {
|
|
142
|
+
docs.push({ title: `UI labels (i18n): ${rel}`, content });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return docs;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function _isDir(p) {
|
|
153
|
+
try {
|
|
154
|
+
return fs.statSync(p).isDirectory();
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _isFile(p) {
|
|
161
|
+
try {
|
|
162
|
+
return fs.statSync(p).isFile();
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _readTrimmed(filePath, maxLines) {
|
|
169
|
+
try {
|
|
170
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
171
|
+
const lines = raw.split("\n");
|
|
172
|
+
const trimmed = lines.slice(0, maxLines);
|
|
173
|
+
if (lines.length > maxLines) {
|
|
174
|
+
trimmed.push(`... (${lines.length - maxLines} more lines omitted)`);
|
|
175
|
+
}
|
|
176
|
+
return trimmed.join("\n").trim();
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build a directory tree string up to `depth` levels.
|
|
184
|
+
*/
|
|
185
|
+
function _buildTree(dir, depth, prefix = "") {
|
|
186
|
+
const lines = [];
|
|
187
|
+
if (depth < 0) return lines;
|
|
188
|
+
|
|
189
|
+
let entries;
|
|
190
|
+
try {
|
|
191
|
+
entries = fs.readdirSync(dir);
|
|
192
|
+
} catch {
|
|
193
|
+
return lines;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const filtered = entries
|
|
197
|
+
.filter((e) => !SKIP_DIRS.has(e) && !e.startsWith("."))
|
|
198
|
+
.sort((a, b) => {
|
|
199
|
+
// dirs first
|
|
200
|
+
const aIsDir = _isDir(path.join(dir, a));
|
|
201
|
+
const bIsDir = _isDir(path.join(dir, b));
|
|
202
|
+
if (aIsDir && !bIsDir) return -1;
|
|
203
|
+
if (!aIsDir && bIsDir) return 1;
|
|
204
|
+
return a.localeCompare(b);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
208
|
+
const entry = filtered[i];
|
|
209
|
+
const isLast = i === filtered.length - 1;
|
|
210
|
+
const connector = isLast ? "└── " : "├── ";
|
|
211
|
+
const fullPath = path.join(dir, entry);
|
|
212
|
+
const isDir = _isDir(fullPath);
|
|
213
|
+
|
|
214
|
+
lines.push(prefix + connector + entry + (isDir ? "/" : ""));
|
|
215
|
+
|
|
216
|
+
if (isDir && depth > 0) {
|
|
217
|
+
const childPrefix = prefix + (isLast ? " " : "│ ");
|
|
218
|
+
lines.push(..._buildTree(fullPath, depth - 1, childPrefix));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _findFiles(dir, extensions, maxDepth, _depth = 0) {
|
|
226
|
+
const results = [];
|
|
227
|
+
if (_depth > maxDepth) return results;
|
|
228
|
+
|
|
229
|
+
let entries;
|
|
230
|
+
try {
|
|
231
|
+
entries = fs.readdirSync(dir);
|
|
232
|
+
} catch {
|
|
233
|
+
return results;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const entry of entries) {
|
|
237
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
|
|
238
|
+
const fullPath = path.join(dir, entry);
|
|
239
|
+
try {
|
|
240
|
+
const stat = fs.statSync(fullPath);
|
|
241
|
+
if (stat.isDirectory()) {
|
|
242
|
+
results.push(..._findFiles(fullPath, extensions, maxDepth, _depth + 1));
|
|
243
|
+
} else if (extensions.some((ext) => entry.endsWith(ext))) {
|
|
244
|
+
results.push(fullPath);
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
/* skip inaccessible */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return results;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function _findFilesByName(dir, name, maxDepth, _depth = 0) {
|
|
254
|
+
const results = [];
|
|
255
|
+
if (_depth > maxDepth) return results;
|
|
256
|
+
|
|
257
|
+
let entries;
|
|
258
|
+
try {
|
|
259
|
+
entries = fs.readdirSync(dir);
|
|
260
|
+
} catch {
|
|
261
|
+
return results;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
|
|
266
|
+
const fullPath = path.join(dir, entry);
|
|
267
|
+
try {
|
|
268
|
+
const stat = fs.statSync(fullPath);
|
|
269
|
+
if (stat.isDirectory()) {
|
|
270
|
+
results.push(..._findFilesByName(fullPath, name, maxDepth, _depth + 1));
|
|
271
|
+
} else if (entry === name) {
|
|
272
|
+
results.push(fullPath);
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
/* skip */
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return results;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function _readPackageJson(projectRoot) {
|
|
282
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
283
|
+
if (!_isFile(pkgPath)) return null;
|
|
284
|
+
try {
|
|
285
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
286
|
+
const lines = [
|
|
287
|
+
`Application: ${pkg.name ?? "unknown"}`,
|
|
288
|
+
`Version: ${pkg.version ?? "unknown"}`,
|
|
289
|
+
];
|
|
290
|
+
if (pkg.description) lines.push(`Description: ${pkg.description}`);
|
|
291
|
+
|
|
292
|
+
// CDS configuration hints
|
|
293
|
+
const cdsConf = pkg.cds ?? {};
|
|
294
|
+
if (cdsConf.requires) {
|
|
295
|
+
const requires = Object.keys(cdsConf.requires).filter(
|
|
296
|
+
(k) => k !== "btp-copilot",
|
|
297
|
+
);
|
|
298
|
+
if (requires.length) lines.push(`CDS requires: ${requires.join(", ")}`);
|
|
299
|
+
}
|
|
300
|
+
if (cdsConf.features) {
|
|
301
|
+
lines.push(`CDS features: ${JSON.stringify(cdsConf.features)}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Dependencies hint (just names, no versions — for context)
|
|
305
|
+
const deps = Object.keys({
|
|
306
|
+
...(pkg.dependencies ?? {}),
|
|
307
|
+
...(pkg.devDependencies ?? {}),
|
|
308
|
+
}).filter(
|
|
309
|
+
(d) =>
|
|
310
|
+
d.startsWith("@sap") || d.startsWith("hdb") || d.startsWith("@cap"),
|
|
311
|
+
);
|
|
312
|
+
if (deps.length) lines.push(`SAP dependencies: ${deps.join(", ")}`);
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
title: "Application metadata (package.json)",
|
|
316
|
+
content: lines.join("\n"),
|
|
317
|
+
};
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Parse a Fiori manifest.json into a concise summary document.
|
|
325
|
+
*/
|
|
326
|
+
function _readManifest(filePath, relPath) {
|
|
327
|
+
try {
|
|
328
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
329
|
+
const lines = [`Fiori app manifest: ${relPath}`];
|
|
330
|
+
|
|
331
|
+
const sapApp = raw["sap.app"] ?? {};
|
|
332
|
+
if (sapApp.id) lines.push(`App ID: ${sapApp.id}`);
|
|
333
|
+
if (sapApp.title) lines.push(`Title: ${sapApp.title}`);
|
|
334
|
+
if (sapApp.description) lines.push(`Description: ${sapApp.description}`);
|
|
335
|
+
if (sapApp.type) lines.push(`Type: ${sapApp.type}`);
|
|
336
|
+
|
|
337
|
+
// Data sources
|
|
338
|
+
const dataSources = sapApp.dataSources ?? {};
|
|
339
|
+
for (const [dsName, ds] of Object.entries(dataSources)) {
|
|
340
|
+
lines.push(
|
|
341
|
+
`Data source "${dsName}": type=${ds.type ?? "OData"}, uri=${ds.uri ?? ""}`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const sapUi5 = raw["sap.ui5"] ?? {};
|
|
346
|
+
|
|
347
|
+
// Routing targets
|
|
348
|
+
const routes = sapUi5.routing?.routes ?? [];
|
|
349
|
+
if (routes.length) {
|
|
350
|
+
const routeNames = routes.map((r) => r.name ?? r.pattern).filter(Boolean);
|
|
351
|
+
lines.push(`Routes: ${routeNames.join(", ")}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Models
|
|
355
|
+
const models = sapUi5.models ?? {};
|
|
356
|
+
for (const [modelName, model] of Object.entries(models)) {
|
|
357
|
+
if (model.dataSource) {
|
|
358
|
+
lines.push(
|
|
359
|
+
`Model "${modelName}": bound to data source "${model.dataSource}"`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
title: `Fiori app: ${sapApp.id ?? relPath}`,
|
|
366
|
+
content: lines.join("\n"),
|
|
367
|
+
};
|
|
368
|
+
} catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
module.exports = { scan };
|
package/src/Registrar.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const https = require("https");
|
|
3
|
+
const http = require("http");
|
|
4
|
+
const { URL } = require("url");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register (or update) the app context documents with the BTP Copilot backend.
|
|
8
|
+
*
|
|
9
|
+
* POST /api/apps/register
|
|
10
|
+
* Body: { app_id, app_name, documents, replace }
|
|
11
|
+
*
|
|
12
|
+
* @param {{ backendUrl: string, appId: string, appName: string,
|
|
13
|
+
* documents: {title:string, content:string}[],
|
|
14
|
+
* token?: string }} opts
|
|
15
|
+
* @returns {Promise<boolean>}
|
|
16
|
+
*/
|
|
17
|
+
async function register(opts) {
|
|
18
|
+
const { backendUrl, appId, appName, documents, token } = opts;
|
|
19
|
+
|
|
20
|
+
// Backend allows max 50 documents — trim if needed
|
|
21
|
+
const docs = documents.slice(0, 50);
|
|
22
|
+
|
|
23
|
+
const url = new URL("/api/apps/register", backendUrl);
|
|
24
|
+
const body = JSON.stringify({
|
|
25
|
+
app_id: appId,
|
|
26
|
+
app_name: appName ?? appId,
|
|
27
|
+
documents: docs,
|
|
28
|
+
replace: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const lib = url.protocol === "https:" ? https : http;
|
|
33
|
+
const headers = {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"Content-Length": Buffer.byteLength(body),
|
|
36
|
+
};
|
|
37
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
38
|
+
|
|
39
|
+
const req = lib.request(
|
|
40
|
+
{
|
|
41
|
+
hostname: url.hostname,
|
|
42
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
43
|
+
path: url.pathname + url.search,
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers,
|
|
46
|
+
// Accept self-signed certs in dev environments
|
|
47
|
+
rejectUnauthorized: process.env.NODE_ENV !== "production",
|
|
48
|
+
},
|
|
49
|
+
(res) => {
|
|
50
|
+
let body = "";
|
|
51
|
+
res.on("data", (chunk) => (body += chunk));
|
|
52
|
+
res.on("end", () => {
|
|
53
|
+
const ok = res.statusCode >= 200 && res.statusCode < 300;
|
|
54
|
+
if (!ok) {
|
|
55
|
+
console.warn(
|
|
56
|
+
`[btp-copilot] Registration HTTP ${res.statusCode}: ${body.slice(0, 200)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
resolve(ok);
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
req.on("error", (err) => {
|
|
65
|
+
console.warn("[btp-copilot] Registration network error:", err.message);
|
|
66
|
+
resolve(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
req.write(body);
|
|
70
|
+
req.end();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { register };
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SchemaExtractor — reads the CDS compiled model and produces
|
|
4
|
+
* plain-text documents for RAG ingestion.
|
|
5
|
+
*
|
|
6
|
+
* Extracts:
|
|
7
|
+
* - Each entity's fields, types, keys, and annotations
|
|
8
|
+
* - Association and Composition relationships between entities
|
|
9
|
+
* - Service definitions (which entities each service exposes)
|
|
10
|
+
* - Application-level summary with OData query examples
|
|
11
|
+
* - Runtime entity data via srv.entities (types, cardinalities)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('@sap/cds')} cds
|
|
16
|
+
* @param {{ serviceUrl?: string }} [options]
|
|
17
|
+
* @returns {{ title: string, content: string }[]}
|
|
18
|
+
*/
|
|
19
|
+
function extract(cds, options = {}) {
|
|
20
|
+
const model = cds.model;
|
|
21
|
+
if (!model) return [];
|
|
22
|
+
|
|
23
|
+
const docs = [];
|
|
24
|
+
const serviceUrl = options.serviceUrl ?? "";
|
|
25
|
+
const defs = model.definitions ?? {};
|
|
26
|
+
|
|
27
|
+
// ── Collect entity definitions (skip SAP framework entities, deduplicate by short name) ──
|
|
28
|
+
const seenShortNames = new Set();
|
|
29
|
+
const entityDefs = Object.entries(defs).filter(([fqName, d]) => {
|
|
30
|
+
if (d.kind !== "entity") return false;
|
|
31
|
+
// Skip SAP common / framework entities
|
|
32
|
+
if (fqName.startsWith("sap.common.") || fqName.startsWith("cds.")) return false;
|
|
33
|
+
// Deduplicate: keep only the first occurrence of each short entity name
|
|
34
|
+
const shortName = fqName.split(".").pop();
|
|
35
|
+
if (seenShortNames.has(shortName)) return false;
|
|
36
|
+
seenShortNames.add(shortName);
|
|
37
|
+
return true;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const [fqName, def] of entityDefs) {
|
|
41
|
+
const shortName = fqName.split(".").pop();
|
|
42
|
+
const elements = def.elements ?? {};
|
|
43
|
+
|
|
44
|
+
const keys = [];
|
|
45
|
+
const fields = [];
|
|
46
|
+
const associations = [];
|
|
47
|
+
const compositions = [];
|
|
48
|
+
const enums = [];
|
|
49
|
+
|
|
50
|
+
for (const [elName, el] of Object.entries(elements)) {
|
|
51
|
+
if (elName.startsWith("_")) continue; // skip internal managed fields
|
|
52
|
+
|
|
53
|
+
const typeName = _shortType(el.type ?? el.name ?? "");
|
|
54
|
+
|
|
55
|
+
if (el.type === "cds.Composition") {
|
|
56
|
+
const target = _shortName(el.target ?? "");
|
|
57
|
+
const card = el.cardinality?.max === "*" ? "many" : "one";
|
|
58
|
+
compositions.push(`${elName} → ${target} (composition of ${card})`);
|
|
59
|
+
} else if (el.type === "cds.Association") {
|
|
60
|
+
const target = _shortName(el.target ?? "");
|
|
61
|
+
const card = el.cardinality?.max === "*" ? "many" : "one";
|
|
62
|
+
const fk = el.keys
|
|
63
|
+
?.map((k) => k.ref?.join(".") ?? "")
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join(", ");
|
|
66
|
+
const fkStr = fk ? `, FK: ${fk}` : "";
|
|
67
|
+
associations.push(
|
|
68
|
+
`${elName} → ${target} (association to ${card}${fkStr})`,
|
|
69
|
+
);
|
|
70
|
+
} else if (el.key) {
|
|
71
|
+
keys.push(`${elName} (${typeName}, key)`);
|
|
72
|
+
} else {
|
|
73
|
+
const nullable = el.notNull ? "" : ", optional";
|
|
74
|
+
const maxLen = el.length ? `, maxLength: ${el.length}` : "";
|
|
75
|
+
// Detect enum values
|
|
76
|
+
if (el.enum) {
|
|
77
|
+
const vals = Object.entries(el.enum)
|
|
78
|
+
.map(([k, v]) => `${k}=${v?.val ?? k}`)
|
|
79
|
+
.join("|");
|
|
80
|
+
enums.push(`${elName}: ${typeName} [${vals}]${nullable}`);
|
|
81
|
+
} else {
|
|
82
|
+
fields.push(`${elName} (${typeName}${nullable}${maxLen})`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const lines = [`Entity: ${shortName}`, `Fully qualified name: ${fqName}`];
|
|
88
|
+
|
|
89
|
+
if (serviceUrl) {
|
|
90
|
+
lines.push(`OData path: ${serviceUrl}/${shortName}`);
|
|
91
|
+
lines.push(`Count records: GET ${serviceUrl}/${shortName}/$count`);
|
|
92
|
+
lines.push(
|
|
93
|
+
`Filter example: GET ${serviceUrl}/${shortName}?$filter=ID eq 1`,
|
|
94
|
+
);
|
|
95
|
+
lines.push(
|
|
96
|
+
`Expand example: GET ${serviceUrl}/${shortName}?$expand=${associations[0]?.split(" ")[0] ?? "navProp"}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (keys.length) lines.push(`Key fields: ${keys.join(", ")}`);
|
|
101
|
+
if (fields.length) lines.push(`Fields: ${fields.join(", ")}`);
|
|
102
|
+
if (enums.length) lines.push(`Enum fields: ${enums.join("; ")}`);
|
|
103
|
+
if (associations.length)
|
|
104
|
+
lines.push(`Associations: ${associations.join("; ")}`);
|
|
105
|
+
if (compositions.length)
|
|
106
|
+
lines.push(`Compositions (child entities): ${compositions.join("; ")}`);
|
|
107
|
+
|
|
108
|
+
if (def["@title"]) lines.push(`UI label: ${def["@title"]}`);
|
|
109
|
+
if (def["@description"]) lines.push(`Description: ${def["@description"]}`);
|
|
110
|
+
if (def["@readonly"]) lines.push("Access: read-only");
|
|
111
|
+
if (def["@insertonly"]) lines.push("Access: insert-only");
|
|
112
|
+
|
|
113
|
+
docs.push({
|
|
114
|
+
title: `${shortName} entity schema`,
|
|
115
|
+
content: lines.join("\n"),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Entity relationship map ─────────────────────────────────────────────────
|
|
120
|
+
const relLines = ["Entity relationship overview:"];
|
|
121
|
+
for (const [fqName, def] of entityDefs) {
|
|
122
|
+
if (def.kind !== "entity") continue;
|
|
123
|
+
const shortName = fqName.split(".").pop();
|
|
124
|
+
const rels = [];
|
|
125
|
+
for (const [, el] of Object.entries(def.elements ?? {})) {
|
|
126
|
+
if (el.type === "cds.Association" || el.type === "cds.Composition") {
|
|
127
|
+
const target = _shortName(el.target ?? "");
|
|
128
|
+
const card = el.cardinality?.max === "*" ? "1:N" : "1:1";
|
|
129
|
+
rels.push(`${shortName} -[${card}]-> ${target}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (rels.length) relLines.push(...rels);
|
|
133
|
+
}
|
|
134
|
+
if (relLines.length > 1) {
|
|
135
|
+
docs.push({
|
|
136
|
+
title: "Entity relationship map",
|
|
137
|
+
content: relLines.join("\n"),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Services ────────────────────────────────────────────────────────────────
|
|
142
|
+
const serviceEntries = [];
|
|
143
|
+
for (const [fqName, def] of Object.entries(defs)) {
|
|
144
|
+
if (def.kind !== "service") continue;
|
|
145
|
+
const svcName = fqName.split(".").pop();
|
|
146
|
+
const exposed = Object.keys(def.elements ?? {});
|
|
147
|
+
if (exposed.length) {
|
|
148
|
+
serviceEntries.push(`${svcName}: ${exposed.join(", ")}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (serviceEntries.length) {
|
|
152
|
+
docs.push({
|
|
153
|
+
title: "Service definitions",
|
|
154
|
+
content: [
|
|
155
|
+
"CAP services and the entities/projections they expose:",
|
|
156
|
+
...serviceEntries,
|
|
157
|
+
].join("\n"),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Application summary ─────────────────────────────────────────────────────
|
|
162
|
+
const entityNames = entityDefs.map(([n]) => n.split(".").pop());
|
|
163
|
+
if (entityNames.length) {
|
|
164
|
+
const summaryLines = [
|
|
165
|
+
`This CAP application defines ${entityNames.length} entities: ${entityNames.join(", ")}.`,
|
|
166
|
+
];
|
|
167
|
+
if (serviceUrl) {
|
|
168
|
+
summaryLines.push(`OData service base URL: ${serviceUrl}`);
|
|
169
|
+
summaryLines.push("Query patterns:");
|
|
170
|
+
summaryLines.push(" GET {serviceUrl}/{Entity} — list all records");
|
|
171
|
+
summaryLines.push(
|
|
172
|
+
" GET {serviceUrl}/{Entity}/{key} — single record by key",
|
|
173
|
+
);
|
|
174
|
+
summaryLines.push(" GET {serviceUrl}/{Entity}/$count — total count");
|
|
175
|
+
summaryLines.push(
|
|
176
|
+
" GET {serviceUrl}/{Entity}?$filter=field eq value — filter",
|
|
177
|
+
);
|
|
178
|
+
summaryLines.push(
|
|
179
|
+
" GET {serviceUrl}/{Entity}?$select=f1,f2 — specific fields",
|
|
180
|
+
);
|
|
181
|
+
summaryLines.push(
|
|
182
|
+
" GET {serviceUrl}/{Entity}?$top=10&$skip=0 — pagination",
|
|
183
|
+
);
|
|
184
|
+
summaryLines.push(
|
|
185
|
+
" GET {serviceUrl}/{Entity}?$orderby=field desc — sorting",
|
|
186
|
+
);
|
|
187
|
+
summaryLines.push(
|
|
188
|
+
" GET {serviceUrl}/{Entity}?$expand=navProp — related entities",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
docs.push({
|
|
192
|
+
title: "Application schema summary",
|
|
193
|
+
content: summaryLines.join("\n"),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return docs;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Extract additional context from running CDS services via srv.entities.
|
|
202
|
+
* Called after cds.on('served') with the actual service objects.
|
|
203
|
+
*
|
|
204
|
+
* @param {import('@sap/cds').Service[]} services
|
|
205
|
+
* @returns {{ title: string, content: string }[]}
|
|
206
|
+
*/
|
|
207
|
+
function extractFromServices(services) {
|
|
208
|
+
const docs = [];
|
|
209
|
+
|
|
210
|
+
for (const srv of services) {
|
|
211
|
+
if (!srv || !srv.entities) continue;
|
|
212
|
+
|
|
213
|
+
const srvName = srv.name?.split(".").pop() ?? srv.name ?? "Service";
|
|
214
|
+
const lines = [`Runtime service: ${srvName}`];
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const entities = srv.entities;
|
|
218
|
+
const entityNames = [];
|
|
219
|
+
|
|
220
|
+
for (const [name, entity] of Object.entries(entities)) {
|
|
221
|
+
if (!entity || name.startsWith("_")) continue;
|
|
222
|
+
entityNames.push(name);
|
|
223
|
+
lines.push(
|
|
224
|
+
` - ${name} (draft: ${entity.drafts ? "yes" : "no"}, keys: ${Object.keys(entity.keys ?? {}).join(", ")})`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (entityNames.length) {
|
|
229
|
+
docs.push({
|
|
230
|
+
title: `${srvName} runtime entities`,
|
|
231
|
+
content: lines.join("\n"),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
// srv.entities not available or failed
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return docs;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _shortType(type) {
|
|
243
|
+
if (!type) return "String";
|
|
244
|
+
const map = {
|
|
245
|
+
"cds.String": "String",
|
|
246
|
+
"cds.Integer": "Integer",
|
|
247
|
+
"cds.Integer64": "Integer64",
|
|
248
|
+
"cds.Decimal": "Decimal",
|
|
249
|
+
"cds.Double": "Double",
|
|
250
|
+
"cds.Boolean": "Boolean",
|
|
251
|
+
"cds.Date": "Date",
|
|
252
|
+
"cds.Time": "Time",
|
|
253
|
+
"cds.DateTime": "DateTime",
|
|
254
|
+
"cds.Timestamp": "Timestamp",
|
|
255
|
+
"cds.UUID": "UUID",
|
|
256
|
+
"cds.LargeString": "LargeString",
|
|
257
|
+
"cds.Binary": "Binary",
|
|
258
|
+
};
|
|
259
|
+
return map[type] ?? type.split(".").pop() ?? type;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function _shortName(fqn) {
|
|
263
|
+
return fqn.split(".").pop() ?? fqn;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { extract, extractFromServices };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @btp-copilot/cap-plugin — CDS plugin entry point.
|
|
4
|
+
*
|
|
5
|
+
* On CAP startup this plugin:
|
|
6
|
+
* 1. Reads the CDS compiled model (entity schemas, types, relationships)
|
|
7
|
+
* 2. Reads the project folder structure (srv handlers, db schema files,
|
|
8
|
+
* Fiori XML views, manifests, i18n labels, annotations)
|
|
9
|
+
* 3. Iterates every running CDS service's srv.entities for runtime info
|
|
10
|
+
* 4. POSTs all collected documents to the BTP Copilot backend so that the
|
|
11
|
+
* chatbot can answer context-aware questions about this app
|
|
12
|
+
*
|
|
13
|
+
* Minimal configuration in your app's package.json:
|
|
14
|
+
* {
|
|
15
|
+
* "cds": {
|
|
16
|
+
* "requires": {
|
|
17
|
+
* "btp-copilot": {
|
|
18
|
+
* "kind": "btp-copilot",
|
|
19
|
+
* "backendUrl": "https://your-copilot.cfapps.eu10.hana.ondemand.com",
|
|
20
|
+
* "appId": "my-sales-app",
|
|
21
|
+
* "appName": "My Sales Application",
|
|
22
|
+
* "serviceUrl": "/odata/v4/SalesService",
|
|
23
|
+
* "token": "<bearer-token-or-read-from-env>"
|
|
24
|
+
* }
|
|
25
|
+
* },
|
|
26
|
+
* "plugins": ["@btp-copilot/cap-plugin"]
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* Or programmatically (in a custom server.js):
|
|
31
|
+
* const btpCopilot = require('@btp-copilot/cap-plugin');
|
|
32
|
+
* btpCopilot({ backendUrl: '...', appId: '...' });
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const path = require("path");
|
|
36
|
+
const { extract, extractFromServices } = require("./SchemaExtractor");
|
|
37
|
+
const { scan } = require("./ProjectScanner");
|
|
38
|
+
const { register } = require("./Registrar");
|
|
39
|
+
|
|
40
|
+
module.exports = (options = {}) => {
|
|
41
|
+
const cds = require("@sap/cds");
|
|
42
|
+
const log = cds.log("btp-copilot");
|
|
43
|
+
|
|
44
|
+
// Merge: explicit options > cds.env.requires config > env vars
|
|
45
|
+
const cfg = Object.assign(
|
|
46
|
+
{},
|
|
47
|
+
cds.env.requires?.["btp-copilot"] ?? {},
|
|
48
|
+
options,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const {
|
|
52
|
+
backendUrl = process.env.BTP_COPILOT_URL,
|
|
53
|
+
appId = process.env.BTP_COPILOT_APP_ID,
|
|
54
|
+
appName,
|
|
55
|
+
serviceUrl,
|
|
56
|
+
token = process.env.BTP_COPILOT_TOKEN,
|
|
57
|
+
includeProjectFiles = true,
|
|
58
|
+
includeHandlers = true,
|
|
59
|
+
includeViews = true,
|
|
60
|
+
} = cfg;
|
|
61
|
+
|
|
62
|
+
if (!backendUrl || !appId) {
|
|
63
|
+
log.warn(
|
|
64
|
+
"Missing required config (backendUrl, appId). " +
|
|
65
|
+
"Set them in cds.requires.btp-copilot or via BTP_COPILOT_URL / BTP_COPILOT_APP_ID env vars. " +
|
|
66
|
+
"Skipping registration.",
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate appId format (backend requires ^[a-zA-Z0-9_-]+$)
|
|
72
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(appId)) {
|
|
73
|
+
log.warn(
|
|
74
|
+
`appId "${appId}" contains invalid characters. Use only letters, digits, underscores and hyphens.`,
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
cds.on("served", async (services) => {
|
|
80
|
+
try {
|
|
81
|
+
log.info(`Collecting context for app "${appId}"…`);
|
|
82
|
+
const allDocs = [];
|
|
83
|
+
|
|
84
|
+
// ── 1. CDS model schema (entities, relationships, services) ─────────────
|
|
85
|
+
const schemaDocs = extract(cds, { serviceUrl });
|
|
86
|
+
allDocs.push(...schemaDocs);
|
|
87
|
+
log.info(` + ${schemaDocs.length} schema documents`);
|
|
88
|
+
|
|
89
|
+
// ── 2. Runtime srv.entities for each running service ────────────────────
|
|
90
|
+
const serviceList = Array.isArray(services)
|
|
91
|
+
? services
|
|
92
|
+
: Object.values(services ?? {});
|
|
93
|
+
|
|
94
|
+
const runtimeDocs = extractFromServices(serviceList);
|
|
95
|
+
allDocs.push(...runtimeDocs);
|
|
96
|
+
if (runtimeDocs.length) {
|
|
97
|
+
log.info(` + ${runtimeDocs.length} runtime service documents`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── 3. Project folder structure, src files, manifests ──────────────────
|
|
101
|
+
if (includeProjectFiles) {
|
|
102
|
+
const projectRoot = cds.env.root ?? process.cwd();
|
|
103
|
+
const fileDocs = scan(projectRoot, { includeHandlers, includeViews });
|
|
104
|
+
allDocs.push(...fileDocs);
|
|
105
|
+
log.info(` + ${fileDocs.length} project file documents`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!allDocs.length) {
|
|
109
|
+
log.info("No documents to register.");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
log.info(
|
|
114
|
+
`Registering ${Math.min(allDocs.length, 50)} documents (of ${allDocs.length} total) for app "${appId}"…`,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const ok = await register({
|
|
118
|
+
backendUrl,
|
|
119
|
+
appId,
|
|
120
|
+
appName: appName ?? appId,
|
|
121
|
+
serviceUrl,
|
|
122
|
+
documents: allDocs,
|
|
123
|
+
token,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (ok) {
|
|
127
|
+
log.info(
|
|
128
|
+
`✔ App "${appId}" registered with BTP Copilot. Chatbot is now context-aware.`,
|
|
129
|
+
);
|
|
130
|
+
} else {
|
|
131
|
+
log.warn(
|
|
132
|
+
`✘ Registration failed for "${appId}". Check backendUrl (${backendUrl}) and token.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
log.error("Registration error —", err.message);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
};
|