-
Notifications
You must be signed in to change notification settings - Fork 21
/
Plugin.js
412 lines (378 loc) · 14.7 KB
/
Plugin.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
import slug from 'speakingurl'
import globs from 'globs'
import bower from 'bower'
import endpointParser from 'bower-endpoint-parser'
import semver from 'semver'
import fs from 'fs-extra'
import path from 'path'
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 */
const semverOptions = { includePrerelease: ADAPT_ALLOW_PRERELEASE }
// when a bower command errors this is the maximum number of attempts the command will be repeated
const BOWER_MAX_TRY = 5
export default class Plugin {
/**
* @param {Object} options
* @param {string} options.name
* @param {string} options.requestedVersion
* @param {boolean} options.isContrib
* @param {boolean} options.isCompatibleEnabled whether to target the latest compatible version for all plugin installations (overrides requestedVersion)
* @param {Project} options.project
* @param {string} options.cwd
* @param {Object} options.logger
*/
constructor ({
name,
requestedVersion = '*',
isContrib = false,
isCompatibleEnabled = false,
project,
cwd = (project?.cwd ?? process.cwd()),
logger
} = {}) {
this.logger = logger
/** @type {Project} */
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
// 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
this.matchedVersion = null
// a flag describing if the plugin can be updated
this.canBeUpdated = null
const isNameAPath = /\\|\//g.test(this.name)
const isVersionAPath = /\\|\//g.test(this.requestedVersion)
const isLocalPath = (isNameAPath || isVersionAPath)
if (isLocalPath) {
// wait to name the plugin until the local config file is loaded
this.sourcePath = isNameAPath ? this.name : this.requestedVersion
this.name = isVersionAPath ? this.packageName : ''
this.packageName = isNameAPath ? '' : this.packageName
this.requestedVersion = '*'
}
// the path of the source files
this.projectPath = null
// the project plugin .bower.json or bower.json
this._projectInfo = null
// the result of a query to the server or disk for source files
this._sourceInfo = null
// server given versions
this._versionsInfo = null
Plugin.instances.push(this)
}
/**
* the installed version is the latest version
* @returns {boolean|null}
*/
get isUpToDate () {
if (!this.hasFrameworkCompatibleVersion) return true
const canCheckSourceAgainstProject = (this.latestSourceVersion && this.projectVersion)
if (!canCheckSourceAgainstProject) return null
const isLatestVersion = (this.projectVersion === this.latestSourceVersion)
const isLatestMatchedVersion = (this.projectVersion === this.matchedVersion)
const isProjectVersionGreater = semver.gt(this.projectVersion, this.matchedVersion)
return (isLatestVersion || isLatestMatchedVersion || isProjectVersionGreater)
}
/**
* the most recent version of the plugin
* @returns {string|null}
*/
get latestSourceVersion () {
return (this._sourceInfo?.version || null)
}
/**
* the installed version of the plugin
* @returns {string|null}
*/
get projectVersion () {
return (this._projectInfo?.version || null)
}
/**
* the required framework version from the source package json
* @returns {string|null}
*/
get frameworkVersion () {
return (this._sourceInfo?.framework || null)
}
/**
* a list of tags denoting the source versions of the plugin
* @returns {[string]}
*/
get sourceVersions () {
return this._versionsInfo
}
/**
* plugin will be or was installed from a local source
* @returns {boolean}
*/
get isLocalSource () {
return Boolean(this.sourcePath || this?._projectInfo?._wasInstalledFromPath)
}
/**
* check if source path is a zip
* @returns {boolean}
*/
get isLocalSourceZip () {
return Boolean(this.isLocalSource && (this.sourcePath?.includes('.zip') || this._projectInfo?._source?.includes('.zip')))
}
/** @returns {boolean} */
get isVersioned () {
return Boolean(this.sourceVersions?.length)
}
/**
* is a contrib plugin
* @returns {boolean}
*/
get isContrib () {
return /^adapt-contrib/.test(this.packageName)
}
/**
* whether querying the server or disk for plugin information worked
* @returns {boolean}
*/
get isPresent () {
return Boolean(this._projectInfo || this._sourceInfo)
}
/**
* has user requested version
* @returns {boolean}
*/
get hasUserRequestVersion () {
return (this.requestedVersion !== '*')
}
/**
* the supplied a constraint is valid and supported by the plugin
* @returns {boolean|null}
*/
get hasValidRequestVersion () {
return (this.latestSourceVersion)
? semver.validRange(this.requestedVersion, semverOptions) &&
(this.isVersioned
? semver.maxSatisfying(this.sourceVersions, this.requestedVersion, semverOptions) !== null
: semver.satisfies(this.latestSourceVersion, this.requestedVersion, semverOptions)
)
: null
}
/** @returns {boolean} */
get hasFrameworkCompatibleVersion () {
return (this.latestCompatibleSourceVersion !== null)
}
async fetchSourceInfo () {
if (this.isLocalSource) return await this.fetchLocalSourceInfo()
await this.fetchBowerInfo()
}
async fetchLocalSourceInfo () {
if (this._sourceInfo) return this._sourceInfo
this._sourceInfo = null
if (!this.isLocalSource) throw new Error('Plugin name or version must be a path to the source')
if (this.isLocalSourceZip) throw new Error('Cannot install from zip files')
this._sourceInfo = await new Promise((resolve, reject) => {
// get bower.json data
const paths = [
path.resolve(this.cwd, `${this.sourcePath}/bower.json`)
]
const bowerJSON = paths.reduce((bowerJSON, bowerJSONPath) => {
if (bowerJSON) return bowerJSON
if (!fs.existsSync(bowerJSONPath)) return null
return fs.readJSONSync(bowerJSONPath)
}, null)
resolve(bowerJSON)
})
if (!this._sourceInfo) return
this.name = this._sourceInfo.name
this.matchedVersion = this.latestSourceVersion
this.packageName = this.name
}
async fetchBowerInfo () {
if (this._sourceInfo) return this._sourceInfo
this._sourceInfo = null
if (this.isLocalSource) return
const perform = async (attemptCount = 0) => {
try {
return await new Promise((resolve, reject) => {
bower.commands.info(`${this.packageName}`, null, { cwd: this.cwd, registry: this.BOWER_REGISTRY_CONFIG })
.on('end', resolve)
.on('error', reject)
})
} catch (err) {
const isFinished = (err?.code === 'ENOTFOUND' || attemptCount >= BOWER_MAX_TRY)
if (isFinished) return null
return await perform(attemptCount + 1)
}
}
const info = await perform()
if (!info) return
this._sourceInfo = info.latest
this._versionsInfo = info.versions.filter(version => semverOptions.includePrerelease ? true : !semver.prerelease(version))
}
async fetchProjectInfo () {
if (this._projectInfo) return this._projectInfo
this._projectInfo = null
this._projectInfo = await new Promise((resolve, reject) => {
// get bower.json data
globs([
`${this.cwd.replace(/\\/g, '/')}/src/*/${this.packageName}/.bower.json`,
`${this.cwd.replace(/\\/g, '/')}/src/*/${this.packageName}/bower.json`
], (err, matches) => {
if (err) return resolve(null)
const tester = new RegExp(`/${this.packageName}/`, 'i')
const match = matches.find(match => tester.test(match))
if (!match) {
// widen the search
globs([
`${this.cwd.replace(/\\/g, '/')}/src/**/.bower.json`,
`${this.cwd.replace(/\\/g, '/')}/src/**/bower.json`
], (err, matches) => {
if (err) return resolve(null)
const tester = new RegExp(`/${this.packageName}/`, 'i')
const match = matches.find(match => tester.test(match))
if (!match) return resolve(null)
this.projectPath = path.resolve(match, '../')
resolve(fs.readJSONSync(match))
})
return
}
this.projectPath = path.resolve(match, '../')
resolve(fs.readJSONSync(match))
})
})
if (!this._projectInfo) return
this.name = this._projectInfo.name
this.packageName = this.name
}
async findCompatibleVersion (framework) {
const getBowerVersionInfo = async (version) => {
const perform = async (attemptCount = 0) => {
try {
return await new Promise((resolve, reject) => {
bower.commands.info(`${this.packageName}@${version}`, null, { cwd: this.cwd, registry: this.BOWER_REGISTRY_CONFIG })
.on('end', resolve)
.on('error', reject)
})
} catch (err) {
const isFinished = (err?.code === 'ENOTFOUND' || attemptCount >= BOWER_MAX_TRY)
if (isFinished) return null
return await perform(attemptCount++)
}
}
return await perform()
}
const getMatchingVersion = async () => {
if (this.isLocalSource) {
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)
if (satisfiesFramework && satisfiesConstraint) this.latestCompatibleSourceVersion = info.version
return info.version
}
if (!this.isPresent) return null
// check if the latest version is compatible
const satisfiesConstraint = !this.hasValidRequestVersion || semver.satisfies(this._sourceInfo.version, this.requestedVersion, semverOptions)
const satisfiesFramework = semver.satisfies(framework, this.frameworkVersion, semverOptions)
if (!this.latestCompatibleSourceVersion && satisfiesFramework) this.latestCompatibleSourceVersion = this.latestSourceVersion
if (satisfiesConstraint && satisfiesFramework) {
return this.latestSourceVersion
}
if (!this.isVersioned) return null
// find the highest version that is compatible with the framework and constraint
const searchVersionInfo = async (framework, versionIndex = 0) => {
const versioninfo = await getBowerVersionInfo(this.sourceVersions[versionIndex])
// give up if there is any failure to obtain version info
if (!this.isPresent) return null
// check that the proposed plugin is compatible with the contraint and installed framework
const satisfiesConstraint = !this.hasValidRequestVersion || semver.satisfies(versioninfo.version, this.requestedVersion, semverOptions)
const satisfiesFramework = semver.satisfies(framework, versioninfo.framework, semverOptions)
if (!this.latestCompatibleSourceVersion && satisfiesFramework) this.latestCompatibleSourceVersion = versioninfo.version
const checkNext = (!satisfiesFramework || !satisfiesConstraint)
const hasNoMoreVersions = (versionIndex + 1 >= this.sourceVersions.length)
if (checkNext && hasNoMoreVersions) return null
if (checkNext) return await searchVersionInfo(framework, versionIndex + 1)
return versioninfo.version
}
return await searchVersionInfo(framework)
}
this.matchedVersion = await getMatchingVersion()
this.canBeUpdated = (this.projectVersion && this.matchedVersion) && (this.projectVersion !== this.matchedVersion)
}
/**
* @returns {string}
*/
async getType () {
if (this._type) return this._type
const info = await this.getInfo()
const foundAttributeType = PLUGIN_TYPES.find(type => info[type])
const foundKeywordType = info.keywords
.map(keyword => {
const typematches = PLUGIN_TYPES.filter(type => keyword?.toLowerCase()?.includes(type))
return typematches.length ? typematches[0] : null
})
.filter(Boolean)[0]
return (this._type = foundAttributeType || foundKeywordType || PLUGIN_DEFAULT_TYPE)
}
async getTypeFolder () {
const type = await this.getType()
return PLUGIN_TYPE_FOLDERS[type]
}
async getInfo () {
if (this._projectInfo) return this._projectInfo
if (!this._sourceInfo) await this.fetchSourceInfo()
return this._sourceInfo
}
async getRepositoryUrl () {
if (this._repositoryUrl) return this._repositoryUrl
if (this.isLocalSource) return
const url = await new Promise((resolve, reject) => {
bower.commands.lookup(this.packageName, { cwd: this.cwd, registry: this.BOWER_REGISTRY_CONFIG })
.on('end', resolve)
.on('error', reject)
})
return (this._repositoryUrl = url)
}
/** @returns {string} */
toString () {
const isAny = (this.projectVersion === '*' || this.projectVersion === null)
return `${this.packageName}${isAny ? '' : `@${this.projectVersion}`}`
}
async getSchemaPaths () {
if (this.isLocalSource) await this.fetchLocalSourceInfo()
else if (this.project) await this.fetchProjectInfo()
else throw new Error(`Cannot fetch schemas from remote plugin: ${this.name}`)
const pluginPath = this.projectPath ?? this.sourcePath
return new Promise((resolve, reject) => {
return globs(path.resolve(this.cwd, pluginPath, '**/*.schema.json'), (err, matches) => {
if (err) return reject(err)
resolve(matches)
})
})
}
/**
* Read plugin data from pluginPath
* @param {Object} options
* @param {string} options.pluginPath Path to source directory
* @param {string} [options.projectPath=process.cwd()] Optional path to potential installation project
* @returns {Plugin}
*/
static async fromPath ({
pluginPath,
projectPath = process.cwd()
}) {
const plugin = new Plugin({
name: pluginPath,
cwd: projectPath
})
await plugin.fetchLocalSourceInfo()
return plugin
}
static get instances () {
return (Plugin._instances = Plugin._instances || [])
}
}