@saptools/cf-hana 0.4.1 → 0.5.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/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
 
6
6
  // src/config.ts
7
7
  var CLI_NAME = "cf-hana";
8
- var CLI_VERSION = "0.4.0";
8
+ var CLI_VERSION = "0.5.1";
9
9
  var ENV_PREFIX = "CF_HANA";
10
10
  var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
11
11
  var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
@@ -18,6 +18,27 @@ var DEFAULT_RESULT_TTL_MINUTES = 10080;
18
18
  var DEFAULT_RESULT_SEARCH_LIMIT = 20;
19
19
  var DEFAULT_MAX_RESULT_STORE_BYTES = 256 * 1024 * 1024;
20
20
  var MAX_RESULT_STORE_BYTES = resolveMaxResultStoreBytes();
21
+ function resolvePositiveIntEnv(suffix, defaultValue) {
22
+ const raw = readEnv(envName(suffix));
23
+ if (raw === void 0) {
24
+ return defaultValue;
25
+ }
26
+ const parsed = Number(raw);
27
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : defaultValue;
28
+ }
29
+ var DEFAULT_TUNNEL_FALLBACK_BUDGET_MS = resolvePositiveIntEnv(
30
+ "TUNNEL_FALLBACK_BUDGET_MS",
31
+ 25e3
32
+ );
33
+ var DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS = resolvePositiveIntEnv(
34
+ "TUNNEL_CANDIDATE_TIMEOUT_MS",
35
+ 15e3
36
+ );
37
+ var DEFAULT_TUNNEL_MAX_CANDIDATES = resolvePositiveIntEnv("TUNNEL_MAX_CANDIDATES", 3);
38
+ var DEFAULT_TUNNEL_KEEPALIVE_SECONDS = resolvePositiveIntEnv(
39
+ "TUNNEL_KEEPALIVE_SECONDS",
40
+ 1200
41
+ );
21
42
  function resolveMaxResultStoreBytes() {
22
43
  if (readEnv(envName("DRIVER")) !== "fake") {
23
44
  return DEFAULT_MAX_RESULT_STORE_BYTES;
@@ -1052,7 +1073,7 @@ function findTopLevelChar(sql, target, startIndex, endIndex) {
1052
1073
  }
1053
1074
 
1054
1075
  // src/statements.ts
1055
- var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
1076
+ var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"]);
1056
1077
  var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
1057
1078
  var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
1058
1079
  function firstKeyword(sql) {
@@ -1092,8 +1113,165 @@ function firstKeyword(sql) {
1092
1113
  }
1093
1114
  return sql.slice(index, keywordEnd).toUpperCase();
1094
1115
  }
1095
- function classifyStatement(sql) {
1096
- const keyword = firstKeyword(sql);
1116
+ function skipQuotedText2(sql, start) {
1117
+ const quote = sql[start];
1118
+ let index = start + 1;
1119
+ while (index < sql.length) {
1120
+ if (sql[index] === quote) {
1121
+ if (sql[index + 1] === quote) {
1122
+ index += 2;
1123
+ continue;
1124
+ }
1125
+ index += 1;
1126
+ break;
1127
+ }
1128
+ index += 1;
1129
+ }
1130
+ return index;
1131
+ }
1132
+ function skipLineComment3(sql, start) {
1133
+ let index = start + 2;
1134
+ while (index < sql.length && sql[index] !== "\n") {
1135
+ index += 1;
1136
+ }
1137
+ return index;
1138
+ }
1139
+ function skipBlockComment3(sql, start) {
1140
+ let index = start + 2;
1141
+ while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
1142
+ index += 1;
1143
+ }
1144
+ return Math.min(index + 2, sql.length);
1145
+ }
1146
+ function maskIgnoredSqlText(sql) {
1147
+ let masked = "";
1148
+ let index = 0;
1149
+ while (index < sql.length) {
1150
+ const char = sql[index];
1151
+ if (char === "'" || char === '"') {
1152
+ const end = skipQuotedText2(sql, index);
1153
+ masked += " ".repeat(end - index);
1154
+ index = end;
1155
+ continue;
1156
+ }
1157
+ if (char === "-" && sql[index + 1] === "-") {
1158
+ const end = skipLineComment3(sql, index);
1159
+ masked += " ".repeat(end - index);
1160
+ index = end;
1161
+ continue;
1162
+ }
1163
+ if (char === "/" && sql[index + 1] === "*") {
1164
+ const end = skipBlockComment3(sql, index);
1165
+ masked += " ".repeat(end - index);
1166
+ index = end;
1167
+ continue;
1168
+ }
1169
+ masked += char ?? "";
1170
+ index += 1;
1171
+ }
1172
+ return masked;
1173
+ }
1174
+ function topLevelKeywordIndex(sql, keyword) {
1175
+ const masked = maskIgnoredSqlText(sql);
1176
+ let depth = 0;
1177
+ for (let index = 0; index < masked.length; index += 1) {
1178
+ const char = masked[index];
1179
+ if (char === "(") {
1180
+ depth += 1;
1181
+ continue;
1182
+ }
1183
+ if (char === ")") {
1184
+ depth = Math.max(0, depth - 1);
1185
+ continue;
1186
+ }
1187
+ if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
1188
+ return index;
1189
+ }
1190
+ }
1191
+ return void 0;
1192
+ }
1193
+ function hasTopLevelKeyword(sql, keyword) {
1194
+ return topLevelKeywordIndex(sql, keyword) !== void 0;
1195
+ }
1196
+ function isIdentifierChar2(char) {
1197
+ return /[A-Za-z0-9_$#]/.test(char);
1198
+ }
1199
+ function skipWhitespace(masked, start) {
1200
+ let index = start;
1201
+ while (index < masked.length && /\s/.test(masked.charAt(index))) {
1202
+ index += 1;
1203
+ }
1204
+ return index;
1205
+ }
1206
+ function matchingCloseParenIndex(masked, openIndex) {
1207
+ let depth = 0;
1208
+ for (let index = openIndex; index < masked.length; index += 1) {
1209
+ const char = masked.charAt(index);
1210
+ if (char === "(") {
1211
+ depth += 1;
1212
+ } else if (char === ")") {
1213
+ depth -= 1;
1214
+ if (depth === 0) {
1215
+ return index;
1216
+ }
1217
+ }
1218
+ }
1219
+ return void 0;
1220
+ }
1221
+ function isAsKeywordAt(masked, index) {
1222
+ return masked.slice(index, index + 2).toUpperCase() === "AS" && !isIdentifierChar2(masked.charAt(index - 1)) && !isIdentifierChar2(masked.charAt(index + 2));
1223
+ }
1224
+ function cteListEndIndex(masked, afterWith) {
1225
+ let index = afterWith;
1226
+ for (; ; ) {
1227
+ index = skipWhitespace(masked, index);
1228
+ while (index < masked.length && isIdentifierChar2(masked.charAt(index))) {
1229
+ index += 1;
1230
+ }
1231
+ index = skipWhitespace(masked, index);
1232
+ if (masked.charAt(index) === "(") {
1233
+ const columnListEnd = matchingCloseParenIndex(masked, index);
1234
+ if (columnListEnd === void 0) {
1235
+ return void 0;
1236
+ }
1237
+ index = skipWhitespace(masked, columnListEnd + 1);
1238
+ }
1239
+ if (!isAsKeywordAt(masked, index)) {
1240
+ return void 0;
1241
+ }
1242
+ index = skipWhitespace(masked, index + 2);
1243
+ if (masked.charAt(index) !== "(") {
1244
+ return void 0;
1245
+ }
1246
+ const closeIndex = matchingCloseParenIndex(masked, index);
1247
+ if (closeIndex === void 0) {
1248
+ return void 0;
1249
+ }
1250
+ index = skipWhitespace(masked, closeIndex + 1);
1251
+ if (masked.charAt(index) === ",") {
1252
+ index += 1;
1253
+ continue;
1254
+ }
1255
+ return index;
1256
+ }
1257
+ }
1258
+ function resolveWithStatement(sql) {
1259
+ const withIndex = topLevelKeywordIndex(sql, "WITH");
1260
+ if (withIndex === void 0) {
1261
+ return void 0;
1262
+ }
1263
+ const masked = maskIgnoredSqlText(sql);
1264
+ const trailingIndex = cteListEndIndex(masked, withIndex + "WITH".length);
1265
+ if (trailingIndex === void 0) {
1266
+ return void 0;
1267
+ }
1268
+ let keywordEnd = trailingIndex;
1269
+ while (keywordEnd < masked.length && isIdentifierChar2(masked.charAt(keywordEnd))) {
1270
+ keywordEnd += 1;
1271
+ }
1272
+ return { keyword: masked.slice(trailingIndex, keywordEnd).toUpperCase(), index: trailingIndex };
1273
+ }
1274
+ function classifyByKeyword(keyword) {
1097
1275
  if (SELECT_KEYWORDS.has(keyword)) {
1098
1276
  return "select";
1099
1277
  }
@@ -1105,6 +1283,14 @@ function classifyStatement(sql) {
1105
1283
  }
1106
1284
  return "unknown";
1107
1285
  }
1286
+ function classifyStatement(sql) {
1287
+ const keyword = firstKeyword(sql);
1288
+ if (keyword === "WITH") {
1289
+ const resolved = resolveWithStatement(sql);
1290
+ return resolved === void 0 ? "unknown" : classifyByKeyword(resolved.keyword);
1291
+ }
1292
+ return classifyByKeyword(keyword);
1293
+ }
1108
1294
  function quoteIdentifier(identifier) {
1109
1295
  if (identifier.length === 0) {
1110
1296
  throw new QueryError("A SQL identifier must not be empty");
@@ -1268,11 +1454,11 @@ function mergeTargetReference(statementSql, target, usingStart) {
1268
1454
  return target.reference;
1269
1455
  }
1270
1456
  if (/^PARTITION\b/i.test(suffix)) {
1271
- const open = suffix.indexOf("(");
1272
- if (open === -1) {
1457
+ const open2 = suffix.indexOf("(");
1458
+ if (open2 === -1) {
1273
1459
  return void 0;
1274
1460
  }
1275
- const close = findTopLevelChar(suffix, ")", open + 1, suffix.length);
1461
+ const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
1276
1462
  if (close === void 0) {
1277
1463
  return void 0;
1278
1464
  }
@@ -1455,13 +1641,19 @@ function dispatchWriteBackupPlan(keyword, statementSql, params) {
1455
1641
  }
1456
1642
  }
1457
1643
  function buildWriteBackupPlan(sql, params = []) {
1458
- const statementSql = trimStatementSql(sql);
1459
- const keyword = firstKeyword(statementSql);
1644
+ const trimmed = trimStatementSql(sql);
1645
+ const leading = firstKeyword(trimmed);
1646
+ const withResolved = leading === "WITH" ? resolveWithStatement(trimmed) : void 0;
1647
+ const keyword = withResolved?.keyword ?? leading;
1460
1648
  if (!isWriteKeyword(keyword)) {
1461
1649
  return void 0;
1462
1650
  }
1463
- assertParamArity(statementSql, params);
1464
- return dispatchWriteBackupPlan(keyword, statementSql, params);
1651
+ const startIndex = withResolved?.index ?? 0;
1652
+ const statementSql = trimmed.slice(startIndex);
1653
+ const slicedParams = params.slice(countPlaceholders(trimmed.slice(0, startIndex)));
1654
+ assertParamArity(statementSql, slicedParams);
1655
+ const plan = dispatchWriteBackupPlan(keyword, statementSql, slicedParams);
1656
+ return plan === void 0 ? void 0 : { ...plan, statementSql: trimmed };
1465
1657
  }
1466
1658
 
1467
1659
  // src/backup.ts
@@ -1790,9 +1982,9 @@ function buildEnv(ctx, overrides = {}) {
1790
1982
  env["CF_HOME"] = ctx.cfHome;
1791
1983
  return env;
1792
1984
  }
1793
- async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
1985
+ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = CF_RETRY_ATTEMPTS) {
1794
1986
  let lastErr;
1795
- for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
1987
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1796
1988
  try {
1797
1989
  const { stdout } = await execFileAsync(bin, args, {
1798
1990
  env,
@@ -1811,18 +2003,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
1811
2003
  if (isEnoent || !isTimeout && !isNetworkFlake) {
1812
2004
  throw err;
1813
2005
  }
1814
- if (attempt < CF_RETRY_ATTEMPTS - 1) {
2006
+ if (attempt < maxAttempts - 1) {
1815
2007
  await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
1816
2008
  }
1817
2009
  }
1818
2010
  }
1819
2011
  throw lastErr;
1820
2012
  }
1821
- async function runCf(args, ctx, overrides = {}) {
2013
+ async function runCf(args, ctx, overrides = {}, execOptions = {}) {
1822
2014
  const { bin, argsPrefix } = resolveCfBin();
1823
2015
  const env = buildEnv(ctx, overrides);
1824
2016
  try {
1825
- return await execWithRetries(bin, [...argsPrefix, ...args], env);
2017
+ return await execWithRetries(
2018
+ bin,
2019
+ [...argsPrefix, ...args],
2020
+ env,
2021
+ execOptions.timeoutMs,
2022
+ execOptions.maxAttempts
2023
+ );
1826
2024
  } catch (lastErr) {
1827
2025
  const e = lastErr;
1828
2026
  const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
@@ -1852,6 +2050,27 @@ async function cfEnvDirect(appName) {
1852
2050
  delete env["SAP_PASSWORD"];
1853
2051
  return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
1854
2052
  }
2053
+ async function cfApps(ctx, timeoutMs) {
2054
+ return await runCf(
2055
+ ["apps"],
2056
+ ctx,
2057
+ {},
2058
+ timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
2059
+ );
2060
+ }
2061
+ async function cfAppsDirect(timeoutMs) {
2062
+ const { bin, argsPrefix } = resolveCfBin();
2063
+ const env = { ...process.env };
2064
+ delete env["SAP_EMAIL"];
2065
+ delete env["SAP_PASSWORD"];
2066
+ return await execWithRetries(
2067
+ bin,
2068
+ [...argsPrefix, "apps"],
2069
+ env,
2070
+ timeoutMs,
2071
+ timeoutMs === void 0 ? void 0 : 1
2072
+ );
2073
+ }
1855
2074
  async function readCurrentCfTarget() {
1856
2075
  const { bin, argsPrefix } = resolveCfBin();
1857
2076
  const env = { ...process.env };
@@ -1901,6 +2120,26 @@ function parseTargetFields(stdout) {
1901
2120
  }
1902
2121
  return map;
1903
2122
  }
2123
+ function parseCfAppsOutput(stdout) {
2124
+ const lines = stdout.split(/\r?\n/);
2125
+ const headerIndex = lines.findIndex((line) => /^\s*name\s+\S/i.test(line));
2126
+ if (headerIndex === -1) {
2127
+ return [];
2128
+ }
2129
+ const rows = [];
2130
+ for (const line of lines.slice(headerIndex + 1)) {
2131
+ const trimmed = line.trim();
2132
+ if (trimmed.length === 0) {
2133
+ continue;
2134
+ }
2135
+ const [name, state] = trimmed.split(/\s+/);
2136
+ if (name === void 0 || state === void 0) {
2137
+ continue;
2138
+ }
2139
+ rows.push({ name, state });
2140
+ }
2141
+ return rows;
2142
+ }
1904
2143
  function formatCurrentCfAppSelector(target, appName) {
1905
2144
  const name = appName.trim();
1906
2145
  if (!name) {
@@ -2212,7 +2451,10 @@ async function resolveAppBindings(rawSelector, options) {
2212
2451
  source: "live",
2213
2452
  selectorSource: target.selectorSource,
2214
2453
  regionConfirmed: target.regionConfirmed,
2215
- selectorCanBePinned: target.selectorCanBePinned
2454
+ selectorCanBePinned: target.selectorCanBePinned,
2455
+ apiEndpoint: target.apiEndpoint,
2456
+ orgName: target.orgName,
2457
+ spaceName: target.spaceName
2216
2458
  };
2217
2459
  }
2218
2460
  async function readExplicitBindings(target, options) {
@@ -2278,6 +2520,7 @@ function toConnectionTarget(binding, role) {
2278
2520
  // src/driver/fake.ts
2279
2521
  import { appendFile } from "fs/promises";
2280
2522
  var catalogFailureInjected = false;
2523
+ var connectFailureInjectedOnce = false;
2281
2524
  function catalogObjects() {
2282
2525
  return [
2283
2526
  { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "EXISTING_TABLE", OBJECT_TYPE: "TABLE" },
@@ -2486,10 +2729,28 @@ var FakeConnection = class {
2486
2729
  return this.closed;
2487
2730
  }
2488
2731
  };
2732
+ function maybeFailConnect() {
2733
+ const mode = readEnv(envName("FAKE_FAIL_CONNECT"));
2734
+ if (mode !== "once" && mode !== "always") {
2735
+ return;
2736
+ }
2737
+ if (mode === "once" && connectFailureInjectedOnce) {
2738
+ return;
2739
+ }
2740
+ connectFailureInjectedOnce = true;
2741
+ const cause = Object.assign(
2742
+ new Error("Could not connect to any host: [ fake-host:443 - forced fixture failure ]"),
2743
+ { code: "EHDBOPENCONN" }
2744
+ );
2745
+ throw new CfHanaError("CONNECTION", `Failed to connect to HANA: ${cause.message}`, { cause });
2746
+ }
2489
2747
  function createFakeDriver() {
2490
2748
  return {
2491
2749
  name: "fake",
2492
- connect: (_params) => Promise.resolve(new FakeConnection())
2750
+ connect: (_params) => {
2751
+ maybeFailConnect();
2752
+ return Promise.resolve(new FakeConnection());
2753
+ }
2493
2754
  };
2494
2755
  }
2495
2756
 
@@ -2714,7 +2975,15 @@ async function connectHdb(params) {
2714
2975
  user: params.user,
2715
2976
  password: params.password,
2716
2977
  ca: params.certificate,
2717
- useTLS: true
2978
+ useTLS: true,
2979
+ // HANA Cloud can reactively redirect a connection mid-auth to an
2980
+ // internal per-node hostname (pod-locality routing). That target is
2981
+ // often unreachable outside SAP's own network, and for a tunneled
2982
+ // connection it would silently abandon the SSH forward for a fresh,
2983
+ // untunneled socket. The original bound host is always a real, working
2984
+ // endpoint, so disabling the redirect is safe on the direct path too.
2985
+ disableCloudRedirect: true,
2986
+ ...params.servername === void 0 ? {} : { servername: params.servername }
2718
2987
  });
2719
2988
  await openClient(client, params.connectTimeoutMs);
2720
2989
  try {
@@ -2823,83 +3092,6 @@ async function appendSqlHistory(input, options = {}) {
2823
3092
  // src/safety.ts
2824
3093
  var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
2825
3094
  var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
2826
- function skipQuotedText2(sql, start) {
2827
- const quote = sql[start];
2828
- let index = start + 1;
2829
- while (index < sql.length) {
2830
- if (sql[index] === quote) {
2831
- if (sql[index + 1] === quote) {
2832
- index += 2;
2833
- continue;
2834
- }
2835
- index += 1;
2836
- break;
2837
- }
2838
- index += 1;
2839
- }
2840
- return index;
2841
- }
2842
- function skipLineComment3(sql, start) {
2843
- let index = start + 2;
2844
- while (index < sql.length && sql[index] !== "\n") {
2845
- index += 1;
2846
- }
2847
- return index;
2848
- }
2849
- function skipBlockComment3(sql, start) {
2850
- let index = start + 2;
2851
- while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
2852
- index += 1;
2853
- }
2854
- return Math.min(index + 2, sql.length);
2855
- }
2856
- function maskIgnoredSqlText(sql) {
2857
- let masked = "";
2858
- let index = 0;
2859
- while (index < sql.length) {
2860
- const char = sql[index];
2861
- if (char === "'" || char === '"') {
2862
- const end = skipQuotedText2(sql, index);
2863
- masked += " ".repeat(end - index);
2864
- index = end;
2865
- continue;
2866
- }
2867
- if (char === "-" && sql[index + 1] === "-") {
2868
- const end = skipLineComment3(sql, index);
2869
- masked += " ".repeat(end - index);
2870
- index = end;
2871
- continue;
2872
- }
2873
- if (char === "/" && sql[index + 1] === "*") {
2874
- const end = skipBlockComment3(sql, index);
2875
- masked += " ".repeat(end - index);
2876
- index = end;
2877
- continue;
2878
- }
2879
- masked += char ?? "";
2880
- index += 1;
2881
- }
2882
- return masked;
2883
- }
2884
- function hasTopLevelKeyword(sql, keyword) {
2885
- const masked = maskIgnoredSqlText(sql);
2886
- let depth = 0;
2887
- for (let index = 0; index < masked.length; index += 1) {
2888
- const char = masked[index];
2889
- if (char === "(") {
2890
- depth += 1;
2891
- continue;
2892
- }
2893
- if (char === ")") {
2894
- depth = Math.max(0, depth - 1);
2895
- continue;
2896
- }
2897
- if (depth === 0 && masked.slice(index, index + keyword.length).toUpperCase() === keyword && !/[A-Za-z0-9_$#]/.test(masked.charAt(index - 1)) && !/[A-Za-z0-9_$#]/.test(masked.charAt(index + keyword.length))) {
2898
- return true;
2899
- }
2900
- }
2901
- return false;
2902
- }
2903
3095
  function isUnconditionalMergeDelete(sql) {
2904
3096
  return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
2905
3097
  }
@@ -2942,17 +3134,23 @@ function appendLimit(sql, limit) {
2942
3134
  }
2943
3135
  function inspectStatement(sql) {
2944
3136
  const kind = classifyStatement(sql);
2945
- const keyword = firstKeyword(sql);
3137
+ const leading = firstKeyword(sql);
3138
+ const withResolved = leading === "WITH" ? resolveWithStatement(sql) : void 0;
3139
+ const keyword = withResolved?.keyword ?? leading;
3140
+ const tail = withResolved === void 0 ? sql : sql.slice(withResolved.index);
2946
3141
  if (kind === "ddl") {
2947
3142
  return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
2948
3143
  }
2949
3144
  if (kind === "dml") {
2950
- const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(sql, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(sql) || keyword === "REPLACE" && isMalformedReplace(sql);
3145
+ const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(tail, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(tail) || keyword === "REPLACE" && isMalformedReplace(tail);
2951
3146
  return {
2952
3147
  kind,
2953
3148
  destructive
2954
3149
  };
2955
3150
  }
3151
+ if (kind === "unknown") {
3152
+ return { kind, destructive: keyword === "CALL" };
3153
+ }
2956
3154
  return { kind, destructive: false };
2957
3155
  }
2958
3156
  function evaluateGuard(sql, config) {
@@ -2995,6 +3193,707 @@ function applyAutoLimit(sql, limit) {
2995
3193
  };
2996
3194
  }
2997
3195
 
3196
+ // src/tunnel/cache.ts
3197
+ import { createHash as createHash2 } from "crypto";
3198
+ import { mkdir as mkdir4, readdir as readdir2, readFile as readFile3, rename as rename2, rm as rm5, writeFile as writeFile3 } from "fs/promises";
3199
+ import { homedir as homedir4 } from "os";
3200
+ import { join as join6 } from "path";
3201
+
3202
+ // src/tunnel/process.ts
3203
+ import { spawn } from "child_process";
3204
+ import { mkdtemp as mkdtemp2, open, readFile as readFile2, rm as rm4 } from "fs/promises";
3205
+ import { connect as netConnect, createServer } from "net";
3206
+ import { tmpdir as tmpdir2 } from "os";
3207
+ import { join as join5 } from "path";
3208
+ var PORT_POLL_INTERVAL_MS = 100;
3209
+ var DEFAULT_PORT_PROBE_TIMEOUT_MS = 500;
3210
+ function assertPositiveSafeInteger(label, value) {
3211
+ if (!Number.isSafeInteger(value) || value <= 0) {
3212
+ throw new CfHanaError("CONFIG", `${label} must be a positive integer`);
3213
+ }
3214
+ }
3215
+ async function allocateLocalPort() {
3216
+ return await new Promise((resolve, reject) => {
3217
+ const server = createServer();
3218
+ server.once("error", reject);
3219
+ server.listen(0, "127.0.0.1", () => {
3220
+ const address = server.address();
3221
+ server.close(() => {
3222
+ if (address === null || typeof address === "string") {
3223
+ reject(new CfHanaError("CONNECTION", "Could not allocate a local port for a tunnel"));
3224
+ return;
3225
+ }
3226
+ resolve(address.port);
3227
+ });
3228
+ });
3229
+ });
3230
+ }
3231
+ function probeLocalPort(port, timeoutMs = DEFAULT_PORT_PROBE_TIMEOUT_MS) {
3232
+ return new Promise((resolve) => {
3233
+ let settled = false;
3234
+ const socket = netConnect({ host: "127.0.0.1", port });
3235
+ const finish = (result) => {
3236
+ if (settled) {
3237
+ return;
3238
+ }
3239
+ settled = true;
3240
+ clearTimeout(timer);
3241
+ socket.destroy();
3242
+ resolve(result);
3243
+ };
3244
+ const timer = setTimeout(() => {
3245
+ finish(false);
3246
+ }, timeoutMs);
3247
+ timer.unref();
3248
+ socket.once("connect", () => {
3249
+ finish(true);
3250
+ });
3251
+ socket.once("error", () => {
3252
+ finish(false);
3253
+ });
3254
+ });
3255
+ }
3256
+ function killTunnelProcess(pid) {
3257
+ if (pid === void 0) {
3258
+ return;
3259
+ }
3260
+ try {
3261
+ process.kill(pid, "SIGTERM");
3262
+ } catch {
3263
+ }
3264
+ }
3265
+ async function withScopedCfSession(selectorSource, target, sapCredentials, work) {
3266
+ if (selectorSource === "ambient") {
3267
+ return await work();
3268
+ }
3269
+ if (sapCredentials === void 0) {
3270
+ throw new CredentialsNotFoundError(
3271
+ "SAP credentials are required to open a tunnel session for an explicit selector"
3272
+ );
3273
+ }
3274
+ const cfHome = await mkdtemp2(join5(tmpdir2(), "saptools-cf-hana-tunnel-"));
3275
+ try {
3276
+ const ctx = { cfHome };
3277
+ await cfApi(target.apiEndpoint, ctx);
3278
+ await cfAuth(sapCredentials.email, sapCredentials.password, ctx);
3279
+ await cfTargetSpace(target.orgName, target.spaceName, ctx);
3280
+ return await work(ctx);
3281
+ } finally {
3282
+ await rm4(cfHome, { recursive: true, force: true });
3283
+ }
3284
+ }
3285
+ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess) {
3286
+ return new Promise((resolve) => {
3287
+ let settled = false;
3288
+ const timers = {};
3289
+ const finish = (result) => {
3290
+ if (settled) {
3291
+ return;
3292
+ }
3293
+ settled = true;
3294
+ clearInterval(timers.poll);
3295
+ clearTimeout(timers.deadline);
3296
+ child.removeListener("exit", onAbort);
3297
+ child.removeListener("error", onAbort);
3298
+ if (result === void 0) {
3299
+ killProcess(child.pid);
3300
+ } else {
3301
+ child.unref();
3302
+ }
3303
+ resolve(result);
3304
+ };
3305
+ function onAbort() {
3306
+ finish();
3307
+ }
3308
+ child.on("exit", onAbort);
3309
+ child.on("error", onAbort);
3310
+ timers.poll = setInterval(() => {
3311
+ void probePort(localPort).then((open2) => {
3312
+ if (open2 && child.pid !== void 0) {
3313
+ finish({ localPort, pid: child.pid });
3314
+ }
3315
+ });
3316
+ }, PORT_POLL_INTERVAL_MS);
3317
+ timers.deadline = setTimeout(finish, boundMs);
3318
+ });
3319
+ }
3320
+ var STDERR_TAIL_MAX_CHARS = 4096;
3321
+ async function openStderrCapture() {
3322
+ try {
3323
+ const dir = await mkdtemp2(join5(tmpdir2(), "saptools-cf-hana-tunnel-log-"));
3324
+ const path = join5(dir, "stderr.log");
3325
+ const handle = await open(path, "w");
3326
+ return {
3327
+ path,
3328
+ fd: handle.fd,
3329
+ close: async () => {
3330
+ await handle.close().catch(() => {
3331
+ });
3332
+ await rm4(dir, { recursive: true, force: true });
3333
+ }
3334
+ };
3335
+ } catch {
3336
+ return void 0;
3337
+ }
3338
+ }
3339
+ async function readStderrTail(path) {
3340
+ try {
3341
+ const content = (await readFile2(path, "utf8")).trim().replace(/\s+/g, " ");
3342
+ if (content.length === 0) {
3343
+ return void 0;
3344
+ }
3345
+ return content.length > STDERR_TAIL_MAX_CHARS ? content.slice(-STDERR_TAIL_MAX_CHARS) : content;
3346
+ } catch {
3347
+ return void 0;
3348
+ }
3349
+ }
3350
+ async function spawnTunnel(params, deps = {}) {
3351
+ assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
3352
+ const remainingMs = params.deadline - Date.now();
3353
+ if (remainingMs <= 0) {
3354
+ return void 0;
3355
+ }
3356
+ const boundMs = Math.min(remainingMs, params.candidateTimeoutMs);
3357
+ const spawnProcess = deps.spawnProcess ?? spawn;
3358
+ const probePort = deps.probePort ?? probeLocalPort;
3359
+ const allocatePort = deps.allocatePort ?? allocateLocalPort;
3360
+ const killProcess = deps.killProcess ?? killTunnelProcess;
3361
+ const localPort = await allocatePort();
3362
+ const { bin, argsPrefix } = resolveCfBin();
3363
+ const forward = `${String(localPort)}:${params.hanaHost}:${String(params.hanaPort)}`;
3364
+ const args = [
3365
+ ...argsPrefix,
3366
+ "ssh",
3367
+ params.app,
3368
+ "-L",
3369
+ forward,
3370
+ "-c",
3371
+ `sleep ${String(params.keepaliveSeconds)}`
3372
+ ];
3373
+ const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
3374
+ const openCapture = deps.openStderrCapture ?? openStderrCapture;
3375
+ const capture = await openCapture();
3376
+ const stdio = capture === void 0 ? "ignore" : ["ignore", "ignore", capture.fd];
3377
+ const child = spawnProcess(bin, args, { detached: true, stdio, env });
3378
+ const result = await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
3379
+ if (capture !== void 0) {
3380
+ if (result === void 0) {
3381
+ try {
3382
+ const tail = await readStderrTail(capture.path);
3383
+ if (tail !== void 0) {
3384
+ deps.onFailureDiagnostic?.(tail);
3385
+ }
3386
+ } catch {
3387
+ }
3388
+ }
3389
+ await capture.close();
3390
+ }
3391
+ return result;
3392
+ }
3393
+
3394
+ // src/tunnel/cache.ts
3395
+ var DEFAULT_ESTABLISHING_STALE_MS = 2 * DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
3396
+ var DEFAULT_POLL_INTERVAL_MS = 300;
3397
+ function isRecord3(value) {
3398
+ return typeof value === "object" && value !== null;
3399
+ }
3400
+ function defaultIsProcessAlive(pid) {
3401
+ try {
3402
+ process.kill(pid, 0);
3403
+ return true;
3404
+ } catch (error) {
3405
+ return isRecord3(error) && error["code"] !== "ESRCH";
3406
+ }
3407
+ }
3408
+ function sleep(ms) {
3409
+ return new Promise((resolve) => {
3410
+ setTimeout(resolve, ms);
3411
+ });
3412
+ }
3413
+ function tunnelCacheRoot(saptoolsRoot) {
3414
+ return join6(saptoolsRoot ?? join6(homedir4(), ".saptools"), "cf-hana", "tunnel");
3415
+ }
3416
+ function tunnelCacheKey(host) {
3417
+ return createHash2("sha256").update(host).digest("hex");
3418
+ }
3419
+ function tunnelCachePath(host, saptoolsRoot) {
3420
+ return join6(tunnelCacheRoot(saptoolsRoot), `${tunnelCacheKey(host)}.json`);
3421
+ }
3422
+ function isOrgKey(value) {
3423
+ return isRecord3(value) && typeof value["apiEndpoint"] === "string" && typeof value["orgName"] === "string";
3424
+ }
3425
+ function hasEstablishingFields(value) {
3426
+ return value["status"] === "establishing" && typeof value["host"] === "string" && typeof value["startedAt"] === "string" && typeof value["ownerPid"] === "number" && isOrgKey(value["orgKey"]);
3427
+ }
3428
+ function hasReadyFields(value) {
3429
+ 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"]);
3430
+ }
3431
+ function isTunnelCacheRecord(value) {
3432
+ return isRecord3(value) && value["version"] === 1 && (hasEstablishingFields(value) || hasReadyFields(value));
3433
+ }
3434
+ async function parseTunnelCacheFile(path) {
3435
+ try {
3436
+ const raw = JSON.parse(await readFile3(path, "utf8"));
3437
+ return isTunnelCacheRecord(raw) ? raw : void 0;
3438
+ } catch {
3439
+ return void 0;
3440
+ }
3441
+ }
3442
+ async function readTunnelCacheEntry(host, options = {}) {
3443
+ const entry = await parseTunnelCacheFile(tunnelCachePath(host, options.saptoolsRoot));
3444
+ return entry?.host === host ? entry : void 0;
3445
+ }
3446
+ function isExpired(entry, now) {
3447
+ const expiresAt = Date.parse(entry.expiresAt);
3448
+ return !Number.isFinite(expiresAt) || now.getTime() >= expiresAt;
3449
+ }
3450
+ async function isTunnelUsable(entry, options = {}) {
3451
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
3452
+ if (!isAlive(entry.pid)) {
3453
+ return false;
3454
+ }
3455
+ if (isExpired(entry, options.now?.() ?? /* @__PURE__ */ new Date())) {
3456
+ return false;
3457
+ }
3458
+ const probePort = options.probePort ?? probeLocalPort;
3459
+ return await probePort(entry.localPort);
3460
+ }
3461
+ function isEstablishingStale(entry, options) {
3462
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
3463
+ if (!isAlive(entry.ownerPid)) {
3464
+ return true;
3465
+ }
3466
+ const startedAt = Date.parse(entry.startedAt);
3467
+ if (!Number.isFinite(startedAt)) {
3468
+ return true;
3469
+ }
3470
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
3471
+ const staleAfterMs = options.staleAfterMs ?? DEFAULT_ESTABLISHING_STALE_MS;
3472
+ return now.getTime() - startedAt >= staleAfterMs;
3473
+ }
3474
+ async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
3475
+ const root = tunnelCacheRoot(options.saptoolsRoot);
3476
+ await mkdir4(root, { recursive: true, mode: 448 });
3477
+ const record = {
3478
+ version: 1,
3479
+ status: "establishing",
3480
+ host,
3481
+ startedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
3482
+ ownerPid,
3483
+ orgKey
3484
+ };
3485
+ try {
3486
+ await writeFile3(tunnelCachePath(host, options.saptoolsRoot), `${JSON.stringify(record)}
3487
+ `, {
3488
+ encoding: "utf8",
3489
+ mode: 384,
3490
+ flag: "wx"
3491
+ });
3492
+ return true;
3493
+ } catch (error) {
3494
+ if (isRecord3(error) && error["code"] === "EEXIST") {
3495
+ return false;
3496
+ }
3497
+ throw error;
3498
+ }
3499
+ }
3500
+ async function claimEstablishing(host, ownerPid, orgKey, options = {}) {
3501
+ if (await writeEstablishingMarker(host, ownerPid, orgKey, options)) {
3502
+ return { outcome: "claimed" };
3503
+ }
3504
+ const existing = await readTunnelCacheEntry(host, options);
3505
+ if (existing === void 0) {
3506
+ return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
3507
+ }
3508
+ if (existing.status === "ready") {
3509
+ return { outcome: "already-ready", record: existing };
3510
+ }
3511
+ if (!isEstablishingStale(existing, options)) {
3512
+ return { outcome: "wait" };
3513
+ }
3514
+ await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3515
+ return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
3516
+ }
3517
+ async function waitForEstablishment(host, deadline, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, options = {}) {
3518
+ for (; ; ) {
3519
+ const entry = await readTunnelCacheEntry(host, options);
3520
+ if (entry === void 0) {
3521
+ return void 0;
3522
+ }
3523
+ if (entry.status === "ready") {
3524
+ return entry;
3525
+ }
3526
+ const remainingMs = deadline - Date.now();
3527
+ if (remainingMs <= 0) {
3528
+ return void 0;
3529
+ }
3530
+ await sleep(Math.min(pollIntervalMs, remainingMs));
3531
+ }
3532
+ }
3533
+ async function finalizeEstablishingReady(host, record, options = {}) {
3534
+ const root = tunnelCacheRoot(options.saptoolsRoot);
3535
+ const path = tunnelCachePath(host, options.saptoolsRoot);
3536
+ const tempPath = `${path}.tmp-${String(process.pid)}`;
3537
+ const stored = { version: 1, status: "ready", host, ...record };
3538
+ await mkdir4(root, { recursive: true, mode: 448 });
3539
+ await rm5(tempPath, { force: true });
3540
+ await writeFile3(tempPath, `${JSON.stringify(stored)}
3541
+ `, { encoding: "utf8", mode: 384 });
3542
+ await rename2(tempPath, path);
3543
+ }
3544
+ async function finalizeEstablishingFailed(host, options = {}) {
3545
+ await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3546
+ }
3547
+ async function evictTunnelCache(host, options = {}) {
3548
+ await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3549
+ }
3550
+ function sameOrg(a, b) {
3551
+ return a.apiEndpoint === b.apiEndpoint && a.orgName === b.orgName;
3552
+ }
3553
+ async function reapOneFile(path, currentOrgKey, options) {
3554
+ const entry = await parseTunnelCacheFile(path);
3555
+ if (entry === void 0) {
3556
+ return;
3557
+ }
3558
+ const killProcess = options.killProcess ?? killTunnelProcess;
3559
+ if (entry.status === "establishing") {
3560
+ if (isEstablishingStale(entry, options)) {
3561
+ await rm5(path, { force: true });
3562
+ }
3563
+ return;
3564
+ }
3565
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
3566
+ if (!isAlive(entry.pid)) {
3567
+ await rm5(path, { force: true });
3568
+ return;
3569
+ }
3570
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
3571
+ if (isExpired(entry, now) || !sameOrg(entry.orgKey, currentOrgKey)) {
3572
+ killProcess(entry.pid);
3573
+ await rm5(path, { force: true });
3574
+ }
3575
+ }
3576
+ async function reapStaleAndCrossOrgTunnels(currentOrgKey, options = {}) {
3577
+ const root = tunnelCacheRoot(options.saptoolsRoot);
3578
+ let files;
3579
+ try {
3580
+ files = await readdir2(root);
3581
+ } catch {
3582
+ return;
3583
+ }
3584
+ await Promise.all(
3585
+ files.filter((file) => file.endsWith(".json")).map(async (file) => {
3586
+ await reapOneFile(join6(root, file), currentOrgKey, options);
3587
+ })
3588
+ );
3589
+ }
3590
+
3591
+ // src/tunnel/candidates.ts
3592
+ var RUNNING_STATE = "started";
3593
+ var VALID_APP_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
3594
+ function isValidAppName(name) {
3595
+ return VALID_APP_NAME.test(name);
3596
+ }
3597
+ function discoveredAppNames(stdout, targetAppName) {
3598
+ if (stdout === void 0) {
3599
+ return [];
3600
+ }
3601
+ const seen = /* @__PURE__ */ new Set();
3602
+ const names = [];
3603
+ for (const row of parseCfAppsOutput(stdout)) {
3604
+ if (row.state !== RUNNING_STATE) {
3605
+ continue;
3606
+ }
3607
+ if (row.name === targetAppName || seen.has(row.name)) {
3608
+ continue;
3609
+ }
3610
+ if (!isValidAppName(row.name)) {
3611
+ continue;
3612
+ }
3613
+ seen.add(row.name);
3614
+ names.push(row.name);
3615
+ }
3616
+ return names;
3617
+ }
3618
+ function buildCandidateList(targetAppName, discoveredStdout, maxCandidates) {
3619
+ const discovered = discoveredAppNames(discoveredStdout, targetAppName);
3620
+ return [targetAppName, ...discovered.slice(0, maxCandidates)];
3621
+ }
3622
+
3623
+ // src/tunnel/classifier.ts
3624
+ var CONNECT_TIMEOUT_MESSAGE = "HANA connection timed out";
3625
+ var OPEN_CONNECTION_CODE = "EHDBOPENCONN";
3626
+ var MAX_CAUSE_DEPTH = 5;
3627
+ function causeCode(value) {
3628
+ return typeof value === "object" && value !== null ? value.code : void 0;
3629
+ }
3630
+ function causeOf(value) {
3631
+ return typeof value === "object" && value !== null ? value.cause : void 0;
3632
+ }
3633
+ function hasOpenConnectionCause(cause, depth) {
3634
+ if (depth > MAX_CAUSE_DEPTH) {
3635
+ return false;
3636
+ }
3637
+ if (causeCode(cause) === OPEN_CONNECTION_CODE) {
3638
+ return true;
3639
+ }
3640
+ return hasOpenConnectionCause(causeOf(cause), depth + 1);
3641
+ }
3642
+ function isConnectivityFailure(error) {
3643
+ if (!(error instanceof CfHanaError)) {
3644
+ return false;
3645
+ }
3646
+ if (error.code === "TIMEOUT") {
3647
+ return error.message.includes(CONNECT_TIMEOUT_MESSAGE);
3648
+ }
3649
+ if (error.code !== "CONNECTION") {
3650
+ return false;
3651
+ }
3652
+ return hasOpenConnectionCause(error.cause, 0);
3653
+ }
3654
+ function isUnattributedQueryFailure(error) {
3655
+ return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
3656
+ }
3657
+
3658
+ // src/tunnel/fallback.ts
3659
+ var EXPIRY_SAFETY_MARGIN_MS = 3e4;
3660
+ function directParamsOf(config) {
3661
+ return {
3662
+ host: config.host,
3663
+ port: config.port,
3664
+ user: config.user,
3665
+ password: config.password,
3666
+ schema: config.schema,
3667
+ certificate: config.certificate,
3668
+ connectTimeoutMs: config.connectTimeoutMs
3669
+ };
3670
+ }
3671
+ function tunneledParams(directParams, localPort) {
3672
+ return { ...directParams, host: "127.0.0.1", port: localPort, servername: directParams.host };
3673
+ }
3674
+ function orgKeyOf(config) {
3675
+ return { apiEndpoint: config.apiEndpoint, orgName: config.orgName };
3676
+ }
3677
+ function tunnelExpiryIso() {
3678
+ return new Date(
3679
+ Date.now() + DEFAULT_TUNNEL_KEEPALIVE_SECONDS * 1e3 - EXPIRY_SAFETY_MARGIN_MS
3680
+ ).toISOString();
3681
+ }
3682
+ async function discardQuietly(work) {
3683
+ await work.catch(() => {
3684
+ });
3685
+ }
3686
+ function shouldDiscardTunnel(error) {
3687
+ return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
3688
+ }
3689
+ async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
3690
+ try {
3691
+ const connection = await driver.connect(tunneledParams(directParams, record.localPort));
3692
+ config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
3693
+ return connection;
3694
+ } catch (error) {
3695
+ if (shouldDiscardTunnel(error)) {
3696
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3697
+ return void 0;
3698
+ }
3699
+ throw error;
3700
+ }
3701
+ }
3702
+ function knownCandidates(targetAppName, hintApp) {
3703
+ return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
3704
+ }
3705
+ async function discoverAppsStdout(ctx, timeoutMs) {
3706
+ try {
3707
+ return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
3708
+ } catch {
3709
+ return;
3710
+ }
3711
+ }
3712
+ async function discoverExtraCandidates(config, ctx, alreadyTried, deadline) {
3713
+ const remainingMs = deadline - Date.now();
3714
+ if (remainingMs <= 0) {
3715
+ return [];
3716
+ }
3717
+ const stdout = await discoverAppsStdout(ctx, remainingMs);
3718
+ const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
3719
+ return base.filter((app) => !alreadyTried.includes(app));
3720
+ }
3721
+ async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
3722
+ const readyRecord = {
3723
+ localPort: spawned.localPort,
3724
+ pid: spawned.pid,
3725
+ app,
3726
+ orgKey: orgKeyOf(config),
3727
+ expiresAt: tunnelExpiryIso()
3728
+ };
3729
+ try {
3730
+ const connection = await driver.connect(tunneledParams(directParams, spawned.localPort));
3731
+ await discardQuietly(finalizeEstablishingReady(config.host, readyRecord, cacheOptions));
3732
+ config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
3733
+ return connection;
3734
+ } catch (error) {
3735
+ if (shouldDiscardTunnel(error)) {
3736
+ await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
3737
+ killTunnelProcess(spawned.pid);
3738
+ return void 0;
3739
+ }
3740
+ await discardQuietly(finalizeEstablishingReady(config.host, readyRecord, cacheOptions));
3741
+ throw error;
3742
+ }
3743
+ }
3744
+ async function tryCandidate(app, ctx, driver, config, directParams, deadline, overrides) {
3745
+ const cacheOptions = overrides.cache ?? {};
3746
+ config.onTunnelStatus?.(`trying to reach ${config.host} via app ${app}...`);
3747
+ const claim = await claimEstablishing(config.host, process.pid, orgKeyOf(config), cacheOptions);
3748
+ if (claim.outcome === "already-ready") {
3749
+ return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
3750
+ }
3751
+ if (claim.outcome === "wait") {
3752
+ const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
3753
+ const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
3754
+ return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
3755
+ }
3756
+ const spawned = await spawnTunnel(
3757
+ {
3758
+ cfHome: ctx?.cfHome,
3759
+ app,
3760
+ hanaHost: config.host,
3761
+ hanaPort: config.port,
3762
+ keepaliveSeconds: DEFAULT_TUNNEL_KEEPALIVE_SECONDS,
3763
+ deadline,
3764
+ candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
3765
+ },
3766
+ {
3767
+ ...overrides.process,
3768
+ onFailureDiagnostic: (message) => {
3769
+ config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
3770
+ }
3771
+ }
3772
+ );
3773
+ if (spawned === void 0) {
3774
+ await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
3775
+ return void 0;
3776
+ }
3777
+ return await finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions);
3778
+ }
3779
+ async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
3780
+ if (config.refreshTunnel) {
3781
+ const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
3782
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3783
+ if (superseded?.status === "ready") {
3784
+ (cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
3785
+ }
3786
+ return { connection: void 0, hintApp: void 0 };
3787
+ }
3788
+ const entry = await readTunnelCacheEntry(config.host, cacheOptions);
3789
+ if (entry?.status !== "ready") {
3790
+ return { connection: void 0, hintApp: void 0 };
3791
+ }
3792
+ if (!await isTunnelUsable(entry, cacheOptions)) {
3793
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3794
+ return { connection: void 0, hintApp: entry.app };
3795
+ }
3796
+ const connection = await tryReadyRecord(entry, driver, config, directParams, cacheOptions);
3797
+ return { connection, hintApp: entry.app };
3798
+ }
3799
+ async function tryDirectConnect(driver, config, directParams) {
3800
+ if (config.tunnelMode !== "auto") {
3801
+ return { connection: void 0, directError: void 0 };
3802
+ }
3803
+ const boundedDirectParams = {
3804
+ ...directParams,
3805
+ connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
3806
+ };
3807
+ try {
3808
+ return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
3809
+ } catch (error) {
3810
+ if (!isConnectivityFailure(error)) {
3811
+ throw error;
3812
+ }
3813
+ return { connection: void 0, directError: error };
3814
+ }
3815
+ }
3816
+ function buildExhaustionError(host, directError, candidates) {
3817
+ if (directError !== void 0) {
3818
+ return directError;
3819
+ }
3820
+ return new CfHanaError(
3821
+ "CONNECTION",
3822
+ `Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
3823
+ );
3824
+ }
3825
+ async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
3826
+ for (const app of apps) {
3827
+ if (Date.now() >= deadline) {
3828
+ return void 0;
3829
+ }
3830
+ tried.push(app);
3831
+ const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
3832
+ if (connection !== void 0) {
3833
+ return connection;
3834
+ }
3835
+ }
3836
+ return void 0;
3837
+ }
3838
+ async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
3839
+ const tried = [];
3840
+ const known = knownCandidates(config.appName, hintApp);
3841
+ const knownConnection = await tryCandidateList(
3842
+ known,
3843
+ ctx,
3844
+ driver,
3845
+ config,
3846
+ directParams,
3847
+ deadline,
3848
+ overrides,
3849
+ tried
3850
+ );
3851
+ if (knownConnection !== void 0) {
3852
+ return { connection: knownConnection, candidatesTried: tried };
3853
+ }
3854
+ const discovered = await discoverExtraCandidates(config, ctx, tried, deadline);
3855
+ const discoveredConnection = await tryCandidateList(
3856
+ discovered,
3857
+ ctx,
3858
+ driver,
3859
+ config,
3860
+ directParams,
3861
+ deadline,
3862
+ overrides,
3863
+ tried
3864
+ );
3865
+ return { connection: discoveredConnection, candidatesTried: tried };
3866
+ }
3867
+ async function connectWithTunnelFallback(driver, config, overrides = {}) {
3868
+ const directParams = directParamsOf(config);
3869
+ const cacheOptions = overrides.cache ?? {};
3870
+ await discardQuietly(reapStaleAndCrossOrgTunnels(orgKeyOf(config), cacheOptions));
3871
+ const cached = await tryCachedTunnel(driver, config, directParams, cacheOptions);
3872
+ if (cached.connection !== void 0) {
3873
+ return cached.connection;
3874
+ }
3875
+ const direct = await tryDirectConnect(driver, config, directParams);
3876
+ if (direct.connection !== void 0) {
3877
+ return direct.connection;
3878
+ }
3879
+ const deadline = Date.now() + DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
3880
+ const target = {
3881
+ apiEndpoint: config.apiEndpoint,
3882
+ orgName: config.orgName,
3883
+ spaceName: config.spaceName
3884
+ };
3885
+ const loopResult = await withScopedCfSession(
3886
+ config.selectorSource,
3887
+ target,
3888
+ config.sapCredentials,
3889
+ (ctx) => runCandidateLoop(ctx, driver, config, directParams, deadline, cached.hintApp, overrides)
3890
+ );
3891
+ if (loopResult.connection !== void 0) {
3892
+ return loopResult.connection;
3893
+ }
3894
+ throw buildExhaustionError(config.host, direct.directError, loopResult.candidatesTried);
3895
+ }
3896
+
2998
3897
  // src/connection.ts
2999
3898
  function assertValidAutoLimit(value) {
3000
3899
  if (value !== false && (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER)) {
@@ -3088,15 +3987,7 @@ var Connection = class _Connection {
3088
3987
  driverConnection;
3089
3988
  config;
3090
3989
  static async open(driver, config) {
3091
- const driverConnection = await driver.connect({
3092
- host: config.host,
3093
- port: config.port,
3094
- user: config.user,
3095
- password: config.password,
3096
- schema: config.schema,
3097
- certificate: config.certificate,
3098
- connectTimeoutMs: config.connectTimeoutMs
3099
- });
3990
+ const driverConnection = await connectWithTunnelFallback(driver, config);
3100
3991
  return new _Connection(driverConnection, config);
3101
3992
  }
3102
3993
  async query(sql, params, options) {
@@ -3378,6 +4269,31 @@ function toPoolOptions(options) {
3378
4269
  }
3379
4270
  return options.pool ?? {};
3380
4271
  }
4272
+ function buildConnectionConfig(resolved, target, options) {
4273
+ const sapCredentials = readSapCredentials({ email: options.email, password: options.password });
4274
+ return {
4275
+ host: target.host,
4276
+ port: target.port,
4277
+ user: target.user,
4278
+ password: target.password,
4279
+ schema: target.schema,
4280
+ certificate: target.certificate,
4281
+ connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
4282
+ queryTimeoutMs: options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
4283
+ readOnly: options.readOnly ?? false,
4284
+ allowDestructive: options.allowDestructive ?? false,
4285
+ autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT,
4286
+ appName: resolved.appName,
4287
+ orgName: resolved.orgName,
4288
+ spaceName: resolved.spaceName,
4289
+ apiEndpoint: resolved.apiEndpoint,
4290
+ selectorSource: resolved.selectorSource,
4291
+ tunnelMode: options.tunnel === true ? "always" : "auto",
4292
+ refreshTunnel: options.refreshTunnel ?? false,
4293
+ ...sapCredentials === void 0 ? {} : { sapCredentials },
4294
+ ...options.onTunnelStatus === void 0 ? {} : { onTunnelStatus: options.onTunnelStatus }
4295
+ };
4296
+ }
3381
4297
  function toSelectedBindingInfo(bindings, selected) {
3382
4298
  const availableBindingNames = bindings.flatMap(
3383
4299
  (binding) => binding.name === void 0 ? [] : [binding.name]
@@ -3405,19 +4321,7 @@ var HanaClient = class _HanaClient {
3405
4321
  const bindingInfo = toSelectedBindingInfo(resolved.bindings, binding);
3406
4322
  const target = toConnectionTarget(binding, role);
3407
4323
  const driver = createDriver();
3408
- const config = {
3409
- host: target.host,
3410
- port: target.port,
3411
- user: target.user,
3412
- password: target.password,
3413
- schema: target.schema,
3414
- certificate: target.certificate,
3415
- connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
3416
- queryTimeoutMs: options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
3417
- readOnly: options.readOnly ?? false,
3418
- allowDestructive: options.allowDestructive ?? false,
3419
- autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT
3420
- };
4324
+ const config = buildConnectionConfig(resolved, target, options);
3421
4325
  const poolOptions = toPoolOptions(options);
3422
4326
  const info = {
3423
4327
  selector: resolved.selector,
@@ -3614,7 +4518,7 @@ async function connect(selector, options) {
3614
4518
  }
3615
4519
 
3616
4520
  // src/cli-results.ts
3617
- import { writeFile as writeFile4 } from "fs/promises";
4521
+ import { writeFile as writeFile5 } from "fs/promises";
3618
4522
 
3619
4523
  // src/result-inspect.ts
3620
4524
  function assertPositiveInteger(name, value) {
@@ -3886,19 +4790,19 @@ function searchResultSession(session, searchTerm, options) {
3886
4790
 
3887
4791
  // src/result-store.ts
3888
4792
  import { randomBytes } from "crypto";
3889
- import { mkdir as mkdir4, readFile as readFile2, readdir as readdir2, rename as rename2, rm as rm4, writeFile as writeFile3 } from "fs/promises";
3890
- import { homedir as homedir4 } from "os";
3891
- import { join as join5 } from "path";
4793
+ import { mkdir as mkdir5, readFile as readFile4, readdir as readdir3, rename as rename3, rm as rm6, writeFile as writeFile4 } from "fs/promises";
4794
+ import { homedir as homedir5 } from "os";
4795
+ import { join as join7 } from "path";
3892
4796
  var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
3893
4797
  var MANIFEST_FILE_NAME = "manifest.json";
3894
4798
  function resultsRoot(saptoolsRoot) {
3895
- return join5(saptoolsRoot ?? join5(homedir4(), ".saptools"), "cf-hana", "results");
4799
+ return join7(saptoolsRoot ?? join7(homedir5(), ".saptools"), "cf-hana", "results");
3896
4800
  }
3897
4801
  function sessionDirectory(ref, saptoolsRoot) {
3898
- return join5(resultsRoot(saptoolsRoot), ref);
4802
+ return join7(resultsRoot(saptoolsRoot), ref);
3899
4803
  }
3900
4804
  function manifestPath(ref, saptoolsRoot) {
3901
- return join5(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
4805
+ return join7(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
3902
4806
  }
3903
4807
  function encodeCell(value) {
3904
4808
  if (value === null) {
@@ -3997,11 +4901,11 @@ function toStoredSession(input, ref, now) {
3997
4901
  result: encodeResult(input.result)
3998
4902
  };
3999
4903
  }
4000
- function isRecord3(value) {
4904
+ function isRecord4(value) {
4001
4905
  return typeof value === "object" && value !== null && !Array.isArray(value);
4002
4906
  }
4003
4907
  function isStoredSession(value) {
4004
- if (!isRecord3(value) || !isRecord3(value["result"]) || !isRecord3(value["info"])) {
4908
+ if (!isRecord4(value) || !isRecord4(value["result"]) || !isRecord4(value["info"])) {
4005
4909
  return false;
4006
4910
  }
4007
4911
  const result = value["result"];
@@ -4009,7 +4913,7 @@ function isStoredSession(value) {
4009
4913
  }
4010
4914
  async function readStoredSession(path) {
4011
4915
  try {
4012
- const parsed = JSON.parse(await readFile2(path, "utf8"));
4916
+ const parsed = JSON.parse(await readFile4(path, "utf8"));
4013
4917
  return isStoredSession(parsed) ? parsed : void 0;
4014
4918
  } catch (error) {
4015
4919
  if (error.code === "ENOENT") {
@@ -4028,7 +4932,7 @@ function toSession(stored, saptoolsRoot) {
4028
4932
  }
4029
4933
  async function listSessionRefs(saptoolsRoot) {
4030
4934
  try {
4031
- const entries = await readdir2(resultsRoot(saptoolsRoot), { withFileTypes: true });
4935
+ const entries = await readdir3(resultsRoot(saptoolsRoot), { withFileTypes: true });
4032
4936
  return entries.filter((entry) => entry.isDirectory() && RESULT_REF_PATTERN.test(entry.name)).map((entry) => entry.name);
4033
4937
  } catch (error) {
4034
4938
  if (error.code === "ENOENT") {
@@ -4049,17 +4953,17 @@ async function createResultSession(input, options = {}) {
4049
4953
  const root = resultsRoot(options.saptoolsRoot);
4050
4954
  const finalDirectory = sessionDirectory(ref, options.saptoolsRoot);
4051
4955
  const tempDirectory = `${finalDirectory}.tmp-${process.pid.toString()}`;
4052
- await mkdir4(root, { recursive: true, mode: 448 });
4053
- await rm4(tempDirectory, { recursive: true, force: true });
4054
- await mkdir4(tempDirectory, { mode: 448 });
4956
+ await mkdir5(root, { recursive: true, mode: 448 });
4957
+ await rm6(tempDirectory, { recursive: true, force: true });
4958
+ await mkdir5(tempDirectory, { mode: 448 });
4055
4959
  try {
4056
- await writeFile3(join5(tempDirectory, MANIFEST_FILE_NAME), serialized, {
4960
+ await writeFile4(join7(tempDirectory, MANIFEST_FILE_NAME), serialized, {
4057
4961
  encoding: "utf8",
4058
4962
  mode: 384
4059
4963
  });
4060
- await rename2(tempDirectory, finalDirectory);
4964
+ await rename3(tempDirectory, finalDirectory);
4061
4965
  } catch (error) {
4062
- await rm4(tempDirectory, { recursive: true, force: true });
4966
+ await rm6(tempDirectory, { recursive: true, force: true });
4063
4967
  throw error;
4064
4968
  }
4065
4969
  return toSession(stored, options.saptoolsRoot);
@@ -4102,7 +5006,7 @@ async function pruneResultSessions(options = {}) {
4102
5006
  for (const ref of refs) {
4103
5007
  const stored = await readStoredSession(manifestPath(ref, options.saptoolsRoot));
4104
5008
  if (stored === void 0 || Date.parse(stored.expiresAt) <= now) {
4105
- await rm4(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
5009
+ await rm6(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
4106
5010
  removed += 1;
4107
5011
  }
4108
5012
  }
@@ -4112,7 +5016,7 @@ async function clearResultSessions(options = {}) {
4112
5016
  const refs = await listSessionRefs(options.saptoolsRoot);
4113
5017
  await Promise.all(
4114
5018
  refs.map(async (ref) => {
4115
- await rm4(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
5019
+ await rm6(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
4116
5020
  })
4117
5021
  );
4118
5022
  return refs.length;
@@ -4282,7 +5186,7 @@ async function runExport(ref, options) {
4282
5186
  }
4283
5187
  const session = await readResultSession(ref);
4284
5188
  const selected = selectResultCell(session, options.row, options.column);
4285
- await writeFile4(options.output, cellExportValue(selected.value, selected.typeName), {
5189
+ await writeFile5(options.output, cellExportValue(selected.value, selected.typeName), {
4286
5190
  mode: 384
4287
5191
  });
4288
5192
  print(`wrote=${options.output}`);
@@ -4350,8 +5254,12 @@ function printResolvedTarget(info) {
4350
5254
  `
4351
5255
  );
4352
5256
  }
5257
+ function printTunnelStatus(message) {
5258
+ process.stderr.write(`${CLI_NAME}: ${message}
5259
+ `);
5260
+ }
4353
5261
  async function connectForCli(selector, options) {
4354
- const client = await connect(selector, options);
5262
+ const client = await connect(selector, { ...options, onTunnelStatus: printTunnelStatus });
4355
5263
  printResolvedTarget(client.info);
4356
5264
  return client;
4357
5265
  }
@@ -4416,6 +5324,8 @@ function toConnectOptions(opts) {
4416
5324
  readOnly: opts.readOnly,
4417
5325
  allowDestructive: opts.allowDestructive,
4418
5326
  autoLimit: opts.autoLimit ? opts.limit ?? DEFAULT_AUTO_LIMIT : false,
5327
+ tunnel: opts.tunnel,
5328
+ refreshTunnel: opts.refreshTunnel,
4419
5329
  ...opts.binding === void 0 ? {} : { bindingName: opts.binding },
4420
5330
  ...opts.bindingIndex === void 0 ? {} : { bindingIndex: opts.bindingIndex },
4421
5331
  ...opts.timeout === void 0 ? {} : { queryTimeoutMs: opts.timeout, connectTimeoutMs: opts.timeout }
@@ -4512,7 +5422,15 @@ function formatInfo(info) {
4512
5422
  ].join("\n");
4513
5423
  }
4514
5424
  function withConnectionOptions(command) {
4515
- return command.option("--refresh", "deprecated compatibility flag; binding discovery is already live", false).option("--role <role>", "HANA user role: runtime or hdi", "runtime").option("--binding <name>", "select a HANA binding by service name").option("--binding-index <n>", "select a HANA binding by index", parseIntOption2).option("--read-only", "block every DML and DDL statement", false).option("--allow-destructive", "permit destructive statements", false).option("--timeout <ms>", "connection and query timeout in milliseconds", parseIntOption2).option("--limit <n>", "row cap auto-applied to bare SELECT statements", parseIntOption2).option("--no-auto-limit", "disable the automatic SELECT row cap").option("--refresh-metadata", "bypass the 30-minute table/view metadata suggestion cache", false);
5425
+ return command.option("--refresh", "deprecated compatibility flag; binding discovery is already live", false).option("--role <role>", "HANA user role: runtime or hdi", "runtime").option("--binding <name>", "select a HANA binding by service name").option("--binding-index <n>", "select a HANA binding by index", parseIntOption2).option("--read-only", "block every DML and DDL statement", false).option("--allow-destructive", "permit destructive statements", false).option("--timeout <ms>", "connection and query timeout in milliseconds", parseIntOption2).option("--limit <n>", "row cap auto-applied to bare SELECT statements", parseIntOption2).option("--no-auto-limit", "disable the automatic SELECT row cap").option("--refresh-metadata", "bypass the 30-minute table/view metadata suggestion cache", false).option(
5426
+ "--tunnel",
5427
+ "skip the direct connection attempt and connect via an SSH tunnel immediately",
5428
+ false
5429
+ ).option(
5430
+ "--refresh-tunnel",
5431
+ "bypass a cached/live SSH tunnel and force a fresh establishment attempt",
5432
+ false
5433
+ );
4516
5434
  }
4517
5435
  function withFormattedConnectionOptions(command) {
4518
5436
  return withConnectionOptions(command).option(