@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.
- package/README.md +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +58 -21
- 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 +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- 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-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 +15 -3
- package/dist/cost/pricing.js +6 -5
- 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/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- 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 +96 -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 +152 -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 +238 -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 +42 -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/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 +36 -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 +795 -332
- 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/resume.js +104 -36
- package/dist/session/store.js +69 -27
- 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/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-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/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- 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 +1 -0
- 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 +28 -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 +11 -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,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1-1 — `Common.ValueListReferences`: read them out of a main `$metadata`,
|
|
3
|
+
* and resolve each (relative) reference against the service path.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: a RAP V4 service does NOT carry `Common.ValueList` inline
|
|
6
|
+
* for a `@Consumption.valueHelpDefinition`. Its main document names a separate
|
|
7
|
+
* value-help service per field, as a relative URL with matrix parameters:
|
|
8
|
+
*
|
|
9
|
+
* ../../../../srvd_f4/sap/<vh>/0001;ps='srvd-<srvd>-0001';va='…'/$metadata
|
|
10
|
+
*
|
|
11
|
+
* and the mapping itself lives in THAT document (see audit.ts). Both functions
|
|
12
|
+
* here are pure: no network, no filesystem.
|
|
13
|
+
*/
|
|
14
|
+
import { parse } from '@sap-ux/edmx-parser';
|
|
15
|
+
const VALUE_LIST_REFERENCES = 'com.sap.vocabularies.Common.v1.ValueListReferences';
|
|
16
|
+
/**
|
|
17
|
+
* Pure: scan a main $metadata for ValueListReferences, in document order.
|
|
18
|
+
*
|
|
19
|
+
* Uses the EDMX parser, not a text match: the parser resolves whatever alias
|
|
20
|
+
* the document declares (`SAP__common.ValueListReferences` on a real system,
|
|
21
|
+
* the full namespace elsewhere) to the vocabulary namespace. The URL strings
|
|
22
|
+
* come back exactly as written — raw apostrophes and all — which is also the
|
|
23
|
+
* key shape of the captured refs maps.
|
|
24
|
+
*/
|
|
25
|
+
export function readValueListReferences(mainXml) {
|
|
26
|
+
const raw = parse(mainXml);
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const list of Object.values(raw.schema.annotations)) {
|
|
29
|
+
for (const target of list) {
|
|
30
|
+
for (const ann of target.annotations) {
|
|
31
|
+
if (ann.term !== VALUE_LIST_REFERENCES)
|
|
32
|
+
continue;
|
|
33
|
+
const urls = stringsOf(ann.collection);
|
|
34
|
+
if (urls.length === 0)
|
|
35
|
+
continue;
|
|
36
|
+
const property = target.target.slice(target.target.lastIndexOf('/') + 1);
|
|
37
|
+
const existing = out.find((r) => r.property === property);
|
|
38
|
+
if (existing) {
|
|
39
|
+
for (const u of urls)
|
|
40
|
+
if (!existing.urls.includes(u))
|
|
41
|
+
existing.urls.push(u);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
out.push({ property, urls });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function stringsOf(collection) {
|
|
52
|
+
if (!Array.isArray(collection))
|
|
53
|
+
return [];
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const item of collection) {
|
|
56
|
+
if (typeof item === 'string')
|
|
57
|
+
out.push(item);
|
|
58
|
+
else if (item && typeof item === 'object' && typeof item.String === 'string') {
|
|
59
|
+
out.push(item.String);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Pure: resolve a relative reference URL against the service path.
|
|
66
|
+
*
|
|
67
|
+
* '/sap/opu/odata4/sap/zx/srvd/sap/zc_x/0001/$metadata' + '../../../../srvd_f4/sap/i_vh/0001/$metadata'
|
|
68
|
+
* → '/sap/opu/odata4/sap/zx/srvd_f4/sap/i_vh/0001/$metadata'
|
|
69
|
+
*
|
|
70
|
+
* The base is the DIRECTORY of the service path: everything up to its last
|
|
71
|
+
* `/`, so `…/0001/$metadata` and `…/0001/` resolve alike. Segments are
|
|
72
|
+
* processed by hand rather than through `URL`, so the matrix parameters and
|
|
73
|
+
* their raw apostrophes come out byte for byte as SAP wrote them — no
|
|
74
|
+
* percent-encoding, no normalisation. An absolute reference (leading `/`) is
|
|
75
|
+
* returned untouched.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveReferenceUrl(servicePath, referenceUrl) {
|
|
78
|
+
if (referenceUrl.startsWith('/'))
|
|
79
|
+
return referenceUrl;
|
|
80
|
+
const pathOnly = servicePath.split('?')[0];
|
|
81
|
+
const baseDir = pathOnly.slice(0, pathOnly.lastIndexOf('/') + 1);
|
|
82
|
+
const segments = baseDir.split('/').filter((s, i, all) => !(s === '' && i === all.length - 1));
|
|
83
|
+
// segments[0] is '' (leading slash); keep it as the root marker.
|
|
84
|
+
const refSegments = referenceUrl.split('/');
|
|
85
|
+
for (let i = 0; i < refSegments.length; i++) {
|
|
86
|
+
const seg = refSegments[i];
|
|
87
|
+
const last = i === refSegments.length - 1;
|
|
88
|
+
if (seg === '..') {
|
|
89
|
+
if (segments.length > 1)
|
|
90
|
+
segments.pop();
|
|
91
|
+
if (last)
|
|
92
|
+
segments.push('');
|
|
93
|
+
}
|
|
94
|
+
else if (seg === '.') {
|
|
95
|
+
if (last)
|
|
96
|
+
segments.push('');
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
segments.push(seg);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const joined = segments.join('/');
|
|
103
|
+
return joined.startsWith('/') ? joined : `/${joined}`;
|
|
104
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1-1 — the contract `fiori_metadata_audit` returns: machine truth about ONE
|
|
3
|
+
* entity set of a published V4 service, read from its `$metadata` and from
|
|
4
|
+
* every value-help document it references. Everything downstream (the build
|
|
5
|
+
* phases, the smoke checks, lane 2) reads this instead of trusting a
|
|
6
|
+
* model-supplied string.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/preview/app-guard.ts
|
|
2
|
+
/**
|
|
3
|
+
* L1-6 fix round 2 — the checks that run BEFORE the SAP password is read from
|
|
4
|
+
* the keychain. Each one closes a way for the app directory (which the model
|
|
5
|
+
* can write) to receive, or to redirect, the login that `fiori_preview` hands
|
|
6
|
+
* to `npm run start`.
|
|
7
|
+
*
|
|
8
|
+
* - R1-2: npm runs `prestart` / `poststart` around `start`, and a project
|
|
9
|
+
* `.npmrc` can swap the script shell or preload code with `node-options`.
|
|
10
|
+
* `--ignore-scripts` is passed as well; these refusals make the reason
|
|
11
|
+
* visible instead of silently skipping a hook the app may rely on.
|
|
12
|
+
* - R1-4: the preview proxy presents the login to whatever `backend[].url`
|
|
13
|
+
* the app's ui5.yaml names. A copied app folder previewed with another
|
|
14
|
+
* alias would send that alias's password to the first system's host — no
|
|
15
|
+
* attacker needed. Every backend url must name the alias's host and port.
|
|
16
|
+
*
|
|
17
|
+
* No YAML library: the CLI ships none as a direct dependency and the ruling
|
|
18
|
+
* forbids adding one. The reader below only extracts `url:` values under a
|
|
19
|
+
* `backend:` key and FAILS CLOSED on anything it cannot read with certainty
|
|
20
|
+
* (flow style, anchors/aliases, block scalars, escapes).
|
|
21
|
+
*/
|
|
22
|
+
import { promises as fs } from 'node:fs';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
const WHY = "the SAP login would be in that script's environment";
|
|
25
|
+
/** R1-2 — npm lifecycle hooks and project .npmrc settings that run code. */
|
|
26
|
+
export async function lifecycleProblem(appDir, scripts) {
|
|
27
|
+
for (const hook of ['prestart', 'poststart']) {
|
|
28
|
+
if (scripts && Object.prototype.hasOwnProperty.call(scripts, hook)) {
|
|
29
|
+
return (`refusing to start: package.json has a "${hook}" script (${JSON.stringify(scripts[hook])}), and npm ` +
|
|
30
|
+
`runs it around "start" — ${WHY}. Remove it (the preview only needs "start").`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
let npmrc;
|
|
34
|
+
try {
|
|
35
|
+
npmrc = await fs.readFile(path.join(appDir, '.npmrc'), 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null; // no project .npmrc — nothing to check
|
|
39
|
+
}
|
|
40
|
+
const m = /^\s*(script[-_]shell|node[-_]options)\s*=.*$/im.exec(npmrc);
|
|
41
|
+
if (m) {
|
|
42
|
+
return (`refusing to start: ${path.join(appDir, '.npmrc')} sets ${m[1]} (${JSON.stringify(m[0].trim())}), which ` +
|
|
43
|
+
`makes npm run other code for "start" — ${WHY}. Remove that line from the app's .npmrc.`);
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/** The ui5 config files the start script will read: every --config/-c value, else ui5.yaml. */
|
|
48
|
+
export function configFilesFor(startScript) {
|
|
49
|
+
const tokens = startScript.trim().split(/\s+/).map((t) => t.replace(/^["']|["']$/g, ''));
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
52
|
+
const t = tokens[i];
|
|
53
|
+
if (t === '--config' || t === '-c') {
|
|
54
|
+
out.push(tokens[i + 1] ?? '');
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
else if (t.startsWith('--config=')) {
|
|
58
|
+
out.push(t.slice('--config='.length));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out.length > 0 ? out : ['ui5.yaml'];
|
|
62
|
+
}
|
|
63
|
+
function inside(root, candidate) {
|
|
64
|
+
const rel = path.relative(root, candidate);
|
|
65
|
+
return rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
66
|
+
}
|
|
67
|
+
async function realOr(p) {
|
|
68
|
+
try {
|
|
69
|
+
return await fs.realpath(p);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return p;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Parse one YAML plain / single- / double-quoted scalar, or null when unsure. */
|
|
76
|
+
function scalar(raw) {
|
|
77
|
+
const v = raw.trim();
|
|
78
|
+
if (v.startsWith('"')) {
|
|
79
|
+
const end = v.indexOf('"', 1);
|
|
80
|
+
if (end < 0)
|
|
81
|
+
return null;
|
|
82
|
+
const body = v.slice(1, end);
|
|
83
|
+
return body.includes('\\') ? null : body; // escapes could spell another host
|
|
84
|
+
}
|
|
85
|
+
if (v.startsWith("'")) {
|
|
86
|
+
const end = v.indexOf("'", 1);
|
|
87
|
+
if (end < 0)
|
|
88
|
+
return null;
|
|
89
|
+
return v.slice(1, end);
|
|
90
|
+
}
|
|
91
|
+
const plain = v.replace(/\s+#.*$/, '').trim();
|
|
92
|
+
if (plain.length === 0 || /^[|>*&!{[\]]/.test(plain))
|
|
93
|
+
return null;
|
|
94
|
+
return plain;
|
|
95
|
+
}
|
|
96
|
+
/** Every `url:` under a `backend:` key, block style only. */
|
|
97
|
+
export function backendUrlsFromUi5Yaml(text) {
|
|
98
|
+
const urls = [];
|
|
99
|
+
let keyCol = null;
|
|
100
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
101
|
+
if (/^\s*(#.*)?$/.test(raw))
|
|
102
|
+
continue;
|
|
103
|
+
const indent = raw.length - raw.trimStart().length;
|
|
104
|
+
const body = raw.slice(indent);
|
|
105
|
+
if (keyCol !== null) {
|
|
106
|
+
const inBlock = indent > keyCol || (indent === keyCol && body.startsWith('-'));
|
|
107
|
+
if (!inBlock)
|
|
108
|
+
keyCol = null;
|
|
109
|
+
}
|
|
110
|
+
const key = /^(-\s+)?backend\s*:(.*)$/.exec(body);
|
|
111
|
+
if (key) {
|
|
112
|
+
const rest = key[2].replace(/\s+#.*$/, '').trim();
|
|
113
|
+
if (rest.length > 0)
|
|
114
|
+
return { error: `the backend list is written inline (${JSON.stringify(body.trim())})` };
|
|
115
|
+
keyCol = raw.indexOf('backend');
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (keyCol !== null) {
|
|
119
|
+
if (/(^|\s)<<\s*:|:\s*[*&]|^-\s+[*&]/.test(body)) {
|
|
120
|
+
return { error: `the backend list uses a YAML anchor or alias (${JSON.stringify(body.trim())})` };
|
|
121
|
+
}
|
|
122
|
+
const u = /^(-\s+)?url\s*:(.*)$/.exec(body);
|
|
123
|
+
if (u) {
|
|
124
|
+
const value = scalar(u[2]);
|
|
125
|
+
if (value === null)
|
|
126
|
+
return { error: `a backend url is not a plain value (${JSON.stringify(body.trim())})` };
|
|
127
|
+
urls.push(value);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { urls };
|
|
132
|
+
}
|
|
133
|
+
function hostPort(u) {
|
|
134
|
+
const port = u.port !== '' ? u.port : u.protocol === 'https:' ? '443' : u.protocol === 'http:' ? '80' : null;
|
|
135
|
+
return port === null ? null : `${u.hostname.toLowerCase()}:${port}`;
|
|
136
|
+
}
|
|
137
|
+
function aliasHostPort(host, port) {
|
|
138
|
+
const bare = host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/[/?#].*$/, '').replace(/:\d+$/, '');
|
|
139
|
+
return `${bare.toLowerCase()}:${port}`;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* THE decision (round 2b, owner ruling 2026-09-21) — the one source of truth
|
|
143
|
+
* for both the approval hook and the start path:
|
|
144
|
+
* - no backend url in any ui5 config the start script reads → 'none': the
|
|
145
|
+
* preview starts WITHOUT the SAP login (no keychain read, no prompt);
|
|
146
|
+
* - backend urls present and all on the alias's host:port → 'login';
|
|
147
|
+
* - a config outside appDir, an unreadable or uncertain one, a mismatch,
|
|
148
|
+
* a non-http(s) url → refuse (fail closed).
|
|
149
|
+
* `system` may be omitted by a caller that only needs to know whether a login
|
|
150
|
+
* WOULD be passed (the approval hook): urls present then means 'login'
|
|
151
|
+
* without the host comparison — the start path always passes `system`.
|
|
152
|
+
*/
|
|
153
|
+
export async function loginNeed(appDir, startScript, alias, system) {
|
|
154
|
+
const expected = system ? aliasHostPort(system.host, system.port) : null;
|
|
155
|
+
const realApp = await realOr(appDir);
|
|
156
|
+
let total = 0;
|
|
157
|
+
for (const name of configFilesFor(startScript)) {
|
|
158
|
+
const file = path.resolve(appDir, name);
|
|
159
|
+
if (name.length === 0 || !inside(appDir, file) || !inside(realApp, await realOr(file))) {
|
|
160
|
+
return {
|
|
161
|
+
refuse: `refusing to start: the start script reads ui5 config ${JSON.stringify(name)}, which is not a file ` +
|
|
162
|
+
`inside the app directory — the SAP login for alias ${alias} would go wherever that file says.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
let text;
|
|
166
|
+
try {
|
|
167
|
+
text = await fs.readFile(file, 'utf8');
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return {
|
|
171
|
+
refuse: `refusing to start: cannot read ${file} — without it there is no way to confirm which host the ` +
|
|
172
|
+
`preview proxy would send the SAP login for alias ${alias} to.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const read = backendUrlsFromUi5Yaml(text);
|
|
176
|
+
if ('error' in read) {
|
|
177
|
+
return {
|
|
178
|
+
refuse: `refusing to start: ${file} could not be read with certainty — ${read.error}. Write the backend ` +
|
|
179
|
+
`entries in plain block style ("- path: /sap" / "url: https://host:port").`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
total += read.urls.length;
|
|
183
|
+
for (const raw of read.urls) {
|
|
184
|
+
let actual = null;
|
|
185
|
+
try {
|
|
186
|
+
actual = hostPort(new URL(raw));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
actual = null;
|
|
190
|
+
}
|
|
191
|
+
if (actual === null) {
|
|
192
|
+
return { refuse: `refusing to start: backend url ${JSON.stringify(raw)} in ${file} is not an http(s) URL.` };
|
|
193
|
+
}
|
|
194
|
+
if (expected !== null && actual !== expected) {
|
|
195
|
+
return {
|
|
196
|
+
refuse: `refusing to start: this app was scaffolded for ${actual}; alias ${alias} points at ${expected}. ` +
|
|
197
|
+
`The preview proxy would send the SAP login for ${alias} to ${actual} (${file}). Preview it with ` +
|
|
198
|
+
'the alias for that system, or re-scaffold the app for this one.',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return total === 0 ? { need: 'none' } : { need: 'login' };
|
|
204
|
+
}
|
|
205
|
+
/** The app's `scripts.start`, or null when package.json or the script is missing/unreadable. */
|
|
206
|
+
export async function readStartScript(appDir) {
|
|
207
|
+
try {
|
|
208
|
+
const pkg = JSON.parse(await fs.readFile(path.join(appDir, 'package.json'), 'utf8'));
|
|
209
|
+
const v = pkg.scripts?.start;
|
|
210
|
+
return typeof v === 'string' ? v : null;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/preview/env.ts
|
|
2
|
+
/**
|
|
3
|
+
* L1-6 — the preview child's ENVIRONMENT CONTRACT.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS NOT buildSafeEnv()
|
|
6
|
+
* ------------------------------
|
|
7
|
+
* `buildSafeEnv()` (`../../_command-shared.ts`) whitelists ENV_PASSTHROUGH and
|
|
8
|
+
* DROPS everything else — including the two things a Fiori preview needs:
|
|
9
|
+
* - NODE_EXTRA_CA_CERTS, so the proxy's Node process trusts the SAP box's CA
|
|
10
|
+
* while certificate verification stays ON;
|
|
11
|
+
* - FIORI_TOOLS_USER / FIORI_TOOLS_PASSWORD, which `fiori run`'s proxy
|
|
12
|
+
* answers the backend's basic-auth challenge with.
|
|
13
|
+
* Loosening ENV_PASSTHROUGH would loosen it for shell_exec and background_run
|
|
14
|
+
* too (no-regression), so the preview builds its OWN environment instead:
|
|
15
|
+
* the SAME passthrough set plus EXACTLY three keys.
|
|
16
|
+
*
|
|
17
|
+
* WHAT NEVER HAPPENS HERE
|
|
18
|
+
* -----------------------
|
|
19
|
+
* - No file is written. The credentials and the CA path travel only in the
|
|
20
|
+
* child's process environment — never `.env`, never `ui5.yaml`, never a
|
|
21
|
+
* temp file (constraints.md: "No credential reaches a file").
|
|
22
|
+
* - Certificate checking is NEVER turned off. There is no code path that sets
|
|
23
|
+
* NODE_TLS_REJECT_UNAUTHORIZED, and `insecure` mode is REPORTED (loudly),
|
|
24
|
+
* not implemented. An alias in insecure mode still gets a preview — its
|
|
25
|
+
* ui5.yaml proxy posture is the alias owner's business — but the tool says
|
|
26
|
+
* so in its result and points at `sap.<alias>.ca_cert_path`.
|
|
27
|
+
* - The parent's own NODE_EXTRA_CA_CERTS is never inherited. A stale value
|
|
28
|
+
* pointing at a cert for a different system would silently change which
|
|
29
|
+
* roots the child trusts, so the key is set from the alias config or not at
|
|
30
|
+
* all.
|
|
31
|
+
*/
|
|
32
|
+
import { inspect } from 'node:util';
|
|
33
|
+
import { ENV_PASSTHROUGH } from '../../_command-shared.js';
|
|
34
|
+
/** Lower-cased copy of the shared allowlist, for the Windows lookup. */
|
|
35
|
+
const ENV_PASSTHROUGH_LOWER = new Set([...ENV_PASSTHROUGH].map((n) => n.toLowerCase()));
|
|
36
|
+
function isPassthrough(name) {
|
|
37
|
+
if (ENV_PASSTHROUGH.has(name))
|
|
38
|
+
return true;
|
|
39
|
+
return process.platform === 'win32' && ENV_PASSTHROUGH_LOWER.has(name.toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Pure. Builds the child environment: the shell passthrough set PLUS
|
|
43
|
+
* NODE_EXTRA_CA_CERTS, FIORI_TOOLS_USER, FIORI_TOOLS_PASSWORD. Nothing else
|
|
44
|
+
* from the parent leaks; nothing is written to disk.
|
|
45
|
+
*/
|
|
46
|
+
export function buildPreviewEnv(input) {
|
|
47
|
+
const env = {};
|
|
48
|
+
// 1. The same passthrough set the other spawning tools use — and ONLY that.
|
|
49
|
+
// CSPEACH_*, ANTHROPIC_*, SAP_*, *_TOKEN and every other parent variable
|
|
50
|
+
// are dropped because they are not on the list.
|
|
51
|
+
//
|
|
52
|
+
// On Windows the match is case-INSENSITIVE (F-12): environment names there
|
|
53
|
+
// are case-insensitive, and the real parent process spells them `Path` and
|
|
54
|
+
// `ComSpec` under PowerShell but `PATH` and `COMSPEC` under Git Bash — so a
|
|
55
|
+
// case-sensitive match silently gave the child a different environment
|
|
56
|
+
// depending on which shell started the CLI. The shared ENV_PASSTHROUGH set
|
|
57
|
+
// is NOT modified; only this lookup is relaxed.
|
|
58
|
+
for (const k of Object.keys(input.baseEnv)) {
|
|
59
|
+
if (isPassthrough(k))
|
|
60
|
+
env[k] = input.baseEnv[k];
|
|
61
|
+
}
|
|
62
|
+
// 2. The CA. Set ONLY from the alias config, never inherited. Verification
|
|
63
|
+
// stays on in every branch.
|
|
64
|
+
if (input.tls.mode === 'ca-trusted' && input.tls.caCertPath) {
|
|
65
|
+
env.NODE_EXTRA_CA_CERTS = input.tls.caCertPath;
|
|
66
|
+
}
|
|
67
|
+
// 3. The credentials, by environment only.
|
|
68
|
+
// Round 2b: an app whose ui5 config names no backend url gets NEITHER.
|
|
69
|
+
if (input.password !== undefined) {
|
|
70
|
+
env.FIORI_TOOLS_USER = input.username;
|
|
71
|
+
env.FIORI_TOOLS_PASSWORD = input.password;
|
|
72
|
+
}
|
|
73
|
+
const insecureFallback = input.tls.mode === 'insecure';
|
|
74
|
+
return { env, insecureFallback, tlsNote: tlsNoteFor(input.tls) };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The one-line TLS posture, shared by `buildPreviewEnv` and the reuse path in
|
|
78
|
+
* `start.ts` (it needs the note without building an environment). One copy —
|
|
79
|
+
* the two used to be the same 13 lines twice (F-13).
|
|
80
|
+
*/
|
|
81
|
+
export function tlsNoteFor(tls) {
|
|
82
|
+
switch (tls.mode) {
|
|
83
|
+
case 'ca-trusted':
|
|
84
|
+
return `CA trusted: ${tls.caCertPath} (certificate verification ON)`;
|
|
85
|
+
case 'insecure':
|
|
86
|
+
return ('certificate verification is OFF for this alias (insecure_skip_tls_verify) — ' +
|
|
87
|
+
'set sap.<alias>.ca_cert_path to a PEM root CA instead');
|
|
88
|
+
default:
|
|
89
|
+
return 'certificate verification ON (system trust store; no extra CA configured)';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export const REDACTED = '***';
|
|
93
|
+
/**
|
|
94
|
+
* Literal replacement is skipped for a secret this short. Replacing a one- or
|
|
95
|
+
* two-character password shreds the output into unreadable `***`-confetti
|
|
96
|
+
* (`npm ERR! *** f***t***l`), and the shredding pattern itself tells a reader
|
|
97
|
+
* exactly how long the password is. The generic header / env-dump / userinfo
|
|
98
|
+
* patterns below still apply to short secrets — they are what actually catch
|
|
99
|
+
* the dangerous shapes.
|
|
100
|
+
*/
|
|
101
|
+
export const MIN_LITERAL_SECRET_LENGTH = 4;
|
|
102
|
+
/**
|
|
103
|
+
* Every ENCODING of the credential that a crashing HTTP client, an env dump or
|
|
104
|
+
* a JSON error body can print. Review F-2 measured five of these leaking.
|
|
105
|
+
* Built as LITERALS and removed with split/join — never a RegExp compiled from
|
|
106
|
+
* the secret, which would have to be escaped correctly for every input and
|
|
107
|
+
* whose replacement string could re-inject `$&`.
|
|
108
|
+
*/
|
|
109
|
+
function literalForms(password, username) {
|
|
110
|
+
if (password.length < MIN_LITERAL_SECRET_LENGTH)
|
|
111
|
+
return [];
|
|
112
|
+
const forms = new Set();
|
|
113
|
+
const add = (s) => { if (s && s.length > 0)
|
|
114
|
+
forms.add(s); };
|
|
115
|
+
add(password);
|
|
116
|
+
// `Authorization: Basic base64(user:password)` — what an uncaught request
|
|
117
|
+
// error prints from `request._header` / `config.headers`. This leaks for
|
|
118
|
+
// EVERY password, not just exotic ones.
|
|
119
|
+
// R1-7: per base64 form also the unpadded one, the base64url one and the
|
|
120
|
+
// latin1 one (a client that encodes the header as latin1, not utf8).
|
|
121
|
+
const addBase64 = (raw) => {
|
|
122
|
+
for (const enc of ['utf8', 'latin1']) {
|
|
123
|
+
const buf = Buffer.from(raw, enc);
|
|
124
|
+
const std = buf.toString('base64');
|
|
125
|
+
add(std);
|
|
126
|
+
add(std.replace(/=+$/, ''));
|
|
127
|
+
add(buf.toString('base64url'));
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
if (username)
|
|
131
|
+
addBase64(`${username}:${password}`);
|
|
132
|
+
addBase64(password);
|
|
133
|
+
// `https://user:pass@host` in a proxy error, and query-string echoes — plus
|
|
134
|
+
// the form-encoded `+` for space and the lower-case-hex spellings (R1-7).
|
|
135
|
+
const uri = encodeURIComponent(password);
|
|
136
|
+
const lowerHex = (x) => x.replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase());
|
|
137
|
+
for (const f of [uri, uri.replace(/%20/g, '+')]) {
|
|
138
|
+
add(f);
|
|
139
|
+
add(lowerHex(f));
|
|
140
|
+
}
|
|
141
|
+
// A string printed inside quotes of the same kind: `\'` and `\"` (R1-7).
|
|
142
|
+
add(password.replace(/'/g, "\\'"));
|
|
143
|
+
add(password.replace(/"/g, '\\"'));
|
|
144
|
+
// A JSON error body: `{"FIORI_TOOLS_PASSWORD":"Pa\"ss\r"}`.
|
|
145
|
+
add(JSON.stringify(password).slice(1, -1));
|
|
146
|
+
// `util.inspect` of an options object: quotes chosen at runtime, so strip
|
|
147
|
+
// whatever quote characters it picked rather than assuming one.
|
|
148
|
+
const inspected = inspect(password);
|
|
149
|
+
if (inspected.length >= 2)
|
|
150
|
+
add(inspected.slice(1, -1));
|
|
151
|
+
// Longest first: a shorter form that is a substring of a longer one must not
|
|
152
|
+
// break the longer match up before it is replaced.
|
|
153
|
+
return [...forms].sort((a, b) => b.length - a.length);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Shapes that are secrets whatever their value is. These run for EVERY call,
|
|
157
|
+
* including when the password is unknown or too short to replace literally.
|
|
158
|
+
*/
|
|
159
|
+
const GENERIC_PATTERNS = [
|
|
160
|
+
// Authorization: Basic <base64> / Bearer <token>, header or log line.
|
|
161
|
+
[/\b(Basic|Bearer)\s+[A-Za-z0-9+/=._~-]{8,}/gi, `$1 ${REDACTED}`],
|
|
162
|
+
// Any authorization / proxy-authorization header value, quoted or not.
|
|
163
|
+
[/(authorization["']?\s*[:=]\s*)(["']?)[^"'\r\n]+/gi, `$1$2${REDACTED}`],
|
|
164
|
+
// FIORI_TOOLS_PASSWORD in KEY=value, JSON, or quoted-with-spaces shape.
|
|
165
|
+
// R1-7: quoted values may contain escaped quotes; unquoted ones may contain
|
|
166
|
+
// spaces (up to the end of the line, a comma or a closing brace).
|
|
167
|
+
[/(FIORI_TOOLS_PASSWORD["']?\s*[:=]\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\r\n,}]+)/gi, `$1${REDACTED}`],
|
|
168
|
+
// URL userinfo: https://user:pass@host.
|
|
169
|
+
[/(\/\/[^/\s:@]+:)[^@\s/]+@/g, `$1${REDACTED}@`],
|
|
170
|
+
];
|
|
171
|
+
/**
|
|
172
|
+
* Redact credentials from any child output we are about to SURFACE (a stderr
|
|
173
|
+
* tail in an error, a log line, a tool result). The child may print its own
|
|
174
|
+
* environment, its request headers or a JSON error body on a crash — and the
|
|
175
|
+
* most likely crash is exactly the one this tool exists to surface, an
|
|
176
|
+
* unverifiable certificate on first boot.
|
|
177
|
+
*
|
|
178
|
+
* Two passes: every literal ENCODING of the credential (§literalForms), then
|
|
179
|
+
* the generic shapes that are secrets regardless of value (§GENERIC_PATTERNS).
|
|
180
|
+
*
|
|
181
|
+
* `username` is needed to compute `base64(user:password)` — without it the
|
|
182
|
+
* Basic-auth form can only be caught by the generic pattern.
|
|
183
|
+
*/
|
|
184
|
+
export function scrubSecrets(text, password, username) {
|
|
185
|
+
let out = text;
|
|
186
|
+
if (password && password.length > 0) {
|
|
187
|
+
for (const form of literalForms(password, username))
|
|
188
|
+
out = out.split(form).join(REDACTED);
|
|
189
|
+
}
|
|
190
|
+
for (const [pattern, replacement] of GENERIC_PATTERNS)
|
|
191
|
+
out = out.replace(pattern, replacement);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/preview/readiness.ts
|
|
2
|
+
/**
|
|
3
|
+
* L1-6 — the bounded readiness probe.
|
|
4
|
+
*
|
|
5
|
+
* A preview is READY only when BOTH legs answer:
|
|
6
|
+
* 1. the app URL itself (the UI5 server is serving index.html), and
|
|
7
|
+
* 2. `<origin><servicePath>$metadata` (the proxy leg actually reaches the
|
|
8
|
+
* backend with the credentials and the CA we handed the child).
|
|
9
|
+
*
|
|
10
|
+
* Leg 2 is the whole point. A UI5 server comes up in a second and serves
|
|
11
|
+
* index.html happily while the proxy is still 401-ing, 502-ing or dying on a
|
|
12
|
+
* certificate — which is exactly how July's run looked green until a browser
|
|
13
|
+
* opened it. So "the port answered" is never enough.
|
|
14
|
+
*
|
|
15
|
+
* The outcome is CLASSIFIED, because the three failures need three different
|
|
16
|
+
* fixes:
|
|
17
|
+
* - `refused` — nothing is listening: the child died or never bound.
|
|
18
|
+
* - `metadata-not-served` — the app is up, the backend leg is not.
|
|
19
|
+
* - `timeout` — the socket answers but the app never arrives.
|
|
20
|
+
* When a later round regresses (the app stops answering after the service leg
|
|
21
|
+
* already failed) we keep the FURTHEST stage reached, because that is the
|
|
22
|
+
* informative one.
|
|
23
|
+
*
|
|
24
|
+
* Everything time-ish and network-ish is injected (ReadinessDeps) so the unit
|
|
25
|
+
* suite drives every path offline.
|
|
26
|
+
*/
|
|
27
|
+
/** Gap between polls. Small enough to feel instant, large enough not to spin. */
|
|
28
|
+
export const POLL_INTERVAL_MS = 500;
|
|
29
|
+
/**
|
|
30
|
+
* Furthest-stage ranking — a later round never downgrades the classification.
|
|
31
|
+
*
|
|
32
|
+
* `refused` is the LOWEST rank, not a high one (review F-8): every real start
|
|
33
|
+
* begins with a few refusals while the server binds, so if a refusal outranked
|
|
34
|
+
* an HTTP answer, every "server is up but the page 404s" failure would be
|
|
35
|
+
* reported as "nothing is listening" — the opposite of actionable. An answer,
|
|
36
|
+
* any answer, is further along than a refusal.
|
|
37
|
+
*/
|
|
38
|
+
const RANK = { refused: 0, timeout: 1, 'app-not-served': 2, 'metadata-not-served': 3 };
|
|
39
|
+
/** A 2xx is served. A 3xx is a redirect (typically to a logon page) — not ready. */
|
|
40
|
+
function served(status) {
|
|
41
|
+
return status >= 200 && status < 300;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `<origin><servicePath>$metadata`, or undefined when no service path is known
|
|
45
|
+
* (a freestyle app with no backend service still has a valid preview — it just
|
|
46
|
+
* has no second leg to probe).
|
|
47
|
+
*/
|
|
48
|
+
export function metadataUrlFor(appUrl, servicePath) {
|
|
49
|
+
const trimmed = (servicePath ?? '').trim();
|
|
50
|
+
if (trimmed.length === 0)
|
|
51
|
+
return undefined;
|
|
52
|
+
let origin;
|
|
53
|
+
try {
|
|
54
|
+
origin = new URL(appUrl).origin;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const withLead = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
60
|
+
const withTrail = withLead.endsWith('/') ? withLead : `${withLead}/`;
|
|
61
|
+
return `${origin}${withTrail}$metadata`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Polls until BOTH the app URL and <appUrl-origin><servicePath>$metadata answer
|
|
65
|
+
* 2xx through the proxy, or the budget runs out.
|
|
66
|
+
*/
|
|
67
|
+
export async function waitForPreview(appUrl, servicePath, budgetMs, deps) {
|
|
68
|
+
const metadataUrl = metadataUrlFor(appUrl, servicePath);
|
|
69
|
+
const started = deps.now();
|
|
70
|
+
// The starting point is "nothing answered at all"; the first refusal or HTTP
|
|
71
|
+
// status replaces it with something more informative.
|
|
72
|
+
let reason = 'timeout';
|
|
73
|
+
let detail = `no response from ${appUrl} within ${budgetMs}ms`;
|
|
74
|
+
let seenAnything = false;
|
|
75
|
+
const note = (r, d) => {
|
|
76
|
+
if (!seenAnything || RANK[r] >= RANK[reason]) {
|
|
77
|
+
reason = r;
|
|
78
|
+
detail = d;
|
|
79
|
+
seenAnything = true;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
while (deps.now() - started < budgetMs) {
|
|
83
|
+
let appStatus;
|
|
84
|
+
try {
|
|
85
|
+
appStatus = (await deps.probe(appUrl)).status;
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
const code = err?.code;
|
|
89
|
+
// R1-10: ETIMEDOUT = the port is open but the server stays silent.
|
|
90
|
+
if (code === 'ETIMEDOUT')
|
|
91
|
+
note('timeout', `${appUrl} accepted the connection but did not answer (ETIMEDOUT)`);
|
|
92
|
+
else
|
|
93
|
+
note('refused', `${appUrl} refused the connection (${code ?? err?.message ?? 'unknown'})`);
|
|
94
|
+
await deps.sleep(POLL_INTERVAL_MS);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!served(appStatus)) {
|
|
98
|
+
// The server IS up — it answered. The app page is the problem, and the
|
|
99
|
+
// usual cause is the wrong entry file. Say both things.
|
|
100
|
+
const entry = appUrl.split('/').pop() || appUrl;
|
|
101
|
+
note('app-not-served', `the preview server is up but ${appUrl} answered HTTP ${appStatus} — check that "${entry}" is the ` +
|
|
102
|
+
'app\'s entry page (a redirect is not a served app either)');
|
|
103
|
+
await deps.sleep(POLL_INTERVAL_MS);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (metadataUrl === undefined) {
|
|
107
|
+
return { ready: true, ms: deps.now() - started };
|
|
108
|
+
}
|
|
109
|
+
let metaStatus;
|
|
110
|
+
try {
|
|
111
|
+
metaStatus = (await deps.probe(metadataUrl)).status;
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const code = err?.code;
|
|
115
|
+
note('metadata-not-served', `${metadataUrl} could not be reached through the preview proxy (${code ?? err?.message ?? 'unknown'})`);
|
|
116
|
+
await deps.sleep(POLL_INTERVAL_MS);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (served(metaStatus)) {
|
|
120
|
+
return { ready: true, ms: deps.now() - started };
|
|
121
|
+
}
|
|
122
|
+
note('metadata-not-served', `the app is served but ${metadataUrl} answered HTTP ${metaStatus} through the preview proxy ` +
|
|
123
|
+
'(check the service path, the backend credentials and the CA)');
|
|
124
|
+
await deps.sleep(POLL_INTERVAL_MS);
|
|
125
|
+
}
|
|
126
|
+
return { ready: false, reason, detail };
|
|
127
|
+
}
|