diff --git a/.github/workflows/cli-package-manual.yml b/.github/workflows/cli-package-manual.yml index e083bd59b5..41451a6aa0 100644 --- a/.github/workflows/cli-package-manual.yml +++ b/.github/workflows/cli-package-manual.yml @@ -13,9 +13,10 @@ on: - linux-x64 - macos-arm64 - macos-x64 + - windows-x64 - all ubuntu_version: - description: "Ubuntu runner baseline for Linux builds. Ignored for macOS." + description: "Ubuntu runner baseline for Linux builds. Ignored for macOS and Windows." required: true default: "22.04" type: choice @@ -83,19 +84,22 @@ jobs: case "${INPUT_PLATFORM}" in linux-arm64) - MATRIX="{\"platform\":[{\"os\":\"${LINUX_ARM64_RUNNER}\",\"name\":\"linux-arm64-${LINUX_NAME_SUFFIX}\",\"target\":\"aarch64-unknown-linux-gnu\",\"can_smoke_test\":true}]}" + MATRIX="{\"platform\":[{\"os\":\"${LINUX_ARM64_RUNNER}\",\"name\":\"linux-arm64-${LINUX_NAME_SUFFIX}\",\"target\":\"aarch64-unknown-linux-gnu\"}]}" ;; linux-x64) - MATRIX="{\"platform\":[{\"os\":\"${LINUX_X64_RUNNER}\",\"name\":\"linux-x64-${LINUX_NAME_SUFFIX}\",\"target\":\"x86_64-unknown-linux-gnu\",\"can_smoke_test\":true}]}" + MATRIX="{\"platform\":[{\"os\":\"${LINUX_X64_RUNNER}\",\"name\":\"linux-x64-${LINUX_NAME_SUFFIX}\",\"target\":\"x86_64-unknown-linux-gnu\"}]}" ;; macos-arm64) - MATRIX='{"platform":[{"os":"macos-15","name":"macos-arm64","target":"aarch64-apple-darwin","can_smoke_test":true}]}' + MATRIX='{"platform":[{"os":"macos-15","name":"macos-arm64","target":"aarch64-apple-darwin"}]}' ;; macos-x64) - MATRIX='{"platform":[{"os":"macos-15-intel","name":"macos-x64","target":"x86_64-apple-darwin","can_smoke_test":true}]}' + MATRIX='{"platform":[{"os":"macos-15-intel","name":"macos-x64","target":"x86_64-apple-darwin"}]}' + ;; + windows-x64) + MATRIX='{"platform":[{"os":"windows-latest","name":"windows-x64","target":"x86_64-pc-windows-msvc"}]}' ;; all) - MATRIX="{\"platform\":[{\"os\":\"macos-15\",\"name\":\"macos-arm64\",\"target\":\"aarch64-apple-darwin\",\"can_smoke_test\":true},{\"os\":\"macos-15-intel\",\"name\":\"macos-x64\",\"target\":\"x86_64-apple-darwin\",\"can_smoke_test\":true},{\"os\":\"${LINUX_X64_RUNNER}\",\"name\":\"linux-x64-${LINUX_NAME_SUFFIX}\",\"target\":\"x86_64-unknown-linux-gnu\",\"can_smoke_test\":true},{\"os\":\"${LINUX_ARM64_RUNNER}\",\"name\":\"linux-arm64-${LINUX_NAME_SUFFIX}\",\"target\":\"aarch64-unknown-linux-gnu\",\"can_smoke_test\":true}]}" + MATRIX="{\"platform\":[{\"os\":\"macos-15\",\"name\":\"macos-arm64\",\"target\":\"aarch64-apple-darwin\"},{\"os\":\"macos-15-intel\",\"name\":\"macos-x64\",\"target\":\"x86_64-apple-darwin\"},{\"os\":\"${LINUX_X64_RUNNER}\",\"name\":\"linux-x64-${LINUX_NAME_SUFFIX}\",\"target\":\"x86_64-unknown-linux-gnu\"},{\"os\":\"${LINUX_ARM64_RUNNER}\",\"name\":\"linux-arm64-${LINUX_NAME_SUFFIX}\",\"target\":\"aarch64-unknown-linux-gnu\"},{\"os\":\"windows-latest\",\"name\":\"windows-x64\",\"target\":\"x86_64-pc-windows-msvc\"}]}" ;; *) echo "Unsupported platform: ${INPUT_PLATFORM}" >&2 @@ -148,7 +152,8 @@ jobs: shared-key: "cli-manual-v1-${{ matrix.platform.name }}" cache-bin: false - - name: Build bitfun-cli + - name: Build CLI (Unix) + if: runner.os != 'Windows' shell: bash run: | set -euo pipefail @@ -156,16 +161,30 @@ jobs: --target ${{ matrix.platform.target }} \ -p bitfun-cli - - name: Smoke test (--version) - if: matrix.platform.can_smoke_test == true + - name: Build CLI (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + RUSTFLAGS: -C target-feature=+crt-static + run: cargo build --release --target ${{ matrix.platform.target }} -p bitfun-cli + + - name: Test installer (Unix) + if: runner.os != 'Windows' shell: bash - run: | - set -euo pipefail - BIN="target/${{ matrix.platform.target }}/release/bitfun-cli" - "$BIN" --version - "$BIN" --help > /dev/null + env: + CARGO_BUILD_TARGET: ${{ matrix.platform.target }} + run: bash scripts/cli/test-install-unix.sh "${{ matrix.platform.target }}" - - name: Stage tarball + - name: Test installer (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + CARGO_BUILD_TARGET: ${{ matrix.platform.target }} + RUSTFLAGS: -C target-feature=+crt-static + run: ./scripts/cli/test-install-windows.ps1 -Target $env:CARGO_BUILD_TARGET + + - name: Package and smoke test (Unix) + if: runner.os != 'Windows' id: stage shell: bash env: @@ -173,56 +192,17 @@ jobs: TARGET: ${{ matrix.platform.target }} run: | set -euo pipefail + bash scripts/cli/package-unix.sh "$VERSION" "$TARGET" - STAGE_DIR="$(pwd)/dist-cli/bitfun-cli-${VERSION}-${TARGET}" - mkdir -p "$STAGE_DIR" - - cp "target/${TARGET}/release/bitfun-cli" "$STAGE_DIR/" - cp LICENSE "$STAGE_DIR/" || true - cp README.md "$STAGE_DIR/" || true - - if [[ -d "src/apps/cli/themes" ]]; then - cp -R "src/apps/cli/themes" "$STAGE_DIR/themes" - fi - if [[ -d "src/apps/cli/prompts" ]]; then - cp -R "src/apps/cli/prompts" "$STAGE_DIR/prompts" - fi - - ARCHIVE="bitfun-cli-${VERSION}-${TARGET}.tar.gz" - tar -C "$(dirname "$STAGE_DIR")" -czf "$ARCHIVE" "$(basename "$STAGE_DIR")" - - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$ARCHIVE" > "${ARCHIVE}.sha256" - else - shasum -a 256 "$ARCHIVE" > "${ARCHIVE}.sha256" - fi - - echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" - echo "checksum=${ARCHIVE}.sha256" >> "$GITHUB_OUTPUT" - - - name: Smoke test packaged archive - shell: bash + - name: Package and smoke test (Windows) + if: runner.os == 'Windows' + id: stage-windows + shell: pwsh env: - ARCHIVE: ${{ steps.stage.outputs.archive }} - CHECKSUM: ${{ steps.stage.outputs.checksum }} + VERSION: ${{ needs.prepare.outputs.version }} + TARGET: ${{ matrix.platform.target }} run: | - set -euo pipefail - - if command -v sha256sum >/dev/null 2>&1; then - sha256sum -c "$CHECKSUM" - else - shasum -a 256 -c "$CHECKSUM" - fi - - EXTRACT_DIR="$(mktemp -d)" - trap 'rm -rf "$EXTRACT_DIR"' EXIT - tar -xzf "$ARCHIVE" -C "$EXTRACT_DIR" - - shopt -s nullglob - BIN_CANDIDATES=("$EXTRACT_DIR"/*/bitfun-cli) - [[ "${#BIN_CANDIDATES[@]}" -eq 1 ]] - "${BIN_CANDIDATES[0]}" --version - "${BIN_CANDIDATES[0]}" --help > /dev/null + ./scripts/cli/package-windows.ps1 -Version $env:VERSION -Target $env:TARGET - name: Upload artifact uses: actions/upload-artifact@v6 @@ -230,5 +210,5 @@ jobs: name: bitfun-cli-${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.short_sha }}-${{ matrix.platform.name }} if-no-files-found: error path: | - ${{ steps.stage.outputs.archive }} - ${{ steps.stage.outputs.checksum }} + ${{ steps.stage.outputs.archive || steps.stage-windows.outputs.archive }} + ${{ steps.stage.outputs.checksum || steps.stage-windows.outputs.checksum }} diff --git a/.github/workflows/cli-package.yml b/.github/workflows/cli-package.yml index e10ff55e39..7bd0350796 100644 --- a/.github/workflows/cli-package.yml +++ b/.github/workflows/cli-package.yml @@ -17,7 +17,7 @@ on: type: boolean permissions: - contents: write + contents: read concurrency: group: cli-package-${{ github.event.release.tag_name || inputs.tag_name || github.sha }} @@ -87,19 +87,18 @@ jobs: - os: macos-15 name: macos-arm64 target: aarch64-apple-darwin - can_smoke_test: true - os: macos-15-intel name: macos-x64 target: x86_64-apple-darwin - can_smoke_test: true - os: ubuntu-latest name: linux-x64 target: x86_64-unknown-linux-gnu - can_smoke_test: true - os: ubuntu-24.04-arm name: linux-arm64 target: aarch64-unknown-linux-gnu - can_smoke_test: true + - os: windows-latest + name: windows-x64 + target: x86_64-pc-windows-msvc steps: - name: Checkout @@ -132,7 +131,8 @@ jobs: shared-key: "cli-v1-${{ matrix.platform.name }}" cache-bin: false - - name: Build bitfun-cli + - name: Build CLI (Unix) + if: runner.os != 'Windows' shell: bash run: | set -euo pipefail @@ -140,16 +140,30 @@ jobs: --target ${{ matrix.platform.target }} \ -p bitfun-cli - - name: Smoke test (--version) - if: matrix.platform.can_smoke_test == true + - name: Build CLI (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + RUSTFLAGS: -C target-feature=+crt-static + run: cargo build --release --target ${{ matrix.platform.target }} -p bitfun-cli + + - name: Test installer (Unix) + if: runner.os != 'Windows' shell: bash - run: | - set -euo pipefail - BIN="target/${{ matrix.platform.target }}/release/bitfun-cli" - "$BIN" --version - "$BIN" --help > /dev/null + env: + CARGO_BUILD_TARGET: ${{ matrix.platform.target }} + run: bash scripts/cli/test-install-unix.sh "${{ matrix.platform.target }}" + + - name: Test installer (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + CARGO_BUILD_TARGET: ${{ matrix.platform.target }} + RUSTFLAGS: -C target-feature=+crt-static + run: ./scripts/cli/test-install-windows.ps1 -Target $env:CARGO_BUILD_TARGET - - name: Stage tarball + - name: Package and smoke test (Unix) + if: runner.os != 'Windows' id: stage shell: bash env: @@ -157,58 +171,17 @@ jobs: TARGET: ${{ matrix.platform.target }} run: | set -euo pipefail + bash scripts/cli/package-unix.sh "$VERSION" "$TARGET" - STAGE_DIR="$(pwd)/dist-cli/bitfun-cli-${VERSION}-${TARGET}" - mkdir -p "$STAGE_DIR" - - cp "target/${TARGET}/release/bitfun-cli" "$STAGE_DIR/" - cp LICENSE "$STAGE_DIR/" || true - cp README.md "$STAGE_DIR/" || true - - # Optional: bundle prompts and themes shipped alongside the CLI source - if [[ -d "src/apps/cli/themes" ]]; then - cp -R "src/apps/cli/themes" "$STAGE_DIR/themes" - fi - if [[ -d "src/apps/cli/prompts" ]]; then - cp -R "src/apps/cli/prompts" "$STAGE_DIR/prompts" - fi - - ARCHIVE="bitfun-cli-${VERSION}-${TARGET}.tar.gz" - tar -C "$(dirname "$STAGE_DIR")" -czf "$ARCHIVE" "$(basename "$STAGE_DIR")" - - # SHA256 (portable: shasum on macOS, sha256sum on Linux) - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$ARCHIVE" > "${ARCHIVE}.sha256" - else - shasum -a 256 "$ARCHIVE" > "${ARCHIVE}.sha256" - fi - - echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" - echo "checksum=${ARCHIVE}.sha256" >> "$GITHUB_OUTPUT" - - - name: Smoke test packaged archive - shell: bash + - name: Package and smoke test (Windows) + if: runner.os == 'Windows' + id: stage-windows + shell: pwsh env: - ARCHIVE: ${{ steps.stage.outputs.archive }} - CHECKSUM: ${{ steps.stage.outputs.checksum }} + VERSION: ${{ needs.prepare.outputs.version }} + TARGET: ${{ matrix.platform.target }} run: | - set -euo pipefail - - if command -v sha256sum >/dev/null 2>&1; then - sha256sum -c "$CHECKSUM" - else - shasum -a 256 -c "$CHECKSUM" - fi - - EXTRACT_DIR="$(mktemp -d)" - trap 'rm -rf "$EXTRACT_DIR"' EXIT - tar -xzf "$ARCHIVE" -C "$EXTRACT_DIR" - - shopt -s nullglob - BIN_CANDIDATES=("$EXTRACT_DIR"/*/bitfun-cli) - [[ "${#BIN_CANDIDATES[@]}" -eq 1 ]] - "${BIN_CANDIDATES[0]}" --version - "${BIN_CANDIDATES[0]}" --help > /dev/null + ./scripts/cli/package-windows.ps1 -Version $env:VERSION -Target $env:TARGET - name: Upload artifact uses: actions/upload-artifact@v6 @@ -216,8 +189,8 @@ jobs: name: bitfun-cli-${{ needs.prepare.outputs.release_tag }}-${{ matrix.platform.name }} if-no-files-found: error path: | - ${{ steps.stage.outputs.archive }} - ${{ steps.stage.outputs.checksum }} + ${{ steps.stage.outputs.archive || steps.stage-windows.outputs.archive }} + ${{ steps.stage.outputs.checksum || steps.stage-windows.outputs.checksum }} # ── Aggregate and upload to GitHub Release ───────────────────────── upload-release-assets: @@ -225,6 +198,8 @@ jobs: needs: [prepare, build] if: needs.prepare.outputs.upload_to_release == 'true' runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Download CLI artifacts @@ -243,16 +218,14 @@ jobs: working-directory: cli-release-assets run: | set -euo pipefail - # Sort for reproducibility; only the tarballs go into the combined file - ( - for f in $(ls bitfun-cli-*.tar.gz | sort); do - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$f" - else - shasum -a 256 "$f" - fi - done - ) > SHA256SUMS + # Sort for reproducibility; include Unix tarballs and the Windows zip. + mapfile -t archives < <( + find . -maxdepth 1 -type f \ + \( -name 'bitfun-cli-*.tar.gz' -o -name 'bitfun-cli-*.zip' \) \ + -printf '%f\n' | sort + ) + [[ "${#archives[@]}" -gt 0 ]] + sha256sum "${archives[@]}" > SHA256SUMS echo "---- SHA256SUMS ----" cat SHA256SUMS @@ -264,28 +237,43 @@ jobs: files: | cli-release-assets/bitfun-cli-*.tar.gz cli-release-assets/bitfun-cli-*.tar.gz.sha256 + cli-release-assets/bitfun-cli-*.zip + cli-release-assets/bitfun-cli-*.zip.sha256 cli-release-assets/SHA256SUMS fail_on_unmatched_files: true - # Tell GCWing/homebrew-tap to bump the bitfun-cli formula now that - # the tarballs are live on the release. Safe no-op if the secret - # has not been configured (e.g. on forks or before the tap exists). - - name: Notify homebrew-tap + # This post-publication dispatch only proves that GitHub accepted the + # notification. The external tap owns formula rollout and readback. + - name: Notify homebrew-tap (post-publication) env: GH_TOKEN: ${{ secrets.HOMEBREW_TAP_DISPATCH_TOKEN }} RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + OFFICIAL_REPOSITORY: ${{ github.repository == 'GCWing/BitFun' }} shell: bash run: | set -euo pipefail if [[ -z "${GH_TOKEN}" ]]; then - echo "HOMEBREW_TAP_DISPATCH_TOKEN not set; skipping tap dispatch." + if [[ "${OFFICIAL_REPOSITORY}" == "true" ]]; then + echo "::error::HOMEBREW_TAP_DISPATCH_TOKEN is required for the official post-publication notification." + exit 1 + fi + echo "HOMEBREW_TAP_DISPATCH_TOKEN not set on fork; skipping tap dispatch." exit 0 fi echo "Dispatching bitfun-release-published for ${RELEASE_TAG} to GCWing/homebrew-tap" + PAYLOAD="$(jq -cn --arg tag_name "${RELEASE_TAG}" '{ + event_type: "bitfun-release-published", + client_payload: { + tag_name: $tag_name, + primary_binary: "bitfun", + deprecated_binary: "bitfun-cli", + deprecated: true + } + }')" curl -fsSL -X POST \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ https://api.github.com/repos/GCWing/homebrew-tap/dispatches \ - -d "{\"event_type\":\"bitfun-release-published\",\"client_payload\":{\"tag_name\":\"${RELEASE_TAG}\"}}" + -d "$PAYLOAD" diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 20ad8c9247..9e4442e7cb 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -55,7 +55,7 @@ pnpm run desktop:dev # 完整热更新:Vite HMR + Rust 自动重 pnpm run desktop:preview:debug # 复用预构建二进制 + Vite HMR;无 Rust 自动重编译 pnpm run dev:web # 纯浏览器前端 pnpm run cli:dev # CLI 运行时 -pnpm run cli:install # release 编译并安装 bitfun-cli 到 ~/.local/bin(写入 bashrc/zshrc PATH) +pnpm run cli:install # release 编译并安装 bitfun(Windows/macOS/Linux;含废弃兼容入口 bitfun-cli) # 检查 pnpm run fmt:rs # 只格式化已改动 / 已暂存的 Rust 文件 diff --git a/AGENTS.md b/AGENTS.md index b3067d7779..d3c252e22b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ pnpm run desktop:dev # full hot-reload: Vite HMR + Rust auto-rebui pnpm run desktop:preview:debug # reuse pre-built binary + Vite HMR; no Rust auto-rebuild pnpm run dev:web # browser-only frontend pnpm run cli:dev # CLI runtime -pnpm run cli:install # build release + install bitfun-cli to ~/.local/bin (bashrc/zshrc PATH) +pnpm run cli:install # build release + install bitfun (Windows/macOS/Linux; deprecated bitfun-cli included) # Check pnpm run fmt:rs # format only changed / staged Rust files diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index e8349ced2d..a24a9a0474 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -44,7 +44,8 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 5. 消费已校验的产品组装结果和 TUI 布局选择,生成不同品牌和能力范围的 CLI 产物;通用产品定制不在 CLI 入口重复实现。 6. 以任务成功率、恢复能力、工具正确性、上下文效率和资源开销评估 Agent 能力,而不是只比较命令数量。 -7. 把 HarmonyOS PC 作为一等 CLI/TUI 目标:用户在系统真实终端中安装并执行本地 `bitfun-cli`;HAP 内终端式 +7. 把 HarmonyOS PC 作为一等 CLI/TUI 目标:用户在系统真实终端中安装并执行本地 `bitfun`;废弃兼容入口 + `bitfun-cli` 不作为新集成入口。HAP 内终端式 界面、`hdc shell`、移动 Remote App 和其他设备代执行均不构成该目标。 ### 1.2 能力对齐口径 @@ -139,8 +140,9 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 显示警告。不承诺本次变更范围外的 ACK、重放或重连恢复。 - `doctor` 与 `health` 构造并校验真实 Runtime Parts,区分 assembly-ready、Core compatibility owner 和不可用扩展。 它们证明必需能力已注册,不把 Core 的 Network/Git/MCP compatibility marker 描述为外部服务实时可用。 -- 独立 CLI 测试与打包工作流;主 CI 的三平台 workspace check 同时覆盖 `bitfun-cli` 编译,发布归档在上传前校验 - SHA-256 摘要,并从解压后的目录执行 `--version` / `--help`。 +- 独立 CLI 测试与打包工作流;主 CI 的 Windows/macOS/Linux workspace check 同时覆盖 Cargo package + `bitfun-cli` 编译,原生发布归档包含主入口 `bitfun` 和废弃兼容入口 `bitfun-cli`,上传前校验 SHA-256 摘要, + 并从解压后的目录验证两个入口及废弃告警。 上述切换不等于运行时 owner 已迁移,也不表示 CLI-P0 全部完成。CLI crate 仍以 `bitfun-core/product-full` 承载协调器、调度器、持久化、工具管线和部分 SDK v1 缺口,但 Peer Host 不再自行构造这些 owner;ACP 的 stdio、 @@ -373,7 +375,7 @@ Plugin Runtime 状态。 ### 4.6 HarmonyOS PC 原生终端产品 HarmonyOS PC 复用现有 `DeliveryProfile::Cli`、action、TUI 和 Runtime 语义;平台 target 不成为新的 Delivery -Profile。目标产物是普通用户在系统真实终端中直接执行的本地 `bitfun-cli`,不是 HAP、ArkUI/ArkWeb 终端模拟器、 +Profile。目标产物是普通用户在系统真实终端中直接执行的本地 `bitfun`,不是 HAP、ArkUI/ArkWeb 终端模拟器、 `hdc shell` 工具或现有 HarmonyOS 手机 Remote App。 问题清单、风险和旧设计闭环统一见[平台规约](platform-portability-design.md)。具体鸿蒙化工作、OpenCode 平台资格、 @@ -487,9 +489,9 @@ OpenCode 来源解释,首次连接、策略限制和凭据缺失分别显示 | 能力 | 入口 | 说明 | |---|---|---| -| 外部 ACP 智能体 | `bitfun-cli acp ...` | 启动外部智能体进程并通过 ACP 协作。 | -| 配置兼容与导入 | 启动时持续兼容来源;`bitfun-cli config import ...` 显式迁移 | 前者后台发现并按风险应用/确认 OpenCode 来源,后者写入 BitFun 原生配置;两者都不在解析线程执行插件。 | -| 运行时插件 | `bitfun-cli plugins ...` | 当前只提供 BitFun 专用包的来源、启用和静态工具名称预览;目标直接运行 OpenCode 插件。 | +| 外部 ACP 智能体 | `bitfun acp ...` | 启动外部智能体进程并通过 ACP 协作。 | +| 配置兼容与导入 | 启动时持续兼容来源;`bitfun config import ...` 显式迁移 | 前者后台发现并按风险应用/确认 OpenCode 来源,后者写入 BitFun 原生配置;两者都不在解析线程执行插件。 | +| 运行时插件 | `bitfun plugins ...` | 当前只提供 BitFun 专用包的来源、启用和静态工具名称预览;目标直接运行 OpenCode 插件。 | 三者不能共享“已安装/已启用”状态,也不能互相推导来源确认或运行权限。 @@ -616,8 +618,8 @@ CLI Agent 能力加强必须落在共享 Agent Runtime、Tool Runtime 或 Harnes 通用 `cargo check --workspace` 负责三平台 CLI 编译保护;独立 CLI CI 运行 `cargo test --locked -p bitfun-cli -p bitfun-acp -p bitfun-agent-runtime`。Linux 启动页 PTY 生命周期冒烟随独立 CLI -测试运行,Windows 启动页 ConPTY 生命周期冒烟复用通用 Windows job;发布归档在上传前完成 SHA-256 与解压执行 -验证。真实供应商模型进程级交互、macOS 活动 PTY 与 OS 级终端故障进程矩阵仍按对应切片补入门禁;Linux PTY +测试运行,Windows 启动页 ConPTY 生命周期冒烟复用通用 Windows job;Windows x64、macOS 和 Linux 的原生发布归档 +在上传前完成 SHA-256、双入口和解压执行验证。真实供应商模型进程级交互、macOS 活动 PTY 与 OS 级终端故障进程矩阵仍按对应切片补入门禁;Linux PTY 与 Windows ConPTY 的 Chat 活动 turn resize/取消及 `exec` Ctrl+C 已由本地确定性流式模型夹具覆盖,不能用它替代上述验收。 ### 10.2 阶段退出条件 diff --git a/docs/architecture/platform-portability-design.md b/docs/architecture/platform-portability-design.md index 1d8cd8218f..9bbb7105d9 100644 --- a/docs/architecture/platform-portability-design.md +++ b/docs/architecture/platform-portability-design.md @@ -10,7 +10,7 @@ ## 1. 产品裁决 HarmonyOS PC 是 BitFun CLI/TUI 的目标平台之一,TUI 优先于 HarmonyOS PC GUI。目标产品是用户在 -HarmonyOS PC 真实系统终端中安装并本地执行的原生 `bitfun-cli`: +HarmonyOS PC 真实系统终端中安装并本地执行的原生 `bitfun`(`bitfun-cli` 仅为废弃兼容入口): - TUI 直接使用真实 TTY; - Agent Runtime、模型访问、工作区文件和命令执行均在该 PC 本机运行; @@ -88,7 +88,7 @@ HarmonyOS PC 真实系统终端中安装并本地执行的原生 `bitfun-cli`: ## 5. 当前问题清单 本清单基于上游 `ecad4f843`(2026-07-16)与 -`bitfun-cli` 的 `aarch64-unknown-linux-ohos` 目标依赖解析。依赖解析成功只表示问题已经可见,不表示可以编译、 +Cargo package `bitfun-cli` 的 `aarch64-unknown-linux-ohos` 目标依赖解析。依赖解析成功只表示问题已经可见,不表示可以编译、 运行或交付。 | 问题域 | 当前识别结果 | 主要风险 | 后续专题需要回答 | diff --git a/package.json b/package.json index 1baba40ccd..a64b51543c 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,9 @@ "installer:dev": "pnpm --dir BitFun-Installer run installer:dev", "cli:dev": "cd src/apps/cli && cargo run --", "cli:build": "cd src/apps/cli && cargo build --release", - "cli:install": "bash src/apps/cli/install.sh", + "cli:install": "node scripts/install-cli.mjs", + "cli:install:unix": "bash src/apps/cli/install.sh", + "cli:install:windows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File src/apps/cli/install.ps1", "cli:run": "cd src/apps/cli && cargo run --release --", "cli:exec": "cd src/apps/cli && cargo run -- exec", "cli:check": "cd src/apps/cli && cargo check", diff --git a/scripts/cli/package-contract.test.mjs b/scripts/cli/package-contract.test.mjs new file mode 100644 index 0000000000..8a174ddbec --- /dev/null +++ b/scripts/cli/package-contract.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (relativePath) => + fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); + +for (const packageScript of [ + 'scripts/cli/package-unix.sh', + 'scripts/cli/package-windows.ps1', +]) { + const content = read(packageScript); + assert.match(content, /src[\\/]apps[\\/]cli[\\/]README\.md/); + assert.match(content, /PROJECT-README\.md/); +} + +for (const workflow of [ + '.github/workflows/cli-package.yml', + '.github/workflows/cli-package-manual.yml', +]) { + const content = read(workflow); + assert.match(content, /target-feature=\+crt-static/); + assert.match(content, /scripts\/cli\/test-install-unix\.sh/); + assert.match(content, /scripts\/cli\/test-install-windows\.ps1/); +} + +const releaseWorkflow = read('.github/workflows/cli-package.yml'); +assert.match(releaseWorkflow, /primary_binary:\s*"bitfun"/); +assert.match(releaseWorkflow, /deprecated_binary:\s*"bitfun-cli"/); +assert.match(releaseWorkflow, /post-publication/i); +assert.doesNotMatch(releaseWorkflow, /Homebrew release gate/i); diff --git a/scripts/cli/package-unix.sh b/scripts/cli/package-unix.sh new file mode 100644 index 0000000000..6babd5b2d4 --- /dev/null +++ b/scripts/cli/package-unix.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ "$#" -lt 2 ] || [ "$#" -gt 4 ]; then + echo "Usage: package-unix.sh [release-dir] [output-dir]" >&2 + exit 2 +fi + +VERSION="$1" +TARGET="$2" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +RELEASE_DIR="${3:-${REPO_ROOT}/target/${TARGET}/release}" +OUTPUT_DIR="${4:-${REPO_ROOT}}" +PRIMARY="${RELEASE_DIR}/bitfun" +LEGACY="${RELEASE_DIR}/bitfun-cli" +DEPRECATION='Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' + +assert_legacy_entrypoint() { + local executable="$1" + local stderr_file warning + stderr_file="$(mktemp)" + if ! "$executable" --version >/dev/null 2>"$stderr_file"; then + rm -f "$stderr_file" + echo "Error: deprecated bitfun-cli entrypoint failed" >&2 + return 1 + fi + warning="$(cat "$stderr_file")" + rm -f "$stderr_file" + if [ "$warning" != "$DEPRECATION" ]; then + echo "Error: unexpected deprecated entrypoint warning: $warning" >&2 + return 1 + fi +} + +"$PRIMARY" --version +"$PRIMARY" --help >/dev/null +assert_legacy_entrypoint "$LEGACY" + +STAGE_NAME="bitfun-cli-${VERSION}-${TARGET}" +STAGE_DIR="${OUTPUT_DIR}/dist-cli/${STAGE_NAME}" +mkdir -p "$STAGE_DIR" +cp "$PRIMARY" "$STAGE_DIR/" +cp "$LEGACY" "$STAGE_DIR/" +cp "${REPO_ROOT}/LICENSE" "$STAGE_DIR/" 2>/dev/null || true +cp "${REPO_ROOT}/src/apps/cli/README.md" "$STAGE_DIR/README.md" +cp "${REPO_ROOT}/README.md" "$STAGE_DIR/PROJECT-README.md" + +if [ -d "${REPO_ROOT}/src/apps/cli/themes" ]; then + cp -R "${REPO_ROOT}/src/apps/cli/themes" "$STAGE_DIR/themes" +fi +if [ -d "${REPO_ROOT}/src/apps/cli/prompts" ]; then + cp -R "${REPO_ROOT}/src/apps/cli/prompts" "$STAGE_DIR/prompts" +fi + +ARCHIVE="${OUTPUT_DIR}/${STAGE_NAME}.tar.gz" +tar -C "$(dirname "$STAGE_DIR")" -czf "$ARCHIVE" "$(basename "$STAGE_DIR")" + +if command -v sha256sum >/dev/null 2>&1; then + ARCHIVE_HASH="$(sha256sum "$ARCHIVE" | awk '{print $1}')" + printf '%s %s\n' "$ARCHIVE_HASH" "$(basename "$ARCHIVE")" >"${ARCHIVE}.sha256" + (cd "$OUTPUT_DIR" && sha256sum -c "$(basename "${ARCHIVE}.sha256")") +else + ARCHIVE_HASH="$(shasum -a 256 "$ARCHIVE" | awk '{print $1}')" + printf '%s %s\n' "$ARCHIVE_HASH" "$(basename "$ARCHIVE")" >"${ARCHIVE}.sha256" + (cd "$OUTPUT_DIR" && shasum -a 256 -c "$(basename "${ARCHIVE}.sha256")") +fi + +EXTRACT_DIR="$(mktemp -d)" +trap 'rm -rf "$EXTRACT_DIR"' EXIT +tar -xzf "$ARCHIVE" -C "$EXTRACT_DIR" + +shopt -s nullglob +PRIMARY_CANDIDATES=("$EXTRACT_DIR"/*/bitfun) +LEGACY_CANDIDATES=("$EXTRACT_DIR"/*/bitfun-cli) +[ "${#PRIMARY_CANDIDATES[@]}" -eq 1 ] +[ "${#LEGACY_CANDIDATES[@]}" -eq 1 ] +[ -f "$EXTRACT_DIR/$STAGE_NAME/README.md" ] +[ -f "$EXTRACT_DIR/$STAGE_NAME/PROJECT-README.md" ] +"${PRIMARY_CANDIDATES[0]}" --version +"${PRIMARY_CANDIDATES[0]}" --help >/dev/null +assert_legacy_entrypoint "${LEGACY_CANDIDATES[0]}" + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "archive=$(basename "$ARCHIVE")" >>"$GITHUB_OUTPUT" + echo "checksum=$(basename "$ARCHIVE").sha256" >>"$GITHUB_OUTPUT" +fi + +echo "Packaged and verified: $ARCHIVE" diff --git a/scripts/cli/package-windows.ps1 b/scripts/cli/package-windows.ps1 new file mode 100644 index 0000000000..6039e89319 --- /dev/null +++ b/scripts/cli/package-windows.ps1 @@ -0,0 +1,147 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Version, + + [Parameter(Mandatory = $true)] + [string]$Target, + + [string]$ReleaseDir, + [string]$OutputDir, + [string]$GitHubOutput = $env:GITHUB_OUTPUT +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +if (-not $ReleaseDir) { + $ReleaseDir = Join-Path $repoRoot "target\$Target\release" +} +if (-not $OutputDir) { + $OutputDir = $repoRoot +} +$ReleaseDir = [IO.Path]::GetFullPath($ReleaseDir) +$OutputDir = [IO.Path]::GetFullPath($OutputDir) + +$primary = Join-Path $ReleaseDir 'bitfun.exe' +$legacy = Join-Path $ReleaseDir 'bitfun-cli.exe' +$deprecation = 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' + +function Assert-LastExitCode([string]$Description) { + if ($LASTEXITCODE -ne 0) { + throw "$Description failed with exit code $LASTEXITCODE" + } +} + +function Assert-LegacyEntrypoint([string]$Executable) { + $id = [guid]::NewGuid().ToString('N') + $stdout = Join-Path ([IO.Path]::GetTempPath()) "bitfun-cli-$id.out" + $stderr = Join-Path ([IO.Path]::GetTempPath()) "bitfun-cli-$id.err" + try { + $process = Start-Process -FilePath $Executable -ArgumentList '--version' -Wait -PassThru -NoNewWindow ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + if ($process.ExitCode -ne 0) { + throw "Deprecated bitfun-cli entrypoint failed with exit code $($process.ExitCode)" + } + $warning = (Get-Content -LiteralPath $stderr -Raw).TrimEnd("`r", "`n") + if ($warning -cne $deprecation) { + throw "Unexpected deprecated entrypoint warning: $warning" + } + } + finally { + Remove-Item -LiteralPath $stdout, $stderr -Force -ErrorAction SilentlyContinue + } +} + +function Assert-NoRedistributableRuntime([string]$Executable) { + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path -LiteralPath $vswhere -PathType Leaf)) { + throw 'vswhere.exe was not found; cannot verify the Windows runtime dependency contract' + } + $installPath = (& $vswhere -latest -products '*' ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath | Select-Object -First 1) + $dumpbin = Get-ChildItem -LiteralPath (Join-Path $installPath 'VC\Tools\MSVC') -Recurse ` + -Filter dumpbin.exe | Where-Object { $_.FullName -match '\\bin\\Hostx64\\x64\\dumpbin\.exe$' } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $dumpbin) { + throw 'dumpbin.exe was not found; cannot verify the Windows runtime dependency contract' + } + + $dependents = (& $dumpbin.FullName /nologo /dependents $Executable 2>&1 | Out-String) + Assert-LastExitCode "dumpbin dependency check for $Executable" + if ($dependents -match '(?im)^\s*(?:VCRUNTIME|MSVCP)[^\s]*\.dll\s*$') { + throw "$Executable requires the Visual C++ Redistributable; build it with static CRT linkage" + } +} + +& $primary --version +Assert-LastExitCode 'bitfun --version' +& $primary --help | Out-Null +Assert-LastExitCode 'bitfun --help' +Assert-LegacyEntrypoint $legacy +Assert-NoRedistributableRuntime $primary +Assert-NoRedistributableRuntime $legacy + +$stageName = "bitfun-cli-$Version-$Target" +$stageDir = Join-Path (Join-Path $OutputDir 'dist-cli') $stageName +New-Item -ItemType Directory -Path $stageDir -Force | Out-Null +Copy-Item -LiteralPath $primary -Destination $stageDir -Force +Copy-Item -LiteralPath $legacy -Destination $stageDir -Force +Copy-Item -LiteralPath (Join-Path $repoRoot 'LICENSE') -Destination $stageDir -Force -ErrorAction SilentlyContinue +Copy-Item -LiteralPath (Join-Path $repoRoot 'src\apps\cli\README.md') ` + -Destination (Join-Path $stageDir 'README.md') -Force +Copy-Item -LiteralPath (Join-Path $repoRoot 'README.md') ` + -Destination (Join-Path $stageDir 'PROJECT-README.md') -Force + +$themes = Join-Path $repoRoot 'src\apps\cli\themes' +$prompts = Join-Path $repoRoot 'src\apps\cli\prompts' +if (Test-Path -LiteralPath $themes -PathType Container) { + Copy-Item -LiteralPath $themes -Destination (Join-Path $stageDir 'themes') -Recurse -Force +} +if (Test-Path -LiteralPath $prompts -PathType Container) { + Copy-Item -LiteralPath $prompts -Destination (Join-Path $stageDir 'prompts') -Recurse -Force +} + +$archive = Join-Path $OutputDir "$stageName.zip" +Compress-Archive -Path $stageDir -DestinationPath $archive -CompressionLevel Optimal -Force +$hash = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() +$checksum = "$archive.sha256" +"$hash $(Split-Path -Leaf $archive)" | Set-Content -LiteralPath $checksum -Encoding ascii + +$recordedHash = ((Get-Content -LiteralPath $checksum -Raw).Trim() -split '\s+')[0] +if ($recordedHash -cne $hash) { + throw 'Packaged archive checksum mismatch' +} + +$extractDir = Join-Path ([IO.Path]::GetTempPath()) "bitfun-cli-package-$([guid]::NewGuid().ToString('N'))" +try { + Expand-Archive -LiteralPath $archive -DestinationPath $extractDir + $primaryCandidates = @(Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'bitfun.exe') + $legacyCandidates = @(Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'bitfun-cli.exe') + if ($primaryCandidates.Count -ne 1 -or $legacyCandidates.Count -ne 1) { + throw 'Expected exactly one of each CLI entrypoint in the packaged archive' + } + foreach ($readme in @('README.md', 'PROJECT-README.md')) { + if (-not (Test-Path -LiteralPath (Join-Path $primaryCandidates[0].DirectoryName $readme) -PathType Leaf)) { + throw "Packaged archive is missing $readme" + } + } + + & $primaryCandidates[0].FullName --version + Assert-LastExitCode 'packaged bitfun --version' + & $primaryCandidates[0].FullName --help | Out-Null + Assert-LastExitCode 'packaged bitfun --help' + Assert-LegacyEntrypoint $legacyCandidates[0].FullName +} +finally { + Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($GitHubOutput) { + "archive=$(Split-Path -Leaf $archive)" >> $GitHubOutput + "checksum=$(Split-Path -Leaf $checksum)" >> $GitHubOutput +} + +Write-Host "Packaged and verified: $archive" diff --git a/scripts/cli/test-install-unix.sh b/scripts/cli/test-install-unix.sh new file mode 100644 index 0000000000..51cc1b2e97 --- /dev/null +++ b/scripts/cli/test-install-unix.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: test-install-unix.sh " >&2 + exit 2 +fi + +TARGET="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_ROOT"' EXIT + +ORIGINAL_HOME="$HOME" +export RUSTUP_HOME="${RUSTUP_HOME:-${ORIGINAL_HOME}/.rustup}" +export CARGO_HOME="${CARGO_HOME:-${ORIGINAL_HOME}/.cargo}" +export HOME="${TEST_ROOT}/home" +export BITFUN_CLI_BIN_DIR="${TEST_ROOT}/bin" +export CARGO_BUILD_TARGET="$TARGET" +mkdir -p "$HOME" + +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +bash "${REPO_ROOT}/src/apps/cli/install.sh" +bash "${REPO_ROOT}/src/apps/cli/install.sh" + +"${BITFUN_CLI_BIN_DIR}/bitfun" --version >/dev/null +LEGACY_STDERR="${TEST_ROOT}/legacy.err" +"${BITFUN_CLI_BIN_DIR}/bitfun-cli" --version >/dev/null 2>"$LEGACY_STDERR" +grep -Fxq 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' "$LEGACY_STDERR" + +for rc_file in "$HOME/.bashrc" "$HOME/.zshrc"; do + [ "$(grep -Fc '# >>> BitFun CLI PATH (managed by src/apps/cli/install.sh) >>>' "$rc_file")" -eq 1 ] +done + +PRIMARY_HASH="$(hash_file "${BITFUN_CLI_BIN_DIR}/bitfun")" +LEGACY_HASH="$(hash_file "${BITFUN_CLI_BIN_DIR}/bitfun-cli")" +REAL_MV="$(command -v mv)" +SHIM_DIR="${TEST_ROOT}/shim" +mkdir -p "$SHIM_DIR" +cat >"${SHIM_DIR}/mv" <<'EOF' +#!/bin/sh +if [ "${1:-}" = "${BITFUN_CLI_TEST_FAIL_SOURCE:-}" ]; then + exit 91 +fi +exec "${BITFUN_CLI_TEST_REAL_MV}" "$@" +EOF +chmod +x "${SHIM_DIR}/mv" + +if PATH="${SHIM_DIR}:$PATH" \ + BITFUN_CLI_SKIP_SHELLRC=1 \ + BITFUN_CLI_TEST_REAL_MV="$REAL_MV" \ + BITFUN_CLI_TEST_FAIL_SOURCE="${BITFUN_CLI_BIN_DIR}/bitfun-cli" \ + bash "${REPO_ROOT}/src/apps/cli/install.sh"; then + echo "Error: installer unexpectedly succeeded during injected replacement failure" >&2 + exit 1 +fi + +[ "$(hash_file "${BITFUN_CLI_BIN_DIR}/bitfun")" = "$PRIMARY_HASH" ] +[ "$(hash_file "${BITFUN_CLI_BIN_DIR}/bitfun-cli")" = "$LEGACY_HASH" ] +"${BITFUN_CLI_BIN_DIR}/bitfun-cli" --version >/dev/null 2>"$LEGACY_STDERR" +grep -Fxq 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' "$LEGACY_STDERR" diff --git a/scripts/cli/test-install-windows.ps1 b/scripts/cli/test-install-windows.ps1 new file mode 100644 index 0000000000..1a7957aaba --- /dev/null +++ b/scripts/cli/test-install-windows.ps1 @@ -0,0 +1,56 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Target +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$installer = Join-Path $repoRoot 'src\apps\cli\install.ps1' +$testRoot = Join-Path ([IO.Path]::GetTempPath()) "bitfun-cli-install-$([guid]::NewGuid().ToString('N'))" +$binDir = Join-Path $testRoot 'bin' + +try { + $env:CARGO_BUILD_TARGET = $Target + & $installer -BinDir $binDir -SkipPathUpdate + & $installer -BinDir $binDir -SkipPathUpdate + + & (Join-Path $binDir 'bitfun.exe') --version | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'Installed bitfun smoke check failed' + } + + $primary = Join-Path $binDir 'bitfun.exe' + $legacy = Join-Path $binDir 'bitfun-cli.exe' + [IO.File]::WriteAllText($primary, 'previous primary') + [IO.File]::WriteAllText($legacy, 'previous legacy') + $primaryHash = (Get-FileHash -LiteralPath $primary -Algorithm SHA256).Hash + $legacyHash = (Get-FileHash -LiteralPath $legacy -Algorithm SHA256).Hash + + $lock = [IO.File]::Open($legacy, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) + $failedAsExpected = $false + try { + & $installer -BinDir $binDir -SkipPathUpdate 2>$null + } + catch { + $failedAsExpected = $true + } + finally { + $lock.Dispose() + } + if (-not $failedAsExpected) { + throw 'Installer unexpectedly succeeded while the legacy entrypoint was locked' + } + + if ((Get-FileHash -LiteralPath $primary -Algorithm SHA256).Hash -cne $primaryHash) { + throw 'Failed update did not restore the previous primary entrypoint' + } + if ((Get-FileHash -LiteralPath $legacy -Algorithm SHA256).Hash -cne $legacyHash) { + throw 'Failed update did not preserve the previous legacy entrypoint' + } +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 16e41c0d1d..6d0a6faaab 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -537,7 +537,7 @@ export const externalSourceCorePublicApiEntries = [ externalIntegrationPolicyEntry( symbol, 'bitfun-core external integration policy composition facade', - 'bitfun-cli, Desktop, Server, Peer Host, and Web API adapters', + 'BitFun CLI, Desktop, Server, Peer Host, and Web API adapters', true, ), ), @@ -573,7 +573,7 @@ export const externalSourceCorePublicApiEntries = [ externalSourceEntry( symbol, 'bitfun-core external source composition facade', - 'bitfun-cli and desktop host APIs', + 'BitFun CLI and desktop host APIs', ), ), ...[ @@ -590,7 +590,7 @@ export const externalSourceCorePublicApiEntries = [ externalToolEntry( symbol, 'bitfun-core external tool composition facade', - 'bitfun-cli and desktop host APIs', + 'BitFun CLI and desktop host APIs', ), ), ...[ @@ -605,7 +605,7 @@ export const externalSourceCorePublicApiEntries = [ externalSubagentEntry( symbol, 'bitfun-core external subagent composition facade', - 'bitfun-cli and desktop host APIs', + 'BitFun CLI and desktop host APIs', ), ), ...[ @@ -621,7 +621,7 @@ export const externalSourceCorePublicApiEntries = [ externalMcpEntry( symbol, 'bitfun-core external MCP composition facade', - 'bitfun-cli and desktop host APIs', + 'BitFun CLI and desktop host APIs', ), ), ]; @@ -675,8 +675,8 @@ export const managedPluginSourcePublicApiEntries = [ pluginSourceEntry( symbol, 'bitfun-core managed plugin source compatibility facade', - 'bitfun-cli plugins and doctor commands', - 'services-integrations plugin_source tests, core boundary checks, and bitfun-cli plugin command tests', + 'BitFun CLI plugins and doctor commands', + 'services-integrations plugin_source tests, core boundary checks, and BitFun CLI plugin command tests', false, ), ); @@ -692,8 +692,8 @@ export const managedPluginActivationPublicApiEntries = [ pluginSourceEntry( symbol, 'bitfun-core managed plugin composition root', - 'bitfun-cli plugin activation commands', - 'bitfun-core plugin_runtime tests, bitfun-cli plugin source tests, and core boundary checks', + 'BitFun CLI plugin activation commands', + 'bitfun-core plugin_runtime tests, BitFun CLI plugin source tests, and core boundary checks', false, ), ); diff --git a/scripts/install-cli.mjs b/scripts/install-cli.mjs new file mode 100644 index 0000000000..b513afa631 --- /dev/null +++ b/scripts/install-cli.mjs @@ -0,0 +1,46 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +export function installerCommand(platform = process.platform) { + if (platform === 'win32') { + return { + command: 'powershell.exe', + args: [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(repoRoot, 'src', 'apps', 'cli', 'install.ps1'), + ], + }; + } + + if (platform === 'darwin' || platform === 'linux') { + return { + command: 'bash', + args: [path.join(repoRoot, 'src', 'apps', 'cli', 'install.sh')], + }; + } + + throw new Error(`Unsupported platform for BitFun CLI installation: ${platform}`); +} + +function main() { + const installer = installerCommand(); + const result = spawnSync(installer.command, [...installer.args, ...process.argv.slice(2)], { + cwd: repoRoot, + stdio: 'inherit', + }); + + if (result.error) { + throw result.error; + } + process.exit(result.status ?? 1); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/install-cli.test.mjs b/scripts/install-cli.test.mjs new file mode 100644 index 0000000000..4e1b5cf3bb --- /dev/null +++ b/scripts/install-cli.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { installerCommand } from './install-cli.mjs'; + +const windows = installerCommand('win32'); +assert.equal(windows.command, 'powershell.exe'); +assert.deepEqual(windows.args.slice(0, 4), [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', +]); +assert.equal(path.basename(windows.args.at(-1)), 'install.ps1'); + +for (const platform of ['darwin', 'linux']) { + const unix = installerCommand(platform); + assert.equal(unix.command, 'bash'); + assert.equal(path.basename(unix.args.at(-1)), 'install.sh'); +} + +assert.throws(() => installerCommand('aix'), /unsupported platform/i); + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const installPowerShell = fs.readFileSync( + path.join(repoRoot, 'src/apps/cli/install.ps1'), + 'utf8', +); +const installShell = fs.readFileSync( + path.join(repoRoot, 'src/apps/cli/install.sh'), + 'utf8', +); +const cliReadme = fs.readFileSync( + path.join(repoRoot, 'src/apps/cli/README.md'), + 'utf8', +); + +for (const content of [installPowerShell, installShell, cliReadme]) { + assert.match(content, /open a new terminal/i); +} +assert.doesNotMatch(installShell, /Sourced ~\/\.(?:bashrc|zshrc) for this session/); diff --git a/scripts/test-acp.js b/scripts/test-acp.js index afe8d02368..501f46f036 100644 --- a/scripts/test-acp.js +++ b/scripts/test-acp.js @@ -7,13 +7,13 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const cliPath = path.join(__dirname, '..', 'target', 'debug', 'bitfun-cli'); -const cliReleasePath = path.join(__dirname, '..', 'target', 'release', 'bitfun-cli'); +const cliPath = path.join(__dirname, '..', 'target', 'debug', 'bitfun'); +const cliReleasePath = path.join(__dirname, '..', 'target', 'release', 'bitfun'); const usePath = fs.existsSync(cliPath) ? cliPath : fs.existsSync(cliReleasePath) ? cliReleasePath - : 'bitfun-cli'; + : 'bitfun'; const cwd = '/tmp/test-acp-node'; fs.mkdirSync(cwd, { recursive: true }); diff --git a/scripts/test-acp.sh b/scripts/test-acp.sh index 21780ab2c1..f942f2825d 100644 --- a/scripts/test-acp.sh +++ b/scripts/test-acp.sh @@ -5,7 +5,7 @@ echo "=== BitFun ACP Server Test ===" echo "" -BINARY="${BITFUN_CLI:-target/debug/bitfun-cli}" +BINARY="${BITFUN_CLI:-target/debug/bitfun}" WORKSPACE="/tmp/test-acp" PIPE_DIR="$(mktemp -d /tmp/bitfun-acp-test-sh.XXXXXX)" ACP_IN="$PIPE_DIR/in" diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index ae55a1a345..e213cbf299 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -112,6 +112,6 @@ two-product build assertion. ## Install for end users -Prefer [`install.sh`](install.sh) / [`README.md`](README.md): release build, copy to -`~/.local/bin`, and idempotent `~/.bashrc` / `~/.zshrc` PATH wiring so users can -run `bitfun-cli` after install. +Use [`install.ps1`](install.ps1), [`install.sh`](install.sh), and [`README.md`](README.md) for +platform-native per-user installation. Document `bitfun` as primary; ship `bitfun-cli` only as the +deprecated compatibility entrypoint, and use `bitfun` in all new examples and integrations. diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 7006d231ef..f8d33a49f8 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -4,11 +4,17 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "BitFun CLI - Terminal User Interface" +default-run = "bitfun" +autobins = false [[bin]] -name = "bitfun-cli" +name = "bitfun" path = "src/main.rs" +[[bin]] +name = "bitfun-cli" +path = "src/bin/bitfun_cli_compat.rs" + [dependencies] # Internal crates bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } @@ -77,6 +83,9 @@ sha2 = { workspace = true } hex = { workspace = true } portable-pty = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = ["Win32_Foundation", "Win32_System_Console"] } + [features] default = [] diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index fa4d2e37c4..a2f3ee49ce 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -13,15 +13,22 @@ scheduler, persistence manager, or event queue. Plugin execution is not enabled ## Common commands ```bash -bitfun-cli # interactive TUI -bitfun-cli exec "summarize this project" # non-interactive, rejects permission requests -bitfun-cli exec "run tests" --auto # approve tool requests for this invocation -bitfun-cli sessions list -bitfun-cli usage -bitfun-cli doctor -bitfun-cli health +bitfun # interactive TUI +bitfun exec "summarize this project" # non-interactive, rejects permission requests +bitfun exec "run tests" --auto # approve tool requests for this invocation +bitfun sessions list +bitfun usage +bitfun doctor +bitfun health ``` +`bitfun-cli` is a deprecated compatibility entrypoint. It writes +`Warning: \`bitfun-cli\` is deprecated; use \`bitfun\` instead.` to stderr; new scripts and +integrations must use `bitfun`. Official installers and archives ship both commands as a pair; a +standalone legacy launcher is an incomplete installation and reports how to reinstall the pair. +The naming change is limited to the shell command: the Cargo package, archive prefix, service +identifiers, and persistent paths retain the `bitfun-cli` name. + The TUI asks before protected tool calls and offers `Allow once`, `Allow always`, and `Reject`. `Allow always` applies only to matching tools in the current runtime context; it does not update the global configuration. Non-interactive `exec` rejects permission requests by default. Use `--auto` @@ -64,23 +71,23 @@ up. Full setup flow on a server: ```bash -bash src/apps/cli/install.sh # build + install the CLI (see below) -bitfun-cli # start the TUI, then /login with your account -bitfun-cli daemon install # register auto-start; device stays reachable after exit/reboot -bitfun-cli daemon status # verify: daemon running + service installed/active +pnpm run cli:install # build + install both entrypoints (see below) +bitfun # start the TUI, then /login with your account +bitfun daemon install # register auto-start; device stays reachable after exit/reboot +bitfun daemon status # verify: daemon running + service installed/active ``` ```bash -bitfun-cli daemon status # daemon liveness + auto-start service status -bitfun-cli daemon install # register and start the auto-start service (requires /login first) -bitfun-cli daemon uninstall # stop and remove the auto-start service -bitfun-cli daemon run # foreground mode (used by the service manager; also for debugging) +bitfun daemon status # daemon liveness + auto-start service status +bitfun daemon install # register and start the auto-start service (requires /login first) +bitfun daemon uninstall # stop and remove the auto-start service +bitfun daemon run # foreground mode (used by the service manager; also for debugging) ``` - Prerequisites: a persisted account session (`/login` inside the TUI first), and on Linux a working systemd user session (`systemctl --user`). Containers, WSL, and some minimal images do not have one; there `daemon install` reports a clear error and you can instead run - `bitfun-cli daemon run` under your own supervisor (tmux, nohup, a custom unit, ...). + `bitfun daemon run` under your own supervisor (tmux, nohup, a custom unit, ...). - Linux: installs a systemd user unit (`~/.config/systemd/user/bitfun-cli-daemon.service`) and enables linger, so the daemon starts at boot and keeps running without an interactive login session. macOS: installs a LaunchAgent. Windows is not supported for auto-start; use @@ -99,7 +106,7 @@ bitfun-cli daemon run # foreground mode (used by the service manager; al Both the interactive CLI and the daemon continuously sync account settings (model configs, default model, agent preferences, ...) with the account cloud: -- Local changes (TUI model picker / model forms, `bitfun-cli models set-default`, or a peer +- Local changes (TUI model picker / model forms, `bitfun models set-default`, or a peer controller's `set_config`) upload after a ~5s debounce, deduped by content hash. - Cloud changes from other devices are pulled right after process start, then every ~30s, and applied to the running process (AI client cache invalidated, config reloaded). @@ -111,38 +118,61 @@ default model, agent preferences, ...) with the account cloud: ### Upgrading -Re-run `bash src/apps/cli/install.sh`. It installs the new binary and restarts the daemon's -auto-start service when one is installed (a running daemon otherwise keeps executing the old -binary). If you supervise the daemon yourself (`daemon run` under tmux/nohup/a custom unit), -restart it manually after upgrading. +Re-run `pnpm run cli:install`, or use the direct Bash/PowerShell command below for your platform. +The installer stages and verifies both entrypoints before replacing an existing pair; a failed +replacement restores the previous pair. It also restarts the daemon's auto-start service when one +is installed (a running daemon otherwise keeps executing the old binary). If you supervise the +daemon yourself (`daemon run` under tmux/nohup/a custom unit), restart it manually after upgrading. -## One-click install (Linux / macOS, amd64 + arm64) +## One-click install (Windows / macOS / Linux) From the repository root: ```bash -bash src/apps/cli/install.sh +pnpm run cli:install ``` -Or from this directory: +The dispatcher selects PowerShell on Windows and Bash on macOS/Linux. Direct platform commands are: ```bash -bash install.sh +# macOS / Linux (amd64 + arm64) +bash src/apps/cli/install.sh + +# Windows x64 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File src/apps/cli/install.ps1 ``` -The script will: +Both installers: 1. `cargo build -p bitfun-cli --release` (native host CPU) -2. Install `bitfun-cli` to `~/.local/bin` (override with `BITFUN_CLI_BIN_DIR`) -3. Idempotently add a PATH block to `~/.bashrc` and `~/.zshrc` -4. `source` the matching rc when the current shell is interactive bash/zsh +2. Install `bitfun` and the deprecated `bitfun-cli` compatibility entrypoint +3. Verify the primary command and the exact compatibility warning +4. Add the install directory to PATH idempotently unless requested otherwise + +The Unix default directory is `~/.local/bin`; the Windows default is +`%LOCALAPPDATA%\BitFun\bin`. Unix updates managed blocks in `~/.bashrc` and `~/.zshrc`; +Windows updates the current user's PATH. -Then run: +The installer process cannot update the shell that launched it. Open a new terminal, then run: ```bash -bitfun-cli +bitfun ``` +For the current shell, either invoke the printed direct path or temporarily prepend the install +directory to `PATH` using the copyable command printed by the installer. + +### Release archives + +Official macOS/Linux archives contain `bitfun`, deprecated `bitfun-cli`, `README.md`, and +`PROJECT-README.md`; the Windows x64 ZIP contains the matching `.exe` pair and documents. Extract +the whole archive and keep the two executables together in the same directory. Run `./bitfun` on +macOS/Linux or `.\bitfun.exe` in PowerShell, then add that directory to `PATH` if desired. + +Do not copy only `bitfun-cli` from an archive. The deprecated command is intentionally a thin +launcher for its sibling `bitfun`; if the sibling is missing, it reports an incomplete installation +with recovery guidance instead of attempting another lookup. + On a server that should stay reachable for account multi-device access, continue with the [daemon section](#always-on-account-device-host-daemon) above after `/login`. @@ -155,6 +185,9 @@ On a server that should stay reachable for account multi-device access, continue | `CARGO_TARGET_DIR` | Cargo target dir (e.g. `$HOME/bitfun-build/target` on shared mounts) | | `CARGO_BUILD_JOBS` | Limit rustc parallelism on small VPS | +Windows accepts `-BinDir ` and `-SkipPathUpdate` arguments. Unix accepts the environment +variables above. + Example on a small arm64 VPS: ```bash @@ -171,5 +204,5 @@ CARGO_BUILD_JOBS=1 bash src/apps/cli/install.sh ```bash pnpm run cli:dev # cargo run pnpm run cli:build # cargo build --release -pnpm run cli:install # same as bash src/apps/cli/install.sh +pnpm run cli:install # dispatch to install.ps1 on Windows or install.sh on macOS/Linux ``` diff --git a/src/apps/cli/install.ps1 b/src/apps/cli/install.ps1 new file mode 100644 index 0000000000..6f5ec3442a --- /dev/null +++ b/src/apps/cli/install.ps1 @@ -0,0 +1,216 @@ +[CmdletBinding()] +param( + [string]$BinDir = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'BitFun\bin'), + [switch]$SkipPathUpdate +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Resolve-RepoRoot { + $candidate = (Resolve-Path $PSScriptRoot).Path + while ($candidate) { + $manifest = Join-Path $candidate 'Cargo.toml' + $cliDirectory = Join-Path $candidate 'src\apps\cli' + if ((Test-Path -LiteralPath $manifest -PathType Leaf) -and + (Test-Path -LiteralPath $cliDirectory -PathType Container) -and + (Select-String -LiteralPath $manifest -Pattern '^\[workspace\]' -Quiet)) { + return $candidate + } + + $parent = Split-Path -Parent $candidate + if (-not $parent -or $parent -eq $candidate) { + break + } + $candidate = $parent + } + + throw "Could not locate the BitFun repository root from $PSScriptRoot" +} + +function Resolve-TargetRoot([string]$RepoRoot) { + if (-not $env:CARGO_TARGET_DIR) { + return Join-Path $RepoRoot 'target' + } + if ([IO.Path]::IsPathRooted($env:CARGO_TARGET_DIR)) { + return [IO.Path]::GetFullPath($env:CARGO_TARGET_DIR) + } + return [IO.Path]::GetFullPath((Join-Path $RepoRoot $env:CARGO_TARGET_DIR)) +} + +function Resolve-ReleaseDir([string]$RepoRoot) { + $targetRoot = Resolve-TargetRoot $RepoRoot + if ($env:CARGO_BUILD_TARGET) { + return Join-Path $targetRoot "$($env:CARGO_BUILD_TARGET)\release" + } + return Join-Path $targetRoot 'release' +} + +function Add-BinDirToUserPath([string]$Directory) { + $normalizedDirectory = [IO.Path]::GetFullPath($Directory).TrimEnd('\') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @($userPath -split ';' | Where-Object { $_ }) + $alreadyPresent = $entries | Where-Object { + try { + [IO.Path]::GetFullPath($_).TrimEnd('\') -ieq $normalizedDirectory + } + catch { + $_.TrimEnd('\') -ieq $normalizedDirectory + } + } + + if (-not $alreadyPresent) { + $updated = (@($entries) + $normalizedDirectory) -join ';' + [Environment]::SetEnvironmentVariable('Path', $updated, 'User') + Write-Host "Added $normalizedDirectory to the user PATH." + } + else { + Write-Host "$normalizedDirectory is already on the user PATH." + } + +} + +function Assert-CommandSucceeded([string]$Description) { + if ($LASTEXITCODE -ne 0) { + throw "$Description failed with exit code $LASTEXITCODE" + } +} + +function Assert-EntrypointPair([string]$Primary, [string]$Legacy) { + & $Primary --version | Out-Null + Assert-CommandSucceeded 'bitfun --version' + + $id = [guid]::NewGuid().ToString('N') + $stdoutFile = Join-Path ([IO.Path]::GetTempPath()) "bitfun-install-$id.out" + $stderrFile = Join-Path ([IO.Path]::GetTempPath()) "bitfun-install-$id.err" + try { + $legacyProcess = Start-Process -FilePath $Legacy -ArgumentList '--version' -Wait -PassThru -NoNewWindow ` + -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + if ($legacyProcess.ExitCode -ne 0) { + throw "bitfun-cli --version failed with exit code $($legacyProcess.ExitCode)" + } + $legacyWarning = (Get-Content -LiteralPath $stderrFile -Raw).TrimEnd("`r", "`n") + if ($legacyWarning -cne $script:deprecation) { + throw "Deprecated entrypoint emitted an unexpected warning: $legacyWarning" + } + } + finally { + Remove-Item -LiteralPath $stdoutFile, $stderrFile -Force -ErrorAction SilentlyContinue + } +} + +function Install-EntrypointPair( + [string]$PrimarySource, + [string]$LegacySource, + [string]$Destination +) { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + $stageDir = Join-Path $Destination ".bitfun-install-$([guid]::NewGuid().ToString('N'))" + $stagedPrimary = Join-Path $stageDir 'bitfun.exe' + $stagedLegacy = Join-Path $stageDir 'bitfun-cli.exe' + $primaryTarget = Join-Path $Destination 'bitfun.exe' + $legacyTarget = Join-Path $Destination 'bitfun-cli.exe' + $primaryBackup = Join-Path $stageDir 'previous-bitfun.exe' + $legacyBackup = Join-Path $stageDir 'previous-bitfun-cli.exe' + $primaryBackedUp = $false + $legacyBackedUp = $false + $primaryCommitted = $false + $legacyCommitted = $false + + New-Item -ItemType Directory -Path $stageDir | Out-Null + try { + Copy-Item -LiteralPath $PrimarySource -Destination $stagedPrimary + Copy-Item -LiteralPath $LegacySource -Destination $stagedLegacy + Assert-EntrypointPair $stagedPrimary $stagedLegacy + + if (Test-Path -LiteralPath $primaryTarget -PathType Leaf) { + Move-Item -LiteralPath $primaryTarget -Destination $primaryBackup + $primaryBackedUp = $true + } + if (Test-Path -LiteralPath $legacyTarget -PathType Leaf) { + Move-Item -LiteralPath $legacyTarget -Destination $legacyBackup + $legacyBackedUp = $true + } + + Move-Item -LiteralPath $stagedPrimary -Destination $primaryTarget + $primaryCommitted = $true + Move-Item -LiteralPath $stagedLegacy -Destination $legacyTarget + $legacyCommitted = $true + Assert-EntrypointPair $primaryTarget $legacyTarget + } + catch { + $installError = $_ + if ($legacyCommitted) { + Remove-Item -LiteralPath $legacyTarget -Force -ErrorAction SilentlyContinue + } + if ($primaryCommitted) { + Remove-Item -LiteralPath $primaryTarget -Force -ErrorAction SilentlyContinue + } + if ($legacyBackedUp) { + Move-Item -LiteralPath $legacyBackup -Destination $legacyTarget -Force + } + if ($primaryBackedUp) { + Move-Item -LiteralPath $primaryBackup -Destination $primaryTarget -Force + } + throw "CLI installation failed; the previous entrypoint pair was restored. $installError" + } + finally { + Remove-Item -LiteralPath $stageDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$repoRoot = Resolve-RepoRoot +$releaseDir = Resolve-ReleaseDir $repoRoot +$primarySource = Join-Path $releaseDir 'bitfun.exe' +$legacySource = Join-Path $releaseDir 'bitfun-cli.exe' +$primaryInstalled = Join-Path $BinDir 'bitfun.exe' +$legacyInstalled = Join-Path $BinDir 'bitfun-cli.exe' +$deprecation = 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' + +if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + throw 'cargo was not found. Install Rust from https://rustup.rs and re-run.' +} +if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) { + throw 'rustc was not found. Install Rust from https://rustup.rs and re-run.' +} + +Write-Host '=== BitFun CLI Install ===' +Write-Host "Repo: $repoRoot" +Write-Host "Install dir: $BinDir" + +Push-Location $repoRoot +try { + Write-Host '[1/3] Building the bitfun and deprecated bitfun-cli entrypoints...' + & cargo build -p bitfun-cli --release + Assert-CommandSucceeded 'cargo build' +} +finally { + Pop-Location +} + +foreach ($source in @($primarySource, $legacySource)) { + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Built executable was not found at $source" + } +} + +Write-Host '[2/3] Installing executables...' +Install-EntrypointPair $primarySource $legacySource $BinDir +Write-Host "Installed: $primaryInstalled" +Write-Host "Installed deprecated compatibility entrypoint: $legacyInstalled" + +if (-not $SkipPathUpdate) { + Add-BinDirToUserPath $BinDir +} +else { + Write-Host 'Skipped the user PATH update (-SkipPathUpdate).' +} + +Write-Host '[3/3] Verifying both entrypoints...' +Assert-EntrypointPair $primaryInstalled $legacyInstalled + +Write-Host '=== Install complete ===' +Write-Host 'Open a new terminal, then run: bitfun' +Write-Host "Current PowerShell: `$env:Path = `"$([IO.Path]::GetFullPath($BinDir));`$env:Path`"; bitfun" +Write-Host "Direct path: $primaryInstalled" +Write-Host 'Deprecated compatibility command: bitfun-cli' diff --git a/src/apps/cli/install.sh b/src/apps/cli/install.sh index fb2103862c..e92aa50570 100755 --- a/src/apps/cli/install.sh +++ b/src/apps/cli/install.sh @@ -7,9 +7,10 @@ # # What it does: # 1. cargo build -p bitfun-cli --release (native host arch) -# 2. Install binary to ~/.local/bin/bitfun-cli (override with BITFUN_CLI_BIN_DIR) +# 2. Install bitfun plus the deprecated bitfun-cli compatibility entrypoint +# to ~/.local/bin (override with BITFUN_CLI_BIN_DIR) # 3. Idempotently append a PATH block to ~/.bashrc and ~/.zshrc -# 4. Source the matching rc for the current shell when interactive +# 4. Print explicit current-shell and new-terminal instructions # 5. Restart the account daemon's auto-start service when installed, so # upgrades take effect (a running daemon keeps the old binary otherwise) # @@ -98,7 +99,7 @@ ensure_bin_dir_on_path_block() { local bin_dir="$1" cat </dev/null || return 1 + stderr_file="$(mktemp)" || return 1 + if ! "$legacy" --version >/dev/null 2>"$stderr_file"; then + rm -f "$stderr_file" + return 1 fi + warning="$(cat "$stderr_file")" + rm -f "$stderr_file" + if [ "$warning" != 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' ]; then + echo "Error: deprecated bitfun-cli entrypoint emitted an unexpected warning: $warning" >&2 + return 1 + fi +} - local shell_name - shell_name="$(basename "${SHELL:-}")" - case "$shell_name" in - zsh) - # shellcheck disable=SC1090 - source "${HOME}/.zshrc" 2>/dev/null || true - echo "Sourced ~/.zshrc for this session." - ;; - bash) - # shellcheck disable=SC1090 - source "${HOME}/.bashrc" 2>/dev/null || true - echo "Sourced ~/.bashrc for this session." - ;; - *) - echo "Current SHELL=${SHELL:-unknown}: PATH updated for this install process." - echo "For new terminals, ensure ${bin_dir} is on PATH (bashrc/zshrc were updated)." - ;; - esac +install_entrypoint_pair() { + local primary_source="$1" + local legacy_source="$2" + local destination="$3" + local stage_dir staged_primary staged_legacy primary_target legacy_target + local primary_backup legacy_backup + local primary_backed_up=0 legacy_backed_up=0 primary_committed=0 legacy_committed=0 + local failed=0 + + mkdir -p "$destination" + stage_dir="$(mktemp -d "${destination}/.bitfun-install.XXXXXX")" + staged_primary="${stage_dir}/bitfun" + staged_legacy="${stage_dir}/bitfun-cli" + primary_target="${destination}/bitfun" + legacy_target="${destination}/bitfun-cli" + primary_backup="${stage_dir}/previous-bitfun" + legacy_backup="${stage_dir}/previous-bitfun-cli" + + install -m 755 "$primary_source" "$staged_primary" || failed=1 + if [ "$failed" -eq 0 ]; then + install -m 755 "$legacy_source" "$staged_legacy" || failed=1 + fi + if [ "$failed" -eq 0 ]; then + assert_entrypoint_pair "$staged_primary" "$staged_legacy" || failed=1 + fi + if [ "$failed" -eq 0 ] && { [ -e "$primary_target" ] || [ -L "$primary_target" ]; }; then + if mv "$primary_target" "$primary_backup"; then primary_backed_up=1; else failed=1; fi + fi + if [ "$failed" -eq 0 ] && { [ -e "$legacy_target" ] || [ -L "$legacy_target" ]; }; then + if mv "$legacy_target" "$legacy_backup"; then legacy_backed_up=1; else failed=1; fi + fi + if [ "$failed" -eq 0 ]; then + if mv "$staged_primary" "$primary_target"; then primary_committed=1; else failed=1; fi + fi + if [ "$failed" -eq 0 ]; then + if mv "$staged_legacy" "$legacy_target"; then legacy_committed=1; else failed=1; fi + fi + if [ "$failed" -eq 0 ]; then + assert_entrypoint_pair "$primary_target" "$legacy_target" || failed=1 + fi + + if [ "$failed" -ne 0 ]; then + if [ "$legacy_committed" -eq 1 ]; then rm -f "$legacy_target"; fi + if [ "$primary_committed" -eq 1 ]; then rm -f "$primary_target"; fi + if [ "$legacy_backed_up" -eq 1 ]; then mv "$legacy_backup" "$legacy_target"; fi + if [ "$primary_backed_up" -eq 1 ]; then mv "$primary_backup" "$primary_target"; fi + rm -rf "$stage_dir" + echo "Error: CLI installation failed; the previous entrypoint pair was restored." >&2 + return 1 + fi + + rm -rf "$stage_dir" } # A running daemon keeps executing the previous binary (old inode) until it is @@ -207,7 +254,7 @@ restart_daemon_for_upgrade() { # No auto-start service installed — warn only when a manually supervised # daemon is still running the old binary. - if "${BIN_DIR}/bitfun-cli" daemon status 2>/dev/null | grep -q "daemon process: running"; then + if "${BIN_DIR}/bitfun" daemon status 2>/dev/null | grep -q "daemon process: running"; then echo "Note: a running daemon was detected (not service-managed); restart it" echo "so it picks up the new binary." fi @@ -220,7 +267,8 @@ BitFun CLI install script Usage: bash install.sh [--help] -Builds a release bitfun-cli and installs it for interactive use. +Builds the release CLI and installs the primary bitfun command plus the +deprecated bitfun-cli compatibility entrypoint. Options: -h, --help Show this help @@ -263,36 +311,40 @@ require_cargo mkdir -p "$BIN_DIR" echo "" -echo "[1/4] Building bitfun-cli (release)..." +echo "[1/4] Building bitfun and the deprecated bitfun-cli entrypoint (release)..." cd "$REPO_ROOT" # Build from workspace root so path deps resolve. cargo build -p bitfun-cli --release TARGET_DIR="${CARGO_TARGET_DIR:-${REPO_ROOT}/target}" -BUILT_BIN="${TARGET_DIR}/release/bitfun-cli" -if [ ! -x "$BUILT_BIN" ]; then - echo "Error: built binary not found at $BUILT_BIN" - exit 1 +if [ -n "${CARGO_BUILD_TARGET:-}" ]; then + RELEASE_DIR="${TARGET_DIR}/${CARGO_BUILD_TARGET}/release" +else + RELEASE_DIR="${TARGET_DIR}/release" fi +BUILT_BIN="${RELEASE_DIR}/bitfun" +BUILT_LEGACY_BIN="${RELEASE_DIR}/bitfun-cli" +for binary in "$BUILT_BIN" "$BUILT_LEGACY_BIN"; do + if [ ! -x "$binary" ]; then + echo "Error: built binary not found at $binary" + exit 1 + fi +done echo "" -echo "[2/4] Installing binary..." -install -m 755 "$BUILT_BIN" "${BIN_DIR}/bitfun-cli" -echo "Installed: ${BIN_DIR}/bitfun-cli" -"${BIN_DIR}/bitfun-cli" --version 2>/dev/null || "${BIN_DIR}/bitfun-cli" -V 2>/dev/null || true +echo "[2/4] Installing binaries..." +install_entrypoint_pair "$BUILT_BIN" "$BUILT_LEGACY_BIN" "$BIN_DIR" +echo "Installed: ${BIN_DIR}/bitfun" +echo "Installed deprecated compatibility entrypoint: ${BIN_DIR}/bitfun-cli" +assert_entrypoint_pair "${BIN_DIR}/bitfun" "${BIN_DIR}/bitfun-cli" echo "" echo "[3/4] Configuring shell PATH..." if [ "${BITFUN_CLI_SKIP_SHELLRC:-0}" = "1" ]; then echo "Skipped shell rc edits (BITFUN_CLI_SKIP_SHELLRC=1)." - case ":${PATH}:" in - *":${BIN_DIR}:"*) ;; - *) export PATH="${BIN_DIR}:$PATH" ;; - esac else upsert_shellrc_path "${HOME}/.bashrc" "$BIN_DIR" upsert_shellrc_path "${HOME}/.zshrc" "$BIN_DIR" - maybe_source_shellrc "$BIN_DIR" fi echo "" @@ -301,15 +353,8 @@ restart_daemon_for_upgrade echo "" echo "=== Install complete ===" -echo "Run: bitfun-cli" +print_path_guidance "$BIN_DIR" +echo "Deprecated compatibility command: bitfun-cli" echo "Login (Peer Host): open /login inside the TUI after start." -echo "Server use: after /login, run \`bitfun-cli daemon install\` to keep this" +echo "Server use: after /login, run \`bitfun daemon install\` to keep this" echo "device reachable by your account even after exit or reboot." -if ! command -v bitfun-cli >/dev/null 2>&1; then - echo "" - echo "Note: \`bitfun-cli\` is not yet visible in this shell's command lookup." - echo "Use one of:" - echo " hash -r && bitfun-cli" - echo " export PATH=\"${BIN_DIR}:\$PATH\" && bitfun-cli" - echo " ${BIN_DIR}/bitfun-cli" -fi diff --git a/src/apps/cli/src/account.rs b/src/apps/cli/src/account.rs index 91f5dfa510..b7058fad9b 100644 --- a/src/apps/cli/src/account.rs +++ b/src/apps/cli/src/account.rs @@ -208,7 +208,7 @@ pub(crate) async fn login_with_credentials( " Device routing is handled by the running CLI daemon.".to_string() } else { match spawn_device_routing(relay_url, &device_name).await { - Ok(()) => " Device routing connected (Peer Host ready). Tip: `bitfun-cli daemon install` keeps this device reachable after exit or reboot.".to_string(), + Ok(()) => " Device routing connected (Peer Host ready). Tip: `bitfun daemon install` keeps this device reachable after exit or reboot.".to_string(), Err(e) => format!(" (Warning: device routing failed: {e})"), } }; diff --git a/src/apps/cli/src/acp_cli.rs b/src/apps/cli/src/acp_cli.rs index 5c36088e4b..3c1ed17b06 100644 --- a/src/apps/cli/src/acp_cli.rs +++ b/src/apps/cli/src/acp_cli.rs @@ -260,8 +260,8 @@ pub(crate) async fn list_external_clients() -> Result<()> { } println!(); - println!("Enable a built-in client with `bitfun-cli acp clients enable opencode`."); - println!("Run a prompt with `bitfun-cli acp run opencode \"your task\"`."); + println!("Enable a built-in client with `bitfun acp clients enable opencode`."); + println!("Run a prompt with `bitfun acp run opencode \"your task\"`."); Ok(()) } diff --git a/src/apps/cli/src/bin/bitfun_cli_compat.rs b/src/apps/cli/src/bin/bitfun_cli_compat.rs new file mode 100644 index 0000000000..0c24fb35a4 --- /dev/null +++ b/src/apps/cli/src/bin/bitfun_cli_compat.rs @@ -0,0 +1,72 @@ +use std::path::{Path, PathBuf}; + +const DEPRECATION: &str = "Warning: `bitfun-cli` is deprecated; use `bitfun` instead."; + +fn sibling_primary() -> std::io::Result { + let current = std::env::current_exe()?; + Ok(current.with_file_name(if cfg!(windows) { + "bitfun.exe" + } else { + "bitfun" + })) +} + +#[cfg(unix)] +fn hand_off(primary: &Path) -> i32 { + use std::os::unix::process::CommandExt; + + let error = std::process::Command::new(primary) + .args(std::env::args_os().skip(1)) + .exec(); + eprintln!("Error: failed to launch {}: {error}", primary.display()); + 1 +} + +#[cfg(windows)] +unsafe extern "system" fn keep_wrapper_alive(ctrl_type: u32) -> windows::core::BOOL { + use windows::Win32::System::Console::{CTRL_BREAK_EVENT, CTRL_C_EVENT}; + + (ctrl_type == CTRL_C_EVENT || ctrl_type == CTRL_BREAK_EVENT).into() +} + +#[cfg(windows)] +fn hand_off(primary: &Path) -> i32 { + use windows::Win32::System::Console::SetConsoleCtrlHandler; + + if let Err(error) = unsafe { SetConsoleCtrlHandler(Some(keep_wrapper_alive), true) } { + eprintln!("Error: failed to initialize deprecated launcher: {error}"); + return 1; + } + + match std::process::Command::new(primary) + .args(std::env::args_os().skip(1)) + .status() + { + Ok(status) => status.code().unwrap_or(1), + Err(error) => { + eprintln!("Error: failed to launch {}: {error}", primary.display()); + 1 + } + } +} + +fn main() { + eprintln!("{DEPRECATION}"); + let code = sibling_primary().map_or_else( + |error| { + eprintln!("Error: failed to locate bitfun: {error}"); + 1 + }, + |primary| { + if !primary.is_file() { + eprintln!( + "Error: incomplete installation: {} is missing; install both `bitfun` and `bitfun-cli` from the same BitFun release.", + primary.display() + ); + return 1; + } + hand_off(&primary) + }, + ); + std::process::exit(code); +} diff --git a/src/apps/cli/src/daemon/runner.rs b/src/apps/cli/src/daemon/runner.rs index d33d494989..7f41286a9c 100644 --- a/src/apps/cli/src/daemon/runner.rs +++ b/src/apps/cli/src/daemon/runner.rs @@ -20,7 +20,7 @@ use super::pid; pub(crate) async fn run_daemon() -> Result<()> { if pid::is_daemon_running() { return Err(anyhow!( - "another bitfun-cli daemon is already running (see `bitfun-cli daemon status`)" + "another bitfun daemon is already running (see `bitfun daemon status`)" )); } @@ -36,7 +36,7 @@ pub(crate) async fn run_daemon() -> Result<()> { let Some(user_id) = account::try_restore_session().await else { return Err(anyhow!( - "not logged in; run `bitfun-cli`, log in with `/login`, then start the daemon again" + "not logged in; run `bitfun`, log in with `/login`, then start the daemon again" )); }; tracing::info!("Daemon restored account session for user {user_id}"); @@ -51,7 +51,7 @@ pub(crate) async fn run_daemon() -> Result<()> { crate::account_sync::start_settings_sync_loop(); pid::write_pid_file()?; - tracing::info!("bitfun-cli daemon running (pid {})", std::process::id()); + tracing::info!("bitfun daemon running (pid {})", std::process::id()); let mut expired_check = tokio::time::interval(Duration::from_secs(5)); expired_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -75,7 +75,7 @@ pub(crate) async fn run_daemon() -> Result<()> { account::stop_device_routing().await; pid::remove_pid_file(); crate::shutdown_mcp_servers().await; - tracing::info!("bitfun-cli daemon stopped"); + tracing::info!("bitfun daemon stopped"); Ok(()) } diff --git a/src/apps/cli/src/daemon/service.rs b/src/apps/cli/src/daemon/service.rs index cae9471086..e6470ac9dc 100644 --- a/src/apps/cli/src/daemon/service.rs +++ b/src/apps/cli/src/daemon/service.rs @@ -128,7 +128,7 @@ fn ensure_systemd_user_available() -> Result<()> { _ => Err(anyhow!( "systemd user session is not available (no user bus; common in containers, WSL, or SSH sessions without systemd --user).\n\ Enable a systemd user session for this account, or run the daemon under your own supervisor instead:\n\ - \x20 bitfun-cli daemon run" + \x20 bitfun daemon run" )), } } @@ -201,7 +201,7 @@ fn install_platform_service(executable: &Path) -> Result { #[cfg(not(unix))] fn install_platform_service(_executable: &Path) -> Result { Err(anyhow!( - "daemon auto-start service is not supported on this platform; run `bitfun-cli daemon run` in a terminal instead" + "daemon auto-start service is not supported on this platform; run `bitfun daemon run` in a terminal instead" )) } @@ -274,7 +274,7 @@ pub(crate) fn install_service() -> Result<()> { Ok(Some(_)) => {} Ok(None) => { return Err(anyhow!( - "not logged in; run `bitfun-cli`, log in with `/login`, then re-run `bitfun-cli daemon install`" + "not logged in; run `bitfun`, log in with `/login`, then re-run `bitfun daemon install`" )); } Err(error) => return Err(anyhow!("read account session: {error}")), @@ -318,7 +318,7 @@ pub(crate) fn print_status() -> Result<()> { ); } if !installed && !running { - println!("hint: `bitfun-cli daemon install` keeps this device reachable after reboot"); + println!("hint: `bitfun daemon install` keeps this device reachable after reboot"); } Ok(()) } @@ -329,8 +329,8 @@ mod tests { #[test] fn systemd_unit_runs_daemon_run_and_restarts_on_failure() { - let unit = render_systemd_unit(Path::new("/home/u/.local/bin/bitfun-cli")); - assert!(unit.contains("ExecStart=/home/u/.local/bin/bitfun-cli daemon run")); + let unit = render_systemd_unit(Path::new("/home/u/.local/bin/bitfun")); + assert!(unit.contains("ExecStart=/home/u/.local/bin/bitfun daemon run")); assert!(unit.contains("Restart=on-failure")); assert!(unit.contains("WantedBy=default.target")); assert!(unit.contains("After=network-online.target")); @@ -339,8 +339,8 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn launch_agent_runs_daemon_run_at_load() { - let plist = render_launch_agent(Path::new("/usr/local/bin/bitfun-cli")); - assert!(plist.contains("/usr/local/bin/bitfun-cli")); + let plist = render_launch_agent(Path::new("/usr/local/bin/bitfun")); + assert!(plist.contains("/usr/local/bin/bitfun")); assert!(plist.contains("run")); assert!(plist.contains("RunAtLoad")); assert!(plist.contains(LAUNCH_AGENT_LABEL)); diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 05d6e0d97b..51712d1e45 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -261,13 +261,13 @@ enum AcpAction { /// Show ACP server status and capabilities Status { /// Command name or path to show in generated examples - #[arg(long, default_value = "bitfun-cli")] + #[arg(long, default_value = "bitfun")] command: String, }, /// Check local readiness for ACP clients Doctor { /// Command name or path to show in generated examples - #[arg(long, default_value = "bitfun-cli")] + #[arg(long, default_value = "bitfun")] command: String, }, /// Print editor/client integration snippets @@ -277,7 +277,7 @@ enum AcpAction { client: acp_cli::AcpConfigClient, /// Command name or path your editor should execute - #[arg(long, default_value = "bitfun-cli")] + #[arg(long, default_value = "bitfun")] command: String, }, /// Manage external ACP agents that BitFun can launch @@ -1114,7 +1114,7 @@ fn main() { .expect("failed to build tokio runtime"); runtime.block_on(run_cli()) }) - .expect("failed to spawn bitfun-cli worker thread"); + .expect("failed to spawn bitfun worker thread"); match worker.join() { Ok(Ok(())) => {} @@ -1126,7 +1126,7 @@ fn main() { std::process::exit(1); } Err(_) => { - eprintln!("Error: bitfun-cli worker thread panicked"); + eprintln!("Error: bitfun worker thread panicked"); std::process::exit(1); } } @@ -1139,15 +1139,14 @@ mod plugin_command_tests { #[test] fn plugin_commands_parse_list_and_source_review_actions() { - let list = Cli::try_parse_from(["bitfun-cli", "plugins"]).expect("parse plugin list"); + let list = Cli::try_parse_from(["bitfun", "plugins"]).expect("parse plugin list"); assert!(matches!( list.command, Some(Commands::Plugins { action: None }) )); - let approval = - Cli::try_parse_from(["bitfun-cli", "plugins", "approve-source", "acme.demo"]) - .expect("parse plugin source approval"); + let approval = Cli::try_parse_from(["bitfun", "plugins", "approve-source", "acme.demo"]) + .expect("parse plugin source approval"); assert!(matches!( approval.command, Some(Commands::Plugins { @@ -1155,7 +1154,7 @@ mod plugin_command_tests { }) if package_id == "acme.demo" )); - let deny = Cli::try_parse_from(["bitfun-cli", "plugins", "deny", "acme.demo"]) + let deny = Cli::try_parse_from(["bitfun", "plugins", "deny", "acme.demo"]) .expect("parse plugin deny"); assert!(matches!( deny.command, @@ -1164,7 +1163,7 @@ mod plugin_command_tests { }) if package_id == "acme.demo" )); - let revoke = Cli::try_parse_from(["bitfun-cli", "plugins", "revoke", "acme.demo"]) + let revoke = Cli::try_parse_from(["bitfun", "plugins", "revoke", "acme.demo"]) .expect("parse plugin revoke"); assert!(matches!( revoke.command, @@ -1173,7 +1172,7 @@ mod plugin_command_tests { }) if package_id == "acme.demo" )); - let preview = Cli::try_parse_from(["bitfun-cli", "plugins", "activate", "acme.demo"]) + let preview = Cli::try_parse_from(["bitfun", "plugins", "activate", "acme.demo"]) .expect("parse plugin activation preview"); assert!(matches!( preview.command, @@ -1186,7 +1185,7 @@ mod plugin_command_tests { )); let confirm = Cli::try_parse_from([ - "bitfun-cli", + "bitfun", "plugins", "activate", "acme.demo", @@ -1204,7 +1203,7 @@ mod plugin_command_tests { }) if package_id == "acme.demo" && content_hash == "sha256:previewed" )); - let deactivate = Cli::try_parse_from(["bitfun-cli", "plugins", "deactivate", "acme.demo"]) + let deactivate = Cli::try_parse_from(["bitfun", "plugins", "deactivate", "acme.demo"]) .expect("parse plugin deactivation"); assert!(matches!( deactivate.command, @@ -1225,7 +1224,7 @@ mod external_config_command_tests { #[test] fn external_config_commands_keep_scope_and_capability_explicit() { - let status = Cli::try_parse_from(["bitfun-cli", "config", "external", "status"]) + let status = Cli::try_parse_from(["bitfun", "config", "external", "status"]) .expect("parse external status"); assert!(matches!( status.command, @@ -1237,7 +1236,7 @@ mod external_config_command_tests { )); let mode = Cli::try_parse_from([ - "bitfun-cli", + "bitfun", "config", "external", "set-mode", @@ -1260,7 +1259,7 @@ mod external_config_command_tests { )); let capability = Cli::try_parse_from([ - "bitfun-cli", + "bitfun", "config", "external", "set-capability", @@ -1284,7 +1283,7 @@ mod external_config_command_tests { }) if ecosystem == "opencode" )); - let reset = Cli::try_parse_from(["bitfun-cli", "config", "external", "reset-incompatible"]) + let reset = Cli::try_parse_from(["bitfun", "config", "external", "reset-incompatible"]) .expect("parse incompatible policy reset"); assert!(matches!( reset.command, diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index 8f7bc63daf..bfd7d49aa0 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -346,7 +346,7 @@ pub(crate) async fn activate_plugin(package_id: &str, confirm: Option<&str>) -> let diagnostic = crate::plugin_diagnostics::escape_terminal_text(&error.to_string()); if confirm.is_some() { anyhow!( - "{}\nRe-run `bitfun-cli plugins activate {}` to preview the current content, then confirm with the new content hash.", + "{}\nRe-run `bitfun plugins activate {}` to preview the current content, then confirm with the new content hash.", diagnostic, crate::plugin_diagnostics::escape_terminal_text(package_id) ) @@ -359,7 +359,7 @@ pub(crate) async fn activate_plugin(package_id: &str, confirm: Option<&str>) -> if confirm.is_none() { println!(); println!( - "No activation state changed. Re-run `bitfun-cli plugins activate {} --confirm {}` to confirm this exact package content.", + "No activation state changed. Re-run `bitfun plugins activate {} --confirm {}` to confirm this exact package content.", crate::plugin_diagnostics::escape_terminal_text(package_id), crate::plugin_diagnostics::escape_terminal_text(&view.content_hash) ); @@ -378,7 +378,7 @@ pub(crate) async fn deactivate_plugin(package_id: &str) -> Result<()> { ManagedPluginSourceError::DeactivationPersistenceUncertain { .. } ) { anyhow!( - "{diagnostic}\nThe saved state may already be cleared. Retry `bitfun-cli plugins deactivate {}` to confirm the result; the operation is idempotent.", + "{diagnostic}\nThe saved state may already be cleared. Retry `bitfun plugins deactivate {}` to confirm the result; the operation is idempotent.", crate::plugin_diagnostics::escape_terminal_text(package_id) ) } else { diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 65eaea8649..f55481af89 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -630,7 +630,7 @@ impl ChatMode { self.handle_external_tool_review("", chat_view, chat_state, rt_handle); } ActionHandler::AcpHelp => { - chat_state.add_system_message(crate::acp_cli::acp_help_text("bitfun-cli")); + chat_state.add_system_message(crate::acp_cli::acp_help_text("bitfun")); chat_view.set_status(Some( "ACP setup added to the conversation. You can keep typing.".to_string(), )); diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index ed73b480b1..b9379aca75 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -179,7 +179,7 @@ fn external_integration_policy_lines(snapshot: &ExternalSourceCatalogSnapshot) - "Access: safely off; unsupported policy schema {}", policy.schema_major ), - "Recover: bitfun-cli config external reset-incompatible".to_string(), + "Recover: bitfun config external reset-incompatible".to_string(), ]; } if !policy.status.is_compatible() { @@ -244,7 +244,7 @@ fn external_integration_policy_lines(snapshot: &ExternalSourceCatalogSnapshot) - descriptor.display_name )); } - lines.push("Manage: bitfun-cli config external --help".to_string()); + lines.push("Manage: bitfun config external --help".to_string()); lines } diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 316ae52c18..dd313a12f6 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -278,7 +278,7 @@ mod tests { assert!(text.contains("OpenCode: recommended")); assert!(text.contains("command auto")); assert!(text.contains("tool ask")); - assert!(text.contains("bitfun-cli config external --help")); + assert!(text.contains("bitfun config external --help")); } #[test] diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 105879710b..0207159251 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -455,7 +455,7 @@ fn print_external_policy_status(snapshot: &ExternalSourceCatalogSnapshot) { "Status: safely off (policy schema {} is not supported by this version)", policy.schema_major ); - println!("Recovery: bitfun-cli config external reset-incompatible"); + println!("Recovery: bitfun config external reset-incompatible"); println!("The original policy will be backed up before safe defaults are restored."); return; } diff --git a/src/apps/cli/tests/acp_stdio_cli.rs b/src/apps/cli/tests/acp_stdio_cli.rs index 78d8d10194..9acfd79be6 100644 --- a/src/apps/cli/tests/acp_stdio_cli.rs +++ b/src/apps/cli/tests/acp_stdio_cli.rs @@ -16,7 +16,7 @@ struct AcpProcess { impl AcpProcess { async fn spawn(environment: &CliTestEnvironment) -> Self { - let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_bitfun-cli")); + let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_bitfun")); command .arg("acp") .stdin(Stdio::piped()) diff --git a/src/apps/cli/tests/compat_entrypoint.rs b/src/apps/cli/tests/compat_entrypoint.rs new file mode 100644 index 0000000000..69ed26021c --- /dev/null +++ b/src/apps/cli/tests/compat_entrypoint.rs @@ -0,0 +1,60 @@ +use std::process::Command; + +const DEPRECATION: &str = "Warning: `bitfun-cli` is deprecated; use `bitfun` instead."; + +#[test] +fn legacy_version_matches_primary_and_warns_only_on_stderr() { + let primary = Command::new(env!("CARGO_BIN_EXE_bitfun")) + .arg("--version") + .output() + .expect("run bitfun --version"); + let legacy = Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) + .arg("--version") + .output() + .expect("run deprecated bitfun-cli --version"); + + assert!(primary.status.success()); + assert!(legacy.status.success()); + assert_eq!(legacy.stdout, primary.stdout); + assert_eq!(String::from_utf8_lossy(&legacy.stderr).trim(), DEPRECATION); + assert!(!String::from_utf8_lossy(&primary.stderr).contains("deprecated")); +} + +#[test] +fn legacy_forwards_clap_failure_exit_code() { + let primary = Command::new(env!("CARGO_BIN_EXE_bitfun")) + .arg("--not-a-real-option") + .output() + .expect("run invalid primary command"); + let legacy = Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) + .arg("--not-a-real-option") + .output() + .expect("run invalid legacy command"); + + assert_eq!(legacy.status.code(), primary.status.code()); + assert!(String::from_utf8_lossy(&legacy.stderr).starts_with(DEPRECATION)); +} + +#[test] +fn legacy_reports_a_missing_primary_without_recursing() { + let temp = tempfile::tempdir().expect("create temporary install directory"); + let file_name = if cfg!(windows) { + "bitfun-cli.exe" + } else { + "bitfun-cli" + }; + let copied = temp.path().join(file_name); + std::fs::copy(env!("CARGO_BIN_EXE_bitfun-cli"), &copied) + .expect("copy deprecated launcher without primary sibling"); + let output = Command::new(copied) + .arg("--version") + .output() + .expect("run isolated deprecated launcher"); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(!output.status.success()); + assert!(stderr.starts_with(DEPRECATION)); + assert!(stderr.contains("incomplete installation")); + assert!(stderr.contains("install both `bitfun` and `bitfun-cli`")); + assert_eq!(stderr.matches(DEPRECATION).count(), 1); +} diff --git a/src/apps/cli/tests/exec_cli_contracts.rs b/src/apps/cli/tests/exec_cli_contracts.rs index b1c0a88ce1..b1fac87ff1 100644 --- a/src/apps/cli/tests/exec_cli_contracts.rs +++ b/src/apps/cli/tests/exec_cli_contracts.rs @@ -7,10 +7,10 @@ use support::{ }; fn run_cli(args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) + Command::new(env!("CARGO_BIN_EXE_bitfun")) .args(args) .output() - .expect("run bitfun-cli") + .expect("run bitfun") } fn stdout(output: &Output) -> String { diff --git a/src/apps/cli/tests/plugin_source_cli.rs b/src/apps/cli/tests/plugin_source_cli.rs index 2a159582cf..d754861a6d 100644 --- a/src/apps/cli/tests/plugin_source_cli.rs +++ b/src/apps/cli/tests/plugin_source_cli.rs @@ -42,7 +42,7 @@ fn write_package(workspace: &Path, source: &[u8], declared_hash: &str) { fn run_cli(workspace: &Path, user_root: &Path, home_root: &Path, args: &[&str]) -> Output { let config_root = user_root.join("host-config"); - Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) + Command::new(env!("CARGO_BIN_EXE_bitfun")) .args(args) .current_dir(workspace) .env_remove("BITFUN_USER_ROOT") @@ -54,7 +54,7 @@ fn run_cli(workspace: &Path, user_root: &Path, home_root: &Path, args: &[&str]) .env("XDG_CONFIG_HOME", &config_root) .env("HOME", home_root) .output() - .expect("run bitfun-cli") + .expect("run bitfun") } fn stdout(output: &Output) -> String { @@ -120,7 +120,7 @@ fn plugin_source_cli_rejects_unavailable_product_paths() { let config_root = temp.path().join("host-config"); std::fs::create_dir_all(&workspace).expect("create workspace"); - let output = Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) + let output = Command::new(env!("CARGO_BIN_EXE_bitfun")) .args(["plugins", "list"]) .current_dir(&workspace) .env_remove("BITFUN_USER_ROOT") @@ -131,7 +131,7 @@ fn plugin_source_cli_rejects_unavailable_product_paths() { .env("APPDATA", &config_root) .env("XDG_CONFIG_HOME", &config_root) .output() - .expect("run bitfun-cli"); + .expect("run bitfun"); assert!(!output.status.success()); assert!(stderr(&output).contains("Configuration error")); @@ -196,7 +196,7 @@ fn plugin_source_cli_lifecycle_and_doctor_exit_codes() { assert!(!rejected.status.success()); assert!(stderr(&rejected).contains("does not match")); assert!(stderr(&rejected) - .contains("Re-run `bitfun-cli plugins activate acme.demo` to preview the current content")); + .contains("Re-run `bitfun plugins activate acme.demo` to preview the current content")); let content_hash = activation_content_hash(&preview); diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs index 609b0e6cac..bf019068ff 100644 --- a/src/apps/cli/tests/product_assembly_cli.rs +++ b/src/apps/cli/tests/product_assembly_cli.rs @@ -9,7 +9,7 @@ fn doctor_reports_the_validated_cli_runtime_assembly() { let config_root = temp.path().join("host-config"); std::fs::create_dir_all(&workspace).expect("create workspace"); - let output = Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) + let output = Command::new(env!("CARGO_BIN_EXE_bitfun")) .arg("doctor") .current_dir(&workspace) .env_remove("BITFUN_USER_ROOT") @@ -21,7 +21,7 @@ fn doctor_reports_the_validated_cli_runtime_assembly() { .env("XDG_CONFIG_HOME", &config_root) .env("HOME", &home_root) .output() - .expect("run bitfun-cli doctor"); + .expect("run bitfun doctor"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -57,7 +57,7 @@ fn health_reports_assembly_and_compatibility_boundaries() { let config_root = temp.path().join("host-config"); std::fs::create_dir_all(&workspace).expect("create workspace"); - let output = Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) + let output = Command::new(env!("CARGO_BIN_EXE_bitfun")) .arg("health") .current_dir(&workspace) .env_remove("BITFUN_USER_ROOT") @@ -69,7 +69,7 @@ fn health_reports_assembly_and_compatibility_boundaries() { .env("XDG_CONFIG_HOME", &config_root) .env("HOME", &home_root) .output() - .expect("run bitfun-cli health"); + .expect("run bitfun health"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -104,7 +104,7 @@ fn doctor_rejects_incomplete_e2e_storage_roots() { let config_root = temp.path().join("host-config"); std::fs::create_dir_all(&workspace).expect("create workspace"); - let mut command = Command::new(env!("CARGO_BIN_EXE_bitfun-cli")); + let mut command = Command::new(env!("CARGO_BIN_EXE_bitfun")); command .arg("doctor") .current_dir(&workspace) @@ -123,7 +123,7 @@ fn doctor_rejects_incomplete_e2e_storage_roots() { command.env("BITFUN_E2E_HOME", &home_root); } - let output = command.output().expect("run bitfun-cli doctor"); + let output = command.output().expect("run bitfun doctor"); let stderr = String::from_utf8_lossy(&output.stderr); assert!(!output.status.success(), "{case_name}: {stderr}"); assert!( diff --git a/src/apps/cli/tests/support/mod.rs b/src/apps/cli/tests/support/mod.rs index 7743b9c50d..e47083f08e 100644 --- a/src/apps/cli/tests/support/mod.rs +++ b/src/apps/cli/tests/support/mod.rs @@ -202,7 +202,7 @@ impl CliTestEnvironment { } pub(crate) fn std_command(&self) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_bitfun-cli")); + let mut command = Command::new(env!("CARGO_BIN_EXE_bitfun")); command.current_dir(&self.workspace); self.apply_std_environment(&mut command); command @@ -224,7 +224,15 @@ impl CliTestEnvironment { } pub(crate) fn pty_command(&self) -> CommandBuilder { - let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_bitfun-cli")); + self.pty_command_for(env!("CARGO_BIN_EXE_bitfun")) + } + + pub(crate) fn deprecated_pty_command(&self) -> CommandBuilder { + self.pty_command_for(env!("CARGO_BIN_EXE_bitfun-cli")) + } + + fn pty_command_for(&self, binary: &str) -> CommandBuilder { + let mut command = CommandBuilder::new(binary); command.cwd(&self.workspace); command.env_remove("BITFUN_USER_ROOT"); command.env_remove("BITFUN_HOME"); diff --git a/src/apps/cli/tests/terminal_process_contracts.rs b/src/apps/cli/tests/terminal_process_contracts.rs index df12fcb34a..c1aa6b5471 100644 --- a/src/apps/cli/tests/terminal_process_contracts.rs +++ b/src/apps/cli/tests/terminal_process_contracts.rs @@ -181,10 +181,23 @@ fn active_turn_resize_can_be_cancelled_and_returns_to_editable_input() { #[test] fn exec_stream_json_ctrl_c_emits_one_cancelled_terminal_and_disconnects() { + assert_exec_stream_json_ctrl_c_contract(false); +} + +#[test] +fn legacy_exec_stream_json_ctrl_c_emits_one_cancelled_terminal_and_disconnects() { + assert_exec_stream_json_ctrl_c_contract(true); +} + +fn assert_exec_stream_json_ctrl_c_contract(deprecated_entrypoint: bool) { let server = MockOpenAiServer::gated(); let environment = CliTestEnvironment::new(); environment.configure_mock_model(server.base_url()); - let mut command = environment.pty_command(); + let mut command = if deprecated_entrypoint { + environment.deprecated_pty_command() + } else { + environment.pty_command() + }; command.args([ "exec", "exercise interrupt contract", @@ -257,6 +270,14 @@ fn exec_stream_json_ctrl_c_emits_one_cancelled_terminal_and_disconnects() { "DialogTurnCancelled", "cancellation must be the final protocol envelope:\n{output}" ); + let deprecation_count = output + .matches("Warning: `bitfun-cli` is deprecated; use `bitfun` instead.") + .count(); + assert_eq!( + deprecation_count, + usize::from(deprecated_entrypoint), + "deprecated warning count changed:\n{output}" + ); } fn strict_stream_json_events(output: &str) -> Vec { @@ -355,7 +376,7 @@ impl PtyProcess { let mut child = pair .slave .spawn_command(command) - .expect("spawn bitfun-cli in native PTY"); + .expect("spawn BitFun CLI in native PTY"); drop(pair.slave); let mut reader = pair.master.try_clone_reader().expect("clone PTY reader"); @@ -377,7 +398,7 @@ impl PtyProcess { // Detect an immediate startup failure before handing the process to the test. if let Some(status) = child.try_wait().expect("poll initial CLI process") { - panic!("bitfun-cli exited during PTY startup: {status}"); + panic!("BitFun CLI exited during PTY startup: {status}"); } Self { @@ -404,7 +425,7 @@ impl PtyProcess { .as_mut() .expect("PTY process child") .try_wait() - .expect("poll bitfun-cli process") + .expect("poll BitFun CLI process") { let output = self.output(); self.close_io(); @@ -440,7 +461,7 @@ impl PtyProcess { .as_mut() .expect("PTY process child") .try_wait() - .expect("poll bitfun-cli process") + .expect("poll BitFun CLI process") { break status; } diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 7d7636904d..3e56382f5c 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -182,7 +182,7 @@ the `/relay` suffix to match the official server format **CLI** -1. Run `bitfun-cli`, open `/login`. +1. Run `bitfun`, open `/login`. 2. Fill **Auth Server**, **Username**, **Password**, then Login. 3. After login, the CLI can act as a **Peer Host** for same-account Desktops.