@saptools/cf-hana 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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.0";
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;
@@ -1852,6 +1873,16 @@ async function cfEnvDirect(appName) {
1852
1873
  delete env["SAP_PASSWORD"];
1853
1874
  return await execWithRetries(bin, [...argsPrefix, "env", appName], env);
1854
1875
  }
1876
+ async function cfApps(ctx) {
1877
+ return await runCf(["apps"], ctx);
1878
+ }
1879
+ async function cfAppsDirect() {
1880
+ const { bin, argsPrefix } = resolveCfBin();
1881
+ const env = { ...process.env };
1882
+ delete env["SAP_EMAIL"];
1883
+ delete env["SAP_PASSWORD"];
1884
+ return await execWithRetries(bin, [...argsPrefix, "apps"], env);
1885
+ }
1855
1886
  async function readCurrentCfTarget() {
1856
1887
  const { bin, argsPrefix } = resolveCfBin();
1857
1888
  const env = { ...process.env };
@@ -1901,6 +1932,26 @@ function parseTargetFields(stdout) {
1901
1932
  }
1902
1933
  return map;
1903
1934
  }
1935
+ function parseCfAppsOutput(stdout) {
1936
+ const lines = stdout.split(/\r?\n/);
1937
+ const headerIndex = lines.findIndex((line) => /^\s*name\s+\S/i.test(line));
1938
+ if (headerIndex === -1) {
1939
+ return [];
1940
+ }
1941
+ const rows = [];
1942
+ for (const line of lines.slice(headerIndex + 1)) {
1943
+ const trimmed = line.trim();
1944
+ if (trimmed.length === 0) {
1945
+ continue;
1946
+ }
1947
+ const [name, state] = trimmed.split(/\s+/);
1948
+ if (name === void 0 || state === void 0) {
1949
+ continue;
1950
+ }
1951
+ rows.push({ name, state });
1952
+ }
1953
+ return rows;
1954
+ }
1904
1955
  function formatCurrentCfAppSelector(target, appName) {
1905
1956
  const name = appName.trim();
1906
1957
  if (!name) {
@@ -2212,7 +2263,10 @@ async function resolveAppBindings(rawSelector, options) {
2212
2263
  source: "live",
2213
2264
  selectorSource: target.selectorSource,
2214
2265
  regionConfirmed: target.regionConfirmed,
2215
- selectorCanBePinned: target.selectorCanBePinned
2266
+ selectorCanBePinned: target.selectorCanBePinned,
2267
+ apiEndpoint: target.apiEndpoint,
2268
+ orgName: target.orgName,
2269
+ spaceName: target.spaceName
2216
2270
  };
2217
2271
  }
2218
2272
  async function readExplicitBindings(target, options) {
@@ -2278,6 +2332,7 @@ function toConnectionTarget(binding, role) {
2278
2332
  // src/driver/fake.ts
2279
2333
  import { appendFile } from "fs/promises";
2280
2334
  var catalogFailureInjected = false;
2335
+ var connectFailureInjectedOnce = false;
2281
2336
  function catalogObjects() {
2282
2337
  return [
2283
2338
  { SCHEMA_NAME: "APP_SCHEMA", OBJECT_NAME: "EXISTING_TABLE", OBJECT_TYPE: "TABLE" },
@@ -2486,10 +2541,28 @@ var FakeConnection = class {
2486
2541
  return this.closed;
2487
2542
  }
2488
2543
  };
2544
+ function maybeFailConnect() {
2545
+ const mode = readEnv(envName("FAKE_FAIL_CONNECT"));
2546
+ if (mode !== "once" && mode !== "always") {
2547
+ return;
2548
+ }
2549
+ if (mode === "once" && connectFailureInjectedOnce) {
2550
+ return;
2551
+ }
2552
+ connectFailureInjectedOnce = true;
2553
+ const cause = Object.assign(
2554
+ new Error("Could not connect to any host: [ fake-host:443 - forced fixture failure ]"),
2555
+ { code: "EHDBOPENCONN" }
2556
+ );
2557
+ throw new CfHanaError("CONNECTION", `Failed to connect to HANA: ${cause.message}`, { cause });
2558
+ }
2489
2559
  function createFakeDriver() {
2490
2560
  return {
2491
2561
  name: "fake",
2492
- connect: (_params) => Promise.resolve(new FakeConnection())
2562
+ connect: (_params) => {
2563
+ maybeFailConnect();
2564
+ return Promise.resolve(new FakeConnection());
2565
+ }
2493
2566
  };
2494
2567
  }
2495
2568
 
@@ -2714,7 +2787,8 @@ async function connectHdb(params) {
2714
2787
  user: params.user,
2715
2788
  password: params.password,
2716
2789
  ca: params.certificate,
2717
- useTLS: true
2790
+ useTLS: true,
2791
+ ...params.servername === void 0 ? {} : { servername: params.servername }
2718
2792
  });
2719
2793
  await openClient(client, params.connectTimeoutMs);
2720
2794
  try {
@@ -2995,6 +3069,605 @@ function applyAutoLimit(sql, limit) {
2995
3069
  };
2996
3070
  }
2997
3071
 
3072
+ // src/tunnel/cache.ts
3073
+ 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";
3075
+ import { homedir as homedir4 } from "os";
3076
+ import { join as join6 } from "path";
3077
+
3078
+ // src/tunnel/process.ts
3079
+ import { spawn } from "child_process";
3080
+ import { mkdtemp as mkdtemp2, rm as rm4 } from "fs/promises";
3081
+ import { connect as netConnect, createServer } from "net";
3082
+ import { tmpdir as tmpdir2 } from "os";
3083
+ import { join as join5 } from "path";
3084
+ var PORT_POLL_INTERVAL_MS = 100;
3085
+ var DEFAULT_PORT_PROBE_TIMEOUT_MS = 500;
3086
+ function assertPositiveSafeInteger(label, value) {
3087
+ if (!Number.isSafeInteger(value) || value <= 0) {
3088
+ throw new CfHanaError("CONFIG", `${label} must be a positive integer`);
3089
+ }
3090
+ }
3091
+ async function allocateLocalPort() {
3092
+ return await new Promise((resolve, reject) => {
3093
+ const server = createServer();
3094
+ server.once("error", reject);
3095
+ server.listen(0, "127.0.0.1", () => {
3096
+ const address = server.address();
3097
+ server.close(() => {
3098
+ if (address === null || typeof address === "string") {
3099
+ reject(new CfHanaError("CONNECTION", "Could not allocate a local port for a tunnel"));
3100
+ return;
3101
+ }
3102
+ resolve(address.port);
3103
+ });
3104
+ });
3105
+ });
3106
+ }
3107
+ function probeLocalPort(port, timeoutMs = DEFAULT_PORT_PROBE_TIMEOUT_MS) {
3108
+ return new Promise((resolve) => {
3109
+ let settled = false;
3110
+ const socket = netConnect({ host: "127.0.0.1", port });
3111
+ const finish = (result) => {
3112
+ if (settled) {
3113
+ return;
3114
+ }
3115
+ settled = true;
3116
+ clearTimeout(timer);
3117
+ socket.destroy();
3118
+ resolve(result);
3119
+ };
3120
+ const timer = setTimeout(() => {
3121
+ finish(false);
3122
+ }, timeoutMs);
3123
+ timer.unref();
3124
+ socket.once("connect", () => {
3125
+ finish(true);
3126
+ });
3127
+ socket.once("error", () => {
3128
+ finish(false);
3129
+ });
3130
+ });
3131
+ }
3132
+ function killTunnelProcess(pid) {
3133
+ if (pid === void 0) {
3134
+ return;
3135
+ }
3136
+ try {
3137
+ process.kill(pid, "SIGTERM");
3138
+ } catch {
3139
+ }
3140
+ }
3141
+ async function withScopedCfSession(selectorSource, target, sapCredentials, work) {
3142
+ if (selectorSource === "ambient") {
3143
+ return await work();
3144
+ }
3145
+ if (sapCredentials === void 0) {
3146
+ throw new CredentialsNotFoundError(
3147
+ "SAP credentials are required to open a tunnel session for an explicit selector"
3148
+ );
3149
+ }
3150
+ const cfHome = await mkdtemp2(join5(tmpdir2(), "saptools-cf-hana-tunnel-"));
3151
+ try {
3152
+ const ctx = { cfHome };
3153
+ await cfApi(target.apiEndpoint, ctx);
3154
+ await cfAuth(sapCredentials.email, sapCredentials.password, ctx);
3155
+ await cfTargetSpace(target.orgName, target.spaceName, ctx);
3156
+ return await work(ctx);
3157
+ } finally {
3158
+ await rm4(cfHome, { recursive: true, force: true });
3159
+ }
3160
+ }
3161
+ function raceTunnelReadiness(child, localPort, boundMs, probePort, killProcess) {
3162
+ return new Promise((resolve) => {
3163
+ let settled = false;
3164
+ const timers = {};
3165
+ const finish = (result) => {
3166
+ if (settled) {
3167
+ return;
3168
+ }
3169
+ settled = true;
3170
+ clearInterval(timers.poll);
3171
+ clearTimeout(timers.deadline);
3172
+ child.removeListener("exit", onAbort);
3173
+ child.removeListener("error", onAbort);
3174
+ if (result === void 0) {
3175
+ killProcess(child.pid);
3176
+ } else {
3177
+ child.unref();
3178
+ }
3179
+ resolve(result);
3180
+ };
3181
+ function onAbort() {
3182
+ finish();
3183
+ }
3184
+ child.on("exit", onAbort);
3185
+ child.on("error", onAbort);
3186
+ timers.poll = setInterval(() => {
3187
+ void probePort(localPort).then((open) => {
3188
+ if (open && child.pid !== void 0) {
3189
+ finish({ localPort, pid: child.pid });
3190
+ }
3191
+ });
3192
+ }, PORT_POLL_INTERVAL_MS);
3193
+ timers.deadline = setTimeout(finish, boundMs);
3194
+ });
3195
+ }
3196
+ async function spawnTunnel(params, deps = {}) {
3197
+ assertPositiveSafeInteger("tunnel keepalive seconds", params.keepaliveSeconds);
3198
+ const remainingMs = params.deadline - Date.now();
3199
+ if (remainingMs <= 0) {
3200
+ return void 0;
3201
+ }
3202
+ const boundMs = Math.min(remainingMs, params.candidateTimeoutMs);
3203
+ const spawnProcess = deps.spawnProcess ?? spawn;
3204
+ const probePort = deps.probePort ?? probeLocalPort;
3205
+ const allocatePort = deps.allocatePort ?? allocateLocalPort;
3206
+ const killProcess = deps.killProcess ?? killTunnelProcess;
3207
+ const localPort = await allocatePort();
3208
+ const { bin, argsPrefix } = resolveCfBin();
3209
+ const forward = `${String(localPort)}:${params.hanaHost}:${String(params.hanaPort)}`;
3210
+ const args = [
3211
+ ...argsPrefix,
3212
+ "ssh",
3213
+ params.app,
3214
+ "-L",
3215
+ forward,
3216
+ "-c",
3217
+ `sleep ${String(params.keepaliveSeconds)}`
3218
+ ];
3219
+ 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);
3222
+ }
3223
+
3224
+ // src/tunnel/cache.ts
3225
+ var DEFAULT_ESTABLISHING_STALE_MS = 2 * DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
3226
+ var DEFAULT_POLL_INTERVAL_MS = 300;
3227
+ function isRecord3(value) {
3228
+ return typeof value === "object" && value !== null;
3229
+ }
3230
+ function defaultIsProcessAlive(pid) {
3231
+ try {
3232
+ process.kill(pid, 0);
3233
+ return true;
3234
+ } catch (error) {
3235
+ return isRecord3(error) && error["code"] !== "ESRCH";
3236
+ }
3237
+ }
3238
+ function sleep(ms) {
3239
+ return new Promise((resolve) => {
3240
+ setTimeout(resolve, ms);
3241
+ });
3242
+ }
3243
+ function tunnelCacheRoot(saptoolsRoot) {
3244
+ return join6(saptoolsRoot ?? join6(homedir4(), ".saptools"), "cf-hana", "tunnel");
3245
+ }
3246
+ function tunnelCacheKey(host) {
3247
+ return createHash2("sha256").update(host).digest("hex");
3248
+ }
3249
+ function tunnelCachePath(host, saptoolsRoot) {
3250
+ return join6(tunnelCacheRoot(saptoolsRoot), `${tunnelCacheKey(host)}.json`);
3251
+ }
3252
+ function isOrgKey(value) {
3253
+ return isRecord3(value) && typeof value["apiEndpoint"] === "string" && typeof value["orgName"] === "string";
3254
+ }
3255
+ function hasEstablishingFields(value) {
3256
+ return value["status"] === "establishing" && typeof value["host"] === "string" && typeof value["startedAt"] === "string" && typeof value["ownerPid"] === "number" && isOrgKey(value["orgKey"]);
3257
+ }
3258
+ function hasReadyFields(value) {
3259
+ 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"]);
3260
+ }
3261
+ function isTunnelCacheRecord(value) {
3262
+ return isRecord3(value) && value["version"] === 1 && (hasEstablishingFields(value) || hasReadyFields(value));
3263
+ }
3264
+ async function parseTunnelCacheFile(path) {
3265
+ try {
3266
+ const raw = JSON.parse(await readFile2(path, "utf8"));
3267
+ return isTunnelCacheRecord(raw) ? raw : void 0;
3268
+ } catch {
3269
+ return void 0;
3270
+ }
3271
+ }
3272
+ async function readTunnelCacheEntry(host, options = {}) {
3273
+ const entry = await parseTunnelCacheFile(tunnelCachePath(host, options.saptoolsRoot));
3274
+ return entry?.host === host ? entry : void 0;
3275
+ }
3276
+ function isExpired(entry, now) {
3277
+ const expiresAt = Date.parse(entry.expiresAt);
3278
+ return !Number.isFinite(expiresAt) || now.getTime() >= expiresAt;
3279
+ }
3280
+ async function isTunnelUsable(entry, options = {}) {
3281
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
3282
+ if (!isAlive(entry.pid)) {
3283
+ return false;
3284
+ }
3285
+ if (isExpired(entry, options.now?.() ?? /* @__PURE__ */ new Date())) {
3286
+ return false;
3287
+ }
3288
+ const probePort = options.probePort ?? probeLocalPort;
3289
+ return await probePort(entry.localPort);
3290
+ }
3291
+ function isEstablishingStale(entry, options) {
3292
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
3293
+ if (!isAlive(entry.ownerPid)) {
3294
+ return true;
3295
+ }
3296
+ const startedAt = Date.parse(entry.startedAt);
3297
+ if (!Number.isFinite(startedAt)) {
3298
+ return true;
3299
+ }
3300
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
3301
+ const staleAfterMs = options.staleAfterMs ?? DEFAULT_ESTABLISHING_STALE_MS;
3302
+ return now.getTime() - startedAt >= staleAfterMs;
3303
+ }
3304
+ async function writeEstablishingMarker(host, ownerPid, orgKey, options) {
3305
+ const root = tunnelCacheRoot(options.saptoolsRoot);
3306
+ await mkdir4(root, { recursive: true, mode: 448 });
3307
+ const record = {
3308
+ version: 1,
3309
+ status: "establishing",
3310
+ host,
3311
+ startedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
3312
+ ownerPid,
3313
+ orgKey
3314
+ };
3315
+ try {
3316
+ await writeFile3(tunnelCachePath(host, options.saptoolsRoot), `${JSON.stringify(record)}
3317
+ `, {
3318
+ encoding: "utf8",
3319
+ mode: 384,
3320
+ flag: "wx"
3321
+ });
3322
+ return true;
3323
+ } catch (error) {
3324
+ if (isRecord3(error) && error["code"] === "EEXIST") {
3325
+ return false;
3326
+ }
3327
+ throw error;
3328
+ }
3329
+ }
3330
+ async function claimEstablishing(host, ownerPid, orgKey, options = {}) {
3331
+ if (await writeEstablishingMarker(host, ownerPid, orgKey, options)) {
3332
+ return { outcome: "claimed" };
3333
+ }
3334
+ const existing = await readTunnelCacheEntry(host, options);
3335
+ if (existing === void 0) {
3336
+ return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
3337
+ }
3338
+ if (existing.status === "ready") {
3339
+ return { outcome: "already-ready", record: existing };
3340
+ }
3341
+ if (!isEstablishingStale(existing, options)) {
3342
+ return { outcome: "wait" };
3343
+ }
3344
+ await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3345
+ return await writeEstablishingMarker(host, ownerPid, orgKey, options) ? { outcome: "claimed" } : { outcome: "wait" };
3346
+ }
3347
+ async function waitForEstablishment(host, deadline, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, options = {}) {
3348
+ for (; ; ) {
3349
+ const entry = await readTunnelCacheEntry(host, options);
3350
+ if (entry === void 0) {
3351
+ return void 0;
3352
+ }
3353
+ if (entry.status === "ready") {
3354
+ return entry;
3355
+ }
3356
+ const remainingMs = deadline - Date.now();
3357
+ if (remainingMs <= 0) {
3358
+ return void 0;
3359
+ }
3360
+ await sleep(Math.min(pollIntervalMs, remainingMs));
3361
+ }
3362
+ }
3363
+ async function finalizeEstablishingReady(host, record, options = {}) {
3364
+ const root = tunnelCacheRoot(options.saptoolsRoot);
3365
+ const path = tunnelCachePath(host, options.saptoolsRoot);
3366
+ const tempPath = `${path}.tmp-${String(process.pid)}`;
3367
+ const stored = { version: 1, status: "ready", host, ...record };
3368
+ await mkdir4(root, { recursive: true, mode: 448 });
3369
+ await rm5(tempPath, { force: true });
3370
+ await writeFile3(tempPath, `${JSON.stringify(stored)}
3371
+ `, { encoding: "utf8", mode: 384 });
3372
+ await rename2(tempPath, path);
3373
+ }
3374
+ async function finalizeEstablishingFailed(host, options = {}) {
3375
+ await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3376
+ }
3377
+ async function evictTunnelCache(host, options = {}) {
3378
+ await rm5(tunnelCachePath(host, options.saptoolsRoot), { force: true });
3379
+ }
3380
+ function sameOrg(a, b) {
3381
+ return a.apiEndpoint === b.apiEndpoint && a.orgName === b.orgName;
3382
+ }
3383
+ async function reapOneFile(path, currentOrgKey, options) {
3384
+ const entry = await parseTunnelCacheFile(path);
3385
+ if (entry === void 0) {
3386
+ return;
3387
+ }
3388
+ const killProcess = options.killProcess ?? killTunnelProcess;
3389
+ if (entry.status === "establishing") {
3390
+ if (isEstablishingStale(entry, options)) {
3391
+ await rm5(path, { force: true });
3392
+ }
3393
+ return;
3394
+ }
3395
+ const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
3396
+ if (!isAlive(entry.pid)) {
3397
+ await rm5(path, { force: true });
3398
+ return;
3399
+ }
3400
+ const now = options.now?.() ?? /* @__PURE__ */ new Date();
3401
+ if (isExpired(entry, now) || !sameOrg(entry.orgKey, currentOrgKey)) {
3402
+ killProcess(entry.pid);
3403
+ await rm5(path, { force: true });
3404
+ }
3405
+ }
3406
+ async function reapStaleAndCrossOrgTunnels(currentOrgKey, options = {}) {
3407
+ const root = tunnelCacheRoot(options.saptoolsRoot);
3408
+ let files;
3409
+ try {
3410
+ files = await readdir2(root);
3411
+ } catch {
3412
+ return;
3413
+ }
3414
+ await Promise.all(
3415
+ files.filter((file) => file.endsWith(".json")).map(async (file) => {
3416
+ await reapOneFile(join6(root, file), currentOrgKey, options);
3417
+ })
3418
+ );
3419
+ }
3420
+
3421
+ // src/tunnel/candidates.ts
3422
+ var RUNNING_STATE = "started";
3423
+ var VALID_APP_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
3424
+ function isValidAppName(name) {
3425
+ return VALID_APP_NAME.test(name);
3426
+ }
3427
+ function discoveredAppNames(stdout, targetAppName) {
3428
+ if (stdout === void 0) {
3429
+ return [];
3430
+ }
3431
+ const seen = /* @__PURE__ */ new Set();
3432
+ const names = [];
3433
+ for (const row of parseCfAppsOutput(stdout)) {
3434
+ if (row.state !== RUNNING_STATE) {
3435
+ continue;
3436
+ }
3437
+ if (row.name === targetAppName || seen.has(row.name)) {
3438
+ continue;
3439
+ }
3440
+ if (!isValidAppName(row.name)) {
3441
+ continue;
3442
+ }
3443
+ seen.add(row.name);
3444
+ names.push(row.name);
3445
+ }
3446
+ return names;
3447
+ }
3448
+ function buildCandidateList(targetAppName, discoveredStdout, maxCandidates) {
3449
+ const discovered = discoveredAppNames(discoveredStdout, targetAppName);
3450
+ return [targetAppName, ...discovered.slice(0, maxCandidates)];
3451
+ }
3452
+
3453
+ // src/tunnel/classifier.ts
3454
+ var CONNECT_TIMEOUT_MESSAGE = "HANA connection timed out";
3455
+ var OPEN_CONNECTION_CODE = "EHDBOPENCONN";
3456
+ var MAX_CAUSE_DEPTH = 5;
3457
+ function causeCode(value) {
3458
+ return typeof value === "object" && value !== null ? value.code : void 0;
3459
+ }
3460
+ function causeOf(value) {
3461
+ return typeof value === "object" && value !== null ? value.cause : void 0;
3462
+ }
3463
+ function hasOpenConnectionCause(cause, depth) {
3464
+ if (depth > MAX_CAUSE_DEPTH) {
3465
+ return false;
3466
+ }
3467
+ if (causeCode(cause) === OPEN_CONNECTION_CODE) {
3468
+ return true;
3469
+ }
3470
+ return hasOpenConnectionCause(causeOf(cause), depth + 1);
3471
+ }
3472
+ function isConnectivityFailure(error) {
3473
+ if (!(error instanceof CfHanaError)) {
3474
+ return false;
3475
+ }
3476
+ if (error.code === "TIMEOUT") {
3477
+ return error.message.includes(CONNECT_TIMEOUT_MESSAGE);
3478
+ }
3479
+ if (error.code !== "CONNECTION") {
3480
+ return false;
3481
+ }
3482
+ return hasOpenConnectionCause(error.cause, 0);
3483
+ }
3484
+
3485
+ // src/tunnel/fallback.ts
3486
+ var EXPIRY_SAFETY_MARGIN_MS = 3e4;
3487
+ function directParamsOf(config) {
3488
+ return {
3489
+ host: config.host,
3490
+ port: config.port,
3491
+ user: config.user,
3492
+ password: config.password,
3493
+ schema: config.schema,
3494
+ certificate: config.certificate,
3495
+ connectTimeoutMs: config.connectTimeoutMs
3496
+ };
3497
+ }
3498
+ function tunneledParams(directParams, localPort) {
3499
+ return { ...directParams, host: "127.0.0.1", port: localPort, servername: directParams.host };
3500
+ }
3501
+ function orgKeyOf(config) {
3502
+ return { apiEndpoint: config.apiEndpoint, orgName: config.orgName };
3503
+ }
3504
+ function tunnelExpiryIso() {
3505
+ return new Date(
3506
+ Date.now() + DEFAULT_TUNNEL_KEEPALIVE_SECONDS * 1e3 - EXPIRY_SAFETY_MARGIN_MS
3507
+ ).toISOString();
3508
+ }
3509
+ async function discardQuietly(work) {
3510
+ await work.catch(() => {
3511
+ });
3512
+ }
3513
+ async function tryReadyRecord(record, driver, config, directParams, cacheOptions) {
3514
+ try {
3515
+ const connection = await driver.connect(tunneledParams(directParams, record.localPort));
3516
+ config.onTunnelStatus?.(`connected via SSH tunnel through ${record.app}`);
3517
+ return connection;
3518
+ } catch (error) {
3519
+ if (isConnectivityFailure(error)) {
3520
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3521
+ return void 0;
3522
+ }
3523
+ throw error;
3524
+ }
3525
+ }
3526
+ async function discoverAppsStdout(ctx) {
3527
+ try {
3528
+ return ctx === void 0 ? await cfAppsDirect() : await cfApps(ctx);
3529
+ } catch {
3530
+ return;
3531
+ }
3532
+ }
3533
+ async function buildCandidates(config, ctx, hintApp) {
3534
+ const stdout = await discoverAppsStdout(ctx);
3535
+ const base = buildCandidateList(config.appName, stdout, DEFAULT_TUNNEL_MAX_CANDIDATES);
3536
+ return hintApp === void 0 || base.includes(hintApp) ? base : [hintApp, ...base];
3537
+ }
3538
+ async function finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions) {
3539
+ const readyRecord = {
3540
+ localPort: spawned.localPort,
3541
+ pid: spawned.pid,
3542
+ app,
3543
+ orgKey: orgKeyOf(config),
3544
+ expiresAt: tunnelExpiryIso()
3545
+ };
3546
+ try {
3547
+ const connection = await driver.connect(tunneledParams(directParams, spawned.localPort));
3548
+ await discardQuietly(finalizeEstablishingReady(config.host, readyRecord, cacheOptions));
3549
+ config.onTunnelStatus?.(`connected via SSH tunnel through ${app}`);
3550
+ return connection;
3551
+ } catch (error) {
3552
+ if (isConnectivityFailure(error)) {
3553
+ await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
3554
+ killTunnelProcess(spawned.pid);
3555
+ return void 0;
3556
+ }
3557
+ await discardQuietly(finalizeEstablishingReady(config.host, readyRecord, cacheOptions));
3558
+ throw error;
3559
+ }
3560
+ }
3561
+ async function tryCandidate(app, ctx, driver, config, directParams, deadline, overrides) {
3562
+ const cacheOptions = overrides.cache ?? {};
3563
+ config.onTunnelStatus?.(`trying to reach ${config.host} via app ${app}...`);
3564
+ const claim = await claimEstablishing(config.host, process.pid, orgKeyOf(config), cacheOptions);
3565
+ if (claim.outcome === "already-ready") {
3566
+ return await tryReadyRecord(claim.record, driver, config, directParams, cacheOptions);
3567
+ }
3568
+ if (claim.outcome === "wait") {
3569
+ const ready = await waitForEstablishment(config.host, deadline, void 0, cacheOptions);
3570
+ return ready === void 0 ? void 0 : await tryReadyRecord(ready, driver, config, directParams, cacheOptions);
3571
+ }
3572
+ const spawned = await spawnTunnel(
3573
+ {
3574
+ cfHome: ctx?.cfHome,
3575
+ app,
3576
+ hanaHost: config.host,
3577
+ hanaPort: config.port,
3578
+ keepaliveSeconds: DEFAULT_TUNNEL_KEEPALIVE_SECONDS,
3579
+ deadline,
3580
+ candidateTimeoutMs: DEFAULT_TUNNEL_CANDIDATE_TIMEOUT_MS
3581
+ },
3582
+ overrides.process ?? {}
3583
+ );
3584
+ if (spawned === void 0) {
3585
+ await discardQuietly(finalizeEstablishingFailed(config.host, cacheOptions));
3586
+ return void 0;
3587
+ }
3588
+ return await finalizeCandidateConnection(app, spawned, driver, config, directParams, cacheOptions);
3589
+ }
3590
+ async function tryCachedTunnel(driver, config, directParams, cacheOptions) {
3591
+ if (config.refreshTunnel) {
3592
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3593
+ return { connection: void 0, hintApp: void 0 };
3594
+ }
3595
+ const entry = await readTunnelCacheEntry(config.host, cacheOptions);
3596
+ if (entry?.status !== "ready") {
3597
+ return { connection: void 0, hintApp: void 0 };
3598
+ }
3599
+ if (!await isTunnelUsable(entry, cacheOptions)) {
3600
+ await discardQuietly(evictTunnelCache(config.host, cacheOptions));
3601
+ return { connection: void 0, hintApp: entry.app };
3602
+ }
3603
+ const connection = await tryReadyRecord(entry, driver, config, directParams, cacheOptions);
3604
+ return { connection, hintApp: entry.app };
3605
+ }
3606
+ async function tryDirectConnect(driver, config, directParams) {
3607
+ if (config.tunnelMode !== "auto") {
3608
+ return { connection: void 0, directError: void 0 };
3609
+ }
3610
+ try {
3611
+ return { connection: await driver.connect(directParams), directError: void 0 };
3612
+ } catch (error) {
3613
+ if (!isConnectivityFailure(error)) {
3614
+ throw error;
3615
+ }
3616
+ return { connection: void 0, directError: error };
3617
+ }
3618
+ }
3619
+ function buildExhaustionError(host, directError, candidates) {
3620
+ if (directError !== void 0) {
3621
+ return directError;
3622
+ }
3623
+ return new CfHanaError(
3624
+ "CONNECTION",
3625
+ `Could not establish an SSH tunnel to ${host} through any candidate app (tried: ${candidates.length > 0 ? candidates.join(", ") : "none"})`
3626
+ );
3627
+ }
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) {
3631
+ if (Date.now() >= deadline) {
3632
+ break;
3633
+ }
3634
+ const connection = await tryCandidate(app, ctx, driver, config, directParams, deadline, overrides);
3635
+ if (connection !== void 0) {
3636
+ return { connection, candidatesTried };
3637
+ }
3638
+ }
3639
+ return { connection: void 0, candidatesTried };
3640
+ }
3641
+ async function connectWithTunnelFallback(driver, config, overrides = {}) {
3642
+ const directParams = directParamsOf(config);
3643
+ const cacheOptions = overrides.cache ?? {};
3644
+ await discardQuietly(reapStaleAndCrossOrgTunnels(orgKeyOf(config), cacheOptions));
3645
+ const cached = await tryCachedTunnel(driver, config, directParams, cacheOptions);
3646
+ if (cached.connection !== void 0) {
3647
+ return cached.connection;
3648
+ }
3649
+ const direct = await tryDirectConnect(driver, config, directParams);
3650
+ if (direct.connection !== void 0) {
3651
+ return direct.connection;
3652
+ }
3653
+ const deadline = Date.now() + DEFAULT_TUNNEL_FALLBACK_BUDGET_MS;
3654
+ const target = {
3655
+ apiEndpoint: config.apiEndpoint,
3656
+ orgName: config.orgName,
3657
+ spaceName: config.spaceName
3658
+ };
3659
+ const loopResult = await withScopedCfSession(
3660
+ config.selectorSource,
3661
+ target,
3662
+ config.sapCredentials,
3663
+ (ctx) => runCandidateLoop(ctx, driver, config, directParams, deadline, cached.hintApp, overrides)
3664
+ );
3665
+ if (loopResult.connection !== void 0) {
3666
+ return loopResult.connection;
3667
+ }
3668
+ throw buildExhaustionError(config.host, direct.directError, loopResult.candidatesTried);
3669
+ }
3670
+
2998
3671
  // src/connection.ts
2999
3672
  function assertValidAutoLimit(value) {
3000
3673
  if (value !== false && (!Number.isSafeInteger(value) || value <= 0 || value >= Number.MAX_SAFE_INTEGER)) {
@@ -3088,15 +3761,7 @@ var Connection = class _Connection {
3088
3761
  driverConnection;
3089
3762
  config;
3090
3763
  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
- });
3764
+ const driverConnection = await connectWithTunnelFallback(driver, config);
3100
3765
  return new _Connection(driverConnection, config);
3101
3766
  }
3102
3767
  async query(sql, params, options) {
@@ -3378,6 +4043,31 @@ function toPoolOptions(options) {
3378
4043
  }
3379
4044
  return options.pool ?? {};
3380
4045
  }
4046
+ function buildConnectionConfig(resolved, target, options) {
4047
+ const sapCredentials = readSapCredentials({ email: options.email, password: options.password });
4048
+ return {
4049
+ host: target.host,
4050
+ port: target.port,
4051
+ user: target.user,
4052
+ password: target.password,
4053
+ schema: target.schema,
4054
+ certificate: target.certificate,
4055
+ connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
4056
+ queryTimeoutMs: options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
4057
+ readOnly: options.readOnly ?? false,
4058
+ allowDestructive: options.allowDestructive ?? false,
4059
+ autoLimit: options.autoLimit ?? DEFAULT_AUTO_LIMIT,
4060
+ appName: resolved.appName,
4061
+ orgName: resolved.orgName,
4062
+ spaceName: resolved.spaceName,
4063
+ apiEndpoint: resolved.apiEndpoint,
4064
+ selectorSource: resolved.selectorSource,
4065
+ tunnelMode: options.tunnel === true ? "always" : "auto",
4066
+ refreshTunnel: options.refreshTunnel ?? false,
4067
+ ...sapCredentials === void 0 ? {} : { sapCredentials },
4068
+ ...options.onTunnelStatus === void 0 ? {} : { onTunnelStatus: options.onTunnelStatus }
4069
+ };
4070
+ }
3381
4071
  function toSelectedBindingInfo(bindings, selected) {
3382
4072
  const availableBindingNames = bindings.flatMap(
3383
4073
  (binding) => binding.name === void 0 ? [] : [binding.name]
@@ -3405,19 +4095,7 @@ var HanaClient = class _HanaClient {
3405
4095
  const bindingInfo = toSelectedBindingInfo(resolved.bindings, binding);
3406
4096
  const target = toConnectionTarget(binding, role);
3407
4097
  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
- };
4098
+ const config = buildConnectionConfig(resolved, target, options);
3421
4099
  const poolOptions = toPoolOptions(options);
3422
4100
  const info = {
3423
4101
  selector: resolved.selector,
@@ -3614,7 +4292,7 @@ async function connect(selector, options) {
3614
4292
  }
3615
4293
 
3616
4294
  // src/cli-results.ts
3617
- import { writeFile as writeFile4 } from "fs/promises";
4295
+ import { writeFile as writeFile5 } from "fs/promises";
3618
4296
 
3619
4297
  // src/result-inspect.ts
3620
4298
  function assertPositiveInteger(name, value) {
@@ -3886,19 +4564,19 @@ function searchResultSession(session, searchTerm, options) {
3886
4564
 
3887
4565
  // src/result-store.ts
3888
4566
  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";
4567
+ import { mkdir as mkdir5, readFile as readFile3, readdir as readdir3, rename as rename3, rm as rm6, writeFile as writeFile4 } from "fs/promises";
4568
+ import { homedir as homedir5 } from "os";
4569
+ import { join as join7 } from "path";
3892
4570
  var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
3893
4571
  var MANIFEST_FILE_NAME = "manifest.json";
3894
4572
  function resultsRoot(saptoolsRoot) {
3895
- return join5(saptoolsRoot ?? join5(homedir4(), ".saptools"), "cf-hana", "results");
4573
+ return join7(saptoolsRoot ?? join7(homedir5(), ".saptools"), "cf-hana", "results");
3896
4574
  }
3897
4575
  function sessionDirectory(ref, saptoolsRoot) {
3898
- return join5(resultsRoot(saptoolsRoot), ref);
4576
+ return join7(resultsRoot(saptoolsRoot), ref);
3899
4577
  }
3900
4578
  function manifestPath(ref, saptoolsRoot) {
3901
- return join5(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
4579
+ return join7(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
3902
4580
  }
3903
4581
  function encodeCell(value) {
3904
4582
  if (value === null) {
@@ -3997,11 +4675,11 @@ function toStoredSession(input, ref, now) {
3997
4675
  result: encodeResult(input.result)
3998
4676
  };
3999
4677
  }
4000
- function isRecord3(value) {
4678
+ function isRecord4(value) {
4001
4679
  return typeof value === "object" && value !== null && !Array.isArray(value);
4002
4680
  }
4003
4681
  function isStoredSession(value) {
4004
- if (!isRecord3(value) || !isRecord3(value["result"]) || !isRecord3(value["info"])) {
4682
+ if (!isRecord4(value) || !isRecord4(value["result"]) || !isRecord4(value["info"])) {
4005
4683
  return false;
4006
4684
  }
4007
4685
  const result = value["result"];
@@ -4009,7 +4687,7 @@ function isStoredSession(value) {
4009
4687
  }
4010
4688
  async function readStoredSession(path) {
4011
4689
  try {
4012
- const parsed = JSON.parse(await readFile2(path, "utf8"));
4690
+ const parsed = JSON.parse(await readFile3(path, "utf8"));
4013
4691
  return isStoredSession(parsed) ? parsed : void 0;
4014
4692
  } catch (error) {
4015
4693
  if (error.code === "ENOENT") {
@@ -4028,7 +4706,7 @@ function toSession(stored, saptoolsRoot) {
4028
4706
  }
4029
4707
  async function listSessionRefs(saptoolsRoot) {
4030
4708
  try {
4031
- const entries = await readdir2(resultsRoot(saptoolsRoot), { withFileTypes: true });
4709
+ const entries = await readdir3(resultsRoot(saptoolsRoot), { withFileTypes: true });
4032
4710
  return entries.filter((entry) => entry.isDirectory() && RESULT_REF_PATTERN.test(entry.name)).map((entry) => entry.name);
4033
4711
  } catch (error) {
4034
4712
  if (error.code === "ENOENT") {
@@ -4049,17 +4727,17 @@ async function createResultSession(input, options = {}) {
4049
4727
  const root = resultsRoot(options.saptoolsRoot);
4050
4728
  const finalDirectory = sessionDirectory(ref, options.saptoolsRoot);
4051
4729
  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 });
4730
+ await mkdir5(root, { recursive: true, mode: 448 });
4731
+ await rm6(tempDirectory, { recursive: true, force: true });
4732
+ await mkdir5(tempDirectory, { mode: 448 });
4055
4733
  try {
4056
- await writeFile3(join5(tempDirectory, MANIFEST_FILE_NAME), serialized, {
4734
+ await writeFile4(join7(tempDirectory, MANIFEST_FILE_NAME), serialized, {
4057
4735
  encoding: "utf8",
4058
4736
  mode: 384
4059
4737
  });
4060
- await rename2(tempDirectory, finalDirectory);
4738
+ await rename3(tempDirectory, finalDirectory);
4061
4739
  } catch (error) {
4062
- await rm4(tempDirectory, { recursive: true, force: true });
4740
+ await rm6(tempDirectory, { recursive: true, force: true });
4063
4741
  throw error;
4064
4742
  }
4065
4743
  return toSession(stored, options.saptoolsRoot);
@@ -4102,7 +4780,7 @@ async function pruneResultSessions(options = {}) {
4102
4780
  for (const ref of refs) {
4103
4781
  const stored = await readStoredSession(manifestPath(ref, options.saptoolsRoot));
4104
4782
  if (stored === void 0 || Date.parse(stored.expiresAt) <= now) {
4105
- await rm4(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
4783
+ await rm6(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
4106
4784
  removed += 1;
4107
4785
  }
4108
4786
  }
@@ -4112,7 +4790,7 @@ async function clearResultSessions(options = {}) {
4112
4790
  const refs = await listSessionRefs(options.saptoolsRoot);
4113
4791
  await Promise.all(
4114
4792
  refs.map(async (ref) => {
4115
- await rm4(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
4793
+ await rm6(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
4116
4794
  })
4117
4795
  );
4118
4796
  return refs.length;
@@ -4282,7 +4960,7 @@ async function runExport(ref, options) {
4282
4960
  }
4283
4961
  const session = await readResultSession(ref);
4284
4962
  const selected = selectResultCell(session, options.row, options.column);
4285
- await writeFile4(options.output, cellExportValue(selected.value, selected.typeName), {
4963
+ await writeFile5(options.output, cellExportValue(selected.value, selected.typeName), {
4286
4964
  mode: 384
4287
4965
  });
4288
4966
  print(`wrote=${options.output}`);
@@ -4350,8 +5028,12 @@ function printResolvedTarget(info) {
4350
5028
  `
4351
5029
  );
4352
5030
  }
5031
+ function printTunnelStatus(message) {
5032
+ process.stderr.write(`${CLI_NAME}: ${message}
5033
+ `);
5034
+ }
4353
5035
  async function connectForCli(selector, options) {
4354
- const client = await connect(selector, options);
5036
+ const client = await connect(selector, { ...options, onTunnelStatus: printTunnelStatus });
4355
5037
  printResolvedTarget(client.info);
4356
5038
  return client;
4357
5039
  }
@@ -4416,6 +5098,8 @@ function toConnectOptions(opts) {
4416
5098
  readOnly: opts.readOnly,
4417
5099
  allowDestructive: opts.allowDestructive,
4418
5100
  autoLimit: opts.autoLimit ? opts.limit ?? DEFAULT_AUTO_LIMIT : false,
5101
+ tunnel: opts.tunnel,
5102
+ refreshTunnel: opts.refreshTunnel,
4419
5103
  ...opts.binding === void 0 ? {} : { bindingName: opts.binding },
4420
5104
  ...opts.bindingIndex === void 0 ? {} : { bindingIndex: opts.bindingIndex },
4421
5105
  ...opts.timeout === void 0 ? {} : { queryTimeoutMs: opts.timeout, connectTimeoutMs: opts.timeout }
@@ -4512,7 +5196,15 @@ function formatInfo(info) {
4512
5196
  ].join("\n");
4513
5197
  }
4514
5198
  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);
5199
+ 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(
5200
+ "--tunnel",
5201
+ "skip the direct connection attempt and connect via an SSH tunnel immediately",
5202
+ false
5203
+ ).option(
5204
+ "--refresh-tunnel",
5205
+ "bypass a cached/live SSH tunnel and force a fresh establishment attempt",
5206
+ false
5207
+ );
4516
5208
  }
4517
5209
  function withFormattedConnectionOptions(command) {
4518
5210
  return withConnectionOptions(command).option(