@cspeach/cli 1.1.22 → 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-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 +1 -1
- 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/package.json +121 -121
|
@@ -38,6 +38,8 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
38
38
|
// silent). Headless runs are declined, never auto-approved.
|
|
39
39
|
// The hook may be async (it reads the app's files). A hook that throws is
|
|
40
40
|
// doubt — never let the call through unprompted: fail the call instead.
|
|
41
|
+
// `scope` (optional) keys the approval per tool too — sap_link sets it so a
|
|
42
|
+
// fiori_preview approval for the same alias does not cover it (Task 8 M-2).
|
|
41
43
|
let handover = null;
|
|
42
44
|
try {
|
|
43
45
|
handover = (await tool.credentialHandover?.(args, ctx)) ?? null;
|
|
@@ -49,7 +51,7 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
const sessionId = ctx.session?.id;
|
|
52
|
-
if (handover && !isCredentialHandoverApproved(sessionId, handover.alias)) {
|
|
54
|
+
if (handover && !isCredentialHandoverApproved(sessionId, handover.alias, handover.scope)) {
|
|
53
55
|
const r = await presentSafetyConfirmation({
|
|
54
56
|
rule: 'CREDENTIAL_HANDOVER',
|
|
55
57
|
op: tool.name,
|
|
@@ -61,7 +63,7 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
61
63
|
});
|
|
62
64
|
if (!r.confirmed)
|
|
63
65
|
return declined(r);
|
|
64
|
-
markCredentialHandoverApproved(sessionId, handover.alias);
|
|
66
|
+
markCredentialHandoverApproved(sessionId, handover.alias, handover.scope);
|
|
65
67
|
}
|
|
66
68
|
// Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
|
|
67
69
|
// explicit confirmation. Fires regardless of /safety-mode toggle (batch
|
package/dist/cli.js
CHANGED
|
@@ -203,6 +203,18 @@ export async function main(argv) {
|
|
|
203
203
|
},
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
|
+
// CAP phase 2 — run the SAP link in the foreground for a local CAP app.
|
|
207
|
+
if (args[0] === 'sap' && args[1] === 'link') {
|
|
208
|
+
const { runSapLinkCommand } = await import('./commands/sap-link.js');
|
|
209
|
+
return runSapLinkCommand(args.slice(2), {
|
|
210
|
+
print: (s) => console.log(s),
|
|
211
|
+
defaultAlias: sapAlias,
|
|
212
|
+
standalone: async () => {
|
|
213
|
+
const { loadConfig, resolveStandalone } = await import('./config/loader.js');
|
|
214
|
+
return resolveStandalone(await loadConfig(), noSap);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
206
218
|
// Typed from yesterday's notes. Without this the words fall through to the
|
|
207
219
|
// one-shot path at the bottom of this function and are sent to the model as
|
|
208
220
|
// a PAID prompt on a machine that is configured and logged in. A hint, not
|
|
@@ -255,6 +267,7 @@ Usage:
|
|
|
255
267
|
cspeach config set <key> <value> Update a config key
|
|
256
268
|
cspeach config unset <key> Remove a config key (revert to default)
|
|
257
269
|
cspeach config show [key] Show config (full or single key)
|
|
270
|
+
cspeach sap link [alias] [--project dir] Let a local CAP app reach its S/4 services (Ctrl+C stops)
|
|
258
271
|
cspeach doctor Diagnose install + connectivity
|
|
259
272
|
cspeach login Sign in via device-code flow
|
|
260
273
|
cspeach logout Sign out (keep keychain key, if any)
|
|
@@ -69,6 +69,52 @@ const PROTECTED_TOOL_RE = /^sap_(set_source|update_method|create_object|delete_o
|
|
|
69
69
|
const EXISTENCE_READ_RE = /^sap_(object_structure|get_source|transport_for_object)/;
|
|
70
70
|
/** Any row the auditor may see is the union of the two tiers. */
|
|
71
71
|
const EVIDENCE_TOOL_RE = new RegExp(`${PROTECTED_TOOL_RE.source}|${EXISTENCE_READ_RE.source}`);
|
|
72
|
+
/**
|
|
73
|
+
* CAP phase 2 (2026-09-30): a CAP phase writes LOCAL files and proves itself
|
|
74
|
+
* with `cds build` + the Fiori/CAP smokes — none of which are sap_* rows. Without
|
|
75
|
+
* these the auditor sees the "(no ...)" sentinel and B4-blocks a finished phase.
|
|
76
|
+
* PROTECTED tier: they are the proof, never evicted by reads.
|
|
77
|
+
*
|
|
78
|
+
* R26: the CAP-only tools (and `cds build` rows) exist only in CAP work, so they
|
|
79
|
+
* are admitted always. The fiori_* smokes also run in RAP/Fiori sessions; they
|
|
80
|
+
* are admitted ONLY when the audited phase is a cap.* phase, so RAP/Fiori audits
|
|
81
|
+
* stay byte-identical to main.
|
|
82
|
+
*/
|
|
83
|
+
export const CAP_PROOF_TOOL_RE = /^(cap_metadata_import|cap_link_smoke)$/;
|
|
84
|
+
/** Fiori smokes: CAP proof only in a cap.* phase audit (R26). */
|
|
85
|
+
export const CAP_PHASE_SMOKE_TOOL_RE = /^(fiori_render_smoke|fiori_draft_smoke|fiori_metadata_audit)$/;
|
|
86
|
+
/** True for a CAP proof row (tool name + args), given whether the audited phase is cap.*. */
|
|
87
|
+
function isCapProof(toolName, args, capPhase) {
|
|
88
|
+
if (CAP_PROOF_TOOL_RE.test(toolName))
|
|
89
|
+
return true;
|
|
90
|
+
if (capPhase && CAP_PHASE_SMOKE_TOOL_RE.test(toolName))
|
|
91
|
+
return true;
|
|
92
|
+
return toolName === 'shell_exec' && isCapBuildShellRow(args);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A shell_exec row is CAP proof only for `cds build|compile|import`, with `cds`
|
|
96
|
+
* matched BY POSITION (controller ruling R6): argv[0] (`cds build`), argv[1]
|
|
97
|
+
* after `npx`, or argv[2] after `npm exec` / `pnpm exec`. Not `pnpm dlx cds`: it
|
|
98
|
+
* fetches the registry package `cds`, not @sap/cds-dk, so it proves nothing. Matching
|
|
99
|
+
* `cds` anywhere would let a safelisted row such as
|
|
100
|
+
* `git commit -m cds build` or `node x.js cds build` pass as PROTECTED build proof.
|
|
101
|
+
*/
|
|
102
|
+
export function isCapBuildShellRow(args) {
|
|
103
|
+
if (!args || typeof args !== 'object')
|
|
104
|
+
return false;
|
|
105
|
+
const a = args;
|
|
106
|
+
const argv = [a.command, ...(Array.isArray(a.args) ? a.args : [])].map(String);
|
|
107
|
+
let i = -1;
|
|
108
|
+
if (argv[0] === 'cds')
|
|
109
|
+
i = 0;
|
|
110
|
+
else if (argv[0] === 'npx' && argv[1] === 'cds')
|
|
111
|
+
i = 1;
|
|
112
|
+
else if ((argv[0] === 'npm' || argv[0] === 'pnpm') && argv[1] === 'exec') {
|
|
113
|
+
if (argv[2] === 'cds')
|
|
114
|
+
i = 2;
|
|
115
|
+
}
|
|
116
|
+
return i >= 0 && ['build', 'compile', 'import'].includes(argv[i + 1] ?? '');
|
|
117
|
+
}
|
|
72
118
|
const NO_LOG_SENTINEL = '(no tool-call log found for this session)';
|
|
73
119
|
const NO_EVIDENCE_ROWS = '(tool-call log present, but no write/verify calls recorded for this session)';
|
|
74
120
|
const DEFAULT_MAX_ROWS = 60;
|
|
@@ -101,7 +147,7 @@ function resultCapFor(toolName) {
|
|
|
101
147
|
return ATC_RESULT_CAP;
|
|
102
148
|
return RESULT_EXCERPT_CHARS;
|
|
103
149
|
}
|
|
104
|
-
function normalizeRow(raw) {
|
|
150
|
+
function normalizeRow(raw, capPhase) {
|
|
105
151
|
if (raw === null || typeof raw !== 'object')
|
|
106
152
|
return null;
|
|
107
153
|
const r = raw;
|
|
@@ -119,9 +165,19 @@ function normalizeRow(raw) {
|
|
|
119
165
|
// PROTECTED is checked first: it is a strict-enough family that no
|
|
120
166
|
// existence-read name can collide with it, but ordering makes the tiering
|
|
121
167
|
// explicit for the reader.
|
|
122
|
-
const kind = PROTECTED_TOOL_RE.test(toolName)
|
|
168
|
+
const kind = PROTECTED_TOOL_RE.test(toolName) || isCapProof(toolName, r.args, capPhase)
|
|
169
|
+
? 'protected' : 'existence';
|
|
123
170
|
return { toolName, args: r.args, result: r.result, isError, at, kind };
|
|
124
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Admission filter: the sap_* write/verify + existence-read tiers, plus CAP
|
|
174
|
+
* proof (cap_link_smoke / cap_metadata_import, the fiori_* smokes in a cap.*
|
|
175
|
+
* phase only, and shell_exec ONLY for `cds build|compile|import` — never
|
|
176
|
+
* arbitrary shell rows).
|
|
177
|
+
*/
|
|
178
|
+
function isEvidenceRow(row, capPhase) {
|
|
179
|
+
return EVIDENCE_TOOL_RE.test(row.toolName) || isCapProof(row.toolName, row.args, capPhase);
|
|
180
|
+
}
|
|
125
181
|
/** Render the args as `name/type` when available, else a compact fallback. */
|
|
126
182
|
function renderArgs(args) {
|
|
127
183
|
if (args === null || args === undefined || typeof args !== 'object')
|
|
@@ -201,6 +257,7 @@ function renderRow(row) {
|
|
|
201
257
|
*/
|
|
202
258
|
export async function extractAuditEvidence(sessionId, opts) {
|
|
203
259
|
const maxRows = opts?.maxRows ?? DEFAULT_MAX_ROWS;
|
|
260
|
+
const capPhase = opts?.capPhase === true;
|
|
204
261
|
const sinceIso = opts?.sinceIso;
|
|
205
262
|
const filePath = path.join(checkpointsRoot(), sessionId, 'tool-calls.jsonl');
|
|
206
263
|
let content;
|
|
@@ -222,8 +279,8 @@ export async function extractAuditEvidence(sessionId, opts) {
|
|
|
222
279
|
catch {
|
|
223
280
|
continue; // malformed line — skip silently
|
|
224
281
|
}
|
|
225
|
-
const row = normalizeRow(parsed);
|
|
226
|
-
if (!row || !
|
|
282
|
+
const row = normalizeRow(parsed, capPhase);
|
|
283
|
+
if (!row || !isEvidenceRow(row, capPhase))
|
|
227
284
|
continue;
|
|
228
285
|
// D-B attempt-window: drop rows recorded before this attempt started.
|
|
229
286
|
// ISO-8601 UTC strings compare correctly lexicographically. A row with no
|
|
@@ -189,6 +189,13 @@ export const PLAN_AUDIT_PROMPT_CONTRACT = [
|
|
|
189
189
|
'- Transport (infra, not a deliverable): recorded in work.transport (and/or an sap_transport',
|
|
190
190
|
' row). A transport is NEVER required in work.generated and is NEVER activated — do not flag',
|
|
191
191
|
' a transport for having no write and no activation.',
|
|
192
|
+
'- Local CAP work (phase layer cap.link / cap.model / cap.service / cap.ui): NO SAP object is',
|
|
193
|
+
' written and NO sap_activate is expected — its absence is NEVER a finding for these layers.',
|
|
194
|
+
' Proof is: an ok shell_exec `cds build` (or `cds compile`) row for the build, and for',
|
|
195
|
+
' cap.link an ok cap_link_smoke live read leg (a result saying "mock only" does not count);',
|
|
196
|
+
' for cap.model and cap.service an ok `cds build` plus an ok cap_link_smoke mock; for cap.ui',
|
|
197
|
+
' ok fiori_render_smoke, ok fiori_draft_smoke when the app has draft, and ok cap_link_smoke.',
|
|
198
|
+
' A cap_link_smoke result that says "mock only" proves the offline gate, not the live one.',
|
|
192
199
|
'If you cannot map a type code to one of these classes, that is a W3 warn, not a block.',
|
|
193
200
|
'',
|
|
194
201
|
'ESCALATION EXCEPTION (§6, governs B3) — recognized from GROUND TRUTH, not the manifest\'s',
|
|
@@ -427,7 +434,12 @@ async function buildAuditPrompt(args) {
|
|
|
427
434
|
manifest: phase.manifest,
|
|
428
435
|
work: phase.work ?? null,
|
|
429
436
|
}, null, 2);
|
|
430
|
-
|
|
437
|
+
// R26: fiori_* smoke rows are evidence only when the audited phase is a CAP phase.
|
|
438
|
+
const capPhase = typeof phase.layer === 'string' && phase.layer.startsWith('cap.');
|
|
439
|
+
const evidence = await extractAuditEvidence(sessionId, {
|
|
440
|
+
...(sinceIso ? { sinceIso } : {}),
|
|
441
|
+
...(capPhase ? { capPhase } : {}),
|
|
442
|
+
});
|
|
431
443
|
const windowAttr = sinceIso ? ` window="this attempt only, since ${sinceIso}"` : '';
|
|
432
444
|
return [
|
|
433
445
|
`Independent audit of plan phase "${phaseId}" — plan "${envelope.title}" (envelope v${envelope.version}).`,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// cspeach-cli/src/commands/sap-link.ts
|
|
2
|
+
//
|
|
3
|
+
// CAP phase 2, Task 8 — `cspeach sap link [alias] [--project dir]`: run the SAP
|
|
4
|
+
// link in the foreground for a local CAP app. Ctrl+C stops it and exits 0.
|
|
5
|
+
// Never prompts for a password; refuses in standalone (no-SAP) mode; prints
|
|
6
|
+
// only plain registry messages, never raw exception text.
|
|
7
|
+
import { ensureSapLink, stopSapLink, SapLinkError } from '../sap-link/registry.js';
|
|
8
|
+
import { LinkConfigError } from '../sap-link/project.js';
|
|
9
|
+
function waitForSignal() {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const done = () => {
|
|
12
|
+
process.off('SIGINT', done);
|
|
13
|
+
process.off('SIGTERM', done);
|
|
14
|
+
resolve();
|
|
15
|
+
};
|
|
16
|
+
process.once('SIGINT', done);
|
|
17
|
+
process.once('SIGTERM', done);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function runSapLinkCommand(argv, deps) {
|
|
21
|
+
if (deps.standalone && (await deps.standalone())) {
|
|
22
|
+
deps.print('The SAP link needs a SAP system. CSPeach is running without one (standalone).');
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
const pi = argv.indexOf('--project');
|
|
26
|
+
const projectDir = pi >= 0 ? argv[pi + 1] ?? '.' : '.';
|
|
27
|
+
const alias = argv.find((a, i) => !a.startsWith('--') && argv[i - 1] !== '--project') ?? deps.defaultAlias;
|
|
28
|
+
if (!alias) {
|
|
29
|
+
deps.print('Name the SAP system: cspeach sap link <alias>');
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const r = await ensureSapLink(projectDir, alias, deps.linkDeps);
|
|
34
|
+
deps.print(`SAP link on: ${r.handle.url} → ${alias}`);
|
|
35
|
+
deps.print(`Services: ${r.services.join(', ')}`);
|
|
36
|
+
if (r.roleNote)
|
|
37
|
+
deps.print(r.roleNote);
|
|
38
|
+
if (r.tlsNote)
|
|
39
|
+
deps.print(r.tlsNote);
|
|
40
|
+
if (r.httpNote)
|
|
41
|
+
deps.print(r.httpNote);
|
|
42
|
+
deps.print('Start your app with: cds watch --profile s4 (Ctrl+C here stops the link)');
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
deps.print(e instanceof SapLinkError || e instanceof LinkConfigError ? e.message : 'The SAP link could not start.');
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
await (deps.waitForStop ?? waitForSignal)();
|
|
49
|
+
await stopSapLink(projectDir);
|
|
50
|
+
deps.print('SAP link stopped.');
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// cspeach-cli/src/lib/shutdown-hooks.ts
|
|
2
|
+
/**
|
|
3
|
+
* One shared "managed-resource shutdown" for the whole CLI process.
|
|
4
|
+
*
|
|
5
|
+
* Why shared (Task 8 fix round 1, I-1): the Fiori preview registry and the SAP
|
|
6
|
+
* link registry each used to prepend their OWN signal listener and re-raise only
|
|
7
|
+
* when `listenerCount(sig) === 1`. With both installed each saw a count of 2,
|
|
8
|
+
* neither re-raised, and Ctrl+C / SIGTERM no longer ended the CLI.
|
|
9
|
+
*
|
|
10
|
+
* Now each registry registers a synchronous cleanup here. This module installs
|
|
11
|
+
* ONE `exit` listener and ONE prepended listener per terminating signal, runs
|
|
12
|
+
* every cleanup (a throwing cleanup never stops the others), then re-raises the
|
|
13
|
+
* signal when every remaining listener is its own — so a command that waits on
|
|
14
|
+
* the signal itself (e.g. `cspeach sap link`) still gets it, and otherwise
|
|
15
|
+
* Node's default "exit on signal" behaviour is kept.
|
|
16
|
+
*
|
|
17
|
+
* PREPEND: ui/alt-screen.ts re-raises with process.kill(process.pid, sig), which
|
|
18
|
+
* on Windows is an immediate TerminateProcess — a listener appended after it
|
|
19
|
+
* would never run.
|
|
20
|
+
*
|
|
21
|
+
* The re-raise can throw (SIGHUP is ENOSYS on Windows): it is wrapped and falls
|
|
22
|
+
* back to process.exit(128 + signal number).
|
|
23
|
+
*/
|
|
24
|
+
import * as os from 'node:os';
|
|
25
|
+
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
26
|
+
const hooks = new Set();
|
|
27
|
+
let installed = false;
|
|
28
|
+
const listeners = new Map();
|
|
29
|
+
function runHooks() {
|
|
30
|
+
for (const h of [...hooks]) {
|
|
31
|
+
try {
|
|
32
|
+
h();
|
|
33
|
+
}
|
|
34
|
+
catch { /* best effort: one failing cleanup never blocks the rest */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function reRaise(sig) {
|
|
38
|
+
const own = listeners.get(sig);
|
|
39
|
+
if (own)
|
|
40
|
+
process.removeListener(sig, own);
|
|
41
|
+
try {
|
|
42
|
+
process.kill(process.pid, sig);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
const n = os.constants.signals[sig] ?? 15;
|
|
46
|
+
process.exit(128 + n);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function install() {
|
|
50
|
+
if (installed)
|
|
51
|
+
return;
|
|
52
|
+
installed = true;
|
|
53
|
+
const onExit = () => runHooks();
|
|
54
|
+
listeners.set('exit', onExit);
|
|
55
|
+
process.on('exit', onExit);
|
|
56
|
+
for (const sig of SIGNALS) {
|
|
57
|
+
const onSignal = () => {
|
|
58
|
+
runHooks();
|
|
59
|
+
const rest = process.listeners(sig);
|
|
60
|
+
if (rest.every((l) => l === onSignal))
|
|
61
|
+
reRaise(sig);
|
|
62
|
+
};
|
|
63
|
+
listeners.set(sig, onSignal);
|
|
64
|
+
process.prependListener(sig, onSignal);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Register a SYNCHRONOUS cleanup to run on process exit or a terminating
|
|
69
|
+
* signal. Installs the shared listeners on first use. Returns an unregister.
|
|
70
|
+
*/
|
|
71
|
+
export function registerShutdownHook(hook) {
|
|
72
|
+
install();
|
|
73
|
+
hooks.add(hook);
|
|
74
|
+
return () => { hooks.delete(hook); };
|
|
75
|
+
}
|
|
76
|
+
/** Test-only — the listener installed for a signal (or 'exit'). */
|
|
77
|
+
export function __shutdownListenerFor(name) {
|
|
78
|
+
return listeners.get(name);
|
|
79
|
+
}
|
|
80
|
+
/** Test-only — remove the shared listeners and forget every hook. */
|
|
81
|
+
export function __resetShutdownHooksForTests() {
|
|
82
|
+
for (const [name, l] of listeners)
|
|
83
|
+
process.removeListener(name, l);
|
|
84
|
+
listeners.clear();
|
|
85
|
+
hooks.clear();
|
|
86
|
+
installed = false;
|
|
87
|
+
}
|
package/dist/one-shot.js
CHANGED
|
@@ -52,6 +52,10 @@ import './tools/filesystem/glob.js';
|
|
|
52
52
|
import './tools/filesystem/grep.js';
|
|
53
53
|
import './tools/shell/shell_exec.js';
|
|
54
54
|
import './tools/fiori/tools.js';
|
|
55
|
+
import './tools/cap/service-catalog.js';
|
|
56
|
+
import './tools/cap/metadata-import.js';
|
|
57
|
+
import './tools/cap/sap-link-tool.js';
|
|
58
|
+
import './tools/cap/link-smoke.js';
|
|
55
59
|
import './tools/extend-model/tool.js';
|
|
56
60
|
import './tools/capability/tool.js';
|
|
57
61
|
import './tools/web/web_fetch.js';
|
|
@@ -112,6 +112,10 @@ const LAYER_LABELS = {
|
|
|
112
112
|
'orchestration': 'Jobs & reports',
|
|
113
113
|
'integration': 'Integration',
|
|
114
114
|
'ui': 'UI',
|
|
115
|
+
'cap.link': 'S/4 link',
|
|
116
|
+
'cap.model': 'CAP data model',
|
|
117
|
+
'cap.service': 'CAP service',
|
|
118
|
+
'cap.ui': 'CAP app',
|
|
115
119
|
};
|
|
116
120
|
const DELEGATE_LABELS = {
|
|
117
121
|
'abap-design': 'Design',
|
|
@@ -353,7 +357,7 @@ g = getGlyphs()) {
|
|
|
353
357
|
continue;
|
|
354
358
|
lines.push(`${g.attention} ${p.id} was NOT audit-verified (audit infrastructure failed — its own exit gate, activation + ATC, passed).`);
|
|
355
359
|
}
|
|
356
|
-
const hasUi = phases.some((p) => p.layer === 'ui' || p.delegateTo === 'abap-fiori-build');
|
|
360
|
+
const hasUi = phases.some((p) => p.layer === 'ui' || p.layer === 'cap.ui' || p.delegateTo === 'abap-fiori-build');
|
|
357
361
|
const hasService = phases.some((p) => p.layer === 'service');
|
|
358
362
|
if (!hasUi && hasService) {
|
|
359
363
|
const binding = findServiceBinding(phases);
|
|
@@ -16,6 +16,7 @@ import { z } from 'zod';
|
|
|
16
16
|
export const PLAN_LAYERS = [
|
|
17
17
|
'types', 'persistence', 'data-model', 'behavior', 'service',
|
|
18
18
|
'orchestration', 'integration', 'ui',
|
|
19
|
+
'cap.link', 'cap.model', 'cap.service', 'cap.ui',
|
|
19
20
|
];
|
|
20
21
|
/**
|
|
21
22
|
* Existing skills a phase may delegate to. Keep in sync with
|
|
@@ -29,7 +30,7 @@ export const PLAN_LAYERS = [
|
|
|
29
30
|
export const PLAN_DELEGATE_SKILLS = [
|
|
30
31
|
'abap-design', 'abap-data-model', 'abap-rap', 'abap-eml',
|
|
31
32
|
'abap-generate', 'abap-segw', 'abap-test', 'abap-fiori-build',
|
|
32
|
-
'abap-extend-model',
|
|
33
|
+
'abap-extend-model', 'abap-cap',
|
|
33
34
|
];
|
|
34
35
|
/**
|
|
35
36
|
* Keep in sync with ItemStatusByType['plan'] in types.ts.
|
|
@@ -153,6 +153,10 @@ const RULES = {
|
|
|
153
153
|
sap_preview_change: sapObject('Previewed change'),
|
|
154
154
|
sap_run_unit_test: sapObject('Ran unit tests'),
|
|
155
155
|
sap_odata_test: (a) => ({ verb: 'OData test', object: shortRight([str(a['servicePath']), str(a['entity'])].filter(Boolean).join(' ')) }),
|
|
156
|
+
sap_service_catalog: (a) => ({ verb: 'Service catalog', object: shortRight(str(a['search']) ?? '') }),
|
|
157
|
+
cap_metadata_import: (a) => ({ verb: 'Import metadata', object: str(a['apiName']) ?? '' }),
|
|
158
|
+
sap_link: (a) => ({ verb: 'SAP link', object: str(a['action']) ?? '' }),
|
|
159
|
+
cap_link_smoke: (a) => ({ verb: 'S/4 link check', object: str(a['s4']?.['entitySet']) ?? '' }),
|
|
156
160
|
sap_badi_list: (a) => ({ verb: 'Searched BAdIs', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
|
|
157
161
|
sap_snapshot_list: (a) => ({ verb: 'Listed snapshots', object: shortRight(str(a['name']) ?? '') }),
|
|
158
162
|
sap_snapshot_read: sapObject('Read snapshot'),
|
|
@@ -241,7 +245,7 @@ const PRESENT = {
|
|
|
241
245
|
Structure: 'Reading structure of', 'Change log': 'Reading change log of', 'Read dumps': 'Reading dumps',
|
|
242
246
|
'API state': 'Checking API state of', 'ABAP docs': 'Reading ABAP docs for', 'Read includes': 'Reading includes of',
|
|
243
247
|
Compared: 'Comparing', 'Previewed change': 'Previewing change to', 'Ran unit tests': 'Running unit tests on',
|
|
244
|
-
'OData test': 'Testing OData', 'Searched BAdIs': 'Searching BAdIs', 'Listed snapshots': 'Listing snapshots',
|
|
248
|
+
'OData test': 'Testing OData', 'Service catalog': 'Reading service catalog', 'Import metadata': 'Importing metadata', 'SAP link': 'SAP link', 'S/4 link check': 'Checking S/4 link', 'Searched BAdIs': 'Searching BAdIs', 'Listed snapshots': 'Listing snapshots',
|
|
245
249
|
'Read snapshot': 'Reading snapshot of', 'Cleaned snapshots': 'Cleaning snapshots',
|
|
246
250
|
'Created transport': 'Creating transport', 'Transport for': 'Finding transport for',
|
|
247
251
|
'Listed transports': 'Listing transports', Released: 'Releasing', 'Created domain': 'Creating domain',
|
|
@@ -14,14 +14,20 @@
|
|
|
14
14
|
* session id inside one, asks again. Tests call the reset in beforeEach.
|
|
15
15
|
*/
|
|
16
16
|
const approved = new Set();
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* `scope` (optional, Task 8 fix M-2) is a key discriminator: sap_link passes
|
|
19
|
+
* 'sap_link' so an earlier fiori_preview approval for the same alias does not
|
|
20
|
+
* cover it. No scope → the key is byte-identical to before (fiori_preview).
|
|
21
|
+
*/
|
|
22
|
+
function key(sessionId, alias, scope) {
|
|
23
|
+
const base = `${sessionId ?? '(no session)'}\u0000${alias}`;
|
|
24
|
+
return scope ? `${base}\u0000${scope}` : base;
|
|
19
25
|
}
|
|
20
|
-
export function isCredentialHandoverApproved(sessionId, alias) {
|
|
21
|
-
return approved.has(key(sessionId, alias));
|
|
26
|
+
export function isCredentialHandoverApproved(sessionId, alias, scope) {
|
|
27
|
+
return approved.has(key(sessionId, alias, scope));
|
|
22
28
|
}
|
|
23
|
-
export function markCredentialHandoverApproved(sessionId, alias) {
|
|
24
|
-
approved.add(key(sessionId, alias));
|
|
29
|
+
export function markCredentialHandoverApproved(sessionId, alias, scope) {
|
|
30
|
+
approved.add(key(sessionId, alias, scope));
|
|
25
31
|
}
|
|
26
32
|
export function __resetCredentialHandoverGateForTests() {
|
|
27
33
|
approved.clear();
|
package/dist/repl.js
CHANGED
|
@@ -148,6 +148,10 @@ import './tools/filesystem/glob.js';
|
|
|
148
148
|
import './tools/filesystem/grep.js';
|
|
149
149
|
import './tools/shell/shell_exec.js';
|
|
150
150
|
import './tools/fiori/tools.js';
|
|
151
|
+
import './tools/cap/service-catalog.js';
|
|
152
|
+
import './tools/cap/metadata-import.js';
|
|
153
|
+
import './tools/cap/sap-link-tool.js';
|
|
154
|
+
import './tools/cap/link-smoke.js';
|
|
151
155
|
import './tools/extend-model/tool.js';
|
|
152
156
|
import './tools/capability/tool.js';
|
|
153
157
|
import './tools/web/web_fetch.js';
|
|
@@ -5,34 +5,40 @@ import { password as promptPassword } from '@inquirer/prompts';
|
|
|
5
5
|
import { isHeadless } from '../renderer/tty.js';
|
|
6
6
|
const SAP_SERVICE = 'cspeach-sap';
|
|
7
7
|
const cache = new Map();
|
|
8
|
+
/**
|
|
9
|
+
* The single place that reads an SAP alias password: keychain first, then a
|
|
10
|
+
* masked prompt when interactive. Headless callers (and the SAP link, which
|
|
11
|
+
* passes `headless: () => true`) never prompt. The password is never logged
|
|
12
|
+
* and never placed in an error message.
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveSapPassword(alias, cfg, deps = {}) {
|
|
15
|
+
if (cfg.authType !== 'basic') {
|
|
16
|
+
throw new Error('x509 auth not yet supported in v0.1 — set authType=basic in config');
|
|
17
|
+
}
|
|
18
|
+
const getPassword = deps.getPassword ?? ((s, a) => keychain.getPassword(s, a));
|
|
19
|
+
let value;
|
|
20
|
+
try {
|
|
21
|
+
value = (await getPassword(SAP_SERVICE, alias)) ?? undefined;
|
|
22
|
+
}
|
|
23
|
+
catch { /* keychain unavailable */ }
|
|
24
|
+
if (value)
|
|
25
|
+
return value;
|
|
26
|
+
// B5 (2026-06-11) — headless guard: the keytar-miss fallback is a
|
|
27
|
+
// stdin password prompt, which would hang a headless run forever
|
|
28
|
+
// (same defect family as D1). Fail fast with an actionable error
|
|
29
|
+
// instead of prompting.
|
|
30
|
+
if ((deps.headless ?? isHeadless)()) {
|
|
31
|
+
throw new Error(`no stored password for SAP alias '${alias}' — store credentials first ` +
|
|
32
|
+
'(run interactively once or use cspeach config) — headless runs cannot prompt');
|
|
33
|
+
}
|
|
34
|
+
const prompt = deps.prompt ?? ((message) => promptPassword({ message, mask: '*' }));
|
|
35
|
+
return prompt(`SAP password for ${alias}/${cfg.username}:`);
|
|
36
|
+
}
|
|
8
37
|
export async function getConnection(alias, cfg) {
|
|
9
38
|
const existing = cache.get(alias);
|
|
10
39
|
if (existing)
|
|
11
40
|
return existing;
|
|
12
|
-
|
|
13
|
-
if (cfg.authType === 'basic') {
|
|
14
|
-
try {
|
|
15
|
-
passwordValue = (await keychain.getPassword(SAP_SERVICE, alias)) ?? undefined;
|
|
16
|
-
}
|
|
17
|
-
catch { /* keychain unavailable */ }
|
|
18
|
-
if (!passwordValue) {
|
|
19
|
-
// B5 (2026-06-11) — headless guard: the keytar-miss fallback is a
|
|
20
|
-
// stdin password prompt, which would hang a headless run forever
|
|
21
|
-
// (same defect family as D1). Fail fast with an actionable error
|
|
22
|
-
// instead of prompting.
|
|
23
|
-
if (isHeadless()) {
|
|
24
|
-
throw new Error(`no stored password for SAP alias '${alias}' — store credentials first ` +
|
|
25
|
-
'(run interactively once or use cspeach config) — headless runs cannot prompt');
|
|
26
|
-
}
|
|
27
|
-
passwordValue = await promptPassword({
|
|
28
|
-
message: `SAP password for ${alias}/${cfg.username}:`,
|
|
29
|
-
mask: '*',
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
if (cfg.authType !== 'basic') {
|
|
34
|
-
throw new Error('x509 auth not yet supported in v0.1 — set authType=basic in config');
|
|
35
|
-
}
|
|
41
|
+
const passwordValue = await resolveSapPassword(alias, cfg);
|
|
36
42
|
// SapConnection constructor is (config, username, password) — 3 positional args.
|
|
37
43
|
// SapConfig.authType is typed as 'basic' only (v1); SapSystemConfig carries the
|
|
38
44
|
// wider union so we cast to satisfy the narrower SapConfig shape.
|