UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

1,585 lines (1,440 loc) 52.8 kB
#!/usr/bin/env node /* * Copyright 2025 The Kubernetes Authors * * 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. */ // @ts-check 'use strict'; const crypto = require('crypto'); const fs = require('fs-extra'); const envPaths = require('env-paths'); const os = require('os'); const path = require('path'); const resolve = path.resolve; const child_process = require('child_process'); const validate = require('validate-npm-package-name'); const yargs = require('yargs/yargs'); const headlampPluginPkg = require('../package.json'); const PluginManager = require('../plugin-management/plugin-management').PluginManager; const { table } = require('table'); const tar = require('tar'); const MultiPluginManager = require('../plugin-management/multi-plugin-management'); // ES imports const viteCopyPluginPromise = import('vite-plugin-static-copy'); const viteConfigPromise = import('../config/vite.config.mjs'); const vitePromise = import('vite'); /** * Creates a new plugin folder. * * Copies the files within template, and modifies a couple. * Then runs npm install inside of the folder. * * @param {string} name - name of package and output folder. * @param {boolean} link - if we link @kinvolk/headlamp-plugin for testing * @returns {0 | 1 | 2 | 3} Exit code, where 0 is success, 1, 2, and 3 are failures. */ function create(name, link) { const dstFolder = name; const templateFolder = path.resolve(__dirname, '..', 'template'); const indexPath = path.join(dstFolder, 'src', 'index.tsx'); const packagePath = path.join(dstFolder, 'package.json'); const readmePath = path.join(dstFolder, 'README.md'); if (fs.existsSync(name)) { console.error(`"${name}" already exists, not initializing`); return 1; } const nameValid = validate(name); if (!nameValid.validForNewPackages) { console.error(`Invalid package name:"${name}":, not initializing`); console.error(nameValid.errors); return 2; } console.log(`Creating folder :${dstFolder}:`); fs.copySync(templateFolder, dstFolder, { errorOnExist: true, overwrite: false, }); function replaceFileVariables(path) { fs.writeFileSync( path, fs .readFileSync(path, 'utf8') .split('$${name}') .join(name) .split('$${headlamp-plugin-version}') .join(headlampPluginPkg.version) .split('$${eslint-config-version}') .join(headlampPluginPkg.dependencies['@headlamp-k8s/eslint-config']) ); } replaceFileVariables(packagePath); replaceFileVariables(indexPath); replaceFileVariables(readmePath); // This can be used to make testing locally easier. if (link) { console.log('Linking @kinvolk/headlamp-plugin'); child_process.spawnSync('npm', ['link', '@kinvolk/headlamp-plugin'], { cwd: dstFolder, }); } console.log('Installing dependencies...'); // Run npm install. try { child_process.execSync('npm install', { stdio: 'inherit', cwd: dstFolder, encoding: 'utf8', }); } catch (e) { console.error( `Problem running npm install inside of "${dstFolder}" abs: "${resolve(dstFolder)}"` ); return 3; } // This can be used to make testing locally easier. if (link) { // Seems to require linking again with npm 7+ console.log('Linking @kinvolk/headlamp-plugin'); child_process.spawnSync('npm', ['link', '@kinvolk/headlamp-plugin'], { cwd: dstFolder, }); } console.log(`"${dstFolder}" created.`); console.log(`1) Run the Headlamp app (so the plugin can be used).`); console.log(`2) Open ${dstFolder}/src/index.tsx in your editor.`); console.log(`3) Start development server of the plugin watching for plugin changes.`); console.log(` cd "${dstFolder}"\n npm run start`); console.log(`4) See the plugin inside Headlamp.`); return 0; } /** * extract copies folders of packages in the form: * packageName/dist/main.js to packageName/main.js * packageName/package.json to packageName/package.json * * @param {string} pluginPackagesPath - can be a package or a folder of packages. * @param {string} outputPlugins - folder where the plugins are placed. * @param {boolean} logSteps - whether to print the steps of the extraction (true by default). * @returns {0 | 1} Exit code, where 0 is success, 1 is failure. */ function extract(pluginPackagesPath, outputPlugins, logSteps = true) { if (!fs.existsSync(pluginPackagesPath)) { console.error(`"${pluginPackagesPath}" does not exist. Not extracting.`); return 1; } if (!fs.existsSync(outputPlugins)) { if (logSteps) { console.log(`"${outputPlugins}" did not exist, making folder.`); } fs.mkdirSync(outputPlugins); } /** * pluginPackagesPath is a package folder, not a folder of packages. */ function extractPackage() { if (fs.existsSync(path.join(pluginPackagesPath, 'dist', 'main.js'))) { const distPath = path.join(pluginPackagesPath, 'dist'); const trimmedPath = pluginPackagesPath.slice(-1) === path.sep ? pluginPackagesPath.slice(0, -1) : pluginPackagesPath; const folderName = trimmedPath.split(path.sep).splice(-1)[0]; const plugName = path.join(outputPlugins, folderName); fs.ensureDirSync(plugName); const files = fs.readdirSync(distPath); files.forEach(file => { const srcFile = path.join(distPath, file); const destFile = path.join(plugName, file); console.log(`Copying "${srcFile}" to "${destFile}".`); fs.copyFileSync(srcFile, destFile); }); const inputPackageJson = path.join(pluginPackagesPath, 'package.json'); const outputPackageJson = path.join(plugName, 'package.json'); console.log(`Copying "${inputPackageJson}" to "${outputPackageJson}".`); fs.copyFileSync(inputPackageJson, outputPackageJson); return true; } return false; } function extractFolderOfPackages() { const folders = fs.readdirSync(pluginPackagesPath, { withFileTypes: true }).filter(fileName => { return ( fileName.isDirectory() && fs.existsSync(path.join(pluginPackagesPath, fileName.name, 'dist', 'main.js')) ); }); folders.forEach(folder => { const distPath = path.join(pluginPackagesPath, folder.name, 'dist'); const plugName = path.join(outputPlugins, folder.name); fs.ensureDirSync(plugName); const files = fs.readdirSync(distPath); files.forEach(file => { const srcFile = path.join(distPath, file); const destFile = path.join(plugName, file); console.log(`Copying "${srcFile}" to "${destFile}".`); fs.copyFileSync(srcFile, destFile); }); const inputPackageJson = path.join(pluginPackagesPath, folder.name, 'package.json'); const outputPackageJson = path.join(plugName, 'package.json'); console.log(`Copying "${inputPackageJson}" to "${outputPackageJson}".`); fs.copyFileSync(inputPackageJson, outputPackageJson); }); return folders.length !== 0; } if (!(extractPackage() || extractFolderOfPackages())) { console.error(`"${pluginPackagesPath}" does not contain packages. Not extracting.`); return 1; } return 0; } /** * Calculate the checksum of a file. * * @param {*} filePath * @returns */ async function calculateChecksum(filePath) { try { const fileBuffer = await fs.readFile(filePath); const hashSum = crypto.createHash('sha256'); hashSum.update(fileBuffer); const hex = hashSum.digest('hex'); return hex; } catch (error) { console.error('Error calculating checksum:', error); throw error; // Rethrow the error if you want to handle it further up the call stack } } /** * Copy extra files specified in package.json to the dist folder * * @param {string} [packagePath='.'] - Path to the package root containing package.json * @returns {Promise<void>} */ async function copyExtraDistFiles(packagePath = '.') { try { const packageJsonPath = path.join(packagePath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; // No package.json, nothing to do } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); if (!packageJson.headlamp || !packageJson.headlamp.extraDist) { return; // No extra files to copy } const extraDist = packageJson.headlamp.extraDist; const distFolder = path.resolve(packagePath, 'dist'); // Create dist folder if it doesn't exist (although it should by this point) if (!fs.existsSync(distFolder)) { fs.mkdirSync(distFolder, { recursive: true }); } // Process all entries in extraDist for (const [target, source] of Object.entries(extraDist)) { const targetPath = path.join(distFolder, target); const sourcePath = path.resolve(packagePath, source); // Skip if source doesn't exist if (!fs.existsSync(sourcePath)) { console.warn(`Warning: extraDist source "${sourcePath}" does not exist, skipping.`); continue; } // Create target directory if needed fs.mkdirSync(path.dirname(targetPath), { recursive: true }); // Copy based on whether it's a directory or file const sourceStats = fs.statSync(sourcePath); if (sourceStats.isDirectory()) { console.log(`Copying extra directory "${sourcePath}" to "${targetPath}"`); fs.copySync(sourcePath, targetPath); } else { console.log(`Copying extra file "${sourcePath}" to "${targetPath}"`); fs.copyFileSync(sourcePath, targetPath); } } console.log('Successfully copied extra dist files'); } catch (error) { console.error('Error copying extra dist files:', error); } } /** * Creates a tarball of the plugin package. The tarball is placed in the outputFolderPath. * It moves files from: * packageName/dist/main.js to packageName/main.js * packageName/package.json to packageName/package.json * And then creates a tarball of the resulting folder. * * @param {string} pluginDir - path to the plugin package. * @param {string} outputDir - folder where the tarball is placed. * * @returns {0 | 1} Exit code, where 0 is success, 1 is failure. */ async function createArchive(pluginDir, outputDir) { const pluginPath = path.resolve(pluginDir); if (!fs.existsSync(pluginPath)) { console.error(`Error: "${pluginPath}" does not exist. Not creating archive.`); return 1; } // Extract name + version from plugin's package.json const packageJsonPath = path.join(pluginPath, 'package.json'); let packageJson = ''; try { packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); } catch (e) { console.error(`Error: Failed to read package.json from "${pluginPath}". Not creating archive.`); return 1; } const sanitizedName = packageJson.name.replace(/@/g, '').replace(/\//g, '-'); const tarballName = `${sanitizedName}-${packageJson.version}.tar.gz`; const outputFolderPath = path.resolve(outputDir); const tarballPath = path.join(outputFolderPath, tarballName); if (!fs.existsSync(outputFolderPath)) { console.log(`"${outputFolderPath}" did not exist, making folder.`); fs.mkdirSync(outputFolderPath, { recursive: true }); } else if (fs.existsSync(tarballPath)) { console.error(`Error: Tarball "${tarballPath}" already exists. Not creating archive.`); return 1; } // Create temporary folder const tempFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'headlamp-plugin-')); // Make sure any extraDist files are in the dist folder before extraction await copyExtraDistFiles(pluginPath); if (extract(pluginPath, tempFolder, false) !== 0) { console.error( `Error: Failed to extract plugin package to "${tempFolder}". Not creating archive.` ); return 1; } const folderName = path.basename(pluginPath); // Create tarball await tar.c( { gzip: true, file: tarballPath, cwd: tempFolder, }, [folderName] ); // Remove temporary folder fs.rmSync(tempFolder, { recursive: true }); console.log(`Created tarball: "${tarballPath}".`); // Print sha256 checksum for convenience const checksum = await calculateChecksum(tarballPath); console.log(`Tarball checksum (sha256): ${checksum}`); return 0; } /** * Start watching for changes, and build again if there are changes. * @returns {Promise<number>} Exit code, where 0 is success. */ async function start() { /** * Copies the built plugin to the app config folder ~/.config/Headlamp/plugins/ * * Adds a webpack config plugin for copying the folder. */ async function copyToPluginsFolder(viteConfig) { const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); // @todo: should the whole package name be used here, // and the load be fixed to use? What about namespace packages? const packageName = packageJson.name.split('/').splice(-1)[0]; const paths = envPaths('Headlamp', { suffix: '' }); const configDir = fs.existsSync(paths.data) ? paths.data : paths.config; const { viteStaticCopy } = await viteCopyPluginPromise; viteConfig.plugins.push( viteStaticCopy({ targets: [ { src: './dist/*', dest: path.join(configDir, 'plugins', packageName), }, { src: './package.json', dest: path.join(configDir, 'plugins', packageName), }, ], }) ); } /** * Inform if @kinvolk/headlamp-plugin is outdated. */ async function informIfOutdated() { console.log('Checking if @kinvolk/headlamp-plugin is up to date...'); child_process.exec('npm outdated --json', (error, stdout) => { if (error) { // npm outdated exit codes 1 when something is not up to date. const result = stdout.toString(); const outdated = JSON.parse(result); if ('@kinvolk/headlamp-plugin' in outdated) { const url = `https://github.com/kubernetes-sigs/headlamp/releases`; console.warn( ' @kinvolk/headlamp-plugin is out of date. Run the following command to upgrade \n' + ` See release notes here: ${url}` + ' npx @kinvolk/headlamp-plugin upgrade' ); return; } } }); } setTimeout(() => { informIfOutdated().catch(error => { console.error('Error checking if @kinvolk/headlamp-plugin is up to date:', error); }); }, 500); const config = (await viteConfigPromise).default; const vite = await vitePromise; if (config.build) { config.build.watch = {}; config.build.sourcemap = 'inline'; } // Add file copy hook to be executed after each build if (config.plugins) { config.plugins.push({ name: 'headlamp-copy-extra-dist', buildEnd: async () => { await copyExtraDistFiles(); }, }); } // Then add the plugins from copyToPluginsFolder which includes ViteStaticCopy await copyToPluginsFolder(config); try { await vite.build(config); } catch (e) { console.error(e); console.error('Failed to start watching for changes.'); return 1; } return 0; } /** * Run script on a plugin package or folder of plugin packages. * * @param packageFolder {string} - folder where the package, or folder of packages is. * @param scriptName {string} - name of the script to run. * @param cmdLine {string} - command line to run. * @param env {object} - environment variables to run the command with. * @returns {0 | 1} - Exit code, where 0 is success, 1 is failure. */ function runScriptOnPackages(packageFolder, scriptName, cmdLine, env) { if (!fs.existsSync(packageFolder)) { console.error(`"${packageFolder}" does not exist. Not ${scriptName}-ing.`); return 1; } const oldCwd = process.cwd(); const runOnPackageReturn = { success: 0, notThere: 1, issue: 2, }; function runOnPackage(folder) { if (!fs.existsSync(path.join(folder, 'package.json'))) { return runOnPackageReturn.notThere; } process.chdir(folder); if (!fs.existsSync('node_modules')) { console.log(`No node_modules in "${folder}" found. Running npm install...`); try { child_process.execSync('npm install', { stdio: 'inherit', encoding: 'utf8', }); } catch (e) { console.error(`Problem running 'npm install' inside of "${folder}"\r\n`); process.chdir(oldCwd); return runOnPackageReturn.issue; } console.log(`Finished npm install.`); } // See if the cmd is in the: // - package/node_modules/.bin // - package/../node_modules/.bin // - the npx node_modules/.bin // If not, just use the original cmdLine and hope for the best :) let cmdLineToUse = cmdLine; const scriptCmd = cmdLine.split(' ')[0]; const scriptCmdRest = cmdLine.split(' ').slice(1).join(' '); const nodeModulesBinCmd = path.join('node_modules', '.bin', scriptCmd); const upNodeModulesBinCmd = path.join('../', nodeModulesBinCmd); // When run as npx, find it in the node_modules npx uses const headlampPluginBin = fs.realpathSync(process.argv[1]); const npxBinCmd = path.join( path.dirname(headlampPluginBin), '..', '..', '..', '..', nodeModulesBinCmd ); if (fs.existsSync(nodeModulesBinCmd)) { cmdLineToUse = nodeModulesBinCmd + ' ' + scriptCmdRest; } else if (fs.existsSync(upNodeModulesBinCmd)) { cmdLineToUse = upNodeModulesBinCmd + ' ' + scriptCmdRest; } else if (fs.existsSync(npxBinCmd)) { cmdLineToUse = npxBinCmd + ' ' + scriptCmdRest; } else { console.warn( `"${scriptCmd}" not found in "${resolve(nodeModulesBinCmd)}" or "${resolve( upNodeModulesBinCmd )}" or "${resolve(npxBinCmd)}".` ); } console.log(`"${folder}": ${scriptName}-ing, :${cmdLineToUse}:...`); const [cmd, ...args] = cmdLineToUse.split(' '); try { child_process.execFileSync(cmd, args, { stdio: 'inherit', encoding: 'utf8', env: { ...process.env, ...(env || {}) }, }); } catch (e) { console.error(`Problem running ${scriptName} inside of "${folder}"\r\n`); process.chdir(oldCwd); return runOnPackageReturn.issue; } console.log(`Done ${scriptName}-ing: "${folder}".\r\n`); process.chdir(oldCwd); return runOnPackageReturn.success; } function runOnFolderOfPackages(packageFolder) { const folders = fs.readdirSync(packageFolder, { withFileTypes: true }).filter(fileName => { return ( fileName.isDirectory() && fs.existsSync(path.join(packageFolder, fileName.name, 'package.json')) ); }); if (folders.length === 0) { return { error: runOnPackageReturn.notThere, failedFolders: [], }; } const errorFolders = folders.map(folder => { const folderToProcess = path.join(packageFolder, folder.name); return { error: runOnPackage(folderToProcess), folder: folderToProcess, }; }); const failedErrorFolders = errorFolders.filter( errFolder => errFolder.error !== runOnPackageReturn.success ); if (failedErrorFolders.length === 0) { return { error: runOnPackageReturn.success, failedFolders: [], }; } return { error: runOnPackageReturn.issue, failedFolders: failedErrorFolders.map(errFolder => path.basename(errFolder.folder)), }; } const exitCode = runOnPackage(packageFolder); if (exitCode === runOnPackageReturn.notThere) { const folderErr = runOnFolderOfPackages(packageFolder); if (folderErr.error === runOnPackageReturn.notThere) { console.error( `"${resolve(packageFolder)}" does not contain a package or packages. Not ${scriptName}-ing.` ); return 1; // failed } else if (folderErr.error === runOnPackageReturn.issue) { console.error( `Some in "${resolve(packageFolder)}" failed. Failed folders: ${folderErr.failedFolders.join( ', ' )}` ); return 1; // failed } } return exitCode > 0 ? 1 : 0; } /** * Build the plugin package or folder of packages for production. * * @param packageFolder {string} - folder where the package, or folder of packages is. * @returns {Promise<0 | 1>} Exit code, where 0 is success, 1 is failure. */ async function build(packageFolder) { if (!fs.existsSync(packageFolder)) { console.error(`"${packageFolder}" does not exist. Not building.`); return 1; } const oldCwd = process.cwd(); async function buildPackage(folder) { if (!fs.existsSync(path.join(folder, 'package.json'))) { return false; } process.chdir(folder); console.log(`Building "${folder}" for production...`); const config = await viteConfigPromise; const vite = await vitePromise; try { await vite.build(config.default); // Copy extra dist files after successful build await copyExtraDistFiles('.'); console.log(`Finished building "${folder}" for production.`); } catch (e) { console.error(e); console.error(`Failed to build "${folder}" for production.`); process.exit(1); } process.chdir(oldCwd); return true; } function buildFolderOfPackages() { const folders = fs.readdirSync(packageFolder, { withFileTypes: true }).filter(fileName => { return ( fileName.isDirectory() && fs.existsSync(path.join(packageFolder, fileName.name, 'package.json')) ); }); folders.forEach(folder => { const folderToBuild = path.join(packageFolder, folder.name); if (!buildPackage(folderToBuild)) { console.error(`"${folderToBuild}" does not contain a package. Not building.`); } }); return folders.length !== 0; } if (!(buildPackage(packageFolder) || buildFolderOfPackages())) { console.error(`"${packageFolder}" does not contain a package or packages. Not building.`); } return 0; } /** * Format plugin code with prettier. Format the plugin package or folder of packages. * * @param packageFolder {string} - folder where the package, or folder of packages is. * @param check {boolean} - if true, check if the code is checked for formatting, but don't format it. * @returns {0 | 1} Exit code, where 0 is success, 1 is failure. */ function format(packageFolder, check) { const cmdLine = check ? `prettier --config package.json --check src` : 'prettier --config package.json --write --cache src'; return runScriptOnPackages(packageFolder, 'format', cmdLine, {}); } /** * Use `npm outdated` to find which dependencies are not up to date. * * @returns a dict keyed by package name * * @see https://docs.npmjs.com/cli/v8/commands/npm-outdated * * #### Example * ```js * { * "@kinvolk/headlamp-plugin": { * "current": "0.5.0", * "wanted": "0.5.1", * "latest": "0.5.1", * "dependent": "pod-counter", * "location": "/home/rene/dev/headlamp/plugins/examples/pod-counter/node_modules/@kinvolk/headlamp-plugin" * } * } * ``` * * #### Example: Nothing needs updating? * ```js * {} * ``` */ function getNpmOutdated() { let result = null; try { result = child_process.execSync('npm outdated --json', { encoding: 'utf8', }); } catch (error) { // npm outdated exit codes 1 when something is not up to date. result = error.stdout.toString(); } return JSON.parse(result); } /** * Upgrade package automatically, updating headlamp-plugin version. * * In the future this could be used for other upgrade tasks. * * @param packageFolder {string} - folder where the package, or folder of packages is. * @parm skipPackageUpdates {boolean} - do not upgrade packages if true. * @param headlampPluginVersion {string} - tag or version of headlamp-plugin to upgrade to. * @returns {0 | 1} Exit code, where 0 is success, 1 is failure. */ function upgrade(packageFolder, skipPackageUpdates, headlampPluginVersion) { /** * Files from the template might not be there. * * Either because they created the package themselves, * or used an old version headlamp-plugin create. * * Assumes we are in the package folder. */ function addMissingTemplateFiles() { const missingFiles = [ path.join('src', 'headlamp-plugin.d.ts'), path.join('.vscode', 'extensions.json'), path.join('.vscode', 'settings.json'), path.join('.vscode', 'tasks.json'), 'tsconfig.json', ]; const templateFolder = path.resolve(__dirname, '..', 'template'); missingFiles.forEach(pathToCheck => { const from = path.join(templateFolder, pathToCheck); const to = path.join('.', pathToCheck); // only copy it if it doesn't exist if (!fs.existsSync(to)) { console.log(`Adding missing file: "${to}"`); // Make the folder in to there if it is not. fs.mkdirSync(path.dirname(to), { recursive: true }); fs.copyFileSync(from, to); } // Add file if it is different if (fs.readFileSync(from, 'utf8') !== fs.readFileSync(to, 'utf8')) { console.log(`Updating file: "${to}"`); fs.copyFileSync(from, to); } }); } /** * If there are material-ui v4 files in src/ folder, upgrade them to v5. * * @see https://mui.com/material-ui/migration/migration-v4/#run-codemods */ function upgradeMui() { const hasMaterialUI = fs .readdirSync('src', { withFileTypes: true }) .filter(dirent => dirent.isFile() && dirent.name.endsWith('.ts')) .map(dirent => path.join('src', dirent.name)) .filter(path => fs.readFileSync(path, 'utf8').includes('@material-ui')); if (hasMaterialUI.length > 0) { console.log('Found files with "@material-ui". Upgrading material-ui v4 to mui v5...'); const cmd = 'npx @mui/codemod v5.0.0/preset-safe src'; if (runCmd(cmd, '.')) { console.error(`Failed to upgrade material-ui v4 to mui v5.`); return false; } } return true; } /** * Some files should not be there anymore. * * Assumes we are in the package folder. */ function removeFiles() { const filesToRemove = ['jsconfig.json']; filesToRemove.forEach(pathToCheck => { const removePath = path.join('.', pathToCheck); if (fs.existsSync(removePath)) { console.log(`Removing file: "${removePath}"`); fs.unlinkSync(removePath); } }); } /** * Adds missing config into package.json */ function addMissingConfiguration() { const templateFolder = path.resolve(__dirname, '..', 'template'); const packageJsonPath = path.join('.', 'package.json'); const templatePackageJson = JSON.parse( fs.readFileSync(path.join(templateFolder, 'package.json'), 'utf8') ); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); let configChanged = false; /** * replaceNestedKeys is used to replace nested keys in the package.json file. * * It only replaces the properties specified, and does so if they * are missing or not equal to the ones in the template. * * @param {string} keyName top-level key in the package.json file that contains the nested keys to be replaced. * @param {string[]} subProperties names of the nested keys to be replaced in keyName. */ function replaceNestedKeys(keyName, subProperties) { subProperties.forEach(key => { if (packageJson[keyName][key] !== templatePackageJson[keyName][key]) { packageJson[keyName][key] = templatePackageJson[keyName][key]; configChanged = true; console.log( `Updated package.json field ${keyName}.${key}: ${JSON.stringify( packageJson[keyName][key] )}` ); } }); } // Update these scripts keys to match the template. replaceNestedKeys('scripts', ['tsc', 'storybook', 'test', 'storybook-build']); // replace top level keys const checkKeys = ['eslintConfig', 'prettier', 'overrides']; checkKeys.forEach(key => { if (JSON.stringify(packageJson[key]) !== JSON.stringify(templatePackageJson[key])) { packageJson[key] = templatePackageJson[key]; configChanged = true; console.log(`Updated package.json field "${key}": ${JSON.stringify(packageJson[key])}`); } }); if (configChanged) { fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, ' ') + '\n'); } } /** * Runs the command (more conveniently than node). * * @param {string} cmd - that you want to run. * @param {string} folder = folder to run inside. * @returns status code, so 0 on success and failure otherwise. */ function runCmd(cmd, folder) { console.log(`Running cmd:"${cmd}" inside of ${folder} abs: "${resolve(folder)}"`); try { child_process.execSync(cmd, { stdio: 'inherit', encoding: 'utf8', }); } catch (e) { console.error(`Problem running ${cmd}`); return e.status; } return 0; } /** * In order to more robustly upgrade packages, * we reset the package-lock.json and node_modules. * * @returns true unless there is a problem. */ function resetPackageLock() { if (fs.existsSync('node_modules')) { console.log(`Resetting node_modules folder for more robust package upgrade...`); // Remove the node_modules folder fs.rmSync('node_modules', { recursive: true }); if (fs.existsSync('node_modules')) { console.error(`Failed to remove node_modules folder.`); return false; } } if (fs.existsSync('package-lock.json')) { console.log(`Resetting package-lock.json file for more robust package upgrade...`); fs.unlinkSync('package-lock.json'); } return true; } /** * Upgrades "@kinvolk/headlamp-plugin" dependency to latest or given version. * * @returns true unless there is a problem with the upgrade. */ function upgradeHeadlampPlugin() { const theTag = headlampPluginVersion ? headlampPluginVersion : 'latest'; if ( headlampPluginVersion !== undefined || '@kinvolk/headlamp-plugin' in getNpmOutdated() || !fs.existsSync('node_modules') ) { // Upgrade the @kinvolk/headlamp-plugin const cmd = `npm install @kinvolk/headlamp-plugin@${theTag} --save`; if (runCmd(cmd, '.')) { return false; } } return true; } /** * Removes "@headlamp-k8s/eslint-config" dependency if it is there. * * It is a transitive dependency of "@kinvolk/headlamp-plugin", and * does not need to be there anymore. * * @returns true unless there is a problem with the upgrade. */ function removeEslintConfig() { const packageJsonPath = path.join('.', 'package.json'); let packageJson = {}; try { packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); } catch (e) { console.error(`Error: Failed to read package.json from "${packageJsonPath}".`); return false; } const oldVersion = packageJson.devDependencies['@headlamp-k8s/eslint-config']; // remove @headlamp-k8s/eslint-config if it is there if (oldVersion) { const cmd = `npm remove @headlamp-k8s/eslint-config --save`; if (runCmd(cmd, '.')) { return false; } } return true; } /** * Upgrade a single package in a folder. * * @param {string} folder - where the package is. * @param {boolean | undefined} skipPackageUpdates - If true do not update packages. * @returns {boolean} - true if it is successful upgrading */ function upgradePackage(folder, skipPackageUpdates) { const oldCwd = process.cwd(); process.chdir(folder); console.log(`Upgrading "${folder}"...`); addMissingTemplateFiles(); addMissingConfiguration(); removeFiles(); let failed = false; let reason = ''; if (skipPackageUpdates !== true) { if (!failed && !removeEslintConfig()) { failed = true; reason = 'removing @headlamp-k8s/eslint-config failed.'; } if (!failed && !resetPackageLock()) { failed = true; reason = 'resetting package-lock.json and node_modules failed.'; } if (!failed && !upgradeHeadlampPlugin()) { failed = true; reason = 'upgrading @kinvolk/headlamp-plugin failed.'; } if (!failed && !upgradeMui()) { failed = true; reason = 'upgrading from material-ui 4 to mui 5 failed.'; } if (!failed && runCmd('npm audit fix', folder)) { console.warn('"npm audit fix" failed. You may need to inspect your dependencies manually.'); } if (!failed && runCmd('npm run format', folder)) { failed = true; reason = '"npm run format" failed.'; } if (!failed && runCmd('npm run lint', folder)) { failed = true; reason = '"npm run lint" failed.'; } if (!failed && runCmd('npm run tsc', folder)) { failed = true; reason = '"npm run tsc" failed'; } } if (failed) { console.error(`Failed upgrading: "${folder}". Reason: ${reason}`); } else { console.log(`Successfully upgraded: "${folder}".`); } process.chdir(oldCwd); return !failed; } /** * Upgrade each package inside the folder. * * @param {fs.Dirent[]} packageFolders - folders to upgrade. * @param {boolean | undefined} skipPackageUpdates - If true do not update packages. * @returns {boolean} - true if all of them are upgraded successfully. */ function upgradeFolderOfPackages(packageFolders, skipPackageUpdates) { let failed = ''; for (const folder of packageFolders) { if (failed) { console.error( `Skipping "${folder.name}", because "${failed}" did not upgrade successfully.` ); continue; } const folderToUpgrade = path.join(packageFolder, folder.name); if (!upgradePackage(folderToUpgrade, skipPackageUpdates)) { failed = folderToUpgrade; } } return !failed; } if (!fs.existsSync(packageFolder)) { console.error(`"${packageFolder}" does not exist. Not upgrading.`); return 1; } if (fs.existsSync(path.join(packageFolder, 'package.json'))) { if (!upgradePackage(packageFolder, skipPackageUpdates)) { return 1; } } else { const packageFolders = fs .readdirSync(packageFolder, { withFileTypes: true }) .filter(fileName => { return ( fileName.isDirectory() && fs.existsSync(path.join(packageFolder, fileName.name, 'package.json')) ); }); if (packageFolders.length === 0) { console.error(`"${packageFolder}" does not contain a package or packages. Not upgrading.`); return 1; } if (!upgradeFolderOfPackages(packageFolders, skipPackageUpdates)) { return 1; } } return 0; } /** * Lint code with eslint. Lint the plugin package or folder of packages. * * @param packageFolder {string} - folder where the package, or folder of packages is. * @param fix {boolean} - automatically fix problems. * @returns {0 | 1} - Exit code, where 0 is success, 1 is failure. */ function lint(packageFolder, fix) { const script = `eslint --cache -c package.json --max-warnings 0 --ext .js,.ts,.tsx src/${ fix ? ' --fix' : '' }`; return runScriptOnPackages(packageFolder, 'lint', script, {}); } /** * Type check code with tsc. Type check the plugin package or folder of packages. * * @param packageFolder {string} - folder where the package, or folder of packages is. * @returns {0 | 1} - Exit code, where 0 is success, 1 is failure. */ function tsc(packageFolder) { const script = 'tsc --noEmit'; return runScriptOnPackages(packageFolder, 'tsc', script, {}); } /** * Start storybook. * * @param packageFolder {string} - folder where the package is. * @returns {0 | 1} Exit code, where 0 is success, 1 is failure. */ function storybook(packageFolder) { try { child_process.execSync( './node_modules/.bin/storybook dev -p 6007 -c node_modules/@kinvolk/headlamp-plugin/config/.storybook', { stdio: 'inherit', cwd: packageFolder, encoding: 'utf8', } ); } catch (e) { console.error( `Problem running storybook dev inside of "${packageFolder}" abs: "${resolve(packageFolder)}"` ); return 1; } return 0; } /** * Build storybook. * * @param packageFolder {string} - folder where the package is. * @returns {0 | 1} Exit code, where 0 is success, 1 is failure. */ function storybook_build(packageFolder) { const script = `storybook build -c node_modules/@kinvolk/headlamp-plugin/config/.storybook`; return runScriptOnPackages(packageFolder, 'storybook build', script, {}); } /** * Run tests. * * @param packageFolder {string} - folder where the package is. * @returns {0 | 1} Exit code, where 0 is success, 1 is failure. */ function test(packageFolder) { const script = `vitest -c node_modules/@kinvolk/headlamp-plugin/config/vite.config.mjs`; return runScriptOnPackages(packageFolder, 'test', script, { UNDER_TEST: 'true' }); } // const headlampPluginBin = fs.realpathSync(process.argv[1]); // console.log('headlampPluginBin path:', headlampPluginBin); yargs(process.argv.slice(2)) .command( 'build [package]', 'Build the plugin, or folder of plugins. <package> defaults to current working directory.', yargs => { yargs.positional('package', { describe: 'Package or folder of packages to build', type: 'string', default: '.', }); }, async argv => { // @ts-ignore process.exitCode = await build(argv.package); } ) .command('start', 'Watch for changes and build plugin.', {}, async () => { process.exitCode = await start(); }) .command( 'create <name>', 'Create a new plugin folder.', yargs => { yargs .positional('name', { describe: 'Name of package', type: 'string', }) .option('link', { describe: 'For development of headlamp-plugin itself, so it uses npm link @kinvolk/headlamp-plugin.', type: 'boolean', }); }, argv => { // @ts-ignore process.exitCode = create(argv.name, argv.link); } ) .command( 'extract <pluginPackages> <outputPlugins>', 'Copies folders of packages from pluginPackages/packageName/dist/main.js ' + 'to outputPlugins/packageName/main.js.', yargs => { yargs.positional('pluginPackages', { describe: 'A folder of plugin packages that have been built with dist/main.js in them.' + 'Can also be a single package folder.', type: 'string', }); yargs.positional('outputPlugins', { describe: 'A plugins folder (eg. ".plugins") to extract plugins to. ' + 'The output is a series of packageName/main.js. ' + 'Creates this folder if it does not exist.', type: 'string', }); }, argv => { // @ts-ignore process.exitCode = extract(argv.pluginPackages, argv.outputPlugins); } ) .command( 'package [pluginPath] [outputDir]', 'Creates a tarball of the plugin package in the format Headlamp expects.', yargs => { yargs.positional('pluginPath', { describe: 'A folder of a plugin package that have been built with dist/main.js in it.' + ' Defaults to current working directory.', type: 'string', }); yargs.positional('outputDir', { describe: 'The destination folder in which to create the archive.' + 'Creates this folder if it does not exist.', type: 'string', }); }, async argv => { let pluginPath = argv.pluginPath; if (!pluginPath) { pluginPath = process.cwd(); } let outputDir = argv.outputDir; if (!outputDir) { outputDir = process.cwd(); } process.exitCode = await createArchive(pluginPath, outputDir); } ) .command( 'format [package]', 'format the plugin code with prettier. <package> defaults to current working directory.' + ' Can also be a folder of packages.', yargs => { yargs .positional('package', { describe: 'Package to code format', type: 'string', default: '.', }) .option('check', { describe: 'Check the formatting without changing files', type: 'boolean', }); }, argv => { // @ts-ignore process.exitCode = format(argv.package, argv.check); } ) .command( 'lint [package]', 'Lint the plugin for coding issues with eslint. ' + '<package> defaults to current working directory.' + ' Can also be a folder of packages.', yargs => { yargs .positional('package', { describe: 'Package to lint', type: 'string', default: '.', }) .option('fix', { describe: 'Automatically fix problems', type: 'boolean', }); }, argv => { // @ts-ignore process.exitCode = lint(argv.package, argv.fix); } ) .command( 'tsc [package]', 'Type check the plugin for coding issues with tsc. ' + '<package> defaults to current working directory.' + ' Can also be a folder of packages.', yargs => { yargs.positional('package', { describe: 'Package to type check', type: 'string', default: '.', }); }, argv => { // @ts-ignore process.exitCode = tsc(argv.package); } ) .command( 'storybook [package]', 'Start storybook. <package> defaults to current working directory.', yargs => { yargs.positional('package', { describe: 'Package to start storybook for', type: 'string', default: '.', }); }, argv => { // @ts-ignore process.exitCode = storybook(argv.package); } ) .command( 'storybook-build [package]', 'Build static storybook. <package> defaults to current working directory.' + ' Can also be a folder of packages.', yargs => { yargs.positional('package', { describe: 'Package to build storybook for', type: 'string', default: '.', }); }, argv => { // @ts-ignore process.exitCode = storybook_build(argv.package); } ) .command( 'upgrade [package]', 'Upgrade the plugin to latest headlamp-plugin; ' + 'upgrades headlamp-plugin and audits packages, formats, lints, type checks.' + '<package> defaults to current working directory. Can also be a folder of packages.', yargs => { yargs .positional('package', { describe: 'Package to upgrade', type: 'string', default: '.', }) .option('skip-package-updates', { describe: 'For development of headlamp-plugin itself, so it does not do package updates.', type: 'boolean', }) .option('headlamp-plugin-version', { describe: 'Use a specific headlamp-plugin-version when upgrading packages. Defaults to "latest".', type: 'string', }); }, argv => { // @ts-ignore process.exitCode = upgrade(argv.package, argv.skipPackageUpdates, argv.headlampPluginVersion); } ) .command( 'test [package]', 'Test. <package> defaults to current working directory.' + ' Can also be a folder of packages.', yargs => { yargs.positional('package', { describe: 'Package to test', type: 'string', default: '.', }); }, argv => { // @ts-ignore process.exitCode = test(argv.package); } ) .command( 'install [URL]', 'Install plugin(s) from a configuration file or a plugin artifact Hub URL', yargs => { return yargs .positional('URL', { describe: 'URL of the plugin to install', type: 'string', }) .option('config', { alias: 'c', describe: 'Path to plugin configuration file', type: 'string', }) .option('folderName', { describe: 'Name of the folder to install the plugin into', type: 'string', }) .option('headlampVersion', { describe: 'Version of headlamp to install the plugin into', type: 'string', }) .option('quiet', { alias: 'q', describe: 'Do not print logs', type: 'boolean', }) .option('watch', { alias: 'w', describe: 'Watch config file for changes and automatically reinstall plugins', type: 'boolean', }) .check(argv => { if (!argv.URL && !argv.config) { throw new Error('Either URL or --config must be specified'); } if (argv.URL && argv.config) { throw new Error('Cannot specify both URL and --config'); } if (argv.watch && !argv.config) { throw new Error('Watch option can only be used with --config'); } return true; }); }, async argv => { const { URL, config, folderName, headlampVersion, quiet, watch } = argv; try { const progressCallback = quiet ? () => {} : data => { const { type = 'info', message, raise = true } = data; if (config && !URL) { // bulk installation let prefix = ''; if (data.current || data.total || data.plugin) { prefix = `${data.current} of ${data.total} (${data.plugin}): `; } if (type === 'info' || type === 'success') { console.log(`${prefix}${type}: ${message}`); } else if (type === 'error' && raise) { throw new Error(message); } else { console.error(`${prefix}${type}: ${message}`); } } else { if (type === 'error' || type === 'success') { console.error(`${type}: ${message}`); } } }; /** * @param {string} configPath */ async function installFromConfig(configPath) { const installer = new MultiPluginManager(folderName, headlampVersion, progressCallback); const result = await installer.installFromConfig(configPath); if (result.failed > 0) { throw new Error(`${result.failed} plugins failed to install`); } } if (URL) { // Single plugin installation try { await PluginManager.install(URL, folderName, headlampVersion, progressCallback); } catch (e) { console.error(e.message); process.exit(1); // Exit with error status } } else if (config) { // Bulk installation from config try { await installFromConfig(config); } catch (error) { console.error('Installation failed', { error: error.message, stack: error.stack, }); } if (watch) { console.log(`Watching ${config} for changes...`); fs.watch(config, async eventType => { if (eventType === 'change') { console.log(`Config file changed, reinstalling plugins...`); try { await installFromConfig(config); console.log('Plugins reinstalled successfully'); } catch (error) { console.error('Installation failed', { error: error.message, stack: error.stack, }); } } }); // Keep the process running process.stdin.resume(); // Handle graceful shutdown process.on('SIGINT', () => { console.log('\nStopping config file watch'); process.exit(0); }); } } } catch (error) { console.error('Installation failed', { error: error.message, stack: error.stack, }); process.exit(1); } } ) .command( 'update <pluginName>', 'Update a plugin to the latest version', yargs => { yargs .positional('pluginName', { describe: 'Name of the plugin to update', type: 'string', }) .positional('folderName', { describe: 'Name of the folder that contains the plugin', type: 'string', }) .positional('headlampVersion', { describe: 'Version of headlamp to update the plugin into', type: 'string', }) .option('quiet', { alias: 'q', describe: 'Do not print logs', type: 'boolean', }); }, async argv => { const { pluginName, folderName, headlam