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/.github/workflows/test.yml b/.github/workflows/test.yml index 2ba5f87..ac9b45f 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,39 @@ 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 + python3 tests/test_library_loader.py + python3 tests/test_bounded.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 + ./tests/test-library-loader.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 + .\tests\test-library-loader.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..ef78e72 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,89 @@ -# LMM 工具菜单 +# LMM 安装脚本 -普通用户只需执行对应系统的一条命令,按数字选择 Pi、DSH 或 LMM CLI,再选择安装、更新、检查、启动和使用说明。 +Pi、DSH、LMM CLI、Codex、Claude Code、CC Switch、Clash Verge Rev 的安装入口。 -**Linux / macOS** -```sh -curl -fsSL https://api.lmm.best/scripts/menu.sh | bash -``` - -**Windows PowerShell** -```powershell -irm https://api.lmm.best/scripts/menu.ps1 | iex -``` - -菜单需要交互终端,默认不改 PATH、不自动登录。网络选项包括自动、官方源、国内镜像;实际安装继续使用带缓存、重试和校验的安装器。菜单先完整下载并校验固定版本的底层脚本,校验不符会使用固定 Git 提交的备用地址,绝不执行校验失败的内容。临时菜单文件退出时清理,安装器缓存保留。 - -维护者通过 `templates/menu.*.in` 和 `tools/generate_menus.py` 生成菜单;升级底层脚本时须更新生成器中的固定提交并重新生成,避免菜单与安装器版本意外混用。PowerShell 菜单带 UTF-8 BOM,兼容 Windows PowerShell 5.1 中文。 - -底层脚本供菜单调用和自动化使用,公共页面只展示以上两个菜单入口。 - ---- +## 菜单 -# LMM 安装与使用脚本 - -公开入口:[api.lmm.best/scripts](https://api.lmm.best/scripts)。所有根目录脚本都可以单独下载运行,不依赖远程 `source`、API Key 或管理员权限。 - -| 脚本 | 完成的工作 | -|---|---| -| `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 的真实退出码 | - -## 开始使用 - -Linux/macOS(以 Pi 为例,文件名可换成 `dsh.sh`、`lmm.sh`): +Linux / macOS / Termux: ```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 +curl -fsSL https://api.lmm.best/scripts/menu.sh | bash ``` Windows PowerShell 5.1+: ```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 +irm https://api.lmm.best/scripts/menu.ps1 | iex ``` -也可以使用网页的操作系统按钮复制完整命令。安装过程不需要输入授权码或 API Key;首次账号授权由 Pi、DSH 或 LMM CLI 的原生登录流程完成。 - -默认只安装,不自动启动交互界面或模型任务。`--launch` / `-Launch` 可以安装后启动。DSH 默认 Web profile;`--profile headless` / `-Profile headless` 仅给该 profile 安装插件,登录应先通过共享同一个 `DSH_HOME` 的 Web profile 完成。 +选择工具,再选安装、更新、检查或启动。默认不启动应用、不登录账号。Pi/DSH/LMM 默认不改 PATH;新增工具使用官方安装位置和更新策略,官方安装器可能修改用户 PATH。桌面软件可能要求管理员授权,不自动配置订阅、启用代理或关闭系统安全提示。 -## 安装位置、重复运行与恢复 +菜单会执行下载的代码。需要先审查时,下载脚本后再运行。`bash menu.sh --list` / `.\menu.ps1 -List` 只列出工具。 -- 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 缓存。不会自动清空其他软件的缓存、配置、凭据或会话。 +## 工具与平台 -如果某步失败,脚本返回非零并指出阶段。保留的旧启动入口不会因插件下载失败而被新入口覆盖。安装器并不声称能回滚第三方包管理器的全部内部状态。 +脚本文件名如下,Unix 使用 `.sh`,Windows 使用 `.ps1`。 -支持构建白名单的 npm 会显式放行 Pi/DSH 已知必需的原生依赖构建,不使用“允许全部构建”开关;已有 `ignore-scripts=true` 配置会明确阻止安装而不会被偷偷覆盖。预编译 DSH 插件安装跳过其依赖的非必需生命周期脚本,避免 pnpm 的交互批准卡住管道安装。 +| 文件名 | 安装方式 | 平台说明 | +|---|---|---| +| `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 不显示 | -## 网络慢、代理与镜像 +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 包环境,不支持直接套用通用二进制安装器。 -`--network auto` / `-Network auto` 会先比较公共下载源的连接延迟,下载时另外监控低速和停滞;它不是“HEAD 快就一定整文件快”的假设。 +官方依据和具体限制见 [安装方式核查](docs/install-sources.md)。模拟测试通过不代表所有发行版、硬件和图形环境都已实测。 -- 文件下载: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 +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 ``` -无法联网时,已安装的 CLI 和完整的已验证下载缓存仍可复用;尚未缓存的 npm 依赖仍需要网络。脚本不会把下载失败当作安装成功。 +Windows 对应 `-DryRun`、`-Check`、`-Update`、`-Version`,例如 `powershell -ExecutionPolicy Bypass -File .\codex.ps1 -Check`。 + +四个新增工具均支持 `--launch` / `-Launch`、`--root` / `-Root`。`--root` 管理本站创建的辅助启动器和便携版,不改变官方原生客户端的安装目录。`--dry-run` 不安装,但默认仍会获取公共函数。桌面工具的检查只确认入口存在,不自动启动图形界面。 -## LMM CLI 当前功能与平台边界 +Pi/DSH/LMM 的原有参数保持不变:`--check`、`--update`、`--launch`、`--add-path`、`--network auto|official|china`。其更新重装 [versions.json](versions.json) 固定版本,不追踪 latest;宿主与 LMM 插件需要一起验证后升级。全部参数见各脚本的 `--help` / `-Help`。 -LMM CLI **仍是 0.1.0 开发预览**。它能进行软件发现、状态线索查询、只读诊断、`setup --dry-run`、浏览器 OAuth 登录和模型/价格目录读取。CLI 内部的实际软件安装、接入、同步、恢复等尚未实现,安装器不会伪装这些功能已完成。 +## Termux -预编译包来自成功的 [三平台 CI 运行](https://github.com/TokenNotIncluded/api.lmm.best/actions/runs/35434517044): +先准备基础工具:`pkg install bash curl coreutils`。安装目录、缓存和临时文件留在私有目录,不放到 `/sdcard` 或 `/storage`。未设置 `TMPDIR` 时使用 `$PREFIX/tmp`,启动器使用当前 Bash 的绝对路径。 -- Linux x64:要求 glibc 2.39+;不适用于 Alpine/musl 或较旧的 glibc。 -- macOS arm64。 -- Windows x64。 +Pi 另需 `pkg install nodejs npm git`;脚本检查 Android 原生 Node,不下载桌面 Linux Node。剪贴板可选 Termux:API 应用和 `pkg install termux-api`。浏览器未打开时用 `termux-open-url` 打开登录地址。 -其他支持源码构建的 x64/arm64 平台可用 `--from-source` / `-FromSource`,要求事先装好 Rust 1.88+ 和平台编译工具。首次构建可能较慢;Cargo 复用缓存。脚本不擅自安装系统包、Xcode 或 Visual Studio,也不绕过操作系统的安全提示。CLI 二进制提供 SHA-256 校验,但没有声称完成 OS 代码签名或 macOS 公证。 +Codex、Claude 使用已有的 PRoot guest: ```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 +pkg install proot-distro +proot-distro install ubuntu:24.04 +bash codex.sh --install-deps +bash claude-code.sh --install-deps ``` -`doctor`、`setup --dry-run` 可能返回退出码 3,表示检查/能力尚未完成。使用脚本保留这个返回值。LMM CLI 登录使用系统凭据库;Linux 需要运行中的 Secret Service,SSH/容器不一定满足。`--no-browser` 不是设备码登录,浏览器仍须能访问该主机的本地回调端口。脚本不复制 Pi/DSH 的授权给 CLI,不自动支付或调用模型。 +默认 guest 名称为 `ubuntu`,其他已安装环境用 `--distro NAME`。脚本不创建或重置 guest;`--install-deps` 仅自动准备 Debian/Ubuntu guest 的依赖。启动器把当前目录绑定到 guest 的 `/workspace`,原样转发参数。PRoot 不是独立 Linux 内核,不保证所有沙箱能力可用;没有关闭 Agent 沙箱作为替代。 -## 维护与验证 +Android 真机、DSH Android 原生依赖和 LMM CLI Android 源码构建尚未验证。CC Switch、Clash Verge Rev 在 Termux 菜单中隐藏。 -版本、下载地址和哈希集中在 `versions.json`。编辑 `templates/install.sh.in`、`templates/install.ps1.in` 后运行: +## 使用与维护 -```sh -python3 tools/generate.py -python3 tools/generate.py --check -shellcheck *.sh -python3 tests/test_installers.py -pwsh -NoProfile -File tests/test-powershell.ps1 -``` +Pi:启动后 `/login` → LMM → 浏览器授权,再用 `/model` 选模型。DSH:`dsh web` → Settings → Models → LMM;headless 登录先在同一 `DSH_HOME` 的 Web profile 完成。Codex、Claude 按各自的官方登录提示操作,安装器不代填账号或模型配置。 -兼容原有的 `generate.py`、`--install-only` / `-InstallOnly` 和 `--no-bootstrap` / `-NoBootstrap` 入口。新版默认只安装;需要安装后启动 DSH 时显式使用 `--launch` / `-Launch`。 +LMM CLI 目前提供 `catalog`、`status`、`doctor --report`、`login`、`models --json`。`setup --dry-run` 只预览,不执行软件接入;退出码 3 不表示全部成功。Linux 登录需要可用的 Secret Service,SSH/容器不一定具备。 -原有网络调优环境变量仍保留:`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/DSH/LMM 默认目录为 `${XDG_DATA_HOME:-~/.local/share}/lmm-tools` 或 `%LOCALAPPDATA%\lmm-tools`,不覆盖系统 Node/npm 包。没有加入 PATH 时使用安装结束打印的完整路径。 -Pi/DSH 安装子进程有总超时和每 15 秒的进度心跳,超时或取消会终止本次子进程树;不会让后台 npm 继续写已经清理的暂存目录。POSIX 整个安装器包在完整函数内,管道传输截断时不会执行半个脚本。 +公共函数从固定 GitHub 提交加载,不再内嵌到每个安装器,也不另设公共库哈希清单。本地开发可设 `LMM_LIB_DIR="$PWD/templates/lib"`;帮助页不联网,检查模式可能获取公共函数。下载失败会停止,不把半个文件当作成功安装。 -只有根目录的八个 `.sh` / `.ps1` 脚本会被网站仓库同步器导入。模板、测试和维护工具不作为公开安装入口。发布时需同时校验各 API 节点提供的脚本内容,避免负载均衡后出现新旧版本混用。 +网络源、生成、缓存恢复、PATH 和卸载说明见 [维护文档](docs/maintenance.md)。 diff --git a/cc-switch.ps1 b/cc-switch.ps1 new file mode 100644 index 0000000..bcd2a0c --- /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='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 +} +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..e2820c5 --- /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=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.' + 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..8abed87 --- /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='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 +} +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..184fbbb --- /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=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.' + 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..09cd827 --- /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='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 +} +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..95ba0b4 --- /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=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.' + 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..d41b8d3 --- /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='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 +} +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..3b3108b --- /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=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.' + 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/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/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..571dd4f --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,54 @@ +# 维护与恢复 + +## 下载与文件 + +下载保留缓存和断点;完整文件每次校验 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` 的设置与会话,除非你明确要删除这些数据。 + +## 新增入口与菜单 + +`tools/catalog.py` 是工具名称、菜单顺序和入口生成的唯一清单。四个新增工具共用 `templates/lib/external.*`;旧安装器的清理、PATH 与启动器逻辑移到 `lifecycle.*`。`--dry-run` / `-DryRun` 只显示选择,不安装;默认仍需获取公共模块,本地测试可设 `LMM_LIB_DIR`。 + +新增工具的 `--network china` 只为本脚本下载的 GitHub 文件选择备用源,不改变官方安装器内部下载源。依赖安装只在 `--install-deps` 时进行;发行版桌面包本身通过包管理器解析依赖。桌面 `--check` 仅检查入口,不偷偷启动图形程序。 + +## 开发与检查 + +```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 +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 +``` + +公共函数放在 `templates/lib/`,Pi、DSH、LMM 的差异放在 `templates/tools/`。`tools/render.py` 负责共用的文本读取、完整管道包装和生成检查;`tools/generate.py` 保留入口与工具差异,生成当前工具所需的公共模块加载调用。菜单复用同一份根目录、哈希和 Termux 函数,`lmm-use.sh` 也从模板生成。 + +只修改这些源文件和版本清单,再生成根目录脚本。`.sh` 与 `.ps1` 入口通过 GitHub 固定提交获取 `templates/lib/`,不内嵌公共库;网站同步清单无需新增公共文件。模块不维护额外哈希,原有软件包和菜单的校验逻辑保留。`LMM_LIB_DIR` 可明确改用本地目录,缺文件即失败。 + +公共模块的提交由 `versions.json` 中的 `library_revision` 指定。修改公共库时先提交公共库,再更新这个引用并重新生成入口;只改入口时无需修改公共模块版本。测试比较该提交中的公共文件与当前源码,避免忘记更新引用。 + +菜单的 `revision` 固定到含有目标脚本的提交,并按该提交计算 SHA-256。更新安装器后,先提交安装器,再更新 `tools/generate_menus.py` 中的 `revision` 并生成菜单,避免入口仍取旧代码。线上同步由网站仓库负责;源码提交和线上节点同步是两回事。 + +CI 区分模拟故障测试、真实安装测试和登录测试。前两者通过不代表真实账号 OAuth、模型调用或所有操作系统已经验证;本仓库不在 CI 中提交账号凭据或发起付费模型调用。 + +兼容参数 `--install-only` / `-InstallOnly`、`--no-bootstrap` / `-NoBootstrap`、顶层 `generate.py` 继续保留。 diff --git a/dsh.ps1 b/dsh.ps1 index 8299a28..f8b29a5 100644 --- a/dsh.ps1 +++ b/dsh.ps1 @@ -12,16 +12,13 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'dsh' -$ScriptVersion = '2026.09.19.1' +$ScriptVersion = '2026.09.21.1' +$LibRevision = '7a42eebbdf13cb350f25aca8c466b1ec8964df15' $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,258 +27,41 @@ $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] -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. --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 +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.' + [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) } - 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)) { - $line=@(Get-Content -LiteralPath $Command)[-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 - 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.' } - } - 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 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 + } finally { [Net.ServicePointManager]::SecurityProtocol=$protocol } } + 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 } @@ -303,82 +83,55 @@ function Install-Pnpm { } $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 - $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.' } - 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 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') } -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" - } + +$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] +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. +"@ } function Invoke-LmmSetup { if ($Help) { Show-Usage; return } @@ -404,6 +157,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" @@ -417,42 +171,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.' @@ -474,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','lifecycle.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 29bb53e..ffd1f96 100755 --- a/dsh.sh +++ b/dsh.sh @@ -1,19 +1,18 @@ #!/usr/bin/env bash +# State is consumed by fetched modules. +# shellcheck disable=SC2034 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.19.1 +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 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,18 +22,78 @@ 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; } + +# 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 +} + +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" +} 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 +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 +# 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; } fail() { log "ERROR: $*"; exit 1; } @@ -43,7 +102,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,11 +114,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 -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. +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;; @@ -83,6 +143,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 @@ -96,20 +157,25 @@ 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 +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 +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 -} -# --check never creates directories, downloads, edits PATH or touches credentials. +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 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 @@ -120,32 +186,19 @@ if [ "$CHECK" = 1 ]; then 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 @@ -163,249 +216,7 @@ 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 - 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" - 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.' - 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' - # 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")" - } > "$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 -} -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 28c4127..4aedd6d 100644 --- a/lmm.ps1 +++ b/lmm.ps1 @@ -12,318 +12,50 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'lmm' -$ScriptVersion = '2026.09.19.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.21.1' +$LibRevision = '7a42eebbdf13cb350f25aca8c466b1ec8964df15' $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] -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. --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 +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.' + [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) } - 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)) { - $line=@(Get-Content -LiteralPath $Command)[-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 - 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.' } - } - 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 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 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 - $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.' } - 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-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 } @@ -351,34 +83,26 @@ function Install-Lmm { } 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" } - 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 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] +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. +"@ } function Invoke-LmmSetup { if ($Help) { Show-Usage; return } @@ -404,6 +128,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" @@ -417,42 +142,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.' @@ -474,7 +164,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','lifecycle.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 08bf501..7e986bc 100755 --- a/lmm.sh +++ b/lmm.sh @@ -1,28 +1,15 @@ #!/usr/bin/env bash +# State is consumed by fetched modules. +# shellcheck disable=SC2034 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.19.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.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 -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 +17,78 @@ lmm_hash() { case "$1" in *) printf '\n';; esac; } +# 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 +} + +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_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 +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 +# State is consumed by dynamically imported helpers. +# shellcheck disable=SC2034 + PNPM_BIN='' log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } fail() { log "ERROR: $*"; exit 1; } @@ -43,7 +97,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,16 +109,17 @@ 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. +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;; --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;; @@ -83,6 +138,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 @@ -96,56 +152,43 @@ 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 +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 +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 -} -# --check never creates directories, downloads, edits PATH or touches credentials. +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 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 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 @@ -163,249 +206,7 @@ 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 - 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" - 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.' - 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' - # 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")" - } > "$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 -} -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.ps1 b/menu.ps1 index 49e7c0a..21d5e84 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' = '32f69187d3cc2677e6d11ef0df74f2d98a6903766a25f1860a2dccc4b1c41326' - 'dsh.ps1' = '38d708ba3c06508349c47d541dfe6bc5c480402855f3a0f16ecc8bee5b2f3919' - 'lmm.ps1' = '25e44d51b0e378ae998c8a879fe875656a4f6d527968317a068f954a7abbab13' +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' = '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 -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/5b6667854523bb355b50a4ffb3da5e13c1b5cf09/$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/b715e644bb665d841f59e063e14b0fc81c6d72bc/$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 5735828..02904e3 100755 --- a/menu.sh +++ b/menu.sh @@ -1,93 +1,139 @@ #!/usr/bin/env bash -# 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 +} + +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; } -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' '6d0ecd2a8a6e45fa19db01b51b6acf5e5fe1f3f4391b07fd8d60bb15b99d3030';; -dsh.sh) printf '%s' '8227ee030552fb1fb257e182af21c9ef746202cbdf150ef64b5d57ca57907555';; -lmm.sh) printf '%s' '0b63311dac684cd55ea8bd5c5d19cd11fe0b74ce6a434384fa272c70f15ac7cc';; -lmm-use.sh) printf '%s' '5240b7192e0fcb7700fd76b0d375f528422d6f38e751d220e9202ac6b6f8d9c5';; +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; } ask() { printf '%s' "$1"; IFS= read -r answer <&3 || exit 0; } -work=$(mktemp -d "${TMPDIR:-/tmp}/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_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} +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" ] && [ "$(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 - 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 + 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/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 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/pi.ps1 b/pi.ps1 index b3cfdff..9f393e2 100644 --- a/pi.ps1 +++ b/pi.ps1 @@ -12,16 +12,11 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Target = 'pi' -$ScriptVersion = '2026.09.19.1' +$ScriptVersion = '2026.09.21.1' +$LibRevision = '7a42eebbdf13cb350f25aca8c466b1ec8964df15' $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,10 +25,67 @@ $NodeHashes = @{ 'win-x64' = '158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541' 'win-arm64' = '8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921' } -$LmmHashes = @{ - 'linux-x64' = '292a1ff8b599466f52747867a0b14bd14860faefaa085cc60746040cd7eba9b7' - 'darwin-arm64' = '8f6b3a2d08500566b528b7089664420d3395e464c172e3a43dfcb73d37f57b3f' - 'win-x64' = 'd0eb3c3d3fe695eaa8a85de7c3161d0f7f17153064db5c20b8ced09defc574a9' + +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) +} + +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") } $script:InstalledSuccess=$false @@ -50,336 +102,11 @@ 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. +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. "@ } -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)) { - $line=@(Get-Content -LiteralPath $Command)[-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 - 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.' } - } - 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 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 - $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.' } - 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" } - 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 @@ -404,6 +131,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" @@ -417,42 +145,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.' @@ -474,7 +167,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','lifecycle.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 178f435..c7c400b 100755 --- a/pi.sh +++ b/pi.sh @@ -1,19 +1,16 @@ #!/usr/bin/env bash +# State is consumed by fetched modules. +# shellcheck disable=SC2034 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.19.1 +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 -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,18 +20,56 @@ 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; } + +# 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 +} + +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 +} 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 +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 +# 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; } fail() { log "ERROR: $*"; exit 1; } @@ -43,7 +78,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,11 +90,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 -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. +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;; @@ -83,6 +119,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 @@ -96,20 +133,25 @@ 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 +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 +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 -} -# --check never creates directories, downloads, edits PATH or touches credentials. +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 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 @@ -120,32 +162,19 @@ if [ "$CHECK" = 1 ]; then 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 @@ -163,249 +192,7 @@ 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 - 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" - 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.' - 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' - # 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")" - } > "$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 -} -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/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/install.ps1.in b/templates/install.ps1.in index 763650e..d8b5213 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 @@ -26,336 +27,11 @@ 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. +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. "@ } -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)) { - $line=@(Get-Content -LiteralPath $Command)[-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 - 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.' } - } - 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 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 - $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.' } - 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" } - 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 @@ -380,6 +56,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" @@ -393,42 +70,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.' @@ -450,7 +92,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 7a777d0..1055d8b 100644 --- a/templates/install.sh.in +++ b/templates/install.sh.in @@ -1,13 +1,16 @@ #!/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@@ +@@LIBRARIES@@ 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 +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 +# State is consumed by dynamically imported helpers. +# shellcheck disable=SC2034 +@@CLIENT_STATE@@ PNPM_BIN='' log() { printf '[lmm %s] %s\n' "$TARGET" "$*" >&2; } fail() { log "ERROR: $*"; exit 1; } @@ -16,7 +19,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,16 +31,17 @@ 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. +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;; --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;; @@ -56,6 +60,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 @@ -69,56 +74,41 @@ 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 +@@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 + 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 -} -# --check never creates directories, downloads, edits PATH or touches credentials. +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 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 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 -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 @@ -136,249 +126,7 @@ 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 - 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" - 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.' - 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' - # 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")" - } > "$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 -} -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/external.ps1 b/templates/lib/external.ps1 new file mode 100644 index 0000000..0e9f247 --- /dev/null +++ b/templates/lib/external.ps1 @@ -0,0 +1,136 @@ +# 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 + $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 + 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 + $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 new file mode 100644 index 0000000..329daed --- /dev/null +++ b/templates/lib/external.sh @@ -0,0 +1,236 @@ +# 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 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;; + 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; 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' + 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 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 + [ -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 + [ "$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 + 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/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/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/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..52bf46c --- /dev/null +++ b/templates/lib/node.sh @@ -0,0 +1,136 @@ +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 {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() { + 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.' + 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/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..a162f02 --- /dev/null +++ b/templates/load.sh.in @@ -0,0 +1,33 @@ +# 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 +} 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 d4b5d03..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; } -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 +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_INSTALL_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/lmm-tools} +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" ] && [ "$(hash_file "$work/$name")" = "$expected" ]; then return 0; fi - printf '正在获取并校验安装程序(下载慢时会重试)…\n' + 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/@@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 - printf '文件版本或校验不匹配,尝试固定版本备用地址。\n' >&2 + 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 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/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-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-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-official-policy.ps1 b/tests/test-official-policy.ps1 new file mode 100644 index 0000000..a2efb40 --- /dev/null +++ b/tests/test-official-policy.ps1 @@ -0,0 +1,75 @@ +$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)) +} +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')) +$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 '+[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.' } + 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-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_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) 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..c1c0f4e --- /dev/null +++ b/tests/test_external.py @@ -0,0 +1,138 @@ +"""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=='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]) + 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','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) + 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}\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()] + + 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_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()) + + 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/tests/test_installers.py b/tests/test_installers.py index 6b3acc0..6799f96 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: @@ -25,7 +30,7 @@ elif name=='npm': if '--help' in a:print('--allow-scripts');sys.exit(0) if a[:2]==['config','get']: - 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) if os.environ.get('LMM_TEST_NPM_FAIL')=='1':sys.exit(9) if os.environ.get('LMM_TEST_NPM_SLEEP'):__import__('time').sleep(int(os.environ['LMM_TEST_NPM_SLEEP'])) prefix=Path(a[a.index('--prefix')+1]);cmd='pi' if any('@earendil-works/pi-coding-agent@' in x for x in a) else ('pnpm' if any(x.startswith('pnpm@') for x in a) else 'dsh') @@ -37,12 +42,13 @@ 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: 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_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/tests/test_official_policy.py b/tests/test_official_policy.py new file mode 100644 index 0000000..983f968 --- /dev/null +++ b/tests/test_official_policy.py @@ -0,0 +1,202 @@ +"""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())) + + +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_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) + + 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/catalog.py b/tools/catalog.py new file mode 100644 index 0000000..1799968 --- /dev/null +++ b/tools/catalog.py @@ -0,0 +1,12 @@ +"""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') +MANAGED = tuple(name for name, _, kind in TOOLS if kind == 'managed') diff --git a/tools/generate.py b/tools/generate.py index 054bd07..72575a6 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -1,25 +1,101 @@ #!/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) +"""Generate small installers that load common functions from a pinned revision.""" +import argparse +import json +import re +import shlex +from render import ROOT, emit, libraries, standalone, template +from catalog import EXTERNAL, MANAGED + +# 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'), + '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')) + 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 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}') + parts.append(f'lib/lifecycle.{ext}') + if target != 'lmm': + parts.append(f'lib/node.{ext}') + parts.append(f'tools/{target}.{ext}') + 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 + '\n' + shared)) + if ext == 'sh': + 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) + + +if __name__ == '__main__': + main() diff --git a/tools/generate_menus.py b/tools/generate_menus.py index 20b0eff..6581c61 100644 --- a/tools/generate_menus.py +++ b/tools/generate_menus.py @@ -1,21 +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 -p=Path(__file__).resolve().parents[1] -a=argparse.ArgumentParser();a.add_argument('--check',action='store_true');args=a.parse_args() -revision='5b6667854523bb355b50a4ffb3da5e13c1b5cf09' -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().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) +"""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='b715e644bb665d841f59e063e14b0fc81c6d72bc' + + +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() diff --git a/tools/render.py b/tools/render.py new file mode 100644 index 0000000..ad8aec9 --- /dev/null +++ b/tools/render.py @@ -0,0 +1,32 @@ +"""Shared rendering and byte-for-byte checks for installer and menu entry points.""" +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) diff --git a/versions.json b/versions.json index 8090e48..d508de3 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "script_version": "2026.09.19.1", + "script_version": "2026.09.21.1", "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": "7a42eebbdf13cb350f25aca8c466b1ec8964df15" }