@gavinc-cn/touchstone-dsh 0.2.0 → 0.4.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 +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6227 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2090 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1747 -0
- package/backend/lib/feishu_cfg.js +262 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +2077 -0
- package/backend/lib/feishu_provision.js +886 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +426 -0
- package/backend/lib/proc.js +361 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4546 -0
- package/backend/lib/seed_hint.js +40 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +227 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1228 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +249 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +298 -0
- package/backend/routes/projects.js +1138 -0
- package/backend/routes/sessions.js +1003 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +505 -0
- package/board.py +96 -13
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/chat.py +33 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +111 -2
- package/dsh-plugin/lib/index.js +340 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +111 -2
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu.py +417 -3
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/server.py +155 -11
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
- package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,1177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `builtin_assets.py`(808 行,仓库根)的 Node 对应物(M6-6 前置批次)。
|
|
3
|
+
*
|
|
4
|
+
* 平台内置资产的清单扫描与安装卸载(skill / dsh_plugin 两类)。HTTP 端点
|
|
5
|
+
* (`GET /api/builtin-assets`、`POST /api/builtin-assets/<id>/(install|uninstall)`,
|
|
6
|
+
* `server.py:1326`/`1085`)按 `AssetError.code` 分流(400/409/500),前端直接把
|
|
7
|
+
* `message` 展示给用户,故**错误串逐字照抄**、四态口径逐行照搬。
|
|
8
|
+
*
|
|
9
|
+
* ── 导出名 ────────────────────────────────────────────────────────────────
|
|
10
|
+
* 主名**照 Python 名**(`ext_dir`/`load_assets`/`asset_status`/`_safe_rel`/…),文件末尾
|
|
11
|
+
* 再给一套**去掉下划线**的 camelCase 别名(`extDir`/`loadAssets`/`_safe_rel → safeRel`/…)。
|
|
12
|
+
* 对拍器缺省把 `fn` 的 Python 名做 snake_case → camelCase 映射,而它的映射对下划线开头的
|
|
13
|
+
* 名字给的是 `_safe_rel → SafeRel`,故用例对这些条显式写 `nodeFn`(与 `export_cases.json`
|
|
14
|
+
* 对 `_norm` 的处理同形)。
|
|
15
|
+
*
|
|
16
|
+
* ── 与 Python 的形态差异(都在量具里显式对齐) ─────────────────────────────
|
|
17
|
+
* ① **元组 → 数组**:`_find_canonical` 的 `(start, end)`、`_write_plugin_patch` /
|
|
18
|
+
* `_remove_plugin_patch` 的 `(action, patch_state)` 在 JS 侧就是数组(不用对象),
|
|
19
|
+
* 值逐位严格比;探针的 `to_jsonable` 也把 tuple 转成 array,故**不需要** `shape` 适配。
|
|
20
|
+
* ② **异常类**:`AssetError` 同名同形(`.message` / `.code`,缺省 400),故「两侧同抛」
|
|
21
|
+
* 的对拍分支能比 `$kind`。`ValueError` / `UnicodeDecodeError` / `AttributeError` 三个
|
|
22
|
+
* 只在「清单写坏 / 文件手改坏」时出现,也按 Python 类名建了同名 JS 类。
|
|
23
|
+
* ③ **同步**:Python 全同步,本文件也全同步(无 Promise)。
|
|
24
|
+
*
|
|
25
|
+
* ── 读文件一律**严格 UTF-8**(与 Python `open(..., encoding="utf-8")` 同口径) ───
|
|
26
|
+
* `fs.readFileSync(p, "utf8")` 会把非法字节**静默**换成 U+FFFD,而 Python 抛
|
|
27
|
+
* `UnicodeDecodeError`。这不是小事:坏 patch 文件在 Python 侧直接报错;JS 侧若静默兜底,
|
|
28
|
+
* 会把它判成 foreign/未注册,接下来的 `_atomic_write_bytes` 就会用一串 U+FFFD
|
|
29
|
+
* **覆盖用户手写的配置**。故本文件所有读口都走 `decodeUtf8Strict`(`TextDecoder`
|
|
30
|
+
* `fatal: true` + `ignoreBOM: true`——后者保证 BOM 与 Python 一样**留在文本里**,
|
|
31
|
+
* 与 `lib.js` 的 `STRICT_UTF8` 同口径),并照文本模式做 **universal newlines**
|
|
32
|
+
* 折行(`\r\n`/`\r` → `\n`,见 `toUniversalNewlines`):CRLF 的 patch 文件在 Python 侧
|
|
33
|
+
* 读出来是 LF、写回也是 LF,JS 若原样保留 `\r` 就会在标记块匹配与写回字节上分叉。
|
|
34
|
+
*
|
|
35
|
+
* ── 依赖 ─────────────────────────────────────────────────────────────────
|
|
36
|
+
* `agents.js:SKILL_DIRS_BY_FAMILY`(skill 落点约定,照要求 import 不重写)与 `lib.js` 的
|
|
37
|
+
* 六个 Python 语义小工具(`pyJoin`/`pySorted`/`pyStrip`/`pyIsAbs`/`realpathOrResolve`/
|
|
38
|
+
* `PY_WS`);其余 Python 语义(`bool`/`str`/`repr`/`dict.get`/`splitlines`/`rstrip`)在本
|
|
39
|
+
* 文件内自足实现——`rag.js` 里有同名的私有实现,但它们没有导出,而为一个 `pyOr` 去 import
|
|
40
|
+
* 整个 RAG 模块不划算。
|
|
41
|
+
*
|
|
42
|
+
* ── 已知的残余差异(原理性/未验;不要当 bug 修) ───────────────────────────
|
|
43
|
+
* - **整数值浮点**:`json.load` 的 `1.0` 与 `1` 在 JS 里都是 `1` ⇒ `repr` 从 `1.0`
|
|
44
|
+
* 变 `1`(`plugin.config` 的标量、`id` 字段的 `!r` 文案会分叉)。与 `pyjson.js`
|
|
45
|
+
* 文件头那条是同一类**不可消除**差异;夹具刻意避开整数值浮点。
|
|
46
|
+
* - **`repr(float)` 的指数档**:Python 在 `decpt > 16` 或 `< -4` 时切指数写法
|
|
47
|
+
* (`repr(1e16) == '1e+16'`),JS 的 `String(1e16)` 给 `'10000000000000000'`。
|
|
48
|
+
* 量级 ≥1e16 或 <1e-4 的标量会分叉;`NaN`/`±Infinity` 已对齐(`nan`/`inf`/`-inf`)。
|
|
49
|
+
* - **`json.load` 的 `NaN`/`Infinity`**:CPython 默认接受,`JSON.parse` 不接受 ⇒
|
|
50
|
+
* 含这两个字面量的清单 Python 能读、JS 判「解析失败」。夹具刻意避开。
|
|
51
|
+
* - **`UnicodeDecodeError` 的文案**:Python 带字节位置(`0xff in position 0`),
|
|
52
|
+
* `TextDecoder` 不带 ⇒ 本文件只对齐**类名**;文案不逐字。这条只在手改坏的文件上出现。
|
|
53
|
+
* - **无 `HOME` 的部署**:`os.path.expanduser` 退回 `pwd` 查库,JS 退回 `os.homedir()`。
|
|
54
|
+
* - **`os.path.realpath`**:用 `lib.js:realpathOrResolve`(真解析失败则 `resolve`),
|
|
55
|
+
* 只在「不存在的路径且其已存在前缀里有符号链接」时分叉(`lib.js` 文件头同条)。
|
|
56
|
+
* - **`os.path.basename` 的尾斜杠**:Node 的 `path.basename('/a/b/')` 给 `'b'`、Python
|
|
57
|
+
* 给 `''`。本模块的 `asset_dir` 都是 `readdir` 拼出来的,到不了这个形态。
|
|
58
|
+
*/
|
|
59
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
60
|
+
import fs from 'node:fs';
|
|
61
|
+
import os from 'node:os';
|
|
62
|
+
import path from 'node:path';
|
|
63
|
+
import { fileURLToPath } from 'node:url';
|
|
64
|
+
|
|
65
|
+
import { SKILL_DIRS_BY_FAMILY } from './agents.js';
|
|
66
|
+
import {
|
|
67
|
+
PY_WS, isDir, isFile, pathExists, pyIsAbs, pyJoin, pySorted, pyStrip, realpathOrResolve,
|
|
68
|
+
} from './lib.js';
|
|
69
|
+
|
|
70
|
+
// --------------------------------------------------------------------------
|
|
71
|
+
// Python 语义小工具(真值 / str / repr / 文本行 / 严格 UTF-8)
|
|
72
|
+
// --------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Python `\s` 的展开形态取自 `lib.js:PY_WS`(`[ … ]` 形态)。
|
|
76
|
+
*
|
|
77
|
+
* `PY_SPACE`/`PY_STRIP_RE` 在 `lib.js` 里是模块私有的,只导出了包好方括号的 `PY_WS`;
|
|
78
|
+
* 本文件还需要**反义类**(`\S`)与**只剥右侧**(`rstrip`)两种形态,故从 `PY_WS` 里取
|
|
79
|
+
* 字符类正文。口径与 `lib.js` 的 `PY_SPACE` 完全同表(Python `str.isspace()`)。
|
|
80
|
+
*/
|
|
81
|
+
const PY_CLASS = PY_WS.slice(1, -1);
|
|
82
|
+
/** Python `\S`(任一非空白字符) */
|
|
83
|
+
const PY_NWS = `[^${PY_CLASS}]`;
|
|
84
|
+
/** Python `str.rstrip()` */
|
|
85
|
+
const PY_RSTRIP_RE = new RegExp(`${PY_WS}+$`);
|
|
86
|
+
/** Python `str.splitlines()` 的分隔集合(`\r\n` 算**一个**边界;比 `split("\n")` 多 8 个字符) */
|
|
87
|
+
const PY_SPLITLINES_RE = /\r\n|[\n\v\f\r\x1c\x1d\x1e\x85\u2028\u2029]/;
|
|
88
|
+
/** `- id: X`(顶层)与 `disabled: true`(缩进)两条 —— Python 的 `\s`/`\S` 均按上表展开 */
|
|
89
|
+
const PATCH_ID_RE = new RegExp(`^-${PY_WS}*id:${PY_WS}*(${PY_NWS}+)${PY_WS}*$`);
|
|
90
|
+
const PATCH_DISABLED_RE = new RegExp(`^${PY_WS}+disabled:${PY_WS}*true${PY_WS}*$`);
|
|
91
|
+
const LEADING_WS_RE = new RegExp(`^${PY_WS}*`);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Python `str.splitlines()`。
|
|
95
|
+
*
|
|
96
|
+
* ⚠ 不能直接用 `String.split`:JS 会给**尾部分隔符**补一个空元素
|
|
97
|
+
* (`"a\n".split(/\n/) === ["a", ""]`),而 Python 的 `splitlines()` 不会
|
|
98
|
+
* (`["a"]`)。这条差一个空行,会让 `_patch_set_enabled` 写出的 patch 多一个换行。
|
|
99
|
+
* 另:`\r\n` 在 Python 里算**一个**边界,故正则里排在最前。
|
|
100
|
+
*/
|
|
101
|
+
function splitLines(text) {
|
|
102
|
+
const s = String(text ?? '');
|
|
103
|
+
if (s === '') return [];
|
|
104
|
+
const out = s.split(PY_SPLITLINES_RE);
|
|
105
|
+
if (out[out.length - 1] === '') out.pop();
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Python `str.rstrip()` */
|
|
110
|
+
function pyRstrip(value) {
|
|
111
|
+
return String(value ?? '').replace(PY_RSTRIP_RE, '');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Python `bool(x)`(空串/空容器/0/None 假;JS 的 `[]`/`{}` 在 `||` 里是真,必须区分) */
|
|
115
|
+
function pyTruthy(value) {
|
|
116
|
+
if (value === null || value === undefined) return false;
|
|
117
|
+
switch (typeof value) {
|
|
118
|
+
case 'boolean': return value;
|
|
119
|
+
case 'number': return value !== 0 && !Number.isNaN(value);
|
|
120
|
+
case 'string': return value !== '';
|
|
121
|
+
case 'object':
|
|
122
|
+
return Array.isArray(value) ? value.length > 0 : Object.keys(value).length > 0;
|
|
123
|
+
default: return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Python `x or d` */
|
|
128
|
+
function pyOr(value, fallback) {
|
|
129
|
+
return pyTruthy(value) ? value : fallback;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Python `isinstance(x, dict)`(JSON 对象;数组/None/标量都不是) */
|
|
133
|
+
function isDict(value) {
|
|
134
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Python 类型名(只用于错误文案;int/float 在 JS 里已不可分,按是否整数猜) */
|
|
138
|
+
function pyTypeName(value) {
|
|
139
|
+
if (value === null || value === undefined) return 'NoneType';
|
|
140
|
+
if (Array.isArray(value)) return 'list';
|
|
141
|
+
switch (typeof value) {
|
|
142
|
+
case 'boolean': return 'bool';
|
|
143
|
+
case 'number': return Number.isInteger(value) ? 'int' : 'float';
|
|
144
|
+
case 'string': return 'str';
|
|
145
|
+
case 'object': return 'dict';
|
|
146
|
+
default: return typeof value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Python `repr(str)` 的可用子集(单引号、转义反斜杠/引号/控制字符) */
|
|
151
|
+
function pyReprString(text) {
|
|
152
|
+
let out = "'";
|
|
153
|
+
for (const ch of String(text)) {
|
|
154
|
+
if (ch === '\\') out += '\\\\';
|
|
155
|
+
else if (ch === "'") out += "\\'";
|
|
156
|
+
else if (ch === '\n') out += '\\n';
|
|
157
|
+
else if (ch === '\r') out += '\\r';
|
|
158
|
+
else if (ch === '\t') out += '\\t';
|
|
159
|
+
else if (ch.codePointAt(0) < 0x20 || ch.codePointAt(0) === 0x7f) {
|
|
160
|
+
out += `\\x${ch.codePointAt(0).toString(16).padStart(2, '0')}`;
|
|
161
|
+
} else out += ch;
|
|
162
|
+
}
|
|
163
|
+
return `${out}'`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Python `str(x)`(容器走 repr;整数值浮点这条不可消除,见文件头) */
|
|
167
|
+
function pyStr(value) {
|
|
168
|
+
if (value === null || value === undefined) return 'None';
|
|
169
|
+
if (typeof value === 'string') return value;
|
|
170
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
171
|
+
if (typeof value === 'number') return String(value);
|
|
172
|
+
if (Array.isArray(value)) return `[${value.map(pyRepr).join(', ')}]`;
|
|
173
|
+
if (typeof value === 'object') {
|
|
174
|
+
const items = Object.keys(value).map((k) => `${pyRepr(k)}: ${pyRepr(value[k])}`);
|
|
175
|
+
return `{${items.join(', ')}}`;
|
|
176
|
+
}
|
|
177
|
+
throw new TypeError(`str() 不支持 ${typeof value}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Python `repr(x)` 的可用子集(str 走 repr 规则,其余用 `pyStr`) */
|
|
181
|
+
function pyRepr(value) {
|
|
182
|
+
if (typeof value === 'string') return pyReprString(value);
|
|
183
|
+
return pyStr(value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `dict.get`:非 dict 时照 Python 抛 `AttributeError`(`'list' object has no attribute 'get'`) */
|
|
187
|
+
function pyGet(obj, key) {
|
|
188
|
+
if (!isDict(obj)) {
|
|
189
|
+
throw new AttributeError(`'${pyTypeName(obj)}' object has no attribute 'get'`);
|
|
190
|
+
}
|
|
191
|
+
return Object.hasOwn(obj, key) ? obj[key] : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 严格 UTF-8 解码(`fatal` 抛错、`ignoreBOM` 让 BOM 与 Python 一样留在文本里) */
|
|
195
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
196
|
+
|
|
197
|
+
/** 字节 → 文本;非法 UTF-8 抛 `UnicodeDecodeError`(Python `open(..., encoding="utf-8")` 同) */
|
|
198
|
+
function decodeUtf8Strict(buf) {
|
|
199
|
+
try {
|
|
200
|
+
return STRICT_UTF8.decode(buf);
|
|
201
|
+
} catch {
|
|
202
|
+
throw new UnicodeDecodeError(
|
|
203
|
+
"'utf-8' codec can't decode bytes: invalid utf-8 sequence(字节位置见 Python 侧)");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 读整个文件 → 文本,**不吞任何错**(OSError 与 UnicodeDecodeError 都原样抛) */
|
|
208
|
+
function readTextStrict(filePath) {
|
|
209
|
+
return toUniversalNewlines(decodeUtf8Strict(fs.readFileSync(filePath)));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Python 文本模式的 **universal newlines**:`\r\n`/`\r` 一律折成 `\n`。
|
|
214
|
+
*
|
|
215
|
+
* `open(path, encoding="utf-8")` 的 `newline=None` 就是这么做的,而 `builtin_assets.py`
|
|
216
|
+
* 的每个读口都走它(`json.load`、patch 文本、profile manifest)⇒ **CRLF 的 patch 文件**
|
|
217
|
+
* 在 Python 侧读出来是 LF,再写回也是 LF(文件会「缩水」);JS 若原样保留 `\r`,
|
|
218
|
+
* 标记块匹配与写回字节都会分叉。故本文件的文件读口一律先折行再交给上层。
|
|
219
|
+
*/
|
|
220
|
+
function toUniversalNewlines(text) {
|
|
221
|
+
return String(text).replace(/\r\n?/g, '\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 读整个文件 → 文本,**只吞 OSError**(Python `open` 的 `except OSError` 分档) */
|
|
225
|
+
function readTextOrEmpty(filePath) {
|
|
226
|
+
let buf;
|
|
227
|
+
try {
|
|
228
|
+
buf = fs.readFileSync(filePath);
|
|
229
|
+
} catch {
|
|
230
|
+
return '';
|
|
231
|
+
}
|
|
232
|
+
return toUniversalNewlines(decodeUtf8Strict(buf));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `os.path.expanduser` 的 POSIX 子集(只处理开头的 `~`;口径见文件头) */
|
|
236
|
+
function expandUser(p) {
|
|
237
|
+
const s = String(p ?? '');
|
|
238
|
+
if (!s.startsWith('~')) return s;
|
|
239
|
+
let i = s.indexOf('/', 1);
|
|
240
|
+
if (i === -1) i = s.length;
|
|
241
|
+
if (i !== 1) return s; // `~user` 形态:SKILL_DIRS_BY_FAMILY 里没有,不猜
|
|
242
|
+
const home = Object.hasOwn(process.env, 'HOME') ? process.env.HOME : os.homedir();
|
|
243
|
+
// CPython posixpath.expanduser:`userhome + path[1:]`(**不做 join**,HOME 的尾斜杠照留)
|
|
244
|
+
return `${home}${s.slice(1)}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `tempfile.mkstemp` 的 8 字符表:`[a-zA-Z0-9_]` */
|
|
248
|
+
const MKSTEMP_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_';
|
|
249
|
+
|
|
250
|
+
/** 8 字符随机名(只用于临时文件,不参与比对) */
|
|
251
|
+
function mkstempSuffix() {
|
|
252
|
+
const bytes = randomBytes(8);
|
|
253
|
+
let out = '';
|
|
254
|
+
for (let i = 0; i < 8; i++) out += MKSTEMP_ALPHABET[bytes[i] % MKSTEMP_ALPHABET.length];
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --------------------------------------------------------------------------
|
|
259
|
+
// 常量(`builtin_assets.py:53-68`)
|
|
260
|
+
// --------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/** 清单根:平台仓库 `extensions/`(`TS_EXT_DIR` 覆盖,测试与自定义部署) */
|
|
263
|
+
export const DEFAULT_EXT_DIR = path.resolve(
|
|
264
|
+
path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'extensions');
|
|
265
|
+
|
|
266
|
+
/** 清单文件名 */
|
|
267
|
+
export const MANIFEST_NAME = 'asset.json';
|
|
268
|
+
|
|
269
|
+
/** manifest 允许的资产类型(Python 是 tuple,JS 用冻结数组) */
|
|
270
|
+
export const ASSET_TYPES = Object.freeze(['skill', 'dsh_plugin']);
|
|
271
|
+
/** manifest 允许的安装目标 */
|
|
272
|
+
export const ASSET_TARGETS = Object.freeze(['user', 'project']);
|
|
273
|
+
/** 资产适用族的展示名(族 → 页面显示名) */
|
|
274
|
+
export const FAMILY_LABELS = { dsh_plugin: 'dsh 插件' };
|
|
275
|
+
/** 类型展示名 */
|
|
276
|
+
export const TYPE_LABELS = { skill: 'skill', dsh_plugin: 'dsh 插件' };
|
|
277
|
+
/** dsh 插件资产的缺省 profile */
|
|
278
|
+
export const DSH_PROFILE_DEFAULT = 'web';
|
|
279
|
+
/** 平台标记块的起止注释(`{aid}` 占位同上 Python 的 `str.format`) */
|
|
280
|
+
export const PATCH_MARK_BEGIN = '# >>> touchstone-asset:{aid}';
|
|
281
|
+
export const PATCH_MARK_END = '# <<< touchstone-asset:{aid}';
|
|
282
|
+
/** 包名(可带 scope)与 profile 名的字符白名单 */
|
|
283
|
+
export const _PKG_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
284
|
+
export const _PROFILE_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
285
|
+
|
|
286
|
+
/** `PATCH_MARK_*.format(aid=…)` 的等价(函数式替换避开 `$` 的替换语义) */
|
|
287
|
+
function patchMarkBegin(aid) {
|
|
288
|
+
return PATCH_MARK_BEGIN.replace('{aid}', () => aid);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function patchMarkEnd(aid) {
|
|
292
|
+
return PATCH_MARK_END.replace('{aid}', () => aid);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** `list(ASSET_TARGETS)` 的 Python repr(错误文案里逐字出现) */
|
|
296
|
+
const ASSET_TARGETS_REPR = `[${ASSET_TARGETS.map((t) => `'${t}'`).join(', ')}]`;
|
|
297
|
+
|
|
298
|
+
/** `str.strip('"\'')`(`_patch_disabled` 剥 id 两侧引号用) */
|
|
299
|
+
function stripQuotes(text) {
|
|
300
|
+
return String(text).replace(/^["']+/, '').replace(/["']+$/, '');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --------------------------------------------------------------------------
|
|
304
|
+
// 错误类(`builtin_assets.py:71-77` + `_safe_rel` 的 ValueError)
|
|
305
|
+
// --------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 资产操作错误(api 层转 400/500;message 为面向用户的中文原因)。
|
|
309
|
+
*
|
|
310
|
+
* 照 `backend/lib/rag.js` 已建立的「Python 异常类」风格:`name` 与 Python 的
|
|
311
|
+
* `type(exc).__name__` 逐字相同,故对拍里「两侧同抛」的分支能比 `$kind`。
|
|
312
|
+
*/
|
|
313
|
+
export class AssetError extends Error {
|
|
314
|
+
constructor(message, code = 400) {
|
|
315
|
+
super(message);
|
|
316
|
+
this.name = 'AssetError';
|
|
317
|
+
this.message = message;
|
|
318
|
+
this.code = code;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Python `ValueError`(`_safe_rel` 抛、`_load_manifest` 只 catch 它) */
|
|
323
|
+
export class ValueError extends Error {
|
|
324
|
+
constructor(message) {
|
|
325
|
+
super(message);
|
|
326
|
+
this.name = 'ValueError';
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Python `UnicodeDecodeError`(`ValueError` 子类;这里只对齐类名,文案见文件头) */
|
|
331
|
+
export class UnicodeDecodeError extends ValueError {
|
|
332
|
+
constructor(message) {
|
|
333
|
+
super(message);
|
|
334
|
+
this.name = 'UnicodeDecodeError';
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Python `AttributeError`(`dict.get` 用在非 dict 上;`_plugin_restart_required` 会撞到) */
|
|
339
|
+
export class AttributeError extends Error {
|
|
340
|
+
constructor(message) {
|
|
341
|
+
super(message);
|
|
342
|
+
this.name = 'AttributeError';
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --------------------------------------------------------------------------
|
|
347
|
+
// 路径与清单
|
|
348
|
+
// --------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
/** 资产清单根目录(`TS_EXT_DIR` 覆盖,默认仓库 `extensions/`)。 */
|
|
351
|
+
export function ext_dir() {
|
|
352
|
+
return path.resolve(process.env.TS_EXT_DIR || DEFAULT_EXT_DIR);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** 把 HOME 前缀缩写为 `~`(回显用,避免泄漏服务进程家目录结构)。 */
|
|
356
|
+
export function display_path(p) {
|
|
357
|
+
const home = expandUser('~');
|
|
358
|
+
const s = String(p ?? '');
|
|
359
|
+
if (home && s.startsWith(home + path.sep)) return `~${s.slice(home.length)}`;
|
|
360
|
+
return s;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 文件内容 sha256;读不到返回空串(调用方按「不存在/不可读」处理)。 */
|
|
364
|
+
export function _sha256(p) {
|
|
365
|
+
const h = createHash('sha256');
|
|
366
|
+
try {
|
|
367
|
+
h.update(fs.readFileSync(p));
|
|
368
|
+
} catch {
|
|
369
|
+
return '';
|
|
370
|
+
}
|
|
371
|
+
return h.digest('hex');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 同目录临时文件 + `rename` 原子写(保留既有文件权限位)。 */
|
|
375
|
+
export function _atomic_write_bytes(filePath, data) {
|
|
376
|
+
const p = String(filePath);
|
|
377
|
+
const d = path.dirname(p) || '.';
|
|
378
|
+
fs.mkdirSync(d, { recursive: true });
|
|
379
|
+
let mode = null;
|
|
380
|
+
try {
|
|
381
|
+
mode = fs.statSync(p).mode & 0o777;
|
|
382
|
+
} catch {
|
|
383
|
+
mode = null;
|
|
384
|
+
}
|
|
385
|
+
let fd = null;
|
|
386
|
+
let tmp = '';
|
|
387
|
+
try {
|
|
388
|
+
// mkstemp:同目录、`wx`(已存在即换名重试)、0600 起步(未给 mode 时新文件就留 0600)
|
|
389
|
+
for (let attempt = 0; ; attempt++) {
|
|
390
|
+
tmp = path.join(d, `.tf-asset-${mkstempSuffix()}`);
|
|
391
|
+
try {
|
|
392
|
+
fd = fs.openSync(tmp, 'wx', 0o600);
|
|
393
|
+
break;
|
|
394
|
+
} catch (e) {
|
|
395
|
+
if (e && e.code === 'EEXIST' && attempt < 100) continue;
|
|
396
|
+
throw e;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
400
|
+
let off = 0;
|
|
401
|
+
while (off < buf.length) off += fs.writeSync(fd, buf, off, buf.length - off);
|
|
402
|
+
fs.closeSync(fd);
|
|
403
|
+
fd = null;
|
|
404
|
+
if (mode !== null) fs.chmodSync(tmp, mode);
|
|
405
|
+
fs.renameSync(tmp, p);
|
|
406
|
+
} catch (e) {
|
|
407
|
+
if (fd !== null) {
|
|
408
|
+
try { fs.closeSync(fd); } catch { /* 已关 */ }
|
|
409
|
+
}
|
|
410
|
+
try { fs.unlinkSync(tmp); } catch { /* 没建成 / 已被 rename 掉 */ }
|
|
411
|
+
throw e;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 清单非法统一异常(白名单校验失败 = 开发者错误,不放行)。 */
|
|
416
|
+
export function _manifest_error(asset_dir, why) {
|
|
417
|
+
return new AssetError(`资产清单非法(${path.basename(asset_dir)}):${why}`, 500);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 校验清单内相对路径:非空、非绝对、无 `..` 段(防越界写)。 */
|
|
421
|
+
export function _safe_rel(rel, what) {
|
|
422
|
+
if (typeof rel !== 'string' || !pyStrip(rel)) {
|
|
423
|
+
throw new ValueError(`${what} 必须是非空字符串`);
|
|
424
|
+
}
|
|
425
|
+
const r = pyStrip(rel);
|
|
426
|
+
if (pyIsAbs(r) || /^[A-Za-z]:[\\/]/.test(r)) {
|
|
427
|
+
throw new ValueError(`${what} 必须是相对路径:${rel}`);
|
|
428
|
+
}
|
|
429
|
+
const parts = r.split(/[\\/]+/);
|
|
430
|
+
if (parts.some((x) => x === '..')) {
|
|
431
|
+
throw new ValueError(`${what} 不允许 .. 越界:${rel}`);
|
|
432
|
+
}
|
|
433
|
+
return r;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** YAML 标量渲染(只放行 str/bool/int/float;复杂结构直接报清单错误)。 */
|
|
437
|
+
export function _scalar_str(value) {
|
|
438
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
439
|
+
if (typeof value === 'number') {
|
|
440
|
+
if (Number.isNaN(value)) return 'nan';
|
|
441
|
+
if (value === Infinity) return 'inf';
|
|
442
|
+
if (value === -Infinity) return '-inf';
|
|
443
|
+
return pyRepr(value); // 指数档的残余差异见文件头
|
|
444
|
+
}
|
|
445
|
+
if (typeof value === 'string') {
|
|
446
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
447
|
+
return `"${escaped}"`;
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 校验并规范化 dsh_plugin 资产的 `plugin` 段。 */
|
|
453
|
+
export function _plugin_spec(asset_dir, raw) {
|
|
454
|
+
if (!isDict(raw)) {
|
|
455
|
+
throw _manifest_error(asset_dir, 'type=dsh_plugin 必须声明 plugin 对象');
|
|
456
|
+
}
|
|
457
|
+
const pkg = pyStrip(pyStr(pyOr(raw.package, '')));
|
|
458
|
+
if (!_PKG_RE.test(pkg)) {
|
|
459
|
+
// `!r` 取的是**原值**(不是 `or` 之后的),故用 `??` 而不是 `pyOr`
|
|
460
|
+
throw _manifest_error(asset_dir, `plugin.package 非法:${pyRepr(raw.package ?? null)}`);
|
|
461
|
+
}
|
|
462
|
+
const pkgParts = pkg.split('/');
|
|
463
|
+
const entry = pyStrip(pyStr(pyOr(raw.entry_id, pkgParts[pkgParts.length - 1])));
|
|
464
|
+
if (!_PROFILE_RE.test(entry)) {
|
|
465
|
+
throw _manifest_error(asset_dir, `plugin.entry_id 非法:${pyRepr(entry)}`);
|
|
466
|
+
}
|
|
467
|
+
const profile = pyStrip(pyStr(pyOr(raw.profile, DSH_PROFILE_DEFAULT)));
|
|
468
|
+
if (!_PROFILE_RE.test(profile)) {
|
|
469
|
+
throw _manifest_error(asset_dir, `plugin.profile 非法:${pyRepr(profile)}`);
|
|
470
|
+
}
|
|
471
|
+
const cfg = raw.config ?? null;
|
|
472
|
+
const norm_cfg = {};
|
|
473
|
+
if (cfg !== null) {
|
|
474
|
+
if (!isDict(cfg)) throw _manifest_error(asset_dir, 'plugin.config 必须是对象');
|
|
475
|
+
for (const [key, val] of Object.entries(cfg)) {
|
|
476
|
+
// 注意:Python `re` 的 `$` 允许「串尾单个 \n」前匹配,JS 不带 m 时不认 ⇒ 补 `(?:\n)?`
|
|
477
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.-]*(?:\n)?$/.test(key)) {
|
|
478
|
+
throw _manifest_error(asset_dir, `plugin.config 键非法:${pyRepr(key)}`);
|
|
479
|
+
}
|
|
480
|
+
if (_scalar_str(val) === null) {
|
|
481
|
+
throw _manifest_error(
|
|
482
|
+
asset_dir, `plugin.config[${key}] 只支持字符串/布尔/数字(复杂结构请手工写 patch)`);
|
|
483
|
+
}
|
|
484
|
+
norm_cfg[key] = val;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const req = pyOr(raw.requires ?? null, []);
|
|
488
|
+
if (!Array.isArray(req) || req.some((s) => !_PKG_RE.test(pyStrip(pyStr(pyOr(s, '')))))) {
|
|
489
|
+
throw _manifest_error(asset_dir, 'plugin.requires 必须是包名数组(如 @deepseek-ai/dsh-llm)');
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
package: pkg,
|
|
493
|
+
entry_id: entry,
|
|
494
|
+
profile,
|
|
495
|
+
config: norm_cfg,
|
|
496
|
+
requires: req.map((s) => pyStrip(pyStr(s))),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** 读取并校验单个 `asset.json`,返回规范化资产 dict;非法抛 `AssetError`。 */
|
|
501
|
+
export function _load_manifest(asset_dir) {
|
|
502
|
+
const p = pyJoin(asset_dir, MANIFEST_NAME);
|
|
503
|
+
let raw;
|
|
504
|
+
try {
|
|
505
|
+
// Python 的 `except (OSError, ValueError)`:读失败、解码失败(UnicodeDecodeError 是
|
|
506
|
+
// ValueError 子类)、JSON 解析失败三种都归这条(三者的**文案**两侧本就不同,见文件头)
|
|
507
|
+
raw = JSON.parse(readTextStrict(p));
|
|
508
|
+
} catch (e) {
|
|
509
|
+
throw _manifest_error(
|
|
510
|
+
asset_dir, `读取/解析失败(${e && e.message !== undefined ? e.message : e})`);
|
|
511
|
+
}
|
|
512
|
+
if (!isDict(raw)) {
|
|
513
|
+
throw _manifest_error(asset_dir, '顶层必须是 JSON 对象');
|
|
514
|
+
}
|
|
515
|
+
const aid = pyStrip(pyStr(pyOr(raw.id, '')));
|
|
516
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(aid)) {
|
|
517
|
+
throw _manifest_error(asset_dir, `id 非法:${pyRepr(raw.id ?? null)}`);
|
|
518
|
+
}
|
|
519
|
+
const atype = pyStrip(pyStr(pyOr(raw.type, '')));
|
|
520
|
+
if (!ASSET_TYPES.includes(atype)) {
|
|
521
|
+
throw _manifest_error(asset_dir, `type 必须是 ${ASSET_TYPES.join('/')}:${pyRepr(atype)}`);
|
|
522
|
+
}
|
|
523
|
+
const family = pyStrip(pyStr(pyOr(raw.family, '')));
|
|
524
|
+
// `family not in SKILL_DIRS_BY_FAMILY` 必须用 hasOwn:`'constructor' in {}` 为真,
|
|
525
|
+
// 而 Python 的 dict 没有原型链上的这种假成员
|
|
526
|
+
if (!Object.hasOwn(SKILL_DIRS_BY_FAMILY, family)) {
|
|
527
|
+
throw _manifest_error(asset_dir, `family 未知:${pyRepr(family)}`);
|
|
528
|
+
}
|
|
529
|
+
let targets = raw.targets ?? null;
|
|
530
|
+
if (!Array.isArray(targets) || !targets.length
|
|
531
|
+
|| targets.some((t) => !ASSET_TARGETS.includes(t))) {
|
|
532
|
+
throw _manifest_error(asset_dir, `targets 必须是 ${ASSET_TARGETS_REPR} 的非空子集`);
|
|
533
|
+
}
|
|
534
|
+
targets = [...new Set(targets)];
|
|
535
|
+
let plugin = null;
|
|
536
|
+
if (atype === 'dsh_plugin') {
|
|
537
|
+
plugin = _plugin_spec(asset_dir, raw.plugin ?? null);
|
|
538
|
+
// dsh 插件是 **profile 级**资产:装进 dsh home 下的 node_modules + profile patch,
|
|
539
|
+
// 与「哪个项目」无关,故只支持用户级目标(声明 project 直接报清单错误,不静默忽略)
|
|
540
|
+
if (!(targets.length === 1 && targets[0] === 'user')) {
|
|
541
|
+
throw _manifest_error(
|
|
542
|
+
asset_dir, 'type=dsh_plugin 只支持用户级(targets 只能是 ["user"])');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const files = raw.files ?? null;
|
|
546
|
+
if (!Array.isArray(files) || !files.length) {
|
|
547
|
+
throw _manifest_error(asset_dir, 'files 必须是非空数组');
|
|
548
|
+
}
|
|
549
|
+
const norm_files = [];
|
|
550
|
+
for (const item of files) {
|
|
551
|
+
if (!isDict(item)) {
|
|
552
|
+
throw _manifest_error(asset_dir, 'files 元素必须是 {from, to} 对象');
|
|
553
|
+
}
|
|
554
|
+
let src;
|
|
555
|
+
let dst;
|
|
556
|
+
try {
|
|
557
|
+
src = _safe_rel(item.from, 'files[].from');
|
|
558
|
+
dst = _safe_rel(item.to, 'files[].to');
|
|
559
|
+
} catch (e) {
|
|
560
|
+
if (!(e instanceof ValueError)) throw e;
|
|
561
|
+
throw _manifest_error(asset_dir, String(e.message));
|
|
562
|
+
}
|
|
563
|
+
if (!isFile(pyJoin(asset_dir, src))) {
|
|
564
|
+
throw _manifest_error(asset_dir, `清单文件不存在:${src}`);
|
|
565
|
+
}
|
|
566
|
+
norm_files.push({ from: src, to: dst });
|
|
567
|
+
}
|
|
568
|
+
// 注册机制(hook 时代的 config.toml 写入)已随族退役:声明即报错,不静默忽略
|
|
569
|
+
// (dsh 插件的注册走 plugin 段的 patch 行,不用这个旧字段)
|
|
570
|
+
if (raw.register !== undefined && raw.register !== null) {
|
|
571
|
+
throw _manifest_error(asset_dir, '不支持 register 声明(skill 资产无需注册)');
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
id: aid,
|
|
575
|
+
name: pyStr(pyOr(raw.name, aid)),
|
|
576
|
+
type: atype,
|
|
577
|
+
family,
|
|
578
|
+
description: pyStr(pyOr(raw.description, '')),
|
|
579
|
+
targets,
|
|
580
|
+
files: norm_files,
|
|
581
|
+
plugin,
|
|
582
|
+
dir: asset_dir,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** 扫描清单根下所有 `extensions/<目录>/asset.json`,返回资产列表(非法的直接报错)。 */
|
|
587
|
+
export function load_assets() {
|
|
588
|
+
const root = ext_dir();
|
|
589
|
+
if (!isDir(root)) return [];
|
|
590
|
+
const assets = [];
|
|
591
|
+
const ids = new Set();
|
|
592
|
+
for (const name of pySorted(fs.readdirSync(root))) {
|
|
593
|
+
const d = pyJoin(root, name);
|
|
594
|
+
if (!isFile(pyJoin(d, MANIFEST_NAME))) continue;
|
|
595
|
+
const asset = _load_manifest(d);
|
|
596
|
+
if (ids.has(asset.id)) {
|
|
597
|
+
throw _manifest_error(d, `id 重复:${asset.id}`);
|
|
598
|
+
}
|
|
599
|
+
ids.add(asset.id);
|
|
600
|
+
assets.push(asset);
|
|
601
|
+
}
|
|
602
|
+
return assets;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** 按 id 取资产;不存在返回 `None`。 */
|
|
606
|
+
export function get_asset(asset_id) {
|
|
607
|
+
for (const a of load_assets()) {
|
|
608
|
+
if (a.id === asset_id) return a;
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// --------------------------------------------------------------------------
|
|
614
|
+
// 落点推导
|
|
615
|
+
// --------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
/** skill 落点根:该族首个用户级目录 / 项目级相对目录(约定见 agents 模块)。 */
|
|
618
|
+
export function _skill_root(family, target, project_dir) {
|
|
619
|
+
const [user_dirs, proj_dirs] = SKILL_DIRS_BY_FAMILY[family] || [[], []];
|
|
620
|
+
if (target === 'user') {
|
|
621
|
+
return user_dirs.length ? expandUser(user_dirs[0]) : null;
|
|
622
|
+
}
|
|
623
|
+
if (!pyTruthy(project_dir)) return null;
|
|
624
|
+
return proj_dirs.length ? pyJoin(project_dir, proj_dirs[0]) : null;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** dsh 的 profiles 根:`TS_DSH_PROFILES` 覆盖 → `DSH_HOME` → `~/.dsh`(后接 profiles)。 */
|
|
628
|
+
export function dsh_profiles_dir() {
|
|
629
|
+
const env = process.env.TS_DSH_PROFILES;
|
|
630
|
+
if (pyTruthy(env)) return path.resolve(env);
|
|
631
|
+
const home = process.env.DSH_HOME || pyJoin(expandUser('~'), '.dsh');
|
|
632
|
+
return pyJoin(path.resolve(home), 'profiles');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** dsh 插件包实体落点:`<profiles>/node_modules/<package>`(scope 按 npm 目录布局展开)。 */
|
|
636
|
+
export function plugin_package_root(asset) {
|
|
637
|
+
return pyJoin(dsh_profiles_dir(), 'node_modules', ...asset.plugin.package.split('/'));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** 注册行落点:`<profiles>/<profile>/cordis.patch.yml`(**不是** `cordis.yml`)。 */
|
|
641
|
+
export function plugin_patch_path(asset) {
|
|
642
|
+
return pyJoin(dsh_profiles_dir(), asset.plugin.profile, 'cordis.patch.yml');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** 平台规范块文本列表(插入顺序即写入顺序):注入块 + 可选 config 覆盖块。 */
|
|
646
|
+
export function plugin_patch_blocks(asset) {
|
|
647
|
+
const p = asset.plugin;
|
|
648
|
+
const aid = asset.id;
|
|
649
|
+
const blocks = [[
|
|
650
|
+
patchMarkBegin(aid),
|
|
651
|
+
'- insert:',
|
|
652
|
+
` - id: ${p.entry_id}`,
|
|
653
|
+
` name: "${p.package}"`,
|
|
654
|
+
patchMarkEnd(aid),
|
|
655
|
+
].join('\n')];
|
|
656
|
+
if (pyTruthy(p.config)) {
|
|
657
|
+
const lines = [
|
|
658
|
+
patchMarkBegin(`${aid}.config`),
|
|
659
|
+
`- id: ${p.entry_id}`,
|
|
660
|
+
' config:',
|
|
661
|
+
];
|
|
662
|
+
for (const [k, v] of Object.entries(p.config)) lines.push(` ${k}: ${_scalar_str(v)}`);
|
|
663
|
+
lines.push(' disabled: false', patchMarkEnd(`${aid}.config`));
|
|
664
|
+
blocks.push(lines.join('\n'));
|
|
665
|
+
}
|
|
666
|
+
return blocks;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** 规范注入块的 3 行(逐行比对用;行尾空白已剥)。 */
|
|
670
|
+
export function _canonical_lines(asset) {
|
|
671
|
+
const p = asset.plugin;
|
|
672
|
+
return ['- insert:', ` - id: ${p.entry_id}`, ` name: "${p.package}"`];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** 找「规范注入块」的起止行号 `[start, end)`,找不到返回 `None`。 */
|
|
676
|
+
export function _find_canonical(text, asset) {
|
|
677
|
+
const lines = splitLines(text);
|
|
678
|
+
const want = _canonical_lines(asset);
|
|
679
|
+
for (let i = 0; i < lines.length - 2; i++) {
|
|
680
|
+
const got = [pyRstrip(lines[i]), pyRstrip(lines[i + 1]), pyRstrip(lines[i + 2])];
|
|
681
|
+
if (got.some((v, n) => v !== want[n])) continue;
|
|
682
|
+
// 必须逐行精确匹配且**下一行不是同一 insert 项内的更深缩进字段**——否则用户自定义写法
|
|
683
|
+
// (如在本块后面又加了 `config:`)会被子串匹配误判成规范块,卸载时截断用户的配置。
|
|
684
|
+
const nxt = i + 3 < lines.length ? lines[i + 3] : '';
|
|
685
|
+
if (nxt.startsWith(' ')) continue;
|
|
686
|
+
return [i, i + 3];
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** 该条目是否在 patch 里被显式停用(dsh 面板停用会追加顶层 `- id: X` + `disabled: true`)。 */
|
|
692
|
+
export function _patch_disabled(text, entry_id) {
|
|
693
|
+
let cur = null;
|
|
694
|
+
for (const line of splitLines(text)) {
|
|
695
|
+
const m = PATCH_ID_RE.exec(line);
|
|
696
|
+
if (m) {
|
|
697
|
+
// 只认**顶层**(列 0)的 `- id:` 行:注入块内部那行是缩进的,不会误命中
|
|
698
|
+
cur = stripQuotes(m[1]);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (line.startsWith('- ')) {
|
|
702
|
+
cur = null;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
if (cur === entry_id && PATCH_DISABLED_RE.test(line)) return true;
|
|
706
|
+
}
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** 注册行状态(读 patch 文件,纯推导):`marked` / `canonical` / `foreign` / `""`。 */
|
|
711
|
+
export function plugin_patch_state(asset) {
|
|
712
|
+
const p = plugin_patch_path(asset);
|
|
713
|
+
// OSError(不存在/是目录/权限)→ 与 Python 同:空文本;解码失败不吞(UnicodeDecodeError
|
|
714
|
+
// 在 Python 里不是 OSError,会一路上抛)
|
|
715
|
+
const text = readTextOrEmpty(p);
|
|
716
|
+
const aid = asset.id;
|
|
717
|
+
let mode;
|
|
718
|
+
if (text.includes(patchMarkBegin(aid))) mode = 'marked';
|
|
719
|
+
else if (_find_canonical(text, asset) !== null) mode = 'canonical';
|
|
720
|
+
else if (text.includes(asset.plugin.package)) mode = 'foreign';
|
|
721
|
+
else mode = '';
|
|
722
|
+
return {
|
|
723
|
+
path: p,
|
|
724
|
+
exists: pyTruthy(text),
|
|
725
|
+
mode,
|
|
726
|
+
registered: mode !== '',
|
|
727
|
+
disabled: pyTruthy(text) && _patch_disabled(text, asset.plugin.entry_id),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** 遇到用户自定义注册写法时的统一拒绝原因(安装/卸载共用)。 */
|
|
732
|
+
export function _patch_foreign_error(asset, patch) {
|
|
733
|
+
return new AssetError(
|
|
734
|
+
`「${asset.name}」在 ${display_path(patch.path)} 里已有自定义写法的注册行,`
|
|
735
|
+
+ '平台不覆盖、也不删除用户手写的 patch 配置;请先手工移除该条目再试', 409);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** 推导该资产在指定目标下的落点根;不支持返回 `None`。 */
|
|
739
|
+
export function install_root(asset, target, project_dir = '') {
|
|
740
|
+
if (!asset.targets.includes(target)) return null;
|
|
741
|
+
if (asset.type === 'dsh_plugin') {
|
|
742
|
+
// dsh 插件是 profile 级资产:只支持用户级(落 dsh home 下的 node_modules)
|
|
743
|
+
return target === 'user' ? plugin_package_root(asset) : null;
|
|
744
|
+
}
|
|
745
|
+
return _skill_root(asset.family, target, project_dir);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** 资产在目标下不可安装的用户可读原因。 */
|
|
749
|
+
export function _unsupported_reason(asset, target) {
|
|
750
|
+
if (!asset.targets.includes(target)) {
|
|
751
|
+
return `该资产只支持${asset.targets
|
|
752
|
+
.map((t) => (t === 'user' ? '用户级' : '项目级')).join('、')}安装`;
|
|
753
|
+
}
|
|
754
|
+
if (asset.type === 'dsh_plugin') {
|
|
755
|
+
return 'dsh 插件资产只支持用户级(装进 dsh profile,与项目无关)';
|
|
756
|
+
}
|
|
757
|
+
return `该资产(${asset.family})暂不支持此目标的安装`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** 清单文件在该落点根下的安装信息列表。 */
|
|
761
|
+
export function _installed_files(asset, root) {
|
|
762
|
+
const out = [];
|
|
763
|
+
for (const f of asset.files) {
|
|
764
|
+
const dst = pyJoin(root, f.to);
|
|
765
|
+
// 双保险:解析后的目标必须落在落点根内(清单已校验相对路径,此处仍复核)
|
|
766
|
+
const base = realpathOrResolve(root);
|
|
767
|
+
const real = realpathOrResolve(dst);
|
|
768
|
+
if (!(real === base || real.startsWith(base + path.sep))) {
|
|
769
|
+
throw new AssetError(`安装路径越界:${f.to}`, 500);
|
|
770
|
+
}
|
|
771
|
+
const src_hash = _sha256(pyJoin(asset.dir, f.from));
|
|
772
|
+
const dst_hash = _sha256(dst);
|
|
773
|
+
out.push({
|
|
774
|
+
to: f.to, path: dst, exists: pyTruthy(dst_hash),
|
|
775
|
+
same: pyTruthy(dst_hash) && dst_hash === src_hash,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// --------------------------------------------------------------------------
|
|
782
|
+
// 状态 / 安装 / 卸载
|
|
783
|
+
// --------------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
/** 判定资产在指定目标下的状态,返回含明细的 dict(详情见模块 docstring)。 */
|
|
786
|
+
export function asset_status(asset, target, project_dir = '') {
|
|
787
|
+
const root = install_root(asset, target, project_dir);
|
|
788
|
+
const supported = root !== null;
|
|
789
|
+
const files = supported ? _installed_files(asset, root) : [];
|
|
790
|
+
const patch = (supported && asset.type === 'dsh_plugin') ? plugin_patch_state(asset) : null;
|
|
791
|
+
let state = 'absent';
|
|
792
|
+
if (supported) {
|
|
793
|
+
const any_exists = files.some((f) => f.exists);
|
|
794
|
+
const file_ok = files.length > 0 && files.every((f) => f.exists);
|
|
795
|
+
const file_same = file_ok && files.every((f) => f.same);
|
|
796
|
+
if (patch === null) {
|
|
797
|
+
if (!any_exists) state = 'absent';
|
|
798
|
+
else if (file_same) state = 'installed';
|
|
799
|
+
else if (file_ok) state = 'outdated';
|
|
800
|
+
else state = 'partial';
|
|
801
|
+
} else {
|
|
802
|
+
const usable = patch.registered && !patch.disabled;
|
|
803
|
+
if (!any_exists && !patch.registered) state = 'absent';
|
|
804
|
+
else if (file_same && usable) state = 'installed';
|
|
805
|
+
else if (file_ok && usable) state = 'outdated';
|
|
806
|
+
else state = 'partial';
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
supported,
|
|
811
|
+
reason: supported ? '' : _unsupported_reason(asset, target),
|
|
812
|
+
root: supported ? display_path(root) : '',
|
|
813
|
+
state,
|
|
814
|
+
files,
|
|
815
|
+
patch,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** 插件的运行时依赖解析状态(只读诊断,不写任何链接)。 */
|
|
820
|
+
export function _plugin_requires_state(asset) {
|
|
821
|
+
const root = plugin_package_root(asset);
|
|
822
|
+
const out = [];
|
|
823
|
+
for (const spec of pyOr(asset.plugin.requires, [])) {
|
|
824
|
+
const found = _node_resolve(spec, root);
|
|
825
|
+
out.push({ spec, ok: pyTruthy(found), path: found ? display_path(found) : '' });
|
|
826
|
+
}
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** 按 Node 祖先查找规则判断从 `start_dir` 能否解析到 `spec`;返回命中目录或空串。 */
|
|
831
|
+
export function _node_resolve(spec, start_dir) {
|
|
832
|
+
let cur = path.resolve(start_dir);
|
|
833
|
+
for (;;) {
|
|
834
|
+
const cand = pyJoin(cur, 'node_modules', ...String(spec).split('/'));
|
|
835
|
+
// 纯文件检查(不跑 node):断链的软链因 statSync 跟随解析而为 false,正是要暴露的情况
|
|
836
|
+
if (pathExists(pyJoin(cand, 'package.json'))) return cand;
|
|
837
|
+
const parent = path.dirname(cur);
|
|
838
|
+
if (parent === cur) return '';
|
|
839
|
+
cur = parent;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** 装/卸后是否需要重启 dsh:profile 的 `package.json` 声明 `patchReload: live` 才热生效。 */
|
|
844
|
+
export function _plugin_restart_required(asset) {
|
|
845
|
+
const manifest = pyJoin(dsh_profiles_dir(), asset.plugin.profile, 'package.json');
|
|
846
|
+
let data;
|
|
847
|
+
try {
|
|
848
|
+
data = JSON.parse(readTextStrict(manifest));
|
|
849
|
+
} catch {
|
|
850
|
+
return true; // 读不到/坏 JSON/坏编码 → 按「需要重启」处理(不猜)
|
|
851
|
+
}
|
|
852
|
+
// Python:`((data.get("dsh") or {}).get("profile") or {}) if isinstance(data, dict) else {}`
|
|
853
|
+
// —— `dsh` 非 dict 且**为真**时 `.get` 抛 AttributeError(`[]`/`{}` 是假值,不抛)
|
|
854
|
+
const prof = isDict(data) ? pyOr(pyGet(pyOr(pyGet(data, 'dsh'), {}), 'profile'), {}) : {};
|
|
855
|
+
return pyStr(pyOr(pyGet(prof, 'patchReload'), '')) !== 'live';
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** 资产的对外 JSON(清单元信息 + 目标下的状态明细)。 */
|
|
859
|
+
export function asset_json(asset, target, project_dir = '') {
|
|
860
|
+
const st = asset_status(asset, target, project_dir);
|
|
861
|
+
const out = {
|
|
862
|
+
id: asset.id,
|
|
863
|
+
name: asset.name,
|
|
864
|
+
type: asset.type,
|
|
865
|
+
type_label: Object.hasOwn(TYPE_LABELS, asset.type) ? TYPE_LABELS[asset.type] : asset.type,
|
|
866
|
+
family: asset.family,
|
|
867
|
+
family_label: Object.hasOwn(FAMILY_LABELS, asset.family)
|
|
868
|
+
? FAMILY_LABELS[asset.family] : asset.family,
|
|
869
|
+
description: asset.description,
|
|
870
|
+
targets: asset.targets,
|
|
871
|
+
supported: st.supported,
|
|
872
|
+
reason: st.reason,
|
|
873
|
+
state: st.state,
|
|
874
|
+
root: st.root,
|
|
875
|
+
files: st.files.map((f) => ({
|
|
876
|
+
to: f.to, path: display_path(f.path), exists: f.exists, same: f.same,
|
|
877
|
+
})),
|
|
878
|
+
};
|
|
879
|
+
if (st.patch !== null) {
|
|
880
|
+
const requires = _plugin_requires_state(asset);
|
|
881
|
+
out.plugin = {
|
|
882
|
+
package: asset.plugin.package,
|
|
883
|
+
entry_id: asset.plugin.entry_id,
|
|
884
|
+
profile: asset.plugin.profile,
|
|
885
|
+
patch_path: display_path(st.patch.path),
|
|
886
|
+
patch_mode: st.patch.mode,
|
|
887
|
+
registered: st.patch.registered,
|
|
888
|
+
disabled: st.patch.disabled,
|
|
889
|
+
restart_required: _plugin_restart_required(asset),
|
|
890
|
+
requires,
|
|
891
|
+
requires_ok: requires.every((r) => r.ok),
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
return out;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** 目标下的资产清单(含状态);`target` 非法抛 `AssetError(400)`。 */
|
|
898
|
+
export function list_assets(target, project_dir = '') {
|
|
899
|
+
if (!ASSET_TARGETS.includes(target)) {
|
|
900
|
+
throw new AssetError(`target 必须是 ${ASSET_TARGETS.join('/')}`);
|
|
901
|
+
}
|
|
902
|
+
return load_assets().map((a) => asset_json(a, target, project_dir));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** dsh 插件安装的前置检查:目标 profile 目录必须存在(不存在则装了也不会被加载)。 */
|
|
906
|
+
export function _require_profile(asset) {
|
|
907
|
+
const prof_dir = path.dirname(plugin_patch_path(asset));
|
|
908
|
+
if (!isDir(prof_dir)) {
|
|
909
|
+
throw new AssetError(
|
|
910
|
+
`未找到 dsh profile「${asset.plugin.profile}」:${display_path(prof_dir)}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** 确保注册块在 patch 里且处于启用态(幂等):返回 `[action, patch_state]`。 */
|
|
915
|
+
export function _write_plugin_patch(asset) {
|
|
916
|
+
const patch = plugin_patch_state(asset);
|
|
917
|
+
if (patch.mode === 'foreign') {
|
|
918
|
+
throw _patch_foreign_error(asset, patch);
|
|
919
|
+
}
|
|
920
|
+
if (patch.mode === 'marked' || patch.mode === 'canonical') {
|
|
921
|
+
if (!patch.disabled) return ['kept', patch];
|
|
922
|
+
const p = patch.path;
|
|
923
|
+
const text = readTextStrict(p);
|
|
924
|
+
_atomic_write_bytes(p, Buffer.from(_patch_set_enabled(text, asset.plugin.entry_id), 'utf8'));
|
|
925
|
+
return ['enabled', plugin_patch_state(asset)];
|
|
926
|
+
}
|
|
927
|
+
const p = patch.path;
|
|
928
|
+
_require_profile(asset);
|
|
929
|
+
let old = '';
|
|
930
|
+
if (isFile(p)) old = readTextStrict(p);
|
|
931
|
+
let text = old;
|
|
932
|
+
if (text && !text.endsWith('\n')) text += '\n';
|
|
933
|
+
if (text && !text.endsWith('\n\n')) text += '\n';
|
|
934
|
+
text += `${plugin_patch_blocks(asset).join('\n')}\n`;
|
|
935
|
+
_atomic_write_bytes(p, Buffer.from(text, 'utf8'));
|
|
936
|
+
return ['added', plugin_patch_state(asset)];
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** 把该条目顶层 `- id: <entry_id>` 行下的 `disabled:` 改成 false(找不到则原样返回)。 */
|
|
940
|
+
export function _patch_set_enabled(text, entry_id) {
|
|
941
|
+
const lines = splitLines(text);
|
|
942
|
+
let cur = null;
|
|
943
|
+
for (let i = 0; i < lines.length; i++) {
|
|
944
|
+
const line = lines[i];
|
|
945
|
+
const m = PATCH_ID_RE.exec(line);
|
|
946
|
+
if (m) {
|
|
947
|
+
cur = stripQuotes(m[1]);
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (line.startsWith('- ')) {
|
|
951
|
+
cur = null;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (cur === entry_id && PATCH_DISABLED_RE.test(line)) {
|
|
955
|
+
const head = line.match(LEADING_WS_RE)[0]; // Python:line[:len(line)-len(line.lstrip())]
|
|
956
|
+
lines[i] = `${head}disabled: false`;
|
|
957
|
+
const out = lines.join('\n');
|
|
958
|
+
return String(text).endsWith('\n') ? `${out}\n` : out;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return text;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** 删掉平台标记块(含可选 config 块)与其留下的多余空行。 */
|
|
965
|
+
export function _strip_marked_blocks(text, aid) {
|
|
966
|
+
const begins = new Set([patchMarkBegin(aid), patchMarkBegin(`${aid}.config`)]);
|
|
967
|
+
const ends = new Set([patchMarkEnd(aid), patchMarkEnd(`${aid}.config`)]);
|
|
968
|
+
const out = [];
|
|
969
|
+
let skip = false;
|
|
970
|
+
for (const line of splitLines(text)) {
|
|
971
|
+
const s = pyStrip(line);
|
|
972
|
+
if (begins.has(s)) {
|
|
973
|
+
skip = true;
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
if (ends.has(s)) {
|
|
977
|
+
skip = false;
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
if (!skip) out.push(line);
|
|
981
|
+
}
|
|
982
|
+
let res = out.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
983
|
+
if (res && !res.endsWith('\n')) res += '\n';
|
|
984
|
+
return res;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** 移除注册块(幂等):返回 `[action, patch_state]`。foreign 一律拒绝。 */
|
|
988
|
+
export function _remove_plugin_patch(asset) {
|
|
989
|
+
const patch = plugin_patch_state(asset);
|
|
990
|
+
if (patch.mode === 'foreign') {
|
|
991
|
+
throw _patch_foreign_error(asset, patch);
|
|
992
|
+
}
|
|
993
|
+
if (patch.mode === '') return ['absent', patch];
|
|
994
|
+
const p = patch.path;
|
|
995
|
+
const text = readTextStrict(p);
|
|
996
|
+
let neu;
|
|
997
|
+
if (patch.mode === 'marked') {
|
|
998
|
+
neu = _strip_marked_blocks(text, asset.id);
|
|
999
|
+
} else {
|
|
1000
|
+
const span = _find_canonical(text, asset);
|
|
1001
|
+
const lines = splitLines(text);
|
|
1002
|
+
const kept = [...lines.slice(0, span[0]), ...lines.slice(span[1])];
|
|
1003
|
+
neu = kept.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
1004
|
+
if (neu && !neu.endsWith('\n')) neu += '\n';
|
|
1005
|
+
}
|
|
1006
|
+
_atomic_write_bytes(p, Buffer.from(neu, 'utf8'));
|
|
1007
|
+
return ['removed', plugin_patch_state(asset)];
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** `os.walk(cur, topdown=False)` 的目录序列(不跟随符号链接、读不到就跳过) */
|
|
1011
|
+
function walkDirsBottomUp(root, out = []) {
|
|
1012
|
+
let entries;
|
|
1013
|
+
try {
|
|
1014
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
1015
|
+
} catch {
|
|
1016
|
+
return out;
|
|
1017
|
+
}
|
|
1018
|
+
for (const ent of entries) {
|
|
1019
|
+
if (ent.isDirectory()) walkDirsBottomUp(path.join(root, ent.name), out);
|
|
1020
|
+
}
|
|
1021
|
+
out.push(root);
|
|
1022
|
+
return out;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** 删空目录:先自底向上清子树里的空目录,再逐级上删空父目录(到 `stop_at` 为止)。 */
|
|
1026
|
+
export function _prune_empty_dirs(p, stop_at) {
|
|
1027
|
+
let cur = path.resolve(p);
|
|
1028
|
+
const stop = path.resolve(stop_at);
|
|
1029
|
+
if (isDir(cur)) {
|
|
1030
|
+
for (const dirpath of walkDirsBottomUp(cur)) {
|
|
1031
|
+
try {
|
|
1032
|
+
fs.rmdirSync(dirpath); // 空则删;非空抛 → 跳过(不递归删别人的文件)
|
|
1033
|
+
} catch { /* 与 Python 的 `except OSError: pass` 同 */ }
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
while (cur.startsWith(stop + path.sep) && cur !== stop) {
|
|
1037
|
+
try {
|
|
1038
|
+
fs.rmdirSync(cur);
|
|
1039
|
+
} catch (e) {
|
|
1040
|
+
if (e && e.code === 'ENOENT') {
|
|
1041
|
+
// 子树里已被删掉 → 继续往上收敛空父目录
|
|
1042
|
+
} else {
|
|
1043
|
+
return; // 非空(还有别人的文件)→ 停
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
cur = path.dirname(cur);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** 安装/更新/修复资产(收敛到期望状态),返回结果 dict。 */
|
|
1051
|
+
export function install(asset, target, project_dir = '') {
|
|
1052
|
+
const root = install_root(asset, target, project_dir);
|
|
1053
|
+
if (root === null) {
|
|
1054
|
+
throw new AssetError(_unsupported_reason(asset, target));
|
|
1055
|
+
}
|
|
1056
|
+
if (target === 'project') {
|
|
1057
|
+
if (!pyTruthy(project_dir) || !isDir(project_dir)) {
|
|
1058
|
+
throw new AssetError('项目目录不存在');
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
if (asset.type === 'dsh_plugin') {
|
|
1062
|
+
// 前置检查放在拷文件之前:profile 不存在时直接报错,不留下「包已拷、注册行没写」
|
|
1063
|
+
// 的半残状态(那种状态面板会显示 partial,用户还得先修一次)
|
|
1064
|
+
_require_profile(asset);
|
|
1065
|
+
}
|
|
1066
|
+
// 落盘:内容一致则不动,其余原子覆盖
|
|
1067
|
+
const written = [];
|
|
1068
|
+
for (const f of asset.files) {
|
|
1069
|
+
const dst = pyJoin(root, f.to);
|
|
1070
|
+
const base = realpathOrResolve(root);
|
|
1071
|
+
const real = realpathOrResolve(dst);
|
|
1072
|
+
if (!(real === base || real.startsWith(base + path.sep))) {
|
|
1073
|
+
throw new AssetError(`安装路径越界:${f.to}`, 500);
|
|
1074
|
+
}
|
|
1075
|
+
const src = pyJoin(asset.dir, f.from);
|
|
1076
|
+
const data = fs.readFileSync(src);
|
|
1077
|
+
if (_sha256(dst) === _sha256(src)) continue;
|
|
1078
|
+
_atomic_write_bytes(dst, data);
|
|
1079
|
+
written.push(display_path(dst));
|
|
1080
|
+
}
|
|
1081
|
+
let patch_action = '';
|
|
1082
|
+
if (asset.type === 'dsh_plugin') {
|
|
1083
|
+
patch_action = _write_plugin_patch(asset)[0];
|
|
1084
|
+
}
|
|
1085
|
+
const st = asset_status(asset, target, project_dir);
|
|
1086
|
+
return {
|
|
1087
|
+
ok: true,
|
|
1088
|
+
state: st.state,
|
|
1089
|
+
files_written: written,
|
|
1090
|
+
patch_action,
|
|
1091
|
+
restart_required: asset.type === 'dsh_plugin' ? _plugin_restart_required(asset) : false,
|
|
1092
|
+
asset: asset_json(asset, target, project_dir),
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** 卸载资产:只删除清单内文件(别人的文件一律不动),返回结果 dict。 */
|
|
1097
|
+
export function uninstall(asset, target, project_dir = '') {
|
|
1098
|
+
const root = install_root(asset, target, project_dir);
|
|
1099
|
+
if (root === null) {
|
|
1100
|
+
throw new AssetError(_unsupported_reason(asset, target));
|
|
1101
|
+
}
|
|
1102
|
+
// dsh 插件:先判注册行——foreign(用户自定义写法)时**在删任何文件之前**拒绝,
|
|
1103
|
+
// 免得出现「包文件已删、注册行还在」的半残状态
|
|
1104
|
+
const patch = asset.type === 'dsh_plugin' ? plugin_patch_state(asset) : null;
|
|
1105
|
+
if (patch !== null && patch.mode === 'foreign') {
|
|
1106
|
+
throw _patch_foreign_error(asset, patch);
|
|
1107
|
+
}
|
|
1108
|
+
const files = _installed_files(asset, root);
|
|
1109
|
+
const deleted = [];
|
|
1110
|
+
for (const f of files) {
|
|
1111
|
+
if (isFile(f.path)) {
|
|
1112
|
+
try {
|
|
1113
|
+
fs.unlinkSync(f.path);
|
|
1114
|
+
} catch (e) {
|
|
1115
|
+
throw new AssetError(
|
|
1116
|
+
`删除失败:${display_path(f.path)}(${e && e.message !== undefined ? e.message : e})`,
|
|
1117
|
+
500);
|
|
1118
|
+
}
|
|
1119
|
+
deleted.push(display_path(f.path));
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
let patch_action = '';
|
|
1123
|
+
if (patch !== null) {
|
|
1124
|
+
patch_action = _remove_plugin_patch(asset)[0];
|
|
1125
|
+
_prune_empty_dirs(root, dsh_profiles_dir());
|
|
1126
|
+
}
|
|
1127
|
+
const st = asset_status(asset, target, project_dir);
|
|
1128
|
+
return {
|
|
1129
|
+
ok: true,
|
|
1130
|
+
state: st.state,
|
|
1131
|
+
files_deleted: deleted,
|
|
1132
|
+
patch_action,
|
|
1133
|
+
restart_required: asset.type === 'dsh_plugin' ? _plugin_restart_required(asset) : false,
|
|
1134
|
+
asset: asset_json(asset, target, project_dir),
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// --------------------------------------------------------------------------
|
|
1139
|
+
// camelCase 别名(JS 调用方用;量具用例仍写 Python 名 + nodeFn)
|
|
1140
|
+
// --------------------------------------------------------------------------
|
|
1141
|
+
|
|
1142
|
+
export const extDir = ext_dir;
|
|
1143
|
+
export const displayPath = display_path;
|
|
1144
|
+
export const sha256 = _sha256;
|
|
1145
|
+
export const atomicWriteBytes = _atomic_write_bytes;
|
|
1146
|
+
export const manifestError = _manifest_error;
|
|
1147
|
+
export const safeRel = _safe_rel;
|
|
1148
|
+
export const scalarStr = _scalar_str;
|
|
1149
|
+
export const pluginSpec = _plugin_spec;
|
|
1150
|
+
export const loadManifest = _load_manifest;
|
|
1151
|
+
export const loadAssets = load_assets;
|
|
1152
|
+
export const getAsset = get_asset;
|
|
1153
|
+
export const skillRoot = _skill_root;
|
|
1154
|
+
export const dshProfilesDir = dsh_profiles_dir;
|
|
1155
|
+
export const pluginPackageRoot = plugin_package_root;
|
|
1156
|
+
export const pluginPatchPath = plugin_patch_path;
|
|
1157
|
+
export const pluginPatchBlocks = plugin_patch_blocks;
|
|
1158
|
+
export const canonicalLines = _canonical_lines;
|
|
1159
|
+
export const findCanonical = _find_canonical;
|
|
1160
|
+
export const patchDisabled = _patch_disabled;
|
|
1161
|
+
export const pluginPatchState = plugin_patch_state;
|
|
1162
|
+
export const patchForeignError = _patch_foreign_error;
|
|
1163
|
+
export const installRoot = install_root;
|
|
1164
|
+
export const unsupportedReason = _unsupported_reason;
|
|
1165
|
+
export const installedFiles = _installed_files;
|
|
1166
|
+
export const assetStatus = asset_status;
|
|
1167
|
+
export const pluginRequiresState = _plugin_requires_state;
|
|
1168
|
+
export const nodeResolve = _node_resolve;
|
|
1169
|
+
export const pluginRestartRequired = _plugin_restart_required;
|
|
1170
|
+
export const assetJson = asset_json;
|
|
1171
|
+
export const listAssets = list_assets;
|
|
1172
|
+
export const requireProfile = _require_profile;
|
|
1173
|
+
export const writePluginPatch = _write_plugin_patch;
|
|
1174
|
+
export const patchSetEnabled = _patch_set_enabled;
|
|
1175
|
+
export const stripMarkedBlocks = _strip_marked_blocks;
|
|
1176
|
+
export const removePluginPatch = _remove_plugin_patch;
|
|
1177
|
+
export const pruneEmptyDirs = _prune_empty_dirs;
|