diff --git a/.gitattributes b/.gitattributes index 13da7fbe..918d4592 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,17 +3,17 @@ # Handle line endings automatically for files detected as text # and leave all files detected as binary untouched. # ============ -* text=auto +* text=auto eol=lf # Source files # ============ -*.pxd text eol=crlf diff=python -*.py text eol=crlf diff=python -*.py3 text eol=crlf diff=python -*.pyw text eol=crlf diff=python -*.pyx text eol=crlf diff=python -*.pyz text eol=crlf diff=python -*.pyi text eol=crlf diff=python +*.pxd text diff=python +*.py text diff=python +*.py3 text diff=python +*.pyw text diff=python +*.pyx text diff=python +*.pyz text diff=python +*.pyi text diff=python # Binary files # ============ @@ -26,7 +26,8 @@ *.pyo binary # MarkDown -*.md text eol=crlf +*.md text *.bat text eol=crlf -*.ps1 text eol=crlf \ No newline at end of file +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.gitignore b/.gitignore index eaa1cb4e..dfab293e 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,14 @@ plugins/webui/frontend/test-results/ docs documentation + +# Temporary files +tmp +temp +*.bak + +# AI +.omo/ +.opencode/ +AGENTS.md +CONTEXT.md diff --git a/README.md b/README.md index c4c8d85c..80e0f96f 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,408 @@ -

- RT-Thread Env -

+
-# RT-Thread Env +RT-Thread Env -> A command-line toolkit for RT-Thread development. +# RT-Thread Env Development Environment -> WARNING -> -> [env v2.0](https://github.com/RT-Thread/env/tree/master) and [env-windows v2.0](https://github.com/RT-Thread/env-windows/tree/v2.0.0) only **FULL SUPPORT** RT-Thread > v5.1.0 or [master](https://github.com/rt-thread/rt-thread) branch. if you work on RT-Thread <= v5.1.0, please use [env v1.5.x](https://github.com/RT-Thread/env/tree/v1.5.x) for linux, [env-windows v1.5.x](https://github.com/RT-Thread/env-windows/tree/v1.5.2) for windows -> -> env v2.0 has made the following important changes: +[![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0-blue.svg)](LICENSE) +[![Python](https://img.shields.io/badge/Python-3.6+-green.svg)](https://www.python.org/) +[![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)]() + +**[简体中文](README_ZH.md) | English** + +
+ +--- + +## ⚠️ Version Compatibility Notice + +| RT-Thread Version | Recommended Env Version | +|-------------------|------------------------| +| **> v5.1.0** or **master branch** | ✅ **env v2.0** (current version) | +| **≤ v5.1.0** | ⚠️ [env v1.5.x](https://github.com/RT-Thread/env/tree/v1.5.x) (Linux) / [env-windows v1.5.2](https://github.com/RT-Thread/env-windows/tree/v1.5.2) (Windows) | + +### 🔄 Key Changes in v2.0 + +- ✨ **Python Version Upgrade**: Upgraded from Python 2 to Python 3 +- 🔧 **Configuration System Refactor**: Replaced kconfig-frontends with Python kconfiglib +- 📦 **Dependency Management Optimization**: Installer automatically handles kconfiglib dependency + +> **Note**: env v2.0 is incompatible with kconfiglib from env v1.5.x. If you need to switch versions, please run `pip uninstall kconfiglib` first. + +--- + +## 📚 Table of Contents + +- [🚀 Quick Start](#-quick-start) + - [Windows Installation](#windows-installation) + - [Linux/macOS Installation](#linuxmacos-installation) +- [⚙️ Installation Script Parameters](#️-installation-script-parameters) +- [💡 Usage Guide](#-usage-guide) +- [❓ Troubleshooting](#-troubleshooting) +- [📖 Resources](#-resources) + +--- + +## 🚀 Quick Start + +### Windows Installation + +#### Prerequisites + +| Item | Requirements | +|------|-------------| +| **Privileges** | 📋 First installation requires **administrator privileges** (to set execution policy and long path support)
Subsequent updates can use normal user privileges | +| **PowerShell** | ✅ Windows PowerShell v5.1+
✅ PowerShell 7+ | + +#### Encoding Compatibility + +> ⚠️ **Important Notice** > -> - Upgrading Python version from v2 to v3 -> - Replacing kconfig-frontends with Python kconfiglib +> | PowerShell Version | Encoding Support | Notes | +> |-------------------|------------------|-------| +> | Windows PowerShell (v5.1) | ⚠️ GB2312 default, requires UTF-8 with BOM | Chinese systems require special handling | +> | PowerShell 7+ | ✅ Native UTF-8 | No encoding issues | > -> env v2.0 require python kconfiglib (install by `pip install kconfiglib`), but env v1.5.x confilt with kconfiglib (please run `pip uninstall kconfiglib`) +> Installation scripts are configured as UTF-8 with BOM encoding to ensure compatibility. + +#### Installation Commands + +
+🌐 International Users (Official Source) + +```powershell +Set-ExecutionPolicy -ExecutionPolicy Bypass Process; irm https://raw.githubusercontent.com/RT-Thread/env/master/tools/install.ps1 | Out-File -Encoding utf8 .\install.ps1; .\install.ps1; Remove-Item .\install.ps1 +``` + +
+ +
+🇨🇳 Users in China (Mirror Source) + +```powershell +Set-ExecutionPolicy -ExecutionPolicy Bypass Process; irm https://gitee.com/RT-Thread-Mirror/env/raw/master/tools/install.ps1 | Out-File -Encoding utf8 .\install.ps1; .\install.ps1; Remove-Item .\install.ps1 +``` + +
+ +#### Notes + +> 💡 **Tip**: Installation script automatically selects mirror sources based on geographic location. To explicitly specify, use `--cn` or `--official` parameters. See [Installation Script Parameters](#️-installation-script-parameters). -## Usage under Linux +> ⚠️ **Important**: +> - ✅ First installation requires administrator privileges for execution policy and long path support +> - 🦠 Antivirus software may block installation, please temporarily disable if needed -### Tutorial +#### Activate Environment -[How to install Env Tool with QEMU simulator in Ubuntu](https://github.com/RT-Thread/rt-thread/blob/master/documentation/quick-start/quick_start_qemu/quick_start_qemu_linux.md) +After installation, you need to activate environment variables to use the tools. -### Install Env +
+🔧 Option A: Manual Activation (Temporary) +Run the following command each time you start a new PowerShell session: + +```powershell +. ~/.rt-env/env.ps1 ``` -# 中国大陆网络: -wget https://gitee.com/RT-Thread-Mirror/env/raw/master/install_ubuntu.sh -# 其他地区网络: -wget https://raw.githubusercontent.com/RT-Thread/env/master/install_ubuntu.sh +
+ +
+⭐ Option B: Auto Activation (Recommended) -chmod 777 install_ubuntu.sh -./install_ubuntu.sh -rm install_ubuntu.sh +Add the activation command to your PowerShell configuration file: + +```powershell +# Open configuration file (creates if it doesn't exist) +notepad $PROFILE + +# Add the following line: +. ~/.rt-env/env.ps1 ``` -请根据自身网络地区选择对应的下载地址来下载安装脚本。安装脚本会自动识别网络区域,使用相应镜像下载后续仓库,完成后将仓库远程地址统一设为 GitHub。 +**Configuration File Paths:** + +| PowerShell Version | Configuration File Path | +|-------------------|------------------------| +| Windows PowerShell (v5.1) | `C:\Users\\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1` | +| PowerShell 7+ | `C:\Users\\Documents\PowerShell\Microsoft.PowerShell_profile.ps1` | -### Prepare Env +After adding, the environment will be automatically activated each time you open PowerShell. -Run `source ~/.env/env.sh` to activate Env. The script creates a missing Python -virtual environment and always attempts to activate it. When the local -`tools/scripts` source changes, it offers to reinstall Env into the venv and -synchronize the activation script. To activate Env automatically, add this -command to `~/.bashrc`. +
-The upgrade check is local and does not fetch the Env Git repository. Python -packages use the Alibaba Cloud PyPI mirror when a mainland China IP is detected; -other regions and detection failures use pip's configured default. Set -`ENV_PYPI_INDEX_URL` to override the package index or -`ENV_VENV_AUTO_UPGRADE=1` to accept a pending local-source upgrade without a -prompt. +### Linux/macOS Installation -### Use Env +#### Installation Commands -Please see: [https://github.com/RT-Thread/rt-thread/blob/master/documentation/env/env.md#bsp-configuration-menuconfig](https://github.com/RT-Thread/rt-thread/blob/master/documentation/env/env.md#bsp-configuration-menuconfig) +
+🌐 International Users (Official Source) -## Usage under Windows +```bash +bash -c "$(wget https://raw.githubusercontent.com/RT-Thread/env/master/tools/install.sh -O -)" +``` -Tested on the following version of PowerShell: +
-- PSVersion 5.1.22621.963 -- PSVersion 5.1.19041.2673 +
+🇨🇳 Users in China (Mirror Source) -### Install Env +```bash +bash -c "$(wget https://gitee.com/RT-Thread-Mirror/env/raw/master/tools/install.sh -O -)" -- --cn +``` + +
+ +#### Notes + +> 💡 **Tips**: +> - Installation script automatically selects mirror sources based on geographic location +> - Linux systems automatically use `sudo` to elevate privileges for installing dependencies, no need to manually run as root +> - Supports installation with normal user privileges, script automatically handles privilege elevation + +#### Activate Environment + +
+🔧 Option A: Manual Activation (Temporary) + +Run the following command each time you open a new terminal: + +```bash +source ~/.rt-env/env.sh +``` -您需要以管理员身份运行 PowerShell 来设置执行。(You need to run PowerShell as an administrator to set up execution.) +
-在 PowerShell 中执行(Execute the command in PowerShell): +
+⭐ Option B: Auto Activation (Recommended) + +Add the activation command to your shell configuration file: + +```bash +# For bash +echo 'source ~/.rt-env/env.sh' >> ~/.bashrc + +# For zsh +echo 'source ~/.rt-env/env.sh' >> ~/.zshrc +``` + +After adding, the environment will be automatically activated each time you log in. + +
+ +#### 📚 Related Tutorials + +- [Install Env with QEMU Simulator in Ubuntu](https://github.com/RT-Thread/rt-thread/blob/master/documentation/quick-start/quick_start_qemu/quick_start_qemu_linux.md) + +--- + +## ⚙️ Installation Script Parameters + +| Parameter | Description | +|-----------|-------------| +| **Basic Parameters**|| +| `--yes`, `--auto` | Automatic installation, no interaction | +| `-h`, `--help` | Display help information | +|**Source Settings**|| +| `--cn`, `--gitee` | Use China mirror sources (Gitee, PyPI TUNA) | +| `--official` | Force use of official sources | +| **Repository Configuration**|| +| `--packages [#]` | Specify packages repository address and branch | +| `--env [#]` | Specify env repository address and branch | +| `--sdk [#]` | Specify sdk repository address and branch | +| `--touch-env ` | Specify touch_env.py download URL | +|**Path & Installation**|| +| `--env-root ` | Set custom .rt-env directory path (default: `~/.rt-env`) | +| `--python [path]` | Install portable Python, installation directory is path (Windows only, default: D:\Tools\Python) | +|**Other Options**|| +| `--lang ` | Force message language | +| `--keep-sdk ` | Keep toolchains (local_pkgs) and config when reinstalling (default: `yes`, prompt if omitted) | + +### Usage Examples + +
+💻 Windows (PowerShell) ```powershell -wget https://raw.githubusercontent.com/RT-Thread/env/master/install_windows.ps1 -O install_windows.ps1 -set-executionpolicy remotesigned -.\install_windows.ps1 +# Basic installation +.\install.ps1 + +# Use China mirror + automatic installation +.\install.ps1 --cn --yes + +# Install portable Python + custom path +.\install.ps1 --python "D:\Tools\Python" --env-root "D:\RT-Env" + +# Specify custom env repository branch +.\install.ps1 --env "https://github.com/RT-Thread/env.git#master" + +# Official source + fresh install (remove existing toolchains) +.\install.ps1 --official --keep-sdk no +``` + +
+ +
+🐧 Linux/macOS (bash) + +```bash +# Basic installation +./install.sh + +# Use China mirror + automatic installation +./install.sh --cn --yes + +# Specify custom packages repository +./install.sh --packages "https://gitee.com/RT-Thread-Mirror/packages.git#master" + +# Keep existing toolchains while reinstalling +./install.sh --keep-sdk yes + +# Specify custom sdk repository +./install.sh --sdk "https://github.com/RT-Thread/sdk.git#master" ``` -安装脚本会自动识别网络区域,使用相应镜像下载仓库,完成后将仓库远程地址统一设为 GitHub。 +
+ +--- + +## 💡 Usage Guide + +After installation completes, follow these steps to start using RT-Thread ENV: + +### Step 1️⃣: Activate Environment + +
+💻 Windows + +```powershell +. ~/.rt-env/env.ps1 +``` + +
+ +
+🐧 Linux/macOS + +```bash +source ~/.rt-env/env.sh +``` + +
+ +> 💡 **Tip**: For auto-activation on startup, please refer to the auto-activation options in the installation section. + +### Step 2️⃣: Install Toolchains + +Run the `sdk` command to install required toolchains for your development board: + +```bash +sdk +``` + +### Step 3️⃣: Use Commands + +After activation, you can use the following commands: + +| Command | Function | Description | +|---------|----------|-------------| +| `menuconfig` | ⚙️ Configure Project | Configure RT-Thread kernel, BSP | +| `menuconfig -s` | 🔧 Configure ENV | Configure packages, tools | +| `pkgs` | 📦 Package Manager | Update, upgrade packages | +| `sdk` | 🛠️ Toolchain Manager | Install development toolchains | +| `scons` | 🔨 Build Project | Build project | + +### Step 4️⃣: Additional Tools + +**pyocd** - For debugging Cortex-M devices, installed by default with the environment (no manual step needed). To upgrade manually: + +```bash +pip install --upgrade pyocd +``` + +### 📚 Detailed Documentation + +- [Env Tool Usage Guide](https://github.com/RT-Thread/rt-thread/blob/master/documentation/env/env.md) +- [Env Official User Manual](https://www.rt-thread.org/document/site/#/development-tools/env/env) + +--- + +## ❓ Troubleshooting + +### 🌐 Network & Mirror Issues + +**Problem: Slow download or failure** + +- ✅ Use `--cn` parameter to enable Gitee mirror +- ✅ Check network connection +- ✅ Try switching network environment (e.g., using VPN) + +### 🔐 Permission Issues + +#### Linux/macOS + +Linux installation script automatically uses `sudo` for privilege elevation, usually no manual handling needed. + +If you encounter permission issues: + +```bash +# Check .rt-env directory permissions +ls -la ~/.rt-env + +# If directory belongs to root, change ownership +sudo chown -R $USER:$USER ~/.rt-env +``` + +#### Windows + +If you encounter permission errors: + +1. ✅ Check if antivirus software is blocking installation +2. ✅ Run PowerShell as administrator +3. ✅ Ensure execution policy allows script running + +### 📝 Other Issues + +If you encounter other issues, please: + +- 📖 Check [Env Tool Complete Documentation](https://github.com/RT-Thread/rt-thread/blob/master/documentation/env/env.md) +- 🐛 Submit issue on [GitHub Issues](https://github.com/RT-Thread/env/issues) +- 💬 Join [RT-Thread Forum](https://www.rt-thread.org/qa/forum.html) for help + +--- + +## 📖 Resources + +### Official Documentation + +- [Env Tool Complete Documentation](https://github.com/RT-Thread/rt-thread/blob/master/documentation/env/env.md) +- [QEMU Quick Start](https://github.com/RT-Thread/rt-thread/blob/master/documentation/quick-start/quick_start_qemu/quick_start_qemu_linux.md) +- [BSP Configuration Guide](https://github.com/RT-Thread/rt-thread/blob/master/documentation/env/env.md#bsp-configuration-menuconfig) + +### Related Links + +| Type | Link | Mirror | +|------|------|--------| +| 📦 Env Repository | [GitHub](https://github.com/RT-Thread/env) | [Gitee](https://gitee.com/RT-Thread-Mirror/env) | +| ![RT-Thread](https://www.rt-thread.org/favicon.ico) RT-Thread Repository | [GitHub](https://github.com/RT-Thread/rt-thread) | [Gitee](https://gitee.com/rtthread/rt-thread) | +| 🌐 Official Website | [RT-Thread Website](https://www.rt-thread.org/) || +| 📚 Documentation | [RT-Thread Docs (EN)](https://www.rt-thread.io/document/site/) | [RT-Thread Docs (CN)](https://www.rt-thread.org/document/site/#/) | + +### License + +[![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0-blue.svg)](LICENSE) + +This project is open-sourced under the **GPL-2.0** license. + +--- + +
-注意: +## 🤝 Contributors -1. Powershell要以管理员身份运行。 -2. 将其设置为 remotesigned 后,您可以作为普通用户运行 PowerShell。( After setting it to remotesigned, you can run PowerShell as a normal user.) -3. 一定要关闭杀毒软件,否则安装过程可能会被杀毒软件强退 +Thanks to all developers who have contributed to the RT-Thread Env project! -### Prepare Env +[![Contributors](https://img.shields.io/github/contributors/RT-Thread/env?style=for-the-badge)](https://github.com/RT-Thread/env/graphs/contributors) -Run `~/.env/env.ps1` to activate Env. It follows the same venv creation, local -upgrade, mirror selection, and activation behavior as `env.sh`. To activate Env -automatically, add `~/.env/env.ps1` to your PowerShell profile. +
\ No newline at end of file diff --git a/README_ZH.md b/README_ZH.md new file mode 100644 index 00000000..1f4511dd --- /dev/null +++ b/README_ZH.md @@ -0,0 +1,408 @@ +
+ +RT-Thread Env + +# RT-Thread Env 开发环境 + +[![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0-blue.svg)](LICENSE) +[![Python](https://img.shields.io/badge/Python-3.6+-green.svg)](https://www.python.org/) +[![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)]() + +**[English](README.md) | 简体中文** + +
+ +--- + +## ⚠️ 版本兼容性提示 + +| RT-Thread 版本 | 推荐使用 Env 版本 | +|----------------|-------------------| +| **> v5.1.0** 或 **master 分支** | ✅ **env v2.0** (当前版本) | +| **≤ v5.1.0** | ⚠️ [env v1.5.x](https://github.com/RT-Thread/env/tree/v1.5.x) (Linux) / [env-windows v1.5.2](https://github.com/RT-Thread/env-windows/tree/v1.5.2) (Windows) | + +### 🔄 v2.0 主要变更 + +- ✨ **Python 版本升级**:从 Python 2 升级到 Python 3 +- 🔧 **配置系统重构**:使用 Python kconfiglib 替代 kconfig-frontends +- 📦 **依赖管理优化**:安装程序自动处理 kconfiglib 依赖 + +> **注意**:env v2.0 与 env v1.5.x 的 kconfiglib 不兼容。如需切换版本,请先运行 `pip uninstall kconfiglib`。 + +--- + +## 📚 目录 + +- [🚀 快速开始](#-快速开始) + - [Windows 安装](#windows-安装) + - [Linux/macOS 安装](#linuxmacos-安装) +- [⚙️ 安装脚本参数](#️-安装脚本参数) +- [💡 使用指南](#-使用指南) +- [❓ 常见问题](#-常见问题) +- [📖 相关资源](#-相关资源) + +--- + +## 🚀 快速开始 + +### Windows 安装 + +#### 前置要求 + +| 项目 | 要求 | +|------|------| +| **权限** | 📋 首次安装需**管理员权限**(设置执行策略和长路径支持)
后续更新可使用普通用户权限 | +| **PowerShell** | ✅ Windows PowerShell v5.1+
✅ PowerShell 7+ | + +#### 编码兼容性说明 + +> ⚠️ **重要提示** +> +> | PowerShell 版本 | 编码支持 | 说明 | +> |----------------|----------|------| +> | Windows PowerShell (v5.1) | ⚠️ GB2312 默认,需 UTF-8 with BOM | 中文系统需特殊处理 | +> | PowerShell 7+ | ✅ 原生 UTF-8 | 无编码问题 | +> +> 安装脚本已配置为 UTF-8 with BOM 编码,确保兼容性。 + +#### 安装命令 + +
+🌐 国际用户(官方源) + +```powershell +Set-ExecutionPolicy -ExecutionPolicy Bypass Process; irm https://raw.githubusercontent.com/RT-Thread/env/master/tools/install.ps1 | Out-File -Encoding utf8 .\install.ps1; .\install.ps1; Remove-Item .\install.ps1 +``` + +
+ +
+🇨🇳 中国大陆用户(镜像源) + +```powershell +Set-ExecutionPolicy -ExecutionPolicy Bypass Process; irm https://gitee.com/RT-Thread-Mirror/env/raw/master/tools/install.ps1 | Out-File -Encoding utf8 .\install.ps1; .\install.ps1; Remove-Item .\install.ps1 +``` + +
+ +#### 注意事项 + +> 💡 **提示**:安装脚本会根据地理位置自动选择镜像源。如需明确指定,请使用 `--cn` 或 `--official` 参数。详见 [安装脚本参数](#️-安装脚本参数)。 + +> ⚠️ **重要**: +> - ✅ 首次安装需管理员权限设置执行策略和长路径支持 +> - 🦠 杀毒软件可能会阻止安装,如有需要请暂时禁用 + +#### 激活环境 + +安装完成后,需要激活环境变量才能使用。 + +
+🔧 方案 A:手动激活(临时) + +每次启动新的 PowerShell 会话时运行: + +```powershell +. ~/.rt-env/env.ps1 +``` + +
+ +
+⭐ 方案 B:自动激活(推荐) + +将激活命令添加到 PowerShell 配置文件: + +```powershell +# 打开配置文件(如不存在则创建) +notepad $PROFILE + +# 添加以下行: +. ~/.rt-env/env.ps1 +``` + +**配置文件路径:** + +| PowerShell 版本 | 配置文件路径 | +|----------------|-------------| +| Windows PowerShell (v5.1) | `C:\Users\<用户名>\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1` | +| PowerShell 7+ | `C:\Users\<用户名>\Documents\PowerShell\Microsoft.PowerShell_profile.ps1` | + +添加后,每次打开 PowerShell 会自动激活环境。 + +
+ +### Linux/macOS 安装 + +#### 安装命令 + +
+🌐 国际用户(官方源) + +```bash +bash -c "$(wget https://raw.githubusercontent.com/RT-Thread/env/master/tools/install.sh -O -)" +``` + +
+ +
+🇨🇳 中国大陆用户(镜像源) + +```bash +bash -c "$(wget https://gitee.com/RT-Thread-Mirror/env/raw/master/tools/install.sh -O -)" -- --cn +``` + +
+ +#### 注意事项 + +> 💡 **提示**: +> - 安装脚本会根据地理位置自动选择镜像源 +> - Linux 系统会自动使用 `sudo` 提权安装依赖,无需手动以 root 运行 +> - 支持普通用户权限安装,脚本会自动处理权限提升 + +#### 激活环境 + +
+🔧 方案 A:手动激活(临时) + +每次打开新终端时运行: + +```bash +source ~/.rt-env/env.sh +``` + +
+ +
+⭐ 方案 B:自动激活(推荐) + +将激活命令添加到 shell 配置文件: + +```bash +# 对于 bash +echo 'source ~/.rt-env/env.sh' >> ~/.bashrc + +# 对于 zsh +echo 'source ~/.rt-env/env.sh' >> ~/.zshrc +``` + +添加后,每次登录系统时自动激活环境。 + +
+ +#### 📚 相关教程 + +- [在 Ubuntu 中安装 Env 并配合 QEMU 模拟器使用](https://github.com/RT-Thread/rt-thread/blob/master/documentation/quick-start/quick_start_qemu/quick_start_qemu_linux.md) + +--- + +## ⚙️ 安装脚本参数 + +| 参数 | 描述 | +|------|------| +| **基础参数**|| +| `--yes`, `--auto` | 自动安装,无交互 | +| `-h`, `--help` | 显示帮助信息 | +|**源设置**|| +| `--cn`, `--gitee` | 使用中国镜像源(Gitee、PyPI TUNA) | +| `--official` | 强制使用官方源 | +| **仓库配置**|| +| `--packages [#]` | 指定 packages 仓库地址和分支 | +| `--env [#]` | 指定 env 仓库地址和分支 | +| `--sdk [#]` | 指定 sdk 仓库地址和分支 | +| `--touch-env ` | 指定 touch_env.py 下载 URL | +|**路径与安装**|| +| `--env-root ` | 设置自定义 .rt-env 目录路径(默认:`~/.rt-env`) | +| `--python [path]` | 安装便携式 Python,安装目录为 path(仅 Windows,默认:D:\Tools\Python) | +|**其他选项**|| +| `--lang ` | 强制消息语言 | +| `--keep-sdk ` | 重装时保留工具链(local_pkgs)与配置(默认:`yes`,未指定时交互询问) | + +### 使用示例 + +
+💻 Windows (PowerShell) + +```powershell +# 基本安装 +.\install.ps1 + +# 使用中国镜像 + 自动安装 +.\install.ps1 --cn --yes + +# 安装便携式 Python + 自定义路径 +.\install.ps1 --python "D:\Tools\Python" --env-root "D:\RT-Env" + +# 指定自定义 env 仓库分支 +.\install.ps1 --env "https://github.com/RT-Thread/env.git#master" + +# 官方源 + 全新安装(删除已有工具链) +.\install.ps1 --official --keep-sdk no +``` + +
+ +
+🐧 Linux/macOS (bash) + +```bash +# 基本安装 +./install.sh + +# 使用中国镜像 + 自动安装 +./install.sh --cn --yes + +# 指定自定义 packages 仓库 +./install.sh --packages "https://gitee.com/RT-Thread-Mirror/packages.git#master" + +# 重装时保留已有工具链 +./install.sh --keep-sdk yes + +# 指定自定义 sdk 仓库 +./install.sh --sdk "https://github.com/RT-Thread/sdk.git#master" +``` + +
+ +--- + +## 💡 使用指南 + +安装完成后,按照以下步骤开始使用 RT-Thread ENV: + +### 步骤 1️⃣:激活环境 + +
+💻 Windows + +```powershell +. ~/.rt-env/env.ps1 +``` + +
+ +
+🐧 Linux/macOS + +```bash +source ~/.rt-env/env.sh +``` + +
+ +> 💡 **提示**:如需每次启动自动激活,请参考安装章节的自动激活方案。 + +### 步骤 2️⃣:安装工具链 + +运行 `sdk` 命令安装开发板所需的工具链: + +```bash +sdk +``` + +### 步骤 3️⃣:使用命令 + +激活后,可以使用以下命令: + +| 命令 | 功能 | 说明 | +|------|------|------| +| `menuconfig` | ⚙️ 配置项目 | 配置 RT-Thread 内核、BSP | +| `menuconfig -s` | 🔧 配置 ENV | 配置软件包、工具 | +| `pkgs` | 📦 包管理器 | 更新、升级软件包 | +| `sdk` | 🛠️ 工具链管理器 | 安装开发工具链 | +| `scons` | 🔨 编译项目 | 构建项目 | + +### 步骤 4️⃣:额外工具 + +**pyocd** - 用于调试 Cortex-M 设备,已随环境默认安装(无需手动步骤)。如需手动升级: + +```bash +pip install --upgrade pyocd +``` + +### 📚 详细文档 + +- [Env 工具使用指南](https://github.com/RT-Thread/rt-thread/blob/master/documentation/env/env.md) +- [Env 官方用户手册](https://www.rt-thread.org/document/site/#/development-tools/env/env) + +--- + +## ❓ 常见问题 + +### 🌐 网络与镜像问题 + +**问题:下载缓慢或失败** + +- ✅ 使用 `--cn` 参数启用 Gitee 镜像 +- ✅ 检查网络连接 +- ✅ 尝试切换网络环境(如使用 VPN) + +### 🔐 权限问题 + +#### Linux/macOS + +Linux 安装脚本会自动使用 `sudo` 提权,通常无需手动处理。 + +如遇权限问题: + +```bash +# 检查 .rt-env 目录权限 +ls -la ~/.rt-env + +# 如果目录属于 root,修改所有权 +sudo chown -R $USER:$USER ~/.rt-env +``` + +#### Windows + +如遇权限错误: + +1. ✅ 检查杀毒软件是否阻止安装 +2. ✅ 以管理员身份运行 PowerShell +3. ✅ 确保执行策略允许脚本运行 + +### 📝 其他问题 + +如遇到其他问题,请: + +- 📖 查看 [Env 工具完整文档](https://github.com/RT-Thread/rt-thread/blob/master/documentation/env/env.md) +- 🐛 在 [GitHub Issues](https://github.com/RT-Thread/env/issues) 提交问题 +- 💬 加入 [RT-Thread 论坛](https://www.rt-thread.org/qa/forum.html) 寻求帮助 + +--- + +## 📖 相关资源 + +### 官方文档 + +- [Env 工具完整文档](https://github.com/RT-Thread/rt-thread/blob/master/documentation/env/env.md) +- [QEMU 快速入门](https://github.com/RT-Thread/rt-thread/blob/master/documentation/quick-start/quick_start_qemu/quick_start_qemu_linux.md) +- [BSP 配置说明](https://github.com/RT-Thread/rt-thread/blob/master/documentation/env/env.md#bsp-configuration-menuconfig) + +### 相关链接 + +| 类型 | 链接 |镜像| +|------|------|----| +| 📦 Env 仓库 | [GitHub](https://github.com/RT-Thread/env) |[Gitee](https://gitee.com/RT-Thread-Mirror/env) | +| ![RT-Thread](https://www.rt-thread.org/favicon.ico) RT-Thread 仓库|[GitHub](https://github.com/RT-Thread/rt-thread) |[Gitee](https://gitee.com/rtthread/rt-thread) | +| 🌐 官方网站 | [RT-Thread 官网](https://www.rt-thread.org/) || +| 📚 文档中心 | [RT-Thread 文档(英文)](https://www.rt-thread.io/document/site/) |[RT-Thread 文档中心(中文)](https://www.rt-thread.org/document/site/#/)| + +### 许可证 + +[![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0-blue.svg)](LICENSE) + +本项目采用 **GPL-2.0** 许可证开源。 + +--- + +
+ +## 🤝 贡献者 + +感谢所有为 RT-Thread Env 项目做出贡献的开发者! + +[![Contributors](https://img.shields.io/github/contributors/RT-Thread/env?style=for-the-badge)](https://github.com/RT-Thread/env/graphs/contributors) + +
\ No newline at end of file diff --git a/cmds/cmd_package/cmd_package_update.py b/cmds/cmd_package/cmd_package_update.py index 7fb712f6..f0c4b0c8 100644 --- a/cmds/cmd_package/cmd_package_update.py +++ b/cmds/cmd_package/cmd_package_update.py @@ -23,6 +23,7 @@ # 2020-04-08 SummerGift Optimize program structure # 2020-04-13 SummerGift refactoring # 2026-05-12 CYFS share hal-sdk packages in libraries and create bridge SConscript for hal-sdk packages in BSP packages +# 2026-09-12 Dongly Resolve submodule mirror urls via info.get_submodule_mirror_url # import json @@ -43,6 +44,7 @@ import pkgsdb from package import PackageOperation, Bridge_SConscript from vars import Import, Export +from info import get_submodule_mirror_url from .cmd_package_utils import ( get_url_from_mirror_server, execute_command, @@ -319,7 +321,7 @@ def get_mirror_giturl(submodule_name): Retrurn the download address of the submodule on the mirror server from the submod_name. """ - mirror_url = 'https://gitee.com/RT-Thread-Mirror/submod_' + submodule_name + '.git' + mirror_url = get_submodule_mirror_url('rt-thread', submodule_name) return mirror_url @@ -328,7 +330,7 @@ def get_esp_mirror_giturl(submodule_name): submodule_name = "CException" elif submodule_name == "unity": submodule_name = "Unity" - mirror_url = 'https://gitee.com/esp-submodules/' + submodule_name + '.git' + mirror_url = get_submodule_mirror_url('esp', submodule_name) return mirror_url diff --git a/cmds/cmd_package/cmd_package_upgrade.py b/cmds/cmd_package/cmd_package_upgrade.py index ce245145..bcab169b 100644 --- a/cmds/cmd_package/cmd_package_upgrade.py +++ b/cmds/cmd_package/cmd_package_upgrade.py @@ -21,12 +21,22 @@ # Change Logs: # Date Author Notes # 2020-04-08 SummerGift Optimize program structure +# 2026-09-12 Dongly Resolve packages/env repo URLs and statistics endpoint +# via env.json (info.get_source / info.get_api_url); logical +# repos no longer query the mirror server +# 2026-09-12 Dongly Refresh rt-env editable install and regenerate the +# root activator after env repo upgrades # import os +import shutil +import platform +import subprocess +import sys import uuid from vars import Import -from .cmd_package_utils import execute_command, git_pull_repo, get_url_from_mirror_server, find_bool_macro_in_config +from info import get_source, get_api_url +from .cmd_package_utils import execute_command, git_pull_repo, find_bool_macro_in_config from .cmd_package_update import need_using_mirror_download try: @@ -49,16 +59,8 @@ def upgrade_packages_index(force_upgrade=False): pkgs_root = Import('pkgs_root') - if need_using_mirror_download(): - get_package_url, get_ver_sha = get_url_from_mirror_server('packages', 'latest') - - if get_package_url is not None: - git_repo = get_package_url - else: - print("Failed to get url from mirror server. Using default url.") - git_repo = 'https://gitee.com/RT-Thread-Mirror/packages.git' - else: - git_repo = 'https://github.com/RT-Thread/packages.git' + src = get_source('packages', use_mirror=need_using_mirror_download()) + git_repo = src.url packages_root = pkgs_root pkgs_path = os.path.join(packages_root, 'packages') @@ -70,7 +72,7 @@ def upgrade_packages_index(force_upgrade=False): else: if force_upgrade: execute_command('git fetch --all', cwd=pkgs_path) - execute_command('git reset --hard origin/master', cwd=pkgs_path) + execute_command('git reset --hard origin/%s' % src.branch, cwd=pkgs_path) print("Begin to upgrade env packages.") git_pull_repo(pkgs_path, git_repo) print("==============================> Env packages upgrade done \n") @@ -96,25 +98,91 @@ def upgrade_env_script(force_upgrade=False): env_root = Import('env_root') - if need_using_mirror_download(): - get_package_url, get_ver_sha = get_url_from_mirror_server('env', 'latest') - - if get_package_url is not None: - env_scripts_repo = get_package_url - else: - print("Failed to get url from mirror server. Using default url.") - env_scripts_repo = 'https://gitee.com/RT-Thread-Mirror/env.git' - else: - env_scripts_repo = 'https://github.com/RT-Thread/env.git' + src = get_source('env', use_mirror=need_using_mirror_download()) env_scripts_root = os.path.join(env_root, 'tools', 'scripts') if force_upgrade: execute_command('git fetch --all', cwd=env_scripts_root) - execute_command('git reset --hard origin/master', cwd=env_scripts_root) + execute_command('git reset --hard origin/%s' % src.branch, cwd=env_scripts_root) print("Begin to upgrade env scripts.") - git_pull_repo(env_scripts_root, env_scripts_repo) + git_pull_repo(env_scripts_root, src.url) print("==============================> Env scripts upgrade done \n") + _post_upgrade_refresh(env_root, env_scripts_root) + + +def _post_upgrade_refresh(env_root, env_scripts_root): + """Best-effort post-upgrade steps after the env repo changed. + + 1. pip install -e so new console scripts and dependency changes take + effect (the editable .pth only redirects code, not entry points). + 2. Regenerate the root activator to match the upgraded env scripts. + The user customization file ($ENV_ROOT/env.user.*) is never touched. + """ + print("Refreshing rt-env installation (pip install -e) ...") + try: + subprocess.run( + [sys.executable, '-m', 'pip', 'install', '-q', '-e', env_scripts_root], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True) + print("==============================> rt-env package refreshed \n") + except (OSError, subprocess.SubprocessError) as e: + output = getattr(e, 'output', b'') + detail = output.decode(errors='replace') if isinstance(output, bytes) else str(e) + print("Warning: pip install -e failed:\n{0}".format(detail.strip() or str(e))) + print("Run manually: \"{0}\" -m pip install -e \"{1}\"".format(sys.executable, env_scripts_root)) + + print("Refreshing root activator ...") + try: + _write_root_activator(env_root, env_scripts_root) + print("==============================> Root activator refreshed \n") + except (OSError, UnicodeDecodeError) as e: + print("Warning: could not refresh root activator: {0}".format(e)) + + +def _sh_quote(text): + """Escape single quotes for a single-quoted shell literal.""" + return text.replace("'", "'\\''") + + +def _ps_quote(text): + """Escape single quotes for a single-quoted PowerShell literal.""" + return text.replace("'", "''") + + +def _write_root_activator(env_root, env_scripts_root): + """Regenerate $ENV_ROOT/env.sh(ps1) to match the upgraded env scripts. + + Thin delegator (RT_ENV_ROOT signal) when the inner script supports it, + full copy otherwise. Mirrors tools/touch_env.py copy_env_scripts + (kept local: tools/ is not importable from the installed package). + """ + name = 'env.ps1' if platform.system() == 'Windows' else 'env.sh' + src = os.path.join(env_scripts_root, name) + dst = os.path.join(env_root, name) + if not os.path.isfile(src): + return + with open(src, encoding='utf-8') as f: + inner = f.read() + if 'RT_ENV_ROOT' in inner: + if name.endswith('.ps1'): + content = ( + "# Generated by the RT-Thread ENV installer. Do not edit.\r\n" + "$env:RT_ENV_ROOT = '{0}'\r\n" + ". '{1}'\r\n".format(_ps_quote(env_root), _ps_quote(src)) + ) + encoding = 'utf-8-sig' + else: + content = ( + "# Generated by the RT-Thread ENV installer. Do not edit.\n" + "RT_ENV_ROOT='{0}'\n" + ". '{1}'\n".format(_sh_quote(env_root), _sh_quote(src)) + ) + encoding = 'utf-8' + with open(dst, 'w', encoding=encoding, newline='') as f: + f.write(content) + else: + shutil.copy2(src, dst) + def get_mac_address(): mac = uuid.UUID(int=uuid.getnode()).hex[-12:] @@ -130,7 +198,8 @@ def Information_statistics(): if os.path.isfile(env_config_file) and find_bool_macro_in_config(env_config_file, 'SYS_PKGS_USING_STATISTICS'): mac_addr = get_mac_address() response = requests.get( - 'https://www.rt-thread.org/studio/statistics/api/envuse?userid=' + get_api_url('statistics') + + '?userid=' + str(mac_addr) + '&username=' + str(mac_addr) diff --git a/cmds/cmd_package/cmd_package_utils.py b/cmds/cmd_package/cmd_package_utils.py index 022468a7..efc17ccb 100644 --- a/cmds/cmd_package/cmd_package_utils.py +++ b/cmds/cmd_package/cmd_package_utils.py @@ -21,6 +21,7 @@ # Change Logs: # Date Author Notes # 2020-04-08 SummerGift Optimize program structure +# 2026-09-12 Dongly Resolve mirror query endpoint via info.get_api_url # import json @@ -34,6 +35,7 @@ import requests import logging from vars import Import +from info import get_api_url def get_git_root_path(repo_path): @@ -220,7 +222,7 @@ def get_url_from_mirror_server(package_name, package_version): payload["packages"][0]['name'] = package_name try: - r = requests.post("https://api.rt-thread.org/packages/queries", data=json.dumps(payload)) + r = requests.post(get_api_url('mirror_query'), data=json.dumps(payload)) if r.status_code == requests.codes.ok: package_info = json.loads(r.text) diff --git a/env.json b/env.json index 674e979e..96f91b07 100644 --- a/env.json +++ b/env.json @@ -2,7 +2,38 @@ "name": "RT-Thread Env Tool", "version": "v2.0.2", "description": "A command-line toolkit for RT-Thread development.", - "repository": { - "url": "https://github.com/RT-Thread/env" + "repositories": { + "env": { + "url": "https://github.com/RT-Thread/env.git", + "branch": "master", + "mirror": { + "url": "https://gitee.com/RT-Thread-Mirror/env.git", + "branch": "master" + } + }, + "sdk": { + "url": "https://github.com/RT-Thread/sdk.git", + "branch": "main", + "mirror": { + "url": "https://gitee.com/RT-Thread-Mirror/sdk.git", + "branch": "main" + } + }, + "packages": { + "url": "https://github.com/RT-Thread/packages.git", + "branch": "master", + "mirror": { + "url": "https://gitee.com/RT-Thread-Mirror/packages.git", + "branch": "master" + } + } + }, + "submodule_mirrors": { + "rt-thread": "https://gitee.com/RT-Thread-Mirror/submod_{name}.git", + "esp": "https://gitee.com/esp-submodules/{name}.git" + }, + "apis": { + "mirror_query": "https://api.rt-thread.org/packages/queries", + "statistics": "https://www.rt-thread.org/studio/statistics/api/envuse" } } diff --git a/env.ps1 b/env.ps1 index 7f39f8c0..0350164b 100644 --- a/env.ps1 +++ b/env.ps1 @@ -1,57 +1,42 @@ -if ([string]::IsNullOrWhiteSpace($env:ENV_ROOT)) { - $EnvRoot = $PSScriptRoot -} else { - $EnvRoot = $env:ENV_ROOT -} - -$env:ENV_ROOT = $EnvRoot -$VenvRoot = Join-Path $EnvRoot ".venv" -$ScriptsRoot = Join-Path $EnvRoot "tools\scripts" -$BootstrapScript = Join-Path $ScriptsRoot "env_venv.py" -$VenvPython = Join-Path $VenvRoot "Scripts\python.exe" -$ActivateScript = Join-Path $VenvRoot "Scripts\Activate.ps1" -$BootstrapStatus = 0 -$ActivateStatus = 0 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::InputEncoding = [System.Text.Encoding]::UTF8 -if (Test-Path -Path $VenvPython -PathType Leaf) { - $BootstrapPython = $VenvPython -} elseif (Get-Command python -ErrorAction SilentlyContinue) { - $BootstrapPython = "python" -} else { - $BootstrapPython = $null +# ENV_ROOT resolution (see env.sh): $env:RT_ENV_ROOT set by the thin root +# activator > tools\scripts layout detection > this file's directory. +if ($env:RT_ENV_ROOT) { + $env:ENV_ROOT = $env:RT_ENV_ROOT +} +elseif ($PSScriptRoot -like '*\tools\scripts') { + $env:ENV_ROOT = Split-Path (Split-Path $PSScriptRoot) +} +else { + $env:ENV_ROOT = $PSScriptRoot } -if ($null -eq $BootstrapPython) { - Write-Error "Cannot prepare the RT-Thread Env venv: Python 3 was not found." - $BootstrapStatus = 1 -} elseif (-not (Test-Path -Path $BootstrapScript -PathType Leaf)) { - Write-Error "Cannot prepare the RT-Thread Env venv: $BootstrapScript was not found." - $BootstrapStatus = 1 -} else { - & $BootstrapPython $BootstrapScript ` - --venv $VenvRoot ` - --source $ScriptsRoot ` - --activation-script (Join-Path $EnvRoot "env.ps1") - $BootstrapStatus = $LASTEXITCODE +# Virtual environment: prefer venv\rt-env, fall back to legacy .venv. +$RT_VENV_DIR = "$env:ENV_ROOT\venv\rt-env" +if (-not (Test-Path "$RT_VENV_DIR\Scripts\Activate.ps1") -and + (Test-Path "$env:ENV_ROOT\.venv\Scripts\Activate.ps1")) { + $RT_VENV_DIR = "$env:ENV_ROOT\.venv" } -if (Test-Path -Path $ActivateScript -PathType Leaf) { - try { - . $ActivateScript - } catch { - Write-Error "Failed to activate the RT-Thread Env Python venv: $_" - $ActivateStatus = 1 +if (Test-Path "$RT_VENV_DIR\Scripts\Activate.ps1") { + . "$RT_VENV_DIR\Scripts\Activate.ps1" + + # Show welcome message using rt-env command + if (Get-Command rt-env -ErrorAction SilentlyContinue) { + rt-env --info } -} else { - Write-Error "Cannot activate the RT-Thread Env Python venv: $ActivateScript was not found." - $ActivateStatus = 1 +} +else { + Write-Host "Virtual environment not found (tried $RT_VENV_DIR\Scripts\Activate.ps1 and $env:ENV_ROOT\.venv\Scripts\Activate.ps1). Please run the installation 'RT-Thread ENV' first." + exit 1 } -$env:PATHEXT = ".PS1;$env:PATHEXT" +$env:pathext = ".PS1;$env:pathext" -if ($BootstrapStatus -ne 0) { - Write-Warning "The Env venv preparation failed, but activation was still attempted." -} -if ($ActivateStatus -ne 0) { - Write-Warning "The Env Python venv is not active." +# User customization lives in $ENV_ROOT\env.user.ps1, outside the managed +# env repository, so upgrades and reinstalls never overwrite it. +if (Test-Path "$env:ENV_ROOT\env.user.ps1") { + . "$env:ENV_ROOT\env.user.ps1" } diff --git a/env.py b/env.py index 64367693..28aef024 100644 --- a/env.py +++ b/env.py @@ -24,6 +24,7 @@ # 2019-1-16 SummerGift Add chinese detection # 2020-4-13 SummerGift refactoring # 2025-1-27 bernard Add env.json for env information +# 2026-09-12 Dongly Add show_version banner with --info flag; migrate to info accessors import os import sys @@ -38,17 +39,31 @@ from cmds import * from vars import Export -from version import get_rt_env_version +from info import get_name, get_version -def show_version_warning(): +def show_version(): + rtt_ver = get_rtt_verion() + rt_env_name, rt_env_ver = get_name(), get_version() + + print('\033[1;36m===================================================================\033[0m') + print('\033[1;36m Welcome to %s %s\033[0m' % (rt_env_name, rt_env_ver)) + print('\033[1;36m===================================================================\033[0m') + print('Environment Information:') + print(' - ENV_ROOT : %s' % get_env_root()) + print(' - PKGS_ROOT: %s' % get_package_root()) + + if rtt_ver != (0, 0, 0): + print(' - RTT_ROOT : %s' % get_rtt_root()) + print(' - BSP_ROOT : %s' % get_bsp_root()) + print(' - RT-Thread Version: %d.%d.%d' % rtt_ver) + print('\033[1;36m===================================================================\033[0m') + +def show_version_warning(is_show_version=True): rtt_ver = get_rtt_verion() - rt_env_name, rt_env_ver = get_rt_env_version() if rtt_ver <= (5, 1, 0) and rtt_ver != (0, 0, 0): - print('===================================================================') - print('Welcome to %s %s' % (rt_env_name, rt_env_ver)) - print('===================================================================') - # print('') + if is_show_version: + show_version() print('env v2.0 has made the following important changes:') print('1. Upgrading Python version from v2 to v3') print('2. Replacing kconfig-frontends with Python kconfiglib') @@ -67,12 +82,14 @@ def show_version_warning(): def init_argparse(): - parser = argparse.ArgumentParser(description=__doc__) + # 'rt-env' mirrors the [project.scripts] entry in pyproject.toml + parser = argparse.ArgumentParser(prog='rt-env', description=__doc__) subs = parser.add_subparsers() - rt_env_name, rt_env_ver = get_rt_env_version() + rt_env_name, rt_env_ver = get_name(), get_version() env_ver_str = '%s %s' % (rt_env_name, rt_env_ver) parser.add_argument('-v', '--version', action='version', version=env_ver_str) + parser.add_argument('--info', action='store_true', help='Show environment information') cmd_system.add_parser(subs) cmd_menuconfig.add_parser(subs) @@ -154,9 +171,9 @@ def get_env_root(): env_root = os.getenv("ENV_ROOT") if env_root is None: if platform.system() != 'Windows': - env_root = os.path.join(os.getenv('HOME'), '.env') + env_root = os.path.join(os.getenv('HOME'), '.rt-env') else: - env_root = os.path.join(os.getenv('USERPROFILE'), '.env') + env_root = os.path.join(os.getenv('USERPROFILE'), '.rt-env') return env_root @@ -219,18 +236,31 @@ def exec_arg(arg): args.func(args) -def main(): - show_version_warning() - export_environment_variable() - init_logger(get_env_root()) +def cmd_env_info(args): + """Handle environment information display.""" + show_version() + show_version_warning(False) + sys.exit(0) + +def main(): parser = init_argparse() args = parser.parse_args() - if not vars(args): + if args.info: + cmd_env_info(args) + + # Check if any subcommand was provided + if not hasattr(args, 'func'): + # No subcommand provided, show help parser.print_help() - else: - args.func(args) + sys.exit(0) + + show_version_warning() + export_environment_variable() + init_logger(get_env_root()) + + args.func(args) def menuconfig(): diff --git a/env.sh b/env.sh index 044c8936..2c2b9891 100644 --- a/env.sh +++ b/env.sh @@ -1,58 +1,48 @@ -# shellcheck shell=sh - -ENV_ROOT="${ENV_ROOT:-$HOME/.env}" -VENV_ROOT="$ENV_ROOT/.venv" -ENV_SCRIPTS_ROOT="$ENV_ROOT/tools/scripts" -ENV_BOOTSTRAP="$ENV_SCRIPTS_ROOT/env_venv.py" -ENV_ACTIVATE="$VENV_ROOT/bin/activate" -bootstrap_status=0 -activate_status=0 - -export ENV_ROOT -if [ "${ENV_VENV_AUTO_UPGRADE+x}" = "x" ]; then - export ENV_VENV_AUTO_UPGRADE -fi -if [ "${ENV_PYPI_INDEX_URL+x}" = "x" ]; then - export ENV_PYPI_INDEX_URL -fi - -if command -v python3 >/dev/null 2>&1; then - BOOTSTRAP_PYTHON=python3 -elif [ -x "$VENV_ROOT/bin/python" ]; then - BOOTSTRAP_PYTHON="$VENV_ROOT/bin/python" +# ENV_ROOT resolution: +# 1. $RT_ENV_ROOT - set by the thin root activator ($ENV_ROOT/env.sh) +# that delegates to this script, so this copy never guesses where +# the installation root is. +# 2. Layout detection - when sourced directly from tools/scripts the +# installation root is two levels up. +# 3. Otherwise this file's own directory (legacy full copy at $ENV_ROOT). +if [ -n "$RT_ENV_ROOT" ]; then + ENV_ROOT="$RT_ENV_ROOT" else - BOOTSTRAP_PYTHON= + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + case "$SCRIPT_DIR" in + */tools/scripts) ENV_ROOT="${SCRIPT_DIR%/tools/scripts}" ;; + *) ENV_ROOT="$SCRIPT_DIR" ;; + esac fi +export "ENV_ROOT=$ENV_ROOT" -if [ -z "$BOOTSTRAP_PYTHON" ]; then - echo "Cannot prepare the RT-Thread Env venv: Python 3 was not found." >&2 - bootstrap_status=1 -elif [ ! -f "$ENV_BOOTSTRAP" ]; then - echo "Cannot prepare the RT-Thread Env venv: $ENV_BOOTSTRAP was not found." >&2 - bootstrap_status=1 -else - "$BOOTSTRAP_PYTHON" "$ENV_BOOTSTRAP" \ - --venv "$VENV_ROOT" \ - --source "$ENV_SCRIPTS_ROOT" \ - --activation-script "$ENV_ROOT/env.sh" || bootstrap_status=$? +# Virtual environment: prefer the current layout (venv/rt-env), +# fall back to the legacy location (.venv) of older installations. +RT_VENV_DIR="$ENV_ROOT/venv/rt-env" +if [ ! -f "$RT_VENV_DIR/bin/activate" ] && [ -f "$ENV_ROOT/.venv/bin/activate" ]; then + RT_VENV_DIR="$ENV_ROOT/.venv" fi -if [ -f "$ENV_ACTIVATE" ]; then - if ! . "$ENV_ACTIVATE"; then - echo "Failed to activate the RT-Thread Env Python venv." >&2 - activate_status=1 +# Activate Python virtual environment +if [ -f "$RT_VENV_DIR/bin/activate" ]; then + source "$RT_VENV_DIR/bin/activate" + + # Show welcome message using rt-env command + if command -v rt-env >/dev/null 2>&1; then + rt-env --info fi else - echo "Cannot activate the RT-Thread Env Python venv: $ENV_ACTIVATE was not found." >&2 - activate_status=1 + echo "Virtual environment not found (tried $ENV_ROOT/venv/rt-env and $ENV_ROOT/.venv)." + echo "Please run the installation script first." + return 1 fi -export PATH="$ENV_SCRIPTS_ROOT:$PATH" +# Set PATH +# export PATH="$ENV_ROOT/tools/scripts:$PATH" export RTT_EXEC_PATH=/usr/bin -if [ "$activate_status" -ne 0 ]; then - return "$activate_status" 2>/dev/null || exit "$activate_status" -fi -if [ "$bootstrap_status" -ne 0 ]; then - return "$bootstrap_status" 2>/dev/null || exit "$bootstrap_status" +# User customization lives in $ENV_ROOT/env.user.sh, outside the managed +# env repository, so upgrades and reinstalls never overwrite it. +if [ -f "$ENV_ROOT/env.user.sh" ]; then + . "$ENV_ROOT/env.user.sh" fi diff --git a/env_venv.py b/env_venv.py deleted file mode 100644 index 33460e13..00000000 --- a/env_venv.py +++ /dev/null @@ -1,489 +0,0 @@ -#!/usr/bin/env python3 -"""Create and update the Python virtual environment used by Env.""" - -from __future__ import print_function - -import argparse -import filecmp -import hashlib -import inspect -import json -import os -from pathlib import Path -import shutil -import subprocess -import sys -import tempfile -from urllib.request import ProxyHandler, Request, build_opener - - -ALIYUN_INDEX_URL = 'https://mirrors.aliyun.com/pypi/simple/' -COUNTRY_URL = 'https://ipinfo.io/country' -PROXY_ENVIRONMENT_KEYS = ( - 'ALL_PROXY', - 'HTTPS_PROXY', - 'HTTP_PROXY', - 'PIP_PROXY', - 'all_proxy', - 'https_proxy', - 'http_proxy', - 'pip_proxy', -) -STATE_FILENAME = '.rt-thread-env-state.json' -STATE_SCHEMA = 1 - -ROOT_RUNTIME_FILES = ( - 'MANIFEST.in', - 'env.json', - 'env.ps1', - 'env.sh', - 'pyproject.toml', - 'setup.py', -) -ROOT_RUNTIME_SUFFIXES = ('.py',) -RUNTIME_DIRECTORIES = ('cmds', 'plugins') -EXCLUDED_DIRECTORY_NAMES = ( - '.git', - '__pycache__', - 'build', - 'dist', - 'node_modules', - 'playwright-report', - 'test-results', -) -EXCLUDED_RUNTIME_PREFIXES = ( - ('plugins', 'examples'), - ('plugins', 'tests'), - ('plugins', 'webui', 'frontend'), -) -EXCLUDED_RUNTIME_SUFFIXES = ('.md', '.rst') - - -class BootstrapError(Exception): - """Raised when the Env venv cannot be prepared safely.""" - - -def _normalized(path): - return Path(path).expanduser().resolve() - - -def _is_excluded(relative): - parts = relative.parts - if any(part in EXCLUDED_DIRECTORY_NAMES for part in parts): - return True - return any(parts[: len(prefix)] == prefix for prefix in EXCLUDED_RUNTIME_PREFIXES) - - -def iter_runtime_files(source_root): - source_root = _normalized(source_root) - files = set() - - for name in ROOT_RUNTIME_FILES: - candidate = source_root / name - if candidate.is_file(): - files.add(candidate) - - for candidate in source_root.iterdir(): - if candidate.is_file() and candidate.suffix in ROOT_RUNTIME_SUFFIXES: - files.add(candidate) - - for directory_name in RUNTIME_DIRECTORIES: - directory = source_root / directory_name - if not directory.is_dir(): - continue - for candidate in directory.rglob('*'): - if not candidate.is_file(): - continue - relative = candidate.relative_to(source_root) - if _is_excluded(relative) or candidate.suffix in EXCLUDED_RUNTIME_SUFFIXES + ('.pyc', '.pyo'): - continue - files.add(candidate) - - return sorted(files, key=lambda path: path.relative_to(source_root).as_posix()) - - -def source_fingerprint(source_root): - source_root = _normalized(source_root) - files = iter_runtime_files(source_root) - if not files: - raise BootstrapError('no Env runtime source files were found in %s' % source_root) - - digest = hashlib.sha256() - for path in files: - relative = path.relative_to(source_root).as_posix().encode('utf-8') - digest.update(relative) - digest.update(b'\0') - with path.open('rb') as source: - while True: - chunk = source.read(1024 * 1024) - if not chunk: - break - digest.update(chunk) - digest.update(b'\0') - return digest.hexdigest() - - -def read_env_version(source_root): - path = _normalized(source_root) / 'env.json' - try: - with path.open('r', encoding='utf-8') as source: - value = json.load(source).get('version') - return value or 'unknown' - except (OSError, ValueError, TypeError): - return 'unknown' - - -def venv_layout(venv_root, platform_name=None): - venv_root = _normalized(venv_root) - platform_name = platform_name or os.name - if platform_name == 'nt': - scripts = venv_root / 'Scripts' - return { - 'python': scripts / 'python.exe', - 'activate': scripts / 'Activate.ps1', - 'rt_env': scripts / 'rt-env.exe', - } - scripts = venv_root / 'bin' - return { - 'python': scripts / 'python', - 'activate': scripts / 'activate', - 'rt_env': scripts / 'rt-env', - } - - -def venv_is_usable(layout): - return layout['python'].is_file() and layout['activate'].is_file() - - -def env_is_installed(layout): - return layout['rt_env'].is_file() - - -def read_state(venv_root): - path = _normalized(venv_root) / STATE_FILENAME - try: - with path.open('r', encoding='utf-8') as source: - value = json.load(source) - return value if isinstance(value, dict) else None - except (OSError, ValueError, TypeError): - return None - - -def write_state(venv_root, value): - venv_root = _normalized(venv_root) - descriptor, temporary_name = tempfile.mkstemp(prefix=STATE_FILENAME + '.', dir=str(venv_root)) - temporary_path = Path(temporary_name) - try: - with os.fdopen(descriptor, 'w', encoding='utf-8') as output: - json.dump(value, output, indent=2, sort_keys=True) - output.write('\n') - os.replace(str(temporary_path), str(venv_root / STATE_FILENAME)) - finally: - if temporary_path.exists(): - temporary_path.unlink() - - -def default_activation_script(venv_root, platform_name=None): - platform_name = platform_name or os.name - filename = 'env.ps1' if platform_name == 'nt' else 'env.sh' - return _normalized(venv_root).parent / filename - - -def activation_source(source_root, activation_target): - return _normalized(source_root) / _normalized(activation_target).name - - -def activation_is_current(source_root, activation_target): - source = activation_source(source_root, activation_target) - target = _normalized(activation_target) - if not source.is_file() or not target.is_file(): - return False - try: - if source.samefile(target): - return True - except OSError: - pass - return filecmp.cmp(str(source), str(target), shallow=False) - - -def sync_activation_script(source_root, activation_target): - source = activation_source(source_root, activation_target) - target = _normalized(activation_target) - if not source.is_file(): - raise BootstrapError('activation source does not exist: %s' % source) - try: - if target.exists() and source.samefile(target): - return - except OSError: - pass - - target.parent.mkdir(parents=True, exist_ok=True) - descriptor, temporary_name = tempfile.mkstemp(prefix=target.name + '.', dir=str(target.parent)) - os.close(descriptor) - temporary_path = Path(temporary_name) - try: - shutil.copy2(str(source), str(temporary_path)) - os.replace(str(temporary_path), str(target)) - finally: - if temporary_path.exists(): - temporary_path.unlink() - - -def _proxy_free_environment(environ=None): - environment = dict(os.environ if environ is None else environ) - for key in PROXY_ENVIRONMENT_KEYS: - environment.pop(key, None) - return environment - - -def _open_without_proxy(request, timeout): - opener = build_opener(ProxyHandler({})) - return opener.open(request, timeout=timeout) - - -def detect_country(timeout=3): - request = Request(COUNTRY_URL, headers={'User-Agent': 'RT-Thread-Env/2'}) - try: - with _open_without_proxy(request, timeout=timeout) as response: - return response.read(16).decode('ascii', 'ignore').strip().upper() or None - except Exception: - return None - - -def select_index_url(country_detector=None, environ=None): - environ = environ if environ is not None else os.environ - configured = environ.get('ENV_PYPI_INDEX_URL', '').strip() - if configured: - print('Using the configured Python package index.') - return configured - - country_detector = country_detector or detect_country - if country_detector() == 'CN': - print('Detected a China Mainland IP; using the Alibaba Cloud PyPI mirror.') - return ALIYUN_INDEX_URL - return None - - -def confirm_upgrade(stdin=None): - stdin = stdin or sys.stdin - if not stdin.isatty(): - print('Env scripts changed, but this shell is not interactive; keeping the current venv installation.') - print('Set ENV_VENV_AUTO_UPGRADE=1 to accept the upgrade non-interactively.') - return False - try: - answer = input('Env scripts have changed. Upgrade the Python venv now? [y/N] ') - except (EOFError, KeyboardInterrupt): - print('') - return False - return answer.strip().lower() in ('y', 'yes') - - -def run_command(command, env=None): - subprocess.check_call([str(value) for value in command], env=_proxy_free_environment(env)) - - -def _command_runner_supports_env(command_runner): - try: - signature = inspect.signature(command_runner) - except (TypeError, ValueError): - return False - if any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()): - return True - return 'env' in signature.parameters - - -def _invoke_command(command_runner, command, env=None): - if env is not None and _command_runner_supports_env(command_runner): - return command_runner(command, env=env) - return command_runner(command) - - -def _index_arguments(index_url): - return ['--index-url', index_url] if index_url else [] - - -def _pip_network_arguments(index_url): - return _index_arguments(index_url) + ['--proxy', ''] - - -def _index_label(index_url): - if not index_url: - return 'default' - if index_url == ALIYUN_INDEX_URL: - return 'aliyun' - return 'custom' - - -def install_env(layout, source_root, initial_install, index_url, command_runner=None): - command_runner = command_runner or run_command - command_environment = _proxy_free_environment() - python = str(layout['python']) - _invoke_command(command_runner, [python, '-m', 'ensurepip', '--upgrade'], env=command_environment) - if initial_install: - _invoke_command( - command_runner, - [python, '-m', 'pip', 'install', '--disable-pip-version-check', '--upgrade'] - + _pip_network_arguments(index_url) - + ['pip'], - env=command_environment, - ) - _invoke_command( - command_runner, - [ - python, - '-m', - 'pip', - 'install', - '--disable-pip-version-check', - '--upgrade', - '--upgrade-strategy', - 'only-if-needed', - ] - + _pip_network_arguments(index_url) - + [str(_normalized(source_root))], - env=command_environment, - ) - - -def _state_matches(state, source_root, fingerprint): - if not state or state.get('schema') != STATE_SCHEMA: - return False - return state.get('source') == str(_normalized(source_root)) and state.get('fingerprint') == fingerprint - - -def mark_environment_current(venv_root, source_root, activation_target): - venv_root = _normalized(venv_root) - source_root = _normalized(source_root) - activation_target = _normalized(activation_target) - layout = venv_layout(venv_root) - if not venv_is_usable(layout): - raise BootstrapError('cannot mark an unusable Python venv as current: %s' % venv_root) - if not env_is_installed(layout): - raise BootstrapError('cannot mark the Python venv as current before Env is installed') - - fingerprint = source_fingerprint(source_root) - sync_activation_script(source_root, activation_target) - write_state( - venv_root, - { - 'schema': STATE_SCHEMA, - 'source': str(source_root), - 'fingerprint': fingerprint, - 'version': read_env_version(source_root), - 'index': 'existing', - }, - ) - - -def ensure_environment( - venv_root, - source_root, - activation_target, - assume_yes=False, - command_runner=None, - country_detector=None, - confirmation=None, -): - venv_root = _normalized(venv_root) - source_root = _normalized(source_root) - activation_target = _normalized(activation_target) - layout = venv_layout(venv_root) - initial_install = not venv_is_usable(layout) - command_runner = command_runner or run_command - - if initial_install: - print('Create Python venv for RT-Thread...') - host_python = getattr(sys, '_base_executable', sys.executable) - command_runner([host_python, '-m', 'venv', str(venv_root)]) - layout = venv_layout(venv_root) - if not venv_is_usable(layout): - raise BootstrapError('Python venv was created without a usable interpreter or activation script') - - fingerprint = source_fingerprint(source_root) - state = read_state(venv_root) - source_changed = not _state_matches(state, source_root, fingerprint) - activation_changed = not activation_is_current(source_root, activation_target) - package_missing = not env_is_installed(layout) - upgrade_required = source_changed or activation_changed - - if not initial_install and not package_missing and upgrade_required and not assume_yes: - confirmation = confirmation or confirm_upgrade - if not confirmation(): - return 'declined' - - if not initial_install and not package_missing and not upgrade_required: - return 'current' - - index_url = select_index_url(country_detector=country_detector) - install_env(layout, source_root, initial_install, index_url, command_runner=command_runner) - if not env_is_installed(layout): - raise BootstrapError('local Env package installation did not create the rt-env command') - sync_activation_script(source_root, activation_target) - write_state( - venv_root, - { - 'schema': STATE_SCHEMA, - 'source': str(source_root), - 'fingerprint': fingerprint, - 'version': read_env_version(source_root), - 'index': _index_label(index_url), - }, - ) - - if initial_install: - return 'created' - if package_missing: - return 'repaired' - return 'upgraded' - - -def _env_flag(name): - return os.environ.get(name, '').strip().lower() in ('1', 'true', 'yes', 'on') - - -def create_parser(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--venv', required=True, help='Env Python virtual-environment directory') - parser.add_argument( - '--source', - help='local Env tools/scripts source directory (default: directory containing this helper)', - ) - parser.add_argument( - '--activation-script', - help='activation-script copy used by the shell (default: env.sh or env.ps1 beside the venv)', - ) - parser.add_argument('--yes', action='store_true', help='accept a pending local-source upgrade') - parser.add_argument( - '--mark-current', - action='store_true', - help='record an installation completed by a legacy activation script', - ) - return parser - - -def main(argv=None): - args = create_parser().parse_args(argv) - source_root = args.source or Path(__file__).resolve().parent - activation_target = args.activation_script or default_activation_script(args.venv) - try: - if args.mark_current: - mark_environment_current(args.venv, source_root, activation_target) - return 0 - status = ensure_environment( - args.venv, - source_root, - activation_target, - assume_yes=args.yes or _env_flag('ENV_VENV_AUTO_UPGRADE'), - ) - if status == 'upgraded': - print('Env Python venv upgraded from the current local scripts.') - elif status == 'repaired': - print('Env package installation repaired in the existing Python venv.') - return 0 - except (BootstrapError, OSError, subprocess.CalledProcessError) as exc: - print('Failed to prepare the Env Python venv: %s' % exc, file=sys.stderr) - return 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/info.py b/info.py new file mode 100644 index 00000000..397cbf10 --- /dev/null +++ b/info.py @@ -0,0 +1,171 @@ +# -*- coding:utf-8 -*- +# +# File : info.py +# This file is part of RT-Thread RTOS +# COPYRIGHT (C) 2006 - 2018, RT-Thread Development Team +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Change Logs: +# Date Author Notes +# 2025-06-23 Dongly Add get_rt_env_version function +# 2026-09-10 Dongly Add get_rt_env_description function, Extract load_env_json common helper +# 2026-09-12 Dongly Rename version.py to info.py; single access layer for env.json (metadata, repositories, submodule mirrors, service endpoints) + +import json +import os +import platform + +from collections import namedtuple + +# Fallback snapshot of the shipped env.json, WITHOUT mirror entries: the +# defaults only guarantee the primary source of each repository. A mirror is +# an optional accelerator that comes from env.json alone — with no mirror +# configured, the primary source is used. +DEFAULTS = { + 'name': 'RT-Thread Env Tool', + 'version': 'v2.0.2', + 'description': 'A command-line toolkit for RT-Thread development.', + 'repositories': { + 'env': { + 'url': 'https://github.com/RT-Thread/env.git', + 'branch': 'master', + }, + 'sdk': { + 'url': 'https://github.com/RT-Thread/sdk.git', + 'branch': 'main', + }, + 'packages': { + 'url': 'https://github.com/RT-Thread/packages.git', + 'branch': 'master', + }, + }, + 'submodule_mirrors': { + 'rt-thread': 'https://gitee.com/RT-Thread-Mirror/submod_{name}.git', + 'esp': 'https://gitee.com/esp-submodules/{name}.git', + }, + 'apis': { + 'mirror_query': 'https://api.rt-thread.org/packages/queries', + 'statistics': 'https://www.rt-thread.org/studio/statistics/api/envuse', + }, +} + +# A repository source is an atomic (url, branch) pair: selecting a mirror +# switches the whole pair, so a primary URL can never be paired with a +# mirror branch. +Source = namedtuple('Source', ['url', 'branch']) + + +def load_env_json(): + # try to read env.json to get information + try: + # Get the directory where this script is located + script_dir = os.path.dirname(os.path.abspath(__file__)) + env_json_path = os.path.join(script_dir, 'env.json') + + # If not found in script directory, try ENV_ROOT + if not os.path.exists(env_json_path): + env_root = os.getenv("ENV_ROOT") + if env_root is None: + if platform.system() != 'Windows': + env_root = os.path.join(os.getenv('HOME'), '.rt-env') + else: + env_root = os.path.join(os.getenv('USERPROFILE'), '.rt-env') + env_json_path = os.path.join(env_root, 'tools', 'scripts', 'env.json') + + with open(env_json_path, 'r') as file: + return json.load(file) + except Exception as e: + # Only print error if running interactively (not imported) + if __name__ == '__main__': + print("Failed to read env.json: %s" % str(e)) + + return None + + +def _config_section(section): + # return the raw config section ({} when absent or malformed) + config = load_env_json() or {} + value = config.get(section) + return value if isinstance(value, dict) else {} + + +def get_name(): + name = (load_env_json() or {}).get('name') + if not isinstance(name, str): + name = DEFAULTS['name'] + return name + + +def get_version(): + version = (load_env_json() or {}).get('version') + if not isinstance(version, str): + version = DEFAULTS['version'] + return version + + +def get_description(): + description = (load_env_json() or {}).get('description') + if not isinstance(description, str): + description = DEFAULTS['description'] + return description + + +def get_source(repo, use_mirror=False, branch=None): + # Resolve a repository source as Source(url, branch). + # Priority: explicit branch argument > configured branch of that source + # (a mirror without 'branch' inherits the primary branch) > DEFAULTS + # branch. DEFAULTS never supplies a mirror: with no mirror url in + # env.json, the primary source is used. Unknown repository names and + # entries without a url raise KeyError — config problems fail loudly. + raw_entry = _config_section('repositories').get(repo) + if not isinstance(raw_entry, dict): + raw_entry = {} + if repo not in DEFAULTS['repositories'] and not raw_entry: + raise KeyError('unknown repository: %r' % (repo,)) + + default_entry = DEFAULTS['repositories'].get(repo, {}) + url = raw_entry.get('url') or default_entry.get('url') + resolved_branch = raw_entry.get('branch') or default_entry.get('branch') + + if use_mirror: + mirror = raw_entry.get('mirror') if isinstance(raw_entry.get('mirror'), dict) else {} + if mirror.get('url'): + url = mirror['url'] + resolved_branch = mirror.get('branch') or resolved_branch + + if url is None: + raise KeyError("repository %r is configured without a 'url'" % (repo,)) + if branch is not None: + resolved_branch = branch + return Source(url=url, branch=resolved_branch) + + +def get_submodule_mirror_url(kind, name): + # Instantiate a submodule mirror template ('{name}' placeholder) for the + # given submodule name. kind: 'rt-thread' or 'esp'. ESP name case mapping + # (unity -> Unity) is caller-side logic, not configuration. + template = _config_section('submodule_mirrors').get(kind) or DEFAULTS['submodule_mirrors'].get(kind) + if not isinstance(template, str): + raise KeyError('unknown submodule mirror kind: %r' % (kind,)) + return template.replace('{name}', name) + + +def get_api_url(name): + # Service endpoints. name: 'mirror_query' or 'statistics'. + url = _config_section('apis').get(name) or DEFAULTS['apis'].get(name) + if not isinstance(url, str): + raise KeyError('unknown api name: %r' % (name,)) + return url diff --git a/install_arch.sh b/install_arch.sh deleted file mode 100755 index cde77d73..00000000 --- a/install_arch.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash - -TOUCH_ENV_URL=https://raw.githubusercontent.com/RT-Thread/env/master/touch_env.sh -COUNTRY=$(wget -qO- --timeout=3 https://ipinfo.io/country 2>/dev/null) -if [ "$COUNTRY" = "CN" ]; then - TOUCH_ENV_URL=https://gitee.com/RT-Thread-Mirror/env/raw/master/touch_env.sh -elif [ -z "$COUNTRY" ] && [ -t 0 ]; then - read -r -p "Unable to detect network region. Use Gitee mirror? (y/N, default: GitHub) " use_gitee - if [[ "$use_gitee" =~ ^[Yy]$ ]]; then - TOUCH_ENV_URL=https://gitee.com/RT-Thread-Mirror/env/raw/master/touch_env.sh - fi -fi - -# 函数:从 AUR 安装 rt-thread-env-meta 包 -install_from_aur() { - echo "正在从 AUR 安装 rt-thread-env-meta 包..." - yay -Syu rt-thread-env-meta -} - -# 函数:手动安装所需的包 -install_manually() { - echo "正在手动安装所需的包..." - - # 安装基本依赖包 - sudo pacman -Syu python python-pip gcc git ncurses \ - arm-none-eabi-gcc arm-none-eabi-gdb \ - qemu-desktop qemu-system-arm-firmware scons \ - python-requests python-tqdm python-kconfiglib - - # 提示用户安装 python-pyocd 及其插件 - echo " - # python-pyocd 可以通过 AUR 安装或从 GitHub 获取: - # https://github.com/taotieren/aur-repo - yay -Syu python-pyocd python-pyocd-pemicro - " - - # 询问用户是否要继续安装 python-pyocd - read -p "是否现在安装 python-pyocd 和 python-pyocd-pemicro? (y/n) " choice - case "$choice" in - y | Y) - yay -Syu python-pyocd python-pyocd-pemicro - ;; - n | N) - echo "跳过安装 python-pyocd 和 python-pyocd-pemicro." - ;; - *) - echo "无效输入,跳过安装 python-pyocd 和 python-pyocd-pemicro." - ;; - esac -} - -# 显示菜单供用户选择 -echo "请选择安装方式:" -echo "1. 从 AUR 安装 rt-thread-env-meta 包" -echo "2. 手动安装所有所需包" -read -p "请输入选项 [1 或 2]: " option - -case $option in -1) - install_from_aur - ;; -2) - install_manually - ;; -*) - echo "无效选项,退出安装程序。" - exit 1 - ;; -esac - -echo "安装完成。" - -wget "$TOUCH_ENV_URL" -O touch_env.sh -chmod 777 touch_env.sh -./touch_env.sh -rm touch_env.sh diff --git a/install_macos.sh b/install_macos.sh deleted file mode 100755 index a9ab0c45..00000000 --- a/install_macos.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash - -RTT_PYTHON=python - -for p_cmd in python3 python; do - $p_cmd --version >/dev/null 2>&1 || continue - RTT_PYTHON=$p_cmd - break -done - -$RTT_PYTHON --version 2 >/dev/null || { - echo "Python not installed. Please install Python before running the installation script." - exit 1 -} - -if ! [ -x "$(command -v brew)" ]; then - echo "Installing Homebrew." - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -fi - -brew update -brew upgrade - -if ! [ -x "$(command -v git)" ]; then - echo "Installing git." - brew install git -fi - -brew list ncurses >/dev/null || { - echo "Installing ncurses." - brew install ncurses -} - -$RTT_PYTHON -m pip list >/dev/null || { - echo "Installing pip." - $RTT_PYTHON -m ensurepip --upgrade -} - -if ! [ -x "$(command -v scons)" ]; then - echo "Installing scons." - $RTT_PYTHON -m pip install scons -fi - -if ! [ -x "$(command -v tqdm)" ]; then - echo "Installing tqdm." - $RTT_PYTHON -m pip install tqdm -fi - -if ! [ -x "$(command -v kconfiglib)" ]; then - echo "Installing kconfiglib." - $RTT_PYTHON -m pip install kconfiglib -fi - -if ! [ -x "$(command -v pyocd)" ]; then - echo "Installing pyocd." - $RTT_PYTHON -m pip install -U pyocd -fi - -if ! [[ $($RTT_PYTHON -m pip list | grep requests) ]]; then - echo "Installing requests." - $RTT_PYTHON -m pip install requests -fi - -if ! [ -x "$(command -v arm-none-eabi-gcc)" ]; then - echo "Installing GNU Arm Embedded Toolchain." - brew install gnu-arm-embedded -fi - -curl https://raw.githubusercontent.com/RT-Thread/env/master/touch_env.sh -o touch_env.sh -chmod 777 touch_env.sh -./touch_env.sh -rm touch_env.sh diff --git a/install_suse.sh b/install_suse.sh deleted file mode 100755 index 63a52048..00000000 --- a/install_suse.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash - -TOUCH_ENV_URL=https://raw.githubusercontent.com/RT-Thread/env/master/touch_env.sh -COUNTRY=$(wget -qO- --timeout=3 https://ipinfo.io/country 2>/dev/null) -if [ "$COUNTRY" = "CN" ]; then - TOUCH_ENV_URL=https://gitee.com/RT-Thread-Mirror/env/raw/master/touch_env.sh -elif [ -z "$COUNTRY" ] && [ -t 0 ]; then - read -r -p "Unable to detect network region. Use Gitee mirror? (y/N, default: GitHub) " use_gitee - if [[ "$use_gitee" =~ ^[Yy]$ ]]; then - TOUCH_ENV_URL=https://gitee.com/RT-Thread-Mirror/env/raw/master/touch_env.sh - fi -fi - -sudo zypper update -y - -sudo zypper install python3 python3-pip python3-venv gcc git ncurses-devel cross-arm-none-gcc11-bootstrap cross-arm-binutils qemu qemu-arm qemu-extra -y -python3 -m pip install scons requests tqdm kconfiglib -python3 -m pip install -U pyocd - -wget "$TOUCH_ENV_URL" -O touch_env.sh -chmod 777 touch_env.sh -./touch_env.sh -rm touch_env.sh diff --git a/install_ubuntu.sh b/install_ubuntu.sh index 8174bcfc..772ca1de 100755 --- a/install_ubuntu.sh +++ b/install_ubuntu.sh @@ -1,21 +1,138 @@ #!/usr/bin/env bash +# +# DEPRECATED / 已废弃 +# +# 此脚本已废弃,推荐直接使用 tools/install.sh +# +# Ubuntu Quick Install Script (Deprecated) +# Usage: +# ./install_ubuntu.sh # Auto-install (auto-detect mirror) +# ./install_ubuntu.sh --cn # Auto-install (China mirror) +# ./install_ubuntu.sh --gitee # Auto-install (Gitee) +# +# Deprecated: Please use tools/install.sh directly +# curl https://raw.githubusercontent.com/RT-Thread/env/master/tools/install.sh | bash -s -- -y +# curl https://gitee.com/RT-Thread-Mirror/env/raw/master/tools/install.sh | bash -s -- -y --cn +# +# This script maintains backward compatibility with old versions while +# delegating to the new unified install.sh script +# -TOUCH_ENV_URL=https://raw.githubusercontent.com/RT-Thread/env/master/touch_env.sh -COUNTRY=$(wget -qO- --timeout=3 https://ipinfo.io/country 2>/dev/null) -if [ "$COUNTRY" = "CN" ]; then - TOUCH_ENV_URL=https://gitee.com/RT-Thread-Mirror/env/raw/master/touch_env.sh -elif [ -z "$COUNTRY" ] && [ -t 0 ]; then - read -r -p "Unable to detect network region. Use Gitee mirror? (y/N, default: GitHub) " use_gitee - if [[ "$use_gitee" =~ ^[Yy]$ ]]; then - TOUCH_ENV_URL=https://gitee.com/RT-Thread-Mirror/env/raw/master/touch_env.sh +set -e + +# ============================================================================ +# Configuration +# ============================================================================ + +# URL configurations +URL_GITHUB="https://raw.githubusercontent.com/RT-Thread/env/master/tools/install.sh" +URL_GITEE="https://gitee.com/RT-Thread-Mirror/env/raw/master/tools/install.sh" + +# IP detection service +IPINFO_URL="https://ipinfo.io/json" + +# Environment directory (compatible with old versions - use .env by default) +ENV_DEFAULT_DIR=".env" +: "${ENV_ROOT:=$HOME/$ENV_DEFAULT_DIR}" + +# ============================================================================ +# Activate Virtual Environment (for backward compatibility) +# ============================================================================ + +activate_venv() { + # Activate the virtual environment if it exists + local venv_path="$ENV_ROOT/venv/rt-env/bin/activate" + if [ -f "$venv_path" ]; then + source "$venv_path" + echo "✓ Virtual environment activated" + else + echo "⚠ Virtual environment not found at $venv_path" fi +} + +# ============================================================================ +# Main +# ============================================================================ + +# Show deprecation notice +echo "============================================================" +echo " DEPRECATED / 已废弃" +echo "============================================================" +echo "" +echo "此脚本已废弃,推荐直接使用 tools/install.sh" +echo "This script is deprecated, please use tools/install.sh directly" +echo "" +echo "使用 GitHub / Using GitHub:" +echo " curl $URL_GITHUB | bash -s -- -y" +echo "" +echo "使用中国镜像 / Using China Mirror:" +echo " curl $URL_GITEE | bash -s -- -y --cn" +echo "" +echo "============================================================" +echo "" + +# Parse arguments +USE_CN="" +USE_CN_SET="false" +OTHER_ARGS="" + +for arg in "$@"; do + case "$arg" in + --cn|--gitee) + USE_CN="true" + USE_CN_SET="true" + ;; + --no-mirror) + USE_CN="false" + USE_CN_SET="true" + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --cn, --gitee Use China mirror (Gitee)" + echo " --no-mirror Force use official GitHub source" + echo " --help, -h Show this help" + echo "" + echo "This script downloads and executes the new install.sh with" + echo "backward compatibility settings for old .env path and GitHub Actions." + exit 0 + ;; + *) + OTHER_ARGS="$OTHER_ARGS $arg" + ;; + esac +done + +# Auto-detect China if not explicitly set +if [[ "$USE_CN_SET" == "false" ]]; then + USE_CN=$(detect_china) fi -sudo apt-get update -sudo apt-get -qq install python3 python3-pip python3-venv gcc git libncurses5-dev -y -pip install scons requests tqdm kconfiglib pyyaml +# Determine URL +if [[ "$USE_CN" == "true" ]]; then + INSTALL_URL="$URL_GITEE" +else + INSTALL_URL="$URL_GITHUB" +fi -wget "$TOUCH_ENV_URL" -O touch_env.sh -chmod 777 touch_env.sh -./touch_env.sh -rm touch_env.sh +echo "检测到位置: $([ "$USE_CN" == "true" ] && echo "中国大陆" || echo "其他地区")" +echo "下载地址: $INSTALL_URL" +echo "" + +# Download and execute install.sh directly (without writing to disk) +wget -qO- "$INSTALL_URL" | bash -s -- -y --env-root "$ENV_ROOT" $OTHER_ARGS + +# Activate virtual environment after installation +if [ -d "$ENV_ROOT" ]; then + echo "" + echo "============================================================" + echo "激活虚拟环境 / Activating Virtual Environment" + echo "============================================================" + echo "" + activate_venv + echo "" + echo "To activate the environment manually, run:" + echo " source $ENV_ROOT/env.sh" + echo "" +fi diff --git a/install_windows.ps1 b/install_windows.ps1 deleted file mode 100644 index 23131bbe..00000000 --- a/install_windows.ps1 +++ /dev/null @@ -1,136 +0,0 @@ - -$RTT_PYTHON = "python" - -function Test-Command( [string] $CommandName ) { - (Get-Command $CommandName -ErrorAction SilentlyContinue) -ne $null -} - -foreach ($p_cmd in ("python3", "python", "py")) { - cmd /c $p_cmd --version | findstr "Python" | Out-Null - if (!$?) { continue } - $RTT_PYTHON = $p_cmd - break -} - -cmd /c $RTT_PYTHON --version | findstr "Python" | Out-Null -if (!$?) { - echo "Python is not installed. Will install python 3.11.2." - echo "Downloading Python." - wget -O Python_setup.exe https://www.python.org/ftp/python/3.11.2/python-3.11.2.exe - echo "Installing Python." - if (Test-Path -Path "D:\") { - cmd /c Python_setup.exe /quiet TargetDir=D:\Progrem\Python311 InstallAllUsers=1 PrependPath=1 Include_test=0 - } else { - cmd /c Python_setup.exe /quiet PrependPath=1 Include_test=0 - } - echo "Install Python done. please close the current terminal and run this script again." - exit -} else { - echo "Python environment has installed. Jump this step." -} - -try { - $useGitee = (Invoke-RestMethod -Uri "https://ipinfo.io/json" -UseBasicParsing -TimeoutSec 3).country -eq "CN" -} catch { - $useGitee = $false -} - -$git_url = "https://github.com/git-for-windows/git/releases/download/v2.39.2.windows.1/Git-2.39.2-64-bit.exe" -if ($useGitee) { - $git_url = "https://registry.npmmirror.com/-/binary/git-for-windows/v2.39.2.windows.1/Git-2.39.2-64-bit.exe" -} - -if (!(Test-Command git)) { - echo "Git is not installed. Will install Git." - echo "Installing git." - winget install --id Git.Git -e --source winget - if (!$?) { - echo "Can't find winget cmd, Will install git 2.39.2." - echo "downloading git." - wget -O Git64.exe $git_url - echo "Please install git. when install done, close the current terminal and run this script again." - cmd /c Git64.exe /quiet PrependPath=1 - exit - } -} else { - echo "Git environment has installed. Jump this step." -} - -$PIP_SOURCE = "https://pypi.org/simple" -$PIP_HOST = "pypi.org" -if ($useGitee) { - $PIP_SOURCE = "http://mirrors.aliyun.com/pypi/simple" - $PIP_HOST = "mirrors.aliyun.com" -} - -cmd /c $RTT_PYTHON -m pip list -i $PIP_SOURCE --trusted-host $PIP_HOST | Out-Null -if (!$?) { - echo "Installing pip." - cmd /c $RTT_PYTHON -m ensurepip --upgrade -} else { - echo "Pip has installed. Jump this step." -} - -cmd /c $RTT_PYTHON -m pip install --upgrade pip -i $PIP_SOURCE --trusted-host $PIP_HOST | Out-Null - -if (!(Test-Command scons)) { - echo "Installing scons." - cmd /c $RTT_PYTHON -m pip install scons -i $PIP_SOURCE --trusted-host $PIP_HOST -} else { - echo "scons has installed. Jump this step." -} - -if (!(Test-Command pyocd)) { - echo "Installing pyocd." - cmd /c $RTT_PYTHON -m pip install -U pyocd -i $PIP_SOURCE --trusted-host $PIP_HOST -} else { - echo "pyocd has installed. Jump this step." -} - -cmd /c $RTT_PYTHON -m pip list -i $PIP_SOURCE --trusted-host $PIP_HOST | findstr "tqdm" | Out-Null -if (!$?) { - echo "Installing tqdm module." - cmd /c $RTT_PYTHON -m pip install tqdm -i $PIP_SOURCE --trusted-host $PIP_HOST -} else { - echo "tqdm module has installed. Jump this step." -} - -cmd /c $RTT_PYTHON -m pip list -i $PIP_SOURCE --trusted-host $PIP_HOST | findstr "kconfiglib" | Out-Null -if (!$?) { - echo "Installing kconfiglib module." - cmd /c $RTT_PYTHON -m pip install kconfiglib -i $PIP_SOURCE --trusted-host $PIP_HOST -} else { - echo "kconfiglib module has installed. Jump this step." -} - - -cmd /c $RTT_PYTHON -m pip list -i $PIP_SOURCE --trusted-host $PIP_HOST | findstr "requests" | Out-Null -if (!$?) { - echo "Installing requests module." - cmd /c $RTT_PYTHON -m pip install requests -i $PIP_SOURCE --trusted-host $PIP_HOST -} else { - echo "requests module has installed. Jump this step." -} - -cmd /c $RTT_PYTHON -m pip list -i $PIP_SOURCE --trusted-host $PIP_HOST | findstr "psutil" | Out-Null -if (!$?) { - echo "Installing psutil module." - cmd /c $RTT_PYTHON -m pip install psutil -i $PIP_SOURCE --trusted-host $PIP_HOST -} else { - echo "psutil module has installed. Jump this step." -} - -$url = "https://raw.githubusercontent.com/RT-Thread/env/master/touch_env.ps1" -if ($useGitee) { - $url = "https://gitee.com/RT-Thread-Mirror/env/raw/master/touch_env.ps1" -} - -wget $url -O touch_env.ps1 -echo "run touch_env.ps1" -./touch_env.ps1 - -if ($args.Count -ge 2 -and $args[1] -eq "-y") { - echo "Windows Env environment installment has finished. (auto mode, no pause)" -} else { - Read-Host -Prompt "Windows Env environment installment has finished. Press any key to continue..." -} diff --git a/plugins/compatibility.py b/plugins/compatibility.py index 3f8059fc..9d2ac4cd 100644 --- a/plugins/compatibility.py +++ b/plugins/compatibility.py @@ -101,10 +101,10 @@ def python_abi(): def current_env_version(): try: - from version import get_rt_env_version + from info import get_version except ImportError: - from env.version import get_rt_env_version - return get_rt_env_version()[1] + from env.info import get_version + return get_version() def compatibility_issues(manifest, env_version=None, system=None, architecture=None, implementation=None, abi=None): diff --git a/plugins/tests/test_env_venv.py b/plugins/tests/test_env_venv.py deleted file mode 100644 index 6e1b993c..00000000 --- a/plugins/tests/test_env_venv.py +++ /dev/null @@ -1,340 +0,0 @@ -import io -import json -import os -from pathlib import Path -import subprocess -import sys -import tempfile -import unittest -from unittest import mock - -import env_venv - - -REPOSITORY = Path(__file__).resolve().parents[2] - - -class FakeRunner: - def __init__(self, venv_root, source_root): - self.venv_root = Path(venv_root) - self.source_root = Path(source_root).resolve() - self.commands = [] - - def __call__(self, command): - command = [str(value) for value in command] - self.commands.append(command) - if command[1:3] == ['-m', 'venv']: - layout = env_venv.venv_layout(self.venv_root) - layout['python'].parent.mkdir(parents=True, exist_ok=True) - layout['python'].write_text('python\n', encoding='utf-8') - layout['activate'].write_text('activate\n', encoding='utf-8') - if command[1:4] == ['-m', 'pip', 'install'] and command[-1] == str(self.source_root): - layout = env_venv.venv_layout(self.venv_root) - layout['rt_env'].write_text('rt-env\n', encoding='utf-8') - - -class FakeResponse: - def __init__(self, content): - self.content = content - - def __enter__(self): - return self - - def __exit__(self, exception_type, exception, traceback): - return False - - def read(self, size=-1): - return self.content[:size] - - -class EnvVenvTest(unittest.TestCase): - def setUp(self): - self.environment = mock.patch.dict(os.environ, {'ENV_PYPI_INDEX_URL': ''}) - self.environment.start() - self.temporary = tempfile.TemporaryDirectory() - self.root = Path(self.temporary.name) - self.source = self.root / 'tools' / 'scripts' - self.venv = self.root / '.venv' - self.activation = self.root / 'env.sh' - self._create_source() - - def tearDown(self): - self.temporary.cleanup() - self.environment.stop() - - def _write(self, relative, content): - path = self.source / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding='utf-8') - return path - - def _create_source(self): - self._write('setup.py', 'from setuptools import setup\n') - self._write('pyproject.toml', '[tool.black]\n') - self._write('env.json', '{"version": "v2.0.2"}\n') - self._write('env.sh', 'source activation v1\n') - self._write('env.ps1', 'powershell activation v1\n') - self._write('env_venv.py', 'bootstrap v1\n') - self._write('env.py', 'runtime root v1\n') - self._write('cmds/tool.py', 'command v1\n') - self._write('plugins/runtime.py', 'plugin v1\n') - self._write('plugins/README.md', 'plugin documentation v1\n') - self._write('plugins/webui/static/index.html', '

runtime v1

\n') - self._write('plugins/tests/test_ignored.py', 'test v1\n') - self._write('plugins/examples/example.txt', 'example v1\n') - self._write('plugins/webui/frontend/src/App.vue', 'frontend v1\n') - self._write('docs/ignored.md', 'docs v1\n') - - def _install(self, country='US'): - runner = FakeRunner(self.venv, self.source) - status = env_venv.ensure_environment( - self.venv, - self.source, - self.activation, - command_runner=runner, - country_detector=lambda: country, - ) - return status, runner - - def test_fingerprint_tracks_runtime_and_ignores_development_files(self): - original = env_venv.source_fingerprint(self.source) - self._write('docs/ignored.md', 'docs v2\n') - self._write('plugins/tests/test_ignored.py', 'test v2\n') - self._write('plugins/examples/example.txt', 'example v2\n') - self._write('plugins/webui/frontend/src/App.vue', 'frontend v2\n') - self._write('plugins/README.md', 'plugin documentation v2\n') - self.assertEqual(env_venv.source_fingerprint(self.source), original) - - self._write('cmds/tool.py', 'command v2\n') - self.assertNotEqual(env_venv.source_fingerprint(self.source), original) - - def test_initial_install_uses_aliyun_and_writes_sanitized_state(self): - status, runner = self._install(country='CN') - self.assertEqual(status, 'created') - self.assertEqual(self.activation.read_text(encoding='utf-8'), 'source activation v1\n') - pip_commands = [command for command in runner.commands if command[1:4] == ['-m', 'pip', 'install']] - self.assertEqual(len(pip_commands), 2) - self.assertTrue(all(env_venv.ALIYUN_INDEX_URL in command for command in pip_commands)) - - with (self.venv / env_venv.STATE_FILENAME).open('r', encoding='utf-8') as source: - state = json.load(source) - self.assertEqual(state['schema'], env_venv.STATE_SCHEMA) - self.assertEqual(state['index'], 'aliyun') - self.assertNotIn('index_url', state) - - def test_current_install_does_not_prompt_run_pip_or_detect_country(self): - self._install() - runner = FakeRunner(self.venv, self.source) - - def unexpected(): - raise AssertionError('current installations must not prompt or detect the country') - - status = env_venv.ensure_environment( - self.venv, - self.source, - self.activation, - command_runner=runner, - country_detector=unexpected, - confirmation=unexpected, - ) - self.assertEqual(status, 'current') - self.assertEqual(runner.commands, []) - - def test_declined_upgrade_keeps_state_and_activation_copy(self): - self._install() - old_state = (self.venv / env_venv.STATE_FILENAME).read_text(encoding='utf-8') - self._write('env.sh', 'source activation v2\n') - runner = FakeRunner(self.venv, self.source) - status = env_venv.ensure_environment( - self.venv, - self.source, - self.activation, - command_runner=runner, - country_detector=lambda: (_ for _ in ()).throw(AssertionError('must not detect country')), - confirmation=lambda: False, - ) - self.assertEqual(status, 'declined') - self.assertEqual(runner.commands, []) - self.assertEqual(self.activation.read_text(encoding='utf-8'), 'source activation v1\n') - self.assertEqual((self.venv / env_venv.STATE_FILENAME).read_text(encoding='utf-8'), old_state) - - def test_confirmed_upgrade_reinstalls_and_synchronizes_activation(self): - self._install() - self._write('env.sh', 'source activation v2\n') - runner = FakeRunner(self.venv, self.source) - status = env_venv.ensure_environment( - self.venv, - self.source, - self.activation, - command_runner=runner, - country_detector=lambda: 'US', - confirmation=lambda: True, - ) - self.assertEqual(status, 'upgraded') - self.assertEqual(self.activation.read_text(encoding='utf-8'), 'source activation v2\n') - pip_commands = [command for command in runner.commands if command[1:4] == ['-m', 'pip', 'install']] - self.assertEqual(len(pip_commands), 1) - self.assertNotIn('--index-url', pip_commands[0]) - self.assertEqual(env_venv.read_state(self.venv)['fingerprint'], env_venv.source_fingerprint(self.source)) - - def test_missing_package_is_repaired_without_upgrade_prompt(self): - layout = env_venv.venv_layout(self.venv) - layout['python'].parent.mkdir(parents=True) - layout['python'].write_text('python\n', encoding='utf-8') - layout['activate'].write_text('activate\n', encoding='utf-8') - runner = FakeRunner(self.venv, self.source) - status = env_venv.ensure_environment( - self.venv, - self.source, - self.activation, - command_runner=runner, - country_detector=lambda: 'US', - confirmation=lambda: (_ for _ in ()).throw(AssertionError('repair must not prompt')), - ) - self.assertEqual(status, 'repaired') - self.assertTrue(layout['rt_env'].is_file()) - - def test_country_detection_and_explicit_index_override(self): - with mock.patch.object(env_venv, 'urlopen', return_value=FakeResponse(b'CN\n')) as request: - self.assertEqual(env_venv.detect_country(), 'CN') - self.assertEqual(request.call_args[1]['timeout'], 3) - - with mock.patch.object(env_venv, 'urlopen', side_effect=OSError('offline')): - self.assertIsNone(env_venv.detect_country()) - - output = io.StringIO() - with mock.patch('sys.stdout', new=output): - selected = env_venv.select_index_url( - country_detector=lambda: (_ for _ in ()).throw(AssertionError('override must skip detection')), - environ={'ENV_PYPI_INDEX_URL': 'https://user:secret@example.invalid/simple'}, - ) - self.assertEqual(selected, 'https://user:secret@example.invalid/simple') - self.assertNotIn('secret', output.getvalue()) - - def test_windows_layout_uses_scripts_directory(self): - layout = env_venv.venv_layout(self.venv, platform_name='nt') - self.assertEqual(layout['python'], self.venv.resolve() / 'Scripts' / 'python.exe') - self.assertEqual(layout['activate'], self.venv.resolve() / 'Scripts' / 'Activate.ps1') - self.assertEqual(layout['rt_env'], self.venv.resolve() / 'Scripts' / 'rt-env.exe') - - def test_default_activation_script_is_beside_the_venv(self): - self.assertEqual( - env_venv.default_activation_script(self.venv, platform_name='posix'), - self.root.resolve() / 'env.sh', - ) - self.assertEqual( - env_venv.default_activation_script(self.venv, platform_name='nt'), - self.root.resolve() / 'env.ps1', - ) - - def test_legacy_arguments_derive_source_and_activation_defaults(self): - with mock.patch.object(env_venv, 'ensure_environment', return_value='current') as ensure: - result = env_venv.main(['--venv', str(self.venv)]) - - self.assertEqual(result, 0) - self.assertEqual(ensure.call_args.args[0], str(self.venv)) - self.assertEqual(ensure.call_args.args[1], Path(env_venv.__file__).resolve().parent) - self.assertEqual(ensure.call_args.args[2], self.root.resolve() / 'env.sh') - - def test_mark_current_legacy_argument_records_state_and_syncs_activation(self): - layout = env_venv.venv_layout(self.venv) - layout['python'].parent.mkdir(parents=True) - layout['python'].write_text('python\n', encoding='utf-8') - layout['activate'].write_text('activate\n', encoding='utf-8') - layout['rt_env'].write_text('rt-env\n', encoding='utf-8') - - result = env_venv.main( - [ - '--venv', - str(self.venv), - '--source', - str(self.source), - '--mark-current', - ] - ) - - self.assertEqual(result, 0) - self.assertEqual(self.activation.read_text(encoding='utf-8'), 'source activation v1\n') - state = env_venv.read_state(self.venv) - self.assertEqual(state['source'], str(self.source.resolve())) - self.assertEqual(state['fingerprint'], env_venv.source_fingerprint(self.source)) - self.assertEqual(state['index'], 'existing') - - def test_legacy_env_sh_can_call_new_helper_with_only_venv(self): - env_root = self.root / 'legacy-env' - scripts = env_root / 'tools' / 'scripts' - scripts.mkdir(parents=True) - scripts_env_venv = scripts / 'env_venv.py' - scripts_env_venv.write_text((REPOSITORY / 'env_venv.py').read_text(encoding='utf-8'), encoding='utf-8') - for name in ('env.sh', 'env.ps1', 'setup.py', 'env.py', 'env.json'): - (scripts / name).write_text((REPOSITORY / name).read_text(encoding='utf-8'), encoding='utf-8') - for directory_name in ('cmds', 'plugins'): - (scripts / directory_name).mkdir() - - copied_legacy_script = env_root / 'env.sh' - copied_legacy_script.write_text( - 'VENV_ROOT="$ENV_ROOT/.venv"\n' - 'ENV_SCRIPTS="$ENV_ROOT/tools/scripts"\n' - '. "$VENV_ROOT/bin/activate"\n' - 'python "$ENV_SCRIPTS/env_venv.py" --venv "$VENV_ROOT" --mark-current || return $?\n' - 'python "$ENV_SCRIPTS/env_venv.py" --venv "$VENV_ROOT"\n', - encoding='utf-8', - ) - layout = env_venv.venv_layout(env_root / '.venv') - layout['python'].parent.mkdir(parents=True) - layout['python'].symlink_to(Path(sys.executable).resolve()) - layout['activate'].write_text( - 'PATH="%s:$PATH"\nexport PATH\nENV_TEST_ACTIVATED=1\nexport ENV_TEST_ACTIVATED\n' - % layout['python'].parent, - encoding='utf-8', - ) - layout['rt_env'].write_text('rt-env\n', encoding='utf-8') - - result = subprocess.run( - [ - 'sh', - '-c', - '. "$ENV_ROOT/env.sh"; env_status=$?; ' - 'test "$ENV_TEST_ACTIVATED" = 1 && test "$env_status" -eq 0', - ], - env=dict(os.environ, HOME=str(self.root), ENV_ROOT=str(env_root)), - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - ) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertNotIn('arguments are required', result.stderr) - self.assertEqual( - copied_legacy_script.read_text(encoding='utf-8'), - (scripts / 'env.sh').read_text(encoding='utf-8'), - ) - self.assertIsNotNone(env_venv.read_state(env_root / '.venv')) - - def test_env_sh_attempts_activation_after_bootstrap_failure(self): - env_root = self.root / 'shell-env' - scripts = env_root / 'tools' / 'scripts' - scripts.mkdir(parents=True) - (env_root / 'env.sh').write_text((REPOSITORY / 'env.sh').read_text(encoding='utf-8'), encoding='utf-8') - (scripts / 'env_venv.py').write_text('raise SystemExit(7)\n', encoding='utf-8') - activate = env_root / '.venv' / 'bin' / 'activate' - activate.parent.mkdir(parents=True) - activate.write_text('ENV_TEST_ACTIVATED=1\nexport ENV_TEST_ACTIVATED\n', encoding='utf-8') - - result = subprocess.run( - [ - 'sh', - '-c', - '. "$ENV_ROOT/env.sh"; env_status=$?; ' - 'test "$ENV_TEST_ACTIVATED" = 1 && test "$env_status" -eq 7', - ], - env=dict(os.environ, ENV_ROOT=str(env_root)), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - ) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - -if __name__ == '__main__': - unittest.main() diff --git a/plugins/tests/test_info.py b/plugins/tests/test_info.py new file mode 100644 index 00000000..37e0cd8b --- /dev/null +++ b/plugins/tests/test_info.py @@ -0,0 +1,121 @@ +import json +import os +import unittest +from unittest import mock + +import info + + +def load_shipped_env_json(): + path = os.path.join(os.path.dirname(info.__file__), 'env.json') + with open(path, 'r') as source: + return json.load(source) + + +class DefaultsDriftTest(unittest.TestCase): + """Pin info.DEFAULTS to the shipped env.json. + + DEFAULTS intentionally has no mirror entries; every leaf it does define + must exist in the shipped env.json with the same value, otherwise the + fallback silently diverges from the real configuration. + """ + + def _assert_leaves_match(self, defaults, shipped, path): + for key, value in defaults.items(): + location = '.'.join(path + [str(key)]) + self.assertIn(key, shipped, 'shipped env.json is missing %s' % location) + if isinstance(value, dict): + self.assertIsInstance(shipped[key], dict, '%s should be an object' % location) + self._assert_leaves_match(value, shipped[key], path + [str(key)]) + else: + self.assertEqual(shipped[key], value, 'DEFAULTS drifted from env.json at %s' % location) + + def test_defaults_leaves_match_shipped_env_json(self): + self._assert_leaves_match(info.DEFAULTS, load_shipped_env_json(), []) + + +class InfoAccessorTest(unittest.TestCase): + def test_metadata_from_shipped_config(self): + config = load_shipped_env_json() + with mock.patch.object(info, 'load_env_json', return_value=config): + self.assertEqual(info.get_name(), config['name']) + self.assertEqual(info.get_version(), config['version']) + self.assertEqual(info.get_description(), config['description']) + + def test_metadata_falls_back_to_defaults_without_config(self): + with mock.patch.object(info, 'load_env_json', return_value=None): + self.assertEqual(info.get_name(), info.DEFAULTS['name']) + self.assertEqual(info.get_version(), info.DEFAULTS['version']) + self.assertEqual(info.get_description(), info.DEFAULTS['description']) + + def test_get_source_primary_and_mirror(self): + config = load_shipped_env_json() + with mock.patch.object(info, 'load_env_json', return_value=config): + primary = info.get_source('env') + mirror = info.get_source('env', use_mirror=True) + self.assertEqual(primary.url, config['repositories']['env']['url']) + self.assertEqual(primary.branch, config['repositories']['env']['branch']) + self.assertEqual(mirror.url, config['repositories']['env']['mirror']['url']) + self.assertEqual(mirror.branch, config['repositories']['env']['mirror']['branch']) + + def test_get_source_falls_back_to_defaults(self): + with mock.patch.object(info, 'load_env_json', return_value=None): + source = info.get_source('packages') + self.assertEqual(source.url, info.DEFAULTS['repositories']['packages']['url']) + self.assertEqual(source.branch, info.DEFAULTS['repositories']['packages']['branch']) + + def test_mirror_is_optional_and_falls_back_to_primary(self): + config = {'repositories': {'env': {'url': 'https://example.com/env.git', 'branch': 'dev'}}} + with mock.patch.object(info, 'load_env_json', return_value=config): + source = info.get_source('env', use_mirror=True) + self.assertEqual(source.url, 'https://example.com/env.git') + self.assertEqual(source.branch, 'dev') + + def test_mirror_branch_inherits_primary_branch(self): + config = {'repositories': {'env': {'url': 'u1', 'branch': 'v2', 'mirror': {'url': 'm1'}}}} + with mock.patch.object(info, 'load_env_json', return_value=config): + source = info.get_source('env', use_mirror=True) + self.assertEqual(source.url, 'm1') + self.assertEqual(source.branch, 'v2') + + def test_explicit_branch_overrides_configuration(self): + config = load_shipped_env_json() + with mock.patch.object(info, 'load_env_json', return_value=config): + source = info.get_source('env', branch='release-2.0') + self.assertEqual(source.branch, 'release-2.0') + + def test_unknown_repository_raises(self): + with mock.patch.object(info, 'load_env_json', return_value={}): + self.assertRaises(KeyError, info.get_source, 'no-such-repo') + + def test_unknown_api_raises(self): + with mock.patch.object(info, 'load_env_json', return_value={}): + self.assertRaises(KeyError, info.get_api_url, 'no-such-api') + + def test_custom_repo_from_config(self): + config = {'repositories': {'mine': {'url': 'git@example.com:me/env.git', 'branch': 'dev'}}} + with mock.patch.object(info, 'load_env_json', return_value=config): + source = info.get_source('mine') + self.assertEqual(source.url, 'git@example.com:me/env.git') + self.assertEqual(source.branch, 'dev') + + def test_custom_repo_without_url_raises(self): + config = {'repositories': {'mine': {'branch': 'dev'}}} + with mock.patch.object(info, 'load_env_json', return_value=config): + self.assertRaises(KeyError, info.get_source, 'mine') + + def test_submodule_mirror_template(self): + with mock.patch.object(info, 'load_env_json', return_value={}): + url = info.get_submodule_mirror_url('rt-thread', 'lwip') + self.assertEqual(url, 'https://gitee.com/RT-Thread-Mirror/submod_lwip.git') + + def test_api_url_from_config_or_defaults(self): + with mock.patch.object(info, 'load_env_json', return_value={}): + self.assertEqual(info.get_api_url('statistics'), info.DEFAULTS['apis']['statistics']) + config = {'apis': {'statistics': 'https://example.com/stats'}} + with mock.patch.object(info, 'load_env_json', return_value=config): + self.assertEqual(info.get_api_url('statistics'), 'https://example.com/stats') + + +if __name__ == '__main__': + unittest.main() diff --git a/plugins/tests/test_root_activator.py b/plugins/tests/test_root_activator.py new file mode 100644 index 00000000..197b758e --- /dev/null +++ b/plugins/tests/test_root_activator.py @@ -0,0 +1,65 @@ +"""Unit tests for the post-upgrade root activator regeneration. + +Covers cmds.cmd_package.cmd_package_upgrade._write_root_activator: thin +delegator when the upgraded env script understands RT_ENV_ROOT, verbatim +copy for legacy scripts, and a no-op when the inner script is missing. + +Run from the repository root: + python -m unittest plugins.tests.test_root_activator +""" + +import os +import shutil +import sys +import tempfile +import unittest + +from cmds.cmd_package import cmd_package_upgrade + + +class WriteRootActivatorTest(unittest.TestCase): + def setUp(self): + self.root = tempfile.mkdtemp(prefix="rt-env-activator-") + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.scripts = os.path.join(self.root, "tools", "scripts") + os.makedirs(self.scripts, exist_ok=True) + + def _write_inner(self, content): + with open(os.path.join(self.scripts, "env.sh"), "w", encoding="utf-8") as f: + f.write(content) + + def _run(self): + cmd_package_upgrade._write_root_activator(self.root, self.scripts) + + def _read_root(self): + with open(os.path.join(self.root, "env.sh"), encoding="utf-8") as f: + return f.read() + + def test_new_inner_produces_thin_delegator(self): + self._write_inner('if [ -n "$RT_ENV_ROOT" ]; then\nfi\n') + self._run() + content = self._read_root() + self.assertIn("RT_ENV_ROOT='%s'" % self.root, content) + self.assertIn(". '%s'" % os.path.join(self.scripts, "env.sh"), content) + + def test_legacy_inner_is_copied_verbatim(self): + self._write_inner("SCRIPT_DIR=legacy\n") + self._run() + self.assertEqual(self._read_root(), "SCRIPT_DIR=legacy\n") + + def test_missing_inner_is_a_no_op(self): + self._run() + self.assertFalse(os.path.exists(os.path.join(self.root, "env.sh"))) + + def test_user_config_never_touched_by_refresh(self): + user = os.path.join(self.root, "env.user.sh") + with open(user, "w", encoding="utf-8") as f: + f.write("# mine\n") + self._write_inner("legacy\n") + self._run() + with open(user, encoding="utf-8") as f: + self.assertEqual(f.read(), "# mine\n") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/pyproject.toml b/pyproject.toml index 3deafd54..43063ba4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,57 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "rt-env" +dynamic = ["version", "description"] +requires-python = ">=3.7" +authors = [{ name = "RT-Thread Development Team", email = "rt-thread@rt-thread.org" }] +keywords = ["rt-thread"] +license = { text = "GPL-2.0-or-later" } +urls = { Homepage = "https://github.com/RT-Thread/env", Repository = "https://github.com/RT-Thread/env" } +dependencies = [ + "SCons>=4.0.0", + "requests", + "psutil", + "tqdm", + "kconfiglib", + "pyyaml", + "windows-curses; sys_platform=='win32'", +] + +[project.scripts] +# 'rt-env' doubles as the argparse prog in env.py init_argparse() +rt-env = "env.env:main" +menuconfig = "env.env:menuconfig" +pkgs = "env.env:pkgs" +sdk = "env.env:sdk" +system = "env.env:system" +webui = "env.env:webui" + +[tool.setuptools] +package-dir = { env = ".", "env.cmds" = "cmds", "env.cmds.cmd_package" = "cmds/cmd_package", "env.plugins" = "plugins", "env.plugins.sdk" = "plugins/sdk", "env.plugins.epack" = "plugins/epack", "env.plugins.spec" = "plugins/spec", "env.plugins.webui" = "plugins/webui" } +include-package-data = false +packages = [ + "env", + "env.cmds", + "env.cmds.cmd_package", + "env.plugins", + "env.plugins.sdk", + "env.plugins.epack", + "env.plugins.spec", + "env.plugins.webui", +] + +[tool.setuptools.package-data] +"env" = ["*.*"] +"env.plugins" = ["*.md", "bundled/**/*", "examples/**/*"] +"env.plugins.spec" = ["*.json", "*.md"] +"env.plugins.webui" = ["static/*", "static/assets/*"] + +[tool.setuptools.exclude-package-data] +"env" = ["MANIFEST.in"] + [tool.black] line-length = 128 skip-string-normalization = true diff --git a/setup.py b/setup.py index 0f0a667a..0950323c 100644 --- a/setup.py +++ b/setup.py @@ -1,70 +1,13 @@ from setuptools import setup -from version import get_rt_env_version +import sys +import os + +# Add current directory to path for the info module discovery +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from info import get_version, get_description -env_name, env_ver = get_rt_env_version() setup( - name='env', - version=env_ver, - description='A command-line toolkit for RT-Thread development.', - url='https://github.com/RT-Thread/env.git', - author='RT-Thread Development Team', - author_email='rt-thread@rt-thread.org', - keywords='rt-thread', - license='Apache License 2.0', - project_urls={ - 'Github repository': 'https:/github.com/rt-thread/env.git', - 'User guide': 'https:/github.com/rt-thread/env.git', - }, - python_requires='>=3.6', - install_requires=[ - 'SCons>=4.0.0', - 'requests', - 'psutil', - 'tqdm', - 'kconfiglib', - 'windows-curses; platform_system=="Windows"', - ], - packages=[ - 'env', - 'env.cmds', - 'env.cmds.cmd_package', - 'env.plugins', - 'env.plugins.sdk', - 'env.plugins.epack', - 'env.plugins.spec', - 'env.plugins.webui', - ], - package_dir={ - 'env': '.', - 'env.cmds': 'cmds', - 'env.cmds.cmd_package': 'cmds/cmd_package', - 'env.plugins': 'plugins', - 'env.plugins.sdk': 'plugins/sdk', - 'env.plugins.epack': 'plugins/epack', - 'env.plugins.spec': 'plugins/spec', - 'env.plugins.webui': 'plugins/webui', - }, - package_data={ - '': ['*.*'], - 'env.plugins.webui': ['static/*', 'static/assets/*'], - }, - exclude_package_data={ - '': ['MANIFEST.in'], - 'env.plugins.webui': [ - 'frontend/*', - 'frontend/e2e/*', - 'frontend/src/*', - ], - }, - include_package_data=True, - entry_points={ - 'console_scripts': [ - 'rt-env=env.env:main', - 'menuconfig=env.env:menuconfig', - 'pkgs=env.env:pkgs', - 'sdk=env.env:sdk', - 'system=env.env:system', - 'webui=env.env:webui', - ] - }, + version=get_version(), + description=get_description(), ) diff --git a/statistics.py b/statistics.py index ce156c98..d529510b 100644 --- a/statistics.py +++ b/statistics.py @@ -21,6 +21,7 @@ # Change Logs: # Date Author Notes # 2022-5-6 WuGenSheng Add copyright information +# 2026-09-12 Dongly Resolve statistics endpoint via info.get_api_url # import os import uuid @@ -28,6 +29,7 @@ import requests from vars import Import +from info import get_api_url from cmds import * @@ -48,7 +50,8 @@ def Information_statistics(): if not os.path.isfile(env_config_file): try: response = requests.get( - 'https://www.rt-thread.org/studio/statistics/api/envuse?userid=' + get_api_url('statistics') + + '?userid=' + str(mac_addr) + '&username=' + str(mac_addr) diff --git a/tools/install.ps1 b/tools/install.ps1 new file mode 100644 index 00000000..974cbd24 --- /dev/null +++ b/tools/install.ps1 @@ -0,0 +1,2187 @@ +# File : install.ps1 +# This file is part of RT-Thread RTOS +# COPYRIGHT (C) 2006 - 2026, RT-Thread Development Team +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Change Logs: +# Date Author Notes +# 2026-01-30 dongly Refactored + +# RT-Thread ENV Installation Script (Windows) +# RT-Thread ENV 安装脚本 (Windows) +# Unified installation script for Windows +# Windows 统一安装脚本 +# Supports: English / 中文 +# +# This script handles the initial setup of RT-Thread ENV on Windows. +# 此脚本处理 Windows 上 RT-Thread ENV 的初始设置。 +# It performs steps 1-3 of the installation process: +# 执行安装过程的步骤 1-3: +# 1. Check and install Python and Git - 检查并安装 Python 和 Git +# 2. Enable Windows long path support (requires admin) - 启用 Windows 长路径支持(需要管理员权限) +# 3. Download and execute touch_env.py for steps 4-9 - 下载并执行 touch_env.py 完成步骤 4-9 +# +# Usage: +# 用法: +# .\install.ps1 [--yes] [--cn] [--official] [--keep-sdk ] [--python [path]] [--env-root ] [--lang ] [--packages [#]] [--env [#]] [--sdk [#]] [--touch-env ] [-h] +# +# Options: +# 选项: +# --yes, --auto Auto-install without prompts +# 自动安装,无提示 +# --cn, --gitee Use China mirror (Gitee, PyPI TUNA) +# 使用中国镜像(Gitee, PyPI TUNA) +# --official Force use official source +# 强制使用官方源 +# --keep-sdk Keep toolchains (local_pkgs) and config when reinstalling (default: yes) +# 重装时保留工具链(local_pkgs)与配置(默认:yes) +# --env-root Set custom install directory +# 设置自定义安装目录 +# --lang Force message language +# 强制消息语言 +# --python [path] Force install portable Python, install directory is path (default: D:\Tools\Python) +# 安装便携式 Python, 安装目录为 path(默认:D:\Tools\Python) +# --packages [#] Specify custom packages repository and branch +# 指定 packages 仓库地址和分支 +# 格式: url[#branch] +# --env [#] Specify custom env repository and branch +# 指定 env 仓库地址和分支 +# 格式: url[#branch] +# --sdk [#] Specify custom sdk repository and branch +# 指定 sdk 仓库地址和分支 +# 格式: url[#branch] +# --touch-env Specify touch_env.py download URL +# 指定 touch_env.py 下载 URL +# -h, --help Show this help message +# 显示此帮助信息 +# + +# ============================================================================ +# Global Constants +# ============================================================================ + +# touch_env.py download URLs +$TOUCH_ENV_URL_GITHUB = "https://raw.githubusercontent.com/RT-Thread/env/master/tools/touch_env.py" +$TOUCH_ENV_URL_GITEE = "https://gitee.com/RT-Thread-Mirror/env/raw/master/tools/touch_env.py" + +# Python Configuration +$PYTHON_VERSION = "3.13.11" +$PYTHON_ARCHIVE = "python-${PYTHON_VERSION}-amd64.zip" +$PYTHON_URL_DEFAULT = "https://www.python.org/ftp/python/$PYTHON_VERSION/$PYTHON_ARCHIVE" +$PYTHON_URL_CN = "https://registry.npmmirror.com/-/binary/python/$PYTHON_VERSION/$PYTHON_ARCHIVE" +$DEFAULT_PYTHON_PATH = "D:\Tools\Python" + +# Git Configuration +$GIT_FALLBACK_VERSION = "v2.52.0.windows.1" +$GIT_FALLBACK_URL = "https://github.com/git-for-windows/git/releases/download/$GIT_FALLBACK_VERSION/Git-${GIT_FALLBACK_VERSION}-64-bit.exe" +$GIT_GITHUB_API_URL = "https://api.github.com/repos/git-for-windows/git/releases/latest" +$GIT_NPMMIRROR_URL = "https://registry.npmmirror.com/-/binary/git-for-windows/" + +# IP Detection Configuration +$IPINFO_URL = "https://ipinfo.io/json" + +# ============================================================================ +# Helper Functions +# ============================================================================ + +# Parse-Arguments function +# Parse command line arguments and return parsed values +function Read-OptionalArg { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + [Parameter(Mandatory = $true)] + [int]$Index + ) + + if ($Index + 1 -lt $Arguments.Count -and $Arguments[$Index + 1] -notmatch "^-") { + return $Arguments[++$Index] + } + else { + return "" + } +} + +function Parse-Arguments { + param([string[]]$Arguments) + + $result = [PSCustomObject]@{ + AutoMode = $false + HelpMode = $false + CnMode = $false + OfficialMode = $false + PythonPath = "" + LangChoice = "" + KeepSdk = "" + EnvRoot = "" + CustomPackages = "" + CustomEnv = "" + CustomSdk = "" + TouchEnvUrlValue = "" + } + + for ($i = 0; $i -lt $Arguments.Count; $i++) { + $arg = $Arguments[$i] + switch -CaseSensitive ($arg) { + "--yes" { $result.AutoMode = $true } + "--auto" { $result.AutoMode = $true } + "-h" { $result.HelpMode = $true } + "--help" { $result.HelpMode = $true } + "--cn" { $result.CnMode = $true } + "--gitee" { $result.CnMode = $true } + "--official" { $result.OfficialMode = $true } + "--python" { $result.PythonPath = Read-OptionalArg -Arguments $Arguments -Index $i } + "--env" { $result.CustomEnv = $Arguments[++$i] } + "--lang" { $result.LangChoice = $Arguments[++$i] } + "--env-root" { $result.EnvRoot = $Arguments[++$i] } + "--packages" { $result.CustomPackages = $Arguments[++$i] } + "--sdk" { $result.CustomSdk = $Arguments[++$i] } + "--keep-sdk" { $result.KeepSdk = $Arguments[++$i] } + "--touch-env" { $result.TouchEnvUrlValue = $Arguments[++$i] } + } + } + + return $result +} + +# Register-CleanupHandler function +# Register cleanup handler for temporary files +# This ensures temporary files are cleaned up even if the script exits unexpectedly +function Register-CleanupHandler { + try { + Unregister-Event -SourceIdentifier Script.Cleanup -ErrorAction SilentlyContinue + } + catch {} + + $cleanupAction = { + foreach ($tempFile in $script:Config.TempFiles) { + if (Test-Path $tempFile) { + Remove-Item $tempFile -ErrorAction SilentlyContinue + } + } + } + + Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action $cleanupAction | Out-Null +} + +# Add-TempFile function +# Track temporary file for cleanup +# Called whenever a temporary file is created to ensure it gets cleaned up on exit +function Add-TempFile { + param([string]$FilePath) + + if ($script:Config.TempFiles -notcontains $FilePath) { + $script:Config.TempFiles += $FilePath + } +} + +# Get-SystemLanguage function +# Detect system language, returns 'zh' or 'en' +function Get-SystemLanguage { + $locale = [System.Globalization.CultureInfo]::CurrentUICulture.Name + if ($locale -like "*zh*" -or $locale -like "*CN*") { + return "zh" + } + return "en" +} + +# Print-Help function +# Display help information and exit +function Print-Help { + if ($script:Config.LangCurrent -eq "zh") { + Write-Host "RT-Thread ENV 安装程序" + Write-Host "" + Write-Host "用法: .\install.ps1 [选项]" + Write-Host "" + Write-Host "选项:" + Write-Host " --yes, --auto 自动安装,无需提示" + Write-Host " --cn, --gitee 使用中国镜像(Gitee,清华 PyPI)" + Write-Host " --official 强制使用官方源" + Write-Host " --keep-sdk [yes|no] 重装时保留工具链(local_pkgs)与配置(默认:yes)" + Write-Host " --python [path] 安装便携式 Python, 安装目录为 path(默认:D:\Tools\Python)" + Write-Host " --env-root [path] 设置自定义安装目录" + Write-Host " --lang [en|zh] 强制消息语言" + Write-Host " --packages [repo] 指定 packages 仓库地址和分支" + Write-Host " 格式: url[#branch]" + Write-Host " --env [repo] 指定 env 仓库地址和分支" + Write-Host " 格式: url[#branch]" + Write-Host " --sdk [repo] 指定 sdk 仓库地址和分支" + Write-Host " 格式: url[#branch]" + Write-Host " --touch-env [url] 指定 touch_env.py 下载 URL" + Write-Host " -h, --help 显示此帮助信息" + Write-Host "" + } + else { + Write-Host "RT-Thread ENV Installation Script" + Write-Host "" + Write-Host "Usage: .\install.ps1 [OPTIONS]" + Write-Host "" + Write-Host "Options:" + Write-Host " --yes, --auto Auto-install without prompts" + Write-Host " --cn, --gitee Use China mirror (Gitee, PyPI TUNA)" + Write-Host " --official Force use official source" + Write-Host " --keep-sdk [yes|no] Keep toolchains (local_pkgs) and config when reinstalling (default: yes)" + Write-Host " --python [path] Force install portable Python, install directory is path (default: D:\Tools\Python)" + Write-Host " --env-root [path] Set custom install directory" + Write-Host " --lang [en|zh] Force message language" + Write-Host " --packages [repo] Specify custom packages repository and branch" + Write-Host " Format: url[#branch]" + Write-Host " --env [repo] Specify custom env repository and branch" + Write-Host " Format: url[#branch]" + Write-Host " --sdk [repo] Specify custom sdk repository and branch" + Write-Host " Format: url[#branch]" + Write-Host " --touch-env [url] Specify touch_env.py download URL" + Write-Host " -h, --help Show this help message" + Write-Host "" + } + exit 0 +} + +# Detect-China function +# Detect if user is in China (by IP or timezone) +function Detect-China { + param( + [bool]$LangEn = $false, + [bool]$LangZh = $false + ) + + $use_cn = $false + + try { + $ip_info = Invoke-RestMethod -Uri $IPINFO_URL -Method Get -UseBasicParsing -TimeoutSec 5 + if ($ip_info.country -eq "CN") { + $use_cn = $true + } + } + catch { + } + + if (-not $use_cn) { + try { + $timezone = [System.TimeZoneInfo]::Local.Id + if ($timezone -like "*Shanghai*" -or $timezone -like "*China*" -or $timezone -like "*Beijing*") { + $use_cn = $true + } + } + catch { + } + } + return $use_cn +} + +# Messages +# Centralized message dictionary for easy maintenance and localization +$script:Messages = @{ + en = @{ + banner_title = "RT-Thread ENV Installation" + info = "INFO" + success = "SUCCESS" + warning = "WARNING" + error = "ERROR" + python_version_too_low = "Python version {0} is too old (requires >= 3.6). Installing portable Python..." + installing_portable_python = "Installing portable Python {0}..." + downloading_portable_python = "Downloading portable Python, from: {0}" + python_installed = "Python installed successfully." + python_version_failed = "Failed to get Python version from: {0}" + python_not_found_or_invalid = "Python not found or invalid. Please install Python first." + python_setup_failed = "Python setup failed with code: {0}" + python_ready = "Python ready: {0} (version: {1})" + git_installed = "Git installed successfully." + git_not_found_no_admin = "Git is not installed and you are not an administrator." + restart_required = "Git installed. Please restart your terminal or run the script again." + git_found = "Git found: {0}" + touch_env_failed = "touch_env.py execution failed with code: {0}" + touch_env_downloaded = "touch_env.py downloaded successfully." + downloading_git = "Downloading Git..." + installing_git = "Installing Git..." + fetching_git_from_npmmirror = "Fetching Git version from npmmirror..." + fetching_git_from_github = "Fetching Git version from GitHub API..." + git_version_found = "Git version found: {0}" + npmmirror_fetch_failed = "Failed to fetch Git version from npmmirror, trying GitHub API..." + download_failed = "Download failed: {0}" + github_api_failed = "GitHub API request failed, using fallback version..." + using_fixed_git_version = "Using fixed Git version: {0}" + git_not_found = "Git is not installed. Please install Git first." + admin_required_for_git_install = "Git installation requires administrator privileges. Please run as administrator." + elevation_failed = "Failed to elevate privileges. Please run as administrator." + execution_policy_too_low = "Execution policy is too low. Need to set to RemoteSigned or higher." + admin_required_for_env_config = "Administrator privileges required to configure Windows environment. Please run as administrator." + admin_run_instructions = "Please run the script as administrator to configure Windows environment:" + admin_step_1 = " 1. Right-click PowerShell" + admin_step_2 = " 2. Select 'Run as administrator'" + admin_step_3 = " 3. Run the script again" + status_current_policy = "Current effective execution policy: {0} (scope: {1})" + status_current_longpath = "Current long path support: {0}" + status_new_policy = "New effective execution policy: {0} (scope: {1})" + status_new_longpath = "New long path support: {0}" + status_enabled = "Enabled" + status_disabled = "Disabled" + verified_policy_set = "Verified: {0} is now {1}" + verified_policy_set_exception = "Success (verified despite exception: {0})" + warning_policy_not_set = "Warning: {0} is {1} (expected RemoteSigned)" + long_path_support_required = "Long path support is required. Please run as administrator." + windows_env_adequate = "Windows environment configuration is adequate." + windows_env_set_failed = "Failed to configure Windows environment." + windows_env_initialized = "Windows environment initialized successfully." + install_portable_python = "Install portable Python - Python {0}" + initializing_windows_env = "Initializing Windows environment..." + requesting_elevation = "Requesting administrator privileges: {0}" + multiple_python_found = "Multiple Python installations found:" + select_python = "Found {0} Python installation(s). Default is option {1} (latest). Select [1-{0}], or {2} to install portable Python: " + auto_selected = "Auto-selected Python: {0}" + python_not_found = "Python not found. Please install Python first." + python_path_prompt = "Enter portable Python installation path" + python_path_default = "[default: {0}]" + python_path_invalid = "Error: Path cannot contain {0}" + python_path_no_permission = "Error: No write permission for directory: {0}" + python_path_creating_dir = "Creating directory: {0}" + python_path_directory_exists = "Error: Directory already exists: {0}. Please specify a different path." + extracting_archive = "Extracting archive: {0}" + cleanup_archive = "Cleaning up archive..." + configuring_pth_file = "Configuring .pth file: {0}" + pth_file_configured = ".pth file configured successfully" + python_pth_config_failed = "Failed to configure .pth file" + downloading_touch_env = "Downloading touch_env.py from: {0}" + touch_env_download_failed = "Failed to download touch_env.py: {0}" + ssl_verification_failed = "SSL verification failed, retrying without verification..." + mirror_selection = "Using mirror: {0}" + china_mirror = "China (Gitee, npmmirror)" + official_mirror = "Official (GitHub, PyPI)" + check_list = "Please check:" + check_list_connection = " 1. Your internet connection" + check_list_url = " 2. The URL is correct: {0}" + check_list_alt_url = " 3. Try using -t parameter to specify a different URL" + } + zh = @{ + banner_title = "RT-Thread ENV 安装程序" + info = "信息" + success = "成功" + warning = "警告" + error = "错误" + python_version_too_low = "Python 版本 {0} 过低(需要 >= 3.6)。将安装便携式 Python..." + installing_portable_python = "正在安装便携式 Python {0}..." + downloading_portable_python = "正在下载便携式 Python,自: {0}" + python_installed = "Python 已安装成功。" + python_version_failed = "从 {0} 获取 Python 版本失败" + python_not_found_or_invalid = "未找到 Python 或 Python 无效。请先安装 Python。" + python_setup_failed = "Python 设置失败,错误代码: {0}" + python_ready = "Python 就绪: {0} (版本: {1})" + git_installed = "Git 已安装成功。" + git_not_found_no_admin = "未安装 Git 且您不是管理员。" + restart_required = "Git 已安装。请重启终端或重新运行脚本。" + git_found = "找到 Git: {0}" + touch_env_failed = "touch_env.py 执行失败,错误代码: {0}" + touch_env_downloaded = "touch_env.py 下载成功。" + downloading_git = "正在下载 Git..." + installing_git = "正在安装 Git..." + fetching_git_from_npmmirror = "正在从 npmmirror 获取 Git 版本..." + fetching_git_from_github = "正在从 GitHub API 获取 Git 版本..." + git_version_found = "找到 Git 版本: {0}" + npmmirror_fetch_failed = "从 npmmirror 获取 Git 版本失败,尝试 GitHub API..." + download_failed = "下载失败: {0}" + github_api_failed = "GitHub API 请求失败,使用备选版本..." + using_fixed_git_version = "使用固定 Git 版本: {0}" + git_not_found = "未安装 Git。请先安装 Git。" + admin_required_for_git_install = "Git 安装需要管理员权限。请以管理员身份运行。" + elevation_failed = "提升权限失败。请以管理员身份运行。" + execution_policy_too_low = "执行策略过低。需要设置为 RemoteSigned 或更高。" + admin_required_for_env_config = "配置 Windows 环境需要管理员权限。请以管理员身份运行。" + admin_run_instructions = "请以管理员身份运行脚本来配置 Windows 环境:" + admin_step_1 = " 1. 右键点击 PowerShell" + admin_step_2 = " 2. 选择 '以管理员身份运行'" + admin_step_3 = " 3. 再次运行脚本" + status_current_policy = "当前生效的执行策略: {0}(作用域: {1})" + status_current_longpath = "当前长路径支持: {0}" + status_new_policy = "新的生效执行策略: {0}(作用域: {1})" + status_new_longpath = "新的长路径支持: {0}" + status_enabled = "已启用" + status_disabled = "已禁用" + verified_policy_set = "已验证: {0} 现在是 {1}" + verified_policy_set_exception = "成功(尽管有异常已验证: {0})" + warning_policy_not_set = "警告: {0} 是 {1}(期望为 RemoteSigned)" + long_path_support_required = "需要启用长路径支持。请以管理员身份运行。" + windows_env_adequate = "Windows 环境配置已满足要求。" + windows_env_set_failed = "Windows 环境配置失败。" + windows_env_initialized = "Windows 环境已成功初始化。" + install_portable_python = "安装便携式 Python - Python {0}" + initializing_windows_env = "正在初始化 Windows 环境..." + requesting_elevation = "正在请求管理员权限: {0}" + multiple_python_found = "找到多个 Python 安装:" + select_python = "找到 {0} 个 Python 安装。默认选项为 {1}(最新)。选择 [1-{0}],或输入 {2} 安装便携式 Python: " + auto_selected = "自动选择 Python: {0}" + python_not_found = "未找到 Python。请先安装 Python。" + python_path_prompt = "请输入便携式 Python 安装路径" + python_path_default = "[默认: {0}]" + python_path_invalid = "错误: 路径不能包含 {0}" + python_path_no_permission = "错误: 没有目录的写入权限: {0}" + python_path_creating_dir = "正在创建目录: {0}" + python_path_directory_exists = "错误: 目录已存在: {0}。请指定其他路径。" + extracting_archive = "正在解压存档: {0}" + cleanup_archive = "正在清理存档..." + configuring_pth_file = "正在配置 .pth 文件: {0}" + pth_file_configured = ".pth 文件配置成功" + python_pth_config_failed = "配置 .pth 文件失败" + downloading_touch_env = "正在下载 touch_env.py,自: {0}" + touch_env_download_failed = "下载 touch_env.py 失败: {0}" + ssl_verification_failed = "SSL 验证失败,正在重试(不验证证书)..." + mirror_selection = "使用镜像: {0}" + china_mirror = "中国(Gitee, npmmirror)" + official_mirror = "官方(GitHub, PyPI)" + check_list = "请检查:" + check_list_connection = " 1. 您的网络连接" + check_list_url = " 2. URL 是否正确: {0}" + check_list_alt_url = " 3. 尝试使用 -t 参数指定不同的 URL" + } +} + +# Message functions +# Get-Message: Get localized message +# Write-LogInfo: Output info log (cyan) +# Write-LogSuccess: Output success log (green) +# Write-LogWarning: Output warning log (yellow) +# Write-LogError: Output error log (red) + +function Get-Message { + param([string]$Key) + + $lang = $script:Config.LangCurrent + if ($script:Messages.ContainsKey($lang) -and $script:Messages[$lang].ContainsKey($Key)) { + return $script:Messages[$lang][$Key] + } + + return "Unknown message: $Key" +} + +function Write-LogInfo { + param([string]$Key, [string]$Arg1, [string]$Arg2) + $msg = Get-Message $Key + $formatted = if ($null -ne $Arg1 -and $null -ne $Arg2) { + $msg -f $Arg1, $Arg2 + } + elseif ($null -ne $Arg1) { + $msg -f $Arg1 + } + else { + $msg + } + Write-Host "[$(Get-Message 'info')] $formatted" -ForegroundColor Cyan +} + +function Write-LogSuccess { + param([string]$Key, [string]$Arg1, [string]$Arg2) + $msg = Get-Message $Key + $formatted = if ($null -ne $Arg1 -and $null -ne $Arg2) { + $msg -f $Arg1, $Arg2 + } + elseif ($null -ne $Arg1) { + $msg -f $Arg1 + } + else { + $msg + } + Write-Host "[$(Get-Message 'success')] $formatted" -ForegroundColor Green +} + +function Write-LogWarning { + param([string]$Key, [string]$Arg1, [string]$Arg2) + $msg = Get-Message $Key + $formatted = if ($null -ne $Arg1 -and $null -ne $Arg2) { + $msg -f $Arg1, $Arg2 + } + elseif ($null -ne $Arg1) { + $msg -f $Arg1 + } + else { + $msg + } + Write-Host "[$(Get-Message 'warning')] $formatted" -ForegroundColor Yellow +} + +function Write-LogError { + param([string]$Key, [string]$Arg1, [string]$Arg2) + $msg = Get-Message $Key + $formatted = if ($null -ne $Arg1 -and $null -ne $Arg2) { + $msg -f $Arg1, $Arg2 + } + elseif ($null -ne $Arg1) { + $msg -f $Arg1 + } + else { + $msg + } + Write-Host "[$(Get-Message 'error')] $formatted" -ForegroundColor Red +} + +# Write-LogRaw function +# Write raw message with configurable color and i18n support +function Write-LogRaw { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + + [Parameter(Mandatory = $false)] + [ConsoleColor]$Color = "White", + + [Parameter(Mandatory = $false)] + [string]$Arg1 = "", + + [Parameter(Mandatory = $false)] + [string]$Arg2 = "" + ) + + # Get message from dictionary (supports i18n) + $msg = if ($script:Messages.ContainsKey($script:Config.LangCurrent) -and $script:Messages[$script:Config.LangCurrent].ContainsKey($Key)) { + $script:Messages[$script:Config.LangCurrent][$Key] + } + else { + $Key # Fallback to the key itself if not found in dictionary + } + + # Format message with arguments if provided + $formatted = if ($null -ne $Arg1 -and $null -ne $Arg2 -and $Arg1 -ne "" -and $Arg2 -ne "") { + $msg -f $Arg1, $Arg2 + } + elseif ($null -ne $Arg1 -and $Arg1 -ne "") { + $msg -f $Arg1 + } + else { + $msg + } + + Write-Host $formatted -ForegroundColor $Color +} + +# Git Functions +# Test-Command: Test if command exists + +function Test-Command { + param([string]$CommandName) + + try { + Get-Command $CommandName -ErrorAction Stop | Out-Null + return $true + } + catch { + return $false + } +} + +# Git Installation Functions +# Get-LatestGitVersion: Get latest Git version +# Install-Git: Install Git (Windows) + +function Get-LatestGitVersion { + param([bool]$UseCNMirror) + + # Helper function to filter valid versions + function Test-ValidGitVersion { + param([string]$Name) + return ($Name -notmatch "-rc\d+" -and + $Name -notmatch "-prerelease$" -and + $Name -notmatch "-mingit$") + } + + # Helper function to build result object + function New-GitVersionInfo { + param( + [string]$Version, + [string]$Installer, + [string]$Url, + [string]$Source + ) + return @{ + Version = $Version + Installer = $Installer + Url = $Url + Source = $Source + } + } + + # Try npmmirror first if using CN mirror + if ($UseCNMirror) { + try { + Write-LogInfo "fetching_git_from_npmmirror" + $versions = Invoke-RestMethod -Uri $GIT_NPMMIRROR_URL -Method Get -UseBasicParsing | + Where-Object { Test-ValidGitVersion -Name $_.name } | + Sort-Object -Property Name -Descending + + if ($versions.Count -gt 0) { + $versionNumber = $versions[0].name -replace '/$', '' + $versionUrl = "$GIT_NPMMIRROR_URL$versionNumber/" + $versionFiles = Invoke-RestMethod -Uri $versionUrl -Method Get -UseBasicParsing + $installerFile = $versionFiles | Where-Object { $_.name -match "^Git-\d+\.\d+\.\d+-64-bit\.exe$" } + + if ($installerFile) { + Write-LogSuccess "git_version_found" "$versionNumber (from npmmirror)" + return New-GitVersionInfo -Version $versionNumber -Installer $installerFile.name -Url $installerFile.url -Source "npmmirror" + } + } + } + catch { + Write-LogWarning "npmmirror_fetch_failed" + } + } + + # Fallback to GitHub API + try { + Write-LogInfo "fetching_git_from_github" + $response = Invoke-RestMethod -Uri $GIT_GITHUB_API_URL -Method Get -UseBasicParsing -ErrorAction Stop + + if ($response -is [string]) { + throw "Received HTML instead of JSON" + } + + $versionNumber = $response.tag_name -replace '^v', '' + $installerAsset = $response.assets | Where-Object { $_.name -match "^Git-\d+\.\d+\.\d+-64-bit\.exe$" } + + if ($installerAsset) { + Write-LogSuccess "git_version_found" "$versionNumber (from GitHub)" + return New-GitVersionInfo -Version $versionNumber -Installer $installerAsset.name -Url $installerAsset.browser_download_url -Source "github" + } + } + catch { + Write-LogWarning "github_api_failed" + } + + # Ultimate fallback: use fixed version + Write-LogWarning "using_fixed_git_version" "$GIT_FALLBACK_VERSION" + return New-GitVersionInfo -Version $GIT_FALLBACK_VERSION -Installer "Git-$GIT_FALLBACK_VERSION-64-bit.exe" -Url $GIT_FALLBACK_URL -Source "fallback" +} + +function Install-Git { + param( + [bool]$UseCNMirror, + [bool]$Interactive = $false + ) + + # Get latest Git version dynamically + $gitInfo = Get-LatestGitVersion -UseCNMirror $UseCNMirror + + Write-LogInfo "downloading_git" + + $installerPath = Join-Path $env:TEMP $gitInfo.Installer + $gitUrl = $gitInfo.Url + + # Track temporary file for cleanup + Add-TempFile -FilePath $installerPath + + try { + # Download Git installer + Invoke-WebRequest -Uri $gitUrl -OutFile $installerPath -UseBasicParsing -ErrorAction Stop + + Write-LogInfo "installing_git" + + if ($Interactive) { + # Interactive installation - show installer UI with default options + Start-Process -FilePath $installerPath -Wait + } + else { + # Silent installation with progress display + # /SILENT: Silent installation with progress bar + # /SUPPRESSMSGBOXES: Suppress message boxes + # /NORESTART: Prevent restart + # /COMPONENTS="": Install all components + # /TASKS="desktopicon,winterminal": Add desktop icon and Windows Terminal profile + # /MERGETASKS="desktopicon,winterminal": Additional tasks to merge + # /DEFAULTBRANCH="main": Set default branch name to main + Start-Process -FilePath $installerPath -ArgumentList @( + "/SILENT", + "/SUPPRESSMSGBOXES", + "/NORESTART", + "/COMPONENTS=", + '/TASKS="desktopicon,winterminal"', + "/DEFAULTBRANCH=main" + ) -Wait + } + + Write-LogSuccess "git_installed" + } + catch { + Write-LogError "download_failed" $_.Exception.Message + throw + } + finally { + # Cleanup installer + Remove-Item $installerPath -ErrorAction SilentlyContinue + } +} + +# Python Installation Functions +# Install-Python: Install portable Python +# Download-PortablePython: Download portable Python +# Extract-PortablePython: Extract portable Python +# Configure-PythonPth: Configure Python _pth file + +class PythonConfig { + [bool]$InstallPortablePython + [string]$PythonPath + [string]$Version + [int]$Result +} +function New-PythonConfig { + return [PythonConfig] @{ + InstallPortablePython = $false + PythonPath = "" + Version = "" + Result = 0 + } +} + +function New-PortingPythonConfig { + param( + [Parameter(Mandatory = $false)] + [string]$PythonPath = $null + ) + + # Use provided PythonPath if available, otherwise fallback to config + if ([string]::IsNullOrEmpty($PythonPath)) { + $PythonPath = $script:Config.PythonConfig.PythonPath + } + + # Check if PythonPath is empty, prompt user if needed + if ([string]::IsNullOrEmpty($PythonPath)) { + if (-not $script:Config.AutoMode) { + $promptedPath = Prompt-PythonPath + if ($promptedPath) { + $PythonPath = $promptedPath + $script:Config.PythonConfig.PythonPath = $promptedPath + } + else { + # Use default if prompt failed + $PythonPath = Join-Path $DEFAULT_PYTHON_PATH "python.exe" + $script:Config.PythonConfig.PythonPath = $PythonPath + } + } + else { + # Auto mode: use default + $PythonPath = Join-Path $DEFAULT_PYTHON_PATH "python.exe" + $script:Config.PythonConfig.PythonPath = $PythonPath + + # Check if directory already exists + $pythonTargetDir = Split-Path -Parent $PythonPath + if (Test-Path $pythonTargetDir) { + Write-LogError "python_path_directory_exists" $pythonTargetDir + return [PythonConfig] @{ + InstallPortablePython = $false + PythonPath = "" + Version = "" + Result = 1 + } + } + } + } + + return [PythonConfig] @{ + InstallPortablePython = $true + PythonPath = $PythonPath + Version = $PYTHON_VERSION + Result = 0 + } +} + +function Prompt-PythonPath { + # Prompt user to enter portable Python installation path + # Returns full path with python.exe suffix + param() + + # Only work in non-auto mode + if ($script:Config.AutoMode) { + return "" + } + + $pythonPath = "" + $isValid = $false + + while (-not $isValid) { + # Display prompt with default value + $promptMsg = Get-Message "python_path_prompt" + $defaultMsgRaw = Get-Message "python_path_default" + $defaultMsg = $defaultMsgRaw -f $DEFAULT_PYTHON_PATH + Write-Host "$promptMsg $defaultMsg" -NoNewline -ForegroundColor Yellow + $input = Read-Host + + # Use default if input is empty + if ([string]::IsNullOrWhiteSpace($input)) { + $input = $DEFAULT_PYTHON_PATH + } + + # Check if directory already exists + if (Test-Path $input) { + Write-LogError "python_path_directory_exists" $input + continue + } + + # Check path format (spaces, non-ASCII characters) + if ($input -match "\s") { + Write-LogError "python_path_invalid" "spaces" + continue + } + if ($input -match "[^\x00-\x7F]") { + Write-LogError "python_path_invalid" "non-ASCII characters" + continue + } + + # Check parent directory and create if needed + $parentDir = Split-Path -Parent $input + if (-not (Test-Path $parentDir)) { + Write-LogInfo "python_path_creating_dir" $parentDir + try { + New-Item -ItemType Directory -Path $parentDir -Force | Out-Null + } + catch { + Write-LogError "python_path_no_permission" $parentDir + continue + } + } + + # Check write permission + $testFile = Join-Path $parentDir ".__write_test__" + try { + [System.IO.File]::WriteAllText($testFile, "test") + Remove-Item $testFile -Force -ErrorAction SilentlyContinue + } + catch { + Write-LogError "python_path_no_permission" $parentDir + continue + } + + # Path is valid + $isValid = $true + $pythonPath = Join-Path $input "python.exe" + } + + return $pythonPath +} + +function Check-Python { + param( + [Parameter(Mandatory = $true)] + [PythonConfig]$PythonConfig + ) + + $result = New-PortingPythonConfig -PythonPath $PythonConfig.PythonPath + # Check if Python.exe exists + if (-not (Test-Path $PythonConfig.PythonPath)) { + Write-LogError "python_not_found" $PythonConfig.PythonPath + $result.Result = 1 + return $result + } + + # Get Python version + $version = Get-PythonVersionString -PythonPath $PythonConfig.PythonPath + if (-not $version) { + Write-LogWarning "python_version_failed" $PythonConfig.PythonPath + $result.Result = 2 + return $result + } + $PythonConfig.Version = $version + + # Check if version meets minimum requirement (>= 3.6) + if (-not (Test-PythonVersion -VersionString $version)) { + Write-LogError "python_version_too_low" $version + $result.Result = 3 + return $result + } + + # All checks passed + return $PythonConfig +} + +function Download-PortablePython { + param([bool]$UseCNMirror) + + # Determine download URL based on mirror setting + $pythonUrl = if ($UseCNMirror) { $PYTHON_URL_CN } else { $PYTHON_URL_DEFAULT } + + Write-LogInfo "downloading_portable_python" $pythonUrl + $archivePath = Join-Path $env:TEMP $PYTHON_ARCHIVE + + # Track temporary file for cleanup + Add-TempFile -FilePath $archivePath + + # Download Python embed archive + try { + Invoke-WebRequest -Uri $pythonUrl -OutFile $archivePath -UseBasicParsing -ErrorAction Stop + } + catch { + Write-LogError "download_failed" $_.Exception.Message + exit 1 + } + + # Verify file was downloaded successfully + if (-not (Test-Path $archivePath) -or (Get-Item $archivePath).Length -eq 0) { + Write-LogError "download_failed" "File not found or empty" + exit 1 + } +} + +function Extract-PortablePython { + Write-LogInfo "installing_portable_python" $PYTHON_VERSION + + $archivePath = Join-Path $env:TEMP $PYTHON_ARCHIVE + # Extract directory from PythonConfig.PythonPath (which includes python.exe) + $pythonTargetDir = Split-Path -Parent $script:Config.PythonConfig.PythonPath + + # Check if directory already exists + if (Test-Path $pythonTargetDir) { + Write-LogError "python_path_directory_exists" $pythonTargetDir + exit 1 + } + + # Create directory + Write-LogInfo "python_path_creating_dir" $pythonTargetDir + New-Item -ItemType Directory -Path $pythonTargetDir -Force | Out-Null + + # Extract zip file, excluding Doc directory + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem + Add-Type -AssemblyName System.IO.Compression + Write-LogInfo "extracting_archive" $archivePath + $zip = [System.IO.Compression.ZipFile]::OpenRead($archivePath) + try { + $totalEntries = ($zip.Entries | Where-Object { $_.FullName -notlike 'Doc/*' -and $_.FullName -ne 'Doc' }).Count + $current = 0 + foreach ($entry in $zip.Entries) { + if ($entry.FullName -like 'Doc/*' -or $entry.FullName -eq 'Doc') { continue } + $current++ + if ($current % 10 -eq 0 -or $current -eq $totalEntries) { + Write-Progress -Activity "Extracting Python" -Status "File $current of $totalEntries" -PercentComplete (($current / $totalEntries) * 100) -Id 1 + } + $entryPath = Join-Path $pythonTargetDir $entry.FullName + if ($entry.Name -eq '') { + # Directory entry + New-Item -ItemType Directory -Path $entryPath -Force | Out-Null + } + else { + $entryDir = Split-Path $entryPath -Parent + if (-not (Test-Path $entryDir)) { + New-Item -ItemType Directory -Path $entryDir -Force | Out-Null + } + # Use .NET 4.5+ method to extract file + $stream = [System.IO.File]::Create($entryPath) + try { + $entryStream = $entry.Open() + try { + $entryStream.CopyTo($stream) + } + finally { + $entryStream.Dispose() + } + } + finally { + $stream.Dispose() + } + } + } + Write-Progress -Activity "Extracting Python" -Completed -Id 1 + } + finally { + $zip.Dispose() + } + } + catch { + Write-Host " [错误详情] $_" -ForegroundColor Red + Write-Host " [错误位置] $($_.ScriptStackTrace)" -ForegroundColor Red + exit 1 + } + + # Cleanup archive + Write-LogInfo "cleanup_archive" + Remove-Item $archivePath -ErrorAction SilentlyContinue +} + + + +function Configure-PythonPth { + # Modify python3xx._pth to enable site-packages and ensurepip + # Extract directory from PythonConfig.PythonPath (which includes python.exe) + $pythonTargetDir = Split-Path -Parent $script:Config.PythonConfig.PythonPath + Write-LogInfo "configuring_pth_file" $pythonTargetDir + try { + $pthFile = Get-ChildItem -Path $pythonTargetDir -Filter "*._pth" -ErrorAction Stop + if ($pthFile) { + $pthContent = Get-Content -Path $pthFile.FullName -Raw -ErrorAction Stop + # Uncomment import site to enable site-packages + $pthContent = $pthContent -replace "#import site", "import site" + Set-Content -Path $pthFile.FullName -Value $pthContent -NoNewline -ErrorAction Stop + } + } + catch { + Write-LogWarning "python_pth_config_failed" + } +} + +function Install-PortablePython { + param( + [bool]$UseCNMirror + ) + + $result = New-PythonConfig + + try { + # Download and extract portable Python + Download-PortablePython -UseCNMirror $UseCNMirror + Extract-PortablePython + # Configure python3xx._pth file + Configure-PythonPth + # Save portable Python path to global variable + $portablePython = $script:Config.PythonConfig.PythonPath + Write-LogSuccess "python_installed" + + # Get Python version + $version = Get-PythonVersionString -PythonPath $portablePython + if ($version) { + $result.PythonPath = $portablePython + $result.Version = $version + $result.InstallPortablePython = $true + $result.Result = 0 + } + else { + Write-LogWarning "python_version_failed" $portablePython + $result.Result = 1 + } + } + catch { + $result.Result = 2 + Write-LogError "python_install_failed" $_.Exception.Message + Write-Host "请手动删除 Python 安装目录后重试" -ForegroundColor Yellow + } + + return $result +} + +# Python Environment Setup Functions +# Find-SystemPython: Find system Python +# Find-LatestPythonVersion: Find latest Python version +# Show-PythonOptions: Show Python options +# Handle-PythonSelection: Handle Python selection +# Select-Python: Select Python installation +# Get-PythonVersionString: Get Python version string +# Test-PythonVersion: Test if Python version meets requirements + +function Find-SystemPython { + # Build search paths + $searchPaths = @( + "$env:LOCALAPPDATA\Programs\Python\python.exe" + "$env:LOCALAPPDATA\Programs\Python\Python*\python.exe" + "$env:ProgramFiles\Python\python.exe" + "${env:ProgramFiles(x86)}\Python\python.exe" + "$env:ProgramFiles\Python\Python*\python.exe" + "${env:ProgramFiles(x86)}\Python\Python*\python.exe" + "$env:USERPROFILE\Anaconda3\python.exe" + "$env:USERPROFILE\Miniconda3\python.exe" + "$env:USERPROFILE\conda\python.exe" + ) + + # Add drive-specific paths + foreach ($drive in (Get-PSDrive -PSProvider FileSystem | Select-Object -ExpandProperty Root)) { + # $searchPaths += "$drive\Python*\python.exe" + $searchPaths += "$drive\py*\python.exe" + $searchPaths += "$drive\Tools\Python*\python.exe" + $searchPaths += "$drive\Anaconda3\python.exe" + $searchPaths += "$drive\Miniconda3\python.exe" + } + + # Test if a path is a valid Python executable + function Test-PythonPath { + param([string]$Path) + + try { + # Check if it's a command name (not a full path) + if ($Path -notmatch '[\\/]') { + # For command names, use Get-Command to resolve + $cmdInfo = Get-Command -Name $Path -ErrorAction SilentlyContinue + if ($cmdInfo) { + $actualPath = $cmdInfo.Source + # Skip Windows Store Python launcher + if ($actualPath -like '*WindowsApps\python.exe') { + return $false + } + # Test the actual path + $version = & $actualPath --version 2>&1 + return $version -match "Python" + } + return $false + } + else { + # For full paths, test directly + $version = & $Path --version 2>&1 + return $version -match "Python" + } + } + catch { + return $false + } + } + + $foundPaths = @() + + # Search all paths + foreach ($pythonPath in $searchPaths) { + if ($pythonPath -like '*\*') { + # Handle wildcard paths + try { + $resolvedPaths = Resolve-Path -Path $pythonPath -ErrorAction SilentlyContinue + if ($resolvedPaths) { + foreach ($resolvedPath in $resolvedPaths) { + if (Test-PythonPath -Path $resolvedPath.Path) { + $foundPaths += $resolvedPath.Path + } + } + } + } + catch { + continue + } + } + elseif (Test-Path $pythonPath -and (Test-PythonPath -Path $pythonPath)) { + $foundPaths += $pythonPath + } + } + + # Check system PATH commands (py launcher) + foreach ($cmd in @("py")) { + if (Test-PythonPath -Path $cmd) { + $foundPaths += $cmd + } + } + + # Remove duplicates + return @($foundPaths | Select-Object -Unique) +} + +function Find-LatestPythonVersion { + param( + [Parameter(Mandatory = $true)] + [string[]]$PythonPaths + ) + + $latestPython = $null + $latestVersion = [version]"0.0.0" + $latestIndex = 0 + + for ($i = 0; $i -lt $PythonPaths.Count; $i++) { + $verString = & $PythonPaths[$i] --version 2>&1 | Select-String "Python" + $verString = $verString.Line -replace 'Python ', '' + try { + $currentVersion = [version]$verString + if ($currentVersion -gt $latestVersion) { + $latestVersion = $currentVersion + $latestPython = $PythonPaths[$i] + $latestIndex = $i + } + } + catch { + # If version parsing fails, use this Python if we haven't found one yet + if (-not $latestPython) { + $latestPython = $PythonPaths[$i] + $latestIndex = $i + } + continue + } + } + + # Fallback: if no Python was selected (all version parsing failed), use the first one + if (-not $latestPython) { + $latestPython = $PythonPaths[0] + $latestIndex = 0 + } + + return @{ + Python = $latestPython + Index = $latestIndex + } +} + +function Show-PythonOptions { + param( + [Parameter(Mandatory = $true)] + [string[]]$PythonPaths + ) + + Write-Host "" + Write-LogInfo "multiple_python_found" + + for ($i = 0; $i -lt $PythonPaths.Count; $i++) { + $ver = & $PythonPaths[$i] --version 2>&1 | Select-String "Python" + Write-Host " $($i + 1)). $($PythonPaths[$i]) - $($ver.Line)" + } + $portablePythonMsg = Get-Message 'install_portable_python' + $portablePythonMsg = $portablePythonMsg -replace '\{0\}', $script:PYTHON_VERSION + Write-Host " $($PythonPaths.Count + 1)). $portablePythonMsg" -ForegroundColor Cyan + Write-Host "" +} + +function Handle-PythonSelection { + param( + [Parameter(Mandatory = $true)] + [string[]]$PythonPaths, + [Parameter(Mandatory = $true)] + [int]$LatestIndex + ) + + $result = New-PythonConfig + $result.InstallPortablePython = $false + + $msg = Get-Message "select_python" + $formatted = $msg -f $PythonPaths.Count, ($LatestIndex + 1), ($PythonPaths.Count + 1) + Write-Host $formatted -NoNewline -ForegroundColor Yellow + $choice = Read-Host + + if ([string]::IsNullOrEmpty($choice)) { + # Use default (latest) + $result.PythonPath = $PythonPaths[$LatestIndex] + } + else { + try { + $choiceInt = [int]$choice + } + catch { + # Invalid input (non-numeric), use default + Write-LogWarning "python_not_found" "" + $result.PythonPath = $PythonPaths[$LatestIndex] + return $result + } + + if ($choiceInt -ge 1 -and $choiceInt -le $PythonPaths.Count) { + $result.PythonPath = $PythonPaths[$choiceInt - 1] + } + elseif ($choiceInt -eq ($PythonPaths.Count + 1)) { + # Install portable Python + # Check if PythonPath is empty, prompt user if in non-auto mode + if ([string]::IsNullOrEmpty($script:Config.PythonConfig.PythonPath)) { + $promptedPath = Prompt-PythonPath + if ($promptedPath) { + $script:Config.PythonConfig.PythonPath = $promptedPath + } + } + $result = New-PortingPythonConfig + } + else { + # Invalid choice, use default + $result.PythonPath = $PythonPaths[$LatestIndex] + } + } + return $result +} + +function Select-Python { + param( + [Parameter(Mandatory = $true)] + [string[]]$PythonPaths, + [bool]$SkipVerification = $false + ) + + $result = $Script:Config.PythonConfig + + if ($PythonPaths.Count -eq 0) { + return $result + } + + # Find the latest version to use as default + $latestInfo = Find-LatestPythonVersion -PythonPaths $PythonPaths + $latestPython = $latestInfo.Python + $latestIndex = $latestInfo.Index + $result.InstallPortablePython = $false + + # In auto mode, automatically select the latest version + if ($script:Config.AutoMode) { + $msg = Get-Message "auto_selected" + $formatted = $msg -f $latestPython + Write-Host $formatted -ForegroundColor Yellow + $result.PythonPath = $latestPython + # Get version and validate + $version = Get-PythonVersionString -PythonPath $latestPython + if ($version -and (Test-PythonVersion -VersionString $version)) { + $result.Version = $version + $result.Result = 0 + } + else { + $result = New-PortingPythonConfig -PythonPath $latestPython + } + } + else { + # Interactive mode, let user choose + Show-PythonOptions -PythonPaths $PythonPaths + $result = Handle-PythonSelection -PythonPaths $PythonPaths -LatestIndex $latestIndex + } + + return $result +} +function Get-PythonVersionString { + param([Parameter(Mandatory = $true)][string]$PythonPath) + + try { + $version = & $PythonPath --version 2>&1 | Select-String "Python" + if ($?) { + return $version.Line -replace 'Python ', '' + } + } + catch { + return $null + } + return $null +} + +# Test-PythonVersion function +# Test if Python version meets minimum requirement (>= 3.6) +function Test-PythonVersion { + param([Parameter(Mandatory = $true)][string]$VersionString) + + $versionParts = $VersionString -split '[ .]' + if ($versionParts.Count -ge 2) { + $major = [int]$versionParts[0] + $minor = [int]$versionParts[1] + if ($major -gt 3 -or ($major -eq 3 -and $minor -ge 6)) { + return $true + } + } + return $false +} +# UI Functions +# Show-Banner: Display installation banner + +function Show-Banner { + Write-Host "" + Write-Host "============================================================" -ForegroundColor Cyan + Write-Host " $(Get-Message 'banner_title') " -ForegroundColor Cyan + Write-Host "============================================================" -ForegroundColor Cyan + Write-Host "" +} + +# Installation Process Functions + +function Ensure-Python { + $result = $script:Config.PythonConfig + + # 步骤 0: 检查便携 Python 路径是否为空 + if ($result.InstallPortablePython -and [string]::IsNullOrEmpty($result.PythonPath)) { + if ($script:Config.AutoMode) { + # Auto mode: use default path + $result.PythonPath = Join-Path $DEFAULT_PYTHON_PATH "python.exe" + } + else { + # Interactive mode: prompt user for path + $promptedPath = Prompt-PythonPath + if ($promptedPath) { + $result.PythonPath = $promptedPath + $script:Config.PythonConfig.PythonPath = $promptedPath + } + else { + # User cancelled or invalid input, use default + $result.PythonPath = Join-Path $DEFAULT_PYTHON_PATH "python.exe" + $script:Config.PythonConfig.PythonPath = $result.PythonPath + } + } + } + + # 步骤 1: 查找选择系统 Python + if (-not $result.InstallPortablePython) { + $result = Select-Python -PythonPaths (Find-SystemPython) + } + + # 步骤 2: 验证系统 Python + if (-not $result.InstallPortablePython -and $result.PythonPath) { + $result = Check-Python -PythonConfig $result + } + if ($result.InstallPortablePython ) { + if ($result.Result -eq 0) { + Write-LogWarning "installing_portable_python" + } + else { + Write-LogWarning "python_not_found_or_invalid" + } + } + + # 步骤 3: 安装便携式 Python(如果需要) + if ($result.InstallPortablePython) { + $result = Install-PortablePython -UseCNMirror $script:Config.UseCN + } + + # 步骤 4: 检查结果 + if ($result.Result -ne 0) { + Write-LogError "python_setup_failed" $result.Result + exit $result.Result + } + $Script:Config.PythonConfig = $result + Write-LogSuccess "python_ready" $result.PythonPath $result.Version +} + +function Ensure-Git { + # Check and install Git if missing + if (-not (Test-Command "git")) { + Write-LogInfo "git_not_found" + if (-not $script:Config.IsAdmin) { + Write-LogError "git_not_found_no_admin" + Write-LogWarning "admin_required_for_git_install" + exit 1 + } + # When -y is used, install Git silently; otherwise show interactive installer + Install-Git -UseCNMirror $script:Config.UseCN -Interactive (-not $script:Config.AutoMode) + Write-Host "" + Write-LogWarning "restart_required" + Read-Host -Prompt "Press Enter to exit..." + exit 0 + } + + $gitVersion = git --version 2>&1 + $gitVersion = $gitVersion.Trim() + Write-LogSuccess "git_found" $gitVersion +} + +# Save-TouchEnvToFile function +# Save touch_env.py script content to temporary file +function Save-TouchEnvToFile { + param( + [string]$ScriptContent + ) + + $touchEnvTempFile = Join-Path $env:TEMP ("touch_env_" + [guid]::NewGuid().ToString("N") + ".py") + Add-TempFile -FilePath $touchEnvTempFile + Set-Content -Path $touchEnvTempFile -Value $ScriptContent -Encoding UTF8 + return $touchEnvTempFile +} + +# Build-TouchEnvArgs function +# Build argument list for touch_env.py +function Build-TouchEnvArgs { + param( + [string]$TouchEnvFilePath + ) + + # Build arguments list + $pythonArgs = @($TouchEnvFilePath) + # 条件传递 --env-root + if ($script:Config.EnvRoot) { + $pythonArgs += "--env-root", $script:Config.EnvRoot + } + if ($script:Config.UseCN) { $pythonArgs += "--use-cn" } + $pythonArgs += "--language", $script:Config.LangCurrent + if ($script:Config.AutoMode) { $pythonArgs += "--auto-mode" } + + # Pass custom repositories with branch info in URL fragment + if ($script:Config.CustomEnv) { + $pythonArgs += "--repo-env", $script:Config.CustomEnv + } + + if ($script:Config.CustomPackages) { + $pythonArgs += "--repo-packages", $script:Config.CustomPackages + } + + if ($script:Config.CustomSdk) { + $pythonArgs += "--repo-sdk", $script:Config.CustomSdk + } + + # Pass keep-sdk decision + if ($script:Config.KeepSdk) { + $pythonArgs += "--keep-sdk", $script:Config.KeepSdk + } + + return $pythonArgs +} + +# Show-TouchEnvError function +# Show touch_env.py error output if available +function Show-TouchEnvError { + $errorFile = "$env:TEMP\touch_env_error.txt" + if (Test-Path $errorFile) { + $errorOutput = Get-Content $errorFile -Raw + if ($errorOutput) { + Write-Host $errorOutput -ForegroundColor Red + } + } +} + +# Invoke-TouchEnv function +# Download and execute touch_env.py to handle Step 5-10 +function Invoke-TouchEnv { + param( + [string]$ScriptContent + ) + + $touchEnvFile = $null + try { + # Save touch_env.py to temp file + $touchEnvFile = Save-TouchEnvToFile -ScriptContent $ScriptContent + + # Build arguments list + $pythonArgs = Build-TouchEnvArgs -TouchEnvFilePath $touchEnvFile + + # 显示"$env:TEMP\touch_env_output.txt" 的内容 + Write-Host "运行参数: $pythonArgs" -ForegroundColor Green + + # Run touch_env.py in the same window with full interactivity + & $script:Config.PythonConfig.PythonPath $pythonArgs + $touchEnvExitCode = $LASTEXITCODE + + if ($touchEnvExitCode -ne 0) { + Write-LogError "touch_env_failed" $touchEnvExitCode + Show-TouchEnvError + exit $touchEnvExitCode + } + } + catch { + Write-LogError "touch_env_download_failed" $_.Exception.Message + exit 1 + } + finally { + if ($touchEnvFile -and (Test-Path $touchEnvFile)) { + Remove-Item $touchEnvFile -Force -ErrorAction SilentlyContinue + } + } +} + + + +# ============================================================================ +# Windows Environment Initialization Functions +# ============================================================================ + + + +# Request-Elevation: Request administrator privileges for a task +function Request-Elevation { + param( + [string]$TaskDescription, + [string]$ScriptBlock + ) + + $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + if ($isAdmin) { + return $true + } + + # Create temporary script + $tempScript = [System.IO.Path]::GetTempFileName() + ".ps1" + Add-TempFile -FilePath $tempScript + + if ($ScriptBlock) { + $ScriptBlock | Out-File -FilePath $tempScript -Encoding UTF8 + } + else { + "" | Out-File -FilePath $tempScript -Encoding UTF8 + } + + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = "powershell.exe" + $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -NoExit -File `"$tempScript`"" + $psi.Verb = "RunAs" + $psi.UseShellExecute = $true + + try { + Write-LogInfo "requesting_elevation" $TaskDescription + $process = [System.Diagnostics.Process]::Start($psi) + $process.WaitForExit() + return $process.ExitCode -eq 0 + } + catch { + Write-LogWarning "elevation_failed" $TaskDescription + return $false + } +} + +# Get-EffectiveExecutionPolicy: Get the effective execution policy (excluding Process scope) +# Returns a hashtable with Policy and EffectiveScope +function Get-EffectiveExecutionPolicy { + $policyLevels = @{ + "Undefined" = 0 + "Restricted" = 1 + "AllSigned" = 2 + "RemoteSigned" = 3 + "Unrestricted" = 4 + "Bypass" = 5 + } + + try { + # Get all execution policies + $policies = Get-ExecutionPolicy -List -ErrorAction SilentlyContinue + + # If policies is empty or null, Get-ExecutionPolicy failed + if (-not $policies -or $policies.Count -eq 0) { + # Fallback: try to get each scope individually + $fallbackPolicies = @() + foreach ($scope in @("MachinePolicy", "UserPolicy", "Process", "CurrentUser", "LocalMachine")) { + try { + $policy = Get-ExecutionPolicy -Scope $scope -ErrorAction SilentlyContinue + $fallbackPolicies += [PSCustomObject]@{ + Scope = $scope + ExecutionPolicy = $policy + } + } catch { + $fallbackPolicies += [PSCustomObject]@{ + Scope = $scope + ExecutionPolicy = "Undefined" + } + } + } + $policies = $fallbackPolicies + } + + # Priority order: MachinePolicy > UserPolicy > Process > CurrentUser > LocalMachine + # We exclude Process scope as it's temporary + $scopePriority = @("MachinePolicy", "UserPolicy", "CurrentUser", "LocalMachine") + + foreach ($scope in $scopePriority) { + $policy = $policies | Where-Object { $_.Scope -eq $scope } + if ($policy -and $policy.ExecutionPolicy -ne "Undefined") { + return @{ + Policy = $policy.ExecutionPolicy + EffectiveScope = $scope + } + } + } + + # If all are Undefined, return Restricted (default) + return @{ + Policy = "Restricted" + EffectiveScope = "LocalMachine (default)" + } + } + catch { + # If Get-ExecutionPolicy fails, assume Restricted + return @{ + Policy = "Restricted" + EffectiveScope = "Unknown" + } + } +} + +# Show-CurrentExecutionPolicyStatus: Display current execution policy status +function Show-CurrentExecutionPolicyStatus { + param( + [hashtable]$PolicyInfo + ) + + $currentPolicy = $PolicyInfo.Policy + $currentScope = $PolicyInfo.EffectiveScope + + if ($currentScope) { + $statusMsg = Get-Message "status_current_policy" + $formatted = $statusMsg -f $currentPolicy, $currentScope + Write-Host $formatted -ForegroundColor Cyan + } else { + $statusMsg = Get-Message "status_current_policy" + $formatted = $statusMsg -f $currentPolicy, "N/A" + Write-Host $formatted -ForegroundColor Cyan + } +} + +# Check-ExecutionPolicy: Check if execution policy needs to be changed +function Check-ExecutionPolicy { + param( + [hashtable]$PolicyInfo + ) + + $currentPolicy = $PolicyInfo.Policy + $currentScope = $PolicyInfo.EffectiveScope + + $policyLevels = @{ + "Undefined" = 0 + "Restricted" = 1 + "AllSigned" = 2 + "RemoteSigned" = 3 + "Unrestricted" = 4 + "Bypass" = 5 + } + + $currentLevel = $policyLevels[$currentPolicy.ToString()] + $targetLevel = $policyLevels["RemoteSigned"] + $needPolicy = ($null -eq $currentLevel -or $currentLevel -lt $targetLevel) + + # Determine which scope to set based on effective scope + $scopeToSet = "" + if ($needPolicy) { + # Extract scope name from "Scope (description)" format if needed + if ($currentScope -match "^(.*?)\s*\(") { + $scopeName = $Matches[1].Trim() + } else { + $scopeName = $currentScope + } + $scopeToSet = if ($scopeName -and $scopeName -ne "Process" -and $scopeName -ne "Unknown" -and $scopeName -ne "N/A") { $scopeName } else { "LocalMachine" } + $script:Config.ScopeToSet = $scopeToSet + } + + return @{ + NeedPolicy = $needPolicy + ScopeToSet = $scopeToSet + } +} + +# Check-LongPathSupport: Check if long path support needs to be enabled +function Check-LongPathSupport { + $needLongPath = $false + $longPathStatus = "Unknown" + + try { + $registryPath = "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" + $longPathEnabled = (Get-ItemProperty -Path $registryPath -ErrorAction SilentlyContinue).LongPathsEnabled + $needLongPath = ($longPathEnabled -ne 1) + $longPathStatusKey = if ($longPathEnabled -eq 1) { "status_enabled" } else { "status_disabled" } + $longPathStatus = Get-Message $longPathStatusKey + Write-LogRaw "status_current_longpath" -Color Cyan -Arg1 $longPathStatus + } + catch { + $needLongPath = $true + Write-Host "Current long path support: Unknown (assuming Disabled)" -ForegroundColor Yellow + } + + return @{ + NeedLongPath = $needLongPath + CurrentStatus = $longPathStatus + } +} + +# Show-ConfigurationNeeds: Display what needs to be changed +function Show-ConfigurationNeeds { + param( + [bool]$NeedPolicy, + [bool]$NeedLongPath + ) + + if ($NeedPolicy) { + Write-LogWarning "execution_policy_too_low" + } + if ($NeedLongPath) { + Write-LogWarning "long_path_support_required" + } +} + +# Execute-SetExecutionPolicy: Execute Set-ExecutionPolicy command +function Execute-SetExecutionPolicy { + param( + [string]$Scope + ) + + $action = "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope $Scope -Force" + Write-Host "Executing: $action" -ForegroundColor Yellow + + $output = Invoke-Expression $action 2>&1 + $actualSuccess = $true + + # Get current Process scope policy to check if it's the effective one + $processPolicy = try { + Get-ExecutionPolicy -Scope Process -ErrorAction SilentlyContinue + } catch { + "Undefined" + } + + # Check if the output contains the "overridden by a policy" warning + if ($output -match "overridden by a policy" -and $processPolicy -eq "Bypass") { + # Only ignore if Process scope is currently effective (Bypass) + Write-Host "Success (policy overridden by Process scope: $processPolicy)" -ForegroundColor Green + } elseif ($LASTEXITCODE -ne 0) { + $actualSuccess = $false + Write-LogWarning "windows_env_set_failed" + Write-Host "Error: $output" -ForegroundColor Red + } else { + Write-Host "Success" -ForegroundColor Green + } + + return $actualSuccess +} + +# Verify-ExecutionPolicy: Verify execution policy was set correctly +function Verify-ExecutionPolicy { + param( + [string]$Scope + ) + + $actualPolicy = try { + Get-ExecutionPolicy -Scope $Scope -ErrorAction SilentlyContinue + } catch { + $null + } + + if ($actualPolicy -eq "RemoteSigned") { + Write-LogRaw "verified_policy_set" -Color Green -Arg1 $Scope -Arg2 $actualPolicy + return $true + } else { + Write-LogWarning "windows_env_set_failed" + Write-LogRaw "warning_policy_not_set" -Color Yellow -Arg1 $Scope -Arg2 $actualPolicy + return $false + } +} + +# Show-NewPolicyStatus: Display new effective policy status +function Show-NewPolicyStatus { + $newPolicyInfo = Get-EffectiveExecutionPolicy + $newEffectivePolicy = $newPolicyInfo.Policy + $newEffectiveScope = $newPolicyInfo.EffectiveScope + + if ($newEffectiveScope) { + $statusMsg = Get-Message "status_new_policy" + $formatted = $statusMsg -f $newEffectivePolicy, $newEffectiveScope + Write-Host $formatted -ForegroundColor Green + } else { + $statusMsg = Get-Message "status_new_policy" + $formatted = $statusMsg -f $newEffectivePolicy, "N/A" + Write-Host $formatted -ForegroundColor Green + } +} + +# Execute-EnableLongPath: Execute command to enable long path support +function Execute-EnableLongPath { + $action = 'Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -Type DWord -Force' + Write-Host "Executing: $action" -ForegroundColor Yellow + + $output = Invoke-Expression $action 2>&1 + $actualSuccess = $true + + if ($LASTEXITCODE -ne 0) { + $actualSuccess = $false + Write-LogWarning "windows_env_set_failed" + Write-Host "Error: $output" -ForegroundColor Red + } else { + Write-Host "Success" -ForegroundColor Green + } + + return $actualSuccess +} + +# Verify-LongPathSupport: Verify long path support was enabled +function Verify-LongPathSupport { + $newLongPathEnabled = try { + (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -ErrorAction SilentlyContinue).LongPathsEnabled + } catch { + 0 + } + + $newLongPathStatusKey = if ($newLongPathEnabled -eq 1) { "status_enabled" } else { "status_disabled" } + $newLongPathStatus = Get-Message $newLongPathStatusKey + Write-LogRaw "status_new_longpath" -Color Green -Arg1 $newLongPathStatus + + return ($newLongPathEnabled -eq 1) +} + +# Configure-WindowsEnvironment: Apply Windows environment configuration changes +function Configure-WindowsEnvironment { + param( + [bool]$NeedPolicy, + [bool]$NeedLongPath + ) + + $actions = @() + $allSuccess = $true + + if ($NeedPolicy) { + $scope = $script:Config.ScopeToSet + $actions += @{ + command = "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope $scope -Force" + type = "policy" + } + } + + if ($NeedLongPath) { + $actions += @{ + command = 'Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -Type DWord -Force' + type = "longpath" + } + } + + foreach ($actionItem in $actions) { + $action = $actionItem.command + $actionType = $actionItem.type + + try { + if ($actionType -eq "policy") { + $success = Execute-SetExecutionPolicy -Scope $script:Config.ScopeToSet + if ($success) { + Verify-ExecutionPolicy -Scope $script:Config.ScopeToSet + Show-NewPolicyStatus + } else { + $allSuccess = $false + } + } elseif ($actionType -eq "longpath") { + $success = Execute-EnableLongPath + if ($success) { + Verify-LongPathSupport + } else { + $allSuccess = $false + } + } + } + catch { + # Even if exception occurs, check if the policy was actually set + if ($action -match "Set-ExecutionPolicy") { + $scope = $script:Config.ScopeToSet + $actualPolicy = try { + Get-ExecutionPolicy -Scope $scope -ErrorAction SilentlyContinue + } catch { + $null + } + if ($actualPolicy -eq "RemoteSigned") { + $exceptionMsg = $_.Exception.Message + Write-LogRaw "verified_policy_set_exception" -Color Green -Arg1 $exceptionMsg + Write-LogRaw "verified_policy_set" -Color Green -Arg1 $scope -Arg2 $actualPolicy + Show-NewPolicyStatus + } else { + Write-LogError "windows_env_set_failed" + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red + $allSuccess = $false + } + } else { + Write-LogError "windows_env_set_failed" + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red + $allSuccess = $false + } + } + } + + return $allSuccess +} + +# Init-WindowsEnv: Initialize Windows environment settings +function Init-WindowsEnv { + Write-LogInfo "initializing_windows_env" + + # Check execution policy + $currentPolicyInfo = Get-EffectiveExecutionPolicy + Show-CurrentExecutionPolicyStatus -PolicyInfo $currentPolicyInfo + + $policyCheck = Check-ExecutionPolicy -PolicyInfo $currentPolicyInfo + + # Check long path support + $longPathCheck = Check-LongPathSupport + + # If everything is OK, return + if (-not $policyCheck.NeedPolicy -and -not $longPathCheck.NeedLongPath) { + Write-LogSuccess "windows_env_adequate" + return + } + + # Show what needs to be changed + Show-ConfigurationNeeds -NeedPolicy $policyCheck.NeedPolicy -NeedLongPath $longPathCheck.NeedLongPath + + # Check if running as administrator + if ($script:Config.IsAdmin) { + $success = Configure-WindowsEnvironment -NeedPolicy $policyCheck.NeedPolicy -NeedLongPath $longPathCheck.NeedLongPath + + if ($success) { + Write-LogSuccess "windows_env_initialized" + } else { + exit 1 + } + return + } else { + # Not admin, show error and exit + Write-LogError "admin_required_for_env_config" + Write-Host "" + Write-LogRaw "admin_run_instructions" -Color Yellow + Write-LogRaw "admin_step_1" -Color White + Write-LogRaw "admin_step_2" -Color White + Write-LogRaw "admin_step_3" -Color White + exit 1 + } +} + + +# Init-Config function +# Initialize installation environment and validate settings +function Init-Config { + param( + [PSCustomObject]$ParsedArg + ) + + # Set strict mode and error handling + Set-StrictMode -Version Latest + $ErrorActionPreference = "Stop" + + # Initialize global config + $script:Config = [PSCustomObject]@{ + LangCurrent = "" + UseCN = $false + UseCNSet = $false + KeepSdk = "" + AutoMode = $false + NeedHelp = $false + IsAdmin = $false + CustomPackages = "" + CustomEnv = "" + CustomSdk = "" + PythonConfig = New-PythonConfig + EnvRoot = "" + TempFiles = @() + ScopeToSet = "" + } + + # Register cleanup handler (must be after Config initialization) + Register-CleanupHandler + + # Set config from parsed arguments + $script:Config.LangCurrent = if ($ParsedArgs.LangChoice) { $ParsedArgs.LangChoice } else { Get-SystemLanguage } + $script:Config.UseCN = $ParsedArgs.CnMode + $script:Config.UseCNSet = $ParsedArgs.CnMode -or $ParsedArgs.OfficialMode + $script:Config.AutoMode = $ParsedArgs.AutoMode + $script:Config.NeedHelp = $ParsedArgs.HelpMode + $script:Config.KeepSdk = $ParsedArgs.KeepSdk + $script:Config.CustomPackages = $ParsedArgs.CustomPackages + $script:Config.CustomEnv = $ParsedArgs.CustomEnv + $script:Config.CustomSdk = $ParsedArgs.CustomSdk + + # Set PythonConfig.PythonPath based on command line arguments + if ($ParsedArgs.PythonPath) { + # User specified path via -p parameter + $script:Config.PythonConfig.PythonPath = Join-Path $ParsedArgs.PythonPath "python.exe" + } + # else: PythonConfig.PythonPath remains empty (will be prompted later) + + # Set EnvRoot for passing to touch_env.py + $script:Config.EnvRoot = $ParsedArgs.EnvRoot + + # Check administrator privileges + $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + $script:Config.IsAdmin = $isAdmin + + # Handle help request + if ($script:Config.NeedHelp) { + Print-Help + return + } + + # Detect China mirror if not explicitly set + if (-not $script:Config.UseCNSet) { + $script:Config.UseCN = Detect-China + } + $mirrorType = if ($script:Config.UseCN) { "china_mirror" } else { "official_mirror" } + Write-LogInfo "mirror_selection" (Get-Message $mirrorType) + + # Override with --official flag + if ($ParsedArgs.OfficialMode) { + $script:Config.UseCN = $false + } +} + +# Show-DownloadError function +# Show download error message with checklist +function Show-DownloadError { + param( + [string]$Url + ) + + Write-Host "" + Write-LogRaw "check_list" -Color Yellow + Write-LogRaw "check_list_connection" -Color Yellow + Write-LogRaw "check_list_url" -Color Yellow -Arg1 $Url + Write-LogRaw "check_list_alt_url" -Color Yellow +} + +# Download-TouchEnv function +# Download touch_env.py script from network with fallback handling +function Download-TouchEnv { + param( + [PSCustomObject]$ParsedArgs + ) + + # Set touch_env.py download URL (priority: -t > --env > UseCN/Gitee > GitHub) + if ($ParsedArgs.TouchEnvUrlValue) { + $TOUCH_ENV_URL = $ParsedArgs.TouchEnvUrlValue + } + elseif ($ParsedArgs.CustomEnv) { + # Use custom env repo for touch_env.py download + # Parse URL and branch from string (format: url[#branch]) + if ($ParsedArgs.CustomEnv -match "#") { + $parts = $ParsedArgs.CustomEnv -split "#", 2 + $repo = $parts[0] + $branch = $parts[1] + } + else { + $repo = $ParsedArgs.CustomEnv + $branch = "master" + } + + # Convert GitHub repo URL to raw.githubusercontent.com URL + if ($repo -match "^https?://github\.com/([^/]+)/([^/]+?)(\.git)?$") { + # GitHub repository: https://github.com/owner/repo -> https://raw.githubusercontent.com/owner/repo/branch/tools/touch_env.py + $owner = $Matches[1] + $repoName = $Matches[2] -replace '\.git$', '' + $TOUCH_ENV_URL = "https://raw.githubusercontent.com/$owner/$repoName/$branch/tools/touch_env.py" + } + else { + # Non-GitHub repository: use /raw/ format + $TOUCH_ENV_URL = "$repo/raw/$branch/tools/touch_env.py" + } + } + elseif ($script:Config.UseCN) { + $TOUCH_ENV_URL = $TOUCH_ENV_URL_GITEE + } + else { + $TOUCH_ENV_URL = $TOUCH_ENV_URL_GITHUB + } + + # Download touch_env.py from network + Write-Host "" + Write-LogInfo "downloading_touch_env" $TOUCH_ENV_URL + $scriptContent = $null + + try { + # Try with SSL verification first + $response = Invoke-WebRequest -Uri $TOUCH_ENV_URL -UseBasicParsing -ErrorAction Stop + $scriptContent = $response.Content + Write-LogInfo "touch_env_downloaded" + } + catch { + # If SSL error, try without SSL verification + if ($_.Exception.Message -match "SSL" -or $_.Exception.Message -match "certificate") { + Write-LogWarning "ssl_verification_failed" + try { + $response = Invoke-WebRequest -Uri $TOUCH_ENV_URL -UseBasicParsing -SkipCertificateCheck -ErrorAction Stop + $scriptContent = $response.Content + } + catch { + Write-LogError "touch_env_download_failed" $_.Exception.Message + Show-DownloadError -Url $TOUCH_ENV_URL + exit 1 + } + } + else { + Write-LogError "touch_env_download_failed" $_.Exception.Message + Show-DownloadError -Url $TOUCH_ENV_URL + exit 1 + } + } + + return $scriptContent +} + +# Main Function +# Main function: Coordinate all installation steps +function Main { + # Parse command line arguments + $parsedArgs = Parse-Arguments -Arguments $args + + # Initialize configuration + Init-Config -ParsedArgs $parsedArgs + + # Initialize Windows environment + Init-WindowsEnv + + # Step 1: Print installation banner + Show-Banner + + # Step 2: Ensure Python and Git are installed + Ensure-Python + Ensure-Git + + # Step 3: Download touch_env.py + $scriptContent = Download-TouchEnv -ParsedArgs $parsedArgs + + # Step 4: Call touch_env.py to handle Step 5-10 + Invoke-TouchEnv -ScriptContent $scriptContent +} + +# Execute main function +Main @args diff --git a/tools/install.sh b/tools/install.sh new file mode 100755 index 00000000..e4c75d5c --- /dev/null +++ b/tools/install.sh @@ -0,0 +1,726 @@ +#!/usr/bin/env bash +# +# File : install.sh +# This file is part of RT-Thread RTOS +# COPYRIGHT (C) 2006 - 2026, RT-Thread Development Team +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Change Logs: +# Date Author Notes +# 2026-01-31 dongly Refactored + +# RT-Thread ENV Installation Script (Unix) +# Unified installation script for Linux and macOS +# Supports: English / 中文 +# +# Usage: +# ./install.sh [--yes] [--cn] [--official] [--keep-sdk ] [--env-root ] [--lang ] [--packages [#]] [--env [#]] [--sdk [#]] [--touch-env ] [--help] +# +# Options: +# --yes, --auto Auto-install without prompts +# --cn, --gitee Use China mirror (Gitee, PyPI TUNA) +# --official Force use official source +# --keep-sdk Keep toolchains (local_pkgs) and config when reinstalling (default: yes) +# 重装时保留工具链(local_pkgs)与配置(默认:是) +# --env-root Set custom install directory +# --lang Force message language +# 强制消息语言 +# --packages [#] Specify custom packages repository and branch +# --env [#] Specify custom env repository and branch +# --sdk [#] Specify custom sdk repository and branch +# --touch-env Specify touch_env.py download URL +# -h, --help Show this help message +# + +# ============================================================================ +# Configuration +# ============================================================================ + +# Verify script is running in bash or zsh +if [ -z "$BASH_VERSION" ] && [ -z "$ZSH_VERSION" ]; then + echo "Error: This script must be run with bash or zsh, not sh" >&2 + exit 1 +fi + +# Global configuration variables (like $script:Config in PowerShell) +CONFIG_AUTO_MODE=false +CONFIG_HELP_MODE=false +CONFIG_ENV_ROOT="" +CONFIG_LANG="en" +CONFIG_USE_CN_SET=false +CONFIG_USE_CN=false +CONFIG_CUSTOM_PACKAGES_REPO="" +CONFIG_CUSTOM_ENV_REPO="" +CONFIG_CUSTOM_SDK_REPO="" +CONFIG_KEEP_SDK="" +CONFIG_TOUCH_ENV_URL_VALUE="" + +# Global variables for user context (initialized in init_environment) +REAL_USER_HOME="" +REAL_USER="" +REAL_USER_SHELL="" + +# Global variable for tracking temporary files (for cleanup) +TEMP_FILES=() + +# IP detection service +IPINFO_URL="https://ipinfo.io/json" + +# Homebrew installation script +HOMEBREW_INSTALL_URL="https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" + +# touch_env.py download URLs +TOUCH_ENV_URL_GITHUB="https://raw.githubusercontent.com/RT-Thread/env/master/tools/touch_env.py" +TOUCH_ENV_URL_GITEE="https://gitee.com/RT-Thread-Mirror/env/raw/master/tools/touch_env.py" + +# ============================================================================ +# Message Dictionary (Centralized i18n messages like PowerShell $script:Messages) +# ============================================================================ + +declare -A MESSAGES_EN=( + ["banner_title"]="RT-Thread ENV Installation" + ["info"]="INFO" + ["success"]="SUCCESS" + ["warning"]="WARNING" + ["error"]="ERROR" + ["git_not_found"]="Git is not installed. Please install Git first." + ["git_found"]="Git version: %s" + ["start"]="Starting RT-Thread ENV installation..." + ["installing_ubuntu"]="Installing dependencies (Ubuntu/Debian)..." + ["installing_suse"]="Installing dependencies (SUSE/openSUSE)..." + ["installing_arch"]="Installing dependencies (Arch/Manjaro)..." + ["installing_fedora"]="Installing dependencies (Fedora/RHEL/CentOS)..." + ["installing_alpine"]="Installing dependencies (Alpine)..." + ["unsupported_os"]="Unsupported OS: %s" + ["missing_gcc"]="Missing GCC compiler, please install manually" + ["installing_homebrew"]="Installing Homebrew..." + ["installing_macos"]="Installing dependencies (macOS)..." + ["missing_python"]="Python 3 not found. Please install Python first." + ["python_version"]="Python version: %s" + ["using_cn_mirror"]="Using China mirror" + ["using_official_source"]="Using official source" + ["downloading_touch_env"]="Downloading touch_env.py from: %s" + ["touch_env_downloaded"]="touch_env.py downloaded successfully." + ["touch_env_failed"]="touch_env.py execution failed with exit code: %s" + ["touch_env_download_failed"]="Failed to download touch_env.py: %s" +) + +declare -A MESSAGES_ZH=( + ["banner_title"]="RT-Thread ENV 安装程序" + ["info"]="信息" + ["success"]="成功" + ["warning"]="警告" + ["error"]="错误" + ["git_not_found"]="未安装 Git。请先安装 Git。" + ["git_found"]="Git 版本: %s" + ["start"]="开始启动 RT-Thread ENV 安装..." + ["installing_ubuntu"]="正在安装依赖 (Ubuntu/Debian)..." + ["installing_suse"]="正在安装依赖 (SUSE/openSUSE)..." + ["installing_arch"]="正在安装依赖 (Arch/Manjaro)..." + ["installing_fedora"]="正在安装依赖 (Fedora/RHEL/CentOS)..." + ["installing_alpine"]="正在安装依赖 (Alpine)..." + ["unsupported_os"]="不支持的操作系统: %s" + ["missing_gcc"]="缺少 GCC 编译器,请手动安装" + ["installing_homebrew"]="正在安装 Homebrew..." + ["installing_macos"]="正在安装依赖 (macOS)..." + ["missing_python"]="未找到 Python 3,请先安装" + ["python_version"]="Python 版本: %s" + ["using_cn_mirror"]="使用中国镜像源" + ["using_official_source"]="使用官方源" + ["downloading_touch_env"]="正在下载 touch_env.py,自: %s" + ["touch_env_downloaded"]="touch_env.py 下载完成。" + ["touch_env_failed"]="touch_env.py 执行失败,退出码: %s" + ["touch_env_download_failed"]="下载 touch_env.py 失败: %s" +) + +# ============================================================================ +# Initialization Functions +# ============================================================================ + +init_environment() { + # Get real user's home directory (handles sudo case) + if [ -n "$SUDO_USER" ]; then + # Running with sudo, use the original user's home + REAL_USER_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6) + REAL_USER="$SUDO_USER" + # Get the real user's default shell from /etc/passwd + REAL_USER_SHELL=$(getent passwd "$SUDO_USER" | cut -d: -f7) + else + # Running without sudo + REAL_USER_HOME="$HOME" + REAL_USER="$USER" + REAL_USER_SHELL="$SHELL" + fi + + # Detect language based on IP or system locale + detect_china +} + +# Cleanup function for temporary files +cleanup() { + for temp_file in "${TEMP_FILES[@]}"; do + rm -f "$temp_file" 2>/dev/null + done +} + +# Register cleanup handler for exit signals +trap cleanup EXIT INT TERM + +# ============================================================================ +# Message Functions +# ============================================================================ + +# Get message from dictionary (similar to PowerShell Get-Message) +get_message() { + local key="$1" + + # Select appropriate language dictionary + if [ "$CONFIG_LANG" = "zh" ]; then + if [ -n "${MESSAGES_ZH[$key]+isset}" ]; then + echo "${MESSAGES_ZH[$key]}" + else + echo "Unknown message: $key" + fi + else + if [ -n "${MESSAGES_EN[$key]+isset}" ]; then + echo "${MESSAGES_EN[$key]}" + else + echo "Unknown message: $key" + fi + fi +} + +# Log functions (similar to PowerShell Write-LogInfo/Success/Warning/Error) +log_info() { + local key="$1" + shift + local msg + msg=$(get_message "$key") + + # Format message with arguments + if [ $# -gt 0 ]; then + # shellcheck disable=SC2059 + printf "\033[0;34m[%s]\033[0m ${msg}\n" "$(get_message 'info')" "$@" >&2 + else + printf "\033[0;34m[%s]\033[0m ${msg}\n" "$(get_message 'info')" >&2 + fi +} + +log_success() { + local key="$1" + shift + local msg + msg=$(get_message "$key") + + if [ $# -gt 0 ]; then + # shellcheck disable=SC2059 + printf "\033[0;32m[%s]\033[0m ${msg}\n" "$(get_message 'success')" "$@" >&2 + else + printf "\033[0;32m[%s]\033[0m ${msg}\n" "$(get_message 'success')" >&2 + fi +} + +log_warning() { + local key="$1" + shift + local msg + msg=$(get_message "$key") + + if [ $# -gt 0 ]; then + # shellcheck disable=SC2059 + printf "\033[1;33m[%s]\033[0m ${msg}\n" "$(get_message 'warning')" "$@" >&2 + else + printf "\033[1;33m[%s]\033[0m ${msg}\n" "$(get_message 'warning')" >&2 + fi +} + +log_error() { + local key="$1" + shift + local msg + msg=$(get_message "$key") + + if [ $# -gt 0 ]; then + # shellcheck disable=SC2059 + printf "\033[0;31m[%s]\033[0m ${msg}\n" "$(get_message 'error')" "$@" >&2 + else + printf "\033[0;31m[%s]\033[0m ${msg}\n" "$(get_message 'error')" >&2 + fi +} + +# ============================================================================ +# Download and Execute touch_env.py Functions +# ============================================================================ + +download_and_run_touch_env() { + # Create temp file (portable: BSD/macOS mktemp rejects GNU-only --suffix) + local touch_env_dest + touch_env_dest=$(mktemp) + + # Track temp file for cleanup + TEMP_FILES+=("$touch_env_dest") + + # Download touch_env.py (determines URL internally) + download_touch_env "$touch_env_dest" || { + return 1 + } + + # Run touch_env.py + run_touch_env "$touch_env_dest" || { + return 1 + } + + # Temp file will be cleaned up by trap handler + + return 0 +} + +download_touch_env() { + local touch_env_dest="$1" + + # Determine touch_env.py download URL + local touch_env_download_url="$TOUCH_ENV_URL_GITHUB" + + if [ -n "$CONFIG_TOUCH_ENV_URL_VALUE" ]; then + touch_env_download_url="$CONFIG_TOUCH_ENV_URL_VALUE" + elif [ -n "$CONFIG_CUSTOM_ENV_REPO" ]; then + # Parse URL and branch from string (format: url[#branch]) + local repo="$CONFIG_CUSTOM_ENV_REPO" + local branch="master" + if [[ "$repo" == *"#"* ]]; then + branch="${repo#*#}" + repo="${repo%#*}" + fi + + # Convert GitHub repo URL to raw.githubusercontent.com URL + if [[ "$repo" =~ ^https?://github\.com/([^/]+)/([^/]+?)(\.git)?$ ]]; then + local owner="${BASH_REMATCH[1]}" + local repo_name="${BASH_REMATCH[2]%.git}" + touch_env_download_url="https://raw.githubusercontent.com/$owner/$repo_name/$branch/tools/touch_env.py" + else + # Non-GitHub repository: use /raw/ format + touch_env_download_url="$repo/raw/$branch/tools/touch_env.py" + fi + elif [ "$CONFIG_USE_CN" = "true" ]; then + touch_env_download_url="$TOUCH_ENV_URL_GITEE" + fi + + log_info "downloading_touch_env" "$touch_env_download_url" + + # Download touch_env.py + if command -v curl &> /dev/null 2>&1; then + curl -fsSL --connect-timeout 30 "$touch_env_download_url" -o "$touch_env_dest" + elif command -v wget &> /dev/null 2>&1; then + wget --timeout=30 -O "$touch_env_dest" "$touch_env_download_url" + else + log_error "touch_env_download_failed" "$touch_env_download_url" + return 1 + fi + + if [ ! -s "$touch_env_dest" ]; then + log_error "touch_env_download_failed" "$touch_env_download_url" + return 1 + fi + + log_success "touch_env_downloaded" +} + +run_touch_env() { + local touch_env_dest="$1" + + # Build Python command arguments for touch_env.py + local python_args=() + + if [ -n "$CONFIG_ENV_ROOT" ]; then + python_args+=("--env-root" "$CONFIG_ENV_ROOT") + fi + + if [ "$CONFIG_USE_CN" = "true" ]; then + python_args+=("--use-cn") + fi + + if [ "$CONFIG_LANG" = "en" ]; then + python_args+=("--language" "en") + elif [ "$CONFIG_LANG" = "zh" ]; then + python_args+=("--language" "zh") + fi + + if [ "$CONFIG_AUTO_MODE" = "true" ]; then + python_args+=("--auto-mode") + fi + + if [ -n "$CONFIG_KEEP_SDK" ]; then + python_args+=("--keep-sdk" "$CONFIG_KEEP_SDK") + fi + + # Custom repositories (pass full URL, touch_env.py parses branch if present) + if [ -n "$CONFIG_CUSTOM_PACKAGES_REPO" ]; then + python_args+=("--repo-packages" "$CONFIG_CUSTOM_PACKAGES_REPO") + fi + + if [ -n "$CONFIG_CUSTOM_ENV_REPO" ]; then + python_args+=("--repo-env" "$CONFIG_CUSTOM_ENV_REPO") + fi + + if [ -n "$CONFIG_CUSTOM_SDK_REPO" ]; then + python_args+=("--repo-sdk" "$CONFIG_CUSTOM_SDK_REPO") + fi + + log_info "start" + + # Execute touch_env.py as REAL_USER + if [ -n "$SUDO_USER" ]; then + # mktemp creates a 0600 root-owned file; grant read so the invoking user can run it, + # and pass the argument array without a shell re-parse (no word splitting) + chmod a+r "$touch_env_dest" + sudo -u "$REAL_USER" python3 "$touch_env_dest" "${python_args[@]}" + else + python3 "$touch_env_dest" "${python_args[@]}" + fi + + local result=$? + + if [ $result -ne 0 ]; then + log_error "touch_env_failed" "$result" + return 1 + fi + + return 0 +} + +# ============================================================================ +# Argument Parsing +# ============================================================================ + +print_help() { + echo "$(get_message 'banner_title')" + echo "" + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --yes, --auto Auto-install without prompts" + echo " --cn, --gitee Use China mirror (Gitee, PyPI TUNA)" + echo " --official Force use official source" + echo " --keep-sdk Keep toolchains (local_pkgs) and config when reinstalling (default: yes)" + echo " 重装时保留工具链(local_pkgs)与配置(默认:是)" + echo " --env-root Set custom install directory" + echo " --lang Force message language" + echo " 强制消息语言" + echo " --packages [#] Specify custom packages repository and branch" + echo " --env [#] Specify custom env repository and branch" + echo " --sdk [#] Specify custom sdk repository and branch" + echo " --touch-env Specify touch_env.py download URL" + echo " -h, --help Show this help message" + echo "" +} + +check_git() { + if ! command -v git &> /dev/null; then + log_error "git_not_found" + return 1 + fi + local git_version + git_version=$(git --version 2>&1 | grep -E 'git version' | awk '{print $3}') + log_info "git_found" "$git_version" + return 0 +} + +detect_china() { + # Check if user is in China (by IP or system locale) + # Only set CONFIG_USE_CN, don't override CONFIG_LANG (which may be set by --lang) + if [ "$CONFIG_USE_CN_SET" = "true" ]; then + return # User explicitly set mirror, skip detection + fi + + # Check IP-based detection (works on all systems) + if command -v curl &> /dev/null 2>&1; then + local ip_info=$(curl -s -m 5 --connect-timeout 3 "$IPINFO_URL" 2>&1) + if [[ "$ip_info" == *"\"country\":\"CN\""* ]]; then + CONFIG_USE_CN="true" + return + fi + fi + + # Fallback: check system timezone + local timezone=$(date +%Z 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null || echo "") + if [[ "$timezone" == *"CST"* ]] || [[ "$timezone" == *"Shanghai"* ]] || [[ "$timezone" == *"Beijing"* ]] || [[ "$timezone" == *"Asia/Shanghai"* ]]; then + CONFIG_USE_CN="true" + return + fi + + # Fallback: check system locale - only set CONFIG_USE_CN + case "${LC_ALL}:${LANG}" in + *zh*|*CN*) + CONFIG_USE_CN="true" + ;; + esac +} + +parse_args() { + # Local state variables for parse_args (not global config) + CONFIG_LANG_SET="false" + CONFIG_OFFICIAL_MODE="false" + CONFIG_CUSTOM_PACKAGES_BRANCH="" + CONFIG_CUSTOM_ENV_BRANCH="" + CONFIG_CUSTOM_SDK_BRANCH="" + + while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + CONFIG_HELP_MODE="true" + ;; + --yes|--auto) + CONFIG_AUTO_MODE="true" + ;; + --lang) + shift + CONFIG_LANG="$1" + CONFIG_LANG_SET="true" + ;; + --env-root) + shift + CONFIG_ENV_ROOT="$1" + ENV_ROOT="$1" + ;; + --packages) + shift + CONFIG_CUSTOM_PACKAGES_REPO="$1" + ;; + --env) + shift + CONFIG_CUSTOM_ENV_REPO="$1" + ;; + --sdk) + shift + CONFIG_CUSTOM_SDK_REPO="$1" + ;; + --cn|--gitee) + CONFIG_CN_MODE="true" + CONFIG_USE_CN_SET="true" + CONFIG_USE_CN="true" + CONFIG_LANG="zh" + ;; + --official) + CONFIG_OFFICIAL_MODE="true" + CONFIG_USE_CN_SET="true" + ;; + --keep-sdk) + shift + CONFIG_KEEP_SDK="$1" + ;; + --touch-env) + shift + CONFIG_TOUCH_ENV_URL_VALUE="$1" + ;; + *) + # Unknown argument, skip + ;; + esac + shift + done + + # IP detection (lower priority, only if not explicitly set) + if [ "$CONFIG_USE_CN_SET" = "false" ]; then + detect_china + fi + + # Override with --official flag + if [ "$CONFIG_OFFICIAL_MODE" = "true" ]; then + CONFIG_USE_CN="false" + fi + + # Set language based on CONFIG_USE_CN if not explicitly set + if [ "$CONFIG_LANG_SET" = "false" ]; then + if [ "$CONFIG_USE_CN" = "true" ]; then + CONFIG_LANG="zh" + else + CONFIG_LANG="en" + fi + fi +} + +# ============================================================================ +# System Detection +# ============================================================================ + +detect_os() { + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + echo "linux" + elif [[ "$OSTYPE" == "darwin"* ]]; then + echo "macos" + else + echo "unknown" + fi +} + +detect_linux_distro() { + if [ -f /etc/os-release ]; then + . /etc/os-release + echo "$ID" + elif [ -f /etc/redhat-release ]; then + echo "rhel" + else + echo "unknown" + fi +} + +# ============================================================================ +# Dependency Installation +# ============================================================================ + +install_dependencies_linux() { + local distro + local sudo_cmd="" + + if [ "$EUID" -ne 0 ]; then + sudo_cmd="sudo" + fi + + distro=$(detect_linux_distro) + + case "$distro" in + ubuntu|debian) + log_info "installing_ubuntu" + $sudo_cmd apt-get update -qq + $sudo_cmd apt-get install -y python3 python3-venv python3-pip git gcc libncurses-dev + ;; + suse|opensuse*) + log_info "installing_suse" + $sudo_cmd zypper install -y python3 python3-venv python3-pip git gcc ncurses-devel + ;; + arch|manjaro) + log_info "installing_arch" + $sudo_cmd pacman -S --noconfirm python python-pip git gcc ncurses + ;; + rhel|centos|fedora) + log_info "installing_fedora" + $sudo_cmd dnf install -y python3 python3-venv python3-pip git gcc ncurses-devel + ;; + alpine) + log_info "installing_alpine" + $sudo_cmd apk add --no-cache python3 py3-venv py3-pip git gcc ncurses-dev linux-headers musl-dev + ;; + *) + log_error "unsupported_os" "$distro" + log_info "missing_gcc" + exit 1 + ;; + esac +} + +install_dependencies_macos() { + log_info "installing_macos" + + # Install Homebrew if not installed + if ! command -v brew &> /dev/null; then + log_info "installing_homebrew" + /bin/bash -c "$(curl -fsSL "$HOMEBREW_INSTALL_URL")" + fi + + # Update Homebrew + brew update + + # Install dependencies + brew list python &> /dev/null || brew install python + brew list git &> /dev/null || brew install git + brew list ncurses &> /dev/null || brew install ncurses +} + +# ============================================================================ +# Python Environment Setup +# ============================================================================ + +check_python() { + # Check python3 + if ! command -v python3 &> /dev/null; then + log_error "missing_python" + return 1 + fi + + # Show Python version first + local version + version=$(python3 --version 2>&1) + version=$(echo "$version" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+') + log_info "python_version" "$version" + + # Check python3-venv + if ! python3 -c "import venv" 2>/dev/null; then + return 1 + fi + + # Check pip + if ! python3 -m pip --version &>/dev/null; then + return 1 + fi + + return 0 +} + +# ============================================================================ +# Banner and Next Steps +# ============================================================================ + +print_banner() { + echo "" + echo "============================================================" + echo " $(get_message 'banner_title') " + echo "============================================================" + echo "" +} + +# ============================================================================ +# Main Function +# ============================================================================ + +main() { + set -e # Exit on error + + # Initialize environment + init_environment + + # Parse command line arguments + parse_args "$@" + + # Print help if requested + if [ "$CONFIG_HELP_MODE" = "true" ]; then + print_help + exit 0 + fi + + # Print installation banner + print_banner + + # Log mirror selection result + if [ "$CONFIG_USE_CN" = "true" ]; then + log_info "using_cn_mirror" + else + log_info "using_official_source" + fi + + # Check dependencies (git, python), install if missing + if ! check_git || ! check_python; then + install_dependencies_linux + fi + + # Download and execute touch_env.py + download_and_run_touch_env +} + +# ============================================================================ +# Run Main Function +# ============================================================================ + +main "$@" diff --git a/tools/tests/README.md b/tools/tests/README.md new file mode 100644 index 00000000..af1599f6 --- /dev/null +++ b/tools/tests/README.md @@ -0,0 +1,215 @@ +# RT-Thread ENV 测试指南 + +本目录的测试覆盖安装器(`touch_env.py`)、编排脚本(`install.sh` / `install.ps1`)、激活器(`env.sh` / `env.ps1`)与 CLI(`rt-env`)。按平台分层组织: + +``` +tools/tests/ +├── common/ 跨平台测试(Python,任何平台可跑) +├── linux/ POSIX 侧(bash:install.sh 编排 + touch_env.py 真实安装) +└── windows/ Windows 侧(PowerShell:install.ps1 编排 + 真实安装) +``` + +> 所有测试均在**临时目录**进行,绝不触碰真实 `~/.rt-env`。 + +## 1. 快速开始 + +```bash +# Linux / macOS / Git Bash —— POSIX 侧 +bash tools/tests/linux/run_all.sh + +# Windows —— PowerShell 侧 +pwsh -NoProfile -File tools/tests/windows/run_all.ps1 + +# 全量真实安装(需网络:真依赖 + pyocd + 真运行工具) +RT_ENV_TEST_FULL=1 bash tools/tests/linux/run_all.sh +# 或: +$env:RT_ENV_TEST_FULL=1; pwsh -NoProfile -File tools/tests/windows/run_all.ps1 +``` + +逐个运行(等价于 run_all 的内容): + +```bash +bash tools/tests/linux/test_install_sh.sh # install.sh 编排层(stub) +python tools/tests/common/test_touch_env_args.py # touch_env.py 参数面 +python tools/tests/common/test_touch_env_behavior.py # touch_env.py 核心行为 +bash tools/tests/linux/test_touch_env_install.sh # touch_env.py 真实安装 E2E +pwsh -NoProfile -File tools/tests/windows/test_install_ps1.ps1 # install.ps1 编排层(stub) +pwsh -NoProfile -File tools/tests/windows/test_install_ps1_install.ps1 # install.ps1 真实安装 E2E +``` + +> 每个脚本输出 `PASS/FAIL/SKIP` 行;存在 `FAIL` 时退出码非 0。`SKIP` 表示前置条件缺失,不算失败。 + +## 2. 测试套件一览 + +| 脚本 | 被测对象 | 覆盖范围 | 平台 | +|---|---|---|---| +| `test_install_sh.sh` | install.sh | 语法、帮助断言、`mktemp` 可移植性、参数转发(stub)、临时文件清理、`--lang` 生效 | Linux / macOS / Git Bash | +| `test_touch_env_args.py` | touch_env.py | 参数面(`--help` + AST)、中英消息对称、i18n 孤儿键、常量 | 任意 | +| `test_touch_env_behavior.py` | touch_env.py | `--keep-sdk` 三态(含 P0 回归)、`show_next_steps` 输出、`parse_repo_url`、安全删除、消息查找 | 任意 | +| `test_touch_env_install.sh` | touch_env.py | **真实安装**:本地 bare 三源 → 克隆 → venv → editable | Linux / macOS / Git Bash | +| `test_install_ps1.ps1` | install.ps1 | parser、帮助断言、`--lang` 生效、清理接线 | Windows | +| `test_install_ps1_install.ps1` | install.ps1 | **真实安装**:本地 HTTP 服务 + 本地 bare 三源 → 完整编排链 | Windows | + +### 2.1 模式:离线 vs 全量 + +真实安装套件(`test_touch_env_install.sh`、`test_install_ps1_install.ps1`)支持两种模式: + +| 模式 | 依赖安装 | pyocd | 真运行工具 | 网络 | +|---|---|---|---|---| +| 默认(离线) | 跳过(`PIP_NO_DEPS=1`) | ✗ | ✗ | 不需要 | +| `RT_ENV_TEST_FULL=1` | ✅ 全装 | ✅ | ✅ `-v`/`--info`/`--help` | 需要 | + +```bash +bash tools/tests/linux/test_touch_env_install.sh # 离线 +RT_ENV_TEST_FULL=1 bash tools/tests/linux/test_touch_env_install.sh # 全量 +``` + +## 3. 真实安装测试(E2E,核心) + +两条 E2E 链:`install.sh`(POSIX)与 `install.ps1`(Windows)各有对应的**编排层 stub 测试**(参数转发)和**真实安装测试**(跑完整流程)。 + +### 3.1 `test_touch_env_install.sh`(touch_env.py 直接安装) + +```bash +bash tools/tests/linux/test_touch_env_install.sh +``` + +1. 临时目录建三个本地 bare 仓库(env 由本仓库 push;packages/sdk 空仓库,HEAD 指向 master) +2. `touch_env.py --repo-* --auto-mode --keep-sdk yes` +3. 断言:三仓库克隆、`venv/rt-env`、`rt-env` 入口、`importlib.metadata` 解析出 `rt-env 2.0.2` + +要点: +- `PIP_NO_DEPS=1` 保证离线;全量模式(`RT_ENV_TEST_FULL=1`)装真依赖 + pyocd 并真运行工具 +- 空仓库 `HEAD` 需 `git symbolic-ref HEAD refs/heads/master`,否则 `git clone` 得空工作树 + +### 3.2 `test_install_ps1_install.ps1`(install.ps1 完整编排链) + +```powershell +pwsh -NoProfile -File tools/tests/windows/test_install_ps1_install.ps1 +$env:RT_ENV_TEST_FULL=1; pwsh -NoProfile -File tools/tests/windows/test_install_ps1_install.ps1 +``` + +1. 本地 bare 三源 + 本地 HTTP 服务提供真实 `touch_env.py`(`Invoke-WebRequest` 不支持 `file://`) +2. `install.ps1 --touch-env --env-root --yes --keep-sdk yes --env/--packages/--sdk ` +3. 断言:退出码 0、三仓库克隆、`venv/rt-env`、`rt-env.exe`、editable 元数据 + +要点: +- **参数名**:install.ps1 收 `--env/--packages/--sdk`(转发给 touch_env.py 时才变 `--repo-*`);传错名会被静默忽略并回落到默认镜像源 +- **提权**:仅当执行策略或长路径需修改时才要求管理员。本机两者已满足,免提权。需提权时: + ```powershell + sudo pwsh -NoProfile -File tools/tests/windows/test_install_ps1_install.ps1 + ``` + +### 3.3 真实 GitHub 源安装(手工,需网络) + +测试套件用本地 bare 源保证离线可控;下列命令**直接使用 GitHub 真实仓库**做一次完整真实安装(隔离 `ENV_ROOT`,不触碰 `~/.rt-env`),用于验证真实下载路径。示例使用自定义 fork(`dongly/env` 的 `install-unified` 分支);把仓库地址换成 `RT-Thread/env` 的 `master` 即为官方源安装。 + +Linux / macOS / Git Bash: + +```bash +repo=https://raw.githubusercontent.com/dongly/env/refs/heads/install # raw 内容 +git=https://github.com/dongly/env.git#install # git 克隆 +bash -c "$(wget -qO- $repo/tools/install.sh)" -- \ + --touch-env "$repo/tools/touch_env.py" \ + --env "$git" \ + --env-root /tmp/rt-env-test --yes +``` + +Windows PowerShell: + +```powershell +$repo = 'https://raw.githubusercontent.com/dongly/env/refs/heads/install' # raw 内容 +$git = 'https://github.com/dongly/env.git#install' # git 克隆 +irm "$repo/tools/install.ps1" -OutFile install.ps1; ` + .\install.ps1 --touch-env "$repo/tools/touch_env.py" ` + --env "$git" ` + --env-root d:\rt-env-test; Remove-Item install.ps1 +``` + +要点: +- `$repo`(raw)供下载脚本文件:`install.sh/ps1` 与 `touch_env.py`;`$git`(clone)供 `--env` 克隆仓库——两种地址形态不同,不能混用 +- 三个仓库源均可省略:不传 `--env/--packages/--sdk` 时按网络区域自动选择 GitHub/Gitee 官方源 +- 完整网络安装(含全部依赖与 pyocd 下载),耗时数分钟;国内网络可加 `--cn` 走 Gitee 镜像 +- 装完验证后删除隔离目录:`rm -rf /tmp/rt-env-github-test`(或 `Remove-Item -Recurse -Force $env:TEMP\rt-env-github-test`) +- 切换官方源:`repo=.../RT-Thread/env/refs/heads/master`、`git=https://github.com/RT-Thread/env.git` + +## 4. 手工测试(辅助验证) + +自动化套件覆盖了绝大多数断言,下列步骤用于**需要人工观察行为**的场景。 + +### 4.1 安装器交互(touch_env.py) + +```bash +python tools/touch_env.py --env-root <临时目录> --language zh +``` + +- 首次安装:直接安装,无询问 +- 重装:出现「保留已下载的工具链(local_pkgs)与配置?[Y/n]」——回车/Y 保留 `local_pkgs/` 与 `cmds/.config` 并清理重装;n 整个目录删除 +- `--keep-sdk yes|no` 参数化验证见 behavior 套件(已自动覆盖) + +### 4.2 激活器(env.sh / env.ps1) + +```bash +bash -n env.sh && bash --posix -n env.sh # 语法 + POSIX 兼容 +source ./env.sh # venv 缺失时:报错 + return 1 +``` + +PowerShell 隔离布局(junction)验证 `rt-env --info` 横幅与"not found"错误路径。 + +### 4.3 CLI(rt-env) + +```bash +rt-env -v # 单行版本号 "RT-Thread Env Tool v2.0.2" +rt-env --info # 环境信息横幅 +rt-env --help # usage 显示 "rt-env",含 plugin/webui 子命令 +rt-env --info menuconfig # 横幅后退出(短路) +``` + +### 4.4 打包(wheel) + +```bash +python -m pip wheel --no-deps -w . +# 一次性 venv 安装:验证 6 个入口 + webui 静态资源 + metadata +``` + +### 4.5 编排脚本参数转发(stub) + +用 stub 替换 `touch_env.py`,验证参数透传: + +```bash +cat > /tmp/stub_touch_env.py <<'EOF' +import sys +print("ARGS: " + " ".join(sys.argv[1:])) +EOF + +bash tools/install.sh --touch-env "file:///tmp/stub_touch_env.py" \ + --env-root /tmp/rt-env-test --cn --yes --keep-sdk no \ + --packages "https://example.com/p.git#dev" +# 预期 ARGS: --env-root ... --use-cn --language zh --auto-mode --keep-sdk no --repo-packages ... +``` + +- `--cn` 同时设语言为 `zh` +- 长参数原样转发;URL 片段保持完整(数组传递无拆词) + +> Windows Git Bash:`python3` 常是商店占位符(`--version` 无输出),需包装脚本 shim;原生 `curl` 的 `file://` 用 Windows 路径(`file:///C:/...`)。 + +### 4.6 临时文件清理 + +- `install.sh`:随机名 + `trap cleanup EXIT INT TERM`,退出即删(含 Ctrl-C) +- `install.ps1`:`touch_env_.py` + `try/finally` 立即删 + 退出事件兜底 +- 回归验证:运行前后 `ls /tmp/tmp.*` 差集为空 / `%TEMP%` 无 `touch_env_*.py` 残留 + +## 5. 约定与注意 + +- **行尾**(`.gitattributes`):`*.sh` 用 LF;`*.ps1` 用 CRLF + UTF-8 BOM(PowerShell 5.1 中文输出需要);其余文本默认 LF +- **负向验证**:注入已知缺陷(`mktemp --suffix`、`--keep-sdk no` 字符串真值、损坏的 `VENV_DIR_RELATIVE`)确认套件变红,恢复后全绿——证明测试有效而非假绿 +- **macOS**:`mktemp --suffix` 是 GNU 扩展、macOS 不支持——安装器已用裸 `mktemp`,回归测试必须跑 +- **安全**:安装器下载并执行远程 `touch_env.py`,无完整性校验。`--touch-env` 可指向任意 URL——仅使用可信来源 + +## 6. 验证后清理 + +```bash +Remove-Item -Recurse -Force +``` + +勿遗留:临时 venv、junction、bare 仓库、wheel 输出、`%TEMP%`/`/tmp` 下的 stub 与 shim。 diff --git a/tools/tests/common/test_touch_env_args.py b/tools/tests/common/test_touch_env_args.py new file mode 100644 index 00000000..2275fa1e --- /dev/null +++ b/tools/tests/common/test_touch_env_args.py @@ -0,0 +1,179 @@ +"""Tests for tools/touch_env.py command-line surface. + +Covers the parameter surface and i18n table described in tools/tests/README.md +section 1. Static analysis only (AST) plus one ``--help`` subprocess call; +no installer side effects. + +Run: python tools/tests/common/test_touch_env_args.py +""" + +import ast +import re +import subprocess +import sys +import unittest +from pathlib import Path + +TOOLS_DIR = Path(__file__).resolve().parents[3] / "tools" +TOUCH_ENV = TOOLS_DIR / "touch_env.py" + +_removed_options = ("--install-pyocd", "--backup", "--restore-config") +_expected_options = ( + "--env-root", + "--use-cn", + "--language", + "--auto-mode", + "--keep-sdk", + "--repo-env", + "--repo-packages", + "--repo-sdk", +) + + +def _module_ast(): + return ast.parse(TOUCH_ENV.read_text(encoding="utf-8")) + + +def _messages_dicts(): + """Return (en_keys, zh_keys) from the MESSAGES literal.""" + for node in ast.walk(_module_ast()): + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "MESSAGES" for t in node.targets + ): + # node.value is Dict{ 'en': Dict{...}, 'zh': Dict{...} } + tables = node.value.values + if len(tables) != 2: + raise AssertionError("MESSAGES must hold exactly en and zh tables") + en, zh = tables + return ( + {k.value for k in en.keys}, + {k.value for k in zh.keys}, + ) + raise AssertionError("MESSAGES literal not found") + + +def _find_add_argument(keyword): + """Yield the add_argument call whose first arg contains ``keyword``.""" + for node in ast.walk(_module_ast()): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)): + continue + if node.func.attr != "add_argument" or not node.args: + continue + first = node.args[0] + if isinstance(first, ast.Constant) and first.value == keyword: + yield node + + +class HelpOutputTest(unittest.TestCase): + """Section 1.1: parameter surface as seen by --help.""" + + @classmethod + def setUpClass(cls): + result = subprocess.run( + [sys.executable, str(TOUCH_ENV), "--help"], + capture_output=True, + text=True, + ) + cls.help = result.stdout + result.stderr + + def test_keep_sdk_listed(self): + self.assertIn("--keep-sdk", self.help) + + def test_expected_options_listed(self): + for option in _expected_options: + with self.subTest(option=option): + self.assertIn(option, self.help) + + def test_removed_options_absent(self): + for option in _removed_options: + with self.subTest(option=option): + self.assertNotIn(option, self.help) + + def test_no_single_letter_short_options(self): + # argparse renders defined short options as "-x, --long"; none expected here. + short_options = re.findall(r"(?m)^\s+(-[a-zA-Z]),\s+--", self.help) + unexpected = [opt for opt in short_options if opt != "-h"] + self.assertEqual(unexpected, [], "unexpected short options defined") + + +class ArgumentDefinitionTest(unittest.TestCase): + """Static checks on argparse definitions.""" + + def test_keep_sdk_default_is_none(self): + calls = list(_find_add_argument("--keep-sdk")) + self.assertEqual(len(calls), 1, "--keep-sdk must be defined once") + keywords = {kw.arg: kw.value for kw in calls[0].keywords} + self.assertIn("default", keywords) + self.assertIsInstance(keywords["default"], ast.Constant) + self.assertIsNone(keywords["default"].value) + + def test_keep_sdk_choices(self): + calls = list(_find_add_argument("--keep-sdk")) + keywords = {kw.arg: kw.value for kw in calls[0].keywords} + choices = {e.value for e in keywords["choices"].elts} + self.assertEqual(choices, {"yes", "no"}) + + def test_removed_options_not_defined(self): + for option in _removed_options: + with self.subTest(option=option): + self.assertEqual(list(_find_add_argument(option)), []) + + +class MessagesTest(unittest.TestCase): + """Section 1.2: the en/zh message tables must stay symmetric.""" + + def test_en_zh_keys_symmetric(self): + en_keys, zh_keys = _messages_dicts() + self.assertEqual( + en_keys - zh_keys, set(), "keys present in en but missing in zh" + ) + self.assertEqual( + zh_keys - en_keys, set(), "keys present in zh but missing in en" + ) + + def test_toolchain_keys_present(self): + en_keys, _ = _messages_dicts() + for key in ("toolchain_keep_prompt", "toolchain_kept", "toolchain_removed"): + with self.subTest(key=key): + self.assertIn(key, en_keys) + + def test_plugin_and_webui_messages_present(self): + en_keys, zh_keys = _messages_dicts() + for table in (en_keys, zh_keys): + for key in ("plugin", "webui"): + with self.subTest(key=key): + self.assertIn(key, table) + + +class MessageUsageTest(unittest.TestCase): + """G5: every defined i18n key must have at least one call site.""" + + def test_no_orphan_message_keys(self): + source = TOUCH_ENV.read_text(encoding="utf-8") + match = re.search(r"MESSAGES = \{\s*'en': \{(.*?)\n \},\s*'zh':", source, re.S) + self.assertIsNotNone(match, "MESSAGES literal not found") + defined = set(re.findall(r"'([a-z_0-9]+)':", match.group(1))) + called = set( + re.findall( + r"(?:get_message|log_info|log_success|log_error|log_warning|log_raw)\s*\(\s*'([a-z_0-9]+)'", + source, + ) + ) + orphans = sorted(defined - called) + self.assertEqual(orphans, [], "orphan message keys (defined but never used)") + + +class ConstantsTest(unittest.TestCase): + """Sanity checks on module constants.""" + + def test_default_env_root_is_rt_env(self): + source = TOUCH_ENV.read_text(encoding="utf-8") + self.assertIn('DEFAULT_ENV_ROOT = "~/.rt-env"', source) + + def test_venv_layout_relative(self): + source = TOUCH_ENV.read_text(encoding="utf-8") + self.assertIn('VENV_DIR_RELATIVE = "venv/rt-env"', source) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/tests/common/test_touch_env_behavior.py b/tools/tests/common/test_touch_env_behavior.py new file mode 100644 index 00000000..b586084c --- /dev/null +++ b/tools/tests/common/test_touch_env_behavior.py @@ -0,0 +1,417 @@ +"""Behavior tests for tools/touch_env.py (no installer side effects). + +Covers tools/tests/README.md section 1.2/1.3 (keep-sdk decision) and the pure +helpers (parse_repo_url, safe removal, message lookup). These tests exist +because a P0 bug ("--keep-sdk no" treated as truthy string) slipped +through when only the parameter surface was tested. + +Run: python tools/tests/common/test_touch_env_behavior.py +""" + +import importlib.util +import io +import json +import os +import shutil +import sys +import tempfile +import types +import unittest +from unittest import mock +from contextlib import redirect_stdout +from pathlib import Path + +TOOLS_DIR = Path(__file__).resolve().parents[3] / "tools" +TOUCH_ENV = TOOLS_DIR / "touch_env.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("touch_env_under_test", TOUCH_ENV) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _make_layout(root): + """Seed a fake installation: local_pkgs marker, cmds/.config, venv tree.""" + os.makedirs(os.path.join(root, "local_pkgs"), exist_ok=True) + marker = os.path.join(root, "local_pkgs", "tool.marker") + with open(marker, "w", encoding="utf-8") as f: + f.write("keep-me") + os.makedirs(os.path.join(root, "tools", "scripts", "cmds"), exist_ok=True) + config = os.path.join(root, "tools", "scripts", "cmds", ".config") + with open(config, "w", encoding="utf-8") as f: + f.write("CFG") + os.makedirs(os.path.join(root, "venv", "rt-env", "Scripts"), exist_ok=True) + os.makedirs(os.path.join(root, "packages", "packages"), exist_ok=True) + return marker, config + + +def _config(root, keep_sdk, auto_mode=True): + return types.SimpleNamespace( + env_root=root, keep_sdk=keep_sdk, auto_mode=auto_mode + ) + + +class CheckExistingEnvTest(unittest.TestCase): + """Section 1.3: the --keep-sdk decision (P0 regression).""" + + def setUp(self): + self.module = _load_module() + self.root = tempfile.mkdtemp(prefix="rt-env-keep-") + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + + def _run(self, keep, auto=True): + with redirect_stdout(io.StringIO()): + self.module.check_existing_env(_config(self.root, keep, auto)) + + def test_keep_yes_preserves_local_pkgs_and_config(self): + _make_layout(self.root) + self._run("yes") + self.assertTrue( + os.path.isfile(os.path.join(self.root, "local_pkgs", "tool.marker")) + ) + self.assertTrue( + os.path.isfile(os.path.join(self.root, "tools", "scripts", "cmds", ".config")) + ) + + def test_keep_yes_rebuilds_venv_and_repos(self): + _make_layout(self.root) + self._run("yes") + self.assertFalse(os.path.exists(os.path.join(self.root, "venv"))) + self.assertFalse(os.path.exists(os.path.join(self.root, ".venv"))) + self.assertFalse(os.path.exists(os.path.join(self.root, "packages"))) + + def test_keep_no_wipes_entire_root(self): + # P0 regression: "no" is a truthy string and must still mean "wipe". + _make_layout(self.root) + self._run("no") + self.assertFalse(os.path.exists(self.root)) + + def test_unspecified_auto_mode_defaults_to_keep(self): + _make_layout(self.root) + self._run(None, auto=True) + self.assertTrue( + os.path.isfile(os.path.join(self.root, "local_pkgs", "tool.marker")) + ) + + def test_missing_root_is_a_no_op(self): + # A root that does not exist must simply return. + missing = os.path.join(self.root, "does-not-exist") + with redirect_stdout(io.StringIO()): + self.module.check_existing_env(_config(missing, "no", True)) + + +class ParseRepoUrlTest(unittest.TestCase): + """Branch-fragment parsing used by --repo-* options.""" + + @classmethod + def setUpClass(cls): + cls.module = _load_module() + + def test_fragment_is_split_off(self): + parsed = self.module.parse_repo_url("https://github.com/u/env.git#dev") + self.assertEqual(parsed, {"url": "https://github.com/u/env.git", "branch": "dev"}) + + def test_plain_url_has_no_branch_key(self): + parsed = self.module.parse_repo_url("https://example.com/r.git") + self.assertEqual(parsed, {"url": "https://example.com/r.git"}) + self.assertNotIn("branch", parsed) + + +class SafeRemoveTest(unittest.TestCase): + """_safe_remove / _safe_remove_tree file-and-directory helpers.""" + + @classmethod + def setUpClass(cls): + cls.module = _load_module() + + def setUp(self): + self.root = tempfile.mkdtemp(prefix="rt-env-rm-") + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + + def test_safe_remove_file(self): + target = os.path.join(self.root, "f.txt") + with open(target, "w", encoding="utf-8") as f: + f.write("x") + with redirect_stdout(io.StringIO()): + self.assertTrue(self.module._safe_remove(target, "f.txt")) + self.assertFalse(os.path.exists(target)) + + def test_safe_remove_directory(self): + target = os.path.join(self.root, "d") + os.makedirs(target) + with redirect_stdout(io.StringIO()): + self.assertTrue(self.module._safe_remove(target, "d")) + self.assertFalse(os.path.exists(target)) + + def test_safe_remove_missing_path_is_idempotent(self): + # Absent paths are treated as already deleted (returns True, no error). + with redirect_stdout(io.StringIO()): + self.assertTrue(self.module._safe_remove(os.path.join(self.root, "nope"), "nope")) + + def test_safe_remove_tree_directory(self): + target = os.path.join(self.root, "tree", "nested") + os.makedirs(target) + marker = os.path.join(target, "m.txt") + with open(marker, "w", encoding="utf-8") as f: + f.write("x") + self.module._safe_remove_tree(os.path.join(self.root, "tree")) + self.assertFalse(os.path.exists(os.path.join(self.root, "tree"))) + + +class GetMessageTest(unittest.TestCase): + """i18n lookup falls back to the key itself for unknown keys.""" + + @classmethod + def setUpClass(cls): + cls.module = _load_module() + + def test_known_key_returns_text(self): + self.assertEqual( + self.module.get_message("toolchain_kept"), + "Keeping toolchains (local_pkgs) and config", + ) + + def test_unknown_key_returns_the_key(self): + self.assertEqual(self.module.get_message("definitely_not_a_key"), "definitely_not_a_key") + + def test_set_language_switches_table(self): + self.module.set_language("zh") + try: + self.assertEqual( + self.module.get_message("toolchain_kept"), + "保留工具链(local_pkgs)与配置", + ) + finally: + self.module.set_language("en") + + +class ShowNextStepsTest(unittest.TestCase): + """G4: show_next_steps prints the full command list incl. plugin/webui.""" + + @classmethod + def setUpClass(cls): + cls.module = _load_module() + + def test_output_lists_all_commands_in_order(self): + config = types.SimpleNamespace(env_root="dummy", auto_mode=True) + with redirect_stdout(io.StringIO()) as buf: + self.module.show_next_steps(config) + out = buf.getvalue() + # Ordered appearance of every command line + cursor = 0 + for cmd in ("menuconfig", "pkgs", "scons", "sdk", "plugin", "webui"): + pos = out.find(f"- {cmd}") + self.assertGreater(pos, cursor, f"expected '- {cmd}' after previous entries") + cursor = pos + self.assertIn("Install toolchains", out) + + +class LoadRepoDefaultsTest(unittest.TestCase): + """Default packages/sdk sources come from the downloaded env's env.json.""" + + @classmethod + def setUpClass(cls): + cls.module = _load_module() + + def setUp(self): + self.root = tempfile.mkdtemp(prefix="rt-env-defs-") + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + + def _write_env_json(self, payload): + scripts = os.path.join(self.root, "tools", "scripts") + os.makedirs(scripts, exist_ok=True) + path = os.path.join(scripts, "env.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(payload, f) + + def _defaults(self): + config = types.SimpleNamespace(env_root=self.root, use_cn=False) + with redirect_stdout(io.StringIO()): + return self.module.load_repo_defaults(config) + + def test_urls_and_branches_from_env_json(self): + self._write_env_json({ + "repositories": { + "packages": { + "url": "https://fork.example/packages.git", + "branch": "dev", + "mirror": { + "url": "https://mirror.example/packages.git", + "branch": "dev-cn", + }, + }, + }, + }) + defaults = self._defaults() + self.assertEqual(defaults["packages"]["url"], "https://fork.example/packages.git") + self.assertEqual(defaults["packages"]["branch"], "dev") + self.assertEqual(defaults["packages"]["mirror_url"], "https://mirror.example/packages.git") + self.assertEqual(defaults["packages"]["mirror_branch"], "dev-cn") + + def test_missing_env_json_falls_back_to_constants(self): + defaults = self._defaults() + self.assertEqual(defaults["packages"]["url"], self.module.REPO_PACKAGES_GITHUB) + self.assertEqual(defaults["sdk"]["url"], self.module.REPO_SDK_GITHUB) + self.assertEqual(defaults["packages"]["branch"], "") + self.assertEqual(defaults["packages"]["mirror_url"], self.module.REPO_PACKAGES_GITEE) + + def test_malformed_env_json_falls_back_to_constants(self): + scripts = os.path.join(self.root, "tools", "scripts") + os.makedirs(scripts, exist_ok=True) + with open(os.path.join(scripts, "env.json"), "w", encoding="utf-8") as f: + f.write("{not json") + defaults = self._defaults() + self.assertEqual(defaults["sdk"]["url"], self.module.REPO_SDK_GITHUB) + + def test_partial_env_json_only_overrides_listed_repos(self): + self._write_env_json({ + "repositories": { + "packages": {"url": "https://fork.example/packages.git"}, + }, + }) + defaults = self._defaults() + self.assertEqual(defaults["packages"]["url"], "https://fork.example/packages.git") + self.assertEqual(defaults["sdk"]["url"], self.module.REPO_SDK_GITHUB) + # mirror untouched when env.json carries none for that repo + self.assertEqual(defaults["packages"]["mirror_url"], self.module.REPO_PACKAGES_GITEE) + + def test_mirror_without_branch_inherits_primary_branch(self): + self._write_env_json({ + "repositories": { + "sdk": { + "url": "https://fork.example/sdk.git", + "branch": "lts-3.2", + "mirror": {"url": "https://mirror.example/sdk.git"}, + }, + }, + }) + defaults = self._defaults() + self.assertEqual(defaults["sdk"]["mirror_branch"], "lts-3.2") + + +class SetupRepositoriesOrderTest(unittest.TestCase): + """env is cloned before packages/sdk so env.json can drive their defaults.""" + + def setUp(self): + self.module = _load_module() + self.root = tempfile.mkdtemp(prefix="rt-env-order-") + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.calls = [] + self._orig_clone = self.module.clone_repository + self.module.clone_repository = self._fake_clone + self.addCleanup(setattr, self.module, "clone_repository", self._orig_clone) + + def _fake_clone(self, config, repo_name, url, dest_rel, branch="", depth=1): + self.calls.append((repo_name, url, branch)) + if repo_name == "env": + # emulate: env.json only exists once env has been cloned + scripts = os.path.join(self.root, "tools", "scripts") + os.makedirs(scripts, exist_ok=True) + with open(os.path.join(scripts, "env.json"), "w", encoding="utf-8") as f: + json.dump({ + "repositories": { + "packages": { + "url": "https://fork.example/packages.git", + "branch": "dev", + }, + }, + }, f) + + def _run(self, use_cn=False): + config = types.SimpleNamespace( + env_root=self.root, use_cn=use_cn, custom_repos={}) + with redirect_stdout(io.StringIO()): + self.module.setup_repositories(config) + return self.calls + + def test_env_cloned_first(self): + calls = self._run() + self.assertEqual([name for name, _, _ in calls], ["env", "packages", "sdk"]) + + def test_packages_default_from_downloaded_env_json(self): + calls = self._run() + by_name = dict((name, (url, branch)) for name, url, branch in calls) + self.assertEqual(by_name["packages"], ("https://fork.example/packages.git", "dev")) + # sdk not listed in env.json -> built-in constant, no branch pin + self.assertEqual(by_name["sdk"], (self.module.REPO_SDK_GITHUB, "")) + + def test_cn_mirror_selected_from_env_json_fallback(self): + calls = self._run(use_cn=True) + by_name = dict((name, (url, branch)) for name, url, branch in calls) + # packages has no mirror in the seeded env.json -> gitee constant keeps + # its own default branch (a fork's primary branch must not leak into + # the built-in official mirror) + self.assertEqual(by_name["packages"], (self.module.REPO_PACKAGES_GITEE, "")) + self.assertEqual(by_name["sdk"], (self.module.REPO_SDK_GITEE, "")) + + def test_env_repo_uses_builtin_bootstrap_url(self): + calls = self._run() + env_url = calls[0][1] + self.assertEqual(env_url, self.module.REPO_ENV_GITHUB) + + def test_custom_repos_still_win(self): + config = types.SimpleNamespace( + env_root=self.root, + use_cn=False, + custom_repos={"packages": {"url": "https://custom.example/p.git", "branch": "x"}}, + ) + with redirect_stdout(io.StringIO()): + self.module.setup_repositories(config) + by_name = dict((name, (url, branch)) for name, url, branch in self.calls) + self.assertEqual(by_name["packages"], ("https://custom.example/p.git", "x")) + + +class CopyEnvScriptsTest(unittest.TestCase): + """Root activator generation: delegator vs legacy copy, user config.""" + + def setUp(self): + self.module = _load_module() + self.root = tempfile.mkdtemp(prefix="rt-env-activator-") + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.scripts = os.path.join(self.root, "tools", "scripts") + os.makedirs(self.scripts, exist_ok=True) + + def _write_inner(self, content): + with open(os.path.join(self.scripts, "env.sh"), "w", encoding="utf-8") as f: + f.write(content) + + def _run(self): + with mock.patch.object(self.module.platform, "system", return_value="Linux"): + with redirect_stdout(io.StringIO()): + self.module.copy_env_scripts(types.SimpleNamespace(env_root=self.root)) + + def test_new_inner_produces_thin_delegator(self): + self._write_inner('if [ -n "$RT_ENV_ROOT" ]; then\nfi\n') + self._run() + with open(os.path.join(self.root, "env.sh"), encoding="utf-8") as f: + content = f.read() + self.assertIn("RT_ENV_ROOT='%s'" % self.root, content) + self.assertIn(". '%s'" % os.path.join(self.scripts, "env.sh"), content) + + def test_legacy_inner_is_copied_verbatim(self): + self._write_inner("SCRIPT_DIR=legacy\n") + self._run() + with open(os.path.join(self.root, "env.sh"), encoding="utf-8") as f: + self.assertEqual(f.read(), "SCRIPT_DIR=legacy\n") + + def test_user_config_seeded_once_and_never_overwritten(self): + self._write_inner("legacy\n") + user = os.path.join(self.root, "env.user.sh") + self._run() + self.assertTrue(os.path.isfile(user)) + with open(user, "w", encoding="utf-8") as f: + f.write("# mine\n") + self._run() + with open(user, encoding="utf-8") as f: + self.assertEqual(f.read(), "# mine\n") + + def test_missing_inner_is_a_no_op(self): + self._run() + self.assertFalse(os.path.exists(os.path.join(self.root, "env.sh"))) + self.assertFalse(os.path.exists(os.path.join(self.root, "env.user.sh"))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/tests/linux/run_all.sh b/tools/tests/linux/run_all.sh new file mode 100644 index 00000000..6d92db97 --- /dev/null +++ b/tools/tests/linux/run_all.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Run the POSIX-side offline test suites (install.sh + touch_env.py). +# Windows-side suites live in windows/run_all.ps1. +# Usage: bash tools/tests/linux/run_all.sh +# RT_ENV_TEST_FULL=1 bash tools/tests/linux/run_all.sh # full real install (network) + +set -u + +cd "$(dirname "$0")/../../.." || exit 1 + +FAILED=0 + +run() { + printf '\n########## %s ##########\n' "$1" + if bash -c "$2"; then + printf '########## %s: OK ##########\n' "$1" + else + printf '########## %s: FAILED ##########\n' "$1" + FAILED=$((FAILED + 1)) + fi +} + +run 'install.sh (stub)' 'bash tools/tests/linux/test_install_sh.sh' +run 'touch_env.py args' 'python tools/tests/common/test_touch_env_args.py' +run 'touch_env.py behavior' 'python tools/tests/common/test_touch_env_behavior.py' +run 'touch_env.py real install' 'bash tools/tests/linux/test_touch_env_install.sh' + +printf '\n===== SUMMARY =====\n' +if [ "$FAILED" -eq 0 ]; then + echo 'ALL POSIX SUITES PASSED' +else + echo "$FAILED POSIX suite(s) FAILED" + exit 1 +fi diff --git a/tools/tests/linux/test_install_sh.sh b/tools/tests/linux/test_install_sh.sh new file mode 100644 index 00000000..32ef322f --- /dev/null +++ b/tools/tests/linux/test_install_sh.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# Tests for tools/install.sh (orchestrator script). +# +# Usage: bash tools/tests/linux/test_install_sh.sh +# Needs: bash; python3 (a wrapper is synthesized when only `python` works, +# e.g. the Microsoft Store python3 stub on Windows); curl or wget. +# +# Covers tools/tests/README.md section 4.5 (install.sh side). + +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TOOLS_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +INSTALL_SH="$TOOLS_DIR/tools/install.sh" + +PASS=0 +FAIL=0 +SKIP=0 +WORK="$(mktemp -d 2>/dev/null || mktemp -d -t rt-env-tests)" + +pass() { printf ' PASS: %s\n' "$1"; PASS=$((PASS + 1)); } +fail() { printf ' FAIL: %s\n' "$1"; FAIL=$((FAIL + 1)); } +skip() { printf ' SKIP: %s\n' "$1"; SKIP=$((SKIP + 1)); } +section() { printf '\n== %s ==\n' "$1"; } + +cleanup() { + rm -rf "$WORK" +} +trap cleanup EXIT INT TERM + +# Resolve a usable python3 command line; synthesize a wrapper if needed. +detect_python3() { + if command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; then + printf 'python3' + return 0 + fi + local py + py="$(command -v python 2>/dev/null || true)" + if [ -n "$py" ] && "$py" --version >/dev/null 2>&1; then + mkdir -p "$WORK/bin" + printf '#!/bin/sh\nexec "%s" "$@"\n' "$py" > "$WORK/bin/python3" + chmod +x "$WORK/bin/python3" + printf '%s' "$WORK/bin/python3" + return 0 + fi + return 1 +} + +# Convert a POSIX path into a file:// URL usable by the platform's curl. +file_url() { + case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + local win + win="$(cygpath -m "$1" 2>/dev/null || printf '%s' "$1")" + printf 'file:///%s' "$win" + ;; + *) + printf 'file://%s' "$1" + ;; + esac +} + +# --------------------------------------------------------------------------- +section '1. syntax (7.1)' +# --------------------------------------------------------------------------- +if bash -n "$INSTALL_SH" 2>/dev/null; then + pass 'bash -n' +else + fail 'bash -n' +fi +if bash --posix -n "$INSTALL_SH" 2>/dev/null; then + pass 'bash --posix -n' +else + fail 'bash --posix -n' +fi + +# --------------------------------------------------------------------------- +section '2. --help assertions (7.1)' +# --------------------------------------------------------------------------- +HELP="$(bash "$INSTALL_SH" --help 2>&1 || true)" + +if printf '%s' "$HELP" | grep -q -- '--keep-sdk'; then + pass 'help lists --keep-sdk' +else + fail 'help lists --keep-sdk' +fi + +if printf '%s' "$HELP" | grep -qE -- '--english|--chinese'; then + fail 'help has no removed --english/--chinese' +else + pass 'help has no removed --english/--chinese' +fi + +if printf '%s' "$HELP" | grep -qE -- '--pyocd|--backup'; then + fail 'help has no removed --pyocd/--backup' +else + pass 'help has no removed --pyocd/--backup' +fi + +# Any single-letter option other than -h is unexpected. +SHORTS="$(printf '%s' "$HELP" | grep -oE '(^|[[:space:]])-[a-zA-Z]' | tr -d '[:space:]' | grep -v -- '-h' || true)" +if [ -n "$SHORTS" ]; then + fail "no single-letter short options (found: $SHORTS)" +else + pass 'no single-letter short options' +fi + +# --lang must actually switch the banner language (help body is English-only in install.sh). +if bash "$INSTALL_SH" --lang en --help 2>&1 | head -n 1 | grep -q 'Installation'; then + pass '--lang en switches banner to English' +else + fail '--lang en switches banner to English' +fi +if bash "$INSTALL_SH" --lang zh --help 2>&1 | head -n 1 | grep -q '安装程序'; then + pass '--lang zh switches banner to Chinese' +else + fail '--lang zh switches banner to Chinese' +fi + +# --------------------------------------------------------------------------- +section '3. mktemp portability (7.4 regression)' +# --------------------------------------------------------------------------- +if grep -q 'mktemp --suffix' "$INSTALL_SH"; then + fail 'no GNU-only mktemp --suffix (breaks macOS)' +else + pass 'no GNU-only mktemp --suffix (macOS-portable)' +fi + +# --------------------------------------------------------------------------- +section '4. argument pass-through (7.2)' +# --------------------------------------------------------------------------- +PY3="$(detect_python3 || true)" +if [ -z "$PY3" ]; then + skip 'argument pass-through (no usable python3/python)' +else + STUB="$WORK/stub_touch_env.py" + cat > "$STUB" <<'EOF' +import sys +print("ARGS: " + " ".join(sys.argv[1:])) +EOF + + GOT="$( + PATH="$(dirname "$PY3"):$PATH" bash "$INSTALL_SH" \ + --touch-env "$(file_url "$STUB")" \ + --env-root "$WORK/rt-env-test" \ + --cn --yes --keep-sdk no \ + --packages 'https://example.com/p.git#dev' 2>&1 || true + )" + RELEVANT="$(printf '%s' "$GOT" | grep -a 'ARGS:' | head -n 1 || true)" + + if printf '%s' "$RELEVANT" | grep -q -- '--use-cn' && + printf '%s' "$RELEVANT" | grep -q -- '--language zh' && + printf '%s' "$RELEVANT" | grep -q -- '--auto-mode' && + printf '%s' "$RELEVANT" | grep -q -- '--keep-sdk no' && + printf '%s' "$RELEVANT" | grep -q -- '--repo-packages https://example.com/p.git#dev'; then + pass 'long options forwarded verbatim (incl. URL fragment)' + else + fail "argument pass-through (got: ${RELEVANT:-})" + fi + + # G3: --official / --env / --sdk / --lang forwarding (previously untested) + RELEVANT2="$( + PATH="$(dirname "$PY3"):$PATH" bash "$INSTALL_SH" \ + --touch-env "$(file_url "$STUB")" \ + --env-root "$WORK/rt-env-test" \ + --official --yes --lang en \ + --env 'https://github.com/x/env.git#b1' \ + --sdk 'https://github.com/y/sdk.git' 2>&1 || true + )" + RELEVANT2="$(printf '%s' "$RELEVANT2" | grep -a 'ARGS:' | head -n 1 || true)" + if printf '%s' "$RELEVANT2" | grep -q -- '--language en' && + printf '%s' "$RELEVANT2" | grep -q -- '--repo-env https://github.com/x/env.git#b1' && + printf '%s' "$RELEVANT2" | grep -q -- '--repo-sdk https://github.com/y/sdk.git'; then + pass '--official/--env/--sdk/--lang forwarded' + else + fail "--official/--env/--sdk/--lang forwarded (got: ${RELEVANT2:-})" + fi +fi + +# --------------------------------------------------------------------------- +section '5. temp-file cleanup (7.4)' +# --------------------------------------------------------------------------- +if [ -z "$PY3" ]; then + skip 'temp-file cleanup (no usable python3/python)' +else + STUB="$WORK/stub_touch_env.py" + if [ ! -f "$STUB" ]; then + cat > "$STUB" <<'EOF' +import sys +print("ARGS: " + " ".join(sys.argv[1:])) +EOF + fi + TMPROOT="${TMPDIR:-/tmp}" + BEFORE="$(ls "$TMPROOT"/tmp.* 2>/dev/null | sort || true)" + PATH="$(dirname "$PY3"):$PATH" bash "$INSTALL_SH" \ + --touch-env "$(file_url "$STUB")" --yes >/dev/null 2>&1 || true + AFTER="$(ls "$TMPROOT"/tmp.* 2>/dev/null | sort || true)" + LEFTOVER="$(comm -13 "$BEFORE" "$AFTER" 2>/dev/null || true)" + if [ -z "$LEFTOVER" ]; then + pass 'temp file removed on exit (no new tmp.* leftovers)' + else + fail "temp file removed on exit (leftover: $LEFTOVER)" + fi +fi + +# --------------------------------------------------------------------------- +printf '\n== summary ==\n' +printf 'PASS=%s FAIL=%s SKIP=%s\n' "$PASS" "$FAIL" "$SKIP" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0 diff --git a/tools/tests/linux/test_touch_env_install.sh b/tools/tests/linux/test_touch_env_install.sh new file mode 100644 index 00000000..1b02c467 --- /dev/null +++ b/tools/tests/linux/test_touch_env_install.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# Real-install end-to-end test for tools/touch_env.py (offline, isolated). +# +# Runs the full installer against a throwaway ENV_ROOT using local bare +# repositories for env/packages/sdk, so no network is needed and nothing +# touches the real ~/.rt-env. PIP_NO_DEPS=1 keeps the venv bootstrap local +# (the editable env package install still resolves its own metadata). +# +# Set RT_ENV_TEST_FULL=1 to run a TRULY real install instead: install all +# third-party dependencies and pyocd (network required), then actually run +# the installed rt-env (version, info, help) from the fresh venv. +# +# Usage: bash tools/tests/linux/test_touch_env_install.sh +# RT_ENV_TEST_FULL=1 bash tools/tests/linux/test_touch_env_install.sh +# Needs: bash, git, python (with venv + pip); network only in FULL mode. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TOOLS_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +TOUCH_ENV="$TOOLS_DIR/tools/touch_env.py" +REPO_ROOT="$TOOLS_DIR" + +PASS=0 +FAIL=0 +WORK="$(mktemp -d 2>/dev/null || mktemp -d -t rt-env-install)" + +pass() { printf ' PASS: %s\n' "$1"; PASS=$((PASS + 1)); } +fail() { printf ' FAIL: %s\n' "$1"; FAIL=$((FAIL + 1)); } +section() { printf '\n== %s ==\n' "$1"; } + +cleanup() { + rm -rf "$WORK" +} +trap cleanup EXIT INT TERM + +file_url() { + case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + printf 'file:///%s' "$(cygpath -m "$1" 2>/dev/null || printf '%s' "$1")" + ;; + *) + printf 'file://%s' "$1" + ;; + esac +} + +# Resolve python; synthesize a wrapper when only `python` works (store stub). +detect_python() { + if command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; then + printf 'python3' + return 0 + fi + local py + py="$(command -v python 2>/dev/null || true)" + if [ -n "$py" ] && "$py" --version >/dev/null 2>&1; then + mkdir -p "$WORK/bin" + printf '#!/bin/sh\nexec "%s" "$@"\n' "$py" > "$WORK/bin/python3" + chmod +x "$WORK/bin/python3" + printf '%s' "$WORK/bin/python3" + return 0 + fi + return 1 +} + +PY="$(detect_python || true)" +if [ -z "$PY" ]; then + echo "SKIP: no usable python3/python" >&2 + exit 0 +fi + +# --------------------------------------------------------------------------- +section '1. seed local bare repositories' +# --------------------------------------------------------------------------- +ENV_BARE="$WORK/env.git" +PKGS_BARE="$WORK/packages.git" +SDK_BARE="$WORK/sdk.git" +for bare in "$ENV_BARE" "$PKGS_BARE" "$SDK_BARE"; do + git init --bare -q "$bare" +done + +# env: push the current WORKING TREE (tracked changes included) as master, +# so the suite tests what the developer is editing, not the last commit. +# `git stash create` yields a dangling commit without touching index/HEAD. +STASH_COMMIT="$(git -C "$REPO_ROOT" stash create 2>/dev/null || true)" +[ -n "$STASH_COMMIT" ] || STASH_COMMIT=HEAD +git -C "$REPO_ROOT" push -q "$ENV_BARE" "$STASH_COMMIT:refs/heads/master" 2>/dev/null || true +git --git-dir="$ENV_BARE" symbolic-ref HEAD refs/heads/master + +# packages/sdk: empty repositories are fine; ensure their HEAD points at master +git --git-dir="$PKGS_BARE" symbolic-ref HEAD refs/heads/master +git --git-dir="$SDK_BARE" symbolic-ref HEAD refs/heads/master + +section '2. run the real installer (auto, keep-sdk yes, local sources)' +# --------------------------------------------------------------------------- +ENV_ROOT="$WORK/env-root" +FULL="${RT_ENV_TEST_FULL:-0}" +if [ "$FULL" = "1" ]; then + printf ' (FULL mode: installing all dependencies and pyocd - needs network)\n' +else + # Keep the venv bootstrap local: skip third-party deps (env metadata still resolves) + export PIP_NO_DEPS=1 +fi +OUTPUT="$("$PY" "$TOUCH_ENV" \ + --env-root "$ENV_ROOT" \ + --auto-mode \ + --keep-sdk yes \ + --repo-env "$(file_url "$ENV_BARE")" \ + --repo-packages "$(file_url "$PKGS_BARE")" \ + --repo-sdk "$(file_url "$SDK_BARE")" \ + 2>&1)" +RC=$? + +if [ "$RC" -eq 0 ]; then + pass "installer exits 0" +else + fail "installer exits 0 (rc=$RC)" + printf '%s\n' "$OUTPUT" | tail -20 +fi + +section '3. repository clones' +# --------------------------------------------------------------------------- +if [ -f "$ENV_ROOT/tools/scripts/env.py" ] && + [ -f "$ENV_ROOT/tools/scripts/pyproject.toml" ]; then + pass 'env cloned into tools/scripts' +else + fail 'env cloned into tools/scripts' +fi + +if [ -d "$ENV_ROOT/packages/packages" ]; then + pass 'packages cloned into packages/packages' +else + fail 'packages cloned into packages/packages' +fi + +if [ -d "$ENV_ROOT/packages/sdk" ]; then + pass 'sdk cloned into packages/sdk' +else + fail 'sdk cloned into packages/sdk' +fi + +section '4. venv and editable install' +# --------------------------------------------------------------------------- +VENV_DIR="$ENV_ROOT/venv/rt-env" +if [ -d "$VENV_DIR" ]; then + pass 'venv created at venv/rt-env' +else + fail 'venv created at venv/rt-env' +fi + +case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + RT_ENV_EXE="$VENV_DIR/Scripts/rt-env.exe" + VENV_PY="$VENV_DIR/Scripts/python.exe" + ;; + *) + RT_ENV_EXE="$VENV_DIR/bin/rt-env" + VENV_PY="$VENV_DIR/bin/python" + ;; +esac + +if [ -f "$RT_ENV_EXE" ]; then + pass 'rt-env console script present' +else + fail 'rt-env console script present' +fi + +if [ -f "$VENV_PY" ]; then + VER="$("$VENV_PY" -c 'from importlib.metadata import version; print(version("rt-env"))' 2>/dev/null || true)" + if [ -n "$VER" ]; then + pass "editable install metadata resolves (rt-env $VER)" + else + fail 'editable install metadata resolves' + fi +else + fail 'venv python present' +fi + +# --------------------------------------------------------------------------- +section '4b. installed tool actually runs (FULL mode only)' +# --------------------------------------------------------------------------- +if [ "$FULL" != "1" ]; then + printf ' (skipped: set RT_ENV_TEST_FULL=1 for real deps + runtime checks)\n' +else + # pyocd is installed unconditionally by install_packages() + if [ -f "$VENV_DIR/Scripts/pyocd.exe" ] || [ -f "$VENV_DIR/bin/pyocd" ]; then + pass 'pyocd installed' + else + fail 'pyocd installed' + fi + + if [ -f "$RT_ENV_EXE" ]; then + RV="$("$RT_ENV_EXE" -v 2>&1 || true)" + if printf '%s' "$RV" | grep -q 'RT-Thread Env Tool'; then + pass "rt-env -v runs ($(printf '%s' "$RV" | head -n 1))" + else + fail "rt-env -v runs (got: ${RV:-})" + fi + + RI="$("$RT_ENV_EXE" --info 2>&1 || true)" + if printf '%s' "$RI" | grep -q 'Welcome to RT-Thread Env Tool'; then + pass 'rt-env --info runs' + else + fail "rt-env --info runs (got: $(printf '%s' "$RI" | head -n 1))" + fi + + RH="$("$RT_ENV_EXE" --help 2>&1 || true)" + if printf '%s' "$RH" | grep -q 'usage: rt-env' && + printf '%s' "$RH" | grep -q 'webui'; then + pass 'rt-env --help runs (usage + webui subcommand)' + else + fail 'rt-env --help runs' + fi + else + fail 'rt-env console script present for runtime checks' + fi +fi + +# --------------------------------------------------------------------------- +section '4c. root activator and user customization' +# --------------------------------------------------------------------------- +if bash -n "$ENV_ROOT/env.sh" && bash -n "$ENV_ROOT/tools/scripts/env.sh"; then + pass 'activator syntax (bash -n)' +else + fail 'activator syntax (bash -n)' +fi + +if grep -q 'RT_ENV_ROOT' "$ENV_ROOT/env.sh" && + grep -q 'tools/scripts/env.sh' "$ENV_ROOT/env.sh"; then + pass 'root activator is a thin delegator' +else + fail 'root activator is a thin delegator' +fi + +if [ -f "$ENV_ROOT/env.user.sh" ]; then + pass 'user customization file seeded' +else + fail 'user customization file seeded' +fi + +DELEGATED="$(bash -c ". '$ENV_ROOT/env.sh' >/dev/null 2>&1; printf '%s' \"\$RT_VENV_DIR\"")" +if [ "$DELEGATED" = "$VENV_DIR" ]; then + pass 'delegation activates venv/rt-env' +else + fail "delegation activates venv/rt-env (got: ${DELEGATED:-})" +fi + +FALLBACK_ROOT="$WORK/fallback-root" +mkdir -p "$FALLBACK_ROOT/.venv/bin" "$FALLBACK_ROOT/tools/scripts" +cp "$ENV_ROOT/tools/scripts/env.sh" "$FALLBACK_ROOT/tools/scripts/env.sh" +printf '# fake legacy venv\nexport RTT_FAKE_VENV=legacy\n' > "$FALLBACK_ROOT/.venv/bin/activate" +FALLBACKED="$(RT_ENV_ROOT="$FALLBACK_ROOT" bash -c ". '$FALLBACK_ROOT/tools/scripts/env.sh' >/dev/null 2>&1; printf '%s|%s' \"\$RT_VENV_DIR\" \"\$RTT_FAKE_VENV\"")" +if [ "$FALLBACKED" = "$FALLBACK_ROOT/.venv|legacy" ]; then + pass 'legacy .venv fallback activates' +else + fail "legacy .venv fallback activates (got: ${FALLBACKED:-})" +fi + +section '5. cleanup bookkeeping' +# --------------------------------------------------------------------------- +if [ -z "$OUTPUT" ] || printf '%s' "$OUTPUT" | grep -qi 'completed'; then + pass 'installer reports completion' +else + fail 'installer reports completion' +fi + +# --------------------------------------------------------------------------- +printf '\n== summary ==\n' +printf 'PASS=%s FAIL=%s\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0 diff --git a/tools/tests/windows/run_all.ps1 b/tools/tests/windows/run_all.ps1 new file mode 100644 index 00000000..86a2f1f7 --- /dev/null +++ b/tools/tests/windows/run_all.ps1 @@ -0,0 +1,40 @@ +<# +.SYNOPSIS + Run the Windows-side offline test suites for tools/ (install.ps1). + POSIX-side suites (install.sh / touch_env.py) live in run_all.sh. + +.EXAMPLE + pwsh -NoProfile -File tools/tests/run_all_ps1.ps1 + $env:RT_ENV_TEST_FULL=1; pwsh -NoProfile -File tools/tests/run_all_ps1.ps1 # full real install (network) +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +$failed = 0 + +function Invoke-Suite([string]$Name, [string]$Command) { + Write-Host "`n########## $Name ##########" + if (& { Invoke-Expression $Command }) { + Write-Host "########## ${Name}: OK ##########" + } + else { + Write-Host "########## ${Name}: FAILED ##########" -ForegroundColor Red + $script:failed++ + } +} + +Invoke-Suite 'install.ps1 (stub)' "pwsh -NoProfile -File `"$scriptDir\test_install_ps1.ps1`"" +Invoke-Suite 'install.ps1 real install' "pwsh -NoProfile -File `"$scriptDir\test_install_ps1_install.ps1`"" + +Write-Host "`n===== SUMMARY =====" +if ($failed -eq 0) { + Write-Host 'ALL PS1 SUITES PASSED' +} +else { + Write-Host "$failed PS1 suite(s) FAILED" -ForegroundColor Red + exit 1 +} +exit 0 diff --git a/tools/tests/windows/test_install_ps1.ps1 b/tools/tests/windows/test_install_ps1.ps1 new file mode 100644 index 00000000..21e1de0d --- /dev/null +++ b/tools/tests/windows/test_install_ps1.ps1 @@ -0,0 +1,115 @@ +<# +.SYNOPSIS + Tests for tools/install.ps1 (orchestrator script, PowerShell side). + +.DESCRIPTION + Covers tools/tests/README.md section 5.1 / 5.3 / 5.4 (PowerShell side): + - parser syntax check + - --help assertions (long options only, removed options absent) + - temp-file cleanup wiring (unique name + try/finally) + The argument pass-through test (7.3) needs a local HTTP stub and admin + rights; it is reported as SKIP when not configured. + +.EXAMPLE + pwsh -NoProfile -File tools/tests/test_install_ps1.ps1 +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $scriptDir)) +$installPs1 = Join-Path $repoRoot 'tools\install.ps1' + +$script:Pass = 0 +$script:Fail = 0 +$script:Skip = 0 + +function Pass([string]$Name) { Write-Host " PASS: $Name"; $script:Pass++ } +function Fail([string]$Name) { Write-Host " FAIL: $Name" -ForegroundColor Red; $script:Fail++ } +function Skip([string]$Name) { Write-Host " SKIP: $Name" -ForegroundColor Yellow; $script:Skip++ } +function Section([string]$Name) { Write-Host "`n== $Name ==" } + +Write-Host "Testing: $installPs1" + +# --------------------------------------------------------------------------- +Section '1. parser syntax (7.1)' +# --------------------------------------------------------------------------- +try { + $errors = $null + [System.Management.Automation.PSParser]::Tokenize( + (Get-Content $installPs1 -Raw), [ref]$errors) | Out-Null + if ($errors.Count -eq 0) { Pass 'PSParser reports no errors' } + else { Fail "PSParser errors: $($errors.Count)" } +} +catch { + Fail "parser check threw: $_" +} + +# --------------------------------------------------------------------------- +Section '2. --help assertions (7.1)' +# --------------------------------------------------------------------------- +$help = & $installPs1 -h *>&1 | Out-String + +if ($help -match '--keep-sdk') { Pass 'help lists --keep-sdk' } +else { Fail 'help lists --keep-sdk' } + +if ($help -match '--english|--chinese') { Fail 'help has no removed --english/--chinese' } +else { Pass 'help has no removed --english/--chinese' } + +if ($help -match '--pyocd|--backup') { Fail 'help has no removed --pyocd/--backup' } +else { Pass 'help has no removed --pyocd/--backup' } + +$shortMatches = @([regex]::Matches($help, '(?m)(^|\s)-([a-zA-Z])\b') | + Where-Object { $_.Groups[2].Value -ne 'h' }) +if ($shortMatches.Count -gt 0) { + Fail "no single-letter short options (found: $($shortMatches.Value -join ', '))" +} +else { Pass 'no single-letter short options' } + +# G6: --lang must actually switch the banner language +$helpEn = & $installPs1 --lang en -h *>&1 | Out-String +$helpZh = & $installPs1 --lang zh -h *>&1 | Out-String +if ($helpEn -match 'Installation' -and $helpEn -notmatch '安装程序') { Pass '--lang en switches banner to English' } +else { Fail '--lang en switches banner to English' } +if ($helpZh -match '安装程序') { Pass '--lang zh switches banner to Chinese' } +else { Fail '--lang zh switches banner to Chinese' } + +# --------------------------------------------------------------------------- +Section '3. temp-file cleanup wiring (7.4)' +# --------------------------------------------------------------------------- +$content = Get-Content $installPs1 -Raw + +if ($content -match 'touch_env_" \+ \[guid\]::NewGuid\(\)') { + Pass 'temp file uses a unique GUID name' +} +else { Fail 'temp file uses a unique GUID name' } + +if ($content -match '(?s)function Invoke-TouchEnv.*?finally \{.*?Remove-Item') { + Pass 'Invoke-TouchEnv removes the temp file in finally' +} +else { Fail 'Invoke-TouchEnv removes the temp file in finally' } + +if ($content -match 'Register-EngineEvent -SourceIdentifier PowerShell\.Exiting') { + Pass 'exit-event cleanup handler is registered' +} +else { Fail 'exit-event cleanup handler is registered' } + +# --------------------------------------------------------------------------- +Section '4. argument pass-through (7.3)' +# --------------------------------------------------------------------------- +$stubUrl = $env:RT_ENV_PS1_STUB_URL +if ([string]::IsNullOrWhiteSpace($stubUrl)) { + Skip 'pass-through (set RT_ENV_PS1_STUB_URL=http://127.0.0.1:PORT/stub_touch_env.py to enable; needs admin)' +} +else { + $output = & $installPs1 --touch-env $stubUrl --yes 2>&1 | Out-String + if ($output -match 'ARGS: .*--auto-mode') { Pass 'long options forwarded (--auto-mode seen)' } + else { Fail "pass-through (no ARGS line; output: $output)" } +} + +# --------------------------------------------------------------------------- +Write-Host "`n== summary ==" +Write-Host "PASS=$script:Pass FAIL=$script:Fail SKIP=$script:Skip" +if ($script:Fail -gt 0) { exit 1 } +exit 0 diff --git a/tools/tests/windows/test_install_ps1_install.ps1 b/tools/tests/windows/test_install_ps1_install.ps1 new file mode 100644 index 00000000..d2cceb9d --- /dev/null +++ b/tools/tests/windows/test_install_ps1_install.ps1 @@ -0,0 +1,175 @@ +<# +.SYNOPSIS + Real-install end-to-end test for tools/install.ps1 (Windows, isolated). + +.DESCRIPTION + Runs the full orchestrator install against a throwaway ENV_ROOT: + 1. seeds local bare repos for env/packages/sdk + 2. serves the real touch_env.py over a local HTTP stub + 3. invokes install.ps1 with --touch-env/--env-root/--repo-* (no admin if + the execution policy and long-path support already meet requirements) + 4. asserts clones, venv/rt-env, and the rt-env console script + Nothing touches the real ~/.rt-env. PIP_NO_DEPS keeps the venv bootstrap + local; set RT_ENV_TEST_FULL=1 to install real deps + pyocd and run rt-env. + + Needs elevation only when Init-WindowsEnv must change the execution policy + or long-path support. On this machine both already satisfy the target, so + it runs without admin. If your machine requires elevation, run: + sudo pwsh -NoProfile -File tools/tests/test_install_ps1_install.ps1 + (Windows built-in sudo shows a UAC prompt.) + +.EXAMPLE + pwsh -NoProfile -File tools/tests/test_install_ps1_install.ps1 + $env:RT_ENV_TEST_FULL=1; pwsh -NoProfile -File tools/tests/test_install_ps1_install.ps1 +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $scriptDir)) +$installPs1 = Join-Path $repoRoot 'tools\install.ps1' +$touchEnv = Join-Path $repoRoot 'tools\touch_env.py' + +$script:Pass = 0 +$script:Fail = 0 +$script:Skip = 0 +function Pass([string]$n) { Write-Host " PASS: $n"; $script:Pass++ } +function Fail([string]$n) { Write-Host " FAIL: $n" -ForegroundColor Red; $script:Fail++ } +function Skip([string]$n) { Write-Host " SKIP: $n" -ForegroundColor Yellow; $script:Skip++ } +function Section([string]$n) { Write-Host "`n== $n ==" } + +$full = $env:RT_ENV_TEST_FULL -eq '1' +$work = Join-Path $env:TEMP ("rt_env_ps1_" + [guid]::NewGuid().ToString('N')) +$job = $null +$port = 8899 + +function Cleanup { + if ($job) { + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -Force -ErrorAction SilentlyContinue + } + Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue +} +trap { Cleanup } + +if (-not (Test-Path $installPs1) -or -not (Test-Path $touchEnv)) { + Write-Host "SKIP: install.ps1 or touch_env.py missing" -ForegroundColor Yellow + exit 0 +} + +New-Item -ItemType Directory -Force $work | Out-Null + +# --------------------------------------------------------------------------- +Section '1. seed local bare repositories' +# --------------------------------------------------------------------------- +foreach ($n in 'env', 'packages', 'sdk') { + git init --bare -q "$work\$n.git" 2>$null +} +git -C $repoRoot push -q "$work\env.git" HEAD:master 2>$null +foreach ($n in 'env', 'packages', 'sdk') { + git --git-dir="$work\$n.git" symbolic-ref HEAD refs/heads/master +} +Write-Host ' (env pushed from the working tree; packages/sdk are empty repos)' + +# --------------------------------------------------------------------------- +Section '2. serve touch_env.py over local HTTP' +# --------------------------------------------------------------------------- +$srv = Join-Path $work 'srv' +New-Item -ItemType Directory -Force $srv | Out-Null +Copy-Item $touchEnv (Join-Path $srv 'touch_env.py') +$job = Start-Job -ScriptBlock { + param($d, $p) + Set-Location $d + python -m http.server $p +} -ArgumentList $srv, $port +Start-Sleep -Seconds 3 + +$url = "http://127.0.0.1:$port/touch_env.py" +try { + $probe = (Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 5).StatusCode + if ($probe -eq 200) { Pass 'local HTTP stub serves touch_env.py' } + else { Fail "local HTTP stub (status $probe)" } +} +catch { + Fail "local HTTP stub ($_)" + Cleanup + Write-Host "`n== summary ==" + Write-Host "PASS=$script:Pass FAIL=$script:Fail SKIP=$script:Skip" + exit 1 +} + +# --------------------------------------------------------------------------- +Section '3. run install.ps1 real install' +# --------------------------------------------------------------------------- +$envRoot = Join-Path $work 'root' +$u = $work -replace '\\', '/' +if (-not $full) { $env:PIP_NO_DEPS = '1' } +else { Remove-Item Env:PIP_NO_DEPS -ErrorAction SilentlyContinue } + +$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +Write-Host " (admin=$isAdmin, full=$full)" + +$out = & $installPs1 ` + --touch-env $url ` + --env-root $envRoot ` + --yes --keep-sdk yes ` + --env "file:///$u/env.git" ` + --packages "file:///$u/packages.git" ` + --sdk "file:///$u/sdk.git" 2>&1 +$rc = $LASTEXITCODE + +if ($rc -eq 0) { Pass 'install.ps1 exits 0' } +else { + Fail "install.ps1 exits 0 (rc=$rc)" + $out | Select-Object -Last 15 | ForEach-Object { Write-Host " $_" } +} + +# --------------------------------------------------------------------------- +Section '4. artifacts' +# --------------------------------------------------------------------------- +foreach ($pair in @( + @{ n = 'env cloned into tools/scripts'; p = "$envRoot\tools\scripts\env.py" }, + @{ n = 'packages cloned'; p = "$envRoot\packages\packages" }, + @{ n = 'sdk cloned'; p = "$envRoot\packages\sdk" }, + @{ n = 'venv created at venv/rt-env'; p = "$envRoot\venv\rt-env" }, + @{ n = 'rt-env console script present'; p = "$envRoot\venv\rt-env\Scripts\rt-env.exe" } + )) { + if (Test-Path $pair.p) { Pass $pair.n } else { Fail $pair.n } +} + +$venvPy = "$envRoot\venv\rt-env\Scripts\python.exe" +if (Test-Path $venvPy) { + $ver = & $venvPy -c "from importlib.metadata import version; print(version('rt-env'))" 2>$null + if ($ver) { Pass "editable metadata resolves (rt-env $ver)" } + else { Fail 'editable metadata resolves' } +} + +# --------------------------------------------------------------------------- +Section '5. runtime checks (FULL mode only)' +# --------------------------------------------------------------------------- +if (-not $full) { + Skip 'real deps + pyocd + rt-env runtime (set RT_ENV_TEST_FULL=1)' +} +else { + $rtEnv = "$envRoot\venv\rt-env\Scripts\rt-env.exe" + if (Test-Path "$envRoot\venv\rt-env\Scripts\pyocd.exe") { Pass 'pyocd installed' } + else { Fail 'pyocd installed' } + + if (Test-Path $rtEnv) { + $v = & $rtEnv -v 2>&1 + if ("$v" -match 'RT-Thread Env Tool') { Pass "rt-env -v runs ($v)" } else { Fail 'rt-env -v runs' } + $i = & $rtEnv --info 2>&1 + if ("$i" -match 'Welcome to RT-Thread Env Tool') { Pass 'rt-env --info runs' } else { Fail 'rt-env --info runs' } + $h = & $rtEnv --help 2>&1 + if ("$h" -match 'usage: rt-env' -and "$h" -match 'webui') { Pass 'rt-env --help runs' } else { Fail 'rt-env --help runs' } + } +} + +# --------------------------------------------------------------------------- +Cleanup +Write-Host "`n== summary ==" +Write-Host "PASS=$script:Pass FAIL=$script:Fail SKIP=$script:Skip" +if ($script:Fail -gt 0) { exit 1 } +exit 0 diff --git a/tools/touch_env.py b/tools/touch_env.py new file mode 100644 index 00000000..36942a84 --- /dev/null +++ b/tools/touch_env.py @@ -0,0 +1,1334 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# File : touch_env.py +# This file is part of RT-Thread RTOS +# COPYRIGHT (C) 2006 - 2026, RT-Thread Development Team +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# Change Logs: +# Date Author Notes +# 2026-01-30 dongly Initial version +# +# RT-Thread ENV Setup Script (Python) +# RT-Thread ENV 安装脚本 (Python) +# +# This script handles the setup of RT-Thread ENV after the repository is cloned. +# 此脚本在仓库克隆后处理 RT-Thread ENV 的设置。 +# It performs the installation process: +# 执行安装过程: +# 1. Setup repositories (clone env, then packages and sdk) - 设置仓库(先克隆 env,再克隆 packages 与 sdk) +# Default packages/sdk sources come from the downloaded env's env.json; +# built-in constants are only a bootstrap fallback. +# packages/sdk 的默认源来自已下载 env 的 env.json;内置常量仅作引导兜底。 +# 2. Create Python virtual environment - 创建 Python 虚拟环境 +# 3. Install Python packages - 安装 Python 包 +# 4. Show next steps - 显示后续步骤 +# +# Usage: +# 用法: +# python touch_env.py [OPTIONS] +# +# Options: +# 选项: +# --env-root Installation root directory (default: ~/.rt-env) +# 安装 ENV_ROOT(默认:~/.rt-env) +# --use-cn Use China mirror (Gitee, TUNA PyPI) +# 使用中国镜像(Gitee, TUNA PyPI) +# --language Language: 'en' or 'zh' +# 语言:'en' 或 'zh' +# --auto-mode Auto-install without prompts +# 自动安装,无提示 +# --keep-sdk Keep downloaded toolchains (local_pkgs) and config +# when ENV_ROOT exists (default: yes; prompt if omitted) +# 当 ENV 已存在时保留已下载的工具链(local_pkgs)与配置 +# (默认:yes;未指定时交互询问) +# --repo-env Custom env repository URL, e.g.: +# 自定义 env 仓库 URL,例如: +# https://github.com/user/env.git#branch1 <--- branch is optional +# https://github.com/user/env.git <--- 分支是可选的 +# --repo-packages Custom packages repository URL +# 自定义 packages 仓库 URL +# --repo-sdk Custom sdk repository URL +# 自定义 sdk 仓库 URL +# +# Examples: +# 示例: +# python touch_env.py +# python touch_env.py --env-root /path/to/env +# python touch_env.py --repo-env https://github.com/user/env.git#branch1 +# python touch_env.py --keep-sdk no --repo-packages https://github.com/user/packages.git#my-branch +# + +import os +import sys +import argparse +import platform +import shutil +import subprocess +import json +from pathlib import Path +from datetime import datetime + +# ============================================================================ +# Configuration Constants +# ============================================================================ + +# Bootstrap fallback sources. +# The downloaded env's env.json (repositories.*) is the canonical default for +# packages/sdk; these constants only apply when env.json cannot be read. +# The env repository itself is the bootstrap (chicken-and-egg) and always +# uses these constants unless overridden via --repo-env. +# 引导兜底源。已下载 env 的 env.json(repositories.*)是 packages/sdk 的 +# 规范默认源;仅当 env.json 无法读取时才使用以下常量。env 仓库自身是 +# 引导起点(先有鸡还是先有蛋),除 --repo-env 覆盖外始终使用这些常量。 +REPO_PACKAGES_GITHUB = "https://github.com/RT-Thread/packages.git" +REPO_ENV_GITHUB = "https://github.com/RT-Thread/env.git" +REPO_SDK_GITHUB = "https://github.com/RT-Thread/sdk.git" + +# Gitee mirrors (China) +REPO_PACKAGES_GITEE = "https://gitee.com/RT-Thread-Mirror/packages.git" +REPO_ENV_GITEE = "https://gitee.com/RT-Thread-Mirror/env.git" +REPO_SDK_GITEE = "https://gitee.com/RT-Thread-Mirror/sdk.git" + +# PyPI mirror +PYPI_MIRROR_CN = "https://pypi.tuna.tsinghua.edu.cn/simple" + +# Internal default values +VENV_DIR_RELATIVE = "venv/rt-env" +SCRIPTS_DIR_RELATIVE = "tools/scripts" + +# Default installation root directory +DEFAULT_ENV_ROOT = "~/.rt-env" + +# Portable Python directory name +PORTABLE_PYTHON_DIR = "python" + +# ============================================================================ +# Python Version Check +# ============================================================================ + +MIN_PYTHON_VERSION = (3, 6) + +if sys.version_info < MIN_PYTHON_VERSION: + print( + f"Error: Python {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]} or higher is required.", file=sys.stderr) + print(f"Current Python version: {sys.version}", file=sys.stderr) + sys.exit(1) + +# ============================================================================ +# Runtime Configuration +# ============================================================================ + +class RuntimeConfig: + """Runtime configuration management""" + def __init__(self): + self._language = 'en' # Default language + + @property + def language(self): + """Get current language""" + return self._language + + @language.setter + def language(self, value): + """Set language""" + self._language = value + +# Global runtime configuration instance +_runtime_config = RuntimeConfig() + +def get_language(): + """Get current language""" + return _runtime_config.language + +def set_language(lang): + """Set language""" + _runtime_config.language = lang + +# ============================================================================ +# TouchEnvConfig Class +# ============================================================================ + + +class TouchEnvConfig: + """Configuration management class for touch_env""" + + def __init__(self, args): + # Check if using default env-root + default_env_root = os.path.expanduser(DEFAULT_ENV_ROOT) + if args.env_root == default_env_root: + # Temporarily set language for log_info + set_language(args.language) + log_info('using_default_env_root', default_env_root) + + # Set language in runtime config + set_language(args.language) + + self.env_root = args.env_root + self.use_cn = args.use_cn + self.language = args.language + self.auto_mode = args.auto_mode + self.keep_sdk = args.keep_sdk + self.custom_repos = args.custom_repos + + # Compute internal paths + self._compute_paths() + + # Validate configuration + self._validate() + + def _compute_paths(self): + """Compute internal paths based on env_root""" + self.venv_dir = os.path.join(self.env_root, VENV_DIR_RELATIVE) + self.scripts_dir = os.path.join(self.env_root, SCRIPTS_DIR_RELATIVE) + + def _validate(self): + """Validate configuration""" + # Validate env_root + if not self.env_root: + raise ValueError("env_root is required") + + # Validate language + if self.language not in ['en', 'zh']: + raise ValueError( + f"Invalid language: {self.language}. Must be 'en' or 'zh'") + + # custom_repos is built internally in parse_arguments, no need for extensive validation + # Basic type check is sufficient + if self.custom_repos and not isinstance(self.custom_repos, dict): + raise ValueError("custom_repos must be a dictionary") + +# ============================================================================ +# Internationalization Messages +# ============================================================================ + + +MESSAGES = { + 'en': { + 'info': 'INFO', + 'success': 'SUCCESS', + 'warning': 'WARNING', + 'error': 'ERROR', + 'cloning': 'Cloning {0} to {1}', + 'cloned': 'Cloned {0}', + 'dir_exists': 'Directory already exists: {0}', + 'generating_kconfig': 'Generating Kconfig: {0}', + 'creating_venv': 'Creating virtual environment at: {0}', + 'venv_created': 'Virtual environment created', + 'venv_exists': 'Virtual environment already exists', + 'upgrading_pip': 'Upgrading pip...', + 'installing_packages': 'Installing Python packages...', + 'installed_packages': 'Python packages installed successfully', + 'using_cn_mirror': 'Using China mirror', + 'using_pypi_mirror': 'Using PyPI mirror: {0}', + 'copied_env_script': 'Copied env script: {0}', + 'activator_created': 'Created thin activator: {0}', + 'user_config_created': 'Created user customization file: {0}', + 'fixed_guiconfig': 'Fixed guiconfig.py (added missing import)', + 'setup_complete': 'RT-Thread ENV installation completed!', + 'next_steps': 'Next steps:', + 'activate_env': '1. Activate environment:', + 'add_to_profile': '2. Add to profile:', + 'install_toolchain': '3. Install toolchains:', + 'install_toolchain_cmd': ' Run `sdk` command to install required toolchains', + 'after_activation': '4. After activation, you can use:', + 'menuconfig': ' - menuconfig : Configure project', + 'menuconfig_s': ' - menuconfig -s : Configure RT-Thread ENV', + 'pkgs': ' - pkgs : Package manager', + 'scons': ' - scons : Build project', + 'sdk': ' - sdk : Install toolchains', + 'plugin': ' - plugin : Manage local Env plugins', + 'webui': ' - webui : Manage and run the local Env WebUI', + 'clone_failed': 'Git clone failed: {0}', + 'invalid_git_repo': 'Invalid git repository: {0}', + 'venv_not_found': 'Virtual environment not found', + 'package_install_failed': 'Package installation failed: {0}', + 'venv_creation_failed': 'Virtual environment creation failed: {0}', + 'fix_guiconfig_failed': 'Failed to fix guiconfig.py: {0}', + 'using_custom_repo': 'Using custom repository: {0}', + 'using_custom_repo_branch': 'Using custom repository: {0} (branch: {1})', + 'env_json_defaults': 'Repository defaults loaded from env.json: {0}', + 'env_json_fallback': 'Cannot read repository defaults from {0}, using built-in sources', + 'env_root_exists': 'Existing RT-Thread ENV detected at: {0}', + 'toolchain_keep_prompt': 'Keep downloaded toolchains (local_pkgs) and config? [Y/n]: ', + 'toolchain_kept': 'Keeping toolchains (local_pkgs) and config', + 'toolchain_removed': 'Removing entire existing directory', + 'auto_mode_preserving': 'Auto mode: keeping toolchains (local_pkgs) and config', + 'deleting_item': 'Removing: {0}', + 'installation_cancelled': 'Installation cancelled', + 'installation_failed': 'Installation failed: {0}', + 'file_delete_failed': 'Failed to delete file: {0} - {1}', + 'dir_delete_failed': 'Failed to delete directory: {0} - {1}', + 'deleting_env_root': 'Deleting existing directory: {0}', + 'deleting_env_root_failed': 'Failed to delete directory: {0}', + 'manual_delete_required': 'Please manually delete directory: {0}, then retry', + 'start': '[PY]Starting RT-Thread ENV installation...', + 'using_default_env_root': 'Using default ENV_ROOT: {0}', + 'env_root_prompt': 'Enter installation root directory (ENV_ROOT)', + 'env_root_default': '[default: {0}]', + 'python_path_invalid': 'Path contains {0} (not allowed in Python paths)', + 'python_path_creating_dir': 'Creating directory: {0}', + 'python_path_no_permission': 'No write permission for directory: {0}', + }, + 'zh': { + 'info': '信息', + 'success': '成功', + 'warning': '警告', + 'error': '错误', + 'cloning': '正在克隆: {0} 到 {1}', + 'cloned': '已克隆: {0}', + 'dir_exists': '目录已存在: {0}', + 'generating_kconfig': '生成 Kconfig: {0}', + 'creating_venv': '正在创建虚拟环境: {0}', + 'venv_created': '虚拟环境创建完成', + 'venv_exists': '虚拟环境已存在', + 'upgrading_pip': '正在升级 pip...', + 'installing_packages': '正在安装 Python 包...', + 'installed_packages': 'Python 包安装完成', + 'using_cn_mirror': '使用中国镜像源', + 'using_pypi_mirror': '使用 PyPI 镜像: {0}', + 'copied_env_script': '已复制 env 脚本: {0}', + 'activator_created': '已创建薄激活器: {0}', + 'user_config_created': '已创建用户自定义文件: {0}', + 'fixed_guiconfig': '已修复 guiconfig.py(添加缺失的导入)', + 'setup_complete': 'RT-Thread ENV 安装完成!', + 'next_steps': '后续步骤:', + 'activate_env': '1. 激活环境:', + 'add_to_profile': '2. 添加到配置文件:', + 'install_toolchain': '3. 安装工具链:', + 'install_toolchain_cmd': ' 运行 `sdk` 命令安装所需的工具链', + 'after_activation': '4. 激活后可用命令:', + 'menuconfig': ' - menuconfig : 配置项目', + 'menuconfig_s': ' - menuconfig -s : 配置 RT-Thread ENV', + 'pkgs': ' - pkgs : 包管理器', + 'scons': ' - scons : 编译项目', + 'sdk': ' - sdk : 安装工具链', + 'plugin': ' - plugin : 管理本地 Env 插件', + 'webui': ' - webui : 管理并运行本地 Env WebUI', + 'clone_failed': 'Git 克隆失败: {0}', + 'invalid_git_repo': '无效的 git 仓库: {0}', + 'venv_not_found': '找不到虚拟环境', + 'package_install_failed': '包安装失败: {0}', + 'venv_creation_failed': '虚拟环境创建失败: {0}', + 'fix_guiconfig_failed': '修复 guiconfig.py 失败: {0}', + 'using_custom_repo': '使用自定义仓库: {0}', + 'using_custom_repo_branch': '使用自定义仓库: {0} (分支: {1})', + 'env_json_defaults': '仓库默认配置已从 env.json 加载: {0}', + 'env_json_fallback': '无法从 {0} 读取仓库默认配置,使用内置源', + 'env_root_exists': '检测到已存在的 RT-Thread ENV: {0}', + 'toolchain_keep_prompt': '保留已下载的工具链(local_pkgs)与配置?[Y/n]: ', + 'toolchain_kept': '保留工具链(local_pkgs)与配置', + 'toolchain_removed': '删除整个现有目录', + 'auto_mode_preserving': '自动模式:保留工具链(local_pkgs)与配置', + 'deleting_item': '正在移除: {0}', + 'installation_cancelled': '安装已取消', + 'installation_failed': '安装失败: {0}', + 'file_delete_failed': '删除文件失败: {0} - {1}', + 'dir_delete_failed': '删除目录失败: {0} - {1}', + 'deleting_env_root': '正在删除现有目录: {0}', + 'deleting_env_root_failed': '删除目录失败: {0}', + 'manual_delete_required': '请手动删除目录: {0},然后重试', + 'start': '[PY]开始 RT-Thread ENV 安装...', + 'using_default_env_root': '使用默认 ENV_ROOT: {0}', + 'env_root_prompt': '请选择怎样处理现存目录(ENV_ROOT)', + 'env_root_default': '[默认: {0}]', + 'python_path_invalid': '路径包含 {0}(Python 路径中不允许)', + 'python_path_creating_dir': '正在创建目录: {0}', + 'python_path_no_permission': '没有目录的写入权限: {0}', + } + } + +# ============================================================================ +# Global Variables +# ============================================================================ + +# ============================================================================ +# Message Functions +# ============================================================================ + + +def get_message(key): + """Get localized message using current language""" + lang = get_language() + return MESSAGES.get(lang, {}).get(key, key) + + +def log_info(key, *args): + """Log info message to stdout""" + msg = get_message(key) + if args: + msg = msg.format(*args) + print(f"\033[0;36m[{get_message('info')}]\033[0m {msg}") + + +def log_success(key, *args): + """Log success message to stdout""" + msg = get_message(key) + if args: + msg = msg.format(*args) + print(f"\033[0;32m[{get_message('success')}]\033[0m {msg}") + + +def log_error(key, *args): + """Log error message to stderr""" + msg = get_message(key) + if args: + msg = msg.format(*args) + print(f"\033[0;31m[{get_message('error')}]\033[0m {msg}", file=sys.stderr) + + +def log_warning(key, *args): + """Log warning message to stderr""" + msg = get_message(key) + if args: + msg = msg.format(*args) + print(f"\033[0;33m[{get_message('warning')}]\033[0m {msg}", file=sys.stderr) + + +def log_raw(key, *args, **kwargs): + """Log raw message using current language""" + msg = get_message(key) + if args: + msg = msg.format(*args) + print(msg, **kwargs) + +# ============================================================================ +# Repository Functions +# ============================================================================ + + +def clone_repository(config, repo_name, url, dest_rel, branch='', depth=1): + """ + Clone Git repository with cleanup on failure + + Args: + config: TouchEnvConfig instance + repo_name: Repository name ('packages', 'sdk', or 'env') + url: Repository URL + dest_rel: Destination path relative to env_root + branch: Optional branch name + depth: Clone depth (default 1 for shallow clone) + + Raises: + RuntimeError: If clone fails + """ + dest_path = os.path.join(config.env_root, dest_rel) + + # If directory exists, verify it's a valid git repository + if os.path.exists(dest_path): + try: + result = subprocess.run( + ['git', 'rev-parse', '--git-dir'], + cwd=dest_path, + capture_output=True, + text=True, + check=True + ) + log_success('dir_exists', dest_path) + return + except subprocess.CalledProcessError: + # Invalid git repository, need to clean up + log_error('invalid_git_repo', dest_path) + shutil.rmtree(dest_path, ignore_errors=True) + + # Clone repository + log_info('cloning', url, dest_path) + + clone_args = ['git', 'clone', '--depth', str(depth)] + if branch: + clone_args.extend(['--branch', branch]) + clone_args.extend([url, dest_path]) + + try: + # Run without capture to show verbose git output + subprocess.run(clone_args, check=True) + log_success('cloned', dest_path) + except subprocess.CalledProcessError as e: + # Clone failed, clean up partial clone + log_error('clone_failed', str(e)) + shutil.rmtree(dest_path, ignore_errors=True) + raise RuntimeError(f"Failed to clone {url}") from e + + +def load_repo_defaults(config): + """ + Load default packages/sdk sources from the downloaded env's env.json + + Args: + config: TouchEnvConfig instance + + Returns: + dict: {'packages': {...}, 'sdk': {...}}, each entry with + 'url', 'branch', 'mirror_url', 'mirror_branch'; + built-in constants where env.json provides nothing + """ + env_json_path = os.path.join( + config.env_root, 'tools', 'scripts', 'env.json') + + defaults = { + 'packages': { + 'url': REPO_PACKAGES_GITHUB, + 'branch': '', + 'mirror_url': REPO_PACKAGES_GITEE, + 'mirror_branch': '', + }, + 'sdk': { + 'url': REPO_SDK_GITHUB, + 'branch': '', + 'mirror_url': REPO_SDK_GITEE, + 'mirror_branch': '', + }, + } + + try: + with open(env_json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + repositories = data.get('repositories', {}) + for repo_name in ('packages', 'sdk'): + entry = repositories.get(repo_name) + if not isinstance(entry, dict) or not entry.get('url'): + continue + source = defaults[repo_name] + source['url'] = entry['url'] + source['branch'] = entry.get('branch', '') + mirror = entry.get('mirror') or {} + if mirror.get('url'): + source['mirror_url'] = mirror['url'] + # a mirror without its own branch inherits the primary branch + source['mirror_branch'] = mirror.get('branch') or source['branch'] + log_info('env_json_defaults', env_json_path) + except (OSError, ValueError): + log_warning('env_json_fallback', env_json_path) + + return defaults + + +def setup_repositories(config): + """ + Setup all repositories (env, packages, sdk) + + The env repository is cloned first: the env.json it carries provides + the default sources for packages and sdk. + + Args: + config: TouchEnvConfig instance + + Raises: + RuntimeError: If any repository setup fails + """ + # Repository destinations + repo_dests = { + 'env': 'tools/scripts', + 'packages': 'packages/packages', + 'sdk': 'packages/sdk' + } + + # Clone env first (bootstrap). Its own source cannot come from its + # env.json (chicken-and-egg), so use built-in constants or --repo-env. + if config.custom_repos and 'env' in config.custom_repos: + env_repo = config.custom_repos['env'] + url = env_repo['url'] + branch = env_repo.get('branch', '') + + if branch: + log_info('using_custom_repo_branch', url, branch) + else: + log_info('using_custom_repo', url) + else: + url = REPO_ENV_GITEE if config.use_cn else REPO_ENV_GITHUB + branch = '' + + clone_repository(config, 'env', url, repo_dests['env'], branch) + + # Default packages/sdk sources from the env just cloned + repo_defaults = load_repo_defaults(config) + + # Clone the remaining repositories + for repo_name in ('packages', 'sdk'): + # Check for custom repository + if config.custom_repos and repo_name in config.custom_repos: + repo_info = config.custom_repos[repo_name] + url = repo_info['url'] + branch = repo_info.get('branch', '') + + if branch: + log_info('using_custom_repo_branch', url, branch) + else: + log_info('using_custom_repo', url) + else: + source = repo_defaults[repo_name] + if config.use_cn: + url = source['mirror_url'] + branch = source['mirror_branch'] + else: + url = source['url'] + branch = source['branch'] + + clone_repository(config, repo_name, url, repo_dests[repo_name], branch) + + # Generate Kconfig file + generate_kconfig_file(config) + + # Copy env scripts + copy_env_scripts(config) + + +def generate_kconfig_file(config): + """Generate Kconfig configuration file""" + packages_dir = os.path.join(config.env_root, 'packages') + os.makedirs(packages_dir, exist_ok=True) + + kconfig_path = os.path.join(packages_dir, 'Kconfig') + kconfig_content = 'source "$PKGS_DIR/packages/Kconfig"\n' + + with open(kconfig_path, 'w', encoding='utf-8') as f: + f.write(kconfig_content) + + log_success('generating_kconfig', kconfig_path) + + # Create local_pkgs directory + local_pkgs_dir = os.path.join(config.env_root, 'local_pkgs') + os.makedirs(local_pkgs_dir, exist_ok=True) + + +def _sh_quote(text): + """Escape single quotes for a single-quoted shell literal.""" + return text.replace("'", "'\\''") + + +def _ps_quote(text): + """Escape single quotes for a single-quoted PowerShell literal.""" + return text.replace("'", "''") + + +def copy_env_scripts(config): + """Install the root activator and seed the user customization file. + + Thin delegator when the cloned env scripts understand RT_ENV_ROOT, + full copy for legacy env versions. The user customization file is + created only when missing: it lives outside the managed repository, + so upgrades and reinstalls never overwrite it. The same generation + logic is mirrored in cmds/cmd_package/cmd_package_upgrade.py + (_write_root_activator) for the pkgs --upgrade refresh path. + """ + scripts_dir = os.path.join(config.env_root, 'tools/scripts') + + if platform.system() == 'Windows': + name = 'env.ps1' + else: + name = 'env.sh' + src = os.path.join(scripts_dir, name) + dst = os.path.join(config.env_root, name) + + if not os.path.exists(src): + return + + with open(src, encoding='utf-8') as f: + inner = f.read() + + if 'RT_ENV_ROOT' in inner: + if name.endswith('.ps1'): + content = ( + "# Generated by the RT-Thread ENV installer. Do not edit.\r\n" + "$env:RT_ENV_ROOT = '{0}'\r\n" + ". '{1}'\r\n".format(_ps_quote(config.env_root), _ps_quote(src)) + ) + encoding = 'utf-8-sig' + else: + content = ( + "# Generated by the RT-Thread ENV installer. Do not edit.\n" + "RT_ENV_ROOT='{0}'\n" + ". '{1}'\n".format(_sh_quote(config.env_root), _sh_quote(src)) + ) + encoding = 'utf-8' + with open(dst, 'w', encoding=encoding, newline='') as f: + f.write(content) + log_success('activator_created', dst) + else: + shutil.copy2(src, dst) + log_success('copied_env_script', dst) + + _seed_user_config(config) + + +def _seed_user_config(config): + """Create the user customization file with examples when missing.""" + if platform.system() == 'Windows': + name = 'env.user.ps1' + encoding = 'utf-8-sig' + template = ( + "# RT-Thread ENV user customization.\r\n" + "# Sourced by env.ps1 on every activation; never overwritten\r\n" + "# by upgrades or reinstalls. Add your settings below, e.g.:\r\n" + "# $env:RTT_EXEC_PATH = 'C:\\gcc-arm\\bin'\r\n" + ) + else: + name = 'env.user.sh' + encoding = 'utf-8' + template = ( + "# RT-Thread ENV user customization.\n" + "# Sourced by env.sh on every activation; never overwritten\n" + "# by upgrades or reinstalls. Add your settings below, e.g.:\n" + "# export RTT_EXEC_PATH=/opt/gcc-arm/bin\n" + "# alias pkgs='rt-env pkg'\n" + ) + dst = os.path.join(config.env_root, name) + if os.path.exists(dst): + return + with open(dst, 'w', encoding=encoding, newline='') as f: + f.write(template) + log_success('user_config_created', dst) + +# ============================================================================ +# Virtual Environment Functions +# ============================================================================ + + +def create_venv(config): + """ + Create Python virtual environment + + Args: + config: TouchEnvConfig instance + + Raises: + RuntimeError: If venv creation fails + """ + venv_path = config.venv_dir + + if os.path.exists(venv_path): + log_success('venv_exists') + return + + log_info('creating_venv', venv_path) + + try: + import venv + venv.create(venv_path, with_pip=True) + log_success('venv_created') + except (OSError, PermissionError, ValueError) as e: + log_error('venv_creation_failed', str(e)) + raise RuntimeError(f"Failed to create virtual environment: {e}") from e + + +def get_python_executable(config): + """ + Get virtual environment Python executable path + + Args: + config: TouchEnvConfig instance + + Returns: + Path to Python executable + """ + if platform.system() == 'Windows': + return os.path.join(config.venv_dir, 'Scripts', 'python.exe') + else: + return os.path.join(config.venv_dir, 'bin', 'python') + +# ============================================================================ +# Package Installation Functions +# ============================================================================ + + +def install_packages(config): + """ + Install Python packages + + Args: + config: TouchEnvConfig instance + + Raises: + RuntimeError: If package installation fails + """ + python_exe = get_python_executable(config) + + if not os.path.exists(python_exe): + log_error('venv_not_found') + raise RuntimeError("Virtual environment not found") + + # Upgrade pip + log_info('upgrading_pip') + subprocess.run( + [python_exe, '-m', 'pip', 'install', '--upgrade', 'pip'], + check=True + ) + + # Build pip install arguments + pip_args = [python_exe, '-m', 'pip', 'install'] + + # Add mirror source + if config.use_cn: + log_info('using_cn_mirror') + log_info('using_pypi_mirror', PYPI_MIRROR_CN) + pip_args.extend(['--index-url', PYPI_MIRROR_CN]) + + # Install rt-env package (editable mode) + pip_args.extend(['-e', config.scripts_dir]) + + # Install pyocd + pip_args.append('pyocd') + + # Execute installation + log_info('installing_packages') + try: + subprocess.run(pip_args, check=True) + log_success('installed_packages') + except subprocess.CalledProcessError as e: + log_error('package_install_failed', str(e)) + raise RuntimeError(f"Package installation failed: {e}") from e + + # Fix guiconfig.py missing import re issue + fix_guiconfig_import(config) + + +def fix_guiconfig_import(config): + """ + Fix guiconfig.py missing import re issue + + Args: + config: TouchEnvConfig instance + """ + # Direct path for Windows and Unix-like systems + if platform.system() == 'Windows': + guiconfig_path = os.path.join( + config.venv_dir, 'Lib', 'site-packages', 'guiconfig.py') + else: + guiconfig_path = os.path.join( + config.venv_dir, 'lib', f'python{sys.version_info.major}.{sys.version_info.minor}', 'site-packages', 'guiconfig.py') + + if not os.path.exists(guiconfig_path): + return + + try: + with open(guiconfig_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Check if import re already exists + if 'import re' not in content: + # Insert import re at the appropriate location + lines = content.split('\n') + + # Find the first non-comment, non-docstring line + # Skip shebang, encoding, and docstring + import_index = 0 + in_docstring = False + docstring_delimiter = None + + for i, line in enumerate(lines): + stripped = line.strip() + + # Skip empty lines and comments + if not stripped or stripped.startswith('#'): + continue + + # Handle docstring + if (stripped.startswith('"""') or stripped.startswith("'''")): + if in_docstring: + if stripped.startswith(docstring_delimiter) and len(stripped) > 3: + in_docstring = False + else: + in_docstring = True + docstring_delimiter = stripped[:3] + continue + + if in_docstring: + continue + + # Found first actual code line + # Look for the first import or from statement + if line.startswith('import ') or line.startswith('from '): + import_index = i + 1 + else: + import_index = i + break + + # Insert import re at the calculated position + lines.insert(import_index, 'import re') + content = '\n'.join(lines) + + with open(guiconfig_path, 'w', encoding='utf-8') as f: + f.write(content) + + log_success('fixed_guiconfig') + except (IOError, PermissionError, UnicodeDecodeError, UnicodeEncodeError) as e: + # Fix failure should not interrupt installation + log_error('fix_guiconfig_failed', str(e)) + +# ============================================================================ +# Existing Environment Handling +# ============================================================================ + + +def check_existing_env(config): + """ + Handle an existing ENV installation based on the keep-sdk decision + + Args: + config: TouchEnvConfig instance + + Raises: + SystemExit: If user cancels installation + """ + if not os.path.exists(config.env_root): + return + + log_raw(get_message('env_root_exists').format(config.env_root)) + print() + + # Decide whether to keep toolchains: argument first, then prompt, auto-mode defaults to keep + keep = config.keep_sdk + if keep is None: + if config.auto_mode: + keep = True + log_info('auto_mode_preserving') + else: + response = input(get_message('toolchain_keep_prompt')).strip().lower() + keep = response not in ('n', 'no') + else: + keep = (keep == 'yes') + + if keep: + log_info('toolchain_kept') + + # Preserve local_pkgs/ and tools/scripts/cmds/.config in place + config_path = os.path.join(config.env_root, 'tools', 'scripts', 'cmds', '.config') + config_saved = None + if os.path.isfile(config_path): + try: + with open(config_path, 'rb') as f: + config_saved = f.read() + except OSError: + config_saved = None + + for rel in ('venv', '.venv', 'tools', 'packages'): + target = os.path.join(config.env_root, rel) + if os.path.exists(target): + log_info('deleting_item', rel) + try: + _safe_remove_tree(target) + except (OSError, PermissionError): + pass # installation rebuilds these paths + + if config_saved is not None: + try: + os.makedirs(os.path.dirname(config_path), exist_ok=True) + with open(config_path, 'wb') as f: + f.write(config_saved) + except OSError: + pass # config restore is best-effort + else: + log_info('toolchain_removed') + if os.path.exists(config.env_root): + log_info('deleting_env_root', config.env_root) + try: + _safe_remove_tree(config.env_root) + except (OSError, PermissionError) as e: + log_error('deleting_env_root_failed', str(e)) + log_warning('manual_delete_required', config.env_root) + sys.exit(1) + + +def _safe_remove(path, name): + """ + Safely remove a file or directory with error handling + + Args: + path: Full path to the file or directory + name: Name of the item (for logging) + + Returns: + bool: True if removal succeeded, False otherwise + """ + try: + if os.path.isfile(path) or os.path.islink(path): + os.remove(path) + elif os.path.isdir(path): + shutil.rmtree(path) + log_info('item_deleted', name) + return True + except (OSError, PermissionError) as e: + if os.path.isfile(path) or os.path.islink(path): + log_error('file_delete_failed', name, str(e)) + else: + log_error('dir_delete_failed', name, str(e)) + return False + + +def _safe_remove_tree(path): + """ + Safely remove a directory tree, trying multiple methods + + Args: + path: Path to directory to remove + """ + if not os.path.exists(path): + return + + # Method 1: Try rmtree with ignore_errors first + shutil.rmtree(path, ignore_errors=True) + + # Method 2: If still exists, retry with onerror handler + if os.path.exists(path): + def onerror(func, path, exc_info): + # Try to change permissions and retry + try: + os.chmod(path, 0o700) + if os.path.isdir(path): + shutil.rmtree(path, ignore_errors=True) + else: + os.remove(path) + except Exception: + pass # Ignore if still fails + + shutil.rmtree(path, onerror=onerror) + + # Method 3: If still exists, list and delete individually + if os.path.exists(path): + for item in os.listdir(path): + item_path = os.path.join(path, item) + try: + if os.path.isfile(item_path) or os.path.islink(item_path): + os.chmod(item_path, 0o700) + os.remove(item_path) + elif os.path.isdir(item_path): + _safe_remove_tree(item_path) + except Exception: + pass # Ignore if fails + + # Finally try to remove the directory itself + try: + os.rmdir(path) + except Exception: + pass # Ignore if fails + + +# ============================================================================ +# User Interaction Functions +# ============================================================================ + + + +def show_next_steps(config): + """ + Show installation completion and next steps + + Args: + config: TouchEnvConfig instance + """ + print() + print("=" * 60) + log_success('setup_complete') + print("=" * 60) + print() + log_info('next_steps') + print() + + # Activate environment + log_raw('activate_env') + if platform.system() == 'Windows': + print(f" . {config.env_root}\\env.ps1") + else: + print(f" source {config.env_root}/env.sh") + print() + + # Add to profile + log_raw('add_to_profile') + if platform.system() == 'Windows': + print(f" echo '. {config.env_root}\\env.ps1' >> $PROFILE") + print(f" . $PROFILE") + else: + shell = os.path.basename(os.getenv('SHELL', 'bash')) + profile_file = '~/.zshrc' if 'zsh' in shell else '~/.bashrc' + print(f" echo 'source {config.env_root}/env.sh' >> {profile_file}") + print(f" source {profile_file}") + print() + + # Install toolchain + log_raw('install_toolchain') + print(f" {get_message('install_toolchain_cmd')}") + print() + + # Available commands + log_raw('after_activation') + print(f"{get_message('menuconfig')}") + print(f"{get_message('menuconfig_s')}") + print(f"{get_message('pkgs')}") + print(f"{get_message('scons')}") + print(f"{get_message('sdk')}") + print(f"{get_message('plugin')}") + print(f"{get_message('webui')}") + print() + +# ============================================================================ +# Argument Parsing +# ============================================================================ + + +def parse_repo_url(url): + """ + Parse repository URL and extract branch from fragment (#branch) + + Args: + url: Repository URL with optional branch fragment (e.g., https://github.com/user/repo.git#branch1) + + Returns: + dict: {'url': 'https://github.com/user/repo.git', 'branch': 'branch1'} + or {'url': 'https://github.com/user/repo.git'} if no branch specified + """ + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(url) + repo_info = {'url': urlunparse(parsed._replace(fragment=''))} + + if parsed.fragment: + repo_info['branch'] = parsed.fragment + + return repo_info + + +def prompt_env_root(default_env_root, language='en'): + """ + Prompt user to enter env-root directory + + Args: + default_env_root: Default installation directory + language: Language code ('en' or 'zh') + + Returns: + str: User input env-root directory + """ + # Set language for messages + set_language(language) + + env_root = "" + is_valid = False + + while not is_valid: + # Display prompt with default value + prompt_msg = get_message('env_root_prompt') + default_msg = get_message('env_root_default').format(default_env_root) + print(f"{prompt_msg} {default_msg}", end=' ') + env_root = input().strip() + + # Use default if input is empty + if not env_root: + env_root = default_env_root + + # Expand user home directory + env_root = os.path.expanduser(env_root) + + # Check path format (spaces, non-ASCII characters) + if ' ' in env_root: + log_error('python_path_invalid', 'spaces') + continue + if any(ord(c) > 127 for c in env_root): + log_error('python_path_invalid', 'non-ASCII characters') + continue + + # Check if parent directory exists or can be created + parent_dir = os.path.dirname(env_root) + if parent_dir and not os.path.exists(parent_dir): + log_info('python_path_creating_dir', parent_dir) + try: + os.makedirs(parent_dir, exist_ok=True) + except Exception: + log_error('python_path_no_permission', parent_dir) + continue + + # Check write permission + if parent_dir: + test_file = os.path.join(parent_dir, '.__write_test__') + try: + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + except Exception: + log_error('python_path_no_permission', parent_dir) + continue + + is_valid = True + + return env_root + + +def prompt_env_root_if_needed(config, args): + """ + Prompt for env-root if needed (interactive mode, not explicitly specified) + + Args: + config: TouchEnvConfig instance + args: Parsed command line arguments + """ + if not config.auto_mode: + # Check if --env-root was explicitly provided + import sys + has_explicit_env_root = False + for i in range(len(sys.argv)): + if sys.argv[i] == '--env-root' and i + 1 < len(sys.argv): + has_explicit_env_root = True + break + elif sys.argv[i].startswith('--env-root='): + has_explicit_env_root = True + break + + if not has_explicit_env_root: + default_env_root = os.path.expanduser(DEFAULT_ENV_ROOT) + config.env_root = prompt_env_root(default_env_root, config.language) + # Recompute paths with new env_root + config._compute_paths() + + +def parse_arguments(): + """Parse command line arguments""" + parser = argparse.ArgumentParser( + description='RT-Thread ENV Setup Script', + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument( + '--env-root', + required=False, + default=os.path.expanduser(DEFAULT_ENV_ROOT), + help='Installation root directory (default: ~/.rt-env)' + ) + parser.add_argument( + '--use-cn', + action='store_true', + help='Use China mirror (Gitee, TUNA PyPI)' + ) + parser.add_argument( + '--language', + choices=['en', 'zh'], + default='en', + help='Language (en/zh)' + ) + parser.add_argument( + '--auto-mode', + action='store_true', + help='Auto-install without prompts' + ) + parser.add_argument( + '--keep-sdk', + choices=['yes', 'no'], + default=None, + help='Keep downloaded toolchains (local_pkgs) and config when ENV_ROOT exists (default: yes; prompt if omitted)' + ) + parser.add_argument( + '--repo-env', + type=str, + default='', + help='Custom env repository URL' + ) + parser.add_argument( + '--repo-packages', + type=str, + default='', + help='Custom packages repository URL' + ) + parser.add_argument( + '--repo-sdk', + type=str, + default='', + help='Custom sdk repository URL' + ) + + args = parser.parse_args() + + # Build custom_repos dictionary from individual arguments + args.custom_repos = {} + if args.repo_env: + args.custom_repos['env'] = parse_repo_url(args.repo_env) + if args.repo_packages: + args.custom_repos['packages'] = parse_repo_url(args.repo_packages) + if args.repo_sdk: + args.custom_repos['sdk'] = parse_repo_url(args.repo_sdk) + + return args + +# ============================================================================ +# Main Execution Function +# ============================================================================ + + +def run_touch_env(args): + """ + Main execution function + + Args: + args: Parsed command line arguments + + Returns: + int: Exit code (0 for success, non-zero for failure) + """ + config = None + + try: + # Step 0: Initialize configuration + config = TouchEnvConfig(args) + + # Step 1: Interactive mode: prompt for env-root if needed + prompt_env_root_if_needed(config, args) + + # Step 2: Handle existing ENV (--keep-sdk decision) + check_existing_env(config) + + # Step 3: Setup repositories + setup_repositories(config) + + # Step 4: Create virtual environment + create_venv(config) + + # Step 5: Install packages (includes pyocd) + install_packages(config) + + # Step 6: Show next steps + show_next_steps(config) + + return 0 + + except KeyboardInterrupt: + print() + log_info('installation_cancelled') + return 1 + except Exception as e: + print() + log_error('installation_failed', str(e)) + return 1 + + +def main(): + """Main entry point""" + try: + args = parse_arguments() + # Set language before logging + set_language(args.language) + log_info('start') + result = run_touch_env(args) + sys.exit(result) + except KeyboardInterrupt: + print() + log_info('installation_cancelled') + sys.exit(1) + except Exception as e: + print() + log_error('installation_failed', str(e)) + sys.exit(1) + +# ============================================================================ +if __name__ == '__main__': + main() diff --git a/touch_env.ps1 b/touch_env.ps1 deleted file mode 100644 index 102d806a..00000000 --- a/touch_env.ps1 +++ /dev/null @@ -1,46 +0,0 @@ -$DEFAULT_RTT_PACKAGE_URL = "https://github.com/RT-Thread/packages.git" -$ENV_URL = "https://github.com/RT-Thread/env.git" -$SDK_URL = "https://github.com/RT-Thread/sdk.git" - -try { - $useGitee = (Invoke-RestMethod -Uri "https://ipinfo.io/json" -UseBasicParsing -TimeoutSec 3).country -eq "CN" -} catch { - $useGitee = $false -} - -if ($useGitee) { - $DEFAULT_RTT_PACKAGE_URL = "https://gitee.com/RT-Thread-Mirror/packages.git" - $ENV_URL = "https://gitee.com/RT-Thread-Mirror/env.git" - $SDK_URL = "https://gitee.com/RT-Thread-Mirror/sdk.git" -} - -$env_dir = "$HOME\.env" - -if (Test-Path -Path $env_dir) { - $option = Read-Host ".env directory already exists. Would you like to remove and recreate .env directory? (Y/N) " -} -if (( $option -eq 'Y' ) -or ($option -eq 'y')) { - Get-ChildItem $env_dir -Recurse | Remove-Item -Force -Recurse - rm -r $env_dir -} - -if (!(Test-Path -Path $env_dir)) { - echo "creating .env folder!" - $package_url = $DEFAULT_RTT_PACKAGE_URL - mkdir $env_dir | Out-Null - mkdir $env_dir\local_pkgs | Out-Null - mkdir $env_dir\packages | Out-Null - mkdir $env_dir\tools | Out-Null - git clone $package_url $env_dir/packages/packages --depth=1 - echo 'source "$PKGS_DIR/packages/Kconfig"' | Out-File -FilePath $env_dir/packages/Kconfig -Encoding ASCII - git clone $SDK_URL $env_dir/packages/sdk --depth=1 - git clone $ENV_URL $env_dir/tools/scripts --depth=1 - # Use the Gitee mirror for the initial download in China, then keep the - # canonical GitHub remotes for subsequent updates and metadata. - git -C $env_dir/packages/packages remote set-url origin https://github.com/RT-Thread/packages.git - git -C $env_dir/packages/sdk remote set-url origin https://github.com/RT-Thread/sdk.git - git -C $env_dir/tools/scripts remote set-url origin https://github.com/RT-Thread/env.git - copy $env_dir/tools/scripts/env.ps1 $env_dir/env.ps1 -} else { - echo ".env folder has exsited. Jump this step." -} diff --git a/touch_env.py b/touch_env.py deleted file mode 100644 index e69de29b..00000000 diff --git a/touch_env.sh b/touch_env.sh deleted file mode 100755 index 1174f5e3..00000000 --- a/touch_env.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash - -DEFAULT_RTT_PACKAGE_URL=https://github.com/RT-Thread/packages.git -ENV_URL=https://github.com/RT-Thread/env.git -SDK_URL="https://github.com/RT-Thread/sdk.git" - -if [ "$(wget -qO- --timeout=3 https://ipinfo.io/country 2>/dev/null)" = "CN" ]; then - DEFAULT_RTT_PACKAGE_URL=https://gitee.com/RT-Thread-Mirror/packages.git - ENV_URL=https://gitee.com/RT-Thread-Mirror/env.git - SDK_URL="https://gitee.com/RT-Thread-Mirror/sdk.git" -fi - -env_dir=$HOME/.env -if [ -d $env_dir ]; then - read -p '.env directory already exists. Would you like to remove and recreate .env directory? (Y/N) ' option - if [[ "$option" =~ [Yy*] ]]; then - rm -rf $env_dir - fi -fi - -if ! [ -d $env_dir ]; then - package_url=${RTT_PACKAGE_URL:-$DEFAULT_RTT_PACKAGE_URL} - mkdir $env_dir - mkdir $env_dir/local_pkgs - mkdir $env_dir/packages - mkdir $env_dir/tools - git clone $package_url $env_dir/packages/packages --depth=1 - echo 'source "$PKGS_DIR/packages/Kconfig"' >$env_dir/packages/Kconfig - git clone $SDK_URL $env_dir/packages/sdk --depth=1 - git clone $ENV_URL $env_dir/tools/scripts --depth=1 - # Use the Gitee mirror for the initial download in China, then keep the - # canonical GitHub remotes for subsequent updates and metadata. - git -C $env_dir/packages/packages remote set-url origin https://github.com/RT-Thread/packages.git - git -C $env_dir/packages/sdk remote set-url origin https://github.com/RT-Thread/sdk.git - git -C $env_dir/tools/scripts remote set-url origin https://github.com/RT-Thread/env.git - if ! cp $env_dir/tools/scripts/env.sh $env_dir/env.sh; then - echo "Failed to set up Env activation script." - rm -rf $env_dir - exit 1 - fi -fi diff --git a/version.py b/version.py deleted file mode 100644 index 0bdd79c3..00000000 --- a/version.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- coding:utf-8 -*- -# -# File : version.py -# This file is part of RT-Thread RTOS -# COPYRIGHT (C) 2006 - 2018, RT-Thread Development Team -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -# -# Change Logs: -# Date Author Notes -# 2025-06-23 Dongly Add get_rt_env_version function - -import json -import os -import platform - -def get_rt_env_version(): - rt_env_ver = None - rt_env_name = None - - # try to read env.json to get information - try: - # Get the directory where this script is located - script_dir = os.path.dirname(os.path.abspath(__file__)) - env_json_path = os.path.join(script_dir, 'env.json') - - # If not found in script directory, try ENV_ROOT - if not os.path.exists(env_json_path): - env_root = os.getenv("ENV_ROOT") - if env_root is None: - if platform.system() != 'Windows': - env_root = os.path.join(os.getenv('HOME'), '.env') - else: - env_root = os.path.join(os.getenv('USERPROFILE'), '.env') - env_json_path = os.path.join(env_root, 'tools', 'scripts', 'env.json') - - with open(env_json_path, 'r') as file: - env_data = json.load(file) - rt_env_name = env_data['name'] - rt_env_ver = env_data['version'] - except Exception as e: - # Only print error if running interactively (not imported) - if __name__ == '__main__': - print("Failed to read env.json: %s" % str(e)) - - if rt_env_name is None: - rt_env_name = 'RT-Thread Env Tool' - if rt_env_ver is None: - # use the default 'v2.0.1' - rt_env_ver = 'v2.0.1' - - return rt_env_name, rt_env_ver