hana-cli
Version:
HANA Developer Command Line Interface
1,535 lines (1,389 loc) • 47.1 kB
JavaScript
// @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
}