@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/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ type StatementKind = "select" | "dml" | "ddl" | "unknown";
|
|
|
7
7
|
/** Which HANA user from a service binding to authenticate as. */
|
|
8
8
|
type DbUserRole = "runtime" | "hdi";
|
|
9
9
|
/** Where resolved credentials came from. */
|
|
10
|
-
type CredentialSource = "
|
|
10
|
+
type CredentialSource = "live";
|
|
11
11
|
/** Output rendering for CLI results. */
|
|
12
12
|
type OutputFormat = "table" | "json" | "csv";
|
|
13
13
|
interface QueryResultColumn {
|
|
@@ -96,7 +96,7 @@ interface HanaClientInfo {
|
|
|
96
96
|
readonly credentialSource: CredentialSource;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
type WriteBackupOperation = "update" | "delete";
|
|
99
|
+
type WriteBackupOperation = "update" | "upsert" | "delete";
|
|
100
100
|
interface WriteBackupPlan {
|
|
101
101
|
readonly operation: WriteBackupOperation;
|
|
102
102
|
readonly statementSql: string;
|
|
@@ -107,6 +107,7 @@ interface SqlBackupWriteInput {
|
|
|
107
107
|
readonly operation: WriteBackupOperation;
|
|
108
108
|
readonly statementSql: string;
|
|
109
109
|
readonly result: QueryResult;
|
|
110
|
+
readonly selector?: string;
|
|
110
111
|
}
|
|
111
112
|
interface SqlBackupWriteOptions {
|
|
112
113
|
readonly now?: Date;
|
|
@@ -116,6 +117,7 @@ interface SqlBackupRecord {
|
|
|
116
117
|
readonly directory: string;
|
|
117
118
|
readonly statementPath: string;
|
|
118
119
|
readonly backupPath: string;
|
|
120
|
+
readonly metadataPath: string;
|
|
119
121
|
readonly rowCount: number;
|
|
120
122
|
}
|
|
121
123
|
declare function cfHanaBackupRoot(saptoolsRoot?: string): string;
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/backup.ts
|
|
4
|
-
import { createHash, randomUUID } from "crypto";
|
|
5
4
|
import { mkdir, writeFile } from "fs/promises";
|
|
6
5
|
import { homedir } from "os";
|
|
7
6
|
import { join } from "path";
|
|
@@ -302,6 +301,28 @@ function findTopLevelKeyword(sql, keyword, startIndex = 0) {
|
|
|
302
301
|
}
|
|
303
302
|
return void 0;
|
|
304
303
|
}
|
|
304
|
+
function findTopLevelChar(sql, target, startIndex, endIndex) {
|
|
305
|
+
let index = startIndex;
|
|
306
|
+
let depth = 0;
|
|
307
|
+
while (index < endIndex) {
|
|
308
|
+
const skipped = skipNonCode(sql, index);
|
|
309
|
+
if (skipped !== void 0) {
|
|
310
|
+
index = skipped;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const char = sql[index];
|
|
314
|
+
if (char === target && depth === 0) {
|
|
315
|
+
return index;
|
|
316
|
+
}
|
|
317
|
+
if (char === "(") {
|
|
318
|
+
depth += 1;
|
|
319
|
+
} else if (char === ")" && depth > 0) {
|
|
320
|
+
depth -= 1;
|
|
321
|
+
}
|
|
322
|
+
index += 1;
|
|
323
|
+
}
|
|
324
|
+
return void 0;
|
|
325
|
+
}
|
|
305
326
|
function selectParamsAfterWhere(statementSql, whereIndex, params) {
|
|
306
327
|
return params.slice(countPlaceholders(statementSql.slice(0, whereIndex)));
|
|
307
328
|
}
|
|
@@ -339,6 +360,24 @@ function buildUpdateBackupPlan(statementSql, params) {
|
|
|
339
360
|
params
|
|
340
361
|
);
|
|
341
362
|
}
|
|
363
|
+
function buildUpsertBackupPlan(statementSql, params) {
|
|
364
|
+
const upsertIndex = findTopLevelKeyword(statementSql, "UPSERT");
|
|
365
|
+
const valuesIndex = upsertIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "VALUES", upsertIndex + "UPSERT".length);
|
|
366
|
+
if (upsertIndex === void 0 || valuesIndex === void 0) {
|
|
367
|
+
throw new QueryError("UPSERT backup requires UPSERT <target> VALUES syntax");
|
|
368
|
+
}
|
|
369
|
+
const targetStart = upsertIndex + "UPSERT".length;
|
|
370
|
+
const columnListIndex = findTopLevelChar(statementSql, "(", targetStart, valuesIndex);
|
|
371
|
+
const targetEnd = columnListIndex ?? valuesIndex;
|
|
372
|
+
const whereIndex = findTopLevelKeyword(statementSql, "WHERE", valuesIndex + "VALUES".length);
|
|
373
|
+
return buildSelectPlan(
|
|
374
|
+
"upsert",
|
|
375
|
+
statementSql,
|
|
376
|
+
statementSql.slice(targetStart, targetEnd),
|
|
377
|
+
whereIndex,
|
|
378
|
+
params
|
|
379
|
+
);
|
|
380
|
+
}
|
|
342
381
|
function buildDeleteBackupPlan(statementSql, params) {
|
|
343
382
|
const deleteIndex = findTopLevelKeyword(statementSql, "DELETE");
|
|
344
383
|
const fromIndex = deleteIndex === void 0 ? void 0 : findTopLevelKeyword(statementSql, "FROM", deleteIndex + "DELETE".length);
|
|
@@ -358,8 +397,18 @@ function buildDeleteBackupPlan(statementSql, params) {
|
|
|
358
397
|
function backupTimestamp(now) {
|
|
359
398
|
return now.toISOString().replace(/:/g, "").replace(".", "");
|
|
360
399
|
}
|
|
361
|
-
function
|
|
362
|
-
|
|
400
|
+
function backupMonth(now) {
|
|
401
|
+
const year = String(now.getUTCFullYear());
|
|
402
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
403
|
+
return `${year}${month}`;
|
|
404
|
+
}
|
|
405
|
+
function sanitizePathPart(value) {
|
|
406
|
+
const trimmed = value?.trim() ?? "";
|
|
407
|
+
const normalized = trimmed.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
408
|
+
return normalized.length > 0 ? normalized : "unknown-target";
|
|
409
|
+
}
|
|
410
|
+
function backupBaseName(input, now) {
|
|
411
|
+
return [sanitizePathPart(input.selector), input.operation, backupTimestamp(now)].join("-");
|
|
363
412
|
}
|
|
364
413
|
function cfHanaBackupRoot(saptoolsRoot) {
|
|
365
414
|
return join(saptoolsRoot ?? defaultSaptoolsRoot(), CF_HANA_DIR_NAME, BACKUPS_DIR_NAME);
|
|
@@ -367,33 +416,49 @@ function cfHanaBackupRoot(saptoolsRoot) {
|
|
|
367
416
|
function buildWriteBackupPlan(sql, params = []) {
|
|
368
417
|
const statementSql = trimStatementSql(sql);
|
|
369
418
|
const keyword = firstKeyword(statementSql);
|
|
370
|
-
if (keyword !== "UPDATE" && keyword !== "DELETE") {
|
|
419
|
+
if (keyword !== "UPDATE" && keyword !== "UPSERT" && keyword !== "DELETE") {
|
|
371
420
|
return void 0;
|
|
372
421
|
}
|
|
373
422
|
assertParamArity(statementSql, params);
|
|
374
423
|
if (keyword === "UPDATE") {
|
|
375
424
|
return buildUpdateBackupPlan(statementSql, params);
|
|
376
425
|
}
|
|
426
|
+
if (keyword === "UPSERT") {
|
|
427
|
+
return buildUpsertBackupPlan(statementSql, params);
|
|
428
|
+
}
|
|
377
429
|
return buildDeleteBackupPlan(statementSql, params);
|
|
378
430
|
}
|
|
379
431
|
async function writeSqlBackup(input, options = {}) {
|
|
380
432
|
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
381
|
-
const directory = join(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
);
|
|
385
|
-
const
|
|
386
|
-
const
|
|
433
|
+
const directory = join(cfHanaBackupRoot(options.saptoolsRoot), backupMonth(now));
|
|
434
|
+
const baseName = backupBaseName(input, now);
|
|
435
|
+
const statementPath = join(directory, `${baseName}.statement.sql`);
|
|
436
|
+
const backupPath = join(directory, `${baseName}.sql`);
|
|
437
|
+
const metadataPath = join(directory, `${baseName}.json`);
|
|
438
|
+
const metadata = {
|
|
439
|
+
selector: input.selector ?? null,
|
|
440
|
+
operation: input.operation,
|
|
441
|
+
statementPath,
|
|
442
|
+
backupPath,
|
|
443
|
+
rowCount: input.result.rowCount,
|
|
444
|
+
createdAt: now.toISOString()
|
|
445
|
+
};
|
|
387
446
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
388
447
|
await Promise.all([
|
|
389
448
|
writeFile(statementPath, `${input.statementSql}
|
|
390
449
|
`, { encoding: "utf8", mode: 384 }),
|
|
391
|
-
writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 })
|
|
450
|
+
writeFile(backupPath, formatCsv(input.result), { encoding: "utf8", mode: 384 }),
|
|
451
|
+
writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}
|
|
452
|
+
`, {
|
|
453
|
+
encoding: "utf8",
|
|
454
|
+
mode: 384
|
|
455
|
+
})
|
|
392
456
|
]);
|
|
393
457
|
return {
|
|
394
458
|
directory,
|
|
395
459
|
statementPath,
|
|
396
460
|
backupPath,
|
|
461
|
+
metadataPath,
|
|
397
462
|
rowCount: input.result.rowCount
|
|
398
463
|
};
|
|
399
464
|
}
|
|
@@ -542,44 +607,381 @@ function readSapCredentials(overrides) {
|
|
|
542
607
|
return { email, password };
|
|
543
608
|
}
|
|
544
609
|
|
|
545
|
-
// src/
|
|
546
|
-
import {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
610
|
+
// src/cf.ts
|
|
611
|
+
import { execFile } from "child_process";
|
|
612
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
613
|
+
import { tmpdir } from "os";
|
|
614
|
+
import { join as join2 } from "path";
|
|
615
|
+
import { promisify } from "util";
|
|
616
|
+
var execFileAsync = promisify(execFile);
|
|
617
|
+
var MAX_BUFFER = 16 * 1024 * 1024;
|
|
618
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
619
|
+
var CF_RETRY_ATTEMPTS = 3;
|
|
620
|
+
var CF_RETRY_BASE_DELAY_MS = 120;
|
|
621
|
+
var REGION_API_MAP = {
|
|
622
|
+
ae01: "https://api.cf.ae01.hana.ondemand.com",
|
|
623
|
+
ap01: "https://api.cf.ap01.hana.ondemand.com",
|
|
624
|
+
ap10: "https://api.cf.ap10.hana.ondemand.com",
|
|
625
|
+
ap11: "https://api.cf.ap11.hana.ondemand.com",
|
|
626
|
+
ap12: "https://api.cf.ap12.hana.ondemand.com",
|
|
627
|
+
ap20: "https://api.cf.ap20.hana.ondemand.com",
|
|
628
|
+
ap21: "https://api.cf.ap21.hana.ondemand.com",
|
|
629
|
+
ap30: "https://api.cf.ap30.hana.ondemand.com",
|
|
630
|
+
br10: "https://api.cf.br10.hana.ondemand.com",
|
|
631
|
+
br20: "https://api.cf.br20.hana.ondemand.com",
|
|
632
|
+
br30: "https://api.cf.br30.hana.ondemand.com",
|
|
633
|
+
ca10: "https://api.cf.ca10.hana.ondemand.com",
|
|
634
|
+
ca20: "https://api.cf.ca20.hana.ondemand.com",
|
|
635
|
+
ch20: "https://api.cf.ch20.hana.ondemand.com",
|
|
636
|
+
eu10: "https://api.cf.eu10.hana.ondemand.com",
|
|
637
|
+
eu11: "https://api.cf.eu11.hana.ondemand.com",
|
|
638
|
+
eu12: "https://api.cf.eu12.hana.ondemand.com",
|
|
639
|
+
eu20: "https://api.cf.eu20.hana.ondemand.com",
|
|
640
|
+
eu21: "https://api.cf.eu21.hana.ondemand.com",
|
|
641
|
+
eu30: "https://api.cf.eu30.hana.ondemand.com",
|
|
642
|
+
"eu10-002": "https://api.cf.eu10-002.hana.ondemand.com",
|
|
643
|
+
jp10: "https://api.cf.jp10.hana.ondemand.com",
|
|
644
|
+
jp20: "https://api.cf.jp20.hana.ondemand.com",
|
|
645
|
+
jp30: "https://api.cf.jp30.hana.ondemand.com",
|
|
646
|
+
us10: "https://api.cf.us10.hana.ondemand.com",
|
|
647
|
+
us11: "https://api.cf.us11.hana.ondemand.com",
|
|
648
|
+
us20: "https://api.cf.us20.hana.ondemand.com",
|
|
649
|
+
us21: "https://api.cf.us21.hana.ondemand.com",
|
|
650
|
+
us30: "https://api.cf.us30.hana.ondemand.com",
|
|
651
|
+
in30: "https://api.cf.in30.hana.ondemand.com"
|
|
652
|
+
};
|
|
653
|
+
function getApiEndpointForRegion(regionKey) {
|
|
654
|
+
return REGION_API_MAP[regionKey];
|
|
655
|
+
}
|
|
656
|
+
function getRegionKeyForApi(apiEndpoint) {
|
|
657
|
+
const norm = apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
|
|
658
|
+
for (const [key, endpoint] of Object.entries(REGION_API_MAP)) {
|
|
659
|
+
if (endpoint.toLowerCase() === norm) {
|
|
660
|
+
return key;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return void 0;
|
|
664
|
+
}
|
|
665
|
+
async function withCfSession(work) {
|
|
666
|
+
const cfHome = await mkdtemp(join2(tmpdir(), "saptools-cf-hana-"));
|
|
667
|
+
const ctx = { cfHome };
|
|
668
|
+
try {
|
|
669
|
+
return await work(ctx);
|
|
670
|
+
} finally {
|
|
671
|
+
await rm(cfHome, { recursive: true, force: true });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function resolveCfBin() {
|
|
675
|
+
const raw = process.env["CF_HANA_CF_BIN"] ?? "cf";
|
|
676
|
+
if (/\.(?:c|m)?js$/i.test(raw)) {
|
|
677
|
+
return { bin: process.execPath, argsPrefix: [raw] };
|
|
678
|
+
}
|
|
679
|
+
return { bin: raw, argsPrefix: [] };
|
|
680
|
+
}
|
|
681
|
+
function buildEnv(ctx, overrides = {}) {
|
|
682
|
+
const env = { ...process.env, ...overrides };
|
|
683
|
+
delete env["SAP_EMAIL"];
|
|
684
|
+
delete env["SAP_PASSWORD"];
|
|
685
|
+
env["CF_HOME"] = ctx.cfHome;
|
|
686
|
+
return env;
|
|
687
|
+
}
|
|
688
|
+
async function runCf(args, ctx, overrides = {}) {
|
|
689
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
690
|
+
const env = buildEnv(ctx, overrides);
|
|
691
|
+
let lastErr;
|
|
692
|
+
for (let attempt = 0; attempt < CF_RETRY_ATTEMPTS; attempt++) {
|
|
693
|
+
try {
|
|
694
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, ...args], {
|
|
695
|
+
env,
|
|
696
|
+
maxBuffer: MAX_BUFFER,
|
|
697
|
+
timeout: DEFAULT_TIMEOUT_MS
|
|
698
|
+
});
|
|
699
|
+
return stdout;
|
|
700
|
+
} catch (err) {
|
|
701
|
+
lastErr = err;
|
|
702
|
+
if (attempt < CF_RETRY_ATTEMPTS - 1) {
|
|
703
|
+
await new Promise((r) => setTimeout(r, CF_RETRY_BASE_DELAY_MS * (attempt + 1)));
|
|
704
|
+
}
|
|
557
705
|
}
|
|
558
706
|
}
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
707
|
+
const e = lastErr;
|
|
708
|
+
const detail = e?.stderr ? String(e.stderr) : e?.message ?? "";
|
|
709
|
+
const cmd = args[0] === "auth" ? "cf auth" : `cf ${args.join(" ")}`;
|
|
710
|
+
throw new Error(`${cmd} failed: ${detail}`.trim(), { cause: lastErr });
|
|
711
|
+
}
|
|
712
|
+
async function cfApi(apiEndpoint, ctx) {
|
|
713
|
+
await runCf(["api", apiEndpoint], ctx);
|
|
714
|
+
}
|
|
715
|
+
async function cfAuth(email, password, ctx) {
|
|
716
|
+
await runCf(["auth"], ctx, {
|
|
717
|
+
CF_USERNAME: email,
|
|
718
|
+
CF_PASSWORD: password
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
async function cfTargetSpace(orgName, spaceName, ctx) {
|
|
722
|
+
await runCf(["target", "-o", orgName, "-s", spaceName], ctx);
|
|
723
|
+
}
|
|
724
|
+
async function cfEnv(appName, ctx) {
|
|
725
|
+
return await runCf(["env", appName], ctx);
|
|
726
|
+
}
|
|
727
|
+
async function cfEnvDirect(appName) {
|
|
728
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
729
|
+
const env = { ...process.env };
|
|
730
|
+
delete env["SAP_EMAIL"];
|
|
731
|
+
delete env["SAP_PASSWORD"];
|
|
732
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, "env", appName], {
|
|
733
|
+
env,
|
|
734
|
+
maxBuffer: MAX_BUFFER,
|
|
735
|
+
timeout: DEFAULT_TIMEOUT_MS
|
|
562
736
|
});
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
737
|
+
return stdout;
|
|
738
|
+
}
|
|
739
|
+
async function readCurrentCfTarget() {
|
|
740
|
+
const { bin, argsPrefix } = resolveCfBin();
|
|
741
|
+
const env = { ...process.env };
|
|
742
|
+
delete env["SAP_EMAIL"];
|
|
743
|
+
delete env["SAP_PASSWORD"];
|
|
744
|
+
try {
|
|
745
|
+
const { stdout } = await execFileAsync(bin, [...argsPrefix, "target"], {
|
|
746
|
+
env,
|
|
747
|
+
maxBuffer: MAX_BUFFER,
|
|
748
|
+
timeout: 1e4
|
|
749
|
+
});
|
|
750
|
+
return parseCfTargetOutput(stdout);
|
|
751
|
+
} catch {
|
|
752
|
+
return void 0;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function parseCfTargetOutput(stdout) {
|
|
756
|
+
const fields = parseTargetFields(stdout);
|
|
757
|
+
const api = fields.get("api endpoint");
|
|
758
|
+
const org = fields.get("org");
|
|
759
|
+
const space = fields.get("space");
|
|
760
|
+
if (!api || !org || !space) {
|
|
761
|
+
return void 0;
|
|
762
|
+
}
|
|
763
|
+
const regionKey = getRegionKeyForApi(api);
|
|
764
|
+
return {
|
|
765
|
+
apiEndpoint: api,
|
|
766
|
+
orgName: org,
|
|
767
|
+
spaceName: space,
|
|
768
|
+
...regionKey ? { regionKey } : {}
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function parseTargetFields(stdout) {
|
|
772
|
+
const map = /* @__PURE__ */ new Map();
|
|
773
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
774
|
+
const idx = line.indexOf(":");
|
|
775
|
+
if (idx < 0) {
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
779
|
+
const val = line.slice(idx + 1).trim();
|
|
780
|
+
if (key && val) {
|
|
781
|
+
map.set(key, val);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return map;
|
|
785
|
+
}
|
|
786
|
+
function formatCurrentCfAppSelector(target, appName) {
|
|
787
|
+
const name = appName.trim();
|
|
788
|
+
if (!name) {
|
|
789
|
+
throw new Error("App name is required.");
|
|
790
|
+
}
|
|
791
|
+
if (!target.regionKey) {
|
|
792
|
+
throw new Error(
|
|
793
|
+
`Current CF API endpoint "${target.apiEndpoint}" does not match a known SAP region. Pass a full region/org/space/app selector.`
|
|
566
794
|
);
|
|
567
795
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
796
|
+
return `${target.regionKey}/${target.orgName}/${target.spaceName}/${name}`;
|
|
797
|
+
}
|
|
798
|
+
function extractVcapSection(stdout) {
|
|
799
|
+
const start = stdout.indexOf("VCAP_SERVICES:");
|
|
800
|
+
if (start === -1) {
|
|
801
|
+
throw new Error("VCAP_SERVICES section not found in cf env output");
|
|
802
|
+
}
|
|
803
|
+
const after = stdout.slice(start + "VCAP_SERVICES:".length);
|
|
804
|
+
const end = after.indexOf("VCAP_APPLICATION:");
|
|
805
|
+
const block = end === -1 ? after : after.slice(0, end);
|
|
806
|
+
return block.trim();
|
|
807
|
+
}
|
|
808
|
+
function isRecord(v) {
|
|
809
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
810
|
+
}
|
|
811
|
+
function assertCreds(raw) {
|
|
812
|
+
if (!isRecord(raw)) {
|
|
813
|
+
throw new Error("HANA credentials must be an object");
|
|
814
|
+
}
|
|
815
|
+
const req = ["host", "port", "user", "password", "schema", "hdi_user", "hdi_password", "url", "database_id", "certificate"];
|
|
816
|
+
for (const k of req) {
|
|
817
|
+
if (typeof raw[k] !== "string") {
|
|
818
|
+
throw new Error(`Missing/invalid HANA credential field: "${k}"`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return raw;
|
|
822
|
+
}
|
|
823
|
+
function mapCreds(raw) {
|
|
824
|
+
return {
|
|
825
|
+
host: raw.host,
|
|
826
|
+
port: raw.port,
|
|
827
|
+
user: raw.user,
|
|
828
|
+
password: raw.password,
|
|
829
|
+
schema: raw.schema,
|
|
830
|
+
hdiUser: raw.hdi_user,
|
|
831
|
+
hdiPassword: raw.hdi_password,
|
|
832
|
+
...raw.url ? { url: raw.url } : {},
|
|
833
|
+
databaseId: raw.database_id,
|
|
834
|
+
certificate: raw.certificate
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
function extractHanaBindingsFromCfEnv(stdout) {
|
|
838
|
+
const jsonText = extractVcapSection(stdout);
|
|
839
|
+
let vcap;
|
|
840
|
+
try {
|
|
841
|
+
vcap = JSON.parse(jsonText);
|
|
842
|
+
} catch {
|
|
843
|
+
throw new Error("VCAP_SERVICES is not valid JSON");
|
|
844
|
+
}
|
|
845
|
+
if (!isRecord(vcap)) {
|
|
846
|
+
throw new Error("VCAP_SERVICES must be an object");
|
|
847
|
+
}
|
|
848
|
+
const hana = vcap["hana"];
|
|
849
|
+
if (hana === void 0) {
|
|
850
|
+
return [];
|
|
851
|
+
}
|
|
852
|
+
if (!Array.isArray(hana)) {
|
|
853
|
+
throw new Error("VCAP_SERVICES.hana must be an array when present");
|
|
854
|
+
}
|
|
855
|
+
return hana.map((b) => {
|
|
856
|
+
if (!isRecord(b)) {
|
|
857
|
+
throw new Error("HANA binding must be an object");
|
|
858
|
+
}
|
|
859
|
+
const credsRaw = assertCreds(b["credentials"]);
|
|
860
|
+
const name = typeof b["name"] === "string" ? b["name"] : void 0;
|
|
861
|
+
return {
|
|
862
|
+
...name ? { name } : {},
|
|
863
|
+
credentials: mapCreds(credsRaw)
|
|
864
|
+
};
|
|
572
865
|
});
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
866
|
+
}
|
|
867
|
+
function classifyCfError(stderr = "", stdout = "") {
|
|
868
|
+
const text = `${stderr} ${stdout}`.toLowerCase();
|
|
869
|
+
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")) {
|
|
870
|
+
return { isAuthError: true, reason: "auth/session issue" };
|
|
871
|
+
}
|
|
872
|
+
return { isAuthError: false, reason: "" };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/credentials.ts
|
|
876
|
+
async function resolveAppBindings(rawSelector, options) {
|
|
877
|
+
const selector = rawSelector.trim();
|
|
878
|
+
if (!selector) {
|
|
879
|
+
throw new CfHanaError("CONFIG", "App selector is required");
|
|
880
|
+
}
|
|
881
|
+
let target;
|
|
882
|
+
const isBare = !selector.includes("/");
|
|
883
|
+
if (isBare) {
|
|
884
|
+
const current = await readCurrentCfTarget();
|
|
885
|
+
if (!current) {
|
|
886
|
+
throw new CfHanaError(
|
|
887
|
+
"CONFIG",
|
|
888
|
+
"No current CF target found. Run `cf target -o <org> -s <space>` or pass a full region/org/space/app selector."
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
const displaySelector = current.regionKey ? formatCurrentCfAppSelector(current, selector) : `current/${current.orgName}/${current.spaceName}/${selector}`;
|
|
892
|
+
target = {
|
|
893
|
+
selector: displaySelector,
|
|
894
|
+
apiEndpoint: current.regionKey ? getApiEndpointForRegion(current.regionKey) : void 0,
|
|
895
|
+
orgName: current.orgName,
|
|
896
|
+
spaceName: current.spaceName,
|
|
897
|
+
appName: selector
|
|
898
|
+
};
|
|
899
|
+
} else {
|
|
900
|
+
const parts = selector.split("/").map((p) => p.trim());
|
|
901
|
+
if (parts.length !== 4 || !parts[0] || !parts[1] || !parts[2] || !parts[3]) {
|
|
902
|
+
throw new CfHanaError(
|
|
903
|
+
"CONFIG",
|
|
904
|
+
`Invalid selector "${selector}". Use region/org/space/app or a bare app name.`
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
const [regionKey, orgName, spaceName, appName] = parts;
|
|
908
|
+
const apiEndpoint = getApiEndpointForRegion(regionKey);
|
|
909
|
+
if (!apiEndpoint) {
|
|
910
|
+
throw new CfHanaError(
|
|
911
|
+
"CONFIG",
|
|
912
|
+
`Unknown region key "${regionKey}". Use a known region or the current CF target.`
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
target = { selector, apiEndpoint, orgName, spaceName, appName };
|
|
916
|
+
}
|
|
917
|
+
let bindings;
|
|
918
|
+
const source = "live";
|
|
919
|
+
if (isBare) {
|
|
920
|
+
try {
|
|
921
|
+
const stdout = await cfEnvDirect(target.appName);
|
|
922
|
+
bindings = extractHanaBindingsFromCfEnv(stdout);
|
|
923
|
+
} catch (directError) {
|
|
924
|
+
let stderr = "";
|
|
925
|
+
if (directError && typeof directError === "object") {
|
|
926
|
+
const e = directError;
|
|
927
|
+
stderr = (typeof e.stderr === "string" ? e.stderr : "") || (typeof e.message === "string" ? e.message : "");
|
|
928
|
+
}
|
|
929
|
+
const classified = classifyCfError(stderr);
|
|
930
|
+
if (classified.isAuthError) {
|
|
931
|
+
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
932
|
+
if (!sap) {
|
|
933
|
+
throw new CredentialsNotFoundError(
|
|
934
|
+
`Current CF session problem for bare app "${target.appName}" (${classified.reason}).
|
|
935
|
+
Run "cf login" + "cf target", or provide SAP_EMAIL + SAP_PASSWORD.`,
|
|
936
|
+
{ cause: directError }
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
if (!target.apiEndpoint) {
|
|
940
|
+
throw new CfHanaError("CONFIG", "Cannot determine API endpoint for fallback auth.");
|
|
941
|
+
}
|
|
942
|
+
const api = target.apiEndpoint;
|
|
943
|
+
bindings = await withCfSession(async (ctx) => {
|
|
944
|
+
await cfApi(api, ctx);
|
|
945
|
+
await cfAuth(sap.email, sap.password, ctx);
|
|
946
|
+
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
947
|
+
const stdout = await cfEnv(target.appName, ctx);
|
|
948
|
+
return extractHanaBindingsFromCfEnv(stdout);
|
|
949
|
+
});
|
|
950
|
+
} else {
|
|
951
|
+
throw new CfHanaError(
|
|
952
|
+
"CONFIG",
|
|
953
|
+
`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}` : ""}`,
|
|
954
|
+
{ cause: directError }
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
} else {
|
|
959
|
+
const sap = readSapCredentials({ email: options.email, password: options.password });
|
|
960
|
+
if (!sap) {
|
|
961
|
+
throw new CredentialsNotFoundError(
|
|
962
|
+
`SAP_EMAIL and SAP_PASSWORD are required to fetch HANA bindings for explicit selector "${selector}".`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
if (!target.apiEndpoint) {
|
|
966
|
+
throw new CfHanaError("CONFIG", "Invalid explicit selector.");
|
|
967
|
+
}
|
|
968
|
+
const api = target.apiEndpoint;
|
|
969
|
+
bindings = await withCfSession(async (ctx) => {
|
|
970
|
+
await cfApi(api, ctx);
|
|
971
|
+
await cfAuth(sap.email, sap.password, ctx);
|
|
972
|
+
await cfTargetSpace(target.orgName, target.spaceName, ctx);
|
|
973
|
+
const stdout = await cfEnv(target.appName, ctx);
|
|
974
|
+
return extractHanaBindingsFromCfEnv(stdout);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
if (bindings.length === 0) {
|
|
978
|
+
throw new CredentialsNotFoundError(`App "${target.selector}" has no HANA service binding.`);
|
|
577
979
|
}
|
|
578
980
|
return {
|
|
579
|
-
selector:
|
|
580
|
-
appName:
|
|
581
|
-
bindings
|
|
582
|
-
source
|
|
981
|
+
selector: target.selector,
|
|
982
|
+
appName: target.appName,
|
|
983
|
+
bindings,
|
|
984
|
+
source
|
|
583
985
|
};
|
|
584
986
|
}
|
|
585
987
|
function selectBinding(bindings, selector) {
|
|
@@ -629,7 +1031,7 @@ function toConnectionTarget(binding, role) {
|
|
|
629
1031
|
password: role === "hdi" ? credentials.hdiPassword : credentials.password,
|
|
630
1032
|
schema: credentials.schema,
|
|
631
1033
|
certificate: credentials.certificate,
|
|
632
|
-
databaseId: credentials.databaseId
|
|
1034
|
+
databaseId: credentials.databaseId ?? ""
|
|
633
1035
|
};
|
|
634
1036
|
}
|
|
635
1037
|
|
|
@@ -956,16 +1358,16 @@ function createDriver(name) {
|
|
|
956
1358
|
}
|
|
957
1359
|
|
|
958
1360
|
// src/history.ts
|
|
959
|
-
import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm } from "fs/promises";
|
|
1361
|
+
import { appendFile as appendFile2, mkdir as mkdir2, readdir, rm as rm2 } from "fs/promises";
|
|
960
1362
|
import { homedir as homedir2 } from "os";
|
|
961
|
-
import { join as
|
|
1363
|
+
import { join as join3 } from "path";
|
|
962
1364
|
var SAPTOOLS_DIR_NAME2 = ".saptools";
|
|
963
1365
|
var CF_HANA_DIR_NAME2 = "cf-hana";
|
|
964
1366
|
var HISTORIES_DIR_NAME = "histories";
|
|
965
1367
|
var HISTORY_RETENTION_DAYS = 5;
|
|
966
1368
|
var HISTORY_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
967
1369
|
function defaultSaptoolsRoot2() {
|
|
968
|
-
return
|
|
1370
|
+
return join3(homedir2(), SAPTOOLS_DIR_NAME2);
|
|
969
1371
|
}
|
|
970
1372
|
function padDatePart(value) {
|
|
971
1373
|
return value.toString().padStart(2, "0");
|
|
@@ -987,10 +1389,10 @@ function resolveSaptoolsRoot(root) {
|
|
|
987
1389
|
return root ?? defaultSaptoolsRoot2();
|
|
988
1390
|
}
|
|
989
1391
|
function cfHanaHistoryDirectory(saptoolsRoot) {
|
|
990
|
-
return
|
|
1392
|
+
return join3(resolveSaptoolsRoot(saptoolsRoot), CF_HANA_DIR_NAME2, HISTORIES_DIR_NAME);
|
|
991
1393
|
}
|
|
992
1394
|
function sqlHistoryFilePath(now = /* @__PURE__ */ new Date(), saptoolsRoot) {
|
|
993
|
-
return
|
|
1395
|
+
return join3(cfHanaHistoryDirectory(saptoolsRoot), `${dateKey(now)}.jsonl`);
|
|
994
1396
|
}
|
|
995
1397
|
async function pruneSqlHistory(now, saptoolsRoot) {
|
|
996
1398
|
const historyDir = cfHanaHistoryDirectory(saptoolsRoot);
|
|
@@ -1006,7 +1408,7 @@ async function pruneSqlHistory(now, saptoolsRoot) {
|
|
|
1006
1408
|
const cutoffKey = retentionCutoffKey(now);
|
|
1007
1409
|
await Promise.all(
|
|
1008
1410
|
files.filter((file) => HISTORY_FILE_PATTERN.test(file)).filter((file) => file.slice(0, 10) < cutoffKey).map(async (file) => {
|
|
1009
|
-
await
|
|
1411
|
+
await rm2(join3(historyDir, file), { force: true });
|
|
1010
1412
|
})
|
|
1011
1413
|
);
|
|
1012
1414
|
}
|
|
@@ -1577,7 +1979,8 @@ var HanaClient = class _HanaClient {
|
|
|
1577
1979
|
return await writeSqlBackup({
|
|
1578
1980
|
operation: plan.operation,
|
|
1579
1981
|
statementSql: plan.statementSql,
|
|
1580
|
-
result
|
|
1982
|
+
result,
|
|
1983
|
+
selector: this.info.selector
|
|
1581
1984
|
});
|
|
1582
1985
|
});
|
|
1583
1986
|
}
|