Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ask to install local node runtime if not available in PATH #51

Merged
merged 17 commits into from
Feb 22, 2021
Merged
10 changes: 10 additions & 0 deletions lsp_utils.sublime-commands
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
10 changes: 10 additions & 0 deletions lsp_utils.sublime-settings
Original file line number Diff line number Diff line change
@@ -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"],
}
2 changes: 1 addition & 1 deletion st3/lsp_utils/api_wrapper_interface.py
Original file line number Diff line number Diff line change
@@ -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']

Expand Down
188 changes: 188 additions & 0 deletions st3/lsp_utils/node_runtime.py
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 15 additions & 3 deletions st3/lsp_utils/npm_client_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down Expand Up @@ -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
Expand All @@ -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]:
Expand All @@ -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 ''

Loading