@cspeach/cli 1.1.25 → 1.1.26

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 (44) hide show
  1. package/dist/agent/loop.js +20 -8
  2. package/dist/agent/tool-dispatch.js +6 -0
  3. package/dist/auth/auth-file.js +173 -12
  4. package/dist/classifier/client.js +55 -30
  5. package/dist/cli.js +11 -0
  6. package/dist/commands/config-set.js +13 -0
  7. package/dist/commands/config-show.js +5 -1
  8. package/dist/commands/cost.js +24 -22
  9. package/dist/commands/deploy-card.js +135 -0
  10. package/dist/commands/login.js +31 -13
  11. package/dist/commands/plan-chain.js +31 -0
  12. package/dist/commands/plan-continue.js +1 -0
  13. package/dist/commands/plan-resume.js +49 -2
  14. package/dist/cost/cost-log.js +76 -4
  15. package/dist/cost/credits-wire.js +9 -0
  16. package/dist/cost/credits.js +24 -6
  17. package/dist/cost/router-spend.js +24 -0
  18. package/dist/cost/session-cost.js +16 -2
  19. package/dist/models/server-config.js +21 -1
  20. package/dist/one-shot.js +12 -2
  21. package/dist/projects/plan-lane.js +47 -0
  22. package/dist/projects/plan-run.js +8 -0
  23. package/dist/projects/plan-schema.js +2 -0
  24. package/dist/projects/save-command.js +3 -2
  25. package/dist/renderer/footer-line.js +10 -4
  26. package/dist/renderer/status-footer.js +5 -4
  27. package/dist/renderer/tool-labels.js +2 -1
  28. package/dist/repl.js +41 -8
  29. package/dist/router/byok-classifier.js +159 -0
  30. package/dist/router/classifier.js +130 -6
  31. package/dist/router/router-core.js +307 -0
  32. package/dist/router/router-prompt.js +121 -0
  33. package/dist/router/routing-failure.js +65 -3
  34. package/dist/skills/bundled-skills.js +45 -45
  35. package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
  36. package/dist/tools/fiori/deploy/run.js +608 -0
  37. package/dist/tools/fiori/deploy/scope.js +8 -0
  38. package/dist/tools/fiori/deploy/yaml-read.js +236 -0
  39. package/dist/tools/fiori/deploy-config.js +24 -6
  40. package/dist/tools/fiori/tools.js +117 -5
  41. package/dist/tools/local-build.js +3 -0
  42. package/dist/tools/subagent/read_agent.js +4 -2
  43. package/dist/tools/tool-loading.js +1 -1
  44. package/package.json +1 -1
@@ -0,0 +1,236 @@
1
+ // cspeach-cli/src/tools/fiori/deploy/yaml-read.ts
2
+ /**
3
+ * fiori_deploy — read the deploy-to-abap task out of an app's ui5-deploy.yaml.
4
+ *
5
+ * The file sits in a folder the model can write, and what it says decides
6
+ * where the SAP logon is sent, so it is read STRICTLY: a small block-style
7
+ * YAML reader (the CLI ships no YAML library and the ruling forbids adding
8
+ * one — same stance as preview/app-guard.ts) that FAILS CLOSED on anything it
9
+ * cannot read with certainty: flow style, anchors / aliases, tags, block
10
+ * scalars, tabs, escapes in double quotes, duplicate keys, document markers.
11
+ *
12
+ * It reads exactly what the fiori engine writes (deploy-config.ts) and what
13
+ * `fiori add deploy-config` writes for an on-premise ABAP target. Anything a
14
+ * BTP / destination / cloud target needs is refused (out of scope, and a
15
+ * destination would send the logon somewhere this tool cannot check).
16
+ */
17
+ class YamlUncertain extends Error {
18
+ }
19
+ function stripComment(text) {
20
+ // A comment starts at " #" outside quotes (or at the line start).
21
+ let quote = null;
22
+ for (let i = 0; i < text.length; i++) {
23
+ const c = text[i];
24
+ if (quote) {
25
+ if (c === quote)
26
+ quote = null;
27
+ continue;
28
+ }
29
+ if (c === '"' || c === "'")
30
+ quote = c;
31
+ else if (c === '#' && (i === 0 || /\s/.test(text[i - 1])))
32
+ return text.slice(0, i).trimEnd();
33
+ }
34
+ return text.trimEnd();
35
+ }
36
+ function toLines(src) {
37
+ const out = [];
38
+ const raw = src.replace(/^/, '').split(/\r?\n/);
39
+ raw.forEach((r, i) => {
40
+ if (/^\s*(#.*)?$/.test(r))
41
+ return;
42
+ if (/^[ ]*\t/.test(r))
43
+ throw new YamlUncertain(`line ${i + 1} is indented with a tab`);
44
+ if (/^(---|\.\.\.)(\s|$)/.test(r))
45
+ throw new YamlUncertain(`line ${i + 1} is a YAML document marker`);
46
+ if (/^\s*%/.test(r))
47
+ throw new YamlUncertain(`line ${i + 1} is a YAML directive`);
48
+ const indent = r.length - r.trimStart().length;
49
+ const text = stripComment(r.slice(indent));
50
+ if (text.length === 0)
51
+ return;
52
+ out.push({ indent, text, no: i + 1 });
53
+ });
54
+ return out;
55
+ }
56
+ function scalar(raw, no) {
57
+ const v = raw.trim();
58
+ if (v.startsWith('"')) {
59
+ if (!v.endsWith('"') || v.length < 2)
60
+ throw new YamlUncertain(`line ${no}: unterminated double-quoted value`);
61
+ const body = v.slice(1, -1);
62
+ if (body.includes('\\') || body.includes('"'))
63
+ throw new YamlUncertain(`line ${no}: escapes in a double-quoted value`);
64
+ return body;
65
+ }
66
+ if (v.startsWith("'")) {
67
+ if (!v.endsWith("'") || v.length < 2)
68
+ throw new YamlUncertain(`line ${no}: unterminated single-quoted value`);
69
+ const body = v.slice(1, -1);
70
+ if (body.replace(/''/g, '').includes("'"))
71
+ throw new YamlUncertain(`line ${no}: a stray quote`);
72
+ return body.replace(/''/g, "'");
73
+ }
74
+ if (/^[{[&*!|>%@`]/.test(v))
75
+ throw new YamlUncertain(`line ${no}: the value is not a plain value (flow style, anchor, alias, tag or block scalar)`);
76
+ return v;
77
+ }
78
+ const KEY_RE = /^([A-Za-z0-9_.\-/$]+)\s*:(?:\s+(.*))?$/;
79
+ function parseNode(lines, i, indent) {
80
+ const line = lines[i];
81
+ if (!line || line.indent < indent)
82
+ return [null, i];
83
+ if (line.text === '-' || line.text.startsWith('- '))
84
+ return parseSeq(lines, i, line.indent);
85
+ return parseMap(lines, i, line.indent);
86
+ }
87
+ function parseSeq(lines, i, indent) {
88
+ const items = [];
89
+ while (i < lines.length && lines[i].indent === indent && (lines[i].text === '-' || lines[i].text.startsWith('- '))) {
90
+ const line = lines[i];
91
+ const rest = line.text.slice(1).trimStart();
92
+ if (rest.length === 0) {
93
+ const next = lines[i + 1];
94
+ if (next && next.indent > indent) {
95
+ const [v, ni] = parseNode(lines, i + 1, next.indent);
96
+ items.push(v);
97
+ i = ni;
98
+ }
99
+ else {
100
+ items.push(null);
101
+ i += 1;
102
+ }
103
+ continue;
104
+ }
105
+ const restIndent = indent + (line.text.length - rest.length);
106
+ if (KEY_RE.test(rest)) {
107
+ lines[i] = { indent: restIndent, text: rest, no: line.no };
108
+ const [v, ni] = parseMap(lines, i, restIndent);
109
+ items.push(v);
110
+ i = ni;
111
+ }
112
+ else {
113
+ items.push(scalar(rest, line.no));
114
+ i += 1;
115
+ }
116
+ }
117
+ if (i < lines.length && lines[i].indent > indent) {
118
+ throw new YamlUncertain(`line ${lines[i].no} is indented under nothing`);
119
+ }
120
+ return [items, i];
121
+ }
122
+ function parseMap(lines, i, indent) {
123
+ const obj = {};
124
+ while (i < lines.length && lines[i].indent === indent) {
125
+ const line = lines[i];
126
+ if (line.text === '-' || line.text.startsWith('- '))
127
+ break;
128
+ const m = KEY_RE.exec(line.text);
129
+ if (!m)
130
+ throw new YamlUncertain(`line ${line.no} is not a plain "key: value" line`);
131
+ const key = m[1];
132
+ if (Object.prototype.hasOwnProperty.call(obj, key))
133
+ throw new YamlUncertain(`line ${line.no}: duplicate key "${key}"`);
134
+ const value = m[2];
135
+ if (value !== undefined && value.length > 0) {
136
+ obj[key] = scalar(value, line.no);
137
+ i += 1;
138
+ continue;
139
+ }
140
+ const next = lines[i + 1];
141
+ if (next && next.indent > indent) {
142
+ const [v, ni] = parseNode(lines, i + 1, next.indent);
143
+ obj[key] = v;
144
+ i = ni;
145
+ }
146
+ else if (next && next.indent === indent && (next.text === '-' || next.text.startsWith('- '))) {
147
+ const [v, ni] = parseSeq(lines, i + 1, indent);
148
+ obj[key] = v;
149
+ i = ni;
150
+ }
151
+ else {
152
+ obj[key] = null;
153
+ i += 1;
154
+ }
155
+ }
156
+ if (i < lines.length && lines[i].indent > indent) {
157
+ throw new YamlUncertain(`line ${lines[i].no} is indented under nothing`);
158
+ }
159
+ return [obj, i];
160
+ }
161
+ export function parseStrictYaml(src) {
162
+ const lines = toLines(src);
163
+ if (lines.length === 0)
164
+ return null;
165
+ const [node, i] = parseNode(lines, 0, lines[0].indent);
166
+ if (i < lines.length)
167
+ throw new YamlUncertain(`line ${lines[i].no} could not be read`);
168
+ return node;
169
+ }
170
+ const isMap = (n) => !!n && typeof n === 'object' && !Array.isArray(n);
171
+ const str = (n) => (typeof n === 'string' ? n : undefined);
172
+ /** The only keys allowed under target: — anything else (destination, scp, cloud, service, …) is refused. */
173
+ const TARGET_KEYS = new Set(['url', 'client']);
174
+ /** The deploy-to-abap configuration keys fiori_deploy accepts. */
175
+ const CONFIG_KEYS = new Set(['target', 'app', 'ignoreCertErrors', 'exclude']);
176
+ const APP_KEYS = new Set(['name', 'description', 'package', 'transport']);
177
+ export function readDeployYaml(src) {
178
+ let root;
179
+ try {
180
+ root = parseStrictYaml(src);
181
+ }
182
+ catch (e) {
183
+ return { ok: false, error: `ui5-deploy.yaml could not be read with certainty — ${e instanceof Error ? e.message : String(e)}` };
184
+ }
185
+ const tasks = isMap(root) && isMap(root.builder) ? root.builder.customTasks : undefined;
186
+ const deployTasks = Array.isArray(tasks) ? tasks.filter((t) => isMap(t) && t.name === 'deploy-to-abap') : [];
187
+ if (deployTasks.length !== 1) {
188
+ return {
189
+ ok: false,
190
+ error: deployTasks.length === 0
191
+ ? 'ui5-deploy.yaml has no deploy-to-abap task — write it with fiori_deploy_config first'
192
+ : 'ui5-deploy.yaml has more than one deploy-to-abap task',
193
+ };
194
+ }
195
+ const cfg = deployTasks[0].configuration;
196
+ if (!isMap(cfg))
197
+ return { ok: false, error: 'the deploy-to-abap task has no configuration block' };
198
+ if (cfg.credentials !== undefined) {
199
+ return { ok: false, error: 'ui5-deploy.yaml carries a credentials block — remove it; CSPeach supplies the logon from its own store' };
200
+ }
201
+ // Review 2026-10-03: only known keys pass. Others change what is deployed or how —
202
+ // safeMode: false (no server-side 412 guard), archivePath/archiveUrl (another archive),
203
+ // strictSsl / ignoreCertError (TLS), keep, lrep, verbose (logs the config) …
204
+ for (const k of Object.keys(cfg)) {
205
+ if (!CONFIG_KEYS.has(k)) {
206
+ return { ok: false, error: `configuration.${k} is not supported by fiori_deploy (allowed: ${[...CONFIG_KEYS].join(', ')}) — remove it from ui5-deploy.yaml` };
207
+ }
208
+ }
209
+ const target = cfg.target;
210
+ const app = cfg.app;
211
+ if (!isMap(target) || !isMap(app))
212
+ return { ok: false, error: 'the deploy-to-abap configuration needs a target and an app block' };
213
+ for (const k of Object.keys(app)) {
214
+ if (!APP_KEYS.has(k)) {
215
+ return { ok: false, error: `app.${k} is not supported by fiori_deploy (allowed: ${[...APP_KEYS].join(', ')}) — remove it from ui5-deploy.yaml` };
216
+ }
217
+ }
218
+ for (const k of Object.keys(target)) {
219
+ if (!TARGET_KEYS.has(k)) {
220
+ return { ok: false, error: `target.${k} is not supported — fiori_deploy deploys only to the on-premise ABAP system of the SAP alias (url + client)` };
221
+ }
222
+ }
223
+ const url = str(target.url);
224
+ const client = str(target.client);
225
+ const name = str(app.name);
226
+ const pkg = str(app.package);
227
+ const transport = app.transport === null ? '' : str(app.transport);
228
+ if (!url || !client || !name || !pkg || transport === undefined) {
229
+ return { ok: false, error: 'ui5-deploy.yaml must name target.url, target.client, app.name, app.package and app.transport (empty for $TMP)' };
230
+ }
231
+ const cert = cfg.ignoreCertErrors;
232
+ if (cert !== undefined && cert !== 'true' && cert !== 'false') {
233
+ return { ok: false, error: 'ignoreCertErrors must be true or false' };
234
+ }
235
+ return { ok: true, config: { url, client, name, package: pkg, transport, ignoreCertErrors: cert === 'true' } };
236
+ }
@@ -9,22 +9,40 @@
9
9
  // take the NAMES of environment variables at deploy time.
10
10
  import { readFileSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
- export function writeDeployConfig(appDir, opts) {
13
- const appName = opts.appName;
12
+ /**
13
+ * The BSP name / package / transport rules, shared by the writer below and by
14
+ * `fiori_deploy` (which re-checks the yaml it is about to deploy — the file is
15
+ * in a folder the model can write, so it is never trusted on read).
16
+ * Returns the normalised (upper-cased) package and whether it is $TMP.
17
+ */
18
+ export function validateDeployTarget(t) {
19
+ const appName = t.appName;
20
+ if (appName.length === 0) {
21
+ throw new Error('BSP app name is empty');
22
+ }
14
23
  if (appName.length > 15) {
15
24
  throw new Error(`BSP app name "${appName}" exceeds 15 characters (ABAP repository limit)`);
16
25
  }
17
26
  if (appName !== appName.toUpperCase()) {
18
27
  throw new Error(`BSP app name "${appName}" must be uppercase`);
19
28
  }
20
- const pkg = opts.package.toUpperCase();
29
+ const pkg = t.package.toUpperCase();
21
30
  if (!/^(\$TMP|[ZY][A-Z0-9_]*|\/[A-Z0-9_]+\/[A-Z0-9_]+)$/.test(pkg)) {
22
- throw new Error(`Package "${opts.package}" is not in the customer namespace (Z-/Y-prefixed, $TMP, or /namespace/pkg)`);
31
+ throw new Error(`Package "${t.package}" is not in the customer namespace (Z-/Y-prefixed, $TMP, or /namespace/pkg)`);
23
32
  }
24
33
  const isTmp = pkg === '$TMP';
25
- if (!isTmp && !/^[A-Z0-9]{3}K[0-9]{6,}$/.test(opts.transport)) {
26
- throw new Error(`A valid workbench transport (e.g. S4HK903359) is required unless package is $TMP — got "${opts.transport}"`);
34
+ if (isTmp && t.transport.trim() !== '') {
35
+ throw new Error(`Package $TMP is local-only and takes no transport, but transport "${t.transport}" is set — ` +
36
+ 'either remove the transport (local-only app) or use the real Z/Y package that transport belongs to');
37
+ }
38
+ if (!isTmp && !/^[A-Z0-9]{3}K[0-9]{6,}$/.test(t.transport)) {
39
+ throw new Error(`A valid workbench transport (e.g. S4HK903359) is required unless package is $TMP — got "${t.transport}"`);
27
40
  }
41
+ return { package: pkg, isTmp };
42
+ }
43
+ export function writeDeployConfig(appDir, opts) {
44
+ const appName = opts.appName;
45
+ const { package: pkg, isTmp } = validateDeployTarget({ appName, package: opts.package, transport: opts.transport });
28
46
  const pkgJsonPath = join(appDir, 'package.json');
29
47
  const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
30
48
  const projectName = pkgJson.name ?? appName.toLowerCase();
@@ -53,6 +53,8 @@ import { decodeHtmlEscapes } from './html-escapes.js';
53
53
  import { applyEntry } from './apply.js';
54
54
  import { listCatalog } from './catalog/index.js';
55
55
  import { writeDeployConfig } from './deploy-config.js';
56
+ import { runFioriDeploy } from './deploy/run.js';
57
+ import { FIORI_DEPLOY_HANDOVER_SCOPE } from './deploy/scope.js';
56
58
  import { scaffoldFioriElements } from './fe-scaffold.js';
57
59
  import { deriveExpectedColumns } from './smoke/audit-columns.js';
58
60
  import { PINNED_UI5_VERSION } from './ui5-version.js';
@@ -62,7 +64,7 @@ import { runDraftSmoke, draftSmokePreconditions } from './smoke/draft-smoke.js';
62
64
  import { customerService, isLoopbackAppUrl, capService, isCapDevIndex } from './smoke/draft-safety.js';
63
65
  import { verifyAndSpendApprovalId } from '../../approvals/jwt.js';
64
66
  import { deriveFreestyleSmokeSpec, FreestyleSmokeDerivationError } from './smoke/freestyle-spec.js';
65
- import { loadConfig, resolveRenderSmoke } from '../../config/loader.js';
67
+ import { loadConfig, resolveRenderSmoke, resolveSapTls } from '../../config/loader.js';
66
68
  import { startPreview } from './preview/start.js';
67
69
  import { loginNeed, readStartScript } from './preview/app-guard.js';
68
70
  import { isCredentialHandoverApproved } from '../../repl/credential-handover-gate.js';
@@ -237,7 +239,7 @@ export async function fioriListHandler(_args, _ctx) {
237
239
  return { content: JSON.stringify(entries, null, 2) };
238
240
  }
239
241
  // ──────────────────────────── fiori_deploy_config ─────────────────────────
240
- export async function fioriDeployConfigHandler(args, ctx) {
242
+ export async function fioriDeployConfigHandler(args, ctx, deps) {
241
243
  if (!args.appDir)
242
244
  return { content: 'error: appDir is required', is_error: true };
243
245
  if (!args.url)
@@ -251,6 +253,23 @@ export async function fioriDeployConfigHandler(args, ctx) {
251
253
  const resolved = await safeDir(ctx, args.appDir);
252
254
  if ('error' in resolved)
253
255
  return { content: `error: ${resolved.error}`, is_error: true };
256
+ // Review 2026-10-03 — TLS follows the alias: when the alias verifies
257
+ // certificates (a CA is configured, or the system trust store), the yaml
258
+ // never says ignoreCertErrors: true (fiori_deploy would override it anyway).
259
+ let ignoreCertErrors = args.ignoreCertErrors === true;
260
+ let tlsNote = '';
261
+ if (ignoreCertErrors) {
262
+ try {
263
+ const cfg = await (deps?.loadConfig ?? loadConfig)();
264
+ const sys = cfg.sap?.[ctx.sapAlias];
265
+ if (sys && resolveSapTls(sys).mode !== 'insecure') {
266
+ ignoreCertErrors = false;
267
+ tlsNote = `
268
+ ignoreCertErrors was written as false: alias ${ctx.sapAlias} verifies certificates${sys.ca_cert_path ? ` (CA ${sys.ca_cert_path})` : ''}.`;
269
+ }
270
+ }
271
+ catch { /* no config — keep what was asked */ }
272
+ }
254
273
  try {
255
274
  writeDeployConfig(resolved.abs, {
256
275
  url: args.url,
@@ -259,7 +278,7 @@ export async function fioriDeployConfigHandler(args, ctx) {
259
278
  description: args.description,
260
279
  package: args.package,
261
280
  transport: args.transport ?? '',
262
- ignoreCertErrors: args.ignoreCertErrors === true,
281
+ ignoreCertErrors,
263
282
  });
264
283
  }
265
284
  catch (err) {
@@ -271,9 +290,58 @@ export async function fioriDeployConfigHandler(args, ctx) {
271
290
  return {
272
291
  content: `Deploy config written: ${appDir}/ui5-deploy.yaml (BSP app ${args.name} → package ${args.package}` +
273
292
  `${args.transport ? `, transport ${args.transport}` : ''}).\n` +
274
- `package.json gained "deploy" and "deploy-test" scripts. Run "npm run deploy-test" to validate without deploying.`,
293
+ `package.json gained "deploy" and "deploy-test" scripts. Next: fiori_deploy with mode "test" (the backend validates, ` +
294
+ 'nothing is deployed), then mode "deploy" — it uses the SAP logon CSPeach already stores; no password or variable is needed.' +
295
+ tlsNote,
275
296
  };
276
297
  }
298
+ /** The credential-handover scope: a fiori_preview approval for the alias does not cover a deploy. */
299
+ export { FIORI_DEPLOY_HANDOVER_SCOPE };
300
+ /**
301
+ * fiori_deploy — build + `fiori deploy` the app as a BSP with the SAP logon
302
+ * CSPeach already holds (deploy/run.ts has the full contract). The handler
303
+ * only sandboxes appDir, re-checks the credential-handover approval
304
+ * (TOCTOU, same as fiori_preview) and renders the result.
305
+ */
306
+ export async function fioriDeployHandler(args, ctx, deps) {
307
+ if (!args || typeof args.appDir !== 'string' || args.appDir.trim() === '') {
308
+ return { content: 'error: appDir is required (the scaffolded app root, relative to the project root)', is_error: true };
309
+ }
310
+ const mode = args.mode ?? 'test';
311
+ if (mode !== 'test' && mode !== 'deploy') {
312
+ return { content: 'error: mode must be "test" (the backend validates, nothing is deployed) or "deploy"', is_error: true };
313
+ }
314
+ const resolved = await safeDir(ctx, args.appDir);
315
+ if ('error' in resolved)
316
+ return { content: `error: ${resolved.error}`, is_error: true };
317
+ const alias = args.alias ?? ctx.sapAlias;
318
+ const approved = (deps?.isLoginApproved ?? ((a) => isCredentialHandoverApproved(ctx.session?.id, a, FIORI_DEPLOY_HANDOVER_SCOPE)))(alias);
319
+ if (!approved) {
320
+ return {
321
+ content: `error: fiori_deploy refused (nothing was sent): handing the SAP logon for alias ${alias} to fiori deploy was not ` +
322
+ 'approved in this session. Call fiori_deploy again so the approval prompt can be shown.',
323
+ is_error: true,
324
+ };
325
+ }
326
+ const { isLoginApproved: _gate, ...runDeps } = deps ?? {};
327
+ const r = await runFioriDeploy({
328
+ appDir: resolved.abs,
329
+ projectRoot: ctx.cwd,
330
+ mode,
331
+ alias,
332
+ ...(typeof args.approval_id === 'string' ? { approvalId: args.approval_id } : {}),
333
+ }, { log: (line) => ctx.chunkEmitter?.emit('info', line), ...runDeps });
334
+ if (!r.ok) {
335
+ const head = r.stage === 'refused' ? 'fiori_deploy refused (nothing was sent)' : `fiori_deploy failed at the ${r.stage} step`;
336
+ return { content: `error: ${head}: ${r.error}`, is_error: true };
337
+ }
338
+ const lines = [r.summary, ...r.notes.map((n) => `note: ${n}`)];
339
+ if (r.url)
340
+ lines.push(`deployedUrl: ${r.url}`);
341
+ else
342
+ lines.push('Next: run fiori_deploy with mode "deploy" (needs an approval_id for the BSP).');
343
+ return { content: lines.join('\n') };
344
+ }
277
345
  // ──────────────────────────── fiori_scaffold_fe ───────────────────────────
278
346
  export async function fioriScaffoldFeHandler(args, ctx) {
279
347
  if (!args.basePath)
@@ -948,7 +1016,7 @@ registerTool({
948
1016
  description: 'Write the ABAP-repository (BSP) deploy configuration for a scaffolded app: ui5-deploy.yaml ' +
949
1017
  'plus "deploy" / "deploy-test" package.json scripts. Validates BSP app name (≤15 chars, ' +
950
1018
  'uppercase), package namespace, and transport format. Credentials are NEVER written — ' +
951
- 'fiori deploy takes env-var NAMES at deploy time. appDir is sandboxed to the project root.',
1019
+ 'deploy with fiori_deploy, which uses the stored SAP logon. appDir is sandboxed to the project root.',
952
1020
  isMutating: true,
953
1021
  category: 'fiori',
954
1022
  flagGated: true,
@@ -968,6 +1036,50 @@ registerTool({
968
1036
  },
969
1037
  handler: fioriDeployConfigHandler,
970
1038
  });
1039
+ registerTool({
1040
+ name: 'fiori_deploy',
1041
+ description: 'Deploy a scaffolded UI5 app to the ABAP repository as a BSP application with the SAP "fiori deploy" tool, using ' +
1042
+ 'the SAP logon CSPeach already keeps for the alias in the OS credential store — the developer types no password ' +
1043
+ 'and sets no environment variable. Reads ui5-deploy.yaml in the app (write it first with fiori_deploy_config) and ' +
1044
+ 'REFUSES when it is not valid (BSP name, package, transport rules) or when its target url/port/client are not the ' +
1045
+ 'system of the SAP alias (a yaml that names the box by IP passes only when the alias host resolves to that IP); ' +
1046
+ 'the deploy then goes to the alias url. Runs the "build" script of the app first (without the logon). The logon ' +
1047
+ 'reaches only the fiori deploy process, by environment, and is scrubbed from all output. mode "test" (default): ' +
1048
+ 'the backend validates the upload and deploys nothing — no write approval needed. mode "deploy": a SAP WRITE — ' +
1049
+ 'call request_approval with a change {op:"create", object:"<BSP NAME>", type:"WAPA"} first and pass the id as ' +
1050
+ 'approval_id (one approval, one deploy; an auto-approval does not count); refused in advisory-only mode and on production-tagged aliases. On ' +
1051
+ 'success the tool GETs /sap/bc/ui5_ui5/sap/<bsp>/index.html over the alias connection and returns deployedUrl ' +
1052
+ 'only when it answered 200. Asks once per session per alias before handing the logon over. Flag-gated (enabled ' +
1053
+ 'with local_build).',
1054
+ isMutating: true,
1055
+ requiresSap: true,
1056
+ category: 'fiori',
1057
+ flagGated: true,
1058
+ input_schema: {
1059
+ type: 'object',
1060
+ properties: {
1061
+ appDir: { type: 'string', description: 'The scaffolded app root (relative to the project root, sandboxed). In a plan: phases[ui.build].work.app.dir.' },
1062
+ mode: { type: 'string', enum: ['test', 'deploy'], description: '"test" (default): backend validation only, nothing deployed. "deploy": the real deploy.' },
1063
+ alias: { type: 'string', description: 'SAP alias whose system and stored logon are used. Defaults to the session alias.' },
1064
+ approval_id: { type: 'string', description: 'mode "deploy" only: from request_approval, change {op:"create", object:"<BSP NAME>", type:"WAPA"}.' },
1065
+ },
1066
+ required: ['appDir'],
1067
+ },
1068
+ // The logon is handed to a child process in BOTH modes — dispatchTool asks a
1069
+ // human once per session per alias (scope fiori_deploy, so an earlier
1070
+ // fiori_preview approval does not cover it). The /abap-plan deploy card
1071
+ // records this approval when the developer confirms it.
1072
+ credentialHandover: (args, ctx) => {
1073
+ const alias = args?.alias ?? ctx.sapAlias;
1074
+ return {
1075
+ alias,
1076
+ scope: FIORI_DEPLOY_HANDOVER_SCOPE,
1077
+ summary: `CSPeach's stored SAP logon for alias ${alias} is passed to "fiori deploy" (${args?.appDir ?? '(no appDir given)'}) ` +
1078
+ 'through its environment only — never on the command line, never in a file. Asked once per session for each alias.',
1079
+ };
1080
+ },
1081
+ handler: fioriDeployHandler,
1082
+ });
971
1083
  registerTool({
972
1084
  name: 'fiori_scaffold_fe',
973
1085
  description: 'Scaffold a trivial Fiori Elements app shell (List Report Object Page, Worklist, Overview Page, or Analytical List Page) ' +
@@ -11,6 +11,7 @@
11
11
  * filesystem: file_read, file_write, file_edit, glob, grep
12
12
  * shell: shell_exec
13
13
  * fiori: fiori_scaffold, fiori_apply, fiori_list, fiori_deploy_config,
14
+ * fiori_deploy (BSP deploy with the stored SAP logon — approval-gated write),
14
15
  * fiori_scaffold_fe, fiori_fe_extend, fiori_render_smoke,
15
16
  * fiori_preview (L1-6 — the preview server every browser check
16
17
  * depends on), fiori_metadata_audit, fiori_draft_smoke
@@ -66,6 +67,8 @@ export const LOCAL_BUILD_TOOLS = [
66
67
  'fiori_apply',
67
68
  'fiori_list',
68
69
  'fiori_deploy_config',
70
+ // Deploy with the stored SAP logon (2026-10-02) — a SAP write in mode 'deploy', approval-gated.
71
+ 'fiori_deploy',
69
72
  'fiori_scaffold_fe',
70
73
  'fiori_fe_extend',
71
74
  'fiori_render_smoke',
@@ -22,7 +22,7 @@ import { listTools, registerTool, toolsForContext } from '../index.js';
22
22
  import { runTurn } from '../../agent/loop.js';
23
23
  import { collectTurnAssistantText } from '../../agent/turn-assistant-text.js';
24
24
  import { newSession } from '../../session/schema.js';
25
- import { computeSessionCost } from '../../cost/session-cost.js';
25
+ import { computeSessionModelCost } from '../../cost/session-cost.js';
26
26
  import { sessionIdOf, spillIfLarge } from '../result-spill.js';
27
27
  import { INTERRUPTED_LINE, READER_SKILL, READER_SYSTEM_PROMPT, READER_TOOLS, hasReaderReadTools } from './reader-prompt.js';
28
28
  export { INTERRUPTED_LINE, READER_SKILL, READER_SYSTEM_PROMPT, READER_TOOLS, hasReaderReadTools };
@@ -118,7 +118,9 @@ export async function readAgentHandler(args, ctx) {
118
118
  finally {
119
119
  // Spend counts whether or not the reader finished.
120
120
  if (ctx.readerSpend) {
121
- ctx.readerSpend.cost += computeSessionCost(childSession);
121
+ // The child's own model cost only — the BYOK router spend is already in
122
+ // the session total once (review S1, 2026-10-02).
123
+ ctx.readerSpend.cost += computeSessionModelCost(childSession);
122
124
  ctx.readerSpend.calls += 1;
123
125
  }
124
126
  }
@@ -35,7 +35,7 @@ export const CORE_TOOLS = Object.freeze([
35
35
  'sap_create_object',
36
36
  ]);
37
37
  const FIORI_FAMILY = [
38
- 'fiori_apply', 'fiori_deploy_config', 'fiori_draft_smoke', 'fiori_fe_extend',
38
+ 'fiori_apply', 'fiori_deploy', 'fiori_deploy_config', 'fiori_draft_smoke', 'fiori_fe_extend',
39
39
  'fiori_list', 'fiori_metadata_audit', 'fiori_preview', 'fiori_render_smoke',
40
40
  'fiori_sample_get', 'fiori_sample_search', 'fiori_scaffold', 'fiori_scaffold_fe',
41
41
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.25",
3
+ "version": "1.1.26",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",