@mseep/bw-modeling-mcp 0.8.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/CHANGELOG.md +140 -0
- package/LICENSE +21 -0
- package/README.md +598 -0
- package/dist/bw-client.js +774 -0
- package/dist/index.js +2199 -0
- package/dist/tools/activation.js +171 -0
- package/dist/tools/adso.js +895 -0
- package/dist/tools/composite_provider.js +169 -0
- package/dist/tools/cp_components.js +347 -0
- package/dist/tools/dataflow.js +148 -0
- package/dist/tools/datasource.js +536 -0
- package/dist/tools/delete.js +22 -0
- package/dist/tools/dtp.js +602 -0
- package/dist/tools/infoarea.js +117 -0
- package/dist/tools/infoobject.js +447 -0
- package/dist/tools/infosource.js +225 -0
- package/dist/tools/processchain.js +154 -0
- package/dist/tools/processvariant.js +49 -0
- package/dist/tools/push.js +100 -0
- package/dist/tools/query.js +631 -0
- package/dist/tools/reporting.js +558 -0
- package/dist/tools/repository.js +84 -0
- package/dist/tools/request_monitor.js +174 -0
- package/dist/tools/roles.js +503 -0
- package/dist/tools/search.js +107 -0
- package/dist/tools/transformation.js +1392 -0
- package/package.json +51 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import https from 'https';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
const ECLIPSE_USER_AGENT = 'Eclipse/4.38.0.v20251201-0920 (win32; x86_64; Java 21.0.9) ADT/3.56.0 (devedition)';
|
|
6
|
+
// Media types for each BW object type (from BW/4HANA discovery)
|
|
7
|
+
// These hardcoded values serve as fallback defaults; loadMediaTypes() overwrites them at runtime.
|
|
8
|
+
export const MEDIA_TYPES = {
|
|
9
|
+
adso: 'application/vnd.sap.bw.modeling.adso-v1_7_0+xml',
|
|
10
|
+
iobj: 'application/vnd.sap-bw-modeling.iobj-v2_2_0+xml',
|
|
11
|
+
trfn: 'application/vnd.sap.bw.modeling.trfn-v1_0_0+xml',
|
|
12
|
+
dtpa: 'application/vnd.sap.bw.modeling.dtpa-v1_0_0+xml',
|
|
13
|
+
area: 'application/vnd.sap.bw.modeling.area-v1_1_0+xml',
|
|
14
|
+
trcs: 'application/vnd.sap.bw.modeling.trcs-v1_0_0+xml',
|
|
15
|
+
rsds: 'application/vnd.sap.bw.modeling.rsds-v1_1_0+xml',
|
|
16
|
+
};
|
|
17
|
+
// DTPs do not need an unlock request after activation
|
|
18
|
+
const NO_UNLOCK_TYPES = new Set(['dtpa']);
|
|
19
|
+
function resolveMediaType(type) {
|
|
20
|
+
const mt = MEDIA_TYPES[type.toLowerCase()];
|
|
21
|
+
if (!mt) {
|
|
22
|
+
throw new Error(`Object type '${type}' is not supported on this system (not found in Discovery)`);
|
|
23
|
+
}
|
|
24
|
+
return mt;
|
|
25
|
+
}
|
|
26
|
+
export class BwClient {
|
|
27
|
+
http;
|
|
28
|
+
csrfToken = null;
|
|
29
|
+
csrfTokenFetchedAt = 0;
|
|
30
|
+
// SAP sessions time out after ~5 minutes of inactivity; refresh the token before that.
|
|
31
|
+
static CSRF_TOKEN_TTL_MS = 4 * 60 * 1000;
|
|
32
|
+
cookies = new Map();
|
|
33
|
+
// Basic Auth is only sent during the initial CSRF fetch to establish the session.
|
|
34
|
+
// All subsequent requests use the session cookie only — sending Basic Auth on PUT
|
|
35
|
+
// causes SAP to create a new stateless session, invalidating the lock handle.
|
|
36
|
+
basicAuth;
|
|
37
|
+
frozenCookies = new Set();
|
|
38
|
+
constructor(url, user, password, client, language, initialCookies) {
|
|
39
|
+
this.basicAuth = (user && password)
|
|
40
|
+
? 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64')
|
|
41
|
+
: null;
|
|
42
|
+
// In cookie mode (e.g. BW Bridge): do not send sap-client and X-sap-adt-sessiontype
|
|
43
|
+
// as global defaults. BW Bridge rejects stateful requests with 401 when no
|
|
44
|
+
// pre-established backend session exists on the app instance pointed to by __VCAP_ID__.
|
|
45
|
+
const isCookieMode = initialCookies !== undefined;
|
|
46
|
+
this.http = axios.create({
|
|
47
|
+
baseURL: url,
|
|
48
|
+
headers: {
|
|
49
|
+
...(isCookieMode ? {} : {
|
|
50
|
+
'sap-client': client,
|
|
51
|
+
'X-sap-adt-sessiontype': 'stateful',
|
|
52
|
+
}),
|
|
53
|
+
...(language ? { 'sap-language': language } : {}),
|
|
54
|
+
},
|
|
55
|
+
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
|
56
|
+
validateStatus: () => true,
|
|
57
|
+
});
|
|
58
|
+
delete this.http.defaults.headers.post['Content-Type'];
|
|
59
|
+
delete this.http.defaults.headers.common['Content-Type'];
|
|
60
|
+
// Cookie mode: pre-populate cookie store from caller. Used for SAML/OAuth-fronted
|
|
61
|
+
// systems where Basic Auth is not available and cookies are exported from a browser.
|
|
62
|
+
if (initialCookies) {
|
|
63
|
+
for (const [name, value] of Object.entries(initialCookies)) {
|
|
64
|
+
this.cookies.set(name, value);
|
|
65
|
+
// Names from the cookie file are frozen — set-cookie responses must not overwrite them.
|
|
66
|
+
this.frozenCookies.add(name);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// ── Session info (debug) ──────────────────────────────────────────────────
|
|
71
|
+
/** Returns a snapshot of the current session cookies — for debug assertions only. */
|
|
72
|
+
sessionInfo() {
|
|
73
|
+
return Object.fromEntries(this.cookies.entries());
|
|
74
|
+
}
|
|
75
|
+
// ── Cookie management ──────────────────────────────────────────────────────
|
|
76
|
+
cookieHeader() {
|
|
77
|
+
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
78
|
+
}
|
|
79
|
+
updateCookies(response) {
|
|
80
|
+
const setCookies = response.headers['set-cookie'];
|
|
81
|
+
if (!setCookies)
|
|
82
|
+
return;
|
|
83
|
+
for (const c of setCookies) {
|
|
84
|
+
const part = c.split(';')[0];
|
|
85
|
+
const eqIdx = part.indexOf('=');
|
|
86
|
+
if (eqIdx > 0) {
|
|
87
|
+
const name = part.substring(0, eqIdx).trim();
|
|
88
|
+
if (this.frozenCookies.has(name))
|
|
89
|
+
continue;
|
|
90
|
+
this.cookies.set(name, part.substring(eqIdx + 1).trim());
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// ── CSRF token ─────────────────────────────────────────────────────────────
|
|
95
|
+
async fetchCsrfToken() {
|
|
96
|
+
const response = await this.http.get('/sap/bw/modeling/repo/is/systeminfo', {
|
|
97
|
+
headers: {
|
|
98
|
+
'X-CSRF-Token': 'Fetch',
|
|
99
|
+
Accept: 'application/xml',
|
|
100
|
+
...(this.basicAuth ? { Authorization: this.basicAuth } : {}),
|
|
101
|
+
...this.cookieHeaders(),
|
|
102
|
+
},
|
|
103
|
+
responseType: 'text',
|
|
104
|
+
});
|
|
105
|
+
this.updateCookies(response);
|
|
106
|
+
const token = response.headers['x-csrf-token'];
|
|
107
|
+
if (!token || token.toLowerCase() === 'fetch') {
|
|
108
|
+
if (this.basicAuth) {
|
|
109
|
+
throw new Error(`Failed to fetch CSRF token (HTTP ${response.status}). Check BW_URL, BW_USER, BW_PASSWORD, BW_CLIENT.`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
throw new Error(`Failed to fetch CSRF token (HTTP ${response.status}). ` +
|
|
113
|
+
`Cookie mode in use — refresh cookies in BW_COOKIE_FILE and restart the MCP server.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
this.csrfToken = token;
|
|
117
|
+
this.csrfTokenFetchedAt = Date.now();
|
|
118
|
+
}
|
|
119
|
+
async ensureCsrf() {
|
|
120
|
+
const stale = !this.csrfToken ||
|
|
121
|
+
(Date.now() - this.csrfTokenFetchedAt) > BwClient.CSRF_TOKEN_TTL_MS;
|
|
122
|
+
if (stale) {
|
|
123
|
+
await this.fetchCsrfToken();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
clearCsrfToken() {
|
|
127
|
+
this.csrfToken = null;
|
|
128
|
+
}
|
|
129
|
+
cookieHeaders() {
|
|
130
|
+
const hdr = this.cookieHeader();
|
|
131
|
+
return hdr ? { Cookie: hdr } : {};
|
|
132
|
+
}
|
|
133
|
+
// ── Public HTTP helpers ────────────────────────────────────────────────────
|
|
134
|
+
async get(path, accept) {
|
|
135
|
+
await this.ensureCsrf();
|
|
136
|
+
const IOBJ_ACCEPT_ALL = 'application/vnd.sap-bw-modeling.iobj-v1_0_0+xml, application/vnd.sap-bw-modeling.iobj-v1_1_0+xml, application/vnd.sap-bw-modeling.iobj-v1_2_0+xml, application/vnd.sap-bw-modeling.iobj-v1_3_0+xml, application/vnd.sap-bw-modeling.iobj-v1_4_0+xml, application/vnd.sap-bw-modeling.iobj-v1_5_0+xml, application/vnd.sap-bw-modeling.iobj-v1_6_0+xml, application/vnd.sap-bw-modeling.iobj-v1_7_0+xml, application/vnd.sap-bw-modeling.iobj-v1_8_0+xml, application/vnd.sap-bw-modeling.iobj-v1_9_0+xml, application/vnd.sap-bw-modeling.iobj-v2_0_0+xml, application/vnd.sap-bw-modeling.iobj-v2_1_0+xml, application/vnd.sap-bw-modeling.iobj-v2_2_0+xml, application/vnd.sap-bw-modeling.iobj-v2_3_0+xml, application/vnd.sap-bw-modeling.iobj-v2_4_0+xml';
|
|
137
|
+
const resolvedAccept = accept.includes('iobj') ? IOBJ_ACCEPT_ALL : `application/xml, ${accept}`;
|
|
138
|
+
const response = await this.http.get(path, {
|
|
139
|
+
headers: {
|
|
140
|
+
Accept: resolvedAccept,
|
|
141
|
+
'bwmt-level': '50',
|
|
142
|
+
'X-CSRF-Token': this.csrfToken,
|
|
143
|
+
...this.cookieHeaders(),
|
|
144
|
+
},
|
|
145
|
+
responseType: 'text',
|
|
146
|
+
transformResponse: [(data) => data],
|
|
147
|
+
});
|
|
148
|
+
this.updateCookies(response);
|
|
149
|
+
if (response.status >= 400) {
|
|
150
|
+
throw new Error(`GET ${path} → HTTP ${response.status}\n${response.data}`);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
body: response.data,
|
|
154
|
+
headers: response.headers,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Lock a BW object.
|
|
159
|
+
* Returns the lockHandle string from the response body.
|
|
160
|
+
* Pattern: POST /sap/bw/modeling/{type}/{name}?action=lock
|
|
161
|
+
*
|
|
162
|
+
* extraHeaders: optional additional headers, e.g. for creation mode:
|
|
163
|
+
* { 'activity_context': 'CREA', 'parent_name': 'MYAREA', 'parent_type': 'AREA' }
|
|
164
|
+
*/
|
|
165
|
+
async lock(type, name, extraHeaders, sessionType, cleanHeaders) {
|
|
166
|
+
await this.ensureCsrf();
|
|
167
|
+
const accept = type.toLowerCase() === 'area'
|
|
168
|
+
? 'application/vnd.sap.bw.modeling.area-v1_0_0+xml, application/vnd.sap.bw.modeling.area-v1_1_0+xml'
|
|
169
|
+
: resolveMediaType(type);
|
|
170
|
+
const headers = cleanHeaders
|
|
171
|
+
? {
|
|
172
|
+
Accept: accept,
|
|
173
|
+
'User-Agent': ECLIPSE_USER_AGENT,
|
|
174
|
+
'X-sap-adt-profiling': 'server-time',
|
|
175
|
+
'sap-adt-request-id': randomUUID(),
|
|
176
|
+
'X-CSRF-Token': this.csrfToken,
|
|
177
|
+
...this.cookieHeaders(),
|
|
178
|
+
...extraHeaders,
|
|
179
|
+
'Content-Type': undefined,
|
|
180
|
+
'bwmt-level': undefined,
|
|
181
|
+
'X-sap-adt-sessiontype': undefined,
|
|
182
|
+
'sap-client': undefined,
|
|
183
|
+
'sap-language': undefined,
|
|
184
|
+
}
|
|
185
|
+
: {
|
|
186
|
+
Accept: accept,
|
|
187
|
+
'bwmt-level': '50',
|
|
188
|
+
'X-CSRF-Token': this.csrfToken,
|
|
189
|
+
...this.cookieHeaders(),
|
|
190
|
+
...(sessionType ? { 'X-sap-adt-sessiontype': sessionType } : {}),
|
|
191
|
+
...extraHeaders,
|
|
192
|
+
};
|
|
193
|
+
const response = await this.http.post(`/sap/bw/modeling/${type.toLowerCase()}/${name.toLowerCase()}?action=lock`, '', {
|
|
194
|
+
headers,
|
|
195
|
+
responseType: 'text',
|
|
196
|
+
});
|
|
197
|
+
this.updateCookies(response);
|
|
198
|
+
if (response.status >= 400) {
|
|
199
|
+
throw new Error(`Lock ${type}/${name} → HTTP ${response.status}\n${response.data}`);
|
|
200
|
+
}
|
|
201
|
+
const body = response.data;
|
|
202
|
+
// lockHandle is in <LOCK_HANDLE>...</LOCK_HANDLE> in the response body
|
|
203
|
+
const match = body.match(/<LOCK_HANDLE>([^<]+)<\/LOCK_HANDLE>/);
|
|
204
|
+
if (!match) {
|
|
205
|
+
throw new Error(`No <LOCK_HANDLE> in lock response body:\n${body}`);
|
|
206
|
+
}
|
|
207
|
+
return match[1];
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Create a new BW object (POST, no /m in the URL).
|
|
211
|
+
* Pattern: POST /sap/bw/modeling/{type}/{name}?lockHandle={handle}
|
|
212
|
+
* Used for object creation from template; the lock must have been obtained
|
|
213
|
+
* with activity_context=CREA headers.
|
|
214
|
+
*
|
|
215
|
+
* extraHeaders: e.g. { 'Development-Class': '$TMP' }
|
|
216
|
+
*/
|
|
217
|
+
async create(type, name, lockHandle, body, extraHeaders) {
|
|
218
|
+
await this.ensureCsrf();
|
|
219
|
+
const mediaType = resolveMediaType(type);
|
|
220
|
+
const path = `/sap/bw/modeling/${type.toLowerCase()}/${name.toLowerCase()}?lockHandle=${lockHandle}`;
|
|
221
|
+
const response = await this.http.post(path, body, {
|
|
222
|
+
headers: {
|
|
223
|
+
'Content-Type': `application/xml, ${mediaType}`,
|
|
224
|
+
Accept: mediaType,
|
|
225
|
+
'X-CSRF-Token': this.csrfToken,
|
|
226
|
+
...this.cookieHeaders(),
|
|
227
|
+
...extraHeaders,
|
|
228
|
+
},
|
|
229
|
+
responseType: 'text',
|
|
230
|
+
});
|
|
231
|
+
this.updateCookies(response);
|
|
232
|
+
this.csrfToken = null;
|
|
233
|
+
if (response.status >= 400) {
|
|
234
|
+
throw new Error(`POST ${path} → HTTP ${response.status}\n${response.data}`);
|
|
235
|
+
}
|
|
236
|
+
return response.data;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* PUT (create/update) a BW object in its inactive version.
|
|
240
|
+
* Pattern: PUT /sap/bw/modeling/{type}/{name}/m?lockHandle={handle}
|
|
241
|
+
* Always sends the complete object XML.
|
|
242
|
+
*/
|
|
243
|
+
async put(type, name, lockHandle, body, timestamp, corrNr, transportLockHolder) {
|
|
244
|
+
await this.ensureCsrf();
|
|
245
|
+
const mediaType = resolveMediaType(type);
|
|
246
|
+
const corrNrPrefix = corrNr ? `corrNr=${corrNr}&` : '';
|
|
247
|
+
const path = `/sap/bw/modeling/${type.toLowerCase()}/${name.toLowerCase()}/m?${corrNrPrefix}lockHandle=${lockHandle}`;
|
|
248
|
+
const response = await this.http.put(path, body, {
|
|
249
|
+
headers: {
|
|
250
|
+
'Content-Type': `application/xml, ${mediaType}`,
|
|
251
|
+
Accept: mediaType,
|
|
252
|
+
'X-CSRF-Token': this.csrfToken,
|
|
253
|
+
...this.cookieHeaders(),
|
|
254
|
+
...(timestamp ? { timestamp } : {}),
|
|
255
|
+
...(transportLockHolder ? { 'Transport-Lock-Holder': transportLockHolder } : {}),
|
|
256
|
+
},
|
|
257
|
+
responseType: 'text',
|
|
258
|
+
});
|
|
259
|
+
this.updateCookies(response);
|
|
260
|
+
this.csrfToken = null;
|
|
261
|
+
if (response.status >= 400) {
|
|
262
|
+
throw new Error(`PUT ${path} → HTTP ${response.status}\n${response.data}`);
|
|
263
|
+
}
|
|
264
|
+
return response.data;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Lock a BW object for deletion.
|
|
268
|
+
* Differs from normal lock: URL includes /m before ?action=lock.
|
|
269
|
+
* Pattern: POST /sap/bw/modeling/{type}/{name}/m?action=lock
|
|
270
|
+
*/
|
|
271
|
+
async lockForDelete(type, name, mediaType) {
|
|
272
|
+
await this.ensureCsrf();
|
|
273
|
+
const response = await this.http.post(`/sap/bw/modeling/${type.toLowerCase()}/${name.toLowerCase()}/m?action=lock`, '', {
|
|
274
|
+
headers: {
|
|
275
|
+
Accept: mediaType,
|
|
276
|
+
'bwmt-level': '50',
|
|
277
|
+
'X-CSRF-Token': this.csrfToken,
|
|
278
|
+
...this.cookieHeaders(),
|
|
279
|
+
},
|
|
280
|
+
responseType: 'text',
|
|
281
|
+
});
|
|
282
|
+
this.updateCookies(response);
|
|
283
|
+
if (response.status >= 400) {
|
|
284
|
+
throw new Error(`Delete-lock ${type}/${name} → HTTP ${response.status}\n${response.data}`);
|
|
285
|
+
}
|
|
286
|
+
const body = response.data;
|
|
287
|
+
const match = body.match(/<LOCK_HANDLE>([^<]+)<\/LOCK_HANDLE>/);
|
|
288
|
+
if (!match) {
|
|
289
|
+
throw new Error(`No <LOCK_HANDLE> in delete-lock response:\n${body}`);
|
|
290
|
+
}
|
|
291
|
+
return match[1];
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Delete a BW object.
|
|
295
|
+
* Pattern: DELETE /sap/bw/modeling/{type}/{name}/m?lockHandle={handle}
|
|
296
|
+
* Lock URL uses /m: POST /sap/bw/modeling/{type}/{name}/m?action=lock
|
|
297
|
+
*/
|
|
298
|
+
async delete(type, name, lockHandle, mediaType) {
|
|
299
|
+
await this.ensureCsrf();
|
|
300
|
+
const path = `/sap/bw/modeling/${type.toLowerCase()}/${name.toLowerCase()}/m?lockHandle=${lockHandle}`;
|
|
301
|
+
const response = await this.http.delete(path, {
|
|
302
|
+
headers: {
|
|
303
|
+
'Content-Type': mediaType,
|
|
304
|
+
Accept: mediaType,
|
|
305
|
+
'X-CSRF-Token': this.csrfToken,
|
|
306
|
+
...this.cookieHeaders(),
|
|
307
|
+
},
|
|
308
|
+
responseType: 'text',
|
|
309
|
+
});
|
|
310
|
+
this.updateCookies(response);
|
|
311
|
+
this.csrfToken = null;
|
|
312
|
+
if (response.status >= 400) {
|
|
313
|
+
throw new Error(`DELETE ${path} → HTTP ${response.status}\n${response.data}`);
|
|
314
|
+
}
|
|
315
|
+
return response.data;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Activate one BW object.
|
|
319
|
+
* Pattern: POST /sap/bw/modeling/activation
|
|
320
|
+
* lockHandle is empty string for DTP activation.
|
|
321
|
+
*/
|
|
322
|
+
async activate(type, name, lockHandle, corrNr, sourceSystem) {
|
|
323
|
+
await this.ensureCsrf();
|
|
324
|
+
const mediaType = resolveMediaType(type);
|
|
325
|
+
const typeLower = type.toLowerCase();
|
|
326
|
+
// RSDS (DataSource) has a compound key (DataSource + source system) and uses an
|
|
327
|
+
// uppercase two-segment URI. All other types use the single-segment lowercase URI.
|
|
328
|
+
const href = typeLower === 'rsds'
|
|
329
|
+
? `/sap/bw/modeling/rsds/${name.toUpperCase()}/${(sourceSystem ?? '').toUpperCase()}/m`
|
|
330
|
+
: `/sap/bw/modeling/${typeLower}/${name.toLowerCase()}/m`;
|
|
331
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
332
|
+
<atom:feed xmlns:atom="http://www.w3.org/2005/Atom" xmlns:bwModel="http://www.sap.com/bw/modeling">
|
|
333
|
+
<atom:entry>
|
|
334
|
+
<atom:content type="${mediaType}">
|
|
335
|
+
<bwModel:checkProperties version="inactive" modelContent="" lockHandle="${lockHandle}"/>
|
|
336
|
+
</atom:content>
|
|
337
|
+
<atom:link href="${href}" type="application/*" rel="self"/>
|
|
338
|
+
</atom:entry>
|
|
339
|
+
</atom:feed>`;
|
|
340
|
+
const corrNrParam = corrNr ? `?corrNr=${corrNr}` : '';
|
|
341
|
+
const response = await this.http.post(`/sap/bw/modeling/activation${corrNrParam}`, body, {
|
|
342
|
+
headers: {
|
|
343
|
+
'Content-Type': 'application/atom+xml;type=entry',
|
|
344
|
+
Accept: 'application/atom+xml;type=feed',
|
|
345
|
+
'X-CSRF-Token': this.csrfToken,
|
|
346
|
+
...this.cookieHeaders(),
|
|
347
|
+
},
|
|
348
|
+
responseType: 'text',
|
|
349
|
+
});
|
|
350
|
+
this.updateCookies(response);
|
|
351
|
+
this.csrfToken = null;
|
|
352
|
+
if (response.status >= 400) {
|
|
353
|
+
throw new Error(`Activation of ${type}/${name} → HTTP ${response.status}\n${response.data}`);
|
|
354
|
+
}
|
|
355
|
+
return response.data;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Generic POST to an arbitrary BW modeling path.
|
|
359
|
+
* Used for endpoints that don't follow the lock/create/unlock pattern
|
|
360
|
+
* (e.g. move_requests).
|
|
361
|
+
*/
|
|
362
|
+
/**
|
|
363
|
+
* Like postRaw, but skips ensureCsrf() and uses the already-held CSRF token.
|
|
364
|
+
* Throws if no token is available (caller must have triggered a CSRF fetch beforehand,
|
|
365
|
+
* e.g. via lock()).
|
|
366
|
+
*/
|
|
367
|
+
async postWithCsrf(path, body, contentType, extraHeaders, stripInstanceHeaders) {
|
|
368
|
+
if (!this.csrfToken) {
|
|
369
|
+
throw new Error('postWithCsrf: no CSRF token available. A prior lock() or get() must have established one.');
|
|
370
|
+
}
|
|
371
|
+
const response = await this.http.post(path, Buffer.from(body, 'utf-8'), {
|
|
372
|
+
headers: {
|
|
373
|
+
'Content-Type': contentType,
|
|
374
|
+
Accept: contentType,
|
|
375
|
+
'X-CSRF-Token': this.csrfToken,
|
|
376
|
+
...this.cookieHeaders(),
|
|
377
|
+
...extraHeaders,
|
|
378
|
+
...(stripInstanceHeaders ? {
|
|
379
|
+
'User-Agent': ECLIPSE_USER_AGENT,
|
|
380
|
+
'X-sap-adt-profiling': 'server-time',
|
|
381
|
+
'sap-adt-request-id': randomUUID(),
|
|
382
|
+
'bwmt-level': undefined,
|
|
383
|
+
'X-sap-adt-sessiontype': undefined,
|
|
384
|
+
'sap-client': undefined,
|
|
385
|
+
'sap-language': undefined,
|
|
386
|
+
} : {}),
|
|
387
|
+
},
|
|
388
|
+
responseType: 'text',
|
|
389
|
+
});
|
|
390
|
+
this.updateCookies(response);
|
|
391
|
+
this.csrfToken = null;
|
|
392
|
+
if (response.status >= 400) {
|
|
393
|
+
throw new Error(`POST ${path} → HTTP ${response.status}\n${response.data}`);
|
|
394
|
+
}
|
|
395
|
+
return response.data;
|
|
396
|
+
}
|
|
397
|
+
async postRaw(path, body, contentType, extraHeaders) {
|
|
398
|
+
await this.ensureCsrf();
|
|
399
|
+
const response = await this.http.post(path, body, {
|
|
400
|
+
headers: {
|
|
401
|
+
'Content-Type': contentType,
|
|
402
|
+
'X-CSRF-Token': this.csrfToken,
|
|
403
|
+
...this.cookieHeaders(),
|
|
404
|
+
...extraHeaders,
|
|
405
|
+
},
|
|
406
|
+
responseType: 'text',
|
|
407
|
+
});
|
|
408
|
+
this.updateCookies(response);
|
|
409
|
+
this.csrfToken = null;
|
|
410
|
+
if (response.status >= 400) {
|
|
411
|
+
throw new Error(`POST ${path} → HTTP ${response.status}\n${response.data}`);
|
|
412
|
+
}
|
|
413
|
+
return response.data;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Unlock a BW object after activation.
|
|
417
|
+
* DTPs (dtpa) are skipped — they require no unlock.
|
|
418
|
+
* Pattern: POST /sap/bw/modeling/{type}/{name}?action=unlock
|
|
419
|
+
*/
|
|
420
|
+
/**
|
|
421
|
+
* Returns the current CSRF token, fetching it first if needed.
|
|
422
|
+
* Callers that need to pass the token explicitly (e.g. rawPost) use this.
|
|
423
|
+
*/
|
|
424
|
+
async getCsrfToken() {
|
|
425
|
+
await this.ensureCsrf();
|
|
426
|
+
return this.csrfToken;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* POST with a completely clean axios instance — no default headers at all.
|
|
430
|
+
* Only sends Authorization (Basic Auth) + Cookie (session continuity) + the
|
|
431
|
+
* headers explicitly passed by the caller. Nothing else.
|
|
432
|
+
*
|
|
433
|
+
* Use this when you need to control the exact wire headers (e.g. for
|
|
434
|
+
* Transformation creation where Eclipse sends a very specific header set).
|
|
435
|
+
*/
|
|
436
|
+
async rawPost(url, body, headers) {
|
|
437
|
+
const freshHttp = axios.create({
|
|
438
|
+
baseURL: this.http.defaults.baseURL,
|
|
439
|
+
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
|
440
|
+
validateStatus: () => true,
|
|
441
|
+
// Wipe every axios default-header bucket so nothing leaks through
|
|
442
|
+
headers: { common: {}, get: {}, post: {}, put: {}, patch: {}, delete: {}, head: {} },
|
|
443
|
+
});
|
|
444
|
+
const cookieHdr = this.cookieHeader();
|
|
445
|
+
const response = await freshHttp.post(url, body, {
|
|
446
|
+
headers: {
|
|
447
|
+
...(this.basicAuth ? { Authorization: this.basicAuth } : {}),
|
|
448
|
+
...(cookieHdr ? { Cookie: cookieHdr } : {}),
|
|
449
|
+
...headers,
|
|
450
|
+
},
|
|
451
|
+
responseType: 'text',
|
|
452
|
+
});
|
|
453
|
+
this.updateCookies(response);
|
|
454
|
+
if (response.status >= 400) {
|
|
455
|
+
throw new Error(`POST ${url} → HTTP ${response.status}\n${response.data}`);
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
body: response.data,
|
|
459
|
+
headers: response.headers,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* GET to an arbitrary path using the shared session.
|
|
464
|
+
* Passes the CSRF token and session cookies; the caller controls all other headers.
|
|
465
|
+
* Use this for endpoints that need custom Accept or non-standard request headers.
|
|
466
|
+
*/
|
|
467
|
+
async rawGet(url, headers) {
|
|
468
|
+
await this.ensureCsrf();
|
|
469
|
+
const response = await this.http.get(url, {
|
|
470
|
+
headers: {
|
|
471
|
+
'X-CSRF-Token': this.csrfToken,
|
|
472
|
+
...this.cookieHeaders(),
|
|
473
|
+
...headers,
|
|
474
|
+
},
|
|
475
|
+
responseType: 'text',
|
|
476
|
+
transformResponse: [(data) => data],
|
|
477
|
+
});
|
|
478
|
+
this.updateCookies(response);
|
|
479
|
+
if (response.status >= 400) {
|
|
480
|
+
throw new Error(`GET ${url} → HTTP ${response.status}\n${response.data}`);
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
body: response.data,
|
|
484
|
+
headers: response.headers,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* PUT to an arbitrary path with a clean axios instance.
|
|
489
|
+
* Caller must supply the CSRF token (obtained via getCsrfToken() after any rawGet call).
|
|
490
|
+
*/
|
|
491
|
+
async rawPut(url, body, headers) {
|
|
492
|
+
const freshHttp = axios.create({
|
|
493
|
+
baseURL: this.http.defaults.baseURL,
|
|
494
|
+
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
|
495
|
+
validateStatus: () => true,
|
|
496
|
+
headers: { common: {}, get: {}, post: {}, put: {}, patch: {}, delete: {}, head: {} },
|
|
497
|
+
});
|
|
498
|
+
const cookieHdr = this.cookieHeader();
|
|
499
|
+
const response = await freshHttp.put(url, body, {
|
|
500
|
+
headers: {
|
|
501
|
+
...(this.basicAuth ? { Authorization: this.basicAuth } : {}),
|
|
502
|
+
...(cookieHdr ? { Cookie: cookieHdr } : {}),
|
|
503
|
+
...headers,
|
|
504
|
+
},
|
|
505
|
+
responseType: 'text',
|
|
506
|
+
});
|
|
507
|
+
this.updateCookies(response);
|
|
508
|
+
if (response.status >= 400) {
|
|
509
|
+
throw new Error(`PUT ${url} → HTTP ${response.status}\n${response.data}`);
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
body: response.data,
|
|
513
|
+
headers: response.headers,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* DELETE to an arbitrary path with a clean axios instance.
|
|
518
|
+
* Fetches CSRF token automatically.
|
|
519
|
+
*/
|
|
520
|
+
async rawDelete(url, headers) {
|
|
521
|
+
const csrfToken = await this.getCsrfToken();
|
|
522
|
+
const freshHttp = axios.create({
|
|
523
|
+
baseURL: this.http.defaults.baseURL,
|
|
524
|
+
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
|
525
|
+
validateStatus: () => true,
|
|
526
|
+
headers: { common: {}, get: {}, post: {}, put: {}, patch: {}, delete: {}, head: {} },
|
|
527
|
+
});
|
|
528
|
+
const cookieHdr = this.cookieHeader();
|
|
529
|
+
const response = await freshHttp.delete(url, {
|
|
530
|
+
headers: {
|
|
531
|
+
...(this.basicAuth ? { Authorization: this.basicAuth } : {}),
|
|
532
|
+
...(cookieHdr ? { Cookie: cookieHdr } : {}),
|
|
533
|
+
'x-csrf-token': csrfToken,
|
|
534
|
+
...headers,
|
|
535
|
+
},
|
|
536
|
+
responseType: 'text',
|
|
537
|
+
});
|
|
538
|
+
this.updateCookies(response);
|
|
539
|
+
this.csrfToken = null;
|
|
540
|
+
if (response.status >= 400) {
|
|
541
|
+
throw new Error(`DELETE ${url} → HTTP ${response.status}\n${response.data}`);
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
body: response.data,
|
|
545
|
+
headers: response.headers,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Fetch the BW modeling discovery document and populate MEDIA_TYPES at runtime.
|
|
550
|
+
* Entries returned by the server overwrite the hardcoded fallback defaults.
|
|
551
|
+
* Entries not returned by the server are left unchanged.
|
|
552
|
+
*/
|
|
553
|
+
async loadMediaTypes() {
|
|
554
|
+
const response = await this.http.get('/sap/bw/modeling/discovery', {
|
|
555
|
+
headers: {
|
|
556
|
+
Accept: 'application/atomsvc+xml',
|
|
557
|
+
...(this.basicAuth ? { Authorization: this.basicAuth } : {}),
|
|
558
|
+
...this.cookieHeaders(),
|
|
559
|
+
},
|
|
560
|
+
responseType: 'text',
|
|
561
|
+
});
|
|
562
|
+
this.updateCookies(response);
|
|
563
|
+
if (response.status >= 400) {
|
|
564
|
+
throw new Error(`Discovery GET → HTTP ${response.status}\n${response.data}`);
|
|
565
|
+
}
|
|
566
|
+
const xml = response.data;
|
|
567
|
+
// A single <app:collection> publishes one OR MORE <app:accept> media types,
|
|
568
|
+
// mixing +xml/+json and bw/bw4 namespaces. Split on collection start tags so
|
|
569
|
+
// every accept is attributed to its own collection: the previous single-regex
|
|
570
|
+
// approach captured only the first accept per collection, which silently
|
|
571
|
+
// skipped the +xml variant whenever a +json entry was listed first (e.g. iobj).
|
|
572
|
+
const extractVersion = (mt) => {
|
|
573
|
+
const m = mt.match(/-v(\d+)_(\d+)_(\d+)\+xml$/);
|
|
574
|
+
return m ? parseInt(m[1]) * 10000 + parseInt(m[2]) * 100 + parseInt(m[3]) : 0;
|
|
575
|
+
};
|
|
576
|
+
const segments = xml.split(/(?=<app:collection\s)/);
|
|
577
|
+
for (const segment of segments) {
|
|
578
|
+
const hrefMatch = segment.match(/^<app:collection\b[^>]*?\shref="([^"]+)"/);
|
|
579
|
+
if (!hrefMatch)
|
|
580
|
+
continue;
|
|
581
|
+
// Extract last URL segment as the key (e.g. ".../adso" → "adso")
|
|
582
|
+
const key = hrefMatch[1].split('/').pop()?.toLowerCase();
|
|
583
|
+
if (!key)
|
|
584
|
+
continue;
|
|
585
|
+
// Consider only versioned XML modeling media types ("...-vX_Y_Z+xml").
|
|
586
|
+
// Sub-resource accepts (e.g. "jobs.job+xml") and +json variants score 0.
|
|
587
|
+
const versioned = [...segment.matchAll(/<app:accept>([^<]+)<\/app:accept>/g)]
|
|
588
|
+
.map((a) => a[1].trim())
|
|
589
|
+
.filter((mt) => extractVersion(mt) > 0);
|
|
590
|
+
if (versioned.length === 0)
|
|
591
|
+
continue;
|
|
592
|
+
const best = versioned.reduce((a, b) => (extractVersion(b) >= extractVersion(a) ? b : a));
|
|
593
|
+
const existing = MEDIA_TYPES[key];
|
|
594
|
+
// Only update if the discovered version is >= the existing one (never downgrade).
|
|
595
|
+
if (!existing || extractVersion(best) >= extractVersion(existing)) {
|
|
596
|
+
MEDIA_TYPES[key] = best;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
process.stderr.write(`[bw-modeling-mcp] Loaded media types from discovery: ${JSON.stringify(MEDIA_TYPES)}\n`);
|
|
600
|
+
}
|
|
601
|
+
// ── ADT class write flow (ABAP runtime only) ──────────────────────────────
|
|
602
|
+
/** GET the ABAP class source (working area). Returns null if class does not exist yet (404). */
|
|
603
|
+
async adtGetSource(classEncoded) {
|
|
604
|
+
const token = await this.getCsrfToken();
|
|
605
|
+
const response = await this.http.get(`/sap/bc/adt/oo/classes/${classEncoded}/source/main?version=workingArea`, {
|
|
606
|
+
headers: {
|
|
607
|
+
Accept: 'text/plain',
|
|
608
|
+
'X-CSRF-Token': token,
|
|
609
|
+
...this.cookieHeaders(),
|
|
610
|
+
},
|
|
611
|
+
responseType: 'text',
|
|
612
|
+
transformResponse: [(data) => data],
|
|
613
|
+
});
|
|
614
|
+
this.updateCookies(response);
|
|
615
|
+
if (response.status === 404) {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
if (response.status >= 400) {
|
|
619
|
+
throw new Error(`ADT GET source ${classEncoded} → HTTP ${response.status}\n${response.data}`);
|
|
620
|
+
}
|
|
621
|
+
return response.data;
|
|
622
|
+
}
|
|
623
|
+
/** Lock the ABAP class for editing. Returns the ADT lock handle. */
|
|
624
|
+
async adtLockClass(classEncoded) {
|
|
625
|
+
const token = await this.getCsrfToken();
|
|
626
|
+
const response = await this.http.post(`/sap/bc/adt/oo/classes/${classEncoded}?_action=LOCK&accessMode=MODIFY`, '', {
|
|
627
|
+
headers: {
|
|
628
|
+
Accept: 'application/vnd.sap.as+xml;charset=UTF-8;dataname=com.sap.adt.lock.result;q=0.8,' +
|
|
629
|
+
'application/vnd.sap.as+xml;charset=UTF-8;dataname=com.sap.adt.lock.result2;q=0.9',
|
|
630
|
+
'X-CSRF-Token': token,
|
|
631
|
+
...this.cookieHeaders(),
|
|
632
|
+
},
|
|
633
|
+
responseType: 'text',
|
|
634
|
+
});
|
|
635
|
+
this.updateCookies(response);
|
|
636
|
+
if (response.status >= 400) {
|
|
637
|
+
throw new Error(`ADT LOCK ${classEncoded} → HTTP ${response.status}\n${response.data}`);
|
|
638
|
+
}
|
|
639
|
+
const body = response.data;
|
|
640
|
+
const match = body.match(/<LOCK_HANDLE>([^<]+)<\/LOCK_HANDLE>/);
|
|
641
|
+
if (!match) {
|
|
642
|
+
throw new Error(`No <LOCK_HANDLE> in ADT lock response:\n${body}`);
|
|
643
|
+
}
|
|
644
|
+
return match[1];
|
|
645
|
+
}
|
|
646
|
+
/** PUT updated ABAP class source. */
|
|
647
|
+
async adtPutSource(classEncoded, lockHandle, source) {
|
|
648
|
+
const token = await this.getCsrfToken();
|
|
649
|
+
const response = await this.http.put(`/sap/bc/adt/oo/classes/${classEncoded}/source/main?lockHandle=${lockHandle}`, source, {
|
|
650
|
+
headers: {
|
|
651
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
652
|
+
Accept: 'text/plain',
|
|
653
|
+
'X-CSRF-Token': token,
|
|
654
|
+
...this.cookieHeaders(),
|
|
655
|
+
},
|
|
656
|
+
responseType: 'text',
|
|
657
|
+
});
|
|
658
|
+
this.updateCookies(response);
|
|
659
|
+
this.csrfToken = null;
|
|
660
|
+
if (response.status >= 400) {
|
|
661
|
+
throw new Error(`ADT PUT source ${classEncoded} → HTTP ${response.status}\n${response.data}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/** Activate the ABAP class via ADT. */
|
|
665
|
+
async adtActivate(classEncoded, classNameUpper) {
|
|
666
|
+
await this.ensureCsrf();
|
|
667
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?>` +
|
|
668
|
+
`<adtcore:objectReferences xmlns:adtcore="http://www.sap.com/adt/core">` +
|
|
669
|
+
`<adtcore:objectReference` +
|
|
670
|
+
` adtcore:uri="/sap/bc/adt/oo/classes/${classEncoded}"` +
|
|
671
|
+
` adtcore:name="${classNameUpper}"/>` +
|
|
672
|
+
`</adtcore:objectReferences>`;
|
|
673
|
+
const response = await this.http.post('/sap/bc/adt/activation?method=activate&preauditRequested=true', body, {
|
|
674
|
+
headers: {
|
|
675
|
+
'Content-Type': 'application/xml',
|
|
676
|
+
Accept: 'application/xml',
|
|
677
|
+
'X-CSRF-Token': this.csrfToken,
|
|
678
|
+
...this.cookieHeaders(),
|
|
679
|
+
},
|
|
680
|
+
responseType: 'text',
|
|
681
|
+
});
|
|
682
|
+
this.updateCookies(response);
|
|
683
|
+
this.csrfToken = null;
|
|
684
|
+
if (response.status >= 400) {
|
|
685
|
+
throw new Error(`ADT activate ${classEncoded} → HTTP ${response.status}\n${response.data}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/** Unlock the ABAP class after editing. */
|
|
689
|
+
async adtUnlockClass(classEncoded, lockHandle) {
|
|
690
|
+
await this.ensureCsrf();
|
|
691
|
+
const response = await this.http.post(`/sap/bc/adt/oo/classes/${classEncoded}?_action=UNLOCK&lockHandle=${lockHandle}`, '', {
|
|
692
|
+
headers: {
|
|
693
|
+
'X-CSRF-Token': this.csrfToken,
|
|
694
|
+
...this.cookieHeaders(),
|
|
695
|
+
},
|
|
696
|
+
responseType: 'text',
|
|
697
|
+
});
|
|
698
|
+
this.updateCookies(response);
|
|
699
|
+
this.csrfToken = null;
|
|
700
|
+
if (response.status >= 400) {
|
|
701
|
+
throw new Error(`ADT UNLOCK ${classEncoded} → HTTP ${response.status}\n${response.data}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
async unlock(type, name) {
|
|
705
|
+
if (NO_UNLOCK_TYPES.has(type.toLowerCase()))
|
|
706
|
+
return;
|
|
707
|
+
await this.ensureCsrf();
|
|
708
|
+
const mediaType = resolveMediaType(type);
|
|
709
|
+
const response = await this.http.post(`/sap/bw/modeling/${type.toLowerCase()}/${name.toLowerCase()}?action=unlock`, '', {
|
|
710
|
+
headers: {
|
|
711
|
+
'Content-Type': mediaType,
|
|
712
|
+
'X-CSRF-Token': this.csrfToken,
|
|
713
|
+
...this.cookieHeaders(),
|
|
714
|
+
},
|
|
715
|
+
responseType: 'text',
|
|
716
|
+
});
|
|
717
|
+
this.updateCookies(response);
|
|
718
|
+
this.csrfToken = null;
|
|
719
|
+
if (response.status >= 400) {
|
|
720
|
+
throw new Error(`UNLOCK ${type.toUpperCase()} ${name} → HTTP ${response.status}\n${response.data}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
export function createClientFromEnv() {
|
|
725
|
+
const url = process.env.BW_URL;
|
|
726
|
+
const client = process.env.BW_CLIENT ?? '001';
|
|
727
|
+
const language = process.env.BW_LANGUAGE;
|
|
728
|
+
const cookieFile = process.env.BW_COOKIE_FILE;
|
|
729
|
+
if (!url) {
|
|
730
|
+
throw new Error('Required environment variable missing: BW_URL');
|
|
731
|
+
}
|
|
732
|
+
// Cookie mode: BW Bridge or other SAML/OAuth-fronted BW systems where Basic Auth
|
|
733
|
+
// is not available. Cookies are exported from an authenticated browser session.
|
|
734
|
+
// File format (vsp-compatible): Netscape (7 tab-separated fields) or simple
|
|
735
|
+
// "name=value" lines. Lines starting with # are comments.
|
|
736
|
+
if (cookieFile) {
|
|
737
|
+
let raw;
|
|
738
|
+
try {
|
|
739
|
+
raw = fs.readFileSync(cookieFile, 'utf-8');
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
throw new Error(`Failed to read BW_COOKIE_FILE at ${cookieFile}: ${err.message}`);
|
|
743
|
+
}
|
|
744
|
+
const cookies = {};
|
|
745
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
746
|
+
const trimmed = line.trim();
|
|
747
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
748
|
+
continue;
|
|
749
|
+
const parts = trimmed.split('\t');
|
|
750
|
+
if (parts.length >= 7) {
|
|
751
|
+
cookies[parts[5]] = parts[6];
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
const eq = trimmed.indexOf('=');
|
|
755
|
+
if (eq > 0) {
|
|
756
|
+
cookies[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (Object.keys(cookies).length === 0) {
|
|
761
|
+
throw new Error(`BW_COOKIE_FILE at ${cookieFile} contains no parseable cookies (expected Netscape or name=value format).`);
|
|
762
|
+
}
|
|
763
|
+
const userOpt = process.env.BW_USER ?? null;
|
|
764
|
+
const passwordOpt = process.env.BW_PASSWORD ?? null;
|
|
765
|
+
return new BwClient(url, userOpt, passwordOpt, client, language, cookies);
|
|
766
|
+
}
|
|
767
|
+
// Basic Auth mode: classic on-premise BW/4HANA — unchanged from previous behavior.
|
|
768
|
+
const user = process.env.BW_USER;
|
|
769
|
+
const password = process.env.BW_PASSWORD;
|
|
770
|
+
if (!user || !password) {
|
|
771
|
+
throw new Error('Required environment variables missing: BW_URL, BW_USER, BW_PASSWORD');
|
|
772
|
+
}
|
|
773
|
+
return new BwClient(url, user, password, client, language);
|
|
774
|
+
}
|