From dd89df129e472b27c5286a39ab2395b0d076bb50 Mon Sep 17 00:00:00 2001 From: andrey Date: Fri, 20 Mar 2026 12:29:31 -0400 Subject: [PATCH 1/2] feat: add Linux and WSL installers --- .github/workflows/release.yml | 40 +++ README.md | 66 ++++ install-windows.ps1 | 58 ++++ scripts/install-linux.sh | 586 ++++++++++++++++++++++++++++++++++ scripts/install-wsl.sh | 586 ++++++++++++++++++++++++++++++++++ tests/test_installers.py | 322 +++++++++++++++++++ 6 files changed, 1658 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 install-windows.ps1 create mode 100755 scripts/install-linux.sh create mode 100755 scripts/install-wsl.sh create mode 100644 tests/test_installers.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6f389d7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Verify installer assets + run: | + test -f install-windows.ps1 + test -f scripts/install-linux.sh + test -f scripts/install-wsl.sh + + - name: Build release bundle + run: | + mkdir -p dist + git archive --format=tar.gz --prefix="telecli-${GITHUB_REF_NAME}/" -o "dist/telecli-${GITHUB_REF_NAME}-source.tar.gz" HEAD + cp install-windows.ps1 dist/install-windows.ps1 + cp scripts/install-linux.sh dist/install-linux.sh + cp scripts/install-wsl.sh dist/install-wsl.sh + cp README.md dist/README.md + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + files: | + dist/install-windows.ps1 + dist/install-linux.sh + dist/install-wsl.sh + dist/telecli-${{ github.ref_name }}-source.tar.gz diff --git a/README.md b/README.md index 8f8341e..467a516 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,72 @@ TeleCLI is a web-based terminal interface that allows users to interact with com pip install -r requirements.txt ``` +### Linux + +For Linux hosts, use the dedicated installer to clone TeleCLI into `~/.local/share/telecli`, create a virtualenv, copy `.env.sample` to `.env` if needed, and install a `telecli` launcher into `~/.local/bin`. + +From a checkout: +```bash +./scripts/install-linux.sh +telecli start +telecli status +``` + +Without cloning first: +```bash +curl -fsSL https://raw.githubusercontent.com/malandr/telecli/main/scripts/install-linux.sh | bash +telecli start +``` + +On a fresh install the installer will guide you through the key `.env` settings: +- Telegram bot token +- Allowed Telegram user IDs +- Web host binding (`127.0.0.1` vs `0.0.0.0`) +- Web port +- Whether web auth is required +- Auth token (auto-generated if left blank) +- Whether AI proxy should start enabled +- Which AI proxy provider to use +- Whether TeleCLI should start at startup/login through a user `systemd` service + +For scripted installs, you can skip prompts and preseed answers: +```bash +TELECLI_AUTO_CONFIG=1 \ +TELECLI_INSTALL_TELEGRAM_BOT_TOKEN="" \ +TELECLI_INSTALL_WEB_HOST=127.0.0.1 \ +TELECLI_INSTALL_WEB_PORT=8000 \ +TELECLI_INSTALL_AUTH_REQUIRED=true \ +./scripts/install-linux.sh +``` + +If you opt in to start at startup, the installer writes `~/.config/systemd/user/telecli.service`, runs `systemctl --user daemon-reload`, enables it, and starts it immediately. If `systemctl --user` is unavailable, the installer keeps going and leaves the service file in place for manual setup. + +### Windows (WSL2) + +Windows support is currently delivered through WSL2 so TeleCLI can keep using a real Linux shell and tmux. + +1. Install WSL2 with an Ubuntu distro if you do not already have one: + ```powershell + wsl --install -d Ubuntu + ``` +2. Run the Windows bootstrapper: + ```powershell + powershell -ExecutionPolicy Bypass -File .\install-windows.ps1 + ``` +3. Start TeleCLI inside WSL: + ```powershell + wsl telecli-wsl start + ``` +4. Inspect status or logs from Windows: + ```powershell + wsl telecli-wsl status + wsl telecli-wsl logs + ``` + +The installer clones TeleCLI into `~/.local/share/telecli` inside your WSL distro, creates a virtualenv, copies `.env.sample` to `.env` when needed, and installs a `telecli-wsl` launcher in `~/.local/bin`. +It asks the same setup questions as the Linux installer, and you can preseed them with the same `TELECLI_AUTO_CONFIG=1` and `TELECLI_INSTALL_*` environment variables before running the WSL-side script. +If you enable start at startup there, it uses a user `systemd` service inside WSL. This requires a systemd-enabled WSL distro. + ## Configuration TeleCLI uses environment variables for configuration. Copy `.env.sample` to `.env` and configure the following key settings: diff --git a/install-windows.ps1 b/install-windows.ps1 new file mode 100644 index 0000000..8d891d3 --- /dev/null +++ b/install-windows.ps1 @@ -0,0 +1,58 @@ +param( + [string]$Distro = "", + [string]$RepoUrl = "https://github.com/malandr/telecli.git", + [string]$Ref = "main", + [string]$Prefix = '$HOME/.local/share/telecli', + [switch]$SkipSystemPackages +) + +$ErrorActionPreference = "Stop" + +function Write-Step { + param([string]$Message) + Write-Host "==> $Message" +} + +if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + throw "wsl.exe is not available. Install WSL first with: wsl --install -d Ubuntu" +} + +$listedDistros = & wsl.exe -l -q 2>$null | Where-Object { $_.Trim() -ne "" } +if (-not $listedDistros) { + throw "No WSL distro is installed. Install one first with: wsl --install -d Ubuntu" +} + +$scriptUrl = "https://raw.githubusercontent.com/malandr/telecli/$Ref/scripts/install-wsl.sh" + +Write-Step "Downloading WSL installer from $scriptUrl" +$wslInstaller = Invoke-WebRequest -UseBasicParsing -Uri $scriptUrl | Select-Object -ExpandProperty Content + +$arguments = @() +if ($Distro) { + $arguments += @("-d", $Distro) +} + +$arguments += @( + "--", + "bash", + "-s", + "--", + "--repo-url", + $RepoUrl, + "--ref", + $Ref, + "--prefix", + $Prefix +) + +if ($SkipSystemPackages) { + $arguments += "--skip-system-packages" +} + +Write-Step "Running TeleCLI installer inside WSL" +$wslInstaller | & wsl.exe @arguments + +Write-Step "TeleCLI is installed in WSL" +Write-Host "Start it with: wsl telecli-wsl start" +Write-Host "Check status with: wsl telecli-wsl status" +Write-Host "Open logs with: wsl telecli-wsl logs" diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh new file mode 100755 index 0000000..3fe2b39 --- /dev/null +++ b/scripts/install-linux.sh @@ -0,0 +1,586 @@ +#!/bin/bash + +set -euo pipefail + +REPO_URL="https://github.com/malandr/telecli.git" +REF="main" +PREFIX="${HOME}/.local/share/telecli" +BIN_DIR="${HOME}/.local/bin" +STATE_DIR="${HOME}/.local/state/telecli" +SKIP_SYSTEM_PACKAGES="false" +LAUNCHER_NAME="telecli" +INSTALL_LABEL="Linux" +START_AT_STARTUP="false" + +usage() { + cat <<'EOF' +Usage: scripts/install-linux.sh [options] + +Install TeleCLI on a Linux host and create a telecli launcher. + +Options: + --repo-url URL Git repository to clone or update + --ref REF Git branch or tag to install + --prefix PATH Install directory + --bin-dir PATH Directory for the telecli launcher + --state-dir PATH Runtime state directory for pid/log files + --skip-system-packages Skip apt-based dependency installation + --help Show this help text + +Environment: + TELECLI_DRY_RUN=1 Print the install plan without changing the machine + TELECLI_AUTO_CONFIG=1 Skip prompts and use TELECLI_INSTALL_* values/defaults + TELECLI_INSTALL_TELEGRAM_BOT_TOKEN=... Seed TELEGRAM_BOT_TOKEN + TELECLI_INSTALL_ALLOWED_TELEGRAM_USERS=... Seed ALLOWED_TELEGRAM_USERS + TELECLI_INSTALL_WEB_HOST=... Seed WEB_HOST + TELECLI_INSTALL_WEB_PORT=... Seed WEB_PORT + TELECLI_INSTALL_AUTH_REQUIRED=true|false Seed AUTH_REQUIRED + TELECLI_INSTALL_AUTH_TOKEN=... Seed AUTH_TOKEN + TELECLI_INSTALL_AI_PROXY_ENABLED=true|false Seed AI_PROXY_ENABLED + TELECLI_INSTALL_AI_PROXY_PROVIDER=... Seed AI_PROXY_PROVIDER + TELECLI_INSTALL_START_AT_STARTUP=true|false Enable a user systemd startup service +EOF +} + +is_dry_run() { + [ "${TELECLI_DRY_RUN:-0}" = "1" ] +} + +auto_config() { + [ "${TELECLI_AUTO_CONFIG:-0}" = "1" ] +} + +log() { + printf '%s\n' "$*" +} + +run_cmd() { + local rendered + printf -v rendered '%q ' "$@" + + if is_dry_run; then + log "[dry-run] ${rendered% }" + return 0 + fi + + "$@" +} + +write_text() { + local destination="$1" + local content="$2" + + if is_dry_run; then + log "[dry-run] write ${destination}" + return 0 + fi + + printf '%s' "${content}" > "${destination}" +} + +can_prompt() { + [ -r /dev/tty ] +} + +placeholder_to_blank() { + case "$1" in + your_telegram_bot_token_here|your_auth_token_here) + printf '%s' "" + ;; + *) + printf '%s' "$1" + ;; + esac +} + +normalize_bool() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|y|on) + printf 'true' + ;; + 0|false|no|n|off) + printf 'false' + ;; + *) + printf '%s' "${2:-false}" + ;; + esac +} + +get_env_value() { + local key="$1" + local line + + line=$(grep -E "^${key}=" "${ENV_FILE}" | tail -1 || true) + if [ -z "${line}" ]; then + return 0 + fi + + printf '%s' "${line#*=}" +} + +set_env_value() { + local key="$1" + local value="$2" + local escaped_value + + escaped_value=$(printf '%s' "${value}" | sed -e 's/[&|]/\\&/g') + + if grep -q -E "^${key}=" "${ENV_FILE}"; then + sed -i "s|^${key}=.*|${key}=${escaped_value}|" "${ENV_FILE}" + else + printf '%s=%s\n' "${key}" "${value}" >> "${ENV_FILE}" + fi +} + +prompt_input() { + local prompt="$1" + local default_value="$2" + local answer="" + + if ! can_prompt || auto_config; then + printf '%s' "${default_value}" + return 0 + fi + + if [ -n "${default_value}" ]; then + printf '%s [%s]: ' "${prompt}" "${default_value}" > /dev/tty + else + printf '%s: ' "${prompt}" > /dev/tty + fi + + IFS= read -r answer < /dev/tty || true + if [ -z "${answer}" ]; then + answer="${default_value}" + fi + + printf '%s' "${answer}" +} + +prompt_bool() { + local prompt="$1" + local default_value + local answer + + default_value=$(normalize_bool "$2" "false") + if [ "${default_value}" = "true" ]; then + answer=$(prompt_input "${prompt}" "Y/n") + case "$(printf '%s' "${answer}" | tr '[:upper:]' '[:lower:]')" in + ""|y|yes) + printf 'true' + ;; + n|no) + printf 'false' + ;; + *) + printf '%s' "${default_value}" + ;; + esac + else + answer=$(prompt_input "${prompt}" "y/N") + case "$(printf '%s' "${answer}" | tr '[:upper:]' '[:lower:]')" in + y|yes) + printf 'true' + ;; + ""|n|no) + printf 'false' + ;; + *) + printf '%s' "${default_value}" + ;; + esac + fi +} + +generate_auth_token() { + od -An -N16 -tx1 /dev/urandom | tr -d ' \n' +} + +systemd_user_dir() { + printf '%s' "${XDG_CONFIG_HOME:-${HOME}/.config}/systemd/user" +} + +while [ $# -gt 0 ]; do + case "$1" in + --repo-url) + REPO_URL="$2" + shift 2 + ;; + --ref) + REF="$2" + shift 2 + ;; + --prefix) + PREFIX="$2" + shift 2 + ;; + --bin-dir) + BIN_DIR="$2" + shift 2 + ;; + --state-dir) + STATE_DIR="$2" + shift 2 + ;; + --skip-system-packages) + SKIP_SYSTEM_PACKAGES="true" + shift + ;; + --help) + usage + exit 0 + ;; + *) + log "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +LAUNCHER_PATH="${BIN_DIR}/${LAUNCHER_NAME}" +ENV_FILE="${PREFIX}/.env" +SERVICE_NAME="${LAUNCHER_NAME}.service" +SERVICE_FILE="$(systemd_user_dir)/${SERVICE_NAME}" + +print_plan() { + log "TeleCLI ${INSTALL_LABEL} install plan" + log " Install dir: ${PREFIX}" + log " Launcher: ${LAUNCHER_PATH}" + log " State dir: ${STATE_DIR}" + log " Repo URL: ${REPO_URL}" + log " Ref: ${REF}" + log " Startup service: ${SERVICE_FILE}" +} + +install_system_packages() { + if [ "${SKIP_SYSTEM_PACKAGES}" = "true" ]; then + log "Skipping apt package installation" + return 0 + fi + + if ! command -v apt-get >/dev/null 2>&1 || ! command -v sudo >/dev/null 2>&1; then + log "Skipping apt package installation because sudo/apt-get is unavailable" + return 0 + fi + + run_cmd sudo apt-get update + run_cmd sudo apt-get install -y git python3 python3-venv python3-pip tmux curl +} + +sync_repo() { + if [ -d "${PREFIX}/.git" ]; then + run_cmd git -C "${PREFIX}" fetch --tags origin + run_cmd git -C "${PREFIX}" checkout "${REF}" + if [[ "${REF}" != refs/tags/* ]] && [[ "${REF}" != v* ]]; then + run_cmd git -C "${PREFIX}" pull --ff-only origin "${REF}" + fi + return 0 + fi + + run_cmd git clone --branch "${REF}" --single-branch "${REPO_URL}" "${PREFIX}" +} + +setup_python() { + run_cmd python3 -m venv "${PREFIX}/venv" + run_cmd "${PREFIX}/venv/bin/pip" install --upgrade pip + run_cmd "${PREFIX}/venv/bin/pip" install -r "${PREFIX}/requirements.txt" +} + +configure_env_file() { + local telegram_token allowed_users web_host web_port auth_required auth_token + local ai_proxy_enabled ai_proxy_provider bind_localhost enable_telegram generated_auth_token="" + + telegram_token=$(placeholder_to_blank "${TELECLI_INSTALL_TELEGRAM_BOT_TOKEN:-$(get_env_value TELEGRAM_BOT_TOKEN)}") + allowed_users="${TELECLI_INSTALL_ALLOWED_TELEGRAM_USERS:-$(get_env_value ALLOWED_TELEGRAM_USERS)}" + web_host="${TELECLI_INSTALL_WEB_HOST:-$(get_env_value WEB_HOST)}" + web_port="${TELECLI_INSTALL_WEB_PORT:-$(get_env_value WEB_PORT)}" + auth_required=$(normalize_bool "${TELECLI_INSTALL_AUTH_REQUIRED:-$(get_env_value AUTH_REQUIRED)}" "true") + auth_token=$(placeholder_to_blank "${TELECLI_INSTALL_AUTH_TOKEN:-$(get_env_value AUTH_TOKEN)}") + ai_proxy_enabled=$(normalize_bool "${TELECLI_INSTALL_AI_PROXY_ENABLED:-$(get_env_value AI_PROXY_ENABLED)}" "false") + ai_proxy_provider="${TELECLI_INSTALL_AI_PROXY_PROVIDER:-$(get_env_value AI_PROXY_PROVIDER)}" + START_AT_STARTUP=$(normalize_bool "${TELECLI_INSTALL_START_AT_STARTUP:-${START_AT_STARTUP}}" "false") + + [ -n "${web_host}" ] || web_host="127.0.0.1" + [ -n "${web_port}" ] || web_port="8000" + [ -n "${ai_proxy_provider}" ] || ai_proxy_provider="gemini-cli" + + if can_prompt && ! auto_config; then + log "Configuring ${ENV_FILE}" + enable_telegram="false" + if [ -n "${telegram_token}" ]; then + enable_telegram="true" + fi + enable_telegram=$(prompt_bool "Enable Telegram bot integration?" "${enable_telegram}") + if [ "${enable_telegram}" = "true" ]; then + telegram_token=$(prompt_input "Telegram bot token" "${telegram_token}") + allowed_users=$(prompt_input "Allowed Telegram user IDs (comma-separated, optional)" "${allowed_users}") + else + telegram_token="" + allowed_users="" + fi + + bind_localhost="true" + if [ "${web_host}" = "0.0.0.0" ]; then + bind_localhost="false" + fi + bind_localhost=$(prompt_bool "Bind the web UI to localhost only?" "${bind_localhost}") + if [ "${bind_localhost}" = "true" ]; then + web_host="127.0.0.1" + else + web_host="0.0.0.0" + fi + + web_port=$(prompt_input "Web port" "${web_port}") + auth_required=$(prompt_bool "Require an auth token for web access?" "${auth_required}") + if [ "${auth_required}" = "true" ]; then + auth_token=$(prompt_input "Auth token (leave blank to auto-generate)" "${auth_token}") + else + auth_token="" + fi + + ai_proxy_enabled=$(prompt_bool "Enable AI proxy by default?" "${ai_proxy_enabled}") + if [ "${ai_proxy_enabled}" = "true" ]; then + ai_proxy_provider=$(prompt_input "AI proxy provider (gemini-cli, claude-cli, github-cli)" "${ai_proxy_provider}") + fi + START_AT_STARTUP=$(prompt_bool "Start TeleCLI at startup/login with systemd?" "${START_AT_STARTUP}") + fi + + auth_required=$(normalize_bool "${auth_required}" "true") + ai_proxy_enabled=$(normalize_bool "${ai_proxy_enabled}" "false") + + if [ "${auth_required}" = "true" ] && [ -z "${auth_token}" ]; then + auth_token=$(generate_auth_token) + generated_auth_token="${auth_token}" + fi + + if [ "${ai_proxy_enabled}" != "true" ]; then + ai_proxy_enabled="false" + fi + + set_env_value TELEGRAM_BOT_TOKEN "${telegram_token}" + set_env_value TELEGRAM_WEBHOOK_URL "" + set_env_value ALLOWED_TELEGRAM_USERS "${allowed_users}" + set_env_value WEB_HOST "${web_host}" + set_env_value WEB_PORT "${web_port}" + set_env_value AUTH_REQUIRED "${auth_required}" + set_env_value AUTH_TOKEN "${auth_token}" + set_env_value AI_PROXY_ENABLED "${ai_proxy_enabled}" + set_env_value AI_PROXY_PROVIDER "${ai_proxy_provider}" + + if [ -n "${generated_auth_token}" ]; then + log "Generated AUTH_TOKEN: ${generated_auth_token}" + fi +} + +ensure_env_file() { + if [ -f "${ENV_FILE}" ]; then + log "Keeping existing ${ENV_FILE}" + return 0 + fi + + if is_dry_run; then + log "[dry-run] cp ${PREFIX}/.env.sample ${ENV_FILE}" + if auto_config; then + log "[dry-run] apply TELECLI_INSTALL_* overrides to ${ENV_FILE}" + fi + return 0 + fi + + cp "${PREFIX}/.env.sample" "${ENV_FILE}" + configure_env_file +} + +install_startup_service() { + local service_content + + if [ "${START_AT_STARTUP}" != "true" ]; then + return 0 + fi + + service_content=$(cat </dev/null 2>&1; then + log "systemctl not found; wrote ${SERVICE_FILE} but did not enable startup" + return 0 + fi + + if ! systemctl --user daemon-reload; then + log "systemctl --user daemon-reload failed; wrote ${SERVICE_FILE} but did not enable startup" + return 0 + fi + + if ! systemctl enable --user "${SERVICE_NAME}"; then + log "systemctl enable --user failed; wrote ${SERVICE_FILE} but did not enable startup" + return 0 + fi + + if ! systemctl start --user "${SERVICE_NAME}"; then + log "systemctl start --user failed; wrote ${SERVICE_FILE} but did not start TeleCLI" + return 0 + fi +} + +write_launcher() { + local launcher_content + launcher_content=$(cat </dev/null 2>&1 +} + +start() { + if is_running; then + printf 'TeleCLI is already running (pid %s)\n' "\$(current_pid)" + return 0 + fi + + cd "\${TELECLI_HOME}" + nohup "\${TELECLI_HOME}/venv/bin/python" -m src.main >> "\${LOG_FILE}" 2>&1 & + local pid=\$! + printf '%s' "\${pid}" > "\${PID_FILE}" + printf 'Started TeleCLI (pid %s)\n' "\${pid}" + printf 'Open http://localhost:%s\n' "\$(get_web_port)" +} + +stop() { + if ! is_running; then + rm -f "\${PID_FILE}" + printf 'TeleCLI is not running\n' + return 0 + fi + + local pid + pid=\$(current_pid) + kill "\${pid}" + rm -f "\${PID_FILE}" + printf 'Stopped TeleCLI (pid %s)\n' "\${pid}" +} + +status() { + if is_running; then + printf 'TeleCLI is running (pid %s)\n' "\$(current_pid)" + else + printf 'TeleCLI is stopped\n' + fi + + printf 'URL: http://localhost:%s\n' "\$(get_web_port)" + printf 'Logs: %s\n' "\${LOG_FILE}" +} + +logs() { + touch "\${LOG_FILE}" + tail -n 100 -f "\${LOG_FILE}" +} + +url() { + printf 'http://localhost:%s\n' "\$(get_web_port)" +} + +case "\${1:-status}" in + start) + start + ;; + stop) + stop + ;; + restart) + stop + start + ;; + status) + status + ;; + logs) + logs + ;; + url) + url + ;; + *) + printf 'Usage: ${LAUNCHER_NAME} {start|stop|restart|status|logs|url}\n' >&2 + exit 1 + ;; +esac +EOF +) + + write_text "${LAUNCHER_PATH}" "${launcher_content}" + run_cmd chmod +x "${LAUNCHER_PATH}" +} + +main() { + print_plan + + run_cmd mkdir -p "$(dirname "${PREFIX}")" "${BIN_DIR}" "${STATE_DIR}" + install_system_packages + sync_repo + setup_python + ensure_env_file + write_launcher + install_startup_service + + log "${INSTALL_LABEL} install complete" + log " Start: ${LAUNCHER_PATH} start" + log " Status: ${LAUNCHER_PATH} status" + log " Logs: ${LAUNCHER_PATH} logs" +} + +main diff --git a/scripts/install-wsl.sh b/scripts/install-wsl.sh new file mode 100755 index 0000000..eb80451 --- /dev/null +++ b/scripts/install-wsl.sh @@ -0,0 +1,586 @@ +#!/bin/bash + +set -euo pipefail + +REPO_URL="https://github.com/malandr/telecli.git" +REF="main" +PREFIX="${HOME}/.local/share/telecli" +BIN_DIR="${HOME}/.local/bin" +STATE_DIR="${HOME}/.local/state/telecli" +SKIP_SYSTEM_PACKAGES="false" +LAUNCHER_NAME="telecli-wsl" +INSTALL_LABEL="WSL" +START_AT_STARTUP="false" + +usage() { + cat <<'EOF' +Usage: scripts/install-wsl.sh [options] + +Install TeleCLI inside a WSL distro and create a telecli-wsl launcher. + +Options: + --repo-url URL Git repository to clone or update + --ref REF Git branch or tag to install + --prefix PATH Install directory inside WSL + --bin-dir PATH Directory for the telecli-wsl launcher + --state-dir PATH Runtime state directory for pid/log files + --skip-system-packages Skip apt-based dependency installation + --help Show this help text + +Environment: + TELECLI_DRY_RUN=1 Print the install plan without changing the machine + TELECLI_AUTO_CONFIG=1 Skip prompts and use TELECLI_INSTALL_* values/defaults + TELECLI_INSTALL_TELEGRAM_BOT_TOKEN=... Seed TELEGRAM_BOT_TOKEN + TELECLI_INSTALL_ALLOWED_TELEGRAM_USERS=... Seed ALLOWED_TELEGRAM_USERS + TELECLI_INSTALL_WEB_HOST=... Seed WEB_HOST + TELECLI_INSTALL_WEB_PORT=... Seed WEB_PORT + TELECLI_INSTALL_AUTH_REQUIRED=true|false Seed AUTH_REQUIRED + TELECLI_INSTALL_AUTH_TOKEN=... Seed AUTH_TOKEN + TELECLI_INSTALL_AI_PROXY_ENABLED=true|false Seed AI_PROXY_ENABLED + TELECLI_INSTALL_AI_PROXY_PROVIDER=... Seed AI_PROXY_PROVIDER + TELECLI_INSTALL_START_AT_STARTUP=true|false Enable a user systemd startup service +EOF +} + +is_dry_run() { + [ "${TELECLI_DRY_RUN:-0}" = "1" ] +} + +auto_config() { + [ "${TELECLI_AUTO_CONFIG:-0}" = "1" ] +} + +log() { + printf '%s\n' "$*" +} + +run_cmd() { + local rendered + printf -v rendered '%q ' "$@" + + if is_dry_run; then + log "[dry-run] ${rendered% }" + return 0 + fi + + "$@" +} + +write_text() { + local destination="$1" + local content="$2" + + if is_dry_run; then + log "[dry-run] write ${destination}" + return 0 + fi + + printf '%s' "${content}" > "${destination}" +} + +can_prompt() { + [ -r /dev/tty ] +} + +placeholder_to_blank() { + case "$1" in + your_telegram_bot_token_here|your_auth_token_here) + printf '%s' "" + ;; + *) + printf '%s' "$1" + ;; + esac +} + +normalize_bool() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|y|on) + printf 'true' + ;; + 0|false|no|n|off) + printf 'false' + ;; + *) + printf '%s' "${2:-false}" + ;; + esac +} + +get_env_value() { + local key="$1" + local line + + line=$(grep -E "^${key}=" "${ENV_FILE}" | tail -1 || true) + if [ -z "${line}" ]; then + return 0 + fi + + printf '%s' "${line#*=}" +} + +set_env_value() { + local key="$1" + local value="$2" + local escaped_value + + escaped_value=$(printf '%s' "${value}" | sed -e 's/[&|]/\\&/g') + + if grep -q -E "^${key}=" "${ENV_FILE}"; then + sed -i "s|^${key}=.*|${key}=${escaped_value}|" "${ENV_FILE}" + else + printf '%s=%s\n' "${key}" "${value}" >> "${ENV_FILE}" + fi +} + +prompt_input() { + local prompt="$1" + local default_value="$2" + local answer="" + + if ! can_prompt || auto_config; then + printf '%s' "${default_value}" + return 0 + fi + + if [ -n "${default_value}" ]; then + printf '%s [%s]: ' "${prompt}" "${default_value}" > /dev/tty + else + printf '%s: ' "${prompt}" > /dev/tty + fi + + IFS= read -r answer < /dev/tty || true + if [ -z "${answer}" ]; then + answer="${default_value}" + fi + + printf '%s' "${answer}" +} + +prompt_bool() { + local prompt="$1" + local default_value + local answer + + default_value=$(normalize_bool "$2" "false") + if [ "${default_value}" = "true" ]; then + answer=$(prompt_input "${prompt}" "Y/n") + case "$(printf '%s' "${answer}" | tr '[:upper:]' '[:lower:]')" in + ""|y|yes) + printf 'true' + ;; + n|no) + printf 'false' + ;; + *) + printf '%s' "${default_value}" + ;; + esac + else + answer=$(prompt_input "${prompt}" "y/N") + case "$(printf '%s' "${answer}" | tr '[:upper:]' '[:lower:]')" in + y|yes) + printf 'true' + ;; + ""|n|no) + printf 'false' + ;; + *) + printf '%s' "${default_value}" + ;; + esac + fi +} + +generate_auth_token() { + od -An -N16 -tx1 /dev/urandom | tr -d ' \n' +} + +systemd_user_dir() { + printf '%s' "${XDG_CONFIG_HOME:-${HOME}/.config}/systemd/user" +} + +while [ $# -gt 0 ]; do + case "$1" in + --repo-url) + REPO_URL="$2" + shift 2 + ;; + --ref) + REF="$2" + shift 2 + ;; + --prefix) + PREFIX="$2" + shift 2 + ;; + --bin-dir) + BIN_DIR="$2" + shift 2 + ;; + --state-dir) + STATE_DIR="$2" + shift 2 + ;; + --skip-system-packages) + SKIP_SYSTEM_PACKAGES="true" + shift + ;; + --help) + usage + exit 0 + ;; + *) + log "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +LAUNCHER_PATH="${BIN_DIR}/${LAUNCHER_NAME}" +ENV_FILE="${PREFIX}/.env" +SERVICE_NAME="${LAUNCHER_NAME}.service" +SERVICE_FILE="$(systemd_user_dir)/${SERVICE_NAME}" + +print_plan() { + log "TeleCLI ${INSTALL_LABEL} install plan" + log " Install dir: ${PREFIX}" + log " Launcher: ${LAUNCHER_PATH}" + log " State dir: ${STATE_DIR}" + log " Repo URL: ${REPO_URL}" + log " Ref: ${REF}" + log " Startup service: ${SERVICE_FILE}" +} + +install_system_packages() { + if [ "${SKIP_SYSTEM_PACKAGES}" = "true" ]; then + log "Skipping apt package installation" + return 0 + fi + + if ! command -v apt-get >/dev/null 2>&1 || ! command -v sudo >/dev/null 2>&1; then + log "Skipping apt package installation because sudo/apt-get is unavailable" + return 0 + fi + + run_cmd sudo apt-get update + run_cmd sudo apt-get install -y git python3 python3-venv python3-pip tmux curl +} + +sync_repo() { + if [ -d "${PREFIX}/.git" ]; then + run_cmd git -C "${PREFIX}" fetch --tags origin + run_cmd git -C "${PREFIX}" checkout "${REF}" + if [[ "${REF}" != refs/tags/* ]] && [[ "${REF}" != v* ]]; then + run_cmd git -C "${PREFIX}" pull --ff-only origin "${REF}" + fi + return 0 + fi + + run_cmd git clone --branch "${REF}" --single-branch "${REPO_URL}" "${PREFIX}" +} + +setup_python() { + run_cmd python3 -m venv "${PREFIX}/venv" + run_cmd "${PREFIX}/venv/bin/pip" install --upgrade pip + run_cmd "${PREFIX}/venv/bin/pip" install -r "${PREFIX}/requirements.txt" +} + +configure_env_file() { + local telegram_token allowed_users web_host web_port auth_required auth_token + local ai_proxy_enabled ai_proxy_provider bind_localhost enable_telegram generated_auth_token="" + + telegram_token=$(placeholder_to_blank "${TELECLI_INSTALL_TELEGRAM_BOT_TOKEN:-$(get_env_value TELEGRAM_BOT_TOKEN)}") + allowed_users="${TELECLI_INSTALL_ALLOWED_TELEGRAM_USERS:-$(get_env_value ALLOWED_TELEGRAM_USERS)}" + web_host="${TELECLI_INSTALL_WEB_HOST:-$(get_env_value WEB_HOST)}" + web_port="${TELECLI_INSTALL_WEB_PORT:-$(get_env_value WEB_PORT)}" + auth_required=$(normalize_bool "${TELECLI_INSTALL_AUTH_REQUIRED:-$(get_env_value AUTH_REQUIRED)}" "true") + auth_token=$(placeholder_to_blank "${TELECLI_INSTALL_AUTH_TOKEN:-$(get_env_value AUTH_TOKEN)}") + ai_proxy_enabled=$(normalize_bool "${TELECLI_INSTALL_AI_PROXY_ENABLED:-$(get_env_value AI_PROXY_ENABLED)}" "false") + ai_proxy_provider="${TELECLI_INSTALL_AI_PROXY_PROVIDER:-$(get_env_value AI_PROXY_PROVIDER)}" + START_AT_STARTUP=$(normalize_bool "${TELECLI_INSTALL_START_AT_STARTUP:-${START_AT_STARTUP}}" "false") + + [ -n "${web_host}" ] || web_host="127.0.0.1" + [ -n "${web_port}" ] || web_port="8000" + [ -n "${ai_proxy_provider}" ] || ai_proxy_provider="gemini-cli" + + if can_prompt && ! auto_config; then + log "Configuring ${ENV_FILE}" + enable_telegram="false" + if [ -n "${telegram_token}" ]; then + enable_telegram="true" + fi + enable_telegram=$(prompt_bool "Enable Telegram bot integration?" "${enable_telegram}") + if [ "${enable_telegram}" = "true" ]; then + telegram_token=$(prompt_input "Telegram bot token" "${telegram_token}") + allowed_users=$(prompt_input "Allowed Telegram user IDs (comma-separated, optional)" "${allowed_users}") + else + telegram_token="" + allowed_users="" + fi + + bind_localhost="true" + if [ "${web_host}" = "0.0.0.0" ]; then + bind_localhost="false" + fi + bind_localhost=$(prompt_bool "Bind the web UI to localhost only?" "${bind_localhost}") + if [ "${bind_localhost}" = "true" ]; then + web_host="127.0.0.1" + else + web_host="0.0.0.0" + fi + + web_port=$(prompt_input "Web port" "${web_port}") + auth_required=$(prompt_bool "Require an auth token for web access?" "${auth_required}") + if [ "${auth_required}" = "true" ]; then + auth_token=$(prompt_input "Auth token (leave blank to auto-generate)" "${auth_token}") + else + auth_token="" + fi + + ai_proxy_enabled=$(prompt_bool "Enable AI proxy by default?" "${ai_proxy_enabled}") + if [ "${ai_proxy_enabled}" = "true" ]; then + ai_proxy_provider=$(prompt_input "AI proxy provider (gemini-cli, claude-cli, github-cli)" "${ai_proxy_provider}") + fi + START_AT_STARTUP=$(prompt_bool "Start TeleCLI at startup/login with systemd?" "${START_AT_STARTUP}") + fi + + auth_required=$(normalize_bool "${auth_required}" "true") + ai_proxy_enabled=$(normalize_bool "${ai_proxy_enabled}" "false") + + if [ "${auth_required}" = "true" ] && [ -z "${auth_token}" ]; then + auth_token=$(generate_auth_token) + generated_auth_token="${auth_token}" + fi + + if [ "${ai_proxy_enabled}" != "true" ]; then + ai_proxy_enabled="false" + fi + + set_env_value TELEGRAM_BOT_TOKEN "${telegram_token}" + set_env_value TELEGRAM_WEBHOOK_URL "" + set_env_value ALLOWED_TELEGRAM_USERS "${allowed_users}" + set_env_value WEB_HOST "${web_host}" + set_env_value WEB_PORT "${web_port}" + set_env_value AUTH_REQUIRED "${auth_required}" + set_env_value AUTH_TOKEN "${auth_token}" + set_env_value AI_PROXY_ENABLED "${ai_proxy_enabled}" + set_env_value AI_PROXY_PROVIDER "${ai_proxy_provider}" + + if [ -n "${generated_auth_token}" ]; then + log "Generated AUTH_TOKEN: ${generated_auth_token}" + fi +} + +ensure_env_file() { + if [ -f "${ENV_FILE}" ]; then + log "Keeping existing ${ENV_FILE}" + return 0 + fi + + if is_dry_run; then + log "[dry-run] cp ${PREFIX}/.env.sample ${ENV_FILE}" + if auto_config; then + log "[dry-run] apply TELECLI_INSTALL_* overrides to ${ENV_FILE}" + fi + return 0 + fi + + cp "${PREFIX}/.env.sample" "${ENV_FILE}" + configure_env_file +} + +install_startup_service() { + local service_content + + if [ "${START_AT_STARTUP}" != "true" ]; then + return 0 + fi + + service_content=$(cat </dev/null 2>&1; then + log "systemctl not found; wrote ${SERVICE_FILE} but did not enable startup" + return 0 + fi + + if ! systemctl --user daemon-reload; then + log "systemctl --user daemon-reload failed; wrote ${SERVICE_FILE} but did not enable startup" + return 0 + fi + + if ! systemctl enable --user "${SERVICE_NAME}"; then + log "systemctl enable --user failed; wrote ${SERVICE_FILE} but did not enable startup" + return 0 + fi + + if ! systemctl start --user "${SERVICE_NAME}"; then + log "systemctl start --user failed; wrote ${SERVICE_FILE} but did not start TeleCLI" + return 0 + fi +} + +write_launcher() { + local launcher_content + launcher_content=$(cat </dev/null 2>&1 +} + +start() { + if is_running; then + printf 'TeleCLI is already running (pid %s)\n' "\$(current_pid)" + return 0 + fi + + cd "\${TELECLI_HOME}" + nohup "\${TELECLI_HOME}/venv/bin/python" -m src.main >> "\${LOG_FILE}" 2>&1 & + local pid=\$! + printf '%s' "\${pid}" > "\${PID_FILE}" + printf 'Started TeleCLI (pid %s)\n' "\${pid}" + printf 'Open http://localhost:%s\n' "\$(get_web_port)" +} + +stop() { + if ! is_running; then + rm -f "\${PID_FILE}" + printf 'TeleCLI is not running\n' + return 0 + fi + + local pid + pid=\$(current_pid) + kill "\${pid}" + rm -f "\${PID_FILE}" + printf 'Stopped TeleCLI (pid %s)\n' "\${pid}" +} + +status() { + if is_running; then + printf 'TeleCLI is running (pid %s)\n' "\$(current_pid)" + else + printf 'TeleCLI is stopped\n' + fi + + printf 'URL: http://localhost:%s\n' "\$(get_web_port)" + printf 'Logs: %s\n' "\${LOG_FILE}" +} + +logs() { + touch "\${LOG_FILE}" + tail -n 100 -f "\${LOG_FILE}" +} + +url() { + printf 'http://localhost:%s\n' "\$(get_web_port)" +} + +case "\${1:-status}" in + start) + start + ;; + stop) + stop + ;; + restart) + stop + start + ;; + status) + status + ;; + logs) + logs + ;; + url) + url + ;; + *) + printf 'Usage: ${LAUNCHER_NAME} {start|stop|restart|status|logs|url}\n' >&2 + exit 1 + ;; +esac +EOF +) + + write_text "${LAUNCHER_PATH}" "${launcher_content}" + run_cmd chmod +x "${LAUNCHER_PATH}" +} + +main() { + print_plan + + run_cmd mkdir -p "$(dirname "${PREFIX}")" "${BIN_DIR}" "${STATE_DIR}" + install_system_packages + sync_repo + setup_python + ensure_env_file + write_launcher + install_startup_service + + log "${INSTALL_LABEL} install complete" + log " Start: ${LAUNCHER_PATH} start" + log " Status: ${LAUNCHER_PATH} status" + log " Logs: ${LAUNCHER_PATH} logs" +} + +main diff --git a/tests/test_installers.py b/tests/test_installers.py new file mode 100644 index 0000000..00c1b48 --- /dev/null +++ b/tests/test_installers.py @@ -0,0 +1,322 @@ +"""Tests for WSL installer and release scaffolding.""" + +from pathlib import Path +import os +import subprocess + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(path.stat().st_mode | 0o111) + + +def _make_fake_python3(bin_dir: Path) -> None: + _write_executable( + bin_dir / "python3", + """#!/bin/sh +set -eu +if [ "$1" = "-m" ] && [ "$2" = "venv" ]; then + target="$3" + mkdir -p "$target/bin" + cat > "$target/bin/pip" <<'INNER' +#!/bin/sh +exit 0 +INNER + chmod +x "$target/bin/pip" + cat > "$target/bin/python" <<'INNER' +#!/bin/sh +exit 0 +INNER + chmod +x "$target/bin/python" + exit 0 +fi +echo "unexpected python3 invocation: $@" >&2 +exit 1 +""", + ) + + +def _init_fake_repo(path: Path) -> None: + path.mkdir() + (path / "requirements.txt").write_text("", encoding="utf-8") + (path / ".env.sample").write_text( + """TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here +ALLOWED_TELEGRAM_USERS= +WEB_HOST=127.0.0.1 +WEB_PORT=8000 +AUTH_REQUIRED=true +AUTH_TOKEN=your_auth_token_here +AI_PROXY_ENABLED=false +AI_PROXY_PROVIDER=gemini-cli +""", + encoding="utf-8", + ) + subprocess.run(["git", "init", "-b", "main"], cwd=path, check=True, capture_output=True, text=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=path, check=True, capture_output=True, text=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True, capture_output=True, text=True) + subprocess.run(["git", "add", "."], cwd=path, check=True, capture_output=True, text=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=path, check=True, capture_output=True, text=True) + + +def test_install_wsl_script_has_help_output(): + """The WSL installer should expose a discoverable CLI surface.""" + script = REPO_ROOT / "scripts" / "install-wsl.sh" + + completed = subprocess.run( + ["/bin/bash", str(script), "--help"], + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert "Usage:" in completed.stdout + assert "--prefix" in completed.stdout + assert "--repo-url" in completed.stdout + assert "--ref" in completed.stdout + + +def test_install_wsl_script_mentions_guided_config_options(): + """The WSL installer should advertise the same guided env setup controls.""" + script = REPO_ROOT / "scripts" / "install-wsl.sh" + text = script.read_text(encoding="utf-8") + + assert "TELECLI_AUTO_CONFIG" in text + assert "TELECLI_INSTALL_TELEGRAM_BOT_TOKEN" in text + assert "TELECLI_INSTALL_AUTH_TOKEN" in text + assert "TELECLI_INSTALL_START_AT_STARTUP" in text + + +def test_install_linux_script_has_help_output(): + """The Linux installer should expose the same documented CLI surface.""" + script = REPO_ROOT / "scripts" / "install-linux.sh" + + completed = subprocess.run( + ["/bin/bash", str(script), "--help"], + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert "Usage:" in completed.stdout + assert "--prefix" in completed.stdout + assert "--repo-url" in completed.stdout + assert "--ref" in completed.stdout + + +def test_install_wsl_script_dry_run_reports_install_plan(tmp_path): + """Dry-run mode should print the install targets without mutating the machine.""" + script = REPO_ROOT / "scripts" / "install-wsl.sh" + fake_home = tmp_path / "home" + fake_home.mkdir() + + completed = subprocess.run( + [ + "/bin/bash", + str(script), + "--repo-url", + "https://github.com/example/telecli.git", + "--ref", + "v1.2.3", + ], + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "HOME": str(fake_home), + "TELECLI_DRY_RUN": "1", + }, + ) + + assert completed.returncode == 0, completed.stderr + assert str(fake_home / ".local" / "share" / "telecli") in completed.stdout + assert str(fake_home / ".local" / "bin" / "telecli-wsl") in completed.stdout + assert "git clone --branch v1.2.3 --single-branch https://github.com/example/telecli.git" in completed.stdout + + +def test_install_linux_script_dry_run_reports_install_plan(tmp_path): + """Linux dry-run mode should print the install targets and launcher name.""" + script = REPO_ROOT / "scripts" / "install-linux.sh" + fake_home = tmp_path / "home" + fake_home.mkdir() + + completed = subprocess.run( + [ + "/bin/bash", + str(script), + "--repo-url", + "https://github.com/example/telecli.git", + "--ref", + "v9.9.9", + ], + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "HOME": str(fake_home), + "TELECLI_DRY_RUN": "1", + }, + ) + + assert completed.returncode == 0, completed.stderr + assert str(fake_home / ".local" / "share" / "telecli") in completed.stdout + assert str(fake_home / ".local" / "bin" / "telecli") in completed.stdout + assert "git clone --branch v9.9.9 --single-branch https://github.com/example/telecli.git" in completed.stdout + + +def test_install_linux_script_can_seed_env_from_answers(tmp_path): + """Installer answers should be written into the generated .env file.""" + script = REPO_ROOT / "scripts" / "install-linux.sh" + source_repo = tmp_path / "source-repo" + _init_fake_repo(source_repo) + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _make_fake_python3(fake_bin) + + prefix = tmp_path / "install-root" + + completed = subprocess.run( + [ + "/bin/bash", + str(script), + "--repo-url", + str(source_repo), + "--ref", + "main", + "--prefix", + str(prefix), + "--skip-system-packages", + ], + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "TELECLI_AUTO_CONFIG": "1", + "TELECLI_INSTALL_TELEGRAM_BOT_TOKEN": "123456:ABCDEF", + "TELECLI_INSTALL_ALLOWED_TELEGRAM_USERS": "111,222", + "TELECLI_INSTALL_WEB_HOST": "0.0.0.0", + "TELECLI_INSTALL_WEB_PORT": "8800", + "TELECLI_INSTALL_AUTH_REQUIRED": "true", + "TELECLI_INSTALL_AUTH_TOKEN": "super-secret-token", + "TELECLI_INSTALL_AI_PROXY_ENABLED": "true", + "TELECLI_INSTALL_AI_PROXY_PROVIDER": "claude-cli", + }, + ) + + assert completed.returncode == 0, completed.stderr + + env_text = (prefix / ".env").read_text(encoding="utf-8") + assert "TELEGRAM_BOT_TOKEN=123456:ABCDEF" in env_text + assert "ALLOWED_TELEGRAM_USERS=111,222" in env_text + assert "WEB_HOST=0.0.0.0" in env_text + assert "WEB_PORT=8800" in env_text + assert "AUTH_REQUIRED=true" in env_text + assert "AUTH_TOKEN=super-secret-token" in env_text + assert "AI_PROXY_ENABLED=true" in env_text + assert "AI_PROXY_PROVIDER=claude-cli" in env_text + + +def test_install_linux_script_can_enable_startup_service(tmp_path): + """Opting into startup should create and enable a user systemd service.""" + script = REPO_ROOT / "scripts" / "install-linux.sh" + source_repo = tmp_path / "source-repo" + _init_fake_repo(source_repo) + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _make_fake_python3(fake_bin) + systemctl_log = tmp_path / "systemctl.log" + _write_executable( + fake_bin / "systemctl", + f"""#!/bin/sh +printf '%s\\n' "$*" >> "{systemctl_log}" +exit 0 +""", + ) + + fake_home = tmp_path / "home" + fake_home.mkdir() + prefix = fake_home / ".local" / "share" / "telecli" + + completed = subprocess.run( + [ + "/bin/bash", + str(script), + "--repo-url", + str(source_repo), + "--ref", + "main", + "--skip-system-packages", + ], + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "HOME": str(fake_home), + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "TELECLI_AUTO_CONFIG": "1", + "TELECLI_INSTALL_START_AT_STARTUP": "true", + "TELECLI_INSTALL_AUTH_REQUIRED": "false", + }, + ) + + assert completed.returncode == 0, completed.stderr + + service_file = fake_home / ".config" / "systemd" / "user" / "telecli.service" + service_text = service_file.read_text(encoding="utf-8") + assert f"WorkingDirectory={prefix}" in service_text + assert f"ExecStart={prefix / 'venv' / 'bin' / 'python'} -m src.main" in service_text + assert "WantedBy=default.target" in service_text + + systemctl_calls = systemctl_log.read_text(encoding="utf-8") + assert "daemon-reload" in systemctl_calls + assert "enable --user telecli.service" in systemctl_calls + assert "start --user telecli.service" in systemctl_calls + + +def test_windows_installer_bootstraps_wsl_install_script(): + """The Windows entrypoint should download the WSL installer and invoke it through wsl.exe.""" + installer = REPO_ROOT / "install-windows.ps1" + text = installer.read_text(encoding="utf-8") + + assert "wsl.exe" in text + assert "Invoke-WebRequest" in text + assert "install-wsl.sh" in text + assert "telecli-wsl start" in text + + +def test_release_workflow_publishes_wsl_assets(): + """Tagged releases should publish the Windows and WSL installer assets.""" + workflow = REPO_ROOT / ".github" / "workflows" / "release.yml" + text = workflow.read_text(encoding="utf-8") + + assert "softprops/action-gh-release" in text + assert "tags:" in text + assert "v*" in text + assert "scripts/install-linux.sh" in text + assert "install-windows.ps1" in text + assert "scripts/install-wsl.sh" in text + + +def test_readme_documents_wsl_install_flow(): + """README should explain the supported Windows via WSL setup path.""" + readme = REPO_ROOT / "README.md" + text = readme.read_text(encoding="utf-8") + + assert "Linux" in text + assert "scripts/install-linux.sh" in text + assert "telecli start" in text + assert "start at startup" in text.lower() + assert "Windows (WSL2)" in text + assert "install-windows.ps1" in text + assert "telecli-wsl start" in text From ea9b035ab7d7cfdc497c89834ec260a478906ba4 Mon Sep 17 00:00:00 2001 From: andrey Date: Fri, 20 Mar 2026 18:46:23 -0400 Subject: [PATCH 2/2] fix: harden installer secret handling --- install-windows.ps1 | 2 +- scripts/install-linux.sh | 9 ++++- scripts/install-wsl.sh | 9 ++++- tests/test_installers.py | 80 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/install-windows.ps1 b/install-windows.ps1 index 8d891d3..119d869 100644 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -2,7 +2,7 @@ param( [string]$Distro = "", [string]$RepoUrl = "https://github.com/malandr/telecli.git", [string]$Ref = "main", - [string]$Prefix = '$HOME/.local/share/telecli', + [string]$Prefix = '~/.local/share/telecli', [switch]$SkipSystemPackages ) diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh index 3fe2b39..14d6146 100755 --- a/scripts/install-linux.sh +++ b/scripts/install-linux.sh @@ -369,13 +369,18 @@ configure_env_file() { set_env_value AI_PROXY_PROVIDER "${ai_proxy_provider}" if [ -n "${generated_auth_token}" ]; then - log "Generated AUTH_TOKEN: ${generated_auth_token}" + log "Generated AUTH_TOKEN and stored it in ${ENV_FILE}. Keep this token secret and do not share it." fi } ensure_env_file() { if [ -f "${ENV_FILE}" ]; then log "Keeping existing ${ENV_FILE}" + if is_dry_run; then + log "[dry-run] chmod 600 ${ENV_FILE}" + return 0 + fi + chmod 600 "${ENV_FILE}" return 0 fi @@ -384,11 +389,13 @@ ensure_env_file() { if auto_config; then log "[dry-run] apply TELECLI_INSTALL_* overrides to ${ENV_FILE}" fi + log "[dry-run] chmod 600 ${ENV_FILE}" return 0 fi cp "${PREFIX}/.env.sample" "${ENV_FILE}" configure_env_file + chmod 600 "${ENV_FILE}" } install_startup_service() { diff --git a/scripts/install-wsl.sh b/scripts/install-wsl.sh index eb80451..3da1fe9 100755 --- a/scripts/install-wsl.sh +++ b/scripts/install-wsl.sh @@ -369,13 +369,18 @@ configure_env_file() { set_env_value AI_PROXY_PROVIDER "${ai_proxy_provider}" if [ -n "${generated_auth_token}" ]; then - log "Generated AUTH_TOKEN: ${generated_auth_token}" + log "Generated AUTH_TOKEN and stored it in ${ENV_FILE}. Keep this token secret and do not share it." fi } ensure_env_file() { if [ -f "${ENV_FILE}" ]; then log "Keeping existing ${ENV_FILE}" + if is_dry_run; then + log "[dry-run] chmod 600 ${ENV_FILE}" + return 0 + fi + chmod 600 "${ENV_FILE}" return 0 fi @@ -384,11 +389,13 @@ ensure_env_file() { if auto_config; then log "[dry-run] apply TELECLI_INSTALL_* overrides to ${ENV_FILE}" fi + log "[dry-run] chmod 600 ${ENV_FILE}" return 0 fi cp "${PREFIX}/.env.sample" "${ENV_FILE}" configure_env_file + chmod 600 "${ENV_FILE}" } install_startup_service() { diff --git a/tests/test_installers.py b/tests/test_installers.py index 00c1b48..5ef0a08 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -4,6 +4,8 @@ import os import subprocess +import pytest + REPO_ROOT = Path(__file__).resolve().parents[1] @@ -225,6 +227,80 @@ def test_install_linux_script_can_seed_env_from_answers(tmp_path): assert "AI_PROXY_PROVIDER=claude-cli" in env_text +@pytest.mark.parametrize( + ("script_name", "launcher_name"), + [ + ("install-linux.sh", "telecli"), + ("install-wsl.sh", "telecli-wsl"), + ], +) +def test_installers_keep_env_private_and_do_not_echo_generated_auth_token(tmp_path, script_name, launcher_name): + """Generated secrets should stay in the env file, and launcher usage should render the real command name.""" + script = REPO_ROOT / "scripts" / script_name + source_repo = tmp_path / f"source-repo-{launcher_name}" + _init_fake_repo(source_repo) + + fake_bin = tmp_path / f"bin-{launcher_name}" + fake_bin.mkdir() + _make_fake_python3(fake_bin) + + prefix = tmp_path / f"install-root-{launcher_name}" + bin_dir = tmp_path / f"launcher-bin-{launcher_name}" + state_dir = tmp_path / f"state-{launcher_name}" + + completed = subprocess.run( + [ + "/bin/bash", + str(script), + "--repo-url", + str(source_repo), + "--ref", + "main", + "--prefix", + str(prefix), + "--bin-dir", + str(bin_dir), + "--state-dir", + str(state_dir), + "--skip-system-packages", + ], + capture_output=True, + text=True, + check=False, + env={ + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "TELECLI_AUTO_CONFIG": "1", + "TELECLI_INSTALL_AUTH_REQUIRED": "true", + }, + ) + + assert completed.returncode == 0, completed.stderr + + env_file = prefix / ".env" + env_text = env_file.read_text(encoding="utf-8") + auth_token_line = next(line for line in env_text.splitlines() if line.startswith("AUTH_TOKEN=")) + auth_token = auth_token_line.split("=", 1)[1] + + assert auth_token + assert auth_token != "your_auth_token_here" + assert (env_file.stat().st_mode & 0o777) == 0o600 + assert auth_token not in completed.stdout + assert auth_token not in completed.stderr + + launcher = bin_dir / launcher_name + invalid = subprocess.run( + [str(launcher), "bogus"], + capture_output=True, + text=True, + check=False, + ) + + assert invalid.returncode == 1 + assert f"Usage: {launcher_name} {{start|stop|restart|status|logs|url}}" in invalid.stderr + assert "${LAUNCHER_NAME}" not in invalid.stderr + + def test_install_linux_script_can_enable_startup_service(tmp_path): """Opting into startup should create and enable a user systemd service.""" script = REPO_ROOT / "scripts" / "install-linux.sh" @@ -289,10 +365,14 @@ def test_windows_installer_bootstraps_wsl_install_script(): installer = REPO_ROOT / "install-windows.ps1" text = installer.read_text(encoding="utf-8") + assert "[string]$Prefix = '~/.local/share/telecli'" in text assert "wsl.exe" in text assert "Invoke-WebRequest" in text assert "install-wsl.sh" in text assert "telecli-wsl start" in text + assert "--prefix" in text + assert "$HOME/.local/share/telecli" not in text + def test_release_workflow_publishes_wsl_assets():