index.js

'use strict'

const http = require('http')
const path = require('path')
const fs = require('fs')
const os = require('os')

const reach = require('./reach')

/**
 * Represents a snapd formatter error
 * @class
 * @param {object}    body
 */
class SnapdError extends Error {
  constructor(body){
    super(reach(body, 'result.message') || 'something went wrong')
    Error.captureStackTrace(this, SnapdError)
    this.code = reach(body, 'result.kind')
  }
}


/**
 * A snap client instance which makes restful call to snapd process using Unix sockets
 * @class
 * @param {string}  authFile    Defaults to `$HOME/.snap/auth.json`
 * @param {string}  socketPath  Defaults to `/run/snapd.socket`
 */
class SnapClient {
  constructor(
    authFile=path.join(os.homedir(), '.snap', 'auth.json'),
    socketPath=`/run/snapd.socket`
  ){
    this.auth = undefined
    this.socketPath = socketPath
  }


  /**
   * make restful call to snapd process thru /run/snapd.socket
   * @method
   * @param {object}  options
   * @param {object}  options.auth
   * @param {string}  options.method
   * @param {string}  options.path
   * @param {string}  options.data
   */
  async rest({auth, method, path, data}) {
    return new Promise((resolve, reject) => {
      const post = method === 'POST'
      const headers = { 'Content-Type': 'application/json' }
      if (post) {
        headers['Content-Length'] = Buffer.byteLength(data)
      }
      if (auth && typeof auth.macaroon === 'string') {
        headers['Authorization'] = `Macaroon root="${auth.macaroon}"`
      }

      const options = {
        socketPath: this.socketPath,
        path: path,
        method: post ? 'POST' : 'GET',
        headers: headers
      }

      const req = http.request(options, (res) => {
        res.setEncoding('utf8')

        let body = ''
        res.on('data', (chunk) => {
          body += chunk
        })
        res.on('end', () => {
          if (res.statusCode === 200 || res.statusCode === 202) {
            if (body.length < 1) {
              return reject(new Error('empty response'))
            }
            const json = JSON.parse(body)
            return resolve(json)
          }

          const json = JSON.parse(body)

          return reject(new SnapdError(json))
        })
      })

      req.on('error', (error) => {
        return reject(error)
      })

      // write data to request body
      if (post && typeof data === 'string') {
        req.write(data)
      }
      req.end()
    })
  }


  /**
   * read auth email & macaroon from ~/.snap/auth.json
   * @method
   * @param {string}  filename
   */
  async readAuth(filename) {

    if(this.auth){
      return Promise.resolve({ email: this.auth.email, macaroon: this.auth.macaroon })
    }

    return new Promise((resolve, reject) => {
      const fn = filename || path.join(os.homedir(), '.snap', 'auth.json')
      fs.readFile(fn, (error, data) => {
        if (error) {
          return reject(error)
        }

        this.auth = JSON.parse(data)

        if (typeof this.auth.macaroon !== 'string') {
          return reject(new Error('failed to read macaroon from auth file'))
        }

        return resolve({ email: this.auth.email, macaroon: this.auth.macaroon })
      })
    })
  }


  /**
   * login may require root privilege
   * @method
   * @param {object}  options
   * @param {object}  options.auth
   * @param {string}  options.method
   * @param {string}  options.path
   * @param {string}  options.data
   */
  async login({ email, password, otp }) {
    const data = {
      email: email,
      password: password,
      otp: otp // one time passkey for 2fa
    }

    const response = await this.rest({
      method: 'POST',
      path: '/v2/login',
      data: JSON.stringify(data)
    })

    if (response && response['status-code'] === 200) {
      return {
        email: response.result.email,
        macaroon: response.result.macaroon
      }
    }

    return Promise.reject(new Error('malformed response'))
  }

  /**
   * logout may require root privilege
   * @method
   * @param {object}  options
   * @param {object}  options.auth
   */
  async logout({ auth }) {
    const response = await this.rest({
      auth: auth || await this.readAuth(),
      method: 'POST',
      path: '/v2/logout'
    })

    if (response && response['status-code'] === 200) {
      return true
    }

    return Promise.reject(new Error('malformed response'))
  }

  /**
   * list of installed snaps
   * @method
   */
  async listSnaps() {
    const response = await this.rest({
      method: 'GET',
      path: '/v2/snaps'
    })

    if (response && response['status-code'] === 200) {
      return response.result.map(entry => entry.name)
    }

    throw new Error('malformed response')
  }

  /**
   * Get detailed info about a snap
   * @method
   * @param {object}  options
   * @param {object}  options.name
   */
  async info({ name }) {

    if (typeof name !== 'string') {
      return Promise.reject(new Error('malformed name argument'))
    }

    const response = await this.rest({
      method: 'GET',
      path: `/v2/snaps/${name}`
    })

    if (response && response['status-code'] === 200) {
      return response.result
    }

    return Promise.reject(new Error('malformed response'))
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.action
   * @param {string}  options.name
   * @param {object}  options.auth
   * @param {object}  options.opts
   */
  async modify({ action, name, auth, ...opts }) {

    if (typeof name !== 'string') {
      return Promise.reject(new Error('malformed name argument'))
    }

    const data = { action: action }
    if (opts) {
      for (const b of ['classic', 'devmode', 'ignore-validation', 'jailmode']) {
        if (opts[b]) {
          data[b] = true
        }
      }
      for (const str of ['channel', 'version']) {
        if (typeof opts[str] === 'string') {
          data[str] = opts[str]
        }
      }
    }

    const response = await this.rest({
      auth: auth || await this.readAuth(),
      method: 'POST',
      path: `/v2/snaps/${name}`,
      data: JSON.stringify(data)
    })

    if (response && response['status-code'] === 202) {
      return response.change
    }

    return Promise.reject(new Error('malformed response'))
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.name
   * @param {object}  options.auth
   * @param {object}  options.opts
   */
  async install ({ name, auth, ...opts }) {
    return this.modify({ action: 'install', name, auth, ...opts })
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.name
   * @param {object}  options.auth
   * @param {object}  options.opts
   */
  async remove ({ name, auth, ...opts }) {
    return this.modify({ action: 'remove', name, auth, ...opts })
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.name
   * @param {object}  options.auth
   * @param {object}  options.opts
   */
  async switch ({ name, auth, ...opts }) {
    return this.modify({ action: 'switch', name, auth, ...opts })
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.name
   * @param {object}  options.auth
   * @param {object}  options.opts
   */
  async refresh ({ name, auth, ...opts }) {
    return this.modify({ action: 'refresh', name, auth, ...opts })
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.name
   * @param {object}  options.auth
   * @param {object}  options.opts
   */
  async revert ({ name, auth, ...opts }) {
    return this.modify({ action: 'revert', name, auth, ...opts })
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.name
   * @param {object}  options.auth
   * @param {object}  options.opts
   */
  async enable ({ name, auth, ...opts }) {
    return this.modify({ action: 'enable', name, auth, ...opts })
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.name
   * @param {object}  options.auth
   * @param {object}  options.opts
   */
  async disable ({ name, auth, ...opts }) {
    return this.modify({ action: 'disable', name, auth, ...opts })
  }

  /**
   * check on status of change by id (or all changes without id arg)
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.id
   */
  async status ({ id }) {
    const response = await this.rest({
      method: 'GET',
      path: id ? `/v2/changes/${id}` : '/v2/changes'
    })

    if (response && response['status-code'] === 200) {
      return response.result
    }

    return Promise.reject(new Error('malformed response'))
  }

  /**
   * abort ongoing change by id
   * 
   * @method
   * @param {object}  options

   * @param {string}  options.id
   * @param {object}  options.auth
   */
  async abort ({ id, auth }) {

    if (typeof id !== 'string') {
      return Promise.reject(new Error('malformed id argument'))
    }

    const response = await this.rest({
      auth: auth || await this.readAuth(),
      method: 'POST',
      path: `/v2/changes/${id}`,
      data: JSON.stringify({ action: 'abort' })
    })

    if (response && response['status-code'] === 200) {
      return response.result
    }

    return Promise.reject(new Error('malformed response'))
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {object}  options.auth
   */
  async listInterfaces ({ auth }={}) {
    const response = await this.rest({
      auth: auth || await this.readAuth(),
      method: 'GET',
      path: '/v2/interfaces'
    })

    if (response && response['status-code'] === 200) {
      return response.result
    }

    return Promise.reject(new Error('malformed response'))
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.action
   * @param {object}  options.slot
   * @param {object}  options.plug
   * @param {object}  options.auth
   */
  async modifyInterface ({ action, slot, plug, auth }) {

    const data = {
      action: action,
      slots: [{ snap: slot.snap, slot: slot.slot }],
      plugs: [{ snap: plug.snap, plug: plug.plug }]
    }

    const response = await this.rest({
      auth: auth || await this.readAuth(),
      method: 'POST',
      path: '/v2/interfaces',
      data: JSON.stringify(data)
    })

    if (response && response['status-code'] === 202) {
      return response.change
    }

    return Promise.reject(new Error('malformed response'))
  }


  /**
   * 
   * @method
   * @param {object}  options
   * @param {object}  options.slot
   * @param {object}  options.plug
   * @param {object}  options.auth
   */
  async connect ({ slot, plug, auth }) {
    return this.modifyInterface({ action: 'connect', slot, plug, auth })
  }

  /**
   * 
   * @method
   * @param {object}  options
   * @param {string}  options.name
   * @param {object}  options.auth
   * @param {object}  options.opts
   */
  async disconnect ({ slot, plug, auth }) {
    return this.modifyInterface({ action: 'disconnect', slot, plug, auth })
  }

}

module.exports = SnapClient