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
+

-> 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)
+[](https://www.python.org/)
+[]()
+
+**[简体中文](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 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)
+
+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
+[](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 开发环境
+
+[](LICENSE)
+[](https://www.python.org/)
+[]()
+
+**[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 仓库|[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** 许可证开源。
+
+---
+
+
+
+## 🤝 贡献者
+
+感谢所有为 RT-Thread Env 项目做出贡献的开发者!
+
+[](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