@saptools/cf-hana 0.4.1 → 0.5.0

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/index.js CHANGED
@@ -6,7 +6,7 @@ import { homedir } from "os";
6
6
  import { join } from "path";
7
7
 
8
8
  // src/config.ts
9
- var CLI_VERSION = "0.4.0";
9
+ var CLI_VERSION = "0.5.0";
10
10
  var ENV_PREFIX = "CF_HANA";
11
11
  var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
12
12
  var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
@@ -15,6 +15,27 @@ var DEFAULT_POOL_IDLE_MS = 6e4;
15
15
  var DEFAULT_AUTO_LIMIT = 100;
16
16
  var DEFAULT_MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
17
17
  var MAX_RESULT_STORE_BYTES = resolveMaxResultStoreBytes();
18
+ function resolvePositiveIntEnv(suffix, defaultValue) {
19
+ const raw = readEnv(envName(suffix));
20
+ if (raw === void 0) {
21
+ return defaultValue;
22
+ }
23
+ const parsed = Number(raw);
24
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : defaultValue;
25
+ }
26
+ var DEFAULT_TUNNEL_FALLBACK_BUDGET_MS = resolvePositiveIntEnv(
27
+ "TUNNEL_FALLBACK_BUDGET_MS",
28
+ 25e3
29
+ );
30
+ var DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS = resolvePositiveIntEnv(
31
+ "TUNNEL_CANDIDATE_TIMEOUT_MS",
32
+ 15e3
33
+ );
34
+ var DEFAULT_TUNNEL_MAX_CANDIDATES = resolvePositiveIntEnv("TUNNEL_MAX_CANDIDATES", 3);
35
+ var DEFAULT_TUNNEL_KEEPALIVE_SECONDS = resolvePositiveIntEnv(
36
+ "TUNNEL_KEEPALIVE_SECONDS",
37
+ 1200
38
+ );
18
39
  function resolveMaxResultStoreBytes() {
19
40
  if (readEnv(envName("DRIVER")) !== "fake") {
20
41
  return DEFAULT_MAX_RESULT_STORE_BYTES;
@@ -1217,6 +1238,16 @@ async function cfEnvDirect(appName) {
1217
1238
  delete env["SAP_PASSWORD"];
1218
1239
  return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
1219
1240
  }
1241
+ async function cfApps(ctx) {
1242
+ return await runCf(["apps"], ctx);
1243
+ }
1244
+ async function cfAppsDirect() {
1245
+ const { bin, argsPrefix } = resolveCfBin();
1246
+ const env = { ...process.env };
1247
+ delete env["SAP_EMAIL"];
1248
+ delete env["SAP_PASSWORD"];
1249
+ return await execWithRetries(bin, [...argsPrefix, "apps"], env);
1250
+ }
1220
1251
  async function readCurrentCfTarget() {
1221
1252
  const { bin, argsPrefix } = resolveCfBin();
1222
1253
  const env = { ...process.env };
@@ -1266,6 +1297,26 @@ function parseTargetFields(stdout) {
1266
1297
  }
1267
1298
  return map;
1268
1299
  }
1300
+ function parseCfAppsOutput(stdout) {
1301
+ const lines = stdout.split(/\r?\n/);
1302
+ const headerIndex = lines.findIndex((line) => /^\s*name\s+\S/i.test(line));
1303
+ if (headerIndex === -1) {
1304
+ return [];
1305
+ }
1306
+ const rows = [];
1307
+ for (const line of lines.slice(headerIndex + 1)) {
1308
+ const trimmed = line.trim();
1309
+ if (trimmed.length === 0) {
1310
+ continue;
1311
+ }
1312
+ const [name, state] = trimmed.split(/\s+/);
1313
+ if (name === void 0 || state === void 0) {
1314
+ continue;
1315
+ }
1316
+ rows.push({ name, state });
1317
+ }
1318
+ return rows;
1319
+ }
1269
1320
  function formatCurrentCfAppSelector(target, appName) {
1270
1321
  const name = appName.trim();
1271
1322
  if (!name) {
@@ -1577,7 +1628,10 @@ async function resolveAppBindings(rawSelector, options) {
1577
1628
  source: "live",
1578
1629
  selectorSource: target.selectorSource,
1579
1630
  regionConfirmed: target.regionConfirmed,
1580
- selectorCanBePinned: target.selectorCanBePinned
1631
+ selectorCanBePinned: target.selectorCanBePinned,
1632
+ apiEndpoint: target.apiEndpoint,
1633
+ orgName: target.orgName,
1634
+ spaceName: target.spaceName
1581
1635
  };
1582
1636
  }
1583
1637
  async function readExplicitBindings(target, options) {
@@ -1643,6 +1697,7 @@ function toConnectionTarget(binding, role) {
1643
1697
  // src/driver/fake.ts
1644
1698
  import { appendFile } from "fs/promises";
1645
1699
  var catalogFailureInjected = false;
1700
+ var connectFailureInjectedOnce = false;
1646
1701
  function catalogObjects() {
1647
1702
  return [
1648
1703
  { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "EXISTING_TABLE", OBJECT_TYPE: "TABLE" },
@@ -1851,10 +1906,28 @@ var FakeConnection = class {
1851
1906
  return this.closed;
1852
1907
  }
1853
1908
  };
1909
+ function maybeFailConnect() {
1910
+ const mode = readEnv(envName("FAKE_FAIL_CONNECT"));
1911
+ if (mode !== "once" && mode !== "always") {
1912
+ return;
1913
+ }
1914
+ if (mode === "once" && connectFailureInjectedOnce) {
1915
+ return;
1916
+ }
1917
+ connectFailureInjectedOnce = true;
1918
+ const cause = Object.assign(
1919
+ new Error("Could not connect to any host: [ fake-host:443 - forced fixture failure ]"),
1920
+ { code: "EHDBOPENCONN" }
1921
+ );
1922
+ throw new CfHanaError("CONNECTION", `Failed to connect to HANA: ${cause.message}`, { cause });
1923
+ }
1854
1924
  function createFakeDriver() {
1855
1925
  return {
1856
1926
  name: "fake",
1857
- connect: (_params) => Promise.resolve(new FakeConnection())
1927
+ connect: (_params) => {
1928
+ maybeFailConnect();
1929
+ return Promise.resolve(new FakeConnection());
1930
+ }
1858
1931
  };
1859
1932
  }
1860
1933
 
@@ -2079,7 +2152,8 @@ async function connectHdb(params) {
2079
2152
  user: params.user,
2080
2153
  password: params.password,
2081
2154
  ca: params.certificate,
2082
- useTLS: true
2155
+ useTLS: true,
2156
+ ...params.servername === void 0 ? {} : { servername: params.servername }
2083
2157
  });
2084
2158
  await openClient(client, params.connectTimeoutMs);
2085
2159
  try {
@@ -2360,6 +2434,605 @@ function applyAutoLimit(sql, limit) {
2360
2434
  };
2361
2435
  }
2362
2436
 
2437
+ // src/tunnel/cache.ts
2438
+ import { createHash } from "crypto";
2439
+ import { mkdir as mkdir3, readdir as readdir2, readFile, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2440
+ import { homedir as homedir3 } from "os";
2441
+ import { join as join5 } from "path";
2442
+
2443
+ // src/tunnel/process.ts
2444
+ import { spawn } from "child_process";
2445
+ import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
2446
+ import { connect as netConnect, createServer } from "net";
2447
+ import { tmpdir as tmpdir2 } from "os";
2448
+ import { join as join4 } from "path";
2449
+ var PORT_POLL_INTERVAL_MS = 100;
2450
+ var DEFAULT_PORT_PROBE_TIMEOUT_MS = 500;
2451
+ function assertPositiveSafeInteger(label, value) {
2452
+ if (!Number.isSafeInteger(value) || value <= 0) {
2453
+ throw new CfHanaError("CONFIG", `${label} must be a positive integer`);
2454
+ }
2455
+ }
2456
+ async function allocateLocalPort() {
2457
+ return await new Promise((resolve, reject) => {
2458
+ const server = createServer();
2459
+ server.once("error", reject);
2460
+ server.listen(0, "127.0.0.1", () => {
2461
+ const address = server.address();
2462
+ server.close(() => {
2463
+ if (address === null || typeof address === "string") {
2464
+ reject(new CfHanaError("CONNECTION", "Could not allocate a local port for a tunnel"));
2465
+ return;
2466
+ }
2467
+ resolve(address.port);
2468
+ });
2469
+ });
2470
+ });
2471
+ }
2472
+ function probeLocalPort(port, timeoutMs = DEFAULT_PORT_PROBE_TIMEOUT_MS) {
2473
+ return new Promise((resolve) => {
2474
+ let settled = false;
2475
+ const socket = netConnect({ host: "127.0.0.1", port });
2476
+ const finish = (result) => {
2477
+ if (settled) {
2478
+ return;
2479
+ }
2480
+ settled = true;
2481
+ clearTimeout(timer);
2482
+ socket.destroy();
2483
+ resolve(result);
2484
+ };
2485
+ const timer = setTimeout(() => {
2486
+ finish(false);
2487
+ }, timeoutMs);
2488
+ timer.unref();
2489
+ socket.once("connect", () => {
2490
+ finish(true);
2491
+ });
2492
+ socket.once("error", () => {
2493
+ finish(false);
2494
+ });
2495
+ });
2496
+ }
2497
+ function killTunnelProcess(pid) {
2498
+ if (pid === void 0) {
2499
+ return;
2500
+ }
2501
+ try {
2502
+ process.kill(pid, "SIGTERM");
2503
+ } catch {
2504
+ }
2505
+ }
2506
+ async function withScopedCfSession(selectorSource, target, sapCredentials, work) {
2507
+ if (selectorSource === "ambient") {
2508
+ return await work();
2509
+ }
2510
+ if (sapCredentials === void 0) {
2511
+ throw new CredentialsNotFoundError(
2512
+ "SAP credentials are required to open a tunnel session for an explicit selector"
2513
+ );
2514
+ }
2515
+ const cfHome = await mkdtemp2(join4(tmpdir2(), "saptools-cf-hana-tunnel-"));
2516
+ try {
2517
+ const ctx = { cfHome };
2518
+ await cfApi(target.apiEndpoint, ctx);
2519
+ await cfAuth(sapCredentials.email, sapCredentials.password, ctx);
2520
+ await cfTargetSpace(target.orgName, target.spaceName, ctx);
2521
+ return await work(ctx);
2522
+ } finally {
2523
+ await rm3(cfHome, { recursive: true, force: true });
2524
+ }
2525
+ }
2526
+ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess) {
2527
+ return new Promise((resolve) => {
2528
+ let settled = false;
2529
+ const timers = {};
2530
+ const finish = (result) => {
2531
+ if (settled) {
2532
+ return;
2533
+ }
2534
+ settled = true;
2535
+ clearInterval(timers.poll);
2536
+ clearTimeout(timers.deadline);
2537
+ child.removeListener("exit", onAbort);
2538
+ child.removeListener("error", onAbort);
2539
+ if (result === void 0) {
2540
+ killProcess(child.pid);
2541
+ } else {
2542
+ child.unref();
2543
+ }
2544
+ resolve(result);
2545
+ };
2546
+ function onAbort() {
2547
+ finish();
2548
+ }
2549
+ child.on("exit", onAbort);
2550
+ child.on("error", onAbort);
2551
+ timers.poll = setInterval(() => {
2552
+ void probePort(localPort).then((open) => {
2553
+ if (open && child.pid !== void 0) {
2554
+ finish({ localPort, pid: child.pid });
2555
+ }
2556
+ });
2557
+ }, PORT_POLL_INTERVAL_MS);
2558
+ timers.deadline = setTimeout(finish, boundMs);
2559
+ });
2560
+ }
2561
+ async function spawnTunnel(params, deps = {}) {
2562
+ assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
2563
+ const remainingMs = params.deadline - Date.now();
2564
+ if (remainingMs <= 0) {
2565
+ return void 0;
2566
+ }
2567
+ const boundMs = Math.min(remainingMs, params.candidateTimeoutMs);
2568
+ const spawnProcess = deps.spawnProcess ?? spawn;
2569
+ const probePort = deps.probePort ?? probeLocalPort;
2570
+ const allocatePort = deps.allocatePort ?? allocateLocalPort;
2571
+ const killProcess = deps.killProcess ?? killTunnelProcess;
2572
+ const localPort = await allocatePort();
2573
+ const { bin, argsPrefix } = resolveCfBin();
2574
+ const forward = `${String(localPort)}:${params.hanaHost}:${String(params.hanaPort)}`;
2575
+ const args = [
2576
+ ...argsPrefix,
2577
+ "ssh",
2578
+ params.app,
2579
+ "-L",
2580
+ forward,
2581
+ "-c",
2582
+ `sleep ${String(params.keepaliveSeconds)}`
2583
+ ];
2584
+ const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
2585
+ const child = spawnProcess(bin, args, { detached: true, stdio: "ignore", env });
2586
+ return await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
2587
+ }
2588
+
2589
+ // src/tunnel/cache.ts
2590
+ var DEFAULT_ESTABLISHING_STALE_MS = 2 * DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
2591
+ var DEFAULT_POLL_INTERVAL_MS = 300;
2592
+ function isRecord2(value) {
2593
+ return typeof value === "object" && value !== null;
2594
+ }
2595
+ function defaultIsProcessAlive(pid) {
2596
+ try {
2597
+ process.kill(pid, 0);
2598
+ return true;
2599
+ } catch (error) {
2600
+ return isRecord2(error) && error["code"] !== "ESRCH";
2601
+ }
2602
+ }
2603
+ function sleep(ms) {
2604
+ return new Promise((resolve) => {
2605
+ setTimeout(resolve, ms);
2606
+ });
2607
+ }
2608
+ function tunnelCacheRoot(saptoolsRoot) {
2609
+ return join5(saptoolsRoot ?? join5(homedir3(), ".saptools"), "cf-hana", "tunnel");
2610
+ }
2611
+ function tunnelCacheKey(host) {
2612
+ return createHash("sha256").update(host).digest("hex");
2613
+ }
2614
+ function tunnelCachePath(host, saptoolsRoot) {
2615
+ return join5(tunnelCacheRoot(saptoolsRoot), `${tunnelCacheKey(host)}.json`);
2616
+ }
2617
+ function isOrgKey(value) {
2618
+ return isRecord2(value) && typeof value["apiEndpoint"] === "string" && typeof value["orgName"] === "string";
2619
+ }
2620
+ function hasEstablishingFields(value) {
2621
+ return value["status"] === "establishing" && typeof value["host"] === "string" && typeof value["startedAt"] === "string" && typeof value["ownerPid"] === "number" && isOrgKey(value["orgKey"]);
2622
+ }
2623
+ function hasReadyFields(value) {
2624
+ return value["status"] === "ready" && typeof value["host"] === "string" && typeof value["localPort"] === "number" && typeof value["pid"] === "number" && typeof value["app"] === "string" && typeof value["expiresAt"] === "string" && isOrgKey(value["orgKey"]);
2625
+ }
2626
+ function isTunnelCacheRecord(value) {
2627
+ return isRecord2(value) && value["version"] === 1 && (hasEstablishingFields(value) || hasReadyFields(value));
2628
+ }
2629
+ async function parseTunnelCacheFile(path) {
2630
+ try {
2631
+ const raw = JSON.parse(await readFile(path, "utf8"));
2632
+ return isTunnelCacheRecord(raw) ? raw : void 0;
2633
+ } catch {
2634
+ return void 0;
2635
+ }
2636
+ }
2637
+ async function readTunnelCacheEntry(host, options = {}) {
2638
+ const entry = await parseTunnelCacheFile(tunnelCachePath(host, options.saptoolsRoot));
2639
+ return entry?.host === host ? entry : void 0;
2640
+ }
2641
+ function isExpired(entry, now) {
2642
+ const expiresAt = Date.parse(entry.expiresAt);
2643
+ return !Number.isFinite(expiresAt) || now.getTime() >= expiresAt;
2644
+ }
2645
+ async function isTunnelUsable(entry, options = {}) {
2646
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
2647
+ if (!isAlive(entry.pid)) {
2648
+ return false;
2649
+ }
2650
+ if (isExpired(entry, options.now?.() ?? /* @__PURE__ */ new Date())) {
2651
+ return false;
2652
+ }
2653
+ const probePort = options.probePort ?? probeLocalPort;
2654
+ return await probePort(entry.localPort);
2655
+ }
2656
+ function isEstablishingStale(entry, options) {
2657
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
2658
+ if (!isAlive(entry.ownerPid)) {
2659
+ return true;
2660
+ }
2661
+ const startedAt = Date.parse(entry.startedAt);
2662
+ if (!Number.isFinite(startedAt)) {
2663
+ return true;
2664
+ }
2665
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
2666
+ const staleAfterMs = options.staleAfterMs ?? DEFAULT_ESTABLISHING_STALE_MS;
2667
+ return now.getTime() - startedAt >= staleAfterMs;
2668
+ }
2669
+ async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
2670
+ const root = tunnelCacheRoot(options.saptoolsRoot);
2671
+ await mkdir3(root, { recursive: true, mode: 448 });
2672
+ const record = {
2673
+ version: 1,
2674
+ status: "establishing",
2675
+ host,
2676
+ startedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
2677
+ ownerPid,
2678
+ orgKey
2679
+ };
2680
+ try {
2681
+ await writeFile2(tunnelCachePath(host, options.saptoolsRoot), `${JSON.stringify(record)}
2682
+ `, {
2683
+ encoding: "utf8",
2684
+ mode: 384,
2685
+ flag: "wx"
2686
+ });
2687
+ return true;
2688
+ } catch (error) {
2689
+ if (isRecord2(error) && error["code"] === "EEXIST") {
2690
+ return false;
2691
+ }
2692
+ throw error;
2693
+ }
2694
+ }
2695
+ async function claimEstablishing(host, ownerPid, orgKey, options = {}) {
2696
+ if (await writeEstablishingMarker(host, ownerPid, orgKey, options)) {
2697
+ return { outcome: "claimed" };
2698
+ }
2699
+ const existing = await readTunnelCacheEntry(host, options);
2700
+ if (existing === void 0) {
2701
+ return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
2702
+ }
2703
+ if (existing.status === "ready") {
2704
+ return { outcome: "already-ready", record: existing };
2705
+ }
2706
+ if (!isEstablishingStale(existing, options)) {
2707
+ return { outcome: "wait" };
2708
+ }
2709
+ await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
2710
+ return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
2711
+ }
2712
+ async function waitForEstablishment(host, deadline, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, options = {}) {
2713
+ for (; ; ) {
2714
+ const entry = await readTunnelCacheEntry(host, options);
2715
+ if (entry === void 0) {
2716
+ return void 0;
2717
+ }
2718
+ if (entry.status === "ready") {
2719
+ return entry;
2720
+ }
2721
+ const remainingMs = deadline - Date.now();
2722
+ if (remainingMs <= 0) {
2723
+ return void 0;
2724
+ }
2725
+ await sleep(Math.min(pollIntervalMs, remainingMs));
2726
+ }
2727
+ }
2728
+ async function finalizeEstablishingReady(host, record, options = {}) {
2729
+ const root = tunnelCacheRoot(options.saptoolsRoot);
2730
+ const path = tunnelCachePath(host, options.saptoolsRoot);
2731
+ const tempPath = `${path}.tmp-${String(process.pid)}`;
2732
+ const stored = { version: 1, status: "ready", host, ...record };
2733
+ await mkdir3(root, { recursive: true, mode: 448 });
2734
+ await rm4(tempPath, { force: true });
2735
+ await writeFile2(tempPath, `${JSON.stringify(stored)}
2736
+ `, { encoding: "utf8", mode: 384 });
2737
+ await rename(tempPath, path);
2738
+ }
2739
+ async function finalizeEstablishingFailed(host, options = {}) {
2740
+ await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
2741
+ }
2742
+ async function evictTunnelCache(host, options = {}) {
2743
+ await rm4(tunnelCachePath(host, options.saptoolsRoot), { force: true });
2744
+ }
2745
+ function sameOrg(a, b) {
2746
+ return a.apiEndpoint === b.apiEndpoint && a.orgName === b.orgName;
2747
+ }
2748
+ async function reapOneFile(path, currentOrgKey, options) {
2749
+ const entry = await parseTunnelCacheFile(path);
2750
+ if (entry === void 0) {
2751
+ return;
2752
+ }
2753
+ const killProcess = options.killProcess ?? killTunnelProcess;
2754
+ if (entry.status === "establishing") {
2755
+ if (isEstablishingStale(entry, options)) {
2756
+ await rm4(path, { force: true });
2757
+ }
2758
+ return;
2759
+ }
2760
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
2761
+ if (!isAlive(entry.pid)) {
2762
+ await rm4(path, { force: true });
2763
+ return;
2764
+ }
2765
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
2766
+ if (isExpired(entry, now) || !sameOrg(entry.orgKey, currentOrgKey)) {
2767
+ killProcess(entry.pid);
2768
+ await rm4(path, { force: true });
2769
+ }
2770
+ }
2771
+ async function reapStaleAndCrossOrgTunnels(currentOrgKey, options = {}) {
2772
+ const root = tunnelCacheRoot(options.saptoolsRoot);
2773
+ let files;
2774
+ try {
2775
+ files = await readdir2(root);
2776
+ } catch {
2777
+ return;
2778
+ }
2779
+ await Promise.all(
2780
+ files.filter((file) => file.endsWith(".json")).map(async (file) => {
2781
+ await reapOneFile(join5(root, file), currentOrgKey, options);
2782
+ })
2783
+ );
2784
+ }
2785
+
2786
+ // src/tunnel/candidates.ts
2787
+ var RUNNING_STATE = "started";
2788
+ var VALID_APP_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
2789
+ function isValidAppName(name) {
2790
+ return VALID_APP_NAME.test(name);
2791
+ }
2792
+ function discoveredAppNames(stdout, targetAppName) {
2793
+ if (stdout === void 0) {
2794
+ return [];
2795
+ }
2796
+ const seen = /* @__PURE__ */ new Set();
2797
+ const names = [];
2798
+ for (const row of parseCfAppsOutput(stdout)) {
2799
+ if (row.state !== RUNNING_STATE) {
2800
+ continue;
2801
+ }
2802
+ if (row.name === targetAppName || seen.has(row.name)) {
2803
+ continue;
2804
+ }
2805
+ if (!isValidAppName(row.name)) {
2806
+ continue;
2807
+ }
2808
+ seen.add(row.name);
2809
+ names.push(row.name);
2810
+ }
2811
+ return names;
2812
+ }
2813
+ function buildCandidateList(targetAppName, discoveredStdout, maxCandidates) {
2814
+ const discovered = discoveredAppNames(discoveredStdout, targetAppName);
2815
+ return [targetAppName, ...discovered.slice(0, maxCandidates)];
2816
+ }
2817
+
2818
+ // src/tunnel/classifier.ts
2819
+ var CONNECT_TIMEOUT_MESSAGE = "HANA connection timed out";
2820
+ var OPEN_CONNECTION_CODE = "EHDBOPENCONN";
2821
+ var MAX_CAUSE_DEPTH = 5;
2822
+ function causeCode(value) {
2823
+ return typeof value === "object" && value !== null ? value.code : void 0;
2824
+ }
2825
+ function causeOf(value) {
2826
+ return typeof value === "object" && value !== null ? value.cause : void 0;
2827
+ }
2828
+ function hasOpenConnectionCause(cause, depth) {
2829
+ if (depth > MAX_CAUSE_DEPTH) {
2830
+ return false;
2831
+ }
2832
+ if (causeCode(cause) === OPEN_CONNECTION_CODE) {
2833
+ return true;
2834
+ }
2835
+ return hasOpenConnectionCause(causeOf(cause), depth + 1);
2836
+ }
2837
+ function isConnectivityFailure(error) {
2838
+ if (!(error instanceof CfHanaError)) {
2839
+ return false;
2840
+ }
2841
+ if (error.code === "TIMEOUT") {
2842
+ return error.message.includes(CONNECT_TIMEOUT_MESSAGE);
2843
+ }
2844
+ if (error.code !== "CONNECTION") {
2845
+ return false;
2846
+ }
2847
+ return hasOpenConnectionCause(error.cause, 0);
2848
+ }
2849
+
2850
+ // src/tunnel/fallback.ts
2851
+ var EXPIRY_SAFETY_MARGIN_MS = 3e4;
2852
+ function directParamsOf(config) {
2853
+ return {
2854
+ host: config.host,
2855
+ port: config.port,
2856
+ user: config.user,
2857
+ password: config.password,
2858
+ schema: config.schema,
2859
+ certificate: config.certificate,
2860
+ connectTimeoutMs: config.connectTimeoutMs
2861
+ };
2862
+ }
2863
+ function tunneledParams(directParams, localPort) {
2864
+ return { ...directParams, host: "127.0.0.1", port: localPort, servername: directParams.host };
2865
+ }
2866
+ function orgKeyOf(config) {
2867
+ return { apiEndpoint: config.apiEndpoint, orgName: config.orgName };
2868
+ }
2869
+ function tunnelExpiryIso() {
2870
+ return new Date(
2871
+ Date.now() + DEFAULT_TUNNEL_KEEPALIVE_SECONDS * 1e3 - EXPIRY_SAFETY_MARGIN_MS
2872
+ ).toISOString();
2873
+ }
2874
+ async function discardQuietly(work) {
2875
+ await work.catch(() => {
2876
+ });
2877
+ }
2878
+ async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
2879
+ try {
2880
+ const connection = await driver.connect(tunneledParams(directParams, record.localPort));
2881
+ config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
2882
+ return connection;
2883
+ } catch (error) {
2884
+ if (isConnectivityFailure(error)) {
2885
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
2886
+ return void 0;
2887
+ }
2888
+ throw error;
2889
+ }
2890
+ }
2891
+ async function discoverAppsStdout(ctx) {
2892
+ try {
2893
+ return ctx === void 0 ? await cfAppsDirect() : await cfApps(ctx);
2894
+ } catch {
2895
+ return;
2896
+ }
2897
+ }
2898
+ async function buildCandidates(config, ctx, hintApp) {
2899
+ const stdout = await discoverAppsStdout(ctx);
2900
+ const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
2901
+ return hintApp === void 0 || base.includes(hintApp) ? base : [hintApp, ...base];
2902
+ }
2903
+ async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
2904
+ const readyRecord = {
2905
+ localPort: spawned.localPort,
2906
+ pid: spawned.pid,
2907
+ app,
2908
+ orgKey: orgKeyOf(config),
2909
+ expiresAt: tunnelExpiryIso()
2910
+ };
2911
+ try {
2912
+ const connection = await driver.connect(tunneledParams(directParams, spawned.localPort));
2913
+ await discardQuietly(finalizeEstablishingReady(config.host, readyRecord, cacheOptions));
2914
+ config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
2915
+ return connection;
2916
+ } catch (error) {
2917
+ if (isConnectivityFailure(error)) {
2918
+ await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
2919
+ killTunnelProcess(spawned.pid);
2920
+ return void 0;
2921
+ }
2922
+ await discardQuietly(finalizeEstablishingReady(config.host, readyRecord, cacheOptions));
2923
+ throw error;
2924
+ }
2925
+ }
2926
+ async function tryCandidate(app, ctx, driver, config, directParams, deadline, overrides) {
2927
+ const cacheOptions = overrides.cache ?? {};
2928
+ config.onTunnelStatus?.(`trying to reach ${config.host} via app ${app}...`);
2929
+ const claim = await claimEstablishing(config.host, process.pid, orgKeyOf(config), cacheOptions);
2930
+ if (claim.outcome === "already-ready") {
2931
+ return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
2932
+ }
2933
+ if (claim.outcome === "wait") {
2934
+ const ready = await waitForEstablishment(config.host, deadline, void 0, cacheOptions);
2935
+ return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
2936
+ }
2937
+ const spawned = await spawnTunnel(
2938
+ {
2939
+ cfHome: ctx?.cfHome,
2940
+ app,
2941
+ hanaHost: config.host,
2942
+ hanaPort: config.port,
2943
+ keepaliveSeconds: DEFAULT_TUNNEL_KEEPALIVE_SECONDS,
2944
+ deadline,
2945
+ candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
2946
+ },
2947
+ overrides.process ?? {}
2948
+ );
2949
+ if (spawned === void 0) {
2950
+ await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
2951
+ return void 0;
2952
+ }
2953
+ return await finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions);
2954
+ }
2955
+ async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
2956
+ if (config.refreshTunnel) {
2957
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
2958
+ return { connection: void 0, hintApp: void 0 };
2959
+ }
2960
+ const entry = await readTunnelCacheEntry(config.host, cacheOptions);
2961
+ if (entry?.status !== "ready") {
2962
+ return { connection: void 0, hintApp: void 0 };
2963
+ }
2964
+ if (!await isTunnelUsable(entry, cacheOptions)) {
2965
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
2966
+ return { connection: void 0, hintApp: entry.app };
2967
+ }
2968
+ const connection = await tryReadyRecord(entry, driver, config, directParams, cacheOptions);
2969
+ return { connection, hintApp: entry.app };
2970
+ }
2971
+ async function tryDirectConnect(driver, config, directParams) {
2972
+ if (config.tunnelMode !== "auto") {
2973
+ return { connection: void 0, directError: void 0 };
2974
+ }
2975
+ try {
2976
+ return { connection: await driver.connect(directParams), directError: void 0 };
2977
+ } catch (error) {
2978
+ if (!isConnectivityFailure(error)) {
2979
+ throw error;
2980
+ }
2981
+ return { connection: void 0, directError: error };
2982
+ }
2983
+ }
2984
+ function buildExhaustionError(host, directError, candidates) {
2985
+ if (directError !== void 0) {
2986
+ return directError;
2987
+ }
2988
+ return new CfHanaError(
2989
+ "CONNECTION",
2990
+ `Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
2991
+ );
2992
+ }
2993
+ async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
2994
+ const candidatesTried = await buildCandidates(config, ctx, hintApp);
2995
+ for (const app of candidatesTried) {
2996
+ if (Date.now() >= deadline) {
2997
+ break;
2998
+ }
2999
+ const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
3000
+ if (connection !== void 0) {
3001
+ return { connection, candidatesTried };
3002
+ }
3003
+ }
3004
+ return { connection: void 0, candidatesTried };
3005
+ }
3006
+ async function connectWithTunnelFallback(driver, config, overrides = {}) {
3007
+ const directParams = directParamsOf(config);
3008
+ const cacheOptions = overrides.cache ?? {};
3009
+ await discardQuietly(reapStaleAndCrossOrgTunnels(orgKeyOf(config), cacheOptions));
3010
+ const cached = await tryCachedTunnel(driver, config, directParams, cacheOptions);
3011
+ if (cached.connection !== void 0) {
3012
+ return cached.connection;
3013
+ }
3014
+ const direct = await tryDirectConnect(driver, config, directParams);
3015
+ if (direct.connection !== void 0) {
3016
+ return direct.connection;
3017
+ }
3018
+ const deadline = Date.now() + DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
3019
+ const target = {
3020
+ apiEndpoint: config.apiEndpoint,
3021
+ orgName: config.orgName,
3022
+ spaceName: config.spaceName
3023
+ };
3024
+ const loopResult = await withScopedCfSession(
3025
+ config.selectorSource,
3026
+ target,
3027
+ config.sapCredentials,
3028
+ (ctx) => runCandidateLoop(ctx, driver, config, directParams, deadline, cached.hintApp, overrides)
3029
+ );
3030
+ if (loopResult.connection !== void 0) {
3031
+ return loopResult.connection;
3032
+ }
3033
+ throw buildExhaustionError(config.host, direct.directError, loopResult.candidatesTried);
3034
+ }
3035
+
2363
3036
  // src/connection.ts
2364
3037
  function assertValidAutoLimit(value) {
2365
3038
  if (value !== false && (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER)) {
@@ -2453,15 +3126,7 @@ var Connection = class _Connection {
2453
3126
  driverConnection;
2454
3127
  config;
2455
3128
  static async open(driver, config) {
2456
- const driverConnection = await driver.connect({
2457
- host: config.host,
2458
- port: config.port,
2459
- user: config.user,
2460
- password: config.password,
2461
- schema: config.schema,
2462
- certificate: config.certificate,
2463
- connectTimeoutMs: config.connectTimeoutMs
2464
- });
3129
+ const driverConnection = await connectWithTunnelFallback(driver, config);
2465
3130
  return new _Connection(driverConnection, config);
2466
3131
  }
2467
3132
  async query(sql, params, options) {
@@ -2743,6 +3408,31 @@ function toPoolOptions(options) {
2743
3408
  }
2744
3409
  return options.pool ?? {};
2745
3410
  }
3411
+ function buildConnectionConfig(resolved, target, options) {
3412
+ const sapCredentials = readSapCredentials({ email: options.email, password: options.password });
3413
+ return {
3414
+ host: target.host,
3415
+ port: target.port,
3416
+ user: target.user,
3417
+ password: target.password,
3418
+ schema: target.schema,
3419
+ certificate: target.certificate,
3420
+ connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
3421
+ queryTimeoutMs: options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
3422
+ readOnly: options.readOnly ?? false,
3423
+ allowDestructive: options.allowDestructive ?? false,
3424
+ autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT,
3425
+ appName: resolved.appName,
3426
+ orgName: resolved.orgName,
3427
+ spaceName: resolved.spaceName,
3428
+ apiEndpoint: resolved.apiEndpoint,
3429
+ selectorSource: resolved.selectorSource,
3430
+ tunnelMode: options.tunnel === true ? "always" : "auto",
3431
+ refreshTunnel: options.refreshTunnel ?? false,
3432
+ ...sapCredentials === void 0 ? {} : { sapCredentials },
3433
+ ...options.onTunnelStatus === void 0 ? {} : { onTunnelStatus: options.onTunnelStatus }
3434
+ };
3435
+ }
2746
3436
  function toSelectedBindingInfo(bindings, selected) {
2747
3437
  const availableBindingNames = bindings.flatMap(
2748
3438
  (binding) => binding.name === void 0 ? [] : [binding.name]
@@ -2770,19 +3460,7 @@ var HanaClient = class _HanaClient {
2770
3460
  const bindingInfo = toSelectedBindingInfo(resolved.bindings, binding);
2771
3461
  const target = toConnectionTarget(binding, role);
2772
3462
  const driver = createDriver();
2773
- const config = {
2774
- host: target.host,
2775
- port: target.port,
2776
- user: target.user,
2777
- password: target.password,
2778
- schema: target.schema,
2779
- certificate: target.certificate,
2780
- connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
2781
- queryTimeoutMs: options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
2782
- readOnly: options.readOnly ?? false,
2783
- allowDestructive: options.allowDestructive ?? false,
2784
- autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT
2785
- };
3463
+ const config = buildConnectionConfig(resolved, target, options);
2786
3464
  const poolOptions = toPoolOptions(options);
2787
3465
  const info = {
2788
3466
  selector: resolved.selector,