@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
281 lines (251 loc) • 10 kB
JavaScript
const path = require('node:path')
const fs = require('node:fs')
const os = require('node:os');
const cds = require('../../cds')
const IS_WIN = os.platform() === 'win32';
const { SEVERITY_INFO, SEVERITY_WARNING, SEVERITY_ERROR } = require('../constants')
const { find } = require('../../util/fs')
const { hasOptionValue, relativePaths, BuildMessage, filterFeaturePaths } = require('../util')
const DEBUG = cds.debug('cli|build')
/**
* @typedef {{ src?: string, dest?: string, options?: object } & Record<string, any>} TaskDefaults
* @typedef {{ src: string, dest: string, for: string, options: object } & Record<string, any>} Task
* @typedef {{ options: Record<string, any>, tasks: Task[] }} Context
* @callback FileWriterTo
* @param {string} dest - absolute or relative file path. Relative paths will be resolved to this task's destination path.
* @returns {Promise<void>} - resolves when the file has been written
* @typedef {Object} FileWriter
* @property {FileWriterTo} to
*/
/**
* The build plugin creates the build output for a dedicated build task. It is uniquely identified
* by the build task's `for<` or `use` property. The `use` property represents
* the fully qualified node module path of the build plugin implementation.
* The build task engine defines the following protocol. The methods are invoked in descending order:
* - `init()` - optional
* - `async clean()`
* - `async build()`
* The reflected CSN can be accessed using the async method `model()`.
*/
module.exports = class Plugin {
static INFO = SEVERITY_INFO
static WARNING = SEVERITY_WARNING
static ERROR = SEVERITY_ERROR
/** @type {TaskDefaults} */
taskDefaults // injected externally
/**
* @type {Task | undefined}
*/
_task
/**
* @type {Context | undefined}
*/
_context
/**
* @type {Set<string>}
* @private
*/
_files = new Set()
/**
* Returns the list of files and folders written by this build plugin.
*/
get files() {
return [...this._files]
}
/**
* @type {(string | BuildMessage)[]}
* @private
*/
_messages = []
/**
* Returns a list of build and compiler messages created by this build plugin.
* Supported message severities are 'Info', 'Warning', and 'Error'.
*/
get messages() {
return this._messages
}
/**
* Determines whether a task of this type will be created when cds build is executed,
* returns true by default.
*/
static hasTask() {
return true
}
/**
* Returns the build task executed by this build plugin.
* @returns {Task}
*/
get task() {
// @ts-expect-error - possibly undefined, but expected to be injected by framework
return this._task
}
/**
* Returns the build context
* @returns {Context}
*/
get context() {
// @ts-expect-error - possibly undefined, but expected to be injected by framework
return this._context
}
get requires() { return [] }
/**
* Called by the framework immediately after this instance has been created.
* The instance has already been fully initialized.
*/
init() {
if (cds.env.build.target === '.') {
this.task.dest = path.join(this.task.dest, 'gen')
}
}
/**
* @abstract
* Called by the framework to create the artifacts of this build plugin.
*/
async build() { /* abstract */ }
/**
* Called by the framework immediately before 'build' to delete any output created by this build plugin.
* Note: The common generation target folder is cleaned by default if the build is
* executed in staging mode, e.g. build.target: "gen".
*/
async clean() {
/**
* @param {string} parent - absolute path of the parent directory
* @param {string} child - absolute path of the child directory
*/
function isSubDirectory(parent, child) {
return !path.relative(parent, child).startsWith('..')
}
if (cds.env.build.target === '.'
&& this.task.src !== this.task.dest
&& !isSubDirectory(this.task.dest, this.task.src)
&& fs.existsSync(this.task.dest)) {
return fs.promises.rm(this.task.dest, { force: true, recursive: true })
}
}
/**
* Asynchronously write the given content to a given file path.
* If the file exists the content is replaced. If the file does not exist, a new file will be created.
* The file name is stored in the list of files written by this build plugin.
* @param {object | Buffer} data - If data is of type object the JSON-stringified version is written.
* @returns {FileWriter}
*/
write(data) {
return {
to: async (/** @type {string} */ dest) => {
if (!path.isAbsolute(dest)) {
// relative to build task's destination path
dest = path.resolve(this.task.dest, dest)
}
this.pushFile(dest)
await fs.promises.mkdir(path.dirname(dest), { recursive: true })
await fs.promises.writeFile(dest, typeof data === "object" && !Buffer.isBuffer(data) ? JSON.stringify(data, null, 2) : data)
}
}
}
/**
* Asynchronously copies a single file or the entire directory structure from 'src' to 'dest', including subdirectories and files.
*
* Note: The file names are stored in the list of files written by this build plugin.
*
* @param {string} src - The absolute or relative source path of the file or directory to copy.
* Relative paths will be resolved to this task's source path.
* @returns {FileWriter}
*
*/
copy(src) {
return {
to: async (/** @type {string} */ dest) => {
if (!path.isAbsolute(src)) {
// relative to build task's source path
src = path.resolve(this.task.src, src)
}
if (!path.isAbsolute(dest)) {
// relative to build task's destination path
dest = path.resolve(this.task.dest, dest)
}
// list all copied files recursively
if (cds.utils.isdir(src)) {
await fs.promises.cp(src, dest, { recursive: true })
const files = await find(dest)
files.forEach(file => this.pushFile(file))
} else {
this.pushFile(dest)
if (IS_WIN && fs.lstatSync(src).isSymbolicLink()) {
const linkTarget = await fs.promises.readlink(src);
src = path.resolve(path.dirname(src), linkTarget);
}
return fs.promises.cp(src, dest, { recursive: true })
}
}
}
}
/**
* Adds the given user message and severity to the list of messages issued by this build task.
*
* User messages will be logged after CDS build has been finished based on the log-level that has been set.
* By default messages with severity <em>warning</em> and <em>error</em> will be logged.
* @param {string} message - the message text
* @param {import('../constants').Severity} severity - the severity of the message
*/
pushMessage(message, severity) {
this.messages.push(new BuildMessage(message, severity))
}
/**
* Returns a compiled CSN model according to the model paths defined by this build task.
* The model includes model enhancements defined by feature toggles, if any.
* @returns {Promise<import('@sap/cds').csn.CSN | null>} the compiled CSN model
*/
async model() {
const { src, options: { model }, for: type } = this.task
const files = cds.resolve(model || src)
if (!files || files.length === 0) {
console.log(`no CDS model found for [${type}] build task [${src}] - nothing to be done`)
return
}
DEBUG?.(`model: ${relativePaths(cds.root, files).join(", ")}`)
const options = { ...this.options(), cwd: cds.root }
return cds.load(files, options)
}
/**
* Returns a compiled base model CSN according to the model paths defined by this build task.
* The base model does not include any model enhancements defined by feature toggles.
* @returns {ReturnType<Plugin['model']>} the compiled base model CSN
*/
async baseModel() {
const model = await this.model()
if (!model) {
return model
}
const sources = filterFeaturePaths(model, this.task.options.model || this.task.src)
if (!sources.features) {
return model
}
const options = this.options()
return cds.load(sources.base, options)
}
options() {
return { messages: this._messages }
}
/**
* Adds the given fully qualified file path to the list of files that are written by this build task.
* @param {string} filePath - fully qualified file path
*/
pushFile(filePath) {
this._files.add(filePath)
}
/** Determines whether the given build option value has been set for this build task.
* If the value is omitted, the existence of the given property name is checked.
*/
_hasBuildOption(qualifiedName, value) {
return hasOptionValue(this._getBuildOption(qualifiedName), value)
}
// Returns the value of the given build option defined for this build task.
_getBuildOption(qualifiedName) {
// build task options overwriting other settings
let value = this.context.options[qualifiedName] // command line
if (value === undefined) {
value = this.task.options[qualifiedName] // task options
}
return value
}
}