improve: workspace handling (#6495)

* improve: workspace

* fixes
This commit is contained in:
naman-bruno
2025-12-23 20:22:51 +05:30
committed by GitHub
parent ce33cee03d
commit 8c7ed3fe51
8 changed files with 458 additions and 252 deletions

View File

@@ -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);
}
}
}

View File

@@ -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) {

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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 }) {

View File

@@ -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
};

View File

@@ -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
};