Files
profile/profile.sh
T

566 lines
21 KiB
Bash

#!/usr/bin/env bash
# Begin profile
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * Redistribution and use in source and binary forms,
# * with or without modification, are permitted provided
# * that the following conditions are met:
# *
# * Redistributions of source code must retain the above
# * copyright notice, this list of conditions and the
# * following disclaimer.
# *
# * Redistributions in binary form must reproduce the above
# * copyright notice, this list of conditions and the following
# * disclaimer in the documentation and/or other materials
# * provided with the distribution.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
# This first test is POSIX on purpose: it runs before anything bash-specific, so
# that sourcing the file from dash, zsh, ... ends with a message instead of
# cascading errors. It checks the running shell: $SHELL is the user's login
# shell, which says nothing about it (and is unset under cron or systemd).
if [ -z "${BASH_VERSION:-}" ]; then
echo "That environment script is designed to be used with bash." >&2
echo "Please consider using bash to enjoy our features!" >&2
# shellcheck disable=SC2317 # exit is the fallback when not sourced
return 1 2>/dev/null || exit 1
fi
_profile_is_sourced()
{
[[ "${BASH_SOURCE[0]}" != "$0" ]]
}
# Installation helper: write $2 (the source line) in the file $1, unless that
# file already sources $3 (the profile) in some form.
_profile_install_in_file()
{
local rc_file="$1"
local source_line="$2"
local target="$3"
[[ -f "$rc_file" ]] || touch "$rc_file" || {
printf "[ Error ] Cannot create %s\n" "$rc_file" >&2
return 1
}
# The exact line, or any other sourcing statement naming the same file
# (". ~/p/profile.sh", with other quotes, ...): adding another one would
# load the profile twice.
if grep -Fqx "$source_line" "$rc_file" || _profile_file_sources "$rc_file" "$target"; then
printf "[ Info ] Already configured in %s\n" "$rc_file"
return 0
fi
printf "\n%s\n" "$source_line" >> "$rc_file" || {
printf "[ Error ] Cannot write to %s\n" "$rc_file" >&2
return 1
}
printf "[ Info ] Added profile source line to %s\n" "$rc_file"
return 0
}
# Detect whether $rc_file sources the file whose basename is $target_name.
# Returns 0 (true) if a sourcing statement referencing $target_name is found,
# ignoring commented-out lines.
_profile_file_sources()
{
local rc_file="$1"
local target_name="$2"
local line
[[ -f "$rc_file" ]] || return 1
while IFS= read -r line || [[ -n "$line" ]]; do
# Strip comments so commented-out source lines are not matched.
line="${line%%#*}"
# Keep only lines whose first word is a source command (`source` or `.`).
[[ "$line" =~ ^[[:space:]]*(source|\.)[[:space:]] ]] || continue
# Match if the sourcing statement references the target file.
[[ "$line" == *"$target_name"* ]] && return 0
done < "$rc_file"
return 1
}
_profile_install()
{
local install_bashrc=0
local install_profile=0
local target_selected=0
local script_dir source_line rc=0
while [[ $# -gt 0 ]]; do
case "$1" in
--bashrc)
install_bashrc=1
target_selected=1
;;
--profile)
install_profile=1
target_selected=1
;;
-h|--help)
printf "Usage: %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}"
printf "If no target is specified, both ~/.bashrc and ~/.profile are configured.\n"
return 0
;;
*)
printf "[ Error ] Unknown install option: %s\n" "$1" >&2
return 2
;;
esac
shift
done
if (( target_selected == 0 )); then
install_bashrc=1
install_profile=1
fi
# Avoid double loading when both targets are selected and one already
# sources the other: only configure the called file so profile.sh is
# loaded exactly once.
if (( install_bashrc == 1 && install_profile == 1 )); then
if _profile_file_sources "$HOME/.bashrc" ".profile"; then
printf "[ Info ] ~/.bashrc sources ~/.profile; configuring only ~/.profile to avoid double loading.\n"
install_bashrc=0
elif _profile_file_sources "$HOME/.profile" ".bashrc"; then
printf "[ Info ] ~/.profile sources ~/.bashrc; configuring only ~/.bashrc to avoid double loading.\n"
install_profile=0
fi
fi
script_dir=$(dirname "$(realpath -s "${BASH_SOURCE[0]}")")
# The path goes between double quotes in the rc file: escape what stays
# special there (a plain path is left unchanged, so existing lines keep
# matching)
local escaped_dir="$script_dir"
escaped_dir="${escaped_dir//\\/\\\\}"
escaped_dir="${escaped_dir//\"/\\\"}"
escaped_dir="${escaped_dir//\$/\\\$}"
escaped_dir="${escaped_dir//\`/\\\`}"
source_line="source \"$escaped_dir/profile.sh\""
if (( install_bashrc == 1 )); then
_profile_install_in_file "$HOME/.bashrc" "$source_line" "$script_dir/profile.sh" || rc=$?
fi
if (( install_profile == 1 )); then
_profile_install_in_file "$HOME/.profile" "$source_line" "$script_dir/profile.sh" || rc=$?
fi
return "$rc"
}
# Options are only handled when the file is executed: a sourced file receives
# the positional parameters of whoever sources it ("./script --install" sourcing
# the profile must not install anything, nor fail on an option that is not for
# us).
if ! _profile_is_sourced; then
case "${1:-}" in
--install)
shift
_profile_install "$@"
exit $?
;;
-h|--help)
printf "Usage: source %s\n" "${BASH_SOURCE[0]}"
printf " %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}"
exit 0
;;
"")
;;
*)
printf "[ Error ] Unknown option: %s\n" "$1" >&2
exit 2
;;
esac
printf "[ Warning ] profile.sh is designed to be sourced, not executed directly.\n" >&2
printf "Use: source \"%s\"\n" "$(realpath -s "${BASH_SOURCE[0]}")" >&2
printf "Or run: %s --install [--bashrc] [--profile]\n" "${BASH_SOURCE[0]}" >&2
exit 1
fi
# Required for associative arrays (4.0+) and namerefs (4.3+)
if ((BASH_VERSINFO[0] < 4)) || [[ ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 3 ]]; then
echo "[ Error ] This profile requires Bash 4.3 or higher." >&2
echo "Current version: $BASH_VERSION" >&2
return 1
fi
# ------------------------------------------------------------------------------
# path* : private functions for PATH variable management
pathremove()
{
[[ -z "$1" ]] && return 0
local IFS=':'
local newpath dir
local pathvar="${2:-PATH}"
[[ "$pathvar" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || {
printf "pathremove: unsafe variable name '%s'\n" "$pathvar" >&2
return 1
}
for dir in ${!pathvar}; do
[[ "$dir" != "$1" ]] && newpath="${newpath:+$newpath:}$dir"
done
export "$pathvar=$newpath"
}
#pathprepend() # Unused for now, but might be useful in the future
#{
# [[ -z "$1" ]] && return 0
# local pathvar="${2:-PATH}"
# [[ "$pathvar" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || {
# printf "pathprepend: unsafe variable name '%s'\n" "$pathvar" >&2
# return 1
# }
# pathremove "$1" "$pathvar"
# export "$pathvar=$1${!pathvar:+:${!pathvar}}"
#}
# Append one directory to a path variable (PATH by default), unless it is
# already in it: an entry that exists keeps its place. Moving it to the end
# would, for instance, demote ~/.local/bin that Debian's ~/.profile puts first,
# so that system programs shadow the user's own ones.
pathappend()
{
[[ -z "$1" ]] && return 0
local pathvar="${2:-PATH}"
[[ "$pathvar" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || {
printf "pathappend: unsafe variable name '%s'\n" "$pathvar" >&2
return 1
}
case ":${!pathvar}:" in
*":$1:"*) return 0 ;;
esac
export "$pathvar=${!pathvar:+${!pathvar}:}$1"
}
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Configuration file parser
parse_conf()
{
local config_file="$1"
local current_section=""
local key value
[[ ! -f "$config_file" ]] && return 1
while IFS='=' read -r key value || [[ -n "$key" ]]; do
# Internal trimming (removes leading/trailing whitespace & CR)
key="${key%"${key##*[![:space:]]}"}"
key="${key#"${key%%[![:space:]]*}"}"
key="${key%$'\r'}" # Strip potential Windows line endings
# Skip comments and empty lines
[[ -z "$key" || "$key" =~ ^[#\;] ]] && continue
# Section Detection: [section_name], optionally followed by a comment
if [[ "$key" =~ ^\[([a-zA-Z0-9_]+)\][[:space:]]*([#\;].*)?$ ]]; then
current_section="${BASH_REMATCH[1]}"
declare -g -A "CONF_$current_section"
continue
fi
# A key is a variable name: it ends up in "export" (load_conf) and as an
# array subscript. Anything else (a stray line, a typo) is reported.
if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
printf "[ Warning ] %s: invalid configuration line ignored: '%s'\n" \
"$config_file" "$key" >&2
continue
fi
# Secure Assignment (if inside a section)
if [[ -n "$current_section" ]]; then
# Clean the value
value="${value%"${value##*[![:space:]]}"}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%$'\r'}"
# A quoted value is what is between the quotes (a trailing comment
# is dropped); an unquoted one ends at an inline comment, which is
# a "#" preceded by a blank ("dark # my theme").
if [[ "$value" == \"* ]]; then
value="${value#\"}"
value="${value%%\"*}"
elif [[ "$value" == \'* ]]; then
value="${value#\'}"
value="${value%%\'*}"
else
value="${value%%[[:space:]]#*}"
value="${value%"${value##*[![:space:]]}"}"
fi
# Protect against command injection by disallowing certain characters in keys
value="${value//\`/}"
value="${value//\$\(/}"
# Correctly interpretet internal variables (e.g. $HOME). In the
# [prompt] section only the variables that exist are substituted:
# envsubst would otherwise replace an unknown one by nothing,
# wiping out the $Blue-style colour references that are resolved
# later, when the colour variables exist (see prompt.sh).
if [[ "$value" == *\$* ]]; then
if command -v envsubst >/dev/null 2>&1; then
if [[ "$current_section" == "prompt" ]]; then
local _envnames="" _envname
for _envname in $(compgen -e); do
[[ "$_envname" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] && _envnames+="\${${_envname}}"
done
value=$(envsubst "$_envnames" <<< "$value")
else
value=$(envsubst <<< "$value")
fi
fi
# If envsubst is unavailable, $VAR references are left as-is.
fi
# Use a nameref for safe, eval-free assignment
local -n current_array="CONF_$current_section"
# shellcheck disable=SC2034 # Dynamic var creation
current_array["$key"]="$value"
fi
done < "$config_file"
}
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Load command aliases from configuration
load_alias()
{
local section_name="CONF_$1"
# Check if the associative array exists using declare -p
[[ "$(declare -p "$section_name" 2>/dev/null)" != "declare -A"* ]] && return 1
# Create a nameref to the section array
local -n current_aliases="$section_name"
local key
# Iterate safely over the keys of the associative array
for key in "${!current_aliases[@]}"; do
local cmd="${current_aliases[$key]}"
# Extract the base command (first word) safely without awk
local base_cmd="${cmd%% *}"
# Only alias if the base command is executable
if command -v "$base_cmd" >/dev/null 2>&1; then
# shellcheck disable=SC2139 # Dynamic alias creation
alias "$key"="$cmd"
fi
done
}
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Load configuration values as environment variables
load_conf()
{
local section_name="CONF_$1"
# Missing section is not an error: modules can rely on built-in defaults.
[[ "$(declare -p "$section_name" 2>/dev/null)" != "declare -A"* ]] && return 0
local -n current_vars="$section_name"
local key
for key in "${!current_vars[@]}"; do
# Export the key/value pair as a standard shell variable
# We use 'export' directly; Bash handles the assignment safely here
export "$key"="${current_vars[$key]}"
done
}
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# ********************************** MAIN PROGRAM ******************************
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Store the script's path. Because we're more likely to be sourced, we use
# BASH_SOURCE to get the path of the sourced file instead of $0. If profile.sh
# itself is a symbolic link, it is followed to find the real installation
# (profile.d, version, ...); a symlinked directory is kept as the user sees it
# (realpath -s).
if [[ -z "${PROFILE_PATH:-}" ]]; then
_profile_src="${BASH_SOURCE[0]}"
[[ -L "$_profile_src" ]] && _profile_src=$(readlink -f -- "$_profile_src")
MYPATH=$(dirname "$(realpath -s -- "$_profile_src")")
unset _profile_src
else
MYPATH="$PROFILE_PATH"
fi
export MYPATH
if [[ ! -e "$MYPATH/profile.sh" ]]; then
echo "[ Warning ] Path detection failed, trying to use pwd..." >&2
MYPATH=$(pwd)
if [[ ! -e "$MYPATH/profile.sh" ]]; then
echo "[ Error ] Unable to determine installation path, pretty much nothing will work." >&2
fi
fi
PROFVERSION=""
if [[ -s "$MYPATH/version" ]]; then
IFS= read -r PROFVERSION < "$MYPATH/version"
PROFVERSION="${PROFVERSION%$'\r'}"
else
echo "[ Warning ] Unable to determine running profile version; your installation might be broken." >&2
fi
export PROFVERSION
# Build PATH environment variable
if [[ $EUID -eq 0 ]]; then
pathappend /sbin
pathappend /usr/sbin
fi
[[ -d ~/bin ]] && pathappend ~/bin
[[ -d ~/.local/bin ]] && pathappend ~/.local/bin
# Parse and load general configuration
export PROFILE_CONF="$MYPATH/profile.conf"
parse_conf "$PROFILE_CONF"
# Overload with user configuration if it exists
if [[ -f "$HOME/.profile.conf" ]]; then
parse_conf "$HOME/.profile.conf"
fi
load_conf system # Load Bash system behavior configuration (history, pager, etc.)
load_conf general # General purpose configuration (compilation flags, etc.)
# Helpers shared by several modules (each module also loads the common helpers
# itself when it is sourced alone, see _profile_load_helpers).
if [[ -r "$MYPATH/helpers/common.sh" ]]; then
# shellcheck source=helpers/common.sh
. "$MYPATH/helpers/common.sh"
else
printf "[ Error ] Common helpers not found: %s\n" "$MYPATH/helpers/common.sh" >&2
# shellcheck disable=SC2317 # exit is the fallback when not sourced
return 1 2>/dev/null || exit 1
fi
# Load module scripts. nullglob is only needed for the expansion: the caller's
# setting is put back afterwards instead of being forced off.
_profile_ng=0
shopt -q nullglob && _profile_ng=1
shopt -s nullglob
for _profile_script in "$MYPATH/profile.d/"*.sh; do
if [[ -f "$_profile_script" && -r "$_profile_script" ]]; then
# shellcheck source=/dev/null
. "$_profile_script" || printf "[ Warning ] Failed to source module: %s\n" "$_profile_script" >&2
fi
done
unset _profile_script
(( _profile_ng )) || shopt -u nullglob
# Interactive shell detection. INTERACTIVE is exported for the modules, so it
# is reset when the shell is not interactive: a non-interactive child of an
# interactive shell would otherwise inherit INTERACTIVE=1 and run the whole
# interactive part (banner, prompt, update check) inside scripts.
if [[ $- == *i* ]]; then
export INTERACTIVE=1
else
unset INTERACTIVE
fi
if [[ -n "${INTERACTIVE:-}" ]]; then
# Load custom bash completions
shopt -s nullglob
for _compl in "$MYPATH/profile.d/bash-completion/"*.sh; do
# shellcheck disable=SC1090 # Dynamic sourcing of completion scripts
[[ -f "$_compl" && -r "$_compl" ]] && . "$_compl"
done
unset _compl
(( _profile_ng )) || shopt -u nullglob
# Aliases
load_alias aliases
# Define PS1. The timer hook is a DEBUG trap: it replaces any DEBUG trap set
# before (it cannot even be read from a sourced file, bash hides it there),
# so load this profile before tools such as bash-preexec.
trap 'timer_start' DEBUG
# set_prompt is added to PROMPT_COMMAND instead of replacing it, so that
# what exists (VTE's directory reporting, direnv, "history -a", ...) keeps
# working. It has to read the exit status of the user's last command, which
# any command running before it resets: _profile_save_status stores it
# first, set_prompt (which uses it) runs last. It must also run last for the
# timer: the DEBUG trap fires for every command of PROMPT_COMMAND, and the
# elapsed time is measured from the first one of the user's command.
# Nothing changes when PROMPT_COMMAND is empty or already has set_prompt.
case "$(declare -p PROMPT_COMMAND 2>/dev/null)" in
"declare -a"*)
# Array form (bash 5.1+)
if [[ " ${PROMPT_COMMAND[*]} " != *" set_prompt "* ]]; then
if (( ${#PROMPT_COMMAND[@]} )); then
PROMPT_COMMAND=(_profile_save_status "${PROMPT_COMMAND[@]}" set_prompt)
else
PROMPT_COMMAND=(set_prompt)
fi
fi
;;
*)
if [[ -z "${PROMPT_COMMAND:-}" ]]; then
PROMPT_COMMAND='set_prompt'
elif [[ "$PROMPT_COMMAND" != *set_prompt* ]]; then
PROMPT_COMMAND="_profile_save_status;${PROMPT_COMMAND};set_prompt"
fi
;;
esac
# Set default language from DEFAULT_LANG config key (set in [general]).
# The value must match one of the alias names defined in SET_LOCALE so that
# the corresponding set<alias> function exists after build_locale_shortcuts.
if [[ -n "${DEFAULT_LANG:-}" ]]; then
_lang_fn="set${DEFAULT_LANG}"
if declare -F "$_lang_fn" >/dev/null 2>&1; then
"$_lang_fn"
else
disp W "DEFAULT_LANG '$DEFAULT_LANG' has no matching locale shortcut (check SET_LOCALE in profile.conf)."
fi
unset _lang_fn
fi
showinfo && printf "\n"
check_updates -q
disp I "Profile version $PROFVERSION loaded..."
fi
unset _profile_ng
# Cleanup
unset -f _profile_is_sourced _profile_install_in_file _profile_file_sources _profile_install
unset -f parse_conf load_alias load_conf
unset -f pathremove pathprepend pathappend
#return 0
# End profile.sh