modules/3pm/index.js

/**
 * Web3os Package Manager
 * @module @web3os-core/3pm
 * @description Manage installed packages
 * @author Jay Mathis <code@mathis.network>
 * @license MIT
 * @see https://github.com/web3os-org/kernel
 */

/* global fetch, localStorage */
/* eslint-disable no-new-func */

import arg from 'arg'
import colors from 'ansi-colors'
import { parse as cliParse } from 'shell-quote'

export let DefaultPackageRegistry = 'https://unpkg.com'

export const name = '3pm'
export const version = '0.1.0'
export const description = 'Web3os Package Manager'
export const help = `
  ${colors.blue('3pm')}: ${colors.green('The web3os package manager')}

  Usage:
    3pm <command> <args> [options]

  Commands:
    install <name>         Install (or update) package named <name> from the registry
    install <url>          Install (or update) package located at <url>
    ls                     List installed packages
    uninstall <name>       Uninstall package

  Options:
    --help                 Show this help message
    --main                 Override a package's main entrypoint
    --registry             Package registry {https://unpkg.com}
    --system               Install the package as a SystemJS module
    --umd                  Install the package as a UMD module
    --version              Show version information
`

const spec = {
  '--help': Boolean,
  '--main': String,
  '--registry': String,
  '--system': Boolean,
  '--umd': Boolean,
  '--version': Boolean,

  '-h': '--help',
  '-v': '--version'
}

let kernel = globalThis.Kernel
let terminal = globalThis.Terminal

/**
 * ModInfo Object
 * @typedef ModInfo
 * @property {string} name - Module name
 * @property {string} description - Module description
 * @property {string} help - Module help information
 * @property {string} url - Module URL
 * @property {string} pkgJson - Module package.json
 * @property {string} pkgJson.web3osData - Data specific to web3os
 */

/**
 * Install a package
 * @async
 * @param {!string} url - The package URL (or just package name to use default registry)
 * @param {Object=} args - The arguments for the installation
 * @param {boolean=} [args.warn=true] - Show the installation warning if the user has never seen it
 * @returns {Promise<(boolean|ModInfo)>} ModInfo or false on error
 */
export async function install (url, args = { warn: true }) {
  console.log({ url })
  const warned = localStorage.getItem('web3os_3pm_install_warning_hidden')
  if (!warned && args.warn) {
    localStorage.setItem('web3os_3pm_install_warning_hidden', true)
    terminal.log(`\n${colors.bgRed.white('WARNING')}: Do not install any packages unless you trust them completely.`)
    terminal.log(colors.bold(`All apps run in an inherently insecure context. ${colors.danger('This will be your last warning!')}`))
    terminal.log(colors.underline('Repeat the command to continue with the installation\n'))
    return false
  }

  if (typeof url === 'object') url = url.url || url.pattern
  if (!url) throw new Error('Invalid package name or URL')

  if (!/^(http|ftp).*\:/i.test(url)) url = `${args['--registry'] || kernel.get('3pm', 'default-package-registry') || DefaultPackageRegistry}/${url}`
  const pkgJson = await (await fetch(`${url}/package.json?t=${Math.random().toString(36)}`, { cache: 'no-store' })).json()
  const name = pkgJson.name
  const pkgName = name.split('/')
  const pkgBrowser = pkgJson.browser ? (typeof pkgJson.browser === 'string' ? pkgJson.browser : Object.values(pkgJson.browser)[0]) : null
  const pkgModule = pkgJson.module ? (typeof pkgJson.module === 'string' ? pkgJson.module : Object.values(pkgJson.module)[0]) : null
  const main = args['--main'] || pkgBrowser || pkgModule || pkgJson.main || 'index.js'
  const mainUrl = `${url}/${main}`

  let type = 'es'
  if (args['--umd']) type = 'umd'
  if (args['--system']) type = 'system'

  if (kernel.modules[name]) await uninstall(name)

  let mod
  try {
    switch (type) {
      case 'umd':
        mod = await kernel.importUMDModule(mainUrl)
        break
      case 'systemjs':
        mod = await globalThis.System.import(mainUrl)
        break
      default:
        mod = await import(/* webpackIgnore: true */ mainUrl)
    }
  } catch (err) {
    console.error('IMPORT ERROR:', err, { name, type, main, mod, pkgJson })
  }

  if (!mod) throw new Error('Failed to import module')

  if (pkgName.length > 1) {
    if (!kernel.fs.existsSync(`/var/packages/${pkgName[0]}`)) kernel.fs.mkdirSync(`/var/packages/${pkgName[0]}`)
    if (!kernel.fs.existsSync(`/var/packages/${pkgName[0]}/${pkgName[1]}@${pkgJson.version}`)) kernel.fs.mkdirSync(`/var/packages/${pkgName[0]}/${pkgName[1]}@${pkgJson.version}`)
  } else {
    if (!kernel.fs.existsSync(`/var/packages/${pkgName[0]}@${pkgJson.version}`)) kernel.fs.mkdirSync(`/var/packages/${pkgName[0]}@${pkgJson.version}`)
  }

  const pkgFolder = pkgName.length > 1 ? `/var/packages/${pkgName[0]}/${pkgName[1]}@${pkgJson.version}` : `/var/packages/${pkgName[0]}@${pkgJson.version}`
  pkgJson.web3osData = { pkgFolder, url, main, type }
  kernel.fs.writeFileSync(`${pkgFolder}/package.json`, JSON.stringify(pkgJson, null, 2), 'utf8')

  const packages = JSON.parse(kernel.fs.readFileSync('/config/packages', 'utf8'))
  const index = packages.indexOf(url)
  if (index > -1) packages.splice(index, 1)
  packages.push(`${pkgJson.name}@${pkgJson.version}`)
  kernel.fs.writeFileSync('/config/packages', JSON.stringify(packages, null, 2))

  const description = pkgJson.description || mod.description
  const help = pkgJson.help || mod?.help
  const modInfo = { description, help, name, url, pkgJson }
  await kernel.loadModule(mod, modInfo)
  await mod.web3osInstall?.(modInfo)

  kernel.notify.success(`Installed ${modInfo.name}@${pkgJson.version}`)
  return modInfo
}

/**
 * Uninstall a package
 * @async
 * @param {!string} name - The name of the package to uninstall
 * @returns {boolean} true if the package was uninstalled
 */
export async function uninstall (name) {
  const packages = JSON.parse(kernel.fs.readFileSync('/config/packages', 'utf8'))
  const pkg = packages.find(p => (new RegExp(`^${name}@`)).test(p))
  if (!pkg) throw new Error('Package not found')
  const pkgJson = JSON.parse(kernel.fs.readFileSync(`/var/packages/${pkg}/package.json`))
  const newPackages = packages.filter(p => p !== `${pkgJson.name}@${pkgJson.version}`)
  kernel.fs.writeFileSync('/config/packages', JSON.stringify(newPackages, null, 2))
  if (kernel.fs.existsSync(`/bin/${pkgJson.name}`)) kernel.fs.unlinkSync(`/bin/${pkgJson.name}`)
  // TODO: Delete package folder
  delete kernel.modules[pkgJson.name]
  return true
}

/**
 * Run the 3pm CLI
 * @async
 * @param {Web3osTerminal} term - The terminal to attach to
 * @param {string} context - The arguments string to parse
 * @returns {any} result of the command
 */
export async function run (term, context) {
  terminal = term
  kernel = term.kernel

  const args = arg(spec, { argv: cliParse(context) })
  const cmd = args._?.[0]

  if (args['--help']) return term.log(help)
  if (args['--version']) return term.log(version)

  try {
    switch (cmd) {
      case 'install':
        return await install(args._?.slice(1)[0], args)
      case 'ls':
        return await kernel.execute('cat /config/packages')
      case 'uninstall':
        return await uninstall(args._?.slice(1)[0], args)
      default:
        return term.log(help)
    }
  } catch (err) {
    console.error(err)
    term.log(err.message)
  }
}