@saptools/cf-hana 0.5.0 → 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.5.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;
@@ -1073,7 +1073,7 @@ function findTopLevelChar(sql, target, startIndex, endIndex) {
1073
1073
  }
1074
1074
 
1075
1075
  // src/statements.ts
1076
- var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
1076
+ var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"]);
1077
1077
  var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
1078
1078
  var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
1079
1079
  function firstKeyword(sql) {
@@ -1113,8 +1113,165 @@ function firstKeyword(sql) {
1113
1113
  }
1114
1114
  return sql.slice(index, keywordEnd).toUpperCase();
1115
1115
  }
1116
- function classifyStatement(sql) {
1117
- 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) {
1118
1275
  if (SELECT_KEYWORDS.has(keyword)) {
1119
1276
  return "select";
1120
1277
  }
@@ -1126,6 +1283,14 @@ function classifyStatement(sql) {
1126
1283
  }
1127
1284
  return "unknown";
1128
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
+ }
1129
1294
  function quoteIdentifier(identifier) {
1130
1295
  if (identifier.length === 0) {
1131
1296
  throw new QueryError("A SQL identifier must not be empty");
@@ -1289,11 +1454,11 @@ function mergeTargetReference(statementSql, target, usingStart) {
1289
1454
  return target.reference;
1290
1455
  }
1291
1456
  if (/^PARTITION\b/i.test(suffix)) {
1292
- const open = suffix.indexOf("(");
1293
- if (open === -1) {
1457
+ const open2 = suffix.indexOf("(");
1458
+ if (open2 === -1) {
1294
1459
  return void 0;
1295
1460
  }
1296
- const close = findTopLevelChar(suffix, ")", open + 1, suffix.length);
1461
+ const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
1297
1462
  if (close === void 0) {
1298
1463
  return void 0;
1299
1464
  }
@@ -1476,13 +1641,19 @@ function dispatchWriteBackupPlan(keyword, statementSql, params) {
1476
1641
  }
1477
1642
  }
1478
1643
  function buildWriteBackupPlan(sql, params = []) {
1479
- const statementSql = trimStatementSql(sql);
1480
- 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;
1481
1648
  if (!isWriteKeyword(keyword)) {
1482
1649
  return void 0;
1483
1650
  }
1484
- assertParamArity(statementSql, params);
1485
- 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 };
1486
1657
  }
1487
1658
 
1488
1659
  // src/backup.ts
@@ -1811,9 +1982,9 @@ function buildEnv(ctx, overrides = {}) {
1811
1982
  env["CF_HOME"] = ctx.cfHome;
1812
1983
  return env;
1813
1984
  }
1814
- 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) {
1815
1986
  let lastErr;
1816
- for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
1987
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1817
1988
  try {
1818
1989
  const { stdout } = await execFileAsync(bin, args, {
1819
1990
  env,
@@ -1832,18 +2003,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
1832
2003
  if (isEnoent || !isTimeout && !isNetworkFlake) {
1833
2004
  throw err;
1834
2005
  }
1835
- if (attempt < CF_RETRY_ATTEMPTS - 1) {
2006
+ if (attempt < maxAttempts - 1) {
1836
2007
  await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
1837
2008
  }
1838
2009
  }
1839
2010
  }
1840
2011
  throw lastErr;
1841
2012
  }
1842
- async function runCf(args, ctx, overrides = {}) {
2013
+ async function runCf(args, ctx, overrides = {}, execOptions = {}) {
1843
2014
  const { bin, argsPrefix } = resolveCfBin();
1844
2015
  const env = buildEnv(ctx, overrides);
1845
2016
  try {
1846
- 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
+ );
1847
2024
  } catch (lastErr) {
1848
2025
  const e = lastErr;
1849
2026
  const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
@@ -1873,15 +2050,26 @@ async function cfEnvDirect(appName) {
1873
2050
  delete env["SAP_PASSWORD"];
1874
2051
  return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
1875
2052
  }
1876
- async function cfApps(ctx) {
1877
- return await runCf(["apps"], ctx);
2053
+ async function cfApps(ctx, timeoutMs) {
2054
+ return await runCf(
2055
+ ["apps"],
2056
+ ctx,
2057
+ {},
2058
+ timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
2059
+ );
1878
2060
  }
1879
- async function cfAppsDirect() {
2061
+ async function cfAppsDirect(timeoutMs) {
1880
2062
  const { bin, argsPrefix } = resolveCfBin();
1881
2063
  const env = { ...process.env };
1882
2064
  delete env["SAP_EMAIL"];
1883
2065
  delete env["SAP_PASSWORD"];
1884
- return await execWithRetries(bin, [...argsPrefix, "apps"], env);
2066
+ return await execWithRetries(
2067
+ bin,
2068
+ [...argsPrefix, "apps"],
2069
+ env,
2070
+ timeoutMs,
2071
+ timeoutMs === void 0 ? void 0 : 1
2072
+ );
1885
2073
  }
1886
2074
  async function readCurrentCfTarget() {
1887
2075
  const { bin, argsPrefix } = resolveCfBin();
@@ -2788,6 +2976,13 @@ async function connectHdb(params) {
2788
2976
  password: params.password,
2789
2977
  ca: params.certificate,
2790
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,
2791
2986
  ...params.servername === void 0 ? {} : { servername: params.servername }
2792
2987
  });
2793
2988
  await openClient(client, params.connectTimeoutMs);
@@ -2897,83 +3092,6 @@ async function appendSqlHistory(input, options = {}) {
2897
3092
  // src/safety.ts
2898
3093
  var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
2899
3094
  var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
2900
- function skipQuotedText2(sql, start) {
2901
- const quote = sql[start];
2902
- let index = start + 1;
2903
- while (index < sql.length) {
2904
- if (sql[index] === quote) {
2905
- if (sql[index + 1] === quote) {
2906
- index += 2;
2907
- continue;
2908
- }
2909
- index += 1;
2910
- break;
2911
- }
2912
- index += 1;
2913
- }
2914
- return index;
2915
- }
2916
- function skipLineComment3(sql, start) {
2917
- let index = start + 2;
2918
- while (index < sql.length && sql[index] !== "\n") {
2919
- index += 1;
2920
- }
2921
- return index;
2922
- }
2923
- function skipBlockComment3(sql, start) {
2924
- let index = start + 2;
2925
- while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
2926
- index += 1;
2927
- }
2928
- return Math.min(index + 2, sql.length);
2929
- }
2930
- function maskIgnoredSqlText(sql) {
2931
- let masked = "";
2932
- let index = 0;
2933
- while (index < sql.length) {
2934
- const char = sql[index];
2935
- if (char === "'" || char === '"') {
2936
- const end = skipQuotedText2(sql, index);
2937
- masked += " ".repeat(end - index);
2938
- index = end;
2939
- continue;
2940
- }
2941
- if (char === "-" && sql[index + 1] === "-") {
2942
- const end = skipLineComment3(sql, index);
2943
- masked += " ".repeat(end - index);
2944
- index = end;
2945
- continue;
2946
- }
2947
- if (char === "/" && sql[index + 1] === "*") {
2948
- const end = skipBlockComment3(sql, index);
2949
- masked += " ".repeat(end - index);
2950
- index = end;
2951
- continue;
2952
- }
2953
- masked += char ?? "";
2954
- index += 1;
2955
- }
2956
- return masked;
2957
- }
2958
- function hasTopLevelKeyword(sql, keyword) {
2959
- const masked = maskIgnoredSqlText(sql);
2960
- let depth = 0;
2961
- for (let index = 0; index < masked.length; index += 1) {
2962
- const char = masked[index];
2963
- if (char === "(") {
2964
- depth += 1;
2965
- continue;
2966
- }
2967
- if (char === ")") {
2968
- depth = Math.max(0, depth - 1);
2969
- continue;
2970
- }
2971
- 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))) {
2972
- return true;
2973
- }
2974
- }
2975
- return false;
2976
- }
2977
3095
  function isUnconditionalMergeDelete(sql) {
2978
3096
  return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
2979
3097
  }
@@ -3016,17 +3134,23 @@ function appendLimit(sql, limit) {
3016
3134
  }
3017
3135
  function inspectStatement(sql) {
3018
3136
  const kind = classifyStatement(sql);
3019
- 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);
3020
3141
  if (kind === "ddl") {
3021
3142
  return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
3022
3143
  }
3023
3144
  if (kind === "dml") {
3024
- 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);
3025
3146
  return {
3026
3147
  kind,
3027
3148
  destructive
3028
3149
  };
3029
3150
  }
3151
+ if (kind === "unknown") {
3152
+ return { kind, destructive: keyword === "CALL" };
3153
+ }
3030
3154
  return { kind, destructive: false };
3031
3155
  }
3032
3156
  function evaluateGuard(sql, config) {
@@ -3071,13 +3195,13 @@ function applyAutoLimit(sql, limit) {
3071
3195
 
3072
3196
  // src/tunnel/cache.ts
3073
3197
  import { createHash as createHash2 } from "crypto";
3074
- import { mkdir as mkdir4, readdir as readdir2, readFile as readFile2, rename as rename2, rm as rm5, writeFile as writeFile3 } from "fs/promises";
3198
+ import { mkdir as mkdir4, readdir as readdir2, readFile as readFile3, rename as rename2, rm as rm5, writeFile as writeFile3 } from "fs/promises";
3075
3199
  import { homedir as homedir4 } from "os";
3076
3200
  import { join as join6 } from "path";
3077
3201
 
3078
3202
  // src/tunnel/process.ts
3079
3203
  import { spawn } from "child_process";
3080
- import { mkdtemp as mkdtemp2, rm as rm4 } from "fs/promises";
3204
+ import { mkdtemp as mkdtemp2, open, readFile as readFile2, rm as rm4 } from "fs/promises";
3081
3205
  import { connect as netConnect, createServer } from "net";
3082
3206
  import { tmpdir as tmpdir2 } from "os";
3083
3207
  import { join as join5 } from "path";
@@ -3184,8 +3308,8 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
3184
3308
  child.on("exit", onAbort);
3185
3309
  child.on("error", onAbort);
3186
3310
  timers.poll = setInterval(() => {
3187
- void probePort(localPort).then((open) => {
3188
- if (open && child.pid !== void 0) {
3311
+ void probePort(localPort).then((open2) => {
3312
+ if (open2 && child.pid !== void 0) {
3189
3313
  finish({ localPort, pid: child.pid });
3190
3314
  }
3191
3315
  });
@@ -3193,6 +3317,36 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
3193
3317
  timers.deadline = setTimeout(finish, boundMs);
3194
3318
  });
3195
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
+ }
3196
3350
  async function spawnTunnel(params, deps = {}) {
3197
3351
  assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
3198
3352
  const remainingMs = params.deadline - Date.now();
@@ -3217,8 +3371,24 @@ async function spawnTunnel(params, deps = {}) {
3217
3371
  `sleep ${String(params.keepaliveSeconds)}`
3218
3372
  ];
3219
3373
  const env = params.cfHome === void 0 ? { ...process.env } : { ...process.env, CF_HOME: params.cfHome };
3220
- const child = spawnProcess(bin, args, { detached: true, stdio: "ignore", env });
3221
- return await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
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;
3222
3392
  }
3223
3393
 
3224
3394
  // src/tunnel/cache.ts
@@ -3263,7 +3433,7 @@ function isTunnelCacheRecord(value) {
3263
3433
  }
3264
3434
  async function parseTunnelCacheFile(path) {
3265
3435
  try {
3266
- const raw = JSON.parse(await readFile2(path, "utf8"));
3436
+ const raw = JSON.parse(await readFile3(path, "utf8"));
3267
3437
  return isTunnelCacheRecord(raw) ? raw : void 0;
3268
3438
  } catch {
3269
3439
  return void 0;
@@ -3481,6 +3651,9 @@ function isConnectivityFailure(error) {
3481
3651
  }
3482
3652
  return hasOpenConnectionCause(error.cause, 0);
3483
3653
  }
3654
+ function isUnattributedQueryFailure(error) {
3655
+ return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
3656
+ }
3484
3657
 
3485
3658
  // src/tunnel/fallback.ts
3486
3659
  var EXPIRY_SAFETY_MARGIN_MS = 3e4;
@@ -3510,30 +3683,40 @@ async function discardQuietly(work) {
3510
3683
  await work.catch(() => {
3511
3684
  });
3512
3685
  }
3686
+ function shouldDiscardTunnel(error) {
3687
+ return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
3688
+ }
3513
3689
  async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
3514
3690
  try {
3515
3691
  const connection = await driver.connect(tunneledParams(directParams, record.localPort));
3516
3692
  config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
3517
3693
  return connection;
3518
3694
  } catch (error) {
3519
- if (isConnectivityFailure(error)) {
3695
+ if (shouldDiscardTunnel(error)) {
3520
3696
  await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3521
3697
  return void 0;
3522
3698
  }
3523
3699
  throw error;
3524
3700
  }
3525
3701
  }
3526
- async function discoverAppsStdout(ctx) {
3702
+ function knownCandidates(targetAppName, hintApp) {
3703
+ return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
3704
+ }
3705
+ async function discoverAppsStdout(ctx, timeoutMs) {
3527
3706
  try {
3528
- return ctx === void 0 ? await cfAppsDirect() : await cfApps(ctx);
3707
+ return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
3529
3708
  } catch {
3530
3709
  return;
3531
3710
  }
3532
3711
  }
3533
- async function buildCandidates(config, ctx, hintApp) {
3534
- const stdout = await discoverAppsStdout(ctx);
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);
3535
3718
  const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
3536
- return hintApp === void 0 || base.includes(hintApp) ? base : [hintApp, ...base];
3719
+ return base.filter((app) => !alreadyTried.includes(app));
3537
3720
  }
3538
3721
  async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
3539
3722
  const readyRecord = {
@@ -3549,7 +3732,7 @@ async function finalizeCandidateConnection(app, spawned, driver, config, directP
3549
3732
  config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
3550
3733
  return connection;
3551
3734
  } catch (error) {
3552
- if (isConnectivityFailure(error)) {
3735
+ if (shouldDiscardTunnel(error)) {
3553
3736
  await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
3554
3737
  killTunnelProcess(spawned.pid);
3555
3738
  return void 0;
@@ -3566,7 +3749,8 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
3566
3749
  return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
3567
3750
  }
3568
3751
  if (claim.outcome === "wait") {
3569
- const ready = await waitForEstablishment(config.host, deadline, void 0, cacheOptions);
3752
+ const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
3753
+ const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
3570
3754
  return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
3571
3755
  }
3572
3756
  const spawned = await spawnTunnel(
@@ -3579,7 +3763,12 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
3579
3763
  deadline,
3580
3764
  candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
3581
3765
  },
3582
- overrides.process ?? {}
3766
+ {
3767
+ ...overrides.process,
3768
+ onFailureDiagnostic: (message) => {
3769
+ config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
3770
+ }
3771
+ }
3583
3772
  );
3584
3773
  if (spawned === void 0) {
3585
3774
  await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
@@ -3589,7 +3778,11 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
3589
3778
  }
3590
3779
  async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
3591
3780
  if (config.refreshTunnel) {
3781
+ const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
3592
3782
  await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3783
+ if (superseded?.status === "ready") {
3784
+ (cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
3785
+ }
3593
3786
  return { connection: void 0, hintApp: void 0 };
3594
3787
  }
3595
3788
  const entry = await readTunnelCacheEntry(config.host, cacheOptions);
@@ -3607,8 +3800,12 @@ async function tryDirectConnect(driver, config, directParams) {
3607
3800
  if (config.tunnelMode !== "auto") {
3608
3801
  return { connection: void 0, directError: void 0 };
3609
3802
  }
3803
+ const boundedDirectParams = {
3804
+ ...directParams,
3805
+ connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
3806
+ };
3610
3807
  try {
3611
- return { connection: await driver.connect(directParams), directError: void 0 };
3808
+ return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
3612
3809
  } catch (error) {
3613
3810
  if (!isConnectivityFailure(error)) {
3614
3811
  throw error;
@@ -3625,18 +3822,47 @@ function buildExhaustionError(host, directError, candidates) {
3625
3822
  `Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
3626
3823
  );
3627
3824
  }
3628
- async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
3629
- const candidatesTried = await buildCandidates(config, ctx, hintApp);
3630
- for (const app of candidatesTried) {
3825
+ async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
3826
+ for (const app of apps) {
3631
3827
  if (Date.now() >= deadline) {
3632
- break;
3828
+ return void 0;
3633
3829
  }
3830
+ tried.push(app);
3634
3831
  const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
3635
3832
  if (connection !== void 0) {
3636
- return { connection, candidatesTried };
3833
+ return connection;
3637
3834
  }
3638
3835
  }
3639
- return { connection: void 0, candidatesTried };
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 };
3640
3866
  }
3641
3867
  async function connectWithTunnelFallback(driver, config, overrides = {}) {
3642
3868
  const directParams = directParamsOf(config);
@@ -4564,7 +4790,7 @@ function searchResultSession(session, searchTerm, options) {
4564
4790
 
4565
4791
  // src/result-store.ts
4566
4792
  import { randomBytes } from "crypto";
4567
- import { mkdir as mkdir5, readFile as readFile3, readdir as readdir3, rename as rename3, rm as rm6, writeFile as writeFile4 } from "fs/promises";
4793
+ import { mkdir as mkdir5, readFile as readFile4, readdir as readdir3, rename as rename3, rm as rm6, writeFile as writeFile4 } from "fs/promises";
4568
4794
  import { homedir as homedir5 } from "os";
4569
4795
  import { join as join7 } from "path";
4570
4796
  var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
@@ -4687,7 +4913,7 @@ function isStoredSession(value) {
4687
4913
  }
4688
4914
  async function readStoredSession(path) {
4689
4915
  try {
4690
- const parsed = JSON.parse(await readFile3(path, "utf8"));
4916
+ const parsed = JSON.parse(await readFile4(path, "utf8"));
4691
4917
  return isStoredSession(parsed) ? parsed : void 0;
4692
4918
  } catch (error) {
4693
4919
  if (error.code === "ENOENT") {