Repository: ansible-collections/community.general

Issue:
community.general.yarn module fails when provided state:latest and global:true
### Summary

[The docs state](https://docs.ansible.com/ansible/latest/collections/community/general/yarn_module.html#parameter-state) I can pass a `state: latest` parameter, but when I try it I get an error "Invalid subcommand" if global is also set. 

### Issue Type

Bug Report

### Component Name

yarn

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.13.6]
  config file = None
  configured module search path = ['/Users/sargunv/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /opt/homebrew/Cellar/ansible/6.6.0/libexec/lib/python3.10/site-packages/ansible
  ansible collection location = /Users/sargunv/.ansible/collections:/usr/share/ansible/collections
  executable location = /opt/homebrew/Cellar/ansible/6.6.0/libexec/bin/ansible
  python version = 3.10.9 (main, Dec 15 2022, 17:11:09) [Clang 14.0.0 (clang-1400.0.29.202)]
  jinja version = 3.1.2
  libyaml = True
```


### Community.general Version

```console (paste below)
$ ansible-galaxy collection list community.general

# /opt/homebrew/Cellar/ansible/6.6.0/libexec/lib/python3.10/site-packages/ansible_collections
Collection        Version
----------------- -------
community.general 5.8.0  
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed

```


### OS / Environment

macOS 13
yarn 1.22.19

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
- community.general.yarn:
    name: "{{ item.name }}"
    global: true
    state: latest
  with_items:
    - { name: "node-gyp" }
```


### Expected Results

I expected node-gyp to be installed or upgraded via `yarn global`.

### Actual Results

```console (paste below)
failed: [localhost] (item={'name': 'node-gyp'}) =>
    ansible_loop_var: item
    changed: false
    item:
        name: node-gyp
    msg: |-
        {"type":"error","data":"Invalid subcommand. Try \"add, bin, dir, ls, list, remove, upgrade, upgrade-interactive\""}
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/yarn.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/yarn.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @chrishoffman @verkaufer
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Upon investigation, it appears that the issue is because `yarn global outdated` is not a valid command, [as called here](https://github.com/ansible-collections/community.general/blob/2b39470a774c28e26780e4e2e4cc9b998471da3c/plugins/modules/yarn.py#L279). The fix would be to perform the equivalent of `cd $(yarn global dir); yarn outdated` instead.

Might make sense to update the below code to set the working directory of the executed command instead of adding `global` to the args. 

https://github.com/ansible-collections/community.general/blob/2b39470a774c28e26780e4e2e4cc9b998471da3c/plugins/modules/yarn.py#L194-L196

Resolving patch:
diff --git a/plugins/modules/yarn.py b/plugins/modules/yarn.py
--- a/plugins/modules/yarn.py
+++ b/plugins/modules/yarn.py
@@ -163,8 +163,6 @@
 
 class Yarn(object):
 
-    DEFAULT_GLOBAL_INSTALLATION_PATH = os.path.expanduser('~/.config/yarn/global')
-
     def __init__(self, module, **kwargs):
         self.module = module
         self.globally = kwargs['globally']
@@ -188,10 +186,12 @@ def __init__(self, module, **kwargs):
         elif self.name is not None:
             self.name_version = self.name
 
-    def _exec(self, args, run_in_check_mode=False, check_rc=True):
+    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):
 
-            if self.globally:
+            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')
 
@@ -207,7 +207,7 @@ def _exec(self, args, run_in_check_mode=False, check_rc=True):
 
             # If path is specified, cd into that path and run the command.
             cwd = None
-            if self.path and not self.globally:
+            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)
@@ -233,24 +233,21 @@ def list(self):
             missing.append(self.name)
             return installed, missing
 
-        result, error = self._exec(cmd, True, False)
+        # `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)
 
         if error:
             self.module.fail_json(msg=error)
 
         for json_line in result.strip().split('\n'):
             data = json.loads(json_line)
-            if self.globally:
-                if data['type'] == 'list' and data['data']['type'].startswith('bins-'):
-                    # This is a string in format: 'bins-<PACKAGE_NAME>'
-                    installed.append(data['data']['type'][5:])
-            else:
-                if data['type'] == 'tree':
-                    dependencies = data['data']['trees']
-
-                    for dep in dependencies:
-                        name, version = dep['name'].rsplit('@', 1)
-                        installed.append(name)
+            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)
@@ -276,9 +273,12 @@ def list_outdated(self):
         if not os.path.isfile(os.path.join(self.path, 'yarn.lock')):
             return outdated
 
-        cmd_result, err = self._exec(['outdated', '--json'], True, False)
-        if err:
-            self.module.fail_json(msg=err)
+        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
+        for line in err.splitlines():
+            if json.loads(line)['type'] == 'error':
+                self.module.fail_json(msg=err)
 
         if not cmd_result:
             return outdated
@@ -340,7 +340,8 @@ def main():
 
     # When installing globally, use the defined path for global node_modules
     if globally:
-        path = Yarn.DEFAULT_GLOBAL_INSTALLATION_PATH
+        _rc, out, _err = module.run_command([executable, 'global', 'dir'], check_rc=True)
+        path = out.strip()
 
     yarn = Yarn(module,
                 name=name,


Test patch:
diff --git a/tests/integration/targets/yarn/tasks/run.yml b/tests/integration/targets/yarn/tasks/run.yml
--- a/tests/integration/targets/yarn/tasks/run.yml
+++ b/tests/integration/targets/yarn/tasks/run.yml
@@ -39,6 +39,7 @@
     package: 'iconv-lite'
   environment:
     PATH: "{{ node_bin_path }}:{{ansible_env.PATH}}"
+    YARN_IGNORE_ENGINES: true
   block:
 
     # Get the version of Yarn and register to a variable
@@ -135,3 +136,89 @@
       assert:
         that:
           - yarn_uninstall_package is changed
+
+    - name: 'Global install binary with explicit version (older version of package)'
+      yarn:
+        global: true
+        executable: '{{ yarn_bin_path }}/yarn'
+        name: prettier
+        version: 2.0.0
+        state: present
+      environment:
+        PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}'
+      register: yarn_global_install_old_binary
+
+    - assert:
+        that:
+          - yarn_global_install_old_binary is changed
+
+    - name: 'Global upgrade old binary'
+      yarn:
+        global: true
+        executable: '{{ yarn_bin_path }}/yarn'
+        name: prettier
+        state: latest
+      environment:
+        PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}'
+      register: yarn_global_update_old_binary
+
+    - assert:
+        that:
+          - yarn_global_update_old_binary is changed
+
+    - name: 'Global remove a binary'
+      yarn:
+        global: true
+        executable: '{{ yarn_bin_path }}/yarn'
+        name: prettier
+        state: absent
+      environment:
+        PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}'
+      register: yarn_global_uninstall_binary
+
+    - assert:
+        that:
+          - yarn_global_uninstall_binary is changed
+
+    - name: 'Global install package with no binary with explicit version (older version of package)'
+      yarn:
+        global: true
+        executable: '{{ yarn_bin_path }}/yarn'
+        name: left-pad
+        version: 1.1.0
+        state: present
+      environment:
+        PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}'
+      register: yarn_global_install_old_package
+
+    - assert:
+        that:
+          - yarn_global_install_old_package is changed
+
+    - name: 'Global upgrade old package with no binary'
+      yarn:
+        global: true
+        executable: '{{ yarn_bin_path }}/yarn'
+        name: left-pad
+        state: latest
+      environment:
+        PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}'
+      register: yarn_global_update_old_package
+
+    - assert:
+        that:
+          - yarn_global_update_old_package is changed
+
+    - name: 'Global remove a package with no binary'
+      yarn:
+        global: true
+        executable: '{{ yarn_bin_path }}/yarn'
+        name: left-pad
+        state: absent
+      environment:
+        PATH: '{{ node_bin_path }}:{{ ansible_env.PATH }}'
+      register: yarn_global_uninstall_package
+
+    - assert:
+        that:
+          - yarn_global_uninstall_package is changed
diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt
--- a/tests/sanity/ignore-2.11.txt
+++ b/tests/sanity/ignore-2.11.txt
@@ -24,6 +24,5 @@ plugins/modules/rax_files.py validate-modules:parameter-state-invalid-choice
 plugins/modules/rax.py use-argspec-type-path # fix needed
 plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
 plugins/modules/xfconf.py validate-modules:return-syntax-error
-plugins/modules/yarn.py use-argspec-type-path
 tests/integration/targets/django_manage/files/base_test/simple_project/p1/manage.py compile-2.6   # django generated code
 tests/integration/targets/django_manage/files/base_test/simple_project/p1/manage.py compile-2.7   # django generated code
diff --git a/tests/sanity/ignore-2.12.txt b/tests/sanity/ignore-2.12.txt
--- a/tests/sanity/ignore-2.12.txt
+++ b/tests/sanity/ignore-2.12.txt
@@ -19,4 +19,3 @@ plugins/modules/rax_files.py validate-modules:parameter-state-invalid-choice
 plugins/modules/rax.py use-argspec-type-path # fix needed
 plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
 plugins/modules/xfconf.py validate-modules:return-syntax-error
-plugins/modules/yarn.py use-argspec-type-path
diff --git a/tests/sanity/ignore-2.13.txt b/tests/sanity/ignore-2.13.txt
--- a/tests/sanity/ignore-2.13.txt
+++ b/tests/sanity/ignore-2.13.txt
@@ -19,4 +19,3 @@ plugins/modules/rax_files.py validate-modules:parameter-state-invalid-choice
 plugins/modules/rax.py use-argspec-type-path # fix needed
 plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
 plugins/modules/xfconf.py validate-modules:return-syntax-error
-plugins/modules/yarn.py use-argspec-type-path
diff --git a/tests/sanity/ignore-2.14.txt b/tests/sanity/ignore-2.14.txt
--- a/tests/sanity/ignore-2.14.txt
+++ b/tests/sanity/ignore-2.14.txt
@@ -21,4 +21,3 @@ plugins/modules/rax.py use-argspec-type-path # fix needed
 plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
 plugins/modules/udm_user.py import-3.11  # Uses deprecated stdlib library 'crypt'
 plugins/modules/xfconf.py validate-modules:return-syntax-error
-plugins/modules/yarn.py use-argspec-type-path
diff --git a/tests/sanity/ignore-2.15.txt b/tests/sanity/ignore-2.15.txt
--- a/tests/sanity/ignore-2.15.txt
+++ b/tests/sanity/ignore-2.15.txt
@@ -21,4 +21,3 @@ plugins/modules/rax.py use-argspec-type-path # fix needed
 plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice
 plugins/modules/udm_user.py import-3.11  # Uses deprecated stdlib library 'crypt'
 plugins/modules/xfconf.py validate-modules:return-syntax-error
-plugins/modules/yarn.py use-argspec-type-path