@quasar/app-webpack
Version:
Quasar Framework App CLI with Webpack
333 lines (294 loc) • 9.98 kB
JavaScript
const fs = require('fs-extra')
const path = require('node:path')
const { merge } = require('webpack-merge')
const semver = require('semver')
const { stringifyJSON, parseJSON } = require('confbox')
const { warn, fatal } = require('../../utils/logger.js')
const { getPackageJson } = require('../../utils/get-package-json.js')
const { getCallerPath } = require('../../utils/get-caller-path.js')
const { getBackwardCompatiblePackageName } = require('../utils.app-extension.js')
const { BaseAPI } = require('./BaseAPI.js')
/**
* API for extension's /install.js script
*/
module.exports.InstallAPI = class InstallAPI extends BaseAPI {
prompts
constructor (opts, appExtJson) {
super(opts)
this.prompts = opts.prompts
this.#appExtJson = appExtJson
}
/**
* Get the internal persistent config of this extension.
* Returns empty object if it has none.
*
* @return {object} cfg
*/
getPersistentConf () {
return this.#appExtJson.getInternal(this.extId)
}
/**
* Set the internal persistent config of this extension.
* If it already exists, it is overwritten.
*
* @param {object} cfg
*/
setPersistentConf (cfg) {
this.#appExtJson.setInternal(this.extId, cfg || {})
}
/**
* Deep merge into the internal persistent config of this extension.
* If extension does not have any config already set, this is
* essentially equivalent to setting it for the first time.
*
* @param {object} cfg
*/
mergePersistentConf (cfg = {}) {
const currentCfg = this.getPersistentConf()
this.setPersistentConf(merge({}, currentCfg, cfg))
}
/**
* Ensure the App Extension is compatible with
* host app installed package through a
* semver condition.
*
* If the semver condition is not met, then
* @quasar/app-webpack errors out and halts execution
*
* Example of semver condition:
* '1.x || >=2.5.0 || 5.0.0 - 7.2.3'
*
* @param {string} packageName
* @param {string} semverCondition
*/
compatibleWith (packageName, semverCondition) {
const name = getBackwardCompatiblePackageName(packageName)
const json = getPackageJson(name, this.appDir)
if (json === void 0) {
fatal(`Extension(${ this.extId }): Dependency not found - ${ name }. Please install it.`)
}
if (!semver.satisfies(json.version, semverCondition)) {
fatal(`Extension(${ this.extId }): is not compatible with ${ name } v${ json.version }. Required version: ${ semverCondition }`)
}
}
/**
* Check if an app package is installed. Can also
* check its version against specific semver condition.
*
* Example of semver condition:
* '1.x || >=2.5.0 || 5.0.0 - 7.2.3'
*
* @param {string} packageName
* @param {string} semverCondition
* @return {boolean} package is installed and meets optional semver condition
*/
hasPackage (packageName, semverCondition) {
const name = getBackwardCompatiblePackageName(packageName)
const json = getPackageJson(name, this.appDir)
if (json === void 0) {
return false
}
return semverCondition !== void 0
? semver.satisfies(json.version, semverCondition)
: true
}
/**
* Check if another app extension is installed
* (app extension npm package is installed and it was invoked)
*
* @param {string} extId
* @return {boolean} has the extension installed & invoked
*/
hasExtension (extId) {
return this.#appExtJson.has(extId)
}
/**
* Get the version of an an app's package.
*
* @param {string} packageName
* @return {string|undefined} version of app's package
*/
getPackageVersion (packageName) {
const name = getBackwardCompatiblePackageName(packageName)
const json = getPackageJson(name, this.appDir)
return json !== void 0
? json.version
: void 0
}
/**
* Extend package.json with new props.
* If specifying existing props, it will override them.
*
* @param {object|string} extPkg - Object to extend with or relative path to a JSON file
*/
extendPackageJson (extPkg) {
if (!extPkg) return
if (typeof extPkg === 'string') {
const dir = getCallerPath()
const source = path.resolve(dir, extPkg)
if (!fs.existsSync(source)) {
warn()
warn(`Extension(${ this.extId }): extendPackageJson() - cannot locate ${ extPkg }. Skipping...`)
warn()
return
}
if (fs.lstatSync(source).isDirectory()) {
warn()
warn(`Extension(${ this.extId }): extendPackageJson() - "${ extPkg }" is a folder instead of file. Skipping...`)
warn()
return
}
try {
extPkg = JSON.parse(
fs.readFileSync(source, 'utf-8')
)
}
catch (_) {
warn(`Extension(${ this.extId }): extendPackageJson() - "${ extPkg }" is malformed`)
warn()
process.exit(1)
}
}
if (
Object(extPkg) !== extPkg
|| Object.keys(extPkg).length === 0
) return
const pkg = merge({}, this.ctx.pkg.appPkg, extPkg)
fs.writeFileSync(
this.resolve.app('package.json'),
stringifyJSON(pkg),
'utf-8'
)
if (
extPkg.dependencies
|| extPkg.devDependencies
|| extPkg.optionalDependencies
|| extPkg.bundleDependencies
|| extPkg.peerDependencies
) {
this.#needsNodeModulesUpdate = true
}
}
/**
* Extend a JSON file with new props (deep merge).
* If specifying existing props, it will override them.
*
* @param {string} file (relative path to app root folder)
* @param {object} newData (Object to merge in)
*/
extendJsonFile (file, newData) {
if (newData !== void 0 && Object(newData) === newData && Object.keys(newData).length > 0) {
const filePath = this.resolve.app(file)
// Try to parse the JSON with Node native tools.
// It will soft-fail and log a warning if the JSON isn't parseable
// which usually means we are dealing with an extended JSON flavour,
// for example JSON with comments or JSON5.
// Notable examples are TS 'tsconfig.json' or VSCode 'settings.json'
// TODO: use parseJSONC/stringifyJSONC from confbox
try {
const existingData = fs.existsSync(filePath) ? parseJSON(fs.readFileSync(filePath, 'utf-8')) : {}
const data = merge({}, existingData, newData)
fs.writeFileSync(
this.resolve.app(file),
// if file exists, preserve indentation, otherwise use 2 spaces
stringifyJSON(data, { indent: Object.keys(existingData).length > 0 ? undefined : 2 }),
'utf-8'
)
}
catch (_) {
warn()
warn(`Extension(${ this.extId }): extendJsonFile() - "${ filePath }" doesn't conform to JSON format: this could happen if you are trying to update flavoured JSON files (eg. JSON with Comments or JSON5). Skipping...`)
warn(`Extension(${ this.extId }): extendJsonFile() - The extension tried to apply these updates to "${ filePath }" file: ${ JSON.stringify(newData) }`)
warn()
}
}
}
/**
* Render a folder from extension templates into devland.
* Needs a path (to a folder) relative to the path of the file where render() is called
*
* @param {string} templatePath (relative path to folder to render in app)
* @param {object} scope (optional; rendering scope variables)
*/
render (templatePath, scope) {
const dir = getCallerPath()
const source = path.resolve(dir, templatePath)
const rawCopy = !scope || Object.keys(scope).length === 0
if (!fs.existsSync(source)) {
warn()
warn(`Extension(${ this.extId }): render() - cannot locate ${ templatePath }. Skipping...\n`)
return
}
if (!fs.lstatSync(source).isDirectory()) {
warn()
warn(`Extension(${ this.extId }): render() - "${ templatePath }" is a file instead of folder. Skipping...\n`)
return
}
this.#hooks.renderFolders.push({
source,
rawCopy,
scope
})
}
/**
* Render a file from extension template into devland
* Needs a path (to a file) relative to the path of the file where renderFile() is called
*
* @param {string} relativeSourcePath (file path relative to the folder from which the install script is called)
* @param {string} relativeTargetPath (file path relative to the root of the app -- including filename!)
* @param {object} scope (optional; rendering scope variables)
*/
renderFile (relativeSourcePath, relativeTargetPath, scope) {
const dir = getCallerPath()
const sourcePath = path.resolve(dir, relativeSourcePath)
const targetPath = this.resolve.app(relativeTargetPath)
const rawCopy = !scope || Object.keys(scope).length === 0
if (!fs.existsSync(sourcePath)) {
warn()
warn(`Extension(${ this.extId }): renderFile() - cannot locate ${ relativeSourcePath }. Skipping...\n`)
return
}
if (fs.lstatSync(sourcePath).isDirectory()) {
warn()
warn(`Extension(${ this.extId }): renderFile() - "${ relativeSourcePath }" is a folder instead of a file. Skipping...\n`)
return
}
this.#hooks.renderFiles.push({
sourcePath,
targetPath,
rawCopy,
scope,
overwritePrompt: true
})
}
/**
* Add a message to be printed after App CLI finishes up install.
*
* @param {string} msg
*/
onExitLog (msg) {
this.#hooks.exitLog.push(msg)
}
/**
* Private stuff; to NOT be used in devland
*/
#appExtJson
#needsNodeModulesUpdate = false
__getNodeModuleNeedsUpdate (appExtJson) {
// protect against external access
if (appExtJson === this.#appExtJson) {
return this.#needsNodeModulesUpdate
}
}
#hooks = {
renderFolders: [],
renderFiles: [],
exitLog: []
}
__getHooks (appExtJson) {
// protect against external access
if (appExtJson === this.#appExtJson) {
return this.#hooks
}
}
}