UNPKG

hana-cli

Version:
395 lines (339 loc) 13.1 kB
// @ts-check /** * @module connections - helper utility for making connections to HANA DB and determine connection settings */ import * as base from "./base.js" import { require } from "./base.js" import * as fs from 'fs' import * as path from 'path' import dotenv from 'dotenv' import { homedir } from 'os' import * as xsenv from '@sap/xsenv' import cds from '@sap/cds' const fileCheckCache = new Map() const cdsrcPrivateCache = new Map() //import cds from '@sap/cds' // @ts-ignore //const LOG = cds.log('bind') //import { createRequire } from 'module' //const require = createRequire(import.meta.url) /** * Check parameter folders to see if the input file exists there * @param {string} filename * @param {number} maxDepth - Maximum directory depth to search (default: 5) * @returns {string|undefined} - the file path if found */ export function getFileCheckParents(filename, maxDepth = 5) { base.debug(base.bundle.getText("debug.callWithParams", ["getFileCheckParents", filename])) try { const cacheKey = `${process.cwd()}|${filename}|${maxDepth}` if (fileCheckCache.has(cacheKey)) { const cachedPath = fileCheckCache.get(cacheKey) return cachedPath || undefined } let currentPath = '.' for (let i = 0; i < maxDepth; i++) { const fullPath = path.join(currentPath, filename) if (fs.existsSync(fullPath)) { fileCheckCache.set(cacheKey, fullPath) return fullPath } currentPath = path.join(currentPath, '..') } fileCheckCache.set(cacheKey, null) return undefined } catch (error) { throw new Error(`${base.bundle.getText("error")} ${error}`) } } // Convenience wrapper functions that delegate to getFileCheckParents /** * Check current and parent directories for a package.json * @returns {string|undefined} - the file path if found */ export const getPackageJSON = () => getFileCheckParents('package.json') /** * Check current and parent directories for a mta.yaml * @returns {string|undefined} - the file path if found */ export const getMTA = () => getFileCheckParents('mta.yaml') /** * Check current and parent directories for a default-env.json * @returns {string|undefined} - the file path if found */ export const getDefaultEnv = () => getFileCheckParents('default-env.json') /** * Check current and parent directories for a default-env-admin.json * @returns {string|undefined} - the file path if found */ export const getDefaultEnvAdmin = () => getFileCheckParents('default-env-admin.json') /** * Check current and parent directories for a .env * @returns {string|undefined} - the file path if found */ export const getEnv = () => getFileCheckParents('.env') /** * Check current and parent directories for a .cdsrc-private.json * @returns {string|undefined} - the file path if found */ export const getCdsrcPrivate = () => getFileCheckParents('.cdsrc-private.json') /** * Extract the first available CDS binding or credentials from .cdsrc-private.json * Supports multiple formats (profiled and unprofiled) for backwards compatibility. * @param {object} config * @returns {{ type: 'binding' | 'credentials', value: object } | undefined} */ function extractCdsBindingOrCredentials(config) { const requires = config?.requires if (!requires || typeof requires !== 'object') { return undefined } /** @type {Array<any>} */ const candidates = [] if (requires['[hybrid]']) { candidates.push(requires['[hybrid]']) } if (requires.db) { candidates.push(requires.db) } for (const value of Object.values(requires)) { if (value) { candidates.push(value) } } for (const candidate of candidates) { const dbSection = candidate?.db || candidate if (dbSection?.credentials) { return { type: 'credentials', value: dbSection.credentials } } if (dbSection?.binding) { return { type: 'binding', value: dbSection.binding } } } return undefined } /** * Resolve CDS binding using cds-dk (supports multiple module paths). * @param {object} binding * @returns {Promise<object>} resolved service with credentials */ async function resolveCdsBinding(binding) { const bindingType = binding?.type || 'cf' const candidates = [ `@sap/cds-dk/lib/bind/${bindingType}`, '@sap/cds-dk/lib/bind/cf', '@sap/cds-dk/lib/bind', '@sap/cds-dk/lib/bindings/cf' ] let resolverModule let loadError for (const modulePath of candidates) { try { resolverModule = require(modulePath) if (resolverModule) { break } } catch (error) { loadError = error } } // If not found locally, try to resolve from global installation if (!resolverModule && loadError?.code === 'MODULE_NOT_FOUND') { try { const { execSync } = await import('child_process') const globalPath = execSync('npm root -g', { encoding: 'utf8' }).trim() const globalCandidates = candidates.map(p => path.join(globalPath, p)) for (const modulePath of globalCandidates) { try { resolverModule = require(modulePath) if (resolverModule) { break } } catch (error) { // Continue trying } } } catch (globalError) { // Unable to resolve global path } } const resolveFn = resolverModule?.resolve || resolverModule?.default?.resolve if (!resolveFn) { if (loadError && loadError.code === 'MODULE_NOT_FOUND') { throw new Error(base.bundle.getText("cds-dk2")) } throw loadError || new Error(base.bundle.getText("cds-dk2")) } try { // The binding instance name (from binding.instance property) const instanceName = binding.instance || 'db' // Call resolve with proper context - it's a method on the resolver instance if (resolverModule.resolve) { return await resolverModule.resolve(instanceName, binding) } else if (resolverModule.default?.resolve) { return await resolverModule.default.resolve(instanceName, binding) } // Fallback to old behavior for other resolver types if (resolveFn.length >= 2) { return await resolveFn(null, binding) } return await resolveFn(binding) } catch (error) { throw error } } /** * Resolve CDS binding credentials from .cdsrc-private.json only * @param {object} prompts * @returns {Promise<object|undefined>} */ export async function getCdsrcBindingOptions(prompts) { const cdsrcPrivate = prompts?.admin ? undefined : getCdsrcPrivate() if (!cdsrcPrivate) { return undefined } try { let object = cdsrcPrivateCache.get(cdsrcPrivate) if (!object) { const data = fs.readFileSync(cdsrcPrivate, { encoding: 'utf8', flag: 'r' }) object = JSON.parse(data) cdsrcPrivateCache.set(cdsrcPrivate, object) } const bindingEntry = extractCdsBindingOrCredentials(object) if (!bindingEntry) { return undefined } if (bindingEntry.type === 'credentials') { const options = { hana: bindingEntry.value } base.debug(options) if (base.verboseOutput(prompts)) { console.log(`${base.bundle.getText("connFile2")} .cdsrc-private.json\n`) } return options } const resolvedService = await resolveCdsBinding(bindingEntry.value) const options = { hana: resolvedService.credentials } base.debug(options) if (base.verboseOutput(prompts)) { console.log(`${base.bundle.getText("connFile2")} .cdsrc-private.json\n`) } return options } catch (e) { if (e.code !== 'MODULE_NOT_FOUND') throw e } return undefined } /** * Resolve Environment by deciding which option between default-env and default-env-admin we should take * @param {object} options * @returns {string} - the file path if found */ export function resolveEnv(options) { base.debug(base.bundle.getText("debug.callWithParams", ["resolveEnv", options])) const file = options?.admin ? 'default-env-admin.json' : 'default-env.json' return path.resolve(process.cwd(), file) } /** * Get Connection Options from input prompts * @param {object} prompts - input prompts * @returns {Promise<object>} connection options */ export async function getConnOptions(prompts) { base.debug(base.bundle.getText("debug.call", ["getConnOptions"])) // NEW: Check for project-specific context from MCP server // This allows AI agents to specify which project's database to use const projectPath = process.env.HANA_CLI_PROJECT_PATH; const connFile = process.env.HANA_CLI_CONN_FILE; // If project path provided, change to that directory so connection resolution starts there if (projectPath && fs.existsSync(projectPath)) { process.chdir(projectPath); base.debug(`Using project directory for connection resolution: ${projectPath}`); } // NEW: Check for direct database credentials from MCP (for explicit connection setup) // This is used when connection file isn't available or for temporary connections if (process.env.HANA_CLI_HOST) { const directConnection = { hana: { host: process.env.HANA_CLI_HOST, port: parseInt(process.env.HANA_CLI_PORT || '30013'), user: process.env.HANA_CLI_USER, password: process.env.HANA_CLI_PASSWORD, database: process.env.HANA_CLI_DATABASE || 'SYSTEMDB', } }; base.debug('Using direct database connection from MCP context'); return directConnection; } delete process.env.VCAP_SERVICES // Try .cdsrc-private.json with CDS binding first const cdsrcOptions = await getCdsrcBindingOptions(prompts) if (cdsrcOptions) { return cdsrcOptions } // Determine which env file to load let envFile = null; // If MCP provided a specific connection file, use it (relative to projectPath or cwd) if (connFile) { envFile = getFileCheckParents(connFile); if (envFile) { base.debug(`Using MCP-specified connection file: ${envFile}`); } } if (!envFile) { envFile = prompts?.admin ? getDefaultEnvAdmin() : getDefaultEnv() } if (!envFile && prompts?.conn) { // Try custom configuration file envFile = getFileCheckParents(prompts.conn) if (!envFile) { envFile = getFileCheckParents(`${homedir()}/.hana-cli/${prompts.conn}`) } } if (!envFile) { // Last resort - configuration file in user profile envFile = getFileCheckParents(`${homedir()}/.hana-cli/default.json`) } if (!envFile && !process.env.VCAP_SERVICES) { // Try .env file as fallback const dotEnvFile = getEnv() if (dotEnvFile) dotenv.config({ path: dotEnvFile, quiet: true }) } if (envFile && base.verboseOutput(prompts)) { console.log(`${base.bundle.getText("connFile2")} ${envFile}\n`) } xsenv.loadEnv(envFile) base.debug(base.bundle.getText("connectionFile")) base.debug(envFile) // Fetch HANA service configuration let options try { const serviceQuery = process.env.TARGET_CONTAINER ? { hana: { name: process.env.TARGET_CONTAINER } } : { hana: { tag: 'hana' } } options = xsenv.getServices(serviceQuery) } catch (error) { try { options = xsenv.getServices({ hana: { tag: 'hana', plan: 'hdi-shared' } }) } catch (retryError) { const errorMsg = envFile ? `${base.bundle.getText("badConfig")} ${envFile}. ${base.bundle.getText("fullDetails")} ${retryError}` : `${base.bundle.getText("missingConfig")} ${retryError}` throw new Error(errorMsg) } } base.debug(options) return options } /** * Create Database Connection * @param {object} prompts - input prompt values * @param {boolean} directConnect - Direct Connection parameters are supplied in prompts * @returns {Promise<object>} HANA DB connection of type hdb */ export async function createConnection(prompts, directConnect = false) { base.debug(base.bundle.getText("debug.call", ["createConnection"])) const options = directConnect ? { hana: prompts } : await getConnOptions(prompts) base.debug(base.bundle.getText("debug.connectionCreateStart")) return base.dbClass.createConnection(options) }