Files
bruno/packages/bruno-electron/src/utils/workspace-config.js

718 lines
22 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { writeFile, validateName, isValidCollectionDirectory } = require('./filesystem');
const { generateUidBasedOnHash } = require('./common');
const { withLock, getWorkspaceLockKey } = require('./workspace-lock');
// Normalize Windows backslash paths to forward slashes for cross-platform compatibility.
const posixifyPath = (p) => (p ? p.replace(/\\/g, '/') : p);
const WORKSPACE_TYPE = 'workspace';
const OPENCOLLECTION_VERSION = '1.0.0';
const GITIGNORE_MANAGED_BLOCK_START = '# Bruno managed collection remotes';
const GITIGNORE_MANAGED_BLOCK_END = '# End Bruno managed collection remotes';
const quoteYamlValue = (value) => {
if (typeof value !== 'string') {
return `"${String(value)}"`;
}
if (value === '') {
return '""';
}
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}"`;
};
const writeWorkspaceFileAtomic = async (workspacePath, content) => {
const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
await writeFile(workspaceFilePath, content);
// Previous atomic write implementation commented out due to permission issues on Linux
// when temp directory is on a different filesystem (cross-device link error)
// const tempFilePath = path.join(os.tmpdir(), `workspace-${Date.now()}-${crypto.randomBytes(16).toString('hex')}.yml`);
// try {
// await writeFile(tempFilePath, content);
// if (fs.existsSync(workspaceFilePath)) {
// fs.unlinkSync(workspaceFilePath);
// }
// fs.renameSync(tempFilePath, workspaceFilePath);
// } catch (error) {
// if (fs.existsSync(tempFilePath)) {
// try {
// fs.unlinkSync(tempFilePath);
// } catch (_) {}
// }
// throw error;
// }
};
const isValidCollectionEntry = (collection) => {
if (!collection || typeof collection !== 'object') {
return false;
}
if (!collection.name || typeof collection.name !== 'string' || collection.name.trim() === '') {
return false;
}
if (!collection.path || typeof collection.path !== 'string' || collection.path.trim() === '') {
return false;
}
return true;
};
const isValidSpecEntry = (spec) => {
if (!spec || typeof spec !== 'object') {
return false;
}
if (!spec.name || typeof spec.name !== 'string' || spec.name.trim() === '') {
return false;
}
if (!spec.path || typeof spec.path !== 'string' || spec.path.trim() === '') {
return false;
}
return true;
};
const sanitizeCollections = (collections) => {
if (!Array.isArray(collections)) {
return [];
}
return collections.filter((collection) => {
if (!isValidCollectionEntry(collection)) {
console.error('Skipping invalid collection entry:', collection);
return false;
}
return true;
}).map((collection) => {
const sanitized = {
name: collection.name.trim(),
path: posixifyPath(collection.path.trim())
};
if (collection.remote && typeof collection.remote === 'string') {
sanitized.remote = collection.remote.trim();
}
return sanitized;
});
};
const sanitizeSpecs = (specs) => {
if (!Array.isArray(specs)) {
return [];
}
return specs.filter((spec) => {
if (!isValidSpecEntry(spec)) {
console.error('Skipping invalid spec entry:', spec);
return false;
}
return true;
}).map((spec) => ({
name: spec.name.trim(),
path: posixifyPath(spec.path.trim())
}));
};
const makeRelativePath = (workspacePath, absolutePath) => {
if (!path.isAbsolute(absolutePath)) {
return posixifyPath(absolutePath);
}
try {
const relativePath = path.relative(workspacePath, absolutePath);
if (relativePath.startsWith('..') && relativePath.split(path.sep).filter((s) => s === '..').length > 2) {
return posixifyPath(absolutePath);
}
return posixifyPath(relativePath);
} catch (error) {
return posixifyPath(absolutePath);
}
};
const getNormalizedAbsoluteCollectionPath = (workspacePath, collection) => {
if (!collection?.path) return null;
const resolved = path.isAbsolute(collection.path) ? collection.path : path.resolve(workspacePath, collection.path);
return path.normalize(resolved);
};
const normalizeCollectionEntry = (workspacePath, collection) => {
const relativePath = makeRelativePath(workspacePath, collection.path);
const normalizedCollection = {
name: collection.name,
path: relativePath
};
if (collection.remote) {
normalizedCollection.remote = collection.remote;
}
return normalizedCollection;
};
const validateWorkspacePath = (workspacePath) => {
if (!workspacePath) {
throw new Error('Workspace path is required');
}
if (!fs.existsSync(workspacePath)) {
throw new Error(`Workspace path does not exist: ${workspacePath}`);
}
const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
if (!fs.existsSync(workspaceFilePath)) {
throw new Error('Invalid workspace: workspace.yml not found');
}
return true;
};
const validateWorkspaceDirectory = (dirPath) => {
if (!validateName(path.basename(dirPath))) {
throw new Error(`Invalid workspace directory name: ${dirPath}`);
}
return true;
};
const createWorkspaceConfig = (workspaceName) => ({
opencollection: OPENCOLLECTION_VERSION,
info: {
name: workspaceName,
type: WORKSPACE_TYPE
},
collections: [],
specs: [],
docs: ''
});
const normalizeWorkspaceConfig = (config) => {
// Coerce `specs` to an array once. A malformed workspace.yml (e.g. `specs`
// authored as a map) would otherwise flow through as a non-array and crash
// both the renderer sidebar (.map) and the write paths (.findIndex/.filter).
const specs = Array.isArray(config.specs) ? config.specs : [];
return {
...config,
name: config.info?.name,
type: config.info?.type,
collections: config.collections || [],
specs,
// Distinct array (not an alias of `specs`) so a later in-place mutation of
// one field can't silently change the other.
apiSpecs: [...specs]
};
};
const readWorkspaceConfig = (workspacePath) => {
const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
if (!fs.existsSync(workspaceFilePath)) {
throw new Error('Invalid workspace: workspace.yml not found');
}
const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8');
const workspaceConfig = yaml.load(yamlContent);
if (!workspaceConfig || typeof workspaceConfig !== 'object') {
throw new Error('Invalid workspace: workspace.yml is malformed');
}
return normalizeWorkspaceConfig(workspaceConfig);
};
const generateYamlContent = (config) => {
const yamlLines = [];
const workspaceName = config.info?.name || config.name || 'Untitled Workspace';
const workspaceType = config.info?.type || config.type || WORKSPACE_TYPE;
yamlLines.push(`opencollection: ${config.opencollection || OPENCOLLECTION_VERSION}`);
yamlLines.push('info:');
yamlLines.push(` name: ${quoteYamlValue(workspaceName)}`);
yamlLines.push(` type: ${workspaceType}`);
yamlLines.push('');
const collections = sanitizeCollections(config.collections);
if (collections.length > 0) {
yamlLines.push('collections:');
for (const collection of collections) {
yamlLines.push(` - name: ${quoteYamlValue(collection.name)}`);
yamlLines.push(` path: ${quoteYamlValue(collection.path)}`);
if (collection.remote) {
yamlLines.push(` remote: ${quoteYamlValue(collection.remote)}`);
}
}
} else {
yamlLines.push('collections:');
}
yamlLines.push('');
const specs = sanitizeSpecs(config.specs);
if (specs.length > 0) {
yamlLines.push('specs:');
for (const spec of specs) {
yamlLines.push(` - name: ${quoteYamlValue(spec.name)}`);
yamlLines.push(` path: ${quoteYamlValue(spec.path)}`);
}
} else {
yamlLines.push('specs:');
}
yamlLines.push('');
const docs = config.docs || '';
if (docs) {
const escapedDocs = docs.includes('\n')
? `|-\n ${docs.split('\n').join('\n ')}`
: quoteYamlValue(docs);
yamlLines.push(`docs: ${escapedDocs}`);
} else {
yamlLines.push('docs: \'\'');
}
yamlLines.push('');
return yamlLines.join('\n');
};
const writeWorkspaceConfig = async (workspacePath, config) => {
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const yamlContent = generateYamlContent(config);
await writeWorkspaceFileAtomic(workspacePath, yamlContent);
});
};
const validateWorkspaceConfig = (config) => {
if (!config || typeof config !== 'object') {
throw new Error('Workspace configuration must be an object');
}
const type = config.info?.type || config.type;
if (type !== WORKSPACE_TYPE) {
throw new Error('Invalid workspace: not a bruno workspace');
}
const name = config.info?.name || config.name;
if (!name || typeof name !== 'string') {
throw new Error('Workspace must have a valid name');
}
return true;
};
const updateWorkspaceName = async (workspacePath, newName) => {
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const config = readWorkspaceConfig(workspacePath);
config.name = newName;
if (config.info) {
config.info.name = newName;
}
const yamlContent = generateYamlContent(config);
await writeWorkspaceFileAtomic(workspacePath, yamlContent);
return config;
});
};
const updateWorkspaceDocs = async (workspacePath, docs) => {
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const config = readWorkspaceConfig(workspacePath);
config.docs = docs;
const yamlContent = generateYamlContent(config);
await writeWorkspaceFileAtomic(workspacePath, yamlContent);
return docs;
});
};
const addCollectionToWorkspace = async (workspacePath, collection) => {
if (!isValidCollectionEntry(collection)) {
throw new Error('Invalid collection: name and path are required');
}
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const config = readWorkspaceConfig(workspacePath);
if (!config.collections) {
config.collections = [];
}
const normalizedCollection = {
name: collection.name.trim(),
path: posixifyPath(collection.path.trim())
};
if (collection.remote && typeof collection.remote === 'string') {
normalizedCollection.remote = collection.remote.trim();
}
const existingIndex = config.collections.findIndex((c) => c.path && posixifyPath(c.path) === normalizedCollection.path);
if (existingIndex >= 0) {
config.collections[existingIndex] = normalizedCollection;
} else {
config.collections.push(normalizedCollection);
}
const yamlContent = generateYamlContent(config);
await writeWorkspaceFileAtomic(workspacePath, yamlContent);
return config.collections;
});
};
const getCollectionGitignoreEntry = (workspacePath, collectionPath) => {
const absolute = path.isAbsolute(collectionPath)
? collectionPath
: path.resolve(workspacePath, collectionPath);
const relative = path.relative(workspacePath, absolute);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
return posixifyPath(relative).replace(/\/+$/, '') + '/';
};
const findGitignoreManagedBlock = (lines) => {
const start = lines.findIndex((line) => line.trim() === GITIGNORE_MANAGED_BLOCK_START);
if (start === -1) return null;
const end = lines.findIndex((line, index) => index > start && line.trim() === GITIGNORE_MANAGED_BLOCK_END);
if (end === -1) return null;
return { start, end };
};
const addCollectionToWorkspaceGitignore = async (workspacePath, collectionPath) => {
const entry = getCollectionGitignoreEntry(workspacePath, collectionPath);
if (!entry) return;
const gitignorePath = path.join(workspacePath, '.gitignore');
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
const lines = existing.split('\n');
if (lines.some((line) => line.trim() === entry)) return;
const managedBlock = findGitignoreManagedBlock(lines);
if (managedBlock) {
const updated = [...lines];
updated.splice(managedBlock.end, 0, entry);
await writeFile(gitignorePath, updated.join('\n'));
return;
}
const prefix = existing.length === 0 || existing.endsWith('\n') ? existing : existing + '\n';
await writeFile(gitignorePath, `${prefix}${GITIGNORE_MANAGED_BLOCK_START}\n${entry}\n${GITIGNORE_MANAGED_BLOCK_END}\n`);
};
const removeCollectionFromWorkspaceGitignore = async (workspacePath, collectionPath) => {
const entry = getCollectionGitignoreEntry(workspacePath, collectionPath);
if (!entry) return;
const gitignorePath = path.join(workspacePath, '.gitignore');
if (!fs.existsSync(gitignorePath)) return;
const lines = fs.readFileSync(gitignorePath, 'utf8').split('\n');
const managedBlock = findGitignoreManagedBlock(lines);
if (!managedBlock) return;
const managedLines = lines.slice(managedBlock.start + 1, managedBlock.end);
const filteredManagedLines = managedLines.filter((line) => line.trim() !== entry);
if (filteredManagedLines.length === managedLines.length) return;
const hasManagedEntries = filteredManagedLines.some((line) => line.trim() !== '');
const filtered = hasManagedEntries
? [
...lines.slice(0, managedBlock.start + 1),
...filteredManagedLines,
...lines.slice(managedBlock.end)
]
: [
...lines.slice(0, managedBlock.start),
...lines.slice(managedBlock.end + 1)
];
await writeFile(gitignorePath, filtered.join('\n'));
};
const setCollectionGitRemote = async (workspacePath, collectionPath, remoteUrl) => {
if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') {
throw new Error('A non-empty Git remote URL is required');
}
const trimmedUrl = remoteUrl.trim();
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const config = readWorkspaceConfig(workspacePath);
const target = path.normalize(collectionPath);
let matched = false;
config.collections = (config.collections || []).map((c) => {
if (getNormalizedAbsoluteCollectionPath(workspacePath, c) !== target) return c;
matched = true;
return { ...c, remote: trimmedUrl };
});
if (!matched) {
throw new Error('Collection not found in workspace');
}
await writeWorkspaceFileAtomic(workspacePath, generateYamlContent(config));
await addCollectionToWorkspaceGitignore(workspacePath, collectionPath);
return config;
});
};
const clearCollectionGitRemote = async (workspacePath, collectionPath) => {
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const config = readWorkspaceConfig(workspacePath);
const target = path.normalize(collectionPath);
let matched = false;
config.collections = (config.collections || []).map((c) => {
if (getNormalizedAbsoluteCollectionPath(workspacePath, c) !== target) return c;
matched = true;
const updated = { ...c };
delete updated.remote;
return updated;
});
if (!matched) {
throw new Error('Collection not found in workspace');
}
await writeWorkspaceFileAtomic(workspacePath, generateYamlContent(config));
await removeCollectionFromWorkspaceGitignore(workspacePath, collectionPath);
return config;
});
};
const removeCollectionFromWorkspace = async (workspacePath, collectionPath) => {
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const config = readWorkspaceConfig(workspacePath);
let removedCollection = null;
config.collections = (config.collections || []).filter((c) => {
const collectionPathFromYml = c.path ? posixifyPath(c.path) : c.path;
if (!collectionPathFromYml) {
return true;
}
const absoluteCollectionPath = path.isAbsolute(collectionPathFromYml)
? collectionPathFromYml
: path.resolve(workspacePath, collectionPathFromYml);
if (path.normalize(absoluteCollectionPath) === path.normalize(collectionPath)) {
removedCollection = c;
return false;
}
return true;
});
const yamlContent = generateYamlContent(config);
await writeWorkspaceFileAtomic(workspacePath, yamlContent);
return {
removedCollection,
updatedConfig: config
};
});
};
/**
* Reorders the collections array in the workspace's workspace.yml to match the given path list.
* Entries not in the list are appended at the end.
* @param {string} workspacePath - Absolute path to the workspace directory
* @param {string[]} collectionPaths - Absolute collection pathnames in the desired order
*/
const reorderWorkspaceCollections = async (workspacePath, collectionPaths) => {
if (!Array.isArray(collectionPaths)) {
throw new Error('collectionPaths must be an array');
}
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const config = readWorkspaceConfig(workspacePath);
const existing = config.collections || [];
const inNewOrder = [];
const matched = new Set();
for (const absolutePath of collectionPaths) {
const targetPath = posixifyPath(path.normalize(absolutePath));
const entry = existing.find(
(c) => posixifyPath(getNormalizedAbsoluteCollectionPath(workspacePath, c)) === targetPath
);
if (entry && !matched.has(entry)) {
inNewOrder.push(entry);
matched.add(entry);
}
}
const notInList = existing.filter((c) => !matched.has(c));
config.collections = [...inNewOrder, ...notInList];
const yamlContent = generateYamlContent(config);
await writeWorkspaceFileAtomic(workspacePath, yamlContent);
});
};
const resolveAndFilterWorkspaceCollections = (workspacePath, rawCollections) => {
const seenPaths = new Set();
return (rawCollections || [])
.map((collection) => {
if (!collection.path) return collection;
const collectionPath = posixifyPath(collection.path);
const absolute = path.isAbsolute(collectionPath)
? collectionPath
: path.resolve(workspacePath, collectionPath);
return { ...collection, path: absolute };
})
.map((collection) => {
if (!collection.path) return null;
const normalizedPath = path.normalize(collection.path);
if (seenPaths.has(normalizedPath)) return null;
seenPaths.add(normalizedPath);
if (isValidCollectionDirectory(collection.path)) return collection;
if (collection.remote) return { ...collection, notFoundLocally: true };
return null;
})
.filter(Boolean);
};
const getWorkspaceCollections = (workspacePath) => {
const config = readWorkspaceConfig(workspacePath);
return resolveAndFilterWorkspaceCollections(workspacePath, config.collections);
};
const getWorkspaceApiSpecs = (workspacePath) => {
const config = readWorkspaceConfig(workspacePath);
const specs = config.specs || [];
return specs.map((spec) => {
const specPath = spec.path ? posixifyPath(spec.path) : spec.path;
if (specPath && !path.isAbsolute(specPath)) {
return {
...spec,
path: path.join(workspacePath, specPath)
};
}
return { ...spec, path: specPath };
});
};
const addApiSpecToWorkspace = async (workspacePath, apiSpec) => {
if (!isValidSpecEntry(apiSpec)) {
throw new Error('Invalid API spec: name and path are required');
}
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const config = readWorkspaceConfig(workspacePath);
if (!config.specs) {
config.specs = [];
}
const normalizedSpec = {
name: apiSpec.name.trim(),
path: makeRelativePath(workspacePath, apiSpec.path).trim()
};
const existingIndex = config.specs.findIndex(
(a) => a.name === normalizedSpec.name || (a.path && posixifyPath(a.path) === normalizedSpec.path)
);
if (existingIndex >= 0) {
config.specs[existingIndex] = normalizedSpec;
} else {
config.specs.push(normalizedSpec);
}
const yamlContent = generateYamlContent(config);
await writeWorkspaceFileAtomic(workspacePath, yamlContent);
return config.specs;
});
};
const removeApiSpecFromWorkspace = async (workspacePath, apiSpecPath) => {
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const config = readWorkspaceConfig(workspacePath);
if (!config.specs) {
return { removedApiSpec: null, updatedConfig: config };
}
let removedApiSpec = null;
config.specs = config.specs.filter((a) => {
const specPathFromYml = a.path ? posixifyPath(a.path) : a.path;
if (!specPathFromYml) return true;
const absoluteSpecPath = path.isAbsolute(specPathFromYml)
? specPathFromYml
: path.resolve(workspacePath, specPathFromYml);
if (path.normalize(absoluteSpecPath) === path.normalize(apiSpecPath)) {
removedApiSpec = a;
return false;
}
return true;
});
const yamlContent = generateYamlContent(config);
await writeWorkspaceFileAtomic(workspacePath, yamlContent);
return {
removedApiSpec,
updatedConfig: config
};
});
};
const getWorkspaceUid = (workspacePath) => {
const { defaultWorkspaceManager } = require('../store/default-workspace');
const defaultWorkspacePath = defaultWorkspaceManager.getDefaultWorkspacePath();
if (defaultWorkspacePath && path.normalize(workspacePath) === path.normalize(defaultWorkspacePath)) {
return defaultWorkspaceManager.getDefaultWorkspaceUid();
}
return generateUidBasedOnHash(workspacePath);
};
module.exports = {
makeRelativePath,
normalizeCollectionEntry,
validateWorkspacePath,
validateWorkspaceDirectory,
createWorkspaceConfig,
normalizeWorkspaceConfig,
readWorkspaceConfig,
writeWorkspaceConfig,
validateWorkspaceConfig,
updateWorkspaceName,
updateWorkspaceDocs,
addCollectionToWorkspace,
removeCollectionFromWorkspace,
setCollectionGitRemote,
clearCollectionGitRemote,
reorderWorkspaceCollections,
getWorkspaceCollections,
resolveAndFilterWorkspaceCollections,
getWorkspaceApiSpecs,
addApiSpecToWorkspace,
removeApiSpecFromWorkspace,
generateYamlContent,
getWorkspaceUid,
writeWorkspaceFileAtomic,
isValidCollectionEntry,
isValidSpecEntry
};