Repository: infinitered/solidarity (ts)

Issue:
Shell Rule
How about a rule that allows you to run an arbitrary command and test it's output?

### Simple

* run a shell command
* trim the `stdout`
* match against a regexp

```json
{
  "rule": "shell",
  "command": "mix escript",
  "match": "No escripts currently installed.",
  "error": "No escripts allowed even though I don't know what they are."
}
```

### Advanced

* run a shell command
* trim the `stdout`
* match a subset with a regexp capture group
* convert it to a number
* run it through an expression checker

```json
{
  "rule": "shell",
  "command": "ruby -e 'puts RUBY_RELEASE_DATE'",
  "match": "-([0-9][0-9])-",
  "as": "number",
  "check": "x >= 11 or x <= 3",
  "error": "Ruby must have been released while it was snowing."
}
```

### Another Advanced

* run a shell command
* check the exit code against what you're expecting

```json
{
  "rule": "shell",
  "command": "mix deps.tree",
  "check": "exit == 0",
  "error": "Unable to list dependencies, you and/or your mix.exs is insane."
}
```

### Why?

This is a good short-term rule.

It allows some of what people want in #87 (minus the "rewrite this in a compiled language") and #107 (minus the support snapshots & custom `js` plugins).

I think it'll give people enough flexibility with adding custom rules without needing to write any `js`.

Thoughts?

I'd be up doing this work if you'd like.


Pull request description:
✨ Adds 'shell' rule for checking cli output via regexp
This adds a new `shell` rule.  For example:

```json
{
  "git email": [{ 
    "rule": "shell",
    "command": "git config user.email", 
    "match": ".+@.+"
  }]
}
```

I opted out of the expression evaluation since `bc` can do some (or all?) of it.

I also left off the exit code stuff.  I'm not sure if it's best in here or a new `exit` code rule?  Either way, I don't have an immediate need for it.  This matching stuff will probably fulfill most of the needs for now.

Closes #133 


Patch:
diff --git a/.solidarity.example.json b/.solidarity.example.json
index 1ac24e0..4dbf969 100644
--- a/.solidarity.example.json
+++ b/.solidarity.example.json
@@ -17,6 +17,13 @@
         "platform": "darwin"
       }
     ],
+    "Git email": [
+      {
+        "rule": "shell",
+        "command": "git config user.email",
+        "match": ".+@.+"
+      }
+    ],
     "Package.json": [{ "rule": "file", "location": "./package.json"}],
     "React Native": [
       { "rule": "cli", "binary": "react-native", "semver": "~2.0.1"},
diff --git a/docs/options.md b/docs/options.md
index e6fd388..512c277 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -83,6 +83,18 @@ Lastly, if output has multiple versions, you can identify the index of the versi
 ### Directory Rules
 `dir` (or `directory`) rule means for a given directory passed in the `location` property, we will verify it exists.
 
+### Shell Rules
+`shell` allows you to run a shell command and match some expected output from it's output. This is useful for 
+
+*e.g.* This checks to see if you have configured git with your email address.
+```json
+  "git email": [{ 
+    "rule": "shell",
+    "command": "git config user.email", 
+    "match": ".+@.+"
+  }]
+```
+
 ### Friendly Errors
 So what do we do if a rule fails?  The return code will be non-zero, but that's not the most friendly option.  You can set the `error` for any rule to give the user legible instruction on why the failure happened, and how they should solve it.
 
diff --git a/solidaritySchema.json b/solidaritySchema.json
index 060e83a..326aa9d 100644
--- a/solidaritySchema.json
+++ b/solidaritySchema.json
@@ -22,7 +22,8 @@
             { "$ref": "#/definitions/cli" },
             { "$ref": "#/definitions/dir" },
             { "$ref": "#/definitions/file" },
-            { "$ref": "#/definitions/env" }
+            { "$ref": "#/definitions/env" },
+            { "$ref": "#/definitions/shell" }
           ]
         },
         "minItems": 1,
@@ -80,6 +81,17 @@
         "error": { "type": "string"}
       },
       "required": ["rule", "variable"]
+    },
+    "shell": {
+      "description": "Shell Rule",
+      "type": "object",
+      "properties": {
+        "rule": { "enum": [ "shell" ] },
+        "platform": { "enum": ["darwin", "macos", "freebsd", "linux", "sunos", "win32", "windows"] },
+        "error": { "type": "string" },
+        "match": { "type": "string", "description": "A regexp to search the output." }
+      },
+      "required": ["rule", "match"]
     }
   }
 }
diff --git a/src/commands/report.ts b/src/commands/report.ts
index 46e302b..5704df1 100644
--- a/src/commands/report.ts
+++ b/src/commands/report.ts
@@ -38,6 +38,9 @@ module.exports = {
       ],
       filesystemRules: [
         ['Location', 'Type', 'Exists']
+      ],
+      shellRules: [
+        ['Command', 'Pattern', 'Matches']
       ]
     }
 
@@ -58,7 +61,7 @@ module.exports = {
         printResults(results, context)
       })
       .catch(err => {
-        print.error(err)
+        print.error(`\n\n${err}`)
         process.exit(2)
       })
 
diff --git a/src/extensions/functions/checkRequirement.ts b/src/extensions/functions/checkRequirement.ts
index 39eae5b..5b695d8 100644
--- a/src/extensions/functions/checkRequirement.ts
+++ b/src/extensions/functions/checkRequirement.ts
@@ -3,6 +3,7 @@ const checkCLI = require('./checkCLI')
 const checkENV = require('./checkENV')
 const checkDir = require('./checkDir')
 const checkFile = require('./checkFile')
+const checkShell = require('./checkShell')
 const skipRule = require('./skipRule')
 
 module.exports = async (requirement: SolidarityRequirementChunk, context: SolidarityRunContext): Promise<void | object[]> => {
@@ -95,6 +96,17 @@ module.exports = async (requirement: SolidarityRequirementChunk, context: Solida
         } else {
           return addFailure(rule.error || `'${rule.location}' file not found`)
         }
+      // Handle the shell rule
+      case 'shell':
+        const shellResult = await checkShell(rule, context)
+        if (shellResult) {
+          ruleString = `${requirementName} - '${rule.command}' matches '${rule.match}'`
+          printResult(true, ruleString)
+          return []
+        } else {
+          return addFailure(rule.error || `'${rule.command}' output did not match '${rule.match}'`)
+        }
+
       default:
         return addFailure(`Encountered unknown rule`)
     }
diff --git a/src/extensions/functions/checkShell.ts b/src/extensions/functions/checkShell.ts
new file mode 100644
index 0000000..b073e46
--- /dev/null
+++ b/src/extensions/functions/checkShell.ts
@@ -0,0 +1,27 @@
+import { ShellRule, SolidarityRunContext } from '../../types'
+
+module.exports = async (
+  rule: ShellRule,
+  context: SolidarityRunContext
+): Promise<boolean> => {
+  const { system, strings } = context
+  try {
+    // execute the command
+    const exec = await system.spawn(rule.command, { shell: true })
+    const { stdout = '' } = exec
+
+    // clean it up
+    const output = strings.trimEnd(stdout.toString())
+
+    // look for matches
+    let isMatch: boolean = false
+    const match = rule.match && new RegExp(rule.match)
+    if (match) {
+      isMatch = match.test(output)
+    }
+
+    return isMatch
+  } catch (e) {
+    return false
+  }
+}
diff --git a/src/extensions/functions/printResults.ts b/src/extensions/functions/printResults.ts
index 0ea5747..e062f55 100644
--- a/src/extensions/functions/printResults.ts
+++ b/src/extensions/functions/printResults.ts
@@ -16,5 +16,6 @@ module.exports = (results: SolidarityReportResults, context: SolidarityRunContex
   printIfData(results.cliRules, 'Commands')
   printIfData(results.envRules, 'Environment Variables')
   printIfData(results.filesystemRules, 'Filesystem')
+  printIfData(results.shellRules, 'Shell Checks')
   printSeparator()
 }
diff --git a/src/extensions/functions/reviewRule.ts b/src/extensions/functions/reviewRule.ts
index dcc7440..9d98cb9 100644
--- a/src/extensions/functions/reviewRule.ts
+++ b/src/extensions/functions/reviewRule.ts
@@ -4,6 +4,7 @@ import { tail, pipe, flatten, map } from 'ramda'
 const skipRule = require('./skipRule')
 const checkDir = require('./checkDir')
 const checkFile = require('./checkFile')
+const checkShell = require('./checkShell')
 
 module.exports = async (requirement: SolidarityRequirementChunk, report: SolidarityReportResults, context: SolidarityRunContext) => {
   const { print, system, solidarity } = context
@@ -50,6 +51,10 @@ module.exports = async (requirement: SolidarityRequirementChunk, report: Solidar
         const fileExists = prettyBool(checkFile(rule, context))
         report.filesystemRules.push([rule.location, 'File', fileExists])
         break
+      case 'shell':
+        const shellCheckPass = prettyBool(await checkShell(rule, context))
+        report.shellRules.push([rule.command, rule.match, shellCheckPass])
+        break
       default:
         throw 'Encountered unknown rule'
     }
diff --git a/src/types.ts b/src/types.ts
index 2bc8b6e..dc3d09e 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -59,8 +59,37 @@ export interface FSRule {
   readonly platform?: string | string[]
 }
 
+/**
+ * Runs a shell command and evaluates the output is what we expect.
+ */
+export interface ShellRule {
+  /**
+   * We're a shell rule.
+   */
+  readonly rule: 'shell'
+  /**
+   * The command to execute.
+   */
+  readonly command: string
+  /**
+   * A regular expression to match the output against.
+   *
+   * If you add a capture group, it will only target that instead of the whole
+   * expression. This is great for selecting a subset.
+   */
+  readonly match: string
+  /**
+   * An optional error message to override.
+   */
+  readonly error?: string
+  /**
+   * An optional platform or platforms to target.
+   */
+  readonly platform?: string | string[]
+}
+
 // discriminated union for rule sets
-export type SolidarityRule = CLIRule | ENVRule | FSRule
+export type SolidarityRule = CLIRule | ENVRule | FSRule | ShellRule
 
 export enum SolidarityOutputMode {
   MODERATE,
@@ -89,4 +118,5 @@ export interface SolidarityReportResults {
   cliRules: Array<Array<string>>
   envRules: Array<Array<string>>
   filesystemRules: Array<Array<string>>
+  shellRules: Array<Array<string>>
 }


Test patch:
diff --git a/__tests__/command_helpers/checkRequirement.ts b/__tests__/command_helpers/checkRequirement.ts
index b7eb31f..9649b61 100644
--- a/__tests__/command_helpers/checkRequirement.ts
+++ b/__tests__/command_helpers/checkRequirement.ts
@@ -1,5 +1,6 @@
 import { SolidarityRequirement } from '../../dist/types'
 import { toPairs } from 'ramda'
+import { strings } from 'gluegun'
 
 import checkRequirement from '../../src/extensions/functions/checkRequirement'
 import solidarityExtension from '../../src/extensions/solidarity-extension'
@@ -46,6 +47,13 @@ describe('checkRequirement', () => {
       spin: jest.fn(() => spinner),
       error: jest.fn()
     }
+    context.strings = strings
+    context.system = {
+      spawn: jest.fn().mockReturnValue(Promise.resolve({
+        stdout: 'you did it!',
+        status: 0,
+      }))
+    }
   })
 
   test('there is a spinner message', async () => {
@@ -206,5 +214,14 @@ describe('checkRequirement', () => {
       const result = await checkRequirement(rule, context)
       expect(result).toEqual([customError])
     })
+
+    test('failed SHELL rule with custom message', async () => {
+      const rule = toPairs({
+        YARN: [{ rule: 'shell', match: 'hello', command: 'mocked', error: customError }]
+      })[0]
+      const result = await checkRequirement(rule, context)
+      expect(result).toEqual([customError])
+    })
+
   })
 })
diff --git a/__tests__/command_helpers/checkShell.ts b/__tests__/command_helpers/checkShell.ts
new file mode 100644
index 0000000..ec1a9b9
--- /dev/null
+++ b/__tests__/command_helpers/checkShell.ts
@@ -0,0 +1,69 @@
+import { ShellRule, SolidarityRunContext } from '../../src/types'
+import { strings } from 'gluegun'
+const checkShell: any = require('../../src/extensions/functions/checkShell')
+
+/**
+ * Creates a mock gluegun environment in which `checkShell` runs.
+ *
+ * @param stdout The stdout to mock return
+ * @param status The exit code to mock return (default: 0)
+ */
+const createContext = (stdout: string, status: number = 0) => ({
+  system: {
+    spawn: jest.fn().mockReturnValue(Promise.resolve({ stdout, status }))
+  },
+  strings
+})
+
+describe('match', () => {
+  describe('seamingly just a string', () => {
+    const context = createContext('hi')
+    it('matches exact', async () => {
+      expect(await checkShell({ match: 'hi' }, context)).toBe(true)
+    })
+
+    it('detects no matches', async () => {
+      expect(await checkShell({ match: 'bye' }, context)).toBe(false)
+    })
+  })
+
+  describe('regexp basics', () => {
+    const context = createContext("Wow, you don't look a day over 100!")
+
+    it('finds matches', async () => {
+      expect(await checkShell({ match: '100!$' }, context)).toBe(true)
+    })
+
+    it('detects no matches', async () => {
+      expect(await checkShell({ match: '200!$' }, context)).toBe(false)
+    })
+
+    it('works with capture groups', async () => {
+      expect(await checkShell({ match: '.*(look).*(100).*' }, context)).toBe(
+        true
+      )
+    })
+  })
+
+  describe('capture group', () => {
+    const context = createContext("Wow, you don't look a day over 100!")
+    it('capture groups with ', async () => {
+      expect(await checkShell({ match: '.*(look).*' }, context)).toBe(true)
+    })
+  })
+
+  describe('crazy inputs', () => {
+    const context = createContext('hi')
+    const expectBadMatch = (input: any) => async () => {
+      expect(await checkShell({ match: input }, context)).toBe(false)
+    }
+
+    test('null', expectBadMatch(null))
+    test('undefined', expectBadMatch(undefined))
+    test('number', expectBadMatch(69))
+    test('boolean', expectBadMatch(true))
+    test('object', expectBadMatch({ omg: 'lol' }))
+    test('array', expectBadMatch([1, 2, 3]))
+    test('function', expectBadMatch(() => '💩'))
+  })
+})
diff --git a/__tests__/command_helpers/printResults.ts b/__tests__/command_helpers/printResults.ts
index 6bcb93c..69fe441 100644
--- a/__tests__/command_helpers/printResults.ts
+++ b/__tests__/command_helpers/printResults.ts
@@ -21,6 +21,9 @@ describe('reviewRule', () => {
       ],
       filesystemRules: [
         ['Location', 'Type', 'Exists']
+      ],
+      shellRules: [
+        ['Command', 'Pattern', 'Matches']
       ]
     }
   })
diff --git a/__tests__/command_helpers/reviewRule.ts b/__tests__/command_helpers/reviewRule.ts
index 8fa64eb..c94f3eb 100644
--- a/__tests__/command_helpers/reviewRule.ts
+++ b/__tests__/command_helpers/reviewRule.ts
@@ -20,6 +20,9 @@ describe('reviewRule', () => {
       ],
       filesystemRules: [
         ['Location', 'Type', 'Exists']
+      ],
+      shellRules: [
+        ['Command', 'Pattern', 'Matches']
       ]
     }
   })
@@ -63,4 +66,14 @@ describe('reviewRule', () => {
       expect(reportResults.filesystemRules.length).toBe(2)
     })
   })
+
+  describe('when rule: shell', () => {
+    test('rule gets added' async () => {
+      const rule = ['SHELL', [{ rule: 'shell', command: 'ls', match: '.+' }]]
+
+      const result = await reviewRule(rule, reportResults, mockContext)
+      // CLI rule was added
+      expect(reportResults.shellRules.length).toBe(2)
+    })
+  })
 })