""" Node.js Runtime Adapter for NebulaShell ===================================== This plugin acts as a pure service provider (Adapter). It does NOT contain its own business logic or pkg. Instead, it exposes standard interfaces for OTHER plugins to execute Node.js/npm commands within THEIR own contexts (specifically their ./pkg directories). Usage by other plugins: 1. Get this adapter from the shared service registry. 2. Call adapter.execute_in_context(plugin_root="./path/to/other-plugin", command="npm start") 3. The adapter will automatically switch CWD to "./path/to/other-plugin/pkg" and run the command. """ import os import sys import json import subprocess import shutil from typing import Dict, Any, List, Optional class NodeJSAdapter: """ Pure Node.js Runtime Adapter. Provides execution context switching for other plugins. """ def __init__(self): self.name = "nodejs-adapter" self.version = "1.0.0" self.description = "Stateless Node.js runtime adapter for cross-plugin execution" self.node_path = None self.npm_path = None self._detect_runtime() def _detect_runtime(self): """Detect global Node.js and npm installation""" try: self.node_path = shutil.which('node') self.npm_path = shutil.which('npm') if not self.node_path: print("[WARNING] Node.js not found in global PATH") if not self.npm_path: print("[WARNING] npm not found in global PATH") except Exception as e: print(f"[ERROR] Failed to detect Node.js runtime: {type(e).__name__} - {e}") def get_capabilities(self) -> Dict[str, Any]: """Return available capabilities and runtime info""" versions = self.check_versions() return { 'available': bool(self.node_path), 'npm_available': bool(self.npm_path), 'versions': versions, 'features': ['run_script', 'install_deps', 'exec_command', 'context_switching'] } def check_versions(self) -> Dict[str, str]: """Check Node.js and npm versions""" result = {} if self.node_path: try: result['node'] = subprocess.check_output([self.node_path, '--version'], stderr=subprocess.STDOUT).decode().strip() except Exception as e: result['node'] = f"Error: {type(e).__name__} - {e}" if self.npm_path: try: result['npm'] = subprocess.check_output([self.npm_path, '--version'], stderr=subprocess.STDOUT).decode().strip() except Exception as e: result['npm'] = f"Error: {type(e).__name__} - {e}" return result def execute_in_context(self, plugin_root: str, command_args: List[str], is_npm: bool = False) -> Dict[str, Any]: """ CORE METHOD: Execute a command within the context of another plugin. Args: plugin_root: The root directory of the CALLING plugin (e.g., /workspace/oss/plugins/my-web-app) command_args: The command arguments (e.g., ['start'] or ['install', 'express']) is_npm: If True, uses 'npm'. If False, uses 'node'. Behavior: 1. Targets the './pkg' subdirectory inside plugin_root. 2. Sets cwd to that pkg directory. 3. Executes the command. 4. Ensures dependencies install into that specific pkg folder. """ if not self.node_path: return {'success': False, 'error': 'Node.js runtime not found'} if is_npm and not self.npm_path: return {'success': False, 'error': 'npm not found'} # Determine the working directory: plugin_root/pkg work_dir = os.path.join(plugin_root, 'pkg') if not os.path.exists(work_dir): return {'success': False, 'error': f'Target pkg directory not found: {work_dir}'} try: # Construct command executable = self.npm_path if is_npm else self.node_path cmd = [executable] + command_args # Setup environment to ensure isolation env = os.environ.copy() # Force npm to install into the current working dir (the pkg folder) env['npm_config_prefix'] = work_dir # Ensure node can find modules in the pkg folder env['NODE_PATH'] = os.path.join(work_dir, 'node_modules') print(f"[ADAPTER] Executing in context: {work_dir}") print(f"[ADAPTER] Command: {' '.join(cmd)}") result = subprocess.run( cmd, cwd=work_dir, env=env, capture_output=True, text=True, timeout=300 # 5 min timeout for installs ) return { 'success': result.returncode == 0, 'stdout': result.stdout, 'stderr': result.stderr, 'returncode': result.returncode, 'cwd': work_dir } except subprocess.TimeoutExpired: return {'success': False, 'error': 'Command execution timeout'} except Exception as e: return {'success': False, 'error': f'{type(e).__name__} - {e}'} def install_dependencies(self, plugin_root: str, packages: List[str] = None) -> Dict[str, Any]: """ Helper: Install dependencies for a specific plugin. If packages is None, runs 'npm install' (installs from package.json). If packages is provided, runs 'npm install ...'. """ args = ['install'] if packages: args.extend(packages) return self.execute_in_context(plugin_root, args, is_npm=True) def run_script(self, plugin_root: str, script_name: str, extra_args: List[str] = None) -> Dict[str, Any]: """ Helper: Run an npm script (e.g., 'start', 'build') for a specific plugin. """ args = ['run', script_name] if extra_args: args.append('--') args.extend(extra_args) return self.execute_in_context(plugin_root, args, is_npm=True) def run_file(self, plugin_root: str, file_path: str, args: List[str] = None) -> Dict[str, Any]: """ Helper: Run a specific JS file within a plugin's pkg directory. file_path should be relative to the pkg dir (e.g., 'index.js'). """ cmd_args = [file_path] if args: cmd_args.extend(args) return self.execute_in_context(plugin_root, cmd_args, is_npm=False) def init_project(self, plugin_root: str, name: str = "plugin-project") -> Dict[str, Any]: """ Helper: Initialize a package.json in the plugin's pkg directory. """ # First run npm init -y res = self.execute_in_context(plugin_root, ['init', '-y'], is_npm=True) if not res['success']: return res # Then update the name to be more specific pkg_json_path = os.path.join(plugin_root, 'pkg', 'package.json') if os.path.exists(pkg_json_path): try: with open(pkg_json_path, 'r+') as f: data = json.load(f) data['name'] = name data['private'] = True f.seek(0) json.dump(data, f, indent=2) f.truncate() return {'success': True, 'message': f'Initialized project {name}'} except Exception as e: return {'success': False, 'error': f'Failed to update package.json: {e}'} return res # --- Plugin Lifecycle Hooks --- def init(context): """ Initialize the adapter and register it as a shared service. This plugin does NOT start any server or run any code itself. It just registers the tool for others to use. """ adapter = NodeJSAdapter() versions = adapter.check_versions() print(f"[INFO] Node.js Adapter Service Registered") if versions.get('node'): print(f"[INFO] Runtime: Node {versions['node']}") if versions.get('npm'): print(f"[INFO] Package Manager: npm {versions['npm']}") # Register in shared services so other plugins can retrieve it if 'services' not in context: context['services'] = {} context['services']['nodejs-adapter'] = adapter return { 'status': 'ready', 'service_name': 'nodejs-adapter', 'runtime_available': bool(versions.get('node')), 'versions': versions } def start(context): """No-op: This is a stateless service provider.""" return {'status': 'active'} def stop(context): """No-op: Nothing to clean up.""" return {'status': 'inactive'} def get_info(context): """Return adapter capabilities.""" adapter = context.get('services', {}).get('nodejs-adapter') if adapter: return adapter.get_capabilities() return {'error': 'Adapter service not found'}