@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
486 lines (451 loc) • 15.9 kB
JavaScript
const fs = require('fs')
const path = require('path')
const cds = require('../cds')
const cp = require('child_process');
const execAsync = require('util').promisify(cp.exec)
const IS_WIN = require('os').platform() === 'win32'
const { SEVERITY_ERROR, FILE_EXT_CDS, NODEJS_MODEL_EXCLUDE_LIST } = require('./constants');
const DEBUG = cds.debug('cli|build')
const SECRETS = /(passw)|(cert)|(ca)|(secret)|(key)/i
const WS_MAX_DEPTH = 2
function getProperty(src, segments) {
segments = Array.isArray(segments) ? segments : segments.split('.')
return segments.reduce((p, n) => p && p[n], src)
}
function setProperty(src, segments, value) {
segments = Array.isArray(segments) ? segments : segments.split('.')
segments.reduce((p, n, idx) => {
if (segments.length === idx + 1) {
p[n] = value
} else {
if (p[n] === undefined) {
p[n] = {}
}
}
return p[n]
}, src)
}
/**
* Returns whether this project is a java project or not.
*/
function hasJavaNature() {
return [cds.env.folders.srv, '.'].some(f => f && fs.existsSync(path.join(cds.root, f, 'pom.xml')))
}
/**
* Determines whether the both values are identical.
* @param {*} actual
* @param {*} expected
*/
function hasOptionValue(actual, expected) {
if (typeof expected === 'undefined') {
return actual !== undefined
}
if (typeof actual === 'undefined') {
return false
}
if (typeof expected === 'boolean') {
if (typeof actual === 'string') {
return String(expected) === actual
}
}
if (typeof expected === 'number') {
return (actual & expected) !== 0
}
return actual === expected
}
// Returning the project relative path representation of the given path(s),
function relativePaths(root, qualifiedPaths) {
qualifiedPaths = typeof qualifiedPaths === "string" ? [qualifiedPaths] : qualifiedPaths
if (Array.isArray(qualifiedPaths)) {
return qualifiedPaths.map(qualifiedPath => {
const relPath = path.relative(root, qualifiedPath)
return relPath || "."
})
}
return qualifiedPaths
}
function redactCredentials(config) {
if (typeof config !== 'object' || config === null) {
return config;
}
if (Array.isArray(config)) {
return config.map(item => redactCredentials(item));
} else {
const newConfig = Object.assign({}, config);
for (let key in newConfig) {
if (key === 'credentials') {
newConfig[key] = _redacted(newConfig[key]);
} else {
newConfig[key] = redactCredentials(newConfig[key]);
}
}
return newConfig;
}
}
/**
* Masks password-like strings, also reducing clutter in output
* @param {any} cred - object or array with credentials
* @returns {any}
*/
function _redacted(cred) {
if (!cred) return cred
if (Array.isArray(cred)) return cred.map(c => typeof c === 'string' ? '...' : _redacted(c))
if (typeof cred === 'object') {
const newCred = Object.assign({}, cred)
Object.keys(newCred).forEach(k => (typeof newCred[k] === 'string' && SECRETS.test(k)) ? (newCred[k] = '...') : (newCred[k] = _redacted(newCred[k])))
return newCred
}
return cred
}
/**
* Returns a list of fully qualified model names belonging to the '@sap' namespace that cannot be resolved.
* E.g. the module might NOT have been installed.
* @param {Array} modelPaths
* @returns {Array}
*/
function resolveRequiredSapModels(modelPaths) {
return Array.isArray(modelPaths) && modelPaths.filter(p => {
if (p.startsWith('@sap/')) {
const files = cds.resolve(p)
return !files || files.length === 0
}
})
}
async function getDefaultModelOptions(ws = false, filter) {
// Note: requires.toggles holds the actual kind representation and not the value 'true'
const wildcards = cds.env.requires.toggles && cds.env.features.folders ? ['*', cds.env.features.folders] : '*'
let paths = _filterModelPaths(cds.resolve(wildcards, false), filter)
if (ws) {
const workspaces = await getWorkspaces()
const env = cds.env
const root = cds.root
try {
workspaces.forEach(workspace => {
cds.root = path.join(root, workspace)
cds.env = cds.env.for('cds', cds.root)
paths = paths.concat(_filterModelPaths(cds.resolve(cds.env.requires.toggles ? wildcards : '*', false), filter).map(p => normalizePath(path.join(workspace, p))))
})
} finally {
cds.root = root
cds.env = env
}
// exclude redundant built-in models starting with workspace path, e.g. 'books/@sap/cds-mtxs/srv/bootstrap'
paths = paths.filter(p => NODEJS_MODEL_EXCLUDE_LIST.includes(p) || !NODEJS_MODEL_EXCLUDE_LIST.some(ex => p.endsWith(ex)))
}
return paths
}
/**
* For valid paths replace '\\' with '/' and remove trailing '/'. Otherwise return as is
* @param {*} dir
*/
function normalizePath(dir) {
return typeof dir === "string" ? dir.replace(/\\/g, '/').replace(/\/$/, '') : dir
}
function flatten(modelPaths) {
return modelPaths.reduce((acc, m) => {
if (Array.isArray(m)) {
acc = acc.concat(flatten(m))
} else if (m) {
acc.push(m)
}
return acc
}, [])
}
function getI18nDefaultFolder() {
// keep '_i18n' as preferred folder for compatibility reasons
if (cds.env.i18n.folders.includes('_i18n')) return '_i18n'
if (cds.env.i18n.folders.includes('i18n')) return 'i18n'
return cds.env.i18n.folders[0] ?? 'i18n'
}
async function pathExists(path) {
return fs.promises.access(path).then(() => true).catch(() => false);
}
async function getWorkspacePaths() {
const wsRoot = findWorkspaceRoot(cds.root)
if (!wsRoot) {
return []
}
return (await getWorkspaces(wsRoot)).filter(ws => cds.root !== path.join(wsRoot, ws)).map(ws => path.join(wsRoot, ws))
}
/**
* Returns the list of workspaces defined for the current project. A workspace dependency
* needs to be defined resulting in a corresponding sym-link.
* @returns the paths are relative to 'wsRoot'.
*/
async function getWorkspaces(wsRoot, resolve = false, devDependencies = true) {
wsRoot ??= findWorkspaceRoot(cds.root)
if (!wsRoot) {
return []
}
const pgkJsonWsRoot = require(path.join(wsRoot, 'package.json'))
let pgkJson = pgkJsonWsRoot
if (wsRoot !== cds.root) {
pgkJson = require(path.join(cds.root, 'package.json'))
}
const workspaces = []
if (pgkJsonWsRoot.workspaces) {
if (resolve) {
_validateWsDependencies(wsRoot, pgkJson.dependencies, devDependencies ? pgkJson.devDependencies : {})
}
// read npm workspaces
const pkgs = await _execNpmWs(wsRoot)
pkgs.forEach(pkg => {
// only create tarball archives for dependencies with version '*'
if (!resolve || devDependencies && pgkJson.devDependencies?.[pkg] === '*' || pgkJson.dependencies?.[pkg] === '*') {
const workspace = _getWorkspace(wsRoot, pkg)
if (workspace) {
workspaces.push(workspace)
}
}
})
}
DEBUG?.(`Found ${workspaces.length} workspaces ${workspaces.join(', ')}`)
return workspaces
}
function findWorkspaceRoot(currentPath, depth = 0) {
if (depth > WS_MAX_DEPTH) {
DEBUG?.(`No workspaces exist, max depth of ${WS_MAX_DEPTH} reached`)
return
}
const packageJson = path.join(currentPath, 'package.json')
if (fs.existsSync(packageJson)) {
if (require(path.join(currentPath, 'package.json')).workspaces) {
return currentPath
}
}
if (fs.existsSync(path.join(currentPath, '.gitmodules'))) {
return // project root reached
}
if (path.dirname(currentPath) !== currentPath) {
return findWorkspaceRoot(path.dirname(currentPath), depth + 1)
}
}
/**
* This JavaScript function filterFeaturePaths is used to filter and categorize file paths
* from a given model into two categories: base and features.
* @param {*} model - represents a data model. It's expected to have a $sources property which is an array of file paths.
* @param {*} modelPaths - an array of file paths.
* @returns
*/
function filterFeaturePaths(model, modelPaths) {
const ftsName = path.dirname(cds.env.features.folders || 'fts/*')
const regex = new RegExp(`[/|\\\\]+${ftsName}[/|\\\\]+(?<ftName>[^/|\\\\]*)`)
const sources = { base: [] }
// add ROOT source file paths for the base model
sources.base = cds.resolve(modelPaths).reduce((acc, file) => {
const match = file.match(regex)
if (!match) {
acc.push(file)
}
return acc
}, [])
// add source file paths for the features
sources.features = model['$sources'].reduce((acc, file) => {
const match = file.match(regex)
if (match) {
const { ftName } = match.groups
//feature
if (!acc[ftName]) {
acc[ftName] = []
}
acc[ftName].push(file)
}
return acc
}, {})
return sources
}
/**
* Read installed npm workspaces using 'npm ls -ws --json'.
* @returns {Array} list of package paths, or an empty array if either
* no workspaces are defined or have not been installed.
*/
async function _execNpmWs(wsRoot) {
try {
const cmdLine = 'npm ls -ws --json'
DEBUG?.(`execute ${cmdLine}`)
let result = await execAsync(cmdLine, { shell: IS_WIN, stdio: ['inherit', 'pipe', 'inherit'], cwd: wsRoot })
// DEBUG?.(result.stdout)
result = JSON.parse(result.stdout)
if (result.dependencies) {
return Object.keys(result.dependencies)
} else if (result.name) {
return [result.name]
}
} catch (e) {
e.stderr = e.stderr?.replace(/npm ERR! /g, '')
throw e
}
return []
}
function _getWorkspace(wsRoot, pkg) {
try {
const pkgPath = require.resolve(path.join(pkg, 'package.json'), { paths: [wsRoot] })
if (!pkgPath.match(/(\/|\\)node_modules(\/|\\)?/) && pkgPath.startsWith(wsRoot)) {
return path.relative(wsRoot, path.dirname(pkgPath))
}
} catch (e) {
// ignore - workspace dependencies have already been validated
}
}
function _validateWsDependencies(wsRoot, dependencies, devDependencies) {
const allDependencies = { ...dependencies, ...devDependencies }
for (const name in allDependencies) {
if (allDependencies[name] === '*') {
try {
require.resolve(path.join(name, 'package.json'), { paths: [wsRoot] })
} catch (e) {
if (e.code === 'MODULE_NOT_FOUND') {
throw new BuildError(`npm packaging failed, module '${name}' not found. Make sure configured npm workspaces are installed.`)
}
throw e
}
}
}
}
/**
* Returns a flat array of existing files and folders, trailing '/' or '\\' characters have been removed.
* @param {Array} modelPaths array of files and folders - optionally nested
* @returns
*/
function _filterModelPaths(modelPaths, filter) {
const model = new Set()
// may contain nested arrays
modelPaths = flatten(modelPaths)
const { roots } = cds.env
modelPaths.forEach(m => {
if (m && !model.has(m) && !model.has(m + "/")) {
// filter roots and absolute model paths that do not exist
if (roots.includes(m)) {
const dir = path.resolve(cds.root, m)
if (fs.existsSync(dir)) {
model.add(m)
} else if (fs.existsSync(dir + FILE_EXT_CDS)) { //might be cds file name, compatibility to old build configs
model.add(m)
}
} else {
model.add(m)
}
}
})
return [...model].filter(m => filter ? filter(m) : true).map(m => normalizePath(m))
}
/**
* Returns a set of files found in the given directory and its subdirectories.
* @param {string} srcDir - the directory to search for files
* @param {Function} filter - the filter function to apply to the found files
* @param {Array} files - the set of files to add the found files to
* @returns {Array} files found in the given directory and its subdirectories
*/
function getFiles(srcDir, filter, files = []) {
function handleResource(entry, filter, files) {
if (!filter || filter.call(this, entry)) {
const stats = fs.lstatSync(entry)
if (stats.isDirectory()) {
getFiles(entry, filter, files)
} else if (stats.isFile() || stats.isSymbolicLink()) {
files.push(entry)
}
}
}
let entries = []
try {
entries = fs.readdirSync(srcDir)
} catch (e) {
// ignore if not existing
}
entries.map(subDirEntry => path.join(srcDir, subDirEntry)).forEach((entry) => {
handleResource(entry, filter, files)
})
return files
}
/**
* Return gnu-style error string for location `loc`:
* - 'File:Line:Col' without `loc.end`
* - 'File:Line:StartCol-EndCol' if Line = start.line = end.line
* - 'File:StartLine.StartCol-EndLine.EndCol' otherwise
*
* @param {CSN.Location|CSN.Location} location
*/
function _locationString(loc) {
if (!loc)
return '<???>';
if (!(loc instanceof Object))
return loc;
if (!loc.line) {
return loc.file;
}
else if (!loc.endLine) {
return (loc.col)
? `${loc.file}:${loc.line}:${loc.col}`
: `${loc.file}:${loc.line}`;
}
return (loc.line === loc.endLine)
? `${loc.file}:${loc.line}:${loc.col}-${loc.endCol}`
: `${loc.file}:${loc.line}.${loc.col}-${loc.endLine}.${loc.endCol}`;
}
/**
* Class for individual build message.
*
* @class BuildMessage
*/
class BuildMessage {
/**
* Creates an instance of BuildMessage.
* @param {string} message The message text
* @param {string} [severity='Error'] Severity: Debug, Info, Warning, Error
* @param {any} location Location of the message
*
* @memberOf BuildMessage
*/
constructor(message, severity = SEVERITY_ERROR, location) {
this.message = message
this.name = "BuildMessage"
this.severity = severity
this.$location = location
}
toString() {
return `${this.$location?.file ? _locationString(this.$location) + ':' : ''} ${this.severity}: ${this.message}`
}
}
/**
* Class for combined build and compiler errors.
* Additional members:
* messages: array of detailed build messages
* @class BuildError
* @extends {Error}
*/
class BuildError extends Error {
constructor(message, messages = []) {
super(message)
this.name = "BuildError"
this.messages = Array.isArray(messages) ? messages : [messages]
}
// for compatibility reasons
get errors() {
return this.messages
}
toString() {
return this.message + (this.messages.length > 0 ? '\n' + this.messages.map(m => m.toString()).join('\n') : '')
}
}
module.exports = {
getProperty,
setProperty,
hasJavaNature,
redactCredentials,
hasOptionValue,
relativePaths,
resolveRequiredSapModels,
getDefaultModelOptions,
flatten,
getI18nDefaultFolder,
normalizePath,
pathExists,
getWorkspacePaths,
getWorkspaces,
findWorkspaceRoot,
filterFeaturePaths,
getFiles,
BuildMessage,
BuildError
}