1
0

16 Commits

Author SHA1 Message Date
598e2acb7e feat: 供应商列表 Base URL、API Key 和模型列表统一模型 ID 增加一键复制按钮 2026-05-06 00:43:48 +08:00
4870d29638 fix: pre-commit Go lint 按包目录分组执行,修复测试文件 typecheck 失败
将逐文件 lint 改为按包目录去重分组,同包的 _test.go 与被测文件在同一轮
typecheck 中参与分析,避免 undefined 错误。
2026-05-05 23:52:43 +08:00
8600a39b6c fix: 发布产物自包含数据库迁移资源,修复 macOS DMG 安装后无法启动
使用 go:embed 嵌入迁移 SQL 到二进制,移除 runtime.Caller 源码路径依赖,
server 和 desktop 发布产物均可在无源码目录环境下完成数据库初始化和迁移。
2026-05-05 23:47:58 +08:00
407d008e19 chore: 版本升迁 v0.1.7 2026-05-05 22:00:04 +08:00
a2751eab31 feat: 原生 Git hooks 方案,增强版本升迁工作流 2026-05-05 21:58:30 +08:00
5655fc5560 chore: 移除 Windows arm64 构建与发布支持
Windows ARM64 使用场景极少,windows-11-arm runner 上 MSYS2
CLANGARM64 交叉编译不稳定,CGO 编译问题难以排查,维护成本
远超收益。移除 arm64 的 CI 矩阵条目、Makefile Windows 变量、
versionctl 资产白名单、README 文档和规范中的相关需求。
Linux 和 macOS arm64 不受影响。
2026-05-05 20:20:04 +08:00
49b47a1ae0 chore: 版本升迁 v0.1.6 2026-05-05 19:31:21 +08:00
bcf82d42bc fix: 修复 Windows arm64 可执行文件图标嵌入构建失败
MSYS2 CLANGARM64 环境下使用 llvm-windres,需使用 LLVM target triple
(aarch64-w64-mingw32) 而非 GNU BFD 格式 (pe-aarch64)。新增双格式变量
并增强 windres 检测逻辑,通过 --version 输出区分 GNU/LLVM 工具。
2026-05-05 19:30:22 +08:00
394025c8ea chore: 版本升迁 v0.1.5 2026-05-05 18:59:09 +08:00
34bd749741 fix: 修复发布流水线 Windows arm64 CGO 工具链和 macOS 磁盘空间问题
- Windows arm64: 在 workflow matrix 中设置 CC=clang/CXX=clang++ 并注入构建环境
- macOS: 在 Makefile 关键节点清理中间产物和临时目录释放磁盘空间
- 预检步骤改用 $CC/$CXX 替代硬编码编译器名称,与 matrix 声明保持一致
- 同步新增 release-pipeline spec 需求: Windows arm64 CGO 编译器指定
2026-05-05 18:58:50 +08:00
290f299e22 chore: 版本升迁 v0.1.4 2026-05-05 13:15:45 +08:00
859dec8ada fix: 修复 Windows arm64 发布构建 CI 失败
将 Windows arm64 构建从 x86_64 runner 上的交叉编译改为使用 windows-11-arm
原生 ARM64 runner,消除 CLANGARM64 环境在 x86_64 上的 Exec format error。
同时去掉 Linux/Windows 构建步骤中冗余的 TARGET_ARCH 显式传参,统一依赖
Makefile 中 go env GOARCH 自动检测。
2026-05-05 13:15:00 +08:00
993c0a72d6 chore: 版本升迁 v0.1.3 2026-05-05 12:38:12 +08:00
c9c3a84b33 feat: 扩展发布打包支持多组件多架构多格式产物
- 新增 web 组件独立发布为 nex-web_<version>.tar.gz
- server 新增 arm64 架构、macOS universal、Windows arm64 产物
- desktop 新增 arm64 架构支持(Linux/Windows)
- Linux desktop 新增 AppImage、deb、rpm 安装包格式
- macOS desktop 新增 unsigned DMG 安装包
- 统一发布资产命名为 {component}_{version}_{platform}_{arch}.{ext}
- 新增 SHA256SUMS 校验和清单覆盖全部发布资产
- versionctl 新增 asset-name CLI 支持按参数生成资产文件名
- Makefile release target 重构为组件/平台/架构参数化
- GitHub Actions release workflow 扩展多组件多架构构建矩阵
- 同步更新 openspec 主规范(desktop-app/release-pipeline/workspace-command-flows)
2026-05-05 12:36:33 +08:00
6de7a2d2e1 chore: 版本升迁 v0.1.2 2026-05-05 09:58:08 +08:00
6181923d8d fix: 修复发布流水线 LFS 资产校验 2026-05-05 09:57:02 +08:00
41 changed files with 2049 additions and 275 deletions

View File

@@ -19,6 +19,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@@ -35,8 +37,8 @@ jobs:
go run ./versionctl verify-tag "${GITHUB_REF_NAME}" go run ./versionctl verify-tag "${GITHUB_REF_NAME}"
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
build-linux: build-web:
name: Build Linux Assets name: Build Web Asset
needs: prepare needs: prepare
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -44,6 +46,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@@ -56,14 +60,65 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
- name: Install Linux desktop build dependencies - name: Preflight web release toolchain
run: |
set -euo pipefail
command -v go
go version
command -v bun
bun --version
make release-assets-check
- name: Build web release asset
run: make release-assets-web
- name: Upload web release asset
uses: actions/upload-artifact@v4
with:
name: release-web
path: build/release/*
if-no-files-found: error
build-linux:
name: Build Linux ${{ matrix.arch }} Assets
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Linux desktop and package dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libayatana-appindicator3-dev libgtk-3-dev sudo apt-get install -y curl file libayatana-appindicator3-dev libgtk-3-dev rpm
- name: Preflight Linux release toolchain - name: Preflight Linux release toolchain
run: | run: |
set -euo pipefail set -euo pipefail
printf 'runner arch: %s\n' "$(uname -m)"
command -v go command -v go
go version go version
command -v bun command -v bun
@@ -73,6 +128,12 @@ jobs:
command -v pkg-config command -v pkg-config
pkg-config --modversion ayatana-appindicator3-0.1 pkg-config --modversion ayatana-appindicator3-0.1
pkg-config --modversion gtk+-3.0 pkg-config --modversion gtk+-3.0
command -v curl
command -v dpkg-deb
dpkg-deb --version
command -v rpmbuild
rpmbuild --version
make release-assets-check
- name: Build Linux release assets - name: Build Linux release assets
run: make release-assets-linux run: make release-assets-linux
@@ -80,18 +141,33 @@ jobs:
- name: Upload Linux release assets - name: Upload Linux release assets
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release-linux name: release-linux-${{ matrix.arch }}
path: build/release/* path: build/release/*
if-no-files-found: error
build-windows: build-windows:
name: Build Windows Assets name: Build Windows ${{ matrix.arch }} Assets
needs: prepare needs: prepare
runs-on: windows-latest runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: windows-latest
msystem: MINGW64
cc: gcc
cxx: g++
packages: >-
make
mingw-w64-x86_64-gcc
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@@ -107,15 +183,16 @@ jobs:
- name: Setup MSYS2 toolchain - name: Setup MSYS2 toolchain
uses: msys2/setup-msys2@v2 uses: msys2/setup-msys2@v2
with: with:
msystem: MINGW64 msystem: ${{ matrix.msystem }}
path-type: inherit path-type: inherit
update: true update: true
install: >- install: ${{ matrix.packages }}
make
mingw-w64-x86_64-gcc
- name: Preflight Windows release toolchain - name: Preflight Windows release toolchain
shell: msys2 {0} shell: msys2 {0}
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
run: | run: |
set -euo pipefail set -euo pipefail
command -v go command -v go
@@ -124,8 +201,10 @@ jobs:
bun --version bun --version
command -v make command -v make
make --version make --version
command -v gcc command -v "$CC"
gcc --version "$CC" --version
command -v "$CXX"
"$CXX" --version
command -v windres command -v windres
windres --version windres --version
if command -v powershell.exe >/dev/null 2>&1; then if command -v powershell.exe >/dev/null 2>&1; then
@@ -134,26 +213,33 @@ jobs:
command -v powershell command -v powershell
powershell -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' powershell -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
fi fi
make release-assets-check
- name: Build Windows release assets - name: Build Windows release assets
shell: msys2 {0} shell: msys2 {0}
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
run: make release-assets-windows run: make release-assets-windows
- name: Upload Windows release assets - name: Upload Windows release assets
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release-windows name: release-windows-${{ matrix.arch }}
path: build/release/* path: build/release/*
if-no-files-found: error
build-macos: build-macos:
name: Build macOS Assets name: Build macOS Assets
needs: prepare needs: prepare
runs-on: macos-latest runs-on: macos-15
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
with:
lfs: true
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@@ -169,13 +255,16 @@ jobs:
- name: Preflight macOS release toolchain - name: Preflight macOS release toolchain
run: | run: |
set -euo pipefail set -euo pipefail
printf 'runner arch: %s\n' "$(uname -m)"
command -v go command -v go
go version go version
command -v bun command -v bun
bun --version bun --version
command -v ditto command -v ditto
command -v hdiutil
xcrun --find lipo xcrun --find lipo
xcrun --find vtool xcrun --find vtool
make release-assets-check
- name: Build macOS release assets - name: Build macOS release assets
run: make release-assets-macos run: make release-assets-macos
@@ -185,14 +274,18 @@ jobs:
with: with:
name: release-macos name: release-macos
path: build/release/* path: build/release/*
if-no-files-found: error
draft-release: draft-release:
name: Create Draft Release name: Create Draft Release
needs: [prepare, build-linux, build-windows, build-macos] needs: [prepare, build-web, build-linux, build-windows, build-macos]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
steps: steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download release assets - name: Download release assets
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@@ -200,6 +293,9 @@ jobs:
merge-multiple: true merge-multiple: true
path: dist path: dist
- name: Generate checksums
run: make release-assets-checksums RELEASE_DIR=dist
- name: Publish draft release - name: Publish draft release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:

406
Makefile
View File

@@ -1,15 +1,21 @@
.PHONY: \ .PHONY: \
lint test clean \ lint test clean hooks-install hooks-check hooks-test \
version-sync version-check version-bump \ version-sync version-check version-bump \
server-run server-build server-lint server-test server-clean \ server-run server-build server-lint server-test server-clean \
desktop-build-mac desktop-build-win desktop-build-linux \ desktop-build-mac desktop-build-win desktop-build-linux \
desktop-lint desktop-test desktop-clean \ desktop-lint desktop-test desktop-clean \
release-assets-linux release-assets-windows release-assets-macos \ release-assets-check release-assets-web release-assets-linux release-assets-windows release-assets-macos release-assets-checksums \
release-assets-server-linux release-assets-server-windows release-assets-server-macos \
release-assets-desktop-linux release-assets-desktop-windows release-assets-desktop-macos \
_backend-lint _backend-test _backend-clean _backend-build \ _backend-lint _backend-test _backend-clean _backend-build \
_versionctl-lint _versionctl-test \ _versionctl-lint _versionctl-test \
_frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \ _frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \
_hooks-pre-commit _check-clean-worktree \
_desktop-test _desktop-clean _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource \ _desktop-test _desktop-clean _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource \
_server-run-backend _server-run-frontend _server-run-backend _server-run-frontend \
_check-linux-target-arch _check-windows-target-arch _ensure-appimagetool \
_package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm \
_package-macos-zip _package-macos-dmg
# Delay shell lookups until a target needs them, then cache the result for this make run. # Delay shell lookups until a target needs them, then cache the result for this make run.
lazy_shell = $(or $($(1)),$(eval $(1) := $(shell $(2)))$($(1))) lazy_shell = $(or $($(1)),$(eval $(1) := $(shell $(2)))$($(1)))
@@ -17,16 +23,32 @@ lazy_shell = $(or $($(1)),$(eval $(1) := $(shell $(2)))$($(1)))
VERSION = $(call lazy_shell,_VERSION,go run ./versionctl print) VERSION = $(call lazy_shell,_VERSION,go run ./versionctl print)
GIT_COMMIT ?= $(call lazy_shell,_GIT_COMMIT,git rev-parse --short HEAD 2>/dev/null || printf 'unknown') GIT_COMMIT ?= $(call lazy_shell,_GIT_COMMIT,git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
BUILD_TIME ?= $(call lazy_shell,_BUILD_TIME,date -u +"%Y-%m-%dT%H:%M:%SZ") BUILD_TIME ?= $(call lazy_shell,_BUILD_TIME,date -u +"%Y-%m-%dT%H:%M:%SZ")
TARGET_ARCH ?= $(call lazy_shell,_TARGET_ARCH,go env GOARCH)
GO_LDFLAGS = -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME) GO_LDFLAGS = -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
GO_LDFLAGS_WIN = $(GO_LDFLAGS) -H=windowsgui GO_LDFLAGS_WIN = $(GO_LDFLAGS) -H=windowsgui
RELEASE_DIR := build/release RELEASE_DIR ?= build/release
SERVER_LINUX_ASSET = $(call lazy_shell,_SERVER_LINUX_ASSET,go run ./versionctl asset-name server linux amd64) LINUX_DESKTOP_BINARY = build/nex-linux-$(TARGET_ARCH)
SERVER_WINDOWS_ASSET = $(call lazy_shell,_SERVER_WINDOWS_ASSET,go run ./versionctl asset-name server windows amd64) WINDOWS_DESKTOP_BINARY = build/nex-win-$(TARGET_ARCH).exe
SERVER_DARWIN_AMD64_ASSET = $(call lazy_shell,_SERVER_DARWIN_AMD64_ASSET,go run ./versionctl asset-name server darwin amd64) WINDOWS_SERVER_BINARY = build/nex-server-windows-$(TARGET_ARCH).exe
SERVER_DARWIN_ARM64_ASSET = $(call lazy_shell,_SERVER_DARWIN_ARM64_ASSET,go run ./versionctl asset-name server darwin arm64) WINDRES ?= windres
DESKTOP_LINUX_ASSET = $(call lazy_shell,_DESKTOP_LINUX_ASSET,go run ./versionctl asset-name desktop linux)
DESKTOP_WINDOWS_ASSET = $(call lazy_shell,_DESKTOP_WINDOWS_ASSET,go run ./versionctl asset-name desktop windows) ifeq ($(TARGET_ARCH),arm64)
DESKTOP_MACOS_ASSET = $(call lazy_shell,_DESKTOP_MACOS_ASSET,go run ./versionctl asset-name desktop macos) APPIMAGE_ARCH := aarch64
DEB_ARCH := arm64
RPM_ARCH := aarch64
else
APPIMAGE_ARCH := x86_64
DEB_ARCH := amd64
RPM_ARCH := x86_64
endif
WINDOWS_WINDRES_FORMAT_BFD := pe-x86-64
WINDOWS_WINDRES_FORMAT_LLVM := x86_64-w64-mingw32
WINDOWS_RESOURCE := rsrc_windows_amd64.syso
APPIMAGETOOL_PATH := build/tools/appimagetool-$(APPIMAGE_ARCH).AppImage
APPIMAGETOOL_URL ?= https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(APPIMAGE_ARCH).AppImage
APPIMAGETOOL ?= $(APPIMAGETOOL_PATH)
# ============================================ # ============================================
# 全局命令 # 全局命令
@@ -41,6 +63,90 @@ test: _backend-test _frontend-test _desktop-test _versionctl-test
clean: _backend-clean _frontend-clean _desktop-clean clean: _backend-clean _frontend-clean _desktop-clean
@printf 'Clean complete\n' @printf 'Clean complete\n'
# ============================================
# Git hooks
# ============================================
hooks-install:
@hooks_dir=$$(git rev-parse --git-path hooks); \
mkdir -p "$$hooks_dir"; \
cp scripts/git-hooks/pre-commit "$$hooks_dir/pre-commit"; \
cp scripts/git-hooks/commit-msg "$$hooks_dir/commit-msg"; \
chmod +x "$$hooks_dir/pre-commit" "$$hooks_dir/commit-msg"; \
printf 'Installed Git hooks to %s\n' "$$hooks_dir"
hooks-check:
@hooks_dir=$$(git rev-parse --git-path hooks); \
status=0; \
for hook in pre-commit commit-msg; do \
if [ -x "$$hooks_dir/$$hook" ]; then \
printf 'OK: %s\n' "$$hook"; \
else \
printf 'MISSING: %s (%s/%s)\n' "$$hook" "$$hooks_dir" "$$hook"; \
status=1; \
fi; \
done; \
exit $$status
hooks-test:
@scripts/git-hooks/test-hooks.sh
_hooks-pre-commit:
@set -e; \
staged_files=$$(git diff --cached --name-only --diff-filter=ACM); \
if [ -z "$$staged_files" ]; then \
printf 'No staged files to check\n'; \
exit 0; \
fi; \
backend_pkgs=''; \
versionctl_pkgs=''; \
for file in $$staged_files; do \
[ -n "$$file" ] || continue; \
case "$$file" in scripts/git-hooks/*) continue ;; esac; \
if git show ":$$file" 2>/dev/null | grep -Eq '^(<<<<<<<|=======|>>>>>>>)'; then \
printf 'Found conflict markers in staged file: %s\n' "$$file" >&2; \
printf 'Resolve conflict markers before committing.\n' >&2; \
exit 1; \
fi; \
size=$$(git cat-file -s ":$$file" 2>/dev/null || printf '0'); \
if [ "$$size" -gt 512000 ] 2>/dev/null; then \
if git show ":$$file" 2>/dev/null | LC_ALL=C grep -Iq .; then \
printf 'Warning: large staged text file (%s bytes): %s\n' "$$size" "$$file" >&2; \
fi; \
fi; \
case "$$file" in \
backend/*.go) \
dir=$$(dirname "$${file#backend/}"); \
case " $$backend_pkgs " in *" $$dir "*) ;; *) backend_pkgs="$$backend_pkgs $$dir" ;; esac; \
;; \
versionctl/*.go) \
dir=$$(dirname "$${file#versionctl/}"); \
case " $$versionctl_pkgs " in *" $$dir "*) ;; *) versionctl_pkgs="$$versionctl_pkgs $$dir" ;; esac; \
;; \
frontend/*.ts|frontend/*.tsx) \
rel=$${file#frontend/}; \
printf 'Frontend lint: frontend/%s\n' "$$rel"; \
(cd frontend && bunx eslint "$$rel"); \
printf 'Frontend format: frontend/%s\n' "$$rel"; \
(cd frontend && bunx prettier --check "$$rel"); \
;; \
frontend/*.scss) \
rel=$${file#frontend/}; \
printf 'Frontend format: frontend/%s\n' "$$rel"; \
(cd frontend && bunx prettier --check "$$rel"); \
;; \
esac; \
done; \
for dir in $$backend_pkgs; do \
printf 'Go lint: backend/%s\n' "$$dir"; \
(cd backend && go tool golangci-lint run "$$dir/"); \
done; \
for dir in $$versionctl_pkgs; do \
printf 'Go lint: versionctl/%s\n' "$$dir"; \
(cd versionctl && go tool golangci-lint run "$$dir/"); \
done; \
printf 'Pre-commit checks passed\n'
# ============================================ # ============================================
# 版本管理 # 版本管理
# ============================================ # ============================================
@@ -52,13 +158,21 @@ version-check:
go run ./versionctl check go run ./versionctl check
version-bump: BUMP ?= patch version-bump: BUMP ?= patch
version-bump: version-bump: lint test _check-clean-worktree
$(eval _BUMP_ARG := $(if $(SET_VERSION),$(SET_VERSION),$(BUMP))) @set -e; \
$(eval _NEW_VERSION := $(shell go run ./versionctl bump $(_BUMP_ARG))) bump_arg="$(if $(SET_VERSION),$(SET_VERSION),$(BUMP))"; \
git add VERSION frontend/ new_version=$$(go run ./versionctl bump "$$bump_arg"); \
git commit -m "chore: 版本升迁 v$(_NEW_VERSION)" git add VERSION frontend/; \
git tag "v$(_NEW_VERSION)" git commit -m "chore: 版本升迁 v$$new_version"; \
@printf '版本升迁完成: v%s\n' "$(_NEW_VERSION)" git tag "v$$new_version"; \
printf '版本升迁完成: v%s\n' "$$new_version"
_check-clean-worktree:
@if [ -n "$$(git status --porcelain)" ]; then \
printf '工作区不干净,请先提交或清理改动后再执行版本升迁。\n' >&2; \
git status --short; \
exit 1; \
fi
# ============================================ # ============================================
# Server 模式 # Server 模式
@@ -94,13 +208,17 @@ desktop-build-mac: version-check _desktop-prepare-frontend _desktop-prepare-embe
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-arm64 ./cmd/desktop cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-arm64 ./cmd/desktop
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-amd64 ./cmd/desktop cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-amd64 ./cmd/desktop
lipo -create build/nex-mac-arm64 build/nex-mac-amd64 -output build/nex-mac-universal lipo -create build/nex-mac-arm64 build/nex-mac-amd64 -output build/nex-mac-universal
lipo -info build/nex-mac-universal | grep -q 'x86_64 arm64'
rm -f build/nex-mac-arm64 build/nex-mac-amd64
@printf 'Packaging macOS app bundle...\n' @printf 'Packaging macOS app bundle...\n'
rm -rf build/Nex.app
mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources
cp build/nex-mac-universal build/Nex.app/Contents/MacOS/nex cp build/nex-mac-universal build/Nex.app/Contents/MacOS/nex
@if [ -f assets/icon.icns ]; then \ @if [ -f assets/icon.icns ]; then \
cp assets/icon.icns build/Nex.app/Contents/Resources/; \ cp assets/icon.icns build/Nex.app/Contents/Resources/; \
else \ else \
printf 'Missing assets/icon.icns\n'; \ printf 'Missing assets/icon.icns\n'; \
exit 1; \
fi fi
@MIN_MACOS_VERSION=$$(vtool -show-build build/nex-mac-universal | awk '/minos / {print $$2; exit}'); \ @MIN_MACOS_VERSION=$$(vtool -show-build build/nex-mac-universal | awk '/minos / {print $$2; exit}'); \
if [ -z "$$MIN_MACOS_VERSION" ]; then \ if [ -z "$$MIN_MACOS_VERSION" ]; then \
@@ -111,20 +229,16 @@ desktop-build-mac: version-check _desktop-prepare-frontend _desktop-prepare-embe
chmod +x build/Nex.app/Contents/MacOS/nex chmod +x build/Nex.app/Contents/MacOS/nex
@printf 'macOS desktop build complete\n' @printf 'macOS desktop build complete\n'
desktop-build-win: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource desktop-build-win: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource _check-windows-target-arch
@printf 'Building Windows desktop...\n' @printf 'Building Windows desktop $(TARGET_ARCH)...\n'
ifeq ($(OS),Windows_NT)
powershell -NoProfile -Command "New-Item -ItemType Directory -Path 'build' -Force | Out-Null"
cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop
else
mkdir -p build mkdir -p build
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../$(WINDOWS_DESKTOP_BINARY) ./cmd/desktop
endif
@printf 'Windows desktop build complete\n' @printf 'Windows desktop build complete\n'
desktop-build-linux: version-check _desktop-prepare-frontend _desktop-prepare-embedfs desktop-build-linux: version-check _desktop-prepare-frontend _desktop-prepare-embedfs _check-linux-target-arch
@printf 'Building Linux desktop...\n' @printf 'Building Linux desktop $(TARGET_ARCH)...\n'
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-linux-amd64 ./cmd/desktop mkdir -p build
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../$(LINUX_DESKTOP_BINARY) ./cmd/desktop
@printf 'Linux desktop build complete\n' @printf 'Linux desktop build complete\n'
desktop-lint: _backend-lint _frontend-check desktop-lint: _backend-lint _frontend-check
@@ -144,71 +258,215 @@ _desktop-clean:
_desktop-prepare-frontend: _frontend-install _desktop-prepare-frontend: _frontend-install
@printf 'Preparing frontend for desktop...\n' @printf 'Preparing frontend for desktop...\n'
ifeq ($(OS),Windows_NT)
powershell -NoProfile -Command "Copy-Item -LiteralPath 'frontend/.env.desktop' -Destination 'frontend/.env.production.local' -Force"
cd frontend && bun run build
powershell -NoProfile -Command "Remove-Item -LiteralPath 'frontend/.env.production.local' -Force -ErrorAction SilentlyContinue"
else
cd frontend && cp .env.desktop .env.production.local cd frontend && cp .env.desktop .env.production.local
cd frontend && bun run build cd frontend && bun run build
rm -f frontend/.env.production.local rm -f frontend/.env.production.local
endif
_desktop-prepare-embedfs: _desktop-prepare-embedfs:
@printf 'Preparing embedded filesystem...\n' @printf 'Preparing embedded filesystem...\n'
ifeq ($(OS),Windows_NT)
powershell -NoProfile -Command "Remove-Item -LiteralPath 'embedfs/assets' -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath 'embedfs/frontend-dist' -Recurse -Force -ErrorAction SilentlyContinue; Copy-Item -LiteralPath 'assets' -Destination 'embedfs/assets' -Recurse; Copy-Item -LiteralPath 'frontend/dist' -Destination 'embedfs/frontend-dist' -Recurse"
else
rm -rf embedfs/assets embedfs/frontend-dist rm -rf embedfs/assets embedfs/frontend-dist
cp -r assets embedfs/assets cp -r assets embedfs/assets
cp -r frontend/dist embedfs/frontend-dist cp -r frontend/dist embedfs/frontend-dist
endif
_desktop-prepare-windows-resource: _desktop-prepare-windows-resource: _check-windows-target-arch
@printf 'Preparing Windows executable icon...\n' @printf 'Preparing Windows $(TARGET_ARCH) executable icon...\n'
ifeq ($(OS),Windows_NT) @WINDRES_CMD="$(WINDRES)"; \
cd backend/cmd/desktop && windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_BFD)"; \
else if command -v llvm-windres >/dev/null 2>&1; then \
@if command -v x86_64-w64-mingw32-windres >/dev/null 2>&1; then \ WINDRES_CMD=llvm-windres; \
cd backend/cmd/desktop && x86_64-w64-mingw32-windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso; \ WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
elif command -v windres >/dev/null 2>&1; then \ elif "$$WINDRES_CMD" --version 2>&1 | grep -qi LLVM; then \
cd backend/cmd/desktop && windres -O coff -F pe-x86-64 -i icon_windows.rc -o rsrc_windows_amd64.syso; \ WINDRES_FMT="$(WINDOWS_WINDRES_FORMAT_LLVM)"; \
else \ fi; \
printf 'Missing windres for Windows icon resource generation\n'; \ command -v "$$WINDRES_CMD" >/dev/null 2>&1 || { printf 'Missing windres tool: %s\n' "$$WINDRES_CMD"; exit 1; }; \
exit 1; \ cd backend/cmd/desktop && "$$WINDRES_CMD" -O coff -F "$$WINDRES_FMT" -i icon_windows.rc -o $(WINDOWS_RESOURCE)
fi
endif
# ============================================ # ============================================
# 发布资产 # 发布资产
# ============================================ # ============================================
release-assets-linux: version-check desktop-build-linux release-assets-check:
go run ./versionctl release-assets-check
@printf 'Release assets check passed\n'
release-assets-web: version-check release-assets-check _frontend-build
rm -rf "$(RELEASE_DIR)" rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)" mkdir -p "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-amd64 ./cmd/server asset=$$(go run ./versionctl asset-name web tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_LINUX_ASSET)" nex-server-linux-amd64 tar -C frontend -czf "$(RELEASE_DIR)/$$asset" dist
tar -C build -czf "$(RELEASE_DIR)/$(DESKTOP_LINUX_ASSET)" nex-linux-amd64
release-assets-windows: version-check desktop-build-win release-assets-linux: version-check release-assets-check _check-linux-target-arch
ifeq ($(OS),Windows_NT)
powershell -NoProfile -Command "Remove-Item -LiteralPath '$(RELEASE_DIR)' -Recurse -Force -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Path '$(RELEASE_DIR)' -Force | Out-Null"
cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-server-win-amd64.exe ./cmd/server
powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-server-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(SERVER_WINDOWS_ASSET)' -Force"
powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(DESKTOP_WINDOWS_ASSET)' -Force"
else
@printf 'release-assets-windows requires Windows\n'
@exit 1
endif
release-assets-macos: version-check desktop-build-mac
rm -rf "$(RELEASE_DIR)" rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)" mkdir -p "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-amd64 ./cmd/server @$(MAKE) release-assets-server-linux TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-arm64 ./cmd/server @$(MAKE) release-assets-desktop-linux TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_AMD64_ASSET)" nex-server-darwin-amd64
tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_ARM64_ASSET)" nex-server-darwin-arm64 release-assets-windows: version-check release-assets-check _check-windows-target-arch
ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$(DESKTOP_MACOS_ASSET)" rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)"
@$(MAKE) release-assets-server-windows TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
@$(MAKE) release-assets-desktop-windows TARGET_ARCH=$(TARGET_ARCH) RELEASE_DIR="$(RELEASE_DIR)"
release-assets-macos: version-check release-assets-check
rm -rf "$(RELEASE_DIR)"
mkdir -p "$(RELEASE_DIR)"
@$(MAKE) release-assets-server-macos RELEASE_DIR="$(RELEASE_DIR)"
@$(MAKE) release-assets-desktop-macos RELEASE_DIR="$(RELEASE_DIR)"
release-assets-server-linux: version-check _check-linux-target-arch
mkdir -p build "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-$(TARGET_ARCH) ./cmd/server
asset=$$(go run ./versionctl asset-name server linux $(TARGET_ARCH) tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-linux-$(TARGET_ARCH)
release-assets-server-windows: version-check _check-windows-target-arch
mkdir -p build "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=$(TARGET_ARCH) go build -ldflags "$(GO_LDFLAGS)" -o ../$(WINDOWS_SERVER_BINARY) ./cmd/server
asset=$$(go run ./versionctl asset-name server windows $(TARGET_ARCH) zip); \
if command -v powershell.exe >/dev/null 2>&1; then POWERSHELL=powershell.exe; else POWERSHELL=powershell; fi; \
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_SERVER_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
release-assets-server-macos: version-check
mkdir -p build "$(RELEASE_DIR)"
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-macos-amd64 ./cmd/server
cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-macos-arm64 ./cmd/server
lipo -create build/nex-server-macos-amd64 build/nex-server-macos-arm64 -output build/nex-server-macos-universal
lipo -info build/nex-server-macos-universal | grep -q 'x86_64 arm64'
asset=$$(go run ./versionctl asset-name server macos amd64 tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-amd64
asset=$$(go run ./versionctl asset-name server macos arm64 tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-arm64
asset=$$(go run ./versionctl asset-name server macos universal tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-server-macos-universal
rm -f build/nex-server-macos-amd64 build/nex-server-macos-arm64 build/nex-server-macos-universal
release-assets-desktop-linux: version-check release-assets-check desktop-build-linux _package-linux-tar _package-linux-appimage _package-linux-deb _package-linux-rpm
release-assets-desktop-windows: version-check release-assets-check desktop-build-win
mkdir -p "$(RELEASE_DIR)"
asset=$$(go run ./versionctl asset-name desktop windows $(TARGET_ARCH) zip); \
if command -v powershell.exe >/dev/null 2>&1; then POWERSHELL=powershell.exe; else POWERSHELL=powershell; fi; \
"$$POWERSHELL" -NoProfile -Command "Compress-Archive -LiteralPath '$(WINDOWS_DESKTOP_BINARY)' -DestinationPath '$(RELEASE_DIR)/$$asset' -Force"
release-assets-desktop-macos: version-check release-assets-check desktop-build-mac _package-macos-zip _package-macos-dmg
rm -rf build/Nex.app build/dmg
release-assets-checksums:
@cd "$(RELEASE_DIR)" && \
rm -f SHA256SUMS && \
for asset in *; do \
[ -f "$$asset" ] || continue; \
if command -v sha256sum >/dev/null 2>&1; then \
sha256sum "$$asset"; \
elif command -v shasum >/dev/null 2>&1; then \
shasum -a 256 "$$asset"; \
else \
printf 'Missing sha256sum or shasum\n' >&2; \
exit 1; \
fi; \
done > SHA256SUMS && \
test -s SHA256SUMS
_check-linux-target-arch:
@if [ "$(TARGET_ARCH)" != "amd64" ] && [ "$(TARGET_ARCH)" != "arm64" ]; then \
printf 'Unsupported Linux TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
exit 1; \
fi
_check-windows-target-arch:
@if [ "$(TARGET_ARCH)" != "amd64" ]; then \
printf 'Unsupported Windows TARGET_ARCH: %s\n' "$(TARGET_ARCH)"; \
exit 1; \
fi
_ensure-appimagetool:
@mkdir -p build/tools
@if [ ! -x "$(APPIMAGETOOL)" ]; then \
printf 'Downloading appimagetool for %s...\n' "$(APPIMAGE_ARCH)"; \
command -v curl >/dev/null 2>&1 || { printf 'Missing curl for appimagetool download\n'; exit 1; }; \
curl -L "$(APPIMAGETOOL_URL)" -o "$(APPIMAGETOOL)"; \
chmod +x "$(APPIMAGETOOL)"; \
fi; \
printf 'Using appimagetool: %s\n' "$(APPIMAGETOOL)"
_package-linux-tar:
mkdir -p "$(RELEASE_DIR)"
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) tar.gz); \
tar -C build -czf "$(RELEASE_DIR)/$$asset" nex-linux-$(TARGET_ARCH)
_package-linux-appimage: _ensure-appimagetool
mkdir -p "$(RELEASE_DIR)"
appdir="build/appimage/nex-$(TARGET_ARCH).AppDir"; \
rm -rf "$$appdir"; \
mkdir -p "$$appdir/usr/bin" "$$appdir/usr/share/applications" "$$appdir/usr/share/icons"; \
install -m 0755 "$(LINUX_DESKTOP_BINARY)" "$$appdir/usr/bin/nex"; \
install -m 0644 packaging/linux/nex.desktop "$$appdir/nex.desktop"; \
install -m 0644 packaging/linux/nex.desktop "$$appdir/usr/share/applications/nex.desktop"; \
install -m 0755 packaging/linux/AppRun "$$appdir/AppRun"; \
cp -R assets/icons/hicolor "$$appdir/usr/share/icons/"; \
cp assets/icon.png "$$appdir/nex.png"; \
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) AppImage); \
ARCH=$(APPIMAGE_ARCH) APPIMAGE_EXTRACT_AND_RUN=1 "$(APPIMAGETOOL)" "$$appdir" "$(RELEASE_DIR)/$$asset"; \
chmod +x "$(RELEASE_DIR)/$$asset"; \
test -s "$(RELEASE_DIR)/$$asset"
_package-linux-deb:
mkdir -p "$(RELEASE_DIR)"
pkgdir="build/pkg/deb/nex-$(TARGET_ARCH)"; \
rm -rf "$$pkgdir"; \
mkdir -p "$$pkgdir/DEBIAN" "$$pkgdir/usr/bin" "$$pkgdir/usr/share/applications" "$$pkgdir/usr/share/icons"; \
install -m 0755 "$(LINUX_DESKTOP_BINARY)" "$$pkgdir/usr/bin/nex"; \
install -m 0644 packaging/linux/nex.desktop "$$pkgdir/usr/share/applications/nex.desktop"; \
cp -R assets/icons/hicolor "$$pkgdir/usr/share/icons/"; \
printf '%s\n' \
'Package: nex' \
'Version: $(VERSION)' \
'Section: utils' \
'Priority: optional' \
'Architecture: $(DEB_ARCH)' \
'Maintainer: Nex Maintainers <noreply@example.com>' \
'Depends: libgtk-3-0, libayatana-appindicator3-1, xdg-utils' \
'Description: AI Gateway desktop application' \
' Nex is an AI Gateway desktop application.' \
> "$$pkgdir/DEBIAN/control"; \
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) deb); \
dpkg-deb --build --root-owner-group "$$pkgdir" "$(RELEASE_DIR)/$$asset"; \
dpkg-deb -I "$(RELEASE_DIR)/$$asset" >/dev/null
_package-linux-rpm:
mkdir -p "$(RELEASE_DIR)"
topdir="$(abspath build/rpmbuild-$(TARGET_ARCH))"; \
rm -rf "$$topdir"; \
mkdir -p "$$topdir/BUILD" "$$topdir/BUILDROOT" "$$topdir/RPMS" "$$topdir/SOURCES" "$$topdir/SPECS" "$$topdir/SRPMS"; \
rpmbuild -bb --target "$(RPM_ARCH)" \
--define "_topdir $$topdir" \
--define "nex_version $(VERSION)" \
--define "nex_binary $(abspath $(LINUX_DESKTOP_BINARY))" \
--define "nex_desktop_file $(abspath packaging/linux/nex.desktop)" \
--define "nex_icons_dir $(abspath assets/icons/hicolor)" \
packaging/linux/nex.spec; \
rpm_file=$$(find "$$topdir/RPMS" -type f -name '*.rpm' | sort | tail -n 1); \
test -n "$$rpm_file"; \
asset=$$(go run ./versionctl asset-name desktop linux $(TARGET_ARCH) rpm); \
cp "$$rpm_file" "$(RELEASE_DIR)/$$asset"; \
rpm -qip "$(RELEASE_DIR)/$$asset" >/dev/null
_package-macos-zip:
mkdir -p "$(RELEASE_DIR)"
asset=$$(go run ./versionctl asset-name desktop macos universal zip); \
ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$$asset"
_package-macos-dmg:
mkdir -p "$(RELEASE_DIR)"
dmgdir="build/dmg/Nex"; \
rm -rf "$$dmgdir"; \
mkdir -p "$$dmgdir"; \
cp -R build/Nex.app "$$dmgdir/Nex.app"; \
ln -s /Applications "$$dmgdir/Applications"; \
asset=$$(go run ./versionctl asset-name desktop macos universal dmg); \
hdiutil create -volname Nex -srcfolder "$$dmgdir" -ov -format UDZO "$(RELEASE_DIR)/$$asset"; \
hdiutil verify "$(RELEASE_DIR)/$$asset" >/dev/null && \
rm -rf "$$dmgdir"
# ============================================ # ============================================
# 共享 helper targets # 共享 helper targets

View File

@@ -39,6 +39,8 @@ nex/
│ ├── icon.icns # macOS 应用图标 │ ├── icon.icns # macOS 应用图标
│ └── icon.ico # Windows 应用图标 │ └── icon.ico # Windows 应用图标
├── packaging/ # 桌面发布包元数据Linux desktop entry、RPM spec 等)
└── README.md # 本文件 └── README.md # 本文件
``` ```
@@ -109,10 +111,13 @@ make desktop-build-win
# Linux # Linux
make desktop-build-linux make desktop-build-linux
# Linux arm64
make desktop-build-linux TARGET_ARCH=arm64
``` ```
**使用桌面应用** **使用桌面应用**
- 双击启动应用macOS: Nex.appWindows: nex-win-amd64.exeLinux: nex-linux-amd64 - 双击启动应用macOS: Nex.appWindows: nex-win-amd64.exeLinux: nex-linux-amd64 / nex-linux-arm64
- 系统托盘图标出现,浏览器自动打开管理界面 - 系统托盘图标出现,浏览器自动打开管理界面
- 点击托盘图标显示菜单,可打开管理界面或退出 - 点击托盘图标显示菜单,可打开管理界面或退出
- 关闭浏览器后服务继续运行,可通过托盘重新打开 - 关闭浏览器后服务继续运行,可通过托盘重新打开
@@ -120,8 +125,10 @@ make desktop-build-linux
**注意事项** **注意事项**
- 桌面应用需要 CGO 支持 - 桌面应用需要 CGO 支持
- macOS: 自带 Xcode Command Line Tools - macOS: 自带 Xcode Command Line Tools
- Linux: 自带 gcc部分桌面环境需要 `libappindicator3-dev` - Linux 构建: 需要 gcc、pkg-config、GTK3 开发包和 Ayatana AppIndicator 开发包Ubuntu/Debian: `libgtk-3-dev``libayatana-appindicator3-dev`
- Windows: 需要 MinGW-w64 或在 Windows 环境构建 - Linux 运行: 需要 GTK3、Ayatana AppIndicator 和 xdg-utilsAppImage 也依赖系统提供 AppImage runtime/FUSE 能力,不承诺完全自包含
- Windows: 需要对应架构的 MinGW-w64/MSYS2 工具链desktop 使用 GUI linker flags 隐藏控制台窗口
- macOS DMG: 发布包暂不签名、不 notarize首次打开可能出现 Gatekeeper 提示
**Linux 桌面环境兼容性** **Linux 桌面环境兼容性**
- GNOME: 需要 AppIndicator 扩展 - GNOME: 需要 AppIndicator 扩展
@@ -151,6 +158,40 @@ make server-run
make server-build make server-build
``` ```
### Release 产物
发布流程由 Git tag `vX.Y.Z` 触发GitHub Actions 会创建 Draft Release 并上传 server、web 和 desktop 三类产物,同时生成 `SHA256SUMS`
**server 产物**(不内置 Web 管理界面):
| 平台 | 产物 |
|------|------|
| Linux amd64 | `nex-server_<version>_linux_amd64.tar.gz` |
| Linux arm64 | `nex-server_<version>_linux_arm64.tar.gz` |
| macOS amd64 | `nex-server_<version>_macos_amd64.tar.gz` |
| macOS arm64 | `nex-server_<version>_macos_arm64.tar.gz` |
| macOS universal | `nex-server_<version>_macos_universal.tar.gz` |
| Windows amd64 | `nex-server_<version>_windows_amd64.zip` |
**web 产物**
| 内容 | 产物 |
|------|------|
| `frontend/dist` | `nex-web_<version>.tar.gz` |
**desktop 产物**
| 平台 | 产物 |
|------|------|
| Linux amd64 | `nex-desktop_<version>_linux_amd64.tar.gz``.AppImage``.deb``.rpm` |
| Linux arm64 | `nex-desktop_<version>_linux_arm64.tar.gz``.AppImage``.deb``.rpm` |
| macOS universal | `nex-desktop_<version>_macos_universal.zip``nex-desktop_<version>_macos_universal.dmg` |
| Windows amd64 | `nex-desktop_<version>_windows_amd64.zip` |
Linux deb 包声明 `libgtk-3-0``libayatana-appindicator3-1``xdg-utils` 运行依赖rpm 包声明 `gtk3``libayatana-appindicator-gtk3``xdg-utils` 运行依赖。Rocky Linux 9 等发行版可能需要启用 EPEL 才能解析 Ayatana AppIndicator 依赖。
server 和 desktop 发布产物自包含运行时数据库迁移资源(通过 `go:embed` 嵌入二进制),安装后首次启动不再依赖仓库源码目录。
## API 接口 ## API 接口
### 代理接口(对外部应用) ### 代理接口(对外部应用)
@@ -289,7 +330,13 @@ backend 分类测试、MySQL 专项测试和前端 E2E 测试请分别查看 `ba
```bash ```bash
# 首次克隆后安装 Git hooks # 首次克隆后安装 Git hooks
lefthook install make hooks-install
# 检查 Git hooks 安装状态
make hooks-check
# 运行 Git hooks 回归测试
make hooks-test
# 全局命令 # 全局命令
make lint # 前后端共享检查 make lint # 前后端共享检查
@@ -312,6 +359,11 @@ make desktop-test # desktop 专属测试
make desktop-clean # 清理 desktop 产物 make desktop-clean # 清理 desktop 产物
``` ```
Git hooks 使用仓库内 `scripts/git-hooks/` 的原生脚本,不依赖额外 hook 框架。当前 hooks 包含:
- pre-commit检查 staged files 的冲突标记、Go lint、前端 lint/格式和大文件告警
- commit-msg校验提交信息格式为 `类型: 简短描述`,描述需使用中文
## 版本与发布 ## 版本与发布
### 统一版本源 ### 统一版本源
@@ -322,7 +374,7 @@ make desktop-clean # 清理 desktop 产物
### 本地版本演进 ### 本地版本演进
```bash ```bash
# 递增版本(自动 sync + check + commit + tag # 递增版本(自动 lint + test + 工作区检查 + sync/check + commit + tag
make version-bump BUMP=minor make version-bump BUMP=minor
# 或指定具体版本号 # 或指定具体版本号

View File

@@ -1 +1 @@
0.1.1 0.1.7

View File

@@ -164,6 +164,10 @@ backend/
│ └── validator/ # 验证器 │ └── validator/ # 验证器
│ └── validator.go │ └── validator.go
├── migrations/ # 数据库迁移 ├── migrations/ # 数据库迁移
│ ├── embed.go # go:embed 迁移资源入口
│ ├── sqlite/
│ │ └── 20260421000001_initial_schema.sql
│ └── mysql/
│ └── 20260421000001_initial_schema.sql │ └── 20260421000001_initial_schema.sql
├── tests/ # 集成测试 ├── tests/ # 集成测试
│ ├── helpers.go # 测试辅助函数 │ ├── helpers.go # 测试辅助函数
@@ -456,6 +460,8 @@ make mysql-test-quick
## 数据库迁移 ## 数据库迁移
应用启动时使用随二进制打包的迁移资源(`go:embed`自动执行迁移server 和 desktop 发布产物均自包含,不依赖源码目录。开发时可继续通过 Makefile goose CLI 操作文件系统中的 `migrations/<dialect>/` 目录,运行时嵌入资源与文件系统目录共享同一批 SQL 文件。
```bash ```bash
# 使用 Makefile # 使用 Makefile
make migrate-up DB_DSN=~/.nex/config.db make migrate-up DB_DSN=~/.nex/config.db

View File

@@ -0,0 +1,43 @@
package main
import (
"os"
"path/filepath"
"testing"
"nex/backend/internal/config"
"nex/backend/internal/database"
"go.uber.org/zap"
)
func TestDesktop_InitMigrationsWithoutSourceTree(t *testing.T) {
tmpDir := t.TempDir()
origDir, err := os.Getwd()
if err == nil {
defer func() {
if chdirErr := os.Chdir(origDir); chdirErr != nil {
t.Logf("无法恢复工作目录: %v", chdirErr)
}
}()
}
if chdirErr := os.Chdir(tmpDir); chdirErr != nil {
t.Skipf("无法切换到临时目录: %v", chdirErr)
}
cfg := &config.DatabaseConfig{
Driver: "sqlite",
Path: filepath.Join(tmpDir, "nex-test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
db, err := database.Init(cfg, zapLogger)
if err != nil {
t.Fatalf("在无源码目录环境下数据库初始化应成功,但返回错误: %v", err)
}
database.Close(db)
}

View File

@@ -1,10 +1,10 @@
package database package database
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
"go.uber.org/zap" "go.uber.org/zap"
@@ -13,6 +13,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"nex/backend/internal/config" "nex/backend/internal/config"
"nex/backend/migrations"
pkglogger "nex/backend/pkg/logger" pkglogger "nex/backend/pkg/logger"
) )
@@ -77,29 +78,24 @@ func runMigrations(db *gorm.DB, driver string, zapLogger *zap.Logger) error {
return err return err
} }
gooseDialect := "sqlite3" dialect, fsys, err := migrations.ForDriver(driver)
migrationsSubDir := "sqlite" if err != nil {
if driver == "mysql" { return err
gooseDialect = "mysql"
migrationsSubDir = "mysql"
}
migrationsDir := getMigrationsDir(driver)
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
return fmt.Errorf("迁移目录不存在: %s", migrationsDir)
} }
if zapLogger != nil { if zapLogger != nil {
zapLogger.Info("执行数据库迁移", zapLogger.Info("执行数据库迁移",
zap.String("dialect", gooseDialect), zap.String("dialect", string(dialect)),
zap.String("dir", migrationsSubDir)) zap.String("driver", driver))
} }
if err := goose.SetDialect(gooseDialect); err != nil { provider, err := goose.NewProvider(dialect, sqlDB, fsys)
return err if err != nil {
return fmt.Errorf("创建迁移提供者失败: %w", err)
} }
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return err if _, err := provider.Up(context.Background()); err != nil {
return fmt.Errorf("执行迁移失败: %w", err)
} }
return nil return nil
@@ -130,21 +126,6 @@ func configurePool(db *gorm.DB, cfg *config.DatabaseConfig, zapLogger *zap.Logge
} }
} }
func getMigrationsDir(driver string) string {
_, filename, _, ok := runtime.Caller(0)
if ok {
subDir := "sqlite"
if driver == "mysql" {
subDir = "mysql"
}
dir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations", subDir)
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
}
return "./migrations"
}
func BuildDSN(cfg *config.DatabaseConfig) string { func BuildDSN(cfg *config.DatabaseConfig) string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local", return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName) cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)

View File

@@ -1,10 +1,13 @@
package database package database
import ( import (
"io/fs"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"nex/backend/internal/config" "nex/backend/internal/config"
"nex/backend/migrations"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -76,3 +79,87 @@ func TestBuildDSN_EmptyPassword(t *testing.T) {
dsn := BuildDSN(cfg) dsn := BuildDSN(cfg)
assert.Equal(t, "root:@tcp(localhost:3306)/nex?charset=utf8mb4&parseTime=true&loc=Local", dsn) assert.Equal(t, "root:@tcp(localhost:3306)/nex?charset=utf8mb4&parseTime=true&loc=Local", dsn)
} }
func TestInit_SQLite_AnyCWD(t *testing.T) {
dir := t.TempDir()
origDir, err := os.Getwd()
if err == nil {
defer func() {
if chdirErr := os.Chdir(origDir); chdirErr != nil {
t.Logf("无法恢复工作目录: %v", chdirErr)
}
}()
}
if chdirErr := os.Chdir(dir); chdirErr != nil {
t.Skipf("无法切换到临时目录: %v", chdirErr)
}
cfg := &config.DatabaseConfig{
Driver: "sqlite",
Path: filepath.Join(dir, "test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
db, err := Init(cfg, zapLogger)
require.NoError(t, err)
require.NotNil(t, db)
defer Close(db)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NotNil(t, sqlDB)
}
func TestForDriverDialect_SQLite(t *testing.T) {
require.NoError(t, testMigrateWithDriver(t, "sqlite"))
}
func TestForDriverDialect_MySQL(t *testing.T) {
dialect, fsys, err := migrations.ForDriver("mysql")
require.NoError(t, err)
assert.Equal(t, "mysql", string(dialect))
entries, fsErr := fs.ReadDir(fsys, ".")
require.NoError(t, fsErr)
assert.NotEmpty(t, entries, "MySQL 迁移资源应至少包含一个文件")
}
func TestForDriverDialect_Invalid(t *testing.T) {
dir := t.TempDir()
cfg := &config.DatabaseConfig{
Driver: "postgres",
Path: filepath.Join(dir, "test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
_, err := Init(cfg, zapLogger)
assert.Error(t, err, "非法 driver 应返回错误")
assert.Contains(t, err.Error(), "不支持的数据库驱动")
}
func testMigrateWithDriver(t *testing.T, driver string) error {
t.Helper()
dir := t.TempDir()
cfg := &config.DatabaseConfig{
Driver: driver,
Path: filepath.Join(dir, "test.db"),
MaxIdleConns: 5,
MaxOpenConns: 10,
ConnMaxLifetime: 0,
}
zapLogger := zap.NewNop()
db, err := Init(cfg, zapLogger)
if err != nil {
return err
}
Close(db)
return nil
}

View File

@@ -0,0 +1,71 @@
package database
import (
"io/fs"
"testing"
"nex/backend/migrations"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEmbeddedMigrations_SQLiteResourcesPresent(t *testing.T) {
entries, err := fs.ReadDir(migrations.FS, "sqlite")
require.NoError(t, err)
var sqlFiles []string
for _, entry := range entries {
if !entry.IsDir() {
sqlFiles = append(sqlFiles, entry.Name())
}
}
assert.NotEmpty(t, sqlFiles, "SQLite 迁移资源应至少包含一个 .sql 文件")
}
func TestEmbeddedMigrations_MySQLResourcesPresent(t *testing.T) {
entries, err := fs.ReadDir(migrations.FS, "mysql")
require.NoError(t, err)
var sqlFiles []string
for _, entry := range entries {
if !entry.IsDir() {
sqlFiles = append(sqlFiles, entry.Name())
}
}
assert.NotEmpty(t, sqlFiles, "MySQL 迁移资源应至少包含一个 .sql 文件")
}
func TestEmbeddedMigrations_SQLiteSQLParsable(t *testing.T) {
subFS, err := fs.Sub(migrations.FS, "sqlite")
require.NoError(t, err)
entries, err := fs.ReadDir(subFS, ".")
require.NoError(t, err)
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := fs.ReadFile(subFS, entry.Name())
require.NoError(t, err, "无法读取迁移文件: %s", entry.Name())
assert.NotEmpty(t, data, "迁移文件内容不应为空: %s", entry.Name())
}
}
func TestEmbeddedMigrations_MySQLSQLParsable(t *testing.T) {
subFS, err := fs.Sub(migrations.FS, "mysql")
require.NoError(t, err)
entries, err := fs.ReadDir(subFS, ".")
require.NoError(t, err)
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := fs.ReadFile(subFS, entry.Name())
require.NoError(t, err, "无法读取迁移文件: %s", entry.Name())
assert.NotEmpty(t, data, "迁移文件内容不应为空: %s", entry.Name())
}
}

View File

@@ -0,0 +1,31 @@
package migrations
import (
"embed"
"fmt"
"io/fs"
"github.com/pressly/goose/v3"
)
//go:embed sqlite/*.sql mysql/*.sql
var FS embed.FS
func ForDriver(driver string) (goose.Dialect, fs.FS, error) {
switch driver {
case "sqlite":
subFS, err := fs.Sub(FS, "sqlite")
if err != nil {
return goose.DialectSQLite3, nil, fmt.Errorf("SQLite 迁移资源不可用: %w", err)
}
return goose.DialectSQLite3, subFS, nil
case "mysql":
subFS, err := fs.Sub(FS, "mysql")
if err != nil {
return goose.DialectMySQL, nil, fmt.Errorf("MySQL 迁移资源不可用: %w", err)
}
return goose.DialectMySQL, subFS, nil
default:
return goose.DialectSQLite3, nil, fmt.Errorf("不支持的数据库驱动: %s仅支持 sqlite 或 mysql", driver)
}
}

View File

@@ -1,2 +1,2 @@
VITE_API_BASE= VITE_API_BASE=
VITE_APP_VERSION=0.1.1 VITE_APP_VERSION=0.1.7

View File

@@ -1,2 +1,2 @@
VITE_API_BASE= VITE_API_BASE=
VITE_APP_VERSION=0.1.1 VITE_APP_VERSION=0.1.7

View File

@@ -1,2 +1,2 @@
VITE_API_BASE=/api VITE_API_BASE=/api
VITE_APP_VERSION=0.1.1 VITE_APP_VERSION=0.1.7

View File

@@ -188,13 +188,14 @@ bun run test:e2e
- API Key 脱敏显示 - API Key 脱敏显示
- 启用/禁用状态标签 - 启用/禁用状态标签
- **协议字段**:支持 OpenAI 和 Anthropic 协议选择 - **协议字段**:支持 OpenAI 和 Anthropic 协议选择
- **一键复制**Base URL 和 API Key 支持一键复制到剪贴板
### 模型管理 ### 模型管理
- 展开供应商行查看关联模型 - 展开供应商行查看关联模型
- 添加/编辑/删除模型 - 添加/编辑/删除模型
- 按供应商筛选模型 - 按供应商筛选模型
- **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别 - **统一模型 ID**:显示格式为 `provider_id/model_name`,用于跨协议模型识别,支持一键复制
- **UUID 自动生成**:创建模型时后端自动生成 UUID无需手动输入 ID - **UUID 自动生成**:创建模型时后端自动生成 UUID无需手动输入 ID
### 用量统计 ### 用量统计

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.1.1", "version": "0.1.7",
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -4,6 +4,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ModelTable } from '@/pages/Providers/ModelTable' import { ModelTable } from '@/pages/Providers/ModelTable'
import type { Model } from '@/types' import type { Model } from '@/types'
const { mockMessagePluginSuccess } = vi.hoisted(() => ({
mockMessagePluginSuccess: vi.fn(),
}))
vi.mock('tdesign-react', async () => {
const actual = await vi.importActual('tdesign-react')
return {
...actual,
MessagePlugin: {
success: mockMessagePluginSuccess,
error: vi.fn(),
},
}
})
const mockModels: Model[] = [ const mockModels: Model[] = [
{ {
id: 'model-1', id: 'model-1',
@@ -44,6 +59,7 @@ const defaultProps = {
describe('ModelTable', () => { describe('ModelTable', () => {
beforeEach(() => { beforeEach(() => {
mockMutate.mockClear() mockMutate.mockClear()
mockMessagePluginSuccess.mockClear()
}) })
it('renders model list with unified ID and model name', () => { it('renders model list with unified ID and model name', () => {
@@ -120,4 +136,19 @@ describe('ModelTable', () => {
expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /编 ?辑/ })).not.toBeInTheDocument()
}) })
it('renders unified model ID with copy button and copies on click', async () => {
const user = userEvent.setup()
const { container } = render(<ModelTable {...defaultProps} />)
const allCells = container.querySelectorAll('td')
const modelIdCell = Array.from(allCells).find((td) => td.textContent?.includes('openai/gpt-4o'))
expect(modelIdCell).toBeTruthy()
const buttons = modelIdCell!.querySelectorAll('button')
expect(buttons.length).toBeGreaterThan(0)
await user.click(buttons[0]!)
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制统一模型 ID')
})
}) })

View File

@@ -1,9 +1,24 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ProviderTable } from '@/pages/Providers/ProviderTable' import { ProviderTable } from '@/pages/Providers/ProviderTable'
import type { Provider } from '@/types' import type { Provider } from '@/types'
const { mockMessagePluginSuccess } = vi.hoisted(() => ({
mockMessagePluginSuccess: vi.fn(),
}))
vi.mock('tdesign-react', async () => {
const actual = await vi.importActual('tdesign-react')
return {
...actual,
MessagePlugin: {
success: mockMessagePluginSuccess,
error: vi.fn(),
},
}
})
const mockModelsData = [ const mockModelsData = [
{ id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' }, { id: 'model-1', providerId: 'openai', modelName: 'gpt-4o', enabled: true, unifiedId: 'openai/gpt-4o' },
{ {
@@ -54,6 +69,9 @@ const defaultProps = {
} }
describe('ProviderTable', () => { describe('ProviderTable', () => {
beforeEach(() => {
mockMessagePluginSuccess.mockClear()
})
it('renders provider list with name, baseUrl, apiKey, and status tags', () => { it('renders provider list with name, baseUrl, apiKey, and status tags', () => {
render(<ProviderTable {...defaultProps} />) render(<ProviderTable {...defaultProps} />)
@@ -203,4 +221,66 @@ describe('ProviderTable', () => {
const protocolCell = container.querySelector('[data-colkey="protocol"]') const protocolCell = container.querySelector('[data-colkey="protocol"]')
expect(protocolCell).toBeInTheDocument() expect(protocolCell).toBeInTheDocument()
}) })
it('renders Base URL with copy button and copies on click', async () => {
const user = userEvent.setup()
const { container } = render(<ProviderTable {...defaultProps} />)
const baseUrlCells = container.querySelectorAll('td')
const baseUrlCellWithContent = Array.from(baseUrlCells).find((td) =>
td.textContent?.includes('https://api.openai.com/v1')
)
expect(baseUrlCellWithContent).toBeTruthy()
const buttons = baseUrlCellWithContent!.querySelectorAll('button')
expect(buttons.length).toBeGreaterThan(0)
await user.click(buttons[0]!)
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制 Base URL')
})
it('renders API Key with copy button and copies on click', async () => {
const user = userEvent.setup()
const { container } = render(<ProviderTable {...defaultProps} />)
const allCells = container.querySelectorAll('td')
const apiKeyCell = Array.from(allCells).find((td) => td.textContent?.includes('sk-abcdefgh12345678'))
expect(apiKeyCell).toBeTruthy()
const buttons = apiKeyCell!.querySelectorAll('button')
expect(buttons.length).toBeGreaterThan(0)
await user.click(buttons[0]!)
expect(mockMessagePluginSuccess).toHaveBeenCalledWith('已复制 API Key')
})
it('does not render copy button when Base URL is empty', () => {
const emptyUrlProvider: Provider[] = [
{
...mockProviders[0],
id: 'empty-url',
baseUrl: '',
},
]
const { container } = render(<ProviderTable {...defaultProps} providers={emptyUrlProvider} />)
const allCells = container.querySelectorAll('td')
const baseUrlCells = Array.from(allCells).filter((td) => td.textContent === '')
expect(baseUrlCells.length).toBeGreaterThanOrEqual(0)
})
it('does not render copy button when API Key is empty', () => {
const emptyKeyProvider: Provider[] = [
{
...mockProviders[0],
id: 'empty-key',
apiKey: '',
},
]
const { container } = render(<ProviderTable {...defaultProps} providers={emptyKeyProvider} />)
const allCells = container.querySelectorAll('td')
const apiKeyCells = Array.from(allCells).filter((td) => td.textContent === '')
expect(apiKeyCells.length).toBeGreaterThanOrEqual(0)
})
}) })

View File

@@ -1,4 +1,4 @@
import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react' import { Button, Table, Tag, Popconfirm, Space, Typography, MessagePlugin } from 'tdesign-react'
import { useModels, useDeleteModel } from '@/hooks/useModels' import { useModels, useDeleteModel } from '@/hooks/useModels'
import type { Model } from '@/types' import type { Model } from '@/types'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type' import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
@@ -18,8 +18,30 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
title: '统一模型 ID', title: '统一模型 ID',
colKey: 'unifiedId', colKey: 'unifiedId',
width: 250, width: 250,
ellipsis: true, cell: ({ row }) => {
cell: ({ row }) => row.unifiedId || `${row.providerId}/${row.modelName}`, const id = row.unifiedId || `${row.providerId}/${row.modelName}`
return id ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{id}
</span>
<Typography.Text
style={{ flexShrink: 0 }}
copyable={{
text: id,
onCopy: () => MessagePlugin.success('已复制统一模型 ID'),
}}
/>
</span>
) : null
},
}, },
{ {
title: '模型名称', title: '模型名称',

View File

@@ -1,4 +1,4 @@
import { Button, Table, Tag, Popconfirm, Space, Card } from 'tdesign-react' import { Button, Table, Tag, Popconfirm, Space, Card, Typography, MessagePlugin } from 'tdesign-react'
import type { Provider, Model } from '@/types' import type { Provider, Model } from '@/types'
import { ModelTable } from './ModelTable' import { ModelTable } from './ModelTable'
import type { PrimaryTableCol } from 'tdesign-react/es/table/type' import type { PrimaryTableCol } from 'tdesign-react/es/table/type'
@@ -32,7 +32,28 @@ export function ProviderTable({
{ {
title: 'Base URL', title: 'Base URL',
colKey: 'baseUrl', colKey: 'baseUrl',
ellipsis: true, cell: ({ row }) =>
row.baseUrl ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{row.baseUrl}
</span>
<Typography.Text
style={{ flexShrink: 0 }}
copyable={{
text: row.baseUrl,
onCopy: () => MessagePlugin.success('已复制 Base URL'),
}}
/>
</span>
) : null,
}, },
{ {
title: '协议', title: '协议',
@@ -47,7 +68,28 @@ export function ProviderTable({
{ {
title: 'API Key', title: 'API Key',
colKey: 'apiKey', colKey: 'apiKey',
ellipsis: true, cell: ({ row }) =>
row.apiKey ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: '100%' }}>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
{row.apiKey}
</span>
<Typography.Text
style={{ flexShrink: 0 }}
copyable={{
text: row.apiKey,
onCopy: () => MessagePlugin.success('已复制 API Key'),
}}
/>
</span>
) : null,
}, },
{ {
title: '状态', title: '状态',

View File

@@ -1,5 +0,0 @@
pre-commit:
commands:
backend-lint:
glob: "backend/**/*.go"
run: cd backend && go tool golangci-lint run --new-from-rev HEAD ./...

View File

@@ -104,14 +104,14 @@
### Requirement: 应用启动时迁移 ### Requirement: 应用启动时迁移
应用 SHALL 在启动时执行迁移。 应用 SHALL 在启动时执行迁移,并 SHALL 使用随应用构建产物可用的打包迁移资源
#### Scenario: 自动迁移 #### Scenario: 自动迁移
- **WHEN** 应用启动 - **WHEN** 应用启动
- **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移目录和 goose dialect - **THEN** SHALL 根据 `database.driver` 配置选择对应的迁移资源和 goose dialect
- **THEN** SHALL 在 `driver=sqlite` 时使用 `migrations/sqlite/` 目录goose dialect 为 `sqlite3` - **THEN** SHALL 在 `driver=sqlite` 时使用 SQLite 方言迁移资源goose dialect 为 `sqlite3`
- **THEN** SHALL 在 `driver=mysql` 时使用 `migrations/mysql/` 目录goose dialect 为 `mysql` - **THEN** SHALL 在 `driver=mysql` 时使用 MySQL 方言迁移资源goose dialect 为 `mysql`
- **THEN** SHALL 自动执行待执行的迁移 - **THEN** SHALL 自动执行待执行的迁移
- **THEN** SHALL 在迁移失败时拒绝启动 - **THEN** SHALL 在迁移失败时拒绝启动
- **THEN** SHALL 记录迁移日志 - **THEN** SHALL 记录迁移日志
@@ -122,6 +122,15 @@
- **THEN** SHALL 检查数据库迁移版本 - **THEN** SHALL 检查数据库迁移版本
- **THEN** SHALL 在版本不匹配时执行迁移 - **THEN** SHALL 在版本不匹配时执行迁移
#### Scenario: 发布产物无源码目录时自动迁移
- **WHEN** 应用以发布产物形式运行,且安装环境不存在仓库源码目录
- **THEN** 应用启动 SHALL 能找到迁移资源
- **THEN** SHALL 自动执行待执行迁移
- **THEN** SHALL NOT 依赖源码工作区中的 `backend/migrations/...` 文件系统路径
- **THEN** SHALL NOT 通过 `runtime.Caller` 推导构建机或源码目录作为运行时迁移目录
- **THEN** 日志中的迁移资源位置 SHALL NOT 指向构建机路径,如 `/Users/runner/work/...`
### Requirement: 连接池配置 ### Requirement: 连接池配置
系统 SHALL 配置数据库连接池。 系统 SHALL 配置数据库连接池。
@@ -157,7 +166,7 @@
### Requirement: 迁移文件管理 ### Requirement: 迁移文件管理
迁移文件 SHALL 版本化管理。 迁移文件 SHALL 版本化管理,并 SHALL 在构建发布产物时作为运行时迁移资源打包
#### Scenario: 迁移文件命名 #### Scenario: 迁移文件命名
@@ -171,3 +180,10 @@
- **WHEN** 创建迁移文件 - **WHEN** 创建迁移文件
- **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/``migrations/mysql/` - **THEN** SHALL 按 SQL 方言存储在对应子目录(`migrations/sqlite/``migrations/mysql/`
- **THEN** SHALL 提交到版本控制系统 - **THEN** SHALL 提交到版本控制系统
#### Scenario: 迁移文件打包
- **WHEN** 构建 server 或 desktop 二进制
- **THEN** SQLite 和 MySQL 迁移文件 SHALL 被作为运行时迁移资源打包进二进制或等效发布资源
- **THEN** 应用启动迁移 SHALL 使用该打包资源
- **THEN** backend Makefile 的 goose CLI 迁移命令 MAY 继续使用文件系统中的 `migrations/<dialect>/` 目录

View File

@@ -139,29 +139,92 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
### Requirement: 跨平台构建 ### Requirement: 跨平台构建
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,中间构建产物文件名 SHALL 保持 `nex-{os}-{arch}[.exe]` 格式,最终桌面发布资产文件名 SHALL 包含统一版本号和平台标识 系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息
#### Scenario: macOS 构建 #### Scenario: macOS 构建
- **WHEN** 执行 `desktop-build-mac` 构建命令且当前版本为 `1.2.3` - **WHEN** 执行 macOS desktop 构建命令且当前版本为 `1.2.3`
- **THEN** 生成 `nex-mac-arm64``nex-mac-amd64` 可执行文件 - **THEN** 系统 SHALL 生成 macOS arm64 和 amd64 桌面可执行文件
- **AND** 系统生成可打包为 `.app` bundle 的 macOS 桌面产物 - **AND** 系统 SHALL 使用 `lipo` 生成 macOS universal 桌面可执行文件
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3``macOS` 平台标识 - **AND** 系统 SHALL 生成可打包为 `.app` bundle 的 macOS desktop 产物
- **AND** 最终 macOS desktop 发布资产文件名 SHALL 包含 `1.2.3``macos``universal`
#### Scenario: Windows 构建 #### Scenario: Windows 构建
- **WHEN** 执行 `desktop-build-win` 构建命令且当前版本为 `1.2.3` - **WHEN** 执行 Windows desktop 构建命令且当前版本为 `1.2.3`
- **THEN** 系统生成 Windows 桌面可执行文件 - **THEN** 系统 SHALL 生成 Windows amd64 desktop 可执行文件
- **AND** 生成 `nex-win-amd64.exe` 可执行文件 - **AND** Windows desktop 构建 SHALL 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
- **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口 - **AND** 最终 Windows desktop 发布资产文件名 SHALL 包含 `1.2.3``windows``amd64`
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3``windows` 平台标识
#### Scenario: Linux 构建 #### Scenario: Linux 构建
- **WHEN** 执行 `desktop-build-linux` 构建命令且当前版本为 `1.2.3` - **WHEN** 执行 Linux desktop 构建命令且当前版本为 `1.2.3`
- **THEN** 系统生成 Linux 桌面可执行文件 - **THEN** 系统 SHALL 生成 Linux amd64 和 arm64 desktop 可执行文件
- **AND** 生成 `nex-linux-amd64` 可执行文件 - **AND** Linux desktop 构建 SHALL 使用 CGO 和 GTK/AppIndicator 构建依赖
- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3``linux` 平台标识 - **AND** 最终 Linux desktop 发布资产文件名 SHALL 包含 `1.2.3``linux` 和对应架构标识
### Requirement: Linux 桌面发布安装包
系统 SHALL 为 Linux desktop amd64 和 arm64 生成 tar.gz、AppImage、deb 和 rpm 发布安装包,并 SHALL 在安装包中包含标准桌面集成元数据。
#### Scenario: Linux desktop tar.gz 裸包
- **WHEN** 构建 Linux desktop 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.tar.gz`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.tar.gz`
#### Scenario: Linux desktop AppImage 包
- **WHEN** 构建 Linux desktop AppImage 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.AppImage`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.AppImage`
- **AND** AppImage SHALL 包含 desktop entry、应用图标和 desktop 可执行文件
- **AND** AppImage SHALL 依赖目标系统提供 GTK3、Ayatana AppIndicator 和运行 AppImage 所需的 runtime/FUSE 能力
#### Scenario: Linux desktop deb 包
- **WHEN** 构建 Linux desktop deb 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.deb`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.deb`
- **AND** deb 包 SHALL 将可执行文件安装到 `/usr/bin/nex`
- **AND** deb 包 SHALL 将 desktop entry 安装到 `/usr/share/applications/nex.desktop`
- **AND** deb 包 SHALL 将 hicolor 图标安装到 `/usr/share/icons/hicolor/.../apps/nex.png`
- **AND** deb 包 SHALL 声明 `libgtk-3-0``libayatana-appindicator3-1``xdg-utils` 运行时依赖
- **AND** deb 包 metadata 的架构字段 SHALL 使用 `amd64``arm64`
#### Scenario: Linux desktop rpm 包
- **WHEN** 构建 Linux desktop rpm 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.rpm`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.rpm`
- **AND** rpm 包 SHALL 将可执行文件安装到 `/usr/bin/nex`
- **AND** rpm 包 SHALL 将 desktop entry 安装到 `/usr/share/applications/nex.desktop`
- **AND** rpm 包 SHALL 将 hicolor 图标安装到 `/usr/share/icons/hicolor/.../apps/nex.png`
- **AND** rpm 包 SHALL 声明 `gtk3``libayatana-appindicator-gtk3``xdg-utils` 运行时依赖
- **AND** rpm 包 metadata 的架构字段 SHALL 使用 `x86_64``aarch64`
### Requirement: macOS DMG 打包
系统 SHALL 为 macOS desktop universal `.app` 生成 unsigned DMG 安装包,并 SHALL 保留 universal zip 发布资产。
#### Scenario: macOS universal zip 包
- **WHEN** 构建 macOS desktop 发布资产且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 `nex-desktop_1.2.3_macos_universal.zip`
- **AND** zip 包 SHALL 包含 `Nex.app`
#### Scenario: macOS universal DMG 包
- **WHEN** 构建 macOS desktop DMG 发布资产且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 `nex-desktop_1.2.3_macos_universal.dmg`
- **AND** DMG SHALL 包含 `Nex.app`
- **AND** DMG SHALL 包含指向 `/Applications` 的快捷方式
- **AND** DMG SHALL NOT 要求 macOS 签名或 notarization 才能完成构建
#### Scenario: macOS universal 架构校验
- **WHEN** macOS desktop universal 可执行文件生成完成
- **THEN** 系统 SHALL 验证该可执行文件包含 amd64 和 arm64 架构
### Requirement: macOS .app 打包 ### Requirement: macOS .app 打包
@@ -253,3 +316,28 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
#### Scenario: 多行文本处理 #### Scenario: 多行文本处理
- **WHEN** 对话框消息包含换行符 `\n` - **WHEN** 对话框消息包含换行符 `\n`
- **THEN** AppleScript 正确显示多行文本 - **THEN** AppleScript 正确显示多行文本
### Requirement: 桌面应用打包迁移资源
桌面应用 SHALL 在打包安装后仍能访问数据库迁移资源,并 SHALL 在首次启动时完成数据库初始化和迁移。
#### Scenario: 打包安装后首次启动执行迁移
- **WHEN** 用户从 macOS DMG 安装并首次启动 `Nex.app`
- **THEN** 系统 SHALL 初始化默认配置和数据库
- **THEN** 系统 SHALL 使用打包在应用内的迁移资源执行 SQLite 迁移
- **THEN** 系统 SHALL NOT 尝试访问构建机源码路径或仓库源码路径
- **THEN** 系统 SHALL 成功启动后端服务、托盘和管理界面
#### Scenario: .app 包含运行时必需迁移资源
- **WHEN** 执行 macOS 桌面打包脚本
- **THEN** `Nex.app` SHALL 包含启动后端服务所需的数据库迁移资源
- **THEN** 迁移资源 SHALL 随应用移动到任意安装位置后仍可用
- **THEN** `.app` SHALL NOT 依赖构建目录、源码目录或 GitHub Actions runner 路径
#### Scenario: DMG 安装后运行时资源完整
- **WHEN** 用户从 DMG 将 `Nex.app` 拖入 `/Applications` 并启动
- **THEN** 应用 SHALL 能访问数据库迁移资源
- **THEN** 应用 SHALL NOT 因 `migrations/sqlite``migrations/mysql` 文件系统目录不存在而启动失败

View File

@@ -125,6 +125,26 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 供应商列表为空 - **WHEN** 供应商列表为空
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加" - **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加"
#### Scenario: Base URL 一键复制
- **WHEN** 供应商表格渲染 Base URL 列
- **THEN** Base URL 文本右侧 SHALL 显示复制图标按钮
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text``copyable` 属性
- **WHEN** 用户点击 Base URL 的复制按钮
- **THEN** 系统 SHALL 将完整 Base URL 写入剪贴板
- **THEN** 系统 SHALL 显示 `已复制 Base URL` 成功提示
- **THEN** 当 Base URL 为空时,复制按钮 SHALL 禁用
#### Scenario: API Key 一键复制
- **WHEN** 供应商表格渲染 API Key 列
- **THEN** API Key 文本右侧 SHALL 显示复制图标按钮
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text``copyable` 属性
- **WHEN** 用户点击 API Key 的复制按钮
- **THEN** 系统 SHALL 将完整 API Key 写入剪贴板
- **THEN** 系统 SHALL 显示 `已复制 API Key` 成功提示
- **THEN** 当 API Key 为空时,复制按钮 SHALL 禁用
#### Scenario: 添加新供应商 #### Scenario: 添加新供应商
- **WHEN** 用户点击"添加供应商"按钮 - **WHEN** 用户点击"添加供应商"按钮
@@ -184,6 +204,16 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
- **WHEN** 模型列表为空 - **WHEN** 模型列表为空
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加" - **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加"
#### Scenario: 统一模型 ID 一键复制
- **WHEN** 模型表格渲染统一模型 ID 列
- **THEN** 统一模型 ID 文本右侧 SHALL 显示复制图标按钮
- **THEN** 复制图标 SHALL 使用 TDesign `Typography.Text``copyable` 属性
- **WHEN** 用户点击统一模型 ID 的复制按钮
- **THEN** 系统 SHALL 将完整统一模型 ID 写入剪贴板
- **THEN** 系统 SHALL 显示 `已复制统一模型 ID` 成功提示
- **THEN** 当统一模型 ID 为空时,复制按钮 SHALL 禁用
#### Scenario: 为供应商添加模型 #### Scenario: 为供应商添加模型
- **WHEN** 用户在展开行中点击"添加模型" - **WHEN** 用户在展开行中点击"添加模型"

View File

@@ -0,0 +1,167 @@
# git-hooks
## Purpose
定义仓库原生 Git hooks 的安装、校验、测试与跨平台执行规则,确保提交前快速检查和提交信息格式校验符合项目规范。
## Requirements
### Requirement: pre-commit hook 快速检查
pre-commit hook SHALL 在 `git commit` 执行前对 staged files 进行快速检查,仅检查本次提交涉及的文件。
#### Scenario: 无 Go 和前端文件变更时跳过
- **WHEN** staged files 中既无 `.go` 文件也无 `.ts`/`.tsx`/`.scss` 文件
- **THEN** pre-commit hook SHALL 直接通过,不运行任何 linter
#### Scenario: 冲突标记检测
- **WHEN** staged files 中包含 `<<<<<<<``=======``>>>>>>>` 冲突标记
- **THEN** pre-commit hook SHALL 报告错误并列出包含冲突的文件名
- **THEN** commit SHALL 被阻止
#### Scenario: Go 文件 lint 检查
- **WHEN** staged files 中包含 `.go` 文件
- **THEN** pre-commit hook SHALL 对 staged `.go` 文件运行 `golangci-lint run`(复用 `backend/.golangci.yml` 配置)
- **THEN** 若 lint 报告任何错误commit SHALL 被阻止
#### Scenario: 前端文件 lint 检查
- **WHEN** staged files 中包含 `.ts``.tsx` 文件
- **THEN** pre-commit hook SHALL 对 staged 前端文件运行 ESLint复用 `frontend/eslint.config.js` 配置)
- **THEN** 若 ESLint 报告任何错误commit SHALL 被阻止
#### Scenario: 前端文件格式检查
- **WHEN** staged files 中包含 `.ts``.tsx``.scss` 文件
- **THEN** pre-commit hook SHALL 对 staged 前端文件运行 Prettier 格式检查(复用 `frontend/.prettierrc` 配置)
- **THEN** 若存在格式不符合规范的文件commit SHALL 被阻止
#### Scenario: 大文件告警
- **WHEN** staged files 中存在超过 500KB 的文本文件
- **THEN** pre-commit hook SHALL 输出警告信息(不阻止提交),提示检查是否误提交
#### Scenario: commit 被阻止时显示修复提示
- **WHEN** pre-commit hook 检查失败
- **THEN** hook SHALL 输出明确的修复提示(如 `bun run fix`、手动解决冲突标记等)
### Requirement: commit-msg hook 校验提交信息格式
commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确保符合项目规范。提交描述 SHALL 使用中文;版本号、英文专有名词可与中文描述混用。
#### Scenario: 合法格式通过
- **WHEN** 提交信息首行格式为 `<类型>: <描述>`,类型为 `feat``fix``refactor``docs``style``test``chore` 之一
- **THEN** commit-msg hook SHALL 通过commit 正常执行
#### Scenario: 非法类型被拒绝
- **WHEN** 提交信息首行使用的类型不在允许列表中(如 `update: xxx`
- **THEN** commit-msg hook SHALL 报告错误显示允许的类型列表commit SHALL 被阻止
#### Scenario: 英文描述被拒绝
- **WHEN** 提交信息首行为 `feat: add auth`
- **THEN** commit-msg hook SHALL 报告错误,提示提交描述需使用中文
- **THEN** commit SHALL 被阻止
#### Scenario: 缺少冒号空格被拒绝
- **WHEN** 提交信息首行为 `feat:xxx`(冒号后无空格)或 `feat xxx`
- **THEN** commit-msg hook SHALL 报告格式错误commit SHALL 被阻止
#### Scenario: 首行过长告警
- **WHEN** 提交信息首行超过 72 个字符
- **THEN** commit-msg hook SHALL 输出警告(不阻止提交),提示首行应简短
#### Scenario: Merge commit 自动放行
- **WHEN** 提交信息首行以 `Merge` 开头
- **THEN** commit-msg hook SHALL 直接通过,不进行格式校验
#### Scenario: 格式错误时显示示例
- **WHEN** commit-msg hook 检查失败
- **THEN** hook SHALL 输出包含正确格式示例的错误信息(如 `feat: 添加供应商批量管理功能`
### Requirement: hooks-install 安装命令
`make hooks-install` SHALL 将 `scripts/git-hooks/` 下的 hook 脚本安装到 `.git/hooks/`,不覆盖 Git LFS 管理的 hook。
#### Scenario: 安装 pre-commit 和 commit-msg
- **WHEN** 执行 `make hooks-install`
- **THEN** `scripts/git-hooks/pre-commit` SHALL 被复制到 `.git/hooks/pre-commit`
- **THEN** `scripts/git-hooks/commit-msg` SHALL 被复制到 `.git/hooks/commit-msg`
- **THEN** 两个文件 SHALL 被设置为可执行(`chmod +x`
#### Scenario: 不覆盖 LFS 管理的 hook
- **WHEN** `.git/hooks/post-checkout``.git/hooks/post-commit``.git/hooks/post-merge``.git/hooks/pre-push` 已由 Git LFS 管理
- **THEN** `make hooks-install` SHALL NOT 覆盖或修改这些文件
#### Scenario: 重复安装幂等
- **WHEN** `make hooks-install` 被执行多次
- **THEN** hook 文件 SHALL 被正确覆盖更新,不会产生重复或损坏
#### Scenario: hooks-check 验证安装状态
- **WHEN** 执行 `make hooks-check`
- **THEN** 命令 SHALL 检查 `.git/hooks/pre-commit``.git/hooks/commit-msg` 是否存在且可执行
- **THEN** SHALL 输出每个 hook 的安装状态
### Requirement: hooks-test 回归测试命令
`make hooks-test` SHALL 运行仓库内 hook 回归测试,覆盖 commit-msg 格式校验和 pre-commit staged-file 检查,不污染真实 git index。
#### Scenario: 运行 hook 回归测试
- **WHEN** 执行 `make hooks-test`
- **THEN** SHALL 运行 `scripts/git-hooks/test-hooks.sh`
- **THEN** 测试 SHALL 使用临时 `GIT_INDEX_FILE` 构造 staged fixture
- **THEN** 若任一 hook 行为不符合预期,命令 SHALL 返回非零退出码
### Requirement: 跨平台可用
pre-commit 和 commit-msg hook 脚本 SHALL 可在 macOS 和 WindowsGit Bash上正常执行。
#### Scenario: macOS 上正常执行
- **WHEN** hook 脚本在 macOS 上被 git 调用
- **THEN** `#!/bin/sh` shebang SHALL 被系统正确解析
- **THEN** `exec make` SHALL 正确调用 Makefile target
#### Scenario: Windows Git Bash 上正常执行
- **WHEN** hook 脚本在 Windows 的 Git Bash 环境中被 git 调用
- **THEN** Git for Windows 自带的 sh.exe SHALL 正确解析 `#!/bin/sh`
- **THEN** `exec make` SHALL 正确调用 Makefile target依赖 Git Bash/MINGW64 环境中 `make` 可用)
- **THEN** Go 和 Bun 工具链 SHALL 通过 PATH 可被 Makefile 调用
### Requirement: pre-commit 核心逻辑在 Makefile 中复用
pre-commit hook 的检查逻辑 SHALL 通过 Makefile target 调用项目已有工具链,不重复实现 hook 框架逻辑。commit-msg hook SHALL 在脚本内直接完成格式校验。
#### Scenario: Go lint 复用后端配置
- **WHEN** pre-commit 需要检查 Go 文件
- **THEN** SHALL 调用 Makefile 逻辑,在 `backend/` 目录对 staged `.go` 文件运行 `go tool golangci-lint run`
- **THEN** SHALL 复用 `backend/.golangci.yml` 中的 lint 配置
#### Scenario: 前端 lint 使用 staged 文件参数
- **WHEN** pre-commit 需要检查前端文件
- **THEN** SHALL 调用 Makefile 逻辑,在 `frontend/` 目录对 staged 前端文件运行 ESLint 和 Prettier 的文件参数模式
- **THEN** SHALL NOT 在 pre-commit 阶段运行全量 `bun run check`
#### Scenario: 终端直接调试
- **WHEN** 开发者执行 `make _hooks-pre-commit`
- **THEN** SHALL 执行与 pre-commit hook 完全相同的检查逻辑
- **THEN** 输出 SHALL 与 hook 触发时一致

View File

@@ -54,13 +54,13 @@
### Requirement: 数据库初始化公共包 ### Requirement: 数据库初始化公共包
系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server``cmd/desktop` 共同调用。 系统 SHALL 提供 `internal/database` 公共包,封装数据库初始化、迁移执行和连接关闭逻辑,供 `cmd/server``cmd/desktop` 共同调用,并 SHALL 使用随应用构建产物打包的迁移资源执行运行时迁移
#### Scenario: 公共包 Init 函数 #### Scenario: 公共包 Init 函数
- **WHEN** 调用 `database.Init(cfg, logger)` - **WHEN** 调用 `database.Init(cfg, logger)`
- **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接 - **THEN** SHALL 根据 `cfg.Driver` 选择对应的 GORM 驱动打开连接
- **THEN** SHALL 执行对应方言的 goose 迁移 - **THEN** SHALL 使用随应用构建产物打包的迁移资源执行对应方言的 goose 迁移
- **THEN** SHALL 配置连接池参数 - **THEN** SHALL 配置连接池参数
- **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL` - **THEN** SHALL 在 `driver=sqlite` 时执行 `PRAGMA journal_mode=WAL`
- **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA - **THEN** SHALL 在 `driver=mysql` 时跳过 SQLite 专有 PRAGMA
@@ -71,11 +71,20 @@
- **WHEN** 调用 `database.Close(db)` - **WHEN** 调用 `database.Close(db)`
- **THEN** SHALL 获取底层 `sql.DB` 并关闭连接 - **THEN** SHALL 获取底层 `sql.DB` 并关闭连接
#### Scenario: 迁移目录选择 #### Scenario: 迁移方言资源选择
- **WHEN** 执行迁移 - **WHEN** 执行运行时迁移
- **THEN** SHALL 在 `driver=sqlite`使用 `migrations/sqlite/` 目录goose dialect 为 `sqlite3` - **THEN** SHALL 在 `driver=sqlite`选择 SQLite 方言迁移资源goose dialect 为 `sqlite3`
- **THEN** SHALL 在 `driver=mysql`使用 `migrations/mysql/` 目录goose dialect 为 `mysql` - **THEN** SHALL 在 `driver=mysql`选择 MySQL 方言迁移资源goose dialect 为 `mysql`
- **THEN** 运行时迁移资源 SHALL 来源于打包资源而非源码目录
- **THEN** SHALL 在方言子资源解析失败时返回明确错误并拒绝启动
#### Scenario: 公共包迁移资源来源
- **WHEN** 调用 `database.Init(cfg, logger)` 且当前工作目录不是仓库根目录或 `backend/` 目录
- **THEN** SHALL 仍能解析并执行对应方言的迁移资源
- **THEN** SHALL NOT 要求当前进程工作目录位于仓库根目录或 `backend/` 目录
- **THEN** SHALL NOT 依赖 `runtime.Caller` 推导源码路径
### Requirement: MySQL 方言迁移文件 ### Requirement: MySQL 方言迁移文件

View File

@@ -32,58 +32,127 @@
### Requirement: 三平台发布构建 ### Requirement: 三平台发布构建
系统 SHALL 在发布流水线中构建 server 与 desktop 的 Linux、Windows、macOS 三个平台产物 系统 SHALL 在发布流水线中构建 server、web 与 desktop 的发布产物,并覆盖 Linux、Windows、macOS 的目标架构和格式矩阵
#### Scenario: Linux 发布构建 #### Scenario: server 发布构建
- **WHEN** 发布流水线执行 Linux 构建 job - **WHEN** 发布流水线执行 server 发布构建
- **THEN** 系统 SHALL 在可访问 Go、Bun 和 Linux 桌面构建依赖的 shell 环境中执行 Linux 发布构建 - **THEN** 系统 SHALL 生成 `nex-server_<version>_linux_amd64.tar.gz`
- **AND** 系统 SHALL 生成 Linux server 发布资产 - **AND** 系统 SHALL 生成 `nex-server_<version>_linux_arm64.tar.gz`
- **AND** 系统 SHALL 生成 Linux desktop 发布资产 - **AND** 系统 SHALL 生成 `nex-server_<version>_macos_amd64.tar.gz`
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_arm64.tar.gz`
- **AND** 系统 SHALL 生成 `nex-server_<version>_macos_universal.tar.gz`
- **AND** 系统 SHALL 生成 `nex-server_<version>_windows_amd64.zip`
#### Scenario: Windows 发布构建 #### Scenario: web 发布构建
- **WHEN** 发布流水线执行 Windows 构建 job - **WHEN** 发布流水线执行 web 发布构建
- **THEN** 系统 SHALL 在包含 MSYS2 / MINGW64 构建工具且可访问 Go 与 Bun 工具链的 shell 环境中执行 Windows 发布构建 - **THEN** 系统 SHALL 使用 Bun 构建 `frontend/dist`
- **AND** 系统 SHALL 生成 Windows server 发布资产 - **AND** 系统 SHALL 将前端静态资源打包为 `nex-web_<version>.tar.gz`
- **AND** 系统 SHALL 生成 Windows desktop 发布资产 - **AND** server 发布资产 SHALL NOT 内置 Web 管理界面静态资源
#### Scenario: macOS 发布构建 #### Scenario: Linux desktop 发布构建
- **WHEN** 发布流水线执行 macOS 构建 job - **WHEN** 发布流水线执行 Linux desktop 发布构建
- **THEN** 系统 SHALL 在可访问 Go、Bun 和 macOS 打包工具链的 shell 环境中执行 macOS 发布构建 - **THEN** 系统 SHALL 在可访问 Go、Bun、CGO、GTK3、Ayatana AppIndicator 和 Linux 打包工具链的环境中构建
- **AND** 系统 SHALL 生成 darwin-amd64 server 发布资产 - **AND** 系统 SHALL `amd64``arm64` 分别生成 tar.gz、AppImage、deb 和 rpm desktop 发布资产
- **AND** 系统 SHALL 生成 darwin-arm64 server 发布资产 - **AND** Linux amd64 desktop 发布构建 SHALL 在 `ubuntu-latest` runner 上执行
- **AND** 系统 SHALL 生成 macOS desktop universal 发布资产 - **AND** Linux arm64 desktop 发布构建 SHALL 在 `ubuntu-24.04-arm` runner 上执行
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
#### Scenario: Windows desktop 发布构建
- **WHEN** 发布流水线执行 Windows desktop 发布构建
- **THEN** 系统 SHALL 在包含对应架构 MSYS2/MinGW 或等价 CGO 工具链的环境中构建
- **AND** Windows amd64 desktop 发布构建 SHALL 在 `windows-latest` runner 上的 MSYS2 MINGW64 环境中执行
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_windows_amd64.zip`
- **AND** 系统 SHALL NOT 在构建步骤中显式传递 TARGET_ARCH 参数
#### Scenario: macOS desktop 发布构建
- **WHEN** 发布流水线执行 macOS desktop 发布构建
- **THEN** 系统 SHALL 在可访问 Go、Bun、Xcode 命令行工具、`lipo``hdiutil` 和 zip 打包工具的 macOS 环境中构建
- **AND** 系统 SHALL 在 ARM64 macOS runner 上编译 amd64 和 arm64 双架构二进制并使用 `lipo` 合并为 universal binary
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.zip`
- **AND** 系统 SHALL 生成 `nex-desktop_<version>_macos_universal.dmg`
#### Scenario: 原生架构构建
- **WHEN** 发布流水线执行 Linux 或 Windows 的 server/desktop 构建步骤
- **THEN** 系统 SHALL NOT 显式传递 TARGET_ARCH 参数
- **AND** Makefile SHALL 通过 `go env GOARCH` 自动检测目标架构
- **AND** 原生 runner 的实际架构 SHALL 与 `go env GOARCH` 返回值一致
### Requirement: 三平台发布构建预检 ### Requirement: 三平台发布构建预检
系统 SHALL 在正式执行各平台 `make release-assets-*` 前验证对应发布 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。 系统 SHALL 在正式执行各平台 release 构建前验证对应 job 的关键工具链可用性,并在环境不完整时快速失败且输出明确诊断。
#### Scenario: Linux 预检通过后开始构建 #### Scenario: Linux 预检通过后开始构建
- **WHEN** Linux 发布 job 中的 `go``bun` Linux 桌面构建依赖均可用 - **WHEN** Linux 发布 job 中的 `go``bun``gcc``pkg-config`、GTK3、Ayatana AppIndicator 和 Linux 打包工具均可用
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径 - **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
- **AND** 系统 SHALL 继续执行 `make release-assets-linux` - **AND** 系统 SHALL 继续执行对应 Linux release 构建
#### Scenario: Windows 预检通过后开始构建 #### Scenario: Windows 预检通过后开始构建
- **WHEN** Windows 发布 job 中的 `go``bun` 与 MSYS2 构建工具均可用 - **WHEN** Windows 发布 job 中的 `go``bun``make`、对应架构 CGO 编译器和 resource 生成工具均可用
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径 - **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
- **AND** 系统 SHALL 继续执行 `make release-assets-windows` - **AND** 系统 SHALL 继续执行对应 Windows release 构建
#### Scenario: macOS 预检通过后开始构建 #### Scenario: macOS 预检通过后开始构建
- **WHEN** macOS 发布 job 中的 `go``bun` 与 macOS 打包工具均可用 - **WHEN** macOS 发布 job 中的 `go``bun``ditto``lipo``vtool``hdiutil` 均可用
- **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径 - **THEN** 系统 SHALL 输出关键工具的版本信息或解析路径
- **AND** 系统 SHALL 继续执行 `make release-assets-macos` - **AND** 系统 SHALL 继续执行对应 macOS release 构建
#### Scenario: 任一平台预检发现工具缺失 #### Scenario: web 预检通过后开始构建
- **WHEN** 任一平台发布 job 中存在关键工具不可用 - **WHEN** web 发布 job 中`bun` 和前端构建依赖均可用
- **THEN** 系统 SHALL 输出 Bun 版本信息
- **AND** 系统 SHALL 继续执行 web release 构建
#### Scenario: 任一预检发现工具缺失
- **WHEN** 任一发布 job 中存在关键工具不可用
- **THEN** 发布流水线 SHALL 在正式构建前失败 - **THEN** 发布流水线 SHALL 在正式构建前失败
- **AND** 系统 SHALL 在日志中标识缺失的工具链名称 - **AND** 系统 SHALL 在日志中标识缺失的工具链名称
### Requirement: 发布流水线 LFS 资产拉取
发布流水线 SHALL 在所有会 checkout 仓库并参与版本校验、web 构建、server 构建或 desktop 构建的 job 中拉取 Git LFS 真实文件,确保发布构建读取到真实二进制资产而非 LFS pointer 文本。
#### Scenario: 发布 job 获取真实 LFS 图标资产
- **WHEN** 发布流水线执行任一参与版本校验、web 构建、server 构建或 desktop 构建的 job 的 checkout 步骤
- **THEN** checkout 步骤 SHALL 拉取 Git LFS 文件
- **AND** `assets/icon.ico``assets/icon.icns``assets/icon.png``frontend/public/icon.png` SHALL 在后续步骤中表现为真实图标文件而非 LFS pointer 文本
#### Scenario: 新增矩阵 job 获取真实 LFS 资产
- **WHEN** 发布流水线新增 server、web、desktop、platform 或 arch 矩阵 job
- **THEN** 该 job 的 checkout 步骤 SHALL 使用与现有发布 job 一致的 Git LFS 拉取配置
### Requirement: 发布资产图标预检
发布流水线 SHALL 在正式执行任何需要图标资产、前端 public 图标或 desktop 打包资源的发布构建前校验关键图标资产可用,并在检测到 LFS pointer 或错误格式时快速失败且输出明确诊断。
#### Scenario: 图标资产为 LFS pointer
- **WHEN** 发布资产预检发现关键图标文件内容为 Git LFS pointer 文本
- **THEN** 发布流水线 SHALL 在执行对应 release 构建前失败
- **AND** 系统 SHALL 在日志中标识对应图标文件需要拉取 Git LFS 真实内容
#### Scenario: 图标资产格式无效
- **WHEN** 发布资产预检发现关键图标文件不是对应格式的有效资源
- **THEN** 发布流水线 SHALL 在执行对应 release 构建前失败
- **AND** 系统 SHALL 在日志中标识格式无效的图标文件路径
#### Scenario: 图标资产预检通过
- **WHEN** `assets/icon.ico``assets/icon.icns``assets/icon.png``frontend/public/icon.png` 均为真实且格式可用的图标资产
- **THEN** 发布流水线 SHALL 继续执行依赖这些资产的 release 构建
### Requirement: 发布流水线运行时兼容性 ### Requirement: 发布流水线运行时兼容性
系统 SHALL 保持与 GitHub-hosted runner 当前受支持的 workflow runtime 约束兼容,避免发布流程依赖已声明弃用的 runtime 或执行约束。 系统 SHALL 保持与 GitHub-hosted runner 当前受支持的 workflow runtime 约束兼容,避免发布流程依赖已声明弃用的 runtime 或执行约束。
@@ -100,34 +169,83 @@
### Requirement: 版本化发布资产命名 ### Requirement: 版本化发布资产命名
系统 SHALL 为 server 与 desktop 发布资产使用包含统一版本号和目标平台信息的文件名,确保 Release 页面可直接区分产物用途平台。 系统 SHALL 为 server、web 与 desktop 发布资产使用包含统一版本号、组件、目标平台和目标架构信息的文件名,确保 Release 页面可直接区分产物用途平台、架构和格式
#### Scenario: server 资产命名 #### Scenario: server 资产命名
- **WHEN** 当前发布版本为 `1.2.3` - **WHEN** 当前发布版本为 `1.2.3`
- **THEN** Linux server 发布资产文件名 SHALL 包含 `1.2.3``linux``amd64` - **THEN** Linux server 发布资产文件名 SHALL `nex-server_1.2.3_linux_amd64.tar.gz``nex-server_1.2.3_linux_arm64.tar.gz`
- **AND** Windows server 发布资产文件名 SHALL 包含 `1.2.3``windows``amd64` - **AND** macOS server 发布资产文件名 SHALL `nex-server_1.2.3_macos_amd64.tar.gz``nex-server_1.2.3_macos_arm64.tar.gz``nex-server_1.2.3_macos_universal.tar.gz`
- **AND** macOS server 发布资产文件名 SHALL 分别包含 `1.2.3``darwin``amd64``1.2.3``darwin``arm64` - **AND** Windows server 发布资产文件名 SHALL `nex-server_1.2.3_windows_amd64.zip`
#### Scenario: web 资产命名
- **WHEN** 当前发布版本为 `1.2.3`
- **THEN** web 发布资产文件名 SHALL 为 `nex-web_1.2.3.tar.gz`
- **AND** web 发布资产文件名 SHALL NOT 包含平台或架构字段
#### Scenario: desktop 资产命名 #### Scenario: desktop 资产命名
- **WHEN** 当前发布版本为 `1.2.3` - **WHEN** 当前发布版本为 `1.2.3`
- **THEN** Linux desktop 发布资产文件名 SHALL 包含 `1.2.3``linux` - **THEN** Linux desktop 发布资产文件名 SHALL 使用 `nex-desktop_1.2.3_linux_<arch>.<format>` 格式
- **AND** Windows desktop 发布资产文件名 SHALL 包含 `1.2.3``windows` - **AND** Windows desktop 发布资产文件名 SHALL `nex-desktop_1.2.3_windows_amd64.zip`
- **AND** macOS desktop universal 发布资产文件名 SHALL 包含 `1.2.3``macOS` - **AND** macOS desktop 发布资产文件名 SHALL `nex-desktop_1.2.3_macos_universal.zip``nex-desktop_1.2.3_macos_universal.dmg`
- **AND** 发布资产文件名中的 macOS 平台字段 SHALL 使用 `macos` 而非 `darwin`
### Requirement: Draft Release 组装 ### Requirement: Draft Release 组装
系统 SHALL 将发布流水线产物上传到 GitHub Draft Release由人工确认后再公开发布。 系统 SHALL 将发布流水线产物上传到 GitHub Draft Release由人工确认后再公开发布,并 SHALL 生成覆盖全部发布资产的校验和清单
#### Scenario: 发布成功时创建 Draft Release #### Scenario: 发布成功时创建 Draft Release
- **WHEN** 版本校验通过且三平台发布资产构建完成 - **WHEN** 版本校验通过且 server、web、desktop 的全部目标发布资产构建完成
- **THEN** 系统 SHALL 创建或更新与该 tag 对应的 GitHub Draft Release - **THEN** 系统 SHALL 创建或更新与该 tag 对应的 GitHub Draft Release
- **AND** 系统 SHALL 上传 server 与 desktop 的全部发布资产 - **AND** 系统 SHALL 上传 server、web 与 desktop 的全部发布资产
- **AND** 系统 SHALL 上传 `SHA256SUMS`
#### Scenario: 校验和覆盖全部资产
- **WHEN** Draft Release 组装步骤生成 `SHA256SUMS`
- **THEN** `SHA256SUMS` SHALL 包含除自身以外的全部发布资产文件
- **AND** `SHA256SUMS` 中的文件名 SHALL 与实际上传的 release asset 文件名一致
#### Scenario: 构建失败时阻止完成发布 #### Scenario: 构建失败时阻止完成发布
- **WHEN** 任一平台发布资产构建失败或版本校验失败 - **WHEN** 任一目标发布资产构建失败、打包失败、校验失败、artifact 上传为空或版本校验失败
- **THEN** 发布流水线 SHALL 失败 - **THEN** 发布流水线 SHALL 失败
- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果 - **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果
#### Scenario: artifact 缺失时快速失败
- **WHEN** 任一构建 job 尝试上传 release artifact 但匹配不到目标文件
- **THEN** 该 job SHALL 失败
- **AND** Draft Release 组装 SHALL NOT 继续发布不完整资产集合
### Requirement: 发布产物运行时资源完整性
发布流水线 SHALL 确保 server 和 desktop 发布产物包含运行时启动所需的数据库迁移资源,且 SHALL NOT 依赖 CI runner 的源码路径。
#### Scenario: desktop 发布产物包含迁移资源
- **WHEN** 发布流水线构建 desktop 发布资产
- **THEN** 生成的 desktop 二进制或应用包 SHALL 包含 SQLite 和 MySQL 迁移资源
- **THEN** macOS `.app``.zip``.dmg` 安装后 SHALL 不需要仓库源码目录即可执行启动迁移
#### Scenario: server 发布产物包含迁移资源
- **WHEN** 发布流水线构建 server 发布资产
- **THEN** 生成的 server 二进制 SHALL 包含 SQLite 和 MySQL 迁移资源
- **THEN** server 发布资产 SHALL 不需要仓库源码目录即可执行启动迁移
#### Scenario: 发布产物不泄漏构建机迁移路径
- **WHEN** 发布流水线完成 server 或 desktop 构建
- **THEN** 构建产物 SHALL NOT 在运行时使用 `/Users/runner/work/.../backend/migrations/...` 作为迁移目录
- **THEN** 若检测到运行时迁移路径依赖 CI runner 源码路径,发布构建 SHALL 失败
#### Scenario: 发布构建迁移资源验证
- **WHEN** 发布流水线执行 release 构建验证
- **THEN** 验证 SHALL 覆盖迁移资源可用性
- **THEN** 验证 SHALL 覆盖安装包内应用在无源码目录环境下可解析迁移资源
- **THEN** 验证 MAY 通过 Go 测试或轻量资源自检完成,不要求启动图形托盘界面

View File

@@ -279,3 +279,27 @@
- **WHEN** mockgen 生成的 mock 就绪 - **WHEN** mockgen 生成的 mock 就绪
- **THEN** handler 测试中的手写 mock SHALL 被替换为生成的 mock - **THEN** handler 测试中的手写 mock SHALL 被替换为生成的 mock
- **THEN** 所有测试 SHALL 继续通过,行为不变 - **THEN** 所有测试 SHALL 继续通过,行为不变
### Requirement: 运行时迁移资源测试覆盖
系统 SHALL 覆盖打包迁移资源解析和启动迁移回归场景,确保发布产物不依赖源码迁移目录。
#### Scenario: 运行时迁移资源解析测试
- **WHEN** 运行 database 包单元测试
- **THEN** SHALL 验证 `database.Init` 在当前工作目录不是仓库根目录或 `backend/` 目录时仍能执行迁移
- **THEN** SHALL 验证迁移资源不依赖 `runtime.Caller` 推导的源码路径
- **THEN** SHALL 覆盖 SQLite 方言迁移资源解析
#### Scenario: 双方言迁移资源选择测试
- **WHEN** 运行迁移资源选择相关测试
- **THEN** SHALL 验证 SQLite 方言资源可被解析
- **THEN** SHALL 验证 MySQL 方言资源可被解析
- **THEN** SHALL 验证未知或非法 driver 不会被静默映射到错误方言资源
#### Scenario: desktop 打包迁移资源测试
- **WHEN** 运行 desktop 专属测试
- **THEN** SHALL 验证 desktop 模式启动数据库迁移时使用打包迁移资源
- **THEN** SHALL 验证应用在发布产物环境中可执行迁移而不依赖源码迁移目录

View File

@@ -90,22 +90,34 @@
### Requirement: 版本升迁 Makefile 编排 ### Requirement: 版本升迁 Makefile 编排
`make version-bump` SHALL 编排完整的版本升迁流程:工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。不传 `BUMP` 参数时 SHALL 默认执行 `BUMP=patch` `make version-bump` SHALL 编排完整的版本升迁流程:全量 lint 检查 → 全量单元测试 → 工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。不传 `BUMP` 参数时 SHALL 默认执行 `BUMP=patch`lint/test 前置检查 SHALL NOT 替代工作区干净检查。
#### Scenario: 完整升迁流程 #### Scenario: 完整升迁流程
- **WHEN** 执行 `make version-bump BUMP=minor`,工作区干净,当前版本 `0.1.0` - **WHEN** 执行 `make version-bump BUMP=minor`,工作区干净,当前版本 `0.1.0`
- **THEN** Makefile SHALL 依次执行:工作区检查 → `version bump minor``git add VERSION frontend/``git commit -m "chore: 版本升迁 v0.2.0"``git tag v0.2.0` - **THEN** Makefile SHALL 依次执行:`make lint``make test`工作区检查 → `version bump minor``git add VERSION frontend/``git commit -m "chore: 版本升迁 v0.2.0"``git tag v0.2.0`
#### Scenario: 不传 BUMP 默认 patch #### Scenario: 不传 BUMP 默认 patch
- **WHEN** 执行 `make version-bump`,工作区干净,当前版本 `0.1.0` - **WHEN** 执行 `make version-bump`,工作区干净,当前版本 `0.1.0`
- **THEN** Makefile SHALL 等效于执行 `make version-bump BUMP=patch`,将版本更新为 `0.1.1` - **THEN** Makefile SHALL 等效于执行 `make version-bump BUMP=patch`,将版本更新为 `0.1.1`
#### Scenario: lint 失败时终止
- **WHEN** 执行 `make version-bump`,但 `make lint` 报告错误
- **THEN** Makefile SHALL 以非零退出码失败SHALL NOT 执行 `version bump`、git commit、git tag
- **THEN** SHALL 输出错误信息提示修复 lint 问题后重试
#### Scenario: test 失败时终止
- **WHEN** 执行 `make version-bump`,但 `make test` 报告测试失败
- **THEN** Makefile SHALL 以非零退出码失败SHALL NOT 执行 `version bump`、git commit、git tag
- **THEN** SHALL 输出错误信息提示修复测试失败后重试
#### Scenario: 工作区不干净 #### Scenario: 工作区不干净
- **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动 - **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或暂存改动 - **THEN** Makefile SHALL 以非零退出码失败并提示先提交或清理改动
#### Scenario: 支持指定版本号 #### Scenario: 支持指定版本号

View File

@@ -8,17 +8,26 @@
### Requirement: 根目录公开命令分层 ### Requirement: 根目录公开命令分层
根目录 `Makefile` SHALL 仅暴露全局命令、版本命令、server 产品命令、desktop 产品命令和 release 命令,不再作为 backend 局部维护命令或内部打包步骤的公开入口。 根目录 `Makefile` SHALL 仅暴露全局命令、版本命令、server 产品命令、desktop 产品命令和 release 命令,不再作为 backend 局部维护命令或内部打包步骤的公开入口。release 命令 SHALL 使用 `release-assets` 前缀,并 SHALL 通过清晰的目标名或变量参数表达 component、platform、arch 和 format。
#### Scenario: 查看根目录公开命令 #### Scenario: 查看根目录公开命令
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target - **WHEN** 开发者查看根目录 `Makefile` 的公开 target
- **THEN** SHALL 仅看到 `lint``test``clean``version-sync``version-check``server-run``server-build``server-lint``server-test``server-clean``desktop-build-mac``desktop-build-win``desktop-build-linux``desktop-lint``desktop-test``desktop-clean``release-assets-linux``release-assets-windows``release-assets-macos` 这类公共入口 - **THEN** SHALL 仅看到 `lint``test``clean``version-sync``version-check``server-run``server-build``server-lint``server-test``server-clean``desktop-build-mac``desktop-build-win``desktop-build-linux``desktop-lint``desktop-test``desktop-clean``release-assets` 前缀的 release 公共入口
- **AND** release 公共入口 SHALL 能覆盖 server、web、desktop 的目标发布产物
#### Scenario: 根目录不暴露局部和内部命令 #### Scenario: 根目录不暴露局部和内部命令
- **WHEN** 开发者查看根目录 `Makefile` 的公开 target - **WHEN** 开发者查看根目录 `Makefile` 的公开 target
- **THEN** SHALL NOT 暴露 `backend-*``frontend-*`、数据库迁移命令、MySQL 专项测试命令或 `desktop-prepare-*` 之类内部步骤 - **THEN** SHALL NOT 暴露 `backend-*``frontend-*`、数据库迁移命令、MySQL 专项测试命令或 `desktop-prepare-*` 之类内部步骤
- **THEN** SHALL NOT 暴露 `dev``build``all``desktop-dev``desktop-build` 这类模糊或聚合式公共命令 - **THEN** SHALL NOT 暴露 `dev``build``all``desktop-dev``desktop-build` 这类模糊或聚合式公共命令
#### Scenario: release 内部步骤保持内部化
- **WHEN** 根目录 `Makefile` 需要复用 release 构建、打包、校验辅助步骤
- **THEN** 内部辅助 target SHALL 使用 `_` 前缀或 Make 变量参数化方式表达
- **AND** 内部辅助 target SHALL NOT 成为文档化的公共入口
### Requirement: 全局质量与清理命令 ### Requirement: 全局质量与清理命令
根目录 `Makefile` SHALL 提供 `lint``test``clean` 作为全仓默认入口。 根目录 `Makefile` SHALL 提供 `lint``test``clean` 作为全仓默认入口。
@@ -97,12 +106,33 @@
### Requirement: Release 命令沿用根目录入口 ### Requirement: Release 命令沿用根目录入口
根目录 `Makefile` SHALL 继续提供 `release-assets-*` 作为发布资产入口,并与新的版本校验规则保持一致。 根目录 `Makefile` SHALL 继续提供 `release-assets` 前缀 target 作为发布资产入口,并与版本校验、发布资产预检和多组件打包规则保持一致。
#### Scenario: 执行 release 资产命令 #### Scenario: 执行 release 资产命令
- **WHEN** 执行 `make release-assets-linux``make release-assets-windows``make release-assets-macos`
- **WHEN** 执行任一 `release-assets` 前缀的公共 release target
- **THEN** SHALL 在构建发布资产前执行版本一致性校验 - **THEN** SHALL 在构建发布资产前执行版本一致性校验
- **THEN** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件 - **AND** SHALL 在需要图标或桌面资源的构建前执行发布资产预检
- **AND** SHALL NOT 隐式执行 `version-sync` 或修改版本镜像文件
#### Scenario: release target 职责清晰
- **WHEN** GitHub Actions 调用根目录 `Makefile` 生成 release 产物
- **THEN** 对应 release target SHALL 明确生成的 component、platform、arch 或 format 范围
- **AND** GitHub Actions SHALL NOT 以内联脚本替代 Makefile 中已有的核心构建和打包逻辑
#### Scenario: web release 产物生成
- **WHEN** 执行 web release 资产命令
- **THEN** SHALL 使用 Bun 构建 `frontend/dist`
- **AND** SHALL 打包生成 `nex-web_<version>.tar.gz`
- **AND** SHALL NOT 修改前端版本镜像文件
#### Scenario: checksum release 产物生成
- **WHEN** 执行 release 汇总或 Draft Release 组装相关命令
- **THEN** SHALL 能基于当前 release 产物目录生成 `SHA256SUMS`
- **AND** `SHA256SUMS` SHALL 覆盖除自身以外的全部 release 资产
### Requirement: Backend 局部命令下沉 ### Requirement: Backend 局部命令下沉

5
packaging/linux/AppRun Normal file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
APPDIR=$(dirname "$(readlink -f "$0")")
exec "$APPDIR/usr/bin/nex" "$@"

View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Nex
Comment=AI Gateway
Exec=nex
Icon=nex
Terminal=false
Categories=Development;Network;
StartupNotify=false

29
packaging/linux/nex.spec Normal file
View File

@@ -0,0 +1,29 @@
Name: nex
Version: %{nex_version}
Release: 1%{?dist}
Summary: AI Gateway desktop application
License: Apache-2.0
URL: https://github.com/nex/gateway
Requires: gtk3
Requires: libayatana-appindicator-gtk3
Requires: xdg-utils
%description
Nex is an AI Gateway desktop application.
%prep
%build
%install
mkdir -p %{buildroot}/usr/bin
install -m 0755 %{nex_binary} %{buildroot}/usr/bin/nex
mkdir -p %{buildroot}/usr/share/applications
install -m 0644 %{nex_desktop_file} %{buildroot}/usr/share/applications/nex.desktop
mkdir -p %{buildroot}/usr/share/icons/hicolor
cp -a %{nex_icons_dir}/. %{buildroot}/usr/share/icons/hicolor/
%files
/usr/bin/nex
/usr/share/applications/nex.desktop
/usr/share/icons/hicolor/*/apps/nex.png

42
scripts/git-hooks/commit-msg Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/sh
set -e
MSG_FILE=$1
if [ ! -f "$MSG_FILE" ]; then
printf '%s\n' '提交信息文件不存在。' >&2
exit 1
fi
IFS= read -r FIRST_LINE < "$MSG_FILE" || FIRST_LINE=
case "$FIRST_LINE" in
Merge*)
exit 0
;;
esac
if ! printf '%s\n' "$FIRST_LINE" | grep -Eq '^(feat|fix|refactor|docs|style|test|chore): .+$'; then
cat >&2 <<'EOF'
提交信息格式错误。
格式: <类型>: <简短描述>
类型: feat / fix / refactor / docs / style / test / chore
示例:
feat: 添加供应商批量管理功能
fix: 修复流式响应断连问题
chore: 版本升迁 v0.2.0
EOF
exit 1
fi
DESCRIPTION=${FIRST_LINE#*: }
if printf '%s\n' "$DESCRIPTION" | LC_ALL=C grep -Eq '^[ -~]+$'; then
printf '%s\n' '提交描述需使用中文。' >&2
exit 1
fi
if [ ${#FIRST_LINE} -gt 72 ]; then
printf '%s\n' '警告: 提交信息首行超过 72 个字符,建议保持简短。' >&2
fi

12
scripts/git-hooks/pre-commit Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
set -e
ROOT_DIR=$(git rev-parse --show-toplevel)
cd "$ROOT_DIR"
command -v make >/dev/null 2>&1 || {
printf '%s\n' '缺少 make 命令,请先安装 Make 或使用 Git Bash/MINGW64 环境。' >&2
exit 1
}
exec make _hooks-pre-commit

134
scripts/git-hooks/test-hooks.sh Executable file
View File

@@ -0,0 +1,134 @@
#!/bin/sh
set -eu
ROOT_DIR=$(git rev-parse --show-toplevel)
cd "$ROOT_DIR"
TMP_DIR=${TMPDIR:-/tmp}/nex-hooks-test.$$
mkdir -p "$TMP_DIR"
cleanup() {
rm -f \
backend/pkg/buildinfo/hook_bad_test_fixture.go \
frontend/src/hook_bad_fixture.ts \
frontend/src/hook_format_fixture.ts \
docs/hook-doc-fixture.md \
docs/hook-conflict-fixture.md \
docs/hook-large-fixture.txt
rm -rf "$TMP_DIR"
}
trap cleanup EXIT HUP INT TERM
pass() {
printf 'OK: %s\n' "$1"
}
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
write_msg() {
file=$1
shift
printf '%s\n' "$*" > "$file"
}
expect_success() {
name=$1
shift
if "$@" > "$TMP_DIR/out" 2>&1; then
pass "$name"
else
cat "$TMP_DIR/out" >&2
fail "$name"
fi
}
expect_failure() {
name=$1
shift
if "$@" > "$TMP_DIR/out" 2>&1; then
cat "$TMP_DIR/out" >&2
fail "$name"
fi
pass "$name"
}
run_precommit_for() {
index=$TMP_DIR/index
rm -f "$index"
GIT_INDEX_FILE=$index git read-tree HEAD
for file in "$@"; do
GIT_INDEX_FILE=$index git add -f "$file"
done
GIT_INDEX_FILE=$index make _hooks-pre-commit
}
MSG_FILE=$TMP_DIR/commit-msg.txt
write_msg "$MSG_FILE" 'feat: 添加 hook 测试'
expect_success 'commit-msg accepts Chinese description' scripts/git-hooks/commit-msg "$MSG_FILE"
write_msg "$MSG_FILE" 'feat: add hook tests'
expect_failure 'commit-msg rejects English-only description' scripts/git-hooks/commit-msg "$MSG_FILE"
write_msg "$MSG_FILE" 'update: 添加 hook 测试'
expect_failure 'commit-msg rejects invalid type' scripts/git-hooks/commit-msg "$MSG_FILE"
write_msg "$MSG_FILE" 'Merge branch feature'
expect_success 'commit-msg accepts merge commits' scripts/git-hooks/commit-msg "$MSG_FILE"
cat > backend/pkg/buildinfo/hook_bad_test_fixture.go <<'EOF'
package buildinfo
import "fmt"
func hookBadTestFixture() {
fmt.Println("bad")
}
EOF
expect_failure 'pre-commit rejects Go lint errors' run_precommit_for backend/pkg/buildinfo/hook_bad_test_fixture.go
rm -f backend/pkg/buildinfo/hook_bad_test_fixture.go
cat > frontend/src/hook_bad_fixture.ts <<'EOF'
console.log('bad')
EOF
expect_failure 'pre-commit rejects frontend lint errors' run_precommit_for frontend/src/hook_bad_fixture.ts
rm -f frontend/src/hook_bad_fixture.ts
cat > frontend/src/hook_format_fixture.ts <<'EOF'
const hookFormatFixture={foo:"bar"}
export { hookFormatFixture }
EOF
expect_failure 'pre-commit rejects frontend format errors' run_precommit_for frontend/src/hook_format_fixture.ts
rm -f frontend/src/hook_format_fixture.ts
cat > docs/hook-doc-fixture.md <<'EOF'
hook doc fixture
EOF
expect_success 'pre-commit skips non-code staged files' run_precommit_for docs/hook-doc-fixture.md
rm -f docs/hook-doc-fixture.md
cat > docs/hook-conflict-fixture.md <<'EOF'
<<<<<<< HEAD
conflict
=======
other
>>>>>>> branch
EOF
expect_failure 'pre-commit rejects conflict markers' run_precommit_for docs/hook-conflict-fixture.md
rm -f docs/hook-conflict-fixture.md
i=0
while [ "$i" -lt 40000 ]; do
printf 'large hook fixture line\n'
i=$((i + 1))
done > docs/hook-large-fixture.txt
if run_precommit_for docs/hook-large-fixture.txt > "$TMP_DIR/out" 2>&1 && grep -q 'Warning: large staged text file' "$TMP_DIR/out"; then
pass 'pre-commit warns for large text files'
else
cat "$TMP_DIR/out" >&2
fail 'pre-commit warns for large text files'
fi
rm -f docs/hook-large-fixture.txt

30
scripts/push-all-remotes.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR=$(git rev-parse --show-toplevel)
BRANCH=$(git branch --show-current)
if [[ -z "$BRANCH" ]]; then
echo "当前仓库处于 detached HEAD 状态,无法推送当前分支" >&2
exit 1
fi
cd "$ROOT_DIR"
REMOTES=()
while IFS= read -r REMOTE; do
REMOTES+=("$REMOTE")
done < <(git remote)
if [[ ${#REMOTES[@]} -eq 0 ]]; then
echo "当前仓库未配置任何远端" >&2
exit 1
fi
for REMOTE in "${REMOTES[@]}"; do
echo "推送分支 $BRANCH 和 tags 到远端 $REMOTE"
git push "$REMOTE" "$BRANCH" --tags
done
echo "已推送分支 $BRANCH 和 tags 到所有远端"

View File

@@ -53,6 +53,11 @@ func run(args []string) error {
return printMacOSPlist(root, args[1]) return printMacOSPlist(root, args[1])
case "asset-name": case "asset-name":
return printAssetName(root, args[1:]) return printAssetName(root, args[1:])
case "release-assets-check":
if len(args) != 1 {
return fmt.Errorf("release-assets-check 不需要额外参数")
}
return projectversion.CheckReleaseAssets(root)
default: default:
return usageError() return usageError()
} }
@@ -101,8 +106,8 @@ func printMacOSPlist(root, minMacOSVersion string) error {
} }
func printAssetName(root string, args []string) error { func printAssetName(root string, args []string) error {
if len(args) < 2 { if len(args) == 0 {
return fmt.Errorf("asset-name 至少需要 kind 和 platform 参数") return fmt.Errorf("asset-name 需要组件参数: server|web|desktop")
} }
version, err := projectversion.ReadString(root) version, err := projectversion.ReadString(root)
@@ -110,30 +115,31 @@ func printAssetName(root string, args []string) error {
return err return err
} }
var platform, arch, format string
switch args[0] { switch args[0] {
case "server": case "server", "desktop":
if len(args) != 3 { if len(args) != 4 {
return fmt.Errorf("server 资产命名需要 platformarch 参数") return fmt.Errorf("%s 资产命名需要 platformarch 和 format 参数", args[0])
} }
name, nameErr := projectversion.ServerAssetName(version, args[1], args[2]) platform = args[1]
if nameErr != nil { arch = args[2]
return nameErr format = args[3]
} case "web":
fmt.Println(name)
return nil
case "desktop":
if len(args) != 2 { if len(args) != 2 {
return fmt.Errorf("desktop 资产命名只需要 platform 参数") return fmt.Errorf("web 资产命名只需要 format 参数")
} }
name, nameErr := projectversion.DesktopAssetName(version, args[1]) format = args[1]
default:
return fmt.Errorf("不支持的资产组件 %q", args[0])
}
name, nameErr := projectversion.ReleaseAssetName(version, args[0], platform, arch, format)
if nameErr != nil { if nameErr != nil {
return nameErr return nameErr
} }
fmt.Println(name) fmt.Println(name)
return nil return nil
default:
return fmt.Errorf("不支持的资产类型 %q", args[0])
}
} }
func mustGetwd() string { func mustGetwd() string {
@@ -147,5 +153,5 @@ func mustGetwd() string {
} }
func usageError() error { func usageError() error {
return fmt.Errorf("用法: version <print|sync|check|verify-tag|bump|macos-plist|asset-name>") return fmt.Errorf("用法: version <print|sync|check|verify-tag|bump|macos-plist|asset-name|release-assets-check>")
} }

View File

@@ -0,0 +1,69 @@
package projectversion
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
)
var releaseAssetChecks = []releaseAssetCheck{
{
path: "assets/icon.ico",
description: "Windows ICO 图标",
magic: []byte{0x00, 0x00, 0x01, 0x00},
},
{
path: "assets/icon.icns",
description: "macOS ICNS 图标",
magic: []byte("icns"),
},
{
path: "assets/icon.png",
description: "PNG 图标",
magic: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
},
{
path: "frontend/public/icon.png",
description: "前端 PNG 图标",
magic: []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
},
}
var gitLFSPointerPrefix = []byte("version https://git-lfs.github.com/spec/v1")
type releaseAssetCheck struct {
path string
description string
magic []byte
}
func CheckReleaseAssets(root string) error {
var errs []error
for _, check := range releaseAssetChecks {
if err := checkReleaseAsset(root, check); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func checkReleaseAsset(root string, check releaseAssetCheck) error {
content, err := os.ReadFile(filepath.Join(root, check.path))
if err != nil {
return fmt.Errorf("%s 不可读取: %w", check.path, err)
}
if bytes.HasPrefix(content, gitLFSPointerPrefix) {
return fmt.Errorf("%s 是 Git LFS pointer请先拉取 Git LFS 真实内容", check.path)
}
if !bytes.HasPrefix(content, check.magic) {
return fmt.Errorf("%s 不是有效的%s", check.path, check.description)
}
return nil
}

View File

@@ -0,0 +1,58 @@
package projectversion
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckReleaseAssets(t *testing.T) {
t.Run("valid assets", func(t *testing.T) {
root := setupReleaseAssetRoot(t)
require.NoError(t, CheckReleaseAssets(root))
})
t.Run("lfs pointer", func(t *testing.T) {
root := setupReleaseAssetRoot(t)
writeReleaseAsset(t, root, "assets/icon.ico", []byte("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n"))
err := CheckReleaseAssets(root)
require.Error(t, err)
assert.Contains(t, err.Error(), "assets/icon.ico 是 Git LFS pointer")
})
t.Run("invalid format", func(t *testing.T) {
root := setupReleaseAssetRoot(t)
writeReleaseAsset(t, root, "frontend/public/icon.png", []byte("not a png"))
err := CheckReleaseAssets(root)
require.Error(t, err)
assert.Contains(t, err.Error(), "frontend/public/icon.png 不是有效的前端 PNG 图标")
})
}
func setupReleaseAssetRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeReleaseAsset(t, root, "assets/icon.ico", []byte{0x00, 0x00, 0x01, 0x00, 0x01})
writeReleaseAsset(t, root, "assets/icon.icns", []byte("icnsdata"))
writeReleaseAsset(t, root, "assets/icon.png", []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00})
writeReleaseAsset(t, root, "frontend/public/icon.png", []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00})
return root
}
func writeReleaseAsset(t *testing.T, root, relPath string, content []byte) {
t.Helper()
fullPath := filepath.Join(root, relPath)
require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
require.NoError(t, os.WriteFile(fullPath, content, 0o600))
}

View File

@@ -263,44 +263,84 @@ func ReadEnvVar(content, key string) (string, bool) {
return "", false return "", false
} }
func ServerAssetName(version, goos, arch string) (string, error) { func ReleaseAssetName(version, component, platform, arch, format string) (string, error) {
if _, err := Parse(version); err != nil { if _, err := Parse(version); err != nil {
return "", err return "", err
} }
switch goos { switch component {
case "linux", "windows", "darwin": case "server":
return serverAssetName(version, platform, arch, format)
case "web":
return webAssetName(version, platform, arch, format)
case "desktop":
return desktopAssetName(version, platform, arch, format)
default: default:
return "", fmt.Errorf("不支持的 server 平台 %q", goos) return "", fmt.Errorf("不支持的资产组件 %q", component)
}
} }
if arch == "" { func serverAssetName(version, platform, arch, format string) (string, error) {
return "", errors.New("server 资产命名缺少架构") if !validCombination(platform, arch, format, []releaseAssetTarget{
{platform: "linux", arch: "amd64", format: "tar.gz"},
{platform: "linux", arch: "arm64", format: "tar.gz"},
{platform: "macos", arch: "amd64", format: "tar.gz"},
{platform: "macos", arch: "arm64", format: "tar.gz"},
{platform: "macos", arch: "universal", format: "tar.gz"},
{platform: "windows", arch: "amd64", format: "zip"},
}) {
return "", fmt.Errorf("不支持的 server 资产目标 %s/%s/%s", platform, arch, format)
} }
ext := ".tar.gz" return fmt.Sprintf("nex-server_%s_%s_%s.%s", version, platform, arch, format), nil
if goos == "windows" {
ext = ".zip"
} }
return fmt.Sprintf("nex-server_%s_%s_%s%s", version, goos, arch, ext), nil func webAssetName(version, platform, arch, format string) (string, error) {
if platform != "" || arch != "" {
return "", errors.New("web 资产命名不支持平台或架构参数")
} }
func DesktopAssetName(version, platform string) (string, error) { if format != "tar.gz" {
if _, err := Parse(version); err != nil { return "", fmt.Errorf("不支持的 web 资产格式 %q", format)
return "", err
} }
switch platform { return fmt.Sprintf("nex-web_%s.tar.gz", version), nil
case "linux":
return fmt.Sprintf("Nex_%s_linux_amd64.tar.gz", version), nil
case "windows":
return fmt.Sprintf("Nex_%s_windows_amd64.zip", version), nil
case "macos":
return fmt.Sprintf("Nex_%s_macOS_universal.zip", version), nil
default:
return "", fmt.Errorf("不支持的 desktop 平台 %q", platform)
} }
func desktopAssetName(version, platform, arch, format string) (string, error) {
if !validCombination(platform, arch, format, []releaseAssetTarget{
{platform: "linux", arch: "amd64", format: "tar.gz"},
{platform: "linux", arch: "amd64", format: "AppImage"},
{platform: "linux", arch: "amd64", format: "deb"},
{platform: "linux", arch: "amd64", format: "rpm"},
{platform: "linux", arch: "arm64", format: "tar.gz"},
{platform: "linux", arch: "arm64", format: "AppImage"},
{platform: "linux", arch: "arm64", format: "deb"},
{platform: "linux", arch: "arm64", format: "rpm"},
{platform: "macos", arch: "universal", format: "zip"},
{platform: "macos", arch: "universal", format: "dmg"},
{platform: "windows", arch: "amd64", format: "zip"},
}) {
return "", fmt.Errorf("不支持的 desktop 资产目标 %s/%s/%s", platform, arch, format)
}
return fmt.Sprintf("nex-desktop_%s_%s_%s.%s", version, platform, arch, format), nil
}
type releaseAssetTarget struct {
platform string
arch string
format string
}
func validCombination(platform, arch, format string, targets []releaseAssetTarget) bool {
for _, target := range targets {
if target.platform == platform && target.arch == arch && target.format == format {
return true
}
}
return false
} }
func DesktopInfoPlist(version, minMacOSVersion string) (string, error) { func DesktopInfoPlist(version, minMacOSVersion string) (string, error) {

View File

@@ -83,20 +83,70 @@ func TestVerifyTag(t *testing.T) {
} }
func TestAssetNames(t *testing.T) { func TestAssetNames(t *testing.T) {
linuxServer, err := ServerAssetName("1.2.3", "linux", "amd64") testCases := []struct {
require.NoError(t, err) name string
assert.Equal(t, "nex-server_1.2.3_linux_amd64.tar.gz", linuxServer) component string
platform string
arch string
format string
want string
}{
{"server linux amd64", "server", "linux", "amd64", "tar.gz", "nex-server_1.2.3_linux_amd64.tar.gz"},
{"server linux arm64", "server", "linux", "arm64", "tar.gz", "nex-server_1.2.3_linux_arm64.tar.gz"},
{"server macos amd64", "server", "macos", "amd64", "tar.gz", "nex-server_1.2.3_macos_amd64.tar.gz"},
{"server macos arm64", "server", "macos", "arm64", "tar.gz", "nex-server_1.2.3_macos_arm64.tar.gz"},
{"server macos universal", "server", "macos", "universal", "tar.gz", "nex-server_1.2.3_macos_universal.tar.gz"},
{"server windows amd64", "server", "windows", "amd64", "zip", "nex-server_1.2.3_windows_amd64.zip"},
{"web", "web", "", "", "tar.gz", "nex-web_1.2.3.tar.gz"},
{"desktop linux amd64 tar", "desktop", "linux", "amd64", "tar.gz", "nex-desktop_1.2.3_linux_amd64.tar.gz"},
{"desktop linux amd64 appimage", "desktop", "linux", "amd64", "AppImage", "nex-desktop_1.2.3_linux_amd64.AppImage"},
{"desktop linux amd64 deb", "desktop", "linux", "amd64", "deb", "nex-desktop_1.2.3_linux_amd64.deb"},
{"desktop linux amd64 rpm", "desktop", "linux", "amd64", "rpm", "nex-desktop_1.2.3_linux_amd64.rpm"},
{"desktop linux arm64 tar", "desktop", "linux", "arm64", "tar.gz", "nex-desktop_1.2.3_linux_arm64.tar.gz"},
{"desktop linux arm64 appimage", "desktop", "linux", "arm64", "AppImage", "nex-desktop_1.2.3_linux_arm64.AppImage"},
{"desktop linux arm64 deb", "desktop", "linux", "arm64", "deb", "nex-desktop_1.2.3_linux_arm64.deb"},
{"desktop linux arm64 rpm", "desktop", "linux", "arm64", "rpm", "nex-desktop_1.2.3_linux_arm64.rpm"},
{"desktop macos zip", "desktop", "macos", "universal", "zip", "nex-desktop_1.2.3_macos_universal.zip"},
{"desktop macos dmg", "desktop", "macos", "universal", "dmg", "nex-desktop_1.2.3_macos_universal.dmg"},
{"desktop windows amd64", "desktop", "windows", "amd64", "zip", "nex-desktop_1.2.3_windows_amd64.zip"},
}
macServer, err := ServerAssetName("1.2.3", "darwin", "arm64") for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := ReleaseAssetName("1.2.3", tc.component, tc.platform, tc.arch, tc.format)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "nex-server_1.2.3_darwin_arm64.tar.gz", macServer) assert.Equal(t, tc.want, got)
})
}
macDesktop, err := DesktopAssetName("1.2.3", "macos") invalidCases := []struct {
require.NoError(t, err) name string
assert.Equal(t, "Nex_1.2.3_macOS_universal.zip", macDesktop) component string
platform string
arch string
format string
}{
{"invalid version", "server", "linux", "amd64", "tar.gz"},
{"invalid component", "mobile", "linux", "amd64", "tar.gz"},
{"darwin platform", "server", "darwin", "arm64", "tar.gz"},
{"server unsupported format", "server", "linux", "amd64", "zip"},
{"server unsupported arch", "server", "windows", "universal", "zip"},
{"web with platform", "web", "linux", "amd64", "tar.gz"},
{"web unsupported format", "web", "", "", "zip"},
{"desktop unsupported platform", "desktop", "ios", "arm64", "zip"},
{"desktop unsupported format", "desktop", "macos", "universal", "tar.gz"},
}
_, err = DesktopAssetName("1.2.3", "ios") for _, tc := range invalidCases {
t.Run(tc.name, func(t *testing.T) {
version := "1.2.3"
if tc.name == "invalid version" {
version = "1.2"
}
_, err := ReleaseAssetName(version, tc.component, tc.platform, tc.arch, tc.format)
assert.Error(t, err) assert.Error(t, err)
})
}
} }
func TestDesktopInfoPlist(t *testing.T) { func TestDesktopInfoPlist(t *testing.T) {