@cspeach/cli 1.1.24 → 1.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +20 -8
- package/dist/agent/tool-dispatch.js +6 -0
- package/dist/auth/auth-file.js +173 -12
- package/dist/classifier/client.js +55 -30
- package/dist/cli.js +11 -0
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +5 -1
- package/dist/commands/cost.js +24 -22
- package/dist/commands/deploy-card.js +135 -0
- package/dist/commands/login.js +31 -13
- package/dist/commands/plan-chain.js +31 -0
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +49 -2
- package/dist/cost/cost-log.js +76 -4
- package/dist/cost/credits-wire.js +9 -0
- package/dist/cost/credits.js +24 -6
- package/dist/cost/router-spend.js +24 -0
- package/dist/cost/session-cost.js +16 -2
- package/dist/models/server-config.js +21 -1
- package/dist/one-shot.js +12 -2
- package/dist/projects/detail-path.js +117 -0
- package/dist/projects/merge-cca.js +19 -0
- package/dist/projects/merge-upgrade.js +19 -1
- package/dist/projects/plan-lane.js +47 -0
- package/dist/projects/plan-run.js +8 -0
- package/dist/projects/plan-schema.js +2 -0
- package/dist/projects/promote.js +7 -1
- package/dist/projects/save-command.js +3 -2
- package/dist/register/records.js +7 -2
- package/dist/renderer/footer-line.js +10 -4
- package/dist/renderer/status-footer.js +5 -4
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +41 -8
- package/dist/router/byok-classifier.js +159 -0
- package/dist/router/classifier.js +130 -6
- package/dist/router/router-core.js +307 -0
- package/dist/router/router-prompt.js +121 -0
- package/dist/router/routing-failure.js +65 -3
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/_filesystem-shared.js +363 -33
- package/dist/tools/cap/metadata-import.js +10 -4
- package/dist/tools/filesystem/file-write.js +12 -39
- package/dist/tools/filesystem/glob.js +25 -2
- package/dist/tools/filesystem/grep.js +25 -2
- package/dist/tools/fiori/apply.js +30 -21
- package/dist/tools/fiori/commit-gate.js +58 -0
- package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
- package/dist/tools/fiori/deploy/run.js +608 -0
- package/dist/tools/fiori/deploy/scope.js +8 -0
- package/dist/tools/fiori/deploy/yaml-read.js +236 -0
- package/dist/tools/fiori/deploy-config.js +24 -6
- package/dist/tools/fiori/fe-extend.js +6 -1
- package/dist/tools/fiori/fe-scaffold.js +6 -1
- package/dist/tools/fiori/path-params.js +78 -0
- package/dist/tools/fiori/scaffold.js +2 -1
- package/dist/tools/fiori/tools.js +170 -19
- package/dist/tools/local-build.js +3 -0
- package/dist/tools/project/_merge-shared.js +36 -1
- package/dist/tools/project/cca_merge.js +4 -0
- package/dist/tools/project/upgrade_merge_progress.js +21 -10
- package/dist/tools/subagent/read_agent.js +4 -2
- package/dist/tools/tool-loading.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/deploy/yaml-read.ts
|
|
2
|
+
/**
|
|
3
|
+
* fiori_deploy — read the deploy-to-abap task out of an app's ui5-deploy.yaml.
|
|
4
|
+
*
|
|
5
|
+
* The file sits in a folder the model can write, and what it says decides
|
|
6
|
+
* where the SAP logon is sent, so it is read STRICTLY: a small block-style
|
|
7
|
+
* YAML reader (the CLI ships no YAML library and the ruling forbids adding
|
|
8
|
+
* one — same stance as preview/app-guard.ts) that FAILS CLOSED on anything it
|
|
9
|
+
* cannot read with certainty: flow style, anchors / aliases, tags, block
|
|
10
|
+
* scalars, tabs, escapes in double quotes, duplicate keys, document markers.
|
|
11
|
+
*
|
|
12
|
+
* It reads exactly what the fiori engine writes (deploy-config.ts) and what
|
|
13
|
+
* `fiori add deploy-config` writes for an on-premise ABAP target. Anything a
|
|
14
|
+
* BTP / destination / cloud target needs is refused (out of scope, and a
|
|
15
|
+
* destination would send the logon somewhere this tool cannot check).
|
|
16
|
+
*/
|
|
17
|
+
class YamlUncertain extends Error {
|
|
18
|
+
}
|
|
19
|
+
function stripComment(text) {
|
|
20
|
+
// A comment starts at " #" outside quotes (or at the line start).
|
|
21
|
+
let quote = null;
|
|
22
|
+
for (let i = 0; i < text.length; i++) {
|
|
23
|
+
const c = text[i];
|
|
24
|
+
if (quote) {
|
|
25
|
+
if (c === quote)
|
|
26
|
+
quote = null;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (c === '"' || c === "'")
|
|
30
|
+
quote = c;
|
|
31
|
+
else if (c === '#' && (i === 0 || /\s/.test(text[i - 1])))
|
|
32
|
+
return text.slice(0, i).trimEnd();
|
|
33
|
+
}
|
|
34
|
+
return text.trimEnd();
|
|
35
|
+
}
|
|
36
|
+
function toLines(src) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const raw = src.replace(/^/, '').split(/\r?\n/);
|
|
39
|
+
raw.forEach((r, i) => {
|
|
40
|
+
if (/^\s*(#.*)?$/.test(r))
|
|
41
|
+
return;
|
|
42
|
+
if (/^[ ]*\t/.test(r))
|
|
43
|
+
throw new YamlUncertain(`line ${i + 1} is indented with a tab`);
|
|
44
|
+
if (/^(---|\.\.\.)(\s|$)/.test(r))
|
|
45
|
+
throw new YamlUncertain(`line ${i + 1} is a YAML document marker`);
|
|
46
|
+
if (/^\s*%/.test(r))
|
|
47
|
+
throw new YamlUncertain(`line ${i + 1} is a YAML directive`);
|
|
48
|
+
const indent = r.length - r.trimStart().length;
|
|
49
|
+
const text = stripComment(r.slice(indent));
|
|
50
|
+
if (text.length === 0)
|
|
51
|
+
return;
|
|
52
|
+
out.push({ indent, text, no: i + 1 });
|
|
53
|
+
});
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
function scalar(raw, no) {
|
|
57
|
+
const v = raw.trim();
|
|
58
|
+
if (v.startsWith('"')) {
|
|
59
|
+
if (!v.endsWith('"') || v.length < 2)
|
|
60
|
+
throw new YamlUncertain(`line ${no}: unterminated double-quoted value`);
|
|
61
|
+
const body = v.slice(1, -1);
|
|
62
|
+
if (body.includes('\\') || body.includes('"'))
|
|
63
|
+
throw new YamlUncertain(`line ${no}: escapes in a double-quoted value`);
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
if (v.startsWith("'")) {
|
|
67
|
+
if (!v.endsWith("'") || v.length < 2)
|
|
68
|
+
throw new YamlUncertain(`line ${no}: unterminated single-quoted value`);
|
|
69
|
+
const body = v.slice(1, -1);
|
|
70
|
+
if (body.replace(/''/g, '').includes("'"))
|
|
71
|
+
throw new YamlUncertain(`line ${no}: a stray quote`);
|
|
72
|
+
return body.replace(/''/g, "'");
|
|
73
|
+
}
|
|
74
|
+
if (/^[{[&*!|>%@`]/.test(v))
|
|
75
|
+
throw new YamlUncertain(`line ${no}: the value is not a plain value (flow style, anchor, alias, tag or block scalar)`);
|
|
76
|
+
return v;
|
|
77
|
+
}
|
|
78
|
+
const KEY_RE = /^([A-Za-z0-9_.\-/$]+)\s*:(?:\s+(.*))?$/;
|
|
79
|
+
function parseNode(lines, i, indent) {
|
|
80
|
+
const line = lines[i];
|
|
81
|
+
if (!line || line.indent < indent)
|
|
82
|
+
return [null, i];
|
|
83
|
+
if (line.text === '-' || line.text.startsWith('- '))
|
|
84
|
+
return parseSeq(lines, i, line.indent);
|
|
85
|
+
return parseMap(lines, i, line.indent);
|
|
86
|
+
}
|
|
87
|
+
function parseSeq(lines, i, indent) {
|
|
88
|
+
const items = [];
|
|
89
|
+
while (i < lines.length && lines[i].indent === indent && (lines[i].text === '-' || lines[i].text.startsWith('- '))) {
|
|
90
|
+
const line = lines[i];
|
|
91
|
+
const rest = line.text.slice(1).trimStart();
|
|
92
|
+
if (rest.length === 0) {
|
|
93
|
+
const next = lines[i + 1];
|
|
94
|
+
if (next && next.indent > indent) {
|
|
95
|
+
const [v, ni] = parseNode(lines, i + 1, next.indent);
|
|
96
|
+
items.push(v);
|
|
97
|
+
i = ni;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
items.push(null);
|
|
101
|
+
i += 1;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const restIndent = indent + (line.text.length - rest.length);
|
|
106
|
+
if (KEY_RE.test(rest)) {
|
|
107
|
+
lines[i] = { indent: restIndent, text: rest, no: line.no };
|
|
108
|
+
const [v, ni] = parseMap(lines, i, restIndent);
|
|
109
|
+
items.push(v);
|
|
110
|
+
i = ni;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
items.push(scalar(rest, line.no));
|
|
114
|
+
i += 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (i < lines.length && lines[i].indent > indent) {
|
|
118
|
+
throw new YamlUncertain(`line ${lines[i].no} is indented under nothing`);
|
|
119
|
+
}
|
|
120
|
+
return [items, i];
|
|
121
|
+
}
|
|
122
|
+
function parseMap(lines, i, indent) {
|
|
123
|
+
const obj = {};
|
|
124
|
+
while (i < lines.length && lines[i].indent === indent) {
|
|
125
|
+
const line = lines[i];
|
|
126
|
+
if (line.text === '-' || line.text.startsWith('- '))
|
|
127
|
+
break;
|
|
128
|
+
const m = KEY_RE.exec(line.text);
|
|
129
|
+
if (!m)
|
|
130
|
+
throw new YamlUncertain(`line ${line.no} is not a plain "key: value" line`);
|
|
131
|
+
const key = m[1];
|
|
132
|
+
if (Object.prototype.hasOwnProperty.call(obj, key))
|
|
133
|
+
throw new YamlUncertain(`line ${line.no}: duplicate key "${key}"`);
|
|
134
|
+
const value = m[2];
|
|
135
|
+
if (value !== undefined && value.length > 0) {
|
|
136
|
+
obj[key] = scalar(value, line.no);
|
|
137
|
+
i += 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const next = lines[i + 1];
|
|
141
|
+
if (next && next.indent > indent) {
|
|
142
|
+
const [v, ni] = parseNode(lines, i + 1, next.indent);
|
|
143
|
+
obj[key] = v;
|
|
144
|
+
i = ni;
|
|
145
|
+
}
|
|
146
|
+
else if (next && next.indent === indent && (next.text === '-' || next.text.startsWith('- '))) {
|
|
147
|
+
const [v, ni] = parseSeq(lines, i + 1, indent);
|
|
148
|
+
obj[key] = v;
|
|
149
|
+
i = ni;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
obj[key] = null;
|
|
153
|
+
i += 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (i < lines.length && lines[i].indent > indent) {
|
|
157
|
+
throw new YamlUncertain(`line ${lines[i].no} is indented under nothing`);
|
|
158
|
+
}
|
|
159
|
+
return [obj, i];
|
|
160
|
+
}
|
|
161
|
+
export function parseStrictYaml(src) {
|
|
162
|
+
const lines = toLines(src);
|
|
163
|
+
if (lines.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
const [node, i] = parseNode(lines, 0, lines[0].indent);
|
|
166
|
+
if (i < lines.length)
|
|
167
|
+
throw new YamlUncertain(`line ${lines[i].no} could not be read`);
|
|
168
|
+
return node;
|
|
169
|
+
}
|
|
170
|
+
const isMap = (n) => !!n && typeof n === 'object' && !Array.isArray(n);
|
|
171
|
+
const str = (n) => (typeof n === 'string' ? n : undefined);
|
|
172
|
+
/** The only keys allowed under target: — anything else (destination, scp, cloud, service, …) is refused. */
|
|
173
|
+
const TARGET_KEYS = new Set(['url', 'client']);
|
|
174
|
+
/** The deploy-to-abap configuration keys fiori_deploy accepts. */
|
|
175
|
+
const CONFIG_KEYS = new Set(['target', 'app', 'ignoreCertErrors', 'exclude']);
|
|
176
|
+
const APP_KEYS = new Set(['name', 'description', 'package', 'transport']);
|
|
177
|
+
export function readDeployYaml(src) {
|
|
178
|
+
let root;
|
|
179
|
+
try {
|
|
180
|
+
root = parseStrictYaml(src);
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
return { ok: false, error: `ui5-deploy.yaml could not be read with certainty — ${e instanceof Error ? e.message : String(e)}` };
|
|
184
|
+
}
|
|
185
|
+
const tasks = isMap(root) && isMap(root.builder) ? root.builder.customTasks : undefined;
|
|
186
|
+
const deployTasks = Array.isArray(tasks) ? tasks.filter((t) => isMap(t) && t.name === 'deploy-to-abap') : [];
|
|
187
|
+
if (deployTasks.length !== 1) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
error: deployTasks.length === 0
|
|
191
|
+
? 'ui5-deploy.yaml has no deploy-to-abap task — write it with fiori_deploy_config first'
|
|
192
|
+
: 'ui5-deploy.yaml has more than one deploy-to-abap task',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const cfg = deployTasks[0].configuration;
|
|
196
|
+
if (!isMap(cfg))
|
|
197
|
+
return { ok: false, error: 'the deploy-to-abap task has no configuration block' };
|
|
198
|
+
if (cfg.credentials !== undefined) {
|
|
199
|
+
return { ok: false, error: 'ui5-deploy.yaml carries a credentials block — remove it; CSPeach supplies the logon from its own store' };
|
|
200
|
+
}
|
|
201
|
+
// Review 2026-10-03: only known keys pass. Others change what is deployed or how —
|
|
202
|
+
// safeMode: false (no server-side 412 guard), archivePath/archiveUrl (another archive),
|
|
203
|
+
// strictSsl / ignoreCertError (TLS), keep, lrep, verbose (logs the config) …
|
|
204
|
+
for (const k of Object.keys(cfg)) {
|
|
205
|
+
if (!CONFIG_KEYS.has(k)) {
|
|
206
|
+
return { ok: false, error: `configuration.${k} is not supported by fiori_deploy (allowed: ${[...CONFIG_KEYS].join(', ')}) — remove it from ui5-deploy.yaml` };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const target = cfg.target;
|
|
210
|
+
const app = cfg.app;
|
|
211
|
+
if (!isMap(target) || !isMap(app))
|
|
212
|
+
return { ok: false, error: 'the deploy-to-abap configuration needs a target and an app block' };
|
|
213
|
+
for (const k of Object.keys(app)) {
|
|
214
|
+
if (!APP_KEYS.has(k)) {
|
|
215
|
+
return { ok: false, error: `app.${k} is not supported by fiori_deploy (allowed: ${[...APP_KEYS].join(', ')}) — remove it from ui5-deploy.yaml` };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const k of Object.keys(target)) {
|
|
219
|
+
if (!TARGET_KEYS.has(k)) {
|
|
220
|
+
return { ok: false, error: `target.${k} is not supported — fiori_deploy deploys only to the on-premise ABAP system of the SAP alias (url + client)` };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const url = str(target.url);
|
|
224
|
+
const client = str(target.client);
|
|
225
|
+
const name = str(app.name);
|
|
226
|
+
const pkg = str(app.package);
|
|
227
|
+
const transport = app.transport === null ? '' : str(app.transport);
|
|
228
|
+
if (!url || !client || !name || !pkg || transport === undefined) {
|
|
229
|
+
return { ok: false, error: 'ui5-deploy.yaml must name target.url, target.client, app.name, app.package and app.transport (empty for $TMP)' };
|
|
230
|
+
}
|
|
231
|
+
const cert = cfg.ignoreCertErrors;
|
|
232
|
+
if (cert !== undefined && cert !== 'true' && cert !== 'false') {
|
|
233
|
+
return { ok: false, error: 'ignoreCertErrors must be true or false' };
|
|
234
|
+
}
|
|
235
|
+
return { ok: true, config: { url, client, name, package: pkg, transport, ignoreCertErrors: cert === 'true' } };
|
|
236
|
+
}
|
|
@@ -9,22 +9,40 @@
|
|
|
9
9
|
// take the NAMES of environment variables at deploy time.
|
|
10
10
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The BSP name / package / transport rules, shared by the writer below and by
|
|
14
|
+
* `fiori_deploy` (which re-checks the yaml it is about to deploy — the file is
|
|
15
|
+
* in a folder the model can write, so it is never trusted on read).
|
|
16
|
+
* Returns the normalised (upper-cased) package and whether it is $TMP.
|
|
17
|
+
*/
|
|
18
|
+
export function validateDeployTarget(t) {
|
|
19
|
+
const appName = t.appName;
|
|
20
|
+
if (appName.length === 0) {
|
|
21
|
+
throw new Error('BSP app name is empty');
|
|
22
|
+
}
|
|
14
23
|
if (appName.length > 15) {
|
|
15
24
|
throw new Error(`BSP app name "${appName}" exceeds 15 characters (ABAP repository limit)`);
|
|
16
25
|
}
|
|
17
26
|
if (appName !== appName.toUpperCase()) {
|
|
18
27
|
throw new Error(`BSP app name "${appName}" must be uppercase`);
|
|
19
28
|
}
|
|
20
|
-
const pkg =
|
|
29
|
+
const pkg = t.package.toUpperCase();
|
|
21
30
|
if (!/^(\$TMP|[ZY][A-Z0-9_]*|\/[A-Z0-9_]+\/[A-Z0-9_]+)$/.test(pkg)) {
|
|
22
|
-
throw new Error(`Package "${
|
|
31
|
+
throw new Error(`Package "${t.package}" is not in the customer namespace (Z-/Y-prefixed, $TMP, or /namespace/pkg)`);
|
|
23
32
|
}
|
|
24
33
|
const isTmp = pkg === '$TMP';
|
|
25
|
-
if (
|
|
26
|
-
throw new Error(`
|
|
34
|
+
if (isTmp && t.transport.trim() !== '') {
|
|
35
|
+
throw new Error(`Package $TMP is local-only and takes no transport, but transport "${t.transport}" is set — ` +
|
|
36
|
+
'either remove the transport (local-only app) or use the real Z/Y package that transport belongs to');
|
|
37
|
+
}
|
|
38
|
+
if (!isTmp && !/^[A-Z0-9]{3}K[0-9]{6,}$/.test(t.transport)) {
|
|
39
|
+
throw new Error(`A valid workbench transport (e.g. S4HK903359) is required unless package is $TMP — got "${t.transport}"`);
|
|
27
40
|
}
|
|
41
|
+
return { package: pkg, isTmp };
|
|
42
|
+
}
|
|
43
|
+
export function writeDeployConfig(appDir, opts) {
|
|
44
|
+
const appName = opts.appName;
|
|
45
|
+
const { package: pkg, isTmp } = validateDeployTarget({ appName, package: opts.package, transport: opts.transport });
|
|
28
46
|
const pkgJsonPath = join(appDir, 'package.json');
|
|
29
47
|
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
30
48
|
const projectName = pkgJson.name ?? appName.toLowerCase();
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { create } from 'mem-fs';
|
|
18
18
|
import { create as createEditor } from 'mem-fs-editor';
|
|
19
|
+
import { commitContained } from './commit-gate.js';
|
|
20
|
+
import { assertFeExtendParams } from './path-params.js';
|
|
19
21
|
/** The four supported extension operations. Closed union → schema enum. */
|
|
20
22
|
export const FE_EXTEND_OPS = ['custom-column', 'custom-action', 'custom-section', 'controller-extension'];
|
|
21
23
|
/** Thrown when the target app is not a V4 Fiori Elements app (no sap.fe.templates). */
|
|
@@ -52,6 +54,9 @@ export function withActionHandler(params) {
|
|
|
52
54
|
return { ...params, settings, eventHandler };
|
|
53
55
|
}
|
|
54
56
|
export async function feExtend(opts) {
|
|
57
|
+
// Path-shaped params first (name / folder / handler fileName): no `..`, no
|
|
58
|
+
// absolute paths, no drive letters. The commit gate below is the backstop.
|
|
59
|
+
assertFeExtendParams(opts.params);
|
|
55
60
|
// Dynamic import: keep the heavy writer out of the cold-start path (mirrors
|
|
56
61
|
// the engine's lazy-load convention; only paid when the tool actually runs).
|
|
57
62
|
const fpm = await import('@sap-ux/fe-fpm-writer');
|
|
@@ -87,5 +92,5 @@ export async function feExtend(opts) {
|
|
|
87
92
|
throw new NotV4FeAppError();
|
|
88
93
|
throw err;
|
|
89
94
|
}
|
|
90
|
-
await
|
|
95
|
+
await commitContained(result, opts.basePath, opts.denyRoot ?? opts.basePath);
|
|
91
96
|
}
|
|
@@ -2,6 +2,8 @@ import { create } from 'mem-fs';
|
|
|
2
2
|
import { create as createEditor } from 'mem-fs-editor';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { PINNED_UI5_VERSION } from './ui5-version.js';
|
|
5
|
+
import { commitContained } from './commit-gate.js';
|
|
6
|
+
import { assertPlainName } from './path-params.js';
|
|
5
7
|
// The ONE pinned, CDN-hosted patch (ui5-version.ts) — never an x.y.0 guess.
|
|
6
8
|
const DEFAULT_UI5_VERSION = PINNED_UI5_VERSION;
|
|
7
9
|
/**
|
|
@@ -102,6 +104,9 @@ function loadListsOnOpen(fs, manifestPath, ui5Version) {
|
|
|
102
104
|
export async function scaffoldFioriElements(opts) {
|
|
103
105
|
// Loaded on use (fix round 3, item 11e): this writer's module graph alone
|
|
104
106
|
// cost ~2.8 s of every CLI start, Fiori work or not.
|
|
107
|
+
// technicalName becomes webapp/annotations/<name>.xml — a plain name only.
|
|
108
|
+
if (opts.localAnnotations)
|
|
109
|
+
assertPlainName('localAnnotations.technicalName', opts.localAnnotations.technicalName);
|
|
105
110
|
const { generate, TemplateType, OdataVersion } = await import('@sap-ux/fiori-elements-writer');
|
|
106
111
|
const ui5Version = opts.ui5Version ?? DEFAULT_UI5_VERSION;
|
|
107
112
|
const version = opts.service.version === '2.0' ? OdataVersion.v2 : OdataVersion.v4;
|
|
@@ -194,5 +199,5 @@ export async function scaffoldFioriElements(opts) {
|
|
|
194
199
|
if (opts.localAnnotations) {
|
|
195
200
|
fs.write(join(opts.basePath, 'webapp', 'annotations', `${opts.localAnnotations.technicalName}.xml`), opts.localAnnotations.xml);
|
|
196
201
|
}
|
|
197
|
-
await
|
|
202
|
+
await commitContained(fs, opts.basePath, opts.denyRoot ?? opts.basePath);
|
|
198
203
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/path-params.ts
|
|
2
|
+
/**
|
|
3
|
+
* Validation for caller-supplied values the @sap-ux writers turn into file
|
|
4
|
+
* paths (fe_extend `name` / `folder` / event-handler `fileName`, scaffold_fe
|
|
5
|
+
* `localAnnotations.technicalName`). First layer only — commit-gate.ts is the
|
|
6
|
+
* authoritative check on the final paths.
|
|
7
|
+
*/
|
|
8
|
+
import { isWindowsReservedName } from '../_filesystem-shared.js';
|
|
9
|
+
/** Thrown for a path-shaped param that could leave the app folder. */
|
|
10
|
+
export class UnsafePathParamError extends Error {
|
|
11
|
+
constructor(label, value, why) {
|
|
12
|
+
super(`${label} ${JSON.stringify(value)} is not allowed: ${why}`);
|
|
13
|
+
this.name = 'UnsafePathParamError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** A single name segment: no separators, no `..`, no `:`, no NUL, no trailing dot/space. */
|
|
17
|
+
function badSegment(seg) {
|
|
18
|
+
if (seg.length === 0)
|
|
19
|
+
return 'empty name';
|
|
20
|
+
if (seg === '.' || seg === '..' || seg.includes('..'))
|
|
21
|
+
return 'no ".." allowed';
|
|
22
|
+
if (seg.includes(':'))
|
|
23
|
+
return 'no ":" allowed';
|
|
24
|
+
if (seg.includes('\0'))
|
|
25
|
+
return 'no NUL allowed';
|
|
26
|
+
if (/[. ]$/.test(seg))
|
|
27
|
+
return 'must not end with a dot or space';
|
|
28
|
+
if (/[<>"|?*\u0000-\u001f]/.test(seg))
|
|
29
|
+
return 'contains a character not allowed in file names';
|
|
30
|
+
if (isWindowsReservedName(seg))
|
|
31
|
+
return 'is a reserved Windows device name (CON, PRN, AUX, NUL, COM0-9, LPT0-9)';
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/** A plain file/folder name: one segment, no separators. */
|
|
35
|
+
export function assertPlainName(label, value) {
|
|
36
|
+
if (typeof value !== 'string')
|
|
37
|
+
throw new UnsafePathParamError(label, value, 'must be a string');
|
|
38
|
+
if (value.includes('/') || value.includes('\\'))
|
|
39
|
+
throw new UnsafePathParamError(label, value, 'must be a plain name, no folders');
|
|
40
|
+
const why = badSegment(value);
|
|
41
|
+
if (why)
|
|
42
|
+
throw new UnsafePathParamError(label, value, why);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A relative sub-folder (`ext/sections`): '/'-separated plain segments.
|
|
46
|
+
* Not absolute, no backslashes, no drive letters, no `..` anywhere.
|
|
47
|
+
*/
|
|
48
|
+
export function assertRelativeSubfolder(label, value) {
|
|
49
|
+
if (typeof value !== 'string')
|
|
50
|
+
throw new UnsafePathParamError(label, value, 'must be a string');
|
|
51
|
+
if (value.includes('\\'))
|
|
52
|
+
throw new UnsafePathParamError(label, value, 'use "/" between folders');
|
|
53
|
+
if (value.startsWith('/'))
|
|
54
|
+
throw new UnsafePathParamError(label, value, 'must be relative to the app');
|
|
55
|
+
const segs = value.replace(/\/+$/, '').split('/');
|
|
56
|
+
for (const seg of segs) {
|
|
57
|
+
const why = badSegment(seg);
|
|
58
|
+
if (why)
|
|
59
|
+
throw new UnsafePathParamError(label, value, why);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* fe_extend params: `name` (plain), `folder` (relative sub-folder), and an
|
|
64
|
+
* event handler's `fileName` (top level or under settings) — a sub-folder
|
|
65
|
+
* path is allowed there too, still with no `..`.
|
|
66
|
+
*/
|
|
67
|
+
export function assertFeExtendParams(params) {
|
|
68
|
+
if (params.name !== undefined)
|
|
69
|
+
assertPlainName('name', params.name);
|
|
70
|
+
if (params.folder !== undefined)
|
|
71
|
+
assertRelativeSubfolder('folder', params.folder);
|
|
72
|
+
const handlers = [params.eventHandler, params.settings?.eventHandler];
|
|
73
|
+
for (const h of handlers) {
|
|
74
|
+
if (h && typeof h === 'object' && h.fileName !== undefined) {
|
|
75
|
+
assertRelativeSubfolder('eventHandler.fileName', h.fileName);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -4,6 +4,7 @@ import { create as createEditor } from 'mem-fs-editor';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { PINNED_UI5_VERSION } from './ui5-version.js';
|
|
6
6
|
import { decodePropertiesValues } from './html-escapes.js';
|
|
7
|
+
import { commitContained } from './commit-gate.js';
|
|
7
8
|
// Without an explicit ui5 config the writer falls back to minUI5Version 1.60,
|
|
8
9
|
// which fiori-tools-proxy resolves to the 1.71 LTS — whose bootstrap does NOT
|
|
9
10
|
// understand the hyphenated `data-sap-ui-on-init` attribute the writer itself
|
|
@@ -47,5 +48,5 @@ export async function scaffoldFreestyle(opts) {
|
|
|
47
48
|
const i18nPath = join(opts.basePath, 'webapp', 'i18n', 'i18n.properties');
|
|
48
49
|
if (fs.exists(i18nPath))
|
|
49
50
|
fs.write(i18nPath, decodePropertiesValues(fs.read(i18nPath)));
|
|
50
|
-
await
|
|
51
|
+
await commitContained(fs, opts.basePath, opts.denyRoot ?? opts.basePath);
|
|
51
52
|
}
|