@cspeach/cli 0.9.0 → 1.1.0

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 (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -0,0 +1,76 @@
1
+ // cspeach-cli/src/tools/fiori/fe-extend.ts
2
+ /**
3
+ * Add a Fiori Elements extension point (custom column / action / section /
4
+ * controller-extension) to an EXISTING V4 FE app on disk, via
5
+ * @sap-ux/fe-fpm-writer. Mirrors scaffold.ts's generate()+commit() pattern:
6
+ * run the matching generator into a mem-fs editor, then flush with commit().
7
+ *
8
+ * SCOPE LIMIT (V4 FE only)
9
+ * ------------------------
10
+ * fe-fpm-writer's validateBasePath requires a `sap.fe.templates` dependency in
11
+ * the manifest — it works ONLY on V4 Fiori Elements apps (LROP / Worklist /
12
+ * ALP / FEOP) and REFUSES OVP (sap.ovp) and every OData V2 app with a
13
+ * "Dependency sap.fe.templates is missing …" library error. We catch that and
14
+ * rethrow the clean typed message so callers get a single, actionable line
15
+ * instead of the writer's internal wording.
16
+ */
17
+ import { create } from 'mem-fs';
18
+ import { create as createEditor } from 'mem-fs-editor';
19
+ /** The four supported extension operations. Closed union → schema enum. */
20
+ export const FE_EXTEND_OPS = ['custom-column', 'custom-action', 'custom-section', 'controller-extension'];
21
+ /** Thrown when the target app is not a V4 Fiori Elements app (no sap.fe.templates). */
22
+ export class NotV4FeAppError extends Error {
23
+ constructor() {
24
+ super('fiori_fe_extend requires a V4 Fiori Elements app');
25
+ this.name = 'NotV4FeAppError';
26
+ }
27
+ }
28
+ // fe-fpm-writer's validateBasePath library error — both the single-dependency
29
+ // and (defensively) the shared trailing sentence are matched so a wording
30
+ // tweak on either side still maps to the typed V4-only error.
31
+ function isMissingFeLibError(msg) {
32
+ return /sap\.fe\.templates is missing/i.test(msg) || /Fiori elements FPM requires the SAP FE libraries/i.test(msg);
33
+ }
34
+ /**
35
+ * Run the requested FE extension generator against an existing V4 FE app and
36
+ * flush the result to disk. Throws NotV4FeAppError when the target is not a V4
37
+ * FE app; rethrows any other writer error unchanged.
38
+ */
39
+ export async function feExtend(opts) {
40
+ // Dynamic import: keep the heavy writer out of the cold-start path (mirrors
41
+ // the engine's lazy-load convention; only paid when the tool actually runs).
42
+ const fpm = await import('@sap-ux/fe-fpm-writer');
43
+ const editor = createEditor(create());
44
+ let result;
45
+ try {
46
+ // params is passed through verbatim (1:1 with the writer's Custom*/Controller
47
+ // config types) — the tool is a thin, deterministic pass-through.
48
+ switch (opts.op) {
49
+ case 'custom-column':
50
+ result = await fpm.generateCustomColumn(opts.basePath, opts.params, editor);
51
+ break;
52
+ case 'custom-action':
53
+ result = await fpm.generateCustomAction(opts.basePath, opts.params, editor);
54
+ break;
55
+ case 'custom-section':
56
+ result = await fpm.generateCustomSection(opts.basePath, opts.params, editor);
57
+ break;
58
+ case 'controller-extension':
59
+ result = await fpm.generateControllerExtension(opts.basePath, opts.params, editor);
60
+ break;
61
+ default: {
62
+ // Exhaustiveness guard: the union is closed. A future op added without a
63
+ // branch here fails loudly instead of silently no-op'ing.
64
+ const never = opts.op;
65
+ throw new Error(`unsupported fe_extend op: ${String(never)}`);
66
+ }
67
+ }
68
+ }
69
+ catch (err) {
70
+ const msg = err instanceof Error ? err.message : String(err);
71
+ if (isMissingFeLibError(msg))
72
+ throw new NotV4FeAppError();
73
+ throw err;
74
+ }
75
+ await new Promise((res, rej) => result.commit((e) => (e ? rej(e) : res())));
76
+ }
@@ -0,0 +1,71 @@
1
+ // cspeach-cli/src/tools/fiori/fe-scaffold.ts
2
+ import { generate, TemplateType, OdataVersion } from '@sap-ux/fiori-elements-writer';
3
+ import { create } from 'mem-fs';
4
+ import { create as createEditor } from 'mem-fs-editor';
5
+ import { join } from 'node:path';
6
+ const DEFAULT_UI5_VERSION = '1.120.0'; // same rationale as scaffold.ts (avoids the 1.71 blank-page trap)
7
+ /** Scaffold a Fiori Elements shell and FULLY flush it to disk. Relies on the
8
+ * service's backend @UI annotations — authors no local annotation.xml. */
9
+ export async function scaffoldFioriElements(opts) {
10
+ const ui5Version = opts.ui5Version ?? DEFAULT_UI5_VERSION;
11
+ const version = opts.service.version === '2.0' ? OdataVersion.v2 : OdataVersion.v4;
12
+ // Per-template settings (spike §3): LROP/Worklist/ALP use
13
+ // entityConfig.mainEntityName (ALP is a LROP-based analytical variant — same
14
+ // settings shape); OVP uses filterEntitySet (the modern replacement for the
15
+ // deprecated filterEntityType). Each arm is explicit so adding a branch never
16
+ // silently changes another floorplan's behaviour.
17
+ let template;
18
+ if (opts.template === 'lrop') {
19
+ template = { type: TemplateType.ListReportObjectPage, settings: { entityConfig: { mainEntityName: opts.mainEntity } } };
20
+ }
21
+ else if (opts.template === 'worklist') {
22
+ template = { type: TemplateType.Worklist, settings: { entityConfig: { mainEntityName: opts.mainEntity } } };
23
+ }
24
+ else if (opts.template === 'ovp') {
25
+ template = { type: TemplateType.OverviewPage, settings: { filterEntitySet: opts.mainEntity } };
26
+ }
27
+ else if (opts.template === 'alp') {
28
+ template = { type: TemplateType.AnalyticalListPage, settings: { entityConfig: { mainEntityName: opts.mainEntity } } };
29
+ }
30
+ else {
31
+ // Exhaustiveness guard: the union is closed, so this is unreachable — but a
32
+ // future template added to the union without a branch here fails loudly
33
+ // instead of silently misrouting to another floorplan.
34
+ const never = opts.template;
35
+ throw new Error(`unsupported FE template: ${String(never)}`);
36
+ }
37
+ // Build the service object: version is required; url/path/metadata/client only
38
+ // when provided (metadata is optional — spike §4).
39
+ const service = { version };
40
+ if (opts.service.url)
41
+ service.url = opts.service.url;
42
+ if (opts.service.path)
43
+ service.path = opts.service.path;
44
+ if (opts.service.metadata)
45
+ service.metadata = opts.service.metadata;
46
+ if (opts.service.client)
47
+ service.client = opts.service.client;
48
+ // Foreign-service local annotations: thread ONLY localAnnotationsName so the
49
+ // writer produces a LOCAL ODataAnnotation dataSource (uri === localUri ===
50
+ // annotations/<name>.xml). The real EDMX is written over the skeleton below,
51
+ // after generate() and before commit. When absent, the writer's own default
52
+ // ('annotation') is left untouched — zero regression.
53
+ if (opts.localAnnotations)
54
+ service.localAnnotationsName = opts.localAnnotations.technicalName;
55
+ const fs = createEditor(create());
56
+ const config = {
57
+ app: { id: opts.appId, title: opts.appTitle, projectType: 'EDMXBackend' },
58
+ package: { name: opts.appId },
59
+ template,
60
+ service,
61
+ appOptions: { typescript: false },
62
+ ui5: { minUI5Version: ui5Version, version: ui5Version },
63
+ };
64
+ await generate(opts.basePath, config, fs);
65
+ // Overwrite the writer's empty EDMX skeleton with the caller's real annotations
66
+ // EDMX, in the mem-fs editor, BEFORE commit so the flushed file is the caller's.
67
+ if (opts.localAnnotations) {
68
+ fs.write(join(opts.basePath, 'webapp', 'annotations', `${opts.localAnnotations.technicalName}.xml`), opts.localAnnotations.xml);
69
+ }
70
+ await new Promise((res, rej) => fs.commit((err) => (err ? rej(err) : res())));
71
+ }
@@ -0,0 +1,19 @@
1
+ // cspeach-cli/src/tools/fiori/floorplan-map.ts
2
+ //
3
+ // Single source of truth for the plan-name ↔ tool-template spelling
4
+ // divergence: the plan schema (PlanPhaseUi.floorplan) names the List Report
5
+ // floorplan 'listReport', but the FE writer / fiori_scaffold_fe tool names
6
+ // the same template 'lrop'. Every other floorplan spells identically.
7
+ //
8
+ // Deliberate leaf module (cycle-1 M7): it imports NOTHING from the writer, so
9
+ // plan-resume.ts can consume the map without pulling
10
+ // @sap-ux/fiori-elements-writer into plan-resume's test graph. Both
11
+ // fe-scaffold's consumers and plan-resume import FLOORPLAN_TO_TEMPLATE here.
12
+ /** Deterministic plan-floorplan → tool-template mapping. Only 'listReport'
13
+ * diverges ('lrop'); the rest map to their identical spelling. */
14
+ export const FLOORPLAN_TO_TEMPLATE = {
15
+ listReport: 'lrop',
16
+ worklist: 'worklist',
17
+ ovp: 'ovp',
18
+ alp: 'alp',
19
+ };
@@ -0,0 +1,39 @@
1
+ // cspeach-cli/src/tools/fiori/i18n.ts
2
+ export class I18nConflictError extends Error {
3
+ constructor(key, a, b) {
4
+ super(`i18n key "${key}" already set to "${a}", refusing to overwrite with "${b}"`);
5
+ this.name = 'I18nConflictError';
6
+ }
7
+ }
8
+ /** Parse a .properties body into a key→value map (ignores comments/blanks). */
9
+ function parse(text) {
10
+ const map = new Map();
11
+ for (const line of text.split(/\r?\n/)) {
12
+ const t = line.trim();
13
+ if (!t || t.startsWith('#'))
14
+ continue;
15
+ const eq = t.indexOf('=');
16
+ if (eq === -1)
17
+ continue;
18
+ map.set(t.slice(0, eq).trim(), t.slice(eq + 1));
19
+ }
20
+ return map;
21
+ }
22
+ /** Returns the properties text with `keys` appended; dedupes, errors on conflict. */
23
+ export function appendI18n(existing, keys) {
24
+ const map = parse(existing);
25
+ const additions = [];
26
+ for (const [k, v] of Object.entries(keys)) {
27
+ const cur = map.get(k);
28
+ if (cur === undefined) {
29
+ additions.push(`${k}=${v}`);
30
+ map.set(k, v);
31
+ }
32
+ else if (cur !== v)
33
+ throw new I18nConflictError(k, cur, v);
34
+ }
35
+ if (additions.length === 0)
36
+ return existing;
37
+ const sep = existing.endsWith('\n') || existing === '' ? '' : '\n';
38
+ return `${existing}${sep}${additions.join('\n')}\n`;
39
+ }
@@ -0,0 +1,70 @@
1
+ // cspeach-cli/src/tools/fiori/manifest.ts
2
+ /** Deep-merges manifest.json fragments. Unions object maps by key and arrays
3
+ * of named objects (routes) by `name`; identical value = no-op, differing
4
+ * value for the same key = hard error. Never silent last-wins. */
5
+ export class ManifestMergeError extends Error {
6
+ path;
7
+ a;
8
+ b;
9
+ constructor(path, a, b) {
10
+ super(`Manifest merge conflict at "${path}": ${JSON.stringify(a)} vs ${JSON.stringify(b)}`);
11
+ this.path = path;
12
+ this.a = a;
13
+ this.b = b;
14
+ this.name = 'ManifestMergeError';
15
+ }
16
+ }
17
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
18
+ /** Arrays whose elements are merged by a key field instead of concatenated. */
19
+ const ARRAY_KEY_BY = { routes: 'name' };
20
+ function mergeNode(a, b, path) {
21
+ if (b === undefined)
22
+ return a;
23
+ if (a === undefined)
24
+ return b;
25
+ if (isObj(a) && isObj(b)) {
26
+ const out = { ...a };
27
+ for (const k of Object.keys(b))
28
+ out[k] = mergeNode(a[k], b[k], `${path}.${k}`);
29
+ return out;
30
+ }
31
+ if (Array.isArray(a) && Array.isArray(b)) {
32
+ const keyField = ARRAY_KEY_BY[path.split('.').pop() ?? ''];
33
+ if (keyField) {
34
+ const out = [...a];
35
+ for (const item of b) {
36
+ const idx = out.findIndex((x) => x?.[keyField] === item?.[keyField]);
37
+ if (idx === -1)
38
+ out.push(item);
39
+ else if (JSON.stringify(out[idx]) !== JSON.stringify(item))
40
+ throw new ManifestMergeError(`${path}[${keyField}=${item[keyField]}]`, out[idx], item);
41
+ }
42
+ return out;
43
+ }
44
+ return [...a, ...b];
45
+ }
46
+ if (JSON.stringify(a) === JSON.stringify(b))
47
+ return a;
48
+ throw new ManifestMergeError(path, a, b);
49
+ }
50
+ export function mergeManifest(base, patch) {
51
+ return mergeNode(base ?? {}, patch ?? {}, 'manifest');
52
+ }
53
+ export function addDataSource(m, name, uri, type = 'OData', odataVersion) {
54
+ const ds = { uri, type };
55
+ if (odataVersion)
56
+ ds.settings = { odataVersion };
57
+ return mergeManifest(m, { 'sap.app': { dataSources: { [name]: ds } } });
58
+ }
59
+ export function addModel(m, name, def) {
60
+ return mergeManifest(m, { 'sap.ui5': { models: { [name]: def } } });
61
+ }
62
+ export function addTarget(m, name, viewName, viewLevel = 1) {
63
+ return mergeManifest(m, { 'sap.ui5': { routing: { targets: { [name]: { viewName, viewLevel, viewType: 'XML' } } } } });
64
+ }
65
+ export function addRoute(m, name, pattern, target) {
66
+ return mergeManifest(m, { 'sap.ui5': { routing: { routes: [{ name, pattern, target: [target] }] } } });
67
+ }
68
+ export function addLib(m, lib) {
69
+ return mergeManifest(m, { 'sap.ui5': { dependencies: { libs: { [lib]: {} } } } });
70
+ }
@@ -0,0 +1,77 @@
1
+ // cspeach-cli/src/tools/fiori/render.ts
2
+ import { render as ejsRender } from 'ejs';
3
+ export class MissingParamError extends Error {
4
+ param;
5
+ constructor(param, entry) {
6
+ super(`Catalog entry "${entry}" requires param "${param}"`);
7
+ this.param = param;
8
+ this.name = 'MissingParamError';
9
+ }
10
+ }
11
+ /**
12
+ * Raised when a param value fails its declared `pattern`. This is a SECURITY
13
+ * boundary for any param that reaches a file path (e.g. `ns`): a value like
14
+ * `../../../x` must fail here, loudly, before render — never silently produce a
15
+ * traversal path. Keep the message human-actionable.
16
+ */
17
+ export class InvalidParamError extends Error {
18
+ param;
19
+ constructor(param, entry, value, hint) {
20
+ super(`Catalog entry "${entry}" param "${param}" is invalid: ${hint} ` +
21
+ `(got ${JSON.stringify(String(value))}).`);
22
+ this.param = param;
23
+ this.name = 'InvalidParamError';
24
+ }
25
+ }
26
+ const ejs = (tpl, data) => ejsRender(tpl, data, { rmWhitespace: false });
27
+ /**
28
+ * Render manifestPatch with JSON-safe per-placeholder substitution.
29
+ *
30
+ * Works by serialising the patch to JSON and replacing each `<%- name %>`
31
+ * placeholder with a JSON-escaped string value (the surrounding quotes from
32
+ * JSON.stringify are stripped because the placeholder already sits inside a
33
+ * JSON string literal). Only simple `<%- name %>` (raw output) substitutions
34
+ * are supported; any remaining `<%` tag after substitution triggers a clear
35
+ * error so callers are not silently given a partially-rendered patch.
36
+ */
37
+ function renderManifestPatch(patch, params, entryName) {
38
+ const json = JSON.stringify(patch).replace(/<%-\s*(\w+)\s*%>/g, (_m, name) => {
39
+ const val = params[name];
40
+ // JSON.stringify yields a quoted, fully-escaped string literal; strip the
41
+ // surrounding quotes because the placeholder already sits inside a JSON string.
42
+ return JSON.stringify(val === undefined ? '' : String(val)).slice(1, -1);
43
+ });
44
+ if (json.includes('<%')) {
45
+ throw new Error(`Catalog entry "${entryName}" manifestPatch uses an unsupported EJS expression; ` +
46
+ `only simple "<%- name %>" substitutions are allowed in manifestPatch.`);
47
+ }
48
+ return JSON.parse(json);
49
+ }
50
+ /** Render a catalog entry's EJS files/i18n/manifest with resolved params. */
51
+ export function renderEntry(entry, params) {
52
+ for (const [name, def] of Object.entries(entry.params)) {
53
+ const value = params[name];
54
+ if (def.required && (value === undefined || value === null))
55
+ throw new MissingParamError(name, entry.name);
56
+ // Enforce declared pattern on any provided value. This is the early,
57
+ // catalog-driven half of the defense-in-depth: a path-bearing param (ns)
58
+ // declares a strict identifier pattern so a `../`-traversal is rejected
59
+ // here, before it can ever reach `join(appDir, renderedPath)`.
60
+ if (def.pattern !== undefined && value !== undefined && value !== null) {
61
+ const re = new RegExp(`^(?:${def.pattern})$`);
62
+ if (typeof value !== 'string' || !re.test(value)) {
63
+ throw new InvalidParamError(name, entry.name, value, `must be a simple identifier matching /${def.pattern}/ ` +
64
+ `(no dots, slashes, or path separators)`);
65
+ }
66
+ }
67
+ }
68
+ const files = entry.files.map((f) => ({
69
+ path: ejs(f.path, params),
70
+ content: ejs(f.content, params),
71
+ }));
72
+ const i18nKeys = {};
73
+ for (const [k, v] of Object.entries(entry.i18nKeys))
74
+ i18nKeys[ejs(k, params)] = ejs(v, params);
75
+ const manifestPatch = renderManifestPatch(entry.manifestPatch, params, entry.name);
76
+ return { files, manifestPatch, i18nKeys, libraries: entry.libraries };
77
+ }