@oclif/plugin-update
Version:
[](https://npmjs.org/package/@oclif/plugin-update) [](https://npmjs.org/package/@oclif/plugin-update) [ • 15.5 kB
JavaScript
import { Config, ux } from '@oclif/core';
import { green, yellow } from 'ansis';
import makeDebug from 'debug';
import fileSize from 'filesize';
import { got, HTTPError } from 'got';
import { existsSync } from 'node:fs';
import { mkdir, readdir, readFile, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { ProxyAgent } from 'proxy-agent';
import { Extractor } from './tar.js';
import { ls, wait } from './util.js';
const debug = makeDebug('oclif:update');
const filesize = (n) => {
const [num, suffix] = fileSize(n, { output: 'array' });
return Number.parseFloat(num).toFixed(1) + ` ${suffix}`;
};
async function httpGet(url) {
debug(`[${url}] GET`);
return got
.get(url, {
agent: { https: new ProxyAgent() },
})
.then((res) => {
debug(`[${url}] ${res.statusCode}`);
return res;
})
.catch((error) => {
debug(`[${url}] ${error.response?.statusCode ?? error.code}`);
// constructing a new HTTPError here will produce a more actionable stack trace
debug(new HTTPError(error.response));
throw error;
});
}
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() {
const newIndexUrl = this.config.s3Url(s3VersionIndexKey(this.config));
try {
const { body } = await httpGet(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 {
ux.action.status = 'fetching version index';
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.stdout();
ux.stdout(`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();
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() {
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);
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 ${green(current)} to ${green(updated)}${channel === 'stable' ? '' : ' (' + 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 ${green(current)} to ${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;
}
}
};
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.stdout();
}
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 httpGet(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) {
debug(msg);
}
else {
ux.stdout(msg);
output = true;
}
await wait(60 * 1000); // wait 1 minute
return debounce(cacheDir);
}
ux.stdout('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 { code, statusCode } = error;
if (statusCode === 403 || code === 'ERR_NON_2XX_3XX_RESPONSE')
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,
}));
debug(`Streaming ${gzUrl} to ${output}`);
const stream = got.stream(gzUrl, {
agent: { https: new ProxyAgent() },
});
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') {
stream.on('downloadProgress', (progress) => {
ux.action.status =
progress.percent === 1
? `${filesize(progress.transferred)}/${filesize(progress.total)} - Finishing up...`
: `${filesize(progress.transferred)}/${filesize(progress.total)}`;
});
}
stream.resume();
await extraction;
};
const determineChannel = async ({ config, version }) => {
ux.action.status = version ? `Determining channel for ${version}` : 'Determining channel';
const channelPath = join(config.dataDir, 'channel');
const channel = existsSync(channelPath) ? (await readFile(channelPath, 'utf8')).trim() : 'stable';
if (config.pjson.oclif.update?.disableNpmLookup ?? false) {
return channel;
}
try {
const { body } = await httpGet(`${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) {
if (error instanceof Error) {
debug(error.name, error.message);
}
else if (typeof error === 'string') {
debug(error);
}
}
return version;
};