From d4d685b0769f22ed94b1ded6d5741c1ee3dcab0a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Mar 2026 10:49:39 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ci/ai):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20dev=20=E5=88=86=E6=94=AF=E8=87=AA=E5=8A=A8=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=B9=B6=E5=A2=9E=E5=BC=BA=20Claude?= =?UTF-8?q?=20CLI=20Windows=20=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI 新增:添加 dev-build.yml,push dev 分支自动触发全平台构建并发布 Pre-release - CI 清理:删除已废弃的 test-build-all-platforms.yml 和 test-macos-build.yml - Claude CLI:新增 Windows 环境自动探测 Git Bash 路径(ProgramFiles/LocalAppData 多候选) - Claude CLI:setEnv 改为可返回 error,环境变量操作提纯为 buildClaudeCLIEnv 纯函数 - Claude CLI:新增 upsertEnv/fileExists/joinWindowsPath 等工具函数 --- .github/workflows/dev-build.yml | 607 ++++++++++++++++++ .../workflows/test-build-all-platforms.yml | 412 ------------ .github/workflows/test-macos-build.yml | 94 --- frontend/package.json.md5 | 2 +- internal/ai/provider/claude_cli.go | 163 ++++- internal/ai/provider/claude_cli_test.go | 69 ++ 6 files changed, 829 insertions(+), 518 deletions(-) create mode 100644 .github/workflows/dev-build.yml delete mode 100644 .github/workflows/test-build-all-platforms.yml delete mode 100644 .github/workflows/test-macos-build.yml create mode 100644 internal/ai/provider/claude_cli_test.go diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 0000000..2bd557c --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,607 @@ +name: Dev Build + +on: + push: + branches: + - dev + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: darwin/amd64 + os_name: MacOS + arch_name: Amd64 + build_name: gonavi-build-darwin-amd64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: macos-latest + platform: darwin/arm64 + os_name: MacOS + arch_name: Arm64 + build_name: gonavi-build-darwin-arm64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: windows-latest + platform: windows/amd64 + os_name: Windows + arch_name: Amd64 + build_name: gonavi-build-windows-amd64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: windows-latest + platform: windows/arm64 + os_name: Windows + arch_name: Arm64 + build_name: gonavi-build-windows-arm64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "" + - os: ubuntu-22.04 + platform: linux/amd64 + os_name: Linux + arch_name: Amd64 + build_name: gonavi-build-linux-amd64 + wails_tags: "" + artifact_suffix: "" + build_optional_agents: true + linux_webkit: "4.0" + - os: ubuntu-24.04 + platform: linux/amd64 + os_name: Linux + arch_name: Amd64 + build_name: gonavi-build-linux-amd64-webkit41 + wails_tags: "webkit2_41" + artifact_suffix: "-WebKit41" + build_optional_agents: false + linux_webkit: "4.1" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + check-latest: true + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install UPX (Windows) + if: contains(matrix.platform, 'windows') + shell: pwsh + run: | + $UPX_VERSION = "4.2.4" + $url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip" + $zipPath = "$env:RUNNER_TEMP\upx.zip" + $extractPath = "$env:RUNNER_TEMP\upx" + Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..." + Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing + Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force + $upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1 + "$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + $upxCmd = Join-Path $upxDir.FullName "upx.exe" + if (!(Test-Path $upxCmd)) { + Write-Error "❌ 未检测到 upx,无法保证 Windows 产物经过压缩" + exit 1 + } + & $upxCmd --version + + - name: Install Linux Dependencies + if: contains(matrix.platform, 'linux') + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev + + if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then + sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev + else + sudo apt-get install -y libwebkit2gtk-4.0-dev + fi + + sudo apt-get install -y upx-ucl || sudo apt-get install -y upx + upx --version + + sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true + + LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" + PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage" + + echo "📥 下载 linuxdeploy..." + wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \ + -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || { + echo "⚠️ linuxdeploy 下载失败,AppImage 打包将跳过" + touch /tmp/skip-appimage + } + + echo "📥 下载 linuxdeploy-plugin-gtk..." + wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \ + -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || { + echo "⚠️ linuxdeploy-plugin-gtk 下载失败,AppImage 打包将跳过" + touch /tmp/skip-appimage + } + + if [ ! -f /tmp/skip-appimage ]; then + chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk + echo "✅ AppImage 工具准备完成" + fi + + - name: Install Wails + run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest + + - name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64) + id: msys2_duckdb + if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} + continue-on-error: true + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: >- + mingw-w64-ucrt-x86_64-gcc + + - name: Configure DuckDB CGO Toolchain (Windows AMD64) + if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} + shell: pwsh + run: | + function Find-MingwBin([string[]]$candidates) { + foreach ($bin in $candidates) { + if ([string]::IsNullOrWhiteSpace($bin)) { + continue + } + $gcc = Join-Path $bin 'gcc.exe' + $gxx = Join-Path $bin 'g++.exe' + if ((Test-Path $gcc) -and (Test-Path $gxx)) { + return $bin + } + } + return $null + } + + $msys2Outcome = "${{ steps.msys2_duckdb.outcome }}" + $msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}" + $candidateBins = @() + if (-not [string]::IsNullOrWhiteSpace($msys2Location)) { + $candidateBins += Join-Path $msys2Location 'ucrt64\bin' + } + $candidateBins += @( + 'C:\msys64\ucrt64\bin', + 'D:\a\_temp\msys64\ucrt64\bin' + ) + $candidateBins = @($candidateBins | Select-Object -Unique) + + $mingwBin = Find-MingwBin $candidateBins + if (-not $mingwBin) { + if ($msys2Outcome -ne 'success') { + Write-Warning "⚠️ MSYS2 安装步骤结果为 $msys2Outcome,回退到 UCRT64 本机路径探测" + } else { + Write-Warning "⚠️ MSYS2 已执行,但未找到 UCRT64 gcc/g++,回退到本机路径探测" + } + $mingwBin = Find-MingwBin $candidateBins + } + + if (-not $mingwBin) { + Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。已检查:$($candidateBins -join ', ')" + exit 1 + } + + $gcc = (Join-Path $mingwBin 'gcc.exe') + $gxx = (Join-Path $mingwBin 'g++.exe') + + if (!(Test-Path $gcc) -or !(Test-Path $gxx)) { + Write-Error "❌ DuckDB 编译器缺失:gcc=$gcc g++=$gxx" + exit 1 + } + + "$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + "CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx" + + - name: Verify DuckDB CGO Toolchain (Windows AMD64) + if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} + shell: pwsh + run: | + & "$env:CC" --version + & "$env:CXX" --version + + # ---- 生成 dev 版本号 ---- + - name: Generate Dev Version + id: version + shell: bash + run: | + SHORT_SHA=$(git rev-parse --short HEAD) + DEV_VERSION="dev-${SHORT_SHA}" + echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" + echo "📌 Dev 版本号: ${DEV_VERSION}" + + - name: Build + shell: bash + run: | + set -euo pipefail + DEV_VERSION="${{ steps.version.outputs.version }}" + if [ -n "${{ matrix.wails_tags }}" ]; then + wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}" + else + wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}" + fi + + - name: Build Optional Driver Agents + if: ${{ matrix.build_optional_agents }} + shell: bash + run: | + set -euo pipefail + TARGET_PLATFORM="${{ matrix.platform }}" + GOOS="${TARGET_PLATFORM%%/*}" + GOARCH="${TARGET_PLATFORM##*/}" + DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse) + OUTDIR="drivers/${{ matrix.os_name }}" + mkdir -p "$OUTDIR" + + for DRIVER in "${DRIVERS[@]}"; do + BUILD_DRIVER="$DRIVER" + if [ "$DRIVER" = "doris" ]; then + BUILD_DRIVER="diros" + fi + if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then + echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)" + continue + fi + TAG="gonavi_${BUILD_DRIVER}_driver" + OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}" + if [ "$GOOS" = "windows" ]; then + OUTPUT="${OUTPUT}.exe" + fi + OUTPUT_PATH="${OUTDIR}/${OUTPUT}" + echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})" + if [ "$DRIVER" = "duckdb" ]; then + CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ + -tags "${TAG}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${OUTPUT_PATH}" \ + ./cmd/optional-driver-agent + else + CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \ + -tags "${TAG}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${OUTPUT_PATH}" \ + ./cmd/optional-driver-agent + fi + done + + # macOS Packaging + - name: Package macOS DMG + if: contains(matrix.platform, 'darwin') + run: | + brew install create-dmg + VERSION="${{ steps.version.outputs.version }}" + cd build/bin + + APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1) + if [ -z "$APP_PATH" ]; then + echo "❌ 未找到 .app 应用包!" + exit 1 + fi + APP_NAME=$(basename "$APP_PATH") + + APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1) + if [ -z "$APP_BIN" ]; then + echo "❌ 未找到 macOS 应用主程序!" + exit 1 + fi + echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。" + + echo "🔏 正在进行 Ad-hoc 签名..." + codesign --force --deep --sign - "$APP_NAME" + + DMG_NAME="${{ matrix.build_name }}.dmg" + FINAL_NAME="GoNavi-${VERSION}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg" + echo "📦 正在生成 DMG: $DMG_NAME..." + + create-dmg \ + --volname "GoNavi Dev Build" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "$APP_NAME" 200 190 \ + --hide-extension "$APP_NAME" \ + --app-drop-link 600 185 \ + "$DMG_NAME" \ + "$APP_NAME" + + mv "$DMG_NAME" "../../$FINAL_NAME" + + # Windows Packaging + - name: Package Windows EXE + if: contains(matrix.platform, 'windows') + shell: pwsh + run: | + Set-Location build/bin + $version = "${{ steps.version.outputs.version }}" + $target = "${{ matrix.build_name }}" + $finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe" + + if (Test-Path "$target.exe") { + $finalExe = "$target.exe" + } elseif (Test-Path "$target") { + Rename-Item -Path "$target" -NewName "$target.exe" + $finalExe = "$target.exe" + } else { + Write-Error "❌ 未找到构建产物 '$target'!" + exit 1 + } + + $isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64" + if ($isArm64Target) { + Write-Warning "⚠️ UPX 当前不支持 win64/arm64,跳过压缩并保留原始 EXE。" + $LASTEXITCODE = 0 + } else { + $upxCmd = Get-Command upx -ErrorAction SilentlyContinue + if ($null -eq $upxCmd) { + Write-Error "❌ 未找到 upx,无法保证 Windows 产物经过压缩" + exit 1 + } + $beforeBytes = (Get-Item -LiteralPath $finalExe).Length + Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..." + & upx --best --lzma --force $finalExe | Out-Host + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ UPX 压缩失败($LASTEXITCODE)" + exit 1 + } + & upx -t $finalExe | Out-Host + if ($LASTEXITCODE -ne 0) { + Write-Error "❌ UPX 校验失败($LASTEXITCODE)" + exit 1 + } + $afterBytes = (Get-Item -LiteralPath $finalExe).Length + if ($afterBytes -lt $beforeBytes) { + $savedBytes = $beforeBytes - $afterBytes + Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB,减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB)) + } else { + Write-Host ("ℹ️ UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB)) + } + } + + Write-Host "📦 输出 Windows 可执行文件 $finalExeName..." + Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force + + # Linux Packaging + - name: Package Linux + if: contains(matrix.platform, 'linux') + run: | + VERSION="${{ steps.version.outputs.version }}" + cd build/bin + TARGET="${{ matrix.build_name }}" + TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.tar.gz" + APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.AppImage" + + if [ ! -f "$TARGET" ]; then + echo "❌ 未找到构建产物 '$TARGET'!" + exit 1 + fi + + chmod +x "$TARGET" + BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]') + echo "🗜️ 正在使用 UPX 压缩 Linux 可执行文件: $TARGET ..." + upx --best --lzma --force "$TARGET" + upx -t "$TARGET" + AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]') + if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then + SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES)) + awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }' + else + awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf "ℹ️ Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }' + fi + + echo "📦 正在打包 $TAR_NAME..." + tar -czvf "$TAR_NAME" "$TARGET" + mv "$TAR_NAME" ../../ + + if [ -f /tmp/skip-appimage ]; then + echo "⚠️ 跳过 AppImage 打包" + exit 0 + fi + + echo "📦 正在生成 AppImage..." + mkdir -p AppDir/usr/bin + mkdir -p AppDir/usr/share/applications + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + + cp "$TARGET" AppDir/usr/bin/gonavi + + printf '%s\n' \ + '[Desktop Entry]' \ + 'Name=GoNavi' \ + 'Exec=gonavi' \ + 'Icon=gonavi' \ + 'Type=Application' \ + 'Categories=Development;Database;' \ + 'Comment=Database Management Tool' \ + > AppDir/usr/share/applications/gonavi.desktop + + cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop + + if [ -f "../../build/appicon.png" ]; then + cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png + cp "../../build/appicon.png" AppDir/gonavi.png + else + convert -size 256x256 xc:#336791 -fill white -gravity center -pointsize 48 -annotate 0 "GoNavi" AppDir/gonavi.png || \ + wget -q "https://via.placeholder.com/256/336791/FFFFFF?text=GoNavi" -O AppDir/gonavi.png || \ + touch AppDir/gonavi.png + cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png + fi + + export DEPLOY_GTK_VERSION=3 + /tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || { + echo "⚠️ AppImage 生成失败,但 tar.gz 已成功生成" + exit 0 + } + + mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || { + echo "⚠️ AppImage 重命名失败" + exit 0 + } + + if [ -f "$APPIMAGE_NAME" ]; then + mv "$APPIMAGE_NAME" ../../ + echo "✅ AppImage 生成成功" + fi + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: dev-build-artifacts-${{ strategy.job-index }} + path: | + GoNavi-*.dmg + GoNavi-*.exe + GoNavi-*.tar.gz + GoNavi-*.AppImage + drivers/** + retention-days: 7 + + # 汇总所有产物并发布为 Pre-release + release: + name: Publish Dev Pre-release + needs: build + runs-on: ubuntu-latest + steps: + - name: Download All Artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + pattern: dev-build-artifacts-* + merge-multiple: true + + - name: List Assets + run: ls -R release-assets + + - name: Package Driver Agents Bundle + shell: bash + run: | + set -euo pipefail + cd release-assets + if [ ! -d drivers ]; then + echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包" + exit 0 + fi + if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then + echo "⚠️ drivers 目录为空,跳过驱动总包打包" + rm -rf drivers + exit 0 + fi + + echo "📦 打包驱动总包:GoNavi-DriverAgents.zip" + python3 - <<'PY' + import json + import os + import zipfile + from pathlib import Path + + out_name = "GoNavi-DriverAgents.zip" + index_name = "GoNavi-DriverAgents-Index.json" + base = Path("drivers") + out_path = Path(out_name) + index_path = Path(index_name) + if out_path.exists(): + out_path.unlink() + if index_path.exists(): + index_path.unlink() + + size_index = {} + with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for p in base.rglob("*"): + if not p.is_file(): + continue + arcname = p.relative_to(base).as_posix() + zf.write(p, arcname) + size_index[p.name] = p.stat().st_size + + index_path.write_text( + json.dumps({"assets": size_index}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + print(f"created {out_name} size={out_path.stat().st_size} bytes") + print(f"created {index_name} entries={len(size_index)}") + PY + + rm -rf drivers + + - name: Generate SHA256SUMS + shell: bash + run: | + cd release-assets + FILES=() + while IFS= read -r file; do + if [ -n "$file" ]; then + FILES+=("$file") + fi + done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort) + if [ ${#FILES[@]} -eq 0 ]; then + echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS" + : > SHA256SUMS + else + sha256sum "${FILES[@]}" > SHA256SUMS + fi + + - name: Generate Dev Version + id: version + run: | + SHORT_SHA="${GITHUB_SHA:0:7}" + DEV_VERSION="dev-${SHORT_SHA}" + echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" + + # 删除旧的 dev pre-release(保持只有最新一个) + - name: Delete Previous Dev Release + uses: dev-drprasad/delete-tag-and-release@v1.1 + continue-on-error: true + with: + tag_name: dev-latest + delete_release: true + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Dev Pre-release + uses: softprops/action-gh-release@v2 + with: + tag_name: dev-latest + name: "🧪 Dev Build (${{ steps.version.outputs.version }})" + target_commitish: ${{ github.sha }} + files: release-assets/* + prerelease: true + draft: false + body: | + ## 🧪 测试版本 (Dev Build) + + **版本**: `${{ steps.version.outputs.version }}` + **分支**: `dev` + **提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}) + **构建时间**: ${{ github.event.head_commit.timestamp }} + + > ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。 + > 每次 push 到 `dev` 分支会自动覆盖此 release。 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-build-all-platforms.yml b/.github/workflows/test-build-all-platforms.yml deleted file mode 100644 index d1df7e5..0000000 --- a/.github/workflows/test-build-all-platforms.yml +++ /dev/null @@ -1,412 +0,0 @@ -name: Test Build All Platforms (Manual) - -on: - workflow_dispatch: - inputs: - build_label: - description: "测试包标识(仅用于文件名)" - required: false - default: "test" - -permissions: - contents: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - -concurrency: - group: test-build-${{ github.ref }} - cancel-in-progress: false - -jobs: - build: - name: Build ${{ matrix.platform }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - os: macos-latest - platform: darwin/amd64 - os_name: MacOS - arch_name: Amd64 - build_name: gonavi-test-darwin-amd64 - wails_tags: "" - artifact_suffix: "" - build_optional_agents: true - linux_webkit: "" - - os: macos-latest - platform: darwin/arm64 - os_name: MacOS - arch_name: Arm64 - build_name: gonavi-test-darwin-arm64 - wails_tags: "" - artifact_suffix: "" - build_optional_agents: true - linux_webkit: "" - - os: windows-latest - platform: windows/amd64 - os_name: Windows - arch_name: Amd64 - build_name: gonavi-test-windows-amd64 - wails_tags: "" - artifact_suffix: "" - build_optional_agents: true - linux_webkit: "" - - os: windows-latest - platform: windows/arm64 - os_name: Windows - arch_name: Arm64 - build_name: gonavi-test-windows-arm64 - wails_tags: "" - artifact_suffix: "" - build_optional_agents: true - linux_webkit: "" - - os: ubuntu-22.04 - platform: linux/amd64 - os_name: Linux - arch_name: Amd64 - build_name: gonavi-test-linux-amd64 - wails_tags: "" - artifact_suffix: "" - build_optional_agents: true - linux_webkit: "4.0" - - os: ubuntu-24.04 - platform: linux/amd64 - os_name: Linux - arch_name: Amd64 - build_name: gonavi-test-linux-amd64-webkit41 - wails_tags: "webkit2_41" - artifact_suffix: "-WebKit41" - build_optional_agents: false - linux_webkit: "4.1" - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: '1.24' - check-latest: true - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install UPX (Windows) - if: contains(matrix.platform, 'windows') - shell: pwsh - run: | - $UPX_VERSION = "4.2.4" - $url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip" - $zipPath = "$env:RUNNER_TEMP\upx.zip" - $extractPath = "$env:RUNNER_TEMP\upx" - Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..." - Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing - Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force - $upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1 - "$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - $upxCmd = Join-Path $upxDir.FullName "upx.exe" - if (!(Test-Path $upxCmd)) { - Write-Error "❌ 未检测到 upx,无法保证 Windows 测试产物经过压缩" - exit 1 - } - & $upxCmd --version - - - name: Install Linux Dependencies - if: contains(matrix.platform, 'linux') - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev - - if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then - sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev - else - sudo apt-get install -y libwebkit2gtk-4.0-dev - fi - - sudo apt-get install -y upx-ucl || sudo apt-get install -y upx - upx --version - - sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true - - LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" - PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage" - - wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || { - echo "skip-appimage=true" >> "$GITHUB_ENV" - } - wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || { - echo "skip-appimage=true" >> "$GITHUB_ENV" - } - - if [ "${skip-appimage:-false}" != "true" ]; then - chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk - fi - - - name: Install Wails - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - - - name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64) - id: msys2_duckdb - if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} - continue-on-error: true - uses: msys2/setup-msys2@v2 - with: - msystem: UCRT64 - update: true - install: >- - mingw-w64-ucrt-x86_64-gcc - - - name: Configure DuckDB CGO Toolchain (Windows AMD64) - if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }} - shell: pwsh - run: | - function Find-MingwBin([string[]]$candidates) { - foreach ($bin in $candidates) { - if ([string]::IsNullOrWhiteSpace($bin)) { - continue - } - $gcc = Join-Path $bin 'gcc.exe' - $gxx = Join-Path $bin 'g++.exe' - if ((Test-Path $gcc) -and (Test-Path $gxx)) { - return $bin - } - } - return $null - } - - $msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}" - $candidateBins = @() - if (-not [string]::IsNullOrWhiteSpace($msys2Location)) { - $candidateBins += Join-Path $msys2Location 'ucrt64\bin' - } - $candidateBins += @( - 'C:\msys64\ucrt64\bin', - 'D:\a\_temp\msys64\ucrt64\bin' - ) - $candidateBins = @($candidateBins | Select-Object -Unique) - - $mingwBin = Find-MingwBin $candidateBins - if (-not $mingwBin) { - Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。" - exit 1 - } - - $gcc = Join-Path $mingwBin 'gcc.exe' - $gxx = Join-Path $mingwBin 'g++.exe' - "$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - "CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Build App - shell: bash - run: | - set -euo pipefail - BUILD_LABEL="${{ inputs.build_label }}" - if [ -z "$BUILD_LABEL" ]; then - BUILD_LABEL="test" - fi - APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}" - if [ -n "${{ matrix.wails_tags }}" ]; then - wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" - else - wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" - fi - - - name: Build Optional Driver Agents - if: ${{ matrix.build_optional_agents }} - shell: bash - run: | - set -euo pipefail - TARGET_PLATFORM="${{ matrix.platform }}" - GOOS="${TARGET_PLATFORM%%/*}" - GOARCH="${TARGET_PLATFORM##*/}" - DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse) - OUTDIR="drivers/${{ matrix.os_name }}" - mkdir -p "$OUTDIR" - - for DRIVER in "${DRIVERS[@]}"; do - BUILD_DRIVER="$DRIVER" - if [ "$DRIVER" = "doris" ]; then - BUILD_DRIVER="diros" - fi - if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then - echo "跳过 DuckDB driver: ${GOOS}/${GOARCH}" - continue - fi - TAG="gonavi_${BUILD_DRIVER}_driver" - OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}" - if [ "$GOOS" = "windows" ]; then - OUTPUT="${OUTPUT}.exe" - fi - OUTPUT_PATH="${OUTDIR}/${OUTPUT}" - if [ "$DRIVER" = "duckdb" ]; then - CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent - else - CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent - fi - done - - - name: Package macOS - if: contains(matrix.platform, 'darwin') - shell: bash - run: | - set -euo pipefail - brew install create-dmg - LABEL="${{ inputs.build_label }}" - if [ -z "$LABEL" ]; then - LABEL="test" - fi - cd build/bin - APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1) - if [ -z "$APP_PATH" ]; then - echo "未找到 .app 应用包" - exit 1 - fi - APP_NAME=$(basename "$APP_PATH") - APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1) - if [ -z "$APP_BIN" ]; then - echo "未找到 macOS 应用主程序" - exit 1 - fi - echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。" - codesign --force --deep --sign - "$APP_NAME" - ZIP_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.zip" - DMG_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.dmg" - mkdir -p ../../artifacts - ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "../../artifacts/$ZIP_NAME" - create-dmg \ - --volname "GoNavi Test Installer" \ - --window-pos 200 120 \ - --window-size 800 400 \ - --icon-size 100 \ - --icon "$APP_NAME" 200 190 \ - --hide-extension "$APP_NAME" \ - --app-drop-link 600 185 \ - "$DMG_NAME" \ - "$APP_NAME" - mv "$DMG_NAME" "../../artifacts/$DMG_NAME" - shasum -a 256 "../../artifacts/$ZIP_NAME" > "../../artifacts/$ZIP_NAME.sha256" - shasum -a 256 "../../artifacts/$DMG_NAME" > "../../artifacts/$DMG_NAME.sha256" - - - name: Package Windows - if: contains(matrix.platform, 'windows') - shell: pwsh - run: | - $label = "${{ inputs.build_label }}" - if ([string]::IsNullOrWhiteSpace($label)) { $label = 'test' } - Set-Location build/bin - $target = "${{ matrix.build_name }}" - $finalExeName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.exe" - if (Test-Path "$target.exe") { - $finalExe = "$target.exe" - } elseif (Test-Path "$target") { - Rename-Item -Path "$target" -NewName "$target.exe" - $finalExe = "$target.exe" - } else { - Write-Error "未找到构建产物 '$target'" - exit 1 - } - $isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64" - if ($isArm64Target) { - Write-Warning "⚠️ UPX 当前不支持 win64/arm64,跳过压缩并保留原始 EXE。" - $LASTEXITCODE = 0 - } else { - $upxCmd = Get-Command upx -ErrorAction SilentlyContinue - if ($null -eq $upxCmd) { - Write-Error "❌ 未找到 upx,无法保证 Windows 测试产物经过压缩" - exit 1 - } - $beforeBytes = (Get-Item -LiteralPath $finalExe).Length - Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..." - & upx --best --lzma --force $finalExe | Out-Host - if ($LASTEXITCODE -ne 0) { - Write-Error "❌ UPX 压缩失败($LASTEXITCODE)" - exit 1 - } - & upx -t $finalExe | Out-Host - if ($LASTEXITCODE -ne 0) { - Write-Error "❌ UPX 校验失败($LASTEXITCODE)" - exit 1 - } - $afterBytes = (Get-Item -LiteralPath $finalExe).Length - if ($afterBytes -lt $beforeBytes) { - $savedBytes = $beforeBytes - $afterBytes - Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB,减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB)) - } else { - Write-Host ("ℹ️ UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB)) - } - } - New-Item -ItemType Directory -Force -Path ..\..\artifacts | Out-Null - Copy-Item -LiteralPath $finalExe -Destination "..\..\artifacts\$finalExeName" -Force - Get-FileHash "..\..\artifacts\$finalExeName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalExeName.sha256" -Encoding ascii - - - name: Package Linux - if: contains(matrix.platform, 'linux') - shell: bash - run: | - set -euo pipefail - LABEL="${{ inputs.build_label }}" - if [ -z "$LABEL" ]; then - LABEL="test" - fi - cd build/bin - TARGET="${{ matrix.build_name }}" - TAR_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.tar.gz" - APPIMAGE_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.AppImage" - mkdir -p ../../artifacts - - if [ ! -f "$TARGET" ]; then - echo "未找到构建产物 '$TARGET'" - exit 1 - fi - chmod +x "$TARGET" - BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]') - echo "🗜️ 使用 UPX 压缩 Linux 可执行文件: $TARGET ..." - upx --best --lzma --force "$TARGET" - upx -t "$TARGET" - AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]') - if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then - SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES)) - awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }' - else - awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf "ℹ️ Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }' - fi - tar -czvf "../../artifacts/$TAR_NAME" "$TARGET" - sha256sum "../../artifacts/$TAR_NAME" > "../../artifacts/$TAR_NAME.sha256" - - if [ "${skip-appimage:-false}" = "true" ]; then - echo "跳过 AppImage 打包" - exit 0 - fi - - mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps - cp "$TARGET" AppDir/usr/bin/gonavi - printf '%s\n' '[Desktop Entry]' 'Name=GoNavi' 'Exec=gonavi' 'Icon=gonavi' 'Type=Application' 'Categories=Development;Database;' 'Comment=Database Management Tool' > AppDir/usr/share/applications/gonavi.desktop - cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop - if [ -f "../../build/appicon.png" ]; then - cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png - cp "../../build/appicon.png" AppDir/gonavi.png - else - touch AppDir/gonavi.png - cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png - fi - export DEPLOY_GTK_VERSION=3 - /tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || exit 0 - mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || exit 0 - mv "$APPIMAGE_NAME" "../../artifacts/$APPIMAGE_NAME" - sha256sum "../../artifacts/$APPIMAGE_NAME" > "../../artifacts/$APPIMAGE_NAME.sha256" - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: test-build-${{ matrix.build_name }}-run${{ github.run_number }} - path: | - artifacts/* - drivers/** - if-no-files-found: error - retention-days: 7 diff --git a/.github/workflows/test-macos-build.yml b/.github/workflows/test-macos-build.yml deleted file mode 100644 index d022e91..0000000 --- a/.github/workflows/test-macos-build.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Test Build macOS (Manual) - -on: - workflow_dispatch: - inputs: - build_label: - description: "测试包标识(仅用于文件名)" - required: false - default: "test" - push: - branches: - - feature/kingbase_opt - paths: - - ".github/workflows/test-macos-build.yml" - -permissions: - contents: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - -jobs: - build-macos: - name: Build macOS ${{ matrix.arch }} - runs-on: macos-latest - strategy: - fail-fast: false - matrix: - include: - - platform: darwin/amd64 - arch: amd64 - - platform: darwin/arm64 - arch: arm64 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: "1.24.3" - check-latest: true - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install Wails - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - - - name: Build App - run: | - set -euo pipefail - OUTPUT_NAME="gonavi-test-${{ matrix.arch }}" - BUILD_LABEL="${{ inputs.build_label }}" - if [ -z "$BUILD_LABEL" ]; then - BUILD_LABEL="test" - fi - APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}" - wails build \ - -platform "${{ matrix.platform }}" \ - -clean \ - -o "$OUTPUT_NAME" \ - -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}" - - - name: Package Zip - run: | - set -euo pipefail - APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app" - if [ ! -d "$APP_PATH" ]; then - APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true) - fi - if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then - echo "未找到 .app 产物" - ls -la build/bin || true - exit 1 - fi - LABEL="${{ inputs.build_label }}" - if [ -z "$LABEL" ]; then - LABEL="test" - fi - ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip" - mkdir -p artifacts - ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME" - shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256" - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }} - path: artifacts/* - if-no-files-found: error diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index efbd2b6..3018db7 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -6ba85e4f456d2c0d230cab198c7dc02b \ No newline at end of file +dcb87159cf0f1f6f750d1c4870911d3f \ No newline at end of file diff --git a/internal/ai/provider/claude_cli.go b/internal/ai/provider/claude_cli.go index 4cd9b00..4f5b64a 100644 --- a/internal/ai/provider/claude_cli.go +++ b/internal/ai/provider/claude_cli.go @@ -6,12 +6,16 @@ import ( "context" "encoding/json" "fmt" + "os" "os/exec" + "runtime" "strings" ai "GoNavi-Wails/internal/ai" ) +var claudeLookPath = exec.LookPath + // ClaudeCLIProvider 通过 Claude Code CLI 发送聊天请求 // 适用于 anyrouter/newapi 等只支持 Claude Code 协议的代理服务 type ClaudeCLIProvider struct { @@ -28,10 +32,13 @@ func (p *ClaudeCLIProvider) Name() string { } func (p *ClaudeCLIProvider) Validate() error { - _, err := exec.LookPath("claude") + _, err := claudeLookPath("claude") if err != nil { return fmt.Errorf("未找到 claude 命令,请先安装 Claude Code CLI: npm install -g @anthropic-ai/claude-code") } + if _, err := resolveClaudeCodeGitBashPath(os.Environ(), runtime.GOOS, claudeLookPath, fileExists); err != nil { + return err + } return nil } @@ -48,7 +55,9 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C } cmd := exec.CommandContext(ctx, "claude", args...) - p.setEnv(cmd) + if err := p.setEnv(cmd); err != nil { + return nil, err + } output, err := cmd.Output() if err != nil { @@ -85,7 +94,9 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args) cmd := exec.CommandContext(ctx, "claude", args...) - p.setEnv(cmd) + if err := p.setEnv(cmd); err != nil { + return err + } // 关闭 stdin,防止 claude CLI 等待输入 cmd.Stdin = nil @@ -174,16 +185,146 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, } // setEnv 设置 Claude CLI 的环境变量 -func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) { - env := cmd.Environ() - if p.config.BaseURL != "" { - baseURL := strings.TrimRight(p.config.BaseURL, "/") - env = append(env, "ANTHROPIC_BASE_URL="+baseURL) - } - if p.config.APIKey != "" { - env = append(env, "ANTHROPIC_API_KEY="+p.config.APIKey) +func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) error { + env, err := buildClaudeCLIEnv(p.config, cmd.Environ(), runtime.GOOS, claudeLookPath, fileExists) + if err != nil { + return err } cmd.Env = env + return nil +} + +func buildClaudeCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string, lookPath func(string) (string, error), exists func(string) bool) ([]string, error) { + env := append([]string(nil), baseEnv...) + if config.BaseURL != "" { + env = upsertEnv(env, "ANTHROPIC_BASE_URL", strings.TrimRight(config.BaseURL, "/")) + } + if config.APIKey != "" { + env = upsertEnv(env, "ANTHROPIC_API_KEY", config.APIKey) + } + + gitBashPath, err := resolveClaudeCodeGitBashPath(env, goos, lookPath, exists) + if err != nil { + return nil, err + } + if gitBashPath != "" { + env = upsertEnv(env, "CLAUDE_CODE_GIT_BASH_PATH", gitBashPath) + } + return env, nil +} + +func resolveClaudeCodeGitBashPath(env []string, goos string, lookPath func(string) (string, error), exists func(string) bool) (string, error) { + if goos != "windows" { + return "", nil + } + + if configured := strings.TrimSpace(envValue(env, "CLAUDE_CODE_GIT_BASH_PATH")); configured != "" { + if exists(configured) { + return configured, nil + } + return "", fmt.Errorf("Claude Code CLI 在 Windows 下需要 git-bash,但 CLAUDE_CODE_GIT_BASH_PATH 指向的 bash.exe 不存在: %s", configured) + } + + for _, command := range []string{"bash.exe", "bash"} { + if bashPath, err := lookPath(command); err == nil && exists(bashPath) { + return bashPath, nil + } + } + + if gitPath, err := lookPath("git.exe"); err == nil { + gitDir := parentWindowsPath(gitPath) + for _, candidate := range []string{ + joinWindowsPath(parentWindowsPath(gitDir), "bin", "bash.exe"), + joinWindowsPath(gitDir, "bash.exe"), + } { + if candidate != "" && exists(candidate) { + return candidate, nil + } + } + } + + for _, candidate := range windowsGitBashCandidates(env) { + if exists(candidate) { + return candidate, nil + } + } + + return "", fmt.Errorf("Claude Code CLI 在 Windows 下需要 git-bash。请安装 Git for Windows(https://git-scm.com/downloads/win);如果已安装但未加入 PATH,请设置环境变量 CLAUDE_CODE_GIT_BASH_PATH 指向 bash.exe,例如 C:\\Program Files\\Git\\bin\\bash.exe") +} + +func windowsGitBashCandidates(env []string) []string { + candidates := make([]string, 0, 3) + for _, base := range []string{ + envValue(env, "ProgramFiles"), + envValue(env, "ProgramFiles(x86)"), + envValue(env, "LocalAppData"), + } { + base = strings.TrimSpace(base) + if base == "" { + continue + } + if strings.EqualFold(base, envValue(env, "LocalAppData")) { + candidates = append(candidates, joinWindowsPath(base, "Programs", "Git", "bin", "bash.exe")) + continue + } + candidates = append(candidates, joinWindowsPath(base, "Git", "bin", "bash.exe")) + } + return candidates +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + return "" +} + +func upsertEnv(env []string, key, value string) []string { + prefix := key + "=" + for i, entry := range env { + if strings.HasPrefix(entry, prefix) { + env[i] = prefix + value + return env + } + } + return append(env, prefix+value) +} + +func fileExists(path string) bool { + info, err := os.Stat(strings.TrimSpace(path)) + return err == nil && !info.IsDir() +} + +func joinWindowsPath(base string, parts ...string) string { + result := strings.TrimSpace(strings.ReplaceAll(base, "/", `\`)) + if result != "" { + result = strings.TrimRight(result, `\`) + } + + for _, part := range parts { + part = strings.Trim(strings.ReplaceAll(strings.TrimSpace(part), "/", `\`), `\`) + if part == "" { + continue + } + if result == "" { + result = part + continue + } + result += `\` + part + } + return result +} + +func parentWindowsPath(path string) string { + path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`) + idx := strings.LastIndex(path, `\`) + if idx <= 0 { + return "" + } + return path[:idx] } // buildPrompt 将消息列表拼接为适合 claude -p 的提示文本 diff --git a/internal/ai/provider/claude_cli_test.go b/internal/ai/provider/claude_cli_test.go new file mode 100644 index 0000000..4bd3ccb --- /dev/null +++ b/internal/ai/provider/claude_cli_test.go @@ -0,0 +1,69 @@ +package provider + +import ( + "errors" + "strings" + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestBuildClaudeCLIEnv_IncludesAnthropicProxyEnv(t *testing.T) { + env, err := buildClaudeCLIEnv(ai.ProviderConfig{ + BaseURL: "https://proxy.example.com/", + APIKey: "sk-test", + }, []string{"PATH=/usr/bin"}, "darwin", func(name string) (string, error) { + return "", errors.New("unexpected lookup") + }, func(path string) bool { + return false + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got := envValue(env, "ANTHROPIC_BASE_URL"); got != "https://proxy.example.com" { + t.Fatalf("expected trimmed base url, got %q", got) + } + if got := envValue(env, "ANTHROPIC_API_KEY"); got != "sk-test" { + t.Fatalf("expected api key in env, got %q", got) + } +} + +func TestBuildClaudeCLIEnv_UsesDetectedGitBashOnWindows(t *testing.T) { + env, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) { + switch name { + case "bash.exe": + return "", errors.New("not found") + case "bash": + return "", errors.New("not found") + case "git.exe": + return "C:\\Program Files\\Git\\cmd\\git.exe", nil + default: + return "", errors.New("unexpected lookup") + } + }, func(path string) bool { + return path == `C:\Program Files\Git\bin\bash.exe` + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got := envValue(env, "CLAUDE_CODE_GIT_BASH_PATH"); got != `C:\Program Files\Git\bin\bash.exe` { + t.Fatalf("expected detected git bash path, got %q", got) + } +} + +func TestBuildClaudeCLIEnv_ReturnsActionableErrorWhenGitBashMissingOnWindows(t *testing.T) { + _, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) { + return "", errors.New("not found") + }, func(path string) bool { + return false + }) + if err == nil { + t.Fatal("expected error when git bash is missing on windows") + } + if !strings.Contains(err.Error(), "git-bash") { + t.Fatalf("expected git-bash hint, got %v", err) + } + if !strings.Contains(err.Error(), "CLAUDE_CODE_GIT_BASH_PATH") { + t.Fatalf("expected env var hint, got %v", err) + } +}