From 8c7ed3fe5152819082b1ea1c5fed97a5e8a0498a Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Tue, 23 Dec 2025 20:22:51 +0530 Subject: [PATCH] improve: workspace handling (#6495) * improve: workspace * fixes --- packages/bruno-electron/src/app/apiSpecs.js | 63 ++- .../src/app/workspace-watcher.js | 3 + packages/bruno-electron/src/ipc/apiSpec.js | 24 +- packages/bruno-electron/src/ipc/workspace.js | 72 ++-- .../src/store/default-workspace.js | 75 +++- .../src/store/workspace-environments.js | 45 +- .../src/utils/workspace-config.js | 385 ++++++++++++------ .../src/utils/workspace-lock.js | 43 ++ 8 files changed, 458 insertions(+), 252 deletions(-) create mode 100644 packages/bruno-electron/src/utils/workspace-lock.js diff --git a/packages/bruno-electron/src/app/apiSpecs.js b/packages/bruno-electron/src/app/apiSpecs.js index 8980d2f52..0e88aea1b 100644 --- a/packages/bruno-electron/src/app/apiSpecs.js +++ b/packages/bruno-electron/src/app/apiSpecs.js @@ -1,7 +1,15 @@ +const fs = require('fs'); +const path = require('path'); const { dialog, ipcMain } = require('electron'); const { normalizeAndResolvePath } = require('../utils/filesystem'); const { generateUidBasedOnHash } = require('../utils/common'); -const { generateYamlContent, getWorkspaceUid } = require('../utils/workspace-config'); +const { + addApiSpecToWorkspace, + readWorkspaceConfig, + getWorkspaceUid +} = require('../utils/workspace-config'); + +const DEFAULT_WORKSPACE_NAME = 'My Workspace'; const normalizeWorkspaceConfig = (config) => { return { @@ -13,6 +21,17 @@ const normalizeWorkspaceConfig = (config) => { }; }; +const prepareWorkspaceConfigForClient = (workspaceConfig, isDefault) => { + if (isDefault) { + return { + ...workspaceConfig, + name: DEFAULT_WORKSPACE_NAME, + type: 'default' + }; + } + return workspaceConfig; +}; + const openApiSpecDialog = async (win, watcher, options = {}) => { const { filePaths } = await dialog.showOpenDialog(win, { properties: ['openFile', 'createFile'] @@ -21,7 +40,7 @@ const openApiSpecDialog = async (win, watcher, options = {}) => { if (filePaths && filePaths[0]) { const resolvedPath = normalizeAndResolvePath(filePaths[0]); try { - openApiSpec(win, watcher, resolvedPath, options); + await openApiSpec(win, watcher, resolvedPath, options); } catch (err) { console.error(`[ERROR] Cannot open API spec: "${resolvedPath}"`); } @@ -33,35 +52,16 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => { const uid = generateUidBasedOnHash(apiSpecPath); if (options.workspacePath) { - const fs = require('fs'); - const path = require('path'); - const yaml = require('js-yaml'); - const workspaceFilePath = path.join(options.workspacePath, 'workspace.yml'); if (fs.existsSync(workspaceFilePath)) { - const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8'); - const workspaceConfig = yaml.load(yamlContent); - + const workspaceConfig = readWorkspaceConfig(options.workspacePath); const specs = workspaceConfig.specs || []; - let relativePath = apiSpecPath; - try { - const relPath = path.relative(options.workspacePath, apiSpecPath); - if (!relPath.startsWith('..') && !path.isAbsolute(relPath)) { - relativePath = relPath; - } - } catch (error) { - console.log('Using absolute path for API spec:', error.message); - } - const specName = path.basename(apiSpecPath, path.extname(apiSpecPath)); - const specEntry = { - name: specName, - path: relativePath - }; const existingSpec = specs.find((a) => { + if (!a.path) return false; const existingPath = path.isAbsolute(a.path) ? a.path : path.resolve(options.workspacePath, a.path); @@ -69,18 +69,17 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => { }); if (!existingSpec) { - workspaceConfig.specs = [...specs, specEntry]; + await addApiSpecToWorkspace(options.workspacePath, { + name: specName, + path: apiSpecPath + }); - const updatedYamlContent = generateYamlContent(workspaceConfig); - fs.writeFileSync(workspaceFilePath, updatedYamlContent); - - const normalizedConfig = normalizeWorkspaceConfig(workspaceConfig); + const updatedConfig = readWorkspaceConfig(options.workspacePath); + const normalizedConfig = normalizeWorkspaceConfig(updatedConfig); const workspaceUid = getWorkspaceUid(options.workspacePath); const isDefault = workspaceUid === 'default'; - win.webContents.send('main:workspace-config-updated', options.workspacePath, workspaceUid, { - ...normalizedConfig, - type: isDefault ? 'default' : normalizedConfig.type - }); + const configForClient = prepareWorkspaceConfigForClient(normalizedConfig, isDefault); + win.webContents.send('main:workspace-config-updated', options.workspacePath, workspaceUid, configForClient); } } } diff --git a/packages/bruno-electron/src/app/workspace-watcher.js b/packages/bruno-electron/src/app/workspace-watcher.js index 261e0e6da..f57d0841b 100644 --- a/packages/bruno-electron/src/app/workspace-watcher.js +++ b/packages/bruno-electron/src/app/workspace-watcher.js @@ -11,6 +11,8 @@ const { decryptStringSafe } = require('../utils/encryption'); const environmentSecretsStore = new EnvironmentSecretsStore(); +const DEFAULT_WORKSPACE_NAME = 'My Workspace'; + const envHasSecrets = (environment) => { const secrets = _.filter(environment.variables, (v) => v.secret === true); return secrets && secrets.length > 0; @@ -48,6 +50,7 @@ const handleWorkspaceFileChange = (win, workspacePath) => { win.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, { ...workspaceConfig, + name: isDefault ? DEFAULT_WORKSPACE_NAME : workspaceConfig.name, type: isDefault ? 'default' : workspaceConfig.type }); } catch (error) { diff --git a/packages/bruno-electron/src/ipc/apiSpec.js b/packages/bruno-electron/src/ipc/apiSpec.js index 39a28a7e0..b8f553117 100644 --- a/packages/bruno-electron/src/ipc/apiSpec.js +++ b/packages/bruno-electron/src/ipc/apiSpec.js @@ -2,10 +2,9 @@ const { ipcMain } = require('electron'); const { openApiSpecDialog, openApiSpec } = require('../app/apiSpecs'); const { writeFile } = require('../utils/filesystem'); const { removeApiSpecUid } = require('../cache/apiSpecUids'); -const { generateYamlContent } = require('../utils/workspace-config'); +const { removeApiSpecFromWorkspace } = require('../utils/workspace-config'); const path = require('path'); const fs = require('fs'); -const yaml = require('js-yaml'); const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedApiSpecs) => { ipcMain.handle('renderer:open-api-spec', (event, workspacePath = null) => { @@ -52,26 +51,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedApiSpecs) const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); if (fs.existsSync(workspaceFilePath)) { - const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8'); - const workspaceConfig = yaml.load(yamlContent); - - const specs = workspaceConfig.specs || []; - - const filteredSpecs = specs.filter((a) => { - const specPathFromYml = a.path; - if (!specPathFromYml) return true; - - const absoluteSpecPath = path.isAbsolute(specPathFromYml) - ? specPathFromYml - : path.resolve(workspacePath, specPathFromYml); - - return absoluteSpecPath !== pathname; - }); - - workspaceConfig.specs = filteredSpecs; - - const updatedYamlContent = generateYamlContent(workspaceConfig); - fs.writeFileSync(workspaceFilePath, updatedYamlContent); + await removeApiSpecFromWorkspace(workspacePath, pathname); } } } diff --git a/packages/bruno-electron/src/ipc/workspace.js b/packages/bruno-electron/src/ipc/workspace.js index 13d2fe0cb..01704c1cb 100644 --- a/packages/bruno-electron/src/ipc/workspace.js +++ b/packages/bruno-electron/src/ipc/workspace.js @@ -26,6 +26,19 @@ const { getWorkspaceUid } = require('../utils/workspace-config'); +const DEFAULT_WORKSPACE_NAME = 'My Workspace'; + +const prepareWorkspaceConfigForClient = (workspaceConfig, isDefault) => { + if (isDefault) { + return { + ...workspaceConfig, + name: DEFAULT_WORKSPACE_NAME, + type: 'default' + }; + } + return workspaceConfig; +}; + const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const lastOpenedWorkspaces = new LastOpenedWorkspaces(); @@ -58,17 +71,16 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { lastOpenedWorkspaces.add(dirPath); - mainWindow.webContents.send('main:workspace-opened', dirPath, workspaceUid, { - ...workspaceConfig, - type: isDefault ? 'default' : workspaceConfig.type - }); + const configForClient = prepareWorkspaceConfigForClient(workspaceConfig, isDefault); + + mainWindow.webContents.send('main:workspace-opened', dirPath, workspaceUid, configForClient); if (workspaceWatcher) { workspaceWatcher.addWatcher(mainWindow, dirPath); } return { - workspaceConfig: { ...workspaceConfig, type: isDefault ? 'default' : workspaceConfig.type }, + workspaceConfig: configForClient, workspaceUid, workspacePath: dirPath }; @@ -86,18 +98,18 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const workspaceUid = getWorkspaceUid(workspacePath); const isDefault = workspaceUid === 'default'; - const configWithType = { ...workspaceConfig, type: isDefault ? 'default' : workspaceConfig.type }; + const configForClient = prepareWorkspaceConfigForClient(workspaceConfig, isDefault); lastOpenedWorkspaces.add(workspacePath); - mainWindow.webContents.send('main:workspace-opened', workspacePath, workspaceUid, configWithType); + mainWindow.webContents.send('main:workspace-opened', workspacePath, workspaceUid, configForClient); if (workspaceWatcher) { workspaceWatcher.addWatcher(mainWindow, workspacePath); } return { - workspaceConfig: configWithType, + workspaceConfig: configForClient, workspaceUid, workspacePath }; @@ -126,18 +138,18 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const workspaceUid = getWorkspaceUid(workspacePath); const isDefault = workspaceUid === 'default'; - const configWithType = { ...workspaceConfig, type: isDefault ? 'default' : workspaceConfig.type }; + const configForClient = prepareWorkspaceConfigForClient(workspaceConfig, isDefault); lastOpenedWorkspaces.add(workspacePath); - mainWindow.webContents.send('main:workspace-opened', workspacePath, workspaceUid, configWithType); + mainWindow.webContents.send('main:workspace-opened', workspacePath, workspaceUid, configForClient); if (workspaceWatcher) { workspaceWatcher.addWatcher(mainWindow, workspacePath); } return { - workspaceConfig: configWithType, + workspaceConfig: configForClient, workspaceUid, workspacePath }; @@ -361,11 +373,11 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const workspaceUid = getWorkspaceUid(finalWorkspacePath); const isDefault = workspaceUid === 'default'; - const configWithType = { ...finalConfig, type: isDefault ? 'default' : finalConfig.type }; + const configForClient = prepareWorkspaceConfigForClient(finalConfig, isDefault); lastOpenedWorkspaces.add(finalWorkspacePath); - mainWindow.webContents.send('main:workspace-opened', finalWorkspacePath, workspaceUid, configWithType); + mainWindow.webContents.send('main:workspace-opened', finalWorkspacePath, workspaceUid, configForClient); if (workspaceWatcher) { workspaceWatcher.addWatcher(mainWindow, finalWorkspacePath); @@ -373,7 +385,7 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { return { success: true, - workspaceConfig: configWithType, + workspaceConfig: configForClient, workspaceUid, workspacePath: finalWorkspacePath }; @@ -490,10 +502,8 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const workspaceConfig = readWorkspaceConfig(workspacePath); const workspaceUid = getWorkspaceUid(workspacePath); const isDefault = workspaceUid === 'default'; - mainWindow.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, { - ...workspaceConfig, - type: isDefault ? 'default' : workspaceConfig.type - }); + const configForClient = prepareWorkspaceConfigForClient(workspaceConfig, isDefault); + mainWindow.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, configForClient); return updatedCollections; } catch (error) { @@ -534,10 +544,8 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const correctWorkspaceUid = getWorkspaceUid(workspacePath); const isDefault = correctWorkspaceUid === 'default'; - mainWindow.webContents.send('main:workspace-config-updated', workspacePath, correctWorkspaceUid, { - ...result.updatedConfig, - type: isDefault ? 'default' : result.updatedConfig.type - }); + const configForClient = prepareWorkspaceConfigForClient(result.updatedConfig, isDefault); + mainWindow.webContents.send('main:workspace-config-updated', workspacePath, correctWorkspaceUid, configForClient); return true; } catch (error) { @@ -588,12 +596,10 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const { workspacePath, workspaceUid } = result; const workspaceConfig = readWorkspaceConfig(workspacePath); + const configForClient = prepareWorkspaceConfigForClient(workspaceConfig, true); return { - workspaceConfig: { - ...workspaceConfig, - type: 'default' - }, + workspaceConfig: configForClient, workspaceUid, workspacePath }; @@ -605,23 +611,19 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { ipcMain.on('main:renderer-ready', async (win) => { try { - // Load default workspace const defaultResult = await defaultWorkspaceManager.ensureDefaultWorkspaceExists(); if (defaultResult) { const { workspacePath, workspaceUid } = defaultResult; const workspaceConfig = readWorkspaceConfig(workspacePath); + const configForClient = prepareWorkspaceConfigForClient(workspaceConfig, true); - win.webContents.send('main:workspace-opened', workspacePath, workspaceUid, { - ...workspaceConfig, - type: 'default' - }); + win.webContents.send('main:workspace-opened', workspacePath, workspaceUid, configForClient); if (workspaceWatcher) { workspaceWatcher.addWatcher(win, workspacePath); } } - // Load other workspaces const workspacePaths = lastOpenedWorkspaces.getAll(); const invalidPaths = []; @@ -634,11 +636,9 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { validateWorkspaceConfig(workspaceConfig); const workspaceUid = getWorkspaceUid(workspacePath); const isDefault = workspaceUid === 'default'; + const configForClient = prepareWorkspaceConfigForClient(workspaceConfig, isDefault); - win.webContents.send('main:workspace-opened', workspacePath, workspaceUid, { - ...workspaceConfig, - type: isDefault ? 'default' : workspaceConfig.type - }); + win.webContents.send('main:workspace-opened', workspacePath, workspaceUid, configForClient); if (workspaceWatcher) { workspaceWatcher.addWatcher(win, workspacePath); diff --git a/packages/bruno-electron/src/store/default-workspace.js b/packages/bruno-electron/src/store/default-workspace.js index 44c27f6f4..24534a670 100644 --- a/packages/bruno-electron/src/store/default-workspace.js +++ b/packages/bruno-electron/src/store/default-workspace.js @@ -5,11 +5,17 @@ const { generateUidBasedOnHash } = require('../utils/common'); const { writeFile } = require('../utils/filesystem'); const { getPreferences, savePreferences } = require('./preferences'); const { globalEnvironmentsStore } = require('./global-environments'); -const { generateYamlContent, readWorkspaceConfig, validateWorkspaceConfig } = require('../utils/workspace-config'); +const { + generateYamlContent, + readWorkspaceConfig, + validateWorkspaceConfig, + isValidCollectionEntry +} = require('../utils/workspace-config'); const OPENCOLLECTION_VERSION = '1.0.0'; const WORKSPACE_TYPE = 'workspace'; const DEFAULT_WORKSPACE_UID = 'default'; +const MAX_WORKSPACE_CREATION_ATTEMPTS = 20; class DefaultWorkspaceManager { constructor() { @@ -106,11 +112,15 @@ class DefaultWorkspaceManager { let workspacePath = baseWorkspacePath; let counter = 1; - while (fs.existsSync(workspacePath)) { + while (fs.existsSync(workspacePath) && counter < MAX_WORKSPACE_CREATION_ATTEMPTS) { workspacePath = `${baseWorkspacePath}-${counter}`; counter++; } + if (counter >= MAX_WORKSPACE_CREATION_ATTEMPTS) { + throw new Error('Unable to create default workspace: too many existing workspace directories'); + } + fs.mkdirSync(workspacePath, { recursive: true }); fs.mkdirSync(path.join(workspacePath, 'collections'), { recursive: true }); fs.mkdirSync(path.join(workspacePath, 'environments'), { recursive: true }); @@ -126,8 +136,9 @@ class DefaultWorkspaceManager { docs: '' }; + let migrationCleanupFn = null; if (migrateFromPreferences) { - await this.migrateFromPreferences(workspacePath, workspaceConfig); + migrationCleanupFn = await this.migrateFromPreferences(workspacePath, workspaceConfig); } const yamlContent = generateYamlContent(workspaceConfig); @@ -135,27 +146,38 @@ class DefaultWorkspaceManager { await this.setDefaultWorkspacePath(workspacePath); + if (migrationCleanupFn) { + migrationCleanupFn(); + } + return workspacePath; } async migrateFromPreferences(workspacePath, workspaceConfig) { - try { - const Store = require('electron-store'); - const preferencesStore = new Store({ name: 'preferences' }); + const Store = require('electron-store'); + const preferencesStore = new Store({ name: 'preferences' }); + let shouldClearGlobalEnvStore = false; + let shouldDeleteWorkspaceDocs = false; + + try { const lastOpenedCollections = preferencesStore.get('lastOpenedCollections', []); if (lastOpenedCollections && lastOpenedCollections.length > 0) { - const collections = lastOpenedCollections.map((collectionPath) => { - const absolutePath = path.resolve(collectionPath); - const collectionName = path.basename(absolutePath); + const collections = lastOpenedCollections + .map((collectionPath) => { + if (!collectionPath || typeof collectionPath !== 'string') { + return null; + } + const absolutePath = path.resolve(collectionPath); + const collectionName = path.basename(absolutePath); - return { - type: 'preference', - path: absolutePath, - name: collectionName - }; - }); + return { + path: absolutePath, + name: collectionName + }; + }) + .filter((collection) => isValidCollectionEntry(collection)); workspaceConfig.collections = collections; } @@ -168,6 +190,10 @@ class DefaultWorkspaceManager { const environmentsDir = path.join(workspacePath, 'environments'); for (const env of globalEnvironments) { + if (!env || !env.name || typeof env.name !== 'string') { + continue; + } + const envFilePath = path.join(environmentsDir, `${env.name}.yml`); const environment = { @@ -184,18 +210,31 @@ class DefaultWorkspaceManager { } } - const globalEnvStore = new Store({ name: 'global-environments' }); - globalEnvStore.clear(); + shouldClearGlobalEnvStore = true; } const defaultWorkspaceDocs = preferencesStore.get('preferences.defaultWorkspaceDocs', ''); if (defaultWorkspaceDocs) { workspaceConfig.docs = defaultWorkspaceDocs; - preferencesStore.delete('preferences.defaultWorkspaceDocs'); + shouldDeleteWorkspaceDocs = true; } } catch (error) { console.error('Failed to migrate from preferences:', error); } + + return () => { + try { + if (shouldClearGlobalEnvStore) { + const globalEnvStore = new Store({ name: 'global-environments' }); + globalEnvStore.clear(); + } + if (shouldDeleteWorkspaceDocs) { + preferencesStore.delete('preferences.defaultWorkspaceDocs'); + } + } catch (cleanupError) { + console.error('Failed to cleanup after migration:', cleanupError); + } + }; } needsMigration() { diff --git a/packages/bruno-electron/src/store/workspace-environments.js b/packages/bruno-electron/src/store/workspace-environments.js index 8f1c17da9..b27ad67b4 100644 --- a/packages/bruno-electron/src/store/workspace-environments.js +++ b/packages/bruno-electron/src/store/workspace-environments.js @@ -7,7 +7,12 @@ const { writeFile, createDirectory } = require('../utils/filesystem'); const { generateUidBasedOnHash, uuid } = require('../utils/common'); const { decryptStringSafe } = require('../utils/encryption'); const EnvironmentSecretsStore = require('./env-secrets'); -const { generateYamlContent } = require('../utils/workspace-config'); +const { + readWorkspaceConfig, + generateYamlContent, + writeWorkspaceFileAtomic +} = require('../utils/workspace-config'); +const { withLock, getWorkspaceLockKey } = require('../utils/workspace-lock'); const environmentSecretsStore = new EnvironmentSecretsStore(); @@ -147,29 +152,23 @@ class GlobalEnvironmentsManager { } async setActiveGlobalEnvironmentUid(workspacePath, environmentUid) { - try { - if (!workspacePath) { - throw new Error('Workspace path is required'); - } - - 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); - - workspaceConfig.activeEnvironmentUid = environmentUid; - - const yamlOutput = generateYamlContent(workspaceConfig); - - await writeFile(workspaceFilePath, yamlOutput); - return true; - } catch (error) { - throw error; + if (!workspacePath) { + throw new Error('Workspace path is required'); } + + const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); + + if (!fs.existsSync(workspaceFilePath)) { + throw new Error('Invalid workspace: workspace.yml not found'); + } + + return withLock(getWorkspaceLockKey(workspacePath), async () => { + const workspaceConfig = readWorkspaceConfig(workspacePath); + workspaceConfig.activeEnvironmentUid = environmentUid; + const yamlOutput = generateYamlContent(workspaceConfig); + await writeWorkspaceFileAtomic(workspacePath, yamlOutput); + return true; + }); } async createGlobalEnvironment(workspacePath, { uid, name, variables }) { diff --git a/packages/bruno-electron/src/utils/workspace-config.js b/packages/bruno-electron/src/utils/workspace-config.js index 4bef7bdd6..3ea73b4b5 100644 --- a/packages/bruno-electron/src/utils/workspace-config.js +++ b/packages/bruno-electron/src/utils/workspace-config.js @@ -1,12 +1,124 @@ const fs = require('fs'); const path = require('path'); +const os = require('os'); const yaml = require('js-yaml'); +const crypto = require('node:crypto'); const { writeFile, validateName } = require('./filesystem'); const { generateUidBasedOnHash } = require('./common'); +const { withLock, getWorkspaceLockKey } = require('./workspace-lock'); const WORKSPACE_TYPE = 'workspace'; const OPENCOLLECTION_VERSION = '1.0.0'; +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'); + 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: 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: spec.path.trim() + })); +}; + const makeRelativePath = (workspacePath, absolutePath) => { if (!path.isAbsolute(absolutePath)) { return absolutePath; @@ -102,23 +214,23 @@ const readWorkspaceConfig = (workspacePath) => { const generateYamlContent = (config) => { const yamlLines = []; + const workspaceName = config.info?.name || config.name || 'Unnamed Workspace'; + const workspaceType = config.info?.type || config.type || WORKSPACE_TYPE; + yamlLines.push(`opencollection: ${config.opencollection || OPENCOLLECTION_VERSION}`); yamlLines.push('info:'); - yamlLines.push(` name: ${config.info?.name || config.name}`); - yamlLines.push(` type: ${config.info?.type || config.type || WORKSPACE_TYPE}`); + yamlLines.push(` name: ${quoteYamlValue(workspaceName)}`); + yamlLines.push(` type: ${workspaceType}`); yamlLines.push(''); - const collections = config.collections || []; + const collections = sanitizeCollections(config.collections); if (collections.length > 0) { yamlLines.push('collections:'); for (const collection of collections) { - yamlLines.push(` - name: ${collection.name}`); - yamlLines.push(` path: ${collection.path}`); + yamlLines.push(` - name: ${quoteYamlValue(collection.name)}`); + yamlLines.push(` path: ${quoteYamlValue(collection.path)}`); if (collection.remote) { - yamlLines.push(` remote: ${collection.remote}`); - } - if (collection.type) { - yamlLines.push(` type: ${collection.type}`); + yamlLines.push(` remote: ${quoteYamlValue(collection.remote)}`); } } } else { @@ -126,12 +238,12 @@ const generateYamlContent = (config) => { } yamlLines.push(''); - const specs = config.specs || []; + const specs = sanitizeSpecs(config.specs); if (specs.length > 0) { yamlLines.push('specs:'); for (const spec of specs) { - yamlLines.push(` - name: ${spec.name}`); - yamlLines.push(` path: ${spec.path}`); + yamlLines.push(` - name: ${quoteYamlValue(spec.name)}`); + yamlLines.push(` path: ${quoteYamlValue(spec.path)}`); } } else { yamlLines.push('specs:'); @@ -142,13 +254,13 @@ const generateYamlContent = (config) => { if (docs) { const escapedDocs = docs.includes('\n') ? `|-\n ${docs.split('\n').join('\n ')}` - : `'${docs.replace(/'/g, '\'\'')}'`; + : quoteYamlValue(docs); yamlLines.push(`docs: ${escapedDocs}`); } else { yamlLines.push('docs: \'\''); } - if (config.activeEnvironmentUid) { + if (config.activeEnvironmentUid && typeof config.activeEnvironmentUid === 'string') { yamlLines.push(''); yamlLines.push(`activeEnvironmentUid: ${config.activeEnvironmentUid}`); } @@ -159,8 +271,10 @@ const generateYamlContent = (config) => { }; const writeWorkspaceConfig = async (workspacePath, config) => { - const yamlContent = generateYamlContent(config); - await writeFile(path.join(workspacePath, 'workspace.yml'), yamlContent); + return withLock(getWorkspaceLockKey(workspacePath), async () => { + const yamlContent = generateYamlContent(config); + await writeWorkspaceFileAtomic(workspacePath, yamlContent); + }); }; const validateWorkspaceConfig = (config) => { @@ -182,88 +296,104 @@ const validateWorkspaceConfig = (config) => { }; const updateWorkspaceName = async (workspacePath, newName) => { - const config = readWorkspaceConfig(workspacePath); - config.name = newName; - if (config.info) { - config.info.name = newName; - } - await writeWorkspaceConfig(workspacePath, config); - return config; + 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) => { - const config = readWorkspaceConfig(workspacePath); - config.docs = docs; - await writeWorkspaceConfig(workspacePath, config); - return 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) => { - const config = readWorkspaceConfig(workspacePath); - - if (!config.collections) { - config.collections = []; + if (!isValidCollectionEntry(collection)) { + throw new Error('Invalid collection: name and path are required'); } - const normalizedCollection = { - name: collection.name, - path: collection.path - }; + return withLock(getWorkspaceLockKey(workspacePath), async () => { + const config = readWorkspaceConfig(workspacePath); - if (collection.remote) { - normalizedCollection.remote = collection.remote; - } + if (!config.collections) { + config.collections = []; + } - const existingIndex = config.collections.findIndex((c) => c.path === normalizedCollection.path); + const normalizedCollection = { + name: collection.name.trim(), + path: collection.path.trim() + }; - if (existingIndex >= 0) { - config.collections[existingIndex] = normalizedCollection; - } else { - config.collections.push(normalizedCollection); - } + if (collection.remote && typeof collection.remote === 'string') { + normalizedCollection.remote = collection.remote.trim(); + } - await writeWorkspaceConfig(workspacePath, config); - return config.collections; + const existingIndex = config.collections.findIndex((c) => 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 removeCollectionFromWorkspace = async (workspacePath, collectionPath) => { - const config = readWorkspaceConfig(workspacePath); + return withLock(getWorkspaceLockKey(workspacePath), async () => { + const config = readWorkspaceConfig(workspacePath); - let removedCollection = null; - let shouldDeleteFiles = false; + let removedCollection = null; + let shouldDeleteFiles = false; - config.collections = (config.collections || []).filter((c) => { - const collectionPathFromYml = c.path; + config.collections = (config.collections || []).filter((c) => { + const collectionPathFromYml = 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; + + const hasRemote = c.remote; + const isExternalPath = path.isAbsolute(collectionPathFromYml); + + shouldDeleteFiles = !hasRemote && !isExternalPath; + + return false; + } - if (!collectionPathFromYml) { return true; - } + }); - const absoluteCollectionPath = path.isAbsolute(collectionPathFromYml) - ? collectionPathFromYml - : path.resolve(workspacePath, collectionPathFromYml); + const yamlContent = generateYamlContent(config); + await writeWorkspaceFileAtomic(workspacePath, yamlContent); - if (path.normalize(absoluteCollectionPath) === path.normalize(collectionPath)) { - removedCollection = c; - - const hasRemote = c.remote; - const isExternalPath = path.isAbsolute(collectionPathFromYml); - - shouldDeleteFiles = !hasRemote && !isExternalPath; - - return false; - } - - return true; + return { + removedCollection, + shouldDeleteFiles, + updatedConfig: config + }; }); - - await writeWorkspaceConfig(workspacePath, config); - - return { - removedCollection, - shouldDeleteFiles, - updatedConfig: config - }; }; const getWorkspaceCollections = (workspacePath) => { @@ -297,62 +427,72 @@ const getWorkspaceApiSpecs = (workspacePath) => { }; const addApiSpecToWorkspace = async (workspacePath, apiSpec) => { - const config = readWorkspaceConfig(workspacePath); - - if (!config.specs) { - config.specs = []; + if (!isValidSpecEntry(apiSpec)) { + throw new Error('Invalid API spec: name and path are required'); } - const normalizedSpec = { - name: apiSpec.name, - path: makeRelativePath(workspacePath, apiSpec.path) - }; + return withLock(getWorkspaceLockKey(workspacePath), async () => { + const config = readWorkspaceConfig(workspacePath); - const existingIndex = config.specs.findIndex( - (a) => a.name === normalizedSpec.name || a.path === normalizedSpec.path - ); + if (!config.specs) { + config.specs = []; + } - if (existingIndex >= 0) { - config.specs[existingIndex] = normalizedSpec; - } else { - config.specs.push(normalizedSpec); - } + const normalizedSpec = { + name: apiSpec.name.trim(), + path: makeRelativePath(workspacePath, apiSpec.path).trim() + }; - await writeWorkspaceConfig(workspacePath, config); - return config.specs; + const existingIndex = config.specs.findIndex( + (a) => a.name === normalizedSpec.name || 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) => { - const config = readWorkspaceConfig(workspacePath); + 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; - 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; + if (!config.specs) { + return { removedApiSpec: null, updatedConfig: config }; } - return true; + let removedApiSpec = null; + + config.specs = config.specs.filter((a) => { + const specPathFromYml = 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 + }; }); - - await writeWorkspaceConfig(workspacePath, config); - - return { - removedApiSpec, - updatedConfig: config - }; }; const getWorkspaceUid = (workspacePath) => { @@ -382,5 +522,8 @@ module.exports = { addApiSpecToWorkspace, removeApiSpecFromWorkspace, generateYamlContent, - getWorkspaceUid + getWorkspaceUid, + writeWorkspaceFileAtomic, + isValidCollectionEntry, + isValidSpecEntry }; diff --git a/packages/bruno-electron/src/utils/workspace-lock.js b/packages/bruno-electron/src/utils/workspace-lock.js new file mode 100644 index 000000000..c44cc0ebe --- /dev/null +++ b/packages/bruno-electron/src/utils/workspace-lock.js @@ -0,0 +1,43 @@ +const locks = new Map(); + +const acquireLock = async (key, timeout = 10000) => { + const startTime = Date.now(); + + while (locks.has(key)) { + if (Date.now() - startTime > timeout) { + throw new Error(`Lock acquisition timeout for: ${key}`); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + let releaseFn; + const releasePromise = new Promise((resolve) => { + releaseFn = resolve; + }); + + locks.set(key, releasePromise); + + return () => { + locks.delete(key); + releaseFn(); + }; +}; + +const withLock = async (key, fn) => { + const release = await acquireLock(key); + try { + return await fn(); + } finally { + release(); + } +}; + +const getWorkspaceLockKey = (workspacePath) => { + return `workspace:${workspacePath}`; +}; + +module.exports = { + acquireLock, + withLock, + getWorkspaceLockKey +};