diff --git a/build-python/meson.build b/build-python/meson.build
index 3ebe8ee..5f50198 100644
--- a/build-python/meson.build
+++ b/build-python/meson.build
@@ -107,6 +107,7 @@ py_installation.install_sources(
meson.project_source_root() + '/fourdst/core/platform.py',
meson.project_source_root() + '/fourdst/core/utils.py',
meson.project_source_root() + '/fourdst/core/keys.py',
+ meson.project_source_root() + '/fourdst/core/plugin.py',
),
subdir: 'fourdst/core'
)
diff --git a/electron/bridge.py b/electron/bridge.py
index 1652629..a9ecfb5 100644
--- a/electron/bridge.py
+++ b/electron/bridge.py
@@ -34,7 +34,7 @@ class FourdstEncoder(json.JSONEncoder):
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
-from fourdst.core import bundle, keys
+from fourdst.core import bundle, keys, plugin
def main():
# Use stderr for all logging to avoid interfering with JSON output on stdout
@@ -74,9 +74,18 @@ def main():
'sync_remotes', 'get_remote_sources', 'add_remote_source', 'remove_remote_source'
]
+ plugin_commands = [
+ 'parse_cpp_interface', 'generate_plugin_project', 'validate_bundle_directory',
+ 'pack_bundle_directory', 'extract_plugin_from_bundle', 'compare_plugin_sources',
+ 'validate_plugin_project'
+ ]
+
if command in key_commands:
func = getattr(keys, command)
module_name = "keys"
+ elif command in plugin_commands:
+ func = getattr(plugin, command)
+ module_name = "plugin"
else:
func = getattr(bundle, command)
module_name = "bundle"
diff --git a/electron/index.html b/electron/index.html
index 2517be9..58581af 100644
--- a/electron/index.html
+++ b/electron/index.html
@@ -63,6 +63,16 @@
+
+
+
@@ -480,6 +490,150 @@
+
+
diff --git a/electron/main-refactored.js b/electron/main-refactored.js
index 4cbabfd..1abe900 100644
--- a/electron/main-refactored.js
+++ b/electron/main-refactored.js
@@ -5,7 +5,7 @@
// Import modular components
const { setupAppEventHandlers, setupThemeHandlers } = require('./main/app-lifecycle');
const { setupFileDialogHandlers } = require('./main/file-dialogs');
-const { setupBundleIPCHandlers, setupKeyIPCHandlers } = require('./main/ipc-handlers');
+const { setupBundleIPCHandlers, setupKeyIPCHandlers, setupPluginIPCHandlers } = require('./main/ipc-handlers');
// Initialize all modules in the correct order
function initializeMainProcess() {
@@ -24,6 +24,9 @@ function initializeMainProcess() {
// Setup key management IPC handlers
setupKeyIPCHandlers();
+ // Setup plugin management IPC handlers
+ setupPluginIPCHandlers();
+
console.log('[MAIN_PROCESS] All modules initialized successfully');
}
diff --git a/electron/main/ipc-handlers.js b/electron/main/ipc-handlers.js
index 7fc67fb..739ae8e 100644
--- a/electron/main/ipc-handlers.js
+++ b/electron/main/ipc-handlers.js
@@ -231,9 +231,92 @@ const setupBundleIPCHandlers = () => {
return { success: false, error: error.message };
}
});
+
+
+};
+
+const setupPluginIPCHandlers = () => {
+ // Parse C++ interface handler
+ ipcMain.handle('parse-cpp-interface', async (event, { headerContent, fileName }) => {
+ try {
+ // Write header content to a temporary file since parse_cpp_interface expects a file path
+ const os = require('os');
+ const tempDir = os.tmpdir();
+ const tempFilePath = path.join(tempDir, fileName || 'temp_interface.h');
+
+ await fs.writeFile(tempFilePath, headerContent);
+
+ const kwargs = {
+ header_path: tempFilePath
+ };
+
+ const result = await runPythonCommand('parse_cpp_interface', kwargs, event);
+
+ // Clean up temporary file
+ try {
+ await fs.unlink(tempFilePath);
+ } catch (cleanupError) {
+ console.warn('[IPC_HANDLER] Failed to clean up temp file:', cleanupError);
+ }
+
+ return result;
+ } catch (error) {
+ console.error('[IPC_HANDLER] Error in parse-cpp-interface:', error);
+ return { success: false, error: error.message };
+ }
+ });
+
+ // Generate plugin project handler
+ ipcMain.handle('generate-plugin-project', async (event, projectConfig) => {
+ // The function expects a single 'config' parameter containing all the configuration
+ // Note: directory and header_path need to be converted to Path objects in Python
+ const kwargs = {
+ config: {
+ project_name: projectConfig.project_name,
+ chosen_interface: projectConfig.chosen_interface,
+ interfaces: projectConfig.interfaces,
+ directory: projectConfig.output_directory, // Will be converted to Path in Python
+ version: projectConfig.version,
+ libplugin_rev: projectConfig.libplugin_revision,
+ header_path: projectConfig.header_path // Will be converted to Path in Python
+ }
+ };
+ return runPythonCommand('generate_plugin_project', kwargs, event);
+ });
+
+ // Validate plugin project handler
+ ipcMain.handle('validate-plugin-project', async (event, { plugin_directory }) => {
+ const kwargs = {
+ project_path: plugin_directory // Function expects 'project_path', not 'plugin_directory'
+ };
+ return runPythonCommand('validate_plugin_project', kwargs, event);
+ });
+
+
+
+ // Extract plugin from bundle handler
+ ipcMain.handle('extract-plugin-from-bundle', async (event, { plugin_name, bundle_path, output_directory }) => {
+ const kwargs = {
+ plugin_name: plugin_name,
+ bundle_path: bundle_path,
+ output_directory: output_directory
+ };
+ return runPythonCommand('extract_plugin_from_bundle', kwargs, event);
+ });
+
+ // Compare plugin sources handler
+ ipcMain.handle('compare-plugin-sources', async (event, { plugin_name, bundle_a_path, bundle_b_path }) => {
+ const kwargs = {
+ plugin_name: plugin_name,
+ bundle_a_path: bundle_a_path,
+ bundle_b_path: bundle_b_path
+ };
+ return runPythonCommand('compare_plugin_sources', kwargs, event);
+ });
};
module.exports = {
setupBundleIPCHandlers,
- setupKeyIPCHandlers
+ setupKeyIPCHandlers,
+ setupPluginIPCHandlers
};
diff --git a/electron/renderer-refactored.js b/electron/renderer-refactored.js
index 4da0d65..4f5d1df 100644
--- a/electron/renderer-refactored.js
+++ b/electron/renderer-refactored.js
@@ -9,6 +9,9 @@ const stateManager = require('./renderer/state-manager');
const domManager = require('./renderer/dom-manager');
const bundleOperations = require('./renderer/bundle-operations');
const keyOperations = require('./renderer/key-operations');
+console.log('[RENDERER] Loading plugin operations module...');
+const pluginOperations = require('./renderer/plugin-operations');
+console.log('[RENDERER] Plugin operations module loaded:', !!pluginOperations);
const uiComponents = require('./renderer/ui-components');
const eventHandlers = require('./renderer/event-handlers');
const opatHandler = require('./renderer/opat-handler');
@@ -23,6 +26,7 @@ function initializeModules() {
domManager,
bundleOperations,
keyOperations,
+ pluginOperations,
uiComponents,
eventHandlers,
opatHandler,
@@ -33,6 +37,7 @@ function initializeModules() {
// Initialize each module with its dependencies
bundleOperations.initializeDependencies(deps);
keyOperations.initializeDependencies(deps);
+ pluginOperations.initializeDependencies(deps);
uiComponents.initializeDependencies(deps);
eventHandlers.initializeDependencies(deps);
opatHandler.initializeDependencies(deps);
@@ -87,6 +92,7 @@ window.stateManager = stateManager;
window.domManager = domManager;
window.bundleOperations = bundleOperations;
window.keyOperations = keyOperations;
+window.pluginOperations = pluginOperations;
window.uiComponents = uiComponents;
window.eventHandlers = eventHandlers;
window.opatHandler = opatHandler;
diff --git a/electron/renderer/dom-manager.js b/electron/renderer/dom-manager.js
index 835916a..d3d3e51 100644
--- a/electron/renderer/dom-manager.js
+++ b/electron/renderer/dom-manager.js
@@ -2,8 +2,9 @@
// Extracted from renderer.js to centralize DOM element handling and view management
// --- DOM ELEMENTS (will be initialized in initializeDOMElements) ---
-let welcomeScreen, bundleView, keysView, createBundleForm;
+let welcomeScreen, bundleView, keysView, createBundleForm, pluginView;
let openBundleBtn, createBundleBtn;
+let pluginInitBtn, pluginValidateBtn, pluginPackBtn, pluginExtractBtn, pluginDiffBtn;
let signBundleBtn, validateBundleBtn, clearBundleBtn, saveMetadataBtn;
let saveOptionsModal, overwriteBundleBtn, saveAsNewBtn;
let signatureWarningModal, signatureWarningCancel, signatureWarningContinue;
@@ -45,10 +46,17 @@ function initializeDOMElements() {
bundleView = document.getElementById('bundle-view');
keysView = document.getElementById('keys-view');
createBundleForm = document.getElementById('create-bundle-form');
+ pluginView = document.getElementById('plugin-view');
// Sidebar buttons
openBundleBtn = document.getElementById('open-bundle-btn');
createBundleBtn = document.getElementById('create-bundle-btn');
+
+ // Plugin management buttons
+ initPluginBtn = document.getElementById('init-plugin-btn');
+ validatePluginBtn = document.getElementById('validate-plugin-btn');
+ extractPluginBtn = document.getElementById('extract-plugin-btn');
+ diffPluginBtn = document.getElementById('diff-plugin-btn');
// Bundle action buttons
signBundleBtn = document.getElementById('sign-bundle-btn');
@@ -90,10 +98,16 @@ function showView(viewId) {
const opatView = document.getElementById('opat-view');
// Hide main content views
- [welcomeScreen, bundleView, keysView, createBundleForm].forEach(view => {
+ [welcomeScreen, bundleView, keysView, createBundleForm, pluginView].forEach(view => {
view.classList.toggle('hidden', view.id !== viewId);
});
+ // When switching away from plugin view, hide all plugin management sub-views
+ if (viewId !== 'plugin-view') {
+ const pluginManagementViews = document.querySelectorAll('.plugin-management-view');
+ pluginManagementViews.forEach(view => view.classList.add('hidden'));
+ }
+
// Handle OPAT view separately since it's not in the main views array
if (opatView) {
opatView.classList.toggle('hidden', viewId !== 'opat-view');
@@ -117,6 +131,15 @@ function showView(viewId) {
if (libpluginView) {
libpluginView.classList.remove('hidden');
}
+ } else if (viewId === 'plugin-view') {
+ // When switching to plugin view, show the default plugin sub-view (init view)
+ const pluginManagementViews = document.querySelectorAll('.plugin-management-view');
+ pluginManagementViews.forEach(view => view.classList.add('hidden'));
+
+ const defaultPluginView = document.getElementById('plugin-init-view');
+ if (defaultPluginView) {
+ defaultPluginView.classList.remove('hidden');
+ }
} else if (viewId === 'opat-view') {
// Ensure OPAT view is visible and properly initialized
if (opatView) {
@@ -135,6 +158,12 @@ function switchTab(tabId) {
tabLinks.forEach(link => {
link.classList.toggle('active', link.dataset.tab === tabId);
});
+
+ // When switching away from libplugin tab, hide all plugin management views
+ if (tabId !== 'libplugin') {
+ const pluginManagementViews = document.querySelectorAll('.plugin-management-view');
+ pluginManagementViews.forEach(view => view.classList.add('hidden'));
+ }
}
function showSpinner() {
@@ -174,8 +203,13 @@ module.exports = {
bundleView,
keysView,
createBundleForm,
+ pluginView,
openBundleBtn,
createBundleBtn,
+ initPluginBtn,
+ validatePluginBtn,
+ extractPluginBtn,
+ diffPluginBtn,
signBundleBtn,
validateBundleBtn,
clearBundleBtn,
diff --git a/electron/renderer/event-handlers.js b/electron/renderer/event-handlers.js
index 9c7d5da..755548c 100644
--- a/electron/renderer/event-handlers.js
+++ b/electron/renderer/event-handlers.js
@@ -4,7 +4,7 @@
const { ipcRenderer } = require('electron');
// Import dependencies (these will be injected when integrated)
-let stateManager, domManager, bundleOperations, keyOperations, fillWorkflow, uiComponents, opatHandler;
+let stateManager, domManager, bundleOperations, keyOperations, pluginOperations, fillWorkflow, uiComponents, opatHandler;
// --- EVENT LISTENERS SETUP ---
function setupEventListeners() {
@@ -66,6 +66,9 @@ function setupEventListeners() {
domManager.showModal('Not Implemented', 'The create bundle form will be moved to a modal dialog.');
});
+ // Plugin management navigation is handled by plugin-operations.js
+ // No duplicate event listeners needed here
+
// Tab navigation
elements.tabLinks.forEach(link => {
link.addEventListener('click', () => domManager.switchTab(link.dataset.tab));
@@ -89,6 +92,17 @@ function setupEventListeners() {
// Key Management event listeners
setupKeyManagementEventListeners();
+ // Plugin Management event listeners
+ console.log('[EVENT_HANDLERS] Setting up plugin event listeners...');
+ console.log('[EVENT_HANDLERS] pluginOperations available:', !!pluginOperations);
+ console.log('[EVENT_HANDLERS] setupPluginEventListeners method available:', !!(pluginOperations && pluginOperations.setupPluginEventListeners));
+
+ if (pluginOperations && pluginOperations.setupPluginEventListeners) {
+ pluginOperations.setupPluginEventListeners();
+ } else {
+ console.warn('[EVENT_HANDLERS] Plugin operations not available for event listener setup');
+ }
+
// Signature warning modal event listeners
elements.signatureWarningCancel.addEventListener('click', () => {
elements.signatureWarningModal.classList.add('hidden');
@@ -172,6 +186,14 @@ function setupCategoryNavigation() {
// Show category home screen
showCategoryHomeScreen(category);
+
+ // Set up plugin button listeners when libplugin category is shown
+ if (category === 'libplugin' && pluginOperations) {
+ // Use setTimeout to ensure DOM is ready
+ setTimeout(() => {
+ pluginOperations.ensurePluginButtonListeners();
+ }, 100);
+ }
}
// Update welcome screen
@@ -210,7 +232,7 @@ function showCategoryHomeScreen(category) {
const views = [
'welcome-screen', 'libplugin-home', 'opat-home',
'libconstants-home', 'serif-home', 'opat-view', 'libplugin-view',
- 'bundle-view', 'keys-view', 'create-bundle-form'
+ 'bundle-view', 'keys-view', 'create-bundle-form', 'plugin-view'
];
// Hide all views
@@ -512,6 +534,7 @@ function initializeDependencies(deps) {
domManager = deps.domManager;
bundleOperations = deps.bundleOperations;
keyOperations = deps.keyOperations;
+ pluginOperations = deps.pluginOperations;
fillWorkflow = deps.fillWorkflow;
uiComponents = deps.uiComponents;
opatHandler = deps.opatHandler;
diff --git a/electron/renderer/plugin-operations.js b/electron/renderer/plugin-operations.js
new file mode 100644
index 0000000..40b6c9d
--- /dev/null
+++ b/electron/renderer/plugin-operations.js
@@ -0,0 +1,509 @@
+// Plugin Operations Module
+// Handles all plugin-related functionality in the GUI
+
+const { ipcRenderer } = require('electron');
+
+class PluginOperations {
+ constructor() {
+ this.parsedInterfaces = null;
+ this.headerFilePath = null;
+ this.deps = null;
+ this.currentView = 'plugin-init-view';
+ }
+
+ initializeDependencies(deps) {
+ this.deps = deps;
+ console.log('[PLUGIN_OPERATIONS] Dependencies initialized');
+ console.log('[PLUGIN_OPERATIONS] Available deps:', Object.keys(deps));
+ }
+
+ // Show specific plugin view
+ showPluginView(viewId) {
+ // Show the main plugin view container first
+ this.deps.domManager.showView('plugin-view');
+
+ // Ensure plugin button event listeners are set up now that buttons are visible
+ this.ensurePluginButtonListeners();
+
+ // Hide all plugin views
+ const pluginViews = document.querySelectorAll('.plugin-management-view');
+ pluginViews.forEach(view => view.classList.add('hidden'));
+
+ // Show the requested view
+ const targetView = document.getElementById(viewId);
+ if (targetView) {
+ targetView.classList.remove('hidden');
+ this.currentView = viewId;
+ }
+ }
+
+ // Parse interface header file and populate interface selection
+ async parseInterfaceHeader(headerFile) {
+ try {
+ this.showPluginLoading('Parsing interface header file...');
+
+ // Read header file content
+ const headerContent = await this.readFileAsText(headerFile);
+
+ // Call backend to parse interfaces using specific IPC handler
+ const result = await ipcRenderer.invoke('parse-cpp-interface', {
+ headerContent: headerContent,
+ fileName: headerFile.name
+ });
+
+ if (result.success) {
+ this.populateInterfaceSelection(result.data);
+ this.headerFilePath = headerFile.path; // Store the header file path for later use
+ this.hidePluginResults();
+ } else {
+ this.showPluginError(`Failed to parse header file: ${result.error}`);
+ }
+ } catch (error) {
+ this.showPluginError(`Failed to parse header file: ${error.message}`);
+ }
+ }
+
+ // Populate interface selection dropdown
+ populateInterfaceSelection(interfaces) {
+ const interfaceSelection = document.getElementById('plugin-interface-selection');
+ const interfaceSelect = document.getElementById('plugin-interface-select');
+ const methodsPreview = document.getElementById('plugin-interface-methods');
+ const methodsList = document.getElementById('plugin-methods-list');
+
+ // Clear existing options
+ interfaceSelect.innerHTML = '
';
+
+ // Add interfaces to dropdown
+ Object.keys(interfaces).forEach(interfaceName => {
+ const option = document.createElement('option');
+ option.value = interfaceName;
+ option.textContent = interfaceName;
+ interfaceSelect.appendChild(option);
+ });
+
+ // Show interface selection
+ interfaceSelection.style.display = 'block';
+
+ // Store interfaces data for later use
+ this.parsedInterfaces = interfaces;
+
+ // Setup interface selection change handler
+ interfaceSelect.onchange = () => {
+ const selectedInterface = interfaceSelect.value;
+ if (selectedInterface && interfaces[selectedInterface]) {
+ // Show methods preview
+ methodsList.innerHTML = '';
+ interfaces[selectedInterface].forEach(method => {
+ const li = document.createElement('li');
+ li.textContent = method.signature;
+ methodsList.appendChild(li);
+ });
+ methodsPreview.style.display = 'block';
+ } else {
+ methodsPreview.style.display = 'none';
+ }
+ this.updateInitButtonState();
+ };
+
+ this.updateInitButtonState();
+ }
+
+ // Initialize Plugin Project
+ async initializePlugin() {
+ const projectName = document.getElementById('plugin-project-name').value.trim();
+ const headerFile = document.getElementById('plugin-header-file').files[0];
+ const selectedInterface = document.getElementById('plugin-interface-select').value;
+ const outputDir = document.getElementById('plugin-directory').value.trim();
+ const version = document.getElementById('plugin-version').value.trim();
+ const libpluginRev = document.getElementById('plugin-libplugin-rev').value.trim();
+
+ if (!projectName || !headerFile || !selectedInterface || !outputDir) {
+ this.showPluginError('Please fill in all required fields and select an interface.');
+ return;
+ }
+
+ try {
+ this.showPluginLoading('Initializing plugin project...');
+
+ // Call backend to initialize plugin using specific IPC handler
+ const result = await ipcRenderer.invoke('generate-plugin-project', {
+ project_name: projectName,
+ chosen_interface: selectedInterface,
+ interfaces: this.parsedInterfaces,
+ output_directory: outputDir,
+ version: version,
+ libplugin_revision: libpluginRev,
+ header_path: this.headerFilePath
+ });
+
+ this.handlePluginResult(result, 'Plugin project initialized successfully!');
+ } catch (error) {
+ this.showPluginError(`Failed to initialize plugin: ${error.message}`);
+ }
+ }
+
+ // Validate Plugin Project
+ async validatePlugin() {
+ const pluginPath = document.getElementById('validate-plugin-path').value.trim();
+
+ if (!pluginPath) {
+ this.showPluginError('Please select a plugin directory.');
+ return;
+ }
+
+ try {
+ this.showPluginLoading('Validating plugin project...');
+
+ const result = await ipcRenderer.invoke('validate-plugin-project', {
+ plugin_directory: pluginPath
+ });
+
+ this.handlePluginResult(result, 'Plugin validation completed!');
+ } catch (error) {
+ this.showPluginError(`Failed to validate plugin: ${error.message}`);
+ }
+ }
+
+
+
+ // Extract Plugin from Bundle
+ async extractPlugin() {
+ const pluginName = document.getElementById('extract-plugin-name').value.trim();
+ const bundleFile = document.getElementById('extract-bundle-file').files[0];
+ const outputDir = document.getElementById('extract-output-dir').value.trim();
+
+ if (!pluginName || !bundleFile || !outputDir) {
+ this.showPluginError('Please fill in all required fields.');
+ return;
+ }
+
+ try {
+ this.showPluginLoading('Extracting plugin from bundle...');
+
+ const result = await ipcRenderer.invoke('extract-plugin-from-bundle', {
+ plugin_name: pluginName,
+ bundle_path: bundleFile.path,
+ output_directory: outputDir
+ });
+
+ this.handlePluginResult(result, 'Plugin extracted successfully!');
+ } catch (error) {
+ this.showPluginError(`Failed to extract plugin: ${error.message}`);
+ }
+ }
+
+ // Compare Plugin Sources
+ async comparePlugins() {
+ const pluginName = document.getElementById('diff-plugin-name').value.trim();
+ const bundleA = document.getElementById('diff-bundle-a').files[0];
+ const bundleB = document.getElementById('diff-bundle-b').files[0];
+
+ if (!pluginName || !bundleA || !bundleB) {
+ this.showPluginError('Please fill in all required fields.');
+ return;
+ }
+
+ try {
+ this.showPluginLoading('Comparing plugin sources...');
+
+ const result = await ipcRenderer.invoke('compare-plugin-sources', {
+ plugin_name: pluginName,
+ bundle_a_path: bundleA.path,
+ bundle_b_path: bundleB.path
+ });
+
+ this.handlePluginResult(result, 'Plugin comparison completed!');
+ } catch (error) {
+ this.showPluginError(`Failed to compare plugins: ${error.message}`);
+ }
+ }
+
+ // Helper Methods
+ async readFileAsText(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = e => resolve(e.target.result);
+ reader.onerror = e => reject(new Error('Failed to read file'));
+ reader.readAsText(file);
+ });
+ }
+
+ showPluginLoading(message) {
+ const resultsDiv = document.querySelector(`#${this.currentView} .plugin-results`);
+ if (resultsDiv) {
+ resultsDiv.classList.remove('hidden');
+ resultsDiv.innerHTML = `
+
+ `;
+ }
+ }
+
+ showPluginError(message) {
+ const resultsDiv = document.querySelector(`#${this.currentView} .plugin-results`);
+ if (resultsDiv) {
+ resultsDiv.classList.remove('hidden');
+ resultsDiv.innerHTML = `
+
+ `;
+ }
+ }
+
+ hidePluginResults() {
+ const resultsDiv = document.querySelector(`#${this.currentView} .plugin-results`);
+ if (resultsDiv) {
+ resultsDiv.classList.add('hidden');
+ }
+ }
+
+ handlePluginResult(result, successMessage) {
+ const resultsDiv = document.querySelector(`#${this.currentView} .plugin-results`);
+ if (!resultsDiv) return;
+
+ resultsDiv.classList.remove('hidden');
+
+ if (result.success) {
+ let content = `
${successMessage}
`;
+
+ if (result.data) {
+ if (result.data.output) {
+ content += `
${result.data.output}`;
+ }
+ if (result.data.diff) {
+ content += `
${result.data.diff}`;
+ }
+ if (result.data.validation_results) {
+ content += `
`;
+ result.data.validation_results.forEach(item => {
+ content += `
${item.check}: ${item.status}
`;
+ });
+ content += `
`;
+ }
+ }
+
+ content += `
`;
+ resultsDiv.innerHTML = content;
+ } else {
+ resultsDiv.innerHTML = `
+
+
Operation Failed
+
${result.error}
+
+ `;
+ }
+ }
+
+ // Setup event listeners for plugin operations (called when plugin buttons are visible)
+ setupPluginEventListeners() {
+ // Don't set up listeners during initial app startup - wait until plugin buttons are visible
+ // This method will be called from showPluginView() when needed
+ }
+
+ // Setup plugin button event listeners when they're actually in the DOM
+ ensurePluginButtonListeners() {
+ if (this.listenersSetup) return; // Avoid duplicate setup
+
+ // Plugin navigation buttons with active state management
+ const pluginButtons = [
+ { id: 'init-plugin-btn', view: 'plugin-init-view' },
+ { id: 'validate-plugin-btn', view: 'plugin-validate-view' },
+ { id: 'extract-plugin-btn', view: 'plugin-extract-view' },
+ { id: 'diff-plugin-btn', view: 'plugin-diff-view' }
+ ];
+
+ let allButtonsFound = true;
+ pluginButtons.forEach(({ id, view }) => {
+ const button = document.getElementById(id);
+
+ if (button) {
+ button.addEventListener('click', () => {
+ // Remove active class from all plugin buttons
+ pluginButtons.forEach(({ id: btnId }) => {
+ document.getElementById(btnId)?.classList.remove('active');
+ });
+
+ // Add active class to clicked button
+ button.classList.add('active');
+
+ // Show the plugin view
+ this.showPluginView(view);
+ });
+ } else {
+ allButtonsFound = false;
+ }
+ });
+
+ if (allButtonsFound) {
+ this.listenersSetup = true;
+ }
+
+ // Plugin Initialize form
+ document.getElementById('plugin-header-browse-btn')?.addEventListener('click', () => {
+ document.getElementById('plugin-header-file').click();
+ });
+
+ document.getElementById('plugin-header-file')?.addEventListener('change', async (e) => {
+ const file = e.target.files[0];
+ const filename = file?.name || 'No file selected';
+ document.getElementById('plugin-header-filename').textContent = filename;
+
+ // Hide interface selection and reset state when new file is selected
+ const interfaceSelection = document.getElementById('plugin-interface-selection');
+ const interfaceSelect = document.getElementById('plugin-interface-select');
+ const methodsPreview = document.getElementById('plugin-interface-methods');
+
+ if (interfaceSelection) interfaceSelection.style.display = 'none';
+ if (interfaceSelect) interfaceSelect.value = '';
+ if (methodsPreview) methodsPreview.style.display = 'none';
+
+ this.parsedInterfaces = null;
+ this.updateInitButtonState();
+
+ // Parse interface file if one was selected
+ if (file) {
+ await this.parseInterfaceHeader(file);
+ }
+ });
+
+ document.getElementById('plugin-directory-browse-btn')?.addEventListener('click', async () => {
+ const result = await ipcRenderer.invoke('select-directory');
+ if (result) {
+ document.getElementById('plugin-directory').value = result;
+ this.updateInitButtonState();
+ }
+ });
+
+ document.getElementById('plugin-project-name')?.addEventListener('input', () => {
+ this.updateInitButtonState();
+ });
+
+ document.getElementById('plugin-init-execute-btn')?.addEventListener('click', () => {
+ this.initializePlugin();
+ });
+
+ // Plugin Validate form
+ document.getElementById('validate-plugin-browse-btn')?.addEventListener('click', async () => {
+ const result = await ipcRenderer.invoke('select-directory');
+ if (result) {
+ document.getElementById('validate-plugin-path').value = result;
+ }
+ });
+
+ document.getElementById('plugin-validate-execute-btn')?.addEventListener('click', () => {
+ this.validatePlugin();
+ });
+
+ // Plugin Pack form
+ document.getElementById('pack-plugin-browse-btn')?.addEventListener('click', async () => {
+ const result = await ipcRenderer.invoke('select-directory');
+ if (result) {
+ document.getElementById('pack-plugin-path').value = result;
+ }
+ });
+
+ document.getElementById('plugin-pack-execute-btn')?.addEventListener('click', () => {
+ this.packPlugin();
+ });
+
+ // Plugin Extract form
+ document.getElementById('extract-bundle-browse-btn')?.addEventListener('click', () => {
+ document.getElementById('extract-bundle-file').click();
+ });
+
+ document.getElementById('extract-bundle-file')?.addEventListener('change', (e) => {
+ const filename = e.target.files[0]?.name || 'No file selected';
+ document.getElementById('extract-bundle-filename').textContent = filename;
+ this.updateExtractButtonState();
+ });
+
+ document.getElementById('extract-output-browse-btn')?.addEventListener('click', async () => {
+ const result = await ipcRenderer.invoke('select-directory');
+ if (result) {
+ document.getElementById('extract-output-dir').value = result;
+ this.updateExtractButtonState();
+ }
+ });
+
+ document.getElementById('extract-plugin-name')?.addEventListener('input', () => {
+ this.updateExtractButtonState();
+ });
+
+ document.getElementById('plugin-extract-execute-btn')?.addEventListener('click', () => {
+ this.extractPlugin();
+ });
+
+ // Plugin Diff form
+ document.getElementById('diff-bundle-a-browse-btn')?.addEventListener('click', () => {
+ document.getElementById('diff-bundle-a').click();
+ });
+
+ document.getElementById('diff-bundle-a')?.addEventListener('change', (e) => {
+ const filename = e.target.files[0]?.name || 'No file selected';
+ document.getElementById('diff-bundle-a-filename').textContent = filename;
+ this.updateDiffButtonState();
+ });
+
+ document.getElementById('diff-bundle-b-browse-btn')?.addEventListener('click', () => {
+ document.getElementById('diff-bundle-b').click();
+ });
+
+ document.getElementById('diff-bundle-b')?.addEventListener('change', (e) => {
+ const filename = e.target.files[0]?.name || 'No file selected';
+ document.getElementById('diff-bundle-b-filename').textContent = filename;
+ this.updateDiffButtonState();
+ });
+
+ document.getElementById('diff-plugin-name')?.addEventListener('input', () => {
+ this.updateDiffButtonState();
+ });
+
+ document.getElementById('plugin-diff-execute-btn')?.addEventListener('click', () => {
+ this.comparePlugins();
+ });
+
+ console.log('[PLUGIN_OPERATIONS] Event listeners setup complete');
+ }
+
+ // Button state management
+ updateInitButtonState() {
+ const projectName = document.getElementById('plugin-project-name').value.trim();
+ const headerFile = document.getElementById('plugin-header-file').files[0];
+ const selectedInterface = document.getElementById('plugin-interface-select').value;
+ const outputDir = document.getElementById('plugin-directory').value.trim();
+ const button = document.getElementById('plugin-init-execute-btn');
+
+ if (button) {
+ // Button is enabled only when all required fields are filled AND an interface is selected
+ button.disabled = !projectName || !headerFile || !selectedInterface || !outputDir;
+ }
+ }
+
+ updateExtractButtonState() {
+ const pluginName = document.getElementById('extract-plugin-name').value.trim();
+ const bundleFile = document.getElementById('extract-bundle-file').files[0];
+ const outputDir = document.getElementById('extract-output-dir').value.trim();
+ const button = document.getElementById('plugin-extract-execute-btn');
+
+ if (button) {
+ button.disabled = !pluginName || !bundleFile || !outputDir;
+ }
+ }
+
+ updateDiffButtonState() {
+ const pluginName = document.getElementById('diff-plugin-name').value.trim();
+ const bundleA = document.getElementById('diff-bundle-a').files[0];
+ const bundleB = document.getElementById('diff-bundle-b').files[0];
+ const button = document.getElementById('plugin-diff-execute-btn');
+
+ if (button) {
+ button.disabled = !pluginName || !bundleA || !bundleB;
+ }
+ }
+}
+
+module.exports = new PluginOperations();
diff --git a/electron/styles.css b/electron/styles.css
index 0faa407..04a71aa 100644
--- a/electron/styles.css
+++ b/electron/styles.css
@@ -298,6 +298,79 @@ body.dark-mode .app-title {
flex-shrink: 0;
transition: all 0.2s ease;
box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.05);
+ height: 100vh;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scroll-behavior: smooth;
+}
+
+/* Custom scrollbar for secondary sidebar */
+.secondary-sidebar::-webkit-scrollbar {
+ width: 6px;
+}
+
+.secondary-sidebar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.secondary-sidebar::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: 3px;
+ transition: background 0.2s ease;
+}
+
+.secondary-sidebar::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 0, 0, 0.2);
+}
+
+/* Responsive sidebar content spacing */
+.sidebar-content {
+ padding: 1rem;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0; /* Allow content to shrink */
+}
+
+/* Responsive button spacing based on available height */
+@media (max-height: 700px) {
+ .sidebar-nav {
+ gap: 0.5rem;
+ }
+
+ .nav-button {
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+ }
+
+ .sidebar-header {
+ margin-top: 1rem !important;
+ margin-bottom: 0.5rem;
+ }
+
+ .sidebar-header h3 {
+ font-size: 1rem;
+ }
+}
+
+@media (max-height: 600px) {
+ .sidebar-nav {
+ gap: 0.25rem;
+ }
+
+ .nav-button {
+ padding: 0.375rem 0.5rem;
+ font-size: 0.8rem;
+ }
+
+ .sidebar-header {
+ margin-top: 0.75rem !important;
+ margin-bottom: 0.25rem;
+ }
+
+ .sidebar-header h3 {
+ font-size: 0.9rem;
+ }
}
body.dark-mode .secondary-sidebar {
@@ -306,6 +379,15 @@ body.dark-mode .secondary-sidebar {
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.05);
}
+/* Dark mode scrollbar */
+body.dark-mode .secondary-sidebar::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.1);
+}
+
+body.dark-mode .secondary-sidebar::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.2);
+}
+
/* Responsive secondary sidebar */
@media (min-width: 1280px) {
.secondary-sidebar {
@@ -914,7 +996,16 @@ body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover {
background: var(--background-color);
}
-/* Individual Key Management Views */
+/* Main Content Area */
+.main-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 2rem;
+ background: var(--background-color);
+ max-height: calc(100vh - 60px); /* Fix scrolling by setting max height */
+}
+
+/* Individual Key Management Styles */
.key-management-view {
flex: 1;
overflow-y: auto;
@@ -922,6 +1013,98 @@ body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover {
background: var(--background-color);
}
+/* Plugin Management Styles */
+.plugin-management-view {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px;
+ background: var(--background-color);
+ max-height: calc(100vh - 120px); /* Fix scrolling by setting max height */
+}
+
+
+
+/* File Input Group Styling */
+.file-input-group {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ overflow: hidden;
+ background: white;
+ transition: all 0.2s ease;
+}
+
+.file-input-group:focus-within {
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.file-input-group input {
+ flex: 1;
+ border: none !important;
+ border-radius: 0 !important;
+ margin: 0;
+ box-shadow: none !important;
+ background: transparent;
+}
+
+.file-input-group input:focus {
+ box-shadow: none !important;
+ border: none !important;
+}
+
+/* Modern Browse Button Styling */
+.browse-button {
+ background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
+ color: white;
+ border: none;
+ padding: 0.75rem 1.25rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ border-radius: 0;
+ white-space: nowrap;
+ position: relative;
+ overflow: hidden;
+}
+
+.browse-button::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
+ transition: left 0.5s ease;
+}
+
+.browse-button:hover {
+ background: linear-gradient(135deg, var(--secondary-color) 0%, #1d4ed8 100%);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
+}
+
+.browse-button:hover::before {
+ left: 100%;
+}
+
+.browse-button:active {
+ transform: translateY(0);
+}
+
+/* Filename Display Styling */
+.filename-display {
+ color: var(--text-light);
+ font-size: 0.9rem;
+ font-style: italic;
+ margin-left: 0.5rem;
+ padding: 0.75rem 0;
+}
+
/* Key Management Headers */
.keys-header,
.generate-key-header,
@@ -937,6 +1120,13 @@ body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover {
gap: 1rem;
}
+/* Plugin Management Headers */
+.plugin-header {
+ margin-bottom: 2rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
.keys-header .keys-actions {
display: flex;
gap: 0.75rem;
@@ -965,7 +1155,8 @@ body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover {
/* Form Styling */
.generate-key-form,
-.add-key-form {
+.add-key-form,
+.plugin-form {
background: white;
border-radius: 12px;
padding: 2rem;
@@ -1549,6 +1740,29 @@ body.dark-mode .info-banner svg {
color: #60a5fa;
}
+/* Dark Mode Support for File Input Groups and Browse Buttons */
+body.dark-mode .file-input-group {
+ background: #1e293b;
+ border-color: #334155;
+}
+
+body.dark-mode .file-input-group input {
+ background: transparent;
+ color: #e2e8f0;
+}
+
+body.dark-mode .browse-button {
+ background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
+}
+
+body.dark-mode .browse-button:hover {
+ background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
+}
+
+body.dark-mode .filename-display {
+ color: #94a3b8;
+}
+
/* Responsive Design */
@media (max-width: 1024px) {
.key-management-view {
diff --git a/fourdst/cli/plugin/diff.py b/fourdst/cli/plugin/diff.py
index ba6441b..2db0dd6 100644
--- a/fourdst/cli/plugin/diff.py
+++ b/fourdst/cli/plugin/diff.py
@@ -1,46 +1,13 @@
# fourdst/cli/plugin/diff.py
import typer
-import yaml
-import zipfile
from pathlib import Path
-import tempfile
-import shutil
-import difflib
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
-console = Console()
+from fourdst.core.plugin import compare_plugin_sources
-def _extract_sdist(bundle_path: Path, plugin_name: str, temp_dir: Path):
- """Extracts a specific plugin's sdist from a bundle to a directory."""
- sdist_extract_path = temp_dir / f"{plugin_name}_src"
-
- with tempfile.TemporaryDirectory() as bundle_unpack_dir_str:
- bundle_unpack_dir = Path(bundle_unpack_dir_str)
-
- with zipfile.ZipFile(bundle_path, 'r') as bundle_zip:
- bundle_zip.extractall(bundle_unpack_dir)
-
- manifest_path = bundle_unpack_dir / "manifest.yaml"
- if not manifest_path.exists():
- raise FileNotFoundError("manifest.yaml not found in bundle.")
-
- with open(manifest_path, 'r') as f:
- manifest = yaml.safe_load(f)
-
- plugin_data = manifest.get('bundlePlugins', {}).get(plugin_name)
- if not plugin_data or 'sdist' not in plugin_data:
- raise FileNotFoundError(f"Plugin '{plugin_name}' or its sdist not found in {bundle_path.name}.")
-
- sdist_path_in_bundle = bundle_unpack_dir / plugin_data['sdist']['path']
- if not sdist_path_in_bundle.exists():
- raise FileNotFoundError(f"sdist archive '{plugin_data['sdist']['path']}' not found in bundle.")
-
- with zipfile.ZipFile(sdist_path_in_bundle, 'r') as sdist_zip:
- sdist_zip.extractall(sdist_extract_path)
-
- return sdist_extract_path
+console = Console()
def plugin_diff(
plugin_name: str = typer.Argument(..., help="The name of the plugin to compare."),
@@ -52,54 +19,41 @@ def plugin_diff(
"""
console.print(Panel(f"Comparing source for plugin [bold blue]{plugin_name}[/bold blue] between bundles"))
- with tempfile.TemporaryDirectory() as temp_a_str, tempfile.TemporaryDirectory() as temp_b_str:
- try:
- src_a_path = _extract_sdist(bundle_a_path, plugin_name, Path(temp_a_str))
- src_b_path = _extract_sdist(bundle_b_path, plugin_name, Path(temp_b_str))
- except FileNotFoundError as e:
- console.print(f"[red]Error: {e}[/red]")
- raise typer.Exit(code=1)
+ # Compare using core function
+ compare_result = compare_plugin_sources(bundle_a_path, bundle_b_path, plugin_name)
+ if not compare_result['success']:
+ console.print(f"[red]Error: {compare_result['error']}[/red]")
+ raise typer.Exit(code=1)
- files_a = {p.relative_to(src_a_path) for p in src_a_path.rglob('*') if p.is_file()}
- files_b = {p.relative_to(src_b_path) for p in src_b_path.rglob('*') if p.is_file()}
+ # Display results
+ compare_data = compare_result['data']
+ has_changes = compare_data['has_changes']
+ added_files = compare_data['added_files']
+ removed_files = compare_data['removed_files']
+ modified_files = compare_data['modified_files']
- added_files = files_b - files_a
- removed_files = files_a - files_b
- common_files = files_a & files_b
+ if added_files:
+ console.print(Panel("\n".join(f"[green]+ {f}[/green]" for f in added_files), title="[bold]Added Files[/bold]"))
+
+ if removed_files:
+ console.print(Panel("\n".join(f"[red]- {f}[/red]" for f in removed_files), title="[bold]Removed Files[/bold]"))
+
+ for modified_file in modified_files:
+ file_path = modified_file['file_path']
+ diff_content = modified_file['diff']
- has_changes = False
-
- if added_files:
- has_changes = True
- console.print(Panel("\n".join(f"[green]+ {f}[/green]" for f in sorted(list(added_files))), title="[bold]Added Files[/bold]"))
+ diff_text = Text()
+ for line in diff_content.splitlines(keepends=True):
+ if line.startswith('+'):
+ diff_text.append(line, style="green")
+ elif line.startswith('-'):
+ diff_text.append(line, style="red")
+ else:
+ diff_text.append(line)
- if removed_files:
- has_changes = True
- console.print(Panel("\n".join(f"[red]- {f}[/red]" for f in sorted(list(removed_files))), title="[bold]Removed Files[/bold]"))
+ console.print(Panel(diff_text, title=f"[bold yellow]Modified: {file_path}[/bold yellow]", border_style="yellow", expand=False))
- modified_files_count = 0
- for file_rel_path in sorted(list(common_files)):
- content_a = (src_a_path / file_rel_path).read_text()
- content_b = (src_b_path / file_rel_path).read_text()
-
- if content_a != content_b:
- has_changes = True
- modified_files_count += 1
- diff = difflib.unified_diff(
- content_a.splitlines(keepends=True),
- content_b.splitlines(keepends=True),
- fromfile=f"a/{file_rel_path}",
- tofile=f"b/{file_rel_path}",
- )
- diff_text = Text()
- for line in diff:
- if line.startswith('+'): diff_text.append(line, style="green")
- elif line.startswith('-'): diff_text.append(line, style="red")
- else: diff_text.append(line)
-
- console.print(Panel(diff_text, title=f"[bold yellow]Modified: {file_rel_path}[/bold yellow]", border_style="yellow", expand=False))
-
- if not has_changes:
- console.print(Panel("[green]No source code changes detected for this plugin.[/green]", title="Result"))
- else:
- console.print(f"\nFound changes in {modified_files_count} file(s).")
+ if not has_changes:
+ console.print(Panel("[green]No source code changes detected for this plugin.[/green]", title="Result"))
+ else:
+ console.print(f"\nFound changes in {len(modified_files)} file(s).")
diff --git a/fourdst/cli/plugin/extract.py b/fourdst/cli/plugin/extract.py
index caade19..a34b954 100644
--- a/fourdst/cli/plugin/extract.py
+++ b/fourdst/cli/plugin/extract.py
@@ -1,10 +1,8 @@
# fourdst/cli/plugin/extract.py
import typer
-import yaml
-import zipfile
from pathlib import Path
-import tempfile
-import shutil
+
+from fourdst.core.plugin import extract_plugin_from_bundle
def plugin_extract(
plugin_name: str = typer.Argument(..., help="The name of the plugin to extract."),
@@ -22,61 +20,20 @@ def plugin_extract(
"""
Extracts a plugin's source code from a bundle.
"""
- output_dir.mkdir(parents=True, exist_ok=True)
+ typer.echo(f"Opening bundle: {bundle_path.name}")
- try:
- with tempfile.TemporaryDirectory() as temp_dir_str:
- temp_dir = Path(temp_dir_str)
-
- # 1. Unpack the main bundle
- typer.echo(f"Opening bundle: {bundle_path.name}")
- with zipfile.ZipFile(bundle_path, 'r') as bundle_zip:
- bundle_zip.extractall(temp_dir)
-
- # 2. Read the manifest
- manifest_path = temp_dir / "manifest.yaml"
- if not manifest_path.exists():
- typer.secho("Error: Bundle is invalid. Missing manifest.yaml.", fg=typer.colors.RED)
- raise typer.Exit(code=1)
-
- with open(manifest_path, 'r') as f:
- manifest = yaml.safe_load(f)
-
- # 3. Find the plugin and its sdist
- plugin_data = manifest.get('bundlePlugins', {}).get(plugin_name)
- if not plugin_data:
- typer.secho(f"Error: Plugin '{plugin_name}' not found in the bundle.", fg=typer.colors.RED)
- available_plugins = list(manifest.get('bundlePlugins', {}).keys())
- if available_plugins:
- typer.echo("Available plugins are: " + ", ".join(available_plugins))
- raise typer.Exit(code=1)
-
- sdist_info = plugin_data.get('sdist')
- if not sdist_info or 'path' not in sdist_info:
- typer.secho(f"Error: Source distribution (sdist) not found for plugin '{plugin_name}'.", fg=typer.colors.RED)
- raise typer.Exit(code=1)
-
- sdist_path_in_bundle = temp_dir / sdist_info['path']
- if not sdist_path_in_bundle.is_file():
- typer.secho(f"Error: sdist file '{sdist_info['path']}' is missing from the bundle archive.", fg=typer.colors.RED)
- raise typer.Exit(code=1)
-
- # 4. Extract the sdist to the final output directory
- final_destination = output_dir / plugin_name
- if final_destination.exists():
- typer.secho(f"Warning: Output directory '{final_destination}' already exists. Files may be overwritten.", fg=typer.colors.YELLOW)
- else:
- final_destination.mkdir(parents=True)
-
- typer.echo(f"Extracting '{plugin_name}' source to '{final_destination.resolve()}'...")
- with zipfile.ZipFile(sdist_path_in_bundle, 'r') as sdist_zip:
- sdist_zip.extractall(final_destination)
-
- typer.secho(f"\n✅ Plugin '{plugin_name}' extracted successfully.", fg=typer.colors.GREEN)
-
- except zipfile.BadZipFile:
- typer.secho(f"Error: '{bundle_path}' is not a valid bundle (zip) file.", fg=typer.colors.RED)
- raise typer.Exit(code=1)
- except Exception as e:
- typer.secho(f"An unexpected error occurred: {e}", fg=typer.colors.RED)
+ # Extract using core function
+ extract_result = extract_plugin_from_bundle(bundle_path, plugin_name, output_dir)
+ if not extract_result['success']:
+ typer.secho(f"Error: {extract_result['error']}", fg=typer.colors.RED)
raise typer.Exit(code=1)
+
+ # Display results
+ extract_data = extract_result['data']
+ final_destination = Path(extract_data['output_path'])
+
+ if final_destination.exists():
+ typer.secho(f"Warning: Output directory '{final_destination}' already existed. Files may have been overwritten.", fg=typer.colors.YELLOW)
+
+ typer.echo(f"Extracting '{plugin_name}' source to '{final_destination}'...")
+ typer.secho(f"\n✅ Plugin '{plugin_name}' extracted successfully.", fg=typer.colors.GREEN)
diff --git a/fourdst/cli/plugin/init.py b/fourdst/cli/plugin/init.py
index 8154d05..500c9c4 100644
--- a/fourdst/cli/plugin/init.py
+++ b/fourdst/cli/plugin/init.py
@@ -2,12 +2,10 @@
import typer
import sys
-import shutil
from pathlib import Path
import questionary
-from fourdst.cli.common.utils import run_command, get_template_content
-from fourdst.cli.common.templates import GITIGNORE_CONTENT
+from fourdst.core.plugin import parse_cpp_interface, generate_plugin_project
plugin_app = typer.Typer()
@@ -23,13 +21,25 @@ def plugin_init(
Initializes a new Meson-based C++ plugin project from an interface header.
"""
print(f"Parsing interface header: {header.name}")
- interfaces = parse_cpp_header(header)
-
+
+ # Parse the C++ header using core function
+ parse_result = parse_cpp_interface(header)
+ if not parse_result['success']:
+ print(f"Error: {parse_result['error']}", file=sys.stderr)
+ raise typer.Exit(code=1)
+
+ interfaces = parse_result['data']
if not interfaces:
print(f"Error: No suitable interfaces (classes with pure virtual methods) found in {header}", file=sys.stderr)
raise typer.Exit(code=1)
- # --- Interactive Selection ---
+ # Display found interfaces
+ for interface_name, methods in interfaces.items():
+ print(f"Found interface: '{interface_name}'")
+ for method in methods:
+ print(f" -> Found pure virtual method: {method['signature']}")
+
+ # Interactive Selection
chosen_interface = questionary.select(
"Which interface would you like to implement?",
choices=list(interfaces.keys())
@@ -40,152 +50,29 @@ def plugin_init(
print(f"Initializing plugin '{project_name}' implementing interface '{chosen_interface}'...")
- # --- Code Generation ---
- method_stubs = "\n".join(
- f" {method['signature']} override {{\n{method['body']}\n }}"
- for method in interfaces[chosen_interface]
- )
-
- class_name = ''.join(filter(str.isalnum, project_name.replace('_', ' ').title().replace(' ', ''))) + "Plugin"
- root_path = directory / project_name
- src_path = root_path / "src"
- include_path = src_path / "include"
- subprojects_path = root_path / "subprojects"
+ # Generate the project using core function
+ config = {
+ 'project_name': project_name,
+ 'header_path': header,
+ 'directory': directory,
+ 'version': version,
+ 'libplugin_rev': libplugin_rev,
+ 'chosen_interface': chosen_interface,
+ 'interfaces': interfaces
+ }
- try:
- src_path.mkdir(parents=True, exist_ok=True)
- include_path.mkdir(exist_ok=True)
- subprojects_path.mkdir(exist_ok=True)
-
- # --- Copy interface header to make project self-contained ---
- local_header_path = include_path / header.name
- shutil.copy(header, local_header_path)
- print(f" -> Copied interface header to {local_header_path.relative_to(root_path)}")
-
- # --- Create libplugin.wrap file ---
- libplugin_wrap_content = f"""[wrap-git]
-url = https://github.com/4D-STAR/libplugin
-revision = {libplugin_rev}
-depth = 1
-"""
- (subprojects_path / "libplugin.wrap").write_text(libplugin_wrap_content)
- print(f" -> Created {subprojects_path / 'libplugin.wrap'}")
-
- # --- Create meson.build from template ---
- meson_template = get_template_content("meson.build.in")
- meson_content = meson_template.format(
- project_name=project_name,
- version=version
- )
- (root_path / "meson.build").write_text(meson_content)
- print(f" -> Created {root_path / 'meson.build'}")
-
- # --- Create C++ source file from template ---
- cpp_template = get_template_content("plugin.cpp.in")
- cpp_content = cpp_template.format(
- class_name=class_name,
- project_name=project_name,
- interface=chosen_interface,
- interface_header_path=header.name, # Use just the filename
- method_stubs=method_stubs
- )
- (src_path / f"{project_name}.cpp").write_text(cpp_content)
- print(f" -> Created {src_path / f'{project_name}.cpp'}")
-
- # --- Create .gitignore ---
- (root_path / ".gitignore").write_text(GITIGNORE_CONTENT)
- print(f" -> Created .gitignore")
-
- # --- Initialize Git Repository ---
- print(" -> Initializing Git repository...")
- run_command(["git", "init"], cwd=root_path)
- run_command(["git", "add", "."], cwd=root_path)
- commit_message = f"Initial commit: Scaffold fourdst plugin '{project_name}'"
- run_command(["git", "commit", "-m", commit_message], cwd=root_path)
-
-
- except OSError as e:
- print(f"Error creating project structure: {e}", file=sys.stderr)
+ generation_result = generate_plugin_project(config)
+ if not generation_result['success']:
+ print(f"Error creating project structure: {generation_result['error']}", file=sys.stderr)
raise typer.Exit(code=1)
+ # Display results
+ project_data = generation_result['data']
+ for file_path in project_data['files_created']:
+ print(f" -> Created {file_path}")
+
print("\n✅ Project initialized successfully and committed to Git!")
print("To build your new plugin:")
- print(f" cd {root_path}")
+ print(f" cd {project_data['project_path']}")
print(" meson setup builddir")
print(" meson compile -C builddir")
-
-def parse_cpp_header(header_path: Path):
- """
- Parses a C++ header file using libclang to find classes and their pure virtual methods.
- """
- # This function requires python-clang-16
- try:
- from clang import cindex
- except ImportError:
- print("Error: The 'init' command requires 'libclang'. Please install it.", file=sys.stderr)
- print("Run: pip install python-clang-16", file=sys.stderr)
- # Also ensure the libclang.so/dylib is in your system's library path.
- raise typer.Exit(code=1)
-
- if not cindex.Config.loaded:
- try:
- # Attempt to find libclang automatically. This may need to be configured by the user.
- # On systems like macOS, you might need to point to the specific version, e.g.:
- # cindex.Config.set_library_path('/opt/homebrew/opt/llvm/lib')
- cindex.Config.set_library_file(cindex.conf.get_filename())
- except cindex.LibclangError as e:
- print(f"Error: libclang library not found. Please ensure it's installed and in your system's path.", file=sys.stderr)
- print(f"Details: {e}", file=sys.stderr)
- raise typer.Exit(code=1)
-
- index = cindex.Index.create()
- # Pass standard C++ arguments to the parser. This improves reliability.
- args = ['-x', 'c++', '-std=c++17']
- translation_unit = index.parse(str(header_path), args=args)
-
- if not translation_unit:
- print(f"Error: Unable to parse the translation unit {header_path}", file=sys.stderr)
- raise typer.Exit(code=1)
-
- interfaces = {}
-
- # --- Recursive function to walk the AST ---
- def walk_ast(node):
- # We are looking for class definitions, not just declarations.
- if node.kind == cindex.CursorKind.CLASS_DECL and node.is_definition():
- # Collect pure virtual methods within this class
- pv_methods = [m for m in node.get_children()
- if m.kind == cindex.CursorKind.CXX_METHOD and m.is_pure_virtual_method()]
-
- # If it has pure virtual methods, it's an interface we care about
- if pv_methods:
- interface_name = node.spelling
- methods = []
- print(f"Found interface: '{interface_name}'")
- for method in pv_methods:
- # Get the string representation of all argument types
- args_str = ', '.join([arg.type.spelling for arg in method.get_arguments()])
-
- # Reconstruct the signature from its parts. This is much more reliable.
- sig = f"{method.result_type.spelling} {method.spelling}({args_str})"
-
- # Append 'const' if the method is a const method
- if method.is_const_method():
- sig += " const"
-
- methods.append({"signature": sig, "body": " // TODO: Implement this method"})
- print(f" -> Found pure virtual method: {sig}")
-
- interfaces[interface_name] = methods
-
- interfaces[interface_name] = methods
-
- # --- The recursive step ---
- # Recurse for children of this node
- for child in node.get_children():
- walk_ast(child)
-
- # Start the traversal from the root of the AST
- walk_ast(translation_unit.cursor)
-
- return interfaces
\ No newline at end of file
diff --git a/fourdst/cli/plugin/pack.py b/fourdst/cli/plugin/pack.py
index d0ad6a0..4316191 100644
--- a/fourdst/cli/plugin/pack.py
+++ b/fourdst/cli/plugin/pack.py
@@ -1,52 +1,8 @@
# fourdst/cli/plugin/pack.py
import typer
-import sys
-import yaml
-import zipfile
from pathlib import Path
-from fourdst.cli.common.utils import calculate_sha256
-
-def _validate_bundle_directory(directory: Path) -> list[str]:
- """
- Validates that a directory has the structure of a valid bundle.
- Returns a list of error strings. An empty list means success.
- """
- errors = []
- manifest_path = directory / "manifest.yaml"
-
- if not manifest_path.is_file():
- return ["Error: Missing 'manifest.yaml' in the root of the directory."]
-
- try:
- with open(manifest_path, 'r') as f:
- manifest = yaml.safe_load(f)
- except yaml.YAMLError as e:
- return [f"Error: Invalid YAML in manifest.yaml: {e}"]
-
- # 1. Check that all files referenced in the manifest exist
- for plugin_name, plugin_data in manifest.get('bundlePlugins', {}).items():
- sdist_info = plugin_data.get('sdist', {})
- if sdist_info:
- sdist_path = sdist_info.get('path')
- if sdist_path and not (directory / sdist_path).is_file():
- errors.append(f"Missing sdist file for '{plugin_name}': {sdist_path}")
-
- for binary in plugin_data.get('binaries', []):
- binary_path = binary.get('path')
- if binary_path and not (directory / binary_path).is_file():
- errors.append(f"Missing binary file for '{plugin_name}': {binary_path}")
-
- # 2. If checksums exist, validate them
- expected_checksum = binary.get('checksum')
- if binary_path and expected_checksum:
- file_to_check = directory / binary_path
- if file_to_check.is_file():
- actual_checksum = "sha256:" + calculate_sha256(file_to_check)
- if actual_checksum != expected_checksum:
- errors.append(f"Checksum mismatch for '{binary_path}'")
-
- return errors
+from fourdst.core.plugin import validate_bundle_directory, pack_bundle_directory
def plugin_pack(
@@ -58,8 +14,13 @@ def plugin_pack(
"""
typer.echo(f"--- Validating Bundle Directory: {folder_path.resolve()} ---")
- validation_errors = _validate_bundle_directory(folder_path)
+ # Validate using core function
+ validation_result = validate_bundle_directory(folder_path)
+ if not validation_result['success']:
+ typer.secho(f"Error during validation: {validation_result['error']}", fg=typer.colors.RED)
+ raise typer.Exit(code=1)
+ validation_errors = validation_result['data']['errors']
if validation_errors:
typer.secho("Validation Failed. The following issues were found:", fg=typer.colors.RED, bold=True)
for error in validation_errors:
@@ -70,31 +31,27 @@ def plugin_pack(
typer.echo("\n--- Packing Bundle ---")
output_name = name if name else folder_path.name
- output_path = folder_path.parent / f"{output_name}.fbundle"
+ if folder_path.parent.exists():
+ typer.secho(f"Warning: Output file {folder_path.parent / f'{output_name}.fbundle'} will be created/overwritten.", fg=typer.colors.YELLOW)
- if output_path.exists():
- typer.secho(f"Warning: Output file {output_path} already exists and will be overwritten.", fg=typer.colors.YELLOW)
-
- try:
- with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as bundle_zip:
- for file_to_add in folder_path.rglob('*'):
- if file_to_add.is_file():
- arcname = file_to_add.relative_to(folder_path)
- bundle_zip.write(file_to_add, arcname)
- typer.echo(f" Adding: {arcname}")
-
- typer.secho(f"\n✅ Successfully created bundle: {output_path.resolve()}", fg=typer.colors.GREEN, bold=True)
-
- # Final status report
- with open(folder_path / "manifest.yaml", 'r') as f:
- manifest = yaml.safe_load(f)
-
- is_signed = 'bundleAuthorKeyFingerprint' in manifest and (folder_path / "manifest.sig").exists()
- if is_signed:
- typer.secho("Bundle Status: ✅ SIGNED", fg=typer.colors.GREEN)
- else:
- typer.secho("Bundle Status: 🟡 UNSIGNED", fg=typer.colors.YELLOW)
-
- except Exception as e:
- typer.secho(f"An unexpected error occurred during packing: {e}", fg=typer.colors.RED)
+ # Pack using core function
+ output_config = {
+ 'name': output_name,
+ 'output_dir': folder_path.parent
+ }
+
+ pack_result = pack_bundle_directory(folder_path, output_config)
+ if not pack_result['success']:
+ typer.secho(f"An unexpected error occurred during packing: {pack_result['error']}", fg=typer.colors.RED)
raise typer.Exit(code=1)
+
+ # Display results
+ pack_data = pack_result['data']
+ typer.echo(f" Added {pack_data['files_packed']} files to bundle")
+ typer.secho(f"\n✅ Successfully created bundle: {pack_data['output_path']}", fg=typer.colors.GREEN, bold=True)
+
+ # Final status report
+ if pack_data['is_signed']:
+ typer.secho("Bundle Status: ✅ SIGNED", fg=typer.colors.GREEN)
+ else:
+ typer.secho("Bundle Status: 🟡 UNSIGNED", fg=typer.colors.YELLOW)
diff --git a/fourdst/cli/plugin/validate.py b/fourdst/cli/plugin/validate.py
index 1e1c430..fea2273 100644
--- a/fourdst/cli/plugin/validate.py
+++ b/fourdst/cli/plugin/validate.py
@@ -5,6 +5,8 @@ from rich.console import Console
from rich.panel import Panel
from rich.text import Text
+from fourdst.core.plugin import validate_plugin_project
+
console = Console()
def plugin_validate(
@@ -22,52 +24,39 @@ def plugin_validate(
"""
console.print(Panel(f"Validating Plugin: [bold]{plugin_path.name}[/bold]", border_style="blue"))
- errors = 0
- warnings = 0
+ # Validate using core function
+ validate_result = validate_plugin_project(plugin_path)
+ if not validate_result['success']:
+ console.print(f"[red]Error during validation: {validate_result['error']}[/red]")
+ raise typer.Exit(code=1)
- def check(condition, success_msg, error_msg, is_warning=False):
- nonlocal errors, warnings
- if condition:
- console.print(Text(f"✅ {success_msg}", style="green"))
- return True
+ # Display results
+ validate_data = validate_result['data']
+ errors = validate_data['errors']
+ warnings = validate_data['warnings']
+ checks = validate_data['checks']
+
+ # Display each check result
+ for check in checks:
+ if check['passed']:
+ console.print(Text(f"✅ {check['message']}", style="green"))
else:
- if is_warning:
- console.print(Text(f"⚠️ {error_msg}", style="yellow"))
- warnings += 1
+ if check['is_warning']:
+ console.print(Text(f"⚠️ {check['message']}", style="yellow"))
else:
- console.print(Text(f"❌ {error_msg}", style="red"))
- errors += 1
- return False
-
- # 1. Check for meson.build
- meson_file = plugin_path / "meson.build"
- if check(meson_file.exists(), "Found meson.build file.", "Missing meson.build file."):
- meson_content = meson_file.read_text()
- # 2. Check for project() definition
- check("project(" in meson_content, "Contains project() definition.", "meson.build is missing a project() definition.", is_warning=True)
- # 3. Check for shared_library()
- check("shared_library(" in meson_content, "Contains shared_library() definition.", "meson.build does not appear to define a shared_library().")
-
- # 4. Check for source files
- has_cpp = any(plugin_path.rglob("*.cpp"))
- has_h = any(plugin_path.rglob("*.h")) or any(plugin_path.rglob("*.hpp"))
- check(has_cpp, "Found C++ source files (.cpp).", "No .cpp source files found in the directory.", is_warning=True)
- check(has_h, "Found C++ header files (.h/.hpp).", "No .h or .hpp header files found in the directory.", is_warning=True)
-
- # 5. Check for test definition (optional)
- check("test(" in meson_content, "Contains test() definitions.", "No test() definitions found in meson.build. Consider adding tests.", is_warning=True)
+ console.print(Text(f"❌ {check['message']}", style="red"))
# Final summary
console.print("-" * 40)
- if errors == 0:
+ if not errors:
console.print(Panel(
- f"[bold green]Validation Passed[/bold green]\nWarnings: {warnings}",
+ f"[bold green]Validation Passed[/bold green]\nWarnings: {len(warnings)}",
title="Result",
border_style="green"
))
else:
console.print(Panel(
- f"[bold red]Validation Failed[/bold red]\nErrors: {errors}\nWarnings: {warnings}",
+ f"[bold red]Validation Failed[/bold red]\nErrors: {len(errors)}\nWarnings: {len(warnings)}",
title="Result",
border_style="red"
))
diff --git a/fourdst/core/plugin.py b/fourdst/core/plugin.py
new file mode 100644
index 0000000..44cfe80
--- /dev/null
+++ b/fourdst/core/plugin.py
@@ -0,0 +1,649 @@
+# fourdst/core/plugin.py
+
+import yaml
+import zipfile
+import shutil
+import tempfile
+import difflib
+from pathlib import Path
+from typing import Dict, List, Any, Optional, Tuple
+import logging
+
+from fourdst.cli.common.utils import calculate_sha256, run_command, get_template_content
+from fourdst.cli.common.templates import GITIGNORE_CONTENT
+
+
+def parse_cpp_interface(header_path: Path) -> Dict[str, Any]:
+ """
+ Parses a C++ header file using libclang to find classes and their pure virtual methods.
+
+ Returns:
+ Dict with structure:
+ {
+ "success": bool,
+ "data": {
+ "interface_name": [
+ {"signature": str, "body": str},
+ ...
+ ]
+ },
+ "error": str (if success=False)
+ }
+ """
+ try:
+ # Import libclang
+ try:
+ from clang import cindex
+ except ImportError:
+ return {
+ 'success': False,
+ 'error': "The 'init' command requires 'libclang'. Please install it with: pip install python-clang-16"
+ }
+
+ if not cindex.Config.loaded:
+ try:
+ cindex.Config.set_library_file(cindex.conf.get_filename())
+ except cindex.LibclangError as e:
+ return {
+ 'success': False,
+ 'error': f"libclang library not found. Please ensure it's installed and in your system's path. Details: {e}"
+ }
+
+ index = cindex.Index.create()
+ args = ['-x', 'c++', '-std=c++17']
+ translation_unit = index.parse(str(header_path), args=args)
+
+ if not translation_unit:
+ return {
+ 'success': False,
+ 'error': f"Unable to parse the translation unit {header_path}"
+ }
+
+ interfaces = {}
+
+ def walk_ast(node):
+ if node.kind == cindex.CursorKind.CLASS_DECL and node.is_definition():
+ pv_methods = [m for m in node.get_children()
+ if m.kind == cindex.CursorKind.CXX_METHOD and m.is_pure_virtual_method()]
+
+ if pv_methods:
+ interface_name = node.spelling
+ methods = []
+ for method in pv_methods:
+ args_str = ', '.join([arg.type.spelling for arg in method.get_arguments()])
+ sig = f"{method.result_type.spelling} {method.spelling}({args_str})"
+
+ if method.is_const_method():
+ sig += " const"
+
+ methods.append({
+ "signature": sig,
+ "body": " // TODO: Implement this method"
+ })
+
+ interfaces[interface_name] = methods
+
+ for child in node.get_children():
+ walk_ast(child)
+
+ walk_ast(translation_unit.cursor)
+
+ return {
+ 'success': True,
+ 'data': interfaces
+ }
+
+ except Exception as e:
+ logging.exception(f"Unexpected error parsing C++ header {header_path}")
+ return {
+ 'success': False,
+ 'error': f"Unexpected error: {str(e)}"
+ }
+
+
+def generate_plugin_project(config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Generates a new plugin project from configuration.
+
+ Args:
+ config: {
+ "project_name": str,
+ "header_path": Path,
+ "directory": Path,
+ "version": str,
+ "libplugin_rev": str,
+ "chosen_interface": str,
+ "interfaces": dict # from parse_cpp_interface
+ }
+
+ Returns:
+ Dict with structure:
+ {
+ "success": bool,
+ "data": {
+ "project_path": str,
+ "files_created": [str, ...]
+ },
+ "error": str (if success=False)
+ }
+ """
+ try:
+ project_name = config['project_name']
+ header_path = Path(config['header_path']) # Convert string to Path object
+ directory = Path(config['directory']) # Convert string to Path object
+ version = config['version']
+ libplugin_rev = config['libplugin_rev']
+ chosen_interface = config['chosen_interface']
+ interfaces = config['interfaces']
+
+ # Generate method stubs
+ method_stubs = "\n".join(
+ f" {method['signature']} override {{\n{method['body']}\n }}"
+ for method in interfaces[chosen_interface]
+ )
+
+ class_name = ''.join(filter(str.isalnum, project_name.replace('_', ' ').title().replace(' ', ''))) + "Plugin"
+ root_path = directory / project_name
+ src_path = root_path / "src"
+ include_path = src_path / "include"
+ subprojects_path = root_path / "subprojects"
+
+ files_created = []
+
+ # Create directory structure
+ src_path.mkdir(parents=True, exist_ok=True)
+ include_path.mkdir(exist_ok=True)
+ subprojects_path.mkdir(exist_ok=True)
+
+ # Copy interface header
+ local_header_path = include_path / header_path.name
+ shutil.copy(header_path, local_header_path)
+ files_created.append(str(local_header_path.relative_to(root_path)))
+
+ # Create libplugin.wrap file
+ libplugin_wrap_content = f"""[wrap-git]
+url = https://github.com/4D-STAR/libplugin
+revision = {libplugin_rev}
+depth = 1
+"""
+ wrap_file = subprojects_path / "libplugin.wrap"
+ wrap_file.write_text(libplugin_wrap_content)
+ files_created.append(str(wrap_file.relative_to(root_path)))
+
+ # Create meson.build from template
+ meson_template = get_template_content("meson.build.in")
+ meson_content = meson_template.format(
+ project_name=project_name,
+ version=version
+ )
+ meson_file = root_path / "meson.build"
+ meson_file.write_text(meson_content)
+ files_created.append(str(meson_file.relative_to(root_path)))
+
+ # Create C++ source file from template
+ cpp_template = get_template_content("plugin.cpp.in")
+ cpp_content = cpp_template.format(
+ class_name=class_name,
+ project_name=project_name,
+ interface=chosen_interface,
+ interface_header_path=header_path.name,
+ method_stubs=method_stubs
+ )
+ cpp_file = src_path / f"{project_name}.cpp"
+ cpp_file.write_text(cpp_content)
+ files_created.append(str(cpp_file.relative_to(root_path)))
+
+ # Create .gitignore
+ gitignore_file = root_path / ".gitignore"
+ gitignore_file.write_text(GITIGNORE_CONTENT)
+ files_created.append(str(gitignore_file.relative_to(root_path)))
+
+ # Initialize Git Repository
+ run_command(["git", "init"], cwd=root_path)
+ run_command(["git", "add", "."], cwd=root_path)
+ commit_message = f"Initial commit: Scaffold fourdst plugin '{project_name}'"
+ run_command(["git", "commit", "-m", commit_message], cwd=root_path)
+
+ return {
+ 'success': True,
+ 'data': {
+ 'project_path': str(root_path),
+ 'files_created': files_created
+ }
+ }
+
+ except Exception as e:
+ logging.exception(f"Unexpected error generating plugin project")
+ return {
+ 'success': False,
+ 'error': f"Unexpected error: {str(e)}"
+ }
+
+
+def validate_bundle_directory(directory: Path) -> Dict[str, Any]:
+ """
+ Validates that a directory has the structure of a valid bundle.
+
+ Returns:
+ Dict with structure:
+ {
+ "success": bool,
+ "data": {
+ "errors": [str, ...],
+ "is_signed": bool
+ },
+ "error": str (if success=False)
+ }
+ """
+ try:
+ errors = []
+ manifest_path = directory / "manifest.yaml"
+
+ if not manifest_path.is_file():
+ errors.append("Missing 'manifest.yaml' in the root of the directory.")
+ return {
+ 'success': True,
+ 'data': {
+ 'errors': errors,
+ 'is_signed': False
+ }
+ }
+
+ try:
+ with open(manifest_path, 'r') as f:
+ manifest = yaml.safe_load(f)
+ except yaml.YAMLError as e:
+ errors.append(f"Invalid YAML in manifest.yaml: {e}")
+ return {
+ 'success': True,
+ 'data': {
+ 'errors': errors,
+ 'is_signed': False
+ }
+ }
+
+ # Check that all files referenced in the manifest exist
+ for plugin_name, plugin_data in manifest.get('bundlePlugins', {}).items():
+ sdist_info = plugin_data.get('sdist', {})
+ if sdist_info:
+ sdist_path = sdist_info.get('path')
+ if sdist_path and not (directory / sdist_path).is_file():
+ errors.append(f"Missing sdist file for '{plugin_name}': {sdist_path}")
+
+ for binary in plugin_data.get('binaries', []):
+ binary_path = binary.get('path')
+ if binary_path and not (directory / binary_path).is_file():
+ errors.append(f"Missing binary file for '{plugin_name}': {binary_path}")
+
+ # If checksums exist, validate them
+ expected_checksum = binary.get('checksum')
+ if binary_path and expected_checksum:
+ file_to_check = directory / binary_path
+ if file_to_check.is_file():
+ actual_checksum = "sha256:" + calculate_sha256(file_to_check)
+ if actual_checksum != expected_checksum:
+ errors.append(f"Checksum mismatch for '{binary_path}'")
+
+ # Check if bundle is signed
+ is_signed = ('bundleAuthorKeyFingerprint' in manifest and
+ (directory / "manifest.sig").exists())
+
+ return {
+ 'success': True,
+ 'data': {
+ 'errors': errors,
+ 'is_signed': is_signed
+ }
+ }
+
+ except Exception as e:
+ logging.exception(f"Unexpected error validating bundle directory {directory}")
+ return {
+ 'success': False,
+ 'error': f"Unexpected error: {str(e)}"
+ }
+
+
+def pack_bundle_directory(directory: Path, output_config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Packs a directory into a .fbundle archive.
+
+ Args:
+ directory: Path to directory to pack
+ output_config: {
+ "name": str (optional, defaults to directory name),
+ "output_dir": Path (optional, defaults to directory.parent)
+ }
+
+ Returns:
+ Dict with structure:
+ {
+ "success": bool,
+ "data": {
+ "output_path": str,
+ "is_signed": bool,
+ "files_packed": int
+ },
+ "error": str (if success=False)
+ }
+ """
+ try:
+ # First validate the directory
+ validation_result = validate_bundle_directory(directory)
+ if not validation_result['success']:
+ return validation_result
+
+ if validation_result['data']['errors']:
+ return {
+ 'success': False,
+ 'error': f"Validation failed: {'; '.join(validation_result['data']['errors'])}"
+ }
+
+ output_name = output_config.get('name', directory.name)
+ output_dir = output_config.get('output_dir', directory.parent)
+ output_path = output_dir / f"{output_name}.fbundle"
+
+ files_packed = 0
+ with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as bundle_zip:
+ for file_to_add in directory.rglob('*'):
+ if file_to_add.is_file():
+ arcname = file_to_add.relative_to(directory)
+ bundle_zip.write(file_to_add, arcname)
+ files_packed += 1
+
+ return {
+ 'success': True,
+ 'data': {
+ 'output_path': str(output_path.resolve()),
+ 'is_signed': validation_result['data']['is_signed'],
+ 'files_packed': files_packed
+ }
+ }
+
+ except Exception as e:
+ logging.exception(f"Unexpected error packing bundle directory {directory}")
+ return {
+ 'success': False,
+ 'error': f"Unexpected error: {str(e)}"
+ }
+
+
+def extract_plugin_from_bundle(bundle_path: Path, plugin_name: str, output_path: Path) -> Dict[str, Any]:
+ """
+ Extracts a plugin's source code from a bundle.
+
+ Returns:
+ Dict with structure:
+ {
+ "success": bool,
+ "data": {
+ "output_path": str,
+ "plugin_info": dict
+ },
+ "error": str (if success=False)
+ }
+ """
+ try:
+ output_path.mkdir(parents=True, exist_ok=True)
+
+ with tempfile.TemporaryDirectory() as temp_dir_str:
+ temp_dir = Path(temp_dir_str)
+
+ # Unpack the main bundle
+ with zipfile.ZipFile(bundle_path, 'r') as bundle_zip:
+ bundle_zip.extractall(temp_dir)
+
+ # Read the manifest
+ manifest_path = temp_dir / "manifest.yaml"
+ if not manifest_path.exists():
+ return {
+ 'success': False,
+ 'error': "Bundle is invalid. Missing manifest.yaml."
+ }
+
+ with open(manifest_path, 'r') as f:
+ manifest = yaml.safe_load(f)
+
+ # Find the plugin and its sdist
+ plugin_data = manifest.get('bundlePlugins', {}).get(plugin_name)
+ if not plugin_data:
+ available_plugins = list(manifest.get('bundlePlugins', {}).keys())
+ return {
+ 'success': False,
+ 'error': f"Plugin '{plugin_name}' not found in the bundle. Available plugins: {', '.join(available_plugins) if available_plugins else 'none'}"
+ }
+
+ sdist_info = plugin_data.get('sdist')
+ if not sdist_info or 'path' not in sdist_info:
+ return {
+ 'success': False,
+ 'error': f"Source distribution (sdist) not found for plugin '{plugin_name}'."
+ }
+
+ sdist_path_in_bundle = temp_dir / sdist_info['path']
+ if not sdist_path_in_bundle.is_file():
+ return {
+ 'success': False,
+ 'error': f"sdist file '{sdist_info['path']}' is missing from the bundle archive."
+ }
+
+ # Extract the sdist to the final output directory
+ final_destination = output_path / plugin_name
+ final_destination.mkdir(parents=True, exist_ok=True)
+
+ with zipfile.ZipFile(sdist_path_in_bundle, 'r') as sdist_zip:
+ sdist_zip.extractall(final_destination)
+
+ return {
+ 'success': True,
+ 'data': {
+ 'output_path': str(final_destination.resolve()),
+ 'plugin_info': plugin_data
+ }
+ }
+
+ except zipfile.BadZipFile:
+ return {
+ 'success': False,
+ 'error': f"'{bundle_path}' is not a valid bundle (zip) file."
+ }
+ except Exception as e:
+ logging.exception(f"Unexpected error extracting plugin {plugin_name} from {bundle_path}")
+ return {
+ 'success': False,
+ 'error': f"Unexpected error: {str(e)}"
+ }
+
+
+def compare_plugin_sources(bundle_a_path: Path, bundle_b_path: Path, plugin_name: str) -> Dict[str, Any]:
+ """
+ Compares the source code of a specific plugin between two different bundles.
+
+ Returns:
+ Dict with structure:
+ {
+ "success": bool,
+ "data": {
+ "has_changes": bool,
+ "added_files": [str, ...],
+ "removed_files": [str, ...],
+ "modified_files": [
+ {
+ "file_path": str,
+ "diff": str
+ },
+ ...
+ ]
+ },
+ "error": str (if success=False)
+ }
+ """
+ try:
+ def extract_sdist(bundle_path: Path, plugin_name: str, temp_dir: Path):
+ """Helper function to extract sdist from bundle."""
+ sdist_extract_path = temp_dir / f"{plugin_name}_src"
+
+ with tempfile.TemporaryDirectory() as bundle_unpack_dir_str:
+ bundle_unpack_dir = Path(bundle_unpack_dir_str)
+
+ with zipfile.ZipFile(bundle_path, 'r') as bundle_zip:
+ bundle_zip.extractall(bundle_unpack_dir)
+
+ manifest_path = bundle_unpack_dir / "manifest.yaml"
+ if not manifest_path.exists():
+ raise FileNotFoundError("manifest.yaml not found in bundle.")
+
+ with open(manifest_path, 'r') as f:
+ manifest = yaml.safe_load(f)
+
+ plugin_data = manifest.get('bundlePlugins', {}).get(plugin_name)
+ if not plugin_data or 'sdist' not in plugin_data:
+ raise FileNotFoundError(f"Plugin '{plugin_name}' or its sdist not found in {bundle_path.name}.")
+
+ sdist_path_in_bundle = bundle_unpack_dir / plugin_data['sdist']['path']
+ if not sdist_path_in_bundle.exists():
+ raise FileNotFoundError(f"sdist archive '{plugin_data['sdist']['path']}' not found in bundle.")
+
+ with zipfile.ZipFile(sdist_path_in_bundle, 'r') as sdist_zip:
+ sdist_zip.extractall(sdist_extract_path)
+
+ return sdist_extract_path
+
+ with tempfile.TemporaryDirectory() as temp_a_str, tempfile.TemporaryDirectory() as temp_b_str:
+ try:
+ src_a_path = extract_sdist(bundle_a_path, plugin_name, Path(temp_a_str))
+ src_b_path = extract_sdist(bundle_b_path, plugin_name, Path(temp_b_str))
+ except FileNotFoundError as e:
+ return {
+ 'success': False,
+ 'error': str(e)
+ }
+
+ files_a = {p.relative_to(src_a_path) for p in src_a_path.rglob('*') if p.is_file()}
+ files_b = {p.relative_to(src_b_path) for p in src_b_path.rglob('*') if p.is_file()}
+
+ added_files = list(sorted(files_b - files_a))
+ removed_files = list(sorted(files_a - files_b))
+ common_files = files_a & files_b
+
+ modified_files = []
+ for file_rel_path in sorted(list(common_files)):
+ content_a = (src_a_path / file_rel_path).read_text()
+ content_b = (src_b_path / file_rel_path).read_text()
+
+ if content_a != content_b:
+ diff = ''.join(difflib.unified_diff(
+ content_a.splitlines(keepends=True),
+ content_b.splitlines(keepends=True),
+ fromfile=f"a/{file_rel_path}",
+ tofile=f"b/{file_rel_path}",
+ ))
+ modified_files.append({
+ 'file_path': str(file_rel_path),
+ 'diff': diff
+ })
+
+ has_changes = bool(added_files or removed_files or modified_files)
+
+ return {
+ 'success': True,
+ 'data': {
+ 'has_changes': has_changes,
+ 'added_files': [str(f) for f in added_files],
+ 'removed_files': [str(f) for f in removed_files],
+ 'modified_files': modified_files
+ }
+ }
+
+ except Exception as e:
+ logging.exception(f"Unexpected error comparing plugin {plugin_name} between bundles")
+ return {
+ 'success': False,
+ 'error': f"Unexpected error: {str(e)}"
+ }
+
+
+def validate_plugin_project(project_path: Path) -> Dict[str, Any]:
+ """
+ Validates a plugin's structure and meson.build file.
+
+ Returns:
+ Dict with structure:
+ {
+ "success": bool,
+ "data": {
+ "errors": [str, ...],
+ "warnings": [str, ...],
+ "checks": [
+ {
+ "name": str,
+ "passed": bool,
+ "is_warning": bool,
+ "message": str
+ },
+ ...
+ ]
+ },
+ "error": str (if success=False)
+ }
+ """
+ try:
+ # Convert string path to Path object if needed
+ if isinstance(project_path, str):
+ project_path = Path(project_path)
+
+ errors = []
+ warnings = []
+ checks = []
+
+ def check(condition, name, success_msg, error_msg, is_warning=False):
+ passed = bool(condition)
+ checks.append({
+ 'name': name,
+ 'passed': passed,
+ 'is_warning': is_warning,
+ 'message': success_msg if passed else error_msg
+ })
+
+ if not passed:
+ if is_warning:
+ warnings.append(error_msg)
+ else:
+ errors.append(error_msg)
+
+ return passed
+
+ # Check for meson.build
+ meson_file = project_path / "meson.build"
+ meson_content = ""
+ if check(meson_file.exists(), "meson_build_exists", "Found meson.build file.", "Missing meson.build file."):
+ meson_content = meson_file.read_text()
+ # Check for project() definition
+ check("project(" in meson_content, "has_project_definition", "Contains project() definition.", "meson.build is missing a project() definition.", is_warning=True)
+ # Check for shared_library()
+ check("shared_library(" in meson_content, "has_shared_library", "Contains shared_library() definition.", "meson.build does not appear to define a shared_library().")
+
+ # Check for source files
+ has_cpp = any(project_path.rglob("*.cpp"))
+ has_h = any(project_path.rglob("*.h")) or any(project_path.rglob("*.hpp"))
+ check(has_cpp, "has_cpp_files", "Found C++ source files (.cpp).", "No .cpp source files found in the directory.", is_warning=True)
+ check(has_h, "has_header_files", "Found C++ header files (.h/.hpp).", "No .h or .hpp header files found in the directory.", is_warning=True)
+
+ # Check for test definition (optional)
+ check("test(" in meson_content, "has_tests", "Contains test() definitions.", "No test() definitions found in meson.build. Consider adding tests.", is_warning=True)
+
+ return {
+ 'success': True,
+ 'data': {
+ 'errors': errors,
+ 'warnings': warnings,
+ 'checks': checks
+ }
+ }
+
+ except Exception as e:
+ logging.exception(f"Unexpected error validating plugin project {project_path}")
+ return {
+ 'success': False,
+ 'error': f"Unexpected error: {str(e)}"
+ }
diff --git a/subprojects/libplugin.wrap b/subprojects/libplugin.wrap
index e4f7af3..6246bf3 100644
--- a/subprojects/libplugin.wrap
+++ b/subprojects/libplugin.wrap
@@ -1,4 +1,4 @@
[wrap-git]
url = https://github.com/4D-STAR/libplugin.git
-revision = v0.3.3
+revision = v0.3.4
depth = 1