@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.
@@ -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) ? 'protected' : 'existence';
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 || !EVIDENCE_TOOL_RE.test(row.toolName))
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
- const evidence = await extractAuditEvidence(sessionId, sinceIso ? { sinceIso } : undefined);
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
- function key(sessionId, alias) {
18
- return `${sessionId ?? '(no session)'}\u0000${alias}`;
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
- let passwordValue;
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.