@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.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- 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
|
+
}
|