project/lib/ui5Framework/AbstractResolver.js

  1. import path from "node:path";
  2. import os from "node:os";
  3. import {getLogger} from "@ui5/logger";
  4. const log = getLogger("ui5Framework:AbstractResolver");
  5. import semver from "semver";
  6. // Reduced Semantic Versioning pattern
  7. // Matches MAJOR or MAJOR.MINOR as a simple version range to be resolved to the latest minor/patch
  8. const VERSION_RANGE_REGEXP = /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:-SNAPSHOT)?$/;
  9. /**
  10. * Abstract Resolver
  11. *
  12. * @abstract
  13. * @public
  14. * @class
  15. * @alias @ui5/project/ui5Framework/AbstractResolver
  16. * @hideconstructor
  17. */
  18. class AbstractResolver {
  19. /* eslint-disable max-len */
  20. /**
  21. * @param {*} options options
  22. * @param {string} [options.version] Framework version to use. When omitted, all libraries need to be available
  23. * via <code>providedLibraryMetadata</code> parameter. Otherwise an error is thrown.
  24. * @param {boolean} [options.sources=false] Whether to install framework libraries as sources or
  25. * pre-built (with build manifest)
  26. * @param {string} [options.cwd=process.cwd()] Current working directory
  27. * @param {string} [options.ui5HomeDir="~/.ui5"] UI5 home directory location. This will be used to store packages,
  28. * metadata and configuration used by the resolvers. Relative to `process.cwd()`
  29. * @param {object.<string, @ui5/project/ui5Framework/AbstractResolver~LibraryMetadataEntry>} [options.providedLibraryMetadata]
  30. * Resolver skips installing listed libraries and uses the dependency information to resolve their dependencies.
  31. * <code>version</code> can be omitted in case all libraries can be resolved via the <code>providedLibraryMetadata</code>.
  32. * Otherwise an error is thrown.
  33. */
  34. /* eslint-enable max-len */
  35. constructor({cwd, version, sources, ui5HomeDir, providedLibraryMetadata}) {
  36. if (new.target === AbstractResolver) {
  37. throw new TypeError("Class 'AbstractResolver' is abstract");
  38. }
  39. // In some CI environments, the homedir might be set explicitly to a relative
  40. // path (e.g. "./"), but tooling requires an absolute path
  41. this._ui5HomeDir = path.resolve(
  42. ui5HomeDir || path.join(os.homedir(), ".ui5")
  43. );
  44. this._cwd = cwd ? path.resolve(cwd) : process.cwd();
  45. this._version = version;
  46. // Environment variable should always enforce usage of sources
  47. if (process.env.UI5_PROJECT_USE_FRAMEWORK_SOURCES) {
  48. sources = true;
  49. }
  50. this._sources = !!sources;
  51. this._providedLibraryMetadata = providedLibraryMetadata;
  52. }
  53. async _processLibrary(libraryName, libraryMetadata, errors) {
  54. // Check if library is already processed
  55. if (libraryMetadata[libraryName]) {
  56. return;
  57. }
  58. // Mark library as handled
  59. libraryMetadata[libraryName] = Object.create(null);
  60. log.verbose("Processing " + libraryName);
  61. let promises;
  62. const providedLibraryMetadata = this._providedLibraryMetadata?.[libraryName];
  63. if (providedLibraryMetadata) {
  64. log.verbose(`Skipping install for ${libraryName} (provided)`);
  65. promises = {
  66. // Use existing metadata if library is provided from outside (e.g. workspace)
  67. metadata: Promise.resolve(providedLibraryMetadata),
  68. // Provided libraries are already "installed"
  69. install: Promise.resolve({
  70. pkgPath: providedLibraryMetadata.path
  71. })
  72. };
  73. } else if (!this._version) {
  74. throw new Error(`Unable to install library ${libraryName}. No framework version provided.`);
  75. } else {
  76. promises = await this.handleLibrary(libraryName);
  77. }
  78. const [metadata, {pkgPath}] = await Promise.all([
  79. promises.metadata.then((metadata) =>
  80. this._processDependencies(libraryName, metadata, libraryMetadata, errors)),
  81. promises.install
  82. ]);
  83. // Add path to installed package to metadata
  84. metadata.path = pkgPath;
  85. // Add metadata entry
  86. libraryMetadata[libraryName] = metadata;
  87. }
  88. async _processDependencies(libraryName, metadata, libraryMetadata, errors) {
  89. if (metadata.dependencies.length > 0) {
  90. log.verbose("Processing dependencies of " + libraryName);
  91. await this._processLibraries(metadata.dependencies, libraryMetadata, errors);
  92. log.verbose("Done processing dependencies of " + libraryName);
  93. }
  94. return metadata;
  95. }
  96. async _processLibraries(libraryNames, libraryMetadata, errors) {
  97. const sourceErrors = new Set();
  98. const results = await Promise.all(libraryNames.map(async (libraryName) => {
  99. try {
  100. await this._processLibrary(libraryName, libraryMetadata, errors);
  101. } catch (err) {
  102. if (sourceErrors.has(err.message)) {
  103. return `Failed to resolve library ${libraryName}: Error already logged`;
  104. }
  105. sourceErrors.add(err.message);
  106. log.verbose(`Failed to process library ${libraryName}`);
  107. log.verbose(`Error: ${err.message}`);
  108. log.verbose(`Call stack: ${err.stack}`);
  109. return `Failed to resolve library ${libraryName}: ${err.message}`;
  110. }
  111. }));
  112. // Don't add empty results (success)
  113. errors.push(...results.filter(($) => $));
  114. }
  115. /**
  116. * Library metadata entry
  117. *
  118. * @example
  119. * const libraryMetadataEntry = {
  120. * "id": "@openui5/sap.ui.core",
  121. * "version": "1.75.0",
  122. * "path": "~/.ui5/framework/packages/@openui5/sap.ui.core/1.75.0",
  123. * "dependencies": [],
  124. * "optionalDependencies": []
  125. * };
  126. *
  127. * @public
  128. * @typedef {object} @ui5/project/ui5Framework/AbstractResolver~LibraryMetadataEntry
  129. * @property {string} id Identifier
  130. * @property {string} version Version
  131. * @property {string} path Path
  132. * @property {string[]} dependencies List of dependency ids
  133. * @property {string[]} optionalDependencies List of optional dependency ids
  134. */
  135. /**
  136. * Install result
  137. *
  138. * @example
  139. * const resolverInstallResult = {
  140. * "libraryMetadata": {
  141. * "sap.ui.core": {
  142. * // ...
  143. * },
  144. * "sap.m": {
  145. * // ...
  146. * }
  147. * }
  148. * };
  149. *
  150. * @public
  151. * @typedef {object} @ui5/project/ui5Framework/AbstractResolver~ResolverInstallResult
  152. * @property {object.<string, @ui5/project/ui5Framework/AbstractResolver~LibraryMetadataEntry>} libraryMetadata
  153. * Object containing all installed libraries with library name as key
  154. */
  155. /**
  156. * Installs the provided libraries and their dependencies
  157. *
  158. * @example
  159. * const resolver = new Sapui5Resolver({version: "1.76.0"});
  160. * // Or for OpenUI5:
  161. * // const resolver = new Openui5Resolver({version: "1.76.0"});
  162. *
  163. * resolver.install(["sap.ui.core", "sap.m"]).then(({libraryMetadata}) => {
  164. * // Installation done
  165. * }).catch((err) => {
  166. * // Handle installation errors
  167. * });
  168. *
  169. * @public
  170. * @param {string[]} libraryNames List of library names to be installed
  171. * @returns {@ui5/project/ui5Framework/AbstractResolver~ResolverInstallResult}
  172. * Resolves with an object containing the <code>libraryMetadata</code>
  173. */
  174. async install(libraryNames) {
  175. const libraryMetadata = Object.create(null);
  176. const errors = [];
  177. await this._processLibraries(libraryNames, libraryMetadata, errors);
  178. if (errors.length === 1) {
  179. throw new Error(errors[0]);
  180. } if (errors.length > 1) {
  181. const msg = errors.map((err, idx) => ` ${idx + 1}. ${err}`).join("\n");
  182. throw new Error(`Resolution of framework libraries failed with errors:\n${msg}`);
  183. }
  184. return {
  185. libraryMetadata
  186. };
  187. }
  188. static async resolveVersion(version, {ui5HomeDir, cwd} = {}) {
  189. // Don't allow nullish values
  190. // An empty string is a valid semver range that converts to "*", which should not be supported
  191. if (!version) {
  192. throw new Error(`Framework version specifier "${version}" is incorrect or not supported`);
  193. }
  194. const spec = await this._getVersionSpec(version, {ui5HomeDir, cwd});
  195. // For all invalid cases which are not explicitly handled in _getVersionSpec
  196. if (!spec) {
  197. throw new Error(`Framework version specifier "${version}" is incorrect or not supported`);
  198. }
  199. const versions = await this.fetchAllVersions({ui5HomeDir, cwd});
  200. const resolvedVersion = semver.maxSatisfying(versions, spec, {
  201. // Allow ranges that end with -SNAPSHOT to match any -SNAPSHOT version
  202. // like a normal version in order to support ranges like 1.x.x-SNAPSHOT.
  203. includePrerelease: this._isSnapshotVersionOrRange(version)
  204. });
  205. if (!resolvedVersion) {
  206. if (semver.valid(spec)) {
  207. if (this.name === "Sapui5Resolver" && semver.lt(spec, "1.76.0")) {
  208. throw new Error(`Could not resolve framework version ${version}. ` +
  209. `Note that SAPUI5 framework libraries can only be consumed by the UI5 Tooling ` +
  210. `starting with SAPUI5 v1.76.0`);
  211. } else if (this.name === "Openui5Resolver" && semver.lt(spec, "1.52.5")) {
  212. throw new Error(`Could not resolve framework version ${version}. ` +
  213. `Note that OpenUI5 framework libraries can only be consumed by the UI5 Tooling ` +
  214. `starting with OpenUI5 v1.52.5`);
  215. }
  216. }
  217. throw new Error(
  218. `Could not resolve framework version ${version}. ` +
  219. `Make sure the version is valid and available in the configured registry.`);
  220. }
  221. return resolvedVersion;
  222. }
  223. static async _getVersionSpec(version, {ui5HomeDir, cwd}) {
  224. if (this._isSnapshotVersionOrRange(version)) {
  225. const versionMatch = version.match(VERSION_RANGE_REGEXP);
  226. if (versionMatch) {
  227. // For snapshot version ranges we need to insert a stand-in "x" for the patch level
  228. // and - in case none is provided - another "x" for the major version in order to
  229. // convert it to a valid semver range:
  230. // "1-SNAPSHOT" becomes "1.x.x-SNAPSHOT" and "1.112-SNAPSHOT" becomes "1.112.x-SNAPSHOT"
  231. return `${versionMatch[1]}.${versionMatch[2] || "x"}.x-SNAPSHOT`;
  232. }
  233. }
  234. // Covers versions and ranges, as versions are also valid ranges
  235. if (semver.validRange(version)) {
  236. return version;
  237. }
  238. // Check for invalid tag name (same check as npm does)
  239. if (encodeURIComponent(version) !== version) {
  240. return null;
  241. }
  242. const allTags = await this.fetchAllTags({ui5HomeDir, cwd});
  243. if (!allTags) {
  244. // Resolver doesn't support tags (e.g. Sapui5MavenSnapshotResolver)
  245. // Only latest and latest-snapshot are supported which both resolve
  246. // to the latest available version.
  247. // See "isSnapshotVersionOrRange" for -snapshot handling
  248. if ((version === "latest" || version === "latest-snapshot")) {
  249. return "*";
  250. } else {
  251. return null;
  252. }
  253. }
  254. if (!allTags[version]) {
  255. throw new Error(
  256. `Could not resolve framework version via tag '${version}'. ` +
  257. `Make sure the tag is available in the configured registry.`
  258. );
  259. }
  260. // Use version from tag
  261. return allTags[version];
  262. }
  263. static _isSnapshotVersionOrRange(version) {
  264. return version.toLowerCase().endsWith("-snapshot");
  265. }
  266. // To be implemented by resolver
  267. async getLibraryMetadata(libraryName) {
  268. throw new Error("AbstractResolver: getLibraryMetadata must be implemented!");
  269. }
  270. async handleLibrary(libraryName) {
  271. throw new Error("AbstractResolver: handleLibrary must be implemented!");
  272. }
  273. static fetchAllVersions(options) {
  274. throw new Error("AbstractResolver: static fetchAllVersions must be implemented!");
  275. }
  276. static fetchAllTags(options) {
  277. return null;
  278. }
  279. }
  280. export default AbstractResolver;