#!/usr/bin/python # (c) 2017 David Gunter # Copyright (c) 2017 Chris Hoffman # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import annotations DOCUMENTATION = r""" module: yarn short_description: Manage Node.js packages with Yarn description: - Manage Node.js packages with the Yarn package manager U(https://yarnpkg.com/). - Note that at the moment, this module B(only works with Yarn Classic). author: - "David Gunter (@verkaufer)" - "Chris Hoffman (@chrishoffman), creator of NPM Ansible module)" extends_documentation_fragment: - community.general.attributes attributes: check_mode: support: full diff_mode: support: none options: name: type: str description: - The name of a Node.js library to install. - If omitted all packages in package.json are installed. - To globally install from local Node.js library. Prepend C(file:) to the path of the Node.js library. path: type: path description: - The base path where Node.js installs libraries. - This is where the C(node_modules) folder lives. version: type: str description: - The version of the library to be installed. - Must be in semver format. If "latest" is desired, use O(state) arg instead. global: description: - Install the Node.js library globally. default: false type: bool executable: type: path description: - The executable location for yarn. ignore_scripts: description: - Use the C(--ignore-scripts) flag when installing. type: bool default: false production: description: - Install dependencies in production mode. - C(yarn) ignores any dependencies under devDependencies in C(package.json). type: bool default: false registry: type: str description: - The registry to install modules from. state: type: str description: - Installation state of the named Node.js library. - If V(absent) is selected, a O(name) option must be provided. default: present choices: ["present", "absent", "latest"] requirements: - Yarn Classic installed in bin path (typically C(/usr/local/bin)) """ EXAMPLES = r""" - name: Install "imagemin" Node.js package. community.general.yarn: name: imagemin path: /app/location - name: Install "imagemin" Node.js package on version 5.3.1 community.general.yarn: name: imagemin version: '5.3.1' path: /app/location - name: Install "imagemin" Node.js package globally. community.general.yarn: name: imagemin global: true - name: Remove the globally-installed package "imagemin". community.general.yarn: name: imagemin global: true state: absent - name: Install "imagemin" Node.js package from custom registry. community.general.yarn: name: imagemin registry: 'http://registry.mysite.com' - name: Install packages based on package.json. community.general.yarn: path: /app/location - name: Update all packages in package.json to their latest version. community.general.yarn: path: /app/location state: latest """ RETURN = r""" out: description: Output generated from Yarn. returned: always type: str sample: "yarn add v0.16.1[1/4] Resolving packages...[2/4] Fetching packages...[3/4] Linking dependencies...[4/4] Building fresh packages...success Saved lockfile.success Saved 1 new dependency..left-pad@1.1.3 Done in 0.59s." """ import json import os from ansible.module_utils.basic import AnsibleModule class Yarn: def __init__(self, module, **kwargs): self.module = module self.globally = kwargs["globally"] self.name = kwargs["name"] self.version = kwargs["version"] self.path = kwargs["path"] self.registry = kwargs["registry"] self.production = kwargs["production"] self.ignore_scripts = kwargs["ignore_scripts"] self.executable = kwargs["executable"] # Specify a version of package if version arg passed in self.name_version = None if kwargs["version"] and self.name is not None: self.name_version = f"{self.name}@{self.version!s}" elif self.name is not None: self.name_version = self.name def _exec(self, args, run_in_check_mode=False, check_rc=True, unsupported_with_global=False): if not self.module.check_mode or (self.module.check_mode and run_in_check_mode): with_global_arg = self.globally and not unsupported_with_global if with_global_arg: # Yarn global arg is inserted before the command (e.g. `yarn global {some-command}`) args.insert(0, "global") cmd = self.executable + args if self.production: cmd.append("--production") if self.ignore_scripts: cmd.append("--ignore-scripts") if self.registry: cmd.append("--registry") cmd.append(self.registry) # If path is specified, cd into that path and run the command. cwd = None if self.path and not with_global_arg: if not os.path.exists(self.path): # Module will make directory if not exists. os.makedirs(self.path) if not os.path.isdir(self.path): self.module.fail_json(msg=f"Path provided {self.path} is not a directory") cwd = self.path if not os.path.isfile(os.path.join(self.path, "package.json")): self.module.fail_json(msg="Package.json does not exist in provided path.") rc, out, err = self.module.run_command(cmd, check_rc=check_rc, cwd=cwd) return out, err return None, None def _process_yarn_error(self, err): try: # We need to filter for errors, since Yarn warnings are included in stderr for line in err.splitlines(): if json.loads(line)["type"] == "error": self.module.fail_json(msg=err) except Exception: self.module.fail_json(msg=f"Unexpected stderr output from Yarn: {err}", stderr=err) def list(self): cmd = ["list", "--depth=0", "--json"] installed = list() missing = list() if not os.path.isfile(os.path.join(self.path, "yarn.lock")): missing.append(self.name) return installed, missing # `yarn global list` should be treated as "unsupported with global" even though it exists, # because it only only lists binaries, but `yarn global add` can install libraries too. result, error = self._exec(cmd, run_in_check_mode=True, check_rc=False, unsupported_with_global=True) self._process_yarn_error(error) for json_line in result.strip().split("\n"): data = json.loads(json_line) if data["type"] == "tree": dependencies = data["data"]["trees"] for dep in dependencies: name, version = dep["name"].rsplit("@", 1) installed.append(name) if self.name not in installed: missing.append(self.name) return installed, missing def install(self): if self.name_version: # Yarn has a separate command for installing packages by name... return self._exec(["add", self.name_version]) # And one for installing all packages in package.json return self._exec(["install", "--non-interactive"]) def update(self): return self._exec(["upgrade", "--latest"]) def uninstall(self): return self._exec(["remove", self.name]) def list_outdated(self): outdated = list() if not os.path.isfile(os.path.join(self.path, "yarn.lock")): return outdated cmd_result, err = self._exec(["outdated", "--json"], True, False, unsupported_with_global=True) # the package.json in the global dir is missing a license field, so warnings are expected on stderr self._process_yarn_error(err) if not cmd_result: return outdated outdated_packages_data = cmd_result.splitlines()[1] data = json.loads(outdated_packages_data) try: outdated_dependencies = data["data"]["body"] except KeyError: return outdated for dep in outdated_dependencies: # Outdated dependencies returned as a list of lists, where # item at index 0 is the name of the dependency outdated.append(dep[0]) return outdated def main(): arg_spec = dict( name=dict(), path=dict(type="path"), version=dict(), production=dict(default=False, type="bool"), executable=dict(type="path"), registry=dict(), state=dict(default="present", choices=["present", "absent", "latest"]), ignore_scripts=dict(default=False, type="bool"), ) arg_spec["global"] = dict(default=False, type="bool") module = AnsibleModule(argument_spec=arg_spec, supports_check_mode=True) name = module.params["name"] path = module.params["path"] version = module.params["version"] globally = module.params["global"] production = module.params["production"] registry = module.params["registry"] state = module.params["state"] ignore_scripts = module.params["ignore_scripts"] # When installing globally, users should not be able to define a path for installation. # Require a path if global is False, though! if path is None and globally is False: module.fail_json(msg="Path must be specified when not using global arg") elif path and globally is True: module.fail_json(msg="Cannot specify path if doing global installation") if state == "absent" and not name: module.fail_json(msg="Package must be explicitly named when uninstalling.") if state == "latest": version = "latest" if module.params["executable"]: executable = module.params["executable"].split(" ") else: executable = [module.get_bin_path("yarn", True)] # When installing globally, use the defined path for global node_modules if globally: _rc, out, _err = module.run_command(executable + ["global", "dir"], check_rc=True) path = out.strip() yarn = Yarn( module, name=name, path=path, version=version, globally=globally, production=production, executable=executable, registry=registry, ignore_scripts=ignore_scripts, ) changed = False out = "" err = "" if state == "present": if not name: changed = True out, err = yarn.install() else: installed, missing = yarn.list() if len(missing): changed = True out, err = yarn.install() elif state == "latest": if not name: changed = True out, err = yarn.install() else: installed, missing = yarn.list() outdated = yarn.list_outdated() if len(missing): changed = True out, err = yarn.install() if len(outdated): changed = True out, err = yarn.update() else: # state == absent installed, missing = yarn.list() if name in installed: changed = True out, err = yarn.uninstall() module.exit_json(changed=changed, out=out, err=err) if __name__ == "__main__": main()