@morehao/dsh-skills-console 0.2.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -19
- package/client.js +43 -17
- package/lib/core/archive.js +246 -0
- package/lib/core/discovery.js +148 -0
- package/lib/core/git-tree.js +132 -0
- package/lib/core/installer.js +351 -0
- package/lib/core/lock.js +310 -0
- package/lib/core/remote.js +332 -0
- package/lib/core/service.js +348 -354
- package/lib/core/skill-md.js +49 -0
- package/lib/core/trash.js +2 -2
- package/lib/index.js +3 -3
- package/package.json +2 -2
- package/lib/core/cli.js +0 -256
- package/lib/core/git.js +0 -282
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
DSH(DeepSeek Harness)的**技能控制台**:装、更新、删除、接入其他 Agent、随时启停,外加一份订阅清单。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
它不搬技能。技能真身由插件内的 **native installer** 写进 `~/.agents/skills`,并维护 v3 lock;本插件只在 DSH 这一侧做控制台:决定**哪些技能、对谁、在哪一层可见**,并把每次改动做成可回滚的事务。安装、更新、删除都由 native installer 完成,不依赖 `skills` CLI 或系统 git。
|
|
12
12
|
|
|
13
13
|
## 为什么需要它
|
|
14
14
|
|
|
@@ -22,10 +22,10 @@ DSH(DeepSeek Harness)的**技能控制台**:装、更新、删除、接入
|
|
|
22
22
|
|
|
23
23
|
| 能力 | 说明 |
|
|
24
24
|
| --- | --- |
|
|
25
|
-
| 清单 | canonical 扫描 +
|
|
26
|
-
| 安装 | 填 `owner/repo
|
|
25
|
+
| 清单 | canonical 扫描 + v3 lock 对账 + DSH 实际可见性对账;canonical 里已有的技能首次读取自动纳入追踪,不必逐个「接管」 |
|
|
26
|
+
| 安装 | 填 `owner/repo`(可只装其中几个技能),native installer 下载 codeload tarball → 安全解压 → 本地计算 tree SHA → 原子落盘 canonical + v3 lock;来自订阅快照时核对哈希,不一致会提示 |
|
|
27
27
|
| 更新 | GitHub Trees 检测上游新 commit → 计划(commit 区间 + 影响范围 + 防抖 token)→ 应用前备份,失败自动回滚上一版 |
|
|
28
|
-
| 删除 | 先体检(真实目录 / 外部链接直接中止)→ 快照进回收站 →
|
|
28
|
+
| 删除 | 先体检(真实目录 / 外部链接直接中止)→ 快照进回收站 → installer 删 lock 条目与真身(lock 写失败从回收站恢复)→ 只清本插件建的链接与记账 |
|
|
29
29
|
| 接入 | 软链四态判定(`managed` / `managed-external` / `foreign-link` / `real`),**绝不覆盖**别人创建的东西;只列本机已安装目录,已删的 Agent 自动从列表消失 |
|
|
30
30
|
| 启停 | `mode: on/off` 只摘或重建 DSH 侧链接,不动 `SKILL.md`,其他 Agent 的链接也不受影响 |
|
|
31
31
|
| 作用范围 | 全局 `~/.dsh/skills`,或仅指定项目;项目可直接勾选 DSH 已登记的工作区,也可手动选目录 |
|
|
@@ -41,7 +41,7 @@ DSH(DeepSeek Harness)的**技能控制台**:装、更新、删除、接入
|
|
|
41
41
|
|
|
42
42
|
## 安装
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
插件只带 `tar@7.5.22`(native installer 解压),**不需要用户预装 Node / npm / npx / git**。首次装插件需要联网拉取依赖。
|
|
45
45
|
|
|
46
46
|
在 Harness 里装本地 link:
|
|
47
47
|
|
|
@@ -57,7 +57,7 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
57
57
|
name: '@morehao/dsh-skills-console'
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
客户端 half 只注册 `platform: web`,面板出现在 DSH Web 的设置页 / 侧栏。本地 `link:` 开发时,先在仓库跑一次 `npm install`(装上 `
|
|
60
|
+
客户端 half 只注册 `platform: web`,面板出现在 DSH Web 的设置页 / 侧栏。本地 `link:` 开发时,先在仓库跑一次 `npm install`(装上 `tar`),否则 native installer 的解压依赖层不会命中。
|
|
61
61
|
|
|
62
62
|
## 快速上手
|
|
63
63
|
|
|
@@ -68,9 +68,9 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
68
68
|
|
|
69
69
|
## 可靠性
|
|
70
70
|
|
|
71
|
-
- **canonical 与
|
|
71
|
+
- **canonical 与 v3 lock 的写入口**:安装、更新、删除由 native installer 统一处理(下载/解压/发现/tree SHA/原子 promote/lock 事务);插件其他模块只读。限流兜底的 codeload 扫描只在 `os.tmpdir()` 里解压,结果绝不落进 canonical 或 lock。
|
|
72
72
|
- **只动自己的东西**:只处理本插件创建的 `managed` 链接,以及已记账的 `managed-external`;`real` 目录和 `foreign-link` 永不改写,删除前体检直接中止。
|
|
73
|
-
- **启停不改正文**:`mode` 只增删 DSH
|
|
73
|
+
- **启停不改正文**:`mode` 只增删 DSH 侧链接,避免本地修改被下一次安装/更新覆盖。
|
|
74
74
|
- **可回滚**:删除进回收站(7 天);更新前快照到 `backups/`,失败自动还原(接口另提供手动回滚)。
|
|
75
75
|
- **状态坏了就只读**:`state.json` 是坏 JSON 或版本未知时,插件降级为只读并让写接口返回 409,不猜结构、不自动修复。
|
|
76
76
|
- **不做任意路径**:请求里的目标一律走注册表反查,软链目标恒为 canonical 下同名目录。
|
|
@@ -79,8 +79,8 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
79
79
|
|
|
80
80
|
| 项 | 路径 | 归属 |
|
|
81
81
|
| --- | --- | --- |
|
|
82
|
-
| canonical 真身 | `~/.agents/skills` |
|
|
83
|
-
|
|
|
82
|
+
| canonical 真身 | `~/.agents/skills` | native installer(安装/删除/更新) |
|
|
83
|
+
| v3 lock | `~/.agents/.skill-lock.json`(设了 `XDG_STATE_HOME` 时为 `$XDG_STATE_HOME/skills/.skill-lock.json`) | native installer(安装/更新/删除) |
|
|
84
84
|
| DSH 全局根 | `~/.dsh/skills` | 本插件写软链 |
|
|
85
85
|
| 插件状态 | `$DSH_HOME/skills-console/state.json` | 本插件(`DSH_SKILLS_CONSOLE_HOME` 可覆盖) |
|
|
86
86
|
| 备份 / 回收站 | `$DSH_HOME/skills-console/{backups,trash}` | 本插件 |
|
|
@@ -91,17 +91,12 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
91
91
|
| 变量 | 默认 | 作用 |
|
|
92
92
|
| --- | --- | --- |
|
|
93
93
|
| `DSH_SKILLS_CONSOLE_HOME` | `$DSH_HOME/skills-console` | 状态、备份、回收站与事件日志的根 |
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `DSH_SKILLS_CONSOLE_TIMEOUT_MS` | `120000` | 单次 CLI 调用超时 |
|
|
97
|
-
| `DSH_SKILLS_CONSOLE_GIT` | `auto` | 设为 `off` 关闭 git 通道,强制只走 GitHub REST |
|
|
98
|
-
| `DSH_SKILLS_CONSOLE_GIT_TIMEOUT_MS` | `60000` | git 单次操作(`ls-remote` / `clone`)超时 |
|
|
99
|
-
| `GITHUB_TOKEN` / `GH_TOKEN` | 无 | 只读环境变量;带上后 REST 配额从匿名 60 次/小时提到 5000 次/小时 |
|
|
100
|
-
| `XDG_STATE_HOME` | 未设置 | 设置后 CLI lock 移到 `$XDG_STATE_HOME/skills/.skill-lock.json`,插件跟随读取 |
|
|
94
|
+
| `GITHUB_TOKEN` / `GH_TOKEN` | 无 | 只读环境变量;带上后 REST 配额从匿名 60 次/小时提到 5000 次/小时,私有仓库安装也靠它 |
|
|
95
|
+
| `XDG_STATE_HOME` | 未设置 | 设置后 lock 移到 `$XDG_STATE_HOME/skills/.skill-lock.json`,插件跟随读取 |
|
|
101
96
|
|
|
102
|
-
关于 GitHub 配额:**匿名 REST 是 60 次/小时,按出口 IP 共享**(公司、校园网出口常被同网段的人一起打满),打满后探测与更新会收到 403
|
|
97
|
+
关于 GitHub 配额:**匿名 REST 是 60 次/小时,按出口 IP 共享**(公司、校园网出口常被同网段的人一起打满),打满后探测与更新会收到 403。本插件先走 REST;403/429 时改用 **Atom commit feed** 取 commit,再用 **codeload tarball** 在临时目录解压、扫描并本地计算 tree SHA,不占 REST 配额、也不需要 git。兜底也失败时,错误文案会给出恢复时间,并提示设置 `GITHUB_TOKEN`(5000 次/小时)。
|
|
103
98
|
|
|
104
|
-
>
|
|
99
|
+
> Atom/codeload 兜底只在 `os.tmpdir()` 里解压,用完即删;结果不会落进 canonical 或 lock。
|
|
105
100
|
|
|
106
101
|
|
|
107
102
|
## 开发与发布
|
package/client.js
CHANGED
|
@@ -138,7 +138,7 @@ var DICT = {
|
|
|
138
138
|
"links.hint": "保存后按勾选建立或移除接入;技能本体保留在原位置,不会移动。",
|
|
139
139
|
"autoLink.detected": "安装后自动接入",
|
|
140
140
|
"autoLink.none": "本机没有检测到其他 Agent",
|
|
141
|
-
"autoLink.hint": "dsh
|
|
141
|
+
"autoLink.hint": "列表里的「dsh(全局)」固定勾选、不能取消;这里只影响之后安装时的自动接入,已安装的技能请在「接入设置」里改。",
|
|
142
142
|
"empty.skills": "还没有安装任何技能",
|
|
143
143
|
"empty.skillsHint": "在终端跑 `npx skills add owner/repo`,或点右上角「添加技能」",
|
|
144
144
|
"empty.subs": "还没有添加订阅",
|
|
@@ -268,7 +268,7 @@ var DICT = {
|
|
|
268
268
|
"links.hint": "Saving only adds or removes links; the skill itself stays where it is.",
|
|
269
269
|
"autoLink.detected": "Auto-link after install",
|
|
270
270
|
"autoLink.none": "No other agents detected on this machine",
|
|
271
|
-
"autoLink.hint": 'dsh (global) is always
|
|
271
|
+
"autoLink.hint": 'The "dsh (global)" entry is always checked and cannot be unchecked; this only affects future installs. Change existing skills under "Link settings".',
|
|
272
272
|
"empty.skills": "No skills installed yet",
|
|
273
273
|
"empty.skillsHint": 'Run `npx skills add owner/repo` or use "Add skill".',
|
|
274
274
|
"empty.subs": "No subscriptions yet",
|
|
@@ -945,6 +945,9 @@ var CSS = `
|
|
|
945
945
|
.dshsc-panel { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1);
|
|
946
946
|
padding: 4px; max-height: 300px; overflow: auto; display: flex; flex-direction: column; }
|
|
947
947
|
.dshsc-panel .dshsc-check:hover { background: var(--dsw-alias-bg-layer-2); }
|
|
948
|
+
/* 必选项(dsh 全局):勾着但不可改,别让它看起来能点 */
|
|
949
|
+
.dshsc-panel .dshsc-check.locked { cursor: default; }
|
|
950
|
+
.dshsc-panel .dshsc-check.locked:hover { background: transparent; }
|
|
948
951
|
|
|
949
952
|
.dshsc-modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1); }
|
|
950
953
|
.dshsc-toast { position: fixed; right: 18px; bottom: 18px; z-index: 70; font-size: 12px; padding: 10px 13px; border-radius: 9px;
|
|
@@ -1976,7 +1979,7 @@ function LinksModal({ t, row, agents, workspaces, pickDirectory, onCancel, onSav
|
|
|
1976
1979
|
h("p", { className: "dshsc-sub" }, t("links.canonical").replace("{path}", `~/.agents/skills/${row.name}`)),
|
|
1977
1980
|
...candidates.map((agent) => h(
|
|
1978
1981
|
"label",
|
|
1979
|
-
{ className:
|
|
1982
|
+
{ className: `dshsc-check${agent.target === "dsh-global" ? " locked" : ""}`, key: agent.target },
|
|
1980
1983
|
h("input", {
|
|
1981
1984
|
type: "checkbox",
|
|
1982
1985
|
checked: agent.target === "dsh-global" ? true : targets.has(agent.target),
|
|
@@ -2034,7 +2037,7 @@ function LinksModal({ t, row, agents, workspaces, pickDirectory, onCancel, onSav
|
|
|
2034
2037
|
h("p", { className: "dshsc-sub" }, t("links.hint"))
|
|
2035
2038
|
);
|
|
2036
2039
|
}
|
|
2037
|
-
function AutoLinkModal({ t, settings, agents, onCancel, onSave }) {
|
|
2040
|
+
function AutoLinkModal({ t, settings, agents, dsh, onCancel, onSave }) {
|
|
2038
2041
|
const [targets, setTargets] = import_react3.default.useState(() => new Set(
|
|
2039
2042
|
Array.isArray(settings.autoLinkTargets) ? settings.autoLinkTargets : agents.map((agent) => agent.target)
|
|
2040
2043
|
));
|
|
@@ -2055,19 +2058,39 @@ function AutoLinkModal({ t, settings, agents, onCancel, onSave }) {
|
|
|
2055
2058
|
h("button", { key: "s", className: "dshsc-btn primary", onClick: () => onSave([...targets]) }, t("action.save"))
|
|
2056
2059
|
]
|
|
2057
2060
|
},
|
|
2058
|
-
h("p", { className: "dshsc-sub" }, t("links.dshGlobal") + " · " + t("links.required")),
|
|
2059
2061
|
h("div", { className: "dshsc-group" }, t("autoLink.detected")),
|
|
2060
|
-
|
|
2061
|
-
"
|
|
2062
|
-
{ className: "dshsc-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2062
|
+
h(
|
|
2063
|
+
"div",
|
|
2064
|
+
{ className: "dshsc-panel" },
|
|
2065
|
+
// dsh(全局)始终接入:列表里也勾上,只是不能改,勾选数与页头的计数器才对得上
|
|
2066
|
+
h(
|
|
2067
|
+
"label",
|
|
2068
|
+
{ className: "dshsc-check locked", key: "dsh-global" },
|
|
2069
|
+
h("input", {
|
|
2070
|
+
type: "checkbox",
|
|
2071
|
+
checked: true,
|
|
2072
|
+
disabled: true,
|
|
2073
|
+
readOnly: true,
|
|
2074
|
+
onChange: () => {
|
|
2075
|
+
}
|
|
2076
|
+
}),
|
|
2077
|
+
h("span", null, dsh?.label || t("links.dshGlobal")),
|
|
2078
|
+
h("small", null, t("links.required")),
|
|
2079
|
+
dsh?.dir ? h("small", { title: dsh.dir }, dsh.dir) : null
|
|
2080
|
+
),
|
|
2081
|
+
...agents.map((agent) => h(
|
|
2082
|
+
"label",
|
|
2083
|
+
{ className: "dshsc-check", key: agent.target },
|
|
2084
|
+
h("input", {
|
|
2085
|
+
type: "checkbox",
|
|
2086
|
+
checked: targets.has(agent.target),
|
|
2087
|
+
onChange: () => toggle(agent.target)
|
|
2088
|
+
}),
|
|
2089
|
+
h("span", null, agent.label),
|
|
2090
|
+
h("small", null, agent.dir)
|
|
2091
|
+
))
|
|
2092
|
+
),
|
|
2093
|
+
agents.length === 0 ? h("p", { className: "dshsc-sub" }, t("autoLink.none")) : null,
|
|
2071
2094
|
h("p", { className: "dshsc-sub" }, t("autoLink.hint"))
|
|
2072
2095
|
);
|
|
2073
2096
|
}
|
|
@@ -2211,7 +2234,7 @@ function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
|
|
|
2211
2234
|
h("p", { className: "dshsc-sub" }, t("form.hint"))
|
|
2212
2235
|
);
|
|
2213
2236
|
}
|
|
2214
|
-
function Modals({ t, modal, data, autoLinkAgents, pickDirectory, actions, onClose, onSaveAutoLink }) {
|
|
2237
|
+
function Modals({ t, modal, data, autoLinkAgents, autoLinkDsh, pickDirectory, actions, onClose, onSaveAutoLink }) {
|
|
2215
2238
|
if (modal.type === "add-skill") {
|
|
2216
2239
|
return h(AddSkillModal, {
|
|
2217
2240
|
t,
|
|
@@ -2253,6 +2276,7 @@ function Modals({ t, modal, data, autoLinkAgents, pickDirectory, actions, onClos
|
|
|
2253
2276
|
t,
|
|
2254
2277
|
settings: data?.settings || {},
|
|
2255
2278
|
agents: autoLinkAgents,
|
|
2279
|
+
dsh: autoLinkDsh,
|
|
2256
2280
|
onCancel: onClose,
|
|
2257
2281
|
onSave: (targets) => void onSaveAutoLink(targets)
|
|
2258
2282
|
});
|
|
@@ -2327,6 +2351,7 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
|
|
|
2327
2351
|
const autolinkTargets = (data?.agents?.detected || []).filter((agent) => agent.kind === "agent");
|
|
2328
2352
|
const autoLinkSelection = data?.settings?.autoLinkTargets;
|
|
2329
2353
|
const autoLinkAgents = Array.isArray(autoLinkSelection) ? autolinkTargets.filter((agent) => autoLinkSelection.includes(agent.target)) : autolinkTargets;
|
|
2354
|
+
const autoLinkDsh = (data?.agents?.all || []).find((agent) => agent.target === "dsh-global") || null;
|
|
2330
2355
|
const autoLinkLabel = [t("links.dshGlobal"), ...autoLinkAgents.map((agent) => agent.label)].join("、");
|
|
2331
2356
|
const placement = (placementOverride ?? data?.settings?.placement) === "sidebar" ? "sidebar" : "settings";
|
|
2332
2357
|
async function changePlacement(next) {
|
|
@@ -2442,6 +2467,7 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
|
|
|
2442
2467
|
modal,
|
|
2443
2468
|
data,
|
|
2444
2469
|
autoLinkAgents: autolinkTargets,
|
|
2470
|
+
autoLinkDsh,
|
|
2445
2471
|
pickDirectory,
|
|
2446
2472
|
actions: {
|
|
2447
2473
|
probeSkills: actions.probeSkills,
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as tar from 'tar';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 安全解压 codeload tarball。分两遍:**先全量校验,再解压**。
|
|
7
|
+
*
|
|
8
|
+
* 为什么不在解压时用 filter 跳过坏条目:filter 返回 false 只是“不落盘”,进程仍然算成功,
|
|
9
|
+
* 恶意归档会被静默忽略而不是拒绝。安装器需要 fail closed,所以先 list 一遍:
|
|
10
|
+
* - 只接受普通文件与目录;symlink/hardlink/设备/FIFO/未知类型直接拒绝。
|
|
11
|
+
* - 拒绝绝对路径、盘符、反斜杠、`.` / `..` / 空段。
|
|
12
|
+
* - 整个归档必须只有一个顶层目录(codeload 的 `repo-ref/`),用 strip:1 剥掉。
|
|
13
|
+
* - 文件数 ≤ maxFiles、解压总量 ≤ maxBytes;超限立即中止。
|
|
14
|
+
*
|
|
15
|
+
* 解压后再扫一遍目录,确认没有符号链接或特殊文件落盘(防御性复核)。
|
|
16
|
+
* 所有失败抛 ArchiveError,调用方负责清理 staging。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_MAX_FILES = 10_000;
|
|
20
|
+
export const DEFAULT_MAX_EXTRACTED_BYTES = 200 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
export class ArchiveError extends Error {
|
|
23
|
+
constructor(code, message, extra = {}) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'ArchiveError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
Object.assign(this, extra);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isGzipFile(file) {
|
|
32
|
+
const fd = fs.openSync(file, 'r');
|
|
33
|
+
try {
|
|
34
|
+
const head = Buffer.alloc(2);
|
|
35
|
+
const read = fs.readSync(fd, head, 0, 2, 0);
|
|
36
|
+
return read === 2 && head[0] === 0x1f && head[1] === 0x8b;
|
|
37
|
+
} finally {
|
|
38
|
+
fs.closeSync(fd);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 归一化并校验一个 tar 条目路径。返回 `{ value, segments }`:
|
|
44
|
+
* - `value` 去掉末尾 `/` 后的 POSIX 路径;
|
|
45
|
+
* - 根目录条目(`repo-ref/`)的 segments 为 `['repo-ref']`。
|
|
46
|
+
*/
|
|
47
|
+
function parseEntryPath(raw) {
|
|
48
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
49
|
+
throw new ArchiveError('unsafe-entry', `条目路径为空或不是字符串: ${String(raw)}`);
|
|
50
|
+
}
|
|
51
|
+
if (raw.includes('\0')) throw new ArchiveError('unsafe-entry', '条目路径含 NUL');
|
|
52
|
+
if (raw.includes('\\')) {
|
|
53
|
+
throw new ArchiveError('unsafe-entry', `条目路径含反斜杠: ${JSON.stringify(raw)}`);
|
|
54
|
+
}
|
|
55
|
+
if (raw.startsWith('/')) throw new ArchiveError('unsafe-entry', `条目是绝对路径: ${raw}`);
|
|
56
|
+
if (/^[A-Za-z]:/.test(raw)) throw new ArchiveError('unsafe-entry', `条目带盘符: ${raw}`);
|
|
57
|
+
|
|
58
|
+
let value = raw;
|
|
59
|
+
while (value.endsWith('/')) value = value.slice(0, -1);
|
|
60
|
+
|
|
61
|
+
const segments = value.split('/');
|
|
62
|
+
for (const segment of segments) {
|
|
63
|
+
if (segment === '' || segment === '.' || segment === '..') {
|
|
64
|
+
throw new ArchiveError('unsafe-entry', `条目路径含非法段: ${JSON.stringify(raw)}`);
|
|
65
|
+
}
|
|
66
|
+
// eslint-disable-next-line no-control-regex
|
|
67
|
+
if (/[\u0000-\u001f\u007f]/.test(segment)) {
|
|
68
|
+
throw new ArchiveError('unsafe-entry', `条目路径含控制字符: ${JSON.stringify(raw)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { raw, value, segments };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 扫一遍 tar 头,全部合法才允许进入解压。 */
|
|
75
|
+
async function scanArchive({ tarball, gzip, maxFiles, maxBytes }) {
|
|
76
|
+
let root = null;
|
|
77
|
+
let files = 0;
|
|
78
|
+
let dirs = 0;
|
|
79
|
+
let bytes = 0;
|
|
80
|
+
let failure = null;
|
|
81
|
+
const warnings = [];
|
|
82
|
+
|
|
83
|
+
// 注意:tar 把 onReadEntry 里的异常当同步异常抛到事件循环,不会 reject list 的 Promise。
|
|
84
|
+
// 所以这里先记下第一个校验错误,扫完再抛;有错误时后续条目直接跳过。
|
|
85
|
+
const onReadEntry = (entry) => {
|
|
86
|
+
if (failure) return;
|
|
87
|
+
try {
|
|
88
|
+
const pathInfo = parseEntryPath(entry.path);
|
|
89
|
+
const [top] = pathInfo.segments;
|
|
90
|
+
if (root === null) root = top;
|
|
91
|
+
if (top !== root) {
|
|
92
|
+
throw new ArchiveError('unsafe-entry', `归档里有多个顶层目录: ${root} / ${top}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (pathInfo.value === root) {
|
|
96
|
+
// 顶层目录本身;codeload 里它总是 Directory。
|
|
97
|
+
if (entry.type !== 'Directory') {
|
|
98
|
+
throw new ArchiveError('unsafe-entry', `顶层条目不是目录: ${entry.path}`);
|
|
99
|
+
}
|
|
100
|
+
dirs += 1;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (entry.type === 'File') {
|
|
105
|
+
if (!Number.isFinite(entry.size) || entry.size < 0) {
|
|
106
|
+
throw new ArchiveError('invalid-archive', `条目 size 非法: ${entry.path}`);
|
|
107
|
+
}
|
|
108
|
+
files += 1;
|
|
109
|
+
bytes += entry.size;
|
|
110
|
+
if (files > maxFiles) {
|
|
111
|
+
throw new ArchiveError('too-many-files', `文件数超过 ${maxFiles} 上限`, { maxFiles });
|
|
112
|
+
}
|
|
113
|
+
if (bytes > maxBytes) {
|
|
114
|
+
throw new ArchiveError('archive-too-large', `解压总量超过 ${maxBytes} 字节上限`, {
|
|
115
|
+
maxBytes,
|
|
116
|
+
bytes,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (entry.type === 'Directory') {
|
|
123
|
+
dirs += 1;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw new ArchiveError('unsafe-entry', `拒绝的条目类型 ${entry.type}: ${entry.path}`, {
|
|
128
|
+
entryType: entry.type,
|
|
129
|
+
entryPath: entry.path,
|
|
130
|
+
});
|
|
131
|
+
} catch (error) {
|
|
132
|
+
failure = error;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const onwarn = (code) => {
|
|
137
|
+
// 校验和失败 / 无法识别的归档 / 解压比例异常都说明归档不完整或伪造。
|
|
138
|
+
if (code === 'TAR_BAD_ARCHIVE' || code === 'TAR_ENTRY_INVALID' || code === 'TAR_ABORT') {
|
|
139
|
+
warnings.push(code);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await tar.list({ file: tarball, gzip, onReadEntry, onwarn });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (failure) throw failure;
|
|
147
|
+
throw new ArchiveError('invalid-archive', `不是合法的 tar 归档: ${error.message}`, { cause: error });
|
|
148
|
+
}
|
|
149
|
+
if (failure) throw failure;
|
|
150
|
+
if (warnings.length > 0) {
|
|
151
|
+
throw new ArchiveError('invalid-archive', `归档有损坏条目: ${warnings[0]}`, { warnings });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (root === null) throw new ArchiveError('empty-archive', '归档里没有任何条目');
|
|
155
|
+
if (files === 0) throw new ArchiveError('empty-archive', '归档里没有普通文件');
|
|
156
|
+
return { root, files, dirs, bytes };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 解压后复核:不允许任何 symlink / 设备 / FIFO 落盘。 */
|
|
160
|
+
function verifyExtracted(dest) {
|
|
161
|
+
let files = 0;
|
|
162
|
+
let dirs = 0;
|
|
163
|
+
const walk = (dir) => {
|
|
164
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
165
|
+
const full = path.join(dir, entry.name);
|
|
166
|
+
const stat = fs.lstatSync(full);
|
|
167
|
+
if (stat.isSymbolicLink()) {
|
|
168
|
+
throw new ArchiveError('unsafe-entry', `解压后出现符号链接: ${full}`);
|
|
169
|
+
}
|
|
170
|
+
if (stat.isDirectory()) {
|
|
171
|
+
dirs += 1;
|
|
172
|
+
walk(full);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (stat.isFile()) {
|
|
176
|
+
files += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
throw new ArchiveError('unsafe-entry', `解压后出现特殊文件: ${full}`);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
walk(dest);
|
|
183
|
+
return { files, dirs };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 解压 tarball 到 `dest`。`dest` 必须是空目录或不存在(不存在会自动创建)。
|
|
188
|
+
* 成功返回 `{ dir, root, files, dirs, bytes }`;失败抛 ArchiveError,由调用方清理。
|
|
189
|
+
*/
|
|
190
|
+
export async function extractTarball({
|
|
191
|
+
tarball,
|
|
192
|
+
dest,
|
|
193
|
+
maxFiles = DEFAULT_MAX_FILES,
|
|
194
|
+
maxBytes = DEFAULT_MAX_EXTRACTED_BYTES,
|
|
195
|
+
gzip,
|
|
196
|
+
} = {}) {
|
|
197
|
+
if (typeof tarball !== 'string' || typeof dest !== 'string') {
|
|
198
|
+
throw new ArchiveError('invalid-argument', 'extractTarball 需要 tarball 与 dest 路径');
|
|
199
|
+
}
|
|
200
|
+
if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
|
|
201
|
+
throw new ArchiveError('invalid-argument', 'maxFiles 必须是正整数');
|
|
202
|
+
}
|
|
203
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
|
|
204
|
+
throw new ArchiveError('invalid-argument', 'maxBytes 必须是正数');
|
|
205
|
+
}
|
|
206
|
+
let stat;
|
|
207
|
+
try {
|
|
208
|
+
stat = fs.statSync(tarball);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new ArchiveError('invalid-archive', `tarball 不存在: ${tarball}`, { cause: error });
|
|
211
|
+
}
|
|
212
|
+
if (!stat.isFile()) throw new ArchiveError('invalid-archive', `tarball 不是文件: ${tarball}`);
|
|
213
|
+
|
|
214
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
215
|
+
const destStat = fs.lstatSync(dest);
|
|
216
|
+
if (destStat.isSymbolicLink() || !destStat.isDirectory()) {
|
|
217
|
+
throw new ArchiveError('invalid-dest', `staging 必须是真实目录: ${dest}`);
|
|
218
|
+
}
|
|
219
|
+
if (fs.readdirSync(dest).length > 0) {
|
|
220
|
+
throw new ArchiveError('dest-not-empty', `staging 目录必须为空: ${dest}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const compressed = gzip === undefined ? isGzipFile(tarball) : Boolean(gzip);
|
|
224
|
+
const scanned = await scanArchive({ tarball, gzip: compressed, maxFiles, maxBytes });
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await tar.extract({
|
|
228
|
+
file: tarball,
|
|
229
|
+
cwd: dest,
|
|
230
|
+
strip: 1,
|
|
231
|
+
gzip: compressed,
|
|
232
|
+
preservePaths: false,
|
|
233
|
+
});
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw new ArchiveError('extract-failed', `解压失败: ${error.message}`, { cause: error });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const verified = verifyExtracted(dest);
|
|
239
|
+
return {
|
|
240
|
+
dir: dest,
|
|
241
|
+
root: scanned.root,
|
|
242
|
+
files: verified.files,
|
|
243
|
+
dirs: verified.dirs,
|
|
244
|
+
bytes: scanned.bytes,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readSkillFrontmatter } from './skill-md.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 解压目录里的技能发现(纯只读,不落盘)。
|
|
7
|
+
*
|
|
8
|
+
* 规则与设计 §槽位一致:
|
|
9
|
+
* - 遍历(含 subpath 前缀)所有 `SKILL.md`,每个所在目录算一个技能;
|
|
10
|
+
* - 名字优先 frontmatter `name`,没有则用目录名,根级用仓库名;
|
|
11
|
+
* - 名字必须非空且不含 `/`、`\`、`..`、控制字符;
|
|
12
|
+
* - 重名直接报错,绝不静默择一;
|
|
13
|
+
* - 根级 `SKILL.md` 的 `skillPath` 是 `SKILL.md`,嵌套的是相对仓库根的 POSIX 路径。
|
|
14
|
+
*
|
|
15
|
+
* 返回 `[{ name, skillPath, dirPath, description }]`,按 name 排序。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
19
|
+
|
|
20
|
+
export class DiscoveryError extends Error {
|
|
21
|
+
constructor(code, message, extra = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'DiscoveryError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
Object.assign(this, extra);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toPosix(value) {
|
|
30
|
+
return value.split(path.sep).join('/');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeSubpath(subpath) {
|
|
34
|
+
if (subpath === null || subpath === undefined || subpath === '') return null;
|
|
35
|
+
if (typeof subpath !== 'string') throw new DiscoveryError('invalid-subpath', 'subpath 必须是字符串');
|
|
36
|
+
const cleaned = subpath.trim().replace(/^\/+|\/+$/g, '');
|
|
37
|
+
if (!cleaned) return null;
|
|
38
|
+
if (cleaned.includes('\\') || /^[A-Za-z]:/.test(cleaned)) {
|
|
39
|
+
throw new DiscoveryError('invalid-subpath', `subpath 不是 POSIX 相对路径: ${subpath}`);
|
|
40
|
+
}
|
|
41
|
+
const segments = cleaned.split('/');
|
|
42
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
43
|
+
throw new DiscoveryError('invalid-subpath', `subpath 含非法段: ${subpath}`);
|
|
44
|
+
}
|
|
45
|
+
return segments.join('/');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertSkillName(name, dirPath) {
|
|
49
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
50
|
+
throw new DiscoveryError('invalid-name', `技能名为空: ${dirPath}`, { dirPath });
|
|
51
|
+
}
|
|
52
|
+
if (name.length > 120) {
|
|
53
|
+
throw new DiscoveryError('invalid-name', `技能名过长: ${name}`, { name, dirPath });
|
|
54
|
+
}
|
|
55
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
56
|
+
throw new DiscoveryError('invalid-name', `技能名含路径分隔符: ${name}`, { name, dirPath });
|
|
57
|
+
}
|
|
58
|
+
// eslint-disable-next-line no-control-regex
|
|
59
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) {
|
|
60
|
+
throw new DiscoveryError('invalid-name', `技能名含控制字符: ${JSON.stringify(name)}`, { name, dirPath });
|
|
61
|
+
}
|
|
62
|
+
return name;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{ dir: string, subpath?: string|null, repoName?: string|null }} options
|
|
67
|
+
*/
|
|
68
|
+
export function discoverSkills({ dir, subpath = null, repoName = null } = {}) {
|
|
69
|
+
if (typeof dir !== 'string' || dir.length === 0) {
|
|
70
|
+
throw new DiscoveryError('invalid-dir', 'discoverSkills 需要解压目录');
|
|
71
|
+
}
|
|
72
|
+
const root = path.resolve(dir);
|
|
73
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
74
|
+
throw new DiscoveryError('invalid-dir', `不是目录: ${dir}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const normalizedSubpath = normalizeSubpath(subpath);
|
|
78
|
+
const scanRoot = normalizedSubpath ? path.join(root, ...normalizedSubpath.split('/')) : root;
|
|
79
|
+
let scanStat;
|
|
80
|
+
try {
|
|
81
|
+
scanStat = fs.statSync(scanRoot);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new DiscoveryError('subpath-not-found', `subpath 不存在: ${normalizedSubpath}`, {
|
|
84
|
+
subpath: normalizedSubpath,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (!scanStat.isDirectory()) {
|
|
88
|
+
throw new DiscoveryError('subpath-not-found', `subpath 不是目录: ${normalizedSubpath}`, {
|
|
89
|
+
subpath: normalizedSubpath,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fallbackRootName = repoName || path.basename(root);
|
|
94
|
+
const found = [];
|
|
95
|
+
|
|
96
|
+
const walk = (current) => {
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throw new DiscoveryError('read-failed', `无法读取 ${current}: ${error.message}`, { cause: error });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const skillFile = entries.find((entry) => entry.name === 'SKILL.md' && entry.isFile());
|
|
105
|
+
if (skillFile) {
|
|
106
|
+
const skillDir = current;
|
|
107
|
+
const skillMd = path.join(skillDir, 'SKILL.md');
|
|
108
|
+
const frontmatter = readSkillFrontmatter(skillDir);
|
|
109
|
+
const fallback = skillDir === root ? fallbackRootName : path.basename(skillDir);
|
|
110
|
+
const name = assertSkillName(
|
|
111
|
+
typeof frontmatter.name === 'string' && frontmatter.name.trim()
|
|
112
|
+
? frontmatter.name.trim()
|
|
113
|
+
: fallback,
|
|
114
|
+
skillDir,
|
|
115
|
+
);
|
|
116
|
+
found.push({
|
|
117
|
+
name,
|
|
118
|
+
skillPath: toPosix(path.relative(root, skillMd)),
|
|
119
|
+
dirPath: skillDir,
|
|
120
|
+
description: typeof frontmatter.description === 'string' ? frontmatter.description : null,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (!entry.isDirectory()) continue;
|
|
126
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
127
|
+
// 隐藏目录(.github、.vscode 等)里不会有技能;跳过也能避开 macOS 的 .DS_Store 目录
|
|
128
|
+
if (entry.name.startsWith('.')) continue;
|
|
129
|
+
walk(path.join(current, entry.name));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
walk(scanRoot);
|
|
134
|
+
|
|
135
|
+
const byName = new Map();
|
|
136
|
+
for (const skill of found) {
|
|
137
|
+
const previous = byName.get(skill.name);
|
|
138
|
+
if (previous) {
|
|
139
|
+
throw new DiscoveryError('duplicate-name', `发现重名技能 ${skill.name}`, {
|
|
140
|
+
name: skill.name,
|
|
141
|
+
paths: [previous.skillPath, skill.skillPath],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
byName.set(skill.name, skill);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return found.sort((a, b) => a.name.localeCompare(b.name));
|
|
148
|
+
}
|