// import dependencies
import semver from 'semver'
import sort from 'semver-sort'
import {fetch} from '../utils/contextuals'
import VersionEntry from './VersionEntry'
import {Modules} from '../constants'
/**
* Load a version list for the specified environment and module, then interact with it such
* as adding a new {@link VersionEntry}, or grabbing the latest {@link VersionEntry}.
*/
class VersionList {
constructor ({host, environment, module}) {
this._host = host
this._environment = environment
this._module = module
if ([undefined, null].includes(this._host)) {
throw new Error('host must be specified')
} else if ([undefined, null].includes(this._environment)) {
throw new Error('environment must be specified')
} else if ([undefined, null].includes(this._module)) {
throw new Error('module must be specified')
}
this._versions = []
// load asynchronously
this._loaded = false
}
async _loadRemoteVersions (url, fetchOpts = {}) {
try {
const remoteVersionContents = await fetch(url || `https://${this._host}/version.json`, fetchOpts);
const remoteVersionJson = await remoteVersionContents.json();
// map the versions to an array, then sort
const sortedVersionStrings = sort.desc(remoteVersionJson.map(v => (v.semver || semver.coerce(v.version).version)));
// map back to an array of version entries
this._versions = sortedVersionStrings.map(v => {
const remoteVersion = remoteVersionJson.find(rv => {
return semver.coerce(rv.semver || rv.version).version === v;
})
return new VersionEntry({
...remoteVersion,
downloadUrl: remoteVersion.downloadUrl || `https://${this._host}/${this._module === Modules.ELECTRON ? 'win32-' : ''}v${semver.coerce(remoteVersion.version).version}.zip`
})
})
} catch (err) {
if (err.toString().includes('ENOTFOUND')) {
throw new Error(`Failed to retrieve ${this._module} ${this._environment} version list: https://${this._host}/version.json`)
} else {
throw err;
}
}
// map back to original format, but now sorted
return this._versions;
}
/**
* Load the remote {@link VersionList} from the CDN and return the sorted {@link VersionEntry} instances.
* @returns {Promise<VersionList>}
*/
async load (url, fetchOpts = {}) {
await this._loadRemoteVersions(url, fetchOpts);
this._loaded = true;
return this;
}
/**
* Return the module the {@link VersionList} was loaded for.
* @returns {string}
*/
getModule () {
return this._module;
}
/**
* Return the environment the {@link VersionList} was loaded for.
* @returns {string}
*/
getEnvironment () {
return this._environment;
}
/**
* Return the current {@link VersionList} (including any modifications made to it).
* @returns {VersionEntry[]}
*/
getVersions () {
if (!this._loaded) {
throw new Error('VersionList not loaded yet!');
}
return this._versions;
}
/**
* Return the {@link VersionEntry} matching the one specified according to the findFunc param.
* @param findFunc
* @returns {VersionEntry}
*/
find (findFunc) {
return this._versions.find(findFunc);
}
/**
* Verify the target {@link VersionEntry} is allowed, or return the next best version.
* @param {VersionEntry} targetVersion
* @param {Boolean} allowOverride
* @returns {VersionEntry}
*/
verifyOrNext (targetVersion, allowOverride) {
if (!this._loaded) {
throw new Error('VersionList not loaded yet!');
}
if (!targetVersion.semver) {
throw new Error('New entry is missing property: semver')
} else if (!targetVersion.hash) {
throw new Error('New entry is missing property: hash')
} else if (!targetVersion.origin) {
throw new Error('New entry is missing property: origin')
}
// get the most recent remote version
const current = this.latest() || targetVersion;
// should we bump the minor if overrides aren't allowed?
if (allowOverride !== true && (
semver.eq(targetVersion.semver, current.semver) || // target version is equal to current version (check if we should bump)
semver.lt(targetVersion.semver, current.semver) // target version is less than (check if we should bump)
)) {
// remote version matches target version (version override)
const nextMinor = semver.inc(current.semver, 'minor');
// for sandbox, auto-bump to the next minor version
targetVersion.setVersion(nextMinor)
console.debug(`Bumped${this._environment} version to ${targetVersion.semver}...`)
} else if (semver.major(targetVersion.semver) === semver.major(current.semver) &&
semver.minor(targetVersion.semver) === semver.minor(current.semver)) {
// major and minor matches, but patch doesn't (patch update)
// no action, approve the update
console.debug('Patch update verified against current version list:', targetVersion.toJson())
} else if (semver.major(targetVersion.semver) === semver.major(current.semver)) {
// major matches, but minor and/or patch do not (minor update)
// no action, approve the update
console.debug('Minor update verified against current version list:', targetVersion.toJson())
} else if (semver.major(targetVersion.semver) > semver.major(current.semver)) {
// target major exceeds the current one (major update)
throw new Error('Major version updates are not yet supported!')
}
return targetVersion;
}
/**
* Add a new {@link VersionEntry} to the {@link VersionList}.
* @param {VersionEntry} entry
* @param opts
* @returns {VersionList}
*/
add (entry, opts = {matchOrigin: true, matchSemver: true, matchMajorMinor: true}) {
if (!this._loaded) {
console.warn('VersionList is not loaded yet, proceeding to add new version anyways')
}
if (!entry.semver) {
throw new Error('New entry is missing property: semver')
} else if (!entry.version) {
throw new Error('New entry is missing property: version')
} else if (!entry.hash) {
throw new Error('New entry is missing property: hash')
}
// create a new array from the remote versions
let versions = this._versions
// check if the new entry is a patch, which needs to be appended to a minor entry
if (semver.patch(entry.semver) > 0) {
// check if we have a minor version
const minor = versions.find(v => semver.eq(v.semver || semver.coerce(v.version).version, `${semver.major(entry.semver)}.${semver.minor(entry.semver)}.0`))
if (minor) {
// append the entry patch to the end of the patches
const patches = minor.patches || []
const existingPatchIndex = patches.findIndex(v => (v.semver || semver.coerce(v.version).version) === entry.semver)
// remove patches array from the patch itself
entry.setPatches(undefined)
// see if we need to override a patch
if (~existingPatchIndex) {
console.debug(`Overriding existing ${this._environment} patch entry:`, versions[existingPatchIndex].toJson())
patches[existingPatchIndex] = entry
} else {
console.debug(`Added new ${this._environment} patch entry:`, entry.toJson());
minor.addPatch(entry)
}
console.info(`Updated minor entry:`, minor.toJson())
} else {
throw new Error(`Failed to append patch to parent for entry: ${entry.semver}`)
}
} else {
// existing entry
const existingIndex = versions.findIndex(v => entry.isMutationOf(v, opts));
if (~existingIndex) {
// replace existing, we are overriding it
console.debug(`Overriding existing ${this._environment} minor/major entry:`, versions[existingIndex].toJson())
versions[existingIndex] = entry
} else {
// add to front of array (minor or major versions)
versions.unshift(entry)
}
console.debug(`New ${this._environment} minor/major entry:`, entry.toJson())
}
// map the versions to an array, then sort
const sortedVersionStrings = sort.desc(versions.map(v => (v.semver || semver.coerce(v.version).version)));
// map back to an array of version entries
this._versions = sortedVersionStrings.map(v => {
return versions.find(rv => {
return semver.coerce(rv.semver || rv.version).version === v;
})
})
return this
}
/**
* Remove a {@link VersionEntry} of the specified properties from the version list.
* Supports {@link VersionEntry} instances and semver strings.
* @param entry {string|VersionEntry}
*/
remove (entry) {
this._versions = this._versions.filter(e => {
return entry instanceof VersionEntry
? !entry.isEqual(e)
: (entry?.semver || entry) !== e.semver
})
return this
}
/**
* Return the semver string to be used for the next version.
* @param release
* @returns {string}
*/
next (release = 'minor') {
const latest = this.latest();
return semver.inc(latest.semver, release);
}
/**
* Return the semver string for the version directly preceding the latest.
* @param release
* @returns {string}
*/
previous (release = 'minor') {
if (this._versions.length > 1) {
return this._versions[1];
}
const latest = this.latest();
if (release === 'major') {
return semver.coerce(`${semver.major(latest.semver) - 1}.0.0`).version
} else if (release === 'minor') {
return semver.coerce(`${semver.major(latest.semver)}.${semver.minor(latest.semver) - 1}.${semver.patch(latest.semver)}`).version
} else if (release === 'patch') {
return semver.coerce(`${semver.major(latest.semver)}.${semver.minor(latest.semver)}.${semver.patch(latest.semver) - 1}`)
} else {
throw new Error(`Unsupported release type for decrementing semver: ${release}`)
}
}
/**
* Return the latest {@link VersionEntry}.
* @returns {VersionEntry}
*/
latest () {
return this._versions[0];
}
/**
* Removes all but the most recent 2 versions from the version list and returns
* an array of {@link VersionEntry}s removed.
* @returns {VersionEntry[]}
*/
getAndRemoveArchivableVersions = () => {
// check if there are more than 2 versions
if (this._versions.length > 2) {
// remove versions starting from index 2 and return the removed elements
return this._versions.splice(2);
}
// if there are only two or fewer versions, return an empty array
return []
}
/**
* Convert the {@link VersionList} and it's array of {@link VersionEntry}s to a serializable object.
*/
toJson () {
return this._versions.map(entry => entry.toJson())
}
}
export default VersionList;