diff --git a/lib/api.js b/lib/api.js index 345fcf0..1b248b3 100644 --- a/lib/api.js +++ b/lib/api.js @@ -80,7 +80,11 @@ class API { } = {}) { // cache state of plugins, as these will be wiped const plugins = (await new Project({ cwd }).getInstallTargets()) - .map(p => p.isLocalSource ? p.sourcePath : `${p.name}@${p.requestedVersion}`) + .map(p => { + if (p.isLocalSource) return p.sourcePath + if (p.isGitSource) return p.gitUrl + (p.gitRef ? '#' + p.gitRef : '') + return `${p.name}@${p.requestedVersion}` + }) await this.installFramework({ version, repository, cwd, logger }) // restore plugins @@ -271,7 +275,7 @@ class API { .filter(Boolean) await async.eachOfLimit(filteredPlugins, 8, async plugin => { await plugin.fetchProjectInfo() - await plugin.fetchBowerInfo() + await plugin.fetchSourceInfo() await plugin.findCompatibleVersion(frameworkVersion) }) return filteredPlugins diff --git a/lib/integration/AdaptFramework/clone.js b/lib/integration/AdaptFramework/clone.js index 1fb1004..2c76d8b 100644 --- a/lib/integration/AdaptFramework/clone.js +++ b/lib/integration/AdaptFramework/clone.js @@ -1,7 +1,7 @@ import chalk from 'chalk' -import { exec } from 'child_process' import { ADAPT_FRAMEWORK } from '../../util/constants.js' import path from 'path' +import gitClone from '../../util/gitClone.js' export default async function clone ({ repository = ADAPT_FRAMEWORK, @@ -13,15 +13,6 @@ export default async function clone ({ cwd = path.resolve(process.cwd(), cwd) if (!branch && !repository) throw new Error('Repository details are required.') logger?.write(chalk.cyan('cloning framework to', cwd, '\t')) - await new Promise(function (resolve, reject) { - const child = exec(`git clone ${repository} "${cwd}"`) - child.addListener('error', reject) - child.addListener('exit', resolve) - }) - await new Promise(function (resolve, reject) { - const child = exec(`git checkout ${branch}`) - child.addListener('error', reject) - child.addListener('exit', resolve) - }) + await gitClone({ url: repository, dir: cwd, branch }) logger?.log(' ', 'done!') } diff --git a/lib/integration/Plugin.js b/lib/integration/Plugin.js index c35ed2c..fc9a37f 100644 --- a/lib/integration/Plugin.js +++ b/lib/integration/Plugin.js @@ -5,6 +5,8 @@ import endpointParser from 'bower-endpoint-parser' import semver from 'semver' import fs from 'fs-extra' import path from 'path' +import os from 'os' +import gitClone from '../util/gitClone.js' import getBowerRegistryConfig from './getBowerRegistryConfig.js' import { ADAPT_ALLOW_PRERELEASE, PLUGIN_TYPES, PLUGIN_TYPE_FOLDERS, PLUGIN_DEFAULT_TYPE } from '../util/constants.js' /** @typedef {import("./Project.js").default} Project */ @@ -38,13 +40,23 @@ export default class Plugin { this.project = project this.cwd = cwd this.BOWER_REGISTRY_CONFIG = getBowerRegistryConfig({ cwd: this.cwd }) - const endpoint = name + '#' + (isCompatibleEnabled ? '*' : requestedVersion) - const ep = endpointParser.decompose(endpoint) this.sourcePath = null - this.name = ep.name || ep.source - this.packageName = (/^adapt-/i.test(this.name) ? '' : 'adapt-') + (!isContrib ? '' : 'contrib-') + slug(this.name, { maintainCase: true }) - // the constraint given by the user - this.requestedVersion = requestedVersion + + const isGitUrl = /^https?:\/\//.test(name) + if (isGitUrl) { + this.gitUrl = name + this.gitRef = (requestedVersion && requestedVersion !== '*') ? requestedVersion : null + this.name = '' + this.packageName = '' + this.requestedVersion = '*' + } else { + const endpoint = name + '#' + (isCompatibleEnabled ? '*' : requestedVersion) + const ep = endpointParser.decompose(endpoint) + this.name = ep.name || ep.source + this.packageName = (/^adapt-/i.test(this.name) ? '' : 'adapt-') + (!isContrib ? '' : 'contrib-') + slug(this.name, { maintainCase: true }) + this.requestedVersion = requestedVersion + } + // the most recent version of the plugin compatible with the given framework this.latestCompatibleSourceVersion = null // a non-wildcard constraint resolved to the highest version of the plugin that satisfies the requestedVersion and is compatible with the framework @@ -128,6 +140,14 @@ export default class Plugin { return Boolean(this.sourcePath || this?._projectInfo?._wasInstalledFromPath) } + /** + * plugin will be or was installed from a git URL + * @returns {boolean} + */ + get isGitSource () { + return Boolean(this.gitUrl || this._projectInfo?._wasInstalledFromGitRepo) + } + /** * check if source path is a zip * @returns {boolean} @@ -185,10 +205,28 @@ export default class Plugin { } async fetchSourceInfo () { + if (this.isGitSource) return await this.fetchGitSourceInfo() if (this.isLocalSource) return await this.fetchLocalSourceInfo() await this.fetchBowerInfo() } + async fetchGitSourceInfo () { + if (this._sourceInfo) return this._sourceInfo + this._sourceInfo = null + const tmpDir = path.join(os.tmpdir(), `adapt-git-${Date.now()}`) + try { + await gitClone({ url: this.gitUrl, dir: tmpDir, branch: this.gitRef, shallow: true }) + const bowerJSONPath = path.join(tmpDir, 'bower.json') + if (!fs.existsSync(bowerJSONPath)) return + this._sourceInfo = await fs.readJSON(bowerJSONPath) + this.name = this._sourceInfo.name + this.packageName = this.name + this.matchedVersion = this._sourceInfo.version + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }) + } + } + async fetchLocalSourceInfo () { if (this._sourceInfo) return this._sourceInfo this._sourceInfo = null @@ -269,6 +307,10 @@ export default class Plugin { if (!this._projectInfo) return this.name = this._projectInfo.name this.packageName = this.name + if (this._projectInfo._wasInstalledFromGitRepo) { + this.gitUrl = this._projectInfo._gitUrl + this.gitRef = this._projectInfo._gitRef || null + } } async findCompatibleVersion (framework) { @@ -291,7 +333,7 @@ export default class Plugin { const getMatchingVersion = async () => { if (!this.isPresent) return null - if (this.isLocalSource) { + if (this.isLocalSource || this.isGitSource) { const info = this.projectVersion ? this._projectInfo : this._sourceInfo const satisfiesConstraint = !this.hasValidRequestVersion || semver.satisfies(info.version, this.requestedVersion, semverOptions) const satisfiesFramework = semver.satisfies(framework, info.framework) @@ -360,7 +402,7 @@ export default class Plugin { async getRepositoryUrl () { if (this._repositoryUrl) return this._repositoryUrl - if (this.isLocalSource) return + if (this.isLocalSource || this.isGitSource) return const url = await new Promise((resolve, reject) => { bower.commands.lookup(this.packageName, { cwd: this.cwd, registry: this.BOWER_REGISTRY_CONFIG }) .on('end', resolve) diff --git a/lib/integration/PluginManagement/clone.js b/lib/integration/PluginManagement/clone.js new file mode 100644 index 0000000..0ff67df --- /dev/null +++ b/lib/integration/PluginManagement/clone.js @@ -0,0 +1,27 @@ +import fs from 'fs-extra' +import path from 'path' +import gitClone from '../../util/gitClone.js' + +/** + * Clone a plugin from a git URL and write .bower.json metadata + * @param {Object} options + * @param {string} options.url The git repository URL + * @param {string} options.destPath The target directory for the plugin + * @param {string} [options.branch] Optional branch, tag, or ref + * @returns {Object} The bower.json contents (with git metadata) + */ +export default async function clonePlugin ({ + url, + destPath, + branch = null +} = {}) { + await fs.ensureDir(path.dirname(destPath)) + await fs.rm(destPath, { recursive: true, force: true }) + await gitClone({ url, dir: destPath, branch }) + const bowerJSON = await fs.readJSON(path.join(destPath, 'bower.json')) + bowerJSON._gitUrl = url + bowerJSON._gitRef = branch || undefined + bowerJSON._wasInstalledFromGitRepo = true + await fs.writeJSON(path.join(destPath, '.bower.json'), bowerJSON, { spaces: 2, replacer: null }) + return bowerJSON +} diff --git a/lib/integration/PluginManagement/install.js b/lib/integration/PluginManagement/install.js index 1098bfd..d782b20 100644 --- a/lib/integration/PluginManagement/install.js +++ b/lib/integration/PluginManagement/install.js @@ -63,19 +63,28 @@ async function getInstallTargets ({ logger, project, plugins, isCompatibleEnable const itinerary = isEmpty ? await project.getManifestDependencies() : plugins.reduce((itinerary, arg) => { - const [name, version = '*'] = arg.split(/[#@]/) + let name, version + if (/^https?:\/\//.test(arg)) { + const hashIndex = arg.lastIndexOf('#') + if (hashIndex !== -1) { + name = arg.substring(0, hashIndex) + version = arg.substring(hashIndex + 1) + } else { + name = arg + version = '*' + } + } else { + [name, version = '*'] = arg.split(/[#@]/) + } // Duplicates are removed by assigning to object properties itinerary[name] = version return itinerary }, {}) - const pluginNames = Object.entries(itinerary).map(([name, version]) => `${name}#${version}`) - /** * @type {[Target]} */ - const targets = pluginNames.length - ? pluginNames.map(nameVersion => { - const [name, requestedVersion] = nameVersion.split(/[#@]/) + const targets = Object.keys(itinerary).length + ? Object.entries(itinerary).map(([name, requestedVersion]) => { return new Target({ name, requestedVersion, isCompatibleEnabled, project, logger }) }) : await project.getInstallTargets() diff --git a/lib/integration/PluginManagement/print.js b/lib/integration/PluginManagement/print.js index b2ace82..53d7d9b 100644 --- a/lib/integration/PluginManagement/print.js +++ b/lib/integration/PluginManagement/print.js @@ -22,9 +22,10 @@ export function versionPrinter (plugin, logger) { versionToApply, latestCompatibleSourceVersion } = plugin + const sourceLabel = plugin.isLocalSource ? ' (local)' : plugin.isGitSource ? ' (git)' : ` (latest compatible version is ${greenIfEqual(versionToApply, latestCompatibleSourceVersion)})` logger?.log(highlight(plugin.packageName), latestCompatibleSourceVersion === null ? '(no version information)' - : `${chalk.greenBright(versionToApply)}${plugin.isLocalSource ? ' (local)' : ` (latest compatible version is ${greenIfEqual(versionToApply, latestCompatibleSourceVersion)})`}` + : `${chalk.greenBright(versionToApply)}${sourceLabel}` ) } @@ -37,9 +38,10 @@ export function existingVersionPrinter (plugin, logger) { const fromTo = preUpdateProjectVersion !== null ? `from ${chalk.greenBright(preUpdateProjectVersion)} to ${chalk.greenBright(projectVersion)}` : `${chalk.greenBright(projectVersion)}` + const sourceLabel = plugin.isLocalSource ? ' (local)' : plugin.isGitSource ? ' (git)' : ` (latest compatible version is ${greenIfEqual(projectVersion, latestCompatibleSourceVersion)})` logger?.log(highlight(plugin.packageName), latestCompatibleSourceVersion === null ? fromTo - : `${fromTo}${plugin.isLocalSource ? ' (local)' : ` (latest compatible version is ${greenIfEqual(projectVersion, latestCompatibleSourceVersion)})`}` + : `${fromTo}${sourceLabel}` ) } diff --git a/lib/integration/PluginManagement/update.js b/lib/integration/PluginManagement/update.js index 985b85c..70ad051 100644 --- a/lib/integration/PluginManagement/update.js +++ b/lib/integration/PluginManagement/update.js @@ -163,7 +163,7 @@ async function conflictResolution ({ logger, targets, isInteractive }) { prompt } } - const preFilteredPlugins = targets.filter(target => !target.isLocalSource) + const preFilteredPlugins = targets.filter(target => !target.isLocalSource && !target.isGitSource) const allQuestions = [ add(preFilteredPlugins.filter(target => !target.hasFrameworkCompatibleVersion && target.latestSourceVersion), 'There is no compatible version of the following plugins:', checkVersion), add(preFilteredPlugins.filter(target => target.hasFrameworkCompatibleVersion && !target.hasValidRequestVersion), 'The version requested is invalid, there are newer compatible versions of the following plugins:', checkVersion), @@ -181,15 +181,16 @@ async function conflictResolution ({ logger, targets, isInteractive }) { * @param {[Target]} options.targets */ function summariseDryRun ({ logger, targets }) { - const preFilteredPlugins = targets.filter(target => !target.isLocalSource) + const preFilteredPlugins = targets.filter(target => !target.isLocalSource && !target.isGitSource) const localSources = targets.filter(target => target.isLocalSource) + const gitSources = targets.filter(target => target.isGitSource && target.isToBeUpdated) const toBeInstalled = preFilteredPlugins.filter(target => target.isToBeUpdated) const toBeSkipped = preFilteredPlugins.filter(target => !target.isToBeUpdated || target.isSkipped) const missing = preFilteredPlugins.filter(target => target.isMissing) summarise(logger, localSources, packageNamePrinter, 'The following plugins were installed from a local source and cannot be updated:') summarise(logger, toBeSkipped, packageNamePrinter, 'The following plugins will be skipped:') summarise(logger, missing, packageNamePrinter, 'There was a problem locating the following plugins:') - summarise(logger, toBeInstalled, existingVersionPrinter, 'The following plugins will be updated:') + summarise(logger, [...toBeInstalled, ...gitSources], existingVersionPrinter, 'The following plugins will be updated:') } /** @@ -197,12 +198,14 @@ function summariseDryRun ({ logger, targets }) { * @param {[Target]} options.targets */ function summariseUpdates ({ logger, targets }) { - const preFilteredPlugins = targets.filter(target => !target.isLocalSource) + const preFilteredPlugins = targets.filter(target => !target.isLocalSource && !target.isGitSource) const localSources = targets.filter(target => target.isLocalSource) - const installSucceeded = preFilteredPlugins.filter(target => target.isUpdateSuccessful) + const gitSucceeded = targets.filter(target => target.isGitSource && target.isUpdateSuccessful) + const gitErrored = targets.filter(target => target.isGitSource && target.isUpdateFailure) + const installSucceeded = [...preFilteredPlugins.filter(target => target.isUpdateSuccessful), ...gitSucceeded] const installSkipped = preFilteredPlugins.filter(target => target.isSkipped) const noUpdateAvailable = preFilteredPlugins.filter(target => !target.isToBeUpdated && !target.isSkipped) - const installErrored = preFilteredPlugins.filter(target => target.isUpdateFailure) + const installErrored = [...preFilteredPlugins.filter(target => target.isUpdateFailure), ...gitErrored] const missing = preFilteredPlugins.filter(target => target.isMissing) const noneInstalled = (installSucceeded.length === 0) const allInstalledSuccessfully = (installErrored.length === 0 && missing.length === 0) diff --git a/lib/integration/Project.js b/lib/integration/Project.js index 528f4e0..835d741 100644 --- a/lib/integration/Project.js +++ b/lib/integration/Project.js @@ -56,7 +56,16 @@ export default class Project { /** @returns {[Target]} */ async getInstallTargets () { - return Object.entries(await this.getManifestDependencies()).map(([name, requestedVersion]) => new Target({ name, requestedVersion, project: this, logger: this.logger })) + return Object.entries(await this.getManifestDependencies()).map(([name, requestedVersion]) => { + if (/^https?:\/\//.test(requestedVersion)) { + const hashIndex = requestedVersion.lastIndexOf('#') + if (hashIndex !== -1) { + return new Target({ name: requestedVersion.substring(0, hashIndex), requestedVersion: requestedVersion.substring(hashIndex + 1), project: this, logger: this.logger }) + } + return new Target({ name: requestedVersion, project: this, logger: this.logger }) + } + return new Target({ name, requestedVersion, project: this, logger: this.logger }) + }) } /** @returns {[string]} */ @@ -128,7 +137,9 @@ export default class Project { if (this.containsManifestFile) { manifest = readValidateJSONSync(this.manifestFilePath) } - manifest.dependencies[plugin.packageName] = plugin.sourcePath || plugin.requestedVersion || plugin.version + manifest.dependencies[plugin.packageName] = plugin.gitUrl + ? (plugin.gitUrl + (plugin.gitRef ? '#' + plugin.gitRef : '')) + : plugin.sourcePath || plugin.requestedVersion || plugin.version fs.writeJSONSync(this.manifestFilePath, manifest, { spaces: 2, replacer: null }) } diff --git a/lib/integration/Target.js b/lib/integration/Target.js index 5bf271c..fef7276 100644 --- a/lib/integration/Target.js +++ b/lib/integration/Target.js @@ -1,10 +1,11 @@ import chalk from 'chalk' import bower from 'bower' -import { exec } from 'child_process' import semver from 'semver' import fs from 'fs-extra' import path from 'path' import { ADAPT_ALLOW_PRERELEASE } from '../util/constants.js' +import gitClone from '../util/gitClone.js' +import clonePlugin from './PluginManagement/clone.js' import Plugin from './Plugin.js' /** @typedef {import("./Project.js").default} Project */ const semverOptions = { includePrerelease: ADAPT_ALLOW_PRERELEASE } @@ -121,6 +122,10 @@ export default class Target extends Plugin { } markInstallable () { + if (this.isGitSource && this.matchedVersion) { + this.versionToApply = this.matchedVersion + return + } if (!this.isApplyLatestCompatibleVersion && !(this.isLocalSource && this.latestSourceVersion)) return this.versionToApply = this.matchedVersion } @@ -172,6 +177,17 @@ export default class Target extends Plugin { await this.fetchProjectInfo() return } + if (this.isGitSource) { + const pluginPath = path.resolve(this.cwd, 'src', pluginTypeFolder, this.packageName) + try { + await clonePlugin({ url: this.gitUrl, destPath: pluginPath, branch: this.gitRef }) + } catch (error) { + throw new Error(`The plugin was found but failed to clone from ${this.gitUrl}. Error ${error}`) + } + this._projectInfo = null + await this.fetchProjectInfo() + return + } if (clone) { // clone install const repoDetails = await this.getRepositoryUrl() @@ -180,26 +196,13 @@ export default class Target extends Plugin { const pluginPath = path.resolve(this.cwd, 'src', pluginTypeFolder, this.packageName) await fs.rm(pluginPath, { recursive: true, force: true }) const url = repoDetails.url.replace(/^git:\/\//, 'https://') + const branch = this.versionToApply !== '*' ? `v${this.versionToApply}` : null try { - const exitCode = await new Promise((resolve, reject) => { - try { - exec(`git clone ${url} "${pluginPath}"`, resolve) - } catch (err) { - reject(err) - } - }) - if (exitCode) throw new Error(`The plugin was found but failed to download and install. Exit code ${exitCode}`) + await gitClone({ url, dir: pluginPath, branch }) } catch (error) { throw new Error(`The plugin was found but failed to download and install. Error ${error}`) } - if (this.versionToApply !== '*') { - try { - await new Promise(resolve => exec(`git -C "${pluginPath}" checkout v${this.versionToApply}`, resolve)) - logger?.log(chalk.green(this.packageName), `is on branch "${this.versionToApply}".`) - } catch (err) { - throw new Error(chalk.yellow(this.packageName), `could not checkout branch "${this.versionToApply}".`) - } - } + if (branch) logger?.log(chalk.green(this.packageName), `is on branch "${this.versionToApply}".`) this._projectInfo = null await this.fetchProjectInfo() return @@ -238,6 +241,17 @@ export default class Target extends Plugin { const typeFolder = await this.getTypeFolder() const outputPath = path.join(this.cwd, 'src', typeFolder) const pluginPath = path.join(outputPath, this.name) + if (this.isGitSource) { + this.preUpdateProjectVersion = this.projectVersion + try { + await clonePlugin({ url: this.gitUrl, destPath: pluginPath }) + } catch (error) { + throw new Error(`The plugin was found but failed to clone from ${this.gitUrl}. Error ${error}`) + } + this._projectInfo = null + await this.fetchProjectInfo() + return + } try { await fs.rm(pluginPath, { recursive: true, force: true }) } catch (err) { diff --git a/lib/util/gitClone.js b/lib/util/gitClone.js new file mode 100644 index 0000000..3ce0a1e --- /dev/null +++ b/lib/util/gitClone.js @@ -0,0 +1,28 @@ +import { exec } from 'child_process' + +/** + * Clone a git repository + * @param {Object} options + * @param {string} options.url The repository URL + * @param {string} options.dir The target directory + * @param {string} [options.branch] Optional branch, tag, or ref to clone + * @param {boolean} [options.shallow=false] Whether to use --depth 1 + */ +export default async function gitClone ({ + url, + dir, + branch = null, + shallow = false +} = {}) { + const flags = [ + shallow && '--depth 1', + branch && `--branch ${branch}` + ].filter(Boolean).join(' ') + const cmd = `git clone${flags ? ' ' + flags : ''} ${url} "${dir}"` + await new Promise((resolve, reject) => { + exec(cmd, (err) => { + if (err) return reject(err) + resolve() + }) + }) +}