feat: add Linux and WSL installers#11
Conversation
There was a problem hiding this comment.
Pull request overview
Adds cross-platform installer entrypoints (Linux + WSL, plus a Windows bootstrapper for WSL) and release/README scaffolding so TeleCLI can be installed via dedicated scripts and shipped as tagged-release assets.
Changes:
- Add
scripts/install-linux.shandscripts/install-wsl.shwith guided.envseeding, optional user systemd startup, andtelecli/telecli-wsllaunchers. - Add
install-windows.ps1to download and run the WSL installer viawsl.exe. - Add a tag-driven GitHub Release workflow and README + tests to validate installer/release/docs scaffolding.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
scripts/install-linux.sh |
New Linux installer + launcher generation + optional systemd user service. |
scripts/install-wsl.sh |
New WSL installer mirroring Linux flow with telecli-wsl launcher. |
install-windows.ps1 |
Windows bootstrapper to download and run the WSL installer through wsl.exe. |
README.md |
Installation docs for Linux and Windows (WSL2) flows. |
.github/workflows/release.yml |
GitHub Actions workflow to publish installer assets and a source bundle on tags. |
tests/test_installers.py |
Tests for help output, dry-run planning, env seeding, systemd service creation, workflow and README checks. |
| fi | ||
| return 0 | ||
| fi | ||
|
|
||
| cp "${PREFIX}/.env.sample" "${ENV_FILE}" | ||
| configure_env_file |
There was a problem hiding this comment.
The installer writes ${ENV_FILE} containing secrets (e.g. AUTH_TOKEN, TELEGRAM_BOT_TOKEN) but doesn't set restrictive permissions. On systems with a permissive umask this can leave .env world-readable. Consider explicitly setting permissions (e.g. chmod 600 "${ENV_FILE}") after creating/updating it.
| fi | |
| return 0 | |
| fi | |
| cp "${PREFIX}/.env.sample" "${ENV_FILE}" | |
| configure_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}" |
There was a problem hiding this comment.
Fixed in ea9b035. ensure_env_file() now applies chmod 600 to ${ENV_FILE} for both existing and newly created env files, and the dry-run path logs the permission step as well. Added execution-level coverage that checks the generated .env ends up with mode 0600.
| fi | ||
|
|
||
| cp "${PREFIX}/.env.sample" "${ENV_FILE}" | ||
| configure_env_file |
There was a problem hiding this comment.
The installer writes ${ENV_FILE} containing secrets (e.g. AUTH_TOKEN, TELEGRAM_BOT_TOKEN) but doesn't set restrictive permissions. On systems with a permissive umask this can leave .env world-readable. Consider explicitly setting permissions (e.g. chmod 600 "${ENV_FILE}") after creating/updating it.
| configure_env_file | |
| configure_env_file | |
| chmod 600 "${ENV_FILE}" |
There was a problem hiding this comment.
Fixed in ea9b035. The WSL installer now also applies chmod 600 to ${ENV_FILE} after creation and when preserving an existing env file, with matching dry-run output. The new integration-style installer test covers the resulting file mode.
|
|
||
| assert "wsl.exe" in text | ||
| assert "Invoke-WebRequest" in text | ||
| assert "install-wsl.sh" in text | ||
| assert "telecli-wsl start" in text | ||
|
|
There was a problem hiding this comment.
This test only asserts that install-windows.ps1 contains certain strings; it doesn't validate any behavior (e.g., that the default --prefix passed to WSL is a valid WSL path and not a literal $HOME string). Consider adding an execution-level test (or at least a stricter assertion) that would catch broken defaults/argument wiring in the bootstrapper.
| assert "wsl.exe" in text | |
| assert "Invoke-WebRequest" in text | |
| assert "install-wsl.sh" in text | |
| assert "telecli-wsl start" in text | |
| # Basic wiring: the Windows installer should fetch and invoke the WSL installer. | |
| assert "wsl.exe" in text | |
| assert "Invoke-WebRequest" in text | |
| assert "install-wsl.sh" in text | |
| assert "telecli-wsl start" in text | |
| # Stricter check: ensure the bootstrapper actually passes a --prefix argument | |
| # and does not forward a literal $HOME bash variable into WSL, which would | |
| # indicate broken default/argument wiring. | |
| assert "--prefix" in text | |
| assert "--prefix \"$HOME\"" not in text | |
| assert "--prefix '$HOME'" not in text | |
| assert "$HOME" not in text |
There was a problem hiding this comment.
Fixed in ea9b035. I tightened the Windows bootstrap test to assert the --prefix wiring now uses the WSL-valid ~/.local/share/telecli default and no longer embeds the literal $HOME form. I also added an execution-level installer test for both Linux and WSL that validates generated env permissions, secret-safe token output, and the generated launcher behavior.
| url | ||
| ;; | ||
| *) | ||
| printf 'Usage: ${LAUNCHER_NAME} {start|stop|restart|status|logs|url}\n' >&2 |
There was a problem hiding this comment.
The generated launcher script prints Usage: ${LAUNCHER_NAME} ... using single quotes, and the launcher script itself never defines LAUNCHER_NAME, so the error output will literally show ${LAUNCHER_NAME} instead of telecli. Consider hardcoding the launcher name into the generated script (or defining LAUNCHER_NAME inside the launcher) and using double quotes so it expands correctly.
| printf 'Usage: ${LAUNCHER_NAME} {start|stop|restart|status|logs|url}\n' >&2 | |
| printf 'Usage: telecli {start|stop|restart|status|logs|url}\n' >&2 |
There was a problem hiding this comment.
Checked this one against the generated launcher rather than only the template. The installer heredoc expands ${LAUNCHER_NAME} at generation time, so the produced launcher already prints Usage: telecli ... on invalid input. I left the production code as-is and added execution-level coverage in ea9b035 that runs the generated launcher and asserts the stderr uses telecli, not ${LAUNCHER_NAME}.
| url | ||
| ;; | ||
| *) | ||
| printf 'Usage: ${LAUNCHER_NAME} {start|stop|restart|status|logs|url}\n' >&2 |
There was a problem hiding this comment.
The generated launcher script prints Usage: ${LAUNCHER_NAME} ... using single quotes, and the launcher script itself never defines LAUNCHER_NAME, so the error output will literally show ${LAUNCHER_NAME} instead of telecli-wsl. Consider hardcoding the launcher name into the generated script (or defining LAUNCHER_NAME inside the launcher) and using double quotes so it expands correctly.
| printf 'Usage: ${LAUNCHER_NAME} {start|stop|restart|status|logs|url}\n' >&2 | |
| printf 'Usage: telecli-wsl {start|stop|restart|status|logs|url}\n' >&2 |
There was a problem hiding this comment.
Same conclusion here: the generated WSL launcher already renders Usage: telecli-wsl ... because ${LAUNCHER_NAME} is expanded by the installer when writing the script. I added execution-level coverage in ea9b035 that invokes the generated telecli-wsl launcher with a bad subcommand and asserts the real stderr output.
| [string]$Distro = "", | ||
| [string]$RepoUrl = "https://github.com/malandr/telecli.git", | ||
| [string]$Ref = "main", | ||
| [string]$Prefix = '$HOME/.local/share/telecli', |
There was a problem hiding this comment.
$Prefix defaults to the literal string '$HOME/.local/share/telecli', so the WSL-side bash installer receives an unexpanded $HOME path and will install into a directory literally named $HOME (relative to the working dir). Consider defaulting $Prefix to empty and omitting --prefix unless explicitly provided, or using a WSL-valid default like ~/.local/share/telecli.
| [string]$Prefix = '$HOME/.local/share/telecli', | |
| [string]$Prefix = '~/.local/share/telecli', |
There was a problem hiding this comment.
Fixed in ea9b035. The PowerShell bootstrapper now defaults $Prefix to ~/.local/share/telecli, so the WSL-side installer receives a shell-valid home-relative path instead of the literal $HOME/... string.
| set_env_value AI_PROXY_PROVIDER "${ai_proxy_provider}" | ||
|
|
||
| if [ -n "${generated_auth_token}" ]; then | ||
| log "Generated AUTH_TOKEN: ${generated_auth_token}" |
There was a problem hiding this comment.
configure_env_file logs the newly generated AUTH_TOKEN to stdout. This exposes a secret in terminal output (which may be captured in CI logs, shell scrollback, or transcripts). Consider avoiding printing the token value; instead print a message telling the user where it was written (e.g. ${ENV_FILE}) or only show it when explicitly requested via a flag.
| log "Generated AUTH_TOKEN: ${generated_auth_token}" | |
| log "Generated AUTH_TOKEN and stored it in ${ENV_FILE}" |
There was a problem hiding this comment.
Fixed in ea9b035. The Linux installer no longer prints the generated AUTH_TOKEN value; it now logs only that a token was generated and written to ${ENV_FILE}. The new execution-level test extracts the token from the generated .env and asserts it never appears in stdout/stderr.
| set_env_value AI_PROXY_PROVIDER "${ai_proxy_provider}" | ||
|
|
||
| if [ -n "${generated_auth_token}" ]; then | ||
| log "Generated AUTH_TOKEN: ${generated_auth_token}" |
There was a problem hiding this comment.
configure_env_file logs the newly generated AUTH_TOKEN to stdout. This exposes a secret in terminal output (which may be captured in CI logs, shell scrollback, or transcripts). Consider avoiding printing the token value; instead print a message telling the user where it was written (e.g. ${ENV_FILE}) or only show it when explicitly requested via a flag.
| log "Generated AUTH_TOKEN: ${generated_auth_token}" | |
| log "Generated AUTH_TOKEN and saved it to ${ENV_FILE}. Keep this token secret and do not share it." |
There was a problem hiding this comment.
Fixed in ea9b035. The WSL installer now mirrors the Linux behavior and logs only that the generated token was written to ${ENV_FILE}, without echoing the secret value. The new integration-style test covers both installers.
| $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 |
There was a problem hiding this comment.
Invoke-WebRequest -UseBasicParsing is not supported in PowerShell 6+/7 (it was specific to Windows PowerShell 5.1). This will break installs on systems where users run pwsh. Consider removing -UseBasicParsing or conditionally adding it only when it’s available.
| $wslInstaller = Invoke-WebRequest -UseBasicParsing -Uri $scriptUrl | Select-Object -ExpandProperty Content | |
| $wslInstaller = Invoke-WebRequest -Uri $scriptUrl | Select-Object -ExpandProperty Content |
| --prefix) | ||
| PREFIX="$2" | ||
| shift 2 | ||
| ;; |
There was a problem hiding this comment.
--prefix arguments coming from non-shell callers (notably the Windows bootstrapper) may include a leading ~, but bash won’t perform tilde expansion on values stored in variables. With PREFIX="$2", a value like ~/.local/share/telecli will be treated as a literal path. Consider normalizing PREFIX (and similarly BIN_DIR/STATE_DIR) after parsing options by expanding a leading ~ to $HOME.
| 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 |
There was a problem hiding this comment.
sync_repo skips git pull when REF matches v*, but that also matches legitimate branch names that start with v (e.g. vNext). That can leave an existing install stuck on an old commit even though it’s meant to track a branch. Consider using an explicit tag check (e.g. verifying refs/tags/$REF) instead of a broad v* heuristic.
| if [[ "${REF}" != refs/tags/* ]] && [[ "${REF}" != v* ]]; then | |
| # Only pull when REF is not a tag. Treat both fully-qualified | |
| # refs/tags/* and lightweight tag names as tags. | |
| if ! git -C "${PREFIX}" show-ref --verify --quiet "refs/tags/${REF}" && [[ "${REF}" != refs/tags/* ]]; then |
| 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 | ||
|
|
||
| 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 | ||
| 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() { | ||
| local service_content | ||
|
|
||
| if [ "${START_AT_STARTUP}" != "true" ]; then | ||
| return 0 | ||
| fi |
There was a problem hiding this comment.
TELECLI_INSTALL_START_AT_STARTUP is normalized inside configure_env_file, but ensure_env_file returns early when ${ENV_FILE} already exists and never calls configure_env_file. That means rerunning the installer with TELECLI_INSTALL_START_AT_STARTUP=true won’t actually enable the startup service if a .env is already present. Consider parsing/normalizing START_AT_STARTUP outside of configure_env_file (before ensure_env_file), so startup behavior can be controlled independently of whether .env already exists.
| 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 |
There was a problem hiding this comment.
sync_repo skips git pull when REF matches v*, which will also match branch names starting with v and can prevent expected updates for an existing installation tracking a branch. Consider replacing the v* heuristic with an explicit tag check (e.g. verify refs/tags/$REF) so only real tags skip the pull.
| if [[ "${REF}" != refs/tags/* ]] && [[ "${REF}" != v* ]]; then | |
| # Determine if REF refers to a tag; only skip pull for real tags | |
| local is_tag="false" | |
| if [[ "${REF}" == refs/tags/* ]]; then | |
| is_tag="true" | |
| elif git -C "${PREFIX}" show-ref --verify --quiet "refs/tags/${REF}"; then | |
| is_tag="true" | |
| fi | |
| if [ "${is_tag}" != "true" ]; then |
|
|
||
| if [ "${START_AT_STARTUP}" != "true" ]; then |
There was a problem hiding this comment.
TELECLI_INSTALL_START_AT_STARTUP is only applied inside configure_env_file, but ensure_env_file returns early when .env already exists and never calls configure_env_file. As a result, rerunning the installer to enable startup (or to change startup choice) won’t work unless the user deletes .env first. Consider normalizing START_AT_STARTUP outside of env-file configuration so the startup-service decision is independent of whether .env exists.
| if [ "${START_AT_STARTUP}" != "true" ]; then | |
| local effective_start_at_startup | |
| # Determine effective START_AT_STARTUP value, preferring TELECLI_INSTALL_START_AT_STARTUP if set. | |
| effective_start_at_startup="${START_AT_STARTUP}" | |
| if [ -n "${TELECLI_INSTALL_START_AT_STARTUP:-}" ]; then | |
| effective_start_at_startup="${TELECLI_INSTALL_START_AT_STARTUP}" | |
| fi | |
| if [ "${effective_start_at_startup}" != "true" ]; then |
| 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 |
There was a problem hiding this comment.
This test enforces a default $Prefix of ~/.local/share/telecli in install-windows.ps1, but ~ won’t be expanded when passed as an argv value into the WSL-side bash installer (it will be treated as a literal directory name). After fixing the bootstrapper behavior (e.g. omit --prefix unless explicitly provided, or pass an already-expanded absolute path), update this test to assert the corrected default wiring so it would catch regressions.
| 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 | |
| # The Windows bootstrapper should not hard-code a tilde- or $HOME-based prefix | |
| # that would be passed directly to the WSL-side installer, since bash would | |
| # treat those as literal paths instead of expanding them. | |
| assert "'~/.local/share/telecli'" not in text | |
| assert "$HOME/.local/share/telecli" not in text | |
| assert "wsl.exe" in text | |
| assert "Invoke-WebRequest" in text | |
| assert "install-wsl.sh" in text | |
| assert "telecli-wsl start" in text | |
| # After fixing the bootstrapper, --prefix should not be unconditionally wired | |
| # into the WSL installer invocation. | |
| assert "--prefix" not in text |
| [string]$RepoUrl = "https://github.com/malandr/telecli.git", | ||
| [string]$Ref = "main", | ||
| [string]$Prefix = '~/.local/share/telecli', | ||
| [switch]$SkipSystemPackages |
There was a problem hiding this comment.
The Windows bootstrapper defaults $Prefix to "~/.local/share/telecli" and always passes it through to the bash installer. In this execution path there is no Linux shell to expand ~, so the WSL-side script will treat it as a literal directory name (e.g. install into a ~ folder) rather than the user’s home directory. Consider either omitting --prefix unless the user explicitly supplies one (letting the bash script use its $HOME-based default), or passing an already-expanded absolute WSL path.
Summary
.envsetup, optional usersystemdstartup, and launcher commands fortelecli/telecli-wslTest Plan
source /home/andrey/projects/telecli/venv/bin/activate && pytest -q tests/test_installers.py tests/test_readme.py tests/test_run_web.pyNotes
tests/test_llm_integration.pyaroundAIProxy(... fallback_providers=...); this PR does not change that area