diff --git a/.bitmap b/.bitmap index d52e0127f2b2..5cee5413f9b3 100644 --- a/.bitmap +++ b/.bitmap @@ -380,6 +380,13 @@ "mainFile": "index.ts", "rootDir": "scopes/component/component-tree" }, + "component-version": { + "name": "component-version", + "scope": "teambit.component", + "version": "1.0.3", + "mainFile": "index.ts", + "rootDir": "components/component-version" + }, "component-writer": { "name": "component-writer", "scope": "teambit.component", diff --git a/components/component-version/exceptions/index.ts b/components/component-version/exceptions/index.ts new file mode 100644 index 000000000000..fb67ac3e694f --- /dev/null +++ b/components/component-version/exceptions/index.ts @@ -0,0 +1,3 @@ +import InvalidVersion from './invalid-version'; + +export { InvalidVersion }; diff --git a/components/component-version/exceptions/invalid-version.ts b/components/component-version/exceptions/invalid-version.ts new file mode 100644 index 000000000000..339d9b601e5c --- /dev/null +++ b/components/component-version/exceptions/invalid-version.ts @@ -0,0 +1,7 @@ +import { BitError } from '@teambit/bit-error'; + +export default class InvalidVersion extends BitError { + constructor(version?: string | null) { + super(`error: version ${version || '(empty)'} is not a valid semantic version. learn more: https://semver.org`); + } +} diff --git a/components/component-version/index.ts b/components/component-version/index.ts new file mode 100644 index 000000000000..1654132c7fe6 --- /dev/null +++ b/components/component-version/index.ts @@ -0,0 +1,23 @@ +import { Version, LATEST_VERSION } from './version'; +import versionParser, { + isHash, + isSnap, + isTag, + HASH_SIZE, + SHORT_HASH_MINIMUM_SIZE, + generateSnap, +} from './version-parser'; +import { InvalidVersion } from './exceptions'; + +export { + Version, + LATEST_VERSION, + versionParser, + isHash, + isSnap, + isTag, + generateSnap, + InvalidVersion, + HASH_SIZE, + SHORT_HASH_MINIMUM_SIZE, +}; diff --git a/components/component-version/version-parser.spec.ts b/components/component-version/version-parser.spec.ts new file mode 100644 index 000000000000..1b39933d2c82 --- /dev/null +++ b/components/component-version/version-parser.spec.ts @@ -0,0 +1,29 @@ +import { expect } from 'chai'; + +import versionParser from './version-parser'; +import { InvalidVersion } from './exceptions'; + +describe('versionParser()', () => { + it('should return latest version representation', () => { + const version = versionParser('latest'); + expect(version.latest).to.equal(true); + expect(version.versionNum).to.equal(null); + }); + + it('should throw invalid version', () => { + const version = () => versionParser('$1'); + expect(version).to.throw(InvalidVersion); + }); + + it('should return a concrete version', () => { + const version = versionParser('0.0.1'); + expect(version.latest).to.equal(false); + expect(version.versionNum).to.equal('0.0.1'); + }); + + it('should parse given version as latest', () => { + const version = versionParser('latest'); + expect(version.versionNum).to.equal(null); + expect(version.latest).to.equal(true); + }); +}); diff --git a/components/component-version/version-parser.ts b/components/component-version/version-parser.ts new file mode 100644 index 000000000000..243530e1be32 --- /dev/null +++ b/components/component-version/version-parser.ts @@ -0,0 +1,79 @@ +import semver from 'semver'; +import crypto from 'crypto'; +import { InvalidVersion } from './exceptions'; +import { Version, LATEST_VERSION } from './version'; + +export const HASH_SIZE = 40; + +/** + * because the directory structure is `XX/YY....`, it needs to have at least three characters. + */ +export const SHORT_HASH_MINIMUM_SIZE = 3; + +function isLatest(versionStr: string): boolean { + return versionStr === LATEST_VERSION; +} + +function isSemverValid(versionStr: string) { + return Boolean(semver.valid(versionStr)); +} + +function returnSemver(versionStr: string): Version { + return new Version(versionStr, false); +} + +function returnLatest(): Version { + return new Version(null, true); +} + +function returnSnap(hash: string): Version { + return new Version(hash, false); +} + +/** + * a snap is a 40 characters hash encoded in HEX. so it can be a-f and 0-9. + * also, for convenience, a short-hash can be used, which is a minimum of 3 characters. + */ +export function isHash(str: string | null | undefined): boolean { + return typeof str === 'string' && isHex(str) && str.length >= SHORT_HASH_MINIMUM_SIZE && str.length <= HASH_SIZE; +} + +/** + * a component version can be a tag (semver) or a snap (hash) + */ +export function isTag(str?: string): boolean { + return typeof str === 'string' && isSemverValid(str); +} + +/** + * a component version can be a tag (semver) or a snap (hash). + * a snap must be 40 characters long and consist of a-f and 0-9. (hex). + */ +export function isSnap(str: string | null | undefined): boolean { + return isHash(str) && typeof str === 'string' && str.length === HASH_SIZE; +} + +/** + * generate a random valid snap hash (which is a 40 characters long hash encoded in HEX) + */ +export function generateSnap(): string { + // @ts-ignore until @types/node is updated to v18 or above + return crypto.createHash('sha1').update(crypto.randomUUID()).digest('hex'); +} + +export default function versionParser(versionStr: string | null | undefined): Version { + if (!versionStr) return returnLatest(); + if (isLatest(versionStr)) return returnLatest(); + if (isSemverValid(versionStr)) return returnSemver(versionStr); + if (isHash(versionStr)) return returnSnap(versionStr); + + throw new InvalidVersion(versionStr.toString()); +} + +/** + * check if the string consists of valid hexadecimal characters + */ +function isHex(str: string) { + const hexRegex = /^[0-9a-fA-F]+$/; + return hexRegex.test(str); +} diff --git a/components/component-version/version.spec.ts b/components/component-version/version.spec.ts new file mode 100644 index 000000000000..bd9eeb426dfd --- /dev/null +++ b/components/component-version/version.spec.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai'; + +import { Version } from './version'; + +describe('Version', () => { + describe('toString()', () => { + it('should return latest', () => { + const version = new Version(null, true); + expect(version.toString()).to.equal('latest'); + }); + + it('should return concrete version number', () => { + // @ts-ignore AUTO-ADDED-AFTER-MIGRATION-PLEASE-FIX! + const version = new Version(12, false); + expect(version.toString()).to.equal('12'); + }); + + it('should throw an invalid version exception', () => { + const version = new Version(null, false); + expect(() => { + version.toString(); + }).to.throw(); + }); + }); +}); diff --git a/components/component-version/version.ts b/components/component-version/version.ts new file mode 100644 index 000000000000..f4069ef6ffb8 --- /dev/null +++ b/components/component-version/version.ts @@ -0,0 +1,33 @@ +import semver from 'semver'; +import { InvalidVersion } from './exceptions'; + +export const LATEST_VERSION = 'latest'; + +export class Version { + versionNum: string | null | undefined; + latest: boolean; + + constructor(versionNum: string | null | undefined, latest: boolean) { + this.versionNum = versionNum; + this.latest = latest; + if (versionNum && latest) { + throw new Error(`a component version cannot have both: version and "latest"`); + } + } + + toString() { + if (!this.versionNum && this.latest) return 'latest'; + if (this.versionNum && !this.latest) return this.versionNum.toString(); + throw new InvalidVersion(this.versionNum); + } + + isLaterThan(otherVersion: Version): boolean { + if (!this.versionNum || this.versionNum === LATEST_VERSION) { + return true; + } + if (!otherVersion.versionNum || otherVersion.versionNum === LATEST_VERSION) { + return false; + } + return semver.gt(this.versionNum, otherVersion.versionNum); + } +} diff --git a/scopes/component/snapping/tag-model-component.ts b/scopes/component/snapping/tag-model-component.ts index 36e7d9ae39cb..77a0e4f73720 100644 --- a/scopes/component/snapping/tag-model-component.ts +++ b/scopes/component/snapping/tag-model-component.ts @@ -1,7 +1,6 @@ import mapSeries from 'p-map-series'; import { isEmpty } from 'lodash'; import { ReleaseType } from 'semver'; -import { v4 } from 'uuid'; import { BitError } from '@teambit/bit-error'; import { Scope } from '@teambit/legacy/dist/scope'; import { ComponentID, ComponentIdList } from '@teambit/component-id'; @@ -15,7 +14,6 @@ import { getBasicLog } from '@teambit/legacy/dist/utils/bit/basic-log'; import { Component } from '@teambit/component'; import deleteComponentsFiles from '@teambit/legacy/dist/consumer/component-ops/delete-component-files'; import logger from '@teambit/legacy/dist/logger/logger'; -import { sha1 } from '@teambit/legacy/dist/utils'; import { AutoTagResult, getAutoTagInfo } from '@teambit/legacy/dist/scope/component-ops/auto-tag'; import { getValidVersionOrReleaseType } from '@teambit/legacy/dist/utils/semver-helper'; import { BuilderMain, OnTagOpts } from '@teambit/builder'; @@ -82,7 +80,7 @@ function updateDependenciesVersions( function setHashes(componentsToTag: ConsumerComponent[]): void { componentsToTag.forEach((componentToTag) => { - componentToTag.setNewVersion(sha1(v4())); + componentToTag.setNewVersion(); }); } diff --git a/src/consumer/component/consumer-component.ts b/src/consumer/component/consumer-component.ts index 154c390d500b..911888c6b7da 100644 --- a/src/consumer/component/consumer-component.ts +++ b/src/consumer/component/consumer-component.ts @@ -1,10 +1,10 @@ import { ComponentID, ComponentIdList } from '@teambit/component-id'; import fs from 'fs-extra'; -import { v4 } from 'uuid'; import * as path from 'path'; import R from 'ramda'; import { IssuesList } from '@teambit/component-issues'; import { BitId } from '@teambit/legacy-bit-id'; +import { generateSnap } from '@teambit/component-version'; import { BitError } from '@teambit/bit-error'; import { getCloudDomain, BIT_WORKSPACE_TMP_DIRNAME, BuildStatus, DEFAULT_LANGUAGE, Extensions } from '../../constants'; import docsParser from '../../jsdoc/parser'; @@ -12,7 +12,7 @@ import { Doclet } from '../../jsdoc/types'; import logger from '../../logger/logger'; import { ScopeListItem } from '../../scope/models/model-component'; import Version, { DepEdge, Log } from '../../scope/models/version'; -import { pathNormalizeToLinux, sha1 } from '../../utils'; +import { pathNormalizeToLinux } from '../../utils'; import { PathLinux, PathOsBased, PathOsBasedRelative } from '../../utils/path'; import ComponentMap from '../bit-map/component-map'; import { IgnoredDirectory } from '../component-ops/add-components/exceptions/ignored-directory'; @@ -269,7 +269,7 @@ export default class Component { this.peerDependencies = new Dependencies(peerDependencies); } - setNewVersion(version = sha1(v4())) { + setNewVersion(version = generateSnap()) { this.previouslyUsedVersion = this.id.hasVersion() ? this.version : undefined; this.version = version; }