mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 18:31:52 +00:00
* docs: update preferred external port design in TODO * docs: add user-controlled public/private and port forward mapping to design * docs: overhaul interfaces page design with view/manage split and per-address controls * docs: move address enable/disable to overflow menu, add SSL indicator, defer UI placement decisions * chore: remove tor from startos core Tor is being moved from a built-in OS feature to a service. This removes the Arti-based Tor client, onion address management, hidden service creation, and all related code from the core backend, frontend, and SDK. - Delete core/src/net/tor/ module (~2060 lines) - Remove OnionAddress, TorSecretKey, TorController from all consumers - Remove HostnameInfo::Onion and HostAddress::Onion variants - Remove onion CRUD RPC endpoints and tor subcommand - Remove tor key handling from account and backup/restore - Remove ~12 tor-related Cargo dependencies (arti-client, torut, etc.) - Remove tor UI components, API methods, mock data, and routes - Remove OnionHostname and tor patterns/regexes from SDK - Add v0_4_0_alpha_20 database migration to strip onion data - Bump version to 0.4.0-alpha.20 * chore: flatten HostnameInfo from enum to struct HostnameInfo only had one variant (Ip) after removing Tor. Flatten it into a plain struct with fields gateway, public, hostname. Remove all kind === 'ip' type guards and narrowing across SDK, frontend, and container runtime. Update DB migration to strip the kind field. * chore: format RPCSpec.md markdown table * docs: update TODO.md with DerivedAddressInfo design, remove completed tor task * feat: implement preferred port allocation and per-address enable/disable - Add AvailablePorts::try_alloc() with SSL tracking (BTreeMap<u16, bool>) - Add DerivedAddressInfo on BindInfo with private_disabled/public_enabled/possible sets - Add Bindings wrapper with Map impl for patchdb indexed access - Flatten HostAddress from single-variant enum to struct - Replace set-gateway-enabled RPC with set-address-enabled - Remove hostname_info from Host; computed addresses now in BindInfo.addresses.possible - Compute possible addresses inline in NetServiceData::update() - Update DB migration, SDK types, frontend, and container-runtime * feat: replace InterfaceFilter with ForwardRequirements, add WildcardListener, complete alpha.20 bump - Replace DynInterfaceFilter with ForwardRequirements for per-IP forward precision with source-subnet iptables filtering for private forwards - Add WildcardListener (binds [::]:port) to replace the per-gateway NetworkInterfaceListener/SelfContainedNetworkInterfaceListener/ UpgradableListener infrastructure - Update forward-port script with src_subnet and excluded_src env vars - Remove unused filter types and listener infrastructure from gateway.rs - Add availablePorts migration (IdPool -> BTreeMap<u16, bool>) to alpha.20 - Complete version bump to 0.4.0-alpha.20 in SDK and web * outbound gateway support (#3120) * Multiple (#3111) * fix alerts i18n, fix status display, better, remove usb media, hide shutdown for install complete * trigger chnage detection for localize pipe and round out implementing localize pipe for consistency even though not needed * Fix PackageInfoShort to handle LocaleString on releaseNotes (#3112) * Fix PackageInfoShort to handle LocaleString on releaseNotes * fix: filter by target_version in get_matching_models and pass otherVersions from install * chore: add exver documentation for ai agents * frontend plus some be types --------- Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> * feat: replace SourceFilter with IpNet, add policy routing, remove MASQUERADE * build ts types and fix i18n * fix license display in marketplace * wip refactor * chore: update ts bindings for preferred port design * feat: refactor NetService to watch DB and reconcile network state - NetService sync task now uses PatchDB DbWatch instead of being called directly after DB mutations - Read gateways from DB instead of network interface context when updating host addresses - gateway sync updates all host addresses in the DB - Add Watch<u64> channel for callers to wait on sync completion - Fix ts-rs codegen bug with #[ts(skip)] on flattened Plugin field - Update SDK getServiceInterface.ts for new HostnameInfo shape - Remove unnecessary HTTPS redirect in static_server.rs - Fix tunnel/api.rs to filter for WAN IPv4 address * re-arrange (#3123) * new service interfacee page * feat: add mdns hostname metadata variant and fix vhost routing - Add HostnameMetadata::Mdns variant to distinguish mDNS from private domains - Mark mDNS addresses as private (public: false) since mDNS is local-only - Fall back to null SNI entry when hostname not found in vhost mapping - Simplify public detection in ProxyTarget filter - Pass hostname to update_addresses for mDNS domain name generation * looking good * feat: add port_forwards field to Host for tracking gateway forwarding rules * update bindings for API types, add ARCHITECTURE (#3124) * update binding for API types, add ARCHITECTURE * translations * fix: add CONNMARK restore-mark to mangle OUTPUT chain The CONNMARK --restore-mark rule was only in PREROUTING, which handles forwarded packets. Locally-bound listeners (e.g. vhost) generate replies through the OUTPUT chain, where the fwmark was never restored. This caused response packets to route via the default table instead of back through the originating interface. * chore: reserialize db on equal version, update bindings and docs - Run de/ser roundtrip in pre_init even when db version matches, ensuring all #[serde(default)] fields are populated before any typed access - Add patchdb.md documentation for TypedDbWatch patterns - Update TS bindings for CheckPortParams, CheckPortRes, ifconfigUrl - Update CLAUDE.md docs with patchdb and component-level references * fix: include public gateways for IP-based addresses in vhost targets The server hostname vhost construction only collected private IPs, always setting public to empty. Public IP addresses (Ipv4/Ipv6 metadata with public=true) were never added to the vhost target's public gateway set, causing the vhost filter to reject public traffic for IP-based addresses. * fix: add TLS handshake timeout and fix accept loop deadlock Two issues in TlsListener::poll_accept: 1. No timeout on TLS handshakes: LazyConfigAcceptor waits indefinitely for ClientHello. Attackers that complete TCP handshake but never send TLS data create zombie futures in `in_progress` that never complete. Fix: wrap the entire handshake in tokio::time::timeout(15s). 2. Missing waker on new-connection pending path: when a TCP connection is accepted and the TLS handshake is pending, poll_accept returned Pending without calling wake_by_ref(). Since the TcpListener returned Ready (not Pending), no waker was registered for it. With edge- triggered epoll and no other wakeup source, the task sleeps forever and remaining connections in the kernel accept queue are never drained. Fix: add cx.waker().wake_by_ref() so the task immediately re-polls and continues draining the accept queue. * fix: switch BackgroundJobRunner from Vec to FuturesUnordered BackgroundJobRunner stored active jobs in a Vec<BoxFuture> and polled ALL of them on every wakeup — O(n) per poll. Since this runs in the same tokio::select! as the WebServer accept loop, polling overhead from active connections directly delayed acceptance of new connections. FuturesUnordered only polls woken futures — O(woken) instead of O(n). * chore: update bindings and use typed params for outbound gateway API * feat: per-service and default outbound gateway routing Add set-outbound-gateway RPC for packages and set-default-outbound RPC for the server, with policy routing enforcement via ip rules. Fix connmark restore to skip packets with existing fwmarks, add bridge subnet routes to per-interface tables, and fix squashfs path in update-image-local.sh. * refactor: manifest wraps PackageMetadata, move dependency_metadata to PackageVersionInfo Manifest now embeds PackageMetadata via #[serde(flatten)] instead of duplicating ~14 fields. icon and dependency_metadata moved from PackageMetadata to PackageVersionInfo since they are registry-enrichment data loaded from the S9PK archive. merge_with now returns errors on metadata/icon/dependency_metadata mismatches instead of silently ignoring them. * fix: replace .status() with .invoke() for iptables/ip commands Using .status() leaks stderr directly to system logs, causing noisy iptables error messages. Switch all networking CLI invocations to use .invoke() which captures stderr properly. For check-then-act patterns (iptables -C), use .invoke().await.is_err() instead of .status().await.map_or(false, |s| s.success()). * feat: add check-dns gateway endpoint and fix per-interface routing tables Add a `check-dns` RPC endpoint that verifies whether a gateway's DNS is properly configured for private domain resolution. Uses a three-tier check: direct match (DNS == server IP), TXT challenge probe (DNS on LAN), or failure (DNS off-subnet). Fix per-interface routing tables to clone all non-default routes from the main table instead of only the interface's own subnets. This preserves LAN reachability when the priority-75 catch-all overrides default routing. Filter out status-only flags (linkdown, dead) that are invalid for `ip route add`. * refactor: rename manifest metadata fields and improve error display Rename wrapperRepo→packageRepo, marketingSite→marketingUrl, docsUrl→docsUrls (array), remove supportSite. Add display_src/display_dbg helpers to Error. Fix DepInfo description type to LocaleString. Update web UI, SDK bindings, tests, and fixtures to match. Clean up cli_attach error handling and remove dead commented code. * chore: bump sdk version to 0.4.0-beta.49 * chore: add createTask decoupling TODO * chore: add TODO to clear service error state on install/update * round out dns check, dns server check, port forward check, and gateway port forwards * chore: add TODOs for URL plugins, NAT hairpinning, and start-tunnel OTA updates * version instead of os query param * interface row clickable again, bu now with a chevron! * feat: implement URL plugins with table/row actions and prefill support - Add URL plugin effects (register, export_url, clear_urls) in core - Add PluginHostnameInfo, HostnameMetadata::Plugin, and plugin registration types - Implement plugin URL table in web UI with tableAction button and rowAction overflow menus - Thread urlPluginMetadata (packageId, hostId, interfaceId, internalPort) as prefill to actions - Add prefill support to PackageActionData so metadata passes through form dialogs - Add i18n translations for plugin error messages - Clean up plugin URLs on package uninstall * feat: split row_actions into remove_action and overflow_actions for URL plugins * touch up URL plugins table * show table even when no addresses * feat: NAT hairpinning, DNS static servers, clear service error on install - Add POSTROUTING MASQUERADE rules for container and host hairpin NAT - Allow bridge subnet containers to reach private forwards via LAN IPs - Pass bridge_subnet env var from forward.rs to forward-port script - Use DB-configured static DNS servers in resolver with DB watcher - Fall back to resolv.conf servers when no static servers configured - Clear service error state when install/update completes successfully - Remove completed TODO items * feat: builder-style InputSpec API, prefill plumbing, and port forward fix - Add addKey() and add() builder methods to InputSpec with InputSpecTools - Move OuterType to last generic param on Value, List, and all dynamic methods - Plumb prefill through getActionInput end-to-end (core → container-runtime → SDK) - Filter port_forwards to enabled addresses only - Bump SDK to 0.4.0-beta.50 * fix: propagate host locale into LXC containers and write locale.conf * chore: remove completed URL plugins TODO * feat: OTA updates for start-tunnel via apt repository (untested) - Add apt repo publish script (build/apt/publish-deb.sh) for S3-hosted repo - Add apt source config and GPG key placeholder (apt/) - Add tunnel.update.check and tunnel.update.apply RPC endpoints - Wire up update API in tunnel frontend (api service + mock) - Uses systemd-run --scope to survive service restart during update * fix: publish script dpkg-name, s3cfg fallback, and --reinstall for apply * chore: replace OTA updates TODO with UI TODO for MattDHill * feat: add getOutboundGateway effect and simplify VersionGraph init/uninit Add getOutboundGateway effect across core, container-runtime, and SDK to let services query their effective outbound gateway with callback support. Remove preInstall/uninstall hooks from VersionGraph as they are no longer needed. * frontend start-tunnel updates * chore: remove completed TODO * feat: tor hidden service key migration * chore: migrate from ts-matches to zod across all TypeScript packages * feat(core): allow setting server hostname * send prefill for tasks and hide operations to hidden fields * fix(core): preserve plugin URLs across binding updates BindInfo::update was replacing addresses with a new DerivedAddressInfo that cleared the available set, wiping plugin-exported URLs whenever bind() was called. Also simplify update_addresses plugin preservation to use retain in place rather than collecting into a separate set. * minor cleanup from patch-db audit * clean up prefill flow * frontend support for setting and changing hostname * feat(core): refactor hostname to ServerHostnameInfo with name/hostname pair - Rename Hostname to ServerHostnameInfo, add name + hostname fields - Add set_hostname_rpc for changing hostname at runtime - Migrate alpha_20: generate serverInfo.name from hostname, delete ui.name - Extract gateway.rs helpers to fix rustfmt nesting depth issue - Add i18n key for hostname validation error - Update SDK bindings * add comments to everything potentially consumer facing (#3127) * add comments to everything potentially consumer facing * rework smtp --------- Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> * implement server name * setup changes * clean up copy around addresses table * feat: add zod-deep-partial, partialValidator on InputSpec, and z.deepPartial re-export * fix: header color in zoom (#3128) * fix: merge version ranges when adding existing package signer (#3125) * fix: merge version ranges when adding existing package signer Previously, add_package_signer unconditionally inserted the new version range, overwriting any existing authorization for that signer. Now it OR-merges the new range with the existing one, so running signer add multiple times accumulates permissions rather than replacing them. * add --merge flag to registry package signer add Default behavior remains overwrite. When --merge is passed, the new version range is OR-merged with the existing one, allowing admins to accumulate permissions incrementally. * add missing attribute to TS type * make merge optional * upsert instead of insert * VersionRange::None on upsert * fix: header color in zoom --------- Co-authored-by: Dominion5254 <musashidisciple@proton.me> * update snake and add about this server to system general * chore: bump sdk to beta.53, wrap z.deepPartial with passthrough * reset instead of reset defaults * action failure show dialog * chore: bump sdk to beta.54, add device-info RPC, improve SDK abort handling and InputSpec filtering - Bump SDK version to 0.4.0-beta.54 - Add `server.device-info` RPC endpoint and `s9pk select` CLI command - Extract `HardwareRequirements::is_compatible()` method, reuse in registry filtering - Add `AbortedError` class with `muteUnhandled` flag, replace generic abort errors - Handle unhandled promise rejections in container-runtime with mute support - Improve `InputSpec.filter()` with `keepByDefault` param and boolean filter values - Accept readonly tuples in `CommandType` and `splitCommand` - Remove `sync_host` calls from host API handlers (binding/address changes) - Filter mDNS hostnames by secure gateway availability - Derive mDNS enabled state from LAN IPs in web UI - Add "Open UI" action to address table, disable mDNS toggle - Hide debug details in service error component - Update rpc-toolkit docs for no-params handlers * fix: add --no-nvram to efi grub-install to preserve built-in boot order * update snake * diable actions when in error state * chore: split out nvidia variant * misc bugfixes * create manage-release script (untested) * fix: preserve z namespace types for sdk consumers * sdk version bump * new checkPort types * multiple bugs and better port forward ux * fix link * chore: todos and formatting * fix build --------- Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com> Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Alex Inkin <alexander@inkin.ru> Co-authored-by: Dominion5254 <musashidisciple@proton.me>
1274 lines
37 KiB
TypeScript
1274 lines
37 KiB
TypeScript
import { DeepMap } from 'deep-equality-data-structures'
|
|
import * as P from './exver'
|
|
|
|
/**
|
|
* Compile-time utility type that validates a version string literal conforms to semver format.
|
|
*
|
|
* Resolves to `unknown` if valid, `never` if invalid. Used with {@link testTypeVersion}.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* type Valid = ValidateVersion<"1.2.3"> // unknown (valid)
|
|
* type Invalid = ValidateVersion<"-3"> // never (invalid)
|
|
* ```
|
|
*/
|
|
// prettier-ignore
|
|
export type ValidateVersion<T extends String> =
|
|
T extends `-${infer A}` ? never :
|
|
T extends `${infer A}-${string}` ? ValidateVersion<A> :
|
|
T extends `${bigint}` ? unknown :
|
|
T extends `${bigint}.${infer A}` ? ValidateVersion<A> :
|
|
never
|
|
|
|
/**
|
|
* Compile-time utility type that validates an extended version string literal.
|
|
*
|
|
* Extended versions have the format `upstream:downstream` or `#flavor:upstream:downstream`.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* type Valid = ValidateExVer<"1.2.3:0"> // valid
|
|
* type Flavored = ValidateExVer<"#bitcoin:1.0:0"> // valid
|
|
* type Bad = ValidateExVer<"1.2-3"> // never (invalid)
|
|
* ```
|
|
*/
|
|
// prettier-ignore
|
|
export type ValidateExVer<T extends string> =
|
|
T extends `#${string}:${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> :
|
|
T extends `${infer A}:${infer B}` ? ValidateVersion<A> & ValidateVersion<B> :
|
|
never
|
|
|
|
/**
|
|
* Validates a tuple of extended version string literals at compile time.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* type Valid = ValidateExVers<["1.0:0", "2.0:0"]> // valid
|
|
* ```
|
|
*/
|
|
// prettier-ignore
|
|
export type ValidateExVers<T> =
|
|
T extends [] ? unknown[] :
|
|
T extends [infer A, ...infer B] ? ValidateExVer<A & string> & ValidateExVers<B> :
|
|
never[]
|
|
|
|
type Anchor = {
|
|
type: 'Anchor'
|
|
operator: P.CmpOp
|
|
version: ExtendedVersion
|
|
}
|
|
|
|
type And = {
|
|
type: 'And'
|
|
left: VersionRange
|
|
right: VersionRange
|
|
}
|
|
|
|
type Or = {
|
|
type: 'Or'
|
|
left: VersionRange
|
|
right: VersionRange
|
|
}
|
|
|
|
type Not = {
|
|
type: 'Not'
|
|
value: VersionRange
|
|
}
|
|
|
|
type Flavor = {
|
|
type: 'Flavor'
|
|
flavor: string | null
|
|
}
|
|
|
|
type FlavorNot = {
|
|
type: 'FlavorNot'
|
|
flavors: Set<string | null>
|
|
}
|
|
|
|
type FlavorAtom = Flavor | FlavorNot
|
|
|
|
/**
|
|
* Splits a number line of versions in half, so that every possible semver is either to the left or right.
|
|
* The `side` field handles inclusively.
|
|
*
|
|
* # Example
|
|
* Consider the version `1.2.3`. For side=-1 the version point is like `1.2.2.999*.999*.**` (that is, 1.2.3.0.0.** is greater) and
|
|
* for side=+1 the point is like `1.2.3.0.0.**.1` (that is, 1.2.3.0.0.** is less).
|
|
*/
|
|
type VersionRangePoint = {
|
|
upstream: Version
|
|
downstream: Version
|
|
side: -1 | 1
|
|
}
|
|
|
|
function compareVersionRangePoints(
|
|
a: VersionRangePoint,
|
|
b: VersionRangePoint,
|
|
): -1 | 0 | 1 {
|
|
let up = a.upstream.compareForSort(b.upstream)
|
|
if (up != 0) {
|
|
return up
|
|
}
|
|
let down = a.upstream.compareForSort(b.upstream)
|
|
if (down != 0) {
|
|
return down
|
|
}
|
|
if (a.side < b.side) {
|
|
return -1
|
|
} else if (a.side > b.side) {
|
|
return 1
|
|
} else {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function adjacentVersionRangePoints(
|
|
a: VersionRangePoint,
|
|
b: VersionRangePoint,
|
|
): boolean {
|
|
let up = a.upstream.compareForSort(b.upstream)
|
|
if (up != 0) {
|
|
return false
|
|
}
|
|
let down = a.upstream.compareForSort(b.upstream)
|
|
if (down != 0) {
|
|
return false
|
|
}
|
|
return a.side == -1 && b.side == 1
|
|
}
|
|
|
|
function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null {
|
|
if (a.type == 'Flavor') {
|
|
if (b.type == 'Flavor') {
|
|
if (a.flavor == b.flavor) {
|
|
return a
|
|
} else {
|
|
return null
|
|
}
|
|
} else {
|
|
if (b.flavors.has(a.flavor)) {
|
|
return null
|
|
} else {
|
|
return a
|
|
}
|
|
}
|
|
} else {
|
|
if (b.type == 'Flavor') {
|
|
if (a.flavors.has(b.flavor)) {
|
|
return null
|
|
} else {
|
|
return b
|
|
}
|
|
} else {
|
|
// TODO: use Set.union if targeting esnext or later
|
|
return {
|
|
type: 'FlavorNot',
|
|
flavors: new Set([...a.flavors, ...b.flavors]),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Truth tables for version numbers and flavors. For each flavor we need a separate table, which
|
|
* is quite straightforward. But in order to exhaustively enumerate the boolean values of every
|
|
* combination of flavors and versions we also need tables for flavor negations.
|
|
*/
|
|
type VersionRangeTables = DeepMap<FlavorAtom, VersionRangeTable> | boolean
|
|
|
|
/**
|
|
* A truth table for version numbers. This is easiest to picture as a number line, cut up into
|
|
* ranges of versions between version points.
|
|
*/
|
|
class VersionRangeTable {
|
|
private constructor(
|
|
protected points: Array<VersionRangePoint>,
|
|
protected values: boolean[],
|
|
) {}
|
|
|
|
static zip(
|
|
a: VersionRangeTable,
|
|
b: VersionRangeTable,
|
|
func: (a: boolean, b: boolean) => boolean,
|
|
): VersionRangeTable {
|
|
let c = new VersionRangeTable([], [])
|
|
let i = 0
|
|
let j = 0
|
|
while (true) {
|
|
let next = func(a.values[i], b.values[j])
|
|
if (c.values.length > 0 && c.values[c.values.length - 1] == next) {
|
|
// collapse automatically
|
|
c.points.pop()
|
|
} else {
|
|
c.values.push(next)
|
|
}
|
|
|
|
// which point do we step over?
|
|
if (i == a.points.length) {
|
|
if (j == b.points.length) {
|
|
// just added the last segment, no point to jump over
|
|
return c
|
|
} else {
|
|
// i has reach the end, step over j
|
|
c.points.push(b.points[j])
|
|
j += 1
|
|
}
|
|
} else {
|
|
if (j == b.points.length) {
|
|
// j has reached the end, step over i
|
|
c.points.push(a.points[i])
|
|
i += 1
|
|
} else {
|
|
// depends on which of the next two points is lower
|
|
switch (compareVersionRangePoints(a.points[i], b.points[j])) {
|
|
case -1:
|
|
// i is the lower point
|
|
c.points.push(a.points[i])
|
|
i += 1
|
|
break
|
|
case 1:
|
|
// j is the lower point
|
|
c.points.push(b.points[j])
|
|
j += 1
|
|
break
|
|
default:
|
|
// step over both
|
|
c.points.push(a.points[i])
|
|
i += 1
|
|
j += 1
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a version table which is `true` for the given flavor, and `false` for any other flavor.
|
|
*/
|
|
static eqFlavor(flavor: string | null): VersionRangeTables {
|
|
return new DeepMap([
|
|
[
|
|
{ type: 'Flavor', flavor } as FlavorAtom,
|
|
new VersionRangeTable([], [true]),
|
|
],
|
|
// make sure the truth table is exhaustive, or `not` will not work properly.
|
|
[
|
|
{ type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom,
|
|
new VersionRangeTable([], [false]),
|
|
],
|
|
])
|
|
}
|
|
|
|
/**
|
|
* Creates a version table with exactly two ranges (to the left and right of the given point) and with `false` for any other flavor.
|
|
* This is easiest to understand by looking at `VersionRange.tables`.
|
|
*/
|
|
static cmpPoint(
|
|
flavor: string | null,
|
|
point: VersionRangePoint,
|
|
left: boolean,
|
|
right: boolean,
|
|
): VersionRangeTables {
|
|
return new DeepMap([
|
|
[
|
|
{ type: 'Flavor', flavor } as FlavorAtom,
|
|
new VersionRangeTable([point], [left, right]),
|
|
],
|
|
// make sure the truth table is exhaustive, or `not` will not work properly.
|
|
[
|
|
{ type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom,
|
|
new VersionRangeTable([], [false]),
|
|
],
|
|
])
|
|
}
|
|
|
|
/**
|
|
* Helper for `cmpPoint`.
|
|
*/
|
|
static cmp(
|
|
version: ExtendedVersion,
|
|
side: -1 | 1,
|
|
left: boolean,
|
|
right: boolean,
|
|
): VersionRangeTables {
|
|
return VersionRangeTable.cmpPoint(
|
|
version.flavor,
|
|
{ upstream: version.upstream, downstream: version.downstream, side },
|
|
left,
|
|
right,
|
|
)
|
|
}
|
|
|
|
static not(tables: VersionRangeTables) {
|
|
if (tables === true || tables === false) {
|
|
return !tables
|
|
}
|
|
// because tables are always exhaustive, we can simply invert each range
|
|
for (let [f, t] of tables) {
|
|
for (let i = 0; i < t.values.length; i++) {
|
|
t.values[i] = !t.values[i]
|
|
}
|
|
}
|
|
return tables
|
|
}
|
|
|
|
static and(
|
|
a_tables: VersionRangeTables,
|
|
b_tables: VersionRangeTables,
|
|
): VersionRangeTables {
|
|
if (a_tables === true) {
|
|
return b_tables
|
|
}
|
|
if (b_tables === true) {
|
|
return a_tables
|
|
}
|
|
if (a_tables === false || b_tables == false) {
|
|
return false
|
|
}
|
|
let c_tables: VersionRangeTables = true
|
|
for (let [f_a, a] of a_tables) {
|
|
for (let [f_b, b] of b_tables) {
|
|
let flavor = flavorAnd(f_a, f_b)
|
|
if (flavor == null) {
|
|
continue
|
|
}
|
|
let c = VersionRangeTable.zip(a, b, (a, b) => a && b)
|
|
if (c_tables === true) {
|
|
c_tables = new DeepMap()
|
|
}
|
|
let prev_c = c_tables.get(flavor)
|
|
if (prev_c == null) {
|
|
c_tables.set(flavor, c)
|
|
} else {
|
|
c_tables.set(
|
|
flavor,
|
|
VersionRangeTable.zip(c, prev_c, (a, b) => a || b),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
return c_tables
|
|
}
|
|
|
|
static or(...in_tables: VersionRangeTables[]): VersionRangeTables {
|
|
let out_tables: VersionRangeTables = false
|
|
for (let tables of in_tables) {
|
|
if (tables === false) {
|
|
continue
|
|
}
|
|
if (tables === true) {
|
|
return true
|
|
}
|
|
if (out_tables === false) {
|
|
out_tables = new DeepMap()
|
|
}
|
|
for (let [flavor, table] of tables) {
|
|
let prev = out_tables.get(flavor)
|
|
if (prev == null) {
|
|
out_tables.set(flavor, table)
|
|
} else {
|
|
out_tables.set(
|
|
flavor,
|
|
VersionRangeTable.zip(table, prev, (a, b) => a || b),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
return out_tables
|
|
}
|
|
|
|
/**
|
|
* If this is true for all versions or false for all versions, returen that value. Otherwise return null.
|
|
*/
|
|
static collapse(tables: VersionRangeTables): boolean | null {
|
|
if (tables === true || tables === false) {
|
|
return tables
|
|
} else {
|
|
let found = null
|
|
for (let table of tables.values()) {
|
|
for (let x of table.values) {
|
|
if (found == null) {
|
|
found = x
|
|
} else if (found != x) {
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Expresses this truth table as a series of version range operators.
|
|
* https://en.wikipedia.org/wiki/Canonical_normal_form#Minterms
|
|
*/
|
|
static minterms(tables: VersionRangeTables): VersionRange {
|
|
let collapse = VersionRangeTable.collapse(tables)
|
|
if (tables === true || collapse === true) {
|
|
return VersionRange.any()
|
|
}
|
|
if (tables == false || collapse === false) {
|
|
return VersionRange.none()
|
|
}
|
|
let sum_terms: VersionRange[] = []
|
|
for (let [flavor, table] of tables) {
|
|
let cmp_flavor = null
|
|
if (flavor.type == 'Flavor') {
|
|
cmp_flavor = flavor.flavor
|
|
}
|
|
for (let i = 0; i < table.values.length; i++) {
|
|
let term: VersionRange[] = []
|
|
if (!table.values[i]) {
|
|
continue
|
|
}
|
|
|
|
if (flavor.type == 'FlavorNot') {
|
|
for (let not_flavor of flavor.flavors) {
|
|
term.push(VersionRange.flavor(not_flavor).not())
|
|
}
|
|
}
|
|
|
|
let p = null
|
|
let q = null
|
|
if (i > 0) {
|
|
p = table.points[i - 1]
|
|
}
|
|
if (i < table.points.length) {
|
|
q = table.points[i]
|
|
}
|
|
|
|
if (p != null && q != null && adjacentVersionRangePoints(p, q)) {
|
|
term.push(
|
|
VersionRange.anchor(
|
|
'=',
|
|
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
|
|
),
|
|
)
|
|
} else {
|
|
if (p != null && p.side < 0) {
|
|
term.push(
|
|
VersionRange.anchor(
|
|
'>=',
|
|
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
|
|
),
|
|
)
|
|
}
|
|
if (p != null && p.side >= 0) {
|
|
term.push(
|
|
VersionRange.anchor(
|
|
'>',
|
|
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
|
|
),
|
|
)
|
|
}
|
|
if (q != null && q.side < 0) {
|
|
term.push(
|
|
VersionRange.anchor(
|
|
'<',
|
|
new ExtendedVersion(cmp_flavor, q.upstream, q.downstream),
|
|
),
|
|
)
|
|
}
|
|
if (q != null && q.side >= 0) {
|
|
term.push(
|
|
VersionRange.anchor(
|
|
'<=',
|
|
new ExtendedVersion(cmp_flavor, q.upstream, q.downstream),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
if (term.length == 0) {
|
|
term.push(VersionRange.flavor(cmp_flavor))
|
|
}
|
|
|
|
sum_terms.push(VersionRange.and(...term))
|
|
}
|
|
}
|
|
return VersionRange.or(...sum_terms)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Represents a parsed version range expression used to match against {@link Version} or {@link ExtendedVersion} values.
|
|
*
|
|
* Version ranges support standard comparison operators (`=`, `>`, `<`, `>=`, `<=`, `!=`),
|
|
* caret (`^`) and tilde (`~`) ranges, boolean logic (`&&`, `||`, `!`), and flavor matching (`#flavor`).
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const range = VersionRange.parse(">=1.0.0:0 && <2.0.0:0")
|
|
* const version = ExtendedVersion.parse("1.5.0:0")
|
|
* console.log(range.satisfiedBy(version)) // true
|
|
*
|
|
* // Combine ranges with boolean logic
|
|
* const combined = VersionRange.and(
|
|
* VersionRange.parse(">=1.0:0"),
|
|
* VersionRange.parse("<3.0:0"),
|
|
* )
|
|
*
|
|
* // Match a specific flavor
|
|
* const flavored = VersionRange.parse("#bitcoin")
|
|
* ```
|
|
*/
|
|
export class VersionRange {
|
|
constructor(public atom: Anchor | And | Or | Not | P.Any | P.None | Flavor) {}
|
|
|
|
toStringParens(parent: 'And' | 'Or' | 'Not') {
|
|
let needs = true
|
|
switch (this.atom.type) {
|
|
case 'And':
|
|
case 'Or':
|
|
needs = parent != this.atom.type
|
|
break
|
|
case 'Anchor':
|
|
case 'Any':
|
|
case 'None':
|
|
needs = parent == 'Not'
|
|
break
|
|
case 'Not':
|
|
case 'Flavor':
|
|
needs = false
|
|
break
|
|
}
|
|
|
|
if (needs) {
|
|
return '(' + this.toString() + ')'
|
|
} else {
|
|
return this.toString()
|
|
}
|
|
}
|
|
|
|
/** Serializes this version range back to its canonical string representation. */
|
|
toString(): string {
|
|
switch (this.atom.type) {
|
|
case 'Anchor':
|
|
return `${this.atom.operator}${this.atom.version}`
|
|
case 'And':
|
|
return `${this.atom.left.toStringParens(this.atom.type)} && ${this.atom.right.toStringParens(this.atom.type)}`
|
|
case 'Or':
|
|
return `${this.atom.left.toStringParens(this.atom.type)} || ${this.atom.right.toStringParens(this.atom.type)}`
|
|
case 'Not':
|
|
return `!${this.atom.value.toStringParens(this.atom.type)}`
|
|
case 'Flavor':
|
|
return this.atom.flavor == null ? `#` : `#${this.atom.flavor}`
|
|
case 'Any':
|
|
return '*'
|
|
case 'None':
|
|
return '!'
|
|
}
|
|
}
|
|
|
|
private static parseAtom(atom: P.VersionRangeAtom): VersionRange {
|
|
switch (atom.type) {
|
|
case 'Not':
|
|
return new VersionRange({
|
|
type: 'Not',
|
|
value: VersionRange.parseAtom(atom.value),
|
|
})
|
|
case 'Parens':
|
|
return VersionRange.parseRange(atom.expr)
|
|
case 'Anchor':
|
|
return new VersionRange({
|
|
type: 'Anchor',
|
|
operator: atom.operator || '^',
|
|
version: new ExtendedVersion(
|
|
atom.version.flavor,
|
|
new Version(
|
|
atom.version.upstream.number,
|
|
atom.version.upstream.prerelease,
|
|
),
|
|
new Version(
|
|
atom.version.downstream.number,
|
|
atom.version.downstream.prerelease,
|
|
),
|
|
),
|
|
})
|
|
case 'Flavor':
|
|
return VersionRange.flavor(atom.flavor)
|
|
default:
|
|
return new VersionRange(atom)
|
|
}
|
|
}
|
|
|
|
private static parseRange(range: P.VersionRange): VersionRange {
|
|
let result = VersionRange.parseAtom(range[0])
|
|
for (const next of range[1]) {
|
|
switch (next[1]?.[0]) {
|
|
case '||':
|
|
result = new VersionRange({
|
|
type: 'Or',
|
|
left: result,
|
|
right: VersionRange.parseAtom(next[2]),
|
|
})
|
|
break
|
|
case '&&':
|
|
default:
|
|
result = new VersionRange({
|
|
type: 'And',
|
|
left: result,
|
|
right: VersionRange.parseAtom(next[2]),
|
|
})
|
|
break
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Parses a version range string into a `VersionRange`.
|
|
*
|
|
* @param range - A version range expression, e.g. `">=1.0.0:0 && <2.0.0:0"`, `"^1.2:0"`, `"*"`
|
|
* @returns The parsed `VersionRange`
|
|
* @throws If the string is not a valid version range expression
|
|
*/
|
|
static parse(range: string): VersionRange {
|
|
return VersionRange.parseRange(
|
|
P.parse(range, { startRule: 'VersionRange' }),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Creates a version range from a comparison operator and an {@link ExtendedVersion}.
|
|
*
|
|
* @param operator - One of `"="`, `">"`, `"<"`, `">="`, `"<="`, `"!="`, `"^"`, `"~"`
|
|
* @param version - The version to compare against
|
|
*/
|
|
static anchor(operator: P.CmpOp, version: ExtendedVersion) {
|
|
return new VersionRange({ type: 'Anchor', operator, version })
|
|
}
|
|
|
|
/**
|
|
* Creates a version range that matches only versions with the specified flavor.
|
|
*
|
|
* @param flavor - The flavor string to match, or `null` for the default (unflavored) variant
|
|
*/
|
|
static flavor(flavor: string | null) {
|
|
return new VersionRange({ type: 'Flavor', flavor })
|
|
}
|
|
|
|
/**
|
|
* Parses a legacy "emver" format version range string.
|
|
*
|
|
* @param range - A version range in the legacy emver format
|
|
* @returns The parsed `VersionRange`
|
|
*/
|
|
static parseEmver(range: string): VersionRange {
|
|
return VersionRange.parseRange(
|
|
P.parse(range, { startRule: 'EmverVersionRange' }),
|
|
)
|
|
}
|
|
|
|
/** Returns the intersection of this range with another (logical AND). */
|
|
and(right: VersionRange) {
|
|
return new VersionRange({ type: 'And', left: this, right })
|
|
}
|
|
|
|
/** Returns the union of this range with another (logical OR). */
|
|
or(right: VersionRange) {
|
|
return new VersionRange({ type: 'Or', left: this, right })
|
|
}
|
|
|
|
/** Returns the negation of this range (logical NOT). */
|
|
not() {
|
|
return new VersionRange({ type: 'Not', value: this })
|
|
}
|
|
|
|
/**
|
|
* Returns the logical AND (intersection) of multiple version ranges.
|
|
* Short-circuits on `none()` and skips `any()`.
|
|
*/
|
|
static and(...xs: Array<VersionRange>) {
|
|
let y = VersionRange.any()
|
|
for (let x of xs) {
|
|
if (x.atom.type == 'Any') {
|
|
continue
|
|
}
|
|
if (x.atom.type == 'None') {
|
|
return x
|
|
}
|
|
if (y.atom.type == 'Any') {
|
|
y = x
|
|
} else {
|
|
y = new VersionRange({ type: 'And', left: y, right: x })
|
|
}
|
|
}
|
|
return y
|
|
}
|
|
|
|
/**
|
|
* Returns the logical OR (union) of multiple version ranges.
|
|
* Short-circuits on `any()` and skips `none()`.
|
|
*/
|
|
static or(...xs: Array<VersionRange>) {
|
|
let y = VersionRange.none()
|
|
for (let x of xs) {
|
|
if (x.atom.type == 'None') {
|
|
continue
|
|
}
|
|
if (x.atom.type == 'Any') {
|
|
return x
|
|
}
|
|
if (y.atom.type == 'None') {
|
|
y = x
|
|
} else {
|
|
y = new VersionRange({ type: 'Or', left: y, right: x })
|
|
}
|
|
}
|
|
return y
|
|
}
|
|
|
|
/** Returns a version range that matches all versions (wildcard `*`). */
|
|
static any() {
|
|
return new VersionRange({ type: 'Any' })
|
|
}
|
|
|
|
/** Returns a version range that matches no versions (`!`). */
|
|
static none() {
|
|
return new VersionRange({ type: 'None' })
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if the given version satisfies this range.
|
|
*
|
|
* @param version - A {@link Version} or {@link ExtendedVersion} to test
|
|
*/
|
|
satisfiedBy(version: Version | ExtendedVersion) {
|
|
return version.satisfies(this)
|
|
}
|
|
|
|
tables(): VersionRangeTables {
|
|
switch (this.atom.type) {
|
|
case 'Anchor':
|
|
switch (this.atom.operator) {
|
|
case '=':
|
|
// `=1.2.3` is equivalent to `>=1.2.3 && <=1.2.4 && #flavor`
|
|
return VersionRangeTable.and(
|
|
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
|
VersionRangeTable.cmp(this.atom.version, 1, true, false),
|
|
)
|
|
case '>':
|
|
return VersionRangeTable.cmp(this.atom.version, 1, false, true)
|
|
case '<':
|
|
return VersionRangeTable.cmp(this.atom.version, -1, true, false)
|
|
case '>=':
|
|
return VersionRangeTable.cmp(this.atom.version, -1, false, true)
|
|
case '<=':
|
|
return VersionRangeTable.cmp(this.atom.version, 1, true, false)
|
|
case '!=':
|
|
// `!=1.2.3` is equivalent to `!(>=1.2.3 && <=1.2.3 && #flavor)`
|
|
// **not** equivalent to `(<1.2.3 || >1.2.3) && #flavor`
|
|
return VersionRangeTable.not(
|
|
VersionRangeTable.and(
|
|
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
|
VersionRangeTable.cmp(this.atom.version, 1, true, false),
|
|
),
|
|
)
|
|
case '^':
|
|
// `^1.2.3` is equivalent to `>=1.2.3 && <2.0.0 && #flavor`
|
|
return VersionRangeTable.and(
|
|
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
|
VersionRangeTable.cmp(
|
|
this.atom.version.incrementMajor(),
|
|
-1,
|
|
true,
|
|
false,
|
|
),
|
|
)
|
|
case '~':
|
|
// `~1.2.3` is equivalent to `>=1.2.3 && <1.3.0 && #flavor`
|
|
return VersionRangeTable.and(
|
|
VersionRangeTable.cmp(this.atom.version, -1, false, true),
|
|
VersionRangeTable.cmp(
|
|
this.atom.version.incrementMinor(),
|
|
-1,
|
|
true,
|
|
false,
|
|
),
|
|
)
|
|
}
|
|
case 'Flavor':
|
|
return VersionRangeTable.eqFlavor(this.atom.flavor)
|
|
case 'Not':
|
|
return VersionRangeTable.not(this.atom.value.tables())
|
|
case 'And':
|
|
return VersionRangeTable.and(
|
|
this.atom.left.tables(),
|
|
this.atom.right.tables(),
|
|
)
|
|
case 'Or':
|
|
return VersionRangeTable.or(
|
|
this.atom.left.tables(),
|
|
this.atom.right.tables(),
|
|
)
|
|
case 'Any':
|
|
return true
|
|
case 'None':
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** Returns `true` if any version exists that could satisfy this range. */
|
|
satisfiable(): boolean {
|
|
return VersionRangeTable.collapse(this.tables()) !== false
|
|
}
|
|
|
|
/** Returns `true` if this range and `other` share at least one satisfying version. */
|
|
intersects(other: VersionRange): boolean {
|
|
return VersionRange.and(this, other).satisfiable()
|
|
}
|
|
|
|
/**
|
|
* Returns a canonical (simplified) form of this range using minterm expansion.
|
|
* Useful for normalizing complex boolean expressions into a minimal representation.
|
|
*/
|
|
normalize(): VersionRange {
|
|
return VersionRangeTable.minterms(this.tables())
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Represents a semantic version number with numeric segments and optional prerelease identifiers.
|
|
*
|
|
* Follows semver precedence rules: numeric segments are compared left-to-right,
|
|
* and a version with prerelease identifiers has lower precedence than the same version without.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const v = Version.parse("1.2.3")
|
|
* console.log(v.toString()) // "1.2.3"
|
|
* console.log(v.compare(Version.parse("1.3.0"))) // "less"
|
|
*
|
|
* const pre = Version.parse("2.0.0-beta.1")
|
|
* console.log(pre.compare(Version.parse("2.0.0"))) // "less" (prerelease < release)
|
|
* ```
|
|
*/
|
|
export class Version {
|
|
constructor(
|
|
/** The numeric version segments (e.g. `[1, 2, 3]` for `"1.2.3"`). */
|
|
public number: number[],
|
|
/** Optional prerelease identifiers (e.g. `["beta", 1]` for `"-beta.1"`). */
|
|
public prerelease: (string | number)[],
|
|
) {}
|
|
|
|
/** Serializes this version to its string form (e.g. `"1.2.3"` or `"1.0.0-beta.1"`). */
|
|
toString(): string {
|
|
return `${this.number.join('.')}${this.prerelease.length > 0 ? `-${this.prerelease.join('.')}` : ''}`
|
|
}
|
|
|
|
/**
|
|
* Compares this version against another using semver precedence rules.
|
|
*
|
|
* @param other - The version to compare against
|
|
* @returns `'greater'`, `'equal'`, or `'less'`
|
|
*/
|
|
compare(other: Version): 'greater' | 'equal' | 'less' {
|
|
const numLen = Math.max(this.number.length, other.number.length)
|
|
for (let i = 0; i < numLen; i++) {
|
|
if ((this.number[i] || 0) > (other.number[i] || 0)) {
|
|
return 'greater'
|
|
} else if ((this.number[i] || 0) < (other.number[i] || 0)) {
|
|
return 'less'
|
|
}
|
|
}
|
|
|
|
if (this.prerelease.length === 0 && other.prerelease.length !== 0) {
|
|
return 'greater'
|
|
} else if (this.prerelease.length !== 0 && other.prerelease.length === 0) {
|
|
return 'less'
|
|
}
|
|
|
|
const prereleaseLen = Math.max(
|
|
this.prerelease.length,
|
|
other.prerelease.length,
|
|
)
|
|
for (let i = 0; i < prereleaseLen; i++) {
|
|
if (typeof this.prerelease[i] === typeof other.prerelease[i]) {
|
|
if (this.prerelease[i] > other.prerelease[i]) {
|
|
return 'greater'
|
|
} else if (this.prerelease[i] < other.prerelease[i]) {
|
|
return 'less'
|
|
}
|
|
} else {
|
|
switch (`${typeof this.prerelease[1]}:${typeof other.prerelease[i]}`) {
|
|
case 'number:string':
|
|
return 'less'
|
|
case 'string:number':
|
|
return 'greater'
|
|
case 'number:undefined':
|
|
case 'string:undefined':
|
|
return 'greater'
|
|
case 'undefined:number':
|
|
case 'undefined:string':
|
|
return 'less'
|
|
}
|
|
}
|
|
}
|
|
|
|
return 'equal'
|
|
}
|
|
|
|
/**
|
|
* Compares two versions, returning a numeric value suitable for use with `Array.sort()`.
|
|
*
|
|
* @returns `-1` if less, `0` if equal, `1` if greater
|
|
*/
|
|
compareForSort(other: Version): -1 | 0 | 1 {
|
|
switch (this.compare(other)) {
|
|
case 'greater':
|
|
return 1
|
|
case 'equal':
|
|
return 0
|
|
case 'less':
|
|
return -1
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses a version string into a `Version` instance.
|
|
*
|
|
* @param version - A semver-compatible string, e.g. `"1.2.3"` or `"1.0.0-beta.1"`
|
|
* @throws If the string is not a valid version
|
|
*/
|
|
static parse(version: string): Version {
|
|
const parsed = P.parse(version, { startRule: 'Version' })
|
|
return new Version(parsed.number, parsed.prerelease)
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if this version satisfies the given {@link VersionRange}.
|
|
* Internally treats this as an unflavored {@link ExtendedVersion} with downstream `0`.
|
|
*/
|
|
satisfies(versionRange: VersionRange): boolean {
|
|
return new ExtendedVersion(null, this, new Version([0], [])).satisfies(
|
|
versionRange,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Represents an extended version with an optional flavor, an upstream version, and a downstream version.
|
|
*
|
|
* The format is `#flavor:upstream:downstream` (e.g. `#bitcoin:1.2.3:0`) or `upstream:downstream`
|
|
* for unflavored versions. Flavors allow multiple variants of a package to coexist.
|
|
*
|
|
* - **flavor**: An optional string identifier for the variant (e.g. `"bitcoin"`, `"litecoin"`)
|
|
* - **upstream**: The version of the upstream software being packaged
|
|
* - **downstream**: The version of the StartOS packaging itself
|
|
*
|
|
* Versions with different flavors are incomparable (comparison returns `null`).
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const v = ExtendedVersion.parse("#bitcoin:1.2.3:0")
|
|
* console.log(v.flavor) // "bitcoin"
|
|
* console.log(v.upstream) // Version { number: [1, 2, 3] }
|
|
* console.log(v.downstream) // Version { number: [0] }
|
|
* console.log(v.toString()) // "#bitcoin:1.2.3:0"
|
|
*
|
|
* const range = VersionRange.parse(">=1.0.0:0")
|
|
* console.log(v.satisfies(range)) // true
|
|
* ```
|
|
*/
|
|
export class ExtendedVersion {
|
|
constructor(
|
|
/** The flavor identifier (e.g. `"bitcoin"`), or `null` for unflavored versions. */
|
|
public flavor: string | null,
|
|
/** The upstream software version. */
|
|
public upstream: Version,
|
|
/** The downstream packaging version. */
|
|
public downstream: Version,
|
|
) {}
|
|
|
|
/** Serializes this extended version to its string form (e.g. `"#bitcoin:1.2.3:0"` or `"1.0.0:1"`). */
|
|
toString(): string {
|
|
return `${this.flavor ? `#${this.flavor}:` : ''}${this.upstream.toString()}:${this.downstream.toString()}`
|
|
}
|
|
|
|
/**
|
|
* Compares this extended version against another.
|
|
*
|
|
* @returns `'greater'`, `'equal'`, `'less'`, or `null` if the flavors differ (incomparable)
|
|
*/
|
|
compare(other: ExtendedVersion): 'greater' | 'equal' | 'less' | null {
|
|
if (this.flavor !== other.flavor) {
|
|
return null
|
|
}
|
|
const upstreamCmp = this.upstream.compare(other.upstream)
|
|
if (upstreamCmp !== 'equal') {
|
|
return upstreamCmp
|
|
}
|
|
return this.downstream.compare(other.downstream)
|
|
}
|
|
|
|
/**
|
|
* Lexicographic comparison — compares flavors alphabetically first, then versions.
|
|
* Unlike {@link compare}, this never returns `null`: different flavors are ordered alphabetically.
|
|
*/
|
|
compareLexicographic(other: ExtendedVersion): 'greater' | 'equal' | 'less' {
|
|
if ((this.flavor || '') > (other.flavor || '')) {
|
|
return 'greater'
|
|
} else if ((this.flavor || '') > (other.flavor || '')) {
|
|
return 'less'
|
|
} else {
|
|
return this.compare(other)!
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a numeric comparison result suitable for use with `Array.sort()`.
|
|
* Uses lexicographic ordering (flavors sorted alphabetically, then by version).
|
|
*/
|
|
compareForSort(other: ExtendedVersion): 1 | 0 | -1 {
|
|
switch (this.compareLexicographic(other)) {
|
|
case 'greater':
|
|
return 1
|
|
case 'equal':
|
|
return 0
|
|
case 'less':
|
|
return -1
|
|
}
|
|
}
|
|
|
|
/** Returns `true` if this version is strictly greater than `other`. Returns `false` if flavors differ. */
|
|
greaterThan(other: ExtendedVersion): boolean {
|
|
return this.compare(other) === 'greater'
|
|
}
|
|
|
|
/** Returns `true` if this version is greater than or equal to `other`. Returns `false` if flavors differ. */
|
|
greaterThanOrEqual(other: ExtendedVersion): boolean {
|
|
return ['greater', 'equal'].includes(this.compare(other) as string)
|
|
}
|
|
|
|
/** Returns `true` if this version equals `other` (same flavor, upstream, and downstream). */
|
|
equals(other: ExtendedVersion): boolean {
|
|
return this.compare(other) === 'equal'
|
|
}
|
|
|
|
/** Returns `true` if this version is strictly less than `other`. Returns `false` if flavors differ. */
|
|
lessThan(other: ExtendedVersion): boolean {
|
|
return this.compare(other) === 'less'
|
|
}
|
|
|
|
/** Returns `true` if this version is less than or equal to `other`. Returns `false` if flavors differ. */
|
|
lessThanOrEqual(other: ExtendedVersion): boolean {
|
|
return ['less', 'equal'].includes(this.compare(other) as string)
|
|
}
|
|
|
|
/**
|
|
* Parses an extended version string into an `ExtendedVersion`.
|
|
*
|
|
* @param extendedVersion - A string like `"1.2.3:0"` or `"#bitcoin:1.0.0:0"`
|
|
* @throws If the string is not a valid extended version
|
|
*/
|
|
static parse(extendedVersion: string): ExtendedVersion {
|
|
const parsed = P.parse(extendedVersion, { startRule: 'ExtendedVersion' })
|
|
return new ExtendedVersion(
|
|
parsed.flavor || null,
|
|
new Version(parsed.upstream.number, parsed.upstream.prerelease),
|
|
new Version(parsed.downstream.number, parsed.downstream.prerelease),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Parses a legacy "emver" format extended version string.
|
|
*
|
|
* @param extendedVersion - A version string in the legacy emver format
|
|
* @throws If the string is not a valid emver version (error message includes the input string)
|
|
*/
|
|
static parseEmver(extendedVersion: string): ExtendedVersion {
|
|
try {
|
|
const parsed = P.parse(extendedVersion, { startRule: 'Emver' })
|
|
return new ExtendedVersion(
|
|
parsed.flavor || null,
|
|
new Version(parsed.upstream.number, parsed.upstream.prerelease),
|
|
new Version(parsed.downstream.number, parsed.downstream.prerelease),
|
|
)
|
|
} catch (e) {
|
|
if (e instanceof Error) {
|
|
e.message += ` (${extendedVersion})`
|
|
}
|
|
throw e
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns an ExtendedVersion with the Upstream major version version incremented by 1
|
|
* and sets subsequent digits to zero.
|
|
* If no non-zero upstream digit can be found the last upstream digit will be incremented.
|
|
*/
|
|
incrementMajor(): ExtendedVersion {
|
|
const majorIdx = this.upstream.number.findIndex((num: number) => num !== 0)
|
|
|
|
const majorNumber = this.upstream.number.map((num, idx): number => {
|
|
if (idx > majorIdx) {
|
|
return 0
|
|
} else if (idx === majorIdx) {
|
|
return num + 1
|
|
}
|
|
return num
|
|
})
|
|
|
|
const incrementedUpstream = new Version(majorNumber, [])
|
|
const updatedDownstream = new Version([0], [])
|
|
|
|
return new ExtendedVersion(
|
|
this.flavor,
|
|
incrementedUpstream,
|
|
updatedDownstream,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Returns an ExtendedVersion with the Upstream minor version version incremented by 1
|
|
* also sets subsequent digits to zero.
|
|
* If no non-zero upstream digit can be found the last digit will be incremented.
|
|
*/
|
|
incrementMinor(): ExtendedVersion {
|
|
const majorIdx = this.upstream.number.findIndex((num: number) => num !== 0)
|
|
let minorIdx = majorIdx === -1 ? majorIdx : majorIdx + 1
|
|
|
|
const majorNumber = this.upstream.number.map((num, idx): number => {
|
|
if (idx > minorIdx) {
|
|
return 0
|
|
} else if (idx === minorIdx) {
|
|
return num + 1
|
|
}
|
|
return num
|
|
})
|
|
|
|
const incrementedUpstream = new Version(majorNumber, [])
|
|
const updatedDownstream = new Version([0], [])
|
|
|
|
return new ExtendedVersion(
|
|
this.flavor,
|
|
incrementedUpstream,
|
|
updatedDownstream,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Returns a boolean indicating whether a given version satisfies the VersionRange
|
|
* !( >= 1:1 <= 2:2) || <=#bitcoin:1.2.0-alpha:0
|
|
*/
|
|
satisfies(versionRange: VersionRange): boolean {
|
|
switch (versionRange.atom.type) {
|
|
case 'Anchor':
|
|
const otherVersion = versionRange.atom.version
|
|
switch (versionRange.atom.operator) {
|
|
case '=':
|
|
return this.equals(otherVersion)
|
|
case '>':
|
|
return this.greaterThan(otherVersion)
|
|
case '<':
|
|
return this.lessThan(otherVersion)
|
|
case '>=':
|
|
return this.greaterThanOrEqual(otherVersion)
|
|
case '<=':
|
|
return this.lessThanOrEqual(otherVersion)
|
|
case '!=':
|
|
return !this.equals(otherVersion)
|
|
case '^':
|
|
const nextMajor = versionRange.atom.version.incrementMajor()
|
|
if (
|
|
this.greaterThanOrEqual(otherVersion) &&
|
|
this.lessThan(nextMajor)
|
|
) {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
case '~':
|
|
const nextMinor = versionRange.atom.version.incrementMinor()
|
|
if (
|
|
this.greaterThanOrEqual(otherVersion) &&
|
|
this.lessThan(nextMinor)
|
|
) {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
case 'Flavor':
|
|
return versionRange.atom.flavor == this.flavor
|
|
case 'And':
|
|
return (
|
|
this.satisfies(versionRange.atom.left) &&
|
|
this.satisfies(versionRange.atom.right)
|
|
)
|
|
case 'Or':
|
|
return (
|
|
this.satisfies(versionRange.atom.left) ||
|
|
this.satisfies(versionRange.atom.right)
|
|
)
|
|
case 'Not':
|
|
return !this.satisfies(versionRange.atom.value)
|
|
case 'Any':
|
|
return true
|
|
case 'None':
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compile-time type-checking helper that validates an extended version string literal.
|
|
* If the string is invalid, TypeScript will report a type error at the call site.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* testTypeExVer("1.2.3:0") // compiles
|
|
* testTypeExVer("#bitcoin:1.0:0") // compiles
|
|
* testTypeExVer("invalid") // type error
|
|
* ```
|
|
*/
|
|
export const testTypeExVer = <T extends string>(t: T & ValidateExVer<T>) => t
|
|
|
|
/**
|
|
* Compile-time type-checking helper that validates a version string literal.
|
|
* If the string is invalid, TypeScript will report a type error at the call site.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* testTypeVersion("1.2.3") // compiles
|
|
* testTypeVersion("-3") // type error
|
|
* ```
|
|
*/
|
|
export const testTypeVersion = <T extends string>(t: T & ValidateVersion<T>) =>
|
|
t
|
|
|
|
function tests() {
|
|
testTypeVersion('1.2.3')
|
|
testTypeVersion('1')
|
|
testTypeVersion('12.34.56')
|
|
testTypeVersion('1.2-3')
|
|
testTypeVersion('1-3')
|
|
testTypeVersion('1-alpha')
|
|
// @ts-expect-error
|
|
testTypeVersion('-3')
|
|
// @ts-expect-error
|
|
testTypeVersion('1.2.3:1')
|
|
// @ts-expect-error
|
|
testTypeVersion('#cat:1:1')
|
|
|
|
testTypeExVer('1.2.3:1.2.3')
|
|
testTypeExVer('1.2.3.4.5.6.7.8.9.0:1')
|
|
testTypeExVer('100:1')
|
|
testTypeExVer('#cat:1:1')
|
|
testTypeExVer('1.2.3.4.5.6.7.8.9.11.22.33:1')
|
|
testTypeExVer('1-0:1')
|
|
testTypeExVer('1-0:1')
|
|
// @ts-expect-error
|
|
testTypeExVer('1.2-3')
|
|
// @ts-expect-error
|
|
testTypeExVer('1-3')
|
|
// @ts-expect-error
|
|
testTypeExVer('1.2.3.4.5.6.7.8.9.0.10:1' as string)
|
|
// @ts-expect-error
|
|
testTypeExVer('1.-2:1')
|
|
// @ts-expect-error
|
|
testTypeExVer('1..2.3:3')
|
|
}
|