simplemdg-dev-cli 2.0.4 → 2.4.4
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 +63 -354
- package/USER_GUIDE.md +55 -378
- package/dist/commands/cds.command.js +69 -60
- package/dist/commands/cds.command.js.map +1 -1
- package/dist/commands/cf-db.command.d.ts +2 -0
- package/dist/commands/cf-db.command.js +606 -0
- package/dist/commands/cf-db.command.js.map +1 -0
- package/dist/commands/cf.command.js +291 -280
- package/dist/commands/cf.command.js.map +1 -1
- package/dist/commands/gitlab.command.d.ts +2 -0
- package/dist/commands/gitlab.command.js +351 -0
- package/dist/commands/gitlab.command.js.map +1 -0
- package/dist/commands/npmrc.command.js +50 -44
- package/dist/commands/npmrc.command.js.map +1 -1
- package/dist/core/cache.d.ts +1 -1
- package/dist/core/cache.js +58 -31
- package/dist/core/cache.js.map +1 -1
- package/dist/core/cds.js +32 -22
- package/dist/core/cds.js.map +1 -1
- package/dist/core/cf-env-parser.d.ts +1 -1
- package/dist/core/cf-env-parser.js +4 -1
- package/dist/core/cf-env-parser.js.map +1 -1
- package/dist/core/cf.d.ts +1 -1
- package/dist/core/cf.js +46 -31
- package/dist/core/cf.js.map +1 -1
- package/dist/core/db/db-btp.d.ts +48 -0
- package/dist/core/db/db-btp.js +162 -0
- package/dist/core/db/db-btp.js.map +1 -0
- package/dist/core/db/db-cache.d.ts +35 -0
- package/dist/core/db/db-cache.js +164 -0
- package/dist/core/db/db-cache.js.map +1 -0
- package/dist/core/db/db-connection.d.ts +22 -0
- package/dist/core/db/db-connection.js +73 -0
- package/dist/core/db/db-connection.js.map +1 -0
- package/dist/core/db/db-crypto.d.ts +3 -0
- package/dist/core/db/db-crypto.js +54 -0
- package/dist/core/db/db-crypto.js.map +1 -0
- package/dist/core/db/db-hana-adapter.d.ts +32 -0
- package/dist/core/db/db-hana-adapter.js +243 -0
- package/dist/core/db/db-hana-adapter.js.map +1 -0
- package/dist/core/db/db-metadata.d.ts +25 -0
- package/dist/core/db/db-metadata.js +150 -0
- package/dist/core/db/db-metadata.js.map +1 -0
- package/dist/core/db/db-postgres-adapter.d.ts +30 -0
- package/dist/core/db/db-postgres-adapter.js +245 -0
- package/dist/core/db/db-postgres-adapter.js.map +1 -0
- package/dist/core/db/db-query-files.d.ts +20 -0
- package/dist/core/db/db-query-files.js +106 -0
- package/dist/core/db/db-query-files.js.map +1 -0
- package/dist/core/db/db-query-history.d.ts +5 -0
- package/dist/core/db/db-query-history.js +49 -0
- package/dist/core/db/db-query-history.js.map +1 -0
- package/dist/core/db/db-row.d.ts +22 -0
- package/dist/core/db/db-row.js +70 -0
- package/dist/core/db/db-row.js.map +1 -0
- package/dist/core/db/db-studio-html.d.ts +4 -0
- package/dist/core/db/db-studio-html.js +437 -0
- package/dist/core/db/db-studio-html.js.map +1 -0
- package/dist/core/db/db-studio-server.d.ts +11 -0
- package/dist/core/db/db-studio-server.js +465 -0
- package/dist/core/db/db-studio-server.js.map +1 -0
- package/dist/core/db/db-types.d.ts +174 -0
- package/dist/core/db/db-types.js +3 -0
- package/dist/core/db/db-types.js.map +1 -0
- package/dist/core/db/db-vcap-parser.d.ts +7 -0
- package/dist/core/db/db-vcap-parser.js +137 -0
- package/dist/core/db/db-vcap-parser.js.map +1 -0
- package/dist/core/doctor.d.ts +1 -1
- package/dist/core/doctor.js +14 -8
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/guide.js +31 -26
- package/dist/core/guide.js.map +1 -1
- package/dist/core/install.d.ts +1 -1
- package/dist/core/install.js +17 -11
- package/dist/core/install.js.map +1 -1
- package/dist/core/navigator.d.ts +17 -0
- package/dist/core/navigator.js +140 -0
- package/dist/core/navigator.js.map +1 -0
- package/dist/core/npmrc.js +29 -16
- package/dist/core/npmrc.js.map +1 -1
- package/dist/core/process.js +11 -6
- package/dist/core/process.js.map +1 -1
- package/dist/core/prompts.js +16 -8
- package/dist/core/prompts.js.map +1 -1
- package/dist/core/repository.d.ts +1 -1
- package/dist/core/repository.js +16 -9
- package/dist/core/repository.js.map +1 -1
- package/dist/core/scanner.d.ts +1 -1
- package/dist/core/scanner.js +13 -7
- package/dist/core/scanner.js.map +1 -1
- package/dist/core/tooling.d.ts +28 -0
- package/dist/core/tooling.js +168 -0
- package/dist/core/tooling.js.map +1 -0
- package/dist/core/types.js +2 -1
- package/dist/core/version-conflict.d.ts +2 -2
- package/dist/core/version-conflict.js +11 -6
- package/dist/core/version-conflict.js.map +1 -1
- package/dist/index.js +65 -48
- package/dist/index.js.map +1 -1
- package/dist/types-local.js +2 -1
- package/package.json +12 -6
- package/src/commands/cds.command.ts +529 -0
- package/src/commands/cf-db.command.ts +636 -0
- package/src/commands/cf.command.ts +3345 -0
- package/src/commands/gitlab.command.ts +373 -0
- package/src/commands/npmrc.command.ts +581 -0
- package/src/core/cache.ts +332 -0
- package/src/core/cds.ts +278 -0
- package/src/core/cf-env-parser.ts +131 -0
- package/src/core/cf.ts +271 -0
- package/src/core/db/db-btp.ts +207 -0
- package/src/core/db/db-cache.ts +215 -0
- package/src/core/db/db-connection.ts +79 -0
- package/src/core/db/db-crypto.ts +53 -0
- package/src/core/db/db-hana-adapter.ts +294 -0
- package/src/core/db/db-metadata.ts +174 -0
- package/src/core/db/db-postgres-adapter.ts +275 -0
- package/src/core/db/db-query-files.ts +130 -0
- package/src/core/db/db-query-history.ts +53 -0
- package/src/core/db/db-row.ts +93 -0
- package/src/core/db/db-studio-html.ts +439 -0
- package/src/core/db/db-studio-server.ts +559 -0
- package/src/core/db/db-types.ts +195 -0
- package/src/core/db/db-vcap-parser.ts +182 -0
- package/src/core/doctor.ts +70 -0
- package/src/core/guide.ts +261 -0
- package/src/core/install.ts +91 -0
- package/src/core/navigator.ts +164 -0
- package/src/core/npmrc.ts +171 -0
- package/src/core/process.ts +75 -0
- package/src/core/prompts.ts +225 -0
- package/src/core/repository.ts +36 -0
- package/src/core/scanner.ts +41 -0
- package/src/core/tooling.ts +207 -0
- package/src/core/types.ts +152 -0
- package/src/core/version-conflict.ts +46 -0
- package/src/index.ts +460 -0
- package/src/types/external.d.ts +3 -0
- package/src/types-local.ts +11 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import { decryptSecret, encryptSecret } from "./db-crypto";
|
|
6
|
+
import type {
|
|
7
|
+
TDatabaseConnectionProfile,
|
|
8
|
+
TDatabaseType,
|
|
9
|
+
TPublicDatabaseConnection,
|
|
10
|
+
TResolvedDatabaseConnection,
|
|
11
|
+
} from "./db-types";
|
|
12
|
+
|
|
13
|
+
const CACHE_DIRECTORY = path.join(os.homedir(), ".simplemdg");
|
|
14
|
+
const CONNECTIONS_FILE_PATH = path.join(CACHE_DIRECTORY, "db-connections.json");
|
|
15
|
+
|
|
16
|
+
type TConnectionsCacheFile = {
|
|
17
|
+
connections: TDatabaseConnectionProfile[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Draft used when creating or updating a connection. The password is provided
|
|
22
|
+
* in plain text and encrypted before it ever touches disk.
|
|
23
|
+
*/
|
|
24
|
+
export type TConnectionDraft = {
|
|
25
|
+
id?: string;
|
|
26
|
+
name: string;
|
|
27
|
+
type: TDatabaseType;
|
|
28
|
+
region?: string;
|
|
29
|
+
org?: string;
|
|
30
|
+
space?: string;
|
|
31
|
+
app?: string;
|
|
32
|
+
serviceName?: string;
|
|
33
|
+
servicePlan?: string;
|
|
34
|
+
host: string;
|
|
35
|
+
port: number;
|
|
36
|
+
database?: string;
|
|
37
|
+
schema?: string;
|
|
38
|
+
username: string;
|
|
39
|
+
password: string;
|
|
40
|
+
ssl?: boolean;
|
|
41
|
+
sslValidateCertificate?: boolean;
|
|
42
|
+
tags?: string[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
async function readCacheFile(): Promise<TConnectionsCacheFile> {
|
|
46
|
+
if (!(await fs.pathExists(CONNECTIONS_FILE_PATH))) {
|
|
47
|
+
return { connections: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parsed = await fs
|
|
51
|
+
.readJson(CONNECTIONS_FILE_PATH)
|
|
52
|
+
.catch(() => ({ connections: [] })) as Partial<TConnectionsCacheFile>;
|
|
53
|
+
|
|
54
|
+
return { connections: Array.isArray(parsed.connections) ? parsed.connections : [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function writeCacheFile(cache: TConnectionsCacheFile): Promise<void> {
|
|
58
|
+
await fs.ensureDir(CACHE_DIRECTORY);
|
|
59
|
+
// Defensive: ensure every password is encrypted before persisting.
|
|
60
|
+
const secured: TConnectionsCacheFile = {
|
|
61
|
+
connections: cache.connections.map((connection) => ({
|
|
62
|
+
...connection,
|
|
63
|
+
encryptedPassword: encryptSecret(connection.encryptedPassword),
|
|
64
|
+
})),
|
|
65
|
+
};
|
|
66
|
+
await fs.writeJson(CONNECTIONS_FILE_PATH, secured, { spaces: 2 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toPublicConnection(profile: TDatabaseConnectionProfile): TPublicDatabaseConnection {
|
|
70
|
+
const { encryptedPassword: _ignored, ...rest } = profile;
|
|
71
|
+
void _ignored;
|
|
72
|
+
return rest;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function listConnectionProfiles(): Promise<TDatabaseConnectionProfile[]> {
|
|
76
|
+
const cache = await readCacheFile();
|
|
77
|
+
return cache.connections;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function listPublicConnections(): Promise<TPublicDatabaseConnection[]> {
|
|
81
|
+
const cache = await readCacheFile();
|
|
82
|
+
return cache.connections
|
|
83
|
+
.map(toPublicConnection)
|
|
84
|
+
.sort((left, right) => (right.lastUsedAt ?? right.updatedAt).localeCompare(left.lastUsedAt ?? left.updatedAt));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function findConnectionProfile(id: string): Promise<TDatabaseConnectionProfile | undefined> {
|
|
88
|
+
const cache = await readCacheFile();
|
|
89
|
+
return cache.connections.find((connection) => connection.id === id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function getResolvedConnection(id: string): Promise<TResolvedDatabaseConnection> {
|
|
93
|
+
const profile = await findConnectionProfile(id);
|
|
94
|
+
|
|
95
|
+
if (!profile) {
|
|
96
|
+
throw new Error(`Connection not found: ${id}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { encryptedPassword, ...rest } = profile;
|
|
100
|
+
return { ...rest, password: decryptSecret(encryptedPassword) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function upsertConnectionFromDraft(draft: TConnectionDraft): Promise<TDatabaseConnectionProfile> {
|
|
104
|
+
const cache = await readCacheFile();
|
|
105
|
+
const now = new Date().toISOString();
|
|
106
|
+
|
|
107
|
+
const existingIndex = draft.id
|
|
108
|
+
? cache.connections.findIndex((connection) => connection.id === draft.id)
|
|
109
|
+
: cache.connections.findIndex((connection) =>
|
|
110
|
+
connection.app === draft.app &&
|
|
111
|
+
connection.serviceName === draft.serviceName &&
|
|
112
|
+
connection.type === draft.type &&
|
|
113
|
+
Boolean(draft.app) &&
|
|
114
|
+
Boolean(draft.serviceName));
|
|
115
|
+
|
|
116
|
+
const existing = existingIndex >= 0 ? cache.connections[existingIndex] : undefined;
|
|
117
|
+
|
|
118
|
+
const profile: TDatabaseConnectionProfile = {
|
|
119
|
+
id: existing?.id ?? draft.id ?? crypto.randomUUID(),
|
|
120
|
+
name: draft.name,
|
|
121
|
+
type: draft.type,
|
|
122
|
+
region: draft.region,
|
|
123
|
+
org: draft.org,
|
|
124
|
+
space: draft.space,
|
|
125
|
+
app: draft.app,
|
|
126
|
+
serviceName: draft.serviceName,
|
|
127
|
+
servicePlan: draft.servicePlan,
|
|
128
|
+
host: draft.host,
|
|
129
|
+
port: draft.port,
|
|
130
|
+
database: draft.database,
|
|
131
|
+
schema: draft.schema,
|
|
132
|
+
username: draft.username,
|
|
133
|
+
encryptedPassword: encryptSecret(draft.password),
|
|
134
|
+
ssl: draft.ssl,
|
|
135
|
+
sslValidateCertificate: draft.sslValidateCertificate,
|
|
136
|
+
tags: draft.tags,
|
|
137
|
+
createdAt: existing?.createdAt ?? now,
|
|
138
|
+
updatedAt: now,
|
|
139
|
+
lastUsedAt: existing?.lastUsedAt,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (existingIndex >= 0) {
|
|
143
|
+
cache.connections[existingIndex] = profile;
|
|
144
|
+
} else {
|
|
145
|
+
cache.connections.unshift(profile);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await writeCacheFile(cache);
|
|
149
|
+
return profile;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function removeConnection(id: string): Promise<boolean> {
|
|
153
|
+
const cache = await readCacheFile();
|
|
154
|
+
const nextConnections = cache.connections.filter((connection) => connection.id !== id);
|
|
155
|
+
|
|
156
|
+
if (nextConnections.length === cache.connections.length) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await writeCacheFile({ connections: nextConnections });
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function renameConnection(id: string, name: string): Promise<TDatabaseConnectionProfile> {
|
|
165
|
+
const cache = await readCacheFile();
|
|
166
|
+
const profile = cache.connections.find((connection) => connection.id === id);
|
|
167
|
+
|
|
168
|
+
if (!profile) {
|
|
169
|
+
throw new Error(`Connection not found: ${id}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
profile.name = name;
|
|
173
|
+
profile.updatedAt = new Date().toISOString();
|
|
174
|
+
await writeCacheFile(cache);
|
|
175
|
+
return profile;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function duplicateConnection(id: string): Promise<TDatabaseConnectionProfile> {
|
|
179
|
+
const cache = await readCacheFile();
|
|
180
|
+
const source = cache.connections.find((connection) => connection.id === id);
|
|
181
|
+
|
|
182
|
+
if (!source) {
|
|
183
|
+
throw new Error(`Connection not found: ${id}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const now = new Date().toISOString();
|
|
187
|
+
const copy: TDatabaseConnectionProfile = {
|
|
188
|
+
...source,
|
|
189
|
+
id: crypto.randomUUID(),
|
|
190
|
+
name: `${source.name} (copy)`,
|
|
191
|
+
createdAt: now,
|
|
192
|
+
updatedAt: now,
|
|
193
|
+
lastUsedAt: undefined,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
cache.connections.unshift(copy);
|
|
197
|
+
await writeCacheFile(cache);
|
|
198
|
+
return copy;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function touchConnectionUsage(id: string): Promise<void> {
|
|
202
|
+
const cache = await readCacheFile();
|
|
203
|
+
const profile = cache.connections.find((connection) => connection.id === id);
|
|
204
|
+
|
|
205
|
+
if (!profile) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
profile.lastUsedAt = new Date().toISOString();
|
|
210
|
+
await writeCacheFile(cache);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function getConnectionsFilePath(): string {
|
|
214
|
+
return CONNECTIONS_FILE_PATH;
|
|
215
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { getResolvedConnection, touchConnectionUsage } from "./db-cache";
|
|
2
|
+
import { HanaAdapter } from "./db-hana-adapter";
|
|
3
|
+
import { PostgresAdapter } from "./db-postgres-adapter";
|
|
4
|
+
import type { IDatabaseAdapter, TConnectionTestResult, TResolvedDatabaseConnection } from "./db-types";
|
|
5
|
+
|
|
6
|
+
export function createAdapter(
|
|
7
|
+
connection: TResolvedDatabaseConnection,
|
|
8
|
+
options?: { queryTimeoutMs?: number },
|
|
9
|
+
): IDatabaseAdapter {
|
|
10
|
+
switch (connection.type) {
|
|
11
|
+
case "hana":
|
|
12
|
+
return new HanaAdapter(connection, options);
|
|
13
|
+
case "postgresql":
|
|
14
|
+
return new PostgresAdapter(connection, options);
|
|
15
|
+
default:
|
|
16
|
+
throw new Error(`Unsupported database type: ${String(connection.type)}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function testConnectionProfile(
|
|
21
|
+
connection: TResolvedDatabaseConnection,
|
|
22
|
+
options?: { queryTimeoutMs?: number },
|
|
23
|
+
): Promise<TConnectionTestResult> {
|
|
24
|
+
const adapter = createAdapter(connection, options);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await adapter.connect();
|
|
28
|
+
return await adapter.testConnection();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return {
|
|
31
|
+
success: false,
|
|
32
|
+
message: error instanceof Error ? error.message : String(error),
|
|
33
|
+
durationMs: 0,
|
|
34
|
+
};
|
|
35
|
+
} finally {
|
|
36
|
+
await adapter.disconnect();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Keeps one live adapter per connection id for the lifetime of a studio
|
|
42
|
+
* session, so repeated queries against the same connection reuse a single
|
|
43
|
+
* established session.
|
|
44
|
+
*/
|
|
45
|
+
export class StudioConnectionPool {
|
|
46
|
+
private readonly adapters = new Map<string, IDatabaseAdapter>();
|
|
47
|
+
|
|
48
|
+
constructor(private readonly options?: { queryTimeoutMs?: number }) {}
|
|
49
|
+
|
|
50
|
+
public async getAdapter(connectionId: string): Promise<IDatabaseAdapter> {
|
|
51
|
+
const existing = this.adapters.get(connectionId);
|
|
52
|
+
|
|
53
|
+
if (existing) {
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resolved = await getResolvedConnection(connectionId);
|
|
58
|
+
const adapter = createAdapter(resolved, this.options);
|
|
59
|
+
await adapter.connect();
|
|
60
|
+
this.adapters.set(connectionId, adapter);
|
|
61
|
+
await touchConnectionUsage(connectionId).catch(() => undefined);
|
|
62
|
+
return adapter;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public async closeConnection(connectionId: string): Promise<void> {
|
|
66
|
+
const adapter = this.adapters.get(connectionId);
|
|
67
|
+
|
|
68
|
+
if (adapter) {
|
|
69
|
+
this.adapters.delete(connectionId);
|
|
70
|
+
await adapter.disconnect().catch(() => undefined);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public async closeAll(): Promise<void> {
|
|
75
|
+
const adapters = [...this.adapters.values()];
|
|
76
|
+
this.adapters.clear();
|
|
77
|
+
await Promise.all(adapters.map((adapter) => adapter.disconnect().catch(() => undefined)));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
|
|
4
|
+
const ENCRYPTION_PREFIX = "enc:";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Derive a stable 256-bit key bound to the current user + machine. The key is
|
|
8
|
+
* never persisted; it is recomputed on demand. Cache files copied to another
|
|
9
|
+
* machine therefore cannot be decrypted, which is the intended behaviour.
|
|
10
|
+
*/
|
|
11
|
+
function deriveLocalKey(): Buffer {
|
|
12
|
+
const seed = `${os.userInfo().username}|${os.hostname()}|simplemdg-db`;
|
|
13
|
+
return crypto.createHash("sha256").update(seed).digest();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function encryptSecret(plainValue: string): string {
|
|
17
|
+
if (plainValue.startsWith(ENCRYPTION_PREFIX)) {
|
|
18
|
+
return plainValue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const key = deriveLocalKey();
|
|
22
|
+
const iv = crypto.randomBytes(12);
|
|
23
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
24
|
+
const encrypted = Buffer.concat([cipher.update(plainValue, "utf8"), cipher.final()]);
|
|
25
|
+
const authTag = cipher.getAuthTag();
|
|
26
|
+
|
|
27
|
+
return `${ENCRYPTION_PREFIX}${Buffer.concat([iv, authTag, encrypted]).toString("base64")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function decryptSecret(storedValue: string): string {
|
|
31
|
+
if (!storedValue.startsWith(ENCRYPTION_PREFIX)) {
|
|
32
|
+
// Backward compatibility: tolerate legacy plain values already on disk.
|
|
33
|
+
return storedValue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const key = deriveLocalKey();
|
|
38
|
+
const raw = Buffer.from(storedValue.slice(ENCRYPTION_PREFIX.length), "base64");
|
|
39
|
+
const iv = raw.subarray(0, 12);
|
|
40
|
+
const authTag = raw.subarray(12, 28);
|
|
41
|
+
const encrypted = raw.subarray(28);
|
|
42
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
43
|
+
decipher.setAuthTag(authTag);
|
|
44
|
+
|
|
45
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error("Cannot decrypt cached credential. It may have been created on another machine or user account. Re-import the connection.");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isEncrypted(value: string): boolean {
|
|
52
|
+
return value.startsWith(ENCRYPTION_PREFIX);
|
|
53
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { buildQualifiedName, quoteIdentifier } from "./db-metadata";
|
|
2
|
+
import type {
|
|
3
|
+
IDatabaseAdapter,
|
|
4
|
+
TConnectionTestResult,
|
|
5
|
+
TDatabaseColumn,
|
|
6
|
+
TDatabaseIndex,
|
|
7
|
+
TDatabaseObject,
|
|
8
|
+
TDatabaseQueryResult,
|
|
9
|
+
TDatabaseSchema,
|
|
10
|
+
TDatabaseType,
|
|
11
|
+
TListObjectsOptions,
|
|
12
|
+
TResolvedDatabaseConnection,
|
|
13
|
+
TTableDataOptions,
|
|
14
|
+
} from "./db-types";
|
|
15
|
+
|
|
16
|
+
// Minimal typed surface for the untyped @sap/hana-client driver.
|
|
17
|
+
interface IHanaConnection {
|
|
18
|
+
connect(options: Record<string, string>, callback: (error: Error | null) => void): void;
|
|
19
|
+
exec(sql: string, params: unknown[], callback: (error: Error | null, rows: unknown) => void): void;
|
|
20
|
+
disconnect(callback?: (error: Error | null) => void): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface IHanaModule {
|
|
24
|
+
createConnection(): IHanaConnection;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SYSTEM_SCHEMAS = new Set(["SYS", "SYSTEM", "_SYS_BIC", "_SYS_REPO", "_SYS_STATISTICS"]);
|
|
28
|
+
const HANA_ROW_KIND = { TABLE: "table", VIEW: "view" } as const;
|
|
29
|
+
|
|
30
|
+
type THanaRow = Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
export class HanaAdapter implements IDatabaseAdapter {
|
|
33
|
+
public readonly type: TDatabaseType = "hana";
|
|
34
|
+
|
|
35
|
+
private connection: IHanaConnection | undefined;
|
|
36
|
+
|
|
37
|
+
private readonly queryTimeoutMs: number;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly resolvedConnection: TResolvedDatabaseConnection,
|
|
41
|
+
options?: { queryTimeoutMs?: number },
|
|
42
|
+
) {
|
|
43
|
+
this.queryTimeoutMs = options?.queryTimeoutMs ?? 30000;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public async connect(): Promise<void> {
|
|
47
|
+
if (this.connection) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let hanaModule: IHanaModule;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
hanaModule = (await import("@sap/hana-client")) as unknown as IHanaModule;
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error("SAP HANA driver '@sap/hana-client' is not installed. Run: npm install @sap/hana-client");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const connection = hanaModule.createConnection();
|
|
60
|
+
const connectOptions: Record<string, string> = {
|
|
61
|
+
serverNode: `${this.resolvedConnection.host}:${this.resolvedConnection.port}`,
|
|
62
|
+
uid: this.resolvedConnection.username,
|
|
63
|
+
pwd: this.resolvedConnection.password,
|
|
64
|
+
encrypt: this.resolvedConnection.ssl === false ? "false" : "true",
|
|
65
|
+
sslValidateCertificate: this.resolvedConnection.sslValidateCertificate ? "true" : "false",
|
|
66
|
+
communicationTimeout: String(this.queryTimeoutMs),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (this.resolvedConnection.schema) {
|
|
70
|
+
connectOptions.currentSchema = this.resolvedConnection.schema;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await new Promise<void>((resolve, reject) => {
|
|
74
|
+
connection.connect(connectOptions, (error) => (error ? reject(error) : resolve()));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
this.connection = connection;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public async disconnect(): Promise<void> {
|
|
81
|
+
if (this.connection) {
|
|
82
|
+
const connection = this.connection;
|
|
83
|
+
this.connection = undefined;
|
|
84
|
+
await new Promise<void>((resolve) => connection.disconnect(() => resolve()));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async getConnection(): Promise<IHanaConnection> {
|
|
89
|
+
if (!this.connection) {
|
|
90
|
+
await this.connect();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!this.connection) {
|
|
94
|
+
throw new Error("SAP HANA connection is not established");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return this.connection;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async exec(sql: string, params: unknown[] = []): Promise<unknown> {
|
|
101
|
+
const connection = await this.getConnection();
|
|
102
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
103
|
+
connection.exec(sql, params, (error, rows) => (error ? reject(error) : resolve(rows)));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async execRows(sql: string, params: unknown[] = []): Promise<THanaRow[]> {
|
|
108
|
+
const result = await this.exec(sql, params);
|
|
109
|
+
return Array.isArray(result) ? (result as THanaRow[]) : [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public async testConnection(): Promise<TConnectionTestResult> {
|
|
113
|
+
const startedAt = Date.now();
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const rows = await this.execRows("SELECT VERSION FROM SYS.M_DATABASE");
|
|
117
|
+
const version = String(rows[0]?.VERSION ?? "SAP HANA");
|
|
118
|
+
return { success: true, message: "Connection successful", serverVersion: version, durationMs: Date.now() - startedAt };
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return {
|
|
121
|
+
success: false,
|
|
122
|
+
message: error instanceof Error ? error.message : String(error),
|
|
123
|
+
durationMs: Date.now() - startedAt,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private toQueryResult(result: unknown, durationMs: number, maxRows?: number): TDatabaseQueryResult {
|
|
129
|
+
if (typeof result === "number") {
|
|
130
|
+
return { fields: [], rows: [], rowCount: 0, affectedRows: result, command: "DML", durationMs };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const allRows = Array.isArray(result) ? (result as THanaRow[]) : [];
|
|
134
|
+
const limit = maxRows ?? 0;
|
|
135
|
+
const truncated = limit > 0 && allRows.length > limit;
|
|
136
|
+
const rows = truncated ? allRows.slice(0, limit) : allRows;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
fields: rows.length > 0 ? Object.keys(rows[0]) : [],
|
|
140
|
+
rows,
|
|
141
|
+
rowCount: rows.length,
|
|
142
|
+
command: "SELECT",
|
|
143
|
+
durationMs,
|
|
144
|
+
truncated,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public async runQuery(sql: string, options?: { maxRows?: number }): Promise<TDatabaseQueryResult> {
|
|
149
|
+
const startedAt = Date.now();
|
|
150
|
+
const result = await this.exec(sql);
|
|
151
|
+
return this.toQueryResult(result, Date.now() - startedAt, options?.maxRows);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public async runParameterized(sql: string, params: unknown[], options?: { maxRows?: number }): Promise<TDatabaseQueryResult> {
|
|
155
|
+
const startedAt = Date.now();
|
|
156
|
+
const result = await this.exec(sql, params);
|
|
157
|
+
return this.toQueryResult(result, Date.now() - startedAt, options?.maxRows);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public placeholder(_index: number): string {
|
|
161
|
+
return "?";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public async listSchemas(): Promise<TDatabaseSchema[]> {
|
|
165
|
+
const rows = await this.execRows("SELECT SCHEMA_NAME FROM SYS.SCHEMAS ORDER BY SCHEMA_NAME");
|
|
166
|
+
return rows.map((row) => {
|
|
167
|
+
const name = String(row.SCHEMA_NAME);
|
|
168
|
+
return { name, isSystem: SYSTEM_SCHEMAS.has(name) || name.startsWith("_SYS") };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public async listObjects(options: TListObjectsOptions): Promise<TDatabaseObject[]> {
|
|
173
|
+
const schema = options.schema;
|
|
174
|
+
|
|
175
|
+
if (!schema) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const kinds = options.kinds;
|
|
180
|
+
const objects: TDatabaseObject[] = [];
|
|
181
|
+
|
|
182
|
+
if (!kinds || kinds.includes("table")) {
|
|
183
|
+
const tables = await this.execRows("SELECT TABLE_NAME FROM SYS.TABLES WHERE SCHEMA_NAME = ? ORDER BY TABLE_NAME", [schema]);
|
|
184
|
+
objects.push(...tables.map((row) => ({ schema, name: String(row.TABLE_NAME), kind: HANA_ROW_KIND.TABLE })));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!kinds || kinds.includes("view") || kinds.includes("column-view")) {
|
|
188
|
+
const views = await this.execRows("SELECT VIEW_NAME FROM SYS.VIEWS WHERE SCHEMA_NAME = ? ORDER BY VIEW_NAME", [schema]);
|
|
189
|
+
objects.push(...views.map((row) => ({ schema, name: String(row.VIEW_NAME), kind: HANA_ROW_KIND.VIEW })));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!kinds || kinds.includes("procedure")) {
|
|
193
|
+
const procedures = await this.execRows("SELECT PROCEDURE_NAME FROM SYS.PROCEDURES WHERE SCHEMA_NAME = ? ORDER BY PROCEDURE_NAME", [schema]);
|
|
194
|
+
objects.push(...procedures.map((row) => ({ schema, name: String(row.PROCEDURE_NAME), kind: "procedure" as const })));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!kinds || kinds.includes("function")) {
|
|
198
|
+
const functions = await this.execRows("SELECT FUNCTION_NAME FROM SYS.FUNCTIONS WHERE SCHEMA_NAME = ? ORDER BY FUNCTION_NAME", [schema]);
|
|
199
|
+
objects.push(...functions.map((row) => ({ schema, name: String(row.FUNCTION_NAME), kind: "function" as const })));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!kinds || kinds.includes("synonym")) {
|
|
203
|
+
const synonyms = await this.execRows("SELECT SYNONYM_NAME FROM SYS.SYNONYMS WHERE SCHEMA_NAME = ? ORDER BY SYNONYM_NAME", [schema]).catch(() => []);
|
|
204
|
+
objects.push(...synonyms.map((row) => ({ schema, name: String(row.SYNONYM_NAME), kind: "synonym" as const })));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const search = options.search?.trim().toLowerCase();
|
|
208
|
+
return search ? objects.filter((object) => object.name.toLowerCase().includes(search)) : objects;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
public async listColumns(schema: string, table: string): Promise<TDatabaseColumn[]> {
|
|
212
|
+
const primaryKeyRows = await this.execRows(
|
|
213
|
+
"SELECT COLUMN_NAME FROM SYS.CONSTRAINTS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? AND IS_PRIMARY_KEY = 'TRUE'",
|
|
214
|
+
[schema, table],
|
|
215
|
+
).catch(() => []);
|
|
216
|
+
const primaryKeyColumns = new Set(primaryKeyRows.map((row) => String(row.COLUMN_NAME)));
|
|
217
|
+
|
|
218
|
+
const tableColumns = await this.execRows(
|
|
219
|
+
`SELECT COLUMN_NAME, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE, DEFAULT_VALUE, POSITION, COMMENTS
|
|
220
|
+
FROM SYS.TABLE_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ORDER BY POSITION`,
|
|
221
|
+
[schema, table],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const columns = tableColumns.length > 0
|
|
225
|
+
? tableColumns
|
|
226
|
+
: await this.execRows(
|
|
227
|
+
`SELECT COLUMN_NAME, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE, DEFAULT_VALUE, POSITION
|
|
228
|
+
FROM SYS.VIEW_COLUMNS WHERE SCHEMA_NAME = ? AND VIEW_NAME = ? ORDER BY POSITION`,
|
|
229
|
+
[schema, table],
|
|
230
|
+
).catch(() => []);
|
|
231
|
+
|
|
232
|
+
return columns.map((row) => ({
|
|
233
|
+
name: String(row.COLUMN_NAME),
|
|
234
|
+
dataType: String(row.DATA_TYPE_NAME),
|
|
235
|
+
length: row.LENGTH === null || row.LENGTH === undefined ? undefined : Number(row.LENGTH),
|
|
236
|
+
scale: row.SCALE === null || row.SCALE === undefined ? undefined : Number(row.SCALE),
|
|
237
|
+
nullable: String(row.IS_NULLABLE).toUpperCase() === "TRUE",
|
|
238
|
+
defaultValue: row.DEFAULT_VALUE === null || row.DEFAULT_VALUE === undefined ? undefined : String(row.DEFAULT_VALUE),
|
|
239
|
+
isPrimaryKey: primaryKeyColumns.has(String(row.COLUMN_NAME)),
|
|
240
|
+
comment: row.COMMENTS === null || row.COMMENTS === undefined ? undefined : String(row.COMMENTS),
|
|
241
|
+
position: Number(row.POSITION),
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
public async listIndexes(schema: string, table: string): Promise<TDatabaseIndex[]> {
|
|
246
|
+
const indexes = await this.execRows(
|
|
247
|
+
"SELECT INDEX_NAME, INDEX_TYPE, CONSTRAINT FROM SYS.INDEXES WHERE SCHEMA_NAME = ? AND TABLE_NAME = ?",
|
|
248
|
+
[schema, table],
|
|
249
|
+
).catch(() => []);
|
|
250
|
+
|
|
251
|
+
const result: TDatabaseIndex[] = [];
|
|
252
|
+
|
|
253
|
+
for (const indexRow of indexes) {
|
|
254
|
+
const indexName = String(indexRow.INDEX_NAME);
|
|
255
|
+
const columnRows = await this.execRows(
|
|
256
|
+
"SELECT COLUMN_NAME FROM SYS.INDEX_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? AND INDEX_NAME = ? ORDER BY POSITION",
|
|
257
|
+
[schema, table, indexName],
|
|
258
|
+
).catch(() => []);
|
|
259
|
+
const constraint = String(indexRow.CONSTRAINT ?? "");
|
|
260
|
+
|
|
261
|
+
result.push({
|
|
262
|
+
name: indexName,
|
|
263
|
+
columns: columnRows.map((row) => String(row.COLUMN_NAME)),
|
|
264
|
+
isUnique: /UNIQUE/i.test(constraint) || /UNIQUE/i.test(String(indexRow.INDEX_TYPE ?? "")),
|
|
265
|
+
isPrimaryKey: /PRIMARY KEY/i.test(constraint),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
public async countRows(schema: string, table: string): Promise<number> {
|
|
273
|
+
const rows = await this.execRows(`SELECT COUNT(*) AS ROW_COUNT FROM ${buildQualifiedName(this.type, schema, table)}`);
|
|
274
|
+
return Number(rows[0]?.ROW_COUNT ?? 0);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
public async getTableData(options: TTableDataOptions): Promise<TDatabaseQueryResult> {
|
|
278
|
+
const qualifiedName = buildQualifiedName(this.type, options.schema, options.table);
|
|
279
|
+
const whereClause = options.where?.trim() ? ` WHERE ${options.where.trim()}` : "";
|
|
280
|
+
const orderClause = options.orderBy?.trim()
|
|
281
|
+
? ` ORDER BY ${quoteIdentifier(this.type, options.orderBy.trim())} ${options.orderDirection === "desc" ? "DESC" : "ASC"}`
|
|
282
|
+
: "";
|
|
283
|
+
const sql = `SELECT * FROM ${qualifiedName}${whereClause}${orderClause} LIMIT ${options.limit} OFFSET ${options.offset}`;
|
|
284
|
+
return this.runQuery(sql, { maxRows: options.limit });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
public quoteIdentifier(identifier: string): string {
|
|
288
|
+
return quoteIdentifier(this.type, identifier);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
public buildQualifiedName(schema: string, name: string): string {
|
|
292
|
+
return buildQualifiedName(this.type, schema, name);
|
|
293
|
+
}
|
|
294
|
+
}
|