@saptools/cf-hana 0.5.0 → 0.5.2

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.5.0";
9
+ var CLI_VERSION = "0.5.2";
10
10
  var ENV_PREFIX = "CF_HANA";
11
11
  var DEFAULT_QUERY_TIMEOUT_MS = 6e4;
12
12
  var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
@@ -244,7 +244,7 @@ function formatResult(result, format, compactColumn) {
244
244
  }
245
245
  }
246
246
 
247
- // src/003-backup-sql-parser.ts
247
+ // src/backup-sql-parser.ts
248
248
  function isIdentifierChar(char) {
249
249
  return char !== void 0 && /[A-Za-z0-9_$#]/.test(char);
250
250
  }
@@ -438,7 +438,7 @@ function findTopLevelChar(sql, target, startIndex, endIndex) {
438
438
  }
439
439
 
440
440
  // src/statements.ts
441
- var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH"]);
441
+ var SELECT_KEYWORDS = /* @__PURE__ */ new Set(["SELECT"]);
442
442
  var DML_KEYWORDS = /* @__PURE__ */ new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "REPLACE"]);
443
443
  var DDL_KEYWORDS = /* @__PURE__ */ new Set(["CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", "COMMENT"]);
444
444
  function firstKeyword(sql) {
@@ -478,8 +478,189 @@ function firstKeyword(sql) {
478
478
  }
479
479
  return sql.slice(index, keywordEnd).toUpperCase();
480
480
  }
481
- function classifyStatement(sql) {
482
- const keyword = firstKeyword(sql);
481
+ function skipQuotedText2(sql, start) {
482
+ const quote = sql[start];
483
+ let index = start + 1;
484
+ while (index < sql.length) {
485
+ if (sql[index] === quote) {
486
+ if (sql[index + 1] === quote) {
487
+ index += 2;
488
+ continue;
489
+ }
490
+ index += 1;
491
+ break;
492
+ }
493
+ index += 1;
494
+ }
495
+ return index;
496
+ }
497
+ function skipLineComment2(sql, start) {
498
+ let index = start + 2;
499
+ while (index < sql.length && sql[index] !== "\n") {
500
+ index += 1;
501
+ }
502
+ return index;
503
+ }
504
+ function skipBlockComment2(sql, start) {
505
+ let index = start + 2;
506
+ while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
507
+ index += 1;
508
+ }
509
+ return Math.min(index + 2, sql.length);
510
+ }
511
+ function maskIgnoredSqlText(sql) {
512
+ let masked = "";
513
+ let index = 0;
514
+ while (index < sql.length) {
515
+ const char = sql[index];
516
+ if (char === "'" || char === '"') {
517
+ const end = skipQuotedText2(sql, index);
518
+ masked += " ".repeat(end - index);
519
+ index = end;
520
+ continue;
521
+ }
522
+ if (char === "-" && sql[index + 1] === "-") {
523
+ const end = skipLineComment2(sql, index);
524
+ masked += " ".repeat(end - index);
525
+ index = end;
526
+ continue;
527
+ }
528
+ if (char === "/" && sql[index + 1] === "*") {
529
+ const end = skipBlockComment2(sql, index);
530
+ masked += " ".repeat(end - index);
531
+ index = end;
532
+ continue;
533
+ }
534
+ masked += char ?? "";
535
+ index += 1;
536
+ }
537
+ return masked;
538
+ }
539
+ function topLevelKeywordIndex(sql, keyword) {
540
+ const masked = maskIgnoredSqlText(sql);
541
+ let depth = 0;
542
+ for (let index = 0; index < masked.length; index += 1) {
543
+ const char = masked[index];
544
+ if (char === "(") {
545
+ depth += 1;
546
+ continue;
547
+ }
548
+ if (char === ")") {
549
+ depth = Math.max(0, depth - 1);
550
+ continue;
551
+ }
552
+ 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))) {
553
+ return index;
554
+ }
555
+ }
556
+ return void 0;
557
+ }
558
+ function hasTopLevelKeyword(sql, keyword) {
559
+ return topLevelKeywordIndex(sql, keyword) !== void 0;
560
+ }
561
+ function isIdentifierChar2(char) {
562
+ return /[A-Za-z0-9_$#]/.test(char);
563
+ }
564
+ function skipWhitespace(masked, start) {
565
+ let index = start;
566
+ while (index < masked.length && /\s/.test(masked.charAt(index))) {
567
+ index += 1;
568
+ }
569
+ return index;
570
+ }
571
+ function matchingCloseParenIndex(masked, openIndex) {
572
+ let depth = 0;
573
+ for (let index = openIndex; index < masked.length; index += 1) {
574
+ const char = masked.charAt(index);
575
+ if (char === "(") {
576
+ depth += 1;
577
+ } else if (char === ")") {
578
+ depth -= 1;
579
+ if (depth === 0) {
580
+ return index;
581
+ }
582
+ }
583
+ }
584
+ return void 0;
585
+ }
586
+ function isAsKeywordAt(masked, index) {
587
+ return masked.slice(index, index + 2).toUpperCase() === "AS" && !isIdentifierChar2(masked.charAt(index - 1)) && !isIdentifierChar2(masked.charAt(index + 2));
588
+ }
589
+ function skipCteName(sql, masked, start) {
590
+ let index = start;
591
+ while (index < sql.length) {
592
+ const char = sql.charAt(index);
593
+ if (char.trim().length === 0) {
594
+ index += 1;
595
+ continue;
596
+ }
597
+ if (char === "-" && sql.charAt(index + 1) === "-") {
598
+ index = skipLineComment2(sql, index);
599
+ continue;
600
+ }
601
+ if (char === "/" && sql.charAt(index + 1) === "*") {
602
+ index = skipBlockComment2(sql, index);
603
+ continue;
604
+ }
605
+ break;
606
+ }
607
+ if (sql.charAt(index) === "'" || sql.charAt(index) === '"') {
608
+ return skipQuotedText2(sql, index);
609
+ }
610
+ let end = index;
611
+ while (end < masked.length && isIdentifierChar2(masked.charAt(end))) {
612
+ end += 1;
613
+ }
614
+ return end;
615
+ }
616
+ function cteListEndIndex(sql, masked, afterWith) {
617
+ let index = afterWith;
618
+ for (; ; ) {
619
+ index = skipCteName(sql, masked, index);
620
+ index = skipWhitespace(masked, index);
621
+ if (masked.charAt(index) === "(") {
622
+ const columnListEnd = matchingCloseParenIndex(masked, index);
623
+ if (columnListEnd === void 0) {
624
+ return void 0;
625
+ }
626
+ index = skipWhitespace(masked, columnListEnd + 1);
627
+ }
628
+ if (!isAsKeywordAt(masked, index)) {
629
+ return void 0;
630
+ }
631
+ index = skipWhitespace(masked, index + 2);
632
+ if (masked.charAt(index) !== "(") {
633
+ return void 0;
634
+ }
635
+ const closeIndex = matchingCloseParenIndex(masked, index);
636
+ if (closeIndex === void 0) {
637
+ return void 0;
638
+ }
639
+ index = skipWhitespace(masked, closeIndex + 1);
640
+ if (masked.charAt(index) === ",") {
641
+ index += 1;
642
+ continue;
643
+ }
644
+ return index;
645
+ }
646
+ }
647
+ function resolveWithStatement(sql) {
648
+ const withIndex = topLevelKeywordIndex(sql, "WITH");
649
+ if (withIndex === void 0) {
650
+ return void 0;
651
+ }
652
+ const masked = maskIgnoredSqlText(sql);
653
+ const trailingIndex = cteListEndIndex(sql, masked, withIndex + "WITH".length);
654
+ if (trailingIndex === void 0) {
655
+ return void 0;
656
+ }
657
+ let keywordEnd = trailingIndex;
658
+ while (keywordEnd < masked.length && isIdentifierChar2(masked.charAt(keywordEnd))) {
659
+ keywordEnd += 1;
660
+ }
661
+ return { keyword: masked.slice(trailingIndex, keywordEnd).toUpperCase(), index: trailingIndex };
662
+ }
663
+ function classifyByKeyword(keyword) {
483
664
  if (SELECT_KEYWORDS.has(keyword)) {
484
665
  return "select";
485
666
  }
@@ -491,6 +672,14 @@ function classifyStatement(sql) {
491
672
  }
492
673
  return "unknown";
493
674
  }
675
+ function classifyStatement(sql) {
676
+ const keyword = firstKeyword(sql);
677
+ if (keyword === "WITH") {
678
+ const resolved = resolveWithStatement(sql);
679
+ return resolved === void 0 ? "unknown" : classifyByKeyword(resolved.keyword);
680
+ }
681
+ return classifyByKeyword(keyword);
682
+ }
494
683
  function quoteIdentifier(identifier) {
495
684
  if (identifier.length === 0) {
496
685
  throw new QueryError("A SQL identifier must not be empty");
@@ -556,7 +745,7 @@ function assertParamArity(sql, params) {
556
745
  }
557
746
  }
558
747
 
559
- // src/001-backup-planner.ts
748
+ // src/backup-planner.ts
560
749
  var WRITE_KEYWORDS = /* @__PURE__ */ new Set([
561
750
  "UPDATE",
562
751
  "UPSERT",
@@ -654,11 +843,11 @@ function mergeTargetReference(statementSql, target, usingStart) {
654
843
  return target.reference;
655
844
  }
656
845
  if (/^PARTITION\b/i.test(suffix)) {
657
- const open = suffix.indexOf("(");
658
- if (open === -1) {
846
+ const open2 = suffix.indexOf("(");
847
+ if (open2 === -1) {
659
848
  return void 0;
660
849
  }
661
- const close = findTopLevelChar(suffix, ")", open + 1, suffix.length);
850
+ const close = findTopLevelChar(suffix, ")", open2 + 1, suffix.length);
662
851
  if (close === void 0) {
663
852
  return void 0;
664
853
  }
@@ -841,13 +1030,24 @@ function dispatchWriteBackupPlan(keyword, statementSql, params) {
841
1030
  }
842
1031
  }
843
1032
  function buildWriteBackupPlan(sql, params = []) {
844
- const statementSql = trimStatementSql(sql);
845
- const keyword = firstKeyword(statementSql);
1033
+ const trimmed = trimStatementSql(sql);
1034
+ const leading = firstKeyword(trimmed);
1035
+ const withResolved = leading === "WITH" ? resolveWithStatement(trimmed) : void 0;
1036
+ if (leading === "WITH" && withResolved === void 0) {
1037
+ throw new BackupRequiredError(
1038
+ "WITH write refused: cannot determine the statement's real trailing keyword"
1039
+ );
1040
+ }
1041
+ const keyword = withResolved?.keyword ?? leading;
846
1042
  if (!isWriteKeyword(keyword)) {
847
1043
  return void 0;
848
1044
  }
849
- assertParamArity(statementSql, params);
850
- return dispatchWriteBackupPlan(keyword, statementSql, params);
1045
+ const startIndex = withResolved?.index ?? 0;
1046
+ const statementSql = trimmed.slice(startIndex);
1047
+ const slicedParams = params.slice(countPlaceholders(trimmed.slice(0, startIndex)));
1048
+ assertParamArity(statementSql, slicedParams);
1049
+ const plan = dispatchWriteBackupPlan(keyword, statementSql, slicedParams);
1050
+ return plan === void 0 ? void 0 : { ...plan, statementSql: trimmed };
851
1051
  }
852
1052
 
853
1053
  // src/backup.ts
@@ -1176,9 +1376,9 @@ function buildEnv(ctx, overrides = {}) {
1176
1376
  env["CF_HOME"] = ctx.cfHome;
1177
1377
  return env;
1178
1378
  }
1179
- async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
1379
+ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS, maxAttempts = CF_RETRY_ATTEMPTS) {
1180
1380
  let lastErr;
1181
- for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
1381
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1182
1382
  try {
1183
1383
  const { stdout } = await execFileAsync(bin, args, {
1184
1384
  env,
@@ -1197,18 +1397,24 @@ async function execWithRetries(bin, args, env, timeoutMs = DEFAULT_TIMEOUT_MS) {
1197
1397
  if (isEnoent || !isTimeout && !isNetworkFlake) {
1198
1398
  throw err;
1199
1399
  }
1200
- if (attempt < CF_RETRY_ATTEMPTS - 1) {
1400
+ if (attempt < maxAttempts - 1) {
1201
1401
  await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
1202
1402
  }
1203
1403
  }
1204
1404
  }
1205
1405
  throw lastErr;
1206
1406
  }
1207
- async function runCf(args, ctx, overrides = {}) {
1407
+ async function runCf(args, ctx, overrides = {}, execOptions = {}) {
1208
1408
  const { bin, argsPrefix } = resolveCfBin();
1209
1409
  const env = buildEnv(ctx, overrides);
1210
1410
  try {
1211
- return await execWithRetries(bin, [...argsPrefix, ...args], env);
1411
+ return await execWithRetries(
1412
+ bin,
1413
+ [...argsPrefix, ...args],
1414
+ env,
1415
+ execOptions.timeoutMs,
1416
+ execOptions.maxAttempts
1417
+ );
1212
1418
  } catch (lastErr) {
1213
1419
  const e = lastErr;
1214
1420
  const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
@@ -1238,15 +1444,26 @@ async function cfEnvDirect(appName) {
1238
1444
  delete env["SAP_PASSWORD"];
1239
1445
  return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
1240
1446
  }
1241
- async function cfApps(ctx) {
1242
- return await runCf(["apps"], ctx);
1447
+ async function cfApps(ctx, timeoutMs) {
1448
+ return await runCf(
1449
+ ["apps"],
1450
+ ctx,
1451
+ {},
1452
+ timeoutMs === void 0 ? {} : { timeoutMs, maxAttempts: 1 }
1453
+ );
1243
1454
  }
1244
- async function cfAppsDirect() {
1455
+ async function cfAppsDirect(timeoutMs) {
1245
1456
  const { bin, argsPrefix } = resolveCfBin();
1246
1457
  const env = { ...process.env };
1247
1458
  delete env["SAP_EMAIL"];
1248
1459
  delete env["SAP_PASSWORD"];
1249
- return await execWithRetries(bin, [...argsPrefix, "apps"], env);
1460
+ return await execWithRetries(
1461
+ bin,
1462
+ [...argsPrefix, "apps"],
1463
+ env,
1464
+ timeoutMs,
1465
+ timeoutMs === void 0 ? void 0 : 1
1466
+ );
1250
1467
  }
1251
1468
  async function readCurrentCfTarget() {
1252
1469
  const { bin, argsPrefix } = resolveCfBin();
@@ -2153,6 +2370,13 @@ async function connectHdb(params) {
2153
2370
  password: params.password,
2154
2371
  ca: params.certificate,
2155
2372
  useTLS: true,
2373
+ // HANA Cloud can reactively redirect a connection mid-auth to an
2374
+ // internal per-node hostname (pod-locality routing). That target is
2375
+ // often unreachable outside SAP's own network, and for a tunneled
2376
+ // connection it would silently abandon the SSH forward for a fresh,
2377
+ // untunneled socket. The original bound host is always a real, working
2378
+ // endpoint, so disabling the redirect is safe on the direct path too.
2379
+ disableCloudRedirect: true,
2156
2380
  ...params.servername === void 0 ? {} : { servername: params.servername }
2157
2381
  });
2158
2382
  await openClient(client, params.connectTimeoutMs);
@@ -2262,83 +2486,6 @@ async function appendSqlHistory(input, options = {}) {
2262
2486
  // src/safety.ts
2263
2487
  var DESTRUCTIVE_DDL_KEYWORDS = /* @__PURE__ */ new Set(["DROP", "TRUNCATE", "ALTER"]);
2264
2488
  var UNSCOPED_WRITE_KEYWORDS = /* @__PURE__ */ new Set(["UPDATE", "DELETE"]);
2265
- function skipQuotedText2(sql, start) {
2266
- const quote = sql[start];
2267
- let index = start + 1;
2268
- while (index < sql.length) {
2269
- if (sql[index] === quote) {
2270
- if (sql[index + 1] === quote) {
2271
- index += 2;
2272
- continue;
2273
- }
2274
- index += 1;
2275
- break;
2276
- }
2277
- index += 1;
2278
- }
2279
- return index;
2280
- }
2281
- function skipLineComment2(sql, start) {
2282
- let index = start + 2;
2283
- while (index < sql.length && sql[index] !== "\n") {
2284
- index += 1;
2285
- }
2286
- return index;
2287
- }
2288
- function skipBlockComment2(sql, start) {
2289
- let index = start + 2;
2290
- while (index < sql.length && !(sql[index] === "*" && sql[index + 1] === "/")) {
2291
- index += 1;
2292
- }
2293
- return Math.min(index + 2, sql.length);
2294
- }
2295
- function maskIgnoredSqlText(sql) {
2296
- let masked = "";
2297
- let index = 0;
2298
- while (index < sql.length) {
2299
- const char = sql[index];
2300
- if (char === "'" || char === '"') {
2301
- const end = skipQuotedText2(sql, index);
2302
- masked += " ".repeat(end - index);
2303
- index = end;
2304
- continue;
2305
- }
2306
- if (char === "-" && sql[index + 1] === "-") {
2307
- const end = skipLineComment2(sql, index);
2308
- masked += " ".repeat(end - index);
2309
- index = end;
2310
- continue;
2311
- }
2312
- if (char === "/" && sql[index + 1] === "*") {
2313
- const end = skipBlockComment2(sql, index);
2314
- masked += " ".repeat(end - index);
2315
- index = end;
2316
- continue;
2317
- }
2318
- masked += char ?? "";
2319
- index += 1;
2320
- }
2321
- return masked;
2322
- }
2323
- function hasTopLevelKeyword(sql, keyword) {
2324
- const masked = maskIgnoredSqlText(sql);
2325
- let depth = 0;
2326
- for (let index = 0; index < masked.length; index += 1) {
2327
- const char = masked[index];
2328
- if (char === "(") {
2329
- depth += 1;
2330
- continue;
2331
- }
2332
- if (char === ")") {
2333
- depth = Math.max(0, depth - 1);
2334
- continue;
2335
- }
2336
- 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))) {
2337
- return true;
2338
- }
2339
- }
2340
- return false;
2341
- }
2342
2489
  function isUnconditionalMergeDelete(sql) {
2343
2490
  return /\bWHEN\s+MATCHED\s+THEN\s+DELETE\b/i.test(maskIgnoredSqlText(sql));
2344
2491
  }
@@ -2381,17 +2528,24 @@ function appendLimit(sql, limit) {
2381
2528
  }
2382
2529
  function inspectStatement(sql) {
2383
2530
  const kind = classifyStatement(sql);
2384
- const keyword = firstKeyword(sql);
2531
+ const leading = firstKeyword(sql);
2532
+ const withResolved = leading === "WITH" ? resolveWithStatement(sql) : void 0;
2533
+ const keyword = withResolved?.keyword ?? leading;
2534
+ const tail = withResolved === void 0 ? sql : sql.slice(withResolved.index);
2385
2535
  if (kind === "ddl") {
2386
2536
  return { kind, destructive: DESTRUCTIVE_DDL_KEYWORDS.has(keyword) };
2387
2537
  }
2388
2538
  if (kind === "dml") {
2389
- const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(sql, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(sql) || keyword === "REPLACE" && isMalformedReplace(sql);
2539
+ const destructive = UNSCOPED_WRITE_KEYWORDS.has(keyword) && !hasTopLevelKeyword(tail, "WHERE") || keyword === "MERGE" && isUnconditionalMergeDelete(tail) || keyword === "REPLACE" && isMalformedReplace(tail);
2390
2540
  return {
2391
2541
  kind,
2392
2542
  destructive
2393
2543
  };
2394
2544
  }
2545
+ if (kind === "unknown") {
2546
+ const unresolvedWith = leading === "WITH" && withResolved === void 0;
2547
+ return { kind, destructive: keyword === "CALL" || unresolvedWith };
2548
+ }
2395
2549
  return { kind, destructive: false };
2396
2550
  }
2397
2551
  function evaluateGuard(sql, config) {
@@ -2436,13 +2590,13 @@ function applyAutoLimit(sql, limit) {
2436
2590
 
2437
2591
  // src/tunnel/cache.ts
2438
2592
  import { createHash } from "crypto";
2439
- import { mkdir as mkdir3, readdir as readdir2, readFile, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2593
+ import { mkdir as mkdir3, readdir as readdir2, readFile as readFile2, rename, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2440
2594
  import { homedir as homedir3 } from "os";
2441
2595
  import { join as join5 } from "path";
2442
2596
 
2443
2597
  // src/tunnel/process.ts
2444
2598
  import { spawn } from "child_process";
2445
- import { mkdtemp as mkdtemp2, rm as rm3 } from "fs/promises";
2599
+ import { mkdtemp as mkdtemp2, open, readFile, rm as rm3 } from "fs/promises";
2446
2600
  import { connect as netConnect, createServer } from "net";
2447
2601
  import { tmpdir as tmpdir2 } from "os";
2448
2602
  import { join as join4 } from "path";
@@ -2549,8 +2703,8 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
2549
2703
  child.on("exit", onAbort);
2550
2704
  child.on("error", onAbort);
2551
2705
  timers.poll = setInterval(() => {
2552
- void probePort(localPort).then((open) => {
2553
- if (open && child.pid !== void 0) {
2706
+ void probePort(localPort).then((open2) => {
2707
+ if (open2 && child.pid !== void 0) {
2554
2708
  finish({ localPort, pid: child.pid });
2555
2709
  }
2556
2710
  });
@@ -2558,6 +2712,36 @@ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess)
2558
2712
  timers.deadline = setTimeout(finish, boundMs);
2559
2713
  });
2560
2714
  }
2715
+ var STDERR_TAIL_MAX_CHARS = 4096;
2716
+ async function openStderrCapture() {
2717
+ try {
2718
+ const dir = await mkdtemp2(join4(tmpdir2(), "saptools-cf-hana-tunnel-log-"));
2719
+ const path = join4(dir, "stderr.log");
2720
+ const handle = await open(path, "w");
2721
+ return {
2722
+ path,
2723
+ fd: handle.fd,
2724
+ close: async () => {
2725
+ await handle.close().catch(() => {
2726
+ });
2727
+ await rm3(dir, { recursive: true, force: true });
2728
+ }
2729
+ };
2730
+ } catch {
2731
+ return void 0;
2732
+ }
2733
+ }
2734
+ async function readStderrTail(path) {
2735
+ try {
2736
+ const content = (await readFile(path, "utf8")).trim().replace(/\s+/g, " ");
2737
+ if (content.length === 0) {
2738
+ return void 0;
2739
+ }
2740
+ return content.length > STDERR_TAIL_MAX_CHARS ? content.slice(-STDERR_TAIL_MAX_CHARS) : content;
2741
+ } catch {
2742
+ return void 0;
2743
+ }
2744
+ }
2561
2745
  async function spawnTunnel(params, deps = {}) {
2562
2746
  assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
2563
2747
  const remainingMs = params.deadline - Date.now();
@@ -2582,8 +2766,24 @@ async function spawnTunnel(params, deps = {}) {
2582
2766
  `sleep ${String(params.keepaliveSeconds)}`
2583
2767
  ];
2584
2768
  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);
2769
+ const openCapture = deps.openStderrCapture ?? openStderrCapture;
2770
+ const capture = await openCapture();
2771
+ const stdio = capture === void 0 ? "ignore" : ["ignore", "ignore", capture.fd];
2772
+ const child = spawnProcess(bin, args, { detached: true, stdio, env });
2773
+ const result = await raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess);
2774
+ if (capture !== void 0) {
2775
+ if (result === void 0) {
2776
+ try {
2777
+ const tail = await readStderrTail(capture.path);
2778
+ if (tail !== void 0) {
2779
+ deps.onFailureDiagnostic?.(tail);
2780
+ }
2781
+ } catch {
2782
+ }
2783
+ }
2784
+ await capture.close();
2785
+ }
2786
+ return result;
2587
2787
  }
2588
2788
 
2589
2789
  // src/tunnel/cache.ts
@@ -2628,7 +2828,7 @@ function isTunnelCacheRecord(value) {
2628
2828
  }
2629
2829
  async function parseTunnelCacheFile(path) {
2630
2830
  try {
2631
- const raw = JSON.parse(await readFile(path, "utf8"));
2831
+ const raw = JSON.parse(await readFile2(path, "utf8"));
2632
2832
  return isTunnelCacheRecord(raw) ? raw : void 0;
2633
2833
  } catch {
2634
2834
  return void 0;
@@ -2846,6 +3046,9 @@ function isConnectivityFailure(error) {
2846
3046
  }
2847
3047
  return hasOpenConnectionCause(error.cause, 0);
2848
3048
  }
3049
+ function isUnattributedQueryFailure(error) {
3050
+ return error instanceof QueryError && error.databaseCode === void 0 && error.sqlState === void 0;
3051
+ }
2849
3052
 
2850
3053
  // src/tunnel/fallback.ts
2851
3054
  var EXPIRY_SAFETY_MARGIN_MS = 3e4;
@@ -2875,30 +3078,40 @@ async function discardQuietly(work) {
2875
3078
  await work.catch(() => {
2876
3079
  });
2877
3080
  }
3081
+ function shouldDiscardTunnel(error) {
3082
+ return isConnectivityFailure(error) || isUnattributedQueryFailure(error);
3083
+ }
2878
3084
  async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
2879
3085
  try {
2880
3086
  const connection = await driver.connect(tunneledParams(directParams, record.localPort));
2881
3087
  config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
2882
3088
  return connection;
2883
3089
  } catch (error) {
2884
- if (isConnectivityFailure(error)) {
3090
+ if (shouldDiscardTunnel(error)) {
2885
3091
  await discardQuietly(evictTunnelCache(config.host, cacheOptions));
2886
3092
  return void 0;
2887
3093
  }
2888
3094
  throw error;
2889
3095
  }
2890
3096
  }
2891
- async function discoverAppsStdout(ctx) {
3097
+ function knownCandidates(targetAppName, hintApp) {
3098
+ return hintApp === void 0 || hintApp === targetAppName ? [targetAppName] : [targetAppName, hintApp];
3099
+ }
3100
+ async function discoverAppsStdout(ctx, timeoutMs) {
2892
3101
  try {
2893
- return ctx === void 0 ? await cfAppsDirect() : await cfApps(ctx);
3102
+ return ctx === void 0 ? await cfAppsDirect(timeoutMs) : await cfApps(ctx, timeoutMs);
2894
3103
  } catch {
2895
3104
  return;
2896
3105
  }
2897
3106
  }
2898
- async function buildCandidates(config, ctx, hintApp) {
2899
- const stdout = await discoverAppsStdout(ctx);
3107
+ async function discoverExtraCandidates(config, ctx, alreadyTried, deadline) {
3108
+ const remainingMs = deadline - Date.now();
3109
+ if (remainingMs <= 0) {
3110
+ return [];
3111
+ }
3112
+ const stdout = await discoverAppsStdout(ctx, remainingMs);
2900
3113
  const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
2901
- return hintApp === void 0 || base.includes(hintApp) ? base : [hintApp, ...base];
3114
+ return base.filter((app) => !alreadyTried.includes(app));
2902
3115
  }
2903
3116
  async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
2904
3117
  const readyRecord = {
@@ -2914,7 +3127,7 @@ async function finalizeCandidateConnection(app, spawned, driver, config, directP
2914
3127
  config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
2915
3128
  return connection;
2916
3129
  } catch (error) {
2917
- if (isConnectivityFailure(error)) {
3130
+ if (shouldDiscardTunnel(error)) {
2918
3131
  await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
2919
3132
  killTunnelProcess(spawned.pid);
2920
3133
  return void 0;
@@ -2931,7 +3144,8 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
2931
3144
  return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
2932
3145
  }
2933
3146
  if (claim.outcome === "wait") {
2934
- const ready = await waitForEstablishment(config.host, deadline, void 0, cacheOptions);
3147
+ const waitDeadline = Math.min(deadline, Date.now() + DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS);
3148
+ const ready = await waitForEstablishment(config.host, waitDeadline, void 0, cacheOptions);
2935
3149
  return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
2936
3150
  }
2937
3151
  const spawned = await spawnTunnel(
@@ -2944,7 +3158,12 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
2944
3158
  deadline,
2945
3159
  candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
2946
3160
  },
2947
- overrides.process ?? {}
3161
+ {
3162
+ ...overrides.process,
3163
+ onFailureDiagnostic: (message) => {
3164
+ config.onTunnelStatus?.(`tunnel via ${app} failed: ${message}`);
3165
+ }
3166
+ }
2948
3167
  );
2949
3168
  if (spawned === void 0) {
2950
3169
  await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
@@ -2954,7 +3173,11 @@ async function tryCandidate(app, ctx, driver, config, directParams, deadline, ov
2954
3173
  }
2955
3174
  async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
2956
3175
  if (config.refreshTunnel) {
3176
+ const superseded = await readTunnelCacheEntry(config.host, cacheOptions);
2957
3177
  await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3178
+ if (superseded?.status === "ready") {
3179
+ (cacheOptions.killProcess ?? killTunnelProcess)(superseded.pid);
3180
+ }
2958
3181
  return { connection: void 0, hintApp: void 0 };
2959
3182
  }
2960
3183
  const entry = await readTunnelCacheEntry(config.host, cacheOptions);
@@ -2972,8 +3195,12 @@ async function tryDirectConnect(driver, config, directParams) {
2972
3195
  if (config.tunnelMode !== "auto") {
2973
3196
  return { connection: void 0, directError: void 0 };
2974
3197
  }
3198
+ const boundedDirectParams = {
3199
+ ...directParams,
3200
+ connectTimeoutMs: Math.min(directParams.connectTimeoutMs, DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS)
3201
+ };
2975
3202
  try {
2976
- return { connection: await driver.connect(directParams), directError: void 0 };
3203
+ return { connection: await driver.connect(boundedDirectParams), directError: void 0 };
2977
3204
  } catch (error) {
2978
3205
  if (!isConnectivityFailure(error)) {
2979
3206
  throw error;
@@ -2990,18 +3217,47 @@ function buildExhaustionError(host, directError, candidates) {
2990
3217
  `Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
2991
3218
  );
2992
3219
  }
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) {
3220
+ async function tryCandidateList(apps, ctx, driver, config, directParams, deadline, overrides, tried) {
3221
+ for (const app of apps) {
2996
3222
  if (Date.now() >= deadline) {
2997
- break;
3223
+ return void 0;
2998
3224
  }
3225
+ tried.push(app);
2999
3226
  const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
3000
3227
  if (connection !== void 0) {
3001
- return { connection, candidatesTried };
3228
+ return connection;
3002
3229
  }
3003
3230
  }
3004
- return { connection: void 0, candidatesTried };
3231
+ return void 0;
3232
+ }
3233
+ async function runCandidateLoop(ctx, driver, config, directParams, deadline, hintApp, overrides) {
3234
+ const tried = [];
3235
+ const known = knownCandidates(config.appName, hintApp);
3236
+ const knownConnection = await tryCandidateList(
3237
+ known,
3238
+ ctx,
3239
+ driver,
3240
+ config,
3241
+ directParams,
3242
+ deadline,
3243
+ overrides,
3244
+ tried
3245
+ );
3246
+ if (knownConnection !== void 0) {
3247
+ return { connection: knownConnection, candidatesTried: tried };
3248
+ }
3249
+ const discovered = await discoverExtraCandidates(config, ctx, tried, deadline);
3250
+ const discoveredConnection = await tryCandidateList(
3251
+ discovered,
3252
+ ctx,
3253
+ driver,
3254
+ config,
3255
+ directParams,
3256
+ deadline,
3257
+ overrides,
3258
+ tried
3259
+ );
3260
+ return { connection: discoveredConnection, candidatesTried: tried };
3005
3261
  }
3006
3262
  async function connectWithTunnelFallback(driver, config, overrides = {}) {
3007
3263
  const directParams = directParamsOf(config);