@cspeach/cli 1.1.18 → 1.1.19
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/README.md +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +58 -21
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +15 -3
- package/dist/cost/pricing.js +6 -5
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +96 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +152 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +238 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +42 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +36 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +795 -332
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +69 -27
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +28 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +11 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
|
@@ -49,14 +49,26 @@ import * as path from 'node:path';
|
|
|
49
49
|
import { registerTool } from '../index.js';
|
|
50
50
|
import { resolveSafePath, PathOutsideRootError } from '../_filesystem-shared.js';
|
|
51
51
|
import { scaffoldFreestyle } from './scaffold.js';
|
|
52
|
+
import { decodeHtmlEscapes } from './html-escapes.js';
|
|
52
53
|
import { applyEntry } from './apply.js';
|
|
53
54
|
import { listCatalog } from './catalog/index.js';
|
|
54
55
|
import { writeDeployConfig } from './deploy-config.js';
|
|
55
56
|
import { scaffoldFioriElements } from './fe-scaffold.js';
|
|
57
|
+
import { deriveExpectedColumns } from './smoke/audit-columns.js';
|
|
58
|
+
import { PINNED_UI5_VERSION } from './ui5-version.js';
|
|
56
59
|
import { feExtend, FE_EXTEND_OPS, NotV4FeAppError } from './fe-extend.js';
|
|
57
60
|
import { runSmoke } from './smoke/run-smoke.js';
|
|
61
|
+
import { runDraftSmoke, draftSmokePreconditions } from './smoke/draft-smoke.js';
|
|
62
|
+
import { customerService, isLoopbackAppUrl, capService, isCapDevIndex } from './smoke/draft-safety.js';
|
|
63
|
+
import { verifyAndSpendApprovalId } from '../../approvals/jwt.js';
|
|
58
64
|
import { deriveFreestyleSmokeSpec, FreestyleSmokeDerivationError } from './smoke/freestyle-spec.js';
|
|
59
65
|
import { loadConfig, resolveRenderSmoke } from '../../config/loader.js';
|
|
66
|
+
import { startPreview } from './preview/start.js';
|
|
67
|
+
import { loginNeed, readStartScript } from './preview/app-guard.js';
|
|
68
|
+
import { isCredentialHandoverApproved } from '../../repl/credential-handover-gate.js';
|
|
69
|
+
import { listPreviews, stopPreview, findPreviewById, installPreviewExitHandler, } from './preview/registry.js';
|
|
70
|
+
import { auditMetadata } from './metadata/audit.js';
|
|
71
|
+
import { readValueListReferences, resolveReferenceUrl } from './metadata/references.js';
|
|
60
72
|
import { searchSamples } from './samples/search.js';
|
|
61
73
|
import { loadSampleSource, SampleFetchError, UnsafeSampleNameError } from './samples/loader.js';
|
|
62
74
|
// The grounding corpus ships as an imported JSON module + the bundled
|
|
@@ -105,6 +117,10 @@ async function listFilesUnder(ctx, dir) {
|
|
|
105
117
|
await walk(dir);
|
|
106
118
|
return out.sort();
|
|
107
119
|
}
|
|
120
|
+
// Dry run 2026-09-25: the model sent appTitle "Suppliers & Quality
|
|
121
|
+
// Certificates" and the app header showed "&". A title is plain text — the
|
|
122
|
+
// writers escape it where HTML needs it — so escapes arriving in it are decoded.
|
|
123
|
+
const decodeTitle = decodeHtmlEscapes;
|
|
108
124
|
// ───────────────────────────── fiori_scaffold ─────────────────────────────
|
|
109
125
|
export async function fioriScaffoldHandler(args, ctx) {
|
|
110
126
|
if (!args.basePath)
|
|
@@ -118,7 +134,7 @@ export async function fioriScaffoldHandler(args, ctx) {
|
|
|
118
134
|
await scaffoldFreestyle({
|
|
119
135
|
basePath: resolved.abs,
|
|
120
136
|
appId: args.appId,
|
|
121
|
-
appTitle: args.appTitle ?? args.appId,
|
|
137
|
+
appTitle: decodeTitle(args.appTitle ?? args.appId),
|
|
122
138
|
template: args.template ?? 'basic',
|
|
123
139
|
service: args.serviceUrl
|
|
124
140
|
? { url: args.serviceUrl, path: args.servicePath ?? '', version: args.serviceVersion ?? '4.0' }
|
|
@@ -239,12 +255,13 @@ export async function fioriScaffoldFeHandler(args, ctx) {
|
|
|
239
255
|
await scaffoldFioriElements({
|
|
240
256
|
basePath: resolved.abs,
|
|
241
257
|
appId: args.appId,
|
|
242
|
-
appTitle: args.appTitle ?? args.appId,
|
|
258
|
+
appTitle: decodeTitle(args.appTitle ?? args.appId),
|
|
243
259
|
template: args.template ?? 'lrop',
|
|
244
260
|
service: { url: args.serviceUrl, path: args.servicePath, version: args.serviceVersion ?? '4.0', metadata: args.metadata, client: args.client },
|
|
245
261
|
mainEntity: args.mainEntity,
|
|
246
262
|
ui5Version: args.ui5Version,
|
|
247
263
|
localAnnotations: args.localAnnotations,
|
|
264
|
+
...(args.backend === 'cap' ? { backend: 'cap' } : {}),
|
|
248
265
|
});
|
|
249
266
|
}
|
|
250
267
|
catch (err) {
|
|
@@ -293,6 +310,127 @@ export async function feExtendHandler(args, ctx) {
|
|
|
293
310
|
: `No new files (manifest-only change).`),
|
|
294
311
|
};
|
|
295
312
|
}
|
|
313
|
+
// ─────────────────────────────── fiori_preview ────────────────────────────
|
|
314
|
+
/**
|
|
315
|
+
* Preview lifecycle (L1-6). `start` brings a local preview server up for an
|
|
316
|
+
* already-scaffolded app with TLS verification ON and the SAP login supplied
|
|
317
|
+
* through the child's process environment; `stop` kills its whole process tree;
|
|
318
|
+
* `list` shows what this CLI process is running.
|
|
319
|
+
*
|
|
320
|
+
* `deps` is the test seam (spawn / port picker / readiness / config / keychain);
|
|
321
|
+
* in production it is undefined and the real ones are used. No npm process ever
|
|
322
|
+
* starts in the unit suite.
|
|
323
|
+
*/
|
|
324
|
+
export async function fioriPreviewHandler(args, ctx, deps) {
|
|
325
|
+
// Whatever the action, a preview must never outlive this CLI process.
|
|
326
|
+
installPreviewExitHandler();
|
|
327
|
+
const action = args.action ?? 'start';
|
|
328
|
+
if (action === 'list') {
|
|
329
|
+
const rows = listPreviews().map((e) => ({
|
|
330
|
+
previewId: e.previewId,
|
|
331
|
+
url: e.url,
|
|
332
|
+
port: e.port,
|
|
333
|
+
pid: e.pid,
|
|
334
|
+
entry: e.entry,
|
|
335
|
+
alias: e.alias,
|
|
336
|
+
appDir: rel(ctx, e.appDir),
|
|
337
|
+
startedAt: new Date(e.startedAt).toISOString(),
|
|
338
|
+
}));
|
|
339
|
+
return { content: JSON.stringify(rows, null, 2) };
|
|
340
|
+
}
|
|
341
|
+
if (action === 'stop') {
|
|
342
|
+
if (!args.previewId)
|
|
343
|
+
return { content: 'error: previewId is required for action "stop"', is_error: true };
|
|
344
|
+
const res = await stopPreview(args.previewId);
|
|
345
|
+
if (!res.stopped) {
|
|
346
|
+
return {
|
|
347
|
+
content: `error: no running preview with previewId "${args.previewId}" — call fiori_preview with action "list".`,
|
|
348
|
+
is_error: true,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
// F-7: claim only what the evidence shows. The port is polled closed on
|
|
352
|
+
// both loopback families; a kill that was issued but left the port open is
|
|
353
|
+
// an error, not a success line.
|
|
354
|
+
if (res.portClosed) {
|
|
355
|
+
return {
|
|
356
|
+
content: `Preview ${args.previewId} stopped — port ${res.port} is closed.` +
|
|
357
|
+
(res.issued ? '' : `\n(No process was killed: ${res.reason ?? 'no live handle in this session'}.)`),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
content: `error: preview ${args.previewId} was removed from the registry but port ${res.port} still answers` +
|
|
362
|
+
(res.issued ? ` (kill issued, exit code ${String(res.exitCode)})` : ` — nothing was killed: ${res.reason ?? 'no live handle in this session'}`) +
|
|
363
|
+
'. Close it by hand before starting another preview for this app.',
|
|
364
|
+
is_error: true,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
if (action !== 'start') {
|
|
368
|
+
return { content: `error: unknown action "${String(args.action)}" — expected start, stop or list`, is_error: true };
|
|
369
|
+
}
|
|
370
|
+
if (!args.appDir)
|
|
371
|
+
return { content: 'error: appDir is required for action "start"', is_error: true };
|
|
372
|
+
const resolved = safeDir(ctx, args.appDir);
|
|
373
|
+
if ('error' in resolved)
|
|
374
|
+
return { content: `error: ${resolved.error}`, is_error: true };
|
|
375
|
+
const result = await startPreview({
|
|
376
|
+
appDir: resolved.abs,
|
|
377
|
+
alias: args.alias ?? ctx.sapAlias,
|
|
378
|
+
servicePath: args.servicePath ?? '',
|
|
379
|
+
...(args.budgetMs !== undefined ? { budgetMs: args.budgetMs } : {}),
|
|
380
|
+
...(args.entryFile !== undefined ? { entryFile: args.entryFile } : {}),
|
|
381
|
+
}, {
|
|
382
|
+
...deps,
|
|
383
|
+
// Round 2b TOCTOU guard: ALWAYS the real approval gate — a caller's deps
|
|
384
|
+
// can never claim an approval the human did not give.
|
|
385
|
+
isLoginApproved: (alias) => isCredentialHandoverApproved(ctx.session?.id, alias),
|
|
386
|
+
});
|
|
387
|
+
if (!result.ok)
|
|
388
|
+
return { content: `error: ${result.error}`, is_error: true };
|
|
389
|
+
const e = result.entry;
|
|
390
|
+
// The TLS posture is ALWAYS stated, and an insecure alias gets its own line.
|
|
391
|
+
// Every non-fatal note (a replaced preview for another alias, the insecure
|
|
392
|
+
// warning) is surfaced too — a silent replacement is how ':8080 beside :8081'
|
|
393
|
+
// happened in the first place.
|
|
394
|
+
const lines = [
|
|
395
|
+
`Preview ${result.reused ? 'already running (reused)' : 'started'}: ${e.url}`,
|
|
396
|
+
`previewId: ${e.previewId}`,
|
|
397
|
+
`port: ${e.port} pid: ${e.pid ?? 'unknown'} entry: ${e.entry} alias: ${e.alias}`,
|
|
398
|
+
`appDir: ${rel(ctx, e.appDir)}`,
|
|
399
|
+
`TLS: ${result.tlsNote}`,
|
|
400
|
+
];
|
|
401
|
+
for (const w of result.warnings)
|
|
402
|
+
lines.push(`WARNING: ${w}`);
|
|
403
|
+
lines.push(result.loginPassed
|
|
404
|
+
? 'Credentials reached the preview through the process environment only — no file on disk holds them.'
|
|
405
|
+
: 'no backend url in ui5.yaml — started without the SAP login', `Pass previewId "${e.previewId}" to fiori_render_smoke instead of appUrl.`);
|
|
406
|
+
return { content: lines.join('\n') };
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Resolve the preview URL a smoke should drive. `previewId` wins over `appUrl`
|
|
410
|
+
* when both are given (and the caller is told), because the registry knows the
|
|
411
|
+
* port that is actually live — that is the whole point of L1-6. Callers that
|
|
412
|
+
* pass only `appUrl` are untouched: `undefined` comes back and the existing
|
|
413
|
+
* error/behaviour stands byte-identical.
|
|
414
|
+
*
|
|
415
|
+
* Exported so `fiori_draft_smoke` (L1-5b) resolves it the same way.
|
|
416
|
+
*/
|
|
417
|
+
export function resolvePreviewAppUrl(args) {
|
|
418
|
+
if (!args.previewId)
|
|
419
|
+
return {};
|
|
420
|
+
const entry = findPreviewById(args.previewId);
|
|
421
|
+
if (!entry) {
|
|
422
|
+
return {
|
|
423
|
+
error: `no running preview with previewId "${args.previewId}" — call fiori_preview with action "list", ` +
|
|
424
|
+
'or start one with action "start".',
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
appUrl: entry.url,
|
|
429
|
+
...(args.appUrl && args.appUrl !== entry.url
|
|
430
|
+
? { note: `previewId "${args.previewId}" wins over the supplied appUrl — using ${entry.url}` }
|
|
431
|
+
: {}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
296
434
|
/**
|
|
297
435
|
* FREESTYLE MODE (appDir given): derive a SmokeSpec from the app's own webapp/
|
|
298
436
|
* tree via deriveFreestyleSmokeSpec, then let explicit spec args override the
|
|
@@ -337,6 +475,14 @@ export async function fioriRenderSmokeHandler(args, ctx,
|
|
|
337
475
|
// Test seam: injected runSmoke deps (browser/openApp/renderSmokeEnabled). In
|
|
338
476
|
// production this is undefined and the real config-driven deps are used.
|
|
339
477
|
deps) {
|
|
478
|
+
// L1-6: a registered previewId resolves to the URL the preview is ACTUALLY
|
|
479
|
+
// serving. Callers that pass only appUrl take `{}` back here and fall through
|
|
480
|
+
// to exactly the code (and the error) that existed before.
|
|
481
|
+
const resolvedPreview = resolvePreviewAppUrl(args);
|
|
482
|
+
if ('error' in resolvedPreview)
|
|
483
|
+
return { content: `error: ${resolvedPreview.error}`, is_error: true };
|
|
484
|
+
if (resolvedPreview.appUrl)
|
|
485
|
+
args = { ...args, appUrl: resolvedPreview.appUrl };
|
|
340
486
|
if (!args.appUrl) {
|
|
341
487
|
return {
|
|
342
488
|
content: 'error: appUrl is required — the LOCAL AUTHENTICATED PREVIEW URL (the `npm run start` / ' +
|
|
@@ -348,6 +494,8 @@ deps) {
|
|
|
348
494
|
// Freestyle mode (appDir): derive + merge, and surface the derivation warnings.
|
|
349
495
|
let spec = args;
|
|
350
496
|
let warnings;
|
|
497
|
+
if (resolvedPreview.note)
|
|
498
|
+
warnings = [resolvedPreview.note];
|
|
351
499
|
if (args.appDir) {
|
|
352
500
|
const derived = resolveFreestyleSmokeSpec(args, ctx);
|
|
353
501
|
if ('error' in derived) {
|
|
@@ -357,7 +505,24 @@ deps) {
|
|
|
357
505
|
};
|
|
358
506
|
}
|
|
359
507
|
spec = derived.spec;
|
|
360
|
-
|
|
508
|
+
// Keep the previewId note (if any) in front of the derivation warnings.
|
|
509
|
+
warnings = [...(warnings ?? []), ...derived.warnings];
|
|
510
|
+
}
|
|
511
|
+
else if (args.metadataAudit) {
|
|
512
|
+
// L1-2 — FE mode with the audit: the expected columns are the audit's
|
|
513
|
+
// UI.LineItem labels (machine truth), and a model-supplied list does not
|
|
514
|
+
// override them. Without the audit nothing here runs (byte-identical).
|
|
515
|
+
const derivedColumns = deriveExpectedColumns(args.metadataAudit)
|
|
516
|
+
.map((c) => c.label)
|
|
517
|
+
.filter((l) => typeof l === 'string' && l.length > 0);
|
|
518
|
+
if (args.expectedColumns !== undefined) {
|
|
519
|
+
warnings = [
|
|
520
|
+
...(warnings ?? []),
|
|
521
|
+
'expectedColumns ignored: in Fiori Elements mode the expected columns are derived from metadataAudit ' +
|
|
522
|
+
`(${derivedColumns.join(', ') || 'none'})`,
|
|
523
|
+
];
|
|
524
|
+
}
|
|
525
|
+
spec = { ...args, expectedColumns: derivedColumns };
|
|
361
526
|
}
|
|
362
527
|
// The render_smoke config toggle (plain default true) gates whether a browser
|
|
363
528
|
// is actually launched; when off, runSmoke returns a skipped manual result.
|
|
@@ -371,6 +536,149 @@ deps) {
|
|
|
371
536
|
const payload = warnings !== undefined ? { ...result, warnings } : result;
|
|
372
537
|
return { content: JSON.stringify(payload, null, 2) };
|
|
373
538
|
}
|
|
539
|
+
/** One draft smoke per approval: nonces of approvals that already ran one (this process only). */
|
|
540
|
+
const draftSmokeApprovalsUsed = new Set();
|
|
541
|
+
/**
|
|
542
|
+
* L1-5b — fiori_draft_smoke. A SAP WRITE: it creates, edits and deletes one
|
|
543
|
+
* CSP-SMOKE-<runId> test row through the app's own service. Order matters:
|
|
544
|
+
* 1. refusals that need no network: a passed-in audit, the customer service
|
|
545
|
+
* (Z*|Y*) on servicePath, a non-loopback app URL, no approval_id, render_smoke off;
|
|
546
|
+
* 2. the tool's OWN audit — fetchMetadataAudit, the same fetch + reference
|
|
547
|
+
* resolution fiori_metadata_audit uses. Nothing model-supplied chooses the
|
|
548
|
+
* service root, the activation action or the mandatory fields;
|
|
549
|
+
* 3. the audit-based refusals (draft root + activation action, marker field,
|
|
550
|
+
* edit field) — still before any write;
|
|
551
|
+
* 4. the approval, verified through the SAME verifyAndSpendApprovalId every SAP
|
|
552
|
+
* write tool uses (object = the binding of the servicePath the tool fetched),
|
|
553
|
+
* one run per approval;
|
|
554
|
+
* 5. the browser.
|
|
555
|
+
*/
|
|
556
|
+
export async function fioriDraftSmokeHandler(args, ctx, deps) {
|
|
557
|
+
const refuse = (why) => ({
|
|
558
|
+
content: `error: fiori_draft_smoke refused (nothing was sent): ${why}`,
|
|
559
|
+
is_error: true,
|
|
560
|
+
});
|
|
561
|
+
if (args && Object.prototype.hasOwnProperty.call(args, 'metadataAudit')) {
|
|
562
|
+
return refuse('metadataAudit is not an input — pass servicePath; the smoke fetches and audits the service itself ' +
|
|
563
|
+
'(the activation action and the mandatory fields must come from the system, not from the call)');
|
|
564
|
+
}
|
|
565
|
+
const resolvedPreview = resolvePreviewAppUrl(args);
|
|
566
|
+
if ('error' in resolvedPreview)
|
|
567
|
+
return { content: `error: ${resolvedPreview.error}`, is_error: true };
|
|
568
|
+
const appUrl = resolvedPreview.appUrl ?? args.appUrl;
|
|
569
|
+
if (!appUrl) {
|
|
570
|
+
return {
|
|
571
|
+
content: 'error: appUrl (the local preview URL) or previewId (from fiori_preview "start") is required.',
|
|
572
|
+
is_error: true,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
const isCap = args.backend === 'cap';
|
|
576
|
+
const rawPath = typeof args.servicePath === 'string' ? args.servicePath.trim() : '';
|
|
577
|
+
let serviceRoot;
|
|
578
|
+
let approvalObject;
|
|
579
|
+
let capOrigin;
|
|
580
|
+
if (isCap) {
|
|
581
|
+
// CAP rule (owner-approved 2026-09-25) — separate from the SAP rule below.
|
|
582
|
+
const cs = capService(rawPath);
|
|
583
|
+
if (!cs.ok)
|
|
584
|
+
return refuse(cs.reason);
|
|
585
|
+
serviceRoot = cs.serviceRoot;
|
|
586
|
+
approvalObject = cs.service;
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
const svc = customerService(rawPath);
|
|
590
|
+
if (!svc.ok)
|
|
591
|
+
return refuse(svc.reason);
|
|
592
|
+
serviceRoot = svc.serviceRoot;
|
|
593
|
+
approvalObject = svc.binding;
|
|
594
|
+
}
|
|
595
|
+
if (!isLoopbackAppUrl(appUrl)) {
|
|
596
|
+
return refuse(`appUrl "${appUrl}" is not a local preview (http(s) on localhost / 127.x / [::1]) — ` +
|
|
597
|
+
'fiori_draft_smoke runs only against the preview it is given, never a deployed app');
|
|
598
|
+
}
|
|
599
|
+
if (isCap) {
|
|
600
|
+
// The loopback origin must PROVE it is the CAP development server: its root
|
|
601
|
+
// serves CAP's development index (development only) listing this service.
|
|
602
|
+
// A UI5 preview forwarding to a remote CAP app serves its own page instead.
|
|
603
|
+
capOrigin = new URL(appUrl).origin;
|
|
604
|
+
const fetchText = deps?.fetchText ?? defaultCapFetchText;
|
|
605
|
+
let index;
|
|
606
|
+
try {
|
|
607
|
+
index = await fetchText(`${capOrigin}/`);
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
return refuse(`could not reach ${capOrigin}/ — ${err instanceof Error ? err.message : String(err)}`);
|
|
611
|
+
}
|
|
612
|
+
if (index.status < 200 || index.status >= 300 || !isCapDevIndex(index.body, serviceRoot)) {
|
|
613
|
+
return refuse(`${capOrigin} is not the CAP development server for ${serviceRoot} — its root does not serve CAP's ` +
|
|
614
|
+
'development index listing this service. On the CAP path fiori_draft_smoke writes only to the local ' +
|
|
615
|
+
'CAP dev server itself (cds watch), never through a proxy and never to a deployed app');
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const howToApprove = `call request_approval with a change {op:"modify", object:"${approvalObject}", type:"DATA"} — it creates, ` +
|
|
619
|
+
'edits and deletes one CSP-SMOKE test row — and pass the returned id';
|
|
620
|
+
if (typeof args.approval_id !== 'string' || args.approval_id.trim() === '') {
|
|
621
|
+
return { content: `error: approval_id is required — ${howToApprove}.`, is_error: true };
|
|
622
|
+
}
|
|
623
|
+
const renderSmokeEnabled = deps?.renderSmokeEnabled ?? resolveRenderSmoke(await loadConfig());
|
|
624
|
+
if (!renderSmokeEnabled) {
|
|
625
|
+
return {
|
|
626
|
+
content: JSON.stringify({
|
|
627
|
+
verification: 'manual',
|
|
628
|
+
passed: false,
|
|
629
|
+
checks: [
|
|
630
|
+
{ name: 'skipped', ok: false, detail: 'render smoke disabled (render_smoke config is off); nothing was written' },
|
|
631
|
+
],
|
|
632
|
+
}, null, 2),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
// The tool's own audit — read-only GETs of $metadata and its value-help documents.
|
|
636
|
+
const capAuth = args.capUser ? { Authorization: `Basic ${Buffer.from(`${args.capUser}:`).toString('base64')}` } : undefined;
|
|
637
|
+
const fetched = await fetchMetadataAudit({
|
|
638
|
+
servicePath: serviceRoot,
|
|
639
|
+
...(isCap ? { capPath: true } : {}),
|
|
640
|
+
...(args.entitySet ? { entitySet: args.entitySet } : {}),
|
|
641
|
+
...(args.localAnnotationsXml ? { localAnnotationsXml: args.localAnnotationsXml } : {}),
|
|
642
|
+
}, ctx, deps?.fetchDoc
|
|
643
|
+
? { fetchDoc: deps.fetchDoc }
|
|
644
|
+
: isCap
|
|
645
|
+
? { fetchDoc: (p) => defaultCapFetchText(`${capOrigin}${p}`, capAuth) }
|
|
646
|
+
: undefined);
|
|
647
|
+
if ('error' in fetched) {
|
|
648
|
+
return {
|
|
649
|
+
content: `error: fiori_draft_smoke stopped before any write — could not read the service's $metadata: ${fetched.error}`,
|
|
650
|
+
is_error: true,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
const spec = {
|
|
654
|
+
appUrl,
|
|
655
|
+
metadataAudit: fetched.audit,
|
|
656
|
+
markerField: args.markerField,
|
|
657
|
+
...(args.editField ? { editField: args.editField } : {}),
|
|
658
|
+
...(typeof args.slowMo === 'number' ? { slowMo: args.slowMo } : {}),
|
|
659
|
+
...(typeof args.headless === 'boolean' ? { headless: args.headless } : {}),
|
|
660
|
+
...(isCap ? { backend: 'cap' } : {}),
|
|
661
|
+
...(isCap && args.capUser ? { capUser: args.capUser } : {}),
|
|
662
|
+
};
|
|
663
|
+
const refused = draftSmokePreconditions(spec);
|
|
664
|
+
if (refused)
|
|
665
|
+
return refuse(refused);
|
|
666
|
+
const verify = await verifyAndSpendApprovalId(args.approval_id, approvalObject, 'modify');
|
|
667
|
+
if (!verify.ok) {
|
|
668
|
+
return { content: `error: APPROVAL_INVALID: ${verify.reason} — ${howToApprove}.`, is_error: true };
|
|
669
|
+
}
|
|
670
|
+
if (draftSmokeApprovalsUsed.has(verify.payload.nonce)) {
|
|
671
|
+
return {
|
|
672
|
+
content: `error: this approval_id already ran one draft smoke — each run needs its own approval; ${howToApprove}.`,
|
|
673
|
+
is_error: true,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
draftSmokeApprovalsUsed.add(verify.payload.nonce);
|
|
677
|
+
const { renderSmokeEnabled: _enabled, fetchDoc: _fetch, fetchText: _text, ...runDeps } = deps ?? {};
|
|
678
|
+
const result = await runDraftSmoke(spec, runDeps);
|
|
679
|
+
const payload = resolvedPreview.note ? { ...result, warnings: [resolvedPreview.note] } : result;
|
|
680
|
+
return { content: JSON.stringify(payload, null, 2) };
|
|
681
|
+
}
|
|
374
682
|
// ─────────────────────────────── fiori_sample_search ──────────────────────
|
|
375
683
|
export async function fioriSampleSearchHandler(args, _ctx) {
|
|
376
684
|
if (!args.text || !args.text.trim()) {
|
|
@@ -423,6 +731,118 @@ export async function fioriSampleGetHandler(args, _ctx) {
|
|
|
423
731
|
return { content: `error: could not load sample "${args.name}" — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
424
732
|
}
|
|
425
733
|
}
|
|
734
|
+
/** `/sap/…/0001/` or `/sap/…/0001/$metadata` → the `$metadata` path. Query strings dropped. */
|
|
735
|
+
function metadataPathOf(servicePath) {
|
|
736
|
+
const p = servicePath.split('?')[0];
|
|
737
|
+
if (p.endsWith('$metadata'))
|
|
738
|
+
return p;
|
|
739
|
+
return `${p.endsWith('/') ? p : `${p}/`}$metadata`;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* The ONE fetch + audit path (fiori_metadata_audit, and fiori_draft_smoke which
|
|
743
|
+
* must not trust a model-supplied audit): GET the `$metadata` over the alias's
|
|
744
|
+
* connection, resolve every ValueListReferences document, run auditMetadata.
|
|
745
|
+
* Errors come back as data (the message the audit tool prints after "error: ").
|
|
746
|
+
*/
|
|
747
|
+
/** CAP path: a plain loopback GET with a timeout (the CAP dev server, never SAP). */
|
|
748
|
+
async function defaultCapFetchText(url, headers) {
|
|
749
|
+
const ctrl = new AbortController();
|
|
750
|
+
const timer = setTimeout(() => ctrl.abort(), 10_000);
|
|
751
|
+
try {
|
|
752
|
+
const r = await fetch(url, { signal: ctrl.signal, ...(headers ? { headers } : {}) });
|
|
753
|
+
return { status: r.status, body: await r.text() };
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
clearTimeout(timer);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
export async function fetchMetadataAudit(args, ctx, deps) {
|
|
760
|
+
const servicePath = typeof args.servicePath === 'string' ? args.servicePath.trim() : '';
|
|
761
|
+
// A path on the alias's own host only — never a URL, never a relative path.
|
|
762
|
+
// CAP path: the caller proved the local CAP dev server and supplies fetchDoc.
|
|
763
|
+
if (args.capPath ? !capService(servicePath).ok : !servicePath.startsWith('/sap/')) {
|
|
764
|
+
return {
|
|
765
|
+
error: 'servicePath must start with /sap/ — the OData V4 service path on the configured system, e.g. ' +
|
|
766
|
+
'"/sap/opu/odata4/sap/zui_x_o4/srvd/sap/zui_x/0001/" (a host or scheme is never accepted).',
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
const metadataPath = metadataPathOf(servicePath);
|
|
770
|
+
const fetchDoc = deps?.fetchDoc ??
|
|
771
|
+
(async (p) => {
|
|
772
|
+
const { getConnection } = await import('../../sap/connection-manager.js');
|
|
773
|
+
const cfg = await loadConfig();
|
|
774
|
+
const sys = cfg.sap[ctx.sapAlias];
|
|
775
|
+
if (!sys)
|
|
776
|
+
throw new Error(`no SAP alias "${ctx.sapAlias}" is configured`);
|
|
777
|
+
const conn = await getConnection(ctx.sapAlias, sys);
|
|
778
|
+
const resp = await conn.get(p, 'application/xml');
|
|
779
|
+
return { status: resp.status, body: resp.body };
|
|
780
|
+
});
|
|
781
|
+
let main;
|
|
782
|
+
try {
|
|
783
|
+
main = await fetchDoc(metadataPath);
|
|
784
|
+
}
|
|
785
|
+
catch (err) {
|
|
786
|
+
return {
|
|
787
|
+
error: `could not fetch ${metadataPath} — ${err instanceof Error ? err.message : String(err)}`,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
if (main.status < 200 || main.status >= 300) {
|
|
791
|
+
return {
|
|
792
|
+
error: `${metadataPath} → HTTP ${main.status}. Is the service binding published, and is the path the ` +
|
|
793
|
+
'binding\'s service URL?',
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
if (!/<edmx:Edmx[\s>]/.test(main.body) || !main.body.includes('</edmx:Edmx>')) {
|
|
797
|
+
return {
|
|
798
|
+
error: `${metadataPath} answered HTTP ${main.status} but the body is not an OData $metadata document (no complete <edmx:Edmx>).`,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
// Every referenced value-help document, once each, in document order. A
|
|
802
|
+
// failure is DATA (the value-help-unreachable flag), never a thrown error.
|
|
803
|
+
const referenceDocs = new Map();
|
|
804
|
+
const referenceFailures = new Map();
|
|
805
|
+
try {
|
|
806
|
+
const urls = [...new Set(readValueListReferences(main.body).flatMap((r) => r.urls))];
|
|
807
|
+
for (const url of urls) {
|
|
808
|
+
try {
|
|
809
|
+
const resp = await fetchDoc(resolveReferenceUrl(metadataPath, url));
|
|
810
|
+
if (resp.status >= 200 && resp.status < 300)
|
|
811
|
+
referenceDocs.set(url, resp.body);
|
|
812
|
+
else
|
|
813
|
+
referenceFailures.set(url, resp.status);
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
referenceFailures.set(url, 0); // no HTTP answer at all
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
return {
|
|
822
|
+
error: `${metadataPath} could not be parsed — ${err instanceof Error ? err.message : String(err)}`,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
try {
|
|
826
|
+
const audit = auditMetadata({
|
|
827
|
+
servicePath: metadataPath,
|
|
828
|
+
mainXml: main.body,
|
|
829
|
+
referenceDocs,
|
|
830
|
+
referenceFailures,
|
|
831
|
+
...(args.localAnnotationsXml ? { localAnnotationsXml: args.localAnnotationsXml } : {}),
|
|
832
|
+
...(args.entitySet ? { entitySet: args.entitySet } : {}),
|
|
833
|
+
});
|
|
834
|
+
return { audit };
|
|
835
|
+
}
|
|
836
|
+
catch (err) {
|
|
837
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
export async function fioriMetadataAuditHandler(args, ctx, deps) {
|
|
841
|
+
const r = await fetchMetadataAudit(args, ctx, deps);
|
|
842
|
+
if ('error' in r)
|
|
843
|
+
return { content: `error: ${r.error}`, is_error: true };
|
|
844
|
+
return { content: JSON.stringify(r.audit, null, 2) };
|
|
845
|
+
}
|
|
426
846
|
// ─────────────────────────────── registration ─────────────────────────────
|
|
427
847
|
registerTool({
|
|
428
848
|
name: 'fiori_scaffold',
|
|
@@ -444,7 +864,7 @@ registerTool({
|
|
|
444
864
|
servicePath: { type: 'string', description: 'Relative OData service path, e.g. "/sap/opu/odata4/zso/srv/".' },
|
|
445
865
|
serviceVersion: { type: 'string', enum: ['2.0', '4.0'], description: 'OData version of the primary service. Default "4.0".' },
|
|
446
866
|
typescript: { type: 'boolean', description: 'Generate a TypeScript app. Default false.' },
|
|
447
|
-
ui5Version: { type: 'string', description:
|
|
867
|
+
ui5Version: { type: 'string', description: `UI5 version for manifest minUI5Version + preview. Default ${PINNED_UI5_VERSION} (an exact hosted patch — SAP removes old patches from the UI5 CDN). Do not go below 1.84.` },
|
|
448
868
|
},
|
|
449
869
|
required: ['basePath', 'appId'],
|
|
450
870
|
},
|
|
@@ -532,7 +952,12 @@ registerTool({
|
|
|
532
952
|
metadata: { type: 'string', description: 'OPTIONAL service $metadata (EDMX XML). The writer does not need it; pass only if you already have it.' },
|
|
533
953
|
client: { type: 'string', description: 'OPTIONAL SAP client (e.g. "100"), written to the manifest for the proxy layer.' },
|
|
534
954
|
mainEntity: { type: 'string', description: 'The entity set to bind, e.g. "Course".' },
|
|
535
|
-
ui5Version: { type: 'string', description:
|
|
955
|
+
ui5Version: { type: 'string', description: `UI5 version. Default ${PINNED_UI5_VERSION} (an exact hosted patch — SAP removes old patches from the UI5 CDN).` },
|
|
956
|
+
backend: {
|
|
957
|
+
type: 'string',
|
|
958
|
+
enum: ['cap'],
|
|
959
|
+
description: 'CAP apps only: "cap" for an app served inside the CAP server by cds-plugin-ui5 (leaves out the fiori-tools-preview middleware, which loads a second CAP copy into the server and crashes draft saves). Omit for ABAP backends.',
|
|
960
|
+
},
|
|
536
961
|
localAnnotations: {
|
|
537
962
|
type: 'object',
|
|
538
963
|
description: 'OPTIONAL local annotations.xml for a FOREIGN OData service you do NOT own (cannot add backend @UI). ' +
|
|
@@ -605,10 +1030,21 @@ registerTool({
|
|
|
605
1030
|
input_schema: {
|
|
606
1031
|
type: 'object',
|
|
607
1032
|
properties: {
|
|
1033
|
+
capUser: {
|
|
1034
|
+
type: 'string',
|
|
1035
|
+
description: "CAP apps only: the mock user for CAP's development login when the service needs one (e.g. \"alice\").",
|
|
1036
|
+
},
|
|
608
1037
|
appUrl: {
|
|
609
1038
|
type: 'string',
|
|
610
1039
|
description: 'The LOCAL AUTHENTICATED PREVIEW URL (the `npm run start` / `fiori run` URL that proxies to the ' +
|
|
611
|
-
'real backend with your auth), e.g. "http://localhost:8080/index.html". NOT the deployed BSP URL.'
|
|
1040
|
+
'real backend with your auth), e.g. "http://localhost:8080/index.html". NOT the deployed BSP URL. ' +
|
|
1041
|
+
'Not needed when previewId is given.',
|
|
1042
|
+
},
|
|
1043
|
+
previewId: {
|
|
1044
|
+
type: 'string',
|
|
1045
|
+
description: 'PREFERRED: the id returned by fiori_preview action "start". Resolves to the URL that preview is ' +
|
|
1046
|
+
'actually serving, so there is no guessing between :8080 and :8081. When both previewId and appUrl ' +
|
|
1047
|
+
'are given, previewId wins and the result says so in its warnings.',
|
|
612
1048
|
},
|
|
613
1049
|
appDir: {
|
|
614
1050
|
type: 'string',
|
|
@@ -635,11 +1071,183 @@ registerTool({
|
|
|
635
1071
|
description: 'Post-boot interactions to exercise (press/route). Declared for Track 3; unused in Track 2.',
|
|
636
1072
|
items: { type: 'object' },
|
|
637
1073
|
},
|
|
1074
|
+
metadataAudit: {
|
|
1075
|
+
type: 'object',
|
|
1076
|
+
description: 'OPTIONAL: the result of fiori_metadata_audit for the list’s entity set, passed through as-is. When ' +
|
|
1077
|
+
'given, Fiori Elements mode DERIVES expectedColumns from its UI.LineItem labels (a model-supplied ' +
|
|
1078
|
+
'expectedColumns is ignored, with a warning) and adds two checks: "labels" (every column rendered under ' +
|
|
1079
|
+
'its label; no bare technical name as a header) and "no-raw-codes" (every coded column shows its text, ' +
|
|
1080
|
+
'not the raw code — compared row by row, first 20 rows, against the backend read from inside the page).',
|
|
1081
|
+
},
|
|
638
1082
|
},
|
|
639
|
-
|
|
1083
|
+
// L1-6: `previewId` is an equal alternative to `appUrl`, so neither is
|
|
1084
|
+
// schema-required. The handler still returns the SAME "appUrl is required"
|
|
1085
|
+
// error when neither is given — unchanged for every existing caller.
|
|
1086
|
+
required: [],
|
|
640
1087
|
},
|
|
641
1088
|
handler: fioriRenderSmokeHandler,
|
|
642
1089
|
});
|
|
1090
|
+
registerTool({
|
|
1091
|
+
name: 'fiori_draft_smoke',
|
|
1092
|
+
description: 'Draft round trip through a Fiori Elements V4 app on its LOCAL preview — a SAP WRITE. It creates one test row ' +
|
|
1093
|
+
"through the app's own OData service, marked CSP-SMOKE-<runId> in markerField, drives the app like a user " +
|
|
1094
|
+
'(open the row, Edit, change one field THROUGH ITS VALUE HELP, Save; then Edit, change, Discard) and deletes the ' +
|
|
1095
|
+
"row and any draft of it at the end, even when a step fails. At the start it also removes other runs' leftover " +
|
|
1096
|
+
"CSP-SMOKE- rows and drafts (by the prefix, not only this run's id — fine on a development system) and nothing " +
|
|
1097
|
+
"else. It fetches and audits the service's $metadata itself — it never takes an audit from the call. Checks: " +
|
|
1098
|
+
'sweep, setup, rows, value-help (the value ' +
|
|
1099
|
+
'help opens and lists at least one entry — dropdown or dialog), draft-save (the backend has the new value, the ' +
|
|
1100
|
+
'list shows its text), draft-discard (the saved value is back, no draft left), no-error-dialogs, odata (the ' +
|
|
1101
|
+
'INNER $batch statuses) and cleanup. The activation action is the one Common.DraftRoot names in the fetched $metadata — ' +
|
|
1102
|
+
'never a guessed name. REFUSED before anything is sent unless the service binding AND definition are in the ' +
|
|
1103
|
+
'customer namespace (Z*/Y*) and the app URL is a local preview (localhost / 127.x). NEEDS APPROVAL: call ' +
|
|
1104
|
+
'request_approval with a change {op:"modify", object:"<SERVICE BINDING NAME>", type:"DATA"} first and pass the ' +
|
|
1105
|
+
'id as approval_id — one approval runs one smoke. Flag-gated (enabled with local_build); needs a configured SAP ' +
|
|
1106
|
+
'alias and a running preview (fiori_preview).',
|
|
1107
|
+
isMutating: true,
|
|
1108
|
+
// CAP path (2026-09-25): usable without an SAP connection (standalone CAP work);
|
|
1109
|
+
// the SAP path still needs one and fails cleanly without it (no alias configured).
|
|
1110
|
+
requiresSap: false,
|
|
1111
|
+
category: 'fiori',
|
|
1112
|
+
flagGated: true,
|
|
1113
|
+
input_schema: {
|
|
1114
|
+
type: 'object',
|
|
1115
|
+
properties: {
|
|
1116
|
+
previewId: { type: 'string', description: 'PREFERRED: the id from fiori_preview "start". Wins over appUrl.' },
|
|
1117
|
+
appUrl: {
|
|
1118
|
+
type: 'string',
|
|
1119
|
+
description: 'The LOCAL preview URL (localhost / 127.x only), e.g. the fiori_preview url. Never a deployed app.',
|
|
1120
|
+
},
|
|
1121
|
+
servicePath: {
|
|
1122
|
+
type: 'string',
|
|
1123
|
+
description: 'The OData V4 service path of the app, e.g. "/sap/opu/odata4/sap/zui_x_o4/srvd/sap/zui_x/0001/". The smoke ' +
|
|
1124
|
+
'fetches and audits its $metadata itself (keys, mandatory fields, value helps, the Common.DraftRoot actions).',
|
|
1125
|
+
},
|
|
1126
|
+
entitySet: { type: 'string', description: 'The draft root entity set. Default: as fiori_metadata_audit picks it.' },
|
|
1127
|
+
localAnnotationsXml: {
|
|
1128
|
+
type: 'string',
|
|
1129
|
+
description: "Optional: the app's local annotations EDMX content, merged over the service (as in fiori_metadata_audit).",
|
|
1130
|
+
},
|
|
1131
|
+
markerField: {
|
|
1132
|
+
type: 'string',
|
|
1133
|
+
description: 'A writable text property of the entity (e.g. "Note") that holds the CSP-SMOKE-<runId> marker (29 characters).',
|
|
1134
|
+
},
|
|
1135
|
+
editField: {
|
|
1136
|
+
type: 'string',
|
|
1137
|
+
description: 'The property changed through its value help. Default: the first rendered coded field that has one.',
|
|
1138
|
+
},
|
|
1139
|
+
backend: {
|
|
1140
|
+
type: 'string',
|
|
1141
|
+
enum: ['sap', 'cap'],
|
|
1142
|
+
description: 'Optional: "cap" for a local CAP app served by cds watch (servicePath like /odata/v4/<service>/, appUrl on that ' +
|
|
1143
|
+
'same CAP server; the tool refuses unless the server proves it is the CAP development server). Default: an SAP ' +
|
|
1144
|
+
'OData V4 service (Z*/Y*).',
|
|
1145
|
+
},
|
|
1146
|
+
capUser: {
|
|
1147
|
+
type: 'string',
|
|
1148
|
+
description: 'CAP only: the mock user for the CAP development login when the service needs one (e.g. "alice").',
|
|
1149
|
+
},
|
|
1150
|
+
slowMo: {
|
|
1151
|
+
type: 'number',
|
|
1152
|
+
description: 'Optional: slow each browser step by this many milliseconds, to record the run on camera. Default 0.',
|
|
1153
|
+
},
|
|
1154
|
+
headless: {
|
|
1155
|
+
type: 'boolean',
|
|
1156
|
+
description: 'Optional: false shows the browser window, to record the run on camera. Default true.',
|
|
1157
|
+
},
|
|
1158
|
+
approval_id: {
|
|
1159
|
+
type: 'string',
|
|
1160
|
+
description: 'From request_approval: change {op:"modify", object:"<SERVICE BINDING NAME>", type:"DATA"}.',
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
required: ['servicePath', 'markerField', 'approval_id'],
|
|
1164
|
+
},
|
|
1165
|
+
handler: fioriDraftSmokeHandler,
|
|
1166
|
+
});
|
|
1167
|
+
registerTool({
|
|
1168
|
+
name: 'fiori_preview',
|
|
1169
|
+
description: 'Preview lifecycle for a scaffolded Fiori app — the thing every browser check depends on. ' +
|
|
1170
|
+
'action "start" starts a local preview server for the given app directory. The SAP login for the ' +
|
|
1171
|
+
'alias is passed to that process through its environment when the app\'s ui5 config names a backend ' +
|
|
1172
|
+
'(an app with no backend url starts without it) — never on the command line and never in a file. ' +
|
|
1173
|
+
'It picks a free port, runs the app\'s own "npm run start" in its directory, and waits until ' +
|
|
1174
|
+
'BOTH the app AND its OData $metadata answer THROUGH the preview proxy before reporting ready (a ' +
|
|
1175
|
+
'port that merely opened is never called ready). The certificate and the login travel as ' +
|
|
1176
|
+
'NODE_EXTRA_CA_CERTS / FIORI_TOOLS_USER / FIORI_TOOLS_PASSWORD, read from the OS keychain. The ' +
|
|
1177
|
+
'start script itself must be a plain "fiori run" or "ui5 serve" — anything else is refused rather ' +
|
|
1178
|
+
'than handed the login; so is an app with a "prestart"/"poststart" script, an .npmrc that sets ' +
|
|
1179
|
+
'script-shell or node-options, or a ui5.yaml whose backend url is not the alias\'s own host and port. ' +
|
|
1180
|
+
'Certificate verification is never turned off: an alias with checking off ' +
|
|
1181
|
+
'still starts, and the result says so on its own line. ONE preview per app directory: a second ' +
|
|
1182
|
+
'start for the same directory and alias REUSES the live one instead of leaving a second server on ' +
|
|
1183
|
+
'another port; a start for a DIFFERENT alias or entry page stops the old one first and says so. A ' +
|
|
1184
|
+
'start that never becomes ready kills the whole process tree and returns the child\'s last output ' +
|
|
1185
|
+
'with a classified reason (refused / app-not-served / metadata-not-served / timeout). action "stop" ' +
|
|
1186
|
+
'kills the tree and reports whether the port actually closed; action "list" shows what is running. ' +
|
|
1187
|
+
'Pass the returned previewId to fiori_render_smoke instead of a hand-typed appUrl. The app\'s own ' +
|
|
1188
|
+
'start script may open a browser window. A proxy configured in the environment is NOT inherited by ' +
|
|
1189
|
+
'the preview, so a machine that can only reach the UI5 CDN through one may need it in the app\'s own ' +
|
|
1190
|
+
'configuration. Flag-gated (enabled with local_build); needs a configured SAP alias, and a stored ' +
|
|
1191
|
+
'password for it when the app names a backend url.',
|
|
1192
|
+
isMutating: true,
|
|
1193
|
+
requiresSap: true,
|
|
1194
|
+
category: 'fiori',
|
|
1195
|
+
flagGated: true,
|
|
1196
|
+
input_schema: {
|
|
1197
|
+
type: 'object',
|
|
1198
|
+
properties: {
|
|
1199
|
+
action: {
|
|
1200
|
+
type: 'string',
|
|
1201
|
+
enum: ['start', 'stop', 'list'],
|
|
1202
|
+
description: 'start a preview for an app dir, stop one by previewId, or list the running ones. Default "start".',
|
|
1203
|
+
},
|
|
1204
|
+
appDir: { type: 'string', description: 'action "start": the scaffolded app root (relative to the project root, sandboxed).' },
|
|
1205
|
+
alias: { type: 'string', description: 'SAP alias whose CA + credentials the preview proxy uses. Defaults to the session alias.' },
|
|
1206
|
+
servicePath: {
|
|
1207
|
+
type: 'string',
|
|
1208
|
+
description: 'OData service path, e.g. "/sap/opu/odata4/sap/zc_x/srvd/sap/zc_x/0001/". Used for the $metadata readiness ' +
|
|
1209
|
+
'leg — the one that proves the proxy really reached the backend. Omit only for an app with no backend service; ' +
|
|
1210
|
+
'such an app (no backend url in its ui5.yaml) starts WITHOUT the SAP login and without the approval prompt.',
|
|
1211
|
+
},
|
|
1212
|
+
previewId: { type: 'string', description: 'action "stop": which preview to stop.' },
|
|
1213
|
+
entryFile: { type: 'string', description: 'Preview entry page. Default: the page the app’s own npm start opens (the FLP sandbox for scaffolded apps — Fiori Elements V4 needs the shell), else "index.html".' },
|
|
1214
|
+
budgetMs: { type: 'number', description: 'How long to wait for readiness. Default 120000 (a cold UI5 CDN load is slow).' },
|
|
1215
|
+
},
|
|
1216
|
+
required: [],
|
|
1217
|
+
},
|
|
1218
|
+
// R1-6 (owner decision 2026-09-21): a start hands the stored SAP login to
|
|
1219
|
+
// the preview process — dispatchTool asks a human once per session per alias.
|
|
1220
|
+
// Round 2b (owner ruling): NO prompt only when app-guard's loginNeed says
|
|
1221
|
+
// 'none' (no backend url). Any doubt — no appDir, an escaping appDir, no
|
|
1222
|
+
// package.json, an unreadable/uncertain/outside config, anything thrown —
|
|
1223
|
+
// returns the handover, i.e. PROMPTS. The handler re-runs the decision.
|
|
1224
|
+
credentialHandover: async (args, ctx) => {
|
|
1225
|
+
if ((args.action ?? 'start') !== 'start')
|
|
1226
|
+
return null;
|
|
1227
|
+
const alias = args.alias ?? ctx.sapAlias;
|
|
1228
|
+
const handover = {
|
|
1229
|
+
alias,
|
|
1230
|
+
summary: `The SAP login for alias ${alias} is passed to the preview process through its environment ` +
|
|
1231
|
+
`("npm run start" in ${args.appDir ?? '(no appDir given)'}). Asked once per session for each alias.`,
|
|
1232
|
+
};
|
|
1233
|
+
try {
|
|
1234
|
+
if (!args.appDir)
|
|
1235
|
+
return handover;
|
|
1236
|
+
const resolved = safeDir(ctx, args.appDir);
|
|
1237
|
+
if ('error' in resolved)
|
|
1238
|
+
return handover;
|
|
1239
|
+
const startScript = await readStartScript(resolved.abs);
|
|
1240
|
+
if (startScript === null)
|
|
1241
|
+
return handover;
|
|
1242
|
+
const need = await loginNeed(resolved.abs, startScript, alias);
|
|
1243
|
+
return 'need' in need && need.need === 'none' ? null : handover;
|
|
1244
|
+
}
|
|
1245
|
+
catch {
|
|
1246
|
+
return handover;
|
|
1247
|
+
}
|
|
1248
|
+
},
|
|
1249
|
+
handler: fioriPreviewHandler,
|
|
1250
|
+
});
|
|
643
1251
|
registerTool({
|
|
644
1252
|
name: 'fiori_sample_search',
|
|
645
1253
|
description: 'Search the vendored UI5 Demo Kit sample corpus (grounding for the freestyle composer): free-text ' +
|
|
@@ -681,3 +1289,39 @@ registerTool({
|
|
|
681
1289
|
},
|
|
682
1290
|
handler: fioriSampleGetHandler,
|
|
683
1291
|
});
|
|
1292
|
+
registerTool({
|
|
1293
|
+
name: 'fiori_metadata_audit',
|
|
1294
|
+
description: 'Machine truth about a published OData V4 service, read from the system itself. Fetches the service\'s ' +
|
|
1295
|
+
'$metadata and every value-help document it references (Common.ValueListReferences — a RAP V4 service ' +
|
|
1296
|
+
'keeps its value helps in separate documents, not inline), and returns JSON for one entity set: per ' +
|
|
1297
|
+
'property the label and where it comes from, the text path and text arrangement, the value help ' +
|
|
1298
|
+
'(inline / references / fixed-values dropdown, its collection, key and text), whether the field is a ' +
|
|
1299
|
+
'coded value; plus the fully-qualified draft action names, the line item, selection fields, field ' +
|
|
1300
|
+
'groups, header info and create/update/delete capabilities. It also returns flags, each with a ' +
|
|
1301
|
+
'one-sentence fix: no-label, coded-without-text, coded-without-value-help, value-help-unreachable. ' +
|
|
1302
|
+
'Use it so no downstream check has to trust a model-supplied string — never hardcode a draft action ' +
|
|
1303
|
+
'name or assume a label. servicePath is the binding\'s service URL on the configured system, starting ' +
|
|
1304
|
+
'with /sap/ (with or without the trailing $metadata). entitySet defaults to the first set with a line ' +
|
|
1305
|
+
'item, else the draft root. localAnnotationsXml (optional) is the app\'s local annotation file content, ' +
|
|
1306
|
+
'merged over the service. Read-only; flag-gated (enabled with local_build); needs a configured SAP alias.',
|
|
1307
|
+
isMutating: false,
|
|
1308
|
+
requiresSap: true,
|
|
1309
|
+
category: 'fiori',
|
|
1310
|
+
flagGated: true,
|
|
1311
|
+
input_schema: {
|
|
1312
|
+
type: 'object',
|
|
1313
|
+
properties: {
|
|
1314
|
+
servicePath: {
|
|
1315
|
+
type: 'string',
|
|
1316
|
+
description: 'OData V4 service path, e.g. "/sap/opu/odata4/sap/zui_x_o4/srvd/sap/zui_x/0001/" — "$metadata" is appended when missing.',
|
|
1317
|
+
},
|
|
1318
|
+
entitySet: { type: 'string', description: 'Entity set to audit. Default: the first with UI.LineItem, else the draft root.' },
|
|
1319
|
+
localAnnotationsXml: {
|
|
1320
|
+
type: 'string',
|
|
1321
|
+
description: 'Optional: the content of the app\'s local annotations EDMX (webapp/annotations/*.xml), merged over the service.',
|
|
1322
|
+
},
|
|
1323
|
+
},
|
|
1324
|
+
required: ['servicePath'],
|
|
1325
|
+
},
|
|
1326
|
+
handler: fioriMetadataAuditHandler,
|
|
1327
|
+
});
|