Skip to content

Commit 33fbc79

Browse files
committed
New: Support installing and updating plugins from git URLs (fixes #237)
Allow users to install plugins directly from HTTPS git URLs instead of requiring them to be registered in the bower registry. Supports optional branch/tag refs via URL#ref syntax.
1 parent 6ce7ddd commit 33fbc79

6 files changed

Lines changed: 147 additions & 24 deletions

File tree

lib/integration/Plugin.js

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import endpointParser from 'bower-endpoint-parser'
55
import semver from 'semver'
66
import fs from 'fs-extra'
77
import path from 'path'
8+
import os from 'os'
9+
import { exec } from 'child_process'
810
import getBowerRegistryConfig from './getBowerRegistryConfig.js'
911
import { ADAPT_ALLOW_PRERELEASE, PLUGIN_TYPES, PLUGIN_TYPE_FOLDERS, PLUGIN_DEFAULT_TYPE } from '../util/constants.js'
1012
/** @typedef {import("./Project.js").default} Project */
@@ -38,13 +40,23 @@ export default class Plugin {
3840
this.project = project
3941
this.cwd = cwd
4042
this.BOWER_REGISTRY_CONFIG = getBowerRegistryConfig({ cwd: this.cwd })
41-
const endpoint = name + '#' + (isCompatibleEnabled ? '*' : requestedVersion)
42-
const ep = endpointParser.decompose(endpoint)
4343
this.sourcePath = null
44-
this.name = ep.name || ep.source
45-
this.packageName = (/^adapt-/i.test(this.name) ? '' : 'adapt-') + (!isContrib ? '' : 'contrib-') + slug(this.name, { maintainCase: true })
46-
// the constraint given by the user
47-
this.requestedVersion = requestedVersion
44+
45+
const isGitUrl = /^https?:\/\//.test(name)
46+
if (isGitUrl) {
47+
this.gitUrl = name
48+
this.gitRef = (requestedVersion && requestedVersion !== '*') ? requestedVersion : null
49+
this.name = ''
50+
this.packageName = ''
51+
this.requestedVersion = '*'
52+
} else {
53+
const endpoint = name + '#' + (isCompatibleEnabled ? '*' : requestedVersion)
54+
const ep = endpointParser.decompose(endpoint)
55+
this.name = ep.name || ep.source
56+
this.packageName = (/^adapt-/i.test(this.name) ? '' : 'adapt-') + (!isContrib ? '' : 'contrib-') + slug(this.name, { maintainCase: true })
57+
this.requestedVersion = requestedVersion
58+
}
59+
4860
// the most recent version of the plugin compatible with the given framework
4961
this.latestCompatibleSourceVersion = null
5062
// 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 {
128140
return Boolean(this.sourcePath || this?._projectInfo?._wasInstalledFromPath)
129141
}
130142

143+
/**
144+
* plugin will be or was installed from a git URL
145+
* @returns {boolean}
146+
*/
147+
get isGitSource () {
148+
return Boolean(this.gitUrl || this._projectInfo?._wasInstalledFromGitRepo)
149+
}
150+
131151
/**
132152
* check if source path is a zip
133153
* @returns {boolean}
@@ -185,10 +205,34 @@ export default class Plugin {
185205
}
186206

187207
async fetchSourceInfo () {
208+
if (this.isGitSource) return await this.fetchGitSourceInfo()
188209
if (this.isLocalSource) return await this.fetchLocalSourceInfo()
189210
await this.fetchBowerInfo()
190211
}
191212

213+
async fetchGitSourceInfo () {
214+
if (this._sourceInfo) return this._sourceInfo
215+
this._sourceInfo = null
216+
const tmpDir = path.join(os.tmpdir(), `adapt-git-${Date.now()}`)
217+
try {
218+
const branchFlag = this.gitRef ? ` --branch ${this.gitRef}` : ''
219+
await new Promise((resolve, reject) => {
220+
exec(`git clone --depth 1${branchFlag} ${this.gitUrl} "${tmpDir}"`, (err) => {
221+
if (err) return reject(err)
222+
resolve()
223+
})
224+
})
225+
const bowerJSONPath = path.join(tmpDir, 'bower.json')
226+
if (!fs.existsSync(bowerJSONPath)) return
227+
this._sourceInfo = await fs.readJSON(bowerJSONPath)
228+
this.name = this._sourceInfo.name
229+
this.packageName = this.name
230+
this.matchedVersion = this._sourceInfo.version
231+
} finally {
232+
await fs.rm(tmpDir, { recursive: true, force: true })
233+
}
234+
}
235+
192236
async fetchLocalSourceInfo () {
193237
if (this._sourceInfo) return this._sourceInfo
194238
this._sourceInfo = null
@@ -269,6 +313,10 @@ export default class Plugin {
269313
if (!this._projectInfo) return
270314
this.name = this._projectInfo.name
271315
this.packageName = this.name
316+
if (this._projectInfo._wasInstalledFromGitRepo) {
317+
this.gitUrl = this._projectInfo._gitUrl
318+
this.gitRef = this._projectInfo._gitRef || null
319+
}
272320
}
273321

274322
async findCompatibleVersion (framework) {
@@ -291,7 +339,7 @@ export default class Plugin {
291339
const getMatchingVersion = async () => {
292340
if (!this.isPresent) return null
293341

294-
if (this.isLocalSource) {
342+
if (this.isLocalSource || this.isGitSource) {
295343
const info = this.projectVersion ? this._projectInfo : this._sourceInfo
296344
const satisfiesConstraint = !this.hasValidRequestVersion || semver.satisfies(info.version, this.requestedVersion, semverOptions)
297345
const satisfiesFramework = semver.satisfies(framework, info.framework)
@@ -360,7 +408,7 @@ export default class Plugin {
360408

361409
async getRepositoryUrl () {
362410
if (this._repositoryUrl) return this._repositoryUrl
363-
if (this.isLocalSource) return
411+
if (this.isLocalSource || this.isGitSource) return
364412
const url = await new Promise((resolve, reject) => {
365413
bower.commands.lookup(this.packageName, { cwd: this.cwd, registry: this.BOWER_REGISTRY_CONFIG })
366414
.on('end', resolve)

lib/integration/PluginManagement/install.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,28 @@ async function getInstallTargets ({ logger, project, plugins, isCompatibleEnable
6363
const itinerary = isEmpty
6464
? await project.getManifestDependencies()
6565
: plugins.reduce((itinerary, arg) => {
66-
const [name, version = '*'] = arg.split(/[#@]/)
66+
let name, version
67+
if (/^https?:\/\//.test(arg)) {
68+
const hashIndex = arg.lastIndexOf('#')
69+
if (hashIndex !== -1) {
70+
name = arg.substring(0, hashIndex)
71+
version = arg.substring(hashIndex + 1)
72+
} else {
73+
name = arg
74+
version = '*'
75+
}
76+
} else {
77+
[name, version = '*'] = arg.split(/[#@]/)
78+
}
6779
// Duplicates are removed by assigning to object properties
6880
itinerary[name] = version
6981
return itinerary
7082
}, {})
71-
const pluginNames = Object.entries(itinerary).map(([name, version]) => `${name}#${version}`)
72-
7383
/**
7484
* @type {[Target]}
7585
*/
76-
const targets = pluginNames.length
77-
? pluginNames.map(nameVersion => {
78-
const [name, requestedVersion] = nameVersion.split(/[#@]/)
86+
const targets = Object.keys(itinerary).length
87+
? Object.entries(itinerary).map(([name, requestedVersion]) => {
7988
return new Target({ name, requestedVersion, isCompatibleEnabled, project, logger })
8089
})
8190
: await project.getInstallTargets()

lib/integration/PluginManagement/print.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ export function versionPrinter (plugin, logger) {
2222
versionToApply,
2323
latestCompatibleSourceVersion
2424
} = plugin
25+
const sourceLabel = plugin.isLocalSource ? ' (local)' : plugin.isGitSource ? ' (git)' : ` (latest compatible version is ${greenIfEqual(versionToApply, latestCompatibleSourceVersion)})`
2526
logger?.log(highlight(plugin.packageName), latestCompatibleSourceVersion === null
2627
? '(no version information)'
27-
: `${chalk.greenBright(versionToApply)}${plugin.isLocalSource ? ' (local)' : ` (latest compatible version is ${greenIfEqual(versionToApply, latestCompatibleSourceVersion)})`}`
28+
: `${chalk.greenBright(versionToApply)}${sourceLabel}`
2829
)
2930
}
3031

@@ -37,9 +38,10 @@ export function existingVersionPrinter (plugin, logger) {
3738
const fromTo = preUpdateProjectVersion !== null
3839
? `from ${chalk.greenBright(preUpdateProjectVersion)} to ${chalk.greenBright(projectVersion)}`
3940
: `${chalk.greenBright(projectVersion)}`
41+
const sourceLabel = plugin.isLocalSource ? ' (local)' : plugin.isGitSource ? ' (git)' : ` (latest compatible version is ${greenIfEqual(projectVersion, latestCompatibleSourceVersion)})`
4042
logger?.log(highlight(plugin.packageName), latestCompatibleSourceVersion === null
4143
? fromTo
42-
: `${fromTo}${plugin.isLocalSource ? ' (local)' : ` (latest compatible version is ${greenIfEqual(projectVersion, latestCompatibleSourceVersion)})`}`
44+
: `${fromTo}${sourceLabel}`
4345
)
4446
}
4547

lib/integration/PluginManagement/update.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ async function conflictResolution ({ logger, targets, isInteractive }) {
163163
prompt
164164
}
165165
}
166-
const preFilteredPlugins = targets.filter(target => !target.isLocalSource)
166+
const preFilteredPlugins = targets.filter(target => !target.isLocalSource && !target.isGitSource)
167167
const allQuestions = [
168168
add(preFilteredPlugins.filter(target => !target.hasFrameworkCompatibleVersion && target.latestSourceVersion), 'There is no compatible version of the following plugins:', checkVersion),
169169
add(preFilteredPlugins.filter(target => target.hasFrameworkCompatibleVersion && !target.hasValidRequestVersion), 'The version requested is invalid, there are newer compatible versions of the following plugins:', checkVersion),
@@ -181,28 +181,31 @@ async function conflictResolution ({ logger, targets, isInteractive }) {
181181
* @param {[Target]} options.targets
182182
*/
183183
function summariseDryRun ({ logger, targets }) {
184-
const preFilteredPlugins = targets.filter(target => !target.isLocalSource)
184+
const preFilteredPlugins = targets.filter(target => !target.isLocalSource && !target.isGitSource)
185185
const localSources = targets.filter(target => target.isLocalSource)
186+
const gitSources = targets.filter(target => target.isGitSource && target.isToBeUpdated)
186187
const toBeInstalled = preFilteredPlugins.filter(target => target.isToBeUpdated)
187188
const toBeSkipped = preFilteredPlugins.filter(target => !target.isToBeUpdated || target.isSkipped)
188189
const missing = preFilteredPlugins.filter(target => target.isMissing)
189190
summarise(logger, localSources, packageNamePrinter, 'The following plugins were installed from a local source and cannot be updated:')
190191
summarise(logger, toBeSkipped, packageNamePrinter, 'The following plugins will be skipped:')
191192
summarise(logger, missing, packageNamePrinter, 'There was a problem locating the following plugins:')
192-
summarise(logger, toBeInstalled, existingVersionPrinter, 'The following plugins will be updated:')
193+
summarise(logger, [...toBeInstalled, ...gitSources], existingVersionPrinter, 'The following plugins will be updated:')
193194
}
194195

195196
/**
196197
* @param {Object} options
197198
* @param {[Target]} options.targets
198199
*/
199200
function summariseUpdates ({ logger, targets }) {
200-
const preFilteredPlugins = targets.filter(target => !target.isLocalSource)
201+
const preFilteredPlugins = targets.filter(target => !target.isLocalSource && !target.isGitSource)
201202
const localSources = targets.filter(target => target.isLocalSource)
202-
const installSucceeded = preFilteredPlugins.filter(target => target.isUpdateSuccessful)
203+
const gitSucceeded = targets.filter(target => target.isGitSource && target.isUpdateSuccessful)
204+
const gitErrored = targets.filter(target => target.isGitSource && target.isUpdateFailure)
205+
const installSucceeded = [...preFilteredPlugins.filter(target => target.isUpdateSuccessful), ...gitSucceeded]
203206
const installSkipped = preFilteredPlugins.filter(target => target.isSkipped)
204207
const noUpdateAvailable = preFilteredPlugins.filter(target => !target.isToBeUpdated && !target.isSkipped)
205-
const installErrored = preFilteredPlugins.filter(target => target.isUpdateFailure)
208+
const installErrored = [...preFilteredPlugins.filter(target => target.isUpdateFailure), ...gitErrored]
206209
const missing = preFilteredPlugins.filter(target => target.isMissing)
207210
const noneInstalled = (installSucceeded.length === 0)
208211
const allInstalledSuccessfully = (installErrored.length === 0 && missing.length === 0)

lib/integration/Project.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ export default class Project {
5656

5757
/** @returns {[Target]} */
5858
async getInstallTargets () {
59-
return Object.entries(await this.getManifestDependencies()).map(([name, requestedVersion]) => new Target({ name, requestedVersion, project: this, logger: this.logger }))
59+
return Object.entries(await this.getManifestDependencies()).map(([name, requestedVersion]) => {
60+
if (/^https?:\/\//.test(requestedVersion)) {
61+
return new Target({ name: requestedVersion, project: this, logger: this.logger })
62+
}
63+
return new Target({ name, requestedVersion, project: this, logger: this.logger })
64+
})
6065
}
6166

6267
/** @returns {[string]} */
@@ -128,7 +133,9 @@ export default class Project {
128133
if (this.containsManifestFile) {
129134
manifest = readValidateJSONSync(this.manifestFilePath)
130135
}
131-
manifest.dependencies[plugin.packageName] = plugin.sourcePath || plugin.requestedVersion || plugin.version
136+
manifest.dependencies[plugin.packageName] = plugin.gitUrl
137+
? (plugin.gitUrl + (plugin.gitRef ? '#' + plugin.gitRef : ''))
138+
: plugin.sourcePath || plugin.requestedVersion || plugin.version
132139
fs.writeJSONSync(this.manifestFilePath, manifest, { spaces: 2, replacer: null })
133140
}
134141

lib/integration/Target.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ export default class Target extends Plugin {
121121
}
122122

123123
markInstallable () {
124+
if (this.isGitSource && this.matchedVersion) {
125+
this.versionToApply = this.matchedVersion
126+
return
127+
}
124128
if (!this.isApplyLatestCompatibleVersion && !(this.isLocalSource && this.latestSourceVersion)) return
125129
this.versionToApply = this.matchedVersion
126130
}
@@ -172,6 +176,30 @@ export default class Target extends Plugin {
172176
await this.fetchProjectInfo()
173177
return
174178
}
179+
if (this.isGitSource) {
180+
await fs.ensureDir(path.resolve(this.cwd, 'src', pluginTypeFolder))
181+
const pluginPath = path.resolve(this.cwd, 'src', pluginTypeFolder, this.packageName)
182+
await fs.rm(pluginPath, { recursive: true, force: true })
183+
const branchFlag = this.gitRef ? ` --branch ${this.gitRef}` : ''
184+
try {
185+
await new Promise((resolve, reject) => {
186+
exec(`git clone${branchFlag} ${this.gitUrl} "${pluginPath}"`, (err) => {
187+
if (err) return reject(err)
188+
resolve()
189+
})
190+
})
191+
} catch (error) {
192+
throw new Error(`The plugin was found but failed to clone from ${this.gitUrl}. Error ${error}`)
193+
}
194+
const bowerJSON = await fs.readJSON(path.join(pluginPath, 'bower.json'))
195+
bowerJSON._gitUrl = this.gitUrl
196+
bowerJSON._gitRef = this.gitRef || undefined
197+
bowerJSON._wasInstalledFromGitRepo = true
198+
await fs.writeJSON(path.join(pluginPath, '.bower.json'), bowerJSON, { spaces: 2, replacer: null })
199+
this._projectInfo = null
200+
await this.fetchProjectInfo()
201+
return
202+
}
175203
if (clone) {
176204
// clone install
177205
const repoDetails = await this.getRepositoryUrl()
@@ -238,6 +266,32 @@ export default class Target extends Plugin {
238266
const typeFolder = await this.getTypeFolder()
239267
const outputPath = path.join(this.cwd, 'src', typeFolder)
240268
const pluginPath = path.join(outputPath, this.name)
269+
if (this.isGitSource) {
270+
this.preUpdateProjectVersion = this.projectVersion
271+
try {
272+
await fs.rm(pluginPath, { recursive: true, force: true })
273+
} catch (err) {
274+
throw new Error(`There was a problem writing to the target directory ${pluginPath}`)
275+
}
276+
try {
277+
await new Promise((resolve, reject) => {
278+
exec(`git clone ${this.gitUrl} "${pluginPath}"`, (err) => {
279+
if (err) return reject(err)
280+
resolve()
281+
})
282+
})
283+
} catch (error) {
284+
throw new Error(`The plugin was found but failed to clone from ${this.gitUrl}. Error ${error}`)
285+
}
286+
const bowerJSON = await fs.readJSON(path.join(pluginPath, 'bower.json'))
287+
bowerJSON._gitUrl = this.gitUrl
288+
bowerJSON._gitRef = this.gitRef || undefined
289+
bowerJSON._wasInstalledFromGitRepo = true
290+
await fs.writeJSON(path.join(pluginPath, '.bower.json'), bowerJSON, { spaces: 2, replacer: null })
291+
this._projectInfo = null
292+
await this.fetchProjectInfo()
293+
return
294+
}
241295
try {
242296
await fs.rm(pluginPath, { recursive: true, force: true })
243297
} catch (err) {

0 commit comments

Comments
 (0)