@cspeach/cli 1.1.22 → 1.1.24

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.
Files changed (54) hide show
  1. package/dist/agent/loop.js +124 -0
  2. package/dist/agent/tool-dispatch.js +4 -2
  3. package/dist/classifier/client.js +27 -2
  4. package/dist/cli.js +13 -0
  5. package/dist/commands/plan-audit-evidence.js +61 -4
  6. package/dist/commands/plan-audit.js +13 -1
  7. package/dist/commands/plan-continue.js +11 -1
  8. package/dist/commands/plan-resume.js +38 -1
  9. package/dist/commands/sap-link.js +52 -0
  10. package/dist/lane/app-chain.js +297 -0
  11. package/dist/lane/app-flow.js +55 -0
  12. package/dist/lane/card.js +148 -0
  13. package/dist/lane/chain-token.js +59 -0
  14. package/dist/lane/inputs.js +459 -0
  15. package/dist/lane/paths.js +21 -0
  16. package/dist/lane/resolve.js +109 -0
  17. package/dist/lane/roots.js +113 -0
  18. package/dist/lane/signals.js +80 -0
  19. package/dist/lane/store.js +232 -0
  20. package/dist/lane/types.js +1 -0
  21. package/dist/lib/shutdown-hooks.js +87 -0
  22. package/dist/one-shot.js +5 -0
  23. package/dist/projects/extract-plan.js +46 -6
  24. package/dist/projects/plan-lane.js +214 -0
  25. package/dist/projects/plan-run.js +5 -1
  26. package/dist/projects/plan-schema.js +12 -1
  27. package/dist/projects/promote-command.js +8 -2
  28. package/dist/projects/save-command.js +71 -25
  29. package/dist/projects/workspace.js +10 -0
  30. package/dist/renderer/tool-labels.js +6 -1
  31. package/dist/repl/credential-handover-gate.js +12 -6
  32. package/dist/repl/resume-standalone.js +3 -0
  33. package/dist/repl.js +27 -2
  34. package/dist/sap/connection-manager.js +30 -24
  35. package/dist/sap-link/policy.js +185 -0
  36. package/dist/sap-link/project.js +163 -0
  37. package/dist/sap-link/registry.js +259 -0
  38. package/dist/sap-link/server.js +351 -0
  39. package/dist/sap-link/upstream.js +367 -0
  40. package/dist/sap-link/write-log.js +60 -0
  41. package/dist/skill-catalog.js +14 -6
  42. package/dist/skills/bundled-skills.js +61 -54
  43. package/dist/tools/cap/_fetch-errors.js +20 -0
  44. package/dist/tools/cap/link-smoke.js +703 -0
  45. package/dist/tools/cap/metadata-import.js +325 -0
  46. package/dist/tools/cap/sap-link-tool.js +133 -0
  47. package/dist/tools/cap/service-catalog.js +248 -0
  48. package/dist/tools/fiori/preview/registry.js +30 -24
  49. package/dist/tools/lane/app-lane-resolve.js +329 -0
  50. package/dist/tools/local-build.js +15 -0
  51. package/dist/tools/subagent/_background-shared.js +59 -37
  52. package/dist/tools/subagent/background_run.js +9 -5
  53. package/dist/tools/tool-loading.js +2 -0
  54. package/package.json +121 -121
@@ -0,0 +1,325 @@
1
+ // cspeach-cli/src/tools/cap/metadata-import.ts
2
+ //
3
+ // CAP phase 2, Task 10 — `cap_metadata_import`: GET an S/4 OData service's
4
+ // `$metadata` with the stored SAP login and write it to
5
+ // `<projectDir>/srv/external/<apiName>.edmx`. The model then runs `cds import`.
6
+ //
7
+ // Security posture (owner hardening on top of the brief):
8
+ // - Inputs: apiName is a plain name; servicePath is a Gateway OData path only
9
+ // (V2 `/sap/opu/odata/<ns>/<SERVICE>[;v=N]`, V4 `/sap/opu/odata4/` + 6 segments),
10
+ // printable ASCII, no `..`, `%`, `\`, `?`, `#`. Bad input fetches nothing.
11
+ // - The file stays in the workspace: resolveSafePath + the filesystem denylist +
12
+ // a realpath check on the nearest existing folder (catches a linked
13
+ // `srv/external`), and an existing symlink at the target is refused (lstat).
14
+ // The write goes to a fresh temp file then renames over the target, so a link
15
+ // planted after the check is replaced, never followed.
16
+ // - The answer must be HTTP 200, not HTML, at most 20 MB, start with
17
+ // `<edmx:Edmx` within its first 2 KB (after BOM / XML declaration /
18
+ // whitespace) and end with `</edmx:Edmx>`. Anything else is a plain error and
19
+ // NOTHING is written — not even the folder. The body is never echoed.
20
+ // - Connection errors are mapped to plain text; raw exception messages (which
21
+ // can carry headers, cookies or user names) never reach the tool result.
22
+ // - apiName refuses Windows device names (CON, NUL, COM1...); projectDir allows
23
+ // only letters, digits, `.`, `_`, `-`, space and `/` or `\` — no `:`, so an
24
+ // NTFS stream suffix (`.git::$INDEX_ALLOCATION`) cannot slip past the denylist.
25
+ // - The answer is read with linear tag scans (indexOf with cached `>` and `<`
26
+ // positions), never a backtracking regex over the whole body: a hostile
27
+ // $metadata of repeated openers cannot block the event loop.
28
+ // - isMutating: true (ruling R18) — it writes a workspace file, so read-only
29
+ // plan-phase subagents never get it. Precedent: fiori_deploy_config.
30
+ //
31
+ // Accepted residual risks:
32
+ // - Folder-swap race: between the realpath re-check after mkdir and the rename,
33
+ // a process on this machine could swap `srv/external` for a link. That needs
34
+ // a local attacker who can already write the workspace; the rename still
35
+ // never follows a link at the file itself.
36
+ // - The 20 MB cap applies after download: SapConnection.get returns the whole
37
+ // body as a string with no streaming limit, so a huge answer is buffered
38
+ // before it is refused. A pre-download cap needs a limit in sap-client.
39
+ import { promises as fs } from 'node:fs';
40
+ import * as path from 'node:path';
41
+ import { randomBytes } from 'node:crypto';
42
+ import { registerTool } from '../index.js';
43
+ import { resolveSafePath, isDenylistedPath, BLOCKED_PREFIXES } from '../_filesystem-shared.js';
44
+ import { loadConfig } from '../../config/loader.js';
45
+ import { PlainFetchError, plainSapFetchError } from './_fetch-errors.js';
46
+ const NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,59}$/;
47
+ /** Windows device names: `CON.edmx` opens the console, not a file. */
48
+ const RESERVED_NAME_RE = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
49
+ /** projectDir characters. No `:` (drive letters, NTFS streams), no wildcards, no control chars. */
50
+ const PROJECT_DIR_RE = /^[A-Za-z0-9._\-/\\ ]*$/;
51
+ /** Longest start tag we read attributes from; longer ones are skipped. */
52
+ const MAX_TAG_CHARS = 4096;
53
+ const SEG = '[A-Za-z0-9_]+';
54
+ const V2_PATH_RE = new RegExp(`^/sap/opu/odata/${SEG}/${SEG}(?:;v=\\d+)?/?$`);
55
+ const V4_PATH_RE = new RegExp(`^/sap/opu/odata4(?:/${SEG}){6}/?$`);
56
+ const PRINTABLE_ASCII_RE = /^[\x20-\x7E]+$/;
57
+ const HEAD_BYTES = 2048;
58
+ /** Largest $metadata we accept (S/4 API_BUSINESS_PARTNER is ~0.5 MB). */
59
+ export const MAX_METADATA_BYTES = 20 * 1024 * 1024;
60
+ /** Entity-set names we print: plain identifiers only (the answer is SAP-supplied text). */
61
+ const SET_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
62
+ const MAX_SETS_SHOWN = 40;
63
+ const PATH_SHAPE = 'servicePath must be a Gateway OData path: /sap/opu/odata/<namespace>/<SERVICE> (optionally ending ;v=<number>) ' +
64
+ 'or /sap/opu/odata4/ followed by 6 segments. No host, query, "..", "%", "\\", "?" or "#".';
65
+ /**
66
+ * Map a connection error to one plain line. Never includes the raw message:
67
+ * sap-client and fetch errors can carry headers, cookies or the user name.
68
+ * (Shared with sap_service_catalog via ./_fetch-errors.ts.)
69
+ */
70
+ export function plainFetchError(err, alias, host, port) {
71
+ return plainSapFetchError(err, alias, host, port, `Could not read the metadata from ${alias}. Check the SAP login for this system and try again.`);
72
+ }
73
+ function validServicePath(p) {
74
+ if (typeof p !== 'string' || !PRINTABLE_ASCII_RE.test(p))
75
+ return undefined;
76
+ if (p.includes('..') || /[%\\?#]/.test(p))
77
+ return undefined;
78
+ if (!V2_PATH_RE.test(p) && !V4_PATH_RE.test(p))
79
+ return undefined;
80
+ return p.replace(/\/+$/, '');
81
+ }
82
+ /** Looks like EDMX: root `<edmx:Edmx` in the first 2 KB and a closing tag at the end. */
83
+ function looksLikeEdmx(body) {
84
+ const head = body.slice(0, HEAD_BYTES);
85
+ if (!/^\uFEFF?\s*(?:<\?xml\b[^>]*\?>\s*)?<edmx:Edmx[\s>]/.test(head))
86
+ return false;
87
+ return body.trimEnd().endsWith('</edmx:Edmx>');
88
+ }
89
+ const isTagNameEnd = (c) => c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d || c === 0x3e || c === 0x2f;
90
+ /**
91
+ * Call `fn` with the attribute text of every well-formed `<name ...>` start tag.
92
+ * Linear in the body: the next `>` and next `<` positions are cached and only
93
+ * move forward, so repeated unclosed openers cost O(1) each. A tag longer than
94
+ * MAX_TAG_CHARS, or one holding another `<` before its `>`, is skipped.
95
+ * `fn` returns true to stop early.
96
+ */
97
+ function forEachStartTag(body, name, fn) {
98
+ const open = `<${name}`;
99
+ let pos = 0;
100
+ let gt = -1;
101
+ let lt = -1;
102
+ let ltDone = false;
103
+ for (;;) {
104
+ pos = body.indexOf(open, pos);
105
+ if (pos < 0)
106
+ return;
107
+ const attrStart = pos + open.length;
108
+ if (!isTagNameEnd(body.charCodeAt(attrStart))) {
109
+ pos = attrStart;
110
+ continue;
111
+ }
112
+ if (gt < attrStart) {
113
+ gt = body.indexOf('>', attrStart);
114
+ if (gt < 0)
115
+ return;
116
+ }
117
+ if (!ltDone && lt < attrStart) {
118
+ lt = body.indexOf('<', attrStart);
119
+ if (lt < 0)
120
+ ltDone = true;
121
+ }
122
+ const wellFormed = gt - attrStart <= MAX_TAG_CHARS && (ltDone || lt > gt);
123
+ if (wellFormed) {
124
+ if (fn(body.slice(attrStart, gt)) === true)
125
+ return;
126
+ pos = gt + 1;
127
+ }
128
+ else {
129
+ pos = attrStart;
130
+ }
131
+ }
132
+ }
133
+ /** v4 when the root edmx:Edmx says Version="4.0"; v2 when edmx:DataServices carries m:DataServiceVersion. */
134
+ export function odataVersion(body) {
135
+ const root = /<edmx:Edmx\b([^>]*)>/.exec(body.slice(0, HEAD_BYTES));
136
+ const rootVersion = root ? /(?:^|\s)Version="([^"]*)"/.exec(root[1] ?? '') : null;
137
+ if (rootVersion?.[1] === '4.0' || rootVersion?.[1] === '4.01')
138
+ return 'v4';
139
+ let v2 = false;
140
+ forEachStartTag(body, 'edmx:DataServices', (attrs) => {
141
+ v2 = /(?:^|\s)m:DataServiceVersion="[\d.]+"/.test(attrs);
142
+ return v2;
143
+ });
144
+ return v2 ? 'v2' : undefined;
145
+ }
146
+ export function entitySets(body) {
147
+ const out = [];
148
+ forEachStartTag(body, 'EntitySet', (attrs) => {
149
+ const n = /(?:^|\s)Name="([^"]*)"/.exec(attrs)?.[1] ?? '';
150
+ if (SET_NAME_RE.test(n))
151
+ out.push(n);
152
+ });
153
+ return out;
154
+ }
155
+ const isInside = (root, p) => {
156
+ const rel = path.relative(root, p);
157
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
158
+ };
159
+ /** Realpath of the nearest existing ancestor of `p` must sit inside the real workspace root. */
160
+ async function nearestExistingInside(rootReal, p) {
161
+ let cur = p;
162
+ for (;;) {
163
+ try {
164
+ return isInside(rootReal, await fs.realpath(cur));
165
+ }
166
+ catch {
167
+ const up = path.dirname(cur);
168
+ if (up === cur)
169
+ return false;
170
+ cur = up;
171
+ }
172
+ }
173
+ }
174
+ async function isSymlink(p) {
175
+ try {
176
+ return (await fs.lstat(p)).isSymbolicLink();
177
+ }
178
+ catch {
179
+ return false; // does not exist
180
+ }
181
+ }
182
+ export async function capMetadataImportHandler(args, ctx, deps = {}) {
183
+ const fail = (m) => ({ content: `error: ${m}`, is_error: true });
184
+ const apiName = typeof args.apiName === 'string' ? args.apiName : '';
185
+ if (!NAME_RE.test(apiName)) {
186
+ return fail('apiName must be a plain name like API_BUSINESS_PARTNER: a letter first, then letters, digits or _, at most 60.');
187
+ }
188
+ if (RESERVED_NAME_RE.test(apiName)) {
189
+ return fail(`apiName ${apiName} is a reserved Windows device name. Use another name, such as API_${apiName.toUpperCase()}.`);
190
+ }
191
+ const svc = validServicePath(args.servicePath);
192
+ if (!svc)
193
+ return fail(PATH_SHAPE);
194
+ const rawDir = args.projectDir === undefined ? '.' : args.projectDir;
195
+ if (typeof rawDir !== 'string' || !PROJECT_DIR_RE.test(rawDir)) {
196
+ return fail('projectDir must be a relative folder inside the workspace, using only letters, digits, ".", "_", "-", space and "/".');
197
+ }
198
+ // One separator on every OS, so `apps\bp` means the same folder on Windows and Linux.
199
+ const projectDir = rawDir.replace(/\\/g, '/') || '.';
200
+ // ── Where the file goes: inside the workspace, not denylisted, not a link. ──
201
+ const outsideMsg = 'projectDir must be a folder inside the workspace (not .git or .cspeach).';
202
+ let target;
203
+ try {
204
+ target = resolveSafePath(ctx.cwd, path.join(projectDir, 'srv', 'external', `${apiName}.edmx`));
205
+ }
206
+ catch {
207
+ return fail(outsideMsg);
208
+ }
209
+ const rootAbs = path.resolve(ctx.cwd);
210
+ const { blocked, relFromRoot } = isDenylistedPath(target, rootAbs);
211
+ const firstSeg = (relFromRoot.split('/')[0] ?? '').toLowerCase();
212
+ if (blocked || BLOCKED_PREFIXES.some((b) => b.toLowerCase() === firstSeg))
213
+ return fail(outsideMsg);
214
+ let rootReal;
215
+ try {
216
+ rootReal = await fs.realpath(rootAbs);
217
+ }
218
+ catch {
219
+ return fail('The workspace folder could not be read.');
220
+ }
221
+ if (!(await nearestExistingInside(rootReal, path.dirname(target)))) {
222
+ return fail('The target folder links outside the workspace. Nothing was written.');
223
+ }
224
+ if (await isSymlink(target)) {
225
+ return fail(`${relFromRoot} is a symlink. Remove it first; the import never writes through a link.`);
226
+ }
227
+ // ── Fetch. ──
228
+ const get = deps.get ?? (async (p) => {
229
+ const cfg = await loadConfig();
230
+ const sys = cfg.sap[ctx.sapAlias];
231
+ if (!sys)
232
+ throw new PlainFetchError(`No SAP system "${ctx.sapAlias}" is configured.`);
233
+ try {
234
+ const { getConnection } = await import('../../sap/connection-manager.js');
235
+ const conn = await getConnection(ctx.sapAlias, sys);
236
+ const r = await conn.get(p, 'application/xml');
237
+ const contentType = r.headers?.get?.('content-type');
238
+ return { status: r.status, body: r.body, ...(contentType ? { contentType } : {}) };
239
+ }
240
+ catch (err) {
241
+ throw new PlainFetchError(plainFetchError(err, ctx.sapAlias, sys.host, sys.port));
242
+ }
243
+ });
244
+ const metaPath = `${svc}/$metadata`;
245
+ let r;
246
+ try {
247
+ r = await get(metaPath);
248
+ }
249
+ catch (err) {
250
+ return fail(err instanceof PlainFetchError
251
+ ? err.message
252
+ : `Could not read the metadata from ${ctx.sapAlias}. Check the SAP login for this system and try again.`);
253
+ }
254
+ // ── Validate the answer. Nothing is written unless all checks pass. ──
255
+ if (r.status !== 200) {
256
+ return fail(`${metaPath} answered HTTP ${r.status}. Check the service is active (sap_service_catalog) and your user may read it. Nothing was written.`);
257
+ }
258
+ const body = typeof r.body === 'string' ? r.body : '';
259
+ const bytes = Buffer.byteLength(body, 'utf8');
260
+ if (bytes > MAX_METADATA_BYTES) {
261
+ return fail(`${metaPath} answered with more than 20 MB. That is too large for an import. Nothing was written.`);
262
+ }
263
+ if ((r.contentType && /html/i.test(r.contentType)) || !looksLikeEdmx(body)) {
264
+ return fail(`${metaPath} answered, but it is not OData metadata (looks like an HTML, login or error page). Nothing was written.`);
265
+ }
266
+ // ── Write: temp file (exclusive create) then rename over the target. ──
267
+ try {
268
+ await fs.mkdir(path.dirname(target), { recursive: true });
269
+ if (!(await nearestExistingInside(rootReal, path.dirname(target)))) {
270
+ return fail('The target folder links outside the workspace. Nothing was written.');
271
+ }
272
+ if (await isSymlink(target)) {
273
+ return fail(`${relFromRoot} is a symlink. Remove it first; the import never writes through a link.`);
274
+ }
275
+ const tmp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
276
+ try {
277
+ await fs.writeFile(tmp, body, { encoding: 'utf8', flag: 'wx' });
278
+ await fs.rename(tmp, target);
279
+ }
280
+ catch (err) {
281
+ await fs.rm(tmp, { force: true }).catch(() => undefined);
282
+ throw err;
283
+ }
284
+ }
285
+ catch {
286
+ return fail(`Could not write ${relFromRoot}. Check the folder is writable.`);
287
+ }
288
+ const sets = entitySets(body);
289
+ const odata = odataVersion(body);
290
+ const rel = path.relative(path.resolve(rootAbs, projectDir), target).split(path.sep).join('/');
291
+ const relProject = path.relative(rootAbs, path.resolve(rootAbs, projectDir)).split(path.sep).join('/');
292
+ const where = relProject === '' ? 'in the project root' : `in ${/\s/.test(relProject) ? JSON.stringify(relProject) : relProject}`;
293
+ return {
294
+ content: [
295
+ `Wrote ${rel} (${bytes} bytes, ${odata ? `OData ${odata}` : 'OData version unknown'}).`,
296
+ `Entity sets: ${sets.length ? sets.slice(0, MAX_SETS_SHOWN).join(', ') : '(none found)'}` +
297
+ `${sets.length > MAX_SETS_SHOWN ? ` … (${sets.length} total)` : ''}`,
298
+ `Next: cds import ${rel} --as cds (${where}), then add the [s4] profile URL: http://127.0.0.1:4005${svc}`,
299
+ ].join('\n'),
300
+ };
301
+ }
302
+ registerTool({
303
+ name: 'cap_metadata_import',
304
+ description: 'Fetch an S/4 OData service\'s $metadata with the stored SAP login and write it to <projectDir>/srv/external/<apiName>.edmx ' +
305
+ '(CAP phase 2). It only writes that one file; run `cds import` on it afterwards. Refuses anything that is not ' +
306
+ 'OData metadata. Read-only on SAP.',
307
+ // R18: writes a workspace file, so read-only plan-phase subagents must not get it.
308
+ isMutating: true,
309
+ requiresSap: true,
310
+ category: 'fiori',
311
+ flagGated: true,
312
+ input_schema: {
313
+ type: 'object',
314
+ properties: {
315
+ servicePath: {
316
+ type: 'string',
317
+ description: 'Gateway OData path, e.g. /sap/opu/odata/sap/API_BUSINESS_PARTNER or /sap/opu/odata4/sap/<grp>/srvd_a2x/sap/<svc>/0001',
318
+ },
319
+ apiName: { type: 'string', description: 'File and model name, e.g. API_BUSINESS_PARTNER' },
320
+ projectDir: { type: 'string', description: 'CAP project root, relative to the workspace. Default ".".' },
321
+ },
322
+ required: ['servicePath', 'apiName'],
323
+ },
324
+ handler: (args, ctx) => capMetadataImportHandler(args, ctx),
325
+ });
@@ -0,0 +1,133 @@
1
+ // cspeach-cli/src/tools/cap/sap-link-tool.ts
2
+ //
3
+ // CAP phase 2, Task 8 — `sap_link`: start / stop / status of the SAP link, the
4
+ // loopback forwarder that lets a local CAP app's [s4] profile reach the S/4
5
+ // services it imported, with the developer's stored SAP login.
6
+ //
7
+ // Security posture:
8
+ // - start raises the credential-handover prompt (once per session per alias).
9
+ // - projectDir must resolve inside the workspace (resolveSafePath, then a
10
+ // realpath check so a linked folder cannot point outside).
11
+ // - Errors are plain: only the registry's own SapLinkError / LinkConfigError
12
+ // messages pass through. Anything else (keychain, TLS, fs) becomes one fixed
13
+ // sentence. The password never appears in a result.
14
+ // - status shows url → alias (project) only.
15
+ import * as path from 'node:path';
16
+ import { registerTool, isStandalone } from '../index.js';
17
+ import { resolveSafePath, assertRealPathContained } from '../_filesystem-shared.js';
18
+ import { ensureSapLink, stopSapLink, listSapLinks, SapLinkError } from '../../sap-link/registry.js';
19
+ import { LinkConfigError } from '../../sap-link/project.js';
20
+ const OUTSIDE = 'error: projectDir must be inside the workspace.';
21
+ const fail = (message) => ({ content: `error: ${message}`, is_error: true });
22
+ /** Resolve projectDir inside ctx.cwd, or undefined when it escapes. */
23
+ function safeProjectDir(ctx, projectDir) {
24
+ try {
25
+ return resolveSafePath(ctx.cwd, projectDir ?? '.');
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ // eslint-disable-next-line no-control-regex
32
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
33
+ /** The workspace-relative path for the handover card, control characters stripped. Undefined when it escapes. */
34
+ function cardPath(ctx, projectDir) {
35
+ const dir = safeProjectDir(ctx, projectDir);
36
+ if (!dir)
37
+ return undefined;
38
+ const rel = path.relative(path.resolve(ctx.cwd), dir).replace(CONTROL_CHARS, '');
39
+ return rel === '' ? '.' : rel;
40
+ }
41
+ registerTool({
42
+ name: 'sap_link',
43
+ description: 'The SAP link for a local CAP app (CAP phase 2): a forwarder on 127.0.0.1 that lets the app\'s [s4] profile reach ' +
44
+ 'the S/4 services it imported, using the SAP connection CSPeach already has. The password stays in the keychain; ' +
45
+ 'only the services listed in package.json cds.requires ([s4] credentials.url) are reachable; writes pass only when ' +
46
+ 'the alias role is exactly dev. actions: start (projectDir = the CAP project root), stop, status.',
47
+ isMutating: true,
48
+ requiresSap: true,
49
+ category: 'fiori',
50
+ flagGated: true,
51
+ input_schema: {
52
+ type: 'object',
53
+ properties: {
54
+ action: { type: 'string', enum: ['start', 'stop', 'status'] },
55
+ projectDir: { type: 'string', description: 'CAP project root, relative to the workspace. Default ".".' },
56
+ },
57
+ required: ['action'],
58
+ },
59
+ // M-1: the card never shows a raw model-supplied path. An escaping path gets no
60
+ // card (null); the handler then refuses it. M-2: `scope` keeps this approval
61
+ // separate from fiori_preview's for the same alias.
62
+ credentialHandover: (args, ctx) => {
63
+ if (args.action !== 'start')
64
+ return null;
65
+ const shown = cardPath(ctx, args.projectDir);
66
+ if (shown === undefined)
67
+ return null;
68
+ return {
69
+ alias: ctx.sapAlias,
70
+ scope: 'sap_link',
71
+ summary: `Start the SAP link: the CAP app in ${shown} can reach its imported S/4 services on ` +
72
+ `${ctx.sapAlias} with your stored SAP login. The password stays inside CSPeach.`,
73
+ };
74
+ },
75
+ handler: async (args, ctx) => {
76
+ if (args.action === 'status') {
77
+ const all = listSapLinks();
78
+ return { content: all.length ? all.map((l) => `${l.url} → ${l.alias} (${l.projectDir})`).join('\n') : 'No SAP link is running.' };
79
+ }
80
+ if (args.action !== 'start' && args.action !== 'stop')
81
+ return fail('action must be start, stop or status.');
82
+ const dir = safeProjectDir(ctx, args.projectDir);
83
+ if (!dir)
84
+ return { content: OUTSIDE, is_error: true };
85
+ if (args.action === 'stop') {
86
+ // Start keys the registry by the REAL path (M-3), so stop must too: a junction
87
+ // then stops the link it started. When the real path can't be read (the folder
88
+ // was deleted, or a link now points elsewhere), fall back to the resolved path;
89
+ // stop only ever closes a link this session started.
90
+ let stopDir = dir;
91
+ try {
92
+ stopDir = await assertRealPathContained(dir, ctx.cwd);
93
+ }
94
+ catch {
95
+ stopDir = dir;
96
+ }
97
+ return { content: (await stopSapLink(stopDir)) ? 'SAP link stopped.' : 'No SAP link was running for that project.' };
98
+ }
99
+ if (isStandalone(ctx))
100
+ return fail('The SAP link needs a SAP system. This session runs without one.');
101
+ let realDir;
102
+ try {
103
+ // M-3: the real path is the registry key, so a junction and its target share one link.
104
+ realDir = await assertRealPathContained(dir, ctx.cwd);
105
+ }
106
+ catch (e) {
107
+ const code = e?.code;
108
+ if (code === 'ENOENT')
109
+ return fail('projectDir does not exist.');
110
+ return { content: OUTSIDE, is_error: true };
111
+ }
112
+ try {
113
+ const r = await ensureSapLink(realDir, ctx.sapAlias);
114
+ const lines = [
115
+ `SAP link ${r.reused ? 'already on' : 'on'}: ${r.handle.url} → ${ctx.sapAlias}`,
116
+ `Services: ${r.services.join(', ')}`,
117
+ ];
118
+ if (r.roleNote)
119
+ lines.push(r.roleNote);
120
+ if (r.tlsNote)
121
+ lines.push(r.tlsNote);
122
+ if (r.httpNote)
123
+ lines.push(r.httpNote);
124
+ lines.push('Start the app with: cds watch --profile s4');
125
+ return { content: lines.join('\n') };
126
+ }
127
+ catch (e) {
128
+ if (e instanceof SapLinkError || e instanceof LinkConfigError)
129
+ return fail(e.message);
130
+ return fail('The SAP link could not start.');
131
+ }
132
+ },
133
+ });