1
0
Fork 0
mirror of synced 2024-06-26 02:31:09 -04:00
ultimate-vim/sources_non_forked/coc.nvim/src/model/installer.ts
2022-07-20 13:20:15 +08:00

300 lines
9.7 KiB
TypeScript

'use strict'
import { spawn } from 'child_process'
import { EventEmitter } from 'events'
import fs from 'fs-extra'
import { parse, ParseError } from 'jsonc-parser'
import os from 'os'
import path from 'path'
import readline from 'readline'
import semver from 'semver'
import { statAsync } from '../util/fs'
import { omit } from '../util/lodash'
import workspace from '../workspace'
import download from './download'
import fetch from './fetch'
const logger = require('../util/logger')('model-installer')
const HOME_DIR = global.__TEST__ ? os.tmpdir() : os.homedir()
export interface Info {
'dist.tarball'?: string
'engines.coc'?: string
version?: string
name?: string
}
export type Dependencies = Record<string, string>
export function registryUrl(scope = 'coc.nvim'): string {
let res = 'https://registry.npmjs.org/'
let filepath = path.join(HOME_DIR, '.npmrc')
if (fs.existsSync(filepath)) {
try {
let content = fs.readFileSync(filepath, 'utf8')
let obj = {}
for (let line of content.split(/\r?\n/)) {
if (line.indexOf('=') > -1) {
let [_, key, val] = line.match(/^(.*?)=(.*)$/)
obj[key] = val
}
}
if (obj[`${scope}:registry`]) {
res = obj[`${scope}:registry`]
} else if (obj['registry']) {
res = obj['registry']
}
} catch (e) {
logger.error('Error on read .npmrc:', e)
}
}
return res.endsWith('/') ? res : res + '/'
}
export function isNpmCommand(exePath: string): boolean {
let name = path.basename(exePath)
return name === 'npm' || name === 'npm.CMD'
}
export function isYarn(exePath: string) {
let name = path.basename(exePath)
return ['yarn', 'yarn.CMD', 'yarnpkg', 'yarnpkg.CMD'].includes(name)
}
export function getInstallArguments(exePath: string, url: string): string[] {
let args = ['install', '--ignore-scripts', '--no-lockfile', '--production']
if (url.startsWith('https://github.com')) {
args = ['install']
}
if (isNpmCommand(exePath)) {
args.push('--legacy-peer-deps')
args.push('--no-global')
}
if (isYarn(exePath)) {
args.push('--ignore-engines')
}
return args
}
// remove properties that should be devDependencies.
export function getDependencies(content: string): Dependencies {
let dependencies: Dependencies
try {
let obj = JSON.parse(content)
dependencies = obj.dependencies || {}
} catch (e) {
// noop
dependencies = {}
}
return omit(dependencies, ['coc.nvim', 'esbuild', 'webpack', '@types/node'])
}
function isSymbolicLink(folder: string): boolean {
if (fs.existsSync(folder)) {
let stat = fs.lstatSync(folder)
if (stat.isSymbolicLink()) {
return true
}
}
return false
}
export class Installer extends EventEmitter {
private name: string
private url: string
private version: string
constructor(
private root: string,
private npm: string,
// could be url or name@version or name
private def: string
) {
super()
if (!fs.existsSync(root)) fs.mkdirpSync(root)
if (/^https?:/.test(def)) {
this.url = def
} else {
let ms = def.match(/(.+)@([^/]+)$/)
if (ms) {
this.name = ms[1]
this.version = ms[2]
} else {
this.name = def
}
}
}
public get info() {
return { name: this.name, version: this.version }
}
public async install(): Promise<string> {
this.log(`Using npm from: ${this.npm}`)
let info = await this.getInfo()
logger.info(`Fetched info of ${this.def}`, info)
let { name } = info
let required = info['engines.coc'] ? info['engines.coc'].replace(/^\^/, '>=') : ''
if (required && !semver.satisfies(workspace.version, required)) {
throw new Error(`${name} ${info.version} requires coc.nvim >= ${required}, please update coc.nvim.`)
}
await this.doInstall(info)
return name
}
public async update(url?: string): Promise<string> {
this.url = url
let folder = path.join(this.root, this.name)
if (isSymbolicLink(folder)) {
this.log(`Skipped update for symbol link`)
return
}
let version: string
if (fs.existsSync(path.join(folder, 'package.json'))) {
let content = await fs.readFile(path.join(folder, 'package.json'), 'utf8')
version = JSON.parse(content).version
}
this.log(`Using npm from: ${this.npm}`)
let info = await this.getInfo()
if (version && info.version && semver.gte(version, info.version)) {
this.log(`Current version ${version} is up to date.`)
return
}
let required = info['engines.coc'] ? info['engines.coc'].replace(/^\^/, '>=') : ''
if (required && !semver.satisfies(workspace.version, required)) {
throw new Error(`${info.version} requires coc.nvim ${required}, please update coc.nvim.`)
}
await this.doInstall(info)
let jsonFile = path.join(this.root, info.name, 'package.json')
this.log(`Updated to v${info.version}`)
return path.dirname(jsonFile)
}
public async doInstall(info: Info): Promise<boolean> {
let folder = path.join(this.root, info.name)
if (isSymbolicLink(folder)) return false
let tmpFolder = await fs.mkdtemp(path.join(os.tmpdir(), `${info.name.replace('/', '-')}-`))
let url = info['dist.tarball']
this.log(`Downloading from ${url}`)
await download(url, { dest: tmpFolder, onProgress: p => this.log(`Download progress ${p}%`, true), extract: 'untar' })
this.log(`Extension download at ${tmpFolder}`)
let content = await fs.readFile(path.join(tmpFolder, 'package.json'), 'utf8')
let dependencies = getDependencies(content)
if (Object.keys(dependencies).length) {
let p = new Promise<void>((resolve, reject) => {
let args = getInstallArguments(this.npm, url)
this.log(`Installing dependencies by: ${this.npm} ${args.join(' ')}.`)
const child = spawn(this.npm, args, {
cwd: tmpFolder,
})
const rl = readline.createInterface({
input: child.stdout
})
rl.on('line', line => {
this.log(`[npm] ${line}`, true)
})
child.stderr.setEncoding('utf8')
child.stdout.setEncoding('utf8')
child.on('error', reject)
let err = ''
child.stderr.on('data', data => {
err += data
})
child.on('exit', code => {
if (code) {
if (err) this.log(err)
reject(new Error(`${this.npm} install exited with ${code}`))
return
}
resolve()
})
})
await p
}
let jsonFile = path.resolve(this.root, global.__TEST__ ? '' : '..', 'package.json')
let errors: ParseError[] = []
if (!fs.existsSync(jsonFile)) fs.writeFileSync(jsonFile, '{}')
let obj = parse(fs.readFileSync(jsonFile, 'utf8'), errors, { allowTrailingComma: true })
if (errors && errors.length > 0) {
throw new Error(`Error on load ${jsonFile}`)
}
obj.dependencies = obj.dependencies || {}
if (this.url) {
obj.dependencies[info.name] = this.url
} else {
obj.dependencies[info.name] = '>=' + info.version
}
const sortedObj = { dependencies: {} }
Object.keys(obj.dependencies).sort().forEach(k => {
sortedObj.dependencies[k] = obj.dependencies[k]
})
let stat = await statAsync(folder)
if (stat) {
if (stat.isDirectory()) {
fs.removeSync(folder)
} else {
fs.unlinkSync(folder)
}
}
await fs.move(tmpFolder, folder, { overwrite: true })
await fs.writeFile(jsonFile, JSON.stringify(sortedObj, null, 2), { encoding: 'utf8' })
if (fs.existsSync(tmpFolder)) fs.rmdirSync(tmpFolder)
this.log(`Update package.json at ${jsonFile}`)
this.log(`Installed extension ${this.name}@${info.version} at ${folder}`)
return true
}
public async getInfo(): Promise<Info> {
if (this.url) return await this.getInfoFromUri()
let registry = registryUrl()
this.log(`Get info from ${registry}`)
let buffer = await fetch(registry + this.name, { timeout: 10000, buffer: true })
let res = JSON.parse(buffer.toString())
if (!this.version) this.version = res['dist-tags']['latest']
let obj = res['versions'][this.version]
if (!obj) throw new Error(`${this.def} doesn't exists in ${registry}.`)
let requiredVersion = obj['engines'] && obj['engines']['coc']
if (!requiredVersion) {
throw new Error(`${this.def} is not valid coc extension, "engines" field with coc property required.`)
}
return {
'dist.tarball': obj['dist']['tarball'],
'engines.coc': requiredVersion,
version: obj['version'],
name: res.name
} as Info
}
public async getInfoFromUri(): Promise<Info> {
let { url } = this
if (!url.startsWith('https://github.com')) {
throw new Error(`"${url}" is not supported, coc.nvim support github.com only`)
}
url = url.replace(/\/$/, '')
let branch = 'master'
if (url.includes('@')) {
// https://github.com/sdras/vue-vscode-snippets@main
let idx = url.indexOf('@')
branch = url.substr(idx + 1)
url = url.substring(0, idx)
}
let fileUrl = url.replace('github.com', 'raw.githubusercontent.com') + `/${branch}/package.json`
this.log(`Get info from ${fileUrl}`)
let content = await fetch(fileUrl, { timeout: 10000 })
let obj = typeof content == 'string' ? JSON.parse(content) : content
this.name = obj.name
return {
'dist.tarball': `${url}/archive/${branch}.tar.gz`,
'engines.coc': obj['engines'] ? obj['engines']['coc'] : null,
name: obj.name,
version: obj.version
}
}
private log(msg: string, isProgress = false): void {
logger.info(msg)
this.emit('message', msg, isProgress)
}
}
export function createInstallerFactory(npm: string, root: string): (def: string) => Installer {
return (def): Installer => new Installer(root, npm, def)
}