Compare commits
2 Commits
598e2acb7e
...
c04a13bf8a
| Author | SHA1 | Date | |
|---|---|---|---|
| c04a13bf8a | |||
| 5513f0c13d |
83
Makefile
83
Makefile
@@ -70,15 +70,21 @@ clean: _backend-clean _frontend-clean _desktop-clean
|
||||
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"; \
|
||||
for hook in pre-commit commit-msg prepare-commit-msg; do \
|
||||
src="scripts/git-hooks/$$hook"; \
|
||||
if [ ! -f "$$src" ]; then \
|
||||
printf 'ERROR: source hook not found: %s\n' "$$src" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
cp "$$src" "$$hooks_dir/$$hook"; \
|
||||
chmod +x "$$hooks_dir/$$hook"; \
|
||||
done; \
|
||||
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 \
|
||||
for hook in pre-commit commit-msg prepare-commit-msg; do \
|
||||
if [ -x "$$hooks_dir/$$hook" ]; then \
|
||||
printf 'OK: %s\n' "$$hook"; \
|
||||
else \
|
||||
@@ -92,17 +98,18 @@ hooks-test:
|
||||
@scripts/git-hooks/test-hooks.sh
|
||||
|
||||
_hooks-pre-commit:
|
||||
@set -e; \
|
||||
@set -ef; \
|
||||
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=''; \
|
||||
run_backend_lint=; \
|
||||
run_versionctl_lint=; \
|
||||
run_frontend_check=; \
|
||||
lfs_patterns=$$(grep 'filter=lfs' .gitattributes 2>/dev/null | awk '{print $$1}' || true); \
|
||||
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; \
|
||||
@@ -114,37 +121,41 @@ _hooks-pre-commit:
|
||||
printf 'Warning: large staged text file (%s bytes): %s\n' "$$size" "$$file" >&2; \
|
||||
fi; \
|
||||
fi; \
|
||||
if [ -n "$$lfs_patterns" ]; then \
|
||||
for lfs_pat in $$lfs_patterns; do \
|
||||
case "$$file" in $$lfs_pat) \
|
||||
content=$$(git show ":$$file" 2>/dev/null | head -1); \
|
||||
case "$$content" in \
|
||||
"version https://git-lfs.github.com/spec/v1"*) ;; \
|
||||
*) \
|
||||
printf 'LFS-tracked file not using LFS pointer: %s\n' "$$file" >&2; \
|
||||
printf 'Run "git lfs install" and re-add this file.\n' >&2; \
|
||||
exit 1; \
|
||||
;; \
|
||||
esac; \
|
||||
break; \
|
||||
;; \
|
||||
esac; \
|
||||
done; \
|
||||
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"); \
|
||||
;; \
|
||||
backend/*.go) run_backend_lint=1 ;; \
|
||||
versionctl/*.go) run_versionctl_lint=1 ;; \
|
||||
frontend/*.ts|frontend/*.tsx|frontend/*.scss) run_frontend_check=1 ;; \
|
||||
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; \
|
||||
if [ -n "$$run_backend_lint" ]; then \
|
||||
printf 'Running backend lint...\n'; \
|
||||
$(MAKE) _backend-lint; \
|
||||
fi; \
|
||||
if [ -n "$$run_versionctl_lint" ]; then \
|
||||
printf 'Running versionctl lint...\n'; \
|
||||
$(MAKE) _versionctl-lint; \
|
||||
fi; \
|
||||
if [ -n "$$run_frontend_check" ]; then \
|
||||
printf 'Running frontend check...\n'; \
|
||||
$(MAKE) _frontend-check; \
|
||||
fi; \
|
||||
printf 'Pre-commit checks passed\n'
|
||||
|
||||
# ============================================
|
||||
|
||||
27
README.md
27
README.md
@@ -66,7 +66,7 @@ nex/
|
||||
- **ORM**: GORM
|
||||
- **数据库**: SQLite / MySQL
|
||||
- **日志**: zap + lumberjack(结构化日志 + 日志轮转 + 模块标识)
|
||||
- **配置**: Viper + pflag(多层配置:CLI > 环境变量 > 配置文件 > 默认值)
|
||||
- **配置**: Viper + pflag(Server 多层配置,Desktop 配置文件快照)
|
||||
- **验证**: go-playground/validator/v10
|
||||
- **迁移**: goose
|
||||
|
||||
@@ -147,7 +147,6 @@ make server-run
|
||||
- 前端开发服务器:`http://localhost:5173`
|
||||
|
||||
前端请求会继续通过 Vite proxy 转发到后端。后端首次启动会自动:
|
||||
- 创建配置文件 `~/.nex/config.yaml`
|
||||
- 初始化数据库 `~/.nex/config.db`
|
||||
- 运行数据库迁移
|
||||
- 创建日志目录 `~/.nex/log/`
|
||||
@@ -245,11 +244,14 @@ server 和 desktop 发布产物自包含运行时数据库迁移资源(通过
|
||||
|
||||
## 配置
|
||||
|
||||
配置支持多种方式,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
|
||||
配置方式取决于启动模式:
|
||||
|
||||
- **Server 模式**(`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值
|
||||
- **Desktop 模式**(`cmd/desktop`):仅支持配置文件 `~/.nex/config.yaml` > 默认值,修改配置文件后需重启 desktop 生效
|
||||
|
||||
### 配置文件
|
||||
|
||||
配置文件位于 `~/.nex/config.yaml`,首次启动自动生成:
|
||||
配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -279,9 +281,9 @@ log:
|
||||
compress: true
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
### 环境变量(仅 Server 模式)
|
||||
|
||||
所有配置项支持环境变量,使用 `NEX_` 前缀:
|
||||
Server 模式下,所有配置项支持环境变量,使用 `NEX_` 前缀:
|
||||
|
||||
```bash
|
||||
export NEX_SERVER_PORT=9000
|
||||
@@ -299,7 +301,11 @@ export NEX_DATABASE_DBNAME=nex
|
||||
|
||||
命名规则:配置路径转大写 + 下划线(如 `server.port` → `NEX_SERVER_PORT`)。
|
||||
|
||||
### CLI 参数
|
||||
**Desktop 模式不支持环境变量覆盖。**Desktop 仅从 `~/.nex/config.yaml` 和默认值读取配置。
|
||||
|
||||
### CLI 参数(仅 Server 模式)
|
||||
|
||||
Server 模式下,支持命令行参数:
|
||||
|
||||
```bash
|
||||
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
|
||||
@@ -307,6 +313,8 @@ export NEX_DATABASE_DBNAME=nex
|
||||
|
||||
命名规则:配置路径转 kebab-case(如 `server.port` → `--server-port`)。
|
||||
|
||||
**Desktop 不支持命令行参数覆盖配置。**Desktop 忽略所有 CLI 参数,仅从 `~/.nex/config.yaml` 读取。
|
||||
|
||||
### 数据文件
|
||||
|
||||
- `~/.nex/config.yaml` - 配置文件
|
||||
@@ -361,8 +369,9 @@ make desktop-clean # 清理 desktop 产物
|
||||
|
||||
Git hooks 使用仓库内 `scripts/git-hooks/` 的原生脚本,不依赖额外 hook 框架。当前 hooks 包含:
|
||||
|
||||
- pre-commit:检查 staged files 的冲突标记、Go lint、前端 lint/格式和大文件告警
|
||||
- commit-msg:校验提交信息格式为 `类型: 简短描述`,描述需使用中文
|
||||
- pre-commit:检查 staged files 的冲突标记、大文件告警和 LFS 指针,并按文件类型委托后端、versionctl、前端检查
|
||||
- prepare-commit-msg:在编辑器打开时提供提交信息模板,辅助填写 `类型: 简短描述` 和多行说明
|
||||
- commit-msg:校验提交信息格式为 `类型: 简短描述`,多行说明需在首行后保留空行;提交描述按项目规范使用中文,hook 不做字符集检测
|
||||
|
||||
## 版本与发布
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ GORM 日志自动桥接到 zap,SQL 查询映射到 Debug 级别。
|
||||
- **ORM**: GORM
|
||||
- **数据库**: SQLite / MySQL
|
||||
- **日志**: zap + lumberjack
|
||||
- **配置**: Viper + pflag(多层配置:CLI > 环境变量 > 配置文件 > 默认值)
|
||||
- **配置**: Viper + pflag(Server 多层配置,Desktop 配置文件快照)
|
||||
- **验证**: go-playground/validator/v10
|
||||
- **迁移**: goose
|
||||
|
||||
@@ -334,15 +334,18 @@ go mod download
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
|
||||
服务将在端口 9826 启动。首次启动会自动创建配置文件和运行数据库迁移。
|
||||
服务将在端口 9826 启动。首次启动会自动运行数据库迁移。
|
||||
|
||||
## 配置
|
||||
|
||||
配置支持多种方式:配置文件、环境变量、命令行参数,优先级为:**CLI 参数 > 环境变量 > 配置文件 > 默认值**
|
||||
配置方式取决于启动入口:
|
||||
|
||||
- **Server 入口**(`cmd/server`):支持 CLI 参数 > 环境变量 > 配置文件 > 默认值
|
||||
- **Desktop 入口**(`cmd/desktop`):仅支持 `~/.nex/config.yaml` > 默认值,不支持 CLI 参数和 `NEX_*` 环境变量覆盖,修改配置文件后需重启生效
|
||||
|
||||
### 配置文件
|
||||
|
||||
配置文件位于 `~/.nex/config.yaml`,首次启动自动生成。
|
||||
配置文件位于 `~/.nex/config.yaml`。配置文件不存在时使用默认值,不会自动生成;需要自定义时手动创建该文件:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
@@ -372,9 +375,9 @@ log:
|
||||
compress: true
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
### 环境变量(仅 Server 入口)
|
||||
|
||||
所有配置项都支持环境变量,使用 `NEX_` 前缀:
|
||||
Server 入口下,所有配置项都支持环境变量,使用 `NEX_` 前缀:
|
||||
|
||||
```bash
|
||||
export NEX_SERVER_PORT=9000
|
||||
@@ -392,7 +395,7 @@ export NEX_DATABASE_DBNAME=nex
|
||||
|
||||
命名规则:配置路径转大写 + 下划线 + `NEX_` 前缀(如 `server.port` → `NEX_SERVER_PORT`)。
|
||||
|
||||
### 命令行参数
|
||||
### 命令行参数(仅 Server 入口)
|
||||
|
||||
```bash
|
||||
./server --server-port 9000 --log-level debug --database-path /tmp/test.db
|
||||
|
||||
@@ -43,10 +43,23 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := 9826
|
||||
|
||||
minimalLogger := pkgLogger.NewMinimal()
|
||||
|
||||
cfg, err := config.LoadDesktopConfig()
|
||||
if err != nil {
|
||||
minimalLogger.Error("加载配置失败", zap.Error(err))
|
||||
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
port := cfg.Server.Port
|
||||
|
||||
if err := checkPortAvailable(port); err != nil {
|
||||
minimalLogger.Error("端口不可用", zap.Error(err))
|
||||
showError(appName, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
|
||||
if err := singleLock.Lock(); err != nil {
|
||||
minimalLogger.Error("已有 Nex 实例运行")
|
||||
@@ -59,17 +72,6 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := checkPortAvailable(port); err != nil {
|
||||
minimalLogger.Error("端口不可用", zap.Error(err))
|
||||
showError(appName, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
minimalLogger.Fatal("加载配置失败", zap.Error(err))
|
||||
}
|
||||
|
||||
zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{
|
||||
Level: cfg.Log.Level,
|
||||
Path: cfg.Log.Path,
|
||||
@@ -144,7 +146,7 @@ func main() {
|
||||
setupStaticFiles(r)
|
||||
|
||||
server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Addr: desktopListenAddr(port),
|
||||
Handler: r,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
@@ -165,7 +167,7 @@ func main() {
|
||||
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil {
|
||||
if err := openBrowser(desktopURL(port)); err != nil {
|
||||
zapLogger.Warn("无法打开浏览器", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
@@ -309,7 +311,7 @@ func setupSystray(port int) {
|
||||
systray.AddSeparator()
|
||||
mStatus := systray.AddMenuItem("状态: 运行中", "")
|
||||
mStatus.Disable()
|
||||
mPort := systray.AddMenuItem(fmt.Sprintf("端口: %d", port), "")
|
||||
mPort := systray.AddMenuItem(desktopPortMenuTitle(port), "")
|
||||
mPort.Disable()
|
||||
systray.AddSeparator()
|
||||
mQuit := systray.AddMenuItem("退出", "停止服务并退出")
|
||||
@@ -318,7 +320,7 @@ func setupSystray(port int) {
|
||||
for {
|
||||
select {
|
||||
case <-mOpen.ClickedCh:
|
||||
if err := openBrowser(fmt.Sprintf("http://localhost:%d", port)); err != nil {
|
||||
if err := openBrowser(desktopURL(port)); err != nil {
|
||||
zapLogger.Warn("打开浏览器失败", zap.Error(err))
|
||||
}
|
||||
case <-mQuit.ClickedCh:
|
||||
@@ -349,6 +351,30 @@ func doShutdown() {
|
||||
}
|
||||
}
|
||||
|
||||
func getDesktopConfigPath() string {
|
||||
configDir, err := config.GetConfigDir()
|
||||
if err != nil {
|
||||
return "~/.nex/config.yaml"
|
||||
}
|
||||
return filepath.Join(configDir, "config.yaml")
|
||||
}
|
||||
|
||||
func desktopConfigErrorMessage(configPath string, err error) string {
|
||||
return fmt.Sprintf("加载配置失败\n\n配置文件: %s\n\n%v", configPath, err)
|
||||
}
|
||||
|
||||
func desktopListenAddr(port int) string {
|
||||
return fmt.Sprintf(":%d", port)
|
||||
}
|
||||
|
||||
func desktopURL(port int) string {
|
||||
return fmt.Sprintf("http://localhost:%d", port)
|
||||
}
|
||||
|
||||
func desktopPortMenuTitle(port int) string {
|
||||
return fmt.Sprintf("端口: %d", port)
|
||||
}
|
||||
|
||||
func checkPortAvailable(port int) error {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -67,3 +68,62 @@ func TestCheckPortAvailableAfterClose(t *testing.T) {
|
||||
|
||||
t.Log("端口关闭后可用测试通过")
|
||||
}
|
||||
|
||||
func TestCheckPortAvailableErrorContainsPort(t *testing.T) {
|
||||
port := 19829
|
||||
|
||||
listener, err := net.Listen("tcp", ":19829") //nolint:gosec
|
||||
if err != nil {
|
||||
t.Fatalf("无法启动测试服务器: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
err = checkPortAvailable(port)
|
||||
if err == nil {
|
||||
t.Fatal("端口被占用时应该返回错误")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "19829") {
|
||||
t.Fatalf("错误信息应包含端口号 19829,实际: %v", err)
|
||||
}
|
||||
|
||||
t.Log("端口错误信息包含端口号测试通过")
|
||||
}
|
||||
|
||||
func TestGetDesktopConfigPath(t *testing.T) {
|
||||
path := getDesktopConfigPath()
|
||||
if path == "" {
|
||||
t.Fatal("getDesktopConfigPath 应返回非空路径")
|
||||
}
|
||||
if !strings.Contains(path, "config.yaml") {
|
||||
t.Fatalf("路径应包含 config.yaml,实际: %s", path)
|
||||
}
|
||||
t.Log("getDesktopConfigPath 测试通过")
|
||||
}
|
||||
|
||||
func TestDesktopConfiguredPortHelpers(t *testing.T) {
|
||||
port := 19830
|
||||
|
||||
if got := desktopListenAddr(port); got != ":19830" {
|
||||
t.Fatalf("HTTP 监听地址应使用配置端口,实际: %s", got)
|
||||
}
|
||||
if got := desktopURL(port); got != "http://localhost:19830" {
|
||||
t.Fatalf("浏览器 URL 应使用配置端口,实际: %s", got)
|
||||
}
|
||||
if got := desktopPortMenuTitle(port); got != "端口: 19830" {
|
||||
t.Fatalf("托盘端口显示应使用配置端口,实际: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopConfigErrorMessageContainsPathAndReason(t *testing.T) {
|
||||
msg := desktopConfigErrorMessage("/tmp/nex/config.yaml", errors.New("yaml parse failed"))
|
||||
|
||||
if !strings.Contains(msg, "/tmp/nex/config.yaml") {
|
||||
t.Fatalf("配置错误提示应包含配置路径,实际: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "yaml parse failed") {
|
||||
t.Fatalf("配置错误提示应包含失败原因,实际: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
func main() {
|
||||
minimalLogger := pkgLogger.NewMinimal()
|
||||
|
||||
cfg, err := config.LoadConfig()
|
||||
cfg, err := config.LoadServerConfig()
|
||||
if err != nil {
|
||||
minimalLogger.Fatal("加载配置失败", zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -225,68 +224,71 @@ func setupConfigFile(v *viper.Viper, configPath string) error {
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
// 尝试读取配置文件,如果不存在则忽略
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
// 配置文件不存在,创建默认配置文件
|
||||
writeErr := v.SafeWriteConfigAs(configPath)
|
||||
if writeErr == nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var alreadyExistsErr viper.ConfigFileAlreadyExistsError
|
||||
if errors.As(writeErr, &alreadyExistsErr) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return appErrors.Wrap(appErrors.ErrInternal, writeErr)
|
||||
return appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfig loads config from YAML file, creates default if not exists
|
||||
func LoadConfig() (*Config, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return LoadConfigFromPath(configPath)
|
||||
// loadOptions 控制配置加载器行为
|
||||
type loadOptions struct {
|
||||
configPathOverride string
|
||||
useCLI bool
|
||||
useEnv bool
|
||||
useConfigFlag bool
|
||||
}
|
||||
|
||||
// LoadConfigFromPath 从指定路径加载配置
|
||||
func LoadConfigFromPath(configPath string) (*Config, error) {
|
||||
// 1. 创建 Viper 实例
|
||||
// resolveConfigPath 根据 loadOptions 解析 CLI 参数并返回最终配置文件路径
|
||||
func resolveConfigPath(v *viper.Viper, opts loadOptions) (string, error) {
|
||||
configPath := opts.configPathOverride
|
||||
|
||||
if !opts.useCLI && !opts.useConfigFlag {
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError)
|
||||
if opts.useConfigFlag {
|
||||
flagSet.String("config", opts.configPathOverride, "配置文件路径")
|
||||
}
|
||||
if opts.useCLI {
|
||||
setupFlags(v, flagSet)
|
||||
}
|
||||
|
||||
if err := flagSet.Parse(os.Args[1:]); err != nil {
|
||||
return "", appErrors.Wrap(appErrors.ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
if opts.useConfigFlag {
|
||||
if f, err := flagSet.GetString("config"); err == nil && f != "" {
|
||||
configPath = f
|
||||
}
|
||||
}
|
||||
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
// loadConfig 共享配置加载逻辑,通过 loadOptions 控制是否启用 CLI、环境变量和 --config 覆盖
|
||||
func loadConfig(opts loadOptions) (*Config, error) {
|
||||
v := viper.New()
|
||||
|
||||
// 2. 定义 CLI 参数
|
||||
flagSet := pflag.NewFlagSet("config", pflag.ContinueOnError)
|
||||
flagSet.String("config", configPath, "配置文件路径")
|
||||
setupFlags(v, flagSet)
|
||||
|
||||
// 3. 解析 CLI 参数(忽略错误,因为可能没有参数)
|
||||
if err := flagSet.Parse(os.Args[1:]); err != nil {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
// 4. 获取配置文件路径(可能被 --config 参数覆盖)
|
||||
if configPathFlag, err := flagSet.GetString("config"); err == nil && configPathFlag != "" {
|
||||
configPath = configPathFlag
|
||||
}
|
||||
|
||||
// 5. 设置默认值
|
||||
setupDefaults(v)
|
||||
|
||||
// 6. 绑定环境变量
|
||||
setupEnv(v)
|
||||
configPath, err := resolveConfigPath(v, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if opts.useEnv {
|
||||
setupEnv(v)
|
||||
}
|
||||
|
||||
// 7. 读取配置文件
|
||||
if err := setupConfigFile(v, configPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 8. 反序列化到结构体
|
||||
cfg := &Config{}
|
||||
if err := v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
@@ -295,7 +297,6 @@ func LoadConfigFromPath(configPath string) (*Config, error) {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
|
||||
// 9. 验证配置
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -303,6 +304,61 @@ func LoadConfigFromPath(configPath string) (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadServerConfig 为 server 入口加载配置,支持 CLI 参数、环境变量和 --config
|
||||
func LoadServerConfig() (*Config, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return loadConfig(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: true,
|
||||
useEnv: true,
|
||||
useConfigFlag: true,
|
||||
})
|
||||
}
|
||||
|
||||
// LoadDesktopConfig 为 desktop 入口加载配置,固定使用默认配置文件,不支持 CLI、环境变量和 --config
|
||||
func LoadDesktopConfig() (*Config, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, appErrors.Wrap(appErrors.ErrInternal, err)
|
||||
}
|
||||
return loadConfig(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: false,
|
||||
useEnv: false,
|
||||
useConfigFlag: false,
|
||||
})
|
||||
}
|
||||
|
||||
// LoadConfig loads config from YAML file.
|
||||
// 向后兼容,等同于 LoadServerConfig。
|
||||
func LoadConfig() (*Config, error) {
|
||||
return LoadServerConfig()
|
||||
}
|
||||
|
||||
// LoadConfigFromPath 从指定路径加载配置。
|
||||
// 保留向后兼容,沿用 server 语义(支持 CLI、env 和 --config 覆盖)。
|
||||
func LoadConfigFromPath(configPath string) (*Config, error) {
|
||||
return loadConfig(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: true,
|
||||
useEnv: true,
|
||||
useConfigFlag: true,
|
||||
})
|
||||
}
|
||||
|
||||
// LoadDesktopConfigAtPath 从指定路径以 desktop 语义加载配置(仅配置文件和默认值),用于测试场景。
|
||||
func LoadDesktopConfigAtPath(configPath string) (*Config, error) {
|
||||
return loadConfig(loadOptions{
|
||||
configPathOverride: configPath,
|
||||
useCLI: false,
|
||||
useEnv: false,
|
||||
useConfigFlag: false,
|
||||
})
|
||||
}
|
||||
|
||||
// SaveConfig saves config to YAML file
|
||||
func SaveConfig(cfg *Config) error {
|
||||
configPath, err := GetConfigPath()
|
||||
|
||||
@@ -120,7 +120,7 @@ log:
|
||||
assert.Equal(t, "warn", cfg.Log.Level, "YAML value should be used when no CLI/ENV override")
|
||||
}
|
||||
|
||||
func TestLoadConfig_AutoCreate(t *testing.T) {
|
||||
func TestLoadConfig_NoAutoCreate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
@@ -132,6 +132,9 @@ func TestLoadConfig_AutoCreate(t *testing.T) {
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, 9826, cfg.Server.Port, "should load with default values")
|
||||
|
||||
_, err = os.Stat(configPath)
|
||||
assert.True(t, os.IsNotExist(err), "config file should not be auto-created")
|
||||
}
|
||||
|
||||
func TestSaveAndLoadConfig(t *testing.T) {
|
||||
@@ -184,3 +187,124 @@ func TestSaveAndLoadConfig(t *testing.T) {
|
||||
assert.Equal(t, cfg.Log.MaxAge, loaded.Log.MaxAge)
|
||||
assert.Equal(t, cfg.Log.Compress, loaded.Log.Compress)
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_FileOnly(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
yamlContent := `
|
||||
server:
|
||||
port: 8080
|
||||
log:
|
||||
level: debug
|
||||
`
|
||||
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 8080, cfg.Server.Port)
|
||||
assert.Equal(t, "debug", cfg.Log.Level)
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_IgnoresCLI(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originalArgs := os.Args
|
||||
defer func() { os.Args = originalArgs }()
|
||||
os.Args = []string{"nex", "--server-port", "9999"}
|
||||
|
||||
cfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 8080, cfg.Server.Port, "desktop should ignore CLI args and use config file")
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_IgnoresEnv(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Setenv("NEX_SERVER_PORT", "9000")
|
||||
t.Setenv("NEX_LOG_LEVEL", "debug")
|
||||
|
||||
cfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 8080, cfg.Server.Port, "desktop should ignore env vars and use config file")
|
||||
assert.Equal(t, "info", cfg.Log.Level, "desktop should ignore env vars and use default")
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_IgnoresUnknownArgs(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originalArgs := os.Args
|
||||
defer func() { os.Args = originalArgs }()
|
||||
os.Args = []string{"nex", "--unknown-flag", "value", "--another-unknown"}
|
||||
|
||||
cfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err, "desktop should not fail on unknown CLI args")
|
||||
|
||||
assert.Equal(t, 8080, cfg.Server.Port)
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_Snapshot(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte("server:\n port: 8080\n"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 8080, cfg.Server.Port)
|
||||
|
||||
err = os.WriteFile(configPath, []byte("server:\n port: 9999\n"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 8080, cfg.Server.Port, "loaded config snapshot should not change when file changes")
|
||||
|
||||
cfg2, err := config.LoadDesktopConfigAtPath(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 9999, cfg2.Server.Port, "reload should pick up new config values")
|
||||
}
|
||||
|
||||
func TestLoadDesktopConfig_InvalidFileFails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "invalid yaml",
|
||||
content: "server:\n port: [\n",
|
||||
},
|
||||
{
|
||||
name: "validation failure",
|
||||
content: "server:\n port: 70000\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte(tt.content), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = config.LoadDesktopConfigAtPath(configPath)
|
||||
require.Error(t, err, "desktop should not silently fall back to defaults for invalid config files")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,8 @@ bun run build
|
||||
```bash
|
||||
bun run lint # ESLint 检查
|
||||
bun run format:check # Prettier 格式检查
|
||||
bun run check # 同时检查 lint 和格式
|
||||
bun run typecheck # TypeScript 类型检查
|
||||
bun run check # 同时检查类型、lint 和格式
|
||||
```
|
||||
|
||||
### 代码格式化
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && bun run check && vite build",
|
||||
"build": "bun run check && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"check": "bun run lint && bun run format:check",
|
||||
"typecheck": "tsc -b",
|
||||
"check": "bun run typecheck && bun run lint && bun run format:check",
|
||||
"fix": "bun run lint:fix && bun run format",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -38,7 +38,9 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
||||
text: id,
|
||||
onCopy: () => MessagePlugin.success('已复制统一模型 ID'),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{''}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : null
|
||||
},
|
||||
|
||||
@@ -51,7 +51,9 @@ export function ProviderTable({
|
||||
text: row.baseUrl,
|
||||
onCopy: () => MessagePlugin.success('已复制 Base URL'),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{''}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
@@ -87,7 +89,9 @@ export function ProviderTable({
|
||||
text: row.apiKey,
|
||||
onCopy: () => MessagePlugin.success('已复制 API Key'),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{''}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
|
||||
@@ -8,20 +8,27 @@
|
||||
|
||||
### Requirement: 使用 YAML 配置文件
|
||||
|
||||
系统 SHALL 使用 YAML 格式的配置文件。
|
||||
系统 SHALL 使用 YAML 格式的配置文件,并按入口区分配置文件路径选择能力。
|
||||
|
||||
#### Scenario: 配置文件路径
|
||||
#### Scenario: Server 默认配置文件路径
|
||||
|
||||
- **WHEN** 应用启动且未指定 `--config` 参数
|
||||
- **WHEN** server 应用启动且未指定 `--config` 参数
|
||||
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
|
||||
- **THEN** SHALL 解析 YAML 格式
|
||||
|
||||
#### Scenario: 自定义配置文件路径
|
||||
#### Scenario: Server 自定义配置文件路径
|
||||
|
||||
- **WHEN** 应用启动且指定 `--config /path/to/custom.yaml`
|
||||
- **WHEN** server 应用启动且指定 `--config /path/to/custom.yaml`
|
||||
- **THEN** SHALL 从指定路径加载配置文件
|
||||
- **THEN** SHALL NOT 使用默认路径 `~/.nex/config.yaml`
|
||||
|
||||
#### Scenario: Desktop 固定配置文件路径
|
||||
|
||||
- **WHEN** desktop 应用启动
|
||||
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
|
||||
- **THEN** SHALL 解析 YAML 格式
|
||||
- **THEN** SHALL NOT 支持通过 `--config` 指定其他配置文件路径
|
||||
|
||||
#### Scenario: 配置文件结构
|
||||
|
||||
- **WHEN** 加载配置文件
|
||||
@@ -30,14 +37,14 @@
|
||||
|
||||
### Requirement: 自动生成默认配置
|
||||
|
||||
系统 SHALL 在首次使用时自动生成默认配置。
|
||||
系统 SHALL 在配置文件不存在时使用默认配置值,不自动创建配置文件。
|
||||
|
||||
#### Scenario: 配置文件不存在
|
||||
|
||||
- **WHEN** 应用启动且配置文件不存在
|
||||
- **THEN** SHALL 自动创建配置文件
|
||||
- **THEN** SHALL 写入默认配置值
|
||||
- **THEN** SHALL 记录日志提示已创建
|
||||
- **THEN** SHALL 使用默认配置值
|
||||
- **THEN** SHALL NOT 自动创建配置文件
|
||||
- **THEN** SHALL NOT 写入默认配置值到磁盘
|
||||
|
||||
#### Scenario: 配置文件已存在
|
||||
|
||||
@@ -163,22 +170,36 @@
|
||||
|
||||
### Requirement: 配置加载流程
|
||||
|
||||
系统 SHALL 实现标准化的配置加载流程。
|
||||
系统 SHALL 为 server 和 desktop 实现标准化且入口隔离的配置加载流程。
|
||||
|
||||
#### Scenario: 加载步骤
|
||||
#### Scenario: Server 加载步骤
|
||||
|
||||
- **WHEN** 应用启动
|
||||
- **WHEN** server 应用启动
|
||||
- **THEN** SHALL 按以下顺序加载配置:
|
||||
1. 解析 CLI 参数(获取 --config 路径)
|
||||
2. 初始化配置管理器
|
||||
3. 设置默认值
|
||||
4. 绑定 CLI 参数
|
||||
5. 绑定环境变量
|
||||
6. 读取配置文件(不存在时自动创建)
|
||||
6. 读取配置文件(不存在时使用默认值)
|
||||
7. 反序列化到结构体
|
||||
8. 验证配置
|
||||
9. 打印配置摘要
|
||||
|
||||
#### Scenario: Desktop 加载步骤
|
||||
|
||||
- **WHEN** desktop 应用启动
|
||||
- **THEN** SHALL 按以下顺序加载配置:
|
||||
1. 初始化配置管理器
|
||||
2. 设置默认值
|
||||
3. 读取默认配置文件 `~/.nex/config.yaml`(不存在时使用默认值)
|
||||
4. 反序列化到结构体
|
||||
5. 验证配置
|
||||
6. 打印配置摘要
|
||||
- **THEN** SHALL NOT 解析 CLI 参数
|
||||
- **THEN** SHALL NOT 绑定环境变量
|
||||
- **THEN** SHALL NOT 允许 CLI 参数覆盖配置文件路径
|
||||
|
||||
#### Scenario: 加载失败处理
|
||||
|
||||
- **WHEN** 配置加载过程中发生错误
|
||||
@@ -188,25 +209,25 @@
|
||||
|
||||
### Requirement: 配置优先级管理
|
||||
|
||||
系统 SHALL 实现明确的配置优先级机制。
|
||||
系统 SHALL 为不同入口实现明确的配置优先级机制。
|
||||
|
||||
#### Scenario: 优先级顺序
|
||||
#### Scenario: Server 优先级顺序
|
||||
|
||||
- **WHEN** 同一配置项在多个配置源中设置
|
||||
- **WHEN** 同一配置项在多个 server 配置源中设置
|
||||
- **THEN** SHALL 按以下优先级顺序(从高到低):
|
||||
1. CLI 参数
|
||||
2. 环境变量
|
||||
3. 配置文件
|
||||
4. 默认值
|
||||
|
||||
#### Scenario: CLI 参数最高优先级
|
||||
#### Scenario: Server CLI 参数最高优先级
|
||||
|
||||
- **WHEN** 配置文件设置 `server.port: 9826`
|
||||
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
|
||||
- **AND** CLI 参数设置 `--server-port 8080`
|
||||
- **AND** server CLI 参数设置 `--server-port 8080`
|
||||
- **THEN** SHALL 使用 CLI 参数值 8080
|
||||
|
||||
#### Scenario: 环境变量次高优先级
|
||||
#### Scenario: Server 环境变量次高优先级
|
||||
|
||||
- **WHEN** 配置文件设置 `server.port: 9826`
|
||||
- **AND** 环境变量设置 `NEX_SERVER_PORT=9000`
|
||||
@@ -227,21 +248,35 @@
|
||||
- **AND** 未设置 CLI 参数
|
||||
- **THEN** SHALL 使用默认值
|
||||
|
||||
#### Scenario: 部分配置覆盖
|
||||
#### Scenario: Server 部分配置覆盖
|
||||
|
||||
- **WHEN** 配置文件设置完整配置
|
||||
- **AND** CLI 参数仅覆盖部分配置项
|
||||
- **AND** server CLI 参数仅覆盖部分配置项
|
||||
- **THEN** SHALL 合并所有配置源
|
||||
- **THEN** SHALL 使用高优先级源覆盖指定项
|
||||
- **THEN** SHALL 保留其他配置源中的未覆盖项
|
||||
|
||||
#### Scenario: 配置项独立覆盖
|
||||
#### Scenario: Server 配置项独立覆盖
|
||||
|
||||
- **WHEN** 仅通过 CLI 参数设置 `--server-port 9000`
|
||||
- **WHEN** 仅通过 server CLI 参数设置 `--server-port 9000`
|
||||
- **THEN** SHALL 仅覆盖 server.port 配置项
|
||||
- **THEN** SHALL NOT 影响其他配置项
|
||||
- **THEN** SHALL 其他配置项使用配置文件或默认值
|
||||
|
||||
#### Scenario: Desktop 优先级顺序
|
||||
|
||||
- **WHEN** 同一配置项存在于 desktop 默认配置文件和默认值中
|
||||
- **THEN** SHALL 使用 `~/.nex/config.yaml` 中的配置文件值
|
||||
- **THEN** SHALL 仅在配置文件未设置该配置项时使用默认值
|
||||
|
||||
#### Scenario: Desktop 忽略外部覆盖源
|
||||
|
||||
- **WHEN** desktop 启动时存在 `--server-port 9000` 参数
|
||||
- **AND** 存在 `NEX_SERVER_PORT=9001` 环境变量
|
||||
- **AND** `~/.nex/config.yaml` 设置 `server.port: 9826`
|
||||
- **THEN** SHALL 使用配置文件值 9826
|
||||
- **THEN** SHALL NOT 使用 CLI 参数或环境变量覆盖配置
|
||||
|
||||
#### Scenario: 启动后配置锁定
|
||||
|
||||
- **WHEN** 应用启动完成
|
||||
@@ -314,67 +349,79 @@
|
||||
|
||||
### Requirement: CLI 参数配置支持
|
||||
|
||||
系统 SHALL 支持通过命令行参数设置所有配置项。
|
||||
server 入口 SHALL 支持通过命令行参数设置所有配置项;desktop 入口 SHALL NOT 将命令行参数作为配置源。
|
||||
|
||||
#### Scenario: 基本参数解析
|
||||
#### Scenario: Server 基本参数解析
|
||||
|
||||
- **WHEN** 应用启动时传入命令行参数
|
||||
- **WHEN** server 应用启动时传入命令行参数
|
||||
- **THEN** SHALL 解析所有 CLI 参数
|
||||
- **THEN** SHALL 将参数值应用到对应配置项
|
||||
|
||||
#### Scenario: 参数命名规范
|
||||
|
||||
- **WHEN** 使用命令行参数
|
||||
- **WHEN** server 使用命令行参数
|
||||
- **THEN** SHALL 使用 kebab-case 命名(如 `--server-port`)
|
||||
- **THEN** SHALL 保持完整的层次结构(如 `--database-max-idle-conns`)
|
||||
- **THEN** SHALL 与配置文件路径对应(`database.max_idle_conns` → `--database-max-idle-conns`)
|
||||
|
||||
#### Scenario: 参数类型支持
|
||||
|
||||
- **WHEN** 解析不同类型的参数
|
||||
- **WHEN** server 解析不同类型的参数
|
||||
- **THEN** SHALL 支持 int 类型(如 `--server-port 9000`)
|
||||
- **THEN** SHALL 支持 string 类型(如 `--database-path /data/nex.db`)
|
||||
- **THEN** SHALL 支持 duration 类型(如 `--server-read-timeout 60s`)
|
||||
- **THEN** SHALL 支持 bool 类型(如 `--log-compress`)
|
||||
|
||||
#### Scenario: 完整配置覆盖
|
||||
#### Scenario: Server 完整配置覆盖
|
||||
|
||||
- **WHEN** 使用服务器相关参数
|
||||
- **WHEN** server 使用服务器相关参数
|
||||
- **THEN** SHALL 支持 `--server-port`、`--server-read-timeout`、`--server-write-timeout`
|
||||
- **WHEN** 使用数据库相关参数
|
||||
- **WHEN** server 使用数据库相关参数
|
||||
- **THEN** SHALL 支持 `--database-driver`、`--database-path`、`--database-host`、`--database-port`、`--database-user`、`--database-password`、`--database-dbname`、`--database-max-idle-conns`、`--database-max-open-conns`、`--database-conn-max-lifetime`
|
||||
- **WHEN** 使用日志相关参数
|
||||
- **WHEN** server 使用日志相关参数
|
||||
- **THEN** SHALL 支持 `--log-level`、`--log-path`、`--log-max-size`、`--log-max-backups`、`--log-max-age`、`--log-compress`
|
||||
|
||||
#### Scenario: 参数帮助信息
|
||||
#### Scenario: Server 参数帮助信息
|
||||
|
||||
- **WHEN** 使用 `--help` 参数
|
||||
- **WHEN** server 使用 `--help` 参数
|
||||
- **THEN** SHALL 显示所有支持的参数
|
||||
- **THEN** SHALL 按功能分组展示参数(服务器、数据库、日志)
|
||||
- **THEN** SHALL 显示每个参数的默认值和说明
|
||||
|
||||
#### Scenario: 参数错误处理
|
||||
#### Scenario: Server 参数错误处理
|
||||
|
||||
- **WHEN** 传入无效的参数值(如 `--server-port abc`)
|
||||
- **WHEN** server 传入无效的参数值(如 `--server-port abc`)
|
||||
- **THEN** SHALL 返回明确的错误信息,指示参数名称和期望类型
|
||||
- **THEN** SHALL NOT 启动应用
|
||||
- **WHEN** 传入未定义的参数(如 `--unknown-param value`)
|
||||
- **WHEN** server 传入未定义的参数(如 `--unknown-param value`)
|
||||
- **THEN** SHALL 返回错误信息,指示未知参数名称
|
||||
- **THEN** SHALL NOT 启动应用
|
||||
|
||||
#### Scenario: Desktop 忽略配置参数
|
||||
|
||||
- **WHEN** desktop 启动时传入 `--server-port 9000`、`--database-path /tmp/test.db` 或 `--config /tmp/custom.yaml`
|
||||
- **THEN** SHALL 忽略这些参数
|
||||
- **THEN** SHALL 从 `~/.nex/config.yaml` 和默认值加载配置
|
||||
|
||||
#### Scenario: Desktop 忽略未知参数
|
||||
|
||||
- **WHEN** desktop 启动时传入未知命令行参数
|
||||
- **THEN** SHALL NOT 因未知参数导致配置加载失败
|
||||
- **THEN** SHALL NOT 将未知参数应用为配置
|
||||
|
||||
### Requirement: 环境变量配置支持
|
||||
|
||||
系统 SHALL 支持通过环境变量设置所有配置项,符合 12-Factor App 原则。
|
||||
server 入口 SHALL 支持通过环境变量设置所有配置项,符合 server 部署场景的 12-Factor App 原则;desktop 入口 SHALL NOT 将 `NEX_*` 环境变量作为配置源。
|
||||
|
||||
#### Scenario: 环境变量读取
|
||||
#### Scenario: Server 环境变量读取
|
||||
|
||||
- **WHEN** 应用启动时存在环境变量
|
||||
- **WHEN** server 应用启动时存在环境变量
|
||||
- **THEN** SHALL 自动读取所有 `NEX_` 前缀的环境变量
|
||||
- **THEN** SHALL 将环境变量值应用到对应配置项
|
||||
|
||||
#### Scenario: 环境变量命名规范
|
||||
|
||||
- **WHEN** 使用环境变量配置
|
||||
- **WHEN** server 使用环境变量配置
|
||||
- **THEN** SHALL 使用 `NEX_` 前缀
|
||||
- **THEN** SHALL 使用大写字母和下划线分隔(如 `NEX_SERVER_PORT`)
|
||||
- **THEN** SHALL 保持完整层次结构(如 `NEX_DATABASE_MAX_IDLE_CONNS`)
|
||||
@@ -382,35 +429,41 @@
|
||||
|
||||
#### Scenario: 环境变量类型转换
|
||||
|
||||
- **WHEN** 解析不同类型的环境变量
|
||||
- **WHEN** server 解析不同类型的环境变量
|
||||
- **THEN** SHALL 支持 int 类型(如 `NEX_SERVER_PORT=9000`)
|
||||
- **THEN** SHALL 支持 string 类型(如 `NEX_DATABASE_PATH=/data/nex.db`)
|
||||
- **THEN** SHALL 支持 duration 类型(如 `NEX_SERVER_READ_TIMEOUT=60s`)
|
||||
- **THEN** SHALL 支持 bool 类型(如 `NEX_LOG_COMPRESS=true`)
|
||||
|
||||
#### Scenario: 完整环境变量覆盖
|
||||
#### Scenario: Server 完整环境变量覆盖
|
||||
|
||||
- **WHEN** 设置服务器相关环境变量
|
||||
- **WHEN** server 设置服务器相关环境变量
|
||||
- **THEN** SHALL 支持 `NEX_SERVER_PORT`、`NEX_SERVER_READ_TIMEOUT`、`NEX_SERVER_WRITE_TIMEOUT`
|
||||
- **WHEN** 设置数据库相关环境变量
|
||||
- **WHEN** server 设置数据库相关环境变量
|
||||
- **THEN** SHALL 支持 `NEX_DATABASE_DRIVER`、`NEX_DATABASE_PATH`、`NEX_DATABASE_HOST`、`NEX_DATABASE_PORT`、`NEX_DATABASE_USER`、`NEX_DATABASE_PASSWORD`、`NEX_DATABASE_DBNAME`、`NEX_DATABASE_MAX_IDLE_CONNS`、`NEX_DATABASE_MAX_OPEN_CONNS`、`NEX_DATABASE_CONN_MAX_LIFETIME`
|
||||
- **WHEN** 设置日志相关环境变量
|
||||
- **WHEN** server 设置日志相关环境变量
|
||||
- **THEN** SHALL 支持 `NEX_LOG_LEVEL`、`NEX_LOG_PATH`、`NEX_LOG_MAX_SIZE`、`NEX_LOG_MAX_BACKUPS`、`NEX_LOG_MAX_AGE`、`NEX_LOG_COMPRESS`
|
||||
|
||||
#### Scenario: 12-Factor App 合规
|
||||
#### Scenario: Server 12-Factor App 合规
|
||||
|
||||
- **WHEN** 应用部署到不同环境
|
||||
- **WHEN** server 部署到不同环境
|
||||
- **THEN** SHALL 通过环境变量区分环境配置
|
||||
- **THEN** SHALL NOT 修改代码或配置文件
|
||||
- **WHEN** 配置包含敏感信息(如密钥、密码)
|
||||
- **WHEN** server 配置包含敏感信息(如密钥、密码)
|
||||
- **THEN** SHALL 通过环境变量传递
|
||||
- **THEN** SHALL NOT 存储在配置文件中
|
||||
|
||||
#### Scenario: 环境变量错误处理
|
||||
#### Scenario: Server 环境变量错误处理
|
||||
|
||||
- **WHEN** 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`)
|
||||
- **WHEN** server 环境变量值格式无效(如 `NEX_SERVER_PORT=abc`)
|
||||
- **THEN** SHALL 返回明确的错误信息,指示环境变量名称和期望类型
|
||||
- **THEN** SHALL NOT 启动应用
|
||||
- **WHEN** 必需配置项既无配置文件也无环境变量
|
||||
- **WHEN** server 必需配置项既无配置文件也无环境变量
|
||||
- **THEN** SHALL 使用默认值
|
||||
- **THEN** SHALL 正常启动应用
|
||||
|
||||
#### Scenario: Desktop 忽略环境变量
|
||||
|
||||
- **WHEN** desktop 启动时存在 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
|
||||
- **THEN** SHALL NOT 读取这些环境变量作为配置源
|
||||
- **THEN** SHALL 使用 `~/.nex/config.yaml` 和默认值加载配置
|
||||
|
||||
@@ -8,16 +8,18 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
### Requirement: 桌面应用启动
|
||||
|
||||
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。
|
||||
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。
|
||||
|
||||
#### Scenario: 双击启动
|
||||
|
||||
- **WHEN** 用户双击桌面应用可执行文件
|
||||
- **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁
|
||||
- **THEN** 系统从 `~/.nex/config.yaml` 和默认值加载启动配置快照
|
||||
- **AND** 系统使用 `gofrs/flock` 尝试获取排他文件锁
|
||||
- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock`
|
||||
- **AND** 系统启动后端服务
|
||||
- **AND** 系统使用启动配置中的 `server.port` 启动后端服务
|
||||
- **AND** 未配置 `server.port` 时默认端口为 9826
|
||||
- **AND** 系统托盘图标出现
|
||||
- **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面
|
||||
- **AND** 浏览器自动打开 `http://localhost:<server.port>` 显示管理界面
|
||||
|
||||
#### Scenario: 单实例检查
|
||||
|
||||
@@ -34,7 +36,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
### Requirement: 系统托盘
|
||||
|
||||
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。
|
||||
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`。
|
||||
|
||||
#### Scenario: 托盘图标显示
|
||||
|
||||
@@ -50,19 +52,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
- **THEN** 显示托盘菜单
|
||||
- **AND** 菜单包含"打开管理界面"选项
|
||||
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
|
||||
- **AND** 菜单包含"端口: 9826"选项(禁用状态)
|
||||
- **AND** 菜单包含"端口: <server.port>"选项(禁用状态)
|
||||
- **AND** 菜单包含"退出"选项
|
||||
|
||||
#### Scenario: 打开管理界面
|
||||
|
||||
- **WHEN** 用户点击托盘菜单"打开管理界面"
|
||||
- **THEN** 系统在浏览器中打开 `http://localhost:9826`
|
||||
- **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
|
||||
|
||||
#### Scenario: 浏览器打开失败
|
||||
|
||||
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
|
||||
- **THEN** 托盘菜单仍可正常使用
|
||||
- **AND** 用户可手动访问 `http://localhost:9826`
|
||||
- **AND** 用户可手动访问 `http://localhost:<server.port>`
|
||||
|
||||
#### Scenario: 退出应用
|
||||
|
||||
@@ -124,19 +126,81 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
||||
|
||||
### Requirement: 端口冲突检测
|
||||
|
||||
系统 SHALL 在启动前检测端口是否可用。
|
||||
系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用。
|
||||
|
||||
#### Scenario: 端口可用
|
||||
#### Scenario: 配置端口可用
|
||||
|
||||
- **WHEN** 端口 9826 未被占用
|
||||
- **WHEN** 启动配置中的 `server.port` 未被占用
|
||||
- **THEN** 服务正常启动
|
||||
|
||||
#### Scenario: 端口被占用
|
||||
#### Scenario: 配置端口被占用
|
||||
|
||||
- **WHEN** 端口 9826 已被其他程序占用
|
||||
- **THEN** 显示错误提示"端口 9826 已被占用"
|
||||
- **WHEN** 启动配置中的 `server.port` 已被其他程序占用
|
||||
- **THEN** 显示错误提示"端口 <server.port> 已被占用"
|
||||
- **AND** 应用退出
|
||||
|
||||
### Requirement: 桌面配置源隔离和启动快照
|
||||
|
||||
desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。
|
||||
|
||||
#### Scenario: Desktop 仅使用默认配置文件
|
||||
|
||||
- **WHEN** desktop 启动
|
||||
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
|
||||
- **THEN** SHALL 在配置文件不存在时使用默认值
|
||||
- **THEN** SHALL 使用默认值补齐配置文件未设置的配置项
|
||||
|
||||
#### Scenario: Desktop 不支持 CLI 配置源
|
||||
|
||||
- **WHEN** desktop 启动时传入 `--server-port 9000`、`--database-path /tmp/test.db` 或 `--config /tmp/custom.yaml`
|
||||
- **THEN** SHALL 忽略这些参数
|
||||
- **THEN** SHALL NOT 将这些参数应用到运行时配置
|
||||
- **THEN** SHALL NOT 使用 `--config` 指定的配置文件路径
|
||||
|
||||
#### Scenario: Desktop 不支持环境变量配置源
|
||||
|
||||
- **WHEN** desktop 启动环境中存在 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
|
||||
- **THEN** SHALL NOT 将这些环境变量应用到运行时配置
|
||||
- **THEN** SHALL 使用默认配置文件和默认值确定运行时配置
|
||||
|
||||
#### Scenario: Desktop 忽略未知启动参数
|
||||
|
||||
- **WHEN** desktop 启动时传入未知命令行参数
|
||||
- **THEN** SHALL NOT 因未知参数导致配置加载失败
|
||||
- **THEN** SHALL 继续使用默认配置文件和默认值加载配置
|
||||
|
||||
#### Scenario: 配置文件修改仅下次启动生效
|
||||
|
||||
- **WHEN** desktop 已启动并正在处理请求
|
||||
- **AND** 用户修改 `~/.nex/config.yaml` 中的 `server.port`、`database.*`、`log.*` 或 timeout 配置
|
||||
- **THEN** 当前运行中的 desktop SHALL NOT 重新加载配置文件
|
||||
- **THEN** 当前运行中的 HTTP server、数据库连接、日志器和请求处理 SHALL NOT 因配置文件修改而重建或中断
|
||||
- **THEN** 修改后的配置 SHALL 在下一次 desktop 启动时生效
|
||||
|
||||
#### Scenario: 配置文件无效
|
||||
|
||||
- **WHEN** desktop 启动时 `~/.nex/config.yaml` 存在但内容无法解析或验证失败
|
||||
- **THEN** SHALL 显示包含配置文件路径和失败原因的错误提示
|
||||
- **THEN** SHALL 退出应用
|
||||
- **THEN** SHALL NOT 静默回退默认配置继续启动
|
||||
|
||||
### Requirement: Desktop 前端同源 API 访问
|
||||
|
||||
desktop 内嵌前端 SHALL 使用同源相对路径访问后端 API,不主动发现、缓存或覆盖 desktop 端口。
|
||||
|
||||
#### Scenario: 同源 API 请求
|
||||
|
||||
- **WHEN** desktop 浏览器页面打开在 `http://localhost:<server.port>`
|
||||
- **THEN** 前端 SHALL 使用 `/api/*`、`/openai/*` 和 `/anthropic/*` 等相对路径访问同一 origin
|
||||
- **THEN** 前端 SHALL NOT 硬编码 desktop 端口
|
||||
|
||||
#### Scenario: 重启后新端口访问
|
||||
|
||||
- **WHEN** 用户修改配置文件中的 `server.port` 并重启 desktop
|
||||
- **THEN** desktop SHALL 按新端口启动并打开 `http://localhost:<new-port>`
|
||||
- **THEN** 前端 SHALL 继续使用当前 origin 的相对路径访问 API
|
||||
- **THEN** 前端 SHALL NOT 扫描端口、读取本地缓存端口或请求端口发现接口
|
||||
|
||||
### Requirement: 跨平台构建
|
||||
|
||||
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。
|
||||
|
||||
@@ -49,13 +49,14 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
|
||||
|
||||
### Requirement: 构建集成 lint 检查
|
||||
|
||||
前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查。
|
||||
前端 SHALL 在 `build` 命令中集成 TypeScript 类型检查、ESLint 检查和 Prettier 格式检查。
|
||||
|
||||
#### Scenario: 构建时执行 lint 和格式检查
|
||||
#### Scenario: 构建时执行类型检查、lint 和格式检查
|
||||
|
||||
- **WHEN** 执行 `bun run build`
|
||||
- **THEN** 构建 SHALL 依次执行 `tsc -b`、`bun run check`、`vite build`
|
||||
- **THEN** `bun run check` SHALL 执行 `bun run lint && bun run format:check`
|
||||
- **THEN** 构建 SHALL 依次执行 `bun run check`、`vite build`
|
||||
- **THEN** `bun run check` SHALL 依次执行 `bun run typecheck`、`bun run lint`、`bun run format:check`
|
||||
- **THEN** 若 `tsc -b` 报告类型错误,构建 SHALL 中断
|
||||
- **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断
|
||||
- **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断
|
||||
|
||||
@@ -77,8 +78,13 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定
|
||||
#### Scenario: 统一检查命令
|
||||
|
||||
- **WHEN** 执行 `bun run check`
|
||||
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
|
||||
- **THEN** lint 错误和格式问题 SHALL 都被检查
|
||||
- **THEN** SHALL 依次运行 `bun run typecheck`、`bun run lint`、`bun run format:check`
|
||||
- **THEN** 类型错误、lint 错误和格式问题 SHALL 都被检查
|
||||
|
||||
#### Scenario: 单独执行类型检查
|
||||
|
||||
- **WHEN** 执行 `bun run typecheck`
|
||||
- **THEN** SHALL 运行 `tsc -b`
|
||||
|
||||
#### Scenario: 统一修复命令
|
||||
|
||||
|
||||
@@ -8,12 +8,33 @@
|
||||
|
||||
### Requirement: pre-commit hook 快速检查
|
||||
|
||||
pre-commit hook SHALL 在 `git commit` 执行前对 staged files 进行快速检查,仅检查本次提交涉及的文件。
|
||||
pre-commit hook SHALL 在 `git commit` 执行前对 staged files 进行快速检查。非代码检查(冲突标记、大文件告警、LFS 指针)SHALL 在 `_hooks-pre-commit` 中直接实现;代码检查(Go 后端、Go versionctl、前端)SHALL 根据 staged 文件类型有条件地委托给已有 Makefile target(`_backend-lint`、`_versionctl-lint`、`_frontend-check`),不再内联独立的 lint 命令。
|
||||
|
||||
#### Scenario: 无 Go 和前端文件变更时跳过
|
||||
#### Scenario: 无 Go 和前端文件变更时跳过代码检查
|
||||
|
||||
- **WHEN** staged files 中既无 `.go` 文件也无 `.ts`/`.tsx`/`.scss` 文件
|
||||
- **THEN** pre-commit hook SHALL 直接通过,不运行任何 linter
|
||||
- **THEN** pre-commit hook SHALL 跳过代码检查委托,仅执行非代码检查
|
||||
|
||||
#### Scenario: Go 文件变更时委托后端 lint
|
||||
|
||||
- **WHEN** staged files 中包含 `backend/*.go` 文件
|
||||
- **THEN** pre-commit hook SHALL 委托 `_backend-lint` target 进行 Go 代码检查
|
||||
- **THEN** `_backend-lint` SHALL 复用 `backend/.golangci.yml` 配置
|
||||
- **THEN** 若 lint 报告任何错误,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: versionctl Go 文件变更时委托 versionctl lint
|
||||
|
||||
- **WHEN** staged files 中包含 `versionctl/*.go` 文件
|
||||
- **THEN** pre-commit hook SHALL 委托 `_versionctl-lint` target 进行 Go 代码检查
|
||||
- **THEN** `_versionctl-lint` SHALL 复用 `versionctl/.golangci.yml` 配置
|
||||
- **THEN** 若 lint 报告任何错误,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: 前端文件变更时委托前端检查
|
||||
|
||||
- **WHEN** staged files 中包含 `.ts`、`.tsx` 或 `.scss` 文件
|
||||
- **THEN** pre-commit hook SHALL 委托 `_frontend-check` target 进行前端代码检查
|
||||
- **THEN** `_frontend-check` SHALL 运行 `bun run check`(包含 `tsc -b` TypeScript 类型检查、ESLint 和 Prettier 格式检查)
|
||||
- **THEN** 若检查报告任何错误,commit SHALL 被阻止
|
||||
|
||||
#### Scenario: 冲突标记检测
|
||||
|
||||
@@ -21,37 +42,26 @@ pre-commit hook SHALL 在 `git commit` 执行前对 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: LFS 指针校验
|
||||
|
||||
- **WHEN** staged files 匹配 `.gitattributes` 中 `filter=lfs` 的路径模式
|
||||
- **THEN** pre-commit hook SHALL 检查 staged 内容是否为 LFS 指针格式(`version https://git-lfs.github.com/spec/v1`)
|
||||
- **THEN** 若内容不是 LFS 指针格式,commit SHALL 被阻止,并提示安装 git-lfs
|
||||
- **THEN** 若 staged files 不匹配任何 `filter=lfs` 路径模式,SHALL 跳过此检查
|
||||
|
||||
#### Scenario: commit 被阻止时显示修复提示
|
||||
|
||||
- **WHEN** pre-commit hook 检查失败
|
||||
- **THEN** hook SHALL 输出明确的修复提示(如 `bun run fix`、手动解决冲突标记等)
|
||||
- **THEN** hook SHALL 输出明确的修复提示(如 `make lint` 修复代码问题、手动解决冲突标记等)
|
||||
|
||||
### Requirement: commit-msg hook 校验提交信息格式
|
||||
|
||||
commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确保符合项目规范。提交描述 SHALL 使用中文;版本号、英文专有名词可与中文描述混用。
|
||||
commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确保首行符合项目规范。提交描述按项目规范应使用中文,但 hook SHALL NOT 通过 Python/CJK 字符集检测强制判断描述语言,以避免引入新的运行时依赖。
|
||||
|
||||
#### Scenario: 合法格式通过
|
||||
|
||||
@@ -63,12 +73,6 @@ commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确
|
||||
- **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`
|
||||
@@ -89,16 +93,35 @@ commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确
|
||||
- **WHEN** commit-msg hook 检查失败
|
||||
- **THEN** hook SHALL 输出包含正确格式示例的错误信息(如 `feat: 添加供应商批量管理功能`)
|
||||
|
||||
#### Scenario: 不执行字符集检测
|
||||
|
||||
- **WHEN** 提交信息首行格式合法且类型合法,但描述部分不包含 CJK 字符(如 `feat: add hook tests`)
|
||||
- **THEN** commit-msg hook SHALL 通过
|
||||
- **THEN** hook SHALL NOT 调用 `python3` 或其他额外运行时做 Unicode/CJK 检测
|
||||
|
||||
#### Scenario: 多行格式校验
|
||||
|
||||
- **WHEN** 提交信息忽略 `#` 注释行后,第三行及之后存在任一非空详细说明行
|
||||
- **THEN** commit-msg hook SHALL 检查第二行是否为空行
|
||||
- **THEN** 若第二行非空行,commit SHALL 被阻止,提示首行后应空行再写详细描述
|
||||
|
||||
#### Scenario: 模板注释不参与校验
|
||||
|
||||
- **WHEN** 提交信息文件中包含 prepare-commit-msg 写入的 `#` 注释模板
|
||||
- **THEN** commit-msg hook SHALL 忽略这些注释行
|
||||
- **THEN** 注释行 SHALL NOT 导致首行格式、多行空行分隔校验失败
|
||||
|
||||
### Requirement: hooks-install 安装命令
|
||||
|
||||
`make hooks-install` SHALL 将 `scripts/git-hooks/` 下的 hook 脚本安装到 `.git/hooks/`,不覆盖 Git LFS 管理的 hook。
|
||||
|
||||
#### Scenario: 安装 pre-commit 和 commit-msg
|
||||
#### Scenario: 安装所有 hook 脚本
|
||||
|
||||
- **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`)
|
||||
- **THEN** `scripts/git-hooks/prepare-commit-msg` SHALL 被复制到 `.git/hooks/prepare-commit-msg`
|
||||
- **THEN** 所有复制文件 SHALL 被设置为可执行(`chmod +x`)
|
||||
|
||||
#### Scenario: 不覆盖 LFS 管理的 hook
|
||||
|
||||
@@ -113,9 +136,15 @@ commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确
|
||||
#### Scenario: hooks-check 验证安装状态
|
||||
|
||||
- **WHEN** 执行 `make hooks-check`
|
||||
- **THEN** 命令 SHALL 检查 `.git/hooks/pre-commit` 和 `.git/hooks/commit-msg` 是否存在且可执行
|
||||
- **THEN** 命令 SHALL 检查 `.git/hooks/pre-commit`、`.git/hooks/commit-msg`、`.git/hooks/prepare-commit-msg` 是否存在且可执行
|
||||
- **THEN** SHALL 输出每个 hook 的安装状态
|
||||
|
||||
#### Scenario: 安装前验证 source 文件存在
|
||||
|
||||
- **WHEN** 执行 `make hooks-install`
|
||||
- **THEN** 命令 SHALL 在复制前验证每个 source 文件(`scripts/git-hooks/<hook-name>`)是否存在
|
||||
- **THEN** 若 source 文件不存在,命令 SHALL 报告错误并返回非零退出码
|
||||
|
||||
### Requirement: hooks-test 回归测试命令
|
||||
|
||||
`make hooks-test` SHALL 运行仓库内 hook 回归测试,覆盖 commit-msg 格式校验和 pre-commit staged-file 检查,不污染真实 git index。
|
||||
@@ -146,19 +175,19 @@ pre-commit 和 commit-msg hook 脚本 SHALL 可在 macOS 和 Windows(Git Bash
|
||||
|
||||
### Requirement: pre-commit 核心逻辑在 Makefile 中复用
|
||||
|
||||
pre-commit hook 的检查逻辑 SHALL 通过 Makefile target 调用项目已有工具链,不重复实现 hook 框架逻辑。commit-msg hook SHALL 在脚本内直接完成格式校验。
|
||||
pre-commit hook 的检查逻辑 SHALL 通过 Makefile target 调用项目已有工具链,不重复实现。非代码检查(冲突标记、大文件、LFS 指针)SHALL 在 `_hooks-pre-commit` 中直接实现;代码检查 SHALL 委托 `_backend-lint`、`_versionctl-lint`、`_frontend-check` target。
|
||||
|
||||
#### Scenario: Go lint 复用后端配置
|
||||
#### Scenario: Go lint 委托后端 lint target
|
||||
|
||||
- **WHEN** pre-commit 需要检查 Go 文件
|
||||
- **THEN** SHALL 调用 Makefile 逻辑,在 `backend/` 目录对 staged `.go` 文件运行 `go tool golangci-lint run`
|
||||
- **THEN** SHALL 复用 `backend/.golangci.yml` 中的 lint 配置
|
||||
- **THEN** SHALL 委托 `_backend-lint` 或 `_versionctl-lint` target(根据文件路径 `backend/` vs `versionctl/`)
|
||||
- **THEN** SHALL NOT 在 `_hooks-pre-commit` 中内联 `golangci-lint` 命令
|
||||
|
||||
#### Scenario: 前端 lint 使用 staged 文件参数
|
||||
#### Scenario: 前端检查委托前端 check target
|
||||
|
||||
- **WHEN** pre-commit 需要检查前端文件
|
||||
- **THEN** SHALL 调用 Makefile 逻辑,在 `frontend/` 目录对 staged 前端文件运行 ESLint 和 Prettier 的文件参数模式
|
||||
- **THEN** SHALL NOT 在 pre-commit 阶段运行全量 `bun run check`
|
||||
- **THEN** SHALL 委托 `_frontend-check` target
|
||||
- **THEN** SHALL NOT 在 `_hooks-pre-commit` 中内联 `eslint` 或 `prettier` 命令
|
||||
|
||||
#### Scenario: 终端直接调试
|
||||
|
||||
|
||||
57
openspec/specs/prepare-commit-msg-hook/spec.md
Normal file
57
openspec/specs/prepare-commit-msg-hook/spec.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# prepare-commit-msg-hook
|
||||
|
||||
## Purpose
|
||||
|
||||
定义 prepare-commit-msg Git hook,在 `git commit` 编辑器打开时为开发者提供提交信息模板。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: prepare-commit-msg hook 提供提交信息模板
|
||||
|
||||
prepare-commit-msg hook SHALL 在 `git commit` 打开编辑器时,将规范格式的提交信息模板预填充到提交信息文件中,辅助开发者编写符合项目规范的多行提交信息。
|
||||
|
||||
#### Scenario: 模板预填充到提交信息文件
|
||||
|
||||
- **WHEN** `git commit` 被执行且编辑器打开提交信息文件
|
||||
- **THEN** prepare-commit-msg hook SHALL 在提交信息文件中写入模板内容
|
||||
- **THEN** 模板 SHALL 包含注释行(以 `#` 开头)引导开发者填写规范格式
|
||||
|
||||
#### Scenario: 模板包含格式引导
|
||||
|
||||
- **WHEN** 模板被写入提交信息文件
|
||||
- **THEN** 模板 SHALL 包含首行格式提示:`# <类型>: <简短中文描述>`
|
||||
- **THEN** 模板 SHALL 包含空行占位符
|
||||
- **THEN** 模板 SHALL 包含详细描述区:`# <详细说明>`
|
||||
- **THEN** 模板 SHALL 列出可用类型:`feat / fix / refactor / docs / style / test / chore`
|
||||
- **THEN** 模板 SHALL 包含示例:`feat: 添加供应商批量管理功能`
|
||||
|
||||
#### Scenario: 注释行不被提交
|
||||
|
||||
- **WHEN** 用户在编辑器中基于模板填写提交信息并保存
|
||||
- **THEN** 以 `#` 开头的模板注释行 SHALL 被 Git 作为注释过滤,不会成为提交信息的一部分
|
||||
|
||||
#### Scenario: 已有提交信息时跳过
|
||||
|
||||
- **WHEN** 提交信息文件已包含非注释内容(如 `-m` 参数指定、`git commit --amend`、merge commit、cherry-pick)
|
||||
- **THEN** prepare-commit-msg hook SHALL NOT 覆盖已有内容,直接退出
|
||||
|
||||
#### Scenario: Git 默认注释不阻止模板写入
|
||||
|
||||
- **WHEN** 提交信息文件只包含空行或 Git 默认生成的 `#` 注释行
|
||||
- **THEN** prepare-commit-msg hook SHALL 将其视为没有已有提交信息
|
||||
- **THEN** hook SHALL 在文件顶部写入模板,并保留 Git 原有注释内容
|
||||
|
||||
### Requirement: 通过 hooks-install 安装
|
||||
|
||||
prepare-commit-msg hook SHALL 随 `make hooks-install` 一起安装到 `.git/hooks/`。
|
||||
|
||||
#### Scenario: 安装 prepare-commit-msg
|
||||
|
||||
- **WHEN** 执行 `make hooks-install`
|
||||
- **THEN** `scripts/git-hooks/prepare-commit-msg` SHALL 被复制到 `.git/hooks/prepare-commit-msg`
|
||||
- **THEN** 该文件 SHALL 被设置为可执行(`chmod +x`)
|
||||
|
||||
#### Scenario: hooks-check 验证安装状态
|
||||
|
||||
- **WHEN** 执行 `make hooks-check`
|
||||
- **THEN** 命令 SHALL 检查 `.git/hooks/prepare-commit-msg` 是否存在且可执行
|
||||
@@ -189,7 +189,8 @@
|
||||
|
||||
- `format = "prettier --write ."` — 格式化所有文件
|
||||
- `format:check = "prettier --check ."` — 检查文件格式
|
||||
- `check = "bun run lint && bun run format:check"` — 检查 lint 和格式
|
||||
- `typecheck = "tsc -b"` — TypeScript 类型检查
|
||||
- `check = "bun run typecheck && bun run lint && bun run format:check"` — 检查类型、lint 和格式
|
||||
- `fix = "bun run lint:fix && bun run format"` — 修复 lint 问题并格式化
|
||||
|
||||
#### Scenario: 运行格式化命令
|
||||
@@ -207,8 +208,14 @@
|
||||
#### Scenario: 运行统一检查命令
|
||||
|
||||
- **WHEN** 执行 `bun run check`
|
||||
- **THEN** SHALL 运行 `bun run lint && bun run format:check`
|
||||
- **THEN** lint 错误和格式问题 SHALL 都被检查
|
||||
- **THEN** SHALL 依次运行 `bun run typecheck`、`bun run lint`、`bun run format:check`
|
||||
- **THEN** TypeScript 类型错误、lint 错误和格式问题 SHALL 都被检查
|
||||
|
||||
#### Scenario: 运行类型检查命令
|
||||
|
||||
- **WHEN** 执行 `bun run typecheck`
|
||||
- **THEN** SHALL 运行 `tsc -b`
|
||||
- **THEN** TypeScript 类型错误 SHALL 报告错误
|
||||
|
||||
#### Scenario: 运行统一修复命令
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
- **THEN** SHALL 验证 LoadConfigFromPath 正确加载默认值
|
||||
- **THEN** SHALL 验证 YAML 配置文件正确读取
|
||||
- **THEN** SHALL 验证优先级链:CLI 参数 > 环境变量 > YAML 文件 > 默认值
|
||||
- **THEN** SHALL 验证首次启动自动创建配置文件
|
||||
- **THEN** SHALL 验证配置文件缺失时使用默认值,不自动创建配置文件
|
||||
- **THEN** SHALL 验证 SaveConfig 后重新 LoadConfig 数据一致
|
||||
|
||||
#### Scenario: 环境变量覆盖验证
|
||||
@@ -46,11 +46,12 @@
|
||||
- **THEN** SHALL 成功加载
|
||||
- **THEN** 配置值 SHALL 反映环境变量覆盖
|
||||
|
||||
#### Scenario: 自动创建配置文件验证
|
||||
#### Scenario: 配置文件缺失时使用默认值
|
||||
|
||||
- **WHEN** 调用 `LoadConfigFromPath` 并指向不存在的文件路径
|
||||
- **THEN** SHALL 成功加载(不返回 `missing configuration for 'configPath'` 错误)
|
||||
- **THEN** SHALL 返回默认配置对象
|
||||
- **THEN** SHALL NOT 自动创建配置文件
|
||||
|
||||
#### Scenario: handler 错误分支测试
|
||||
|
||||
@@ -303,3 +304,50 @@
|
||||
- **WHEN** 运行 desktop 专属测试
|
||||
- **THEN** SHALL 验证 desktop 模式启动数据库迁移时使用打包迁移资源
|
||||
- **THEN** SHALL 验证应用在发布产物环境中可执行迁移而不依赖源码迁移目录
|
||||
|
||||
### Requirement: Desktop 配置源隔离测试覆盖
|
||||
|
||||
系统 SHALL 为 desktop 配置加载行为建立测试覆盖,验证 desktop 只使用默认配置文件和默认值,不受 CLI 参数或 `NEX_*` 环境变量影响。
|
||||
|
||||
#### Scenario: Desktop 配置文件端口生效
|
||||
|
||||
- **WHEN** 运行 desktop 配置加载相关测试
|
||||
- **THEN** SHALL 验证 `~/.nex/config.yaml` 或等价测试配置文件中的 `server.port` 会进入 desktop 启动配置快照
|
||||
- **THEN** SHALL 验证 desktop 端口检测、HTTP 监听地址、浏览器打开地址和托盘端口显示使用同一个配置端口
|
||||
|
||||
#### Scenario: Desktop 忽略 CLI 参数
|
||||
|
||||
- **WHEN** 测试进程参数包含 `--server-port 9000`、`--database-path /tmp/test.db` 或 `--config /tmp/custom.yaml`
|
||||
- **THEN** desktop 配置加载 SHALL 忽略这些参数
|
||||
- **THEN** desktop 配置加载 SHALL 使用默认配置文件路径和配置文件值
|
||||
|
||||
#### Scenario: Desktop 忽略未知参数
|
||||
|
||||
- **WHEN** 测试进程参数包含未知命令行参数
|
||||
- **THEN** desktop 配置加载 SHALL 成功或仅因配置文件本身无效而失败
|
||||
- **THEN** desktop 配置加载 SHALL NOT 因未知参数返回参数解析错误
|
||||
|
||||
#### Scenario: Desktop 忽略环境变量
|
||||
|
||||
- **WHEN** 测试环境设置 `NEX_SERVER_PORT`、`NEX_DATABASE_PATH`、`NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
|
||||
- **THEN** desktop 配置加载 SHALL NOT 使用这些环境变量覆盖配置文件值
|
||||
- **THEN** server 配置加载的环境变量覆盖测试 SHALL 继续通过
|
||||
|
||||
#### Scenario: Desktop 配置快照不随文件变化自动更新
|
||||
|
||||
- **WHEN** desktop 配置已加载为内存中的启动快照
|
||||
- **AND** 测试修改配置文件中的 `server.port` 或其他配置项
|
||||
- **THEN** 已加载的配置对象 SHALL 保持原值
|
||||
- **THEN** 重新启动或重新执行 desktop 配置加载时 SHALL 读取修改后的配置值
|
||||
|
||||
#### Scenario: Desktop 无效配置错误提示
|
||||
|
||||
- **WHEN** desktop 启动时配置文件存在但 YAML 无法解析或配置验证失败
|
||||
- **THEN** 测试 SHALL 验证启动流程返回或显示包含配置路径和失败原因的错误
|
||||
- **THEN** 测试 SHALL 验证 desktop 不会静默回退默认配置继续启动
|
||||
|
||||
#### Scenario: 配置文件缺失时使用默认值
|
||||
|
||||
- **WHEN** 测试配置加载时指定不存在的配置文件路径
|
||||
- **THEN** SHALL 返回默认配置值,不自动创建配置文件
|
||||
- **THEN** 测试 SHALL 验证配置文件未被创建
|
||||
|
||||
@@ -8,7 +8,33 @@ if [ ! -f "$MSG_FILE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS= read -r FIRST_LINE < "$MSG_FILE" || FIRST_LINE=
|
||||
FIRST_LINE=
|
||||
SECOND_LINE=
|
||||
HAS_BODY=
|
||||
LINE_NO=0
|
||||
|
||||
while IFS= read -r LINE || [ -n "$LINE" ]; do
|
||||
case "$LINE" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
|
||||
if [ -z "$FIRST_LINE" ]; then
|
||||
[ -n "$LINE" ] || continue
|
||||
FIRST_LINE=$LINE
|
||||
LINE_NO=1
|
||||
continue
|
||||
fi
|
||||
|
||||
LINE_NO=$((LINE_NO + 1))
|
||||
case "$LINE_NO" in
|
||||
2) SECOND_LINE=$LINE ;;
|
||||
*)
|
||||
if [ -n "$LINE" ]; then
|
||||
HAS_BODY=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done < "$MSG_FILE"
|
||||
|
||||
case "$FIRST_LINE" in
|
||||
Merge*)
|
||||
@@ -31,12 +57,11 @@ 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
|
||||
|
||||
if [ -n "$HAS_BODY" ] && [ -n "$SECOND_LINE" ]; then
|
||||
printf '%s\n' '提交信息首行后应为空行,再写详细描述。' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
49
scripts/git-hooks/prepare-commit-msg
Executable file
49
scripts/git-hooks/prepare-commit-msg
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
MSG_FILE=$1
|
||||
MSG_SOURCE=$2
|
||||
|
||||
case "$MSG_SOURCE" in
|
||||
"") ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$MSG_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
has_content=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|\#*) ;;
|
||||
*)
|
||||
has_content=1
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done < "$MSG_FILE"
|
||||
|
||||
if [ "$has_content" -eq 1 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp_file=${MSG_FILE}.nex-template.$$
|
||||
{
|
||||
cat <<'EOF'
|
||||
# <类型>: <简短中文描述>
|
||||
#
|
||||
# <详细说明>
|
||||
#
|
||||
# 类型: feat / fix / refactor / docs / style / test / chore
|
||||
# 示例: feat: 添加供应商批量管理功能
|
||||
EOF
|
||||
if [ -s "$MSG_FILE" ]; then
|
||||
printf '\n'
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
printf '%s\n' "$line"
|
||||
done < "$MSG_FILE"
|
||||
fi
|
||||
} > "$tmp_file"
|
||||
|
||||
mv "$tmp_file" "$MSG_FILE"
|
||||
@@ -14,7 +14,9 @@ cleanup() {
|
||||
frontend/src/hook_format_fixture.ts \
|
||||
docs/hook-doc-fixture.md \
|
||||
docs/hook-conflict-fixture.md \
|
||||
docs/hook-large-fixture.txt
|
||||
docs/hook-large-fixture.txt \
|
||||
"$TMP_DIR/lfs-pointer-fixture" \
|
||||
"$TMP_DIR/lfs-bad-fixture"
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
|
||||
@@ -35,6 +37,14 @@ write_msg() {
|
||||
printf '%s\n' "$*" > "$file"
|
||||
}
|
||||
|
||||
write_conflict() {
|
||||
file=$1
|
||||
less7=$(printf '<%.0s' $(seq 7))
|
||||
eq7=$(printf '=%.0s' $(seq 7))
|
||||
gt7=$(printf '>%.0s' $(seq 7))
|
||||
printf '%s\n' "${less7} HEAD" '' "${eq7}" '' "${gt7} branch" > "$file"
|
||||
}
|
||||
|
||||
expect_success() {
|
||||
name=$1
|
||||
shift
|
||||
@@ -66,12 +76,34 @@ run_precommit_for() {
|
||||
GIT_INDEX_FILE=$index make _hooks-pre-commit
|
||||
}
|
||||
|
||||
run_hooks_install_missing_source() {
|
||||
install_repo=$TMP_DIR/hooks-install-missing
|
||||
rm -rf "$install_repo"
|
||||
mkdir -p "$install_repo/scripts/git-hooks"
|
||||
cp Makefile "$install_repo/Makefile"
|
||||
cp scripts/git-hooks/pre-commit "$install_repo/scripts/git-hooks/pre-commit"
|
||||
cp scripts/git-hooks/commit-msg "$install_repo/scripts/git-hooks/commit-msg"
|
||||
git -C "$install_repo" init >/dev/null 2>&1
|
||||
(cd "$install_repo" && make hooks-install)
|
||||
}
|
||||
|
||||
MSG_FILE=$TMP_DIR/commit-msg.txt
|
||||
|
||||
# ============================================
|
||||
# commit-msg 测试
|
||||
# ============================================
|
||||
|
||||
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"
|
||||
expect_success 'commit-msg accepts English-only description without CJK enforcement' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
write_msg "$MSG_FILE" 'fix: 修复 auth 模块 bug'
|
||||
expect_success 'commit-msg accepts Chinese with English technical terms' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
write_msg "$MSG_FILE" 'docs: ajouter une fonctionnalité'
|
||||
expect_success 'commit-msg accepts non-CJK unicode description without CJK enforcement' 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"
|
||||
@@ -79,6 +111,81 @@ expect_failure 'commit-msg rejects invalid type' scripts/git-hooks/commit-msg "$
|
||||
write_msg "$MSG_FILE" 'Merge branch feature'
|
||||
expect_success 'commit-msg accepts merge commits' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
write_msg "$MSG_FILE" 'feat: 添加新功能
|
||||
'
|
||||
expect_success 'commit-msg accepts single line with trailing newline' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加新功能\n\n详细描述内容\n' > "$MSG_FILE"
|
||||
expect_success 'commit-msg accepts multi-line with blank line separator' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加新功能\n缺少空行\n详细描述\n' > "$MSG_FILE"
|
||||
expect_failure 'commit-msg rejects multi-line without blank line separator' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加新功能\n\n' > "$MSG_FILE"
|
||||
expect_success 'commit-msg accepts two lines with blank line 2' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加新功能\n非空行\n' > "$MSG_FILE"
|
||||
expect_success 'commit-msg accepts two lines without body (no line 3)' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加模板测试\n# <类型>: <简短中文描述>\n#\n# <详细说明>\n' > "$MSG_FILE"
|
||||
expect_success 'commit-msg ignores template comments after subject' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf '# <类型>: <简短中文描述>\n#\nfeat: 添加模板测试\n' > "$MSG_FILE"
|
||||
expect_success 'commit-msg ignores leading template comments' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
printf 'feat: 添加新功能\n缺少空行\n# 模板注释\n详细描述\n' > "$MSG_FILE"
|
||||
expect_failure 'commit-msg rejects non-blank separator with intervening comments' scripts/git-hooks/commit-msg "$MSG_FILE"
|
||||
|
||||
# ============================================
|
||||
# prepare-commit-msg 测试
|
||||
# ============================================
|
||||
|
||||
prepare_msg_file="$TMP_DIR/prepare-msg.txt"
|
||||
rm -f "$prepare_msg_file"
|
||||
touch "$prepare_msg_file"
|
||||
expect_success 'prepare-commit-msg writes template for empty commit' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
|
||||
|
||||
if grep -q '<类型>' "$prepare_msg_file" && grep -q 'feat / fix / refactor' "$prepare_msg_file"; then
|
||||
pass 'prepare-commit-msg template contains format guidance'
|
||||
else
|
||||
fail 'prepare-commit-msg template contains format guidance'
|
||||
fi
|
||||
|
||||
printf '\n# Please enter the commit message for your changes.\n# On branch main\n' > "$prepare_msg_file"
|
||||
expect_success 'prepare-commit-msg writes template before git comments' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
|
||||
if grep -q '<类型>' "$prepare_msg_file" && grep -q 'Please enter the commit message' "$prepare_msg_file"; then
|
||||
pass 'prepare-commit-msg preserves git comments after template'
|
||||
else
|
||||
fail 'prepare-commit-msg preserves git comments after template'
|
||||
fi
|
||||
|
||||
write_msg "$prepare_msg_file" 'existing content'
|
||||
expect_success 'prepare-commit-msg skips when file has content' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" ""
|
||||
if printf '%s\n' "$(cat "$prepare_msg_file")" | grep -q '^existing content$'; then
|
||||
pass 'prepare-commit-msg does not overwrite existing content'
|
||||
else
|
||||
fail 'prepare-commit-msg does not overwrite existing content'
|
||||
fi
|
||||
|
||||
rm -f "$prepare_msg_file"
|
||||
touch "$prepare_msg_file"
|
||||
expect_success 'prepare-commit-msg skips for merge' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" "merge"
|
||||
if [ ! -s "$prepare_msg_file" ]; then
|
||||
pass 'prepare-commit-msg skips template for merge'
|
||||
else
|
||||
fail 'prepare-commit-msg skips template for merge'
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# hooks-install 测试
|
||||
# ============================================
|
||||
|
||||
expect_failure 'hooks-install rejects missing source hook' run_hooks_install_missing_source
|
||||
|
||||
# ============================================
|
||||
# pre-commit 测试
|
||||
# ============================================
|
||||
|
||||
cat > backend/pkg/buildinfo/hook_bad_test_fixture.go <<'EOF'
|
||||
package buildinfo
|
||||
|
||||
@@ -88,20 +195,20 @@ 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
|
||||
expect_failure 'pre-commit rejects Go lint errors (delegated to _backend-lint)' 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
|
||||
expect_failure 'pre-commit rejects frontend lint errors (delegated to _frontend-check)' 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
|
||||
expect_failure 'pre-commit rejects frontend format errors (delegated to _frontend-check)' run_precommit_for frontend/src/hook_format_fixture.ts
|
||||
rm -f frontend/src/hook_format_fixture.ts
|
||||
|
||||
cat > docs/hook-doc-fixture.md <<'EOF'
|
||||
@@ -110,16 +217,19 @@ 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
|
||||
write_conflict docs/hook-conflict-fixture.md
|
||||
expect_failure 'pre-commit rejects conflict markers' run_precommit_for docs/hook-conflict-fixture.md
|
||||
rm -f docs/hook-conflict-fixture.md
|
||||
|
||||
index=$TMP_DIR/index
|
||||
rm -f "$index"
|
||||
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||
write_conflict "$TMP_DIR/hook-conflict-fixture.sh"
|
||||
hash=$(git hash-object -w "$TMP_DIR/hook-conflict-fixture.sh")
|
||||
rm -f "$TMP_DIR/hook-conflict-fixture.sh"
|
||||
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "scripts/git-hooks/hook-conflict-fixture.sh"
|
||||
expect_failure 'pre-commit rejects conflict markers in hook scripts' env GIT_INDEX_FILE=$index make _hooks-pre-commit
|
||||
|
||||
i=0
|
||||
while [ "$i" -lt 40000 ]; do
|
||||
printf 'large hook fixture line\n'
|
||||
@@ -132,3 +242,32 @@ else
|
||||
fail 'pre-commit warns for large text files'
|
||||
fi
|
||||
rm -f docs/hook-large-fixture.txt
|
||||
|
||||
# LFS pointer 校验
|
||||
lfs_pointer='version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:abc123
|
||||
size 100
|
||||
'
|
||||
printf '%s\n' "$lfs_pointer" > "$TMP_DIR/lfs-pointer-fixture"
|
||||
hash=$(git hash-object -w "$TMP_DIR/lfs-pointer-fixture")
|
||||
index=$TMP_DIR/index
|
||||
rm -f "$index"
|
||||
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "assets/test-lfs-fixture.png"
|
||||
if GIT_INDEX_FILE=$index make _hooks-pre-commit > "$TMP_DIR/out" 2>&1; then
|
||||
pass 'pre-commit allows LFS pointer files'
|
||||
else
|
||||
cat "$TMP_DIR/out" >&2
|
||||
fail 'pre-commit allows LFS pointer files'
|
||||
fi
|
||||
|
||||
printf 'fake binary content\n' > "$TMP_DIR/lfs-bad-fixture"
|
||||
hash=$(git hash-object -w "$TMP_DIR/lfs-bad-fixture")
|
||||
rm -f "$index"
|
||||
GIT_INDEX_FILE=$index git read-tree HEAD
|
||||
GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "assets/test-lfs-bad-fixture.png"
|
||||
if GIT_INDEX_FILE=$index make _hooks-pre-commit > "$TMP_DIR/out" 2>&1; then
|
||||
cat "$TMP_DIR/out" >&2
|
||||
fail 'pre-commit rejects non-pointer LFS files'
|
||||
fi
|
||||
pass 'pre-commit rejects non-pointer LFS files'
|
||||
|
||||
Reference in New Issue
Block a user