@sakanano-moe/agenthelm 6.7.0 → 6.11.0-rc.1
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/package.json +1 -1
- package/runtime/index.json +6 -6
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/agent-pack/hooks/hooks.json +32 -0
- package/runtime/plugins/agent-pack/plugin.json +1 -1
- package/runtime/plugins/agent-pack/references/local-only.md +126 -0
- package/runtime/plugins/agent-pack/references/nudge.en.md +58 -0
- package/runtime/plugins/agent-pack/references/nudge.md +58 -0
- package/runtime/plugins/agent-pack/runtime/agent_pack_nudge.py +573 -0
- package/runtime/plugins/agent-pack/runtime/agenthelm.py +419 -107
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +77 -53
- package/runtime/plugins/agent-pack/runtime/i18n_agent_pack.py +78 -0
- package/runtime/plugins/agent-pack/runtime/local_only.py +1130 -0
- package/runtime/plugins/agent-pack/runtime/locale/en.json +385 -0
- package/runtime/plugins/agent-pack/runtime/mcp_server.py +25 -0
- package/runtime/plugins/agent-pack/runtime/nudge_text.py +107 -0
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +9 -1
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/agents-init/content.json +17 -2
- package/runtime/plugins/agents-init/plugin.json +1 -1
- package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +47 -0
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +39 -1
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +404 -70
- package/runtime/plugins/agents-init/skills/agents-init/scripts/adopt.py +596 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/i18n_init.py +78 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +245 -72
- package/runtime/plugins/agents-init/skills/agents-init/scripts/locale/en.json +210 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/regions.py +99 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +1081 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_adopt.py +551 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +46 -2
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +555 -0
- package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +66 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/references/inbox.en.md +48 -0
- package/runtime/plugins/opinion-manager/references/inbox.md +48 -0
- package/runtime/plugins/opinion-manager/runtime/context.py +19 -18
- package/runtime/plugins/opinion-manager/runtime/i18n_opinion.py +78 -0
- package/runtime/plugins/opinion-manager/runtime/inbox.py +397 -0
- package/runtime/plugins/opinion-manager/runtime/inbox_text.py +101 -0
- package/runtime/plugins/opinion-manager/runtime/lifecycle.py +45 -44
- package/runtime/plugins/opinion-manager/runtime/locale/en.json +184 -0
- package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +55 -50
- package/runtime/plugins/opinion-manager/runtime/versioned.py +84 -83
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +15 -2
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/content.json +30 -3
- package/runtime/plugins/project-orchestrator/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/references/case-workspaces.md +9 -1
- package/runtime/plugins/project-orchestrator/references/case.schema.json +1 -0
- package/runtime/plugins/project-orchestrator/references/sop-interview.md +83 -0
- package/runtime/plugins/project-orchestrator/references/sop.md +156 -0
- package/runtime/plugins/project-orchestrator/runtime/assets.py +52 -57
- package/runtime/plugins/project-orchestrator/runtime/case.py +139 -80
- package/runtime/plugins/project-orchestrator/runtime/case_structure.py +71 -0
- package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +47 -46
- package/runtime/plugins/project-orchestrator/runtime/i18n_orchestrator.py +78 -0
- package/runtime/plugins/project-orchestrator/runtime/locale/en.json +1409 -0
- package/runtime/plugins/project-orchestrator/runtime/memory.py +44 -43
- package/runtime/plugins/project-orchestrator/runtime/private_paths.py +119 -0
- package/runtime/plugins/project-orchestrator/runtime/project_base.py +37 -19
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +267 -174
- package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +524 -134
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +159 -85
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +191 -106
- package/runtime/plugins/project-orchestrator/runtime/project_write.py +58 -55
- package/runtime/plugins/project-orchestrator/runtime/sop.py +1670 -0
- package/runtime/plugins/project-orchestrator/runtime/sop_structure.py +226 -0
- package/runtime/plugins/project-orchestrator/runtime/wf.py +74 -70
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +22 -0
- package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.en.md +57 -0
- package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.md +57 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.en.md +77 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.md +77 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.en.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.en.md +87 -0
- package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.md +87 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.en.md +75 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.md +75 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.en.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.md +12 -0
- package/runtime/plugins/project-orchestrator/templates/CASE.en.md +39 -0
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""local-only:只属于这台机器的目录(依赖、缓存)不进入云同步盘。
|
|
3
|
+
|
|
4
|
+
项目放在 iCloud / Dropbox / OneDrive 等同步目录里时,``.venv``、``node_modules`` 这类可重建的大目录
|
|
5
|
+
会拖慢同步、产生冲突副本。本模块做三件事:
|
|
6
|
+
|
|
7
|
+
* ``check``:只读。识别同步服务、找出落在同步目录里的本机专属目录,核对已外置条目的软链接状态、
|
|
8
|
+
HOME 是否与项目约定一致、同步范围内目录的体积是否接近阈值,并给出可对比的“本机环境明细”。
|
|
9
|
+
* ``fix``:默认只预览,``--apply`` 才写。把目录移到 ``~/.local/share/agenthelm/local-only/<project_id>/``
|
|
10
|
+
并在原位置建软链接;新设备上补建目标目录;按用户的选择提高阈值。移动失败整体回滚。
|
|
11
|
+
* ``config``:显示生效的配置与文件位置。
|
|
12
|
+
|
|
13
|
+
约定(实测依据见 references/local-only.md):
|
|
14
|
+
|
|
15
|
+
* 链接文本必须在所有设备上相同(软链接本身会被同步),所以目标路径由“项目里记录的 HOME + project_id
|
|
16
|
+
+ 项目内相对路径”拼成,各设备的 HOME 必须一致;不一致时拒绝写入并报告。
|
|
17
|
+
* npm 会把软链接的 ``node_modules`` 换成真实目录,pnpm 会失败:这两个只报告,不外置;yarn(经典版)、
|
|
18
|
+
bun、uv 与普通 venv 实测可以。
|
|
19
|
+
* 缓存类(``__pycache__``、``.ruff_cache`` 等)优先用工具自带的环境变量 / 选项外置,本模块只给出写法。
|
|
20
|
+
|
|
21
|
+
只用标准库;不联网;不读取文件内容(``package.json`` 除外,且跳过 iCloud 未下载的占位文件)。
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import errno
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Mapping
|
|
39
|
+
from i18n_agent_pack import _, N_ # noqa: E402
|
|
40
|
+
|
|
41
|
+
CONFIG_PATH = Path(".agents/moe.sakanano.agent-pack/local-only.json")
|
|
42
|
+
SCHEMA_VERSION = 1
|
|
43
|
+
STORE_PARTS = (".local", "share", "agenthelm", "local-only")
|
|
44
|
+
SF_DATALESS = 0x40000000 # macOS: iCloud 未下载的文件(st_flags)
|
|
45
|
+
MB = 1024 * 1024
|
|
46
|
+
|
|
47
|
+
# (id, 显示名, 路径中的目录名)。路径识别只看目录名,不读文件。
|
|
48
|
+
SYNC_SERVICES = (
|
|
49
|
+
("icloud", "iCloud Drive", re.compile(r"^(com~apple~CloudDocs|Mobile Documents|iCloud Drive|iCloudDrive)$")),
|
|
50
|
+
("dropbox", "Dropbox", re.compile(r"^Dropbox( \(.*\)|-.*)?$")),
|
|
51
|
+
("onedrive", "OneDrive", re.compile(r"^OneDrive( - .*|-.*)?$")),
|
|
52
|
+
("google-drive", "Google Drive", re.compile(r"^(GoogleDrive(-.*)?|Google Drive|My Drive)$")),
|
|
53
|
+
)
|
|
54
|
+
DECLARED_SERVICES = {"icloud": "iCloud Drive", "dropbox": "Dropbox", "onedrive": "OneDrive",
|
|
55
|
+
"google-drive": "Google Drive", "syncthing": "Syncthing", "other": N_("其他同步服务")}
|
|
56
|
+
NOT_SYNCED = "none"
|
|
57
|
+
|
|
58
|
+
VCS_DIRS = {".git", ".svn", ".hg"}
|
|
59
|
+
DEPENDENCY_NAMES = {".tox", ".nox", ".terraform", ".gradle"}
|
|
60
|
+
CACHE_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".hypothesis", ".cache",
|
|
61
|
+
".eslintcache", ".parcel-cache", ".turbo"}
|
|
62
|
+
# 默认保持同步(构建产物与本机工具状态按用户决定同步);项目可在配置里改。
|
|
63
|
+
DEFAULT_KEEP_SYNCED = ("dist", "build", "out", ".claude", ".cursor", ".idea", ".vscode")
|
|
64
|
+
DEFAULT_LIMITS = {"warn_ratio": 0.8, "dir_mb": 200, "dir_files": 20000}
|
|
65
|
+
RUNTIME_SUFFIXES = ("-wal", "-shm", ".pid", ".sock")
|
|
66
|
+
CONFLICT_RE = re.compile(r"\(.*(conflicted copy|Case Conflict).*\)", re.IGNORECASE)
|
|
67
|
+
ICLOUD_PLACEHOLDER_RE = re.compile(r"^\..+\.icloud$")
|
|
68
|
+
LOCKFILES = (("bun.lockb", "bun"), ("bun.lock", "bun"), ("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"),
|
|
69
|
+
("package-lock.json", "npm"), ("npm-shrinkwrap.json", "npm"))
|
|
70
|
+
# 实测(Linux 容器):yarn 1.22.22 与 bun 1.4.4 保留软链接并装进目标;npm 10.9.4 把链接换成真实目录;
|
|
71
|
+
# pnpm 10.28.0 失败。yarn 2+ 未测。
|
|
72
|
+
PM_VERDICT = {"npm": "avoid", "pnpm": "avoid", "yarn": "ok", "bun": "ok"}
|
|
73
|
+
PM_ADVICE = {"npm": N_("npm 会把软链接的 node_modules 换成真实目录(实测 10.9.4),依赖会回到同步盘里"),
|
|
74
|
+
"pnpm": N_("pnpm 对软链接的 node_modules 会安装失败并删掉链接目标(实测 10.28.0)")}
|
|
75
|
+
CACHE_HINTS = {
|
|
76
|
+
"__pycache__": N_("环境变量 PYTHONPYCACHEPREFIX=<本机目录>"),
|
|
77
|
+
".ruff_cache": N_("环境变量 RUFF_CACHE_DIR=<本机目录>"),
|
|
78
|
+
".mypy_cache": N_("环境变量 MYPY_CACHE_DIR=<本机目录>"),
|
|
79
|
+
".pytest_cache": N_("pytest 选项 -o cache_dir=<本机目录>(或在 pytest 配置里写 cache_dir)"),
|
|
80
|
+
}
|
|
81
|
+
# 外置后用什么命令在这台机器上重建。
|
|
82
|
+
REBUILD_BY_LOCK = (("uv.lock", ["uv", "sync"]), ("poetry.lock", ["poetry", "install"]),
|
|
83
|
+
("bun.lockb", ["bun", "install", "--frozen-lockfile"]),
|
|
84
|
+
("bun.lock", ["bun", "install", "--frozen-lockfile"]),
|
|
85
|
+
("yarn.lock", ["yarn", "install", "--frozen-lockfile"]))
|
|
86
|
+
# 时间预算(秒):会话开始钩子只有 10 秒。
|
|
87
|
+
BUDGETS = {"quick": 1.5, "standard": 6.0, "full": 120.0}
|
|
88
|
+
QUICK_DEPTH = 2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LocalOnlyError(Exception):
|
|
92
|
+
def __init__(self, message: str, code: str = "usage") -> None:
|
|
93
|
+
super().__init__(message)
|
|
94
|
+
self.code = code
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------- 小工具
|
|
98
|
+
|
|
99
|
+
def _sha(value: str) -> str:
|
|
100
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _write_json_atomic(path: Path, data: Any) -> None:
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
106
|
+
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
107
|
+
os.replace(temporary, path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_json(path: Path) -> Any:
|
|
111
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_dataless(stat: os.stat_result) -> bool:
|
|
115
|
+
return bool(getattr(stat, "st_flags", 0) & SF_DATALESS)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _human(size: float) -> str:
|
|
119
|
+
return f"{size / MB:.0f} MB" if size >= MB else f"{size / 1024:.0f} KB"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def store_root(home: str | Path) -> str:
|
|
123
|
+
"""外置目录的根(文本形式):<HOME>/.local/share/agenthelm/local-only。不看 XDG_DATA_HOME。"""
|
|
124
|
+
return "/".join([str(home).rstrip("/"), *STORE_PARTS])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def expected_link(home: str, project_id: str, relative: str) -> str:
|
|
128
|
+
return f"{store_root(home)}/{project_id}/{relative}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------- 配置
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class Config:
|
|
135
|
+
path: Path
|
|
136
|
+
exists: bool = False
|
|
137
|
+
errors: list[str] = field(default_factory=list)
|
|
138
|
+
project_id: str | None = None
|
|
139
|
+
home: str | None = None
|
|
140
|
+
service: str | None = None
|
|
141
|
+
items: list[dict] = field(default_factory=list)
|
|
142
|
+
limits: dict = field(default_factory=lambda: dict(DEFAULT_LIMITS))
|
|
143
|
+
overrides: dict[str, dict] = field(default_factory=dict)
|
|
144
|
+
keep_synced: list[str] = field(default_factory=lambda: list(DEFAULT_KEEP_SYNCED))
|
|
145
|
+
ignore: list[str] = field(default_factory=list)
|
|
146
|
+
extra: list[str] = field(default_factory=list)
|
|
147
|
+
raw: dict = field(default_factory=dict)
|
|
148
|
+
|
|
149
|
+
def limit_for(self, bucket: str) -> tuple[float, int]:
|
|
150
|
+
override = self.overrides.get(bucket, {})
|
|
151
|
+
return float(override.get("dir_mb", self.limits["dir_mb"])), int(override.get("dir_files", self.limits["dir_files"]))
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def complete(self) -> bool:
|
|
155
|
+
return bool(self.project_id and self.home)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _apply_limits(target: dict, source: Any, label: str, errors: list[str]) -> None:
|
|
159
|
+
if not isinstance(source, dict):
|
|
160
|
+
errors.append(_("{0}:limits 必须是对象").format(label))
|
|
161
|
+
return
|
|
162
|
+
for key, kind in (("warn_ratio", float), ("dir_mb", float), ("dir_files", int)):
|
|
163
|
+
if key not in source:
|
|
164
|
+
continue
|
|
165
|
+
value = source[key]
|
|
166
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0 \
|
|
167
|
+
or (key == "warn_ratio" and value > 1):
|
|
168
|
+
errors.append(_("{0}:limits.{1} 必须是正数").format(label, key) + (_("且不超过 1") if key == "warn_ratio" else ""))
|
|
169
|
+
continue
|
|
170
|
+
target[key] = kind(value)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _string_list(source: Mapping, key: str, label: str, errors: list[str]) -> list[str] | None:
|
|
174
|
+
if key not in source:
|
|
175
|
+
return None
|
|
176
|
+
value = source[key]
|
|
177
|
+
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
|
178
|
+
errors.append(_("{0}:{1} 必须是字符串数组").format(label, key))
|
|
179
|
+
return None
|
|
180
|
+
return list(value)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_config(root: Path, user_config: Path | None = None) -> Config:
|
|
184
|
+
"""优先级:内置默认 < 用户配置(~/.config/agenthelm/config.json 的 local_only) < 项目配置。
|
|
185
|
+
|
|
186
|
+
项目配置赢过用户配置:阈值影响各设备得出的结论,应当随项目同步,而不是每台机器各一套。
|
|
187
|
+
"""
|
|
188
|
+
config = Config(path=root / CONFIG_PATH)
|
|
189
|
+
if user_config is not None:
|
|
190
|
+
try:
|
|
191
|
+
section = _read_json(user_config).get("local_only")
|
|
192
|
+
except (OSError, ValueError, AttributeError):
|
|
193
|
+
section = None
|
|
194
|
+
if isinstance(section, dict):
|
|
195
|
+
if "limits" in section:
|
|
196
|
+
_apply_limits(config.limits, section["limits"], _("用户配置 {0}").format(user_config), config.errors)
|
|
197
|
+
for key, attribute in (("keep_synced", "keep_synced"), ("ignore", "ignore")):
|
|
198
|
+
value = _string_list(section, key, _("用户配置 {0}").format(user_config), config.errors)
|
|
199
|
+
if value is not None:
|
|
200
|
+
setattr(config, attribute, value)
|
|
201
|
+
path = config.path
|
|
202
|
+
if path.is_symlink() or not path.is_file():
|
|
203
|
+
return config
|
|
204
|
+
try:
|
|
205
|
+
raw = _read_json(path)
|
|
206
|
+
if not isinstance(raw, dict):
|
|
207
|
+
raise ValueError(_("顶层必须是对象"))
|
|
208
|
+
except (OSError, ValueError) as exc:
|
|
209
|
+
config.errors.append(_("{0} 无法读取:{1}").format(CONFIG_PATH.as_posix(), exc))
|
|
210
|
+
return config
|
|
211
|
+
config.exists, config.raw = True, raw
|
|
212
|
+
label = CONFIG_PATH.as_posix()
|
|
213
|
+
if raw.get("schema_version") != SCHEMA_VERSION:
|
|
214
|
+
config.errors.append(_("{0}:schema_version 必须是 {1}").format(label, SCHEMA_VERSION))
|
|
215
|
+
for key in ("project_id", "home"):
|
|
216
|
+
value = raw.get(key)
|
|
217
|
+
if value is not None and (not isinstance(value, str) or not value):
|
|
218
|
+
config.errors.append(_("{0}:{1} 必须是非空字符串").format(label, key))
|
|
219
|
+
elif value:
|
|
220
|
+
setattr(config, key, value)
|
|
221
|
+
service = raw.get("sync_service")
|
|
222
|
+
if service is not None:
|
|
223
|
+
if service != NOT_SYNCED and service not in DECLARED_SERVICES:
|
|
224
|
+
config.errors.append(_("{0}:sync_service 只能是 {1}").format(label, _('、').join([*DECLARED_SERVICES, NOT_SYNCED])))
|
|
225
|
+
else:
|
|
226
|
+
config.service = service
|
|
227
|
+
items = raw.get("items", [])
|
|
228
|
+
if not isinstance(items, list) or not all(
|
|
229
|
+
isinstance(item, dict) and isinstance(item.get("path"), str) and item["path"] for item in items):
|
|
230
|
+
config.errors.append(_("{0}:items 必须是 [{{\"path\": …, \"kind\": …}}] 数组").format(label))
|
|
231
|
+
else:
|
|
232
|
+
config.items = [{"path": item["path"], "kind": str(item.get("kind") or "declared")} for item in items]
|
|
233
|
+
for item in config.items:
|
|
234
|
+
problem = _unsafe_relative(item["path"])
|
|
235
|
+
if problem:
|
|
236
|
+
config.errors.append(_("{0}:items 里的 {1} {2}").format(label, item['path'], problem))
|
|
237
|
+
if "limits" in raw:
|
|
238
|
+
_apply_limits(config.limits, raw["limits"], label, config.errors)
|
|
239
|
+
overrides = raw["limits"].get("overrides") if isinstance(raw["limits"], dict) else None
|
|
240
|
+
if isinstance(overrides, dict):
|
|
241
|
+
for bucket, values in overrides.items():
|
|
242
|
+
probe: dict = {}
|
|
243
|
+
_apply_limits(probe, values, _("{0} 的 limits.overrides.{1}").format(label, bucket), config.errors)
|
|
244
|
+
if probe:
|
|
245
|
+
config.overrides[bucket] = probe
|
|
246
|
+
for key, attribute in (("keep_synced", "keep_synced"), ("ignore", "ignore"), ("extra_local_only", "extra")):
|
|
247
|
+
value = _string_list(raw, key, label, config.errors)
|
|
248
|
+
if value is not None:
|
|
249
|
+
setattr(config, attribute, value)
|
|
250
|
+
return config
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _unsafe_relative(value: str) -> str:
|
|
254
|
+
path = Path(value)
|
|
255
|
+
if path.is_absolute() or ".." in path.parts or value in {".", ""}:
|
|
256
|
+
return _("必须是项目内的相对路径")
|
|
257
|
+
if path.parts[0] in VCS_DIRS:
|
|
258
|
+
return _("不能在版本库目录里")
|
|
259
|
+
return ""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _config_document(config: Config, **changes: Any) -> dict:
|
|
263
|
+
"""要写盘的配置:保留用户写的其余字段,只更新这里管理的字段。"""
|
|
264
|
+
document = dict(config.raw)
|
|
265
|
+
document["schema_version"] = SCHEMA_VERSION
|
|
266
|
+
for key in ("project_id", "home", "sync_service", "items"):
|
|
267
|
+
value = changes.get(key, {"project_id": config.project_id, "home": config.home, "sync_service": config.service,
|
|
268
|
+
"items": config.items}[key])
|
|
269
|
+
if value is not None:
|
|
270
|
+
document[key] = value
|
|
271
|
+
if "limit_overrides" in changes:
|
|
272
|
+
limits = dict(document.get("limits") if isinstance(document.get("limits"), dict) else {})
|
|
273
|
+
overrides = dict(limits.get("overrides") if isinstance(limits.get("overrides"), dict) else {})
|
|
274
|
+
for bucket, values in changes["limit_overrides"].items():
|
|
275
|
+
overrides[bucket] = {**(overrides.get(bucket) if isinstance(overrides.get(bucket), dict) else {}), **values}
|
|
276
|
+
limits["overrides"] = overrides
|
|
277
|
+
document["limits"] = limits
|
|
278
|
+
return document
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------- 同步服务
|
|
282
|
+
|
|
283
|
+
def detect_service(root: Path, declared: str | None) -> dict | None:
|
|
284
|
+
"""已声明的优先;否则按路径里的目录名识别(macOS 的“桌面与文稿同步”路径上没有标记,只能声明)。"""
|
|
285
|
+
if declared == NOT_SYNCED:
|
|
286
|
+
return None
|
|
287
|
+
if declared:
|
|
288
|
+
return {"id": declared, "name": _(DECLARED_SERVICES[declared]), "source": "declared"}
|
|
289
|
+
resolved = root.resolve()
|
|
290
|
+
for identifier, name, pattern in SYNC_SERVICES:
|
|
291
|
+
if any(pattern.match(part) for part in resolved.parts):
|
|
292
|
+
return {"id": identifier, "name": name, "source": "path"}
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------- 扫描
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class Scan:
|
|
300
|
+
candidates: list[dict] = field(default_factory=list)
|
|
301
|
+
links: dict[str, str] = field(default_factory=dict)
|
|
302
|
+
package_json: dict[str, dict] = field(default_factory=dict)
|
|
303
|
+
runtime_files: list[str] = field(default_factory=list)
|
|
304
|
+
conflict_copies: list[str] = field(default_factory=list)
|
|
305
|
+
dataless: int = 0
|
|
306
|
+
buckets: dict[str, dict] = field(default_factory=dict)
|
|
307
|
+
truncated: bool = False
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _classify(name: str, rel: str, path: Path, config: Config) -> str | None:
|
|
311
|
+
if rel in config.keep_synced or name in config.keep_synced:
|
|
312
|
+
return None
|
|
313
|
+
if rel in config.extra:
|
|
314
|
+
return "declared"
|
|
315
|
+
if name == "node_modules":
|
|
316
|
+
return "node_modules"
|
|
317
|
+
if name in DEPENDENCY_NAMES:
|
|
318
|
+
return "dependency"
|
|
319
|
+
if name in CACHE_NAMES:
|
|
320
|
+
return "cache"
|
|
321
|
+
if (path / "pyvenv.cfg").is_file():
|
|
322
|
+
return "venv"
|
|
323
|
+
if (path / "CACHEDIR.TAG").is_file():
|
|
324
|
+
return "cache"
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _package_manager(directory: Path, names: set[str], relative: str) -> dict:
|
|
329
|
+
declared, version = None, ""
|
|
330
|
+
try:
|
|
331
|
+
stat = (directory / "package.json").lstat()
|
|
332
|
+
if not _is_dataless(stat) and stat.st_size <= MB:
|
|
333
|
+
value = _read_json(directory / "package.json").get("packageManager")
|
|
334
|
+
if isinstance(value, str):
|
|
335
|
+
declared = value.split("@", 1)[0].lower()
|
|
336
|
+
version = value.split("@", 1)[1] if "@" in value else ""
|
|
337
|
+
except (OSError, ValueError, AttributeError):
|
|
338
|
+
pass
|
|
339
|
+
locks = {manager for lock, manager in LOCKFILES if lock in names}
|
|
340
|
+
if declared in PM_VERDICT:
|
|
341
|
+
manager, source = declared, "packageManager"
|
|
342
|
+
elif len(locks) == 1:
|
|
343
|
+
manager, source = next(iter(locks)), "lockfile"
|
|
344
|
+
else:
|
|
345
|
+
manager, source = None, "unknown"
|
|
346
|
+
verdict = PM_VERDICT.get(manager or "", "unknown")
|
|
347
|
+
if manager == "yarn":
|
|
348
|
+
berry = ".yarnrc.yml" in names or (declared == "yarn" and version[:1].isdigit() and int(version.split(".")[0]) >= 2)
|
|
349
|
+
if berry:
|
|
350
|
+
verdict = "unverified"
|
|
351
|
+
return {"dir": relative, "pm": manager, "source": source, "verdict": verdict}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def scan_tree(root: Path, config: Config, *, quick: bool, deadline: float) -> Scan:
|
|
355
|
+
scan = Scan()
|
|
356
|
+
stack: list[tuple[Path, str, int]] = [(root, "", 0)]
|
|
357
|
+
while stack:
|
|
358
|
+
if time.monotonic() > deadline:
|
|
359
|
+
scan.truncated = True
|
|
360
|
+
break
|
|
361
|
+
directory, relative, depth = stack.pop()
|
|
362
|
+
try:
|
|
363
|
+
with os.scandir(directory) as iterator:
|
|
364
|
+
entries = sorted(iterator, key=lambda entry: entry.name)
|
|
365
|
+
except OSError:
|
|
366
|
+
continue
|
|
367
|
+
names = {entry.name for entry in entries}
|
|
368
|
+
if "package.json" in names:
|
|
369
|
+
scan.package_json[relative or "."] = _package_manager(directory, names, relative or ".")
|
|
370
|
+
for entry in entries:
|
|
371
|
+
child = f"{relative}/{entry.name}" if relative else entry.name
|
|
372
|
+
if child in config.ignore or entry.name in config.ignore:
|
|
373
|
+
continue
|
|
374
|
+
try:
|
|
375
|
+
if entry.is_symlink():
|
|
376
|
+
scan.links[child] = os.readlink(entry.path)
|
|
377
|
+
continue
|
|
378
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
379
|
+
except OSError:
|
|
380
|
+
continue
|
|
381
|
+
if is_dir:
|
|
382
|
+
if entry.name in VCS_DIRS:
|
|
383
|
+
continue
|
|
384
|
+
kind = _classify(entry.name, child, Path(entry.path), config)
|
|
385
|
+
if kind:
|
|
386
|
+
scan.candidates.append({"path": child, "kind": kind, "name": entry.name})
|
|
387
|
+
elif not quick or depth < QUICK_DEPTH:
|
|
388
|
+
stack.append((Path(entry.path), child, depth + 1))
|
|
389
|
+
continue
|
|
390
|
+
name = entry.name
|
|
391
|
+
if name.endswith(RUNTIME_SUFFIXES):
|
|
392
|
+
scan.runtime_files.append(child)
|
|
393
|
+
if CONFLICT_RE.search(name):
|
|
394
|
+
scan.conflict_copies.append(child)
|
|
395
|
+
if ICLOUD_PLACEHOLDER_RE.match(name):
|
|
396
|
+
scan.dataless += 1
|
|
397
|
+
if quick:
|
|
398
|
+
continue
|
|
399
|
+
try:
|
|
400
|
+
stat = entry.stat(follow_symlinks=False)
|
|
401
|
+
except OSError:
|
|
402
|
+
continue
|
|
403
|
+
if _is_dataless(stat):
|
|
404
|
+
scan.dataless += 1
|
|
405
|
+
bucket = scan.buckets.setdefault(relative.split("/", 1)[0] if relative else ".", {"bytes": 0, "files": 0})
|
|
406
|
+
bucket["bytes"] += stat.st_size
|
|
407
|
+
bucket["files"] += 1
|
|
408
|
+
return scan
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def measure(path: Path, deadline: float) -> dict:
|
|
412
|
+
"""目录的字节数与文件数;超过时间预算就停,结果标记为下限。"""
|
|
413
|
+
total, files, truncated = 0, 0, False
|
|
414
|
+
stack = [path]
|
|
415
|
+
while stack:
|
|
416
|
+
if time.monotonic() > deadline:
|
|
417
|
+
truncated = True
|
|
418
|
+
break
|
|
419
|
+
try:
|
|
420
|
+
with os.scandir(stack.pop()) as iterator:
|
|
421
|
+
for entry in iterator:
|
|
422
|
+
try:
|
|
423
|
+
if entry.is_symlink():
|
|
424
|
+
continue
|
|
425
|
+
if entry.is_dir(follow_symlinks=False):
|
|
426
|
+
stack.append(Path(entry.path))
|
|
427
|
+
else:
|
|
428
|
+
total += entry.stat(follow_symlinks=False).st_size
|
|
429
|
+
files += 1
|
|
430
|
+
except OSError:
|
|
431
|
+
continue
|
|
432
|
+
except OSError:
|
|
433
|
+
continue
|
|
434
|
+
return {"bytes": total, "files": files, "truncated": truncated}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ---------------------------------------------------------------- 评估
|
|
438
|
+
|
|
439
|
+
def rebuild_command(root: Path, relative: str) -> list[str] | None:
|
|
440
|
+
"""在条目所在目录里用锁文件推断重建命令;找不到就返回 None。"""
|
|
441
|
+
parent = (root / relative).parent
|
|
442
|
+
for lock, command in REBUILD_BY_LOCK:
|
|
443
|
+
if (parent / lock).is_file():
|
|
444
|
+
return list(command)
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _item_state(root: Path, relative: str, expected: str) -> tuple[str, str | None]:
|
|
449
|
+
path = root / relative
|
|
450
|
+
if path.is_symlink():
|
|
451
|
+
text = os.readlink(path)
|
|
452
|
+
if text != expected:
|
|
453
|
+
return "wrong-target", text
|
|
454
|
+
return ("ok" if Path(expected).is_dir() else "not-installed"), text
|
|
455
|
+
if not os.path.lexists(path):
|
|
456
|
+
return "absent", None
|
|
457
|
+
return ("reverted" if path.is_dir() else "conflict"), None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _linkable(candidate: dict, managers: dict[str, dict]) -> tuple[bool, str]:
|
|
461
|
+
"""(能否由 fix 外置, 原因)。只有实测过的种类才默认外置。"""
|
|
462
|
+
kind = candidate["kind"]
|
|
463
|
+
if kind == "venv":
|
|
464
|
+
return True, ""
|
|
465
|
+
if kind == "node_modules":
|
|
466
|
+
parent = candidate["path"].rpartition("/")[0] or "."
|
|
467
|
+
info = managers.get(parent) or {}
|
|
468
|
+
if info.get("verdict") == "avoid":
|
|
469
|
+
return False, _(PM_ADVICE[info["pm"]])
|
|
470
|
+
if info.get("verdict") == "unverified":
|
|
471
|
+
return False, _("yarn 2 及以上(PnP / node-modules 链接方式)未测试")
|
|
472
|
+
return True, ""
|
|
473
|
+
if kind == "declared":
|
|
474
|
+
return True, ""
|
|
475
|
+
return False, _("这类目录没有实测过软链接外置:确认后可用 --externalize 指定")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _finding(identifier: str, kind: str, title: str, reason: str, impact: str, fingerprint: str, *,
|
|
479
|
+
attention: bool = False, options: list[dict] | None = None, data: dict | None = None) -> dict:
|
|
480
|
+
options = options or []
|
|
481
|
+
return {"id": identifier, "kind": kind, "level": "suggest", "scope": "machine", "title": title, "reason": reason,
|
|
482
|
+
"impact": impact, "fingerprint": fingerprint, "fix": "local-only", "attention": attention,
|
|
483
|
+
"options": options, "next": options[0]["label"] if options else "", "data": data or {}}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _command(*parts: str) -> str:
|
|
487
|
+
return " ".join(["agenthelm", "local-only", *parts])
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def check(root: Path, *, home: str | Path, environ: Mapping[str, str] | None = None, mode: str = "standard",
|
|
491
|
+
user_config: Path | None = None) -> dict:
|
|
492
|
+
"""只读评估。没有同步服务也没有配置的项目:直接返回空发现,不扫描。"""
|
|
493
|
+
environ = environ or {}
|
|
494
|
+
root = root.resolve()
|
|
495
|
+
config = load_config(root, user_config)
|
|
496
|
+
service = detect_service(root, config.service)
|
|
497
|
+
home_text = str(home)
|
|
498
|
+
report: dict = {
|
|
499
|
+
"root": str(root), "mode": mode, "truncated": False, "service": service,
|
|
500
|
+
"config": {"path": str(config.path), "exists": config.exists, "project_id": config.project_id,
|
|
501
|
+
"home": config.home, "errors": list(config.errors)},
|
|
502
|
+
"home": {"expected": config.home, "actual": home_text, "ok": config.home in (None, home_text)},
|
|
503
|
+
"items": [], "candidates": [], "package_managers": [], "sizes": [], "notes": [], "findings": [],
|
|
504
|
+
"limits": {**config.limits},
|
|
505
|
+
}
|
|
506
|
+
findings: list[dict] = report["findings"]
|
|
507
|
+
if config.errors:
|
|
508
|
+
findings.append(_finding("local:local-only-config", "config", _("local-only 配置有问题"), _(";").join(config.errors),
|
|
509
|
+
_("有问题的配置项不生效,其余按默认处理"), _sha("\n".join(config.errors)),
|
|
510
|
+
options=[{"label": _("修正 {0} 后重新检查").format(CONFIG_PATH.as_posix()), "command": ""}]))
|
|
511
|
+
if service is None and not config.items:
|
|
512
|
+
if sys.platform == "darwin":
|
|
513
|
+
documents = [home_text + "/Documents", home_text + "/Desktop"]
|
|
514
|
+
if any(str(root) == p or str(root).startswith(p + "/") for p in documents):
|
|
515
|
+
report["notes"].append(_("项目在 ~/Documents 或 ~/Desktop 下:如果开了 iCloud“桌面与文稿”同步,路径上看不出来,"
|
|
516
|
+
"请在配置里声明 sync_service(fix --service icloud)"))
|
|
517
|
+
return _finish(report, config)
|
|
518
|
+
quick = mode == "quick"
|
|
519
|
+
deadline = time.monotonic() + BUDGETS.get(mode, BUDGETS["standard"])
|
|
520
|
+
scan = scan_tree(root, config, quick=quick, deadline=deadline)
|
|
521
|
+
report["truncated"] = scan.truncated
|
|
522
|
+
managers = {entry["dir"]: entry for entry in scan.package_json.values()}
|
|
523
|
+
report["package_managers"] = sorted(managers.values(), key=lambda entry: entry["dir"])
|
|
524
|
+
mismatch = not report["home"]["ok"]
|
|
525
|
+
if mismatch:
|
|
526
|
+
findings.append(_finding(
|
|
527
|
+
"local:home-mismatch", "home-mismatch", _("这台机器的 HOME 与项目约定不一致"),
|
|
528
|
+
_("项目记录的是 {0},这台是 {1}").format(config.home, home_text), _("软链接的文本必须在所有设备上相同;不一致时 fix 拒绝建立或修改链接,"
|
|
529
|
+
"已有链接在这台机器上也可能悬空"), _sha(f"{config.home}|{home_text}"), attention=True,
|
|
530
|
+
options=[{"label": _("让各台设备的 HOME 路径一致(例如相同的用户名),或在配置里核对 home 字段"), "command": ""}]))
|
|
531
|
+
# 已声明的外置条目
|
|
532
|
+
item_states: dict[str, str] = {}
|
|
533
|
+
if config.complete:
|
|
534
|
+
for item in config.items:
|
|
535
|
+
relative = item["path"]
|
|
536
|
+
expected = expected_link(config.home, config.project_id, relative)
|
|
537
|
+
state, actual = ("home-mismatch", None) if mismatch else _item_state(root, relative, expected)
|
|
538
|
+
item_states[relative] = state
|
|
539
|
+
command = rebuild_command(root, relative)
|
|
540
|
+
report["items"].append({"path": relative, "kind": item["kind"], "state": state, "expected": expected,
|
|
541
|
+
"actual": actual, "rebuild": command})
|
|
542
|
+
findings.extend(_item_findings(item, state, expected, actual, command))
|
|
543
|
+
elif config.items:
|
|
544
|
+
report["notes"].append(_("配置里有外置条目,但缺少 project_id 或 home:运行 fix --apply 补全"))
|
|
545
|
+
declared = set(item_states)
|
|
546
|
+
# 落在同步目录里的本机专属目录
|
|
547
|
+
synced = service is not None
|
|
548
|
+
for candidate in scan.candidates:
|
|
549
|
+
linkable, why = _linkable(candidate, managers)
|
|
550
|
+
entry = {**candidate, "linkable": linkable, "reason": why}
|
|
551
|
+
if candidate["path"] in declared:
|
|
552
|
+
continue # 已声明的条目由 local:link:<路径> 说明
|
|
553
|
+
report["candidates"].append(entry)
|
|
554
|
+
if synced:
|
|
555
|
+
_candidate_findings(root, report, config, quick)
|
|
556
|
+
# 包管理器
|
|
557
|
+
if synced:
|
|
558
|
+
for entry in report["package_managers"]:
|
|
559
|
+
if entry["verdict"] == "avoid":
|
|
560
|
+
findings.append(_finding(
|
|
561
|
+
f"local:pm:{entry['dir']}", "package-manager", _("同步目录里用 {0}({1})").format(entry['pm'], entry['dir']),
|
|
562
|
+
_(PM_ADVICE[entry["pm"]]), _("node_modules 无法外置到本机,会随项目同步"), _sha(f"pm:{entry['pm']}"),
|
|
563
|
+
options=[{"label": _("尽量改用 yarn(经典版)或 bun,它们实测可以把 node_modules 外置;"
|
|
564
|
+
"换包管理器会改 lock 文件,由你决定"), "command": ""}]))
|
|
565
|
+
# 体积
|
|
566
|
+
if not quick and synced:
|
|
567
|
+
_size_findings(report, scan, config)
|
|
568
|
+
# 其他同步问题
|
|
569
|
+
if synced and scan.runtime_files:
|
|
570
|
+
sample = _("、").join(scan.runtime_files[:5]) + (_(" 等 {0} 个").format(len(scan.runtime_files)) if len(scan.runtime_files) > 5 else "")
|
|
571
|
+
findings.append(_finding(
|
|
572
|
+
"local:runtime-files", "runtime-files", _("同步目录里有只属于运行中进程的文件"), sample,
|
|
573
|
+
_("SQLite 的 -wal/-shm、pid、socket 文件被多台设备同时同步会损坏数据库或误导进程"),
|
|
574
|
+
_sha("\n".join(sorted(scan.runtime_files))),
|
|
575
|
+
options=[{"label": _("把数据库、pid、socket 放到项目外(例如 ~/.local/state),或在同步服务里排除这些文件"), "command": ""}]))
|
|
576
|
+
if synced and scan.conflict_copies:
|
|
577
|
+
sample = _("、").join(scan.conflict_copies[:5]) + (_(" 等 {0} 个").format(len(scan.conflict_copies)) if len(scan.conflict_copies) > 5 else "")
|
|
578
|
+
findings.append(_finding(
|
|
579
|
+
"local:conflict-copies", "conflict-copies", _("同步服务留下了冲突副本"), sample,
|
|
580
|
+
_("两台设备改了同一个文件,其中一份只剩在副本里"), _sha("\n".join(sorted(scan.conflict_copies))),
|
|
581
|
+
options=[{"label": _("比对后合并进原文件并删除副本(命名规则按 Dropbox 的“conflicted copy”,iCloud 的未核对)"),
|
|
582
|
+
"command": ""}]))
|
|
583
|
+
if synced and scan.dataless:
|
|
584
|
+
report["notes"].append(_("有 {0} 个文件尚未下载到本机(iCloud 占位文件):读取它们会触发下载,agenthelm 的扫描不会读取这些文件").format(scan.dataless))
|
|
585
|
+
if scan.truncated:
|
|
586
|
+
report["notes"].append(_("扫描超过时间预算,结果只覆盖了已扫描的部分:用 local-only check --mode full 做完整扫描"))
|
|
587
|
+
store_env = environ.get("XDG_DATA_HOME")
|
|
588
|
+
if store_env and store_env.rstrip("/") != home_text.rstrip("/") + "/.local/share":
|
|
589
|
+
report["notes"].append(_("XDG_DATA_HOME={0}:外置目录固定在 {1},不跟随这个变量(链接文本要各设备一致)").format(store_env, store_root(home_text)))
|
|
590
|
+
return _finish(report, config)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _item_findings(item: dict, state: str, expected: str, actual: str | None, command: list[str] | None) -> list[dict]:
|
|
594
|
+
relative = item["path"]
|
|
595
|
+
rebuild = _("然后在项目里运行 {0}").format(' '.join(command)) if command else _("然后按项目文档重新安装依赖")
|
|
596
|
+
if state in ("ok", "home-mismatch"):
|
|
597
|
+
return []
|
|
598
|
+
identifier = f"local:link:{relative}"
|
|
599
|
+
fix = {"label": _("建立本机目录与链接({0});{1}").format(_command('fix', '--apply'), rebuild), "command": _command("fix", "--apply")}
|
|
600
|
+
if state == "not-installed":
|
|
601
|
+
return [_finding(identifier, "link", _("这台机器还没有安装 {0}").format(relative),
|
|
602
|
+
_("链接 {0} 指向 {1},本机上没有这个目录").format(relative, expected),
|
|
603
|
+
_("不装好就无法使用 {0};此时直接运行 uv sync 之类的命令会报“File exists”").format(relative),
|
|
604
|
+
_sha(f"not-installed:{expected}"), options=[fix])]
|
|
605
|
+
if state == "absent":
|
|
606
|
+
return [_finding(identifier, "link", _("项目声明了外置的 {0},这台机器上还没有").format(relative),
|
|
607
|
+
_("{0} 既不是链接也不存在(链接可能还没同步过来,或同步服务不同步软链接)").format(relative),
|
|
608
|
+
_("这台机器上用到它的命令会因为缺少依赖失败"), _sha(f"absent:{expected}"), options=[fix])]
|
|
609
|
+
if state == "reverted":
|
|
610
|
+
return [_finding(identifier, "link", _("{0} 又变回了真实目录").format(relative),
|
|
611
|
+
_("本应是指向 {0} 的链接;常见原因是 npm install 或手工重装把链接换掉了").format(expected),
|
|
612
|
+
_("依赖回到了同步盘里"), _sha(f"reverted:{expected}"), attention=True,
|
|
613
|
+
options=[{"label": _("重新外置({0});目标目录已有内容时需要你先处理").format(_command('fix', '--externalize', relative, '--apply')), "command":
|
|
614
|
+
_command("fix", "--externalize", relative, "--apply")}])]
|
|
615
|
+
if state == "wrong-target":
|
|
616
|
+
return [_finding(identifier, "link", _("{0} 的链接指向了别处").format(relative), _("期望 {0},实际 {1}").format(expected, actual),
|
|
617
|
+
_("各设备的链接文本不同会造成同步冲突或悬空"), _sha(f"wrong:{actual}"), attention=True,
|
|
618
|
+
options=[{"label": _("这是你自己的链接就保持不动并在配置里去掉该条目;否则手动改回期望的目标"), "command": ""}])]
|
|
619
|
+
return [_finding(identifier, "link", _("{0} 既不是目录也不是链接").format(relative), _("路径被一个普通文件占用"),
|
|
620
|
+
_("无法外置"), _sha(f"conflict:{relative}"), attention=True,
|
|
621
|
+
options=[{"label": _("先处理这个文件"), "command": ""}])]
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _candidate_findings(root: Path, report: dict, config: Config, quick: bool) -> None:
|
|
625
|
+
findings = report["findings"]
|
|
626
|
+
caches: list[dict] = []
|
|
627
|
+
measuring = time.monotonic() + 8 # 所有目录共用的体积统计预算
|
|
628
|
+
for candidate in report["candidates"]:
|
|
629
|
+
kind, relative = candidate["kind"], candidate["path"]
|
|
630
|
+
if kind == "cache":
|
|
631
|
+
caches.append(candidate)
|
|
632
|
+
continue
|
|
633
|
+
size = {"bytes": 0, "files": 0, "truncated": True} if quick else measure(root / relative, measuring)
|
|
634
|
+
candidate["size"] = size
|
|
635
|
+
amount = "" if quick else _(",约 {0}{1}、{2} 个文件").format('≥' if size['truncated'] else '', _human(size['bytes']), size['files'])
|
|
636
|
+
if candidate["linkable"]:
|
|
637
|
+
options = [{"label": _("外置到本机并在原位置建链接({0})").format(_command('fix', '--externalize', relative, '--apply')),
|
|
638
|
+
"command": _command("fix", "--externalize", relative, "--apply")}]
|
|
639
|
+
impact = _("整个目录随项目同步:同步很慢,不同系统或架构的二进制还会互相覆盖")
|
|
640
|
+
else:
|
|
641
|
+
options = [{"label": candidate["reason"], "command": ""}]
|
|
642
|
+
impact = _("整个目录随项目同步,但这一类目前没有已验证的外置办法")
|
|
643
|
+
findings.append(_finding(f"local:real:{relative}", "real-in-cloud", _("同步目录里有本机专属目录 {0}").format(relative),
|
|
644
|
+
_("{0}({1})是真实目录{2}").format(relative, kind, amount), impact, _sha(f"real:{kind}:{candidate['linkable']}"),
|
|
645
|
+
attention=True, options=options, data={"kind": kind, "linkable": candidate["linkable"]}))
|
|
646
|
+
if caches:
|
|
647
|
+
lines = []
|
|
648
|
+
for candidate in caches:
|
|
649
|
+
hint = _(CACHE_HINTS[candidate["name"]]) if candidate["name"] in CACHE_HINTS else ""
|
|
650
|
+
lines.append(candidate["path"] + (_("({0})").format(hint) if hint else ""))
|
|
651
|
+
findings.append(_finding(
|
|
652
|
+
"local:cache", "cache", _("同步目录里有 {0} 个工具缓存目录").format(len(caches)), _(";").join(lines[:8]) + (_(" 等") if len(lines) > 8 else ""),
|
|
653
|
+
_("缓存会不断变化,每次变化都触发同步"),
|
|
654
|
+
_sha("\n".join(sorted(entry["path"] for entry in caches))),
|
|
655
|
+
options=[{"label": _("用工具自带的环境变量把缓存放到 {0}/…(实测 PYTHONPYCACHEPREFIX、RUFF_CACHE_DIR、MYPY_CACHE_DIR 与 pytest -o cache_dir 均有效)").format(store_root(config.home or '~')), "command": ""}]))
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _limit_text(megabytes: float | None, files: int | None) -> str:
|
|
659
|
+
parts = ([f"{megabytes:g} MB"] if megabytes else []) + ([_("{0} 个文件").format(files)] if files else [])
|
|
660
|
+
return _(" 或 ").join(parts)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _size_findings(report: dict, scan: Scan, config: Config) -> None:
|
|
664
|
+
warn = config.limits["warn_ratio"]
|
|
665
|
+
for bucket, usage in sorted(scan.buckets.items()):
|
|
666
|
+
limit_mb, limit_files = config.limit_for(bucket)
|
|
667
|
+
ratio = max(usage["bytes"] / MB / limit_mb, usage["files"] / limit_files)
|
|
668
|
+
state = "exceeded" if ratio >= 1 else "approaching" if ratio >= warn else None
|
|
669
|
+
entry = {"path": bucket, "bytes": usage["bytes"], "files": usage["files"], "ratio": round(ratio, 2),
|
|
670
|
+
"state": state, "limit_mb": limit_mb, "limit_files": limit_files}
|
|
671
|
+
report["sizes"].append(entry)
|
|
672
|
+
if state is None:
|
|
673
|
+
continue
|
|
674
|
+
target = bucket if bucket != "." else _("(项目根的散文件)")
|
|
675
|
+
mb = max(limit_mb * 2, round(usage["bytes"] / MB * 1.25))
|
|
676
|
+
count = max(limit_files * 2, round(usage["files"] * 1.25))
|
|
677
|
+
quoted = bucket if bucket != "." else "."
|
|
678
|
+
raise_to = f"{quoted}={mb:g}/{count}"
|
|
679
|
+
options = [
|
|
680
|
+
{"label": _("确认这个体积正常,把阈值提到 {0:g} MB 或 {1} 个文件({2})").format(mb, count, _command('fix', '--raise-limit', raise_to, '--apply')),
|
|
681
|
+
"command": _command("fix", "--raise-limit", raise_to, "--apply")},
|
|
682
|
+
{"label": _("不再提示(agenthelm fix --skip local:size:{0} --reason …;只记在这台机器上)").format(bucket), "command": ""},
|
|
683
|
+
{"label": _("在同步服务里把它设为不同步,或移出同步目录(本工具不替你做;普通内容外置会让其他设备看不到)"), "command": ""},
|
|
684
|
+
]
|
|
685
|
+
words = _("已超过") if state == "exceeded" else _("接近")
|
|
686
|
+
report["findings"].append(_finding(
|
|
687
|
+
f"local:size:{bucket}", "size", _("{0} 的体积{1}阈值").format(target, words),
|
|
688
|
+
_("{0}、{1} 个文件;阈值 {2}(达到 {3:.0%} 开始提示)").format(_human(usage['bytes']), usage['files'], _limit_text(limit_mb, limit_files), warn),
|
|
689
|
+
_("同步范围太大会拖慢同步,也容易产生冲突副本"), _sha(f"size:{state}:{limit_mb}:{limit_files}"),
|
|
690
|
+
attention=state == "exceeded", options=options,
|
|
691
|
+
data={"bucket": bucket, "state": state, "ratio": entry["ratio"]}))
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _finish(report: dict, config: Config) -> dict:
|
|
695
|
+
detail: dict[str, str] = {}
|
|
696
|
+
if config.exists:
|
|
697
|
+
stable = {"project_id": config.project_id, "service": config.service or "",
|
|
698
|
+
"items": sorted((item["path"], item["kind"]) for item in config.items)}
|
|
699
|
+
detail["config"] = _sha(json.dumps(stable, sort_keys=True, ensure_ascii=False))
|
|
700
|
+
detail["home"] = report["home"]["actual"]
|
|
701
|
+
for item in report["items"]:
|
|
702
|
+
detail[f"link:{item['path']}"] = f"{item['state']}|{item['expected']}"
|
|
703
|
+
report["fingerprint"] = {"fingerprint": _sha(json.dumps(detail, sort_keys=True)) if detail else "", "detail": detail}
|
|
704
|
+
report["summary"] = {"findings": len(report["findings"]),
|
|
705
|
+
"attention": sum(1 for finding in report["findings"] if finding["attention"])}
|
|
706
|
+
return report
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def explain(mine: Mapping[str, str], theirs: Mapping[str, str]) -> list[str]:
|
|
710
|
+
"""两台机器的本机环境明细差在哪里。"""
|
|
711
|
+
lines = []
|
|
712
|
+
for key in sorted(set(mine) | set(theirs)):
|
|
713
|
+
if mine.get(key) == theirs.get(key):
|
|
714
|
+
continue
|
|
715
|
+
if key == "home":
|
|
716
|
+
lines.append(_("HOME 不同:这台 {0},对方 {1}(外置链接要求一致)").format(mine.get('home'), theirs.get('home')))
|
|
717
|
+
elif key == "config":
|
|
718
|
+
lines.append(_("local-only 配置不同(project_id / 同步服务 / 外置条目):等项目文件同步完成后再比较"))
|
|
719
|
+
elif key.startswith("link:"):
|
|
720
|
+
path = key[len("link:"):]
|
|
721
|
+
ours, other = mine.get(key), theirs.get(key)
|
|
722
|
+
if ours is None or other is None:
|
|
723
|
+
lines.append(_("外置条目 {0}:只有{1}声明了").format(path, _('这台') if other is None else _('对方')))
|
|
724
|
+
continue
|
|
725
|
+
(state_a, text_a), (state_b, text_b) = ours.split("|", 1), other.split("|", 1)
|
|
726
|
+
if text_a != text_b:
|
|
727
|
+
lines.append(_("外置条目 {0}:链接文本不同({1} ≠ {2})").format(path, text_a, text_b))
|
|
728
|
+
if state_a != state_b:
|
|
729
|
+
lines.append(_("外置条目 {0}:这台 {1},对方 {2}").format(path, state_a, state_b))
|
|
730
|
+
else:
|
|
731
|
+
lines.append(_("{0}:这台 {1},对方 {2}").format(key, mine.get(key), theirs.get(key)))
|
|
732
|
+
return lines
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# ---------------------------------------------------------------- fix
|
|
736
|
+
|
|
737
|
+
def _same_device(a: Path, b: Path) -> bool:
|
|
738
|
+
try:
|
|
739
|
+
return a.stat().st_dev == b.stat().st_dev
|
|
740
|
+
except OSError:
|
|
741
|
+
return False
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _tree_stats(path: Path) -> tuple[int, int]:
|
|
745
|
+
files = size = 0
|
|
746
|
+
for current, _dirs, names in os.walk(path):
|
|
747
|
+
for name in names:
|
|
748
|
+
files += 1
|
|
749
|
+
try:
|
|
750
|
+
size += os.lstat(os.path.join(current, name)).st_size
|
|
751
|
+
except OSError:
|
|
752
|
+
pass
|
|
753
|
+
return files, size
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _move_tree(source: Path, destination: Path, undo: list) -> None:
|
|
757
|
+
"""同设备用 rename;跨设备先复制并核对文件数与字节数,核对通过才删原目录。"""
|
|
758
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
759
|
+
try:
|
|
760
|
+
os.rename(source, destination)
|
|
761
|
+
undo.append(lambda: os.rename(destination, source))
|
|
762
|
+
return
|
|
763
|
+
except OSError as exc:
|
|
764
|
+
if exc.errno != errno.EXDEV:
|
|
765
|
+
raise
|
|
766
|
+
shutil.copytree(source, destination, symlinks=True)
|
|
767
|
+
undo.append(lambda: shutil.rmtree(destination, ignore_errors=True))
|
|
768
|
+
if _tree_stats(source) != _tree_stats(destination):
|
|
769
|
+
raise LocalOnlyError(_("复制到本机目录后文件数或字节数对不上,已撤销,原目录没有动"), "failed")
|
|
770
|
+
shutil.rmtree(source)
|
|
771
|
+
# 原目录已删除;之后的步骤失败时,把本机目录里的内容复制回原位置。
|
|
772
|
+
undo.append(lambda: (shutil.copytree(destination, source, symlinks=True), shutil.rmtree(destination, ignore_errors=True)))
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _validate_relative(root: Path, relative: str) -> Path:
|
|
776
|
+
problem = _unsafe_relative(relative)
|
|
777
|
+
if problem:
|
|
778
|
+
raise LocalOnlyError(f"{relative} {problem}")
|
|
779
|
+
current = root
|
|
780
|
+
parts = Path(relative).parts
|
|
781
|
+
for part in parts[:-1]:
|
|
782
|
+
current = current / part
|
|
783
|
+
if current.is_symlink():
|
|
784
|
+
raise LocalOnlyError(_("{0} 的上级 {1} 是符号链接,拒绝处理").format(relative, part))
|
|
785
|
+
return root / relative
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _document_digest(path: Path) -> str | None:
|
|
789
|
+
return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() and not path.is_symlink() else None
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def plan_fix(root: Path, *, home: str | Path, environ: Mapping[str, str] | None = None, user_config: Path | None = None,
|
|
793
|
+
externalize: list[str] | None = None, raise_limit: dict[str, dict] | None = None,
|
|
794
|
+
service: str | None = None, rebuild: bool = False) -> dict:
|
|
795
|
+
"""生成修复计划(不写任何东西)。``externalize`` 为空时处理所有“推荐”的条目。"""
|
|
796
|
+
root = root.resolve()
|
|
797
|
+
home_text = str(home)
|
|
798
|
+
raise_limit = raise_limit or {}
|
|
799
|
+
config = load_config(root, user_config)
|
|
800
|
+
blocking = [error for error in config.errors if not error.startswith("用户配置")]
|
|
801
|
+
if blocking and (config.exists or os.path.lexists(config.path)):
|
|
802
|
+
raise LocalOnlyError(_("项目配置有问题,先修正再处理(不会覆盖它):") + _(";").join(blocking), "config-invalid")
|
|
803
|
+
if config.home and config.home != home_text:
|
|
804
|
+
raise LocalOnlyError(_("这台机器的 HOME({0})与项目约定({1})不一致:拒绝建立或修改链接").format(home_text, config.home), "home-mismatch")
|
|
805
|
+
if os.name == "nt":
|
|
806
|
+
raise LocalOnlyError(_("Windows 上只支持检查,不支持外置(建立软链接需要特权)"), "unsupported")
|
|
807
|
+
if service is not None and service != NOT_SYNCED and service not in DECLARED_SERVICES:
|
|
808
|
+
raise LocalOnlyError(_("同步服务只能是 {0}").format(_('、').join([*DECLARED_SERVICES, NOT_SYNCED])))
|
|
809
|
+
report = check(root, home=home_text, environ=environ, mode="standard", user_config=user_config)
|
|
810
|
+
# 由 HOME 与项目路径派生:预览与执行得到同一个值;两台设备同时初始化也写出相同内容,不会产生冲突副本。
|
|
811
|
+
project_id = config.project_id or "p-" + _sha(f"{home_text}\0{root}")[:10]
|
|
812
|
+
steps: list[dict] = []
|
|
813
|
+
blocked: list[str] = []
|
|
814
|
+
items = {item["path"]: dict(item) for item in config.items}
|
|
815
|
+
wanted: list[tuple[str, str]] = [] # (相对路径, 种类)
|
|
816
|
+
if externalize:
|
|
817
|
+
for relative in externalize:
|
|
818
|
+
relative = relative.rstrip("/")
|
|
819
|
+
_validate_relative(root, relative)
|
|
820
|
+
known = next((entry for entry in report["candidates"] if entry["path"] == relative), None)
|
|
821
|
+
wanted.append((relative, known["kind"] if known else items.get(relative, {}).get("kind", "declared")))
|
|
822
|
+
else:
|
|
823
|
+
for entry in report["candidates"]:
|
|
824
|
+
if entry["linkable"] and entry["kind"] in ("venv", "node_modules"):
|
|
825
|
+
wanted.append((entry["path"], entry["kind"]))
|
|
826
|
+
elif entry["kind"] != "cache" and not entry["linkable"]:
|
|
827
|
+
blocked.append(_("{0}:{1}").format(entry['path'], entry['reason']))
|
|
828
|
+
for item in report["items"]:
|
|
829
|
+
if item["state"] in ("absent", "not-installed", "reverted"):
|
|
830
|
+
wanted.append((item["path"], item["kind"]))
|
|
831
|
+
for relative, kind in dict.fromkeys(wanted):
|
|
832
|
+
path = _validate_relative(root, relative)
|
|
833
|
+
target_text = expected_link(home_text, project_id, relative)
|
|
834
|
+
target = Path(target_text)
|
|
835
|
+
if kind == "node_modules":
|
|
836
|
+
manager = next((m for m in report["package_managers"] if m["dir"] == (relative.rpartition("/")[0] or ".")), None)
|
|
837
|
+
if manager and manager["verdict"] == "avoid":
|
|
838
|
+
blocked.append(_("{0}:{1};不外置(改用 yarn 经典版或 bun 后再来)").format(relative, _(PM_ADVICE[manager['pm']])))
|
|
839
|
+
continue
|
|
840
|
+
action, detail = None, ""
|
|
841
|
+
if path.is_symlink():
|
|
842
|
+
if os.readlink(path) != target_text:
|
|
843
|
+
blocked.append(_("{0}:已经是指向别处的链接({1}),不替你改").format(relative, os.readlink(path)))
|
|
844
|
+
continue
|
|
845
|
+
if not target.is_dir():
|
|
846
|
+
action, detail = "make-target", _("建立本机目录 {0}(链接已存在)").format(target_text)
|
|
847
|
+
elif path.is_dir():
|
|
848
|
+
if target.exists() and not (target.is_dir() and not any(target.iterdir())):
|
|
849
|
+
blocked.append(_("{0}:本机目标 {1} 已有内容,先确认哪一份要保留").format(relative, target_text))
|
|
850
|
+
continue
|
|
851
|
+
files, size = _tree_stats(path)
|
|
852
|
+
action = "externalize"
|
|
853
|
+
detail = _("把 {0}({1} 个文件,{2})移到 {3},原位置建软链接").format(relative, files, _human(size), target_text)
|
|
854
|
+
if kind == "declared":
|
|
855
|
+
detail += _("。注意:这不是已知可重建的目录,其他设备将看不到它的内容")
|
|
856
|
+
elif not os.path.lexists(path):
|
|
857
|
+
action, detail = "link", _("建立本机目录 {0} 和软链接 {1}").format(target_text, relative)
|
|
858
|
+
else:
|
|
859
|
+
blocked.append(_("{0}:被一个普通文件占用").format(relative))
|
|
860
|
+
continue
|
|
861
|
+
items[relative] = {"path": relative, "kind": kind}
|
|
862
|
+
if action:
|
|
863
|
+
steps.append({"action": action, "path": relative, "kind": kind, "target": target_text, "detail": detail})
|
|
864
|
+
if rebuild:
|
|
865
|
+
command = rebuild_command(root, relative)
|
|
866
|
+
if command is None:
|
|
867
|
+
blocked.append(_("{0}:找不到锁文件,无法推断重建命令(不重建)").format(relative))
|
|
868
|
+
elif shutil.which(command[0]) is None:
|
|
869
|
+
blocked.append(_("{0}:本机没有 {1},不重建").format(relative, command[0]))
|
|
870
|
+
else:
|
|
871
|
+
cwd = str((root / relative).parent)
|
|
872
|
+
steps.append({"action": "rebuild", "path": relative, "kind": kind, "command": command, "cwd": cwd,
|
|
873
|
+
"detail": _("在 {0} 运行 {1}").format(cwd, ' '.join(command))})
|
|
874
|
+
for bucket, limits in raise_limit.items():
|
|
875
|
+
steps.append({"action": "raise-limit", "path": bucket, "limits": limits,
|
|
876
|
+
"detail": _("把 {0} 的阈值提到 {1}(写入项目配置)").format(bucket, _limit_text(limits.get('dir_mb'), limits.get('dir_files')))})
|
|
877
|
+
new_items = sorted(items.values(), key=lambda entry: entry["path"])
|
|
878
|
+
config_needed = new_items != sorted(config.items, key=lambda entry: entry["path"]) or service is not None or bool(raise_limit)
|
|
879
|
+
changes: dict[str, Any] = {}
|
|
880
|
+
if config_needed:
|
|
881
|
+
changes["items"] = new_items
|
|
882
|
+
if not config.complete:
|
|
883
|
+
changes.update(project_id=project_id, home=home_text)
|
|
884
|
+
if service is not None:
|
|
885
|
+
changes["sync_service"] = service
|
|
886
|
+
verb = _("更新") if config.exists else _("创建")
|
|
887
|
+
steps.insert(0, {"action": "write-config", "path": CONFIG_PATH.as_posix(),
|
|
888
|
+
"detail": _("{0}项目配置 {1}(随项目同步;project_id={2},home={3})").format(verb, CONFIG_PATH.as_posix(), project_id, home_text)})
|
|
889
|
+
return {"root": str(root), "project_id": project_id, "config": str(config.path), "steps": steps, "blocked": blocked,
|
|
890
|
+
"config_changes": changes, "limit_overrides": dict(raise_limit), "empty": not steps}
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def apply_fix(plan: dict, *, home: str | Path, environ: Mapping[str, str] | None = None, user_config: Path | None = None,
|
|
894
|
+
recovery_root: Path | None = None) -> dict:
|
|
895
|
+
"""执行计划。任何一步失败都撤销本次已做的移动、链接和配置写入。"""
|
|
896
|
+
root = Path(plan["root"])
|
|
897
|
+
config = load_config(root, user_config)
|
|
898
|
+
undo: list = []
|
|
899
|
+
done: list[str] = []
|
|
900
|
+
backup = None
|
|
901
|
+
try:
|
|
902
|
+
for step in plan["steps"]:
|
|
903
|
+
action = step["action"]
|
|
904
|
+
if action == "write-config":
|
|
905
|
+
if config.path.is_file():
|
|
906
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
907
|
+
backup = (recovery_root or Path(home) / ".agent-pack" / "recovery") / f"{stamp}-agenthelm-local-only" / "local-only.json"
|
|
908
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
909
|
+
shutil.copy2(config.path, backup)
|
|
910
|
+
previous = config.path.read_bytes()
|
|
911
|
+
undo.append(lambda previous=previous: config.path.write_bytes(previous))
|
|
912
|
+
else:
|
|
913
|
+
undo.append(lambda: config.path.unlink(missing_ok=True))
|
|
914
|
+
changes = plan["config_changes"]
|
|
915
|
+
document = _config_document(
|
|
916
|
+
config, **{key: changes[key] for key in ("project_id", "home", "sync_service", "items") if key in changes},
|
|
917
|
+
**({"limit_overrides": plan["limit_overrides"]} if plan["limit_overrides"] else {}))
|
|
918
|
+
_write_json_atomic(config.path, document)
|
|
919
|
+
elif action in ("externalize", "make-target", "link"):
|
|
920
|
+
path, target_text = root / step["path"], step["target"]
|
|
921
|
+
target = Path(target_text)
|
|
922
|
+
_validate_relative(root, step["path"])
|
|
923
|
+
if action == "externalize":
|
|
924
|
+
if target.exists() and any(target.iterdir()):
|
|
925
|
+
raise LocalOnlyError(_("本机目标目录 {0} 已有内容").format(target_text), "conflict")
|
|
926
|
+
if target.exists():
|
|
927
|
+
target.rmdir()
|
|
928
|
+
_move_tree(path, target, undo)
|
|
929
|
+
else:
|
|
930
|
+
created = not target.exists()
|
|
931
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
932
|
+
if created:
|
|
933
|
+
undo.append(lambda target=target: shutil.rmtree(target, ignore_errors=True))
|
|
934
|
+
if not path.is_symlink():
|
|
935
|
+
os.symlink(target_text, path, target_is_directory=True)
|
|
936
|
+
undo.append(lambda path=path: path.unlink(missing_ok=True))
|
|
937
|
+
if os.readlink(path) != target_text or not target.is_dir():
|
|
938
|
+
raise LocalOnlyError(_("{0} 建链接后核对失败").format(step['path']), "failed")
|
|
939
|
+
elif action == "rebuild":
|
|
940
|
+
completed = subprocess.run(step["command"], cwd=step["cwd"], capture_output=True, text=True,
|
|
941
|
+
check=False, timeout=1800)
|
|
942
|
+
if completed.returncode != 0:
|
|
943
|
+
done.append(_("重建失败({0},退出码 {1}):").format(' '.join(step['command']), completed.returncode)
|
|
944
|
+
+ (completed.stderr or "").strip()[-300:])
|
|
945
|
+
continue
|
|
946
|
+
done.append(step["detail"])
|
|
947
|
+
except Exception:
|
|
948
|
+
for action in reversed(undo):
|
|
949
|
+
try:
|
|
950
|
+
action()
|
|
951
|
+
except OSError:
|
|
952
|
+
pass
|
|
953
|
+
raise
|
|
954
|
+
return {"applied": done, "config_backup": str(backup) if backup else ""}
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
# ---------------------------------------------------------------- 文本输出
|
|
958
|
+
|
|
959
|
+
LEVEL_MARK = {True: N_("需要处理"), False: N_("建议")}
|
|
960
|
+
PM_LABEL = {"ok": N_("可外置"), "avoid": N_("同步目录里应避免"), "unverified": N_("未测试"), "unknown": N_("未知")}
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def render_text(report: dict) -> str:
|
|
964
|
+
lines = []
|
|
965
|
+
service = report["service"]
|
|
966
|
+
lines.append(_("云同步:") + (_("{0}({1})").format(service['name'], _('已声明') if service['source'] == 'declared' else _('按路径识别'))
|
|
967
|
+
if service else _("未识别到同步服务——不需要外置,用什么都行")))
|
|
968
|
+
config = report["config"]
|
|
969
|
+
lines.append(_("配置:") + (config["path"] if config["exists"] else _("(还没有;fix --apply 会创建)")))
|
|
970
|
+
if config["exists"]:
|
|
971
|
+
lines.append(_("HOME:{0}(项目 {1},本机 {2})").format(_('一致') if report['home']['ok'] else _('不一致'), report['home']['expected'], report['home']['actual']))
|
|
972
|
+
for item in report["items"]:
|
|
973
|
+
lines.append(_("外置条目 {0}:{1} → {2}").format(item['path'], item['state'], item['expected']))
|
|
974
|
+
for entry in report["package_managers"]:
|
|
975
|
+
if report["service"] and entry["pm"]:
|
|
976
|
+
lines.append(_("包管理器 {0}:{1}({2})").format(entry['dir'], entry['pm'], _(PM_LABEL[entry['verdict']])))
|
|
977
|
+
for size in report["sizes"]:
|
|
978
|
+
if size["state"]:
|
|
979
|
+
lines.append(_("体积 {0}:{1}、{2} 个文件({3:.0%} 的阈值)").format(size['path'], _human(size['bytes']), size['files'], size['ratio']))
|
|
980
|
+
for finding in report["findings"]:
|
|
981
|
+
lines.append(_("[{0}] {1}\n 原因:{2}\n 影响:{3}").format(_(LEVEL_MARK[finding['attention']]), finding['title'], finding['reason'], finding['impact']))
|
|
982
|
+
for option in finding["options"]:
|
|
983
|
+
lines.append(f" · {option['label']}")
|
|
984
|
+
lines.append(f" ID {finding['id']}")
|
|
985
|
+
lines += [_("说明:{0}").format(note) for note in report["notes"]]
|
|
986
|
+
if not report["findings"]:
|
|
987
|
+
lines.append(_("没有发现需要处理的项。"))
|
|
988
|
+
return "\n".join(lines) + "\n"
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def render_plan(plan: dict, *, applied: dict | None = None) -> str:
|
|
992
|
+
lines = []
|
|
993
|
+
if plan["empty"] and not plan["blocked"]:
|
|
994
|
+
return _("没有需要处理的项。\n")
|
|
995
|
+
for number, step in enumerate(plan["steps"], 1):
|
|
996
|
+
lines.append(f" {number}. {step['detail']}")
|
|
997
|
+
for message in plan["blocked"]:
|
|
998
|
+
lines.append(_(" 不处理:{0}").format(message))
|
|
999
|
+
if applied is None:
|
|
1000
|
+
lines.append(_("以上为预览;确认后追加 --apply 执行。移动失败会整体撤销。"))
|
|
1001
|
+
else:
|
|
1002
|
+
if applied.get("config_backup"):
|
|
1003
|
+
lines.append(_("旧配置已备份到 {0}").format(applied['config_backup']))
|
|
1004
|
+
lines.append(_("已执行。重新运行 local-only check 核对;在其他设备上同步完成后同样运行 fix --apply 建立它们各自的本机目录。"))
|
|
1005
|
+
return "\n".join(lines) + "\n"
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
# ---------------------------------------------------------------- 命令行
|
|
1009
|
+
|
|
1010
|
+
def _parse_limits(values: list[str]) -> dict[str, dict]:
|
|
1011
|
+
"""``目录=MB`` 或 ``目录=MB/文件数``。"""
|
|
1012
|
+
result: dict[str, dict] = {}
|
|
1013
|
+
for value in values:
|
|
1014
|
+
bucket, separator, amount = value.rpartition("=")
|
|
1015
|
+
megabytes, _unused, files = amount.partition("/")
|
|
1016
|
+
try:
|
|
1017
|
+
limits = {"dir_mb": float(megabytes)}
|
|
1018
|
+
if files:
|
|
1019
|
+
limits["dir_files"] = int(files)
|
|
1020
|
+
except ValueError:
|
|
1021
|
+
limits = {"dir_mb": 0}
|
|
1022
|
+
if not separator or not bucket or limits["dir_mb"] <= 0 or limits.get("dir_files", 1) <= 0:
|
|
1023
|
+
raise LocalOnlyError(_("--raise-limit 的格式是 <目录>=<MB> 或 <目录>=<MB>/<文件数>:{0}").format(value))
|
|
1024
|
+
result[bucket.rstrip("/") or "."] = limits
|
|
1025
|
+
return result
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1029
|
+
parser = argparse.ArgumentParser(prog="agenthelm local-only", description=_("只属于这台机器的目录不进云同步盘"))
|
|
1030
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
1031
|
+
|
|
1032
|
+
def common(command: argparse.ArgumentParser) -> None:
|
|
1033
|
+
command.add_argument("--project", type=Path, help=_("项目目录(默认当前目录)"))
|
|
1034
|
+
command.add_argument("--home", type=Path, help=_("把这个目录当作 HOME(测试或隔离用)"))
|
|
1035
|
+
command.add_argument("--output", choices=("text", "json"), default="text")
|
|
1036
|
+
|
|
1037
|
+
check_parser = sub.add_parser("check", help=_("只读检查(云同步、外置链接、HOME、体积、包管理器)"))
|
|
1038
|
+
common(check_parser)
|
|
1039
|
+
check_parser.add_argument("--mode", choices=("quick", "standard", "full"), default="full",
|
|
1040
|
+
help=_("quick 只看浅层且不统计体积;full 完整扫描(默认)"))
|
|
1041
|
+
check_parser.add_argument("--strict", action="store_true", help=_("有需要处理的项时退出码为 1"))
|
|
1042
|
+
check_parser.add_argument("--reprompt", action="store_true", help=_("忽略已记录的决定"))
|
|
1043
|
+
check_parser.add_argument("--fingerprint", action="store_true", help=_("输出本机环境指纹与明细(用于跨设备对比)"))
|
|
1044
|
+
check_parser.add_argument("--explain", metavar=_("对方的 JSON"), help=_("与 --fingerprint 合用:和另一台机器的输出比较"))
|
|
1045
|
+
|
|
1046
|
+
fix = sub.add_parser("fix", help=_("外置、补建目标目录、调整阈值(默认预览,--apply 才写)"))
|
|
1047
|
+
common(fix)
|
|
1048
|
+
fix.add_argument("--externalize", action="append", default=[], metavar=_("路径"), help=_("外置这个项目内路径(可重复)"))
|
|
1049
|
+
fix.add_argument("--raise-limit", action="append", default=[], metavar=_("目录=MB"), help=_("提高某个目录的体积阈值(可重复);可写 目录=MB/文件数"))
|
|
1050
|
+
fix.add_argument("--service", help=_("声明同步服务:{0}").format(_('、').join([*DECLARED_SERVICES, NOT_SYNCED])))
|
|
1051
|
+
fix.add_argument("--rebuild", action="store_true", help=_("外置后在本机运行重建命令(uv sync / yarn install 等)"))
|
|
1052
|
+
fix.add_argument("--apply", action="store_true")
|
|
1053
|
+
|
|
1054
|
+
config = sub.add_parser("config", help=_("显示生效的配置与文件位置"))
|
|
1055
|
+
common(config)
|
|
1056
|
+
return parser
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def main(argv: list[str] | None = None, host: Any = None) -> int:
|
|
1060
|
+
args = build_parser().parse_args(argv)
|
|
1061
|
+
root = (args.project or Path.cwd()).resolve()
|
|
1062
|
+
if host is not None:
|
|
1063
|
+
env = host.current_env(args.home)
|
|
1064
|
+
home, environ, user_config = env.home, env.variables, env.config_file
|
|
1065
|
+
else:
|
|
1066
|
+
home = args.home or Path(os.path.expanduser("~"))
|
|
1067
|
+
environ, user_config = dict(os.environ), home / ".config" / "agenthelm" / "config.json"
|
|
1068
|
+
if host is not None and hasattr(host, "profile_root"):
|
|
1069
|
+
root = host.profile_root(root) or root
|
|
1070
|
+
emit = (lambda payload, text: print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1071
|
+
if args.output == "json" else sys.stdout.write(text))
|
|
1072
|
+
try:
|
|
1073
|
+
if args.action == "config":
|
|
1074
|
+
config = load_config(root, user_config)
|
|
1075
|
+
payload = {"ok": not config.errors, "path": str(config.path), "exists": config.exists,
|
|
1076
|
+
"project_id": config.project_id, "home": config.home, "sync_service": config.service,
|
|
1077
|
+
"items": config.items, "limits": config.limits, "overrides": config.overrides,
|
|
1078
|
+
"keep_synced": config.keep_synced, "ignore": config.ignore, "extra_local_only": config.extra,
|
|
1079
|
+
"errors": config.errors, "user_config": str(user_config), "store": store_root(home)}
|
|
1080
|
+
text = "\n".join([_("项目配置:{0}({1})").format(config.path, _('存在') if config.exists else _('不存在')), _("用户配置:{0} 的 local_only 段").format(user_config),
|
|
1081
|
+
_("外置目录根:{0}").format(store_root(home)), _("阈值:{0}").format(json.dumps(config.limits, ensure_ascii=False)),
|
|
1082
|
+
_("默认保持同步:{0}").format(_('、').join(config.keep_synced)), *config.errors]) + "\n"
|
|
1083
|
+
emit(payload, text)
|
|
1084
|
+
return 0 if not config.errors else 4
|
|
1085
|
+
if args.action == "check":
|
|
1086
|
+
report = host.local_only_report(host.current_env(args.home), root, mode=args.mode, reprompt=args.reprompt) \
|
|
1087
|
+
if host is not None and hasattr(host, "local_only_report") \
|
|
1088
|
+
else check(root, home=home, environ=environ, mode=args.mode, user_config=user_config)
|
|
1089
|
+
if args.fingerprint or args.explain:
|
|
1090
|
+
payload = {"ok": True, "local_only_fingerprint": report["fingerprint"]}
|
|
1091
|
+
differences: list[str] = []
|
|
1092
|
+
lines = [_("本机环境指纹 {0}").format(report['fingerprint']['fingerprint'] or _('(没有 local-only 配置)'))]
|
|
1093
|
+
if args.explain:
|
|
1094
|
+
try:
|
|
1095
|
+
other = _read_json(Path(args.explain).expanduser())
|
|
1096
|
+
theirs = other["local_only_fingerprint"]["detail"]
|
|
1097
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
1098
|
+
print(_("读不了对方的输出(需要对方运行 local-only check --fingerprint --output json):{0}").format(exc), file=sys.stderr)
|
|
1099
|
+
return 2
|
|
1100
|
+
differences = explain(report["fingerprint"]["detail"], theirs)
|
|
1101
|
+
payload["same"], payload["differences"] = not differences, differences
|
|
1102
|
+
lines.append(_("两台机器的本机环境一致。") if not differences else _("有 {0} 处不同:").format(len(differences)))
|
|
1103
|
+
lines += [f" {line}" for line in differences]
|
|
1104
|
+
else:
|
|
1105
|
+
lines += [f" {key} = {value}" for key, value in report["fingerprint"]["detail"].items()]
|
|
1106
|
+
emit(payload, "\n".join(lines) + "\n")
|
|
1107
|
+
return 1 if differences else 0
|
|
1108
|
+
emit(report, render_text(report))
|
|
1109
|
+
return 1 if args.strict and report["summary"]["attention"] else 0
|
|
1110
|
+
plan = plan_fix(root, home=home, environ=environ, user_config=user_config, externalize=args.externalize,
|
|
1111
|
+
raise_limit=_parse_limits(args.raise_limit), service=args.service, rebuild=args.rebuild)
|
|
1112
|
+
applied = apply_fix(plan, home=home, environ=environ, user_config=user_config) if args.apply and not plan["empty"] else None
|
|
1113
|
+
emit({"ok": True, "applied": applied is not None, "plan": plan, "result": applied}, render_plan(plan, applied=applied))
|
|
1114
|
+
return 0
|
|
1115
|
+
except LocalOnlyError as exc:
|
|
1116
|
+
if args.output == "json":
|
|
1117
|
+
print(json.dumps({"ok": False, "error": str(exc), "code": exc.code}, ensure_ascii=False))
|
|
1118
|
+
else:
|
|
1119
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1120
|
+
return 2
|
|
1121
|
+
except OSError as exc:
|
|
1122
|
+
if args.output == "json":
|
|
1123
|
+
print(json.dumps({"ok": False, "error": str(exc), "code": "os-error"}, ensure_ascii=False))
|
|
1124
|
+
else:
|
|
1125
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1126
|
+
return 2
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
if __name__ == "__main__":
|
|
1130
|
+
raise SystemExit(main())
|