UNPKG

@oclif/plugin-update

Version:

[![Version](https://img.shields.io/npm/v/@oclif/plugin-update.svg)](https://npmjs.org/package/@oclif/plugin-update) [![Downloads/week](https://img.shields.io/npm/dw/@oclif/plugin-update.svg)](https://npmjs.org/package/@oclif/plugin-update) [![License](htt

367 lines (366 loc) 14.7 kB
import { Config, ux } from '@oclif/core'; import chalk from 'chalk'; import fileSize from 'filesize'; import { HTTP } from 'http-call'; import throttle from 'lodash.throttle'; import { existsSync } from 'node:fs'; import { mkdir, readFile, readdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import { Extractor } from './tar.js'; import { ls, wait } from './util.js'; const filesize = (n) => { const [num, suffix] = fileSize(n, { output: 'array' }); return Number.parseFloat(num).toFixed(1) + ` ${suffix}`; }; export class Updater { config; clientBin; clientRoot; constructor(config) { this.config = config; this.clientRoot = config.scopedEnvVar('OCLIF_CLIENT_HOME') ?? join(config.dataDir, 'client'); this.clientBin = join(this.clientRoot, 'bin', config.windows ? `${config.bin}.cmd` : config.bin); } async fetchVersionIndex() { ux.action.status = 'fetching version index'; const newIndexUrl = this.config.s3Url(s3VersionIndexKey(this.config)); try { const { body } = await HTTP.get(newIndexUrl); return typeof body === 'string' ? JSON.parse(body) : body; } catch { throw new Error(`No version indices exist for ${this.config.name}.`); } } async findLocalVersions() { await ensureClientDir(this.clientRoot); const dirOrFiles = await readdir(this.clientRoot); return dirOrFiles .filter((dirOrFile) => dirOrFile !== 'bin' && dirOrFile !== 'current') .map((f) => join(this.clientRoot, f)); } async runUpdate(options) { const { autoUpdate, force = false, version } = options; if (autoUpdate) await debounce(this.config.cacheDir); ux.action.start(`${this.config.name}: Updating CLI`); if (notUpdatable(this.config)) { ux.action.stop('not updatable'); return; } const [channel, current] = await Promise.all([ options.channel ?? determineChannel({ config: this.config, version }), determineCurrentVersion(this.clientBin, this.config.version), ]); if (version) { const localVersion = force ? null : await this.findLocalVersion(version); if (alreadyOnVersion(current, localVersion || null)) { ux.action.stop(this.config.scopedEnvVar('HIDE_UPDATED_MESSAGE') ? 'done' : `already on version ${current}`); return; } await this.config.runHook('preupdate', { channel, version }); if (localVersion) { await this.updateToExistingVersion(current, localVersion); } else { const index = await this.fetchVersionIndex(); const url = index[version]; if (!url) { throw new Error(`${version} not found in index:\n${Object.keys(index).join(', ')}`); } const manifest = await this.fetchVersionManifest(version, url); const updated = manifest.sha ? `${manifest.version}-${manifest.sha}` : manifest.version; await this.update(manifest, current, updated, force, channel); } await this.config.runHook('update', { channel, version }); ux.action.stop(); ux.log(); ux.log(`Updating to a specific version will not update the channel. If autoupdate is enabled, the CLI will eventually be updated back to ${channel}.`); } else { const manifest = await fetchChannelManifest(channel, this.config); const updated = manifest.sha ? `${manifest.version}-${manifest.sha}` : manifest.version; if (!force && alreadyOnVersion(current, updated)) { ux.action.stop(this.config.scopedEnvVar('HIDE_UPDATED_MESSAGE') ? 'done' : `already on version ${current}`); } else { await this.config.runHook('preupdate', { channel, version: updated }); await this.update(manifest, current, updated, force, channel); } await this.config.runHook('update', { channel, version: updated }); ux.action.stop(); } await this.touch(); await this.tidy(); ux.debug('done'); } async createBin(version) { const dst = this.clientBin; const { bin, windows } = this.config; const binPathEnvVar = this.config.scopedEnvVarKey('BINPATH'); const redirectedEnvVar = this.config.scopedEnvVarKey('REDIRECTED'); await mkdir(dirname(dst), { recursive: true }); if (windows) { const body = `@echo off setlocal enableextensions set ${redirectedEnvVar}=1 set ${binPathEnvVar}=%~dp0${bin} "%~dp0..\\${version}\\bin\\${bin}.cmd" %* `; await writeFile(dst, body); } else { /* eslint-disable no-useless-escape */ const body = `#!/usr/bin/env bash set -e get_script_dir () { SOURCE="\${BASH_SOURCE[0]}" # While $SOURCE is a symlink, resolve it while [ -h "$SOURCE" ]; do DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" SOURCE="$( readlink "$SOURCE" )" # If $SOURCE was a relative symlink (so no "/" as prefix, need to resolve it relative to the symlink base directory [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" done DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" echo "$DIR" } DIR=$(get_script_dir) ${binPathEnvVar}="\$DIR/${bin}" ${redirectedEnvVar}=1 "$DIR/../${version}/bin/${bin}" "$@" `; /* eslint-enable no-useless-escape */ await writeFile(dst, body, { mode: 0o755 }); await rm(join(this.clientRoot, 'current'), { force: true, recursive: true }); await symlink(`./${version}`, join(this.clientRoot, 'current')); } } async fetchVersionManifest(version, url) { const parts = url.split('/'); const hashIndex = parts.indexOf(version) + 1; const hash = parts[hashIndex]; const s3Key = s3VersionManifestKey({ config: this.config, hash, version }); return fetchManifest(s3Key, this.config); } async findLocalVersion(version) { const versions = await this.findLocalVersions(); return versions.map((file) => basename(file)).find((file) => file.startsWith(version)); } async refreshConfig(version) { this.config = (await Config.load({ root: join(this.clientRoot, version) })); } // removes any unused CLIs async tidy() { ux.debug('tidy'); try { const root = this.clientRoot; if (!existsSync(root)) return; const files = await ls(root); const isNotSpecial = (fPath, version) => !['bin', 'current', version].includes(basename(fPath)); const isOld = (fStat) => { const { mtime } = fStat; mtime.setHours(mtime.getHours() + 42 * 24); return mtime < new Date(); }; await Promise.all(files .filter((f) => isNotSpecial(this.config.version, f.path) && isOld(f.stat)) .map((f) => rm(f.path, { force: true, recursive: true }))); } catch (error) { ux.warn(error); } } async touch() { // touch the client so it won't be tidied up right away try { const p = join(this.clientRoot, this.config.version); ux.debug('touching client at', p); if (!existsSync(p)) return; return utimes(p, new Date(), new Date()); } catch (error) { ux.warn(error); } } // eslint-disable-next-line max-params async update(manifest, current, updated, force, channel) { ux.action.start(`${this.config.name}: Updating CLI from ${chalk.green(current)} to ${chalk.green(updated)}${channel === 'stable' ? '' : ' (' + chalk.yellow(channel) + ')'}`); await ensureClientDir(this.clientRoot); const output = join(this.clientRoot, updated); if (force || !existsSync(output)) await downloadAndExtract(output, manifest, channel, this.config); await this.refreshConfig(updated); await setChannel(channel, this.config.dataDir); await this.createBin(updated); } async updateToExistingVersion(current, updated) { ux.action.start(`${this.config.name}: Updating CLI from ${chalk.green(current)} to ${chalk.green(updated)}`); await ensureClientDir(this.clientRoot); await this.refreshConfig(updated); await this.createBin(updated); } } const alreadyOnVersion = (current, updated) => current === updated; const ensureClientDir = async (clientRoot) => { try { await mkdir(clientRoot, { recursive: true }); } catch (error) { const { code } = error; if (code === 'EEXIST') { // for some reason the client directory is sometimes a file // if so, this happens. Delete it and recreate await rm(clientRoot, { force: true, recursive: true }); await mkdir(clientRoot, { recursive: true }); } else { throw error; } } }; // eslint-disable-next-line unicorn/no-await-expression-member const mtime = async (f) => (await stat(f)).mtime; const notUpdatable = (config) => { if (!config.binPath) { const instructions = config.scopedEnvVar('UPDATE_INSTRUCTIONS'); if (instructions) { ux.warn(instructions); // once the spinner stops, it'll eat this blank line // https://github.com/oclif/core/issues/799 ux.log(); } return true; } return false; }; const composeS3SubDir = (config) => { let s3SubDir = config.pjson.oclif.update.s3.folder || ''; if (s3SubDir !== '' && s3SubDir.slice(-1) !== '/') s3SubDir = `${s3SubDir}/`; return s3SubDir; }; const fetchManifest = async (s3Key, config) => { ux.action.status = 'fetching manifest'; const url = config.s3Url(s3Key); const { body } = await HTTP.get(url); if (typeof body === 'string') { return JSON.parse(body); } return body; }; const s3VersionIndexKey = (config) => { const { arch, bin } = config; const s3SubDir = composeS3SubDir(config); return join(s3SubDir, 'versions', `${bin}-${determinePlatform(config)}-${arch}-tar-gz.json`); }; const determinePlatform = (config) => config.platform === 'wsl' ? 'linux' : config.platform; const s3ChannelManifestKey = (channel, config) => { const { arch, bin } = config; const s3SubDir = composeS3SubDir(config); return join(s3SubDir, 'channels', channel, `${bin}-${determinePlatform(config)}-${arch}-buildmanifest`); }; const s3VersionManifestKey = ({ config, hash, version }) => { const { arch, bin } = config; const s3SubDir = composeS3SubDir(config); return join(s3SubDir, 'versions', version, hash, `${bin}-v${version}-${hash}-${determinePlatform(config)}-${arch}-buildmanifest`); }; // when autoupdating, wait until the CLI isn't active const debounce = async (cacheDir) => { let output = false; const lastrunfile = join(cacheDir, 'lastrun'); const m = await mtime(lastrunfile); m.setHours(m.getHours() + 1); if (m > new Date()) { const msg = `waiting until ${m.toISOString()} to update`; if (output) { ux.debug(msg); } else { ux.log(msg); output = true; } await wait(60 * 1000); // wait 1 minute return debounce(cacheDir); } ux.log('time to update'); }; const setChannel = async (channel, dataDir) => writeFile(join(dataDir, 'channel'), channel, 'utf8'); const fetchChannelManifest = async (channel, config) => { const s3Key = s3ChannelManifestKey(channel, config); try { return await fetchManifest(s3Key, config); } catch (error) { const { statusCode } = error; if (statusCode === 403) throw new Error(`HTTP 403: Invalid channel ${channel}`); throw error; } }; const downloadAndExtract = async (output, manifest, channel, config) => { const { gz, sha256gz, version } = manifest; const gzUrl = gz ?? config.s3Url(config.s3Key('versioned', { arch: config.arch, bin: config.bin, channel, ext: 'gz', platform: determinePlatform(config), version, })); const { response: stream } = await HTTP.stream(gzUrl); stream.pause(); const baseDir = manifest.baseDir ?? config.s3Key('baseDir', { arch: config.arch, bin: config.bin, channel, platform: determinePlatform(config), version, }); const extraction = Extractor.extract(stream, baseDir, output, sha256gz); if (ux.action.type === 'spinner') { const total = Number.parseInt(stream.headers['content-length'], 10); let current = 0; const updateStatus = throttle((newStatus) => { ux.action.status = newStatus; }, 250, { leading: true, trailing: false }); stream.on('data', (data) => { current += data.length; updateStatus(`${filesize(current)}/${filesize(total)}`); }); } stream.resume(); await extraction; }; const determineChannel = async ({ config, version }) => { const channelPath = join(config.dataDir, 'channel'); // eslint-disable-next-line unicorn/no-await-expression-member const channel = existsSync(channelPath) ? (await readFile(channelPath, 'utf8')).trim() : 'stable'; try { const { body } = await HTTP.get(`${config.npmRegistry ?? 'https://registry.npmjs.org'}/${config.pjson.name}`); const tags = body['dist-tags']; const tag = Object.keys(tags).find((v) => tags[v] === version) ?? channel; // convert from npm style tag defaults to OCLIF style if (tag === 'latest') return 'stable'; if (tag === 'latest-rc') return 'stable-rc'; return tag; } catch { return channel; } }; const determineCurrentVersion = async (clientBin, version) => { try { const currentVersion = await readFile(clientBin, 'utf8'); const matches = currentVersion.match(/\.\.[/\\|](.+)[/\\|]bin/); return matches ? matches[1] : version; } catch (error) { ux.warn(error); } return version; };