From 5d82adca2cff7fade36f7832e17fec6640eb11f8 Mon Sep 17 00:00:00 2001 From: lshadler Date: Fri, 4 Jun 2021 09:40:13 -0700 Subject: [PATCH] fix: first pass formatting --- .eslintrc.js | 1 + package.json | 1 + src/commands/update.ts | 725 +++++++++++++++++++--------------- src/commands/use.ts | 150 +++---- src/hooks/init.ts | 106 ++--- src/tar.ts | 153 +++---- src/util.ts | 26 +- test/commands/install.test.ts | 80 ++-- test/commands/update.skip.ts | 151 +++---- test/commands/update.test.ts | 70 ++-- test/commands/use.test.ts | 480 +++++++++++----------- yarn.lock | 5 + 12 files changed, 1052 insertions(+), 896 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index e397fd81..08f6d170 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,5 +13,6 @@ module.exports = { "prettier/prettier": "error", "no-useless-constructor": "off", "@typescript-eslint/no-useless-constructor": "error", + "@typescript-eslint/no-var-requires": "off", }, }; diff --git a/package.json b/package.json index 1fdcac4b..74b5059d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@oclif/test": "^1.2.8", "@types/chai": "^4.2.15", "@types/cross-spawn": "^6.0.2", + "@types/debug": "4.1.5", "@types/fs-extra": "^8.0.1", "@types/glob": "^7.1.3", "@types/jest": "26.0.23", diff --git a/src/commands/update.ts b/src/commands/update.ts index 89875095..6e03cb29 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,6 +1,6 @@ import color from '@oclif/color' -import Command, {flags} from '@oclif/command' -import {IManifest} from '@oclif/dev-cli' +import Command, { flags } from '@oclif/command' +import { IManifest } from '@oclif/dev-cli' import cli from 'cli-ux' import * as spawn from 'cross-spawn' import * as fs from 'fs-extra' @@ -8,348 +8,424 @@ import HTTP from 'http-call' import * as _ from 'lodash' import * as path from 'path' -import {extract} from '../tar' -import {ls, wait} from '../util' +import { extract } from '../tar' +import { ls, wait } from '../util' export default class UpdateCommand extends Command { - static description = 'update the <%= config.bin %> CLI' + static description = 'update the <%= config.bin %> CLI' - static args = [{name: 'channel', optional: true}] + static args = [{ name: 'channel', optional: true }] - static flags: flags.Input = { - autoupdate: flags.boolean({hidden: true}), - 'from-local': flags.boolean({description: 'interactively choose an already installed version'}), - } - - protected autoupdate!: boolean - - protected channel!: string + static flags: flags.Input = { + autoupdate: flags.boolean({ hidden: true }), + 'from-local': flags.boolean({ + description: 'interactively choose an already installed version', + }), + } - protected currentVersion?: string + protected autoupdate!: boolean + + protected channel!: string + + protected currentVersion?: string + + protected updatedVersion!: string + + protected readonly clientRoot = + this.config.scopedEnvVar('OCLIF_CLIENT_HOME') || + path.join(this.config.dataDir, 'client') + + protected readonly clientBin = path.join( + this.clientRoot, + 'bin', + this.config.windows ? `${this.config.bin}.cmd` : this.config.bin + ) + + async run() { + const { args, flags } = this.parse(UpdateCommand) + this.autoupdate = Boolean(flags.autoupdate) + + if (this.autoupdate) await this.debounce() + + this.channel = args.channel || (await this.determineChannel()) + + if (flags['from-local']) { + await this.ensureClientDir() + this.debug( + `Looking for locally installed versions at ${this.clientRoot}` + ) + + // Do not show known non-local version folder names, bin and current. + const versions = fs + .readdirSync(this.clientRoot) + .filter( + (dirOrFile) => + dirOrFile !== 'bin' && dirOrFile !== 'current' + ) + if (versions.length === 0) + throw new Error('No locally installed versions found.') + + this.log( + `Found versions: \n${versions + .map((version) => ` ${version}`) + .join('\n')}\n` + ) + + const pinToVersion = await cli.prompt( + 'Enter a version to update to' + ) + if (!versions.includes(pinToVersion)) + throw new Error( + `Version ${pinToVersion} not found in the locally installed versions.` + ) + + if ( + !(await fs.pathExists(path.join(this.clientRoot, pinToVersion))) + ) { + throw new Error( + `Version ${pinToVersion} is not already installed at ${this.clientRoot}.` + ) + } + cli.action.start(`${this.config.name}: Updating CLI`) + this.debug(`switching to existing version ${pinToVersion}`) + this.updateToExistingVersion(pinToVersion) + + this.log() + this.log( + `Updating to an already installed version will not update the channel. If autoupdate is enabled, the CLI will eventually be updated back to ${this.channel}.` + ) + } else { + cli.action.start(`${this.config.name}: Updating CLI`) + await this.config.runHook('preupdate', { channel: this.channel }) + const manifest = await this.fetchManifest() + this.currentVersion = await this.determineCurrentVersion() + this.updatedVersion = (manifest as any).sha + ? `${manifest.version}-${(manifest as any).sha}` + : manifest.version + const reason = await this.skipUpdate() + if (reason) cli.action.stop(reason || 'done') + else await this.update(manifest) + this.debug('tidy') + await this.tidy() + await this.config.runHook('update', { channel: this.channel }) + } - protected updatedVersion!: string + this.debug('done') + cli.action.stop() + } - protected readonly clientRoot = this.config.scopedEnvVar('OCLIF_CLIENT_HOME') || path.join(this.config.dataDir, 'client') + protected async fetchManifest(): Promise { + const http: typeof HTTP = require('http-call').HTTP + + cli.action.status = 'fetching manifest' + if (!this.config.scopedEnvVarTrue('USE_LEGACY_UPDATE')) { + try { + const newManifestUrl = this.config.s3Url( + this.s3ChannelManifestKey( + this.config.bin, + this.config.platform, + this.config.arch, + (this.config.pjson.oclif.update.s3 as any).folder + ) + ) + const { body } = await http.get( + newManifestUrl + ) + if (typeof body === 'string') { + return JSON.parse(body) + } + return body + } catch (error) { + this.debug(error.message) + } + } - protected readonly clientBin = path.join(this.clientRoot, 'bin', this.config.windows ? `${this.config.bin}.cmd` : this.config.bin) + try { + const url = this.config.s3Url( + this.config.s3Key('manifest', { + channel: this.channel, + platform: this.config.platform, + arch: this.config.arch, + }) + ) + const { body } = await http.get(url) + + // in case the content-type is not set, parse as a string + // this will happen if uploading without `oclif-dev publish` + if (typeof body === 'string') { + return JSON.parse(body) + } + return body + } catch (error) { + if (error.statusCode === 403) + throw new Error(`HTTP 403: Invalid channel ${this.channel}`) + throw error + } + } - async run() { - const {args, flags} = this.parse(UpdateCommand) - this.autoupdate = Boolean(flags.autoupdate) + protected async downloadAndExtract( + output: string, + manifest: IManifest, + channel: string + ) { + const { version } = manifest - if (this.autoupdate) await this.debounce() + const filesize = (n: number): string => { + const [num, suffix] = require('filesize')(n, { output: 'array' }) + return num.toFixed(1) + ` ${suffix}` + } - this.channel = args.channel || await this.determineChannel() + const http: typeof HTTP = require('http-call').HTTP + const gzUrl = + manifest.gz || + this.config.s3Url( + this.config.s3Key('versioned', { + version, + channel, + bin: this.config.bin, + platform: this.config.platform, + arch: this.config.arch, + ext: 'gz', + }) + ) + const { response: stream } = await http.stream(gzUrl) + stream.pause() + + const baseDir = + manifest.baseDir || + this.config.s3Key('baseDir', { + version, + channel, + bin: this.config.bin, + platform: this.config.platform, + arch: this.config.arch, + }) + const extraction = extract(stream, baseDir, output, manifest.sha256gz) + + // to-do: use cli.action.type + if ((cli.action as any).frames) { + // if spinner action + const total = parseInt(stream.headers['content-length']!, 10) + let current = 0 + const updateStatus = _.throttle( + (newStatus: string) => { + cli.action.status = newStatus + }, + 250, + { leading: true, trailing: false } + ) + stream.on('data', (data) => { + current += data.length + updateStatus(`${filesize(current)}/${filesize(total)}`) + }) + } - if (flags['from-local']) { - await this.ensureClientDir() - this.debug(`Looking for locally installed versions at ${this.clientRoot}`) + stream.resume() + await extraction + } - // Do not show known non-local version folder names, bin and current. - const versions = fs.readdirSync(this.clientRoot).filter(dirOrFile => dirOrFile !== 'bin' && dirOrFile !== 'current') - if (versions.length === 0) throw new Error('No locally installed versions found.') + protected async update(manifest: IManifest, channel = this.channel) { + const { channel: manifestChannel } = manifest + if (manifestChannel) channel = manifestChannel + cli.action.start( + `${this.config.name}: Updating CLI from ${color.green( + this.currentVersion + )} to ${color.green(this.updatedVersion)}${ + channel === 'stable' ? '' : ' (' + color.yellow(channel) + ')' + }` + ) - this.log(`Found versions: \n${versions.map(version => ` ${version}`).join('\n')}\n`) + await this.ensureClientDir() + const output = path.join(this.clientRoot, this.updatedVersion) - const pinToVersion = await cli.prompt('Enter a version to update to') - if (!versions.includes(pinToVersion)) throw new Error(`Version ${pinToVersion} not found in the locally installed versions.`) + if (!(await fs.pathExists(output))) { + await this.downloadAndExtract(output, manifest, channel) + } - if (!await fs.pathExists(path.join(this.clientRoot, pinToVersion))) { - throw new Error(`Version ${pinToVersion} is not already installed at ${this.clientRoot}.`) - } - cli.action.start(`${this.config.name}: Updating CLI`) - this.debug(`switching to existing version ${pinToVersion}`) - this.updateToExistingVersion(pinToVersion) + await this.setChannel() + await this.createBin(this.updatedVersion) + await this.touch() + await this.reexec() + } - this.log() - this.log(`Updating to an already installed version will not update the channel. If autoupdate is enabled, the CLI will eventually be updated back to ${this.channel}.`) - } else { - cli.action.start(`${this.config.name}: Updating CLI`) - await this.config.runHook('preupdate', {channel: this.channel}) - const manifest = await this.fetchManifest() - this.currentVersion = await this.determineCurrentVersion() - this.updatedVersion = (manifest as any).sha ? `${manifest.version}-${(manifest as any).sha}` : manifest.version - const reason = await this.skipUpdate() - if (reason) cli.action.stop(reason || 'done') - else await this.update(manifest) - this.debug('tidy') - await this.tidy() - await this.config.runHook('update', {channel: this.channel}) + protected async updateToExistingVersion(version: string) { + await this.createBin(version) + await this.touch() } - this.debug('done') - cli.action.stop() - } - - protected async fetchManifest(): Promise { - const http: typeof HTTP = require('http-call').HTTP - - cli.action.status = 'fetching manifest' - if (!this.config.scopedEnvVarTrue('USE_LEGACY_UPDATE')) { - try { - const newManifestUrl = this.config.s3Url( - this.s3ChannelManifestKey( - this.config.bin, - this.config.platform, - this.config.arch, - (this.config.pjson.oclif.update.s3 as any).folder, - ), - ) - const {body} = await http.get(newManifestUrl) - if (typeof body === 'string') { - return JSON.parse(body) + protected async skipUpdate(): Promise { + if (!this.config.binPath) { + const instructions = this.config.scopedEnvVar('UPDATE_INSTRUCTIONS') + if (instructions) this.warn(instructions) + return 'not updatable' } - return body - } catch (error) { - this.debug(error.message) - } + if (this.currentVersion === this.updatedVersion) { + if (this.config.scopedEnvVar('HIDE_UPDATED_MESSAGE')) return 'done' + return `already on latest version: ${this.currentVersion}` + } + return false } - try { - const url = this.config.s3Url(this.config.s3Key('manifest', { - channel: this.channel, - platform: this.config.platform, - arch: this.config.arch, - })) - const {body} = await http.get(url) - - // in case the content-type is not set, parse as a string - // this will happen if uploading without `oclif-dev publish` - if (typeof body === 'string') { - return JSON.parse(body) - } - return body - } catch (error) { - if (error.statusCode === 403) throw new Error(`HTTP 403: Invalid channel ${this.channel}`) - throw error + protected async determineChannel(): Promise { + const channelPath = path.join(this.config.dataDir, 'channel') + this.debug(`Reading channel from ${channelPath}`) + if (fs.existsSync(channelPath)) { + const channel = await fs.readFile(channelPath, 'utf8') + this.debug(`Read channel from data: ${String(channel)}`) + return String(channel).trim() + } + return this.config.channel || 'stable' } - } - - protected async downloadAndExtract(output: string, manifest: IManifest, channel: string) { - const {version} = manifest - const filesize = (n: number): string => { - const [num, suffix] = require('filesize')(n, {output: 'array'}) - return num.toFixed(1) + ` ${suffix}` + protected async determineCurrentVersion(): Promise { + try { + const currentVersion = await fs.readFile(this.clientBin, 'utf8') + const matches = currentVersion.match(/\.\.[/|\\](.+)[/|\\]bin/) + return matches ? matches[1] : this.config.version + } catch (error) { + this.debug(error) + } + return this.config.version } - const http: typeof HTTP = require('http-call').HTTP - const gzUrl = manifest.gz || this.config.s3Url(this.config.s3Key('versioned', { - version, - channel, - bin: this.config.bin, - platform: this.config.platform, - arch: this.config.arch, - ext: 'gz', - })) - const {response: stream} = await http.stream(gzUrl) - stream.pause() - - const baseDir = manifest.baseDir || this.config.s3Key('baseDir', { - version, - channel, - bin: this.config.bin, - platform: this.config.platform, - arch: this.config.arch, - }) - const extraction = extract(stream, baseDir, output, manifest.sha256gz) - - // to-do: use cli.action.type - if ((cli.action as any).frames) { - // if spinner action - const total = parseInt(stream.headers['content-length']!, 10) - let current = 0 - const updateStatus = _.throttle( - (newStatus: string) => { - cli.action.status = newStatus - }, - 250, - {leading: true, trailing: false}, - ) - stream.on('data', data => { - current += data.length - updateStatus(`${filesize(current)}/${filesize(total)}`) - }) + protected s3ChannelManifestKey( + bin: string, + platform: string, + arch: string, + folder?: string + ): string { + let s3SubDir = folder || '' + if (s3SubDir !== '' && s3SubDir.slice(-1) !== '/') + s3SubDir = `${s3SubDir}/` + return path.join( + s3SubDir, + 'channels', + this.channel, + `${bin}-${platform}-${arch}-buildmanifest` + ) } - stream.resume() - await extraction - } - - protected async update(manifest: IManifest, channel = this.channel) { - const {channel: manifestChannel} = manifest - if (manifestChannel) channel = manifestChannel - cli.action.start(`${this.config.name}: Updating CLI from ${color.green(this.currentVersion)} to ${color.green(this.updatedVersion)}${channel === 'stable' ? '' : ' (' + color.yellow(channel) + ')'}`) - - await this.ensureClientDir() - const output = path.join(this.clientRoot, this.updatedVersion) - - if (!await fs.pathExists(output)) { - await this.downloadAndExtract(output, manifest, channel) + protected async setChannel() { + const channelPath = path.join(this.config.dataDir, 'channel') + this.debug(`Writing channel (${this.channel}) to ${channelPath}`) + fs.writeFile(channelPath, this.channel, 'utf8') } - await this.setChannel() - await this.createBin(this.updatedVersion) - await this.touch() - await this.reexec() - } - - protected async updateToExistingVersion(version: string) { - await this.createBin(version) - await this.touch() - } - - protected async skipUpdate(): Promise { - if (!this.config.binPath) { - const instructions = this.config.scopedEnvVar('UPDATE_INSTRUCTIONS') - if (instructions) this.warn(instructions) - return 'not updatable' - } - if (this.currentVersion === this.updatedVersion) { - if (this.config.scopedEnvVar('HIDE_UPDATED_MESSAGE')) return 'done' - return `already on latest version: ${this.currentVersion}` - } - return false - } - - protected async determineChannel(): Promise { - const channelPath = path.join(this.config.dataDir, 'channel') - this.debug(`Reading channel from ${channelPath}`) - if (fs.existsSync(channelPath)) { - const channel = await fs.readFile(channelPath, 'utf8') - this.debug(`Read channel from data: ${String(channel)}`) - return String(channel).trim() - } - return this.config.channel || 'stable' - } - - protected async determineCurrentVersion(): Promise { - try { - const currentVersion = await fs.readFile(this.clientBin, 'utf8') - const matches = currentVersion.match(/\.\.[/|\\](.+)[/|\\]bin/) - return matches ? matches[1] : this.config.version - } catch (error) { - this.debug(error) - } - return this.config.version - } - - protected s3ChannelManifestKey(bin: string, platform: string, arch: string, folder?: string): string { - let s3SubDir = folder || '' - if (s3SubDir !== '' && s3SubDir.slice(-1) !== '/') s3SubDir = `${s3SubDir}/` - return path.join(s3SubDir, 'channels', this.channel, `${bin}-${platform}-${arch}-buildmanifest`) - } - - protected async setChannel() { - const channelPath = path.join(this.config.dataDir, 'channel') - this.debug(`Writing channel (${this.channel}) to ${channelPath}`) - fs.writeFile(channelPath, this.channel, 'utf8') - } - - protected async logChop() { - try { - this.debug('log chop') - const logChopper = require('log-chopper').default - await logChopper.chop(this.config.errlog) - } catch (error) { - this.debug(error.message) + protected async logChop() { + try { + this.debug('log chop') + const logChopper = require('log-chopper').default + await logChopper.chop(this.config.errlog) + } catch (error) { + this.debug(error.message) + } } - } - - protected async mtime(f: string) { - const {mtime} = await fs.stat(f) - return mtime - } - - // when autoupdating, wait until the CLI isn't active - protected async debounce(): Promise { - let output = false - const lastrunfile = path.join(this.config.cacheDir, 'lastrun') - const m = await this.mtime(lastrunfile) - m.setHours(m.getHours() + 1) - if (m > new Date()) { - const msg = `waiting until ${m.toISOString()} to update` - if (output) { - this.debug(msg) - } else { - await cli.log(msg) - output = true - } - await wait(60 * 1000) // wait 1 minute - return this.debounce() + + protected async mtime(f: string) { + const { mtime } = await fs.stat(f) + return mtime } - cli.log('time to update') - } - - // removes any unused CLIs - protected async tidy() { - try { - const root = this.clientRoot - if (!await fs.pathExists(root)) return - const files = await ls(root) - const promises = files.map(async f => { - if (['bin', 'current', this.config.version].includes(path.basename(f.path))) return - const mtime = f.stat.mtime - mtime.setHours(mtime.getHours() + (14 * 24)) - if (mtime < new Date()) { - await fs.remove(f.path) + + // when autoupdating, wait until the CLI isn't active + protected async debounce(): Promise { + let output = false + const lastrunfile = path.join(this.config.cacheDir, 'lastrun') + const m = await this.mtime(lastrunfile) + m.setHours(m.getHours() + 1) + if (m > new Date()) { + const msg = `waiting until ${m.toISOString()} to update` + if (output) { + this.debug(msg) + } else { + await cli.log(msg) + output = true + } + await wait(60 * 1000) // wait 1 minute + return this.debounce() } - }) - for (const p of promises) await p // eslint-disable-line no-await-in-loop - await this.logChop() - } catch (error) { - cli.warn(error) + cli.log('time to update') } - } - - protected async touch() { - // touch the client so it won't be tidied up right away - try { - const p = path.join(this.clientRoot, this.config.version) - this.debug('touching client at', p) - if (!await fs.pathExists(p)) return - await fs.utimes(p, new Date(), new Date()) - } catch (error) { - this.warn(error) + + // removes any unused CLIs + protected async tidy() { + try { + const root = this.clientRoot + if (!(await fs.pathExists(root))) return + const files = await ls(root) + const promises = files.map(async (f) => { + if ( + ['bin', 'current', this.config.version].includes( + path.basename(f.path) + ) + ) + return + const mtime = f.stat.mtime + mtime.setHours(mtime.getHours() + 14 * 24) + if (mtime < new Date()) { + await fs.remove(f.path) + } + }) + for (const p of promises) await p // eslint-disable-line no-await-in-loop + await this.logChop() + } catch (error) { + cli.warn(error) + } } - } - - protected async reexec() { - cli.action.stop() - return new Promise((_, reject) => { - this.debug('restarting CLI after update', this.clientBin) - const commandArgs = ['update', this.channel ? this.channel : ''].filter(Boolean) - spawn(this.clientBin, commandArgs, { - stdio: 'inherit', - env: {...process.env, [this.config.scopedEnvVarKey('HIDE_UPDATED_MESSAGE')]: '1'}, - }) - .on('error', reject) - .on('close', (status: number) => { + + protected async touch() { + // touch the client so it won't be tidied up right away try { - if (status > 0) this.exit(status) + const p = path.join(this.clientRoot, this.config.version) + this.debug('touching client at', p) + if (!(await fs.pathExists(p))) return + await fs.utimes(p, new Date(), new Date()) } catch (error) { - reject(error) + this.warn(error) } - }) - }) - } - - protected async createBin(version: string) { - const dst = this.clientBin - const {bin} = this.config - const binPathEnvVar = this.config.scopedEnvVarKey('BINPATH') - const redirectedEnvVar = this.config.scopedEnvVarKey('REDIRECTED') - if (this.config.windows) { - const body = `@echo off + } + + protected async reexec() { + cli.action.stop() + return new Promise((_, reject) => { + this.debug('restarting CLI after update', this.clientBin) + const commandArgs = [ + 'update', + this.channel ? this.channel : '', + ].filter(Boolean) + spawn(this.clientBin, commandArgs, { + stdio: 'inherit', + env: { + ...process.env, + [this.config.scopedEnvVarKey('HIDE_UPDATED_MESSAGE')]: '1', + }, + }) + .on('error', reject) + .on('close', (status: number) => { + try { + if (status > 0) this.exit(status) + } catch (error) { + reject(error) + } + }) + }) + } + + protected async createBin(version: string) { + const dst = this.clientBin + const { bin } = this.config + const binPathEnvVar = this.config.scopedEnvVarKey('BINPATH') + const redirectedEnvVar = this.config.scopedEnvVarKey('REDIRECTED') + if (this.config.windows) { + const body = `@echo off setlocal enableextensions set ${redirectedEnvVar}=1 set ${binPathEnvVar}=%~dp0${bin} "%~dp0..\\${version}\\bin\\${bin}.cmd" %* ` - await fs.outputFile(dst, body) - } else { - /* eslint-disable no-useless-escape */ - const body = `#!/usr/bin/env bash + await fs.outputFile(dst, body) + } else { + /* eslint-disable no-useless-escape */ + const body = `#!/usr/bin/env bash set -e get_script_dir () { SOURCE="\${BASH_SOURCE[0]}" @@ -366,28 +442,31 @@ get_script_dir () { DIR=$(get_script_dir) ${binPathEnvVar}="\$DIR/${bin}" ${redirectedEnvVar}=1 "$DIR/../${version}/bin/${bin}" "$@" ` - /* eslint-enable no-useless-escape */ - - await fs.remove(dst) - await fs.outputFile(dst, body) - await fs.chmod(dst, 0o755) - await fs.remove(path.join(this.clientRoot, 'current')) - await fs.symlink(`./${version}`, path.join(this.clientRoot, 'current')) + /* eslint-enable no-useless-escape */ + + await fs.remove(dst) + await fs.outputFile(dst, body) + await fs.chmod(dst, 0o755) + await fs.remove(path.join(this.clientRoot, 'current')) + await fs.symlink( + `./${version}`, + path.join(this.clientRoot, 'current') + ) + } } - } - - protected async ensureClientDir() { - try { - await fs.mkdirp(this.clientRoot) - } catch (error) { - if (error.code === 'EEXIST') { - // for some reason the client directory is sometimes a file - // if so, this happens. Delete it and recreate - await fs.remove(this.clientRoot) - await fs.mkdirp(this.clientRoot) - } else { - throw error - } + + protected async ensureClientDir() { + try { + await fs.mkdirp(this.clientRoot) + } catch (error) { + if (error.code === 'EEXIST') { + // for some reason the client directory is sometimes a file + // if so, this happens. Delete it and recreate + await fs.remove(this.clientRoot) + await fs.mkdirp(this.clientRoot) + } else { + throw error + } + } } - } } diff --git a/src/commands/use.ts b/src/commands/use.ts index 39bb6c6e..f03f0f65 100644 --- a/src/commands/use.ts +++ b/src/commands/use.ts @@ -4,85 +4,101 @@ import * as semver from 'semver' import UpdateCommand from './update' -const SEMVER_REGEX = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?/ +const SEMVER_REGEX = + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?/ export default class UseCommand extends UpdateCommand { - static args = [{name: 'version', optional: false}]; + static args = [{ name: 'version', optional: false }] - static flags = {}; + static flags = {} - async run() { - const {args} = this.parse(UseCommand) + async run() { + const { args } = this.parse(UseCommand) - // Check if this command is trying to update the channel. TODO: make this dynamic - const prereleaseChannels = ['alpha', 'beta', 'next'] - const isExplicitVersion = SEMVER_REGEX.test(args.version || '') - const channelUpdateRequested = ['stable', ...prereleaseChannels].some( - c => args.version === c, - ) + // Check if this command is trying to update the channel. TODO: make this dynamic + const prereleaseChannels = ['alpha', 'beta', 'next'] + const isExplicitVersion = SEMVER_REGEX.test(args.version || '') + const channelUpdateRequested = ['stable', ...prereleaseChannels].some( + (c) => args.version === c + ) - if (!isExplicitVersion && !channelUpdateRequested) { - throw new Error( - `Invalid argument provided: ${args.version}. Please specify either a valid channel (alpha, beta, next, stable) or an explicit version (ex. 2.68.13)`, - ) - } + if (!isExplicitVersion && !channelUpdateRequested) { + throw new Error( + `Invalid argument provided: ${args.version}. Please specify either a valid channel (alpha, beta, next, stable) or an explicit version (ex. 2.68.13)` + ) + } - this.channel = channelUpdateRequested ? - args.version : - await this.determineChannel() + this.channel = channelUpdateRequested + ? args.version + : await this.determineChannel() - const targetVersion = semver.clean(args.version || '') || args.version + const targetVersion = semver.clean(args.version || '') || args.version - // Determine if the version is from a different channel and update to account for it (ex. cli-example update 3.0.0-next.22 should update the channel to next as well.) - const versionParts = targetVersion?.split('-') || ['', ''] - if (versionParts && versionParts[1]) { - this.channel = versionParts[1].substr(0, versionParts[1].indexOf('.')) - this.debug(`Flag overriden target channel: ${this.channel}`) - } + // Determine if the version is from a different channel and update to account for it (ex. cli-example update 3.0.0-next.22 should update the channel to next as well.) + const versionParts = targetVersion?.split('-') || ['', ''] + if (versionParts && versionParts[1]) { + this.channel = versionParts[1].substr( + 0, + versionParts[1].indexOf('.') + ) + this.debug(`Flag overriden target channel: ${this.channel}`) + } - await this.ensureClientDir() - this.debug(`Looking for locally installed versions at ${this.clientRoot}`) + await this.ensureClientDir() + this.debug( + `Looking for locally installed versions at ${this.clientRoot}` + ) - // Do not show known non-local version folder names, bin and current. - const versions = fs - .readdirSync(this.clientRoot) - .filter(dirOrFile => dirOrFile !== 'bin' && dirOrFile !== 'current') - if (versions.length === 0) - throw new Error('No locally installed versions found.') - const matchingLocalVersions = versions - .filter(version => { - // - If the version contains 'partial', ignore it - if (version.includes('partial')) { - return false - } - // - If we request stable, only provide standard versions... - if (this.channel === 'stable') { - return !prereleaseChannels.some(c => version.includes(c)) - } - // - ... otherwise check if the version is contained - return version.includes(targetVersion) - }) - .sort((a, b) => semver.compare(b, a)) + // Do not show known non-local version folder names, bin and current. + const versions = fs + .readdirSync(this.clientRoot) + .filter( + (dirOrFile) => dirOrFile !== 'bin' && dirOrFile !== 'current' + ) + if (versions.length === 0) + throw new Error('No locally installed versions found.') + const matchingLocalVersions = versions + .filter((version) => { + // - If the version contains 'partial', ignore it + if (version.includes('partial')) { + return false + } + // - If we request stable, only provide standard versions... + if (this.channel === 'stable') { + return !prereleaseChannels.some((c) => version.includes(c)) + } + // - ... otherwise check if the version is contained + return version.includes(targetVersion) + }) + .sort((a, b) => semver.compare(b, a)) - if (args.version && (versions.includes(targetVersion) || matchingLocalVersions.length > 0)) { - const target = versions.includes(targetVersion) ? targetVersion : matchingLocalVersions[0] - await this.updateToExistingVersion(target) - this.currentVersion = await this.determineCurrentVersion() - this.updatedVersion = target - if (channelUpdateRequested) { - await this.setChannel() - } - this.log(`Success! You are now on ${target}!`) - } else { - const localVersionsMsg = `Locally installed versions available: \n${versions.map(version => `\t${version}`).join('\n')}\n` + if ( + args.version && + (versions.includes(targetVersion) || + matchingLocalVersions.length > 0) + ) { + const target = versions.includes(targetVersion) + ? targetVersion + : matchingLocalVersions[0] + await this.updateToExistingVersion(target) + this.currentVersion = await this.determineCurrentVersion() + this.updatedVersion = target + if (channelUpdateRequested) { + await this.setChannel() + } + this.log(`Success! You are now on ${target}!`) + } else { + const localVersionsMsg = `Locally installed versions available: \n${versions + .map((version) => `\t${version}`) + .join('\n')}\n` - throw new Error( - `Requested version could not be found locally. ${localVersionsMsg}`, - ) - } + throw new Error( + `Requested version could not be found locally. ${localVersionsMsg}` + ) + } - this.log() - this.debug('done') - cli.action.stop() - } + this.log() + this.debug('done') + cli.action.stop() + } } diff --git a/src/hooks/init.ts b/src/hooks/init.ts index 6cb192d0..f01f6e54 100644 --- a/src/hooks/init.ts +++ b/src/hooks/init.ts @@ -3,71 +3,77 @@ import cli from 'cli-ux' import * as spawn from 'cross-spawn' import * as fs from 'fs-extra' import * as path from 'path' +import debugUtil from 'debug' +import { touch } from '../util' -import {touch} from '../util' - -const debug = require('debug')('cli:updater') +const debug = debugUtil('cli:updater') function timestamp(msg: string): string { - return `[${new Date().toISOString()}] ${msg}` + return `[${new Date().toISOString()}] ${msg}` } async function mtime(f: string) { - const {mtime} = await fs.stat(f) - return mtime + const { mtime } = await fs.stat(f) + return mtime } export const init: Config.Hook<'init'> = async function (opts) { - if (opts.id === 'update') return - if (opts.config.scopedEnvVarTrue('DISABLE_AUTOUPDATE')) return - const binPath = this.config.binPath || this.config.bin - const lastrunfile = path.join(this.config.cacheDir, 'lastrun') - const autoupdatefile = path.join(this.config.cacheDir, 'autoupdate') - const autoupdatelogfile = path.join(this.config.cacheDir, 'autoupdate.log') - const clientRoot = this.config.scopedEnvVar('OCLIF_CLIENT_HOME') || path.join(this.config.dataDir, 'client') + if (opts.id === 'update') return + if (opts.config.scopedEnvVarTrue('DISABLE_AUTOUPDATE')) return + const binPath = this.config.binPath || this.config.bin + const lastrunfile = path.join(this.config.cacheDir, 'lastrun') + const autoupdatefile = path.join(this.config.cacheDir, 'autoupdate') + const autoupdatelogfile = path.join(this.config.cacheDir, 'autoupdate.log') + const clientRoot = + this.config.scopedEnvVar('OCLIF_CLIENT_HOME') || + path.join(this.config.dataDir, 'client') - const autoupdateEnv = { - ...process.env, - [this.config.scopedEnvVarKey('TIMESTAMPS')]: '1', - [this.config.scopedEnvVarKey('SKIP_ANALYTICS')]: '1', - } + const autoupdateEnv = { + ...process.env, + [this.config.scopedEnvVarKey('TIMESTAMPS')]: '1', + [this.config.scopedEnvVarKey('SKIP_ANALYTICS')]: '1', + } - async function autoupdateNeeded(): Promise { - try { - const m = await mtime(autoupdatefile) - let days = 1 - if (opts.config.channel === 'stable') days = 14 - m.setHours(m.getHours() + (days * 24)) - return m < new Date() - } catch (error) { - if (error.code !== 'ENOENT') cli.error(error.stack) - if ((global as any).testing) return false - debug('autoupdate ENOENT') - return true + async function autoupdateNeeded(): Promise { + try { + const m = await mtime(autoupdatefile) + let days = 1 + if (opts.config.channel === 'stable') days = 14 + m.setHours(m.getHours() + days * 24) + return m < new Date() + } catch (error) { + if (error.code !== 'ENOENT') cli.error(error.stack) + if ((global as any).testing) return false + debug('autoupdate ENOENT') + return true + } } - } - await touch(lastrunfile) - const clientDir = path.join(clientRoot, this.config.version) - if (await fs.pathExists(clientDir)) await touch(clientDir) - if (!await autoupdateNeeded()) return + await touch(lastrunfile) + const clientDir = path.join(clientRoot, this.config.version) + if (await fs.pathExists(clientDir)) await touch(clientDir) + if (!(await autoupdateNeeded())) return - debug('autoupdate running') - await fs.outputFile(autoupdatefile, '') + debug('autoupdate running') + await fs.outputFile(autoupdatefile, '') - debug(`spawning autoupdate on ${binPath}`) + debug(`spawning autoupdate on ${binPath}`) - const fd = await fs.open(autoupdatelogfile, 'a') - fs.write( - fd, - timestamp(`starting \`${binPath} update --autoupdate\` from ${process.argv.slice(1, 3).join(' ')}\n`), - ) + const fd = await fs.open(autoupdatelogfile, 'a') + fs.write( + fd, + timestamp( + `starting \`${binPath} update --autoupdate\` from ${process.argv + .slice(1, 3) + .join(' ')}\n` + ) + ) - spawn(binPath, ['update', '--autoupdate'], { - detached: !this.config.windows, - stdio: ['ignore', fd, fd], - env: autoupdateEnv, - }) - .on('error', (e: Error) => process.emitWarning(e)) - .unref() + spawn(binPath, ['update', '--autoupdate'], { + detached: !this.config.windows, + stdio: ['ignore', fd, fd], + env: autoupdateEnv, + }) + .on('error', (e: Error) => process.emitWarning(e)) + .unref() } diff --git a/src/tar.ts b/src/tar.ts index 137ece9e..e4db2c4c 100644 --- a/src/tar.ts +++ b/src/tar.ts @@ -1,82 +1,97 @@ import * as fs from 'fs-extra' import * as path from 'path' +import debugUtil from 'debug' +import { touch } from './util' -import {touch} from './util' +const debug = debugUtil('oclif-update') -const debug = require('debug')('oclif-update') +export async function extract( + stream: NodeJS.ReadableStream, + basename: string, + output: string, + sha?: string +) { + const getTmp = () => + `${output}.partial.${Math.random() + .toString() + .split('.')[1] + .slice(0, 5)}` + let tmp = getTmp() + if (fs.pathExistsSync(tmp)) tmp = getTmp() + debug(`extracting to ${tmp}`) + try { + await new Promise((resolve, reject) => { + const zlib = require('zlib') + const tar = require('tar-fs') + const crypto = require('crypto') -export async function extract(stream: NodeJS.ReadableStream, basename: string, output: string, sha?: string) { - const getTmp = () => `${output}.partial.${Math.random().toString().split('.')[1].slice(0, 5)}` - let tmp = getTmp() - if (fs.pathExistsSync(tmp)) tmp = getTmp() - debug(`extracting to ${tmp}`) - try { - await new Promise((resolve, reject) => { - const zlib = require('zlib') - const tar = require('tar-fs') - const crypto = require('crypto') - let shaValidated = false - let extracted = false - const check = () => shaValidated && extracted && resolve(true) + let shaValidated = false + let extracted = false + const check = () => shaValidated && extracted && resolve(true) - if (sha) { - const hasher = crypto.createHash('sha256') - stream.on('error', reject) - stream.on('data', d => hasher.update(d)) - stream.on('end', () => { - const shasum = hasher.digest('hex') - if (sha === shasum) { - shaValidated = true - check() - } else { - reject(new Error(`SHA mismatch: expected ${shasum} to be ${sha}`)) - } - }) - } else shaValidated = true + if (sha) { + const hasher = crypto.createHash('sha256') + stream.on('error', reject) + stream.on('data', (d) => hasher.update(d)) + stream.on('end', () => { + const shasum = hasher.digest('hex') + if (sha === shasum) { + shaValidated = true + check() + } else { + reject( + new Error( + `SHA mismatch: expected ${shasum} to be ${sha}` + ) + ) + } + }) + } else shaValidated = true - const ignore = (_: any, header: any) => { - switch (header.type) { - case 'directory': - case 'file': - if (process.env.OCLIF_DEBUG_UPDATE_FILES) debug(header.name) - return false - case 'symlink': - return true - default: - throw new Error(header.type) - } - } - const extract = tar.extract(tmp, {ignore}) - extract.on('error', reject) - extract.on('finish', () => { - extracted = true - check() - }) + const ignore = (_: any, header: any) => { + switch (header.type) { + case 'directory': + case 'file': + if (process.env.OCLIF_DEBUG_UPDATE_FILES) + debug(header.name) + return false + case 'symlink': + return true + default: + throw new Error(header.type) + } + } + const extract = tar.extract(tmp, { ignore }) + extract.on('error', reject) + extract.on('finish', () => { + extracted = true + check() + }) - const gunzip = zlib.createGunzip() - gunzip.on('error', reject) + const gunzip = zlib.createGunzip() + gunzip.on('error', reject) - stream.pipe(gunzip).pipe(extract) - }) + stream.pipe(gunzip).pipe(extract) + }) - if (await fs.pathExists(output)) { - try { - const tmp = getTmp() - await fs.move(output, tmp) + if (await fs.pathExists(output)) { + try { + const tmp = getTmp() + await fs.move(output, tmp) + await fs.remove(tmp).catch(debug) + } catch (error) { + debug(error) + await fs.remove(output) + } + } + const from = path.join(tmp, basename) + debug('moving %s to %s', from, output) + await fs.rename(from, output) await fs.remove(tmp).catch(debug) - } catch (error) { - debug(error) - await fs.remove(output) - } + await touch(output) + debug('done extracting') + } catch (error) { + await fs.remove(tmp).catch(process.emitWarning) + throw error } - const from = path.join(tmp, basename) - debug('moving %s to %s', from, output) - await fs.rename(from, output) - await fs.remove(tmp).catch(debug) - await touch(output) - debug('done extracting') - } catch (error) { - await fs.remove(tmp).catch(process.emitWarning) - throw error - } } diff --git a/src/util.ts b/src/util.ts index 070617cd..4c86be08 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2,22 +2,24 @@ import * as fs from 'fs-extra' import * as path from 'path' export async function touch(p: string) { - try { - await fs.utimes(p, new Date(), new Date()) - } catch { - await fs.outputFile(p, '') - } + try { + await fs.utimes(p, new Date(), new Date()) + } catch { + await fs.outputFile(p, '') + } } export async function ls(dir: string) { - const files = await fs.readdir(dir) - const paths = files.map(f => path.join(dir, f)) - return Promise.all(paths.map(path => fs.stat(path).then(stat => ({path, stat})))) + const files = await fs.readdir(dir) + const paths = files.map((f) => path.join(dir, f)) + return Promise.all( + paths.map((path) => fs.stat(path).then((stat) => ({ path, stat }))) + ) } export function wait(ms: number, unref = false): Promise { - return new Promise(resolve => { - const t: any = setTimeout(() => resolve(), ms) - if (unref) t.unref() - }) + return new Promise((resolve) => { + const t: any = setTimeout(() => resolve(), ms) + if (unref) t.unref() + }) } diff --git a/test/commands/install.test.ts b/test/commands/install.test.ts index 52ae3a12..fe7c8420 100644 --- a/test/commands/install.test.ts +++ b/test/commands/install.test.ts @@ -1,49 +1,53 @@ import InstallCommand from '../../src/commands/use' import * as fs from 'fs' -import {mocked} from 'ts-jest/utils' -import {IConfig} from '@oclif/config' +import { mocked } from 'ts-jest/utils' +import { IConfig } from '@oclif/config' const mockFs = mocked(fs, true) class MockedInstallCommand extends InstallCommand { - public fetchManifest = jest.fn() + public fetchManifest = jest.fn() - public downloadAndExtract = jest.fn() + public downloadAndExtract = jest.fn() } describe.skip('Install Command', () => { - let commandInstance: MockedInstallCommand - let config: IConfig - beforeEach(() => { - mockFs.existsSync.mockReturnValue(true) - - config = { - name: 'test', - version: '', - channel: '', - cacheDir: '', - commandIDs: [''], - topics: [], - valid: true, - arch: 'arm64', - platform: 'darwin', - plugins: [], - commands: [], - configDir: '', - pjson: {} as any, - root: '', - bin: '', - } as any - }) - - it.skip('will run an update', async () => { - commandInstance = new MockedInstallCommand([], config) - - await commandInstance.run() - }) - - it.todo('when requesting a channel, will fetch manifest to install the latest version') - it.todo('when requesting a version, will return the explicit version with appropriate URL') - it.todo('will handle an invalid version request') - it.todo('will handle an invalid channel request') + let commandInstance: MockedInstallCommand + let config: IConfig + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true) + + config = { + name: 'test', + version: '', + channel: '', + cacheDir: '', + commandIDs: [''], + topics: [], + valid: true, + arch: 'arm64', + platform: 'darwin', + plugins: [], + commands: [], + configDir: '', + pjson: {} as any, + root: '', + bin: '', + } as any + }) + + it.skip('will run an update', async () => { + commandInstance = new MockedInstallCommand([], config) + + await commandInstance.run() + }) + + it.todo( + 'when requesting a channel, will fetch manifest to install the latest version' + ) + it.todo( + 'when requesting a version, will return the explicit version with appropriate URL' + ) + it.todo('will handle an invalid version request') + it.todo('will handle an invalid channel request') }) diff --git a/test/commands/update.skip.ts b/test/commands/update.skip.ts index 897c45f0..df5468e2 100644 --- a/test/commands/update.skip.ts +++ b/test/commands/update.skip.ts @@ -1,77 +1,94 @@ -import {expect} from 'chai' +import { expect } from 'chai' import * as path from 'path' import * as qq from 'qqjs' const skipIfWindows = process.platform === 'win32' ? it.skip : it describe('update', () => { - skipIfWindows('tests the updater', async () => { - await qq.rm([process.env.HOME!, '.local', 'share', 'oclif-example-s3-cli']) - await qq.x('aws s3 rm --recursive s3://oclif-staging/s3-update-example-cli') - const sha = await qq.x.stdout('git', ['rev-parse', '--short', 'HEAD']) - const stdout = await qq.x.stdout('npm', ['pack', '--unsafe-perm']) - const tarball = path.resolve(stdout.split('\n').pop()!) + skipIfWindows('tests the updater', async () => { + await qq.rm([ + process.env.HOME!, + '.local', + 'share', + 'oclif-example-s3-cli', + ]) + await qq.x( + 'aws s3 rm --recursive s3://oclif-staging/s3-update-example-cli' + ) + const sha = await qq.x.stdout('git', ['rev-parse', '--short', 'HEAD']) + const stdout = await qq.x.stdout('npm', ['pack', '--unsafe-perm']) + const tarball = path.resolve(stdout.split('\n').pop()!) - qq.cd('examples/s3-update-example-cli') - /* eslint-disable require-atomic-updates */ - process.env.EXAMPLE_CLI_DISABLE_AUTOUPDATE = '1' - process.env.YARN_CACHE_FOLDER = path.resolve('tmp', 'yarn') - /* eslint-enable require-atomic-updates */ - await qq.rm(process.env.YARN_CACHE_FOLDER) - const pjson = await qq.readJSON('package.json') - pjson.name = `s3-update-example-cli-${Math.floor(Math.random() * 100000)}` - pjson.oclif.bin = pjson.name - delete pjson.dependencies['@oclif/plugin-update'] - await qq.writeJSON('package.json', pjson) + qq.cd('examples/s3-update-example-cli') + /* eslint-disable require-atomic-updates */ + process.env.EXAMPLE_CLI_DISABLE_AUTOUPDATE = '1' + process.env.YARN_CACHE_FOLDER = path.resolve('tmp', 'yarn') + /* eslint-enable require-atomic-updates */ + await qq.rm(process.env.YARN_CACHE_FOLDER) + const pjson = await qq.readJSON('package.json') + pjson.name = `s3-update-example-cli-${Math.floor( + Math.random() * 100000 + )}` + pjson.oclif.bin = pjson.name + delete pjson.dependencies['@oclif/plugin-update'] + await qq.writeJSON('package.json', pjson) - await qq.rm('yarn.lock') - await qq.x(`yarn add ${tarball}`) - // await qq.x('yarn') + await qq.rm('yarn.lock') + await qq.x(`yarn add ${tarball}`) + // await qq.x('yarn') - const release = async (version: string) => { - const pjson = await qq.readJSON('package.json') - pjson.version = version - await qq.writeJSON('package.json', pjson) - await qq.x('./node_modules/.bin/oclif-dev pack') - await qq.x('./node_modules/.bin/oclif-dev publish') - } - const checkVersion = async (version: string, nodeVersion = pjson.oclif.update.node.version) => { - const stdout = await qq.x.stdout(`./tmp/${pjson.oclif.bin}/bin/${pjson.oclif.bin}`, ['version']) - expect(stdout).to.equal(`${pjson.oclif.bin}/${version} ${process.platform}-${process.arch} node-v${nodeVersion}`) - } - const update = async (channel?: string) => { - const f = `tmp/${pjson.oclif.bin}/package.json` - const pj = await qq.readJSON(f) - pj.version = '0.0.0' - await qq.writeJSON(f, pj) - const args = ['update'] - if (channel) args.push(channel) - await qq.x(`./tmp/${pjson.oclif.bin}/bin/${pjson.oclif.bin}`, args) - } - await release('1.0.0') - await checkVersion('1.0.0', process.versions.node) - await release('2.0.0-beta') - await checkVersion(`2.0.0-beta.${sha}`, process.versions.node) - await update() - await checkVersion('1.0.0') - await release('1.0.1') - await checkVersion('1.0.0') - await update() - await checkVersion('1.0.1') - await update() - await checkVersion('1.0.1') - await update('beta') - await checkVersion(`2.0.0-beta.${sha}`) - await release('2.0.1-beta') - await checkVersion(`2.0.0-beta.${sha}`) - await update() - await checkVersion(`2.0.1-beta.${sha}`) - await update() - await checkVersion(`2.0.1-beta.${sha}`) - await release('1.0.3') - await update() - await checkVersion(`2.0.1-beta.${sha}`) - await update('stable') - await checkVersion('1.0.3') - }) + const release = async (version: string) => { + const pjson = await qq.readJSON('package.json') + pjson.version = version + await qq.writeJSON('package.json', pjson) + await qq.x('./node_modules/.bin/oclif-dev pack') + await qq.x('./node_modules/.bin/oclif-dev publish') + } + const checkVersion = async ( + version: string, + nodeVersion = pjson.oclif.update.node.version + ) => { + const stdout = await qq.x.stdout( + `./tmp/${pjson.oclif.bin}/bin/${pjson.oclif.bin}`, + ['version'] + ) + expect(stdout).to.equal( + `${pjson.oclif.bin}/${version} ${process.platform}-${process.arch} node-v${nodeVersion}` + ) + } + const update = async (channel?: string) => { + const f = `tmp/${pjson.oclif.bin}/package.json` + const pj = await qq.readJSON(f) + pj.version = '0.0.0' + await qq.writeJSON(f, pj) + const args = ['update'] + if (channel) args.push(channel) + await qq.x(`./tmp/${pjson.oclif.bin}/bin/${pjson.oclif.bin}`, args) + } + await release('1.0.0') + await checkVersion('1.0.0', process.versions.node) + await release('2.0.0-beta') + await checkVersion(`2.0.0-beta.${sha}`, process.versions.node) + await update() + await checkVersion('1.0.0') + await release('1.0.1') + await checkVersion('1.0.0') + await update() + await checkVersion('1.0.1') + await update() + await checkVersion('1.0.1') + await update('beta') + await checkVersion(`2.0.0-beta.${sha}`) + await release('2.0.1-beta') + await checkVersion(`2.0.0-beta.${sha}`) + await update() + await checkVersion(`2.0.1-beta.${sha}`) + await update() + await checkVersion(`2.0.1-beta.${sha}`) + await release('1.0.3') + await update() + await checkVersion(`2.0.1-beta.${sha}`) + await update('stable') + await checkVersion('1.0.3') + }) }) diff --git a/test/commands/update.test.ts b/test/commands/update.test.ts index 894393da..b2b1496e 100644 --- a/test/commands/update.test.ts +++ b/test/commands/update.test.ts @@ -1,49 +1,49 @@ import UpdateCommand from '../../src/commands/update' import * as fs from 'fs' -import {mocked} from 'ts-jest/utils' -import {IConfig} from '@oclif/config' +import { mocked } from 'ts-jest/utils' +import { IConfig } from '@oclif/config' const mockFs = mocked(fs, true) class MockedUpdateCommand extends UpdateCommand { - constructor(a: string[], v: IConfig) { - super(a, v) - this.fetchManifest = jest.fn() - this.downloadAndExtract = jest.fn() - } + constructor(a: string[], v: IConfig) { + super(a, v) + this.fetchManifest = jest.fn() + this.downloadAndExtract = jest.fn() + } } describe('Update Command', () => { - let commandInstance: MockedUpdateCommand - let config: IConfig - beforeEach(() => { - mockFs.existsSync.mockReturnValue(true) + let commandInstance: MockedUpdateCommand + let config: IConfig + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true) - config = { - name: 'test', - version: '', - channel: '', - cacheDir: '', - commandIDs: [''], - topics: [], - valid: true, - arch: 'arm64', - platform: 'darwin', - plugins: [], - commands: [], - configDir: '', - pjson: {} as any, - root: '', - bin: '', - } as any - }) + config = { + name: 'test', + version: '', + channel: '', + cacheDir: '', + commandIDs: [''], + topics: [], + valid: true, + arch: 'arm64', + platform: 'darwin', + plugins: [], + commands: [], + configDir: '', + pjson: {} as any, + root: '', + bin: '', + } as any + }) - it.skip('will run an update', async () => { - commandInstance = new MockedUpdateCommand([], config) + it.skip('will run an update', async () => { + commandInstance = new MockedUpdateCommand([], config) - await commandInstance.run() - }) + await commandInstance.run() + }) - it.todo('Will update to the current channel when no options are provided') - it.todo('Will update to a new channel when provided in args') + it.todo('Will update to the current channel when no options are provided') + it.todo('Will update to a new channel when provided in args') }) diff --git a/test/commands/use.test.ts b/test/commands/use.test.ts index 591ebb38..711cfd4d 100644 --- a/test/commands/use.test.ts +++ b/test/commands/use.test.ts @@ -1,250 +1,260 @@ import UseCommand from '../../src/commands/use' import * as fs from 'fs-extra' -import {mocked} from 'ts-jest/utils' -import {IConfig} from '@oclif/config' -import {IManifest} from '@oclif/dev-cli' +import { mocked } from 'ts-jest/utils' +import { IConfig } from '@oclif/config' +import { IManifest } from '@oclif/dev-cli' jest.mock('fs-extra') const mockFs = mocked(fs, true) class MockedUseCommand extends UseCommand { - public channel!: string; + public channel!: string - public clientRoot!: string; + public clientRoot!: string - public currentVersion!: string; + public currentVersion!: string - public updatedVersion!: string; + public updatedVersion!: string - public fetchManifest = jest.fn(); + public fetchManifest = jest.fn() - public downloadAndExtract = jest.fn(); + public downloadAndExtract = jest.fn() } describe('Use Command', () => { - let commandInstance: MockedUseCommand - let config: IConfig - beforeEach(() => { - mockFs.existsSync.mockReturnValue(true) - - config = { - name: 'test', - version: '1.0.0', - channel: 'stable', - cacheDir: '', - commandIDs: [''], - runHook: jest.fn(), - topics: [], - valid: true, - arch: 'arm64', - platform: 'darwin', - plugins: [], - commands: [], - configDir: '', - dataDir: '', - pjson: {} as any, - root: '', - bin: 'cli', - scopedEnvVarKey: jest.fn(), - scopedEnvVar: jest.fn(), - } as any - }) - - it('when provided a channel, uses the latest version available locally', async () => { - mockFs.readdirSync.mockReturnValue([ - '1.0.0-next.2', - '1.0.0-next.3', - '1.0.1', - '1.0.0-alpha.0', - ] as any) - - // oclif-example use next - commandInstance = new MockedUseCommand(['next'], config) - - commandInstance.fetchManifest.mockResolvedValue({}) - - await commandInstance.run() - - expect(commandInstance.downloadAndExtract).not.toBeCalled() - expect(commandInstance.updatedVersion).toBe('1.0.0-next.3') - expect(commandInstance.channel).toBe('next') - }) - - it('when provided stable channel, uses only release versions', async () => { - mockFs.readdirSync.mockReturnValue([ - '1.0.0-next.2', - '1.0.3', - '1.0.0-next.3', - '1.0.1', - '1.0.0-alpha.0', - ] as any) - - // oclif-example use next - commandInstance = new MockedUseCommand(['stable'], config) - - commandInstance.fetchManifest.mockResolvedValue({}) - - await commandInstance.run() - - expect(commandInstance.downloadAndExtract).not.toBeCalled() - expect(commandInstance.updatedVersion).toBe('1.0.3') - expect(commandInstance.channel).toBe('stable') - }) - - it('when provided a version, will directly switch to it locally', async () => { - mockFs.readdirSync.mockReturnValue([ - '1.0.0-next.2', - '1.0.0-next.3', - '1.0.1', - '1.0.0-alpha.0', - ] as any) - - // oclif-example use '1.0.0-alpha.0' - commandInstance = new MockedUseCommand(['1.0.0-alpha.0'], config) - - commandInstance.fetchManifest.mockResolvedValue({ - channel: 'alpha', - } as IManifest) - - await commandInstance.run() - - expect(commandInstance.downloadAndExtract).not.toBeCalled() - expect(commandInstance.updatedVersion).toBe('1.0.0-alpha.0') - }) - - it('will print a warning when the requested static version is not available locally', async () => { - mockFs.readdirSync.mockReturnValue([ - '1.0.0-next.2', - '1.0.0-next.3', - '1.0.1', - '1.0.0-alpha.0', - ] as any) - - // oclif-example use '1.0.0-alpha.3' - commandInstance = new MockedUseCommand(['1.0.0-alpha.3'], config) - - commandInstance.fetchManifest.mockResolvedValue({}) - - let err - - try { - await commandInstance.run() - } catch (error) { - err = error - } - - const localVersionsMsg = `Locally installed versions available: \n${[ - '1.0.0-next.2', - '1.0.0-next.3', - '1.0.1', - '1.0.0-alpha.0', - ].map(version => `\t${version}`).join('\n')}\n` - - expect(commandInstance.downloadAndExtract).not.toBeCalled() - expect(err.message).toBe(`Requested version could not be found locally. ${localVersionsMsg}`) - }) - - it('will ignore partials when trying to update to stable', async () => { - mockFs.readdirSync.mockReturnValue([ - '1.0.0-next.2', - '1.0.0-next.3', - '1.0.1', - '1.0.2.partial.0000', - '1.0.1.partial.0000', - '1.0.0-alpha.0', - ] as any) - - // oclif-example use '1.0.0-alpha.0' - commandInstance = new MockedUseCommand(['stable'], config) - - commandInstance.fetchManifest.mockResolvedValue({ - channel: 'stable', - } as IManifest) - - await commandInstance.run() - - expect(commandInstance.downloadAndExtract).not.toBeCalled() - expect(commandInstance.updatedVersion).toBe('1.0.1') - }) - - it('will ignore partials when trying to update to a prerelease', async () => { - mockFs.readdirSync.mockReturnValue([ - '1.0.0-next.2', - '1.0.0-next.3', - '1.0.1', - '1.0.2.partial.0000', - '1.0.1.partial.0000', - '1.0.0-alpha.0', - '1.0.0-alpha.1.partial.0', - '1.0.0-alpha.2.partial.12', - ] as any) - - // oclif-example use '1.0.0-alpha.0' - commandInstance = new MockedUseCommand(['alpha'], config) - - commandInstance.fetchManifest.mockResolvedValue({ - channel: 'stable', - } as IManifest) - - await commandInstance.run() - - expect(commandInstance.downloadAndExtract).not.toBeCalled() - expect(commandInstance.updatedVersion).toBe('1.0.0-alpha.0') - }) - - it('will print a warning when the requested channel is not available locally', async () => { - mockFs.readdirSync.mockReturnValue([ - '1.0.0-next.2', - '1.0.0-next.3', - '1.0.1', - '1.0.0-alpha.0', - ] as any) - - // oclif-example use test - commandInstance = new MockedUseCommand(['beta'], config) - - commandInstance.fetchManifest.mockResolvedValue({}) - - let err - - try { - await commandInstance.run() - } catch (error) { - err = error - } - - const localVersionsMsg = `Locally installed versions available: \n${[ - '1.0.0-next.2', - '1.0.0-next.3', - '1.0.1', - '1.0.0-alpha.0', - ].map(version => `\t${version}`).join('\n')}\n` - - expect(commandInstance.downloadAndExtract).not.toBeCalled() - expect(err.message).toBe(`Requested version could not be found locally. ${localVersionsMsg}`) - }) - - it('will throw an error when invalid channel is provided', async () => { - mockFs.readdirSync.mockReturnValue([ - '1.0.0-next.2', - '1.0.0-next.3', - '1.0.1', - '1.0.0-alpha.0', - ] as any) - - // oclif-example use test - commandInstance = new MockedUseCommand(['test'], config) - - commandInstance.fetchManifest.mockResolvedValue({}) - - let err - - try { - await commandInstance.run() - } catch (error) { - err = error - } - - expect(commandInstance.downloadAndExtract).not.toBeCalled() - expect(err.message).toBe('Invalid argument provided: test. Please specify either a valid channel (alpha, beta, next, stable) or an explicit version (ex. 2.68.13)') - }) + let commandInstance: MockedUseCommand + let config: IConfig + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true) + + config = { + name: 'test', + version: '1.0.0', + channel: 'stable', + cacheDir: '', + commandIDs: [''], + runHook: jest.fn(), + topics: [], + valid: true, + arch: 'arm64', + platform: 'darwin', + plugins: [], + commands: [], + configDir: '', + dataDir: '', + pjson: {} as any, + root: '', + bin: 'cli', + scopedEnvVarKey: jest.fn(), + scopedEnvVar: jest.fn(), + } as any + }) + + it('when provided a channel, uses the latest version available locally', async () => { + mockFs.readdirSync.mockReturnValue([ + '1.0.0-next.2', + '1.0.0-next.3', + '1.0.1', + '1.0.0-alpha.0', + ] as any) + + // oclif-example use next + commandInstance = new MockedUseCommand(['next'], config) + + commandInstance.fetchManifest.mockResolvedValue({}) + + await commandInstance.run() + + expect(commandInstance.downloadAndExtract).not.toBeCalled() + expect(commandInstance.updatedVersion).toBe('1.0.0-next.3') + expect(commandInstance.channel).toBe('next') + }) + + it('when provided stable channel, uses only release versions', async () => { + mockFs.readdirSync.mockReturnValue([ + '1.0.0-next.2', + '1.0.3', + '1.0.0-next.3', + '1.0.1', + '1.0.0-alpha.0', + ] as any) + + // oclif-example use next + commandInstance = new MockedUseCommand(['stable'], config) + + commandInstance.fetchManifest.mockResolvedValue({}) + + await commandInstance.run() + + expect(commandInstance.downloadAndExtract).not.toBeCalled() + expect(commandInstance.updatedVersion).toBe('1.0.3') + expect(commandInstance.channel).toBe('stable') + }) + + it('when provided a version, will directly switch to it locally', async () => { + mockFs.readdirSync.mockReturnValue([ + '1.0.0-next.2', + '1.0.0-next.3', + '1.0.1', + '1.0.0-alpha.0', + ] as any) + + // oclif-example use '1.0.0-alpha.0' + commandInstance = new MockedUseCommand(['1.0.0-alpha.0'], config) + + commandInstance.fetchManifest.mockResolvedValue({ + channel: 'alpha', + } as IManifest) + + await commandInstance.run() + + expect(commandInstance.downloadAndExtract).not.toBeCalled() + expect(commandInstance.updatedVersion).toBe('1.0.0-alpha.0') + }) + + it('will print a warning when the requested static version is not available locally', async () => { + mockFs.readdirSync.mockReturnValue([ + '1.0.0-next.2', + '1.0.0-next.3', + '1.0.1', + '1.0.0-alpha.0', + ] as any) + + // oclif-example use '1.0.0-alpha.3' + commandInstance = new MockedUseCommand(['1.0.0-alpha.3'], config) + + commandInstance.fetchManifest.mockResolvedValue({}) + + let err + + try { + await commandInstance.run() + } catch (error) { + err = error + } + + const localVersionsMsg = `Locally installed versions available: \n${[ + '1.0.0-next.2', + '1.0.0-next.3', + '1.0.1', + '1.0.0-alpha.0', + ] + .map((version) => `\t${version}`) + .join('\n')}\n` + + expect(commandInstance.downloadAndExtract).not.toBeCalled() + expect(err.message).toBe( + `Requested version could not be found locally. ${localVersionsMsg}` + ) + }) + + it('will ignore partials when trying to update to stable', async () => { + mockFs.readdirSync.mockReturnValue([ + '1.0.0-next.2', + '1.0.0-next.3', + '1.0.1', + '1.0.2.partial.0000', + '1.0.1.partial.0000', + '1.0.0-alpha.0', + ] as any) + + // oclif-example use '1.0.0-alpha.0' + commandInstance = new MockedUseCommand(['stable'], config) + + commandInstance.fetchManifest.mockResolvedValue({ + channel: 'stable', + } as IManifest) + + await commandInstance.run() + + expect(commandInstance.downloadAndExtract).not.toBeCalled() + expect(commandInstance.updatedVersion).toBe('1.0.1') + }) + + it('will ignore partials when trying to update to a prerelease', async () => { + mockFs.readdirSync.mockReturnValue([ + '1.0.0-next.2', + '1.0.0-next.3', + '1.0.1', + '1.0.2.partial.0000', + '1.0.1.partial.0000', + '1.0.0-alpha.0', + '1.0.0-alpha.1.partial.0', + '1.0.0-alpha.2.partial.12', + ] as any) + + // oclif-example use '1.0.0-alpha.0' + commandInstance = new MockedUseCommand(['alpha'], config) + + commandInstance.fetchManifest.mockResolvedValue({ + channel: 'stable', + } as IManifest) + + await commandInstance.run() + + expect(commandInstance.downloadAndExtract).not.toBeCalled() + expect(commandInstance.updatedVersion).toBe('1.0.0-alpha.0') + }) + + it('will print a warning when the requested channel is not available locally', async () => { + mockFs.readdirSync.mockReturnValue([ + '1.0.0-next.2', + '1.0.0-next.3', + '1.0.1', + '1.0.0-alpha.0', + ] as any) + + // oclif-example use test + commandInstance = new MockedUseCommand(['beta'], config) + + commandInstance.fetchManifest.mockResolvedValue({}) + + let err + + try { + await commandInstance.run() + } catch (error) { + err = error + } + + const localVersionsMsg = `Locally installed versions available: \n${[ + '1.0.0-next.2', + '1.0.0-next.3', + '1.0.1', + '1.0.0-alpha.0', + ] + .map((version) => `\t${version}`) + .join('\n')}\n` + + expect(commandInstance.downloadAndExtract).not.toBeCalled() + expect(err.message).toBe( + `Requested version could not be found locally. ${localVersionsMsg}` + ) + }) + + it('will throw an error when invalid channel is provided', async () => { + mockFs.readdirSync.mockReturnValue([ + '1.0.0-next.2', + '1.0.0-next.3', + '1.0.1', + '1.0.0-alpha.0', + ] as any) + + // oclif-example use test + commandInstance = new MockedUseCommand(['test'], config) + + commandInstance.fetchManifest.mockResolvedValue({}) + + let err + + try { + await commandInstance.run() + } catch (error) { + err = error + } + + expect(commandInstance.downloadAndExtract).not.toBeCalled() + expect(err.message).toBe( + 'Invalid argument provided: test. Please specify either a valid channel (alpha, beta, next, stable) or an explicit version (ex. 2.68.13)' + ) + }) }) diff --git a/yarn.lock b/yarn.lock index b360ed7d..e16348c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1084,6 +1084,11 @@ dependencies: "@types/node" "*" +"@types/debug@4.1.5": + version "4.1.5" + resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" + integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"