specmodule 0.1.0__py3-none-any.whl
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.
- llm/__init__.py +21 -0
- llm/client.py +654 -0
- llm/config.py +213 -0
- module_harness/__init__.py +201 -0
- module_harness/align.py +39 -0
- module_harness/builtins.py +29 -0
- module_harness/checkpoint.py +336 -0
- module_harness/cli.py +1492 -0
- module_harness/command.py +115 -0
- module_harness/config.py +95 -0
- module_harness/consistency.py +123 -0
- module_harness/entry.py +74 -0
- module_harness/events.py +149 -0
- module_harness/feed.py +197 -0
- module_harness/graph_builder.py +334 -0
- module_harness/harness.py +181 -0
- module_harness/loader.py +215 -0
- module_harness/module.py +452 -0
- module_harness/outputfmt.py +139 -0
- module_harness/prompt.py +84 -0
- module_harness/query.py +216 -0
- module_harness/registry.py +180 -0
- module_harness/scaffold.py +404 -0
- module_harness/spec.py +209 -0
- module_harness/status.py +96 -0
- module_harness/store.py +482 -0
- module_harness/submodule.py +268 -0
- module_harness/templates/builtin/codereview.json +32 -0
- module_harness/templates/builtin/docwrite.json +30 -0
- module_harness/templates/builtin/summarize.json +24 -0
- module_harness/templates/builtin/translate.json +27 -0
- module_harness/translator.py +314 -0
- specmodule-0.1.0.dist-info/METADATA +321 -0
- specmodule-0.1.0.dist-info/RECORD +38 -0
- specmodule-0.1.0.dist-info/WHEEL +5 -0
- specmodule-0.1.0.dist-info/entry_points.txt +2 -0
- specmodule-0.1.0.dist-info/licenses/LICENSE +21 -0
- specmodule-0.1.0.dist-info/top_level.txt +2 -0
module_harness/status.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# module_harness/status.py
|
|
2
|
+
"""运行状态查询 — 跨进程读取 Module 当前运行状态(roadmap #7)。
|
|
3
|
+
|
|
4
|
+
数据真相源:``.specmodule/runs/<module_id>/status.json``(阶段级,Module 原子写)
|
|
5
|
+
+ ``run.sqlite`` 最新快照(tick 级,persist 模式每 tick 写)。
|
|
6
|
+
独立于 Module 内部实现——任何进程可直接查询。
|
|
7
|
+
|
|
8
|
+
并发安全:SQLite WAL 模式下单写者 + 多读者读写互不阻塞(实测 500 次写对撞
|
|
9
|
+
读取 0 次 database is locked)。同一 module_id 不可并发(双写者会锁冲突)。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ModuleStatus:
|
|
25
|
+
"""Module 运行状态静态快照。"""
|
|
26
|
+
|
|
27
|
+
module_id: str
|
|
28
|
+
phase: str # idle/translating/reviewing/building/ready/running/done/aborted/cancelled
|
|
29
|
+
status: str | None = None # tickflow RunStatus("running"/"idle"/...;无 DB 时为 None)
|
|
30
|
+
tick: int | None = None # 最新快照 tick(无 DB 时为 None)
|
|
31
|
+
fireable: list[str] = field(default_factory=list)
|
|
32
|
+
fired: list[str] = field(default_factory=list) # 最新快照本 tick fire 的节点
|
|
33
|
+
outputs: dict[str, Any] = field(default_factory=dict) # node → 最新输出
|
|
34
|
+
node_states: dict[str, dict] = field(default_factory=dict) # node → mutable state
|
|
35
|
+
error: str | None = None
|
|
36
|
+
updated_at: float = 0.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _run_dir(module_id: str, base_dir: Path) -> Path:
|
|
40
|
+
return base_dir / ".specmodule" / "runs" / module_id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def query_run_status(
|
|
44
|
+
module_id: str, base_dir: Path | None = None
|
|
45
|
+
) -> ModuleStatus | None:
|
|
46
|
+
"""查询 Module 当前运行状态。未开始(status.json 缺失)→ None。
|
|
47
|
+
|
|
48
|
+
有 ``run.sqlite`` 时叠加最新快照的 tick 级信息;DB 读失败降级为
|
|
49
|
+
phase-only(监控方绝不被 DB 锁搞崩)。
|
|
50
|
+
"""
|
|
51
|
+
base = base_dir or Path.cwd()
|
|
52
|
+
run_dir = _run_dir(module_id, base)
|
|
53
|
+
status_path = run_dir / "status.json"
|
|
54
|
+
if not status_path.exists():
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(status_path.read_text(encoding="utf-8"))
|
|
58
|
+
st = ModuleStatus(
|
|
59
|
+
module_id=str(data.get("module_id", module_id)),
|
|
60
|
+
phase=str(data.get("phase", "unknown")),
|
|
61
|
+
error=data.get("error"),
|
|
62
|
+
updated_at=float(data.get("updated_at", 0.0)),
|
|
63
|
+
)
|
|
64
|
+
except (json.JSONDecodeError, OSError, TypeError, ValueError, AttributeError):
|
|
65
|
+
log.warning("status.json 损坏或不可读: %s", status_path)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
db_path = run_dir / "run.sqlite"
|
|
69
|
+
if db_path.exists():
|
|
70
|
+
try:
|
|
71
|
+
from tickflow.persistence import SqliteBackend
|
|
72
|
+
|
|
73
|
+
backend = SqliteBackend(db_path)
|
|
74
|
+
try:
|
|
75
|
+
tick = backend.latest_tick(module_id)
|
|
76
|
+
if tick is not None:
|
|
77
|
+
snap = backend.load_snapshot(module_id, tick)
|
|
78
|
+
st.status = snap.get("status")
|
|
79
|
+
st.tick = snap.get("tick", tick)
|
|
80
|
+
st.fireable = list(snap.get("fireable", []))
|
|
81
|
+
st.fired = list(snap.get("fired", []))
|
|
82
|
+
# S3:快照不再含 edges/state——最新输出/状态从 firings
|
|
83
|
+
# 取(每节点最后一 firing,SqliteBackend.latest_firings)。
|
|
84
|
+
latest = backend.latest_firings(module_id)
|
|
85
|
+
st.outputs = {
|
|
86
|
+
d["node"]: d.get("output") for d in latest if d.get("node")
|
|
87
|
+
}
|
|
88
|
+
st.node_states = {
|
|
89
|
+
d["node"]: d.get("mutable_state", {})
|
|
90
|
+
for d in latest if d.get("node")
|
|
91
|
+
}
|
|
92
|
+
finally:
|
|
93
|
+
backend.close()
|
|
94
|
+
except Exception:
|
|
95
|
+
log.exception("读取 run.sqlite 失败,降级为 phase-only")
|
|
96
|
+
return st
|
module_harness/store.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""store 共享层:家目录、搜索路径、模块统一枚举(module-user-store 主线)。
|
|
2
|
+
|
|
3
|
+
生态契约:TUI/MCP/Web 可视化消费 ``store_home`` / ``search_paths`` /
|
|
4
|
+
``list_modules`` 作为统一枚举;CLI 管理面(install/uninstall/setup/publish/
|
|
5
|
+
update)与 run 解析共用本层。零第三方依赖(stdlib 仅 ``importlib.metadata``)。
|
|
6
|
+
|
|
7
|
+
目录约定(``SPECMODULE_HOME`` 覆盖,默认 ``~/.specmodule``)::
|
|
8
|
+
|
|
9
|
+
~/.specmodule/
|
|
10
|
+
├─ modules/<name>/ pack 格式模块目录(唯一逻辑真相)
|
|
11
|
+
├─ manifests/<name>.json {source, version, files:{rel→sha256}, installed_at}
|
|
12
|
+
├─ .env / config.json / rules.txt 用户级配置(回退层)
|
|
13
|
+
└─ cache/ 临时 clone/下载缓存,可清
|
|
14
|
+
|
|
15
|
+
模块搜索路径 = [cwd/modules] + $SPECMODULE_PATH(os.pathsep 分隔)+ store/modules
|
|
16
|
+
+ pip dist entry points(``specmodule.modules`` 组,附加来源)。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import importlib.metadata
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# entry points 组名:pip 分发的 module 声明 ``specmodule.modules``,
|
|
33
|
+
# 值 = 模块内函数(调用返回 pack 目录路径列表)
|
|
34
|
+
ENTRY_POINT_GROUP = "specmodule.modules"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def store_home() -> Path:
|
|
38
|
+
"""store 家目录:``SPECMODULE_HOME`` 非空则用之,否则 ``~/.specmodule``。
|
|
39
|
+
|
|
40
|
+
目录惰性创建(调用即建,幂等)。
|
|
41
|
+
"""
|
|
42
|
+
env = os.environ.get("SPECMODULE_HOME", "").strip()
|
|
43
|
+
home = Path(env) if env else Path.home() / ".specmodule"
|
|
44
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
return home
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def store_config_dir() -> Path:
|
|
49
|
+
"""store 级配置所在目录(= 家目录本身,与 modules/ 等并列)。"""
|
|
50
|
+
return store_home()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def modules_dir() -> Path:
|
|
54
|
+
"""store 内已安装模块目录(``<home>/modules``)。"""
|
|
55
|
+
d = store_home() / "modules"
|
|
56
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def manifests_dir() -> Path:
|
|
61
|
+
"""store 内安装期元数据目录(``<home>/manifests``)。"""
|
|
62
|
+
d = store_home() / "manifests"
|
|
63
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
return d
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cache_dir() -> Path:
|
|
68
|
+
"""store 内缓存目录(``<home>/cache``,可清)。"""
|
|
69
|
+
d = store_home() / "cache"
|
|
70
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
return d
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def search_paths() -> list[Path]:
|
|
75
|
+
"""模块搜索路径:[cwd/modules] + $SPECMODULE_PATH + store/modules。
|
|
76
|
+
|
|
77
|
+
``$SPECMODULE_PATH`` 用 ``os.pathsep``(Windows ``;`` / POSIX ``:``)分隔,
|
|
78
|
+
与 PATH 语义一致;条目可以是任意目录(手动摆放入口)。
|
|
79
|
+
只返回存在的目录(不存在的路径不参与发现,避免误报)。
|
|
80
|
+
"""
|
|
81
|
+
out: list[Path] = []
|
|
82
|
+
cwd_modules = Path.cwd() / "modules"
|
|
83
|
+
if cwd_modules.is_dir():
|
|
84
|
+
out.append(cwd_modules)
|
|
85
|
+
env = os.environ.get("SPECMODULE_PATH", "").strip()
|
|
86
|
+
if env:
|
|
87
|
+
for raw in env.split(os.pathsep):
|
|
88
|
+
p = Path(raw.strip())
|
|
89
|
+
if p.is_dir():
|
|
90
|
+
out.append(p)
|
|
91
|
+
store_mods = store_home() / "modules"
|
|
92
|
+
if store_mods.is_dir():
|
|
93
|
+
out.append(store_mods)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pip_entry_point_dirs() -> list[Path]:
|
|
98
|
+
"""pip 分发声明的 pack 目录(``specmodule.modules`` entry points)。
|
|
99
|
+
|
|
100
|
+
值 = 模块内函数(无参),返回 pack 目录路径或路径列表。加载/调用失败
|
|
101
|
+
记录 warning 并跳过(不阻断整体枚举)。
|
|
102
|
+
"""
|
|
103
|
+
out: list[Path] = []
|
|
104
|
+
try:
|
|
105
|
+
eps = importlib.metadata.entry_points()
|
|
106
|
+
selected = (
|
|
107
|
+
eps.select(group=ENTRY_POINT_GROUP)
|
|
108
|
+
if hasattr(eps, "select") else eps.get(ENTRY_POINT_GROUP, [])
|
|
109
|
+
)
|
|
110
|
+
except Exception:
|
|
111
|
+
log.exception("读取 entry points 失败")
|
|
112
|
+
return out
|
|
113
|
+
for ep in selected:
|
|
114
|
+
try:
|
|
115
|
+
fn = ep.load()
|
|
116
|
+
result = fn()
|
|
117
|
+
paths = result if isinstance(result, (list, tuple)) else [result]
|
|
118
|
+
for p in paths:
|
|
119
|
+
p = Path(p)
|
|
120
|
+
if p.is_dir():
|
|
121
|
+
out.append(p)
|
|
122
|
+
except Exception:
|
|
123
|
+
log.exception("entry point 加载失败(跳过): %s", ep.name)
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── 统一模块引用 ────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class ModuleSource:
|
|
131
|
+
"""统一模块引用:三类来源(entry 单文件 / packed 目录 / pip dist)。"""
|
|
132
|
+
|
|
133
|
+
name: str
|
|
134
|
+
kind: str # "entry" | "packed" | "pip"
|
|
135
|
+
path: Path # entry 文件路径 | pack 目录 | pip pack 目录
|
|
136
|
+
description: str = ""
|
|
137
|
+
version: str = ""
|
|
138
|
+
priority: int = 0 # 搜索路径序(0 最高);pip 附加来源排最后
|
|
139
|
+
pip_dist: str | None = None # pip 分发名(kind="pip" 时)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def is_packed(self) -> bool:
|
|
143
|
+
"""是否 pack 目录形态(需经 ModuleLoader 加载)。"""
|
|
144
|
+
return self.kind in ("packed", "pip")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _read_module_json(path: Path) -> dict[str, Any]:
|
|
148
|
+
"""读取 pack 目录 module.json(缺失/损坏 → {})。"""
|
|
149
|
+
try:
|
|
150
|
+
return json.loads((path / "module.json").read_text(encoding="utf-8"))
|
|
151
|
+
except (OSError, ValueError):
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _entry_description(entry: Any) -> str:
|
|
156
|
+
return getattr(entry, "description", "")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def list_modules(
|
|
160
|
+
search: list[Path] | None = None,
|
|
161
|
+
include_pip: bool = True,
|
|
162
|
+
) -> dict[str, list[ModuleSource]]:
|
|
163
|
+
"""枚举全部可用模块(同名多来源全量展示)。
|
|
164
|
+
|
|
165
|
+
合并三类来源:
|
|
166
|
+
- ``entry``:搜索路径下 ``<dir>/<name>.py``(模块级 ``entry`` 变量声明,
|
|
167
|
+
经 ``discover_modules`` 解析,导入失败跳过并 warning)
|
|
168
|
+
- ``packed``:搜索路径下 ``<dir>/<name>/module.json`` 存在的子目录
|
|
169
|
+
(不校验内容——加载时由 ModuleLoader 负责;此处只做轻量元数据读取)
|
|
170
|
+
- ``pip``:``specmodule.modules`` entry points 指向的 pack 目录
|
|
171
|
+
|
|
172
|
+
返回 ``{name: [ModuleSource...]}``,列表内按 priority 升序(先命中者优先)。
|
|
173
|
+
同名冲突不静默改名:解析按列表首项,管理面/列表命令全量展示。
|
|
174
|
+
"""
|
|
175
|
+
search = search if search is not None else search_paths()
|
|
176
|
+
out: dict[str, list[ModuleSource]] = {}
|
|
177
|
+
|
|
178
|
+
def add(src: ModuleSource) -> None:
|
|
179
|
+
out.setdefault(src.name, []).append(src)
|
|
180
|
+
|
|
181
|
+
for priority, d in enumerate(search):
|
|
182
|
+
# entry 单文件
|
|
183
|
+
if d.is_dir():
|
|
184
|
+
from .entry import discover_modules
|
|
185
|
+
|
|
186
|
+
entries = discover_modules(d)
|
|
187
|
+
for name, entry in entries.items():
|
|
188
|
+
add(ModuleSource(
|
|
189
|
+
name=name,
|
|
190
|
+
kind="entry",
|
|
191
|
+
path=d / f"{name}.py",
|
|
192
|
+
description=_entry_description(entry),
|
|
193
|
+
priority=priority,
|
|
194
|
+
))
|
|
195
|
+
# packed 目录:<dir>/<name>/module.json
|
|
196
|
+
for sub in sorted(p for p in d.iterdir() if p.is_dir()):
|
|
197
|
+
manifest = _read_module_json(sub)
|
|
198
|
+
if manifest:
|
|
199
|
+
add(ModuleSource(
|
|
200
|
+
name=manifest.get("name", sub.name),
|
|
201
|
+
kind="packed",
|
|
202
|
+
path=sub,
|
|
203
|
+
description=manifest.get("description", ""),
|
|
204
|
+
version=manifest.get("version", ""),
|
|
205
|
+
priority=priority,
|
|
206
|
+
))
|
|
207
|
+
if include_pip:
|
|
208
|
+
try:
|
|
209
|
+
pip_dirs = pip_entry_point_dirs()
|
|
210
|
+
except Exception:
|
|
211
|
+
log.exception("pip entry points 枚举失败(跳过 pip 来源)")
|
|
212
|
+
pip_dirs = []
|
|
213
|
+
for d in pip_dirs:
|
|
214
|
+
manifest = _read_module_json(d)
|
|
215
|
+
if manifest:
|
|
216
|
+
add(ModuleSource(
|
|
217
|
+
name=manifest.get("name", d.name),
|
|
218
|
+
kind="pip",
|
|
219
|
+
path=d,
|
|
220
|
+
description=manifest.get("description", ""),
|
|
221
|
+
version=manifest.get("version", ""),
|
|
222
|
+
priority=len(search),
|
|
223
|
+
pip_dist=None, # entry point 名不保证等于 dist 名
|
|
224
|
+
))
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def resolve_module(
|
|
229
|
+
name: str,
|
|
230
|
+
search: list[Path] | None = None,
|
|
231
|
+
) -> ModuleSource | None:
|
|
232
|
+
"""按名解析:搜索路径序第一个命中(D3:PATH 惯例,不静默改名)。"""
|
|
233
|
+
sources = list_modules(search=search).get(name, [])
|
|
234
|
+
return sources[0] if sources else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── 安装管理(install/uninstall/update/publish 共用)──────────────────
|
|
238
|
+
|
|
239
|
+
def file_sha256(path: Path) -> str:
|
|
240
|
+
"""文件 sha256(安装期元数据;不做物理去重,仅脏检测)。"""
|
|
241
|
+
h = hashlib.sha256()
|
|
242
|
+
with open(path, "rb") as f:
|
|
243
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
244
|
+
h.update(chunk)
|
|
245
|
+
return h.hexdigest()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def parse_dotenv(path: Path) -> dict[str, str]:
|
|
249
|
+
"""解析 .env 文件为 {key: value}(忽略注释/空行;不做 env 写入)。"""
|
|
250
|
+
if not path.is_file():
|
|
251
|
+
return {}
|
|
252
|
+
out: dict[str, str] = {}
|
|
253
|
+
try:
|
|
254
|
+
with open(path, encoding="utf-8") as f:
|
|
255
|
+
for line in f:
|
|
256
|
+
line = line.strip()
|
|
257
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
258
|
+
continue
|
|
259
|
+
key, _, value = line.partition("=")
|
|
260
|
+
out[key.strip()] = value.strip().strip("\"'")
|
|
261
|
+
except OSError:
|
|
262
|
+
pass
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def validate_pack_dir(path: Path) -> dict[str, Any]:
|
|
267
|
+
"""校验 pack 目录(manifest 解析 + 结构检查),返回 manifest。
|
|
268
|
+
|
|
269
|
+
校验失败抛 ValueError(带原因);**不**实例化 LLM client(D6)。
|
|
270
|
+
校验点:module.json 存在且可解析、name 合法、tasklist 存在、
|
|
271
|
+
scripts/harnesses/commands 目录引用完整(经 ModuleLoader 语义)。
|
|
272
|
+
"""
|
|
273
|
+
manifest_path = path / "module.json"
|
|
274
|
+
if not manifest_path.is_file():
|
|
275
|
+
raise ValueError(f"不是有效 pack 目录(缺少 module.json): {path}")
|
|
276
|
+
try:
|
|
277
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
278
|
+
except json.JSONDecodeError as e:
|
|
279
|
+
raise ValueError(f"module.json 解析失败: {e}") from e
|
|
280
|
+
if not isinstance(manifest, dict):
|
|
281
|
+
raise ValueError("module.json 顶层必须是对象")
|
|
282
|
+
name = manifest.get("name")
|
|
283
|
+
if not name:
|
|
284
|
+
raise ValueError("module.json 缺少 'name'")
|
|
285
|
+
tasklist = manifest.get("tasklist")
|
|
286
|
+
if tasklist is None:
|
|
287
|
+
raise ValueError("module.json 缺少 'tasklist'")
|
|
288
|
+
# 引用完整性经 ModuleLoader 校验(requires/provides/子模块目录)
|
|
289
|
+
try:
|
|
290
|
+
from .loader import ModuleLoader
|
|
291
|
+
|
|
292
|
+
ModuleLoader().load(path, lazy_client=True)
|
|
293
|
+
except Exception as e:
|
|
294
|
+
raise ValueError(f"pack 校验失败: {e}") from e
|
|
295
|
+
return manifest
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def install_pack(
|
|
299
|
+
src: Path,
|
|
300
|
+
*,
|
|
301
|
+
source: str,
|
|
302
|
+
name: str | None = None,
|
|
303
|
+
) -> Path:
|
|
304
|
+
"""把校验通过的 pack 目录复制进 store/modules,写 manifest。
|
|
305
|
+
|
|
306
|
+
``source``:来源描述(本地路径 / git URL / pip 包名),写入 manifest。
|
|
307
|
+
校验(validate_pack_dir)先于任何写入——校验失败零落盘。
|
|
308
|
+
同名已存在 → 抛 ValueError(提示 uninstall,不覆盖)。
|
|
309
|
+
"""
|
|
310
|
+
manifest = validate_pack_dir(src)
|
|
311
|
+
target_name = name or manifest["name"]
|
|
312
|
+
dest = modules_dir() / target_name
|
|
313
|
+
if dest.exists():
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"模块 '{target_name}' 已存在于 store({dest})——"
|
|
316
|
+
"先 specmodule uninstall 再重装"
|
|
317
|
+
)
|
|
318
|
+
import shutil
|
|
319
|
+
|
|
320
|
+
shutil.copytree(src, dest)
|
|
321
|
+
try:
|
|
322
|
+
files = {
|
|
323
|
+
str(p.relative_to(dest)): file_sha256(p)
|
|
324
|
+
for p in dest.rglob("*")
|
|
325
|
+
if p.is_file()
|
|
326
|
+
}
|
|
327
|
+
manifest_path = manifests_dir() / f"{target_name}.json"
|
|
328
|
+
manifest_path.write_text(json.dumps({
|
|
329
|
+
"name": target_name,
|
|
330
|
+
"source": source,
|
|
331
|
+
"version": manifest.get("version", "0.1.0"),
|
|
332
|
+
"files": files,
|
|
333
|
+
"installed_at": _now_iso(),
|
|
334
|
+
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
335
|
+
except Exception:
|
|
336
|
+
# 写入失败回滚(manifest 失败 → 删除已复制目录,保持零残留)
|
|
337
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
338
|
+
raise
|
|
339
|
+
return dest
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _now_iso() -> str:
|
|
343
|
+
import datetime
|
|
344
|
+
|
|
345
|
+
return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def load_manifest(name: str) -> dict[str, Any] | None:
|
|
349
|
+
"""读取安装 manifest;缺失/损坏 → None。"""
|
|
350
|
+
p = manifests_dir() / f"{name}.json"
|
|
351
|
+
if not p.is_file():
|
|
352
|
+
return None
|
|
353
|
+
try:
|
|
354
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
355
|
+
except (OSError, ValueError):
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def uninstall_pack(name: str) -> bool:
|
|
360
|
+
"""移除模块目录 + manifest。不存在 → False(调用方报错)。"""
|
|
361
|
+
dest = modules_dir() / name
|
|
362
|
+
removed = False
|
|
363
|
+
if dest.is_dir():
|
|
364
|
+
import shutil
|
|
365
|
+
|
|
366
|
+
shutil.rmtree(dest)
|
|
367
|
+
removed = True
|
|
368
|
+
mp = manifests_dir() / f"{name}.json"
|
|
369
|
+
if mp.is_file():
|
|
370
|
+
mp.unlink()
|
|
371
|
+
removed = True
|
|
372
|
+
return removed
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ── update 脏检测 ────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
def check_updates(name: str, src: Path) -> dict[str, Any]:
|
|
378
|
+
"""按 manifest 比对来源目录与已装模块:返回差异清单。
|
|
379
|
+
|
|
380
|
+
返回::
|
|
381
|
+
|
|
382
|
+
{
|
|
383
|
+
"name": ...,
|
|
384
|
+
"changed": ["rel/path", ...], # 来源内容变化的文件(相对 manifest)
|
|
385
|
+
"added": [...], # 来源新增文件
|
|
386
|
+
"removed": [...], # 来源删除的文件(manifest 有、来源无)
|
|
387
|
+
"untracked": [...], # 已装文件不在 manifest(本地新增)
|
|
388
|
+
"local_modified": [...], # 已装文件内容 ≠ manifest(本地改动)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
manifest 缺失(非 install 安装的模块)→ ValueError(提示用 install)。
|
|
392
|
+
"""
|
|
393
|
+
manifest = load_manifest(name)
|
|
394
|
+
if manifest is None:
|
|
395
|
+
raise ValueError(
|
|
396
|
+
f"模块 '{name}' 无安装 manifest(未用 install/publish 安装)——"
|
|
397
|
+
"无法做脏检测,请用 install 重装"
|
|
398
|
+
)
|
|
399
|
+
recorded = manifest.get("files", {})
|
|
400
|
+
installed = modules_dir() / name
|
|
401
|
+
|
|
402
|
+
changed: list[str] = []
|
|
403
|
+
added: list[str] = []
|
|
404
|
+
removed: list[str] = []
|
|
405
|
+
untracked: list[str] = []
|
|
406
|
+
local_modified: list[str] = []
|
|
407
|
+
|
|
408
|
+
# 来源侧
|
|
409
|
+
src_files = {
|
|
410
|
+
str(p.relative_to(src)): p
|
|
411
|
+
for p in src.rglob("*")
|
|
412
|
+
if p.is_file()
|
|
413
|
+
}
|
|
414
|
+
for rel, sp in src_files.items():
|
|
415
|
+
if rel in recorded:
|
|
416
|
+
if file_sha256(sp) != recorded[rel]:
|
|
417
|
+
changed.append(rel)
|
|
418
|
+
else:
|
|
419
|
+
added.append(rel)
|
|
420
|
+
# 已装侧
|
|
421
|
+
installed_files = {
|
|
422
|
+
str(p.relative_to(installed)): p
|
|
423
|
+
for p in installed.rglob("*")
|
|
424
|
+
if p.is_file()
|
|
425
|
+
}
|
|
426
|
+
for rel in recorded:
|
|
427
|
+
if rel not in src_files:
|
|
428
|
+
removed.append(rel)
|
|
429
|
+
for rel, ip in installed_files.items():
|
|
430
|
+
if rel not in recorded:
|
|
431
|
+
untracked.append(rel)
|
|
432
|
+
elif file_sha256(ip) != recorded[rel]:
|
|
433
|
+
local_modified.append(rel)
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
"name": name,
|
|
437
|
+
"changed": sorted(changed),
|
|
438
|
+
"added": sorted(added),
|
|
439
|
+
"removed": sorted(removed),
|
|
440
|
+
"untracked": sorted(untracked),
|
|
441
|
+
"local_modified": sorted(local_modified),
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def apply_update(name: str, src: Path) -> None:
|
|
446
|
+
"""无差异直接替换(先备份再覆盖);有差异列清单交调用方决策。
|
|
447
|
+
|
|
448
|
+
本函数只做"确认后"的执行:覆盖已装目录内容为来源目录内容,
|
|
449
|
+
重写 manifest(来源/版本/哈希/installed_at 刷新)。未确认不调用。
|
|
450
|
+
"""
|
|
451
|
+
import shutil
|
|
452
|
+
|
|
453
|
+
dest = modules_dir() / name
|
|
454
|
+
backup = dest.with_name(f".{name}.bak")
|
|
455
|
+
if backup.exists():
|
|
456
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
457
|
+
if dest.exists():
|
|
458
|
+
shutil.move(str(dest), str(backup))
|
|
459
|
+
try:
|
|
460
|
+
shutil.copytree(src, dest)
|
|
461
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
462
|
+
old = load_manifest(name) or {}
|
|
463
|
+
manifest = validate_pack_dir(src)
|
|
464
|
+
files = {
|
|
465
|
+
str(p.relative_to(dest)): file_sha256(p)
|
|
466
|
+
for p in dest.rglob("*")
|
|
467
|
+
if p.is_file()
|
|
468
|
+
}
|
|
469
|
+
mp = manifests_dir() / f"{name}.json"
|
|
470
|
+
mp.write_text(json.dumps({
|
|
471
|
+
"name": name,
|
|
472
|
+
"source": old.get("source") or str(src),
|
|
473
|
+
"version": manifest.get("version", "0.1.0"),
|
|
474
|
+
"files": files,
|
|
475
|
+
"installed_at": _now_iso(),
|
|
476
|
+
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
477
|
+
except Exception:
|
|
478
|
+
# 失败回滚备份
|
|
479
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
480
|
+
if backup.exists():
|
|
481
|
+
shutil.move(str(backup), str(dest))
|
|
482
|
+
raise
|