ai-project-manage-cli 7.0.2 → 7.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1194 -339
- package/package.json +1 -1
- package/template/AGENTS.md +21 -12
- package/template/apm.config.json +3 -3
- package/template/deploy/README.md +2 -9
- package/template/rules/reply.md +8 -8
- package/template/rules/write_doc.md +2 -14
- package/template/skills/apm-diff-review/SKILL.md +6 -6
- package/template/deploy/deploy.py +0 -564
- package/template/project/.gitkeep +0 -0
- package/template/skills/apm-apply-change/SKILL.md +0 -114
- package/template/skills/apm-deploy/SKILL.md +0 -10
- package/template/skills/apm-dev/SKILL.md +0 -93
- package/template/skills/apm-propose/SKILL.md +0 -52
- package/template/skills/apm-propose/design.md +0 -37
- package/template/skills/apm-propose/proposal.md +0 -31
- package/template/skills/apm-propose/specs.md +0 -39
- package/template/skills/apm-propose/tasks.md +0 -16
- package/template/skills/apm-recap/SKILL.md +0 -70
- package/template/skills/apm-recap/recap-template.md +0 -121
- package/template/skills/apm-review/SKILL.md +0 -26
- package/template/skills/apm-write-frontend-plan/SKILL.md +0 -27
- package/template/skills/apm-write-frontend-plan/plan-template.md +0 -40
- package/template/skills/apm-write-plan/SKILL.md +0 -79
- package/template/skills/apm-write-plan/api-template.md +0 -35
- package/template/skills/apm-write-plan/plan-template.md +0 -95
- package/template/skills/apm-write-prd/SKILL.md +0 -14
- package/template/skills/apm-write-prd/template.md +0 -134
|
@@ -1,564 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""自动部署脚本"""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import posixpath
|
|
8
|
-
import re
|
|
9
|
-
import shlex
|
|
10
|
-
import shutil
|
|
11
|
-
import subprocess
|
|
12
|
-
import sys
|
|
13
|
-
import time
|
|
14
|
-
import zipfile
|
|
15
|
-
from dataclasses import dataclass
|
|
16
|
-
from datetime import datetime
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
from typing import Any
|
|
19
|
-
|
|
20
|
-
import paramiko
|
|
21
|
-
|
|
22
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
23
|
-
PROJECT_ROOT = SCRIPT_DIR.parent.parent
|
|
24
|
-
APM_CONFIG_PATH = PROJECT_ROOT / ".apm" / "apm.config.json"
|
|
25
|
-
DEPLOY_CACHE_DIR = SCRIPT_DIR / ".deploy_cache"
|
|
26
|
-
MANIFEST_FILE = DEPLOY_CACHE_DIR / "manifest.json"
|
|
27
|
-
SPRINGBOOT_SCRIPT = "springboot.sh"
|
|
28
|
-
MAVEN_MODULE = "jeecg-module-system/jeecg-system-start"
|
|
29
|
-
MAVEN_PROFILE = "dev"
|
|
30
|
-
|
|
31
|
-
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
|
|
32
|
-
sys.stdout.reconfigure(encoding="utf-8")
|
|
33
|
-
sys.stderr.reconfigure(encoding="utf-8")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def log(message: str) -> None:
|
|
37
|
-
print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def fail(message: str, code: int = 1) -> None:
|
|
41
|
-
log(f"ERROR: {message}")
|
|
42
|
-
sys.exit(code)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def expand_path(path_str: str) -> Path:
|
|
46
|
-
return Path(path_str).expanduser().resolve()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def load_apm_config(apm_path: Path) -> dict[str, Any]:
|
|
50
|
-
with apm_path.open("r", encoding="utf-8") as handle:
|
|
51
|
-
data = json.load(handle)
|
|
52
|
-
|
|
53
|
-
name = str(data["name"]).strip()
|
|
54
|
-
deploy = data["wisdomDeploy"]
|
|
55
|
-
health = data["healthCheck"]
|
|
56
|
-
|
|
57
|
-
jar_path = str(deploy["jarPath"]).strip()
|
|
58
|
-
remote_app_dir = posixpath.dirname(jar_path)
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
"project_name": name,
|
|
62
|
-
"host": deploy["host"],
|
|
63
|
-
"port": deploy["port"],
|
|
64
|
-
"username": deploy["username"],
|
|
65
|
-
"password": deploy["password"],
|
|
66
|
-
"remote_vue_dist_dir": str(deploy["remotePath"]).strip(),
|
|
67
|
-
"remote_app_dir": remote_app_dir,
|
|
68
|
-
"remote_lib_dir": posixpath.join(remote_app_dir, "lib"),
|
|
69
|
-
"startup_jar": posixpath.basename(jar_path),
|
|
70
|
-
"package_name": f"{name}.jar.zip",
|
|
71
|
-
"maven_local_repo": str(deploy["mavenLocalRepo"]).strip(),
|
|
72
|
-
"health_check_port": health["port"],
|
|
73
|
-
"health_check_context": health["context"],
|
|
74
|
-
"health_check_timeout": health["timeout"],
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def load_config() -> dict[str, Any]:
|
|
79
|
-
return load_apm_config(APM_CONFIG_PATH)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def get_target_dir() -> Path:
|
|
83
|
-
return PROJECT_ROOT / MAVEN_MODULE / "target"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def get_maven_local_repo(config: dict[str, Any]) -> Path:
|
|
87
|
-
return expand_path(str(config["maven_local_repo"]).strip())
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@dataclass
|
|
91
|
-
class UpdateEntry:
|
|
92
|
-
path: Path
|
|
93
|
-
arcname: str
|
|
94
|
-
reason: str
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def relative_key(path: Path) -> str:
|
|
98
|
-
return path.resolve().relative_to(PROJECT_ROOT.resolve()).as_posix()
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def file_signature(path: Path) -> dict[str, float | int]:
|
|
102
|
-
stat = path.stat()
|
|
103
|
-
return {"size": int(stat.st_size), "mtime": float(stat.st_mtime)}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def load_manifest() -> dict[str, dict[str, float | int]]:
|
|
107
|
-
if not MANIFEST_FILE.exists():
|
|
108
|
-
return {}
|
|
109
|
-
with MANIFEST_FILE.open("r", encoding="utf-8") as handle:
|
|
110
|
-
return json.load(handle)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def save_manifest(manifest: dict[str, dict[str, float | int]]) -> None:
|
|
114
|
-
DEPLOY_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
115
|
-
with MANIFEST_FILE.open("w", encoding="utf-8") as handle:
|
|
116
|
-
json.dump(manifest, handle, ensure_ascii=False, indent=2)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def is_project_lib_jar(jar_name: str) -> bool:
|
|
120
|
-
return jar_name.startswith("jeecg-")
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def list_lib_files_to_upload(
|
|
124
|
-
local_lib_dir: Path,
|
|
125
|
-
remote_stats: dict[str, paramiko.SFTPAttributes],
|
|
126
|
-
*,
|
|
127
|
-
manifest: dict[str, dict[str, float | int]] | None = None,
|
|
128
|
-
) -> list[UpdateEntry]:
|
|
129
|
-
entries: list[UpdateEntry] = []
|
|
130
|
-
for jar_file in sorted(local_lib_dir.glob("*.jar")):
|
|
131
|
-
remote_attr = remote_stats.get(jar_file.name)
|
|
132
|
-
should_upload, reason = should_upload_lib_file(
|
|
133
|
-
jar_file,
|
|
134
|
-
remote_attr,
|
|
135
|
-
manifest,
|
|
136
|
-
)
|
|
137
|
-
if should_upload:
|
|
138
|
-
entries.append(
|
|
139
|
-
UpdateEntry(
|
|
140
|
-
path=jar_file,
|
|
141
|
-
arcname=jar_file.name,
|
|
142
|
-
reason=reason,
|
|
143
|
-
)
|
|
144
|
-
)
|
|
145
|
-
return entries
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def create_update_package(entries: list[UpdateEntry], package_name: str) -> Path:
|
|
149
|
-
DEPLOY_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
150
|
-
zip_path = DEPLOY_CACHE_DIR / package_name
|
|
151
|
-
|
|
152
|
-
log(f"创建更新包: {zip_path.name}({len(entries)} 个文件)")
|
|
153
|
-
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
154
|
-
for entry in entries:
|
|
155
|
-
archive.write(entry.path, arcname=entry.arcname)
|
|
156
|
-
log(f" 打包: {entry.arcname} ({entry.reason})")
|
|
157
|
-
|
|
158
|
-
return zip_path
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def upload_update_package(
|
|
162
|
-
sftp: paramiko.SFTPClient,
|
|
163
|
-
zip_path: Path,
|
|
164
|
-
config: dict[str, Any],
|
|
165
|
-
) -> str:
|
|
166
|
-
remote_dir = config["remote_vue_dist_dir"]
|
|
167
|
-
remote_path = f"{remote_dir}/{zip_path.name}"
|
|
168
|
-
|
|
169
|
-
log(f"上传更新包 -> {remote_path}")
|
|
170
|
-
|
|
171
|
-
try:
|
|
172
|
-
sftp.put(str(zip_path), remote_path)
|
|
173
|
-
log("更新包上传成功")
|
|
174
|
-
except OSError as exc:
|
|
175
|
-
fail(f"更新包上传失败: {exc}")
|
|
176
|
-
|
|
177
|
-
return remote_path
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def update_manifest_entries(
|
|
181
|
-
manifest: dict[str, dict[str, float | int]],
|
|
182
|
-
entries: list[UpdateEntry],
|
|
183
|
-
) -> dict[str, dict[str, float | int]]:
|
|
184
|
-
for entry in entries:
|
|
185
|
-
manifest[relative_key(entry.path)] = file_signature(entry.path)
|
|
186
|
-
return manifest
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def get_remote_file_stats(
|
|
190
|
-
sftp: paramiko.SFTPClient,
|
|
191
|
-
remote_dir: str,
|
|
192
|
-
) -> dict[str, paramiko.SFTPAttributes]:
|
|
193
|
-
stats: dict[str, paramiko.SFTPAttributes] = {}
|
|
194
|
-
try:
|
|
195
|
-
for attr in sftp.listdir_attr(remote_dir):
|
|
196
|
-
if attr.filename.endswith(".jar"):
|
|
197
|
-
stats[attr.filename] = attr
|
|
198
|
-
except FileNotFoundError:
|
|
199
|
-
pass
|
|
200
|
-
return stats
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def should_upload_lib_file(
|
|
204
|
-
local_path: Path,
|
|
205
|
-
remote_attr: paramiko.SFTPAttributes | None,
|
|
206
|
-
manifest: dict[str, dict[str, float | int]] | None = None,
|
|
207
|
-
) -> tuple[bool, str]:
|
|
208
|
-
if remote_attr is None:
|
|
209
|
-
return False, "远程不存在,跳过"
|
|
210
|
-
|
|
211
|
-
local_size = int(local_path.stat().st_size)
|
|
212
|
-
remote_size = int(remote_attr.st_size)
|
|
213
|
-
if local_size != remote_size:
|
|
214
|
-
return True, f"大小变化 {remote_size} -> {local_size}"
|
|
215
|
-
|
|
216
|
-
if is_project_lib_jar(local_path.name) and manifest is not None:
|
|
217
|
-
key = relative_key(local_path)
|
|
218
|
-
current = file_signature(local_path)
|
|
219
|
-
previous = manifest.get(key)
|
|
220
|
-
if previous is None:
|
|
221
|
-
return True, "项目模块未记录"
|
|
222
|
-
if int(previous["size"]) != current["size"]:
|
|
223
|
-
return True, "项目模块大小变化"
|
|
224
|
-
if float(previous["mtime"]) < current["mtime"]:
|
|
225
|
-
return True, "项目模块重新构建"
|
|
226
|
-
|
|
227
|
-
return False, "大小一致,跳过"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def get_mvn_executable() -> str:
|
|
231
|
-
candidates = ("mvn.cmd", "mvn.bat", "mvn") if sys.platform == "win32" else ("mvn",)
|
|
232
|
-
for name in candidates:
|
|
233
|
-
found = shutil.which(name)
|
|
234
|
-
if found:
|
|
235
|
-
return found
|
|
236
|
-
fail("未找到 mvn 命令,请确认 Maven 已安装并加入 PATH")
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def run_maven_build(config: dict[str, Any]) -> None:
|
|
240
|
-
profile = MAVEN_PROFILE
|
|
241
|
-
maven_repo = get_maven_local_repo(config)
|
|
242
|
-
cmd = [
|
|
243
|
-
get_mvn_executable(),
|
|
244
|
-
"clean",
|
|
245
|
-
"package",
|
|
246
|
-
f"-P{profile}",
|
|
247
|
-
f"-Dmaven.repo.local={maven_repo}",
|
|
248
|
-
"-DskipTests",
|
|
249
|
-
]
|
|
250
|
-
|
|
251
|
-
log(f"开始 Maven 构建: {' '.join(cmd)}")
|
|
252
|
-
log(f"Maven 本地仓库: {maven_repo}")
|
|
253
|
-
result = subprocess.run(
|
|
254
|
-
cmd,
|
|
255
|
-
cwd=PROJECT_ROOT,
|
|
256
|
-
)
|
|
257
|
-
if result.returncode != 0:
|
|
258
|
-
fail(f"Maven 构建失败,退出码: {result.returncode}")
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def locate_lib_dir() -> Path:
|
|
262
|
-
target_dir = get_target_dir()
|
|
263
|
-
if not target_dir.exists():
|
|
264
|
-
fail(f"构建产物目录不存在: {target_dir}")
|
|
265
|
-
|
|
266
|
-
lib_dir = target_dir / "lib"
|
|
267
|
-
if not lib_dir.is_dir():
|
|
268
|
-
fail(f"lib 目录不存在: {lib_dir}")
|
|
269
|
-
|
|
270
|
-
lib_jars = list(lib_dir.glob("*.jar"))
|
|
271
|
-
if not lib_jars:
|
|
272
|
-
fail(f"lib 目录下没有依赖 JAR: {lib_dir}")
|
|
273
|
-
|
|
274
|
-
log(f"定位 lib 产物: {len(lib_jars)} 个")
|
|
275
|
-
return lib_dir
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def connect_ssh(config: dict[str, Any]) -> paramiko.SSHClient:
|
|
279
|
-
client = paramiko.SSHClient()
|
|
280
|
-
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
281
|
-
|
|
282
|
-
log(f"连接服务器 {config['username']}@{config['host']}:{config['port']}")
|
|
283
|
-
connect_kwargs: dict[str, Any] = {
|
|
284
|
-
"hostname": config["host"],
|
|
285
|
-
"port": int(config["port"]),
|
|
286
|
-
"username": config["username"],
|
|
287
|
-
"password": config["password"],
|
|
288
|
-
"timeout": 30,
|
|
289
|
-
"allow_agent": False,
|
|
290
|
-
"look_for_keys": False,
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
try:
|
|
294
|
-
client.connect(**connect_kwargs)
|
|
295
|
-
except Exception as exc:
|
|
296
|
-
fail(f"SSH 连接失败: {exc}")
|
|
297
|
-
|
|
298
|
-
return client
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def read_channel_stream(channel: paramiko.Channel) -> str:
|
|
302
|
-
"""流式读取 SSH 通道输出,避免长时间命令无回显。"""
|
|
303
|
-
chunks: list[str] = []
|
|
304
|
-
while not channel.closed:
|
|
305
|
-
if channel.recv_ready():
|
|
306
|
-
data = channel.recv(4096)
|
|
307
|
-
if not data:
|
|
308
|
-
break
|
|
309
|
-
text = data.decode("utf-8", errors="replace")
|
|
310
|
-
chunks.append(text)
|
|
311
|
-
print(text, end="", flush=True)
|
|
312
|
-
elif channel.exit_status_ready():
|
|
313
|
-
while channel.recv_ready():
|
|
314
|
-
data = channel.recv(4096)
|
|
315
|
-
if data:
|
|
316
|
-
text = data.decode("utf-8", errors="replace")
|
|
317
|
-
chunks.append(text)
|
|
318
|
-
print(text, end="", flush=True)
|
|
319
|
-
break
|
|
320
|
-
else:
|
|
321
|
-
time.sleep(0.2)
|
|
322
|
-
|
|
323
|
-
combined = "".join(chunks)
|
|
324
|
-
if combined and not combined.endswith("\n"):
|
|
325
|
-
print()
|
|
326
|
-
return combined.strip()
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def run_remote_command(
|
|
330
|
-
client: paramiko.SSHClient,
|
|
331
|
-
command: str,
|
|
332
|
-
*,
|
|
333
|
-
check: bool = True,
|
|
334
|
-
get_pty: bool = False,
|
|
335
|
-
stream: bool = False,
|
|
336
|
-
timeout_secs: float | None = None,
|
|
337
|
-
) -> tuple[int, str, str]:
|
|
338
|
-
log(f"远程执行: {command}")
|
|
339
|
-
_, stdout, stderr = client.exec_command(command, get_pty=get_pty)
|
|
340
|
-
channel = stdout.channel
|
|
341
|
-
if timeout_secs is not None:
|
|
342
|
-
channel.settimeout(timeout_secs)
|
|
343
|
-
|
|
344
|
-
if stream:
|
|
345
|
-
out = read_channel_stream(channel)
|
|
346
|
-
err = ""
|
|
347
|
-
else:
|
|
348
|
-
out = stdout.read().decode("utf-8", errors="replace").strip()
|
|
349
|
-
err = "" if get_pty else stderr.read().decode("utf-8", errors="replace").strip()
|
|
350
|
-
|
|
351
|
-
exit_code = channel.recv_exit_status()
|
|
352
|
-
|
|
353
|
-
if not stream:
|
|
354
|
-
if out:
|
|
355
|
-
print(out)
|
|
356
|
-
if err:
|
|
357
|
-
print(err, file=sys.stderr)
|
|
358
|
-
|
|
359
|
-
if check and exit_code != 0:
|
|
360
|
-
fail(f"远程命令失败 (exit {exit_code}): {command}")
|
|
361
|
-
|
|
362
|
-
return exit_code, out, err
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def extract_update_package_on_remote(
|
|
366
|
-
client: paramiko.SSHClient,
|
|
367
|
-
config: dict[str, Any],
|
|
368
|
-
remote_zip_path: str,
|
|
369
|
-
) -> int:
|
|
370
|
-
"""解压更新包到 lib 目录,仅覆盖远程已存在的 JAR。"""
|
|
371
|
-
remote_lib_dir = config["remote_lib_dir"]
|
|
372
|
-
quoted_zip = shlex.quote(remote_zip_path)
|
|
373
|
-
quoted_lib = shlex.quote(remote_lib_dir)
|
|
374
|
-
|
|
375
|
-
script = f"""
|
|
376
|
-
set -e
|
|
377
|
-
TMP=$(mktemp -d)
|
|
378
|
-
trap 'rm -rf "$TMP"' EXIT
|
|
379
|
-
unzip -oq {quoted_zip} -d "$TMP"
|
|
380
|
-
updated=0
|
|
381
|
-
while IFS= read -r -d '' src; do
|
|
382
|
-
name=$(basename "$src")
|
|
383
|
-
dest={quoted_lib}/"$name"
|
|
384
|
-
if [ -f "$dest" ]; then
|
|
385
|
-
cp -f "$src" "$dest"
|
|
386
|
-
echo "覆盖: $name"
|
|
387
|
-
updated=$((updated + 1))
|
|
388
|
-
else
|
|
389
|
-
echo "跳过(远程不存在): $name"
|
|
390
|
-
fi
|
|
391
|
-
done < <(find "$TMP" -name '*.jar' -type f -print0)
|
|
392
|
-
echo "UPDATED_COUNT=$updated"
|
|
393
|
-
"""
|
|
394
|
-
|
|
395
|
-
_, out, _ = run_remote_command(client, script)
|
|
396
|
-
match = re.search(r"UPDATED_COUNT=(\d+)", out)
|
|
397
|
-
if not match:
|
|
398
|
-
fail(f"远程解压失败,未获取更新数量\n输出: {out or '(空)'}")
|
|
399
|
-
|
|
400
|
-
updated = int(match.group(1))
|
|
401
|
-
log(f"lib 解压完成: 覆盖 {updated} 个")
|
|
402
|
-
return updated
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def springboot_output_indicates_success(action: str, combined: str) -> bool:
|
|
406
|
-
"""springboot.sh 经 SSH 执行时 exit code 不可靠,需结合输出判断。"""
|
|
407
|
-
lower = combined.lower()
|
|
408
|
-
if action == "health":
|
|
409
|
-
return "健康检查通过" in combined
|
|
410
|
-
if action in {"start", "restart"}:
|
|
411
|
-
return "is starting" in combined or "is running" in lower
|
|
412
|
-
if action == "stop":
|
|
413
|
-
return (
|
|
414
|
-
"is stopping" in combined
|
|
415
|
-
or "not running" in lower
|
|
416
|
-
or "please check it" in lower
|
|
417
|
-
)
|
|
418
|
-
if action == "status":
|
|
419
|
-
return "running" in lower or "not running" in lower
|
|
420
|
-
return True
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
def run_springboot_action(
|
|
424
|
-
client: paramiko.SSHClient,
|
|
425
|
-
config: dict[str, Any],
|
|
426
|
-
action: str,
|
|
427
|
-
*args: str,
|
|
428
|
-
) -> str:
|
|
429
|
-
if action in {"start", "restart"}:
|
|
430
|
-
if not args:
|
|
431
|
-
fail(f"远程 {action} 缺少 jar 参数")
|
|
432
|
-
jar = args[0].strip().splitlines()[0].strip()
|
|
433
|
-
if not jar:
|
|
434
|
-
fail(f"无效的 jar 名称: {args[0]!r}")
|
|
435
|
-
|
|
436
|
-
command = " && ".join(
|
|
437
|
-
[
|
|
438
|
-
f"cd {shlex.quote(config['remote_app_dir'])}",
|
|
439
|
-
" ".join(
|
|
440
|
-
[f"./{SPRINGBOOT_SCRIPT}", *map(shlex.quote, (action, *args))]
|
|
441
|
-
),
|
|
442
|
-
]
|
|
443
|
-
)
|
|
444
|
-
exit_code, out, err = run_remote_command(
|
|
445
|
-
client,
|
|
446
|
-
command,
|
|
447
|
-
check=False,
|
|
448
|
-
)
|
|
449
|
-
combined = f"{out}\n{err}".strip()
|
|
450
|
-
output_ok = springboot_output_indicates_success(action, combined)
|
|
451
|
-
if action == "health":
|
|
452
|
-
if exit_code != 0 or not output_ok:
|
|
453
|
-
fail(
|
|
454
|
-
f"健康检查失败\n"
|
|
455
|
-
f"命令: {command}\n输出: {combined or '(空)'}"
|
|
456
|
-
)
|
|
457
|
-
return combined
|
|
458
|
-
if exit_code != 0 and not output_ok:
|
|
459
|
-
fail(f"远程 {action} 失败: {' '.join(args)}\n{combined}")
|
|
460
|
-
if action in {"start", "restart"} and not output_ok:
|
|
461
|
-
fail(
|
|
462
|
-
f"远程 {action} 未成功: {' '.join(args)}\n"
|
|
463
|
-
f"命令: {command}\n输出: {combined or '(空)'}"
|
|
464
|
-
)
|
|
465
|
-
return combined
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
def get_running_jar(
|
|
469
|
-
client: paramiko.SSHClient,
|
|
470
|
-
app_dir: str,
|
|
471
|
-
config: dict[str, Any],
|
|
472
|
-
) -> str | None:
|
|
473
|
-
combined = run_springboot_action(
|
|
474
|
-
client, config, "status", config["startup_jar"]
|
|
475
|
-
)
|
|
476
|
-
text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", combined).strip().lower()
|
|
477
|
-
if "not running" in text:
|
|
478
|
-
return None
|
|
479
|
-
if "running" in text:
|
|
480
|
-
return config["startup_jar"]
|
|
481
|
-
return None
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def health_check_service(
|
|
485
|
-
client: paramiko.SSHClient,
|
|
486
|
-
config: dict[str, Any],
|
|
487
|
-
) -> None:
|
|
488
|
-
port = str(int(config["health_check_port"]))
|
|
489
|
-
context = str(config["health_check_context"]).strip()
|
|
490
|
-
timeout = str(int(config["health_check_timeout"]))
|
|
491
|
-
log(f"健康检查: springboot.sh health {port} {context} {timeout}")
|
|
492
|
-
run_springboot_action(client, config, "health", port, context, timeout)
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
def main() -> None:
|
|
496
|
-
config = load_config()
|
|
497
|
-
|
|
498
|
-
log(f"=== 自动部署: {config['project_name']} ===")
|
|
499
|
-
log(f"配置文件: {APM_CONFIG_PATH}")
|
|
500
|
-
log(f"项目根目录: {PROJECT_ROOT}")
|
|
501
|
-
|
|
502
|
-
run_maven_build(config)
|
|
503
|
-
|
|
504
|
-
lib_dir = locate_lib_dir()
|
|
505
|
-
manifest = load_manifest()
|
|
506
|
-
|
|
507
|
-
client = connect_ssh(config)
|
|
508
|
-
need_restart = False
|
|
509
|
-
lib_upload_entries: list[UpdateEntry] = []
|
|
510
|
-
try:
|
|
511
|
-
sftp = client.open_sftp()
|
|
512
|
-
try:
|
|
513
|
-
app_dir = config["remote_app_dir"]
|
|
514
|
-
remote_lib_stats = get_remote_file_stats(sftp, config["remote_lib_dir"])
|
|
515
|
-
|
|
516
|
-
log("收集 JAR 更新...")
|
|
517
|
-
lib_upload_entries = list_lib_files_to_upload(
|
|
518
|
-
lib_dir,
|
|
519
|
-
remote_lib_stats,
|
|
520
|
-
manifest=manifest,
|
|
521
|
-
)
|
|
522
|
-
|
|
523
|
-
updated = 0
|
|
524
|
-
if lib_upload_entries:
|
|
525
|
-
zip_path = create_update_package(
|
|
526
|
-
lib_upload_entries,
|
|
527
|
-
config["package_name"],
|
|
528
|
-
)
|
|
529
|
-
remote_zip_path = upload_update_package(sftp, zip_path, config)
|
|
530
|
-
log("远程解压 lib 目录(仅覆盖已有 JAR)...")
|
|
531
|
-
updated = extract_update_package_on_remote(
|
|
532
|
-
client,
|
|
533
|
-
config,
|
|
534
|
-
remote_zip_path,
|
|
535
|
-
)
|
|
536
|
-
else:
|
|
537
|
-
log("无 JAR 需要更新,跳过更新包上传")
|
|
538
|
-
|
|
539
|
-
running_jar = get_running_jar(client, app_dir, config)
|
|
540
|
-
need_restart = updated > 0
|
|
541
|
-
if not need_restart and not running_jar:
|
|
542
|
-
log("服务未运行,需要启动")
|
|
543
|
-
need_restart = True
|
|
544
|
-
elif not need_restart:
|
|
545
|
-
log("没有文件需要更新,跳过重启")
|
|
546
|
-
|
|
547
|
-
if need_restart:
|
|
548
|
-
log("重启服务...")
|
|
549
|
-
run_springboot_action(client, config, "restart", config["startup_jar"])
|
|
550
|
-
|
|
551
|
-
health_check_service(client, config)
|
|
552
|
-
|
|
553
|
-
if lib_upload_entries:
|
|
554
|
-
save_manifest(update_manifest_entries(manifest, lib_upload_entries))
|
|
555
|
-
finally:
|
|
556
|
-
sftp.close()
|
|
557
|
-
finally:
|
|
558
|
-
client.close()
|
|
559
|
-
|
|
560
|
-
log("部署完成")
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if __name__ == "__main__":
|
|
564
|
-
main()
|
|
File without changes
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
## 文档说明
|
|
2
|
-
|
|
3
|
-
按工作项中的 **`plans/tasks.md`** 驱动实现:**读规划 → 写代码 → 对账元数据 → 勾选 → 单独 commit**,直至全部完成、**停止执行**或硬阻塞。
|
|
4
|
-
|
|
5
|
-
**工作项根目录**:`.apm/project/`(下文路径均相对该目录)
|
|
6
|
-
|
|
7
|
-
| 文件 | 路径 | 用途 |
|
|
8
|
-
| ----------------- | ------------------- | ----------------------------------------------------------------------------------------- |
|
|
9
|
-
| tasks(**驱动**) | `plans/tasks.md` | 唯一进度来源(`- [ ]` / `- [x]`);缺文件、无待办 → 阻塞,须先 **apm-propose** 或手工补齐 |
|
|
10
|
-
| PRD | `docs/PRD.md` | 范围与验收 |
|
|
11
|
-
| proposal | `plans/proposal.md` | 变更背景(按需) |
|
|
12
|
-
| design | `plans/design.md` | **改哪里**、技术决策(按任务引用) |
|
|
13
|
-
| specs | `plans/specs/*.md` | **做什么**(按任务 **需求编号** 引用) |
|
|
14
|
-
|
|
15
|
-
**前置**:`plans/tasks.md` 须由 **apm-propose** 生成,格式见 `.apm/skills/apm-propose/tasks.md`(`- [ ]`、元数据子列表等)。
|
|
16
|
-
|
|
17
|
-
**单任务循环**:说明当前项 → 最小实现 → 对账(需求编号 / 预期与实际路径 / 完成标准)→ `- [x]` → **一项待办 = 一个 commit**。
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## 停止执行(优先于继续推进)
|
|
22
|
-
|
|
23
|
-
发现规划与代码**严重不一致**、需用户做产品/架构**决策**、或环境/权限等**硬阻塞**时:**不再**处理下一项、**不**勾选完成,仅输出:
|
|
24
|
-
|
|
25
|
-
1. **客观事实**:已读路径、与任务/代码的**具体矛盾**(可摘引)。
|
|
26
|
-
2. **当前进度**:`plans/tasks.md` 处理到哪一条;未提交改动范围(若有)。
|
|
27
|
-
3. **需要用户提供什么**:清单,一句一项。
|
|
28
|
-
|
|
29
|
-
**禁止**:替用户决策、给「建议先改 A/B」、可选方案、排障命令(除非任务/文档已写明须执行的命令)。
|
|
30
|
-
|
|
31
|
-
**允许**:说明「缺某信息则无法继续」的逻辑关系(不展开成方案)。
|
|
32
|
-
|
|
33
|
-
**仍可继续**:任务略含糊,但在 PRD / design / specs / tasks 已有文字内可**自洽**完成;若有**假设**须写清。一旦假设触及「以谁为准」→ 转 **停止执行**。
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
## 工作流程
|
|
38
|
-
|
|
39
|
-
### 步骤 1:读取 `plans/tasks.md` 并判断状态
|
|
40
|
-
|
|
41
|
-
1. **Read** `plans/tasks.md`。
|
|
42
|
-
2. 缺失、为空或无 `- [ ]` → **停止**,提示先具备可执行 tasks。
|
|
43
|
-
3. 若全部为 `- [x]` → 仅陈述「已全部完成」(不建议是否提交/MR)。
|
|
44
|
-
|
|
45
|
-
### 步骤 2:读取实现上下文
|
|
46
|
-
|
|
47
|
-
开始**第一个**未勾选任务前,至少 **Read** `docs/PRD.md` 及当前项所需的 `plans/design.md` / `plans/specs/` 片段(见任务 **需求编号**)。不要求每项任务重读全部规划;以**当前任务行** + 缺口再 Read 为准。
|
|
48
|
-
|
|
49
|
-
#### 单轮读取策略
|
|
50
|
-
|
|
51
|
-
| 文件 | 建议 |
|
|
52
|
-
| ---------------------------------- | -------------------------------------------------------------------------------- |
|
|
53
|
-
| `docs/PRD.md` | 本轮首次实现前至少读一次 |
|
|
54
|
-
| `plans/design.md` / `plans/specs/` | 连续多项时,已读过且无疑虑可不重复全文;按任务元数据 **Read(偏移)** 或再读全文 |
|
|
55
|
-
|
|
56
|
-
### 步骤 3:展示进度并开始循环
|
|
57
|
-
|
|
58
|
-
展示 **N/M 已完成**(由勾选统计)、**当前将处理**的下一条 `- [ ]`(含编号如 `2.1`)。
|
|
59
|
-
|
|
60
|
-
对每条未勾选任务(建议自上而下):
|
|
61
|
-
|
|
62
|
-
1. 说明正在处理的编号与简述。
|
|
63
|
-
2. **最小**改动实现;与该项描述一致。
|
|
64
|
-
3. **勾选前**对账(与 `plans/tasks.md` 子列表字段一致):
|
|
65
|
-
- **需求编号**、**预期改动路径** vs **实际改动文件**(偏差须简述)
|
|
66
|
-
- **验证用例编号**(若有)、**完成标准** / 验证结果
|
|
67
|
-
4. 依据齐且自洽后,将对应行改为 `- [x]`,**立即**单独 `git commit`(信息含任务编号或简述)。
|
|
68
|
-
|
|
69
|
-
### 步骤 4:收尾
|
|
70
|
-
|
|
71
|
-
- **全部完成**:进度 N/M、本轮已完成项摘要;**不**建议 MR/发布等后续流程。
|
|
72
|
-
- **暂停**:按 **停止执行** 三节输出;无「可选后续」。
|
|
73
|
-
|
|
74
|
-
---
|
|
75
|
-
|
|
76
|
-
## 输出示例
|
|
77
|
-
|
|
78
|
-
**进行中**
|
|
79
|
-
|
|
80
|
-
```
|
|
81
|
-
## 正在实施
|
|
82
|
-
|
|
83
|
-
处理任务 3/7:2.1 实现导出接口
|
|
84
|
-
✓ 任务完成 · commit <short-sha> <subject>
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
**全部完成**
|
|
88
|
-
|
|
89
|
-
```
|
|
90
|
-
## 实现完成
|
|
91
|
-
**进度:** 7/7 ✓
|
|
92
|
-
(每项待办均已对应独立 commit。)
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
**暂停**
|
|
96
|
-
|
|
97
|
-
```
|
|
98
|
-
## 实现已暂停
|
|
99
|
-
**进度:** 4/7
|
|
100
|
-
### 事实与原因
|
|
101
|
-
…
|
|
102
|
-
### 需要用户提供(或决策)
|
|
103
|
-
- …
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
---
|
|
107
|
-
|
|
108
|
-
## Guardrails
|
|
109
|
-
|
|
110
|
-
- 持续执行待办,直至完成、**停止执行**或硬阻塞。
|
|
111
|
-
- 未读清当前任务依赖前**不要**盲改;未对账前**不要**勾选完成。
|
|
112
|
-
- 验证失败或依据不足时保持 `- [ ]` 并写明缺口;元数据缺失时在实现前尽量补全或按 tasks 模板推断并注明。
|
|
113
|
-
- **不要**因发现规划与代码不符而主动改 `plans/` 下规划文件或建议改哪份;**停止**并交还用户。
|
|
114
|
-
- **可分段调用**:部分完成后结束本轮,下次同一工作项继续;规划修订由用户在流程外完成后再运行本技能。
|