@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.
- package/dist/agent/loop.js +20 -8
- package/dist/agent/tool-dispatch.js +6 -0
- package/dist/auth/auth-file.js +173 -12
- package/dist/classifier/client.js +55 -30
- package/dist/cli.js +11 -0
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +5 -1
- package/dist/commands/cost.js +24 -22
- package/dist/commands/deploy-card.js +135 -0
- package/dist/commands/login.js +31 -13
- package/dist/commands/plan-chain.js +31 -0
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +49 -2
- package/dist/cost/cost-log.js +76 -4
- package/dist/cost/credits-wire.js +9 -0
- package/dist/cost/credits.js +24 -6
- package/dist/cost/router-spend.js +24 -0
- package/dist/cost/session-cost.js +16 -2
- package/dist/models/server-config.js +21 -1
- package/dist/one-shot.js +12 -2
- package/dist/projects/plan-lane.js +47 -0
- package/dist/projects/plan-run.js +8 -0
- package/dist/projects/plan-schema.js +2 -0
- package/dist/projects/save-command.js +3 -2
- package/dist/renderer/footer-line.js +10 -4
- package/dist/renderer/status-footer.js +5 -4
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +41 -8
- package/dist/router/byok-classifier.js +159 -0
- package/dist/router/classifier.js +130 -6
- package/dist/router/router-core.js +307 -0
- package/dist/router/router-prompt.js +121 -0
- package/dist/router/routing-failure.js +65 -3
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
- package/dist/tools/fiori/deploy/run.js +608 -0
- package/dist/tools/fiori/deploy/scope.js +8 -0
- package/dist/tools/fiori/deploy/yaml-read.js +236 -0
- package/dist/tools/fiori/deploy-config.js +24 -6
- package/dist/tools/fiori/tools.js +117 -5
- package/dist/tools/local-build.js +3 -0
- package/dist/tools/subagent/read_agent.js +4 -2
- package/dist/tools/tool-loading.js +1 -1
- 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
|
-
|
|
13
|
-
|
|
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 =
|
|
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 "${
|
|
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 (
|
|
26
|
-
throw new Error(`
|
|
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
|
|
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.
|
|
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
|
-
'
|
|
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 {
|
|
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
|
-
|
|
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
|
];
|