@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
1,511 lines (1,331 loc) ⢠74.1 kB
JavaScript
#!/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, MultiPluginManager } = require('@headlamp-k8s/pluginctl');
const { table } = require('table');
const tar = require('tar');
// On Windows, use the .cmd shim so execFileSync can run it without a shell.
const binExt = process.platform === 'win32' ? '.cmd' : '';
// 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 ci" inside of the folder.
*
* @param {string} name - name of package and output folder.
* @param {boolean} link - if we link @kinvolk/headlamp-plugin for testing
* @param {boolean} noInstall - if we skip installing with "npm ci"
* @returns {0 | 1 | 2 | 3} Exit code, where 0 is success, 1, 2, and 3 are failures.
*/
function create(name, link, noInstall) {
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 packageLockPath = path.join(dstFolder, 'package-lock.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(packageLockPath);
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,
});
}
if (noInstall) {
console.log('Skipping dependency installation...');
} else {
console.log('Installing...');
// In the package-lock.json we try to update the integrity field of the
// @kinvolk/headlamp-plugin package to match the integrity field on npmjs
// registry for this version. If that fails (e.g. version not published yet),
// we fall back to running `npm install` instead of `npm ci` and skip the update.
let useNpmCi = false;
try {
const npmJsPkgResponse = child_process.execFileSync(
'npm' + binExt,
['view', `@kinvolk/headlamp-plugin@${headlampPluginPkg.version}`, 'dist', '--json'],
{ encoding: 'utf8' }
);
const npmJsPkg = JSON.parse(npmJsPkgResponse);
const npmJsIntegrity = npmJsPkg.integrity;
// Now replace integrity in the package-lock.json to match npmjs registry integrity.
// "node_modules/@kinvolk/headlamp-plugin": {
// "version": "0.13.0-alpha.13",
// "resolved": "...",
// "integrity": "sha512-..."
const packageLockContent = fs.readFileSync(packageLockPath, 'utf8');
const integrityPattern = new RegExp(
`("node_modules/@kinvolk/headlamp-plugin": \\{[\\s\\S]*?"integrity": ")[^"]+(")`,
'g'
);
const packageLockContentNew = packageLockContent.replace(
integrityPattern,
`$1${npmJsIntegrity}$2`
);
if (packageLockContent !== packageLockContentNew) {
fs.writeFileSync(packageLockPath, packageLockContentNew);
}
useNpmCi = true;
} catch (e) {
// no warning, we just fall back to npm install
}
try {
if (useNpmCi) {
console.log('Running npm ci...');
child_process.execSync('npm ci', {
stdio: 'inherit',
cwd: dstFolder,
encoding: 'utf8',
});
} else {
console.log('Running npm install...');
child_process.execSync('npm install', {
stdio: 'inherit',
cwd: dstFolder,
encoding: 'utf8',
});
}
} catch (e) {
console.error(
`Problem running ${
useNpmCi ? 'npm ci' : '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}".`);
if (fs.statSync(srcFile).isDirectory()) {
fs.copySync(srcFile, destFile);
} else {
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}".`);
if (fs.statSync(srcFile).isDirectory()) {
fs.copySync(srcFile, destFile);
} else {
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'));
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 });
}
// Auto-detect and copy locales folder if it exists (for i18n support)
const localesPath = path.resolve(packagePath, 'locales');
if (fs.existsSync(localesPath)) {
const targetLocalesPath = path.join(distFolder, 'locales');
console.log(`Auto-copying locales directory "${localesPath}" to "${targetLocalesPath}"`);
fs.copySync(localesPath, targetLocalesPath);
}
// Process manual extraDist configuration if it exists
if (packageJson.headlamp && packageJson.headlamp.extraDist) {
const extraDist = packageJson.headlamp.extraDist;
// 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;
}
// Skip if this is locales and we already auto-copied it
if (target === 'locales' && fs.existsSync(path.join(distFolder, 'locales'))) {
console.log(`Skipping manual locales copy - already auto-copied`);
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;
}
/**
* Inject selected environment variables into Vite config's define field.
* Supports both import.meta.env and process.env styles.
*
* @param {object} config - Vite config object to mutate
* @param {string[]} extraKeys - Additional Node-style keys to expose
*/
function injectEnvVars(config, extraKeys = ['NODE_ENV']) {
if (!config.define) {
config.define = {};
}
Object.keys(process.env)
.filter(
key =>
key.startsWith('REACT_APP_') || key.startsWith('HEADLAMP_APP_') || extraKeys.includes(key)
)
.forEach(key => {
const value = JSON.stringify(process.env[key]);
console.log(`Injecting env var: ${key} = ${value}`);
config.define[`import.meta.env.${key}`] = value;
config.define[`process.env.${key}`] = value;
});
return config;
}
/**
* 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),
},
],
})
);
viteConfig.plugins.push({
name: 'headlamp-log-files-copied-to-plugin-folder',
buildEnd: async () => {
const destDir = path.join(configDir, 'plugins', packageName);
if (!fs.existsSync(destDir)) {
console.log(`No files copied to "${destDir}" (directory does not exist).`);
return;
}
try {
const files = fs.readdirSync(destDir);
if (files.length === 0) {
console.log(`No files found in "${destDir}".`);
return;
}
files.forEach(file => {
const destPath = path.join(destDir, file);
const srcPath =
file === 'package.json' ? path.resolve('package.json') : path.resolve('dist', file);
console.log(`Copied "${srcPath}" -> "${destPath}"`);
});
} catch (err) {
console.error('Error logging copied files:', err);
}
},
});
}
/**
* 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);
// Read package.json to get plugin name for dev mode
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const pluginName = packageJson.name || path.basename(process.cwd());
const config = (await viteConfigPromise).default;
const vite = await vitePromise;
if (config.build) {
config.build.watch = {};
config.build.sourcemap = 'inline';
}
if (typeof process.env.NODE_ENV === 'undefined') {
process.env.NODE_ENV = 'development';
}
injectEnvVars(config);
// Add plugin name injection plugin for dev mode
if (!config.plugins) {
config.plugins = [];
}
const { pluginNameInjection } = await import('../config/vite-plugin-name-injection.mjs');
config.plugins.push(pluginNameInjection({ pluginName }));
// 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 + binExt);
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);
// Read package.json to get plugin name
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const pluginName = packageJson.name || path.basename(folder);
console.log(`Building "${folder}" for production with plugin name: ${pluginName}...`);
const config = await viteConfigPromise;
const vite = await vitePromise;
// Clone the config and add plugin name injection
const buildConfig = { ...config.default };
// Add plugin name injection to the plugins array
if (!buildConfig.plugins) {
buildConfig.plugins = [];
}
if (typeof process.env.NODE_ENV === 'undefined') {
process.env.NODE_ENV = 'production';
}
injectEnvVars(buildConfig);
// Import the plugin name injection plugin
const { pluginNameInjection } = await import('../config/vite-plugin-name-injection.mjs');
buildConfig.plugins.push(pluginNameInjection({ pluginName }));
try {
await vite.build(buildConfig);
// 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',
'AGENTS.md',
];
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.existsSync(to)) {
const fromContent = fs.readFileSync(from, 'utf8');
const toContent = fs.readFileSync(to, 'utf8');
if (fromContent !== toContent) {
console.log(`Updating file: "${to}"`);
fs.writeFileSync(to, fromContent);
}
}
});
}
/**
* 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', 'i18n']);
// 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' });
}
/**
* Set up or update i18n for a plugin package.
* This will configure the plugin for internationalization by:
* 1. Adding "i18n": ["en", "es", ...] to package.json headlamp config with supported locales
* 2. Creating the locales directory structure
* 3. Adding npm script for i18n extraction using bundled i18next-parser
* 4. Creating i18next parser configuration
* 5. Running i18next-parser to extract translatable strings
*
* @param packageFolder {string} - folder where the package is.
* @returns {0 | 1} Exit code, where 0 is success, 1 is failure.
*/
function setupI18n(packageFolder) {
if (!fs.existsSync(packageFolder)) {
console.error(`"${packageFolder}" does not exist. Not setting up i18n.`);
return 1;
}
const oldCwd = process.cwd();
/**
* Set up i18n for a single package.
* @param {string} folder - folder where the package is.
* @returns {boolean} - true if successful, false otherwise.
*/
function setupI18nForPackage(folder) {
if (!fs.existsSync(path.join(folder, 'package.json'))) {
return false;
}
process.chdir(folder);
console.log(`Setting up i18n for "${folder}"...`);
try {
// Read and update package.json
const packageJsonPath = 'package.json';
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Initialize headlamp config if it doesn't exist
if (!packageJson.headlamp) {
packageJson.headlamp = {};
}
// Check if i18n is already enabled (array of locales)
if (Array.isArray(packageJson.headlamp.i18n) && packageJson.headlamp.i18n.length > 0) {
console.log(
`ā
i18n is already enabled for ${
packageJson.name
} with locales: ${packageJson.headlamp.i18n.join(', ')}`
);
} else {
// Enable i18n with empty locales array (locales will be added when specified)
packageJson.headlamp.i18n = [];
console.log(`ā
Enabled i18n in package.json for ${packageJson.name}`);
console.log(`š” To add locales, use: npm run i18n <locale> (e.g., npm run i18n es)`);
}
// Add i18n script if not already present
if (!packageJson.scripts) {
packageJson.scripts = {};
}
if (!packageJson.scripts.i18n) {
packageJson.scripts.i18n = 'headlamp-plugin i18n';
console.log(`š Added npm i18n script`);
}
// Write updated package.json
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
// Create locales directory structure
const localesDir = path.join('locales');
if (!fs.existsSync(localesDir)) {
fs.mkdirSync(localesDir, { recursive: true });
console.log(`š Created ${localesDir} directory`);
}
// Create language directories for the locales specified in package.json
const supportedLocales = packageJson.headlamp.i18n || [];
supportedLocales.forEach(locale => {
const localeDir = path.join(localesDir, locale);
if (!fs.existsSync(localeDir)) {
fs.mkdirSync(localeDir, { recursive: true });
// Create empty translation.json file
const translationFile = path.join(localeDir, 'translation.json');
fs.writeFileSync(translationFile, '{\n}\n');
console.log(`š Created ${locale} locale directory`);
}
});
// Check if there's a local config file - if not, we'll use the bundled one
const localConfigPath = 'i18next-parser.config.js';
if (fs.existsSync(localConfigPath)) {
console.log(`āļø Using existing local i18next-parser configuration`);
} else {
console.log(`āļø Using default i18next-parser configuration from headlamp-plugin`);
}
// Run i18next-parser to extract translatable strings
console.log(`š Extracting translatable strings from source code...`);
const i18nextPath = path.join(__dirname, '..', 'node_modules', '.bin', 'i18next' + binExt);
const bundledConfigPath = path.join(__dirname, '..', 'config', 'i18next-parser.config.js');
const { execFileSync } = require('child_process');
try {
execFileSync(i18nextPath, ['src/**/*.{ts,tsx,js,jsx}', '-c', bundledConfigPath], {
stdio: 'inherit',
cwd: process.cwd(),
});
console.log(`š Successfully extracted translatable strings`);
} catch (error) {
console.warn(`ā ļø Warning: Failed to run i18next-parser: ${error.message}`);
console.log(`š” You can manually run: npm run i18n`);
}
console.log(`\nš i18n setup complete for ${packageJson.name}!`);
console.log(`\nš Next steps:`);
console.log(`1. Use the useTranslation() hook in your components:`);
console.log(` import { useTranslation } from '@kinvolk/headlamp-plugin/i18n';`);
console.log(` const { t } = useTranslation();`);
console.log(` return <div>{t('your.translation.key')}</div>;`);
console.log(`2. Add more locales: npm run i18n <locale> (e.g., npm run i18n es)`);
console.log(`3. Extract updated translatable strings: npm run i18n`);
console.log(`4. Add translations in locales/<locale>/translation.json`);
console.log(`\nš” Note: Using default i18next-parser configuration from headlamp-plugin.`);
console.log(` If you need custom configuration, create your own i18next-parser.config.js`);
return true;
} catch (error) {
console.error(`ā Failed to set up i18n for "${folder}":`, error.message);
return false;
} finally {
process.chdir(oldCwd);
}
}
/*