UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

551 lines (490 loc) 20.5 kB
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 }