@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
551 lines (490 loc) • 20.5 kB
JavaScript
const path = require('node:path')
const { inspect } = require('node:util')
// @ts-expect-error - _log not declared in cds-types
const cds = require('../cds'), { _log: log } = cds
const { exists, isdir, rimraf, mkdirp, write, fs, colors: { YELLOW, RESET } } = cds.utils
const Plugin = require('./plugins/plugin')
const { relativePaths, BuildError, BuildMessage, resolveRequiredSapModels, normalizePath, getDefaultModelPaths, hasOptionValue } = require('./util')
const { sortMessagesSeverityAware, deduplicateMessages, CompilationError } = cds.compiler
const { SEVERITIES, SEVERITY_ERROR, LOG_LEVELS, DEFAULT_SRC_FOLDER, OPTION_WS, IGNORE_DEFAULT_MODELS, NODEJS_MODEL_EXCLUDE_LIST, BUILD_TASK_NODEJS, BUILD_TASK_NODE_CF, BUILD_TASK_JAVA_CF, BUILD_TASK_JAVA } = require('./constants')
const COMPILATION_ERROR = 'CompilationError'
const COMPILE_MESSAGE = 'CompileMessage'
const DEBUG = cds.debug('cli|build')
const LOG_LIMIT = 30
/**
* @typedef {import('./plugins/plugin').Task} Task
* @typedef {import('../env/schemas/cds-rc')['$defs']['buildTaskType']['enum'][number]} BuildFor
* using (string & {}) prevents widening of `'x' | string` to just `string`
* @typedef {{
* clean?: boolean,
* for?: BuildFor | (string & {}),
* src?: import('node:fs').PathLike,
* dest?: import('node:fs').PathLike,
* model?: string[],
* root?: import('node:fs').PathLike
* tasks?: Task[]
* } & {[key:string]:unknown}} BuildOptions
*/
/**
* Executes cds build with specified options.
*
* @param {BuildOptions} options - command options
*/
function build({ root = cds.root, tasks, clean = true, ...options } = {}) {
if (!exists(root) || !isdir(root)) {
throw `Project folder '${root}' does not exist`
}
const runner = createBuildRunner({ ...options, clean })
if (options.resolve) return resolveBuildTasks(runner)
return processTasks(tasks, runner)
}
function register(id, plugin) {
return require('./plugins').register(id, plugin)
}
/** @param {BuildOptions} options - command options */
function createBuildRunner(options) {
for (const key of ['for', 'src', 'dest']) if (options[key]) {
if (typeof options[key] !== 'string') {
throw new BuildError(`Invalid build options - property '${key}' must be a string`)
}
if (key === 'src' || key === 'dest') {
options[key] = normalizePath(options[key])
}
}
const context = { options, tasks: [] }
const plugins = require('./plugins')
plugins.registerDefaults()
return { options, context, plugins: plugins.all }
}
async function processTasks(tasks, runner) {
const startTime = Date.now()
tasks ??= await resolveBuildTasks(runner)
console.log(`building project with`, inspect({
versions: { cds: cds.version, compiler: cds.compiler.version(), dk: require('../../package.json').version },
target: cds.env.build.target,
tasks,
...(!runner.options.clean && { clean: runner.options.clean }),
}, { depth: 11, colors: cds.utils.colors.enabled, compact: 3, breakLength: 250 }))
const unresolved = resolveRequiredSapServices(tasks)
if (unresolved.length > 0) {
throw new BuildError(`Required CDS models [${unresolved.join(', ')}] cannot be resolved. Run the 'npm install' command to install up-to-date versions of the missing packages.`)
}
const plugins = []
tasks.forEach((task) => {
if (task) {
const plugin = createPlugin(task, runner)
plugin.init()
plugins.push(plugin)
}
})
try {
await executeCleanBuildTasks(plugins, runner)
const buildResult = await executeBuildTasks(plugins, runner)
await writeGenerationLog(plugins)
logOutput(plugins)
logMessages(plugins.reduce((acc, plugin) => acc.concat(plugin.messages), []), runner.options)
console.log(`build completed in ${Date.now() - startTime} ms`)
return buildResult
} catch (error) {
logOutput(plugins)
throw error
}
}
async function resolveBuildTasks(runner) {
const tasks = await createTasks(runner)
const resolvedTasks = resolveTasks(tasks, runner.options)
if (runner.options.resolve) return resolvedTasks
return tasks
}
function createPlugin(task, runner) {
const PluginClass = getPlugin(task, runner.plugins)
const resolvedTask = resolveTask(task, runner.options.ws)
DEBUG?.(`loaded build plugin [${resolvedTask.for}]`)
const plugin = new PluginClass()
if (!(plugin instanceof PluginClass)) {
throw new Error(`Invalid Build plugin type ${task.for}`)
}
plugin._task = resolvedTask
plugin._context = runner.context
runner.context.tasks.push(resolvedTask)
DEBUG?.(`created build plugin [${resolvedTask.for}]`)
return plugin
}
async function createTasks(runner) {
DEBUG?.(`determining build tasks for project [${cds.root}].`)
let tasks = Array.isArray(cds.env.build?.tasks) ? cds.clone(cds.env.build.tasks) : []
if (tasks.length === 0) {
tasks = await lookupTasks([], false, runner)
applyCliOptions(tasks, runner.options)
} else {
applyCliOptions(tasks, runner.options)
await applyTaskDefaults(tasks, runner)
await lookupTasks(tasks, true, runner)
}
let existingTasks = tasks
tasks = await filterTasksForCli(tasks, runner)
if (tasks.length === 0) return tasks
existingTasks = [...tasks]
await lookupTasks(tasks, true, runner)
if (tasks.length > existingTasks.length) {
const newTasks = tasks.filter(task => !existingTasks.includes(task))
applyCliOptions(newTasks, runner.options)
}
await applyCommonTaskDefaults(tasks)
if (cds.env['project-nature'] === 'java' && cds.env.build.target !== '.') {
const userEnv = cds.env.for('cds', cds.root, false)
if (!userEnv.build?.target) {
cds.env.build.target = '.'
DEBUG?.('using inplace build for java project instead of default staging build')
}
}
return tasks
}
async function applyCommonTaskDefaults(tasks) {
tasks.forEach(task => {
if (task.options?.model && !Array.isArray(task.options.model)) {
task.options.model = [task.options.model]
}
})
const modelPaths = await getDefaultModelPaths(false)
let wsModelPaths
if (cds.cli.options?.[OPTION_WS] || tasks.some(task => hasOptionValue(task.options?.[OPTION_WS], true))) {
wsModelPaths = await getDefaultModelPaths(true)
}
tasks.forEach(task => {
if (!task.src) {
throw new BuildError(`Mandatory property 'src' not defined for build task '${task.for}'.`)
}
const optionWs = cds.cli.options?.[OPTION_WS] || hasOptionValue(task.options?.[OPTION_WS], true)
setTaskModelOptions(task, optionWs ? [...wsModelPaths] : [...modelPaths])
})
}
async function filterTasksForCli(tasks, runner) {
const options = runner.options
let resultTasks = tasks.filter(task => {
return (!options.for || options.for === task.for)
&& (!options.src || options.src === task.src)
})
if (resultTasks.length === 0) {
if (options.for) {
const task = getTask({ for: options.for }, runner.plugins)
if (options.src) task.src = options.src
resultTasks.push(task)
applyCliOptions(resultTasks, options)
await applyTaskDefaults(resultTasks, runner)
}
} else if (resultTasks.length <= tasks.length) {
tasks.length = 0
resultTasks.forEach(task => tasks.push(task))
resultTasks = tasks
}
return resultTasks
}
function applyCliOptions(tasks, options) {
tasks.forEach(task => {
if (options.dest) task.dest = options.dest
if (options.taskOptions) {
const taskOptions = cds.clone(options.taskOptions)
task.options = task.options ? Object.assign(task.options, taskOptions) : taskOptions
}
})
}
function escapeRegExpInput(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function setTaskModelOptions(task, defaultModelPaths) {
if (task.for !== BUILD_TASK_NODEJS && task.for !== BUILD_TASK_NODE_CF) {
defaultModelPaths = defaultModelPaths.filter(p => !NODEJS_MODEL_EXCLUDE_LIST.includes(p))
}
let taskModelPaths = task.options?.model
if (taskModelPaths && !Array.isArray(taskModelPaths)) {
taskModelPaths = [taskModelPaths]
}
const allowList = new RegExp(`^@sap/cds${cds.env.requires.toggles
? '|^' + escapeRegExpInput(cds.env.features.folders.replace('*', ''))
: ''}`)
task.options = task.options || {}
let modelPaths = []
if (taskModelPaths?.length) {
if (!hasOptionValue(task.options?.[IGNORE_DEFAULT_MODELS], true)) {
modelPaths = defaultModelPaths.filter(p => p.match(allowList))
}
} else {
modelPaths = [...defaultModelPaths]
if (!modelPaths.includes(task.src)) modelPaths.push(task.src)
if (hasOptionValue(task.options?.[IGNORE_DEFAULT_MODELS], true)) {
modelPaths = modelPaths.filter(p => !p.match(allowList))
}
}
task.options.model = [...new Set(taskModelPaths?.length ? taskModelPaths.concat(modelPaths) : modelPaths)]
}
function getTask(key, plugins) {
normalizeKey(key)
assertProvided(key, plugins)
const task = cds.clone(plugins.get(key.for)?.taskDefaults ?? {})
task.for = key.for
task.src ??= DEFAULT_SRC_FOLDER
task.src = task.src.replace(/\/$/, '')
return task
}
async function applyTaskDefaults(tasks, runner) {
return Promise.all(tasks.map(async (task) => {
if (!task.for) throw new BuildError(`Mandatory property 'for' not defined for build task.`)
return applyTaskDefault(task, runner)
}))
}
async function lookupTasks(tasks = [], dependencies, runner) {
const existingTasks = [...tasks]
if (!dependencies) {
for (const [id, PluginClass] of runner.plugins) {
if (PluginClass.hasTask()) tasks.push(getTask({ for: id }, runner.plugins))
}
} else {
const toAdd = []
for (const task of tasks) {
const PluginClass = getPlugin(task, runner.plugins)
const reqs = new PluginClass().requires || []
for (const req of reqs) {
const reqId = normalizeKey({ for: req }).for
const ReqPluginClass = runner.plugins.get(reqId)
if (ReqPluginClass && !tasks.some(t => t.for === reqId) && !toAdd.some(t => t.for === reqId)) {
const defaultTaskSrc = getTask({ for: reqId }, runner.plugins).src
const configuredTasks = cds.env.build?.tasks?.filter(t => t.for === reqId)
let configuredTask
if (configuredTasks?.length) {
configuredTask = configuredTasks.find(t => !t.src || t.src === defaultTaskSrc) || configuredTasks[0]
}
if (configuredTask) {
configuredTask = cds.clone(configuredTask)
await applyTaskDefault(configuredTask, runner)
toAdd.push(configuredTask)
} else if (ReqPluginClass.hasTask?.()) {
toAdd.push(getTask({ for: reqId }, runner.plugins))
}
}
}
}
tasks.push(...toAdd)
}
if (existingTasks.length < tasks.length) {
const newTasks = tasks.filter(task => !existingTasks.includes(task))
await Promise.all(newTasks.map(t => applyTaskDefault(t, runner)))
DEBUG?.(`Build task provider build returned build tasks ${JSON.stringify(newTasks)}`)
}
return tasks
}
/**
* Loads the build plugin implementation for the given build task.
* @param {Task} task
* @param {Map<string, typeof import('./plugins/plugin')>} plugins
* @returns {typeof import('./plugins/plugin')}
*/
function getPlugin(task, plugins) {
normalizeKey(task)
assertProvided(task, plugins)
try {
return plugins.get(task.for)
} catch (e) {
console.error(`Provider failed to load build plugin class ${task.for} - provider: build`)
throw e
}
}
function resolveTasks(tasks, options = {}) {
return tasks.map(task => resolveTask(task, options.ws))
}
function resolveTask(task, ws) {
const resolvedTask = cds.clone(task)
resolvedTask.src = path.resolve(cds.root, task.src)
if (!ws) {
try {
fs.realpathSync(resolvedTask.src)
} catch {
throw new BuildError(`The 'src' folder '${path.resolve(cds.root, task.src)}' for build task '${resolvedTask.for}' does not exist`)
}
}
resolvedTask.dest = path.resolve(cds.root, cds.env.build.target, task.dest || task.src)
resolvedTask.options = task.options || {}
return resolvedTask
}
function normalizeKey(key) {
const compat = { [BUILD_TASK_NODE_CF]: BUILD_TASK_NODEJS, [BUILD_TASK_JAVA_CF]: BUILD_TASK_JAVA }
if (compat[key.for]) {
console.warn(`${YELLOW}Build task '${key.for}' is deprecated, use '${compat[key.for]}' instead${RESET}`)
key.for = compat[key.for]
}
return key
}
function assertProvided(key, plugins) {
if (!plugins.has(key.for)) {
throw new BuildError(`No provider found for build task '${key.for}'. Ensure that all required dependencies have been added and 'npm install' has been executed.`)
}
}
async function applyTaskDefault(task, runner) {
const defaultTask = getTask(task, runner.plugins)
const names = Object.getOwnPropertyNames(defaultTask)
names.forEach(name => {
task[name] ??= defaultTask[name]
})
}
async function executeCleanBuildTasks(plugins, runner) {
if (!runner.options.clean) return
if (cds.env.build.target !== '.') {
const target = path.resolve(cds.root, cds.env.build.target)
DEBUG?.(`cleaning staging folder ${target}`)
await rimraf(target)
}
const results = await Promise.allSettled(plugins.map((plugin) => {
DEBUG?.(`cleaning, plugin [${plugin.constructor.name}], src [${relativePaths(cds.root, plugin.task.src)}]`)
return plugin.clean()
}))
resolveHandlerResponse(results, [], runner.options)
}
async function executeBuildTasks(plugins, runner) {
const levels = new Map()
const getLevel = (PluginClass) => {
if (levels.has(PluginClass)) return levels.get(PluginClass)
const plugin = new PluginClass()
if (plugin.priority && plugin.priority < 0) {
const level = Number.MAX_SAFE_INTEGER
levels.set(PluginClass, level)
return level
}
let maxDepLevel = -1
const reqs = plugin.requires || []
for (const req of reqs) {
const reqId = normalizeKey({ for: req }).for
const ReqPluginClass = runner.plugins.get(reqId)
if (ReqPluginClass) maxDepLevel = Math.max(maxDepLevel, getLevel(ReqPluginClass))
}
const level = maxDepLevel + 1
levels.set(PluginClass, level)
return level
}
plugins.forEach(p => getLevel(p.constructor))
const groupsMap = new Map()
plugins.forEach(p => {
const lvl = levels.get(p.constructor)
if (!groupsMap.has(lvl)) groupsMap.set(lvl, [])
groupsMap.get(lvl).push(p)
})
const sortedLevels = [...groupsMap.keys()].sort((a, b) => a - b)
const pipeline = sortedLevels.map(lvl => groupsMap.get(lvl))
const results = await executePipeline(pipeline, runner.options)
return resolveHandlerResponse(results, plugins.flatMap(plugin => plugin.messages), runner.options)
}
async function executePipeline(pipeline, options) {
let allResults = []
for (const group of pipeline) {
const results = await Promise.allSettled(group.map((plugin) => {
DEBUG?.(`building, plugin [${plugin.constructor.name}], src [${relativePaths(cds.root, plugin.task.src)}]`)
return plugin.build().then(pluginResult => Promise.resolve({
task: plugin.task,
result: pluginResult,
messages: sortMessagesUnique(options, plugin.messages)
}))
}))
allResults = allResults.concat(results)
}
return allResults
}
function resolveHandlerResponse(results, pluginMessages = [], options) {
/** @type {(BuildError | Error)[]}*/
const errors = []
const resolvedResults = results.reduce((acc, r) => {
if (r.status === 'fulfilled') acc.push(r.value)
if (r.status === 'rejected' && r.reason) errors.push(r.reason)
return acc
}, [])
if (errors.length > 0) {
const error = errors.find(e => e.constructor.name !== COMPILATION_ERROR)
if (error) {
if (!(error instanceof BuildError)) throw error
error.messages = error.messages?.map(m => typeof m === 'string' ? new BuildMessage(m, SEVERITY_ERROR) : m)
error.messages = sortMessagesUnique(options, pluginMessages.filter(m => m instanceof BuildMessage), error.messages)
throw error
}
const compileErrors = errors.filter(e => e.constructor.name === COMPILATION_ERROR)
const compileMessages = pluginMessages.filter(message => message.constructor.name === COMPILE_MESSAGE)
if (compileErrors.length) {
throw new CompilationError(sortMessagesUnique(options, getErrorMessages(compileErrors), compileMessages))
}
}
return resolvedResults
}
function logOutput(plugins) {
const files = getOutput(plugins)
if (files.length > 0) {
const term = require('../util/term')
if (!process.env.DEBUG && files.length > LOG_LIMIT) {
const length = files.length
files.length = LOG_LIMIT
files.push(`... ${length - files.length} more. Run with DEBUG=build to show all files.`)
}
console.log(`done > wrote output to:\n ${term.dim(files.join("\n "))}\n`)
}
}
async function writeGenerationLog(plugins) {
const outputFile = cds.env.build.outputfile || process.env.GENERATION_LOG
if (outputFile) {
const files = getOutput(plugins)
console.log(`writing generation log to [${outputFile}]\n`)
await mkdirp(path.dirname(outputFile))
await write(outputFile, files.join('\n'))
}
}
function getOutput(plugins) {
const files = plugins.flatMap(plugin => plugin.files).sort()
return files.map(file => {
if (path.isAbsolute(cds.env.build.target)) return file
return path.relative(cds.root, file)
})
}
function logMessages(messages, options) {
if (messages.length > 0) {
const logOptions = {
log: console.log,
'log-level': getLogLevel(options)
}
deduplicateMessages(messages)
sortMessagesSeverityAware(messages)
log(messages, logOptions)
}
}
function resolveRequiredSapServices(tasks) {
const taskModelPaths = tasks.reduce((acc, task) => {
const model = task.options?.model
if (model) {
if (Array.isArray(model)) model.forEach(m => acc.add(m))
else acc.add(model)
}
return acc
}, new Set())
return resolveRequiredSapModels([...taskModelPaths])
}
/**
* @param {(string | BuildError)[]} errors
*/
function getErrorMessages(errors) {
let messages = []
errors.forEach(error => {
if (typeof error !== 'string' && Array.isArray(error.errors) && error.errors.length > 0) {
messages = messages.concat(getErrorMessages(error.errors))
} else {
messages.push(error)
}
})
return messages
}
function sortMessagesUnique(options, ...messages) {
const logLevelIdx = LOG_LEVELS.indexOf(getLogLevel(options))
messages = messages.reduce((acc, m) => acc.concat(m), [])
const filteredMessages = messages.filter(message => message && (!message.severity || logLevelIdx >= SEVERITIES.indexOf(message.severity)))
// @ts-expect-error - formally expects CompileMessage, but actually, an .severity is the only expected property
deduplicateMessages(filteredMessages)
// @ts-expect-error - see above
return sortMessagesSeverityAware(filteredMessages)
}
function getLogLevel(options) {
return options['log-level'] || cds.env['log-level']
}
module.exports = { build, register, Plugin, BuildError }