UNPKG

@sap/cds-dk

Version:

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

281 lines (251 loc) 10 kB
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 } }