@cspeach/cli 1.1.24 → 1.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +20 -8
- package/dist/agent/tool-dispatch.js +6 -0
- package/dist/auth/auth-file.js +173 -12
- package/dist/classifier/client.js +55 -30
- package/dist/cli.js +11 -0
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +5 -1
- package/dist/commands/cost.js +24 -22
- package/dist/commands/deploy-card.js +135 -0
- package/dist/commands/login.js +31 -13
- package/dist/commands/plan-chain.js +31 -0
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +49 -2
- package/dist/cost/cost-log.js +76 -4
- package/dist/cost/credits-wire.js +9 -0
- package/dist/cost/credits.js +24 -6
- package/dist/cost/router-spend.js +24 -0
- package/dist/cost/session-cost.js +16 -2
- package/dist/models/server-config.js +21 -1
- package/dist/one-shot.js +12 -2
- package/dist/projects/detail-path.js +117 -0
- package/dist/projects/merge-cca.js +19 -0
- package/dist/projects/merge-upgrade.js +19 -1
- package/dist/projects/plan-lane.js +47 -0
- package/dist/projects/plan-run.js +8 -0
- package/dist/projects/plan-schema.js +2 -0
- package/dist/projects/promote.js +7 -1
- package/dist/projects/save-command.js +3 -2
- package/dist/register/records.js +7 -2
- package/dist/renderer/footer-line.js +10 -4
- package/dist/renderer/status-footer.js +5 -4
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +41 -8
- package/dist/router/byok-classifier.js +159 -0
- package/dist/router/classifier.js +130 -6
- package/dist/router/router-core.js +307 -0
- package/dist/router/router-prompt.js +121 -0
- package/dist/router/routing-failure.js +65 -3
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/_filesystem-shared.js +363 -33
- package/dist/tools/cap/metadata-import.js +10 -4
- package/dist/tools/filesystem/file-write.js +12 -39
- package/dist/tools/filesystem/glob.js +25 -2
- package/dist/tools/filesystem/grep.js +25 -2
- package/dist/tools/fiori/apply.js +30 -21
- package/dist/tools/fiori/commit-gate.js +58 -0
- package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
- package/dist/tools/fiori/deploy/run.js +608 -0
- package/dist/tools/fiori/deploy/scope.js +8 -0
- package/dist/tools/fiori/deploy/yaml-read.js +236 -0
- package/dist/tools/fiori/deploy-config.js +24 -6
- package/dist/tools/fiori/fe-extend.js +6 -1
- package/dist/tools/fiori/fe-scaffold.js +6 -1
- package/dist/tools/fiori/path-params.js +78 -0
- package/dist/tools/fiori/scaffold.js +2 -1
- package/dist/tools/fiori/tools.js +170 -19
- package/dist/tools/local-build.js +3 -0
- package/dist/tools/project/_merge-shared.js +36 -1
- package/dist/tools/project/cca_merge.js +4 -0
- package/dist/tools/project/upgrade_merge_progress.js +21 -10
- package/dist/tools/subagent/read_agent.js +4 -2
- package/dist/tools/tool-loading.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/deploy/run.ts
|
|
2
|
+
/**
|
|
3
|
+
* fiori_deploy — deploy a built UI5 app to the ABAP repository (a BSP) with
|
|
4
|
+
* SAP's own `fiori deploy` (@sap/ux-ui5-tooling), using the SAP logon CSPeach
|
|
5
|
+
* already holds for the alias. The developer never types a password and no
|
|
6
|
+
* environment variable has to be set before CSPeach starts.
|
|
7
|
+
*
|
|
8
|
+
* HOW THE LOGON TRAVELS
|
|
9
|
+
* ---------------------
|
|
10
|
+
* `fiori deploy --username X --password Y` takes the NAMES of environment
|
|
11
|
+
* variables (verified against @sap/ux-ui5-tooling 1.25.0: handleConfigCredentials
|
|
12
|
+
* turns them into `env:X` and the deploy tooling resolves process.env[X]).
|
|
13
|
+
* This module reads the password from the same keychain entry every ADT call
|
|
14
|
+
* uses (connection-manager resolveSapPassword, headless — never a prompt) and
|
|
15
|
+
* puts user + password into the deploy child's environment ONLY, under
|
|
16
|
+
* random per-run names (CSP_DEPLOY_U_<hex> / CSP_DEPLOY_P_<hex>).
|
|
17
|
+
*
|
|
18
|
+
* Why explicit names and not FIORI_TOOLS_USER / FIORI_TOOLS_PASSWORD (which
|
|
19
|
+
* the tooling also reads): those are consulted only AFTER the Fiori tools'
|
|
20
|
+
* own secure store, so a login saved there for the same URL would silently
|
|
21
|
+
* win over CSPeach's. Explicit --username/--password win over both.
|
|
22
|
+
*
|
|
23
|
+
* - Never on argv (argv holds the variable names), never in the build child,
|
|
24
|
+
* never in a file, never in a log line or result: every captured byte is
|
|
25
|
+
* scrubbed (preview/env.ts scrubSecrets — every encoding of the secret).
|
|
26
|
+
* - The child inherits the environment normally, minus CSPeach / Anthropic
|
|
27
|
+
* secrets and minus NODE_TLS_REJECT_UNAUTHORIZED / NODE_EXTRA_CA_CERTS;
|
|
28
|
+
* the CA is set ONLY from sap.<alias>.ca_cert_path.
|
|
29
|
+
*
|
|
30
|
+
* WHERE THE LOGON MAY GO (host rule)
|
|
31
|
+
* ----------------------------------
|
|
32
|
+
* The yaml's target must be the alias's system: same scheme (https when the
|
|
33
|
+
* alias uses SSL), same port, same client, and a host that is EITHER the
|
|
34
|
+
* alias host itself (case-insensitive) OR resolves only to addresses the alias
|
|
35
|
+
* host resolves to (this is what lets a yaml that names the box by IP pass
|
|
36
|
+
* for an alias that names it by hostname — and refuses an IP or host that is
|
|
37
|
+
* anything else). Then the deploy is sent to the ALIAS url and client
|
|
38
|
+
* (`--url` / `--client` override the yaml), so the logon only ever reaches the
|
|
39
|
+
* exact host ADT already talks to, and a yaml edited after the check changes
|
|
40
|
+
* nothing.
|
|
41
|
+
*
|
|
42
|
+
* GATES
|
|
43
|
+
* -----
|
|
44
|
+
* - role prd: refused in both modes (CSPeach is a development agent).
|
|
45
|
+
* - mode 'deploy' is a SAP write: refused when the effective write mode is
|
|
46
|
+
* advisory-only; otherwise it needs an approval_id from request_approval
|
|
47
|
+
* for the BSP name (op create or modify), one approval per deploy.
|
|
48
|
+
* - mode 'test' writes nothing (the backend validates and rolls back), so it
|
|
49
|
+
* needs no write approval — but it still hands the logon to a child, so the
|
|
50
|
+
* tool's credentialHandover prompt (once per session per alias) applies.
|
|
51
|
+
*/
|
|
52
|
+
import crossSpawn from 'cross-spawn';
|
|
53
|
+
import { promises as fs } from 'node:fs';
|
|
54
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
55
|
+
import { isIP } from 'node:net';
|
|
56
|
+
import * as path from 'node:path';
|
|
57
|
+
import { randomBytes } from 'node:crypto';
|
|
58
|
+
import { resolveSapTls } from '../../../config/loader.js';
|
|
59
|
+
import { scrubSecrets } from '../preview/env.js';
|
|
60
|
+
import { readDeployYaml } from './yaml-read.js';
|
|
61
|
+
import { readExistingBsp, overwriteApprovalObject, bspFilePath } from './existing-bsp.js';
|
|
62
|
+
import { validateDeployTarget } from '../deploy-config.js';
|
|
63
|
+
import { killProcessTree } from '../preview/registry.js';
|
|
64
|
+
const KEYCHAIN_SERVICE = 'cspeach-sap';
|
|
65
|
+
/** Nonces of approvals that already ran one deploy (this process only). */
|
|
66
|
+
const deployApprovalsUsed = new Set();
|
|
67
|
+
export function __resetDeployApprovalsForTests() {
|
|
68
|
+
deployApprovalsUsed.clear();
|
|
69
|
+
}
|
|
70
|
+
/* ── host rule ─────────────────────────────────────────────────────────── */
|
|
71
|
+
function bareHost(host) {
|
|
72
|
+
return host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/[/?#].*$/, '').replace(/:\d+$/, '')
|
|
73
|
+
.replace(/^\[|\]$/g, '').toLowerCase();
|
|
74
|
+
}
|
|
75
|
+
async function addressesOf(host, lookup) {
|
|
76
|
+
if (isIP(host))
|
|
77
|
+
return [host];
|
|
78
|
+
try {
|
|
79
|
+
return (await lookup(host)).map((a) => a.toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function matchDeployTarget(yamlUrl, yamlClient, system, lookup) {
|
|
86
|
+
const aliasHost = bareHost(system.host);
|
|
87
|
+
const aliasScheme = system.useSsl === false ? 'http:' : 'https:';
|
|
88
|
+
const aliasUrl = `${aliasScheme}//${aliasHost.includes(':') ? `[${aliasHost}]` : aliasHost}:${system.port}`;
|
|
89
|
+
let u;
|
|
90
|
+
try {
|
|
91
|
+
u = new URL(yamlUrl);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { ok: false, error: `target.url "${yamlUrl}" is not a URL` };
|
|
95
|
+
}
|
|
96
|
+
if (u.username || u.password)
|
|
97
|
+
return { ok: false, error: 'target.url must not carry a user or password' };
|
|
98
|
+
if (u.protocol !== aliasScheme) {
|
|
99
|
+
return { ok: false, error: `target.url uses ${u.protocol.replace(':', '')}, but alias ${aliasUrl} uses ${aliasScheme.replace(':', '')} — refusing to send the SAP logon` };
|
|
100
|
+
}
|
|
101
|
+
const yamlPort = u.port !== '' ? Number(u.port) : u.protocol === 'https:' ? 443 : 80;
|
|
102
|
+
if (yamlPort !== Number(system.port)) {
|
|
103
|
+
return { ok: false, error: `target.url port ${yamlPort} is not the alias port ${system.port} — refusing to send the SAP logon` };
|
|
104
|
+
}
|
|
105
|
+
if (String(yamlClient).trim() !== String(system.client).trim()) {
|
|
106
|
+
return { ok: false, error: `target client ${yamlClient} is not the alias client ${system.client} — deploy with the alias for that client, or fix the yaml` };
|
|
107
|
+
}
|
|
108
|
+
const yamlHost = bareHost(u.hostname);
|
|
109
|
+
if (yamlHost === aliasHost)
|
|
110
|
+
return { ok: true, aliasUrl };
|
|
111
|
+
const aliasAddrs = new Set(await addressesOf(aliasHost, lookup));
|
|
112
|
+
const yamlAddrs = await addressesOf(yamlHost, lookup);
|
|
113
|
+
if (aliasAddrs.size > 0 && yamlAddrs.length > 0 && yamlAddrs.every((a) => aliasAddrs.has(a))) {
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
aliasUrl,
|
|
117
|
+
note: `ui5-deploy.yaml names ${yamlHost}; it resolves to the alias host ${aliasHost}, so the deploy goes to ${aliasUrl}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: `ui5-deploy.yaml targets ${yamlHost}:${yamlPort}, which is not the system of this SAP alias (${aliasUrl}` +
|
|
123
|
+
`${aliasAddrs.size > 0 ? `, ${[...aliasAddrs].join(', ')}` : ''}). Refusing to send the SAP logon there. ` +
|
|
124
|
+
'Deploy with the alias for that system, or rewrite the deploy config with fiori_deploy_config for this one.',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/* ── helpers ───────────────────────────────────────────────────────────── */
|
|
128
|
+
/** Variables the app's .env could set that would redirect or weaken the connection. dotenv runs inside fiori deploy. */
|
|
129
|
+
const DOTENV_DANGER = /^\s*(?:export\s+)?(HTTPS?_PROXY|ALL_PROXY|NO_PROXY|NODE_TLS_REJECT_UNAUTHORIZED|NODE_EXTRA_CA_CERTS|NODE_OPTIONS|NODE_USE_ENV_PROXY|NO_RETRY|H2O_URL|WS_BASE_URL|FIORI_TOOLS_\w+|SAP_UX_\w+|SERVICE_\w+)(?:\s*=|:\s)/im;
|
|
130
|
+
/**
|
|
131
|
+
* The child environment is an ALLOWLIST (the fiori_preview stance, preview/env.ts):
|
|
132
|
+
* what Node, npm and the Windows shell need to run, the locale, and the
|
|
133
|
+
* proxy settings a corporate network needs to reach SAP — nothing else. A
|
|
134
|
+
* parent GITHUB_TOKEN, AWS key, ANTHROPIC_API_KEY, CSPEACH_* token or
|
|
135
|
+
* NODE_OPTIONS never reaches project code. Names are compared upper-cased:
|
|
136
|
+
* Windows spells `Path` or `PATH` depending on the shell, and POSIX tools use
|
|
137
|
+
* both `http_proxy` and `HTTP_PROXY`.
|
|
138
|
+
*/
|
|
139
|
+
const ENV_ALLOW = new Set([
|
|
140
|
+
'PATH', 'PATHEXT', 'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR', 'COMSPEC',
|
|
141
|
+
'TEMP', 'TMP', 'TMPDIR', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'HOMEDRIVE', 'HOMEPATH',
|
|
142
|
+
'PROGRAMDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE', 'OS',
|
|
143
|
+
'LANG', 'LANGUAGE', 'TZ',
|
|
144
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'ALL_PROXY', 'NODE_USE_ENV_PROXY',
|
|
145
|
+
// npm settings needed to resolve and cache — never npm auth (npm_config__auth*, //registry:_authToken).
|
|
146
|
+
'NPM_CONFIG_CACHE', 'NPM_CONFIG_PREFIX', 'NPM_CONFIG_USERCONFIG', 'NPM_CONFIG_GLOBALCONFIG',
|
|
147
|
+
]);
|
|
148
|
+
function allowedEnvKey(name) {
|
|
149
|
+
const n = name.toUpperCase();
|
|
150
|
+
return ENV_ALLOW.has(n) || /^LC_[A-Z]+$/.test(n);
|
|
151
|
+
}
|
|
152
|
+
/** Case-insensitive lookup (Windows env names are case-insensitive; POSIX tools use both cases for proxies). */
|
|
153
|
+
function lookupEnv(env, name) {
|
|
154
|
+
const exact = env[name];
|
|
155
|
+
if (exact !== undefined)
|
|
156
|
+
return exact;
|
|
157
|
+
const hit = Object.keys(env).find((k) => k.toUpperCase() === name);
|
|
158
|
+
return hit === undefined ? undefined : env[hit];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Set `name` to `value`, removing any other spelling first. On POSIX the
|
|
162
|
+
* lower-case form is set too for the proxy variables (axios reads the
|
|
163
|
+
* lower-case one first there; on Windows the names are one variable).
|
|
164
|
+
*/
|
|
165
|
+
function pinEnv(env, name, value, bothCases = false) {
|
|
166
|
+
for (const k of Object.keys(env))
|
|
167
|
+
if (k.toUpperCase() === name)
|
|
168
|
+
delete env[k];
|
|
169
|
+
env[name] = value;
|
|
170
|
+
if (bothCases && process.platform !== 'win32')
|
|
171
|
+
env[name.toLowerCase()] = value;
|
|
172
|
+
}
|
|
173
|
+
const PROXY_VARS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'ALL_PROXY'];
|
|
174
|
+
/**
|
|
175
|
+
* Review 2026-10-03 — the tooling runs dotenv on the app's .env, and dotenv
|
|
176
|
+
* never overrides a variable that is already SET. So every variable that
|
|
177
|
+
* could redirect or weaken the connection carrying the logon is set here
|
|
178
|
+
* explicitly — to the parent's value (proxies) or a safe fixed value — and a
|
|
179
|
+
* .env line for it can no longer take effect.
|
|
180
|
+
*/
|
|
181
|
+
const PINNED = [
|
|
182
|
+
['NODE_TLS_REJECT_UNAUTHORIZED', '1'], // any value but '0' keeps Node's verification on
|
|
183
|
+
['NODE_OPTIONS', ''],
|
|
184
|
+
['H2O_URL', ''], // the tooling's App Studio switches (destination / BAS paths)
|
|
185
|
+
['WS_BASE_URL', ''],
|
|
186
|
+
['FIORI_TOOLS_USER', ''], // the explicit --username/--password win; never a stray login
|
|
187
|
+
['FIORI_TOOLS_PASSWORD', ''],
|
|
188
|
+
['FIORI_TOOLS_DISABLE_SECURE_STORE', 'true'], // never read or write the Fiori tools' own credential store
|
|
189
|
+
['SAP_UX_FIORI_TOOLS_DISABLE_TELEMETRY', 'true'],
|
|
190
|
+
['NO_RETRY', '1'], // no 401 re-prompt, no auto-confirmed 412 overwrite (with --failfast)
|
|
191
|
+
];
|
|
192
|
+
function childEnv(base, caCertPath) {
|
|
193
|
+
const env = {};
|
|
194
|
+
for (const [k, v] of Object.entries(base))
|
|
195
|
+
if (allowedEnvKey(k))
|
|
196
|
+
env[k] = v;
|
|
197
|
+
for (const name of PROXY_VARS)
|
|
198
|
+
pinEnv(env, name, lookupEnv(base, name) ?? '', true);
|
|
199
|
+
pinEnv(env, 'NODE_USE_ENV_PROXY', lookupEnv(base, 'NODE_USE_ENV_PROXY') ?? '');
|
|
200
|
+
for (const [name, value] of PINNED)
|
|
201
|
+
pinEnv(env, name, value);
|
|
202
|
+
for (const k of Object.keys(env))
|
|
203
|
+
if (k.toUpperCase() === 'NODE_EXTRA_CA_CERTS')
|
|
204
|
+
delete env[k];
|
|
205
|
+
if (caCertPath)
|
|
206
|
+
env.NODE_EXTRA_CA_CERTS = caCertPath;
|
|
207
|
+
return env;
|
|
208
|
+
}
|
|
209
|
+
async function readGuardedInputs(appDir) {
|
|
210
|
+
let yamlText;
|
|
211
|
+
try {
|
|
212
|
+
const real = await fs.realpath(path.join(appDir, 'ui5-deploy.yaml'));
|
|
213
|
+
const realApp = await fs.realpath(appDir);
|
|
214
|
+
const rel = path.relative(realApp, real);
|
|
215
|
+
if (rel.startsWith('..') || path.isAbsolute(rel))
|
|
216
|
+
return { ok: false, error: 'ui5-deploy.yaml links outside the app directory' };
|
|
217
|
+
yamlText = await fs.readFile(real, 'utf8');
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return { ok: false, error: `no ui5-deploy.yaml in ${appDir} — write it first with fiori_deploy_config (BSP name, package, transport)` };
|
|
221
|
+
}
|
|
222
|
+
let dotenvText = null;
|
|
223
|
+
try {
|
|
224
|
+
dotenvText = await fs.readFile(path.join(appDir, '.env'), 'utf8');
|
|
225
|
+
}
|
|
226
|
+
catch { /* no .env */ }
|
|
227
|
+
if (dotenvText !== null) {
|
|
228
|
+
const m = DOTENV_DANGER.exec(dotenvText);
|
|
229
|
+
if (m) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: `the app's .env sets ${m[1]}, which fiori deploy would load and which can redirect or weaken the connection that carries the SAP logon — remove that line from ${path.join(appDir, '.env')}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, yamlText, dotenvText };
|
|
237
|
+
}
|
|
238
|
+
async function findTooling(appDir, projectRoot) {
|
|
239
|
+
const stop = path.resolve(projectRoot);
|
|
240
|
+
let dir = path.resolve(appDir);
|
|
241
|
+
for (;;) {
|
|
242
|
+
const pkgDir = path.join(dir, 'node_modules', '@sap', 'ux-ui5-tooling');
|
|
243
|
+
try {
|
|
244
|
+
const pkg = JSON.parse(await fs.readFile(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
245
|
+
const bin = path.join(pkgDir, 'bin', 'fiori.cjs');
|
|
246
|
+
if (pkg.name === '@sap/ux-ui5-tooling') {
|
|
247
|
+
await fs.access(bin);
|
|
248
|
+
return bin;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch { /* not here */ }
|
|
252
|
+
if (dir === stop)
|
|
253
|
+
return null;
|
|
254
|
+
const parent = path.dirname(dir);
|
|
255
|
+
const rel = path.relative(stop, parent);
|
|
256
|
+
if (parent === dir || rel.startsWith('..') || path.isAbsolute(rel))
|
|
257
|
+
return null;
|
|
258
|
+
dir = parent;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const HEAD_CAP = 20_000;
|
|
262
|
+
const TAIL_CAP = 180_000;
|
|
263
|
+
/**
|
|
264
|
+
* The child's output, bounded: the first ~20k characters and a rolling last
|
|
265
|
+
* ~180k, both cut at line ends (so a secret on one line is never split by the
|
|
266
|
+
* cut and stays scrubbable), so the tooling's final success/failure line is
|
|
267
|
+
* never lost however chatty the run was.
|
|
268
|
+
*/
|
|
269
|
+
class CappedOutput {
|
|
270
|
+
head = '';
|
|
271
|
+
tail = '';
|
|
272
|
+
headFull = false;
|
|
273
|
+
truncated = false;
|
|
274
|
+
push(chunk) {
|
|
275
|
+
if (!this.headFull) {
|
|
276
|
+
this.head += chunk;
|
|
277
|
+
if (this.head.length > HEAD_CAP) {
|
|
278
|
+
const cut = this.head.lastIndexOf('\n', HEAD_CAP);
|
|
279
|
+
const at = cut >= 0 ? cut + 1 : HEAD_CAP;
|
|
280
|
+
this.tail = this.head.slice(at);
|
|
281
|
+
this.head = this.head.slice(0, at);
|
|
282
|
+
this.headFull = true;
|
|
283
|
+
this.trim();
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
this.tail += chunk;
|
|
288
|
+
this.trim();
|
|
289
|
+
}
|
|
290
|
+
trim() {
|
|
291
|
+
if (this.tail.length <= TAIL_CAP)
|
|
292
|
+
return;
|
|
293
|
+
const excess = this.tail.length - TAIL_CAP;
|
|
294
|
+
const nlAt = this.tail.indexOf('\n', excess);
|
|
295
|
+
this.tail = nlAt >= 0 ? this.tail.slice(nlAt + 1) : this.tail.slice(excess);
|
|
296
|
+
this.truncated = true;
|
|
297
|
+
}
|
|
298
|
+
text() {
|
|
299
|
+
return this.truncated ? `${this.head}\n…(output truncated)…\n${this.tail}` : this.head + this.tail;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function runChild(spawn, cmd, args, opts, timeoutMs, kill) {
|
|
303
|
+
return new Promise((resolve) => {
|
|
304
|
+
const out = new CappedOutput();
|
|
305
|
+
let child;
|
|
306
|
+
try {
|
|
307
|
+
// detached on POSIX so the timeout can signal the whole process group (npm → node → workers).
|
|
308
|
+
child = spawn(cmd, args, {
|
|
309
|
+
...opts, windowsHide: true, shell: false, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'],
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
catch (e) {
|
|
313
|
+
resolve({ code: null, signal: null, out: '', timedOut: false, spawnError: e instanceof Error ? e.message : String(e) });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
let done = false;
|
|
317
|
+
let timedOut = false;
|
|
318
|
+
const timer = setTimeout(() => {
|
|
319
|
+
timedOut = true;
|
|
320
|
+
// The whole tree: taskkill /T /F on Windows, the process group on POSIX.
|
|
321
|
+
void kill(child.pid, child).catch(() => { try {
|
|
322
|
+
child.kill('SIGKILL');
|
|
323
|
+
}
|
|
324
|
+
catch { /* gone */ } });
|
|
325
|
+
}, timeoutMs);
|
|
326
|
+
// Decode as UTF-8 per stream, so a multibyte character (or password) split
|
|
327
|
+
// across two chunks is reassembled before anything is scrubbed.
|
|
328
|
+
child.stdout?.setEncoding?.('utf8');
|
|
329
|
+
child.stderr?.setEncoding?.('utf8');
|
|
330
|
+
child.stdout?.on('data', (c) => out.push(String(c)));
|
|
331
|
+
child.stderr?.on('data', (c) => out.push(String(c)));
|
|
332
|
+
const finish = (r) => {
|
|
333
|
+
if (done)
|
|
334
|
+
return;
|
|
335
|
+
done = true;
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
resolve({ ...r, out: out.text() });
|
|
338
|
+
};
|
|
339
|
+
child.on('error', (e) => finish({ code: null, signal: null, timedOut, spawnError: e.message }));
|
|
340
|
+
child.on('close', (code, signal) => finish({ code, signal: signal ?? null, timedOut }));
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function tail(text, lines = 30) {
|
|
344
|
+
return text.split(/\r?\n/).filter((l) => l.trim().length > 0).slice(-lines).join('\n');
|
|
345
|
+
}
|
|
346
|
+
/** The tooling's own success lines (@sap-ux/deploy-tooling, 1.25.0). */
|
|
347
|
+
/** The tooling's 412 warning; seen after all the checks above it is a failure, never a prompt to confirm. */
|
|
348
|
+
const DIFFERENT_APP_412 = /different sap app id/i;
|
|
349
|
+
const SUCCESS = {
|
|
350
|
+
deploy: /Deployment Successful\./,
|
|
351
|
+
test: /Deployment in TestMode completed\./,
|
|
352
|
+
};
|
|
353
|
+
/* ── the run ───────────────────────────────────────────────────────────── */
|
|
354
|
+
export async function runFioriDeploy(input, deps = {}) {
|
|
355
|
+
const refuse = (error) => ({ ok: false, stage: 'refused', error });
|
|
356
|
+
const log = deps.log ?? (() => { });
|
|
357
|
+
const notes = [];
|
|
358
|
+
// ── 1. The alias ───────────────────────────────────────────────────────
|
|
359
|
+
let cfg;
|
|
360
|
+
try {
|
|
361
|
+
cfg = await (deps.loadConfig ?? (async () => (await import('../../../config/loader.js')).loadConfig()))();
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
return refuse(`could not read the CSPeach config — ${e instanceof Error ? e.message : String(e)}`);
|
|
365
|
+
}
|
|
366
|
+
const system = cfg.sap?.[input.alias];
|
|
367
|
+
if (!system)
|
|
368
|
+
return refuse(`unknown SAP alias "${input.alias}" — configure it first`);
|
|
369
|
+
if (system.authType !== 'basic')
|
|
370
|
+
return refuse(`alias ${input.alias} does not use a user + password logon — fiori_deploy supports basic logon only`);
|
|
371
|
+
if (system.role === 'prd') {
|
|
372
|
+
return refuse(`alias ${input.alias} is tagged as a production system (role prd) — CSPeach does not deploy there. Deploy in development and move the app with the transport.`);
|
|
373
|
+
}
|
|
374
|
+
// ── 2. The deploy config (untrusted file) ──────────────────────────────
|
|
375
|
+
const before = await readGuardedInputs(input.appDir);
|
|
376
|
+
if (!before.ok)
|
|
377
|
+
return refuse(before.error);
|
|
378
|
+
const read = readDeployYaml(before.yamlText);
|
|
379
|
+
if (!read.ok)
|
|
380
|
+
return refuse(read.error);
|
|
381
|
+
const y = read.config;
|
|
382
|
+
let pkg;
|
|
383
|
+
let isTmp;
|
|
384
|
+
try {
|
|
385
|
+
({ package: pkg, isTmp } = validateDeployTarget({ appName: y.name, package: y.package, transport: y.transport }));
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
return refuse(`ui5-deploy.yaml is not valid — ${e instanceof Error ? e.message : String(e)}`);
|
|
389
|
+
}
|
|
390
|
+
if (!/^([ZY][A-Z0-9_]*|\/[A-Z0-9_]+\/[A-Z0-9_]+)$/.test(y.name)) {
|
|
391
|
+
return refuse(`BSP app name "${y.name}" must be in the customer namespace (Z…/Y… or /NAMESPACE/NAME)`);
|
|
392
|
+
}
|
|
393
|
+
const bsp = y.name;
|
|
394
|
+
// ── 3. Host rule ───────────────────────────────────────────────────────
|
|
395
|
+
const lookup = deps.lookupHost ?? (async (h) => (await dnsLookup(h, { all: true })).map((a) => a.address));
|
|
396
|
+
const match = await matchDeployTarget(y.url, y.client, system, lookup);
|
|
397
|
+
if (!match.ok)
|
|
398
|
+
return refuse(match.error);
|
|
399
|
+
if (match.note)
|
|
400
|
+
notes.push(match.note);
|
|
401
|
+
// ── 3b. TLS: the yaml cannot switch certificate checking off when the alias verifies.
|
|
402
|
+
// The tooling turns ignoreCertErrors: true into --no-strict-ssl; --strict-ssl true is read first.
|
|
403
|
+
const tls = resolveSapTls(system);
|
|
404
|
+
const strictSsl = tls.mode !== 'insecure';
|
|
405
|
+
if (strictSsl && y.ignoreCertErrors) {
|
|
406
|
+
notes.push('ignoreCertErrors: true in ui5-deploy.yaml was overridden — certificate verification stays ON for this deploy ' +
|
|
407
|
+
`(alias ${input.alias} verifies certificates${tls.caCertPath ? ` with ${tls.caCertPath}` : ''}); set it to false in the yaml`);
|
|
408
|
+
}
|
|
409
|
+
if (!strictSsl) {
|
|
410
|
+
notes.push(y.ignoreCertErrors
|
|
411
|
+
? `certificate verification is OFF for this deploy (alias ${input.alias} is in insecure mode and the yaml sets ignoreCertErrors: true) — set sap.${input.alias}.ca_cert_path to a PEM root CA instead`
|
|
412
|
+
: `alias ${input.alias} is in insecure mode; this deploy still verifies the certificate against the system trust store (ignoreCertErrors: false in the yaml)`);
|
|
413
|
+
}
|
|
414
|
+
// ── 4. Write gate (deploy mode) ────────────────────────────────────────
|
|
415
|
+
const howToApprove = (object) => `call request_approval with a change {op:"create", object:"${object}", type:"WAPA", package:"${pkg}"` +
|
|
416
|
+
`${isTmp ? '' : `, transport "${y.transport}"`}} and pass the returned id as approval_id`;
|
|
417
|
+
if (input.mode === 'deploy') {
|
|
418
|
+
const effective = (deps.effectiveWriteMode ?? (await import('../../../repl/mode-cycle.js')).getEffectiveWriteMode)(cfg.write_mode ?? 'approval-gated', system.role);
|
|
419
|
+
if (effective === 'advisory-only') {
|
|
420
|
+
return refuse(`write mode is advisory-only for alias ${input.alias} — the deploy is a SAP write, so CSPeach will not run it. Run mode "test", or deploy by hand with the plan above.`);
|
|
421
|
+
}
|
|
422
|
+
if (!input.approvalId || input.approvalId.trim() === '')
|
|
423
|
+
return refuse(`approval_id is required for the real deploy — ${howToApprove(bsp)}.`);
|
|
424
|
+
}
|
|
425
|
+
// ── 5. Tooling + build (no logon involved yet) ─────────────────────────
|
|
426
|
+
const fioriBin = await findTooling(input.appDir, input.projectRoot);
|
|
427
|
+
if (!fioriBin) {
|
|
428
|
+
return refuse('@sap/ux-ui5-tooling is not installed in this app (node_modules/@sap/ux-ui5-tooling) — run "npm install" in the app directory first');
|
|
429
|
+
}
|
|
430
|
+
const spawn = deps.spawn ?? crossSpawn;
|
|
431
|
+
const kill = deps.kill ?? killProcessTree;
|
|
432
|
+
const baseEnv = childEnv(deps.baseEnv ?? process.env, tls.mode === 'ca-trusted' ? tls.caCertPath : undefined);
|
|
433
|
+
// ── 6. The logon — keychain only, never a prompt (read before the build, so a missing one fails fast)
|
|
434
|
+
let password;
|
|
435
|
+
try {
|
|
436
|
+
const { resolveSapPassword } = await import('../../../sap/connection-manager.js');
|
|
437
|
+
password = await resolveSapPassword(input.alias, system, {
|
|
438
|
+
headless: () => true,
|
|
439
|
+
...(deps.getPassword ? { getPassword: deps.getPassword } : {}),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return refuse(`no stored password for SAP alias '${input.alias}' — the deploy uses the logon CSPeach keeps in the OS credential store and cannot prompt. ` +
|
|
444
|
+
'Store it once (run cspeach interactively and connect, or use cspeach config) and try again.');
|
|
445
|
+
}
|
|
446
|
+
const username = system.username;
|
|
447
|
+
const scrub = (t) => scrubSecrets(t, password, username);
|
|
448
|
+
const fetchSap = deps.fetchSap ?? (async (alias, p) => {
|
|
449
|
+
const { getConnection } = await import('../../../sap/connection-manager.js');
|
|
450
|
+
const conn = await getConnection(alias, system);
|
|
451
|
+
const r = await conn.get(p, 'application/json, text/html, */*');
|
|
452
|
+
return { status: r.status, body: r.body };
|
|
453
|
+
});
|
|
454
|
+
// ── 6b. Existing BSP pre-check (read-only): never overwrite another app silently
|
|
455
|
+
const existing = await readExistingBsp(bsp, (p) => fetchSap(input.alias, p));
|
|
456
|
+
if (existing.kind === 'unknown') {
|
|
457
|
+
return refuse(`could not check whether BSP ${bsp} already exists — ${scrub(existing.reason)}. CSPeach does not deploy without that check (it would risk overwriting another app).`);
|
|
458
|
+
}
|
|
459
|
+
let approvalObject = bsp;
|
|
460
|
+
if (existing.kind === 'existing') {
|
|
461
|
+
let localId = null;
|
|
462
|
+
try {
|
|
463
|
+
const id = JSON.parse(await fs.readFile(path.join(input.appDir, 'webapp', 'manifest.json'), 'utf8'))['sap.app']?.id;
|
|
464
|
+
if (typeof id === 'string' && id.trim() !== '')
|
|
465
|
+
localId = id.trim();
|
|
466
|
+
}
|
|
467
|
+
catch { /* unreadable — handled below */ }
|
|
468
|
+
if (!localId) {
|
|
469
|
+
return refuse(`BSP ${bsp} already exists on the system, and this app's webapp/manifest.json has no readable sap.app.id to compare it with — fix the manifest first.`);
|
|
470
|
+
}
|
|
471
|
+
const sameApp = existing.appId !== null && existing.appId === localId;
|
|
472
|
+
if (!sameApp) {
|
|
473
|
+
approvalObject = overwriteApprovalObject(bsp, existing.appId);
|
|
474
|
+
const held = existing.appId
|
|
475
|
+
? `holds a different app (app id ${existing.appId})`
|
|
476
|
+
: 'holds an app whose app id could not be read (no readable manifest.json)';
|
|
477
|
+
const where = existing.package ? ` in package ${existing.package}` : '';
|
|
478
|
+
const desc = existing.description ? ` — "${existing.description}"` : '';
|
|
479
|
+
const why = `BSP ${bsp} already exists${where} and ${held}${desc}; this app is ${localId}. Deploying would overwrite it — it may belong to another team. ` +
|
|
480
|
+
`Choose another BSP name with fiori_deploy_config, or, if replacing it is intended, run mode "deploy" with an approval that names the overwrite: ${howToApprove(approvalObject)}.`;
|
|
481
|
+
if (input.mode === 'test')
|
|
482
|
+
return refuse(why);
|
|
483
|
+
notes.push(`overwrites the existing BSP ${bsp} (app id ${existing.appId ?? 'unknown'}) — approved explicitly`);
|
|
484
|
+
if (input.approvalId === undefined)
|
|
485
|
+
return refuse(why);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (input.mode === 'deploy') {
|
|
489
|
+
const verify = deps.verifyApproval ?? (await import('../../../approvals/jwt.js')).verifyAndSpendApprovalId;
|
|
490
|
+
const v = await verify(input.approvalId ?? '', approvalObject, 'modify');
|
|
491
|
+
if (!v.ok)
|
|
492
|
+
return refuse(`APPROVAL_INVALID: ${v.reason} — ${howToApprove(approvalObject)}.`);
|
|
493
|
+
if (String(v.payload.type ?? '').trim().toUpperCase() !== 'WAPA') {
|
|
494
|
+
return refuse(`APPROVAL_INVALID: the approval is for type ${v.payload.type}, not a BSP application (WAPA) — ${howToApprove(approvalObject)}.`);
|
|
495
|
+
}
|
|
496
|
+
if (v.matchKind === 'list_member') {
|
|
497
|
+
return refuse(`APPROVAL_INVALID: the approval lists several objects; a deploy needs an approval for exactly this BSP — ${howToApprove(approvalObject)}.`);
|
|
498
|
+
}
|
|
499
|
+
// A real deploy writes to SAP: it needs an approval a human saw on screen,
|
|
500
|
+
// never one minted by auto_approve (as cap_link_smoke's write leg).
|
|
501
|
+
if (v.payload.auto) {
|
|
502
|
+
return refuse(`the real deploy needs an approval you confirmed on screen, not an auto-approval — ${howToApprove(approvalObject)}.`);
|
|
503
|
+
}
|
|
504
|
+
if (deployApprovalsUsed.has(v.payload.nonce)) {
|
|
505
|
+
return refuse(`this approval_id already ran one deploy — each deploy needs its own approval; ${howToApprove(approvalObject)}.`);
|
|
506
|
+
}
|
|
507
|
+
// Spent at verify: a build failure below does not free it.
|
|
508
|
+
deployApprovalsUsed.add(v.payload.nonce);
|
|
509
|
+
}
|
|
510
|
+
let scripts = {};
|
|
511
|
+
try {
|
|
512
|
+
scripts = JSON.parse(await fs.readFile(path.join(input.appDir, 'package.json'), 'utf8')).scripts ?? {};
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
return refuse(`could not read ${path.join(input.appDir, 'package.json')}`);
|
|
516
|
+
}
|
|
517
|
+
if (typeof scripts.build === 'string') {
|
|
518
|
+
const npm = await (deps.resolveNpm ?? (async () => (await import('../../_command-shared.js')).resolveExecutable('npm')))();
|
|
519
|
+
if (!npm)
|
|
520
|
+
return refuse('npm is not on PATH — needed to run the app\'s "build" script before the deploy');
|
|
521
|
+
log(`fiori_deploy: building ${input.appDir} (npm run build)`);
|
|
522
|
+
const b = await runChild(spawn, npm, ['run', 'build', '--ignore-scripts'], { cwd: input.appDir, env: baseEnv }, deps.buildTimeoutMs ?? 300_000, kill);
|
|
523
|
+
if (b.spawnError || b.timedOut || b.code !== 0) {
|
|
524
|
+
const why = b.spawnError ? `could not start npm — ${b.spawnError}` : b.timedOut ? 'timed out' : `exit code ${b.code}`;
|
|
525
|
+
return { ok: false, stage: 'build', error: `npm run build failed (${why}); nothing was sent to SAP.\n${tail(b.out)}` };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
try {
|
|
530
|
+
await fs.access(path.join(input.appDir, 'dist'));
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return refuse('the app has no "build" script and no dist/ folder — build the app first');
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// ── 6c. TOCTOU: the build ran project code in the app folder. Re-read what
|
|
537
|
+
// fiori deploy will read, right before it starts; any change is refused.
|
|
538
|
+
const after = await readGuardedInputs(input.appDir);
|
|
539
|
+
if (!after.ok)
|
|
540
|
+
return refuse(after.error);
|
|
541
|
+
if (after.yamlText !== before.yamlText) {
|
|
542
|
+
return refuse('ui5-deploy.yaml changed while the app was being built — nothing was sent. Check it, then run fiori_deploy again.');
|
|
543
|
+
}
|
|
544
|
+
if (after.dotenvText !== before.dotenvText) {
|
|
545
|
+
return refuse("the app's .env changed while the app was being built — nothing was sent. Check it, then run fiori_deploy again.");
|
|
546
|
+
}
|
|
547
|
+
// ── 7. Deploy child ────────────────────────────────────────────────────
|
|
548
|
+
const suffix = (deps.varSuffix ?? (() => randomBytes(8).toString('hex').toUpperCase()))();
|
|
549
|
+
const userVar = `CSP_DEPLOY_U_${suffix}`;
|
|
550
|
+
const passVar = `CSP_DEPLOY_P_${suffix}`;
|
|
551
|
+
// NO_RETRY (pinned in childEnv) + --failfast switch the tooling's retry handler off: no 401 re-prompt,
|
|
552
|
+
// and no auto-confirmed HTTP 412 "different sap app id" overwrite (with --yes it would overwrite silently).
|
|
553
|
+
const env = { ...baseEnv, [userVar]: username, [passVar]: password };
|
|
554
|
+
const args = [
|
|
555
|
+
fioriBin, 'deploy',
|
|
556
|
+
'--config', 'ui5-deploy.yaml',
|
|
557
|
+
'--url', match.aliasUrl,
|
|
558
|
+
'--client', String(system.client),
|
|
559
|
+
'--name', bsp,
|
|
560
|
+
'--package', pkg,
|
|
561
|
+
...(isTmp ? [] : ['--transport', y.transport]),
|
|
562
|
+
...(input.mode === 'test' ? ['--testMode'] : []),
|
|
563
|
+
...(strictSsl ? ['--strict-ssl', 'true'] : []),
|
|
564
|
+
'--yes',
|
|
565
|
+
'--failfast',
|
|
566
|
+
'--username', userVar,
|
|
567
|
+
'--password', passVar,
|
|
568
|
+
];
|
|
569
|
+
log(`fiori_deploy: ${input.mode === 'test' ? 'test-mode deploy (nothing is written)' : 'deploying'} ${bsp} to ${match.aliasUrl} client ${system.client}`);
|
|
570
|
+
const d = await runChild(spawn, deps.nodePath ?? process.execPath, args, { cwd: input.appDir, env }, deps.deployTimeoutMs ?? 600_000, kill);
|
|
571
|
+
const output = scrub(d.out);
|
|
572
|
+
const okLine = SUCCESS[input.mode].test(output) && !/Deployment has failed/i.test(output) && !DIFFERENT_APP_412.test(output);
|
|
573
|
+
if (d.spawnError || d.timedOut || d.code !== 0 || !okLine) {
|
|
574
|
+
const why = d.spawnError ? `could not start fiori deploy — ${scrub(d.spawnError)}` : d.timedOut ? 'timed out' : d.code !== 0 ? `exit code ${d.code}` : 'no success line in the output';
|
|
575
|
+
return {
|
|
576
|
+
ok: false,
|
|
577
|
+
stage: 'deploy',
|
|
578
|
+
error: `fiori deploy ${input.mode === 'test' ? '(test mode) ' : ''}failed for ${bsp} (${why}). Backend output:\n${tail(output) || '(no output)'}`,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
if (input.mode === 'test') {
|
|
582
|
+
return {
|
|
583
|
+
ok: true, mode: 'test', bsp, output, notes,
|
|
584
|
+
summary: `Test mode passed for ${bsp} → package ${pkg}${isTmp ? '' : `, transport ${y.transport}`} on ${match.aliasUrl} client ${system.client}. The backend validated the upload; nothing was deployed.`,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
// ── 8. Verify (Forge Rule 10) ──────────────────────────────────────────
|
|
588
|
+
const sapPath = bspFilePath(bsp, 'index.html');
|
|
589
|
+
const url = `${match.aliasUrl}${sapPath}?sap-client=${system.client}`;
|
|
590
|
+
let status;
|
|
591
|
+
try {
|
|
592
|
+
status = (await fetchSap(input.alias, sapPath)).status;
|
|
593
|
+
}
|
|
594
|
+
catch (e) {
|
|
595
|
+
return { ok: false, stage: 'verify', error: `deployed ${bsp}, but the check GET ${url} failed — ${scrub(e instanceof Error ? e.message : String(e))}` };
|
|
596
|
+
}
|
|
597
|
+
if (status !== 200) {
|
|
598
|
+
return {
|
|
599
|
+
ok: false,
|
|
600
|
+
stage: 'verify',
|
|
601
|
+
error: `deployed ${bsp}, but GET ${url} answered HTTP ${status} (expected 200). A 403/404 usually means the SICF node /sap/bc/ui5_ui5 needs activating or the upload was rejected — deploy output:\n${tail(output, 15)}`,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
ok: true, mode: 'deploy', bsp, url, output, notes,
|
|
606
|
+
summary: `Deployed ${bsp} → package ${pkg}${isTmp ? '' : `, transport ${y.transport}`} on ${match.aliasUrl} client ${system.client}. Verified: GET ${url} → 200.`,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/deploy/scope.ts
|
|
2
|
+
/**
|
|
3
|
+
* The credential-handover scope of fiori_deploy. Its own module so the plan
|
|
4
|
+
* chain's deploy card (commands/deploy-card.ts) can record the approval
|
|
5
|
+
* without importing the tool registry. With a scope, an earlier fiori_preview
|
|
6
|
+
* approval for the same alias does not cover a deploy (and vice versa).
|
|
7
|
+
*/
|
|
8
|
+
export const FIORI_DEPLOY_HANDOVER_SCOPE = 'fiori_deploy';
|