From a49ff64632e3af7bb82d1b2ffc869375be2e050b Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 16:19:17 +0800 Subject: [PATCH 01/39] test: add official-installation regressions and concise usage docs --- .github/workflows/_prepare-official.yml | 46 ++++++++ .github/workflows/test.yml | 24 +++- README.md | 150 +++++++----------------- docs/maintenance.md | 40 +++++++ tests/test-official-policy.ps1 | 74 ++++++++++++ tests/test_official_policy.py | 64 ++++++++++ tools/_apply_official_fix.py | 146 +++++++++++++++++++++++ 7 files changed, 435 insertions(+), 109 deletions(-) create mode 100644 .github/workflows/_prepare-official.yml create mode 100644 docs/maintenance.md create mode 100644 tests/test-official-policy.ps1 create mode 100644 tests/test_official_policy.py create mode 100644 tools/_apply_official_fix.py diff --git a/.github/workflows/_prepare-official.yml b/.github/workflows/_prepare-official.yml new file mode 100644 index 0000000..f8b7fb5 --- /dev/null +++ b/.github/workflows/_prepare-official.yml @@ -0,0 +1,46 @@ +name: Prepare official installer fixes +on: + push: + branches: [codex/official-installers-20260920] + paths: [tools/_apply_official_fix.py, .github/workflows/_prepare-official.yml] +permissions: + contents: write +jobs: + prepare: + if: github.repository == 'TokenNotIncluded/lmm-scripts' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24.21.0 + - name: Apply and test source edits + run: | + python3 tools/_apply_official_fix.py + python3 tools/generate.py + python3 tools/generate.py --check + python3 tests/test_installers.py + python3 tests/test_official_policy.py + shellcheck *.sh + rm tools/_apply_official_fix.py + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add templates versions.json '*.sh' '*.ps1' tests tools/_apply_official_fix.py + git commit -m 'fix: follow Pi installation policy and check platform prerequisites' + python3 - <<'PY' + from pathlib import Path + import re, subprocess + path = Path('tools/generate_menus.py') + revision = subprocess.check_output(['git','rev-parse','HEAD'], text=True).strip() + text, count = re.subn(r"revision='[0-9a-f]{40}'", f"revision='{revision}'", path.read_text()) + assert count == 1 + path.write_text(text) + PY + python3 tools/generate_menus.py + python3 tools/generate_menus.py --check + git add tools/generate_menus.py menu.sh menu.ps1 + git commit -m 'fix: pin tool menus to corrected installers' + git push origin HEAD:codex/official-installers-20260920 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ba5f87..18b2165 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,8 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 with: node-version: 24.21.0 @@ -29,19 +31,35 @@ jobs: if ($errors.Count) { throw ($errors | Out-String) } ./menu.ps1 -Help - run: python tools/generate.py --check + - run: python tools/generate_menus.py --check - name: Shell fault-injection tests if: runner.os != 'Windows' - run: python3 tests/test_installers.py + run: | + python3 tests/test_installers.py + python3 tests/test_official_policy.py - name: ShellCheck if: runner.os == 'Linux' run: shellcheck *.sh - name: PowerShell checks shell: pwsh - run: ./tests/test-powershell.ps1 + run: | + ./tests/test-powershell.ps1 + ./tests/test-official-policy.ps1 - name: Windows PowerShell 5.1 checks if: runner.os == 'Windows' shell: powershell - run: .\tests\test-powershell.ps1 + run: | + .\tests\test-powershell.ps1 + .\tests\test-official-policy.ps1 + - name: Install real Pi on Unix with lifecycle scripts disabled + if: runner.os != 'Windows' + shell: bash + run: | + export PI_CODING_AGENT_DIR="$RUNNER_TEMP/pi-profile" + npm_config_ignore_scripts=true bash pi.sh --root "$RUNNER_TEMP/lmm clients" --no-path --network official + "$RUNNER_TEMP/lmm clients/bin/pi" --version + "$RUNNER_TEMP/lmm clients/bin/pi" list + bash pi.sh --root "$RUNNER_TEMP/lmm clients" --check - name: Install real CLI on Unix if: runner.os != 'Windows' shell: bash diff --git a/README.md b/README.md index ad66ece..846fa03 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,82 @@ -# LMM 工具菜单 +# LMM 安装脚本 -普通用户只需执行对应系统的一条命令,按数字选择 Pi、DSH 或 LMM CLI,再选择安装、更新、检查、启动和使用说明。 +安装 Pi、DeepSeek Harness(DSH)及其 LMM 插件,也可安装 LMM CLI 预览版。 + +## 安装 + +Linux / macOS: -**Linux / macOS** ```sh curl -fsSL https://api.lmm.best/scripts/menu.sh | bash ``` -**Windows PowerShell** +Windows PowerShell 5.1+: + ```powershell irm https://api.lmm.best/scripts/menu.ps1 | iex ``` -菜单需要交互终端,默认不改 PATH、不自动登录。网络选项包括自动、官方源、国内镜像;实际安装继续使用带缓存、重试和校验的安装器。菜单先完整下载并校验固定版本的底层脚本,校验不符会使用固定 Git 提交的备用地址,绝不执行校验失败的内容。临时菜单文件退出时清理,安装器缓存保留。 +选择工具,再选择安装、检查或启动。默认不改 PATH、不启动工具、不登录账号。菜单入口执行远程代码;需要先审查时,下载脚本后再运行。 -维护者通过 `templates/menu.*.in` 和 `tools/generate_menus.py` 生成菜单;升级底层脚本时须更新生成器中的固定提交并重新生成,避免菜单与安装器版本意外混用。PowerShell 菜单带 UTF-8 BOM,兼容 Windows PowerShell 5.1 中文。 +Pi 在 Windows 上需要 **Git Bash**,或 Pi 设置中的有效 `shellPath`。脚本只检查,不覆盖你的设置,也不自动安装系统软件。 -底层脚本供菜单调用和自动化使用,公共页面只展示以上两个菜单入口。 +## 安装后怎么用 ---- +没有加入 PATH 时,用安装结束打印的完整路径代替下方的 `pi`、`dsh`、`lmm`。 -# LMM 安装与使用脚本 +| 工具 | 启动与登录 | 常用操作 | +|---|---|---| +| Pi | `pi` → `/login` → LMM → 浏览器授权 | `/model` 选模型;`pi -c` 继续会话;`pi list` 查看插件 | +| DSH | `dsh web` → Settings → Models → LMM → Sign in with LMM | `dsh web --no-open` 不自动开浏览器;其他参数见 `dsh --help` | +| LMM CLI | `lmm login` | `lmm catalog pi`、`lmm status`、`lmm doctor --report`、`lmm models --json` | -公开入口:[api.lmm.best/scripts](https://api.lmm.best/scripts)。所有根目录脚本都可以单独下载运行,不依赖远程 `source`、API Key 或管理员权限。 +DSH 插件按 profile 安装,默认 `web`。使用 `headless` 前,先在相同 `DSH_HOME` 的 Web profile 完成登录。不要把 Pi、DSH 的凭据文件复制给其他客户端。 -| 脚本 | 完成的工作 | -|---|---| -| `pi.sh` / `pi.ps1` | 检查/补齐 Node.js 和 npm,安装已验证版本的 Pi,安装 LMM provider,创建启动入口,引导 `/login` 和 `/model` | -| `dsh.sh` / `dsh.ps1` | 检查/补齐 Node.js 与私有 pnpm,安装 DSH,下载并校验编译好的 LMM 插件,装进指定 profile,引导网页登录 | -| `lmm.sh` / `lmm.ps1` | 安装 LMM CLI 预编译包;也可显式使用已有 Rust 工具链从 crates.io 构建 | -| `lmm-use.sh` / `lmm-use.ps1` | 软件目录、状态、诊断、接入预览、登录、模型目录和退出的快捷入口,保留 CLI 的真实退出码 | - -## 开始使用 +LMM CLI 的实际软件安装、接入、恢复尚未完成;`lmm setup pi --dry-run` 仅预览。`doctor` / `setup --dry-run` 返回 3 时不代表全部成功。Linux 登录需要可用的 Secret Service;SSH 或容器中不一定具备。 -Linux/macOS(以 Pi 为例,文件名可换成 `dsh.sh`、`lmm.sh`): +## 直接运行与更新 ```sh curl -fsSLo pi.sh https://api.lmm.best/scripts/pi.sh -bash pi.sh -# 网络较慢时 -bash pi.sh --network china -# 只检查,不下载、不修改文件 -bash pi.sh --check -# 更新到当前脚本固定的已验证版本 -bash pi.sh --update +bash pi.sh # 安装 +bash pi.sh --check # 只检查可执行程序,不代表登录成功 +bash pi.sh --update # 重装脚本固定版本,不追踪上游 latest +bash pi.sh --launch # 安装后启动 +bash pi.sh --add-path # 明确允许加入用户 PATH +bash pi.sh --network china # 镜像优先;官方源可用 official +bash pi.sh --help # 全部参数 ``` -Windows PowerShell 5.1+: +Windows 对应参数为 `-Check`、`-Update`、`-Launch`、`-AddPath`、`-Network china`、`-Help`。例如: ```powershell Invoke-WebRequest https://api.lmm.best/scripts/pi.ps1 -OutFile pi.ps1 powershell -ExecutionPolicy Bypass -File .\pi.ps1 -powershell -ExecutionPolicy Bypass -File .\pi.ps1 -Network china powershell -ExecutionPolicy Bypass -File .\pi.ps1 -Check ``` -也可以使用网页的操作系统按钮复制完整命令。安装过程不需要输入授权码或 API Key;首次账号授权由 Pi、DSH 或 LMM CLI 的原生登录流程完成。 - -默认只安装,不自动启动交互界面或模型任务。`--launch` / `-Launch` 可以安装后启动。DSH 默认 Web profile;`--profile headless` / `-Profile headless` 仅给该 profile 安装插件,登录应先通过共享同一个 `DSH_HOME` 的 Web profile 完成。 - -## 安装位置、重复运行与恢复 - -- Linux/macOS:`${XDG_DATA_HOME:-~/.local/share}/lmm-tools`。 -- Windows:`%LOCALAPPDATA%\lmm-tools`。 -- 自定义:环境变量 `LMM_INSTALL_ROOT`,或 `--root PATH` / `-Root PATH`。 -- 客户端安装到按版本隔离的目录。仅在客户端和插件步骤成功后切换管理的启动入口;不会覆盖系统 Node 或系统全局 npm 包。 -- 重复运行复用已安装客户端和下载缓存,并通过原生包管理器确认插件。`--update` / `-Update` 重新安装脚本固定的版本,保留旧的版本目录。 -- 并发安装由锁拒绝。Unix 仅自动回收标记明确、进程已不存在的旧锁;未知锁保留供检查。Windows 使用操作系统文件锁,进程退出会释放。 -- 默认不修改 PATH 或终端配置;需要时显式传 `--add-path` / `-AddPath`。Unix 追加带标记的段,已有启动文件先备份、不重复追加;符号链接文件不自动修改。Windows 只追加当前用户 PATH。`--no-path` / `-NoPath` 仍可明确保持不变。 -- 修改 PATH 不会改变父终端的环境;安装结束会打印当前终端可立即使用的完整路径和 PATH 命令。使用了 `--add-path` / `-AddPath` 后,新开终端可直接运行工具名;否则使用打印的完整路径。 -- 缓存保留在安装目录 `cache` 下;npm 优先复用用户已有 npm 缓存。不会自动清空其他软件的缓存、配置、凭据或会话。 - -如果某步失败,脚本返回非零并指出阶段。保留的旧启动入口不会因插件下载失败而被新入口覆盖。安装器并不声称能回滚第三方包管理器的全部内部状态。 - -支持构建白名单的 npm 会显式放行 Pi/DSH 已知必需的原生依赖构建,不使用“允许全部构建”开关;已有 `ignore-scripts=true` 配置会明确阻止安装而不会被偷偷覆盖。预编译 DSH 插件安装跳过其依赖的非必需生命周期脚本,避免 pnpm 的交互批准卡住管道安装。 - -## 网络慢、代理与镜像 - -`--network auto` / `-Network auto` 会先比较公共下载源的连接延迟,下载时另外监控低速和停滞;它不是“HEAD 快就一定整文件快”的假设。 - -- 文件下载:10 秒连接超时,20 秒低于 16 KiB/s 会中断,单次最多 10 分钟;每个源有有界重试,失败后换源。 -- 部分文件保留用于断点续传;服务器不支持 Range 时重试完整下载。切换来源时不混用未验证的部分文件。 -- 完整缓存每次都重新验证 SHA-256。校验不符的文件不会解压或执行。 -- Node、LMM CLI 和 DSH 插件使用 `versions.json` 中固定的官方发布文件哈希。镜像只提供相同字节,不能改变期望哈希。 -- Node 备用源为 npmmirror;GitHub 备用公共代理为 ghfast.top、ghproxy.net。它们可能不可用,失败后仍尝试其他源。 -- `official` 仅使用官方下载地址;`china` 优先尝试镜像后回退官方。已有自定义 npm registry 配置优先保留;未自定义时根据模式选择 npm 官方或 npmmirror,并仅作用于安装进程。 -- npm 设置有界请求超时和重试,自动选择的 registry 失败时尝试另一个;npm 依赖仍遵循该 registry 的包元数据/integrity 信任体系,不能把它等同于安装器内置的独立 SHA-256 固定值。 -- 继承 `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`、系统代理与 CA 环境设置,不关闭 TLS 校验,不写全局 registry/proxy 配置。curl 使用 `-q`,不读取可能包含全局认证头的 `.curlrc`;请用代理/CA 环境变量配置下载器。 - -示例: - -```sh -HTTPS_PROXY=http://127.0.0.1:7890 bash dsh.sh --network auto -bash dsh.sh --network official --no-install-node -``` - -无法联网时,已安装的 CLI 和完整的已验证下载缓存仍可复用;尚未缓存的 npm 依赖仍需要网络。脚本不会把下载失败当作安装成功。 - -## LMM CLI 当前功能与平台边界 +安装 DSH 或 LMM CLI 时,把文件名中的 `pi` 换成 `dsh` 或 `lmm`。DSH 支持 `--profile headless` / `-Profile headless`。安装脚本固定的版本见 [versions.json](versions.json);兼容版本升级时同时更新宿主和插件,不单独追新宿主。 -LMM CLI **仍是 0.1.0 开发预览**。它能进行软件发现、状态线索查询、只读诊断、`setup --dry-run`、浏览器 OAuth 登录和模型/价格目录读取。CLI 内部的实际软件安装、接入、同步、恢复等尚未实现,安装器不会伪装这些功能已完成。 +## 环境与故障 -预编译包来自成功的 [三平台 CI 运行](https://github.com/TokenNotIncluded/api.lmm.best/actions/runs/35434517044): +客户端采用用户目录下的独立安装,不覆盖系统 Node 或全局 npm 包。默认目录:Unix 为 `${XDG_DATA_HOME:-~/.local/share}/lmm-tools`,Windows 为 `%LOCALAPPDATA%\lmm-tools`;可用 `LMM_INSTALL_ROOT` 或 `--root` / `-Root` 修改。 -- Linux x64:要求 glibc 2.39+;不适用于 Alpine/musl 或较旧的 glibc。 -- macOS arm64。 -- Windows x64。 +Pi 按官方文档使用 `npm install --ignore-scripts`,接受已有的 `ignore-scripts=true`。DSH 有原生依赖,仍使用单独的构建策略;脚本不会偷偷解除用户的构建限制。Node 要求为 22.19+ 的 22.x 或 24+。 -其他支持源码构建的 x64/arm64 平台可用 `--from-source` / `-FromSource`,要求事先装好 Rust 1.88+ 和平台编译工具。首次构建可能较慢;Cargo 复用缓存。脚本不擅自安装系统包、Xcode 或 Visual Studio,也不绕过操作系统的安全提示。CLI 二进制提供 SHA-256 校验,但没有声称完成 OS 代码签名或 macOS 公证。 - -```sh -lmm catalog pi -lmm status -lmm doctor --report -lmm setup pi --dry-run -lmm login -lmm models --json -# 或通过使用脚本 -bash lmm-use.sh catalog pi -bash lmm-use.sh plan pi -``` - -`doctor`、`setup --dry-run` 可能返回退出码 3,表示检查/能力尚未完成。使用脚本保留这个返回值。LMM CLI 登录使用系统凭据库;Linux 需要运行中的 Secret Service,SSH/容器不一定满足。`--no-browser` 不是设备码登录,浏览器仍须能访问该主机的本地回调端口。脚本不复制 Pi/DSH 的授权给 CLI,不自动支付或调用模型。 - -## 维护与验证 - -版本、下载地址和哈希集中在 `versions.json`。编辑 `templates/install.sh.in`、`templates/install.ps1.in` 后运行: +| 情况 | 处理 | +|---|---| +| Windows 提示缺少 Bash | 安装 Git for Windows 后重新打开终端;自定义 Bash 用 Pi 的 `shellPath` | +| 下载失败或停滞 | 检查 HTTPS 代理/证书,尝试 `--network official` 或 `china`;不要关闭 TLS 校验 | +| Termux 的 Pi 安装 | 先用 `pkg install nodejs git termux-api` 安装原生依赖;脚本不会下载桌面 Linux Node 代替 Android Node | +| Alpine / musl | 先用系统包管理器安装兼容的 Node/npm;官方桌面 Node 压缩包使用 glibc | +| LMM CLI 预编译包不兼容 | 当前仅 Linux x64(glibc 2.39+)、macOS arm64、Windows x64;没有 Android 包。已有 Rust 1.88+ 和编译工具时可尝试 `--from-source` | -```sh -python3 tools/generate.py -python3 tools/generate.py --check -shellcheck *.sh -python3 tests/test_installers.py -pwsh -NoProfile -File tests/test-powershell.ps1 -``` +下载缓存、校验失败处理、PATH 恢复、卸载注意事项和维护命令见 [维护说明](docs/maintenance.md)。 -兼容原有的 `generate.py`、`--install-only` / `-InstallOnly` 和 `--no-bootstrap` / `-NoBootstrap` 入口。新版默认只安装;需要安装后启动 DSH 时显式使用 `--launch` / `-Launch`。 +## 文档依据 -原有网络调优环境变量仍保留:`LMM_RETRIES`、`LMM_CONNECT_TIMEOUT`、`LMM_STALL_TIMEOUT`、`LMM_DOWNLOAD_TIMEOUT`、`LMM_COMMAND_TIMEOUT`、`LMM_CACHE_ROOT`、`LMM_NODE_BASE_URL`、`LMM_NPM_REGISTRY`。另可用 `LMM_MIN_SPEED_BYTES` 调整低速门槛。极慢链路可降低门槛并增加总超时,例如 `LMM_MIN_SPEED_BYTES=128 LMM_DOWNLOAD_TIMEOUT=3600 bash dsh.sh`。自定义源必须是没有内嵌凭据的 HTTPS 地址。 +[Pi 安装](https://pi.dev/docs/latest/quickstart) · [Windows](https://pi.dev/docs/latest/windows) · [Termux](https://pi.dev/docs/latest/termux) · [Pi 包管理](https://pi.dev/docs/latest/packages) · [DSH 官方 README](https://github.com/deepseek-ai/deepseek-harness/blob/master/README.md) -Pi/DSH 安装子进程有总超时和每 15 秒的进度心跳,超时或取消会终止本次子进程树;不会让后台 npm 继续写已经清理的暂存目录。POSIX 整个安装器包在完整函数内,管道传输截断时不会执行半个脚本。 +[LMM Pi 插件](https://github.com/TokenNotIncluded/pi-lmm-provider) · [LMM DSH 插件](https://github.com/TokenNotIncluded/dsh-lmm-provider) -只有根目录的八个 `.sh` / `.ps1` 脚本会被网站仓库同步器导入。模板、测试和维护工具不作为公开安装入口。发布时需同时校验各 API 节点提供的脚本内容,避免负载均衡后出现新旧版本混用。 +官方文档说明宿主的使用方法;这里的隔离目录、镜像、固定版本和 LMM 登录属于本项目的集成选择,不是官方安装器。 diff --git a/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..0804c08 --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,40 @@ +# 维护与恢复 + +## 下载与文件 + +下载保留缓存和断点;完整文件每次校验 SHA-256。校验失败的压缩包不会执行。官方发布文件的地址和哈希在 `versions.json`,镜像不能改变期望哈希。npm 依赖仍依赖所选 registry 的元数据和 integrity,不能等同于这里单独固定的文件哈希。 + +默认 `auto` 比较公共源延迟,传输期间另设低速、停滞和总超时。`official` 仅使用官方下载地址,`china` 镜像优先。已有自定义 registry 优先保留,安装过程不写全局 npm 配置,不关闭 TLS 校验。 + +配置项:`LMM_RETRIES`、`LMM_CONNECT_TIMEOUT`、`LMM_STALL_TIMEOUT`、`LMM_DOWNLOAD_TIMEOUT`、`LMM_COMMAND_TIMEOUT`、`LMM_MIN_SPEED_BYTES`、`LMM_CACHE_ROOT`、`LMM_NODE_BASE_URL`、`LMM_NPM_REGISTRY`。继承代理/CA 环境;自定义下载源须使用无内嵌凭据的 HTTPS URL。 + +重复安装复用客户端及缓存。更新采用独立目录,客户端和插件安装成功后才切换启动入口;插件失败不覆盖旧入口。第三方包管理器的内部状态不保证可自动回滚。不要直接删除不认识的安装锁或覆盖没有 `.lmm-managed` 标记的目录。 + +## PATH、卸载与账号 + +默认不修改终端启动文件。`--add-path` 在 Unix 追加带标记的 PATH 段并备份已有文件,不自动修改符号链接;Windows 只修改用户 PATH。新终端才会读到持久化 PATH。 + +安装器暂不提供自动卸载。只删除某个工具的受管启动入口和对应 `apps/<工具>` 目录;Node、pnpm 和缓存可能由其他工具共享,不能随手删除整个根目录。需要移除 PATH 时,删掉 Unix 启动文件中的 `LMM tools PATH` 段,或 Windows 用户 PATH 中对应的 `bin` 项。 + +Pi 插件可用原生命令 `pi remove npm:@tokennotincluded/pi-lmm-provider` 移除。DSH 插件维护以 `dsh plugin --help` 和当前 profile 的原生设置为准。删除客户端不等于退出账号;先在客户端退出,需要撤销授权时再到 LMM 账号管理中撤销。保留 `~/.pi/agent`、`DSH_HOME` 的设置与会话,除非你明确要删除这些数据。 + +## 开发与检查 + +```sh +python3 tools/generate.py +python3 tools/generate.py --check +python3 tools/generate_menus.py --check +shellcheck *.sh +python3 tests/test_installers.py +python3 tests/test_official_policy.py +pwsh -NoProfile -File tests/test-powershell.ps1 +pwsh -NoProfile -File tests/test-official-policy.ps1 +``` + +只修改模板和版本清单,再生成根目录脚本。公开脚本必须能独立运行;不要添加远程 `source` 依赖。 + +菜单的 `revision` 固定到含有目标脚本的提交,并按该提交计算 SHA-256。更新安装器后,先提交安装器,再更新 `tools/generate_menus.py` 中的 `revision` 并生成菜单,避免入口仍取旧代码。线上同步由网站仓库负责;源码提交和线上节点同步是两回事。 + +CI 区分模拟故障测试、真实安装测试和登录测试。前两者通过不代表真实账号 OAuth、模型调用或所有操作系统已经验证;本仓库不在 CI 中提交账号凭据或发起付费模型调用。 + +兼容参数 `--install-only` / `-InstallOnly`、`--no-bootstrap` / `-NoBootstrap`、顶层 `generate.py` 继续保留。 diff --git a/tests/test-official-policy.ps1 b/tests/test-official-policy.ps1 new file mode 100644 index 0000000..fa51051 --- /dev/null +++ b/tests/test-official-policy.ps1 @@ -0,0 +1,74 @@ +$ErrorActionPreference='Stop' +$project=Split-Path $PSScriptRoot +$tokens=$null; $errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $project 'pi.ps1'),[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +foreach ($definition in $ast.FindAll({param($a) $a -is [Management.Automation.Language.FunctionDefinitionAst]},$true)) { + . ([scriptblock]::Create($definition.Extent.Text)) +} +# Only these functions are exercised; never invoke the installer or download. +if ($env:OS -ne 'Windows_NT') { Write-Host 'Windows policy tests skipped on non-Windows.'; return } +$testRoot=Join-Path ([IO.Path]::GetTempPath()) ('lmm-policy-'+[Guid]::NewGuid().ToString('N')) +$oldAgent=$env:PI_CODING_AGENT_DIR; $oldPrograms=$env:ProgramFiles; $oldPath=$env:PATH +$script:NodeForTest=Join-Path $testRoot 'runtime\node.exe' +function Get-Command { + param([string]$Name, $CommandType, $ErrorAction) + if ($Name -eq 'node.exe') { return [pscustomobject]@{ Source=$script:NodeForTest } } + return $null +} +function Assert-Throws([scriptblock]$Action, [string]$Message) { + $caught=$false + try { & $Action } catch { $caught=$true; if ($_.Exception.Message -notlike "*$Message*") { throw } } + if (!$caught) { throw "Expected error containing: $Message" } +} +function Invoke-Bounded([string]$Executable, [string[]]$Arguments) { + $script:CapturedExe=$Executable; $script:CapturedArgs=$Arguments +} +try { + $env:PI_CODING_AGENT_DIR=Join-Path $testRoot 'agent' + $env:ProgramFiles=Join-Path $testRoot 'programs' + New-Item -ItemType Directory -Path $env:PI_CODING_AGENT_DIR -Force | Out-Null + Assert-Throws { Assert-PiShell } 'Pi requires Bash' + $bash=Join-Path $env:ProgramFiles 'Git\bin\bash.exe' + New-Item -ItemType Directory -Path (Split-Path $bash) -Force | Out-Null + Set-Content -LiteralPath $bash -Value '' + Assert-PiShell + $config=Join-Path $env:PI_CODING_AGENT_DIR 'settings.json' + @{shellPath=(Join-Path $testRoot 'missing.exe')} | ConvertTo-Json | Set-Content -LiteralPath $config + Assert-Throws { Assert-PiShell } 'shellPath does not exist' + $custom=Join-Path $testRoot 'custom bash.exe'; Set-Content -LiteralPath $custom -Value '' + @{shellPath=$custom} | ConvertTo-Json | Set-Content -LiteralPath $config + $before=Get-Content -LiteralPath $config -Raw + Assert-PiShell + if ((Get-Content -LiteralPath $config -Raw) -ne $before) { throw 'Shell preflight modified settings.' } + Set-Content -LiteralPath $config -Value '{bad-json' + Assert-Throws { Assert-PiShell } 'Invalid Pi settings' + + $Root=$testRoot + $client=Join-Path $Root 'apps\pi\test' + $package=Join-Path $client 'node_modules\@earendil-works\pi-coding-agent' + $entry=Join-Path $package 'new-layout\main.js' + New-Item -ItemType Directory -Path (Split-Path $entry) -Force | Out-Null + Set-Content -LiteralPath $entry -Value '' + $manifest=Join-Path $package 'package.json' + @{bin=@{pi='new-layout/main.js'}} | ConvertTo-Json | Set-Content -LiteralPath $manifest + $shim=Join-Path $client 'pi.cmd'; Set-Content -LiteralPath $shim -Value '@echo off' + Invoke-Native $shim @('--version','two words') + if ($script:CapturedArgs[0] -ne $entry -or $script:CapturedArgs[2] -ne 'two words') { throw 'Package bin resolution or argument boundaries failed.' } + @{bin=@{pi='../outside.js'}} | ConvertTo-Json | Set-Content -LiteralPath $manifest + Assert-Throws { Invoke-Native $shim @('--version') } 'escaped its package' + @{bin=@{pi='new-layout/main.js'}} | ConvertTo-Json | Set-Content -LiteralPath $manifest + + $runtime=Join-Path $Root 'runtime' + New-Item -ItemType Directory -Path $runtime -Force | Out-Null + $launcher=Join-Path $Root 'bin\pi.cmd' + New-Item -ItemType Directory -Path (Split-Path $launcher) -Force | Out-Null + @('@echo off','rem Managed by LMM installers','set "PATH=%~dp0..\runtime;%PATH%"','"%~dp0..\apps\pi\test\pi.cmd" %*') | Set-Content -LiteralPath $launcher + Invoke-Native $launcher @('--version') + if ($env:PATH.Split(';')[0] -ne $runtime) { throw 'Managed runtime PATH was not restored for -Check.' } + if ($script:CapturedArgs[0] -ne $entry) { throw 'Managed launcher did not resolve its package entry.' } + Write-Host 'Windows Bash lookup, configuration preservation, package bin containment and managed runtime checks passed.' +} finally { + $env:PI_CODING_AGENT_DIR=$oldAgent; $env:ProgramFiles=$oldPrograms; $env:PATH=$oldPath + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/tests/test_official_policy.py b/tests/test_official_policy.py new file mode 100644 index 0000000..b05015e --- /dev/null +++ b/tests/test_official_policy.py @@ -0,0 +1,64 @@ +"""Regression tests for the documented installation policies (no network).""" +import unittest +from pathlib import Path +import test_installers as fixtures + +P = Path(__file__).resolve().parents[1] + +class OfficialPolicyTests(unittest.TestCase): + def setUp(self): + self.fixture = fixtures.InstallerTests() + self.fixture.setUp() + + def tearDown(self): + self.fixture.tearDown() + + def client_install(self, target): + return next(args for command, args in self.fixture.calls() + if command == 'npm' and any(f'/{target}' in arg for arg in args) + and 'install' in args) + + def test_pi_disables_lifecycle_scripts(self): + result = self.fixture.run_script('pi') + self.assertEqual(result.returncode, 0, result.stderr) + args = self.client_install('pi-coding-agent') + self.assertIn('--ignore-scripts', args) + self.assertFalse(any(a.startswith('--allow-scripts') for a in args)) + + def test_pi_accepts_existing_ignore_scripts_policy(self): + result = self.fixture.run_script('pi', env={'npm_config_ignore_scripts': 'true'}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('--ignore-scripts', self.client_install('pi-coding-agent')) + + def test_dsh_does_not_silently_override_build_policy(self): + result = self.fixture.run_script('dsh', env={'npm_config_ignore_scripts': 'true'}) + self.assertNotEqual(result.returncode, 0) + self.assertIn('DSH needs native build scripts', result.stderr) + self.assertFalse((self.fixture.root / 'bin/dsh').exists()) + + def test_dsh_keeps_native_build_allowlist(self): + result = self.fixture.run_script('dsh', fixture=True) + self.assertEqual(result.returncode, 0, result.stderr) + args = self.client_install('dsh') + self.assertNotIn('--ignore-scripts', args) + self.assertTrue(any(a.startswith('--allow-scripts=@deepseek-ai/dsh-subprocess-local,') for a in args)) + + def test_termux_never_downloads_desktop_node(self): + result = self.fixture.run_script('pi', env={'TERMUX_VERSION': 'test', 'LMM_TEST_NODE_OK': '0'}) + self.assertNotEqual(result.returncode, 0) + self.assertIn('pkg install nodejs', result.stderr) + self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) + + def test_termux_reuses_compatible_native_node(self): + result = self.fixture.run_script('pi', env={'TERMUX_VERSION': 'test'}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) + + def test_termux_rejects_desktop_lmm_binary(self): + result = self.fixture.run_script('lmm', env={'TERMUX_VERSION': 'test'}) + self.assertNotEqual(result.returncode, 0) + self.assertIn('No Android LMM CLI binary', result.stderr) + self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tools/_apply_official_fix.py b/tools/_apply_official_fix.py new file mode 100644 index 0000000..75e4920 --- /dev/null +++ b/tools/_apply_official_fix.py @@ -0,0 +1,146 @@ +from pathlib import Path +import json + +P = Path(__file__).resolve().parents[1] + +def replace(path, old, new): + file = P / path + text = file.read_text(encoding='utf-8') + if text.count(old) != 1: + raise RuntimeError(f'{path}: expected one match, found {text.count(old)}: {old[:100]}') + file.write_text(text.replace(old, new), encoding='utf-8') + +sh = 'templates/install.sh.in' +ps = 'templates/install.ps1.in' +replace(sh, ''' case "$TARGET" in + pi) allow='esbuild,@google/genai,protobufjs';; + dsh) allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs';; + esac + INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") + if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi + [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'Your npm configuration disables required native build scripts. Configure a package-specific build policy before installing this client.' +''', ''' INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") + if [ "$TARGET" = pi ]; then + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + INSTALL_ARGS+=(--ignore-scripts) + else + allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi + [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' + fi +''') +replace(ps, ''' $allow='esbuild,@google/genai,protobufjs' + if ($Target -eq 'dsh') { $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' } + $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") + $npmHelp=(& npm.cmd install --help 2>$null | Out-String) + if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } + if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'Your npm configuration blocks required native builds. Configure a package-specific build policy first.' } +''', ''' $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") + if ($Target -eq 'pi') { + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + $installArgs+=@('--ignore-scripts') + } else { + $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + $npmHelp=(& npm.cmd install --help 2>$null | Out-String) + if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } + if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } + } +''') +replace(sh, ''' local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive +''', ''' # Android uses bionic, not the glibc used by the Linux Node archives. + if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then + fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' + fi + local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive +''') +replace(sh, ''' hash=$(lmm_hash "$PLATFORM") +''', ''' if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then + fail 'No Android LMM CLI binary is provided. The Linux archive is not compatible with Termux.' + fi + hash=$(lmm_hash "$PLATFORM") +''') +replace(ps, '''function Set-RequestProxy($request) { +''', '''function Assert-PiShell { + # Follow Pi's shellPath -> Git Bash -> PATH lookup, without editing settings. + $agentDirectory=$env:PI_CODING_AGENT_DIR + if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\\agent' } + $settingsPath=Join-Path $agentDirectory 'settings.json' + if (Test-Path -LiteralPath $settingsPath) { + try { $settings=Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } + catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } + if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } + $property=$settings.PSObject.Properties['shellPath'] + if ($property -and $property.Value) { + $shell=[string]$property.Value + if (Test-Path -LiteralPath $shell -PathType Leaf) { return } + if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } + throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." + } + } + if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\\bin\\bash.exe') -PathType Leaf)) { return } + if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } + throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' +} +function Set-RequestProxy($request) { +''') +replace(ps, ''' if ($Check) { + Write-Log "Platform: $Platform; root: $Root" +''', ''' if ($Target -eq 'pi') { Assert-PiShell } + if ($Check) { + Write-Log "Platform: $Platform; root: $Root" +''') +replace(ps, ''' $line=@(Get-Content -LiteralPath $Command)[-1] +''', ''' $launcherLines=@(Get-Content -LiteralPath $Command) + foreach ($pathLine in $launcherLines) { + if ($pathLine -match '^set "PATH=%~dp0\\.\\.\\\\(.+);%PATH%"$') { + $runtime=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) + if (!$runtime.StartsWith($Root + '\\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed runtime escaped its root.' } + if (!(Test-Path -LiteralPath $runtime -PathType Container)) { throw 'Managed runtime is missing. Rerun the installer.' } + $env:PATH="$runtime;$env:PATH" + } + } + $line=$launcherLines[-1] +''') +replace(ps, ''' switch ([IO.Path]::GetFileName($Command).ToLowerInvariant()) { + 'npm.cmd' { $entry=Join-Path $parent 'node_modules\\npm\\bin\\npm-cli.js' } + 'pi.cmd' { $entry=Join-Path $parent 'node_modules\\@earendil-works\\pi-coding-agent\\dist\\bundle\\cli.js' } + 'pnpm.cmd' { $entry=Join-Path $parent 'node_modules\\pnpm\\bin\\pnpm.cjs' } + 'dsh.cmd' { $entry=Join-Path $parent 'node_modules\\@deepseek-ai\\dsh\\lib\\bin.js' } + default { throw 'Unsupported command shim; use the managed installer or a native executable.' } + } +''', ''' $binName=[IO.Path]::GetFileNameWithoutExtension($Command).ToLowerInvariant() + switch ($binName) { + 'npm' { $packageName='npm' } + 'pi' { $packageName='@earendil-works/pi-coding-agent' } + 'pnpm' { $packageName='pnpm' } + 'dsh' { $packageName='@deepseek-ai/dsh' } + default { throw 'Unsupported command shim; use the managed installer or a native executable.' } + } + $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) + $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw | ConvertFrom-Json + $binProperty=$manifest.PSObject.Properties['bin'] + if (!$binProperty) { throw 'Package manifest has no bin entry.' } + $bins=$binProperty.Value + $relative=$null + if ($bins -is [string]) { $relative=$bins } + elseif ($null -ne $bins -and $bins.PSObject.Properties[$binName]) { $relative=$bins.PSObject.Properties[$binName].Value } + if ($relative -isnot [string] -or !$relative -or [IO.Path]::IsPathRooted($relative)) { throw 'Invalid package bin entry.' } + $entry=[IO.Path]::GetFullPath((Join-Path $packageRoot $relative)) + if (!$entry.StartsWith($packageRoot + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Package bin entry escaped its package.' } +''') +replace(sh, ' --update Reinstall the versions tested by this script', ' --update Reinstall the versions pinned in versions.json') +replace(sh, '''Installs in user space. Existing system Node, npm configuration and login data +are not replaced. Downloads are cached, resumed and SHA-256 checked. Proxy/CA +settings are inherited. No login, paid call or OS package install is automatic. +''', '''No automatic login or PATH changes. Versions and platform notes: README.md. +''') +replace(ps, '''Per-user installation; no administrator, login or paid model call is required. +Downloads are cached, resumed, retried and SHA-256 checked. Existing system Node, +npm proxy/registry configuration and credentials are preserved. +''', '''No automatic login or PATH changes. Pi on Windows requires Bash. +''') +replace('tests/test_installers.py', " print('https://registry.npmjs.org/' if a[-1]=='registry' else os.environ['LMM_TEST_CACHE']);sys.exit(0)", " print('https://registry.npmjs.org/' if a[-1]=='registry' else os.environ.get('npm_config_ignore_scripts','false') if a[-1]=='ignore-scripts' else os.environ['LMM_TEST_CACHE']);sys.exit(0)") +versions = P / 'versions.json' +v = json.loads(versions.read_text()) +v['script_version'] = '2026.09.20.1' +versions.write_text(json.dumps(v, indent=2) + '\n') From bb38629f825339f64c4b5b0018c3dde47787d9a2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:20:10 +0000 Subject: [PATCH 02/39] fix: follow Pi installation policy and check platform prerequisites --- dsh.ps1 | 75 ++++++++++++++---- dsh.sh | 29 ++++--- lmm.ps1 | 75 ++++++++++++++---- lmm.sh | 29 ++++--- pi.ps1 | 75 ++++++++++++++---- pi.sh | 29 ++++--- templates/install.ps1.in | 73 ++++++++++++++---- templates/install.sh.in | 27 ++++--- tests/test_installers.py | 2 +- tools/_apply_official_fix.py | 146 ----------------------------------- versions.json | 2 +- 11 files changed, 312 insertions(+), 250 deletions(-) delete mode 100644 tools/_apply_official_fix.py diff --git a/dsh.ps1 b/dsh.ps1 index 8299a28..fab6110 100644 --- a/dsh.ps1 +++ b/dsh.ps1 @@ -12,7 +12,7 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'dsh' -$ScriptVersion = '2026.09.19.1' +$ScriptVersion = '2026.09.20.1' $NodeVersion = '24.21.0' $PiVersion = '0.85.1' $PiProviderVersion = '0.1.0-alpha.1' @@ -50,9 +50,7 @@ LMM $Target installer $ScriptVersion Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] [-FromSource] [-Launch] [-Help] -Per-user installation; no administrator, login or paid model call is required. -Downloads are cached, resumed, retried and SHA-256 checked. Existing system Node, -npm proxy/registry configuration and credentials are preserved. +No automatic login or PATH changes. Pi on Windows requires Bash. -FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. "@ } @@ -103,7 +101,16 @@ function Invoke-Bounded([string]$Executable,[string[]]$Arguments) { function Invoke-Native([string]$Command, [string[]]$Arguments) { if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { if ((Test-Path -LiteralPath $Command) -and (Select-String -LiteralPath $Command -SimpleMatch 'Managed by LMM installers' -Quiet)) { - $line=@(Get-Content -LiteralPath $Command)[-1] + $launcherLines=@(Get-Content -LiteralPath $Command) + foreach ($pathLine in $launcherLines) { + if ($pathLine -match '^set "PATH=%~dp0\.\.\\(.+);%PATH%"$') { + $runtime=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) + if (!$runtime.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed runtime escaped its root.' } + if (!(Test-Path -LiteralPath $runtime -PathType Container)) { throw 'Managed runtime is missing. Rerun the installer.' } + $env:PATH="$runtime;$env:PATH" + } + } + $line=$launcherLines[-1] if ($line -match '^"%~dp0\.\.\\(.+)" %\*$') { $resolved=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) if (!$resolved.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed launcher target escaped its root.' } @@ -112,13 +119,25 @@ function Invoke-Native([string]$Command, [string[]]$Arguments) { } if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { $parent=Split-Path $Command - switch ([IO.Path]::GetFileName($Command).ToLowerInvariant()) { - 'npm.cmd' { $entry=Join-Path $parent 'node_modules\npm\bin\npm-cli.js' } - 'pi.cmd' { $entry=Join-Path $parent 'node_modules\@earendil-works\pi-coding-agent\dist\bundle\cli.js' } - 'pnpm.cmd' { $entry=Join-Path $parent 'node_modules\pnpm\bin\pnpm.cjs' } - 'dsh.cmd' { $entry=Join-Path $parent 'node_modules\@deepseek-ai\dsh\lib\bin.js' } + $binName=[IO.Path]::GetFileNameWithoutExtension($Command).ToLowerInvariant() + switch ($binName) { + 'npm' { $packageName='npm' } + 'pi' { $packageName='@earendil-works/pi-coding-agent' } + 'pnpm' { $packageName='pnpm' } + 'dsh' { $packageName='@deepseek-ai/dsh' } default { throw 'Unsupported command shim; use the managed installer or a native executable.' } } + $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) + $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw | ConvertFrom-Json + $binProperty=$manifest.PSObject.Properties['bin'] + if (!$binProperty) { throw 'Package manifest has no bin entry.' } + $bins=$binProperty.Value + $relative=$null + if ($bins -is [string]) { $relative=$bins } + elseif ($null -ne $bins -and $bins.PSObject.Properties[$binName]) { $relative=$bins.PSObject.Properties[$binName].Value } + if ($relative -isnot [string] -or !$relative -or [IO.Path]::IsPathRooted($relative)) { throw 'Invalid package bin entry.' } + $entry=[IO.Path]::GetFullPath((Join-Path $packageRoot $relative)) + if (!$entry.StartsWith($packageRoot + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Package bin entry escaped its package.' } if (!(Test-Path -LiteralPath $entry)) { throw 'Client entry is missing. Rerun with -Update.' } $Command=(Get-Command node.exe).Source $Arguments=@($entry)+$Arguments @@ -136,6 +155,27 @@ function Test-Node { $major=[int]$Matches[1];$minor=[int]$Matches[2] return (($major -eq 22 -and $minor -ge 19) -or $major -ge 24) } +function Assert-PiShell { + # Follow Pi's shellPath -> Git Bash -> PATH lookup, without editing settings. + $agentDirectory=$env:PI_CODING_AGENT_DIR + if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } + $settingsPath=Join-Path $agentDirectory 'settings.json' + if (Test-Path -LiteralPath $settingsPath) { + try { $settings=Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } + catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } + if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } + $property=$settings.PSObject.Properties['shellPath'] + if ($property -and $property.Value) { + $shell=[string]$property.Value + if (Test-Path -LiteralPath $shell -PathType Leaf) { return } + if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } + throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." + } + } + if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') -PathType Leaf)) { return } + if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } + throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' +} function Set-RequestProxy($request) { $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } if ($proxyValue) { @@ -308,12 +348,16 @@ function Install-Client([string]$Package,[string]$Version,[string]$Entry) { if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } if ($NoBootstrap) { throw 'Managed client is missing. Rerun without -NoBootstrap.' } $work=Join-Path $script:Stage 'client'; New-Item -ItemType Directory -Path $work | Out-Null - $allow='esbuild,@google/genai,protobufjs' - if ($Target -eq 'dsh') { $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' } $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") - $npmHelp=(& npm.cmd install --help 2>$null | Out-String) - if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } - if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'Your npm configuration blocks required native builds. Configure a package-specific build policy first.' } + if ($Target -eq 'pi') { + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + $installArgs+=@('--ignore-scripts') + } else { + $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + $npmHelp=(& npm.cmd install --help 2>$null | Out-String) + if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } + if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } + } Invoke-WithRegistryRetry (Get-Command npm.cmd).Source $installArgs Invoke-Native (Join-Path $work "$Entry.cmd") @('--version') Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value "$Version|$ScriptVersion" @@ -404,6 +448,7 @@ function Invoke-LmmSetup { elseif ([Environment]::Is64BitOperatingSystem) { $script:Platform='win-x64' } else { throw 'These installers require 64-bit Windows.' } if ($FromSource -and $Target -ne 'lmm') { throw '-FromSource is only for LMM CLI.' } + if ($Target -eq 'pi') { Assert-PiShell } if ($Check) { Write-Log "Platform: $Platform; root: $Root" $entry=Join-Path $Root "bin\$Target.cmd" diff --git a/dsh.sh b/dsh.sh index 29bb53e..cf21c38 100755 --- a/dsh.sh +++ b/dsh.sh @@ -4,7 +4,7 @@ lmm_install_main() { set -euo pipefail set +x TARGET=dsh -SCRIPT_VERSION=2026.09.19.1 +SCRIPT_VERSION=2026.09.20.1 NODE_VERSION=24.21.0 PI_VERSION=0.85.1 PI_PROVIDER_VERSION=0.1.0-alpha.1 @@ -43,7 +43,7 @@ usage() { LMM $TARGET installer $SCRIPT_VERSION Usage: bash $TARGET.sh [options] [-- launch arguments] --check Read-only environment/installation check - --update Reinstall the versions tested by this script + --update Reinstall the versions pinned in versions.json --root PATH User-owned install directory (default: $ROOT) --network MODE auto (latency probes), official, or china --profile NAME DSH: web or headless (default: web) @@ -55,9 +55,7 @@ Usage: bash $TARGET.sh [options] [-- launch arguments] --from-source LMM CLI: build the pinned crate using existing Rust 1.88+ --launch Start the installed tool (DSH starts the chosen profile) --help Show this help -Installs in user space. Existing system Node, npm configuration and login data -are not replaced. Downloads are cached, resumed and SHA-256 checked. Proxy/CA -settings are inherited. No login, paid call or OS package install is automatic. +No automatic login or PATH changes. Versions and platform notes: README.md. USAGE } while [ "$#" -gt 0 ]; do @@ -225,6 +223,10 @@ download() { ensure_node() { PHASE='Node.js runtime' if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi + # Android uses bionic, not the glibc used by the Linux Node archives. + if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then + fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' + fi local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi if compatible_node; then NODE_BIN="$dir/bin"; return; fi @@ -317,13 +319,15 @@ install_client() { if [ "$FORCE" = 0 ] && [ -x "$target/bin/$entry" ] && [ -f "$target/.lmm-managed" ] && [ "$(cat "$target/.lmm-managed")" = "$version|$SCRIPT_VERSION" ]; then CLIENT="$target/bin/$entry"; log "Client $version already installed."; return; fi [ "$BOOTSTRAP" = 1 ] || fail 'Managed client is missing; rerun without --no-bootstrap.' mkdir -p "$work" - case "$TARGET" in - pi) allow='esbuild,@google/genai,protobufjs';; - dsh) allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs';; - esac INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") - if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi - [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'Your npm configuration disables required native build scripts. Configure a package-specific build policy before installing this client.' + if [ "$TARGET" = pi ]; then + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + INSTALL_ARGS+=(--ignore-scripts) + else + allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi + [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' + fi with_registry_retry npm "${INSTALL_ARGS[@]}" "$work/bin/$entry" --version >/dev/null printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" @@ -348,6 +352,9 @@ install_lmm() { cargo install lmm-cli --version "$LMM_VERSION" --locked --root "$STAGE/cargo" Git Bash -> PATH lookup, without editing settings. + $agentDirectory=$env:PI_CODING_AGENT_DIR + if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } + $settingsPath=Join-Path $agentDirectory 'settings.json' + if (Test-Path -LiteralPath $settingsPath) { + try { $settings=Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } + catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } + if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } + $property=$settings.PSObject.Properties['shellPath'] + if ($property -and $property.Value) { + $shell=[string]$property.Value + if (Test-Path -LiteralPath $shell -PathType Leaf) { return } + if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } + throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." + } + } + if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') -PathType Leaf)) { return } + if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } + throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' +} function Set-RequestProxy($request) { $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } if ($proxyValue) { @@ -308,12 +348,16 @@ function Install-Client([string]$Package,[string]$Version,[string]$Entry) { if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } if ($NoBootstrap) { throw 'Managed client is missing. Rerun without -NoBootstrap.' } $work=Join-Path $script:Stage 'client'; New-Item -ItemType Directory -Path $work | Out-Null - $allow='esbuild,@google/genai,protobufjs' - if ($Target -eq 'dsh') { $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' } $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") - $npmHelp=(& npm.cmd install --help 2>$null | Out-String) - if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } - if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'Your npm configuration blocks required native builds. Configure a package-specific build policy first.' } + if ($Target -eq 'pi') { + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + $installArgs+=@('--ignore-scripts') + } else { + $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + $npmHelp=(& npm.cmd install --help 2>$null | Out-String) + if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } + if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } + } Invoke-WithRegistryRetry (Get-Command npm.cmd).Source $installArgs Invoke-Native (Join-Path $work "$Entry.cmd") @('--version') Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value "$Version|$ScriptVersion" @@ -404,6 +448,7 @@ function Invoke-LmmSetup { elseif ([Environment]::Is64BitOperatingSystem) { $script:Platform='win-x64' } else { throw 'These installers require 64-bit Windows.' } if ($FromSource -and $Target -ne 'lmm') { throw '-FromSource is only for LMM CLI.' } + if ($Target -eq 'pi') { Assert-PiShell } if ($Check) { Write-Log "Platform: $Platform; root: $Root" $entry=Join-Path $Root "bin\$Target.cmd" diff --git a/lmm.sh b/lmm.sh index 08bf501..adf8280 100755 --- a/lmm.sh +++ b/lmm.sh @@ -4,7 +4,7 @@ lmm_install_main() { set -euo pipefail set +x TARGET=lmm -SCRIPT_VERSION=2026.09.19.1 +SCRIPT_VERSION=2026.09.20.1 NODE_VERSION=24.21.0 PI_VERSION=0.85.1 PI_PROVIDER_VERSION=0.1.0-alpha.1 @@ -43,7 +43,7 @@ usage() { LMM $TARGET installer $SCRIPT_VERSION Usage: bash $TARGET.sh [options] [-- launch arguments] --check Read-only environment/installation check - --update Reinstall the versions tested by this script + --update Reinstall the versions pinned in versions.json --root PATH User-owned install directory (default: $ROOT) --network MODE auto (latency probes), official, or china --profile NAME DSH: web or headless (default: web) @@ -55,9 +55,7 @@ Usage: bash $TARGET.sh [options] [-- launch arguments] --from-source LMM CLI: build the pinned crate using existing Rust 1.88+ --launch Start the installed tool (DSH starts the chosen profile) --help Show this help -Installs in user space. Existing system Node, npm configuration and login data -are not replaced. Downloads are cached, resumed and SHA-256 checked. Proxy/CA -settings are inherited. No login, paid call or OS package install is automatic. +No automatic login or PATH changes. Versions and platform notes: README.md. USAGE } while [ "$#" -gt 0 ]; do @@ -225,6 +223,10 @@ download() { ensure_node() { PHASE='Node.js runtime' if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi + # Android uses bionic, not the glibc used by the Linux Node archives. + if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then + fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' + fi local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi if compatible_node; then NODE_BIN="$dir/bin"; return; fi @@ -317,13 +319,15 @@ install_client() { if [ "$FORCE" = 0 ] && [ -x "$target/bin/$entry" ] && [ -f "$target/.lmm-managed" ] && [ "$(cat "$target/.lmm-managed")" = "$version|$SCRIPT_VERSION" ]; then CLIENT="$target/bin/$entry"; log "Client $version already installed."; return; fi [ "$BOOTSTRAP" = 1 ] || fail 'Managed client is missing; rerun without --no-bootstrap.' mkdir -p "$work" - case "$TARGET" in - pi) allow='esbuild,@google/genai,protobufjs';; - dsh) allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs';; - esac INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") - if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi - [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'Your npm configuration disables required native build scripts. Configure a package-specific build policy before installing this client.' + if [ "$TARGET" = pi ]; then + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + INSTALL_ARGS+=(--ignore-scripts) + else + allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi + [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' + fi with_registry_retry npm "${INSTALL_ARGS[@]}" "$work/bin/$entry" --version >/dev/null printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" @@ -348,6 +352,9 @@ install_lmm() { cargo install lmm-cli --version "$LMM_VERSION" --locked --root "$STAGE/cargo" Git Bash -> PATH lookup, without editing settings. + $agentDirectory=$env:PI_CODING_AGENT_DIR + if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } + $settingsPath=Join-Path $agentDirectory 'settings.json' + if (Test-Path -LiteralPath $settingsPath) { + try { $settings=Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } + catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } + if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } + $property=$settings.PSObject.Properties['shellPath'] + if ($property -and $property.Value) { + $shell=[string]$property.Value + if (Test-Path -LiteralPath $shell -PathType Leaf) { return } + if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } + throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." + } + } + if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') -PathType Leaf)) { return } + if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } + throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' +} function Set-RequestProxy($request) { $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } if ($proxyValue) { @@ -308,12 +348,16 @@ function Install-Client([string]$Package,[string]$Version,[string]$Entry) { if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } if ($NoBootstrap) { throw 'Managed client is missing. Rerun without -NoBootstrap.' } $work=Join-Path $script:Stage 'client'; New-Item -ItemType Directory -Path $work | Out-Null - $allow='esbuild,@google/genai,protobufjs' - if ($Target -eq 'dsh') { $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' } $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") - $npmHelp=(& npm.cmd install --help 2>$null | Out-String) - if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } - if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'Your npm configuration blocks required native builds. Configure a package-specific build policy first.' } + if ($Target -eq 'pi') { + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + $installArgs+=@('--ignore-scripts') + } else { + $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + $npmHelp=(& npm.cmd install --help 2>$null | Out-String) + if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } + if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } + } Invoke-WithRegistryRetry (Get-Command npm.cmd).Source $installArgs Invoke-Native (Join-Path $work "$Entry.cmd") @('--version') Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value "$Version|$ScriptVersion" @@ -404,6 +448,7 @@ function Invoke-LmmSetup { elseif ([Environment]::Is64BitOperatingSystem) { $script:Platform='win-x64' } else { throw 'These installers require 64-bit Windows.' } if ($FromSource -and $Target -ne 'lmm') { throw '-FromSource is only for LMM CLI.' } + if ($Target -eq 'pi') { Assert-PiShell } if ($Check) { Write-Log "Platform: $Platform; root: $Root" $entry=Join-Path $Root "bin\$Target.cmd" diff --git a/pi.sh b/pi.sh index 178f435..9b3d037 100755 --- a/pi.sh +++ b/pi.sh @@ -4,7 +4,7 @@ lmm_install_main() { set -euo pipefail set +x TARGET=pi -SCRIPT_VERSION=2026.09.19.1 +SCRIPT_VERSION=2026.09.20.1 NODE_VERSION=24.21.0 PI_VERSION=0.85.1 PI_PROVIDER_VERSION=0.1.0-alpha.1 @@ -43,7 +43,7 @@ usage() { LMM $TARGET installer $SCRIPT_VERSION Usage: bash $TARGET.sh [options] [-- launch arguments] --check Read-only environment/installation check - --update Reinstall the versions tested by this script + --update Reinstall the versions pinned in versions.json --root PATH User-owned install directory (default: $ROOT) --network MODE auto (latency probes), official, or china --profile NAME DSH: web or headless (default: web) @@ -55,9 +55,7 @@ Usage: bash $TARGET.sh [options] [-- launch arguments] --from-source LMM CLI: build the pinned crate using existing Rust 1.88+ --launch Start the installed tool (DSH starts the chosen profile) --help Show this help -Installs in user space. Existing system Node, npm configuration and login data -are not replaced. Downloads are cached, resumed and SHA-256 checked. Proxy/CA -settings are inherited. No login, paid call or OS package install is automatic. +No automatic login or PATH changes. Versions and platform notes: README.md. USAGE } while [ "$#" -gt 0 ]; do @@ -225,6 +223,10 @@ download() { ensure_node() { PHASE='Node.js runtime' if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi + # Android uses bionic, not the glibc used by the Linux Node archives. + if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then + fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' + fi local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi if compatible_node; then NODE_BIN="$dir/bin"; return; fi @@ -317,13 +319,15 @@ install_client() { if [ "$FORCE" = 0 ] && [ -x "$target/bin/$entry" ] && [ -f "$target/.lmm-managed" ] && [ "$(cat "$target/.lmm-managed")" = "$version|$SCRIPT_VERSION" ]; then CLIENT="$target/bin/$entry"; log "Client $version already installed."; return; fi [ "$BOOTSTRAP" = 1 ] || fail 'Managed client is missing; rerun without --no-bootstrap.' mkdir -p "$work" - case "$TARGET" in - pi) allow='esbuild,@google/genai,protobufjs';; - dsh) allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs';; - esac INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") - if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi - [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'Your npm configuration disables required native build scripts. Configure a package-specific build policy before installing this client.' + if [ "$TARGET" = pi ]; then + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + INSTALL_ARGS+=(--ignore-scripts) + else + allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi + [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' + fi with_registry_retry npm "${INSTALL_ARGS[@]}" "$work/bin/$entry" --version >/dev/null printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" @@ -348,6 +352,9 @@ install_lmm() { cargo install lmm-cli --version "$LMM_VERSION" --locked --root "$STAGE/cargo" Git Bash -> PATH lookup, without editing settings. + $agentDirectory=$env:PI_CODING_AGENT_DIR + if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } + $settingsPath=Join-Path $agentDirectory 'settings.json' + if (Test-Path -LiteralPath $settingsPath) { + try { $settings=Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } + catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } + if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } + $property=$settings.PSObject.Properties['shellPath'] + if ($property -and $property.Value) { + $shell=[string]$property.Value + if (Test-Path -LiteralPath $shell -PathType Leaf) { return } + if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } + throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." + } + } + if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') -PathType Leaf)) { return } + if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } + throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' +} function Set-RequestProxy($request) { $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } if ($proxyValue) { @@ -284,12 +324,16 @@ function Install-Client([string]$Package,[string]$Version,[string]$Entry) { if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } if ($NoBootstrap) { throw 'Managed client is missing. Rerun without -NoBootstrap.' } $work=Join-Path $script:Stage 'client'; New-Item -ItemType Directory -Path $work | Out-Null - $allow='esbuild,@google/genai,protobufjs' - if ($Target -eq 'dsh') { $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' } $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") - $npmHelp=(& npm.cmd install --help 2>$null | Out-String) - if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } - if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'Your npm configuration blocks required native builds. Configure a package-specific build policy first.' } + if ($Target -eq 'pi') { + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + $installArgs+=@('--ignore-scripts') + } else { + $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + $npmHelp=(& npm.cmd install --help 2>$null | Out-String) + if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } + if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } + } Invoke-WithRegistryRetry (Get-Command npm.cmd).Source $installArgs Invoke-Native (Join-Path $work "$Entry.cmd") @('--version') Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value "$Version|$ScriptVersion" @@ -380,6 +424,7 @@ function Invoke-LmmSetup { elseif ([Environment]::Is64BitOperatingSystem) { $script:Platform='win-x64' } else { throw 'These installers require 64-bit Windows.' } if ($FromSource -and $Target -ne 'lmm') { throw '-FromSource is only for LMM CLI.' } + if ($Target -eq 'pi') { Assert-PiShell } if ($Check) { Write-Log "Platform: $Platform; root: $Root" $entry=Join-Path $Root "bin\$Target.cmd" diff --git a/templates/install.sh.in b/templates/install.sh.in index 7a777d0..b303f11 100644 --- a/templates/install.sh.in +++ b/templates/install.sh.in @@ -16,7 +16,7 @@ usage() { LMM $TARGET installer $SCRIPT_VERSION Usage: bash $TARGET.sh [options] [-- launch arguments] --check Read-only environment/installation check - --update Reinstall the versions tested by this script + --update Reinstall the versions pinned in versions.json --root PATH User-owned install directory (default: $ROOT) --network MODE auto (latency probes), official, or china --profile NAME DSH: web or headless (default: web) @@ -28,9 +28,7 @@ Usage: bash $TARGET.sh [options] [-- launch arguments] --from-source LMM CLI: build the pinned crate using existing Rust 1.88+ --launch Start the installed tool (DSH starts the chosen profile) --help Show this help -Installs in user space. Existing system Node, npm configuration and login data -are not replaced. Downloads are cached, resumed and SHA-256 checked. Proxy/CA -settings are inherited. No login, paid call or OS package install is automatic. +No automatic login or PATH changes. Versions and platform notes: README.md. USAGE } while [ "$#" -gt 0 ]; do @@ -198,6 +196,10 @@ download() { ensure_node() { PHASE='Node.js runtime' if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi + # Android uses bionic, not the glibc used by the Linux Node archives. + if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then + fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' + fi local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi if compatible_node; then NODE_BIN="$dir/bin"; return; fi @@ -290,13 +292,15 @@ install_client() { if [ "$FORCE" = 0 ] && [ -x "$target/bin/$entry" ] && [ -f "$target/.lmm-managed" ] && [ "$(cat "$target/.lmm-managed")" = "$version|$SCRIPT_VERSION" ]; then CLIENT="$target/bin/$entry"; log "Client $version already installed."; return; fi [ "$BOOTSTRAP" = 1 ] || fail 'Managed client is missing; rerun without --no-bootstrap.' mkdir -p "$work" - case "$TARGET" in - pi) allow='esbuild,@google/genai,protobufjs';; - dsh) allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs';; - esac INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") - if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi - [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'Your npm configuration disables required native build scripts. Configure a package-specific build policy before installing this client.' + if [ "$TARGET" = pi ]; then + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + INSTALL_ARGS+=(--ignore-scripts) + else + allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi + [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' + fi with_registry_retry npm "${INSTALL_ARGS[@]}" "$work/bin/$entry" --version >/dev/null printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" @@ -321,6 +325,9 @@ install_lmm() { cargo install lmm-cli --version "$LMM_VERSION" --locked --root "$STAGE/cargo" /dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi - [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'Your npm configuration disables required native build scripts. Configure a package-specific build policy before installing this client.' -''', ''' INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") - if [ "$TARGET" = pi ]; then - # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. - INSTALL_ARGS+=(--ignore-scripts) - else - allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' - if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi - [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' - fi -''') -replace(ps, ''' $allow='esbuild,@google/genai,protobufjs' - if ($Target -eq 'dsh') { $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' } - $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") - $npmHelp=(& npm.cmd install --help 2>$null | Out-String) - if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } - if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'Your npm configuration blocks required native builds. Configure a package-specific build policy first.' } -''', ''' $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") - if ($Target -eq 'pi') { - # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. - $installArgs+=@('--ignore-scripts') - } else { - $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' - $npmHelp=(& npm.cmd install --help 2>$null | Out-String) - if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } - if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } - } -''') -replace(sh, ''' local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive -''', ''' # Android uses bionic, not the glibc used by the Linux Node archives. - if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then - fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' - fi - local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive -''') -replace(sh, ''' hash=$(lmm_hash "$PLATFORM") -''', ''' if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then - fail 'No Android LMM CLI binary is provided. The Linux archive is not compatible with Termux.' - fi - hash=$(lmm_hash "$PLATFORM") -''') -replace(ps, '''function Set-RequestProxy($request) { -''', '''function Assert-PiShell { - # Follow Pi's shellPath -> Git Bash -> PATH lookup, without editing settings. - $agentDirectory=$env:PI_CODING_AGENT_DIR - if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\\agent' } - $settingsPath=Join-Path $agentDirectory 'settings.json' - if (Test-Path -LiteralPath $settingsPath) { - try { $settings=Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } - catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } - if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } - $property=$settings.PSObject.Properties['shellPath'] - if ($property -and $property.Value) { - $shell=[string]$property.Value - if (Test-Path -LiteralPath $shell -PathType Leaf) { return } - if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } - throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." - } - } - if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\\bin\\bash.exe') -PathType Leaf)) { return } - if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } - throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' -} -function Set-RequestProxy($request) { -''') -replace(ps, ''' if ($Check) { - Write-Log "Platform: $Platform; root: $Root" -''', ''' if ($Target -eq 'pi') { Assert-PiShell } - if ($Check) { - Write-Log "Platform: $Platform; root: $Root" -''') -replace(ps, ''' $line=@(Get-Content -LiteralPath $Command)[-1] -''', ''' $launcherLines=@(Get-Content -LiteralPath $Command) - foreach ($pathLine in $launcherLines) { - if ($pathLine -match '^set "PATH=%~dp0\\.\\.\\\\(.+);%PATH%"$') { - $runtime=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) - if (!$runtime.StartsWith($Root + '\\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed runtime escaped its root.' } - if (!(Test-Path -LiteralPath $runtime -PathType Container)) { throw 'Managed runtime is missing. Rerun the installer.' } - $env:PATH="$runtime;$env:PATH" - } - } - $line=$launcherLines[-1] -''') -replace(ps, ''' switch ([IO.Path]::GetFileName($Command).ToLowerInvariant()) { - 'npm.cmd' { $entry=Join-Path $parent 'node_modules\\npm\\bin\\npm-cli.js' } - 'pi.cmd' { $entry=Join-Path $parent 'node_modules\\@earendil-works\\pi-coding-agent\\dist\\bundle\\cli.js' } - 'pnpm.cmd' { $entry=Join-Path $parent 'node_modules\\pnpm\\bin\\pnpm.cjs' } - 'dsh.cmd' { $entry=Join-Path $parent 'node_modules\\@deepseek-ai\\dsh\\lib\\bin.js' } - default { throw 'Unsupported command shim; use the managed installer or a native executable.' } - } -''', ''' $binName=[IO.Path]::GetFileNameWithoutExtension($Command).ToLowerInvariant() - switch ($binName) { - 'npm' { $packageName='npm' } - 'pi' { $packageName='@earendil-works/pi-coding-agent' } - 'pnpm' { $packageName='pnpm' } - 'dsh' { $packageName='@deepseek-ai/dsh' } - default { throw 'Unsupported command shim; use the managed installer or a native executable.' } - } - $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) - $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw | ConvertFrom-Json - $binProperty=$manifest.PSObject.Properties['bin'] - if (!$binProperty) { throw 'Package manifest has no bin entry.' } - $bins=$binProperty.Value - $relative=$null - if ($bins -is [string]) { $relative=$bins } - elseif ($null -ne $bins -and $bins.PSObject.Properties[$binName]) { $relative=$bins.PSObject.Properties[$binName].Value } - if ($relative -isnot [string] -or !$relative -or [IO.Path]::IsPathRooted($relative)) { throw 'Invalid package bin entry.' } - $entry=[IO.Path]::GetFullPath((Join-Path $packageRoot $relative)) - if (!$entry.StartsWith($packageRoot + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Package bin entry escaped its package.' } -''') -replace(sh, ' --update Reinstall the versions tested by this script', ' --update Reinstall the versions pinned in versions.json') -replace(sh, '''Installs in user space. Existing system Node, npm configuration and login data -are not replaced. Downloads are cached, resumed and SHA-256 checked. Proxy/CA -settings are inherited. No login, paid call or OS package install is automatic. -''', '''No automatic login or PATH changes. Versions and platform notes: README.md. -''') -replace(ps, '''Per-user installation; no administrator, login or paid model call is required. -Downloads are cached, resumed, retried and SHA-256 checked. Existing system Node, -npm proxy/registry configuration and credentials are preserved. -''', '''No automatic login or PATH changes. Pi on Windows requires Bash. -''') -replace('tests/test_installers.py', " print('https://registry.npmjs.org/' if a[-1]=='registry' else os.environ['LMM_TEST_CACHE']);sys.exit(0)", " print('https://registry.npmjs.org/' if a[-1]=='registry' else os.environ.get('npm_config_ignore_scripts','false') if a[-1]=='ignore-scripts' else os.environ['LMM_TEST_CACHE']);sys.exit(0)") -versions = P / 'versions.json' -v = json.loads(versions.read_text()) -v['script_version'] = '2026.09.20.1' -versions.write_text(json.dumps(v, indent=2) + '\n') diff --git a/versions.json b/versions.json index 8090e48..c00f33d 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "script_version": "2026.09.19.1", + "script_version": "2026.09.20.1", "node_version": "24.21.0", "node_sha256": { "linux-x64": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", From aed91169d50a181e66d14942c529c1c320766c5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:20:10 +0000 Subject: [PATCH 03/39] fix: pin tool menus to corrected installers --- menu.ps1 | 8 ++++---- menu.sh | 8 ++++---- tools/generate_menus.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/menu.ps1 b/menu.ps1 index 49e7c0a..f21dd25 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -4,9 +4,9 @@ param([switch]$Help) $ErrorActionPreference = 'Stop' if ($Help) { Write-Output 'LMM menu: .\menu.ps1 [-Help]. Interactive terminal required.'; exit 0 } $hashes = @{ - 'pi.ps1' = '32f69187d3cc2677e6d11ef0df74f2d98a6903766a25f1860a2dccc4b1c41326' - 'dsh.ps1' = '38d708ba3c06508349c47d541dfe6bc5c480402855f3a0f16ecc8bee5b2f3919' - 'lmm.ps1' = '25e44d51b0e378ae998c8a879fe875656a4f6d527968317a068f954a7abbab13' + 'pi.ps1' = '687fb5800a9c3f4d029ad47df9813768d67b083045d7122842fb8112254ecbb7' + 'dsh.ps1' = '6d47bb6ccb0f5e28c0b1c5b35a587a53a3e7ae8b7ca412a3fa63cd299acbdd00' + 'lmm.ps1' = '7ed5ea27c157a13d2603123776666af12781ebcb396e01bc9f07a63bbb8d46c7' 'lmm-use.ps1' = 'ac137e30b6609580cb6ee4fbb60ed77ed01ec6153b733140540d5bc38d9179c1' } $network = 'auto' @@ -25,7 +25,7 @@ function Fetch-Script([string]$Name) { $path = Join-Path $work $Name if ((Test-Path -LiteralPath $path) -and ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -eq $hashes[$Name])) { return $path } Write-Host '正在获取并校验安装程序(下载慢时会重试)…' - foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/5b6667854523bb355b50a4ffb3da5e13c1b5cf09/$Name")) { + foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/bb38629f825339f64c4b5b0018c3dde47787d9a2/$Name")) { for ($attempt = 1; $attempt -le 3; $attempt++) { try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 -ErrorAction Stop diff --git a/menu.sh b/menu.sh index 5735828..2120cc6 100755 --- a/menu.sh +++ b/menu.sh @@ -17,9 +17,9 @@ hash_file() { else printf '需要 sha256sum 或 shasum。\n' >&2; return 1; fi } expected_hash() { case "$1" in -pi.sh) printf '%s' '6d0ecd2a8a6e45fa19db01b51b6acf5e5fe1f3f4391b07fd8d60bb15b99d3030';; -dsh.sh) printf '%s' '8227ee030552fb1fb257e182af21c9ef746202cbdf150ef64b5d57ca57907555';; -lmm.sh) printf '%s' '0b63311dac684cd55ea8bd5c5d19cd11fe0b74ce6a434384fa272c70f15ac7cc';; +pi.sh) printf '%s' 'ae5f009593005768c10266618049135d0a9a42eb05f2d357ba5dbc4fccb533bf';; +dsh.sh) printf '%s' 'd4d18452773a93c8f22b1722ff4fce0d32af870510065d3a1472530b477e23b0';; +lmm.sh) printf '%s' 'bc5367e6bd2283680a1906d4748f808dcd9c4ef5e4c428d754bbef11ce48f5bc';; lmm-use.sh) printf '%s' '5240b7192e0fcb7700fd76b0d375f528422d6f38e751d220e9202ac6b6f8d9c5';; *) return 1;; esac; } @@ -35,7 +35,7 @@ fetch_script() { expected=$(expected_hash "$name") || return 1 if [ -f "$work/$name" ] && [ "$(hash_file "$work/$name")" = "$expected" ]; then return 0; fi printf '正在获取并校验安装程序(下载慢时会重试)…\n' - for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/5b6667854523bb355b50a4ffb3da5e13c1b5cf09/$name"; do + for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/bb38629f825339f64c4b5b0018c3dde47787d9a2/$name"; do if curl -q -fSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 2 --retry-delay 2 "$url" -o "$work/download"; then if [ "$(hash_file "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi printf '文件版本或校验不匹配,尝试固定版本备用地址。\n' >&2 diff --git a/tools/generate_menus.py b/tools/generate_menus.py index 20b0eff..a0303f5 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -4,7 +4,7 @@ import argparse, hashlib, subprocess p=Path(__file__).resolve().parents[1] a=argparse.ArgumentParser();a.add_argument('--check',action='store_true');args=a.parse_args() -revision='5b6667854523bb355b50a4ffb3da5e13c1b5cf09' +revision='bb38629f825339f64c4b5b0018c3dde47787d9a2' for ext in ('sh','ps1'): lines=[] for stem in ('pi','dsh','lmm','lmm-use'): From 2fda1c83d49cf41446b40eb40376b740d15d1bfa Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 16:20:27 +0800 Subject: [PATCH 04/39] chore: remove one-off preparation workflow after verification --- .github/workflows/_prepare-official.yml | 46 ------------------------- 1 file changed, 46 deletions(-) delete mode 100644 .github/workflows/_prepare-official.yml diff --git a/.github/workflows/_prepare-official.yml b/.github/workflows/_prepare-official.yml deleted file mode 100644 index f8b7fb5..0000000 --- a/.github/workflows/_prepare-official.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Prepare official installer fixes -on: - push: - branches: [codex/official-installers-20260920] - paths: [tools/_apply_official_fix.py, .github/workflows/_prepare-official.yml] -permissions: - contents: write -jobs: - prepare: - if: github.repository == 'TokenNotIncluded/lmm-scripts' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 - with: - node-version: 24.21.0 - - name: Apply and test source edits - run: | - python3 tools/_apply_official_fix.py - python3 tools/generate.py - python3 tools/generate.py --check - python3 tests/test_installers.py - python3 tests/test_official_policy.py - shellcheck *.sh - rm tools/_apply_official_fix.py - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add templates versions.json '*.sh' '*.ps1' tests tools/_apply_official_fix.py - git commit -m 'fix: follow Pi installation policy and check platform prerequisites' - python3 - <<'PY' - from pathlib import Path - import re, subprocess - path = Path('tools/generate_menus.py') - revision = subprocess.check_output(['git','rev-parse','HEAD'], text=True).strip() - text, count = re.subn(r"revision='[0-9a-f]{40}'", f"revision='{revision}'", path.read_text()) - assert count == 1 - path.write_text(text) - PY - python3 tools/generate_menus.py - python3 tools/generate_menus.py --check - git add tools/generate_menus.py menu.sh menu.ps1 - git commit -m 'fix: pin tool menus to corrected installers' - git push origin HEAD:codex/official-installers-20260920 From 327fe0b65f9948da70b5f0010dda589a36e78e86 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 16:22:17 +0800 Subject: [PATCH 05/39] fix: decode menu templates as UTF-8 on Windows --- tools/generate_menus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/generate_menus.py b/tools/generate_menus.py index a0303f5..00c6597 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -12,7 +12,7 @@ payload=subprocess.check_output(['git','show',f'{revision}:{name}'],cwd=p) sha=hashlib.sha256(payload).hexdigest() lines.append(f"{name}) printf '%s' '{sha}';;" if ext=='sh' else f" '{name}' = '{sha}'") - text=(p/f'templates/menu.{ext}.in').read_text().replace('@@REVISION@@',revision).replace('@@HASHES@@','\n'.join(lines)) + text=(p/f'templates/menu.{ext}.in').read_text(encoding='utf-8').replace('@@REVISION@@',revision).replace('@@HASHES@@','\n'.join(lines)) data=text.encode('utf-8-sig' if ext=='ps1' else 'utf-8') path=p/f'menu.{ext}' if args.check: From 3a323b5550f0dda9a3ad51fa2e80d7f602202a51 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 16:23:41 +0800 Subject: [PATCH 06/39] test: cover UTF-8 Pi shell paths before final Windows checks --- .github/workflows/_prepare-official.yml | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/_prepare-official.yml diff --git a/.github/workflows/_prepare-official.yml b/.github/workflows/_prepare-official.yml new file mode 100644 index 0000000..5dfe728 --- /dev/null +++ b/.github/workflows/_prepare-official.yml @@ -0,0 +1,62 @@ +name: Verify UTF-8 installer paths +on: + push: + branches: [codex/official-installers-20260920] + paths: [.github/workflows/_prepare-official.yml] +permissions: + contents: write +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + - name: Correct UTF-8 reads and regenerate + run: | + python3 - <<'PY' + from pathlib import Path + p = Path('templates/install.ps1.in') + text = p.read_text(encoding='utf-8') + replacements = { + 'Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json': 'Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json', + "Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw | ConvertFrom-Json": "Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json", + } + for old, new in replacements.items(): + assert text.count(old) == 1, old + text = text.replace(old, new) + p.write_text(text, encoding='utf-8') + p = Path('tests/test-official-policy.ps1') + text = p.read_text(encoding='utf-8') + old = "$custom=Join-Path $testRoot 'custom bash.exe'; Set-Content -LiteralPath $custom -Value ''" + new = "$custom=Join-Path $testRoot ('custom '+[char]0x6D4B+[char]0x8BD5+' bash.exe'); Set-Content -LiteralPath $custom -Value ''" + assert text.count(old) == 1 + text = text.replace(old, new) + old = '@{shellPath=$custom} | ConvertTo-Json | Set-Content -LiteralPath $config' + new = "[IO.File]::WriteAllText($config,(@{shellPath=$custom} | ConvertTo-Json),[Text.UTF8Encoding]::new($false))" + assert text.count(old) == 1 + text = text.replace(old, new) + p.write_text(text, encoding='utf-8') + PY + python3 tools/generate.py + python3 tools/generate.py --check + pwsh -NoProfile -File tests/test-powershell.ps1 + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add templates/install.ps1.in pi.ps1 dsh.ps1 lmm.ps1 tests/test-official-policy.ps1 + git commit -m 'fix: preserve UTF-8 shell paths and package manifests on PowerShell 5.1' + python3 - <<'PY' + from pathlib import Path + import re, subprocess + p = Path('tools/generate_menus.py') + revision = subprocess.check_output(['git','rev-parse','HEAD'], text=True).strip() + text, count = re.subn(r"revision='[0-9a-f]{40}'", f"revision='{revision}'", p.read_text(encoding='utf-8')) + assert count == 1 + p.write_text(text, encoding='utf-8') + PY + python3 tools/generate_menus.py + python3 tools/generate_menus.py --check + git add tools/generate_menus.py menu.sh menu.ps1 + git commit -m 'fix: update menu pins for UTF-8 path handling' + git push origin HEAD:codex/official-installers-20260920 From 95c162c2031ecba34942b2a91631c1ec1f6f3d05 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:23:58 +0000 Subject: [PATCH 07/39] fix: preserve UTF-8 shell paths and package manifests on PowerShell 5.1 --- dsh.ps1 | 4 ++-- lmm.ps1 | 4 ++-- pi.ps1 | 4 ++-- templates/install.ps1.in | 4 ++-- tests/test-official-policy.ps1 | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dsh.ps1 b/dsh.ps1 index fab6110..635450c 100644 --- a/dsh.ps1 +++ b/dsh.ps1 @@ -128,7 +128,7 @@ function Invoke-Native([string]$Command, [string[]]$Arguments) { default { throw 'Unsupported command shim; use the managed installer or a native executable.' } } $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) - $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw | ConvertFrom-Json + $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json $binProperty=$manifest.PSObject.Properties['bin'] if (!$binProperty) { throw 'Package manifest has no bin entry.' } $bins=$binProperty.Value @@ -161,7 +161,7 @@ function Assert-PiShell { if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } $settingsPath=Join-Path $agentDirectory 'settings.json' if (Test-Path -LiteralPath $settingsPath) { - try { $settings=Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } + try { $settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } $property=$settings.PSObject.Properties['shellPath'] diff --git a/lmm.ps1 b/lmm.ps1 index fe63148..9e0e28a 100644 --- a/lmm.ps1 +++ b/lmm.ps1 @@ -128,7 +128,7 @@ function Invoke-Native([string]$Command, [string[]]$Arguments) { default { throw 'Unsupported command shim; use the managed installer or a native executable.' } } $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) - $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw | ConvertFrom-Json + $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json $binProperty=$manifest.PSObject.Properties['bin'] if (!$binProperty) { throw 'Package manifest has no bin entry.' } $bins=$binProperty.Value @@ -161,7 +161,7 @@ function Assert-PiShell { if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } $settingsPath=Join-Path $agentDirectory 'settings.json' if (Test-Path -LiteralPath $settingsPath) { - try { $settings=Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } + try { $settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } $property=$settings.PSObject.Properties['shellPath'] diff --git a/pi.ps1 b/pi.ps1 index d674376..9e43e28 100644 --- a/pi.ps1 +++ b/pi.ps1 @@ -128,7 +128,7 @@ function Invoke-Native([string]$Command, [string[]]$Arguments) { default { throw 'Unsupported command shim; use the managed installer or a native executable.' } } $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) - $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw | ConvertFrom-Json + $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json $binProperty=$manifest.PSObject.Properties['bin'] if (!$binProperty) { throw 'Package manifest has no bin entry.' } $bins=$binProperty.Value @@ -161,7 +161,7 @@ function Assert-PiShell { if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } $settingsPath=Join-Path $agentDirectory 'settings.json' if (Test-Path -LiteralPath $settingsPath) { - try { $settings=Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } + try { $settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } $property=$settings.PSObject.Properties['shellPath'] diff --git a/templates/install.ps1.in b/templates/install.ps1.in index 49a97af..2ed8612 100644 --- a/templates/install.ps1.in +++ b/templates/install.ps1.in @@ -104,7 +104,7 @@ function Invoke-Native([string]$Command, [string[]]$Arguments) { default { throw 'Unsupported command shim; use the managed installer or a native executable.' } } $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) - $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw | ConvertFrom-Json + $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json $binProperty=$manifest.PSObject.Properties['bin'] if (!$binProperty) { throw 'Package manifest has no bin entry.' } $bins=$binProperty.Value @@ -137,7 +137,7 @@ function Assert-PiShell { if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } $settingsPath=Join-Path $agentDirectory 'settings.json' if (Test-Path -LiteralPath $settingsPath) { - try { $settings=Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json } + try { $settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } $property=$settings.PSObject.Properties['shellPath'] diff --git a/tests/test-official-policy.ps1 b/tests/test-official-policy.ps1 index fa51051..d9d66d9 100644 --- a/tests/test-official-policy.ps1 +++ b/tests/test-official-policy.ps1 @@ -36,8 +36,8 @@ try { $config=Join-Path $env:PI_CODING_AGENT_DIR 'settings.json' @{shellPath=(Join-Path $testRoot 'missing.exe')} | ConvertTo-Json | Set-Content -LiteralPath $config Assert-Throws { Assert-PiShell } 'shellPath does not exist' - $custom=Join-Path $testRoot 'custom bash.exe'; Set-Content -LiteralPath $custom -Value '' - @{shellPath=$custom} | ConvertTo-Json | Set-Content -LiteralPath $config + $custom=Join-Path $testRoot ('custom '+[char]0x6D4B+[char]0x8BD5+' bash.exe'); Set-Content -LiteralPath $custom -Value '' + [IO.File]::WriteAllText($config,(@{shellPath=$custom} | ConvertTo-Json),[Text.UTF8Encoding]::new($false)) $before=Get-Content -LiteralPath $config -Raw Assert-PiShell if ((Get-Content -LiteralPath $config -Raw) -ne $before) { throw 'Shell preflight modified settings.' } From 5ed91d5b5cd3173b48c6a358ae3f5865e753d818 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:23:58 +0000 Subject: [PATCH 08/39] fix: update menu pins for UTF-8 path handling --- menu.ps1 | 8 ++++---- menu.sh | 2 +- tools/generate_menus.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/menu.ps1 b/menu.ps1 index f21dd25..0c8e424 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -4,9 +4,9 @@ param([switch]$Help) $ErrorActionPreference = 'Stop' if ($Help) { Write-Output 'LMM menu: .\menu.ps1 [-Help]. Interactive terminal required.'; exit 0 } $hashes = @{ - 'pi.ps1' = '687fb5800a9c3f4d029ad47df9813768d67b083045d7122842fb8112254ecbb7' - 'dsh.ps1' = '6d47bb6ccb0f5e28c0b1c5b35a587a53a3e7ae8b7ca412a3fa63cd299acbdd00' - 'lmm.ps1' = '7ed5ea27c157a13d2603123776666af12781ebcb396e01bc9f07a63bbb8d46c7' + 'pi.ps1' = '64d39c29e77d7db50fb62ca306f998e24a1839640710c147ead900179c7f40ac' + 'dsh.ps1' = '163a5e60322869b5ad55dc9eb918f0acab25fdb8301bfbba026cdd13fd88f0f7' + 'lmm.ps1' = 'ce77455667245f02adbb25c4a9d36b1ba9c05e0fdc4b7466cd84e08d13397d69' 'lmm-use.ps1' = 'ac137e30b6609580cb6ee4fbb60ed77ed01ec6153b733140540d5bc38d9179c1' } $network = 'auto' @@ -25,7 +25,7 @@ function Fetch-Script([string]$Name) { $path = Join-Path $work $Name if ((Test-Path -LiteralPath $path) -and ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -eq $hashes[$Name])) { return $path } Write-Host '正在获取并校验安装程序(下载慢时会重试)…' - foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/bb38629f825339f64c4b5b0018c3dde47787d9a2/$Name")) { + foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/95c162c2031ecba34942b2a91631c1ec1f6f3d05/$Name")) { for ($attempt = 1; $attempt -le 3; $attempt++) { try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 -ErrorAction Stop diff --git a/menu.sh b/menu.sh index 2120cc6..2bc8b4a 100755 --- a/menu.sh +++ b/menu.sh @@ -35,7 +35,7 @@ fetch_script() { expected=$(expected_hash "$name") || return 1 if [ -f "$work/$name" ] && [ "$(hash_file "$work/$name")" = "$expected" ]; then return 0; fi printf '正在获取并校验安装程序(下载慢时会重试)…\n' - for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/bb38629f825339f64c4b5b0018c3dde47787d9a2/$name"; do + for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/95c162c2031ecba34942b2a91631c1ec1f6f3d05/$name"; do if curl -q -fSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 2 --retry-delay 2 "$url" -o "$work/download"; then if [ "$(hash_file "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi printf '文件版本或校验不匹配,尝试固定版本备用地址。\n' >&2 diff --git a/tools/generate_menus.py b/tools/generate_menus.py index 00c6597..1796bb3 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -4,7 +4,7 @@ import argparse, hashlib, subprocess p=Path(__file__).resolve().parents[1] a=argparse.ArgumentParser();a.add_argument('--check',action='store_true');args=a.parse_args() -revision='bb38629f825339f64c4b5b0018c3dde47787d9a2' +revision='95c162c2031ecba34942b2a91631c1ec1f6f3d05' for ext in ('sh','ps1'): lines=[] for stem in ('pi','dsh','lmm','lmm-use'): From eccdf9ada0ff823b1512d89f380430b115cff8dc Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 16:24:20 +0800 Subject: [PATCH 09/39] chore: remove temporary UTF-8 regeneration workflow --- .github/workflows/_prepare-official.yml | 62 ------------------------- 1 file changed, 62 deletions(-) delete mode 100644 .github/workflows/_prepare-official.yml diff --git a/.github/workflows/_prepare-official.yml b/.github/workflows/_prepare-official.yml deleted file mode 100644 index 5dfe728..0000000 --- a/.github/workflows/_prepare-official.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Verify UTF-8 installer paths -on: - push: - branches: [codex/official-installers-20260920] - paths: [.github/workflows/_prepare-official.yml] -permissions: - contents: write -jobs: - prepare: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - - name: Correct UTF-8 reads and regenerate - run: | - python3 - <<'PY' - from pathlib import Path - p = Path('templates/install.ps1.in') - text = p.read_text(encoding='utf-8') - replacements = { - 'Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json': 'Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json', - "Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw | ConvertFrom-Json": "Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json", - } - for old, new in replacements.items(): - assert text.count(old) == 1, old - text = text.replace(old, new) - p.write_text(text, encoding='utf-8') - p = Path('tests/test-official-policy.ps1') - text = p.read_text(encoding='utf-8') - old = "$custom=Join-Path $testRoot 'custom bash.exe'; Set-Content -LiteralPath $custom -Value ''" - new = "$custom=Join-Path $testRoot ('custom '+[char]0x6D4B+[char]0x8BD5+' bash.exe'); Set-Content -LiteralPath $custom -Value ''" - assert text.count(old) == 1 - text = text.replace(old, new) - old = '@{shellPath=$custom} | ConvertTo-Json | Set-Content -LiteralPath $config' - new = "[IO.File]::WriteAllText($config,(@{shellPath=$custom} | ConvertTo-Json),[Text.UTF8Encoding]::new($false))" - assert text.count(old) == 1 - text = text.replace(old, new) - p.write_text(text, encoding='utf-8') - PY - python3 tools/generate.py - python3 tools/generate.py --check - pwsh -NoProfile -File tests/test-powershell.ps1 - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add templates/install.ps1.in pi.ps1 dsh.ps1 lmm.ps1 tests/test-official-policy.ps1 - git commit -m 'fix: preserve UTF-8 shell paths and package manifests on PowerShell 5.1' - python3 - <<'PY' - from pathlib import Path - import re, subprocess - p = Path('tools/generate_menus.py') - revision = subprocess.check_output(['git','rev-parse','HEAD'], text=True).strip() - text, count = re.subn(r"revision='[0-9a-f]{40}'", f"revision='{revision}'", p.read_text(encoding='utf-8')) - assert count == 1 - p.write_text(text, encoding='utf-8') - PY - python3 tools/generate_menus.py - python3 tools/generate_menus.py --check - git add tools/generate_menus.py menu.sh menu.ps1 - git commit -m 'fix: update menu pins for UTF-8 path handling' - git push origin HEAD:codex/official-installers-20260920 From d8ef9e2959d415e31fb21acebfc161e8f1c93dd0 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 16:41:21 +0800 Subject: [PATCH 10/39] test: prepare shared helper composition and native Termux regressions --- .github/workflows/_refactor.yml | 61 +++++++++ tools/_termux_refactor.py | 223 ++++++++++++++++++++++++++++++++ tools/_termux_tests.txt | 136 +++++++++++++++++++ tools/generate.py | 101 +++++++++++---- tools/render.py | 32 +++++ 5 files changed, 529 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/_refactor.yml create mode 100644 tools/_termux_refactor.py create mode 100644 tools/_termux_tests.txt create mode 100644 tools/render.py diff --git a/.github/workflows/_refactor.yml b/.github/workflows/_refactor.yml new file mode 100644 index 0000000..f9e4608 --- /dev/null +++ b/.github/workflows/_refactor.yml @@ -0,0 +1,61 @@ +name: Prepare shared installers +on: + push: + branches: [codex/official-installers-20260920] + paths: [tools/_termux_refactor.py, tools/_termux_tests.txt, .github/workflows/_refactor.yml] +permissions: + contents: write +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24.21.0 + - name: Refactor and validate + run: | + python3 tools/_termux_refactor.py + python3 tools/generate.py + python3 tools/generate_menus.py + python3 tools/generate.py --check + python3 tools/generate_menus.py --check + python3 tests/test_installers.py + python3 tests/test_official_policy.py + shellcheck *.sh + pwsh -NoProfile -File tests/test-powershell.ps1 + python3 - <<'PY' + from pathlib import Path + import subprocess + names=[f'{name}.{ext}' for ext in ('sh','ps1') for name in ('pi','dsh','lmm')] + before=after=0 + for name in names: + old=len(subprocess.check_output(['git','show',f'eccdf9ada0ff823b1512d89f380430b115cff8dc:{name}'])) + new=Path(name).stat().st_size + before+=old;after+=new + print(f'{name}: {old} -> {new} bytes') + print(f'TOTAL: {before} -> {after} bytes; reduction {(1-after/before)*100:.1f}%') + assert after < before + PY + rm tools/_termux_refactor.py tools/_termux_tests.txt + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'refactor: share installer helpers and handle native Termux paths and launchers' + python3 - <<'PY' + from pathlib import Path + import re, subprocess + p=Path('tools/generate_menus.py') + sha=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() + text,count=re.subn(r"revision='[0-9a-f]{40}'",f"revision='{sha}'",p.read_text(encoding='utf-8')) + assert count==1 + p.write_text(text,encoding='utf-8') + PY + python3 tools/generate_menus.py + python3 tools/generate_menus.py --check + git add tools/generate_menus.py menu.sh menu.ps1 + git commit -m 'fix: pin menus to shared Termux-aware installers' + git push origin HEAD:codex/official-installers-20260920 diff --git a/tools/_termux_refactor.py b/tools/_termux_refactor.py new file mode 100644 index 0000000..776676b --- /dev/null +++ b/tools/_termux_refactor.py @@ -0,0 +1,223 @@ +from pathlib import Path +import json, re +P = Path.cwd() + +def read(path): return (P/path).read_text(encoding='utf-8') +def write(path, text): + f=P/path; f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(text, encoding='utf-8', newline='\n') +def once(text, old, new): + assert text.count(old)==1, (old[:120], text.count(old)) + return text.replace(old,new) +def take(text, name, ext): + pattern = rf'(?m)^{re.escape(name)}\(\) \{{' if ext=='sh' else rf'(?m)^function {re.escape(name)}(?=[( {{])' + m=re.search(pattern,text); assert m, name + line_end=text.find('\n',m.start()) + if text[m.start():line_end].rstrip().endswith('}'): + end=line_end+1 + else: + end=text.index('\n}',line_end)+2 + if text[end:end+1]=='\n': end+=1 + return text[:m.start()]+text[end:], text[m.start():end] +def take_many(text, names, ext): + parts=[] + for name in names: + text, part=take(text,name,ext); parts.append(part) + return text,''.join(parts) + +write('templates/lib/root.sh', '''lmm_root() { + printf '%s\\n' "${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}" +} +''') +write('templates/lib/termux.sh', '''# Native Termux uses Android/bionic, not desktop Linux/glibc. +lmm_is_termux() { + [ -n "${TERMUX_VERSION:-}${TERMUX_APP__PACKAGE_NAME:-}" ] || + case "${PREFIX:-}" in */com.termux/files/usr) true;; *) false;; esac +} +lmm_temp_root() { + if [ -n "${TMPDIR:-}" ]; then printf '%s\\n' "$TMPDIR" + elif lmm_is_termux; then printf '%s/tmp\\n' "${PREFIX:-$HOME/.cache/lmm-tools}" + else printf '/tmp\\n'; fi +} +lmm_check_storage() { + lmm_is_termux || return 0 + local resolved + # realpath -m also resolves missing paths and symlinked storage aliases. + command -v realpath >/dev/null 2>&1 || { + printf 'Termux needs coreutils: pkg install coreutils\\n' >&2; return 1; + } + resolved=$(realpath -m -- "$1") || return 1 + case "$resolved/" in + /sdcard/*|/storage/*|/mnt/sdcard/*|/mnt/media_rw/*|/mnt/runtime/*|/mnt/user/*|/mnt/pass_through/*) + printf 'Use Termux private storage under HOME, not shared storage: %s\\n' "$1" >&2 + return 1;; + esac +} +''') +sh=read('templates/install.sh.in') +sh=once(sh, 'ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}', 'ROOT=$(lmm_root)') +sh=once(sh, 'CACHE=${LMM_CACHE_ROOT:-$ROOT/cache}', 'case "$ROOT" in /*) ;; *) ROOT="$PWD/$ROOT";; esac\nCACHE=${LMM_CACHE_ROOT:-$ROOT/cache}') +sh=once(sh, 'case "$(uname -s)" in Linux) OS=linux;; Darwin) OS=darwin;; *) fail \'This script supports Linux/macOS. On Windows use the .ps1 script.\';; esac\ncase "$(uname -m)" in x86_64|amd64) ARCH=x64;; arm64|aarch64) ARCH=arm64;; *) fail \'Unsupported CPU; use the documented source build on this platform.\';; esac\nPLATFORM="$OS-$ARCH"', '''case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac +if lmm_is_termux; then OS=android; fi +case "$(uname -m)" in + x86_64|amd64) ARCH=x64;; arm64|aarch64) ARCH=arm64;; + armv7l|armv8l|arm) [ "$OS" = android ] || fail '32-bit desktop Linux is not supported'; ARCH=arm;; + i386|i686) [ "$OS" = android ] || fail '32-bit desktop Linux is not supported'; ARCH=ia32;; + *) fail 'Unsupported CPU';; +esac +PLATFORM="$OS-$ARCH" +lmm_check_storage "$ROOT" || exit 1 +lmm_check_storage "$CACHE" || exit 1 +if [ "$OS" = android ] && [ "$TARGET" != lmm ]; then + compatible_node || fail 'In Termux, install native Node/npm: pkg install nodejs npm git; then rerun. Desktop Node cannot run on Android.' + command -v git >/dev/null 2>&1 || fail 'Pi/DSH need git: pkg install git' +fi''') +sh=once(sh, '''node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit((a===22&&b>=19)||a>=24?0:1)' >/dev/null 2>&1''', '''node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(((a===22&&b>=19)||a>=24)&&(process.argv[1]!=="android"||process.platform==="android")?0:1)' "$OS" >/dev/null 2>&1''') +sh=once(sh, ''' # Android uses bionic, not the glibc used by the Linux Node archives. + if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then + fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' + fi +''', ''' [ "$OS" != android ] || fail 'In Termux, run pkg install nodejs npm git; desktop Node archives are incompatible.' +''') +sh=once(sh, ''' if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then''', ''' if [ "$OS" = android ]; then''') +sh=once(sh, 'umask 077\nmkdir -p', '''umask 077 +if [ "$OS" = android ]; then + TMPDIR=$(lmm_temp_root); lmm_check_storage "$TMPDIR" || exit 1 + mkdir -p "$TMPDIR"; export TMPDIR + if [ "$TARGET" = dsh ]; then log 'DSH native dependencies have not been validated on Android.'; fi +fi +mkdir -p''') +sh=once(sh, ' "$work/bin/$entry" --version >/dev/null', ' node "$work/bin/$entry" --version >/dev/null') +sh=once(sh, ' bounded "$work/bin/pnpm" --version', ' bounded node "$work/bin/pnpm" --version') +sh=once(sh, " printf '#!/usr/bin/env bash\\n# Managed by LMM installers.\\n'", ''' if [ "$OS" = android ]; then printf '#!%s\\n' "$BASH" + else printf '#!/usr/bin/env bash\\n'; fi + printf '# Managed by LMM installers.\\n' ''') +sh=once(sh, ''' printf 'exec %s "$@"\\n' "$(quote_sh "$CLIENT")"''', ''' if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\\n' "$(quote_sh "$CLIENT")" + else printf 'exec %s %s "$@"\\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi''') +sh, hash_source=take(sh,'sha256','sh') +write('templates/lib/hash.sh', '''sha256() { + local digest + if command -v sha256sum >/dev/null 2>&1; then digest=$(sha256sum "$1") || return; printf '%s\\n' "${digest%% *}" + elif command -v shasum >/dev/null 2>&1; then digest=$(shasum -a 256 "$1") || return; printf '%s\\n' "${digest%% *}" + elif command -v openssl >/dev/null 2>&1; then digest=$(openssl dgst -sha256 "$1") || return; printf '%s\\n' "${digest##* }" + else printf 'Install a SHA-256 tool.\\n' >&2; return 1; fi +} +''') +sh, quote=take(sh,'quote_sh','sh'); write('templates/lib/quote.sh',quote) +sh, network=take_many(sh,['rank_urls','urls_for','download'],'sh') +network=once(network, "cat \"$probe_dir\"/* | sort -n -k1,1 -k2,2 | awk '{print $2}'", "cat \"$probe_dir\"/* | sort -n -k1,1 -k2,2 | while read -r elapsed index; do printf '%s\\n' \"$index\"; done") +write('templates/lib/download.sh',network) +sh, node=take_many(sh,['compatible_node','ensure_node','configure_npm','bounded','with_registry_retry','install_client'],'sh'); write('templates/lib/node.sh',node) +sh, pnpm=take(sh,'ensure_pnpm','sh'); sh, lmm=take(sh,'install_lmm','sh') +start=sh.index('if [ "$TARGET" = lmm ]; then install_lmm\n') +end=sh.index("PHASE='launchers and PATH'",start) +sh=sh[:start]+'install_tool\n'+sh[end:] +sh=once(sh,'@@CONSTANTS@@','@@CONSTANTS@@\n@@LIBRARIES@@') +start=sh.index(' if [ "$TARGET" != lmm ]; then\n if compatible_node;') +end=sh.index(" log 'Executable check complete",start) +write('templates/lib/node-check.sh',sh[start:end]) +sh=sh[:start]+'@@NODE_CHECK@@\n'+sh[end:] +sh=sh.replace('# Generated from templates/install.sh.in and versions.json. No sudo, no API keys.', '# Generated from templates/ and versions.json. Edit the source, not this file.') +write('templates/install.sh.in',sh) +write('templates/tools/pi.sh', '''install_tool() { + ensure_node; configure_npm + install_client @earendil-works/pi-coding-agent "$PI_VERSION" pi + PHASE='Pi LMM provider' + with_registry_retry node "$CLIENT" install "npm:@tokennotincluded/pi-lmm-provider@$PI_PROVIDER_VERSION" + if [ "$OS" = android ]; then log 'Optional clipboard: install the Termux:API app and pkg install termux-api. Open login links with termux-open-url.'; fi +} +''') +write('templates/tools/dsh.sh',pnpm+'''install_tool() { + ensure_node; configure_npm; ensure_pnpm + install_client @deepseek-ai/dsh "$DSH_VERSION" dsh + PHASE='DSH LMM provider' + local artifact="$CACHE/${DSH_PROVIDER_URL##*/}" + download "$DSH_PROVIDER_URL" "$artifact" "$DSH_PROVIDER_SHA256" + with_registry_retry node "$CLIENT" plugin --profile "$PROFILE" add "$artifact" --ignore-scripts --store-dir "$CACHE/pnpm" +} +''') +write('templates/tools/lmm.sh',lmm+'install_tool() { install_lmm; }\n') +ps=read('templates/install.ps1.in') +ps, common=take_many(ps,['Setting','QuoteArgument','Stop-InstallChild','Invoke-Bounded','Invoke-Native','Get-Hash'],'ps1') +write('templates/lib/common.ps1',common) +ps, network=take_many(ps,['Set-RequestProxy','New-DownloadRequest','Get-RankedUrls','Get-DownloadUrls','Receive-Stream','Get-VerifiedFile'],'ps1') +write('templates/lib/download.ps1',network) +ps, node=take_many(ps,['Test-Node','Install-Node','Set-NpmNetwork','Invoke-WithRegistryRetry','Install-Client'],'ps1') +write('templates/lib/node.ps1',node) +ps, shell=take(ps,'Assert-PiShell','ps1'); ps, pnpm=take(ps,'Install-Pnpm','ps1'); ps, lmm=take(ps,'Install-Lmm','ps1') +start=ps.index(" if ($Target -eq 'lmm') { Install-Lmm }\n") +end=ps.index(" $script:Phase='launcher and PATH'",start) +run=ps[start:end] +dsh_start=run.index(' Install-Pnpm\n') +dsh_end=run.rindex('\n }\n }') +dsh_body=run[dsh_start:dsh_end] +write('templates/tools/pi.ps1',shell+'''function Install-Tool { + Install-Node; Set-NpmNetwork + Install-Client '@earendil-works/pi-coding-agent' $PiVersion 'pi' + $script:Phase='Pi LMM provider' + Invoke-WithRegistryRetry $script:Client @('install',"npm:@tokennotincluded/pi-lmm-provider@$PiProviderVersion") +} +''') +write('templates/tools/dsh.ps1',pnpm+'function Install-Tool {\n Install-Node; Set-NpmNetwork\n'+dsh_body+'\n}\n') +write('templates/tools/lmm.ps1',lmm+'function Install-Tool { Install-Lmm }\n') +ps=ps[:start]+' Install-Tool\n'+ps[end:] +ps=once(ps,'@@CONSTANTS@@','@@CONSTANTS@@\n@@LIBRARIES@@') +write('templates/install.ps1.in',ps) +menu=read('templates/menu.sh.in') +menu,_=take(menu,'hash_file','sh') +menu=menu.replace('hash_file ', 'sha256 ') +menu=once(menu, 'set -u\n', 'set -u\n@@LIBRARIES@@\n') +menu=once(menu,'root=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}', 'root=$(lmm_root)') +menu=once(menu,'work=$(mktemp -d "${TMPDIR:-/tmp}/lmm-menu.XXXXXXXX") || exit 1', '''umask 077 +temp_root=$(lmm_temp_root) +lmm_check_storage "$temp_root" || exit 1 +mkdir -p "$temp_root" || exit 1 +work=$(mktemp -d "$temp_root/lmm-menu.XXXXXXXX") || exit 1''') +write('templates/menu.sh.in',menu) +use=read('lmm-use.sh') +use=once(use, 'ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}', '@@LIBRARIES@@\nROOT=$(lmm_root)') +write('templates/use.sh.in',use) +test=read('tests/test_installers.py') +test=once(test,"for name in ['curl','uname','node','npm']:","for name in ['curl','uname','node','npm','realpath']:") +test=once(test,"if name=='uname':print('Linux' if '-s' in a else 'x86_64')", "if name=='uname':print(os.environ.get('LMM_TEST_OS','Linux') if '-s' in a else os.environ.get('LMM_TEST_ARCH','x86_64'))\nelif name=='realpath':print(os.path.realpath(a[-1]))") +test=once(test," if '-e' in a:sys.exit(0 if os.environ.get('LMM_TEST_NODE_OK','1')=='1' else 1)", """ if '-e' in a: + ok=os.environ.get('LMM_TEST_NODE_OK','1')=='1' + if a[-1]=='android':ok=ok and os.environ.get('LMM_TEST_NODE_PLATFORM','android')=='android' + sys.exit(0 if ok else 1) + if a and Path(a[0]).is_file():os.execv('/bin/bash',['bash',a[0]]+a[1:])""") +write('tests/test_installers.py',test) +v=json.loads(read('versions.json'));v['script_version']='2026.09.20.2';write('versions.json',json.dumps(v,indent=2)+'\n') +menus=read('tools/generate_menus.py') +menus=once(menus, 'import argparse, hashlib, subprocess', 'import argparse, hashlib, subprocess\nfrom render import emit, libraries') +start=menus.index(' data=text.encode(') +menus=menus[:start]+''' if ext=='sh': text=text.replace('@@LIBRARIES@@',libraries('lib/root.sh','lib/hash.sh','lib/termux.sh')) + emit(f'menu.{ext}',text,args.check,'utf-8-sig' if ext=='ps1' else 'utf-8') +''' +write('tools/generate_menus.py',menus) +policy=read('tests/test_official_policy.py') +start=policy.index("if __name__ == '__main__':") +policy=policy[:start]+read('tools/_termux_tests.txt')+'\n'+policy[start:] +write('tests/test_official_policy.py',policy) +readme=read('README.md');pos=readme.index('## 直接运行与更新') +readme=readme[:pos]+'''## Termux(原生 Android) + +先准备 Termux 自己的依赖,不使用桌面 Linux 的 Node 压缩包: + +```sh +pkg install bash curl coreutils nodejs npm git +curl -fsSL https://api.lmm.best/scripts/menu.sh | bash +``` + +安装目录保持在 `$HOME`。脚本检查 Node 是否为 Android 版本;拒绝把安装、缓存或临时目录放在 `/sdcard`、`/storage`,包括指向共享存储的链接。未设置 `TMPDIR` 时使用 `$PREFIX/tmp`,启动器使用当前 Bash 的绝对路径和明确的 Node 入口。 + +文本剪贴板另需 Termux:API 应用和 `pkg install termux-api`,不作为安装的强制条件。浏览器没有自动打开时,可手动用 `termux-open-url` 打开登录地址。脚本不申请存储权限、不清空缓存、不执行系统升级。 + +Pi 的安装方式遵循官方 Termux 文档。DSH 的 Android 原生依赖、LMM CLI 的 Android 源码构建尚未经真机验证;LMM CLI 没有 Android 预编译包。不要把环境模拟测试当成真机验证。 + +'''+readme[pos:] +write('README.md',readme) +maintenance=read('docs/maintenance.md') +maintenance=maintenance.replace('只修改模板和版本清单,再生成根目录脚本。公开脚本必须能独立运行;不要添加远程 `source` 依赖。', '''公共函数放在 `templates/lib/`,Pi、DSH、LMM 的差异放在 `templates/tools/`。`tools/render.py` 负责共用的文本读取、完整管道包装和生成检查;`tools/generate.py` 只组装当前工具需要的代码、版本和哈希。菜单复用同一份根目录、哈希和 Termux 函数,`lmm-use.sh` 也从模板生成。 + +只修改这些源文件和版本清单,再生成根目录脚本。`.sh` 与 `.ps1` 都保留单文件入口,不在运行时下载或 `source` 公共库;网站现有同步清单无需增加运行时文件。Windows/Linux/macOS 的编码和完整脚本校验保持不变。''') +write('docs/maintenance.md',maintenance) diff --git a/tools/_termux_tests.txt b/tools/_termux_tests.txt new file mode 100644 index 0000000..93b6288 --- /dev/null +++ b/tools/_termux_tests.txt @@ -0,0 +1,136 @@ + +class TermuxAndCompositionTests(unittest.TestCase): + def setUp(self): + self.fixture = fixtures.InstallerTests() + self.fixture.setUp() + self.prefix = self.fixture.base / 'termux prefix' + (self.prefix / 'tmp').mkdir(parents=True) + self.env = {'TERMUX_VERSION': 'test', 'PREFIX': str(self.prefix), 'TMPDIR': ''} + + def tearDown(self): + self.fixture.tearDown() + + def test_termux_32_bit_native_node_is_supported(self): + for arch in ('armv7l', 'i686'): + with self.subTest(arch=arch): + r = self.fixture.run_script('pi', env=self.env | {'LMM_TEST_ARCH': arch}) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) + + def test_desktop_32_bit_is_not_misidentified_as_termux(self): + r = self.fixture.run_script('pi', env={'TERMUX_VERSION': '', 'TERMUX_APP__PACKAGE_NAME': '', 'PREFIX': '', 'LMM_TEST_ARCH': 'armv7l'}) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(self.fixture.root.exists()) + + def test_termux_requires_native_android_node(self): + r = self.fixture.run_script('pi', env=self.env | {'LMM_TEST_NODE_PLATFORM': 'linux'}) + self.assertNotEqual(r.returncode, 0) + self.assertIn('native Node/npm', r.stderr) + self.assertFalse(self.fixture.root.exists()) + self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) + + def test_prefix_alone_identifies_termux(self): + env = self.env | {'TERMUX_VERSION': '', 'TERMUX_APP__PACKAGE_NAME': '', 'PREFIX': str(self.fixture.base / 'com.termux/files/usr'), 'LMM_TEST_NODE_OK': '0'} + r = self.fixture.run_script('pi', env=env) + self.assertNotEqual(r.returncode, 0) + self.assertIn('In Termux', r.stderr) + self.assertFalse(self.fixture.root.exists()) + + def test_termux_check_is_read_only(self): + r = self.fixture.run_script('pi', '--check', env=self.env) + self.assertIn(r.returncode, (0, 1)) + self.assertFalse(self.fixture.root.exists()) + self.assertFalse(any(name in ('curl', 'npm') and 'install' in args for name, args in self.fixture.calls())) + + def test_shared_storage_is_rejected_before_installation(self): + for path in ('/sdcard/lmm-tools', '/storage/emulated/0/lmm-tools', '/mnt/media_rw/card/lmm-tools'): + with self.subTest(path=path): + r = self.fixture.run_script('pi', '--root', path, env=self.env) + self.assertNotEqual(r.returncode, 0) + self.assertIn('shared storage', r.stderr) + self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) + + def test_shared_storage_symlink_ancestor_is_rejected(self): + alias = self.fixture.base / 'shared alias' + alias.symlink_to('/storage/emulated/0', target_is_directory=True) + r = self.fixture.run_script('pi', '--root', str(alias / 'tools'), env=self.env) + self.assertNotEqual(r.returncode, 0) + self.assertIn('shared storage', r.stderr) + + def test_shared_cache_and_temp_are_rejected(self): + for key in ('LMM_CACHE_ROOT', 'TMPDIR'): + with self.subTest(key=key): + r = self.fixture.run_script('pi', env=self.env | {key: '/sdcard/lmm-cache'}) + self.assertNotEqual(r.returncode, 0) + self.assertIn('shared storage', r.stderr) + self.assertFalse((self.fixture.root / 'bin/pi').exists()) + + def test_launcher_uses_absolute_bash_and_explicit_node(self): + r = self.fixture.run_script('pi', env=self.env) + self.assertEqual(r.returncode, 0, r.stderr) + body = (self.fixture.root / 'bin/pi').read_text() + self.assertNotIn('/usr/bin/env', body) + self.assertIn('/node', body) + self.assertTrue(body.startswith('#!/')) + version = fixtures.subprocess.run([str(self.fixture.root / 'bin/pi'), '--version'], env=self.fixture.env | self.env, capture_output=True, text=True) + self.assertEqual(version.returncode, 0, version.stderr) + + def test_menu_temp_fallback_uses_prefix_not_desktop_tmp(self): + source = (P / 'templates/lib/termux.sh').read_text(encoding='utf-8') + r = fixtures.subprocess.run(['bash', '-c', source + '\nlmm_temp_root'], env=self.fixture.env | self.env, capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(r.stdout.strip(), str(self.prefix / 'tmp')) + explicit = str(self.fixture.base / 'explicit temp') + r = fixtures.subprocess.run(['bash', '-c', source + '\nlmm_temp_root'], env=self.fixture.env | self.env | {'TMPDIR': explicit}, capture_output=True, text=True) + self.assertEqual(r.stdout.strip(), explicit) + + def test_termux_does_not_reuse_cached_linux_lmm(self): + v = fixtures.json.loads((P / 'versions.json').read_text()) + app = self.fixture.root / 'apps/lmm' / (v['lmm_version'] + '-linux-x64') + app.mkdir(parents=True) + (app / '.lmm-managed').write_text(v['lmm_version']) + binary = app / 'lmm' + binary.write_text('#!/bin/sh\nexit 0\n') + binary.chmod(0o755) + r = self.fixture.run_script('lmm', env=self.env) + self.assertNotEqual(r.returncode, 0) + self.assertIn('No Android LMM CLI binary', r.stderr) + self.assertFalse((self.fixture.root / 'bin/lmm').exists()) + + def test_relative_install_root_works(self): + relative = fixtures.os.path.relpath(self.fixture.root) + r = self.fixture.run_script('pi', '--root', relative) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertTrue((self.fixture.root / 'bin/pi').exists()) + + def test_generated_scripts_omit_other_target_implementations(self): + for ext, pnpm, lmm, node in [('sh', 'ensure_pnpm()', 'install_lmm()', 'ensure_node()'), ('ps1', 'function Install-Pnpm', 'function Install-Lmm', 'function Install-Node')]: + pi = (P / f'pi.{ext}').read_text() + dsh = (P / f'dsh.{ext}').read_text() + cli = (P / f'lmm.{ext}').read_text() + self.assertNotIn(pnpm, pi) + self.assertNotIn(lmm, pi) + self.assertNotIn(lmm, dsh) + self.assertNotIn(pnpm, cli) + self.assertNotIn(node, cli) + self.assertNotIn('DSH_PROVIDER_SHA256=', pi) + + def test_shared_helpers_are_embedded_once_and_remain_offline(self): + for target in ('pi', 'dsh', 'lmm', 'menu'): + body = (P / f'{target}.sh').read_text(encoding='utf-8') + self.assertEqual(body.count('lmm_is_termux() {'), 1) + self.assertEqual(body.count('sha256() {'), 1) + self.assertEqual(body.count('lmm_root() {'), 1) + self.assertNotIn('@@LIBRARIES@@', body) + self.assertNotIn('source https:', body) + fixtures.subprocess.run(['python3', str(P / 'tools/generate_menus.py'), '--check'], check=True) + + def test_every_generated_shell_help_is_standalone(self): + for target in ('pi', 'dsh', 'lmm', 'lmm-use', 'menu'): + body = (P / f'{target}.sh').read_text(encoding='utf-8') + r = fixtures.subprocess.run(['bash', '-s', '--', '--help'], input=body, env=self.fixture.env | self.env, text=True, capture_output=True) + self.assertEqual(r.returncode, 0, r.stderr) + partial = body.rsplit('if true; then', 1)[0] + r = fixtures.subprocess.run(['bash', '-s'], input=partial, env=self.fixture.env | self.env, text=True, capture_output=True) + self.assertFalse(self.fixture.root.exists()) + diff --git a/tools/generate.py b/tools/generate.py index 054bd07..5c40cb4 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -1,25 +1,78 @@ #!/usr/bin/env python3 -"""Emit standalone hosted installers; the server imports only root scripts.""" -from pathlib import Path -import argparse,json,shlex -P=Path(__file__).resolve().parents[1] -v=json.loads((P/'versions.json').read_text()) -parser=argparse.ArgumentParser();parser.add_argument('--check',action='store_true');args=parser.parse_args() -keys={'script_version':'SCRIPT_VERSION','node_version':'NODE_VERSION','pi_version':'PI_VERSION','pi_provider_version':'PI_PROVIDER_VERSION','pnpm_version':'PNPM_VERSION','dsh_version':'DSH_VERSION','dsh_provider_url':'DSH_PROVIDER_URL','dsh_provider_sha256':'DSH_PROVIDER_SHA256','lmm_version':'LMM_VERSION','lmm_release_base':'LMM_RELEASE_BASE'} -pkeys={'script_version':'ScriptVersion','node_version':'NodeVersion','pi_version':'PiVersion','pi_provider_version':'PiProviderVersion','pnpm_version':'PnpmVersion','dsh_version':'DshVersion','dsh_provider_url':'DshProviderUrl','dsh_provider_sha256':'DshProviderSha256','lmm_version':'LmmVersion','lmm_release_base':'LmmReleaseBase'} -for target in ['pi','dsh','lmm']: - sh=f'TARGET={shlex.quote(target)}\n'+''.join(f'{name}={shlex.quote(v[key])}\n' for key,name in keys.items()) - for name,key in [('node_hash','node_sha256'),('lmm_hash','lmm_sha256')]: - sh+=name+'() { case "$1" in\n'+''.join(f' {platform}) printf \'%s\\n\' {shlex.quote(sha)};;\n' for platform,sha in v[key].items())+' *) printf \'\\n\';;\nesac; }\n' - ps=f"$Target = '{target}'\n"+''.join(f"${name} = '{v[key]}'\n" for key,name in pkeys.items()) - for name,key in [('NodeHashes','node_sha256'),('LmmHashes','lmm_sha256')]: - ps+=f'${name} = @{{\n'+''.join(f" '{platform}' = '{sha}'\n" for platform,sha in v[key].items())+'}\n' - for ext,constants in [('sh',sh),('ps1',ps)]: - body=(P/'templates'/f'install.{ext}.in').read_text().replace('@@CONSTANTS@@',constants) - if ext=='sh': - lines=body.splitlines(keepends=True);body=lines[0]+'lmm_install_main() {\n'+''.join(lines[1:])+'\n}\nif true; then\n lmm_install_main "$@"\nfi\n' - if ext=='ps1':body.encode('ascii') - path=P/f'{target}.{ext}' - if args.check: - if not path.exists() or path.read_text()!=body:raise SystemExit(f'Generated file out of date: {path}') - else:path.write_text(body);path.chmod(0o755 if ext=='sh' else 0o644) +"""Compose only the shared code and target adapter needed by each installer.""" +import argparse +import json +import re +import shlex +from render import ROOT, emit, libraries, standalone, template + +# JSON field -> shell / PowerShell variable. Keep a single naming map. +NAMES = { + 'script_version': ('SCRIPT_VERSION', 'ScriptVersion'), + 'node_version': ('NODE_VERSION', 'NodeVersion'), + 'pi_version': ('PI_VERSION', 'PiVersion'), + 'pi_provider_version': ('PI_PROVIDER_VERSION', 'PiProviderVersion'), + 'pnpm_version': ('PNPM_VERSION', 'PnpmVersion'), + 'dsh_version': ('DSH_VERSION', 'DshVersion'), + 'dsh_provider_url': ('DSH_PROVIDER_URL', 'DshProviderUrl'), + 'dsh_provider_sha256': ('DSH_PROVIDER_SHA256', 'DshProviderSha256'), + 'lmm_version': ('LMM_VERSION', 'LmmVersion'), + 'lmm_release_base': ('LMM_RELEASE_BASE', 'LmmReleaseBase'), +} + + +def constants(versions: dict, target: str, ext: str, body: str) -> str: + shell = ext == 'sh' + quote = shlex.quote if shell else lambda value: "'" + value.replace("'", "''") + "'" + result = f'TARGET={quote(target)}\n' if shell else f'$Target = {quote(target)}\n' + for key, names in NAMES.items(): + name = names[0 if shell else 1] + if not re.search(r'\$(?:\{)?' + name + r'\b', body, re.I if not shell else 0): + continue + value = versions[key] + if not isinstance(value, str) or '\n' in value or '\r' in value: + raise ValueError(f'Invalid version field: {key}') + result += f'{name}={quote(value)}\n' if shell else f'${name} = {quote(value)}\n' + for key, shname, psname in [('node_sha256', 'node_hash', 'NodeHashes'), ('lmm_sha256', 'lmm_hash', 'LmmHashes')]: + if (shname if shell else '$' + psname) not in body: + continue + hashes = versions[key] + for platform, digest in hashes.items(): + if not re.fullmatch(r'[a-z0-9-]+', platform) or not re.fullmatch(r'[0-9a-f]{64}', digest): + raise ValueError(f'Invalid {key} entry: {platform}') + if shell: + result += shname + '() { case "$1" in\n' + result += ''.join(f" {platform}) printf '%s\\n' {quote(digest)};;\n" for platform, digest in hashes.items()) + result += " *) printf '\\n';;\nesac; }\n" + else: + result += f'${psname} = @{{\n' + result += ''.join(f' {quote(platform)} = {quote(digest)}\n' for platform, digest in hashes.items()) + '}\n' + return result + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + versions = json.loads((ROOT / 'versions.json').read_text(encoding='utf-8')) + for target in ('pi', 'dsh', 'lmm'): + for ext in ('sh', 'ps1'): + parts = ['lib/root.sh', 'lib/hash.sh', 'lib/termux.sh', 'lib/quote.sh'] if ext == 'sh' else ['lib/common.ps1'] + parts.append(f'lib/download.{ext}') + if target != 'lmm': + parts.append(f'lib/node.{ext}') + parts.append(f'tools/{target}.{ext}') + body = template(f'install.{ext}.in').replace('@@LIBRARIES@@', libraries(*parts)) + body = body.replace('@@NODE_CHECK@@', template('lib/node-check.sh') if target != 'lmm' and ext == 'sh' else '') + body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body)) + if ext == 'sh': + body = standalone(body, 'lmm_install_main') + else: + body.encode('ascii') + emit(f'{target}.{ext}', body, args.check) + use = template('use.sh.in').replace('@@LIBRARIES@@', libraries('lib/root.sh')) + emit('lmm-use.sh', standalone(use, 'lmm_use_main'), args.check) + + +if __name__ == '__main__': + main() diff --git a/tools/render.py b/tools/render.py new file mode 100644 index 0000000..fd5d4fd --- /dev/null +++ b/tools/render.py @@ -0,0 +1,32 @@ +"""Build-time composition only: published scripts never source remote helpers.""" +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] + + +def template(name: str) -> str: + return (ROOT / 'templates' / name).read_text(encoding='utf-8') + + +def libraries(*names: str) -> str: + return '\n'.join(template(name).rstrip() for name in names) + '\n' + + +def standalone(text: str, name: str) -> str: + """Do not execute a partial pipe before the complete function is received.""" + first, body = text.split('\n', 1) + return f'{first}\n{name}() {{\n{body}\n}}\nif true; then\n {name} "$@"\nfi\n' + + +def emit(name: str, text: str, check: bool, encoding: str = 'utf-8') -> None: + if re.search(r'@@[A-Z_]+@@', text): + raise ValueError(f'Unexpanded template marker in {name}') + data = text.encode(encoding) + path = ROOT / name + if check: + if not path.exists() or path.read_bytes() != data: + raise SystemExit(f'Generated file out of date: {name}') + else: + path.write_bytes(data) + path.chmod(0o755 if name.endswith('.sh') else 0o644) From 83c0ab52eb2023ef3329f3877821d2d10e519440 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 16:43:21 +0800 Subject: [PATCH 11/39] fix: omit unused Node installer state from LMM-only builds --- .github/workflows/_refactor.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/_refactor.yml b/.github/workflows/_refactor.yml index f9e4608..ea1ec24 100644 --- a/.github/workflows/_refactor.yml +++ b/.github/workflows/_refactor.yml @@ -19,6 +19,33 @@ jobs: - name: Refactor and validate run: | python3 tools/_termux_refactor.py + python3 - <<'PY' + from pathlib import Path + p=Path('templates/install.sh.in') + text=p.read_text(encoding='utf-8') + replacements={ + 'SOURCE=0 INSTALL_NODE=1':'SOURCE=0', + 'PHASE=arguments BOOTSTRAP=1':'PHASE=arguments', + 'NPM_SELECTED=0\n':'@@CLIENT_STATE@@\n', + '--no-bootstrap) INSTALL_NODE=0; BOOTSTRAP=0;;':'--no-bootstrap) @@NO_BOOTSTRAP@@;;', + '--no-install-node) INSTALL_NODE=0;;':'--no-install-node) @@NO_INSTALL_NODE@@;;', + } + for old,new in replacements.items(): + assert text.count(old)==1, old + text=text.replace(old,new) + p.write_text(text,encoding='utf-8') + p=Path('tools/generate.py') + text=p.read_text(encoding='utf-8') + old=" body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body))" + new=""" client = target != 'lmm' + body = body.replace('@@CLIENT_STATE@@', 'INSTALL_NODE=1 BOOTSTRAP=1 NPM_SELECTED=0' if client else '') + body = body.replace('@@NO_BOOTSTRAP@@', 'INSTALL_NODE=0; BOOTSTRAP=0' if client else ':') + body = body.replace('@@NO_INSTALL_NODE@@', 'INSTALL_NODE=0' if client else ':') + body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body))""" + new='\n'.join(' '+line.strip() for line in new.splitlines()) + assert text.count(old)==1 + p.write_text(text.replace(old,new),encoding='utf-8') + PY python3 tools/generate.py python3 tools/generate_menus.py python3 tools/generate.py --check From b21e36ecfdae2a1c97f86177f841635552ec40db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:43:54 +0000 Subject: [PATCH 12/39] refactor: share installer helpers and handle native Termux paths and launchers --- README.md | 15 ++ docs/maintenance.md | 4 +- dsh.ps1 | 217 +++++++---------- dsh.sh | 417 ++++++++++++++++----------------- lmm-use.sh | 12 +- lmm.ps1 | 206 ++--------------- lmm.sh | 424 +++++++++++++--------------------- menu.sh | 52 ++++- pi.ps1 | 197 +++++----------- pi.sh | 401 +++++++++++++++----------------- templates/install.ps1.in | 379 +----------------------------- templates/install.sh.in | 282 +++------------------- templates/lib/common.ps1 | 92 ++++++++ templates/lib/download.ps1 | 108 +++++++++ templates/lib/download.sh | 59 +++++ templates/lib/hash.sh | 7 + templates/lib/node-check.sh | 5 + templates/lib/node.ps1 | 72 ++++++ templates/lib/node.sh | 99 ++++++++ templates/lib/quote.sh | 1 + templates/lib/root.sh | 3 + templates/lib/termux.sh | 24 ++ templates/menu.sh.in | 18 +- templates/tools/dsh.ps1 | 51 ++++ templates/tools/dsh.sh | 29 +++ templates/tools/lmm.ps1 | 28 +++ templates/tools/lmm.sh | 30 +++ templates/tools/pi.ps1 | 27 +++ templates/tools/pi.sh | 7 + templates/use.sh.in | 37 +++ tests/test_installers.py | 11 +- tests/test_official_policy.py | 137 +++++++++++ tools/_termux_refactor.py | 223 ------------------ tools/_termux_tests.txt | 136 ----------- tools/generate.py | 4 + tools/generate_menus.py | 9 +- versions.json | 2 +- 37 files changed, 1663 insertions(+), 2162 deletions(-) mode change 100644 => 100755 lmm-use.sh create mode 100644 templates/lib/common.ps1 create mode 100644 templates/lib/download.ps1 create mode 100644 templates/lib/download.sh create mode 100644 templates/lib/hash.sh create mode 100644 templates/lib/node-check.sh create mode 100644 templates/lib/node.ps1 create mode 100644 templates/lib/node.sh create mode 100644 templates/lib/quote.sh create mode 100644 templates/lib/root.sh create mode 100644 templates/lib/termux.sh create mode 100644 templates/tools/dsh.ps1 create mode 100644 templates/tools/dsh.sh create mode 100644 templates/tools/lmm.ps1 create mode 100644 templates/tools/lmm.sh create mode 100644 templates/tools/pi.ps1 create mode 100644 templates/tools/pi.sh create mode 100644 templates/use.sh.in delete mode 100644 tools/_termux_refactor.py delete mode 100644 tools/_termux_tests.txt diff --git a/README.md b/README.md index 846fa03..958aa90 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,21 @@ DSH 插件按 profile 安装,默认 `web`。使用 `headless` 前,先在相 LMM CLI 的实际软件安装、接入、恢复尚未完成;`lmm setup pi --dry-run` 仅预览。`doctor` / `setup --dry-run` 返回 3 时不代表全部成功。Linux 登录需要可用的 Secret Service;SSH 或容器中不一定具备。 +## Termux(原生 Android) + +先准备 Termux 自己的依赖,不使用桌面 Linux 的 Node 压缩包: + +```sh +pkg install bash curl coreutils nodejs npm git +curl -fsSL https://api.lmm.best/scripts/menu.sh | bash +``` + +安装目录保持在 `$HOME`。脚本检查 Node 是否为 Android 版本;拒绝把安装、缓存或临时目录放在 `/sdcard`、`/storage`,包括指向共享存储的链接。未设置 `TMPDIR` 时使用 `$PREFIX/tmp`,启动器使用当前 Bash 的绝对路径和明确的 Node 入口。 + +文本剪贴板另需 Termux:API 应用和 `pkg install termux-api`,不作为安装的强制条件。浏览器没有自动打开时,可手动用 `termux-open-url` 打开登录地址。脚本不申请存储权限、不清空缓存、不执行系统升级。 + +Pi 的安装方式遵循官方 Termux 文档。DSH 的 Android 原生依赖、LMM CLI 的 Android 源码构建尚未经真机验证;LMM CLI 没有 Android 预编译包。不要把环境模拟测试当成真机验证。 + ## 直接运行与更新 ```sh diff --git a/docs/maintenance.md b/docs/maintenance.md index 0804c08..e2d6166 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -31,7 +31,9 @@ pwsh -NoProfile -File tests/test-powershell.ps1 pwsh -NoProfile -File tests/test-official-policy.ps1 ``` -只修改模板和版本清单,再生成根目录脚本。公开脚本必须能独立运行;不要添加远程 `source` 依赖。 +公共函数放在 `templates/lib/`,Pi、DSH、LMM 的差异放在 `templates/tools/`。`tools/render.py` 负责共用的文本读取、完整管道包装和生成检查;`tools/generate.py` 只组装当前工具需要的代码、版本和哈希。菜单复用同一份根目录、哈希和 Termux 函数,`lmm-use.sh` 也从模板生成。 + +只修改这些源文件和版本清单,再生成根目录脚本。`.sh` 与 `.ps1` 都保留单文件入口,不在运行时下载或 `source` 公共库;网站现有同步清单无需增加运行时文件。Windows/Linux/macOS 的编码和完整脚本校验保持不变。 菜单的 `revision` 固定到含有目标脚本的提交,并按该提交计算 SHA-256。更新安装器后,先提交安装器,再更新 `tools/generate_menus.py` 中的 `revision` 并生成菜单,避免入口仍取旧代码。线上同步由网站仓库负责;源码提交和线上节点同步是两回事。 diff --git a/dsh.ps1 b/dsh.ps1 index 635450c..5cf0165 100644 --- a/dsh.ps1 +++ b/dsh.ps1 @@ -12,16 +12,12 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'dsh' -$ScriptVersion = '2026.09.20.1' +$ScriptVersion = '2026.09.20.2' $NodeVersion = '24.21.0' -$PiVersion = '0.85.1' -$PiProviderVersion = '0.1.0-alpha.1' $PnpmVersion = '11.7.0' $DshVersion = '0.1.5-rc.2' $DshProviderUrl = 'https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/download/v0.1.0-alpha.2/tokennotincluded-dsh-lmm-provider-0.1.0-alpha.2.tgz' $DshProviderSha256 = '609eba9f1516cadf7086e44d290752d1361ac607eb1d1cb5682abfa5e806304d' -$LmmVersion = '0.1.0' -$LmmReleaseBase = 'https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0' $NodeHashes = @{ 'linux-x64' = '6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff' 'linux-arm64' = '724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5' @@ -30,30 +26,7 @@ $NodeHashes = @{ 'win-x64' = '158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541' 'win-arm64' = '8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921' } -$LmmHashes = @{ - 'linux-x64' = '292a1ff8b599466f52747867a0b14bd14860faefaa085cc60746040cd7eba9b7' - 'darwin-arm64' = '8f6b3a2d08500566b528b7089664420d3395e464c172e3a43dfcb73d37f57b3f' - 'win-x64' = 'd0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9' -} -$script:InstalledSuccess=$false -$script:Stage = $null; $script:LockHandle = $null; $script:Phase = 'arguments' -$Retries=3; $ConnectTimeout=10; $StallTimeout=20; $DownloadTimeout=600; $CommandTimeout=1800; $MinSpeed=16384 -$script:Cache=$null -$script:PnpmBin=$null -$script:Client = $null; $script:NodeBin = $null; $script:NpmSelected = $false -function Write-Log([string]$Message) { Write-Host "[lmm $Target] $Message" } -function Stop-Setup([string]$Message) { throw $Message } -function Show-Usage { - Write-Host @" -LMM $Target installer $ScriptVersion -Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] - [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] - [-FromSource] [-Launch] [-Help] -No automatic login or PATH changes. Pi on Windows requires Bash. --FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. -"@ -} function Setting([string]$Name, [int]$Default, [int]$Maximum) { $raw = [Environment]::GetEnvironmentVariable($Name) if ([string]::IsNullOrEmpty($raw)) { return $Default } @@ -146,36 +119,6 @@ function Invoke-Native([string]$Command, [string[]]$Arguments) { Invoke-Bounded $Command $Arguments } function Get-Hash([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } -function Test-Node { - $node = Get-Command node.exe -ErrorAction SilentlyContinue - $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue - if (-not $node -or -not $npm) { return $false } - try { $versionText=(& $node.Source --version 2>$null | Out-String).Trim() } catch { return $false } - if ($LASTEXITCODE -ne 0 -or $versionText -notmatch '^v([0-9]+)\.([0-9]+)\.[0-9]+') { return $false } - $major=[int]$Matches[1];$minor=[int]$Matches[2] - return (($major -eq 22 -and $minor -ge 19) -or $major -ge 24) -} -function Assert-PiShell { - # Follow Pi's shellPath -> Git Bash -> PATH lookup, without editing settings. - $agentDirectory=$env:PI_CODING_AGENT_DIR - if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } - $settingsPath=Join-Path $agentDirectory 'settings.json' - if (Test-Path -LiteralPath $settingsPath) { - try { $settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json } - catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } - if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } - $property=$settings.PSObject.Properties['shellPath'] - if ($property -and $property.Value) { - $shell=[string]$property.Value - if (Test-Path -LiteralPath $shell -PathType Leaf) { return } - if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } - throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." - } - } - if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') -PathType Leaf)) { return } - if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } - throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' -} function Set-RequestProxy($request) { $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } if ($proxyValue) { @@ -284,6 +227,15 @@ function Get-VerifiedFile([string]$Url, [string]$Destination, [string]$Expected) } throw 'All download sources failed. Retry -Network official or -Network china; inspect your proxy/CA settings.' } +function Test-Node { + $node = Get-Command node.exe -ErrorAction SilentlyContinue + $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue + if (-not $node -or -not $npm) { return $false } + try { $versionText=(& $node.Source --version 2>$null | Out-String).Trim() } catch { return $false } + if ($LASTEXITCODE -ne 0 -or $versionText -notmatch '^v([0-9]+)\.([0-9]+)\.[0-9]+') { return $false } + $major=[int]$Matches[1];$minor=[int]$Matches[2] + return (($major -eq 22 -and $minor -ge 19) -or $major -ge 24) +} function Install-Node { $script:Phase = 'Node.js runtime' if (Test-Node) { $script:NodeBin = Split-Path (Get-Command node.exe).Source; return } @@ -322,27 +274,6 @@ function Invoke-WithRegistryRetry([string]$Command,[string[]]$Arguments) { Invoke-Native $Command $Arguments } } -function Install-Pnpm { - $script:Phase='DSH package manager';$destination=Join-Path $Root "tools\pnpm\$PnpmVersion" - if ((Test-Path -LiteralPath (Join-Path $destination 'pnpm.cmd')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:PnpmBin=$destination } - elseif ($NoBootstrap) { - $pm=Get-Command pnpm.cmd -ErrorAction SilentlyContinue - if (!$pm) { throw 'pnpm is missing; rerun without -NoBootstrap.' } - Invoke-Native $pm.Source @('--version');$script:PnpmBin=Split-Path $pm.Source - } else { - $work=Join-Path $script:Stage 'pnpm';New-Item -ItemType Directory -Path $work | Out-Null - Invoke-WithRegistryRetry (Get-Command npm.cmd).Source @('install','--global','--prefix',$work,'--ignore-scripts','--no-audit','--no-fund',"pnpm@$PnpmVersion") - Invoke-Native (Join-Path $work 'pnpm.cmd') @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value $PnpmVersion - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw 'Unowned pnpm installation directory.' } - $destination+='-reinstall-'+[Guid]::NewGuid().ToString('N') - } - Move-Item -LiteralPath $work -Destination $destination;$script:PnpmBin=$destination - } - $env:PATH="$script:PnpmBin;$env:PATH" -} function Install-Client([string]$Package,[string]$Version,[string]$Entry) { $script:Phase="$Target client"; $destination=Join-Path $Root "apps\$Target\$Version" if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } @@ -368,32 +299,75 @@ function Install-Client([string]$Package,[string]$Version,[string]$Entry) { } Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination "$Entry.cmd" } -function Install-Lmm { - $script:Phase='LMM CLI'; $destination=Join-Path $Root "apps\lmm\$LmmVersion-$Platform" - if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination 'lmm.exe')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:Client=Join-Path $destination 'lmm.exe'; return } - $work=Join-Path $script:Stage 'lmm' - if ($FromSource) { - $cargo=Get-Command cargo.exe -ErrorAction SilentlyContinue - if (-not $cargo) { throw 'Source install needs Rust 1.88+ and Visual Studio C++ Build Tools. Install those, then retry -FromSource.' } - $cargoRoot=Join-Path $script:Stage 'cargo' - Invoke-Native $cargo.Source @('install','lmm-cli','--version',$LmmVersion,'--locked','--root',$cargoRoot) - New-Item -ItemType Directory -Path $work | Out-Null - Copy-Item -LiteralPath (Join-Path $cargoRoot 'bin\lmm.exe') -Destination $work +function Install-Pnpm { + $script:Phase='DSH package manager';$destination=Join-Path $Root "tools\pnpm\$PnpmVersion" + if ((Test-Path -LiteralPath (Join-Path $destination 'pnpm.cmd')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:PnpmBin=$destination } + elseif ($NoBootstrap) { + $pm=Get-Command pnpm.cmd -ErrorAction SilentlyContinue + if (!$pm) { throw 'pnpm is missing; rerun without -NoBootstrap.' } + Invoke-Native $pm.Source @('--version');$script:PnpmBin=Split-Path $pm.Source } else { - $hash=$LmmHashes[$Platform] - if (-not $hash) { throw "No prebuilt LMM CLI for $Platform yet. Use -FromSource with Rust 1.88+ and build tools." } - $name="lmm-v$LmmVersion-$Platform.zip"; $archive=Join-Path $script:Cache $name - Get-VerifiedFile "$LmmReleaseBase/$name" $archive $hash - Expand-Archive -LiteralPath $archive -DestinationPath $work - } - Invoke-Native (Join-Path $work 'lmm.exe') @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value $LmmVersion - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw "Refusing unowned directory: $destination" } - $destination += '-reinstall-' + [Guid]::NewGuid().ToString('N') + $work=Join-Path $script:Stage 'pnpm';New-Item -ItemType Directory -Path $work | Out-Null + Invoke-WithRegistryRetry (Get-Command npm.cmd).Source @('install','--global','--prefix',$work,'--ignore-scripts','--no-audit','--no-fund',"pnpm@$PnpmVersion") + Invoke-Native (Join-Path $work 'pnpm.cmd') @('--version') + Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value $PnpmVersion + New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null + if (Test-Path -LiteralPath $destination) { + if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw 'Unowned pnpm installation directory.' } + $destination+='-reinstall-'+[Guid]::NewGuid().ToString('N') + } + Move-Item -LiteralPath $work -Destination $destination;$script:PnpmBin=$destination } - Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination 'lmm.exe' + $env:PATH="$script:PnpmBin;$env:PATH" +} +function Install-Tool { + Install-Node; Set-NpmNetwork + Install-Pnpm + Install-Client '@deepseek-ai/dsh' $DshVersion 'dsh' + $script:Phase='DSH LMM provider'; $archive=Join-Path $script:Cache ($DshProviderUrl.Split('/')[-1]) + Get-VerifiedFile $DshProviderUrl $archive $DshProviderSha256 + # DSH 0.1.5 uses a shell to invoke pnpm on Windows. Passing absolute + # paths containing spaces loses argument boundaries in that layer. + # Keep the verified immutable package inside the profile and pass a + # path-free file: spec; configure the store through environment instead. + $profileHome=$env:DSH_HOME + $userDirectory=[Environment]::GetFolderPath('UserProfile') + if (!$profileHome) { $profileHome=Join-Path $userDirectory '.dsh' } + elseif ($profileHome -eq '~') { $profileHome=$userDirectory } + elseif ($profileHome.StartsWith('~/') -or $profileHome.StartsWith('~\')) { $profileHome=Join-Path $userDirectory $profileHome.Substring(2) } + if (![IO.Path]::IsPathRooted($profileHome)) { $profileHome=Join-Path (Get-Location).ProviderPath $profileHome } + $profileDirectory=Join-Path ([IO.Path]::GetFullPath($profileHome)) "profiles\$Profile" + New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null + $packageName='.lmm-provider-'+$DshProviderSha256.Substring(0,16)+'.tgz' + $profilePackage=Join-Path $profileDirectory $packageName + if (Test-Path -LiteralPath $profilePackage) { + if ((Get-Hash $profilePackage) -ne $DshProviderSha256) { throw 'Conflicting installer package in the DSH profile; inspect it before retrying.' } + } else { + $pending=Join-Path $profileDirectory ('.lmm-package-'+[Guid]::NewGuid().ToString('N')+'.tmp') + try { Copy-Item -LiteralPath $archive -Destination $pending; Move-Item -LiteralPath $pending -Destination $profilePackage -Force } + finally { if (Test-Path -LiteralPath $pending) { Remove-Item -LiteralPath $pending -Force } } + } + $env:npm_config_store_dir=Join-Path $script:Cache 'pnpm' + Invoke-WithRegistryRetry $script:Client @('plugin','--profile',$Profile,'add',"file:$packageName",'--ignore-scripts') +} + +$script:InstalledSuccess=$false +$script:Stage = $null; $script:LockHandle = $null; $script:Phase = 'arguments' +$Retries=3; $ConnectTimeout=10; $StallTimeout=20; $DownloadTimeout=600; $CommandTimeout=1800; $MinSpeed=16384 +$script:Cache=$null +$script:PnpmBin=$null +$script:Client = $null; $script:NodeBin = $null; $script:NpmSelected = $false +function Write-Log([string]$Message) { Write-Host "[lmm $Target] $Message" } +function Stop-Setup([string]$Message) { throw $Message } +function Show-Usage { + Write-Host @" +LMM $Target installer $ScriptVersion +Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] + [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] + [-FromSource] [-Launch] [-Help] +No automatic login or PATH changes. Pi on Windows requires Bash. +-FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. +"@ } function Write-Launcher { $destination=Join-Path $Root "bin\$Target.cmd" @@ -462,42 +436,7 @@ function Invoke-LmmSetup { } catch { throw 'Another LMM installer is running. Wait for it to finish.' } try { $script:Stage=Join-Path $Root ('.setup-'+[Guid]::NewGuid().ToString('N')); New-Item -ItemType Directory -Path $script:Stage | Out-Null - if ($Target -eq 'lmm') { Install-Lmm } - else { - Install-Node; Set-NpmNetwork - if ($Target -eq 'pi') { - Install-Client '@earendil-works/pi-coding-agent' $PiVersion 'pi' - $script:Phase='Pi LMM provider'; Invoke-WithRegistryRetry $script:Client @('install',"npm:@tokennotincluded/pi-lmm-provider@$PiProviderVersion") - } else { - Install-Pnpm - Install-Client '@deepseek-ai/dsh' $DshVersion 'dsh' - $script:Phase='DSH LMM provider'; $archive=Join-Path $script:Cache ($DshProviderUrl.Split('/')[-1]) - Get-VerifiedFile $DshProviderUrl $archive $DshProviderSha256 - # DSH 0.1.5 uses a shell to invoke pnpm on Windows. Passing absolute - # paths containing spaces loses argument boundaries in that layer. - # Keep the verified immutable package inside the profile and pass a - # path-free file: spec; configure the store through environment instead. - $profileHome=$env:DSH_HOME - $userDirectory=[Environment]::GetFolderPath('UserProfile') - if (!$profileHome) { $profileHome=Join-Path $userDirectory '.dsh' } - elseif ($profileHome -eq '~') { $profileHome=$userDirectory } - elseif ($profileHome.StartsWith('~/') -or $profileHome.StartsWith('~\')) { $profileHome=Join-Path $userDirectory $profileHome.Substring(2) } - if (![IO.Path]::IsPathRooted($profileHome)) { $profileHome=Join-Path (Get-Location).ProviderPath $profileHome } - $profileDirectory=Join-Path ([IO.Path]::GetFullPath($profileHome)) "profiles\$Profile" - New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null - $packageName='.lmm-provider-'+$DshProviderSha256.Substring(0,16)+'.tgz' - $profilePackage=Join-Path $profileDirectory $packageName - if (Test-Path -LiteralPath $profilePackage) { - if ((Get-Hash $profilePackage) -ne $DshProviderSha256) { throw 'Conflicting installer package in the DSH profile; inspect it before retrying.' } - } else { - $pending=Join-Path $profileDirectory ('.lmm-package-'+[Guid]::NewGuid().ToString('N')+'.tmp') - try { Copy-Item -LiteralPath $archive -Destination $pending; Move-Item -LiteralPath $pending -Destination $profilePackage -Force } - finally { if (Test-Path -LiteralPath $pending) { Remove-Item -LiteralPath $pending -Force } } - } - $env:npm_config_store_dir=Join-Path $script:Cache 'pnpm' - Invoke-WithRegistryRetry $script:Client @('plugin','--profile',$Profile,'add',"file:$packageName",'--ignore-scripts') - } - } + Install-Tool $script:Phase='launcher and PATH'; Write-Launcher; $script:InstalledSuccess=$true Write-Log "Ready: $(Join-Path $Root "bin\$Target.cmd")" Write-Log 'Use the full path above. With -AddPath, new terminals can use the short command.' diff --git a/dsh.sh b/dsh.sh index cf21c38..0c090a0 100755 --- a/dsh.sh +++ b/dsh.sh @@ -1,19 +1,15 @@ #!/usr/bin/env bash lmm_install_main() { -# Generated from templates/install.sh.in and versions.json. No sudo, no API keys. +# Generated from templates/ and versions.json. Edit the source, not this file. set -euo pipefail set +x TARGET=dsh -SCRIPT_VERSION=2026.09.20.1 +SCRIPT_VERSION=2026.09.20.2 NODE_VERSION=24.21.0 -PI_VERSION=0.85.1 -PI_PROVIDER_VERSION=0.1.0-alpha.1 PNPM_VERSION=11.7.0 DSH_VERSION=0.1.5-rc.2 DSH_PROVIDER_URL=https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/download/v0.1.0-alpha.2/tokennotincluded-dsh-lmm-provider-0.1.0-alpha.2.tgz DSH_PROVIDER_SHA256=609eba9f1516cadf7086e44d290752d1361ac607eb1d1cb5682abfa5e806304d -LMM_VERSION=0.1.0 -LMM_RELEASE_BASE=https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0 node_hash() { case "$1" in linux-x64) printf '%s\n' 6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff;; linux-arm64) printf '%s\n' 724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5;; @@ -23,144 +19,42 @@ node_hash() { case "$1" in win-arm64) printf '%s\n' 8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921;; *) printf '\n';; esac; } -lmm_hash() { case "$1" in - linux-x64) printf '%s\n' 292a1ff8b599466f52747867a0b14bd14860faefaa085cc60746040cd7eba9b7;; - darwin-arm64) printf '%s\n' 8f6b3a2d08500566b528b7089664420d3395e464c172e3a43dfcb73d37f57b3f;; - win-x64) printf '%s\n' d0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9;; - *) printf '\n';; -esac; } -ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} -NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 INSTALL_NODE=1 -STAGE='' LOCKED=0 PHASE=arguments BOOTSTRAP=1 -RUN_ARGS=() -NPM_SELECTED=0 -PNPM_BIN='' -log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } -fail() { log "ERROR: $*"; exit 1; } -usage() { - cat </dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' - elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' - elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' - else fail 'Install a SHA-256 tool (sha256sum, shasum or openssl)'; fi + local digest + if command -v sha256sum >/dev/null 2>&1; then digest=$(sha256sum "$1") || return; printf '%s\n' "${digest%% *}" + elif command -v shasum >/dev/null 2>&1; then digest=$(shasum -a 256 "$1") || return; printf '%s\n' "${digest%% *}" + elif command -v openssl >/dev/null 2>&1; then digest=$(openssl dgst -sha256 "$1") || return; printf '%s\n' "${digest##* }" + else printf 'Install a SHA-256 tool.\n' >&2; return 1; fi } -compatible_node() { - command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && - node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit((a===22&&b>=19)||a>=24?0:1)' >/dev/null 2>&1 +# Native Termux uses Android/bionic, not desktop Linux/glibc. +lmm_is_termux() { + [ -n "${TERMUX_VERSION:-}${TERMUX_APP__PACKAGE_NAME:-}" ] || + case "${PREFIX:-}" in */com.termux/files/usr) true;; *) false;; esac } -# --check never creates directories, downloads, edits PATH or touches credentials. -if [ "$CHECK" = 1 ]; then - log "Platform: $PLATFORM; install root: $ROOT" - if [ -x "$ROOT/bin/$TARGET" ]; then "$ROOT/bin/$TARGET" --version - elif command -v "$TARGET" >/dev/null 2>&1; then "$TARGET" --version - else log "$TARGET is not installed in this root or PATH"; exit 1; fi - if [ "$TARGET" != lmm ]; then - if compatible_node; then log 'Current PATH has compatible Node/npm.' - elif [ -x "$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" ]; then log 'Managed Node runtime is available.' - else fail 'No compatible Node runtime found'; fi - fi - log 'Executable check complete; login and model access are not inferred.' - exit 0 -fi -release_setup() { - if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then rm -rf -- "$STAGE"; fi - STAGE='' - if [ "$LOCKED" = 1 ] && [ "$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true)" = "$$" ]; then - rm -f -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" - rmdir "$ROOT/.setup-lock" 2>/dev/null || true - fi - LOCKED=0 +lmm_temp_root() { + if [ -n "${TMPDIR:-}" ]; then printf '%s\n' "$TMPDIR" + elif lmm_is_termux; then printf '%s/tmp\n' "${PREFIX:-$HOME/.cache/lmm-tools}" + else printf '/tmp\n'; fi } -cleanup() { - rc=$? - trap - EXIT - release_setup - if [ "$rc" -ne 0 ]; then - log "Stopped during $PHASE. Existing launchers were preserved unless installation already completed." - log 'Check disk space, HTTPS proxy/CA settings, or retry --network official / --network china. Do not disable TLS validation.' - fi - exit "$rc" +lmm_check_storage() { + lmm_is_termux || return 0 + local resolved + # realpath -m also resolves missing paths and symlinked storage aliases. + command -v realpath >/dev/null 2>&1 || { + printf 'Termux needs coreutils: pkg install coreutils\n' >&2; return 1; + } + resolved=$(realpath -m -- "$1") || return 1 + case "$resolved/" in + /sdcard/*|/storage/*|/mnt/sdcard/*|/mnt/media_rw/*|/mnt/runtime/*|/mnt/user/*|/mnt/pass_through/*) + printf 'Use Termux private storage under HOME, not shared storage: %s\n' "$1" >&2 + return 1;; + esac } -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 143' TERM -umask 077 -mkdir -p "$ROOT" "$CACHE" "$ROOT/bin" "$ROOT/apps" "$ROOT/runtime" -ROOT=$(cd "$ROOT" && pwd -P) -if ! mkdir "$ROOT/.setup-lock" 2>/dev/null; then - oldpid=$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true) - case "$oldpid" in ''|*[!0-9]*) fail "Unknown lock at $ROOT/.setup-lock; inspect it before removing";; esac - if kill -0 "$oldpid" 2>/dev/null; then fail "Another installer is running (PID $oldpid)"; fi - [ "$(cat "$ROOT/.setup-lock/owner" 2>/dev/null || true)" = lmm-installer-v1 ] || fail 'Unknown lock owner' - rm -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" - rmdir "$ROOT/.setup-lock" || fail 'Lock contains unexpected files; left it untouched' - mkdir "$ROOT/.setup-lock" -fi -printf '%s\n' "$$" > "$ROOT/.setup-lock/pid" -printf '%s\n' lmm-installer-v1 > "$ROOT/.setup-lock/owner" -LOCKED=1 -STAGE=$(mktemp -d "$ROOT/.setup.XXXXXX") -# Probe only public, credential-free artifact URLs. Slow transfers are still -# interrupted independently of the latency ranking below. +quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } rank_urls() { local i=0 url response code elapsed probe_dir if ! command -v curl >/dev/null 2>&1; then for url in "$@"; do printf '%s\n' "$i"; i=$((i+1)); done; return; fi @@ -176,7 +70,7 @@ rank_urls() { i=$((i+1)) done wait - cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | awk '{print $2}' + cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | while read -r elapsed index; do printf '%s\n' "$index"; done } urls_for() { URLS=("$1") @@ -220,13 +114,14 @@ download() { done fail "Download failed: ${destination##*/}. Rerun to resume, or choose another --network mode." } +compatible_node() { + command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && + node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(((a===22&&b>=19)||a>=24)&&(process.argv[1]!=="android"||process.platform==="android")?0:1)' "$OS" >/dev/null 2>&1 +} ensure_node() { PHASE='Node.js runtime' if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi - # Android uses bionic, not the glibc used by the Linux Node archives. - if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then - fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' - fi + [ "$OS" != android ] || fail 'In Termux, run pkg install nodejs npm git; desktop Node archives are incompatible.' local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi if compatible_node; then NODE_BIN="$dir/bin"; return; fi @@ -292,27 +187,6 @@ with_registry_retry() { bounded "$@" else fail 'Package installation failed. Check network/proxy settings or try another --network mode.'; fi } -ensure_pnpm() { - PHASE='DSH package manager' - local directory="$ROOT/tools/pnpm/$PNPM_VERSION" work="$STAGE/pnpm" - if [ -x "$directory/bin/pnpm" ] && [ -f "$directory/.lmm-managed" ]; then PNPM_BIN="$directory/bin" - elif [ "$BOOTSTRAP" = 0 ]; then - command -v pnpm >/dev/null 2>&1 || fail 'pnpm is missing; rerun without --no-bootstrap.' - bounded pnpm --version - PNPM_BIN=$(dirname "$(command -v pnpm)") - else - mkdir -p "$work" "$(dirname "$directory")" - with_registry_retry npm install --global --prefix "$work" --ignore-scripts --no-audit --no-fund "pnpm@$PNPM_VERSION" - bounded "$work/bin/pnpm" --version - printf '%s\n' "$PNPM_VERSION" > "$work/.lmm-managed" - if [ -e "$directory" ]; then - [ -f "$directory/.lmm-managed" ] || fail "Unowned package-manager directory: $directory" - directory="$directory-reinstall-$(date +%s)-$$" - fi - mv -- "$work" "$directory"; PNPM_BIN="$directory/bin" - fi - export PATH="$PNPM_BIN:$PATH" -} install_client() { local package=$1 version=$2 entry=$3 target="$ROOT/apps/$TARGET/$2" work="$STAGE/client" allow PHASE="$TARGET client" @@ -329,7 +203,7 @@ install_client() { [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' fi with_registry_retry npm "${INSTALL_ARGS[@]}" - "$work/bin/$entry" --version >/dev/null + node "$work/bin/$entry" --version >/dev/null printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" mkdir -p "$(dirname "$target")" if [ -e "$target" ]; then @@ -339,44 +213,187 @@ install_client() { mv -- "$work" "$target" CLIENT="$target/bin/$entry" } -install_lmm() { - PHASE='LMM CLI' - local hash target="$ROOT/apps/lmm/$LMM_VERSION-$PLATFORM" archive - if [ "$FORCE" = 0 ] && [ -x "$target/lmm" ] && [ -f "$target/.lmm-managed" ]; then CLIENT="$target/lmm"; return; fi - mkdir -p "$STAGE/lmm" - if [ "$SOURCE" = 1 ]; then - command -v cargo >/dev/null 2>&1 || fail 'Source builds need Rust 1.88+ and OS build tools. Install them first, then rerun --from-source.' - log 'Building the pinned crate; Cargo reuses its existing dependency/build caches. This can take several minutes.' - export CARGO_HTTP_TIMEOUT="$STALL_TIMEOUT" CARGO_NET_RETRY="$RETRIES" - export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$CACHE/cargo-target} - cargo install lmm-cli --version "$LMM_VERSION" --locked --root "$STAGE/cargo" /dev/null 2>&1 || fail 'pnpm is missing; rerun without --no-bootstrap.' + bounded pnpm --version + PNPM_BIN=$(dirname "$(command -v pnpm)") else - if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then - fail 'No Android LMM CLI binary is provided. The Linux archive is not compatible with Termux.' + mkdir -p "$work" "$(dirname "$directory")" + with_registry_retry npm install --global --prefix "$work" --ignore-scripts --no-audit --no-fund "pnpm@$PNPM_VERSION" + bounded node "$work/bin/pnpm" --version + printf '%s\n' "$PNPM_VERSION" > "$work/.lmm-managed" + if [ -e "$directory" ]; then + [ -f "$directory/.lmm-managed" ] || fail "Unowned package-manager directory: $directory" + directory="$directory-reinstall-$(date +%s)-$$" fi - hash=$(lmm_hash "$PLATFORM") - [ -n "$hash" ] || fail "No prebuilt CLI for $PLATFORM yet. With Rust 1.88+ and build tools, use --from-source." - archive="$CACHE/lmm-v$LMM_VERSION-$PLATFORM.tar.gz" - download "$LMM_RELEASE_BASE/${archive##*/}" "$archive" "$hash" - tar -xzf "$archive" -C "$STAGE/lmm" + mv -- "$work" "$directory"; PNPM_BIN="$directory/bin" fi - chmod +x "$STAGE/lmm/lmm" - "$STAGE/lmm/lmm" --version >/dev/null || fail 'CLI cannot run here. Linux x64 preview binaries need glibc 2.39+; use --from-source on older Linux.' - printf '%s\n' "$LMM_VERSION" > "$STAGE/lmm/.lmm-managed" - mkdir -p "$(dirname "$target")" - if [ -e "$target" ]; then [ -f "$target/.lmm-managed" ] || fail "Unowned path: $target"; target="$target-reinstall-$(date +%s)-$$"; fi - mv -- "$STAGE/lmm" "$target"; CLIENT="$target/lmm" + export PATH="$PNPM_BIN:$PATH" } -quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +install_tool() { + ensure_node; configure_npm; ensure_pnpm + install_client @deepseek-ai/dsh "$DSH_VERSION" dsh + PHASE='DSH LMM provider' + local artifact="$CACHE/${DSH_PROVIDER_URL##*/}" + download "$DSH_PROVIDER_URL" "$artifact" "$DSH_PROVIDER_SHA256" + with_registry_retry node "$CLIENT" plugin --profile "$PROFILE" add "$artifact" --ignore-scripts --store-dir "$CACHE/pnpm" +} + +ROOT=$(lmm_root) +NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 +STAGE='' LOCKED=0 PHASE=arguments +RUN_ARGS=() +INSTALL_NODE=1 BOOTSTRAP=1 NPM_SELECTED=0 +PNPM_BIN='' +log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } +fail() { log "ERROR: $*"; exit 1; } +usage() { + cat </dev/null 2>&1 || fail 'Pi/DSH need git: pkg install git' +fi +# --check never creates directories, downloads, edits PATH or touches credentials. +if [ "$CHECK" = 1 ]; then + log "Platform: $PLATFORM; install root: $ROOT" + if [ -x "$ROOT/bin/$TARGET" ]; then "$ROOT/bin/$TARGET" --version + elif command -v "$TARGET" >/dev/null 2>&1; then "$TARGET" --version + else log "$TARGET is not installed in this root or PATH"; exit 1; fi + if [ "$TARGET" != lmm ]; then + if compatible_node; then log 'Current PATH has compatible Node/npm.' + elif [ -x "$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" ]; then log 'Managed Node runtime is available.' + else fail 'No compatible Node runtime found'; fi + fi + + log 'Executable check complete; login and model access are not inferred.' + exit 0 +fi +release_setup() { + if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then rm -rf -- "$STAGE"; fi + STAGE='' + if [ "$LOCKED" = 1 ] && [ "$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true)" = "$$" ]; then + rm -f -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" + rmdir "$ROOT/.setup-lock" 2>/dev/null || true + fi + LOCKED=0 +} +cleanup() { + rc=$? + trap - EXIT + release_setup + if [ "$rc" -ne 0 ]; then + log "Stopped during $PHASE. Existing launchers were preserved unless installation already completed." + log 'Check disk space, HTTPS proxy/CA settings, or retry --network official / --network china. Do not disable TLS validation.' + fi + exit "$rc" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +umask 077 +if [ "$OS" = android ]; then + TMPDIR=$(lmm_temp_root); lmm_check_storage "$TMPDIR" || exit 1 + mkdir -p "$TMPDIR"; export TMPDIR + if [ "$TARGET" = dsh ]; then log 'DSH native dependencies have not been validated on Android.'; fi +fi +mkdir -p "$ROOT" "$CACHE" "$ROOT/bin" "$ROOT/apps" "$ROOT/runtime" +ROOT=$(cd "$ROOT" && pwd -P) +if ! mkdir "$ROOT/.setup-lock" 2>/dev/null; then + oldpid=$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true) + case "$oldpid" in ''|*[!0-9]*) fail "Unknown lock at $ROOT/.setup-lock; inspect it before removing";; esac + if kill -0 "$oldpid" 2>/dev/null; then fail "Another installer is running (PID $oldpid)"; fi + [ "$(cat "$ROOT/.setup-lock/owner" 2>/dev/null || true)" = lmm-installer-v1 ] || fail 'Unknown lock owner' + rm -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" + rmdir "$ROOT/.setup-lock" || fail 'Lock contains unexpected files; left it untouched' + mkdir "$ROOT/.setup-lock" +fi +printf '%s\n' "$$" > "$ROOT/.setup-lock/pid" +printf '%s\n' lmm-installer-v1 > "$ROOT/.setup-lock/owner" +LOCKED=1 +STAGE=$(mktemp -d "$ROOT/.setup.XXXXXX") +# Probe only public, credential-free artifact URLs. Slow transfers are still +# interrupted independently of the latency ranking below. write_launcher() { local launcher="$ROOT/bin/$TARGET" temp="$STAGE/launcher" { - printf '#!/usr/bin/env bash\n# Managed by LMM installers.\n' + if [ "$OS" = android ]; then printf '#!%s\n' "$BASH" + else printf '#!/usr/bin/env bash\n'; fi + printf '# Managed by LMM installers.\n' # The launcher must expand PATH when it runs, not while it is generated. # shellcheck disable=SC2016 if [ "$TARGET" != lmm ]; then printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$NODE_BIN${PNPM_BIN:+:$PNPM_BIN}")"; fi - printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" + if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" + else printf 'exec %s %s "$@"\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi } > "$temp" chmod +x "$temp" if [ -e "$launcher" ] && ! grep -q '# Managed by LMM installers.' "$launcher"; then fail "Refusing to overwrite your existing launcher: $launcher"; fi @@ -398,21 +415,7 @@ add_path() { printf '\n%s\n%s\n# <<< LMM tools PATH <<<\n' "$marker" "$line" >> "$file" done } -if [ "$TARGET" = lmm ]; then install_lmm -else - ensure_node; configure_npm - if [ "$TARGET" = pi ]; then - install_client @earendil-works/pi-coding-agent "$PI_VERSION" pi - PHASE='Pi LMM provider'; with_registry_retry "$CLIENT" install "npm:@tokennotincluded/pi-lmm-provider@$PI_PROVIDER_VERSION" - else - ensure_pnpm - install_client @deepseek-ai/dsh "$DSH_VERSION" dsh - PHASE='DSH LMM provider' - artifact="$CACHE/${DSH_PROVIDER_URL##*/}" - download "$DSH_PROVIDER_URL" "$artifact" "$DSH_PROVIDER_SHA256" - with_registry_retry "$CLIENT" plugin --profile "$PROFILE" add "$artifact" --ignore-scripts --store-dir "$CACHE/pnpm" - fi -fi +install_tool PHASE='launchers and PATH'; write_launcher; add_path log "Ready: $ROOT/bin/$TARGET" log "Use the full command above, or for this terminal: export PATH=$(quote_sh "$ROOT/bin"):\"\$PATH\"" diff --git a/lmm-use.sh b/lmm-use.sh old mode 100644 new mode 100755 index 72d39da..f627359 --- a/lmm-use.sh +++ b/lmm-use.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash +lmm_use_main() { set -euo pipefail -ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} +lmm_root() { + printf '%s\n' "${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}" +} + +ROOT=$(lmm_root) usage() { cat <<'HELP' LMM CLI quick start (developer preview) @@ -34,3 +39,8 @@ case "$command" in else printf 'Authentication requires an interactive terminal. Run lmm %s locally.\n' "$command" >&2; exit 2; fi;; *) printf 'Unsupported quick-start command: %s\n' "$command" >&2; usage; exit 2;; esac + +} +if true; then + lmm_use_main "$@" +fi diff --git a/lmm.ps1 b/lmm.ps1 index 9e0e28a..8c4a37c 100644 --- a/lmm.ps1 +++ b/lmm.ps1 @@ -12,48 +12,15 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'lmm' -$ScriptVersion = '2026.09.20.1' -$NodeVersion = '24.21.0' -$PiVersion = '0.85.1' -$PiProviderVersion = '0.1.0-alpha.1' -$PnpmVersion = '11.7.0' -$DshVersion = '0.1.5-rc.2' -$DshProviderUrl = 'https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/download/v0.1.0-alpha.2/tokennotincluded-dsh-lmm-provider-0.1.0-alpha.2.tgz' -$DshProviderSha256 = '609eba9f1516cadf7086e44d290752d1361ac607eb1d1cb5682abfa5e806304d' +$ScriptVersion = '2026.09.20.2' $LmmVersion = '0.1.0' $LmmReleaseBase = 'https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0' -$NodeHashes = @{ - 'linux-x64' = '6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff' - 'linux-arm64' = '724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5' - 'darwin-x64' = '1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097' - 'darwin-arm64' = 'bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057' - 'win-x64' = '158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541' - 'win-arm64' = '8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921' -} $LmmHashes = @{ 'linux-x64' = '292a1ff8b599466f52747867a0b14bd14860faefaa085cc60746040cd7eba9b7' 'darwin-arm64' = '8f6b3a2d08500566b528b7089664420d3395e464c172e3a43dfcb73d37f57b3f' 'win-x64' = 'd0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9' } -$script:InstalledSuccess=$false -$script:Stage = $null; $script:LockHandle = $null; $script:Phase = 'arguments' -$Retries=3; $ConnectTimeout=10; $StallTimeout=20; $DownloadTimeout=600; $CommandTimeout=1800; $MinSpeed=16384 -$script:Cache=$null -$script:PnpmBin=$null -$script:Client = $null; $script:NodeBin = $null; $script:NpmSelected = $false -function Write-Log([string]$Message) { Write-Host "[lmm $Target] $Message" } -function Stop-Setup([string]$Message) { throw $Message } -function Show-Usage { - Write-Host @" -LMM $Target installer $ScriptVersion -Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] - [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] - [-FromSource] [-Launch] [-Help] -No automatic login or PATH changes. Pi on Windows requires Bash. --FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. -"@ -} function Setting([string]$Name, [int]$Default, [int]$Maximum) { $raw = [Environment]::GetEnvironmentVariable($Name) if ([string]::IsNullOrEmpty($raw)) { return $Default } @@ -146,36 +113,6 @@ function Invoke-Native([string]$Command, [string[]]$Arguments) { Invoke-Bounded $Command $Arguments } function Get-Hash([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } -function Test-Node { - $node = Get-Command node.exe -ErrorAction SilentlyContinue - $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue - if (-not $node -or -not $npm) { return $false } - try { $versionText=(& $node.Source --version 2>$null | Out-String).Trim() } catch { return $false } - if ($LASTEXITCODE -ne 0 -or $versionText -notmatch '^v([0-9]+)\.([0-9]+)\.[0-9]+') { return $false } - $major=[int]$Matches[1];$minor=[int]$Matches[2] - return (($major -eq 22 -and $minor -ge 19) -or $major -ge 24) -} -function Assert-PiShell { - # Follow Pi's shellPath -> Git Bash -> PATH lookup, without editing settings. - $agentDirectory=$env:PI_CODING_AGENT_DIR - if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } - $settingsPath=Join-Path $agentDirectory 'settings.json' - if (Test-Path -LiteralPath $settingsPath) { - try { $settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json } - catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } - if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } - $property=$settings.PSObject.Properties['shellPath'] - if ($property -and $property.Value) { - $shell=[string]$property.Value - if (Test-Path -LiteralPath $shell -PathType Leaf) { return } - if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } - throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." - } - } - if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') -PathType Leaf)) { return } - if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } - throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' -} function Set-RequestProxy($request) { $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } if ($proxyValue) { @@ -284,90 +221,6 @@ function Get-VerifiedFile([string]$Url, [string]$Destination, [string]$Expected) } throw 'All download sources failed. Retry -Network official or -Network china; inspect your proxy/CA settings.' } -function Install-Node { - $script:Phase = 'Node.js runtime' - if (Test-Node) { $script:NodeBin = Split-Path (Get-Command node.exe).Source; return } - $directory = Join-Path $Root "runtime\node-v$NodeVersion-$Platform" - if (Test-Path -LiteralPath (Join-Path $directory 'node.exe')) { $env:PATH = "$directory;$env:PATH" } - if (Test-Node) { $script:NodeBin = $directory; return } - if ($NoInstallNode -or $NoBootstrap) { throw 'Need Node 22.19+ (22.x) or Node 24+, including npm.' } - $hash = $NodeHashes[$Platform] - if (-not $hash) { throw "No verified Node archive for $Platform" } - $name = "node-v$NodeVersion-$Platform.zip"; $archive = Join-Path $script:Cache $name - Get-VerifiedFile "https://nodejs.org/dist/v$NodeVersion/$name" $archive $hash - $unpack = Join-Path $script:Stage 'runtime'; Expand-Archive -LiteralPath $archive -DestinationPath $unpack - $extracted = Join-Path $unpack "node-v$NodeVersion-$Platform" - Invoke-Native (Join-Path $extracted 'node.exe') @('--version') - if (Test-Path -LiteralPath $directory) { throw "Managed runtime is present but unusable; inspect $directory before replacing." } - Move-Item -LiteralPath $extracted -Destination $directory - $script:NodeBin = $directory; $env:PATH = "$directory;$env:PATH" -} -function Set-NpmNetwork { - if (-not $env:npm_config_cache) { $cache=(& npm.cmd config get cache 2>$null | Out-String).Trim(); if ($cache) { $env:npm_config_cache=$cache } else { $env:npm_config_cache=Join-Path $script:Cache 'npm' } } - $env:npm_config_fetch_retries=[string]$Retries; $env:npm_config_fetch_timeout=[string]($StallTimeout*1000); $env:npm_config_fetch_retry_mintimeout='2000'; $env:npm_config_fetch_retry_maxtimeout='30000'; $env:npm_config_strict_ssl='true'; $env:npm_config_prefer_offline='true' - if ($env:LMM_NPM_REGISTRY) { $env:npm_config_registry=$env:LMM_NPM_REGISTRY } - $current = (& npm.cmd config get registry 2>$null | Out-String).Trim() - if ($env:npm_config_registry -or ($current -and $current -ne 'https://registry.npmjs.org/')) { Write-Log 'Keeping your existing npm registry/proxy configuration.'; return } - $script:NpmSelected = $true - if ($Network -eq 'china') { $env:npm_config_registry='https://registry.npmmirror.com/' } - elseif ($Network -eq 'official') { $env:npm_config_registry='https://registry.npmjs.org/' } - else { $env:npm_config_registry = @(Get-RankedUrls @('https://registry.npmjs.org/','https://registry.npmmirror.com/'))[0] } - Write-Log 'Registry selection affects this process only, not your global npm configuration.' -} -function Invoke-WithRegistryRetry([string]$Command,[string[]]$Arguments) { - try { Invoke-Native $Command $Arguments } catch { - if ($Network -ne 'auto' -or -not $script:NpmSelected) { throw } - if ($env:npm_config_registry -eq 'https://registry.npmjs.org/') { $env:npm_config_registry='https://registry.npmmirror.com/' } else { $env:npm_config_registry='https://registry.npmjs.org/' } - Write-Log 'Retrying with the alternate registry and the same cache.' - Invoke-Native $Command $Arguments - } -} -function Install-Pnpm { - $script:Phase='DSH package manager';$destination=Join-Path $Root "tools\pnpm\$PnpmVersion" - if ((Test-Path -LiteralPath (Join-Path $destination 'pnpm.cmd')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:PnpmBin=$destination } - elseif ($NoBootstrap) { - $pm=Get-Command pnpm.cmd -ErrorAction SilentlyContinue - if (!$pm) { throw 'pnpm is missing; rerun without -NoBootstrap.' } - Invoke-Native $pm.Source @('--version');$script:PnpmBin=Split-Path $pm.Source - } else { - $work=Join-Path $script:Stage 'pnpm';New-Item -ItemType Directory -Path $work | Out-Null - Invoke-WithRegistryRetry (Get-Command npm.cmd).Source @('install','--global','--prefix',$work,'--ignore-scripts','--no-audit','--no-fund',"pnpm@$PnpmVersion") - Invoke-Native (Join-Path $work 'pnpm.cmd') @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value $PnpmVersion - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw 'Unowned pnpm installation directory.' } - $destination+='-reinstall-'+[Guid]::NewGuid().ToString('N') - } - Move-Item -LiteralPath $work -Destination $destination;$script:PnpmBin=$destination - } - $env:PATH="$script:PnpmBin;$env:PATH" -} -function Install-Client([string]$Package,[string]$Version,[string]$Entry) { - $script:Phase="$Target client"; $destination=Join-Path $Root "apps\$Target\$Version" - if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } - if ($NoBootstrap) { throw 'Managed client is missing. Rerun without -NoBootstrap.' } - $work=Join-Path $script:Stage 'client'; New-Item -ItemType Directory -Path $work | Out-Null - $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") - if ($Target -eq 'pi') { - # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. - $installArgs+=@('--ignore-scripts') - } else { - $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' - $npmHelp=(& npm.cmd install --help 2>$null | Out-String) - if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } - if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } - } - Invoke-WithRegistryRetry (Get-Command npm.cmd).Source $installArgs - Invoke-Native (Join-Path $work "$Entry.cmd") @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value "$Version|$ScriptVersion" - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw "Refusing unowned directory: $destination" } - $destination += '-reinstall-' + [Guid]::NewGuid().ToString('N') - } - Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination "$Entry.cmd" -} function Install-Lmm { $script:Phase='LMM CLI'; $destination=Join-Path $Root "apps\lmm\$LmmVersion-$Platform" if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination 'lmm.exe')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:Client=Join-Path $destination 'lmm.exe'; return } @@ -395,6 +248,26 @@ function Install-Lmm { } Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination 'lmm.exe' } +function Install-Tool { Install-Lmm } + +$script:InstalledSuccess=$false +$script:Stage = $null; $script:LockHandle = $null; $script:Phase = 'arguments' +$Retries=3; $ConnectTimeout=10; $StallTimeout=20; $DownloadTimeout=600; $CommandTimeout=1800; $MinSpeed=16384 +$script:Cache=$null +$script:PnpmBin=$null +$script:Client = $null; $script:NodeBin = $null; $script:NpmSelected = $false +function Write-Log([string]$Message) { Write-Host "[lmm $Target] $Message" } +function Stop-Setup([string]$Message) { throw $Message } +function Show-Usage { + Write-Host @" +LMM $Target installer $ScriptVersion +Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] + [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] + [-FromSource] [-Launch] [-Help] +No automatic login or PATH changes. Pi on Windows requires Bash. +-FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. +"@ +} function Write-Launcher { $destination=Join-Path $Root "bin\$Target.cmd" if ((Test-Path -LiteralPath $destination) -and !(Select-String -LiteralPath $destination -SimpleMatch 'Managed by LMM installers' -Quiet)) { throw "Refusing existing launcher: $destination" } @@ -462,42 +335,7 @@ function Invoke-LmmSetup { } catch { throw 'Another LMM installer is running. Wait for it to finish.' } try { $script:Stage=Join-Path $Root ('.setup-'+[Guid]::NewGuid().ToString('N')); New-Item -ItemType Directory -Path $script:Stage | Out-Null - if ($Target -eq 'lmm') { Install-Lmm } - else { - Install-Node; Set-NpmNetwork - if ($Target -eq 'pi') { - Install-Client '@earendil-works/pi-coding-agent' $PiVersion 'pi' - $script:Phase='Pi LMM provider'; Invoke-WithRegistryRetry $script:Client @('install',"npm:@tokennotincluded/pi-lmm-provider@$PiProviderVersion") - } else { - Install-Pnpm - Install-Client '@deepseek-ai/dsh' $DshVersion 'dsh' - $script:Phase='DSH LMM provider'; $archive=Join-Path $script:Cache ($DshProviderUrl.Split('/')[-1]) - Get-VerifiedFile $DshProviderUrl $archive $DshProviderSha256 - # DSH 0.1.5 uses a shell to invoke pnpm on Windows. Passing absolute - # paths containing spaces loses argument boundaries in that layer. - # Keep the verified immutable package inside the profile and pass a - # path-free file: spec; configure the store through environment instead. - $profileHome=$env:DSH_HOME - $userDirectory=[Environment]::GetFolderPath('UserProfile') - if (!$profileHome) { $profileHome=Join-Path $userDirectory '.dsh' } - elseif ($profileHome -eq '~') { $profileHome=$userDirectory } - elseif ($profileHome.StartsWith('~/') -or $profileHome.StartsWith('~\')) { $profileHome=Join-Path $userDirectory $profileHome.Substring(2) } - if (![IO.Path]::IsPathRooted($profileHome)) { $profileHome=Join-Path (Get-Location).ProviderPath $profileHome } - $profileDirectory=Join-Path ([IO.Path]::GetFullPath($profileHome)) "profiles\$Profile" - New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null - $packageName='.lmm-provider-'+$DshProviderSha256.Substring(0,16)+'.tgz' - $profilePackage=Join-Path $profileDirectory $packageName - if (Test-Path -LiteralPath $profilePackage) { - if ((Get-Hash $profilePackage) -ne $DshProviderSha256) { throw 'Conflicting installer package in the DSH profile; inspect it before retrying.' } - } else { - $pending=Join-Path $profileDirectory ('.lmm-package-'+[Guid]::NewGuid().ToString('N')+'.tmp') - try { Copy-Item -LiteralPath $archive -Destination $pending; Move-Item -LiteralPath $pending -Destination $profilePackage -Force } - finally { if (Test-Path -LiteralPath $pending) { Remove-Item -LiteralPath $pending -Force } } - } - $env:npm_config_store_dir=Join-Path $script:Cache 'pnpm' - Invoke-WithRegistryRetry $script:Client @('plugin','--profile',$Profile,'add',"file:$packageName",'--ignore-scripts') - } - } + Install-Tool $script:Phase='launcher and PATH'; Write-Launcher; $script:InstalledSuccess=$true Write-Log "Ready: $(Join-Path $Root "bin\$Target.cmd")" Write-Log 'Use the full path above. With -AddPath, new terminals can use the short command.' diff --git a/lmm.sh b/lmm.sh index adf8280..a18912f 100755 --- a/lmm.sh +++ b/lmm.sh @@ -1,28 +1,12 @@ #!/usr/bin/env bash lmm_install_main() { -# Generated from templates/install.sh.in and versions.json. No sudo, no API keys. +# Generated from templates/ and versions.json. Edit the source, not this file. set -euo pipefail set +x TARGET=lmm -SCRIPT_VERSION=2026.09.20.1 -NODE_VERSION=24.21.0 -PI_VERSION=0.85.1 -PI_PROVIDER_VERSION=0.1.0-alpha.1 -PNPM_VERSION=11.7.0 -DSH_VERSION=0.1.5-rc.2 -DSH_PROVIDER_URL=https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/download/v0.1.0-alpha.2/tokennotincluded-dsh-lmm-provider-0.1.0-alpha.2.tgz -DSH_PROVIDER_SHA256=609eba9f1516cadf7086e44d290752d1361ac607eb1d1cb5682abfa5e806304d +SCRIPT_VERSION=2026.09.20.2 LMM_VERSION=0.1.0 LMM_RELEASE_BASE=https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0 -node_hash() { case "$1" in - linux-x64) printf '%s\n' 6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff;; - linux-arm64) printf '%s\n' 724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5;; - darwin-x64) printf '%s\n' 1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097;; - darwin-arm64) printf '%s\n' bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057;; - win-x64) printf '%s\n' 158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541;; - win-arm64) printf '%s\n' 8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921;; - *) printf '\n';; -esac; } lmm_hash() { case "$1" in linux-x64) printf '%s\n' 292a1ff8b599466f52747867a0b14bd14860faefaa085cc60746040cd7eba9b7;; darwin-arm64) printf '%s\n' 8f6b3a2d08500566b528b7089664420d3395e464c172e3a43dfcb73d37f57b3f;; @@ -30,11 +14,136 @@ lmm_hash() { case "$1" in *) printf '\n';; esac; } -ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} -NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 INSTALL_NODE=1 -STAGE='' LOCKED=0 PHASE=arguments BOOTSTRAP=1 +lmm_root() { + printf '%s\n' "${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}" +} +sha256() { + local digest + if command -v sha256sum >/dev/null 2>&1; then digest=$(sha256sum "$1") || return; printf '%s\n' "${digest%% *}" + elif command -v shasum >/dev/null 2>&1; then digest=$(shasum -a 256 "$1") || return; printf '%s\n' "${digest%% *}" + elif command -v openssl >/dev/null 2>&1; then digest=$(openssl dgst -sha256 "$1") || return; printf '%s\n' "${digest##* }" + else printf 'Install a SHA-256 tool.\n' >&2; return 1; fi +} +# Native Termux uses Android/bionic, not desktop Linux/glibc. +lmm_is_termux() { + [ -n "${TERMUX_VERSION:-}${TERMUX_APP__PACKAGE_NAME:-}" ] || + case "${PREFIX:-}" in */com.termux/files/usr) true;; *) false;; esac +} +lmm_temp_root() { + if [ -n "${TMPDIR:-}" ]; then printf '%s\n' "$TMPDIR" + elif lmm_is_termux; then printf '%s/tmp\n' "${PREFIX:-$HOME/.cache/lmm-tools}" + else printf '/tmp\n'; fi +} +lmm_check_storage() { + lmm_is_termux || return 0 + local resolved + # realpath -m also resolves missing paths and symlinked storage aliases. + command -v realpath >/dev/null 2>&1 || { + printf 'Termux needs coreutils: pkg install coreutils\n' >&2; return 1; + } + resolved=$(realpath -m -- "$1") || return 1 + case "$resolved/" in + /sdcard/*|/storage/*|/mnt/sdcard/*|/mnt/media_rw/*|/mnt/runtime/*|/mnt/user/*|/mnt/pass_through/*) + printf 'Use Termux private storage under HOME, not shared storage: %s\n' "$1" >&2 + return 1;; + esac +} +quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +rank_urls() { + local i=0 url response code elapsed probe_dir + if ! command -v curl >/dev/null 2>&1; then for url in "$@"; do printf '%s\n' "$i"; i=$((i+1)); done; return; fi + probe_dir=$(mktemp -d "$STAGE/probes.XXXXXX") + for url in "$@"; do + ( + response=$(curl -q --proto '=https' --proto-redir '=https' -ILs --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code} %{time_starttransfer}' "$url" 2>/dev/null || true) + code=${response%% *}; elapsed=${response#* } + case "$code" in 2??|3??) ;; *) elapsed=999;; esac + case "$elapsed" in ''|*[!0-9.]*) elapsed=999;; esac + printf '%s %s\n' "$elapsed" "$i" > "$probe_dir/$i" + ) & + i=$((i+1)) + done + wait + cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | while read -r elapsed index; do printf '%s\n' "$index"; done +} +urls_for() { + URLS=("$1") + case "$1" in + https://nodejs.org/dist/*) MIRRORS=("https://npmmirror.com/mirrors/node/${1#https://nodejs.org/dist/}");; + https://github.com/*) MIRRORS=("https://ghfast.top/$1" "https://ghproxy.net/$1");; + *) MIRRORS=();; + esac + case "$NETWORK" in + auto) URLS+=("${MIRRORS[@]}");; + china) URLS=("${MIRRORS[@]}" "$1");; + esac +} +download() { + local official=$1 destination=$2 expected=$3 index url part attempt actual order transfer_status + part="$destination.part" + if [ -L "$destination" ] || [ -L "$part" ] || [ -L "$part.url" ]; then fail 'Refusing symlink cache entries'; fi + if [ "$FORCE" = 0 ] && [ -f "$destination" ] && [ "$(sha256 "$destination")" = "$expected" ]; then log "Cached: ${destination##*/}"; return; fi + if [ -f "$part" ] && [ "$(sha256 "$part")" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi + command -v curl >/dev/null 2>&1 || fail 'curl is required for downloads. Install it with your OS package manager.' + if [[ $official == https://nodejs.org/dist/* && -n ${LMM_NODE_BASE_URL:-} ]]; then official="${LMM_NODE_BASE_URL%/}/${official#https://nodejs.org/dist/}"; fi + urls_for "$official" + if [ "$NETWORK" = auto ]; then order=$(rank_urls "${URLS[@]}"); else order=$(printf '%s\n' "${!URLS[@]}"); fi + for index in $order; do + url=${URLS[$index]} + if [ -f "$part" ] && [ "$(cat "$part.url" 2>/dev/null || true)" != "$url" ]; then rm -f -- "$part"; fi + printf '%s\n' "$url" > "$part.url" + for ((attempt=1; attempt<=RETRIES; attempt++)); do + log "Downloading ${destination##*/} (source $((index+1)), attempt $attempt; low-speed cutoff ${STALL_TIMEOUT}s)" + if curl -q --proto '=https' --proto-redir '=https' -fL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$DOWNLOAD_TIMEOUT" --speed-time "$STALL_TIMEOUT" --speed-limit "$MIN_SPEED" --continue-at - --output "$part" "$url"; then + actual=$(sha256 "$part") + if [ "$actual" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi + log 'Checksum mismatch: discarded the download; it will not be executed.' + rm -f -- "$part" + break + else transfer_status=$?; fi + # A server may reject Range; retry once from a clean file. Retain a + # partial transfer after final failure for the next invocation. + if [ "$attempt" = 1 ]; then case "$transfer_status" in 22|33|36) rm -f -- "$part";; esac; fi + done + done + fail "Download failed: ${destination##*/}. Rerun to resume, or choose another --network mode." +} +install_lmm() { + PHASE='LMM CLI' + local hash target="$ROOT/apps/lmm/$LMM_VERSION-$PLATFORM" archive + if [ "$FORCE" = 0 ] && [ -x "$target/lmm" ] && [ -f "$target/.lmm-managed" ]; then CLIENT="$target/lmm"; return; fi + mkdir -p "$STAGE/lmm" + if [ "$SOURCE" = 1 ]; then + command -v cargo >/dev/null 2>&1 || fail 'Source builds need Rust 1.88+ and OS build tools. Install them first, then rerun --from-source.' + log 'Building the pinned crate; Cargo reuses its existing dependency/build caches. This can take several minutes.' + export CARGO_HTTP_TIMEOUT="$STALL_TIMEOUT" CARGO_NET_RETRY="$RETRIES" + export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$CACHE/cargo-target} + cargo install lmm-cli --version "$LMM_VERSION" --locked --root "$STAGE/cargo" /dev/null || fail 'CLI cannot run here. Linux x64 preview binaries need glibc 2.39+; use --from-source on older Linux.' + printf '%s\n' "$LMM_VERSION" > "$STAGE/lmm/.lmm-managed" + mkdir -p "$(dirname "$target")" + if [ -e "$target" ]; then [ -f "$target/.lmm-managed" ] || fail "Unowned path: $target"; target="$target-reinstall-$(date +%s)-$$"; fi + mv -- "$STAGE/lmm" "$target"; CLIENT="$target/lmm" +} +install_tool() { install_lmm; } + +ROOT=$(lmm_root) +NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 +STAGE='' LOCKED=0 PHASE=arguments RUN_ARGS=() -NPM_SELECTED=0 + PNPM_BIN='' log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } fail() { log "ERROR: $*"; exit 1; } @@ -62,7 +171,7 @@ while [ "$#" -gt 0 ]; do case "$1" in --help|-h) usage; exit 0;; --check) CHECK=1;; --update) FORCE=1;; --launch) LAUNCH=1;; - --add-path) ADD_PATH=1;; --no-path) ADD_PATH=0;; --install-only) LAUNCH=0;; --no-bootstrap) INSTALL_NODE=0; BOOTSTRAP=0;; --from-source) SOURCE=1;; --no-install-node) INSTALL_NODE=0;; + --add-path) ADD_PATH=1;; --no-path) ADD_PATH=0;; --install-only) LAUNCH=0;; --no-bootstrap) :;; --from-source) SOURCE=1;; --no-install-node) :;; --root|--network|--profile) if [ "$#" -lt 2 ] || [ -z "${2:-}" ]; then fail "$1 requires a value"; fi case "$1" in --root) ROOT=$2;; --network) NETWORK=$2;; --profile) PROFILE=$2;; esac; shift;; @@ -81,6 +190,7 @@ for setting in "$RETRIES" "$CONNECT_TIMEOUT" "$STALL_TIMEOUT" "$DOWNLOAD_TIMEOUT [[ $setting =~ ^[1-9][0-9]*$ && ${#setting} -le 8 ]] || fail 'Timeouts, retry counts and minimum speed must be positive integers.' done ((RETRIES <= 10 && CONNECT_TIMEOUT <= 300 && STALL_TIMEOUT <= 86400 && DOWNLOAD_TIMEOUT <= 86400 && COMMAND_TIMEOUT <= 86400 && MIN_SPEED <= 10485760)) || fail 'Network setting exceeds supported limits.' +case "$ROOT" in /*) ;; *) ROOT="$PWD/$ROOT";; esac CACHE=${LMM_CACHE_ROOT:-$ROOT/cache} case "$CACHE" in /*) ;; *) fail 'LMM_CACHE_ROOT must be an absolute path';; esac if [ "$CACHE" = / ] || [ -L "$ROOT" ] || [ -L "$CACHE" ]; then fail 'Refusing root or symlink installation/cache paths.'; fi @@ -94,30 +204,28 @@ case "$PROFILE" in web|headless) ;; *) fail 'profile must be web or headless';; [ "$TARGET" = lmm ] || [ "$SOURCE" = 0 ] || fail '--from-source is only for lmm' case "$ROOT" in *$'\n'*|*$'\r'*) fail 'Install path must not contain newlines';; /*) ;; *) ROOT="$PWD/$ROOT";; esac if [ "$ROOT" = / ] || [ "$ROOT" = "$HOME" ]; then fail 'Choose a dedicated installation directory'; fi -case "$(uname -s)" in Linux) OS=linux;; Darwin) OS=darwin;; *) fail 'This script supports Linux/macOS. On Windows use the .ps1 script.';; esac -case "$(uname -m)" in x86_64|amd64) ARCH=x64;; arm64|aarch64) ARCH=arm64;; *) fail 'Unsupported CPU; use the documented source build on this platform.';; esac +case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac +if lmm_is_termux; then OS=android; fi +case "$(uname -m)" in + x86_64|amd64) ARCH=x64;; arm64|aarch64) ARCH=arm64;; + armv7l|armv8l|arm) [ "$OS" = android ] || fail '32-bit desktop Linux is not supported'; ARCH=arm;; + i386|i686) [ "$OS" = android ] || fail '32-bit desktop Linux is not supported'; ARCH=ia32;; + *) fail 'Unsupported CPU';; +esac PLATFORM="$OS-$ARCH" -sha256() { - if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' - elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' - elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' - else fail 'Install a SHA-256 tool (sha256sum, shasum or openssl)'; fi -} -compatible_node() { - command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && - node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit((a===22&&b>=19)||a>=24?0:1)' >/dev/null 2>&1 -} +lmm_check_storage "$ROOT" || exit 1 +lmm_check_storage "$CACHE" || exit 1 +if [ "$OS" = android ] && [ "$TARGET" != lmm ]; then + compatible_node || fail 'In Termux, install native Node/npm: pkg install nodejs npm git; then rerun. Desktop Node cannot run on Android.' + command -v git >/dev/null 2>&1 || fail 'Pi/DSH need git: pkg install git' +fi # --check never creates directories, downloads, edits PATH or touches credentials. if [ "$CHECK" = 1 ]; then log "Platform: $PLATFORM; install root: $ROOT" if [ -x "$ROOT/bin/$TARGET" ]; then "$ROOT/bin/$TARGET" --version elif command -v "$TARGET" >/dev/null 2>&1; then "$TARGET" --version else log "$TARGET is not installed in this root or PATH"; exit 1; fi - if [ "$TARGET" != lmm ]; then - if compatible_node; then log 'Current PATH has compatible Node/npm.' - elif [ -x "$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" ]; then log 'Managed Node runtime is available.' - else fail 'No compatible Node runtime found'; fi - fi + log 'Executable check complete; login and model access are not inferred.' exit 0 fi @@ -144,6 +252,11 @@ trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM umask 077 +if [ "$OS" = android ]; then + TMPDIR=$(lmm_temp_root); lmm_check_storage "$TMPDIR" || exit 1 + mkdir -p "$TMPDIR"; export TMPDIR + if [ "$TARGET" = dsh ]; then log 'DSH native dependencies have not been validated on Android.'; fi +fi mkdir -p "$ROOT" "$CACHE" "$ROOT/bin" "$ROOT/apps" "$ROOT/runtime" ROOT=$(cd "$ROOT" && pwd -P) if ! mkdir "$ROOT/.setup-lock" 2>/dev/null; then @@ -161,222 +274,17 @@ LOCKED=1 STAGE=$(mktemp -d "$ROOT/.setup.XXXXXX") # Probe only public, credential-free artifact URLs. Slow transfers are still # interrupted independently of the latency ranking below. -rank_urls() { - local i=0 url response code elapsed probe_dir - if ! command -v curl >/dev/null 2>&1; then for url in "$@"; do printf '%s\n' "$i"; i=$((i+1)); done; return; fi - probe_dir=$(mktemp -d "$STAGE/probes.XXXXXX") - for url in "$@"; do - ( - response=$(curl -q --proto '=https' --proto-redir '=https' -ILs --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code} %{time_starttransfer}' "$url" 2>/dev/null || true) - code=${response%% *}; elapsed=${response#* } - case "$code" in 2??|3??) ;; *) elapsed=999;; esac - case "$elapsed" in ''|*[!0-9.]*) elapsed=999;; esac - printf '%s %s\n' "$elapsed" "$i" > "$probe_dir/$i" - ) & - i=$((i+1)) - done - wait - cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | awk '{print $2}' -} -urls_for() { - URLS=("$1") - case "$1" in - https://nodejs.org/dist/*) MIRRORS=("https://npmmirror.com/mirrors/node/${1#https://nodejs.org/dist/}");; - https://github.com/*) MIRRORS=("https://ghfast.top/$1" "https://ghproxy.net/$1");; - *) MIRRORS=();; - esac - case "$NETWORK" in - auto) URLS+=("${MIRRORS[@]}");; - china) URLS=("${MIRRORS[@]}" "$1");; - esac -} -download() { - local official=$1 destination=$2 expected=$3 index url part attempt actual order transfer_status - part="$destination.part" - if [ -L "$destination" ] || [ -L "$part" ] || [ -L "$part.url" ]; then fail 'Refusing symlink cache entries'; fi - if [ "$FORCE" = 0 ] && [ -f "$destination" ] && [ "$(sha256 "$destination")" = "$expected" ]; then log "Cached: ${destination##*/}"; return; fi - if [ -f "$part" ] && [ "$(sha256 "$part")" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi - command -v curl >/dev/null 2>&1 || fail 'curl is required for downloads. Install it with your OS package manager.' - if [[ $official == https://nodejs.org/dist/* && -n ${LMM_NODE_BASE_URL:-} ]]; then official="${LMM_NODE_BASE_URL%/}/${official#https://nodejs.org/dist/}"; fi - urls_for "$official" - if [ "$NETWORK" = auto ]; then order=$(rank_urls "${URLS[@]}"); else order=$(printf '%s\n' "${!URLS[@]}"); fi - for index in $order; do - url=${URLS[$index]} - if [ -f "$part" ] && [ "$(cat "$part.url" 2>/dev/null || true)" != "$url" ]; then rm -f -- "$part"; fi - printf '%s\n' "$url" > "$part.url" - for ((attempt=1; attempt<=RETRIES; attempt++)); do - log "Downloading ${destination##*/} (source $((index+1)), attempt $attempt; low-speed cutoff ${STALL_TIMEOUT}s)" - if curl -q --proto '=https' --proto-redir '=https' -fL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$DOWNLOAD_TIMEOUT" --speed-time "$STALL_TIMEOUT" --speed-limit "$MIN_SPEED" --continue-at - --output "$part" "$url"; then - actual=$(sha256 "$part") - if [ "$actual" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi - log 'Checksum mismatch: discarded the download; it will not be executed.' - rm -f -- "$part" - break - else transfer_status=$?; fi - # A server may reject Range; retry once from a clean file. Retain a - # partial transfer after final failure for the next invocation. - if [ "$attempt" = 1 ]; then case "$transfer_status" in 22|33|36) rm -f -- "$part";; esac; fi - done - done - fail "Download failed: ${destination##*/}. Rerun to resume, or choose another --network mode." -} -ensure_node() { - PHASE='Node.js runtime' - if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi - # Android uses bionic, not the glibc used by the Linux Node archives. - if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then - fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' - fi - local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive - if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi - if compatible_node; then NODE_BIN="$dir/bin"; return; fi - [ "$INSTALL_NODE" = 1 ] || fail 'Need Node 22.19+ (22.x) or Node 24+, including npm.' - [ ! -f /etc/alpine-release ] || fail 'On Alpine install nodejs/npm with apk first; official Node archives require glibc.' - hash=$(node_hash "$PLATFORM") - [ -n "$hash" ] || fail "No verified Node archive for $PLATFORM" - archive="$CACHE/node-v$NODE_VERSION-$PLATFORM.tar.gz" - download "https://nodejs.org/dist/v$NODE_VERSION/${archive##*/}" "$archive" "$hash" - mkdir -p "$STAGE/runtime" - tar -xzf "$archive" -C "$STAGE/runtime" - "$STAGE/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" --version >/dev/null || fail 'Node cannot run on this OS/libc. Install compatible Node using your OS package manager.' - [ ! -e "$dir" ] || fail "Managed runtime exists but is unusable: $dir. Inspect it before replacing." - mv -- "$STAGE/runtime/node-v$NODE_VERSION-$PLATFORM" "$dir" - NODE_BIN="$dir/bin"; export PATH="$NODE_BIN:$PATH" -} -configure_npm() { - if [ -z "${npm_config_cache:-}" ]; then - npm_config_cache=$(npm config get cache 2>/dev/null || true) - case "$npm_config_cache" in /*) ;; *) npm_config_cache="$CACHE/npm";; esac - export npm_config_cache - fi - export npm_config_fetch_retries="$RETRIES" npm_config_fetch_timeout="$((STALL_TIMEOUT * 1000))" - export npm_config_fetch_retry_mintimeout=2000 npm_config_fetch_retry_maxtimeout=30000 - export npm_config_strict_ssl=true - if [ -n "${LMM_NPM_REGISTRY:-}" ]; then export npm_config_registry="$LMM_NPM_REGISTRY"; fi - export npm_config_prefer_offline=true - local current order first - current=$(npm config get registry 2>/dev/null || true) - if [ -n "${npm_config_registry:-}" ] || { [ -n "$current" ] && [ "$current" != https://registry.npmjs.org/ ]; }; then - log 'Keeping your existing npm registry/proxy configuration.'; return - fi - NPM_SELECTED=1 - case "$NETWORK" in - official) export npm_config_registry=https://registry.npmjs.org/;; - china) export npm_config_registry=https://registry.npmmirror.com/;; - auto) - order=$(rank_urls https://registry.npmjs.org/ https://registry.npmmirror.com/) - first=${order%%$'\n'*} - if [ "$first" = 1 ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi;; - esac - log 'Selected a registry for this installer process only; global npm settings are unchanged.' -} -bounded() { - node - "$COMMAND_TIMEOUT" "$@" <<'JS' -const {spawn}=require('node:child_process'); -const [seconds,command,...args]=process.argv.slice(2); -const child=spawn(command,args,{stdio:'inherit',detached:true}); -let timedOut=false,stopping=false; -function stop(code){if(stopping)return;stopping=true;timedOut=code===124;try{process.kill(-child.pid,'SIGTERM')}catch{};const hard=setTimeout(()=>{try{process.kill(-child.pid,'SIGKILL')}catch{}},3000);hard.unref()} -const start=Date.now();const heartbeat=setInterval(()=>process.stderr.write(`[install] Still working: ${Math.floor((Date.now()-start)/1000)}s elapsed.\n`),15000); -const timeout=setTimeout(()=>{process.stderr.write('[install] Operation timed out; increase LMM_COMMAND_TIMEOUT for a slow connection.\n');stop(124)},Number(seconds)*1000); -process.on('SIGINT',()=>stop(130));process.on('SIGTERM',()=>stop(143)); -child.on('error',e=>{clearInterval(heartbeat);clearTimeout(timeout);process.stderr.write(`[install] Cannot start ${command}: ${e.code}\n`);process.exitCode=1}); -child.on('exit',(code,signal)=>{clearInterval(heartbeat);clearTimeout(timeout);process.exitCode=timedOut?124:signal?130:code??1}); -JS -} -with_registry_retry() { - if bounded "$@"; then return; fi - if [ "$NETWORK" = auto ] && [ "$NPM_SELECTED" = 1 ]; then - if [ "$npm_config_registry" = https://registry.npmjs.org/ ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi - log 'Retrying the alternate registry with the same package cache.' - bounded "$@" - else fail 'Package installation failed. Check network/proxy settings or try another --network mode.'; fi -} -ensure_pnpm() { - PHASE='DSH package manager' - local directory="$ROOT/tools/pnpm/$PNPM_VERSION" work="$STAGE/pnpm" - if [ -x "$directory/bin/pnpm" ] && [ -f "$directory/.lmm-managed" ]; then PNPM_BIN="$directory/bin" - elif [ "$BOOTSTRAP" = 0 ]; then - command -v pnpm >/dev/null 2>&1 || fail 'pnpm is missing; rerun without --no-bootstrap.' - bounded pnpm --version - PNPM_BIN=$(dirname "$(command -v pnpm)") - else - mkdir -p "$work" "$(dirname "$directory")" - with_registry_retry npm install --global --prefix "$work" --ignore-scripts --no-audit --no-fund "pnpm@$PNPM_VERSION" - bounded "$work/bin/pnpm" --version - printf '%s\n' "$PNPM_VERSION" > "$work/.lmm-managed" - if [ -e "$directory" ]; then - [ -f "$directory/.lmm-managed" ] || fail "Unowned package-manager directory: $directory" - directory="$directory-reinstall-$(date +%s)-$$" - fi - mv -- "$work" "$directory"; PNPM_BIN="$directory/bin" - fi - export PATH="$PNPM_BIN:$PATH" -} -install_client() { - local package=$1 version=$2 entry=$3 target="$ROOT/apps/$TARGET/$2" work="$STAGE/client" allow - PHASE="$TARGET client" - if [ "$FORCE" = 0 ] && [ -x "$target/bin/$entry" ] && [ -f "$target/.lmm-managed" ] && [ "$(cat "$target/.lmm-managed")" = "$version|$SCRIPT_VERSION" ]; then CLIENT="$target/bin/$entry"; log "Client $version already installed."; return; fi - [ "$BOOTSTRAP" = 1 ] || fail 'Managed client is missing; rerun without --no-bootstrap.' - mkdir -p "$work" - INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") - if [ "$TARGET" = pi ]; then - # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. - INSTALL_ARGS+=(--ignore-scripts) - else - allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' - if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi - [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' - fi - with_registry_retry npm "${INSTALL_ARGS[@]}" - "$work/bin/$entry" --version >/dev/null - printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" - mkdir -p "$(dirname "$target")" - if [ -e "$target" ]; then - [ -f "$target/.lmm-managed" ] || fail "Not replacing an unowned directory: $target" - target="$target-reinstall-$(date +%s)-$$" - fi - mv -- "$work" "$target" - CLIENT="$target/bin/$entry" -} -install_lmm() { - PHASE='LMM CLI' - local hash target="$ROOT/apps/lmm/$LMM_VERSION-$PLATFORM" archive - if [ "$FORCE" = 0 ] && [ -x "$target/lmm" ] && [ -f "$target/.lmm-managed" ]; then CLIENT="$target/lmm"; return; fi - mkdir -p "$STAGE/lmm" - if [ "$SOURCE" = 1 ]; then - command -v cargo >/dev/null 2>&1 || fail 'Source builds need Rust 1.88+ and OS build tools. Install them first, then rerun --from-source.' - log 'Building the pinned crate; Cargo reuses its existing dependency/build caches. This can take several minutes.' - export CARGO_HTTP_TIMEOUT="$STALL_TIMEOUT" CARGO_NET_RETRY="$RETRIES" - export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$CACHE/cargo-target} - cargo install lmm-cli --version "$LMM_VERSION" --locked --root "$STAGE/cargo" /dev/null || fail 'CLI cannot run here. Linux x64 preview binaries need glibc 2.39+; use --from-source on older Linux.' - printf '%s\n' "$LMM_VERSION" > "$STAGE/lmm/.lmm-managed" - mkdir -p "$(dirname "$target")" - if [ -e "$target" ]; then [ -f "$target/.lmm-managed" ] || fail "Unowned path: $target"; target="$target-reinstall-$(date +%s)-$$"; fi - mv -- "$STAGE/lmm" "$target"; CLIENT="$target/lmm" -} -quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } write_launcher() { local launcher="$ROOT/bin/$TARGET" temp="$STAGE/launcher" { - printf '#!/usr/bin/env bash\n# Managed by LMM installers.\n' + if [ "$OS" = android ]; then printf '#!%s\n' "$BASH" + else printf '#!/usr/bin/env bash\n'; fi + printf '# Managed by LMM installers.\n' # The launcher must expand PATH when it runs, not while it is generated. # shellcheck disable=SC2016 if [ "$TARGET" != lmm ]; then printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$NODE_BIN${PNPM_BIN:+:$PNPM_BIN}")"; fi - printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" + if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" + else printf 'exec %s %s "$@"\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi } > "$temp" chmod +x "$temp" if [ -e "$launcher" ] && ! grep -q '# Managed by LMM installers.' "$launcher"; then fail "Refusing to overwrite your existing launcher: $launcher"; fi @@ -398,21 +306,7 @@ add_path() { printf '\n%s\n%s\n# <<< LMM tools PATH <<<\n' "$marker" "$line" >> "$file" done } -if [ "$TARGET" = lmm ]; then install_lmm -else - ensure_node; configure_npm - if [ "$TARGET" = pi ]; then - install_client @earendil-works/pi-coding-agent "$PI_VERSION" pi - PHASE='Pi LMM provider'; with_registry_retry "$CLIENT" install "npm:@tokennotincluded/pi-lmm-provider@$PI_PROVIDER_VERSION" - else - ensure_pnpm - install_client @deepseek-ai/dsh "$DSH_VERSION" dsh - PHASE='DSH LMM provider' - artifact="$CACHE/${DSH_PROVIDER_URL##*/}" - download "$DSH_PROVIDER_URL" "$artifact" "$DSH_PROVIDER_SHA256" - with_registry_retry "$CLIENT" plugin --profile "$PROFILE" add "$artifact" --ignore-scripts --store-dir "$CACHE/pnpm" - fi -fi +install_tool PHASE='launchers and PATH'; write_launcher; add_path log "Ready: $ROOT/bin/$TARGET" log "Use the full command above, or for this terminal: export PATH=$(quote_sh "$ROOT/bin"):\"\$PATH\"" diff --git a/menu.sh b/menu.sh index 2bc8b4a..ebdf113 100755 --- a/menu.sh +++ b/menu.sh @@ -2,6 +2,41 @@ # Complete function before execution: safe when downloaded through a pipe. lmm_menu_main() ( set -u +lmm_root() { + printf '%s\n' "${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}" +} +sha256() { + local digest + if command -v sha256sum >/dev/null 2>&1; then digest=$(sha256sum "$1") || return; printf '%s\n' "${digest%% *}" + elif command -v shasum >/dev/null 2>&1; then digest=$(shasum -a 256 "$1") || return; printf '%s\n' "${digest%% *}" + elif command -v openssl >/dev/null 2>&1; then digest=$(openssl dgst -sha256 "$1") || return; printf '%s\n' "${digest##* }" + else printf 'Install a SHA-256 tool.\n' >&2; return 1; fi +} +# Native Termux uses Android/bionic, not desktop Linux/glibc. +lmm_is_termux() { + [ -n "${TERMUX_VERSION:-}${TERMUX_APP__PACKAGE_NAME:-}" ] || + case "${PREFIX:-}" in */com.termux/files/usr) true;; *) false;; esac +} +lmm_temp_root() { + if [ -n "${TMPDIR:-}" ]; then printf '%s\n' "$TMPDIR" + elif lmm_is_termux; then printf '%s/tmp\n' "${PREFIX:-$HOME/.cache/lmm-tools}" + else printf '/tmp\n'; fi +} +lmm_check_storage() { + lmm_is_termux || return 0 + local resolved + # realpath -m also resolves missing paths and symlinked storage aliases. + command -v realpath >/dev/null 2>&1 || { + printf 'Termux needs coreutils: pkg install coreutils\n' >&2; return 1; + } + resolved=$(realpath -m -- "$1") || return 1 + case "$resolved/" in + /sdcard/*|/storage/*|/mnt/sdcard/*|/mnt/media_rw/*|/mnt/runtime/*|/mnt/user/*|/mnt/pass_through/*) + printf 'Use Termux private storage under HOME, not shared storage: %s\n' "$1" >&2 + return 1;; + esac +} + case "${1:-}" in --help|-h) printf 'LMM menu: bash menu.sh [--help]\nInteractive terminal required. Choose Pi, DSH or LMM CLI, then an action.\n'; exit 0;; '') ;; @@ -11,11 +46,6 @@ if ! { exec 3/dev/null; then printf '需要交互终端。请在终端运行菜单;自动化请使用底层安装脚本。\n' >&2; exit 2 fi command -v curl >/dev/null 2>&1 || { printf '请先安装 curl。\n' >&2; exit 2; } -hash_file() { - if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1 - elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d ' ' -f 1 - else printf '需要 sha256sum 或 shasum。\n' >&2; return 1; fi -} expected_hash() { case "$1" in pi.sh) printf '%s' 'ae5f009593005768c10266618049135d0a9a42eb05f2d357ba5dbc4fccb533bf';; dsh.sh) printf '%s' 'd4d18452773a93c8f22b1722ff4fce0d32af870510065d3a1472530b477e23b0';; @@ -24,20 +54,24 @@ lmm-use.sh) printf '%s' '5240b7192e0fcb7700fd76b0d375f528422d6f38e751d220e9202ac *) return 1;; esac; } ask() { printf '%s' "$1"; IFS= read -r answer <&3 || exit 0; } -work=$(mktemp -d "${TMPDIR:-/tmp}/lmm-menu.XXXXXXXX") || exit 1 +umask 077 +temp_root=$(lmm_temp_root) +lmm_check_storage "$temp_root" || exit 1 +mkdir -p "$temp_root" || exit 1 +work=$(mktemp -d "$temp_root/lmm-menu.XXXXXXXX") || exit 1 trap 'rm -rf -- "$work"' EXIT trap 'exit 130' INT trap 'exit 143' TERM network=auto -root=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} +root=$(lmm_root) fetch_script() { local name=$1 expected url expected=$(expected_hash "$name") || return 1 - if [ -f "$work/$name" ] && [ "$(hash_file "$work/$name")" = "$expected" ]; then return 0; fi + if [ -f "$work/$name" ] && [ "$(sha256 "$work/$name")" = "$expected" ]; then return 0; fi printf '正在获取并校验安装程序(下载慢时会重试)…\n' for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/95c162c2031ecba34942b2a91631c1ec1f6f3d05/$name"; do if curl -q -fSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 2 --retry-delay 2 "$url" -o "$work/download"; then - if [ "$(hash_file "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi + if [ "$(sha256 "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi printf '文件版本或校验不匹配,尝试固定版本备用地址。\n' >&2 fi done diff --git a/pi.ps1 b/pi.ps1 index 9e43e28..b699afe 100644 --- a/pi.ps1 +++ b/pi.ps1 @@ -12,16 +12,10 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'pi' -$ScriptVersion = '2026.09.20.1' +$ScriptVersion = '2026.09.20.2' $NodeVersion = '24.21.0' $PiVersion = '0.85.1' $PiProviderVersion = '0.1.0-alpha.1' -$PnpmVersion = '11.7.0' -$DshVersion = '0.1.5-rc.2' -$DshProviderUrl = 'https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/download/v0.1.0-alpha.2/tokennotincluded-dsh-lmm-provider-0.1.0-alpha.2.tgz' -$DshProviderSha256 = '609eba9f1516cadf7086e44d290752d1361ac607eb1d1cb5682abfa5e806304d' -$LmmVersion = '0.1.0' -$LmmReleaseBase = 'https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0' $NodeHashes = @{ 'linux-x64' = '6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff' 'linux-arm64' = '724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5' @@ -30,30 +24,7 @@ $NodeHashes = @{ 'win-x64' = '158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541' 'win-arm64' = '8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921' } -$LmmHashes = @{ - 'linux-x64' = '292a1ff8b599466f52747867a0b14bd14860faefaa085cc60746040cd7eba9b7' - 'darwin-arm64' = '8f6b3a2d08500566b528b7089664420d3395e464c172e3a43dfcb73d37f57b3f' - 'win-x64' = 'd0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9' -} -$script:InstalledSuccess=$false -$script:Stage = $null; $script:LockHandle = $null; $script:Phase = 'arguments' -$Retries=3; $ConnectTimeout=10; $StallTimeout=20; $DownloadTimeout=600; $CommandTimeout=1800; $MinSpeed=16384 -$script:Cache=$null -$script:PnpmBin=$null -$script:Client = $null; $script:NodeBin = $null; $script:NpmSelected = $false -function Write-Log([string]$Message) { Write-Host "[lmm $Target] $Message" } -function Stop-Setup([string]$Message) { throw $Message } -function Show-Usage { - Write-Host @" -LMM $Target installer $ScriptVersion -Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] - [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] - [-FromSource] [-Launch] [-Help] -No automatic login or PATH changes. Pi on Windows requires Bash. --FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. -"@ -} function Setting([string]$Name, [int]$Default, [int]$Maximum) { $raw = [Environment]::GetEnvironmentVariable($Name) if ([string]::IsNullOrEmpty($raw)) { return $Default } @@ -146,36 +117,6 @@ function Invoke-Native([string]$Command, [string[]]$Arguments) { Invoke-Bounded $Command $Arguments } function Get-Hash([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } -function Test-Node { - $node = Get-Command node.exe -ErrorAction SilentlyContinue - $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue - if (-not $node -or -not $npm) { return $false } - try { $versionText=(& $node.Source --version 2>$null | Out-String).Trim() } catch { return $false } - if ($LASTEXITCODE -ne 0 -or $versionText -notmatch '^v([0-9]+)\.([0-9]+)\.[0-9]+') { return $false } - $major=[int]$Matches[1];$minor=[int]$Matches[2] - return (($major -eq 22 -and $minor -ge 19) -or $major -ge 24) -} -function Assert-PiShell { - # Follow Pi's shellPath -> Git Bash -> PATH lookup, without editing settings. - $agentDirectory=$env:PI_CODING_AGENT_DIR - if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } - $settingsPath=Join-Path $agentDirectory 'settings.json' - if (Test-Path -LiteralPath $settingsPath) { - try { $settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json } - catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } - if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } - $property=$settings.PSObject.Properties['shellPath'] - if ($property -and $property.Value) { - $shell=[string]$property.Value - if (Test-Path -LiteralPath $shell -PathType Leaf) { return } - if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } - throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." - } - } - if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') -PathType Leaf)) { return } - if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } - throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' -} function Set-RequestProxy($request) { $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } if ($proxyValue) { @@ -284,6 +225,15 @@ function Get-VerifiedFile([string]$Url, [string]$Destination, [string]$Expected) } throw 'All download sources failed. Retry -Network official or -Network china; inspect your proxy/CA settings.' } +function Test-Node { + $node = Get-Command node.exe -ErrorAction SilentlyContinue + $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue + if (-not $node -or -not $npm) { return $false } + try { $versionText=(& $node.Source --version 2>$null | Out-String).Trim() } catch { return $false } + if ($LASTEXITCODE -ne 0 -or $versionText -notmatch '^v([0-9]+)\.([0-9]+)\.[0-9]+') { return $false } + $major=[int]$Matches[1];$minor=[int]$Matches[2] + return (($major -eq 22 -and $minor -ge 19) -or $major -ge 24) +} function Install-Node { $script:Phase = 'Node.js runtime' if (Test-Node) { $script:NodeBin = Split-Path (Get-Command node.exe).Source; return } @@ -322,27 +272,6 @@ function Invoke-WithRegistryRetry([string]$Command,[string[]]$Arguments) { Invoke-Native $Command $Arguments } } -function Install-Pnpm { - $script:Phase='DSH package manager';$destination=Join-Path $Root "tools\pnpm\$PnpmVersion" - if ((Test-Path -LiteralPath (Join-Path $destination 'pnpm.cmd')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:PnpmBin=$destination } - elseif ($NoBootstrap) { - $pm=Get-Command pnpm.cmd -ErrorAction SilentlyContinue - if (!$pm) { throw 'pnpm is missing; rerun without -NoBootstrap.' } - Invoke-Native $pm.Source @('--version');$script:PnpmBin=Split-Path $pm.Source - } else { - $work=Join-Path $script:Stage 'pnpm';New-Item -ItemType Directory -Path $work | Out-Null - Invoke-WithRegistryRetry (Get-Command npm.cmd).Source @('install','--global','--prefix',$work,'--ignore-scripts','--no-audit','--no-fund',"pnpm@$PnpmVersion") - Invoke-Native (Join-Path $work 'pnpm.cmd') @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value $PnpmVersion - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw 'Unowned pnpm installation directory.' } - $destination+='-reinstall-'+[Guid]::NewGuid().ToString('N') - } - Move-Item -LiteralPath $work -Destination $destination;$script:PnpmBin=$destination - } - $env:PATH="$script:PnpmBin;$env:PATH" -} function Install-Client([string]$Package,[string]$Version,[string]$Entry) { $script:Phase="$Target client"; $destination=Join-Path $Root "apps\$Target\$Version" if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } @@ -368,32 +297,51 @@ function Install-Client([string]$Package,[string]$Version,[string]$Entry) { } Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination "$Entry.cmd" } -function Install-Lmm { - $script:Phase='LMM CLI'; $destination=Join-Path $Root "apps\lmm\$LmmVersion-$Platform" - if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination 'lmm.exe')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:Client=Join-Path $destination 'lmm.exe'; return } - $work=Join-Path $script:Stage 'lmm' - if ($FromSource) { - $cargo=Get-Command cargo.exe -ErrorAction SilentlyContinue - if (-not $cargo) { throw 'Source install needs Rust 1.88+ and Visual Studio C++ Build Tools. Install those, then retry -FromSource.' } - $cargoRoot=Join-Path $script:Stage 'cargo' - Invoke-Native $cargo.Source @('install','lmm-cli','--version',$LmmVersion,'--locked','--root',$cargoRoot) - New-Item -ItemType Directory -Path $work | Out-Null - Copy-Item -LiteralPath (Join-Path $cargoRoot 'bin\lmm.exe') -Destination $work - } else { - $hash=$LmmHashes[$Platform] - if (-not $hash) { throw "No prebuilt LMM CLI for $Platform yet. Use -FromSource with Rust 1.88+ and build tools." } - $name="lmm-v$LmmVersion-$Platform.zip"; $archive=Join-Path $script:Cache $name - Get-VerifiedFile "$LmmReleaseBase/$name" $archive $hash - Expand-Archive -LiteralPath $archive -DestinationPath $work - } - Invoke-Native (Join-Path $work 'lmm.exe') @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value $LmmVersion - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw "Refusing unowned directory: $destination" } - $destination += '-reinstall-' + [Guid]::NewGuid().ToString('N') +function Assert-PiShell { + # Follow Pi's shellPath -> Git Bash -> PATH lookup, without editing settings. + $agentDirectory=$env:PI_CODING_AGENT_DIR + if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } + $settingsPath=Join-Path $agentDirectory 'settings.json' + if (Test-Path -LiteralPath $settingsPath) { + try { $settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json } + catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } + if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } + $property=$settings.PSObject.Properties['shellPath'] + if ($property -and $property.Value) { + $shell=[string]$property.Value + if (Test-Path -LiteralPath $shell -PathType Leaf) { return } + if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } + throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." + } } - Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination 'lmm.exe' + if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') -PathType Leaf)) { return } + if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } + throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' +} +function Install-Tool { + Install-Node; Set-NpmNetwork + Install-Client '@earendil-works/pi-coding-agent' $PiVersion 'pi' + $script:Phase='Pi LMM provider' + Invoke-WithRegistryRetry $script:Client @('install',"npm:@tokennotincluded/pi-lmm-provider@$PiProviderVersion") +} + +$script:InstalledSuccess=$false +$script:Stage = $null; $script:LockHandle = $null; $script:Phase = 'arguments' +$Retries=3; $ConnectTimeout=10; $StallTimeout=20; $DownloadTimeout=600; $CommandTimeout=1800; $MinSpeed=16384 +$script:Cache=$null +$script:PnpmBin=$null +$script:Client = $null; $script:NodeBin = $null; $script:NpmSelected = $false +function Write-Log([string]$Message) { Write-Host "[lmm $Target] $Message" } +function Stop-Setup([string]$Message) { throw $Message } +function Show-Usage { + Write-Host @" +LMM $Target installer $ScriptVersion +Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] + [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] + [-FromSource] [-Launch] [-Help] +No automatic login or PATH changes. Pi on Windows requires Bash. +-FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. +"@ } function Write-Launcher { $destination=Join-Path $Root "bin\$Target.cmd" @@ -462,42 +410,7 @@ function Invoke-LmmSetup { } catch { throw 'Another LMM installer is running. Wait for it to finish.' } try { $script:Stage=Join-Path $Root ('.setup-'+[Guid]::NewGuid().ToString('N')); New-Item -ItemType Directory -Path $script:Stage | Out-Null - if ($Target -eq 'lmm') { Install-Lmm } - else { - Install-Node; Set-NpmNetwork - if ($Target -eq 'pi') { - Install-Client '@earendil-works/pi-coding-agent' $PiVersion 'pi' - $script:Phase='Pi LMM provider'; Invoke-WithRegistryRetry $script:Client @('install',"npm:@tokennotincluded/pi-lmm-provider@$PiProviderVersion") - } else { - Install-Pnpm - Install-Client '@deepseek-ai/dsh' $DshVersion 'dsh' - $script:Phase='DSH LMM provider'; $archive=Join-Path $script:Cache ($DshProviderUrl.Split('/')[-1]) - Get-VerifiedFile $DshProviderUrl $archive $DshProviderSha256 - # DSH 0.1.5 uses a shell to invoke pnpm on Windows. Passing absolute - # paths containing spaces loses argument boundaries in that layer. - # Keep the verified immutable package inside the profile and pass a - # path-free file: spec; configure the store through environment instead. - $profileHome=$env:DSH_HOME - $userDirectory=[Environment]::GetFolderPath('UserProfile') - if (!$profileHome) { $profileHome=Join-Path $userDirectory '.dsh' } - elseif ($profileHome -eq '~') { $profileHome=$userDirectory } - elseif ($profileHome.StartsWith('~/') -or $profileHome.StartsWith('~\')) { $profileHome=Join-Path $userDirectory $profileHome.Substring(2) } - if (![IO.Path]::IsPathRooted($profileHome)) { $profileHome=Join-Path (Get-Location).ProviderPath $profileHome } - $profileDirectory=Join-Path ([IO.Path]::GetFullPath($profileHome)) "profiles\$Profile" - New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null - $packageName='.lmm-provider-'+$DshProviderSha256.Substring(0,16)+'.tgz' - $profilePackage=Join-Path $profileDirectory $packageName - if (Test-Path -LiteralPath $profilePackage) { - if ((Get-Hash $profilePackage) -ne $DshProviderSha256) { throw 'Conflicting installer package in the DSH profile; inspect it before retrying.' } - } else { - $pending=Join-Path $profileDirectory ('.lmm-package-'+[Guid]::NewGuid().ToString('N')+'.tmp') - try { Copy-Item -LiteralPath $archive -Destination $pending; Move-Item -LiteralPath $pending -Destination $profilePackage -Force } - finally { if (Test-Path -LiteralPath $pending) { Remove-Item -LiteralPath $pending -Force } } - } - $env:npm_config_store_dir=Join-Path $script:Cache 'pnpm' - Invoke-WithRegistryRetry $script:Client @('plugin','--profile',$Profile,'add',"file:$packageName",'--ignore-scripts') - } - } + Install-Tool $script:Phase='launcher and PATH'; Write-Launcher; $script:InstalledSuccess=$true Write-Log "Ready: $(Join-Path $Root "bin\$Target.cmd")" Write-Log 'Use the full path above. With -AddPath, new terminals can use the short command.' diff --git a/pi.sh b/pi.sh index 9b3d037..ee84b15 100755 --- a/pi.sh +++ b/pi.sh @@ -1,19 +1,13 @@ #!/usr/bin/env bash lmm_install_main() { -# Generated from templates/install.sh.in and versions.json. No sudo, no API keys. +# Generated from templates/ and versions.json. Edit the source, not this file. set -euo pipefail set +x TARGET=pi -SCRIPT_VERSION=2026.09.20.1 +SCRIPT_VERSION=2026.09.20.2 NODE_VERSION=24.21.0 PI_VERSION=0.85.1 PI_PROVIDER_VERSION=0.1.0-alpha.1 -PNPM_VERSION=11.7.0 -DSH_VERSION=0.1.5-rc.2 -DSH_PROVIDER_URL=https://github.com/TokenNotIncluded/dsh-lmm-provider/releases/download/v0.1.0-alpha.2/tokennotincluded-dsh-lmm-provider-0.1.0-alpha.2.tgz -DSH_PROVIDER_SHA256=609eba9f1516cadf7086e44d290752d1361ac607eb1d1cb5682abfa5e806304d -LMM_VERSION=0.1.0 -LMM_RELEASE_BASE=https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0 node_hash() { case "$1" in linux-x64) printf '%s\n' 6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff;; linux-arm64) printf '%s\n' 724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5;; @@ -23,144 +17,42 @@ node_hash() { case "$1" in win-arm64) printf '%s\n' 8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921;; *) printf '\n';; esac; } -lmm_hash() { case "$1" in - linux-x64) printf '%s\n' 292a1ff8b599466f52747867a0b14bd14860faefaa085cc60746040cd7eba9b7;; - darwin-arm64) printf '%s\n' 8f6b3a2d08500566b528b7089664420d3395e464c172e3a43dfcb73d37f57b3f;; - win-x64) printf '%s\n' d0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9;; - *) printf '\n';; -esac; } -ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} -NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 INSTALL_NODE=1 -STAGE='' LOCKED=0 PHASE=arguments BOOTSTRAP=1 -RUN_ARGS=() -NPM_SELECTED=0 -PNPM_BIN='' -log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } -fail() { log "ERROR: $*"; exit 1; } -usage() { - cat </dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' - elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' - elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' - else fail 'Install a SHA-256 tool (sha256sum, shasum or openssl)'; fi + local digest + if command -v sha256sum >/dev/null 2>&1; then digest=$(sha256sum "$1") || return; printf '%s\n' "${digest%% *}" + elif command -v shasum >/dev/null 2>&1; then digest=$(shasum -a 256 "$1") || return; printf '%s\n' "${digest%% *}" + elif command -v openssl >/dev/null 2>&1; then digest=$(openssl dgst -sha256 "$1") || return; printf '%s\n' "${digest##* }" + else printf 'Install a SHA-256 tool.\n' >&2; return 1; fi } -compatible_node() { - command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && - node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit((a===22&&b>=19)||a>=24?0:1)' >/dev/null 2>&1 +# Native Termux uses Android/bionic, not desktop Linux/glibc. +lmm_is_termux() { + [ -n "${TERMUX_VERSION:-}${TERMUX_APP__PACKAGE_NAME:-}" ] || + case "${PREFIX:-}" in */com.termux/files/usr) true;; *) false;; esac } -# --check never creates directories, downloads, edits PATH or touches credentials. -if [ "$CHECK" = 1 ]; then - log "Platform: $PLATFORM; install root: $ROOT" - if [ -x "$ROOT/bin/$TARGET" ]; then "$ROOT/bin/$TARGET" --version - elif command -v "$TARGET" >/dev/null 2>&1; then "$TARGET" --version - else log "$TARGET is not installed in this root or PATH"; exit 1; fi - if [ "$TARGET" != lmm ]; then - if compatible_node; then log 'Current PATH has compatible Node/npm.' - elif [ -x "$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" ]; then log 'Managed Node runtime is available.' - else fail 'No compatible Node runtime found'; fi - fi - log 'Executable check complete; login and model access are not inferred.' - exit 0 -fi -release_setup() { - if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then rm -rf -- "$STAGE"; fi - STAGE='' - if [ "$LOCKED" = 1 ] && [ "$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true)" = "$$" ]; then - rm -f -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" - rmdir "$ROOT/.setup-lock" 2>/dev/null || true - fi - LOCKED=0 +lmm_temp_root() { + if [ -n "${TMPDIR:-}" ]; then printf '%s\n' "$TMPDIR" + elif lmm_is_termux; then printf '%s/tmp\n' "${PREFIX:-$HOME/.cache/lmm-tools}" + else printf '/tmp\n'; fi } -cleanup() { - rc=$? - trap - EXIT - release_setup - if [ "$rc" -ne 0 ]; then - log "Stopped during $PHASE. Existing launchers were preserved unless installation already completed." - log 'Check disk space, HTTPS proxy/CA settings, or retry --network official / --network china. Do not disable TLS validation.' - fi - exit "$rc" +lmm_check_storage() { + lmm_is_termux || return 0 + local resolved + # realpath -m also resolves missing paths and symlinked storage aliases. + command -v realpath >/dev/null 2>&1 || { + printf 'Termux needs coreutils: pkg install coreutils\n' >&2; return 1; + } + resolved=$(realpath -m -- "$1") || return 1 + case "$resolved/" in + /sdcard/*|/storage/*|/mnt/sdcard/*|/mnt/media_rw/*|/mnt/runtime/*|/mnt/user/*|/mnt/pass_through/*) + printf 'Use Termux private storage under HOME, not shared storage: %s\n' "$1" >&2 + return 1;; + esac } -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 143' TERM -umask 077 -mkdir -p "$ROOT" "$CACHE" "$ROOT/bin" "$ROOT/apps" "$ROOT/runtime" -ROOT=$(cd "$ROOT" && pwd -P) -if ! mkdir "$ROOT/.setup-lock" 2>/dev/null; then - oldpid=$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true) - case "$oldpid" in ''|*[!0-9]*) fail "Unknown lock at $ROOT/.setup-lock; inspect it before removing";; esac - if kill -0 "$oldpid" 2>/dev/null; then fail "Another installer is running (PID $oldpid)"; fi - [ "$(cat "$ROOT/.setup-lock/owner" 2>/dev/null || true)" = lmm-installer-v1 ] || fail 'Unknown lock owner' - rm -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" - rmdir "$ROOT/.setup-lock" || fail 'Lock contains unexpected files; left it untouched' - mkdir "$ROOT/.setup-lock" -fi -printf '%s\n' "$$" > "$ROOT/.setup-lock/pid" -printf '%s\n' lmm-installer-v1 > "$ROOT/.setup-lock/owner" -LOCKED=1 -STAGE=$(mktemp -d "$ROOT/.setup.XXXXXX") -# Probe only public, credential-free artifact URLs. Slow transfers are still -# interrupted independently of the latency ranking below. +quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } rank_urls() { local i=0 url response code elapsed probe_dir if ! command -v curl >/dev/null 2>&1; then for url in "$@"; do printf '%s\n' "$i"; i=$((i+1)); done; return; fi @@ -176,7 +68,7 @@ rank_urls() { i=$((i+1)) done wait - cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | awk '{print $2}' + cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | while read -r elapsed index; do printf '%s\n' "$index"; done } urls_for() { URLS=("$1") @@ -220,13 +112,14 @@ download() { done fail "Download failed: ${destination##*/}. Rerun to resume, or choose another --network mode." } +compatible_node() { + command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && + node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(((a===22&&b>=19)||a>=24)&&(process.argv[1]!=="android"||process.platform==="android")?0:1)' "$OS" >/dev/null 2>&1 +} ensure_node() { PHASE='Node.js runtime' if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi - # Android uses bionic, not the glibc used by the Linux Node archives. - if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then - fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' - fi + [ "$OS" != android ] || fail 'In Termux, run pkg install nodejs npm git; desktop Node archives are incompatible.' local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi if compatible_node; then NODE_BIN="$dir/bin"; return; fi @@ -292,27 +185,6 @@ with_registry_retry() { bounded "$@" else fail 'Package installation failed. Check network/proxy settings or try another --network mode.'; fi } -ensure_pnpm() { - PHASE='DSH package manager' - local directory="$ROOT/tools/pnpm/$PNPM_VERSION" work="$STAGE/pnpm" - if [ -x "$directory/bin/pnpm" ] && [ -f "$directory/.lmm-managed" ]; then PNPM_BIN="$directory/bin" - elif [ "$BOOTSTRAP" = 0 ]; then - command -v pnpm >/dev/null 2>&1 || fail 'pnpm is missing; rerun without --no-bootstrap.' - bounded pnpm --version - PNPM_BIN=$(dirname "$(command -v pnpm)") - else - mkdir -p "$work" "$(dirname "$directory")" - with_registry_retry npm install --global --prefix "$work" --ignore-scripts --no-audit --no-fund "pnpm@$PNPM_VERSION" - bounded "$work/bin/pnpm" --version - printf '%s\n' "$PNPM_VERSION" > "$work/.lmm-managed" - if [ -e "$directory" ]; then - [ -f "$directory/.lmm-managed" ] || fail "Unowned package-manager directory: $directory" - directory="$directory-reinstall-$(date +%s)-$$" - fi - mv -- "$work" "$directory"; PNPM_BIN="$directory/bin" - fi - export PATH="$PNPM_BIN:$PATH" -} install_client() { local package=$1 version=$2 entry=$3 target="$ROOT/apps/$TARGET/$2" work="$STAGE/client" allow PHASE="$TARGET client" @@ -329,7 +201,7 @@ install_client() { [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' fi with_registry_retry npm "${INSTALL_ARGS[@]}" - "$work/bin/$entry" --version >/dev/null + node "$work/bin/$entry" --version >/dev/null printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" mkdir -p "$(dirname "$target")" if [ -e "$target" ]; then @@ -339,44 +211,165 @@ install_client() { mv -- "$work" "$target" CLIENT="$target/bin/$entry" } -install_lmm() { - PHASE='LMM CLI' - local hash target="$ROOT/apps/lmm/$LMM_VERSION-$PLATFORM" archive - if [ "$FORCE" = 0 ] && [ -x "$target/lmm" ] && [ -f "$target/.lmm-managed" ]; then CLIENT="$target/lmm"; return; fi - mkdir -p "$STAGE/lmm" - if [ "$SOURCE" = 1 ]; then - command -v cargo >/dev/null 2>&1 || fail 'Source builds need Rust 1.88+ and OS build tools. Install them first, then rerun --from-source.' - log 'Building the pinned crate; Cargo reuses its existing dependency/build caches. This can take several minutes.' - export CARGO_HTTP_TIMEOUT="$STALL_TIMEOUT" CARGO_NET_RETRY="$RETRIES" - export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$CACHE/cargo-target} - cargo install lmm-cli --version "$LMM_VERSION" --locked --root "$STAGE/cargo" &2; } +fail() { log "ERROR: $*"; exit 1; } +usage() { + cat </dev/null || fail 'CLI cannot run here. Linux x64 preview binaries need glibc 2.39+; use --from-source on older Linux.' - printf '%s\n' "$LMM_VERSION" > "$STAGE/lmm/.lmm-managed" - mkdir -p "$(dirname "$target")" - if [ -e "$target" ]; then [ -f "$target/.lmm-managed" ] || fail "Unowned path: $target"; target="$target-reinstall-$(date +%s)-$$"; fi - mv -- "$STAGE/lmm" "$target"; CLIENT="$target/lmm" +done +case "$NETWORK" in auto|official|china) ;; *) fail 'network must be auto, official or china';; esac +case "$PROFILE" in web|headless) ;; *) fail 'profile must be web or headless';; esac +[ "$TARGET" = lmm ] || [ "$SOURCE" = 0 ] || fail '--from-source is only for lmm' +case "$ROOT" in *$'\n'*|*$'\r'*) fail 'Install path must not contain newlines';; /*) ;; *) ROOT="$PWD/$ROOT";; esac +if [ "$ROOT" = / ] || [ "$ROOT" = "$HOME" ]; then fail 'Choose a dedicated installation directory'; fi +case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac +if lmm_is_termux; then OS=android; fi +case "$(uname -m)" in + x86_64|amd64) ARCH=x64;; arm64|aarch64) ARCH=arm64;; + armv7l|armv8l|arm) [ "$OS" = android ] || fail '32-bit desktop Linux is not supported'; ARCH=arm;; + i386|i686) [ "$OS" = android ] || fail '32-bit desktop Linux is not supported'; ARCH=ia32;; + *) fail 'Unsupported CPU';; +esac +PLATFORM="$OS-$ARCH" +lmm_check_storage "$ROOT" || exit 1 +lmm_check_storage "$CACHE" || exit 1 +if [ "$OS" = android ] && [ "$TARGET" != lmm ]; then + compatible_node || fail 'In Termux, install native Node/npm: pkg install nodejs npm git; then rerun. Desktop Node cannot run on Android.' + command -v git >/dev/null 2>&1 || fail 'Pi/DSH need git: pkg install git' +fi +# --check never creates directories, downloads, edits PATH or touches credentials. +if [ "$CHECK" = 1 ]; then + log "Platform: $PLATFORM; install root: $ROOT" + if [ -x "$ROOT/bin/$TARGET" ]; then "$ROOT/bin/$TARGET" --version + elif command -v "$TARGET" >/dev/null 2>&1; then "$TARGET" --version + else log "$TARGET is not installed in this root or PATH"; exit 1; fi + if [ "$TARGET" != lmm ]; then + if compatible_node; then log 'Current PATH has compatible Node/npm.' + elif [ -x "$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" ]; then log 'Managed Node runtime is available.' + else fail 'No compatible Node runtime found'; fi + fi + + log 'Executable check complete; login and model access are not inferred.' + exit 0 +fi +release_setup() { + if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then rm -rf -- "$STAGE"; fi + STAGE='' + if [ "$LOCKED" = 1 ] && [ "$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true)" = "$$" ]; then + rm -f -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" + rmdir "$ROOT/.setup-lock" 2>/dev/null || true + fi + LOCKED=0 } -quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } +cleanup() { + rc=$? + trap - EXIT + release_setup + if [ "$rc" -ne 0 ]; then + log "Stopped during $PHASE. Existing launchers were preserved unless installation already completed." + log 'Check disk space, HTTPS proxy/CA settings, or retry --network official / --network china. Do not disable TLS validation.' + fi + exit "$rc" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +umask 077 +if [ "$OS" = android ]; then + TMPDIR=$(lmm_temp_root); lmm_check_storage "$TMPDIR" || exit 1 + mkdir -p "$TMPDIR"; export TMPDIR + if [ "$TARGET" = dsh ]; then log 'DSH native dependencies have not been validated on Android.'; fi +fi +mkdir -p "$ROOT" "$CACHE" "$ROOT/bin" "$ROOT/apps" "$ROOT/runtime" +ROOT=$(cd "$ROOT" && pwd -P) +if ! mkdir "$ROOT/.setup-lock" 2>/dev/null; then + oldpid=$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true) + case "$oldpid" in ''|*[!0-9]*) fail "Unknown lock at $ROOT/.setup-lock; inspect it before removing";; esac + if kill -0 "$oldpid" 2>/dev/null; then fail "Another installer is running (PID $oldpid)"; fi + [ "$(cat "$ROOT/.setup-lock/owner" 2>/dev/null || true)" = lmm-installer-v1 ] || fail 'Unknown lock owner' + rm -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" + rmdir "$ROOT/.setup-lock" || fail 'Lock contains unexpected files; left it untouched' + mkdir "$ROOT/.setup-lock" +fi +printf '%s\n' "$$" > "$ROOT/.setup-lock/pid" +printf '%s\n' lmm-installer-v1 > "$ROOT/.setup-lock/owner" +LOCKED=1 +STAGE=$(mktemp -d "$ROOT/.setup.XXXXXX") +# Probe only public, credential-free artifact URLs. Slow transfers are still +# interrupted independently of the latency ranking below. write_launcher() { local launcher="$ROOT/bin/$TARGET" temp="$STAGE/launcher" { - printf '#!/usr/bin/env bash\n# Managed by LMM installers.\n' + if [ "$OS" = android ]; then printf '#!%s\n' "$BASH" + else printf '#!/usr/bin/env bash\n'; fi + printf '# Managed by LMM installers.\n' # The launcher must expand PATH when it runs, not while it is generated. # shellcheck disable=SC2016 if [ "$TARGET" != lmm ]; then printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$NODE_BIN${PNPM_BIN:+:$PNPM_BIN}")"; fi - printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" + if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" + else printf 'exec %s %s "$@"\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi } > "$temp" chmod +x "$temp" if [ -e "$launcher" ] && ! grep -q '# Managed by LMM installers.' "$launcher"; then fail "Refusing to overwrite your existing launcher: $launcher"; fi @@ -398,21 +391,7 @@ add_path() { printf '\n%s\n%s\n# <<< LMM tools PATH <<<\n' "$marker" "$line" >> "$file" done } -if [ "$TARGET" = lmm ]; then install_lmm -else - ensure_node; configure_npm - if [ "$TARGET" = pi ]; then - install_client @earendil-works/pi-coding-agent "$PI_VERSION" pi - PHASE='Pi LMM provider'; with_registry_retry "$CLIENT" install "npm:@tokennotincluded/pi-lmm-provider@$PI_PROVIDER_VERSION" - else - ensure_pnpm - install_client @deepseek-ai/dsh "$DSH_VERSION" dsh - PHASE='DSH LMM provider' - artifact="$CACHE/${DSH_PROVIDER_URL##*/}" - download "$DSH_PROVIDER_URL" "$artifact" "$DSH_PROVIDER_SHA256" - with_registry_retry "$CLIENT" plugin --profile "$PROFILE" add "$artifact" --ignore-scripts --store-dir "$CACHE/pnpm" - fi -fi +install_tool PHASE='launchers and PATH'; write_launcher; add_path log "Ready: $ROOT/bin/$TARGET" log "Use the full command above, or for this terminal: export PATH=$(quote_sh "$ROOT/bin"):\"\$PATH\"" diff --git a/templates/install.ps1.in b/templates/install.ps1.in index 2ed8612..93ba154 100644 --- a/templates/install.ps1.in +++ b/templates/install.ps1.in @@ -12,6 +12,7 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@CONSTANTS@@ +@@LIBRARIES@@ $script:InstalledSuccess=$false $script:Stage = $null; $script:LockHandle = $null; $script:Phase = 'arguments' $Retries=3; $ConnectTimeout=10; $StallTimeout=20; $DownloadTimeout=600; $CommandTimeout=1800; $MinSpeed=16384 @@ -30,347 +31,6 @@ No automatic login or PATH changes. Pi on Windows requires Bash. -FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. "@ } -function Setting([string]$Name, [int]$Default, [int]$Maximum) { - $raw = [Environment]::GetEnvironmentVariable($Name) - if ([string]::IsNullOrEmpty($raw)) { return $Default } - $number = 0 - if ($raw -notmatch '^[1-9][0-9]*$' -or -not [int]::TryParse($raw, [ref]$number) -or $number -gt $Maximum) { throw "$Name must be between 1 and $Maximum." } - return $number -} -function QuoteArgument([string]$Value) { - if($Value -notmatch '[\s"]' -and $Value.Length){return $Value} - return '"' + [regex]::Replace([regex]::Replace($Value,'(\\*)"','$1$1\"'),'(\\+)$','$1$1') + '"' -} -function Stop-InstallChild($Process) { - if($Process.HasExited){return} - $killer=$null - if($env:SystemRoot){$killer=Join-Path $env:SystemRoot 'System32\taskkill.exe'} - if($killer -and (Test-Path -LiteralPath $killer)){ & $killer /PID $Process.Id /T /F 2>$null | Out-Null } - if(-not $Process.HasExited){try{$Process.Kill($true)}catch{$Process.Kill()}} - [void]$Process.WaitForExit(10000) -} -function Invoke-Bounded([string]$Executable,[string[]]$Arguments) { - $info=New-Object Diagnostics.ProcessStartInfo - $info.FileName=$Executable; $info.UseShellExecute=$false - if ((Get-Location).Provider.Name -eq 'FileSystem') { $info.WorkingDirectory=(Get-Location).ProviderPath } - $info.Arguments=($Arguments | ForEach-Object { QuoteArgument $_ }) -join ' ' - $process=New-Object Diagnostics.Process; $process.StartInfo=$info - $started=$false - try { - $started=$process.Start() - if(-not $started){throw 'Could not start the installation process.'} - $watch=[Diagnostics.Stopwatch]::StartNew(); $last=0 - while(-not $process.WaitForExit(1000)) { - if($watch.Elapsed.TotalSeconds -gt $CommandTimeout){ - Stop-InstallChild $process - throw 'Operation timed out. Increase LMM_COMMAND_TIMEOUT for slow networks and rerun.' - } - if($watch.Elapsed.TotalSeconds-$last -ge 15){Write-Log ('Still working: {0:N0}s elapsed.' -f $watch.Elapsed.TotalSeconds);$last=$watch.Elapsed.TotalSeconds} - } - $global:LASTEXITCODE=$process.ExitCode - if($process.ExitCode -ne 0){throw "Installation command failed with exit code $($process.ExitCode)."} - } finally { - if($started -and -not $process.HasExited){Stop-InstallChild $process} - $process.Dispose() - } -} -function Invoke-Native([string]$Command, [string[]]$Arguments) { - if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { - if ((Test-Path -LiteralPath $Command) -and (Select-String -LiteralPath $Command -SimpleMatch 'Managed by LMM installers' -Quiet)) { - $launcherLines=@(Get-Content -LiteralPath $Command) - foreach ($pathLine in $launcherLines) { - if ($pathLine -match '^set "PATH=%~dp0\.\.\\(.+);%PATH%"$') { - $runtime=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) - if (!$runtime.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed runtime escaped its root.' } - if (!(Test-Path -LiteralPath $runtime -PathType Container)) { throw 'Managed runtime is missing. Rerun the installer.' } - $env:PATH="$runtime;$env:PATH" - } - } - $line=$launcherLines[-1] - if ($line -match '^"%~dp0\.\.\\(.+)" %\*$') { - $resolved=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) - if (!$resolved.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed launcher target escaped its root.' } - $Command=$resolved - } - } - if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { - $parent=Split-Path $Command - $binName=[IO.Path]::GetFileNameWithoutExtension($Command).ToLowerInvariant() - switch ($binName) { - 'npm' { $packageName='npm' } - 'pi' { $packageName='@earendil-works/pi-coding-agent' } - 'pnpm' { $packageName='pnpm' } - 'dsh' { $packageName='@deepseek-ai/dsh' } - default { throw 'Unsupported command shim; use the managed installer or a native executable.' } - } - $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) - $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json - $binProperty=$manifest.PSObject.Properties['bin'] - if (!$binProperty) { throw 'Package manifest has no bin entry.' } - $bins=$binProperty.Value - $relative=$null - if ($bins -is [string]) { $relative=$bins } - elseif ($null -ne $bins -and $bins.PSObject.Properties[$binName]) { $relative=$bins.PSObject.Properties[$binName].Value } - if ($relative -isnot [string] -or !$relative -or [IO.Path]::IsPathRooted($relative)) { throw 'Invalid package bin entry.' } - $entry=[IO.Path]::GetFullPath((Join-Path $packageRoot $relative)) - if (!$entry.StartsWith($packageRoot + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Package bin entry escaped its package.' } - if (!(Test-Path -LiteralPath $entry)) { throw 'Client entry is missing. Rerun with -Update.' } - $Command=(Get-Command node.exe).Source - $Arguments=@($entry)+$Arguments - } - } - Invoke-Bounded $Command $Arguments -} -function Get-Hash([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } -function Test-Node { - $node = Get-Command node.exe -ErrorAction SilentlyContinue - $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue - if (-not $node -or -not $npm) { return $false } - try { $versionText=(& $node.Source --version 2>$null | Out-String).Trim() } catch { return $false } - if ($LASTEXITCODE -ne 0 -or $versionText -notmatch '^v([0-9]+)\.([0-9]+)\.[0-9]+') { return $false } - $major=[int]$Matches[1];$minor=[int]$Matches[2] - return (($major -eq 22 -and $minor -ge 19) -or $major -ge 24) -} -function Assert-PiShell { - # Follow Pi's shellPath -> Git Bash -> PATH lookup, without editing settings. - $agentDirectory=$env:PI_CODING_AGENT_DIR - if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } - $settingsPath=Join-Path $agentDirectory 'settings.json' - if (Test-Path -LiteralPath $settingsPath) { - try { $settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json } - catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } - if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } - $property=$settings.PSObject.Properties['shellPath'] - if ($property -and $property.Value) { - $shell=[string]$property.Value - if (Test-Path -LiteralPath $shell -PathType Leaf) { return } - if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } - throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." - } - } - if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') -PathType Leaf)) { return } - if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } - throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' -} -function Set-RequestProxy($request) { - $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } - if ($proxyValue) { - $proxyUri = [Uri]$proxyValue - if ($proxyUri.Scheme -notin @('http','https')) { throw 'Use an HTTP(S) proxy with Windows PowerShell; SOCKS needs a local HTTP proxy adapter.' } - $proxy = New-Object Net.WebProxy($proxyUri.GetLeftPart([UriPartial]::Authority)) - if ($proxyUri.UserInfo) { - $parts=$proxyUri.UserInfo.Split(':',2) - $password=if ($parts.Length -eq 2) { [Uri]::UnescapeDataString($parts[1]) } else { '' } - $proxy.Credentials=New-Object Net.NetworkCredential([Uri]::UnescapeDataString($parts[0]),$password) - } - if ($env:NO_PROXY) { - $proxy.BypassList=@($env:NO_PROXY.Split(',') | ForEach-Object { $hostName=$_.Trim().TrimStart('.'); if($hostName -eq '*') { '.*' } elseif($hostName) { '^https?://([^/]+\.)?' + [regex]::Escape($hostName) + '(:[0-9]+)?(/|$)' } }) - } - $request.Proxy=$proxy - } -} -function New-DownloadRequest([Uri]$Uri) { return [Net.HttpWebRequest]::Create($Uri) } -function Get-RankedUrls([string[]]$Urls) { - $scores = @(); $index = 0 - foreach ($url in $Urls) { - $timer = [Diagnostics.Stopwatch]::StartNew(); $score = 999999 - try { - $request = New-DownloadRequest ([Uri]$url) - $request.Method = 'HEAD'; $request.Timeout = 4000; $request.AllowAutoRedirect = $true - Set-RequestProxy $request - $response = $request.GetResponse(); $response.Close(); $score = $timer.ElapsedMilliseconds - } catch { } finally { $timer.Stop() } - $scores += [pscustomobject]@{ Url=$url; Score=$score; Order=$index }; $index++ - } - return @($scores | Sort-Object Score,Order | ForEach-Object { $_.Url }) -} -function Get-DownloadUrls([string]$Official) { - $mirrors = @() - if ($Official.StartsWith('https://nodejs.org/dist/')) { $mirrors = @($Official.Replace('https://nodejs.org/dist/','https://npmmirror.com/mirrors/node/')) } - elseif ($Official.StartsWith('https://github.com/')) { $mirrors = @("https://ghfast.top/$Official", "https://ghproxy.net/$Official") } - if ($Network -eq 'official') { return @($Official) } - if ($Network -eq 'china') { return @($mirrors) + @($Official) } - return @(Get-RankedUrls (@($Official) + @($mirrors))) -} -function Receive-Stream([string]$Url, [string]$Path) { - # Used on older Windows without curl.exe. ReadWriteTimeout bounds stalled reads. - $offset = 0L - if (Test-Path -LiteralPath $Path) { $offset = (Get-Item -LiteralPath $Path).Length } - $request = New-DownloadRequest ([Uri]$Url) - $request.Timeout = $ConnectTimeout*1000; $request.ReadWriteTimeout = $StallTimeout*1000; $request.AllowAutoRedirect = $true - Set-RequestProxy $request - if ($offset -gt 0) { $request.AddRange($offset) } - $response = $null; $inputStream = $null; $outputStream = $null - try { - $response = $request.GetResponse() - if ($response.ResponseUri.Scheme -ne 'https') { throw 'Insecure redirect refused.' } - $mode = [IO.FileMode]::Create - if ($offset -gt 0 -and [int]$response.StatusCode -eq 206) { - if ($response.Headers['Content-Range'] -notlike "bytes $offset-*") { throw 'Invalid resume response.' } - $mode = [IO.FileMode]::Append - } - $inputStream = $response.GetResponseStream() - $outputStream = [IO.File]::Open($Path,$mode,[IO.FileAccess]::Write,[IO.FileShare]::None) - $buffer = New-Object byte[] 65536; $windowBytes = 0L; $total = 0L - $window = [Diagnostics.Stopwatch]::StartNew(); $overall = [Diagnostics.Stopwatch]::StartNew() - while (($read = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) { - $outputStream.Write($buffer,0,$read); $windowBytes += $read; $total += $read - if ($overall.Elapsed.TotalSeconds -gt $DownloadTimeout) { throw 'Download timeout.' } - if ($window.Elapsed.TotalSeconds -ge $StallTimeout -and ($response.ContentLength -lt 0 -or $total -lt $response.ContentLength)) { - if ($windowBytes / $window.Elapsed.TotalSeconds -lt $MinSpeed) { throw 'Download too slow; changing source.' } - $window.Restart(); $windowBytes = 0 - } - } - } finally { - if ($outputStream) { $outputStream.Dispose() }; if ($inputStream) { $inputStream.Dispose() }; if ($response) { $response.Close() } - } -} -function Get-VerifiedFile([string]$Url, [string]$Destination, [string]$Expected) { - $parsed=[Uri]$Url - if ($parsed.Scheme -ne 'https' -or $parsed.UserInfo) { throw 'Download URLs must use HTTPS without credentials.' } - foreach($file in @($Destination,"$Destination.part","$Destination.part.url")) { if ((Test-Path -LiteralPath $file) -and ((Get-Item -LiteralPath $file).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Refusing symlink cache entries.' } } - if (-not $Update -and (Test-Path -LiteralPath $Destination) -and (Get-Hash $Destination) -eq $Expected) { Write-Log "Cached: $([IO.Path]::GetFileName($Destination))"; return } - $partial = "$Destination.part"; $sourceFile = "$partial.url" - if ((Test-Path -LiteralPath $partial) -and (Get-Hash $partial) -eq $Expected) { Move-Item -LiteralPath $partial -Destination $Destination -Force; return } - if ($env:LMM_NODE_BASE_URL -and $Url.StartsWith('https://nodejs.org/dist/')) { $Url=$Url.Replace('https://nodejs.org/dist',$env:LMM_NODE_BASE_URL.TrimEnd('/')) } - $sourceNumber = 0 - foreach ($source in @(Get-DownloadUrls $Url)) { - $sourceNumber++ - if ((Test-Path -LiteralPath $partial) -and (!(Test-Path -LiteralPath $sourceFile) -or (Get-Content -LiteralPath $sourceFile -Raw).Trim() -ne $source)) { Remove-Item -LiteralPath $partial -Force } - Set-Content -LiteralPath $sourceFile -Value $source -Encoding ASCII - foreach ($attempt in 1..$Retries) { - Write-Log "Downloading $([IO.Path]::GetFileName($Destination)) (source $sourceNumber, attempt $attempt)" - try { - $curl = Get-Command curl.exe -ErrorAction SilentlyContinue - if ($curl) { - Invoke-Native $curl.Source @('-q','--proto','=https','--proto-redir','=https','-fL','--connect-timeout',([string]$ConnectTimeout),'--max-time',([string]$DownloadTimeout),'--speed-time',([string]$StallTimeout),'--speed-limit',([string]$MinSpeed),'--continue-at','-','--output',$partial,$source) - } else { Receive-Stream $source $partial } - if ((Get-Hash $partial) -ne $Expected) { Remove-Item -LiteralPath $partial -Force; Write-Log 'Checksum mismatch; download will not be executed.'; break } - Move-Item -LiteralPath $partial -Destination $Destination -Force - Remove-Item -LiteralPath $sourceFile -Force -ErrorAction SilentlyContinue - return - } catch { - Write-Log 'Transfer failed or stalled. Retrying, then trying another source.' - if ($attempt -eq 1 -and (Test-Path -LiteralPath $partial)) { - # Keep a partial for retry; a rejected Range gets a clean retry next source. - if ($curl -and $LASTEXITCODE -in @(22,33,36)) { Remove-Item -LiteralPath $partial -Force } - } - } - } - } - throw 'All download sources failed. Retry -Network official or -Network china; inspect your proxy/CA settings.' -} -function Install-Node { - $script:Phase = 'Node.js runtime' - if (Test-Node) { $script:NodeBin = Split-Path (Get-Command node.exe).Source; return } - $directory = Join-Path $Root "runtime\node-v$NodeVersion-$Platform" - if (Test-Path -LiteralPath (Join-Path $directory 'node.exe')) { $env:PATH = "$directory;$env:PATH" } - if (Test-Node) { $script:NodeBin = $directory; return } - if ($NoInstallNode -or $NoBootstrap) { throw 'Need Node 22.19+ (22.x) or Node 24+, including npm.' } - $hash = $NodeHashes[$Platform] - if (-not $hash) { throw "No verified Node archive for $Platform" } - $name = "node-v$NodeVersion-$Platform.zip"; $archive = Join-Path $script:Cache $name - Get-VerifiedFile "https://nodejs.org/dist/v$NodeVersion/$name" $archive $hash - $unpack = Join-Path $script:Stage 'runtime'; Expand-Archive -LiteralPath $archive -DestinationPath $unpack - $extracted = Join-Path $unpack "node-v$NodeVersion-$Platform" - Invoke-Native (Join-Path $extracted 'node.exe') @('--version') - if (Test-Path -LiteralPath $directory) { throw "Managed runtime is present but unusable; inspect $directory before replacing." } - Move-Item -LiteralPath $extracted -Destination $directory - $script:NodeBin = $directory; $env:PATH = "$directory;$env:PATH" -} -function Set-NpmNetwork { - if (-not $env:npm_config_cache) { $cache=(& npm.cmd config get cache 2>$null | Out-String).Trim(); if ($cache) { $env:npm_config_cache=$cache } else { $env:npm_config_cache=Join-Path $script:Cache 'npm' } } - $env:npm_config_fetch_retries=[string]$Retries; $env:npm_config_fetch_timeout=[string]($StallTimeout*1000); $env:npm_config_fetch_retry_mintimeout='2000'; $env:npm_config_fetch_retry_maxtimeout='30000'; $env:npm_config_strict_ssl='true'; $env:npm_config_prefer_offline='true' - if ($env:LMM_NPM_REGISTRY) { $env:npm_config_registry=$env:LMM_NPM_REGISTRY } - $current = (& npm.cmd config get registry 2>$null | Out-String).Trim() - if ($env:npm_config_registry -or ($current -and $current -ne 'https://registry.npmjs.org/')) { Write-Log 'Keeping your existing npm registry/proxy configuration.'; return } - $script:NpmSelected = $true - if ($Network -eq 'china') { $env:npm_config_registry='https://registry.npmmirror.com/' } - elseif ($Network -eq 'official') { $env:npm_config_registry='https://registry.npmjs.org/' } - else { $env:npm_config_registry = @(Get-RankedUrls @('https://registry.npmjs.org/','https://registry.npmmirror.com/'))[0] } - Write-Log 'Registry selection affects this process only, not your global npm configuration.' -} -function Invoke-WithRegistryRetry([string]$Command,[string[]]$Arguments) { - try { Invoke-Native $Command $Arguments } catch { - if ($Network -ne 'auto' -or -not $script:NpmSelected) { throw } - if ($env:npm_config_registry -eq 'https://registry.npmjs.org/') { $env:npm_config_registry='https://registry.npmmirror.com/' } else { $env:npm_config_registry='https://registry.npmjs.org/' } - Write-Log 'Retrying with the alternate registry and the same cache.' - Invoke-Native $Command $Arguments - } -} -function Install-Pnpm { - $script:Phase='DSH package manager';$destination=Join-Path $Root "tools\pnpm\$PnpmVersion" - if ((Test-Path -LiteralPath (Join-Path $destination 'pnpm.cmd')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:PnpmBin=$destination } - elseif ($NoBootstrap) { - $pm=Get-Command pnpm.cmd -ErrorAction SilentlyContinue - if (!$pm) { throw 'pnpm is missing; rerun without -NoBootstrap.' } - Invoke-Native $pm.Source @('--version');$script:PnpmBin=Split-Path $pm.Source - } else { - $work=Join-Path $script:Stage 'pnpm';New-Item -ItemType Directory -Path $work | Out-Null - Invoke-WithRegistryRetry (Get-Command npm.cmd).Source @('install','--global','--prefix',$work,'--ignore-scripts','--no-audit','--no-fund',"pnpm@$PnpmVersion") - Invoke-Native (Join-Path $work 'pnpm.cmd') @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value $PnpmVersion - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw 'Unowned pnpm installation directory.' } - $destination+='-reinstall-'+[Guid]::NewGuid().ToString('N') - } - Move-Item -LiteralPath $work -Destination $destination;$script:PnpmBin=$destination - } - $env:PATH="$script:PnpmBin;$env:PATH" -} -function Install-Client([string]$Package,[string]$Version,[string]$Entry) { - $script:Phase="$Target client"; $destination=Join-Path $Root "apps\$Target\$Version" - if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } - if ($NoBootstrap) { throw 'Managed client is missing. Rerun without -NoBootstrap.' } - $work=Join-Path $script:Stage 'client'; New-Item -ItemType Directory -Path $work | Out-Null - $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") - if ($Target -eq 'pi') { - # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. - $installArgs+=@('--ignore-scripts') - } else { - $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' - $npmHelp=(& npm.cmd install --help 2>$null | Out-String) - if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } - if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } - } - Invoke-WithRegistryRetry (Get-Command npm.cmd).Source $installArgs - Invoke-Native (Join-Path $work "$Entry.cmd") @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value "$Version|$ScriptVersion" - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw "Refusing unowned directory: $destination" } - $destination += '-reinstall-' + [Guid]::NewGuid().ToString('N') - } - Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination "$Entry.cmd" -} -function Install-Lmm { - $script:Phase='LMM CLI'; $destination=Join-Path $Root "apps\lmm\$LmmVersion-$Platform" - if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination 'lmm.exe')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:Client=Join-Path $destination 'lmm.exe'; return } - $work=Join-Path $script:Stage 'lmm' - if ($FromSource) { - $cargo=Get-Command cargo.exe -ErrorAction SilentlyContinue - if (-not $cargo) { throw 'Source install needs Rust 1.88+ and Visual Studio C++ Build Tools. Install those, then retry -FromSource.' } - $cargoRoot=Join-Path $script:Stage 'cargo' - Invoke-Native $cargo.Source @('install','lmm-cli','--version',$LmmVersion,'--locked','--root',$cargoRoot) - New-Item -ItemType Directory -Path $work | Out-Null - Copy-Item -LiteralPath (Join-Path $cargoRoot 'bin\lmm.exe') -Destination $work - } else { - $hash=$LmmHashes[$Platform] - if (-not $hash) { throw "No prebuilt LMM CLI for $Platform yet. Use -FromSource with Rust 1.88+ and build tools." } - $name="lmm-v$LmmVersion-$Platform.zip"; $archive=Join-Path $script:Cache $name - Get-VerifiedFile "$LmmReleaseBase/$name" $archive $hash - Expand-Archive -LiteralPath $archive -DestinationPath $work - } - Invoke-Native (Join-Path $work 'lmm.exe') @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value $LmmVersion - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw "Refusing unowned directory: $destination" } - $destination += '-reinstall-' + [Guid]::NewGuid().ToString('N') - } - Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination 'lmm.exe' -} function Write-Launcher { $destination=Join-Path $Root "bin\$Target.cmd" if ((Test-Path -LiteralPath $destination) -and !(Select-String -LiteralPath $destination -SimpleMatch 'Managed by LMM installers' -Quiet)) { throw "Refusing existing launcher: $destination" } @@ -438,42 +98,7 @@ function Invoke-LmmSetup { } catch { throw 'Another LMM installer is running. Wait for it to finish.' } try { $script:Stage=Join-Path $Root ('.setup-'+[Guid]::NewGuid().ToString('N')); New-Item -ItemType Directory -Path $script:Stage | Out-Null - if ($Target -eq 'lmm') { Install-Lmm } - else { - Install-Node; Set-NpmNetwork - if ($Target -eq 'pi') { - Install-Client '@earendil-works/pi-coding-agent' $PiVersion 'pi' - $script:Phase='Pi LMM provider'; Invoke-WithRegistryRetry $script:Client @('install',"npm:@tokennotincluded/pi-lmm-provider@$PiProviderVersion") - } else { - Install-Pnpm - Install-Client '@deepseek-ai/dsh' $DshVersion 'dsh' - $script:Phase='DSH LMM provider'; $archive=Join-Path $script:Cache ($DshProviderUrl.Split('/')[-1]) - Get-VerifiedFile $DshProviderUrl $archive $DshProviderSha256 - # DSH 0.1.5 uses a shell to invoke pnpm on Windows. Passing absolute - # paths containing spaces loses argument boundaries in that layer. - # Keep the verified immutable package inside the profile and pass a - # path-free file: spec; configure the store through environment instead. - $profileHome=$env:DSH_HOME - $userDirectory=[Environment]::GetFolderPath('UserProfile') - if (!$profileHome) { $profileHome=Join-Path $userDirectory '.dsh' } - elseif ($profileHome -eq '~') { $profileHome=$userDirectory } - elseif ($profileHome.StartsWith('~/') -or $profileHome.StartsWith('~\')) { $profileHome=Join-Path $userDirectory $profileHome.Substring(2) } - if (![IO.Path]::IsPathRooted($profileHome)) { $profileHome=Join-Path (Get-Location).ProviderPath $profileHome } - $profileDirectory=Join-Path ([IO.Path]::GetFullPath($profileHome)) "profiles\$Profile" - New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null - $packageName='.lmm-provider-'+$DshProviderSha256.Substring(0,16)+'.tgz' - $profilePackage=Join-Path $profileDirectory $packageName - if (Test-Path -LiteralPath $profilePackage) { - if ((Get-Hash $profilePackage) -ne $DshProviderSha256) { throw 'Conflicting installer package in the DSH profile; inspect it before retrying.' } - } else { - $pending=Join-Path $profileDirectory ('.lmm-package-'+[Guid]::NewGuid().ToString('N')+'.tmp') - try { Copy-Item -LiteralPath $archive -Destination $pending; Move-Item -LiteralPath $pending -Destination $profilePackage -Force } - finally { if (Test-Path -LiteralPath $pending) { Remove-Item -LiteralPath $pending -Force } } - } - $env:npm_config_store_dir=Join-Path $script:Cache 'pnpm' - Invoke-WithRegistryRetry $script:Client @('plugin','--profile',$Profile,'add',"file:$packageName",'--ignore-scripts') - } - } + Install-Tool $script:Phase='launcher and PATH'; Write-Launcher; $script:InstalledSuccess=$true Write-Log "Ready: $(Join-Path $Root "bin\$Target.cmd")" Write-Log 'Use the full path above. With -AddPath, new terminals can use the short command.' diff --git a/templates/install.sh.in b/templates/install.sh.in index b303f11..5a2dc0e 100644 --- a/templates/install.sh.in +++ b/templates/install.sh.in @@ -1,13 +1,14 @@ #!/usr/bin/env bash -# Generated from templates/install.sh.in and versions.json. No sudo, no API keys. +# Generated from templates/ and versions.json. Edit the source, not this file. set -euo pipefail set +x @@CONSTANTS@@ -ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} -NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 INSTALL_NODE=1 -STAGE='' LOCKED=0 PHASE=arguments BOOTSTRAP=1 +@@LIBRARIES@@ +ROOT=$(lmm_root) +NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 +STAGE='' LOCKED=0 PHASE=arguments RUN_ARGS=() -NPM_SELECTED=0 +@@CLIENT_STATE@@ PNPM_BIN='' log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } fail() { log "ERROR: $*"; exit 1; } @@ -35,7 +36,7 @@ while [ "$#" -gt 0 ]; do case "$1" in --help|-h) usage; exit 0;; --check) CHECK=1;; --update) FORCE=1;; --launch) LAUNCH=1;; - --add-path) ADD_PATH=1;; --no-path) ADD_PATH=0;; --install-only) LAUNCH=0;; --no-bootstrap) INSTALL_NODE=0; BOOTSTRAP=0;; --from-source) SOURCE=1;; --no-install-node) INSTALL_NODE=0;; + --add-path) ADD_PATH=1;; --no-path) ADD_PATH=0;; --install-only) LAUNCH=0;; --no-bootstrap) @@NO_BOOTSTRAP@@;; --from-source) SOURCE=1;; --no-install-node) @@NO_INSTALL_NODE@@;; --root|--network|--profile) if [ "$#" -lt 2 ] || [ -z "${2:-}" ]; then fail "$1 requires a value"; fi case "$1" in --root) ROOT=$2;; --network) NETWORK=$2;; --profile) PROFILE=$2;; esac; shift;; @@ -54,6 +55,7 @@ for setting in "$RETRIES" "$CONNECT_TIMEOUT" "$STALL_TIMEOUT" "$DOWNLOAD_TIMEOUT [[ $setting =~ ^[1-9][0-9]*$ && ${#setting} -le 8 ]] || fail 'Timeouts, retry counts and minimum speed must be positive integers.' done ((RETRIES <= 10 && CONNECT_TIMEOUT <= 300 && STALL_TIMEOUT <= 86400 && DOWNLOAD_TIMEOUT <= 86400 && COMMAND_TIMEOUT <= 86400 && MIN_SPEED <= 10485760)) || fail 'Network setting exceeds supported limits.' +case "$ROOT" in /*) ;; *) ROOT="$PWD/$ROOT";; esac CACHE=${LMM_CACHE_ROOT:-$ROOT/cache} case "$CACHE" in /*) ;; *) fail 'LMM_CACHE_ROOT must be an absolute path';; esac if [ "$CACHE" = / ] || [ -L "$ROOT" ] || [ -L "$CACHE" ]; then fail 'Refusing root or symlink installation/cache paths.'; fi @@ -67,30 +69,28 @@ case "$PROFILE" in web|headless) ;; *) fail 'profile must be web or headless';; [ "$TARGET" = lmm ] || [ "$SOURCE" = 0 ] || fail '--from-source is only for lmm' case "$ROOT" in *$'\n'*|*$'\r'*) fail 'Install path must not contain newlines';; /*) ;; *) ROOT="$PWD/$ROOT";; esac if [ "$ROOT" = / ] || [ "$ROOT" = "$HOME" ]; then fail 'Choose a dedicated installation directory'; fi -case "$(uname -s)" in Linux) OS=linux;; Darwin) OS=darwin;; *) fail 'This script supports Linux/macOS. On Windows use the .ps1 script.';; esac -case "$(uname -m)" in x86_64|amd64) ARCH=x64;; arm64|aarch64) ARCH=arm64;; *) fail 'Unsupported CPU; use the documented source build on this platform.';; esac +case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac +if lmm_is_termux; then OS=android; fi +case "$(uname -m)" in + x86_64|amd64) ARCH=x64;; arm64|aarch64) ARCH=arm64;; + armv7l|armv8l|arm) [ "$OS" = android ] || fail '32-bit desktop Linux is not supported'; ARCH=arm;; + i386|i686) [ "$OS" = android ] || fail '32-bit desktop Linux is not supported'; ARCH=ia32;; + *) fail 'Unsupported CPU';; +esac PLATFORM="$OS-$ARCH" -sha256() { - if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' - elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' - elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' - else fail 'Install a SHA-256 tool (sha256sum, shasum or openssl)'; fi -} -compatible_node() { - command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && - node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit((a===22&&b>=19)||a>=24?0:1)' >/dev/null 2>&1 -} +lmm_check_storage "$ROOT" || exit 1 +lmm_check_storage "$CACHE" || exit 1 +if [ "$OS" = android ] && [ "$TARGET" != lmm ]; then + compatible_node || fail 'In Termux, install native Node/npm: pkg install nodejs npm git; then rerun. Desktop Node cannot run on Android.' + command -v git >/dev/null 2>&1 || fail 'Pi/DSH need git: pkg install git' +fi # --check never creates directories, downloads, edits PATH or touches credentials. if [ "$CHECK" = 1 ]; then log "Platform: $PLATFORM; install root: $ROOT" if [ -x "$ROOT/bin/$TARGET" ]; then "$ROOT/bin/$TARGET" --version elif command -v "$TARGET" >/dev/null 2>&1; then "$TARGET" --version else log "$TARGET is not installed in this root or PATH"; exit 1; fi - if [ "$TARGET" != lmm ]; then - if compatible_node; then log 'Current PATH has compatible Node/npm.' - elif [ -x "$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" ]; then log 'Managed Node runtime is available.' - else fail 'No compatible Node runtime found'; fi - fi +@@NODE_CHECK@@ log 'Executable check complete; login and model access are not inferred.' exit 0 fi @@ -117,6 +117,11 @@ trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM umask 077 +if [ "$OS" = android ]; then + TMPDIR=$(lmm_temp_root); lmm_check_storage "$TMPDIR" || exit 1 + mkdir -p "$TMPDIR"; export TMPDIR + if [ "$TARGET" = dsh ]; then log 'DSH native dependencies have not been validated on Android.'; fi +fi mkdir -p "$ROOT" "$CACHE" "$ROOT/bin" "$ROOT/apps" "$ROOT/runtime" ROOT=$(cd "$ROOT" && pwd -P) if ! mkdir "$ROOT/.setup-lock" 2>/dev/null; then @@ -134,222 +139,17 @@ LOCKED=1 STAGE=$(mktemp -d "$ROOT/.setup.XXXXXX") # Probe only public, credential-free artifact URLs. Slow transfers are still # interrupted independently of the latency ranking below. -rank_urls() { - local i=0 url response code elapsed probe_dir - if ! command -v curl >/dev/null 2>&1; then for url in "$@"; do printf '%s\n' "$i"; i=$((i+1)); done; return; fi - probe_dir=$(mktemp -d "$STAGE/probes.XXXXXX") - for url in "$@"; do - ( - response=$(curl -q --proto '=https' --proto-redir '=https' -ILs --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code} %{time_starttransfer}' "$url" 2>/dev/null || true) - code=${response%% *}; elapsed=${response#* } - case "$code" in 2??|3??) ;; *) elapsed=999;; esac - case "$elapsed" in ''|*[!0-9.]*) elapsed=999;; esac - printf '%s %s\n' "$elapsed" "$i" > "$probe_dir/$i" - ) & - i=$((i+1)) - done - wait - cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | awk '{print $2}' -} -urls_for() { - URLS=("$1") - case "$1" in - https://nodejs.org/dist/*) MIRRORS=("https://npmmirror.com/mirrors/node/${1#https://nodejs.org/dist/}");; - https://github.com/*) MIRRORS=("https://ghfast.top/$1" "https://ghproxy.net/$1");; - *) MIRRORS=();; - esac - case "$NETWORK" in - auto) URLS+=("${MIRRORS[@]}");; - china) URLS=("${MIRRORS[@]}" "$1");; - esac -} -download() { - local official=$1 destination=$2 expected=$3 index url part attempt actual order transfer_status - part="$destination.part" - if [ -L "$destination" ] || [ -L "$part" ] || [ -L "$part.url" ]; then fail 'Refusing symlink cache entries'; fi - if [ "$FORCE" = 0 ] && [ -f "$destination" ] && [ "$(sha256 "$destination")" = "$expected" ]; then log "Cached: ${destination##*/}"; return; fi - if [ -f "$part" ] && [ "$(sha256 "$part")" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi - command -v curl >/dev/null 2>&1 || fail 'curl is required for downloads. Install it with your OS package manager.' - if [[ $official == https://nodejs.org/dist/* && -n ${LMM_NODE_BASE_URL:-} ]]; then official="${LMM_NODE_BASE_URL%/}/${official#https://nodejs.org/dist/}"; fi - urls_for "$official" - if [ "$NETWORK" = auto ]; then order=$(rank_urls "${URLS[@]}"); else order=$(printf '%s\n' "${!URLS[@]}"); fi - for index in $order; do - url=${URLS[$index]} - if [ -f "$part" ] && [ "$(cat "$part.url" 2>/dev/null || true)" != "$url" ]; then rm -f -- "$part"; fi - printf '%s\n' "$url" > "$part.url" - for ((attempt=1; attempt<=RETRIES; attempt++)); do - log "Downloading ${destination##*/} (source $((index+1)), attempt $attempt; low-speed cutoff ${STALL_TIMEOUT}s)" - if curl -q --proto '=https' --proto-redir '=https' -fL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$DOWNLOAD_TIMEOUT" --speed-time "$STALL_TIMEOUT" --speed-limit "$MIN_SPEED" --continue-at - --output "$part" "$url"; then - actual=$(sha256 "$part") - if [ "$actual" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi - log 'Checksum mismatch: discarded the download; it will not be executed.' - rm -f -- "$part" - break - else transfer_status=$?; fi - # A server may reject Range; retry once from a clean file. Retain a - # partial transfer after final failure for the next invocation. - if [ "$attempt" = 1 ]; then case "$transfer_status" in 22|33|36) rm -f -- "$part";; esac; fi - done - done - fail "Download failed: ${destination##*/}. Rerun to resume, or choose another --network mode." -} -ensure_node() { - PHASE='Node.js runtime' - if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi - # Android uses bionic, not the glibc used by the Linux Node archives. - if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then - fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' - fi - local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive - if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi - if compatible_node; then NODE_BIN="$dir/bin"; return; fi - [ "$INSTALL_NODE" = 1 ] || fail 'Need Node 22.19+ (22.x) or Node 24+, including npm.' - [ ! -f /etc/alpine-release ] || fail 'On Alpine install nodejs/npm with apk first; official Node archives require glibc.' - hash=$(node_hash "$PLATFORM") - [ -n "$hash" ] || fail "No verified Node archive for $PLATFORM" - archive="$CACHE/node-v$NODE_VERSION-$PLATFORM.tar.gz" - download "https://nodejs.org/dist/v$NODE_VERSION/${archive##*/}" "$archive" "$hash" - mkdir -p "$STAGE/runtime" - tar -xzf "$archive" -C "$STAGE/runtime" - "$STAGE/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" --version >/dev/null || fail 'Node cannot run on this OS/libc. Install compatible Node using your OS package manager.' - [ ! -e "$dir" ] || fail "Managed runtime exists but is unusable: $dir. Inspect it before replacing." - mv -- "$STAGE/runtime/node-v$NODE_VERSION-$PLATFORM" "$dir" - NODE_BIN="$dir/bin"; export PATH="$NODE_BIN:$PATH" -} -configure_npm() { - if [ -z "${npm_config_cache:-}" ]; then - npm_config_cache=$(npm config get cache 2>/dev/null || true) - case "$npm_config_cache" in /*) ;; *) npm_config_cache="$CACHE/npm";; esac - export npm_config_cache - fi - export npm_config_fetch_retries="$RETRIES" npm_config_fetch_timeout="$((STALL_TIMEOUT * 1000))" - export npm_config_fetch_retry_mintimeout=2000 npm_config_fetch_retry_maxtimeout=30000 - export npm_config_strict_ssl=true - if [ -n "${LMM_NPM_REGISTRY:-}" ]; then export npm_config_registry="$LMM_NPM_REGISTRY"; fi - export npm_config_prefer_offline=true - local current order first - current=$(npm config get registry 2>/dev/null || true) - if [ -n "${npm_config_registry:-}" ] || { [ -n "$current" ] && [ "$current" != https://registry.npmjs.org/ ]; }; then - log 'Keeping your existing npm registry/proxy configuration.'; return - fi - NPM_SELECTED=1 - case "$NETWORK" in - official) export npm_config_registry=https://registry.npmjs.org/;; - china) export npm_config_registry=https://registry.npmmirror.com/;; - auto) - order=$(rank_urls https://registry.npmjs.org/ https://registry.npmmirror.com/) - first=${order%%$'\n'*} - if [ "$first" = 1 ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi;; - esac - log 'Selected a registry for this installer process only; global npm settings are unchanged.' -} -bounded() { - node - "$COMMAND_TIMEOUT" "$@" <<'JS' -const {spawn}=require('node:child_process'); -const [seconds,command,...args]=process.argv.slice(2); -const child=spawn(command,args,{stdio:'inherit',detached:true}); -let timedOut=false,stopping=false; -function stop(code){if(stopping)return;stopping=true;timedOut=code===124;try{process.kill(-child.pid,'SIGTERM')}catch{};const hard=setTimeout(()=>{try{process.kill(-child.pid,'SIGKILL')}catch{}},3000);hard.unref()} -const start=Date.now();const heartbeat=setInterval(()=>process.stderr.write(`[install] Still working: ${Math.floor((Date.now()-start)/1000)}s elapsed.\n`),15000); -const timeout=setTimeout(()=>{process.stderr.write('[install] Operation timed out; increase LMM_COMMAND_TIMEOUT for a slow connection.\n');stop(124)},Number(seconds)*1000); -process.on('SIGINT',()=>stop(130));process.on('SIGTERM',()=>stop(143)); -child.on('error',e=>{clearInterval(heartbeat);clearTimeout(timeout);process.stderr.write(`[install] Cannot start ${command}: ${e.code}\n`);process.exitCode=1}); -child.on('exit',(code,signal)=>{clearInterval(heartbeat);clearTimeout(timeout);process.exitCode=timedOut?124:signal?130:code??1}); -JS -} -with_registry_retry() { - if bounded "$@"; then return; fi - if [ "$NETWORK" = auto ] && [ "$NPM_SELECTED" = 1 ]; then - if [ "$npm_config_registry" = https://registry.npmjs.org/ ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi - log 'Retrying the alternate registry with the same package cache.' - bounded "$@" - else fail 'Package installation failed. Check network/proxy settings or try another --network mode.'; fi -} -ensure_pnpm() { - PHASE='DSH package manager' - local directory="$ROOT/tools/pnpm/$PNPM_VERSION" work="$STAGE/pnpm" - if [ -x "$directory/bin/pnpm" ] && [ -f "$directory/.lmm-managed" ]; then PNPM_BIN="$directory/bin" - elif [ "$BOOTSTRAP" = 0 ]; then - command -v pnpm >/dev/null 2>&1 || fail 'pnpm is missing; rerun without --no-bootstrap.' - bounded pnpm --version - PNPM_BIN=$(dirname "$(command -v pnpm)") - else - mkdir -p "$work" "$(dirname "$directory")" - with_registry_retry npm install --global --prefix "$work" --ignore-scripts --no-audit --no-fund "pnpm@$PNPM_VERSION" - bounded "$work/bin/pnpm" --version - printf '%s\n' "$PNPM_VERSION" > "$work/.lmm-managed" - if [ -e "$directory" ]; then - [ -f "$directory/.lmm-managed" ] || fail "Unowned package-manager directory: $directory" - directory="$directory-reinstall-$(date +%s)-$$" - fi - mv -- "$work" "$directory"; PNPM_BIN="$directory/bin" - fi - export PATH="$PNPM_BIN:$PATH" -} -install_client() { - local package=$1 version=$2 entry=$3 target="$ROOT/apps/$TARGET/$2" work="$STAGE/client" allow - PHASE="$TARGET client" - if [ "$FORCE" = 0 ] && [ -x "$target/bin/$entry" ] && [ -f "$target/.lmm-managed" ] && [ "$(cat "$target/.lmm-managed")" = "$version|$SCRIPT_VERSION" ]; then CLIENT="$target/bin/$entry"; log "Client $version already installed."; return; fi - [ "$BOOTSTRAP" = 1 ] || fail 'Managed client is missing; rerun without --no-bootstrap.' - mkdir -p "$work" - INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") - if [ "$TARGET" = pi ]; then - # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. - INSTALL_ARGS+=(--ignore-scripts) - else - allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' - if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi - [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' - fi - with_registry_retry npm "${INSTALL_ARGS[@]}" - "$work/bin/$entry" --version >/dev/null - printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" - mkdir -p "$(dirname "$target")" - if [ -e "$target" ]; then - [ -f "$target/.lmm-managed" ] || fail "Not replacing an unowned directory: $target" - target="$target-reinstall-$(date +%s)-$$" - fi - mv -- "$work" "$target" - CLIENT="$target/bin/$entry" -} -install_lmm() { - PHASE='LMM CLI' - local hash target="$ROOT/apps/lmm/$LMM_VERSION-$PLATFORM" archive - if [ "$FORCE" = 0 ] && [ -x "$target/lmm" ] && [ -f "$target/.lmm-managed" ]; then CLIENT="$target/lmm"; return; fi - mkdir -p "$STAGE/lmm" - if [ "$SOURCE" = 1 ]; then - command -v cargo >/dev/null 2>&1 || fail 'Source builds need Rust 1.88+ and OS build tools. Install them first, then rerun --from-source.' - log 'Building the pinned crate; Cargo reuses its existing dependency/build caches. This can take several minutes.' - export CARGO_HTTP_TIMEOUT="$STALL_TIMEOUT" CARGO_NET_RETRY="$RETRIES" - export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$CACHE/cargo-target} - cargo install lmm-cli --version "$LMM_VERSION" --locked --root "$STAGE/cargo" /dev/null || fail 'CLI cannot run here. Linux x64 preview binaries need glibc 2.39+; use --from-source on older Linux.' - printf '%s\n' "$LMM_VERSION" > "$STAGE/lmm/.lmm-managed" - mkdir -p "$(dirname "$target")" - if [ -e "$target" ]; then [ -f "$target/.lmm-managed" ] || fail "Unowned path: $target"; target="$target-reinstall-$(date +%s)-$$"; fi - mv -- "$STAGE/lmm" "$target"; CLIENT="$target/lmm" -} -quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } write_launcher() { local launcher="$ROOT/bin/$TARGET" temp="$STAGE/launcher" { - printf '#!/usr/bin/env bash\n# Managed by LMM installers.\n' + if [ "$OS" = android ]; then printf '#!%s\n' "$BASH" + else printf '#!/usr/bin/env bash\n'; fi + printf '# Managed by LMM installers.\n' # The launcher must expand PATH when it runs, not while it is generated. # shellcheck disable=SC2016 if [ "$TARGET" != lmm ]; then printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$NODE_BIN${PNPM_BIN:+:$PNPM_BIN}")"; fi - printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" + if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" + else printf 'exec %s %s "$@"\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi } > "$temp" chmod +x "$temp" if [ -e "$launcher" ] && ! grep -q '# Managed by LMM installers.' "$launcher"; then fail "Refusing to overwrite your existing launcher: $launcher"; fi @@ -371,21 +171,7 @@ add_path() { printf '\n%s\n%s\n# <<< LMM tools PATH <<<\n' "$marker" "$line" >> "$file" done } -if [ "$TARGET" = lmm ]; then install_lmm -else - ensure_node; configure_npm - if [ "$TARGET" = pi ]; then - install_client @earendil-works/pi-coding-agent "$PI_VERSION" pi - PHASE='Pi LMM provider'; with_registry_retry "$CLIENT" install "npm:@tokennotincluded/pi-lmm-provider@$PI_PROVIDER_VERSION" - else - ensure_pnpm - install_client @deepseek-ai/dsh "$DSH_VERSION" dsh - PHASE='DSH LMM provider' - artifact="$CACHE/${DSH_PROVIDER_URL##*/}" - download "$DSH_PROVIDER_URL" "$artifact" "$DSH_PROVIDER_SHA256" - with_registry_retry "$CLIENT" plugin --profile "$PROFILE" add "$artifact" --ignore-scripts --store-dir "$CACHE/pnpm" - fi -fi +install_tool PHASE='launchers and PATH'; write_launcher; add_path log "Ready: $ROOT/bin/$TARGET" log "Use the full command above, or for this terminal: export PATH=$(quote_sh "$ROOT/bin"):\"\$PATH\"" diff --git a/templates/lib/common.ps1 b/templates/lib/common.ps1 new file mode 100644 index 0000000..27b46a5 --- /dev/null +++ b/templates/lib/common.ps1 @@ -0,0 +1,92 @@ +function Setting([string]$Name, [int]$Default, [int]$Maximum) { + $raw = [Environment]::GetEnvironmentVariable($Name) + if ([string]::IsNullOrEmpty($raw)) { return $Default } + $number = 0 + if ($raw -notmatch '^[1-9][0-9]*$' -or -not [int]::TryParse($raw, [ref]$number) -or $number -gt $Maximum) { throw "$Name must be between 1 and $Maximum." } + return $number +} +function QuoteArgument([string]$Value) { + if($Value -notmatch '[\s"]' -and $Value.Length){return $Value} + return '"' + [regex]::Replace([regex]::Replace($Value,'(\\*)"','$1$1\"'),'(\\+)$','$1$1') + '"' +} +function Stop-InstallChild($Process) { + if($Process.HasExited){return} + $killer=$null + if($env:SystemRoot){$killer=Join-Path $env:SystemRoot 'System32\taskkill.exe'} + if($killer -and (Test-Path -LiteralPath $killer)){ & $killer /PID $Process.Id /T /F 2>$null | Out-Null } + if(-not $Process.HasExited){try{$Process.Kill($true)}catch{$Process.Kill()}} + [void]$Process.WaitForExit(10000) +} +function Invoke-Bounded([string]$Executable,[string[]]$Arguments) { + $info=New-Object Diagnostics.ProcessStartInfo + $info.FileName=$Executable; $info.UseShellExecute=$false + if ((Get-Location).Provider.Name -eq 'FileSystem') { $info.WorkingDirectory=(Get-Location).ProviderPath } + $info.Arguments=($Arguments | ForEach-Object { QuoteArgument $_ }) -join ' ' + $process=New-Object Diagnostics.Process; $process.StartInfo=$info + $started=$false + try { + $started=$process.Start() + if(-not $started){throw 'Could not start the installation process.'} + $watch=[Diagnostics.Stopwatch]::StartNew(); $last=0 + while(-not $process.WaitForExit(1000)) { + if($watch.Elapsed.TotalSeconds -gt $CommandTimeout){ + Stop-InstallChild $process + throw 'Operation timed out. Increase LMM_COMMAND_TIMEOUT for slow networks and rerun.' + } + if($watch.Elapsed.TotalSeconds-$last -ge 15){Write-Log ('Still working: {0:N0}s elapsed.' -f $watch.Elapsed.TotalSeconds);$last=$watch.Elapsed.TotalSeconds} + } + $global:LASTEXITCODE=$process.ExitCode + if($process.ExitCode -ne 0){throw "Installation command failed with exit code $($process.ExitCode)."} + } finally { + if($started -and -not $process.HasExited){Stop-InstallChild $process} + $process.Dispose() + } +} +function Invoke-Native([string]$Command, [string[]]$Arguments) { + if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { + if ((Test-Path -LiteralPath $Command) -and (Select-String -LiteralPath $Command -SimpleMatch 'Managed by LMM installers' -Quiet)) { + $launcherLines=@(Get-Content -LiteralPath $Command) + foreach ($pathLine in $launcherLines) { + if ($pathLine -match '^set "PATH=%~dp0\.\.\\(.+);%PATH%"$') { + $runtime=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) + if (!$runtime.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed runtime escaped its root.' } + if (!(Test-Path -LiteralPath $runtime -PathType Container)) { throw 'Managed runtime is missing. Rerun the installer.' } + $env:PATH="$runtime;$env:PATH" + } + } + $line=$launcherLines[-1] + if ($line -match '^"%~dp0\.\.\\(.+)" %\*$') { + $resolved=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) + if (!$resolved.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed launcher target escaped its root.' } + $Command=$resolved + } + } + if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { + $parent=Split-Path $Command + $binName=[IO.Path]::GetFileNameWithoutExtension($Command).ToLowerInvariant() + switch ($binName) { + 'npm' { $packageName='npm' } + 'pi' { $packageName='@earendil-works/pi-coding-agent' } + 'pnpm' { $packageName='pnpm' } + 'dsh' { $packageName='@deepseek-ai/dsh' } + default { throw 'Unsupported command shim; use the managed installer or a native executable.' } + } + $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) + $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json + $binProperty=$manifest.PSObject.Properties['bin'] + if (!$binProperty) { throw 'Package manifest has no bin entry.' } + $bins=$binProperty.Value + $relative=$null + if ($bins -is [string]) { $relative=$bins } + elseif ($null -ne $bins -and $bins.PSObject.Properties[$binName]) { $relative=$bins.PSObject.Properties[$binName].Value } + if ($relative -isnot [string] -or !$relative -or [IO.Path]::IsPathRooted($relative)) { throw 'Invalid package bin entry.' } + $entry=[IO.Path]::GetFullPath((Join-Path $packageRoot $relative)) + if (!$entry.StartsWith($packageRoot + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Package bin entry escaped its package.' } + if (!(Test-Path -LiteralPath $entry)) { throw 'Client entry is missing. Rerun with -Update.' } + $Command=(Get-Command node.exe).Source + $Arguments=@($entry)+$Arguments + } + } + Invoke-Bounded $Command $Arguments +} +function Get-Hash([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } diff --git a/templates/lib/download.ps1 b/templates/lib/download.ps1 new file mode 100644 index 0000000..dd1b95e --- /dev/null +++ b/templates/lib/download.ps1 @@ -0,0 +1,108 @@ +function Set-RequestProxy($request) { + $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } + if ($proxyValue) { + $proxyUri = [Uri]$proxyValue + if ($proxyUri.Scheme -notin @('http','https')) { throw 'Use an HTTP(S) proxy with Windows PowerShell; SOCKS needs a local HTTP proxy adapter.' } + $proxy = New-Object Net.WebProxy($proxyUri.GetLeftPart([UriPartial]::Authority)) + if ($proxyUri.UserInfo) { + $parts=$proxyUri.UserInfo.Split(':',2) + $password=if ($parts.Length -eq 2) { [Uri]::UnescapeDataString($parts[1]) } else { '' } + $proxy.Credentials=New-Object Net.NetworkCredential([Uri]::UnescapeDataString($parts[0]),$password) + } + if ($env:NO_PROXY) { + $proxy.BypassList=@($env:NO_PROXY.Split(',') | ForEach-Object { $hostName=$_.Trim().TrimStart('.'); if($hostName -eq '*') { '.*' } elseif($hostName) { '^https?://([^/]+\.)?' + [regex]::Escape($hostName) + '(:[0-9]+)?(/|$)' } }) + } + $request.Proxy=$proxy + } +} +function New-DownloadRequest([Uri]$Uri) { return [Net.HttpWebRequest]::Create($Uri) } +function Get-RankedUrls([string[]]$Urls) { + $scores = @(); $index = 0 + foreach ($url in $Urls) { + $timer = [Diagnostics.Stopwatch]::StartNew(); $score = 999999 + try { + $request = New-DownloadRequest ([Uri]$url) + $request.Method = 'HEAD'; $request.Timeout = 4000; $request.AllowAutoRedirect = $true + Set-RequestProxy $request + $response = $request.GetResponse(); $response.Close(); $score = $timer.ElapsedMilliseconds + } catch { } finally { $timer.Stop() } + $scores += [pscustomobject]@{ Url=$url; Score=$score; Order=$index }; $index++ + } + return @($scores | Sort-Object Score,Order | ForEach-Object { $_.Url }) +} +function Get-DownloadUrls([string]$Official) { + $mirrors = @() + if ($Official.StartsWith('https://nodejs.org/dist/')) { $mirrors = @($Official.Replace('https://nodejs.org/dist/','https://npmmirror.com/mirrors/node/')) } + elseif ($Official.StartsWith('https://github.com/')) { $mirrors = @("https://ghfast.top/$Official", "https://ghproxy.net/$Official") } + if ($Network -eq 'official') { return @($Official) } + if ($Network -eq 'china') { return @($mirrors) + @($Official) } + return @(Get-RankedUrls (@($Official) + @($mirrors))) +} +function Receive-Stream([string]$Url, [string]$Path) { + # Used on older Windows without curl.exe. ReadWriteTimeout bounds stalled reads. + $offset = 0L + if (Test-Path -LiteralPath $Path) { $offset = (Get-Item -LiteralPath $Path).Length } + $request = New-DownloadRequest ([Uri]$Url) + $request.Timeout = $ConnectTimeout*1000; $request.ReadWriteTimeout = $StallTimeout*1000; $request.AllowAutoRedirect = $true + Set-RequestProxy $request + if ($offset -gt 0) { $request.AddRange($offset) } + $response = $null; $inputStream = $null; $outputStream = $null + try { + $response = $request.GetResponse() + if ($response.ResponseUri.Scheme -ne 'https') { throw 'Insecure redirect refused.' } + $mode = [IO.FileMode]::Create + if ($offset -gt 0 -and [int]$response.StatusCode -eq 206) { + if ($response.Headers['Content-Range'] -notlike "bytes $offset-*") { throw 'Invalid resume response.' } + $mode = [IO.FileMode]::Append + } + $inputStream = $response.GetResponseStream() + $outputStream = [IO.File]::Open($Path,$mode,[IO.FileAccess]::Write,[IO.FileShare]::None) + $buffer = New-Object byte[] 65536; $windowBytes = 0L; $total = 0L + $window = [Diagnostics.Stopwatch]::StartNew(); $overall = [Diagnostics.Stopwatch]::StartNew() + while (($read = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) { + $outputStream.Write($buffer,0,$read); $windowBytes += $read; $total += $read + if ($overall.Elapsed.TotalSeconds -gt $DownloadTimeout) { throw 'Download timeout.' } + if ($window.Elapsed.TotalSeconds -ge $StallTimeout -and ($response.ContentLength -lt 0 -or $total -lt $response.ContentLength)) { + if ($windowBytes / $window.Elapsed.TotalSeconds -lt $MinSpeed) { throw 'Download too slow; changing source.' } + $window.Restart(); $windowBytes = 0 + } + } + } finally { + if ($outputStream) { $outputStream.Dispose() }; if ($inputStream) { $inputStream.Dispose() }; if ($response) { $response.Close() } + } +} +function Get-VerifiedFile([string]$Url, [string]$Destination, [string]$Expected) { + $parsed=[Uri]$Url + if ($parsed.Scheme -ne 'https' -or $parsed.UserInfo) { throw 'Download URLs must use HTTPS without credentials.' } + foreach($file in @($Destination,"$Destination.part","$Destination.part.url")) { if ((Test-Path -LiteralPath $file) -and ((Get-Item -LiteralPath $file).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Refusing symlink cache entries.' } } + if (-not $Update -and (Test-Path -LiteralPath $Destination) -and (Get-Hash $Destination) -eq $Expected) { Write-Log "Cached: $([IO.Path]::GetFileName($Destination))"; return } + $partial = "$Destination.part"; $sourceFile = "$partial.url" + if ((Test-Path -LiteralPath $partial) -and (Get-Hash $partial) -eq $Expected) { Move-Item -LiteralPath $partial -Destination $Destination -Force; return } + if ($env:LMM_NODE_BASE_URL -and $Url.StartsWith('https://nodejs.org/dist/')) { $Url=$Url.Replace('https://nodejs.org/dist',$env:LMM_NODE_BASE_URL.TrimEnd('/')) } + $sourceNumber = 0 + foreach ($source in @(Get-DownloadUrls $Url)) { + $sourceNumber++ + if ((Test-Path -LiteralPath $partial) -and (!(Test-Path -LiteralPath $sourceFile) -or (Get-Content -LiteralPath $sourceFile -Raw).Trim() -ne $source)) { Remove-Item -LiteralPath $partial -Force } + Set-Content -LiteralPath $sourceFile -Value $source -Encoding ASCII + foreach ($attempt in 1..$Retries) { + Write-Log "Downloading $([IO.Path]::GetFileName($Destination)) (source $sourceNumber, attempt $attempt)" + try { + $curl = Get-Command curl.exe -ErrorAction SilentlyContinue + if ($curl) { + Invoke-Native $curl.Source @('-q','--proto','=https','--proto-redir','=https','-fL','--connect-timeout',([string]$ConnectTimeout),'--max-time',([string]$DownloadTimeout),'--speed-time',([string]$StallTimeout),'--speed-limit',([string]$MinSpeed),'--continue-at','-','--output',$partial,$source) + } else { Receive-Stream $source $partial } + if ((Get-Hash $partial) -ne $Expected) { Remove-Item -LiteralPath $partial -Force; Write-Log 'Checksum mismatch; download will not be executed.'; break } + Move-Item -LiteralPath $partial -Destination $Destination -Force + Remove-Item -LiteralPath $sourceFile -Force -ErrorAction SilentlyContinue + return + } catch { + Write-Log 'Transfer failed or stalled. Retrying, then trying another source.' + if ($attempt -eq 1 -and (Test-Path -LiteralPath $partial)) { + # Keep a partial for retry; a rejected Range gets a clean retry next source. + if ($curl -and $LASTEXITCODE -in @(22,33,36)) { Remove-Item -LiteralPath $partial -Force } + } + } + } + } + throw 'All download sources failed. Retry -Network official or -Network china; inspect your proxy/CA settings.' +} diff --git a/templates/lib/download.sh b/templates/lib/download.sh new file mode 100644 index 0000000..141121f --- /dev/null +++ b/templates/lib/download.sh @@ -0,0 +1,59 @@ +rank_urls() { + local i=0 url response code elapsed probe_dir + if ! command -v curl >/dev/null 2>&1; then for url in "$@"; do printf '%s\n' "$i"; i=$((i+1)); done; return; fi + probe_dir=$(mktemp -d "$STAGE/probes.XXXXXX") + for url in "$@"; do + ( + response=$(curl -q --proto '=https' --proto-redir '=https' -ILs --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code} %{time_starttransfer}' "$url" 2>/dev/null || true) + code=${response%% *}; elapsed=${response#* } + case "$code" in 2??|3??) ;; *) elapsed=999;; esac + case "$elapsed" in ''|*[!0-9.]*) elapsed=999;; esac + printf '%s %s\n' "$elapsed" "$i" > "$probe_dir/$i" + ) & + i=$((i+1)) + done + wait + cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | while read -r elapsed index; do printf '%s\n' "$index"; done +} +urls_for() { + URLS=("$1") + case "$1" in + https://nodejs.org/dist/*) MIRRORS=("https://npmmirror.com/mirrors/node/${1#https://nodejs.org/dist/}");; + https://github.com/*) MIRRORS=("https://ghfast.top/$1" "https://ghproxy.net/$1");; + *) MIRRORS=();; + esac + case "$NETWORK" in + auto) URLS+=("${MIRRORS[@]}");; + china) URLS=("${MIRRORS[@]}" "$1");; + esac +} +download() { + local official=$1 destination=$2 expected=$3 index url part attempt actual order transfer_status + part="$destination.part" + if [ -L "$destination" ] || [ -L "$part" ] || [ -L "$part.url" ]; then fail 'Refusing symlink cache entries'; fi + if [ "$FORCE" = 0 ] && [ -f "$destination" ] && [ "$(sha256 "$destination")" = "$expected" ]; then log "Cached: ${destination##*/}"; return; fi + if [ -f "$part" ] && [ "$(sha256 "$part")" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi + command -v curl >/dev/null 2>&1 || fail 'curl is required for downloads. Install it with your OS package manager.' + if [[ $official == https://nodejs.org/dist/* && -n ${LMM_NODE_BASE_URL:-} ]]; then official="${LMM_NODE_BASE_URL%/}/${official#https://nodejs.org/dist/}"; fi + urls_for "$official" + if [ "$NETWORK" = auto ]; then order=$(rank_urls "${URLS[@]}"); else order=$(printf '%s\n' "${!URLS[@]}"); fi + for index in $order; do + url=${URLS[$index]} + if [ -f "$part" ] && [ "$(cat "$part.url" 2>/dev/null || true)" != "$url" ]; then rm -f -- "$part"; fi + printf '%s\n' "$url" > "$part.url" + for ((attempt=1; attempt<=RETRIES; attempt++)); do + log "Downloading ${destination##*/} (source $((index+1)), attempt $attempt; low-speed cutoff ${STALL_TIMEOUT}s)" + if curl -q --proto '=https' --proto-redir '=https' -fL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$DOWNLOAD_TIMEOUT" --speed-time "$STALL_TIMEOUT" --speed-limit "$MIN_SPEED" --continue-at - --output "$part" "$url"; then + actual=$(sha256 "$part") + if [ "$actual" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi + log 'Checksum mismatch: discarded the download; it will not be executed.' + rm -f -- "$part" + break + else transfer_status=$?; fi + # A server may reject Range; retry once from a clean file. Retain a + # partial transfer after final failure for the next invocation. + if [ "$attempt" = 1 ]; then case "$transfer_status" in 22|33|36) rm -f -- "$part";; esac; fi + done + done + fail "Download failed: ${destination##*/}. Rerun to resume, or choose another --network mode." +} diff --git a/templates/lib/hash.sh b/templates/lib/hash.sh new file mode 100644 index 0000000..ca3fabb --- /dev/null +++ b/templates/lib/hash.sh @@ -0,0 +1,7 @@ +sha256() { + local digest + if command -v sha256sum >/dev/null 2>&1; then digest=$(sha256sum "$1") || return; printf '%s\n' "${digest%% *}" + elif command -v shasum >/dev/null 2>&1; then digest=$(shasum -a 256 "$1") || return; printf '%s\n' "${digest%% *}" + elif command -v openssl >/dev/null 2>&1; then digest=$(openssl dgst -sha256 "$1") || return; printf '%s\n' "${digest##* }" + else printf 'Install a SHA-256 tool.\n' >&2; return 1; fi +} diff --git a/templates/lib/node-check.sh b/templates/lib/node-check.sh new file mode 100644 index 0000000..71b3f8a --- /dev/null +++ b/templates/lib/node-check.sh @@ -0,0 +1,5 @@ + if [ "$TARGET" != lmm ]; then + if compatible_node; then log 'Current PATH has compatible Node/npm.' + elif [ -x "$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" ]; then log 'Managed Node runtime is available.' + else fail 'No compatible Node runtime found'; fi + fi diff --git a/templates/lib/node.ps1 b/templates/lib/node.ps1 new file mode 100644 index 0000000..eec7ade --- /dev/null +++ b/templates/lib/node.ps1 @@ -0,0 +1,72 @@ +function Test-Node { + $node = Get-Command node.exe -ErrorAction SilentlyContinue + $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue + if (-not $node -or -not $npm) { return $false } + try { $versionText=(& $node.Source --version 2>$null | Out-String).Trim() } catch { return $false } + if ($LASTEXITCODE -ne 0 -or $versionText -notmatch '^v([0-9]+)\.([0-9]+)\.[0-9]+') { return $false } + $major=[int]$Matches[1];$minor=[int]$Matches[2] + return (($major -eq 22 -and $minor -ge 19) -or $major -ge 24) +} +function Install-Node { + $script:Phase = 'Node.js runtime' + if (Test-Node) { $script:NodeBin = Split-Path (Get-Command node.exe).Source; return } + $directory = Join-Path $Root "runtime\node-v$NodeVersion-$Platform" + if (Test-Path -LiteralPath (Join-Path $directory 'node.exe')) { $env:PATH = "$directory;$env:PATH" } + if (Test-Node) { $script:NodeBin = $directory; return } + if ($NoInstallNode -or $NoBootstrap) { throw 'Need Node 22.19+ (22.x) or Node 24+, including npm.' } + $hash = $NodeHashes[$Platform] + if (-not $hash) { throw "No verified Node archive for $Platform" } + $name = "node-v$NodeVersion-$Platform.zip"; $archive = Join-Path $script:Cache $name + Get-VerifiedFile "https://nodejs.org/dist/v$NodeVersion/$name" $archive $hash + $unpack = Join-Path $script:Stage 'runtime'; Expand-Archive -LiteralPath $archive -DestinationPath $unpack + $extracted = Join-Path $unpack "node-v$NodeVersion-$Platform" + Invoke-Native (Join-Path $extracted 'node.exe') @('--version') + if (Test-Path -LiteralPath $directory) { throw "Managed runtime is present but unusable; inspect $directory before replacing." } + Move-Item -LiteralPath $extracted -Destination $directory + $script:NodeBin = $directory; $env:PATH = "$directory;$env:PATH" +} +function Set-NpmNetwork { + if (-not $env:npm_config_cache) { $cache=(& npm.cmd config get cache 2>$null | Out-String).Trim(); if ($cache) { $env:npm_config_cache=$cache } else { $env:npm_config_cache=Join-Path $script:Cache 'npm' } } + $env:npm_config_fetch_retries=[string]$Retries; $env:npm_config_fetch_timeout=[string]($StallTimeout*1000); $env:npm_config_fetch_retry_mintimeout='2000'; $env:npm_config_fetch_retry_maxtimeout='30000'; $env:npm_config_strict_ssl='true'; $env:npm_config_prefer_offline='true' + if ($env:LMM_NPM_REGISTRY) { $env:npm_config_registry=$env:LMM_NPM_REGISTRY } + $current = (& npm.cmd config get registry 2>$null | Out-String).Trim() + if ($env:npm_config_registry -or ($current -and $current -ne 'https://registry.npmjs.org/')) { Write-Log 'Keeping your existing npm registry/proxy configuration.'; return } + $script:NpmSelected = $true + if ($Network -eq 'china') { $env:npm_config_registry='https://registry.npmmirror.com/' } + elseif ($Network -eq 'official') { $env:npm_config_registry='https://registry.npmjs.org/' } + else { $env:npm_config_registry = @(Get-RankedUrls @('https://registry.npmjs.org/','https://registry.npmmirror.com/'))[0] } + Write-Log 'Registry selection affects this process only, not your global npm configuration.' +} +function Invoke-WithRegistryRetry([string]$Command,[string[]]$Arguments) { + try { Invoke-Native $Command $Arguments } catch { + if ($Network -ne 'auto' -or -not $script:NpmSelected) { throw } + if ($env:npm_config_registry -eq 'https://registry.npmjs.org/') { $env:npm_config_registry='https://registry.npmmirror.com/' } else { $env:npm_config_registry='https://registry.npmjs.org/' } + Write-Log 'Retrying with the alternate registry and the same cache.' + Invoke-Native $Command $Arguments + } +} +function Install-Client([string]$Package,[string]$Version,[string]$Entry) { + $script:Phase="$Target client"; $destination=Join-Path $Root "apps\$Target\$Version" + if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } + if ($NoBootstrap) { throw 'Managed client is missing. Rerun without -NoBootstrap.' } + $work=Join-Path $script:Stage 'client'; New-Item -ItemType Directory -Path $work | Out-Null + $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") + if ($Target -eq 'pi') { + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + $installArgs+=@('--ignore-scripts') + } else { + $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + $npmHelp=(& npm.cmd install --help 2>$null | Out-String) + if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } + if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } + } + Invoke-WithRegistryRetry (Get-Command npm.cmd).Source $installArgs + Invoke-Native (Join-Path $work "$Entry.cmd") @('--version') + Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value "$Version|$ScriptVersion" + New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null + if (Test-Path -LiteralPath $destination) { + if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw "Refusing unowned directory: $destination" } + $destination += '-reinstall-' + [Guid]::NewGuid().ToString('N') + } + Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination "$Entry.cmd" +} diff --git a/templates/lib/node.sh b/templates/lib/node.sh new file mode 100644 index 0000000..c59922a --- /dev/null +++ b/templates/lib/node.sh @@ -0,0 +1,99 @@ +compatible_node() { + command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && + node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(((a===22&&b>=19)||a>=24)&&(process.argv[1]!=="android"||process.platform==="android")?0:1)' "$OS" >/dev/null 2>&1 +} +ensure_node() { + PHASE='Node.js runtime' + if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi + [ "$OS" != android ] || fail 'In Termux, run pkg install nodejs npm git; desktop Node archives are incompatible.' + local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive + if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi + if compatible_node; then NODE_BIN="$dir/bin"; return; fi + [ "$INSTALL_NODE" = 1 ] || fail 'Need Node 22.19+ (22.x) or Node 24+, including npm.' + [ ! -f /etc/alpine-release ] || fail 'On Alpine install nodejs/npm with apk first; official Node archives require glibc.' + hash=$(node_hash "$PLATFORM") + [ -n "$hash" ] || fail "No verified Node archive for $PLATFORM" + archive="$CACHE/node-v$NODE_VERSION-$PLATFORM.tar.gz" + download "https://nodejs.org/dist/v$NODE_VERSION/${archive##*/}" "$archive" "$hash" + mkdir -p "$STAGE/runtime" + tar -xzf "$archive" -C "$STAGE/runtime" + "$STAGE/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" --version >/dev/null || fail 'Node cannot run on this OS/libc. Install compatible Node using your OS package manager.' + [ ! -e "$dir" ] || fail "Managed runtime exists but is unusable: $dir. Inspect it before replacing." + mv -- "$STAGE/runtime/node-v$NODE_VERSION-$PLATFORM" "$dir" + NODE_BIN="$dir/bin"; export PATH="$NODE_BIN:$PATH" +} +configure_npm() { + if [ -z "${npm_config_cache:-}" ]; then + npm_config_cache=$(npm config get cache 2>/dev/null || true) + case "$npm_config_cache" in /*) ;; *) npm_config_cache="$CACHE/npm";; esac + export npm_config_cache + fi + export npm_config_fetch_retries="$RETRIES" npm_config_fetch_timeout="$((STALL_TIMEOUT * 1000))" + export npm_config_fetch_retry_mintimeout=2000 npm_config_fetch_retry_maxtimeout=30000 + export npm_config_strict_ssl=true + if [ -n "${LMM_NPM_REGISTRY:-}" ]; then export npm_config_registry="$LMM_NPM_REGISTRY"; fi + export npm_config_prefer_offline=true + local current order first + current=$(npm config get registry 2>/dev/null || true) + if [ -n "${npm_config_registry:-}" ] || { [ -n "$current" ] && [ "$current" != https://registry.npmjs.org/ ]; }; then + log 'Keeping your existing npm registry/proxy configuration.'; return + fi + NPM_SELECTED=1 + case "$NETWORK" in + official) export npm_config_registry=https://registry.npmjs.org/;; + china) export npm_config_registry=https://registry.npmmirror.com/;; + auto) + order=$(rank_urls https://registry.npmjs.org/ https://registry.npmmirror.com/) + first=${order%%$'\n'*} + if [ "$first" = 1 ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi;; + esac + log 'Selected a registry for this installer process only; global npm settings are unchanged.' +} +bounded() { + node - "$COMMAND_TIMEOUT" "$@" <<'JS' +const {spawn}=require('node:child_process'); +const [seconds,command,...args]=process.argv.slice(2); +const child=spawn(command,args,{stdio:'inherit',detached:true}); +let timedOut=false,stopping=false; +function stop(code){if(stopping)return;stopping=true;timedOut=code===124;try{process.kill(-child.pid,'SIGTERM')}catch{};const hard=setTimeout(()=>{try{process.kill(-child.pid,'SIGKILL')}catch{}},3000);hard.unref()} +const start=Date.now();const heartbeat=setInterval(()=>process.stderr.write(`[install] Still working: ${Math.floor((Date.now()-start)/1000)}s elapsed.\n`),15000); +const timeout=setTimeout(()=>{process.stderr.write('[install] Operation timed out; increase LMM_COMMAND_TIMEOUT for a slow connection.\n');stop(124)},Number(seconds)*1000); +process.on('SIGINT',()=>stop(130));process.on('SIGTERM',()=>stop(143)); +child.on('error',e=>{clearInterval(heartbeat);clearTimeout(timeout);process.stderr.write(`[install] Cannot start ${command}: ${e.code}\n`);process.exitCode=1}); +child.on('exit',(code,signal)=>{clearInterval(heartbeat);clearTimeout(timeout);process.exitCode=timedOut?124:signal?130:code??1}); +JS +} +with_registry_retry() { + if bounded "$@"; then return; fi + if [ "$NETWORK" = auto ] && [ "$NPM_SELECTED" = 1 ]; then + if [ "$npm_config_registry" = https://registry.npmjs.org/ ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi + log 'Retrying the alternate registry with the same package cache.' + bounded "$@" + else fail 'Package installation failed. Check network/proxy settings or try another --network mode.'; fi +} +install_client() { + local package=$1 version=$2 entry=$3 target="$ROOT/apps/$TARGET/$2" work="$STAGE/client" allow + PHASE="$TARGET client" + if [ "$FORCE" = 0 ] && [ -x "$target/bin/$entry" ] && [ -f "$target/.lmm-managed" ] && [ "$(cat "$target/.lmm-managed")" = "$version|$SCRIPT_VERSION" ]; then CLIENT="$target/bin/$entry"; log "Client $version already installed."; return; fi + [ "$BOOTSTRAP" = 1 ] || fail 'Managed client is missing; rerun without --no-bootstrap.' + mkdir -p "$work" + INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") + if [ "$TARGET" = pi ]; then + # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. + INSTALL_ARGS+=(--ignore-scripts) + else + allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' + if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi + [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' + fi + with_registry_retry npm "${INSTALL_ARGS[@]}" + node "$work/bin/$entry" --version >/dev/null + printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" + mkdir -p "$(dirname "$target")" + if [ -e "$target" ]; then + [ -f "$target/.lmm-managed" ] || fail "Not replacing an unowned directory: $target" + target="$target-reinstall-$(date +%s)-$$" + fi + mv -- "$work" "$target" + CLIENT="$target/bin/$entry" +} diff --git a/templates/lib/quote.sh b/templates/lib/quote.sh new file mode 100644 index 0000000..92bf18d --- /dev/null +++ b/templates/lib/quote.sh @@ -0,0 +1 @@ +quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } diff --git a/templates/lib/root.sh b/templates/lib/root.sh new file mode 100644 index 0000000..ae5bf71 --- /dev/null +++ b/templates/lib/root.sh @@ -0,0 +1,3 @@ +lmm_root() { + printf '%s\n' "${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}" +} diff --git a/templates/lib/termux.sh b/templates/lib/termux.sh new file mode 100644 index 0000000..d292da7 --- /dev/null +++ b/templates/lib/termux.sh @@ -0,0 +1,24 @@ +# Native Termux uses Android/bionic, not desktop Linux/glibc. +lmm_is_termux() { + [ -n "${TERMUX_VERSION:-}${TERMUX_APP__PACKAGE_NAME:-}" ] || + case "${PREFIX:-}" in */com.termux/files/usr) true;; *) false;; esac +} +lmm_temp_root() { + if [ -n "${TMPDIR:-}" ]; then printf '%s\n' "$TMPDIR" + elif lmm_is_termux; then printf '%s/tmp\n' "${PREFIX:-$HOME/.cache/lmm-tools}" + else printf '/tmp\n'; fi +} +lmm_check_storage() { + lmm_is_termux || return 0 + local resolved + # realpath -m also resolves missing paths and symlinked storage aliases. + command -v realpath >/dev/null 2>&1 || { + printf 'Termux needs coreutils: pkg install coreutils\n' >&2; return 1; + } + resolved=$(realpath -m -- "$1") || return 1 + case "$resolved/" in + /sdcard/*|/storage/*|/mnt/sdcard/*|/mnt/media_rw/*|/mnt/runtime/*|/mnt/user/*|/mnt/pass_through/*) + printf 'Use Termux private storage under HOME, not shared storage: %s\n' "$1" >&2 + return 1;; + esac +} diff --git a/templates/menu.sh.in b/templates/menu.sh.in index d4b5d03..c3d7150 100644 --- a/templates/menu.sh.in +++ b/templates/menu.sh.in @@ -2,6 +2,7 @@ # Complete function before execution: safe when downloaded through a pipe. lmm_menu_main() ( set -u +@@LIBRARIES@@ case "${1:-}" in --help|-h) printf 'LMM menu: bash menu.sh [--help]\nInteractive terminal required. Choose Pi, DSH or LMM CLI, then an action.\n'; exit 0;; '') ;; @@ -11,30 +12,29 @@ if ! { exec 3/dev/null; then printf '需要交互终端。请在终端运行菜单;自动化请使用底层安装脚本。\n' >&2; exit 2 fi command -v curl >/dev/null 2>&1 || { printf '请先安装 curl。\n' >&2; exit 2; } -hash_file() { - if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1 - elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d ' ' -f 1 - else printf '需要 sha256sum 或 shasum。\n' >&2; return 1; fi -} expected_hash() { case "$1" in @@HASHES@@ *) return 1;; esac; } ask() { printf '%s' "$1"; IFS= read -r answer <&3 || exit 0; } -work=$(mktemp -d "${TMPDIR:-/tmp}/lmm-menu.XXXXXXXX") || exit 1 +umask 077 +temp_root=$(lmm_temp_root) +lmm_check_storage "$temp_root" || exit 1 +mkdir -p "$temp_root" || exit 1 +work=$(mktemp -d "$temp_root/lmm-menu.XXXXXXXX") || exit 1 trap 'rm -rf -- "$work"' EXIT trap 'exit 130' INT trap 'exit 143' TERM network=auto -root=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} +root=$(lmm_root) fetch_script() { local name=$1 expected url expected=$(expected_hash "$name") || return 1 - if [ -f "$work/$name" ] && [ "$(hash_file "$work/$name")" = "$expected" ]; then return 0; fi + if [ -f "$work/$name" ] && [ "$(sha256 "$work/$name")" = "$expected" ]; then return 0; fi printf '正在获取并校验安装程序(下载慢时会重试)…\n' for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/@@REVISION@@/$name"; do if curl -q -fSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 2 --retry-delay 2 "$url" -o "$work/download"; then - if [ "$(hash_file "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi + if [ "$(sha256 "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi printf '文件版本或校验不匹配,尝试固定版本备用地址。\n' >&2 fi done diff --git a/templates/tools/dsh.ps1 b/templates/tools/dsh.ps1 new file mode 100644 index 0000000..4b21094 --- /dev/null +++ b/templates/tools/dsh.ps1 @@ -0,0 +1,51 @@ +function Install-Pnpm { + $script:Phase='DSH package manager';$destination=Join-Path $Root "tools\pnpm\$PnpmVersion" + if ((Test-Path -LiteralPath (Join-Path $destination 'pnpm.cmd')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:PnpmBin=$destination } + elseif ($NoBootstrap) { + $pm=Get-Command pnpm.cmd -ErrorAction SilentlyContinue + if (!$pm) { throw 'pnpm is missing; rerun without -NoBootstrap.' } + Invoke-Native $pm.Source @('--version');$script:PnpmBin=Split-Path $pm.Source + } else { + $work=Join-Path $script:Stage 'pnpm';New-Item -ItemType Directory -Path $work | Out-Null + Invoke-WithRegistryRetry (Get-Command npm.cmd).Source @('install','--global','--prefix',$work,'--ignore-scripts','--no-audit','--no-fund',"pnpm@$PnpmVersion") + Invoke-Native (Join-Path $work 'pnpm.cmd') @('--version') + Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value $PnpmVersion + New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null + if (Test-Path -LiteralPath $destination) { + if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw 'Unowned pnpm installation directory.' } + $destination+='-reinstall-'+[Guid]::NewGuid().ToString('N') + } + Move-Item -LiteralPath $work -Destination $destination;$script:PnpmBin=$destination + } + $env:PATH="$script:PnpmBin;$env:PATH" +} +function Install-Tool { + Install-Node; Set-NpmNetwork + Install-Pnpm + Install-Client '@deepseek-ai/dsh' $DshVersion 'dsh' + $script:Phase='DSH LMM provider'; $archive=Join-Path $script:Cache ($DshProviderUrl.Split('/')[-1]) + Get-VerifiedFile $DshProviderUrl $archive $DshProviderSha256 + # DSH 0.1.5 uses a shell to invoke pnpm on Windows. Passing absolute + # paths containing spaces loses argument boundaries in that layer. + # Keep the verified immutable package inside the profile and pass a + # path-free file: spec; configure the store through environment instead. + $profileHome=$env:DSH_HOME + $userDirectory=[Environment]::GetFolderPath('UserProfile') + if (!$profileHome) { $profileHome=Join-Path $userDirectory '.dsh' } + elseif ($profileHome -eq '~') { $profileHome=$userDirectory } + elseif ($profileHome.StartsWith('~/') -or $profileHome.StartsWith('~\')) { $profileHome=Join-Path $userDirectory $profileHome.Substring(2) } + if (![IO.Path]::IsPathRooted($profileHome)) { $profileHome=Join-Path (Get-Location).ProviderPath $profileHome } + $profileDirectory=Join-Path ([IO.Path]::GetFullPath($profileHome)) "profiles\$Profile" + New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null + $packageName='.lmm-provider-'+$DshProviderSha256.Substring(0,16)+'.tgz' + $profilePackage=Join-Path $profileDirectory $packageName + if (Test-Path -LiteralPath $profilePackage) { + if ((Get-Hash $profilePackage) -ne $DshProviderSha256) { throw 'Conflicting installer package in the DSH profile; inspect it before retrying.' } + } else { + $pending=Join-Path $profileDirectory ('.lmm-package-'+[Guid]::NewGuid().ToString('N')+'.tmp') + try { Copy-Item -LiteralPath $archive -Destination $pending; Move-Item -LiteralPath $pending -Destination $profilePackage -Force } + finally { if (Test-Path -LiteralPath $pending) { Remove-Item -LiteralPath $pending -Force } } + } + $env:npm_config_store_dir=Join-Path $script:Cache 'pnpm' + Invoke-WithRegistryRetry $script:Client @('plugin','--profile',$Profile,'add',"file:$packageName",'--ignore-scripts') +} diff --git a/templates/tools/dsh.sh b/templates/tools/dsh.sh new file mode 100644 index 0000000..638e617 --- /dev/null +++ b/templates/tools/dsh.sh @@ -0,0 +1,29 @@ +ensure_pnpm() { + PHASE='DSH package manager' + local directory="$ROOT/tools/pnpm/$PNPM_VERSION" work="$STAGE/pnpm" + if [ -x "$directory/bin/pnpm" ] && [ -f "$directory/.lmm-managed" ]; then PNPM_BIN="$directory/bin" + elif [ "$BOOTSTRAP" = 0 ]; then + command -v pnpm >/dev/null 2>&1 || fail 'pnpm is missing; rerun without --no-bootstrap.' + bounded pnpm --version + PNPM_BIN=$(dirname "$(command -v pnpm)") + else + mkdir -p "$work" "$(dirname "$directory")" + with_registry_retry npm install --global --prefix "$work" --ignore-scripts --no-audit --no-fund "pnpm@$PNPM_VERSION" + bounded node "$work/bin/pnpm" --version + printf '%s\n' "$PNPM_VERSION" > "$work/.lmm-managed" + if [ -e "$directory" ]; then + [ -f "$directory/.lmm-managed" ] || fail "Unowned package-manager directory: $directory" + directory="$directory-reinstall-$(date +%s)-$$" + fi + mv -- "$work" "$directory"; PNPM_BIN="$directory/bin" + fi + export PATH="$PNPM_BIN:$PATH" +} +install_tool() { + ensure_node; configure_npm; ensure_pnpm + install_client @deepseek-ai/dsh "$DSH_VERSION" dsh + PHASE='DSH LMM provider' + local artifact="$CACHE/${DSH_PROVIDER_URL##*/}" + download "$DSH_PROVIDER_URL" "$artifact" "$DSH_PROVIDER_SHA256" + with_registry_retry node "$CLIENT" plugin --profile "$PROFILE" add "$artifact" --ignore-scripts --store-dir "$CACHE/pnpm" +} diff --git a/templates/tools/lmm.ps1 b/templates/tools/lmm.ps1 new file mode 100644 index 0000000..197f658 --- /dev/null +++ b/templates/tools/lmm.ps1 @@ -0,0 +1,28 @@ +function Install-Lmm { + $script:Phase='LMM CLI'; $destination=Join-Path $Root "apps\lmm\$LmmVersion-$Platform" + if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination 'lmm.exe')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:Client=Join-Path $destination 'lmm.exe'; return } + $work=Join-Path $script:Stage 'lmm' + if ($FromSource) { + $cargo=Get-Command cargo.exe -ErrorAction SilentlyContinue + if (-not $cargo) { throw 'Source install needs Rust 1.88+ and Visual Studio C++ Build Tools. Install those, then retry -FromSource.' } + $cargoRoot=Join-Path $script:Stage 'cargo' + Invoke-Native $cargo.Source @('install','lmm-cli','--version',$LmmVersion,'--locked','--root',$cargoRoot) + New-Item -ItemType Directory -Path $work | Out-Null + Copy-Item -LiteralPath (Join-Path $cargoRoot 'bin\lmm.exe') -Destination $work + } else { + $hash=$LmmHashes[$Platform] + if (-not $hash) { throw "No prebuilt LMM CLI for $Platform yet. Use -FromSource with Rust 1.88+ and build tools." } + $name="lmm-v$LmmVersion-$Platform.zip"; $archive=Join-Path $script:Cache $name + Get-VerifiedFile "$LmmReleaseBase/$name" $archive $hash + Expand-Archive -LiteralPath $archive -DestinationPath $work + } + Invoke-Native (Join-Path $work 'lmm.exe') @('--version') + Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value $LmmVersion + New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null + if (Test-Path -LiteralPath $destination) { + if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw "Refusing unowned directory: $destination" } + $destination += '-reinstall-' + [Guid]::NewGuid().ToString('N') + } + Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination 'lmm.exe' +} +function Install-Tool { Install-Lmm } diff --git a/templates/tools/lmm.sh b/templates/tools/lmm.sh new file mode 100644 index 0000000..6c028ee --- /dev/null +++ b/templates/tools/lmm.sh @@ -0,0 +1,30 @@ +install_lmm() { + PHASE='LMM CLI' + local hash target="$ROOT/apps/lmm/$LMM_VERSION-$PLATFORM" archive + if [ "$FORCE" = 0 ] && [ -x "$target/lmm" ] && [ -f "$target/.lmm-managed" ]; then CLIENT="$target/lmm"; return; fi + mkdir -p "$STAGE/lmm" + if [ "$SOURCE" = 1 ]; then + command -v cargo >/dev/null 2>&1 || fail 'Source builds need Rust 1.88+ and OS build tools. Install them first, then rerun --from-source.' + log 'Building the pinned crate; Cargo reuses its existing dependency/build caches. This can take several minutes.' + export CARGO_HTTP_TIMEOUT="$STALL_TIMEOUT" CARGO_NET_RETRY="$RETRIES" + export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$CACHE/cargo-target} + cargo install lmm-cli --version "$LMM_VERSION" --locked --root "$STAGE/cargo" /dev/null || fail 'CLI cannot run here. Linux x64 preview binaries need glibc 2.39+; use --from-source on older Linux.' + printf '%s\n' "$LMM_VERSION" > "$STAGE/lmm/.lmm-managed" + mkdir -p "$(dirname "$target")" + if [ -e "$target" ]; then [ -f "$target/.lmm-managed" ] || fail "Unowned path: $target"; target="$target-reinstall-$(date +%s)-$$"; fi + mv -- "$STAGE/lmm" "$target"; CLIENT="$target/lmm" +} +install_tool() { install_lmm; } diff --git a/templates/tools/pi.ps1 b/templates/tools/pi.ps1 new file mode 100644 index 0000000..f2abf6d --- /dev/null +++ b/templates/tools/pi.ps1 @@ -0,0 +1,27 @@ +function Assert-PiShell { + # Follow Pi's shellPath -> Git Bash -> PATH lookup, without editing settings. + $agentDirectory=$env:PI_CODING_AGENT_DIR + if (!$agentDirectory) { $agentDirectory=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pi\agent' } + $settingsPath=Join-Path $agentDirectory 'settings.json' + if (Test-Path -LiteralPath $settingsPath) { + try { $settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json } + catch { throw "Invalid Pi settings: $settingsPath. Repair the JSON before installing." } + if ($null -eq $settings) { throw "Invalid Pi settings: $settingsPath" } + $property=$settings.PSObject.Properties['shellPath'] + if ($property -and $property.Value) { + $shell=[string]$property.Value + if (Test-Path -LiteralPath $shell -PathType Leaf) { return } + if (Get-Command $shell -CommandType Application -ErrorAction SilentlyContinue) { return } + throw "Pi shellPath does not exist: $shell. Correct it in $settingsPath." + } + } + if ($env:ProgramFiles -and (Test-Path -LiteralPath (Join-Path $env:ProgramFiles 'Git\bin\bash.exe') -PathType Leaf)) { return } + if (Get-Command 'bash.exe' -CommandType Application -ErrorAction SilentlyContinue) { return } + throw 'Pi requires Bash on Windows. Install Git for Windows, reopen PowerShell, or set shellPath in Pi settings. See https://pi.dev/docs/latest/windows' +} +function Install-Tool { + Install-Node; Set-NpmNetwork + Install-Client '@earendil-works/pi-coding-agent' $PiVersion 'pi' + $script:Phase='Pi LMM provider' + Invoke-WithRegistryRetry $script:Client @('install',"npm:@tokennotincluded/pi-lmm-provider@$PiProviderVersion") +} diff --git a/templates/tools/pi.sh b/templates/tools/pi.sh new file mode 100644 index 0000000..b550759 --- /dev/null +++ b/templates/tools/pi.sh @@ -0,0 +1,7 @@ +install_tool() { + ensure_node; configure_npm + install_client @earendil-works/pi-coding-agent "$PI_VERSION" pi + PHASE='Pi LMM provider' + with_registry_retry node "$CLIENT" install "npm:@tokennotincluded/pi-lmm-provider@$PI_PROVIDER_VERSION" + if [ "$OS" = android ]; then log 'Optional clipboard: install the Termux:API app and pkg install termux-api. Open login links with termux-open-url.'; fi +} diff --git a/templates/use.sh.in b/templates/use.sh.in new file mode 100644 index 0000000..3772fe4 --- /dev/null +++ b/templates/use.sh.in @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +@@LIBRARIES@@ +ROOT=$(lmm_root) +usage() { + cat <<'HELP' +LMM CLI quick start (developer preview) + bash lmm-use.sh catalog [keyword] + bash lmm-use.sh status [application] + bash lmm-use.sh doctor [application] # adds --report + bash lmm-use.sh plan [application] # setup --dry-run only + bash lmm-use.sh login [--no-browser] + bash lmm-use.sh models [--json] + bash lmm-use.sh logout +Install first: https://api.lmm.best/scripts -> lmm.sh +Login uses the OS credential store; Linux needs a running Secret Service. +SSH/headless login is not a device-code flow: the browser must reach this host's +loopback callback. Application setup is not implemented yet; planning/doctor +can return exit 3. This wrapper preserves that status rather than claiming success. +HELP +} +case "${1:-help}" in help|--help|-h) usage; exit 0;; esac +if [ -x "$ROOT/bin/lmm" ]; then CLI="$ROOT/bin/lmm" +elif command -v lmm >/dev/null 2>&1; then CLI=$(command -v lmm) +else printf 'LMM CLI is not installed. Run lmm.sh first.\n' >&2; exit 2; fi +command=$1;shift +case "$command" in + catalog|status|models) exec "$CLI" "$command" "$@";; + doctor) exec "$CLI" doctor --report "$@";; + plan) exec "$CLI" setup --dry-run "$@";; + login|logout) + if [ -n "${SSH_CONNECTION:-}" ]; then printf 'SSH note: the browser callback and OS credential store must be usable on this host.\n' >&2; fi + if [ -t 0 ]; then exec "$CLI" "$command" "$@" + elif { true /dev/null; then exec "$CLI" "$command" "$@" &2; exit 2; fi;; + *) printf 'Unsupported quick-start command: %s\n' "$command" >&2; usage; exit 2;; +esac diff --git a/tests/test_installers.py b/tests/test_installers.py index f57fede..13df58c 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -6,10 +6,15 @@ from pathlib import Path name=Path(sys.argv[0]).name;a=sys.argv[1:] with open(os.environ['LMM_TEST_LOG'],'a') as f:f.write(json.dumps([name,a])+'\n') -if name=='uname':print('Linux' if '-s' in a else 'x86_64') +if name=='uname':print(os.environ.get('LMM_TEST_OS','Linux') if '-s' in a else os.environ.get('LMM_TEST_ARCH','x86_64')) +elif name=='realpath':print(os.path.realpath(a[-1])) elif name=='node': if a and a[0]=='-':os.execv(os.environ['LMM_TEST_REAL_NODE'],[os.environ['LMM_TEST_REAL_NODE']]+a) - if '-e' in a:sys.exit(0 if os.environ.get('LMM_TEST_NODE_OK','1')=='1' else 1) + if '-e' in a: + ok=os.environ.get('LMM_TEST_NODE_OK','1')=='1' + if a[-1]=='android':ok=ok and os.environ.get('LMM_TEST_NODE_PLATFORM','android')=='android' + sys.exit(0 if ok else 1) + if a and Path(a[0]).is_file():os.execv('/bin/bash',['bash',a[0]]+a[1:]) print('v24.21.0') elif name=='curl': if '-ILs' in a: @@ -37,7 +42,7 @@ class InstallerTests(unittest.TestCase): def setUp(self): self.tmp=tempfile.TemporaryDirectory();self.base=Path(self.tmp.name);self.root=self.base/"install space's path";self.bin=self.base/'fake';self.bin.mkdir();self.log=self.base/'calls.jsonl';self.log.write_text('') - for name in ['curl','uname','node','npm']: + for name in ['curl','uname','node','npm','realpath']: f=self.bin/name;f.write_text(FAKE);f.chmod(0o755) self.archive=self.base/'fixture.tar.gz' with tarfile.open(self.archive,'w:gz') as t: diff --git a/tests/test_official_policy.py b/tests/test_official_policy.py index b05015e..c21d7c9 100644 --- a/tests/test_official_policy.py +++ b/tests/test_official_policy.py @@ -60,5 +60,142 @@ def test_termux_rejects_desktop_lmm_binary(self): self.assertIn('No Android LMM CLI binary', result.stderr) self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) + +class TermuxAndCompositionTests(unittest.TestCase): + def setUp(self): + self.fixture = fixtures.InstallerTests() + self.fixture.setUp() + self.prefix = self.fixture.base / 'termux prefix' + (self.prefix / 'tmp').mkdir(parents=True) + self.env = {'TERMUX_VERSION': 'test', 'PREFIX': str(self.prefix), 'TMPDIR': ''} + + def tearDown(self): + self.fixture.tearDown() + + def test_termux_32_bit_native_node_is_supported(self): + for arch in ('armv7l', 'i686'): + with self.subTest(arch=arch): + r = self.fixture.run_script('pi', env=self.env | {'LMM_TEST_ARCH': arch}) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) + + def test_desktop_32_bit_is_not_misidentified_as_termux(self): + r = self.fixture.run_script('pi', env={'TERMUX_VERSION': '', 'TERMUX_APP__PACKAGE_NAME': '', 'PREFIX': '', 'LMM_TEST_ARCH': 'armv7l'}) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(self.fixture.root.exists()) + + def test_termux_requires_native_android_node(self): + r = self.fixture.run_script('pi', env=self.env | {'LMM_TEST_NODE_PLATFORM': 'linux'}) + self.assertNotEqual(r.returncode, 0) + self.assertIn('native Node/npm', r.stderr) + self.assertFalse(self.fixture.root.exists()) + self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) + + def test_prefix_alone_identifies_termux(self): + env = self.env | {'TERMUX_VERSION': '', 'TERMUX_APP__PACKAGE_NAME': '', 'PREFIX': str(self.fixture.base / 'com.termux/files/usr'), 'LMM_TEST_NODE_OK': '0'} + r = self.fixture.run_script('pi', env=env) + self.assertNotEqual(r.returncode, 0) + self.assertIn('In Termux', r.stderr) + self.assertFalse(self.fixture.root.exists()) + + def test_termux_check_is_read_only(self): + r = self.fixture.run_script('pi', '--check', env=self.env) + self.assertIn(r.returncode, (0, 1)) + self.assertFalse(self.fixture.root.exists()) + self.assertFalse(any(name in ('curl', 'npm') and 'install' in args for name, args in self.fixture.calls())) + + def test_shared_storage_is_rejected_before_installation(self): + for path in ('/sdcard/lmm-tools', '/storage/emulated/0/lmm-tools', '/mnt/media_rw/card/lmm-tools'): + with self.subTest(path=path): + r = self.fixture.run_script('pi', '--root', path, env=self.env) + self.assertNotEqual(r.returncode, 0) + self.assertIn('shared storage', r.stderr) + self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) + + def test_shared_storage_symlink_ancestor_is_rejected(self): + alias = self.fixture.base / 'shared alias' + alias.symlink_to('/storage/emulated/0', target_is_directory=True) + r = self.fixture.run_script('pi', '--root', str(alias / 'tools'), env=self.env) + self.assertNotEqual(r.returncode, 0) + self.assertIn('shared storage', r.stderr) + + def test_shared_cache_and_temp_are_rejected(self): + for key in ('LMM_CACHE_ROOT', 'TMPDIR'): + with self.subTest(key=key): + r = self.fixture.run_script('pi', env=self.env | {key: '/sdcard/lmm-cache'}) + self.assertNotEqual(r.returncode, 0) + self.assertIn('shared storage', r.stderr) + self.assertFalse((self.fixture.root / 'bin/pi').exists()) + + def test_launcher_uses_absolute_bash_and_explicit_node(self): + r = self.fixture.run_script('pi', env=self.env) + self.assertEqual(r.returncode, 0, r.stderr) + body = (self.fixture.root / 'bin/pi').read_text() + self.assertNotIn('/usr/bin/env', body) + self.assertIn('/node', body) + self.assertTrue(body.startswith('#!/')) + version = fixtures.subprocess.run([str(self.fixture.root / 'bin/pi'), '--version'], env=self.fixture.env | self.env, capture_output=True, text=True) + self.assertEqual(version.returncode, 0, version.stderr) + + def test_menu_temp_fallback_uses_prefix_not_desktop_tmp(self): + source = (P / 'templates/lib/termux.sh').read_text(encoding='utf-8') + r = fixtures.subprocess.run(['bash', '-c', source + '\nlmm_temp_root'], env=self.fixture.env | self.env, capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(r.stdout.strip(), str(self.prefix / 'tmp')) + explicit = str(self.fixture.base / 'explicit temp') + r = fixtures.subprocess.run(['bash', '-c', source + '\nlmm_temp_root'], env=self.fixture.env | self.env | {'TMPDIR': explicit}, capture_output=True, text=True) + self.assertEqual(r.stdout.strip(), explicit) + + def test_termux_does_not_reuse_cached_linux_lmm(self): + v = fixtures.json.loads((P / 'versions.json').read_text()) + app = self.fixture.root / 'apps/lmm' / (v['lmm_version'] + '-linux-x64') + app.mkdir(parents=True) + (app / '.lmm-managed').write_text(v['lmm_version']) + binary = app / 'lmm' + binary.write_text('#!/bin/sh\nexit 0\n') + binary.chmod(0o755) + r = self.fixture.run_script('lmm', env=self.env) + self.assertNotEqual(r.returncode, 0) + self.assertIn('No Android LMM CLI binary', r.stderr) + self.assertFalse((self.fixture.root / 'bin/lmm').exists()) + + def test_relative_install_root_works(self): + relative = fixtures.os.path.relpath(self.fixture.root) + r = self.fixture.run_script('pi', '--root', relative) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertTrue((self.fixture.root / 'bin/pi').exists()) + + def test_generated_scripts_omit_other_target_implementations(self): + for ext, pnpm, lmm, node in [('sh', 'ensure_pnpm()', 'install_lmm()', 'ensure_node()'), ('ps1', 'function Install-Pnpm', 'function Install-Lmm', 'function Install-Node')]: + pi = (P / f'pi.{ext}').read_text() + dsh = (P / f'dsh.{ext}').read_text() + cli = (P / f'lmm.{ext}').read_text() + self.assertNotIn(pnpm, pi) + self.assertNotIn(lmm, pi) + self.assertNotIn(lmm, dsh) + self.assertNotIn(pnpm, cli) + self.assertNotIn(node, cli) + self.assertNotIn('DSH_PROVIDER_SHA256=', pi) + + def test_shared_helpers_are_embedded_once_and_remain_offline(self): + for target in ('pi', 'dsh', 'lmm', 'menu'): + body = (P / f'{target}.sh').read_text(encoding='utf-8') + self.assertEqual(body.count('lmm_is_termux() {'), 1) + self.assertEqual(body.count('sha256() {'), 1) + self.assertEqual(body.count('lmm_root() {'), 1) + self.assertNotIn('@@LIBRARIES@@', body) + self.assertNotIn('source https:', body) + fixtures.subprocess.run(['python3', str(P / 'tools/generate_menus.py'), '--check'], check=True) + + def test_every_generated_shell_help_is_standalone(self): + for target in ('pi', 'dsh', 'lmm', 'lmm-use', 'menu'): + body = (P / f'{target}.sh').read_text(encoding='utf-8') + r = fixtures.subprocess.run(['bash', '-s', '--', '--help'], input=body, env=self.fixture.env | self.env, text=True, capture_output=True) + self.assertEqual(r.returncode, 0, r.stderr) + partial = body.rsplit('if true; then', 1)[0] + r = fixtures.subprocess.run(['bash', '-s'], input=partial, env=self.fixture.env | self.env, text=True, capture_output=True) + self.assertFalse(self.fixture.root.exists()) + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/tools/_termux_refactor.py b/tools/_termux_refactor.py deleted file mode 100644 index 776676b..0000000 --- a/tools/_termux_refactor.py +++ /dev/null @@ -1,223 +0,0 @@ -from pathlib import Path -import json, re -P = Path.cwd() - -def read(path): return (P/path).read_text(encoding='utf-8') -def write(path, text): - f=P/path; f.parent.mkdir(parents=True, exist_ok=True) - f.write_text(text, encoding='utf-8', newline='\n') -def once(text, old, new): - assert text.count(old)==1, (old[:120], text.count(old)) - return text.replace(old,new) -def take(text, name, ext): - pattern = rf'(?m)^{re.escape(name)}\(\) \{{' if ext=='sh' else rf'(?m)^function {re.escape(name)}(?=[( {{])' - m=re.search(pattern,text); assert m, name - line_end=text.find('\n',m.start()) - if text[m.start():line_end].rstrip().endswith('}'): - end=line_end+1 - else: - end=text.index('\n}',line_end)+2 - if text[end:end+1]=='\n': end+=1 - return text[:m.start()]+text[end:], text[m.start():end] -def take_many(text, names, ext): - parts=[] - for name in names: - text, part=take(text,name,ext); parts.append(part) - return text,''.join(parts) - -write('templates/lib/root.sh', '''lmm_root() { - printf '%s\\n' "${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}" -} -''') -write('templates/lib/termux.sh', '''# Native Termux uses Android/bionic, not desktop Linux/glibc. -lmm_is_termux() { - [ -n "${TERMUX_VERSION:-}${TERMUX_APP__PACKAGE_NAME:-}" ] || - case "${PREFIX:-}" in */com.termux/files/usr) true;; *) false;; esac -} -lmm_temp_root() { - if [ -n "${TMPDIR:-}" ]; then printf '%s\\n' "$TMPDIR" - elif lmm_is_termux; then printf '%s/tmp\\n' "${PREFIX:-$HOME/.cache/lmm-tools}" - else printf '/tmp\\n'; fi -} -lmm_check_storage() { - lmm_is_termux || return 0 - local resolved - # realpath -m also resolves missing paths and symlinked storage aliases. - command -v realpath >/dev/null 2>&1 || { - printf 'Termux needs coreutils: pkg install coreutils\\n' >&2; return 1; - } - resolved=$(realpath -m -- "$1") || return 1 - case "$resolved/" in - /sdcard/*|/storage/*|/mnt/sdcard/*|/mnt/media_rw/*|/mnt/runtime/*|/mnt/user/*|/mnt/pass_through/*) - printf 'Use Termux private storage under HOME, not shared storage: %s\\n' "$1" >&2 - return 1;; - esac -} -''') -sh=read('templates/install.sh.in') -sh=once(sh, 'ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}', 'ROOT=$(lmm_root)') -sh=once(sh, 'CACHE=${LMM_CACHE_ROOT:-$ROOT/cache}', 'case "$ROOT" in /*) ;; *) ROOT="$PWD/$ROOT";; esac\nCACHE=${LMM_CACHE_ROOT:-$ROOT/cache}') -sh=once(sh, 'case "$(uname -s)" in Linux) OS=linux;; Darwin) OS=darwin;; *) fail \'This script supports Linux/macOS. On Windows use the .ps1 script.\';; esac\ncase "$(uname -m)" in x86_64|amd64) ARCH=x64;; arm64|aarch64) ARCH=arm64;; *) fail \'Unsupported CPU; use the documented source build on this platform.\';; esac\nPLATFORM="$OS-$ARCH"', '''case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac -if lmm_is_termux; then OS=android; fi -case "$(uname -m)" in - x86_64|amd64) ARCH=x64;; arm64|aarch64) ARCH=arm64;; - armv7l|armv8l|arm) [ "$OS" = android ] || fail '32-bit desktop Linux is not supported'; ARCH=arm;; - i386|i686) [ "$OS" = android ] || fail '32-bit desktop Linux is not supported'; ARCH=ia32;; - *) fail 'Unsupported CPU';; -esac -PLATFORM="$OS-$ARCH" -lmm_check_storage "$ROOT" || exit 1 -lmm_check_storage "$CACHE" || exit 1 -if [ "$OS" = android ] && [ "$TARGET" != lmm ]; then - compatible_node || fail 'In Termux, install native Node/npm: pkg install nodejs npm git; then rerun. Desktop Node cannot run on Android.' - command -v git >/dev/null 2>&1 || fail 'Pi/DSH need git: pkg install git' -fi''') -sh=once(sh, '''node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit((a===22&&b>=19)||a>=24?0:1)' >/dev/null 2>&1''', '''node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(((a===22&&b>=19)||a>=24)&&(process.argv[1]!=="android"||process.platform==="android")?0:1)' "$OS" >/dev/null 2>&1''') -sh=once(sh, ''' # Android uses bionic, not the glibc used by the Linux Node archives. - if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then - fail 'In Termux, install Node with: pkg install nodejs git termux-api; then rerun. Desktop Linux Node archives cannot run on Android.' - fi -''', ''' [ "$OS" != android ] || fail 'In Termux, run pkg install nodejs npm git; desktop Node archives are incompatible.' -''') -sh=once(sh, ''' if [ -n "${TERMUX_VERSION:-}" ] || [[ ${PREFIX:-} == */com.termux/files/usr ]]; then''', ''' if [ "$OS" = android ]; then''') -sh=once(sh, 'umask 077\nmkdir -p', '''umask 077 -if [ "$OS" = android ]; then - TMPDIR=$(lmm_temp_root); lmm_check_storage "$TMPDIR" || exit 1 - mkdir -p "$TMPDIR"; export TMPDIR - if [ "$TARGET" = dsh ]; then log 'DSH native dependencies have not been validated on Android.'; fi -fi -mkdir -p''') -sh=once(sh, ' "$work/bin/$entry" --version >/dev/null', ' node "$work/bin/$entry" --version >/dev/null') -sh=once(sh, ' bounded "$work/bin/pnpm" --version', ' bounded node "$work/bin/pnpm" --version') -sh=once(sh, " printf '#!/usr/bin/env bash\\n# Managed by LMM installers.\\n'", ''' if [ "$OS" = android ]; then printf '#!%s\\n' "$BASH" - else printf '#!/usr/bin/env bash\\n'; fi - printf '# Managed by LMM installers.\\n' ''') -sh=once(sh, ''' printf 'exec %s "$@"\\n' "$(quote_sh "$CLIENT")"''', ''' if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\\n' "$(quote_sh "$CLIENT")" - else printf 'exec %s %s "$@"\\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi''') -sh, hash_source=take(sh,'sha256','sh') -write('templates/lib/hash.sh', '''sha256() { - local digest - if command -v sha256sum >/dev/null 2>&1; then digest=$(sha256sum "$1") || return; printf '%s\\n' "${digest%% *}" - elif command -v shasum >/dev/null 2>&1; then digest=$(shasum -a 256 "$1") || return; printf '%s\\n' "${digest%% *}" - elif command -v openssl >/dev/null 2>&1; then digest=$(openssl dgst -sha256 "$1") || return; printf '%s\\n' "${digest##* }" - else printf 'Install a SHA-256 tool.\\n' >&2; return 1; fi -} -''') -sh, quote=take(sh,'quote_sh','sh'); write('templates/lib/quote.sh',quote) -sh, network=take_many(sh,['rank_urls','urls_for','download'],'sh') -network=once(network, "cat \"$probe_dir\"/* | sort -n -k1,1 -k2,2 | awk '{print $2}'", "cat \"$probe_dir\"/* | sort -n -k1,1 -k2,2 | while read -r elapsed index; do printf '%s\\n' \"$index\"; done") -write('templates/lib/download.sh',network) -sh, node=take_many(sh,['compatible_node','ensure_node','configure_npm','bounded','with_registry_retry','install_client'],'sh'); write('templates/lib/node.sh',node) -sh, pnpm=take(sh,'ensure_pnpm','sh'); sh, lmm=take(sh,'install_lmm','sh') -start=sh.index('if [ "$TARGET" = lmm ]; then install_lmm\n') -end=sh.index("PHASE='launchers and PATH'",start) -sh=sh[:start]+'install_tool\n'+sh[end:] -sh=once(sh,'@@CONSTANTS@@','@@CONSTANTS@@\n@@LIBRARIES@@') -start=sh.index(' if [ "$TARGET" != lmm ]; then\n if compatible_node;') -end=sh.index(" log 'Executable check complete",start) -write('templates/lib/node-check.sh',sh[start:end]) -sh=sh[:start]+'@@NODE_CHECK@@\n'+sh[end:] -sh=sh.replace('# Generated from templates/install.sh.in and versions.json. No sudo, no API keys.', '# Generated from templates/ and versions.json. Edit the source, not this file.') -write('templates/install.sh.in',sh) -write('templates/tools/pi.sh', '''install_tool() { - ensure_node; configure_npm - install_client @earendil-works/pi-coding-agent "$PI_VERSION" pi - PHASE='Pi LMM provider' - with_registry_retry node "$CLIENT" install "npm:@tokennotincluded/pi-lmm-provider@$PI_PROVIDER_VERSION" - if [ "$OS" = android ]; then log 'Optional clipboard: install the Termux:API app and pkg install termux-api. Open login links with termux-open-url.'; fi -} -''') -write('templates/tools/dsh.sh',pnpm+'''install_tool() { - ensure_node; configure_npm; ensure_pnpm - install_client @deepseek-ai/dsh "$DSH_VERSION" dsh - PHASE='DSH LMM provider' - local artifact="$CACHE/${DSH_PROVIDER_URL##*/}" - download "$DSH_PROVIDER_URL" "$artifact" "$DSH_PROVIDER_SHA256" - with_registry_retry node "$CLIENT" plugin --profile "$PROFILE" add "$artifact" --ignore-scripts --store-dir "$CACHE/pnpm" -} -''') -write('templates/tools/lmm.sh',lmm+'install_tool() { install_lmm; }\n') -ps=read('templates/install.ps1.in') -ps, common=take_many(ps,['Setting','QuoteArgument','Stop-InstallChild','Invoke-Bounded','Invoke-Native','Get-Hash'],'ps1') -write('templates/lib/common.ps1',common) -ps, network=take_many(ps,['Set-RequestProxy','New-DownloadRequest','Get-RankedUrls','Get-DownloadUrls','Receive-Stream','Get-VerifiedFile'],'ps1') -write('templates/lib/download.ps1',network) -ps, node=take_many(ps,['Test-Node','Install-Node','Set-NpmNetwork','Invoke-WithRegistryRetry','Install-Client'],'ps1') -write('templates/lib/node.ps1',node) -ps, shell=take(ps,'Assert-PiShell','ps1'); ps, pnpm=take(ps,'Install-Pnpm','ps1'); ps, lmm=take(ps,'Install-Lmm','ps1') -start=ps.index(" if ($Target -eq 'lmm') { Install-Lmm }\n") -end=ps.index(" $script:Phase='launcher and PATH'",start) -run=ps[start:end] -dsh_start=run.index(' Install-Pnpm\n') -dsh_end=run.rindex('\n }\n }') -dsh_body=run[dsh_start:dsh_end] -write('templates/tools/pi.ps1',shell+'''function Install-Tool { - Install-Node; Set-NpmNetwork - Install-Client '@earendil-works/pi-coding-agent' $PiVersion 'pi' - $script:Phase='Pi LMM provider' - Invoke-WithRegistryRetry $script:Client @('install',"npm:@tokennotincluded/pi-lmm-provider@$PiProviderVersion") -} -''') -write('templates/tools/dsh.ps1',pnpm+'function Install-Tool {\n Install-Node; Set-NpmNetwork\n'+dsh_body+'\n}\n') -write('templates/tools/lmm.ps1',lmm+'function Install-Tool { Install-Lmm }\n') -ps=ps[:start]+' Install-Tool\n'+ps[end:] -ps=once(ps,'@@CONSTANTS@@','@@CONSTANTS@@\n@@LIBRARIES@@') -write('templates/install.ps1.in',ps) -menu=read('templates/menu.sh.in') -menu,_=take(menu,'hash_file','sh') -menu=menu.replace('hash_file ', 'sha256 ') -menu=once(menu, 'set -u\n', 'set -u\n@@LIBRARIES@@\n') -menu=once(menu,'root=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}', 'root=$(lmm_root)') -menu=once(menu,'work=$(mktemp -d "${TMPDIR:-/tmp}/lmm-menu.XXXXXXXX") || exit 1', '''umask 077 -temp_root=$(lmm_temp_root) -lmm_check_storage "$temp_root" || exit 1 -mkdir -p "$temp_root" || exit 1 -work=$(mktemp -d "$temp_root/lmm-menu.XXXXXXXX") || exit 1''') -write('templates/menu.sh.in',menu) -use=read('lmm-use.sh') -use=once(use, 'ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}', '@@LIBRARIES@@\nROOT=$(lmm_root)') -write('templates/use.sh.in',use) -test=read('tests/test_installers.py') -test=once(test,"for name in ['curl','uname','node','npm']:","for name in ['curl','uname','node','npm','realpath']:") -test=once(test,"if name=='uname':print('Linux' if '-s' in a else 'x86_64')", "if name=='uname':print(os.environ.get('LMM_TEST_OS','Linux') if '-s' in a else os.environ.get('LMM_TEST_ARCH','x86_64'))\nelif name=='realpath':print(os.path.realpath(a[-1]))") -test=once(test," if '-e' in a:sys.exit(0 if os.environ.get('LMM_TEST_NODE_OK','1')=='1' else 1)", """ if '-e' in a: - ok=os.environ.get('LMM_TEST_NODE_OK','1')=='1' - if a[-1]=='android':ok=ok and os.environ.get('LMM_TEST_NODE_PLATFORM','android')=='android' - sys.exit(0 if ok else 1) - if a and Path(a[0]).is_file():os.execv('/bin/bash',['bash',a[0]]+a[1:])""") -write('tests/test_installers.py',test) -v=json.loads(read('versions.json'));v['script_version']='2026.09.20.2';write('versions.json',json.dumps(v,indent=2)+'\n') -menus=read('tools/generate_menus.py') -menus=once(menus, 'import argparse, hashlib, subprocess', 'import argparse, hashlib, subprocess\nfrom render import emit, libraries') -start=menus.index(' data=text.encode(') -menus=menus[:start]+''' if ext=='sh': text=text.replace('@@LIBRARIES@@',libraries('lib/root.sh','lib/hash.sh','lib/termux.sh')) - emit(f'menu.{ext}',text,args.check,'utf-8-sig' if ext=='ps1' else 'utf-8') -''' -write('tools/generate_menus.py',menus) -policy=read('tests/test_official_policy.py') -start=policy.index("if __name__ == '__main__':") -policy=policy[:start]+read('tools/_termux_tests.txt')+'\n'+policy[start:] -write('tests/test_official_policy.py',policy) -readme=read('README.md');pos=readme.index('## 直接运行与更新') -readme=readme[:pos]+'''## Termux(原生 Android) - -先准备 Termux 自己的依赖,不使用桌面 Linux 的 Node 压缩包: - -```sh -pkg install bash curl coreutils nodejs npm git -curl -fsSL https://api.lmm.best/scripts/menu.sh | bash -``` - -安装目录保持在 `$HOME`。脚本检查 Node 是否为 Android 版本;拒绝把安装、缓存或临时目录放在 `/sdcard`、`/storage`,包括指向共享存储的链接。未设置 `TMPDIR` 时使用 `$PREFIX/tmp`,启动器使用当前 Bash 的绝对路径和明确的 Node 入口。 - -文本剪贴板另需 Termux:API 应用和 `pkg install termux-api`,不作为安装的强制条件。浏览器没有自动打开时,可手动用 `termux-open-url` 打开登录地址。脚本不申请存储权限、不清空缓存、不执行系统升级。 - -Pi 的安装方式遵循官方 Termux 文档。DSH 的 Android 原生依赖、LMM CLI 的 Android 源码构建尚未经真机验证;LMM CLI 没有 Android 预编译包。不要把环境模拟测试当成真机验证。 - -'''+readme[pos:] -write('README.md',readme) -maintenance=read('docs/maintenance.md') -maintenance=maintenance.replace('只修改模板和版本清单,再生成根目录脚本。公开脚本必须能独立运行;不要添加远程 `source` 依赖。', '''公共函数放在 `templates/lib/`,Pi、DSH、LMM 的差异放在 `templates/tools/`。`tools/render.py` 负责共用的文本读取、完整管道包装和生成检查;`tools/generate.py` 只组装当前工具需要的代码、版本和哈希。菜单复用同一份根目录、哈希和 Termux 函数,`lmm-use.sh` 也从模板生成。 - -只修改这些源文件和版本清单,再生成根目录脚本。`.sh` 与 `.ps1` 都保留单文件入口,不在运行时下载或 `source` 公共库;网站现有同步清单无需增加运行时文件。Windows/Linux/macOS 的编码和完整脚本校验保持不变。''') -write('docs/maintenance.md',maintenance) diff --git a/tools/_termux_tests.txt b/tools/_termux_tests.txt deleted file mode 100644 index 93b6288..0000000 --- a/tools/_termux_tests.txt +++ /dev/null @@ -1,136 +0,0 @@ - -class TermuxAndCompositionTests(unittest.TestCase): - def setUp(self): - self.fixture = fixtures.InstallerTests() - self.fixture.setUp() - self.prefix = self.fixture.base / 'termux prefix' - (self.prefix / 'tmp').mkdir(parents=True) - self.env = {'TERMUX_VERSION': 'test', 'PREFIX': str(self.prefix), 'TMPDIR': ''} - - def tearDown(self): - self.fixture.tearDown() - - def test_termux_32_bit_native_node_is_supported(self): - for arch in ('armv7l', 'i686'): - with self.subTest(arch=arch): - r = self.fixture.run_script('pi', env=self.env | {'LMM_TEST_ARCH': arch}) - self.assertEqual(r.returncode, 0, r.stderr) - self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) - - def test_desktop_32_bit_is_not_misidentified_as_termux(self): - r = self.fixture.run_script('pi', env={'TERMUX_VERSION': '', 'TERMUX_APP__PACKAGE_NAME': '', 'PREFIX': '', 'LMM_TEST_ARCH': 'armv7l'}) - self.assertNotEqual(r.returncode, 0) - self.assertFalse(self.fixture.root.exists()) - - def test_termux_requires_native_android_node(self): - r = self.fixture.run_script('pi', env=self.env | {'LMM_TEST_NODE_PLATFORM': 'linux'}) - self.assertNotEqual(r.returncode, 0) - self.assertIn('native Node/npm', r.stderr) - self.assertFalse(self.fixture.root.exists()) - self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) - - def test_prefix_alone_identifies_termux(self): - env = self.env | {'TERMUX_VERSION': '', 'TERMUX_APP__PACKAGE_NAME': '', 'PREFIX': str(self.fixture.base / 'com.termux/files/usr'), 'LMM_TEST_NODE_OK': '0'} - r = self.fixture.run_script('pi', env=env) - self.assertNotEqual(r.returncode, 0) - self.assertIn('In Termux', r.stderr) - self.assertFalse(self.fixture.root.exists()) - - def test_termux_check_is_read_only(self): - r = self.fixture.run_script('pi', '--check', env=self.env) - self.assertIn(r.returncode, (0, 1)) - self.assertFalse(self.fixture.root.exists()) - self.assertFalse(any(name in ('curl', 'npm') and 'install' in args for name, args in self.fixture.calls())) - - def test_shared_storage_is_rejected_before_installation(self): - for path in ('/sdcard/lmm-tools', '/storage/emulated/0/lmm-tools', '/mnt/media_rw/card/lmm-tools'): - with self.subTest(path=path): - r = self.fixture.run_script('pi', '--root', path, env=self.env) - self.assertNotEqual(r.returncode, 0) - self.assertIn('shared storage', r.stderr) - self.assertFalse(any(name == 'curl' for name, _ in self.fixture.calls())) - - def test_shared_storage_symlink_ancestor_is_rejected(self): - alias = self.fixture.base / 'shared alias' - alias.symlink_to('/storage/emulated/0', target_is_directory=True) - r = self.fixture.run_script('pi', '--root', str(alias / 'tools'), env=self.env) - self.assertNotEqual(r.returncode, 0) - self.assertIn('shared storage', r.stderr) - - def test_shared_cache_and_temp_are_rejected(self): - for key in ('LMM_CACHE_ROOT', 'TMPDIR'): - with self.subTest(key=key): - r = self.fixture.run_script('pi', env=self.env | {key: '/sdcard/lmm-cache'}) - self.assertNotEqual(r.returncode, 0) - self.assertIn('shared storage', r.stderr) - self.assertFalse((self.fixture.root / 'bin/pi').exists()) - - def test_launcher_uses_absolute_bash_and_explicit_node(self): - r = self.fixture.run_script('pi', env=self.env) - self.assertEqual(r.returncode, 0, r.stderr) - body = (self.fixture.root / 'bin/pi').read_text() - self.assertNotIn('/usr/bin/env', body) - self.assertIn('/node', body) - self.assertTrue(body.startswith('#!/')) - version = fixtures.subprocess.run([str(self.fixture.root / 'bin/pi'), '--version'], env=self.fixture.env | self.env, capture_output=True, text=True) - self.assertEqual(version.returncode, 0, version.stderr) - - def test_menu_temp_fallback_uses_prefix_not_desktop_tmp(self): - source = (P / 'templates/lib/termux.sh').read_text(encoding='utf-8') - r = fixtures.subprocess.run(['bash', '-c', source + '\nlmm_temp_root'], env=self.fixture.env | self.env, capture_output=True, text=True) - self.assertEqual(r.returncode, 0, r.stderr) - self.assertEqual(r.stdout.strip(), str(self.prefix / 'tmp')) - explicit = str(self.fixture.base / 'explicit temp') - r = fixtures.subprocess.run(['bash', '-c', source + '\nlmm_temp_root'], env=self.fixture.env | self.env | {'TMPDIR': explicit}, capture_output=True, text=True) - self.assertEqual(r.stdout.strip(), explicit) - - def test_termux_does_not_reuse_cached_linux_lmm(self): - v = fixtures.json.loads((P / 'versions.json').read_text()) - app = self.fixture.root / 'apps/lmm' / (v['lmm_version'] + '-linux-x64') - app.mkdir(parents=True) - (app / '.lmm-managed').write_text(v['lmm_version']) - binary = app / 'lmm' - binary.write_text('#!/bin/sh\nexit 0\n') - binary.chmod(0o755) - r = self.fixture.run_script('lmm', env=self.env) - self.assertNotEqual(r.returncode, 0) - self.assertIn('No Android LMM CLI binary', r.stderr) - self.assertFalse((self.fixture.root / 'bin/lmm').exists()) - - def test_relative_install_root_works(self): - relative = fixtures.os.path.relpath(self.fixture.root) - r = self.fixture.run_script('pi', '--root', relative) - self.assertEqual(r.returncode, 0, r.stderr) - self.assertTrue((self.fixture.root / 'bin/pi').exists()) - - def test_generated_scripts_omit_other_target_implementations(self): - for ext, pnpm, lmm, node in [('sh', 'ensure_pnpm()', 'install_lmm()', 'ensure_node()'), ('ps1', 'function Install-Pnpm', 'function Install-Lmm', 'function Install-Node')]: - pi = (P / f'pi.{ext}').read_text() - dsh = (P / f'dsh.{ext}').read_text() - cli = (P / f'lmm.{ext}').read_text() - self.assertNotIn(pnpm, pi) - self.assertNotIn(lmm, pi) - self.assertNotIn(lmm, dsh) - self.assertNotIn(pnpm, cli) - self.assertNotIn(node, cli) - self.assertNotIn('DSH_PROVIDER_SHA256=', pi) - - def test_shared_helpers_are_embedded_once_and_remain_offline(self): - for target in ('pi', 'dsh', 'lmm', 'menu'): - body = (P / f'{target}.sh').read_text(encoding='utf-8') - self.assertEqual(body.count('lmm_is_termux() {'), 1) - self.assertEqual(body.count('sha256() {'), 1) - self.assertEqual(body.count('lmm_root() {'), 1) - self.assertNotIn('@@LIBRARIES@@', body) - self.assertNotIn('source https:', body) - fixtures.subprocess.run(['python3', str(P / 'tools/generate_menus.py'), '--check'], check=True) - - def test_every_generated_shell_help_is_standalone(self): - for target in ('pi', 'dsh', 'lmm', 'lmm-use', 'menu'): - body = (P / f'{target}.sh').read_text(encoding='utf-8') - r = fixtures.subprocess.run(['bash', '-s', '--', '--help'], input=body, env=self.fixture.env | self.env, text=True, capture_output=True) - self.assertEqual(r.returncode, 0, r.stderr) - partial = body.rsplit('if true; then', 1)[0] - r = fixtures.subprocess.run(['bash', '-s'], input=partial, env=self.fixture.env | self.env, text=True, capture_output=True) - self.assertFalse(self.fixture.root.exists()) - diff --git a/tools/generate.py b/tools/generate.py index 5c40cb4..73d2a3f 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -64,6 +64,10 @@ def main() -> None: parts.append(f'tools/{target}.{ext}') body = template(f'install.{ext}.in').replace('@@LIBRARIES@@', libraries(*parts)) body = body.replace('@@NODE_CHECK@@', template('lib/node-check.sh') if target != 'lmm' and ext == 'sh' else '') + client = target != 'lmm' + body = body.replace('@@CLIENT_STATE@@', 'INSTALL_NODE=1 BOOTSTRAP=1 NPM_SELECTED=0' if client else '') + body = body.replace('@@NO_BOOTSTRAP@@', 'INSTALL_NODE=0; BOOTSTRAP=0' if client else ':') + body = body.replace('@@NO_INSTALL_NODE@@', 'INSTALL_NODE=0' if client else ':') body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body)) if ext == 'sh': body = standalone(body, 'lmm_install_main') diff --git a/tools/generate_menus.py b/tools/generate_menus.py index 1796bb3..3d52986 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -2,6 +2,7 @@ """Pin menu payloads to a reviewed installer revision, not a moving branch.""" from pathlib import Path import argparse, hashlib, subprocess +from render import emit, libraries p=Path(__file__).resolve().parents[1] a=argparse.ArgumentParser();a.add_argument('--check',action='store_true');args=a.parse_args() revision='95c162c2031ecba34942b2a91631c1ec1f6f3d05' @@ -13,9 +14,5 @@ sha=hashlib.sha256(payload).hexdigest() lines.append(f"{name}) printf '%s' '{sha}';;" if ext=='sh' else f" '{name}' = '{sha}'") text=(p/f'templates/menu.{ext}.in').read_text(encoding='utf-8').replace('@@REVISION@@',revision).replace('@@HASHES@@','\n'.join(lines)) - data=text.encode('utf-8-sig' if ext=='ps1' else 'utf-8') - path=p/f'menu.{ext}' - if args.check: - assert path.read_bytes()==data, f'{path} is stale' - else: - path.write_bytes(data);path.chmod(0o755 if ext=='sh' else 0o644) + if ext=='sh': text=text.replace('@@LIBRARIES@@',libraries('lib/root.sh','lib/hash.sh','lib/termux.sh')) + emit(f'menu.{ext}',text,args.check,'utf-8-sig' if ext=='ps1' else 'utf-8') diff --git a/versions.json b/versions.json index c00f33d..55fe8ec 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "script_version": "2026.09.20.1", + "script_version": "2026.09.20.2", "node_version": "24.21.0", "node_sha256": { "linux-x64": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", From 496a0ac408e6360f7f4aa298c80273a320e84e04 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:43:55 +0000 Subject: [PATCH 13/39] fix: pin menus to shared Termux-aware installers --- menu.ps1 | 8 ++++---- menu.sh | 10 +++++----- tools/generate_menus.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/menu.ps1 b/menu.ps1 index 0c8e424..4a1b7aa 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -4,9 +4,9 @@ param([switch]$Help) $ErrorActionPreference = 'Stop' if ($Help) { Write-Output 'LMM menu: .\menu.ps1 [-Help]. Interactive terminal required.'; exit 0 } $hashes = @{ - 'pi.ps1' = '64d39c29e77d7db50fb62ca306f998e24a1839640710c147ead900179c7f40ac' - 'dsh.ps1' = '163a5e60322869b5ad55dc9eb918f0acab25fdb8301bfbba026cdd13fd88f0f7' - 'lmm.ps1' = 'ce77455667245f02adbb25c4a9d36b1ba9c05e0fdc4b7466cd84e08d13397d69' + 'pi.ps1' = '74aeaf6f7e8e7a2c9975dd5dc3abbc5f66a20138e44746dd95d8b4f2b8ac8cb7' + 'dsh.ps1' = '115ee0030a5951a089321df877c4725d8081e2a0c139f6270662c2ab02958efc' + 'lmm.ps1' = '789dd5bd0b9d2dada7c1b23dac0aa077c8e9134b69ca39dff7d3336b9f092c23' 'lmm-use.ps1' = 'ac137e30b6609580cb6ee4fbb60ed77ed01ec6153b733140540d5bc38d9179c1' } $network = 'auto' @@ -25,7 +25,7 @@ function Fetch-Script([string]$Name) { $path = Join-Path $work $Name if ((Test-Path -LiteralPath $path) -and ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -eq $hashes[$Name])) { return $path } Write-Host '正在获取并校验安装程序(下载慢时会重试)…' - foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/95c162c2031ecba34942b2a91631c1ec1f6f3d05/$Name")) { + foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/b21e36ecfdae2a1c97f86177f841635552ec40db/$Name")) { for ($attempt = 1; $attempt -le 3; $attempt++) { try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 -ErrorAction Stop diff --git a/menu.sh b/menu.sh index ebdf113..8aa2c55 100755 --- a/menu.sh +++ b/menu.sh @@ -47,10 +47,10 @@ if ! { exec 3/dev/null; then fi command -v curl >/dev/null 2>&1 || { printf '请先安装 curl。\n' >&2; exit 2; } expected_hash() { case "$1" in -pi.sh) printf '%s' 'ae5f009593005768c10266618049135d0a9a42eb05f2d357ba5dbc4fccb533bf';; -dsh.sh) printf '%s' 'd4d18452773a93c8f22b1722ff4fce0d32af870510065d3a1472530b477e23b0';; -lmm.sh) printf '%s' 'bc5367e6bd2283680a1906d4748f808dcd9c4ef5e4c428d754bbef11ce48f5bc';; -lmm-use.sh) printf '%s' '5240b7192e0fcb7700fd76b0d375f528422d6f38e751d220e9202ac6b6f8d9c5';; +pi.sh) printf '%s' '0dcc9f61ece125c9b0dacdcad28429b98e5d886ce8f2ef3b9f219faf4109282c';; +dsh.sh) printf '%s' '9ebfbc135329b9a1b2a0a36dd372456740ba961042494aec6ecaf9521df6ceb3';; +lmm.sh) printf '%s' 'bba6547ccb32f6cbafc9d63f662e32959089b448922c375d22931ed135b44d66';; +lmm-use.sh) printf '%s' '8f5cb27ef99bc3fd51a5b85e5aba0418f791e3cae03cd0ea624384b3dd50a06b';; *) return 1;; esac; } ask() { printf '%s' "$1"; IFS= read -r answer <&3 || exit 0; } @@ -69,7 +69,7 @@ fetch_script() { expected=$(expected_hash "$name") || return 1 if [ -f "$work/$name" ] && [ "$(sha256 "$work/$name")" = "$expected" ]; then return 0; fi printf '正在获取并校验安装程序(下载慢时会重试)…\n' - for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/95c162c2031ecba34942b2a91631c1ec1f6f3d05/$name"; do + for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/b21e36ecfdae2a1c97f86177f841635552ec40db/$name"; do if curl -q -fSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 2 --retry-delay 2 "$url" -o "$work/download"; then if [ "$(sha256 "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi printf '文件版本或校验不匹配,尝试固定版本备用地址。\n' >&2 diff --git a/tools/generate_menus.py b/tools/generate_menus.py index 3d52986..f3cd853 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -5,7 +5,7 @@ from render import emit, libraries p=Path(__file__).resolve().parents[1] a=argparse.ArgumentParser();a.add_argument('--check',action='store_true');args=a.parse_args() -revision='95c162c2031ecba34942b2a91631c1ec1f6f3d05' +revision='b21e36ecfdae2a1c97f86177f841635552ec40db' for ext in ('sh','ps1'): lines=[] for stem in ('pi','dsh','lmm','lmm-use'): From ce6aea96cd73d633424daaa6e2e25ac18fd33b5c Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 16:45:23 +0800 Subject: [PATCH 14/39] docs: clarify native Termux dependencies and remove preparation workflow --- .github/workflows/_refactor.yml | 88 --------------------------------- README.md | 64 +++++++++++------------- 2 files changed, 30 insertions(+), 122 deletions(-) delete mode 100644 .github/workflows/_refactor.yml diff --git a/.github/workflows/_refactor.yml b/.github/workflows/_refactor.yml deleted file mode 100644 index ea1ec24..0000000 --- a/.github/workflows/_refactor.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Prepare shared installers -on: - push: - branches: [codex/official-installers-20260920] - paths: [tools/_termux_refactor.py, tools/_termux_tests.txt, .github/workflows/_refactor.yml] -permissions: - contents: write -jobs: - prepare: - runs-on: ubuntu-latest - timeout-minutes: 8 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 - with: - node-version: 24.21.0 - - name: Refactor and validate - run: | - python3 tools/_termux_refactor.py - python3 - <<'PY' - from pathlib import Path - p=Path('templates/install.sh.in') - text=p.read_text(encoding='utf-8') - replacements={ - 'SOURCE=0 INSTALL_NODE=1':'SOURCE=0', - 'PHASE=arguments BOOTSTRAP=1':'PHASE=arguments', - 'NPM_SELECTED=0\n':'@@CLIENT_STATE@@\n', - '--no-bootstrap) INSTALL_NODE=0; BOOTSTRAP=0;;':'--no-bootstrap) @@NO_BOOTSTRAP@@;;', - '--no-install-node) INSTALL_NODE=0;;':'--no-install-node) @@NO_INSTALL_NODE@@;;', - } - for old,new in replacements.items(): - assert text.count(old)==1, old - text=text.replace(old,new) - p.write_text(text,encoding='utf-8') - p=Path('tools/generate.py') - text=p.read_text(encoding='utf-8') - old=" body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body))" - new=""" client = target != 'lmm' - body = body.replace('@@CLIENT_STATE@@', 'INSTALL_NODE=1 BOOTSTRAP=1 NPM_SELECTED=0' if client else '') - body = body.replace('@@NO_BOOTSTRAP@@', 'INSTALL_NODE=0; BOOTSTRAP=0' if client else ':') - body = body.replace('@@NO_INSTALL_NODE@@', 'INSTALL_NODE=0' if client else ':') - body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body))""" - new='\n'.join(' '+line.strip() for line in new.splitlines()) - assert text.count(old)==1 - p.write_text(text.replace(old,new),encoding='utf-8') - PY - python3 tools/generate.py - python3 tools/generate_menus.py - python3 tools/generate.py --check - python3 tools/generate_menus.py --check - python3 tests/test_installers.py - python3 tests/test_official_policy.py - shellcheck *.sh - pwsh -NoProfile -File tests/test-powershell.ps1 - python3 - <<'PY' - from pathlib import Path - import subprocess - names=[f'{name}.{ext}' for ext in ('sh','ps1') for name in ('pi','dsh','lmm')] - before=after=0 - for name in names: - old=len(subprocess.check_output(['git','show',f'eccdf9ada0ff823b1512d89f380430b115cff8dc:{name}'])) - new=Path(name).stat().st_size - before+=old;after+=new - print(f'{name}: {old} -> {new} bytes') - print(f'TOTAL: {before} -> {after} bytes; reduction {(1-after/before)*100:.1f}%') - assert after < before - PY - rm tools/_termux_refactor.py tools/_termux_tests.txt - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'refactor: share installer helpers and handle native Termux paths and launchers' - python3 - <<'PY' - from pathlib import Path - import re, subprocess - p=Path('tools/generate_menus.py') - sha=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() - text,count=re.subn(r"revision='[0-9a-f]{40}'",f"revision='{sha}'",p.read_text(encoding='utf-8')) - assert count==1 - p.write_text(text,encoding='utf-8') - PY - python3 tools/generate_menus.py - python3 tools/generate_menus.py --check - git add tools/generate_menus.py menu.sh menu.ps1 - git commit -m 'fix: pin menus to shared Termux-aware installers' - git push origin HEAD:codex/official-installers-20260920 diff --git a/README.md b/README.md index 958aa90..6ea13ff 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,26 @@ irm https://api.lmm.best/scripts/menu.ps1 | iex 选择工具,再选择安装、检查或启动。默认不改 PATH、不启动工具、不登录账号。菜单入口执行远程代码;需要先审查时,下载脚本后再运行。 -Pi 在 Windows 上需要 **Git Bash**,或 Pi 设置中的有效 `shellPath`。脚本只检查,不覆盖你的设置,也不自动安装系统软件。 +Pi 在 Windows 上需要 Git Bash,或 Pi 设置中的有效 `shellPath`。脚本只检查,不覆盖设置,也不自动安装系统软件。 + +## Termux(原生 Android) + +先准备 Termux 自己的依赖: + +```sh +pkg install bash curl coreutils nodejs npm git +curl -fsSL https://api.lmm.best/scripts/menu.sh | bash +``` + +安装目录保持在 `$HOME`。脚本确认 Node 是 Android 版本,不下载桌面 Linux 二进制。安装、缓存和临时目录不能放在 `/sdcard`、`/storage`,包括指向共享存储的链接。未设置 `TMPDIR` 时使用 `$PREFIX/tmp`;启动器使用当前 Bash 的绝对路径和明确的 Node 入口。 + +文本剪贴板另需 Termux:API 应用和 `pkg install termux-api`,不作为强制依赖。浏览器未打开时可用 `termux-open-url` 打开登录地址。脚本不申请存储权限、不清空用户缓存、不执行系统升级。 + +Pi 的安装方式遵循官方 Termux 文档。DSH 的 Android 原生依赖、LMM CLI 的 Android 源码构建尚未经真机验证;LMM CLI 没有 Android 预编译包。环境模拟测试不等于真机验证。 ## 安装后怎么用 -没有加入 PATH 时,用安装结束打印的完整路径代替下方的 `pi`、`dsh`、`lmm`。 +没有加入 PATH 时,用安装结束打印的完整路径代替下方的工具名。 | 工具 | 启动与登录 | 常用操作 | |---|---|---| @@ -32,37 +47,22 @@ Pi 在 Windows 上需要 **Git Bash**,或 Pi 设置中的有效 `shellPath`。 DSH 插件按 profile 安装,默认 `web`。使用 `headless` 前,先在相同 `DSH_HOME` 的 Web profile 完成登录。不要把 Pi、DSH 的凭据文件复制给其他客户端。 -LMM CLI 的实际软件安装、接入、恢复尚未完成;`lmm setup pi --dry-run` 仅预览。`doctor` / `setup --dry-run` 返回 3 时不代表全部成功。Linux 登录需要可用的 Secret Service;SSH 或容器中不一定具备。 - -## Termux(原生 Android) - -先准备 Termux 自己的依赖,不使用桌面 Linux 的 Node 压缩包: - -```sh -pkg install bash curl coreutils nodejs npm git -curl -fsSL https://api.lmm.best/scripts/menu.sh | bash -``` - -安装目录保持在 `$HOME`。脚本检查 Node 是否为 Android 版本;拒绝把安装、缓存或临时目录放在 `/sdcard`、`/storage`,包括指向共享存储的链接。未设置 `TMPDIR` 时使用 `$PREFIX/tmp`,启动器使用当前 Bash 的绝对路径和明确的 Node 入口。 - -文本剪贴板另需 Termux:API 应用和 `pkg install termux-api`,不作为安装的强制条件。浏览器没有自动打开时,可手动用 `termux-open-url` 打开登录地址。脚本不申请存储权限、不清空缓存、不执行系统升级。 - -Pi 的安装方式遵循官方 Termux 文档。DSH 的 Android 原生依赖、LMM CLI 的 Android 源码构建尚未经真机验证;LMM CLI 没有 Android 预编译包。不要把环境模拟测试当成真机验证。 +LMM CLI 的实际软件安装、接入、恢复尚未完成;`lmm setup pi --dry-run` 仅预览。`doctor` / `setup --dry-run` 返回 3 时不代表全部成功。Linux 登录需要可用的 Secret Service,SSH 或容器中不一定具备。 ## 直接运行与更新 ```sh curl -fsSLo pi.sh https://api.lmm.best/scripts/pi.sh bash pi.sh # 安装 -bash pi.sh --check # 只检查可执行程序,不代表登录成功 -bash pi.sh --update # 重装脚本固定版本,不追踪上游 latest +bash pi.sh --check # 只检查,不代表登录成功 +bash pi.sh --update # 重装固定版本,不追踪 latest bash pi.sh --launch # 安装后启动 bash pi.sh --add-path # 明确允许加入用户 PATH bash pi.sh --network china # 镜像优先;官方源可用 official bash pi.sh --help # 全部参数 ``` -Windows 对应参数为 `-Check`、`-Update`、`-Launch`、`-AddPath`、`-Network china`、`-Help`。例如: +Windows 对应参数为 `-Check`、`-Update`、`-Launch`、`-AddPath`、`-Network china`、`-Help`: ```powershell Invoke-WebRequest https://api.lmm.best/scripts/pi.ps1 -OutFile pi.ps1 @@ -70,28 +70,24 @@ powershell -ExecutionPolicy Bypass -File .\pi.ps1 powershell -ExecutionPolicy Bypass -File .\pi.ps1 -Check ``` -安装 DSH 或 LMM CLI 时,把文件名中的 `pi` 换成 `dsh` 或 `lmm`。DSH 支持 `--profile headless` / `-Profile headless`。安装脚本固定的版本见 [versions.json](versions.json);兼容版本升级时同时更新宿主和插件,不单独追新宿主。 +安装 DSH 或 LMM CLI 时,把文件名中的 `pi` 换成 `dsh` 或 `lmm`。DSH 可选 `--profile headless` / `-Profile headless`。固定版本见 [versions.json](versions.json),宿主与插件须一起验证后升级。 ## 环境与故障 -客户端采用用户目录下的独立安装,不覆盖系统 Node 或全局 npm 包。默认目录:Unix 为 `${XDG_DATA_HOME:-~/.local/share}/lmm-tools`,Windows 为 `%LOCALAPPDATA%\lmm-tools`;可用 `LMM_INSTALL_ROOT` 或 `--root` / `-Root` 修改。 +默认目录:Unix 为 `${XDG_DATA_HOME:-~/.local/share}/lmm-tools`,Windows 为 `%LOCALAPPDATA%\lmm-tools`;可用 `LMM_INSTALL_ROOT` 或 `--root` / `-Root` 修改。不覆盖系统 Node 或全局 npm 包。 + +Pi 使用官方的 `npm install --ignore-scripts`,接受已有的 `ignore-scripts=true`。DSH 的原生构建策略单独处理,不解除用户的构建限制。Node 要求为 22.19+ 的 22.x 或 24+。 -Pi 按官方文档使用 `npm install --ignore-scripts`,接受已有的 `ignore-scripts=true`。DSH 有原生依赖,仍使用单独的构建策略;脚本不会偷偷解除用户的构建限制。Node 要求为 22.19+ 的 22.x 或 24+。 +下载失败时检查 HTTPS 代理和证书,尝试 `--network official` 或 `china`,不要关闭 TLS 校验。Alpine / musl 需要先安装系统提供的兼容 Node/npm。 -| 情况 | 处理 | -|---|---| -| Windows 提示缺少 Bash | 安装 Git for Windows 后重新打开终端;自定义 Bash 用 Pi 的 `shellPath` | -| 下载失败或停滞 | 检查 HTTPS 代理/证书,尝试 `--network official` 或 `china`;不要关闭 TLS 校验 | -| Termux 的 Pi 安装 | 先用 `pkg install nodejs git termux-api` 安装原生依赖;脚本不会下载桌面 Linux Node 代替 Android Node | -| Alpine / musl | 先用系统包管理器安装兼容的 Node/npm;官方桌面 Node 压缩包使用 glibc | -| LMM CLI 预编译包不兼容 | 当前仅 Linux x64(glibc 2.39+)、macOS arm64、Windows x64;没有 Android 包。已有 Rust 1.88+ 和编译工具时可尝试 `--from-source` | +LMM CLI 预编译包仅提供 Linux x64(glibc 2.39+)、macOS arm64、Windows x64。其他平台可在准备 Rust 1.88+ 和编译工具后尝试 `--from-source`,不保证所有平台都能构建。 -下载缓存、校验失败处理、PATH 恢复、卸载注意事项和维护命令见 [维护说明](docs/maintenance.md)。 +公共函数、生成方式、缓存、PATH 恢复和卸载注意事项见 [维护说明](docs/maintenance.md)。发布脚本仍可单文件运行,不需要另外下载公共函数库。 ## 文档依据 [Pi 安装](https://pi.dev/docs/latest/quickstart) · [Windows](https://pi.dev/docs/latest/windows) · [Termux](https://pi.dev/docs/latest/termux) · [Pi 包管理](https://pi.dev/docs/latest/packages) · [DSH 官方 README](https://github.com/deepseek-ai/deepseek-harness/blob/master/README.md) -[LMM Pi 插件](https://github.com/TokenNotIncluded/pi-lmm-provider) · [LMM DSH 插件](https://github.com/TokenNotIncluded/dsh-lmm-provider) +[Termux 执行环境](https://github.com/termux/termux-packages/wiki/Termux-execution-environment) · [Termux Node/npm 包定义](https://github.com/termux/termux-packages/blob/master/packages/nodejs/build.sh) · [LMM Pi 插件](https://github.com/TokenNotIncluded/pi-lmm-provider) · [LMM DSH 插件](https://github.com/TokenNotIncluded/dsh-lmm-provider) -官方文档说明宿主的使用方法;这里的隔离目录、镜像、固定版本和 LMM 登录属于本项目的集成选择,不是官方安装器。 +隔离目录、镜像、固定版本和 LMM 登录是本项目的集成选择,不是官方安装器。 From 5a258b9209c5f9655eda5987778140fa3e0c9eae Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 17:30:35 +0800 Subject: [PATCH 15/39] test: add buffered runtime library loaders and regression coverage --- .github/workflows/_runtime.yml | 68 +++++++++++++ templates/load.ps1.in | 33 +++++++ templates/load.sh.in | 27 ++++++ tests/test-library-loader.ps1 | 55 +++++++++++ tests/test_library_loader.py | 171 +++++++++++++++++++++++++++++++++ tools/_runtime_refactor.py | 142 +++++++++++++++++++++++++++ 6 files changed, 496 insertions(+) create mode 100644 .github/workflows/_runtime.yml create mode 100644 templates/load.ps1.in create mode 100644 templates/load.sh.in create mode 100644 tests/test-library-loader.ps1 create mode 100644 tests/test_library_loader.py create mode 100644 tools/_runtime_refactor.py diff --git a/.github/workflows/_runtime.yml b/.github/workflows/_runtime.yml new file mode 100644 index 0000000..c485b75 --- /dev/null +++ b/.github/workflows/_runtime.yml @@ -0,0 +1,68 @@ +name: Prepare runtime library loaders +on: + push: + branches: [codex/official-installers-20260920] + paths: + - tools/_runtime_refactor.py + - templates/load.*.in + - tests/test_library_loader.py + - tests/test-library-loader.ps1 + - .github/workflows/_runtime.yml +permissions: + contents: write +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24.21.0 + - name: Apply changes, generate and test + run: | + python3 tools/_runtime_refactor.py + python3 tools/generate.py + python3 tools/generate.py --check + python3 tools/generate_menus.py --check + python3 tests/test_installers.py + python3 tests/test_official_policy.py + python3 tests/test_library_loader.py + shellcheck *.sh + pwsh -NoProfile -File tests/test-powershell.ps1 + pwsh -NoProfile -File tests/test-library-loader.ps1 + python3 - <<'PY' + from pathlib import Path + import subprocess + before=after=0 + for ext in ('sh','ps1'): + for target in ('pi','dsh','lmm'): + name=f'{target}.{ext}' + old=len(subprocess.check_output(['git','show',f'ce6aea96cd73d633424daaa6e2e25ac18fd33b5c:{name}'])) + new=Path(name).stat().st_size + before+=old;after+=new + print(f'{name}: {old} -> {new} bytes') + print(f'TOTAL: {before} -> {after}; reduction {(1-after/before)*100:.1f}%') + assert after < before * .7 + PY + rm tools/_runtime_refactor.py + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'refactor: fetch pinned common libraries instead of embedding them' + python3 - <<'PY' + from pathlib import Path + import re, subprocess + p=Path('tools/generate_menus.py') + revision=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() + text,count=re.subn(r"revision='[0-9a-f]{40}'",f"revision='{revision}'",p.read_text(encoding='utf-8')) + assert count==1 + p.write_text(text,encoding='utf-8') + PY + python3 tools/generate_menus.py + python3 tools/generate_menus.py --check + git add tools/generate_menus.py menu.sh menu.ps1 + git commit -m 'fix: point menus at runtime-loading installers' + git push origin HEAD:codex/official-installers-20260920 diff --git a/templates/load.ps1.in b/templates/load.ps1.in new file mode 100644 index 0000000..882a506 --- /dev/null +++ b/templates/load.ps1.in @@ -0,0 +1,33 @@ +function Get-LmmLibrary([string]$Name) { + if ($env:LMM_LIB_DIR) { + $text=[IO.File]::ReadAllText((Join-Path $env:LMM_LIB_DIR $Name),[Text.Encoding]::UTF8) + } else { + $uri="https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LibRevision/templates/lib/$Name" + $text=$null + $protocol=[Net.ServicePointManager]::SecurityProtocol + try { + [Net.ServicePointManager]::SecurityProtocol=$protocol -bor [Net.SecurityProtocolType]::Tls12 + $options=@{Uri=$uri;UseBasicParsing=$true;TimeoutSec=60;ErrorAction='Stop'} + $proxyValue=if($env:HTTPS_PROXY){$env:HTTPS_PROXY}else{$env:HTTP_PROXY} + if ($proxyValue) { + $proxy=[Uri]$proxyValue + $options.Proxy=$proxy.GetLeftPart([UriPartial]::Authority) + if ($proxy.UserInfo) { + $parts=$proxy.UserInfo.Split(':',2) + $password=if($parts.Length -eq 2){[Uri]::UnescapeDataString($parts[1])}else{''} + $secure=ConvertTo-SecureString $password -AsPlainText -Force + $options.ProxyCredential=New-Object System.Management.Automation.PSCredential([Uri]::UnescapeDataString($parts[0]),$secure) + } + } + for ($attempt=1;$attempt -le 3;$attempt++) { + try { + $response=Invoke-WebRequest @options + $text=[Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) + break + } catch { if ($attempt -eq 3) { throw "Cannot fetch library $Name at $LibRevision. Check the network or set LMM_LIB_DIR." } } + } + } finally { [Net.ServicePointManager]::SecurityProtocol=$protocol } + } + if ([string]::IsNullOrWhiteSpace($text)) { throw "Empty library: $Name" } + return [scriptblock]::Create($text) +} diff --git a/templates/load.sh.in b/templates/load.sh.in new file mode 100644 index 0000000..e025e67 --- /dev/null +++ b/templates/load.sh.in @@ -0,0 +1,27 @@ +# Fetch completely before sourcing: process substitution alone hides curl errors. +lmm_source_lib() { + local lmm_name=$1 lmm_text='' lmm_attempt + if [ -n "${LMM_LIB_DIR:-}" ]; then + lmm_text=$(cat -- "$LMM_LIB_DIR/$lmm_name") || { + printf 'Cannot read local library: %s/%s\n' "$LMM_LIB_DIR" "$lmm_name" >&2; return 1; + } + else + for lmm_attempt in 1 2 3; do + if lmm_text=$(curl -q -fsSL --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 60 \ + "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LIB_REVISION/templates/lib/$lmm_name"); then + break + fi + if [ "$lmm_attempt" = 3 ]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + done + fi + if [[ $lmm_text != *[![:space:]]* ]]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") +} diff --git a/tests/test-library-loader.ps1 b/tests/test-library-loader.ps1 new file mode 100644 index 0000000..902717a --- /dev/null +++ b/tests/test-library-loader.ps1 @@ -0,0 +1,55 @@ +$ErrorActionPreference='Stop' +$project=Split-Path $PSScriptRoot +. ([scriptblock]::Create([IO.File]::ReadAllText((Join-Path $project 'templates/load.ps1.in')))) +$LibRevision=('a'*40) +$root=Join-Path ([IO.Path]::GetTempPath()) ('lmm-loader-'+[Guid]::NewGuid().ToString('N')) +$beforeDir=$env:LMM_LIB_DIR; $beforeProxy=$env:HTTPS_PROXY; $beforeHttp=$env:HTTP_PROXY +$beforeProtocol=[Net.ServicePointManager]::SecurityProtocol +$script:Calls=0; $script:Mode='ok'; $script:SeenUri='' +function Invoke-WebRequest { + [CmdletBinding()] + param([string]$Uri,[switch]$UseBasicParsing,[int]$TimeoutSec,[string]$Proxy,[pscredential]$ProxyCredential) + $script:Calls++; $script:SeenUri=$Uri + if($script:Mode -eq 'fail' -or ($script:Mode -eq 'retry' -and $script:Calls -eq 1)){throw 'interrupted'} + $code='$LmmLoaded=42; function Get-LmmTestValue { $LmmLoaded }' + if($script:Mode -eq 'empty'){$code=' '} + if($script:Mode -eq 'invalid'){$code='function {'} + return [pscustomobject]@{RawContentStream=[IO.MemoryStream]::new([Text.Encoding]::UTF8.GetBytes($code))} +} +function Assert-Throws([scriptblock]$Code) { + $caught=$false + try{& $Code}catch{$caught=$true} + if(!$caught){throw 'Expected a library loading error.'} +} +try { + $env:LMM_LIB_DIR='';$env:HTTPS_PROXY='';$env:HTTP_PROXY='' + . (Get-LmmLibrary 'common.ps1') + if((Get-LmmTestValue) -ne 42){throw 'Remote functions did not survive dot-sourcing.'} + if($script:SeenUri -ne "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LibRevision/templates/lib/common.ps1"){throw 'Wrong pinned library URL.'} + $script:Mode='retry';$script:Calls=0 + . (Get-LmmLibrary 'common.ps1') + if($script:Calls -ne 2){throw 'Transient fetch failure was not retried.'} + $script:Mode='fail';$script:Calls=0 + Assert-Throws { . (Get-LmmLibrary 'common.ps1') } + if($script:Calls -ne 3){throw 'Retries were not bounded.'} + foreach($mode in @('empty','invalid')){ + $script:Mode=$mode + Assert-Throws { . (Get-LmmLibrary 'common.ps1') } + } + New-Item -ItemType Directory -Path $root | Out-Null + $local=Join-Path $root ('local '+[char]0x6D4B+[char]0x8BD5+' libraries') + New-Item -ItemType Directory -Path $local | Out-Null + $code='$LmmUnicode="'+[char]0x6D4B+[char]0x8BD5+'"; function Get-LmmLocalValue { $LmmUnicode }' + [IO.File]::WriteAllText((Join-Path $local 'local.ps1'),$code,[Text.UTF8Encoding]::new($false)) + $env:LMM_LIB_DIR=$local;$script:Calls=0 + . (Get-LmmLibrary 'local.ps1') + if((Get-LmmLocalValue) -ne ([string][char]0x6D4B+[char]0x8BD5)){throw 'UTF-8 local library changed text.'} + Assert-Throws { . (Get-LmmLibrary 'missing.ps1') } + if($script:Calls -ne 0){throw 'Local override silently fetched a missing library.'} + if([Net.ServicePointManager]::SecurityProtocol -ne $beforeProtocol){throw 'Protocol setting leaked.'} + Write-Host 'Library import scope, fixed URL, retries, errors and UTF-8 local imports passed.' +} finally { + $env:LMM_LIB_DIR=$beforeDir;$env:HTTPS_PROXY=$beforeProxy;$env:HTTP_PROXY=$beforeHttp + [Net.ServicePointManager]::SecurityProtocol=$beforeProtocol + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/tests/test_library_loader.py b/tests/test_library_loader.py new file mode 100644 index 0000000..2e76160 --- /dev/null +++ b/tests/test_library_loader.py @@ -0,0 +1,171 @@ +"""Network-loader regressions; all HTTP is replaced with deterministic fixtures.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +P = Path(__file__).resolve().parents[1] +FAKE_CURL = r'''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +with open(os.environ['LMM_LOADER_LOG'], 'a') as log: + log.write(json.dumps(sys.argv[1:]) + '\n') +mode = os.environ.get('LMM_LOADER_MODE', 'ok') +if mode == 'empty': + print(' ') + sys.exit(0) +if mode == 'fail' or (mode == 'retry' and not Path(os.environ['LMM_LOADER_RETRIED']).exists()): + Path(os.environ['LMM_LOADER_RETRIED']).touch() + print('touch "$LMM_LOADER_MARKER"') + sys.exit(18) +name = sys.argv[-1].rsplit('/', 1)[-1] +sys.stdout.write((Path(os.environ['LMM_LOADER_FIXTURES']) / name).read_text()) +''' + +class LoaderTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) + self.bin = self.base / 'bin' + self.bin.mkdir() + curl = self.bin / 'curl' + curl.write_text(FAKE_CURL) + curl.chmod(0o755) + self.libs = self.base / "local libraries with ' spaces" + self.libs.mkdir() + (self.libs / 'one.sh').write_text('lmm_loaded=42\nlmm_test() { printf "%s\\n" "$lmm_loaded"; }\n') + (self.libs / 'two.sh').write_text('lmm_loaded=43\n') + self.log = self.base / 'requests.jsonl' + self.log.write_text('') + self.marker = self.base / 'partial-executed' + self.revision = json.loads((P / 'versions.json').read_text())['library_revision'] + self.loader = (P / 'templates/load.sh.in').read_text() + self.env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ['PATH'], + LMM_LIB_DIR='', LMM_LOADER_LOG=str(self.log), LMM_LOADER_FIXTURES=str(self.libs), + LMM_LOADER_MARKER=str(self.marker), LMM_LOADER_RETRIED=str(self.base / 'retried')) + + def tearDown(self): + self.tmp.cleanup() + + def run_loader(self, commands, **env): + code = 'set -euo pipefail\nLIB_REVISION=' + self.revision + '\n' + self.loader + '\n' + commands + return subprocess.run(['bash', '-s'], input=code, env=self.env | env, text=True, capture_output=True, timeout=10) + + def calls(self): + return [json.loads(line) for line in self.log.read_text().splitlines()] + + def test_remote_import_keeps_functions_and_variables(self): + result = self.run_loader('lmm_source_lib one.sh\nlmm_test') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, '42\n') + url = self.calls()[0][-1] + self.assertEqual(url, f'https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/{self.revision}/templates/lib/one.sh') + self.assertIn('--max-time', self.calls()[0]) + self.assertIn('--connect-timeout', self.calls()[0]) + + def test_multiple_imports_share_scope(self): + result = self.run_loader('lmm_source_lib one.sh\nlmm_source_lib two.sh\nlmm_test') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, '43\n') + + def test_failed_transfer_never_executes_partial_text(self): + result = self.run_loader('lmm_source_lib one.sh\nprintf continued', LMM_LOADER_MODE='fail') + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.marker.exists()) + self.assertNotIn('continued', result.stdout) + self.assertIn('Cannot load library one.sh', result.stderr) + self.assertEqual(len(self.calls()), 3) + + def test_retry_discards_previous_response(self): + result = self.run_loader('lmm_source_lib one.sh\nlmm_test', LMM_LOADER_MODE='retry') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, '42\n') + self.assertFalse(self.marker.exists()) + self.assertEqual(len(self.calls()), 2) + + def test_empty_response_is_not_success(self): + result = self.run_loader('lmm_source_lib one.sh\nprintf continued', LMM_LOADER_MODE='empty') + self.assertNotEqual(result.returncode, 0) + self.assertNotIn('continued', result.stdout) + + def test_local_directory_with_spaces_never_fetches(self): + result = self.run_loader('lmm_source_lib one.sh\nlmm_test', LMM_LIB_DIR=str(self.libs)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, '42\n') + self.assertEqual(self.calls(), []) + + def test_missing_local_module_does_not_silently_fetch(self): + result = self.run_loader('lmm_source_lib missing.sh', LMM_LIB_DIR=str(self.libs)) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.calls(), []) + + def test_local_source_error_stops_caller(self): + (self.libs / 'bad.sh').write_text('return 9\n') + result = self.run_loader('lmm_source_lib bad.sh\nprintf continued', LMM_LIB_DIR=str(self.libs)) + self.assertEqual(result.returncode, 9) + self.assertNotIn('continued', result.stdout) + + def test_help_and_invalid_options_do_not_fetch(self): + for target in ('pi', 'dsh', 'lmm'): + for args, code in [(['--help'], 0), (['--not-an-option'], 1)]: + with self.subTest(target=target, args=args): + result = subprocess.run(['bash', str(P / f'{target}.sh'), *args], env=self.env, + text=True, capture_output=True, timeout=10) + self.assertEqual(result.returncode, code, result.stderr) + self.assertEqual(self.calls(), []) + + def test_library_failure_preserves_existing_launcher(self): + root = self.base / 'installed' + (root / 'bin').mkdir(parents=True) + for target in ('pi', 'dsh', 'lmm'): + launcher = root / 'bin' / target + launcher.write_text('old launcher') + result = subprocess.run(['bash', str(P / f'{target}.sh'), '--root', str(root)], + env=self.env | {'LMM_LOADER_MODE': 'fail'}, text=True, capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(launcher.read_text(), 'old launcher') + self.assertFalse((root / '.setup-lock').exists()) + self.assertFalse((root / 'cache').exists()) + + def test_termux_remote_path_uses_same_importer(self): + import test_installers as fixtures + fixture = fixtures.InstallerTests() + fixture.setUp() + try: + (fixture.bin / 'curl').write_text(FAKE_CURL) + env = self.env | {'TERMUX_VERSION': 'test', 'PREFIX': str(self.base / 'termux'), + 'LMM_LOADER_FIXTURES': str(P / 'templates/lib'), 'TMPDIR': ''} + env.pop('PATH') + result = fixture.run_script('pi', env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertGreaterEqual(len(self.calls()), 4) + self.assertNotIn('/usr/bin/env', (fixture.root / 'bin/pi').read_text()) + finally: + fixture.tearDown() + + def test_pinned_modules_match_checked_out_sources(self): + self.assertRegex(self.revision, r'^[0-9a-f]{40}$') + for path in sorted((P / 'templates/lib').glob('*')): + if path.is_file(): + data = subprocess.check_output(['git', 'show', f'{self.revision}:{path.relative_to(P).as_posix()}'], cwd=P) + self.assertEqual(data, path.read_bytes(), f'Update library_revision after changing {path.name}') + + def test_published_installers_are_small_and_do_not_embed_shared_code(self): + for target in ('pi', 'dsh', 'lmm'): + sh = (P / f'{target}.sh').read_text() + ps = (P / f'{target}.ps1').read_text() + self.assertNotIn('download() {', sh) + self.assertNotIn('lmm_is_termux() {', sh) + self.assertNotIn('function Invoke-Bounded', ps) + self.assertNotIn('function Receive-Stream', ps) + self.assertIn("source <(printf '%s\\n'", sh) + self.assertIn('Get-LmmLibrary $library', ps) + self.assertLess(len(sh.encode()), 18000) + self.assertLess(len(ps.encode()), 20000) + self.assertNotIn('sha256', self.loader.lower()) + self.assertNotIn('Get-FileHash', (P / 'templates/load.ps1.in').read_text()) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tools/_runtime_refactor.py b/tools/_runtime_refactor.py new file mode 100644 index 0000000..db0954e --- /dev/null +++ b/tools/_runtime_refactor.py @@ -0,0 +1,142 @@ +from pathlib import Path +import json + +P = Path(__file__).resolve().parents[1] + +def replace(path, old, new): + file = P / path + body = file.read_text(encoding='utf-8') + if body.count(old) != 1: + raise RuntimeError(f'{path}: expected one match: {old[:100]!r}; found {body.count(old)}') + file.write_text(body.replace(old, new), encoding='utf-8') + +replace('tools/generate.py', + '"""Compose only the shared code and target adapter needed by each installer."""', + '"""Generate small installers that load common functions from a pinned revision."""') +replace('tools/generate.py', + " 'script_version': ('SCRIPT_VERSION', 'ScriptVersion'),", + " 'script_version': ('SCRIPT_VERSION', 'ScriptVersion'),\n 'library_revision': ('LIB_REVISION', 'LibRevision'),") +replace('tools/generate.py', + " versions = json.loads((ROOT / 'versions.json').read_text(encoding='utf-8'))", + " versions = json.loads((ROOT / 'versions.json').read_text(encoding='utf-8'))\n if not re.fullmatch(r'[0-9a-f]{40}', versions['library_revision']):\n raise ValueError('library_revision must be a full Git commit ID')") +replace('tools/generate.py', + "parts = ['lib/root.sh', 'lib/hash.sh', 'lib/termux.sh', 'lib/quote.sh']", + "parts = ['lib/hash.sh', 'lib/termux.sh', 'lib/quote.sh']") +replace('tools/generate.py', + " body = template(f'install.{ext}.in').replace('@@LIBRARIES@@', libraries(*parts))", + ''' shared = libraries(*parts[:-1]) + names = [part.rsplit('/', 1)[1] for part in parts[:-1]] + loader = template(f'load.{ext}.in') + '\\n' + libraries(parts[-1]) + if ext == 'sh': + imports = 'for library in ' + ' '.join(names) + '; do\\n lmm_source_lib "$library" || exit $?\\ndone' + else: + imports = "foreach ($library in @(" + ','.join("'" + name + "'" for name in names) + ")) {\\n . (Get-LmmLibrary $library)\\n }" + body = template(f'install.{ext}.in').replace('@@LIBRARIES@@', loader) + body = body.replace('@@LOAD_LIBRARIES@@', imports)''') +replace('tools/generate.py', + "body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body))", + "body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body + '\\n' + shared))") +replace('tools/render.py', + '"""Build-time composition only: published scripts never source remote helpers."""', + '"""Shared rendering and byte-for-byte checks for installer and menu entry points."""') +replace('templates/install.sh.in', 'ROOT=$(lmm_root)', + 'ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}') +replace('templates/install.sh.in', + 'case "$(uname -s)" in Linux|Android)', + '@@LOAD_LIBRARIES@@\ncase "$(uname -s)" in Linux|Android)') +replace('templates/install.sh.in', + '# --check never creates directories, downloads, edits PATH or touches credentials.', + '# --check does not write files; common modules may be fetched into memory.') +replace('templates/install.sh.in', + 'No automatic login or PATH changes. Versions and platform notes: README.md.', + 'Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch).\nNo automatic login or PATH changes. See README.md.') +replace('templates/install.ps1.in', + 'try { Invoke-LmmSetup; exit 0 }', + '''try { + if ($Help) { Show-Usage; exit 0 } + $script:Phase='libraries' + @@LOAD_LIBRARIES@@ + $script:Phase='arguments' + Invoke-LmmSetup; exit 0 +}''') +replace('templates/install.ps1.in', + 'No automatic login or PATH changes. Pi on Windows requires Bash.', + 'Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch).\nNo automatic login or PATH changes. Pi on Windows requires Bash.') + +replace('tests/test_installers.py', + " for k in list(self.env):", + " self.env['LMM_LIB_DIR']=str(P/'templates/lib')\n for k in list(self.env):") +file = P / 'tests/test_official_policy.py' +text = file.read_text(encoding='utf-8') +start = text.index(' def test_shared_helpers_are_embedded_once_and_remain_offline(self):') +end = text.index(' def test_every_generated_shell_help_is_standalone(self):', start) +text = text[:start] + ''' def test_shared_helpers_are_loaded_not_copied(self): + for target in ('pi', 'dsh', 'lmm'): + body = (P / f'{target}.sh').read_text(encoding='utf-8') + for definition in ('lmm_is_termux() {', 'sha256() {', 'lmm_root() {', 'download() {'): + self.assertNotIn(definition, body) + self.assertIn('lmm_source_lib "$library"', body) + self.assertNotIn('@@LIBRARIES@@', body) + menu = (P / 'menu.sh').read_text(encoding='utf-8') + self.assertEqual(menu.count('lmm_is_termux() {'), 1) + fixtures.subprocess.run(['python3', str(P / 'tools/generate_menus.py'), '--check'], check=True) + +''' + text[end:] +file.write_text(text, encoding='utf-8') +replace('tests/test-powershell.ps1', + "$Target='test';$Network='official';$Update=$false;$script:Phase='test'", + "foreach($library in Get-ChildItem (Join-Path $project 'templates/lib') -Filter '*.ps1') { . $library.FullName }\n$Target='test';$Network='official';$Update=$false;$script:Phase='test'") +replace('tests/test-official-policy.ps1', + '# Only these functions are exercised; never invoke the installer or download.', + "foreach($library in Get-ChildItem (Join-Path $project 'templates/lib') -Filter '*.ps1') { . $library.FullName }\n# Only these functions are exercised; never invoke the installer or download.") +replace('.github/workflows/test.yml', + ' python3 tests/test_official_policy.py', + ' python3 tests/test_official_policy.py\n python3 tests/test_library_loader.py') +replace('.github/workflows/test.yml', + ' ./tests/test-official-policy.ps1', + ' ./tests/test-official-policy.ps1\n ./tests/test-library-loader.ps1') +replace('.github/workflows/test.yml', + ' .\\tests\\test-official-policy.ps1', + ' .\\tests\\test-official-policy.ps1\n .\\tests\\test-library-loader.ps1') + +versions = P / 'versions.json' +v = json.loads(versions.read_text()) +v['script_version'] = '2026.09.20.3' +v['library_revision'] = 'ce6aea96cd73d633424daaa6e2e25ac18fd33b5c' +versions.write_text(json.dumps(v, indent=2) + '\n', encoding='utf-8') +replace('README.md', + '发布脚本仍可单文件运行,不需要另外下载公共函数库。', + '安装器运行时从固定 Git 提交加载公共函数,不再把它们复制进每个发布脚本。') +replace('README.md', '## 环境与故障', '''## 公共函数加载 + +默认从 `versions.json` 的 `library_revision` 获取 GitHub 公共模块。Shell 完整获取文件后通过 `source <(...)` 导入;PowerShell 使用对应的点导入。没有公共模块哈希清单,不内嵌另一套备用库。下载失败就停止,不执行部分响应。 + +`--help` / `-Help` 不联网。`--check` / `-Check` 不修改安装文件,但默认需要联网加载公共模块。断网或调试时,明确指定同版本的本地公共目录: + +```sh +LMM_LIB_DIR="$PWD/templates/lib" bash pi.sh --check +``` + +```powershell +$env:LMM_LIB_DIR = Join-Path $PWD 'templates/lib' +.\\pi.ps1 -Check +``` + +本地目录缺少模块时直接报错,不偷偷转为联网。这里只控制公共函数的来源;安装客户端仍可能需要下载软件包。`--network` 控制软件包来源,不改变公共模块的 GitHub 地址。客户端安装完成后的启动入口不需要重新获取这些模块。 + +## 环境与故障''') +replace('docs/maintenance.md', + '`tools/generate.py` 只组装当前工具需要的代码、版本和哈希。', + '`tools/generate.py` 保留入口与工具差异,生成当前工具所需的公共模块加载调用。') +replace('docs/maintenance.md', + '`.sh` 与 `.ps1` 都保留单文件入口,不在运行时下载或 `source` 公共库;网站现有同步清单无需增加运行时文件。Windows/Linux/macOS 的编码和完整脚本校验保持不变。', + '`.sh` 与 `.ps1` 入口通过 GitHub 固定提交获取 `templates/lib/`,不内嵌公共库;网站同步清单无需新增公共文件。模块不维护额外哈希,原有软件包和菜单的校验逻辑保留。`LMM_LIB_DIR` 可明确改用本地目录,缺文件即失败。') +replace('docs/maintenance.md', + '菜单的 `revision` 固定到含有目标脚本的提交,', + '公共模块的提交由 `versions.json` 中的 `library_revision` 指定。修改公共库时先提交公共库,再更新这个引用并重新生成入口;只改入口时无需修改公共模块版本。测试比较该提交中的公共文件与当前源码,避免忘记更新引用。\n\n菜单的 `revision` 固定到含有目标脚本的提交,') +replace('docs/maintenance.md', + 'python3 tests/test_official_policy.py', + 'python3 tests/test_official_policy.py\npython3 tests/test_library_loader.py') +replace('docs/maintenance.md', + 'pwsh -NoProfile -File tests/test-official-policy.ps1', + 'pwsh -NoProfile -File tests/test-official-policy.ps1\npwsh -NoProfile -File tests/test-library-loader.ps1') From afc8d17ea98084f3fd026417f84f8bd5fc124a73 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 17:33:11 +0800 Subject: [PATCH 16/39] fix: annotate installer state consumed by fetched shell helpers --- .github/workflows/_runtime.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/_runtime.yml b/.github/workflows/_runtime.yml index c485b75..30a1318 100644 --- a/.github/workflows/_runtime.yml +++ b/.github/workflows/_runtime.yml @@ -24,6 +24,15 @@ jobs: - name: Apply changes, generate and test run: | python3 tools/_runtime_refactor.py + python3 - <<'PY' + from pathlib import Path + p=Path('templates/install.sh.in') + text=p.read_text(encoding='utf-8') + for old in ('@@CLIENT_STATE@@', 'while [ "$#" -gt 0 ]; do'): + assert text.count(old)==1 + text=text.replace(old,'# State is consumed by dynamically imported helpers.\n# shellcheck disable=SC2034\n'+old) + p.write_text(text,encoding='utf-8') + PY python3 tools/generate.py python3 tools/generate.py --check python3 tools/generate_menus.py --check From 73237be36d2c3e3a8763019f97179b5490e5405b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:33:47 +0000 Subject: [PATCH 17/39] refactor: fetch pinned common libraries instead of embedding them --- .github/workflows/test.yml | 3 + README.md | 19 +- docs/maintenance.md | 8 +- dsh.ps1 | 308 +++++---------------------------- dsh.sh | 228 ++++-------------------- lmm.ps1 | 236 +++++-------------------- lmm.sh | 133 ++++---------- pi.ps1 | 308 +++++---------------------------- pi.sh | 228 ++++-------------------- templates/install.ps1.in | 9 +- templates/install.sh.in | 12 +- tests/test-official-policy.ps1 | 1 + tests/test-powershell.ps1 | 1 + tests/test_installers.py | 1 + tests/test_official_policy.py | 13 +- tools/_runtime_refactor.py | 142 --------------- tools/generate.py | 19 +- tools/render.py | 2 +- versions.json | 5 +- 19 files changed, 303 insertions(+), 1373 deletions(-) delete mode 100644 tools/_runtime_refactor.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18b2165..518247a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,6 +37,7 @@ jobs: run: | python3 tests/test_installers.py python3 tests/test_official_policy.py + python3 tests/test_library_loader.py - name: ShellCheck if: runner.os == 'Linux' run: shellcheck *.sh @@ -45,12 +46,14 @@ jobs: run: | ./tests/test-powershell.ps1 ./tests/test-official-policy.ps1 + ./tests/test-library-loader.ps1 - name: Windows PowerShell 5.1 checks if: runner.os == 'Windows' shell: powershell run: | .\tests\test-powershell.ps1 .\tests\test-official-policy.ps1 + .\tests\test-library-loader.ps1 - name: Install real Pi on Unix with lifecycle scripts disabled if: runner.os != 'Windows' shell: bash diff --git a/README.md b/README.md index 6ea13ff..f68c3c2 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,23 @@ powershell -ExecutionPolicy Bypass -File .\pi.ps1 -Check 安装 DSH 或 LMM CLI 时,把文件名中的 `pi` 换成 `dsh` 或 `lmm`。DSH 可选 `--profile headless` / `-Profile headless`。固定版本见 [versions.json](versions.json),宿主与插件须一起验证后升级。 +## 公共函数加载 + +默认从 `versions.json` 的 `library_revision` 获取 GitHub 公共模块。Shell 完整获取文件后通过 `source <(...)` 导入;PowerShell 使用对应的点导入。没有公共模块哈希清单,不内嵌另一套备用库。下载失败就停止,不执行部分响应。 + +`--help` / `-Help` 不联网。`--check` / `-Check` 不修改安装文件,但默认需要联网加载公共模块。断网或调试时,明确指定同版本的本地公共目录: + +```sh +LMM_LIB_DIR="$PWD/templates/lib" bash pi.sh --check +``` + +```powershell +$env:LMM_LIB_DIR = Join-Path $PWD 'templates/lib' +.\pi.ps1 -Check +``` + +本地目录缺少模块时直接报错,不偷偷转为联网。这里只控制公共函数的来源;安装客户端仍可能需要下载软件包。`--network` 控制软件包来源,不改变公共模块的 GitHub 地址。客户端安装完成后的启动入口不需要重新获取这些模块。 + ## 环境与故障 默认目录:Unix 为 `${XDG_DATA_HOME:-~/.local/share}/lmm-tools`,Windows 为 `%LOCALAPPDATA%\lmm-tools`;可用 `LMM_INSTALL_ROOT` 或 `--root` / `-Root` 修改。不覆盖系统 Node 或全局 npm 包。 @@ -82,7 +99,7 @@ Pi 使用官方的 `npm install --ignore-scripts`,接受已有的 `ignore-scri LMM CLI 预编译包仅提供 Linux x64(glibc 2.39+)、macOS arm64、Windows x64。其他平台可在准备 Rust 1.88+ 和编译工具后尝试 `--from-source`,不保证所有平台都能构建。 -公共函数、生成方式、缓存、PATH 恢复和卸载注意事项见 [维护说明](docs/maintenance.md)。发布脚本仍可单文件运行,不需要另外下载公共函数库。 +公共函数、生成方式、缓存、PATH 恢复和卸载注意事项见 [维护说明](docs/maintenance.md)。安装器运行时从固定 Git 提交加载公共函数,不再把它们复制进每个发布脚本。 ## 文档依据 diff --git a/docs/maintenance.md b/docs/maintenance.md index e2d6166..0374d1e 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -27,13 +27,17 @@ python3 tools/generate_menus.py --check shellcheck *.sh python3 tests/test_installers.py python3 tests/test_official_policy.py +python3 tests/test_library_loader.py pwsh -NoProfile -File tests/test-powershell.ps1 pwsh -NoProfile -File tests/test-official-policy.ps1 +pwsh -NoProfile -File tests/test-library-loader.ps1 ``` -公共函数放在 `templates/lib/`,Pi、DSH、LMM 的差异放在 `templates/tools/`。`tools/render.py` 负责共用的文本读取、完整管道包装和生成检查;`tools/generate.py` 只组装当前工具需要的代码、版本和哈希。菜单复用同一份根目录、哈希和 Termux 函数,`lmm-use.sh` 也从模板生成。 +公共函数放在 `templates/lib/`,Pi、DSH、LMM 的差异放在 `templates/tools/`。`tools/render.py` 负责共用的文本读取、完整管道包装和生成检查;`tools/generate.py` 保留入口与工具差异,生成当前工具所需的公共模块加载调用。菜单复用同一份根目录、哈希和 Termux 函数,`lmm-use.sh` 也从模板生成。 -只修改这些源文件和版本清单,再生成根目录脚本。`.sh` 与 `.ps1` 都保留单文件入口,不在运行时下载或 `source` 公共库;网站现有同步清单无需增加运行时文件。Windows/Linux/macOS 的编码和完整脚本校验保持不变。 +只修改这些源文件和版本清单,再生成根目录脚本。`.sh` 与 `.ps1` 入口通过 GitHub 固定提交获取 `templates/lib/`,不内嵌公共库;网站同步清单无需新增公共文件。模块不维护额外哈希,原有软件包和菜单的校验逻辑保留。`LMM_LIB_DIR` 可明确改用本地目录,缺文件即失败。 + +公共模块的提交由 `versions.json` 中的 `library_revision` 指定。修改公共库时先提交公共库,再更新这个引用并重新生成入口;只改入口时无需修改公共模块版本。测试比较该提交中的公共文件与当前源码,避免忘记更新引用。 菜单的 `revision` 固定到含有目标脚本的提交,并按该提交计算 SHA-256。更新安装器后,先提交安装器,再更新 `tools/generate_menus.py` 中的 `revision` 并生成菜单,避免入口仍取旧代码。线上同步由网站仓库负责;源码提交和线上节点同步是两回事。 diff --git a/dsh.ps1 b/dsh.ps1 index 5cf0165..d08def4 100644 --- a/dsh.ps1 +++ b/dsh.ps1 @@ -12,7 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'dsh' -$ScriptVersion = '2026.09.20.2' +$ScriptVersion = '2026.09.20.3' +$LibRevision = 'ce6aea96cd73d633424daaa6e2e25ac18fd33b5c' $NodeVersion = '24.21.0' $PnpmVersion = '11.7.0' $DshVersion = '0.1.5-rc.2' @@ -27,278 +28,40 @@ $NodeHashes = @{ 'win-arm64' = '8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921' } -function Setting([string]$Name, [int]$Default, [int]$Maximum) { - $raw = [Environment]::GetEnvironmentVariable($Name) - if ([string]::IsNullOrEmpty($raw)) { return $Default } - $number = 0 - if ($raw -notmatch '^[1-9][0-9]*$' -or -not [int]::TryParse($raw, [ref]$number) -or $number -gt $Maximum) { throw "$Name must be between 1 and $Maximum." } - return $number -} -function QuoteArgument([string]$Value) { - if($Value -notmatch '[\s"]' -and $Value.Length){return $Value} - return '"' + [regex]::Replace([regex]::Replace($Value,'(\\*)"','$1$1\"'),'(\\+)$','$1$1') + '"' -} -function Stop-InstallChild($Process) { - if($Process.HasExited){return} - $killer=$null - if($env:SystemRoot){$killer=Join-Path $env:SystemRoot 'System32\taskkill.exe'} - if($killer -and (Test-Path -LiteralPath $killer)){ & $killer /PID $Process.Id /T /F 2>$null | Out-Null } - if(-not $Process.HasExited){try{$Process.Kill($true)}catch{$Process.Kill()}} - [void]$Process.WaitForExit(10000) -} -function Invoke-Bounded([string]$Executable,[string[]]$Arguments) { - $info=New-Object Diagnostics.ProcessStartInfo - $info.FileName=$Executable; $info.UseShellExecute=$false - if ((Get-Location).Provider.Name -eq 'FileSystem') { $info.WorkingDirectory=(Get-Location).ProviderPath } - $info.Arguments=($Arguments | ForEach-Object { QuoteArgument $_ }) -join ' ' - $process=New-Object Diagnostics.Process; $process.StartInfo=$info - $started=$false +function Get-LmmLibrary([string]$Name) { + if ($env:LMM_LIB_DIR) { + $text=[IO.File]::ReadAllText((Join-Path $env:LMM_LIB_DIR $Name),[Text.Encoding]::UTF8) + } else { + $uri="https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LibRevision/templates/lib/$Name" + $text=$null + $protocol=[Net.ServicePointManager]::SecurityProtocol try { - $started=$process.Start() - if(-not $started){throw 'Could not start the installation process.'} - $watch=[Diagnostics.Stopwatch]::StartNew(); $last=0 - while(-not $process.WaitForExit(1000)) { - if($watch.Elapsed.TotalSeconds -gt $CommandTimeout){ - Stop-InstallChild $process - throw 'Operation timed out. Increase LMM_COMMAND_TIMEOUT for slow networks and rerun.' - } - if($watch.Elapsed.TotalSeconds-$last -ge 15){Write-Log ('Still working: {0:N0}s elapsed.' -f $watch.Elapsed.TotalSeconds);$last=$watch.Elapsed.TotalSeconds} - } - $global:LASTEXITCODE=$process.ExitCode - if($process.ExitCode -ne 0){throw "Installation command failed with exit code $($process.ExitCode)."} - } finally { - if($started -and -not $process.HasExited){Stop-InstallChild $process} - $process.Dispose() - } -} -function Invoke-Native([string]$Command, [string[]]$Arguments) { - if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { - if ((Test-Path -LiteralPath $Command) -and (Select-String -LiteralPath $Command -SimpleMatch 'Managed by LMM installers' -Quiet)) { - $launcherLines=@(Get-Content -LiteralPath $Command) - foreach ($pathLine in $launcherLines) { - if ($pathLine -match '^set "PATH=%~dp0\.\.\\(.+);%PATH%"$') { - $runtime=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) - if (!$runtime.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed runtime escaped its root.' } - if (!(Test-Path -LiteralPath $runtime -PathType Container)) { throw 'Managed runtime is missing. Rerun the installer.' } - $env:PATH="$runtime;$env:PATH" + [Net.ServicePointManager]::SecurityProtocol=$protocol -bor [Net.SecurityProtocolType]::Tls12 + $options=@{Uri=$uri;UseBasicParsing=$true;TimeoutSec=60;ErrorAction='Stop'} + $proxyValue=if($env:HTTPS_PROXY){$env:HTTPS_PROXY}else{$env:HTTP_PROXY} + if ($proxyValue) { + $proxy=[Uri]$proxyValue + $options.Proxy=$proxy.GetLeftPart([UriPartial]::Authority) + if ($proxy.UserInfo) { + $parts=$proxy.UserInfo.Split(':',2) + $password=if($parts.Length -eq 2){[Uri]::UnescapeDataString($parts[1])}else{''} + $secure=ConvertTo-SecureString $password -AsPlainText -Force + $options.ProxyCredential=New-Object System.Management.Automation.PSCredential([Uri]::UnescapeDataString($parts[0]),$secure) } } - $line=$launcherLines[-1] - if ($line -match '^"%~dp0\.\.\\(.+)" %\*$') { - $resolved=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) - if (!$resolved.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed launcher target escaped its root.' } - $Command=$resolved - } - } - if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { - $parent=Split-Path $Command - $binName=[IO.Path]::GetFileNameWithoutExtension($Command).ToLowerInvariant() - switch ($binName) { - 'npm' { $packageName='npm' } - 'pi' { $packageName='@earendil-works/pi-coding-agent' } - 'pnpm' { $packageName='pnpm' } - 'dsh' { $packageName='@deepseek-ai/dsh' } - default { throw 'Unsupported command shim; use the managed installer or a native executable.' } - } - $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) - $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json - $binProperty=$manifest.PSObject.Properties['bin'] - if (!$binProperty) { throw 'Package manifest has no bin entry.' } - $bins=$binProperty.Value - $relative=$null - if ($bins -is [string]) { $relative=$bins } - elseif ($null -ne $bins -and $bins.PSObject.Properties[$binName]) { $relative=$bins.PSObject.Properties[$binName].Value } - if ($relative -isnot [string] -or !$relative -or [IO.Path]::IsPathRooted($relative)) { throw 'Invalid package bin entry.' } - $entry=[IO.Path]::GetFullPath((Join-Path $packageRoot $relative)) - if (!$entry.StartsWith($packageRoot + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Package bin entry escaped its package.' } - if (!(Test-Path -LiteralPath $entry)) { throw 'Client entry is missing. Rerun with -Update.' } - $Command=(Get-Command node.exe).Source - $Arguments=@($entry)+$Arguments - } - } - Invoke-Bounded $Command $Arguments -} -function Get-Hash([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } -function Set-RequestProxy($request) { - $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } - if ($proxyValue) { - $proxyUri = [Uri]$proxyValue - if ($proxyUri.Scheme -notin @('http','https')) { throw 'Use an HTTP(S) proxy with Windows PowerShell; SOCKS needs a local HTTP proxy adapter.' } - $proxy = New-Object Net.WebProxy($proxyUri.GetLeftPart([UriPartial]::Authority)) - if ($proxyUri.UserInfo) { - $parts=$proxyUri.UserInfo.Split(':',2) - $password=if ($parts.Length -eq 2) { [Uri]::UnescapeDataString($parts[1]) } else { '' } - $proxy.Credentials=New-Object Net.NetworkCredential([Uri]::UnescapeDataString($parts[0]),$password) - } - if ($env:NO_PROXY) { - $proxy.BypassList=@($env:NO_PROXY.Split(',') | ForEach-Object { $hostName=$_.Trim().TrimStart('.'); if($hostName -eq '*') { '.*' } elseif($hostName) { '^https?://([^/]+\.)?' + [regex]::Escape($hostName) + '(:[0-9]+)?(/|$)' } }) - } - $request.Proxy=$proxy - } -} -function New-DownloadRequest([Uri]$Uri) { return [Net.HttpWebRequest]::Create($Uri) } -function Get-RankedUrls([string[]]$Urls) { - $scores = @(); $index = 0 - foreach ($url in $Urls) { - $timer = [Diagnostics.Stopwatch]::StartNew(); $score = 999999 - try { - $request = New-DownloadRequest ([Uri]$url) - $request.Method = 'HEAD'; $request.Timeout = 4000; $request.AllowAutoRedirect = $true - Set-RequestProxy $request - $response = $request.GetResponse(); $response.Close(); $score = $timer.ElapsedMilliseconds - } catch { } finally { $timer.Stop() } - $scores += [pscustomobject]@{ Url=$url; Score=$score; Order=$index }; $index++ - } - return @($scores | Sort-Object Score,Order | ForEach-Object { $_.Url }) -} -function Get-DownloadUrls([string]$Official) { - $mirrors = @() - if ($Official.StartsWith('https://nodejs.org/dist/')) { $mirrors = @($Official.Replace('https://nodejs.org/dist/','https://npmmirror.com/mirrors/node/')) } - elseif ($Official.StartsWith('https://github.com/')) { $mirrors = @("https://ghfast.top/$Official", "https://ghproxy.net/$Official") } - if ($Network -eq 'official') { return @($Official) } - if ($Network -eq 'china') { return @($mirrors) + @($Official) } - return @(Get-RankedUrls (@($Official) + @($mirrors))) -} -function Receive-Stream([string]$Url, [string]$Path) { - # Used on older Windows without curl.exe. ReadWriteTimeout bounds stalled reads. - $offset = 0L - if (Test-Path -LiteralPath $Path) { $offset = (Get-Item -LiteralPath $Path).Length } - $request = New-DownloadRequest ([Uri]$Url) - $request.Timeout = $ConnectTimeout*1000; $request.ReadWriteTimeout = $StallTimeout*1000; $request.AllowAutoRedirect = $true - Set-RequestProxy $request - if ($offset -gt 0) { $request.AddRange($offset) } - $response = $null; $inputStream = $null; $outputStream = $null - try { - $response = $request.GetResponse() - if ($response.ResponseUri.Scheme -ne 'https') { throw 'Insecure redirect refused.' } - $mode = [IO.FileMode]::Create - if ($offset -gt 0 -and [int]$response.StatusCode -eq 206) { - if ($response.Headers['Content-Range'] -notlike "bytes $offset-*") { throw 'Invalid resume response.' } - $mode = [IO.FileMode]::Append - } - $inputStream = $response.GetResponseStream() - $outputStream = [IO.File]::Open($Path,$mode,[IO.FileAccess]::Write,[IO.FileShare]::None) - $buffer = New-Object byte[] 65536; $windowBytes = 0L; $total = 0L - $window = [Diagnostics.Stopwatch]::StartNew(); $overall = [Diagnostics.Stopwatch]::StartNew() - while (($read = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) { - $outputStream.Write($buffer,0,$read); $windowBytes += $read; $total += $read - if ($overall.Elapsed.TotalSeconds -gt $DownloadTimeout) { throw 'Download timeout.' } - if ($window.Elapsed.TotalSeconds -ge $StallTimeout -and ($response.ContentLength -lt 0 -or $total -lt $response.ContentLength)) { - if ($windowBytes / $window.Elapsed.TotalSeconds -lt $MinSpeed) { throw 'Download too slow; changing source.' } - $window.Restart(); $windowBytes = 0 - } - } - } finally { - if ($outputStream) { $outputStream.Dispose() }; if ($inputStream) { $inputStream.Dispose() }; if ($response) { $response.Close() } - } -} -function Get-VerifiedFile([string]$Url, [string]$Destination, [string]$Expected) { - $parsed=[Uri]$Url - if ($parsed.Scheme -ne 'https' -or $parsed.UserInfo) { throw 'Download URLs must use HTTPS without credentials.' } - foreach($file in @($Destination,"$Destination.part","$Destination.part.url")) { if ((Test-Path -LiteralPath $file) -and ((Get-Item -LiteralPath $file).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Refusing symlink cache entries.' } } - if (-not $Update -and (Test-Path -LiteralPath $Destination) -and (Get-Hash $Destination) -eq $Expected) { Write-Log "Cached: $([IO.Path]::GetFileName($Destination))"; return } - $partial = "$Destination.part"; $sourceFile = "$partial.url" - if ((Test-Path -LiteralPath $partial) -and (Get-Hash $partial) -eq $Expected) { Move-Item -LiteralPath $partial -Destination $Destination -Force; return } - if ($env:LMM_NODE_BASE_URL -and $Url.StartsWith('https://nodejs.org/dist/')) { $Url=$Url.Replace('https://nodejs.org/dist',$env:LMM_NODE_BASE_URL.TrimEnd('/')) } - $sourceNumber = 0 - foreach ($source in @(Get-DownloadUrls $Url)) { - $sourceNumber++ - if ((Test-Path -LiteralPath $partial) -and (!(Test-Path -LiteralPath $sourceFile) -or (Get-Content -LiteralPath $sourceFile -Raw).Trim() -ne $source)) { Remove-Item -LiteralPath $partial -Force } - Set-Content -LiteralPath $sourceFile -Value $source -Encoding ASCII - foreach ($attempt in 1..$Retries) { - Write-Log "Downloading $([IO.Path]::GetFileName($Destination)) (source $sourceNumber, attempt $attempt)" - try { - $curl = Get-Command curl.exe -ErrorAction SilentlyContinue - if ($curl) { - Invoke-Native $curl.Source @('-q','--proto','=https','--proto-redir','=https','-fL','--connect-timeout',([string]$ConnectTimeout),'--max-time',([string]$DownloadTimeout),'--speed-time',([string]$StallTimeout),'--speed-limit',([string]$MinSpeed),'--continue-at','-','--output',$partial,$source) - } else { Receive-Stream $source $partial } - if ((Get-Hash $partial) -ne $Expected) { Remove-Item -LiteralPath $partial -Force; Write-Log 'Checksum mismatch; download will not be executed.'; break } - Move-Item -LiteralPath $partial -Destination $Destination -Force - Remove-Item -LiteralPath $sourceFile -Force -ErrorAction SilentlyContinue - return - } catch { - Write-Log 'Transfer failed or stalled. Retrying, then trying another source.' - if ($attempt -eq 1 -and (Test-Path -LiteralPath $partial)) { - # Keep a partial for retry; a rejected Range gets a clean retry next source. - if ($curl -and $LASTEXITCODE -in @(22,33,36)) { Remove-Item -LiteralPath $partial -Force } - } + for ($attempt=1;$attempt -le 3;$attempt++) { + try { + $response=Invoke-WebRequest @options + $text=[Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) + break + } catch { if ($attempt -eq 3) { throw "Cannot fetch library $Name at $LibRevision. Check the network or set LMM_LIB_DIR." } } } - } - } - throw 'All download sources failed. Retry -Network official or -Network china; inspect your proxy/CA settings.' -} -function Test-Node { - $node = Get-Command node.exe -ErrorAction SilentlyContinue - $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue - if (-not $node -or -not $npm) { return $false } - try { $versionText=(& $node.Source --version 2>$null | Out-String).Trim() } catch { return $false } - if ($LASTEXITCODE -ne 0 -or $versionText -notmatch '^v([0-9]+)\.([0-9]+)\.[0-9]+') { return $false } - $major=[int]$Matches[1];$minor=[int]$Matches[2] - return (($major -eq 22 -and $minor -ge 19) -or $major -ge 24) -} -function Install-Node { - $script:Phase = 'Node.js runtime' - if (Test-Node) { $script:NodeBin = Split-Path (Get-Command node.exe).Source; return } - $directory = Join-Path $Root "runtime\node-v$NodeVersion-$Platform" - if (Test-Path -LiteralPath (Join-Path $directory 'node.exe')) { $env:PATH = "$directory;$env:PATH" } - if (Test-Node) { $script:NodeBin = $directory; return } - if ($NoInstallNode -or $NoBootstrap) { throw 'Need Node 22.19+ (22.x) or Node 24+, including npm.' } - $hash = $NodeHashes[$Platform] - if (-not $hash) { throw "No verified Node archive for $Platform" } - $name = "node-v$NodeVersion-$Platform.zip"; $archive = Join-Path $script:Cache $name - Get-VerifiedFile "https://nodejs.org/dist/v$NodeVersion/$name" $archive $hash - $unpack = Join-Path $script:Stage 'runtime'; Expand-Archive -LiteralPath $archive -DestinationPath $unpack - $extracted = Join-Path $unpack "node-v$NodeVersion-$Platform" - Invoke-Native (Join-Path $extracted 'node.exe') @('--version') - if (Test-Path -LiteralPath $directory) { throw "Managed runtime is present but unusable; inspect $directory before replacing." } - Move-Item -LiteralPath $extracted -Destination $directory - $script:NodeBin = $directory; $env:PATH = "$directory;$env:PATH" -} -function Set-NpmNetwork { - if (-not $env:npm_config_cache) { $cache=(& npm.cmd config get cache 2>$null | Out-String).Trim(); if ($cache) { $env:npm_config_cache=$cache } else { $env:npm_config_cache=Join-Path $script:Cache 'npm' } } - $env:npm_config_fetch_retries=[string]$Retries; $env:npm_config_fetch_timeout=[string]($StallTimeout*1000); $env:npm_config_fetch_retry_mintimeout='2000'; $env:npm_config_fetch_retry_maxtimeout='30000'; $env:npm_config_strict_ssl='true'; $env:npm_config_prefer_offline='true' - if ($env:LMM_NPM_REGISTRY) { $env:npm_config_registry=$env:LMM_NPM_REGISTRY } - $current = (& npm.cmd config get registry 2>$null | Out-String).Trim() - if ($env:npm_config_registry -or ($current -and $current -ne 'https://registry.npmjs.org/')) { Write-Log 'Keeping your existing npm registry/proxy configuration.'; return } - $script:NpmSelected = $true - if ($Network -eq 'china') { $env:npm_config_registry='https://registry.npmmirror.com/' } - elseif ($Network -eq 'official') { $env:npm_config_registry='https://registry.npmjs.org/' } - else { $env:npm_config_registry = @(Get-RankedUrls @('https://registry.npmjs.org/','https://registry.npmmirror.com/'))[0] } - Write-Log 'Registry selection affects this process only, not your global npm configuration.' -} -function Invoke-WithRegistryRetry([string]$Command,[string[]]$Arguments) { - try { Invoke-Native $Command $Arguments } catch { - if ($Network -ne 'auto' -or -not $script:NpmSelected) { throw } - if ($env:npm_config_registry -eq 'https://registry.npmjs.org/') { $env:npm_config_registry='https://registry.npmmirror.com/' } else { $env:npm_config_registry='https://registry.npmjs.org/' } - Write-Log 'Retrying with the alternate registry and the same cache.' - Invoke-Native $Command $Arguments - } -} -function Install-Client([string]$Package,[string]$Version,[string]$Entry) { - $script:Phase="$Target client"; $destination=Join-Path $Root "apps\$Target\$Version" - if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } - if ($NoBootstrap) { throw 'Managed client is missing. Rerun without -NoBootstrap.' } - $work=Join-Path $script:Stage 'client'; New-Item -ItemType Directory -Path $work | Out-Null - $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") - if ($Target -eq 'pi') { - # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. - $installArgs+=@('--ignore-scripts') - } else { - $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' - $npmHelp=(& npm.cmd install --help 2>$null | Out-String) - if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } - if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } - } - Invoke-WithRegistryRetry (Get-Command npm.cmd).Source $installArgs - Invoke-Native (Join-Path $work "$Entry.cmd") @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value "$Version|$ScriptVersion" - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw "Refusing unowned directory: $destination" } - $destination += '-reinstall-' + [Guid]::NewGuid().ToString('N') + } finally { [Net.ServicePointManager]::SecurityProtocol=$protocol } } - Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination "$Entry.cmd" + if ([string]::IsNullOrWhiteSpace($text)) { throw "Empty library: $Name" } + return [scriptblock]::Create($text) } + function Install-Pnpm { $script:Phase='DSH package manager';$destination=Join-Path $Root "tools\pnpm\$PnpmVersion" if ((Test-Path -LiteralPath (Join-Path $destination 'pnpm.cmd')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:PnpmBin=$destination } @@ -365,6 +128,7 @@ LMM $Target installer $ScriptVersion Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] [-FromSource] [-Launch] [-Help] +Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch). No automatic login or PATH changes. Pi on Windows requires Bash. -FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. "@ @@ -458,7 +222,15 @@ function Invoke-LmmSetup { } $savedEnvironment=@{} foreach($name in @('PATH','npm_config_cache','npm_config_fetch_retries','npm_config_fetch_timeout','npm_config_prefer_offline','npm_config_fetch_retry_mintimeout','npm_config_fetch_retry_maxtimeout','npm_config_strict_ssl','npm_config_registry','npm_config_store_dir')) { $savedEnvironment[$name]=[Environment]::GetEnvironmentVariable($name,'Process') } -try { Invoke-LmmSetup; exit 0 } +try { + if ($Help) { Show-Usage; exit 0 } + $script:Phase='libraries' + foreach ($library in @('common.ps1','download.ps1','node.ps1')) { + . (Get-LmmLibrary $library) + } + $script:Phase='arguments' + Invoke-LmmSetup; exit 0 +} catch { Write-Error "Stopped during $script:Phase. $($_.Exception.Message)" -ErrorAction Continue; exit 1 } finally { foreach($name in $savedEnvironment.Keys) { [Environment]::SetEnvironmentVariable($name,$savedEnvironment[$name],'Process') } diff --git a/dsh.sh b/dsh.sh index 0c090a0..2bf7dd5 100755 --- a/dsh.sh +++ b/dsh.sh @@ -4,7 +4,8 @@ lmm_install_main() { set -euo pipefail set +x TARGET=dsh -SCRIPT_VERSION=2026.09.20.2 +SCRIPT_VERSION=2026.09.20.3 +LIB_REVISION=ce6aea96cd73d633424daaa6e2e25ac18fd33b5c NODE_VERSION=24.21.0 PNPM_VERSION=11.7.0 DSH_VERSION=0.1.5-rc.2 @@ -20,199 +21,34 @@ node_hash() { case "$1" in *) printf '\n';; esac; } -lmm_root() { - printf '%s\n' "${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}" -} -sha256() { - local digest - if command -v sha256sum >/dev/null 2>&1; then digest=$(sha256sum "$1") || return; printf '%s\n' "${digest%% *}" - elif command -v shasum >/dev/null 2>&1; then digest=$(shasum -a 256 "$1") || return; printf '%s\n' "${digest%% *}" - elif command -v openssl >/dev/null 2>&1; then digest=$(openssl dgst -sha256 "$1") || return; printf '%s\n' "${digest##* }" - else printf 'Install a SHA-256 tool.\n' >&2; return 1; fi -} -# Native Termux uses Android/bionic, not desktop Linux/glibc. -lmm_is_termux() { - [ -n "${TERMUX_VERSION:-}${TERMUX_APP__PACKAGE_NAME:-}" ] || - case "${PREFIX:-}" in */com.termux/files/usr) true;; *) false;; esac -} -lmm_temp_root() { - if [ -n "${TMPDIR:-}" ]; then printf '%s\n' "$TMPDIR" - elif lmm_is_termux; then printf '%s/tmp\n' "${PREFIX:-$HOME/.cache/lmm-tools}" - else printf '/tmp\n'; fi -} -lmm_check_storage() { - lmm_is_termux || return 0 - local resolved - # realpath -m also resolves missing paths and symlinked storage aliases. - command -v realpath >/dev/null 2>&1 || { - printf 'Termux needs coreutils: pkg install coreutils\n' >&2; return 1; - } - resolved=$(realpath -m -- "$1") || return 1 - case "$resolved/" in - /sdcard/*|/storage/*|/mnt/sdcard/*|/mnt/media_rw/*|/mnt/runtime/*|/mnt/user/*|/mnt/pass_through/*) - printf 'Use Termux private storage under HOME, not shared storage: %s\n' "$1" >&2 - return 1;; - esac -} -quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } -rank_urls() { - local i=0 url response code elapsed probe_dir - if ! command -v curl >/dev/null 2>&1; then for url in "$@"; do printf '%s\n' "$i"; i=$((i+1)); done; return; fi - probe_dir=$(mktemp -d "$STAGE/probes.XXXXXX") - for url in "$@"; do - ( - response=$(curl -q --proto '=https' --proto-redir '=https' -ILs --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code} %{time_starttransfer}' "$url" 2>/dev/null || true) - code=${response%% *}; elapsed=${response#* } - case "$code" in 2??|3??) ;; *) elapsed=999;; esac - case "$elapsed" in ''|*[!0-9.]*) elapsed=999;; esac - printf '%s %s\n' "$elapsed" "$i" > "$probe_dir/$i" - ) & - i=$((i+1)) - done - wait - cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | while read -r elapsed index; do printf '%s\n' "$index"; done -} -urls_for() { - URLS=("$1") - case "$1" in - https://nodejs.org/dist/*) MIRRORS=("https://npmmirror.com/mirrors/node/${1#https://nodejs.org/dist/}");; - https://github.com/*) MIRRORS=("https://ghfast.top/$1" "https://ghproxy.net/$1");; - *) MIRRORS=();; - esac - case "$NETWORK" in - auto) URLS+=("${MIRRORS[@]}");; - china) URLS=("${MIRRORS[@]}" "$1");; - esac -} -download() { - local official=$1 destination=$2 expected=$3 index url part attempt actual order transfer_status - part="$destination.part" - if [ -L "$destination" ] || [ -L "$part" ] || [ -L "$part.url" ]; then fail 'Refusing symlink cache entries'; fi - if [ "$FORCE" = 0 ] && [ -f "$destination" ] && [ "$(sha256 "$destination")" = "$expected" ]; then log "Cached: ${destination##*/}"; return; fi - if [ -f "$part" ] && [ "$(sha256 "$part")" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi - command -v curl >/dev/null 2>&1 || fail 'curl is required for downloads. Install it with your OS package manager.' - if [[ $official == https://nodejs.org/dist/* && -n ${LMM_NODE_BASE_URL:-} ]]; then official="${LMM_NODE_BASE_URL%/}/${official#https://nodejs.org/dist/}"; fi - urls_for "$official" - if [ "$NETWORK" = auto ]; then order=$(rank_urls "${URLS[@]}"); else order=$(printf '%s\n' "${!URLS[@]}"); fi - for index in $order; do - url=${URLS[$index]} - if [ -f "$part" ] && [ "$(cat "$part.url" 2>/dev/null || true)" != "$url" ]; then rm -f -- "$part"; fi - printf '%s\n' "$url" > "$part.url" - for ((attempt=1; attempt<=RETRIES; attempt++)); do - log "Downloading ${destination##*/} (source $((index+1)), attempt $attempt; low-speed cutoff ${STALL_TIMEOUT}s)" - if curl -q --proto '=https' --proto-redir '=https' -fL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$DOWNLOAD_TIMEOUT" --speed-time "$STALL_TIMEOUT" --speed-limit "$MIN_SPEED" --continue-at - --output "$part" "$url"; then - actual=$(sha256 "$part") - if [ "$actual" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi - log 'Checksum mismatch: discarded the download; it will not be executed.' - rm -f -- "$part" +# Fetch completely before sourcing: process substitution alone hides curl errors. +lmm_source_lib() { + local lmm_name=$1 lmm_text='' lmm_attempt + if [ -n "${LMM_LIB_DIR:-}" ]; then + lmm_text=$(cat -- "$LMM_LIB_DIR/$lmm_name") || { + printf 'Cannot read local library: %s/%s\n' "$LMM_LIB_DIR" "$lmm_name" >&2; return 1; + } + else + for lmm_attempt in 1 2 3; do + if lmm_text=$(curl -q -fsSL --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 60 \ + "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LIB_REVISION/templates/lib/$lmm_name"); then break - else transfer_status=$?; fi - # A server may reject Range; retry once from a clean file. Retain a - # partial transfer after final failure for the next invocation. - if [ "$attempt" = 1 ]; then case "$transfer_status" in 22|33|36) rm -f -- "$part";; esac; fi + fi + if [ "$lmm_attempt" = 3 ]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi done - done - fail "Download failed: ${destination##*/}. Rerun to resume, or choose another --network mode." -} -compatible_node() { - command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && - node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(((a===22&&b>=19)||a>=24)&&(process.argv[1]!=="android"||process.platform==="android")?0:1)' "$OS" >/dev/null 2>&1 -} -ensure_node() { - PHASE='Node.js runtime' - if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi - [ "$OS" != android ] || fail 'In Termux, run pkg install nodejs npm git; desktop Node archives are incompatible.' - local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive - if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi - if compatible_node; then NODE_BIN="$dir/bin"; return; fi - [ "$INSTALL_NODE" = 1 ] || fail 'Need Node 22.19+ (22.x) or Node 24+, including npm.' - [ ! -f /etc/alpine-release ] || fail 'On Alpine install nodejs/npm with apk first; official Node archives require glibc.' - hash=$(node_hash "$PLATFORM") - [ -n "$hash" ] || fail "No verified Node archive for $PLATFORM" - archive="$CACHE/node-v$NODE_VERSION-$PLATFORM.tar.gz" - download "https://nodejs.org/dist/v$NODE_VERSION/${archive##*/}" "$archive" "$hash" - mkdir -p "$STAGE/runtime" - tar -xzf "$archive" -C "$STAGE/runtime" - "$STAGE/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" --version >/dev/null || fail 'Node cannot run on this OS/libc. Install compatible Node using your OS package manager.' - [ ! -e "$dir" ] || fail "Managed runtime exists but is unusable: $dir. Inspect it before replacing." - mv -- "$STAGE/runtime/node-v$NODE_VERSION-$PLATFORM" "$dir" - NODE_BIN="$dir/bin"; export PATH="$NODE_BIN:$PATH" -} -configure_npm() { - if [ -z "${npm_config_cache:-}" ]; then - npm_config_cache=$(npm config get cache 2>/dev/null || true) - case "$npm_config_cache" in /*) ;; *) npm_config_cache="$CACHE/npm";; esac - export npm_config_cache fi - export npm_config_fetch_retries="$RETRIES" npm_config_fetch_timeout="$((STALL_TIMEOUT * 1000))" - export npm_config_fetch_retry_mintimeout=2000 npm_config_fetch_retry_maxtimeout=30000 - export npm_config_strict_ssl=true - if [ -n "${LMM_NPM_REGISTRY:-}" ]; then export npm_config_registry="$LMM_NPM_REGISTRY"; fi - export npm_config_prefer_offline=true - local current order first - current=$(npm config get registry 2>/dev/null || true) - if [ -n "${npm_config_registry:-}" ] || { [ -n "$current" ] && [ "$current" != https://registry.npmjs.org/ ]; }; then - log 'Keeping your existing npm registry/proxy configuration.'; return + if [[ $lmm_text != *[![:space:]]* ]]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 fi - NPM_SELECTED=1 - case "$NETWORK" in - official) export npm_config_registry=https://registry.npmjs.org/;; - china) export npm_config_registry=https://registry.npmmirror.com/;; - auto) - order=$(rank_urls https://registry.npmjs.org/ https://registry.npmmirror.com/) - first=${order%%$'\n'*} - if [ "$first" = 1 ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi;; - esac - log 'Selected a registry for this installer process only; global npm settings are unchanged.' -} -bounded() { - node - "$COMMAND_TIMEOUT" "$@" <<'JS' -const {spawn}=require('node:child_process'); -const [seconds,command,...args]=process.argv.slice(2); -const child=spawn(command,args,{stdio:'inherit',detached:true}); -let timedOut=false,stopping=false; -function stop(code){if(stopping)return;stopping=true;timedOut=code===124;try{process.kill(-child.pid,'SIGTERM')}catch{};const hard=setTimeout(()=>{try{process.kill(-child.pid,'SIGKILL')}catch{}},3000);hard.unref()} -const start=Date.now();const heartbeat=setInterval(()=>process.stderr.write(`[install] Still working: ${Math.floor((Date.now()-start)/1000)}s elapsed.\n`),15000); -const timeout=setTimeout(()=>{process.stderr.write('[install] Operation timed out; increase LMM_COMMAND_TIMEOUT for a slow connection.\n');stop(124)},Number(seconds)*1000); -process.on('SIGINT',()=>stop(130));process.on('SIGTERM',()=>stop(143)); -child.on('error',e=>{clearInterval(heartbeat);clearTimeout(timeout);process.stderr.write(`[install] Cannot start ${command}: ${e.code}\n`);process.exitCode=1}); -child.on('exit',(code,signal)=>{clearInterval(heartbeat);clearTimeout(timeout);process.exitCode=timedOut?124:signal?130:code??1}); -JS -} -with_registry_retry() { - if bounded "$@"; then return; fi - if [ "$NETWORK" = auto ] && [ "$NPM_SELECTED" = 1 ]; then - if [ "$npm_config_registry" = https://registry.npmjs.org/ ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi - log 'Retrying the alternate registry with the same package cache.' - bounded "$@" - else fail 'Package installation failed. Check network/proxy settings or try another --network mode.'; fi -} -install_client() { - local package=$1 version=$2 entry=$3 target="$ROOT/apps/$TARGET/$2" work="$STAGE/client" allow - PHASE="$TARGET client" - if [ "$FORCE" = 0 ] && [ -x "$target/bin/$entry" ] && [ -f "$target/.lmm-managed" ] && [ "$(cat "$target/.lmm-managed")" = "$version|$SCRIPT_VERSION" ]; then CLIENT="$target/bin/$entry"; log "Client $version already installed."; return; fi - [ "$BOOTSTRAP" = 1 ] || fail 'Managed client is missing; rerun without --no-bootstrap.' - mkdir -p "$work" - INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") - if [ "$TARGET" = pi ]; then - # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. - INSTALL_ARGS+=(--ignore-scripts) - else - allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' - if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi - [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' - fi - with_registry_retry npm "${INSTALL_ARGS[@]}" - node "$work/bin/$entry" --version >/dev/null - printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" - mkdir -p "$(dirname "$target")" - if [ -e "$target" ]; then - [ -f "$target/.lmm-managed" ] || fail "Not replacing an unowned directory: $target" - target="$target-reinstall-$(date +%s)-$$" - fi - mv -- "$work" "$target" - CLIENT="$target/bin/$entry" + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") } + ensure_pnpm() { PHASE='DSH package manager' local directory="$ROOT/tools/pnpm/$PNPM_VERSION" work="$STAGE/pnpm" @@ -243,10 +79,12 @@ install_tool() { with_registry_retry node "$CLIENT" plugin --profile "$PROFILE" add "$artifact" --ignore-scripts --store-dir "$CACHE/pnpm" } -ROOT=$(lmm_root) +ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 STAGE='' LOCKED=0 PHASE=arguments RUN_ARGS=() +# State is consumed by dynamically imported helpers. +# shellcheck disable=SC2034 INSTALL_NODE=1 BOOTSTRAP=1 NPM_SELECTED=0 PNPM_BIN='' log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } @@ -268,9 +106,12 @@ Usage: bash $TARGET.sh [options] [-- launch arguments] --from-source LMM CLI: build the pinned crate using existing Rust 1.88+ --launch Start the installed tool (DSH starts the chosen profile) --help Show this help -No automatic login or PATH changes. Versions and platform notes: README.md. +Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch). +No automatic login or PATH changes. See README.md. USAGE } +# State is consumed by dynamically imported helpers. +# shellcheck disable=SC2034 while [ "$#" -gt 0 ]; do case "$1" in --help|-h) usage; exit 0;; @@ -308,6 +149,9 @@ case "$PROFILE" in web|headless) ;; *) fail 'profile must be web or headless';; [ "$TARGET" = lmm ] || [ "$SOURCE" = 0 ] || fail '--from-source is only for lmm' case "$ROOT" in *$'\n'*|*$'\r'*) fail 'Install path must not contain newlines';; /*) ;; *) ROOT="$PWD/$ROOT";; esac if [ "$ROOT" = / ] || [ "$ROOT" = "$HOME" ]; then fail 'Choose a dedicated installation directory'; fi +for library in hash.sh termux.sh quote.sh download.sh node.sh; do + lmm_source_lib "$library" || exit $? +done case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac if lmm_is_termux; then OS=android; fi case "$(uname -m)" in @@ -323,7 +167,7 @@ if [ "$OS" = android ] && [ "$TARGET" != lmm ]; then compatible_node || fail 'In Termux, install native Node/npm: pkg install nodejs npm git; then rerun. Desktop Node cannot run on Android.' command -v git >/dev/null 2>&1 || fail 'Pi/DSH need git: pkg install git' fi -# --check never creates directories, downloads, edits PATH or touches credentials. +# --check does not write files; common modules may be fetched into memory. if [ "$CHECK" = 1 ]; then log "Platform: $PLATFORM; install root: $ROOT" if [ -x "$ROOT/bin/$TARGET" ]; then "$ROOT/bin/$TARGET" --version diff --git a/lmm.ps1 b/lmm.ps1 index 8c4a37c..7d4899a 100644 --- a/lmm.ps1 +++ b/lmm.ps1 @@ -12,7 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'lmm' -$ScriptVersion = '2026.09.20.2' +$ScriptVersion = '2026.09.20.3' +$LibRevision = 'ce6aea96cd73d633424daaa6e2e25ac18fd33b5c' $LmmVersion = '0.1.0' $LmmReleaseBase = 'https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0' $LmmHashes = @{ @@ -21,206 +22,40 @@ $LmmHashes = @{ 'win-x64' = 'd0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9' } -function Setting([string]$Name, [int]$Default, [int]$Maximum) { - $raw = [Environment]::GetEnvironmentVariable($Name) - if ([string]::IsNullOrEmpty($raw)) { return $Default } - $number = 0 - if ($raw -notmatch '^[1-9][0-9]*$' -or -not [int]::TryParse($raw, [ref]$number) -or $number -gt $Maximum) { throw "$Name must be between 1 and $Maximum." } - return $number -} -function QuoteArgument([string]$Value) { - if($Value -notmatch '[\s"]' -and $Value.Length){return $Value} - return '"' + [regex]::Replace([regex]::Replace($Value,'(\\*)"','$1$1\"'),'(\\+)$','$1$1') + '"' -} -function Stop-InstallChild($Process) { - if($Process.HasExited){return} - $killer=$null - if($env:SystemRoot){$killer=Join-Path $env:SystemRoot 'System32\taskkill.exe'} - if($killer -and (Test-Path -LiteralPath $killer)){ & $killer /PID $Process.Id /T /F 2>$null | Out-Null } - if(-not $Process.HasExited){try{$Process.Kill($true)}catch{$Process.Kill()}} - [void]$Process.WaitForExit(10000) -} -function Invoke-Bounded([string]$Executable,[string[]]$Arguments) { - $info=New-Object Diagnostics.ProcessStartInfo - $info.FileName=$Executable; $info.UseShellExecute=$false - if ((Get-Location).Provider.Name -eq 'FileSystem') { $info.WorkingDirectory=(Get-Location).ProviderPath } - $info.Arguments=($Arguments | ForEach-Object { QuoteArgument $_ }) -join ' ' - $process=New-Object Diagnostics.Process; $process.StartInfo=$info - $started=$false +function Get-LmmLibrary([string]$Name) { + if ($env:LMM_LIB_DIR) { + $text=[IO.File]::ReadAllText((Join-Path $env:LMM_LIB_DIR $Name),[Text.Encoding]::UTF8) + } else { + $uri="https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LibRevision/templates/lib/$Name" + $text=$null + $protocol=[Net.ServicePointManager]::SecurityProtocol try { - $started=$process.Start() - if(-not $started){throw 'Could not start the installation process.'} - $watch=[Diagnostics.Stopwatch]::StartNew(); $last=0 - while(-not $process.WaitForExit(1000)) { - if($watch.Elapsed.TotalSeconds -gt $CommandTimeout){ - Stop-InstallChild $process - throw 'Operation timed out. Increase LMM_COMMAND_TIMEOUT for slow networks and rerun.' - } - if($watch.Elapsed.TotalSeconds-$last -ge 15){Write-Log ('Still working: {0:N0}s elapsed.' -f $watch.Elapsed.TotalSeconds);$last=$watch.Elapsed.TotalSeconds} - } - $global:LASTEXITCODE=$process.ExitCode - if($process.ExitCode -ne 0){throw "Installation command failed with exit code $($process.ExitCode)."} - } finally { - if($started -and -not $process.HasExited){Stop-InstallChild $process} - $process.Dispose() - } -} -function Invoke-Native([string]$Command, [string[]]$Arguments) { - if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { - if ((Test-Path -LiteralPath $Command) -and (Select-String -LiteralPath $Command -SimpleMatch 'Managed by LMM installers' -Quiet)) { - $launcherLines=@(Get-Content -LiteralPath $Command) - foreach ($pathLine in $launcherLines) { - if ($pathLine -match '^set "PATH=%~dp0\.\.\\(.+);%PATH%"$') { - $runtime=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) - if (!$runtime.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed runtime escaped its root.' } - if (!(Test-Path -LiteralPath $runtime -PathType Container)) { throw 'Managed runtime is missing. Rerun the installer.' } - $env:PATH="$runtime;$env:PATH" + [Net.ServicePointManager]::SecurityProtocol=$protocol -bor [Net.SecurityProtocolType]::Tls12 + $options=@{Uri=$uri;UseBasicParsing=$true;TimeoutSec=60;ErrorAction='Stop'} + $proxyValue=if($env:HTTPS_PROXY){$env:HTTPS_PROXY}else{$env:HTTP_PROXY} + if ($proxyValue) { + $proxy=[Uri]$proxyValue + $options.Proxy=$proxy.GetLeftPart([UriPartial]::Authority) + if ($proxy.UserInfo) { + $parts=$proxy.UserInfo.Split(':',2) + $password=if($parts.Length -eq 2){[Uri]::UnescapeDataString($parts[1])}else{''} + $secure=ConvertTo-SecureString $password -AsPlainText -Force + $options.ProxyCredential=New-Object System.Management.Automation.PSCredential([Uri]::UnescapeDataString($parts[0]),$secure) } } - $line=$launcherLines[-1] - if ($line -match '^"%~dp0\.\.\\(.+)" %\*$') { - $resolved=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) - if (!$resolved.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed launcher target escaped its root.' } - $Command=$resolved + for ($attempt=1;$attempt -le 3;$attempt++) { + try { + $response=Invoke-WebRequest @options + $text=[Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) + break + } catch { if ($attempt -eq 3) { throw "Cannot fetch library $Name at $LibRevision. Check the network or set LMM_LIB_DIR." } } } - } - if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { - $parent=Split-Path $Command - $binName=[IO.Path]::GetFileNameWithoutExtension($Command).ToLowerInvariant() - switch ($binName) { - 'npm' { $packageName='npm' } - 'pi' { $packageName='@earendil-works/pi-coding-agent' } - 'pnpm' { $packageName='pnpm' } - 'dsh' { $packageName='@deepseek-ai/dsh' } - default { throw 'Unsupported command shim; use the managed installer or a native executable.' } - } - $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) - $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json - $binProperty=$manifest.PSObject.Properties['bin'] - if (!$binProperty) { throw 'Package manifest has no bin entry.' } - $bins=$binProperty.Value - $relative=$null - if ($bins -is [string]) { $relative=$bins } - elseif ($null -ne $bins -and $bins.PSObject.Properties[$binName]) { $relative=$bins.PSObject.Properties[$binName].Value } - if ($relative -isnot [string] -or !$relative -or [IO.Path]::IsPathRooted($relative)) { throw 'Invalid package bin entry.' } - $entry=[IO.Path]::GetFullPath((Join-Path $packageRoot $relative)) - if (!$entry.StartsWith($packageRoot + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Package bin entry escaped its package.' } - if (!(Test-Path -LiteralPath $entry)) { throw 'Client entry is missing. Rerun with -Update.' } - $Command=(Get-Command node.exe).Source - $Arguments=@($entry)+$Arguments - } - } - Invoke-Bounded $Command $Arguments -} -function Get-Hash([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } -function Set-RequestProxy($request) { - $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } - if ($proxyValue) { - $proxyUri = [Uri]$proxyValue - if ($proxyUri.Scheme -notin @('http','https')) { throw 'Use an HTTP(S) proxy with Windows PowerShell; SOCKS needs a local HTTP proxy adapter.' } - $proxy = New-Object Net.WebProxy($proxyUri.GetLeftPart([UriPartial]::Authority)) - if ($proxyUri.UserInfo) { - $parts=$proxyUri.UserInfo.Split(':',2) - $password=if ($parts.Length -eq 2) { [Uri]::UnescapeDataString($parts[1]) } else { '' } - $proxy.Credentials=New-Object Net.NetworkCredential([Uri]::UnescapeDataString($parts[0]),$password) - } - if ($env:NO_PROXY) { - $proxy.BypassList=@($env:NO_PROXY.Split(',') | ForEach-Object { $hostName=$_.Trim().TrimStart('.'); if($hostName -eq '*') { '.*' } elseif($hostName) { '^https?://([^/]+\.)?' + [regex]::Escape($hostName) + '(:[0-9]+)?(/|$)' } }) - } - $request.Proxy=$proxy - } -} -function New-DownloadRequest([Uri]$Uri) { return [Net.HttpWebRequest]::Create($Uri) } -function Get-RankedUrls([string[]]$Urls) { - $scores = @(); $index = 0 - foreach ($url in $Urls) { - $timer = [Diagnostics.Stopwatch]::StartNew(); $score = 999999 - try { - $request = New-DownloadRequest ([Uri]$url) - $request.Method = 'HEAD'; $request.Timeout = 4000; $request.AllowAutoRedirect = $true - Set-RequestProxy $request - $response = $request.GetResponse(); $response.Close(); $score = $timer.ElapsedMilliseconds - } catch { } finally { $timer.Stop() } - $scores += [pscustomobject]@{ Url=$url; Score=$score; Order=$index }; $index++ + } finally { [Net.ServicePointManager]::SecurityProtocol=$protocol } } - return @($scores | Sort-Object Score,Order | ForEach-Object { $_.Url }) -} -function Get-DownloadUrls([string]$Official) { - $mirrors = @() - if ($Official.StartsWith('https://nodejs.org/dist/')) { $mirrors = @($Official.Replace('https://nodejs.org/dist/','https://npmmirror.com/mirrors/node/')) } - elseif ($Official.StartsWith('https://github.com/')) { $mirrors = @("https://ghfast.top/$Official", "https://ghproxy.net/$Official") } - if ($Network -eq 'official') { return @($Official) } - if ($Network -eq 'china') { return @($mirrors) + @($Official) } - return @(Get-RankedUrls (@($Official) + @($mirrors))) -} -function Receive-Stream([string]$Url, [string]$Path) { - # Used on older Windows without curl.exe. ReadWriteTimeout bounds stalled reads. - $offset = 0L - if (Test-Path -LiteralPath $Path) { $offset = (Get-Item -LiteralPath $Path).Length } - $request = New-DownloadRequest ([Uri]$Url) - $request.Timeout = $ConnectTimeout*1000; $request.ReadWriteTimeout = $StallTimeout*1000; $request.AllowAutoRedirect = $true - Set-RequestProxy $request - if ($offset -gt 0) { $request.AddRange($offset) } - $response = $null; $inputStream = $null; $outputStream = $null - try { - $response = $request.GetResponse() - if ($response.ResponseUri.Scheme -ne 'https') { throw 'Insecure redirect refused.' } - $mode = [IO.FileMode]::Create - if ($offset -gt 0 -and [int]$response.StatusCode -eq 206) { - if ($response.Headers['Content-Range'] -notlike "bytes $offset-*") { throw 'Invalid resume response.' } - $mode = [IO.FileMode]::Append - } - $inputStream = $response.GetResponseStream() - $outputStream = [IO.File]::Open($Path,$mode,[IO.FileAccess]::Write,[IO.FileShare]::None) - $buffer = New-Object byte[] 65536; $windowBytes = 0L; $total = 0L - $window = [Diagnostics.Stopwatch]::StartNew(); $overall = [Diagnostics.Stopwatch]::StartNew() - while (($read = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) { - $outputStream.Write($buffer,0,$read); $windowBytes += $read; $total += $read - if ($overall.Elapsed.TotalSeconds -gt $DownloadTimeout) { throw 'Download timeout.' } - if ($window.Elapsed.TotalSeconds -ge $StallTimeout -and ($response.ContentLength -lt 0 -or $total -lt $response.ContentLength)) { - if ($windowBytes / $window.Elapsed.TotalSeconds -lt $MinSpeed) { throw 'Download too slow; changing source.' } - $window.Restart(); $windowBytes = 0 - } - } - } finally { - if ($outputStream) { $outputStream.Dispose() }; if ($inputStream) { $inputStream.Dispose() }; if ($response) { $response.Close() } - } -} -function Get-VerifiedFile([string]$Url, [string]$Destination, [string]$Expected) { - $parsed=[Uri]$Url - if ($parsed.Scheme -ne 'https' -or $parsed.UserInfo) { throw 'Download URLs must use HTTPS without credentials.' } - foreach($file in @($Destination,"$Destination.part","$Destination.part.url")) { if ((Test-Path -LiteralPath $file) -and ((Get-Item -LiteralPath $file).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Refusing symlink cache entries.' } } - if (-not $Update -and (Test-Path -LiteralPath $Destination) -and (Get-Hash $Destination) -eq $Expected) { Write-Log "Cached: $([IO.Path]::GetFileName($Destination))"; return } - $partial = "$Destination.part"; $sourceFile = "$partial.url" - if ((Test-Path -LiteralPath $partial) -and (Get-Hash $partial) -eq $Expected) { Move-Item -LiteralPath $partial -Destination $Destination -Force; return } - if ($env:LMM_NODE_BASE_URL -and $Url.StartsWith('https://nodejs.org/dist/')) { $Url=$Url.Replace('https://nodejs.org/dist',$env:LMM_NODE_BASE_URL.TrimEnd('/')) } - $sourceNumber = 0 - foreach ($source in @(Get-DownloadUrls $Url)) { - $sourceNumber++ - if ((Test-Path -LiteralPath $partial) -and (!(Test-Path -LiteralPath $sourceFile) -or (Get-Content -LiteralPath $sourceFile -Raw).Trim() -ne $source)) { Remove-Item -LiteralPath $partial -Force } - Set-Content -LiteralPath $sourceFile -Value $source -Encoding ASCII - foreach ($attempt in 1..$Retries) { - Write-Log "Downloading $([IO.Path]::GetFileName($Destination)) (source $sourceNumber, attempt $attempt)" - try { - $curl = Get-Command curl.exe -ErrorAction SilentlyContinue - if ($curl) { - Invoke-Native $curl.Source @('-q','--proto','=https','--proto-redir','=https','-fL','--connect-timeout',([string]$ConnectTimeout),'--max-time',([string]$DownloadTimeout),'--speed-time',([string]$StallTimeout),'--speed-limit',([string]$MinSpeed),'--continue-at','-','--output',$partial,$source) - } else { Receive-Stream $source $partial } - if ((Get-Hash $partial) -ne $Expected) { Remove-Item -LiteralPath $partial -Force; Write-Log 'Checksum mismatch; download will not be executed.'; break } - Move-Item -LiteralPath $partial -Destination $Destination -Force - Remove-Item -LiteralPath $sourceFile -Force -ErrorAction SilentlyContinue - return - } catch { - Write-Log 'Transfer failed or stalled. Retrying, then trying another source.' - if ($attempt -eq 1 -and (Test-Path -LiteralPath $partial)) { - # Keep a partial for retry; a rejected Range gets a clean retry next source. - if ($curl -and $LASTEXITCODE -in @(22,33,36)) { Remove-Item -LiteralPath $partial -Force } - } - } - } - } - throw 'All download sources failed. Retry -Network official or -Network china; inspect your proxy/CA settings.' + if ([string]::IsNullOrWhiteSpace($text)) { throw "Empty library: $Name" } + return [scriptblock]::Create($text) } + function Install-Lmm { $script:Phase='LMM CLI'; $destination=Join-Path $Root "apps\lmm\$LmmVersion-$Platform" if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination 'lmm.exe')) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { $script:Client=Join-Path $destination 'lmm.exe'; return } @@ -264,6 +99,7 @@ LMM $Target installer $ScriptVersion Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] [-FromSource] [-Launch] [-Help] +Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch). No automatic login or PATH changes. Pi on Windows requires Bash. -FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. "@ @@ -357,7 +193,15 @@ function Invoke-LmmSetup { } $savedEnvironment=@{} foreach($name in @('PATH','npm_config_cache','npm_config_fetch_retries','npm_config_fetch_timeout','npm_config_prefer_offline','npm_config_fetch_retry_mintimeout','npm_config_fetch_retry_maxtimeout','npm_config_strict_ssl','npm_config_registry','npm_config_store_dir')) { $savedEnvironment[$name]=[Environment]::GetEnvironmentVariable($name,'Process') } -try { Invoke-LmmSetup; exit 0 } +try { + if ($Help) { Show-Usage; exit 0 } + $script:Phase='libraries' + foreach ($library in @('common.ps1','download.ps1')) { + . (Get-LmmLibrary $library) + } + $script:Phase='arguments' + Invoke-LmmSetup; exit 0 +} catch { Write-Error "Stopped during $script:Phase. $($_.Exception.Message)" -ErrorAction Continue; exit 1 } finally { foreach($name in $savedEnvironment.Keys) { [Environment]::SetEnvironmentVariable($name,$savedEnvironment[$name],'Process') } diff --git a/lmm.sh b/lmm.sh index a18912f..586a076 100755 --- a/lmm.sh +++ b/lmm.sh @@ -4,7 +4,8 @@ lmm_install_main() { set -euo pipefail set +x TARGET=lmm -SCRIPT_VERSION=2026.09.20.2 +SCRIPT_VERSION=2026.09.20.3 +LIB_REVISION=ce6aea96cd73d633424daaa6e2e25ac18fd33b5c LMM_VERSION=0.1.0 LMM_RELEASE_BASE=https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0 lmm_hash() { case "$1" in @@ -14,100 +15,34 @@ lmm_hash() { case "$1" in *) printf '\n';; esac; } -lmm_root() { - printf '%s\n' "${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}" -} -sha256() { - local digest - if command -v sha256sum >/dev/null 2>&1; then digest=$(sha256sum "$1") || return; printf '%s\n' "${digest%% *}" - elif command -v shasum >/dev/null 2>&1; then digest=$(shasum -a 256 "$1") || return; printf '%s\n' "${digest%% *}" - elif command -v openssl >/dev/null 2>&1; then digest=$(openssl dgst -sha256 "$1") || return; printf '%s\n' "${digest##* }" - else printf 'Install a SHA-256 tool.\n' >&2; return 1; fi -} -# Native Termux uses Android/bionic, not desktop Linux/glibc. -lmm_is_termux() { - [ -n "${TERMUX_VERSION:-}${TERMUX_APP__PACKAGE_NAME:-}" ] || - case "${PREFIX:-}" in */com.termux/files/usr) true;; *) false;; esac -} -lmm_temp_root() { - if [ -n "${TMPDIR:-}" ]; then printf '%s\n' "$TMPDIR" - elif lmm_is_termux; then printf '%s/tmp\n' "${PREFIX:-$HOME/.cache/lmm-tools}" - else printf '/tmp\n'; fi -} -lmm_check_storage() { - lmm_is_termux || return 0 - local resolved - # realpath -m also resolves missing paths and symlinked storage aliases. - command -v realpath >/dev/null 2>&1 || { - printf 'Termux needs coreutils: pkg install coreutils\n' >&2; return 1; - } - resolved=$(realpath -m -- "$1") || return 1 - case "$resolved/" in - /sdcard/*|/storage/*|/mnt/sdcard/*|/mnt/media_rw/*|/mnt/runtime/*|/mnt/user/*|/mnt/pass_through/*) - printf 'Use Termux private storage under HOME, not shared storage: %s\n' "$1" >&2 - return 1;; - esac -} -quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } -rank_urls() { - local i=0 url response code elapsed probe_dir - if ! command -v curl >/dev/null 2>&1; then for url in "$@"; do printf '%s\n' "$i"; i=$((i+1)); done; return; fi - probe_dir=$(mktemp -d "$STAGE/probes.XXXXXX") - for url in "$@"; do - ( - response=$(curl -q --proto '=https' --proto-redir '=https' -ILs --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code} %{time_starttransfer}' "$url" 2>/dev/null || true) - code=${response%% *}; elapsed=${response#* } - case "$code" in 2??|3??) ;; *) elapsed=999;; esac - case "$elapsed" in ''|*[!0-9.]*) elapsed=999;; esac - printf '%s %s\n' "$elapsed" "$i" > "$probe_dir/$i" - ) & - i=$((i+1)) - done - wait - cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | while read -r elapsed index; do printf '%s\n' "$index"; done -} -urls_for() { - URLS=("$1") - case "$1" in - https://nodejs.org/dist/*) MIRRORS=("https://npmmirror.com/mirrors/node/${1#https://nodejs.org/dist/}");; - https://github.com/*) MIRRORS=("https://ghfast.top/$1" "https://ghproxy.net/$1");; - *) MIRRORS=();; - esac - case "$NETWORK" in - auto) URLS+=("${MIRRORS[@]}");; - china) URLS=("${MIRRORS[@]}" "$1");; - esac -} -download() { - local official=$1 destination=$2 expected=$3 index url part attempt actual order transfer_status - part="$destination.part" - if [ -L "$destination" ] || [ -L "$part" ] || [ -L "$part.url" ]; then fail 'Refusing symlink cache entries'; fi - if [ "$FORCE" = 0 ] && [ -f "$destination" ] && [ "$(sha256 "$destination")" = "$expected" ]; then log "Cached: ${destination##*/}"; return; fi - if [ -f "$part" ] && [ "$(sha256 "$part")" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi - command -v curl >/dev/null 2>&1 || fail 'curl is required for downloads. Install it with your OS package manager.' - if [[ $official == https://nodejs.org/dist/* && -n ${LMM_NODE_BASE_URL:-} ]]; then official="${LMM_NODE_BASE_URL%/}/${official#https://nodejs.org/dist/}"; fi - urls_for "$official" - if [ "$NETWORK" = auto ]; then order=$(rank_urls "${URLS[@]}"); else order=$(printf '%s\n' "${!URLS[@]}"); fi - for index in $order; do - url=${URLS[$index]} - if [ -f "$part" ] && [ "$(cat "$part.url" 2>/dev/null || true)" != "$url" ]; then rm -f -- "$part"; fi - printf '%s\n' "$url" > "$part.url" - for ((attempt=1; attempt<=RETRIES; attempt++)); do - log "Downloading ${destination##*/} (source $((index+1)), attempt $attempt; low-speed cutoff ${STALL_TIMEOUT}s)" - if curl -q --proto '=https' --proto-redir '=https' -fL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$DOWNLOAD_TIMEOUT" --speed-time "$STALL_TIMEOUT" --speed-limit "$MIN_SPEED" --continue-at - --output "$part" "$url"; then - actual=$(sha256 "$part") - if [ "$actual" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi - log 'Checksum mismatch: discarded the download; it will not be executed.' - rm -f -- "$part" +# Fetch completely before sourcing: process substitution alone hides curl errors. +lmm_source_lib() { + local lmm_name=$1 lmm_text='' lmm_attempt + if [ -n "${LMM_LIB_DIR:-}" ]; then + lmm_text=$(cat -- "$LMM_LIB_DIR/$lmm_name") || { + printf 'Cannot read local library: %s/%s\n' "$LMM_LIB_DIR" "$lmm_name" >&2; return 1; + } + else + for lmm_attempt in 1 2 3; do + if lmm_text=$(curl -q -fsSL --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 60 \ + "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LIB_REVISION/templates/lib/$lmm_name"); then break - else transfer_status=$?; fi - # A server may reject Range; retry once from a clean file. Retain a - # partial transfer after final failure for the next invocation. - if [ "$attempt" = 1 ]; then case "$transfer_status" in 22|33|36) rm -f -- "$part";; esac; fi + fi + if [ "$lmm_attempt" = 3 ]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi done - done - fail "Download failed: ${destination##*/}. Rerun to resume, or choose another --network mode." + fi + if [[ $lmm_text != *[![:space:]]* ]]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") } + install_lmm() { PHASE='LMM CLI' local hash target="$ROOT/apps/lmm/$LMM_VERSION-$PLATFORM" archive @@ -139,10 +74,12 @@ install_lmm() { } install_tool() { install_lmm; } -ROOT=$(lmm_root) +ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 STAGE='' LOCKED=0 PHASE=arguments RUN_ARGS=() +# State is consumed by dynamically imported helpers. +# shellcheck disable=SC2034 PNPM_BIN='' log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } @@ -164,9 +101,12 @@ Usage: bash $TARGET.sh [options] [-- launch arguments] --from-source LMM CLI: build the pinned crate using existing Rust 1.88+ --launch Start the installed tool (DSH starts the chosen profile) --help Show this help -No automatic login or PATH changes. Versions and platform notes: README.md. +Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch). +No automatic login or PATH changes. See README.md. USAGE } +# State is consumed by dynamically imported helpers. +# shellcheck disable=SC2034 while [ "$#" -gt 0 ]; do case "$1" in --help|-h) usage; exit 0;; @@ -204,6 +144,9 @@ case "$PROFILE" in web|headless) ;; *) fail 'profile must be web or headless';; [ "$TARGET" = lmm ] || [ "$SOURCE" = 0 ] || fail '--from-source is only for lmm' case "$ROOT" in *$'\n'*|*$'\r'*) fail 'Install path must not contain newlines';; /*) ;; *) ROOT="$PWD/$ROOT";; esac if [ "$ROOT" = / ] || [ "$ROOT" = "$HOME" ]; then fail 'Choose a dedicated installation directory'; fi +for library in hash.sh termux.sh quote.sh download.sh; do + lmm_source_lib "$library" || exit $? +done case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac if lmm_is_termux; then OS=android; fi case "$(uname -m)" in @@ -219,7 +162,7 @@ if [ "$OS" = android ] && [ "$TARGET" != lmm ]; then compatible_node || fail 'In Termux, install native Node/npm: pkg install nodejs npm git; then rerun. Desktop Node cannot run on Android.' command -v git >/dev/null 2>&1 || fail 'Pi/DSH need git: pkg install git' fi -# --check never creates directories, downloads, edits PATH or touches credentials. +# --check does not write files; common modules may be fetched into memory. if [ "$CHECK" = 1 ]; then log "Platform: $PLATFORM; install root: $ROOT" if [ -x "$ROOT/bin/$TARGET" ]; then "$ROOT/bin/$TARGET" --version diff --git a/pi.ps1 b/pi.ps1 index b699afe..a0ba3d1 100644 --- a/pi.ps1 +++ b/pi.ps1 @@ -12,7 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'pi' -$ScriptVersion = '2026.09.20.2' +$ScriptVersion = '2026.09.20.3' +$LibRevision = 'ce6aea96cd73d633424daaa6e2e25ac18fd33b5c' $NodeVersion = '24.21.0' $PiVersion = '0.85.1' $PiProviderVersion = '0.1.0-alpha.1' @@ -25,278 +26,40 @@ $NodeHashes = @{ 'win-arm64' = '8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921' } -function Setting([string]$Name, [int]$Default, [int]$Maximum) { - $raw = [Environment]::GetEnvironmentVariable($Name) - if ([string]::IsNullOrEmpty($raw)) { return $Default } - $number = 0 - if ($raw -notmatch '^[1-9][0-9]*$' -or -not [int]::TryParse($raw, [ref]$number) -or $number -gt $Maximum) { throw "$Name must be between 1 and $Maximum." } - return $number -} -function QuoteArgument([string]$Value) { - if($Value -notmatch '[\s"]' -and $Value.Length){return $Value} - return '"' + [regex]::Replace([regex]::Replace($Value,'(\\*)"','$1$1\"'),'(\\+)$','$1$1') + '"' -} -function Stop-InstallChild($Process) { - if($Process.HasExited){return} - $killer=$null - if($env:SystemRoot){$killer=Join-Path $env:SystemRoot 'System32\taskkill.exe'} - if($killer -and (Test-Path -LiteralPath $killer)){ & $killer /PID $Process.Id /T /F 2>$null | Out-Null } - if(-not $Process.HasExited){try{$Process.Kill($true)}catch{$Process.Kill()}} - [void]$Process.WaitForExit(10000) -} -function Invoke-Bounded([string]$Executable,[string[]]$Arguments) { - $info=New-Object Diagnostics.ProcessStartInfo - $info.FileName=$Executable; $info.UseShellExecute=$false - if ((Get-Location).Provider.Name -eq 'FileSystem') { $info.WorkingDirectory=(Get-Location).ProviderPath } - $info.Arguments=($Arguments | ForEach-Object { QuoteArgument $_ }) -join ' ' - $process=New-Object Diagnostics.Process; $process.StartInfo=$info - $started=$false +function Get-LmmLibrary([string]$Name) { + if ($env:LMM_LIB_DIR) { + $text=[IO.File]::ReadAllText((Join-Path $env:LMM_LIB_DIR $Name),[Text.Encoding]::UTF8) + } else { + $uri="https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LibRevision/templates/lib/$Name" + $text=$null + $protocol=[Net.ServicePointManager]::SecurityProtocol try { - $started=$process.Start() - if(-not $started){throw 'Could not start the installation process.'} - $watch=[Diagnostics.Stopwatch]::StartNew(); $last=0 - while(-not $process.WaitForExit(1000)) { - if($watch.Elapsed.TotalSeconds -gt $CommandTimeout){ - Stop-InstallChild $process - throw 'Operation timed out. Increase LMM_COMMAND_TIMEOUT for slow networks and rerun.' - } - if($watch.Elapsed.TotalSeconds-$last -ge 15){Write-Log ('Still working: {0:N0}s elapsed.' -f $watch.Elapsed.TotalSeconds);$last=$watch.Elapsed.TotalSeconds} - } - $global:LASTEXITCODE=$process.ExitCode - if($process.ExitCode -ne 0){throw "Installation command failed with exit code $($process.ExitCode)."} - } finally { - if($started -and -not $process.HasExited){Stop-InstallChild $process} - $process.Dispose() - } -} -function Invoke-Native([string]$Command, [string[]]$Arguments) { - if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { - if ((Test-Path -LiteralPath $Command) -and (Select-String -LiteralPath $Command -SimpleMatch 'Managed by LMM installers' -Quiet)) { - $launcherLines=@(Get-Content -LiteralPath $Command) - foreach ($pathLine in $launcherLines) { - if ($pathLine -match '^set "PATH=%~dp0\.\.\\(.+);%PATH%"$') { - $runtime=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) - if (!$runtime.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed runtime escaped its root.' } - if (!(Test-Path -LiteralPath $runtime -PathType Container)) { throw 'Managed runtime is missing. Rerun the installer.' } - $env:PATH="$runtime;$env:PATH" + [Net.ServicePointManager]::SecurityProtocol=$protocol -bor [Net.SecurityProtocolType]::Tls12 + $options=@{Uri=$uri;UseBasicParsing=$true;TimeoutSec=60;ErrorAction='Stop'} + $proxyValue=if($env:HTTPS_PROXY){$env:HTTPS_PROXY}else{$env:HTTP_PROXY} + if ($proxyValue) { + $proxy=[Uri]$proxyValue + $options.Proxy=$proxy.GetLeftPart([UriPartial]::Authority) + if ($proxy.UserInfo) { + $parts=$proxy.UserInfo.Split(':',2) + $password=if($parts.Length -eq 2){[Uri]::UnescapeDataString($parts[1])}else{''} + $secure=ConvertTo-SecureString $password -AsPlainText -Force + $options.ProxyCredential=New-Object System.Management.Automation.PSCredential([Uri]::UnescapeDataString($parts[0]),$secure) } } - $line=$launcherLines[-1] - if ($line -match '^"%~dp0\.\.\\(.+)" %\*$') { - $resolved=[IO.Path]::GetFullPath((Join-Path (Split-Path (Split-Path $Command)) $Matches[1])) - if (!$resolved.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Managed launcher target escaped its root.' } - $Command=$resolved - } - } - if ([IO.Path]::GetExtension($Command) -ieq '.cmd') { - $parent=Split-Path $Command - $binName=[IO.Path]::GetFileNameWithoutExtension($Command).ToLowerInvariant() - switch ($binName) { - 'npm' { $packageName='npm' } - 'pi' { $packageName='@earendil-works/pi-coding-agent' } - 'pnpm' { $packageName='pnpm' } - 'dsh' { $packageName='@deepseek-ai/dsh' } - default { throw 'Unsupported command shim; use the managed installer or a native executable.' } - } - $packageRoot=[IO.Path]::GetFullPath((Join-Path $parent ('node_modules/' + $packageName))) - $manifest=Get-Content -LiteralPath (Join-Path $packageRoot 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json - $binProperty=$manifest.PSObject.Properties['bin'] - if (!$binProperty) { throw 'Package manifest has no bin entry.' } - $bins=$binProperty.Value - $relative=$null - if ($bins -is [string]) { $relative=$bins } - elseif ($null -ne $bins -and $bins.PSObject.Properties[$binName]) { $relative=$bins.PSObject.Properties[$binName].Value } - if ($relative -isnot [string] -or !$relative -or [IO.Path]::IsPathRooted($relative)) { throw 'Invalid package bin entry.' } - $entry=[IO.Path]::GetFullPath((Join-Path $packageRoot $relative)) - if (!$entry.StartsWith($packageRoot + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { throw 'Package bin entry escaped its package.' } - if (!(Test-Path -LiteralPath $entry)) { throw 'Client entry is missing. Rerun with -Update.' } - $Command=(Get-Command node.exe).Source - $Arguments=@($entry)+$Arguments - } - } - Invoke-Bounded $Command $Arguments -} -function Get-Hash([string]$Path) { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } -function Set-RequestProxy($request) { - $proxyValue = if ($env:HTTPS_PROXY) { $env:HTTPS_PROXY } else { $env:HTTP_PROXY } - if ($proxyValue) { - $proxyUri = [Uri]$proxyValue - if ($proxyUri.Scheme -notin @('http','https')) { throw 'Use an HTTP(S) proxy with Windows PowerShell; SOCKS needs a local HTTP proxy adapter.' } - $proxy = New-Object Net.WebProxy($proxyUri.GetLeftPart([UriPartial]::Authority)) - if ($proxyUri.UserInfo) { - $parts=$proxyUri.UserInfo.Split(':',2) - $password=if ($parts.Length -eq 2) { [Uri]::UnescapeDataString($parts[1]) } else { '' } - $proxy.Credentials=New-Object Net.NetworkCredential([Uri]::UnescapeDataString($parts[0]),$password) - } - if ($env:NO_PROXY) { - $proxy.BypassList=@($env:NO_PROXY.Split(',') | ForEach-Object { $hostName=$_.Trim().TrimStart('.'); if($hostName -eq '*') { '.*' } elseif($hostName) { '^https?://([^/]+\.)?' + [regex]::Escape($hostName) + '(:[0-9]+)?(/|$)' } }) - } - $request.Proxy=$proxy - } -} -function New-DownloadRequest([Uri]$Uri) { return [Net.HttpWebRequest]::Create($Uri) } -function Get-RankedUrls([string[]]$Urls) { - $scores = @(); $index = 0 - foreach ($url in $Urls) { - $timer = [Diagnostics.Stopwatch]::StartNew(); $score = 999999 - try { - $request = New-DownloadRequest ([Uri]$url) - $request.Method = 'HEAD'; $request.Timeout = 4000; $request.AllowAutoRedirect = $true - Set-RequestProxy $request - $response = $request.GetResponse(); $response.Close(); $score = $timer.ElapsedMilliseconds - } catch { } finally { $timer.Stop() } - $scores += [pscustomobject]@{ Url=$url; Score=$score; Order=$index }; $index++ - } - return @($scores | Sort-Object Score,Order | ForEach-Object { $_.Url }) -} -function Get-DownloadUrls([string]$Official) { - $mirrors = @() - if ($Official.StartsWith('https://nodejs.org/dist/')) { $mirrors = @($Official.Replace('https://nodejs.org/dist/','https://npmmirror.com/mirrors/node/')) } - elseif ($Official.StartsWith('https://github.com/')) { $mirrors = @("https://ghfast.top/$Official", "https://ghproxy.net/$Official") } - if ($Network -eq 'official') { return @($Official) } - if ($Network -eq 'china') { return @($mirrors) + @($Official) } - return @(Get-RankedUrls (@($Official) + @($mirrors))) -} -function Receive-Stream([string]$Url, [string]$Path) { - # Used on older Windows without curl.exe. ReadWriteTimeout bounds stalled reads. - $offset = 0L - if (Test-Path -LiteralPath $Path) { $offset = (Get-Item -LiteralPath $Path).Length } - $request = New-DownloadRequest ([Uri]$Url) - $request.Timeout = $ConnectTimeout*1000; $request.ReadWriteTimeout = $StallTimeout*1000; $request.AllowAutoRedirect = $true - Set-RequestProxy $request - if ($offset -gt 0) { $request.AddRange($offset) } - $response = $null; $inputStream = $null; $outputStream = $null - try { - $response = $request.GetResponse() - if ($response.ResponseUri.Scheme -ne 'https') { throw 'Insecure redirect refused.' } - $mode = [IO.FileMode]::Create - if ($offset -gt 0 -and [int]$response.StatusCode -eq 206) { - if ($response.Headers['Content-Range'] -notlike "bytes $offset-*") { throw 'Invalid resume response.' } - $mode = [IO.FileMode]::Append - } - $inputStream = $response.GetResponseStream() - $outputStream = [IO.File]::Open($Path,$mode,[IO.FileAccess]::Write,[IO.FileShare]::None) - $buffer = New-Object byte[] 65536; $windowBytes = 0L; $total = 0L - $window = [Diagnostics.Stopwatch]::StartNew(); $overall = [Diagnostics.Stopwatch]::StartNew() - while (($read = $inputStream.Read($buffer,0,$buffer.Length)) -gt 0) { - $outputStream.Write($buffer,0,$read); $windowBytes += $read; $total += $read - if ($overall.Elapsed.TotalSeconds -gt $DownloadTimeout) { throw 'Download timeout.' } - if ($window.Elapsed.TotalSeconds -ge $StallTimeout -and ($response.ContentLength -lt 0 -or $total -lt $response.ContentLength)) { - if ($windowBytes / $window.Elapsed.TotalSeconds -lt $MinSpeed) { throw 'Download too slow; changing source.' } - $window.Restart(); $windowBytes = 0 - } - } - } finally { - if ($outputStream) { $outputStream.Dispose() }; if ($inputStream) { $inputStream.Dispose() }; if ($response) { $response.Close() } - } -} -function Get-VerifiedFile([string]$Url, [string]$Destination, [string]$Expected) { - $parsed=[Uri]$Url - if ($parsed.Scheme -ne 'https' -or $parsed.UserInfo) { throw 'Download URLs must use HTTPS without credentials.' } - foreach($file in @($Destination,"$Destination.part","$Destination.part.url")) { if ((Test-Path -LiteralPath $file) -and ((Get-Item -LiteralPath $file).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Refusing symlink cache entries.' } } - if (-not $Update -and (Test-Path -LiteralPath $Destination) -and (Get-Hash $Destination) -eq $Expected) { Write-Log "Cached: $([IO.Path]::GetFileName($Destination))"; return } - $partial = "$Destination.part"; $sourceFile = "$partial.url" - if ((Test-Path -LiteralPath $partial) -and (Get-Hash $partial) -eq $Expected) { Move-Item -LiteralPath $partial -Destination $Destination -Force; return } - if ($env:LMM_NODE_BASE_URL -and $Url.StartsWith('https://nodejs.org/dist/')) { $Url=$Url.Replace('https://nodejs.org/dist',$env:LMM_NODE_BASE_URL.TrimEnd('/')) } - $sourceNumber = 0 - foreach ($source in @(Get-DownloadUrls $Url)) { - $sourceNumber++ - if ((Test-Path -LiteralPath $partial) -and (!(Test-Path -LiteralPath $sourceFile) -or (Get-Content -LiteralPath $sourceFile -Raw).Trim() -ne $source)) { Remove-Item -LiteralPath $partial -Force } - Set-Content -LiteralPath $sourceFile -Value $source -Encoding ASCII - foreach ($attempt in 1..$Retries) { - Write-Log "Downloading $([IO.Path]::GetFileName($Destination)) (source $sourceNumber, attempt $attempt)" - try { - $curl = Get-Command curl.exe -ErrorAction SilentlyContinue - if ($curl) { - Invoke-Native $curl.Source @('-q','--proto','=https','--proto-redir','=https','-fL','--connect-timeout',([string]$ConnectTimeout),'--max-time',([string]$DownloadTimeout),'--speed-time',([string]$StallTimeout),'--speed-limit',([string]$MinSpeed),'--continue-at','-','--output',$partial,$source) - } else { Receive-Stream $source $partial } - if ((Get-Hash $partial) -ne $Expected) { Remove-Item -LiteralPath $partial -Force; Write-Log 'Checksum mismatch; download will not be executed.'; break } - Move-Item -LiteralPath $partial -Destination $Destination -Force - Remove-Item -LiteralPath $sourceFile -Force -ErrorAction SilentlyContinue - return - } catch { - Write-Log 'Transfer failed or stalled. Retrying, then trying another source.' - if ($attempt -eq 1 -and (Test-Path -LiteralPath $partial)) { - # Keep a partial for retry; a rejected Range gets a clean retry next source. - if ($curl -and $LASTEXITCODE -in @(22,33,36)) { Remove-Item -LiteralPath $partial -Force } - } + for ($attempt=1;$attempt -le 3;$attempt++) { + try { + $response=Invoke-WebRequest @options + $text=[Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) + break + } catch { if ($attempt -eq 3) { throw "Cannot fetch library $Name at $LibRevision. Check the network or set LMM_LIB_DIR." } } } - } - } - throw 'All download sources failed. Retry -Network official or -Network china; inspect your proxy/CA settings.' -} -function Test-Node { - $node = Get-Command node.exe -ErrorAction SilentlyContinue - $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue - if (-not $node -or -not $npm) { return $false } - try { $versionText=(& $node.Source --version 2>$null | Out-String).Trim() } catch { return $false } - if ($LASTEXITCODE -ne 0 -or $versionText -notmatch '^v([0-9]+)\.([0-9]+)\.[0-9]+') { return $false } - $major=[int]$Matches[1];$minor=[int]$Matches[2] - return (($major -eq 22 -and $minor -ge 19) -or $major -ge 24) -} -function Install-Node { - $script:Phase = 'Node.js runtime' - if (Test-Node) { $script:NodeBin = Split-Path (Get-Command node.exe).Source; return } - $directory = Join-Path $Root "runtime\node-v$NodeVersion-$Platform" - if (Test-Path -LiteralPath (Join-Path $directory 'node.exe')) { $env:PATH = "$directory;$env:PATH" } - if (Test-Node) { $script:NodeBin = $directory; return } - if ($NoInstallNode -or $NoBootstrap) { throw 'Need Node 22.19+ (22.x) or Node 24+, including npm.' } - $hash = $NodeHashes[$Platform] - if (-not $hash) { throw "No verified Node archive for $Platform" } - $name = "node-v$NodeVersion-$Platform.zip"; $archive = Join-Path $script:Cache $name - Get-VerifiedFile "https://nodejs.org/dist/v$NodeVersion/$name" $archive $hash - $unpack = Join-Path $script:Stage 'runtime'; Expand-Archive -LiteralPath $archive -DestinationPath $unpack - $extracted = Join-Path $unpack "node-v$NodeVersion-$Platform" - Invoke-Native (Join-Path $extracted 'node.exe') @('--version') - if (Test-Path -LiteralPath $directory) { throw "Managed runtime is present but unusable; inspect $directory before replacing." } - Move-Item -LiteralPath $extracted -Destination $directory - $script:NodeBin = $directory; $env:PATH = "$directory;$env:PATH" -} -function Set-NpmNetwork { - if (-not $env:npm_config_cache) { $cache=(& npm.cmd config get cache 2>$null | Out-String).Trim(); if ($cache) { $env:npm_config_cache=$cache } else { $env:npm_config_cache=Join-Path $script:Cache 'npm' } } - $env:npm_config_fetch_retries=[string]$Retries; $env:npm_config_fetch_timeout=[string]($StallTimeout*1000); $env:npm_config_fetch_retry_mintimeout='2000'; $env:npm_config_fetch_retry_maxtimeout='30000'; $env:npm_config_strict_ssl='true'; $env:npm_config_prefer_offline='true' - if ($env:LMM_NPM_REGISTRY) { $env:npm_config_registry=$env:LMM_NPM_REGISTRY } - $current = (& npm.cmd config get registry 2>$null | Out-String).Trim() - if ($env:npm_config_registry -or ($current -and $current -ne 'https://registry.npmjs.org/')) { Write-Log 'Keeping your existing npm registry/proxy configuration.'; return } - $script:NpmSelected = $true - if ($Network -eq 'china') { $env:npm_config_registry='https://registry.npmmirror.com/' } - elseif ($Network -eq 'official') { $env:npm_config_registry='https://registry.npmjs.org/' } - else { $env:npm_config_registry = @(Get-RankedUrls @('https://registry.npmjs.org/','https://registry.npmmirror.com/'))[0] } - Write-Log 'Registry selection affects this process only, not your global npm configuration.' -} -function Invoke-WithRegistryRetry([string]$Command,[string[]]$Arguments) { - try { Invoke-Native $Command $Arguments } catch { - if ($Network -ne 'auto' -or -not $script:NpmSelected) { throw } - if ($env:npm_config_registry -eq 'https://registry.npmjs.org/') { $env:npm_config_registry='https://registry.npmmirror.com/' } else { $env:npm_config_registry='https://registry.npmjs.org/' } - Write-Log 'Retrying with the alternate registry and the same cache.' - Invoke-Native $Command $Arguments - } -} -function Install-Client([string]$Package,[string]$Version,[string]$Entry) { - $script:Phase="$Target client"; $destination=Join-Path $Root "apps\$Target\$Version" - if (-not $Update -and (Test-Path -LiteralPath (Join-Path $destination "$Entry.cmd")) -and (Test-Path -LiteralPath (Join-Path $destination '.lmm-managed')) -and (Get-Content -LiteralPath (Join-Path $destination '.lmm-managed') -Raw).Trim() -eq "$Version|$ScriptVersion") { $script:Client=Join-Path $destination "$Entry.cmd"; return } - if ($NoBootstrap) { throw 'Managed client is missing. Rerun without -NoBootstrap.' } - $work=Join-Path $script:Stage 'client'; New-Item -ItemType Directory -Path $work | Out-Null - $installArgs=@('install','--global','--prefix',$work,'--no-audit','--no-fund',"$Package@$Version") - if ($Target -eq 'pi') { - # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. - $installArgs+=@('--ignore-scripts') - } else { - $allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' - $npmHelp=(& npm.cmd install --help 2>$null | Out-String) - if ($npmHelp.Contains('--allow-scripts')) { $installArgs+=@("--allow-scripts=$allow") } - if ((& npm.cmd config get ignore-scripts 2>$null | Out-String).Trim() -eq 'true') { throw 'DSH needs native build scripts. Review your package-specific build policy; ignore-scripts=true will not be overridden.' } - } - Invoke-WithRegistryRetry (Get-Command npm.cmd).Source $installArgs - Invoke-Native (Join-Path $work "$Entry.cmd") @('--version') - Set-Content -LiteralPath (Join-Path $work '.lmm-managed') -Value "$Version|$ScriptVersion" - New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null - if (Test-Path -LiteralPath $destination) { - if (!(Test-Path -LiteralPath (Join-Path $destination '.lmm-managed'))) { throw "Refusing unowned directory: $destination" } - $destination += '-reinstall-' + [Guid]::NewGuid().ToString('N') + } finally { [Net.ServicePointManager]::SecurityProtocol=$protocol } } - Move-Item -LiteralPath $work -Destination $destination; $script:Client=Join-Path $destination "$Entry.cmd" + if ([string]::IsNullOrWhiteSpace($text)) { throw "Empty library: $Name" } + return [scriptblock]::Create($text) } + function Assert-PiShell { # Follow Pi's shellPath -> Git Bash -> PATH lookup, without editing settings. $agentDirectory=$env:PI_CODING_AGENT_DIR @@ -339,6 +102,7 @@ LMM $Target installer $ScriptVersion Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] [-FromSource] [-Launch] [-Help] +Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch). No automatic login or PATH changes. Pi on Windows requires Bash. -FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. "@ @@ -432,7 +196,15 @@ function Invoke-LmmSetup { } $savedEnvironment=@{} foreach($name in @('PATH','npm_config_cache','npm_config_fetch_retries','npm_config_fetch_timeout','npm_config_prefer_offline','npm_config_fetch_retry_mintimeout','npm_config_fetch_retry_maxtimeout','npm_config_strict_ssl','npm_config_registry','npm_config_store_dir')) { $savedEnvironment[$name]=[Environment]::GetEnvironmentVariable($name,'Process') } -try { Invoke-LmmSetup; exit 0 } +try { + if ($Help) { Show-Usage; exit 0 } + $script:Phase='libraries' + foreach ($library in @('common.ps1','download.ps1','node.ps1')) { + . (Get-LmmLibrary $library) + } + $script:Phase='arguments' + Invoke-LmmSetup; exit 0 +} catch { Write-Error "Stopped during $script:Phase. $($_.Exception.Message)" -ErrorAction Continue; exit 1 } finally { foreach($name in $savedEnvironment.Keys) { [Environment]::SetEnvironmentVariable($name,$savedEnvironment[$name],'Process') } diff --git a/pi.sh b/pi.sh index ee84b15..103ba89 100755 --- a/pi.sh +++ b/pi.sh @@ -4,7 +4,8 @@ lmm_install_main() { set -euo pipefail set +x TARGET=pi -SCRIPT_VERSION=2026.09.20.2 +SCRIPT_VERSION=2026.09.20.3 +LIB_REVISION=ce6aea96cd73d633424daaa6e2e25ac18fd33b5c NODE_VERSION=24.21.0 PI_VERSION=0.85.1 PI_PROVIDER_VERSION=0.1.0-alpha.1 @@ -18,199 +19,34 @@ node_hash() { case "$1" in *) printf '\n';; esac; } -lmm_root() { - printf '%s\n' "${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}" -} -sha256() { - local digest - if command -v sha256sum >/dev/null 2>&1; then digest=$(sha256sum "$1") || return; printf '%s\n' "${digest%% *}" - elif command -v shasum >/dev/null 2>&1; then digest=$(shasum -a 256 "$1") || return; printf '%s\n' "${digest%% *}" - elif command -v openssl >/dev/null 2>&1; then digest=$(openssl dgst -sha256 "$1") || return; printf '%s\n' "${digest##* }" - else printf 'Install a SHA-256 tool.\n' >&2; return 1; fi -} -# Native Termux uses Android/bionic, not desktop Linux/glibc. -lmm_is_termux() { - [ -n "${TERMUX_VERSION:-}${TERMUX_APP__PACKAGE_NAME:-}" ] || - case "${PREFIX:-}" in */com.termux/files/usr) true;; *) false;; esac -} -lmm_temp_root() { - if [ -n "${TMPDIR:-}" ]; then printf '%s\n' "$TMPDIR" - elif lmm_is_termux; then printf '%s/tmp\n' "${PREFIX:-$HOME/.cache/lmm-tools}" - else printf '/tmp\n'; fi -} -lmm_check_storage() { - lmm_is_termux || return 0 - local resolved - # realpath -m also resolves missing paths and symlinked storage aliases. - command -v realpath >/dev/null 2>&1 || { - printf 'Termux needs coreutils: pkg install coreutils\n' >&2; return 1; - } - resolved=$(realpath -m -- "$1") || return 1 - case "$resolved/" in - /sdcard/*|/storage/*|/mnt/sdcard/*|/mnt/media_rw/*|/mnt/runtime/*|/mnt/user/*|/mnt/pass_through/*) - printf 'Use Termux private storage under HOME, not shared storage: %s\n' "$1" >&2 - return 1;; - esac -} -quote_sh() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } -rank_urls() { - local i=0 url response code elapsed probe_dir - if ! command -v curl >/dev/null 2>&1; then for url in "$@"; do printf '%s\n' "$i"; i=$((i+1)); done; return; fi - probe_dir=$(mktemp -d "$STAGE/probes.XXXXXX") - for url in "$@"; do - ( - response=$(curl -q --proto '=https' --proto-redir '=https' -ILs --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code} %{time_starttransfer}' "$url" 2>/dev/null || true) - code=${response%% *}; elapsed=${response#* } - case "$code" in 2??|3??) ;; *) elapsed=999;; esac - case "$elapsed" in ''|*[!0-9.]*) elapsed=999;; esac - printf '%s %s\n' "$elapsed" "$i" > "$probe_dir/$i" - ) & - i=$((i+1)) - done - wait - cat "$probe_dir"/* | sort -n -k1,1 -k2,2 | while read -r elapsed index; do printf '%s\n' "$index"; done -} -urls_for() { - URLS=("$1") - case "$1" in - https://nodejs.org/dist/*) MIRRORS=("https://npmmirror.com/mirrors/node/${1#https://nodejs.org/dist/}");; - https://github.com/*) MIRRORS=("https://ghfast.top/$1" "https://ghproxy.net/$1");; - *) MIRRORS=();; - esac - case "$NETWORK" in - auto) URLS+=("${MIRRORS[@]}");; - china) URLS=("${MIRRORS[@]}" "$1");; - esac -} -download() { - local official=$1 destination=$2 expected=$3 index url part attempt actual order transfer_status - part="$destination.part" - if [ -L "$destination" ] || [ -L "$part" ] || [ -L "$part.url" ]; then fail 'Refusing symlink cache entries'; fi - if [ "$FORCE" = 0 ] && [ -f "$destination" ] && [ "$(sha256 "$destination")" = "$expected" ]; then log "Cached: ${destination##*/}"; return; fi - if [ -f "$part" ] && [ "$(sha256 "$part")" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi - command -v curl >/dev/null 2>&1 || fail 'curl is required for downloads. Install it with your OS package manager.' - if [[ $official == https://nodejs.org/dist/* && -n ${LMM_NODE_BASE_URL:-} ]]; then official="${LMM_NODE_BASE_URL%/}/${official#https://nodejs.org/dist/}"; fi - urls_for "$official" - if [ "$NETWORK" = auto ]; then order=$(rank_urls "${URLS[@]}"); else order=$(printf '%s\n' "${!URLS[@]}"); fi - for index in $order; do - url=${URLS[$index]} - if [ -f "$part" ] && [ "$(cat "$part.url" 2>/dev/null || true)" != "$url" ]; then rm -f -- "$part"; fi - printf '%s\n' "$url" > "$part.url" - for ((attempt=1; attempt<=RETRIES; attempt++)); do - log "Downloading ${destination##*/} (source $((index+1)), attempt $attempt; low-speed cutoff ${STALL_TIMEOUT}s)" - if curl -q --proto '=https' --proto-redir '=https' -fL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$DOWNLOAD_TIMEOUT" --speed-time "$STALL_TIMEOUT" --speed-limit "$MIN_SPEED" --continue-at - --output "$part" "$url"; then - actual=$(sha256 "$part") - if [ "$actual" = "$expected" ]; then mv -f -- "$part" "$destination"; rm -f -- "$part.url"; return; fi - log 'Checksum mismatch: discarded the download; it will not be executed.' - rm -f -- "$part" +# Fetch completely before sourcing: process substitution alone hides curl errors. +lmm_source_lib() { + local lmm_name=$1 lmm_text='' lmm_attempt + if [ -n "${LMM_LIB_DIR:-}" ]; then + lmm_text=$(cat -- "$LMM_LIB_DIR/$lmm_name") || { + printf 'Cannot read local library: %s/%s\n' "$LMM_LIB_DIR" "$lmm_name" >&2; return 1; + } + else + for lmm_attempt in 1 2 3; do + if lmm_text=$(curl -q -fsSL --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 60 \ + "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LIB_REVISION/templates/lib/$lmm_name"); then break - else transfer_status=$?; fi - # A server may reject Range; retry once from a clean file. Retain a - # partial transfer after final failure for the next invocation. - if [ "$attempt" = 1 ]; then case "$transfer_status" in 22|33|36) rm -f -- "$part";; esac; fi + fi + if [ "$lmm_attempt" = 3 ]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi done - done - fail "Download failed: ${destination##*/}. Rerun to resume, or choose another --network mode." -} -compatible_node() { - command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 && - node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(((a===22&&b>=19)||a>=24)&&(process.argv[1]!=="android"||process.platform==="android")?0:1)' "$OS" >/dev/null 2>&1 -} -ensure_node() { - PHASE='Node.js runtime' - if compatible_node; then NODE_BIN=$(dirname "$(command -v node)"); log "Using Node $(node --version)"; return; fi - [ "$OS" != android ] || fail 'In Termux, run pkg install nodejs npm git; desktop Node archives are incompatible.' - local dir="$ROOT/runtime/node-v$NODE_VERSION-$PLATFORM" hash archive - if [ -x "$dir/bin/node" ]; then export PATH="$dir/bin:$PATH"; fi - if compatible_node; then NODE_BIN="$dir/bin"; return; fi - [ "$INSTALL_NODE" = 1 ] || fail 'Need Node 22.19+ (22.x) or Node 24+, including npm.' - [ ! -f /etc/alpine-release ] || fail 'On Alpine install nodejs/npm with apk first; official Node archives require glibc.' - hash=$(node_hash "$PLATFORM") - [ -n "$hash" ] || fail "No verified Node archive for $PLATFORM" - archive="$CACHE/node-v$NODE_VERSION-$PLATFORM.tar.gz" - download "https://nodejs.org/dist/v$NODE_VERSION/${archive##*/}" "$archive" "$hash" - mkdir -p "$STAGE/runtime" - tar -xzf "$archive" -C "$STAGE/runtime" - "$STAGE/runtime/node-v$NODE_VERSION-$PLATFORM/bin/node" --version >/dev/null || fail 'Node cannot run on this OS/libc. Install compatible Node using your OS package manager.' - [ ! -e "$dir" ] || fail "Managed runtime exists but is unusable: $dir. Inspect it before replacing." - mv -- "$STAGE/runtime/node-v$NODE_VERSION-$PLATFORM" "$dir" - NODE_BIN="$dir/bin"; export PATH="$NODE_BIN:$PATH" -} -configure_npm() { - if [ -z "${npm_config_cache:-}" ]; then - npm_config_cache=$(npm config get cache 2>/dev/null || true) - case "$npm_config_cache" in /*) ;; *) npm_config_cache="$CACHE/npm";; esac - export npm_config_cache fi - export npm_config_fetch_retries="$RETRIES" npm_config_fetch_timeout="$((STALL_TIMEOUT * 1000))" - export npm_config_fetch_retry_mintimeout=2000 npm_config_fetch_retry_maxtimeout=30000 - export npm_config_strict_ssl=true - if [ -n "${LMM_NPM_REGISTRY:-}" ]; then export npm_config_registry="$LMM_NPM_REGISTRY"; fi - export npm_config_prefer_offline=true - local current order first - current=$(npm config get registry 2>/dev/null || true) - if [ -n "${npm_config_registry:-}" ] || { [ -n "$current" ] && [ "$current" != https://registry.npmjs.org/ ]; }; then - log 'Keeping your existing npm registry/proxy configuration.'; return + if [[ $lmm_text != *[![:space:]]* ]]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 fi - NPM_SELECTED=1 - case "$NETWORK" in - official) export npm_config_registry=https://registry.npmjs.org/;; - china) export npm_config_registry=https://registry.npmmirror.com/;; - auto) - order=$(rank_urls https://registry.npmjs.org/ https://registry.npmmirror.com/) - first=${order%%$'\n'*} - if [ "$first" = 1 ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi;; - esac - log 'Selected a registry for this installer process only; global npm settings are unchanged.' -} -bounded() { - node - "$COMMAND_TIMEOUT" "$@" <<'JS' -const {spawn}=require('node:child_process'); -const [seconds,command,...args]=process.argv.slice(2); -const child=spawn(command,args,{stdio:'inherit',detached:true}); -let timedOut=false,stopping=false; -function stop(code){if(stopping)return;stopping=true;timedOut=code===124;try{process.kill(-child.pid,'SIGTERM')}catch{};const hard=setTimeout(()=>{try{process.kill(-child.pid,'SIGKILL')}catch{}},3000);hard.unref()} -const start=Date.now();const heartbeat=setInterval(()=>process.stderr.write(`[install] Still working: ${Math.floor((Date.now()-start)/1000)}s elapsed.\n`),15000); -const timeout=setTimeout(()=>{process.stderr.write('[install] Operation timed out; increase LMM_COMMAND_TIMEOUT for a slow connection.\n');stop(124)},Number(seconds)*1000); -process.on('SIGINT',()=>stop(130));process.on('SIGTERM',()=>stop(143)); -child.on('error',e=>{clearInterval(heartbeat);clearTimeout(timeout);process.stderr.write(`[install] Cannot start ${command}: ${e.code}\n`);process.exitCode=1}); -child.on('exit',(code,signal)=>{clearInterval(heartbeat);clearTimeout(timeout);process.exitCode=timedOut?124:signal?130:code??1}); -JS -} -with_registry_retry() { - if bounded "$@"; then return; fi - if [ "$NETWORK" = auto ] && [ "$NPM_SELECTED" = 1 ]; then - if [ "$npm_config_registry" = https://registry.npmjs.org/ ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi - log 'Retrying the alternate registry with the same package cache.' - bounded "$@" - else fail 'Package installation failed. Check network/proxy settings or try another --network mode.'; fi -} -install_client() { - local package=$1 version=$2 entry=$3 target="$ROOT/apps/$TARGET/$2" work="$STAGE/client" allow - PHASE="$TARGET client" - if [ "$FORCE" = 0 ] && [ -x "$target/bin/$entry" ] && [ -f "$target/.lmm-managed" ] && [ "$(cat "$target/.lmm-managed")" = "$version|$SCRIPT_VERSION" ]; then CLIENT="$target/bin/$entry"; log "Client $version already installed."; return; fi - [ "$BOOTSTRAP" = 1 ] || fail 'Managed client is missing; rerun without --no-bootstrap.' - mkdir -p "$work" - INSTALL_ARGS=(install --global --prefix "$work" --no-audit --no-fund "$package@$version") - if [ "$TARGET" = pi ]; then - # https://pi.dev/docs/latest/quickstart: Pi ships a prebuilt CLI. - INSTALL_ARGS+=(--ignore-scripts) - else - allow='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' - if npm install --help 2>/dev/null | grep -q -- '--allow-scripts'; then INSTALL_ARGS+=("--allow-scripts=$allow"); fi - [ "$(npm config get ignore-scripts 2>/dev/null || true)" != true ] || fail 'DSH needs native build scripts. Review your package-specific build policy; this installer will not override ignore-scripts=true.' - fi - with_registry_retry npm "${INSTALL_ARGS[@]}" - node "$work/bin/$entry" --version >/dev/null - printf '%s\n' "$version|$SCRIPT_VERSION" > "$work/.lmm-managed" - mkdir -p "$(dirname "$target")" - if [ -e "$target" ]; then - [ -f "$target/.lmm-managed" ] || fail "Not replacing an unowned directory: $target" - target="$target-reinstall-$(date +%s)-$$" - fi - mv -- "$work" "$target" - CLIENT="$target/bin/$entry" + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") } + install_tool() { ensure_node; configure_npm install_client @earendil-works/pi-coding-agent "$PI_VERSION" pi @@ -219,10 +55,12 @@ install_tool() { if [ "$OS" = android ]; then log 'Optional clipboard: install the Termux:API app and pkg install termux-api. Open login links with termux-open-url.'; fi } -ROOT=$(lmm_root) +ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 STAGE='' LOCKED=0 PHASE=arguments RUN_ARGS=() +# State is consumed by dynamically imported helpers. +# shellcheck disable=SC2034 INSTALL_NODE=1 BOOTSTRAP=1 NPM_SELECTED=0 PNPM_BIN='' log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } @@ -244,9 +82,12 @@ Usage: bash $TARGET.sh [options] [-- launch arguments] --from-source LMM CLI: build the pinned crate using existing Rust 1.88+ --launch Start the installed tool (DSH starts the chosen profile) --help Show this help -No automatic login or PATH changes. Versions and platform notes: README.md. +Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch). +No automatic login or PATH changes. See README.md. USAGE } +# State is consumed by dynamically imported helpers. +# shellcheck disable=SC2034 while [ "$#" -gt 0 ]; do case "$1" in --help|-h) usage; exit 0;; @@ -284,6 +125,9 @@ case "$PROFILE" in web|headless) ;; *) fail 'profile must be web or headless';; [ "$TARGET" = lmm ] || [ "$SOURCE" = 0 ] || fail '--from-source is only for lmm' case "$ROOT" in *$'\n'*|*$'\r'*) fail 'Install path must not contain newlines';; /*) ;; *) ROOT="$PWD/$ROOT";; esac if [ "$ROOT" = / ] || [ "$ROOT" = "$HOME" ]; then fail 'Choose a dedicated installation directory'; fi +for library in hash.sh termux.sh quote.sh download.sh node.sh; do + lmm_source_lib "$library" || exit $? +done case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac if lmm_is_termux; then OS=android; fi case "$(uname -m)" in @@ -299,7 +143,7 @@ if [ "$OS" = android ] && [ "$TARGET" != lmm ]; then compatible_node || fail 'In Termux, install native Node/npm: pkg install nodejs npm git; then rerun. Desktop Node cannot run on Android.' command -v git >/dev/null 2>&1 || fail 'Pi/DSH need git: pkg install git' fi -# --check never creates directories, downloads, edits PATH or touches credentials. +# --check does not write files; common modules may be fetched into memory. if [ "$CHECK" = 1 ]; then log "Platform: $PLATFORM; install root: $ROOT" if [ -x "$ROOT/bin/$TARGET" ]; then "$ROOT/bin/$TARGET" --version diff --git a/templates/install.ps1.in b/templates/install.ps1.in index 93ba154..cf9c5d4 100644 --- a/templates/install.ps1.in +++ b/templates/install.ps1.in @@ -27,6 +27,7 @@ LMM $Target installer $ScriptVersion Usage: .\$Target.ps1 [-Check] [-Update] [-Root PATH] [-Network auto|official|china] [-Profile web|headless] [-AddPath] [-NoPath] [-NoInstallNode] [-FromSource] [-Launch] [-Help] +Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch). No automatic login or PATH changes. Pi on Windows requires Bash. -FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. "@ @@ -120,7 +121,13 @@ function Invoke-LmmSetup { } $savedEnvironment=@{} foreach($name in @('PATH','npm_config_cache','npm_config_fetch_retries','npm_config_fetch_timeout','npm_config_prefer_offline','npm_config_fetch_retry_mintimeout','npm_config_fetch_retry_maxtimeout','npm_config_strict_ssl','npm_config_registry','npm_config_store_dir')) { $savedEnvironment[$name]=[Environment]::GetEnvironmentVariable($name,'Process') } -try { Invoke-LmmSetup; exit 0 } +try { + if ($Help) { Show-Usage; exit 0 } + $script:Phase='libraries' + @@LOAD_LIBRARIES@@ + $script:Phase='arguments' + Invoke-LmmSetup; exit 0 +} catch { Write-Error "Stopped during $script:Phase. $($_.Exception.Message)" -ErrorAction Continue; exit 1 } finally { foreach($name in $savedEnvironment.Keys) { [Environment]::SetEnvironmentVariable($name,$savedEnvironment[$name],'Process') } diff --git a/templates/install.sh.in b/templates/install.sh.in index 5a2dc0e..dc4c016 100644 --- a/templates/install.sh.in +++ b/templates/install.sh.in @@ -4,10 +4,12 @@ set -euo pipefail set +x @@CONSTANTS@@ @@LIBRARIES@@ -ROOT=$(lmm_root) +ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} NETWORK=auto PROFILE=web CHECK=0 FORCE=0 LAUNCH=0 ADD_PATH=0 SOURCE=0 STAGE='' LOCKED=0 PHASE=arguments RUN_ARGS=() +# State is consumed by dynamically imported helpers. +# shellcheck disable=SC2034 @@CLIENT_STATE@@ PNPM_BIN='' log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } @@ -29,9 +31,12 @@ Usage: bash $TARGET.sh [options] [-- launch arguments] --from-source LMM CLI: build the pinned crate using existing Rust 1.88+ --launch Start the installed tool (DSH starts the chosen profile) --help Show this help -No automatic login or PATH changes. Versions and platform notes: README.md. +Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch). +No automatic login or PATH changes. See README.md. USAGE } +# State is consumed by dynamically imported helpers. +# shellcheck disable=SC2034 while [ "$#" -gt 0 ]; do case "$1" in --help|-h) usage; exit 0;; @@ -69,6 +74,7 @@ case "$PROFILE" in web|headless) ;; *) fail 'profile must be web or headless';; [ "$TARGET" = lmm ] || [ "$SOURCE" = 0 ] || fail '--from-source is only for lmm' case "$ROOT" in *$'\n'*|*$'\r'*) fail 'Install path must not contain newlines';; /*) ;; *) ROOT="$PWD/$ROOT";; esac if [ "$ROOT" = / ] || [ "$ROOT" = "$HOME" ]; then fail 'Choose a dedicated installation directory'; fi +@@LOAD_LIBRARIES@@ case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac if lmm_is_termux; then OS=android; fi case "$(uname -m)" in @@ -84,7 +90,7 @@ if [ "$OS" = android ] && [ "$TARGET" != lmm ]; then compatible_node || fail 'In Termux, install native Node/npm: pkg install nodejs npm git; then rerun. Desktop Node cannot run on Android.' command -v git >/dev/null 2>&1 || fail 'Pi/DSH need git: pkg install git' fi -# --check never creates directories, downloads, edits PATH or touches credentials. +# --check does not write files; common modules may be fetched into memory. if [ "$CHECK" = 1 ]; then log "Platform: $PLATFORM; install root: $ROOT" if [ -x "$ROOT/bin/$TARGET" ]; then "$ROOT/bin/$TARGET" --version diff --git a/tests/test-official-policy.ps1 b/tests/test-official-policy.ps1 index d9d66d9..a2efb40 100644 --- a/tests/test-official-policy.ps1 +++ b/tests/test-official-policy.ps1 @@ -6,6 +6,7 @@ if ($errors.Count) { throw ($errors | Out-String) } foreach ($definition in $ast.FindAll({param($a) $a -is [Management.Automation.Language.FunctionDefinitionAst]},$true)) { . ([scriptblock]::Create($definition.Extent.Text)) } +foreach($library in Get-ChildItem (Join-Path $project 'templates/lib') -Filter '*.ps1') { . $library.FullName } # Only these functions are exercised; never invoke the installer or download. if ($env:OS -ne 'Windows_NT') { Write-Host 'Windows policy tests skipped on non-Windows.'; return } $testRoot=Join-Path ([IO.Path]::GetTempPath()) ('lmm-policy-'+[Guid]::NewGuid().ToString('N')) diff --git a/tests/test-powershell.ps1 b/tests/test-powershell.ps1 index 6e8e140..7748952 100644 --- a/tests/test-powershell.ps1 +++ b/tests/test-powershell.ps1 @@ -8,6 +8,7 @@ foreach($file in Get-ChildItem -LiteralPath $project -Filter '*.ps1') { $tokens=$null;$errors=$null $ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $project 'lmm.ps1'),[ref]$tokens,[ref]$errors) foreach($definition in $ast.FindAll({param($a) $a -is [Management.Automation.Language.FunctionDefinitionAst]},$true)) { . ([scriptblock]::Create($definition.Extent.Text)) } +foreach($library in Get-ChildItem (Join-Path $project 'templates/lib') -Filter '*.ps1') { . $library.FullName } $Target='test';$Network='official';$Update=$false;$script:Phase='test' $Retries=2;$ConnectTimeout=1;$StallTimeout=2;$DownloadTimeout=10;$CommandTimeout=1;$MinSpeed=1 $testRoot=Join-Path ([IO.Path]::GetTempPath()) ('lmm-ps-test-'+[Guid]::NewGuid().ToString('N')) diff --git a/tests/test_installers.py b/tests/test_installers.py index 13df58c..6799f96 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -48,6 +48,7 @@ def setUp(self): with tarfile.open(self.archive,'w:gz') as t: data=b'#!/usr/bin/env bash\necho "lmm 0.1.0"\n';x=tarfile.TarInfo('lmm');x.size=len(data);x.mode=0o755;t.addfile(x,io.BytesIO(data)) self.env=dict(os.environ,PATH=str(self.bin)+os.pathsep+os.environ['PATH'],LMM_TEST_REAL_NODE=shutil.which('node'),LMM_TEST_LOG=str(self.log),LMM_TEST_ARCHIVE=str(self.archive),LMM_TEST_CACHE=str(self.base/'npm-cache')) + self.env['LMM_LIB_DIR']=str(P/'templates/lib') for k in list(self.env): if k.lower().startswith('npm_config_'):self.env.pop(k) def tearDown(self):self.tmp.cleanup() diff --git a/tests/test_official_policy.py b/tests/test_official_policy.py index c21d7c9..983f968 100644 --- a/tests/test_official_policy.py +++ b/tests/test_official_policy.py @@ -177,14 +177,15 @@ def test_generated_scripts_omit_other_target_implementations(self): self.assertNotIn(node, cli) self.assertNotIn('DSH_PROVIDER_SHA256=', pi) - def test_shared_helpers_are_embedded_once_and_remain_offline(self): - for target in ('pi', 'dsh', 'lmm', 'menu'): + def test_shared_helpers_are_loaded_not_copied(self): + for target in ('pi', 'dsh', 'lmm'): body = (P / f'{target}.sh').read_text(encoding='utf-8') - self.assertEqual(body.count('lmm_is_termux() {'), 1) - self.assertEqual(body.count('sha256() {'), 1) - self.assertEqual(body.count('lmm_root() {'), 1) + for definition in ('lmm_is_termux() {', 'sha256() {', 'lmm_root() {', 'download() {'): + self.assertNotIn(definition, body) + self.assertIn('lmm_source_lib "$library"', body) self.assertNotIn('@@LIBRARIES@@', body) - self.assertNotIn('source https:', body) + menu = (P / 'menu.sh').read_text(encoding='utf-8') + self.assertEqual(menu.count('lmm_is_termux() {'), 1) fixtures.subprocess.run(['python3', str(P / 'tools/generate_menus.py'), '--check'], check=True) def test_every_generated_shell_help_is_standalone(self): diff --git a/tools/_runtime_refactor.py b/tools/_runtime_refactor.py deleted file mode 100644 index db0954e..0000000 --- a/tools/_runtime_refactor.py +++ /dev/null @@ -1,142 +0,0 @@ -from pathlib import Path -import json - -P = Path(__file__).resolve().parents[1] - -def replace(path, old, new): - file = P / path - body = file.read_text(encoding='utf-8') - if body.count(old) != 1: - raise RuntimeError(f'{path}: expected one match: {old[:100]!r}; found {body.count(old)}') - file.write_text(body.replace(old, new), encoding='utf-8') - -replace('tools/generate.py', - '"""Compose only the shared code and target adapter needed by each installer."""', - '"""Generate small installers that load common functions from a pinned revision."""') -replace('tools/generate.py', - " 'script_version': ('SCRIPT_VERSION', 'ScriptVersion'),", - " 'script_version': ('SCRIPT_VERSION', 'ScriptVersion'),\n 'library_revision': ('LIB_REVISION', 'LibRevision'),") -replace('tools/generate.py', - " versions = json.loads((ROOT / 'versions.json').read_text(encoding='utf-8'))", - " versions = json.loads((ROOT / 'versions.json').read_text(encoding='utf-8'))\n if not re.fullmatch(r'[0-9a-f]{40}', versions['library_revision']):\n raise ValueError('library_revision must be a full Git commit ID')") -replace('tools/generate.py', - "parts = ['lib/root.sh', 'lib/hash.sh', 'lib/termux.sh', 'lib/quote.sh']", - "parts = ['lib/hash.sh', 'lib/termux.sh', 'lib/quote.sh']") -replace('tools/generate.py', - " body = template(f'install.{ext}.in').replace('@@LIBRARIES@@', libraries(*parts))", - ''' shared = libraries(*parts[:-1]) - names = [part.rsplit('/', 1)[1] for part in parts[:-1]] - loader = template(f'load.{ext}.in') + '\\n' + libraries(parts[-1]) - if ext == 'sh': - imports = 'for library in ' + ' '.join(names) + '; do\\n lmm_source_lib "$library" || exit $?\\ndone' - else: - imports = "foreach ($library in @(" + ','.join("'" + name + "'" for name in names) + ")) {\\n . (Get-LmmLibrary $library)\\n }" - body = template(f'install.{ext}.in').replace('@@LIBRARIES@@', loader) - body = body.replace('@@LOAD_LIBRARIES@@', imports)''') -replace('tools/generate.py', - "body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body))", - "body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body + '\\n' + shared))") -replace('tools/render.py', - '"""Build-time composition only: published scripts never source remote helpers."""', - '"""Shared rendering and byte-for-byte checks for installer and menu entry points."""') -replace('templates/install.sh.in', 'ROOT=$(lmm_root)', - 'ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools}') -replace('templates/install.sh.in', - 'case "$(uname -s)" in Linux|Android)', - '@@LOAD_LIBRARIES@@\ncase "$(uname -s)" in Linux|Android)') -replace('templates/install.sh.in', - '# --check never creates directories, downloads, edits PATH or touches credentials.', - '# --check does not write files; common modules may be fetched into memory.') -replace('templates/install.sh.in', - 'No automatic login or PATH changes. Versions and platform notes: README.md.', - 'Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch).\nNo automatic login or PATH changes. See README.md.') -replace('templates/install.ps1.in', - 'try { Invoke-LmmSetup; exit 0 }', - '''try { - if ($Help) { Show-Usage; exit 0 } - $script:Phase='libraries' - @@LOAD_LIBRARIES@@ - $script:Phase='arguments' - Invoke-LmmSetup; exit 0 -}''') -replace('templates/install.ps1.in', - 'No automatic login or PATH changes. Pi on Windows requires Bash.', - 'Common functions load from GitHub. LMM_LIB_DIR selects local libraries (no fetch).\nNo automatic login or PATH changes. Pi on Windows requires Bash.') - -replace('tests/test_installers.py', - " for k in list(self.env):", - " self.env['LMM_LIB_DIR']=str(P/'templates/lib')\n for k in list(self.env):") -file = P / 'tests/test_official_policy.py' -text = file.read_text(encoding='utf-8') -start = text.index(' def test_shared_helpers_are_embedded_once_and_remain_offline(self):') -end = text.index(' def test_every_generated_shell_help_is_standalone(self):', start) -text = text[:start] + ''' def test_shared_helpers_are_loaded_not_copied(self): - for target in ('pi', 'dsh', 'lmm'): - body = (P / f'{target}.sh').read_text(encoding='utf-8') - for definition in ('lmm_is_termux() {', 'sha256() {', 'lmm_root() {', 'download() {'): - self.assertNotIn(definition, body) - self.assertIn('lmm_source_lib "$library"', body) - self.assertNotIn('@@LIBRARIES@@', body) - menu = (P / 'menu.sh').read_text(encoding='utf-8') - self.assertEqual(menu.count('lmm_is_termux() {'), 1) - fixtures.subprocess.run(['python3', str(P / 'tools/generate_menus.py'), '--check'], check=True) - -''' + text[end:] -file.write_text(text, encoding='utf-8') -replace('tests/test-powershell.ps1', - "$Target='test';$Network='official';$Update=$false;$script:Phase='test'", - "foreach($library in Get-ChildItem (Join-Path $project 'templates/lib') -Filter '*.ps1') { . $library.FullName }\n$Target='test';$Network='official';$Update=$false;$script:Phase='test'") -replace('tests/test-official-policy.ps1', - '# Only these functions are exercised; never invoke the installer or download.', - "foreach($library in Get-ChildItem (Join-Path $project 'templates/lib') -Filter '*.ps1') { . $library.FullName }\n# Only these functions are exercised; never invoke the installer or download.") -replace('.github/workflows/test.yml', - ' python3 tests/test_official_policy.py', - ' python3 tests/test_official_policy.py\n python3 tests/test_library_loader.py') -replace('.github/workflows/test.yml', - ' ./tests/test-official-policy.ps1', - ' ./tests/test-official-policy.ps1\n ./tests/test-library-loader.ps1') -replace('.github/workflows/test.yml', - ' .\\tests\\test-official-policy.ps1', - ' .\\tests\\test-official-policy.ps1\n .\\tests\\test-library-loader.ps1') - -versions = P / 'versions.json' -v = json.loads(versions.read_text()) -v['script_version'] = '2026.09.20.3' -v['library_revision'] = 'ce6aea96cd73d633424daaa6e2e25ac18fd33b5c' -versions.write_text(json.dumps(v, indent=2) + '\n', encoding='utf-8') -replace('README.md', - '发布脚本仍可单文件运行,不需要另外下载公共函数库。', - '安装器运行时从固定 Git 提交加载公共函数,不再把它们复制进每个发布脚本。') -replace('README.md', '## 环境与故障', '''## 公共函数加载 - -默认从 `versions.json` 的 `library_revision` 获取 GitHub 公共模块。Shell 完整获取文件后通过 `source <(...)` 导入;PowerShell 使用对应的点导入。没有公共模块哈希清单,不内嵌另一套备用库。下载失败就停止,不执行部分响应。 - -`--help` / `-Help` 不联网。`--check` / `-Check` 不修改安装文件,但默认需要联网加载公共模块。断网或调试时,明确指定同版本的本地公共目录: - -```sh -LMM_LIB_DIR="$PWD/templates/lib" bash pi.sh --check -``` - -```powershell -$env:LMM_LIB_DIR = Join-Path $PWD 'templates/lib' -.\\pi.ps1 -Check -``` - -本地目录缺少模块时直接报错,不偷偷转为联网。这里只控制公共函数的来源;安装客户端仍可能需要下载软件包。`--network` 控制软件包来源,不改变公共模块的 GitHub 地址。客户端安装完成后的启动入口不需要重新获取这些模块。 - -## 环境与故障''') -replace('docs/maintenance.md', - '`tools/generate.py` 只组装当前工具需要的代码、版本和哈希。', - '`tools/generate.py` 保留入口与工具差异,生成当前工具所需的公共模块加载调用。') -replace('docs/maintenance.md', - '`.sh` 与 `.ps1` 都保留单文件入口,不在运行时下载或 `source` 公共库;网站现有同步清单无需增加运行时文件。Windows/Linux/macOS 的编码和完整脚本校验保持不变。', - '`.sh` 与 `.ps1` 入口通过 GitHub 固定提交获取 `templates/lib/`,不内嵌公共库;网站同步清单无需新增公共文件。模块不维护额外哈希,原有软件包和菜单的校验逻辑保留。`LMM_LIB_DIR` 可明确改用本地目录,缺文件即失败。') -replace('docs/maintenance.md', - '菜单的 `revision` 固定到含有目标脚本的提交,', - '公共模块的提交由 `versions.json` 中的 `library_revision` 指定。修改公共库时先提交公共库,再更新这个引用并重新生成入口;只改入口时无需修改公共模块版本。测试比较该提交中的公共文件与当前源码,避免忘记更新引用。\n\n菜单的 `revision` 固定到含有目标脚本的提交,') -replace('docs/maintenance.md', - 'python3 tests/test_official_policy.py', - 'python3 tests/test_official_policy.py\npython3 tests/test_library_loader.py') -replace('docs/maintenance.md', - 'pwsh -NoProfile -File tests/test-official-policy.ps1', - 'pwsh -NoProfile -File tests/test-official-policy.ps1\npwsh -NoProfile -File tests/test-library-loader.ps1') diff --git a/tools/generate.py b/tools/generate.py index 73d2a3f..324191c 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Compose only the shared code and target adapter needed by each installer.""" +"""Generate small installers that load common functions from a pinned revision.""" import argparse import json import re @@ -9,6 +9,7 @@ # JSON field -> shell / PowerShell variable. Keep a single naming map. NAMES = { 'script_version': ('SCRIPT_VERSION', 'ScriptVersion'), + 'library_revision': ('LIB_REVISION', 'LibRevision'), 'node_version': ('NODE_VERSION', 'NodeVersion'), 'pi_version': ('PI_VERSION', 'PiVersion'), 'pi_provider_version': ('PI_PROVIDER_VERSION', 'PiProviderVersion'), @@ -55,20 +56,30 @@ def main() -> None: parser.add_argument('--check', action='store_true') args = parser.parse_args() versions = json.loads((ROOT / 'versions.json').read_text(encoding='utf-8')) + if not re.fullmatch(r'[0-9a-f]{40}', versions['library_revision']): + raise ValueError('library_revision must be a full Git commit ID') for target in ('pi', 'dsh', 'lmm'): for ext in ('sh', 'ps1'): - parts = ['lib/root.sh', 'lib/hash.sh', 'lib/termux.sh', 'lib/quote.sh'] if ext == 'sh' else ['lib/common.ps1'] + parts = ['lib/hash.sh', 'lib/termux.sh', 'lib/quote.sh'] if ext == 'sh' else ['lib/common.ps1'] parts.append(f'lib/download.{ext}') if target != 'lmm': parts.append(f'lib/node.{ext}') parts.append(f'tools/{target}.{ext}') - body = template(f'install.{ext}.in').replace('@@LIBRARIES@@', libraries(*parts)) + shared = libraries(*parts[:-1]) + names = [part.rsplit('/', 1)[1] for part in parts[:-1]] + loader = template(f'load.{ext}.in') + '\n' + libraries(parts[-1]) + if ext == 'sh': + imports = 'for library in ' + ' '.join(names) + '; do\n lmm_source_lib "$library" || exit $?\ndone' + else: + imports = "foreach ($library in @(" + ','.join("'" + name + "'" for name in names) + ")) {\n . (Get-LmmLibrary $library)\n }" + body = template(f'install.{ext}.in').replace('@@LIBRARIES@@', loader) + body = body.replace('@@LOAD_LIBRARIES@@', imports) body = body.replace('@@NODE_CHECK@@', template('lib/node-check.sh') if target != 'lmm' and ext == 'sh' else '') client = target != 'lmm' body = body.replace('@@CLIENT_STATE@@', 'INSTALL_NODE=1 BOOTSTRAP=1 NPM_SELECTED=0' if client else '') body = body.replace('@@NO_BOOTSTRAP@@', 'INSTALL_NODE=0; BOOTSTRAP=0' if client else ':') body = body.replace('@@NO_INSTALL_NODE@@', 'INSTALL_NODE=0' if client else ':') - body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body)) + body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body + '\n' + shared)) if ext == 'sh': body = standalone(body, 'lmm_install_main') else: diff --git a/tools/render.py b/tools/render.py index fd5d4fd..ad8aec9 100644 --- a/tools/render.py +++ b/tools/render.py @@ -1,4 +1,4 @@ -"""Build-time composition only: published scripts never source remote helpers.""" +"""Shared rendering and byte-for-byte checks for installer and menu entry points.""" from pathlib import Path import re diff --git a/versions.json b/versions.json index 55fe8ec..fd7ee25 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "script_version": "2026.09.20.2", + "script_version": "2026.09.20.3", "node_version": "24.21.0", "node_sha256": { "linux-x64": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", @@ -21,5 +21,6 @@ "darwin-arm64": "8f6b3a2d08500566b528b7089664420d3395e464c172e3a43dfcb73d37f57b3f", "win-x64": "d0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9" }, - "pnpm_version": "11.7.0" + "pnpm_version": "11.7.0", + "library_revision": "ce6aea96cd73d633424daaa6e2e25ac18fd33b5c" } From 16a065fb0f8cd505d33d5ba2a89635e09587a49a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:33:47 +0000 Subject: [PATCH 18/39] fix: point menus at runtime-loading installers --- menu.ps1 | 8 ++++---- menu.sh | 8 ++++---- tools/generate_menus.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/menu.ps1 b/menu.ps1 index 4a1b7aa..de23491 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -4,9 +4,9 @@ param([switch]$Help) $ErrorActionPreference = 'Stop' if ($Help) { Write-Output 'LMM menu: .\menu.ps1 [-Help]. Interactive terminal required.'; exit 0 } $hashes = @{ - 'pi.ps1' = '74aeaf6f7e8e7a2c9975dd5dc3abbc5f66a20138e44746dd95d8b4f2b8ac8cb7' - 'dsh.ps1' = '115ee0030a5951a089321df877c4725d8081e2a0c139f6270662c2ab02958efc' - 'lmm.ps1' = '789dd5bd0b9d2dada7c1b23dac0aa077c8e9134b69ca39dff7d3336b9f092c23' + 'pi.ps1' = '587871a019c480e0f216b8524a351a1fcc9a2d890ca55e496c84a0f528e6ceaf' + 'dsh.ps1' = '3c421e3c277a77b7d0fcd557a4b0dbb9c67678881100fc72c90607774e11609c' + 'lmm.ps1' = 'b8009a96a57119cc9ee95a521e967298c957d5c6b4e0a8a3482a748492a2bdef' 'lmm-use.ps1' = 'ac137e30b6609580cb6ee4fbb60ed77ed01ec6153b733140540d5bc38d9179c1' } $network = 'auto' @@ -25,7 +25,7 @@ function Fetch-Script([string]$Name) { $path = Join-Path $work $Name if ((Test-Path -LiteralPath $path) -and ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -eq $hashes[$Name])) { return $path } Write-Host '正在获取并校验安装程序(下载慢时会重试)…' - foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/b21e36ecfdae2a1c97f86177f841635552ec40db/$Name")) { + foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/73237be36d2c3e3a8763019f97179b5490e5405b/$Name")) { for ($attempt = 1; $attempt -le 3; $attempt++) { try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 -ErrorAction Stop diff --git a/menu.sh b/menu.sh index 8aa2c55..3b4a107 100755 --- a/menu.sh +++ b/menu.sh @@ -47,9 +47,9 @@ if ! { exec 3/dev/null; then fi command -v curl >/dev/null 2>&1 || { printf '请先安装 curl。\n' >&2; exit 2; } expected_hash() { case "$1" in -pi.sh) printf '%s' '0dcc9f61ece125c9b0dacdcad28429b98e5d886ce8f2ef3b9f219faf4109282c';; -dsh.sh) printf '%s' '9ebfbc135329b9a1b2a0a36dd372456740ba961042494aec6ecaf9521df6ceb3';; -lmm.sh) printf '%s' 'bba6547ccb32f6cbafc9d63f662e32959089b448922c375d22931ed135b44d66';; +pi.sh) printf '%s' 'd8c82a3b9cc6821199e7276f2fda34d2ad7624fc8e771495bb506bdf0427e1ca';; +dsh.sh) printf '%s' '5aadceeee493de9fe9f195f461dbd06a6b82424c680702be753d7fbfd22b9031';; +lmm.sh) printf '%s' '7dbae6a302e755d86c8deb9a34ce3c11a25975ddf39330635479c545f84d6fff';; lmm-use.sh) printf '%s' '8f5cb27ef99bc3fd51a5b85e5aba0418f791e3cae03cd0ea624384b3dd50a06b';; *) return 1;; esac; } @@ -69,7 +69,7 @@ fetch_script() { expected=$(expected_hash "$name") || return 1 if [ -f "$work/$name" ] && [ "$(sha256 "$work/$name")" = "$expected" ]; then return 0; fi printf '正在获取并校验安装程序(下载慢时会重试)…\n' - for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/b21e36ecfdae2a1c97f86177f841635552ec40db/$name"; do + for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/73237be36d2c3e3a8763019f97179b5490e5405b/$name"; do if curl -q -fSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 2 --retry-delay 2 "$url" -o "$work/download"; then if [ "$(sha256 "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi printf '文件版本或校验不匹配,尝试固定版本备用地址。\n' >&2 diff --git a/tools/generate_menus.py b/tools/generate_menus.py index f3cd853..0a5e5ab 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -5,7 +5,7 @@ from render import emit, libraries p=Path(__file__).resolve().parents[1] a=argparse.ArgumentParser();a.add_argument('--check',action='store_true');args=a.parse_args() -revision='b21e36ecfdae2a1c97f86177f841635552ec40db' +revision='73237be36d2c3e3a8763019f97179b5490e5405b' for ext in ('sh','ps1'): lines=[] for stem in ('pi','dsh','lmm','lmm-use'): From 2b54971e177b02815a4e1ced1baa6ca8dcf2b41e Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 17:36:08 +0800 Subject: [PATCH 19/39] chore: remove temporary runtime-loader preparation workflow --- .github/workflows/_runtime.yml | 77 ---------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 .github/workflows/_runtime.yml diff --git a/.github/workflows/_runtime.yml b/.github/workflows/_runtime.yml deleted file mode 100644 index 30a1318..0000000 --- a/.github/workflows/_runtime.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Prepare runtime library loaders -on: - push: - branches: [codex/official-installers-20260920] - paths: - - tools/_runtime_refactor.py - - templates/load.*.in - - tests/test_library_loader.py - - tests/test-library-loader.ps1 - - .github/workflows/_runtime.yml -permissions: - contents: write -jobs: - prepare: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 - with: - node-version: 24.21.0 - - name: Apply changes, generate and test - run: | - python3 tools/_runtime_refactor.py - python3 - <<'PY' - from pathlib import Path - p=Path('templates/install.sh.in') - text=p.read_text(encoding='utf-8') - for old in ('@@CLIENT_STATE@@', 'while [ "$#" -gt 0 ]; do'): - assert text.count(old)==1 - text=text.replace(old,'# State is consumed by dynamically imported helpers.\n# shellcheck disable=SC2034\n'+old) - p.write_text(text,encoding='utf-8') - PY - python3 tools/generate.py - python3 tools/generate.py --check - python3 tools/generate_menus.py --check - python3 tests/test_installers.py - python3 tests/test_official_policy.py - python3 tests/test_library_loader.py - shellcheck *.sh - pwsh -NoProfile -File tests/test-powershell.ps1 - pwsh -NoProfile -File tests/test-library-loader.ps1 - python3 - <<'PY' - from pathlib import Path - import subprocess - before=after=0 - for ext in ('sh','ps1'): - for target in ('pi','dsh','lmm'): - name=f'{target}.{ext}' - old=len(subprocess.check_output(['git','show',f'ce6aea96cd73d633424daaa6e2e25ac18fd33b5c:{name}'])) - new=Path(name).stat().st_size - before+=old;after+=new - print(f'{name}: {old} -> {new} bytes') - print(f'TOTAL: {before} -> {after}; reduction {(1-after/before)*100:.1f}%') - assert after < before * .7 - PY - rm tools/_runtime_refactor.py - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'refactor: fetch pinned common libraries instead of embedding them' - python3 - <<'PY' - from pathlib import Path - import re, subprocess - p=Path('tools/generate_menus.py') - revision=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() - text,count=re.subn(r"revision='[0-9a-f]{40}'",f"revision='{revision}'",p.read_text(encoding='utf-8')) - assert count==1 - p.write_text(text,encoding='utf-8') - PY - python3 tools/generate_menus.py - python3 tools/generate_menus.py --check - git add tools/generate_menus.py menu.sh menu.ps1 - git commit -m 'fix: point menus at runtime-loading installers' - git push origin HEAD:codex/official-installers-20260920 From b78a32d87a7d3902ee1323ad3fb447fd8b3ac0b5 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 17:38:20 +0800 Subject: [PATCH 20/39] fix: add Bash 3.2 compatibility for buffered library imports --- .github/workflows/_regenerate.yml | 46 +++++++++++++++++++++++++++++++ templates/load.sh.in | 10 +++++-- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/_regenerate.yml diff --git a/.github/workflows/_regenerate.yml b/.github/workflows/_regenerate.yml new file mode 100644 index 0000000..4121e33 --- /dev/null +++ b/.github/workflows/_regenerate.yml @@ -0,0 +1,46 @@ +name: Verify legacy Bash loader +on: + push: + branches: [codex/official-installers-20260920] + paths: [templates/load.sh.in, .github/workflows/_regenerate.yml] +permissions: + contents: write +jobs: + regenerate: + runs-on: macos-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24.21.0 + - name: Generate and test on the system Bash + shell: bash + run: | + /bin/bash --version + python3 tools/generate.py + python3 tools/generate.py --check + python3 tests/test_installers.py + python3 tests/test_official_policy.py + python3 tests/test_library_loader.py + pwsh -NoProfile -File tests/test-library-loader.ps1 + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add pi.sh dsh.sh lmm.sh + git commit -m 'fix: support buffered library imports on macOS Bash 3.2' + python3 - <<'PY' + from pathlib import Path + import re, subprocess + p=Path('tools/generate_menus.py') + revision=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() + text,count=re.subn(r"revision='[0-9a-f]{40}'",f"revision='{revision}'",p.read_text(encoding='utf-8')) + assert count==1 + p.write_text(text,encoding='utf-8') + PY + python3 tools/generate_menus.py + python3 tools/generate_menus.py --check + git add tools/generate_menus.py menu.sh menu.ps1 + git commit -m 'fix: pin menus to Bash-compatible library loaders' + git push origin HEAD:codex/official-installers-20260920 diff --git a/templates/load.sh.in b/templates/load.sh.in index e025e67..a162f02 100644 --- a/templates/load.sh.in +++ b/templates/load.sh.in @@ -22,6 +22,12 @@ lmm_source_lib() { printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 return 1 fi - # shellcheck disable=SC1090 - source <(printf '%s\n' "$lmm_text") + # macOS Bash 3.2 needs a here-string when sourcing buffered text. + if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + # shellcheck disable=SC1091 + source /dev/stdin <<< "$lmm_text" + else + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") + fi } From 9c8f76329ec7846e5b899b2e9fef2320172cdbb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:38:58 +0000 Subject: [PATCH 21/39] fix: support buffered library imports on macOS Bash 3.2 --- dsh.sh | 10 ++++++++-- lmm.sh | 10 ++++++++-- pi.sh | 10 ++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/dsh.sh b/dsh.sh index 2bf7dd5..c57d831 100755 --- a/dsh.sh +++ b/dsh.sh @@ -45,8 +45,14 @@ lmm_source_lib() { printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 return 1 fi - # shellcheck disable=SC1090 - source <(printf '%s\n' "$lmm_text") + # macOS Bash 3.2 needs a here-string when sourcing buffered text. + if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + # shellcheck disable=SC1091 + source /dev/stdin <<< "$lmm_text" + else + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") + fi } ensure_pnpm() { diff --git a/lmm.sh b/lmm.sh index 586a076..ae25359 100755 --- a/lmm.sh +++ b/lmm.sh @@ -39,8 +39,14 @@ lmm_source_lib() { printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 return 1 fi - # shellcheck disable=SC1090 - source <(printf '%s\n' "$lmm_text") + # macOS Bash 3.2 needs a here-string when sourcing buffered text. + if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + # shellcheck disable=SC1091 + source /dev/stdin <<< "$lmm_text" + else + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") + fi } install_lmm() { diff --git a/pi.sh b/pi.sh index 103ba89..6097dc9 100755 --- a/pi.sh +++ b/pi.sh @@ -43,8 +43,14 @@ lmm_source_lib() { printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 return 1 fi - # shellcheck disable=SC1090 - source <(printf '%s\n' "$lmm_text") + # macOS Bash 3.2 needs a here-string when sourcing buffered text. + if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + # shellcheck disable=SC1091 + source /dev/stdin <<< "$lmm_text" + else + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") + fi } install_tool() { From 73a0110eca1edb6705dc4850d8d21c62e5fc35ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:38:58 +0000 Subject: [PATCH 22/39] fix: pin menus to Bash-compatible library loaders --- menu.ps1 | 2 +- menu.sh | 8 ++++---- tools/generate_menus.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/menu.ps1 b/menu.ps1 index de23491..0f04e16 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -25,7 +25,7 @@ function Fetch-Script([string]$Name) { $path = Join-Path $work $Name if ((Test-Path -LiteralPath $path) -and ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -eq $hashes[$Name])) { return $path } Write-Host '正在获取并校验安装程序(下载慢时会重试)…' - foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/73237be36d2c3e3a8763019f97179b5490e5405b/$Name")) { + foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/9c8f76329ec7846e5b899b2e9fef2320172cdbb7/$Name")) { for ($attempt = 1; $attempt -le 3; $attempt++) { try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 -ErrorAction Stop diff --git a/menu.sh b/menu.sh index 3b4a107..e467611 100755 --- a/menu.sh +++ b/menu.sh @@ -47,9 +47,9 @@ if ! { exec 3/dev/null; then fi command -v curl >/dev/null 2>&1 || { printf '请先安装 curl。\n' >&2; exit 2; } expected_hash() { case "$1" in -pi.sh) printf '%s' 'd8c82a3b9cc6821199e7276f2fda34d2ad7624fc8e771495bb506bdf0427e1ca';; -dsh.sh) printf '%s' '5aadceeee493de9fe9f195f461dbd06a6b82424c680702be753d7fbfd22b9031';; -lmm.sh) printf '%s' '7dbae6a302e755d86c8deb9a34ce3c11a25975ddf39330635479c545f84d6fff';; +pi.sh) printf '%s' 'c027a9ec05ca3aebac4625a5bf8c9a6d187689946e37cfed05148bc55905fe88';; +dsh.sh) printf '%s' '39aedcac869fe80322f9a41d689136584f89fe899c87882aaec79e5612863033';; +lmm.sh) printf '%s' '15d459a5acb210588608b1d0d3d80de78f60771c7c3596c9d300549eb5f19473';; lmm-use.sh) printf '%s' '8f5cb27ef99bc3fd51a5b85e5aba0418f791e3cae03cd0ea624384b3dd50a06b';; *) return 1;; esac; } @@ -69,7 +69,7 @@ fetch_script() { expected=$(expected_hash "$name") || return 1 if [ -f "$work/$name" ] && [ "$(sha256 "$work/$name")" = "$expected" ]; then return 0; fi printf '正在获取并校验安装程序(下载慢时会重试)…\n' - for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/73237be36d2c3e3a8763019f97179b5490e5405b/$name"; do + for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/9c8f76329ec7846e5b899b2e9fef2320172cdbb7/$name"; do if curl -q -fSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 2 --retry-delay 2 "$url" -o "$work/download"; then if [ "$(sha256 "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi printf '文件版本或校验不匹配,尝试固定版本备用地址。\n' >&2 diff --git a/tools/generate_menus.py b/tools/generate_menus.py index 0a5e5ab..88d1a84 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -5,7 +5,7 @@ from render import emit, libraries p=Path(__file__).resolve().parents[1] a=argparse.ArgumentParser();a.add_argument('--check',action='store_true');args=a.parse_args() -revision='73237be36d2c3e3a8763019f97179b5490e5405b' +revision='9c8f76329ec7846e5b899b2e9fef2320172cdbb7' for ext in ('sh','ps1'): lines=[] for stem in ('pi','dsh','lmm','lmm-use'): From f09e4681d010946cd4833fc59795107d22c6ac02 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 17:39:30 +0800 Subject: [PATCH 23/39] chore: remove one-off legacy Bash regeneration workflow --- .github/workflows/_regenerate.yml | 46 ------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 .github/workflows/_regenerate.yml diff --git a/.github/workflows/_regenerate.yml b/.github/workflows/_regenerate.yml deleted file mode 100644 index 4121e33..0000000 --- a/.github/workflows/_regenerate.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Verify legacy Bash loader -on: - push: - branches: [codex/official-installers-20260920] - paths: [templates/load.sh.in, .github/workflows/_regenerate.yml] -permissions: - contents: write -jobs: - regenerate: - runs-on: macos-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 - with: - node-version: 24.21.0 - - name: Generate and test on the system Bash - shell: bash - run: | - /bin/bash --version - python3 tools/generate.py - python3 tools/generate.py --check - python3 tests/test_installers.py - python3 tests/test_official_policy.py - python3 tests/test_library_loader.py - pwsh -NoProfile -File tests/test-library-loader.ps1 - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add pi.sh dsh.sh lmm.sh - git commit -m 'fix: support buffered library imports on macOS Bash 3.2' - python3 - <<'PY' - from pathlib import Path - import re, subprocess - p=Path('tools/generate_menus.py') - revision=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() - text,count=re.subn(r"revision='[0-9a-f]{40}'",f"revision='{revision}'",p.read_text(encoding='utf-8')) - assert count==1 - p.write_text(text,encoding='utf-8') - PY - python3 tools/generate_menus.py - python3 tools/generate_menus.py --check - git add tools/generate_menus.py menu.sh menu.ps1 - git commit -m 'fix: pin menus to Bash-compatible library loaders' - git push origin HEAD:codex/official-installers-20260920 From b78106e6946bb3a0d876a55b63c38728ea0250e0 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 18:40:20 +0800 Subject: [PATCH 24/39] feat: add official cross-platform tool installers and shared catalog --- .github/workflows/_catalog.yml | 62 +++++++++ .github/workflows/extended.yml | 88 +++++++++++++ docs/install-sources.md | 21 +++ templates/external.ps1.in | 18 +++ templates/external.sh.in | 19 +++ templates/lib/external.ps1 | 133 +++++++++++++++++++ templates/lib/external.sh | 230 +++++++++++++++++++++++++++++++++ templates/menu.ps1.in | 132 +++++++++---------- templates/menu.sh.in | 80 ++++++------ tests/test-external.ps1 | 31 +++++ tests/test_catalog.py | 39 ++++++ tests/test_external.py | 128 ++++++++++++++++++ tools/_catalog_patch.py | 61 +++++++++ tools/catalog.py | 11 ++ tools/generate_menus.py | 50 ++++--- 15 files changed, 982 insertions(+), 121 deletions(-) create mode 100644 .github/workflows/_catalog.yml create mode 100644 .github/workflows/extended.yml create mode 100644 docs/install-sources.md create mode 100644 templates/external.ps1.in create mode 100644 templates/external.sh.in create mode 100644 templates/lib/external.ps1 create mode 100644 templates/lib/external.sh create mode 100644 tests/test-external.ps1 create mode 100644 tests/test_catalog.py create mode 100644 tests/test_external.py create mode 100644 tools/_catalog_patch.py create mode 100644 tools/catalog.py diff --git a/.github/workflows/_catalog.yml b/.github/workflows/_catalog.yml new file mode 100644 index 0000000..689ad57 --- /dev/null +++ b/.github/workflows/_catalog.yml @@ -0,0 +1,62 @@ +name: Prepare compact tool catalog +on: + push: + branches: [codex/official-installers-20260920] + paths: [tools/_catalog_patch.py, .github/workflows/_catalog.yml] +permissions: + contents: write +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24.21.0 + - name: Compose, test and pin modules + run: | + python3 tools/_catalog_patch.py + rm tools/_catalog_patch.py + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add templates tools README.md docs tests + git commit -m 'refactor: share lifecycle helpers and delegate new tools to official installers' + python3 - <<'PY' + from pathlib import Path + import json, subprocess + p=Path('versions.json'); v=json.loads(p.read_text(encoding='utf-8')) + v['library_revision']=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() + v['script_version']='2026.09.20.4' + p.write_text(json.dumps(v,indent=2)+'\n',encoding='utf-8') + PY + python3 tools/generate.py + python3 tools/generate.py --check + git add versions.json '*.sh' '*.ps1' + git commit -m 'feat: publish compact Codex Claude Code CC Switch and Clash Verge Rev installers' + python3 - <<'PY' + from pathlib import Path + import re, subprocess + p=Path('tools/generate_menus.py') + sha=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() + text,count=re.subn(r"revision='[0-9a-f]{40}'",f"revision='{sha}'",p.read_text(encoding='utf-8')) + assert count==1 + p.write_text(text,encoding='utf-8') + PY + python3 tools/generate_menus.py + python3 tools/generate_menus.py --check + python3 tests/test_installers.py + python3 tests/test_official_policy.py + python3 tests/test_library_loader.py + python3 tests/test_external.py + python3 tests/test_catalog.py + shellcheck *.sh + pwsh -NoProfile -File tests/test-powershell.ps1 + pwsh -NoProfile -File tests/test-library-loader.ps1 + pwsh -NoProfile -File tests/test-external.ps1 + git add tools/generate_menus.py menu.sh menu.ps1 + git commit -m 'feat: drive both menus from one seven-tool catalog' + wc -c pi.sh dsh.sh lmm.sh codex.sh claude-code.sh cc-switch.sh clash-verge-rev.sh + git push origin HEAD:codex/official-installers-20260920 diff --git a/.github/workflows/extended.yml b/.github/workflows/extended.yml new file mode 100644 index 0000000..9f6215f --- /dev/null +++ b/.github/workflows/extended.yml @@ -0,0 +1,88 @@ +name: Extended installer checks +on: + push: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: extended-${{ github.ref }} + cancel-in-progress: true +jobs: + platforms: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.x' + - run: python tools/generate.py --check + - run: python tools/generate_menus.py --check + - run: python tests/test_catalog.py + - name: Distribution and Termux routing + if: runner.os != 'Windows' + run: python3 tests/test_external.py + - name: PowerShell entries + shell: pwsh + run: ./tests/test-external.ps1 + - name: Windows PowerShell 5.1 entries + if: runner.os == 'Windows' + shell: powershell + run: .\tests\test-external.ps1 + - name: Real upstream CLI installs on Unix + if: runner.os != 'Windows' + shell: bash + run: | + export CODEX_HOME="$RUNNER_TEMP/codex-profile" + export CODEX_INSTALL_DIR="$RUNNER_TEMP/codex-bin" + bash codex.sh + bash codex.sh --check + bash claude-code.sh + bash claude-code.sh --check + - name: Real upstream CLI and portable CC Switch installs on Windows + if: runner.os == 'Windows' + shell: powershell + run: | + $env:CODEX_HOME="$env:RUNNER_TEMP\codex-profile" + $env:CODEX_INSTALL_DIR="$env:RUNNER_TEMP\codex-bin" + .\codex.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Codex install failed' } + .\codex.ps1 -Check + if ($LASTEXITCODE -ne 0) { throw 'Codex check failed' } + .\claude-code.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Claude install failed' } + .\claude-code.ps1 -Check + if ($LASTEXITCODE -ne 0) { throw 'Claude check failed' } + .\cc-switch.ps1 -Root "$env:RUNNER_TEMP\cc switch" + if ($LASTEXITCODE -ne 0) { throw 'CC Switch installation failed' } + .\cc-switch.ps1 -Root "$env:RUNNER_TEMP\cc switch" -Check + if ($LASTEXITCODE -ne 0) { throw 'CC Switch check failed' } + linux-libc: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + image: ['debian:12', 'alpine:3.22'] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + - name: Install CLIs in a clean glibc or musl userland + env: + IMAGE: ${{ matrix.image }} + run: | + docker run --rm -v "$PWD:/src:ro" "$IMAGE" sh -ec ' + if command -v apk >/dev/null; then apk add --no-cache bash curl ca-certificates; + else apt-get update && apt-get install -y bash curl ca-certificates; fi + export LMM_LIB_DIR=/src/templates/lib + bash /src/codex.sh --install-deps + bash /src/codex.sh --check + bash /src/claude-code.sh --install-deps + bash /src/claude-code.sh --check + ' diff --git a/docs/install-sources.md b/docs/install-sources.md new file mode 100644 index 0000000..76f0f23 --- /dev/null +++ b/docs/install-sources.md @@ -0,0 +1,21 @@ +# 安装方式核查 + +核查日期:2026-09-20。只采用项目官方文档、官方仓库和发布包;社区教程用于定位问题,不作为自动执行来源。 + +| 工具 | 依据 | 本项目处理 | +|---|---|---| +| Codex | [CLI](https://developers.openai.com/codex/cli/)、[官方安装器](https://github.com/openai/codex/tree/main/scripts/install) | 原生 sh/PowerShell 安装器,`--release` / `-Release` 传版本;默认 latest,无需 npm。Linux 由上游选择架构及 libc;本项目不维护另一套二进制命名。 | +| Claude Code | [Setup](https://code.claude.com/docs/en/setup) | 官方原生安装器,默认 stable。Linux 包括 glibc/musl;Alpine 需要 `libgcc libstdc++ ripgrep` 和 `USE_BUILTIN_RIPGREP=0`。Windows 不把 Git Bash 写成硬性前提。 | +| CC Switch | [README](https://github.com/farion1231/cc-switch)、[Releases](https://github.com/farion1231/cc-switch/releases) | deb/rpm/AppImage、macOS Homebrew/DMG、Windows portable ZIP。核查到 v3.20.3;按运行时 release 的真实 asset 列表选择 x64/arm64,不混用签名文件。 | +| Clash Verge Rev | [安装文档](https://www.clashverge.dev/install.html)、[Releases](https://github.com/clash-verge-rev/clash-verge-rev/releases)、[Homebrew](https://github.com/Homebrew/homebrew-cask/blob/main/Casks/c/clash-verge-rev.rb) | deb/rpm、现有 AUR helper、macOS Homebrew/DMG、Windows setup。核查到 v2.5.2,不杜撰 AppImage。macOS Intel 包后缀为 x64。 | +| Pi | [Quickstart](https://pi.dev/docs/latest/quickstart)、[Termux](https://pi.dev/docs/latest/termux)、[Windows](https://pi.dev/docs/latest/windows) | 保留官方 npm `--ignore-scripts`;Termux 使用原生 Node/npm;Windows 检查 Bash。LMM 插件属于本站集成,不冒称官方内置。 | +| DSH | [README](https://github.com/deepseek-ai/deepseek-harness)、[CLI](https://github.com/deepseek-ai/deepseek-harness/blob/master/apps/cli/README.md) | 保留 `dsh web` 与 profile 插件安装方式,原生构建策略单独处理。源码运行需先 build,不等同于发布包安装。 | +| LMM CLI | [项目](https://github.com/TokenNotIncluded/api.lmm.best) | 保持预览版范围:安装器安装 CLI,但 CLI 的 setup 只生成计划。未改变账号存储和 OAuth。 | + +## Linux 与 Termux + +Codex、Claude 不依赖发行版提供足够新的 Node。依赖安装显式使用 `--install-deps`,支持 apt、dnf/yum、zypper、pacman、apk、xbps;不进行整机升级。NixOS 需要它自己的 Nix 包环境,32 位 CPU 不在这两个官方原生 CLI 的范围内。 + +Claude 的新 npm 包也使用平台原生组件,不能靠 npm 就声称 Android 原生可用。Termux 入口采用 [PRoot-Distro 官方用法](https://github.com/termux/proot-distro):用户先准备 guest,脚本再在该 guest 中运行官方安装器。`--distro` 选择已有环境,默认 ubuntu;`--install-deps` 只自动准备 Debian/Ubuntu guest 的依赖,其他 guest 按自身包管理器准备。不修改 Android 路由,不关闭 Agent 沙箱,不复制账号文件。PRoot 没有独立内核,安装成功也不表示所有沙箱能力可用。 + +`--dry-run` / `-DryRun` 只展示路由,不是实装验证;桌面 `--check` 只检查安装入口。真实 Android 设备、图形交互、登录及模型调用需要另外验证。官方安装器和桌面安装包仍执行自身的签名、权限与更新流程;本项目没有另外增加公共库哈希清单。 diff --git a/templates/external.ps1.in b/templates/external.ps1.in new file mode 100644 index 0000000..71f02cb --- /dev/null +++ b/templates/external.ps1.in @@ -0,0 +1,18 @@ +[CmdletBinding(PositionalBinding=$false)] +param([string]$Root='', [string]$Version='', + [ValidateSet('auto','official','china')][string]$Network='official', + [switch]$Check,[switch]$Update,[switch]$Launch,[switch]$DryRun,[switch]$Help, + [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) +$ErrorActionPreference='Stop' +$Target='@@TARGET@@' +$LibRevision='@@REVISION@@' +if ($Help) { + Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." + exit 0 +} +@@LOADER@@ +try { + . (Get-LmmLibrary 'external.ps1') + Invoke-ExternalSetup -Target $Target -Root $Root -Version $Version -Network $Network -Check:$Check -Update:$Update -Launch:$Launch -DryRun:$DryRun -RunArgs $RunArgs + exit 0 +} catch { Write-Error $_ -ErrorAction Continue; exit 1 } diff --git a/templates/external.sh.in b/templates/external.sh.in new file mode 100644 index 0000000..7af52c9 --- /dev/null +++ b/templates/external.sh.in @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +lmm_entry() { +set -euo pipefail +# shellcheck disable=SC2034 +TARGET=@@TARGET@@ +LIB_REVISION=@@REVISION@@ +for arg in "$@"; do + case "$arg" in --) break;; --help|-h) + printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' + return 0;; + esac +done +@@LOADER@@ +for library in termux.sh quote.sh external.sh; do lmm_source_lib "$library" || exit $?; done +lmm_external_main "$@" +} +if true; then + lmm_entry "$@" +fi diff --git a/templates/lib/external.ps1 b/templates/lib/external.ps1 new file mode 100644 index 0000000..e6cc971 --- /dev/null +++ b/templates/lib/external.ps1 @@ -0,0 +1,133 @@ +# Native upstream installers; desktop apps are never launched implicitly. +function Get-ExternalEntry([string]$Command, [string]$Root) { + $paths=@((Join-Path $Root "bin\$Command.cmd"), (Join-Path $HOME ".local\bin\$Command.exe")) + if ($Command -eq 'codex') { + if ($env:CODEX_INSTALL_DIR) { $paths+=Join-Path $env:CODEX_INSTALL_DIR 'codex.exe' } + $paths+=Join-Path $env:LOCALAPPDATA 'Programs\OpenAI\Codex\bin\codex.exe' + } + if ($Command -eq 'clash-verge') { + foreach ($base in @($env:LOCALAPPDATA,$env:ProgramFiles,${env:ProgramFiles(x86)})) { + if ($base) { foreach ($folder in @('Clash Verge','Programs\Clash Verge')) { $paths+=Join-Path $base "$folder\clash-verge.exe" } } + } + foreach ($key in @('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')) { + foreach ($item in @(Get-ItemProperty $key -ErrorAction SilentlyContinue)) { + if ($item.PSObject.Properties['DisplayName'] -and $item.DisplayName -like 'Clash Verge*' -and $item.PSObject.Properties['InstallLocation'] -and $item.InstallLocation) { + $paths+=Join-Path $item.InstallLocation 'clash-verge.exe' + } + } + } + } + foreach ($path in $paths) { if (Test-Path -LiteralPath $path -PathType Leaf) { return $path } } + $found=Get-Command $Command -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($found) { return $found.Source } + return $null +} +function Get-ExternalAssetPattern([string]$Target, [string]$Architecture) { + if ($Target -eq 'cc-switch') { + if ($Architecture -eq 'arm64') { return '-Windows-arm64-Portable\.zip$' } + return '-Windows-Portable\.zip$' + } + return "_${Architecture}-setup\.exe$" +} +function Receive-ExternalFile([string]$Url, [string]$Path) { + $urls=@($Url) + if ((Get-Variable Network -ErrorAction SilentlyContinue) -and $Network -eq 'china' -and $Url.StartsWith('https://github.com/')) { $urls=@("https://ghfast.top/$Url",$Url) } + foreach ($source in $urls) { + for ($attempt=1; $attempt -le 3; $attempt++) { + try { + Invoke-WebRequest -UseBasicParsing -Uri $source -OutFile $Path -TimeoutSec 600 -ErrorAction Stop + if ((Get-Item -LiteralPath $Path).Length -eq 0) { throw 'Empty download' } + return + } catch { if ($attempt -eq 3 -and $source -eq $urls[-1]) { throw }; Start-Sleep -Seconds 1 } + } + } +} +function Invoke-ExternalSetup { + param([string]$Target,[string]$Root,[string]$Version,[string]$Network='official', + [switch]$Check,[switch]$Update,[switch]$Launch,[switch]$DryRun,[string[]]$RunArgs=@()) + if ($env:OS -ne 'Windows_NT') { throw 'Use the .sh installer on Linux/macOS/Termux.' } + if (!$Root) { $Root=if($env:LMM_INSTALL_ROOT){$env:LMM_INSTALL_ROOT}else{Join-Path $env:LOCALAPPDATA 'lmm-tools'} } + $Root=[IO.Path]::GetFullPath($Root) + if ($Root -eq [IO.Path]::GetPathRoot($Root) -or $Root -eq $HOME -or $Root -match '[\r\n]') { throw 'Choose a dedicated install directory' } + if ((Test-Path -LiteralPath $Root) -and ((Get-Item -LiteralPath $Root).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Refusing a linked install root' } + $architecture=$env:PROCESSOR_ARCHITECTURE + if ($env:PROCESSOR_ARCHITEW6432) { $architecture=$env:PROCESSOR_ARCHITEW6432 } + switch ($architecture) { 'ARM64' { $arch='arm64' } 'AMD64' { $arch='x64' } default { throw 'x64 or ARM64 Windows is required' } } + $url=$null; $repo=$null + switch ($Target) { + 'codex' { $command='codex'; $url='https://chatgpt.com/codex/install.ps1' } + 'claude-code' { $command='claude'; $url='https://claude.ai/install.ps1' } + 'cc-switch' { $command='cc-switch'; $repo='farion1231/cc-switch' } + 'clash-verge-rev' { $command='clash-verge'; $repo='clash-verge-rev/clash-verge-rev' } + default { throw 'Unknown tool' } + } + if ($Version) { $Update=$true } + if (!$Version) { $Version=if($Target -eq 'claude-code'){'stable'}else{'latest'} } + if ($Version -notmatch '^[a-zA-Z0-9][a-zA-Z0-9.+-]*$') { throw 'Invalid version' } + if ($DryRun) { + if ($url) { Write-Output "$Target windows/$arch official:$url version:$Version" } + else { Write-Output "$Target windows/$arch release:$repo pattern:$(Get-ExternalAssetPattern $Target $arch)" } + return + } + $entry=Get-ExternalEntry $command $Root + if ($Check) { + if (!$entry) { throw "$command is not installed" } + if ($url) { & $entry --version; if ($LASTEXITCODE -ne 0) { throw 'Executable check failed' } } + else { Write-Output "Installed: $entry" } + return + } + $stage=$null + $oldTls=[Net.ServicePointManager]::SecurityProtocol + try { + [Net.ServicePointManager]::SecurityProtocol=$oldTls -bor [Net.SecurityProtocolType]::Tls12 + if (!$entry -or $Update) { + $stage=Join-Path ([IO.Path]::GetTempPath()) ('lmm-'+[Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $stage | Out-Null + if ($url) { + $installer=Join-Path $stage 'install.ps1'; Receive-ExternalFile $url $installer + $shell=(Get-Process -Id $PID).Path + if ($Target -eq 'codex') { & $shell -NoProfile -ExecutionPolicy Bypass -File $installer -Release $Version } + else { & $shell -NoProfile -ExecutionPolicy Bypass -File $installer $Version } + if ($LASTEXITCODE -ne 0) { throw "Official installer exited with $LASTEXITCODE" } + } else { + $release=if($Version -eq 'latest'){'latest'}else{'tags/v'+$Version.TrimStart('v')} + $metadata=Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/$release" -TimeoutSec 60 + $pattern=Get-ExternalAssetPattern $Target $arch + $assets=@($metadata.assets | Where-Object { $_.name -match $pattern }) + if ($assets.Count -ne 1) { throw "Expected one $arch asset matching $pattern" } + $asset=$assets[0]; $file=Join-Path $stage $asset.name + Receive-ExternalFile $asset.browser_download_url $file + if ($Target -eq 'cc-switch') { + $app=Join-Path $stage 'app'; Expand-Archive -LiteralPath $file -DestinationPath $app + $binaries=@(Get-ChildItem -LiteralPath $app -Recurse -File -Filter 'cc-switch.exe') + if ($binaries.Count -ne 1) { throw 'Expected one cc-switch.exe in portable archive' } + $relative=$binaries[0].FullName.Substring($app.Length).TrimStart('\') + $destination=Join-Path $Root ('apps\cc-switch\'+[Guid]::NewGuid().ToString('N')) + $launcher=Join-Path $Root 'bin\cc-switch.cmd' + if ((Test-Path -LiteralPath $launcher) -and !(Select-String -LiteralPath $launcher -SimpleMatch 'Managed by LMM installers' -Quiet)) { throw 'Refusing existing launcher' } + New-Item -ItemType Directory -Path (Split-Path $destination),(Split-Path $launcher) -Force | Out-Null + Move-Item -LiteralPath $app -Destination $destination + $binary=Join-Path $destination $relative + $within=$binary.Substring($Root.Length).TrimStart('\') + $text="@echo off`r`nrem Managed by LMM installers`r`nsetlocal DisableDelayedExpansion`r`n`"%~dp0..\$within`" %*`r`n" + $pending=Join-Path (Split-Path $launcher) ('launcher-'+[Guid]::NewGuid().ToString('N')+'.tmp') + [IO.File]::WriteAllText($pending,$text,[Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $pending -Destination $launcher -Force + } else { + Write-Output 'Complete the official installer window; system service/UAC prompts belong to Clash Verge Rev.' + $process=Start-Process -FilePath $file -Wait -PassThru + if ($process.ExitCode -notin @(0,1641,3010)) { throw "Installer exited with $($process.ExitCode)" } + if ($process.ExitCode -ne 0) { Write-Output 'Windows restart requested by installer.' } + } + } + $entry=Get-ExternalEntry $command $Root + if (!$entry) { throw 'Installer finished but executable was not found; inspect its installation directory.' } + if ($url) { & $entry --version; if ($LASTEXITCODE -ne 0) { throw 'Installed binary did not run' } } + } + Write-Output "Ready: $entry" + if ($Launch) { & $entry @RunArgs; if ($LASTEXITCODE -ne 0) { throw "Program exited with $LASTEXITCODE" } } + } finally { + [Net.ServicePointManager]::SecurityProtocol=$oldTls + if ($stage) { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue } + } +} diff --git a/templates/lib/external.sh b/templates/lib/external.sh new file mode 100644 index 0000000..5d3f510 --- /dev/null +++ b/templates/lib/external.sh @@ -0,0 +1,230 @@ +# shellcheck shell=bash +# Delegate CLI installation to upstream; share desktop package selection. +lmm_external_main() ( + set -euo pipefail + local ROOT=${LMM_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} + local CHECK=0 UPDATE=0 LAUNCH=0 PLAN=0 DEPS=0 NETWORK=official VERSION='' + local DISTRO=${LMM_PROOT_DISTRO:-ubuntu} STAGE='' MOUNT='' COMMAND='' KIND='' APP='' REPO='' + local OS ARCH FAMILY='' LIBC=glibc ENTRY='' ASSET='' URL='' RUN_ARGS=() + die() { printf '%s\n' "$*" >&2; exit 1; } + while [ "$#" -gt 0 ]; do + case "$1" in + --check) CHECK=1;; --update) UPDATE=1;; --launch) LAUNCH=1;; + --dry-run) PLAN=1;; --install-deps) DEPS=1;; + --root|--network|--version|--distro) + [ "$#" -ge 2 ] && [ -n "$2" ] || die "$1 requires a value" + case "$1" in --root) ROOT=$2;; --network) NETWORK=$2;; --version) VERSION=$2; UPDATE=1;; --distro) DISTRO=$2;; esac; shift;; + --) shift; RUN_ARGS=("$@"); break;; + *) die "Unknown option: $1 (use --help)";; + esac; shift + done + case "$NETWORK" in auto|official|china) ;; *) die 'network: auto, official or china';; esac + [[ $DISTRO =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] || die 'Invalid proot distro name' + case "$ROOT" in /*) ;; *) ROOT="$PWD/$ROOT";; esac + [ "$ROOT" != / ] && [ "$ROOT" != "$HOME" ] && [ ! -L "$ROOT" ] || die 'Choose a dedicated install directory' + case "$ROOT" in *$'\n'*|*$'\r'*) die 'Invalid install path';; esac + case "$TARGET" in + codex) COMMAND=codex; KIND=cli; URL=https://chatgpt.com/codex/install.sh;; + claude-code) COMMAND=claude; KIND=cli; URL=https://claude.ai/install.sh; VERSION=${VERSION:-stable};; + cc-switch) COMMAND=cc-switch; KIND=desktop; APP='CC Switch'; REPO=farion1231/cc-switch;; + clash-verge-rev) COMMAND=clash-verge; KIND=desktop; APP='Clash Verge'; REPO=clash-verge-rev/clash-verge-rev;; + *) die 'Unknown tool';; + esac + VERSION=${VERSION:-latest} + [[ $VERSION =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]] || die 'Invalid version' + case "$(uname -s)" in Linux) OS=linux;; Darwin) OS=darwin;; *) die 'Use the .ps1 installer on Windows';; esac + if lmm_is_termux; then OS=android; fi + case "$(uname -m)" in x86_64|amd64) ARCH=x64;; aarch64|arm64) ARCH=arm64;; *) die 'This installer requires x64 or arm64';; esac + if [ "$OS" = android ]; then + [ "$KIND" = cli ] || die "$APP is a desktop application; Termux is not supported" + lmm_check_storage "$ROOT" || exit 1 + elif [ "$OS" = linux ]; then + local ID='' ID_LIKE='' + if [ -f "${LMM_OS_RELEASE:-/etc/os-release}" ]; then + # shellcheck disable=SC1090 + . "${LMM_OS_RELEASE:-/etc/os-release}" + fi + case " $ID $ID_LIKE " in + *alpine*) FAMILY=alpine;; *debian*|*ubuntu*) FAMILY=debian;; + *fedora*|*rhel*|*centos*|*rocky*|*almalinux*) FAMILY=fedora;; *suse*) FAMILY=suse;; + *arch*) FAMILY=arch;; *void*) FAMILY=void;; *nixos*) FAMILY=nixos;; + esac + if [ "$FAMILY" = alpine ] || { ldd --version 2>&1 || :; } | grep -qi musl; then LIBC=musl; fi + fi + strategy() { + if [ "$OS" = linux ] && [ "$FAMILY" = nixos ]; then die 'NixOS: use a Nix package/dev shell'; fi + if [ "$KIND" = desktop ] && [ "$LIBC" = musl ]; then die 'No compatible musl desktop package'; fi + if [ "$OS" = android ]; then printf 'proot:%s official:%s\n' "$DISTRO" "$URL" + elif [ "$KIND" = cli ]; then printf 'official:%s libc:%s\n' "$URL" "$LIBC" + elif [ "$OS" = darwin ]; then printf 'macOS:Homebrew-or-DMG\n' + else case "$FAMILY" in debian) printf 'apt:deb\n';; fedora) printf 'dnf-or-yum:rpm\n';; suse) printf 'zypper:rpm\n';; arch) printf 'paru-or-yay:AUR\n';; *) [ "$TARGET" = cc-switch ] && printf 'AppImage\n' || die 'Clash Verge Rev requires a deb/rpm distro or an AUR helper';; esac; fi + } + if [ "$PLAN" = 1 ]; then printf '%s %s/%s ' "$TARGET" "$OS" "$ARCH"; strategy; exit 0; fi + find_entry() { + local candidate + for candidate in "$ROOT/bin/$COMMAND" "${CODEX_INSTALL_DIR:-$HOME/.local/bin}/$COMMAND" "$HOME/.local/bin/$COMMAND"; do + if [ -x "$candidate" ]; then ENTRY=$candidate; return; fi + done + if [ "$OS" = darwin ] && [ "$KIND" = desktop ]; then + for candidate in "$HOME/Applications/$APP.app" "/Applications/$APP.app"; do + if [ -d "$candidate" ]; then ENTRY=$candidate; return; fi + done + fi + ENTRY=$(command -v "$COMMAND" || :) + } + find_entry + if [ "$CHECK" = 1 ]; then + [ -n "$ENTRY" ] || die "$COMMAND is not installed" + if [ "$KIND" = cli ]; then "$ENTRY" --version; else printf 'Installed: %s\n' "$ENTRY"; fi + exit 0 + fi + cleanup() { + if [ -n "$MOUNT" ]; then hdiutil detach "$MOUNT" >/dev/null 2>&1 || :; fi + if [ -n "$STAGE" ]; then rm -rf -- "$STAGE"; fi + } + trap cleanup EXIT; trap 'exit 130' INT; trap 'exit 143' TERM + admin() { if [ "$(id -u)" = 0 ]; then "$@"; else sudo "$@"; fi; } + prerequisites() { + local packages=(bash curl ca-certificates git) + [ "$KIND" != desktop ] || packages+=(jq) + case "$FAMILY" in + alpine) [ "$TARGET" != claude-code ] || packages+=(libgcc libstdc++ ripgrep); admin apk add "${packages[@]}";; + debian) admin apt-get update; admin apt-get install -y "${packages[@]}";; + fedora) if command -v dnf >/dev/null; then admin dnf install -y "${packages[@]}"; else admin yum install -y "${packages[@]}"; fi;; + suse) admin zypper --non-interactive install "${packages[@]}";; + arch) admin pacman -S --needed --noconfirm "${packages[@]}";; + void) admin xbps-install -y "${packages[@]}";; + *) die 'Install bash, curl, CA certificates and git with your package manager (GUI release selection also needs jq)';; + esac + } + fetch() { + local source=$1 output=$2 + if [ "$NETWORK" = china ] && [[ $source == https://github.com/* ]]; then + if curl -q -fsSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 600 "https://ghfast.top/$source" -o "$output" && [ -s "$output" ]; then return; fi + fi + curl -q -fsSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 600 --retry 2 "$source" -o "$output" + [ -s "$output" ] || die "Empty download: $source" + } + shim() { + local file="$ROOT/bin/$COMMAND" + mkdir -p "$ROOT/bin" + if [ -e "$file" ] || [ -L "$file" ]; then + grep -Fq '# Managed by LMM installers.' "$file" || die "Refusing existing launcher: $file" + fi + { printf '#!%s\n# Managed by LMM installers.\n' "$BASH"; printf '%s\n' "$1"; } > "$STAGE/launcher" + chmod +x "$STAGE/launcher"; mv -f -- "$STAGE/launcher" "$file"; ENTRY=$file + } + stage() { + local tmp + tmp=$(lmm_temp_root); lmm_check_storage "$tmp" || exit 1 + mkdir -p "$tmp"; STAGE=$(mktemp -d "$tmp/lmm-$TARGET.XXXXXXXX") + } + if [ -z "$ENTRY" ] || [ "$UPDATE" = 1 ]; then + if [ "$OS" = linux ]; then + [ "$FAMILY" != nixos ] || die 'NixOS: use a Nix package/dev shell; the upstream generic Linux installer is not compatible with its loader layout' + [ "$DEPS" = 0 ] || prerequisites + fi + command -v curl >/dev/null || die 'Install curl first' + stage + if [ "$OS" = android ]; then + if ! command -v proot-distro >/dev/null; then + [ "$DEPS" = 1 ] || die 'Termux: pkg install proot-distro; proot-distro install ubuntu:24.04; then rerun' + pkg install -y proot-distro + fi + proot-distro login "$DISTRO" -- /bin/true || die "Prepare an existing Linux guest first: proot-distro install ubuntu:24.04 (selected: $DISTRO)" + if [ "$DEPS" = 1 ]; then + proot-distro login "$DISTRO" -- /bin/sh -c 'command -v apt-get >/dev/null || { echo "Prepare guest dependencies with its package manager" >&2; exit 1; }; apt-get update && apt-get install -y bash curl ca-certificates git' + fi + proot-distro login "$DISTRO" -- /bin/sh -c 'command -v bash && command -v curl' >/dev/null || die 'Install bash, curl, ca-certificates and git inside the selected guest' + fetch "$URL" "$STAGE/install.sh" + local install_args=("$VERSION") + [ "$TARGET" != codex ] || install_args=(--release "$VERSION") + proot-distro login "$DISTRO" --bind "$STAGE:/mnt/lmm-install" -- /bin/bash -c 'unset CODEX_HOME CODEX_INSTALL_DIR; bash /mnt/lmm-install/install.sh "$@"' -- "${install_args[@]}" + proot-distro login "$DISTRO" -- /bin/bash -c '"$HOME/.local/bin/$1" --version' -- "$COMMAND" + shim "exec $(quote_sh "$(command -v proot-distro)") login $(quote_sh "$DISTRO") --bind \"\$PWD:/workspace\" --work-dir /workspace -- /bin/bash -c 'exec \"\$HOME/.local/bin/$COMMAND\" \"\$@\"' -- \"\$@\"" + printf 'Termux uses a PRoot Linux guest; native Android and sandbox parity are not implied.\n' + elif [ "$KIND" = cli ]; then + if [ "$TARGET" = claude-code ] && [ "$LIBC" = musl ]; then + command -v rg >/dev/null || die 'musl: install libgcc, libstdc++ and ripgrep (Alpine: rerun with --install-deps)' + export USE_BUILTIN_RIPGREP=0 + fi + fetch "$URL" "$STAGE/install.sh" + if [ "$TARGET" = codex ]; then bash "$STAGE/install.sh" --release "$VERSION" + else bash "$STAGE/install.sh" "$VERSION"; fi + ENTRY=''; find_entry + if [ "$TARGET" = claude-code ] && [ "$LIBC" = musl ] && [ -x "$HOME/.local/bin/claude" ]; then ENTRY="$HOME/.local/bin/claude"; fi + [ -n "$ENTRY" ] || die "Installer returned without a usable $COMMAND executable" + "$ENTRY" --version + if [ "$TARGET" = claude-code ] && [ "$LIBC" = musl ]; then + [ "$ENTRY" != "$ROOT/bin/$COMMAND" ] || die 'Refusing a recursive Claude launcher; rerun the official installer' + shim "export USE_BUILTIN_RIPGREP=0; exec $(quote_sh "$ENTRY") \"\$@\"" + fi + else + lmm_install_desktop + fi + fi + printf 'Ready: %s\n' "$ENTRY" + if [ "$LAUNCH" = 1 ]; then + if [ "$OS" = darwin ] && [[ $ENTRY == *.app ]]; then open "$ENTRY" --args ${RUN_ARGS[@]+"${RUN_ARGS[@]}"} + else "$ENTRY" ${RUN_ARGS[@]+"${RUN_ARGS[@]}"}; fi + fi +) + +# Called inside lmm_external_main; reuse its paths and download functions. +lmm_install_desktop() { + local manager ext pattern metadata candidate target + if [ "$OS" = darwin ] && command -v brew >/dev/null && [ "$VERSION" = latest ]; then + if brew list --cask "$TARGET" >/dev/null 2>&1; then brew upgrade --cask "$TARGET" + else brew install --cask "$TARGET"; fi + ENTRY=''; find_entry; [ -n "$ENTRY" ] || die "Find $APP in Applications"; return + fi + if [ "$OS" = linux ] && [ "$FAMILY" = arch ]; then + [ "$VERSION" = latest ] || die 'AUR tracks its packaged version; an exact upstream version is not supported here' + manager=$(command -v paru || command -v yay || :) + [ -n "$manager" ] || die "Install an AUR helper, then: paru -S $TARGET-bin" + "$manager" -S --needed "$TARGET-bin" + ENTRY=''; find_entry; [ -n "$ENTRY" ] || die 'Package installed but executable was not found'; return + fi + case "$OS:$FAMILY" in darwin:*) ext=dmg;; linux:debian) ext=deb;; linux:fedora|linux:suse) ext=rpm;; *) + [ "$TARGET" = cc-switch ] && [ "$LIBC" = glibc ] || die 'No compatible desktop package for this distribution' + ext=AppImage;; + esac + pattern=$(lmm_desktop_pattern "$TARGET" "$OS" "$ARCH" "$ext") + metadata=latest; [ "$VERSION" = latest ] || metadata="tags/v${VERSION#v}" + fetch "https://api.github.com/repos/$REPO/releases/$metadata" "$STAGE/release.json" + if command -v jq >/dev/null; then + candidate=$(jq -er --arg pattern "$pattern" '[.assets[] | select(.name | test($pattern; "i")) | .browser_download_url] | if length == 1 then .[0] else error("expected exactly one matching asset") end' "$STAGE/release.json") || die "No unique $OS/$ARCH $ext asset" + elif [ "$OS" = darwin ]; then + candidate=$(osascript -l JavaScript -e 'function run(a) {var f=Application.currentApplication(); f.includeStandardAdditions=true; var j=JSON.parse(f.read(Path(a[0]))); var r=j.assets.filter(x=>new RegExp(a[1],"i").test(x.name)); if(r.length!==1)throw Error("expected one asset"); return r[0].browser_download_url;}' "$STAGE/release.json" "$pattern") + else die 'Install jq first (or rerun with --install-deps)'; fi + ASSET="$STAGE/${candidate##*/}"; fetch "$candidate" "$ASSET" + case "$ext" in + deb) admin apt-get install -y "$ASSET";; + rpm) case "$FAMILY" in suse) admin zypper --non-interactive install "$ASSET";; *) if command -v dnf >/dev/null; then admin dnf install -y "$ASSET"; else admin yum localinstall -y "$ASSET"; fi;; esac;; + AppImage) + target="$ROOT/apps/$TARGET/$(date +%s)-$$/${ASSET##*/}"; mkdir -p "$(dirname "$target")" + chmod +x "$ASSET"; mv -- "$ASSET" "$target"; shim "exec $(quote_sh "$target") \"\$@\""; return;; + dmg) + MOUNT="$STAGE/mount"; mkdir "$MOUNT"; hdiutil attach "$ASSET" -readonly -nobrowse -mountpoint "$MOUNT" >/dev/null + [ -d "$MOUNT/$APP.app" ] || die 'Application bundle missing in DMG' + target="$HOME/Applications/$APP.app"; mkdir -p "$HOME/Applications" + [ ! -e "$target" ] || mv "$target" "$target.backup-$(date +%s)-$$" + ditto "$MOUNT/$APP.app" "$target"; ENTRY=$target; return;; + esac + ENTRY=''; find_entry; [ -n "$ENTRY" ] || die 'Package installed but executable was not found' +} + +lmm_desktop_pattern() { + local target=$1 os=$2 arch=$3 ext=$4 suffix + if [ "$target" = cc-switch ]; then + if [ "$os" = darwin ]; then printf '%s\n' '-macOS\.dmg$'; return; fi + suffix=x86_64; [ "$arch" != arm64 ] || suffix=arm64 + printf '%s\n' "-Linux-$suffix\\.$ext$" + else + case "$ext:$arch" in + deb:x64) suffix=amd64;; deb:arm64) suffix=arm64;; + dmg:x64) suffix=x64;; *:arm64) suffix=aarch64;; *) suffix=x86_64;; + esac + printf '%s\n' "[._]$suffix\\.$ext$" + fi +} diff --git a/templates/menu.ps1.in b/templates/menu.ps1.in index 3d8dc2d..8880bf3 100644 --- a/templates/menu.ps1.in +++ b/templates/menu.ps1.in @@ -1,102 +1,98 @@ -# PowerShell 5.1+. Generated with UTF-8 BOM for Chinese text on Windows. +# PowerShell 5.1+. Generated with UTF-8 BOM. [CmdletBinding()] -param([switch]$Help) -$ErrorActionPreference = 'Stop' -if ($Help) { Write-Output 'LMM menu: .\menu.ps1 [-Help]. Interactive terminal required.'; exit 0 } -$hashes = @{ +param([switch]$Help,[switch]$List) +$ErrorActionPreference='Stop' +@@CATALOG@@ +function Show-Tools { for ($i=0; $i -lt $tools.Count; $i++) { Write-Output "$($i+1) $($tools[$i].Label)" } } +if ($Help) { Write-Output 'LMM menu: .\menu.ps1 [-List]'; exit 0 } +if ($List) { Show-Tools; exit 0 } +$hashes=@{ @@HASHES@@ } -$network = 'auto' -$root = $env:LMM_INSTALL_ROOT -if (!$root) { $root = Join-Path $env:LOCALAPPDATA 'lmm-tools' } -$work = Join-Path ([IO.Path]::GetTempPath()) ('lmm-menu-' + [Guid]::NewGuid().ToString('N')) -$engine = (Get-Process -Id $PID).Path -$oldProtocol = [Net.ServicePointManager]::SecurityProtocol -function Ask([string]$Prompt) { - $value = Read-Host $Prompt - if ($null -eq $value) { throw '输入已关闭,请在交互终端运行。' } - return $value.Trim() -} +$network='auto'; $root=$env:LMM_INSTALL_ROOT +if (!$root) { $root=Join-Path $env:LOCALAPPDATA 'lmm-tools' } +$work=Join-Path ([IO.Path]::GetTempPath()) ('lmm-menu-'+[Guid]::NewGuid().ToString('N')) +$engine=(Get-Process -Id $PID).Path +$oldProtocol=[Net.ServicePointManager]::SecurityProtocol +function Ask([string]$Prompt) { $value=Read-Host $Prompt; if ($null -eq $value) { throw '需要交互终端。' }; return $value.Trim() } function Fetch-Script([string]$Name) { if (!$hashes.ContainsKey($Name)) { throw 'Unknown script' } - $path = Join-Path $work $Name - if ((Test-Path -LiteralPath $path) -and ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -eq $hashes[$Name])) { return $path } - Write-Host '正在获取并校验安装程序(下载慢时会重试)…' - foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/@@REVISION@@/$Name")) { - for ($attempt = 1; $attempt -le 3; $attempt++) { + $path=Join-Path $work $Name + if ((Test-Path -LiteralPath $path) -and (Get-FileHash -LiteralPath $path).Hash -eq $hashes[$Name]) { return $path } + foreach ($url in @("https://api.lmm.best/scripts/$Name","https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/@@REVISION@@/$Name")) { + for ($attempt=1; $attempt -le 3; $attempt++) { try { - Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 -ErrorAction Stop - if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $hashes[$Name]) { throw '文件版本或校验不匹配' } + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 + if ((Get-FileHash -LiteralPath $path).Hash -ne $hashes[$Name]) { throw '版本不匹配' } return $path - } catch { Write-Host ("下载未完成:{0}" -f $_.Exception.Message); if ($attempt -lt 3) { Start-Sleep -Seconds 2 } } + } catch { if ($attempt -lt 3) { Start-Sleep -Seconds 1 } } } } - throw '下载失败;未执行任何未校验的文件。请检查网络后重试。' + throw "下载失败或版本不匹配:$Name" } -function Run-Script([string]$Name, [string[]]$Arguments) { +function Run-Script([string]$Name,[string[]]$Arguments) { try { - $path = Fetch-Script $Name + $path=Fetch-Script $Name & $engine -NoProfile -ExecutionPolicy Bypass -File $path @Arguments - if ($LASTEXITCODE -eq 0) { Write-Host '操作完成。' } - else { Write-Host "操作退出,状态码 $LASTEXITCODE。请查看上方提示;可以切换网络后重试。" } - } catch { Write-Host $_.Exception.Message -ForegroundColor Red } + if ($LASTEXITCODE -ne 0) { Write-Host "退出码 $LASTEXITCODE,请查看上方错误。" } + } catch { Write-Host $_.Exception.Message } } -function Show-Help([string]$Tool) { - switch ($Tool) { - pi { Write-Host 'Pi:启动后输入 /login,选择 LMM 并完成浏览器授权;再用 /model 选择模型。' } - dsh { Write-Host 'DSH:打开启动时提示的网址,在 Settings -> Models 的 LMM 卡片选择 Sign in with LMM。' } - lmm { Write-Host 'LMM CLI 是开发预览版。支持目录、状态、诊断、登录和模型列表;setup 目前只生成计划,不会安装应用。' } +function Show-Help([string]$Name) { + switch ($Name) { + pi { Write-Host 'pi → /login → LMM → /model。' } + dsh { Write-Host 'dsh web → Settings → Models → LMM。' } + lmm { Write-Host 'LMM CLI 为预览版,setup 只生成计划。' } + codex { Write-Host '运行 codex,按官方提示登录;使用上游安装位置与更新策略。' } + claude-code { Write-Host '运行 claude,按官方提示登录;使用上游安装位置与更新策略。' } + default { Write-Host '桌面应用按系统安装,不自动配置账号、订阅或启用代理。' } } - Write-Host "安装位置:$root" - Write-Host '默认不修改 PATH;以后可以重新运行菜单启动。' } try { - if ([Console]::IsInputRedirected) { throw '需要交互终端。请下载菜单后用 PowerShell -File 执行,不要重定向输入。' } - [Net.ServicePointManager]::SecurityProtocol = $oldProtocol -bor [Net.SecurityProtocolType]::Tls12 - [void](New-Item -ItemType Directory -Path $work) + if ([Console]::IsInputRedirected) { throw '需要交互终端;自动化请直接执行工具脚本。' } + [Net.ServicePointManager]::SecurityProtocol=$oldProtocol -bor [Net.SecurityProtocolType]::Tls12 + New-Item -ItemType Directory -Path $work | Out-Null :main while ($true) { - Write-Host "`n======== LMM 工具菜单 ========" - Write-Host "1 Pi Coding Agent`n2 DSH + LMM 插件`n3 LMM CLI(开发预览)`n4 下载网络(当前:$network)`n0 退出" - switch (Ask '输入数字') { - '0' { break main } - '4' { - Write-Host '1 自动选择 2 官方源 3 国内镜像' - switch (Ask '选择') { '1' { $network='auto' }; '2' { $network='official' }; '3' { $network='china' }; default { Write-Host '无效选择。' } } - continue main - } - '1' { $tool='pi' }; '2' { $tool='dsh' }; '3' { $tool='lmm' } - default { Write-Host '请输入菜单中的数字。'; continue main } + Write-Host "`nLMM 工具"; Show-Tools; Write-Host "n 下载网络($network)`n0 退出" + $choice=Ask '选择' + if ($choice -eq '0') { break } + if ($choice -eq 'n') { + Write-Host '1 自动 2 官方 3 国内镜像' + switch (Ask '选择') { '1' { $network='auto' }; '2' { $network='official' }; '3' { $network='china' } } + continue } + $index=0 + if (![int]::TryParse($choice,[ref]$index) -or $index -lt 1 -or $index -gt $tools.Count) { Write-Host '无效选择。'; continue } + $item=$tools[$index-1]; $tool=$item.Name :actions while ($true) { - Write-Host "`n-- $tool --`n1 安装 / 修复`n2 更新到菜单维护的版本`n3 检查安装环境`n4 启动 / 使用`n5 登录与使用说明`n0 返回" - switch (Ask '输入数字') { + Write-Host "`n$($item.Label)`n1 安装 2 更新 3 检查 4 启动 5 使用说明" + if ($item.Kind -ne 'managed') { Write-Host '6 预览安装方案' } + Write-Host '0 返回' + switch (Ask '选择') { '0' { break actions } '1' { Run-Script "$tool.ps1" @('-Network',$network) } '2' { Run-Script "$tool.ps1" @('-Network',$network,'-Update') } '3' { Run-Script "$tool.ps1" @('-Check') } '5' { Show-Help $tool } + '6' { if ($item.Kind -ne 'managed') { Run-Script "$tool.ps1" @('-DryRun') } } '4' { if ($tool -eq 'lmm') { - Write-Host "1 应用目录 2 状态 3 诊断 4 安装计划(不执行)`n5 登录 LMM 6 模型列表 7 退出登录 0 返回" - $actions = @{'1'='catalog';'2'='status';'3'='doctor';'4'='plan';'5'='login';'6'='models';'7'='logout'} - $choice=Ask '选择' - if ($actions.ContainsKey($choice)) { Run-Script 'lmm-use.ps1' @('-Command',$actions[$choice]) } - elseif ($choice -ne '0') { Write-Host '无效选择。' } - } else { - $launcher = Join-Path $root "bin\$tool.cmd" - if (Test-Path -LiteralPath $launcher) { - Show-Help $tool - if ($tool -eq 'dsh') { & $launcher --profile web } else { & $launcher } - Write-Host '已返回菜单。' - } else { Write-Host '尚未安装,请先选择 1。' } + Write-Host "1 目录 2 状态 3 诊断 4 安装计划`n5 登录 6 模型 7 退出登录 0 返回" + $actions=@{'1'='catalog';'2'='status';'3'='doctor';'4'='plan';'5'='login';'6'='models';'7'='logout'} + $action=Ask '选择' + if ($actions.ContainsKey($action)) { Run-Script 'lmm-use.ps1' @('-Command',$actions[$action]) } + } elseif ($item.Kind -ne 'managed') { Run-Script "$tool.ps1" @('-Network',$network,'-Launch') } + else { + $launcher=Join-Path $root "bin\$tool.cmd" + if (Test-Path -LiteralPath $launcher) { if ($tool -eq 'dsh') { & $launcher --profile web } else { & $launcher } } + else { Write-Host '请先安装。' } } } - default { Write-Host '请输入菜单中的数字。' } + default { Write-Host '无效选择。' } } } } -} catch { Write-Host $_.Exception.Message -ForegroundColor Red; exit 1 } +} catch { Write-Host $_.Exception.Message; exit 1 } finally { - [Net.ServicePointManager]::SecurityProtocol = $oldProtocol + [Net.ServicePointManager]::SecurityProtocol=$oldProtocol if (Test-Path -LiteralPath $work) { Remove-Item -LiteralPath $work -Recurse -Force } } diff --git a/templates/menu.sh.in b/templates/menu.sh.in index c3d7150..57e8524 100644 --- a/templates/menu.sh.in +++ b/templates/menu.sh.in @@ -1,90 +1,96 @@ #!/usr/bin/env bash -# Complete function before execution: safe when downloaded through a pipe. lmm_menu_main() ( set -u @@LIBRARIES@@ +@@CATALOG@@ +root=$(lmm_root); network=auto +show_tools() { + local i + for i in "${!tools[@]}"; do + if lmm_is_termux && [ "${kinds[$i]}" = desktop ]; then continue; fi + printf '%s %s\n' "$((i+1))" "${labels[$i]}" + done +} case "${1:-}" in - --help|-h) printf 'LMM menu: bash menu.sh [--help]\nInteractive terminal required. Choose Pi, DSH or LMM CLI, then an action.\n'; exit 0;; + --help|-h) printf 'LMM menu: bash menu.sh [--list]\n'; exit 0;; + --list) show_tools; exit 0;; '') ;; *) printf 'Unknown option. Use --help.\n' >&2; exit 2;; esac -if ! { exec 3/dev/null; then - printf '需要交互终端。请在终端运行菜单;自动化请使用底层安装脚本。\n' >&2; exit 2 -fi +if ! { exec 3/dev/null; then printf '需要交互终端;自动化请直接运行工具脚本。\n' >&2; exit 2; fi command -v curl >/dev/null 2>&1 || { printf '请先安装 curl。\n' >&2; exit 2; } expected_hash() { case "$1" in @@HASHES@@ *) return 1;; esac; } ask() { printf '%s' "$1"; IFS= read -r answer <&3 || exit 0; } -umask 077 -temp_root=$(lmm_temp_root) -lmm_check_storage "$temp_root" || exit 1 -mkdir -p "$temp_root" || exit 1 -work=$(mktemp -d "$temp_root/lmm-menu.XXXXXXXX") || exit 1 +tmp=$(lmm_temp_root); lmm_check_storage "$tmp" || exit 1 +mkdir -p "$tmp" || exit 1 +work=$(mktemp -d "$tmp/lmm-menu.XXXXXXXX") || exit 1 trap 'rm -rf -- "$work"' EXIT -trap 'exit 130' INT -trap 'exit 143' TERM -network=auto -root=$(lmm_root) +trap 'exit 130' INT; trap 'exit 143' TERM fetch_script() { local name=$1 expected url expected=$(expected_hash "$name") || return 1 if [ -f "$work/$name" ] && [ "$(sha256 "$work/$name")" = "$expected" ]; then return 0; fi - printf '正在获取并校验安装程序(下载慢时会重试)…\n' for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/@@REVISION@@/$name"; do - if curl -q -fSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 2 --retry-delay 2 "$url" -o "$work/download"; then + if curl -q -fsSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --retry 2 "$url" -o "$work/download"; then if [ "$(sha256 "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi - printf '文件版本或校验不匹配,尝试固定版本备用地址。\n' >&2 fi done - printf '下载失败,未执行任何未校验的文件。请检查网络后重试。\n' >&2; return 1 + printf '下载失败或版本不匹配:%s\n' "$name" >&2; return 1 } run_script() { local name=$1 code; shift fetch_script "$name" || return 1 bash "$work/$name" "$@" <&3; code=$? - if [ "$code" -eq 0 ]; then printf '\n操作完成。\n' - else printf '\n操作退出,状态码 %s;请查看上方提示。可切换网络后重试。\n' "$code"; fi + [ "$code" -eq 0 ] || printf '\n退出码 %s,请查看上方错误。\n' "$code" return "$code" } help_tool() { case "$tool" in - pi) printf '\nPi:安装后选择启动,输入 /login 并选择 LMM 完成浏览器授权,再用 /model 选模型。\n';; - dsh) printf '\nDSH:启动后打开终端提示的网址,在 Settings → Models 的 LMM 卡片选择 Sign in with LMM。\n';; - lmm) printf '\nLMM CLI 是开发预览版。支持目录、状态、诊断、登录和模型列表;setup 目前只提供计划,不会安装应用。\nLinux 登录需要 Secret Service;SSH 登录需浏览器能访问当前主机回调地址。\n';; + pi) printf 'pi → /login → LMM → /model。\n';; + dsh) printf 'dsh web → Settings → Models → LMM。\n';; + lmm) printf 'LMM CLI 是预览版;setup 仅生成计划。Linux 登录需要 Secret Service。\n';; + codex) printf '运行 codex,按官方提示登录。Termux 使用已有 PRoot guest。\n';; + claude-code) printf '运行 claude,按官方提示登录。Termux 使用已有 PRoot guest。\n';; + *) printf '桌面应用按系统安装;不会自动配置账号、订阅或启用代理。\n';; esac - printf '安装位置:%s\n默认不修改 PATH;关闭后可重新运行菜单启动。\n' "$root" + if [ "$kind" = managed ]; then printf '受管目录:%s\n' "$root" + else printf '使用官方安装位置;预览可查看安装方式。\n'; fi } while :; do - printf '\n━━━━━━━━ LMM 工具菜单 ━━━━━━━━\n1 Pi Coding Agent\n2 DSH + LMM 插件\n3 LMM CLI(开发预览)\n4 下载网络(当前:%s)\n0 退出\n' "$network" - ask '输入数字:' + printf '\nLMM 工具\n'; show_tools + printf 'n 下载网络(%s)\n0 退出\n' "$network" + ask '选择:' case "$answer" in 0) exit 0;; - 4) printf '\n1 自动选择 2 官方源 3 国内镜像\n'; ask '选择:'; case "$answer" in 1) network=auto;; 2) network=official;; 3) network=china;; *) printf '无效选择。\n';; esac; continue;; - 1) tool=pi;; 2) tool=dsh;; 3) tool=lmm;; *) printf '请输入菜单中的数字。\n'; continue;; + n|N) printf '1 自动 2 官方 3 国内镜像\n'; ask '选择:'; case "$answer" in 1) network=auto;; 2) network=official;; 3) network=china;; esac; continue;; esac + if ! [[ $answer =~ ^[1-9][0-9]*$ ]] || [ "${#answer}" -gt 2 ] || [ "$answer" -gt "${#tools[@]}" ]; then printf '无效选择。\n'; continue; fi + index=$((answer-1)); tool=${tools[$index]}; kind=${kinds[$index]} + if lmm_is_termux && [ "$kind" = desktop ]; then printf '此工具不支持 Termux。\n'; continue; fi while :; do - printf '\n── %s ──\n1 安装 / 修复\n2 更新到菜单维护的版本\n3 检查安装环境\n4 启动 / 使用\n5 登录与使用说明\n0 返回\n' "$tool" - ask '输入数字:' + printf '\n%s\n1 安装 2 更新 3 检查 4 启动 5 使用说明\n' "${labels[$index]}" + [ "$kind" = managed ] || printf '6 预览安装方案\n' + printf '0 返回\n'; ask '选择:' case "$answer" in 0) break;; 1) run_script "$tool.sh" --network "$network" || :;; 2) run_script "$tool.sh" --network "$network" --update || :;; 3) run_script "$tool.sh" --check || :;; 5) help_tool;; + 6) if [ "$kind" != managed ]; then run_script "$tool.sh" --dry-run || :; fi;; 4) if [ "$tool" = lmm ]; then - printf '\n1 应用目录 2 状态 3 诊断 4 安装计划(不执行)\n5 登录 LMM 6 模型列表 7 退出登录 0 返回\n' - ask '选择:' - case "$answer" in 1) action=catalog;; 2) action=status;; 3) action=doctor;; 4) action=plan;; 5) action=login;; 6) action=models;; 7) action=logout;; 0) continue;; *) printf '无效选择。\n'; continue;; esac + printf '1 目录 2 状态 3 诊断 4 安装计划\n5 登录 6 模型 7 退出登录 0 返回\n'; ask '选择:' + case "$answer" in 1) action=catalog;; 2) action=status;; 3) action=doctor;; 4) action=plan;; 5) action=login;; 6) action=models;; 7) action=logout;; *) continue;; esac run_script lmm-use.sh "$action" || : + elif [ "$kind" != managed ]; then run_script "$tool.sh" --network "$network" --launch || : elif [ -x "$root/bin/$tool" ]; then - help_tool if [ "$tool" = dsh ]; then "$root/bin/dsh" --profile web <&3; else "$root/bin/pi" <&3; fi - printf '\n已返回菜单。\n' - else printf '尚未安装,请先选择 1。\n'; fi;; - *) printf '请输入菜单中的数字。\n';; + else printf '请先安装。\n'; fi;; + *) printf '无效选择。\n';; esac done done diff --git a/tests/test-external.ps1 b/tests/test-external.ps1 new file mode 100644 index 0000000..f9df83d --- /dev/null +++ b/tests/test-external.ps1 @@ -0,0 +1,31 @@ +$ErrorActionPreference='Stop' +$project=Split-Path $PSScriptRoot +. (Join-Path $project 'templates/lib/external.ps1') +$cases=@( + @('cc-switch','x64','CC-Switch-v3.20.3-Windows-Portable.zip'), + @('cc-switch','arm64','CC-Switch-v3.20.3-Windows-arm64-Portable.zip'), + @('clash-verge-rev','x64','Clash.Verge_2.5.2_x64-setup.exe'), + @('clash-verge-rev','arm64','Clash.Verge_2.5.2_arm64-setup.exe') +) +foreach ($case in $cases) { + $pattern=Get-ExternalAssetPattern $case[0] $case[1] + if ($case[2] -notmatch $pattern -or ($case[2]+'.sig') -match $pattern) { throw "Wrong asset pattern: $pattern" } + $other=if($case[1] -eq 'x64'){'arm64'}else{'x64'} + if ($case[2] -match (Get-ExternalAssetPattern $case[0] $other)) { throw 'Selected another architecture' } +} +$engine=(Get-Process -Id $PID).Path +foreach ($tool in @('codex','claude-code','cc-switch','clash-verge-rev')) { + & $engine -NoProfile -File (Join-Path $project "$tool.ps1") -Help + if ($LASTEXITCODE -ne 0) { throw "Help failed: $tool" } + if ((Get-Item (Join-Path $project "$tool.ps1")).Length -gt 4000) { throw 'Entry should remain small' } +} +if ($env:OS -eq 'Windows_NT') { + $path=Join-Path ([IO.Path]::GetTempPath()) ('lmm-plan-'+[Guid]::NewGuid()) + function Receive-ExternalFile { throw 'Dry run attempted a download' } + foreach ($tool in @('codex','claude-code','cc-switch','clash-verge-rev')) { + $plan=Invoke-ExternalSetup -Target $tool -Root $path -DryRun + if (!$plan) { throw 'Missing installation plan' } + if (Test-Path $path) { throw 'Dry run wrote files' } + } +} +Write-Host 'External help, package architecture and no-install plans passed.' diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..5190bff --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,39 @@ +"""Catalog entries, compact published launchers and Termux menu filtering.""" +import os +from pathlib import Path +import subprocess +import sys +import unittest +P=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(P/'tools')) +from catalog import TOOLS, EXTERNAL + +class CatalogTests(unittest.TestCase): + def test_all_entries_and_menu_payloads_exist(self): + self.assertEqual(len(TOOLS),7) + for ext in ('sh','ps1'): + menu=(P/f'menu.{ext}').read_text(encoding='utf-8-sig') + for name,label,_ in TOOLS: + self.assertTrue((P/f'{name}.{ext}').exists()) + self.assertIn(label,menu); self.assertIn(f'{name}.{ext}',menu) + for name in EXTERNAL: + self.assertLess((P/f'{name}.{ext}').stat().st_size,4000) + + @unittest.skipIf(sys.platform=='win32','Shell execution is tested on Unix') + def test_termux_menu_hides_only_desktop_tools(self): + normal=dict(os.environ,TERMUX_VERSION='',TERMUX_APP__PACKAGE_NAME='',PREFIX='') + desktop=subprocess.check_output(['bash',str(P/'menu.sh'),'--list'],env=normal,text=True) + mobile=subprocess.check_output(['bash',str(P/'menu.sh'),'--list'],env=normal|{'TERMUX_VERSION':'test'},text=True) + for _,label,kind in TOOLS: + self.assertIn(label,desktop) + if kind=='desktop': self.assertNotIn(label,mobile) + else: self.assertIn(label,mobile) + + @unittest.skipIf(sys.platform=='win32','Shell execution is tested on Unix') + def test_new_help_is_offline(self): + for name in EXTERNAL: + result=subprocess.run(['bash',str(P/f'{name}.sh'),'--help'],env=dict(os.environ,LMM_LIB_DIR='/missing/local/libraries'),capture_output=True,text=True,timeout=10) + self.assertEqual(result.returncode,0,result.stderr) + self.assertIn('--dry-run',result.stdout) + +if __name__=='__main__': unittest.main(verbosity=2) diff --git a/tests/test_external.py b/tests/test_external.py new file mode 100644 index 0000000..c9ebde5 --- /dev/null +++ b/tests/test_external.py @@ -0,0 +1,128 @@ +"""Distribution routing, official delegation and asset contracts; no real downloads.""" +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +P=Path(__file__).resolve().parents[1] +FAKE=r'''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +name=Path(sys.argv[0]).name; args=sys.argv[1:] +with open(os.environ['TEST_LOG'],'a') as f: f.write(json.dumps([name,args])+'\n') +if name=='uname': print(os.environ.get('TEST_OS','Linux') if '-s' in args else os.environ.get('TEST_ARCH','x86_64')) +elif name=='ldd': print(os.environ.get('TEST_LIBC','glibc')) +elif name=='curl': + out=Path(args[args.index('-o')+1]) + if os.environ.get('TEST_FAIL'): + out.write_text('touch "'+os.environ['TEST_MARKER']+'"\n'); sys.exit(18) + tool='codex' if any('chatgpt.com/codex/' in a for a in args) else 'claude' + out.write_text('#!/bin/bash\nset -eu\nprintf "%s\\n" "$@" > "$TEST_ARGS"\nmkdir -p "$HOME/.local/bin"\nprintf "#!/bin/sh\\necho version-ok\\n" > "$HOME/.local/bin/TOOL"\nchmod +x "$HOME/.local/bin/TOOL"\n'.replace('TOOL',tool)) +elif name=='proot-distro': print('guest-ok') +elif name in ('apk','apt-get','dnf','yum','pacman','zypper','xbps-install','rg'): sys.exit(0) +else: sys.exit(2) +''' + +class ExternalTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(); self.base=Path(self.tmp.name) + self.fake=self.base/'bin'; self.fake.mkdir(); (self.base/'home').mkdir() + self.log=self.base/'calls'; self.log.write_text('') + self.release=self.base/'os-release' + for name in ('uname','ldd','curl','proot-distro','apk','apt-get','dnf','pacman','zypper','xbps-install','rg'): + p=self.fake/name; p.write_text(FAKE); p.chmod(0o755) + self.env=dict(os.environ,HOME=str(self.base/'home'),TMPDIR=str(self.base),PATH=str(self.fake)+os.pathsep+os.environ['PATH'],TERMUX_VERSION='',TERMUX_APP__PACKAGE_NAME='',PREFIX='',LMM_OS_RELEASE=str(self.release),LMM_INSTALL_ROOT=str(self.base/'tools'),TEST_LOG=str(self.log),TEST_MARKER=str(self.base/'bad'),TEST_ARGS=str(self.base/'args')) + for key in ('CODEX_INSTALL_DIR','CODEX_HOME','LMM_PROOT_DISTRO'): self.env.pop(key,None) + self.code='\n'.join((P/'templates/lib'/f).read_text(encoding='utf-8') for f in ('termux.sh','quote.sh','external.sh')) + + def tearDown(self): self.tmp.cleanup() + + def run_tool(self,target,*args,distro='ubuntu',**env): + self.release.write_text(f'ID={distro}\n') + return subprocess.run(['bash','-c',self.code+'\nTARGET=$1; shift; lmm_external_main "$@"','test',target,*args],env=self.env|env,text=True,capture_output=True,timeout=30) + + def calls(self): return [json.loads(x) for x in self.log.read_text().splitlines()] + + def test_cli_distro_matrix_without_node_or_package_installs(self): + for distro in ('ubuntu','debian','fedora','rocky','opensuse-leap','arch','alpine','void'): + for target in ('codex','claude-code'): + with self.subTest(distro=distro,target=target): + r=self.run_tool(target,'--dry-run',distro=distro) + self.assertEqual(r.returncode,0,r.stderr); self.assertIn('official:https:',r.stdout) + if distro=='alpine': self.assertIn('libc:musl',r.stdout) + self.assertFalse(any(name=='curl' for name,_ in self.calls())) + self.assertFalse((self.base/'tools').exists()) + + def test_linux_and_macos_delegate_to_official_cli_installer(self): + for target in ('codex','claude-code'): + for osname in ('Linux','Darwin'): + r=self.run_tool(target,'--update','--version','1.2.3',TEST_OS=osname) + self.assertEqual(r.returncode,0,r.stderr); self.assertIn('version-ok',r.stdout) + expected=['--release','1.2.3'] if target=='codex' else ['1.2.3'] + self.assertEqual((self.base/'args').read_text().splitlines(),expected) + self.assertFalse((self.base/'home/.claude.json').exists()) + + def test_claude_explicit_latest_is_not_replaced_by_stable(self): + r=self.run_tool('claude-code','--version','latest'); self.assertEqual(r.returncode,0,r.stderr) + self.assertEqual((self.base/'args').read_text().strip(),'latest') + + def test_partial_upstream_script_is_not_executed(self): + r=self.run_tool('codex',TEST_FAIL='1'); self.assertNotEqual(r.returncode,0) + self.assertFalse((self.base/'bad').exists()); self.assertFalse((self.base/'home/.local/bin/codex').exists()) + + def test_check_is_read_only_and_does_not_download(self): + r=self.run_tool('codex','--check'); self.assertNotEqual(r.returncode,0) + self.assertFalse(any(n=='curl' for n,_ in self.calls())); self.assertFalse((self.base/'tools').exists()) + + def test_termux_routes_cli_to_existing_proot_guest(self): + for target in ('codex','claude-code'): + r=self.run_tool(target,'--dry-run','--distro','my-linux',TERMUX_VERSION='test') + self.assertEqual(r.returncode,0,r.stderr); self.assertIn('proot:my-linux',r.stdout) + + def test_termux_launch_keeps_working_directory_and_arguments(self): + r=self.run_tool('codex','--launch','--','two words','--help',TERMUX_VERSION='test') + self.assertEqual(r.returncode,0,r.stderr) + launcher=self.base/'tools/bin/codex' + self.assertTrue(launcher.exists()); self.assertNotIn('/usr/bin/env',launcher.read_text()) + calls=[args for name,args in self.calls() if name=='proot-distro'] + self.assertIn('--work-dir',calls[-1]); self.assertEqual(calls[-1][-2:],['two words','--help']) + self.assertFalse(any(args and args[0] in ('install','reset','remove') for args in calls)) + + def test_termux_desktop_tools_fail_before_downloading(self): + for target in ('cc-switch','clash-verge-rev'): + r=self.run_tool(target,'--dry-run',TERMUX_VERSION='test') + self.assertNotEqual(r.returncode,0); self.assertIn('Termux is not supported',r.stderr) + self.assertFalse(any(n=='curl' for n,_ in self.calls())) + + def test_desktop_package_manager_matrix(self): + for distro,expected in [('ubuntu','apt:deb'),('debian','apt:deb'),('fedora','dnf-or-yum:rpm'),('opensuse','zypper:rpm'),('arch','paru-or-yay:AUR')]: + for target in ('cc-switch','clash-verge-rev'): + r=self.run_tool(target,'--dry-run',distro=distro) + self.assertEqual(r.returncode,0,r.stderr); self.assertIn(expected,r.stdout) + self.assertIn('AppImage',self.run_tool('cc-switch','--dry-run',distro='gentoo').stdout) + self.assertNotEqual(self.run_tool('clash-verge-rev','--dry-run',distro='gentoo').returncode,0) + + def test_release_architecture_and_signature_filtering(self): + cases=[('cc-switch','linux','x64','deb','CC-Switch-v3.20.3-Linux-x86_64.deb'),('cc-switch','linux','arm64','rpm','CC-Switch-v3.20.3-Linux-arm64.rpm'),('cc-switch','darwin','arm64','dmg','CC-Switch-v3.20.3-macOS.dmg'),('clash-verge-rev','linux','x64','deb','Clash.Verge_2.5.2_amd64.deb'),('clash-verge-rev','linux','arm64','rpm','Clash.Verge-2.5.2-1.aarch64.rpm'),('clash-verge-rev','darwin','x64','dmg','Clash.Verge_2.5.2_x64.dmg')] + for target,osname,arch,ext,asset in cases: + r=subprocess.run(['bash','-c',self.code+'\nlmm_desktop_pattern "$@"','test',target,osname,arch,ext],capture_output=True,text=True,check=True) + pattern=r.stdout.strip(); self.assertRegex(asset,pattern); self.assertIsNone(re.search(pattern,asset+'.sig')) + + def test_invalid_arguments_and_32bit_fail(self): + for args in [('--version',';touch-bad'),('--distro','../guest'),('--network','invalid'),('--root','/'),('--root',)]: + self.assertNotEqual(self.run_tool('codex',*args).returncode,0) + self.assertNotEqual(self.run_tool('codex','--dry-run',TEST_ARCH='armv7l').returncode,0) + self.assertFalse(any(n=='curl' for n,_ in self.calls())) + + def test_alpine_claude_update_keeps_nonrecursive_launcher(self): + for _ in range(2): + r=self.run_tool('claude-code','--update',distro='alpine'); self.assertEqual(r.returncode,0,r.stderr) + body=(self.base/'tools/bin/claude').read_text() + self.assertIn('USE_BUILTIN_RIPGREP=0',body) + self.assertIn(str(self.base/'home/.local/bin/claude'),body) + self.assertNotIn(str(self.base/'tools/bin/claude'),body) + +if __name__=='__main__': unittest.main(verbosity=2) diff --git a/tools/_catalog_patch.py b/tools/_catalog_patch.py new file mode 100644 index 0000000..c36ea48 --- /dev/null +++ b/tools/_catalog_patch.py @@ -0,0 +1,61 @@ +from pathlib import Path +import re + +root = Path(__file__).resolve().parents[1] + +def edit(path, old, new): + p=root/path; text=p.read_text(encoding='utf-8') + if text.count(old)!=1: raise ValueError(f'{path}: {old[:80]} matched {text.count(old)} times') + p.write_text(text.replace(old,new),encoding='utf-8') + +for ext,names in [('sh',['release_setup','cleanup','write_launcher','add_path']),('ps1',['Write-Launcher'])]: + p=root/f'templates/install.{ext}.in'; text=p.read_text(encoding='utf-8'); functions=[] + for name in names: + start=re.escape(name)+r'\(\) \{' if ext=='sh' else r'function '+re.escape(name)+r' \{' + text,count=re.subn(r'^'+start+r'\n.*?^}\n',lambda m: functions.append(m.group(0)) or '',text,count=1,flags=re.M|re.S) + assert count==1,(ext,name) + p.write_text(text,encoding='utf-8') + (root/f'templates/lib/lifecycle.{ext}').write_text('\n'.join(functions),encoding='utf-8') + +edit('tools/generate.py','from render import ROOT, emit, libraries, standalone, template','from render import ROOT, emit, libraries, standalone, template\nfrom catalog import EXTERNAL') +edit('tools/generate.py'," parts.append(f'lib/download.{ext}')", " parts.append(f'lib/download.{ext}')\n parts.append(f'lib/lifecycle.{ext}')") +edit('tools/generate.py'," use = template('use.sh.in')", " for target in EXTERNAL:\n for ext in ('sh', 'ps1'):\n text = template(f'external.{ext}.in').replace('@@TARGET@@', target)\n text = text.replace('@@REVISION@@', versions['library_revision'])\n text = text.replace('@@LOADER@@', template(f'load.{ext}.in'))\n emit(f'{target}.{ext}', text, args.check)\n use = template('use.sh.in')") +edit('tools/generate.py', "body = standalone(body, 'lmm_install_main')", "body = standalone(body, 'lmm_install_main').replace('lmm_install_main() {', '# State is consumed by fetched modules.\\n# shellcheck disable=SC2034\\nlmm_install_main() {', 1)") +edit('templates/lib/external.sh', 'then bash "$STAGE/install.sh" --release "$VERSION"', 'then CODEX_NON_INTERACTIVE=true bash "$STAGE/install.sh" --release "$VERSION"') +edit('templates/lib/external.sh', 'unset CODEX_HOME CODEX_INSTALL_DIR; bash /mnt/lmm-install/install.sh', 'unset CODEX_HOME CODEX_INSTALL_DIR; CODEX_NON_INTERACTIVE=true bash /mnt/lmm-install/install.sh') +edit('templates/lib/external.sh', ' local manager ext pattern metadata candidate target', ' local manager ext pattern metadata candidate target\n [ "$LIBC" != musl ] || die "No compatible musl desktop package"') +edit('templates/lib/external.ps1', ' $stage=$null\n', " $stage=$null\n $oldNonInteractive=$env:CODEX_NON_INTERACTIVE\n") +edit('templates/lib/external.ps1', " [Net.ServicePointManager]::SecurityProtocol=$oldTls -bor [Net.SecurityProtocolType]::Tls12", " [Net.ServicePointManager]::SecurityProtocol=$oldTls -bor [Net.SecurityProtocolType]::Tls12\n if ($Target -eq 'codex') { $env:CODEX_NON_INTERACTIVE='true' }") +edit('templates/lib/external.ps1', ' [Net.ServicePointManager]::SecurityProtocol=$oldTls\n', ' [Net.ServicePointManager]::SecurityProtocol=$oldTls\n $env:CODEX_NON_INTERACTIVE=$oldNonInteractive\n') +edit('README.md', '## 安装后怎么用', '''## 新增工具 + +菜单也提供 Codex、Claude Code、CC Switch 和 Clash Verge Rev。文件名分别为 `codex`、`claude-code`、`cc-switch`、`clash-verge-rev`,后缀按系统选择 `.sh` / `.ps1`。 + +```sh +bash codex.sh --dry-run # 查看安装方式,不安装 +bash codex.sh # 使用官方安装器 +bash claude-code.sh --update # 官方 stable;--version latest 可改通道 +bash claude-code.sh --install-deps # 明确允许安装当前发行版的依赖 +``` + +Windows 对应 `-DryRun`、`-Update`、`-Version`。Codex/Claude 使用上游原生安装目录、PATH 和自动更新策略,不强塞到 LMM 独立 npm 目录;无需 Node。安装不会替你登录、修改模型供应商或关闭沙箱。 + +Linux CLI 根据 libc 使用官方安装器,依赖命令覆盖 Debian/Ubuntu、Fedora/RHEL、openSUSE、Arch、Alpine、Void;不执行系统升级。Alpine 的 Claude 需要 `libgcc libstdc++ ripgrep`;NixOS 需自行使用 Nix 包环境,不声称通用二进制可直接运行。 + +Termux 的这两个 CLI 使用已有的 PRoot Linux guest,不是原生 Android 安装。先执行 `pkg install proot-distro`、`proot-distro install ubuntu:24.04`,再运行 `bash codex.sh --install-deps`;另一个 guest 用 `--distro NAME`。安装器不创建/重置 guest;默认 Ubuntu 的依赖可由 `--install-deps` 准备。启动器绑定当前工作目录,参数原样转发;PRoot 不等于完整 Linux 沙箱,真机尚未验证。 + +桌面工具在 Termux 菜单中隐藏。Linux 使用 deb/rpm、现有 AUR helper;CC Switch 另有 AppImage,Clash Verge Rev 不假设存在 AppImage。macOS 优先复用 Homebrew,否则安装官方 DMG;Windows 分别使用官方 portable ZIP、官方安装窗口。桌面安装可能要求管理员授权,Clash 安装包可能带服务;脚本不自动启用代理、TUN、订阅,也不绕过系统签名提示。 + +来源和支持边界见 [安装核查](docs/install-sources.md)。 + +## 安装后怎么用''') +edit('README.md','默认目录:Unix 为', 'Pi/DSH/LMM 的默认目录:Unix 为') +edit('docs/maintenance.md','## 开发与检查', '''## 新增入口与菜单 + +`tools/catalog.py` 是工具名称、菜单顺序和入口生成的唯一清单。四个新增工具共用 `templates/lib/external.*`;旧安装器的清理、PATH 与启动器逻辑移到 `lifecycle.*`。`--dry-run` / `-DryRun` 只显示选择,不安装;默认仍需获取公共模块,本地测试可设 `LMM_LIB_DIR`。 + +新增工具的 `--network china` 只为本脚本下载的 GitHub 文件选择备用源,不改变官方安装器内部下载源。依赖安装只在 `--install-deps` 时进行;发行版桌面包本身通过包管理器解析依赖。桌面 `--check` 仅检查入口,不偷偷启动图形程序。 + +## 开发与检查''') +edit('docs/maintenance.md','python3 tests/test_library_loader.py','python3 tests/test_library_loader.py\npython3 tests/test_external.py\npython3 tests/test_catalog.py') +print('Lifecycle extraction, native installer delegation and docs updated.') diff --git a/tools/catalog.py b/tools/catalog.py new file mode 100644 index 0000000..c07f103 --- /dev/null +++ b/tools/catalog.py @@ -0,0 +1,11 @@ +"""One tool list for installer generation and both menus.""" +TOOLS = ( + ('pi', 'Pi + LMM', 'managed'), + ('dsh', 'DSH + LMM', 'managed'), + ('lmm', 'LMM CLI (preview)', 'managed'), + ('codex', 'Codex CLI', 'external'), + ('claude-code', 'Claude Code', 'external'), + ('cc-switch', 'CC Switch', 'desktop'), + ('clash-verge-rev', 'Clash Verge Rev', 'desktop'), +) +EXTERNAL = tuple(name for name, _, kind in TOOLS if kind != 'managed') diff --git a/tools/generate_menus.py b/tools/generate_menus.py index 88d1a84..5f46553 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -1,18 +1,36 @@ #!/usr/bin/env python3 -"""Pin menu payloads to a reviewed installer revision, not a moving branch.""" -from pathlib import Path -import argparse, hashlib, subprocess -from render import emit, libraries -p=Path(__file__).resolve().parents[1] -a=argparse.ArgumentParser();a.add_argument('--check',action='store_true');args=a.parse_args() +"""Generate both menus from the installer catalog and one published revision.""" +import argparse +import hashlib +import shlex +import subprocess +from catalog import TOOLS +from render import ROOT, emit, libraries, template + revision='9c8f76329ec7846e5b899b2e9fef2320172cdbb7' -for ext in ('sh','ps1'): - lines=[] - for stem in ('pi','dsh','lmm','lmm-use'): - name=f'{stem}.{ext}' - payload=subprocess.check_output(['git','show',f'{revision}:{name}'],cwd=p) - sha=hashlib.sha256(payload).hexdigest() - lines.append(f"{name}) printf '%s' '{sha}';;" if ext=='sh' else f" '{name}' = '{sha}'") - text=(p/f'templates/menu.{ext}.in').read_text(encoding='utf-8').replace('@@REVISION@@',revision).replace('@@HASHES@@','\n'.join(lines)) - if ext=='sh': text=text.replace('@@LIBRARIES@@',libraries('lib/root.sh','lib/hash.sh','lib/termux.sh')) - emit(f'menu.{ext}',text,args.check,'utf-8-sig' if ext=='ps1' else 'utf-8') + + +def main(): + parser=argparse.ArgumentParser() + parser.add_argument('--check', action='store_true') + args=parser.parse_args() + for ext in ('sh', 'ps1'): + hashes=[] + for stem in [name for name, _, _ in TOOLS] + ['lmm-use']: + name=f'{stem}.{ext}' + payload=subprocess.check_output(['git','show',f'{revision}:{name}'],cwd=ROOT) + digest=hashlib.sha256(payload).hexdigest() + hashes.append(f"{name}) printf '%s' '{digest}';;" if ext=='sh' else f" '{name}' = '{digest}'") + if ext=='sh': + catalog='\n'.join(key+'=('+' '.join(shlex.quote(row[index]) for row in TOOLS)+')' for index,key in enumerate(('tools','labels','kinds'))) + else: + catalog='$tools=@(\n'+',\n'.join(" @{Name='%s';Label='%s';Kind='%s'}" % row for row in TOOLS)+'\n)' + text=template(f'menu.{ext}.in').replace('@@CATALOG@@',catalog) + text=text.replace('@@REVISION@@',revision).replace('@@HASHES@@','\n'.join(hashes)) + if ext=='sh': + text=text.replace('@@LIBRARIES@@',libraries('lib/root.sh','lib/hash.sh','lib/termux.sh')) + emit(f'menu.{ext}',text,args.check,'utf-8-sig' if ext=='ps1' else 'utf-8') + + +if __name__=='__main__': + main() From 60692bd80622a0d3d80ee501eacb8db139641a3e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:41:00 +0000 Subject: [PATCH 25/39] refactor: share lifecycle helpers and delegate new tools to official installers --- README.md | 23 +++++++++++++- docs/maintenance.md | 8 +++++ templates/install.ps1.in | 29 ------------------ templates/install.sh.in | 51 ------------------------------- templates/lib/external.ps1 | 3 ++ templates/lib/external.sh | 5 +-- templates/lib/lifecycle.ps1 | 29 ++++++++++++++++++ templates/lib/lifecycle.sh | 54 ++++++++++++++++++++++++++++++++ tools/_catalog_patch.py | 61 ------------------------------------- tools/generate.py | 10 +++++- 10 files changed, 128 insertions(+), 145 deletions(-) create mode 100644 templates/lib/lifecycle.ps1 create mode 100644 templates/lib/lifecycle.sh delete mode 100644 tools/_catalog_patch.py diff --git a/README.md b/README.md index f68c3c2..7388a2f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,27 @@ curl -fsSL https://api.lmm.best/scripts/menu.sh | bash Pi 的安装方式遵循官方 Termux 文档。DSH 的 Android 原生依赖、LMM CLI 的 Android 源码构建尚未经真机验证;LMM CLI 没有 Android 预编译包。环境模拟测试不等于真机验证。 +## 新增工具 + +菜单也提供 Codex、Claude Code、CC Switch 和 Clash Verge Rev。文件名分别为 `codex`、`claude-code`、`cc-switch`、`clash-verge-rev`,后缀按系统选择 `.sh` / `.ps1`。 + +```sh +bash codex.sh --dry-run # 查看安装方式,不安装 +bash codex.sh # 使用官方安装器 +bash claude-code.sh --update # 官方 stable;--version latest 可改通道 +bash claude-code.sh --install-deps # 明确允许安装当前发行版的依赖 +``` + +Windows 对应 `-DryRun`、`-Update`、`-Version`。Codex/Claude 使用上游原生安装目录、PATH 和自动更新策略,不强塞到 LMM 独立 npm 目录;无需 Node。安装不会替你登录、修改模型供应商或关闭沙箱。 + +Linux CLI 根据 libc 使用官方安装器,依赖命令覆盖 Debian/Ubuntu、Fedora/RHEL、openSUSE、Arch、Alpine、Void;不执行系统升级。Alpine 的 Claude 需要 `libgcc libstdc++ ripgrep`;NixOS 需自行使用 Nix 包环境,不声称通用二进制可直接运行。 + +Termux 的这两个 CLI 使用已有的 PRoot Linux guest,不是原生 Android 安装。先执行 `pkg install proot-distro`、`proot-distro install ubuntu:24.04`,再运行 `bash codex.sh --install-deps`;另一个 guest 用 `--distro NAME`。安装器不创建/重置 guest;默认 Ubuntu 的依赖可由 `--install-deps` 准备。启动器绑定当前工作目录,参数原样转发;PRoot 不等于完整 Linux 沙箱,真机尚未验证。 + +桌面工具在 Termux 菜单中隐藏。Linux 使用 deb/rpm、现有 AUR helper;CC Switch 另有 AppImage,Clash Verge Rev 不假设存在 AppImage。macOS 优先复用 Homebrew,否则安装官方 DMG;Windows 分别使用官方 portable ZIP、官方安装窗口。桌面安装可能要求管理员授权,Clash 安装包可能带服务;脚本不自动启用代理、TUN、订阅,也不绕过系统签名提示。 + +来源和支持边界见 [安装核查](docs/install-sources.md)。 + ## 安装后怎么用 没有加入 PATH 时,用安装结束打印的完整路径代替下方的工具名。 @@ -91,7 +112,7 @@ $env:LMM_LIB_DIR = Join-Path $PWD 'templates/lib' ## 环境与故障 -默认目录:Unix 为 `${XDG_DATA_HOME:-~/.local/share}/lmm-tools`,Windows 为 `%LOCALAPPDATA%\lmm-tools`;可用 `LMM_INSTALL_ROOT` 或 `--root` / `-Root` 修改。不覆盖系统 Node 或全局 npm 包。 +Pi/DSH/LMM 的默认目录:Unix 为 `${XDG_DATA_HOME:-~/.local/share}/lmm-tools`,Windows 为 `%LOCALAPPDATA%\lmm-tools`;可用 `LMM_INSTALL_ROOT` 或 `--root` / `-Root` 修改。不覆盖系统 Node 或全局 npm 包。 Pi 使用官方的 `npm install --ignore-scripts`,接受已有的 `ignore-scripts=true`。DSH 的原生构建策略单独处理,不解除用户的构建限制。Node 要求为 22.19+ 的 22.x 或 24+。 diff --git a/docs/maintenance.md b/docs/maintenance.md index 0374d1e..571dd4f 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -18,6 +18,12 @@ Pi 插件可用原生命令 `pi remove npm:@tokennotincluded/pi-lmm-provider` 移除。DSH 插件维护以 `dsh plugin --help` 和当前 profile 的原生设置为准。删除客户端不等于退出账号;先在客户端退出,需要撤销授权时再到 LMM 账号管理中撤销。保留 `~/.pi/agent`、`DSH_HOME` 的设置与会话,除非你明确要删除这些数据。 +## 新增入口与菜单 + +`tools/catalog.py` 是工具名称、菜单顺序和入口生成的唯一清单。四个新增工具共用 `templates/lib/external.*`;旧安装器的清理、PATH 与启动器逻辑移到 `lifecycle.*`。`--dry-run` / `-DryRun` 只显示选择,不安装;默认仍需获取公共模块,本地测试可设 `LMM_LIB_DIR`。 + +新增工具的 `--network china` 只为本脚本下载的 GitHub 文件选择备用源,不改变官方安装器内部下载源。依赖安装只在 `--install-deps` 时进行;发行版桌面包本身通过包管理器解析依赖。桌面 `--check` 仅检查入口,不偷偷启动图形程序。 + ## 开发与检查 ```sh @@ -28,6 +34,8 @@ shellcheck *.sh python3 tests/test_installers.py python3 tests/test_official_policy.py python3 tests/test_library_loader.py +python3 tests/test_external.py +python3 tests/test_catalog.py pwsh -NoProfile -File tests/test-powershell.ps1 pwsh -NoProfile -File tests/test-official-policy.ps1 pwsh -NoProfile -File tests/test-library-loader.ps1 diff --git a/templates/install.ps1.in b/templates/install.ps1.in index cf9c5d4..d8b5213 100644 --- a/templates/install.ps1.in +++ b/templates/install.ps1.in @@ -32,35 +32,6 @@ No automatic login or PATH changes. Pi on Windows requires Bash. -FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. "@ } -function Write-Launcher { - $destination=Join-Path $Root "bin\$Target.cmd" - if ((Test-Path -LiteralPath $destination) -and !(Select-String -LiteralPath $destination -SimpleMatch 'Managed by LMM installers' -Quiet)) { throw "Refusing existing launcher: $destination" } - if (!$script:Client.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Launcher target must remain inside the managed root.' } - $clientRelative=$script:Client.Substring($Root.Length).TrimStart('\') - # Keep the .cmd file ASCII: %~dp0 supports Unicode/space-containing user paths - # without changing the user's console code page. Tail-call batch shims. - $lines=@('@echo off','rem Managed by LMM installers','setlocal DisableDelayedExpansion') - if ($script:NodeBin -and $script:NodeBin.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { - $nodeRelative=$script:NodeBin.Substring($Root.Length).TrimStart('\') - $lines+=@("set `"PATH=%~dp0..\$nodeRelative;%PATH%`"") - } - if ($script:PnpmBin -and $script:PnpmBin.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { - $pmRelative=$script:PnpmBin.Substring($Root.Length).TrimStart('\') - $lines+=@("set `"PATH=%~dp0..\$pmRelative;%PATH%`"") - } - $lines+=@("`"%~dp0..\$clientRelative`" %*") - $temporary=Join-Path $script:Stage 'launcher.cmd' - [IO.File]::WriteAllLines($temporary,$lines,[Text.UTF8Encoding]::new($false)) - Move-Item -LiteralPath $temporary -Destination $destination -Force - if ($AddPath -and -not $NoPath) { - $bin=Join-Path $Root 'bin'; $old=[string][Environment]::GetEnvironmentVariable('Path','User') - if (@($old -split ';' | Where-Object { $_.TrimEnd('\') -ieq $bin.TrimEnd('\') }).Count -eq 0) { - try { [Environment]::SetEnvironmentVariable('Path',($old.TrimEnd(';')+';'+$bin).TrimStart(';'),'User') } - catch { Write-Log 'Could not update user PATH. Use the full launcher path printed below.' } - } - $env:PATH="$bin;$env:PATH" - } -} function Invoke-LmmSetup { if ($Help) { Show-Usage; return } $script:Retries=Setting 'LMM_RETRIES' 3 10 diff --git a/templates/install.sh.in b/templates/install.sh.in index dc4c016..1055d8b 100644 --- a/templates/install.sh.in +++ b/templates/install.sh.in @@ -100,25 +100,6 @@ if [ "$CHECK" = 1 ]; then log 'Executable check complete; login and model access are not inferred.' exit 0 fi -release_setup() { - if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then rm -rf -- "$STAGE"; fi - STAGE='' - if [ "$LOCKED" = 1 ] && [ "$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true)" = "$$" ]; then - rm -f -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" - rmdir "$ROOT/.setup-lock" 2>/dev/null || true - fi - LOCKED=0 -} -cleanup() { - rc=$? - trap - EXIT - release_setup - if [ "$rc" -ne 0 ]; then - log "Stopped during $PHASE. Existing launchers were preserved unless installation already completed." - log 'Check disk space, HTTPS proxy/CA settings, or retry --network official / --network china. Do not disable TLS validation.' - fi - exit "$rc" -} trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM @@ -145,38 +126,6 @@ LOCKED=1 STAGE=$(mktemp -d "$ROOT/.setup.XXXXXX") # Probe only public, credential-free artifact URLs. Slow transfers are still # interrupted independently of the latency ranking below. -write_launcher() { - local launcher="$ROOT/bin/$TARGET" temp="$STAGE/launcher" - { - if [ "$OS" = android ]; then printf '#!%s\n' "$BASH" - else printf '#!/usr/bin/env bash\n'; fi - printf '# Managed by LMM installers.\n' - # The launcher must expand PATH when it runs, not while it is generated. - # shellcheck disable=SC2016 - if [ "$TARGET" != lmm ]; then printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$NODE_BIN${PNPM_BIN:+:$PNPM_BIN}")"; fi - if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" - else printf 'exec %s %s "$@"\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi - } > "$temp" - chmod +x "$temp" - if [ -e "$launcher" ] && ! grep -q '# Managed by LMM installers.' "$launcher"; then fail "Refusing to overwrite your existing launcher: $launcher"; fi - mv -f -- "$temp" "$launcher" -} -add_path() { - [ "$ADD_PATH" = 1 ] || return 0 - local file marker='# >>> LMM tools PATH >>>' line - line="export PATH=$(quote_sh "$ROOT/bin"):\"\$PATH\"" - PATH_FILES=("$HOME/.profile") - case "${SHELL:-}" in */zsh) PATH_FILES+=("$HOME/.zshrc");; */bash) PATH_FILES+=("$HOME/.bashrc"); [ "$OS" != darwin ] || PATH_FILES+=("$HOME/.bash_profile");; */fish) log 'Fish users: add the printed bin directory with fish_add_path.';; esac - for file in "${PATH_FILES[@]}"; do - if [ -f "$file" ] && grep -Fq "$marker" "$file"; then - grep -Fq "$line" "$file" || log "PATH marker already exists in $file; use the printed full command or adjust that entry manually." - continue - fi - [ ! -L "$file" ] || { log "Not editing symlinked startup file: $file"; continue; } - if [ -f "$file" ]; then cp -p -- "$file" "$file.lmm-backup-$(date +%Y%m%d%H%M%S)-$$"; fi - printf '\n%s\n%s\n# <<< LMM tools PATH <<<\n' "$marker" "$line" >> "$file" - done -} install_tool PHASE='launchers and PATH'; write_launcher; add_path log "Ready: $ROOT/bin/$TARGET" diff --git a/templates/lib/external.ps1 b/templates/lib/external.ps1 index e6cc971..0e9f247 100644 --- a/templates/lib/external.ps1 +++ b/templates/lib/external.ps1 @@ -77,9 +77,11 @@ function Invoke-ExternalSetup { return } $stage=$null + $oldNonInteractive=$env:CODEX_NON_INTERACTIVE $oldTls=[Net.ServicePointManager]::SecurityProtocol try { [Net.ServicePointManager]::SecurityProtocol=$oldTls -bor [Net.SecurityProtocolType]::Tls12 + if ($Target -eq 'codex') { $env:CODEX_NON_INTERACTIVE='true' } if (!$entry -or $Update) { $stage=Join-Path ([IO.Path]::GetTempPath()) ('lmm-'+[Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $stage | Out-Null @@ -128,6 +130,7 @@ function Invoke-ExternalSetup { if ($Launch) { & $entry @RunArgs; if ($LASTEXITCODE -ne 0) { throw "Program exited with $LASTEXITCODE" } } } finally { [Net.ServicePointManager]::SecurityProtocol=$oldTls + $env:CODEX_NON_INTERACTIVE=$oldNonInteractive if ($stage) { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue } } } diff --git a/templates/lib/external.sh b/templates/lib/external.sh index 5d3f510..5dd9902 100644 --- a/templates/lib/external.sh +++ b/templates/lib/external.sh @@ -139,7 +139,7 @@ lmm_external_main() ( fetch "$URL" "$STAGE/install.sh" local install_args=("$VERSION") [ "$TARGET" != codex ] || install_args=(--release "$VERSION") - proot-distro login "$DISTRO" --bind "$STAGE:/mnt/lmm-install" -- /bin/bash -c 'unset CODEX_HOME CODEX_INSTALL_DIR; bash /mnt/lmm-install/install.sh "$@"' -- "${install_args[@]}" + proot-distro login "$DISTRO" --bind "$STAGE:/mnt/lmm-install" -- /bin/bash -c 'unset CODEX_HOME CODEX_INSTALL_DIR; CODEX_NON_INTERACTIVE=true bash /mnt/lmm-install/install.sh "$@"' -- "${install_args[@]}" proot-distro login "$DISTRO" -- /bin/bash -c '"$HOME/.local/bin/$1" --version' -- "$COMMAND" shim "exec $(quote_sh "$(command -v proot-distro)") login $(quote_sh "$DISTRO") --bind \"\$PWD:/workspace\" --work-dir /workspace -- /bin/bash -c 'exec \"\$HOME/.local/bin/$COMMAND\" \"\$@\"' -- \"\$@\"" printf 'Termux uses a PRoot Linux guest; native Android and sandbox parity are not implied.\n' @@ -149,7 +149,7 @@ lmm_external_main() ( export USE_BUILTIN_RIPGREP=0 fi fetch "$URL" "$STAGE/install.sh" - if [ "$TARGET" = codex ]; then bash "$STAGE/install.sh" --release "$VERSION" + if [ "$TARGET" = codex ]; then CODEX_NON_INTERACTIVE=true bash "$STAGE/install.sh" --release "$VERSION" else bash "$STAGE/install.sh" "$VERSION"; fi ENTRY=''; find_entry if [ "$TARGET" = claude-code ] && [ "$LIBC" = musl ] && [ -x "$HOME/.local/bin/claude" ]; then ENTRY="$HOME/.local/bin/claude"; fi @@ -173,6 +173,7 @@ lmm_external_main() ( # Called inside lmm_external_main; reuse its paths and download functions. lmm_install_desktop() { local manager ext pattern metadata candidate target + [ "$LIBC" != musl ] || die "No compatible musl desktop package" if [ "$OS" = darwin ] && command -v brew >/dev/null && [ "$VERSION" = latest ]; then if brew list --cask "$TARGET" >/dev/null 2>&1; then brew upgrade --cask "$TARGET" else brew install --cask "$TARGET"; fi diff --git a/templates/lib/lifecycle.ps1 b/templates/lib/lifecycle.ps1 new file mode 100644 index 0000000..ab097dc --- /dev/null +++ b/templates/lib/lifecycle.ps1 @@ -0,0 +1,29 @@ +function Write-Launcher { + $destination=Join-Path $Root "bin\$Target.cmd" + if ((Test-Path -LiteralPath $destination) -and !(Select-String -LiteralPath $destination -SimpleMatch 'Managed by LMM installers' -Quiet)) { throw "Refusing existing launcher: $destination" } + if (!$script:Client.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Launcher target must remain inside the managed root.' } + $clientRelative=$script:Client.Substring($Root.Length).TrimStart('\') + # Keep the .cmd file ASCII: %~dp0 supports Unicode/space-containing user paths + # without changing the user's console code page. Tail-call batch shims. + $lines=@('@echo off','rem Managed by LMM installers','setlocal DisableDelayedExpansion') + if ($script:NodeBin -and $script:NodeBin.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { + $nodeRelative=$script:NodeBin.Substring($Root.Length).TrimStart('\') + $lines+=@("set `"PATH=%~dp0..\$nodeRelative;%PATH%`"") + } + if ($script:PnpmBin -and $script:PnpmBin.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { + $pmRelative=$script:PnpmBin.Substring($Root.Length).TrimStart('\') + $lines+=@("set `"PATH=%~dp0..\$pmRelative;%PATH%`"") + } + $lines+=@("`"%~dp0..\$clientRelative`" %*") + $temporary=Join-Path $script:Stage 'launcher.cmd' + [IO.File]::WriteAllLines($temporary,$lines,[Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporary -Destination $destination -Force + if ($AddPath -and -not $NoPath) { + $bin=Join-Path $Root 'bin'; $old=[string][Environment]::GetEnvironmentVariable('Path','User') + if (@($old -split ';' | Where-Object { $_.TrimEnd('\') -ieq $bin.TrimEnd('\') }).Count -eq 0) { + try { [Environment]::SetEnvironmentVariable('Path',($old.TrimEnd(';')+';'+$bin).TrimStart(';'),'User') } + catch { Write-Log 'Could not update user PATH. Use the full launcher path printed below.' } + } + $env:PATH="$bin;$env:PATH" + } +} diff --git a/templates/lib/lifecycle.sh b/templates/lib/lifecycle.sh new file mode 100644 index 0000000..25e0c4b --- /dev/null +++ b/templates/lib/lifecycle.sh @@ -0,0 +1,54 @@ +release_setup() { + if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then rm -rf -- "$STAGE"; fi + STAGE='' + if [ "$LOCKED" = 1 ] && [ "$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true)" = "$$" ]; then + rm -f -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" + rmdir "$ROOT/.setup-lock" 2>/dev/null || true + fi + LOCKED=0 +} + +cleanup() { + rc=$? + trap - EXIT + release_setup + if [ "$rc" -ne 0 ]; then + log "Stopped during $PHASE. Existing launchers were preserved unless installation already completed." + log 'Check disk space, HTTPS proxy/CA settings, or retry --network official / --network china. Do not disable TLS validation.' + fi + exit "$rc" +} + +write_launcher() { + local launcher="$ROOT/bin/$TARGET" temp="$STAGE/launcher" + { + if [ "$OS" = android ]; then printf '#!%s\n' "$BASH" + else printf '#!/usr/bin/env bash\n'; fi + printf '# Managed by LMM installers.\n' + # The launcher must expand PATH when it runs, not while it is generated. + # shellcheck disable=SC2016 + if [ "$TARGET" != lmm ]; then printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$NODE_BIN${PNPM_BIN:+:$PNPM_BIN}")"; fi + if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" + else printf 'exec %s %s "$@"\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi + } > "$temp" + chmod +x "$temp" + if [ -e "$launcher" ] && ! grep -q '# Managed by LMM installers.' "$launcher"; then fail "Refusing to overwrite your existing launcher: $launcher"; fi + mv -f -- "$temp" "$launcher" +} + +add_path() { + [ "$ADD_PATH" = 1 ] || return 0 + local file marker='# >>> LMM tools PATH >>>' line + line="export PATH=$(quote_sh "$ROOT/bin"):\"\$PATH\"" + PATH_FILES=("$HOME/.profile") + case "${SHELL:-}" in */zsh) PATH_FILES+=("$HOME/.zshrc");; */bash) PATH_FILES+=("$HOME/.bashrc"); [ "$OS" != darwin ] || PATH_FILES+=("$HOME/.bash_profile");; */fish) log 'Fish users: add the printed bin directory with fish_add_path.';; esac + for file in "${PATH_FILES[@]}"; do + if [ -f "$file" ] && grep -Fq "$marker" "$file"; then + grep -Fq "$line" "$file" || log "PATH marker already exists in $file; use the printed full command or adjust that entry manually." + continue + fi + [ ! -L "$file" ] || { log "Not editing symlinked startup file: $file"; continue; } + if [ -f "$file" ]; then cp -p -- "$file" "$file.lmm-backup-$(date +%Y%m%d%H%M%S)-$$"; fi + printf '\n%s\n%s\n# <<< LMM tools PATH <<<\n' "$marker" "$line" >> "$file" + done +} diff --git a/tools/_catalog_patch.py b/tools/_catalog_patch.py deleted file mode 100644 index c36ea48..0000000 --- a/tools/_catalog_patch.py +++ /dev/null @@ -1,61 +0,0 @@ -from pathlib import Path -import re - -root = Path(__file__).resolve().parents[1] - -def edit(path, old, new): - p=root/path; text=p.read_text(encoding='utf-8') - if text.count(old)!=1: raise ValueError(f'{path}: {old[:80]} matched {text.count(old)} times') - p.write_text(text.replace(old,new),encoding='utf-8') - -for ext,names in [('sh',['release_setup','cleanup','write_launcher','add_path']),('ps1',['Write-Launcher'])]: - p=root/f'templates/install.{ext}.in'; text=p.read_text(encoding='utf-8'); functions=[] - for name in names: - start=re.escape(name)+r'\(\) \{' if ext=='sh' else r'function '+re.escape(name)+r' \{' - text,count=re.subn(r'^'+start+r'\n.*?^}\n',lambda m: functions.append(m.group(0)) or '',text,count=1,flags=re.M|re.S) - assert count==1,(ext,name) - p.write_text(text,encoding='utf-8') - (root/f'templates/lib/lifecycle.{ext}').write_text('\n'.join(functions),encoding='utf-8') - -edit('tools/generate.py','from render import ROOT, emit, libraries, standalone, template','from render import ROOT, emit, libraries, standalone, template\nfrom catalog import EXTERNAL') -edit('tools/generate.py'," parts.append(f'lib/download.{ext}')", " parts.append(f'lib/download.{ext}')\n parts.append(f'lib/lifecycle.{ext}')") -edit('tools/generate.py'," use = template('use.sh.in')", " for target in EXTERNAL:\n for ext in ('sh', 'ps1'):\n text = template(f'external.{ext}.in').replace('@@TARGET@@', target)\n text = text.replace('@@REVISION@@', versions['library_revision'])\n text = text.replace('@@LOADER@@', template(f'load.{ext}.in'))\n emit(f'{target}.{ext}', text, args.check)\n use = template('use.sh.in')") -edit('tools/generate.py', "body = standalone(body, 'lmm_install_main')", "body = standalone(body, 'lmm_install_main').replace('lmm_install_main() {', '# State is consumed by fetched modules.\\n# shellcheck disable=SC2034\\nlmm_install_main() {', 1)") -edit('templates/lib/external.sh', 'then bash "$STAGE/install.sh" --release "$VERSION"', 'then CODEX_NON_INTERACTIVE=true bash "$STAGE/install.sh" --release "$VERSION"') -edit('templates/lib/external.sh', 'unset CODEX_HOME CODEX_INSTALL_DIR; bash /mnt/lmm-install/install.sh', 'unset CODEX_HOME CODEX_INSTALL_DIR; CODEX_NON_INTERACTIVE=true bash /mnt/lmm-install/install.sh') -edit('templates/lib/external.sh', ' local manager ext pattern metadata candidate target', ' local manager ext pattern metadata candidate target\n [ "$LIBC" != musl ] || die "No compatible musl desktop package"') -edit('templates/lib/external.ps1', ' $stage=$null\n', " $stage=$null\n $oldNonInteractive=$env:CODEX_NON_INTERACTIVE\n") -edit('templates/lib/external.ps1', " [Net.ServicePointManager]::SecurityProtocol=$oldTls -bor [Net.SecurityProtocolType]::Tls12", " [Net.ServicePointManager]::SecurityProtocol=$oldTls -bor [Net.SecurityProtocolType]::Tls12\n if ($Target -eq 'codex') { $env:CODEX_NON_INTERACTIVE='true' }") -edit('templates/lib/external.ps1', ' [Net.ServicePointManager]::SecurityProtocol=$oldTls\n', ' [Net.ServicePointManager]::SecurityProtocol=$oldTls\n $env:CODEX_NON_INTERACTIVE=$oldNonInteractive\n') -edit('README.md', '## 安装后怎么用', '''## 新增工具 - -菜单也提供 Codex、Claude Code、CC Switch 和 Clash Verge Rev。文件名分别为 `codex`、`claude-code`、`cc-switch`、`clash-verge-rev`,后缀按系统选择 `.sh` / `.ps1`。 - -```sh -bash codex.sh --dry-run # 查看安装方式,不安装 -bash codex.sh # 使用官方安装器 -bash claude-code.sh --update # 官方 stable;--version latest 可改通道 -bash claude-code.sh --install-deps # 明确允许安装当前发行版的依赖 -``` - -Windows 对应 `-DryRun`、`-Update`、`-Version`。Codex/Claude 使用上游原生安装目录、PATH 和自动更新策略,不强塞到 LMM 独立 npm 目录;无需 Node。安装不会替你登录、修改模型供应商或关闭沙箱。 - -Linux CLI 根据 libc 使用官方安装器,依赖命令覆盖 Debian/Ubuntu、Fedora/RHEL、openSUSE、Arch、Alpine、Void;不执行系统升级。Alpine 的 Claude 需要 `libgcc libstdc++ ripgrep`;NixOS 需自行使用 Nix 包环境,不声称通用二进制可直接运行。 - -Termux 的这两个 CLI 使用已有的 PRoot Linux guest,不是原生 Android 安装。先执行 `pkg install proot-distro`、`proot-distro install ubuntu:24.04`,再运行 `bash codex.sh --install-deps`;另一个 guest 用 `--distro NAME`。安装器不创建/重置 guest;默认 Ubuntu 的依赖可由 `--install-deps` 准备。启动器绑定当前工作目录,参数原样转发;PRoot 不等于完整 Linux 沙箱,真机尚未验证。 - -桌面工具在 Termux 菜单中隐藏。Linux 使用 deb/rpm、现有 AUR helper;CC Switch 另有 AppImage,Clash Verge Rev 不假设存在 AppImage。macOS 优先复用 Homebrew,否则安装官方 DMG;Windows 分别使用官方 portable ZIP、官方安装窗口。桌面安装可能要求管理员授权,Clash 安装包可能带服务;脚本不自动启用代理、TUN、订阅,也不绕过系统签名提示。 - -来源和支持边界见 [安装核查](docs/install-sources.md)。 - -## 安装后怎么用''') -edit('README.md','默认目录:Unix 为', 'Pi/DSH/LMM 的默认目录:Unix 为') -edit('docs/maintenance.md','## 开发与检查', '''## 新增入口与菜单 - -`tools/catalog.py` 是工具名称、菜单顺序和入口生成的唯一清单。四个新增工具共用 `templates/lib/external.*`;旧安装器的清理、PATH 与启动器逻辑移到 `lifecycle.*`。`--dry-run` / `-DryRun` 只显示选择,不安装;默认仍需获取公共模块,本地测试可设 `LMM_LIB_DIR`。 - -新增工具的 `--network china` 只为本脚本下载的 GitHub 文件选择备用源,不改变官方安装器内部下载源。依赖安装只在 `--install-deps` 时进行;发行版桌面包本身通过包管理器解析依赖。桌面 `--check` 仅检查入口,不偷偷启动图形程序。 - -## 开发与检查''') -edit('docs/maintenance.md','python3 tests/test_library_loader.py','python3 tests/test_library_loader.py\npython3 tests/test_external.py\npython3 tests/test_catalog.py') -print('Lifecycle extraction, native installer delegation and docs updated.') diff --git a/tools/generate.py b/tools/generate.py index 324191c..295685b 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -5,6 +5,7 @@ import re import shlex from render import ROOT, emit, libraries, standalone, template +from catalog import EXTERNAL # JSON field -> shell / PowerShell variable. Keep a single naming map. NAMES = { @@ -62,6 +63,7 @@ def main() -> None: for ext in ('sh', 'ps1'): parts = ['lib/hash.sh', 'lib/termux.sh', 'lib/quote.sh'] if ext == 'sh' else ['lib/common.ps1'] parts.append(f'lib/download.{ext}') + parts.append(f'lib/lifecycle.{ext}') if target != 'lmm': parts.append(f'lib/node.{ext}') parts.append(f'tools/{target}.{ext}') @@ -81,10 +83,16 @@ def main() -> None: body = body.replace('@@NO_INSTALL_NODE@@', 'INSTALL_NODE=0' if client else ':') body = body.replace('@@CONSTANTS@@', constants(versions, target, ext, body + '\n' + shared)) if ext == 'sh': - body = standalone(body, 'lmm_install_main') + body = standalone(body, 'lmm_install_main').replace('lmm_install_main() {', '# State is consumed by fetched modules.\n# shellcheck disable=SC2034\nlmm_install_main() {', 1) else: body.encode('ascii') emit(f'{target}.{ext}', body, args.check) + for target in EXTERNAL: + for ext in ('sh', 'ps1'): + text = template(f'external.{ext}.in').replace('@@TARGET@@', target) + text = text.replace('@@REVISION@@', versions['library_revision']) + text = text.replace('@@LOADER@@', template(f'load.{ext}.in')) + emit(f'{target}.{ext}', text, args.check) use = template('use.sh.in').replace('@@LIBRARIES@@', libraries('lib/root.sh')) emit('lmm-use.sh', standalone(use, 'lmm_use_main'), args.check) From a5e8b423bfa3ac8176bc6735da00339ffff811e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:41:00 +0000 Subject: [PATCH 26/39] feat: publish compact Codex Claude Code CC Switch and Clash Verge Rev installers --- cc-switch.ps1 | 51 +++++++++++++++++++++++++++++++++++++++ cc-switch.sh | 52 +++++++++++++++++++++++++++++++++++++++ clash-verge-rev.ps1 | 51 +++++++++++++++++++++++++++++++++++++++ clash-verge-rev.sh | 52 +++++++++++++++++++++++++++++++++++++++ claude-code.ps1 | 51 +++++++++++++++++++++++++++++++++++++++ claude-code.sh | 52 +++++++++++++++++++++++++++++++++++++++ codex.ps1 | 51 +++++++++++++++++++++++++++++++++++++++ codex.sh | 52 +++++++++++++++++++++++++++++++++++++++ dsh.ps1 | 35 +++------------------------ dsh.sh | 59 ++++----------------------------------------- lmm.ps1 | 35 +++------------------------ lmm.sh | 59 ++++----------------------------------------- pi.ps1 | 35 +++------------------------ pi.sh | 59 ++++----------------------------------------- versions.json | 4 +-- 15 files changed, 438 insertions(+), 260 deletions(-) create mode 100644 cc-switch.ps1 create mode 100755 cc-switch.sh create mode 100644 clash-verge-rev.ps1 create mode 100755 clash-verge-rev.sh create mode 100644 claude-code.ps1 create mode 100755 claude-code.sh create mode 100644 codex.ps1 create mode 100755 codex.sh diff --git a/cc-switch.ps1 b/cc-switch.ps1 new file mode 100644 index 0000000..ee35c58 --- /dev/null +++ b/cc-switch.ps1 @@ -0,0 +1,51 @@ +[CmdletBinding(PositionalBinding=$false)] +param([string]$Root='', [string]$Version='', + [ValidateSet('auto','official','china')][string]$Network='official', + [switch]$Check,[switch]$Update,[switch]$Launch,[switch]$DryRun,[switch]$Help, + [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) +$ErrorActionPreference='Stop' +$Target='cc-switch' +$LibRevision='60692bd80622a0d3d80ee501eacb8db139641a3e' +if ($Help) { + Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." + exit 0 +} +function Get-LmmLibrary([string]$Name) { + if ($env:LMM_LIB_DIR) { + $text=[IO.File]::ReadAllText((Join-Path $env:LMM_LIB_DIR $Name),[Text.Encoding]::UTF8) + } else { + $uri="https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LibRevision/templates/lib/$Name" + $text=$null + $protocol=[Net.ServicePointManager]::SecurityProtocol + try { + [Net.ServicePointManager]::SecurityProtocol=$protocol -bor [Net.SecurityProtocolType]::Tls12 + $options=@{Uri=$uri;UseBasicParsing=$true;TimeoutSec=60;ErrorAction='Stop'} + $proxyValue=if($env:HTTPS_PROXY){$env:HTTPS_PROXY}else{$env:HTTP_PROXY} + if ($proxyValue) { + $proxy=[Uri]$proxyValue + $options.Proxy=$proxy.GetLeftPart([UriPartial]::Authority) + if ($proxy.UserInfo) { + $parts=$proxy.UserInfo.Split(':',2) + $password=if($parts.Length -eq 2){[Uri]::UnescapeDataString($parts[1])}else{''} + $secure=ConvertTo-SecureString $password -AsPlainText -Force + $options.ProxyCredential=New-Object System.Management.Automation.PSCredential([Uri]::UnescapeDataString($parts[0]),$secure) + } + } + for ($attempt=1;$attempt -le 3;$attempt++) { + try { + $response=Invoke-WebRequest @options + $text=[Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) + break + } catch { if ($attempt -eq 3) { throw "Cannot fetch library $Name at $LibRevision. Check the network or set LMM_LIB_DIR." } } + } + } finally { [Net.ServicePointManager]::SecurityProtocol=$protocol } + } + if ([string]::IsNullOrWhiteSpace($text)) { throw "Empty library: $Name" } + return [scriptblock]::Create($text) +} + +try { + . (Get-LmmLibrary 'external.ps1') + Invoke-ExternalSetup -Target $Target -Root $Root -Version $Version -Network $Network -Check:$Check -Update:$Update -Launch:$Launch -DryRun:$DryRun -RunArgs $RunArgs + exit 0 +} catch { Write-Error $_ -ErrorAction Continue; exit 1 } diff --git a/cc-switch.sh b/cc-switch.sh new file mode 100755 index 0000000..c052707 --- /dev/null +++ b/cc-switch.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +lmm_entry() { +set -euo pipefail +# shellcheck disable=SC2034 +TARGET=cc-switch +LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +for arg in "$@"; do + case "$arg" in --) break;; --help|-h) + printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' + return 0;; + esac +done +# Fetch completely before sourcing: process substitution alone hides curl errors. +lmm_source_lib() { + local lmm_name=$1 lmm_text='' lmm_attempt + if [ -n "${LMM_LIB_DIR:-}" ]; then + lmm_text=$(cat -- "$LMM_LIB_DIR/$lmm_name") || { + printf 'Cannot read local library: %s/%s\n' "$LMM_LIB_DIR" "$lmm_name" >&2; return 1; + } + else + for lmm_attempt in 1 2 3; do + if lmm_text=$(curl -q -fsSL --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 60 \ + "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LIB_REVISION/templates/lib/$lmm_name"); then + break + fi + if [ "$lmm_attempt" = 3 ]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + done + fi + if [[ $lmm_text != *[![:space:]]* ]]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + # macOS Bash 3.2 needs a here-string when sourcing buffered text. + if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + # shellcheck disable=SC1091 + source /dev/stdin <<< "$lmm_text" + else + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") + fi +} + +for library in termux.sh quote.sh external.sh; do lmm_source_lib "$library" || exit $?; done +lmm_external_main "$@" +} +if true; then + lmm_entry "$@" +fi diff --git a/clash-verge-rev.ps1 b/clash-verge-rev.ps1 new file mode 100644 index 0000000..64c6723 --- /dev/null +++ b/clash-verge-rev.ps1 @@ -0,0 +1,51 @@ +[CmdletBinding(PositionalBinding=$false)] +param([string]$Root='', [string]$Version='', + [ValidateSet('auto','official','china')][string]$Network='official', + [switch]$Check,[switch]$Update,[switch]$Launch,[switch]$DryRun,[switch]$Help, + [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) +$ErrorActionPreference='Stop' +$Target='clash-verge-rev' +$LibRevision='60692bd80622a0d3d80ee501eacb8db139641a3e' +if ($Help) { + Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." + exit 0 +} +function Get-LmmLibrary([string]$Name) { + if ($env:LMM_LIB_DIR) { + $text=[IO.File]::ReadAllText((Join-Path $env:LMM_LIB_DIR $Name),[Text.Encoding]::UTF8) + } else { + $uri="https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LibRevision/templates/lib/$Name" + $text=$null + $protocol=[Net.ServicePointManager]::SecurityProtocol + try { + [Net.ServicePointManager]::SecurityProtocol=$protocol -bor [Net.SecurityProtocolType]::Tls12 + $options=@{Uri=$uri;UseBasicParsing=$true;TimeoutSec=60;ErrorAction='Stop'} + $proxyValue=if($env:HTTPS_PROXY){$env:HTTPS_PROXY}else{$env:HTTP_PROXY} + if ($proxyValue) { + $proxy=[Uri]$proxyValue + $options.Proxy=$proxy.GetLeftPart([UriPartial]::Authority) + if ($proxy.UserInfo) { + $parts=$proxy.UserInfo.Split(':',2) + $password=if($parts.Length -eq 2){[Uri]::UnescapeDataString($parts[1])}else{''} + $secure=ConvertTo-SecureString $password -AsPlainText -Force + $options.ProxyCredential=New-Object System.Management.Automation.PSCredential([Uri]::UnescapeDataString($parts[0]),$secure) + } + } + for ($attempt=1;$attempt -le 3;$attempt++) { + try { + $response=Invoke-WebRequest @options + $text=[Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) + break + } catch { if ($attempt -eq 3) { throw "Cannot fetch library $Name at $LibRevision. Check the network or set LMM_LIB_DIR." } } + } + } finally { [Net.ServicePointManager]::SecurityProtocol=$protocol } + } + if ([string]::IsNullOrWhiteSpace($text)) { throw "Empty library: $Name" } + return [scriptblock]::Create($text) +} + +try { + . (Get-LmmLibrary 'external.ps1') + Invoke-ExternalSetup -Target $Target -Root $Root -Version $Version -Network $Network -Check:$Check -Update:$Update -Launch:$Launch -DryRun:$DryRun -RunArgs $RunArgs + exit 0 +} catch { Write-Error $_ -ErrorAction Continue; exit 1 } diff --git a/clash-verge-rev.sh b/clash-verge-rev.sh new file mode 100755 index 0000000..81d2f9f --- /dev/null +++ b/clash-verge-rev.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +lmm_entry() { +set -euo pipefail +# shellcheck disable=SC2034 +TARGET=clash-verge-rev +LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +for arg in "$@"; do + case "$arg" in --) break;; --help|-h) + printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' + return 0;; + esac +done +# Fetch completely before sourcing: process substitution alone hides curl errors. +lmm_source_lib() { + local lmm_name=$1 lmm_text='' lmm_attempt + if [ -n "${LMM_LIB_DIR:-}" ]; then + lmm_text=$(cat -- "$LMM_LIB_DIR/$lmm_name") || { + printf 'Cannot read local library: %s/%s\n' "$LMM_LIB_DIR" "$lmm_name" >&2; return 1; + } + else + for lmm_attempt in 1 2 3; do + if lmm_text=$(curl -q -fsSL --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 60 \ + "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LIB_REVISION/templates/lib/$lmm_name"); then + break + fi + if [ "$lmm_attempt" = 3 ]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + done + fi + if [[ $lmm_text != *[![:space:]]* ]]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + # macOS Bash 3.2 needs a here-string when sourcing buffered text. + if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + # shellcheck disable=SC1091 + source /dev/stdin <<< "$lmm_text" + else + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") + fi +} + +for library in termux.sh quote.sh external.sh; do lmm_source_lib "$library" || exit $?; done +lmm_external_main "$@" +} +if true; then + lmm_entry "$@" +fi diff --git a/claude-code.ps1 b/claude-code.ps1 new file mode 100644 index 0000000..a7de628 --- /dev/null +++ b/claude-code.ps1 @@ -0,0 +1,51 @@ +[CmdletBinding(PositionalBinding=$false)] +param([string]$Root='', [string]$Version='', + [ValidateSet('auto','official','china')][string]$Network='official', + [switch]$Check,[switch]$Update,[switch]$Launch,[switch]$DryRun,[switch]$Help, + [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) +$ErrorActionPreference='Stop' +$Target='claude-code' +$LibRevision='60692bd80622a0d3d80ee501eacb8db139641a3e' +if ($Help) { + Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." + exit 0 +} +function Get-LmmLibrary([string]$Name) { + if ($env:LMM_LIB_DIR) { + $text=[IO.File]::ReadAllText((Join-Path $env:LMM_LIB_DIR $Name),[Text.Encoding]::UTF8) + } else { + $uri="https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LibRevision/templates/lib/$Name" + $text=$null + $protocol=[Net.ServicePointManager]::SecurityProtocol + try { + [Net.ServicePointManager]::SecurityProtocol=$protocol -bor [Net.SecurityProtocolType]::Tls12 + $options=@{Uri=$uri;UseBasicParsing=$true;TimeoutSec=60;ErrorAction='Stop'} + $proxyValue=if($env:HTTPS_PROXY){$env:HTTPS_PROXY}else{$env:HTTP_PROXY} + if ($proxyValue) { + $proxy=[Uri]$proxyValue + $options.Proxy=$proxy.GetLeftPart([UriPartial]::Authority) + if ($proxy.UserInfo) { + $parts=$proxy.UserInfo.Split(':',2) + $password=if($parts.Length -eq 2){[Uri]::UnescapeDataString($parts[1])}else{''} + $secure=ConvertTo-SecureString $password -AsPlainText -Force + $options.ProxyCredential=New-Object System.Management.Automation.PSCredential([Uri]::UnescapeDataString($parts[0]),$secure) + } + } + for ($attempt=1;$attempt -le 3;$attempt++) { + try { + $response=Invoke-WebRequest @options + $text=[Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) + break + } catch { if ($attempt -eq 3) { throw "Cannot fetch library $Name at $LibRevision. Check the network or set LMM_LIB_DIR." } } + } + } finally { [Net.ServicePointManager]::SecurityProtocol=$protocol } + } + if ([string]::IsNullOrWhiteSpace($text)) { throw "Empty library: $Name" } + return [scriptblock]::Create($text) +} + +try { + . (Get-LmmLibrary 'external.ps1') + Invoke-ExternalSetup -Target $Target -Root $Root -Version $Version -Network $Network -Check:$Check -Update:$Update -Launch:$Launch -DryRun:$DryRun -RunArgs $RunArgs + exit 0 +} catch { Write-Error $_ -ErrorAction Continue; exit 1 } diff --git a/claude-code.sh b/claude-code.sh new file mode 100755 index 0000000..47444f2 --- /dev/null +++ b/claude-code.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +lmm_entry() { +set -euo pipefail +# shellcheck disable=SC2034 +TARGET=claude-code +LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +for arg in "$@"; do + case "$arg" in --) break;; --help|-h) + printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' + return 0;; + esac +done +# Fetch completely before sourcing: process substitution alone hides curl errors. +lmm_source_lib() { + local lmm_name=$1 lmm_text='' lmm_attempt + if [ -n "${LMM_LIB_DIR:-}" ]; then + lmm_text=$(cat -- "$LMM_LIB_DIR/$lmm_name") || { + printf 'Cannot read local library: %s/%s\n' "$LMM_LIB_DIR" "$lmm_name" >&2; return 1; + } + else + for lmm_attempt in 1 2 3; do + if lmm_text=$(curl -q -fsSL --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 60 \ + "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LIB_REVISION/templates/lib/$lmm_name"); then + break + fi + if [ "$lmm_attempt" = 3 ]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + done + fi + if [[ $lmm_text != *[![:space:]]* ]]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + # macOS Bash 3.2 needs a here-string when sourcing buffered text. + if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + # shellcheck disable=SC1091 + source /dev/stdin <<< "$lmm_text" + else + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") + fi +} + +for library in termux.sh quote.sh external.sh; do lmm_source_lib "$library" || exit $?; done +lmm_external_main "$@" +} +if true; then + lmm_entry "$@" +fi diff --git a/codex.ps1 b/codex.ps1 new file mode 100644 index 0000000..448abf3 --- /dev/null +++ b/codex.ps1 @@ -0,0 +1,51 @@ +[CmdletBinding(PositionalBinding=$false)] +param([string]$Root='', [string]$Version='', + [ValidateSet('auto','official','china')][string]$Network='official', + [switch]$Check,[switch]$Update,[switch]$Launch,[switch]$DryRun,[switch]$Help, + [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) +$ErrorActionPreference='Stop' +$Target='codex' +$LibRevision='60692bd80622a0d3d80ee501eacb8db139641a3e' +if ($Help) { + Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." + exit 0 +} +function Get-LmmLibrary([string]$Name) { + if ($env:LMM_LIB_DIR) { + $text=[IO.File]::ReadAllText((Join-Path $env:LMM_LIB_DIR $Name),[Text.Encoding]::UTF8) + } else { + $uri="https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LibRevision/templates/lib/$Name" + $text=$null + $protocol=[Net.ServicePointManager]::SecurityProtocol + try { + [Net.ServicePointManager]::SecurityProtocol=$protocol -bor [Net.SecurityProtocolType]::Tls12 + $options=@{Uri=$uri;UseBasicParsing=$true;TimeoutSec=60;ErrorAction='Stop'} + $proxyValue=if($env:HTTPS_PROXY){$env:HTTPS_PROXY}else{$env:HTTP_PROXY} + if ($proxyValue) { + $proxy=[Uri]$proxyValue + $options.Proxy=$proxy.GetLeftPart([UriPartial]::Authority) + if ($proxy.UserInfo) { + $parts=$proxy.UserInfo.Split(':',2) + $password=if($parts.Length -eq 2){[Uri]::UnescapeDataString($parts[1])}else{''} + $secure=ConvertTo-SecureString $password -AsPlainText -Force + $options.ProxyCredential=New-Object System.Management.Automation.PSCredential([Uri]::UnescapeDataString($parts[0]),$secure) + } + } + for ($attempt=1;$attempt -le 3;$attempt++) { + try { + $response=Invoke-WebRequest @options + $text=[Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) + break + } catch { if ($attempt -eq 3) { throw "Cannot fetch library $Name at $LibRevision. Check the network or set LMM_LIB_DIR." } } + } + } finally { [Net.ServicePointManager]::SecurityProtocol=$protocol } + } + if ([string]::IsNullOrWhiteSpace($text)) { throw "Empty library: $Name" } + return [scriptblock]::Create($text) +} + +try { + . (Get-LmmLibrary 'external.ps1') + Invoke-ExternalSetup -Target $Target -Root $Root -Version $Version -Network $Network -Check:$Check -Update:$Update -Launch:$Launch -DryRun:$DryRun -RunArgs $RunArgs + exit 0 +} catch { Write-Error $_ -ErrorAction Continue; exit 1 } diff --git a/codex.sh b/codex.sh new file mode 100755 index 0000000..56caf47 --- /dev/null +++ b/codex.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +lmm_entry() { +set -euo pipefail +# shellcheck disable=SC2034 +TARGET=codex +LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +for arg in "$@"; do + case "$arg" in --) break;; --help|-h) + printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' + return 0;; + esac +done +# Fetch completely before sourcing: process substitution alone hides curl errors. +lmm_source_lib() { + local lmm_name=$1 lmm_text='' lmm_attempt + if [ -n "${LMM_LIB_DIR:-}" ]; then + lmm_text=$(cat -- "$LMM_LIB_DIR/$lmm_name") || { + printf 'Cannot read local library: %s/%s\n' "$LMM_LIB_DIR" "$lmm_name" >&2; return 1; + } + else + for lmm_attempt in 1 2 3; do + if lmm_text=$(curl -q -fsSL --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 60 \ + "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/$LIB_REVISION/templates/lib/$lmm_name"); then + break + fi + if [ "$lmm_attempt" = 3 ]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + done + fi + if [[ $lmm_text != *[![:space:]]* ]]; then + printf 'Cannot load library %s at %s. Check the network or set LMM_LIB_DIR.\n' "$lmm_name" "$LIB_REVISION" >&2 + return 1 + fi + # macOS Bash 3.2 needs a here-string when sourcing buffered text. + if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + # shellcheck disable=SC1091 + source /dev/stdin <<< "$lmm_text" + else + # shellcheck disable=SC1090 + source <(printf '%s\n' "$lmm_text") + fi +} + +for library in termux.sh quote.sh external.sh; do lmm_source_lib "$library" || exit $?; done +lmm_external_main "$@" +} +if true; then + lmm_entry "$@" +fi diff --git a/dsh.ps1 b/dsh.ps1 index d08def4..e94f316 100644 --- a/dsh.ps1 +++ b/dsh.ps1 @@ -12,8 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'dsh' -$ScriptVersion = '2026.09.20.3' -$LibRevision = 'ce6aea96cd73d633424daaa6e2e25ac18fd33b5c' +$ScriptVersion = '2026.09.20.4' +$LibRevision = '60692bd80622a0d3d80ee501eacb8db139641a3e' $NodeVersion = '24.21.0' $PnpmVersion = '11.7.0' $DshVersion = '0.1.5-rc.2' @@ -133,35 +133,6 @@ No automatic login or PATH changes. Pi on Windows requires Bash. -FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. "@ } -function Write-Launcher { - $destination=Join-Path $Root "bin\$Target.cmd" - if ((Test-Path -LiteralPath $destination) -and !(Select-String -LiteralPath $destination -SimpleMatch 'Managed by LMM installers' -Quiet)) { throw "Refusing existing launcher: $destination" } - if (!$script:Client.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Launcher target must remain inside the managed root.' } - $clientRelative=$script:Client.Substring($Root.Length).TrimStart('\') - # Keep the .cmd file ASCII: %~dp0 supports Unicode/space-containing user paths - # without changing the user's console code page. Tail-call batch shims. - $lines=@('@echo off','rem Managed by LMM installers','setlocal DisableDelayedExpansion') - if ($script:NodeBin -and $script:NodeBin.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { - $nodeRelative=$script:NodeBin.Substring($Root.Length).TrimStart('\') - $lines+=@("set `"PATH=%~dp0..\$nodeRelative;%PATH%`"") - } - if ($script:PnpmBin -and $script:PnpmBin.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { - $pmRelative=$script:PnpmBin.Substring($Root.Length).TrimStart('\') - $lines+=@("set `"PATH=%~dp0..\$pmRelative;%PATH%`"") - } - $lines+=@("`"%~dp0..\$clientRelative`" %*") - $temporary=Join-Path $script:Stage 'launcher.cmd' - [IO.File]::WriteAllLines($temporary,$lines,[Text.UTF8Encoding]::new($false)) - Move-Item -LiteralPath $temporary -Destination $destination -Force - if ($AddPath -and -not $NoPath) { - $bin=Join-Path $Root 'bin'; $old=[string][Environment]::GetEnvironmentVariable('Path','User') - if (@($old -split ';' | Where-Object { $_.TrimEnd('\') -ieq $bin.TrimEnd('\') }).Count -eq 0) { - try { [Environment]::SetEnvironmentVariable('Path',($old.TrimEnd(';')+';'+$bin).TrimStart(';'),'User') } - catch { Write-Log 'Could not update user PATH. Use the full launcher path printed below.' } - } - $env:PATH="$bin;$env:PATH" - } -} function Invoke-LmmSetup { if ($Help) { Show-Usage; return } $script:Retries=Setting 'LMM_RETRIES' 3 10 @@ -225,7 +196,7 @@ foreach($name in @('PATH','npm_config_cache','npm_config_fetch_retries','npm_con try { if ($Help) { Show-Usage; exit 0 } $script:Phase='libraries' - foreach ($library in @('common.ps1','download.ps1','node.ps1')) { + foreach ($library in @('common.ps1','download.ps1','lifecycle.ps1','node.ps1')) { . (Get-LmmLibrary $library) } $script:Phase='arguments' diff --git a/dsh.sh b/dsh.sh index c57d831..5991884 100755 --- a/dsh.sh +++ b/dsh.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash +# State is consumed by fetched modules. +# shellcheck disable=SC2034 lmm_install_main() { # Generated from templates/ and versions.json. Edit the source, not this file. set -euo pipefail set +x TARGET=dsh -SCRIPT_VERSION=2026.09.20.3 -LIB_REVISION=ce6aea96cd73d633424daaa6e2e25ac18fd33b5c +SCRIPT_VERSION=2026.09.20.4 +LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e NODE_VERSION=24.21.0 PNPM_VERSION=11.7.0 DSH_VERSION=0.1.5-rc.2 @@ -155,7 +157,7 @@ case "$PROFILE" in web|headless) ;; *) fail 'profile must be web or headless';; [ "$TARGET" = lmm ] || [ "$SOURCE" = 0 ] || fail '--from-source is only for lmm' case "$ROOT" in *$'\n'*|*$'\r'*) fail 'Install path must not contain newlines';; /*) ;; *) ROOT="$PWD/$ROOT";; esac if [ "$ROOT" = / ] || [ "$ROOT" = "$HOME" ]; then fail 'Choose a dedicated installation directory'; fi -for library in hash.sh termux.sh quote.sh download.sh node.sh; do +for library in hash.sh termux.sh quote.sh download.sh lifecycle.sh node.sh; do lmm_source_lib "$library" || exit $? done case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac @@ -188,25 +190,6 @@ if [ "$CHECK" = 1 ]; then log 'Executable check complete; login and model access are not inferred.' exit 0 fi -release_setup() { - if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then rm -rf -- "$STAGE"; fi - STAGE='' - if [ "$LOCKED" = 1 ] && [ "$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true)" = "$$" ]; then - rm -f -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" - rmdir "$ROOT/.setup-lock" 2>/dev/null || true - fi - LOCKED=0 -} -cleanup() { - rc=$? - trap - EXIT - release_setup - if [ "$rc" -ne 0 ]; then - log "Stopped during $PHASE. Existing launchers were preserved unless installation already completed." - log 'Check disk space, HTTPS proxy/CA settings, or retry --network official / --network china. Do not disable TLS validation.' - fi - exit "$rc" -} trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM @@ -233,38 +216,6 @@ LOCKED=1 STAGE=$(mktemp -d "$ROOT/.setup.XXXXXX") # Probe only public, credential-free artifact URLs. Slow transfers are still # interrupted independently of the latency ranking below. -write_launcher() { - local launcher="$ROOT/bin/$TARGET" temp="$STAGE/launcher" - { - if [ "$OS" = android ]; then printf '#!%s\n' "$BASH" - else printf '#!/usr/bin/env bash\n'; fi - printf '# Managed by LMM installers.\n' - # The launcher must expand PATH when it runs, not while it is generated. - # shellcheck disable=SC2016 - if [ "$TARGET" != lmm ]; then printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$NODE_BIN${PNPM_BIN:+:$PNPM_BIN}")"; fi - if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" - else printf 'exec %s %s "$@"\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi - } > "$temp" - chmod +x "$temp" - if [ -e "$launcher" ] && ! grep -q '# Managed by LMM installers.' "$launcher"; then fail "Refusing to overwrite your existing launcher: $launcher"; fi - mv -f -- "$temp" "$launcher" -} -add_path() { - [ "$ADD_PATH" = 1 ] || return 0 - local file marker='# >>> LMM tools PATH >>>' line - line="export PATH=$(quote_sh "$ROOT/bin"):\"\$PATH\"" - PATH_FILES=("$HOME/.profile") - case "${SHELL:-}" in */zsh) PATH_FILES+=("$HOME/.zshrc");; */bash) PATH_FILES+=("$HOME/.bashrc"); [ "$OS" != darwin ] || PATH_FILES+=("$HOME/.bash_profile");; */fish) log 'Fish users: add the printed bin directory with fish_add_path.';; esac - for file in "${PATH_FILES[@]}"; do - if [ -f "$file" ] && grep -Fq "$marker" "$file"; then - grep -Fq "$line" "$file" || log "PATH marker already exists in $file; use the printed full command or adjust that entry manually." - continue - fi - [ ! -L "$file" ] || { log "Not editing symlinked startup file: $file"; continue; } - if [ -f "$file" ]; then cp -p -- "$file" "$file.lmm-backup-$(date +%Y%m%d%H%M%S)-$$"; fi - printf '\n%s\n%s\n# <<< LMM tools PATH <<<\n' "$marker" "$line" >> "$file" - done -} install_tool PHASE='launchers and PATH'; write_launcher; add_path log "Ready: $ROOT/bin/$TARGET" diff --git a/lmm.ps1 b/lmm.ps1 index 7d4899a..7580ab0 100644 --- a/lmm.ps1 +++ b/lmm.ps1 @@ -12,8 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'lmm' -$ScriptVersion = '2026.09.20.3' -$LibRevision = 'ce6aea96cd73d633424daaa6e2e25ac18fd33b5c' +$ScriptVersion = '2026.09.20.4' +$LibRevision = '60692bd80622a0d3d80ee501eacb8db139641a3e' $LmmVersion = '0.1.0' $LmmReleaseBase = 'https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0' $LmmHashes = @{ @@ -104,35 +104,6 @@ No automatic login or PATH changes. Pi on Windows requires Bash. -FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. "@ } -function Write-Launcher { - $destination=Join-Path $Root "bin\$Target.cmd" - if ((Test-Path -LiteralPath $destination) -and !(Select-String -LiteralPath $destination -SimpleMatch 'Managed by LMM installers' -Quiet)) { throw "Refusing existing launcher: $destination" } - if (!$script:Client.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Launcher target must remain inside the managed root.' } - $clientRelative=$script:Client.Substring($Root.Length).TrimStart('\') - # Keep the .cmd file ASCII: %~dp0 supports Unicode/space-containing user paths - # without changing the user's console code page. Tail-call batch shims. - $lines=@('@echo off','rem Managed by LMM installers','setlocal DisableDelayedExpansion') - if ($script:NodeBin -and $script:NodeBin.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { - $nodeRelative=$script:NodeBin.Substring($Root.Length).TrimStart('\') - $lines+=@("set `"PATH=%~dp0..\$nodeRelative;%PATH%`"") - } - if ($script:PnpmBin -and $script:PnpmBin.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { - $pmRelative=$script:PnpmBin.Substring($Root.Length).TrimStart('\') - $lines+=@("set `"PATH=%~dp0..\$pmRelative;%PATH%`"") - } - $lines+=@("`"%~dp0..\$clientRelative`" %*") - $temporary=Join-Path $script:Stage 'launcher.cmd' - [IO.File]::WriteAllLines($temporary,$lines,[Text.UTF8Encoding]::new($false)) - Move-Item -LiteralPath $temporary -Destination $destination -Force - if ($AddPath -and -not $NoPath) { - $bin=Join-Path $Root 'bin'; $old=[string][Environment]::GetEnvironmentVariable('Path','User') - if (@($old -split ';' | Where-Object { $_.TrimEnd('\') -ieq $bin.TrimEnd('\') }).Count -eq 0) { - try { [Environment]::SetEnvironmentVariable('Path',($old.TrimEnd(';')+';'+$bin).TrimStart(';'),'User') } - catch { Write-Log 'Could not update user PATH. Use the full launcher path printed below.' } - } - $env:PATH="$bin;$env:PATH" - } -} function Invoke-LmmSetup { if ($Help) { Show-Usage; return } $script:Retries=Setting 'LMM_RETRIES' 3 10 @@ -196,7 +167,7 @@ foreach($name in @('PATH','npm_config_cache','npm_config_fetch_retries','npm_con try { if ($Help) { Show-Usage; exit 0 } $script:Phase='libraries' - foreach ($library in @('common.ps1','download.ps1')) { + foreach ($library in @('common.ps1','download.ps1','lifecycle.ps1')) { . (Get-LmmLibrary $library) } $script:Phase='arguments' diff --git a/lmm.sh b/lmm.sh index ae25359..10f5a46 100755 --- a/lmm.sh +++ b/lmm.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash +# State is consumed by fetched modules. +# shellcheck disable=SC2034 lmm_install_main() { # Generated from templates/ and versions.json. Edit the source, not this file. set -euo pipefail set +x TARGET=lmm -SCRIPT_VERSION=2026.09.20.3 -LIB_REVISION=ce6aea96cd73d633424daaa6e2e25ac18fd33b5c +SCRIPT_VERSION=2026.09.20.4 +LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e LMM_VERSION=0.1.0 LMM_RELEASE_BASE=https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0 lmm_hash() { case "$1" in @@ -150,7 +152,7 @@ case "$PROFILE" in web|headless) ;; *) fail 'profile must be web or headless';; [ "$TARGET" = lmm ] || [ "$SOURCE" = 0 ] || fail '--from-source is only for lmm' case "$ROOT" in *$'\n'*|*$'\r'*) fail 'Install path must not contain newlines';; /*) ;; *) ROOT="$PWD/$ROOT";; esac if [ "$ROOT" = / ] || [ "$ROOT" = "$HOME" ]; then fail 'Choose a dedicated installation directory'; fi -for library in hash.sh termux.sh quote.sh download.sh; do +for library in hash.sh termux.sh quote.sh download.sh lifecycle.sh; do lmm_source_lib "$library" || exit $? done case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac @@ -178,25 +180,6 @@ if [ "$CHECK" = 1 ]; then log 'Executable check complete; login and model access are not inferred.' exit 0 fi -release_setup() { - if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then rm -rf -- "$STAGE"; fi - STAGE='' - if [ "$LOCKED" = 1 ] && [ "$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true)" = "$$" ]; then - rm -f -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" - rmdir "$ROOT/.setup-lock" 2>/dev/null || true - fi - LOCKED=0 -} -cleanup() { - rc=$? - trap - EXIT - release_setup - if [ "$rc" -ne 0 ]; then - log "Stopped during $PHASE. Existing launchers were preserved unless installation already completed." - log 'Check disk space, HTTPS proxy/CA settings, or retry --network official / --network china. Do not disable TLS validation.' - fi - exit "$rc" -} trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM @@ -223,38 +206,6 @@ LOCKED=1 STAGE=$(mktemp -d "$ROOT/.setup.XXXXXX") # Probe only public, credential-free artifact URLs. Slow transfers are still # interrupted independently of the latency ranking below. -write_launcher() { - local launcher="$ROOT/bin/$TARGET" temp="$STAGE/launcher" - { - if [ "$OS" = android ]; then printf '#!%s\n' "$BASH" - else printf '#!/usr/bin/env bash\n'; fi - printf '# Managed by LMM installers.\n' - # The launcher must expand PATH when it runs, not while it is generated. - # shellcheck disable=SC2016 - if [ "$TARGET" != lmm ]; then printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$NODE_BIN${PNPM_BIN:+:$PNPM_BIN}")"; fi - if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" - else printf 'exec %s %s "$@"\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi - } > "$temp" - chmod +x "$temp" - if [ -e "$launcher" ] && ! grep -q '# Managed by LMM installers.' "$launcher"; then fail "Refusing to overwrite your existing launcher: $launcher"; fi - mv -f -- "$temp" "$launcher" -} -add_path() { - [ "$ADD_PATH" = 1 ] || return 0 - local file marker='# >>> LMM tools PATH >>>' line - line="export PATH=$(quote_sh "$ROOT/bin"):\"\$PATH\"" - PATH_FILES=("$HOME/.profile") - case "${SHELL:-}" in */zsh) PATH_FILES+=("$HOME/.zshrc");; */bash) PATH_FILES+=("$HOME/.bashrc"); [ "$OS" != darwin ] || PATH_FILES+=("$HOME/.bash_profile");; */fish) log 'Fish users: add the printed bin directory with fish_add_path.';; esac - for file in "${PATH_FILES[@]}"; do - if [ -f "$file" ] && grep -Fq "$marker" "$file"; then - grep -Fq "$line" "$file" || log "PATH marker already exists in $file; use the printed full command or adjust that entry manually." - continue - fi - [ ! -L "$file" ] || { log "Not editing symlinked startup file: $file"; continue; } - if [ -f "$file" ]; then cp -p -- "$file" "$file.lmm-backup-$(date +%Y%m%d%H%M%S)-$$"; fi - printf '\n%s\n%s\n# <<< LMM tools PATH <<<\n' "$marker" "$line" >> "$file" - done -} install_tool PHASE='launchers and PATH'; write_launcher; add_path log "Ready: $ROOT/bin/$TARGET" diff --git a/pi.ps1 b/pi.ps1 index a0ba3d1..2804e4b 100644 --- a/pi.ps1 +++ b/pi.ps1 @@ -12,8 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'pi' -$ScriptVersion = '2026.09.20.3' -$LibRevision = 'ce6aea96cd73d633424daaa6e2e25ac18fd33b5c' +$ScriptVersion = '2026.09.20.4' +$LibRevision = '60692bd80622a0d3d80ee501eacb8db139641a3e' $NodeVersion = '24.21.0' $PiVersion = '0.85.1' $PiProviderVersion = '0.1.0-alpha.1' @@ -107,35 +107,6 @@ No automatic login or PATH changes. Pi on Windows requires Bash. -FromSource is for the LMM CLI and requires existing Rust 1.88+ and build tools. "@ } -function Write-Launcher { - $destination=Join-Path $Root "bin\$Target.cmd" - if ((Test-Path -LiteralPath $destination) -and !(Select-String -LiteralPath $destination -SimpleMatch 'Managed by LMM installers' -Quiet)) { throw "Refusing existing launcher: $destination" } - if (!$script:Client.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Launcher target must remain inside the managed root.' } - $clientRelative=$script:Client.Substring($Root.Length).TrimStart('\') - # Keep the .cmd file ASCII: %~dp0 supports Unicode/space-containing user paths - # without changing the user's console code page. Tail-call batch shims. - $lines=@('@echo off','rem Managed by LMM installers','setlocal DisableDelayedExpansion') - if ($script:NodeBin -and $script:NodeBin.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { - $nodeRelative=$script:NodeBin.Substring($Root.Length).TrimStart('\') - $lines+=@("set `"PATH=%~dp0..\$nodeRelative;%PATH%`"") - } - if ($script:PnpmBin -and $script:PnpmBin.StartsWith($Root + '\',[StringComparison]::OrdinalIgnoreCase)) { - $pmRelative=$script:PnpmBin.Substring($Root.Length).TrimStart('\') - $lines+=@("set `"PATH=%~dp0..\$pmRelative;%PATH%`"") - } - $lines+=@("`"%~dp0..\$clientRelative`" %*") - $temporary=Join-Path $script:Stage 'launcher.cmd' - [IO.File]::WriteAllLines($temporary,$lines,[Text.UTF8Encoding]::new($false)) - Move-Item -LiteralPath $temporary -Destination $destination -Force - if ($AddPath -and -not $NoPath) { - $bin=Join-Path $Root 'bin'; $old=[string][Environment]::GetEnvironmentVariable('Path','User') - if (@($old -split ';' | Where-Object { $_.TrimEnd('\') -ieq $bin.TrimEnd('\') }).Count -eq 0) { - try { [Environment]::SetEnvironmentVariable('Path',($old.TrimEnd(';')+';'+$bin).TrimStart(';'),'User') } - catch { Write-Log 'Could not update user PATH. Use the full launcher path printed below.' } - } - $env:PATH="$bin;$env:PATH" - } -} function Invoke-LmmSetup { if ($Help) { Show-Usage; return } $script:Retries=Setting 'LMM_RETRIES' 3 10 @@ -199,7 +170,7 @@ foreach($name in @('PATH','npm_config_cache','npm_config_fetch_retries','npm_con try { if ($Help) { Show-Usage; exit 0 } $script:Phase='libraries' - foreach ($library in @('common.ps1','download.ps1','node.ps1')) { + foreach ($library in @('common.ps1','download.ps1','lifecycle.ps1','node.ps1')) { . (Get-LmmLibrary $library) } $script:Phase='arguments' diff --git a/pi.sh b/pi.sh index 6097dc9..4230957 100755 --- a/pi.sh +++ b/pi.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash +# State is consumed by fetched modules. +# shellcheck disable=SC2034 lmm_install_main() { # Generated from templates/ and versions.json. Edit the source, not this file. set -euo pipefail set +x TARGET=pi -SCRIPT_VERSION=2026.09.20.3 -LIB_REVISION=ce6aea96cd73d633424daaa6e2e25ac18fd33b5c +SCRIPT_VERSION=2026.09.20.4 +LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e NODE_VERSION=24.21.0 PI_VERSION=0.85.1 PI_PROVIDER_VERSION=0.1.0-alpha.1 @@ -131,7 +133,7 @@ case "$PROFILE" in web|headless) ;; *) fail 'profile must be web or headless';; [ "$TARGET" = lmm ] || [ "$SOURCE" = 0 ] || fail '--from-source is only for lmm' case "$ROOT" in *$'\n'*|*$'\r'*) fail 'Install path must not contain newlines';; /*) ;; *) ROOT="$PWD/$ROOT";; esac if [ "$ROOT" = / ] || [ "$ROOT" = "$HOME" ]; then fail 'Choose a dedicated installation directory'; fi -for library in hash.sh termux.sh quote.sh download.sh node.sh; do +for library in hash.sh termux.sh quote.sh download.sh lifecycle.sh node.sh; do lmm_source_lib "$library" || exit $? done case "$(uname -s)" in Linux|Android) OS=linux;; Darwin) OS=darwin;; *) fail 'Use the .ps1 script on Windows.';; esac @@ -164,25 +166,6 @@ if [ "$CHECK" = 1 ]; then log 'Executable check complete; login and model access are not inferred.' exit 0 fi -release_setup() { - if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then rm -rf -- "$STAGE"; fi - STAGE='' - if [ "$LOCKED" = 1 ] && [ "$(cat "$ROOT/.setup-lock/pid" 2>/dev/null || true)" = "$$" ]; then - rm -f -- "$ROOT/.setup-lock/pid" "$ROOT/.setup-lock/owner" - rmdir "$ROOT/.setup-lock" 2>/dev/null || true - fi - LOCKED=0 -} -cleanup() { - rc=$? - trap - EXIT - release_setup - if [ "$rc" -ne 0 ]; then - log "Stopped during $PHASE. Existing launchers were preserved unless installation already completed." - log 'Check disk space, HTTPS proxy/CA settings, or retry --network official / --network china. Do not disable TLS validation.' - fi - exit "$rc" -} trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM @@ -209,38 +192,6 @@ LOCKED=1 STAGE=$(mktemp -d "$ROOT/.setup.XXXXXX") # Probe only public, credential-free artifact URLs. Slow transfers are still # interrupted independently of the latency ranking below. -write_launcher() { - local launcher="$ROOT/bin/$TARGET" temp="$STAGE/launcher" - { - if [ "$OS" = android ]; then printf '#!%s\n' "$BASH" - else printf '#!/usr/bin/env bash\n'; fi - printf '# Managed by LMM installers.\n' - # The launcher must expand PATH when it runs, not while it is generated. - # shellcheck disable=SC2016 - if [ "$TARGET" != lmm ]; then printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$NODE_BIN${PNPM_BIN:+:$PNPM_BIN}")"; fi - if [ "$TARGET" = lmm ]; then printf 'exec %s "$@"\n' "$(quote_sh "$CLIENT")" - else printf 'exec %s %s "$@"\n' "$(quote_sh "$NODE_BIN/node")" "$(quote_sh "$CLIENT")"; fi - } > "$temp" - chmod +x "$temp" - if [ -e "$launcher" ] && ! grep -q '# Managed by LMM installers.' "$launcher"; then fail "Refusing to overwrite your existing launcher: $launcher"; fi - mv -f -- "$temp" "$launcher" -} -add_path() { - [ "$ADD_PATH" = 1 ] || return 0 - local file marker='# >>> LMM tools PATH >>>' line - line="export PATH=$(quote_sh "$ROOT/bin"):\"\$PATH\"" - PATH_FILES=("$HOME/.profile") - case "${SHELL:-}" in */zsh) PATH_FILES+=("$HOME/.zshrc");; */bash) PATH_FILES+=("$HOME/.bashrc"); [ "$OS" != darwin ] || PATH_FILES+=("$HOME/.bash_profile");; */fish) log 'Fish users: add the printed bin directory with fish_add_path.';; esac - for file in "${PATH_FILES[@]}"; do - if [ -f "$file" ] && grep -Fq "$marker" "$file"; then - grep -Fq "$line" "$file" || log "PATH marker already exists in $file; use the printed full command or adjust that entry manually." - continue - fi - [ ! -L "$file" ] || { log "Not editing symlinked startup file: $file"; continue; } - if [ -f "$file" ]; then cp -p -- "$file" "$file.lmm-backup-$(date +%Y%m%d%H%M%S)-$$"; fi - printf '\n%s\n%s\n# <<< LMM tools PATH <<<\n' "$marker" "$line" >> "$file" - done -} install_tool PHASE='launchers and PATH'; write_launcher; add_path log "Ready: $ROOT/bin/$TARGET" diff --git a/versions.json b/versions.json index fd7ee25..81a36b3 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "script_version": "2026.09.20.3", + "script_version": "2026.09.20.4", "node_version": "24.21.0", "node_sha256": { "linux-x64": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", @@ -22,5 +22,5 @@ "win-x64": "d0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9" }, "pnpm_version": "11.7.0", - "library_revision": "ce6aea96cd73d633424daaa6e2e25ac18fd33b5c" + "library_revision": "60692bd80622a0d3d80ee501eacb8db139641a3e" } From e055b0d949c26342cc72a882b698453503452225 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:41:32 +0000 Subject: [PATCH 27/39] feat: drive both menus from one seven-tool catalog --- menu.ps1 | 150 +++++++++++++++++++++------------------- menu.sh | 94 ++++++++++++++----------- tools/generate_menus.py | 2 +- 3 files changed, 133 insertions(+), 113 deletions(-) diff --git a/menu.ps1 b/menu.ps1 index 0f04e16..b09743a 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -1,105 +1,113 @@ -# PowerShell 5.1+. Generated with UTF-8 BOM for Chinese text on Windows. +# PowerShell 5.1+. Generated with UTF-8 BOM. [CmdletBinding()] -param([switch]$Help) -$ErrorActionPreference = 'Stop' -if ($Help) { Write-Output 'LMM menu: .\menu.ps1 [-Help]. Interactive terminal required.'; exit 0 } -$hashes = @{ - 'pi.ps1' = '587871a019c480e0f216b8524a351a1fcc9a2d890ca55e496c84a0f528e6ceaf' - 'dsh.ps1' = '3c421e3c277a77b7d0fcd557a4b0dbb9c67678881100fc72c90607774e11609c' - 'lmm.ps1' = 'b8009a96a57119cc9ee95a521e967298c957d5c6b4e0a8a3482a748492a2bdef' +param([switch]$Help,[switch]$List) +$ErrorActionPreference='Stop' +$tools=@( + @{Name='pi';Label='Pi + LMM';Kind='managed'}, + @{Name='dsh';Label='DSH + LMM';Kind='managed'}, + @{Name='lmm';Label='LMM CLI (preview)';Kind='managed'}, + @{Name='codex';Label='Codex CLI';Kind='external'}, + @{Name='claude-code';Label='Claude Code';Kind='external'}, + @{Name='cc-switch';Label='CC Switch';Kind='desktop'}, + @{Name='clash-verge-rev';Label='Clash Verge Rev';Kind='desktop'} +) +function Show-Tools { for ($i=0; $i -lt $tools.Count; $i++) { Write-Output "$($i+1) $($tools[$i].Label)" } } +if ($Help) { Write-Output 'LMM menu: .\menu.ps1 [-List]'; exit 0 } +if ($List) { Show-Tools; exit 0 } +$hashes=@{ + 'pi.ps1' = 'f5c1fa6f32b948dceab748a764097d00354b1bf1780452662710d2bb3eb873e4' + 'dsh.ps1' = '3f860eaf6db01fc9e22221bcc4d1e6c8c5e03610644a972b835b7e60f8136543' + 'lmm.ps1' = 'f3fccb41d48d4f76c6fb896c6f9cee2c38adcd399a04a0c118a0bf658ebc820b' + 'codex.ps1' = '12db2701adc5c99be542d816de4d33da204b0b2831afef0e8473db1be7d44ffc' + 'claude-code.ps1' = '0a806c261592b1720f21d549d10ee2dba9c736cff74f2b6641c81c0e328abe5c' + 'cc-switch.ps1' = 'e2512a44170235011ffd3c7e3e61b5a55f9a4105178baa3d46cabcc641335da6' + 'clash-verge-rev.ps1' = '18968d7173d706ded660d587a17dc7c77b72d03652c9b853b25b8b42c21cc8a4' 'lmm-use.ps1' = 'ac137e30b6609580cb6ee4fbb60ed77ed01ec6153b733140540d5bc38d9179c1' } -$network = 'auto' -$root = $env:LMM_INSTALL_ROOT -if (!$root) { $root = Join-Path $env:LOCALAPPDATA 'lmm-tools' } -$work = Join-Path ([IO.Path]::GetTempPath()) ('lmm-menu-' + [Guid]::NewGuid().ToString('N')) -$engine = (Get-Process -Id $PID).Path -$oldProtocol = [Net.ServicePointManager]::SecurityProtocol -function Ask([string]$Prompt) { - $value = Read-Host $Prompt - if ($null -eq $value) { throw '输入已关闭,请在交互终端运行。' } - return $value.Trim() -} +$network='auto'; $root=$env:LMM_INSTALL_ROOT +if (!$root) { $root=Join-Path $env:LOCALAPPDATA 'lmm-tools' } +$work=Join-Path ([IO.Path]::GetTempPath()) ('lmm-menu-'+[Guid]::NewGuid().ToString('N')) +$engine=(Get-Process -Id $PID).Path +$oldProtocol=[Net.ServicePointManager]::SecurityProtocol +function Ask([string]$Prompt) { $value=Read-Host $Prompt; if ($null -eq $value) { throw '需要交互终端。' }; return $value.Trim() } function Fetch-Script([string]$Name) { if (!$hashes.ContainsKey($Name)) { throw 'Unknown script' } - $path = Join-Path $work $Name - if ((Test-Path -LiteralPath $path) -and ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -eq $hashes[$Name])) { return $path } - Write-Host '正在获取并校验安装程序(下载慢时会重试)…' - foreach ($url in @("https://api.lmm.best/scripts/$Name", "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/9c8f76329ec7846e5b899b2e9fef2320172cdbb7/$Name")) { - for ($attempt = 1; $attempt -le 3; $attempt++) { + $path=Join-Path $work $Name + if ((Test-Path -LiteralPath $path) -and (Get-FileHash -LiteralPath $path).Hash -eq $hashes[$Name]) { return $path } + foreach ($url in @("https://api.lmm.best/scripts/$Name","https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/a5e8b423bfa3ac8176bc6735da00339ffff811e9/$Name")) { + for ($attempt=1; $attempt -le 3; $attempt++) { try { - Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 -ErrorAction Stop - if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $hashes[$Name]) { throw '文件版本或校验不匹配' } + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 + if ((Get-FileHash -LiteralPath $path).Hash -ne $hashes[$Name]) { throw '版本不匹配' } return $path - } catch { Write-Host ("下载未完成:{0}" -f $_.Exception.Message); if ($attempt -lt 3) { Start-Sleep -Seconds 2 } } + } catch { if ($attempt -lt 3) { Start-Sleep -Seconds 1 } } } } - throw '下载失败;未执行任何未校验的文件。请检查网络后重试。' + throw "下载失败或版本不匹配:$Name" } -function Run-Script([string]$Name, [string[]]$Arguments) { +function Run-Script([string]$Name,[string[]]$Arguments) { try { - $path = Fetch-Script $Name + $path=Fetch-Script $Name & $engine -NoProfile -ExecutionPolicy Bypass -File $path @Arguments - if ($LASTEXITCODE -eq 0) { Write-Host '操作完成。' } - else { Write-Host "操作退出,状态码 $LASTEXITCODE。请查看上方提示;可以切换网络后重试。" } - } catch { Write-Host $_.Exception.Message -ForegroundColor Red } + if ($LASTEXITCODE -ne 0) { Write-Host "退出码 $LASTEXITCODE,请查看上方错误。" } + } catch { Write-Host $_.Exception.Message } } -function Show-Help([string]$Tool) { - switch ($Tool) { - pi { Write-Host 'Pi:启动后输入 /login,选择 LMM 并完成浏览器授权;再用 /model 选择模型。' } - dsh { Write-Host 'DSH:打开启动时提示的网址,在 Settings -> Models 的 LMM 卡片选择 Sign in with LMM。' } - lmm { Write-Host 'LMM CLI 是开发预览版。支持目录、状态、诊断、登录和模型列表;setup 目前只生成计划,不会安装应用。' } +function Show-Help([string]$Name) { + switch ($Name) { + pi { Write-Host 'pi → /login → LMM → /model。' } + dsh { Write-Host 'dsh web → Settings → Models → LMM。' } + lmm { Write-Host 'LMM CLI 为预览版,setup 只生成计划。' } + codex { Write-Host '运行 codex,按官方提示登录;使用上游安装位置与更新策略。' } + claude-code { Write-Host '运行 claude,按官方提示登录;使用上游安装位置与更新策略。' } + default { Write-Host '桌面应用按系统安装,不自动配置账号、订阅或启用代理。' } } - Write-Host "安装位置:$root" - Write-Host '默认不修改 PATH;以后可以重新运行菜单启动。' } try { - if ([Console]::IsInputRedirected) { throw '需要交互终端。请下载菜单后用 PowerShell -File 执行,不要重定向输入。' } - [Net.ServicePointManager]::SecurityProtocol = $oldProtocol -bor [Net.SecurityProtocolType]::Tls12 - [void](New-Item -ItemType Directory -Path $work) + if ([Console]::IsInputRedirected) { throw '需要交互终端;自动化请直接执行工具脚本。' } + [Net.ServicePointManager]::SecurityProtocol=$oldProtocol -bor [Net.SecurityProtocolType]::Tls12 + New-Item -ItemType Directory -Path $work | Out-Null :main while ($true) { - Write-Host "`n======== LMM 工具菜单 ========" - Write-Host "1 Pi Coding Agent`n2 DSH + LMM 插件`n3 LMM CLI(开发预览)`n4 下载网络(当前:$network)`n0 退出" - switch (Ask '输入数字') { - '0' { break main } - '4' { - Write-Host '1 自动选择 2 官方源 3 国内镜像' - switch (Ask '选择') { '1' { $network='auto' }; '2' { $network='official' }; '3' { $network='china' }; default { Write-Host '无效选择。' } } - continue main - } - '1' { $tool='pi' }; '2' { $tool='dsh' }; '3' { $tool='lmm' } - default { Write-Host '请输入菜单中的数字。'; continue main } + Write-Host "`nLMM 工具"; Show-Tools; Write-Host "n 下载网络($network)`n0 退出" + $choice=Ask '选择' + if ($choice -eq '0') { break } + if ($choice -eq 'n') { + Write-Host '1 自动 2 官方 3 国内镜像' + switch (Ask '选择') { '1' { $network='auto' }; '2' { $network='official' }; '3' { $network='china' } } + continue } + $index=0 + if (![int]::TryParse($choice,[ref]$index) -or $index -lt 1 -or $index -gt $tools.Count) { Write-Host '无效选择。'; continue } + $item=$tools[$index-1]; $tool=$item.Name :actions while ($true) { - Write-Host "`n-- $tool --`n1 安装 / 修复`n2 更新到菜单维护的版本`n3 检查安装环境`n4 启动 / 使用`n5 登录与使用说明`n0 返回" - switch (Ask '输入数字') { + Write-Host "`n$($item.Label)`n1 安装 2 更新 3 检查 4 启动 5 使用说明" + if ($item.Kind -ne 'managed') { Write-Host '6 预览安装方案' } + Write-Host '0 返回' + switch (Ask '选择') { '0' { break actions } '1' { Run-Script "$tool.ps1" @('-Network',$network) } '2' { Run-Script "$tool.ps1" @('-Network',$network,'-Update') } '3' { Run-Script "$tool.ps1" @('-Check') } '5' { Show-Help $tool } + '6' { if ($item.Kind -ne 'managed') { Run-Script "$tool.ps1" @('-DryRun') } } '4' { if ($tool -eq 'lmm') { - Write-Host "1 应用目录 2 状态 3 诊断 4 安装计划(不执行)`n5 登录 LMM 6 模型列表 7 退出登录 0 返回" - $actions = @{'1'='catalog';'2'='status';'3'='doctor';'4'='plan';'5'='login';'6'='models';'7'='logout'} - $choice=Ask '选择' - if ($actions.ContainsKey($choice)) { Run-Script 'lmm-use.ps1' @('-Command',$actions[$choice]) } - elseif ($choice -ne '0') { Write-Host '无效选择。' } - } else { - $launcher = Join-Path $root "bin\$tool.cmd" - if (Test-Path -LiteralPath $launcher) { - Show-Help $tool - if ($tool -eq 'dsh') { & $launcher --profile web } else { & $launcher } - Write-Host '已返回菜单。' - } else { Write-Host '尚未安装,请先选择 1。' } + Write-Host "1 目录 2 状态 3 诊断 4 安装计划`n5 登录 6 模型 7 退出登录 0 返回" + $actions=@{'1'='catalog';'2'='status';'3'='doctor';'4'='plan';'5'='login';'6'='models';'7'='logout'} + $action=Ask '选择' + if ($actions.ContainsKey($action)) { Run-Script 'lmm-use.ps1' @('-Command',$actions[$action]) } + } elseif ($item.Kind -ne 'managed') { Run-Script "$tool.ps1" @('-Network',$network,'-Launch') } + else { + $launcher=Join-Path $root "bin\$tool.cmd" + if (Test-Path -LiteralPath $launcher) { if ($tool -eq 'dsh') { & $launcher --profile web } else { & $launcher } } + else { Write-Host '请先安装。' } } } - default { Write-Host '请输入菜单中的数字。' } + default { Write-Host '无效选择。' } } } } -} catch { Write-Host $_.Exception.Message -ForegroundColor Red; exit 1 } +} catch { Write-Host $_.Exception.Message; exit 1 } finally { - [Net.ServicePointManager]::SecurityProtocol = $oldProtocol + [Net.ServicePointManager]::SecurityProtocol=$oldProtocol if (Test-Path -LiteralPath $work) { Remove-Item -LiteralPath $work -Recurse -Force } } diff --git a/menu.sh b/menu.sh index e467611..2bfcb6e 100755 --- a/menu.sh +++ b/menu.sh @@ -1,5 +1,4 @@ #!/usr/bin/env bash -# Complete function before execution: safe when downloaded through a pipe. lmm_menu_main() ( set -u lmm_root() { @@ -37,91 +36,104 @@ lmm_check_storage() { esac } +tools=(pi dsh lmm codex claude-code cc-switch clash-verge-rev) +labels=('Pi + LMM' 'DSH + LMM' 'LMM CLI (preview)' 'Codex CLI' 'Claude Code' 'CC Switch' 'Clash Verge Rev') +kinds=(managed managed managed external external desktop desktop) +root=$(lmm_root); network=auto +show_tools() { + local i + for i in "${!tools[@]}"; do + if lmm_is_termux && [ "${kinds[$i]}" = desktop ]; then continue; fi + printf '%s %s\n' "$((i+1))" "${labels[$i]}" + done +} case "${1:-}" in - --help|-h) printf 'LMM menu: bash menu.sh [--help]\nInteractive terminal required. Choose Pi, DSH or LMM CLI, then an action.\n'; exit 0;; + --help|-h) printf 'LMM menu: bash menu.sh [--list]\n'; exit 0;; + --list) show_tools; exit 0;; '') ;; *) printf 'Unknown option. Use --help.\n' >&2; exit 2;; esac -if ! { exec 3/dev/null; then - printf '需要交互终端。请在终端运行菜单;自动化请使用底层安装脚本。\n' >&2; exit 2 -fi +if ! { exec 3/dev/null; then printf '需要交互终端;自动化请直接运行工具脚本。\n' >&2; exit 2; fi command -v curl >/dev/null 2>&1 || { printf '请先安装 curl。\n' >&2; exit 2; } expected_hash() { case "$1" in -pi.sh) printf '%s' 'c027a9ec05ca3aebac4625a5bf8c9a6d187689946e37cfed05148bc55905fe88';; -dsh.sh) printf '%s' '39aedcac869fe80322f9a41d689136584f89fe899c87882aaec79e5612863033';; -lmm.sh) printf '%s' '15d459a5acb210588608b1d0d3d80de78f60771c7c3596c9d300549eb5f19473';; +pi.sh) printf '%s' '0b26790dcc9f094dd38ca04440ae4ba7a4e92825a188db71652f0ccfaeca8188';; +dsh.sh) printf '%s' 'ec1eb4dae198ac531b5d639d4f54b43213d46af1251f9e9683032bf38bdce071';; +lmm.sh) printf '%s' '7a6a1fa88cdeb29de2980a9d627a7e59c726e40834cf6be43f6e3a8687bba5ab';; +codex.sh) printf '%s' '2e05359985cf31a4b81d58604a2d855d56fd05f85c0b761f04a56f1626619d5e';; +claude-code.sh) printf '%s' 'fa9834d403452179e0576c10abeb1d33ef4574fc5caaccdf5b5a2a62fe958f0c';; +cc-switch.sh) printf '%s' '08acf41ae33e57ad70c5fc666271fa9128d9e2f5cbe036c86adfb838771ae0a9';; +clash-verge-rev.sh) printf '%s' 'b1384017a9258e3cf3ecdd1f46d2909d1ba2d5f35e04553dcd967e03ecb198a7';; lmm-use.sh) printf '%s' '8f5cb27ef99bc3fd51a5b85e5aba0418f791e3cae03cd0ea624384b3dd50a06b';; *) return 1;; esac; } ask() { printf '%s' "$1"; IFS= read -r answer <&3 || exit 0; } -umask 077 -temp_root=$(lmm_temp_root) -lmm_check_storage "$temp_root" || exit 1 -mkdir -p "$temp_root" || exit 1 -work=$(mktemp -d "$temp_root/lmm-menu.XXXXXXXX") || exit 1 +tmp=$(lmm_temp_root); lmm_check_storage "$tmp" || exit 1 +mkdir -p "$tmp" || exit 1 +work=$(mktemp -d "$tmp/lmm-menu.XXXXXXXX") || exit 1 trap 'rm -rf -- "$work"' EXIT -trap 'exit 130' INT -trap 'exit 143' TERM -network=auto -root=$(lmm_root) +trap 'exit 130' INT; trap 'exit 143' TERM fetch_script() { local name=$1 expected url expected=$(expected_hash "$name") || return 1 if [ -f "$work/$name" ] && [ "$(sha256 "$work/$name")" = "$expected" ]; then return 0; fi - printf '正在获取并校验安装程序(下载慢时会重试)…\n' - for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/9c8f76329ec7846e5b899b2e9fef2320172cdbb7/$name"; do - if curl -q -fSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --speed-limit 1024 --speed-time 20 --retry 2 --retry-delay 2 "$url" -o "$work/download"; then + for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/a5e8b423bfa3ac8176bc6735da00339ffff811e9/$name"; do + if curl -q -fsSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --retry 2 "$url" -o "$work/download"; then if [ "$(sha256 "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi - printf '文件版本或校验不匹配,尝试固定版本备用地址。\n' >&2 fi done - printf '下载失败,未执行任何未校验的文件。请检查网络后重试。\n' >&2; return 1 + printf '下载失败或版本不匹配:%s\n' "$name" >&2; return 1 } run_script() { local name=$1 code; shift fetch_script "$name" || return 1 bash "$work/$name" "$@" <&3; code=$? - if [ "$code" -eq 0 ]; then printf '\n操作完成。\n' - else printf '\n操作退出,状态码 %s;请查看上方提示。可切换网络后重试。\n' "$code"; fi + [ "$code" -eq 0 ] || printf '\n退出码 %s,请查看上方错误。\n' "$code" return "$code" } help_tool() { case "$tool" in - pi) printf '\nPi:安装后选择启动,输入 /login 并选择 LMM 完成浏览器授权,再用 /model 选模型。\n';; - dsh) printf '\nDSH:启动后打开终端提示的网址,在 Settings → Models 的 LMM 卡片选择 Sign in with LMM。\n';; - lmm) printf '\nLMM CLI 是开发预览版。支持目录、状态、诊断、登录和模型列表;setup 目前只提供计划,不会安装应用。\nLinux 登录需要 Secret Service;SSH 登录需浏览器能访问当前主机回调地址。\n';; + pi) printf 'pi → /login → LMM → /model。\n';; + dsh) printf 'dsh web → Settings → Models → LMM。\n';; + lmm) printf 'LMM CLI 是预览版;setup 仅生成计划。Linux 登录需要 Secret Service。\n';; + codex) printf '运行 codex,按官方提示登录。Termux 使用已有 PRoot guest。\n';; + claude-code) printf '运行 claude,按官方提示登录。Termux 使用已有 PRoot guest。\n';; + *) printf '桌面应用按系统安装;不会自动配置账号、订阅或启用代理。\n';; esac - printf '安装位置:%s\n默认不修改 PATH;关闭后可重新运行菜单启动。\n' "$root" + if [ "$kind" = managed ]; then printf '受管目录:%s\n' "$root" + else printf '使用官方安装位置;预览可查看安装方式。\n'; fi } while :; do - printf '\n━━━━━━━━ LMM 工具菜单 ━━━━━━━━\n1 Pi Coding Agent\n2 DSH + LMM 插件\n3 LMM CLI(开发预览)\n4 下载网络(当前:%s)\n0 退出\n' "$network" - ask '输入数字:' + printf '\nLMM 工具\n'; show_tools + printf 'n 下载网络(%s)\n0 退出\n' "$network" + ask '选择:' case "$answer" in 0) exit 0;; - 4) printf '\n1 自动选择 2 官方源 3 国内镜像\n'; ask '选择:'; case "$answer" in 1) network=auto;; 2) network=official;; 3) network=china;; *) printf '无效选择。\n';; esac; continue;; - 1) tool=pi;; 2) tool=dsh;; 3) tool=lmm;; *) printf '请输入菜单中的数字。\n'; continue;; + n|N) printf '1 自动 2 官方 3 国内镜像\n'; ask '选择:'; case "$answer" in 1) network=auto;; 2) network=official;; 3) network=china;; esac; continue;; esac + if ! [[ $answer =~ ^[1-9][0-9]*$ ]] || [ "${#answer}" -gt 2 ] || [ "$answer" -gt "${#tools[@]}" ]; then printf '无效选择。\n'; continue; fi + index=$((answer-1)); tool=${tools[$index]}; kind=${kinds[$index]} + if lmm_is_termux && [ "$kind" = desktop ]; then printf '此工具不支持 Termux。\n'; continue; fi while :; do - printf '\n── %s ──\n1 安装 / 修复\n2 更新到菜单维护的版本\n3 检查安装环境\n4 启动 / 使用\n5 登录与使用说明\n0 返回\n' "$tool" - ask '输入数字:' + printf '\n%s\n1 安装 2 更新 3 检查 4 启动 5 使用说明\n' "${labels[$index]}" + [ "$kind" = managed ] || printf '6 预览安装方案\n' + printf '0 返回\n'; ask '选择:' case "$answer" in 0) break;; 1) run_script "$tool.sh" --network "$network" || :;; 2) run_script "$tool.sh" --network "$network" --update || :;; 3) run_script "$tool.sh" --check || :;; 5) help_tool;; + 6) if [ "$kind" != managed ]; then run_script "$tool.sh" --dry-run || :; fi;; 4) if [ "$tool" = lmm ]; then - printf '\n1 应用目录 2 状态 3 诊断 4 安装计划(不执行)\n5 登录 LMM 6 模型列表 7 退出登录 0 返回\n' - ask '选择:' - case "$answer" in 1) action=catalog;; 2) action=status;; 3) action=doctor;; 4) action=plan;; 5) action=login;; 6) action=models;; 7) action=logout;; 0) continue;; *) printf '无效选择。\n'; continue;; esac + printf '1 目录 2 状态 3 诊断 4 安装计划\n5 登录 6 模型 7 退出登录 0 返回\n'; ask '选择:' + case "$answer" in 1) action=catalog;; 2) action=status;; 3) action=doctor;; 4) action=plan;; 5) action=login;; 6) action=models;; 7) action=logout;; *) continue;; esac run_script lmm-use.sh "$action" || : + elif [ "$kind" != managed ]; then run_script "$tool.sh" --network "$network" --launch || : elif [ -x "$root/bin/$tool" ]; then - help_tool if [ "$tool" = dsh ]; then "$root/bin/dsh" --profile web <&3; else "$root/bin/pi" <&3; fi - printf '\n已返回菜单。\n' - else printf '尚未安装,请先选择 1。\n'; fi;; - *) printf '请输入菜单中的数字。\n';; + else printf '请先安装。\n'; fi;; + *) printf '无效选择。\n';; esac done done diff --git a/tools/generate_menus.py b/tools/generate_menus.py index 5f46553..e9db6cb 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -7,7 +7,7 @@ from catalog import TOOLS from render import ROOT, emit, libraries, template -revision='9c8f76329ec7846e5b899b2e9fef2320172cdbb7' +revision='a5e8b423bfa3ac8176bc6735da00339ffff811e9' def main(): From 6b3c92a1328eaec129bbd1dbaa86d7f8a3dd9de5 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 18:43:35 +0800 Subject: [PATCH 28/39] chore: remove completed catalog preparation workflow --- .github/workflows/_catalog.yml | 62 ---------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 .github/workflows/_catalog.yml diff --git a/.github/workflows/_catalog.yml b/.github/workflows/_catalog.yml deleted file mode 100644 index 689ad57..0000000 --- a/.github/workflows/_catalog.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Prepare compact tool catalog -on: - push: - branches: [codex/official-installers-20260920] - paths: [tools/_catalog_patch.py, .github/workflows/_catalog.yml] -permissions: - contents: write -jobs: - prepare: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 - with: - node-version: 24.21.0 - - name: Compose, test and pin modules - run: | - python3 tools/_catalog_patch.py - rm tools/_catalog_patch.py - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add templates tools README.md docs tests - git commit -m 'refactor: share lifecycle helpers and delegate new tools to official installers' - python3 - <<'PY' - from pathlib import Path - import json, subprocess - p=Path('versions.json'); v=json.loads(p.read_text(encoding='utf-8')) - v['library_revision']=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() - v['script_version']='2026.09.20.4' - p.write_text(json.dumps(v,indent=2)+'\n',encoding='utf-8') - PY - python3 tools/generate.py - python3 tools/generate.py --check - git add versions.json '*.sh' '*.ps1' - git commit -m 'feat: publish compact Codex Claude Code CC Switch and Clash Verge Rev installers' - python3 - <<'PY' - from pathlib import Path - import re, subprocess - p=Path('tools/generate_menus.py') - sha=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() - text,count=re.subn(r"revision='[0-9a-f]{40}'",f"revision='{sha}'",p.read_text(encoding='utf-8')) - assert count==1 - p.write_text(text,encoding='utf-8') - PY - python3 tools/generate_menus.py - python3 tools/generate_menus.py --check - python3 tests/test_installers.py - python3 tests/test_official_policy.py - python3 tests/test_library_loader.py - python3 tests/test_external.py - python3 tests/test_catalog.py - shellcheck *.sh - pwsh -NoProfile -File tests/test-powershell.ps1 - pwsh -NoProfile -File tests/test-library-loader.ps1 - pwsh -NoProfile -File tests/test-external.ps1 - git add tools/generate_menus.py menu.sh menu.ps1 - git commit -m 'feat: drive both menus from one seven-tool catalog' - wc -c pi.sh dsh.sh lmm.sh codex.sh claude-code.sh cc-switch.sh clash-verge-rev.sh - git push origin HEAD:codex/official-installers-20260920 From 9d277217b3f59da59979b997ed20c5bd50dda315 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 18:50:48 +0800 Subject: [PATCH 29/39] fix: isolate distro metadata and cover native installation regressions --- .github/workflows/_portability.yml | 98 ++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/_portability.yml diff --git a/.github/workflows/_portability.yml b/.github/workflows/_portability.yml new file mode 100644 index 0000000..e6d75db --- /dev/null +++ b/.github/workflows/_portability.yml @@ -0,0 +1,98 @@ +name: Verify distribution portability fixes +on: + push: + branches: [codex/official-installers-20260920] + paths: [.github/workflows/_portability.yml] +permissions: + contents: write +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24.21.0 + - name: Isolate os-release variables and complete portable test fixtures + run: | + python3 - <<'PY' + from pathlib import Path + def replace(path, old, new): + p=Path(path); text=p.read_text(encoding='utf-8') + assert text.count(old)==1, (path,old[:80],text.count(old)) + p.write_text(text.replace(old,new),encoding='utf-8') + replace('templates/lib/external.sh', ''' local ID='' ID_LIKE='' + if [ -f "${LMM_OS_RELEASE:-/etc/os-release}" ]; then + # shellcheck disable=SC1090 + . "${LMM_OS_RELEASE:-/etc/os-release}" + fi + case " $ID $ID_LIKE " in'''.replace('\n ', '\n'), ''' local distro_info + distro_info=$( + ID='' ID_LIKE='' + if [ -f "${LMM_OS_RELEASE:-/etc/os-release}" ]; then + # Do not let os-release VERSION replace the requested tool version. + # shellcheck disable=SC1090 + . "${LMM_OS_RELEASE:-/etc/os-release}" + fi + printf '%s %s\\n' "$ID" "$ID_LIKE" + ) + case " $distro_info " in'''.replace('\n ', '\n')) + replace('tests/test_external.py', "elif name=='ldd':", "elif name=='realpath': print(os.path.realpath(args[-1]))\nelif name=='ldd':") + replace('tests/test_external.py', "('uname','ldd','curl','proot-distro',", "('uname','realpath','ldd','curl','proot-distro',") + replace('tests/test_external.py', "self.release.write_text(f'ID={distro}\\n')", "self.release.write_text(f'ID={distro}\\nVERSION=\"24.04.5 LTS (Noble Numbat)\"\\nNAME=\"Fixture Linux\"\\n')") + replace('tests/test_external.py', ' def test_partial_upstream_script_is_not_executed(self):', ''' def test_os_release_does_not_override_tool_version(self): + for target, expected in [('codex', ['--release', 'latest']), ('claude-code', ['stable'])]: + r=self.run_tool(target, '--update') + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual((self.base/'args').read_text().splitlines(), expected) + r=self.run_tool('codex', '--version', '1.2.3') + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual((self.base/'args').read_text().splitlines(), ['--release', '1.2.3']) + + def test_partial_upstream_script_is_not_executed(self):'''.replace('\n ', '\n')) + p=Path('tools/catalog.py') + with p.open('a',encoding='utf-8') as f: + f.write("MANAGED = tuple(name for name, _, kind in TOOLS if kind == 'managed')\n") + replace('tools/generate.py', 'from catalog import EXTERNAL', 'from catalog import EXTERNAL, MANAGED') + replace('tools/generate.py', "for target in ('pi', 'dsh', 'lmm'):", 'for target in MANAGED:') + PY + python3 -m py_compile tests/test_external.py tools/generate.py + python3 tests/test_external.py + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add templates/lib/external.sh tests/test_external.py tools/catalog.py tools/generate.py + git commit -m 'fix: keep distro metadata out of installer state and isolate Termux test tools' + python3 - <<'PY' + import json, subprocess + from pathlib import Path + p=Path('versions.json'); v=json.loads(p.read_text(encoding='utf-8')) + v['library_revision']=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() + v['script_version']='2026.09.20.5' + p.write_text(json.dumps(v,indent=2)+'\n',encoding='utf-8') + PY + python3 tools/generate.py + python3 tools/generate.py --check + python3 tests/test_installers.py + python3 tests/test_official_policy.py + python3 tests/test_library_loader.py + shellcheck *.sh + git add versions.json '*.sh' '*.ps1' + git commit -m 'fix: pin generated installers to corrected distribution detection' + python3 - <<'PY' + import re, subprocess + from pathlib import Path + p=Path('tools/generate_menus.py') + sha=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() + text,count=re.subn(r"revision='[0-9a-f]{40}'",f"revision='{sha}'",p.read_text(encoding='utf-8')) + assert count==1 + p.write_text(text,encoding='utf-8') + PY + python3 tools/generate_menus.py + python3 tools/generate_menus.py --check + python3 tests/test_catalog.py + git add tools/generate_menus.py menu.sh menu.ps1 + git commit -m 'fix: update seven-tool menu to tested installer revision' + git push origin HEAD:codex/official-installers-20260920 From 85beae2c898b2ed3e616762bcc9a05b75160221f Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 18:51:38 +0800 Subject: [PATCH 30/39] test: preserve regression fixture indentation during generation --- .github/workflows/_portability.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/_portability.yml b/.github/workflows/_portability.yml index e6d75db..2b58549 100644 --- a/.github/workflows/_portability.yml +++ b/.github/workflows/_portability.yml @@ -29,7 +29,7 @@ jobs: # shellcheck disable=SC1090 . "${LMM_OS_RELEASE:-/etc/os-release}" fi - case " $ID $ID_LIKE " in'''.replace('\n ', '\n'), ''' local distro_info + case " $ID $ID_LIKE " in''', ''' local distro_info distro_info=$( ID='' ID_LIKE='' if [ -f "${LMM_OS_RELEASE:-/etc/os-release}" ]; then @@ -39,7 +39,7 @@ jobs: fi printf '%s %s\\n' "$ID" "$ID_LIKE" ) - case " $distro_info " in'''.replace('\n ', '\n')) + case " $distro_info " in''') replace('tests/test_external.py', "elif name=='ldd':", "elif name=='realpath': print(os.path.realpath(args[-1]))\nelif name=='ldd':") replace('tests/test_external.py', "('uname','ldd','curl','proot-distro',", "('uname','realpath','ldd','curl','proot-distro',") replace('tests/test_external.py', "self.release.write_text(f'ID={distro}\\n')", "self.release.write_text(f'ID={distro}\\nVERSION=\"24.04.5 LTS (Noble Numbat)\"\\nNAME=\"Fixture Linux\"\\n')") @@ -52,7 +52,7 @@ jobs: self.assertEqual(r.returncode, 0, r.stderr) self.assertEqual((self.base/'args').read_text().splitlines(), ['--release', '1.2.3']) - def test_partial_upstream_script_is_not_executed(self):'''.replace('\n ', '\n')) + def test_partial_upstream_script_is_not_executed(self):''') p=Path('tools/catalog.py') with p.open('a',encoding='utf-8') as f: f.write("MANAGED = tuple(name for name, _, kind in TOOLS if kind == 'managed')\n") From cced5d9313020a87b10774a1445d5e44a973a7fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:51:58 +0000 Subject: [PATCH 31/39] fix: keep distro metadata out of installer state and isolate Termux test tools --- templates/lib/external.sh | 17 +++++++++++------ tests/test_external.py | 14 ++++++++++++-- tools/catalog.py | 1 + tools/generate.py | 4 ++-- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/templates/lib/external.sh b/templates/lib/external.sh index 5dd9902..329daed 100644 --- a/templates/lib/external.sh +++ b/templates/lib/external.sh @@ -39,12 +39,17 @@ lmm_external_main() ( [ "$KIND" = cli ] || die "$APP is a desktop application; Termux is not supported" lmm_check_storage "$ROOT" || exit 1 elif [ "$OS" = linux ]; then - local ID='' ID_LIKE='' - if [ -f "${LMM_OS_RELEASE:-/etc/os-release}" ]; then - # shellcheck disable=SC1090 - . "${LMM_OS_RELEASE:-/etc/os-release}" - fi - case " $ID $ID_LIKE " in + local distro_info + distro_info=$( + ID='' ID_LIKE='' + if [ -f "${LMM_OS_RELEASE:-/etc/os-release}" ]; then + # Do not let os-release VERSION replace the requested tool version. + # shellcheck disable=SC1090 + . "${LMM_OS_RELEASE:-/etc/os-release}" + fi + printf '%s %s\n' "$ID" "$ID_LIKE" + ) + case " $distro_info " in *alpine*) FAMILY=alpine;; *debian*|*ubuntu*) FAMILY=debian;; *fedora*|*rhel*|*centos*|*rocky*|*almalinux*) FAMILY=fedora;; *suse*) FAMILY=suse;; *arch*) FAMILY=arch;; *void*) FAMILY=void;; *nixos*) FAMILY=nixos;; diff --git a/tests/test_external.py b/tests/test_external.py index c9ebde5..c1c0f4e 100644 --- a/tests/test_external.py +++ b/tests/test_external.py @@ -14,6 +14,7 @@ name=Path(sys.argv[0]).name; args=sys.argv[1:] with open(os.environ['TEST_LOG'],'a') as f: f.write(json.dumps([name,args])+'\n') if name=='uname': print(os.environ.get('TEST_OS','Linux') if '-s' in args else os.environ.get('TEST_ARCH','x86_64')) +elif name=='realpath': print(os.path.realpath(args[-1])) elif name=='ldd': print(os.environ.get('TEST_LIBC','glibc')) elif name=='curl': out=Path(args[args.index('-o')+1]) @@ -32,7 +33,7 @@ def setUp(self): self.fake=self.base/'bin'; self.fake.mkdir(); (self.base/'home').mkdir() self.log=self.base/'calls'; self.log.write_text('') self.release=self.base/'os-release' - for name in ('uname','ldd','curl','proot-distro','apk','apt-get','dnf','pacman','zypper','xbps-install','rg'): + for name in ('uname','realpath','ldd','curl','proot-distro','apk','apt-get','dnf','pacman','zypper','xbps-install','rg'): p=self.fake/name; p.write_text(FAKE); p.chmod(0o755) self.env=dict(os.environ,HOME=str(self.base/'home'),TMPDIR=str(self.base),PATH=str(self.fake)+os.pathsep+os.environ['PATH'],TERMUX_VERSION='',TERMUX_APP__PACKAGE_NAME='',PREFIX='',LMM_OS_RELEASE=str(self.release),LMM_INSTALL_ROOT=str(self.base/'tools'),TEST_LOG=str(self.log),TEST_MARKER=str(self.base/'bad'),TEST_ARGS=str(self.base/'args')) for key in ('CODEX_INSTALL_DIR','CODEX_HOME','LMM_PROOT_DISTRO'): self.env.pop(key,None) @@ -41,7 +42,7 @@ def setUp(self): def tearDown(self): self.tmp.cleanup() def run_tool(self,target,*args,distro='ubuntu',**env): - self.release.write_text(f'ID={distro}\n') + self.release.write_text(f'ID={distro}\nVERSION="24.04.5 LTS (Noble Numbat)"\nNAME="Fixture Linux"\n') return subprocess.run(['bash','-c',self.code+'\nTARGET=$1; shift; lmm_external_main "$@"','test',target,*args],env=self.env|env,text=True,capture_output=True,timeout=30) def calls(self): return [json.loads(x) for x in self.log.read_text().splitlines()] @@ -69,6 +70,15 @@ def test_claude_explicit_latest_is_not_replaced_by_stable(self): r=self.run_tool('claude-code','--version','latest'); self.assertEqual(r.returncode,0,r.stderr) self.assertEqual((self.base/'args').read_text().strip(),'latest') + def test_os_release_does_not_override_tool_version(self): + for target, expected in [('codex', ['--release', 'latest']), ('claude-code', ['stable'])]: + r=self.run_tool(target, '--update') + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual((self.base/'args').read_text().splitlines(), expected) + r=self.run_tool('codex', '--version', '1.2.3') + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual((self.base/'args').read_text().splitlines(), ['--release', '1.2.3']) + def test_partial_upstream_script_is_not_executed(self): r=self.run_tool('codex',TEST_FAIL='1'); self.assertNotEqual(r.returncode,0) self.assertFalse((self.base/'bad').exists()); self.assertFalse((self.base/'home/.local/bin/codex').exists()) diff --git a/tools/catalog.py b/tools/catalog.py index c07f103..1799968 100644 --- a/tools/catalog.py +++ b/tools/catalog.py @@ -9,3 +9,4 @@ ('clash-verge-rev', 'Clash Verge Rev', 'desktop'), ) EXTERNAL = tuple(name for name, _, kind in TOOLS if kind != 'managed') +MANAGED = tuple(name for name, _, kind in TOOLS if kind == 'managed') diff --git a/tools/generate.py b/tools/generate.py index 295685b..72575a6 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -5,7 +5,7 @@ import re import shlex from render import ROOT, emit, libraries, standalone, template -from catalog import EXTERNAL +from catalog import EXTERNAL, MANAGED # JSON field -> shell / PowerShell variable. Keep a single naming map. NAMES = { @@ -59,7 +59,7 @@ def main() -> None: versions = json.loads((ROOT / 'versions.json').read_text(encoding='utf-8')) if not re.fullmatch(r'[0-9a-f]{40}', versions['library_revision']): raise ValueError('library_revision must be a full Git commit ID') - for target in ('pi', 'dsh', 'lmm'): + for target in MANAGED: for ext in ('sh', 'ps1'): parts = ['lib/hash.sh', 'lib/termux.sh', 'lib/quote.sh'] if ext == 'sh' else ['lib/common.ps1'] parts.append(f'lib/download.{ext}') From 1d2fb99abe87d8af2cd3a17489cadd32cabcc189 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:52:13 +0000 Subject: [PATCH 32/39] fix: pin generated installers to corrected distribution detection --- cc-switch.ps1 | 2 +- cc-switch.sh | 2 +- clash-verge-rev.ps1 | 2 +- clash-verge-rev.sh | 2 +- claude-code.ps1 | 2 +- claude-code.sh | 2 +- codex.ps1 | 2 +- codex.sh | 2 +- dsh.ps1 | 4 ++-- dsh.sh | 4 ++-- lmm.ps1 | 4 ++-- lmm.sh | 4 ++-- pi.ps1 | 4 ++-- pi.sh | 4 ++-- versions.json | 4 ++-- 15 files changed, 22 insertions(+), 22 deletions(-) diff --git a/cc-switch.ps1 b/cc-switch.ps1 index ee35c58..836bb66 100644 --- a/cc-switch.ps1 +++ b/cc-switch.ps1 @@ -5,7 +5,7 @@ param([string]$Root='', [string]$Version='', [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) $ErrorActionPreference='Stop' $Target='cc-switch' -$LibRevision='60692bd80622a0d3d80ee501eacb8db139641a3e' +$LibRevision='cced5d9313020a87b10774a1445d5e44a973a7fa' if ($Help) { Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." exit 0 diff --git a/cc-switch.sh b/cc-switch.sh index c052707..9f03e32 100755 --- a/cc-switch.sh +++ b/cc-switch.sh @@ -3,7 +3,7 @@ lmm_entry() { set -euo pipefail # shellcheck disable=SC2034 TARGET=cc-switch -LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa for arg in "$@"; do case "$arg" in --) break;; --help|-h) printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' diff --git a/clash-verge-rev.ps1 b/clash-verge-rev.ps1 index 64c6723..8211892 100644 --- a/clash-verge-rev.ps1 +++ b/clash-verge-rev.ps1 @@ -5,7 +5,7 @@ param([string]$Root='', [string]$Version='', [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) $ErrorActionPreference='Stop' $Target='clash-verge-rev' -$LibRevision='60692bd80622a0d3d80ee501eacb8db139641a3e' +$LibRevision='cced5d9313020a87b10774a1445d5e44a973a7fa' if ($Help) { Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." exit 0 diff --git a/clash-verge-rev.sh b/clash-verge-rev.sh index 81d2f9f..41e52d4 100755 --- a/clash-verge-rev.sh +++ b/clash-verge-rev.sh @@ -3,7 +3,7 @@ lmm_entry() { set -euo pipefail # shellcheck disable=SC2034 TARGET=clash-verge-rev -LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa for arg in "$@"; do case "$arg" in --) break;; --help|-h) printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' diff --git a/claude-code.ps1 b/claude-code.ps1 index a7de628..72ab630 100644 --- a/claude-code.ps1 +++ b/claude-code.ps1 @@ -5,7 +5,7 @@ param([string]$Root='', [string]$Version='', [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) $ErrorActionPreference='Stop' $Target='claude-code' -$LibRevision='60692bd80622a0d3d80ee501eacb8db139641a3e' +$LibRevision='cced5d9313020a87b10774a1445d5e44a973a7fa' if ($Help) { Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." exit 0 diff --git a/claude-code.sh b/claude-code.sh index 47444f2..db39ba1 100755 --- a/claude-code.sh +++ b/claude-code.sh @@ -3,7 +3,7 @@ lmm_entry() { set -euo pipefail # shellcheck disable=SC2034 TARGET=claude-code -LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa for arg in "$@"; do case "$arg" in --) break;; --help|-h) printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' diff --git a/codex.ps1 b/codex.ps1 index 448abf3..359ce80 100644 --- a/codex.ps1 +++ b/codex.ps1 @@ -5,7 +5,7 @@ param([string]$Root='', [string]$Version='', [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) $ErrorActionPreference='Stop' $Target='codex' -$LibRevision='60692bd80622a0d3d80ee501eacb8db139641a3e' +$LibRevision='cced5d9313020a87b10774a1445d5e44a973a7fa' if ($Help) { Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." exit 0 diff --git a/codex.sh b/codex.sh index 56caf47..30941ed 100755 --- a/codex.sh +++ b/codex.sh @@ -3,7 +3,7 @@ lmm_entry() { set -euo pipefail # shellcheck disable=SC2034 TARGET=codex -LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa for arg in "$@"; do case "$arg" in --) break;; --help|-h) printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' diff --git a/dsh.ps1 b/dsh.ps1 index e94f316..10d84ba 100644 --- a/dsh.ps1 +++ b/dsh.ps1 @@ -12,8 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'dsh' -$ScriptVersion = '2026.09.20.4' -$LibRevision = '60692bd80622a0d3d80ee501eacb8db139641a3e' +$ScriptVersion = '2026.09.20.5' +$LibRevision = 'cced5d9313020a87b10774a1445d5e44a973a7fa' $NodeVersion = '24.21.0' $PnpmVersion = '11.7.0' $DshVersion = '0.1.5-rc.2' diff --git a/dsh.sh b/dsh.sh index 5991884..2ef67cb 100755 --- a/dsh.sh +++ b/dsh.sh @@ -6,8 +6,8 @@ lmm_install_main() { set -euo pipefail set +x TARGET=dsh -SCRIPT_VERSION=2026.09.20.4 -LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +SCRIPT_VERSION=2026.09.20.5 +LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa NODE_VERSION=24.21.0 PNPM_VERSION=11.7.0 DSH_VERSION=0.1.5-rc.2 diff --git a/lmm.ps1 b/lmm.ps1 index 7580ab0..d332b29 100644 --- a/lmm.ps1 +++ b/lmm.ps1 @@ -12,8 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'lmm' -$ScriptVersion = '2026.09.20.4' -$LibRevision = '60692bd80622a0d3d80ee501eacb8db139641a3e' +$ScriptVersion = '2026.09.20.5' +$LibRevision = 'cced5d9313020a87b10774a1445d5e44a973a7fa' $LmmVersion = '0.1.0' $LmmReleaseBase = 'https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0' $LmmHashes = @{ diff --git a/lmm.sh b/lmm.sh index 10f5a46..ff7867d 100755 --- a/lmm.sh +++ b/lmm.sh @@ -6,8 +6,8 @@ lmm_install_main() { set -euo pipefail set +x TARGET=lmm -SCRIPT_VERSION=2026.09.20.4 -LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +SCRIPT_VERSION=2026.09.20.5 +LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa LMM_VERSION=0.1.0 LMM_RELEASE_BASE=https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0 lmm_hash() { case "$1" in diff --git a/pi.ps1 b/pi.ps1 index 2804e4b..6aafb41 100644 --- a/pi.ps1 +++ b/pi.ps1 @@ -12,8 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'pi' -$ScriptVersion = '2026.09.20.4' -$LibRevision = '60692bd80622a0d3d80ee501eacb8db139641a3e' +$ScriptVersion = '2026.09.20.5' +$LibRevision = 'cced5d9313020a87b10774a1445d5e44a973a7fa' $NodeVersion = '24.21.0' $PiVersion = '0.85.1' $PiProviderVersion = '0.1.0-alpha.1' diff --git a/pi.sh b/pi.sh index 4230957..01be85e 100755 --- a/pi.sh +++ b/pi.sh @@ -6,8 +6,8 @@ lmm_install_main() { set -euo pipefail set +x TARGET=pi -SCRIPT_VERSION=2026.09.20.4 -LIB_REVISION=60692bd80622a0d3d80ee501eacb8db139641a3e +SCRIPT_VERSION=2026.09.20.5 +LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa NODE_VERSION=24.21.0 PI_VERSION=0.85.1 PI_PROVIDER_VERSION=0.1.0-alpha.1 diff --git a/versions.json b/versions.json index 81a36b3..eccd526 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "script_version": "2026.09.20.4", + "script_version": "2026.09.20.5", "node_version": "24.21.0", "node_sha256": { "linux-x64": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", @@ -22,5 +22,5 @@ "win-x64": "d0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9" }, "pnpm_version": "11.7.0", - "library_revision": "60692bd80622a0d3d80ee501eacb8db139641a3e" + "library_revision": "cced5d9313020a87b10774a1445d5e44a973a7fa" } From 69d6fd0148374adc20ea6f8c8ec8218a21ff6104 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:52:14 +0000 Subject: [PATCH 33/39] fix: update seven-tool menu to tested installer revision --- menu.ps1 | 16 ++++++++-------- menu.sh | 16 ++++++++-------- tools/generate_menus.py | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/menu.ps1 b/menu.ps1 index b09743a..25636b5 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -15,13 +15,13 @@ function Show-Tools { for ($i=0; $i -lt $tools.Count; $i++) { Write-Output "$($i if ($Help) { Write-Output 'LMM menu: .\menu.ps1 [-List]'; exit 0 } if ($List) { Show-Tools; exit 0 } $hashes=@{ - 'pi.ps1' = 'f5c1fa6f32b948dceab748a764097d00354b1bf1780452662710d2bb3eb873e4' - 'dsh.ps1' = '3f860eaf6db01fc9e22221bcc4d1e6c8c5e03610644a972b835b7e60f8136543' - 'lmm.ps1' = 'f3fccb41d48d4f76c6fb896c6f9cee2c38adcd399a04a0c118a0bf658ebc820b' - 'codex.ps1' = '12db2701adc5c99be542d816de4d33da204b0b2831afef0e8473db1be7d44ffc' - 'claude-code.ps1' = '0a806c261592b1720f21d549d10ee2dba9c736cff74f2b6641c81c0e328abe5c' - 'cc-switch.ps1' = 'e2512a44170235011ffd3c7e3e61b5a55f9a4105178baa3d46cabcc641335da6' - 'clash-verge-rev.ps1' = '18968d7173d706ded660d587a17dc7c77b72d03652c9b853b25b8b42c21cc8a4' + 'pi.ps1' = 'e64cfb40182d8edbf80c17a28c5ce3cc5e22c1179b52e94a8ea140996ab3de9b' + 'dsh.ps1' = 'a0c9ff55a3445bcacf35b64e6a1b96e65a28aebf45fd1c3c221a464efa0dedf7' + 'lmm.ps1' = '6df5d2ffe8ad8b65a1bcf60570f4eb086dbbd8c22f1f5dbc5e99359d97b61c0e' + 'codex.ps1' = '79ee4587b25a4e8b45bcf035a9bed1b8abee6d2f98552eb4defc11c183e642ab' + 'claude-code.ps1' = 'ecf5df65c96ba8c4f6265264d2ba381e942eb09fe262d769ab607d4c1d592ec6' + 'cc-switch.ps1' = '2f0270b3261d22f20d8f01fb4fde8c5d588aaa61499f5b3a89916727c7e742cc' + 'clash-verge-rev.ps1' = '0a3b5a66631082e96eeabdc8c9b242302e9bc1a42438f2517e2658d87fb814bf' 'lmm-use.ps1' = 'ac137e30b6609580cb6ee4fbb60ed77ed01ec6153b733140540d5bc38d9179c1' } $network='auto'; $root=$env:LMM_INSTALL_ROOT @@ -34,7 +34,7 @@ function Fetch-Script([string]$Name) { if (!$hashes.ContainsKey($Name)) { throw 'Unknown script' } $path=Join-Path $work $Name if ((Test-Path -LiteralPath $path) -and (Get-FileHash -LiteralPath $path).Hash -eq $hashes[$Name]) { return $path } - foreach ($url in @("https://api.lmm.best/scripts/$Name","https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/a5e8b423bfa3ac8176bc6735da00339ffff811e9/$Name")) { + foreach ($url in @("https://api.lmm.best/scripts/$Name","https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/1d2fb99abe87d8af2cd3a17489cadd32cabcc189/$Name")) { for ($attempt=1; $attempt -le 3; $attempt++) { try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 diff --git a/menu.sh b/menu.sh index 2bfcb6e..4551f65 100755 --- a/menu.sh +++ b/menu.sh @@ -56,13 +56,13 @@ esac if ! { exec 3/dev/null; then printf '需要交互终端;自动化请直接运行工具脚本。\n' >&2; exit 2; fi command -v curl >/dev/null 2>&1 || { printf '请先安装 curl。\n' >&2; exit 2; } expected_hash() { case "$1" in -pi.sh) printf '%s' '0b26790dcc9f094dd38ca04440ae4ba7a4e92825a188db71652f0ccfaeca8188';; -dsh.sh) printf '%s' 'ec1eb4dae198ac531b5d639d4f54b43213d46af1251f9e9683032bf38bdce071';; -lmm.sh) printf '%s' '7a6a1fa88cdeb29de2980a9d627a7e59c726e40834cf6be43f6e3a8687bba5ab';; -codex.sh) printf '%s' '2e05359985cf31a4b81d58604a2d855d56fd05f85c0b761f04a56f1626619d5e';; -claude-code.sh) printf '%s' 'fa9834d403452179e0576c10abeb1d33ef4574fc5caaccdf5b5a2a62fe958f0c';; -cc-switch.sh) printf '%s' '08acf41ae33e57ad70c5fc666271fa9128d9e2f5cbe036c86adfb838771ae0a9';; -clash-verge-rev.sh) printf '%s' 'b1384017a9258e3cf3ecdd1f46d2909d1ba2d5f35e04553dcd967e03ecb198a7';; +pi.sh) printf '%s' '03ce0f2ebbe1b160017244b76287d378b18592622b40bee229772ebe90d254f9';; +dsh.sh) printf '%s' '45e6db16f0be8d468d99b2325d703a2fa21a1ee961a316f8056c4f3cab369130';; +lmm.sh) printf '%s' 'a8578cb820369034a7c28fa7da87a9ba51795d5cec75d48aa056c8c380f02819';; +codex.sh) printf '%s' '7e117367fd9c3a349e84aaab02d7398c9649f43d0dd03cb6133d562a8612b57d';; +claude-code.sh) printf '%s' 'a4df55371e625bea9f222f1135b1fe6e30536e4c5a5947948af7072040b712c9';; +cc-switch.sh) printf '%s' 'd97dc4cd1ec88ac48f283a717bb8caec58b42154772ac4e63a84da639a38ff77';; +clash-verge-rev.sh) printf '%s' '26d2efc3add8beec179ba9efee4a62c0e7d79e03539eca4e7168cd4f1674f9ca';; lmm-use.sh) printf '%s' '8f5cb27ef99bc3fd51a5b85e5aba0418f791e3cae03cd0ea624384b3dd50a06b';; *) return 1;; esac; } @@ -76,7 +76,7 @@ fetch_script() { local name=$1 expected url expected=$(expected_hash "$name") || return 1 if [ -f "$work/$name" ] && [ "$(sha256 "$work/$name")" = "$expected" ]; then return 0; fi - for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/a5e8b423bfa3ac8176bc6735da00339ffff811e9/$name"; do + for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/1d2fb99abe87d8af2cd3a17489cadd32cabcc189/$name"; do if curl -q -fsSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --retry 2 "$url" -o "$work/download"; then if [ "$(sha256 "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi fi diff --git a/tools/generate_menus.py b/tools/generate_menus.py index e9db6cb..995cb2e 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -7,7 +7,7 @@ from catalog import TOOLS from render import ROOT, emit, libraries, template -revision='a5e8b423bfa3ac8176bc6735da00339ffff811e9' +revision='1d2fb99abe87d8af2cd3a17489cadd32cabcc189' def main(): From 45d264f834d58d1003a9ba31abe92aa8b4c47471 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 18:52:49 +0800 Subject: [PATCH 34/39] chore: remove completed portability preparation workflow --- .github/workflows/_portability.yml | 98 ------------------------------ 1 file changed, 98 deletions(-) delete mode 100644 .github/workflows/_portability.yml diff --git a/.github/workflows/_portability.yml b/.github/workflows/_portability.yml deleted file mode 100644 index 2b58549..0000000 --- a/.github/workflows/_portability.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Verify distribution portability fixes -on: - push: - branches: [codex/official-installers-20260920] - paths: [.github/workflows/_portability.yml] -permissions: - contents: write -jobs: - prepare: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 - with: - node-version: 24.21.0 - - name: Isolate os-release variables and complete portable test fixtures - run: | - python3 - <<'PY' - from pathlib import Path - def replace(path, old, new): - p=Path(path); text=p.read_text(encoding='utf-8') - assert text.count(old)==1, (path,old[:80],text.count(old)) - p.write_text(text.replace(old,new),encoding='utf-8') - replace('templates/lib/external.sh', ''' local ID='' ID_LIKE='' - if [ -f "${LMM_OS_RELEASE:-/etc/os-release}" ]; then - # shellcheck disable=SC1090 - . "${LMM_OS_RELEASE:-/etc/os-release}" - fi - case " $ID $ID_LIKE " in''', ''' local distro_info - distro_info=$( - ID='' ID_LIKE='' - if [ -f "${LMM_OS_RELEASE:-/etc/os-release}" ]; then - # Do not let os-release VERSION replace the requested tool version. - # shellcheck disable=SC1090 - . "${LMM_OS_RELEASE:-/etc/os-release}" - fi - printf '%s %s\\n' "$ID" "$ID_LIKE" - ) - case " $distro_info " in''') - replace('tests/test_external.py', "elif name=='ldd':", "elif name=='realpath': print(os.path.realpath(args[-1]))\nelif name=='ldd':") - replace('tests/test_external.py', "('uname','ldd','curl','proot-distro',", "('uname','realpath','ldd','curl','proot-distro',") - replace('tests/test_external.py', "self.release.write_text(f'ID={distro}\\n')", "self.release.write_text(f'ID={distro}\\nVERSION=\"24.04.5 LTS (Noble Numbat)\"\\nNAME=\"Fixture Linux\"\\n')") - replace('tests/test_external.py', ' def test_partial_upstream_script_is_not_executed(self):', ''' def test_os_release_does_not_override_tool_version(self): - for target, expected in [('codex', ['--release', 'latest']), ('claude-code', ['stable'])]: - r=self.run_tool(target, '--update') - self.assertEqual(r.returncode, 0, r.stderr) - self.assertEqual((self.base/'args').read_text().splitlines(), expected) - r=self.run_tool('codex', '--version', '1.2.3') - self.assertEqual(r.returncode, 0, r.stderr) - self.assertEqual((self.base/'args').read_text().splitlines(), ['--release', '1.2.3']) - - def test_partial_upstream_script_is_not_executed(self):''') - p=Path('tools/catalog.py') - with p.open('a',encoding='utf-8') as f: - f.write("MANAGED = tuple(name for name, _, kind in TOOLS if kind == 'managed')\n") - replace('tools/generate.py', 'from catalog import EXTERNAL', 'from catalog import EXTERNAL, MANAGED') - replace('tools/generate.py', "for target in ('pi', 'dsh', 'lmm'):", 'for target in MANAGED:') - PY - python3 -m py_compile tests/test_external.py tools/generate.py - python3 tests/test_external.py - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add templates/lib/external.sh tests/test_external.py tools/catalog.py tools/generate.py - git commit -m 'fix: keep distro metadata out of installer state and isolate Termux test tools' - python3 - <<'PY' - import json, subprocess - from pathlib import Path - p=Path('versions.json'); v=json.loads(p.read_text(encoding='utf-8')) - v['library_revision']=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() - v['script_version']='2026.09.20.5' - p.write_text(json.dumps(v,indent=2)+'\n',encoding='utf-8') - PY - python3 tools/generate.py - python3 tools/generate.py --check - python3 tests/test_installers.py - python3 tests/test_official_policy.py - python3 tests/test_library_loader.py - shellcheck *.sh - git add versions.json '*.sh' '*.ps1' - git commit -m 'fix: pin generated installers to corrected distribution detection' - python3 - <<'PY' - import re, subprocess - from pathlib import Path - p=Path('tools/generate_menus.py') - sha=subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip() - text,count=re.subn(r"revision='[0-9a-f]{40}'",f"revision='{sha}'",p.read_text(encoding='utf-8')) - assert count==1 - p.write_text(text,encoding='utf-8') - PY - python3 tools/generate_menus.py - python3 tools/generate_menus.py --check - python3 tests/test_catalog.py - git add tools/generate_menus.py menu.sh menu.ps1 - git commit -m 'fix: update seven-tool menu to tested installer revision' - git push origin HEAD:codex/official-installers-20260920 From a193f70c0d405100f419572bba33ef3df27f1e88 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Sun, 20 Sep 2026 18:54:58 +0800 Subject: [PATCH 35/39] docs: simplify seven-tool usage and distinguish upstream PATH policies --- README.md | 132 +++++++++++++++++++----------------------------------- 1 file changed, 45 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 7388a2f..ef78e72 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # LMM 安装脚本 -安装 Pi、DeepSeek Harness(DSH)及其 LMM 插件,也可安装 LMM CLI 预览版。 +Pi、DSH、LMM CLI、Codex、Claude Code、CC Switch、Clash Verge Rev 的安装入口。 -## 安装 +## 菜单 -Linux / macOS: +Linux / macOS / Termux: ```sh curl -fsSL https://api.lmm.best/scripts/menu.sh | bash @@ -16,116 +16,74 @@ Windows PowerShell 5.1+: irm https://api.lmm.best/scripts/menu.ps1 | iex ``` -选择工具,再选择安装、检查或启动。默认不改 PATH、不启动工具、不登录账号。菜单入口执行远程代码;需要先审查时,下载脚本后再运行。 +选择工具,再选安装、更新、检查或启动。默认不启动应用、不登录账号。Pi/DSH/LMM 默认不改 PATH;新增工具使用官方安装位置和更新策略,官方安装器可能修改用户 PATH。桌面软件可能要求管理员授权,不自动配置订阅、启用代理或关闭系统安全提示。 -Pi 在 Windows 上需要 Git Bash,或 Pi 设置中的有效 `shellPath`。脚本只检查,不覆盖设置,也不自动安装系统软件。 +菜单会执行下载的代码。需要先审查时,下载脚本后再运行。`bash menu.sh --list` / `.\menu.ps1 -List` 只列出工具。 -## Termux(原生 Android) +## 工具与平台 -先准备 Termux 自己的依赖: +脚本文件名如下,Unix 使用 `.sh`,Windows 使用 `.ps1`。 -```sh -pkg install bash curl coreutils nodejs npm git -curl -fsSL https://api.lmm.best/scripts/menu.sh | bash -``` - -安装目录保持在 `$HOME`。脚本确认 Node 是 Android 版本,不下载桌面 Linux 二进制。安装、缓存和临时目录不能放在 `/sdcard`、`/storage`,包括指向共享存储的链接。未设置 `TMPDIR` 时使用 `$PREFIX/tmp`;启动器使用当前 Bash 的绝对路径和明确的 Node 入口。 +| 文件名 | 安装方式 | 平台说明 | +|---|---|---| +| `pi` | 固定 npm 版本及 LMM 插件 | Linux、macOS、Windows、原生 Termux;Windows 需要 Bash | +| `dsh` | 固定 npm 版本及 profile 内的 LMM 插件 | Linux、macOS、Windows;Android 原生依赖未验证 | +| `lmm` | 固定预编译包;可显式从源码构建 | Linux x64、macOS arm64、Windows x64;开发预览,无 Android 包 | +| `codex` | 官方原生安装器,默认 latest | Linux、macOS、Windows;Termux 走 PRoot Linux | +| `claude-code` | 官方原生安装器,默认 stable | Linux、macOS、Windows;Termux 走 PRoot Linux | +| `cc-switch` | deb/rpm、现有 AUR helper、AppImage;macOS Homebrew/DMG;Windows portable ZIP | 桌面平台;Termux 不显示 | +| `clash-verge-rev` | deb/rpm、现有 AUR helper;macOS Homebrew/DMG;Windows 官方安装窗口 | 桌面平台;不提供 AppImage 路径,Termux 不显示 | -文本剪贴板另需 Termux:API 应用和 `pkg install termux-api`,不作为强制依赖。浏览器未打开时可用 `termux-open-url` 打开登录地址。脚本不申请存储权限、不清空用户缓存、不执行系统升级。 +Codex、Claude 原生安装不需要 Node。Linux 的依赖准备覆盖 Debian/Ubuntu、Fedora/RHEL、openSUSE、Arch、Alpine、Void;只有显式传 `--install-deps` 才安装系统依赖,不执行整机升级。Alpine 的 Claude 另需 `libgcc libstdc++ ripgrep`,启动器保留 `USE_BUILTIN_RIPGREP=0`。NixOS 需使用自身的 Nix 包环境,不支持直接套用通用二进制安装器。 -Pi 的安装方式遵循官方 Termux 文档。DSH 的 Android 原生依赖、LMM CLI 的 Android 源码构建尚未经真机验证;LMM CLI 没有 Android 预编译包。环境模拟测试不等于真机验证。 +官方依据和具体限制见 [安装方式核查](docs/install-sources.md)。模拟测试通过不代表所有发行版、硬件和图形环境都已实测。 -## 新增工具 +## 直接运行 -菜单也提供 Codex、Claude Code、CC Switch 和 Clash Verge Rev。文件名分别为 `codex`、`claude-code`、`cc-switch`、`clash-verge-rev`,后缀按系统选择 `.sh` / `.ps1`。 +先下载对应脚本,或在仓库目录执行: ```sh -bash codex.sh --dry-run # 查看安装方式,不安装 -bash codex.sh # 使用官方安装器 -bash claude-code.sh --update # 官方 stable;--version latest 可改通道 -bash claude-code.sh --install-deps # 明确允许安装当前发行版的依赖 +bash codex.sh --dry-run # 预览安装方式 +bash codex.sh # 安装;已有入口则复用 +bash codex.sh --check # 检查可执行程序 +bash codex.sh --update # 再次运行官方安装器 +bash claude-code.sh --version latest +bash claude-code.sh --install-deps ``` -Windows 对应 `-DryRun`、`-Update`、`-Version`。Codex/Claude 使用上游原生安装目录、PATH 和自动更新策略,不强塞到 LMM 独立 npm 目录;无需 Node。安装不会替你登录、修改模型供应商或关闭沙箱。 - -Linux CLI 根据 libc 使用官方安装器,依赖命令覆盖 Debian/Ubuntu、Fedora/RHEL、openSUSE、Arch、Alpine、Void;不执行系统升级。Alpine 的 Claude 需要 `libgcc libstdc++ ripgrep`;NixOS 需自行使用 Nix 包环境,不声称通用二进制可直接运行。 - -Termux 的这两个 CLI 使用已有的 PRoot Linux guest,不是原生 Android 安装。先执行 `pkg install proot-distro`、`proot-distro install ubuntu:24.04`,再运行 `bash codex.sh --install-deps`;另一个 guest 用 `--distro NAME`。安装器不创建/重置 guest;默认 Ubuntu 的依赖可由 `--install-deps` 准备。启动器绑定当前工作目录,参数原样转发;PRoot 不等于完整 Linux 沙箱,真机尚未验证。 +Windows 对应 `-DryRun`、`-Check`、`-Update`、`-Version`,例如 `powershell -ExecutionPolicy Bypass -File .\codex.ps1 -Check`。 -桌面工具在 Termux 菜单中隐藏。Linux 使用 deb/rpm、现有 AUR helper;CC Switch 另有 AppImage,Clash Verge Rev 不假设存在 AppImage。macOS 优先复用 Homebrew,否则安装官方 DMG;Windows 分别使用官方 portable ZIP、官方安装窗口。桌面安装可能要求管理员授权,Clash 安装包可能带服务;脚本不自动启用代理、TUN、订阅,也不绕过系统签名提示。 +四个新增工具均支持 `--launch` / `-Launch`、`--root` / `-Root`。`--root` 管理本站创建的辅助启动器和便携版,不改变官方原生客户端的安装目录。`--dry-run` 不安装,但默认仍会获取公共函数。桌面工具的检查只确认入口存在,不自动启动图形界面。 -来源和支持边界见 [安装核查](docs/install-sources.md)。 +Pi/DSH/LMM 的原有参数保持不变:`--check`、`--update`、`--launch`、`--add-path`、`--network auto|official|china`。其更新重装 [versions.json](versions.json) 固定版本,不追踪 latest;宿主与 LMM 插件需要一起验证后升级。全部参数见各脚本的 `--help` / `-Help`。 -## 安装后怎么用 +## Termux -没有加入 PATH 时,用安装结束打印的完整路径代替下方的工具名。 +先准备基础工具:`pkg install bash curl coreutils`。安装目录、缓存和临时文件留在私有目录,不放到 `/sdcard` 或 `/storage`。未设置 `TMPDIR` 时使用 `$PREFIX/tmp`,启动器使用当前 Bash 的绝对路径。 -| 工具 | 启动与登录 | 常用操作 | -|---|---|---| -| Pi | `pi` → `/login` → LMM → 浏览器授权 | `/model` 选模型;`pi -c` 继续会话;`pi list` 查看插件 | -| DSH | `dsh web` → Settings → Models → LMM → Sign in with LMM | `dsh web --no-open` 不自动开浏览器;其他参数见 `dsh --help` | -| LMM CLI | `lmm login` | `lmm catalog pi`、`lmm status`、`lmm doctor --report`、`lmm models --json` | - -DSH 插件按 profile 安装,默认 `web`。使用 `headless` 前,先在相同 `DSH_HOME` 的 Web profile 完成登录。不要把 Pi、DSH 的凭据文件复制给其他客户端。 +Pi 另需 `pkg install nodejs npm git`;脚本检查 Android 原生 Node,不下载桌面 Linux Node。剪贴板可选 Termux:API 应用和 `pkg install termux-api`。浏览器未打开时用 `termux-open-url` 打开登录地址。 -LMM CLI 的实际软件安装、接入、恢复尚未完成;`lmm setup pi --dry-run` 仅预览。`doctor` / `setup --dry-run` 返回 3 时不代表全部成功。Linux 登录需要可用的 Secret Service,SSH 或容器中不一定具备。 - -## 直接运行与更新 +Codex、Claude 使用已有的 PRoot guest: ```sh -curl -fsSLo pi.sh https://api.lmm.best/scripts/pi.sh -bash pi.sh # 安装 -bash pi.sh --check # 只检查,不代表登录成功 -bash pi.sh --update # 重装固定版本,不追踪 latest -bash pi.sh --launch # 安装后启动 -bash pi.sh --add-path # 明确允许加入用户 PATH -bash pi.sh --network china # 镜像优先;官方源可用 official -bash pi.sh --help # 全部参数 -``` - -Windows 对应参数为 `-Check`、`-Update`、`-Launch`、`-AddPath`、`-Network china`、`-Help`: - -```powershell -Invoke-WebRequest https://api.lmm.best/scripts/pi.ps1 -OutFile pi.ps1 -powershell -ExecutionPolicy Bypass -File .\pi.ps1 -powershell -ExecutionPolicy Bypass -File .\pi.ps1 -Check +pkg install proot-distro +proot-distro install ubuntu:24.04 +bash codex.sh --install-deps +bash claude-code.sh --install-deps ``` -安装 DSH 或 LMM CLI 时,把文件名中的 `pi` 换成 `dsh` 或 `lmm`。DSH 可选 `--profile headless` / `-Profile headless`。固定版本见 [versions.json](versions.json),宿主与插件须一起验证后升级。 - -## 公共函数加载 - -默认从 `versions.json` 的 `library_revision` 获取 GitHub 公共模块。Shell 完整获取文件后通过 `source <(...)` 导入;PowerShell 使用对应的点导入。没有公共模块哈希清单,不内嵌另一套备用库。下载失败就停止,不执行部分响应。 - -`--help` / `-Help` 不联网。`--check` / `-Check` 不修改安装文件,但默认需要联网加载公共模块。断网或调试时,明确指定同版本的本地公共目录: - -```sh -LMM_LIB_DIR="$PWD/templates/lib" bash pi.sh --check -``` - -```powershell -$env:LMM_LIB_DIR = Join-Path $PWD 'templates/lib' -.\pi.ps1 -Check -``` - -本地目录缺少模块时直接报错,不偷偷转为联网。这里只控制公共函数的来源;安装客户端仍可能需要下载软件包。`--network` 控制软件包来源,不改变公共模块的 GitHub 地址。客户端安装完成后的启动入口不需要重新获取这些模块。 - -## 环境与故障 - -Pi/DSH/LMM 的默认目录:Unix 为 `${XDG_DATA_HOME:-~/.local/share}/lmm-tools`,Windows 为 `%LOCALAPPDATA%\lmm-tools`;可用 `LMM_INSTALL_ROOT` 或 `--root` / `-Root` 修改。不覆盖系统 Node 或全局 npm 包。 - -Pi 使用官方的 `npm install --ignore-scripts`,接受已有的 `ignore-scripts=true`。DSH 的原生构建策略单独处理,不解除用户的构建限制。Node 要求为 22.19+ 的 22.x 或 24+。 +默认 guest 名称为 `ubuntu`,其他已安装环境用 `--distro NAME`。脚本不创建或重置 guest;`--install-deps` 仅自动准备 Debian/Ubuntu guest 的依赖。启动器把当前目录绑定到 guest 的 `/workspace`,原样转发参数。PRoot 不是独立 Linux 内核,不保证所有沙箱能力可用;没有关闭 Agent 沙箱作为替代。 -下载失败时检查 HTTPS 代理和证书,尝试 `--network official` 或 `china`,不要关闭 TLS 校验。Alpine / musl 需要先安装系统提供的兼容 Node/npm。 +Android 真机、DSH Android 原生依赖和 LMM CLI Android 源码构建尚未验证。CC Switch、Clash Verge Rev 在 Termux 菜单中隐藏。 -LMM CLI 预编译包仅提供 Linux x64(glibc 2.39+)、macOS arm64、Windows x64。其他平台可在准备 Rust 1.88+ 和编译工具后尝试 `--from-source`,不保证所有平台都能构建。 +## 使用与维护 -公共函数、生成方式、缓存、PATH 恢复和卸载注意事项见 [维护说明](docs/maintenance.md)。安装器运行时从固定 Git 提交加载公共函数,不再把它们复制进每个发布脚本。 +Pi:启动后 `/login` → LMM → 浏览器授权,再用 `/model` 选模型。DSH:`dsh web` → Settings → Models → LMM;headless 登录先在同一 `DSH_HOME` 的 Web profile 完成。Codex、Claude 按各自的官方登录提示操作,安装器不代填账号或模型配置。 -## 文档依据 +LMM CLI 目前提供 `catalog`、`status`、`doctor --report`、`login`、`models --json`。`setup --dry-run` 只预览,不执行软件接入;退出码 3 不表示全部成功。Linux 登录需要可用的 Secret Service,SSH/容器不一定具备。 -[Pi 安装](https://pi.dev/docs/latest/quickstart) · [Windows](https://pi.dev/docs/latest/windows) · [Termux](https://pi.dev/docs/latest/termux) · [Pi 包管理](https://pi.dev/docs/latest/packages) · [DSH 官方 README](https://github.com/deepseek-ai/deepseek-harness/blob/master/README.md) +Pi/DSH/LMM 默认目录为 `${XDG_DATA_HOME:-~/.local/share}/lmm-tools` 或 `%LOCALAPPDATA%\lmm-tools`,不覆盖系统 Node/npm 包。没有加入 PATH 时使用安装结束打印的完整路径。 -[Termux 执行环境](https://github.com/termux/termux-packages/wiki/Termux-execution-environment) · [Termux Node/npm 包定义](https://github.com/termux/termux-packages/blob/master/packages/nodejs/build.sh) · [LMM Pi 插件](https://github.com/TokenNotIncluded/pi-lmm-provider) · [LMM DSH 插件](https://github.com/TokenNotIncluded/dsh-lmm-provider) +公共函数从固定 GitHub 提交加载,不再内嵌到每个安装器,也不另设公共库哈希清单。本地开发可设 `LMM_LIB_DIR="$PWD/templates/lib"`;帮助页不联网,检查模式可能获取公共函数。下载失败会停止,不把半个文件当作成功安装。 -隔离目录、镜像、固定版本和 LMM 登录是本项目的集成选择,不是官方安装器。 +网络源、生成、缓存恢复、PATH 和卸载说明见 [维护文档](docs/maintenance.md)。 From 7a42eebbdf13cb350f25aca8c466b1ec8964df15 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 21 Sep 2026 01:13:17 +0800 Subject: [PATCH 36/39] fix: retain process-group cleanup on timeout and cancellation Keep the SIGKILL grace timer referenced after the direct child exits. Preserve cancellation exit codes and do not retry a cancelled install. Add real Unix process-tree, cleanup-order and registry-retry regressions. Fixes #2 --- .github/workflows/_review-pins.yml | 46 +++++++ .github/workflows/test.yml | 1 + templates/lib/node.sh | 59 ++++++-- tests/test_bounded.py | 213 +++++++++++++++++++++++++++++ 4 files changed, 308 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/_review-pins.yml create mode 100644 tests/test_bounded.py diff --git a/.github/workflows/_review-pins.yml b/.github/workflows/_review-pins.yml new file mode 100644 index 0000000..21a94a5 --- /dev/null +++ b/.github/workflows/_review-pins.yml @@ -0,0 +1,46 @@ +name: Pin reviewed process cleanup +on: + push: + branches: [codex/official-installers-20260920] + paths: [.github/workflows/_review-pins.yml] +permissions: + contents: write +jobs: + regenerate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + - name: Generate pinned installers and menus + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + python3 - <<'PY' + import json, subprocess + from pathlib import Path + path = Path('versions.json') + versions = json.loads(path.read_text(encoding='utf-8')) + versions['library_revision'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + versions['script_version'] = '2026.09.21.1' + path.write_text(json.dumps(versions, indent=2) + '\n', encoding='utf-8') + PY + python3 tools/generate.py + python3 tools/generate.py --check + git add versions.json '*.sh' '*.ps1' + git commit -m 'fix: pin installers to reviewed process-group cleanup' + python3 - <<'PY' + import re, subprocess + from pathlib import Path + path = Path('tools/generate_menus.py') + revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + text, count = re.subn(r"revision='[0-9a-f]{40}'", f"revision='{revision}'", path.read_text(encoding='utf-8')) + assert count == 1 + path.write_text(text, encoding='utf-8') + PY + python3 tools/generate_menus.py + python3 tools/generate_menus.py --check + git add tools/generate_menus.py menu.sh menu.ps1 + git commit -m 'fix: point menus at process-cleanup regression fix' + git push origin HEAD:codex/official-installers-20260920 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 518247a..ac9b45f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,7 @@ jobs: python3 tests/test_installers.py python3 tests/test_official_policy.py python3 tests/test_library_loader.py + python3 tests/test_bounded.py - name: ShellCheck if: runner.os == 'Linux' run: shellcheck *.sh diff --git a/templates/lib/node.sh b/templates/lib/node.sh index c59922a..52bf46c 100644 --- a/templates/lib/node.sh +++ b/templates/lib/node.sh @@ -51,20 +51,57 @@ configure_npm() { } bounded() { node - "$COMMAND_TIMEOUT" "$@" <<'JS' -const {spawn}=require('node:child_process'); -const [seconds,command,...args]=process.argv.slice(2); -const child=spawn(command,args,{stdio:'inherit',detached:true}); -let timedOut=false,stopping=false; -function stop(code){if(stopping)return;stopping=true;timedOut=code===124;try{process.kill(-child.pid,'SIGTERM')}catch{};const hard=setTimeout(()=>{try{process.kill(-child.pid,'SIGKILL')}catch{}},3000);hard.unref()} -const start=Date.now();const heartbeat=setInterval(()=>process.stderr.write(`[install] Still working: ${Math.floor((Date.now()-start)/1000)}s elapsed.\n`),15000); -const timeout=setTimeout(()=>{process.stderr.write('[install] Operation timed out; increase LMM_COMMAND_TIMEOUT for a slow connection.\n');stop(124)},Number(seconds)*1000); -process.on('SIGINT',()=>stop(130));process.on('SIGTERM',()=>stop(143)); -child.on('error',e=>{clearInterval(heartbeat);clearTimeout(timeout);process.stderr.write(`[install] Cannot start ${command}: ${e.code}\n`);process.exitCode=1}); -child.on('exit',(code,signal)=>{clearInterval(heartbeat);clearTimeout(timeout);process.exitCode=timedOut?124:signal?130:code??1}); +const {spawn} = require('node:child_process'); +const {signals} = require('node:os').constants; +const [seconds, command, ...args] = process.argv.slice(2); +const child = spawn(command, args, {stdio: 'inherit', detached: true}); +let stopCode; +const start = Date.now(); +const heartbeat = setInterval(() => { + process.stderr.write(`[install] Still working: ${Math.floor((Date.now() - start) / 1000)}s elapsed.\n`); +}, 15000); +const timeout = setTimeout(() => { + process.stderr.write('[install] Operation timed out; increase LMM_COMMAND_TIMEOUT for a slow connection.\n'); + stop(124); +}, Number(seconds) * 1000); +function clearTimers() { + clearInterval(heartbeat); + clearTimeout(timeout); +} +function signalGroup(signal) { + if (!child.pid) return; + try { process.kill(-child.pid, signal); } + catch (error) { + if (error.code !== 'ESRCH') process.stderr.write(`[install] Cannot send ${signal}: ${error.code}\n`); + } +} +function stop(code) { + if (stopCode !== undefined) return; + stopCode = code; + process.exitCode = code; + clearTimers(); + signalGroup('SIGTERM'); + // Keep this timer referenced: the leader can exit while descendants survive. + if (child.pid) setTimeout(() => signalGroup('SIGKILL'), 3000); +} +process.on('SIGINT', () => stop(130)); +process.on('SIGTERM', () => stop(143)); +child.on('error', error => { + clearTimers(); + process.stderr.write(`[install] Cannot start ${command}: ${error.code}\n`); + process.exitCode = stopCode ?? 1; +}); +child.on('exit', (code, signal) => { + clearTimers(); + process.exitCode = stopCode ?? code ?? (signal ? 128 + signals[signal] : 1); +}); JS } with_registry_retry() { - if bounded "$@"; then return; fi + local status=0 + bounded "$@" || status=$? + # Cancellation is not a network failure. Preserve it without another install. + case "$status" in 0) return;; 130|143) return "$status";; esac if [ "$NETWORK" = auto ] && [ "$NPM_SELECTED" = 1 ]; then if [ "$npm_config_registry" = https://registry.npmjs.org/ ]; then export npm_config_registry=https://registry.npmmirror.com/; else export npm_config_registry=https://registry.npmjs.org/; fi log 'Retrying the alternate registry with the same package cache.' diff --git a/tests/test_bounded.py b/tests/test_bounded.py new file mode 100644 index 0000000..e3f06ee --- /dev/null +++ b/tests/test_bounded.py @@ -0,0 +1,213 @@ +"""Exercise the actual Bash wrapper with real Unix process groups, without HTTP.""" +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import unittest + +ROOT = Path(__file__).resolve().parents[1] +LIBRARY = ROOT / 'templates/lib/node.sh' +FIXTURE = r''' +import json, os, signal, subprocess, sys, time +from pathlib import Path +base = Path(sys.argv[1]) +mode = sys.argv[2] +if mode == 'retry': + attempted = base / 'attempted' + if attempted.exists(): + pid = (base / 'worker').read_text() + state = subprocess.run(['ps', '-o', 'stat=', '-p', pid], text=True, capture_output=True).stdout.strip() + sys.exit(88 if state and not state.startswith('Z') else 0) + attempted.touch() + mode = 'orphan' +if mode == 'worker': + signal.signal(signal.SIGTERM, signal.SIG_IGN) + signal.signal(signal.SIGINT, signal.SIG_IGN) + (base / 'worker').write_text(str(os.getpid())) + while True: + if not (base / 'stage').exists() or not (base / '.setup-lock').exists(): + (base / 'early-cleanup').touch() + time.sleep(.02) +if mode == 'ignore': + signal.signal(signal.SIGTERM, signal.SIG_IGN) +if mode == 'cooperative': + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) +(base / 'leader').write_text(json.dumps({'pid': os.getpid(), 'wrapper': os.getppid()})) +if mode in ('orphan', 'cooperative'): + subprocess.Popen([sys.executable, __file__, str(base), 'worker'], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +while True: + time.sleep(.02) +''' +HARNESS = r''' +set -euo pipefail +source "$1/templates/lib/node.sh" +source "$1/templates/lib/lifecycle.sh" +shift +ROOT=$1; shift +STAGE=$ROOT/stage LOCKED=1 PHASE=test +mkdir -p "$STAGE" "$ROOT/.setup-lock" +printf '%s\n' "$$" > "$ROOT/.setup-lock/pid" +log() { printf '%s\n' "$*" >&2; } +fail() { log "$*"; exit 1; } +trap cleanup EXIT +if [ "${TEST_RETRY:-0}" = 1 ]; then + NETWORK=auto NPM_SELECTED=1 npm_config_registry=https://registry.npmjs.org/ + with_registry_retry "$@" +else + bounded "$@" +fi +''' + + +@unittest.skipUnless(os.name == 'posix' and shutil.which('bash') and shutil.which('node'), + 'Bash, Node and Unix process groups required') +class BoundedTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) + self.fixture = self.base / 'build fixture.py' + self.fixture.write_text(FIXTURE, encoding='utf-8') + self.process = None + + def tearDown(self): + # Every test owns a separate detached group; never leak even on failure. + leader = self.base / 'leader' + if leader.exists(): + try: + os.killpg(json.loads(leader.read_text())['pid'], signal.SIGKILL) + except ProcessLookupError: + pass + if self.process: + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.process.communicate(timeout=5) + self.tmp.cleanup() + + def start(self, *command, seconds=1, retry=False): + self.started = time.monotonic() + self.process = subprocess.Popen( + ['bash', '-c', HARNESS, 'bounded-test', str(ROOT), str(self.base), *command], + env=dict(os.environ, COMMAND_TIMEOUT=str(seconds), TEST_RETRY=str(int(retry))), + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) + + def wait_ready(self, name): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + path = self.base / name + if path.exists() and path.stat().st_size: + return path + if self.process.poll() is not None: + break + time.sleep(.02) + self.fail(f'Fixture never became ready: {name}') + + def finish(self, expected): + stdout, stderr = self.process.communicate(timeout=9) + self.assertEqual(self.process.returncode, expected, stdout + stderr) + self.assertFalse((self.base / '.setup-lock').exists()) + self.assertFalse((self.base / 'stage').exists()) + self.assertFalse((self.base / 'early-cleanup').exists(), 'cleanup ran while a worker was active') + return stderr + + def assert_stopped(self, pid): + # Orphans can briefly remain zombies until init reaps them. + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + state = subprocess.run(['ps', '-o', 'stat=', '-p', str(pid)], + text=True, capture_output=True, check=False).stdout.strip() + if not state or state.startswith('Z'): + return + time.sleep(.02) + self.fail(f'Process {pid} still running after wrapper returned: {state}') + + def test_timeout_keeps_lock_and_stage_until_orphan_is_killed(self): + self.start(sys.executable, str(self.fixture), str(self.base), 'orphan') + worker = int(self.wait_ready('worker').read_text()) + time.sleep(max(0, self.started + 1.5 - time.monotonic())) + self.assertIsNone(self.process.poll(), 'wrapper exited before its hard-kill deadline') + self.assertTrue((self.base / '.setup-lock').is_dir()) + self.assertTrue((self.base / 'stage').is_dir()) + self.assertIn('timed out', self.finish(124)) + self.assert_stopped(worker) + + def test_timeout_kills_leader_ignoring_sigterm(self): + self.start(sys.executable, str(self.fixture), str(self.base), 'ignore') + leader = json.loads(self.wait_ready('leader').read_text())['pid'] + self.finish(124) + self.assert_stopped(leader) + + def test_normal_exit_has_no_grace_delay(self): + for code in (0, 7): + with self.subTest(code=code): + self.start(sys.executable, '-c', f'raise SystemExit({code})', seconds=30) + self.finish(code) + self.assertLess(time.monotonic() - self.started, 3) + + def test_spawn_error_does_not_keep_timers_alive(self): + self.start(str(self.base / 'missing-command'), seconds=30) + self.assertIn('ENOENT', self.finish(1)) + self.assertLess(time.monotonic() - self.started, 3) + + def test_already_exited_group_preserves_signal_status(self): + self.start(sys.executable, '-c', 'import os,signal; os.kill(os.getpid(),signal.SIGTERM)', seconds=30) + self.finish(143) + self.assertLess(time.monotonic() - self.started, 3) + + def cancel(self, signum, expected, mode='orphan', repeated=False): + self.start(sys.executable, str(self.fixture), str(self.base), mode, seconds=2) + worker = int(self.wait_ready('worker').read_text()) + wrapper = json.loads((self.base / 'leader').read_text())['wrapper'] + os.kill(wrapper, signum) + if repeated: + time.sleep(.1) + os.kill(wrapper, signal.SIGTERM) + stderr = self.finish(expected) + self.assertNotIn('timed out', stderr, 'cancellation must clear the original deadline') + self.assert_stopped(worker) + + def test_sigint_waits_for_descendants(self): + self.cancel(signal.SIGINT, 130) + + def test_sigterm_preserved_when_child_exits_successfully(self): + self.cancel(signal.SIGTERM, 143, mode='cooperative') + + def test_repeated_cancel_preserves_first_reason(self): + self.cancel(signal.SIGINT, 130, repeated=True) + + def test_cancellation_does_not_retry_registry(self): + for code in (130, 143): + for network in ('auto', 'official'): + with self.subTest(code=code, network=network): + calls = self.base / 'calls' + calls.write_text('') + script = r''' +set -euo pipefail +source "$1" +bounded() { printf 'attempt\n' >> "$2"; return "$1"; } +log() { :; } +fail() { exit 1; } +NETWORK=$4 NPM_SELECTED=1 npm_config_registry=https://registry.npmjs.org/ +with_registry_retry "$2" "$3" +''' + result = subprocess.run(['bash', '-c', script, 'retry-test', str(LIBRARY), str(code), str(calls), network], + text=True, capture_output=True, timeout=5) + self.assertEqual(result.returncode, code, result.stderr) + self.assertEqual(calls.read_text().splitlines(), ['attempt']) + + def test_registry_retry_waits_for_timed_out_group(self): + self.start(sys.executable, str(self.fixture), str(self.base), 'retry', retry=True) + worker = int(self.wait_ready('worker').read_text()) + self.finish(0) + self.assert_stopped(worker) + + +if __name__ == '__main__': + unittest.main(verbosity=2) From b715e644bb665d841f59e063e14b0fc81c6d72bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:13:36 +0000 Subject: [PATCH 37/39] fix: pin installers to reviewed process-group cleanup --- cc-switch.ps1 | 2 +- cc-switch.sh | 2 +- clash-verge-rev.ps1 | 2 +- clash-verge-rev.sh | 2 +- claude-code.ps1 | 2 +- claude-code.sh | 2 +- codex.ps1 | 2 +- codex.sh | 2 +- dsh.ps1 | 4 ++-- dsh.sh | 4 ++-- lmm.ps1 | 4 ++-- lmm.sh | 4 ++-- pi.ps1 | 4 ++-- pi.sh | 4 ++-- versions.json | 4 ++-- 15 files changed, 22 insertions(+), 22 deletions(-) diff --git a/cc-switch.ps1 b/cc-switch.ps1 index 836bb66..bcd2a0c 100644 --- a/cc-switch.ps1 +++ b/cc-switch.ps1 @@ -5,7 +5,7 @@ param([string]$Root='', [string]$Version='', [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) $ErrorActionPreference='Stop' $Target='cc-switch' -$LibRevision='cced5d9313020a87b10774a1445d5e44a973a7fa' +$LibRevision='7a42eebbdf13cb350f25aca8c466b1ec8964df15' if ($Help) { Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." exit 0 diff --git a/cc-switch.sh b/cc-switch.sh index 9f03e32..e2820c5 100755 --- a/cc-switch.sh +++ b/cc-switch.sh @@ -3,7 +3,7 @@ lmm_entry() { set -euo pipefail # shellcheck disable=SC2034 TARGET=cc-switch -LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa +LIB_REVISION=7a42eebbdf13cb350f25aca8c466b1ec8964df15 for arg in "$@"; do case "$arg" in --) break;; --help|-h) printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' diff --git a/clash-verge-rev.ps1 b/clash-verge-rev.ps1 index 8211892..8abed87 100644 --- a/clash-verge-rev.ps1 +++ b/clash-verge-rev.ps1 @@ -5,7 +5,7 @@ param([string]$Root='', [string]$Version='', [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) $ErrorActionPreference='Stop' $Target='clash-verge-rev' -$LibRevision='cced5d9313020a87b10774a1445d5e44a973a7fa' +$LibRevision='7a42eebbdf13cb350f25aca8c466b1ec8964df15' if ($Help) { Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." exit 0 diff --git a/clash-verge-rev.sh b/clash-verge-rev.sh index 41e52d4..184fbbb 100755 --- a/clash-verge-rev.sh +++ b/clash-verge-rev.sh @@ -3,7 +3,7 @@ lmm_entry() { set -euo pipefail # shellcheck disable=SC2034 TARGET=clash-verge-rev -LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa +LIB_REVISION=7a42eebbdf13cb350f25aca8c466b1ec8964df15 for arg in "$@"; do case "$arg" in --) break;; --help|-h) printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' diff --git a/claude-code.ps1 b/claude-code.ps1 index 72ab630..09cd827 100644 --- a/claude-code.ps1 +++ b/claude-code.ps1 @@ -5,7 +5,7 @@ param([string]$Root='', [string]$Version='', [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) $ErrorActionPreference='Stop' $Target='claude-code' -$LibRevision='cced5d9313020a87b10774a1445d5e44a973a7fa' +$LibRevision='7a42eebbdf13cb350f25aca8c466b1ec8964df15' if ($Help) { Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." exit 0 diff --git a/claude-code.sh b/claude-code.sh index db39ba1..95ba0b4 100755 --- a/claude-code.sh +++ b/claude-code.sh @@ -3,7 +3,7 @@ lmm_entry() { set -euo pipefail # shellcheck disable=SC2034 TARGET=claude-code -LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa +LIB_REVISION=7a42eebbdf13cb350f25aca8c466b1ec8964df15 for arg in "$@"; do case "$arg" in --) break;; --help|-h) printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' diff --git a/codex.ps1 b/codex.ps1 index 359ce80..d41b8d3 100644 --- a/codex.ps1 +++ b/codex.ps1 @@ -5,7 +5,7 @@ param([string]$Root='', [string]$Version='', [Parameter(ValueFromRemainingArguments=$true)][string[]]$RunArgs=@()) $ErrorActionPreference='Stop' $Target='codex' -$LibRevision='cced5d9313020a87b10774a1445d5e44a973a7fa' +$LibRevision='7a42eebbdf13cb350f25aca8c466b1ec8964df15' if ($Help) { Write-Output "Install $Target. Options: -Check -Update -Launch -DryRun -Root PATH -Version VERSION -Network official. Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers." exit 0 diff --git a/codex.sh b/codex.sh index 30941ed..3b3108b 100755 --- a/codex.sh +++ b/codex.sh @@ -3,7 +3,7 @@ lmm_entry() { set -euo pipefail # shellcheck disable=SC2034 TARGET=codex -LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa +LIB_REVISION=7a42eebbdf13cb350f25aca8c466b1ec8964df15 for arg in "$@"; do case "$arg" in --) break;; --help|-h) printf '%s\n' "Install $TARGET" 'Options: --check --update --launch --dry-run --install-deps' ' --root PATH --version VERSION --network auto|official|china' ' --distro NAME (Termux Linux guest; default ubuntu) -- [launch arguments]' 'Uses upstream locations and update policies. LMM_LIB_DIR selects local helpers.' diff --git a/dsh.ps1 b/dsh.ps1 index 10d84ba..f8b29a5 100644 --- a/dsh.ps1 +++ b/dsh.ps1 @@ -12,8 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'dsh' -$ScriptVersion = '2026.09.20.5' -$LibRevision = 'cced5d9313020a87b10774a1445d5e44a973a7fa' +$ScriptVersion = '2026.09.21.1' +$LibRevision = '7a42eebbdf13cb350f25aca8c466b1ec8964df15' $NodeVersion = '24.21.0' $PnpmVersion = '11.7.0' $DshVersion = '0.1.5-rc.2' diff --git a/dsh.sh b/dsh.sh index 2ef67cb..ffd1f96 100755 --- a/dsh.sh +++ b/dsh.sh @@ -6,8 +6,8 @@ lmm_install_main() { set -euo pipefail set +x TARGET=dsh -SCRIPT_VERSION=2026.09.20.5 -LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa +SCRIPT_VERSION=2026.09.21.1 +LIB_REVISION=7a42eebbdf13cb350f25aca8c466b1ec8964df15 NODE_VERSION=24.21.0 PNPM_VERSION=11.7.0 DSH_VERSION=0.1.5-rc.2 diff --git a/lmm.ps1 b/lmm.ps1 index d332b29..4aedd6d 100644 --- a/lmm.ps1 +++ b/lmm.ps1 @@ -12,8 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'lmm' -$ScriptVersion = '2026.09.20.5' -$LibRevision = 'cced5d9313020a87b10774a1445d5e44a973a7fa' +$ScriptVersion = '2026.09.21.1' +$LibRevision = '7a42eebbdf13cb350f25aca8c466b1ec8964df15' $LmmVersion = '0.1.0' $LmmReleaseBase = 'https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0' $LmmHashes = @{ diff --git a/lmm.sh b/lmm.sh index ff7867d..7e986bc 100755 --- a/lmm.sh +++ b/lmm.sh @@ -6,8 +6,8 @@ lmm_install_main() { set -euo pipefail set +x TARGET=lmm -SCRIPT_VERSION=2026.09.20.5 -LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa +SCRIPT_VERSION=2026.09.21.1 +LIB_REVISION=7a42eebbdf13cb350f25aca8c466b1ec8964df15 LMM_VERSION=0.1.0 LMM_RELEASE_BASE=https://github.com/TokenNotIncluded/api.lmm.best/releases/download/lmm-cli-v0.1.0 lmm_hash() { case "$1" in diff --git a/pi.ps1 b/pi.ps1 index 6aafb41..9f393e2 100644 --- a/pi.ps1 +++ b/pi.ps1 @@ -12,8 +12,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'pi' -$ScriptVersion = '2026.09.20.5' -$LibRevision = 'cced5d9313020a87b10774a1445d5e44a973a7fa' +$ScriptVersion = '2026.09.21.1' +$LibRevision = '7a42eebbdf13cb350f25aca8c466b1ec8964df15' $NodeVersion = '24.21.0' $PiVersion = '0.85.1' $PiProviderVersion = '0.1.0-alpha.1' diff --git a/pi.sh b/pi.sh index 01be85e..c7c400b 100755 --- a/pi.sh +++ b/pi.sh @@ -6,8 +6,8 @@ lmm_install_main() { set -euo pipefail set +x TARGET=pi -SCRIPT_VERSION=2026.09.20.5 -LIB_REVISION=cced5d9313020a87b10774a1445d5e44a973a7fa +SCRIPT_VERSION=2026.09.21.1 +LIB_REVISION=7a42eebbdf13cb350f25aca8c466b1ec8964df15 NODE_VERSION=24.21.0 PI_VERSION=0.85.1 PI_PROVIDER_VERSION=0.1.0-alpha.1 diff --git a/versions.json b/versions.json index eccd526..d508de3 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "script_version": "2026.09.20.5", + "script_version": "2026.09.21.1", "node_version": "24.21.0", "node_sha256": { "linux-x64": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", @@ -22,5 +22,5 @@ "win-x64": "d0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9" }, "pnpm_version": "11.7.0", - "library_revision": "cced5d9313020a87b10774a1445d5e44a973a7fa" + "library_revision": "7a42eebbdf13cb350f25aca8c466b1ec8964df15" } From 4e798c63206a341a512491eae17287f5d16dff63 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:13:36 +0000 Subject: [PATCH 38/39] fix: point menus at process-cleanup regression fix --- menu.ps1 | 16 ++++++++-------- menu.sh | 16 ++++++++-------- tools/generate_menus.py | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/menu.ps1 b/menu.ps1 index 25636b5..21d5e84 100644 --- a/menu.ps1 +++ b/menu.ps1 @@ -15,13 +15,13 @@ function Show-Tools { for ($i=0; $i -lt $tools.Count; $i++) { Write-Output "$($i if ($Help) { Write-Output 'LMM menu: .\menu.ps1 [-List]'; exit 0 } if ($List) { Show-Tools; exit 0 } $hashes=@{ - 'pi.ps1' = 'e64cfb40182d8edbf80c17a28c5ce3cc5e22c1179b52e94a8ea140996ab3de9b' - 'dsh.ps1' = 'a0c9ff55a3445bcacf35b64e6a1b96e65a28aebf45fd1c3c221a464efa0dedf7' - 'lmm.ps1' = '6df5d2ffe8ad8b65a1bcf60570f4eb086dbbd8c22f1f5dbc5e99359d97b61c0e' - 'codex.ps1' = '79ee4587b25a4e8b45bcf035a9bed1b8abee6d2f98552eb4defc11c183e642ab' - 'claude-code.ps1' = 'ecf5df65c96ba8c4f6265264d2ba381e942eb09fe262d769ab607d4c1d592ec6' - 'cc-switch.ps1' = '2f0270b3261d22f20d8f01fb4fde8c5d588aaa61499f5b3a89916727c7e742cc' - 'clash-verge-rev.ps1' = '0a3b5a66631082e96eeabdc8c9b242302e9bc1a42438f2517e2658d87fb814bf' + 'pi.ps1' = 'e18bdc5e94576fbfceac6757df1cb1a8825a1ba3620295e2aa4ff73149713fee' + 'dsh.ps1' = '5bfa7571b4ccb7898339553f8e8e251b2d90d9cd84851b456c6244c1abb4cc53' + 'lmm.ps1' = 'a96d8b558263af590de731969c7acce1983c0a980efa0f0e89fa1dfd5a8e37c9' + 'codex.ps1' = '03256493ee33ac2a7f3d982f4e321c422f016b474951a719dc06245631ca614e' + 'claude-code.ps1' = 'e4f838bb381955774082bc016cb454556ec54c1d71313625bac5494f5f61d11c' + 'cc-switch.ps1' = '3d0f93be88a551a13606c60507dc6319697a955b29c67d36e073137e4f15f993' + 'clash-verge-rev.ps1' = '5f9cc77d1be24825fa48a21ecd34da8084abaa75394b5630dd12ab9219d9ee07' 'lmm-use.ps1' = 'ac137e30b6609580cb6ee4fbb60ed77ed01ec6153b733140540d5bc38d9179c1' } $network='auto'; $root=$env:LMM_INSTALL_ROOT @@ -34,7 +34,7 @@ function Fetch-Script([string]$Name) { if (!$hashes.ContainsKey($Name)) { throw 'Unknown script' } $path=Join-Path $work $Name if ((Test-Path -LiteralPath $path) -and (Get-FileHash -LiteralPath $path).Hash -eq $hashes[$Name]) { return $path } - foreach ($url in @("https://api.lmm.best/scripts/$Name","https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/1d2fb99abe87d8af2cd3a17489cadd32cabcc189/$Name")) { + foreach ($url in @("https://api.lmm.best/scripts/$Name","https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/b715e644bb665d841f59e063e14b0fc81c6d72bc/$Name")) { for ($attempt=1; $attempt -le 3; $attempt++) { try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $path -TimeoutSec 120 diff --git a/menu.sh b/menu.sh index 4551f65..02904e3 100755 --- a/menu.sh +++ b/menu.sh @@ -56,13 +56,13 @@ esac if ! { exec 3/dev/null; then printf '需要交互终端;自动化请直接运行工具脚本。\n' >&2; exit 2; fi command -v curl >/dev/null 2>&1 || { printf '请先安装 curl。\n' >&2; exit 2; } expected_hash() { case "$1" in -pi.sh) printf '%s' '03ce0f2ebbe1b160017244b76287d378b18592622b40bee229772ebe90d254f9';; -dsh.sh) printf '%s' '45e6db16f0be8d468d99b2325d703a2fa21a1ee961a316f8056c4f3cab369130';; -lmm.sh) printf '%s' 'a8578cb820369034a7c28fa7da87a9ba51795d5cec75d48aa056c8c380f02819';; -codex.sh) printf '%s' '7e117367fd9c3a349e84aaab02d7398c9649f43d0dd03cb6133d562a8612b57d';; -claude-code.sh) printf '%s' 'a4df55371e625bea9f222f1135b1fe6e30536e4c5a5947948af7072040b712c9';; -cc-switch.sh) printf '%s' 'd97dc4cd1ec88ac48f283a717bb8caec58b42154772ac4e63a84da639a38ff77';; -clash-verge-rev.sh) printf '%s' '26d2efc3add8beec179ba9efee4a62c0e7d79e03539eca4e7168cd4f1674f9ca';; +pi.sh) printf '%s' 'da34fe287699808d1f1ebac0dbb4e16ddf91c7c32f351b89f6d9ba6fe9a57288';; +dsh.sh) printf '%s' '9198b7b0cea47a2fe26717ec42466ff332539dec617740ef400c21a6fe4c4807';; +lmm.sh) printf '%s' 'f8b5f76f16786c600fc1d0c67475d04527af4cdad2f246b205e5318e1ade6da9';; +codex.sh) printf '%s' '624bef260481f86fa87b4f2cbd072db5aedd611a418e8b981aca82e73dc80ec7';; +claude-code.sh) printf '%s' '616e5715fa11e029c1501d66058436f58cc79d92dd550018f46d6231a95ca555';; +cc-switch.sh) printf '%s' '1e52aa4be0d47af1d3ddb89cf85a643d373910da3eeb11aecb0389a67f6d0d97';; +clash-verge-rev.sh) printf '%s' '9f807a11bcf1271cf449889abf75a70389ef4cc0e6b1520cd9427cadf045a91b';; lmm-use.sh) printf '%s' '8f5cb27ef99bc3fd51a5b85e5aba0418f791e3cae03cd0ea624384b3dd50a06b';; *) return 1;; esac; } @@ -76,7 +76,7 @@ fetch_script() { local name=$1 expected url expected=$(expected_hash "$name") || return 1 if [ -f "$work/$name" ] && [ "$(sha256 "$work/$name")" = "$expected" ]; then return 0; fi - for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/1d2fb99abe87d8af2cd3a17489cadd32cabcc189/$name"; do + for url in "https://api.lmm.best/scripts/$name" "https://raw.githubusercontent.com/TokenNotIncluded/lmm-scripts/b715e644bb665d841f59e063e14b0fc81c6d72bc/$name"; do if curl -q -fsSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 --retry 2 "$url" -o "$work/download"; then if [ "$(sha256 "$work/download")" = "$expected" ]; then mv "$work/download" "$work/$name"; return 0; fi fi diff --git a/tools/generate_menus.py b/tools/generate_menus.py index 995cb2e..6581c61 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -7,7 +7,7 @@ from catalog import TOOLS from render import ROOT, emit, libraries, template -revision='1d2fb99abe87d8af2cd3a17489cadd32cabcc189' +revision='b715e644bb665d841f59e063e14b0fc81c6d72bc' def main(): From 90496c4d7d4d1ce9b72a230dea885d5d4e1806c9 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 21 Sep 2026 01:14:22 +0800 Subject: [PATCH 39/39] chore: remove completed review-pin generation workflow --- .github/workflows/_review-pins.yml | 46 ------------------------------ 1 file changed, 46 deletions(-) delete mode 100644 .github/workflows/_review-pins.yml diff --git a/.github/workflows/_review-pins.yml b/.github/workflows/_review-pins.yml deleted file mode 100644 index 21a94a5..0000000 --- a/.github/workflows/_review-pins.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Pin reviewed process cleanup -on: - push: - branches: [codex/official-installers-20260920] - paths: [.github/workflows/_review-pins.yml] -permissions: - contents: write -jobs: - regenerate: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - - name: Generate pinned installers and menus - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - python3 - <<'PY' - import json, subprocess - from pathlib import Path - path = Path('versions.json') - versions = json.loads(path.read_text(encoding='utf-8')) - versions['library_revision'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() - versions['script_version'] = '2026.09.21.1' - path.write_text(json.dumps(versions, indent=2) + '\n', encoding='utf-8') - PY - python3 tools/generate.py - python3 tools/generate.py --check - git add versions.json '*.sh' '*.ps1' - git commit -m 'fix: pin installers to reviewed process-group cleanup' - python3 - <<'PY' - import re, subprocess - from pathlib import Path - path = Path('tools/generate_menus.py') - revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() - text, count = re.subn(r"revision='[0-9a-f]{40}'", f"revision='{revision}'", path.read_text(encoding='utf-8')) - assert count == 1 - path.write_text(text, encoding='utf-8') - PY - python3 tools/generate_menus.py - python3 tools/generate_menus.py --check - git add tools/generate_menus.py menu.sh menu.ps1 - git commit -m 'fix: point menus at process-cleanup regression fix' - git push origin HEAD:codex/official-installers-20260920