jsoma/mcpyver

View on GitHub
src/executables/executable.js

Summary

Maintainability
A
2 hrs
Test Coverage
import { execFile } from '../executive'
import { stat, realpathSync } from 'fs'
import { which } from 'shelljs'
import ExecutableCollection from './executable_collection'
import { glob } from 'glob'
import { homedir } from 'os'
import { join, normalize } from 'path'
import trueCasePathSync from 'true-case-path'

/**
 * A specific file on a computer that
 * can be run
*/
export default class Executable {

  /**
   * @param {string} path the full path to the binary
   * @param {string} command a command that executes this file
   * @param {boolean} isDefault whether the command is first in the PATH
   * @todo should prob get rid of command isDefault,
   * replace with init + .addCommand?
   */
  constructor (path, command, isDefault) {
    /**
     * Any paths that you can find this executable at (symlinked or otherwise)
     * @type {string[]} path
     */
    this.paths = path ? [ trueCasePathSync(path) || path ] : []

    /**
     * The commands for which this executable is first in line to run
     * e.g., running which returns it
     * @type {string[]}
     */
    this.defaultCommands = []

    /**
     * Any errors encountered get caught by Promises and added here
     * @type {Error[]}
     */
    this.errors = []

    /**
     * Is this executable the default for any commands?
     * @type {boolean}
     * @todo I think this only exists because of legacy,
     * should probably get rid of it and replace with examining
     * defaultCommands instead
     */
    this.isDefault = isDefault || false
    if (this.isDefault) {
      this.addCommand(command)
    }
  }

  /**
   * Take any rescued error and attach it to the object, e.g.
   * pass errors from the command line to the end user
   * @param {Error} error an error object, typically rescued from elsewhere
   */
  addError (error) {
    this.errors.push({
      error: error,
      message: error.message,
      stack: error.stack
    })
  }

  /**
   * Add a command that this executable is first in line for,
   * e.g. if you run blahblah from the shell this executes
   * @param {string} command
   */
  addCommand (command) {
    if (command && this.defaultCommands.indexOf(command) === -1) {
      this.defaultCommands.push(command)
    }
  }

  /**
   * When merging an ExecutableCollection, this is what you group
   * the executables by. Mostly exists because PipExecutable on
   * Windows has different paths but point to the same package
   * install directory, which makes them equivalent.
   * @type {string}
   * @return {string} the value that makes the executable unique
   */
  get mergeField () {
    return this.realpath
  }

  assureMergeable () {
    return Promise.resolve(true)
  }

  /**
   * Gets the version of the executable by shelling out and
   * running --version
   * @return {Promise<string>} A promise to the version
   */
  requestVersion () {
    return new Promise((resolve, reject) => {
      execFile(this.path, ['--version'], (error, stdout, stderr) => {
        if (error) {
          reject(error)
        }
        resolve(stdout || stderr)
      })
    })
  }

  /**
   * Given a version-y string, do slight cleanup and set the
   * executable's rawVersion. Typically comes from stdout/stderr.
   * @param {string} version the version string, fresh from terminal output
   */
  setRawVersion (version) {
    /**
     * The raw output from stdout/stderr of --version
     * @type {string}
    */
    this.rawVersion = version.trim()
  }

  /**
   * Clean up the rawVersion, pulling out the actual version number
   * @return {string}
   */
  cleanVersion () {
    return this.rawVersion.trim()
  }

  /**
   * Query for and set the rawVersion and version
   * @return {Promise}
   */
  setVersion () {
    return this.requestVersion()
      .then(version => { this.setRawVersion(version) })
      .then(() => {
        /**
         * The version number of the program, typically cleaned up in
         * a subclass
         * @return {string}
        */
        this.version = this.cleanVersion()
        return this
      })
      .then(() => this)
      .catch(error => {
        this.addError(error)
        return this
      })
  }

  /**
   * Query for the executable file's creation/modification/access time
   * and save it to the object
   * @return {Promise}
   */
  setStats () {
    return new Promise((resolve, reject) => {
      stat(this.path, (error, stats) => {
        // TODO
        if (error) {
          reject(error)
        } else {
          this.atime = stats.atime
          this.ctime = stats.ctime
          this.mtime = stats.mtime
        }
        resolve(this)
      })
    })
  }

  /**
   * When you're looking for a path, but don't necessarily care
   * if it's the symlinked one or the non-symlinked on?
   * @todo it says 'FIXME' but I think it should just be 'get rid of me'
   * @type {string}
   * @return {string} the best known path for the executable
   */
  get path () {
    try {
      return this.realpath
    } catch (e) {
      return this.paths[0]
    }
  }

  /**
   * The path of the executable, or the target of a symlinked executable
   * @return {string} The executable file's path
   */
  get realpath () {
    if (!this._realpath) {
      try {
        let path = realpathSync(this.paths[0])
        this._realpath = trueCasePathSync(path) || path
      } catch (err) {
        this._realpath = this.paths[0]
        this.addError(err)
      }
    }
    return this._realpath
  }

  /**
   * Set the realpath
   * @param {string} the new true path
   */
  set realpath (path) {
    this._realpath = path
  }

  /**
   * Fills in all of the details of the executable
   * @return {Promise}
   */
  populate () {
    return Promise.all([
      this.setVersion(),
      this.setStats()
    ])
    .then(() => this.setExtras())
    .then(() => this)
    .catch((error) => {
      this.addError(error)
      return this
    })
  }

  /**
   * Subclasses that need extra details (lists of packages, etc)
   * override this method
   * @abstract
   * @return {Promise}
   */
  setExtras () {
    return Promise.resolve(this)
  }

  /**
   * Adds a known path to this executable (e.g. a symlink)
   */
  addPath (path) {
    let corrected = trueCasePathSync(path) || path
    if (corrected && this.paths.indexOf(corrected) === -1) {
      this.paths.push(corrected)
    }
  }

  /**
   * Converts the executable's data to a JSON-friendly object
   * it's mostly so we can rename _realpath to realpath
   */
  toJSON () {
    return Object.keys(this).reduce((obj, key) => {
      if (key === '_realpath') {
        obj['realpath'] = this.realpath
      } else {
        obj[key] = this[key]
      }
      return obj
    }, {})
  }

  /**
   * Given a command name, creates an Executable from the executable
   * file that would have been run had you typed the command in (e.g. which)
   * @return {Promise<Executable>} the discovered executable
   * @todo what if one is not found?
   */
  static findOne (command) {
    return new Promise((resolve, reject) => {
      let path = which(command).toString()
      let executable = new (this)(path, command, true)
      resolve(executable)
    })
  }

  /**
   * Given a command name or list of commands, returns an ExecutableCollection
   * of all the executables with that name your computer might know about
   * Looks in the path as well as looking in common paths.
   * The Executables are all merged together, so you don't have different
   * symlinks pointing at the same file.
   * Each actual binary file yields one Executable
   * @param {string|string[]} command the command or a list of commands
   * @return {Promise<ExecutableCollection>} the discovered executables
   */
  static findAll (command) {
    if (Array.isArray(command)) {
      let promises = command.map(c => this.findAllWithoutMerge(c))
      return Promise.all(promises)
        .then(nested => ExecutableCollection.from([]).concat(...nested))
        .then(executables => executables.merge())
    } else {
      return this.findAllWithoutMerge(command)
        .then(executables => executables.merge())
    }
  }

  /**
   * Given a command name or list of commands, returns an ExecutableCollection
   * of all the executables with that name your computer might know about
   * Looks in the path as well as looking in common paths.
   * The Executables are not merged
   * Each path yields one Executable
   * @param {string} command the command
   * @return {Promise<ExecutableCollection>} the discovered executables
   */
  static findAllWithoutMerge (command) {
    return Promise.all([
      this.findByWhich(command),
      this.findByPaths(command)
    ]).then(exeArray => {
      return new ExecutableCollection(...exeArray[0], ...exeArray[1])
    })
  }

  /**
   * Uses which to find all of the paths for a given command
   * @param {string} command the command
   * @return {Promise<ExecutableCollection>} the found executables
   */
  static findByWhich (command) {
    return new Promise((resolve, reject) => {
      let executables = which('-a', command).map((path, index) => {
        // eslint-disable-next-line
        return new (this)(path, command, index === 0)
      })

      executables = new ExecutableCollection(...executables)
      resolve(executables)
    })
  }

  /**
   * Manually searches paths to find executables with a given name
   * @param {string} command the command
   * @return {Promise<ExecutableCollection>} the found executables
   */
  static findByPaths (command) {
    let promises = this.searchPaths.map((path) => {
      return new Promise((resolve, reject) => {
        glob(join(path, command + this.searchExtensionsGlob), (error, paths) => {
          if (error) {
            reject(error)
          } else {
            let executables = paths.map(path => new (this)(path))
            executables = new ExecutableCollection(...executables)
            resolve(executables)
          }
        })
      })
    })

    return Promise.all(promises)
      .then(nested => ExecutableCollection.from([]).concat(...nested))
  }

  /**
   * Paths to manually search in for executables
   */
  static get searchPaths () {
    return [
      '/usr/local/Cellar/python*/*/bin',
      join(homedir(), '*conda*'),
      join(homedir(), '*conda*', 'bin'),
      join(homedir(), '*conda*', 'Scripts'),
      '/usr/local/bin',
      '/usr/bin/',
      '/Library/Frameworks/Python.framework/Versions/*/bin/',
      '/System/Library/Frameworks/Python.framework/Versions/*/bin/',
      '/*conda*',
      '/*conda*/bin',
      '/*conda*/Scripts',
      '/*ython*',
      '/*ython*/Scripts',
      join(homedir(), '*ython*'),
      join(homedir(), '*ython*', 'Scripts')
    ].map(normalize)
  }

  /**
   * Allowable extensions for execuables
   */
  static get searchExtensionsGlob () {
    return '?(.exe|.bat)'
  }

}