Repository: intuit/auto (ts)

Issue:
Documentation: how to install and use unofficial plugins with binary bundle
I am looking to use auto with multiple projects, including maven (which has a plugin) and sbt (which does not). The maven plugin is not considered "official" and therefore will not be bundled with the binary distribution.

**Describe the solution you'd like**
I'd like the documentation to outline the recommended method for installing the unofficial plugins for use by a binary installation of auto.

**Describe alternatives you've considered**
I've considered forking the auto repo and building my own bundle, but that seems like overkill. I also note the `--plugins string[]` command line parameter, but there's no discoverability there (there should maybe be a `--list-plugins` to show what's available) nor does the documentation speak to a method for "loading" auto with an unofficial plugin.

**Additional context**
Also, planning on including auto as a binary in our self-hosted Docker-in-Docker [github-runner](https://github.com/terradatum/github-runner).

Pull request description:
Add `--list-plugins` flag to `info` command
# What Changed

see title.

# Why

closes #1102

Todo:

- [ ] Add tests
- [x] Add docs

<!-- GITHUB_RELEASE PR BODY: canary-version -->
<details>
  <summary>📦 Published PR as canary version: <code>under canary scope @auto-canary@9.24.0-canary.1103.14427.0</code></summary>
  <br />
  
  :sparkles: Test out this PR locally via:
  
  ```sh
  npm install @auto-canary/bot-list@9.24.0-canary.1103.14427.0
  npm install @auto-canary/auto@9.24.0-canary.1103.14427.0
  npm install @auto-canary/core@9.24.0-canary.1103.14427.0
  npm install @auto-canary/all-contributors@9.24.0-canary.1103.14427.0
  npm install @auto-canary/brew@9.24.0-canary.1103.14427.0
  npm install @auto-canary/chrome@9.24.0-canary.1103.14427.0
  npm install @auto-canary/cocoapods@9.24.0-canary.1103.14427.0
  npm install @auto-canary/conventional-commits@9.24.0-canary.1103.14427.0
  npm install @auto-canary/crates@9.24.0-canary.1103.14427.0
  npm install @auto-canary/exec@9.24.0-canary.1103.14427.0
  npm install @auto-canary/first-time-contributor@9.24.0-canary.1103.14427.0
  npm install @auto-canary/gh-pages@9.24.0-canary.1103.14427.0
  npm install @auto-canary/git-tag@9.24.0-canary.1103.14427.0
  npm install @auto-canary/gradle@9.24.0-canary.1103.14427.0
  npm install @auto-canary/jira@9.24.0-canary.1103.14427.0
  npm install @auto-canary/maven@9.24.0-canary.1103.14427.0
  npm install @auto-canary/npm@9.24.0-canary.1103.14427.0
  npm install @auto-canary/omit-commits@9.24.0-canary.1103.14427.0
  npm install @auto-canary/omit-release-notes@9.24.0-canary.1103.14427.0
  npm install @auto-canary/released@9.24.0-canary.1103.14427.0
  npm install @auto-canary/s3@9.24.0-canary.1103.14427.0
  npm install @auto-canary/slack@9.24.0-canary.1103.14427.0
  npm install @auto-canary/twitter@9.24.0-canary.1103.14427.0
  npm install @auto-canary/upload-assets@9.24.0-canary.1103.14427.0
  # or 
  yarn add @auto-canary/bot-list@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/auto@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/core@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/all-contributors@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/brew@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/chrome@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/cocoapods@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/conventional-commits@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/crates@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/exec@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/first-time-contributor@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/gh-pages@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/git-tag@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/gradle@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/jira@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/maven@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/npm@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/omit-commits@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/omit-release-notes@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/released@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/s3@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/slack@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/twitter@9.24.0-canary.1103.14427.0
  yarn add @auto-canary/upload-assets@9.24.0-canary.1103.14427.0
  ```
</details>
<!-- GITHUB_RELEASE PR BODY: canary-version -->


Patch:
diff --git a/docs/pages/non-npm.md b/docs/pages/non-npm.md
index ea9e7d19..eb6c7fb7 100644
--- a/docs/pages/non-npm.md
+++ b/docs/pages/non-npm.md
@@ -4,7 +4,8 @@
 
 ## Installation
 
-If you're on some platform other than [npm](https://npmjs.com) it doesn't make sense to download `auto` through [npm](https://npmjs.com). For these situations we package `auto` for all major operating systems (`linux`, `osx`, `windows`).
+If you're on some platform other than [npm](https://npmjs.com) it doesn't make sense to download `auto` through [npm](https://npmjs.com).
+For these situations we package `auto` for all major operating systems (`linux`, `osx`, `windows`).
 
 Simply download the appropriate version for your operating system and make it executable.
 
@@ -15,7 +16,9 @@ curl -vkL -o - https://github.com/intuit/auto/releases/download/v7.2.0/auto-linu
 chmod a+x ~/auto
 ```
 
-This executable contains all of the official `auto` plugins so you do not have to download anything extra. This version of `auto` uses the [git-tag](../../plugins/git-tag/README.md) plugins as the default instead of [npm](../../plugins/npm/README.md). If you specify any plugins this will be overriden.
+This executable contains all of the official `auto` plugins so you do not have to download anything extra.
+This version of `auto` uses the [git-tag](../../plugins/git-tag/README.md) plugins as the default instead of [npm](../../plugins/npm/README.md).
+If you specify any plugins this will be overridden.
 
 ### Installation via brew (OSX)
 
@@ -76,3 +79,61 @@ Now that you have `auto` all set up you can use all of it's commands!
 ::: message is-info
 ℹ️ Tip: Using `auto` locally with an `.env` file is a great experience. See how [here](./getting-started.md#local-.env).
 :::
+
+## Plugins
+
+As stated above, all of the official plugins (the ones included in this repo/docs) are included in the binary version of `auto`.
+However you might want to use an unofficial plugin or even write your own.
+
+#### Local Plugins
+
+One way to use unofficial plugins in a non-javascript project is to have a directory committed to your project where you store them.
+
+You could:
+
+- Write your own custom plugins and store them in here
+- Copy a plugin from the internet
+
+**`.autorc`:**
+
+```json
+{
+  "plugins": ["./plugins/custom-plugin.js"]
+}
+```
+
+#### Hosted Plugins
+
+If you want to share your custom plugins between projects then you can host them on some SCM (ex: GitHub).
+A plugin just needs to consist of an `index.js` that implements your plugin. (or it could be as complex as any NPM package)
+
+Edit your CI build to download these plugins when building.
+
+```sh
+mkdir plugins
+git clone https://github.com/me/custom-plugin plugins/custom-plugin
+```
+
+**`.autorc`:**
+
+```json
+{
+  "plugins": ["./plugins/custom-plugin"]
+}
+```
+
+#### NPM Plugins
+
+If distributed via NPM you could install those plugins globally through `npm`:
+
+```sh
+npm i -g auto-plugin-my-custom-plugin
+```
+
+**`.autorc`:**
+
+```json
+{
+  "plugins": ["my-custom-plugin"]
+}
+```
diff --git a/packages/cli/src/parse-args.ts b/packages/cli/src/parse-args.ts
index 23f5e8c7..14ce71a9 100644
--- a/packages/cli/src/parse-args.ts
+++ b/packages/cli/src/parse-args.ts
@@ -187,8 +187,16 @@ export const commands: AutoCommand[] = [
     name: "info",
     group: "Setup Command",
     description:
-      "Determine the environment and check if auto is set up correctly",
+      "Determine the environment, check if auto is set up correctly, and list plugins.",
     examples: ["{green $} auto info"],
+    options: [
+      {
+        name: "list-plugins",
+        type: Boolean,
+        description: "List the available plugins",
+        group: "main",
+      },
+    ],
   },
   {
     name: "create-labels",
diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts
index fb169222..5f9eb093 100644
--- a/packages/cli/src/run.ts
+++ b/packages/cli/src/run.ts
@@ -2,6 +2,7 @@
 
 import Auto, {
   ApiOptions,
+  IInfoOptions,
   ICanaryOptions,
   IChangelogOptions,
   ICommentOptions,
@@ -35,7 +36,7 @@ export async function run(command: string, args: ApiOptions) {
       try {
         // We don't want auto.info throwing an error during another
         // command
-        const { hasError } = await auto.info();
+        const { hasError } = await auto.info(args as IInfoOptions);
 
         if (command === "info") {
           // eslint-disable-next-line max-depth
diff --git a/packages/core/src/auto-args.ts b/packages/core/src/auto-args.ts
index 4e8c5c40..3fe6169c 100644
--- a/packages/core/src/auto-args.ts
+++ b/packages/core/src/auto-args.ts
@@ -126,6 +126,11 @@ export interface INextOptions {
   message?: string;
 }
 
+export interface IInfoOptions {
+  /** List some of the available plugins */
+  listPlugins?: boolean;
+}
+
 export type GlobalOptions = {
   /** Plugins to initialize "auto" with */
   plugins?: string[];
@@ -134,6 +139,7 @@ export type GlobalOptions = {
 
 export type ApiOptions = GlobalOptions &
   (
+    | IInfoOptions
     | ICreateLabelsOptions
     | ILabelOptions
     | IPRCheckOptions
diff --git a/packages/core/src/auto.ts b/packages/core/src/auto.ts
index c7f8690c..839b8a55 100644
--- a/packages/core/src/auto.ts
+++ b/packages/core/src/auto.ts
@@ -22,6 +22,7 @@ import on from "await-to-js";
 import createHttpsProxyAgent from "https-proxy-agent";
 import {
   ApiOptions,
+  IInfoOptions,
   ICanaryOptions,
   IChangelogOptions,
   ICommentOptions,
@@ -43,7 +44,7 @@ import LogParse, { IExtendedCommit } from "./log-parse";
 import Release, { getVersionMap, ILabelDefinition } from "./release";
 import SEMVER, { calculateSemVerBump, IVersionLabels } from "./semver";
 import execPromise from "./utils/exec-promise";
-import loadPlugin, { IPlugin } from "./utils/load-plugins";
+import { loadPlugin, IPlugin, listPlugins } from "./utils/load-plugins";
 import createLog, { ILogger, setLogLevel } from "./utils/logger";
 import { makeHooks } from "./utils/make-hooks";
 import { getCurrentBranch } from "./utils/get-current-branch";
@@ -409,6 +410,15 @@ export default class Auto {
     this.logger.verbose.info("ENV:", env);
   }
 
+  /** List some of the plugins available to auto */
+  private async listPlugins() {
+    await listPlugins(
+      this.config!,
+      this.logger,
+      this.getExtendedLocation(this.config!)
+    );
+  }
+
   /**
    * Load the default hook behaviors. Should run after loadPlugins so
    * plugins take precedence.
@@ -587,7 +597,7 @@ export default class Auto {
   }
 
   /** Check if auto is set up correctly */
-  async info() {
+  async info(args: IInfoOptions) {
     if (!this.git) {
       return { hasError: false };
     }
@@ -678,6 +688,10 @@ export default class Auto {
     `);
     console.log("");
 
+    if (args.listPlugins) {
+      await this.listPlugins();
+    }
+
     return { hasError };
   }
 
@@ -1790,6 +1804,21 @@ export default class Auto {
     return author;
   }
 
+  /** Find the location of the extended configuration */
+  private getExtendedLocation(config: AutoRc) {
+    let extendedLocation: string | undefined;
+
+    try {
+      if (config.extends) {
+        extendedLocation = require.resolve(config.extends);
+      }
+    } catch (error) {
+      this.logger.veryVerbose.error(error);
+    }
+
+    return extendedLocation;
+  }
+
   /**
    * Apply all of the plugins in the config.
    */
@@ -1800,15 +1829,7 @@ export default class Auto {
       require.resolve("./plugins/filter-non-pull-request"),
       ...(Array.isArray(config.plugins) ? config.plugins : defaultPlugins),
     ];
-    let extendedLocation: string | undefined;
-
-    try {
-      if (config.extends) {
-        extendedLocation = require.resolve(config.extends);
-      }
-    } catch (error) {
-      this.logger.veryVerbose.error();
-    }
+    const extendedLocation = this.getExtendedLocation(config);
 
     pluginsPaths
       .map((plugin) =>
diff --git a/packages/core/src/init.ts b/packages/core/src/init.ts
index 242f43fe..383f09ab 100644
--- a/packages/core/src/init.ts
+++ b/packages/core/src/init.ts
@@ -7,7 +7,7 @@ import { AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from "tapable";
 import { makeInteractiveInitHooks } from "./utils/make-hooks";
 import { defaultLabels, ILabelDefinition } from "./release";
 import SEMVER from "./semver";
-import loadPlugin from "./utils/load-plugins";
+import { loadPlugin } from "./utils/load-plugins";
 import { ILogger } from "./utils/logger";
 import { readFileSync, writeFileSync } from "fs";
 import {
diff --git a/packages/core/src/utils/load-plugins.ts b/packages/core/src/utils/load-plugins.ts
index f824643e..b40c9ad7 100644
--- a/packages/core/src/utils/load-plugins.ts
+++ b/packages/core/src/utils/load-plugins.ts
@@ -1,10 +1,14 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
+import { execSync } from "child_process";
+import chalk from "chalk";
+import endent from "endent";
 
 import * as path from "path";
 import Auto from "../auto";
 import { ILogger } from "./logger";
 import tryRequire from "./try-require";
 import InteractiveInit from "../init";
+import { LoadedAutoRc } from "../types";
 
 export type IPluginConstructor = new (options?: any) => IPlugin;
 
@@ -18,6 +22,59 @@ export interface IPlugin {
   apply(auto: Auto): void;
 }
 
+const pluginPatterns = ["@auto-it", "@auto-canary", "auto-plugin-"].map(
+  (p) => new RegExp(`${p}\\/([a-zA-Z-_\\.]+)$`)
+);
+const excluded = ["core", "cli", "bot-list"];
+
+export interface InstalledModule {
+  /** The name of the module */
+  name: string;
+  /** The path to the module */
+  path: string;
+}
+
+/** Get the paths of available installed plugins. */
+export const getInstalledPlugins = (global = false): InstalledModule[] => {
+  let modules: string[] = [];
+
+  try {
+    const stdout = execSync(`npm2 ls --parseable ${global ? "--global" : ""}`, {
+      encoding: "utf8",
+      stdio: ["pipe", "pipe", "ignore"],
+    });
+
+    modules = stdout.split("\n");
+  } catch (error) {
+    console.log(error);
+    modules = error.stdout.split("\n");
+  }
+
+  return modules
+    .map((p) => {
+      if (!pluginPatterns.some((pattern) => p.match(pattern))) {
+        return undefined;
+      }
+
+      const parts = p.split("node_modules/");
+      const name = parts[parts.length - 1]
+        .replace("@auto-it/", "")
+        .replace("@auto-canary/", "");
+
+      if (excluded.includes(name)) {
+        return undefined;
+      }
+
+      return {
+        name,
+        path: p,
+      };
+    })
+    .filter((m: InstalledModule | undefined): m is InstalledModule =>
+      Boolean(m)
+    );
+};
+
 /** Require a plugin and log where it was found. */
 function requirePlugin(
   pluginPath: string,
@@ -30,85 +87,109 @@ function requirePlugin(
     logger.verbose.info(`Found plugin using: ${pluginPath}`);
   }
 
-  return plugin as IPluginConstructor;
+  return plugin as
+    | IPluginConstructor
+    | {
+        /** The plugin under the default export */
+        default: IPluginConstructor;
+      }
+    | undefined;
 }
 
 /** Try to load a plugin in various ways */
-export default function loadPlugin(
-  [pluginPath, options]: [string, any],
+export function findPlugin(
+  pluginPath: string,
   logger: ILogger,
   extendedLocation?: string
-): IPlugin | undefined {
+): string | undefined {
   const isLocal =
     pluginPath.startsWith(".") ||
     pluginPath.startsWith("/") ||
     pluginPath.match(/^[A-Z]:\\/); // Support for windows paths
 
   /** Attempt to require a plugin */
-  const attempt = (p: string) => requirePlugin(p, logger, extendedLocation);
-
-  let plugin:
-    | IPluginConstructor
-    | {
-        /** The plugin under the default export */
-        default: IPluginConstructor;
-      }
-    | undefined;
+  const exists = (p: string) =>
+    Boolean(requirePlugin(p, logger, extendedLocation));
 
   // Try requiring a path
-  if (isLocal) {
-    plugin = attempt(pluginPath);
+  if (isLocal && exists(pluginPath)) {
+    return pluginPath;
   }
 
   // Try requiring a path from cwd
-  if (!plugin && isLocal) {
+  if (isLocal) {
     const localPath = path.join(process.cwd(), pluginPath);
-    plugin = attempt(localPath);
 
-    if (!plugin) {
+    if (!exists(localPath)) {
       logger.log.warn(`Could not find plugin from path: ${localPath}`);
       return;
     }
+
+    return localPath;
   }
 
   // For pkg bundle
-  if (!plugin) {
-    const pkgPath = path.join(
-      __dirname,
-      "../../../../../plugins/",
-      pluginPath,
-      "dist/index.js"
-    );
-    plugin = attempt(pkgPath);
+  const pkgPath = path.join(
+    __dirname,
+    "../../../../../plugins/",
+    pluginPath,
+    "dist/index.js"
+  );
+
+  if (exists(pkgPath)) {
+    return pkgPath;
   }
 
+  const userPlugin = `auto-plugin-${pluginPath}`;
+
   // For a user created plugin
-  if (!plugin) {
-    plugin = attempt(`auto-plugin-${pluginPath}`);
+  if (exists(userPlugin)) {
+    return userPlugin;
   }
 
+  const officialPlugin = path.join("@auto-it", pluginPath);
+
   // Try importing official plugin
-  if (!plugin) {
-    plugin = attempt(path.join("@auto-it", pluginPath));
+  if (exists(officialPlugin)) {
+    return officialPlugin;
   }
 
-  // Try importing canary version of plugin
-  if (!plugin) {
-    plugin = attempt(path.join("@auto-canary", pluginPath));
+  const canaryPlugin = path.join("@auto-canary", pluginPath);
+
+  // Try importing official plugin
+  if (exists(canaryPlugin)) {
+    return canaryPlugin;
   }
 
   // Try requiring a package
   if (
-    !plugin &&
-    (pluginPath.includes("/auto-plugin-") ||
-      pluginPath.startsWith("auto-plugin-") ||
-      pluginPath.startsWith("@auto-it"))
+    pluginPath.includes("/auto-plugin-") ||
+    pluginPath.startsWith("auto-plugin-") ||
+    pluginPath.startsWith("@auto-it")
   ) {
-    plugin = attempt(pluginPath);
+    if (exists(pluginPath)) {
+      return pluginPath;
+    }
+  }
+
+  logger.log.warn(`Could not find plugin: ${pluginPath}`);
+}
+
+/** Try to load a plugin in various ways */
+export function loadPlugin(
+  [pluginPath, options]: [string, any],
+  logger: ILogger,
+  extendedLocation?: string
+): IPlugin | undefined {
+  const localPluginPath = findPlugin(pluginPath, logger, extendedLocation);
+
+  if (!localPluginPath) {
+    return;
   }
 
+  const plugin = requirePlugin(localPluginPath, logger, extendedLocation);
+
   if (!plugin) {
-    logger.log.warn(`Could not find plugin: ${pluginPath}`);
     return;
   }
 
@@ -126,3 +207,58 @@ export default function loadPlugin(
     throw error;
   }
 }
+
+/** Print a list of plugins */
+const printPlugins = (title: string, modules: InstalledModule[]) => {
+  if (!modules.length) {
+    return;
+  }
+
+  console.log(endent`
+    ${chalk.underline.white(title)}
+
+    ${modules.map((plugin) => `- ${plugin.name} (${plugin.path})`).join("\n")}
+  `);
+  console.log("");
+};
+
+/** List some of the plugins available to auto */
+export const listPlugins = async (
+  { plugins = [] }: LoadedAutoRc,
+  logger: ILogger,
+  extendedLocation?: string
+) => {
+  printPlugins(
+    "Found the following plugins in your .autorc:",
+    plugins.map((plugin) => {
+      if (typeof plugin === "string") {
+        return {
+          name: plugin,
+          path: findPlugin(plugin, logger, extendedLocation) || "",
+        };
+      }
+
+      return {
+        name: plugin[0],
+        path: findPlugin(plugin[0], logger, extendedLocation) || "",
+      };
+    })
+  );
+
+  printPlugins(
+    "Found the following plugins installed in your project:",
+    getInstalledPlugins().map((installed) => ({
+      ...installed,
+      path: path.relative(process.cwd(), installed.path),
+    }))
+  );
+
+  printPlugins(
+    "Found the following plugins globally installed in your environment:",
+    getInstalledPlugins(true)
+  );
+
+  logger.log.note(
+    'There might be other plugins available as files on your machine. This flag can only list plugins from your .autorc or plugins managed with "npm".'
+  );
+};


Test patch:
diff --git a/packages/core/src/__tests__/auto.test.ts b/packages/core/src/__tests__/auto.test.ts
index d9bcedef..533179ec 100644
--- a/packages/core/src/__tests__/auto.test.ts
+++ b/packages/core/src/__tests__/auto.test.ts
@@ -3,7 +3,7 @@ import { IPRStatusOptions } from "../auto-args";
 import SEMVER from "../semver";
 import { dummyLog } from "../utils/logger";
 import makeCommitFromMsg from "./make-commit-from-msg";
-import loadPlugin from "../utils/load-plugins";
+import { loadPlugin } from "../utils/load-plugins";
 import child from "child_process";
 
 const importMock = jest.fn();
diff --git a/packages/core/src/utils/__tests__/__snapshots__/load-plugin.test.ts.snap b/packages/core/src/utils/__tests__/__snapshots__/load-plugin.test.ts.snap
new file mode 100644
index 00000000..7239585f
--- /dev/null
+++ b/packages/core/src/utils/__tests__/__snapshots__/load-plugin.test.ts.snap
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`listPlugins should get plugins from autorc 1`] = `
+Array [
+  "Found the following plugins in your .autorc:
+
+- npm (@auto-it/npm)",
+]
+`;
+
+exports[`listPlugins should get plugins from local module 1`] = `
+Array [
+  "Found the following plugins in your .autorc:
+
+- npm (@auto-it/npm)",
+]
+`;
+
+exports[`listPlugins should get plugins from local module 2`] = `
+Array [
+  "Found the following plugins installed in your project:
+
+- npm (../some/folder/node_modules/@auto-it/npm)
+- released (../some/folder/node_modules/@auto-it/released)",
+]
+`;
+
+exports[`listPlugins should get plugins from local module 3`] = `
+Array [
+  "Found the following plugins globally installed in your environment:
+
+- npm (/some/folder/node_modules/@auto-it/npm)
+- released (/some/folder/node_modules/@auto-it/released)",
+]
+`;
diff --git a/packages/core/src/utils/__tests__/load-plugin-canary.test.ts b/packages/core/src/utils/__tests__/load-plugin-canary.test.ts
index 28498732..9c5b3887 100644
--- a/packages/core/src/utils/__tests__/load-plugin-canary.test.ts
+++ b/packages/core/src/utils/__tests__/load-plugin-canary.test.ts
@@ -1,4 +1,4 @@
-import loadPlugin from "../load-plugins";
+import { loadPlugin } from "../load-plugins";
 import { dummyLog } from "../logger";
 
 const logger = dummyLog();
diff --git a/packages/core/src/utils/__tests__/load-plugin-windows.test.ts b/packages/core/src/utils/__tests__/load-plugin-windows.test.ts
index 6bab6ed7..dcfaad6c 100644
--- a/packages/core/src/utils/__tests__/load-plugin-windows.test.ts
+++ b/packages/core/src/utils/__tests__/load-plugin-windows.test.ts
@@ -1,4 +1,4 @@
-import loadPlugin from "../load-plugins";
+import { loadPlugin } from "../load-plugins";
 import { dummyLog } from "../logger";
 
 const logger = dummyLog();
diff --git a/packages/core/src/utils/__tests__/load-plugin.test.ts b/packages/core/src/utils/__tests__/load-plugin.test.ts
index d1448428..60c20610 100644
--- a/packages/core/src/utils/__tests__/load-plugin.test.ts
+++ b/packages/core/src/utils/__tests__/load-plugin.test.ts
@@ -1,5 +1,8 @@
 import path from "path";
-import loadPlugin from "../load-plugins";
+import endent from "endent";
+import { execSync } from "child_process";
+
+import { getInstalledPlugins, loadPlugin, listPlugins } from "../load-plugins";
 import { dummyLog } from "../logger";
 
 const logger = dummyLog();
@@ -87,3 +90,90 @@ describe("loadPlugins", () => {
     );
   });
 });
+
+const exec = jest.fn();
+
+jest.mock("child_process");
+// @ts-ignore
+execSync.mockImplementation(exec);
+
+describe("getInstalledPlugins", () => {
+  test("should find local modules", () => {
+    exec.mockReturnValueOnce(
+      "/some/folder/node_modules/@auto-it/npm\n/some/folder/node_modules/@auto-it/released"
+    );
+
+    expect(getInstalledPlugins()).toStrictEqual([
+      {
+        name: "npm",
+        path: "/some/folder/node_modules/@auto-it/npm",
+      },
+      {
+        name: "released",
+        path: "/some/folder/node_modules/@auto-it/released",
+      },
+    ]);
+  });
+
+  test("should exclude non plugins", () => {
+    exec.mockReturnValueOnce(endent`
+      /some/folder/node_modules/@auto-it/npm
+      /some/folder/node_modules/@auto-it/core
+      /some/folder/node_modules/autoprefixer
+    `);
+
+    expect(getInstalledPlugins()).toStrictEqual([
+      {
+        name: "npm",
+        path: "/some/folder/node_modules/@auto-it/npm",
+      },
+    ]);
+  });
+
+  test("should find modules even with errors", () => {
+    exec.mockImplementationOnce(() => {
+      const error = new Error();
+      // @ts-ignore
+      error.stdout =
+        "/some/folder/node_modules/@auto-it/gradle\n/some/folder/node_modules/@auto-it/chrome";
+      throw error;
+    });
+
+    expect(getInstalledPlugins(true)).toStrictEqual([
+      {
+        name: "gradle",
+        path: "/some/folder/node_modules/@auto-it/gradle",
+      },
+      {
+        name: "chrome",
+        path: "/some/folder/node_modules/@auto-it/chrome",
+      },
+    ]);
+  });
+});
+
+const log = jest.fn();
+console.log = log;
+
+describe("listPlugins", () => {
+  beforeEach(() => {
+    log.mockClear();
+  });
+
+  test("should get plugins from autorc", async () => {
+    exec.mockReturnValue("");
+    await listPlugins({ plugins: ["npm"] } as any, dummyLog());
+    expect(log.mock.calls[0]).toMatchSnapshot();
+  });
+
+  test("should get plugins from local module", async () => {
+    process.cwd = () => "/home";
+    exec.mockReturnValue(
+      "/some/folder/node_modules/@auto-it/npm\n/some/folder/node_modules/@auto-it/released"
+    );
+    await listPlugins({ plugins: ["npm"] } as any, dummyLog());
+    expect(log.mock.calls[0]).toMatchSnapshot();
+    expect(log.mock.calls[2]).toMatchSnapshot();
+    expect(log.mock.calls[4]).toMatchSnapshot();
+  });
+});