module VersionSet = Set.Make(String); let lwtIgnore = lwt => Lwt.catch(() => lwt, _ => Lwt.return()); let flip = (fn, a, b) => fn(b, a); let skip = (~amount, str) => Str.last_chars(str, String.length(str) - amount); let parseSemver = version => version |> skip(~amount=1) |> Semver.fromString; let compare = (v1, v2) => switch (parseSemver(v1), parseSemver(v2)) { | (Some(v1), Some(v2)) => Semver.compare(v1, v2) | (None, _) | (_, None) => - Base.String.compare(v1, v2) }; let isVersionFitsPrefix = (prefix, version) => { let length = String.length(prefix); String.length(version) >= length + 1 && Str.first_chars(version, length + 1) == prefix ++ "."; }; module Local = { type t = { name: string, fullPath: string, aliases: list(string), }; let systemVersion: t = { name: "system", fullPath: "/dev/null/installation", aliases: [], }; let toDirectory = name => Filename.concat( Filename.concat(Directories.nodeVersions, name), "installation", ); let remove = version => Fs.rmdir(version.fullPath); let getLatestInstalledNameByPrefix = prefix => Lwt.Infix.( { let%lwt versions = Lwt.catch( () => Fs.readdir(Directories.nodeVersions) >|= List.filter(isVersionFitsPrefix(prefix)) >|= List.sort(flip(compare)), _ => Lwt.return_nil, ); switch (versions) { | [version, ..._xs] => Lwt.return_some(version) | [] => Lwt.return_none }; } ); }; exception Version_not_found(string); module Aliases = { module VersionAliasMap = Map.Make(String); type t = { name: string, versionName: string, fullPath: string, }; let toDirectory = name => Filename.concat(Directories.aliases, name); let getAll = () => { let%lwt aliases = try%lwt(Fs.readdir(Directories.aliases)) { | _ => Lwt.return([]) }; aliases |> Lwt_list.map_p(alias => { let fullPath = Filename.concat(Directories.aliases, alias); let%lwt realpath = Filename.concat(Directories.aliases, alias) |> Fs.realpath |> Lwt.map( fun | Fs.Exists(x) => x | Fs.Missing(x) => x, ); Lwt.return({ name: alias, fullPath, versionName: realpath |> Filename.dirname |> Filename.basename, }); }); }; let byVersion = () => { let%lwt aliases = getAll(); aliases |> List.fold_left( (map, curr) => { let value = switch (VersionAliasMap.find_opt(curr.versionName, map)) { | None => [curr.name] | Some(arr) => [curr.name, ...arr] }; VersionAliasMap.add(curr.versionName, value, map); }, VersionAliasMap.empty, ) |> Lwt.return; }; let set = (~alias, ~versionPath) => { let aliasPath = alias |> toDirectory; let%lwt _ = System.mkdirp(Directories.aliases); let%lwt _ = Lwt_unix.unlink(aliasPath) |> lwtIgnore; let%lwt _ = Lwt_unix.symlink(versionPath, aliasPath); Lwt.return(); }; }; module Remote = { type t = { name: string, baseURL: string, installed: bool, }; let getInstalledVersionSet = () => Lwt.( catch(() => Fs.readdir(Directories.nodeVersions), _ => return([])) >|= List.fold_left( (acc, curr) => VersionSet.add(curr, acc), VersionSet.empty, ) ); let getRelativeLinksFromHTML = html => Soup.parse(html) |> Soup.select("pre a") |> Soup.to_list |> List.map(Soup.attribute("href")) |> Base.List.filter_map(~f=x => x) |> List.map(x => { let parts = String.split_on_char('/', x) |> List.rev; switch (parts) { | ["", x, ..._xs] => x ++ "/" | [x, ..._xs] => x | [] => "" }; }); let downloadFileSuffix = ".tar.xz"; let getVersionFromFilename = filename => { let strings = filename |> String.split_on_char('-'); List.nth(strings, 1); }; }; let ltsVersion = version => if (Base.String.is_prefix(version, ~prefix="lts/")) { switch (Str.last_chars(version, String.length(version) - 4)) { | exception _ => None | x => Some(x) }; } else { None; }; let format = version => { let version = switch (Str.first_chars(version, 1) |> Int32.of_string) { | _ => "v" ++ version | exception _ => version }; let version = switch (ltsVersion(version)) { | Some(lts) => "latest-" ++ lts | None => version }; version; }; let endsWith = (~suffix, str) => { let suffixLength = String.length(suffix); String.length(str) > suffixLength && Str.last_chars(str, suffixLength) == suffix; }; exception No_Download_For_System(System.NodeOS.t, System.NodeArch.t); let getCurrentVersion = () => switch%lwt (Fs.realpath(Directories.currentVersion)) { | Missing(x) when x == Directories.currentVersion => Lwt.return_none | Missing(_) => Lwt.return_some(Local.systemVersion) | Exists(installationPath) => let fullPath = Filename.dirname(installationPath); Lwt.return_some( Local.{ fullPath, name: fullPath |> Path.absolute |> Base.Option.bind(~f=Path.baseName) |> Base.Option.value(~default=""), aliases: [], }, ); }; let getInstalledVersions = () => { let%lwt versions = Fs.readdir(Directories.nodeVersions) |> Lwt.map(List.sort(compare)) and aliases = Aliases.byVersion(); versions |> List.map(name => Local.{ name, fullPath: Filename.concat(Directories.nodeVersions, name), aliases: Opt.(Aliases.VersionAliasMap.find_opt(name, aliases) or []), } ) |> List.append([Local.systemVersion]) |> Lwt.return; }; let getRemoteVersions = () => { let%lwt bodyString = Config.FNM_NODE_DIST_MIRROR.get() |> Http.makeRequest |> Lwt.map(Http.body); let versions = bodyString |> Remote.getRelativeLinksFromHTML; let%lwt installedVersions = Remote.getInstalledVersionSet(); versions |> List.filter(x => Str.last_chars(x, 1) == "/" && Str.first_chars(x, 1) != "." ) |> List.map(x => Str.first_chars(x, String.length(x) - 1)) |> List.sort(compare) |> List.map(name => Remote.{ name, installed: VersionSet.find_opt(name, installedVersions) != None, baseURL: Printf.sprintf("%s%s/", Config.FNM_NODE_DIST_MIRROR.get(), name), } ) |> Lwt.return; }; let getRemoteLatestVersionByPrefix = prefix => Remote.( { let%lwt remoteVersions = getRemoteVersions(); let compatibleVersions = remoteVersions |> List.map(x => x.name) |> List.filter(isVersionFitsPrefix(prefix)) |> List.sort(flip(compare)); switch (compatibleVersions) { | [version, ..._vs] => Lwt.return_some(version) | [] => Lwt.return_none }; } ); let getExactFileToDownload = (~version as versionName, ~os, ~arch) => { let versionName = switch (Str.first_chars(versionName, 1) |> Int32.of_string) { | _ => "v" ++ versionName | exception _ => versionName }; let url = Printf.sprintf("%s%s/", Config.FNM_NODE_DIST_MIRROR.get(), versionName); let%lwt html = try%lwt(Http.makeRequest(url) |> Lwt.map(Http.body)) { | Http.Not_found(_) => Lwt.fail(Version_not_found(versionName)) }; let filenames = html |> Remote.getRelativeLinksFromHTML |> List.filter( endsWith( ~suffix= System.NodeOS.toString(os) ++ "-" ++ System.NodeArch.toString(arch) ++ Remote.downloadFileSuffix, ), ); switch (filenames |> List.hd) { | filename => let nodeVersion = List.nth(String.split_on_char('-', filename), 1); Lwt.return((nodeVersion, url ++ filename)); | exception _ => Lwt.fail(No_Download_For_System(os, arch)) }; }; let getFileToDownload = (~version, ~os, ~arch) => try%lwt(getExactFileToDownload(~version, ~os, ~arch)) { | Version_not_found(_) as e => switch%lwt (getRemoteLatestVersionByPrefix(version)) { | None => Lwt.fail(e) | Some(exactVersion) => getExactFileToDownload(~version=exactVersion, ~os, ~arch) } }; type t = | System | Alias(string) | Local(string); let parse = version => { let formattedVersion = format(version); if (formattedVersion == Local.systemVersion.name) { Lwt.return_ok(System); } else { let%lwt aliasExists = Aliases.toDirectory(version) |> Fs.exists and aliasExistsOnFormatted = Aliases.toDirectory(formattedVersion) |> Fs.exists and versionExists = Local.toDirectory(formattedVersion) |> Fs.exists and versionByPrefixPath = Local.getLatestInstalledNameByPrefix(formattedVersion); switch ( versionExists, aliasExists, aliasExistsOnFormatted, versionByPrefixPath, ) { | (true, _, _, _) => Local(formattedVersion) |> Lwt.return_ok | (_, true, _, _) => Alias(version) |> Lwt.return_ok | (_, _, true, _) => Alias(formattedVersion) |> Lwt.return_ok | (_, false, false, Some(version)) => Local(version) |> Lwt.return_ok | (false, false, false, None) => Lwt.return_error(formattedVersion) }; }; }; let isInstalled = versionName => { let%lwt installedVersions = try%lwt(getInstalledVersions()) { | _ => Lwt.return([]) }; installedVersions |> List.exists(x => Local.(x.name == versionName)) |> Lwt.return; };