From 821852e7f299c531abaf84da1c7b8bdcad474470 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Sat, 26 Sep 2026 21:41:50 +0800 Subject: [PATCH] feat(cli): bootstrap mise on demand instead of bundling --- .gitignore | 2 +- .goreleaser.yaml | 4 - CONTRIBUTING.md | 20 +- Taskfile.yml | 19 +- apps/docs/content/docs/en/installation.md | 24 ++ apps/docs/content/docs/zh/create.md | 2 +- apps/docs/content/docs/zh/error-codes.md | 2 +- apps/docs/content/docs/zh/hk.md | 6 +- apps/docs/content/docs/zh/installation.md | 28 +- apps/docs/content/docs/zh/run.md | 2 +- .../plans/2026-09-14-mise-runtime-adoption.md | 8 +- ...-14-polyglot-workspace-and-dependencies.md | 2 +- .../2026-09-26-mise-runtime-bootstrap.md | 285 ++++++++++++++++++ .../adapters/runtime/mise/download.go | 133 ++++++++ .../adapters/runtime/mise/download_test.go | 165 ++++++++++ .../runtime/mise/embed_darwin_amd64.go | 8 - .../runtime/mise/embed_darwin_arm64.go | 8 - .../runtime/mise/embed_linux_amd64.go | 8 - .../runtime/mise/embed_linux_arm64.go | 8 - .../adapters/runtime/mise/embed_other.go | 5 - .../runtime/mise/embed_windows_amd64.go | 8 - .../internal/adapters/runtime/mise/install.go | 114 ++++--- .../adapters/runtime/mise/install_test.go | 176 +++++++---- .../internal/adapters/runtime/mise/mise.go | 174 +++++++---- .../adapters/runtime/mise/mise_test.go | 202 ++++++++++++- .../runtime/mise/miserelease/release.go | 2 +- .../internal/adapters/runtime/mise/paths.go | 150 +++++++++ .../adapters/runtime/mise/paths_test.go | 94 ++++++ .../cli/internal/platform/errors/codes.go | 2 +- .../internal/platform/i18n/locales/en-US.json | 6 +- .../internal/platform/i18n/locales/zh-CN.json | 6 +- packages/cli/testdata/reference/help/hk.txt | 2 +- packages/cli/testdata/reference/help/mise.txt | 4 +- packages/cli/tests/e2e/mise_runtime_test.go | 120 ++++++-- packages/cli/tools/sync-mise/main.go | 184 ----------- packages/cli/tools/sync-mise/main_test.go | 157 ---------- 36 files changed, 1509 insertions(+), 631 deletions(-) create mode 100644 docs/plans/2026-09-26-mise-runtime-bootstrap.md create mode 100644 packages/cli/internal/adapters/runtime/mise/download.go create mode 100644 packages/cli/internal/adapters/runtime/mise/download_test.go delete mode 100644 packages/cli/internal/adapters/runtime/mise/embed_darwin_amd64.go delete mode 100644 packages/cli/internal/adapters/runtime/mise/embed_darwin_arm64.go delete mode 100644 packages/cli/internal/adapters/runtime/mise/embed_linux_amd64.go delete mode 100644 packages/cli/internal/adapters/runtime/mise/embed_linux_arm64.go delete mode 100644 packages/cli/internal/adapters/runtime/mise/embed_other.go delete mode 100644 packages/cli/internal/adapters/runtime/mise/embed_windows_amd64.go create mode 100644 packages/cli/internal/adapters/runtime/mise/paths.go create mode 100644 packages/cli/internal/adapters/runtime/mise/paths_test.go delete mode 100644 packages/cli/tools/sync-mise/main.go delete mode 100644 packages/cli/tools/sync-mise/main_test.go diff --git a/.gitignore b/.gitignore index d67980f..78fb23f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,5 +51,5 @@ bin-go/ .seo/blog-drafts/ .seo/skills/ .seo/patches/ -# Fixed mise release archives fetched by task sync-mise, embedded per target. +# Legacy mise build resources from older checkouts; no longer used or embedded. /packages/cli/internal/adapters/runtime/mise/assets/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 130fdd3..2ba6f10 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,10 +15,6 @@ version: 2 project_name: one-cli -before: - hooks: - - go run ./packages/cli/tools/sync-mise --all - builds: - id: one dir: packages/cli diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e99d7a..78282fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,8 @@ one --version # 验证装好 > **fresh-clone 提示**:`packages/cli/internal/resources/bundled/` 整个目录是 gitignore 的—— > registry / templates / dashboard dist 都由 `task sync-bundled` + -> `task sync-web` 按需重建。`sync-bundled` 还会运行 `sync-mise`,下载并校验 -> 当前平台的固定 mise 压缩包用于内置,作为 `task vet` / `test` / `build` 的依赖自动跑。 +> `task sync-web` 按需重建。这些任务作为 `task vet` / `test` / `build` 的依赖自动运行; +> mise 不再是内嵌资源,构建与普通测试不需要下载 mise。 > 第一次 `task install` 会触发 `pnpm install + vite build`,~30s;之后 > task fingerprint 命中,几乎零成本。如果你直接跑 `go build` 而不走 Taskfile, > 会看到 `pattern all:_templates: no matching files found` 这种报错——跑一次 @@ -74,17 +74,15 @@ CI;`task pre-push` 额外运行 Go race detector。远端五项检查会在 PR - 公开 API(`packages/cli/pkg/`)改动要考虑 semver;详见 [CLAUDE.md 的 Public API stability](./CLAUDE.md) - 加新错误码:在 `packages/cli/internal/platform/errors/codes.go` 注册 `Code` 常量 + `Codes` map 条目;测试会强制对应;改完跑 `task gen-error-codes` 刷新文档 -### 内置 mise +### mise 运行时 -发布的 One 文件内置对应平台的 mise 压缩包,首次运行从自身解压,不下载 mise。 -`task sync-mise` 在构建时下载并校验本机平台资源;`task sync-mise-all` 准备五个平台。 -`task build`、检查任务自动准备本机资源,`task build-all` 和 GoReleaser 自动准备全部平台。 -资源位于被忽略的 `packages/cli/internal/adapters/runtime/mise/assets/`,不提交二进制资源。 -资源已准备且摘要匹配时可离线构建。首次构建需要访问 GitHub Releases。 +One 发布文件不包含 mise 程序或压缩包。实际运行时优先使用 `ONE_MISE_BINARY`,其次使用 PATH 中兼容的 mise,否则复用或从官方 GitHub Release 下载固定版本。最低兼容版本和托管版本分别维护;系统或托管程序被删除后,下一次需要 runtime 时重新解析并按需恢复。 -升级时更新 `internal/adapters/runtime/mise/miserelease/release.go` 中的版本、压缩包和解压后程序的 SHA256, -以及 runtime 最低版本和相关文档;重新执行 `task sync-mise-all`。上游许可证保留于 -`third_party/mise/LICENSE`,也包含在内置的原始压缩包和 One 发布归档中。 +托管程序位于 `$XDG_DATA_HOME/one/runtimes/mise///`,默认 `~/.local/share/one/runtimes/mise/`;工具、配置、状态和缓存分别使用对应 XDG 根下的 `one/mise/`。One 在托管子进程中设置四个 `MISE_*_DIR`,并关闭自动更新。外部 mise 沿用原目录。旧版 One 缓存中校验通过的同版本程序可以离线迁移,原缓存保留。 + +升级时验证上游校验文件的签名,更新 `packages/cli/internal/adapters/runtime/mise/miserelease/release.go` 中的版本、压缩包和程序 SHA256,再更新需要提高的 runtime 最低版本及相关文档。托管版本随 One 更新,不通过 `mise self-update` 维护。上游许可证保留于 `third_party/mise/LICENSE` 和 One 发布归档。 + +下载器及安装器测试使用本地 HTTP fixture,覆盖并发、重试、取消、摘要和删除修复。`ONE_TEST_MISE_BINARY=/absolute/path/to/mise go test ./packages/cli/tests/e2e -run Mise` 启用真实配置与信任测试;其中托管迁移测试要求与仓库固定摘要一致的官方程序。真实下载和多平台冒烟验证在发布前单独执行,不作为普通构建的资源依赖。 ### 改 templates(`packages/templates//`) diff --git a/Taskfile.yml b/Taskfile.yml index 47c8635..b7621cd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -55,8 +55,6 @@ tasks: - 'packages/kernel/**/*.go' - 'packages/cli/cmd/**/*.go' - 'packages/cli/internal/**/*.go' - - 'packages/cli/internal/adapters/runtime/mise/assets/*.tar.gz' - - 'packages/cli/internal/adapters/runtime/mise/assets/*.zip' - 'packages/cli/internal/adapters/deploy/kustomize/templates/*' - 'packages/cli/internal/platform/i18n/locales/*.json' - 'packages/cli/internal/resources/bundled/**/*' @@ -188,12 +186,12 @@ tasks: build-all: desc: Cross-compile every supported platform under go-dist/ - deps: [sync-bundled, sync-mise-all, sync-web] + deps: [sync-bundled, sync-web] dir: '{{.CLI_DIR}}' cmds: - rm -rf ../../go-dist - mkdir -p ../../go-dist - - for: { var: PLATFORMS, default: ['darwin/amd64', 'darwin/arm64', 'linux/amd64', 'linux/arm64', 'windows/amd64'] } + - for: ['darwin/amd64', 'darwin/arm64', 'linux/amd64', 'linux/arm64', 'windows/amd64'] cmd: | plat={{.ITEM}} os=${plat%/*} @@ -366,20 +364,7 @@ tasks: generates: - 'apps/dashboard/dist/index.html' - sync-mise: - desc: Fetch and verify the pinned mise archive embedded for the local platform - run: once - cmds: - - go run ./packages/cli/tools/sync-mise - - sync-mise-all: - desc: Fetch and verify mise archives for all release platforms - run: once - cmds: - - go run ./packages/cli/tools/sync-mise --all - sync-bundled: - deps: [sync-mise] desc: Regenerate registry/_templates/_skills under packages/cli/internal/resources/bundled (go:embed sources) run: once summary: | diff --git a/apps/docs/content/docs/en/installation.md b/apps/docs/content/docs/en/installation.md index c2ac02b..0e82b45 100644 --- a/apps/docs/content/docs/en/installation.md +++ b/apps/docs/content/docs/en/installation.md @@ -85,6 +85,30 @@ On Windows, the archive is `one-cli_windows_amd64.zip`. For normal upgrades, rerun the install command. Use `ONE_FORCE` only for downgrade or repair. +## mise Runtime + +One does not bundle mise. When a command needs it, One uses an explicit `ONE_MISE_BINARY` first, then a compatible mise on PATH (minimum 2026.9.7), then its own verified installation. If none is available, it downloads the pinned official mise 2026.9.7 release, verifies both archive and executable SHA256, and installs it atomically. Supported targets are macOS/Linux x64 and arm64, and Windows x64; Linux uses musl releases. [Official distribution](https://mise.jdx.dev/installing-mise.html). + +Selection happens again on each invocation. Removing system mise switches to the managed runtime; a missing or damaged managed executable is repaired automatically. A compatible system installation takes priority again when restored. Invalid explicit overrides report an error instead of falling back. No shell activation is required. + +| Managed content | Default location | Root override | +|---|---|---| +| mise executable | `~/.local/share/one/runtimes/mise///` | `XDG_DATA_HOME` | +| Tools and plugins | `~/.local/share/one/mise/` | `XDG_DATA_HOME` | +| Global configuration | `~/.config/one/mise/` | `XDG_CONFIG_HOME` | +| State and trust records | `~/.local/state/one/mise/` | `XDG_STATE_HOME` | +| Disposable cache | `~/.cache/one/mise/` | `XDG_CACHE_HOME` | + +The same effective Home convention applies on Windows. XDG roots must be absolute. For managed mise, One sets `MISE_DATA_DIR`, `MISE_CONFIG_DIR`, `MISE_STATE_DIR`, and `MISE_CACHE_DIR` in the child environment, replacing inherited values. External mise retains its existing directory settings. Project configuration stays in the project. Switching to managed mise can require reinstalling tools and granting trust again; One does not copy external configuration or trust records. + +Managed mise updates with One; automatic self-updates are disabled for that child process. Manually replacing it with `mise self-update` causes the next invocation to restore the pinned executable. A verified executable from an older One cache can be migrated without downloading, preserving the old file. Cache cleanup does not remove tools or trust records. + +**Offline use:** a valid external, managed, or migratable legacy executable can be reused offline. A fresh environment without any of them needs network access. Prepare mise and the required tools/dependencies beforehand, or point `ONE_MISE_BINARY` to a compatible external executable. `ONE_RUNTIME=builtin` is a temporary diagnostic escape hatch using existing tools. + +Download, migration, or verification failures return `MISE_INSTALL_FAILED`. Check network/proxy access to GitHub Releases and permissions on One's runtime directory, then retry the same command. Help, dry-run, and static project/configuration generation do not prepare mise. + +Use `one mise --version`, `one mise doctor`, or `one mise trust ` to work with the same selected runtime. Review configuration before trusting it; `MISE_PARANOID=1` requires explicit trust. Arguments, IO, and exit codes are forwarded, without One project secrets; use `one run` when those secrets are needed. + ## Configure Provider Credentials Provider credentials are configured once with `one configure add / --profile ` and can be reused across workspaces. Current configurable pairs are: diff --git a/apps/docs/content/docs/zh/create.md b/apps/docs/content/docs/zh/create.md index aceacf8..4af0698 100644 --- a/apps/docs/content/docs/zh/create.md +++ b/apps/docs/content/docs/zh/create.md @@ -50,7 +50,7 @@ one create my-app --yes --env-provider infisical | 工具环境 | mise | 自动生成根 `.mise/conf.d/one.toml`;后续 `one add` 自动生成项目配置 | | Git 检查 | hk | 创建共享检查配置并安装本地提交钩子;后续 `one add` 增量加入语言检查 | -创建和添加项目只生成配置,不下载工具。首次运行时 One 从自身解压内置 mise,用户无需单独安装或下载 mise;正常命令保持不变。工具版本与已有 workspace 的启用方式见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 +创建和添加项目只生成配置,不下载工具。首次运行时 One 优先使用兼容的系统 mise,否则按需下载并托管;本地没有可用版本时需要联网,正常命令保持不变。工具版本与已有 workspace 的启用方式见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 空工作区先保持语言无关:首次添加 Go 模块时创建根 `go.work` 并登记该模块;首次添加 JS/TS 项目时创建根 `package.json` 和 `pnpm-workspace.yaml`。后续项目增量加入,两套配置可以共存。Git hooks 从创建工作区时就由 hk 提供,纯 Go 工作区不生成 Node 配置;JS 工作区也不再依赖 Husky 或 commitlint。工作区不默认安装版本管理工具或生成 Changesets 配置,发布流程由项目按需配置。 diff --git a/apps/docs/content/docs/zh/error-codes.md b/apps/docs/content/docs/zh/error-codes.md index 3f17be9..9700ab0 100644 --- a/apps/docs/content/docs/zh/error-codes.md +++ b/apps/docs/content/docs/zh/error-codes.md @@ -693,7 +693,7 @@ A managed mise configuration was modified or changed during generation. ### `MISE_INSTALL_FAILED` -One could not extract, verify, or prepare its bundled mise runtime. +One could not download, migrate, verify, or prepare its managed mise runtime. > 没有默认 remediation。具体恢复方式请看错误的 `context` 字段。 diff --git a/apps/docs/content/docs/zh/hk.md b/apps/docs/content/docs/zh/hk.md index 1d3a658..106cf8a 100644 --- a/apps/docs/content/docs/zh/hk.md +++ b/apps/docs/content/docs/zh/hk.md @@ -3,7 +3,7 @@ title: one hk description: 使用 hk 统一工作区检查、显式修复和 Git 提交检查。 --- -`one hk` 通过 One 内置的 mise 运行固定版本的 hk。Go、JS/TS 和混合工作区使用同一套入口,不需要为了 Git hooks 安装 Husky 或 commitlint。 +`one hk` 通过 One 选择的 mise(优先系统版本,否则按需下载并托管) 运行固定版本的 hk。Go、JS/TS 和混合工作区使用同一套入口,不需要为了 Git hooks 安装 Husky 或 commitlint。 ## 日常使用 @@ -34,7 +34,7 @@ one mise exec -- pnpm exec oxfmt --write package.json one configure hooks ``` -Git 启动器记录本次 One 可执行文件的位置,并通过它运行内置 mise;不依赖终端的 mise 激活状态。移动或更换 One 安装位置后,可以重新执行 `one configure hooks`。 +Git 启动器记录本次 One 可执行文件的位置,并通过它解析和运行 mise;不依赖终端的 mise 激活状态。移动或更换 One 安装位置后,可以重新执行 `one configure hooks`。 ## 默认检查 @@ -48,7 +48,7 @@ Git 启动器记录本次 One 可执行文件的位置,并通过它运行内 提交检查不调用 Go 模板中包含 `go mod tidy` 的 `task check`。构建、类型检查和测试仍可通过项目原有命令执行,也可以自行加入 hk 配置。 -工具版本由 mise 提供,首次需要时下载并缓存。mise 本身已经内置在 One 中;hk 是单独安装的工作区工具。JS 检查使用项目依赖,需要先安装依赖;`one dev ` 会准备开发依赖,也可运行 `one mise exec -- pnpm install`。 +工具版本由 mise 提供,首次需要时下载并缓存。mise 本身优先使用系统版本,本地没有可用程序时由 One 下载并托管;hk 是单独安装的工作区工具。JS 检查使用项目依赖,需要先安装依赖;`one dev ` 会准备开发依赖,也可运行 `one mise exec -- pnpm install`。 ## 配置与项目增量更新 diff --git a/apps/docs/content/docs/zh/installation.md b/apps/docs/content/docs/zh/installation.md index cd7ef62..ab09fa0 100644 --- a/apps/docs/content/docs/zh/installation.md +++ b/apps/docs/content/docs/zh/installation.md @@ -87,20 +87,34 @@ Windows 归档名是 `one-cli_windows_amd64.zip`。 ## One 自动管理 mise -新建 workspace 的 `one run` 和 `one dev` 使用 mise 管理工具环境。**发布的 `one` 文件已内置当前平台的 mise 2026.9.7,无需单独安装,首次使用也无需下载 mise。** One 校验内置压缩包,解压并校验可执行文件后放入自己的缓存;后续运行复用缓存。macOS、Linux 的 x64 / arm64 和 Windows x64 发布文件分别携带对应平台资源。[官方二进制分发说明](https://mise.jdx.dev/installing-mise.html)。 +新建 workspace 的 `one run` 和 `one dev` 使用 mise 管理工具环境。**One 安装包不包含 mise;实际使用时优先采用 PATH 中兼容的 mise,否则复用或从官方 GitHub Release 下载固定版本 2026.9.7。** 下载后校验压缩包和程序 SHA256,再原子安装。支持 macOS、Linux 的 x64 / arm64 和 Windows x64;Linux 使用 musl 资源。[官方二进制分发说明](https://mise.jdx.dev/installing-mise.html)。 -不需要激活 shell。配置信任遵循 mise 自身规则;默认模式下执行命令可能自动信任当前配置,设置 `MISE_PARANOID=1` 后需先显式审查并信任配置;Node、Go 等工具下载由 mise 处理,应用依赖仍由包管理器安装,尚未安装的工具和依赖仍可能需要联网。旧 workspace 在显式启用前继续沿用已有工具,详见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 +每次需要 runtime 时重新检查:系统 mise 被删除、版本过旧或不可执行时,One 转用托管版本;托管程序缺失或损坏时自动恢复。系统版本重新可用后恢复系统优先。显式设置 `ONE_MISE_BINARY` 的路径或版本有误时直接报错,不自动回退。 -缓存位于 `$XDG_CACHE_HOME/one/runtimes/mise///`,未设置时使用 `~/.cache/one/runtimes/mise/`。One 默认使用内置固定版本,仅在子进程 PATH 中加入缓存目录。缓存损坏会从内置资源重新解压,无需联网。内置 mise 随 One 升级;One 会关闭该子进程的 mise 自动升级和更新提示。内置资源会增加 One 发布文件体积;运行时需要可写、可执行的缓存目录。 +不需要激活 shell。配置信任遵循 mise 自身规则;设置 `MISE_PARANOID=1` 后需先显式审查并信任配置。旧 workspace 在显式启用前继续沿用已有工具,详见 [`one configure mise`](/zh/docs/configure/#mise-工作区工具配置)。 + +| 托管内容 | 默认目录 | 自定义根目录 | +|---|---|---| +| mise 程序 | `~/.local/share/one/runtimes/mise///` | `XDG_DATA_HOME` | +| Node、Go、插件等 | `~/.local/share/one/mise/` | `XDG_DATA_HOME` | +| mise 全局配置 | `~/.config/one/mise/` | `XDG_CONFIG_HOME` | +| 状态、信任记录 | `~/.local/state/one/mise/` | `XDG_STATE_HOME` | +| 可清理缓存 | `~/.cache/one/mise/` | `XDG_CACHE_HOME` | + +Windows 同样使用有效用户 Home 下的对应目录。XDG 根必须是绝对路径。托管模式会在子进程中设置 `MISE_DATA_DIR`、`MISE_CONFIG_DIR`、`MISE_STATE_DIR`、`MISE_CACHE_DIR`,覆盖继承的同名变量;外部 mise 保留原目录设置。项目配置文件仍留在项目内。切换到托管模式时,工具可能需要重新安装,配置可能需要重新授权;不复制系统配置或信任记录。 + +One 仅修改子进程 PATH。托管版本随 One 更新,自动升级和更新提示在该子进程中关闭;自行用 `mise self-update` 替换托管程序后,下次运行会按固定摘要修复。旧版 One 缓存中的同版本程序经校验后可迁移,旧文件保留。缓存与程序、工具、状态分开,清理缓存不会删除工具或信任记录。 + +**离线使用**:已有可用系统版本、托管程序或可迁移旧缓存时无需下载 mise。全新环境没有这些程序时需要联网;可提前安装兼容 mise 或用 `ONE_MISE_BINARY` 指定已准备的程序。Node、Go、hk 和项目依赖也须提前安装,mise 程序可用并不代表这些工具已可离线使用。 | 变量 | 用途 | |---|---| -| `ONE_MISE_BINARY` | 显式使用其他 mise 可执行文件的绝对路径,最低支持版本为 2026.9.7 | +| `ONE_MISE_BINARY` | 显式指定 mise 可执行文件的绝对路径,外部程序最低支持版本为 2026.9.7 | | `ONE_RUNTIME=builtin` | 临时诊断时使用机器原有工具,跳过 mise | -解压、写入或校验失败返回 `MISE_INSTALL_FAILED`;不会执行不完整的文件。修复缓存权限后重试原命令,内置资源损坏时重新安装 One。显式指定的 mise 文件不存在时返回 `MISE_NOT_FOUND`。`--dry-run`、创建项目和生成配置不会解压 runtime。 +下载、迁移、写入或校验失败返回 `MISE_INSTALL_FAILED`;检查到 GitHub Releases 的网络/代理及托管目录权限后重试原命令。显式外部文件不存在时返回 `MISE_NOT_FOUND`。帮助、`--dry-run`、创建项目和生成配置不准备 runtime。 -需要访问 mise 的原生命令时,使用 `one mise`,无需把缓存目录加入 PATH: +需要访问 mise 的原生命令时,使用 `one mise`,无需把托管程序目录加入 PATH: ```bash one mise --version @@ -110,7 +124,7 @@ one mise trust apps/web/.mise/conf.d/one.toml one mise exec -- pnpm install ``` -`trust` 请在审查对应配置后运行;自定义配置同样遵循 mise 的信任规则。安装依赖的例子应在 workspace 根目录执行。`one mise` 原样转发参数、IO 和退出码,不额外注入 One 项目密钥;需要项目密钥时继续使用 `one run`。`one mise --help` 展示 One 的入口说明,不触发解压。 +`trust` 请在审查对应配置后运行;自定义配置同样遵循 mise 的信任规则。安装依赖的例子应在 workspace 根目录执行。`one mise` 原样转发参数、IO 和退出码,不额外注入 One 项目密钥;需要项目密钥时继续使用 `one run`。`one mise --help` 展示 One 的入口说明,不探测或下载 mise。 ## 配置 Provider 凭据 diff --git a/apps/docs/content/docs/zh/run.md b/apps/docs/content/docs/zh/run.md index 54a9d30..9d56bab 100644 --- a/apps/docs/content/docs/zh/run.md +++ b/apps/docs/content/docs/zh/run.md @@ -33,7 +33,7 @@ one run [-p ] [--env-provider dotenv|infisical] [--env ] -- //`,默认 `~/.cache/one/runtimes/mise/`。压缩包与可执行文件分别固定 SHA256;并发解压使用文件锁,缓存损坏从内置资源恢复。Linux 使用 musl 发行包;覆盖 One 的五个发布平台,每个 One 文件只携带对应平台资源。`sync-mise` 在构建时准备本机资源,`sync-mise-all` 和 GoReleaser 准备全部平台;资源不加入 Git。保留上游 MIT 许可证。 +托管程序放在 `$XDG_DATA_HOME/one/runtimes/mise///`,默认 `~/.local/share/one/runtimes/mise/`。工具、配置、状态和缓存使用各自 XDG 根下的 `one/mise/`;旧缓存中校验通过的同版本程序可迁移,原文件保留。托管子进程关闭自动更新。外部 mise 保留自己的目录;切换到托管模式可能需要重新安装工具、重新授权项目配置。 -One 仅修改子进程 PATH,不覆盖现有 mise,不额外设置自动信任。mise 默认模式可能自动信任执行中的配置;设置 `MISE_PARANOID=1` 可要求显式授权。创建、配置生成和 dry-run 均不触发解压。代价是 One 文件增大,首次运行需要缓存空间;Node、Go 等工具和应用依赖的下载仍由各自工具处理。 +构建不下载 mise;首次没有可用本地程序时需要联网。下载采用固定压缩包及程序 SHA256、文件锁、临时文件和原子替换。普通测试使用本地 HTTP fixture,真实官方版本验证由独立集成测试完成。One 仅调整子进程环境,不激活 shell,不额外设置自动信任;创建、静态配置生成和 dry-run 不触发准备。 提供可选的 `one mise ` 原生转发入口,用同一套 runtime 解析访问 trust、doctor、exec 等操作;该入口不先运行 mise exec,也不额外加载 One 项目密钥,因此未信任配置时也能执行显式 trust。普通 `create/add/dev/run` 仍保持原用法。 @@ -107,6 +107,6 @@ CI 与部署必须分别梳理 Cloudflare/EdgeOne 的本地 build、S3-compatibl - `mise_runtime_test.go` 验证原参数、退出码、环境顺序、静态预览、自动生成、冲突预检和真实生成任务。 - `mise_runtime_unix_test.go` 验证 Ctrl-C / 子服务失败之后双服务释放端口。 - 设置 `ONE_TEST_MISE_BINARY` 可启用真实 mise 集成;环境与任务测试通过用户 system 工具覆盖避免下载,不能替代干净机器安装验收。 -- installer 测试覆盖并发解压、tar/zip 校验、缓存损坏恢复、摘要失败和取消清理,并验证内置的真实官方压缩包。Linux 默认 e2e 在没有 PATH mise、不可用代理、空缓存的临时 workspace 中验证原始 One 命令首次执行和缓存复用。`ONE_TEST_MISE_BINARY` 可额外指定真实 mise 验证配置分层、信任和任务。 +- installer 测试使用本地 HTTP fixture 覆盖并发下载、tar/zip 校验、删除/损坏恢复、迁移、摘要失败和取消清理。e2e 验证系统发现以及空缓存离线失败;`ONE_TEST_MISE_BINARY` 可额外指定真实 mise 验证配置分层、信任和任务,固定官方版本还可验证托管迁移及离线复用。 - 执行仓库规定的 `task check`,进程和配置相关包补充 race 检查;Windows 交叉编译不能代替实机验证。 - 用户测试完成后再提交。后续阶段按实际验收推进,本轮不宣称 CI、部署、完整锁文件或跨平台实机验证已完成。 diff --git a/docs/plans/2026-09-14-polyglot-workspace-and-dependencies.md b/docs/plans/2026-09-14-polyglot-workspace-and-dependencies.md index 35c5a45..a700076 100644 --- a/docs/plans/2026-09-14-polyglot-workspace-and-dependencies.md +++ b/docs/plans/2026-09-14-polyglot-workspace-and-dependencies.md @@ -2,7 +2,7 @@ 日期:2026-09-14。状态:已实现并通过自动检查与 Linux 真实运行验收;等待用户测试后再提交。 -延续 [mise 接入方案](./2026-09-14-mise-runtime-adoption.md):保留现有 `create/add/dev/run` 用法,继续内置 mise,不增加额外二进制压缩。用户测试确认后再提交;实现阶段不按每个小步骤提交 commit。 +延续 [mise 接入方案](./2026-09-14-mise-runtime-adoption.md):保留现有 `create/add/dev/run` 用法;mise 分发已按 [2026-09-26 方案](2026-09-26-mise-runtime-bootstrap.md) 改为系统优先、缺失时按需下载,不再内置。用户测试确认后再提交;实现阶段不按每个小步骤提交 commit。 ## 目标和当前证据 diff --git a/docs/plans/2026-09-26-mise-runtime-bootstrap.md b/docs/plans/2026-09-26-mise-runtime-bootstrap.md new file mode 100644 index 0000000..d78626c --- /dev/null +++ b/docs/plans/2026-09-26-mise-runtime-bootstrap.md @@ -0,0 +1,285 @@ +# mise 按需获取与 One 托管 Implementation Plan + +> 使用 `executing-plans` 分步执行。用户已授权实施;验证结果记录在文末。 + +**Goal:** One 发布包不包含 mise 程序或压缩包;优先使用系统中可用的 mise,否则按需从官方获取,并能在外部安装或托管程序被删除后恢复运行。 + +**Architecture:** 在现有 mise adapter 中统一解析显式指定、系统 PATH、One 托管这三种来源;installer 负责下载、校验、加锁和原子安装。每次需要 runtime 时重新判断实际文件,不保存“安装时已检测到 mise”这样的永久状态;托管版本及校验摘要继续随 One 发布。 + +**Tech Stack:** Go、net/http、gofrs/flock、现有 fsutil、Cobra、Task、GoReleaser;沿用 tar.gz / zip 平台资源。 + +--- + +## 1. 需求和建议的边界 + +用户明确要求: + +- 构建产物不嵌入 mise。 +- 系统已有 mise 时优先使用。 +- 系统没有或后来被删除时,能够从官方下载。 +- One 下载的 mise 使用 One 管理的本地目录。 + +本计划建议的默认行为: + +- 延迟到首次执行需要 mise 的命令时获取;安装 One 本身不强制联网下载 mise。 +- 系统版本须满足现有最低版本 `2026.9.7`;过旧、不可执行或探测失败时使用托管版本。 +- 保留 `ONE_MISE_BINARY` 显式指定入口;配置错误直接报错,避免悄悄切换用户明确指定的程序。 +- 采用完整托管:mise 的程序、工具、配置、缓存和状态均使用 One 管理的目录。 +- 系统 mise 与显式指定的外部 mise 保留其现有工具和配置目录。 +- 首轮不增加后台更新服务、全局 shell 激活或新的日常命令参数。 + +方案比较: + +| 方案 | 效果 | 结论 | +| --- | --- | --- | +| 运行时系统优先,缺失后按需托管 | 安装包小,可覆盖卸载、误删和多种 One 安装方式 | 推荐 | +| 只在 install.sh / install.ps1 中检测并安装 | 实现表面简单,但安装后被删、手动解压安装仍需运行时补救 | 不能作为唯一入口 | +| One 始终下载和使用私有 mise | 版本行为更统一,但重复已有安装 | 不符合系统优先要求 | + +## 2. 当前仓库情况 + +- `packages/cli/internal/adapters/runtime/mise/mise.go`:当前只有显式路径与内嵌版本两条分支,没有系统 PATH 自动发现。 +- `packages/cli/internal/adapters/runtime/mise/install.go`:已有 SHA256 校验、跨进程锁、二次检查、限量解压、临时文件和原子替换。 +- `packages/cli/internal/adapters/runtime/mise/miserelease/release.go`:已有 `2026.9.7` 的五平台官方资源名、压缩包及程序摘要。 +- `packages/cli/internal/adapters/runtime/mise/embed_*.go`:按平台嵌入 mise 压缩包。 +- `packages/cli/tools/sync-mise/`:构建时下载;已有代理兼容的 HTTP 下载、有限重试、摘要和失败保留原文件测试。 +- `Taskfile.yml`:`sync-bundled` 间接运行 `sync-mise`,`build-all` 运行 `sync-mise-all`。 +- `.goreleaser.yaml`:before hook 下载所有平台压缩包。 +- 旧程序缓存位于 `${XDG_CACHE_HOME:-~/.cache}/one/runtimes/mise///`;目前工具、配置和状态使用 mise 原有目录。 + +## 3. 查找和恢复规则 + +```text +需要 mise 的实际执行入口 + ├─ ONE_MISE_BINARY 已设置 → 校验后使用;失败则明确报错 + ├─ 有兼容的系统 PATH mise → 使用系统安装 + ├─ One 托管目标版本完整可用 → 直接复用 + ├─ 旧 One 缓存存在且摘要匹配 → 校验并迁移程序 + └─ 从官方 Release 下载 → 校验 → 原子安装 → 使用 +``` + +| 场景 | 行为 | +| --- | --- | +| 安装 One 前已有兼容的系统 mise | 第一次需要时找到系统程序,不下载 | +| 原来的系统 mise 被删除或 PATH 改变 | 下次执行重新解析,转用托管版本;托管版本缺失时下载 | +| 系统 mise 版本过旧或文件不可执行 | 保留外部安装,说明回退原因并使用托管版本 | +| One 托管 mise 被删除或校验失败 | 重新下载并修复;不要求重装 One | +| 系统 mise 后来重新安装 | 下次执行恢复系统优先;不自动删除托管目录 | +| 系统或托管程序可用,但下载源不可访问 | 继续使用;解析阶段不检查网络上的最新版本 | +| 所有程序都不可用,下载又失败 | 返回可操作错误,说明重试原命令或使用显式外部路径 | +| 显式指定的 mise 不存在或版本不支持 | 返回现有显式路径错误,不自动回退 | +| 帮助、版本、创建配置、dry-run | 不探测、下载或安装 mise;原来需要实际执行依赖安装的分支仍按运行时规则处理 | + +查找实现要求: + +- 使用待执行命令的有效 `Env/PATH`,避免全局进程 PATH 与子命令 PATH 不一致。Windows 处理 PATH 大小写和可执行后缀。 +- “系统已有”具体指能在有效 PATH 找到的外部可执行程序;shell alias / function 不视为独立程序。PATH 外的安装使用 `ONE_MISE_BINARY` 指定。 +- 路径需要绝对化,并识别符号链接目标是否位于 One 管理目录。系统发现阶段跳过 One 当前及旧托管目录,避免嵌套 `one` 因继承 PATH 把托管程序误当系统安装;显式指定托管目录中的程序仍采用托管环境和校验。 +- 版本探测最多 5 秒,关闭探测过程的 mise 自动更新与更新提示;父 context 被取消时立即退出,不再开始下载。 +- 系统发现不支持的本机平台仍可使用兼容的外部程序;只有走托管下载时才验证官方资源平台支持。 +- 不写入固定的“上次来源”配置。可在一次执行准备过程中复用已验证结果,但不能跨命令永久缓存存在状态。 +- 程序已启动后的命令失败保持原始退出码,不因非零退出而重新安装、重复执行用户命令。检查与启动之间被删除的极小竞态可以明确报错,下次执行自动恢复。 + +内部解析结果应包含来源,供环境和诊断共同使用,而不是继续用 `ONE_MISE_BINARY == ""` 推断所有权: + +```go +// 示意内部类型,不变更公共 runtime.Provider 接口。 +type resolvedBinary struct { + Path string + Source binarySource // explicit、system、managed + Version string + Managed bool // 显式路径也可能指向 One 的托管目录 +} +``` + +## 4. One 托管目录 + +沿用仓库已有的 XDG + `userdirs.Home()` 习惯,统一封装解析;以下未设置 XDG 时的路径也适用于 Windows 的有效用户 Home,路径分隔符由 Go 处理。测试通过临时 HOME / XDG 完全隔离。 + +| 内容 | 默认目录 | 环境根 | +| --- | --- | --- | +| mise 程序 | `~/.local/share/one/runtimes/mise///mise[.exe]` | `XDG_DATA_HOME` | +| Node、Go、插件等 | `~/.local/share/one/mise/` | `XDG_DATA_HOME` | +| mise 全局配置 | `~/.config/one/mise/` | `XDG_CONFIG_HOME` | +| mise 状态和信任记录 | `~/.local/state/one/mise/` | `XDG_STATE_HOME` | +| mise 可清理缓存 | `~/.cache/one/mise/` | `XDG_CACHE_HOME` | + +托管程序子进程中设置: + +```text +MISE_DATA_DIR = /mise +MISE_CONFIG_DIR = /mise +MISE_STATE_DIR = /mise +MISE_CACHE_DIR = /mise +``` + +这四个变量在托管模式下由 One 设置;已有同名变量仅在系统或显式外部模式沿用。文档需明确这个行为变化,不能让目录归属依赖用户偶然继承的变量。项目中的 `mise.toml` 和 `.mise/conf.d/one.toml` 仍在项目中按原规则读取;这里管理的是用户级运行数据,不移动项目配置。 + +程序、安装的工具与可删除缓存分开,清理 mise 缓存不能导致程序或工具丢失。One 负责目录选择与 mise 程序生命周期;Node、Go 等工具的安装仍交给 mise。mise 官方支持这些目录变量,并明确区分缓存和安装目录。[目录说明](https://mise.jdx.dev/directories.html)。 + +托管模式通过子进程环境关闭 mise 自动升级,固定版本随 One 更新;不写系统环境或 shell 配置。`mise self-update` 不作为托管版本升级入口;若用户自行替换托管程序,下次摘要检查会按 One 的固定版本修复。 + +系统与托管目录彼此独立,因此首次切换到托管模式可能需要重新安装工具和重新建立配置授权。不会自动复制系统配置、工具目录或信任记录,也不会自动信任项目。`one mise trust` 与实际运行使用同一个解析器和相同目录。 + +## 5. 官方下载和版本策略 + +直接下载官方 GitHub Release 的平台资源。官方安装文档提供 Release 二进制及校验方式;无需在运行时执行 shell 安装脚本。[官方分发说明](https://mise.jdx.dev/installing-mise.html#github-releases)。 + +```text +https://github.com/jdx/mise/releases/download/v/ +``` + +- 首轮沿用仓库当前已固定的版本和五平台摘要,不顺带更换 mise 版本;最低兼容版本与托管发布版本分开维护。 +- One 包内只保留下载元数据、校验摘要和许可说明,不保留 mise 程序或压缩包。 +- 系统版本通过最低版本检查即可;托管版本精确固定,不请求 latest,不依赖 GitHub API 查询。 +- 保持 Linux musl、macOS x64 / arm64、Windows x64 的现有支持矩阵。 +- 官方资源通过 HTTPS 获取并允许正常 Release CDN 重定向;拒绝降级到 HTTP,保留 TLS 校验。使用标准代理设置,不在错误信息中输出代理凭据。 +- 复用现有下载错误分类:连接中断、HTTP 408 / 429 / 5xx 可有限重试;404、摘要错误、本地写入错误不重试。建议保持 4 次上限、可取消退避,整个下载阶段设置有限总时限。 +- 保留现有压缩包 256 MiB、可执行文件 512 MiB 上限;限量读取,不把未知体积响应直接读入内存。 +- 请求、等待锁、解压和重试等待均响应 context 取消。 +- 下载进度和回退说明走 stderr,不污染用户命令 stdout 或 JSON 输出。 + +安装顺序:验证现有目标 → 获取按版本/平台区分的锁 → 再次验证 → 下载临时归档 → 校验归档摘要 → 只提取预期程序 → 校验程序摘要 → 设置权限 → 原子替换 → 同步目录。 + +临时文件在目标版本目录内创建,成功及失败均清理。首轮不持久保存完整归档,托管程序删除后需要重新下载;已有验证通过的旧 One 缓存可以离线迁移。解压路径不直接拼接归档成员名,保留现有防越界实现。并发 `one dev` / hooks / run 只应产生一次有效安装。 + +维护者更新托管版本时验证上游校验文件的签名,再更新版本、两类摘要及平台测试;用户运行时以 One 中已审核的摘要校验下载内容。固定版本仍需随 One 常规维护,避免长期停在旧发布。 + +## 6. 旧版本迁移和离线行为 + +- 发现旧缓存中的同版本、同平台程序时,验证固定摘要后通过临时文件原子复制到新 data 目录;避免升级 One 后无意义地重新下载。 +- 不删除旧缓存,使旧版 One 仍可使用。首轮不做自动旧版本回收,避免并发 One 版本使用期间误删。 +- 不移动系统 mise 的工具和用户配置;完整托管目录是新的运行数据空间。 +- 新安装且没有系统 mise 的离线机器,不能再承诺首次运行成功。预装兼容 mise / 显式指定路径,或提前联网完成一次准备。 +- mise 程序可离线使用不代表 Node、Go、hk 和项目依赖均已安装;对应工具和依赖仍需要预先准备。 +- 安装脚本继续只安装 One。未来若需要安装时预热,应复用同一个运行时获取逻辑,不能在 shell / PowerShell 中再维护一套安装器。 + +## 7. 分步实施 + +### Task 1:解析来源和兼容策略 + +**Files:** +- Modify: `packages/cli/internal/adapters/runtime/mise/mise.go` +- Modify: `packages/cli/internal/adapters/runtime/mise/mise_test.go` +- Modify: `packages/cli/internal/ports/runtime/runtime.go`(仅在需要集中版本解析时) + +1. 写优先级测试:显式路径、兼容系统路径、过旧系统路径、托管复用;模拟下载器确认命中前两种时请求数为零。 +2. 运行测试确认当前系统发现用例失败。 +3. 加入来源类型、有效 PATH 解析和兼容性探测;把当前重复的最低版本数字收敛为单一来源。 +4. 增加符号链接指向托管目录、继承托管 PATH、上下文取消和外部路径包含空格的用例。 +5. 定向测试通过后保留可审查 diff;不要改调用方的命令参数、环境注入和退出码契约。 + +Run: `go test ./packages/cli/internal/adapters/runtime/mise/...` + +### Task 2:运行时下载与原子安装 + +**Files:** +- Create: `packages/cli/internal/adapters/runtime/mise/download.go` +- Create: `packages/cli/internal/adapters/runtime/mise/download_test.go` +- Modify: `packages/cli/internal/adapters/runtime/mise/install.go` +- Modify: `packages/cli/internal/adapters/runtime/mise/install_test.go` +- Modify: `packages/cli/internal/adapters/runtime/mise/miserelease/release.go` +- Reference / subsequently remove: `packages/cli/tools/sync-mise/main.go`, `main_test.go` + +1. 将已有下载逻辑和有价值的测试迁入 adapter;HTTP client、资源地址及 fixture 摘要通过内部依赖注入,不新增生产用的任意下载地址环境变量。 +2. 用 `httptest` 验证“缺失下载 → 关闭服务后复用 → 删除目标 → 恢复服务后重新下载”;当前内嵌安装器不满足该用例。 +3. 保留锁内二次检查,将内嵌字节来源改为按需下载的临时文件。tar.gz 从文件流读取,zip 使用文件 ReaderAt。 +4. 验证超时、取消、4xx/5xx、截断响应、两个摘要失败、大小限制、恶意归档、目录无权限和并发首次执行;失败不发布半成品。 +5. 验证通过后保留可审查 diff。 + +Run: `go test -race ./packages/cli/internal/adapters/runtime/mise/...` + +### Task 3:目录归属和旧缓存迁移 + +**Files:** +- Create: `packages/cli/internal/adapters/runtime/mise/paths.go` +- Create: `packages/cli/internal/adapters/runtime/mise/paths_test.go` +- Modify: `packages/cli/internal/adapters/runtime/mise/install.go` +- Modify: `packages/cli/internal/adapters/runtime/mise/mise.go` +- Modify: `packages/cli/internal/adapters/runtime/mise/mise_test.go` +- Reference: `packages/cli/internal/platform/userdirs/home.go` + +1. 写临时 HOME / 各 XDG 根测试,验证缺省值、Windows 路径、空格和非绝对 XDG 路径错误;不创建真实用户目录。 +2. 测试托管子进程的四个目录覆盖,以及系统/显式外部模式保持原环境;输入 Env 切片不能被原地修改。 +3. 实现路径解析和托管环境;保留项目配置与工作目录。 +4. 写旧缓存有效、损坏、缺失和离线迁移用例;仅复制验证通过的程序,旧文件不删除。 +5. 增加 trust 与 run 使用同一状态目录,以及切换外部/托管来源不会误删任一数据空间的测试;通过后保留可审查 diff。 + +Run: `go test ./packages/cli/internal/adapters/runtime/mise/... ./packages/cli/internal/platform/userdirs/...` + +### Task 4:去掉内嵌资源及构建下载 + +**Files:** +- Delete: `packages/cli/internal/adapters/runtime/mise/embed_darwin_amd64.go` +- Delete: `packages/cli/internal/adapters/runtime/mise/embed_darwin_arm64.go` +- Delete: `packages/cli/internal/adapters/runtime/mise/embed_linux_amd64.go` +- Delete: `packages/cli/internal/adapters/runtime/mise/embed_linux_arm64.go` +- Delete: `packages/cli/internal/adapters/runtime/mise/embed_windows_amd64.go` +- Delete: `packages/cli/internal/adapters/runtime/mise/embed_other.go` +- Delete after test migration: `packages/cli/tools/sync-mise/main.go`, `main_test.go` +- Modify: `Taskfile.yml`, `.goreleaser.yaml`, `.gitignore` +- Modify: `packages/cli/internal/adapters/runtime/mise/install_test.go` + +1. 下载和并发测试已迁移后移除 embed 文件、内嵌资源测试和旧构建下载工具。 +2. 移除 `sync-mise` / `sync-mise-all` 任务、依赖边和 build sources 中的归档项;保留模板、技能和 Dashboard 的正常资源同步。 +3. 删除 GoReleaser 的 mise 下载 hook;保留上游许可文件及说明。普通构建不再请求 mise 官方站点。 +4. 在没有 mise assets 的干净环境验证本机及五平台构建;记录前后产物体积,不预先承诺减少的具体数值。 +5. 检查发布归档仅含预期 One 产物与文档,没有 mise 程序或压缩包;通过后保留可审查 diff。 + +Run: `task build`、`task build-all`;验证不再有 `sync-mise` 调用。这里“离线构建”只约束 mise 资源,不承诺首次获取 Go / Node 依赖也可离线。 + +### Task 5:集成测试、帮助和用户文档 + +**Files:** +- Modify: `packages/cli/tests/e2e/mise_runtime_test.go` +- Review: `packages/cli/tests/e2e/mise_runtime_unix_test.go` +- Modify as needed: `.github/workflows/ci.yml`, `.github/workflows/cli.yml` +- Modify: `packages/cli/internal/platform/errors/codes.go` +- Modify: `packages/cli/internal/platform/i18n/locales/en-US.json`, `zh-CN.json` +- Refresh: `packages/cli/testdata/reference/help/mise.txt`, `hk.txt` +- Modify: `CONTRIBUTING.md` +- Modify: `apps/docs/content/docs/zh/installation.md`, `run.md`, `create.md`, `hk.md` +- Review existing English pages: `apps/docs/content/docs/en/installation.md`, `run.md`, `create.md`(目前没有对应的英文 hk 页面) +- Regenerate: `apps/docs/content/docs/zh/error-codes.md` +- Update superseded runtime distribution description: `docs/plans/2026-09-14-mise-runtime-adoption.md` + +1. 将“首次离线解压成功”替换为系统复用、首次获取、删除恢复、已有托管版本离线复用和全缺失离线失败的契约测试。 +2. 普通 PR 检查使用本地 HTTP fixture / fake mise,不访问公网;下载和平台安装全链路可在 adapter 集成测试注入资源,真实 CLI e2e 验证外部发现与错误传播。 +3. 真实 mise 环境/信任测试继续支持 `ONE_TEST_MISE_BINARY`;在明确的联网集成验证中预取官方固定版本,验证真实托管缓存复用。测试准备不得重新成为生产构建的依赖。 +4. 原生 Linux、macOS、Windows 验证来源选择、路径、替换、并发和启动行为;平台 smoke test 不能仅靠交叉编译替代。 +5. 更新错误提示中的 bundled/extract-only 语义,保留现有错误码兼容;下载失败须给出版本、平台、目标目录和重试方式。 +6. 更新帮助快照、中英文文档,写明系统优先、首次联网、目录归属、旧缓存迁移和离线限制;通过检查后记录提交。 + +Run: `task gen-error-codes`、`task check`;必要时使用仓库现有方式更新帮助快照,然后检查 diff。最终运行 `task test:go` 完成 Go race 检查。 + +## 8. 最终验收清单 + +- [x] 五平台发布文件不包含 mise 程序或压缩包,构建不下载 mise。 +- [x] PATH 已有兼容 mise 时无 bootstrap HTTP 请求。 +- [x] 外部 mise 被删除后下一次需要 runtime 的命令成功转入下载/托管路径。 +- [x] 托管程序损坏或丢失可自动恢复,已有正常程序不重复下载。 +- [x] 显式外部路径无效时不静默回退。 +- [x] 托管程序、工具与缓存归属清晰,清理缓存不删除工具或信任记录。 +- [x] 系统 mise 和系统工具目录不被 One 的安装器覆盖或清理。 +- [x] 并发首次执行只安装一次,取消和失败不遗留可执行半成品。 +- [x] 旧缓存有效时可离线迁移,原有文件保留。 +- [x] `one run`、`one dev`、`one hk`、`one mise` 共用同一解析策略。 +- [x] 原参数、输出、退出码、项目环境隔离和配置授权行为保持契约。 +- [x] 帮助、静态配置生成和 dry-run 不下载、不探测 mise。 +- [x] 用户文档不再宣称“没有外部/托管 mise 时首次运行也可离线”。 + +## 9. 实施与验证结果(2026-09-26) + +已完成系统优先解析、运行时官方下载、完整托管目录、旧缓存迁移、取消/并发/摘要验证,移除全部 mise embed 文件与构建下载工具,并更新中英文安装说明、命令帮助及错误码文档。 + +验证结果: + +- `task check` 通过:文档与帮助校验、Go vet/gofmt、全部 Go 普通测试、Dashboard 检查及 56 个测试。 +- `task test:go` 通过:全部 Go race 测试。 +- `task build-all` 通过并实际生成 darwin/amd64、darwin/arm64、linux/amd64、linux/arm64、windows/amd64 五个产物。验证过程中发现旧 `for: { var: PLATFORMS, default: [...] }` 没有执行循环,改为显式平台列表。 +- 在 macOS arm64 的全新临时 Home/XDG 目录中,真实官方 2026.9.7 下载成功,程序 SHA256 与固定摘要一致;阻断代理后复用成功,删除程序后重新官方下载成功。 +- 将该固定官方程序用于 `ONE_TEST_MISE_BINARY=... go test ./packages/cli/tests/e2e -run Mise -count=1 -v`,全部真实集成测试通过,包括托管迁移与环境、配置分层、信任、生成任务及多进程退出。 +- 本机已有二进制约 63 MiB,改动后重新构建约 31 MiB。构建任务和 GoReleaser 配置不再获取或嵌入 mise;归档仍保留上游许可证。 +- 差异检查通过。保留旧 assets 目录的 ignore 规则,仅用于忽略旧 checkout 的下载残留;这些文件不参与构建。 + +验证范围:原生运行环境为 macOS arm64;其他平台本次完成交叉编译,原生 Linux/Windows 行为仍由仓库现有 CI 作最终验证。本次未执行 GoReleaser 发布/归档流程;提交信息以 Git 历史为准。 diff --git a/packages/cli/internal/adapters/runtime/mise/download.go b/packages/cli/internal/adapters/runtime/mise/download.go new file mode 100644 index 0000000..66204f7 --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/download.go @@ -0,0 +1,133 @@ +package mise + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/runtime/mise/miserelease" +) + +const maxDownloadAttempts = 4 + +type downloader struct { + client *http.Client + baseURL string + backoff time.Duration +} + +func defaultDownloader() downloader { + return downloader{ + baseURL: miserelease.BaseURL, + backoff: time.Second, + client: &http.Client{ + Timeout: 2 * time.Minute, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return fmt.Errorf("mise download cannot redirect to non-HTTPS URL") + } + if len(via) >= 10 { + return fmt.Errorf("mise download exceeded redirect limit") + } + return nil + }, + }, + } +} + +// fetch returns a verified temporary archive. The caller owns its removal. +// Dependencies are injected internally for local HTTP tests, never via a +// user-controlled production download URL or a shell installer. +func (d downloader) fetch(ctx context.Context, dir string, a releaseAsset) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + for attempt := 1; ; attempt++ { + if err := ctx.Err(); err != nil { + return "", err + } + path, retry, err := d.attempt(ctx, dir, a) + if err == nil { + return path, nil + } + if ctx.Err() != nil { + return "", ctx.Err() + } + if !retry || attempt == maxDownloadAttempts { + return "", fmt.Errorf("mise download failed after %d attempt(s): %w", attempt, err) + } + timer := time.NewTimer(d.backoff << (attempt - 1)) + select { + case <-ctx.Done(): + timer.Stop() + return "", ctx.Err() + case <-timer.C: + } + } +} + +func (d downloader) attempt(ctx context.Context, dir string, a releaseAsset) (path string, retry bool, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.baseURL+"/"+a.Filename(), nil) + if err != nil { + return "", false, err + } + req.Header.Set("User-Agent", "one-cli/mise-bootstrap") + res, err := d.client.Do(req) + if err != nil { + return "", true, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + retry = res.StatusCode == http.StatusRequestTimeout || res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 && res.StatusCode < 600 + return "", retry, fmt.Errorf("official mise release returned HTTP %d", res.StatusCode) + } + if res.ContentLength > maxArchiveSize { + return "", false, fmt.Errorf("mise archive exceeds size limit") + } + f, err := os.CreateTemp(dir, ".fetch-*") + if err != nil { + return "", false, err + } + keep := false + defer func() { + if !keep { + os.Remove(f.Name()) + } + }() + hash := sha256.New() + body := &archiveReader{Reader: res.Body} + n, copyErr := io.Copy(io.MultiWriter(f, hash), io.LimitReader(body, maxArchiveSize+1)) + closeErr := f.Close() + if copyErr != nil { + return "", body.err != nil, copyErr + } + if closeErr != nil { + return "", false, closeErr + } + if n > maxArchiveSize { + return "", false, fmt.Errorf("mise archive exceeds size limit") + } + if fmt.Sprintf("%x", hash.Sum(nil)) != a.ArchiveSHA256 { + return "", false, fmt.Errorf("mise archive SHA256 mismatch: %s", filepath.Base(a.Filename())) + } + keep = true + return f.Name(), false, nil +} + +// Distinguish a retryable interrupted response from a failed local file write. +type archiveReader struct { + io.Reader + err error +} + +func (r *archiveReader) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + if err != nil && err != io.EOF { + r.err = err + } + return n, err +} diff --git a/packages/cli/internal/adapters/runtime/mise/download_test.go b/packages/cli/internal/adapters/runtime/mise/download_test.go new file mode 100644 index 0000000..c9a6e43 --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/download_test.go @@ -0,0 +1,165 @@ +package mise + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + "time" +) + +func serveArchive(t *testing.T, archive []byte) (downloader, *httptest.Server, *atomic.Int32) { + t.Helper() + requests := new(atomic.Int32) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + _, _ = w.Write(archive) + })) + t.Cleanup(server.Close) + return downloader{client: server.Client(), baseURL: server.URL, backoff: time.Millisecond}, server, requests +} + +func TestDownloadRetriesOnlyTransientFailures(t *testing.T) { + for _, status := range []int{http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusBadGateway, http.StatusGatewayTimeout, http.StatusOK, http.StatusNotFound, http.StatusForbidden} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + t.Parallel() + archive := []byte("verified archive") + a := releaseAsset{Platform: "test", Format: "zip", ArchiveSHA256: fmt.Sprintf("%x", sha256.Sum256(archive))} + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/"+a.Filename() || r.Header.Get("User-Agent") != "one-cli/mise-bootstrap" { + t.Errorf("unexpected download request: %s %s", r.URL.Path, r.Header.Get("User-Agent")) + } + if requests.Add(1) == 1 { + if status == http.StatusOK { + w.Header().Set("Content-Length", "100") // Interrupted response. + } + w.WriteHeader(status) + _, _ = w.Write([]byte("partial")) + return + } + _, _ = w.Write(archive) + })) + defer server.Close() + d := downloader{client: server.Client(), baseURL: server.URL, backoff: time.Millisecond} + dir := t.TempDir() + path, err := d.fetch(context.Background(), dir, a) + if status == http.StatusNotFound || status == http.StatusForbidden { + if err == nil || requests.Load() != 1 || !strings.Contains(err.Error(), fmt.Sprint(status)) { + t.Fatalf("permanent failure: requests=%d err=%v", requests.Load(), err) + } + } else { + if err != nil || requests.Load() != 2 { + t.Fatalf("retry: requests=%d err=%v", requests.Load(), err) + } + if err := verifyFile(path, a.ArchiveSHA256, maxArchiveSize); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } + assertNoTemporaryFiles(t, dir) + }) + } +} + +func TestDownloadRejectsDigestAndOversizedResponseWithoutRetry(t *testing.T) { + for _, oversized := range []bool{false, true} { + t.Run(fmt.Sprint(oversized), func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if oversized { + w.Header().Set("Content-Length", fmt.Sprint(maxArchiveSize+1)) + } + _, _ = w.Write([]byte("bad digest")) + })) + defer server.Close() + d := downloader{client: server.Client(), baseURL: server.URL} + dir := t.TempDir() + _, err := d.fetch(context.Background(), dir, releaseAsset{Format: "zip"}) + if err == nil || requests.Load() != 1 { + t.Fatalf("unverified response: requests=%d err=%v", requests.Load(), err) + } + assertNoTemporaryFiles(t, dir) + }) + } +} + +func TestDownloadRetryLimitAndCancellation(t *testing.T) { + for _, cancelWait := range []bool{false, true} { + t.Run(fmt.Sprint(cancelWait), func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + d := downloader{client: server.Client(), baseURL: server.URL, backoff: time.Millisecond} + ctx := context.Background() + if cancelWait { + d.backoff = time.Second + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + } + dir := t.TempDir() + _, err := d.fetch(ctx, dir, releaseAsset{Format: "zip"}) + if cancelWait { + if !errors.Is(err, context.DeadlineExceeded) || requests.Load() != 1 { + t.Fatalf("retry cancellation: %d %v", requests.Load(), err) + } + } else if err == nil || requests.Load() != maxDownloadAttempts { + t.Fatalf("retry limit: %d %v", requests.Load(), err) + } + assertNoTemporaryFiles(t, dir) + }) + } +} + +func TestDownloadCancelsInFlightResponse(t *testing.T) { + started := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("partial")) + w.(http.Flusher).Flush() + close(started) + <-r.Context().Done() + })) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { <-started; cancel() }() + dir := t.TempDir() + d := downloader{client: server.Client(), baseURL: server.URL} + if _, err := d.fetch(ctx, dir, releaseAsset{Format: "zip"}); !errors.Is(err, context.Canceled) { + t.Fatalf("in-flight cancellation: %v", err) + } + assertNoTemporaryFiles(t, dir) +} + +func TestOfficialDownloadRejectsHTTPSDowngrade(t *testing.T) { + var targetRequests atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetRequests.Add(1) })) + defer target.Close() + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer server.Close() + d := defaultDownloader() + d.client.Transport = server.Client().Transport + d.baseURL, d.backoff = server.URL, 0 + if _, err := d.fetch(context.Background(), t.TempDir(), releaseAsset{Format: "zip"}); err == nil || !strings.Contains(err.Error(), "non-HTTPS") { + t.Fatalf("downgrade: %v", err) + } + if targetRequests.Load() != 0 { + t.Fatal("followed insecure redirect") + } +} diff --git a/packages/cli/internal/adapters/runtime/mise/embed_darwin_amd64.go b/packages/cli/internal/adapters/runtime/mise/embed_darwin_amd64.go deleted file mode 100644 index 717d2d5..0000000 --- a/packages/cli/internal/adapters/runtime/mise/embed_darwin_amd64.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build darwin && amd64 - -package mise - -import _ "embed" - -//go:embed assets/mise-darwin-amd64.tar.gz -var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/embed_darwin_arm64.go b/packages/cli/internal/adapters/runtime/mise/embed_darwin_arm64.go deleted file mode 100644 index 9c8115e..0000000 --- a/packages/cli/internal/adapters/runtime/mise/embed_darwin_arm64.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build darwin && arm64 - -package mise - -import _ "embed" - -//go:embed assets/mise-darwin-arm64.tar.gz -var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/embed_linux_amd64.go b/packages/cli/internal/adapters/runtime/mise/embed_linux_amd64.go deleted file mode 100644 index b65c646..0000000 --- a/packages/cli/internal/adapters/runtime/mise/embed_linux_amd64.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build linux && amd64 - -package mise - -import _ "embed" - -//go:embed assets/mise-linux-amd64.tar.gz -var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/embed_linux_arm64.go b/packages/cli/internal/adapters/runtime/mise/embed_linux_arm64.go deleted file mode 100644 index 592e83b..0000000 --- a/packages/cli/internal/adapters/runtime/mise/embed_linux_arm64.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build linux && arm64 - -package mise - -import _ "embed" - -//go:embed assets/mise-linux-arm64.tar.gz -var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/embed_other.go b/packages/cli/internal/adapters/runtime/mise/embed_other.go deleted file mode 100644 index cb6da8a..0000000 --- a/packages/cli/internal/adapters/runtime/mise/embed_other.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build (!linux && !darwin && !windows) || (!amd64 && !arm64) || (windows && arm64) - -package mise - -var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/embed_windows_amd64.go b/packages/cli/internal/adapters/runtime/mise/embed_windows_amd64.go deleted file mode 100644 index 2f174ad..0000000 --- a/packages/cli/internal/adapters/runtime/mise/embed_windows_amd64.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build windows && amd64 - -package mise - -import _ "embed" - -//go:embed assets/mise-windows-amd64.zip -var bundledArchive []byte diff --git a/packages/cli/internal/adapters/runtime/mise/install.go b/packages/cli/internal/adapters/runtime/mise/install.go index a621cbe..fcf4907 100644 --- a/packages/cli/internal/adapters/runtime/mise/install.go +++ b/packages/cli/internal/adapters/runtime/mise/install.go @@ -3,7 +3,6 @@ package mise import ( "archive/tar" "archive/zip" - "bytes" "compress/gzip" "context" "crypto/sha256" @@ -17,39 +16,24 @@ import ( "github.com/gofrs/flock" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/userdirs" ) const maxArchiveSize = 256 << 20 const maxBinarySize = 512 << 20 -// installer owns only One's private versioned runtime cache. It never changes +// installer owns only One's private versioned runtime directory. It never changes // PATH, shell configuration, an existing mise installation, or config trust. type installer struct { - root string - archive []byte - out io.Writer -} - -func defaultInstaller() (installer, error) { - cache := os.Getenv("XDG_CACHE_HOME") - if cache == "" { - home, err := userdirs.Home() - if err != nil { - return installer{}, err - } - cache = filepath.Join(home, ".cache") - } - if !filepath.IsAbs(cache) { - return installer{}, fmt.Errorf("mise runtime cache must have an absolute path") - } - return installer{ - root: filepath.Join(cache, "one", "runtimes", "mise"), - archive: bundledArchive, out: os.Stderr, - }, nil + root string + legacyRoot string + downloader downloader + out io.Writer } func (i installer) ensure(ctx context.Context, a releaseAsset) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } dir := filepath.Join(i.root, managedVersion, a.Platform) target := filepath.Join(dir, a.BinaryName()) if err := verifyExecutable(target, a.BinarySHA256); err == nil { @@ -58,12 +42,6 @@ func (i installer) ensure(ctx context.Context, a releaseAsset) (string, error) { if err := ctx.Err(); err != nil { return "", err } - if len(i.archive) == 0 { - return "", fmt.Errorf("this One build has no bundled mise for the current platform") - } - if len(i.archive) > maxArchiveSize || fmt.Sprintf("%x", sha256.Sum256(i.archive)) != a.ArchiveSHA256 { - return "", fmt.Errorf("bundled mise archive SHA256 mismatch") - } if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } @@ -81,39 +59,66 @@ func (i installer) ensure(ctx context.Context, a releaseAsset) (string, error) { if err := verifyExecutable(target, a.BinarySHA256); err == nil { return target, nil } + if i.legacyRoot != "" { + legacy := filepath.Join(i.legacyRoot, managedVersion, a.Platform, a.BinaryName()) + if verifyExecutable(legacy, a.BinarySHA256) == nil { + if i.out != nil { + fmt.Fprintf(i.out, "[one] Migrating verified mise %s to %s.\n", managedVersion, dir) + } + return target, publishBinary(ctx, target, a.BinarySHA256, func(dest io.Writer) error { + f, err := os.Open(legacy) + if err != nil { + return err + } + defer f.Close() + return copyLimited(contextWriter{ctx: ctx, dest: dest}, f, maxBinarySize) + }) + } + } if i.out != nil { - fmt.Fprintf(i.out, "[one] Preparing bundled mise %s (%s); extracting to the One runtime cache.\n", managedVersion, a.Platform) + fmt.Fprintf(i.out, "[one] Downloading official mise %s (%s) into %s.\n", managedVersion, a.Platform, dir) + } + archive, err := i.downloader.fetch(ctx, dir, a) + if err != nil { + return "", err } + defer os.Remove(archive) + return target, publishBinary(ctx, target, a.BinarySHA256, func(dest io.Writer) error { + return extractBinary(ctx, archive, a, dest) + }) +} +func publishBinary(ctx context.Context, target, digest string, write func(io.Writer) error) error { + dir := filepath.Dir(target) binary, err := os.CreateTemp(dir, ".extract-*") if err != nil { - return "", err + return err } defer os.Remove(binary.Name()) - err = extractBinary(ctx, i.archive, a, binary) + err = write(binary) if err == nil { err = binary.Sync() } closeErr := binary.Close() if err != nil { - return "", err + return err } if closeErr != nil { - return "", closeErr + return closeErr } - if err := verifyFile(binary.Name(), a.BinarySHA256, maxBinarySize); err != nil { - return "", fmt.Errorf("mise executable verification failed: %w", err) + if err := verifyFile(binary.Name(), digest, maxBinarySize); err != nil { + return fmt.Errorf("mise executable verification failed: %w", err) } if err := ctx.Err(); err != nil { - return "", err + return err } if err := os.Chmod(binary.Name(), 0o755); err != nil { - return "", err + return err } if err := fsutil.ReplaceFile(binary.Name(), target); err != nil { - return "", err + return err } - return target, fsutil.SyncDir(dir) + return fsutil.SyncDir(dir) } func verifyExecutable(path, expected string) error { @@ -166,21 +171,36 @@ func copyLimited(dest io.Writer, source io.Reader, limit int64) error { // Extract only the expected executable into a caller-owned temporary file. // Archive paths are never joined to a destination or extracted as directories. -func extractBinary(ctx context.Context, archive []byte, a releaseAsset, dest io.Writer) error { +func extractBinary(ctx context.Context, archive string, a releaseAsset, dest io.Writer) error { + f, err := os.Open(archive) + if err != nil { + return err + } + defer f.Close() dest = contextWriter{ctx: ctx, dest: dest} match := func(name string) bool { name = strings.TrimPrefix(name, "./") return name == "mise/bin/"+a.BinaryName() || name == a.BinaryName() } if a.Format == "zip" { - z, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + info, err := f.Stat() + if err != nil { + return err + } + z, err := zip.NewReader(f, info.Size()) if err != nil { return err } for _, entry := range z.File { + if err := ctx.Err(); err != nil { + return err + } if !match(entry.Name) || !entry.Mode().IsRegular() { continue } + if entry.UncompressedSize64 > maxBinarySize { + return fmt.Errorf("mise executable exceeds size limit") + } r, err := entry.Open() if err != nil { return err @@ -189,13 +209,16 @@ func extractBinary(ctx context.Context, archive []byte, a releaseAsset, dest io. return copyLimited(dest, r, maxBinarySize) } } else { - gz, err := gzip.NewReader(bytes.NewReader(archive)) + gz, err := gzip.NewReader(f) if err != nil { return err } defer gz.Close() tr := tar.NewReader(gz) for { + if err := ctx.Err(); err != nil { + return err + } entry, err := tr.Next() if err == io.EOF { break @@ -204,6 +227,9 @@ func extractBinary(ctx context.Context, archive []byte, a releaseAsset, dest io. return err } if match(entry.Name) && entry.Typeflag == tar.TypeReg { + if entry.Size > maxBinarySize { + return fmt.Errorf("mise executable exceeds size limit") + } return copyLimited(dest, tr, maxBinarySize) } } diff --git a/packages/cli/internal/adapters/runtime/mise/install_test.go b/packages/cli/internal/adapters/runtime/mise/install_test.go index f9f7a6a..376f8aa 100644 --- a/packages/cli/internal/adapters/runtime/mise/install_test.go +++ b/packages/cli/internal/adapters/runtime/mise/install_test.go @@ -8,13 +8,16 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" + "github.com/gofrs/flock" + "io" "os" "path/filepath" "strings" "sync" - "sync/atomic" "testing" + "time" ) func archiveFixture(t *testing.T, format string, binary []byte) (releaseAsset, []byte) { @@ -84,11 +87,10 @@ func TestReleaseAssetsCoverSupportedOnePlatforms(t *testing.T) { func TestManagedInstallConcurrentOfflineReuseAndRepair(t *testing.T) { for _, format := range []string{"tar.gz", "zip"} { t.Run(format, func(t *testing.T) { - binary := []byte("verified executable fixture") - a, archive := archiveFixture(t, format, binary) - var preparations countWriter + a, archive := archiveFixture(t, format, []byte("verified executable fixture")) + d, server, requests := serveArchive(t, archive) root := t.TempDir() - i := installer{root: root, archive: archive, out: &preparations} + i := installer{root: root, downloader: d} var wg sync.WaitGroup for range 8 { wg.Add(1) @@ -100,8 +102,8 @@ func TestManagedInstallConcurrentOfflineReuseAndRepair(t *testing.T) { }() } wg.Wait() - if preparations.Load() != 1 { - t.Fatalf("extracted %d times", preparations.Load()) + if requests.Load() != 1 { + t.Fatalf("downloaded %d times", requests.Load()) } path, err := i.ensure(context.Background(), a) if err != nil { @@ -116,18 +118,26 @@ func TestManagedInstallConcurrentOfflineReuseAndRepair(t *testing.T) { if _, err := i.ensure(context.Background(), a); err != nil { t.Fatal(err) } - if preparations.Load() != 2 { - t.Fatal("cache was not repaired") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if _, err := i.ensure(context.Background(), a); err != nil { + t.Fatal(err) + } + if requests.Load() != 3 { + t.Fatalf("repair requests = %d, want 3", requests.Load()) } + server.Close() if _, err := i.ensure(context.Background(), a); err != nil { t.Fatalf("offline cache: %v", err) } + assertNoTemporaryFiles(t, filepath.Dir(path)) }) } } func TestManagedInstallFailureNeverPublishesOrReplacesBinary(t *testing.T) { - for _, failure := range []string{"missing-archive", "archive-digest", "binary-digest", "cancelled"} { + for _, failure := range []string{"archive-digest", "binary-digest", "cancelled", "invalid-archive"} { t.Run(failure, func(t *testing.T) { a, archive := archiveFixture(t, "tar.gz", []byte("expected binary")) if failure == "archive-digest" { @@ -136,10 +146,12 @@ func TestManagedInstallFailureNeverPublishesOrReplacesBinary(t *testing.T) { if failure == "binary-digest" { a.BinarySHA256 = strings.Repeat("0", 64) } - if failure == "missing-archive" { - archive = nil + if failure == "invalid-archive" { + archive = []byte("not a tarball") + a.ArchiveSHA256 = fmt.Sprintf("%x", sha256.Sum256(archive)) } - i := installer{root: t.TempDir(), archive: archive} + d, _, requests := serveArchive(t, archive) + i := installer{root: t.TempDir(), downloader: d} dir := filepath.Join(i.root, managedVersion, a.Platform) if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) @@ -160,67 +172,121 @@ func TestManagedInstallFailureNeverPublishesOrReplacesBinary(t *testing.T) { if err != nil || string(raw) != "previous file" { t.Fatalf("previous binary modified: %v %q", err, raw) } - entries, err := os.ReadDir(dir) + if failure == "cancelled" && requests.Load() != 0 { + t.Fatal("cancelled install downloaded") + } + assertNoTemporaryFiles(t, dir) + }) + } +} + +func TestManagedInstallMigratesOnlyVerifiedLegacyBinary(t *testing.T) { + for _, valid := range []bool{true, false} { + t.Run(fmt.Sprint(valid), func(t *testing.T) { + binary := []byte("verified legacy binary") + a, archive := archiveFixture(t, "tar.gz", binary) + d, server, requests := serveArchive(t, archive) + i := installer{root: filepath.Join(t.TempDir(), "new"), legacyRoot: filepath.Join(t.TempDir(), "old"), downloader: d} + legacy := filepath.Join(i.legacyRoot, managedVersion, a.Platform, a.BinaryName()) + if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil { + t.Fatal(err) + } + old := binary + if !valid { + old = []byte("corrupt legacy") + } else { + server.Close() + } + if err := os.WriteFile(legacy, old, 0o755); err != nil { + t.Fatal(err) + } + target, err := i.ensure(context.Background(), a) if err != nil { t.Fatal(err) } - for _, entry := range entries { - if strings.HasPrefix(entry.Name(), ".extract-") { - t.Errorf("temporary file leaked: %s", entry.Name()) - } + if err := verifyExecutable(target, a.BinarySHA256); err != nil { + t.Fatal(err) + } + preserved, err := os.ReadFile(legacy) + if err != nil || !bytes.Equal(preserved, old) { + t.Fatal("legacy cache changed") + } + want := int32(1) + if valid { + want = 0 + } + if requests.Load() != want { + t.Fatalf("requests = %d, want %d", requests.Load(), want) } }) } } -func TestMissingBundleHasNoSideEffects(t *testing.T) { - a, _ := archiveFixture(t, "tar.gz", []byte("binary")) - root := filepath.Join(t.TempDir(), "not-created") - i := installer{root: root} - if _, err := i.ensure(context.Background(), a); err == nil { - t.Fatal("missing runtime accepted") +func TestManagedInstallCancellationWhileWaitingForLock(t *testing.T) { + a, archive := archiveFixture(t, "tar.gz", []byte("binary")) + d, _, requests := serveArchive(t, archive) + i := installer{root: t.TempDir(), downloader: d} + dir := filepath.Join(i.root, managedVersion, a.Platform) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) } - if _, err := os.Stat(root); !os.IsNotExist(err) { - t.Fatal("missing bundle wrote files") + lock := flock.New(filepath.Join(dir, ".install.lock")) + if err := lock.Lock(); err != nil { + t.Fatal(err) } -} - -// The real per-platform resource must unpack to the separately pinned digest. -func TestBundledOfficialArchive(t *testing.T) { - a, err := currentAsset() - if err != nil { - t.Skip(err) + defer lock.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if _, err := i.ensure(ctx, a); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("lock cancellation: %v", err) } - i := installer{root: t.TempDir(), archive: bundledArchive} - if _, err := i.ensure(context.Background(), a); err != nil { - t.Fatal(err) + if requests.Load() != 0 { + t.Fatal("downloaded before obtaining lock") } } -// sync-mise-all makes foreign release resources available for cross-builds. -// Validate their executable digests too, without executing foreign binaries. -func TestPreparedCrossPlatformArchives(t *testing.T) { - for _, target := range [][2]string{{"linux", "amd64"}, {"linux", "arm64"}, {"darwin", "amd64"}, {"darwin", "arm64"}, {"windows", "amd64"}} { - t.Run(target[0]+"-"+target[1], func(t *testing.T) { - a, err := assetFor(target[0], target[1]) - if err != nil { +func TestPublishFailureAndCancellationPreserveDestination(t *testing.T) { + for _, cancelWrite := range []bool{false, true} { + t.Run(fmt.Sprint(cancelWrite), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "mise") + if err := os.WriteFile(path, []byte("previous"), 0o755); err != nil { t.Fatal(err) } - archive, err := os.ReadFile(filepath.Join("assets", "mise-"+target[0]+"-"+target[1]+"."+a.Format)) - if os.IsNotExist(err) { - t.Skip("cross-platform resources are prepared by task sync-mise-all") - } - if err != nil { - t.Fatal(err) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + data := []byte("new") + digest := fmt.Sprintf("%x", sha256.Sum256(data)) + err := publishBinary(ctx, path, digest, func(w io.Writer) error { + if _, err := w.Write(data); err != nil { + return err + } + if cancelWrite { + cancel() + return nil + } + return errors.New("local write failed") + }) + if err == nil { + t.Fatal("failed write published") } - i := installer{root: t.TempDir(), archive: archive} - if _, err := i.ensure(context.Background(), a); err != nil { - t.Fatal(err) + got, err := os.ReadFile(path) + if err != nil || string(got) != "previous" { + t.Fatalf("destination: %q %v", got, err) } + assertNoTemporaryFiles(t, filepath.Dir(path)) }) } } -type countWriter struct{ atomic.Int32 } - -func (w *countWriter) Write(p []byte) (int, error) { w.Add(1); return len(p), nil } +func assertNoTemporaryFiles(t *testing.T, dir string) { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".extract-") || strings.HasPrefix(entry.Name(), ".fetch-") { + t.Errorf("temporary file leaked: %s", entry.Name()) + } + } +} diff --git a/packages/cli/internal/adapters/runtime/mise/mise.go b/packages/cli/internal/adapters/runtime/mise/mise.go index 7bc2604..bab49a3 100644 --- a/packages/cli/internal/adapters/runtime/mise/mise.go +++ b/packages/cli/internal/adapters/runtime/mise/mise.go @@ -1,9 +1,10 @@ -// Package mise prepares commands through an explicitly selected or bundled mise CLI. +// Package mise prepares commands using an external or One-managed mise CLI. package mise import ( "context" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -25,49 +26,133 @@ func (p Provider) Prepare(ctx context.Context, command runtimeport.Command) (run } func (Provider) PrepareCLI(ctx context.Context, command runtimeport.Command) (runtimeport.Command, error) { - path, err := resolveBinary(ctx, command) + paths, pathsErr := defaultPaths() + i := installer{root: paths.runtimeRoot(), legacyRoot: paths.legacyRoot(), downloader: defaultDownloader(), out: os.Stderr} + r := binaryResolver{paths: paths, pathsErr: pathsErr, asset: currentAsset, install: i.ensure, out: os.Stderr} + selected, err := r.resolve(ctx, command) if err != nil { return command, err } - command.Argv = append([]string{path}, command.Argv...) - command.Env = prependRuntimePath(command.Env, filepath.Dir(path)) - if os.Getenv("ONE_MISE_BINARY") == "" { - command.Env = pinnedRuntimeEnv(command.Env) + command.Argv = append([]string{selected.path}, command.Argv...) + command.Env = prependRuntimePath(command.Env, filepath.Dir(selected.path)) + if selected.managed { + command.Env = paths.environment(command.Env) } return command, nil } -func resolveBinary(ctx context.Context, command runtimeport.Command) (string, error) { +type resolvedBinary struct { + path string + source string + managed bool +} + +type binaryResolver struct { + paths runtimePaths + pathsErr error + asset func() (releaseAsset, error) + install func(context.Context, releaseAsset) (string, error) + out io.Writer +} + +func (r binaryResolver) resolve(ctx context.Context, command runtimeport.Command) (resolvedBinary, error) { + if err := ctx.Err(); err != nil { + return resolvedBinary{}, err + } if override := os.Getenv("ONE_MISE_BINARY"); override != "" { path, err := filepath.Abs(override) if err != nil { - return "", err + return resolvedBinary{}, err } - if err := checkVersion(ctx, path, command); err != nil { - return "", err + owned := r.paths.owns(path) + if owned { + if r.pathsErr != nil { + return resolvedBinary{}, r.pathsErr + } + a, err := r.asset() + if err != nil { + return resolvedBinary{}, err + } + if err := verifyExecutable(canonicalPath(path), a.BinarySHA256); err != nil { + return resolvedBinary{}, cliErrors.New(cliErrors.MISE_INSTALL_FAILED, "Explicit One-managed mise failed verification: "+err.Error()) + } + } else if err := checkVersion(ctx, path, command); err != nil { + return resolvedBinary{}, err + } + return resolvedBinary{path: path, source: "explicit", managed: owned}, nil + } + for _, path := range systemCandidates(command.Env) { + if r.paths.owns(path) { + continue + } + if err := checkVersion(ctx, path, command); err == nil { + return resolvedBinary{path: path, source: "system"}, nil + } else if ctx.Err() != nil { + return resolvedBinary{}, ctx.Err() + } else if r.out != nil { + fmt.Fprintf(r.out, "[one] Skipping unavailable or incompatible mise at %s: %v\n", path, err) } - return path, nil } if err := ctx.Err(); err != nil { - return "", err + return resolvedBinary{}, err } - a, err := currentAsset() - if err != nil { - return "", cliErrors.New(cliErrors.MISE_INSTALL_FAILED, err.Error()) + if r.pathsErr != nil { + return resolvedBinary{}, cliErrors.New(cliErrors.MISE_INSTALL_FAILED, r.pathsErr.Error()) } - i, err := defaultInstaller() + a, err := r.asset() if err != nil { - return "", cliErrors.New(cliErrors.MISE_INSTALL_FAILED, err.Error()) + return resolvedBinary{}, cliErrors.New(cliErrors.MISE_INSTALL_FAILED, err.Error()) } - path, err := i.ensure(ctx, a) + path, err := r.install(ctx, a) if err != nil { - return "", cliErrors.New(cliErrors.MISE_INSTALL_FAILED, "Could not prepare bundled mise: "+err.Error()).WithRemediation(output.Remediation{ - Action: "prepare-mise", Hint: "Ensure the One runtime cache is writable and executable, then retry. Reinstall One if its bundled archive is damaged, or set ONE_MISE_BINARY to a compatible executable. ONE_RUNTIME=builtin uses existing tools for diagnostics.", + return resolvedBinary{}, cliErrors.New(cliErrors.MISE_INSTALL_FAILED, fmt.Sprintf("Could not prepare mise %s (%s) in %s: %v", managedVersion, a.Platform, r.paths.runtimeRoot(), err)).WithRemediation(output.Remediation{ + Action: "prepare-mise", Hint: "Check network/proxy access to GitHub Releases and permissions on the One runtime directory, then retry the same command. For offline use, install a compatible mise on PATH or set ONE_MISE_BINARY to its executable. ONE_RUNTIME=builtin uses existing tools for diagnostics.", }) } - // The executable digest already pins the bundled version. Probing it again - // would add startup work and mise's default online update check. - return path, nil + // The executable digest pins the managed version; no online probe is needed. + return resolvedBinary{path: path, source: "managed", managed: true}, nil +} + +func systemCandidates(env []string) []string { + names := []string{"mise"} + if runtime.GOOS == "windows" { + names = nil + extensions := envValue(env, "PATHEXT") + if extensions == "" { + extensions = ".COM;.EXE;.BAT;.CMD" + } + for _, ext := range strings.Split(extensions, ";") { + // Native executables only: no implicit shell or command-script execution. + if strings.EqualFold(ext, ".exe") || strings.EqualFold(ext, ".com") { + names = append(names, "mise"+strings.ToLower(ext)) + } + } + } + var paths []string + seen := make(map[string]bool) + for _, dir := range filepath.SplitList(envValue(env, "PATH")) { + // Like exec.ErrDot, do not silently run project-local executables. + if !filepath.IsAbs(dir) { + continue + } + for _, name := range names { + path := filepath.Join(dir, name) + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + continue + } + key := canonicalPath(path) + if runtime.GOOS == "windows" { + key = strings.ToLower(key) + } + if seen[key] { + continue + } + seen[key] = true + paths = append(paths, path) + } + } + return paths } func checkVersion(ctx context.Context, path string, command runtimeport.Command) error { @@ -77,8 +162,12 @@ func checkVersion(ctx context.Context, path string, command runtimeport.Command) probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() probe := exec.CommandContext(probeCtx, path, "--version") + probe.WaitDelay = time.Second probe.Dir, probe.Env = command.Directory, pinnedRuntimeEnv(command.Env) version, err := probe.Output() + if ctx.Err() != nil { + return ctx.Err() + } if err != nil { return cliErrors.New(cliErrors.MISE_VERSION_UNSUPPORTED, "Could not read the mise version: "+err.Error()) } @@ -88,38 +177,18 @@ func checkVersion(ctx context.Context, path string, command runtimeport.Command) return nil } -// One owns updates to its bundled runtime. These process-local settings also +// One owns updates to its managed runtime. These process-local settings also // keep a --version probe from consulting the network or replacing the binary. func pinnedRuntimeEnv(env []string) []string { - if env == nil { - env = os.Environ() - } - result := make([]string, 0, len(env)+2) - for _, kv := range env { - key, _, _ := strings.Cut(kv, "=") - if runtime.GOOS == "windows" { - key = strings.ToUpper(key) - } - if key != "MISE_AUTO_UPDATE" && key != "MISE_DISABLE_UPDATE_WARNING" { - result = append(result, kv) - } - } - return append(result, "MISE_AUTO_UPDATE=false", "MISE_DISABLE_UPDATE_WARNING=true") + return replaceEnv(env, "MISE_AUTO_UPDATE=false", "MISE_DISABLE_UPDATE_WARNING=true") } func prependRuntimePath(env []string, dir string) []string { - if env == nil { - env = os.Environ() - } - result := append([]string(nil), env...) - for i, kv := range result { - key, value, found := strings.Cut(kv, "=") - if found && (key == "PATH" || runtime.GOOS == "windows" && strings.EqualFold(key, "PATH")) { - result[i] = key + "=" + dir + string(os.PathListSeparator) + value - return result - } + value := envValue(env, "PATH") + if value != "" { + dir += string(os.PathListSeparator) + value } - return append(result, "PATH="+dir) + return replaceEnv(env, "PATH="+dir) } func supportedVersion(value string) bool { @@ -131,7 +200,7 @@ func supportedVersion(value string) bool { if len(parts) != 3 { return false } - minimum := []int{2026, 9, 7} + minimum := strings.Split(runtimeport.MinimumMiseVersion, ".") comparison := 0 for i, part := range parts { n, err := strconv.Atoi(part) @@ -139,10 +208,11 @@ func supportedVersion(value string) bool { return false } if comparison == 0 { - if n < minimum[i] { + min, _ := strconv.Atoi(minimum[i]) + if n < min { comparison = -1 } - if n > minimum[i] { + if n > min { comparison = 1 } } diff --git a/packages/cli/internal/adapters/runtime/mise/mise_test.go b/packages/cli/internal/adapters/runtime/mise/mise_test.go index 5ab241e..5df7ed6 100644 --- a/packages/cli/internal/adapters/runtime/mise/mise_test.go +++ b/packages/cli/internal/adapters/runtime/mise/mise_test.go @@ -2,6 +2,9 @@ package mise import ( "context" + "crypto/sha256" + "errors" + "fmt" "os" "path/filepath" "runtime" @@ -11,6 +14,78 @@ import ( runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" ) +// Copies of this test executable act as portable PATH mise fixtures, including +// on Windows where shell scripts cannot be launched as native executables. +func TestMain(m *testing.M) { + if len(os.Args) == 2 && os.Args[1] == "--version" { + path, err := os.Executable() + if err == nil { + if version, err := os.ReadFile(path + ".version"); err == nil { + fmt.Print(string(version)) + os.Exit(0) + } + } + os.Exit(1) + } + os.Exit(m.Run()) +} + +func isolatedRuntimeHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("ONE_MISE_BINARY", "") + for _, key := range []string{"XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"} { + t.Setenv(key, filepath.Join(home, key)) + } + return home +} + +func fakeMise(t *testing.T, dir, version string) string { + t.Helper() + name := "mise" + if runtime.GOOS == "windows" { + name += ".exe" + } + path := filepath.Join(dir, name) + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+".version", []byte(version), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestSystemMiseUsesCommandPathWithoutInstalling(t *testing.T) { + home := isolatedRuntimeHome(t) + dir := filepath.Join(t.TempDir(), "system mise") + want := fakeMise(t, dir, "2026.9.7") + command := runtimeport.Command{Directory: home, Argv: []string{"--version"}, Env: []string{"PATH=" + dir, "KEEP=yes"}} + got, err := (Provider{}).PrepareCLI(context.Background(), command) + if err != nil { + t.Fatal(err) + } + if got.Argv[0] != want { + t.Fatalf("selected %s, want system %s", got.Argv[0], want) + } + entries, err := os.ReadDir(home) + if err != nil || len(entries) != 0 { + t.Fatalf("system resolution wrote managed files: %v %v", entries, err) + } +} + func TestSupportedVersion(t *testing.T) { for _, tt := range []struct { version string @@ -43,7 +118,7 @@ func TestExplicitBinaryIsRespectedAndVersionChecked(t *testing.T) { if prepared.Argv[0] != path || prepared.Argv[len(prepared.Argv)-1] != "" { t.Fatalf("argv: %q", prepared.Argv) } - if !strings.HasPrefix(prepared.Env[0], "PATH="+dir+string(os.PathListSeparator)) { + if !strings.HasPrefix(envValue(prepared.Env, "PATH"), dir+string(os.PathListSeparator)) { t.Fatalf("private runtime path missing: %q", prepared.Env) } if command.Env[0] != "PATH=/somewhere" { @@ -56,3 +131,128 @@ func TestExplicitBinaryIsRespectedAndVersionChecked(t *testing.T) { t.Fatal("unsupported explicit version accepted") } } + +func TestResolverRechecksSystemDeletionAndManagedRepair(t *testing.T) { + home := isolatedRuntimeHome(t) + paths, err := defaultPaths() + if err != nil { + t.Fatal(err) + } + a, archive := archiveFixture(t, "tar.gz", []byte("managed fixture")) + d, server, requests := serveArchive(t, archive) + i := installer{root: paths.runtimeRoot(), legacyRoot: paths.legacyRoot(), downloader: d} + r := binaryResolver{paths: paths, asset: func() (releaseAsset, error) { return a, nil }, install: i.ensure} + systemDir := filepath.Join(t.TempDir(), "system") + system := fakeMise(t, systemDir, "2026.9.7") + command := runtimeport.Command{Directory: home, Env: []string{"PATH=" + systemDir}} + resolve := func(source string, downloads int32) resolvedBinary { + t.Helper() + got, err := r.resolve(context.Background(), command) + if err != nil || got.source != source || requests.Load() != downloads { + t.Fatalf("resolve: %+v err=%v requests=%d; want %s/%d", got, err, requests.Load(), source, downloads) + } + return got + } + resolve("system", 0) + if err := os.Remove(system); err != nil { + t.Fatal(err) + } + managed := resolve("managed", 1) + resolve("managed", 1) + if err := os.Remove(managed.path); err != nil { + t.Fatal(err) + } + resolve("managed", 2) + fakeMise(t, systemDir, "2026.10.0") + resolve("system", 2) + if err := os.WriteFile(system+".version", []byte("2025.1.0"), 0o600); err != nil { + t.Fatal(err) + } + resolve("managed", 2) + server.Close() + resolve("managed", 2) + if err := os.Remove(managed.path); err != nil { + t.Fatal(err) + } + if _, err := r.resolve(context.Background(), command); err == nil || !strings.Contains(err.Error(), "Could not prepare mise") { + t.Fatalf("offline empty install must fail: %v", err) + } +} + +func TestResolverExplicitFailureAndCancellationDoNotInstall(t *testing.T) { + isolatedRuntimeHome(t) + r := binaryResolver{install: func(context.Context, releaseAsset) (string, error) { + t.Fatal("unexpected installation") + return "", nil + }} + path := fakeMise(t, t.TempDir(), "2025.1.0") + t.Setenv("ONE_MISE_BINARY", path) + if _, err := r.resolve(context.Background(), runtimeport.Command{}); err == nil { + t.Fatal("unsupported explicit version accepted") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if _, err := r.resolve(context.Background(), runtimeport.Command{}); err == nil { + t.Fatal("missing explicit version accepted") + } + t.Setenv("ONE_MISE_BINARY", "") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := r.resolve(ctx, runtimeport.Command{}); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation: %v", err) + } +} + +func TestResolverSkipsManagedPathAndAcceptsExternalWithoutManagedDirectories(t *testing.T) { + isolatedRuntimeHome(t) + paths, err := defaultPaths() + if err != nil { + t.Fatal(err) + } + managedDir := filepath.Join(paths.runtimeRoot(), managedVersion, "fixture") + managed := fakeMise(t, managedDir, "2026.9.7") + externalDir := t.TempDir() + external := fakeMise(t, externalDir, "2026.9.7") + r := binaryResolver{paths: paths, pathsErr: errors.New("managed directory unavailable")} + command := runtimeport.Command{Env: []string{"PATH=" + managedDir + string(os.PathListSeparator) + externalDir}} + got, err := r.resolve(context.Background(), command) + if err != nil || got.source != "system" || got.path != external { + t.Fatalf("discovery: %+v %v", got, err) + } + // A direct override inside One's tree must be verified BEFORE execution. + t.Setenv("ONE_MISE_BINARY", managed) + r.pathsErr = nil + r.asset = func() (releaseAsset, error) { return releaseAsset{BinarySHA256: strings.Repeat("0", 64)}, nil } + if _, err := r.resolve(context.Background(), command); err == nil { + t.Fatal("unverified managed override accepted") + } + raw, err := os.ReadFile(managed) + if err != nil { + t.Fatal(err) + } + r.asset = func() (releaseAsset, error) { + return releaseAsset{BinarySHA256: fmt.Sprintf("%x", sha256.Sum256(raw))}, nil + } + got, err = r.resolve(context.Background(), command) + if err != nil || !got.managed || got.source != "explicit" { + t.Fatalf("managed override: %+v %v", got, err) + } +} + +func TestExternalProviderPreservesMiseDirectories(t *testing.T) { + isolatedRuntimeHome(t) + dir := t.TempDir() + fakeMise(t, dir, "2026.9.7") + env := []string{"PATH=" + dir, "MISE_DATA_DIR=/external-data", "MISE_CONFIG_DIR=/external-config", "MISE_STATE_DIR=/external-state", "MISE_CACHE_DIR=/external-cache", "MISE_AUTO_UPDATE=true"} + got, err := (Provider{}).PrepareCLI(context.Background(), runtimeport.Command{Env: env}) + if err != nil { + t.Fatal(err) + } + for _, value := range env[1:] { + key, expected, _ := strings.Cut(value, "=") + if envValue(got.Env, key) != expected { + t.Errorf("external setting changed: %s", key) + } + } +} diff --git a/packages/cli/internal/adapters/runtime/mise/miserelease/release.go b/packages/cli/internal/adapters/runtime/mise/miserelease/release.go index 758dc82..94a8963 100644 --- a/packages/cli/internal/adapters/runtime/mise/miserelease/release.go +++ b/packages/cli/internal/adapters/runtime/mise/miserelease/release.go @@ -31,7 +31,7 @@ func ForPlatform(goos, goarch string) (Asset, error) { case "windows/amd64": a = Asset{"windows-x64", "zip", "caa1ca158f04d91f42dc2cd99bb1f69f6b5bfa0d0772d485150120aad9778685", "80552c6a4a03849707cb6154186a22795516388d06bf0760b4418729a42efe43"} default: - return a, fmt.Errorf("bundled mise is unsupported on %s/%s; set ONE_MISE_BINARY to a compatible executable", goos, goarch) + return a, fmt.Errorf("managed mise download is unsupported on %s/%s; install a compatible mise on PATH or set ONE_MISE_BINARY", goos, goarch) } return a, nil } diff --git a/packages/cli/internal/adapters/runtime/mise/paths.go b/packages/cli/internal/adapters/runtime/mise/paths.go new file mode 100644 index 0000000..22ee98d --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/paths.go @@ -0,0 +1,150 @@ +package mise + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/userdirs" +) + +type runtimePaths struct { + data, config, state, cache string +} + +// Match One's existing XDG/Home conventions on every supported platform. +// Resolve each root independently so a bad config/cache setting does not hide +// ownership of an otherwise valid runtime path during external discovery. +func defaultPaths() (runtimePaths, error) { + var paths runtimePaths + var firstErr error + for _, entry := range []struct { + key, fallback string + dest *string + }{ + {"XDG_DATA_HOME", ".local/share", &paths.data}, + {"XDG_CONFIG_HOME", ".config", &paths.config}, + {"XDG_STATE_HOME", ".local/state", &paths.state}, + {"XDG_CACHE_HOME", ".cache", &paths.cache}, + } { + root := os.Getenv(entry.key) + if root == "" { + home, err := userdirs.Home() + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + root = filepath.Join(home, filepath.FromSlash(entry.fallback)) + } + if !filepath.IsAbs(root) { + if firstErr == nil { + firstErr = fmt.Errorf("%s must resolve to an absolute path", entry.key) + } + continue + } + *entry.dest = filepath.Join(root, "one") + } + return paths, firstErr +} + +func (p runtimePaths) runtimeRoot() string { return runtimeRoot(p.data) } +func (p runtimePaths) legacyRoot() string { return runtimeRoot(p.cache) } + +func runtimeRoot(base string) string { + if base == "" { + return "" + } + return filepath.Join(base, "runtimes", "mise") +} + +func (p runtimePaths) owns(path string) bool { + for _, root := range []string{p.runtimeRoot(), p.legacyRoot()} { + if root != "" && (withinCleanPath(root, path) || withinPath(root, path)) { + return true + } + } + return false +} + +func withinPath(root, path string) bool { + root, path = canonicalPath(root), canonicalPath(path) + return withinCleanPath(root, path) +} + +func withinCleanPath(root, path string) bool { + if runtime.GOOS == "windows" { + root, path = strings.ToLower(root), strings.ToLower(path) + } + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +// Resolve existing parents too: a missing binary beneath a symlinked data root +// still belongs to One, and macOS /var and /private/var name the same tree. +func canonicalPath(path string) string { + original := filepath.Clean(path) + suffix := "" + for { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return filepath.Join(resolved, suffix) + } + parent := filepath.Dir(path) + if parent == path { + return original + } + suffix = filepath.Join(filepath.Base(path), suffix) + path = parent + } +} + +func (p runtimePaths) environment(env []string) []string { + return replaceEnv(pinnedRuntimeEnv(env), + "MISE_DATA_DIR="+filepath.Join(p.data, "mise"), + "MISE_CONFIG_DIR="+filepath.Join(p.config, "mise"), + "MISE_STATE_DIR="+filepath.Join(p.state, "mise"), + "MISE_CACHE_DIR="+filepath.Join(p.cache, "mise"), + ) +} + +func envKey(key string) string { + if runtime.GOOS == "windows" { + return strings.ToUpper(key) + } + return key +} + +func envValue(env []string, key string) string { + if env == nil { + env = os.Environ() + } + for i := len(env) - 1; i >= 0; i-- { + k, value, _ := strings.Cut(env[i], "=") + if envKey(k) == envKey(key) { + return value + } + } + return "" +} + +func replaceEnv(env []string, values ...string) []string { + if env == nil { + env = os.Environ() + } + keys := make(map[string]bool, len(values)) + for _, value := range values { + key, _, _ := strings.Cut(value, "=") + keys[envKey(key)] = true + } + result := make([]string, 0, len(env)+len(values)) + for _, value := range env { + key, _, _ := strings.Cut(value, "=") + if !keys[envKey(key)] { + result = append(result, value) + } + } + return append(result, values...) +} diff --git a/packages/cli/internal/adapters/runtime/mise/paths_test.go b/packages/cli/internal/adapters/runtime/mise/paths_test.go new file mode 100644 index 0000000..fcac28c --- /dev/null +++ b/packages/cli/internal/adapters/runtime/mise/paths_test.go @@ -0,0 +1,94 @@ +package mise + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "testing" +) + +func TestRuntimePathsDefaultsAndXDG(t *testing.T) { + home := isolatedRuntimeHome(t) + paths, err := defaultPaths() + if err != nil { + t.Fatal(err) + } + if paths.runtimeRoot() != filepath.Join(home, "XDG_DATA_HOME", "one", "runtimes", "mise") || paths.legacyRoot() != filepath.Join(home, "XDG_CACHE_HOME", "one", "runtimes", "mise") { + t.Fatalf("XDG paths: %+v", paths) + } + for _, key := range []string{"XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"} { + t.Setenv(key, "") + } + paths, err = defaultPaths() + want := runtimePaths{data: filepath.Join(home, ".local", "share", "one"), config: filepath.Join(home, ".config", "one"), state: filepath.Join(home, ".local", "state", "one"), cache: filepath.Join(home, ".cache", "one")} + if err != nil || paths != want { + t.Fatalf("default paths: %+v %v", paths, err) + } + for _, key := range []string{"XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"} { + t.Run(key, func(t *testing.T) { + t.Setenv(key, "relative") + if _, err := defaultPaths(); err == nil { + t.Fatal("relative root accepted") + } + }) + } +} + +func TestManagedEnvironmentOwnsDirectoriesWithoutMutatingCaller(t *testing.T) { + isolatedRuntimeHome(t) + paths, err := defaultPaths() + if err != nil { + t.Fatal(err) + } + env := []string{"PATH=/existing", "KEEP=value", "MISE_DATA_DIR=/external", "MISE_DATA_DIR=/duplicate", "MISE_CONFIG_DIR=/external", "MISE_STATE_DIR=/external", "MISE_CACHE_DIR=/external", "MISE_AUTO_UPDATE=true", "MISE_DISABLE_UPDATE_WARNING=false"} + before := append([]string(nil), env...) + got := paths.environment(env) + for key, value := range map[string]string{ + "MISE_DATA_DIR": filepath.Join(paths.data, "mise"), "MISE_CONFIG_DIR": filepath.Join(paths.config, "mise"), + "MISE_STATE_DIR": filepath.Join(paths.state, "mise"), "MISE_CACHE_DIR": filepath.Join(paths.cache, "mise"), + "MISE_AUTO_UPDATE": "false", "MISE_DISABLE_UPDATE_WARNING": "true", "KEEP": "value", "PATH": "/existing", + } { + if envValue(got, key) != value { + t.Errorf("%s = %s, want %s", key, envValue(got, key), value) + } + } + if !reflect.DeepEqual(env, before) || len(got) != len(env)-1 { + t.Fatalf("environment mutation or duplicate: %q -> %q", env, got) + } + if withinPath(paths.cache, paths.runtimeRoot()) || withinPath(paths.cache, filepath.Join(paths.data, "mise")) || withinPath(paths.cache, filepath.Join(paths.state, "mise")) { + t.Fatal("cache cleanup can delete runtime, tools or trust") + } +} + +func TestRuntimeOwnershipThroughSymlinkAndMissingFile(t *testing.T) { + isolatedRuntimeHome(t) + paths, err := defaultPaths() + if err != nil { + t.Fatal(err) + } + root := paths.runtimeRoot() + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "linked root") + if err := os.Symlink(root, link); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlink privilege unavailable: %v", err) + } + t.Fatal(err) + } + if !paths.owns(filepath.Join(link, "missing", "mise")) || !paths.owns(filepath.Join(paths.legacyRoot(), "missing", "mise")) { + t.Fatal("managed missing/symlink path treated as external") + } + if paths.owns(root + "-external") { + t.Fatal("path prefix collision treated as owned") + } + outward := filepath.Join(root, "outside") + if err := os.Symlink(t.TempDir(), outward); err != nil { + t.Fatal(err) + } + if !paths.owns(filepath.Join(outward, "mise")) { + t.Fatal("symlink inside runtime root bypassed ownership") + } +} diff --git a/packages/cli/internal/platform/errors/codes.go b/packages/cli/internal/platform/errors/codes.go index a980245..056637f 100644 --- a/packages/cli/internal/platform/errors/codes.go +++ b/packages/cli/internal/platform/errors/codes.go @@ -186,7 +186,7 @@ var Codes = map[Code]Definition{ }, RUNTIME_INVALID: {Summary: "The selected execution runtime is not builtin or mise."}, MISE_NOT_FOUND: {Summary: "The explicitly selected mise executable is unavailable."}, - MISE_INSTALL_FAILED: {Summary: "One could not extract, verify, or prepare its bundled mise runtime."}, + MISE_INSTALL_FAILED: {Summary: "One could not download, migrate, verify, or prepare its managed mise runtime."}, MISE_VERSION_UNSUPPORTED: {Summary: "The installed mise version is unsupported or could not be read."}, MISE_CONFIG_CONFLICT: {Summary: "A managed mise configuration was modified or changed during generation."}, HOOKS_CONFIG_CONFLICT: {Summary: "Existing Git hooks or hk configuration conflict with One's generated setup."}, diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index eb7af58..959081e 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -299,13 +299,13 @@ "dev.dependencies_missing": "Node dependencies required for local development are not installed.", "dev.install_hint": "Install workspace dependencies, then rerun the development command", "dev.install_mise_hint": "Run this installation command from the workspace root, then retry one dev. One prepares the mise runtime automatically.", - "mise.short": "Run mise commands using the runtime managed by One", + "mise.short": "Run mise commands using the runtime selected by One", "hooks.short": "Run workspace checks and fixes with hk through One", - "hooks.tip": "Use one hk check for changed files, one hk check --all for CI, and one hk fix for explicit fixes. Git hook installation and migration are managed by one configure hooks. hk and its tool environment run through One's bundled mise; application dependencies remain project-specific.", + "hooks.tip": "Use one hk check for changed files, one hk check --all for CI, and one hk fix for explicit fixes. Git hook installation and migration are managed by one configure hooks. hk and its tool environment run through the mise selected by One (system first, otherwise downloaded and managed by One); application dependencies remain project-specific.", "hooks.configure_short": "Generate hk checks, migrate default Husky hooks, and install local Git launchers", "hooks.check_failed_hint": "Run `one hk fix` from the workspace root to apply available fixes, then review and stage the changes with `git add` before retrying. hk selects each project's directory and tools automatically.", "create.hooks_warning": "Workspace created, but Git hooks need setup (%v); run one configure hooks after resolving it", - "mise.tip": "Optional access to mise for configuration trust, diagnostics, and tool installation. One includes a pinned mise runtime and extracts it locally on first use without downloading mise. Arguments and output are passed through; project environment secrets are not loaded by One here. Normal development continues to use one dev and one run.", + "mise.tip": "Optional access to mise for configuration trust, diagnostics, and tool installation. One first uses a compatible mise on PATH. Otherwise it reuses or downloads a verified, pinned runtime into its own directories; first use without a local runtime requires network access. Arguments and output are passed through; project environment secrets are not loaded by One here. Normal development continues to use one dev and one run.", "dev.install_confirm": "Dependencies are not installed. Run `%s` now and continue?", "dev.package_manager_missing": "Package manager %s was not found.", "dev.install_failed": "Running %s failed: %v", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index ca6720d..c5a6256 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -299,13 +299,13 @@ "dev.dependencies_missing": "本地开发所需的 Node 依赖尚未安装。", "dev.install_hint": "先安装工作区依赖,再重新运行开发命令", "dev.install_mise_hint": "在工作区根目录运行此安装命令,再重试 one dev。One 会自动准备 mise runtime。", - "mise.short": "通过 One 管理的 runtime 运行 mise 命令", + "mise.short": "通过 One 选择的 runtime 运行 mise 命令", "hooks.short": "通过 One 运行 hk 工作区检查与修复", - "hooks.tip": "one hk check 检查变更文件,one hk check --all 用于 CI,one hk fix 显式修复。通过 one configure hooks 安装或迁移 Git hooks。hk 及工具环境由 One 内置的 mise 提供;项目依赖由各项目管理。", + "hooks.tip": "one hk check 检查变更文件,one hk check --all 用于 CI,one hk fix 显式修复。通过 one configure hooks 安装或迁移 Git hooks。hk 及工具环境由 One 选择的 mise 提供(优先系统版本,否则由 One 下载并托管);项目依赖由各项目管理。", "hooks.configure_short": "生成 hk 检查、迁移默认 Husky hooks 并安装本地 Git 启动器", "hooks.check_failed_hint": "请在工作区根目录运行 `one hk fix` 修复可自动处理的问题,检查改动并用 `git add` 重新暂存后再重试。hk 会自动选择各项目的目录和工具。", "create.hooks_warning": "工作区已创建,但 Git hooks 尚未安装(%v);解决后运行 one configure hooks", - "mise.tip": "按需访问 mise 的配置信任、诊断和工具安装功能。One 已内置固定版本的 mise,首次使用从自身解压,无需下载 mise。参数和输出直接透传,此入口不加载 One 的项目密钥。日常开发继续使用 one dev 和 one run。", + "mise.tip": "按需访问 mise 的配置信任、诊断和工具安装功能。One 优先使用 PATH 中兼容的 mise,否则复用或下载校验过的固定版本并放入自己的目录;本地没有可用版本时首次使用需要联网。参数和输出直接透传,此入口不加载 One 的项目密钥。日常开发继续使用 one dev 和 one run。", "dev.install_confirm": "尚未安装依赖。现在运行 `%s` 并继续吗?", "dev.package_manager_missing": "找不到包管理器 %s。", "dev.install_failed": "运行 %s 失败:%v", diff --git a/packages/cli/testdata/reference/help/hk.txt b/packages/cli/testdata/reference/help/hk.txt index 4fc1601..c034635 100644 --- a/packages/cli/testdata/reference/help/hk.txt +++ b/packages/cli/testdata/reference/help/hk.txt @@ -12,7 +12,7 @@ EXAMPLES one hk validate TIPS -Use one hk check for changed files, one hk check --all for CI, and one hk fix for explicit fixes. Git hook installation and migration are managed by one configure hooks. hk and its tool environment run through One's bundled mise; application dependencies remain project-specific. +Use one hk check for changed files, one hk check --all for CI, and one hk fix for explicit fixes. Git hook installation and migration are managed by one configure hooks. hk and its tool environment run through the mise selected by One (system first, otherwise downloaded and managed by One); application dependencies remain project-specific. COMMON OPTIONS -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/mise.txt b/packages/cli/testdata/reference/help/mise.txt index 2fa6bfb..3a92542 100644 --- a/packages/cli/testdata/reference/help/mise.txt +++ b/packages/cli/testdata/reference/help/mise.txt @@ -1,6 +1,6 @@ DESCRIPTION -Run mise commands using the runtime managed by One +Run mise commands using the runtime selected by One USAGE one mise [args...] [flags] @@ -11,7 +11,7 @@ EXAMPLES one mise exec -- pnpm install TIPS -Optional access to mise for configuration trust, diagnostics, and tool installation. One includes a pinned mise runtime and extracts it locally on first use without downloading mise. Arguments and output are passed through; project environment secrets are not loaded by One here. Normal development continues to use one dev and one run. +Optional access to mise for configuration trust, diagnostics, and tool installation. One first uses a compatible mise on PATH. Otherwise it reuses or downloads a verified, pinned runtime into its own directories; first use without a local runtime requires network access. Arguments and output are passed through; project environment secrets are not loaded by One here. Normal development continues to use one dev and one run. COMMON OPTIONS -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/tests/e2e/mise_runtime_test.go b/packages/cli/tests/e2e/mise_runtime_test.go index bcca5a0..7d12800 100644 --- a/packages/cli/tests/e2e/mise_runtime_test.go +++ b/packages/cli/tests/e2e/mise_runtime_test.go @@ -1,19 +1,25 @@ package cli_test import ( + "crypto/sha256" "encoding/json" + "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" + + "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/runtime/mise/miserelease" ) func runtimeFixture(t *testing.T) string { t.Helper() root := t.TempDir() isolateHome(t, root) + t.Setenv("XDG_DATA_HOME", filepath.Join(root, ".local", "share")) + t.Setenv("XDG_STATE_HOME", filepath.Join(root, ".local", "state")) t.Setenv("ONE_RUNTIME", "") files := map[string]string{ "one.manifest.json": `{"version":1,"workspace":{"id":"runtime-test","name":"runtime-test"},"projects":[{"name":"web","relativeDir":"apps/web","toolchain":"node","templateId":"react-spa","domains":{"dev":{"command":"node dev.cjs"}}},{"name":"api","relativeDir":"services/api","toolchain":"go","templateId":"go-api","domains":{"dev":{"command":"node dev.cjs"}}}]}`, @@ -160,8 +166,10 @@ func TestE2E_MiseDryRunDoesNotRunHooksOrNeedMise(t *testing.T) { if plan.Runtime != "mise" || !plan.DryRun || strings.Contains(out, "web-secret") { t.Fatalf("bad plan: %s", out) } - if _, err := os.Stat(filepath.Join(root, ".cache/one/runtimes/mise")); !os.IsNotExist(err) { - t.Fatal("dry-run prepared the bundled runtime") + for _, rel := range []string{".cache/one/runtimes/mise", ".local/share/one/runtimes/mise"} { + if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) { + t.Fatal("dry-run prepared the runtime") + } } if _, err := os.Stat(filepath.Join(root, "mise-was-called")); !os.IsNotExist(err) { t.Fatal("preview invoked mise") @@ -211,49 +219,99 @@ func isolateMiseState(t *testing.T, root string) { t.Setenv("MISE_AUTO_INSTALL", "false") } -// No runtime network or preinstalled mise is needed, including on first use. -func TestE2E_MiseBundledFirstRunWithoutMiseOnPath(t *testing.T) { - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - t.Skip("Linux amd64 offline fixture") - } - root := runtimeFixture(t) - isolateMiseState(t, root) - t.Setenv("PATH", "/usr/bin:/bin") - if _, err := exec.LookPath("mise"); err == nil { - t.Skip("fixture requires mise absent from system directories") - } +func disableMiseNetwork(t *testing.T) { + t.Helper() for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"} { t.Setenv(key, "http://127.0.0.1:1") } t.Setenv("NO_PROXY", "") t.Setenv("no_proxy", "") +} + +func TestE2E_MiseSystemDiscoveryThenDeletionReportsDownloadFailure(t *testing.T) { + root := runtimeFixture(t) + installFakeMise(t) + system := os.Getenv("ONE_MISE_BINARY") + t.Setenv("ONE_MISE_BINARY", "") + t.Setenv("PATH", filepath.Dir(system)) + disableMiseNetwork(t) + out, stderr, code := runBinaryIn(t, root, "mise", "--version") + if code != 0 || !strings.HasPrefix(out, "2026.9.7") || strings.Contains(stderr, "Downloading") { + t.Fatalf("system mise: %d %q %s", code, out, stderr) + } + if _, err := os.Stat(filepath.Join(root, ".local/share/one/runtimes/mise")); !os.IsNotExist(err) { + t.Fatal("system selection installed a managed runtime") + } + if err := os.Remove(system); err != nil { + t.Fatal(err) + } + out, stderr, code = runBinaryIn(t, root, "mise", "--version") + if code == 0 || !strings.Contains(stderr, "Downloading official mise") || !strings.Contains(out+stderr, "MISE_INSTALL_FAILED") { + t.Fatalf("deleted system/offline: %d %q %s", code, out, stderr) + } + if _, err := os.Stat(filepath.Join(root, ".local/share/one/runtimes/mise/2026.9.7")); err != nil { + t.Fatal("runtime did not attempt managed bootstrap") + } +} + +// An opt-in official pinned binary checks real execution without putting mise +// back into build inputs. Other compatible ONE_TEST_MISE_BINARY versions still +// exercise configuration/trust tests, but cannot satisfy the managed digest. +func TestE2E_MiseManagedLegacyMigrationAndOfflineReuse(t *testing.T) { + source := os.Getenv("ONE_TEST_MISE_BINARY") + if source == "" { + t.Skip("set ONE_TEST_MISE_BINARY to the pinned official mise for managed integration") + } + a, err := miserelease.ForPlatform(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skip(err) + } + raw, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if fmt.Sprintf("%x", sha256.Sum256(raw)) != a.BinarySHA256 { + t.Skip("managed integration requires the pinned official binary digest") + } + root := runtimeFixture(t) + isolateMiseState(t, root) + t.Setenv("PATH", t.TempDir()) + disableMiseNetwork(t) + legacy := filepath.Join(root, ".cache/one/runtimes/mise", miserelease.Version, a.Platform, a.BinaryName()) + if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacy, raw, 0o755); err != nil { + t.Fatal(err) + } t.Setenv("MISE_AUTO_UPDATE", "true") t.Setenv("MISE_DISABLE_UPDATE_WARNING", "false") - out, stderr, code := runBinaryIn(t, root, "run", "web", "--", "/bin/sh", "-c", `printf '%s' "$ONE_MISE_TEST_VALUE"`) - if code != 0 || out != "web-secret" || !strings.Contains(stderr, "Preparing bundled mise") { - t.Fatalf("first run: %d %q %s", code, out, stderr) - } - // The user's auto-update preference must not mutate One's pinned runtime. - out, stderr, code = runBinaryIn(t, root, "run", "web", "--", "/bin/sh", "-c", `printf '%s %s' "$MISE_AUTO_UPDATE" "$MISE_DISABLE_UPDATE_WARNING"`) - if code != 0 || out != "false true" { - t.Fatalf("bundled update settings: %d %q %s", code, out, stderr) + out, stderr, code := runBinaryIn(t, root, "mise", "--version") + if code != 0 || !strings.HasPrefix(out, miserelease.Version) || !strings.Contains(stderr, "Migrating verified mise") { + t.Fatalf("migration: %d %q %s", code, out, stderr) } - path := filepath.Join(root, ".cache/one/runtimes/mise/2026.9.7/linux-x64-musl/mise") - if _, err := os.Stat(path); err != nil { + managed := filepath.Join(root, ".local/share/one/runtimes/mise", miserelease.Version, a.Platform, a.BinaryName()) + if _, err := os.Stat(managed); err != nil { t.Fatal(err) } - out, stderr, code = runBinaryIn(t, root, "run", "web", "--", "mise", "--version") - if code != 0 || !strings.HasPrefix(out, "2026.9.7") || strings.Contains(stderr, "Preparing bundled mise") { - t.Fatalf("cached run: %d %q %s", code, out, stderr) + if _, err := os.Stat(legacy); err != nil { + t.Fatal("migration removed legacy binary") } out, stderr, code = runBinaryIn(t, root, "mise", "--version") - if code != 0 || !strings.HasPrefix(out, "2026.9.7") { - t.Fatalf("offline mise command: %d %q %s", code, out, stderr) + if code != 0 || !strings.HasPrefix(out, miserelease.Version) || strings.Contains(stderr, "Migrating") || strings.Contains(stderr, "Downloading") { + t.Fatalf("offline reuse: %d %q %s", code, out, stderr) } - if _, err := os.Stat(filepath.Join(root, "MISE_CACHE_DIR", "latest-version")); !os.IsNotExist(err) { - t.Fatal("bundled runtime performed a version update check") + // Pin the current shell path before hiding system mise; this command checks + // real mise directory settings and update flags through its exec environment. + shell := "/bin/sh" + if runtime.GOOS != "windows" { + args := []string{"run", "web", "--", shell, "-c", `printf '%s|%s|%s|%s|%s|%s' "$MISE_DATA_DIR" "$MISE_CONFIG_DIR" "$MISE_STATE_DIR" "$MISE_CACHE_DIR" "$MISE_AUTO_UPDATE" "$MISE_DISABLE_UPDATE_WARNING"`} + out, stderr, code = runBinaryIn(t, root, args...) + want := strings.Join([]string{filepath.Join(root, ".local/share/one/mise"), filepath.Join(root, ".config/one/mise"), filepath.Join(root, ".local/state/one/mise"), filepath.Join(root, ".cache/one/mise"), "false", "true"}, "|") + if code != 0 || out != want { + t.Fatalf("managed environment: %d %q want %q %s", code, out, want, stderr) + } } - } func TestE2E_MiseCreateAddAndRefreshWithoutNewFlags(t *testing.T) { diff --git a/packages/cli/tools/sync-mise/main.go b/packages/cli/tools/sync-mise/main.go deleted file mode 100644 index d0100dd..0000000 --- a/packages/cli/tools/sync-mise/main.go +++ /dev/null @@ -1,184 +0,0 @@ -// Command sync-mise fetches verified release archives at build time. The runtime -// adapter embeds one archive per target and never downloads mise at runtime. -package main - -import ( - "context" - "crypto/sha256" - "flag" - "fmt" - "io" - "net/http" - "os" - "os/signal" - "path/filepath" - "runtime" - "time" - - "github.com/gofrs/flock" - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/runtime/mise/miserelease" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" -) - -const ( - maxArchiveSize = 256 << 20 - maxDownloadAttempts = 4 -) - -func main() { - all := flag.Bool("all", false, "prepare archives for all five One release targets") - flag.Parse() - if err := run(*all); err != nil { - fmt.Fprintln(os.Stderr, "sync-mise:", err) - os.Exit(1) - } -} - -func run(all bool) error { - root, err := os.Getwd() - if err != nil { - return err - } - for { - if _, err := os.Stat(filepath.Join(root, "go.work")); err == nil { - break - } - parent := filepath.Dir(root) - if parent == root { - return fmt.Errorf("go.work not found in current directory or its parents") - } - root = parent - } - targets := [][2]string{{runtime.GOOS, runtime.GOARCH}} - if all { - targets = [][2]string{{"linux", "amd64"}, {"linux", "arm64"}, {"darwin", "amd64"}, {"darwin", "arm64"}, {"windows", "amd64"}} - } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - client := &http.Client{Timeout: 10 * time.Minute} - for _, target := range targets { - a, err := miserelease.ForPlatform(target[0], target[1]) - if err != nil { - return err - } - path := filepath.Join(root, "packages/cli/internal/adapters/runtime/mise/assets", "mise-"+target[0]+"-"+target[1]+"."+a.Format) - if err := syncArchive(ctx, client, miserelease.BaseURL+"/"+a.Filename(), path, a.ArchiveSHA256); err != nil { - return err - } - fmt.Printf("mise %s: %s/%s verified\n", miserelease.Version, target[0], target[1]) - } - return nil -} - -func syncArchive(ctx context.Context, client *http.Client, url, path, digest string) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - lock := flock.New(path + ".lock") - locked, err := lock.TryLockContext(ctx, 100*time.Millisecond) - if err != nil { - return err - } - if !locked { - return fmt.Errorf("could not lock %s", path) - } - defer lock.Unlock() - if validArchive(path, digest) { - return nil - } - fmt.Fprintf(os.Stderr, "Fetching build resource %s\n", filepath.Base(path)) - for attempt := 1; ; attempt++ { - if err := ctx.Err(); err != nil { - return err - } - retry, err := downloadArchive(ctx, client, url, path, digest) - if err == nil { - return nil - } - if ctx.Err() != nil { - return ctx.Err() - } - if !retry { - return err - } - if attempt == maxDownloadAttempts { - return fmt.Errorf("mise build resource failed after %d attempts: %w", attempt, err) - } - delay := time.Second << (attempt - 1) - fmt.Fprintf(os.Stderr, "Fetching %s failed: %v; retrying in %s (attempt %d/%d)\n", filepath.Base(path), err, delay, attempt+1, maxDownloadAttempts) - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(delay): - } - } -} - -// Every attempt uses a fresh temporary file. Only transient download failures -// are retried; verification and local filesystem errors remain fatal. -func downloadArchive(ctx context.Context, client *http.Client, url, path, digest string) (retry bool, err error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return false, err - } - req.Header.Set("User-Agent", "one-cli/build-mise") - res, err := client.Do(req) - if err != nil { - return true, err - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - retry := res.StatusCode == http.StatusRequestTimeout || res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 && res.StatusCode < 600 - return retry, fmt.Errorf("mise build resource returned HTTP %d", res.StatusCode) - } - tmp, err := os.CreateTemp(filepath.Dir(path), ".fetch-*") - if err != nil { - return false, err - } - defer os.Remove(tmp.Name()) - hash := sha256.New() - body := &archiveReader{Reader: res.Body} - n, copyErr := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(body, maxArchiveSize+1)) - closeErr := tmp.Close() - if copyErr != nil { - return body.err != nil, copyErr - } - if closeErr != nil { - return false, closeErr - } - if n > maxArchiveSize { - return false, fmt.Errorf("mise build resource exceeds size limit") - } - if fmt.Sprintf("%x", hash.Sum(nil)) != digest { - return false, fmt.Errorf("mise archive SHA256 mismatch: %s", filepath.Base(path)) - } - return false, fsutil.ReplaceFile(tmp.Name(), path) -} - -type archiveReader struct { - io.Reader - err error -} - -func (r *archiveReader) Read(p []byte) (int, error) { - n, err := r.Reader.Read(p) - if err != nil && err != io.EOF { - r.err = err - } - return n, err -} - -func validArchive(path, digest string) bool { - info, err := os.Lstat(path) - if err != nil || !info.Mode().IsRegular() || info.Size() > maxArchiveSize { - return false - } - f, err := os.Open(path) - if err != nil { - return false - } - defer f.Close() - hash := sha256.New() - n, err := io.Copy(hash, io.LimitReader(f, maxArchiveSize+1)) - return err == nil && n <= maxArchiveSize && fmt.Sprintf("%x", hash.Sum(nil)) == digest -} diff --git a/packages/cli/tools/sync-mise/main_test.go b/packages/cli/tools/sync-mise/main_test.go deleted file mode 100644 index 52b0949..0000000 --- a/packages/cli/tools/sync-mise/main_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package main - -import ( - "context" - "crypto/sha256" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" -) - -func TestBuildResourceVerificationAndCache(t *testing.T) { - archive := []byte("pinned archive") - digest := fmt.Sprintf("%x", sha256.Sum256(archive)) - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - _, _ = w.Write(archive) - })) - defer server.Close() - path := filepath.Join(t.TempDir(), "assets", "mise.tar.gz") - for range 2 { - if err := syncArchive(context.Background(), server.Client(), server.URL, path, digest); err != nil { - t.Fatal(err) - } - } - if requests.Load() != 1 { - t.Fatal("valid asset was downloaded again") - } - if err := os.WriteFile(path, []byte("previous file"), 0o644); err != nil { - t.Fatal(err) - } - if err := syncArchive(context.Background(), server.Client(), server.URL, path, "wrong-digest"); err == nil { - t.Fatal("accepted mismatched archive") - } - if requests.Load() != 2 { - t.Fatal("checksum mismatch was retried") - } - got, err := os.ReadFile(path) - if err != nil || string(got) != "previous file" { - t.Fatalf("previous resource replaced: %q %v", got, err) - } - if err := syncArchive(context.Background(), server.Client(), server.URL, path, digest); err != nil { - t.Fatal(err) - } - server.Close() - if err := syncArchive(context.Background(), server.Client(), server.URL, path, digest); err != nil { - t.Fatalf("offline build cache: %v", err) - } - leaked, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".fetch-*")) - if err != nil || len(leaked) != 0 { - t.Fatalf("temporary resources: %v %v", leaked, err) - } -} - -func TestBuildResourceHTTPErrorAndCancellation(t *testing.T) { - t.Parallel() - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer server.Close() - path := filepath.Join(t.TempDir(), "mise.zip") - if err := syncArchive(context.Background(), server.Client(), server.URL, path, "digest"); err == nil || !strings.Contains(err.Error(), "HTTP 503") { - t.Fatalf("HTTP error lost: %v", err) - } - if requests.Load() != maxDownloadAttempts { - t.Fatalf("requests = %d, want %d", requests.Load(), maxDownloadAttempts) - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if err := syncArchive(ctx, server.Client(), server.URL, path, "digest"); err == nil { - t.Fatal("cancellation ignored") - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatal("failed fetch published an asset") - } -} - -func TestBuildResourceRetriesTransientFailures(t *testing.T) { - for _, status := range []int{http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusBadGateway, http.StatusGatewayTimeout, http.StatusOK} { - t.Run(fmt.Sprint(status), func(t *testing.T) { - t.Parallel() - archive := []byte("verified archive") - digest := fmt.Sprintf("%x", sha256.Sum256(archive)) - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if requests.Add(1) == 1 { - if status == http.StatusOK { - // A dropped connection must discard the partial download. - w.Header().Set("Content-Length", "100") - } - w.WriteHeader(status) - _, _ = w.Write([]byte("partial archive")) - return - } - _, _ = w.Write(archive) - })) - defer server.Close() - path := filepath.Join(t.TempDir(), "mise.tar.gz") - if err := syncArchive(context.Background(), server.Client(), server.URL, path, digest); err != nil { - t.Fatal(err) - } - if requests.Load() != 2 || !validArchive(path, digest) { - t.Fatalf("retry did not publish verified archive: requests=%d", requests.Load()) - } - leaked, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".fetch-*")) - if err != nil || len(leaked) != 0 { - t.Fatalf("temporary resources: %v %v", leaked, err) - } - }) - } -} - -func TestBuildResourcePermanentHTTPError(t *testing.T) { - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - w.WriteHeader(http.StatusNotFound) - })) - defer server.Close() - path := filepath.Join(t.TempDir(), "mise.zip") - if err := os.WriteFile(path, []byte("previous file"), 0o644); err != nil { - t.Fatal(err) - } - err := syncArchive(context.Background(), server.Client(), server.URL, path, "digest") - if err == nil || !strings.Contains(err.Error(), "HTTP 404") || requests.Load() != 1 { - t.Fatalf("permanent error: requests=%d, err=%v", requests.Load(), err) - } - got, err := os.ReadFile(path) - if err != nil || string(got) != "previous file" { - t.Fatalf("previous resource replaced: %q %v", got, err) - } -} - -func TestBuildResourceCancelsRetryWait(t *testing.T) { - t.Parallel() - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - w.WriteHeader(http.StatusGatewayTimeout) - })) - defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - err := syncArchive(ctx, server.Client(), server.URL, filepath.Join(t.TempDir(), "mise.zip"), "digest") - if !errors.Is(err, context.DeadlineExceeded) || requests.Load() != 1 { - t.Fatalf("cancellation: requests=%d, err=%v", requests.Load(), err) - } -}