cap-copilot-sdk 0.1.1 → 0.2.3
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 +547 -88
- package/package.json +12 -2
- package/postinstall.js +93 -0
- package/src/DataSender.js +469 -0
- package/src/ProjectScanner.js +17 -5
- package/src/Registrar.js +227 -48
- package/src/SchemaExtractor.js +222 -41
- package/src/index.js +290 -45
package/cds-plugin.js
CHANGED
|
@@ -1,100 +1,193 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
// Resolve @sap/cds from the project root (process.cwd()) to get the same
|
|
3
3
|
// singleton instance that cds watch uses — not a duplicate from this package's location.
|
|
4
|
-
const cdsPath = require.resolve(
|
|
4
|
+
const cdsPath = require.resolve("@sap/cds", { paths: [process.cwd()] });
|
|
5
5
|
const cds = require(cdsPath);
|
|
6
|
-
const path = require(
|
|
7
|
-
const fs
|
|
8
|
-
const { extract, extractFromServices } = require(
|
|
9
|
-
const { scan } = require(
|
|
10
|
-
const {
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const { extract, extractFromServices } = require("./src/SchemaExtractor");
|
|
9
|
+
const { scan } = require("./src/ProjectScanner");
|
|
10
|
+
const {
|
|
11
|
+
register,
|
|
12
|
+
registerServiceTool,
|
|
13
|
+
registerAllServiceTools,
|
|
14
|
+
} = require("./src/Registrar");
|
|
15
|
+
|
|
16
|
+
// Read consuming app's package.json for zero-config defaults (name → appId, description → appName)
|
|
17
|
+
let _appPkg = {};
|
|
18
|
+
try {
|
|
19
|
+
_appPkg = JSON.parse(
|
|
20
|
+
fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"),
|
|
21
|
+
);
|
|
22
|
+
} catch {}
|
|
11
23
|
|
|
12
|
-
const cfg = cds.env.requires?.[
|
|
24
|
+
const cfg = cds.env.requires?.["btp-copilot"] ?? {};
|
|
13
25
|
const {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
// Zero-config defaults — works out of the box without any cds.requires config
|
|
27
|
+
backendUrl = process.env.BTP_COPILOT_URL ?? "http://localhost:8000",
|
|
28
|
+
appId = process.env.BTP_COPILOT_APP_ID ??
|
|
29
|
+
(_appPkg.name ?? "cap-app").replace(/[^a-zA-Z0-9_-]/g, "-"),
|
|
30
|
+
appName = _appPkg.description ?? cfg.appId ?? _appPkg.name ?? "CAP App",
|
|
31
|
+
serviceUrl, // single service URL (legacy / simple apps)
|
|
32
|
+
serviceUrls, // array of service URLs for multi-service CAP apps
|
|
33
|
+
iframeUrl = process.env.BTP_COPILOT_IFRAME_URL ?? "http://localhost:5173",
|
|
34
|
+
token = process.env.BTP_COPILOT_TOKEN,
|
|
20
35
|
includeProjectFiles = true,
|
|
21
|
-
includeHandlers
|
|
22
|
-
includeViews
|
|
23
|
-
injectWidget
|
|
24
|
-
widgetPosition
|
|
36
|
+
includeHandlers = true,
|
|
37
|
+
includeViews = true,
|
|
38
|
+
injectWidget = true,
|
|
39
|
+
widgetPosition = "bottom-right",
|
|
40
|
+
autoWatch = false, // auto-register READ + mutation hooks on all entities (opt-in for enterprise)
|
|
41
|
+
autoSync = false, // background paginated sync of all entities on startup (opt-in for enterprise)
|
|
42
|
+
syncPageSize = 100, // rows per sync page
|
|
43
|
+
syncJitter = true, // random 0-30 s delay before startup sync (staggers multi-app restarts)
|
|
44
|
+
maxDocs = 200, // max documents sent to backend per startup (increase if your app has many entities)
|
|
45
|
+
// multiTenant=true → the app has many end-users; raw record data MUST NOT go into
|
|
46
|
+
// the shared vector store (User A would see User B's records).
|
|
47
|
+
// In this mode autoWatch + autoSync push only entity stats (count +
|
|
48
|
+
// field list). Per-user live data is fetched at query time via OData
|
|
49
|
+
// using each user's own JWT — the CAP auth layer enforces isolation.
|
|
50
|
+
// multiTenant=false → single-user or dev; raw records are pushed to the vector store
|
|
51
|
+
// so the AI can answer questions even without an OData token.
|
|
52
|
+
multiTenant = process.env.BTP_COPILOT_MULTI_TENANT === "true"
|
|
53
|
+
? true
|
|
54
|
+
: process.env.BTP_COPILOT_MULTI_TENANT === "false"
|
|
55
|
+
? false
|
|
56
|
+
: false, // default: safe for single-user / dev; set true for production
|
|
25
57
|
} = cfg;
|
|
26
58
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
59
|
+
// Normalize service URLs: accept both `serviceUrl` (single, backward-compat) and
|
|
60
|
+
// `serviceUrls` (array, for apps with multiple OData services).
|
|
61
|
+
// When NEITHER is configured the plugin auto-discovers all internal service paths
|
|
62
|
+
// from the running CDS services at startup — zero config needed for any app size.
|
|
63
|
+
//
|
|
64
|
+
// Auto-discovery rules (applied inside cds.on('served')):
|
|
65
|
+
// - Include only services that have a .path starting with /odata
|
|
66
|
+
// - Exclude services backed by external .csn/.edmx imports (proxy services)
|
|
67
|
+
// because those forward to external systems not reachable via localhost
|
|
68
|
+
// - Exclude pure value-help / reference services with no writable entities
|
|
69
|
+
// (they are still indexed in the vector store via autoWatch)
|
|
70
|
+
const _configuredUrls = Array.isArray(serviceUrls)
|
|
71
|
+
? serviceUrls.filter(Boolean)
|
|
72
|
+
: serviceUrl
|
|
73
|
+
? [serviceUrl]
|
|
74
|
+
: null; // null = auto-discover at served time
|
|
75
|
+
|
|
76
|
+
// Resolved after 'served' fires; used for widget attribute + schema docs.
|
|
77
|
+
// Initialised to the first configured URL (or null) so the widget snippet
|
|
78
|
+
// built during 'bootstrap' has a value even before 'served' fires.
|
|
79
|
+
let _serviceUrls = _configuredUrls ?? [];
|
|
80
|
+
let _primarySvcUrl = _serviceUrls[0] ?? null;
|
|
81
|
+
|
|
82
|
+
// Base URL of this CAP server (e.g. http://localhost:4004).
|
|
83
|
+
// Sent to the chatbot backend so it can resolve relative OData paths to full URLs.
|
|
84
|
+
// Resolved from the CDS server address after 'served' fires.
|
|
85
|
+
// Can be overridden via BTP_COPILOT_BASE_URL env var for non-standard setups.
|
|
86
|
+
let _appBaseUrl = process.env.BTP_COPILOT_BASE_URL ?? null;
|
|
87
|
+
|
|
88
|
+
const log = (...args) => console.log("\x1b[36m[btp-copilot]\x1b[0m", ...args);
|
|
89
|
+
const warn = (...args) => console.warn("\x1b[33m[btp-copilot]\x1b[0m", ...args);
|
|
90
|
+
const err = (...args) => console.error("\x1b[31m[btp-copilot]\x1b[0m", ...args);
|
|
91
|
+
|
|
92
|
+
// ── HTML attribute escaping ───────────────────────────────────────────────────
|
|
93
|
+
// Prevents XSS if appName or other config values contain special characters.
|
|
94
|
+
function _escHtml(str) {
|
|
95
|
+
return String(str ?? "")
|
|
96
|
+
.replace(/&/g, "&")
|
|
97
|
+
.replace(/"/g, """)
|
|
98
|
+
.replace(/'/g, "'")
|
|
99
|
+
.replace(/</g, "<")
|
|
100
|
+
.replace(/>/g, ">");
|
|
101
|
+
}
|
|
30
102
|
|
|
31
103
|
// ── Widget auto-injection middleware ─────────────────────────────────────────
|
|
32
104
|
// Intercepts every HTML response from CAP and appends the <btp-copilot> tag
|
|
33
105
|
// + the widget script. Works for ALL Fiori/UI5 apps served by this CAP server
|
|
34
106
|
// without any manual changes to their index.html files.
|
|
35
|
-
function _buildWidgetSnippet
|
|
107
|
+
function _buildWidgetSnippet() {
|
|
36
108
|
// Serve the widget JS bundle from node_modules so no CDN dependency
|
|
37
109
|
const widgetBundlePath = (() => {
|
|
38
110
|
try {
|
|
39
|
-
return require.resolve(
|
|
40
|
-
|
|
41
|
-
|
|
111
|
+
return require.resolve("cap-copilot-widget/dist/btp-copilot.js", {
|
|
112
|
+
paths: [process.cwd()],
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
42
117
|
})();
|
|
43
118
|
|
|
119
|
+
if (!widgetBundlePath) {
|
|
120
|
+
warn(
|
|
121
|
+
"cap-copilot-widget not found in node_modules — falling back to unpkg.com CDN. " +
|
|
122
|
+
"Add cap-copilot-widget to your dependencies for offline/air-gapped deployments.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
44
125
|
const scriptTag = widgetBundlePath
|
|
45
126
|
? `<script src="/__btp-copilot-widget/btp-copilot.js"></script>`
|
|
46
127
|
: `<script src="https://unpkg.com/cap-copilot-widget/dist/btp-copilot.js"></script>`;
|
|
47
128
|
|
|
48
129
|
const attrs = [
|
|
49
|
-
`app-id="${appId}"`,
|
|
50
|
-
`position="${widgetPosition}"`,
|
|
51
|
-
iframeUrl
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
130
|
+
`app-id="${_escHtml(appId)}"`,
|
|
131
|
+
`position="${_escHtml(widgetPosition)}"`,
|
|
132
|
+
iframeUrl ? `iframe-url="${_escHtml(iframeUrl)}"` : "",
|
|
133
|
+
backendUrl ? `backend-url="${_escHtml(backendUrl)}"` : "",
|
|
134
|
+
_primarySvcUrl ? `service-url="${_escHtml(_primarySvcUrl)}"` : "",
|
|
135
|
+
appName ? `app-name="${_escHtml(appName)}"` : "",
|
|
136
|
+
]
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.join(" ");
|
|
58
139
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
140
|
+
return {
|
|
141
|
+
snippet: `\n${scriptTag}\n<btp-copilot ${attrs}></btp-copilot>\n</body>`,
|
|
142
|
+
widgetBundlePath,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
64
145
|
|
|
146
|
+
function _registerWidgetMiddleware(app) {
|
|
147
|
+
// Always serve the widget bundle — even when iframeUrl is not configured
|
|
148
|
+
// so that apps that manually embed <btp-copilot> in their index.html can
|
|
149
|
+
// load the script from /__btp-copilot-widget/btp-copilot.js.
|
|
65
150
|
const { snippet, widgetBundlePath } = _buildWidgetSnippet();
|
|
66
151
|
|
|
67
|
-
// Serve the local widget bundle at a fixed path so no CDN needed
|
|
68
152
|
if (widgetBundlePath) {
|
|
69
|
-
app.get(
|
|
70
|
-
res.setHeader(
|
|
71
|
-
res.setHeader(
|
|
153
|
+
app.get("/__btp-copilot-widget/btp-copilot.js", (_req, res) => {
|
|
154
|
+
res.setHeader("Content-Type", "application/javascript");
|
|
155
|
+
res.setHeader("Cache-Control", "public, max-age=3600");
|
|
72
156
|
res.sendFile(widgetBundlePath);
|
|
73
157
|
});
|
|
74
|
-
log(
|
|
158
|
+
log("Serving widget bundle at /__btp-copilot-widget/btp-copilot.js");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Auto-inject widget into HTML responses only when iframeUrl is configured
|
|
162
|
+
if (!injectWidget || !iframeUrl) {
|
|
163
|
+
if (!iframeUrl)
|
|
164
|
+
warn(
|
|
165
|
+
'No iframeUrl configured — widget auto-injection skipped. Add "iframeUrl" to cds.requires["btp-copilot"] or embed <btp-copilot> manually in your index.html.',
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
75
168
|
}
|
|
76
169
|
|
|
77
170
|
// Intercept HTML responses and inject the widget before </body>
|
|
78
171
|
app.use((req, res, next) => {
|
|
79
|
-
const _write
|
|
80
|
-
const _end
|
|
81
|
-
let
|
|
82
|
-
let
|
|
83
|
-
let
|
|
172
|
+
const _write = res.write.bind(res);
|
|
173
|
+
const _end = res.end.bind(res);
|
|
174
|
+
let _buffer = "";
|
|
175
|
+
let _isHtml = false;
|
|
176
|
+
let _intercepted = false;
|
|
84
177
|
|
|
85
|
-
res.on(
|
|
178
|
+
res.on("pipe", () => {});
|
|
86
179
|
|
|
87
180
|
const _intercept = () => {
|
|
88
|
-
const ct = res.getHeader(
|
|
89
|
-
_isHtml = ct.includes(
|
|
181
|
+
const ct = res.getHeader("content-type") || "";
|
|
182
|
+
_isHtml = ct.includes("text/html");
|
|
90
183
|
};
|
|
91
184
|
|
|
92
185
|
const _inject = (chunk) => {
|
|
93
186
|
if (!_isHtml) return chunk;
|
|
94
|
-
const str = Buffer.isBuffer(chunk) ? chunk.toString(
|
|
95
|
-
if (str.includes(
|
|
187
|
+
const str = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk || "";
|
|
188
|
+
if (str.includes("</body>")) {
|
|
96
189
|
_intercepted = true;
|
|
97
|
-
return str.replace(
|
|
190
|
+
return str.replace("</body>", snippet);
|
|
98
191
|
}
|
|
99
192
|
return str;
|
|
100
193
|
};
|
|
@@ -119,53 +212,419 @@ function _registerWidgetMiddleware (app) {
|
|
|
119
212
|
next();
|
|
120
213
|
});
|
|
121
214
|
|
|
122
|
-
log(
|
|
215
|
+
log(
|
|
216
|
+
`Widget auto-injection active — <btp-copilot> will appear in all HTML pages.`,
|
|
217
|
+
);
|
|
123
218
|
}
|
|
124
219
|
|
|
220
|
+
// ── Graceful shutdown: flush debounce queue ───────────────────────────────────
|
|
221
|
+
// Ensures any queued entity data is sent before the process exits so no records
|
|
222
|
+
// are silently lost when cds watch restarts or the container is killed.
|
|
223
|
+
const _gracefulShutdown = async () => {
|
|
224
|
+
try {
|
|
225
|
+
const { flushAll } = require("./src/DataSender");
|
|
226
|
+
log("Flushing pending data before shutdown…");
|
|
227
|
+
await flushAll();
|
|
228
|
+
} catch {}
|
|
229
|
+
};
|
|
230
|
+
// cds.on("shutdown") is the preferred CDS lifecycle hook; SIGTERM is the fallback
|
|
231
|
+
// for Docker/Kubernetes environments where the CDS hook may not fire.
|
|
232
|
+
try { cds.on("shutdown", _gracefulShutdown); } catch {}
|
|
233
|
+
process.once("SIGTERM", _gracefulShutdown);
|
|
234
|
+
|
|
125
235
|
if (!backendUrl || !appId) {
|
|
126
|
-
log(
|
|
236
|
+
log(
|
|
237
|
+
'No backendUrl/appId in cds.requires["btp-copilot"] — skipping registration.',
|
|
238
|
+
);
|
|
127
239
|
} else if (!/^[a-zA-Z0-9_-]+$/.test(appId)) {
|
|
128
|
-
warn(
|
|
240
|
+
warn(
|
|
241
|
+
`appId "${appId}" contains invalid characters. Use only letters, digits, _ and -.`,
|
|
242
|
+
);
|
|
129
243
|
} else {
|
|
130
244
|
// Register the middleware as early as possible so it wraps all HTML routes
|
|
131
|
-
cds.on(
|
|
245
|
+
cds.on("bootstrap", (app) => {
|
|
132
246
|
_registerWidgetMiddleware(app);
|
|
133
247
|
});
|
|
134
248
|
|
|
135
|
-
cds.on(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
249
|
+
cds.on("served", (services) => {
|
|
250
|
+
// Resolve the CAP server's own listening URL so relative service paths can be
|
|
251
|
+
// reconstructed into full URLs by the chatbot backend.
|
|
252
|
+
if (!_appBaseUrl) {
|
|
253
|
+
try {
|
|
254
|
+
// Cloud Foundry / BTP: external URL from VCAP_APPLICATION
|
|
255
|
+
const vcap = process.env.VCAP_APPLICATION;
|
|
256
|
+
if (vcap) {
|
|
257
|
+
const vcapData = JSON.parse(vcap);
|
|
258
|
+
const externalUri = vcapData.application_uris?.[0];
|
|
259
|
+
if (externalUri) {
|
|
260
|
+
_appBaseUrl = `https://${externalUri}`;
|
|
261
|
+
log(`Detected BTP/CF external URL: ${_appBaseUrl}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Local development: use the actual listening port
|
|
265
|
+
if (!_appBaseUrl) {
|
|
266
|
+
const addr = cds.server?.address?.();
|
|
267
|
+
const port = addr?.port || cds.env?.server?.port || 4004;
|
|
268
|
+
_appBaseUrl = `http://localhost:${port}`;
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
_appBaseUrl = "http://localhost:4004";
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Layer 1: Auto-register READ + mutation hooks on all entities ──────────
|
|
276
|
+
// Every OData read AND every create/update is captured automatically.
|
|
277
|
+
// In multiTenant mode: hooks still fire but the data is NOT sent to the
|
|
278
|
+
// vector store — the SDK silently skips push since each user's OData data
|
|
279
|
+
// must stay private and is fetched live at query time instead.
|
|
280
|
+
if (autoWatch && !multiTenant) {
|
|
281
|
+
const { watchEntity: _autoWatch } = require("./src/DataSender");
|
|
282
|
+
const svcList = Array.isArray(services)
|
|
283
|
+
? services
|
|
284
|
+
: Object.values(services ?? {});
|
|
285
|
+
let watchCount = 0;
|
|
286
|
+
for (const svc of svcList) {
|
|
287
|
+
if (typeof svc.after !== "function" || !svc.entities) continue;
|
|
288
|
+
for (const [, entity] of Object.entries(svc.entities)) {
|
|
289
|
+
_autoWatch(svc, entity, { backendUrl, appId, token, appName });
|
|
290
|
+
watchCount++;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (watchCount > 0)
|
|
294
|
+
log(
|
|
295
|
+
`Auto-watching ${watchCount} entit${watchCount === 1 ? "y" : "ies"} (READ + mutations) across all services.`,
|
|
296
|
+
);
|
|
297
|
+
} else if (autoWatch && multiTenant) {
|
|
298
|
+
log(
|
|
299
|
+
"multiTenant=true — skipping autoWatch (raw record push). Live user data is fetched via OData at query time.",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Layer 2: Background startup sync ─────────────────────────────────────
|
|
304
|
+
// Catches all historical data in the DB — not just records visited today.
|
|
305
|
+
// Incremental on restart: only re-syncs rows changed since last sync.
|
|
306
|
+
if (autoSync) {
|
|
307
|
+
const { syncAllEntities } = require("./src/DataSender");
|
|
308
|
+
// Use os.tmpdir() so cds watch never sees this file and doesn't restart
|
|
309
|
+
const cacheFile = path.join(
|
|
310
|
+
require("os").tmpdir(),
|
|
311
|
+
`.btp-copilot-${appId}.json`,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
let modifiedSince = null;
|
|
315
|
+
try {
|
|
316
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
|
317
|
+
modifiedSince = cached.lastSyncedAt ?? null;
|
|
318
|
+
} catch {}
|
|
319
|
+
|
|
320
|
+
const svcList = Array.isArray(services)
|
|
321
|
+
? services
|
|
322
|
+
: Object.values(services ?? {});
|
|
323
|
+
const syncStart = new Date().toISOString();
|
|
324
|
+
|
|
325
|
+
setImmediate(async () => {
|
|
326
|
+
try {
|
|
327
|
+
// Startup jitter: delay by a random amount so that when 100+ apps
|
|
328
|
+
// restart simultaneously they don't all hammer the backend at once.
|
|
329
|
+
if (syncJitter) {
|
|
330
|
+
const jitterMs = Math.floor(Math.random() * 30_000);
|
|
331
|
+
log(
|
|
332
|
+
`Startup sync delayed ${Math.round(jitterMs / 1000)}s (jitter) to spread backend load.`,
|
|
333
|
+
);
|
|
334
|
+
await new Promise((r) => setTimeout(r, jitterMs));
|
|
335
|
+
}
|
|
336
|
+
log(
|
|
337
|
+
modifiedSince
|
|
338
|
+
? `Starting incremental sync (changes since ${modifiedSince})…`
|
|
339
|
+
: `Starting full background sync — all entity data will be indexed…`,
|
|
340
|
+
);
|
|
341
|
+
const total = await syncAllEntities(
|
|
342
|
+
cds,
|
|
343
|
+
svcList,
|
|
344
|
+
{ backendUrl, appId, token, appName },
|
|
345
|
+
{
|
|
346
|
+
pageSize: syncPageSize,
|
|
347
|
+
modifiedSince,
|
|
348
|
+
multiTenant,
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
log(
|
|
352
|
+
`\x1b[32m✔ Background sync complete — ${total} row(s) indexed.\x1b[0m`,
|
|
353
|
+
);
|
|
354
|
+
try {
|
|
355
|
+
let cached = {};
|
|
356
|
+
try {
|
|
357
|
+
cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
|
358
|
+
} catch {}
|
|
359
|
+
fs.writeFileSync(
|
|
360
|
+
cacheFile,
|
|
361
|
+
JSON.stringify({ ...cached, lastSyncedAt: syncStart }),
|
|
362
|
+
);
|
|
363
|
+
} catch {}
|
|
364
|
+
} catch (e) {
|
|
365
|
+
warn("Background sync error —", e.message);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Layer 3: Auto-discover + register OData service(s) as live query tools ─
|
|
371
|
+
// When the user has not configured serviceUrl(s) we build the list automatically
|
|
372
|
+
// from every CDS service that has an /odata path and real (non-external) entities.
|
|
373
|
+
// This means zero config for any app, regardless of how many services it has.
|
|
374
|
+
{
|
|
375
|
+
const svcList = Array.isArray(services)
|
|
376
|
+
? services
|
|
377
|
+
: Object.values(services ?? {});
|
|
378
|
+
|
|
379
|
+
// ── Identify external/proxy services (backed by .csn/.edmx imports) ───
|
|
380
|
+
// CDS marks external service definitions with a "from" pointing to an
|
|
381
|
+
// external file. We detect them by checking the model definitions.
|
|
382
|
+
const _externalServiceNames = new Set();
|
|
383
|
+
try {
|
|
384
|
+
const defs = cds.model?.definitions ?? {};
|
|
385
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
386
|
+
if (def.kind === "service") {
|
|
387
|
+
const src = def.$location?.file ?? def["@src"] ?? "";
|
|
388
|
+
if (src && !/\.cds$/i.test(src)) _externalServiceNames.add(name);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
/* ignore */
|
|
154
393
|
}
|
|
155
394
|
|
|
156
|
-
|
|
395
|
+
// ── Auto-discover: build URL list from running services ───────────────
|
|
396
|
+
if (_configuredUrls === null) {
|
|
397
|
+
_serviceUrls = svcList
|
|
398
|
+
.filter((svc) => {
|
|
399
|
+
// Must have an /odata path
|
|
400
|
+
if (!svc.path?.startsWith("/odata")) return false;
|
|
401
|
+
// Skip CDS-internal services: the database service (cds.db) is
|
|
402
|
+
// accessible via svc.path="/odata/v4/db" but is NOT a real OData
|
|
403
|
+
// endpoint — it is the internal persistence layer. Same for any
|
|
404
|
+
// service whose name is exactly "db" or is a DatabaseService instance.
|
|
405
|
+
if (svc.name === "db" || svc.path === "/odata/v4/db") return false;
|
|
406
|
+
if (cds.DatabaseService && svc instanceof cds.DatabaseService)
|
|
407
|
+
return false;
|
|
408
|
+
// Skip external proxy services (backed by .csn/.edmx imports)
|
|
409
|
+
if (_externalServiceNames.has(svc.name)) return false;
|
|
410
|
+
// Must expose at least one entity
|
|
411
|
+
if (!svc.entities || Object.keys(svc.entities).length === 0)
|
|
412
|
+
return false;
|
|
413
|
+
return true;
|
|
414
|
+
})
|
|
415
|
+
.map((svc) => svc.path);
|
|
157
416
|
|
|
158
|
-
|
|
159
|
-
|
|
417
|
+
_primarySvcUrl = _serviceUrls[0] ?? null;
|
|
418
|
+
if (_serviceUrls.length) {
|
|
419
|
+
log(
|
|
420
|
+
`Auto-discovered ${_serviceUrls.length} OData service(s) for live-query tool registration.`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
160
424
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
425
|
+
if (_serviceUrls.length) {
|
|
426
|
+
setImmediate(() => {
|
|
427
|
+
const registrations = _serviceUrls.map((svcUrl) => {
|
|
428
|
+
// Match the service whose path equals this URL.
|
|
429
|
+
const matchedSvc = svcList.find((svc) => svc.path === svcUrl);
|
|
430
|
+
|
|
431
|
+
// Only pass entities that belong to THIS service.
|
|
432
|
+
const entityNames = matchedSvc?.entities
|
|
433
|
+
? Object.keys(matchedSvc.entities)
|
|
434
|
+
: [];
|
|
435
|
+
|
|
436
|
+
return registerServiceTool({
|
|
437
|
+
backendUrl,
|
|
438
|
+
appId,
|
|
439
|
+
appName,
|
|
440
|
+
serviceUrl: svcUrl,
|
|
441
|
+
entities: entityNames,
|
|
442
|
+
token,
|
|
443
|
+
appBaseUrl: _appBaseUrl,
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
Promise.all(registrations).catch(() => {});
|
|
447
|
+
});
|
|
166
448
|
}
|
|
167
|
-
} catch (e) {
|
|
168
|
-
err('Registration error —', e.message);
|
|
169
449
|
}
|
|
450
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
setImmediate(async () => {
|
|
453
|
+
try {
|
|
454
|
+
log(`Collecting context for app "${appId}"…`);
|
|
455
|
+
const allDocs = [];
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
const serviceList = Array.isArray(services)
|
|
459
|
+
? services
|
|
460
|
+
: Object.values(services ?? {});
|
|
461
|
+
|
|
462
|
+
const schemaDocs = extract(cds, {
|
|
463
|
+
serviceUrl: _primarySvcUrl,
|
|
464
|
+
serviceList,
|
|
465
|
+
});
|
|
466
|
+
allDocs.push(...schemaDocs);
|
|
467
|
+
log(` + ${schemaDocs.length} schema documents`);
|
|
468
|
+
|
|
469
|
+
const runtimeDocs = extractFromServices(serviceList);
|
|
470
|
+
allDocs.push(...runtimeDocs);
|
|
471
|
+
if (runtimeDocs.length)
|
|
472
|
+
log(` + ${runtimeDocs.length} runtime service documents`);
|
|
473
|
+
|
|
474
|
+
if (includeProjectFiles) {
|
|
475
|
+
const projectRoot = cds.env.root ?? process.cwd();
|
|
476
|
+
const fileDocs = scan(projectRoot, { includeHandlers, includeViews });
|
|
477
|
+
allDocs.push(...fileDocs);
|
|
478
|
+
log(` + ${fileDocs.length} project file documents`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!allDocs.length) {
|
|
482
|
+
log("No documents to register.");
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Hash-based cache: skip re-registration if docs haven't changed ──
|
|
487
|
+
const crypto = require("crypto");
|
|
488
|
+
// Use os.tmpdir() so cds watch never sees this file and doesn't restart
|
|
489
|
+
const cacheFile = path.join(
|
|
490
|
+
require("os").tmpdir(),
|
|
491
|
+
`.btp-copilot-${appId}.json`,
|
|
492
|
+
);
|
|
493
|
+
const contentHash = crypto
|
|
494
|
+
.createHash("sha256")
|
|
495
|
+
.update(allDocs.map((d) => d.title + d.content).join("|"))
|
|
496
|
+
.digest("hex");
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
|
500
|
+
if (cached.appId === appId && cached.hash === contentHash) {
|
|
501
|
+
log(
|
|
502
|
+
`\x1b[32m✔ Schema unchanged — skipping re-registration (cached). Live data will still be seeded on first READ.\x1b[0m`,
|
|
503
|
+
);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
} catch {
|
|
507
|
+
// No cache file yet — proceed with registration
|
|
508
|
+
}
|
|
509
|
+
// ────────────────────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
const toSend = Math.min(allDocs.length, maxDocs);
|
|
512
|
+
if (toSend < allDocs.length) {
|
|
513
|
+
warn(
|
|
514
|
+
`Sending ${toSend} of ${allDocs.length} documents (maxDocs=${maxDocs}). ` +
|
|
515
|
+
`Increase via cds.requires["btp-copilot"].maxDocs to index all documents.`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
log(
|
|
519
|
+
`Registering ${toSend} of ${allDocs.length} documents with backend…`,
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const ok = await register({
|
|
523
|
+
backendUrl,
|
|
524
|
+
appId,
|
|
525
|
+
appName: appName ?? appId,
|
|
526
|
+
serviceUrl,
|
|
527
|
+
documents: allDocs,
|
|
528
|
+
token,
|
|
529
|
+
});
|
|
530
|
+
if (ok) {
|
|
531
|
+
// Save cache so next restart skips re-registration
|
|
532
|
+
try {
|
|
533
|
+
fs.writeFileSync(
|
|
534
|
+
cacheFile,
|
|
535
|
+
JSON.stringify({ appId, hash: contentHash, ts: Date.now() }),
|
|
536
|
+
);
|
|
537
|
+
} catch {
|
|
538
|
+
/* ignore cache write errors */
|
|
539
|
+
}
|
|
540
|
+
log(
|
|
541
|
+
`\x1b[32m✔ App "${appId}" registered — chatbot is now context-aware!\x1b[0m`,
|
|
542
|
+
);
|
|
543
|
+
} else {
|
|
544
|
+
warn(
|
|
545
|
+
`✘ Registration failed. Backend: ${backendUrl} — start your chatbot backend to enable AI answers.`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
} catch (e) {
|
|
549
|
+
err("Registration error —", e.message);
|
|
550
|
+
}
|
|
551
|
+
}); // end setImmediate
|
|
170
552
|
});
|
|
171
553
|
}
|
|
554
|
+
|
|
555
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
556
|
+
const {
|
|
557
|
+
sendEntityData,
|
|
558
|
+
watchEntity: _watchEntity,
|
|
559
|
+
syncAllEntities: _syncAll,
|
|
560
|
+
} = require("./src/DataSender");
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Push live DB query results to the BTP Copilot backend from any CAP
|
|
564
|
+
* service handler or action — minimum code, no extra config needed.
|
|
565
|
+
*
|
|
566
|
+
* The plugin already has backendUrl / appId / token from cds.env so you
|
|
567
|
+
* only pass the entity name and the data you just queried.
|
|
568
|
+
*
|
|
569
|
+
* @example
|
|
570
|
+
* const { sendData } = require('cap-copilot-sdk');
|
|
571
|
+
*
|
|
572
|
+
* // inside any srv.on / srv.before / srv.after handler:
|
|
573
|
+
* const data = await tx.run(SELECT.from(Orders).where({ ID: orderID }));
|
|
574
|
+
* await sendData('Orders', data);
|
|
575
|
+
*
|
|
576
|
+
* @param {string} entityName - entity name, e.g. 'Orders'
|
|
577
|
+
* @param {object[]|object} data - result of tx.run(SELECT.from(...))
|
|
578
|
+
* @param {string} [label] - optional custom document title
|
|
579
|
+
* @returns {Promise<boolean>}
|
|
580
|
+
*/
|
|
581
|
+
module.exports.sendData = (entityName, data, label) =>
|
|
582
|
+
sendEntityData(
|
|
583
|
+
{ backendUrl, appId, token, appName },
|
|
584
|
+
entityName,
|
|
585
|
+
data,
|
|
586
|
+
label,
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Register an after-READ hook so this entity's read results are pushed to the
|
|
591
|
+
* BTP Copilot chatbot automatically — no manual sendData calls needed.
|
|
592
|
+
*
|
|
593
|
+
* This is the recommended pattern for CAP apps. Only data that the user
|
|
594
|
+
* actually reads is sent, so it scales to large tables without bulk dumps.
|
|
595
|
+
*
|
|
596
|
+
* @example
|
|
597
|
+
* const { watchEntity } = require('cap-copilot-sdk');
|
|
598
|
+
*
|
|
599
|
+
* // In module.exports = (srv) => { ... }:
|
|
600
|
+
* const { FertilizerBlend, AssignMaterialToBlend, UnitsOfMeasuresV1 } = srv.entities;
|
|
601
|
+
*
|
|
602
|
+
* // Transactional: group by key so each order is a separate titled document
|
|
603
|
+
* watchEntity(srv, FertilizerBlend, { groupByKey: 'orderID' });
|
|
604
|
+
* watchEntity(srv, AssignMaterialToBlend, { groupByKey: 'to_FertilizerBlend_orderID' });
|
|
605
|
+
*
|
|
606
|
+
* // Reference/lookup: send only on the first READ, then cache
|
|
607
|
+
* watchEntity(srv, UnitsOfMeasuresV1, { once: true });
|
|
608
|
+
*
|
|
609
|
+
* @param {object} srv
|
|
610
|
+
* @param {object} entity CDS entity from srv.entities
|
|
611
|
+
* @param {object} [options]
|
|
612
|
+
* @param {string} [options.groupByKey] Field to group rows by (one doc per unique value)
|
|
613
|
+
* @param {string} [options.labelPrefix] Custom document title prefix (default: entity name)
|
|
614
|
+
* @param {boolean} [options.once] Only send on the first READ
|
|
615
|
+
*/
|
|
616
|
+
module.exports.watchEntity = (srv, entity, options) =>
|
|
617
|
+
_watchEntity(srv, entity, { backendUrl, appId, token, appName }, options);
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Manually trigger a paginated background sync of all entities across all services.
|
|
621
|
+
* Useful for triggering a re-sync after bulk imports or migrations.
|
|
622
|
+
*
|
|
623
|
+
* @param {object} cds - @sap/cds instance
|
|
624
|
+
* @param {object[]} services - array of CDS service instances
|
|
625
|
+
* @param {object} [options]
|
|
626
|
+
* @param {number} [options.pageSize] rows per page (default: 100)
|
|
627
|
+
* @param {string} [options.modifiedSince] ISO timestamp — only sync rows newer than this
|
|
628
|
+
*/
|
|
629
|
+
module.exports.syncAll = (cds, services, options) =>
|
|
630
|
+
_syncAll(cds, services, { backendUrl, appId, token, appName }, options);
|