Compare commits
10 Commits
48bacdbed6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fb8761a546 | |||
| fb91556832 | |||
| f42633bbd2 | |||
| 0e9c862ade | |||
| 871e0589cd | |||
| e07d57eea1 | |||
| d7a9a06a9f | |||
| 008ffc4889 | |||
| 5da86b9c6e | |||
| 434a4683ea |
Executable
+354
@@ -0,0 +1,354 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# verify.sh — 检查项目工具链配置是否完整
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./scripts/verify.sh [OPTIONS] [PROJECT_PATH]
|
||||||
|
#
|
||||||
|
# 选项:
|
||||||
|
# -h, --help 显示帮助信息
|
||||||
|
# -v, --verbose 详细输出(显示所有通过项和跳过项)
|
||||||
|
# -s, --strict 严格模式:警告也视为失败
|
||||||
|
#
|
||||||
|
# 退出码:
|
||||||
|
# 0 全部通过(严格模式下无警告且无失败)
|
||||||
|
# 1 存在失败项
|
||||||
|
# 2 命令行参数错误
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── 默认值 ────────────────────────────────────────────────────────────────────
|
||||||
|
VERBOSE=0
|
||||||
|
STRICT=0
|
||||||
|
ROOT="."
|
||||||
|
|
||||||
|
# ── 全局计数(按分类) ────────────────────────────────────────────────────────
|
||||||
|
# 用索引数组而非关联数组(declare -A),以兼容 macOS 自带的 bash 3.2。
|
||||||
|
CATEGORIES=("根目录" "Backend" "Agent" "Frontend" "Pre-commit 钩子" "Claude 规则文件" "工具链可用性")
|
||||||
|
CAT_PASS=(); CAT_WARN=(); CAT_FAIL=()
|
||||||
|
for _i in "${!CATEGORIES[@]}"; do CAT_PASS[$_i]=0; CAT_WARN[$_i]=0; CAT_FAIL[$_i]=0; done
|
||||||
|
TOTAL_PASS=0
|
||||||
|
TOTAL_WARN=0
|
||||||
|
TOTAL_FAIL=0
|
||||||
|
CURRENT_CAT=""
|
||||||
|
CURRENT_IDX=-1
|
||||||
|
|
||||||
|
# ── 颜色 ──────────────────────────────────────────────────────────────────────
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
C_GREEN='\033[32m'; C_YELLOW='\033[33m'; C_RED='\033[31m'
|
||||||
|
C_CYAN='\033[36m'; C_BOLD='\033[1m'; C_RESET='\033[0m'
|
||||||
|
else
|
||||||
|
C_GREEN=''; C_YELLOW=''; C_RED=''; C_CYAN=''; C_BOLD=''; C_RESET=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 帮助 ──────────────────────────────────────────────────────────────────────
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
${C_BOLD}用法:${C_RESET} $(basename "$0") [OPTIONS] [PROJECT_PATH]
|
||||||
|
|
||||||
|
检查项目工具链配置是否符合 scaffold 规范。
|
||||||
|
不修改任何文件,安全幂等,可重复运行。
|
||||||
|
|
||||||
|
${C_BOLD}选项:${C_RESET}
|
||||||
|
-h, --help 显示此帮助信息
|
||||||
|
-v, --verbose 详细输出(含通过项、跳过项的具体路径)
|
||||||
|
-s, --strict 严格模式:警告(WARN)也视为失败,退出码 1
|
||||||
|
|
||||||
|
${C_BOLD}参数:${C_RESET}
|
||||||
|
PROJECT_PATH 要检查的项目根目录(默认:当前目录)
|
||||||
|
|
||||||
|
${C_BOLD}示例:${C_RESET}
|
||||||
|
$(basename "$0") # 检查当前目录
|
||||||
|
$(basename "$0") /path/to/project # 检查指定目录
|
||||||
|
$(basename "$0") -v -s . # 详细 + 严格模式
|
||||||
|
|
||||||
|
${C_BOLD}退出码:${C_RESET}
|
||||||
|
0 全部通过(严格模式下无警告)
|
||||||
|
1 存在失败项(或严格模式下存在警告)
|
||||||
|
2 参数错误
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 参数解析 ──────────────────────────────────────────────────────────────────
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
-v|--verbose) VERBOSE=1; shift ;;
|
||||||
|
-s|--strict) STRICT=1; shift ;;
|
||||||
|
-vs|-sv) VERBOSE=1; STRICT=1; shift ;;
|
||||||
|
-*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
|
||||||
|
*) ROOT="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
ROOT="$(cd "$ROOT" 2>/dev/null && pwd)" || { echo "路径不存在: $ROOT" >&2; exit 2; }
|
||||||
|
|
||||||
|
# ── 输出辅助 ──────────────────────────────────────────────────────────────────
|
||||||
|
log_pass() {
|
||||||
|
TOTAL_PASS=$((TOTAL_PASS + 1))
|
||||||
|
[ "$CURRENT_IDX" -ge 0 ] && CAT_PASS[$CURRENT_IDX]=$(( ${CAT_PASS[$CURRENT_IDX]:-0} + 1 ))
|
||||||
|
[ "$VERBOSE" -eq 1 ] && printf " ${C_GREEN}✓${C_RESET} %s\n" "$*"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
TOTAL_WARN=$((TOTAL_WARN + 1))
|
||||||
|
[ "$CURRENT_IDX" -ge 0 ] && CAT_WARN[$CURRENT_IDX]=$(( ${CAT_WARN[$CURRENT_IDX]:-0} + 1 ))
|
||||||
|
printf " ${C_YELLOW}△${C_RESET} %s\n" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_fail() {
|
||||||
|
TOTAL_FAIL=$((TOTAL_FAIL + 1))
|
||||||
|
[ "$CURRENT_IDX" -ge 0 ] && CAT_FAIL[$CURRENT_IDX]=$(( ${CAT_FAIL[$CURRENT_IDX]:-0} + 1 ))
|
||||||
|
printf " ${C_RED}✗${C_RESET} %s\n" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_skip() {
|
||||||
|
[ "$VERBOSE" -eq 1 ] && printf " ${C_CYAN}–${C_RESET} %s\n" "$*"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
section() {
|
||||||
|
CURRENT_CAT="$1"
|
||||||
|
CURRENT_IDX=-1
|
||||||
|
local i
|
||||||
|
for i in "${!CATEGORIES[@]}"; do
|
||||||
|
[ "${CATEGORIES[$i]}" = "$1" ] && { CURRENT_IDX=$i; break; }
|
||||||
|
done
|
||||||
|
printf "\n${C_BOLD}[ %s ]${C_RESET}\n" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 检查函数 ──────────────────────────────────────────────────────────────────
|
||||||
|
# 检查文件或目录是否存在
|
||||||
|
check() {
|
||||||
|
local label="$1" path="$2" severity="${3:-fail}"
|
||||||
|
if [ -e "$ROOT/$path" ]; then
|
||||||
|
log_pass "$label"
|
||||||
|
else
|
||||||
|
[ "$severity" = "warn" ] && log_warn "$label — 缺失: $path" || log_fail "$label — 缺失: $path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查文件中是否包含指定内容
|
||||||
|
check_contains() {
|
||||||
|
local label="$1" path="$2" pattern="$3" severity="${4:-fail}"
|
||||||
|
if [ ! -e "$ROOT/$path" ]; then
|
||||||
|
log_skip "$label — 文件不存在,跳过内容检查: $path"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if grep -q "$pattern" "$ROOT/$path" 2>/dev/null; then
|
||||||
|
log_pass "$label"
|
||||||
|
else
|
||||||
|
[ "$severity" = "warn" ] \
|
||||||
|
&& log_warn "$label — 未找到 '$pattern' 在 $path" \
|
||||||
|
|| log_fail "$label — 未找到 '$pattern' 在 $path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查命令是否可用
|
||||||
|
check_cmd() {
|
||||||
|
local label="$1" cmd="$2" severity="${3:-warn}"
|
||||||
|
if command -v "$cmd" &>/dev/null; then
|
||||||
|
local ver
|
||||||
|
ver=$(${cmd} --version 2>/dev/null | head -1 || echo "版本未知")
|
||||||
|
log_pass "$label ($ver)"
|
||||||
|
else
|
||||||
|
[ "$severity" = "warn" ] && log_warn "$label — 未安装: $cmd" || log_fail "$label — 未安装: $cmd"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 读取 copier 配置(可选)────────────────────────────────────────────────────
|
||||||
|
COPIER_ANSWERS="$ROOT/.copier-answers.yml"
|
||||||
|
LLM_PROVIDER=""
|
||||||
|
DB_TYPE=""
|
||||||
|
if [ -f "$COPIER_ANSWERS" ]; then
|
||||||
|
LLM_PROVIDER=$(grep "^llm_provider:" "$COPIER_ANSWERS" 2>/dev/null | awk '{print $2}' | tr -d "\"'" || true)
|
||||||
|
DB_TYPE=$(grep "^database:" "$COPIER_ANSWERS" 2>/dev/null | awk '{print $2}' | tr -d "\"'" || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 开始检查 ──────────────────────────────────────────────────────────────────
|
||||||
|
printf "${C_BOLD}═══ 工具链合规检查: %s ═══${C_RESET}\n" "$ROOT"
|
||||||
|
[ -n "$LLM_PROVIDER" ] && printf "${C_CYAN}配置来源:.copier-answers.yml llm_provider=%s database=%s${C_RESET}\n" \
|
||||||
|
"${LLM_PROVIDER:-?}" "${DB_TYPE:-?}"
|
||||||
|
|
||||||
|
# ── 根目录 ────────────────────────────────────────────────────────────────────
|
||||||
|
section "根目录"
|
||||||
|
check "Git 仓库" ".git"
|
||||||
|
check "Pre-commit 配置" ".pre-commit-config.yaml"
|
||||||
|
check "Gitignore" ".gitignore"
|
||||||
|
check "CLAUDE.md" "CLAUDE.md"
|
||||||
|
check "Docker Compose" "docker-compose.yml" "warn"
|
||||||
|
check "Copier 答案文件" ".copier-answers.yml" "warn"
|
||||||
|
|
||||||
|
# ── Backend ───────────────────────────────────────────────────────────────────
|
||||||
|
if [ -d "$ROOT/backend" ]; then
|
||||||
|
section "Backend"
|
||||||
|
check "pyproject.toml" "backend/pyproject.toml"
|
||||||
|
check "uv.lock" "backend/uv.lock"
|
||||||
|
check ".env.example" "backend/.env.example"
|
||||||
|
check "Dockerfile" "backend/Dockerfile" "warn"
|
||||||
|
check_contains "ruff 命名规则 N" "backend/pyproject.toml" '"N"'
|
||||||
|
check_contains "ruff 禁 print T20" "backend/pyproject.toml" '"T20"'
|
||||||
|
check_contains "mypy strict" "backend/pyproject.toml" "strict = true"
|
||||||
|
check_contains "pytest-asyncio 配置" "backend/pyproject.toml" "asyncio_mode"
|
||||||
|
if [ -n "$DB_TYPE" ]; then
|
||||||
|
local_driver="aiosqlite"
|
||||||
|
[ "$DB_TYPE" = "postgresql" ] && local_driver="asyncpg"
|
||||||
|
check_contains "DB driver ($DB_TYPE)" "backend/pyproject.toml" "$local_driver"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_skip "Backend 目录不存在,跳过 Backend 检查"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Agent ─────────────────────────────────────────────────────────────────────
|
||||||
|
if [ -d "$ROOT/agent" ]; then
|
||||||
|
section "Agent"
|
||||||
|
check "pyproject.toml" "agent/pyproject.toml"
|
||||||
|
check "uv.lock" "agent/uv.lock"
|
||||||
|
check ".env.example" "agent/.env.example"
|
||||||
|
check "Dockerfile" "agent/Dockerfile" "warn"
|
||||||
|
check_contains "ruff 命名规则 N" "agent/pyproject.toml" '"N"'
|
||||||
|
check_contains "ruff 禁 print T20" "agent/pyproject.toml" '"T20"'
|
||||||
|
check_contains "mypy strict" "agent/pyproject.toml" "strict = true"
|
||||||
|
check_contains "pytest-asyncio 配置" "agent/pyproject.toml" "asyncio_mode"
|
||||||
|
if [ -n "$LLM_PROVIDER" ]; then
|
||||||
|
case "$LLM_PROVIDER" in
|
||||||
|
gemini-cli) : ;; # pty 是标准库,无需额外包
|
||||||
|
openai-api) check_contains "openai SDK" "agent/pyproject.toml" "openai" ;;
|
||||||
|
anthropic-api) check_contains "anthropic SDK" "agent/pyproject.toml" "anthropic" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_skip "Agent 目录不存在,跳过 Agent 检查"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Frontend ──────────────────────────────────────────────────────────────────
|
||||||
|
if [ -d "$ROOT/frontend" ]; then
|
||||||
|
section "Frontend"
|
||||||
|
check "package.json" "frontend/package.json"
|
||||||
|
check "pnpm-lock.yaml" "frontend/pnpm-lock.yaml"
|
||||||
|
check ".env.example" "frontend/.env.example"
|
||||||
|
check "tsconfig.json" "frontend/tsconfig.json"
|
||||||
|
check "vite.config.ts" "frontend/vite.config.ts"
|
||||||
|
check "vitest.config.ts" "frontend/vitest.config.ts" "warn"
|
||||||
|
check "eslint.config.js" "frontend/eslint.config.js"
|
||||||
|
check_contains "禁 console.log" "frontend/eslint.config.js" "no-console"
|
||||||
|
check_contains "命名约定规则" "frontend/eslint.config.js" "naming-convention"
|
||||||
|
check_contains "TypeScript strict" "frontend/tsconfig.json" '"strict": true'
|
||||||
|
else
|
||||||
|
log_skip "Frontend 目录不存在,跳过 Frontend 检查"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Pre-commit 钩子 ───────────────────────────────────────────────────────────
|
||||||
|
if [ -f "$ROOT/.pre-commit-config.yaml" ]; then
|
||||||
|
section "Pre-commit 钩子"
|
||||||
|
check_contains "Conventional Commits" ".pre-commit-config.yaml" "conventional-pre-commit"
|
||||||
|
check_contains "ruff lint/format" ".pre-commit-config.yaml" "ruff"
|
||||||
|
check_contains "防密钥泄漏" ".pre-commit-config.yaml" "detect-private-key"
|
||||||
|
check_contains "gitleaks" ".pre-commit-config.yaml" "gitleaks" "warn"
|
||||||
|
check "Git hooks 已安装" ".git/hooks/pre-commit" "warn"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Claude 规则文件 ───────────────────────────────────────────────────────────
|
||||||
|
if [ -d "$ROOT/.claude/rules" ]; then
|
||||||
|
section "Claude 规则文件"
|
||||||
|
RULES_DIR="$ROOT/.claude/rules"
|
||||||
|
|
||||||
|
# 全局规则(原理层,始终期望存在)
|
||||||
|
GLOBAL_RULES=(architecture.md ddd.md design-discipline.md communication-protocol.md
|
||||||
|
testing-tdd.md quality-gates.md security.md config.md
|
||||||
|
git-workflow.md conventions.md)
|
||||||
|
for r in "${GLOBAL_RULES[@]}"; do
|
||||||
|
check "全局规则: $r" ".claude/rules/$r"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Backend 落地层规则
|
||||||
|
if [ -d "$ROOT/backend" ]; then
|
||||||
|
check "Backend 规则: backend.md" ".claude/rules/backend.md"
|
||||||
|
check "Backend 规则: backend-db.md" ".claude/rules/backend-db.md"
|
||||||
|
check "Backend 规则: backend-concurrency.md" ".claude/rules/backend-concurrency.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Agent 落地层规则
|
||||||
|
if [ -d "$ROOT/agent" ]; then
|
||||||
|
check "Agent 规则: agent.md" ".claude/rules/agent.md"
|
||||||
|
check "Agent 规则: agent-dag.md" ".claude/rules/agent-dag.md"
|
||||||
|
check "Agent 规则: agent-concurrency.md" ".claude/rules/agent-concurrency.md"
|
||||||
|
check "Agent 规则: agent-extension-points.md" ".claude/rules/agent-extension-points.md"
|
||||||
|
|
||||||
|
# LLM 驱动层规则:按 llm_provider 检查正确的变体
|
||||||
|
if [ -z "$LLM_PROVIDER" ] || [ "$LLM_PROVIDER" = "gemini-cli" ]; then
|
||||||
|
check "Agent LLM 驱动规则: agent-llm-pty.md" ".claude/rules/agent-llm-pty.md"
|
||||||
|
if [ -f "$RULES_DIR/agent-llm-api.md" ]; then
|
||||||
|
log_warn "Agent LLM 驱动规则冲突 — agent-llm-api.md 不应与 gemini-cli 共存"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
check "Agent LLM 驱动规则: agent-llm-api.md" ".claude/rules/agent-llm-api.md"
|
||||||
|
if [ -f "$RULES_DIR/agent-llm-pty.md" ]; then
|
||||||
|
log_warn "Agent LLM 驱动规则冲突 — agent-llm-pty.md 不应与 $LLM_PROVIDER 共存"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Frontend 落地层规则
|
||||||
|
if [ -d "$ROOT/frontend" ]; then
|
||||||
|
check "Frontend 规则: frontend.md" ".claude/rules/frontend.md"
|
||||||
|
check "Frontend 规则: frontend-runtime.md" ".claude/rules/frontend-runtime.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 跨服务规则
|
||||||
|
if [ -d "$ROOT/backend" ] && [ -d "$ROOT/agent" ]; then
|
||||||
|
check "跨服务规则: data-lifecycle.md" ".claude/rules/data-lifecycle.md"
|
||||||
|
check "跨服务规则: communication-catalog.md" ".claude/rules/communication-catalog.md" "warn"
|
||||||
|
check "跨服务规则: error-codes.md" ".claude/rules/error-codes.md" "warn"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warn ".claude/rules/ 目录不存在 — Claude 规则文件未部署"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 工具链可用性(信息性,不影响退出码)─────────────────────────────────────
|
||||||
|
section "工具链可用性"
|
||||||
|
check_cmd "uv" "uv" "warn"
|
||||||
|
check_cmd "pnpm" "pnpm" "warn"
|
||||||
|
check_cmd "pre-commit" "pre-commit" "warn"
|
||||||
|
check_cmd "copier" "copier" "warn"
|
||||||
|
if [ -d "$ROOT/agent" ]; then
|
||||||
|
check_cmd "playwright" "playwright" "warn"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 汇总 ──────────────────────────────────────────────────────────────────────
|
||||||
|
printf "\n${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}\n"
|
||||||
|
printf "${C_BOLD}%-22s %6s %6s %6s${C_RESET}\n" "分类" "通过" "警告" "失败"
|
||||||
|
printf "───────────────────────────────────────────────────────────────\n"
|
||||||
|
for i in "${!CATEGORIES[@]}"; do
|
||||||
|
cat="${CATEGORIES[$i]}"
|
||||||
|
p=${CAT_PASS[$i]:-0}; w=${CAT_WARN[$i]:-0}; f=${CAT_FAIL[$i]:-0}
|
||||||
|
[ $((p + w + f)) -eq 0 ] && continue
|
||||||
|
wc="${C_RESET}"; fc="${C_RESET}"
|
||||||
|
[ "$w" -gt 0 ] && wc="${C_YELLOW}"
|
||||||
|
[ "$f" -gt 0 ] && fc="${C_RED}"
|
||||||
|
printf "%-22s ${C_GREEN}%6d${C_RESET} ${wc}%6d${C_RESET} ${fc}%6d${C_RESET}\n" \
|
||||||
|
"$cat" "$p" "$w" "$f"
|
||||||
|
done
|
||||||
|
printf "───────────────────────────────────────────────────────────────\n"
|
||||||
|
wc="${C_RESET}"; fc="${C_RESET}"
|
||||||
|
[ "$TOTAL_WARN" -gt 0 ] && wc="${C_YELLOW}"
|
||||||
|
[ "$TOTAL_FAIL" -gt 0 ] && fc="${C_RED}"
|
||||||
|
printf "${C_BOLD}%-22s ${C_GREEN}%6d${C_RESET} ${wc}%6d${C_RESET} ${fc}%6d${C_RESET}${C_BOLD}${C_RESET}\n" \
|
||||||
|
"合计" "$TOTAL_PASS" "$TOTAL_WARN" "$TOTAL_FAIL"
|
||||||
|
printf "${C_BOLD}═══════════════════════════════════════════════════════════════${C_RESET}\n"
|
||||||
|
|
||||||
|
# 最终判定
|
||||||
|
if [ "$TOTAL_FAIL" -gt 0 ]; then
|
||||||
|
printf "\n${C_RED}${C_BOLD}✗ %d 项失败,项目尚不合规${C_RESET}\n" "$TOTAL_FAIL"
|
||||||
|
[ "$TOTAL_WARN" -gt 0 ] && printf "${C_YELLOW}△ %d 项警告(非阻塞)${C_RESET}\n" "$TOTAL_WARN"
|
||||||
|
exit 1
|
||||||
|
elif [ "$STRICT" -eq 1 ] && [ "$TOTAL_WARN" -gt 0 ]; then
|
||||||
|
printf "\n${C_YELLOW}${C_BOLD}△ 严格模式:%d 项警告视为失败${C_RESET}\n" "$TOTAL_WARN"
|
||||||
|
exit 1
|
||||||
|
elif [ "$TOTAL_WARN" -gt 0 ]; then
|
||||||
|
printf "\n${C_GREEN}${C_BOLD}✓ 核心检查全部通过${C_RESET} ${C_YELLOW}△ %d 项警告(建议修复)${C_RESET}\n" "$TOTAL_WARN"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
printf "\n${C_GREEN}${C_BOLD}✓ 全部 %d 项通过,项目完全合规${C_RESET}\n" "$TOTAL_PASS"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: docs-check
|
||||||
|
|
||||||
|
# scaffold 仓库自身的 CI(区别于 template/.github/ 那份——那是生成项目用的)。
|
||||||
|
# 防止 README 文档随 template/ 结构改动而漂移。
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "template/**"
|
||||||
|
- "README.md"
|
||||||
|
- "scripts/check-docs.sh"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "template/**"
|
||||||
|
- "README.md"
|
||||||
|
- "scripts/check-docs.sh"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docs-sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: 校验 README 与 template 结构同步
|
||||||
|
run: bash scripts/check-docs.sh
|
||||||
|
- name: 脚本语法检查
|
||||||
|
run: |
|
||||||
|
for s in scripts/*.sh; do bash -n "$s"; done
|
||||||
@@ -26,7 +26,7 @@ curl -fsSL https://raw.githubusercontent.com/baozaotumao2025/scaffold/main/scrip
|
|||||||
|
|
||||||
可用 preset:`principles`(仅规则)· `fullstack` · `fullstack-ai` · `api-only`。
|
可用 preset:`principles`(仅规则)· `fullstack` · `fullstack-ai` · `api-only`。
|
||||||
|
|
||||||
> 模板由 copier 从 GitHub 实时拉取。`verify.sh` 在仓库根 `scripts/` 单一维护,安装时由 `install-skill.sh` 组装进 skill,使安装后的 skill 自包含。
|
> 模板由 copier 从 GitHub 实时拉取。skill 目录 `.claude/skills/new-project/` **自包含**(SKILL.md + scripts/verify.sh),安装即整目录拷贝,符合 skill 可移植惯例。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -107,7 +107,55 @@ domain/(实体+值对象+端口 Protocol) → application/services → infrastru
|
|||||||
|
|
||||||
命令按选型自动裁剪,只生成存在服务对应的层,末尾给出**待填充 TODO 清单**。
|
命令按选型自动裁剪,只生成存在服务对应的层,末尾给出**待填充 TODO 清单**。
|
||||||
|
|
||||||
> 分工:脚手架预生成 `core/` 等可运行 plumbing;`/new-feature` 引导 Claude 搭**结构正确、契约清晰、零需调试**的骨架;业务逻辑你按 `testing-tdd.md`(先测试后实现)自己填。
|
> 分工:脚手架 **copier 预生成完整架构基座**(分层目录 + 横切层,确定性、能跑、过门禁,见下「架构基座」);`/new-feature` 引导 Claude 在基座上搭功能切片骨架;业务逻辑你按 `testing-tdd.md`(先测试后实现)自己填。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude 署名策略(每个生成项目自带)
|
||||||
|
|
||||||
|
控制提交信息里是否包含 `Co-Authored-By: Claude` 署名。**默认 off(不含)**,由 commit-msg 门禁每次提交强制执行。仅本项目生效。
|
||||||
|
|
||||||
|
在 Claude Code 里(项目自带斜杠命令,打 `/` 即可找到):
|
||||||
|
|
||||||
|
```
|
||||||
|
/coauthor status # 查看当前策略
|
||||||
|
/coauthor off # 不含署名(默认)
|
||||||
|
/coauthor on # 保留署名
|
||||||
|
```
|
||||||
|
|
||||||
|
或在终端直接用脚本(CI / 不用 Claude 时):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/claude-attribution.sh status # 查看当前策略
|
||||||
|
./scripts/claude-attribution.sh off # 不含署名(默认)
|
||||||
|
./scripts/claude-attribution.sh on # 保留署名
|
||||||
|
```
|
||||||
|
|
||||||
|
- 策略存于 `git config scaffold.includeClaude`(项目级),`_tasks` 生成时默认设为 `false`
|
||||||
|
- **门禁**:`check-claude-attribution.sh` 挂在 pre-commit 的 commit-msg 阶段——策略 off 时自动剥除任何 Claude 署名行并提示,on 时保留
|
||||||
|
- 即使有人/AI 在提交里带了署名,off 策略下也会被自动清掉,保证不入库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构基座(每个生成项目自带,开箱即跑)
|
||||||
|
|
||||||
|
不加任何功能,生成的项目第一天就有**完整分层结构 + 与业务无关的横切基础设施**,三服务各自通过 ruff/mypy/build 门禁:
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/ agent/src/ frontend/src/
|
||||||
|
├── domain/ (空层包) ├── domain/ dag/ llm/ ├── components/ErrorBoundary
|
||||||
|
├── application/services/ ├── providers/ export/ storage/ ├── services/wsClient
|
||||||
|
├── infrastructure/db/ base(engine) │ (空层包) ├── utils/logger
|
||||||
|
├── routers/ health + ws ├── domain/exceptions ├── App / main(ErrorBoundary
|
||||||
|
├── middleware/ request_id+access ├── utils/ ids/files/concurrency│ + Toaster) + tailwind
|
||||||
|
├── exceptions/ AppException+handler ├── core/ config+logging └── vite 类型
|
||||||
|
├── utils/ ids+files(防穿越) └── main health/ready + ws
|
||||||
|
└── core/ config+logging+deps
|
||||||
|
```
|
||||||
|
|
||||||
|
- **横切层是确定性的**(中间件、异常、DB 接线、日志、WS、错误边界规则里逐字给定)→ copier 预生成,永远一致、不漂移。
|
||||||
|
- **运行时目录**(日志 `~/{项目}/logs/`、临时 `~/{项目}/{uuid}/`)由代码按需创建,不入库。
|
||||||
|
- 空层包(domain/services 等)等 `/new-feature` 或你按规则填业务。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -117,11 +165,14 @@ domain/(实体+值对象+端口 Protocol) → application/services → infrastru
|
|||||||
scaffold/
|
scaffold/
|
||||||
├── copier.yml ← 问卷变量 + 条件排除逻辑
|
├── copier.yml ← 问卷变量 + 条件排除逻辑
|
||||||
├── README.md
|
├── README.md
|
||||||
├── .claude/skills/new-project/
|
├── .claude/skills/new-project/ ← 自包含 skill(拷了即用,符合惯例)
|
||||||
│ └── SKILL.md ← skill 元数据 + 生成指令(verify.sh 安装时组装)
|
│ ├── SKILL.md ← skill 元数据 + 生成指令
|
||||||
|
│ └── scripts/verify.sh ← skill 自带(与根 scripts/verify.sh 同步,CI 防漂移)
|
||||||
|
├── .github/workflows/docs-check.yml ← scaffold 自身 CI:防文档/verify.sh 漂移
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── verify.sh ← 工具链合规检查(幂等只读,单一源)
|
│ ├── verify.sh ← 工具链合规检查(幂等只读,维护/retrofit 用)
|
||||||
│ ├── install-skill.sh ← 把 skill 安装到 ~/.claude/skills/
|
│ ├── install-skill.sh ← 整目录拷贝安装 skill 到 ~/.claude/skills/
|
||||||
|
│ ├── check-docs.sh ← 校验 README 覆盖 template 架构基座目录
|
||||||
│ └── retrofit.sh ← 补装工具链到已有项目
|
│ └── retrofit.sh ← 补装工具链到已有项目
|
||||||
└── template/
|
└── template/
|
||||||
├── .copier-answers.yml.jinja ← 记录模板版本+答案,供 copier update
|
├── .copier-answers.yml.jinja ← 记录模板版本+答案,供 copier update
|
||||||
@@ -133,8 +184,12 @@ scaffold/
|
|||||||
│ └── tasks.json.jinja ← 自动 watch(仅 vscode_auto_watch=true)
|
│ └── tasks.json.jinja ← 自动 watch(仅 vscode_auto_watch=true)
|
||||||
├── docker-compose.yml.jinja
|
├── docker-compose.yml.jinja
|
||||||
├── CLAUDE.md.jinja ← 项目说明参数化
|
├── CLAUDE.md.jinja ← 项目说明参数化
|
||||||
|
├── scripts/
|
||||||
|
│ ├── claude-attribution.sh ← 开关:本项目提交是否含 Claude 署名(默认 off)
|
||||||
|
│ └── check-claude-attribution.sh ← commit-msg 门禁:按开关移除/保留署名
|
||||||
├── .claude/commands/
|
├── .claude/commands/
|
||||||
│ └── new-feature.md.jinja ← 项目内命令:按规则引导生成功能分层代码
|
│ ├── new-feature.md.jinja ← 项目内命令:按规则引导生成功能分层代码
|
||||||
|
│ └── coauthor.md.jinja ← 项目内命令:/coauthor 切换 Claude 署名开关
|
||||||
├── .claude/rules/
|
├── .claude/rules/
|
||||||
│ ├── # 原理层(10 个,静态,始终复制)
|
│ ├── # 原理层(10 个,静态,始终复制)
|
||||||
│ ├── architecture.md / ddd.md / design-discipline.md ...
|
│ ├── architecture.md / ddd.md / design-discipline.md ...
|
||||||
@@ -152,9 +207,13 @@ scaffold/
|
|||||||
│ ├── agent.md.jinja ← 技术栈表 / 依赖 / 目录 / 配置 / 测试
|
│ ├── agent.md.jinja ← 技术栈表 / 依赖 / 目录 / 配置 / 测试
|
||||||
│ ├── agent-concurrency.md.jinja ← PTY 并发节 vs API 并发节
|
│ ├── agent-concurrency.md.jinja ← PTY 并发节 vs API 并发节
|
||||||
│ └── backend-db.md.jinja ← SQLite 配置 vs PostgreSQL 配置
|
│ └── backend-db.md.jinja ← SQLite 配置 vs PostgreSQL 配置
|
||||||
├── backend/ pyproject.toml / Dockerfile / .env.example / src/
|
├── backend/ pyproject / Dockerfile / .env.example
|
||||||
├── agent/ 同上
|
│ └── src/ 架构基座:domain application infrastructure/db routers middleware
|
||||||
└── frontend/ package.json / pnpm-workspace.yaml / tsconfig / eslint / prettier / vitest / vite
|
│ exceptions utils core(详见上「架构基座」)
|
||||||
|
├── agent/ pyproject / Dockerfile / .env.example
|
||||||
|
│ └── src/ 架构基座:domain dag llm providers export storage utils core + WS
|
||||||
|
└── frontend/ package.json / pnpm-workspace / tsconfig / tailwind / postcss / eslint / vite
|
||||||
|
└── src/ 架构基座:components(ErrorBoundary) services(wsClient) utils(logger) + 入口
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ _exclude:
|
|||||||
|
|
||||||
_tasks:
|
_tasks:
|
||||||
- command: git init
|
- command: git init
|
||||||
|
# 默认不在提交中包含 Claude 署名(commit-msg 门禁据此剥除);改用 ./scripts/claude-attribution.sh on 保留
|
||||||
|
- command: "git config scaffold.includeClaude false"
|
||||||
- command: git add .
|
- command: git add .
|
||||||
- command: "git commit -m 'chore: initialize project from scaffold'"
|
- command: "git commit -m 'chore: initialize project from scaffold'"
|
||||||
- command: uv sync
|
- command: uv sync
|
||||||
|
|||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scaffold 仓库自身的一致性检查(不进生成项目、不属 skill):
|
||||||
|
# 1) README 是否记录了 template/ 的架构基座目录(防文档漂移)
|
||||||
|
# 2) skill 自包含的 verify.sh 是否与根 scripts/verify.sh 同步(防两份漂移)
|
||||||
|
# 用法:./scripts/check-docs.sh 退出码 0=一致 / 1=有问题
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
README="$ROOT/README.md"
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
check_service_layers() {
|
||||||
|
local svc="$1" srcdir="$ROOT/template/$svc/src"
|
||||||
|
[ -d "$srcdir" ] || return 0
|
||||||
|
for d in "$srcdir"/*/; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
local name
|
||||||
|
name="$(basename "$d")"
|
||||||
|
if ! grep -q "$name" "$README"; then
|
||||||
|
echo "✗ template/$svc/src/$name/ 未在 README 记录"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
for svc in backend agent frontend; do
|
||||||
|
check_service_layers "$svc"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 顶层关键产物也应在 README 出现
|
||||||
|
for entry in ".vscode" ".claude/commands" ".copier-answers.yml" "pnpm-workspace.yaml"; do
|
||||||
|
if [ -e "$ROOT/template/$entry" ] && ! grep -q "$(basename "$entry")" "$README"; then
|
||||||
|
echo "✗ template/$entry 未在 README 记录"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── skill 自包含校验:两份 verify.sh 必须一致 ──────────────────────────────
|
||||||
|
ROOT_VERIFY="$ROOT/scripts/verify.sh"
|
||||||
|
SKILL_VERIFY="$ROOT/.claude/skills/new-project/scripts/verify.sh"
|
||||||
|
if [ -f "$ROOT_VERIFY" ] && [ -f "$SKILL_VERIFY" ]; then
|
||||||
|
if ! cmp -s "$ROOT_VERIFY" "$SKILL_VERIFY"; then
|
||||||
|
echo "✗ scripts/verify.sh 与 skill 内副本不一致"
|
||||||
|
echo " 改了 scripts/verify.sh 后需同步:cp scripts/verify.sh .claude/skills/new-project/scripts/verify.sh"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✗ verify.sh 缺失(根或 skill 内)"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$fail" -eq 0 ]; then
|
||||||
|
echo "✓ README 覆盖 template 架构基座目录;skill verify.sh 与根一致"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "→ 修复上述问题后重试(文档更新 / 同步 verify.sh)。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+13
-16
@@ -1,9 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 把 new-project skill 安装到 ~/.claude/skills/(全局,任意项目可用)。
|
# 把 new-project skill 安装到 ~/.claude/skills/(全局,任意项目可用)。
|
||||||
#
|
#
|
||||||
# 单一源原则:verify.sh 只在仓库根 scripts/ 维护一份,
|
# skill 目录是自包含的(SKILL.md + scripts/verify.sh 都在 .claude/skills/new-project/ 内),
|
||||||
# 本脚本在安装时把它和 SKILL.md 组装进 ~/.claude/skills/new-project/,
|
# 安装即整目录拷贝——符合 skill 自包含、可移植的惯例,无需"组装"。
|
||||||
# 使安装后的 skill 自包含(无需仓库即可运行验证)。
|
|
||||||
#
|
#
|
||||||
# 用法:
|
# 用法:
|
||||||
# curl -fsSL https://raw.githubusercontent.com/baozaotumao2025/scaffold/main/scripts/install-skill.sh | bash
|
# curl -fsSL https://raw.githubusercontent.com/baozaotumao2025/scaffold/main/scripts/install-skill.sh | bash
|
||||||
@@ -13,14 +12,12 @@ set -euo pipefail
|
|||||||
|
|
||||||
SKILL_NAME="new-project"
|
SKILL_NAME="new-project"
|
||||||
DEST="$HOME/.claude/skills/$SKILL_NAME"
|
DEST="$HOME/.claude/skills/$SKILL_NAME"
|
||||||
RAW_BASE="https://raw.githubusercontent.com/baozaotumao2025/scaffold/main"
|
RAW_BASE="https://raw.githubusercontent.com/baozaotumao2025/scaffold/main/.claude/skills/$SKILL_NAME"
|
||||||
SKILL_MD_URL="$RAW_BASE/.claude/skills/$SKILL_NAME/SKILL.md"
|
# skill 自包含的文件清单(新增文件时在此登记)
|
||||||
VERIFY_URL="$RAW_BASE/scripts/verify.sh"
|
FILES=("SKILL.md" "scripts/verify.sh")
|
||||||
|
|
||||||
# 在仓库内运行时优先用本地文件,否则从 GitHub 拉取
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
LOCAL_SKILL_MD="$SCRIPT_DIR/../.claude/skills/$SKILL_NAME/SKILL.md"
|
LOCAL_SKILL="$SCRIPT_DIR/../.claude/skills/$SKILL_NAME"
|
||||||
LOCAL_VERIFY="$SCRIPT_DIR/verify.sh"
|
|
||||||
|
|
||||||
fetch() { # fetch <url> <dest>
|
fetch() { # fetch <url> <dest>
|
||||||
if command -v curl &>/dev/null; then
|
if command -v curl &>/dev/null; then
|
||||||
@@ -35,14 +32,14 @@ fetch() { # fetch <url> <dest>
|
|||||||
|
|
||||||
mkdir -p "$DEST/scripts"
|
mkdir -p "$DEST/scripts"
|
||||||
|
|
||||||
if [[ -f "$LOCAL_SKILL_MD" && -f "$LOCAL_VERIFY" ]]; then
|
if [[ -d "$LOCAL_SKILL" ]]; then
|
||||||
echo "→ 从本地仓库组装安装"
|
echo "→ 从本地仓库整目录拷贝"
|
||||||
cp "$LOCAL_SKILL_MD" "$DEST/SKILL.md"
|
cp -R "$LOCAL_SKILL/." "$DEST/"
|
||||||
cp "$LOCAL_VERIFY" "$DEST/scripts/verify.sh"
|
|
||||||
else
|
else
|
||||||
echo "→ 从 GitHub 组装安装"
|
echo "→ 从 GitHub 拉取 skill 文件"
|
||||||
fetch "$SKILL_MD_URL" "$DEST/SKILL.md"
|
for f in "${FILES[@]}"; do
|
||||||
fetch "$VERIFY_URL" "$DEST/scripts/verify.sh"
|
fetch "$RAW_BASE/$f" "$DEST/$f"
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chmod +x "$DEST/scripts/verify.sh"
|
chmod +x "$DEST/scripts/verify.sh"
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ── 默认值 ────────────────────────────────────────────────────────────────────
|
# ── 默认值 ────────────────────────────────────────────────────────────────────
|
||||||
SCAFFOLD_URL="${SCAFFOLD_URL:-git+https://github.com/your-org/scaffold.git}"
|
SCAFFOLD_URL="${SCAFFOLD_URL:-git+https://github.com/baozaotumao2025/scaffold.git}"
|
||||||
TARGET="."
|
TARGET="."
|
||||||
DRY_RUN=0
|
DRY_RUN=0
|
||||||
FORCE=0
|
FORCE=0
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
设置本项目提交是否包含 Claude 署名(`Co-Authored-By: Claude`)。
|
||||||
|
|
||||||
|
参数:$ARGUMENTS
|
||||||
|
|
||||||
|
## 行为
|
||||||
|
|
||||||
|
按参数运行开关脚本并回报结果(参数为空时默认 `status`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/claude-attribution.sh <on|off|status>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `off` —— 提交移除 Claude 署名(**默认**)
|
||||||
|
- `on` —— 提交保留 Claude 署名
|
||||||
|
- `status` —— 查看当前策略
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
1. 解析参数为 `on` / `off` / `status` 之一;无参或无法识别时用 `status`。
|
||||||
|
2. 运行 `./scripts/claude-attribution.sh <参数>`,把输出原样回报给用户。
|
||||||
|
3. 一句话说明:策略存于 `git config scaffold.includeClaude`,**仅本项目生效**;由 commit-msg 门禁(pre-commit)每次提交强制执行——`off` 时自动剥除任何 Claude 署名行。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
- 只改本项目设置,不碰其它项目、不碰全局。
|
||||||
|
- 不要手改 `.pre-commit-config.yaml` 或门禁脚本来达到目的——用本命令切换即可。
|
||||||
@@ -24,6 +24,15 @@ repos:
|
|||||||
- id: conventional-pre-commit
|
- id: conventional-pre-commit
|
||||||
stages: [commit-msg]
|
stages: [commit-msg]
|
||||||
|
|
||||||
|
# Claude 署名门禁:按 scaffold.includeClaude 策略移除/保留(默认移除)
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: claude-attribution-gate
|
||||||
|
name: Claude 署名门禁
|
||||||
|
entry: bash scripts/check-claude-attribution.sh
|
||||||
|
language: system
|
||||||
|
stages: [commit-msg]
|
||||||
|
|
||||||
{%- if include_backend or include_agent %}
|
{%- if include_backend or include_agent %}
|
||||||
|
|
||||||
# Python(ruff 同时做 lint + format)
|
# Python(ruff 同时做 lint + format)
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: str = "INFO") -> None:
|
def configure_logging(
|
||||||
"""配置 loguru:结构化日志、单一 stderr sink。
|
level: str = "INFO", work_dir: str = "~/{{ project_slug }}"
|
||||||
|
) -> None:
|
||||||
|
"""配置 loguru:stderr(人类可读)+ {WORK_DIR}/logs/agent.log(JSON 行)。
|
||||||
|
|
||||||
生产可改 serialize=True 输出 JSON、或加文件 sink(见 .claude/rules/observability.md)。
|
日志目录在运行时按需创建(不提交 git)。详见 .claude/rules/observability.md。
|
||||||
"""
|
"""
|
||||||
logger.remove()
|
logger.remove()
|
||||||
logger.add(sys.stderr, level=level.upper(), backtrace=False, diagnose=False)
|
logger.add(sys.stderr, level=level.upper(), backtrace=False, diagnose=False)
|
||||||
|
|
||||||
|
log_dir = Path(work_dir).expanduser() / "logs"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.add(
|
||||||
|
log_dir / "agent.log",
|
||||||
|
level=level.upper(),
|
||||||
|
serialize=True,
|
||||||
|
rotation="100 MB",
|
||||||
|
retention="14 days",
|
||||||
|
compression="zip",
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
class AgentException(Exception): # noqa: N818 # 规则约定名为 AgentException
|
||||||
|
"""Agent 领域异常基类。
|
||||||
|
|
||||||
|
`code` 对应错误码契约(见 .claude/rules/error-codes.md)。属领域层词汇,
|
||||||
|
由 domain 拥有;框架级 handler 注册在接口层。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str, status_code: int = 500) -> None:
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMStartupError(AgentException):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("LLM_STARTUP_ERROR", "LLM 启动/连接失败")
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalServiceTimeoutError(AgentException):
|
||||||
|
def __init__(self, phase: str) -> None:
|
||||||
|
super().__init__("EXTERNAL_SERVICE_TIMEOUT", f"外部依赖在 {phase} 阶段超时")
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalServiceError(AgentException):
|
||||||
|
def __init__(self, detail: str) -> None:
|
||||||
|
super().__init__("EXTERNAL_SERVICE_ERROR", f"外部依赖返回错误: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
class OutputValidationError(AgentException):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("INVALID_INPUT", "无法从输出中提取有效结果")
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .core.config import get_settings
|
from .core.config import get_settings
|
||||||
from .core.logging import configure_logging
|
from .core.logging import configure_logging
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level, settings.work_dir)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="{{ project_name }} Agent",
|
title="{{ project_name }} Agent",
|
||||||
@@ -17,16 +17,41 @@ app = FastAPI(
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
|
"""存活探针。"""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/ready")
|
@app.get("/ready")
|
||||||
async def ready() -> dict[str, str]:
|
async def ready() -> dict[str, str]:
|
||||||
|
"""就绪探针:可在此扩展外部依赖检查。"""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/{session_id}")
|
||||||
|
async def ws_endpoint(ws: WebSocket, session_id: str) -> None:
|
||||||
|
"""会话接入点(骨架)。
|
||||||
|
|
||||||
|
功能开发时在此驱动 DAG runner:建会话 → 节点编排 → 流式推事件
|
||||||
|
(见 .claude/rules/agent-dag.md)。
|
||||||
|
"""
|
||||||
|
await ws.accept()
|
||||||
|
logger.info("ws connected | session_id={}", session_id)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await ws.receive_json()
|
||||||
|
# TODO: 交给 dag.runner 处理,流式回推事件
|
||||||
|
await ws.send_json({"type": "ack", "received": msg.get("type")})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("ws disconnected | session_id={}", session_id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
logger.info("Starting agent | port={}", settings.port)
|
logger.info("Starting agent | port={}", settings.port)
|
||||||
uvicorn.run("src.main:app", host="0.0.0.0", port=settings.port, reload=True)
|
uvicorn.run(
|
||||||
|
"src.main:app",
|
||||||
|
host="0.0.0.0", # noqa: S104 容器内绑定 0.0.0.0 属预期
|
||||||
|
port=settings.port,
|
||||||
|
reload=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_ansi(text: str) -> str:
|
||||||
|
"""过滤 ANSI 转义码。PTY 驱动的 CLI 输出推送前必须清洗(见 agent-llm-pty.md)。"""
|
||||||
|
return _ANSI_ESCAPE.sub("", text)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
def make_session_semaphore(max_concurrent: int) -> asyncio.Semaphore:
|
||||||
|
"""全局会话并发上限信号量。
|
||||||
|
|
||||||
|
每个会话持有 LLM 子进程/连接等昂贵资源,必须封顶(见 agent-concurrency.md)。
|
||||||
|
超出上限的会话应排队,并向前端反馈位置。
|
||||||
|
"""
|
||||||
|
return asyncio.Semaphore(max_concurrent)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def expand(path: str) -> Path:
|
||||||
|
"""展开 ~ 与环境变量,返回绝对 Path。"""
|
||||||
|
return Path(path).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: Path) -> Path:
|
||||||
|
"""确保目录存在(含父级),返回该目录。"""
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def session_dir(work_dir: str, session_id: str) -> Path:
|
||||||
|
"""某会话的独立工作目录 {work_dir}/{session_id}/,不存在则创建。"""
|
||||||
|
return ensure_dir(expand(work_dir) / session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_within(work_dir: str, candidate: str) -> Path:
|
||||||
|
"""校验 candidate 解析后仍在 work_dir 内(防路径穿越),否则抛 ValueError。"""
|
||||||
|
root = expand(work_dir)
|
||||||
|
target = (root / candidate).resolve()
|
||||||
|
if not target.is_relative_to(root):
|
||||||
|
raise ValueError(f"路径越界: {candidate}")
|
||||||
|
return target
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
"""生成新的 UUID4 字符串(用于 session_id 等)。"""
|
||||||
|
return str(uuid.uuid4())
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""FastAPI 依赖注入装配点。
|
||||||
|
|
||||||
|
业务 service / repository 的依赖在此组装:路由依赖端口,端口实现在边界注入。
|
||||||
|
功能开发时把 get_xxx_repo / get_xxx_service 加在这里(见 .claude/rules/backend.md)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..infrastructure.db.base import get_db
|
||||||
|
|
||||||
|
__all__ = ["get_db"]
|
||||||
@@ -1,12 +1,26 @@
|
|||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: str = "INFO") -> None:
|
def configure_logging(
|
||||||
"""配置 loguru:结构化日志、单一 stderr sink。
|
level: str = "INFO", work_dir: str = "~/{{ project_slug }}"
|
||||||
|
) -> None:
|
||||||
|
"""配置 loguru:stderr(人类可读)+ {WORK_DIR}/logs/backend.log(JSON 行)。
|
||||||
|
|
||||||
生产可改 serialize=True 输出 JSON、或加文件 sink(见 .claude/rules/observability.md)。
|
日志目录在运行时按需创建(不提交 git)。详见 .claude/rules/observability.md。
|
||||||
"""
|
"""
|
||||||
logger.remove()
|
logger.remove()
|
||||||
logger.add(sys.stderr, level=level.upper(), backtrace=False, diagnose=False)
|
logger.add(sys.stderr, level=level.upper(), backtrace=False, diagnose=False)
|
||||||
|
|
||||||
|
log_dir = Path(work_dir).expanduser() / "logs"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.add(
|
||||||
|
log_dir / "backend.log",
|
||||||
|
level=level.upper(),
|
||||||
|
serialize=True, # JSON 每行一条
|
||||||
|
rotation="100 MB",
|
||||||
|
retention="14 days",
|
||||||
|
compression="zip",
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
class AppException(Exception): # noqa: N818 # 规则约定名为 AppException(见 backend.md)
|
||||||
|
"""业务异常基类。
|
||||||
|
|
||||||
|
`code` 对应错误码契约(见 .claude/rules/error-codes.md),前端按 code 分支处理。
|
||||||
|
禁止在路由层直接 raise HTTPException——抛 AppException 子类,
|
||||||
|
由 error_handler 统一转译。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str, status_code: int = 500) -> None:
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidInputError(AppException):
|
||||||
|
def __init__(self, message: str = "输入校验失败") -> None:
|
||||||
|
super().__init__("INVALID_INPUT", message, status_code=400)
|
||||||
|
|
||||||
|
|
||||||
|
class InternalError(AppException):
|
||||||
|
def __init__(self, message: str = "服务器内部错误") -> None:
|
||||||
|
super().__init__("INTERNAL_ERROR", message, status_code=500)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .base import AppException
|
||||||
|
|
||||||
|
|
||||||
|
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
|
||||||
|
request_id = getattr(request.state, "request_id", None)
|
||||||
|
logger.warning("AppException | code={} msg={}", exc.code, exc.message)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={
|
||||||
|
"error": {
|
||||||
|
"code": exc.code,
|
||||||
|
"message": exc.message,
|
||||||
|
"request_id": request_id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
request_id = getattr(request.state, "request_id", None)
|
||||||
|
logger.exception("Unhandled exception")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={
|
||||||
|
"error": {
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": "服务器内部错误",
|
||||||
|
"request_id": request_id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
# handler 形参类型为具体异常,与 Starlette 宽签名不完全一致,忽略该告警
|
||||||
|
app.add_exception_handler(AppException, app_exception_handler) # type: ignore[arg-type]
|
||||||
|
app.add_exception_handler(Exception, global_exception_handler)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
{% if database == 'sqlite' -%}
|
||||||
|
from sqlalchemy import event
|
||||||
|
{% endif -%}
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from ...core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""所有 ORM 模型的声明基类。"""
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=settings.env == "dev",
|
||||||
|
{%- if database == 'sqlite' %}
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
{%- elif database == 'postgresql' %}
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
{%- endif %}
|
||||||
|
)
|
||||||
|
{% if database == 'sqlite' %}
|
||||||
|
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _set_sqlite_pragma(dbapi_conn: object, _: object) -> None:
|
||||||
|
cursor = dbapi_conn.cursor() # type: ignore[attr-defined]
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL") # 读写不互斥
|
||||||
|
cursor.execute("PRAGMA busy_timeout=5000") # 锁等待 5s 而非立即报错
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
{% endif %}
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""请求级会话:提交/回滚由本依赖统一管理。"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
@@ -3,9 +3,13 @@ from loguru import logger
|
|||||||
|
|
||||||
from .core.config import get_settings
|
from .core.config import get_settings
|
||||||
from .core.logging import configure_logging
|
from .core.logging import configure_logging
|
||||||
|
from .exceptions.handlers import register_exception_handlers
|
||||||
|
from .middleware.access_log import AccessLogMiddleware
|
||||||
|
from .middleware.request_id import RequestIdMiddleware
|
||||||
|
from .routers import health, ws
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level, settings.work_dir)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="{{ project_name }} Backend",
|
title="{{ project_name }} Backend",
|
||||||
@@ -14,14 +18,25 @@ app = FastAPI(
|
|||||||
openapi_url="/openapi.json" if settings.enable_api_docs else None,
|
openapi_url="/openapi.json" if settings.enable_api_docs else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 中间件(后添加的先执行):先打 request_id,再记访问日志
|
||||||
|
app.add_middleware(AccessLogMiddleware)
|
||||||
|
app.add_middleware(RequestIdMiddleware)
|
||||||
|
|
||||||
@app.get("/health")
|
# 统一异常 → 标准错误响应
|
||||||
async def health() -> dict[str, str]:
|
register_exception_handlers(app)
|
||||||
return {"status": "ok"}
|
|
||||||
|
# 路由
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(ws.router)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
logger.info("Starting backend | port={}", settings.port)
|
logger.info("Starting backend | port={}", settings.port)
|
||||||
uvicorn.run("src.main:app", host="0.0.0.0", port=settings.port, reload=True)
|
uvicorn.run(
|
||||||
|
"src.main:app",
|
||||||
|
host="0.0.0.0", # noqa: S104 容器内绑定 0.0.0.0 属预期
|
||||||
|
port=settings.port,
|
||||||
|
reload=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
|
||||||
|
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""结构化记录每个 HTTP 请求:method / path / status / 耗时。"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||||
|
) -> Response:
|
||||||
|
start = time.perf_counter()
|
||||||
|
response = await call_next(request)
|
||||||
|
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||||
|
logger.info(
|
||||||
|
"access | method={} path={} status={} duration_ms={}",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
response.status_code,
|
||||||
|
duration_ms,
|
||||||
|
)
|
||||||
|
return response
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from ..utils.ids import new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""为每个请求注入 X-Request-Id,并绑定到 loguru 上下文,贯穿全链路日志。"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||||
|
) -> Response:
|
||||||
|
request_id = request.headers.get("X-Request-Id") or new_uuid()
|
||||||
|
request.state.request_id = request_id
|
||||||
|
with logger.contextualize(request_id=request_id):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-Id"] = request_id
|
||||||
|
return response
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
"""存活探针。"""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ready")
|
||||||
|
async def ready() -> dict[str, str]:
|
||||||
|
"""就绪探针:可在此扩展依赖检查(DB 连通等)。"""
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/{session_id}")
|
||||||
|
async def ws_endpoint(ws: WebSocket, session_id: str) -> None:
|
||||||
|
"""WebSocket 接入点(骨架)。
|
||||||
|
|
||||||
|
架构定位:Backend 是 WS 代理层{% if include_agent %},转发 Browser ↔ Agent{% endif %}。
|
||||||
|
功能开发时在此接入会话服务{% if include_agent %} / Agent 转发{% endif %},
|
||||||
|
见 .claude/rules/backend.md、communication-protocol.md。
|
||||||
|
"""
|
||||||
|
await ws.accept()
|
||||||
|
logger.info("ws connected | session_id={}", session_id)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await ws.receive_json()
|
||||||
|
# TODO: 校验信封 → 分发命令 → 回推事件(见 communication-catalog.md)
|
||||||
|
await ws.send_json({"type": "ack", "received": msg.get("type")})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("ws disconnected | session_id={}", session_id)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def expand(path: str) -> Path:
|
||||||
|
"""展开 ~ 与环境变量,返回绝对 Path。"""
|
||||||
|
return Path(path).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: Path) -> Path:
|
||||||
|
"""确保目录存在(含父级),返回该目录。"""
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def session_dir(work_dir: str, session_id: str) -> Path:
|
||||||
|
"""某会话的独立工作目录 {work_dir}/{session_id}/,不存在则创建。"""
|
||||||
|
return ensure_dir(expand(work_dir) / session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_within(work_dir: str, candidate: str) -> Path:
|
||||||
|
"""校验 candidate 解析后仍在 work_dir 内(防路径穿越),否则抛 ValueError。"""
|
||||||
|
root = expand(work_dir)
|
||||||
|
target = (root / candidate).resolve()
|
||||||
|
if not target.is_relative_to(root):
|
||||||
|
raise ValueError(f"路径越界: {candidate}")
|
||||||
|
return target
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
"""生成新的 UUID4 字符串(用于 request_id / session_id 等)。"""
|
||||||
|
return str(uuid.uuid4())
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main className="mx-auto max-w-2xl p-8">
|
||||||
<h1>{{ project_name }}</h1>
|
<h1 className="text-2xl font-bold">{{ project_name }}</h1>
|
||||||
<p>脚手架已就绪。按 <code>.claude/rules</code> 在 <code>src/</code> 下分层实现业务。</p>
|
<p className="mt-2 text-gray-600">
|
||||||
|
脚手架已就绪。按 <code className="rounded bg-gray-100 px-1">.claude/rules</code>
|
||||||
|
{" "}在 <code className="rounded bg-gray-100 px-1">src/</code> 下分层实现业务。
|
||||||
|
</p>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
type Props = { children: ReactNode };
|
||||||
|
type State = { hasError: boolean };
|
||||||
|
|
||||||
|
// 组件级错误边界:捕获渲染异常,记录日志并展示兜底 UI。
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): State {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
logger.error("component error", { message: error.message, info });
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return <div className="p-8 text-red-600">页面出错了,请刷新重试。</div>;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,3 @@
|
|||||||
:root {
|
@tailwind base;
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
@tailwind components;
|
||||||
line-height: 1.5;
|
@tailwind utilities;
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
background: #f0f0f0;
|
|
||||||
padding: 0.1em 0.3em;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
@@ -9,6 +11,9 @@ if (!root) throw new Error("root element not found");
|
|||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
<Toaster />
|
||||||
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export type WsHandlers = {
|
||||||
|
onMessage: (data: unknown) => void;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通用 WebSocket 客户端:建连 + 收发 + 消息反序列化。
|
||||||
|
// 业务层在 hooks 中调用,组件不直接碰(见 .claude/rules/frontend.md 分层边界)。
|
||||||
|
export function connectWs(url: string, handlers: WsHandlers): WebSocket {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
ws.onopen = (): void => {
|
||||||
|
logger.info("ws open", { url });
|
||||||
|
handlers.onOpen?.();
|
||||||
|
};
|
||||||
|
ws.onmessage = (e: MessageEvent): void => {
|
||||||
|
try {
|
||||||
|
handlers.onMessage(JSON.parse(e.data));
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn("invalid ws message", { err });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = (): void => {
|
||||||
|
logger.info("ws closed");
|
||||||
|
handlers.onClose?.();
|
||||||
|
};
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendWs(ws: WebSocket, message: object): void {
|
||||||
|
ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// 统一前端日志:dev 输出 console,prod 可接 Sentry。
|
||||||
|
// 禁止在组件/hooks 直接用 console.log(见 .claude/rules/frontend-runtime.md)。
|
||||||
|
export const logger = {
|
||||||
|
info: (msg: string, ctx?: object): void => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
if (import.meta.env.DEV) console.info(`[INFO] ${msg}`, ctx ?? "");
|
||||||
|
},
|
||||||
|
warn: (msg: string, ctx?: object): void => {
|
||||||
|
console.warn(`[WARN] ${msg}`, ctx ?? "");
|
||||||
|
},
|
||||||
|
error: (msg: string, ctx?: object): void => {
|
||||||
|
console.error(`[ERROR] ${msg}`, ctx ?? "");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
{% if include_backend %}
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_BACKEND_WS_URL: string;
|
||||||
|
readonly VITE_BACKEND_HTTP_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
{%- endif %}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# commit-msg 门禁:按 scaffold.includeClaude 策略处理 Claude 署名。
|
||||||
|
# 由 pre-commit 在 commit-msg 阶段调用,参数 $1 为提交信息文件。
|
||||||
|
# off(默认):剥除 Co-Authored-By: Claude 行
|
||||||
|
# on :保留(./scripts/claude-attribution.sh on)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
msg_file="${1:?用法: check-claude-attribution.sh <commit-msg-file>}"
|
||||||
|
include="$(git config --get scaffold.includeClaude 2>/dev/null || echo false)"
|
||||||
|
[ "$include" = "true" ] && exit 0
|
||||||
|
|
||||||
|
# 仅匹配行首的真尾注,避免误删正文里提到该短语的行
|
||||||
|
if grep -qiE '^[[:space:]]*co-authored-by:.*claude' "$msg_file"; then
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
grep -viE '^[[:space:]]*co-authored-by:.*claude' "$msg_file" >"$tmp"
|
||||||
|
mv "$tmp" "$msg_file"
|
||||||
|
echo "🛡 已移除 Claude 署名(scaffold.includeClaude=false;保留请运行 ./scripts/claude-attribution.sh on)"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 设置本项目提交是否包含 Claude 署名(Co-Authored-By: Claude)。
|
||||||
|
# 策略存于 git config scaffold.includeClaude,由 commit-msg 门禁强制执行。
|
||||||
|
# 用法:./scripts/claude-attribution.sh on|off|status
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
on)
|
||||||
|
git config scaffold.includeClaude true
|
||||||
|
echo "✓ 本项目提交将【保留】Claude 署名"
|
||||||
|
;;
|
||||||
|
off)
|
||||||
|
git config scaffold.includeClaude false
|
||||||
|
echo "✓ 本项目提交将【移除】Claude 署名(默认)"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
v="$(git config --get scaffold.includeClaude 2>/dev/null || echo 'false(默认,未显式设置)')"
|
||||||
|
echo "scaffold.includeClaude = $v"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "用法: $0 on|off|status" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user