@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
336 lines (318 loc) • 18.5 kB
JavaScript
const cds = require('../cds')
const { join, resolve, basename } = require('node:path')
const { execSync } = require('node:child_process')
const { fs, exists, isfile, isdir, path } = cds.utils
const { parseXml } = require('./xml')
const { REGEX_JAVA_VERSION, JAVA_LTS_VERSIONS } = require('./constants')
const TRACE = cds.debug('trace')
module.exports = new class ProjectReader {
constructor() {
this.readProject = this.readProject.bind(this)
this.cachedWorkspaceRoot = null
}
#setPackageInfo() {
this.isJava = (exists('pom.xml') || cds.cli.options?.add?.has('java'))
&& !cds.env.for('cds').profiles.includes('node') // opt-out for hybrid projects like sflight
// 1. Use project name and static default values
this.appVersion = this.isJava ? '1.0.0-SNAPSHOT' : '1.0.0'
this.appName = this.appId = basename(cds.root)
this.appDescription = 'A simple CAP project.'
if (this.isJava) {
let artifactId, description, version, properties
try {
({ artifactId, description, version, properties } = parseXml(resolve(cds.root, 'pom.xml')) ?? {})
} catch (e) {
if (e.code !== 'ENOENT') throw e
}
const v = version?.[0] === '${revision}' ? properties?.[0]?.revision[0] : version?.[0] ?? '1.0.0-SNAPSHOT'
if (v && REGEX_JAVA_VERSION.test(v)) this.appVersion = v
this.jdkVersion = properties?.[0]?.['jdk.version']?.[0]?._text?.[0] ?? JAVA_LTS_VERSIONS[JAVA_LTS_VERSIONS.length - 1]
if (artifactId?.[0]) this.appName = this.appId = artifactId?.[0]?.split(/-parent/)[0]
if (description?.[0]) this.appDescription = description[0]
}
else if (exists('package.json')) {
const { name, version, description } = JSON.parse(fs.readFileSync(join(cds.root, 'package.json')))
this.appVersion = version ?? this.appVersion
const segments = (name ?? this.appName).trim().replace(/@/g, '').split('/').map(encodeURIComponent)
this.appName = segments[segments.length - 1]
this.appId = segments.join('.')
this.appDescription = description ?? this.appDescription
}
}
/**
* Returns cds.env using 'production' profile by default as mta deployment is executed with having production profile set.
*/
env4(profile = 'production') {
const nodeEnv = process.env.NODE_ENV
const cdsEnv = process.env.CDS_ENV
process.env.CDS_ENV = process.env.NODE_ENV = profile
try {
return cds.env.for('cds')
} finally {
cdsEnv ? process.env.CDS_ENV = cdsEnv : delete process.env.CDS_ENV
nodeEnv ? process.env.NODE_ENV = nodeEnv : delete process.env.NODE_ENV
}
}
/**
* Reads the project configuration and constructs a project object.
* This function dynamically resolves project properties and returns an object with these properties.
*
* @returns {ProjectConfig} An object representing the project configuration.
*/
readProject() {
if (!this.appName) this.#setPackageInfo()
const env = this.env4('production')
const { appVersion, appName, appId, appDescription, jdkVersion } = this
TRACE?.({ env })
const pkgJson = () => exists('package.json') ? JSON.parse(fs.readFileSync(join(cds.root, 'package.json'))) : {}
const _isESM = () => pkgJson().type === 'module'
const _isTypescript = () => !!pkgJson().dependencies?.typescript || !!pkgJson().devDependencies?.typescript
const _hasTyper = () => !!pkgJson().dependencies?.['@cap-js/cds-typer'] || !!pkgJson().devDependencies?.['@cap-js/cds-typer']
const _inProd = plugin => require(`./template/${plugin}`).hasInProduction(env) || cds.cli.command === 'add' && cds.cli.argv[0]?.split(',').includes(plugin) || cds.cli.options.add?.has(plugin)
const _uiEmbedded = () => exists(join(env.folders.app, 'xs-app.json'))
const _uiModule = () => !_uiEmbedded() && isdir(env.folders.app) && fs.readdirSync(resolve(cds.root, env.folders.app)).length > 0
const _isJava = () => this.isJava
const _extends = () => pkgJson().extends
const isApp = name => {
return name !== 'appconfig' && !name.match(/^[._]/) && name !== 'router' && name !== 'portal' && name !== 'workzone' && name !== 'html5-deployer'
}
const reserved = {
dependencies: () => pkgJson().dependencies,
devDependencies: () => pkgJson().devDependencies,
has: x => _inProd(x),
profile: () => cds.cli.options?.for,
profileSpecified: () => !!cds.cli.options.for,
profileProduction: () => cds.cli.options?.for === 'production',
language: () => _isJava() ? 'java' : 'nodejs',
isJava: () => _isJava(),
isNodejs: () => !_isJava(),
isTypescript: () => _isTypescript(),
isESM: () => _isESM,
hasEsmStyle: () => _isTypescript() || _isESM(),
hasTyper: () => _hasTyper(),
srvPath: () => join(env.build.target, env.folders.srv.replace(/\/$/, '')).replace(/\\/g, '/'),
archiveName: () => {
const pomXmlPath = resolve(cds.root, env.folders.srv, 'pom.xml')
const pom = exists(pomXmlPath) ? parseXml(pomXmlPath) : {}
const { artifactId = [basename(cds.root)], packaging = ['jar'] } = pom
return artifactId[0] + '-exec.' + packaging[0]
},
db: () => {
const folder = env.folders.db
const name = folder.replace(/\/$/, '')
const path = join(env.build.target, name).replace(/\\/g, '/')
return { folder, name, path }
},
configFile: () => _isJava() ? '.cdsrc.json' : 'package.json',
apps: () => {
const appPath = path.resolve(cds.root, cds.env.folders.app)
if (!exists(appPath)) return []
const apps = fs.readdirSync(appPath).filter(e =>
isdir(path.join(cds.root, cds.env.folders.app, e)) && isApp(e)
)
return apps.map((app, i) => {
const manifestPath = path.resolve(cds.root, cds.env.folders.app, app, 'webapp/manifest.json')
const manifest = exists(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) : {}
const inbounds = manifest?.['sap.app']?.crossNavigation?.inbounds
const [firstInbound] = Object.values(inbounds ?? { intent: { semanticObject: 'Books', action: 'display' } })
const vizId = firstInbound.semanticObject + '-' + firstInbound.action
// Try to read archiveName from ui5.yaml if it exists
let archiveName = ''
const ui5YamlPaths = [
path.resolve(cds.root, cds.env.folders.app, app, 'ui5-deploy.yaml'),
path.resolve(cds.root, cds.env.folders.app, app, 'ui5.yaml')
]
for (const yamlPath of ui5YamlPaths) {
if (exists(yamlPath)) {
const ui5Deploy = cds.load.yaml(yamlPath)
const zipperTask = ui5Deploy?.builder?.customTasks?.find?.(t => t.name === 'ui5-task-zipper')
if (zipperTask?.configuration?.archiveName) {
archiveName = `${zipperTask.configuration.archiveName}.zip`
break
}
}
}
return { app, strippedApp: app.replace(/-/g, ''), vizId, isNotLast: i < apps.length - 1, manifestAppId: manifest?.['sap.app']?.id ?? app, archiveName }
})
},
appUIPaths: () => {
const appPath = path.resolve(cds.root, cds.env.folders.app)
if (!exists(appPath)) return []
return fs.readdirSync(appPath).filter(e =>
isdir(path.join(cds.root, cds.env.folders.app, e)) && isApp(e)
)
},
hasIndexHtml: () => { },
appPath: () => env.folders.app,
appVersion: () => appVersion,
jdkVersion: () => jdkVersion,
approuterPath: () => (_uiEmbedded() ? join(env.folders.app) : exists('.deploy/app-router') ? join('.deploy', 'app-router') : join(env.folders.app, 'router')).replace(/\\/g, '/'),
appName: () => appName,
cleanedAppName: () => appName.replaceAll('_', '-'),
strippedAppName: () => appName.replace(/-/g, ''),
appId: () => appId,
appDescription: () => appDescription,
hasUIEmbedded: _uiEmbedded,
hasUIModule: _uiModule,
hasUI: () => _uiEmbedded() || _uiModule(),
hasUI5: () => _uiEmbedded() || _uiModule(),
literal: () => () => text => text,
hasMTXRoute: () => _inProd('extensibility') || (_inProd('multitenancy') && !_isJava()),
hasMTXRouteJava: () => (_inProd('helm-unified-runtime') || _inProd('helm')) && _isJava() && _inProd('multitenancy'),
sap: () => cds.cli.options.sap,
imageRegistry: () => exists('chart/values.yaml') ? cds.load.yaml(join(cds.root, 'chart/values.yaml')).global?.image?.registry ?? '<your-container-registry-server>' : '<your-container-registry-server>',
extendsApp: _extends,
isBas: () => Object.entries(process.env).some(([key, value]) => key === 'WS_BASE_URL' && value.includes('applicationstudio') || /THEIA_(DEFAULT_)?PLUGINS/.test(key)),
npmWorkspaceRoot: () => {
if (this.cachedWorkspaceRoot) return this.cachedWorkspaceRoot
this.cachedWorkspaceRoot = execSync('npm prefix', { encoding: 'utf8', cwd: cds.root, stdio: 'pipe' }).trim()
return this.cachedWorkspaceRoot
},
isMonorepoMicroservice: function () {
return this.npmWorkspaceRoot() !== cds.root
},
hasRoles: () => { }, roles: () => { }, // for xs-security generation
hasCustomRoleCollections: () => exists('xs-security.json') && !!JSON.parse(fs.readFileSync(path.join(cds.root, 'xs-security.json')))['role-collections'],
k8s: () => {
if (!exists('chart/values.yaml')) return {}
const values = cds.load.yaml(join(cds.root, 'chart/values.yaml'))
let containerize = {}
if (exists('containerize.yaml'))
containerize = cds.load.yaml(join(cds.root, 'containerize.yaml'))
return {
values: values ?? {},
containerize: containerize ?? {}
}
}
}
// Automatically creates availability checks for `cds add` commands.
// Maps to the `hasInProduction` implemented in the command.
// E.g. `cds add hana` can be checked using `hasHana` or `isHana`
const project = (() => {
const defined = {}
const _get = property => {
const p = property
if (p in reserved) return reserved[p]()
for (const prefix of ['has', 'is', 'add']) if (p.startsWith(prefix) && p.length > prefix.length) {
const facet = p.slice(prefix.length).replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
const inProd = _inProd(facet)
if (prefix === 'add') {
const adding = plugin => cds.cli.command === 'add' && cds.cli.argv[0]?.split(',').includes(plugin) || cds.cli.options.add?.has(plugin)
const caller = new Error().stack.split('\n')[3].replace(/\\/g, '/').match(/lib\/init\/template\/([^/]+)\/index\.js/)[1]
return inProd && (adding(facet) || adding(caller))
}
return inProd
}
}
return new Proxy({}, {
get(_, p) { return _get(p) ?? defined[p] },
set(_, p, value) { defined[p] = value; return true },
has(_, p) { return _get(p) ?? p in defined }
})
})()
return project
}
}
/**
* Finds and reads SpringBoot configuration in `cds.root/srv` dir.
* This is done in a best-effort way. In no way can we replicate all of SpringBoot's
* config variants.
* @param {string[]?} roots array of directories to search for application.yaml, like `srv`
* @returns {Promise<object[]>} array of objects with `cds` property
*/
module.exports._readSpringBootConfig = async function (roots = [cds.env.folders.srv]) {
const configNodes = []
const rootDirs = roots.map(d => join(cds.root, d))
for (let r of rootDirs) {
const file = isfile(join(r, './src/main/resources/application.yaml'))
if (file) {
const yaml = cds.load.yaml(file)
for (let yamlDoc of Array.isArray(yaml) ? yaml : [yaml]) {
let cds = yamlDoc?.cds;
if (!cds) continue
cds = _normalizeSpringBootCfg(cds)
configNodes.push({ cds })
}
}
}
return configNodes
}
/**
* Normalize SpringBoot's dots in keys to express nested objects
*
* @example: `cds.foo.bar = 1` --> `cds: { foo: { bar: 1 } }`
* @param {object} obj
* @returns {object}
*/
function _normalizeSpringBootCfg(obj) {
if (typeof obj !== 'object') return obj
Object.keys(obj).forEach(k => {
const prop = k.split('.')
const last = prop.pop()
// and define the object if not already defined
const res = prop.reduce((o, key) => {
// define the object if not defined and return
return o[key] = o[key] ?? {}
}, obj)
res[last] = obj[k]
// recursively normalize
_normalizeSpringBootCfg(obj[k])
// delete the original property from object if it was rewritten
if (prop.length) delete obj[k]
})
return obj
}
/* Types */
/**
* @typedef {Object} ProjectConfig
* @property {string} appVersion The version of the application.
* @property {string} appName The name of the application.
* @property {string} appId The ID of the application.
* @property {string} appDescription The description of the application.
* @property {'java' | 'nodejs'} language The programming language of the project.
* @property {string} srvPath The path to the server module.
* @property {string} archiveName The name of the archive.
* @property {Object} db Database configuration.
* @property {string} configFile The name of the configuration file. This is typically the `package.json` for Node.js and `.cdsrc.json` for Java.
* @property {string} appPath The path to the app folder.
* @property {string} approuterPath The path to the approuter folder.
* @property {boolean} hasUIEmbedded Indicates if the project uses an embedded UI.
* @property {boolean} hasUIModule Indicates if the project is using a modular UI.
* @property {boolean} hasUI Indicates if the project has a UI.
* @property {boolean} hasUI5 Indicates if the project is a UI5 project.
* @property {boolean} isUI5 Indicates if the project is a UI5 project.
* @property {boolean} isJava Indicates if the project is using Java.
* @property {boolean} isNodejs Indicates if the project is using Node.js.
* @property {boolean} isESM Indicates if the project is an ESM project (type 'module')
* @property {boolean} hasEsmStyle Indicates if code supports ESM-style imports and exports. This is true for ESM and Typescript projects.
* @property {boolean} isTypescript Indicates if the project is a Typescript project.
* @property {boolean} hasTyper Indicates if code supports CDS typer.
* @property {boolean} hasHelmUnifiedRuntime Indicates if the project uses Unified Runtime Helm deployment.
* @property {boolean} hasHelm Indicates if the project uses CAP Helm deployment.
* @property {boolean} hasKyma Indicates if the project uses Kyma.
* @property {boolean} hasMta Indicates if the project uses MTA deployment.
* @property {boolean} hasConnectivity Indicates if the project uses connectivity.
* @property {boolean} hasMultitenancy Indicates if the project is multitenant.
* @property {boolean} hasToggles Indicates if the project uses feature toggles.
* @property {boolean} hasExtensibility Indicates if the project is extensible.
* @property {boolean} hasXsuaa Indicates if the project uses XSUAA.
* @property {boolean} hasHana Indicates if the project uses HANA.
* @property {boolean} hasPostgres Indicates if the project uses PostgreSQL.
* @property {boolean} hasEnterpriseMessaging Indicates if the project uses SAP BTP Event Mesh.
* @property {boolean} hasAttachments Indicates if the project uses SAP BTP Object Store Service.
* @property {boolean} hasApprouter Indicates if the project uses the SAP Application Router.
* @property {boolean} hasHtml5Repo Indicates if the project uses the SAP BTP HTML5 Application Repository.
* @property {boolean} hasPortal Indicates if the project uses the SAP Cloud Portal service.
* @property {boolean} hasWorkzoneStandard Indicates if the project uses the SAP Build Work Zone, Standard Edition service.
* @property {boolean} hasDestination Indicates if the project uses the SAP BTP Destination service.
* @property {boolean} hasMTXRoute Indicates if the approuter should have a multitenant route.
* @property {boolean} hasMTXRouteJava Indicates if the approuter should have a multitenant subscription route (java).
* @property {boolean} hasMalwareScanner Indicates if the project uses the SAP Malware Scanning service.
* @property {boolean} hasDynatrace Indicates if the project uses Dynatrace.
* @property {boolean} hasCloudLogging Indicates if the project uses Cloud Logging.
* @property {String} extendsApp Indicates which project is extended if any.
* @property {String} githubSlug GitHub org/repo.
* @property {boolean} isMonorepoMicroservice Indicates if the project is a monorepo microservice.
* @property {String} npmWorkspaceRoot The root path of the monorepo workspace.
* @property {String} isBas Indicates if the project is running in Business Application Studio (BAS).
*/