@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.
Files changed (51) hide show
  1. package/dist/agent/loop.js +124 -0
  2. package/dist/classifier/client.js +27 -2
  3. package/dist/commands/plan-continue.js +11 -1
  4. package/dist/commands/plan-resume.js +38 -1
  5. package/dist/lane/app-chain.js +297 -0
  6. package/dist/lane/app-flow.js +55 -0
  7. package/dist/lane/card.js +148 -0
  8. package/dist/lane/chain-token.js +59 -0
  9. package/dist/lane/inputs.js +459 -0
  10. package/dist/lane/paths.js +21 -0
  11. package/dist/lane/resolve.js +109 -0
  12. package/dist/lane/roots.js +113 -0
  13. package/dist/lane/signals.js +80 -0
  14. package/dist/lane/store.js +232 -0
  15. package/dist/lane/types.js +1 -0
  16. package/dist/one-shot.js +1 -0
  17. package/dist/projects/detail-path.js +117 -0
  18. package/dist/projects/extract-plan.js +46 -6
  19. package/dist/projects/merge-cca.js +19 -0
  20. package/dist/projects/merge-upgrade.js +19 -1
  21. package/dist/projects/plan-lane.js +214 -0
  22. package/dist/projects/plan-schema.js +10 -0
  23. package/dist/projects/promote-command.js +8 -2
  24. package/dist/projects/promote.js +7 -1
  25. package/dist/projects/save-command.js +71 -25
  26. package/dist/projects/workspace.js +10 -0
  27. package/dist/register/records.js +7 -2
  28. package/dist/renderer/tool-labels.js +2 -1
  29. package/dist/repl/resume-standalone.js +3 -0
  30. package/dist/repl.js +23 -2
  31. package/dist/skill-catalog.js +14 -6
  32. package/dist/skills/bundled-skills.js +61 -54
  33. package/dist/tools/_filesystem-shared.js +363 -33
  34. package/dist/tools/cap/metadata-import.js +10 -4
  35. package/dist/tools/filesystem/file-write.js +12 -39
  36. package/dist/tools/filesystem/glob.js +25 -2
  37. package/dist/tools/filesystem/grep.js +25 -2
  38. package/dist/tools/fiori/apply.js +30 -21
  39. package/dist/tools/fiori/commit-gate.js +58 -0
  40. package/dist/tools/fiori/fe-extend.js +6 -1
  41. package/dist/tools/fiori/fe-scaffold.js +6 -1
  42. package/dist/tools/fiori/path-params.js +78 -0
  43. package/dist/tools/fiori/scaffold.js +2 -1
  44. package/dist/tools/fiori/tools.js +53 -14
  45. package/dist/tools/lane/app-lane-resolve.js +329 -0
  46. package/dist/tools/local-build.js +4 -0
  47. package/dist/tools/project/_merge-shared.js +36 -1
  48. package/dist/tools/project/cca_merge.js +4 -0
  49. package/dist/tools/project/upgrade_merge_progress.js +21 -10
  50. package/dist/tools/tool-loading.js +2 -0
  51. 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) — is resolved
19
- * through `resolveSafePath(opts.appDir, rel)`, which throws PathOutsideRootError
20
- * if the resolved absolute path is not contained under appDir. This is the
21
- * authoritative defense; the catalog-side `ns` pattern (render.ts) is the
22
- * second, earlier layer.
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
- // 1. write files — re-validate EVERY rendered (param-derived) path against appDir.
27
- for (const f of rendered.files) {
28
- const abs = resolveSafePath(opts.appDir, f.path);
29
- mkdirSync(dirname(abs), { recursive: true });
30
- writeFileSync(abs, f.content, 'utf8');
31
- }
32
- // 2. merge manifest — ALWAYS re-read from disk (I2). Fixed relative path, but
33
- // routed through resolveSafePath for uniform containment.
34
- const manifestPath = resolveSafePath(opts.appDir, 'webapp/manifest.json');
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
- writeFileSync(manifestPath, JSON.stringify(merged, null, 2), 'utf8');
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 i18nPath = resolveSafePath(opts.appDir, `webapp/${i18nRel}`);
45
- const existing = existsSync(i18nPath) ? readFileSync(i18nPath, 'utf8') : '';
46
- if (Object.keys(rendered.i18nKeys).length) {
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 new Promise((res, rej) => result.commit((e) => (e ? rej(e) : res())));
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 new Promise((res, rej) => fs.commit((err) => (err ? rej(err) : res())));
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 new Promise((res, rej) => fs.commit((err) => (err ? rej(err) : res())));
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
- /** Resolve a user-supplied dir inside the project root, mapping the escape error. */
84
- function safeDir(ctx, userPath) {
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
- return { abs: resolveSafePath(ctx.cwd, userPath) };
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 = safeDir(ctx, args.appDir);
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);