dsh-plugin-snapshot 0.0.0-stage → 0.1.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/LICENSE +22 -0
- package/README.en.md +86 -0
- package/README.md +119 -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 +58 -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,86 @@
|
|
|
1
|
+
# dsh-plugin-snapshot
|
|
2
|
+
|
|
3
|
+
**Pre-upgrade snapshots, one-step rollback and a static health check for DeepSeek Harness plugins.**
|
|
4
|
+
|
|
5
|
+
One plugin, three tools.
|
|
6
|
+
|
|
7
|
+
- npm: https://www.npmjs.com/package/dsh-plugin-snapshot
|
|
8
|
+
- repo: https://github.com/Uesccda/dsh-plugin-snapshot
|
|
9
|
+
- compatible with DSH 0.2.0+ (tested on 0.2.2-rc.1)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What problem it solves
|
|
14
|
+
|
|
15
|
+
| The trouble you hit | What this does |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| A plugin upgrade fails halfway: manifests changed, dependencies not installed, unknown state | Takes a snapshot with fingerprint and hashes before upgrading, so you can always go back |
|
|
18
|
+
| Dozens of plugins installed, no idea which collide or whose compatibility claim is stale | One scan reports installed / enabled / missing / incompatible / untested, plus a review priority |
|
|
19
|
+
| No idea which plugins have newer versions, and afraid of auto-upgrading to a beta | Lists updatable items and marks prereleases separately instead of mixing them in |
|
|
20
|
+
|
|
21
|
+
It deliberately does **not** install, remove or toggle plugins: that is what the built-in plugin_manager is for. Two implementations fighting over the same profile is the most common source of breakage in this ecosystem.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## The three tools
|
|
26
|
+
|
|
27
|
+
| Tool | Does | Writes |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| plugin_snapshot | create / list / restore snapshots of the profile manifests | only its own data dir, plus the three manifests on restore |
|
|
30
|
+
| plugin_doctor | static diagnostic: install state, author-declared compatibility, duplicate patch ids, missing packages | read-only |
|
|
31
|
+
| plugin_updates | compares the version resolved in pnpm-lock.yaml against the registry | read-only |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Just talk to the model:
|
|
38
|
+
|
|
39
|
+
- "Take a snapshot before upgrading my plugins"
|
|
40
|
+
- "Check my installed plugins for problems"
|
|
41
|
+
- "Which plugins have updates available?"
|
|
42
|
+
- "That upgrade failed, roll back to before it"
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
dsh plugin add dsh-plugin-snapshot
|
|
49
|
+
|
|
50
|
+
Restart DSH afterwards. Remove with:
|
|
51
|
+
|
|
52
|
+
dsh plugin remove dsh-plugin-snapshot
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## What a snapshot contains
|
|
57
|
+
|
|
58
|
+
The three profile manifests (package.json, pnpm-lock.yaml, pnpm-workspace.yaml) plus a manifest.json recording the profile fingerprint, creation time, per-file existence and sha256.
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
Snapshots do **not** include node_modules. Dependencies must be reinstalled after a restore.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Where data lives
|
|
67
|
+
|
|
68
|
+
~/.dsh-plugin-snapshot/snapshots/<timestamp>/
|
|
69
|
+
~/.dsh-plugin-snapshot/logs/
|
|
70
|
+
|
|
71
|
+
Nothing else is written except the three profile manifests on restore.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Limitations
|
|
76
|
+
|
|
77
|
+
- Snapshots cover the three manifests only, not node_modules
|
|
78
|
+
- Diagnostics are static evidence, not runtime verification
|
|
79
|
+
- No scheduler: the plugin only exists while DSH runs
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
86
|
+
|
package/README.md
CHANGED
|
@@ -1,3 +1,120 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-plugin-snapshot
|
|
2
|
+
|
|
3
|
+
**给 DeepSeek Harness 的插件做升级前快照、一键回滚和静态体检。**
|
|
4
|
+
|
|
5
|
+
一个插件,三个工具。
|
|
6
|
+
|
|
7
|
+
- npm: https://www.npmjs.com/package/dsh-plugin-snapshot
|
|
8
|
+
- 仓库: https://github.com/Uesccda/dsh-plugin-snapshot
|
|
9
|
+
- 兼容: DSH 0.2.0 及以上(已在 0.2.2-rc.1 实测)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 它解决什么问题
|
|
14
|
+
|
|
15
|
+
| 你会遇到的麻烦 | 这个插件怎么处理 |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| 升级插件失败,清单已经改了、依赖没装完,不知道回到了什么状态 | 升级前先存一份带指纹和哈希的快照,任何时候都能退回去 |
|
|
18
|
+
| 装了几十个插件,不知道谁跟谁冲突、谁的兼容声明已经过期 | 一次扫描给出:已装/已启用/缺失/不兼容/未验证,以及检查优先级 |
|
|
19
|
+
| 不知道哪些插件有新版本,又怕自动升到测试版 | 列出可更新项,预发布版本单独标注、不混进"可升级"里 |
|
|
20
|
+
|
|
21
|
+
它**不负责**安装、卸载、启停插件——那是 DSH 自带的 plugin_manager 的事。两套实现打架是插件市场里最常见的坑,这里直接避开。
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 三个工具
|
|
26
|
+
|
|
27
|
+
| 工具 | 做什么 | 会不会写文件 |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| plugin_snapshot | 创建快照 / 列出快照 / 恢复到某个快照 | 只写自己的数据目录,恢复时覆盖 profile 的三份清单 |
|
|
30
|
+
| plugin_doctor | 静态体检:安装状态、作者声明的兼容性、patch id 冲突、缺失的包 | 纯只读 |
|
|
31
|
+
| plugin_updates | 以 lockfile 解析出的版本为基准,查 registry 有没有新版本 | 纯只读 |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 怎么用(对话示例)
|
|
36
|
+
|
|
37
|
+
你不用记命令,直接跟模型说:
|
|
38
|
+
|
|
39
|
+
- **"升级插件前先给我打个快照"** → 调用 plugin_snapshot 的 create,返回快照名
|
|
40
|
+
- **"看看我这些插件有没有问题"** → 调用 plugin_doctor,给出 48 个插件的体检结果
|
|
41
|
+
- **"哪些插件可以升级"** → 调用 plugin_updates,列出可更新项
|
|
42
|
+
- **"刚才那次升级失败了,退回到升级前"** → 调用 plugin_snapshot 的 restore
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 安装
|
|
47
|
+
|
|
48
|
+
dsh plugin add dsh-plugin-snapshot
|
|
49
|
+
|
|
50
|
+
装完重启 DSH(启用 HMR 时自动生效),三个工具随即可用。
|
|
51
|
+
|
|
52
|
+
卸载:
|
|
53
|
+
|
|
54
|
+
dsh plugin remove dsh-plugin-snapshot
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 兼容性
|
|
59
|
+
|
|
60
|
+
本插件声明支持 DSH 0.2.0 及以上,并在 0.2.2-rc.1 实测通过:
|
|
61
|
+
|
|
62
|
+
0.2.2-rc.1 compatible
|
|
63
|
+
0.2.1 compatible
|
|
64
|
+
0.2.0+ compatible
|
|
65
|
+
|
|
66
|
+
如果你在别的 DSH 版本上使用,plugin_doctor 会如实显示为「未验证」而不是假装兼容。
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 快照里存了什么
|
|
71
|
+
|
|
72
|
+
profile 目录下的三份清单:
|
|
73
|
+
|
|
74
|
+
package.json
|
|
75
|
+
pnpm-lock.yaml
|
|
76
|
+
pnpm-workspace.yaml
|
|
77
|
+
|
|
78
|
+
每份快照额外带一个 manifest.json,记录:profile 指纹、创建时间、每个文件是否存在、每个文件的 sha256。
|
|
79
|
+
|
|
80
|
+
恢复时会先比对 profile 指纹——**别的 profile 的快照会被拒绝**,避免把 A 环境的清单写进 B。文件摘要对不上只告警,因为你可能手工改过清单。
|
|
81
|
+
|
|
82
|
+
⚠️ 快照**不包含 node_modules**。恢复之后需要让包管理器重新安装依赖。
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 数据放在哪
|
|
87
|
+
|
|
88
|
+
~/.dsh-plugin-snapshot/snapshots/<时间戳>/
|
|
89
|
+
~/.dsh-plugin-snapshot/logs/
|
|
90
|
+
|
|
91
|
+
只写这两个目录,以及恢复时写回 profile 的三份清单。不碰 node_modules、不改 bundles 清单、不启动或关闭 DSH。
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 安全边界
|
|
96
|
+
|
|
97
|
+
- plugin_doctor 与 plugin_updates 是**纯只读**:只读文件、只发 GET 请求
|
|
98
|
+
- plugin_snapshot 的 create 只写自己的数据目录;restore 必须显式传 name
|
|
99
|
+
- 不调用 pnpm、不修改依赖树、不结束任何进程
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 已知限制
|
|
104
|
+
|
|
105
|
+
- 快照只覆盖三份清单,不含 node_modules
|
|
106
|
+
- 诊断基于静态扫描与作者声明,不代表运行时验证
|
|
107
|
+
- 没有定时任务:插件只在 DSH 运行时存在
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 开发
|
|
112
|
+
|
|
113
|
+
npm test # 5 项自测,用 stub 顶替 @deepseek-ai/dsh-tools
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 许可
|
|
118
|
+
|
|
119
|
+
MIT
|
|
2
120
|
|
|
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,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-snapshot",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"compatibility": {
|
|
38
|
+
"dshReleases": {
|
|
39
|
+
"0.2.2-rc.1": "compatible",
|
|
40
|
+
"0.2.1": "compatible",
|
|
41
|
+
"0.2.0+": "compatible"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@deepseek-ai/dsh-tools": ">=0.1.7-rc.1 <0.4.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@deepseek-ai/dsh-tools": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=20"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"test": "node --test test/plugin.test.mjs",
|
|
58
|
+
"smoke": "node test/smoke.mjs"
|
|
59
|
+
}
|
|
60
|
+
}
|