util/bux/util.ts (543 lines of code) (raw):
/* eslint-disable no-console */
/* eslint-disable @typescript-eslint/no-var-requires */
import { execSync } from "child_process";
import color from "cli-color";
import * as editorconfig from "editorconfig";
import * as fs from "fs";
import inquirer from "inquirer";
import * as os from "os";
import * as path from "path";
import * as shell from "shelljs";
import {
createEnglishTranslations,
mergeAllTranslations,
} from "./translation-functions";
export const defaultBatchExplorerHome = path.resolve(__dirname, "../../");
export const configFile = path.resolve(os.homedir(), ".config/batch/bux.json");
const defaultJsonIndentSize = 2;
const printJsonIndentSize = 4;
export interface Configuration {
paths: {
batchExplorer?: string;
};
}
type ConfigPath = keyof Configuration["paths"];
export interface BuxConfig {
rootPackage: string;
}
export const info = (...args: string[]) => console.log(color.blue(...args));
export const warn = (...args: string[]) => console.warn(color.yellow(...args));
export const error = (...args: string[]) => console.error(color.red(...args));
export function readJsonOrDefault(filename: string, defaultJson = {}) {
if (fs.existsSync(filename)) {
// eslint-disable-next-line security/detect-non-literal-require
return require(filename);
}
return defaultJson;
}
export async function saveJson(filename: string, json: unknown) {
const indent =
((await getEditorConfig("indent_size", filename)) as number) ??
defaultJsonIndentSize;
const confPath = path.dirname(filename);
if (!fs.existsSync(confPath)) {
mkdirp(confPath);
}
return new Promise((resolve) =>
fs.writeFile(filename, JSON.stringify(json, null, indent), resolve)
);
}
export function validateDirectory(path: string) {
if (shell.test("-d", path)) {
return true;
}
return "Not a valid directory";
}
export function resolvePath(aPath: string) {
aPath = aPath?.replace(/^~/, os.homedir());
return path.resolve(aPath);
}
export async function loadConfiguration(): Promise<Configuration> {
return readJsonOrDefault(configFile);
}
// Recursively searches upward from startDir (the current working directory from which the command is run) for a bux.json configuration file
export async function findBuxConfig(
startDir: string
): Promise<{ buxConfig: BuxConfig | null; rootDir: string }> {
let currentDir = startDir;
while (currentDir !== path.parse(currentDir).root) {
const configPath = path.join(currentDir, "bux.json");
if (fs.existsSync(configPath)) {
return {
buxConfig: readJsonOrDefault(configPath),
rootDir: currentDir,
};
}
currentDir = path.dirname(currentDir);
}
return { buxConfig: null, rootDir: "" };
}
/**
* Gets option from the project's editor config.
* @param {string} option The config option to return
* @param {string} filePattern The file pattern to get the option for (defaults to global)
* @returns a promise that resolves to the option
*/
export async function getEditorConfig(
option: keyof editorconfig.KnownProps,
filePattern?: string
) {
let configPath = `${defaultBatchExplorerHome}/.editorconfig`;
if (filePattern) {
configPath += "/" + filePattern;
}
const config: editorconfig.KnownProps =
await editorconfig.parse(configPath);
return config[option];
}
export function copyFiles(sourcePath: string, destPath: string) {
if (!sourcePath) {
error("Failed to copy: No source path specified");
return;
}
if (!destPath) {
error("Failed to copy: No dest path specified");
return;
}
shell.cp(sourcePath, destPath);
}
export async function buildTranslations(
sourcePath: string,
destPathRESJSON: string,
outputPath: string,
packageName?: string
) {
if (!sourcePath) {
error("Failed to build translations: No source path specified");
return;
}
if (!destPathRESJSON) {
error(
"Failed to build translations: No dest path for RESJSON output files specified"
);
return;
} else {
//Create a new destination directory specified by this parameter if it doesn't already exist
fs.mkdir(destPathRESJSON, { recursive: true }, (err) => {
if (err) {
if (err.code !== "EEXIST") throw err;
}
});
}
if (!outputPath) {
error("Failed to build translations: No output path specified");
return;
}
await createEnglishTranslations(
sourcePath,
destPathRESJSON,
outputPath,
packageName
);
// If no packageName, merge all translations (for web/desktop). packageName indicates package-specific operation.
if (!packageName) {
await mergeAllTranslations(outputPath);
}
}
export function mkdirp(targetPath: string) {
if (!targetPath) {
error("Failed to make directory: No path specified");
return;
}
targetPath = path.resolve(targetPath);
fs.mkdirSync(targetPath, { recursive: true });
}
export function moveFiles(sourcePath: string, destPath: string) {
if (!sourcePath) {
error("Failed to move: No source path specified");
return;
}
if (!destPath) {
error("Failed to move: No dest path specified");
return;
}
shell.mv(sourcePath, destPath);
}
export function rmrf(targetPath: string) {
if (!targetPath) {
error("Failed to delete: No path specified");
return;
}
fs.rmSync(path.resolve(targetPath), {
recursive: true,
force: true,
});
}
export function chmodx(paths: string[] | unknown) {
if (!paths || !Array.isArray(paths)) {
return;
}
for (const path of paths) {
shell.chmod("+x", path);
}
}
type GitInfo = {
branch: string;
stagedChanges: string[];
unstagedChanges: string[];
};
function getGitInfo(path: string): GitInfo {
const info: GitInfo = {
branch: "unknown",
stagedChanges: [],
unstagedChanges: [],
};
try {
info.branch = execSync("git branch --show-current", {
cwd: path,
encoding: "utf-8",
}).trim();
execSync("git status --porcelain=v1", {
cwd: path,
encoding: "utf-8",
})
.split(/\r?\n/) // Handle CRLF & LF line endings
.forEach((line) => {
if (line.startsWith(" ")) {
info.unstagedChanges.push(line.trim());
} else if (line.trim() !== "") {
info.stagedChanges.push(line.trim());
}
});
} catch (e) {
if (e instanceof Error) {
error(`Error retrieving Git info: ${e.message}`);
}
throw e;
}
return info;
}
function printInfo(name: string, repoPath?: string): void {
if (!repoPath) {
throw new Error(`No ${name} path configured`);
}
if (fs.existsSync(repoPath)) {
const gitInfo = getGitInfo(repoPath);
info(`${name} (${gitInfo.branch})`);
info(` - Path: ${repoPath}`);
if (gitInfo.stagedChanges.length > 0) {
info(" - Staged changes:");
info(` ${gitInfo.stagedChanges.join("\n ")}`);
}
if (gitInfo.unstagedChanges.length > 0) {
info(" - Unstaged changes:");
info(` ${gitInfo.unstagedChanges.join("\n ")}`);
}
} else {
throw new Error(`${name} not found at ${repoPath}`);
}
}
async function printLinkStatus(rootPackage: string): Promise<void> {
const linkChecks: boolean[] = [];
await runLinkageTask(
(opts: LinkOptions) => {
let isLinked = false;
const nodeModulePath = path.join(
opts.targetPath,
"node_modules",
opts.packageName
);
if (fs.existsSync(nodeModulePath)) {
const stats = fs.lstatSync(nodeModulePath);
if (stats.isSymbolicLink()) {
isLinked = true;
}
}
linkChecks.push(isLinked);
},
undefined,
rootPackage
);
const allPackagesLinked = linkChecks.reduce(
(prev, curr) => prev && curr,
true
);
let linkStatus;
if (allPackagesLinked) {
linkStatus = color.green("active");
} else {
linkStatus = color.blue("inactive");
}
console.log(`${color.blue("Link is")} ${linkStatus}`);
}
export async function printStatus() {
const config = await loadConfiguration();
const { buxConfig, rootDir } = await findBuxConfig(process.cwd());
if (!buxConfig || !rootDir) {
error("No bux.json configuration found. Exiting...");
process.exit(1);
}
try {
printInfo("Batch Explorer", config.paths.batchExplorer);
info("");
const gitRoot = findGitRoot(process.cwd());
if (gitRoot) {
printInfo("Current repository", gitRoot);
info("");
}
await printLinkStatus(buxConfig.rootPackage);
} catch (e) {
error("Config errors found. Run `bux configure` to re-configure.");
throw e;
}
}
/**
* Gather all build and test results into a single top-level 'build' directory.
* Used for both test/coverage reporting and release artifact upload.
*
* @param basePath The path to the top level of the repo
*/
export async function gatherBuildResults(basePath: string) {
if (!basePath) {
basePath =
(await loadConfiguration()).paths?.batchExplorer ??
defaultBatchExplorerHome;
}
const baseBuildDir = path.join(basePath, "build");
const doCopy = (src: string, dst: string, patterns: string[] = ["*"]) => {
info(`Copying [${patterns.join(",")}] from ${src}/* to ${dst}}`);
if (!fs.existsSync(src)) {
warn(`${src} does not exist - skipping`);
return;
}
mkdirp(dst);
for (const pattern of patterns) {
shell.cp("-r", src + "/" + pattern, dst);
}
};
// packages
doCopy(
path.join(basePath, "packages", "bonito-core", "build"),
path.join(baseBuildDir, "bonito-core")
);
doCopy(
path.join(basePath, "packages", "bonito-ui", "build"),
path.join(baseBuildDir, "bonito-ui")
);
doCopy(
path.join(basePath, "packages", "playground", "build"),
path.join(baseBuildDir, "playground")
);
doCopy(
path.join(basePath, "packages", "react", "build"),
path.join(baseBuildDir, "react")
);
doCopy(
path.join(basePath, "packages", "service", "build"),
path.join(baseBuildDir, "service")
);
// web
doCopy(path.join(basePath, "web", "build"), path.join(baseBuildDir, "web"));
// desktop
doCopy(
path.join(basePath, "desktop", "coverage"),
path.join(baseBuildDir, "desktop", "coverage")
);
doCopy(
path.join(basePath, "desktop", "release"),
path.join(baseBuildDir, "desktop", "release"),
[
"manifest.json",
"*.yml",
"*.exe",
"*.zip",
"*.dmg",
"*.deb",
"*.rpm",
"*.AppImage",
]
);
}
export async function linkLocalProjects() {
const { buxConfig, rootDir } = await findBuxConfig(process.cwd());
if (!buxConfig || !rootDir) {
error("No bux.json configuration found. Exiting...");
process.exit(1);
}
runLinkageTask(
(opts: LinkOptions) => {
info(`Linking ${opts.versionedPackageName}`);
const nodeModulesPath = path.join(opts.targetPath, "node_modules");
const targetPath = path.join(nodeModulesPath, opts.packageName);
if (!fs.existsSync(nodeModulesPath)) {
throw new Error(
`Failed to link ${opts.packageName}: ${nodeModulesPath} doesn't exist`
);
}
if (!fs.lstatSync(nodeModulesPath).isDirectory()) {
throw new Error(
`Failed to link ${opts.packageName}: ${nodeModulesPath} is not a directory`
);
}
if (fs.existsSync(targetPath)) {
if (fs.lstatSync(targetPath).isSymbolicLink()) {
// Early out if target is already a symlink
console.warn(
`${targetPath} is already a symbolic link - skipping`
);
return;
} else {
shell.rm("-rf", targetPath);
}
} else {
console.warn(
`No directory for ${opts.packageName} found in node_modules.`
);
}
shell.ln("-s", opts.packagePath, targetPath);
},
undefined,
buxConfig.rootPackage
);
}
export async function unlinkLocalProjects() {
const { buxConfig, rootDir } = await findBuxConfig(process.cwd());
if (!buxConfig || !rootDir) {
error("No bux.json configuration found. Exiting...");
process.exit(1);
}
runLinkageTask(
(opts: LinkOptions) => {
info(`Unlinking ${opts.versionedPackageName}`);
const nodeModulePath = path.join(
opts.targetPath,
"node_modules",
opts.packageName
);
if (!fs.existsSync(nodeModulePath)) {
throw new Error(
`Failed to unlink ${opts.packageName}: ${nodeModulePath} doesn't exist`
);
}
const stats = fs.lstatSync(nodeModulePath);
if (!stats.isSymbolicLink()) {
throw new Error(
`Failed to unlink ${opts.packageName}: ${nodeModulePath} is not a symlink`
);
}
shell.rm(nodeModulePath);
},
(targetPath: string) => {
info("Running `npm install` to restore packages...");
shell.cd(targetPath);
shell.exec(`npm install -s`);
},
buxConfig.rootPackage
);
}
export type ConfigureCommandOptions = {
"paths.batchExplorer"?: string;
print: boolean;
};
/**
* Configure CLI properties
*/
export async function configure(options: ConfigureCommandOptions) {
const config = await loadConfiguration();
config.paths ||= {};
// If valid configuration options are passed in the command-line, set or
// update the config object and skip prompting.
if (isConfigurationObject(options)) {
for (const [key, configPath] of Object.entries(options.paths)) {
if (isConfigPathKey(key)) {
config.paths[key] = resolvePath(configPath);
}
}
} else {
const answers = await inquirer.prompt([
{
name: "batchExplorer",
message:
"Path to Batch Explorer (use /mnt/c/... for WSL, e.g., CycleCloud):",
default: config.paths.batchExplorer || defaultBatchExplorerHome,
validate: validateDirectory,
type: "string",
},
]);
for (const [key, value] of Object.entries(answers)) {
config.paths[key as ConfigPath] = resolvePath(value as string);
}
}
await saveJson(configFile, config);
info(`Configuration saved to ${configFile}`);
if (options.print) {
printJson(config);
}
}
const versionRegExp = /^[\^~]?([0-9.]+)/;
/**
* Loosely compare two different package versions, ignoring anything but
* major/minor/patch version numbers.
*
* Examples:
* "^1.2.3-foo.9" and "1.2.3" match
* "^1.2.3" and "1.2.5" do not match"
*/
export function versionsLooselyMatch(v1: string, v2: string) {
const normalize = (version: string) => {
const matches = version.match(versionRegExp);
if (matches?.length !== 2) {
throw new Error(`Invalid version: ${version}`);
}
return matches[1];
};
return normalize(v1) === normalize(v2);
}
interface LinkOptions {
versionedPackageName: string;
packageName: string;
packagePath: string;
targetPath: string;
}
/**
* Links or unlinks local NPM projects as dependencies
*/
async function runLinkageTask(
perPackageCallback: (opts: LinkOptions) => void,
cleanupCallback?: (targetPath: string) => void,
rootPackage?: string
) {
const config = await loadConfiguration();
if (!rootPackage) {
error(
`No root package path found. Ensure bux.json contains the rootPackage path.`
);
return;
}
const { buxConfig, rootDir } = await findBuxConfig(process.cwd());
if (!buxConfig || !rootDir) {
error(`No bux.json configuration found. Exiting...`);
process.exit(1);
}
const targetPath = path.join(rootDir, rootPackage);
const targetPackageJson = path.join(targetPath, "package.json");
if (!fs.existsSync(targetPackageJson)) {
error(`No package.json in target directory ${targetPath}`);
return;
}
const targetConf = readJsonOrDefault(targetPackageJson);
const packageRoot = path.join(
config.paths.batchExplorer || defaultBatchExplorerHome,
"packages"
);
// Iterate over each source package to link/unlink
shell.ls(`${packageRoot}/*/package.json`).forEach((packageJson) => {
const packagePath = packageJson.replace("/package.json", "");
const json = readJsonOrDefault(packageJson);
const packageName = json.name;
const sourceVersion = json.version;
let targetVersion = targetConf.dependencies?.[packageName];
if (!targetVersion) {
targetVersion = targetConf.devDependencies?.[packageName];
}
if (targetVersion) {
if (!versionsLooselyMatch(targetVersion, sourceVersion)) {
warn(
`Target version ${targetVersion} different ` +
`than source version ${sourceVersion}`
);
}
perPackageCallback({
packageName,
versionedPackageName: `${packageName}@${sourceVersion}`,
packagePath,
targetPath,
});
}
});
if (cleanupCallback) {
cleanupCallback(targetPath);
}
}
function isConfigurationObject(object: unknown): object is Configuration {
if (!object || typeof object !== "object") {
return false;
}
return "paths" in object;
}
function isConfigPathKey(key: string): key is ConfigPath {
return ["batchExplorer"].includes(key);
}
function printJson(json: object) {
info(JSON.stringify(json, null, printJsonIndentSize));
}
function findGitRoot(currentDir: string): string | null {
while (currentDir !== path.parse(currentDir).root) {
if (fs.existsSync(path.join(currentDir, ".git"))) {
return currentDir;
}
currentDir = path.dirname(currentDir);
}
return null;
}