//// Tinman allows you to query information about the operating system, runtime //// and environment. //// //// It's useful for working around platform quirks or setting up global //// resource limits at startup. It's **not** suitable as a monitoring library. //// import gleam/bool import gleam/int import gleam/list import gleam/result import gleam/string // -- TYPES ------------------------------------------------------------------- /// The operating system platform. pub type OperatingSystem { Linux Macos Windows FreeBsd OpenBsd } /// The CPU architecture. pub type CpuArchitecture { X64 Arm64 Arm Riscv64 } /// The JavaScript or Erlang runtime. pub type Runtime { Beam AtomVm Node Deno Bun } /// The C standard library implementation (Linux only). pub type Libc { Glibc Musl } /// Byte order / endianness. pub type Endianness { BigEndian LittleEndian } /// Container runtime pub type ContainerRuntime { Docker Podman Wsl } /// Version information. pub type Version { Version(major: Int, minor: Int, patch: Int) } // -- SYSTEM IDENTIFICATION --------------------------------------------------- /// Returns the operating system platform. /// Returns `Error(Nil)` if the platform cannot be determined or is unknown. pub fn os() -> Result(OperatingSystem, Nil) { case ffi_os() { "linux" -> Ok(Linux) "darwin" -> Ok(Macos) "win32" -> Ok(Windows) "freebsd" -> Ok(FreeBsd) "openbsd" -> Ok(OpenBsd) _ -> Error(Nil) } } /// Returns whether the current platform is Windows. @external(erlang, "tinman_ffi", "is_windows") @external(javascript, "./tinman_ffi.mjs", "is_windows") fn is_windows() -> Bool @external(erlang, "tinman_ffi", "platform") @external(javascript, "./tinman_ffi.mjs", "platform") fn ffi_os() -> String /// Returns the C standard library type (Linux only). /// Returns `Error(Nil)` on non-Linux systems or if detection fails. @external(erlang, "tinman_ffi", "libc_type") pub fn libc() -> Result(Libc, Nil) { case os() { Ok(Linux) -> { let output = string.lowercase(shell("ldd --version 2>&1")) case string.contains(output, "musl") { True -> Ok(Musl) False -> Ok(Glibc) } } Ok(_) | Error(_) -> Error(Nil) } } /// Returns the operating system version. /// Returns `Error(Nil)` if the version cannot be determined or parsed. @external(javascript, "./tinman_ffi.mjs", "version") pub fn os_version() -> Result(Version, Nil) { case is_windows() { True -> { // On Windows, use PowerShell to get the actual OS version // Erlang's os:version() returns kernel version which differs from actual Windows version parse_version(powershell( "[System.Environment]::OSVersion.Version.ToString()", )) } False -> ffi_os_version() } } @external(erlang, "tinman_ffi", "version") fn ffi_os_version() -> Result(Version, Nil) /// Returns the hostname of the system. @external(erlang, "tinman_ffi", "hostname") @external(javascript, "./tinman_ffi.mjs", "hostname") pub fn hostname() -> String /// Returns the byte order of the system. @external(erlang, "tinman_ffi", "endianness") @external(javascript, "./tinman_ffi.mjs", "endianness") pub fn endianness() -> Endianness /// Returns the detected container runtime. /// Detects Docker, Podman, and WSL environments. /// Returns `Error(Nil)` if no container could be detected. pub fn container_runtime() -> Result(ContainerRuntime, Nil) { use <- bool.guard(when: exists("/.dockerenv"), return: Ok(Docker)) use <- bool.guard(when: exists("/run/.containerenv"), return: Ok(Podman)) let version = read("/proc/version") |> result.unwrap("") |> string.lowercase use <- bool.guard( when: string.contains(version, "microsoft") || string.contains(version, "wsl"), return: Ok(Wsl), ) Error(Nil) } // -- RUNTIME INFORMATION ----------------------------------------------------- /// Returns the current runtime (Erlang, Node, Deno, or Bun). /// Returns `Error(Nil)` if the runtime cannot be determined. pub fn runtime() -> Result(Runtime, Nil) { case ffi_runtime() { "BEAM" -> Ok(Beam) "ATOM" -> Ok(AtomVm) "node" -> Ok(Node) "deno" -> Ok(Deno) "bun" -> Ok(Bun) _other -> Error(Nil) } } @external(erlang, "tinman_ffi", "runtime") @external(javascript, "./tinman_ffi.mjs", "runtime") fn ffi_runtime() -> String /// Returns the runtime version. /// Returns `Error(Nil)` if the version cannot be determined or parsed. /// /// > **Note:** On Erlang, this is the version of the Erlang runtime system (ERTS), /// > not the version of the OTP release! pub fn runtime_version() -> Result(Version, Nil) { parse_version(ffi_runtime_version()) } @external(erlang, "tinman_ffi", "runtime_version") @external(javascript, "./tinman_ffi.mjs", "runtime_version") fn ffi_runtime_version() -> String /// Returns the runtimes operating system process ID. @external(erlang, "tinman_ffi", "process_id") @external(javascript, "./tinman_ffi.mjs", "process_id") pub fn process_id() -> Int // -- ENVIRONMENT ------------------------------------------------------------- /// Returns whether your program is running in a CI environment. /// /// Checks for `CI` and `CONTINUOUS_INTEGRATION` environment variables. pub fn is_ci() -> Bool { env("CI") != "" || env("CONTINUOUS_INTEGRATION") != "" } /// Returns whether your program is running in an SSH session. /// /// Checks for `SSH_CONNECTION`, `SSH_CLIENT`, and `SSH_TTY` environment variables. pub fn is_in_ssh() -> Bool { env("SSH_CONNECTION") != "" || env("SSH_CLIENT") != "" || env("SSH_TTY") != "" } /// Checks whether the program is running in an interactive terminal (TTY). pub fn is_tty() -> Bool { do_is_tty() && env("TERM") != "DUMB" } @external(erlang, "tinman_ffi", "is_tty") @external(javascript, "./tinman_ffi.mjs", "is_tty") fn do_is_tty() -> Bool /// Returns whether the program is running in test mode. /// /// Checks if the entrypoint module is `_test`, which is the case when running /// with `gleam test`. @external(erlang, "tinman_ffi", "is_test") @external(javascript, "./tinman_ffi.mjs", "is_test") pub fn is_test() -> Bool /// Returns whether the system appears to be online. /// /// Checks if there is an active non-loopback network interface available. @external(erlang, "tinman_ffi", "is_online") @external(javascript, "./tinman_ffi.mjs", "is_online") pub fn is_online() -> Bool // -- MEMORY INFORMATION ------------------------------------------------------ /// Returns the total available system memory in bytes. When running inside a /// container, memory limits applied to the container are respected. /// /// > *Note:* This information is not exposed by the Erlang runtime system, /// > and has to be queried from the operating system in various ways. /// > Doing so might be unexpectedly slow, especially on Windows! @external(javascript, "./tinman_ffi.mjs", "total_memory") pub fn total_memory() -> Result(Int, Nil) { case os() { Ok(Linux) -> { case cgroup_memory_limit() { Ok(limit) -> Ok(limit) Error(_) -> meminfo("MemTotal:") } } Ok(Macos) -> int(shell("sysctl -n hw.memsize")) Ok(FreeBsd) | Ok(OpenBsd) -> int(shell("sysctl -n hw.physmem64")) Ok(Windows) -> { int(powershell( "(Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize * 1024", )) } Error(_) -> Error(Nil) } } /// Returns the memory used by the runtime and your program. @external(erlang, "tinman_ffi", "used_memory") @external(javascript, "./tinman_ffi.mjs", "used_memory") pub fn used_memory() -> Int /// Returns the available system memory in bytes. When running inside a container, /// memory limits applied to the container are respected. /// /// > *Note:* This information is not exposed by the Erlang runtime system, /// > and has to be queried from the operating system in various ways. /// > Doing so might be unexpectedly slow, especially on Windows! @external(javascript, "./tinman_ffi.mjs", "free_memory") pub fn free_memory() -> Result(Int, Nil) { case os() { Ok(Linux) -> { case cgroup_memory_limit(), cgroup_memory_usage() { Ok(limit), Ok(usage) -> Ok(int.max(0, limit - usage)) Error(_), _ | _, Error(_) -> meminfo("MemAvailable:") } } Ok(Macos) -> { let cmd = "vm_stat | awk 'NR==1{page_size=$8} /Pages free/{free=$3} /Pages inactive/{inactive=$3} END{print (free+inactive)*page_size}'" int(shell(cmd)) } Ok(FreeBsd) | Ok(OpenBsd) -> int(shell("sysctl -n hw.usermem64")) Ok(Windows) -> { int(powershell( "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory * 1024", )) } Error(_) -> Error(Nil) } } fn cgroup_memory_limit() -> Result(Int, Nil) { case cgroup("memory.max") { Ok(val) -> Ok(val) Error(_) -> cgroup("memory/memory.limit_in_bytes") } } fn cgroup_memory_usage() -> Result(Int, Nil) { case cgroup("memory.current") { Ok(val) -> Ok(val) Error(_) -> cgroup("memory/memory.usage_in_bytes") } } fn cgroup(path: String) -> Result(Int, Nil) { case read("/sys/fs/cgroup/" <> path) { Ok(content) -> int(content) Error(_) -> Error(Nil) } } fn meminfo(key: String) -> Result(Int, Nil) { use content <- result.try(read("/proc/meminfo")) use rest <- result.try(list.last(string.split(content, key))) use kb_str <- result.try(extract_column(rest, 1)) use kb <- result.try(int(kb_str)) Ok(kb * 1024) } // -- DISK INFORMATION -------------------------------------------------------- /// Returns the total disk space in bytes for the root/system drive. /// Uses `df` on Unix-like systems and PowerShell on Windows. /// Returns `Error(Nil)` if the information cannot be determined. pub fn total_disk_space() -> Result(Int, Nil) { case is_windows() { True -> { // Use PowerShell Get-PSDrive to get total disk space (Used + Free) // Output: just the number in bytes int(powershell("(Get-PSDrive C).Used + (Get-PSDrive C).Free")) } False -> { let output = shell("df -k / | tail -n 1") parse_df_column(output, 2) } } } /// Returns the free disk space in bytes for the root/system drive. /// Uses `df` on Unix-like systems and PowerShell on Windows. /// Returns `Error(Nil)` if the information cannot be determined. pub fn free_disk_space() -> Result(Int, Nil) { case is_windows() { True -> { // Use PowerShell Get-PSDrive to get free disk space // Output: just the number in bytes int(powershell("(Get-PSDrive C).Free")) } False -> { // Use df -k to get free space in 1K blocks let output = shell("df -k / | tail -n 1") parse_df_column(output, 4) } } } fn parse_df_column(line: String, column: Int) -> Result(Int, Nil) { // Parse df output line and extract the specified column (1-indexed) // df output: "Filesystem 1K-blocks Used Available Use% Mounted on" // "/dev/sda1 123456789 1234567 12345678 10% /" // Parse column as KB and convert to bytes use kb_str <- result.try(extract_column(line, column)) use kb <- result.try(int(kb_str)) Ok(kb * 1024) } // -- CPU INFORMATION --------------------------------------------------------- /// Returns the CPU architecture. /// Returns `Error(Nil)` if the architecture cannot be determined or is unknown. pub fn cpu_architecture() -> Result(CpuArchitecture, Nil) { let arch = case is_windows() { True -> { // On Windows, use PROCESSOR_ARCHITECTURE environment variable // Erlang's system_info returns "win32" instead of the actual architecture string.lowercase(env("PROCESSOR_ARCHITECTURE")) } False -> ffi_cpu_architecture() } case arch { "x64" | "x86_64" | "amd64" -> Ok(X64) "arm64" | "aarch64" -> Ok(Arm64) "arm" -> Ok(Arm) "riscv64" -> Ok(Riscv64) _ -> Error(Nil) } } @external(erlang, "tinman_ffi", "arch") @external(javascript, "./tinman_ffi.mjs", "arch") fn ffi_cpu_architecture() -> String /// Returns the number of logical CPU cores. /// Returns `Error(Nil)` if the CPU count cannot be determined. /// /// > *Note:* Usually you want to use `available_parallelism` instead! @external(erlang, "tinman_ffi", "cpu_count") @external(javascript, "./tinman_ffi.mjs", "cpu_count") pub fn cpu_count() -> Result(Int, Nil) /// Returns the recommended level of parallelism. /// /// This is the number of available schedulers on Erlang, or the number of /// runtime threads on Javascript. Note that Javascript itself is always /// single-threaded by default. @external(erlang, "tinman_ffi", "available_parallelism") @external(javascript, "./tinman_ffi.mjs", "available_parallelism") pub fn available_parallelism() -> Int // -- USER INFORMATION -------------------------------------------------------- /// Returns the current username. /// Returns `Error(Nil)` if the username cannot be determined. @external(javascript, "./tinman_ffi.mjs", "user_name") pub fn user_name() -> String { let whoami = string.trim(shell("whoami")) case is_windows() { True -> case string.split(whoami, "\\") { [_domain, name, ..] -> name [name] -> name [] -> whoami } False -> whoami } } /// Returns the current user ID. /// Returns `Error(Nil)` if the user ID cannot be determined. @external(javascript, "./tinman_ffi.mjs", "user_id") pub fn user_id() -> Result(Int, Nil) { case is_windows() { True -> Error(Nil) False -> int(shell("id -u")) } } /// Returns the current group ID. /// Returns `Error(Nil)` if the group ID cannot be determined. @external(javascript, "./tinman_ffi.mjs", "group_id") pub fn group_id() -> Result(Int, Nil) { case is_windows() { True -> Error(Nil) False -> int(shell("id -g")) } } /// Returns whether the current user has administrator/root privileges. /// On Unix-like systems, checks if the user ID is `0` (root). /// On Windows, checks if we can successfully run a utility with elevated privileges. pub fn is_admin() -> Bool { case is_windows() { True -> { // Try to run 'fltmc' (Filter Manager Control) which requires admin privileges let output = shell("fltmc >nul 2>&1 && echo OK") string.contains(output, "OK") } False -> case user_id() { Ok(0) -> True _ -> False } } } // -- PLATFORM CONSTANTS ------------------------------------------------------ /// Returns the end-of-line marker for the current platform. /// `"\n"` on Unix-like systems, `"\r\n"` on Windows. pub fn end_of_line() -> String { case is_windows() { True -> "\r\n" False -> "\n" } } /// Returns the path delimiter - the character used inside the `PATH` environment /// variable to separate multiple paths - for the current platform. /// `":"` on Unix-like systems, `";"` on Windows. pub fn path_delimiter() -> String { case is_windows() { True -> ";" False -> ":" } } /// Returns the path separator - the character used inside paths to separate /// files and directories - for the current platform. /// `"/"` on Unix-like systems, `"\\"` on Windows. pub fn path_separator() -> String { case is_windows() { True -> "\\" False -> "/" } } /// Returns the executable file extension for the current platform. /// `".exe"` on Windows, `""` (empty string) on Unix-like systems. pub fn executable_extension() -> String { case is_windows() { True -> ".exe" False -> "" } } // -- HELPER FUNCTIONS -------------------------------------------------------- fn int(s: String) -> Result(Int, Nil) { int.parse(string.trim(s)) } fn powershell(cmd: String) -> String { // Try pwsh first (PowerShell Core), fall back to powershell (Windows PowerShell) case shell("pwsh -NoProfile -NonInteractive -Command \"" <> cmd <> "\" 2>nul") { "" -> shell("powershell -NoProfile -NonInteractive -Command \"" <> cmd <> "\"") output -> output } } fn split_whitespace(text: String) -> List(String) { text |> string.split(" ") |> list.filter(fn(s) { s != "" }) } fn extract_column(line: String, column: Int) -> Result(String, Nil) { // Extract the nth column (1-indexed) from a whitespace-separated line line |> split_whitespace |> list.drop(column - 1) |> list.first } @internal pub fn parse_version(version_string: String) -> Result(Version, Nil) { let version_string = case version_string { "v" <> version_string -> version_string other -> other } case string.split(version_string, on: ".") { [major, minor, patch, ..] -> { use major <- result.try(int.parse(major)) use minor <- result.try(int.parse(minor)) use patch <- result.try(int.parse(patch)) Ok(Version(major:, minor:, patch:)) } [major, minor] -> { use major <- result.try(int.parse(major)) use minor <- result.try(int.parse(minor)) Ok(Version(major:, minor:, patch: 0)) } [major] -> { use major <- result.try(int.parse(major)) Ok(Version(major:, minor: 0, patch: 0)) } _ -> Error(Nil) } } // -- FFI FUNCTIONS ----------------------------------------------------------- @external(erlang, "tinman_ffi", "os_cmd") @external(javascript, "./tinman_ffi.mjs", "os_cmd") fn shell(command: String) -> String @external(erlang, "tinman_ffi", "read_file") @external(javascript, "./tinman_ffi.mjs", "read_file") fn read(path: String) -> Result(String, Nil) @external(erlang, "tinman_ffi", "file_exists") @external(javascript, "./tinman_ffi.mjs", "file_exists") fn exists(path: String) -> Bool @external(erlang, "tinman_ffi", "env") @external(javascript, "./tinman_ffi.mjs", "env") fn env(name: String) -> String