@qooxdoo/framework
Version:
The JS Framework for Coders
796 lines (740 loc) • 26.7 kB
JavaScript
/* ************************************************************************
qooxdoo - the new era of web development
http://qooxdoo.org
Copyright:
2017-2021 Christian Boulanger
License:
MIT: https://opensource.org/licenses/MIT
See the LICENSE file in the project's top-level directory for details.
Authors:
* Christian Boulanger (info@bibliograph.org, @cboulanger)
************************************************************************ */
const download = require("download");
const fs = qx.tool.utils.Promisify.fs;
const path = require("upath");
const process = require("process");
const semver = require("semver");
const rimraf = require("rimraf");
/**
* Installs a package
*/
qx.Class.define("qx.tool.cli.commands.package.Install", {
extend: qx.tool.cli.commands.Package,
statics: {
/**
* Yarg commands data
* @return {{}}
*/
getYargsCommand() {
return {
command: "install [uri[@release_tag]]",
describe: `installs the latest compatible release of package (as per Manifest.json). Use "-r <release tag>" or @<release tag> to install a particular release.
examples:
* qx package install name: Install latest published version
* qx package install name@v0.0.2: Install version 0.0.2,
* qx package install name@master: Install current master branch from github`,
builder: {
release: {
alias: "r",
describe:
"Use a specific release tag instead of the tag of the latest compatible release",
nargs: 1,
requiresArg: true,
type: "string"
},
ignore: {
alias: "i",
describe: "Ignore unmatch of qooxdoo"
},
verbose: {
alias: "v",
describe: "Verbose logging"
},
quiet: {
alias: "q",
describe: "No output"
},
save: {
alias: "s",
default: false,
describe: "Save the libraries as permanent dependencies"
},
"from-path": {
alias: "p",
nargs: 1,
describe: "Install a library/the given library from a local path"
},
"qx-version": {
check: argv => semver.valid(argv.qxVersion),
describe:
"A semver string. If given, the maximum qooxdoo version for which to install a package"
}
}
};
}
},
members: {
/**
* @var {Boolean}
*/
__cacheUpdated: false,
/**
* API method to install a library via its URI and version tag
* @param {String} library_uri
* @param {String} release_tag
* @return {Promise<void>}
*/
async install(library_uri, release_tag) {
let installee = library_uri + (release_tag ? "@" + release_tag : "");
if (this.argv.verbose) {
qx.tool.compiler.Console.info(`>>> To be installed: ${installee}`);
}
this.argv.uri = installee;
this.argv.fromPath = false;
await this.process();
},
/**
* API method to install a library from a local path
* @param {String} local_path
* @param {String} library_uri Optional library URI.
* @return {Promise<void>}
*/
async installFromLocaPath(local_path, library_uri) {
if (!path.isAbsolute(local_path)) {
local_path = path.join(process.cwd(), local_path);
}
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> To be installed: ${
library_uri || "local libarary"
} from ${local_path}`
);
}
this.argv.uri = library_uri;
this.argv.fromPath = local_path;
await this.process();
},
/**
* API method to check if a library has been installed
* @param {String} library_uri
* @param {String} release_tag
* @return {Promise<Boolean>}
*/
async isInstalled(library_uri, release_tag) {
return (await this.getLockfileModel())
.getValue("libraries")
.some(
lib =>
lib.uri === library_uri &&
(release_tag === undefined || release_tag === lib.repo_tag)
);
},
/**
* Installs a package
*/
async process() {
await super.process();
await this.__updateCache();
const [manifestModel, lockfileModel] = await this._getConfigData();
// create shorthand for uri@id
this.argv.uri = this.argv.uri || this.argv["uri@release_tag"];
// if no library uri has been passed, install from lockfile or manifest
if (!this.argv.uri && !this.argv.fromPath) {
if (lockfileModel.getValue("libraries").length) {
await this.__downloadLibrariesInLockfile();
} else {
await this.__installDependenciesFromManifest(manifestModel.getData());
await this._saveConfigData();
}
return;
}
// library uri and id, which can be none (=latest), version, or tree-ish expression
let uri = this.argv.uri;
let id;
if (this.argv.release) {
id = this.argv.release;
} else if (uri) {
[uri, id] = uri.split(/@/);
}
// prepend "v" to valid semver strings
if (semver.valid(id) && id[0] !== "v") {
if (this.argv.verbose) {
qx.tool.compiler.Console.info(`>>> Prepending "v" to ${id}.`);
}
id = `v${id}`;
}
if (this.argv.fromPath) {
// install from local path?
if (id) {
throw new qx.tool.utils.Utils.UserError(
`Version identifier cannot be used when installing from local path.`
);
}
let saveToManifest = uri ? this.argv.save : false;
await this.__installFromPath(uri, this.argv.fromPath, saveToManifest);
} else if (!id || (qx.lang.Type.isString(id) && id.startsWith("v"))) {
// install library/libraries from GitHub release
await this.__installFromRelease(uri, id, this.argv.save);
} else {
// install library from GitHub code tree
await this.__installFromTree(uri, id, this.argv.save);
}
await this._saveConfigData();
if (this.argv.verbose) {
qx.tool.compiler.Console.info(">>> Done.");
}
},
/**
* Update repo cache
* @return {Promise<void>}
* @private
*/
async __updateCache() {
let repos_cache = this.getCache().repos;
if (repos_cache.list.length === 0) {
if (!this.argv.quiet) {
qx.tool.compiler.Console.info(">>> Updating cache...");
}
this.clearCache();
// implicit update
await new qx.tool.cli.commands.package.Update({
quiet: true
}).process();
await new qx.tool.cli.commands.package.List({ quiet: true }).process();
}
},
/**
* Returns information on the given URI
* @param {String} uri
* @return {{package_path: string | string, repo_name: string}}
* @private
*/
__getUriInfo(uri) {
if (!uri) {
throw new qx.tool.utils.Utils.UserError(
"No package resource identifier given"
);
}
// currently, the uri is github_username/repo_name[/path/to/repo].
let parts = uri.split(/\//);
let repo_name = parts.slice(0, 2).join("/");
let package_path = parts.length > 2 ? parts.slice(2).join("/") : "";
if (!this.getCache().repos.data[repo_name]) {
throw new qx.tool.utils.Utils.UserError(
`A repository '${repo_name}' cannot be found.`
);
}
return {
repo_name,
package_path
};
},
/**
* Installs libraries in a repository from a given release tag name
* @param {String} uri The name of the repository (e.g. qooxdoo/qxl.apiviewer),
* or of a library within a repository (such as ergobyte/qookery/qookeryace)
* @param {String} tag_name The tag name of the release, such as "v1.1.0"
* @param {Boolean} writeToManifest Whether the library should be written to
* Manifest.json as a dependency
* @return {Promise<void>}
* @private
*/
async __installFromRelease(uri, tag_name, writeToManifest) {
let qxVersion = (await this.getAppQxVersion()).replace("-beta", "");
let { repo_name, package_path } = this.__getUriInfo(uri);
if (!tag_name) {
let cache = this.getCache();
if (cache.compat[qxVersion] === undefined) {
if (this.argv.verbose && !this.argv.quiet) {
qx.tool.compiler.Console.info(">>> Updating cache...");
}
let options = { quiet: true, all: true, qxVersion };
await new qx.tool.cli.commands.package.List(options).process();
cache = this.getCache(true);
}
tag_name =
cache.compat[qxVersion] && cache.compat[qxVersion][repo_name];
if (!tag_name) {
qx.tool.compiler.Console.warn(
`'${repo_name}' has no (stable) release compatible with qooxdoo version ${qxVersion}.
To install anyways, use '--release <release>' or 'qx install ${repo_name}@<release>'.
Please ask the library maintainer to release a compatible version.`
);
return;
}
}
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> Installing '${uri}', release '${tag_name}' for qooxdoo version: ${qxVersion}`
);
}
let { download_path } = await this.__download(repo_name, tag_name);
// iterate over contained libraries
let found = false;
let repo_data = this.getCache().repos.data[repo_name];
if (!repo_data) {
throw new qx.tool.utils.Utils.UserError(
`A repository '${repo_name}' cannot be found.`
);
}
let release_data = repo_data.releases.data[tag_name];
if (!release_data) {
throw new qx.tool.utils.Utils.UserError(
`'${repo_name}' has no release '${tag_name}'.`
);
}
// TO DO: the path in the cache data should be the path to the library containing Manifest.json, not to the Manifest.json itself
for (let { path: manifest_path } of release_data.manifests) {
if (package_path && path.dirname(manifest_path) !== package_path) {
// if a path component exists, only install the library in this path
continue;
}
let library_uri = path.join(repo_name, path.dirname(manifest_path));
found = true;
await this.__updateInstalledLibraryData(
library_uri,
tag_name,
download_path,
writeToManifest
);
}
if (!found) {
throw new qx.tool.utils.Utils.UserError(
`The package/library identified by '${uri}' could not be found.`
);
}
},
/**
* Installs libraries in a given repository from the given hash of a code tree
* independent from the library cache. This ignores dependency constraints.
* The given uri must point to a folder containing Manifest.json
* @param {String} uri
* The path to a library in a a repository
* (e.g. qooxdoo/qxl.apiviewer or ergobyte/qookery/qookeryace)
* @param {String} hash
* A path into the code tree on GitHub such as "tree/892f44d1d1ae5d65c7dd99b18da6876de2f2a920"
* @param {Boolean} writeToManifest Whether the library should be written to
* Manifest.json as a dependency
* @return {Promise<void>}
* @private
*/
async __installFromTree(uri, hash, writeToManifest) {
let qxVersion = await this.getAppQxVersion();
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> Installing '${uri}' from tree hash '${hash}' for qooxdoo version ${qxVersion}`
);
}
let { repo_name } = this.__getUriInfo(uri);
let { download_path } = await this.__download(repo_name, hash);
await this.__updateInstalledLibraryData(
uri,
hash,
download_path,
writeToManifest
);
},
/**
* Installs libraries from a local path
* @param {String} uri
* The URI identifying a library (e.g. qooxdoo/qxl.apiviewer or
* ergobyte/qookery/qookeryace)
* @param {String} dir
* The path to a local directory
* @param {Boolean} writeToManifest
* Whether the library should be written to Manifest.json as a dependency
* @return {Promise<void>}
* @private
*/
async __installFromPath(uri, dir, writeToManifest = false) {
let qxVersion = await this.getAppQxVersion();
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> Installing '${uri}' from '${dir}' for qooxdoo version ${qxVersion}`
);
}
await this.__updateInstalledLibraryData(
uri,
undefined,
dir,
writeToManifest
);
},
/**
* Updates the data in the lockfile and (optionally) in the manifest
* @param {String} uri The path to a library in a a repository
* (e.g. qooxdoo/qxl.apiviewer or ergobyte/qookery/qookeryace)
* @param {String} id
* The tag name of a release such as "v1.1.0" or a tree hash such as
* tree/892f44d1d1ae5d65c7dd99b18da6876de2f2a920
* @param {String} download_path The path to the downloaded repository
* @param {Boolean} writeToManifest
* Whether the library should be written to Manifest.json as a dependency
* @return {Promise<void>}
* @private
*/
async __updateInstalledLibraryData(
uri,
id,
download_path,
writeToManifest
) {
let { repo_name, package_path } = uri
? this.__getUriInfo(uri)
: { repo_name: "", package_path: "" };
const [manifestModel, lockfileModel] = await this._getConfigData();
let library_path = path.join(download_path, package_path);
let manifest_path = path.join(
library_path,
qx.tool.config.Manifest.config.fileName
);
if (!fs.existsSync(manifest_path)) {
throw new qx.tool.utils.Utils.UserError(
`No manifest file in '${library_path}'.`
);
}
let { info } = qx.tool.utils.Json.parseJson(
fs.readFileSync(manifest_path, "utf-8")
);
let local_path = path.relative(process.cwd(), library_path);
// create entry
let lib = {
library_name: info.name,
library_version: info.version,
path: local_path
};
if (uri) {
lib.uri = uri;
}
// remote library info
if (repo_name) {
lib.repo_name = repo_name;
if (id) {
lib.repo_tag = id;
}
}
// do we already have an entry for the library that matches either the URI or the local path?
let index = lockfileModel
.getValue("libraries")
.findIndex(
elem =>
(uri && elem.uri === uri) || (!uri && elem.path === local_path)
);
if (index >= 0) {
lockfileModel.setValue(["libraries", index], lib);
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> Updating already existing lockfile entry for ${info.name}, ${
info.version
}, installed from '${uri ? uri : local_path}'.`
);
}
} else {
lockfileModel.transform("libraries", libs => libs.push(lib) && libs);
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> Added new lockfile entry for ${info.name}, ${
info.version
}, installed from '${uri ? uri : local_path}'.`
);
}
}
if (writeToManifest) {
manifestModel.setValue(["requires", uri], "^" + info.version);
}
let appsInstalled = await this.__installApplication(library_path);
if (!appsInstalled && this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> No applications installed for ${uri}.`
);
}
let depsInstalled = await this.__installDependenciesFromPath(
library_path
);
if (!depsInstalled && this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> No dependencies installed for ${uri}.`
);
}
if (!this.argv.quiet) {
qx.tool.compiler.Console.info(
`Installed ${info.name} (${uri}, ${info.version})`
);
}
},
/**
* Given a download path of a library, install its dependencies
* @param {String} downloadPath
* @return {Promise<Boolean>} Wether any libraries were installed
*/
async __installDependenciesFromPath(downloadPath) {
let manifest_file = path.join(
downloadPath,
qx.tool.config.Manifest.config.fileName
);
let manifest = await qx.tool.utils.Json.loadJsonAsync(manifest_file);
if (!manifest.requires) {
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> ${manifest_file} does not contain library dependencies.`
);
}
return false;
}
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> Installing libraries from ${manifest_file}.`
);
}
return this.__installDependenciesFromManifest(manifest);
},
/**
* Given a library's manifest data, install its dependencies
* @param {Object} manifest
* @return {Promise<Boolean>} Wether any libraries were installed
*/
async __installDependenciesFromManifest(manifest) {
if (!manifest.requires) {
return false;
}
for (let lib_uri of Object.getOwnPropertyNames(manifest.requires)) {
let lib_range = manifest.requires[lib_uri];
switch (lib_uri) {
case "@qooxdoo/compiler":
case "qooxdoo-sdk":
case "qooxdoo-compiler":
// ignore legacy entries
break;
case "@qooxdoo/framework": {
let qxVersion = await this.getAppQxVersion();
if (
!semver.satisfies(qxVersion, lib_range, { loose: true }) &&
this.argv.ignore
) {
throw new qx.tool.utils.Utils.UserError(
`Library '${lib_uri}' needs /framework version ${lib_range}, found ${qxVersion}`
);
}
break;
}
default: {
// version info is semver range -> released version
if (semver.validRange(lib_range)) {
let { tag } = this.__getHighestCompatibleVersion(
lib_uri,
lib_range
);
if (!tag) {
throw new qx.tool.utils.Utils.UserError(
`No satisfying release found for ${lib_uri}@${lib_range}!`
);
}
if (!(await this.isInstalled(lib_uri, tag))) {
await this.__installFromRelease(lib_uri, tag, false);
break;
}
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> ${lib_uri}@${tag} is already installed.`
);
}
break;
}
// treat version info as tree-ish identifier
if (!(await this.isInstalled(lib_uri, lib_range))) {
try {
await this.__installFromTree(lib_uri, lib_range, false);
break;
} catch (e) {
throw new qx.tool.utils.Utils.UserError(
`Could not install ${lib_uri}@${lib_range}: ${e.message}`
);
}
}
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> ${lib_uri}@${lib_range} is already installed.`
);
}
}
}
}
return true;
},
/**
* Given the URI of a library repo and a semver range, returns the highest
* version compatible with the semver range and the release tag containing
* this version.
* @param {String} lib_uri The URI of the library
* @param {String} lib_range The semver range
* @return {Object} Returns an object with the keys "tag" and "version"
* @private
*/
__getHighestCompatibleVersion(lib_uri, lib_range) {
let { repo_name } = this.__getUriInfo(lib_uri);
let lib = this.getCache().repos.data[repo_name];
if (!lib) {
throw new qx.tool.utils.Utils.UserError(
`${lib_uri} is not in the library registry!`
);
}
// map version to release (this helps with prereleases)
let version2release = {};
let versionList = lib.releases.list
.map(tag => {
// all libraries in a package MUST have the same version
let manifest = lib.releases.data[tag].manifests[0];
if (
!qx.lang.Type.isObject(manifest) ||
!qx.lang.Type.isObject(manifest.info) ||
!manifest.info.version
) {
this.debug(`${repo_name}/${tag}: Invalid Manifest!`);
return null;
}
let version = manifest.info.version;
version2release[version] = tag;
return version;
})
.filter(version => Boolean(version));
let highestCompatibleVersion = semver.maxSatisfying(
versionList,
lib_range,
{ loose: true }
);
return {
version: highestCompatibleVersion,
tag: version2release[highestCompatibleVersion]
};
},
/**
* Given the download path of a library, install its applications
* todo use config API, use compile.js where it exists
* @param {String} downloadPath
* @return {Promise<Boolean>} Returns true if applications were installed
*/
async __installApplication(downloadPath) {
let manifest = await qx.tool.utils.Json.loadJsonAsync(
path.join(downloadPath, qx.tool.config.Manifest.config.fileName)
);
if (!manifest.provides || !manifest.provides.application) {
return false;
}
let manifestApp = manifest.provides.application;
const compileConfigModel = await qx.tool.config.Compile.getInstance();
if (!(await compileConfigModel.exists())) {
qx.tool.compiler.Console.info(
">>> Cannot install application " +
(manifestApp.name || manifestApp["class"]) +
" because compile.json does not exist (you must manually add it)"
);
return false;
}
// relaod config. We need a fresh model here because data will be verified.
// The original model is enriched during parsing so validate will fail.
compileConfigModel.setLoaded(false);
await compileConfigModel.load();
let app = compileConfigModel.getValue("applications").find(app => {
if (manifestApp.name && app.name) {
return manifestApp.name === app.name;
}
return manifestApp["class"] === app["class"];
});
if (!app) {
compileConfigModel.transform("applications", apps =>
apps.concat([manifestApp])
);
app = manifestApp;
}
if (compileConfigModel.isDirty()) {
await compileConfigModel.save();
}
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
">>> Installed application " + (app.name || app["class"])
);
}
return true;
},
/**
* Download repos listed in the lockfile
* @return {Promise<void>}
* @private
*/
async __downloadLibrariesInLockfile() {
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> Downloading libraries listed in ${qx.tool.config.Lockfile.config.fileName}...`
);
}
let libraries = (await this.getLockfileData()).libraries;
return qx.Promise.all(
libraries
.filter(lib => lib.repo_name && lib.repo_tag)
.map(lib => this.__download(lib.repo_name, lib.repo_tag))
);
},
/**
* Downloads a release
* @return {Object} A map containing {release_data, download_path}
* @param {String} repo_name The name of the repository
* @param {String} treeish
* If prefixed by "v", the name of a release tag. Otherwise, arbitrary
* tree-ish expression (see https://help.github.com/en/articles/getting-permanent-links-to-files)
* @param {Boolean} force Overwrite existing downloads
* @return {{download_path:String}}
*/
async __download(repo_name, treeish = null, force = false) {
qx.core.Assert.assertNotNull(treeish, "Empty tree-ish id is not allowed");
let url = `https://github.com/${repo_name}/archive/${treeish}.zip`;
// create local directory
let dir_name = `${repo_name}_${treeish}`.replace(/[\^./*?"'<>:]/g, "_");
let parts = [
process.cwd(),
qx.tool.cli.commands.Package.cache_dir,
dir_name
];
let dir_exists;
let download_path = parts.reduce((prev, current) => {
let dir = prev + path.sep + current;
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
dir_exists = false;
} else {
dir_exists = true;
}
return dir;
});
// download zip
if (!force && dir_exists) {
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> Repository '${repo_name}', '${treeish}' has already been downloaded to ${download_path}. To download again, execute 'qx clean'.`
);
}
} else {
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> Downloading repository '${repo_name}', '${treeish}' from ${url} to ${download_path}`
);
}
try {
await download(url, download_path, { extract: true, strip: 1 });
} catch (e) {
// remove download path so that failed downloads do not result in empty folder
if (this.argv.verbose) {
qx.tool.compiler.Console.info(
`>>> Download failed: ${e.message}. Removing download folder.`
);
}
rimraf.sync(download_path);
qx.tool.compiler.Console.error(
`Could not install '${repo_name}@${treeish}'. Use the --verbose flag for more information.`
);
process.exit(1);
}
}
return { download_path, dir_exists };
}
}
});