UNPKG

node-red-contrib-uibuilder

Version:

Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.

676 lines (569 loc) 28.2 kB
/* eslint-disable class-methods-use-this */ /** Manage npm packages * * Copyright (c) 2021-2024 Julian Knight (Totally Information) * https://it.knightnet.org.uk, https://github.com/TotallyInformation/node-red-contrib-uibuilder * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict' /** --- Type Defs --- * @typedef {import('../../typedefs.js').runtimeRED} runtimeRED * @typedef {import('../../typedefs.js').uibNode} uibNode * @typedef {import('../../typedefs.js').uibConfig} uibConfig * @typedef {import('../../typedefs.js').uibPackageJson} uibPackageJson */ const { join } = require('node:path') const { copy, copySync, existsSync, writeJson } = require('./fs.js') const { runOsCmd, runOsCmdSync } = require('./uiblib.js') class UibPackages { //#region ---- Class Variables ---- /** PRIVATE Flag to indicate whether setup() has been run (ignore the false eslint error) * @type {boolean} */ #isConfigured = false #logUndefinedError = new Error('pkgMgt: this.log is undefined') #uibUndefinedError = new Error('pkgMgt: this.uib is undefined') #rootFldrNullError = new Error('pkgMgt: this.uib.rootFolder is null') /** @type {Array<string>} Updated by updateMergedPackageList which is called first in setup and then in various updates */ mergedPkgMasterList = [] /** @type {string} The name of the package.json file 'package.json' */ packageJson = 'package.json' /** @type {uibPackageJson|null} The uibRoot package.json contents */ uibPackageJson /** @type {string} Get npm's global install location */ globalPrefix // set in constructor /** Details of actually installed packages - set in setup > pkgCheck */ dependencyDetails /** Details of mismatched installed packages - set in setup > pkgCheck */ dependencyProblems // OS Command options for running npm commands - https://nodejs.org/docs/latest-v18.x/api/child_process.html#child_processspawncommand-args-options npmCmdOpts = { cwd: '', shell: true, windowsHide: true, timeout: 300000, // 5min out: '', // uib addition - set to 'bare' when requesting JSON output } //#endregion ---- ---- ---- constructor() { /** Get npm's global install location */ this.globalPrefix = this.npmGetGlobalPrefix() } // ---- End of constructor ---- // /** Gets the global install folder for npm & saves to a class variable * @returns {string} The npm global install folder name */ async npmGetGlobalPrefix() { // eslint-disable-line class-methods-use-this // Does not need setup to have run const opts = this.npmCmdOpts opts.out = 'bare' // returns just the output string const args = [ 'config', 'get', 'prefix', ] /** @type {string} */ let out try { out = await runOsCmd('npm', args, opts) } catch (e) { const myerr = new Error(`runOsCmd/npmGetGlobalPrefix failed. ${e.message}`) myerr.all = '' myerr.code = 3 myerr.command = `npm ${args.join(' ')}` throw myerr } return out } // ---- End of npmGetGlobalPrefix ---- // /** Configure this class with uibuilder module specifics * @param {uibConfig} uib uibuilder module-level configuration */ setup( uib ) { if ( !uib ) throw new Error('[uibuilder:UibPackages.js:setup] Called without required uib parameter or uib is undefined.') if ( uib.RED === null ) throw new Error('[uibuilder:UibPackages.js:setup] uib.RED is null') if ( uib.rootFolder === null ) throw this.#rootFldrNullError // Prevent setup from being called more than once if ( this.#isConfigured === true ) { uib.RED.log.warn('🌐⚠️[uibuilder:UibPackages:setup] Setup has already been called, it cannot be called again.') return } this.RED = uib.RED this.uib = uib const log = this.log = uib.RED.log log.trace('🌐[uibuilder:package-mgt:setup] Package Management setup started') // ! HAVE TO CHECK IF uibRoot/package.json or uibRoot/node_modules EXISTS FIRST // or checks and installs could happen at a parent level if it has a package.json if (!existsSync(uib.rootFolder, this.packageJson)) { log.warn(`🌐⚠️[uibuilder:package-mgt:setup] uibRoot package.json not found. Copying template file. ${uib.rootFolder}`) this.createBasicPj() } // Get the uibuilder root folder's package.json file and save to class var or create minimal version if one doesn't exist // const pj = this.uibPackageJson = this.getUibRootPJ() this.uibPackageJson = this.pkgCheck() // Update the version string to match uibuilder version this.uibPackageJson.version = this.uib.version // TODO Good enough for now but all SHOULD be auto-fixable. if (this.dependencyProblems) { setTimeout(() => { this.log.warn('------------------------------------------------------') this.log.warn([`🌐⚠️[uibuilder:package-mgt:setup] Problems with uibuilder root package.json.\nRoot folder: ${this.uib.rootFolder}\nPlease resolve before continuing.`, this.dependencyProblems,]) this.log.warn('------------------------------------------------------\n \n') }, 3000) } // SYNC - minimum update of the in-memory pj.uibuilder.packages with enough info to be able to serve the folders this.pkgsQuickUpd() // At this point we have the refs to uib and RED & enough to be able to serve the libraries this.#isConfigured = true // ASYNC - Re-build package.json uibuilder.packages with details & rewrite file (async) this.updateInstalledPackageDetails() log.trace('🌐[uibuilder:package-mgt:setup] Package Management setup completed') } // ---- End of setup ---- // /** Make sure that <uibRoot>/package.json exists and contains basic info * @returns {boolean} True if successful */ createBasicPj() { if (!this.log) console.error('🌐🛑[uibuilder:package-mgt:createBasicPj] this.log not defined!') try { const pj = copySync([__dirname, '..', '..', 'templates', 'uibroot-package.json'], [this.uib.rootFolder, 'package.json']) this.log.trace(`🌐[uibuilder[:package-mgt:createBasicPj] Basic package.json created in "${this.uib.rootFolder}"`) return pj } catch (e) { this.log.error(`🌐🛑[uibuilder:package-mgt:createBasicPj] Basic package.json creation FAILED in "${this.uib.rootFolder}". ${e.message}`, e) console.trace() return null } } /** Use npm to check packages installed to uibRoot * ! This could return a PARENT details if package.json doesn't exist in the given folder * ! If no package.json found, returns empty json if no node_modules. * ! If no package.json but modules anyway, output includes a `problems` entry * @returns {object} uibRoot package.json + dependencyDetails for actual installed packages */ pkgCheck() { if ( this.uib === undefined ) throw this.#uibUndefinedError if ( this.uib.rootFolder === null ) throw this.#rootFldrNullError const opts = { ...this.npmCmdOpts } opts.cwd = this.uib.rootFolder opts.out = 'bare' // returns just the output string const args = [ 'list', // Long output includes parent package.json and all dependencies package.json // So this could replace all other reads '--long', // Short output seems to take .1s longer! '--omit=dev', '--json', '--depth=0', ] let out // console.time('⏱️ >> pkgCheckCmd') try { out = runOsCmdSync('npm', args, opts) out = JSON.parse(out) } catch (e) { this.log.error(`🌐🛑[uibuilder:package-mgt:pkgCheck] npm list installed packages FAILED. ${e.message}`, e) } // console.timeEnd('⏱️ >> pkgCheckCmd') // Save dependencies seperatelly then move _dependendencies back to dependencies to normalise the data this.dependencyDetails = out.dependencies delete out['dependencies'] out.dependencies = out._dependencies delete out['_dependencies'] delete out['extraneous'] if (out.problems) { this.dependencyProblems = out.problems delete out.problems } return out } /** Do a fast update of the min data in pj.uibuilder.packages required for web.serveVendorPackages() * Only compares dependencies with uibuilder.packages in the package.json file. Does not compare to installed. */ pkgsQuickUpd() { if ( this.uib === undefined ) throw this.#uibUndefinedError if ( this.uib.rootFolder === null ) throw this.#rootFldrNullError const pj = this.uibPackageJson // Make sure there is a dependencies prop if ( !pj.dependencies ) pj.dependencies = {} // Make sure there is a uibuilder prop if ( !pj.uibuilder ) pj.uibuilder = {} // Make sure there is a uibuilder.packagedetails prop if ( !pj.uibuilder.packages ) pj.uibuilder.packages = {} // Make sure no extra package details for (const pkgName in pj.uibuilder.packages) { if ( !pj.dependencies[pkgName] ) delete pj.uibuilder.packages[pkgName] } // Make sure all dependencies are reflected in uibuilder.packagedetails for (const depName in pj.dependencies) { if ( !pj.uibuilder.packages[depName] ) { pj.uibuilder.packages[depName] = { installedVersion: pj.dependencies[depName] } } } // Get folders for web:startup:serveVendorPackages() for (const pkgName in pj.uibuilder.packages) { const pkg = pj.uibuilder.packages[pkgName] // The actual location of the package folder pkg.installFolder = this.dependencyDetails[pkgName].path || join(this.uib.rootFolder, 'node_modules', pkgName) // The base url used by uib - note this is changed if this is a scoped package pkg.packageUrl = '/' + pkgName } // Re-save the updated file // this.setUibRootPackageJson(pj) // this.writePackageJson(this.uib.rootFolder, pj) } /** Write updated <folder>/package.json (async) * Also makes a backup copy to package.json.bak * @param {string} folder The folder where to write the file * @param {object} json The Object data to write to the file */ async writePackageJson(folder, json) { // Does not need setup to have finished running const fileName = join( folder, this.packageJson ) try { // Make a backup copy await copy(fileName, `${fileName}.bak`) this.log.trace(`🌐[uibuilder:package-mgt:writePackageJson] package.json file successfully backed up in ${folder}`) } catch (err) { this.log.error(`🌐🛑[uibuilder:package-mgt:writePackageJson] Failed to copy package.json to backup. ${folder}`, this.packageJson, err) } try { await writeJson(fileName, json, { spaces: 2 }) this.log.trace(`🌐[uibuilder:package-mgt:writePackageJson] package.json file written successfully in ${folder}`) } catch (err) { this.log.error(`🌐🛑[uibuilder:package-mgt:writePackageJson] Failed to write package.json. ${folder}`, this.packageJson, err) } } async updIndividualPkgDetails(pkgName, dependencyDetails) { if ( this.uibPackageJson === null ) throw new Error('[uibuilder:UibPackages.js:updIndividualPkgDetails] this.uibPackageJson is null') const pj = this.uibPackageJson if ( pj.uibuilder === undefined || pj.uibuilder.packages === undefined || pj.dependencies === undefined ) throw new Error('pgkMgt:updIndividualPkgDetails: pj.uibuilder, pj.uibuilder.packages or pj.dependencies is undefined') // Make sure only packages in uibRoot/package.json dependencies are processed if ( !pj.dependencies[pkgName] ) return const packages = pj.uibuilder.packages packages[pkgName] = {} const pkg = packages[pkgName] const lsp = dependencyDetails[pkgName] // save the version/location spec from the dependencies prop so everything is together pkg.spec = pj.dependencies[pkgName] if ( lsp.missing ) { pkg.missing = true pkg.problems = lsp.problems } else { // Get/Update package details pkg.installFolder = lsp.path pkg.installedVersion = lsp.version /** If we can, lets work out what resource is actually needed * when using one of these packages in the browser. * If we can't, leave a ? to make it obvious * Annoyingly, a few packages have decided to make the `browser` property an object instead of a string. * (e.g. vgauge) - ignore in that case as it isn't clear what the intent is. */ if (lsp.browser && (typeof lsp.browser === 'string') ) pkg.estimatedEntryPoint = lsp.browser else if (lsp.jsdelivr) pkg.estimatedEntryPoint = lsp.jsdelivr else if (lsp.unpkg) pkg.estimatedEntryPoint = lsp.unpkg else if (lsp.main) pkg.estimatedEntryPoint = lsp.main else pkg.estimatedEntryPoint = '?' if ( pkg.estimatedEntryPoint === 'none') pkg.estimatedEntryPoint = '?' // Homepage - used for a help ref in the Editor if (lsp.homepage) pkg.homepage = lsp.homepage else pkg.homepage = `https://www.npmjs.com/search?q=${pkgName}` // The base url used by uib - note this is changed if this is a scoped package pkg.packageUrl = '/' + pkgName // As the url may have changed (by removing scope), record the usable url pkg.url = `../uibuilder/vendor${pkg.packageUrl}/${pkg.estimatedEntryPoint}` // If the package name is npm @scoped, remove the scope, add leading / & track scope name if ( pkgName.startsWith('@') ) { // pkg.packageUrl = '/' + pkgName.replace(/^@.*\//, '') pkg.scope = pkgName.replace(pkg.packageUrl, '') } } if ( pj.dependencies[pkgName] && pj.dependencies[pkgName].includes(':') ) { // Must be installed from somewhere other than npmjs so don't try to find latest version pkg.latestVersion = null pkg.installedFrom = pj.dependencies[pkgName].split(':')[0] pkg.outdated = {} } else { pkg.installedFrom = 'npm' // Add current version details let res = await this.npmOutdated(pkgName) try { res = JSON.parse(res) } catch (e) { /* */ } if ( res[pkgName] ) { res = { current: res[pkgName].current, wanted: res[pkgName].wanted, latest: res[pkgName].latest, } } pkg.outdated = res } } /** Use npm to get detailed pkg info (slow, async) to pj.uibuilder.packages & rewrite the pj file */ async updateInstalledPackageDetails() { const pj = this.uibPackageJson if ( this.uib === undefined ) throw this.#uibUndefinedError if ( this.uib.rootFolder === null ) throw this.#rootFldrNullError const rootFolder = this.uib.rootFolder // Make sure we have package details for all installed packages - NB: don't use await with forEach! const depPkgNames = Object.keys(this.dependencyDetails || {}) // await depPkgNames.forEach( async pkgName => { // await this.updIndividualPkgDetails(pkgName, lsParsed) // }) // EITHER (serial) // for ( const pkgName of depPkgNames ) { // await this.updIndividualPkgDetails(pkgName, lsParsed) // } // OR (parallel) await Promise.all( depPkgNames.map(async (pkgName) => { await this.updIndividualPkgDetails(pkgName, this.dependencyDetails) })) // (re)Write package.json this.writePackageJson(rootFolder, pj) } /** Find install folder for a package - allows an array of locations to be given * NOTE: require.resolve can be a little ODD! * When run from a linked package, it uses the link root not the linked location, * this throws out the tree search. That's why we have to try several different locations here. * Also, it finds the "main" script name which might not be in the package root. * Also, it won't find ANYTHING if a `main` entry doesn't exist :( * So we no longer use it, just search for folder names. * @param {string} packageName - Name of the package who's install folder we are looking for. * @param {string|Array<string>} installRoot Location to search. Can be an array of locations. * @returns {null|string} Actual filing system path to the installed package */ getPackagePath2(packageName, installRoot) { if ( this.log === undefined ) throw this.#logUndefinedError if ( this.#isConfigured !== true ) { this.log.warn('🌐⚠️[uibuilder:UibPackages:getPackagePath] Cannot run. Setup has not been called.') return null } // If installRoot = string, make an array if ( !Array.isArray(installRoot) ) installRoot = [installRoot] for (const r of installRoot) { const loc = join(r, 'node_modules', packageName) if (existsSync( loc )) return loc } this.log.warn(`🌐⚠️[uibuilder:package-mgt:getPackagePath2] PACKAGE ${packageName} NOT FOUND`) return null } // ---- End of getPackagePath2 ---- // /** Is the specified package installed into uibRoot (e.g. via the library manager) * @param {string} packageName The package name to check * @returns {boolean} True if it is installed, false otherwise */ isPackageInstalled(packageName) { if ( this.#isConfigured !== true ) { this.log.warn('🌐⚠️[uibuilder:UibPackages:isPackageInstalled] Cannot run. Setup has not been called.') return false } if (!this.uibPackageJson) return false if (!this.uibPackageJson.uibuilder.packages[packageName]) return false // eslint-disable-line sonarjs/prefer-single-boolean-return return true } // ---- End of isPackageInstalled ---- // //#region -- Manage Packages via npm -- // TODO Use RED.events `UIBUILDER/npm` as option to show log during install /** Install or update an npm package * NOTE: This fn does not update the list of packages * because that is built from the package.json file * and that is updated by calling web.serveVendorPackages() * which can't be done here - The calling admin API's do that * Editor->API->This fn->API cont.->web.serveVendorPackages->getUibRootPackageJson->API cont2->Editor * @param {string} url Node instance url * @param {string} pkgName The npm name of the package (with scope prefix, version, etc if needed) * @param {string} [tag] Default=''. Specifier for a version, tag, branch, etc. with leading @ for npm and # for GitHub installs * @param {string} [toLocation] Where to install to. '' defaults to uibRoot * @param {'install'|'update'} [action] Install or Update. Defaults to install * @returns {Promise<{all:string, code:number, command:string}|string>} Combined stdout/stderr, return code */ async npmInstallPackage(url, pkgName, tag = '', toLocation = '', action = 'install') { if ( this.log === undefined ) throw this.#logUndefinedError if ( this.#isConfigured !== true ) { this.log.warn('🌐⚠️[uibuilder:UibPackages:npmInstallPackage] Cannot run. Setup has not been called.') return '' } if ( this.uib === undefined ) throw this.#uibUndefinedError if ( this.uib.rootFolder === null ) throw new Error('this.log.rootFolder is null') if ( toLocation === '' ) toLocation = this.uib.rootFolder const opts = { ...this.npmCmdOpts } opts.cwd = toLocation opts.out = '' const args = [ // `npm install --no-audit --no-update-notifier --save --production --color=false --no-fund --json ${params.package}@latest` action, pkgName + tag, '--no-fund', '--no-audit', '--no-update-notifier', '--save', '--production', '--color=false', // '--json', ] /** @type {{all:string, code:number, command:string}} */ let out try { out = await runOsCmd('npm', args, opts) } catch (e) { const myerr = new Error(`runOsCmd/npmInstallPackage failed. ${e.message}`) myerr.all = '' myerr.code = 3 myerr.command = `npm ${args.join(' ')}` throw myerr } if (out.code > 0) { const myerr = new Error(`Install failed. Code: ${out.code}`) myerr.all = out.all myerr.code = out.code myerr.command = out.command throw myerr } this.log.info(`🌐📘[uibuilder:UibPackages:npmInstallPackage] npm output: \n ${out.all}\n `) return out } // ---- End of installPackage ---- // /** Install an npm package * NOTE: This fn does not update the list of packages - see install above for reasons. * @param {string} pkgName The npm name of the package (with scope prefix, version, etc if needed) * @returns {Promise<{all:string, code:number, command:string}|string>} Combined stdout/stderr */ async npmRemovePackage(pkgName) { if ( this.log === undefined ) throw this.#logUndefinedError if ( this.uib === undefined ) throw this.#uibUndefinedError if ( this.uib.rootFolder === null ) throw this.#rootFldrNullError if ( this.#isConfigured !== true ) { this.log.warn('🌐⚠️[uibuilder:UibPackages:npmRemovePackage] Cannot run. Setup has not been called.') return '' } const opts = { ...this.npmCmdOpts } opts.cwd = this.uib.rootFolder opts.out = '' const args = [ 'uninstall', '--save', '--color=false', '--no-fund', '--no-audit', '--no-update-notifier', // '--json', pkgName, ] /** @type {{all:string, code:number, command:string}} */ let out try { out = await runOsCmd('npm', args, opts) } catch (e) { const myerr = new Error(`runOsCmd/npmRemovePackage failed. ${e.message}`) myerr.all = '' myerr.code = 3 myerr.command = `npm ${args.join(' ')}` throw myerr } if (out.code > 0) { const myerr = new Error(`Removal failed. Code: ${out.code}`) myerr.all = out.all myerr.code = out.code myerr.command = out.command throw myerr } this.log.info(`🌐📘[uibuilder:UibPackages:npmRemovePackage] npm output: \n ${out.all}\n `) return out } // ---- End of removePackage ---- // /** Get the latest version string for a package * @param {string} pkgName The npm name of the package (with scope prefix, version, etc if needed) * @returns {Promise<any>} Combined stdout/stderr */ async npmOutdated(pkgName) { if ( this.log === undefined ) throw this.#logUndefinedError if ( this.uib === undefined ) throw this.#uibUndefinedError if ( this.uib.rootFolder === null ) throw this.#rootFldrNullError if ( this.#isConfigured !== true ) { this.log.warn('🌐⚠️[uibuilder:UibPackages:npmOutdated] Cannot run. Setup has not been called.') return } const opts = { ...this.npmCmdOpts } opts.cwd = this.uib.rootFolder opts.out = 'bare' // returns just the output string const args = [ 'outdated', '--json', pkgName, ] /** @type {string} */ let out try { out = await runOsCmd('npm', args, opts) } catch (e) { const myerr = new Error(`runOsCmd/npmOutdated failed. ${e.message}`) myerr.all = '' myerr.code = 3 myerr.command = `npm ${args.join(' ')}` throw myerr } this.log.trace(`🌐[uibuilder[:UibPackages:npmOutdated] npm output: \n ${out}\n `) return out // we asked for bare output so out is a string } // ---- End of npmOutdated ---- // /** Update an npm package (Not yet in use) * @param {string} pkgName The npm name of the package (with scope prefix, version, etc if needed) * @returns {Promise<{all:string, code:number, command:string}|string>} Combined stdout/stderr, return code */ async npmUpdate(pkgName) { if ( this.log === undefined ) throw this.#logUndefinedError if ( this.#isConfigured !== true ) { this.log.warn('🌐⚠️[uibuilder:UibPackages:npmUpdate] Cannot run. Setup has not been called.') return '' } if ( this.uib === undefined ) throw this.#uibUndefinedError if ( this.uib.rootFolder === null ) throw new Error('this.log.rootFolder is null') // if ( toLocation === '' ) toLocation = this.uib.rootFolder const toLocation = this.uib.rootFolder const opts = { ...this.npmCmdOpts } opts.cwd = toLocation opts.out = '' const args = [ 'update', '--no-fund', '--no-audit', '--no-update-notifier', '--save', '--production', '--color=false', // '--json', pkgName, ] /** @type {{all:string, code:number, command:string}} */ let out try { out = await runOsCmd('npm', args, opts) } catch (e) { const myerr = new Error(`runOsCmd/npmInstallPackage failed. ${e.message}`) myerr.all = '' myerr.code = 3 myerr.command = `npm ${args.join(' ')}` throw myerr } if (out.code > 0) { const myerr = new Error(`Install failed. Code: ${out.code}`) myerr.all = out.all myerr.code = out.code myerr.command = out.command throw myerr } this.log.info(`🌐📘[uibuilder:UibPackages:npmUpdate] npm output: \n ${out.all}\n `) return out // return /** @type {string} */ (all) } //#endregion -- ---- -- } // ----- End of UibPackages ----- // /** Singleton model. Only 1 instance of UibWeb should ever exist. * Use as: `const packageMgt = require('./package-mgt.js')` */ // @ts-ignore const uibPackages = new UibPackages() module.exports = uibPackages // EOF