dsh-plugin-snapshot 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 +22 -0
- package/README.en.md +45 -0
- package/README.md +85 -2
- package/cordis.patch.yml +5 -0
- package/lib/doctor.js +177 -0
- package/lib/index.js +303 -0
- package/lib/profile.js +95 -0
- package/lib/snapshot.js +149 -0
- package/lib/updates.js +92 -0
- package/lib/util.js +131 -0
- package/package.json +51 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 YG
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.en.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# dsh-plugin-snapshot
|
|
2
|
+
|
|
3
|
+
Pre-upgrade snapshots, one-step rollback and a static health check for DeepSeek Harness plugins.
|
|
4
|
+
|
|
5
|
+
The built-in plugin_manager already installs, removes and toggles plugins. This plugin adds what it does not: a verifiable recovery point before you change anything, a way back when an upgrade goes wrong, and a clear view of which plugins collide.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
dsh plugin add dsh-plugin-snapshot
|
|
10
|
+
|
|
11
|
+
Restart DSH (or let HMR pick it up), then the three tools are available.
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
### plugin_snapshot
|
|
16
|
+
|
|
17
|
+
Create, list and restore snapshots of the profile manifests (package.json, pnpm-lock.yaml, pnpm-workspace.yaml).
|
|
18
|
+
|
|
19
|
+
Every snapshot carries a manifest with the profile fingerprint, creation time, and per-file sha256. Restoring verifies the fingerprint first, so a snapshot from another profile is rejected; a hash mismatch only warns, because you may have edited the manifest by hand.
|
|
20
|
+
|
|
21
|
+
### plugin_updates
|
|
22
|
+
|
|
23
|
+
Read-only version check. Compares the version resolved in pnpm-lock.yaml against the registry and reports updatable / major / uptodate / prerelease / failed / manual. Prereleases are never suggested, and GitHub sources are always flagged for manual review.
|
|
24
|
+
|
|
25
|
+
### plugin_doctor
|
|
26
|
+
|
|
27
|
+
Static diagnostic: declared vs installed vs enabled, author-declared DSH compatibility (an explicit incompatible is reported as incompatible, never as probably fine), duplicate cordis.patch.yml ids, missing packages and unparseable manifests. The returned priority is a rule-weighted score, not a crash probability.
|
|
28
|
+
|
|
29
|
+
## Where data lives
|
|
30
|
+
|
|
31
|
+
~/.dsh-plugin-snapshot/snapshots/<timestamp>/
|
|
32
|
+
~/.dsh-plugin-snapshot/logs/
|
|
33
|
+
|
|
34
|
+
Nothing else is written except the three profile manifests on restore. node_modules and the bundles list are never touched, and DSH is never started or stopped.
|
|
35
|
+
|
|
36
|
+
## Limitations
|
|
37
|
+
|
|
38
|
+
- Snapshots cover the three manifests only, not node_modules
|
|
39
|
+
- Diagnostics are static evidence, not runtime verification
|
|
40
|
+
- No scheduler: the plugin only exists while DSH runs
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
|
45
|
+
|
package/README.md
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-plugin-snapshot
|
|
2
|
+
|
|
3
|
+
给 DeepSeek Harness 的插件做**升级前快照、一键回滚和静态体检**。
|
|
4
|
+
|
|
5
|
+
DSH 官方的 plugin_manager 已经能装、卸、启停插件;本插件只补它没有的那一块:**改动之前先存一份能验身份的恢复点,出问题能退回来,装完之后能看清谁跟谁冲突**。
|
|
6
|
+
|
|
7
|
+
## 为什么需要它
|
|
8
|
+
|
|
9
|
+
插件升级失败时最难受的不是报错,而是"清单已经改了、依赖没装完、不知道回到了什么状态"。本插件在每次升级前把 profile 的三份清单(package.json / pnpm-lock.yaml / pnpm-workspace.yaml)存成一份带指纹与哈希的快照,任何时候都能退回去。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
dsh plugin add dsh-plugin-snapshot
|
|
14
|
+
|
|
15
|
+
装好后重启 DSH(或启用 HMR 时自动生效),三个工具随即可用。
|
|
16
|
+
|
|
17
|
+
## 提供的工具
|
|
18
|
+
|
|
19
|
+
### plugin_snapshot
|
|
20
|
+
|
|
21
|
+
快照的创建、列表与恢复。
|
|
22
|
+
|
|
23
|
+
- action = "create":把当前 profile 的三份清单存成一份快照,返回快照名
|
|
24
|
+
- action = "list":列出全部快照(时间、依赖数、是否带 manifest)
|
|
25
|
+
- action = "restore" + name:恢复到指定快照;恢复前会先自动给现状再存一份
|
|
26
|
+
|
|
27
|
+
每份快照都带 manifest:profile 指纹、创建时间、每个文件的存在性与 sha256。恢复时会先校验指纹——**别的 profile 的快照会被拒绝**;文件摘要对不上只告警(你可能手工改过清单)。
|
|
28
|
+
|
|
29
|
+
### plugin_updates
|
|
30
|
+
|
|
31
|
+
只读的版本检测。
|
|
32
|
+
|
|
33
|
+
- 以 pnpm-lock.yaml 里解析出来的版本为基准,去 registry 查候选版本
|
|
34
|
+
- 输出每个插件的状态:updatable / major / uptodate / prerelease / failed / manual
|
|
35
|
+
- 预发布版本不会被建议升级;主版本变更、GitHub 源一律标成需人工确认
|
|
36
|
+
|
|
37
|
+
### plugin_doctor
|
|
38
|
+
|
|
39
|
+
静态体检。
|
|
40
|
+
|
|
41
|
+
- 已声明 vs 已安装 vs 已启用,三者分开列
|
|
42
|
+
- 按插件作者声明判定 DSH 版本兼容性:**明确写 incompatible 的判成不兼容,绝不显示成"大概率可用"**
|
|
43
|
+
- 重复的 cordis.patch.yml 行 id、缺失的包、无法解析的清单
|
|
44
|
+
- 给出"检查优先级"(规则加权,不是崩溃概率)
|
|
45
|
+
|
|
46
|
+
## 数据放在哪
|
|
47
|
+
|
|
48
|
+
~/.dsh-plugin-snapshot/snapshots/<时间戳>/
|
|
49
|
+
~/.dsh-plugin-snapshot/logs/
|
|
50
|
+
|
|
51
|
+
只写这两个目录,以及恢复时写回 profile 的三份清单。不碰 node_modules,不改 bundles 清单,不启动或关闭 DSH。
|
|
52
|
+
|
|
53
|
+
## 安全边界
|
|
54
|
+
|
|
55
|
+
- plugin_updates 与 plugin_doctor 是**纯只读**:只读文件、只发 GET 请求
|
|
56
|
+
- plugin_snapshot 的 create 只写自己的数据目录;restore 需要显式传 name,且只覆盖三份清单
|
|
57
|
+
- 不实现安装/卸载/启停——那是官方 plugin_manager 的职责,避免两套实现互相打架
|
|
58
|
+
|
|
59
|
+
## 配置
|
|
60
|
+
|
|
61
|
+
可选,写进 profile 的插件配置:
|
|
62
|
+
|
|
63
|
+
| 字段 | 默认 | 说明 |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| profileDir | 自动定位 | 显式指定 profile 目录 |
|
|
66
|
+
| registry | registry.npmjs.org | 检测用的源,也可用 DSH_REGISTRY |
|
|
67
|
+
| dataDir | ~/.dsh-plugin-snapshot | 快照与日志目录 |
|
|
68
|
+
| snapshotsKeep | 7 | 保留份数 |
|
|
69
|
+
| concurrency | 8 | 检测并发 |
|
|
70
|
+
|
|
71
|
+
profile 定位顺序:配置 > 环境变量 DSH_PROFILE_DIR > ~/.dsh/profiles/desktop > 扫描 profiles 目录取最近修改的。
|
|
72
|
+
|
|
73
|
+
## 已知限制
|
|
74
|
+
|
|
75
|
+
- 快照只覆盖三份清单,不含 node_modules。恢复之后仍需让包管理器重新安装依赖
|
|
76
|
+
- 诊断基于静态扫描与作者声明,不代表运行时验证
|
|
77
|
+
- 不做定时任务:插件只在 DSH 运行时存在
|
|
78
|
+
|
|
79
|
+
## 开发
|
|
80
|
+
|
|
81
|
+
npm test # 5 项自测,用 stub 顶替 @deepseek-ai/dsh-tools
|
|
82
|
+
|
|
83
|
+
## 许可
|
|
84
|
+
|
|
85
|
+
MIT
|
|
2
86
|
|
|
3
|
-
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|
package/cordis.patch.yml
ADDED
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 诊断:静态扫描已安装插件,给出兼容性、互斥与检查优先级。
|
|
3
|
+
* 只读,不修改任何文件;结论都标注为「静态线索」,不是运行时验证。
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { readProfile, readLockVersions } from './profile.js';
|
|
8
|
+
import { parseSemver, cmpSemver, shortVer } from './util.js';
|
|
9
|
+
|
|
10
|
+
const MAX_JSON = 4 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
/** 兼容性判定:否定声明优先于肯定声明(incompatible 绝不能被读成兼容) */
|
|
13
|
+
export function judgeCompatibility(pkg, dshVersion) {
|
|
14
|
+
const cur = parseSemver(dshVersion);
|
|
15
|
+
const dshField = (pkg && pkg.dsh) || {};
|
|
16
|
+
const rel = (dshField.compatibility && dshField.compatibility.dshReleases) || null;
|
|
17
|
+
const out = { state: 'unknown', label: '未声明', detail: '', declared: [], current: dshVersion };
|
|
18
|
+
|
|
19
|
+
if (rel && typeof rel === 'object') {
|
|
20
|
+
const keys = Object.keys(rel);
|
|
21
|
+
out.declared = keys.map((k) => k + ' -> ' + rel[k]);
|
|
22
|
+
let denyKey = null;
|
|
23
|
+
let allowExact = null;
|
|
24
|
+
let allowOpen = false;
|
|
25
|
+
for (const k of keys) {
|
|
26
|
+
const raw = String(k).trim();
|
|
27
|
+
const isOpen = raw.slice(-1) === '+';
|
|
28
|
+
const ver = isOpen ? raw.slice(0, -1) : raw;
|
|
29
|
+
const val = String(rel[k] === undefined ? '' : rel[k]).toLowerCase().trim();
|
|
30
|
+
const isDeny = /^(in|not|no|un|non|unsupported)/.test(val);
|
|
31
|
+
const isPartial = val.indexOf('partial') === 0;
|
|
32
|
+
const isAllow = /compat|^full$|^yes$|^true$|^supported$|^ok$/.test(val);
|
|
33
|
+
if (!isDeny && !isPartial && !isAllow) continue;
|
|
34
|
+
if (!cur) continue;
|
|
35
|
+
const covers = isOpen ? (!!parseSemver(ver) && cmpSemver(dshVersion, ver) >= 0) : (cmpSemver(dshVersion, ver) === 0);
|
|
36
|
+
if (!covers) continue;
|
|
37
|
+
if (isDeny || isPartial) denyKey = raw + ' = ' + rel[k];
|
|
38
|
+
else if (isOpen) allowOpen = true;
|
|
39
|
+
else allowExact = ver;
|
|
40
|
+
}
|
|
41
|
+
if (denyKey) {
|
|
42
|
+
out.state = 'incompatible';
|
|
43
|
+
out.label = '明确不兼容';
|
|
44
|
+
out.detail = '作者显式声明 ' + denyKey + ',当前 DSH ' + dshVersion + ' 不在支持范围';
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
if (allowExact) { out.state = 'compatible'; out.label = '已验证兼容'; out.detail = '显式声明 ' + allowExact + ' 兼容'; return out; }
|
|
48
|
+
if (allowOpen) { out.state = 'compatible'; out.label = '区间兼容'; out.detail = '当前版本落在声明的开放区间内'; return out; }
|
|
49
|
+
out.state = 'untested';
|
|
50
|
+
out.label = '未验证';
|
|
51
|
+
out.detail = '声明范围不含 ' + (dshVersion || '未知版本') + ';风险未知,建议先在测试环境验证';
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
if (pkg && pkg.engines && pkg.engines.dsh) {
|
|
55
|
+
out.state = 'engines-only';
|
|
56
|
+
out.label = '仅 engines 约束';
|
|
57
|
+
out.detail = 'engines.dsh = ' + pkg.engines.dsh + '(作者未声明 compatibility)';
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
out.detail = '插件未声明任何 DSH 版本兼容信息';
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function patchIds(text) {
|
|
65
|
+
const ids = [];
|
|
66
|
+
let inInsert = false;
|
|
67
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
68
|
+
const line = raw.replace(/\s+$/, '');
|
|
69
|
+
if (/^\s*-?\s*insert:\s*$/.test(line)) { inInsert = true; continue; }
|
|
70
|
+
if (!inInsert) continue;
|
|
71
|
+
if (/^\s*$/.test(line) || /^\s*#/.test(line)) continue;
|
|
72
|
+
const m = /^\s*-?\s*id:\s*([^\s#]+)\s*$/.exec(line);
|
|
73
|
+
if (m) { ids.push(m[1]); continue; }
|
|
74
|
+
if (/^\s*-?\s*[a-zA-Z_]+:/.test(line) && !/^\s{4,}/.test(line)) inInsert = false;
|
|
75
|
+
}
|
|
76
|
+
return Array.from(new Set(ids));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hasNative(dir) {
|
|
80
|
+
const walk = (d, depth) => {
|
|
81
|
+
if (depth > 2) return false;
|
|
82
|
+
let items = [];
|
|
83
|
+
try { items = readdirSync(d, { withFileTypes: true }); } catch (e) { return false; }
|
|
84
|
+
for (const e of items) {
|
|
85
|
+
if (e.isFile() && e.name.endsWith('.node')) return true;
|
|
86
|
+
if (e.isDirectory() && /^(build|prebuilds|native)$/.test(e.name) && walk(join(d, e.name), depth + 1)) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
};
|
|
90
|
+
return walk(dir, 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function scanPlugins(profileDir, dshVersion) {
|
|
94
|
+
const prof = readProfile(profileDir);
|
|
95
|
+
const lock = readLockVersions(profileDir);
|
|
96
|
+
const nm = join(profileDir, 'node_modules');
|
|
97
|
+
const items = [];
|
|
98
|
+
for (const name of Object.keys(prof.deps)) {
|
|
99
|
+
const dir = join(nm, ...name.split('/'));
|
|
100
|
+
const enabled = prof.bundles.indexOf(name) >= 0;
|
|
101
|
+
const pkgFile = join(dir, 'package.json');
|
|
102
|
+
if (!existsSync(pkgFile)) {
|
|
103
|
+
items.push({ name, enabled, installed: false, error: '已声明但未安装(node_modules 缺失)', declared: String(prof.deps[name]) });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
let pkg = {};
|
|
107
|
+
let error = '';
|
|
108
|
+
try {
|
|
109
|
+
if (statSync(pkgFile).size > MAX_JSON) error = 'package.json 过大,已跳过解析';
|
|
110
|
+
else pkg = JSON.parse(readFileSync(pkgFile, 'utf8'));
|
|
111
|
+
} catch (e) { error = 'package.json 无法解析'; }
|
|
112
|
+
const patchFile = join(dir, 'cordis.patch.yml');
|
|
113
|
+
let ids = [];
|
|
114
|
+
let hasPatch = false;
|
|
115
|
+
if (existsSync(patchFile)) {
|
|
116
|
+
hasPatch = true;
|
|
117
|
+
try { ids = patchIds(readFileSync(patchFile, 'utf8')); } catch (e) { /* 忽略 */ }
|
|
118
|
+
}
|
|
119
|
+
const rawInject = (pkg.dsh && pkg.dsh.client && pkg.dsh.client.inject);
|
|
120
|
+
items.push({
|
|
121
|
+
name,
|
|
122
|
+
enabled,
|
|
123
|
+
installed: true,
|
|
124
|
+
version: pkg.version || '?',
|
|
125
|
+
declared: String(prof.deps[name]),
|
|
126
|
+
resolved: lock[name] || '',
|
|
127
|
+
hasPatch,
|
|
128
|
+
patchIds: ids,
|
|
129
|
+
hasNative: hasNative(dir),
|
|
130
|
+
clientInject: Array.isArray(rawInject) ? rawInject.filter((x) => typeof x === 'string') : [],
|
|
131
|
+
compatibility: judgeCompatibility(pkg, dshVersion),
|
|
132
|
+
error: error || undefined
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return { items, profile: prof.name };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function detectConflicts(items) {
|
|
139
|
+
const conflicts = [];
|
|
140
|
+
const byPatch = new Map();
|
|
141
|
+
for (const it of items) {
|
|
142
|
+
for (const id of it.patchIds || []) {
|
|
143
|
+
if (!byPatch.has(id)) byPatch.set(id, []);
|
|
144
|
+
byPatch.get(id).push(it.name);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const [id, owners] of byPatch) {
|
|
148
|
+
if (owners.length > 1) conflicts.push({ severity: 'critical', kind: 'patch-id', message: 'patch 行标识 ' + id + ' 被 ' + owners.length + ' 个插件占用', plugins: owners });
|
|
149
|
+
}
|
|
150
|
+
for (const it of items) {
|
|
151
|
+
const c = it.compatibility || {};
|
|
152
|
+
if (c.state === 'incompatible') conflicts.push({ severity: 'critical', kind: 'compat', message: it.name + ':' + c.detail, plugins: [it.name] });
|
|
153
|
+
else if (c.state === 'untested') conflicts.push({ severity: 'high', kind: 'compat', message: it.name + ':' + c.detail, plugins: [it.name] });
|
|
154
|
+
if (it.installed === false) conflicts.push({ severity: 'high', kind: 'missing', message: it.name + ':' + it.error, plugins: [it.name] });
|
|
155
|
+
if (it.error) conflicts.push({ severity: 'medium', kind: 'manifest', message: it.name + ':' + it.error, plugins: [it.name] });
|
|
156
|
+
}
|
|
157
|
+
const rank = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
158
|
+
conflicts.sort((a, b) => (rank[a.severity] - rank[b.severity]) || (a.kind < b.kind ? -1 : 1));
|
|
159
|
+
return conflicts;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 检查优先级(规则加权,不是崩溃概率) */
|
|
163
|
+
export function priorityOf(item, conflicts) {
|
|
164
|
+
let score = 0;
|
|
165
|
+
const factors = [];
|
|
166
|
+
const mine = conflicts.filter((c) => c.plugins.indexOf(item.name) >= 0);
|
|
167
|
+
for (const c of mine) {
|
|
168
|
+
if (c.severity === 'critical') { score += 45; factors.push('严重:' + c.kind); }
|
|
169
|
+
else if (c.severity === 'high') { score += 25; factors.push('高:' + c.kind); }
|
|
170
|
+
else { score += 12; factors.push('中:' + c.kind); }
|
|
171
|
+
}
|
|
172
|
+
if (item.hasNative) { score += 8; factors.push('含原生模块'); }
|
|
173
|
+
score = Math.max(0, Math.min(100, score));
|
|
174
|
+
const level = score >= 60 ? '高' : score >= 30 ? '中' : score >= 10 ? '低' : '未发现已知风险';
|
|
175
|
+
return { score, level, factors };
|
|
176
|
+
}
|
|
177
|
+
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-snapshot — 插件快照、回滚与诊断。
|
|
3
|
+
*
|
|
4
|
+
* 设计边界(刻意保持窄):
|
|
5
|
+
* - 只做三件事:检测可更新项、快照/回滚 profile 清单、静态诊断;
|
|
6
|
+
* - 不安装、不卸载、不启停插件——那是官方 plugin_manager 的职责,不重复实现;
|
|
7
|
+
* - 所有写操作只发生在自己的数据目录与 profile 的三份清单文件上。
|
|
8
|
+
*/
|
|
9
|
+
/*
|
|
10
|
+
* defineTool 由宿主提供。这里做成「优先官方、缺失时降级」:
|
|
11
|
+
* 一旦解析不到就整插件加载失败,代价远大于少一层参数校验。
|
|
12
|
+
* 降级后工具仍按同一契约注册(name/description/parameters/output/execute),
|
|
13
|
+
* 只是不再享受官方 schema 校验;正常情况下走的一定是官方实现。
|
|
14
|
+
*/
|
|
15
|
+
let defineTool;
|
|
16
|
+
try {
|
|
17
|
+
({ defineTool } = await import('@deepseek-ai/dsh-tools'));
|
|
18
|
+
} catch (e) {
|
|
19
|
+
defineTool = null;
|
|
20
|
+
}
|
|
21
|
+
if (typeof defineTool !== 'function') {
|
|
22
|
+
defineTool = (options) => options;
|
|
23
|
+
}
|
|
24
|
+
import { resolveProfileDir, readProfile } from './profile.js';
|
|
25
|
+
import { createSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
|
|
26
|
+
import { checkUpdates } from './updates.js';
|
|
27
|
+
import { scanPlugins, detectConflicts, priorityOf } from './doctor.js';
|
|
28
|
+
import { Logger, dataDir } from './util.js';
|
|
29
|
+
|
|
30
|
+
export const name = 'dsh-plugin-snapshot';
|
|
31
|
+
|
|
32
|
+
export const inject = ['tools'];
|
|
33
|
+
|
|
34
|
+
const TOOL = {
|
|
35
|
+
updates: 'plugin_updates',
|
|
36
|
+
snapshot: 'plugin_snapshot',
|
|
37
|
+
doctor: 'plugin_doctor'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function makeCtx(config) {
|
|
41
|
+
const cfg = config || {};
|
|
42
|
+
return {
|
|
43
|
+
profileDir: cfg.profileDir || '',
|
|
44
|
+
registry: cfg.registry || '',
|
|
45
|
+
dataDir: cfg.dataDir || dataDir(),
|
|
46
|
+
snapshotsKeep: cfg.snapshotsKeep || 7,
|
|
47
|
+
concurrency: cfg.concurrency || 8,
|
|
48
|
+
logger: new Logger()
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function needProfile(cfg) {
|
|
53
|
+
const dir = resolveProfileDir(cfg);
|
|
54
|
+
if (!dir) {
|
|
55
|
+
throw new Error('没能定位 DSH profile 目录。可在插件配置里指定 profileDir,或设置 DSH_PROFILE_DIR 环境变量。');
|
|
56
|
+
}
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderLines(title, lines) {
|
|
61
|
+
return [{ type: 'text', text: title + '\n' + lines.join('\n') }];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function apply(ctx, config) {
|
|
65
|
+
const cfg = makeCtx(config);
|
|
66
|
+
|
|
67
|
+
ctx.inject(['tools'], (sctx) => {
|
|
68
|
+
sctx.tools.register(defineTool({
|
|
69
|
+
name: TOOL.updates,
|
|
70
|
+
description: 'List DeepSeek Harness profile plugins that have a newer npm version. Read-only: compares the version resolved in pnpm-lock.yaml with the registry, and never installs anything. Returns per-plugin state (updatable / major / uptodate / prerelease / failed) plus a summary.',
|
|
71
|
+
parameters: {
|
|
72
|
+
registry: { type: 'string', description: 'npm registry to query. Defaults to the plugin config, DSH_REGISTRY, or registry.npmjs.org.' },
|
|
73
|
+
onlyUpdatable: { type: 'boolean', description: 'When true, return only items that have a newer version.' }
|
|
74
|
+
},
|
|
75
|
+
output: {
|
|
76
|
+
schema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
properties: {
|
|
80
|
+
profile: { type: 'string' },
|
|
81
|
+
registry: { type: 'string' },
|
|
82
|
+
summary: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
properties: {
|
|
86
|
+
total: { type: 'number' },
|
|
87
|
+
updatable: { type: 'number' },
|
|
88
|
+
major: { type: 'number' },
|
|
89
|
+
uptodate: { type: 'number' },
|
|
90
|
+
failed: { type: 'number' },
|
|
91
|
+
skipped: { type: 'number' }
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
items: {
|
|
95
|
+
type: 'array',
|
|
96
|
+
items: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
properties: {
|
|
100
|
+
name: { type: 'string' },
|
|
101
|
+
declared: { type: 'string' },
|
|
102
|
+
resolved: { type: 'string' },
|
|
103
|
+
candidate: { type: 'string' },
|
|
104
|
+
state: { type: 'string' },
|
|
105
|
+
reason: { type: 'string' }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
render(args, value) {
|
|
112
|
+
const s = value.summary || {};
|
|
113
|
+
const rows = (value.items || []).map((x) => '- ' + x.name + ' ' + (x.resolved || x.declared || '?') + ' -> ' + (x.candidate || '-') + ' [' + x.state + ']' + (x.reason ? ' ' + x.reason : ''));
|
|
114
|
+
return renderLines('Plugin updates (profile: ' + value.profile + ', registry: ' + value.registry + ')', [
|
|
115
|
+
'total ' + s.total + ' | updatable ' + s.updatable + ' | major ' + s.major + ' | uptodate ' + s.uptodate + ' | failed ' + s.failed,
|
|
116
|
+
...(rows.length ? rows : ['- nothing to show'])
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
async execute(args) {
|
|
121
|
+
const a = args || {};
|
|
122
|
+
if (a.registry) cfg.registry = String(a.registry);
|
|
123
|
+
const dir = needProfile(cfg);
|
|
124
|
+
cfg.logger.info('check updates in ' + dir);
|
|
125
|
+
const r = await checkUpdates(dir, cfg);
|
|
126
|
+
const items = a.onlyUpdatable ? r.items.filter((x) => x.state === 'updatable') : r.items;
|
|
127
|
+
return { profile: r.profile, registry: r.registry, summary: r.summary, items: items.map((x) => ({
|
|
128
|
+
name: x.name, declared: x.declared, resolved: x.resolved || '', candidate: x.candidate || '', state: x.state, reason: x.reason || ''
|
|
129
|
+
})) };
|
|
130
|
+
}
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
sctx.tools.register(defineTool({
|
|
134
|
+
name: TOOL.snapshot,
|
|
135
|
+
description: 'Create, list or restore a snapshot of the current DSH profile manifests (package.json, pnpm-lock.yaml, pnpm-workspace.yaml). Snapshots live in the plugin data directory, carry an identity fingerprint and per-file hashes, and are the recovery point to use before upgrading plugins. Actions: create / list / restore.',
|
|
136
|
+
parameters: {
|
|
137
|
+
action: { type: 'string', enum: ['create', 'list', 'restore'], description: 'create a snapshot, list existing ones, or restore a named one.' },
|
|
138
|
+
name: { type: 'string', description: 'Snapshot name for action=restore, as returned by action=list.' }
|
|
139
|
+
},
|
|
140
|
+
output: {
|
|
141
|
+
schema: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
additionalProperties: false,
|
|
144
|
+
properties: {
|
|
145
|
+
action: { type: 'string' },
|
|
146
|
+
created: { type: 'string' },
|
|
147
|
+
restored: { type: 'array', items: { type: 'string' } },
|
|
148
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
149
|
+
snapshots: {
|
|
150
|
+
type: 'array',
|
|
151
|
+
items: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
additionalProperties: false,
|
|
154
|
+
properties: {
|
|
155
|
+
name: { type: 'string' },
|
|
156
|
+
createdAt: { type: 'string' },
|
|
157
|
+
deps: { type: 'number' },
|
|
158
|
+
hasManifest: { type: 'boolean' }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
render(args, value) {
|
|
165
|
+
if (value.action === 'list') {
|
|
166
|
+
const rows = (value.snapshots || []).map((s) => '- ' + s.name + ' ' + s.createdAt + ' deps ' + s.deps + (s.hasManifest ? '' : ' (无 manifest)'));
|
|
167
|
+
return renderLines('Snapshots', rows.length ? rows : ['- 还没有快照']);
|
|
168
|
+
}
|
|
169
|
+
if (value.action === 'restore') {
|
|
170
|
+
return renderLines('Restored from ' + value.created, [
|
|
171
|
+
'files: ' + (value.restored || []).join(', '),
|
|
172
|
+
...(value.warnings || []).map((w) => 'warning: ' + w)
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
return renderLines('Snapshot created: ' + value.created, ['先记下这个名字,回滚时用它。']);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
async execute(args) {
|
|
179
|
+
const a = args || {};
|
|
180
|
+
const dir = needProfile(cfg);
|
|
181
|
+
const action = String(a.action || 'list');
|
|
182
|
+
if (action === 'list') {
|
|
183
|
+
const list = listSnapshots(cfg).map((s) => ({
|
|
184
|
+
name: s.name,
|
|
185
|
+
createdAt: new Date(s.mtime).toISOString(),
|
|
186
|
+
deps: s.deps,
|
|
187
|
+
hasManifest: !!s.hasManifest
|
|
188
|
+
}));
|
|
189
|
+
return { action: 'list', snapshots: list };
|
|
190
|
+
}
|
|
191
|
+
if (action === 'create') {
|
|
192
|
+
const s = createSnapshot(dir, cfg);
|
|
193
|
+
cfg.logger.info('snapshot created: ' + s.name);
|
|
194
|
+
return { action: 'create', created: s.name };
|
|
195
|
+
}
|
|
196
|
+
if (action === 'restore') {
|
|
197
|
+
const nm = String(a.name || '').trim();
|
|
198
|
+
if (!nm) throw new Error('action=restore 需要提供 name(先执行 action=list 查看)');
|
|
199
|
+
const r = restoreSnapshot(dir, cfg, nm);
|
|
200
|
+
cfg.logger.warn('snapshot restored: ' + nm);
|
|
201
|
+
return { action: 'restore', created: r.from, restored: r.restored, warnings: r.warnings };
|
|
202
|
+
}
|
|
203
|
+
throw new Error('未知 action: ' + action + '(可用 create / list / restore)');
|
|
204
|
+
}
|
|
205
|
+
}));
|
|
206
|
+
|
|
207
|
+
sctx.tools.register(defineTool({
|
|
208
|
+
name: TOOL.doctor,
|
|
209
|
+
description: 'Static diagnostic of installed DeepSeek Harness plugins: declared vs installed vs enabled state, DSH version compatibility as declared by the author (explicit incompatible is reported as incompatible, never as compatible), duplicate cordis.patch.yml ids, native modules and unparseable manifests. Read-only; findings are static evidence, not runtime verification.',
|
|
210
|
+
parameters: {
|
|
211
|
+
dshVersion: { type: 'string', description: 'DSH version to judge compatibility against. Defaults to DSH_VERSION env or unknown.' },
|
|
212
|
+
limit: { type: 'number', description: 'Max conflicts to return (default 30).' }
|
|
213
|
+
},
|
|
214
|
+
output: {
|
|
215
|
+
schema: {
|
|
216
|
+
type: 'object',
|
|
217
|
+
additionalProperties: false,
|
|
218
|
+
properties: {
|
|
219
|
+
profile: { type: 'string' },
|
|
220
|
+
dshVersion: { type: 'string' },
|
|
221
|
+
summary: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
additionalProperties: false,
|
|
224
|
+
properties: {
|
|
225
|
+
installed: { type: 'number' },
|
|
226
|
+
enabled: { type: 'number' },
|
|
227
|
+
missing: { type: 'number' },
|
|
228
|
+
incompatible: { type: 'number' },
|
|
229
|
+
untested: { type: 'number' },
|
|
230
|
+
native: { type: 'number' }
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
conflicts: {
|
|
234
|
+
type: 'array',
|
|
235
|
+
items: {
|
|
236
|
+
type: 'object',
|
|
237
|
+
additionalProperties: false,
|
|
238
|
+
properties: {
|
|
239
|
+
severity: { type: 'string' },
|
|
240
|
+
kind: { type: 'string' },
|
|
241
|
+
message: { type: 'string' }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
topPriority: {
|
|
246
|
+
type: 'array',
|
|
247
|
+
items: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
additionalProperties: false,
|
|
250
|
+
properties: {
|
|
251
|
+
name: { type: 'string' },
|
|
252
|
+
score: { type: 'number' },
|
|
253
|
+
level: { type: 'string' }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
render(args, value) {
|
|
260
|
+
const s = value.summary || {};
|
|
261
|
+
const head = 'installed ' + s.installed + ' | enabled ' + s.enabled + ' | missing ' + s.missing + ' | incompatible ' + s.incompatible + ' | untested ' + s.untested + ' | native ' + s.native;
|
|
262
|
+
const rows = (value.conflicts || []).map((c) => '- [' + c.severity + '] ' + c.kind + ': ' + c.message);
|
|
263
|
+
return renderLines('Plugin diagnostic (profile: ' + value.profile + ', DSH ' + value.dshVersion + ')', [head, ...(rows.length ? rows : ['- 未发现已知冲突'])]);
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
async execute(args) {
|
|
267
|
+
const a = args || {};
|
|
268
|
+
const dir = needProfile(cfg);
|
|
269
|
+
const dshVersion = String(a.dshVersion || process.env.DSH_VERSION || 'unknown');
|
|
270
|
+
const scan = scanPlugins(dir, dshVersion);
|
|
271
|
+
const conflicts = detectConflicts(scan.items);
|
|
272
|
+
const prior = scan.items
|
|
273
|
+
.map((it) => ({ name: it.name, ...priorityOf(it, conflicts) }))
|
|
274
|
+
.sort((x, y) => y.score - x.score)
|
|
275
|
+
.slice(0, 5)
|
|
276
|
+
.map((x) => ({ name: x.name, score: x.score, level: x.level }));
|
|
277
|
+
const limit = Math.max(1, Math.min(Number(a.limit) || 30, 200));
|
|
278
|
+
const summary = {
|
|
279
|
+
installed: scan.items.filter((x) => x.installed !== false).length,
|
|
280
|
+
enabled: scan.items.filter((x) => x.enabled).length,
|
|
281
|
+
missing: scan.items.filter((x) => x.installed === false).length,
|
|
282
|
+
incompatible: scan.items.filter((x) => (x.compatibility || {}).state === 'incompatible').length,
|
|
283
|
+
untested: scan.items.filter((x) => (x.compatibility || {}).state === 'untested').length,
|
|
284
|
+
native: scan.items.filter((x) => x.hasNative).length
|
|
285
|
+
};
|
|
286
|
+
return {
|
|
287
|
+
profile: scan.profile,
|
|
288
|
+
dshVersion,
|
|
289
|
+
summary,
|
|
290
|
+
conflicts: conflicts.slice(0, limit).map((c) => ({ severity: c.severity, kind: c.kind, message: c.message })),
|
|
291
|
+
topPriority: prior
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}));
|
|
295
|
+
|
|
296
|
+
ctx.logger?.info?.('[dsh-plugin-snapshot] tools registered on ' + needProfileSafe(cfg));
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
function needProfileSafe(c) {
|
|
300
|
+
try { return resolveProfileDir(c) || '(未定位)'; } catch (e) { return '(未定位)'; }
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
package/lib/profile.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile 定位与解析。
|
|
3
|
+
* 插件运行在 DSH 进程内,profile 目录按「显式配置 > 环境变量 > 标准路径 > 扫描」的顺序确定,
|
|
4
|
+
* 任一步失败都不会抛异常,只返回 null 让上层给出可读错误。
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
const PROFILE_FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'];
|
|
11
|
+
|
|
12
|
+
export function profilesRoot() {
|
|
13
|
+
return join(homedir(), '.dsh', 'profiles');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 定位一个可用的 profile 目录 */
|
|
17
|
+
export function resolveProfileDir(cfg) {
|
|
18
|
+
const cands = [];
|
|
19
|
+
if (cfg && typeof cfg.profileDir === 'string' && cfg.profileDir) cands.push(cfg.profileDir);
|
|
20
|
+
for (const k of ['DSH_PROFILE_DIR', 'DSH_PROFILE_PATH', 'DSH_PROFILE']) {
|
|
21
|
+
if (process.env[k]) cands.push(String(process.env[k]));
|
|
22
|
+
}
|
|
23
|
+
cands.push(join(profilesRoot(), 'desktop'));
|
|
24
|
+
try {
|
|
25
|
+
const base = profilesRoot();
|
|
26
|
+
const dirs = readdirSync(base, { withFileTypes: true })
|
|
27
|
+
.filter((e) => e.isDirectory())
|
|
28
|
+
.map((e) => ({ dir: join(base, e.name), mtime: statSync(join(base, e.name)).mtimeMs }))
|
|
29
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
30
|
+
for (const d of dirs) cands.push(d.dir);
|
|
31
|
+
} catch (e) { /* 没有 profiles 目录 */ }
|
|
32
|
+
for (const c of cands) {
|
|
33
|
+
if (c && existsSync(join(c, 'package.json'))) return c;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 读 profile 清单与启用状态 */
|
|
39
|
+
export function readProfile(dir) {
|
|
40
|
+
const p = join(dir, 'package.json');
|
|
41
|
+
if (!existsSync(p)) throw new Error('找不到 profile 清单: ' + p);
|
|
42
|
+
let manifest = null;
|
|
43
|
+
try { manifest = JSON.parse(readFileSync(p, 'utf8')); }
|
|
44
|
+
catch (e) { throw new Error('profile 清单无法解析: ' + p); }
|
|
45
|
+
const deps = (manifest && manifest.dependencies) || {};
|
|
46
|
+
const bundles = (manifest && manifest.dsh && manifest.dsh.profile && manifest.dsh.profile.bundles) || [];
|
|
47
|
+
return { path: p, manifest, deps, bundles, name: manifest.name || '' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 从 pnpm-lock.yaml 的 importers 段读直接依赖的解析版本 */
|
|
51
|
+
export function parseLockVersions(lockText) {
|
|
52
|
+
const out = {};
|
|
53
|
+
const lines = String(lockText || '').split(/\r?\n/);
|
|
54
|
+
let inImporters = false;
|
|
55
|
+
let inDeps = false;
|
|
56
|
+
let depIndent = -1;
|
|
57
|
+
let curName = null;
|
|
58
|
+
for (const raw of lines) {
|
|
59
|
+
if (/^importers:\s*$/.test(raw)) { inImporters = true; continue; }
|
|
60
|
+
if (inImporters && /^[a-zA-Z]/.test(raw)) { inImporters = false; continue; }
|
|
61
|
+
if (!inImporters) continue;
|
|
62
|
+
if (/^\s+dependencies:\s*$/.test(raw)) { inDeps = true; depIndent = raw.indexOf('dependencies:'); curName = null; continue; }
|
|
63
|
+
if (inDeps && /^\s+(devDependencies|optionalDependencies):\s*$/.test(raw)) { inDeps = false; continue; }
|
|
64
|
+
if (!inDeps) continue;
|
|
65
|
+
const indent = raw.length - raw.trimStart().length;
|
|
66
|
+
const t = raw.trim();
|
|
67
|
+
if (!t) continue;
|
|
68
|
+
if (indent === depIndent + 2 && /^[^:]+:\s*$/.test(t)) {
|
|
69
|
+
curName = t.slice(0, -1).trim().replace(/^['"]|['"]$/g, '');
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (curName && indent === depIndent + 4) {
|
|
73
|
+
const m = /^version:\s*(.+)$/.exec(t);
|
|
74
|
+
if (m) { out[curName] = m[1].trim().replace(/^['"]|['"]$/g, ''); curName = null; }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readLockVersions(dir) {
|
|
81
|
+
const p = join(dir, 'pnpm-lock.yaml');
|
|
82
|
+
if (!existsSync(p)) return {};
|
|
83
|
+
try { return parseLockVersions(readFileSync(p, 'utf8')); } catch (e) { return {}; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 依赖的分类:registry / github / local */
|
|
87
|
+
export function specKind(spec) {
|
|
88
|
+
const s = String(spec || '');
|
|
89
|
+
if (/^(github:|git\+https?:|https?:\/\/(codeload\.)?github\.com)/i.test(s)) return 'github';
|
|
90
|
+
if (/^(file:|link:|\.\/|\.\.\/)/.test(s)) return 'local';
|
|
91
|
+
return 'registry';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { PROFILE_FILES };
|
|
95
|
+
|
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 快照:把 profile 的三份清单备份到插件自己的数据目录,支持列表与恢复。
|
|
3
|
+
* 设计要点(与外部工具版一致,都是踩过坑之后的结论):
|
|
4
|
+
* - 一份都没备份到就抛错,不返回空快照;
|
|
5
|
+
* - 每份写 manifest(profile 指纹、创建时间、每文件摘要);
|
|
6
|
+
* - 清理时保护正在使用的快照,按创建时间排序;
|
|
7
|
+
* - 恢复前校验 profile 身份,摘要不符只告警。
|
|
8
|
+
*/
|
|
9
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
10
|
+
import { join, basename } from 'node:path';
|
|
11
|
+
import { atomicWrite, sha256, tsStr, isValidSnapName, dataDir, readJson } from './util.js';
|
|
12
|
+
|
|
13
|
+
const FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'];
|
|
14
|
+
const MANIFEST = 'manifest.json';
|
|
15
|
+
|
|
16
|
+
export function snapRoot(cfg) {
|
|
17
|
+
const base = (cfg && cfg.dataDir) ? cfg.dataDir : dataDir();
|
|
18
|
+
return join(base, 'snapshots');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function profileId(dir) {
|
|
22
|
+
return sha256(Buffer.from(String(dir))).slice(0, 16);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function uniqueDir(base) {
|
|
26
|
+
const stamp = tsStr();
|
|
27
|
+
let dir = join(base, stamp);
|
|
28
|
+
let i = 2;
|
|
29
|
+
while (existsSync(dir)) { dir = join(base, stamp + '-' + i); i++; }
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createSnapshot(profileDir, cfg) {
|
|
34
|
+
const base = snapRoot(cfg);
|
|
35
|
+
mkdirSync(base, { recursive: true });
|
|
36
|
+
const dir = uniqueDir(base);
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
const saved = [];
|
|
39
|
+
for (const f of FILES) {
|
|
40
|
+
const src = join(profileDir, f);
|
|
41
|
+
if (!existsSync(src)) continue;
|
|
42
|
+
copyFileSync(src, join(dir, f));
|
|
43
|
+
saved.push(f);
|
|
44
|
+
}
|
|
45
|
+
if (!saved.length) {
|
|
46
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch (e) { /* 忽略 */ }
|
|
47
|
+
throw new Error('无法创建快照:profile 目录里没有任何清单文件(' + profileDir + ')');
|
|
48
|
+
}
|
|
49
|
+
const files = {};
|
|
50
|
+
for (const f of FILES) {
|
|
51
|
+
const p = join(profileDir, f);
|
|
52
|
+
if (!existsSync(p)) { files[f] = { exists: false }; continue; }
|
|
53
|
+
try { files[f] = { exists: true, size: statSync(p).size, sha256: sha256(readFileSync(p)) }; }
|
|
54
|
+
catch (e) { files[f] = { exists: false }; }
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
atomicWrite(join(dir, MANIFEST), JSON.stringify({
|
|
58
|
+
profileId: profileId(profileDir),
|
|
59
|
+
profileDir,
|
|
60
|
+
createdAt: Date.now(),
|
|
61
|
+
created: tsStr(),
|
|
62
|
+
tool: 'dsh-plugin-snapshot',
|
|
63
|
+
files
|
|
64
|
+
}, null, 2));
|
|
65
|
+
} catch (e) { /* manifest 失败不影响恢复,只是少了身份校验 */ }
|
|
66
|
+
prune(cfg, [basename(dir)]);
|
|
67
|
+
return { name: basename(dir), dir, files: saved };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function prune(cfg, protect) {
|
|
71
|
+
const base = snapRoot(cfg);
|
|
72
|
+
const keep = Math.max(1, Number((cfg && cfg.snapshotsKeep) || 7));
|
|
73
|
+
const prot = new Set((protect || []).map((x) => basename(String(x))));
|
|
74
|
+
let items = [];
|
|
75
|
+
try { items = readdirSync(base); } catch (e) { return; }
|
|
76
|
+
const timed = [];
|
|
77
|
+
for (const n of items) {
|
|
78
|
+
if (!isValidSnapName(n)) continue;
|
|
79
|
+
let ms = 0;
|
|
80
|
+
try { ms = statSync(join(base, n)).mtimeMs; } catch (e) { ms = 0; }
|
|
81
|
+
timed.push({ n, ms });
|
|
82
|
+
}
|
|
83
|
+
timed.sort((a, b) => a.ms - b.ms);
|
|
84
|
+
const snaps = timed.map((x) => x.n);
|
|
85
|
+
const queue = snaps.filter((n) => !prot.has(n));
|
|
86
|
+
let over = snaps.length - keep;
|
|
87
|
+
while (over > 0 && queue.length) {
|
|
88
|
+
const victim = queue.shift();
|
|
89
|
+
try { rmSync(join(base, victim), { recursive: true, force: true }); over--; } catch (e) { /* 忽略 */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function listSnapshots(cfg) {
|
|
94
|
+
const base = snapRoot(cfg);
|
|
95
|
+
let items = [];
|
|
96
|
+
try { items = readdirSync(base); } catch (e) { return []; }
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const name of items) {
|
|
99
|
+
if (!isValidSnapName(name)) continue;
|
|
100
|
+
const dir = join(base, name);
|
|
101
|
+
const man = readJson(join(dir, MANIFEST));
|
|
102
|
+
let mtime = 0;
|
|
103
|
+
try { mtime = statSync(dir).mtimeMs; } catch (e) { /* 忽略 */ }
|
|
104
|
+
if (man && man.createdAt) mtime = man.createdAt;
|
|
105
|
+
let deps = 0;
|
|
106
|
+
try { deps = Object.keys(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dependencies || {}).length; }
|
|
107
|
+
catch (e) { /* 忽略 */ }
|
|
108
|
+
out.push({ name, dir, mtime, deps, hasManifest: !!man, files: FILES.filter((f) => existsSync(join(dir, f))) });
|
|
109
|
+
}
|
|
110
|
+
out.sort((a, b) => b.mtime - a.mtime);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveSnap(cfg, name) {
|
|
115
|
+
const base = basename(String(name || ''));
|
|
116
|
+
if (!isValidSnapName(base)) throw new Error('非法的快照名: ' + (base || '(空)'));
|
|
117
|
+
return join(snapRoot(cfg), base);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function restoreSnapshot(profileDir, cfg, name) {
|
|
121
|
+
const dir = resolveSnap(cfg, name);
|
|
122
|
+
if (!existsSync(dir)) throw new Error('快照不存在: ' + dir);
|
|
123
|
+
const present = FILES.filter((f) => existsSync(join(dir, f)));
|
|
124
|
+
if (!present.length) throw new Error('快照目录内没有可恢复的文件: ' + dir);
|
|
125
|
+
|
|
126
|
+
const man = readJson(join(dir, MANIFEST));
|
|
127
|
+
if (man && man.profileId && man.profileId !== profileId(profileDir)) {
|
|
128
|
+
throw new Error('快照属于另一个 profile(' + String(man.profileDir || '?') + '),已拒绝恢复');
|
|
129
|
+
}
|
|
130
|
+
const warnings = [];
|
|
131
|
+
if (man && man.files) {
|
|
132
|
+
for (const f of present) {
|
|
133
|
+
const meta = man.files[f];
|
|
134
|
+
if (!meta || !meta.exists || !meta.sha256) continue;
|
|
135
|
+
try {
|
|
136
|
+
if (sha256(readFileSync(join(dir, f))) !== meta.sha256) warnings.push('快照文件与创建时摘要不一致: ' + f);
|
|
137
|
+
} catch (e) { /* 忽略 */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
createSnapshot(profileDir, cfg);
|
|
142
|
+
const restored = [];
|
|
143
|
+
for (const f of present) {
|
|
144
|
+
copyFileSync(join(dir, f), join(profileDir, f));
|
|
145
|
+
restored.push(f);
|
|
146
|
+
}
|
|
147
|
+
return { restored, warnings, from: basename(dir) };
|
|
148
|
+
}
|
|
149
|
+
|
package/lib/updates.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版本检测:以 lockfile 的解析版本为准,去 registry 查候选版本。
|
|
3
|
+
* 只读操作,不写 profile、不安装任何东西。
|
|
4
|
+
*/
|
|
5
|
+
import { readProfile, readLockVersions, specKind } from './profile.js';
|
|
6
|
+
import { parseSpec, parseSemver, cmpSemver, shortVer, isValidPkgName, Logger } from './util.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
9
|
+
|
|
10
|
+
function registryOf(cfg) {
|
|
11
|
+
const r = String((cfg && cfg.registry) || process.env.DSH_REGISTRY || '').trim();
|
|
12
|
+
return (r || DEFAULT_REGISTRY).replace(/[/]+$/, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function fetchLatest(name, cfg) {
|
|
16
|
+
if (!isValidPkgName(name)) return { ok: false, reason: '非法包名' };
|
|
17
|
+
const base = registryOf(cfg);
|
|
18
|
+
if (!/^https?:[/][/]/.test(base)) return { ok: false, reason: '非法的 registry 地址' };
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(base + '/' + name + '/latest', {
|
|
21
|
+
signal: AbortSignal.timeout(Math.max(5000, Number((cfg && cfg.timeoutMs) || 20000))),
|
|
22
|
+
headers: { accept: 'application/json' }
|
|
23
|
+
});
|
|
24
|
+
if (res.status === 404) return { ok: false, reason: 'HTTP 404(包不存在)' };
|
|
25
|
+
if (!res.ok) return { ok: false, reason: 'HTTP ' + res.status };
|
|
26
|
+
const j = await res.json();
|
|
27
|
+
if (!j || !j.version) return { ok: false, reason: '响应缺少 version' };
|
|
28
|
+
return { ok: true, latest: j.version, engines: j.engines || null, dsh: j.dsh || null };
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return { ok: false, reason: String((e && e.message) || e) };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function mapLimit(items, limit, worker) {
|
|
35
|
+
const out = new Array(items.length);
|
|
36
|
+
let cursor = 0;
|
|
37
|
+
const runners = new Array(Math.max(1, Math.min(limit || 1, items.length))).fill(0).map(async () => {
|
|
38
|
+
while (true) {
|
|
39
|
+
const i = cursor++;
|
|
40
|
+
if (i >= items.length) return;
|
|
41
|
+
out[i] = await worker(items[i], i);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
await Promise.all(runners);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function checkUpdates(profileDir, cfg) {
|
|
49
|
+
const prof = readProfile(profileDir);
|
|
50
|
+
const lock = readLockVersions(profileDir);
|
|
51
|
+
const names = Object.keys(prof.deps);
|
|
52
|
+
const targets = [];
|
|
53
|
+
const skipped = [];
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
if (!isValidPkgName(name)) { skipped.push({ name, reason: '非法包名' }); continue; }
|
|
56
|
+
const kind = specKind(prof.deps[name]);
|
|
57
|
+
if (kind === 'local') { skipped.push({ name, reason: '本地依赖,不参与升级' }); continue; }
|
|
58
|
+
targets.push({ name, kind, declared: String(prof.deps[name]), resolved: lock[name] || '' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const results = await mapLimit(targets, Math.max(1, Math.min(Number((cfg && cfg.concurrency) || 8), 16)), async (t) => {
|
|
62
|
+
if (t.kind === 'github') {
|
|
63
|
+
return { ...t, state: 'manual', reason: 'GitHub 源需人工确认,本工具不自动判定' };
|
|
64
|
+
}
|
|
65
|
+
const r = await fetchLatest(t.name, cfg);
|
|
66
|
+
if (!r.ok) return { ...t, state: 'failed', reason: r.reason };
|
|
67
|
+
const cur = parseSpec(t.resolved || t.declared);
|
|
68
|
+
const lat = parseSemver(r.latest);
|
|
69
|
+
if (!cur || !lat) return { ...t, state: 'unknown', reason: '版本号无法解析', candidate: r.latest };
|
|
70
|
+
if (lat.pre) return { ...t, state: 'prerelease', reason: '候选是预发布版本', candidate: r.latest };
|
|
71
|
+
if (cur.major !== lat.major) return { ...t, state: 'major', reason: '主版本变更,需人工确认', candidate: r.latest };
|
|
72
|
+
const c = cmpSemver(cur.base, r.latest);
|
|
73
|
+
return {
|
|
74
|
+
...t,
|
|
75
|
+
candidate: r.latest,
|
|
76
|
+
state: (c !== null && c < 0) ? 'updatable' : 'uptodate',
|
|
77
|
+
reason: (c !== null && c < 0) ? '' : '',
|
|
78
|
+
shortResolved: shortVer(t.resolved || t.declared)
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const summary = {
|
|
83
|
+
total: results.length,
|
|
84
|
+
updatable: results.filter((x) => x.state === 'updatable').length,
|
|
85
|
+
major: results.filter((x) => x.state === 'major').length,
|
|
86
|
+
uptodate: results.filter((x) => x.state === 'uptodate').length,
|
|
87
|
+
failed: results.filter((x) => x.state === 'failed').length,
|
|
88
|
+
skipped: skipped.length
|
|
89
|
+
};
|
|
90
|
+
return { items: results, skipped, summary, profile: prof.name, registry: registryOf(cfg) };
|
|
91
|
+
}
|
|
92
|
+
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用工具:原子写、摘要、语义化版本、路径与日志。
|
|
3
|
+
* 本文件不依赖 Electron / DSH 内部包,可在纯 Node 下被单测直接覆盖。
|
|
4
|
+
*/
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, openSync, closeSync, fsyncSync, renameSync, unlinkSync, writeFileSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
/** 原子写:tmp -> fsync -> rename,中途失败不会留下半个文件 */
|
|
11
|
+
export function atomicWrite(file, text) {
|
|
12
|
+
const tmp = file + '.tmp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
13
|
+
let fd = null;
|
|
14
|
+
try {
|
|
15
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
16
|
+
fd = openSync(tmp, 'w');
|
|
17
|
+
writeFileSync(fd, text, 'utf8');
|
|
18
|
+
try { fsyncSync(fd); } catch (e) { /* 部分文件系统不支持 */ }
|
|
19
|
+
closeSync(fd);
|
|
20
|
+
fd = null;
|
|
21
|
+
renameSync(tmp, file);
|
|
22
|
+
return true;
|
|
23
|
+
} catch (e) {
|
|
24
|
+
if (fd !== null) { try { closeSync(fd); } catch (err) { /* 忽略 */ } }
|
|
25
|
+
try { if (existsSync(tmp)) unlinkSync(tmp); } catch (err) { /* 忽略 */ }
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function sha256(buf) {
|
|
31
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function todayStr(d) {
|
|
35
|
+
const x = d || new Date();
|
|
36
|
+
return x.getFullYear() + '-' + pad(x.getMonth() + 1) + '-' + pad(x.getDate());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function tsStr(d) {
|
|
40
|
+
const x = d || new Date();
|
|
41
|
+
return String(x.getFullYear()) + pad(x.getMonth() + 1) + pad(x.getDate()) + '-' + pad(x.getHours()) + pad(x.getMinutes()) + pad(x.getSeconds());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pad(n) { return String(n).padStart(2, '0'); }
|
|
45
|
+
|
|
46
|
+
/** 合法的 npm 包名:可带 @scope/,只允许字母数字与 . _ - */
|
|
47
|
+
export function isValidPkgName(n) {
|
|
48
|
+
if (typeof n !== 'string') return false;
|
|
49
|
+
const s = n.trim();
|
|
50
|
+
if (!s || s.length > 214) return false;
|
|
51
|
+
if (s.startsWith('.') || s.startsWith('-')) return false;
|
|
52
|
+
if (s.indexOf('..') >= 0 || s.indexOf(String.fromCharCode(92)) >= 0 || s.indexOf('/') === s.length - 1) return false;
|
|
53
|
+
if (/[\s\u0000]/.test(s)) return false;
|
|
54
|
+
return /^(@[a-zA-Z0-9][a-zA-Z0-9._-]*\/)?[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(s);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isValidSnapName(n) {
|
|
58
|
+
return typeof n === 'string' && /^[0-9]{8}-[0-9]{6}(-[0-9]+)?$/.test(n);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 解析 spec 里的语义化版本(支持 ^ ~ >= 前缀、v 前缀、prerelease、build、四段) */
|
|
62
|
+
export function parseSpec(spec) {
|
|
63
|
+
if (typeof spec !== 'string') return null;
|
|
64
|
+
const m = /^([\^~>=<*\s]*?)v?(\d+)\.(\d+)\.(\d+)(?:\.\d+)?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(spec.trim());
|
|
65
|
+
if (!m) return null;
|
|
66
|
+
return { prefix: m[1] || '', major: +m[2], minor: +m[3], patch: +m[4], pre: m[5] || null, base: m[2] + '.' + m[3] + '.' + m[4] + (m[5] ? '-' + m[5] : '') };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseSemver(v) {
|
|
70
|
+
const s = parseSpec(v);
|
|
71
|
+
return s ? { major: s.major, minor: s.minor, patch: s.patch, pre: s.pre, raw: s.base } : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function cmpSemver(a, b) {
|
|
75
|
+
const A = parseSemver(a), B = parseSemver(b);
|
|
76
|
+
if (!A || !B) return null;
|
|
77
|
+
for (const k of ['major', 'minor', 'patch']) {
|
|
78
|
+
if (A[k] !== B[k]) return A[k] < B[k] ? -1 : 1;
|
|
79
|
+
}
|
|
80
|
+
if (A.pre === null && B.pre === null) return 0;
|
|
81
|
+
if (A.pre === null) return 1;
|
|
82
|
+
if (B.pre === null) return -1;
|
|
83
|
+
const pa = A.pre.split('.'), pb = B.pre.split('.');
|
|
84
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
85
|
+
const x = pa[i], y = pb[i];
|
|
86
|
+
if (x === undefined) return -1;
|
|
87
|
+
if (y === undefined) return 1;
|
|
88
|
+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
|
|
89
|
+
if (xn && yn) { if (+x !== +y) return +x < +y ? -1 : 1; }
|
|
90
|
+
else if (xn) return -1;
|
|
91
|
+
else if (yn) return 1;
|
|
92
|
+
else if (x !== y) return x < y ? -1 : 1;
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function shortVer(spec) {
|
|
98
|
+
return String(spec == null ? '' : spec).replace(/^[\^~>=<\s]+/, '');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 数据目录:只写用户目录下的独立子目录,不碰 profile */
|
|
102
|
+
export function dataDir() {
|
|
103
|
+
return join(homedir(), '.dsh-plugin-snapshot');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 极简日志器:写本插件自己的目录,失败不抛 */
|
|
107
|
+
export class Logger {
|
|
108
|
+
constructor(dir) {
|
|
109
|
+
this.dir = dir || join(dataDir(), 'logs');
|
|
110
|
+
this.file = join(this.dir, 'snapshot-' + todayStr() + '.log');
|
|
111
|
+
this.lines = [];
|
|
112
|
+
}
|
|
113
|
+
write(level, msg) {
|
|
114
|
+
const line = '[' + new Date().toISOString() + '] [' + level + '] ' + msg;
|
|
115
|
+
this.lines.push(line);
|
|
116
|
+
if (this.lines.length > 200) this.lines.shift();
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(this.dir, { recursive: true });
|
|
119
|
+
appendFileSync(this.file, line + '\n', 'utf8');
|
|
120
|
+
} catch (e) { /* 只影响日志,不影响功能 */ }
|
|
121
|
+
}
|
|
122
|
+
info(m) { this.write('info', m); }
|
|
123
|
+
warn(m) { this.write('warn', m); }
|
|
124
|
+
error(m) { this.write('error', m); }
|
|
125
|
+
tail(n) { return this.lines.slice(-(n || 20)); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readJson(file) {
|
|
129
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch (e) { return null; }
|
|
130
|
+
}
|
|
131
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-snapshot",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Pre-update snapshots, one-step rollback and conflict diagnostics for DeepSeek Harness profile plugins.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"deepseek-harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"dsh-plugin",
|
|
10
|
+
"snapshot",
|
|
11
|
+
"rollback",
|
|
12
|
+
"plugin-manager",
|
|
13
|
+
"diagnostics"
|
|
14
|
+
],
|
|
15
|
+
"author": "YG",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Uesccda/dsh-plugin-snapshot.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/Uesccda/dsh-plugin-snapshot#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/Uesccda/dsh-plugin-snapshot/issues"
|
|
24
|
+
},
|
|
25
|
+
"main": "lib/index.js",
|
|
26
|
+
"files": [
|
|
27
|
+
"lib",
|
|
28
|
+
"cordis.patch.yml",
|
|
29
|
+
"README.md",
|
|
30
|
+
"README.en.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"dsh": {
|
|
34
|
+
"bundle": {
|
|
35
|
+
"patch": "./cordis.patch.yml"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@deepseek-ai/dsh-tools": ">=0.1.7-rc.1 <0.4.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@deepseek-ai/dsh-tools": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"test": "node --test test/plugin.test.mjs",
|
|
51
|
+
"smoke": "node test/smoke.mjs"
|
|
52
|
+
}
|
|
53
|
+
}
|