@mcp-abap-adt/proxy 2.0.0 → 4.0.1
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/CHANGELOG.md +218 -0
- package/LICENSE +669 -17
- package/README.md +79 -8
- package/bin/mcp-abap-adt-proxy-mcp.js +113 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +54 -97
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +9 -1
- package/dist/lib/envInterpolation.d.ts +14 -2
- package/dist/lib/envInterpolation.d.ts.map +1 -1
- package/dist/lib/envInterpolation.js +34 -7
- package/dist/lib/stores.d.ts +23 -2
- package/dist/lib/stores.d.ts.map +1 -1
- package/dist/lib/stores.js +56 -1
- package/dist/mcp/cli.d.ts +2 -0
- package/dist/mcp/cli.d.ts.map +1 -0
- package/dist/mcp/cli.js +21 -0
- package/dist/mcp/configs.d.ts +33 -0
- package/dist/mcp/configs.d.ts.map +1 -0
- package/dist/mcp/configs.js +142 -0
- package/dist/mcp/ports.d.ts +15 -0
- package/dist/mcp/ports.d.ts.map +1 -0
- package/dist/mcp/ports.js +35 -0
- package/dist/mcp/registry.d.ts +57 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/registry.js +145 -0
- package/dist/mcp/server.d.ts +34 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +79 -0
- package/dist/mcp/shutdown.d.ts +37 -0
- package/dist/mcp/shutdown.d.ts.map +1 -0
- package/dist/mcp/shutdown.js +82 -0
- package/dist/mcp/supervisor.d.ts +125 -0
- package/dist/mcp/supervisor.d.ts.map +1 -0
- package/dist/mcp/supervisor.js +330 -0
- package/dist/mcp/tools.d.ts +28 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +152 -0
- package/dist/proxy/btpProxy.d.ts +24 -73
- package/dist/proxy/btpProxy.d.ts.map +1 -1
- package/dist/proxy/btpProxy.js +65 -616
- package/dist/proxy/credentials.d.ts +45 -0
- package/dist/proxy/credentials.d.ts.map +1 -0
- package/dist/proxy/credentials.js +41 -0
- package/dist/proxy/requestHandler.d.ts +38 -0
- package/dist/proxy/requestHandler.d.ts.map +1 -0
- package/dist/proxy/requestHandler.js +73 -0
- package/dist/proxy/reverseProxy.d.ts +11 -2
- package/dist/proxy/reverseProxy.d.ts.map +1 -1
- package/dist/proxy/reverseProxy.js +52 -9
- package/dist/router/headerAnalyzer.js +2 -2
- package/dist/router/requestInterceptor.js +9 -9
- package/docs/API.md +172 -0
- package/docs/ARCHITECTURE.md +322 -0
- package/docs/CLIENT_SETUP.md +413 -0
- package/docs/CONFIGURATION.md +258 -0
- package/docs/MIGRATION-4.0.md +125 -0
- package/docs/ROUTING_LOGIC.md +126 -0
- package/docs/TROUBLESHOOTING.md +488 -0
- package/docs/USAGE.md +422 -0
- package/docs/YAML_CONFIG.md +290 -0
- package/docs/mcp-proxy-config.example.yaml +62 -0
- package/package.json +17 -10
- package/dist/proxy/cloudLlmHubProxy.d.ts +0 -2
- package/dist/proxy/cloudLlmHubProxy.d.ts.map +0 -1
- package/dist/proxy/cloudLlmHubProxy.js +0 -3
package/README.md
CHANGED
|
@@ -13,9 +13,10 @@ Enable local MCP clients to connect to remote MCP servers with automatic JWT tok
|
|
|
13
13
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
|
-
- ✅ **
|
|
16
|
+
- ✅ **One shared credential** - the token is the auth-broker's business: it caches, knows expiry and renews behind the call. The proxy holds no token of its own
|
|
17
17
|
- ✅ **Service Key Based** - MCP server URL is obtained from service key for BTP destination
|
|
18
|
-
- ✅ **
|
|
18
|
+
- ✅ **Transparent forwarding** - the client's headers go through as sent; the proxy answers for the `Authorization` header and nothing else
|
|
19
|
+
- ✅ **Error Handling** - token acquisition is retried with exponential backoff when the failure can get better; clear messages when it cannot
|
|
19
20
|
- ✅ **Multiple Transport Modes** - HTTP, SSE, and stdio support
|
|
20
21
|
- ✅ **Configuration Flexibility** - Environment variables, config files, or defaults
|
|
21
22
|
|
|
@@ -40,6 +41,58 @@ mcp-abap-adt-proxy --btp=ai
|
|
|
40
41
|
mcp-abap-adt-proxy --btp=ai --unsafe
|
|
41
42
|
```
|
|
42
43
|
|
|
44
|
+
## Two commands
|
|
45
|
+
|
|
46
|
+
The package installs two binaries, for two different jobs.
|
|
47
|
+
|
|
48
|
+
| Command | What it is for |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `mcp-abap-adt-proxy` | **Be** a proxy. A client points at it and its requests are authenticated and forwarded. |
|
|
51
|
+
| `mcp-abap-adt-proxy-mcp` | **Manage** proxies. Speaks MCP over stdio; its tools start and stop proxies on demand. |
|
|
52
|
+
|
|
53
|
+
### The management mode
|
|
54
|
+
|
|
55
|
+
Register it like any other MCP server:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"abap-proxy": { "command": "mcp-abap-adt-proxy-mcp" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
It works from the proxy configs you already keep:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
~/.config/mcp-abap-adt/proxy/<name>.yaml (Windows: Documents\mcp-abap-adt\proxy\)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
These are the same files `mcp-abap-adt-proxy --config <file>` takes. Starting a
|
|
72
|
+
proxy is therefore **choosing a name**, not assembling settings — and
|
|
73
|
+
credentials stay in the config, resolved through `${VAR}` interpolation, rather
|
|
74
|
+
than travelling through a tool call.
|
|
75
|
+
|
|
76
|
+
| Tool | What it does |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `proxy_configs` | Lists the configs available, by the name `proxy_start` takes. Call it first — the names cannot be guessed. |
|
|
79
|
+
| `proxy_start` | Starts a proxy from one of those configs and returns the URL it bound. The **port is not taken from the config**: a free one is bound instead. |
|
|
80
|
+
| `proxy_stop` | Stops a proxy this session started, freeing its port and releasing its credential. Proxies started by other sessions are never touched. |
|
|
81
|
+
| `proxy_status` | Lists this session's proxies and any others on this machine. Records whose process has died are pruned when read, so it cannot report a ghost. |
|
|
82
|
+
|
|
83
|
+
**The config name is the unit, not the destination.** Several configs commonly
|
|
84
|
+
name the same `btpDestination` and differ in target URL and headers — a
|
|
85
|
+
destination cannot tell them apart.
|
|
86
|
+
|
|
87
|
+
**Every proxy runs inside the management process.** Closing the session — or
|
|
88
|
+
`SIGINT`, or `SIGTERM` — stops all of them and frees their ports. This is
|
|
89
|
+
deliberate: a spawned child would be orphaned by any signal the parent did not
|
|
90
|
+
forward and would go on holding its HTTP and OAuth callback ports.
|
|
91
|
+
|
|
92
|
+
A proxy nobody has used for 30 minutes stops itself. That is a backstop for a
|
|
93
|
+
client that finished and forgot, not a substitute for `proxy_stop` — which is
|
|
94
|
+
why the tool descriptions say so, and say it again beside the URL.
|
|
95
|
+
|
|
43
96
|
### Configuration
|
|
44
97
|
|
|
45
98
|
The proxy supports multiple configuration methods:
|
|
@@ -156,6 +209,8 @@ The proxy uses BTP/XSUAA authentication:
|
|
|
156
209
|
|
|
157
210
|
## Documentation
|
|
158
211
|
|
|
212
|
+
- 🚚 **[Migration to 4.0](./docs/MIGRATION-4.0.md)** — the contracts leave the umbrella, an SSE response streams and carries your headers, `getJwtToken()` is replaced, the circuit breaker is gone
|
|
213
|
+
|
|
159
214
|
- **[Client Setup Guide](./docs/CLIENT_SETUP.md)** - Step-by-step setup for Cline and GitHub Copilot
|
|
160
215
|
- **[Configuration Guide](./docs/CONFIGURATION.md)** - Complete configuration reference
|
|
161
216
|
- **[YAML Configuration Guide](./docs/YAML_CONFIG.md)** - Using YAML/JSON configuration files
|
|
@@ -221,7 +276,6 @@ Create `mcp-proxy-config.json`:
|
|
|
221
276
|
"httpPort": 3001,
|
|
222
277
|
"logLevel": "info",
|
|
223
278
|
"maxRetries": 3,
|
|
224
|
-
"circuitBreakerThreshold": 5,
|
|
225
279
|
"unsafe": false
|
|
226
280
|
}
|
|
227
281
|
```
|
|
@@ -234,10 +288,9 @@ See [Configuration Guide](./docs/CONFIGURATION.md) for complete options.
|
|
|
234
288
|
|
|
235
289
|
## Error Handling & Resilience
|
|
236
290
|
|
|
237
|
-
- **Retry Logic** -
|
|
238
|
-
- **
|
|
239
|
-
- **
|
|
240
|
-
- **Connection Pooling** - Efficient resource management
|
|
291
|
+
- **Retry Logic** - exponential backoff while getting a token, for failures that can get better (5xx, network). A missing service key is not one of them and fails at once, with a message naming the file to create
|
|
292
|
+
- **Token Refresh** - handled by the credential, which is asked per request and renews behind the call
|
|
293
|
+
- **No circuit breaker** - it guarded the buffered forward that 4.0.0 removed. The forwarding path now streams, and there is nowhere to put one without buffering the response again. `circuitBreakerThreshold` and `circuitBreakerTimeout` are still accepted so existing configs load, and do nothing
|
|
241
294
|
- **Request Timeouts** - Configurable timeout handling
|
|
242
295
|
|
|
243
296
|
## Requirements
|
|
@@ -277,7 +330,25 @@ See [ROADMAP.md](./ROADMAP.md) for details.
|
|
|
277
330
|
|
|
278
331
|
## License
|
|
279
332
|
|
|
280
|
-
|
|
333
|
+
**GNU General Public License v3.0 only** (`GPL-3.0-only`).
|
|
334
|
+
Earlier published versions were MIT and stay MIT — a licence change is not
|
|
335
|
+
retroactive.
|
|
336
|
+
|
|
337
|
+
Copyright © 2025–2026 Oleksii Kyslytsia
|
|
338
|
+
|
|
339
|
+
This program is free software: you can redistribute it and/or modify it under the
|
|
340
|
+
terms of the GNU General Public License as published by the Free Software
|
|
341
|
+
Foundation, version 3.
|
|
342
|
+
|
|
343
|
+
It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
|
|
344
|
+
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
|
345
|
+
PURPOSE. See [`LICENSE`](LICENSE) for the full text.
|
|
346
|
+
|
|
347
|
+
**What this means.** Running it, and using it on your own data, carries no
|
|
348
|
+
conditions at all. Distributing it, or a modified version of it, means passing on
|
|
349
|
+
the same freedoms — including the source. This is a finished tool rather than a
|
|
350
|
+
library to build on; the libraries it is built from are LGPL, so they can be
|
|
351
|
+
linked from programs under any licence.
|
|
281
352
|
|
|
282
353
|
## Links
|
|
283
354
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP ABAP ADT Proxy — management mode.
|
|
5
|
+
*
|
|
6
|
+
* Speaks MCP over stdio and exposes tools that start and stop proxies. This is
|
|
7
|
+
* the command a client registers when it wants to MANAGE proxies; a client that
|
|
8
|
+
* wants to BE proxied still points at `mcp-abap-adt-proxy`.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* mcp-abap-adt-proxy-mcp [--config <file>] [--unsafe]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
|
|
17
|
+
function parseArgs() {
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
return {
|
|
20
|
+
help: args.includes('--help') || args.includes('-h'),
|
|
21
|
+
version: args.includes('--version') || args.includes('-v'),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function showHelp() {
|
|
26
|
+
const pkg = require('../package.json');
|
|
27
|
+
console.log(`
|
|
28
|
+
MCP ABAP ADT Proxy — management mode v${pkg.version}
|
|
29
|
+
|
|
30
|
+
Speaks MCP over stdio. Its tools start and stop authenticating proxies; it does
|
|
31
|
+
not proxy anything itself.
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
mcp-abap-adt-proxy-mcp [options]
|
|
35
|
+
|
|
36
|
+
Tools offered to the client:
|
|
37
|
+
proxy_configs List the proxy configs on this machine, by the name
|
|
38
|
+
proxy_start takes. Call this first — the names cannot be
|
|
39
|
+
guessed.
|
|
40
|
+
proxy_start Start a proxy from one of those configs. The config supplies
|
|
41
|
+
the destination, target URL, default headers and timeouts;
|
|
42
|
+
the PORT does not come from it — a free one is bound, so
|
|
43
|
+
several proxies can run at once, and the URL returned is the
|
|
44
|
+
one actually bound.
|
|
45
|
+
proxy_stop Stop a proxy this session started, freeing its port and
|
|
46
|
+
releasing its credential. Proxies belonging to other sessions
|
|
47
|
+
are never touched.
|
|
48
|
+
proxy_status List this session's proxies and any others on this machine.
|
|
49
|
+
Records whose process has died are pruned when read.
|
|
50
|
+
|
|
51
|
+
Where the configs live:
|
|
52
|
+
Unix: ~/.config/mcp-abap-adt/proxy/<name>.yaml
|
|
53
|
+
Windows: %USERPROFILE%\\Documents\\mcp-abap-adt\\proxy\\<name>.yaml
|
|
54
|
+
|
|
55
|
+
The same files 'mcp-abap-adt-proxy --config <file>' takes. Credentials stay
|
|
56
|
+
in them, resolved through \${VAR} interpolation, and are never passed
|
|
57
|
+
through a tool call.
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
--config=<file>, -c Load configuration from a YAML or JSON file
|
|
61
|
+
--env-file=<path> Load a .env file for \${VAR} interpolation
|
|
62
|
+
--unsafe Persist tokens to disk
|
|
63
|
+
--help, -h Show this help message
|
|
64
|
+
--version, -v Show version number
|
|
65
|
+
|
|
66
|
+
Shutting down:
|
|
67
|
+
Every proxy runs in THIS process. Closing the session — or SIGINT, or SIGTERM
|
|
68
|
+
— stops all of them and frees their ports. A proxy nobody has used for 30
|
|
69
|
+
minutes stops itself; that is a backstop, not a substitute for proxy_stop.
|
|
70
|
+
|
|
71
|
+
Registering it with a client:
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"abap-proxy": { "command": "mcp-abap-adt-proxy-mcp" }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
For more information, see: https://github.com/fr0ster/mcp-abap-adt-proxy
|
|
79
|
+
`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function main() {
|
|
83
|
+
const args = parseArgs();
|
|
84
|
+
|
|
85
|
+
if (args.help) {
|
|
86
|
+
showHelp();
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (args.version) {
|
|
91
|
+
console.log(require('../package.json').version);
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const serverPath = path.resolve(__dirname, '../dist/mcp/cli.js');
|
|
96
|
+
|
|
97
|
+
if (!fs.existsSync(serverPath)) {
|
|
98
|
+
process.stderr.write(`[MCP Proxy] ✗ Server not found at: ${serverPath}\n`);
|
|
99
|
+
process.stderr.write(
|
|
100
|
+
`[MCP Proxy] Make sure to build the project with 'npm run build' first.\n`,
|
|
101
|
+
);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Loaded in THIS process, never spawned — the same rule the proxy launcher
|
|
107
|
+
// follows, and it matters more here: the proxies the tools start are
|
|
108
|
+
// listeners in this process, and a spawned child would be orphaned by any
|
|
109
|
+
// signal this launcher did not forward, still holding their ports.
|
|
110
|
+
require(serverPath);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
main();
|
package/dist/index.d.ts
CHANGED
|
@@ -17,6 +17,16 @@ export declare class McpAbapAdtProxyServer {
|
|
|
17
17
|
private config;
|
|
18
18
|
private httpServer?;
|
|
19
19
|
private btpProxy?;
|
|
20
|
+
private builtProxyHandler?;
|
|
21
|
+
/**
|
|
22
|
+
* The request path, shared with the MCP mode. Here an authentication failure
|
|
23
|
+
* is fatal: the process exits so whatever started it can start it again with
|
|
24
|
+
* a credential that works.
|
|
25
|
+
*
|
|
26
|
+
* Built on first use rather than as a field initializer, because `config` is
|
|
27
|
+
* assigned in the constructor and a field initializer runs before that.
|
|
28
|
+
*/
|
|
29
|
+
private get proxyHandler();
|
|
20
30
|
private authExiting;
|
|
21
31
|
constructor(transportConfig?: TransportConfig, configPath?: string);
|
|
22
32
|
/**
|
|
@@ -31,10 +41,6 @@ export declare class McpAbapAdtProxyServer {
|
|
|
31
41
|
* Start HTTP server with request interception
|
|
32
42
|
*/
|
|
33
43
|
private startHttpServer;
|
|
34
|
-
/**
|
|
35
|
-
* Handle proxy request - get JWT token and forward transparently via reverse proxy
|
|
36
|
-
*/
|
|
37
|
-
private handleProxyRequest;
|
|
38
44
|
/**
|
|
39
45
|
* Start SSE server
|
|
40
46
|
*/
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAuBH,OAAO,EAEL,KAAK,eAAe,EAErB,MAAM,0BAA0B,CAAC;AAgElC;;GAEG;AACH,qBAAa,qBAAqB;IAChC,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,MAAM,CAAgC;IAC9C,OAAO,CAAC,UAAU,CAAC,CAAa;IAChC,OAAO,CAAC,QAAQ,CAAC,CAAW;IAE5B,OAAO,CAAC,iBAAiB,CAAC,CAA+C;IAEzE;;;;;;;OAOG;IACH,OAAO,KAAK,YAAY,GAevB;IACD,OAAO,CAAC,WAAW,CAAS;gBAEhB,eAAe,CAAC,EAAE,eAAe,EAAE,UAAU,CAAC,EAAE,MAAM;IAoDlE;;OAEG;IACH,OAAO,CAAC,aAAa;IAMrB;;OAEG;IACG,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA2E1B;;OAEG;YACW,eAAe;IAiE7B;;OAEG;YACW,cAAc;IAiV5B;;;;;OAKG;YACW,gBAAgB;IAiC9B;;OAEG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAoBhC;AAGD,eAAe,qBAAqB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.McpAbapAdtProxyServer = void 0;
|
|
13
13
|
const node_http_1 = require("node:http");
|
|
14
|
-
const
|
|
14
|
+
const interfaces_network_1 = require("@mcp-abap-adt/interfaces-network");
|
|
15
15
|
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
16
16
|
const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.js");
|
|
17
17
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
18
|
+
const requestHandler_js_1 = require("./proxy/requestHandler.js");
|
|
18
19
|
const reverseProxy_js_1 = require("./proxy/reverseProxy.js");
|
|
19
20
|
const config_js_1 = require("./lib/config.js");
|
|
20
21
|
const logger_js_1 = require("./lib/logger.js");
|
|
@@ -73,6 +74,30 @@ class McpAbapAdtProxyServer {
|
|
|
73
74
|
config;
|
|
74
75
|
httpServer;
|
|
75
76
|
btpProxy;
|
|
77
|
+
builtProxyHandler;
|
|
78
|
+
/**
|
|
79
|
+
* The request path, shared with the MCP mode. Here an authentication failure
|
|
80
|
+
* is fatal: the process exits so whatever started it can start it again with
|
|
81
|
+
* a credential that works.
|
|
82
|
+
*
|
|
83
|
+
* Built on first use rather than as a field initializer, because `config` is
|
|
84
|
+
* assigned in the constructor and a field initializer runs before that.
|
|
85
|
+
*/
|
|
86
|
+
get proxyHandler() {
|
|
87
|
+
if (!this.builtProxyHandler) {
|
|
88
|
+
this.builtProxyHandler = (0, requestHandler_js_1.createProxyRequestHandler)({
|
|
89
|
+
config: this.config,
|
|
90
|
+
proxy: async () => {
|
|
91
|
+
if (!this.btpProxy) {
|
|
92
|
+
this.btpProxy = await (0, btpProxy_js_1.createBtpProxy)(this.config);
|
|
93
|
+
}
|
|
94
|
+
return this.btpProxy;
|
|
95
|
+
},
|
|
96
|
+
onAuthFailure: (error, destination) => this.fatalAuthFailure(error, destination, 'request'),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return this.builtProxyHandler;
|
|
100
|
+
}
|
|
76
101
|
authExiting = false;
|
|
77
102
|
constructor(transportConfig, configPath) {
|
|
78
103
|
// Load config first to extract transport overrides from YAML/JSON file
|
|
@@ -203,39 +228,13 @@ Authorization source: BTP destination "${this.config.btpDestination}"`;
|
|
|
203
228
|
headers: sanitizedHeaders,
|
|
204
229
|
remoteAddress: req.socket.remoteAddress,
|
|
205
230
|
});
|
|
206
|
-
// Intercept and analyze request (headers only, body is piped through)
|
|
207
|
-
const configOverrides = {
|
|
208
|
-
btpDestination: this.config.btpDestination,
|
|
209
|
-
targetUrl: this.config.targetUrl,
|
|
210
|
-
};
|
|
211
|
-
const intercepted = (0, requestInterceptor_js_1.interceptRequest)(req, undefined, configOverrides, {
|
|
212
|
-
skipHeaderValidation: true,
|
|
213
|
-
});
|
|
214
|
-
// Check routing decision
|
|
215
|
-
if (intercepted.routingDecision.strategy === headerAnalyzer_js_1.RoutingStrategy.UNKNOWN) {
|
|
216
|
-
logger_js_1.logger?.error('Routing decision failed', {
|
|
217
|
-
type: 'ROUTING_DECISION_FAILED',
|
|
218
|
-
reason: intercepted.routingDecision.reason,
|
|
219
|
-
});
|
|
220
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
221
|
-
res.end(JSON.stringify({
|
|
222
|
-
error: intercepted.routingDecision.reason,
|
|
223
|
-
}));
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
// Forward request via reverse proxy
|
|
227
|
-
logger_js_1.logger?.info('=== PROXYING REQUEST ===', {
|
|
228
|
-
type: 'PROXY_REQUEST_START',
|
|
229
|
-
btpDestination: intercepted.routingDecision.btpDestination,
|
|
230
|
-
});
|
|
231
231
|
try {
|
|
232
|
-
await this.
|
|
232
|
+
await this.proxyHandler(req, res);
|
|
233
233
|
}
|
|
234
234
|
catch (error) {
|
|
235
235
|
logger_js_1.logger?.error('Failed to process request', {
|
|
236
236
|
type: 'REQUEST_PROCESS_ERROR',
|
|
237
237
|
error: error instanceof Error ? error.message : String(error),
|
|
238
|
-
strategy: intercepted.routingDecision.strategy,
|
|
239
238
|
});
|
|
240
239
|
if (!res.headersSent) {
|
|
241
240
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
@@ -270,62 +269,6 @@ Authorization source: ${authSourceObj}`;
|
|
|
270
269
|
});
|
|
271
270
|
});
|
|
272
271
|
}
|
|
273
|
-
/**
|
|
274
|
-
* Handle proxy request - get JWT token and forward transparently via reverse proxy
|
|
275
|
-
*/
|
|
276
|
-
async handleProxyRequest(intercepted, req, res) {
|
|
277
|
-
const destination = intercepted.routingDecision.btpDestination;
|
|
278
|
-
if (!destination) {
|
|
279
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
280
|
-
res.end(JSON.stringify({ error: 'No BTP destination specified' }));
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
// Ensure proxy is initialized
|
|
284
|
-
if (!this.btpProxy) {
|
|
285
|
-
this.btpProxy = await (0, btpProxy_js_1.createBtpProxy)(this.config);
|
|
286
|
-
}
|
|
287
|
-
// Authentication is separated from forwarding: an auth failure is fatal
|
|
288
|
-
// (the proxy exits so it can be restarted), while a downstream/forward error
|
|
289
|
-
// is returned to the client without killing the proxy.
|
|
290
|
-
let jwtToken;
|
|
291
|
-
try {
|
|
292
|
-
// Get JWT token (cached, auto-refresh)
|
|
293
|
-
jwtToken = await this.btpProxy.getJwtToken(destination);
|
|
294
|
-
}
|
|
295
|
-
catch (authError) {
|
|
296
|
-
logger_js_1.logger?.error('Proxy request failed: authentication error', {
|
|
297
|
-
type: 'PROXY_REQUEST_AUTH_ERROR',
|
|
298
|
-
destination,
|
|
299
|
-
error: authError instanceof Error ? authError.message : String(authError),
|
|
300
|
-
});
|
|
301
|
-
if (!res.headersSent) {
|
|
302
|
-
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
303
|
-
res.end(JSON.stringify({ error: 'Authentication failed' }));
|
|
304
|
-
}
|
|
305
|
-
await this.fatalAuthFailure(authError, destination, 'request');
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
try {
|
|
309
|
-
// Get target URL
|
|
310
|
-
const targetUrl = intercepted.routingDecision.targetUrl ||
|
|
311
|
-
(await this.btpProxy.getTargetUrl(destination));
|
|
312
|
-
// Forward request transparently (inject default headers from config)
|
|
313
|
-
await (0, reverseProxy_js_1.forwardRequest)(req, res, targetUrl, jwtToken, this.config.defaultHeaders);
|
|
314
|
-
}
|
|
315
|
-
catch (error) {
|
|
316
|
-
logger_js_1.logger?.error('Proxy request failed', {
|
|
317
|
-
type: 'PROXY_REQUEST_ERROR',
|
|
318
|
-
destination,
|
|
319
|
-
error: error instanceof Error ? error.message : String(error),
|
|
320
|
-
});
|
|
321
|
-
if (!res.headersSent) {
|
|
322
|
-
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
323
|
-
res.end(JSON.stringify({
|
|
324
|
-
error: error instanceof Error ? error.message : 'Proxy error',
|
|
325
|
-
}));
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
272
|
/**
|
|
330
273
|
* Start SSE server
|
|
331
274
|
*/
|
|
@@ -371,8 +314,8 @@ Authorization source: ${authSourceObj}`;
|
|
|
371
314
|
}
|
|
372
315
|
}
|
|
373
316
|
// Add config overrides if not present in headers
|
|
374
|
-
if (this.config.btpDestination && !headers[
|
|
375
|
-
headers[
|
|
317
|
+
if (this.config.btpDestination && !headers[interfaces_network_1.HEADER_BTP_DESTINATION]) {
|
|
318
|
+
headers[interfaces_network_1.HEADER_BTP_DESTINATION] = this.config.btpDestination;
|
|
376
319
|
}
|
|
377
320
|
logger_js_1.logger?.debug('SSE request received', {
|
|
378
321
|
type: 'SSE_HTTP_REQUEST',
|
|
@@ -483,14 +426,15 @@ Authorization source: ${authSourceObj}`;
|
|
|
483
426
|
}
|
|
484
427
|
// Read request body
|
|
485
428
|
let body;
|
|
429
|
+
let rawBody;
|
|
486
430
|
try {
|
|
487
431
|
const chunks = [];
|
|
488
432
|
for await (const chunk of req) {
|
|
489
433
|
chunks.push(chunk);
|
|
490
434
|
}
|
|
491
435
|
if (chunks.length > 0) {
|
|
492
|
-
|
|
493
|
-
body = JSON.parse(
|
|
436
|
+
rawBody = Buffer.concat(chunks);
|
|
437
|
+
body = JSON.parse(rawBody.toString('utf-8'));
|
|
494
438
|
}
|
|
495
439
|
}
|
|
496
440
|
catch (error) {
|
|
@@ -532,19 +476,32 @@ Authorization source: ${authSourceObj}`;
|
|
|
532
476
|
}));
|
|
533
477
|
return;
|
|
534
478
|
}
|
|
535
|
-
//
|
|
479
|
+
// Forward through the same transparent pipe the other path uses.
|
|
480
|
+
//
|
|
481
|
+
// This used to rebuild the request by hand and carry it over axios,
|
|
482
|
+
// which buffered the response and rewrapped it as a JSON-RPC envelope.
|
|
483
|
+
// The body above was already read — the JSON-RPC `id` in it is what the
|
|
484
|
+
// error envelopes below have to echo — so it is handed over rather than
|
|
485
|
+
// piped. The response still streams, which is the direction that
|
|
486
|
+
// carries an event stream.
|
|
536
487
|
try {
|
|
537
488
|
if (!this.btpProxy) {
|
|
538
489
|
this.btpProxy = await (0, btpProxy_js_1.createBtpProxy)(this.config);
|
|
539
490
|
}
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
491
|
+
const destination = intercepted.routingDecision.btpDestination ??
|
|
492
|
+
this.config.btpDestination;
|
|
493
|
+
if (!destination) {
|
|
494
|
+
throw new Error('No BTP destination for this request');
|
|
495
|
+
}
|
|
496
|
+
const authorization = await this.btpProxy.getAuthorizationHeader(destination);
|
|
497
|
+
const targetUrl = intercepted.routingDecision.targetUrl ||
|
|
498
|
+
(await this.btpProxy.getTargetUrl(destination));
|
|
499
|
+
await (0, reverseProxy_js_1.forwardRequest)(req, res, targetUrl, authorization, this.config.defaultHeaders,
|
|
500
|
+
// The bytes as they arrived, not the parse re-serialised: the
|
|
501
|
+
// client's `content-length` is forwarded unchanged, and a
|
|
502
|
+
// re-serialised body differs from it by whatever whitespace the
|
|
503
|
+
// sender used.
|
|
504
|
+
rawBody);
|
|
548
505
|
}
|
|
549
506
|
catch (error) {
|
|
550
507
|
logger_js_1.logger?.error('Failed to process SSE POST request', {
|
package/dist/lib/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA;;GAEG;AAYH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IAEjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAGxC,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAG/B,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,WAAW,
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA;;GAEG;AAYH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IAEjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAGxC,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAG/B,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,WAAW,CA8C3D;AAwED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,mBAAmB,CAQrB;AAkCD;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,GACf,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAYhC;AAmJD;;GAEG;AACH,wBAAgB,aAAa,IAAI,MAAM,GAAG,SAAS,CAElD;AAUD;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG;IACnD,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAoDA"}
|
package/dist/lib/config.js
CHANGED
|
@@ -68,7 +68,15 @@ function loadConfig(configPath) {
|
|
|
68
68
|
const envFilePath = resolveEnvFilePath(fileConfig, finalConfigPath);
|
|
69
69
|
const envFileMap = envFilePath ? (0, envInterpolation_js_1.loadEnvFile)(envFilePath) : {};
|
|
70
70
|
const lookup = (0, envInterpolation_js_1.buildLookup)(envFileMap);
|
|
71
|
-
const
|
|
71
|
+
const keys = Object.keys(envFileMap);
|
|
72
|
+
const sources = !envFilePath
|
|
73
|
+
? 'process.env only — no env file was given (--env-file or envFile:)'
|
|
74
|
+
: keys.length === 0
|
|
75
|
+
? `process.env, then ${envFilePath}, which yielded NO variables ` +
|
|
76
|
+
`(${fs.statSync(envFilePath).size} bytes) — check its encoding, ` +
|
|
77
|
+
`since a UTF-16 file reads as nothing here, and that lines look like KEY=value`
|
|
78
|
+
: `process.env, then ${envFilePath} (${keys.length}: ${keys.join(', ')})`;
|
|
79
|
+
const interpolated = (0, envInterpolation_js_1.interpolateConfig)(fileConfig, lookup, '', sources);
|
|
72
80
|
delete interpolated.envFile;
|
|
73
81
|
const base = applyDefaults(interpolated);
|
|
74
82
|
const cli = readCliOverrides();
|
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
* default when one is given. A ${VAR} without a default that resolves to
|
|
9
9
|
* undefined throws, naming the variable and the field it came from.
|
|
10
10
|
*/
|
|
11
|
-
export declare function interpolateString(input: string, lookup: (key: string) => string | undefined, fieldPath: string
|
|
11
|
+
export declare function interpolateString(input: string, lookup: (key: string) => string | undefined, fieldPath: string,
|
|
12
|
+
/** Where values were looked for, named in the failure. */
|
|
13
|
+
sources?: string): string;
|
|
12
14
|
/**
|
|
13
15
|
* Recursively interpolate all string values in a parsed config object.
|
|
14
16
|
* Objects/arrays are walked; non-string scalars are returned unchanged.
|
|
15
17
|
* The field path (e.g. `defaultHeaders.x-sap-password`) is threaded through for
|
|
16
18
|
* error messages.
|
|
17
19
|
*/
|
|
18
|
-
export declare function interpolateConfig(value: unknown, lookup: (key: string) => string | undefined, path?: string): unknown;
|
|
20
|
+
export declare function interpolateConfig(value: unknown, lookup: (key: string) => string | undefined, path?: string, sources?: string): unknown;
|
|
19
21
|
/**
|
|
20
22
|
* Parse a .env file into a flat map. Throws if the path is given but missing —
|
|
21
23
|
* a specified-yet-absent secret source is a configuration error, not a no-op.
|
|
@@ -23,6 +25,16 @@ export declare function interpolateConfig(value: unknown, lookup: (key: string)
|
|
|
23
25
|
export declare function loadEnvFile(envFilePath: string): Record<string, string>;
|
|
24
26
|
/**
|
|
25
27
|
* Build a lookup over process.env (highest priority) then the parsed .env map.
|
|
28
|
+
*
|
|
29
|
+
* An EMPTY environment variable does not count as a value. It used to: `??`
|
|
30
|
+
* skips only `undefined`, so a `SAP_LOGIN=` sitting in the environment won
|
|
31
|
+
* against the env file the user had explicitly pointed at with `--env-file`,
|
|
32
|
+
* and the header went out empty with nothing reported. Windows carries
|
|
33
|
+
* variables nobody set deliberately, which is why the same config and the same
|
|
34
|
+
* `.env` behaved differently there.
|
|
35
|
+
*
|
|
36
|
+
* A real value in the environment still wins — that is the documented
|
|
37
|
+
* precedence, and it is how a one-off override is meant to work.
|
|
26
38
|
*/
|
|
27
39
|
export declare function buildLookup(envFileMap: Record<string, string>): (key: string) => string | undefined;
|
|
28
40
|
//# sourceMappingURL=envInterpolation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"envInterpolation.d.ts","sourceRoot":"","sources":["../../src/lib/envInterpolation.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,SAAS,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"envInterpolation.d.ts","sourceRoot":"","sources":["../../src/lib/envInterpolation.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,SAAS,EAAE,MAAM;AACjB,0DAA0D;AAC1D,OAAO,SAAgB,GACtB,MAAM,CAsBR;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,EAC3C,IAAI,SAAK,EACT,OAAO,SAAgB,GACtB,OAAO,CAsBT;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASvE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CACzB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACjC,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAQrC"}
|
|
@@ -50,7 +50,9 @@ const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
|
50
50
|
* default when one is given. A ${VAR} without a default that resolves to
|
|
51
51
|
* undefined throws, naming the variable and the field it came from.
|
|
52
52
|
*/
|
|
53
|
-
function interpolateString(input, lookup, fieldPath
|
|
53
|
+
function interpolateString(input, lookup, fieldPath,
|
|
54
|
+
/** Where values were looked for, named in the failure. */
|
|
55
|
+
sources = 'process.env') {
|
|
54
56
|
return input.replace(PLACEHOLDER, (_match, name, defaultVal) => {
|
|
55
57
|
const value = lookup(name);
|
|
56
58
|
const isEmpty = value === undefined || value === '';
|
|
@@ -58,7 +60,12 @@ function interpolateString(input, lookup, fieldPath) {
|
|
|
58
60
|
return isEmpty ? defaultVal : value;
|
|
59
61
|
}
|
|
60
62
|
if (value === undefined) {
|
|
61
|
-
|
|
63
|
+
// Naming where it looked, because the old message named only the
|
|
64
|
+
// variable — so a file never read, a file read empty, and a value
|
|
65
|
+
// shadowed by the environment all produced the same sentence and the
|
|
66
|
+
// cause had to be guessed.
|
|
67
|
+
throw new Error(`Config references undefined env variable: ${name} ` +
|
|
68
|
+
`(referenced in ${fieldPath}; looked in ${sources})`);
|
|
62
69
|
}
|
|
63
70
|
return value;
|
|
64
71
|
});
|
|
@@ -69,17 +76,17 @@ function interpolateString(input, lookup, fieldPath) {
|
|
|
69
76
|
* The field path (e.g. `defaultHeaders.x-sap-password`) is threaded through for
|
|
70
77
|
* error messages.
|
|
71
78
|
*/
|
|
72
|
-
function interpolateConfig(value, lookup, path = '') {
|
|
79
|
+
function interpolateConfig(value, lookup, path = '', sources = 'process.env') {
|
|
73
80
|
if (typeof value === 'string') {
|
|
74
|
-
return interpolateString(value, lookup, path || '(root)');
|
|
81
|
+
return interpolateString(value, lookup, path || '(root)', sources);
|
|
75
82
|
}
|
|
76
83
|
if (Array.isArray(value)) {
|
|
77
|
-
return value.map((item, i) => interpolateConfig(item, lookup, `${path}[${i}]
|
|
84
|
+
return value.map((item, i) => interpolateConfig(item, lookup, `${path}[${i}]`, sources));
|
|
78
85
|
}
|
|
79
86
|
if (value !== null && typeof value === 'object') {
|
|
80
87
|
const out = {};
|
|
81
88
|
for (const [key, val] of Object.entries(value)) {
|
|
82
|
-
out[key] = interpolateConfig(val, lookup, path ? `${path}.${key}` : key);
|
|
89
|
+
out[key] = interpolateConfig(val, lookup, path ? `${path}.${key}` : key, sources);
|
|
83
90
|
}
|
|
84
91
|
return out;
|
|
85
92
|
}
|
|
@@ -93,11 +100,31 @@ function loadEnvFile(envFilePath) {
|
|
|
93
100
|
if (!fs.existsSync(envFilePath)) {
|
|
94
101
|
throw new Error(`env file not found: ${envFilePath}`);
|
|
95
102
|
}
|
|
103
|
+
// Not an error when it yields nothing: an empty `.env` is legitimate, and a
|
|
104
|
+
// config may take every value from the environment. But the emptiness is
|
|
105
|
+
// reported by whoever needs a variable — see the `sources` note threaded into
|
|
106
|
+
// the interpolation failure, which names the file, its size and its key count.
|
|
96
107
|
return dotenv.parse(fs.readFileSync(envFilePath, 'utf-8'));
|
|
97
108
|
}
|
|
98
109
|
/**
|
|
99
110
|
* Build a lookup over process.env (highest priority) then the parsed .env map.
|
|
111
|
+
*
|
|
112
|
+
* An EMPTY environment variable does not count as a value. It used to: `??`
|
|
113
|
+
* skips only `undefined`, so a `SAP_LOGIN=` sitting in the environment won
|
|
114
|
+
* against the env file the user had explicitly pointed at with `--env-file`,
|
|
115
|
+
* and the header went out empty with nothing reported. Windows carries
|
|
116
|
+
* variables nobody set deliberately, which is why the same config and the same
|
|
117
|
+
* `.env` behaved differently there.
|
|
118
|
+
*
|
|
119
|
+
* A real value in the environment still wins — that is the documented
|
|
120
|
+
* precedence, and it is how a one-off override is meant to work.
|
|
100
121
|
*/
|
|
101
122
|
function buildLookup(envFileMap) {
|
|
102
|
-
return (key) =>
|
|
123
|
+
return (key) => {
|
|
124
|
+
const fromEnvironment = process.env[key];
|
|
125
|
+
if (fromEnvironment !== undefined && fromEnvironment !== '') {
|
|
126
|
+
return fromEnvironment;
|
|
127
|
+
}
|
|
128
|
+
return envFileMap[key];
|
|
129
|
+
};
|
|
103
130
|
}
|