@gavinc-cn/touchstone-dsh 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +335 -2
- package/README.zh.md +317 -0
- package/agents.py +247 -0
- package/auth.py +86 -0
- package/board.py +4229 -0
- package/builtin_assets.py +808 -0
- package/builtin_prompts/free_style/flow.md +301 -0
- package/builtin_prompts/free_style/load_test.md +114 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +41 -0
- package/builtin_prompts/free_style/templates/case_template.md +23 -0
- package/builtin_prompts/free_style/templates/index_template.md +41 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +104 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +5 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +76 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +49 -0
- package/builtin_prompts/free_style/templates/status_template.md +18 -0
- package/chat.py +616 -0
- package/db.py +1407 -0
- package/dsh-plugin/cordis.patch.yml +21 -0
- package/dsh-plugin/install.sh +79 -0
- package/dsh-plugin/lib/agent-driver.js +1888 -0
- package/dsh-plugin/lib/client.bundle.js +397 -0
- package/dsh-plugin/lib/index.js +284 -0
- package/dsh-plugin/scripts/build.mjs +37 -0
- package/dsh-plugin/scripts/prepack-check.mjs +61 -0
- package/dsh-plugin/scripts/select-bundle.mjs +51 -0
- package/dsh-plugin/src/client.js +384 -0
- package/dshdriver.py +702 -0
- package/dshevents.py +438 -0
- package/export_cases.py +160 -0
- package/extensions/dsh-autocommit/asset.json +18 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +361 -0
- package/extensions/dsh-autocommit/pkg/package.json +10 -0
- package/feishu.py +2139 -0
- package/lib.py +245 -0
- package/lifecycle.py +417 -0
- package/loadcase.py +558 -0
- package/loadgen.py +678 -0
- package/localbus.py +91 -0
- package/migrate_p7b.py +191 -0
- package/package.json +74 -4
- package/platcompat.py +272 -0
- package/prompts.py +666 -0
- package/rag.py +553 -0
- package/requirements.txt +9 -0
- package/runner.py +2471 -0
- package/server.py +5337 -0
- package/sessparse.py +638 -0
- package/touchstone.py +512 -0
- package/waitq.py +1319 -0
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +1 -0
- package/webui/dist-plugin/assets/index-eHpxBfP6.js +473 -0
- package/webui/dist-plugin/index.html +18 -0
- package/worktree.py +293 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""平台内置资产的清单扫描与安装卸载(skill / dsh_plugin 两类)。
|
|
3
|
+
|
|
4
|
+
资产清单在平台仓库 `extensions/<资产目录>/asset.json`(清单根可用环境变量
|
|
5
|
+
`TS_EXT_DIR` 覆盖,供测试与自定义部署);每个资产声明:类型、适用族、可安装目标
|
|
6
|
+
(用户级/项目级)与落盘文件。本模块负责扫描清单、按「类型 × 目标」推导落点、
|
|
7
|
+
判定安装状态与执行安装/卸载。
|
|
8
|
+
|
|
9
|
+
两类资产(P8,2026-10-04 补第二类):
|
|
10
|
+
|
|
11
|
+
- **skill**:纯文件拷贝。落点=该族技能目录(用户级 `~/.dsh/skills` 或项目级
|
|
12
|
+
`<项目>/.dsh/skills`,取 `SKILL_DIRS_BY_FAMILY` 元组首个目录)。
|
|
13
|
+
- **dsh_plugin**:dsh 原生插件包(**手放型**——本机自研插件不在 npm 上、也不声明
|
|
14
|
+
`dsh.bundle`,因此 `dsh plugin add` 这条路不适用,见下)。安装 = ① 把清单文件
|
|
15
|
+
收敛到 `~/.dsh/profiles/node_modules/<package>/`;② 在 profile 的
|
|
16
|
+
`<profiles>/<profile>/cordis.patch.yml` 里确保平台标记的 `insert:` 注册块
|
|
17
|
+
(可选再写一条 `- id: <entry_id>` + `config:` 覆盖块)。只支持用户级(dsh 插件是
|
|
18
|
+
**profile 级**资产,与项目无关)。web profile 声明 `dsh.profile.patchReload: live`
|
|
19
|
+
时改 patch 文件即热生效,否则状态里标 `restart_required` 提示重启 dsh。
|
|
20
|
+
|
|
21
|
+
为什么不用 `dsh plugin --profile <p> add`:该子命令只是 pnpm 转发(dsh 0.2.0-rc.2
|
|
22
|
+
实测),它把包装进 `dependencies` 并(仅当包声明 `dsh.bundle` 时)追加进
|
|
23
|
+
`dsh.profile.bundles`;而本机这类手写插件既未发布 registry 也没有 `dsh.bundle`,
|
|
24
|
+
CLI 只会装成一个普通依赖、打一行 warning,插件并不会被加载。故平台走「拷包 +
|
|
25
|
+
写注册行」这条与手工安装等价的路径,且完全离线。
|
|
26
|
+
|
|
27
|
+
状态四态(纯文件推导,不落库;dsh_plugin 另叠注册行状态):
|
|
28
|
+
- absent 文件与注册行都不存在
|
|
29
|
+
- installed 文件齐、内容与仓库一致,且注册行在(未被 disabled)
|
|
30
|
+
- outdated 文件齐,但内容与仓库不一致(页面显示「可更新」)
|
|
31
|
+
- partial 只齐一半(文件缺 或 注册行缺)
|
|
32
|
+
|
|
33
|
+
安装语义 = 收敛到期望状态:文件按内容覆盖(一致则不动),注册行缺则补、在则不动,
|
|
34
|
+
重复安装幂等。
|
|
35
|
+
|
|
36
|
+
安全阀:
|
|
37
|
+
- 只允许写清单声明路径(绝对路径 / `..` 越界直接拒绝——清单是平台自带文件,
|
|
38
|
+
但读写代码不做无谓信任);
|
|
39
|
+
- dsh_plugin 的注册行**只动平台自己写的带标记块**,或与规范文本**逐字一致**的手工块;
|
|
40
|
+
遇到用户自定义写法一律拒绝并说明原因(不猜、不覆盖用户手写配置);
|
|
41
|
+
- 落盘走同目录临时文件 + os.replace 原子替换(保留既有文件权限位);patch 文件同理。
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import hashlib
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import re
|
|
48
|
+
import tempfile
|
|
49
|
+
|
|
50
|
+
import agents # 复用 agent 族的 skill 目录约定(SKILL_DIRS_BY_FAMILY)
|
|
51
|
+
|
|
52
|
+
# 清单根:平台仓库 extensions/;TS_EXT_DIR 覆盖(测试与自定义部署)
|
|
53
|
+
DEFAULT_EXT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "extensions")
|
|
54
|
+
MANIFEST_NAME = "asset.json"
|
|
55
|
+
|
|
56
|
+
# manifest 允许的资产类型与安装目标
|
|
57
|
+
ASSET_TYPES = ("skill", "dsh_plugin")
|
|
58
|
+
ASSET_TARGETS = ("user", "project")
|
|
59
|
+
# 资产适用族的展示名(族 -> 页面显示名)与类型展示名
|
|
60
|
+
FAMILY_LABELS = {"dsh_plugin": "dsh 插件"}
|
|
61
|
+
TYPE_LABELS = {"skill": "skill", "dsh_plugin": "dsh 插件"}
|
|
62
|
+
# dsh 插件资产:默认 profile 与注册块标记(写进 <profile>/cordis.patch.yml 的注释包围)
|
|
63
|
+
DSH_PROFILE_DEFAULT = "web"
|
|
64
|
+
PATCH_MARK_BEGIN = "# >>> touchstone-asset:{aid}"
|
|
65
|
+
PATCH_MARK_END = "# <<< touchstone-asset:{aid}"
|
|
66
|
+
# 包名(可带 scope)与 profile 名的字符白名单
|
|
67
|
+
_PKG_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$")
|
|
68
|
+
_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AssetError(Exception):
|
|
72
|
+
"""资产操作错误(api 层转 400/500;message 为面向用户的中文原因)。"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, message, code=400):
|
|
75
|
+
super().__init__(message)
|
|
76
|
+
self.message = message
|
|
77
|
+
self.code = code
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------- 路径与清单 ----------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ext_dir():
|
|
84
|
+
"""资产清单根目录(TS_EXT_DIR 覆盖,默认仓库 extensions/)。"""
|
|
85
|
+
return os.path.abspath(os.environ.get("TS_EXT_DIR") or DEFAULT_EXT_DIR)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def display_path(path):
|
|
89
|
+
"""把 HOME 前缀缩写为 ~(回显用,避免泄漏服务进程家目录结构)。"""
|
|
90
|
+
home = os.path.expanduser("~")
|
|
91
|
+
if home and path.startswith(home + os.sep):
|
|
92
|
+
return "~" + path[len(home):]
|
|
93
|
+
return path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _sha256(path):
|
|
97
|
+
"""文件内容 sha256;读不到返回空串(调用方按「不存在/不可读」处理)。"""
|
|
98
|
+
h = hashlib.sha256()
|
|
99
|
+
try:
|
|
100
|
+
with open(path, "rb") as f:
|
|
101
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
102
|
+
h.update(chunk)
|
|
103
|
+
except OSError:
|
|
104
|
+
return ""
|
|
105
|
+
return h.hexdigest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _atomic_write_bytes(path, data):
|
|
109
|
+
"""同目录临时文件 + os.replace 原子写(保留既有文件权限位)。"""
|
|
110
|
+
d = os.path.dirname(path) or "."
|
|
111
|
+
os.makedirs(d, exist_ok=True)
|
|
112
|
+
mode = None
|
|
113
|
+
if os.path.exists(path):
|
|
114
|
+
try:
|
|
115
|
+
mode = os.stat(path).st_mode & 0o777
|
|
116
|
+
except OSError:
|
|
117
|
+
mode = None
|
|
118
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".tf-asset-")
|
|
119
|
+
try:
|
|
120
|
+
with os.fdopen(fd, "wb") as f:
|
|
121
|
+
f.write(data)
|
|
122
|
+
if mode is not None:
|
|
123
|
+
os.chmod(tmp, mode)
|
|
124
|
+
os.replace(tmp, path)
|
|
125
|
+
except BaseException:
|
|
126
|
+
try:
|
|
127
|
+
os.unlink(tmp)
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _manifest_error(asset_dir, why):
|
|
134
|
+
"""清单非法统一异常(白名单校验失败 = 开发者错误,不放行)。"""
|
|
135
|
+
return AssetError(f"资产清单非法({os.path.basename(asset_dir)}):{why}", 500)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _safe_rel(rel, what):
|
|
139
|
+
"""校验清单内相对路径:非空、非绝对、无 .. 段(防越界写)。"""
|
|
140
|
+
if not isinstance(rel, str) or not rel.strip():
|
|
141
|
+
raise ValueError(f"{what} 必须是非空字符串")
|
|
142
|
+
r = rel.strip()
|
|
143
|
+
if os.path.isabs(r) or re.match(r"^[A-Za-z]:[\\/]", r):
|
|
144
|
+
raise ValueError(f"{what} 必须是相对路径:{rel}")
|
|
145
|
+
parts = re.split(r"[\\/]+", r)
|
|
146
|
+
if any(p == ".." for p in parts):
|
|
147
|
+
raise ValueError(f"{what} 不允许 .. 越界:{rel}")
|
|
148
|
+
return r
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _scalar_str(value):
|
|
152
|
+
"""YAML 标量渲染(只放行 str/bool/int/float;复杂结构直接报清单错误)。
|
|
153
|
+
|
|
154
|
+
注册行的 `config:` 覆盖块由平台生成,故**不做 YAML 转义魔法**:只接受单行标量,
|
|
155
|
+
字符串一律双引号包裹并转义反斜杠/双引号(避免冒号、井号被读成结构)。
|
|
156
|
+
"""
|
|
157
|
+
if isinstance(value, bool):
|
|
158
|
+
return "true" if value else "false"
|
|
159
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
160
|
+
return repr(value)
|
|
161
|
+
if isinstance(value, str):
|
|
162
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
163
|
+
return f'"{escaped}"'
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _plugin_spec(asset_dir, raw):
|
|
168
|
+
"""校验并规范化 dsh_plugin 资产的 `plugin` 段。
|
|
169
|
+
|
|
170
|
+
必填 `package`(npm 包名,可带 scope);可选 `entry_id`(patch 行的 id,缺省取包名
|
|
171
|
+
最后一段)、`profile`(目标 profile,缺省 web)、`config`(扁平标量映射,写覆盖块)。
|
|
172
|
+
"""
|
|
173
|
+
if not isinstance(raw, dict):
|
|
174
|
+
raise _manifest_error(asset_dir, "type=dsh_plugin 必须声明 plugin 对象")
|
|
175
|
+
pkg = str(raw.get("package") or "").strip()
|
|
176
|
+
if not _PKG_RE.match(pkg):
|
|
177
|
+
raise _manifest_error(asset_dir, f"plugin.package 非法:{raw.get('package')!r}")
|
|
178
|
+
entry = str(raw.get("entry_id") or pkg.split("/")[-1]).strip()
|
|
179
|
+
if not _PROFILE_RE.match(entry):
|
|
180
|
+
raise _manifest_error(asset_dir, f"plugin.entry_id 非法:{entry!r}")
|
|
181
|
+
profile = str(raw.get("profile") or DSH_PROFILE_DEFAULT).strip()
|
|
182
|
+
if not _PROFILE_RE.match(profile):
|
|
183
|
+
raise _manifest_error(asset_dir, f"plugin.profile 非法:{profile!r}")
|
|
184
|
+
cfg = raw.get("config")
|
|
185
|
+
norm_cfg = {}
|
|
186
|
+
if cfg is not None:
|
|
187
|
+
if not isinstance(cfg, dict):
|
|
188
|
+
raise _manifest_error(asset_dir, "plugin.config 必须是对象")
|
|
189
|
+
for key, val in cfg.items():
|
|
190
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_.-]*$", str(key)):
|
|
191
|
+
raise _manifest_error(asset_dir, f"plugin.config 键非法:{key!r}")
|
|
192
|
+
if _scalar_str(val) is None:
|
|
193
|
+
raise _manifest_error(
|
|
194
|
+
asset_dir,
|
|
195
|
+
f"plugin.config[{key}] 只支持字符串/布尔/数字(复杂结构请手工写 patch)")
|
|
196
|
+
norm_cfg[str(key)] = val
|
|
197
|
+
req = raw.get("requires") or []
|
|
198
|
+
if not isinstance(req, list) or any(not _PKG_RE.match(str(s or "").strip()) for s in req):
|
|
199
|
+
raise _manifest_error(asset_dir, "plugin.requires 必须是包名数组(如 @deepseek-ai/dsh-llm)")
|
|
200
|
+
return {"package": pkg, "entry_id": entry, "profile": profile, "config": norm_cfg,
|
|
201
|
+
"requires": [str(s).strip() for s in req]}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _load_manifest(asset_dir):
|
|
205
|
+
"""读取并校验单个 asset.json,返回规范化资产 dict;非法抛 AssetError。"""
|
|
206
|
+
path = os.path.join(asset_dir, MANIFEST_NAME)
|
|
207
|
+
try:
|
|
208
|
+
with open(path, encoding="utf-8") as f:
|
|
209
|
+
raw = json.load(f)
|
|
210
|
+
except (OSError, ValueError) as e:
|
|
211
|
+
raise _manifest_error(asset_dir, f"读取/解析失败({e})")
|
|
212
|
+
if not isinstance(raw, dict):
|
|
213
|
+
raise _manifest_error(asset_dir, "顶层必须是 JSON 对象")
|
|
214
|
+
aid = str(raw.get("id") or "").strip()
|
|
215
|
+
if not re.match(r"^[a-z0-9][a-z0-9._-]*$", aid):
|
|
216
|
+
raise _manifest_error(asset_dir, f"id 非法:{raw.get('id')!r}")
|
|
217
|
+
atype = str(raw.get("type") or "").strip()
|
|
218
|
+
if atype not in ASSET_TYPES:
|
|
219
|
+
raise _manifest_error(asset_dir, f"type 必须是 {'/'.join(ASSET_TYPES)}:{atype!r}")
|
|
220
|
+
family = str(raw.get("family") or "").strip()
|
|
221
|
+
if family not in agents.SKILL_DIRS_BY_FAMILY:
|
|
222
|
+
raise _manifest_error(asset_dir, f"family 未知:{family!r}")
|
|
223
|
+
targets = raw.get("targets")
|
|
224
|
+
if not isinstance(targets, list) or not targets or \
|
|
225
|
+
any(t not in ASSET_TARGETS for t in targets):
|
|
226
|
+
raise _manifest_error(asset_dir, f"targets 必须是 {list(ASSET_TARGETS)} 的非空子集")
|
|
227
|
+
targets = list(dict.fromkeys(targets))
|
|
228
|
+
plugin = None
|
|
229
|
+
if atype == "dsh_plugin":
|
|
230
|
+
plugin = _plugin_spec(asset_dir, raw.get("plugin"))
|
|
231
|
+
# dsh 插件是 **profile 级**资产:装进 dsh home 下的 node_modules + profile patch,
|
|
232
|
+
# 与「哪个项目」无关,故只支持用户级目标(声明 project 直接报清单错误,不静默忽略)
|
|
233
|
+
if targets != ["user"]:
|
|
234
|
+
raise _manifest_error(
|
|
235
|
+
asset_dir, "type=dsh_plugin 只支持用户级(targets 只能是 [\"user\"])")
|
|
236
|
+
files = raw.get("files")
|
|
237
|
+
if not isinstance(files, list) or not files:
|
|
238
|
+
raise _manifest_error(asset_dir, "files 必须是非空数组")
|
|
239
|
+
norm_files = []
|
|
240
|
+
for item in files:
|
|
241
|
+
if not isinstance(item, dict):
|
|
242
|
+
raise _manifest_error(asset_dir, "files 元素必须是 {from, to} 对象")
|
|
243
|
+
try:
|
|
244
|
+
src = _safe_rel(item.get("from"), "files[].from")
|
|
245
|
+
dst = _safe_rel(item.get("to"), "files[].to")
|
|
246
|
+
except ValueError as e:
|
|
247
|
+
raise _manifest_error(asset_dir, str(e))
|
|
248
|
+
if not os.path.isfile(os.path.join(asset_dir, src)):
|
|
249
|
+
raise _manifest_error(asset_dir, f"清单文件不存在:{src}")
|
|
250
|
+
norm_files.append({"from": src, "to": dst})
|
|
251
|
+
# 注册机制(hook 时代的 config.toml 写入)已随族退役:声明即报错,不静默忽略
|
|
252
|
+
# (dsh 插件的注册走 plugin 段的 patch 行,不用这个旧字段)
|
|
253
|
+
if raw.get("register") is not None:
|
|
254
|
+
raise _manifest_error(asset_dir, "不支持 register 声明(skill 资产无需注册)")
|
|
255
|
+
return {
|
|
256
|
+
"id": aid, "name": str(raw.get("name") or aid), "type": atype,
|
|
257
|
+
"family": family, "description": str(raw.get("description") or ""),
|
|
258
|
+
"targets": targets, "files": norm_files, "plugin": plugin,
|
|
259
|
+
"dir": asset_dir,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def load_assets():
|
|
264
|
+
"""扫描清单根下所有 extensions/*/asset.json,返回资产列表(非法的直接报错)。
|
|
265
|
+
|
|
266
|
+
非法的清单不静默跳过——它只可能来自平台自身或自定义 TS_EXT_DIR,属开发者错误,
|
|
267
|
+
查询接口按 error 回显,避免「资产莫名消失」。
|
|
268
|
+
"""
|
|
269
|
+
root = ext_dir()
|
|
270
|
+
if not os.path.isdir(root):
|
|
271
|
+
return []
|
|
272
|
+
assets = []
|
|
273
|
+
ids = set()
|
|
274
|
+
for name in sorted(os.listdir(root)):
|
|
275
|
+
d = os.path.join(root, name)
|
|
276
|
+
if not os.path.isfile(os.path.join(d, MANIFEST_NAME)):
|
|
277
|
+
continue
|
|
278
|
+
asset = _load_manifest(d)
|
|
279
|
+
if asset["id"] in ids:
|
|
280
|
+
raise _manifest_error(d, f"id 重复:{asset['id']}")
|
|
281
|
+
ids.add(asset["id"])
|
|
282
|
+
assets.append(asset)
|
|
283
|
+
return assets
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_asset(asset_id):
|
|
287
|
+
"""按 id 取资产;不存在返回 None。"""
|
|
288
|
+
for a in load_assets():
|
|
289
|
+
if a["id"] == asset_id:
|
|
290
|
+
return a
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------- 落点推导 ----------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _skill_root(family, target, project_dir):
|
|
298
|
+
"""skill 落点根:该族首个用户级目录 / 项目级相对目录(约定见 agents 模块)。"""
|
|
299
|
+
user_dirs, proj_dirs = agents.SKILL_DIRS_BY_FAMILY.get(family, ((), ()))
|
|
300
|
+
if target == "user":
|
|
301
|
+
return os.path.expanduser(user_dirs[0]) if user_dirs else None
|
|
302
|
+
if not project_dir:
|
|
303
|
+
return None
|
|
304
|
+
return os.path.join(project_dir, proj_dirs[0]) if proj_dirs else None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def dsh_profiles_dir():
|
|
308
|
+
"""dsh 的 profiles 根:`TS_DSH_PROFILES` 覆盖 → `DSH_HOME` → `~/.dsh`(后接 profiles)。"""
|
|
309
|
+
env = os.environ.get("TS_DSH_PROFILES")
|
|
310
|
+
if env:
|
|
311
|
+
return os.path.abspath(env)
|
|
312
|
+
home = os.environ.get("DSH_HOME") or os.path.join(os.path.expanduser("~"), ".dsh")
|
|
313
|
+
return os.path.join(os.path.abspath(home), "profiles")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def plugin_package_root(asset):
|
|
317
|
+
"""dsh 插件包实体落点:`<profiles>/node_modules/<package>`(scope 按 npm 目录布局展开)。
|
|
318
|
+
|
|
319
|
+
放**祖先** node_modules(而非 `<profile>/node_modules`)是本机既有约定:Node 的
|
|
320
|
+
祖先查找照样解析得到,且同一份实体对所有 profile 可见(gavinc 系列插件即如此)。
|
|
321
|
+
"""
|
|
322
|
+
return os.path.join(dsh_profiles_dir(), "node_modules",
|
|
323
|
+
*asset["plugin"]["package"].split("/"))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def plugin_patch_path(asset):
|
|
327
|
+
"""注册行落点:`<profiles>/<profile>/cordis.patch.yml`。
|
|
328
|
+
|
|
329
|
+
注意**不是** `<profile>/cordis.yml`——那个文件每次 dsh 启动都被重写为空表,往里写
|
|
330
|
+
状态会丢(dsh 自身的 loader tree write-back 机制)。
|
|
331
|
+
"""
|
|
332
|
+
return os.path.join(dsh_profiles_dir(), asset["plugin"]["profile"], "cordis.patch.yml")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def plugin_patch_blocks(asset):
|
|
336
|
+
"""平台规范块文本列表(插入顺序即写入顺序):注入块 + 可选 config 覆盖块。
|
|
337
|
+
|
|
338
|
+
每块自带 `# >>> touchstone-asset:<id>` / `# <<<` 注释包围,卸载时按标记整块删除
|
|
339
|
+
(注释在 YAML 里合法,dsh 自己的 patch 文件里也有大量注释行)。
|
|
340
|
+
"""
|
|
341
|
+
p, aid = asset["plugin"], asset["id"]
|
|
342
|
+
blocks = ["\n".join([PATCH_MARK_BEGIN.format(aid=aid),
|
|
343
|
+
"- insert:",
|
|
344
|
+
f" - id: {p['entry_id']}",
|
|
345
|
+
f' name: "{p["package"]}"',
|
|
346
|
+
PATCH_MARK_END.format(aid=aid)])]
|
|
347
|
+
if p["config"]:
|
|
348
|
+
lines = [PATCH_MARK_BEGIN.format(aid=aid + ".config"),
|
|
349
|
+
f"- id: {p['entry_id']}",
|
|
350
|
+
" config:"]
|
|
351
|
+
lines += [f" {k}: {_scalar_str(v)}" for k, v in p["config"].items()]
|
|
352
|
+
lines += [" disabled: false", PATCH_MARK_END.format(aid=aid + ".config")]
|
|
353
|
+
blocks.append("\n".join(lines))
|
|
354
|
+
return blocks
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _canonical_lines(asset):
|
|
358
|
+
"""规范注入块的 3 行(逐行比对用;行尾空白已剥)。"""
|
|
359
|
+
p = asset["plugin"]
|
|
360
|
+
return ["- insert:", f" - id: {p['entry_id']}", f' name: "{p["package"]}"']
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _find_canonical(text, asset):
|
|
364
|
+
"""找「规范注入块」的起止行号 [start, end),找不到返回 None。
|
|
365
|
+
|
|
366
|
+
必须逐行精确匹配且**下一行不是同一 insert 项内的更深缩进字段**——否则用户自定义写法
|
|
367
|
+
(如在本块后面又加了 `config:`)会被子串匹配误判成规范块,卸载时截断用户的配置。
|
|
368
|
+
"""
|
|
369
|
+
lines = text.splitlines()
|
|
370
|
+
want = _canonical_lines(asset)
|
|
371
|
+
for i in range(0, len(lines) - 2):
|
|
372
|
+
if [ln.rstrip() for ln in lines[i:i + 3]] != want:
|
|
373
|
+
continue
|
|
374
|
+
nxt = lines[i + 3] if i + 3 < len(lines) else ""
|
|
375
|
+
if nxt.startswith(" "): # 同项还有字段 → 不是规范块(属自定义写法)
|
|
376
|
+
continue
|
|
377
|
+
return i, i + 3
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _patch_disabled(text, entry_id):
|
|
382
|
+
"""该条目是否在 patch 里被显式停用(dsh 面板停用会追加顶层 `- id: X` + `disabled: true`)。
|
|
383
|
+
|
|
384
|
+
只认**顶层**(列 0)的 `- id:` 行:注入块内部那行是缩进的,不会误命中。
|
|
385
|
+
"""
|
|
386
|
+
cur = None
|
|
387
|
+
for line in text.splitlines():
|
|
388
|
+
m = re.match(r"^-\s*id:\s*(\S+)\s*$", line)
|
|
389
|
+
if m:
|
|
390
|
+
cur = m.group(1).strip('"\'')
|
|
391
|
+
continue
|
|
392
|
+
if line.startswith("- "):
|
|
393
|
+
cur = None
|
|
394
|
+
continue
|
|
395
|
+
if cur == entry_id and re.match(r"^\s+disabled:\s*true\s*$", line):
|
|
396
|
+
return True
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def plugin_patch_state(asset):
|
|
401
|
+
"""注册行状态(读 patch 文件,纯推导):
|
|
402
|
+
|
|
403
|
+
- `mode="marked"` 平台写的带标记块(可安全整块删)
|
|
404
|
+
- `mode="canonical"` 手工注册但文本与规范逐字一致(可安全删)
|
|
405
|
+
- `mode="foreign"` 同名条目存在但写法自定义(**只报告、绝不改**)
|
|
406
|
+
- `mode=""` 未注册
|
|
407
|
+
"""
|
|
408
|
+
path = plugin_patch_path(asset)
|
|
409
|
+
try:
|
|
410
|
+
with open(path, encoding="utf-8") as f:
|
|
411
|
+
text = f.read()
|
|
412
|
+
except OSError:
|
|
413
|
+
text = ""
|
|
414
|
+
aid = asset["id"]
|
|
415
|
+
if PATCH_MARK_BEGIN.format(aid=aid) in text:
|
|
416
|
+
mode = "marked"
|
|
417
|
+
elif _find_canonical(text, asset) is not None:
|
|
418
|
+
mode = "canonical"
|
|
419
|
+
elif asset["plugin"]["package"] in text:
|
|
420
|
+
mode = "foreign"
|
|
421
|
+
else:
|
|
422
|
+
mode = ""
|
|
423
|
+
return {"path": path, "exists": bool(text), "mode": mode,
|
|
424
|
+
"registered": mode != "",
|
|
425
|
+
"disabled": bool(text) and _patch_disabled(text, asset["plugin"]["entry_id"])}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _patch_foreign_error(asset, patch):
|
|
429
|
+
"""遇到用户自定义注册写法时的统一拒绝原因(安装/卸载共用)。"""
|
|
430
|
+
return AssetError(
|
|
431
|
+
f"「{asset['name']}」在 {display_path(patch['path'])} 里已有自定义写法的注册行,"
|
|
432
|
+
f"平台不覆盖、也不删除用户手写的 patch 配置;请先手工移除该条目再试", 409)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def install_root(asset, target, project_dir=""):
|
|
436
|
+
"""推导该资产在指定目标下的落点根;不支持返回 None。"""
|
|
437
|
+
if target not in asset["targets"]:
|
|
438
|
+
return None
|
|
439
|
+
if asset["type"] == "dsh_plugin":
|
|
440
|
+
# dsh 插件是 profile 级资产:只支持用户级(落 dsh home 下的 node_modules)
|
|
441
|
+
return plugin_package_root(asset) if target == "user" else None
|
|
442
|
+
return _skill_root(asset["family"], target, project_dir)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _unsupported_reason(asset, target):
|
|
446
|
+
"""资产在目标下不可安装的用户可读原因。"""
|
|
447
|
+
if target not in asset["targets"]:
|
|
448
|
+
return ("该资产只支持" +
|
|
449
|
+
"、".join("用户级" if t == "user" else "项目级" for t in asset["targets"]) +
|
|
450
|
+
"安装")
|
|
451
|
+
if asset["type"] == "dsh_plugin":
|
|
452
|
+
return "dsh 插件资产只支持用户级(装进 dsh profile,与项目无关)"
|
|
453
|
+
return f"该资产({asset['family']})暂不支持此目标的安装"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _installed_files(asset, root):
|
|
457
|
+
"""清单文件在该落点根下的安装信息列表。"""
|
|
458
|
+
out = []
|
|
459
|
+
for f in asset["files"]:
|
|
460
|
+
dst = os.path.join(root, f["to"])
|
|
461
|
+
# 双保险:解析后的目标必须落在落点根内(清单已校验相对路径,此处仍复核)
|
|
462
|
+
base = os.path.realpath(root)
|
|
463
|
+
real = os.path.realpath(dst)
|
|
464
|
+
if not (real == base or real.startswith(base + os.sep)):
|
|
465
|
+
raise AssetError(f"安装路径越界:{f['to']}", 500)
|
|
466
|
+
src_hash = _sha256(os.path.join(asset["dir"], f["from"]))
|
|
467
|
+
dst_hash = _sha256(dst)
|
|
468
|
+
out.append({"to": f["to"], "path": dst, "exists": bool(dst_hash),
|
|
469
|
+
"same": bool(dst_hash) and dst_hash == src_hash})
|
|
470
|
+
return out
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ---------- 状态 / 安装 / 卸载 ----------
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def asset_status(asset, target, project_dir=""):
|
|
477
|
+
"""判定资产在指定目标下的状态,返回含明细的 dict(详情见模块 docstring)。
|
|
478
|
+
|
|
479
|
+
skill:纯文件四态。dsh_plugin:文件态与注册行态**合成**——文件齐且内容一致、
|
|
480
|
+
注册行在且未被停用,才算 installed;只齐一半(文件缺 / 注册行缺 / 被停用)判 partial。
|
|
481
|
+
"""
|
|
482
|
+
root = install_root(asset, target, project_dir)
|
|
483
|
+
supported = root is not None
|
|
484
|
+
files = _installed_files(asset, root) if supported else []
|
|
485
|
+
patch = plugin_patch_state(asset) if (supported and asset["type"] == "dsh_plugin") else None
|
|
486
|
+
state = "absent"
|
|
487
|
+
if supported:
|
|
488
|
+
any_exists = any(f["exists"] for f in files)
|
|
489
|
+
file_ok = bool(files) and all(f["exists"] for f in files)
|
|
490
|
+
file_same = file_ok and all(f["same"] for f in files)
|
|
491
|
+
if patch is None:
|
|
492
|
+
if not any_exists:
|
|
493
|
+
state = "absent"
|
|
494
|
+
elif file_same:
|
|
495
|
+
state = "installed"
|
|
496
|
+
elif file_ok:
|
|
497
|
+
state = "outdated"
|
|
498
|
+
else:
|
|
499
|
+
state = "partial"
|
|
500
|
+
else:
|
|
501
|
+
usable = patch["registered"] and not patch["disabled"]
|
|
502
|
+
if not any_exists and not patch["registered"]:
|
|
503
|
+
state = "absent"
|
|
504
|
+
elif file_same and usable:
|
|
505
|
+
state = "installed"
|
|
506
|
+
elif file_ok and usable:
|
|
507
|
+
state = "outdated"
|
|
508
|
+
else:
|
|
509
|
+
state = "partial"
|
|
510
|
+
return {"supported": supported,
|
|
511
|
+
"reason": "" if supported else _unsupported_reason(asset, target),
|
|
512
|
+
"root": display_path(root) if supported else "",
|
|
513
|
+
"state": state, "files": files, "patch": patch}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _plugin_requires_state(asset):
|
|
517
|
+
"""插件的运行时依赖解析状态(只读诊断,不写任何链接)。
|
|
518
|
+
|
|
519
|
+
手放型插件的包目录在 `<profiles>/node_modules` 下,它 `import '@deepseek-ai/dsh-llm'`
|
|
520
|
+
这类 dsh 自带包时,靠的是**同一祖先目录**里指向 dsh 安装的软链(本机既有约定)。
|
|
521
|
+
新机器上这些链接可能没建/指向旧版本 → 插件装了但加载即报模块解析失败。故这里按
|
|
522
|
+
Node 的祖先查找规则做一次纯文件检查,把「解析不到」提前暴露给用户(见 `requires`)。
|
|
523
|
+
"""
|
|
524
|
+
root = plugin_package_root(asset)
|
|
525
|
+
out = []
|
|
526
|
+
for spec in asset["plugin"].get("requires") or []:
|
|
527
|
+
found = _node_resolve(spec, root)
|
|
528
|
+
out.append({"spec": spec, "ok": bool(found), "path": display_path(found) if found else ""})
|
|
529
|
+
return out
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _node_resolve(spec, start_dir):
|
|
533
|
+
"""按 Node 祖先查找规则判断从 start_dir 能否解析到 spec;返回命中目录或空串。
|
|
534
|
+
|
|
535
|
+
纯文件检查(不跑 node):`<祖先>/node_modules/<spec>/package.json` 存在即可——
|
|
536
|
+
断链的软链因 os.path.exists 跟随解析而为 False,正是我们要暴露的情况。
|
|
537
|
+
"""
|
|
538
|
+
cur = os.path.abspath(start_dir)
|
|
539
|
+
while True:
|
|
540
|
+
cand = os.path.join(cur, "node_modules", *spec.split("/"))
|
|
541
|
+
if os.path.exists(os.path.join(cand, "package.json")):
|
|
542
|
+
return cand
|
|
543
|
+
parent = os.path.dirname(cur)
|
|
544
|
+
if parent == cur:
|
|
545
|
+
return ""
|
|
546
|
+
cur = parent
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _plugin_restart_required(asset):
|
|
550
|
+
"""装/卸后是否需要重启 dsh:profile 的 package.json 声明 `patchReload: live` 才热生效。
|
|
551
|
+
|
|
552
|
+
读不到 profile manifest(不存在/坏 JSON)按「需要重启」处理——不猜(宁可多提示一次)。
|
|
553
|
+
"""
|
|
554
|
+
manifest = os.path.join(dsh_profiles_dir(), asset["plugin"]["profile"], "package.json")
|
|
555
|
+
try:
|
|
556
|
+
with open(manifest, encoding="utf-8") as f:
|
|
557
|
+
data = json.load(f)
|
|
558
|
+
except (OSError, ValueError):
|
|
559
|
+
return True
|
|
560
|
+
prof = ((data.get("dsh") or {}).get("profile") or {}) if isinstance(data, dict) else {}
|
|
561
|
+
return str(prof.get("patchReload") or "") != "live"
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def asset_json(asset, target, project_dir=""):
|
|
565
|
+
"""资产的对外 JSON(清单元信息 + 目标下的状态明细)。"""
|
|
566
|
+
st = asset_status(asset, target, project_dir)
|
|
567
|
+
out = {"id": asset["id"], "name": asset["name"], "type": asset["type"],
|
|
568
|
+
"type_label": TYPE_LABELS.get(asset["type"], asset["type"]),
|
|
569
|
+
"family": asset["family"],
|
|
570
|
+
"family_label": FAMILY_LABELS.get(asset["family"], asset["family"]),
|
|
571
|
+
"description": asset["description"], "targets": asset["targets"],
|
|
572
|
+
"supported": st["supported"], "reason": st["reason"],
|
|
573
|
+
"state": st["state"], "root": st["root"],
|
|
574
|
+
"files": [{"to": f["to"], "path": display_path(f["path"]),
|
|
575
|
+
"exists": f["exists"], "same": f["same"]} for f in st["files"]]}
|
|
576
|
+
if st["patch"] is not None:
|
|
577
|
+
requires = _plugin_requires_state(asset)
|
|
578
|
+
out["plugin"] = {
|
|
579
|
+
"package": asset["plugin"]["package"],
|
|
580
|
+
"entry_id": asset["plugin"]["entry_id"],
|
|
581
|
+
"profile": asset["plugin"]["profile"],
|
|
582
|
+
"patch_path": display_path(st["patch"]["path"]),
|
|
583
|
+
"patch_mode": st["patch"]["mode"],
|
|
584
|
+
"registered": st["patch"]["registered"],
|
|
585
|
+
"disabled": st["patch"]["disabled"],
|
|
586
|
+
"restart_required": _plugin_restart_required(asset),
|
|
587
|
+
"requires": requires,
|
|
588
|
+
"requires_ok": all(r["ok"] for r in requires),
|
|
589
|
+
}
|
|
590
|
+
return out
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def list_assets(target, project_dir=""):
|
|
594
|
+
"""目标下的资产清单(含状态);target 非法抛 AssetError(400)。"""
|
|
595
|
+
if target not in ASSET_TARGETS:
|
|
596
|
+
raise AssetError(f"target 必须是 {'/'.join(ASSET_TARGETS)}")
|
|
597
|
+
return [asset_json(a, target, project_dir) for a in load_assets()]
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _require_profile(asset):
|
|
601
|
+
"""dsh 插件安装的前置检查:目标 profile 目录必须存在(不存在则装了也不会被加载)。"""
|
|
602
|
+
prof_dir = os.path.dirname(plugin_patch_path(asset))
|
|
603
|
+
if not os.path.isdir(prof_dir):
|
|
604
|
+
raise AssetError(f"未找到 dsh profile「{asset['plugin']['profile']}」:"
|
|
605
|
+
f"{display_path(prof_dir)}")
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _write_plugin_patch(asset):
|
|
609
|
+
"""确保注册块在 patch 里且处于启用态(幂等):返回 (action, patch_state)。
|
|
610
|
+
|
|
611
|
+
- 已注册且未停用(平台标记块 / 与规范逐行一致的手工块)→ 不动,`action="kept"`;
|
|
612
|
+
- 已注册但被停用(dsh 面板写的顶层 `- id: X` + `disabled: true`)→ 就地翻回 false,
|
|
613
|
+
`action="enabled"`(这一行是 dsh 自己生成的结构化开关,不是用户手写配置);
|
|
614
|
+
- 自定义写法(foreign)→ 拒绝(不覆盖用户手写配置);
|
|
615
|
+
- 未注册 → 追加平台标记块(profile 目录不存在则报错——没这个 profile 装了也不加载)。
|
|
616
|
+
"""
|
|
617
|
+
patch = plugin_patch_state(asset)
|
|
618
|
+
if patch["mode"] == "foreign":
|
|
619
|
+
raise _patch_foreign_error(asset, patch)
|
|
620
|
+
if patch["mode"] in ("marked", "canonical"):
|
|
621
|
+
if not patch["disabled"]:
|
|
622
|
+
return "kept", patch
|
|
623
|
+
path = patch["path"]
|
|
624
|
+
with open(path, encoding="utf-8") as f:
|
|
625
|
+
text = f.read()
|
|
626
|
+
_atomic_write_bytes(path, _patch_set_enabled(
|
|
627
|
+
text, asset["plugin"]["entry_id"]).encode("utf-8"))
|
|
628
|
+
return "enabled", plugin_patch_state(asset)
|
|
629
|
+
path = patch["path"]
|
|
630
|
+
_require_profile(asset)
|
|
631
|
+
old = ""
|
|
632
|
+
if os.path.isfile(path):
|
|
633
|
+
with open(path, encoding="utf-8") as f:
|
|
634
|
+
old = f.read()
|
|
635
|
+
text = old
|
|
636
|
+
if text and not text.endswith("\n"):
|
|
637
|
+
text += "\n"
|
|
638
|
+
if text and not text.endswith("\n\n"):
|
|
639
|
+
text += "\n"
|
|
640
|
+
text += "\n".join(plugin_patch_blocks(asset)) + "\n"
|
|
641
|
+
_atomic_write_bytes(path, text.encode("utf-8"))
|
|
642
|
+
return "added", plugin_patch_state(asset)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _patch_set_enabled(text, entry_id):
|
|
646
|
+
"""把该条目顶层 `- id: <entry_id>` 行下的 `disabled:` 改成 false(找不到则原样返回)。
|
|
647
|
+
|
|
648
|
+
只动这一行:dsh 面板的 `writePluginEnabled` 写的就是这个形状(顶层 `- id:` + 缩进
|
|
649
|
+
`disabled: true`),改它是**重新启用插件**,不是覆盖用户自定义配置。
|
|
650
|
+
"""
|
|
651
|
+
lines = text.splitlines()
|
|
652
|
+
cur = None
|
|
653
|
+
for i, line in enumerate(lines):
|
|
654
|
+
m = re.match(r"^-\s*id:\s*(\S+)\s*$", line)
|
|
655
|
+
if m:
|
|
656
|
+
cur = m.group(1).strip('"\'')
|
|
657
|
+
continue
|
|
658
|
+
if line.startswith("- "):
|
|
659
|
+
cur = None
|
|
660
|
+
continue
|
|
661
|
+
if cur == entry_id and re.match(r"^\s+disabled:\s*true\s*$", line):
|
|
662
|
+
indent = line[:len(line) - len(line.lstrip())]
|
|
663
|
+
lines[i] = f"{indent}disabled: false"
|
|
664
|
+
out = "\n".join(lines)
|
|
665
|
+
return out + "\n" if text.endswith("\n") else out
|
|
666
|
+
return text
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _strip_marked_blocks(text, aid):
|
|
670
|
+
"""删掉平台标记块(含可选 config 块)与其留下的多余空行。"""
|
|
671
|
+
begins = {PATCH_MARK_BEGIN.format(aid=aid),
|
|
672
|
+
PATCH_MARK_BEGIN.format(aid=aid + ".config")}
|
|
673
|
+
ends = {PATCH_MARK_END.format(aid=aid),
|
|
674
|
+
PATCH_MARK_END.format(aid=aid + ".config")}
|
|
675
|
+
out, skip = [], False
|
|
676
|
+
for line in text.splitlines():
|
|
677
|
+
s = line.strip()
|
|
678
|
+
if s in begins:
|
|
679
|
+
skip = True
|
|
680
|
+
continue
|
|
681
|
+
if s in ends:
|
|
682
|
+
skip = False
|
|
683
|
+
continue
|
|
684
|
+
if not skip:
|
|
685
|
+
out.append(line)
|
|
686
|
+
res = re.sub(r"\n{3,}", "\n\n", "\n".join(out))
|
|
687
|
+
if res and not res.endswith("\n"):
|
|
688
|
+
res += "\n"
|
|
689
|
+
return res
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _remove_plugin_patch(asset):
|
|
693
|
+
"""移除注册块(幂等):返回 (action, patch_state)。foreign 一律拒绝。"""
|
|
694
|
+
patch = plugin_patch_state(asset)
|
|
695
|
+
if patch["mode"] == "foreign":
|
|
696
|
+
raise _patch_foreign_error(asset, patch)
|
|
697
|
+
if patch["mode"] == "":
|
|
698
|
+
return "absent", patch
|
|
699
|
+
path = patch["path"]
|
|
700
|
+
with open(path, encoding="utf-8") as f:
|
|
701
|
+
text = f.read()
|
|
702
|
+
if patch["mode"] == "marked":
|
|
703
|
+
new = _strip_marked_blocks(text, asset["id"])
|
|
704
|
+
else:
|
|
705
|
+
span = _find_canonical(text, asset)
|
|
706
|
+
lines = text.splitlines()
|
|
707
|
+
kept = lines[:span[0]] + lines[span[1]:]
|
|
708
|
+
new = re.sub(r"\n{3,}", "\n\n", "\n".join(kept))
|
|
709
|
+
if new and not new.endswith("\n"):
|
|
710
|
+
new += "\n"
|
|
711
|
+
_atomic_write_bytes(path, new.encode("utf-8"))
|
|
712
|
+
return "removed", plugin_patch_state(asset)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _prune_empty_dirs(path, stop_at):
|
|
716
|
+
"""删空目录:先自底向上清 path 子树里的空目录,再逐级上删空父目录(到 stop_at 为止)。
|
|
717
|
+
|
|
718
|
+
非空目录一律 rmdir 失败即停(不递归删别人的文件);stop_at 自身永不删。
|
|
719
|
+
"""
|
|
720
|
+
cur = os.path.abspath(path)
|
|
721
|
+
stop = os.path.abspath(stop_at)
|
|
722
|
+
if os.path.isdir(cur):
|
|
723
|
+
for dirpath, _dirnames, _files in os.walk(cur, topdown=False):
|
|
724
|
+
try:
|
|
725
|
+
os.rmdir(dirpath) # 空则删;非空抛 OSError → 跳过
|
|
726
|
+
except OSError:
|
|
727
|
+
pass
|
|
728
|
+
while cur.startswith(stop + os.sep) and cur != stop:
|
|
729
|
+
try:
|
|
730
|
+
os.rmdir(cur)
|
|
731
|
+
except FileNotFoundError:
|
|
732
|
+
pass # 子树里已被删掉 → 继续往上收敛空父目录
|
|
733
|
+
except OSError:
|
|
734
|
+
return # 非空(还有别人的文件)→ 停
|
|
735
|
+
cur = os.path.dirname(cur)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def install(asset, target, project_dir=""):
|
|
739
|
+
"""安装/更新/修复资产(收敛到期望状态),返回结果 dict。"""
|
|
740
|
+
root = install_root(asset, target, project_dir)
|
|
741
|
+
if root is None:
|
|
742
|
+
raise AssetError(_unsupported_reason(asset, target))
|
|
743
|
+
if target == "project":
|
|
744
|
+
if not project_dir or not os.path.isdir(project_dir):
|
|
745
|
+
raise AssetError("项目目录不存在")
|
|
746
|
+
if asset["type"] == "dsh_plugin":
|
|
747
|
+
# 前置检查放在拷文件之前:profile 不存在时直接报错,不留下「包已拷、注册行没写」
|
|
748
|
+
# 的半残状态(那种状态面板会显示 partial,用户还得先修一次)
|
|
749
|
+
_require_profile(asset)
|
|
750
|
+
# 落盘:内容一致则不动,其余原子覆盖
|
|
751
|
+
written = []
|
|
752
|
+
for f in asset["files"]:
|
|
753
|
+
dst = os.path.join(root, f["to"])
|
|
754
|
+
base = os.path.realpath(root)
|
|
755
|
+
real = os.path.realpath(dst)
|
|
756
|
+
if not (real == base or real.startswith(base + os.sep)):
|
|
757
|
+
raise AssetError(f"安装路径越界:{f['to']}", 500)
|
|
758
|
+
src = os.path.join(asset["dir"], f["from"])
|
|
759
|
+
with open(src, "rb") as fh:
|
|
760
|
+
data = fh.read()
|
|
761
|
+
if _sha256(dst) == _sha256(src):
|
|
762
|
+
continue
|
|
763
|
+
_atomic_write_bytes(dst, data)
|
|
764
|
+
written.append(display_path(dst))
|
|
765
|
+
patch_action = ""
|
|
766
|
+
if asset["type"] == "dsh_plugin":
|
|
767
|
+
patch_action, _patch = _write_plugin_patch(asset)
|
|
768
|
+
st = asset_status(asset, target, project_dir)
|
|
769
|
+
return {"ok": True, "state": st["state"], "files_written": written,
|
|
770
|
+
"patch_action": patch_action,
|
|
771
|
+
"restart_required": (_plugin_restart_required(asset)
|
|
772
|
+
if asset["type"] == "dsh_plugin" else False),
|
|
773
|
+
"asset": asset_json(asset, target, project_dir)}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def uninstall(asset, target, project_dir=""):
|
|
777
|
+
"""卸载资产:只删除清单内文件(别人的文件一律不动),返回结果 dict。
|
|
778
|
+
|
|
779
|
+
dsh_plugin 另删**平台写的注册块**(或与规范逐字一致的手工块);包目录清空后连空
|
|
780
|
+
目录一起收掉(含 scope 目录,只收到 profiles 根为止)。
|
|
781
|
+
"""
|
|
782
|
+
root = install_root(asset, target, project_dir)
|
|
783
|
+
if root is None:
|
|
784
|
+
raise AssetError(_unsupported_reason(asset, target))
|
|
785
|
+
# dsh 插件:先判注册行——foreign(用户自定义写法)时**在删任何文件之前**拒绝,
|
|
786
|
+
# 免得出现「包文件已删、注册行还在」的半残状态
|
|
787
|
+
patch = plugin_patch_state(asset) if asset["type"] == "dsh_plugin" else None
|
|
788
|
+
if patch is not None and patch["mode"] == "foreign":
|
|
789
|
+
raise _patch_foreign_error(asset, patch)
|
|
790
|
+
files = _installed_files(asset, root)
|
|
791
|
+
deleted = []
|
|
792
|
+
for f in files:
|
|
793
|
+
if os.path.isfile(f["path"]):
|
|
794
|
+
try:
|
|
795
|
+
os.unlink(f["path"])
|
|
796
|
+
except OSError as e:
|
|
797
|
+
raise AssetError(f"删除失败:{display_path(f['path'])}({e})", 500)
|
|
798
|
+
deleted.append(display_path(f["path"]))
|
|
799
|
+
patch_action = ""
|
|
800
|
+
if patch is not None:
|
|
801
|
+
patch_action, _p = _remove_plugin_patch(asset)
|
|
802
|
+
_prune_empty_dirs(root, dsh_profiles_dir())
|
|
803
|
+
st = asset_status(asset, target, project_dir)
|
|
804
|
+
return {"ok": True, "state": st["state"], "files_deleted": deleted,
|
|
805
|
+
"patch_action": patch_action,
|
|
806
|
+
"restart_required": (_plugin_restart_required(asset)
|
|
807
|
+
if asset["type"] == "dsh_plugin" else False),
|
|
808
|
+
"asset": asset_json(asset, target, project_dir)}
|