UNPKG

hana-cli

Version:
1,535 lines (1,389 loc) 47.1 kB
// @ts-check /** * @module base - Central functionality shared by all the various commands */ import { fileURLToPath } from 'url' import { URL } from 'url' const __dirname = fileURLToPath(new URL('.', import.meta.url)) import { createRequire } from 'module' import { execSync } from 'child_process' // Standard require for local modules const standardRequire = createRequire(import.meta.url) // Enhanced require that falls back to global modules let globalNodeModulesPath = null export function require(moduleId) { try { return standardRequire(moduleId) } catch (error) { if (error.code === 'MODULE_NOT_FOUND' && moduleId.startsWith('@sap/cds-dk')) { // Try to resolve from global installation if (!globalNodeModulesPath) { try { globalNodeModulesPath = execSync('npm root -g', { encoding: 'utf8' }).trim() } catch { throw error // Can't get global path, re-throw original error } } try { const globalModulePath = path.join(globalNodeModulesPath, moduleId) return standardRequire(globalModulePath) } catch (globalError) { throw error // Global resolution failed, throw original error } } throw error } } import * as path from 'path' import fs from 'fs' // Database class is kept as eager load since it's only used in actual DB operations import dbClassDef from "sap-hdb-promisfied" /** @typedef {dbClassDef} dbClass - instance of sap-hdb-promisified module */ export const dbClass = dbClassDef import * as conn from "../utils/connections.js" import * as sqlInjectionDef from "../utils/sqlInjection.js" export const sqlInjection = sqlInjectionDef export const sqlInjectionUtils = sqlInjectionDef //alias for backwards compatibility with @sap/hdbext /** @type Object - HANA Client DB Connection */ let dbConnection = null /** @typedef {dbClass} hdbextPromiseInstance - instance of sap-hdbext-promisified module */ let dbClassInstance = null /** @type {typeof import("chalk")} */ import chalk from 'chalk' export const colors = chalk // Lazy-load inquirer prompts (only needed for interactive prompts) let inquirerPrompts = null const getInquirer = async () => { if (!inquirerPrompts) { inquirerPrompts = await import('@inquirer/prompts') } return inquirerPrompts } /** @type typeof import("glob") */ import { glob } from 'glob' // @ts-ignore import open from 'open' /** @type {typeof import("debug") } */ // @ts-ignore import debugModule from 'debug' // Create a lazy-loaded debug instance that can be refreshed at runtime let _debugInstance = null let _debugEnabled = false export const debug = (...args) => { // Check if DEBUG was just enabled (e.g., by --debug flag at runtime) if (process.env.DEBUG && !_debugEnabled) { _debugInstance = null _debugEnabled = true } if (!_debugInstance) { if (process.env.DEBUG) { _debugInstance = debugModule('hana-cli') _debugEnabled = true } else { // No-op function when debug is disabled _debugInstance = () => {} _debugEnabled = false } } return _debugInstance(...args) } // @ts-ignore import setDebug from 'debug' import { inspect } from 'util' /** @type string */ export let hanaBin = __dirname /** @type boolean */ let inDebug = false /** @type boolean */ let inGui = false /** @type any */ let lastResults import * as locale from "../utils/locale.js" import * as commandSuggestions from "./commandSuggestions.js" const TextBundle = require('@sap/textbundle').TextBundle /** * Parse .properties file content into a key-value map. * @param {string} content * @returns {Record<string, string>} */ function parseProperties(content) { /** @type {Record<string, string>} */ const entries = {} const lines = content.split(/\r?\n/) for (const line of lines) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) { continue } const separatorIndex = trimmed.search(/[:=]/) if (separatorIndex === -1) { continue } const key = trimmed.slice(0, separatorIndex).trim() const value = trimmed.slice(separatorIndex + 1).trim() if (key) { entries[key] = value } } return entries } /** * Load .properties file content if present. * @param {string} filePath * @returns {Record<string, string>} */ function loadPropertiesFile(filePath) { try { if (!fs.existsSync(filePath)) { return {} } const content = fs.readFileSync(filePath, 'utf-8') return parseProperties(content) } catch { return {} } } /** * Load additional text resources for a given base name and locale. * @param {string} baseName * @param {string} localeTag * @returns {Record<string, string>} */ function loadAdditionalTexts(baseName, localeTag) { const basePath = path.join(__dirname, '..', '/_i18n', `${baseName}.properties`) let texts = loadPropertiesFile(basePath) const candidates = [] if (localeTag) { candidates.push(localeTag) const languageOnly = localeTag.split(/[_-]/)[0] if (languageOnly && languageOnly !== localeTag) { candidates.push(languageOnly) } } for (const candidate of candidates) { const localizedPath = path.join(__dirname, '..', '/_i18n', `${baseName}_${candidate}.properties`) texts = { ...texts, ...loadPropertiesFile(localizedPath) } } return texts } /** * Format text with {n} placeholders. * @param {string} value * @param {Array<any>} args * @returns {string} */ function formatText(value, args) { if (!args || args.length === 0) { return value } return value.replace(/\{(\d+)\}/g, (match, index) => { const replacement = args[Number(index)] return replacement !== undefined ? String(replacement) : match }) } const normalizedLocale = locale.normalizeLocale(locale.getLocale()) const baseBundle = new TextBundle(path.join(__dirname, '..', '/_i18n/messages'), normalizedLocale) const additionalBundles = [ 'compareData', 'dataDiff', 'dataLineage', 'dataProfile', 'dataValidator', 'duplicateDetection', 'export', 'interactive', 'referentialCheck' ] const additionalTexts = additionalBundles.reduce((acc, bundleName) => { return { ...acc, ...loadAdditionalTexts(bundleName, normalizedLocale) } }, {}) /** @typeof TextBundle - instance of sap/textbundle */ export const bundle = new Proxy(baseBundle, { get(target, prop) { if (prop === 'getText') { return (key, args) => { if (Object.prototype.hasOwnProperty.call(additionalTexts, key)) { return formatText(additionalTexts[key], args) } return baseBundle.getText(key, args) } } return target[prop] } }) /** @typedef {typeof import("ora")} Ora*/ /** @type Ora - Elegant terminal spinner */ let oraModule = null const getOra = async () => { if (!oraModule) { // @ts-ignore oraModule = (await import('ora')).default } return oraModule } /** @typeof Ora.Options - Terminal spinner options */ let oraOptions = { type: 'clock', text: '\n' } /** @typeof Ora.spinner | Void - elgant termianl spinner instance*/ let spinner = null /** * Start the Terminal Spinner */ export async function startSpinnerInt() { const ora = await getOra() // @ts-ignore spinner = ora(oraOptions).start() } /** * Stop the Terminal Spinner */ export function stopSpinnerInt() { if (spinner) { spinner.stop() } } // Lazy-load terminal-kit (only needed for fancy table output) let terminalkitModule = null const getTerminalKit = async () => { if (!terminalkitModule) { // @ts-ignore const module = await import('terminal-kit') terminalkitModule = module.default } return terminalkitModule } export const getTerminal = async () => { const tk = await getTerminalKit() return tk.terminal } // Import terminal-kit synchronously for backward compatibility with existing code // @ts-ignore import terminalKit from 'terminal-kit' // Wrap terminal access to handle test environments where terminal may not be available let _terminal = null try { if (process.env.NODE_ENV !== 'test') { _terminal = terminalKit.terminal } else { // Provide stub terminal for test environment that outputs to console _terminal = { table: (data) => { // In test mode, use console.table to ensure output is captured if (data && Array.isArray(data) && data.length > 0) { console.table(data) } }, progressBar: () => ({ startItem: () => {}, itemDone: () => {}, stop: () => {} }) } } } catch (error) { console.warn(bundle.getText("warning.terminalKitInitFail", [error.message])) // Provide stub terminal that outputs to console _terminal = { table: (data) => { // Fallback: use console.table to ensure output if (data && Array.isArray(data) && data.length > 0) { console.table(data) } }, progressBar: () => ({ startItem: () => {}, itemDone: () => {}, stop: () => {} }) } } export const terminal = _terminal export let tableOptions = { hasBorder: true, contentHasMarkup: false, borderChars: 'lightRounded' , borderAttr: { color: 'blue' } , textAttr: { bgColor: 'default' } , firstRowTextAttr: { bgColor: 'blue' } , width: 150, // Max table width to prevent overly wide output fit: true // Auto-fit columns within max width } /** Maximum rows to display in terminal output before truncating */ export const MAX_DISPLAY_ROWS = 100 /** * Output a blank line to the console * @returns {void} */ export function blankLine(){ console.log(` `) } /** * Validate that a limit parameter is a valid positive number * @param {any} limit - The limit value to validate * @param {string} [paramName='limit'] - Parameter name for error messages * @throws {Error} If limit is not a valid positive number * @returns {number} The validated limit as a number */ export function validateLimit(limit, paramName = 'limit') { // Check if limit is undefined or null if (limit === undefined || limit === null) { throw new Error(bundle.getText("validation.requiredParam", [paramName])) } // Convert to number if it's a string const numLimit = typeof limit === 'string' ? Number(limit) : limit // Check if it's NaN or not a number if (typeof numLimit !== 'number' || isNaN(numLimit)) { throw new Error(bundle.getText("validation.invalidNumber", [paramName, limit])) } // Check if it's a positive integer if (numLimit <= 0 || !Number.isInteger(numLimit)) { throw new Error(bundle.getText("validation.positiveInteger", [paramName, limit])) } return numLimit } /** type {object} - processed input prompts*/ let prompts = {} /** * * @param {object} newPrompts - processed input prompts */ export function setPrompts(newPrompts) { debug(bundle.getText("debug.setPrompts")) prompts = newPrompts isDebug(prompts) isGui(prompts) } /** * * @returns {object} newPrompts - processed input prompts */ export function getPrompts() { debug(bundle.getText("debug.getPrompts")) // @ts-ignore if (!prompts.schema) { prompts.schema = "**CURRENT_SCHEMA**" } // @ts-ignore if (!prompts.table) { prompts.table = "*" } // @ts-ignore if (!prompts.view) { prompts.view = "*" } // @ts-ignore if (!prompts.user) { prompts.user = "*" } // @ts-ignore if (!prompts.limit) { prompts.limit = 200 } // @ts-ignore if (!prompts.folder) { prompts.folder = "./" } // @ts-ignore if (!prompts.admin || prompts.admin === "") { prompts.admin = false } // @ts-ignore if (!prompts.container) { prompts.container = "*" } // @ts-ignore if (!prompts.containerGroup) { prompts.containerGroup = "*" } // @ts-ignore if (!prompts.function) { prompts.function = "*" } // @ts-ignore if (!prompts.procedure) { prompts.procedure = "*" } // @ts-ignore if (!prompts.indexes) { prompts.indexes = "*" } // @ts-ignore if (!prompts.output) { prompts.output = "tbl" } // @ts-ignore if (typeof prompts.cf === 'undefined') { prompts.cf = true } // @ts-ignore if (typeof prompts.useExists === 'undefined') { prompts.useExists = true } // @ts-ignore if (typeof prompts.useQuoted === 'undefined') { prompts.useQuoted = false } // @ts-ignore if (typeof prompts.log === 'undefined') { prompts.log = false } // @ts-ignore if (typeof prompts.profile === 'undefined') { prompts.profile = "" } return prompts } /** * Clear the database connection * @returns {Promise<void>} */ export async function clearConnection() { dbConnection = null } /** * @param {object} [options] - override the already set parameters with new connection options * @returns {Promise<hdbextPromiseInstance>} - hdbext instanced promisfied * @throws {Error} If connection creation fails */ export async function createDBConnection(options) { if (!dbConnection) { try { let rawClient if (options) { if (typeof options !== 'object') { throw new Error(bundle.getText("validation.invalidNumber", ["options", typeof options])) } rawClient = await conn.createConnection(options, true) } else { rawClient = await conn.createConnection(prompts, false) } if (!rawClient) { throw new Error(bundle.getText("error.connectionFailed")) } dbClassInstance = new dbClass(rawClient) dbConnection = dbClassInstance // Store the wrapped instance } catch (err) { dbConnection = null dbClassInstance = null throw err } } return dbConnection } /** * Initialize Yargs builder * @param {import("yargs").CommandBuilder} input - parameters for the command * @param {boolean} [iConn=true] - Add Connection Group * @param {boolean} [iDebug=true] - Add Debug Group * @returns {import("yargs").CommandBuilder} parameters for the command */ export function getBuilder(input, iConn = true, iDebug = true) { let grpConn = {} let grpDebug = {} if (iConn) { grpConn = { admin: { alias: ['a'], type: 'boolean', default: false, group: bundle.getText("grpConn"), desc: bundle.getText("admin") }, conn: { group: bundle.getText("grpConn"), desc: bundle.getText("connFile") }, } } if (iDebug) { grpDebug = { disableVerbose: { alias: ['quiet'], group: bundle.getText("grpDebug"), type: 'boolean', default: false, desc: bundle.getText("disableVerbose") }, debug: { alias: ['d'], group: bundle.getText("grpDebug"), type: 'boolean', default: false, desc: bundle.getText("debug") } } } let builder = { ...input, ...grpConn, ...grpDebug } return builder } /** * Initialize Yargs builder for massConvert Command * @param {boolean} [ui=false] - Mass Convert via Browser-based UI * @returns {import("yargs").CommandBuilder} parameters for the command */ export function getMassConvertBuilder(ui = false) { /** @type any */ let parameters = { table: { alias: ['t'], type: 'string', default: "*", desc: bundle.getText("table") }, view: { alias: ['v'], type: 'string', desc: bundle.getText("view") }, schema: { alias: ['s'], type: 'string', default: '**CURRENT_SCHEMA**', desc: bundle.getText("schema") }, limit: { alias: ['l'], type: 'number', default: 200, desc: bundle.getText("limit") }, folder: { alias: ['f'], type: 'string', default: './', desc: bundle.getText("folder") }, filename: { alias: ['n'], type: 'string', desc: bundle.getText("filename") }, log: { type: 'boolean', default: false, desc: bundle.getText("mass.log") }, output: { alias: ['o'], choices: ["hdbtable", "cds", "hdbmigrationtable"], default: "cds", type: 'string', desc: bundle.getText("outputType") }, useHanaTypes: { alias: ['hana'], type: 'boolean', default: false, desc: bundle.getText("useHanaTypes") }, useCatalogPure: { alias: ['catalog', 'pure'], type: 'boolean', default: false, desc: bundle.getText("useCatalogPure") }, useExists: { alias: ['exists', 'persistence'], desc: bundle.getText("gui.useExists"), type: 'boolean', default: true }, useQuoted: { alias: ['q', 'quoted'], desc: bundle.getText("gui.useQuoted"), type: 'boolean', default: false }, namespace: { alias: ['ns'], type: 'string', desc: bundle.getText("namespace"), default: '' }, synonyms: { type: 'string', desc: bundle.getText("synonyms"), default: '' }, keepPath: { type: 'boolean', default: false, desc: bundle.getText("keepPath") }, noColons: { type: 'boolean', default: false, desc: bundle.getText("noColons") }, profile: { alias: ['p'], type: 'string', default: '', desc: bundle.getText("profile") } } const hasProfile = parameters.profile !== undefined if (ui) { parameters.port = { alias: hasProfile ? [] : ['p'], type: 'integer', default: false, desc: bundle.getText("port") } } return getBuilder(parameters, true, true) } /** * Initialize Yargs builder for massConvert Command * @param {boolean} [ui=false] - Mass Convert via Browser-based UI * @returns {object} - prompts schema object */ export function getMassConvertPrompts(ui = false) { let parameters = { table: { description: bundle.getText("table"), type: 'string', required: true }, view: { description: bundle.getText("view"), type: 'string', required: false }, schema: { description: bundle.getText("schema"), type: 'string', required: true }, limit: { description: bundle.getText("limit"), type: 'number', required: true }, folder: { description: bundle.getText("folder"), type: 'string', required: true }, filename: { description: bundle.getText("filename"), type: 'string', required: true, ask: () => { return false } }, output: { description: bundle.getText("outputType"), type: 'string', // validator: /t[bl]*|s[ql]*|c[ds]?/, required: true }, log: { description: bundle.getText("mass.log"), type: 'boolean' }, useHanaTypes: { description: bundle.getText("useHanaTypes"), type: 'boolean' }, useCatalogPure: { description: bundle.getText("useCatalogPure"), type: 'boolean' }, useExists: { description: bundle.getText("gui.useExists"), type: 'boolean' }, useQuoted: { description: bundle.getText("gui.useQuoted"), type: 'boolean' }, namespace: { description: bundle.getText("namespace"), type: 'string', required: false }, synonyms: { description: bundle.getText("synonyms"), type: 'string', required: false }, keepPath: { type: 'boolean', description: bundle.getText("keepPath") }, noColons: { type: 'boolean', description: bundle.getText("noColons") } } if (ui) { parameters.port = { description: bundle.getText("port"), required: false, ask: () => { return false } } } return parameters } /** * Transform prompt schema from old prompt format to inquirer format * @param {string} name - prompt name/key * @param {object} config - prompt configuration * @param {import("yargs").CommandBuilder} argv - command line arguments for default values * @returns {object|null} - inquirer prompt config or null if should be skipped */ function transformPromptConfig(name, config, argv) { // Check if prompt should be asked if (config.ask && typeof config.ask === 'function' && !config.ask()) { return null } let promptConfig = { name: name, message: colors.green(bundle.getText("input")) + ' ' + (config.description || name) } // Set default value from argv override or config default if (argv && argv[name] !== undefined) { promptConfig.default = argv[name] } else if (config.default !== undefined) { promptConfig.default = config.default } // Determine prompt type if (config.type === 'boolean') { promptConfig.type = 'confirm' } else if (config.hidden) { promptConfig.type = 'password' promptConfig.mask = config.replace || '*' } else { promptConfig.type = 'input' } // Add validation if (config.required || config.pattern) { promptConfig.validate = (value) => { if (config.required && (!value || value === '')) { return config.message || bundle.getText("validation.promptRequired", [name]) } if (config.pattern && !config.pattern.test(value)) { return config.message || bundle.getText("validation.promptPattern", [name]) } return true } } return promptConfig } /** * Fill the prompts schema * @param {object} inputSchema - prompts current value * @param {boolean} [iConn=true] - Add Connection Group * @param {boolean} [iDebug=true] - Add Debug Group * @returns {any} prompts schema as json */ export function getPromptSchema(inputSchema, iConn = true, iDebug = true) { let grpConn = {} let grpDebug = {} if (iConn) { grpConn = { admin: { description: bundle.getText("admin"), type: 'boolean', required: true, ask: askFalse }, conn: { description: bundle.getText("connFile"), type: 'string', required: false, ask: askFalse }, } } if (iDebug) { grpDebug = { disableVerbose: { description: bundle.getText("disableVerbose"), type: 'boolean', required: true, ask: askFalse }, debug: { description: bundle.getText("debug"), type: 'boolean', required: true, ask: askFalse } } } let schema = { properties: { ...inputSchema, ...grpConn, ...grpDebug } } return schema } /** * Function that always retruns false * @returns {boolean} */ export function askFalse() { return false } /** * Check for unknown command-line options and warn the user * @param {object} argv - Command line arguments from yargs * @param {object} inputSchema - Command's input schema * @param {boolean} iConn - Whether connection options are included * @param {boolean} iDebug - Whether debug options are included * @param {object} [builderOptions] - Command builder options (contains all option definitions) */ function checkUnknownOptions(argv, inputSchema, iConn, iDebug, builderOptions) { if (!argv || typeof argv !== 'object') { return } /** * Convert camelCase to kebab-case * @param {string} str * @returns {string} */ const toKebabCase = (str) => { return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() } // Build set of known options const knownOptions = new Set() // Standard yargs options that should always be allowed const standardYargsOptions = ['$0', '_', 'help', 'h', 'version', 'V'] standardYargsOptions.forEach(opt => knownOptions.add(opt)) // Add options from builder (highest priority - includes command-specific options) if (builderOptions && typeof builderOptions === 'object') { Object.keys(builderOptions).forEach(key => { knownOptions.add(key) knownOptions.add(toKebabCase(key)) // Add aliases from builder option definition const optionDef = builderOptions[key] if (optionDef && optionDef.alias) { const aliases = Array.isArray(optionDef.alias) ? optionDef.alias : [optionDef.alias] aliases.forEach(alias => { knownOptions.add(alias) knownOptions.add(toKebabCase(alias)) }) } }) } // Add options from inputSchema if (inputSchema && typeof inputSchema === 'object') { Object.keys(inputSchema).forEach(key => { knownOptions.add(key) knownOptions.add(toKebabCase(key)) }) } // Add connection options if included if (iConn) { ['admin', 'a', 'conn'].forEach(opt => knownOptions.add(opt)) } // Add debug options if included if (iDebug) { ['disableVerbose', 'quiet', 'debug', 'd', 'disable-verbose'].forEach(opt => knownOptions.add(opt)) } // Build a map of values to option names to detect aliases // When yargs creates aliases, multiple keys will have the same value in argv const valueToOptions = new Map() for (const key in argv) { const value = argv[key] // Create a string key for the value (primitive values and simple objects) let valueKey if (value === null || value === undefined) { valueKey = String(value) } else if (typeof value === 'object') { // For objects/arrays, convert to JSON string - cleaner than stringify try { valueKey = JSON.stringify(value) } catch { // If stringify fails, use toString valueKey = Object.prototype.toString.call(value) } } else { valueKey = String(value) } if (!valueToOptions.has(valueKey)) { valueToOptions.set(valueKey, []) } valueToOptions.get(valueKey).push(key) } // Options are likely aliases if they share a value with a known option const likelyAliases = new Set() for (const options of valueToOptions.values()) { if (options.length > 1) { // Multiple keys with same value - likely aliases const hasKnownOption = options.some(opt => knownOptions.has(opt)) if (hasKnownOption) { // If at least one is known, treat all as valid (aliases) options.forEach(opt => likelyAliases.add(opt)) } } } // Check for unknown options const unknownOptions = [] for (const key in argv) { if (!knownOptions.has(key) && !likelyAliases.has(key)) { unknownOptions.push(key) } } // Warn about unknown options if (unknownOptions.length > 0) { const warnedOptions = new Set() // Build list of available options for suggestions (exclude standard yargs options) const availableOptions = Array.from(knownOptions).filter(k => k !== '$0' && k !== '_' && k !== 'help' && k !== 'h' && k !== 'version' && k !== 'V' ) unknownOptions.forEach(opt => { if (opt && opt.trim()) { // Only warn if option name is not empty const normalizedOpt = toKebabCase(opt) if (warnedOptions.has(normalizedOpt)) { return } warnedOptions.add(normalizedOpt) const warningMsg = bundle.getText("warning.unknownOption", [opt]) console.warn(colors.yellow(warningMsg)) // Add suggestion if available const suggestion = commandSuggestions.getOptionSuggestionMessage(opt, availableOptions, bundle) if (suggestion) { console.warn(colors.cyan(suggestion)) } } }) } } /** * Prompts handler function * @param {import("yargs").CommandBuilder} argv - parameters for the command * @param {function} processingFunction - Function to call after prompts to continue command processing * @param {object} inputSchema - prompts current value * @param {boolean} [iConn=true] - Add Connection Group * @param {boolean} [iDebug=true] - Add Debug Group * @param {object} [builderOptions] - Command builder options for validation * @throws {Error} If required parameters are missing or invalid */ export async function promptHandler(argv, processingFunction, inputSchema, iConn = true, iDebug = true, builderOptions) { // Input validation if (!processingFunction || typeof processingFunction !== 'function') { throw new Error(bundle.getText("validation.invalidNumber", ["processingFunction", typeof processingFunction])) } if (!inputSchema || typeof inputSchema !== 'object') { throw new Error(bundle.getText("validation.invalidNumber", ["inputSchema", typeof inputSchema])) } try { // Check for unknown options and warn user checkUnknownOptions(argv, inputSchema, iConn, iDebug, builderOptions) let schema = getPromptSchema(inputSchema, iConn, iDebug) let result = {} // Check if we're in quiet mode (disableVerbose) // @ts-ignore const isQuietMode = argv && (argv.disableVerbose || argv.quiet) // First, copy all values from argv that are defined in schema if (schema.properties) { for (const [name, config] of Object.entries(schema.properties)) { if (argv && argv[name] !== undefined && argv[name] !== null && argv[name] !== '') { result[name] = argv[name] } else if (isQuietMode && config.default !== undefined) { // In quiet mode, use defaults for empty/missing values result[name] = config.default } } } // Transform schema and collect prompts const prompts = [] if (schema.properties && !isQuietMode) { for (const [name, config] of Object.entries(schema.properties)) { const promptConfig = transformPromptConfig(name, config, argv) if (promptConfig) { prompts.push({ name, config: promptConfig }) } } } // Execute prompts based on type (skip if in quiet mode) for (const { name, config: promptConfig } of prompts) { // Skip if already provided in argv if (argv && argv[name] !== undefined && argv[name] !== null && argv[name] !== '') { continue } // Lazy-load inquirer prompts only when needed const { input, password, confirm } = await getInquirer() let answer if (promptConfig.type === 'confirm') { answer = await confirm({ message: promptConfig.message, default: promptConfig.default }) } else if (promptConfig.type === 'password') { answer = await password({ message: promptConfig.message, mask: promptConfig.mask, validate: promptConfig.validate }) } else { answer = await input({ message: promptConfig.message, default: promptConfig.default, validate: promptConfig.validate }) } result[name] = answer } if (isDebug(result)) { setDebug.enable('hana-cli, *') process.env['NO_TELEMETRY'] = 'false' } else { process.env['NO_TELEMETRY'] = 'true' } debug(bundle.getText("yargs")) debug(argv) debug(bundle.getText("prompts")) debug(result) //startSpinner(result) await processingFunction(result) } catch (err) { if (err && err.message) { console.log(err.message) } else { console.log(bundle.getText("prompt.cancelled")) } } } /** * Handle Errors cleanup connections and decide how to alter the user * @param {*} error - Error Object */ export async function error(error) { debug(bundle.getText("debug.errorHandler")) const processCommand = (process.argv && process.argv[2]) ? String(process.argv[2]).toLowerCase() : '' const isServerLikeCommand = processCommand === 'ui' || processCommand === 'gui' || processCommand === 'server' || processCommand === 'launchpad' || processCommand.endsWith('ui') try { // Attempt clean disconnect await disconnectOnly() } catch (disconnectErr) { debug(bundle.getText("debug.errorHandlerDisconnectException", [disconnectErr])) } if (inDebug || inGui) { throw error } else { console.error(colors.red(`${error}`)) // Exit process after error in CLI mode, but keep server/UI mode alive if (!inGui && !isServerLikeCommand) { process.exit(1) } } } /** * Disconnect database connection without exiting process * @returns {Promise<void>} */ export async function disconnectOnly() { debug(bundle.getText("debug.disconnectOnly")) try { if (dbConnection && dbConnection.client && dbConnection.client._settings) { debug(bundle.getText("debug.hanaDisconnectStarted")) return new Promise((resolve, reject) => { const timeout = setTimeout(() => { debug(bundle.getText("debug.disconnectTimeoutExceeded")) dbConnection = null stopSpinner() resolve() }, 5000) dbConnection.client.disconnect((err) => { clearTimeout(timeout) if (err) { debug(bundle.getText("debug.disconnectError", [err])) } else { debug(bundle.getText("debug.hanaDisconnectCompleted")) } dbConnection = null stopSpinner() if (err) { reject(err) } else { resolve() } }) }) } else { debug(bundle.getText("debug.noConnectionToDisconnect")) stopSpinner() return Promise.resolve() } } catch (disconnectErr) { debug(bundle.getText("debug.disconnectException", [disconnectErr])) dbConnection = null stopSpinner() throw disconnectErr } } /** * Stop the spinner safely * @returns {void} */ function stopSpinner() { if (spinner) { try { spinner.stop() } catch (err) { debug(bundle.getText("debug.spinnerStopError", [err])) } finally { spinner = null } } } /** * Normal processing end and cleanup for single command */ export async function end() { debug(bundle.getText("debug.normalEnd")) try { await disconnectOnly() // Only exit the process when running from CLI (not from MCP/programmatic contexts) if (!inGui) { process.exit(0) } } catch (err) { debug(bundle.getText("debug.endCleanupError", [err])) // Only exit the process when running from CLI if (!inGui) { process.exit(1) } } } /** * Start Console UI spinner * @param {*} prompts - input parameters and values */ export function startSpinner(prompts) { if (verboseOutput(prompts)) { startSpinnerInt() } } /** * Check for Verbose output * @param {*} prompts - input parameters and values * @returns {boolean} */ export function verboseOutput(prompts) { if (prompts && Object.prototype.hasOwnProperty.call(prompts, 'disableVerbose') && prompts.disableVerbose) { return false } else { return true } } /** * Check if we are in debug mode * @param {*} prompts - input parameters and values * @returns {boolean} */ export function isDebug(prompts) { if (prompts && Object.prototype.hasOwnProperty.call(prompts, 'debug') && prompts.debug) { inDebug = true // Enable debug module output when debug flag is set process.env.DEBUG = 'hana-cli*' // Re-enable the debug module with the new setting const debugModule = require('debug') debugModule.enable('hana-cli*') return true } else { inDebug = false return false } } /** * Check if we are in GUI mode * @param {*} prompts - input parameters and values * @returns {boolean} */ export function isGui(prompts) { if (prompts && Object.prototype.hasOwnProperty.call(prompts, 'isGui') && prompts.isGui) { inGui = true return true } else { inGui = false return false } } /** * Output JSON content either as a table or as formatted JSON to console * @param {*} content - json content often a HANA result set * @returns void */ export function outputTable(content) { if (content.length < 1) { console.log(bundle.getText('noData')) } else { if (verboseOutput(prompts)) { return console.table(content) } else { return console.log(inspect(content, { maxArrayLength: null })) } } } /** * Convert JSON array to 2D array format for terminal-kit table * @param {Array<Object>} jsonArray - Array of objects * @returns {Array<Array>} 2D array with headers in first row */ function convertJsonToTableArray(jsonArray) { if (!jsonArray || jsonArray.length === 0) return [] // Get all unique keys from all objects const keys = [...new Set(jsonArray.flatMap(obj => Object.keys(obj)))] // Create header row const result = [keys] // Create data rows for (const obj of jsonArray) { result.push(keys.map(key => obj[key] ?? '')) } return result } /** * Output JSON content either as a table or as formatted JSON to console * @param {*} content - json content often a HANA result set * @returns {Promise<void>} */ export async function outputTableFancy(content) { if (!content || !Array.isArray(content) || content.length < 1) { console.log(bundle.getText('noData')) return } if (verboseOutput(prompts)) { try { // Handle large datasets with pagination if (content.length > MAX_DISPLAY_ROWS) { console.log(colors.yellow(`\n${bundle.getText("output.truncatedWithHint", [MAX_DISPLAY_ROWS, content.length])}\n`)) return terminal.table(convertJsonToTableArray(content.slice(0, MAX_DISPLAY_ROWS)), tableOptions) } else { return terminal.table(convertJsonToTableArray(content), tableOptions) } } catch (error) { // Fallback to console.table if terminal.table fails (e.g., buffer allocation errors) console.error(colors.yellow(bundle.getText("warning.terminalTableFallback")), error.message) if (content.length > MAX_DISPLAY_ROWS) { console.log(colors.yellow(bundle.getText("output.truncated", [MAX_DISPLAY_ROWS, content.length]))) return console.table(content.slice(0, MAX_DISPLAY_ROWS)) } return console.table(content) } } else { return console.log(inspect(content, { maxArrayLength: null })) } } /** * Only output this content to console if in verbose mode * @param {*} content - json content often a HANA result set * @returns void */ export function output(content) { if (verboseOutput(prompts)) { return console.log(content) } else { return } } /** * Global error handling middleware for Express * Compatible with Express 4.x and prepared for 5.x * @param {Error} err - The error object * @param {Object} req - Express request object * @param {Object} res - Express response object * @param {Function} next - Express next middleware function */ // @ts-ignore export function globalErrorHandler(err, req, res, next) { // Log error without calling base.error() which would exit the process in CLI mode console.error(colors.red(bundle.getText("error.unhandled", [err && err.message ? err.message : bundle.getText("error.internalServerError")]) )) debug(bundle.getText("error.unhandled", [err && err.message ? err.message : bundle.getText("error.internalServerError")])) debug(err.stack) // @ts-ignore const statusCode = err.statusCode || err.status || 500 // Always send the actual error message to help users diagnose issues const message = err.message || bundle.getText("error.internalServerError") // Don't call next() after sending response (Express 5 requirement) res.status(statusCode).json({ message: message, status: statusCode, ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }) } /** * 404 Not Found handler * Must be placed after all other route definitions * @param {Object} req - Express request object * @param {Object} res - Express response object */ export function notFoundHandler(req, res) { res.status(404).json({ error: { message: bundle.getText("error.routeNotFound"), status: 404, path: req.path } }) } /** * Setup Express and Launch Browser * @param {string} urlPath - URL Path to Launch * @returns void */ export async function webServerSetup(urlPath) { const path = await import('path') // @ts-ignore const expressModule = await import('express') const express = expressModule.default const http = await import('http') debug(bundle.getText("debug.serverSetup")) // Ensure GUI mode to prevent process exit on errors in UI/server context const currentPrompts = getPrompts() if (!currentPrompts.isGui) { setPrompts({ ...currentPrompts, isGui: true }) } // @ts-ignore const port = process.env.PORT || prompts.port || 3010 const host = process.env.HOST || prompts.host || 'localhost' if (!(/^[1-9]\d*$/.test(port) && 1 <= 1 * port && 1 * port <= 65535)) { return error(`${port} ${bundle.getText("errPort")}`) } const server = http.createServer() const app = express() // Configure Express settings for compatibility app.set('x-powered-by', false) // Disable x-powered-by header for security app.disable('etag') // Keep existing etag setting // Load routes let routesDir = path.posix.join(__dirname.split(path.sep).join(path.posix.sep), '..', 'routes', '**', '*.js') let files = await glob(routesDir) if (files.length !== 0) { for (let file of files) { debug(file) const Route = await import(`file://${file}`) Route.route(app, server) } } // Add 404 handler (must be after all routes but before error handler) app.use(notFoundHandler) // Add error handling middleware (must be last, after all routes and 404 handler) app.use(globalErrorHandler) // Start the Server server.on("request", app) server.listen(port, host, async function () { // @ts-ignore let serverAddr = `http://${host}:${server.address().port}${urlPath}` debug(serverAddr) console.info(bundle.getText("server.httpServer", [serverAddr])) startSpinnerInt() await open(serverAddr, {wait: true}) }) return } /** * Store and send results JSON * @param {any} res - Express Response object * @param {any} results - JSON content * @returns void */ export function sendResults(res, results) { lastResults = results res.type("application/json").status(200).send(results) } /** * Return the last results JSON * @returns lastResults */ export function getLastResults() { return lastResults } /** * Get the username of the active database connection * @returns userName */ export async function getUserName() { let userName = '' try { const options = await conn.getConnOptions(prompts) if (options && options.hana && options.hana.user) { userName = options.hana.user debug(bundle.getText("debug.dbUserName", [userName])) } } catch (error) { debug(bundle.getText("debug.dbUserNameError", [error.message])) } return userName }