From 403345a0c5c15e84802c712044c630a9fb236b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ch=C5=82odnicki?= Date: Mon, 1 Feb 2021 23:40:58 +0100 Subject: [PATCH] feat: ask to install local node runtime if not available in PATH (#51) --- lsp_utils.sublime-commands | 10 ++ lsp_utils.sublime-settings | 10 ++ st3/lsp_utils/api_wrapper_interface.py | 2 +- st3/lsp_utils/node_runtime.py | 188 +++++++++++++++++++++++++ st3/lsp_utils/npm_client_handler.py | 18 ++- st3/lsp_utils/server_npm_resource.py | 140 ++++++++++-------- sublime-package.json | 37 +++++ 7 files changed, 345 insertions(+), 60 deletions(-) create mode 100644 lsp_utils.sublime-commands create mode 100644 lsp_utils.sublime-settings create mode 100755 st3/lsp_utils/node_runtime.py create mode 100644 sublime-package.json diff --git a/lsp_utils.sublime-commands b/lsp_utils.sublime-commands new file mode 100644 index 0000000..4a75004 --- /dev/null +++ b/lsp_utils.sublime-commands @@ -0,0 +1,10 @@ +[ + { + "caption": "Preferences: LSP Utils Settings", + "command": "edit_settings", + "args": { + "base_file": "${packages}/lsp_utils/lsp_utils.sublime-settings", + "default": "// Settings in here override those in \"lsp_utils/lsp_utils.sublime-settings\"\n{\n\t$0\n}\n" + } + } +] diff --git a/lsp_utils.sublime-settings b/lsp_utils.sublime-settings new file mode 100644 index 0000000..560f489 --- /dev/null +++ b/lsp_utils.sublime-settings @@ -0,0 +1,10 @@ +{ + // Specifies the type and priority of the Node.js installation that should be used for Node.js-based servers. + // The allowed values are: + // - 'system' - a Node.js runtime found on the PATH + // - 'local' - a Node.js runtime managed by LSP that doesn't affect the system + // The order in which the values are specified determines which one is tried first, + // with the later one being used as a fallback. + // You can also specify just a single value to disable the fallback. + "nodejs_runtime": ["system", "local"], +} diff --git a/st3/lsp_utils/api_wrapper_interface.py b/st3/lsp_utils/api_wrapper_interface.py index ef588a4..1e362a3 100644 --- a/st3/lsp_utils/api_wrapper_interface.py +++ b/st3/lsp_utils/api_wrapper_interface.py @@ -1,5 +1,5 @@ from abc import ABCMeta, abstractmethod -from LSP.plugin.core.typing import Any, Callable, Optional +from LSP.plugin.core.typing import Any, Callable __all__ = ['ApiWrapperInterface'] diff --git a/st3/lsp_utils/node_runtime.py b/st3/lsp_utils/node_runtime.py new file mode 100755 index 0000000..846b764 --- /dev/null +++ b/st3/lsp_utils/node_runtime.py @@ -0,0 +1,188 @@ +from .activity_indicator import ActivityIndicator +from .helpers import parse_version +from .helpers import run_command_sync +from .helpers import SemanticVersion +from contextlib import contextmanager +from LSP.plugin.core.typing import List, Optional, Tuple +from os import path +import os +import shutil +import sublime +import tarfile +import urllib.request +import zipfile + +__all__ = ['NodeRuntime', 'NodeRuntimePATH', 'NodeRuntimeLocal'] + +NODE_VERSION = '12.20.2' + + +class NodeRuntime: + def __init__(self) -> None: + self._node = None # type: Optional[str] + self._npm = None # type: Optional[str] + self._version = None # type: Optional[SemanticVersion] + + def node_exists(self) -> bool: + return self._node is not None + + def node_bin(self) -> Optional[str]: + return self._node + + def resolve_version(self) -> SemanticVersion: + if self._version: + return self._version + if not self._node: + raise Exception('Node.js not initialized') + version, error = run_command_sync([self._node, '--version']) + if error is None: + self._version = parse_version(version) + else: + raise Exception('Error resolving node version:\n{}'.format(error)) + return self._version + + def npm_command(self) -> List[str]: + if self._npm is None: + raise Exception('Npm command not initialized') + return [self._npm] + + def npm_install(self, package_dir: str, use_ci: bool = True) -> None: + if not path.isdir(package_dir): + raise Exception('Specified package_dir path "{}" does not exist'.format(package_dir)) + if not self._node: + raise Exception('Node.js not installed. Use InstallNode command first.') + args = self.npm_command() + [ + 'ci' if use_ci else 'install', + '--scripts-prepend-node-path', + '--verbose', + '--production', + '--prefix', package_dir, + package_dir + ] + _, error = run_command_sync(args, cwd=package_dir) + if error is not None: + raise Exception('Failed to run npm command "{}":\n{}'.format(' '.join(args), error)) + + +class NodeRuntimePATH(NodeRuntime): + def __init__(self) -> None: + super().__init__() + self._node = shutil.which('node') + self._npm = 'npm' + + +class NodeRuntimeLocal(NodeRuntime): + def __init__(self, base_dir: str, node_version: str = NODE_VERSION): + super().__init__() + self._base_dir = path.abspath(path.join(base_dir, node_version)) + self._node_version = node_version + self._node_dir = path.join(self._base_dir, 'node') + self.resolve_paths() + + def resolve_paths(self) -> None: + self._node = self.resolve_binary() + self._node_lib = self.resolve_lib() + self._npm = path.join(self._node_lib, 'npm', 'bin', 'npm-cli.js') + + def resolve_binary(self) -> Optional[str]: + exe_path = path.join(self._node_dir, 'node.exe') + binary_path = path.join(self._node_dir, 'bin', 'node') + if path.isfile(exe_path): + return exe_path + elif path.isfile(binary_path): + return binary_path + + def resolve_lib(self) -> str: + lib_path = path.join(self._node_dir, 'lib', 'node_modules') + if not path.isdir(lib_path): + lib_path = path.join(self._node_dir, 'node_modules') + return lib_path + + def npm_command(self) -> List[str]: + if not self._node or not self._npm: + raise Exception('Node.js or Npm command not initialized') + return [self._node, self._npm] + + def install_node(self) -> None: + with ActivityIndicator(sublime.active_window(), 'Installing Node.js'): + install_node = InstallNode(self._base_dir, self._node_version) + install_node.run() + self.resolve_paths() + + +class InstallNode: + '''Command to install a local copy of Node.js''' + + def __init__(self, base_dir: str, node_version: str = NODE_VERSION, + node_dist_url='https://nodejs.org/dist/') -> None: + """ + :param base_dir: The base directory for storing given Node.js runtime version + :param node_version: The Node.js version to install + :param node_dist_url: Base URL to fetch Node.js from + """ + self._base_dir = base_dir + self._node_version = node_version + self._cache_dir = path.join(self._base_dir, 'cache') + self._node_dist_url = node_dist_url + + def run(self) -> None: + print('Installing Node.js {}'.format(self._node_version)) + archive, url = self._node_archive() + if not self._node_archive_exists(archive): + self._download_node(url, archive) + self._install_node(archive) + + def _node_archive(self) -> Tuple[str, str]: + platform = sublime.platform() + arch = sublime.arch() + if platform == 'windows' and arch == 'x64': + node_os = 'win' + archive = 'zip' + elif platform == 'linux' and arch == 'x64': + node_os = 'linux' + archive = 'tar.gz' + elif platform == 'osx' and arch == 'x64': + node_os = 'darwin' + archive = 'tar.gz' + else: + raise Exception('{} {} is not supported'.format(arch, platform)) + filename = 'node-v{}-{}-{}.{}'.format(self._node_version, node_os, arch, archive) + dist_url = '{}v{}/{}'.format(self._node_dist_url, self._node_version, filename) + return filename, dist_url + + def _node_archive_exists(self, filename: str) -> bool: + archive = path.join(self._cache_dir, filename) + return path.isfile(archive) + + def _download_node(self, url: str, filename: str) -> None: + if not path.isdir(self._cache_dir): + os.makedirs(self._cache_dir) + archive = path.join(self._cache_dir, filename) + with urllib.request.urlopen(url) as response: + with open(archive, 'wb') as f: + shutil.copyfileobj(response, f) + + def _install_node(self, filename: str) -> None: + archive = path.join(self._cache_dir, filename) + opener = zipfile.ZipFile if filename.endswith('.zip') else tarfile.open + with opener(archive) as f: + names = f.namelist() if hasattr(f, 'namelist') else f.getnames() + install_dir, _ = next(x for x in names if '/' in x).split('/', 1) + bad_members = [x for x in names if x.startswith('/') or x.startswith('..')] + if bad_members: + raise Exception('{} appears to be malicious, bad filenames: {}'.format(filename, bad_members)) + f.extractall(self._base_dir) + with chdir(self._base_dir): + os.rename(install_dir, 'node') + os.remove(archive) + + +@contextmanager +def chdir(new_dir: str): + '''Context Manager for changing the working directory''' + cur_dir = os.getcwd() + os.chdir(new_dir) + try: + yield + finally: + os.chdir(cur_dir) diff --git a/st3/lsp_utils/npm_client_handler.py b/st3/lsp_utils/npm_client_handler.py index 38d34c6..69e3826 100644 --- a/st3/lsp_utils/npm_client_handler.py +++ b/st3/lsp_utils/npm_client_handler.py @@ -2,7 +2,6 @@ from .server_npm_resource import ServerNpmResource from .server_resource_interface import ServerResourceInterface from LSP.plugin.core.typing import Dict, List, Optional, Tuple -import sublime __all__ = ['NpmClientHandler'] @@ -48,13 +47,16 @@ def get_additional_variables(cls) -> Dict[str, str]: The additional variables are: - - `${server_path}` - holds filesystem path to the server binary (only + - `${node_bin}`: - holds the binary path of currently used Node.js runtime. This can resolve to just `node` + when using Node.js runtime from the PATH or to a full filesystem path if using the local Node.js runtime. + - `${server_directory_path}` - holds filesystem path to the server directory (only when :meth:`GenericClientHandler.manages_server()` is `True`). Remember to call the super class and merge the results if overriding. """ variables = super().get_additional_variables() variables.update({ + 'node_bin': cls._node_bin(), 'server_directory_path': cls._server_directory_path(), }) return variables @@ -63,7 +65,7 @@ def get_additional_variables(cls) -> Dict[str, str]: @classmethod def get_command(cls) -> List[str]: - return ['node', cls.binary_path()] + cls.get_binary_arguments() + return [cls._node_bin(), cls.binary_path()] + cls.get_binary_arguments() @classmethod def get_binary_arguments(cls) -> List[str]: @@ -82,11 +84,21 @@ def get_server(cls) -> Optional[ServerResourceInterface]: 'server_binary_path': cls.server_binary_path, 'package_storage': cls.package_storage(), 'minimum_node_version': cls.minimum_node_version(), + 'storage_path': cls.storage_path(), }) return cls.__server + # --- Internal ---------------------------------------------------------------------------------------------------- + @classmethod def _server_directory_path(cls) -> str: if cls.__server: return cls.__server.server_directory_path return '' + + @classmethod + def _node_bin(cls) -> str: + if cls.__server: + return cls.__server.node_bin + return '' + diff --git a/st3/lsp_utils/server_npm_resource.py b/st3/lsp_utils/server_npm_resource.py index d37176f..d49adb2 100644 --- a/st3/lsp_utils/server_npm_resource.py +++ b/st3/lsp_utils/server_npm_resource.py @@ -1,46 +1,35 @@ from .helpers import log_and_show_message -from .helpers import parse_version -from .helpers import run_command_sync from .helpers import SemanticVersion from .helpers import version_to_string +from .node_runtime import NodeRuntime +from .node_runtime import NodeRuntimeLocal +from .node_runtime import NodeRuntimePATH from .server_resource_interface import ServerResourceInterface from .server_resource_interface import ServerStatus from hashlib import md5 from LSP.plugin.core.typing import Dict, Optional +from os import path from sublime_lib import ResourcePath -import os import shutil import sublime __all__ = ['ServerNpmResource'] - -class NodeVersionResolver: - """ - A singleton for resolving Node version once per session. - """ - - def __init__(self) -> None: - self._version = None # type: Optional[SemanticVersion] - - def resolve(self) -> Optional[SemanticVersion]: - if self._version: - return self._version - version, error = run_command_sync(['node', '--version']) - if error is not None: - log_and_show_message('lsp_utils(NodeVersionResolver): Error resolving node version: {}!'.format(error)) - else: - self._version = parse_version(version) - return self._version - - -node_version_resolver = NodeVersionResolver() +NO_NODE_FOUND_MESSAGE = 'Could not start {package_name} due to not being able to find Node.js \ +runtime on the PATH. Press the "Install Node.js" button to install Node.js automatically \ +(note that it will be installed locally for LSP and will not affect your system otherwise).' class ServerNpmResource(ServerResourceInterface): """ An implementation of :class:`lsp_utils.ServerResourceInterface` implementing server management for - npm-based severs. Handles installation and updates of the server in package storage. + node-based severs. Handles installation and updates of the server in package storage. + """ + + _node_runtime_resolved = False + _node_runtime = None # Optional[NodeRuntime] + """ + Cached instance of resolved Node.js runtime. This is only done once per-session to avoid unnecessary IO. """ @classmethod @@ -50,37 +39,79 @@ def create(cls, options: Dict) -> Optional['ServerNpmResource']: server_binary_path = options['server_binary_path'] package_storage = options['package_storage'] minimum_node_version = options['minimum_node_version'] - if shutil.which('node') is None: - log_and_show_message( - '{}: Error: Node binary not found on the PATH.' - 'Check the LSP Troubleshooting section for information on how to fix that: ' - 'https://lsp.readthedocs.io/en/latest/troubleshooting/'.format(package_name)) - return None - installed_node_version = node_version_resolver.resolve() - if not installed_node_version: - return None - if installed_node_version < minimum_node_version: - error = 'Installed node version ({}) is lower than required version ({})'.format( - version_to_string(installed_node_version), version_to_string(minimum_node_version)) - log_and_show_message('{}: Error:'.format(package_name), error) - return None - return ServerNpmResource(package_name, server_directory, server_binary_path, package_storage, - version_to_string(installed_node_version)) + storage_path = options['storage_path'] + if not cls._node_runtime_resolved: + cls._node_runtime = cls.resolve_node_runtime(package_name, minimum_node_version, storage_path) + cls._node_runtime_resolved = True + if cls._node_runtime: + return ServerNpmResource( + package_name, server_directory, server_binary_path, package_storage, cls._node_runtime) + + @classmethod + def resolve_node_runtime( + cls, package_name: str, minimum_node_version: SemanticVersion, storage_path: str + ) -> Optional[NodeRuntime]: + selected_runtimes = sublime.load_settings('lsp_utils.sublime-settings').get('nodejs_runtime') + for runtime in selected_runtimes: + if runtime == 'system': + node_runtime = NodeRuntimePATH() + if node_runtime.node_exists(): + try: + cls.check_node_version(node_runtime, minimum_node_version) + return node_runtime + except Exception as ex: + message = 'Ignoring system Node.js runtime due to an error. {}'.format(ex) + log_and_show_message('{}: Error: {}'.format(package_name, message)) + elif runtime == 'local': + node_runtime = NodeRuntimeLocal(path.join(storage_path, 'lsp_utils', 'node-runtime')) + if not node_runtime.node_exists(): + if not sublime.ok_cancel_dialog(NO_NODE_FOUND_MESSAGE.format(package_name=package_name), + 'Install Node.js'): + return + try: + node_runtime.install_node() + except Exception as ex: + log_and_show_message('{}: Error: Failed installing a local Node.js runtime:\n{}'.format( + package_name, ex)) + return + if node_runtime.node_exists(): + try: + cls.check_node_version(node_runtime, minimum_node_version) + return node_runtime + except Exception as ex: + error = 'Ignoring local Node.js runtime due to an error. {}'.format(ex) + log_and_show_message('{}: Error: {}'.format(package_name, error)) + + @classmethod + def check_node_version(cls, node_runtime: NodeRuntime, minimum_node_version: SemanticVersion) -> None: + node_version = node_runtime.resolve_version() + if node_version < minimum_node_version: + raise Exception('Node.js version requirement failed. Expected minimum: {}, got {}.'.format( + version_to_string(minimum_node_version), version_to_string(node_version))) def __init__(self, package_name: str, server_directory: str, server_binary_path: str, - package_storage: str, node_version: str) -> None: - if not package_name or not server_directory or not server_binary_path: + package_storage: str, node_runtime: NodeRuntime) -> None: + if not package_name or not server_directory or not server_binary_path or not node_runtime: raise Exception('ServerNpmResource could not initialize due to wrong input') self._status = ServerStatus.UNINITIALIZED self._package_name = package_name self._server_src = 'Packages/{}/{}/'.format(self._package_name, server_directory) - self._server_dest = os.path.join(package_storage, node_version, server_directory) - self._binary_path = os.path.join(package_storage, node_version, server_binary_path) + node_version = version_to_string(node_runtime.resolve_version()) + self._server_dest = path.join(package_storage, node_version, server_directory) + self._binary_path = path.join(package_storage, node_version, server_binary_path) + self._node_runtime = node_runtime @property def server_directory_path(self) -> str: return self._server_dest + @property + def node_bin(self) -> str: + node_bin = self._node_runtime.node_bin() + if node_bin is None: + raise Exception('Failed to resolve path to the Node.js runtime') + return node_bin + # --- ServerResourceInterface ------------------------------------------------------------------------------------- @property @@ -92,11 +123,11 @@ def get_status(self) -> int: def needs_installation(self) -> bool: installed = False - if os.path.isdir(self._server_dest): + if path.isdir(path.join(self._server_dest, 'node_modules')): # Server already installed. Check if version has changed. try: src_hash = md5(ResourcePath(self._server_src, 'package.json').read_bytes()).hexdigest() - with open(os.path.join(self._server_dest, 'package.json'), 'rb') as file: + with open(path.join(self._server_dest, 'package.json'), 'rb') as file: dst_hash = md5(file.read()).hexdigest() if src_hash == dst_hash: installed = True @@ -111,14 +142,11 @@ def needs_installation(self) -> bool: def install_or_update(self) -> None: shutil.rmtree(self._server_dest, ignore_errors=True) ResourcePath(self._server_src).copytree(self._server_dest, exist_ok=True) - dependencies_installed = os.path.isdir(os.path.join(self._server_dest, 'node_modules')) - if dependencies_installed: - self._status = ServerStatus.READY - else: - args = ["npm", "install", "--verbose", "--production", "--prefix", self._server_dest, self._server_dest] - output, error = run_command_sync(args) - if error is not None: + dependencies_installed = path.isdir(path.join(self._server_dest, 'node_modules')) + if not dependencies_installed: + try: + self._node_runtime.npm_install(self._server_dest) + except Exception as error: self._status = ServerStatus.ERROR raise Exception(error) - else: - self._status = ServerStatus.READY + self._status = ServerStatus.READY diff --git a/sublime-package.json b/sublime-package.json new file mode 100644 index 0000000..1dd377b --- /dev/null +++ b/sublime-package.json @@ -0,0 +1,37 @@ +{ + "contributions": { + "settings": [ + { + "file_patterns": [ + "/lsp_utils.sublime-settings" + ], + "schema": { + "type": "object", + "properties": { + "nodejs_runtime": { + "type": "array", + "markdownDescription": "Specifies the type and priority of the Node.js installation that should be used for Node.js-based servers.\n\nThe allowed values are:\n\n- `system` - a Node.js runtime found on the PATH\n- `local` - a Node.js runtime managed by LSP that doesn't affect the system\n\nThe order in which the values are specified determines which one is tried first,\nwith the later one being used as a fallback.\nYou can also specify just a single value to disable the fallback.", + "default": [ + "system", + "local", + ], + "items": { + "type": "string", + "enum": [ + "system", + "local" + ], + "markdownEnumDescriptions": [ + "Node.js runtime found on the PATH", + "Node.js runtime managed by LSP" + ] + }, + "uniqueItems": true, + } + }, + "additionalProperties": false, + } + } + ] + } +}