#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2023 Aritra Sen # 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 absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = """ --- module: pnpm short_description: Manage node.js packages with pnpm version_added: 7.4.0 description: - Manage node.js packages with the L(pnpm package manager, https://pnpm.io/). author: - "Aritra Sen (@aretrosen)" - "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: description: - The name of a node.js library to install. - All packages in package.json are installed if not provided. type: str required: false alias: description: - Alias of the node.js library. type: str required: false path: description: - The base path to install the node.js libraries. type: path required: false version: description: - The version of the library to be installed, in semver format. type: str required: false global: description: - Install the node.js library globally. required: false default: false type: bool executable: description: - The executable location for pnpm. - The default location it searches for is E(PATH), fails if not set. type: path required: false ignore_scripts: description: - Use the C(--ignore-scripts) flag when installing. required: false type: bool default: false no_optional: description: - Do not install optional packages, equivalent to C(--no-optional). required: false type: bool default: false production: description: - Install dependencies in production mode. - Pnpm will ignore any dependencies under C(devDependencies) in package.json. required: false type: bool default: false dev: description: - Install dependencies in development mode. - Pnpm will ignore any regular dependencies in C(package.json). required: false default: false type: bool optional: description: - Install dependencies in optional mode. required: false default: false type: bool state: description: - Installation state of the named node.js library. - If V(absent) is selected, a name option must be provided. type: str required: false default: present choices: ["present", "absent", "latest"] requirements: - Pnpm executable present in E(PATH). """ EXAMPLES = """ - name: Install "tailwindcss" node.js package. community.general.pnpm: name: tailwindcss path: /app/location - name: Install "tailwindcss" node.js package on version 3.3.2 community.general.pnpm: name: tailwindcss version: 3.3.2 path: /app/location - name: Install "tailwindcss" node.js package globally. community.general.pnpm: name: tailwindcss global: true - name: Install "tailwindcss" node.js package as dev dependency. community.general.pnpm: name: tailwindcss path: /app/location dev: true - name: Install "tailwindcss" node.js package as optional dependency. community.general.pnpm: name: tailwindcss path: /app/location optional: true - name: Install "tailwindcss" node.js package version 0.1.3 as tailwind-1 community.general.pnpm: name: tailwindcss alias: tailwind-1 version: 0.1.3 path: /app/location - name: Remove the globally-installed package "tailwindcss". community.general.pnpm: name: tailwindcss global: true state: absent - name: Install packages based on package.json. community.general.pnpm: path: /app/location - name: Update all packages in package.json to their latest version. community.general.pnpm: path: /app/location state: latest """ import json import os from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_native class Pnpm(object): def __init__(self, module, **kwargs): self.module = module self.name = kwargs["name"] self.alias = kwargs["alias"] self.version = kwargs["version"] self.path = kwargs["path"] self.globally = kwargs["globally"] self.executable = kwargs["executable"] self.ignore_scripts = kwargs["ignore_scripts"] self.no_optional = kwargs["no_optional"] self.production = kwargs["production"] self.dev = kwargs["dev"] self.optional = kwargs["optional"] self.alias_name_ver = None if self.alias is not None: self.alias_name_ver = self.alias + "@npm:" if self.name is not None: self.alias_name_ver = (self.alias_name_ver or "") + self.name if self.version is not None: self.alias_name_ver = self.alias_name_ver + "@" + str(self.version) else: self.alias_name_ver = self.alias_name_ver + "@latest" def _exec(self, args, run_in_check_mode=False, check_rc=True): if not self.module.check_mode or (self.module.check_mode and run_in_check_mode): cmd = self.executable + args if self.globally: cmd.append("-g") if self.ignore_scripts: cmd.append("--ignore-scripts") if self.no_optional: cmd.append("--no-optional") if self.production: cmd.append("-P") if self.dev: cmd.append("-D") if self.name and self.optional: cmd.append("-O") # If path is specified, cd into that path and run the command. cwd = None if self.path: if not os.path.exists(self.path): os.makedirs(self.path) if not os.path.isdir(self.path): self.module.fail_json(msg="Path %s is not a directory" % self.path) if not self.alias_name_ver and not os.path.isfile( os.path.join(self.path, "package.json") ): self.module.fail_json( msg="package.json does not exist in provided path" ) cwd = self.path _rc, out, err = self.module.run_command(cmd, check_rc=check_rc, cwd=cwd) return out, err return None, None def missing(self): if not os.path.isfile(os.path.join(self.path, "pnpm-lock.yaml")): return True cmd = ["list", "--json"] if self.name is not None: cmd.append(self.name) try: out, err = self._exec(cmd, True, False) if err is not None and err != "": raise Exception(out) data = json.loads(out) except Exception as e: self.module.fail_json( msg="Failed to parse pnpm output with error %s" % to_native(e) ) if "error" in data: return True data = data[0] for typedep in [ "dependencies", "devDependencies", "optionalDependencies", "unsavedDependencies", ]: if typedep not in data: continue for dep, prop in data[typedep].items(): if self.alias is not None and self.alias != dep: continue name = prop["from"] if self.alias is not None else dep if self.name != name: continue if self.version is None or self.version == prop["version"]: return False break return True def install(self): if self.alias_name_ver is not None: return self._exec(["add", self.alias_name_ver]) return self._exec(["install"]) def update(self): return self._exec(["update", "--latest"]) def uninstall(self): if self.alias is not None: return self._exec(["remove", self.alias]) return self._exec(["remove", self.name]) def list_outdated(self): if not os.path.isfile(os.path.join(self.path, "pnpm-lock.yaml")): return list() cmd = ["outdated", "--format", "json"] try: out, err = self._exec(cmd, True, False) # BUG: It will not show correct error sometimes, like when it has # plain text output intermingled with a {} if err is not None and err != "": raise Exception(out) # HACK: To fix the above bug, the following hack is implemented data_lines = out.splitlines(True) out = None for line in data_lines: if len(line) > 0 and line[0] == "{": out = line continue if len(line) > 0 and line[0] == "}": out += line break if out is not None: out += line data = json.loads(out) except Exception as e: self.module.fail_json( msg="Failed to parse pnpm output with error %s" % to_native(e) ) return data.keys() def main(): arg_spec = dict( name=dict(default=None), alias=dict(default=None), path=dict(default=None, type="path"), version=dict(default=None), executable=dict(default=None, type="path"), ignore_scripts=dict(default=False, type="bool"), no_optional=dict(default=False, type="bool"), production=dict(default=False, type="bool"), dev=dict(default=False, type="bool"), optional=dict(default=False, type="bool"), state=dict(default="present", choices=["present", "absent", "latest"]), ) arg_spec["global"] = dict(default=False, type="bool") module = AnsibleModule(argument_spec=arg_spec, supports_check_mode=True) name = module.params["name"] alias = module.params["alias"] path = module.params["path"] version = module.params["version"] globally = module.params["global"] ignore_scripts = module.params["ignore_scripts"] no_optional = module.params["no_optional"] production = module.params["production"] dev = module.params["dev"] optional = module.params["optional"] state = module.params["state"] if module.params["executable"]: executable = module.params["executable"].split(" ") else: executable = [module.get_bin_path("pnpm", True)] if name is None and version is not None: module.fail_json(msg="version is meaningless when name is not provided") if name is None and alias is not None: module.fail_json(msg="alias is meaningless when name is not provided") if path is None and not globally: module.fail_json(msg="path must be specified when not using global") elif path is not None and globally: module.fail_json(msg="Cannot specify path when doing global installation") if globally and (production or dev or optional): module.fail_json( msg="Options production, dev, and optional is meaningless when installing packages globally" ) if name is not None and path is not None and globally: module.fail_json(msg="path should not be mentioned when installing globally") if production and dev and optional: module.fail_json( msg="Options production and dev and optional don't go together" ) if production and dev: module.fail_json(msg="Options production and dev don't go together") if production and optional: module.fail_json(msg="Options production and optional don't go together") if dev and optional: module.fail_json(msg="Options dev and optional don't go together") if name is not None and name[0:4] == "http" and version is not None: module.fail_json(msg="Semver not supported on remote url downloads") if name is None and optional: module.fail_json( msg="Optional not available when package name not provided, use no_optional instead" ) if state == "absent" and name is None: module.fail_json(msg="Package name is required for uninstalling") if globally: _rc, out, _err = module.run_command(executable + ["root", "-g"], check_rc=True) path, _tail = os.path.split(out.strip()) pnpm = Pnpm( module, name=name, alias=alias, path=path, version=version, globally=globally, executable=executable, ignore_scripts=ignore_scripts, no_optional=no_optional, production=production, dev=dev, optional=optional, ) changed = False out = "" err = "" if state == "present": if pnpm.missing(): changed = True out, err = pnpm.install() elif state == "latest": outdated = pnpm.list_outdated() if name is not None: if pnpm.missing() or name in outdated: changed = True out, err = pnpm.install() elif len(outdated): changed = True out, err = pnpm.update() else: # absent if not pnpm.missing(): changed = True out, err = pnpm.uninstall() module.exit_json(changed=changed, out=out, err=err) if __name__ == "__main__": main()