@saptools/cf-hana 0.2.0 → 0.2.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/README.md +15 -10
- package/dist/cli.js +490 -68
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +453 -50
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/backup.ts
|
|
7
|
-
import { createHash, randomUUID } from "crypto";
|
|
8
7
|
import { mkdir, writeFile } from "fs/promises";
|
|
9
8
|
import { homedir } from "os";
|
|
10
9
|
import { join } from "path";
|
|
@@ -374,6 +373,28 @@ function findTopLevelKeyword(sql, keyword, startIndex = 0) {
|
|
|
374
373
|
}
|
|
375
374
|
return void 0;
|
|
376
375
|
}
|
|
376
|
+
function findTopLevelChar(sql, target, startIndex, endIndex) {
|
|
377
|
+
let index = startIndex;
|
|
378
|
+
let depth = 0;
|
|
379
|
+
while (index < endIndex) {
|
|
380
|
+
const skipped = skipNonCode(sql, index);
|
|
381
|
+
if (skipped !== void 0) {
|
|
382
|
+
index = skipped;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const char = sql[index];
|
|
386
|
+
if (char === target && depth === 0) {
|
|
387
|
+
return index;
|
|
388
|
+
}
|
|
389
|
+
if (char === "(") {
|
|
390
|
+
depth += 1;
|
|
391
|
+
} else if (char === ")" && depth > 0) {
|
|
392
|
+
depth -= 1;
|
|
393
|
+
}
|
|
394
|
+
index += 1;
|
|
395
|
+
}
|
|
396
|
+
return void 0;
|
|
397
|
+
}
|
|
377
398
|
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
378
399
|
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
379
400
|
}
|
|
@@ -411,6 +432,24 @@ function buildUpdateBackupPlan(statementSql, params) {
|
|
|
411
432
|
params
|
|
412
433
|
);
|
|
413
434
|
}
|
|
435
|
+
function buildUpsertBackupPlan(statementSql, params) {
|
|
436
|
+
const upsertIndex = findTopLevelKeyword(statementSql, "UPSERT");
|
|
437
|
+
const valuesIndex = upsertIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "VALUES", upsertIndex + "UPSERT".length);
|
|
438
|
+
if (upsertIndex === void 0 || valuesIndex === void 0) {
|
|
439
|
+
throw new QueryError("UPSERT backup requires UPSERT <target> VALUES syntax");
|
|
440
|
+
}
|
|
441
|
+
const targetStart = upsertIndex + "UPSERT".length;
|
|
442
|
+
const columnListIndex = findTopLevelChar(statementSql, "(", targetStart, valuesIndex);
|
|
443
|
+
const targetEnd = columnListIndex ?? valuesIndex;
|
|
444
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
|
|
445
|
+
return buildSelectPlan(
|
|
446
|
+
"upsert",
|
|
447
|
+
statementSql,
|
|
448
|
+
statementSql.slice(targetStart, targetEnd),
|
|
449
|
+
whereIndex,
|
|
450
|
+
params
|
|
451
|
+
);
|
|
452
|
+
}
|
|
414
453
|
function buildDeleteBackupPlan(statementSql, params) {
|
|
415
454
|
const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
|
|
416
455
|
const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
|
|
@@ -430,8 +469,18 @@ function buildDeleteBackupPlan(statementSql, params) {
|
|
|
430
469
|
function backupTimestamp(now) {
|
|
431
470
|
return now.toISOString().replace(/:/g, "").replace(".", "");
|
|
432
471
|
}
|
|
433
|
-
function
|
|
434
|
-
|
|
472
|
+
function backupMonth(now) {
|
|
473
|
+
const year = String(now.getUTCFullYear());
|
|
474
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
475
|
+
return `${year}${month}`;
|
|
476
|
+
}
|
|
477
|
+
function sanitizePathPart(value) {
|
|
478
|
+
const trimmed = value?.trim() ?? "";
|
|
479
|
+
const normalized = trimmed.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
480
|
+
return normalized.length > 0 ? normalized : "unknown-target";
|
|
481
|
+
}
|
|
482
|
+
function backupBaseName(input, now) {
|
|
483
|
+
return [sanitizePathPart(input.selector), input.operation, backupTimestamp(now)].join("-");
|
|
435
484
|
}
|
|
436
485
|
function cfHanaBackupRoot(saptoolsRoot) {
|
|
437
486
|
return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
|
|
@@ -439,33 +488,49 @@ function cfHanaBackupRoot(saptoolsRoot) {
|
|
|
439
488
|
function buildWriteBackupPlan(sql, params = []) {
|
|
440
489
|
const statementSql = trimStatementSql(sql);
|
|
441
490
|
const keyword = firstKeyword(statementSql);
|
|
442
|
-
if (keyword !== "UPDATE" && keyword !== "DELETE") {
|
|
491
|
+
if (keyword !== "UPDATE" && keyword !== "UPSERT" && keyword !== "DELETE") {
|
|
443
492
|
return void 0;
|
|
444
493
|
}
|
|
445
494
|
assertParamArity(statementSql, params);
|
|
446
495
|
if (keyword === "UPDATE") {
|
|
447
496
|
return buildUpdateBackupPlan(statementSql, params);
|
|
448
497
|
}
|
|
498
|
+
if (keyword === "UPSERT") {
|
|
499
|
+
return buildUpsertBackupPlan(statementSql, params);
|
|
500
|
+
}
|
|
449
501
|
return buildDeleteBackupPlan(statementSql, params);
|
|
450
502
|
}
|
|
451
503
|
async function writeSqlBackup(input, options = {}) {
|
|
452
504
|
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
453
|
-
const directory = join(
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
);
|
|
457
|
-
const
|
|
458
|
-
const
|
|
505
|
+
const directory = join(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
|
|
506
|
+
const baseName = backupBaseName(input, now);
|
|
507
|
+
const statementPath = join(directory, `${baseName}.statement.sql`);
|
|
508
|
+
const backupPath = join(directory, `${baseName}.sql`);
|
|
509
|
+
const metadataPath = join(directory, `${baseName}.json`);
|
|
510
|
+
const metadata = {
|
|
511
|
+
selector: input.selector ?? null,
|
|
512
|
+
operation: input.operation,
|
|
513
|
+
statementPath,
|
|
514
|
+
backupPath,
|
|
515
|
+
rowCount: input.result.rowCount,
|
|
516
|
+
createdAt: now.toISOString()
|
|
517
|
+
};
|
|
459
518
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
460
519
|
await Promise.all([
|
|
461
520
|
writeFile(statementPath, `${input.statementSql}
|
|
462
521
|
`, { encoding: "utf8", mode: 384 }),
|
|
463
|
-
writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 })
|
|
522
|
+
writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 }),
|
|
523
|
+
writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}
|
|
524
|
+
`, {
|
|
525
|
+
encoding: "utf8",
|
|
526
|
+
mode: 384
|
|
527
|
+
})
|
|
464
528
|
]);
|
|
465
529
|
return {
|
|
466
530
|
directory,
|
|
467
531
|
statementPath,
|
|
468
532
|
backupPath,
|
|
533
|
+
metadataPath,
|
|
469
534
|
rowCount: input.result.rowCount
|
|
470
535
|
};
|
|
471
536
|
}
|
|
@@ -587,7 +652,7 @@ async function listColumns(connection, schema, table) {
|
|
|
587
652
|
|
|
588
653
|
// src/config.ts
|
|
589
654
|
var CLI_NAME = "cf-hana";
|
|
590
|
-
var CLI_VERSION = "0.2.
|
|
655
|
+
var CLI_VERSION = "0.2.1";
|
|
591
656
|
var ENV_PREFIX = "CF_HANA";
|
|
592
657
|
var DEFAULT_QUERY_TIMEOUT_MS = 3e4;
|
|
593
658
|
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
@@ -619,44 +684,381 @@ function readSapCredentials(overrides) {
|
|
|
619
684
|
return { email, password };
|
|
620
685
|
}
|
|
621
686
|
|
|
622
|
-
// src/
|
|
623
|
-
import {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
687
|
+
// src/cf.ts
|
|
688
|
+
import { execFile } from "child_process";
|
|
689
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
690
|
+
import { tmpdir } from "os";
|
|
691
|
+
import { join as join2 } from "path";
|
|
692
|
+
import { promisify } from "util";
|
|
693
|
+
var execFileAsync = promisify(execFile);
|
|
694
|
+
var MAX_BUFFER = 16 * 1024 * 1024;
|
|
695
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
696
|
+
var CF_RETRY_ATTEMPTS = 3;
|
|
697
|
+
var CF_RETRY_BASE_DELAY_MS = 120;
|
|
698
|
+
var REGION_API_MAP = {
|
|
699
|
+
ae01: "https://api.cf.ae01.hana.ondemand.com",
|
|
700
|
+
ap01: "https://api.cf.ap01.hana.ondemand.com",
|
|
701
|
+
ap10: "https://api.cf.ap10.hana.ondemand.com",
|
|
702
|
+
ap11: "https://api.cf.ap11.hana.ondemand.com",
|
|
703
|
+
ap12: "https://api.cf.ap12.hana.ondemand.com",
|
|
704
|
+
ap20: "https://api.cf.ap20.hana.ondemand.com",
|
|
705
|
+
ap21: "https://api.cf.ap21.hana.ondemand.com",
|
|
706
|
+
ap30: "https://api.cf.ap30.hana.ondemand.com",
|
|
707
|
+
br10: "https://api.cf.br10.hana.ondemand.com",
|
|
708
|
+
br20: "https://api.cf.br20.hana.ondemand.com",
|
|
709
|
+
br30: "https://api.cf.br30.hana.ondemand.com",
|
|
710
|
+
ca10: "https://api.cf.ca10.hana.ondemand.com",
|
|
711
|
+
ca20: "https://api.cf.ca20.hana.ondemand.com",
|
|
712
|
+
ch20: "https://api.cf.ch20.hana.ondemand.com",
|
|
713
|
+
eu10: "https://api.cf.eu10.hana.ondemand.com",
|
|
714
|
+
eu11: "https://api.cf.eu11.hana.ondemand.com",
|
|
715
|
+
eu12: "https://api.cf.eu12.hana.ondemand.com",
|
|
716
|
+
eu20: "https://api.cf.eu20.hana.ondemand.com",
|
|
717
|
+
eu21: "https://api.cf.eu21.hana.ondemand.com",
|
|
718
|
+
eu30: "https://api.cf.eu30.hana.ondemand.com",
|
|
719
|
+
"eu10-002": "https://api.cf.eu10-002.hana.ondemand.com",
|
|
720
|
+
jp10: "https://api.cf.jp10.hana.ondemand.com",
|
|
721
|
+
jp20: "https://api.cf.jp20.hana.ondemand.com",
|
|
722
|
+
jp30: "https://api.cf.jp30.hana.ondemand.com",
|
|
723
|
+
us10: "https://api.cf.us10.hana.ondemand.com",
|
|
724
|
+
us11: "https://api.cf.us11.hana.ondemand.com",
|
|
725
|
+
us20: "https://api.cf.us20.hana.ondemand.com",
|
|
726
|
+
us21: "https://api.cf.us21.hana.ondemand.com",
|
|
727
|
+
us30: "https://api.cf.us30.hana.ondemand.com",
|
|
728
|
+
in30: "https://api.cf.in30.hana.ondemand.com"
|
|
729
|
+
};
|
|
730
|
+
function getApiEndpointForRegion(regionKey) {
|
|
731
|
+
return REGION_API_MAP[regionKey];
|
|
732
|
+
}
|
|
733
|
+
function getRegionKeyForApi(apiEndpoint) {
|
|
734
|
+
const norm = apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
|
|
735
|
+
for (const [key, endpoint] of Object.entries(REGION_API_MAP)) {
|
|
736
|
+
if (endpoint.toLowerCase() === norm) {
|
|
737
|
+
return key;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return void 0;
|
|
741
|
+
}
|
|
742
|
+
async function withCfSession(work) {
|
|
743
|
+
const cfHome = await mkdtemp(join2(tmpdir(), "saptools-cf-hana-"));
|
|
744
|
+
const ctx = { cfHome };
|
|
745
|
+
try {
|
|
746
|
+
return await work(ctx);
|
|
747
|
+
} finally {
|
|
748
|
+
await rm(cfHome, { recursive: true, force: true });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function resolveCfBin() {
|
|
752
|
+
const raw = process.env["CF_HANA_CF_BIN"] ?? "cf";
|
|
753
|
+
if (/\.(?:c|m)?js$/i.test(raw)) {
|
|
754
|
+
return { bin: process.execPath, argsPrefix: [raw] };
|
|
755
|
+
}
|
|
756
|
+
return { bin: raw, argsPrefix: [] };
|
|
757
|
+
}
|
|
758
|
+
function buildEnv(ctx, overrides = {}) {
|
|
759
|
+
const env = { ...process.env, ...overrides };
|
|
760
|
+
delete env["SAP_EMAIL"];
|
|
761
|
+
delete env["SAP_PASSWORD"];
|
|
762
|
+
env["CF_HOME"] = ctx.cfHome;
|
|
763
|
+
return env;
|
|
764
|
+
}
|
|
765
|
+
async function runCf(args, ctx, overrides = {}) {
|
|
766
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
767
|
+
const env = buildEnv(ctx, overrides);
|
|
768
|
+
let lastErr;
|
|
769
|
+
for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
|
|
770
|
+
try {
|
|
771
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, ...args], {
|
|
772
|
+
env,
|
|
773
|
+
maxBuffer: MAX_BUFFER,
|
|
774
|
+
timeout: DEFAULT_TIMEOUT_MS
|
|
775
|
+
});
|
|
776
|
+
return stdout;
|
|
777
|
+
} catch (err) {
|
|
778
|
+
lastErr = err;
|
|
779
|
+
if (attempt < CF_RETRY_ATTEMPTS - 1) {
|
|
780
|
+
await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
|
|
781
|
+
}
|
|
634
782
|
}
|
|
635
783
|
}
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
784
|
+
const e = lastErr;
|
|
785
|
+
const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
|
|
786
|
+
const cmd = args[0] === "auth" ? "cf auth" : `cf ${args.join(" ")}`;
|
|
787
|
+
throw new Error(`${cmd} failed: ${detail}`.trim(), { cause: lastErr });
|
|
788
|
+
}
|
|
789
|
+
async function cfApi(apiEndpoint, ctx) {
|
|
790
|
+
await runCf(["api", apiEndpoint], ctx);
|
|
791
|
+
}
|
|
792
|
+
async function cfAuth(email, password, ctx) {
|
|
793
|
+
await runCf(["auth"], ctx, {
|
|
794
|
+
CF_USERNAME: email,
|
|
795
|
+
CF_PASSWORD: password
|
|
639
796
|
});
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
797
|
+
}
|
|
798
|
+
async function cfTargetSpace(orgName, spaceName, ctx) {
|
|
799
|
+
await runCf(["target", "-o", orgName, "-s", spaceName], ctx);
|
|
800
|
+
}
|
|
801
|
+
async function cfEnv(appName, ctx) {
|
|
802
|
+
return await runCf(["env", appName], ctx);
|
|
803
|
+
}
|
|
804
|
+
async function cfEnvDirect(appName) {
|
|
805
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
806
|
+
const env = { ...process.env };
|
|
807
|
+
delete env["SAP_EMAIL"];
|
|
808
|
+
delete env["SAP_PASSWORD"];
|
|
809
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, "env", appName], {
|
|
810
|
+
env,
|
|
811
|
+
maxBuffer: MAX_BUFFER,
|
|
812
|
+
timeout: DEFAULT_TIMEOUT_MS
|
|
813
|
+
});
|
|
814
|
+
return stdout;
|
|
815
|
+
}
|
|
816
|
+
async function readCurrentCfTarget() {
|
|
817
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
818
|
+
const env = { ...process.env };
|
|
819
|
+
delete env["SAP_EMAIL"];
|
|
820
|
+
delete env["SAP_PASSWORD"];
|
|
821
|
+
try {
|
|
822
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, "target"], {
|
|
823
|
+
env,
|
|
824
|
+
maxBuffer: MAX_BUFFER,
|
|
825
|
+
timeout: 1e4
|
|
826
|
+
});
|
|
827
|
+
return parseCfTargetOutput(stdout);
|
|
828
|
+
} catch {
|
|
829
|
+
return void 0;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function parseCfTargetOutput(stdout) {
|
|
833
|
+
const fields = parseTargetFields(stdout);
|
|
834
|
+
const api = fields.get("api endpoint");
|
|
835
|
+
const org = fields.get("org");
|
|
836
|
+
const space = fields.get("space");
|
|
837
|
+
if (!api || !org || !space) {
|
|
838
|
+
return void 0;
|
|
839
|
+
}
|
|
840
|
+
const regionKey = getRegionKeyForApi(api);
|
|
841
|
+
return {
|
|
842
|
+
apiEndpoint: api,
|
|
843
|
+
orgName: org,
|
|
844
|
+
spaceName: space,
|
|
845
|
+
...regionKey ? { regionKey } : {}
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function parseTargetFields(stdout) {
|
|
849
|
+
const map = /* @__PURE__ */ new Map();
|
|
850
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
851
|
+
const idx = line.indexOf(":");
|
|
852
|
+
if (idx < 0) {
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
856
|
+
const val = line.slice(idx + 1).trim();
|
|
857
|
+
if (key && val) {
|
|
858
|
+
map.set(key, val);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return map;
|
|
862
|
+
}
|
|
863
|
+
function formatCurrentCfAppSelector(target, appName) {
|
|
864
|
+
const name = appName.trim();
|
|
865
|
+
if (!name) {
|
|
866
|
+
throw new Error("App name is required.");
|
|
867
|
+
}
|
|
868
|
+
if (!target.regionKey) {
|
|
869
|
+
throw new Error(
|
|
870
|
+
`Current CF API endpoint "${target.apiEndpoint}" does not match a known SAP region. Pass a full region/org/space/app selector.`
|
|
643
871
|
);
|
|
644
872
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
873
|
+
return `${target.regionKey}/${target.orgName}/${target.spaceName}/${name}`;
|
|
874
|
+
}
|
|
875
|
+
function extractVcapSection(stdout) {
|
|
876
|
+
const start = stdout.indexOf("VCAP_SERVICES:");
|
|
877
|
+
if (start === -1) {
|
|
878
|
+
throw new Error("VCAP_SERVICES section not found in cf env output");
|
|
879
|
+
}
|
|
880
|
+
const after = stdout.slice(start + "VCAP_SERVICES:".length);
|
|
881
|
+
const end = after.indexOf("VCAP_APPLICATION:");
|
|
882
|
+
const block = end === -1 ? after : after.slice(0, end);
|
|
883
|
+
return block.trim();
|
|
884
|
+
}
|
|
885
|
+
function isRecord(v) {
|
|
886
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
887
|
+
}
|
|
888
|
+
function assertCreds(raw) {
|
|
889
|
+
if (!isRecord(raw)) {
|
|
890
|
+
throw new Error("HANA credentials must be an object");
|
|
891
|
+
}
|
|
892
|
+
const req = ["host", "port", "user", "password", "schema", "hdi_user", "hdi_password", "url", "database_id", "certificate"];
|
|
893
|
+
for (const k of req) {
|
|
894
|
+
if (typeof raw[k] !== "string") {
|
|
895
|
+
throw new Error(`Missing/invalid HANA credential field: "${k}"`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return raw;
|
|
899
|
+
}
|
|
900
|
+
function mapCreds(raw) {
|
|
901
|
+
return {
|
|
902
|
+
host: raw.host,
|
|
903
|
+
port: raw.port,
|
|
904
|
+
user: raw.user,
|
|
905
|
+
password: raw.password,
|
|
906
|
+
schema: raw.schema,
|
|
907
|
+
hdiUser: raw.hdi_user,
|
|
908
|
+
hdiPassword: raw.hdi_password,
|
|
909
|
+
...raw.url ? { url: raw.url } : {},
|
|
910
|
+
databaseId: raw.database_id,
|
|
911
|
+
certificate: raw.certificate
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
function extractHanaBindingsFromCfEnv(stdout) {
|
|
915
|
+
const jsonText = extractVcapSection(stdout);
|
|
916
|
+
let vcap;
|
|
917
|
+
try {
|
|
918
|
+
vcap = JSON.parse(jsonText);
|
|
919
|
+
} catch {
|
|
920
|
+
throw new Error("VCAP_SERVICES is not valid JSON");
|
|
921
|
+
}
|
|
922
|
+
if (!isRecord(vcap)) {
|
|
923
|
+
throw new Error("VCAP_SERVICES must be an object");
|
|
924
|
+
}
|
|
925
|
+
const hana = vcap["hana"];
|
|
926
|
+
if (hana === void 0) {
|
|
927
|
+
return [];
|
|
928
|
+
}
|
|
929
|
+
if (!Array.isArray(hana)) {
|
|
930
|
+
throw new Error("VCAP_SERVICES.hana must be an array when present");
|
|
931
|
+
}
|
|
932
|
+
return hana.map((b) => {
|
|
933
|
+
if (!isRecord(b)) {
|
|
934
|
+
throw new Error("HANA binding must be an object");
|
|
935
|
+
}
|
|
936
|
+
const credsRaw = assertCreds(b["credentials"]);
|
|
937
|
+
const name = typeof b["name"] === "string" ? b["name"] : void 0;
|
|
938
|
+
return {
|
|
939
|
+
...name ? { name } : {},
|
|
940
|
+
credentials: mapCreds(credsRaw)
|
|
941
|
+
};
|
|
649
942
|
});
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
943
|
+
}
|
|
944
|
+
function classifyCfError(stderr = "", stdout = "") {
|
|
945
|
+
const text = `${stderr} ${stdout}`.toLowerCase();
|
|
946
|
+
if (text.includes("not logged in") || text.includes("unauthorized") || text.includes("forbidden") || text.includes("credentials were rejected") || text.includes("authentication failed") || text.includes("cf auth") || text.includes("token") || text.includes("expired") || text.includes("session")) {
|
|
947
|
+
return { isAuthError: true, reason: "auth/session issue" };
|
|
948
|
+
}
|
|
949
|
+
return { isAuthError: false, reason: "" };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/credentials.ts
|
|
953
|
+
async function resolveAppBindings(rawSelector, options) {
|
|
954
|
+
const selector = rawSelector.trim();
|
|
955
|
+
if (!selector) {
|
|
956
|
+
throw new CfHanaError("CONFIG", "App selector is required");
|
|
957
|
+
}
|
|
958
|
+
let target;
|
|
959
|
+
const isBare = !selector.includes("/");
|
|
960
|
+
if (isBare) {
|
|
961
|
+
const current = await readCurrentCfTarget();
|
|
962
|
+
if (!current) {
|
|
963
|
+
throw new CfHanaError(
|
|
964
|
+
"CONFIG",
|
|
965
|
+
"No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
const displaySelector = current.regionKey ? formatCurrentCfAppSelector(current, selector) : `current/${current.orgName}/${current.spaceName}/${selector}`;
|
|
969
|
+
target = {
|
|
970
|
+
selector: displaySelector,
|
|
971
|
+
apiEndpoint: current.regionKey ? getApiEndpointForRegion(current.regionKey) : void 0,
|
|
972
|
+
orgName: current.orgName,
|
|
973
|
+
spaceName: current.spaceName,
|
|
974
|
+
appName: selector
|
|
975
|
+
};
|
|
976
|
+
} else {
|
|
977
|
+
const parts = selector.split("/").map((p) => p.trim());
|
|
978
|
+
if (parts.length !== 4 || !parts[0] || !parts[1] || !parts[2] || !parts[3]) {
|
|
979
|
+
throw new CfHanaError(
|
|
980
|
+
"CONFIG",
|
|
981
|
+
`Invalid selector "${selector}". Use region/org/space/app or a bare app name.`
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
const [regionKey, orgName, spaceName, appName] = parts;
|
|
985
|
+
const apiEndpoint = getApiEndpointForRegion(regionKey);
|
|
986
|
+
if (!apiEndpoint) {
|
|
987
|
+
throw new CfHanaError(
|
|
988
|
+
"CONFIG",
|
|
989
|
+
`Unknown region key "${regionKey}". Use a known region or the current CF target.`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
target = { selector, apiEndpoint, orgName, spaceName, appName };
|
|
993
|
+
}
|
|
994
|
+
let bindings;
|
|
995
|
+
const source = "live";
|
|
996
|
+
if (isBare) {
|
|
997
|
+
try {
|
|
998
|
+
const stdout = await cfEnvDirect(target.appName);
|
|
999
|
+
bindings = extractHanaBindingsFromCfEnv(stdout);
|
|
1000
|
+
} catch (directError) {
|
|
1001
|
+
let stderr = "";
|
|
1002
|
+
if (directError && typeof directError === "object") {
|
|
1003
|
+
const e = directError;
|
|
1004
|
+
stderr = (typeof e.stderr === "string" ? e.stderr : "") || (typeof e.message === "string" ? e.message : "");
|
|
1005
|
+
}
|
|
1006
|
+
const classified = classifyCfError(stderr);
|
|
1007
|
+
if (classified.isAuthError) {
|
|
1008
|
+
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
1009
|
+
if (!sap) {
|
|
1010
|
+
throw new CredentialsNotFoundError(
|
|
1011
|
+
`Current CF session problem for bare app "${target.appName}" (${classified.reason}).
|
|
1012
|
+
Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
|
|
1013
|
+
{ cause: directError }
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
if (!target.apiEndpoint) {
|
|
1017
|
+
throw new CfHanaError("CONFIG", "Cannot determine API endpoint for fallback auth.");
|
|
1018
|
+
}
|
|
1019
|
+
const api = target.apiEndpoint;
|
|
1020
|
+
bindings = await withCfSession(async (ctx) => {
|
|
1021
|
+
await cfApi(api, ctx);
|
|
1022
|
+
await cfAuth(sap.email, sap.password, ctx);
|
|
1023
|
+
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
1024
|
+
const stdout = await cfEnv(target.appName, ctx);
|
|
1025
|
+
return extractHanaBindingsFromCfEnv(stdout);
|
|
1026
|
+
});
|
|
1027
|
+
} else {
|
|
1028
|
+
throw new CfHanaError(
|
|
1029
|
+
"CONFIG",
|
|
1030
|
+
`Failed to get HANA bindings for bare app "${target.appName}" using current target (org=${target.orgName}, space=${target.spaceName}). Verify with "cf target" and "cf env ${target.appName}". ${stderr ? `Details: ${stderr}` : ""}`,
|
|
1031
|
+
{ cause: directError }
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
} else {
|
|
1036
|
+
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
1037
|
+
if (!sap) {
|
|
1038
|
+
throw new CredentialsNotFoundError(
|
|
1039
|
+
`SAP_EMAIL and SAP_PASSWORD are required to fetch HANA bindings for explicit selector "${selector}".`
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
if (!target.apiEndpoint) {
|
|
1043
|
+
throw new CfHanaError("CONFIG", "Invalid explicit selector.");
|
|
1044
|
+
}
|
|
1045
|
+
const api = target.apiEndpoint;
|
|
1046
|
+
bindings = await withCfSession(async (ctx) => {
|
|
1047
|
+
await cfApi(api, ctx);
|
|
1048
|
+
await cfAuth(sap.email, sap.password, ctx);
|
|
1049
|
+
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
1050
|
+
const stdout = await cfEnv(target.appName, ctx);
|
|
1051
|
+
return extractHanaBindingsFromCfEnv(stdout);
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
if (bindings.length === 0) {
|
|
1055
|
+
throw new CredentialsNotFoundError(`App "${target.selector}" has no HANA service binding.`);
|
|
654
1056
|
}
|
|
655
1057
|
return {
|
|
656
|
-
selector:
|
|
657
|
-
appName:
|
|
658
|
-
bindings
|
|
659
|
-
source
|
|
1058
|
+
selector: target.selector,
|
|
1059
|
+
appName: target.appName,
|
|
1060
|
+
bindings,
|
|
1061
|
+
source
|
|
660
1062
|
};
|
|
661
1063
|
}
|
|
662
1064
|
function selectBinding(bindings, selector) {
|
|
@@ -706,7 +1108,7 @@ function toConnectionTarget(binding, role) {
|
|
|
706
1108
|
password: role === "hdi" ? credentials.hdiPassword : credentials.password,
|
|
707
1109
|
schema: credentials.schema,
|
|
708
1110
|
certificate: credentials.certificate,
|
|
709
|
-
databaseId: credentials.databaseId
|
|
1111
|
+
databaseId: credentials.databaseId ?? ""
|
|
710
1112
|
};
|
|
711
1113
|
}
|
|
712
1114
|
|
|
@@ -1033,16 +1435,16 @@ function createDriver(name) {
|
|
|
1033
1435
|
}
|
|
1034
1436
|
|
|
1035
1437
|
// src/history.ts
|
|
1036
|
-
import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm } from "fs/promises";
|
|
1438
|
+
import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm as rm2 } from "fs/promises";
|
|
1037
1439
|
import { homedir as homedir2 } from "os";
|
|
1038
|
-
import { join as
|
|
1440
|
+
import { join as join3 } from "path";
|
|
1039
1441
|
var SAPTOOLS_DIR_NAME2 = ".saptools";
|
|
1040
1442
|
var CF_HANA_DIR_NAME2 = "cf-hana";
|
|
1041
1443
|
var HISTORIES_DIR_NAME = "histories";
|
|
1042
1444
|
var HISTORY_RETENTION_DAYS = 5;
|
|
1043
1445
|
var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
1044
1446
|
function defaultSaptoolsRoot2() {
|
|
1045
|
-
return
|
|
1447
|
+
return join3(homedir2(), SAPTOOLS_DIR_NAME2);
|
|
1046
1448
|
}
|
|
1047
1449
|
function padDatePart(value) {
|
|
1048
1450
|
return value.toString().padStart(2, "0");
|
|
@@ -1064,10 +1466,10 @@ function resolveSaptoolsRoot(root) {
|
|
|
1064
1466
|
return root ?? defaultSaptoolsRoot2();
|
|
1065
1467
|
}
|
|
1066
1468
|
function cfHanaHistoryDirectory(saptoolsRoot) {
|
|
1067
|
-
return
|
|
1469
|
+
return join3(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
|
|
1068
1470
|
}
|
|
1069
1471
|
function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
|
|
1070
|
-
return
|
|
1472
|
+
return join3(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
|
|
1071
1473
|
}
|
|
1072
1474
|
async function pruneSqlHistory(now, saptoolsRoot) {
|
|
1073
1475
|
const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
|
|
@@ -1083,7 +1485,7 @@ async function pruneSqlHistory(now, saptoolsRoot) {
|
|
|
1083
1485
|
const cutoffKey = retentionCutoffKey(now);
|
|
1084
1486
|
await Promise.all(
|
|
1085
1487
|
files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
|
|
1086
|
-
await
|
|
1488
|
+
await rm2(join3(historyDir, file), { force: true });
|
|
1087
1489
|
})
|
|
1088
1490
|
);
|
|
1089
1491
|
}
|
|
@@ -1654,7 +2056,8 @@ var HanaClient = class _HanaClient {
|
|
|
1654
2056
|
return await writeSqlBackup({
|
|
1655
2057
|
operation: plan.operation,
|
|
1656
2058
|
statementSql: plan.statementSql,
|
|
1657
|
-
result
|
|
2059
|
+
result,
|
|
2060
|
+
selector: this.info.selector
|
|
1658
2061
|
});
|
|
1659
2062
|
});
|
|
1660
2063
|
}
|
|
@@ -2065,19 +2468,19 @@ function searchResultSession(session, searchTerm, options) {
|
|
|
2065
2468
|
|
|
2066
2469
|
// src/result-store.ts
|
|
2067
2470
|
import { randomBytes } from "crypto";
|
|
2068
|
-
import { mkdir as mkdir3, readFile, readdir as readdir2, rename, rm as
|
|
2471
|
+
import { mkdir as mkdir3, readFile, readdir as readdir2, rename, rm as rm3, writeFile as writeFile2 } from "fs/promises";
|
|
2069
2472
|
import { homedir as homedir3 } from "os";
|
|
2070
|
-
import { join as
|
|
2473
|
+
import { join as join4 } from "path";
|
|
2071
2474
|
var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
|
|
2072
2475
|
var MANIFEST_FILE_NAME = "manifest.json";
|
|
2073
2476
|
function resultsRoot(saptoolsRoot) {
|
|
2074
|
-
return
|
|
2477
|
+
return join4(saptoolsRoot ?? join4(homedir3(), ".saptools"), "cf-hana", "results");
|
|
2075
2478
|
}
|
|
2076
2479
|
function sessionDirectory(ref, saptoolsRoot) {
|
|
2077
|
-
return
|
|
2480
|
+
return join4(resultsRoot(saptoolsRoot), ref);
|
|
2078
2481
|
}
|
|
2079
2482
|
function manifestPath(ref, saptoolsRoot) {
|
|
2080
|
-
return
|
|
2483
|
+
return join4(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
|
|
2081
2484
|
}
|
|
2082
2485
|
function encodeCell(value) {
|
|
2083
2486
|
if (value === null) {
|
|
@@ -2176,11 +2579,11 @@ function toStoredSession(input, ref, now) {
|
|
|
2176
2579
|
result: encodeResult(input.result)
|
|
2177
2580
|
};
|
|
2178
2581
|
}
|
|
2179
|
-
function
|
|
2582
|
+
function isRecord2(value) {
|
|
2180
2583
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2181
2584
|
}
|
|
2182
2585
|
function isStoredSession(value) {
|
|
2183
|
-
if (!
|
|
2586
|
+
if (!isRecord2(value) || !isRecord2(value["result"]) || !isRecord2(value["info"])) {
|
|
2184
2587
|
return false;
|
|
2185
2588
|
}
|
|
2186
2589
|
const result = value["result"];
|
|
@@ -2229,16 +2632,16 @@ async function createResultSession(input, options = {}) {
|
|
|
2229
2632
|
const finalDirectory = sessionDirectory(ref, options.saptoolsRoot);
|
|
2230
2633
|
const tempDirectory = `${finalDirectory}.tmp-${process.pid.toString()}`;
|
|
2231
2634
|
await mkdir3(root, { recursive: true, mode: 448 });
|
|
2232
|
-
await
|
|
2635
|
+
await rm3(tempDirectory, { recursive: true, force: true });
|
|
2233
2636
|
await mkdir3(tempDirectory, { mode: 448 });
|
|
2234
2637
|
try {
|
|
2235
|
-
await writeFile2(
|
|
2638
|
+
await writeFile2(join4(tempDirectory, MANIFEST_FILE_NAME), serialized, {
|
|
2236
2639
|
encoding: "utf8",
|
|
2237
2640
|
mode: 384
|
|
2238
2641
|
});
|
|
2239
2642
|
await rename(tempDirectory, finalDirectory);
|
|
2240
2643
|
} catch (error) {
|
|
2241
|
-
await
|
|
2644
|
+
await rm3(tempDirectory, { recursive: true, force: true });
|
|
2242
2645
|
throw error;
|
|
2243
2646
|
}
|
|
2244
2647
|
return toSession(stored, options.saptoolsRoot);
|
|
@@ -2274,7 +2677,7 @@ async function pruneResultSessions(options = {}) {
|
|
|
2274
2677
|
for (const ref of refs) {
|
|
2275
2678
|
const stored = await readStoredSession(manifestPath(ref, options.saptoolsRoot));
|
|
2276
2679
|
if (stored === void 0 || Date.parse(stored.expiresAt) <= now) {
|
|
2277
|
-
await
|
|
2680
|
+
await rm3(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
2278
2681
|
removed += 1;
|
|
2279
2682
|
}
|
|
2280
2683
|
}
|
|
@@ -2284,7 +2687,7 @@ async function clearResultSessions(options = {}) {
|
|
|
2284
2687
|
const refs = await listSessionRefs(options.saptoolsRoot);
|
|
2285
2688
|
await Promise.all(
|
|
2286
2689
|
refs.map(async (ref) => {
|
|
2287
|
-
await
|
|
2690
|
+
await rm3(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
2288
2691
|
})
|
|
2289
2692
|
);
|
|
2290
2693
|
return refs.length;
|
|
@@ -2544,6 +2947,25 @@ function parseQualifiedName(value) {
|
|
|
2544
2947
|
}
|
|
2545
2948
|
return { schema: value.slice(0, dot), table: value.slice(dot + 1) };
|
|
2546
2949
|
}
|
|
2950
|
+
async function resolveSelectorArgument(selector) {
|
|
2951
|
+
if (selector.includes("/")) {
|
|
2952
|
+
return selector;
|
|
2953
|
+
}
|
|
2954
|
+
const current = await readCurrentCfTarget().catch((error) => {
|
|
2955
|
+
throw new CfHanaError(
|
|
2956
|
+
"CONFIG",
|
|
2957
|
+
"No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector.",
|
|
2958
|
+
{ cause: error }
|
|
2959
|
+
);
|
|
2960
|
+
});
|
|
2961
|
+
if (current === void 0) {
|
|
2962
|
+
throw new CfHanaError(
|
|
2963
|
+
"CONFIG",
|
|
2964
|
+
"No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
|
|
2965
|
+
);
|
|
2966
|
+
}
|
|
2967
|
+
return formatCurrentCfAppSelector(current, selector);
|
|
2968
|
+
}
|
|
2547
2969
|
function toConnectOptions(opts) {
|
|
2548
2970
|
assertPositiveOption("--limit", opts.limit);
|
|
2549
2971
|
assertPositiveOption("--timeout", opts.timeout);
|
|
@@ -2611,7 +3033,7 @@ async function runQuery(selector, sql, command) {
|
|
|
2611
3033
|
const opts = command.opts();
|
|
2612
3034
|
assertQueryOptions(sql, opts);
|
|
2613
3035
|
const cellLimit = resolveCellLimit(opts.cellLimit);
|
|
2614
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
3036
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
2615
3037
|
try {
|
|
2616
3038
|
const params = opts.param ?? [];
|
|
2617
3039
|
const backup = await client.backupWriteStatement(sql, params);
|
|
@@ -2652,7 +3074,7 @@ async function runQuery(selector, sql, command) {
|
|
|
2652
3074
|
}
|
|
2653
3075
|
async function runTables(selector, schema, command) {
|
|
2654
3076
|
const opts = command.opts();
|
|
2655
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
3077
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
2656
3078
|
try {
|
|
2657
3079
|
const tables = await client.listTables(schema ?? client.info.schema);
|
|
2658
3080
|
const rows = tables.map((table) => ({
|
|
@@ -2668,7 +3090,7 @@ async function runTables(selector, schema, command) {
|
|
|
2668
3090
|
async function runColumns(selector, target, command) {
|
|
2669
3091
|
const opts = command.opts();
|
|
2670
3092
|
const { schema, table } = parseQualifiedName(target);
|
|
2671
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
3093
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
2672
3094
|
try {
|
|
2673
3095
|
const columns = await client.listColumns(schema, table);
|
|
2674
3096
|
const rows = columns.map((column) => ({
|
|
@@ -2686,7 +3108,7 @@ async function runColumns(selector, target, command) {
|
|
|
2686
3108
|
async function runCount(selector, target, command) {
|
|
2687
3109
|
const opts = command.opts();
|
|
2688
3110
|
const { schema, table } = parseQualifiedName(target);
|
|
2689
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
3111
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
2690
3112
|
try {
|
|
2691
3113
|
const total = await client.count({ schema, table });
|
|
2692
3114
|
print2(String(total));
|
|
@@ -2696,7 +3118,7 @@ async function runCount(selector, target, command) {
|
|
|
2696
3118
|
}
|
|
2697
3119
|
async function runPing(selector, command) {
|
|
2698
3120
|
const opts = command.opts();
|
|
2699
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
3121
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
2700
3122
|
try {
|
|
2701
3123
|
const started = Date.now();
|
|
2702
3124
|
await client.query("SELECT 1 FROM DUMMY");
|
|
@@ -2709,7 +3131,7 @@ async function runPing(selector, command) {
|
|
|
2709
3131
|
}
|
|
2710
3132
|
async function runInfo(selector, command) {
|
|
2711
3133
|
const opts = command.opts();
|
|
2712
|
-
const client = await connect(selector, toConnectOptions(opts));
|
|
3134
|
+
const client = await connect(await resolveSelectorArgument(selector), toConnectOptions(opts));
|
|
2713
3135
|
try {
|
|
2714
3136
|
print2(formatInfo(client.info));
|
|
2715
3137
|
} finally {
|