@cspeach/cli 1.1.21 → 1.1.23
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/dist/agent/tool-dispatch.js +4 -2
- package/dist/cli.js +13 -0
- package/dist/commands/plan-audit-evidence.js +61 -4
- package/dist/commands/plan-audit.js +13 -1
- package/dist/commands/sap-link.js +52 -0
- package/dist/lib/shutdown-hooks.js +87 -0
- package/dist/one-shot.js +4 -0
- package/dist/projects/plan-run.js +5 -1
- package/dist/projects/plan-schema.js +2 -1
- package/dist/renderer/tool-labels.js +5 -1
- package/dist/repl/credential-handover-gate.js +12 -6
- package/dist/repl.js +4 -0
- package/dist/sap/connection-manager.js +30 -24
- package/dist/sap/standalone-profile.js +1 -1
- package/dist/sap/system-info.js +50 -27
- package/dist/sap-link/policy.js +185 -0
- package/dist/sap-link/project.js +163 -0
- package/dist/sap-link/registry.js +259 -0
- package/dist/sap-link/server.js +351 -0
- package/dist/sap-link/upstream.js +367 -0
- package/dist/sap-link/write-log.js +60 -0
- package/dist/skills/bundled-skills.js +118 -118
- package/dist/tools/cap/_fetch-errors.js +20 -0
- package/dist/tools/cap/link-smoke.js +703 -0
- package/dist/tools/cap/metadata-import.js +325 -0
- package/dist/tools/cap/sap-link-tool.js +133 -0
- package/dist/tools/cap/service-catalog.js +248 -0
- package/dist/tools/fiori/preview/registry.js +30 -24
- package/dist/tools/local-build.js +11 -0
- package/dist/tools/subagent/_background-shared.js +59 -37
- package/dist/tools/subagent/background_run.js +9 -5
- package/dist/upgrade.js +4 -2
- package/package.json +1 -1
- package/dist/commands/project.js +0 -301
- package/dist/tools/local-files.js +0 -31
- package/dist/tools/subagent/schedule_create.js +0 -131
- package/dist/ui/error-emitter.js +0 -21
- package/dist/ui/header.js +0 -41
- package/dist/ui/ink-classifier-route.js +0 -19
- package/dist/ui/rich-error-box.js +0 -9
- package/dist/ui/sidebar.js +0 -12
- package/dist/ui/status-emitter.js +0 -19
- package/dist/ui/status-line.js +0 -62
- package/dist/ui/status-row.js +0 -38
- package/dist/ui/todo-panel.js +0 -85
- package/dist/ui/widgets/ask-form.js +0 -250
- package/dist/ui/widgets/ask-question-modal.js +0 -178
- package/fiori/zlas-inspection-lr/package.json +0 -26
- package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
- package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
- package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
- package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
- package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
package/dist/sap/system-info.js
CHANGED
|
@@ -19,6 +19,12 @@ import { join } from 'node:path';
|
|
|
19
19
|
import { auditLog, xmlParser } from '@cspeach/sap-client';
|
|
20
20
|
import { cspeachRoot } from '../config/paths.js';
|
|
21
21
|
const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
22
|
+
/**
|
|
23
|
+
* Bump when the cached shape or its meaning changes, so files written by
|
|
24
|
+
* an older CLI are re-queried instead of served. v2 (2026-09-29): CVERS
|
|
25
|
+
* now includes SAP_APPL; v1 files classified ECC EHP8 as NetWeaver.
|
|
26
|
+
*/
|
|
27
|
+
const CACHE_VERSION = 2;
|
|
22
28
|
const memCache = new Map();
|
|
23
29
|
/**
|
|
24
30
|
* Top-level entry point. Returns cached info when available, queries
|
|
@@ -68,7 +74,8 @@ export function _resetSystemInfoCache() {
|
|
|
68
74
|
async function queryLive(adt, alias) {
|
|
69
75
|
// S4FND covers some private-cloud variants where S4CORE isn't shipped
|
|
70
76
|
// — including both broadens detection without extra cost.
|
|
71
|
-
|
|
77
|
+
// SAP_APPL tells ECC apart from plain NetWeaver (see classify()).
|
|
78
|
+
const sql = `SELECT COMPONENT, RELEASE FROM CVERS WHERE COMPONENT IN ('SAP_BASIS', 'S4CORE', 'S4FND', 'SAP_ABA', 'SAP_APPL')`;
|
|
72
79
|
// Dispatch order is INTENTIONALLY connection-first: AdtClient.sqlQuery
|
|
73
80
|
// internally uses parseDataPreview which silently returns empty rows on
|
|
74
81
|
// shapes it doesn't recognise — exactly the failure mode that surfaced
|
|
@@ -380,11 +387,17 @@ export async function parseCversTree(xml) {
|
|
|
380
387
|
*/
|
|
381
388
|
export function classify(alias, components) {
|
|
382
389
|
const sapBasis = components['SAP_BASIS'];
|
|
390
|
+
// SAP_APPL is the ERP application component: present on ECC, replaced by
|
|
391
|
+
// S4CORE on S/4, absent on BW / SolMan / PI. It — not the SAP_BASIS
|
|
392
|
+
// number — is what tells ECC apart from a plain NetWeaver system
|
|
393
|
+
// (ECC EHP8 runs on SAP_BASIS 750).
|
|
394
|
+
const sapAppl = components['SAP_APPL'];
|
|
383
395
|
const s4Core = components['S4CORE'] || components['S4FND'];
|
|
384
396
|
const platform = s4Core ? 'S/4HANA'
|
|
385
|
-
:
|
|
386
|
-
: '
|
|
387
|
-
|
|
397
|
+
: sapAppl ? 'ECC'
|
|
398
|
+
: sapBasis ? 'NetWeaver'
|
|
399
|
+
: 'unknown';
|
|
400
|
+
const release = humanReadableRelease(platform, sapBasis, s4Core, sapAppl);
|
|
388
401
|
const abapVersion = abapVersionFor(sapBasis);
|
|
389
402
|
return {
|
|
390
403
|
alias,
|
|
@@ -405,38 +418,42 @@ export function classify(alias, components) {
|
|
|
405
418
|
* so we anchor on S4CORE when present and fall back to SAP_BASIS only
|
|
406
419
|
* for ECC/NW systems.
|
|
407
420
|
*/
|
|
408
|
-
function humanReadableRelease(platform, sapBasis, s4Core) {
|
|
421
|
+
function humanReadableRelease(platform, sapBasis, s4Core, sapAppl) {
|
|
409
422
|
if (platform === 'S/4HANA' && s4Core) {
|
|
423
|
+
// S4CORE release → S/4HANA edition. FPS/SPS levels do not change the
|
|
424
|
+
// S4CORE release, so there are no "FPS" entries here.
|
|
410
425
|
const map = {
|
|
411
|
-
'100': 'S/4HANA
|
|
412
|
-
'101': 'S/4HANA
|
|
413
|
-
'102': 'S/4HANA
|
|
414
|
-
'103': 'S/4HANA
|
|
415
|
-
'104': 'S/4HANA
|
|
416
|
-
'105': 'S/4HANA
|
|
417
|
-
'106': 'S/4HANA
|
|
426
|
+
'100': 'S/4HANA 1511',
|
|
427
|
+
'101': 'S/4HANA 1610',
|
|
428
|
+
'102': 'S/4HANA 1709',
|
|
429
|
+
'103': 'S/4HANA 1809',
|
|
430
|
+
'104': 'S/4HANA 1909',
|
|
431
|
+
'105': 'S/4HANA 2020',
|
|
432
|
+
'106': 'S/4HANA 2021',
|
|
418
433
|
'107': 'S/4HANA 2022',
|
|
419
434
|
'108': 'S/4HANA 2023',
|
|
420
|
-
'109': 'S/4HANA
|
|
421
|
-
'110': 'S/4HANA 2025',
|
|
435
|
+
'109': 'S/4HANA 2025',
|
|
422
436
|
};
|
|
423
437
|
return map[s4Core] ?? `S/4HANA (S4CORE ${s4Core})`;
|
|
424
438
|
}
|
|
425
439
|
if (platform === 'NetWeaver' && sapBasis) {
|
|
426
|
-
return `NetWeaver
|
|
440
|
+
return `NetWeaver (SAP_BASIS ${sapBasis})`;
|
|
427
441
|
}
|
|
428
|
-
if (platform === 'ECC' &&
|
|
442
|
+
if (platform === 'ECC' && sapAppl) {
|
|
443
|
+
// Enhancement pack is read from SAP_APPL: SAP_BASIS is ambiguous
|
|
444
|
+
// (730 covers EHP5 and EHP6; 750 is EHP8 but also plain NW 7.50).
|
|
429
445
|
const map = {
|
|
430
|
-
'
|
|
431
|
-
'
|
|
432
|
-
'
|
|
433
|
-
'
|
|
434
|
-
'
|
|
435
|
-
'
|
|
436
|
-
'
|
|
437
|
-
'
|
|
446
|
+
'600': 'ECC 6.0',
|
|
447
|
+
'602': 'ECC 6.0 EHP2',
|
|
448
|
+
'603': 'ECC 6.0 EHP3',
|
|
449
|
+
'604': 'ECC 6.0 EHP4',
|
|
450
|
+
'605': 'ECC 6.0 EHP5',
|
|
451
|
+
'606': 'ECC 6.0 EHP6',
|
|
452
|
+
'616': 'ECC 6.0 EHP6 (HANA)',
|
|
453
|
+
'617': 'ECC 6.0 EHP7',
|
|
454
|
+
'618': 'ECC 6.0 EHP8',
|
|
438
455
|
};
|
|
439
|
-
return map[
|
|
456
|
+
return map[sapAppl] ?? `ECC 6.0 (SAP_APPL ${sapAppl})`;
|
|
440
457
|
}
|
|
441
458
|
return 'unknown';
|
|
442
459
|
}
|
|
@@ -455,6 +472,9 @@ function abapVersionFor(sapBasis) {
|
|
|
455
472
|
// Modern releases — return as N.NN (e.g. 757 → 7.57).
|
|
456
473
|
if (n >= 740 && n < 800)
|
|
457
474
|
return `7.${n - 700}`;
|
|
475
|
+
// ABAP Platform 2025 moved to 8.xx (SAP_BASIS 816 → 8.16).
|
|
476
|
+
if (n >= 800 && n < 900)
|
|
477
|
+
return `8.${String(n - 800).padStart(2, '0')}`;
|
|
458
478
|
return sapBasis;
|
|
459
479
|
}
|
|
460
480
|
// ── Disk cache ─────────────────────────────────────────────────────────────
|
|
@@ -473,12 +493,15 @@ function readDiskCache(alias) {
|
|
|
473
493
|
if (!existsSync(path))
|
|
474
494
|
return null;
|
|
475
495
|
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
496
|
+
if (raw.cacheVersion !== CACHE_VERSION)
|
|
497
|
+
return null;
|
|
476
498
|
if (!raw.queriedAt)
|
|
477
499
|
return null;
|
|
478
500
|
const age = Date.now() - new Date(raw.queriedAt).getTime();
|
|
479
501
|
if (age > CACHE_TTL_MS)
|
|
480
502
|
return null;
|
|
481
|
-
|
|
503
|
+
const { cacheVersion: _v, ...info } = raw;
|
|
504
|
+
return info;
|
|
482
505
|
}
|
|
483
506
|
catch {
|
|
484
507
|
return null;
|
|
@@ -489,7 +512,7 @@ function writeDiskCache(alias, info) {
|
|
|
489
512
|
const dir = cacheDir();
|
|
490
513
|
if (!existsSync(dir))
|
|
491
514
|
mkdirSync(dir, { recursive: true });
|
|
492
|
-
writeFileSync(cacheFile(alias), JSON.stringify(info, null, 2), 'utf8');
|
|
515
|
+
writeFileSync(cacheFile(alias), JSON.stringify({ ...info, cacheVersion: CACHE_VERSION }, null, 2), 'utf8');
|
|
493
516
|
}
|
|
494
517
|
catch {
|
|
495
518
|
// best-effort — we still return the in-memory result
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
const READS = new Set(['GET', 'HEAD']);
|
|
2
|
+
const WRITES = new Set(['POST', 'PUT', 'PATCH', 'MERGE', 'DELETE']);
|
|
3
|
+
const OVERRIDE_HEADERS = ['x-http-method', 'x-http-method-override'];
|
|
4
|
+
// eslint-disable-next-line no-control-regex
|
|
5
|
+
const CONTROL_CHAR = /[\u0000-\u001f\u007f]/;
|
|
6
|
+
/** All values for a header, matched case-insensitively; arrays are flattened. */
|
|
7
|
+
function headerValues(h, name) {
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const [k, v] of Object.entries(h)) {
|
|
10
|
+
if (k.toLowerCase() !== name)
|
|
11
|
+
continue;
|
|
12
|
+
if (Array.isArray(v))
|
|
13
|
+
out.push(...v);
|
|
14
|
+
else if (v !== undefined)
|
|
15
|
+
out.push(v);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The method the request really performs, or an error when the override headers are
|
|
21
|
+
* unusable (unknown/empty verb, or conflicting values).
|
|
22
|
+
*/
|
|
23
|
+
/** Echo a verb only when it is plain letters; never reflect arbitrary text. */
|
|
24
|
+
function unknownVerbMessage(verb) {
|
|
25
|
+
if (verb === '')
|
|
26
|
+
return 'The SAP link does not forward empty requests.';
|
|
27
|
+
if (/^[A-Z]{1,16}$/.test(verb))
|
|
28
|
+
return `The SAP link does not forward ${verb} requests.`;
|
|
29
|
+
return 'The SAP link does not forward that method.';
|
|
30
|
+
}
|
|
31
|
+
function effectiveMethod(realMethod, h) {
|
|
32
|
+
const real = realMethod.trim().toUpperCase();
|
|
33
|
+
if (!READS.has(real) && !WRITES.has(real)) {
|
|
34
|
+
return { error: unknownVerbMessage(real) };
|
|
35
|
+
}
|
|
36
|
+
const overrides = OVERRIDE_HEADERS.flatMap((n) => headerValues(h, n)).map((v) => v.trim().toUpperCase());
|
|
37
|
+
if (overrides.length === 0)
|
|
38
|
+
return { method: real, real };
|
|
39
|
+
const distinct = new Set(overrides);
|
|
40
|
+
if (distinct.size > 1) {
|
|
41
|
+
return { error: 'The SAP link refuses requests with conflicting method override headers.' };
|
|
42
|
+
}
|
|
43
|
+
const override = overrides[0];
|
|
44
|
+
if (!READS.has(override) && !WRITES.has(override)) {
|
|
45
|
+
return { error: unknownVerbMessage(override) };
|
|
46
|
+
}
|
|
47
|
+
return { method: override, real, override };
|
|
48
|
+
}
|
|
49
|
+
/** Each `%` must be `%XY` decoding to a printable ASCII char other than . / \ % ;. */
|
|
50
|
+
function pathEncodingIsSafe(rawPath) {
|
|
51
|
+
for (let i = 0; i < rawPath.length; i++) {
|
|
52
|
+
if (rawPath[i] !== '%')
|
|
53
|
+
continue;
|
|
54
|
+
const hex = rawPath.slice(i + 1, i + 3);
|
|
55
|
+
if (!/^[0-9a-fA-F]{2}$/.test(hex))
|
|
56
|
+
return false;
|
|
57
|
+
const code = parseInt(hex, 16);
|
|
58
|
+
if (code < 0x20 || code >= 0x7f)
|
|
59
|
+
return false;
|
|
60
|
+
if (code === 0x2e || code === 0x2f || code === 0x5c || code === 0x25 || code === 0x3b)
|
|
61
|
+
return false;
|
|
62
|
+
i += 2;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
/** Lower-cased, slash-led, no trailing slash; null when the prefix is not a clean path. */
|
|
67
|
+
function normPrefix(p) {
|
|
68
|
+
const withSlash = p.startsWith('/') ? p : `/${p}`;
|
|
69
|
+
const trimmed = withSlash.replace(/\/+$/, '');
|
|
70
|
+
if (trimmed === '' || trimmed === '/')
|
|
71
|
+
return null;
|
|
72
|
+
if (/[%\\?#;]/.test(trimmed) || /[^\x20-\x7e]/.test(trimmed))
|
|
73
|
+
return null;
|
|
74
|
+
const segments = trimmed.slice(1).split('/');
|
|
75
|
+
if (segments.some((s) => s === '' || s === '.' || s === '..'))
|
|
76
|
+
return null;
|
|
77
|
+
return trimmed.toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
function isBatchPath(pathname) {
|
|
80
|
+
return pathname.split('/').some((seg) => {
|
|
81
|
+
let decoded;
|
|
82
|
+
try {
|
|
83
|
+
decoded = decodeURIComponent(seg);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
decoded = seg;
|
|
87
|
+
}
|
|
88
|
+
return decoded.split(';', 1)[0].trim().toLowerCase() === '$batch';
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* True when any query parameter name (decoded, trimmed) starts with `sap-`, except
|
|
93
|
+
* sap-language. 'malformed' when a name cannot be decoded (caller fails closed).
|
|
94
|
+
*/
|
|
95
|
+
function hasSapQueryParam(rawQuery) {
|
|
96
|
+
for (const part of rawQuery.split(/[&;]/)) {
|
|
97
|
+
const rawName = part.split('=', 1)[0];
|
|
98
|
+
let name;
|
|
99
|
+
try {
|
|
100
|
+
name = decodeURIComponent(rawName.replace(/\+/g, ' '));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return 'malformed';
|
|
104
|
+
}
|
|
105
|
+
const n = name.trim().toLowerCase();
|
|
106
|
+
if (n.startsWith('sap-') && n !== 'sap-language')
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
export function writesBlockedMessage(alias, role) {
|
|
112
|
+
const now = role === undefined ? 'has no role set' : `has role ${role}`;
|
|
113
|
+
return `Writes to ${alias} need role dev. ${alias} ${now}. Set it with: cspeach config set sap.${alias}.role dev`;
|
|
114
|
+
}
|
|
115
|
+
export function decideRequest(input) {
|
|
116
|
+
const m = effectiveMethod(input.method, input.headers);
|
|
117
|
+
if ('error' in m)
|
|
118
|
+
return { allow: false, status: 400, message: m.error };
|
|
119
|
+
const method = m.method;
|
|
120
|
+
const verbIsWrite = WRITES.has(m.real) || (m.override !== undefined && WRITES.has(m.override));
|
|
121
|
+
const raw = input.rawUrl;
|
|
122
|
+
if (!raw.startsWith('/') || raw.startsWith('//')) {
|
|
123
|
+
return { allow: false, status: 400, message: 'The SAP link only accepts paths on this machine, not full URLs.' };
|
|
124
|
+
}
|
|
125
|
+
if (CONTROL_CHAR.test(raw)) {
|
|
126
|
+
return { allow: false, status: 400, message: 'The SAP link refuses paths with control characters.' };
|
|
127
|
+
}
|
|
128
|
+
const rawPath = raw.split(/[?#]/, 1)[0];
|
|
129
|
+
if (rawPath.includes('\\') || /[^\x00-\x7f]/.test(rawPath) || !pathEncodingIsSafe(rawPath)) {
|
|
130
|
+
return { allow: false, status: 400, message: 'The SAP link refuses encoded or backslash paths.' };
|
|
131
|
+
}
|
|
132
|
+
const url = new URL(raw, 'http://127.0.0.1');
|
|
133
|
+
if (url.host !== '127.0.0.1') {
|
|
134
|
+
return { allow: false, status: 400, message: 'The SAP link only accepts paths on this machine, not full URLs.' };
|
|
135
|
+
}
|
|
136
|
+
for (const seg of url.pathname.split('/')) {
|
|
137
|
+
let decoded;
|
|
138
|
+
try {
|
|
139
|
+
decoded = decodeURIComponent(seg);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return { allow: false, status: 400, message: 'The SAP link refuses encoded or backslash paths.' };
|
|
143
|
+
}
|
|
144
|
+
const bare = decoded.split(';', 1)[0].trim();
|
|
145
|
+
if (bare === '.' || bare === '..') {
|
|
146
|
+
return { allow: false, status: 400, message: 'The SAP link refuses dot segments with parameters.' };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const pathLower = url.pathname.toLowerCase();
|
|
150
|
+
const usable = input.allowedPrefixes
|
|
151
|
+
.map((original) => ({ original, norm: normPrefix(original) }))
|
|
152
|
+
.filter((p) => p.norm !== null);
|
|
153
|
+
const hit = usable.find((p) => pathLower === p.norm || pathLower.startsWith(`${p.norm}/`) || pathLower.startsWith(`${p.norm};`));
|
|
154
|
+
if (!hit) {
|
|
155
|
+
return {
|
|
156
|
+
allow: false,
|
|
157
|
+
status: 404,
|
|
158
|
+
message: `The SAP link only forwards the services this project imported: ` +
|
|
159
|
+
`${usable.map((p) => p.original).join(', ') || '(none)'}.`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// Only `;v=<digits>` directly on the service segment; no other `;` anywhere.
|
|
163
|
+
const afterPrefix = pathLower.slice(hit.norm.length);
|
|
164
|
+
const version = /^;v=\d+(?=\/|$)/.exec(afterPrefix);
|
|
165
|
+
const rest = version ? afterPrefix.slice(version[0].length) : afterPrefix;
|
|
166
|
+
if (rest.includes(';')) {
|
|
167
|
+
return {
|
|
168
|
+
allow: false,
|
|
169
|
+
status: 400,
|
|
170
|
+
message: 'The SAP link only accepts ;v=<number> after the service name. Other path parameters are refused.',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (hasSapQueryParam(url.search.replace(/^\?/, '')) !== false) {
|
|
174
|
+
return {
|
|
175
|
+
allow: false,
|
|
176
|
+
status: 400,
|
|
177
|
+
message: 'The SAP link refuses sap- query parameters. It uses the client and login from your SAP settings.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const write = verbIsWrite || isBatchPath(url.pathname);
|
|
181
|
+
if (write && input.role !== 'dev') {
|
|
182
|
+
return { allow: false, status: 403, message: writesBlockedMessage(input.alias, input.role) };
|
|
183
|
+
}
|
|
184
|
+
return { allow: true, write, method, upstreamPath: url.pathname + url.search, servicePrefix: hit.original };
|
|
185
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// cspeach-cli/src/sap-link/project.ts
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
export class LinkConfigError extends Error {
|
|
5
|
+
}
|
|
6
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost']);
|
|
7
|
+
/** Keys that carry a secret. Matched case-insensitively at any depth of an S/4 entry. None may sit in package.json. */
|
|
8
|
+
const SECRET_KEYS = new Set([
|
|
9
|
+
'user', 'username', 'password', 'token', 'clientsecret', 'client_secret',
|
|
10
|
+
'authentication', 'headers', 'key', 'apikey',
|
|
11
|
+
]);
|
|
12
|
+
/** The only OData roots the SAP link may be pointed at, and how many segments name exactly one service. */
|
|
13
|
+
const ODATA_ROOTS = [
|
|
14
|
+
{ root: '/sap/opu/odata/', segments: 2, version: 'v2' }, // <namespace>/<SERVICE>[;v=N]
|
|
15
|
+
{ root: '/sap/opu/odata4/', segments: 6, version: 'v4' }, // <ns>/<group>/<repo>/<ns>/<service>/<version>
|
|
16
|
+
];
|
|
17
|
+
const MAX_PACKAGE_BYTES = 5 * 1024 * 1024;
|
|
18
|
+
const MAX_DEPTH = 20;
|
|
19
|
+
// eslint-disable-next-line no-control-regex
|
|
20
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
21
|
+
/**
|
|
22
|
+
* Raw path chars allowed: printable ASCII 0x21-0x7e that the URL parser leaves as-is,
|
|
23
|
+
* i.e. without `"` `<` `>` backtick `{` `|` `}` `^` `%` and backslash.
|
|
24
|
+
*/
|
|
25
|
+
const PATH_CHARS = /^[!#$&-;=?-[\]_a-z~]*$/;
|
|
26
|
+
/** A URL with a login in it (`scheme://user[:pass]@host`). */
|
|
27
|
+
const URL_USERINFO = /:\/\/[^/?#\s]*@/;
|
|
28
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
29
|
+
const loginMessage = (name) => `package.json holds a login for ${name}. Remove it — the SAP link adds the login from your keychain.`;
|
|
30
|
+
/** Walks a whole `requires` entry and refuses any secret-bearing key at any depth. Never reports values. */
|
|
31
|
+
function scanForSecrets(name, value, depth = 0, seen = new Set()) {
|
|
32
|
+
if (typeof value === 'string' && URL_USERINFO.test(value))
|
|
33
|
+
throw new LinkConfigError(loginMessage(name));
|
|
34
|
+
if (typeof value !== 'object' || value === null)
|
|
35
|
+
return;
|
|
36
|
+
if (depth > MAX_DEPTH)
|
|
37
|
+
throw new LinkConfigError(`The entry for ${name} in package.json is nested too deeply.`);
|
|
38
|
+
if (seen.has(value))
|
|
39
|
+
return;
|
|
40
|
+
seen.add(value);
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
for (const item of value)
|
|
43
|
+
scanForSecrets(name, item, depth + 1, seen);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
for (const [k, v] of Object.entries(value)) {
|
|
47
|
+
if (SECRET_KEYS.has(k.toLowerCase()))
|
|
48
|
+
throw new LinkConfigError(loginMessage(name));
|
|
49
|
+
scanForSecrets(name, v, depth + 1, seen);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Checks one service's `[s4]` URL and returns its port and allowlist prefix. Never echoes the URL. */
|
|
53
|
+
function checkUrl(name, raw) {
|
|
54
|
+
// Before parsing: `URL` silently strips tabs, newlines and leading/trailing controls.
|
|
55
|
+
if (CONTROL_CHARS.test(raw))
|
|
56
|
+
throw new LinkConfigError(`${name}: the [s4] URL holds control characters.`);
|
|
57
|
+
let u;
|
|
58
|
+
try {
|
|
59
|
+
u = new URL(raw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new LinkConfigError(`${name}: the [s4] URL is not a valid URL.`);
|
|
63
|
+
}
|
|
64
|
+
if (u.username !== '' || u.password !== '')
|
|
65
|
+
throw new LinkConfigError(loginMessage(name));
|
|
66
|
+
const authority = /^[a-z][a-z0-9+.-]*:[/\\]{2}([^/\\?#]*)/i.exec(raw);
|
|
67
|
+
const rawAuthority = authority?.[1] ?? '';
|
|
68
|
+
// `URL` drops the default port, so an explicit :80 would otherwise read as "no port".
|
|
69
|
+
if (u.protocol === 'http:' && u.port === '' && /:0*80$/.test(rawAuthority)) {
|
|
70
|
+
throw new LinkConfigError(`${name}: port 80 is not supported for the SAP link. Use another port, such as 4005.`);
|
|
71
|
+
}
|
|
72
|
+
if (u.protocol !== 'http:' || !LOOPBACK.has(u.hostname) || u.port === '' || !authority) {
|
|
73
|
+
throw new LinkConfigError(`${name}: the [s4] URL must point at the SAP link on 127.0.0.1 with a port.`);
|
|
74
|
+
}
|
|
75
|
+
const port = Number(u.port);
|
|
76
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
77
|
+
throw new LinkConfigError(`${name}: the [s4] URL port must be a whole number from 0 to 65535.`);
|
|
78
|
+
}
|
|
79
|
+
// Check the raw string too: `URL` drops an empty `?` or `#`.
|
|
80
|
+
if (u.search !== '' || u.hash !== '' || raw.includes('?') || raw.includes('#')) {
|
|
81
|
+
throw new LinkConfigError(`${name}: the [s4] URL must not have a query or fragment.`);
|
|
82
|
+
}
|
|
83
|
+
const prefix = u.pathname.replace(/\/+$/, '');
|
|
84
|
+
const root = ODATA_ROOTS.find((r) => prefix.toLowerCase().startsWith(r.root));
|
|
85
|
+
if (!root || prefix.length <= root.root.length) {
|
|
86
|
+
throw new LinkConfigError(`${name}: the [s4] URL path must start with /sap/opu/odata/ or /sap/opu/odata4/ and name a service.`);
|
|
87
|
+
}
|
|
88
|
+
// Judge the RAW path, not what normalisation made of it.
|
|
89
|
+
const rawPath = raw.slice(authority[0].length).replace(/\/+$/, '');
|
|
90
|
+
const rawSegments = rawPath.split('/').slice(1);
|
|
91
|
+
if (rawPath.includes('%') || rawPath.includes('\\') ||
|
|
92
|
+
rawSegments.some((s) => {
|
|
93
|
+
// `..;v=1` climbs on servers that strip matrix params, so judge the part before `;`.
|
|
94
|
+
const bare = s.split(';')[0];
|
|
95
|
+
return bare === '' || bare === '.' || bare === '..';
|
|
96
|
+
})) {
|
|
97
|
+
throw new LinkConfigError(`${name}: the [s4] URL path must not hold dot segments, encoded characters, backslashes or empty segments.`);
|
|
98
|
+
}
|
|
99
|
+
// Anything the parser percent-encodes (space, quotes, braces, non-ASCII, ...) would come back
|
|
100
|
+
// as `%XY` in the prefix, which the policy drops. Refuse it on both sides of parsing.
|
|
101
|
+
if (!PATH_CHARS.test(rawPath) || u.pathname.includes('%')) {
|
|
102
|
+
throw new LinkConfigError(`${name}: the [s4] URL path may only hold plain ASCII letters, digits and URL punctuation.`);
|
|
103
|
+
}
|
|
104
|
+
const segments = prefix.slice(root.root.length).split('/');
|
|
105
|
+
if (segments.length !== root.segments || segments.some((s) => s === '')) {
|
|
106
|
+
throw new LinkConfigError(`${name}: the [s4] URL path must name exactly one service, like /sap/opu/odata/sap/API_BUSINESS_PARTNER.`);
|
|
107
|
+
}
|
|
108
|
+
const last = segments.length - 1;
|
|
109
|
+
const matrixOk = segments.every((s, i) => !s.includes(';') || (root.version === 'v2' && i === last && /^[^;]+;v=\d+$/.test(s)));
|
|
110
|
+
if (!matrixOk) {
|
|
111
|
+
throw new LinkConfigError(`${name}: the [s4] URL path may only carry ;v=<number> on the service name.`);
|
|
112
|
+
}
|
|
113
|
+
// The policy matches the bare service and accepts `;v=N` right after it, so the prefix drops it.
|
|
114
|
+
return { port, prefix: prefix.replace(/;v=\d+$/, '') };
|
|
115
|
+
}
|
|
116
|
+
async function readPackageJson(projectDir) {
|
|
117
|
+
const file = path.join(projectDir, 'package.json');
|
|
118
|
+
let size;
|
|
119
|
+
try {
|
|
120
|
+
size = (await fs.stat(file)).size;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new LinkConfigError(`No readable package.json in ${projectDir}.`);
|
|
124
|
+
}
|
|
125
|
+
if (size > MAX_PACKAGE_BYTES) {
|
|
126
|
+
throw new LinkConfigError(`package.json in ${projectDir} is larger than 5 MB. The SAP link will not read it.`);
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw new LinkConfigError(`No readable package.json in ${projectDir}.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** The generated app's `[s4]` profile points each remote service at the SAP link (spec §6.2). */
|
|
136
|
+
export async function readLinkTargets(projectDir) {
|
|
137
|
+
const pkg = await readPackageJson(projectDir);
|
|
138
|
+
const cds = isObject(pkg) ? pkg.cds : undefined;
|
|
139
|
+
const requires = isObject(cds) && isObject(cds.requires) ? cds.requires : {};
|
|
140
|
+
const services = [];
|
|
141
|
+
const ports = new Set();
|
|
142
|
+
for (const [name, entry] of Object.entries(requires)) {
|
|
143
|
+
if (!isObject(entry))
|
|
144
|
+
continue;
|
|
145
|
+
const profile = entry['[s4]'];
|
|
146
|
+
if (!isObject(profile))
|
|
147
|
+
continue;
|
|
148
|
+
const creds = profile.credentials;
|
|
149
|
+
if (!isObject(creds) || typeof creds.url !== 'string')
|
|
150
|
+
continue;
|
|
151
|
+
// Only S/4 entries are scanned: other entries (e.g. mocked auth users) legitimately hold test passwords.
|
|
152
|
+
scanForSecrets(name, entry);
|
|
153
|
+
const { port, prefix } = checkUrl(name, creds.url);
|
|
154
|
+
ports.add(port);
|
|
155
|
+
services.push({ name, prefix });
|
|
156
|
+
}
|
|
157
|
+
if (services.length === 0) {
|
|
158
|
+
throw new LinkConfigError('No S/4 services in this project. Import one first (cap_metadata_import, then cds import).');
|
|
159
|
+
}
|
|
160
|
+
if (ports.size !== 1)
|
|
161
|
+
throw new LinkConfigError('All [s4] URLs must use one port — the SAP link listens on one.');
|
|
162
|
+
return { port: [...ports][0], services };
|
|
163
|
+
}
|