新建技能
This commit is contained in:
67
.codex/skills/fullstack-start-verify/SKILL.md
Normal file
67
.codex/skills/fullstack-start-verify/SKILL.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: fullstack-start-verify
|
||||||
|
description: 启动本地项目的前端和后端服务,并通过进程状态、HTTP 健康检查、接口检查和可用时的浏览器冒烟测试验证整体功能。用于 Codex 需要运行全栈项目、验证前后端联调、排查启动失败,或确认用户流程是否端到端可用的场景。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 全栈启动与验证
|
||||||
|
|
||||||
|
当用户要求启动前后端、验证整体功能、跑通本地联调或确认项目是否可用时使用本技能。目标是得到可复现的验证结论,而不是只确认某个进程曾经启动过。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 在启动前检查项目。
|
||||||
|
- 阅读适用的 `AGENTS.md`、`README.md`、根目录环境变量示例,以及前后端的项目清单文件。
|
||||||
|
- 确认实际使用的包管理器和启动命令。优先使用项目文档和项目自带包装器中的命令,例如 `npm`、`pnpm`、`yarn`、`mvnw`、`gradlew`、`uv` 等。
|
||||||
|
- 找到后端健康检查地址、前端地址、API 基础地址、所需的数据库或缓存服务,以及文档中提供的测试账号。
|
||||||
|
- 不要为了让检查通过而虚构凭据、覆盖 `.env`、执行迁移或重置数据。
|
||||||
|
|
||||||
|
2. 执行启动前检查。
|
||||||
|
- 确认所需运行时和依赖目录可用。
|
||||||
|
- 检查计划使用的端口。只有在进程和健康响应明确属于当前项目时才复用服务,绝不要结束未知进程。
|
||||||
|
- 仅在项目文档说明了启动方式且用户已将其纳入范围时,启动 MySQL 或 Redis 等基础设施。
|
||||||
|
- 如果缺少必要的密钥、数据库或账号,应报告阻塞原因,不要降低验证标准。
|
||||||
|
|
||||||
|
3. 使用 `scripts/start_and_verify.py` 启动两个应用服务。
|
||||||
|
- 从项目根目录运行脚本。
|
||||||
|
- 传入明确的工作目录、启动命令和地址。对于健康检查之外的重要 API 路由,使用 `--check-url label=url` 添加检查。
|
||||||
|
- 后端应使用项目专用的健康检查地址,而不是只检查 TCP 端口是否打开;前端应检查开发服务器根路径或已知路由。
|
||||||
|
- 脚本只管理和清理自己启动的进程。只有用户明确要求服务继续运行时,才使用 `--keep-running`。
|
||||||
|
- 排查失败时使用 `--keep-logs` 保留日志。避免把密钥放在命令行参数中。
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python skills/fullstack-start-verify/scripts/start_and_verify.py `
|
||||||
|
--backend-cmd "mvn -f backend/pom.xml spring-boot:run" `
|
||||||
|
--backend-dir . `
|
||||||
|
--backend-url http://127.0.0.1:8080/api/health `
|
||||||
|
--frontend-cmd "npm run dev -- --host 127.0.0.1" `
|
||||||
|
--frontend-dir frontend `
|
||||||
|
--frontend-url http://127.0.0.1:5173/ `
|
||||||
|
--check-url health=http://127.0.0.1:8080/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 按递进层级验证应用。
|
||||||
|
- 确认两个健康检查地址都返回可接受的 HTTP 状态,并确认对应进程仍在运行。
|
||||||
|
- 针对主要用户流程执行聚焦的后端或 API 检查。除非用户明确要求写入流程且测试数据安全,否则优先执行只读检查。
|
||||||
|
- 在相关且不会重复更权威检查的情况下,执行前端构建或项目已有测试命令。
|
||||||
|
- 如果有 Playwright 或其他浏览器工具,打开前端地址并执行主要用户路径:加载页面、完成最小有意义的操作,同时验证页面可见结果和网络/API 结果。
|
||||||
|
- 如果没有浏览器工具,应明确说明未验证 UI 交互;仅凭 HTTP 检查不能声称完成了端到端验证。
|
||||||
|
|
||||||
|
5. 汇报并清理环境。
|
||||||
|
- 汇报具体命令、地址、执行的检查、通过或失败状态,以及第一个可执行的失败原因。
|
||||||
|
- 分开汇报基础设施、后端、前端和浏览器结果,明确哪些部分已通过。
|
||||||
|
- 失败时给出日志位置或相关日志尾部,同时隐藏密码、令牌和连接字符串。
|
||||||
|
- 除非用户要求服务持续运行,否则在结束前确认脚本启动的进程已经停止。
|
||||||
|
|
||||||
|
## 失败处理
|
||||||
|
|
||||||
|
- 服务在地址就绪前退出,属于启动失败。重试前先检查捕获的日志。
|
||||||
|
- 超时不算通过。检查依赖是否可用、端口是否冲突、环境变量是否加载、代理配置和服务日志。
|
||||||
|
- 后端健康但前端 API 调用失败,属于集成失败。检查前端代理或基础地址,以及 CORS 配置。
|
||||||
|
- 前端页面能加载但浏览器操作失败,属于用户流程失败。记录准确路由、操作、响应状态和控制台错误。
|
||||||
|
- 验证期间不要修复无关代码、修改生产配置或删除数据,除非用户明确扩大任务范围。
|
||||||
|
|
||||||
|
## 内置脚本
|
||||||
|
|
||||||
|
`scripts/start_and_verify.py` 是一个不依赖第三方库的进程运行器,负责本流程中的启动和 HTTP 检查。使用不常见选项前先阅读 `--help` 输出。脚本默认创建临时日志并在清理后删除;排查问题时传入 `--keep-logs` 保留日志。
|
||||||
4
.codex/skills/fullstack-start-verify/agents/openai.yaml
Normal file
4
.codex/skills/fullstack-start-verify/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "全栈启动验证"
|
||||||
|
short_description: "自动启动前后端服务并完成健康检查与端到端功能验证"
|
||||||
|
default_prompt: "使用 $fullstack-start-verify 启动项目的前后端,执行健康检查和端到端验证并汇报结果。"
|
||||||
256
.codex/skills/fullstack-start-verify/scripts/start_and_verify.py
Normal file
256
.codex/skills/fullstack-start-verify/scripts/start_and_verify.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""启动两个本地服务,等待 HTTP 就绪后执行检查并清理进程。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Service:
|
||||||
|
name: str
|
||||||
|
command: str
|
||||||
|
cwd: Path
|
||||||
|
url: str
|
||||||
|
process: subprocess.Popen[bytes] | None = None
|
||||||
|
log_path: Path | None = None
|
||||||
|
log_file: object | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="启动前后端命令,检查 HTTP 地址并清理进程。"
|
||||||
|
)
|
||||||
|
parser.add_argument("--backend-cmd", required=True, help="后端启动命令")
|
||||||
|
parser.add_argument("--backend-dir", default=".", help="后端工作目录")
|
||||||
|
parser.add_argument("--backend-url", required=True, help="后端健康检查地址")
|
||||||
|
parser.add_argument("--frontend-cmd", required=True, help="前端启动命令")
|
||||||
|
parser.add_argument("--frontend-dir", default=".", help="前端工作目录")
|
||||||
|
parser.add_argument("--frontend-url", required=True, help="前端就绪检查地址")
|
||||||
|
parser.add_argument(
|
||||||
|
"--check-url",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
metavar="LABEL=URL",
|
||||||
|
help="额外的 GET 检查,可重复传入",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=float,
|
||||||
|
default=60.0,
|
||||||
|
help="每个服务的就绪超时时间,单位为秒,默认 60",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval",
|
||||||
|
type=float,
|
||||||
|
default=1.0,
|
||||||
|
help="每次就绪检查的间隔秒数,默认 1",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-dir",
|
||||||
|
type=Path,
|
||||||
|
help="日志目录,运行结束后保留",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep-logs",
|
||||||
|
action="store_true",
|
||||||
|
help="运行结束后保留临时日志",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep-running",
|
||||||
|
action="store_true",
|
||||||
|
help="验证结束后保持已启动的服务运行",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dir(value: str) -> Path:
|
||||||
|
path = Path(value).resolve()
|
||||||
|
if not path.is_dir():
|
||||||
|
raise RuntimeError(f"工作目录不存在:{path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def start_service(service: Service, log_dir: Path) -> None:
|
||||||
|
service.log_path = log_dir / f"{service.name}.log"
|
||||||
|
service.log_file = service.log_path.open("w", encoding="utf-8", buffering=1)
|
||||||
|
creationflags = 0
|
||||||
|
start_new_session = False
|
||||||
|
if os.name == "nt":
|
||||||
|
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||||
|
else:
|
||||||
|
start_new_session = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
service.process = subprocess.Popen(
|
||||||
|
service.command,
|
||||||
|
cwd=service.cwd,
|
||||||
|
shell=True,
|
||||||
|
stdout=service.log_file,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
creationflags=creationflags,
|
||||||
|
start_new_session=start_new_session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
service.log_file.close()
|
||||||
|
service.log_file = None
|
||||||
|
raise
|
||||||
|
|
||||||
|
print(f"启动 {service.name}:进程={service.process.pid},工作目录={service.cwd}")
|
||||||
|
|
||||||
|
|
||||||
|
def request(url: str, timeout: float) -> tuple[bool, str]:
|
||||||
|
request_obj = urllib.request.Request(url, headers={"User-Agent": "fullstack-start-verify/1.0"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request_obj, timeout=timeout) as response:
|
||||||
|
status = response.status
|
||||||
|
return 200 <= status < 400, str(status)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
return 200 <= error.code < 400, str(error.code)
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||||
|
reason = getattr(error, "reason", error)
|
||||||
|
return False, type(reason).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_service(service: Service, timeout: float, interval: float) -> None:
|
||||||
|
if service.process is None:
|
||||||
|
raise RuntimeError(f"{service.name} 未启动")
|
||||||
|
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last_reason = "not checked"
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
exit_code = service.process.poll()
|
||||||
|
if exit_code is not None:
|
||||||
|
raise RuntimeError(f"{service.name} 在就绪检查前退出,退出码为 {exit_code}")
|
||||||
|
|
||||||
|
ok, reason = request(service.url, min(interval, 5.0))
|
||||||
|
last_reason = reason
|
||||||
|
if ok:
|
||||||
|
print(f"通过 {service.name}:{service.url} [{reason}]")
|
||||||
|
return
|
||||||
|
time.sleep(max(interval, 0.05))
|
||||||
|
|
||||||
|
raise RuntimeError(f"{service.name} 在 {timeout:g} 秒内未就绪 [{last_reason}]")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_extra_checks(values: list[str]) -> list[tuple[str, str]]:
|
||||||
|
checks: list[tuple[str, str]] = []
|
||||||
|
for value in values:
|
||||||
|
if "=" in value:
|
||||||
|
label, url = value.split("=", 1)
|
||||||
|
else:
|
||||||
|
label, url = value, value
|
||||||
|
label = label.strip() or url.strip()
|
||||||
|
url = url.strip()
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
raise RuntimeError(f"检查地址无效:{url}")
|
||||||
|
checks.append((label, url))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def run_extra_checks(checks: list[tuple[str, str]]) -> None:
|
||||||
|
for label, url in checks:
|
||||||
|
ok, reason = request(url, 10.0)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(f"检查失败:{label} {url} [{reason}]")
|
||||||
|
print(f"通过 {label}:{url} [{reason}]")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_service(service: Service) -> None:
|
||||||
|
process = service.process
|
||||||
|
if process is None or process.poll() is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except OSError:
|
||||||
|
# taskkill 已经结束进程,但 Windows 可能同时使句柄失效。
|
||||||
|
process.returncode = -1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
|
||||||
|
|
||||||
|
def tail(path: Path | None, lines: int = 20) -> list[str]:
|
||||||
|
if path is None or not path.exists():
|
||||||
|
return []
|
||||||
|
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
temp_log_dir: Path | None = None
|
||||||
|
log_dir = args.log_dir
|
||||||
|
if log_dir is None:
|
||||||
|
temp_log_dir = Path(tempfile.mkdtemp(prefix="fullstack-start-verify-"))
|
||||||
|
log_dir = temp_log_dir
|
||||||
|
log_dir = log_dir.resolve()
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
services = [
|
||||||
|
Service("backend", args.backend_cmd, resolve_dir(args.backend_dir), args.backend_url),
|
||||||
|
Service("frontend", args.frontend_cmd, resolve_dir(args.frontend_dir), args.frontend_url),
|
||||||
|
]
|
||||||
|
failure: Exception | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
for service in services:
|
||||||
|
start_service(service, log_dir)
|
||||||
|
for service in services:
|
||||||
|
wait_for_service(service, args.timeout, args.interval)
|
||||||
|
run_extra_checks(parse_extra_checks(args.check_url))
|
||||||
|
print("全栈验证通过(FULLSTACK_VERIFY=PASS)")
|
||||||
|
return 0
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
failure = error
|
||||||
|
print(f"全栈验证失败:{error}", file=sys.stderr)
|
||||||
|
for service in services:
|
||||||
|
for line in tail(service.log_path):
|
||||||
|
print(f"日志 {service.name}:{line}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
if not args.keep_running:
|
||||||
|
for service in reversed(services):
|
||||||
|
stop_service(service)
|
||||||
|
for service in services:
|
||||||
|
if service.log_file is not None:
|
||||||
|
service.log_file.close()
|
||||||
|
if args.keep_running:
|
||||||
|
print(f"服务仍在运行,日志目录:{log_dir}")
|
||||||
|
elif temp_log_dir is not None and not args.keep_logs:
|
||||||
|
shutil.rmtree(temp_log_dir, ignore_errors=True)
|
||||||
|
elif temp_log_dir is not None:
|
||||||
|
print(f"日志已保留:{temp_log_dir}")
|
||||||
|
elif failure is not None:
|
||||||
|
print(f"日志已保留:{log_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user