Files
bruno/packages/bruno-electron/src/utils/filesystem.js
Abhishek S Lal 1877119b81 fix(openapi-sync): simplify IPC calls, fix state priorities, and improve stored spec missing UX (#7489)
* refactor(OpenAPISyncTab): remove unused props and streamline IPC calls

- Eliminated unnecessary sourceUrl prop from various components and hooks in the OpenAPISyncTab.
- Improved pretty-printing logic in OpenAPISpecTab to handle non-JSON content gracefully.
- Updated IPC calls to remove redundant parameters, enhancing code clarity and maintainability.

* feat(OpenAPISyncTab): enhance user interaction and visual feedback

- Added onTabSelect prop to OpenAPISyncTab for improved tab navigation.
- Updated color properties in StyledWrapper for better consistency with theme.
- Replaced IconClock with IconAlertTriangle in CollectionStatusSection for clearer status indication.
- Enhanced messaging in OverviewSection and SpecStatusSection to provide clearer user guidance.
- Introduced handleRestoreSpec function in useSyncFlow for better spec restoration handling.

* fix(OpenAPISyncTab): update button labels for clarity in OverviewSection

- Changed button label from 'restore' to 'spec-details' for better context.
- Updated the button text from 'View Details' to 'Go to Spec Updates' to enhance user understanding of navigation options.

* refactor(OpenAPISyncTab): remove unused props and streamline component logic

- Eliminated unnecessary props from OpenAPISyncTab, CollectionStatusSection, and SpecStatusSection for cleaner code.
- Removed commented-out code in OverviewSection and SpecStatusSection to enhance readability.
- Introduced posixifyPath utility function in filesystem.js to standardize path formatting.

* fix(OpenAPISyncTab): update openapi config handling to support array format

- Modified the logic in loadBrunoConfig to handle openapi as an array, ensuring consistent resolution of source URLs for all entries. This change improves the configuration handling for OpenAPI specifications.

* fix(OpenAPISyncTab): improve openapi config handling and merge logic

- Updated loadBrunoConfig to ensure openapi is treated as an array, enhancing source URL resolution.
- Modified mergeWithUserValues to handle cases where specItems may be undefined, improving robustness in merging user values with specifications.
2026-03-16 18:14:53 +05:30

550 lines
15 KiB
JavaScript

const path = require('path');
const fs = require('fs-extra');
const fsPromises = require('fs/promises');
const { dialog } = require('electron');
const isValidPathname = require('is-valid-path');
const os = require('os');
const DEFAULT_GITIGNORE = [
'# Secrets',
'.env*',
'',
'# Dependencies',
'node_modules',
'',
'# OS files',
'.DS_Store',
'Thumbs.db'
].join('\n');
const exists = async (p) => {
try {
await fsPromises.access(p);
return true;
} catch (_) {
return false;
}
};
const isSymbolicLink = (filepath) => {
try {
return fs.existsSync(filepath) && fs.lstatSync(filepath).isSymbolicLink();
} catch (_) {
return false;
}
};
const isFile = (filepath) => {
try {
return fs.existsSync(filepath) && fs.lstatSync(filepath).isFile();
} catch (_) {
return false;
}
};
const isDirectory = (dirPath) => {
try {
return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory();
} catch (_) {
return false;
}
};
const isValidCollectionDirectory = (dirPath) => {
if (!isDirectory(dirPath)) {
return false;
}
const brunoJsonPath = path.join(dirPath, 'bruno.json');
const opencollectionYmlPath = path.join(dirPath, 'opencollection.yml');
return fs.existsSync(brunoJsonPath) || fs.existsSync(opencollectionYmlPath);
};
const hasSubDirectories = (dir) => {
const files = fs.readdirSync(dir);
return files.some((file) => fs.statSync(path.join(dir, file)).isDirectory());
};
const normalizeAndResolvePath = (pathname) => {
if (isWSLPath(pathname)) {
return normalizeWSLPath(pathname);
}
if (isSymbolicLink(pathname)) {
const absPath = path.dirname(pathname);
const targetPath = path.resolve(absPath, fs.readlinkSync(pathname));
if (isFile(targetPath) || isDirectory(targetPath)) {
return path.resolve(targetPath);
}
console.error(`Cannot resolve link target "${pathname}" (${targetPath}).`);
return '';
}
return path.resolve(pathname);
};
function isWSLPath(pathname) {
// Check if the path starts with the WSL prefix
// eg. "\\wsl.localhost\Ubuntu\home\user\bruno\collection\scripting\api\req\getHeaders.bru"
return pathname.startsWith('\\\\') || pathname.startsWith('//') || pathname.startsWith('/wsl.localhost/') || pathname.startsWith('\\wsl.localhost');
}
function normalizeWSLPath(pathname) {
// Replace the WSL path prefix and convert forward slashes to backslashes
// This is done to achieve WSL paths (linux style) to Windows UNC equivalent (Universal Naming Conversion)
return pathname.replace(/^\/wsl.localhost/, '\\\\wsl.localhost').replace(/\//g, '\\');
}
const writeFile = async (pathname, content, isBinary = false) => {
try {
await safeWriteFile(pathname, content, {
encoding: !isBinary ? 'utf-8' : null
});
} catch (err) {
console.error(`Error writing file at ${pathname}:`, err);
return Promise.reject(err);
}
};
const hasJsonExtension = (filename) => {
if (!filename || typeof filename !== 'string') return false;
return ['json'].some((ext) => filename.toLowerCase().endsWith(`.${ext}`));
};
const hasBruExtension = (filename) => {
if (!filename || typeof filename !== 'string') return false;
return ['bru'].some((ext) => filename.toLowerCase().endsWith(`.${ext}`));
};
const hasRequestExtension = (filename, format = null) => {
if (!filename || typeof filename !== 'string') return false;
if (format) {
const ext = format === 'yml' ? 'yml' : 'bru';
return filename.toLowerCase().endsWith(`.${ext}`);
}
return ['bru', 'yml'].some((ext) => filename.toLowerCase().endsWith(`.${ext}`));
};
const createDirectory = async (dir) => {
if (!dir) {
throw new Error(`directory: path is null`);
}
if (fs.existsSync(dir)) {
throw new Error(`directory: ${dir} already exists`);
}
return fs.mkdirSync(dir);
};
const browseDirectory = async (win) => {
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory']
});
if (!filePaths || !filePaths[0]) {
return false;
}
const resolvedPath = path.resolve(filePaths[0]);
return isDirectory(resolvedPath) ? resolvedPath : false;
};
const browseFiles = async (win, filters = [], properties = []) => {
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openFile', ...properties],
filters
});
if (!filePaths) {
return [];
}
return filePaths.map((filePath) => path.resolve(filePath)).filter((filePath) => isFile(filePath));
};
const chooseFileToSave = async (win, preferredFileName = '') => {
const { filePath } = await dialog.showSaveDialog(win, {
defaultPath: preferredFileName
});
return filePath;
};
const searchForFiles = (dir, extension) => {
let results = [];
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
results = results.concat(searchForFiles(filePath, extension));
} else if (path.extname(file) === extension) {
results.push(filePath);
}
}
return results;
};
// Search for request files based on collection filetype by reading config
const searchForRequestFiles = (dir, collectionPath = null) => {
const format = getCollectionFormat(collectionPath || dir);
if (format === 'yml') {
return searchForFiles(dir, '.yml');
} else if (format === 'bru') {
return searchForFiles(dir, '.bru');
} else {
throw new Error(`Invalid format: ${format}`);
}
};
const sanitizeName = (name) => {
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g;
name = name
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[\s\-]+/, '') // remove leading spaces and hyphens
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces
return name;
};
const isWindowsOS = () => {
return os.platform() === 'win32';
};
/**
* Generate a unique name by adding a "copy" suffix if needed
*
* @param {string} baseName - The base name
* @param {Function} checkExists - Function that takes a name and returns true if it exists
* @returns {string} - A unique name
*/
const generateUniqueName = (baseName, checkExists) => {
if (!checkExists(baseName)) {
return baseName;
}
let counter = 1;
let uniqueName = `${baseName} copy`;
while (checkExists(uniqueName)) {
counter++;
uniqueName = `${baseName} copy ${counter}`;
}
return uniqueName;
};
const getCollectionFormat = (collectionPath) => {
const ocYmlPath = path.join(collectionPath, 'opencollection.yml');
if (fs.existsSync(ocYmlPath)) {
return 'yml';
}
const brunoJsonPath = path.join(collectionPath, 'bruno.json');
if (fs.existsSync(brunoJsonPath)) {
return 'bru';
}
throw new Error(`No collection configuration found at: ${collectionPath}`);
};
const validateName = (name) => {
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // keeping this for informational purpose
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
const firstCharacter = /^[^\s\-<>:"/\\|?*\x00-\x1F]/; // no space, hyphen and `invalidCharacters`
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no `invalidCharacters`
const lastCharacter = /[^.\s<>:"/\\|?*\x00-\x1F]$/; // no dot, space and `invalidCharacters`
if (name.length > 255) return false; // max name length
if (reservedDeviceNames.test(name)) return false; // windows reserved names
return (
firstCharacter.test(name)
&& middleCharacters.test(name)
&& lastCharacter.test(name)
);
};
const safeToRename = (oldPath, newPath) => {
try {
// If the new path doesn't exist, it's safe to rename
if (!fs.existsSync(newPath)) {
return true;
}
const oldStat = fs.statSync(oldPath);
const newStat = fs.statSync(newPath);
if (isWindowsOS()) {
// Windows-specific comparison:
// Check if both files have the same birth time, size (Since, Win FAT-32 doesn't use inodes)
return oldStat.birthtimeMs === newStat.birthtimeMs && oldStat.size === newStat.size;
}
// Unix/Linux/MacOS: Check inode to see if they are the same file
return oldStat.ino === newStat.ino;
} catch (error) {
console.error(`Error checking file rename safety for ${oldPath} and ${newPath}:`, error);
return false;
}
};
const getCollectionStats = async (directoryPath) => {
let size = 0;
let filesCount = 0;
let maxFileSize = 0;
async function calculateStats(directory) {
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
const tasks = entries.map(async (entry) => {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (['node_modules', '.git'].includes(entry.name)) {
return;
}
await calculateStats(fullPath);
}
if (path.extname(fullPath) === '.bru') {
const stats = await fsPromises.stat(fullPath);
size += stats?.size;
if (maxFileSize < stats?.size) {
maxFileSize = stats?.size;
}
filesCount += 1;
}
});
await Promise.all(tasks);
}
await calculateStats(directoryPath);
size = sizeInMB(size);
maxFileSize = sizeInMB(maxFileSize);
return { size, filesCount, maxFileSize };
};
const sizeInMB = (size) => {
return size / (1024 * 1024);
};
const getSafePathToWrite = (filePath) => {
const MAX_FILENAME_LENGTH = 255; // Common limit on most filesystems
let dir = path.dirname(filePath);
let ext = path.extname(filePath);
let base = path.basename(filePath, ext);
if (base.length + ext.length > MAX_FILENAME_LENGTH) {
base = sanitizeName(base);
base = base.slice(0, MAX_FILENAME_LENGTH - ext.length);
}
let safePath = path.join(dir, base + ext);
return safePath;
};
async function safeWriteFile(filePath, data, options) {
const safePath = getSafePathToWrite(filePath);
try {
const fsExtra = require('fs-extra');
fsExtra.outputFileSync(safePath, data, options);
} catch (err) {
console.error(`Error writing file at ${safePath}:`, err);
return Promise.reject(err);
}
}
function safeWriteFileSync(filePath, data) {
const safePath = getSafePathToWrite(filePath);
fs.writeFileSync(safePath, data);
}
// Recursively copies a source <file/directory> to a destination <directory>.
const copyPath = async (source, destination) => {
let targetPath = `${destination}/${path.basename(source)}`;
const targetPathExists = await fsPromises.access(targetPath).then(() => true).catch(() => false);
if (targetPathExists) {
throw new Error(`Cannot copy, ${path.basename(source)} already exists in ${path.basename(destination)}`);
}
const copy = async (source, destination) => {
const stat = await fsPromises.lstat(source);
if (stat.isDirectory()) {
await fsPromises.mkdir(destination, { recursive: true });
const entries = await fsPromises.readdir(source);
for (const entry of entries) {
const srcPath = path.join(source, entry);
const destPath = path.join(destination, entry);
await copy(srcPath, destPath);
}
} else {
await fsPromises.copyFile(source, destination);
}
};
await copy(source, targetPath);
};
// Recursively removes a source <file/directory>.
const removePath = async (source) => {
const stat = await fsPromises.lstat(source);
if (stat.isDirectory()) {
const entries = await fsPromises.readdir(source);
for (const entry of entries) {
const entryPath = path.join(source, entry);
await removePath(entryPath);
}
await fsPromises.rmdir(source);
} else {
await fsPromises.unlink(source);
}
};
// Recursively gets paths.
const getPaths = async (source) => {
let paths = [];
const _getPaths = async (source) => {
const stat = await fsPromises.lstat(source);
paths.push(source);
if (stat.isDirectory()) {
const entries = await fsPromises.readdir(source);
for (const entry of entries) {
const entryPath = path.join(source, entry);
await _getPaths(entryPath);
}
}
};
await _getPaths(source);
return paths;
};
/**
* Checks if a file is larger than a given threshold.
* @param {string} filePath - The path to the file.
* @param {number} threshold - The threshold in bytes. Default is 10MB.
* @returns {boolean} True if the file is larger than the threshold, false otherwise.
*/
const isLargeFile = (filePath, threshold = 10 * 1024 * 1024) => {
if (!isFile(filePath)) {
throw new Error(`File ${filePath} is not a file`);
}
const size = fs.statSync(filePath).size;
return size > threshold;
};
const isDotEnvFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return path.normalize(dirname) === path.normalize(collectionPath) && basename === '.env';
};
const isValidDotEnvFilename = (filename) => {
if (!filename || typeof filename !== 'string') return false;
const basename = path.basename(filename);
if (basename !== filename) return false;
return basename === '.env' || (basename.startsWith('.env.') && /^\.env\.[a-zA-Z0-9._-]+$/.test(basename));
};
const isBrunoConfigFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return path.normalize(dirname) === path.normalize(collectionPath) && basename === 'bruno.json';
};
const isBruEnvironmentConfig = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const envDirectory = path.join(collectionPath, 'environments');
const basename = path.basename(pathname);
return path.normalize(dirname) === path.normalize(envDirectory) && hasBruExtension(basename);
};
const isCollectionRootBruFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return path.normalize(dirname) === path.normalize(collectionPath) && basename === 'collection.bru';
};
const scanForBrunoFiles = async (dir) => {
const brunoFolders = [];
const scanDir = (currentDir) => {
const files = fs.readdirSync(currentDir);
if (files && files.length) {
files.forEach((file) => {
const fullPath = path.join(currentDir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
if (['node_modules', '.git'].includes(file)) {
return;
}
scanDir(fullPath);
} else if (file === 'bruno.json') {
brunoFolders.push(currentDir);
}
});
}
};
scanDir(dir);
return brunoFolders;
};
const posixifyPath = (p) => (p ? p.replace(/\\/g, '/') : p);
module.exports = {
DEFAULT_GITIGNORE,
posixifyPath,
isValidPathname,
exists,
isSymbolicLink,
isFile,
isDirectory,
isValidCollectionDirectory,
normalizeAndResolvePath,
isWSLPath,
normalizeWSLPath,
writeFile,
hasJsonExtension,
hasBruExtension,
hasRequestExtension,
createDirectory,
browseDirectory,
browseFiles,
chooseFileToSave,
searchForFiles,
searchForRequestFiles,
sanitizeName,
isWindowsOS,
safeToRename,
validateName,
hasSubDirectories,
getCollectionStats,
sizeInMB,
safeWriteFile,
safeWriteFileSync,
copyPath,
removePath,
getPaths,
isLargeFile,
generateUniqueName,
getCollectionFormat,
isDotEnvFile,
isValidDotEnvFilename,
isBrunoConfigFile,
isBruEnvironmentConfig,
isCollectionRootBruFile,
scanForBrunoFiles
};