@cspeach/cli 1.1.23 → 1.1.25
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 +124 -0
- package/dist/classifier/client.js +27 -2
- package/dist/commands/plan-continue.js +11 -1
- package/dist/commands/plan-resume.js +38 -1
- package/dist/lane/app-chain.js +297 -0
- package/dist/lane/app-flow.js +55 -0
- package/dist/lane/card.js +148 -0
- package/dist/lane/chain-token.js +59 -0
- package/dist/lane/inputs.js +459 -0
- package/dist/lane/paths.js +21 -0
- package/dist/lane/resolve.js +109 -0
- package/dist/lane/roots.js +113 -0
- package/dist/lane/signals.js +80 -0
- package/dist/lane/store.js +232 -0
- package/dist/lane/types.js +1 -0
- package/dist/one-shot.js +1 -0
- package/dist/projects/detail-path.js +117 -0
- package/dist/projects/extract-plan.js +46 -6
- package/dist/projects/merge-cca.js +19 -0
- package/dist/projects/merge-upgrade.js +19 -1
- package/dist/projects/plan-lane.js +214 -0
- package/dist/projects/plan-schema.js +10 -0
- package/dist/projects/promote-command.js +8 -2
- package/dist/projects/promote.js +7 -1
- package/dist/projects/save-command.js +71 -25
- package/dist/projects/workspace.js +10 -0
- package/dist/register/records.js +7 -2
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl/resume-standalone.js +3 -0
- package/dist/repl.js +23 -2
- package/dist/skill-catalog.js +14 -6
- package/dist/skills/bundled-skills.js +61 -54
- 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/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 +53 -14
- package/dist/tools/lane/app-lane-resolve.js +329 -0
- package/dist/tools/local-build.js +4 -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/tool-loading.js +2 -0
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ import { getEntry } from './catalog/index.js';
|
|
|
5
5
|
import { renderEntry } from './render.js';
|
|
6
6
|
import { mergeManifest } from './manifest.js';
|
|
7
7
|
import { appendI18n } from './i18n.js';
|
|
8
|
-
import { resolveSafePath } from '../_filesystem-shared.js';
|
|
8
|
+
import { resolveSafePath, assertWritableSync } from '../_filesystem-shared.js';
|
|
9
9
|
/**
|
|
10
10
|
* Render a catalog entry and apply it into an existing scaffolded app on disk.
|
|
11
11
|
*
|
|
@@ -15,35 +15,44 @@ import { resolveSafePath } from '../_filesystem-shared.js';
|
|
|
15
15
|
* CUSTOMER params (e.g. `webapp/ext/fragment/<%- ns %>Chart.fragment.xml`), so a
|
|
16
16
|
* hostile param could otherwise carry `..` and escape `appDir`. Every path this
|
|
17
17
|
* function writes — rendered files, manifest.json, and the i18n file (whose
|
|
18
|
-
* relative location is itself read from on-disk manifest data) —
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* relative location is itself read from on-disk manifest data) — goes through
|
|
19
|
+
* `resolveSafePath(opts.appDir, rel)` (string containment) and then
|
|
20
|
+
* `assertWritableSync` (real path after links / short names must stay inside
|
|
21
|
+
* appDir and must not be a protected path such as a nested `.git`). ALL
|
|
22
|
+
* targets are checked BEFORE the first write, so a refusal writes nothing.
|
|
23
|
+
* The catalog-side `ns` pattern (render.ts) is the second, earlier layer.
|
|
23
24
|
*/
|
|
24
25
|
export function applyEntry(opts) {
|
|
25
26
|
const rendered = renderEntry(getEntry(opts.entryName), opts.params);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const abs = resolveSafePath(opts.appDir,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
const denyRoot = opts.denyRoot ?? opts.appDir;
|
|
28
|
+
const target = (rel) => {
|
|
29
|
+
const abs = resolveSafePath(opts.appDir, rel);
|
|
30
|
+
assertWritableSync(abs, opts.appDir, denyRoot);
|
|
31
|
+
return abs;
|
|
32
|
+
};
|
|
33
|
+
// 0. Resolve + verify every write target first.
|
|
34
|
+
const files = rendered.files.map((f) => ({ abs: target(f.path), content: f.content }));
|
|
35
|
+
// manifest — ALWAYS re-read from disk (I2). Fixed relative path, still contained.
|
|
36
|
+
const manifestPath = target('webapp/manifest.json');
|
|
35
37
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
36
38
|
let merged = mergeManifest(manifest, rendered.manifestPatch);
|
|
37
39
|
for (const lib of rendered.libraries) {
|
|
38
40
|
merged = mergeManifest(merged, { 'sap.ui5': { dependencies: { libs: { [lib]: {} } } } });
|
|
39
41
|
}
|
|
40
|
-
|
|
41
|
-
// 3. append i18n — resolve path from sap.app.i18n (M1). i18nRel is on-disk
|
|
42
|
-
// data, not a render param, but it still drives a write target, so contain it.
|
|
42
|
+
// i18n — path from sap.app.i18n (M1): on-disk data that drives a write target.
|
|
43
43
|
const i18nRel = merged['sap.app']?.i18n ?? 'i18n/i18n.properties';
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
44
|
+
const hasI18n = Object.keys(rendered.i18nKeys).length > 0;
|
|
45
|
+
const i18nPath = hasI18n ? target(`webapp/${i18nRel}`) : null;
|
|
46
|
+
// 1. write files.
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
mkdirSync(dirname(f.abs), { recursive: true });
|
|
49
|
+
writeFileSync(f.abs, f.content, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
// 2. merge manifest.
|
|
52
|
+
writeFileSync(manifestPath, JSON.stringify(merged, null, 2), 'utf8');
|
|
53
|
+
// 3. append i18n.
|
|
54
|
+
if (i18nPath) {
|
|
55
|
+
const existing = existsSync(i18nPath) ? readFileSync(i18nPath, 'utf8') : '';
|
|
47
56
|
mkdirSync(dirname(i18nPath), { recursive: true });
|
|
48
57
|
writeFileSync(i18nPath, appendI18n(existing, rendered.i18nKeys), 'utf8');
|
|
49
58
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/commit-gate.ts
|
|
2
|
+
/**
|
|
3
|
+
* Commit-time containment gate for every mem-fs editor the Fiori engine
|
|
4
|
+
* flushes (scaffoldFreestyle, scaffoldFioriElements, feExtend).
|
|
5
|
+
*
|
|
6
|
+
* The @sap-ux writers build paths from caller-supplied config (`folder`,
|
|
7
|
+
* `name`, `localAnnotationsName`, event-handler file names, …). Validating
|
|
8
|
+
* each known param is the first layer; THIS is the authoritative one: before
|
|
9
|
+
* anything reaches disk, every pending path in the editor's store must land —
|
|
10
|
+
* after following links and expanding Windows short names — inside the app
|
|
11
|
+
* folder, and must not be a protected path (`.git` at any depth, `.cspeach`,
|
|
12
|
+
* `.env*`, the CLI home). One bad path refuses the WHOLE commit, so nothing
|
|
13
|
+
* is half-written.
|
|
14
|
+
*/
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { assertWritable, PathOutsideRootError, ProtectedPathError } from '../_filesystem-shared.js';
|
|
17
|
+
/** Thrown when a pending write would leave the app folder or hit a protected path. */
|
|
18
|
+
export class UnsafeCommitError extends Error {
|
|
19
|
+
constructor(detail) {
|
|
20
|
+
super(`refusing to write the app: ${detail}. Nothing was written.`);
|
|
21
|
+
this.name = 'UnsafeCommitError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Every path the editor would write or delete on commit. */
|
|
25
|
+
function pendingPaths(editor) {
|
|
26
|
+
const store = editor.store;
|
|
27
|
+
if (!store || typeof store.each !== 'function') {
|
|
28
|
+
// Without a store we cannot see what commit would touch — fail closed.
|
|
29
|
+
throw new UnsafeCommitError('the pending file list could not be read');
|
|
30
|
+
}
|
|
31
|
+
const out = [];
|
|
32
|
+
store.each((f) => {
|
|
33
|
+
if (f && (f.state === 'modified' || f.state === 'deleted'))
|
|
34
|
+
out.push(f.path);
|
|
35
|
+
});
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Verify every pending path, then commit. `appDir` is the containment root;
|
|
40
|
+
* `denyRoot` (default appDir) is the project root the denylist is judged
|
|
41
|
+
* against — tools pass ctx.cwd so a protected folder ABOVE the app is caught.
|
|
42
|
+
*/
|
|
43
|
+
export async function commitContained(editor, appDir, denyRoot = appDir) {
|
|
44
|
+
for (const p of pendingPaths(editor)) {
|
|
45
|
+
try {
|
|
46
|
+
await assertWritable(path.resolve(p), appDir, denyRoot);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const shown = path.relative(path.resolve(denyRoot), path.resolve(p)).replace(/\\/g, '/');
|
|
50
|
+
if (err instanceof PathOutsideRootError)
|
|
51
|
+
throw new UnsafeCommitError(`"${shown}" is outside the app folder`);
|
|
52
|
+
if (err instanceof ProtectedPathError)
|
|
53
|
+
throw new UnsafeCommitError(`"${shown}" is a protected path`);
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
await new Promise((res, rej) => editor.commit((e) => (e ? rej(e) : res())));
|
|
58
|
+
}
|
|
@@ -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
|
}
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
import { promises as fs } from 'node:fs';
|
|
48
48
|
import * as path from 'node:path';
|
|
49
49
|
import { registerTool } from '../index.js';
|
|
50
|
-
import { resolveSafePath, PathOutsideRootError } from '../_filesystem-shared.js';
|
|
50
|
+
import { resolveSafePath, realPathForWrite, isDenylistedPath, PathOutsideRootError, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
|
|
51
51
|
import { scaffoldFreestyle } from './scaffold.js';
|
|
52
52
|
import { decodeHtmlEscapes } from './html-escapes.js';
|
|
53
53
|
import { applyEntry } from './apply.js';
|
|
@@ -80,16 +80,53 @@ const SAMPLE_INDEX = sampleIndexJson;
|
|
|
80
80
|
// model can cite the source (licensing constraint A-M1). Derived from the
|
|
81
81
|
// corpus NOTICE: OpenUI5 sources, adapted, under Apache-2.0.
|
|
82
82
|
const SAMPLE_ATTRIBUTION = 'Adapted from OpenUI5 (github.com/SAP/openui5) sample sources, licensed under Apache-2.0.';
|
|
83
|
-
/**
|
|
84
|
-
function
|
|
83
|
+
/** Shared denylist refusal text for a protected app / base folder. */
|
|
84
|
+
function protectedDirError(p, ctx) {
|
|
85
|
+
const verdict = isDenylistedPath(p, ctx.cwd);
|
|
86
|
+
return verdict.blocked
|
|
87
|
+
? `"${verdict.relFromRoot}" is a protected folder (denylist: ${DENYLIST_DESCRIPTION})`
|
|
88
|
+
: null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Sync form of safeDir for pure read helpers: containment + the shared
|
|
92
|
+
* denylist on the spelled path (which already covers NTFS stream suffixes,
|
|
93
|
+
* trailing dots/spaces and 8.3 short names). No link resolution.
|
|
94
|
+
*/
|
|
95
|
+
function safeDirSync(ctx, userPath) {
|
|
96
|
+
let abs;
|
|
97
|
+
try {
|
|
98
|
+
abs = resolveSafePath(ctx.cwd, userPath);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (err instanceof PathOutsideRootError)
|
|
102
|
+
return { error: err.message };
|
|
103
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
104
|
+
}
|
|
105
|
+
const refused = protectedDirError(abs, ctx);
|
|
106
|
+
return refused ? { error: refused } : { abs };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a user-supplied dir inside the project root, mapping the escape error.
|
|
110
|
+
* The dir must also not be a protected folder (`.git`, `.cspeach`, …) or an
|
|
111
|
+
* alias of one — checked on the spelled path AND on its real path (links,
|
|
112
|
+
* Windows 8.3 short names), through the shared denylist. The returned `abs` is
|
|
113
|
+
* still the spelled path so result strings and registry keys stay unchanged.
|
|
114
|
+
*/
|
|
115
|
+
async function safeDir(ctx, userPath) {
|
|
116
|
+
const spelled = safeDirSync(ctx, userPath);
|
|
117
|
+
if ('error' in spelled)
|
|
118
|
+
return spelled;
|
|
119
|
+
let real;
|
|
85
120
|
try {
|
|
86
|
-
|
|
121
|
+
real = await realPathForWrite(spelled.abs, ctx.cwd);
|
|
87
122
|
}
|
|
88
123
|
catch (err) {
|
|
89
124
|
if (err instanceof PathOutsideRootError)
|
|
90
125
|
return { error: err.message };
|
|
91
126
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
92
127
|
}
|
|
128
|
+
const refused = protectedDirError(real, ctx);
|
|
129
|
+
return refused ? { error: refused } : spelled;
|
|
93
130
|
}
|
|
94
131
|
/** Relative-to-cwd, forward-slash path for stable, readable result strings. */
|
|
95
132
|
function rel(ctx, abs) {
|
|
@@ -127,11 +164,12 @@ export async function fioriScaffoldHandler(args, ctx) {
|
|
|
127
164
|
return { content: 'error: basePath is required', is_error: true };
|
|
128
165
|
if (!args.appId)
|
|
129
166
|
return { content: 'error: appId is required', is_error: true };
|
|
130
|
-
const resolved = safeDir(ctx, args.basePath);
|
|
167
|
+
const resolved = await safeDir(ctx, args.basePath);
|
|
131
168
|
if ('error' in resolved)
|
|
132
169
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
133
170
|
try {
|
|
134
171
|
await scaffoldFreestyle({
|
|
172
|
+
denyRoot: ctx.cwd,
|
|
135
173
|
basePath: resolved.abs,
|
|
136
174
|
appId: args.appId,
|
|
137
175
|
appTitle: decodeTitle(args.appTitle ?? args.appId),
|
|
@@ -160,7 +198,7 @@ export async function fioriApplyHandler(args, ctx) {
|
|
|
160
198
|
return { content: 'error: appDir is required', is_error: true };
|
|
161
199
|
if (!args.entry)
|
|
162
200
|
return { content: 'error: entry is required', is_error: true };
|
|
163
|
-
const resolved = safeDir(ctx, args.appDir);
|
|
201
|
+
const resolved = await safeDir(ctx, args.appDir);
|
|
164
202
|
if ('error' in resolved)
|
|
165
203
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
166
204
|
// Params may arrive as a parsed object (preferred) or a JSON string (mirrors
|
|
@@ -181,7 +219,7 @@ export async function fioriApplyHandler(args, ctx) {
|
|
|
181
219
|
// applyEntry preserves the engine's loud-on-conflict manifest merge — a
|
|
182
220
|
// conflicting manifest patch throws here and surfaces as is_error, never a
|
|
183
221
|
// silent last-wins overwrite.
|
|
184
|
-
applyEntry({ appDir: resolved.abs, entryName: args.entry, params });
|
|
222
|
+
applyEntry({ appDir: resolved.abs, entryName: args.entry, params, denyRoot: ctx.cwd });
|
|
185
223
|
}
|
|
186
224
|
catch (err) {
|
|
187
225
|
return { content: `error: apply failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
@@ -210,7 +248,7 @@ export async function fioriDeployConfigHandler(args, ctx) {
|
|
|
210
248
|
return { content: 'error: name (BSP app name) is required', is_error: true };
|
|
211
249
|
if (!args.package)
|
|
212
250
|
return { content: 'error: package is required', is_error: true };
|
|
213
|
-
const resolved = safeDir(ctx, args.appDir);
|
|
251
|
+
const resolved = await safeDir(ctx, args.appDir);
|
|
214
252
|
if ('error' in resolved)
|
|
215
253
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
216
254
|
try {
|
|
@@ -248,11 +286,12 @@ export async function fioriScaffoldFeHandler(args, ctx) {
|
|
|
248
286
|
return { content: 'error: localAnnotations requires both technicalName and xml', is_error: true };
|
|
249
287
|
}
|
|
250
288
|
// metadata is OPTIONAL (the FE writer does not need it — spike §4); no $metadata fetch.
|
|
251
|
-
const resolved = safeDir(ctx, args.basePath);
|
|
289
|
+
const resolved = await safeDir(ctx, args.basePath);
|
|
252
290
|
if ('error' in resolved)
|
|
253
291
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
254
292
|
try {
|
|
255
293
|
await scaffoldFioriElements({
|
|
294
|
+
denyRoot: ctx.cwd,
|
|
256
295
|
basePath: resolved.abs,
|
|
257
296
|
appId: args.appId,
|
|
258
297
|
appTitle: decodeTitle(args.appTitle ?? args.appId),
|
|
@@ -284,12 +323,12 @@ export async function feExtendHandler(args, ctx) {
|
|
|
284
323
|
if (!FE_EXTEND_OPS.includes(args.op)) {
|
|
285
324
|
return { content: `error: unknown op "${args.op}" — expected one of ${FE_EXTEND_OPS.join(', ')}`, is_error: true };
|
|
286
325
|
}
|
|
287
|
-
const resolved = safeDir(ctx, args.basePath);
|
|
326
|
+
const resolved = await safeDir(ctx, args.basePath);
|
|
288
327
|
if ('error' in resolved)
|
|
289
328
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
290
329
|
const before = new Set(await listFilesUnder(ctx, resolved.abs));
|
|
291
330
|
try {
|
|
292
|
-
await feExtend({ basePath: resolved.abs, op: args.op, params: args.params ?? {} });
|
|
331
|
+
await feExtend({ basePath: resolved.abs, op: args.op, params: args.params ?? {}, denyRoot: ctx.cwd });
|
|
293
332
|
}
|
|
294
333
|
catch (err) {
|
|
295
334
|
// V4-only scope limit: fe-fpm-writer refuses non-V4-FE apps (no
|
|
@@ -369,7 +408,7 @@ export async function fioriPreviewHandler(args, ctx, deps) {
|
|
|
369
408
|
}
|
|
370
409
|
if (!args.appDir)
|
|
371
410
|
return { content: 'error: appDir is required for action "start"', is_error: true };
|
|
372
|
-
const resolved = safeDir(ctx, args.appDir);
|
|
411
|
+
const resolved = await safeDir(ctx, args.appDir);
|
|
373
412
|
if ('error' in resolved)
|
|
374
413
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
375
414
|
const result = await startPreview({
|
|
@@ -442,7 +481,7 @@ export function resolvePreviewAppUrl(args) {
|
|
|
442
481
|
* without launching a browser; the handler only orchestrates.
|
|
443
482
|
*/
|
|
444
483
|
export function resolveFreestyleSmokeSpec(args, ctx) {
|
|
445
|
-
const resolved =
|
|
484
|
+
const resolved = safeDirSync(ctx, args.appDir);
|
|
446
485
|
if ('error' in resolved)
|
|
447
486
|
return { error: resolved.error };
|
|
448
487
|
let derived;
|
|
@@ -1233,7 +1272,7 @@ registerTool({
|
|
|
1233
1272
|
try {
|
|
1234
1273
|
if (!args.appDir)
|
|
1235
1274
|
return handover;
|
|
1236
|
-
const resolved = safeDir(ctx, args.appDir);
|
|
1275
|
+
const resolved = await safeDir(ctx, args.appDir);
|
|
1237
1276
|
if ('error' in resolved)
|
|
1238
1277
|
return handover;
|
|
1239
1278
|
const startScript = await readStartScript(resolved.abs);
|