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
192 changes: 192 additions & 0 deletions st3/lsp_utils/node_distribution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 Optional, Tuple
from os import path
import os
import shutil
import sublime
import tarfile
import urllib.request
import zipfile

__all__ = ['NodeDistribution', 'NodeDistributionPATH', 'NodeDistributionLocal']

NODE_VERSION = '12.20.1'
rchl marked this conversation as resolved.
Show resolved Hide resolved


class NodeDistribution:
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) -> Optional[SemanticVersion]:
if self._version:
return self._version
if not self._node:
raise Exception('Node 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) -> 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 not installed. Use InstallNode command first.')
args = [
self.node_bin(),
self.npm_command(),
'ci' if use_ci else 'install',
'--scripts-prepend-node-path',
'--verbose',
'--production',
'--prefix', package_dir,
package_dir
]
output, error = run_command_sync(args)
if error is not None:
raise Exception('Failed to run npm command "{}":\n{}'.format(' '.join(args), error))


class NodeDistributionPATH(NodeDistribution):
def __init__(self) -> None:
super().__init__()
self._node = shutil.which('node')
self._npm = 'npm'


class NodeDistributionLocal(NodeDistribution):
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) -> str:
if not self._node or not self._npm:
raise Exception('Node or Npm command not initialized')
return path.join(self._node, self._npm)

def install_node(self) -> None:
with ActivityIndicator(sublime.active_window(), 'Installing Node'):
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'''

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 version and distribution files
:param node_version: The Node version to install
:param node_dist_url: Base URL to fetch Node 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 {}'.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)


13 changes: 12 additions & 1 deletion st3/lsp_utils/npm_client_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def get_additional_variables(cls) -> Dict[str, str]:
"""
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 +64,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 +83,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