@saptools/cf-hana 0.2.1 → 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 +464 -62
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +452 -49
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { formatCurrentCfAppSelector, readCurrentCfTarget } from "@saptools/cf-sync";
|
|
5
4
|
import { Command } from "commander";
|
|
6
5
|
|
|
7
6
|
// src/backup.ts
|
|
8
|
-
import { createHash, randomUUID } from "crypto";
|
|
9
7
|
import { mkdir, writeFile } from "fs/promises";
|
|
10
8
|
import { homedir } from "os";
|
|
11
9
|
import { join } from "path";
|
|
@@ -375,6 +373,28 @@ function findTopLevelKeyword(sql, keyword, startIndex = 0) {
|
|
|
375
373
|
}
|
|
376
374
|
return void 0;
|
|
377
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
|
+
}
|
|
378
398
|
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
379
399
|
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
380
400
|
}
|
|
@@ -412,6 +432,24 @@ function buildUpdateBackupPlan(statementSql, params) {
|
|
|
412
432
|
params
|
|
413
433
|
);
|
|
414
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
|
+
}
|
|
415
453
|
function buildDeleteBackupPlan(statementSql, params) {
|
|
416
454
|
const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
|
|
417
455
|
const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
|
|
@@ -431,8 +469,18 @@ function buildDeleteBackupPlan(statementSql, params) {
|
|
|
431
469
|
function backupTimestamp(now) {
|
|
432
470
|
return now.toISOString().replace(/:/g, "").replace(".", "");
|
|
433
471
|
}
|
|
434
|
-
function
|
|
435
|
-
|
|
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("-");
|
|
436
484
|
}
|
|
437
485
|
function cfHanaBackupRoot(saptoolsRoot) {
|
|
438
486
|
return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
|
|
@@ -440,33 +488,49 @@ function cfHanaBackupRoot(saptoolsRoot) {
|
|
|
440
488
|
function buildWriteBackupPlan(sql, params = []) {
|
|
441
489
|
const statementSql = trimStatementSql(sql);
|
|
442
490
|
const keyword = firstKeyword(statementSql);
|
|
443
|
-
if (keyword !== "UPDATE" && keyword !== "DELETE") {
|
|
491
|
+
if (keyword !== "UPDATE" && keyword !== "UPSERT" && keyword !== "DELETE") {
|
|
444
492
|
return void 0;
|
|
445
493
|
}
|
|
446
494
|
assertParamArity(statementSql, params);
|
|
447
495
|
if (keyword === "UPDATE") {
|
|
448
496
|
return buildUpdateBackupPlan(statementSql, params);
|
|
449
497
|
}
|
|
498
|
+
if (keyword === "UPSERT") {
|
|
499
|
+
return buildUpsertBackupPlan(statementSql, params);
|
|
500
|
+
}
|
|
450
501
|
return buildDeleteBackupPlan(statementSql, params);
|
|
451
502
|
}
|
|
452
503
|
async function writeSqlBackup(input, options = {}) {
|
|
453
504
|
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
454
|
-
const directory = join(
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
);
|
|
458
|
-
const
|
|
459
|
-
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
|
+
};
|
|
460
518
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
461
519
|
await Promise.all([
|
|
462
520
|
writeFile(statementPath, `${input.statementSql}
|
|
463
521
|
`, { encoding: "utf8", mode: 384 }),
|
|
464
|
-
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
|
+
})
|
|
465
528
|
]);
|
|
466
529
|
return {
|
|
467
530
|
directory,
|
|
468
531
|
statementPath,
|
|
469
532
|
backupPath,
|
|
533
|
+
metadataPath,
|
|
470
534
|
rowCount: input.result.rowCount
|
|
471
535
|
};
|
|
472
536
|
}
|
|
@@ -620,44 +684,381 @@ function readSapCredentials(overrides) {
|
|
|
620
684
|
return { email, password };
|
|
621
685
|
}
|
|
622
686
|
|
|
623
|
-
// src/
|
|
624
|
-
import {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
+
}
|
|
635
782
|
}
|
|
636
783
|
}
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
796
|
+
});
|
|
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
|
|
640
813
|
});
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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.`
|
|
644
871
|
);
|
|
645
872
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
+
};
|
|
650
942
|
});
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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.`);
|
|
655
1056
|
}
|
|
656
1057
|
return {
|
|
657
|
-
selector:
|
|
658
|
-
appName:
|
|
659
|
-
bindings
|
|
660
|
-
source
|
|
1058
|
+
selector: target.selector,
|
|
1059
|
+
appName: target.appName,
|
|
1060
|
+
bindings,
|
|
1061
|
+
source
|
|
661
1062
|
};
|
|
662
1063
|
}
|
|
663
1064
|
function selectBinding(bindings, selector) {
|
|
@@ -707,7 +1108,7 @@ function toConnectionTarget(binding, role) {
|
|
|
707
1108
|
password: role === "hdi" ? credentials.hdiPassword : credentials.password,
|
|
708
1109
|
schema: credentials.schema,
|
|
709
1110
|
certificate: credentials.certificate,
|
|
710
|
-
databaseId: credentials.databaseId
|
|
1111
|
+
databaseId: credentials.databaseId ?? ""
|
|
711
1112
|
};
|
|
712
1113
|
}
|
|
713
1114
|
|
|
@@ -1034,16 +1435,16 @@ function createDriver(name) {
|
|
|
1034
1435
|
}
|
|
1035
1436
|
|
|
1036
1437
|
// src/history.ts
|
|
1037
|
-
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";
|
|
1038
1439
|
import { homedir as homedir2 } from "os";
|
|
1039
|
-
import { join as
|
|
1440
|
+
import { join as join3 } from "path";
|
|
1040
1441
|
var SAPTOOLS_DIR_NAME2 = ".saptools";
|
|
1041
1442
|
var CF_HANA_DIR_NAME2 = "cf-hana";
|
|
1042
1443
|
var HISTORIES_DIR_NAME = "histories";
|
|
1043
1444
|
var HISTORY_RETENTION_DAYS = 5;
|
|
1044
1445
|
var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
1045
1446
|
function defaultSaptoolsRoot2() {
|
|
1046
|
-
return
|
|
1447
|
+
return join3(homedir2(), SAPTOOLS_DIR_NAME2);
|
|
1047
1448
|
}
|
|
1048
1449
|
function padDatePart(value) {
|
|
1049
1450
|
return value.toString().padStart(2, "0");
|
|
@@ -1065,10 +1466,10 @@ function resolveSaptoolsRoot(root) {
|
|
|
1065
1466
|
return root ?? defaultSaptoolsRoot2();
|
|
1066
1467
|
}
|
|
1067
1468
|
function cfHanaHistoryDirectory(saptoolsRoot) {
|
|
1068
|
-
return
|
|
1469
|
+
return join3(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
|
|
1069
1470
|
}
|
|
1070
1471
|
function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
|
|
1071
|
-
return
|
|
1472
|
+
return join3(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
|
|
1072
1473
|
}
|
|
1073
1474
|
async function pruneSqlHistory(now, saptoolsRoot) {
|
|
1074
1475
|
const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
|
|
@@ -1084,7 +1485,7 @@ async function pruneSqlHistory(now, saptoolsRoot) {
|
|
|
1084
1485
|
const cutoffKey = retentionCutoffKey(now);
|
|
1085
1486
|
await Promise.all(
|
|
1086
1487
|
files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
|
|
1087
|
-
await
|
|
1488
|
+
await rm2(join3(historyDir, file), { force: true });
|
|
1088
1489
|
})
|
|
1089
1490
|
);
|
|
1090
1491
|
}
|
|
@@ -1655,7 +2056,8 @@ var HanaClient = class _HanaClient {
|
|
|
1655
2056
|
return await writeSqlBackup({
|
|
1656
2057
|
operation: plan.operation,
|
|
1657
2058
|
statementSql: plan.statementSql,
|
|
1658
|
-
result
|
|
2059
|
+
result,
|
|
2060
|
+
selector: this.info.selector
|
|
1659
2061
|
});
|
|
1660
2062
|
});
|
|
1661
2063
|
}
|
|
@@ -2066,19 +2468,19 @@ function searchResultSession(session, searchTerm, options) {
|
|
|
2066
2468
|
|
|
2067
2469
|
// src/result-store.ts
|
|
2068
2470
|
import { randomBytes } from "crypto";
|
|
2069
|
-
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";
|
|
2070
2472
|
import { homedir as homedir3 } from "os";
|
|
2071
|
-
import { join as
|
|
2473
|
+
import { join as join4 } from "path";
|
|
2072
2474
|
var RESULT_REF_PATTERN = /^q[0-9a-f]{8}$/;
|
|
2073
2475
|
var MANIFEST_FILE_NAME = "manifest.json";
|
|
2074
2476
|
function resultsRoot(saptoolsRoot) {
|
|
2075
|
-
return
|
|
2477
|
+
return join4(saptoolsRoot ?? join4(homedir3(), ".saptools"), "cf-hana", "results");
|
|
2076
2478
|
}
|
|
2077
2479
|
function sessionDirectory(ref, saptoolsRoot) {
|
|
2078
|
-
return
|
|
2480
|
+
return join4(resultsRoot(saptoolsRoot), ref);
|
|
2079
2481
|
}
|
|
2080
2482
|
function manifestPath(ref, saptoolsRoot) {
|
|
2081
|
-
return
|
|
2483
|
+
return join4(sessionDirectory(ref, saptoolsRoot), MANIFEST_FILE_NAME);
|
|
2082
2484
|
}
|
|
2083
2485
|
function encodeCell(value) {
|
|
2084
2486
|
if (value === null) {
|
|
@@ -2177,11 +2579,11 @@ function toStoredSession(input, ref, now) {
|
|
|
2177
2579
|
result: encodeResult(input.result)
|
|
2178
2580
|
};
|
|
2179
2581
|
}
|
|
2180
|
-
function
|
|
2582
|
+
function isRecord2(value) {
|
|
2181
2583
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2182
2584
|
}
|
|
2183
2585
|
function isStoredSession(value) {
|
|
2184
|
-
if (!
|
|
2586
|
+
if (!isRecord2(value) || !isRecord2(value["result"]) || !isRecord2(value["info"])) {
|
|
2185
2587
|
return false;
|
|
2186
2588
|
}
|
|
2187
2589
|
const result = value["result"];
|
|
@@ -2230,16 +2632,16 @@ async function createResultSession(input, options = {}) {
|
|
|
2230
2632
|
const finalDirectory = sessionDirectory(ref, options.saptoolsRoot);
|
|
2231
2633
|
const tempDirectory = `${finalDirectory}.tmp-${process.pid.toString()}`;
|
|
2232
2634
|
await mkdir3(root, { recursive: true, mode: 448 });
|
|
2233
|
-
await
|
|
2635
|
+
await rm3(tempDirectory, { recursive: true, force: true });
|
|
2234
2636
|
await mkdir3(tempDirectory, { mode: 448 });
|
|
2235
2637
|
try {
|
|
2236
|
-
await writeFile2(
|
|
2638
|
+
await writeFile2(join4(tempDirectory, MANIFEST_FILE_NAME), serialized, {
|
|
2237
2639
|
encoding: "utf8",
|
|
2238
2640
|
mode: 384
|
|
2239
2641
|
});
|
|
2240
2642
|
await rename(tempDirectory, finalDirectory);
|
|
2241
2643
|
} catch (error) {
|
|
2242
|
-
await
|
|
2644
|
+
await rm3(tempDirectory, { recursive: true, force: true });
|
|
2243
2645
|
throw error;
|
|
2244
2646
|
}
|
|
2245
2647
|
return toSession(stored, options.saptoolsRoot);
|
|
@@ -2275,7 +2677,7 @@ async function pruneResultSessions(options = {}) {
|
|
|
2275
2677
|
for (const ref of refs) {
|
|
2276
2678
|
const stored = await readStoredSession(manifestPath(ref, options.saptoolsRoot));
|
|
2277
2679
|
if (stored === void 0 || Date.parse(stored.expiresAt) <= now) {
|
|
2278
|
-
await
|
|
2680
|
+
await rm3(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
2279
2681
|
removed += 1;
|
|
2280
2682
|
}
|
|
2281
2683
|
}
|
|
@@ -2285,7 +2687,7 @@ async function clearResultSessions(options = {}) {
|
|
|
2285
2687
|
const refs = await listSessionRefs(options.saptoolsRoot);
|
|
2286
2688
|
await Promise.all(
|
|
2287
2689
|
refs.map(async (ref) => {
|
|
2288
|
-
await
|
|
2690
|
+
await rm3(sessionDirectory(ref, options.saptoolsRoot), { recursive: true, force: true });
|
|
2289
2691
|
})
|
|
2290
2692
|
);
|
|
2291
2693
|
return refs.length;
|