dsh-plugin-snapshot 0.1.1 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -72,6 +72,25 @@ Nothing else is written except the three profile manifests on restore.
72
72
 
73
73
  ---
74
74
 
75
+ ## Configuration
76
+
77
+ {
78
+ "profileDir": "explicit profile directory (auto-detected when empty)",
79
+ "snapshotsKeep": 7,
80
+ "concurrency": 8,
81
+ "autoUpdate": false,
82
+ "autoUpdateScope": "patch",
83
+ "autoExclude": ["package-you-never-want-touched"]
84
+ }
85
+
86
+ | Option | Default | Meaning |
87
+ | --- | --- | --- |
88
+ | autoUpdate | false | Disabled by default. Set true to check and update 45 s after DSH starts |
89
+ | autoUpdateScope | patch | patch only / minor / major |
90
+ | autoExclude | [] | packages never touched by auto-update |
91
+ | snapshotsKeep | 7 | how many snapshots to retain |
92
+ | concurrency | 8 | registry query parallelism |
93
+
75
94
  ## Limitations
76
95
 
77
96
  - Snapshots cover the three manifests only, not node_modules
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
  | 装了几十个插件,不知道谁跟谁冲突、谁的兼容声明已经过期 | 一次扫描给出:已装/已启用/缺失/不兼容/未验证,以及检查优先级 |
19
19
  | 不知道哪些插件有新版本,又怕自动升到测试版 | 列出可更新项,预发布版本单独标注、不混进"可升级"里 |
20
20
 
21
- 它**不负责**安装、卸载、启停插件——那是 DSH 自带的 plugin_manager 的事。两套实现打架是插件市场里最常见的坑,这里直接避开。
21
+ 它**默认不做**安装、卸载、启停——那是 DSH 自带的 plugin_manager 的事。两套实现打架是插件市场里最常见的坑,这里直接避开。
22
22
 
23
23
  ---
24
24
 
@@ -96,15 +96,40 @@ profile 目录下的三份清单:
96
96
 
97
97
  - plugin_doctor 与 plugin_updates 是**纯只读**:只读文件、只发 GET 请求
98
98
  - plugin_snapshot 的 create 只写自己的数据目录;restore 必须显式传 name
99
- - 不调用 pnpm、不修改依赖树、不结束任何进程
99
+ - 两个只读工具(plugin_updates、plugin_doctor)只读文件、只发 GET 请求
100
+ - 写操作(快照恢复、更新)会执行 pnpm,但只作用于 profile 中**已安装**的包,
101
+ 只允许升到更高版本,拒绝降级与 dist-tag,并一律带 --ignore-scripts
102
+ - 不结束任何进程
100
103
 
101
104
  ---
102
105
 
106
+ ## 配置项
107
+
108
+ 在插件配置里可写:
109
+
110
+ {
111
+ "profileDir": "指定 profile 目录(留空则自动定位)",
112
+ "snapshotsKeep": 7,
113
+ "concurrency": 8,
114
+ "autoUpdate": false,
115
+ "autoUpdateScope": "patch",
116
+ "autoExclude": ["某个不想被自动更新的包"]
117
+ }
118
+
119
+ | 配置 | 默认 | 说明 |
120
+ | --- | --- | --- |
121
+ | autoUpdate | false | 是否在 DSH 启动 45 秒后自动检查并更新 |
122
+ | autoUpdateScope | patch | 升级档位:patch 仅补丁版 / minor 含次版本 / major 含主版本 |
123
+ | autoExclude | [] | 永不参与自动更新的包名列表 |
124
+ | snapshotsKeep | 7 | 保留的快照份数 |
125
+ | concurrency | 8 | 查询 registry 的并发数 |
126
+
103
127
  ## 已知限制
104
128
 
105
129
  - 快照只覆盖三份清单,不含 node_modules
106
130
  - 诊断基于静态扫描与作者声明,不代表运行时验证
107
- - 没有定时任务:插件只在 DSH 运行时存在
131
+ - 自动更新**默认关闭**:需在插件配置里显式写 autoUpdate: true 才会启用
132
+ - 插件只在 DSH 运行时存在,DSH 关闭期间不做任何事
108
133
 
109
134
  ---
110
135
 
@@ -0,0 +1,476 @@
1
+ /**
2
+ * dsh-plugin-snapshot - 设置页里的「插件更新 / 快照 / 诊断」面板。
3
+ *
4
+ * 契约(照 dsh-soul、dsh-session-manager 两个可用样本):
5
+ * __ModuleLoader__ + module.exports + Symbol.toStringTag='Module';
6
+ * ctx.slots.inject(slot, () => ctx.slots.register(options, Component));
7
+ * Component 是组件函数,不是工厂。
8
+ *
9
+ * 数据全部走同源 HTTP:前端拿不到 host 侧的工具。
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-plugin-snapshot',
13
+ factory: (require) => {
14
+ const module = { exports: {} };
15
+ const exports = module.exports;
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
17
+
18
+ const React = require('react');
19
+
20
+ const S = {
21
+ wrap: { padding: '8px 4px' },
22
+ tabs: { display: 'flex', gap: '6px', marginBottom: '12px', borderBottom: '1px solid #e6eaef', paddingBottom: '8px' },
23
+ tab: { padding: '5px 12px', borderRadius: '6px', border: '1px solid transparent', background: 'transparent', cursor: 'pointer', fontSize: '13px', color: '#57606a' },
24
+ tabOn: { padding: '5px 12px', borderRadius: '6px', border: '1px solid #c3ccd8', background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#1f2328', fontWeight: 600 },
25
+ head: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', marginBottom: '8px' },
26
+ title: { margin: '0', fontSize: '15px', fontWeight: 600 },
27
+ btn: { padding: '4px 10px', borderRadius: '6px', border: '1px solid #c3ccd8', background: '#fff', cursor: 'pointer', fontSize: '12.5px' },
28
+ btnOn: { padding: '4px 10px', borderRadius: '6px', border: '1px solid #2f6bff', background: '#2f6bff', color: '#fff', cursor: 'pointer', fontSize: '12.5px' },
29
+ meta: { fontSize: '12px', color: '#57606a', marginBottom: '10px', lineHeight: '1.7' },
30
+ err: { fontSize: '12px', color: '#cf222e', marginBottom: '10px' },
31
+ ok: { fontSize: '12px', color: '#1a7f37', marginBottom: '10px' },
32
+ table: { width: '100%', borderCollapse: 'collapse', fontSize: '12.5px' },
33
+ th: { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #d8dee6', color: '#57606a', fontWeight: 600, whiteSpace: 'nowrap' },
34
+ td: { padding: '6px 8px', borderBottom: '1px solid #eef1f4', verticalAlign: 'top' },
35
+ mono: { fontFamily: 'Consolas, monospace', fontSize: '12px' },
36
+ empty: { padding: '16px 8px', color: '#57606a', fontSize: '12.5px' }
37
+ };
38
+
39
+ function stateColor(state) {
40
+ if (state === 'updatable') return '#1a7f37';
41
+ if (state === 'major') return '#9a6700';
42
+ if (state === 'prerelease') return '#8250df';
43
+ if (state === 'incompatible') return '#cf222e';
44
+ if (state === 'downgrade') return '#8b949e';
45
+ return '#57606a';
46
+ }
47
+
48
+ function stateText(state) {
49
+ const m = { updatable: '可更新', major: '主版本变更', prerelease: '预发布', uptodate: '已最新', failed: '查询失败', manual: '需人工确认', unknown: '未知', downgrade: '低于本地', incompatible: '不兼容当前 DSH' };
50
+ return m[state] || state;
51
+ }
52
+
53
+ /* 写操作必须带 X-DSH-Snapshot 头 + JSON 内容类型:
54
+ 跨源请求带自定义头会触发 CORS preflight,而本服务不应答 preflight,
55
+ 浏览器会直接拦下 —— 这是服务端写守卫的前端半边。 */
56
+ function post(url, body) {
57
+ return fetch(url, {
58
+ method: 'POST',
59
+ headers: { 'content-type': 'application/json', 'x-dsh-snapshot': '1' },
60
+ body: JSON.stringify(body || {})
61
+ }).then(function (r) { return r.json(); });
62
+ }
63
+
64
+ function get(url) {
65
+ return fetch(url, { headers: { accept: 'application/json' } }).then(function (r) { return r.json(); });
66
+ }
67
+
68
+ /* ---------------- 更新 ---------------- */
69
+ function UpdatesTab() {
70
+ const st = React.useState({ loading: true, items: [], summary: null, error: null, note: null, auto: null });
71
+ const d = st[0];
72
+ const setD = st[1];
73
+ const bulkSt = React.useState(false);
74
+ const bulk = bulkSt[0];
75
+ const setBulk = bulkSt[1];
76
+
77
+ const busySt = React.useState({});
78
+ const busy = busySt[0];
79
+ const busyMap = busy;
80
+ const setBusy = busySt[1];
81
+
82
+ const load = React.useCallback(function () {
83
+ setD(function (p) { return { loading: true, items: p.items, summary: p.summary, error: null, note: null, auto: p.auto }; });
84
+ Promise.all([
85
+ get('/dsh-plugin-snapshot/api/updates'),
86
+ get('/dsh-plugin-snapshot/api/auto').catch(function () { return { ok: false }; })
87
+ ]).then(function (arr) {
88
+ const j = arr[0];
89
+ const au = arr[1];
90
+ if (!j || !j.ok) throw new Error((j && j.error) || '请求失败');
91
+ setD({ loading: false, items: j.items || [], summary: j.summary || null, error: null, note: null, auto: (au && au.ok) ? au.state : null });
92
+ }).catch(function (e) {
93
+ setD({ loading: false, items: [], summary: null, error: String((e && e.message) || e), note: null, auto: null });
94
+ });
95
+ }, []);
96
+
97
+ React.useEffect(function () { load(); }, [load]);
98
+
99
+ function applyAll(list) {
100
+ setBulk(true);
101
+ setD(function (p) { return Object.assign({}, p, { note: null, error: null }); });
102
+ post('/dsh-plugin-snapshot/api/apply-all', {
103
+ items: list.map(function (x) { return { name: x.name, version: x.candidate }; })
104
+ }).then(function (j) {
105
+ setBulk(false);
106
+ if (j && j.ok) {
107
+ setD(function (p) { return Object.assign({}, p, { note: '已更新 ' + j.count + ' 个插件,快照 ' + j.snapshot + ',重启 DSH 后生效' }); });
108
+ load();
109
+ } else {
110
+ /* 后端在「部分失败」时返回 ok:false 但没有 error 字段,
111
+ 直接把这种情况显示成「未知错误」会掩盖真实结果。 */
112
+ const fl = Array.isArray(j && j.failed) ? j.failed : [];
113
+ const detail = (j && j.error)
114
+ ? j.error
115
+ : (function () {
116
+ const okN = (j && j.count) || 0;
117
+ const badN = (j && j.failedCount) || fl.length;
118
+ const names = fl.slice(0, 3).map(function (x) { return x.name; }).join("、");
119
+ return "成功 " + okN + " 个,失败 " + badN + " 个" + (names ? "(" + names + (fl.length > 3 ? " 等" : "") + ")" : "");
120
+ })();
121
+ setD(function (p) { return Object.assign({}, p, { error: "批量更新:" + detail }); });
122
+ }
123
+ }).catch(function (e) {
124
+ setBulk(false);
125
+ setD(function (p) { return Object.assign({}, p, { error: String((e && e.message) || e) }); });
126
+ });
127
+ }
128
+
129
+ function apply(x) {
130
+ if (x.state === 'major') {
131
+ const from = x.resolved || x.declared || '?';
132
+ if (!window.confirm('这是主版本变更:' + from + ' → ' + x.candidate + ',可能有破坏性改动。仍然更新?')) return;
133
+ }
134
+ setBusy(function (p) { const n = Object.assign({}, p); n[x.name] = true; return n; });
135
+ setD(function (p) { return Object.assign({}, p, { note: null }); });
136
+ post('/dsh-plugin-snapshot/api/apply', { name: x.name, version: x.candidate }).then(function (j) {
137
+ setBusy(function (p) { const n = Object.assign({}, p); delete n[x.name]; return n; });
138
+ if (j && j.ok) {
139
+ setD(function (p) { return Object.assign({}, p, { note: '已更新 ' + x.name + ' 到 ' + x.candidate + ',快照 ' + j.snapshot + ',重启 DSH 后生效' }); });
140
+ load();
141
+ } else {
142
+ setD(function (p) { return Object.assign({}, p, { error: '更新失败:' + ((j && j.error) || '未知错误') }); });
143
+ }
144
+ }).catch(function (e) {
145
+ setBusy(function (p) { const n = Object.assign({}, p); delete n[x.name]; return n; });
146
+ setD(function (p) { return Object.assign({}, p, { error: String((e && e.message) || e) }); });
147
+ });
148
+ }
149
+
150
+ const s = d.summary || {};
151
+ const rows = (d.items || []).filter(function (x) { return x.state !== 'uptodate'; });
152
+
153
+ const applyTargets = (d.items || []).filter(function (x) { return (x.state === 'updatable' || x.state === 'major') && x.candidate; });
154
+ const head = React.createElement('div', { style: S.head }, [
155
+ React.createElement('h3', { key: 't', style: S.title }, '插件更新'),
156
+ React.createElement('div', { key: 'acts', style: { display: 'flex', gap: '8px' } }, [
157
+ applyTargets.length > 0
158
+ ? React.createElement('button', {
159
+ key: 'all',
160
+ style: S.btnOn,
161
+ disabled: bulk,
162
+ onClick: function () {
163
+ const majors = applyTargets.filter(function (x) { return x.state === 'major'; }).length;
164
+ const tip = '一次更新 ' + applyTargets.length + ' 个插件'
165
+ + (majors > 0 ? '(其中 ' + majors + ' 个是主版本变更,可能有破坏性改动)' : '')
166
+ + '?会先自动打快照。';
167
+ if (!window.confirm(tip)) return;
168
+ applyAll(applyTargets);
169
+ }
170
+ }, bulk ? '更新中…' : ('全部更新 (' + applyTargets.length + ')' + (applyTargets.filter(function (y) { return y.state === 'major'; }).length > 0 ? ' · 含大版本' : '')))
171
+ : null,
172
+ React.createElement('button', { key: 'b', style: S.btn, disabled: d.loading, onClick: function () { load(); } },
173
+ d.loading ? '检测中…' : '重新检测')
174
+ ])
175
+ ]);
176
+
177
+ const meta = React.createElement('div', { style: S.meta },
178
+ '可更新 ' + (s.updatable || 0) + ' 个 · 主版本 ' + (s.major || 0) + ' 个 · 共检测 ' + (s.total || 0) + ' 个插件');
179
+
180
+ let autoLine = null;
181
+ if (d.auto) {
182
+ const a = d.auto;
183
+ const map = {
184
+ 'no-update': '没有需要更新的版本',
185
+ updated: '已自动更新 ' + (a.count || 0) + ' 个插件,重启 DSH 后生效',
186
+ partial: '部分成功:成功 ' + (a.count || 0) + ' 个,失败 ' + (a.failedCount || 0) + ' 个,失败项已回退',
187
+ 'failed-rolled-back': '全部失败,已整体恢复到快照 ' + (a.snapshot || ''),
188
+ 'failed-rollback-error': '全部失败,且回滚未成功,请手动检查',
189
+ 'locked-abort': '全部目标都被 DSH 占用,本次未执行(关闭 DSH 后重试)',
190
+ 'probe-damaged': '探针改名后无法还原,包目录可能已损坏,本次已中止',
191
+ error: '自动更新出错:' + (a.error || '')
192
+ };
193
+ const text = map[a.outcome] || a.outcome;
194
+ const color = a.outcome === 'updated' ? '#1a7f37' : (a.outcome === 'no-update' || a.outcome === 'locked-abort') ? '#57606a' : (a.outcome === 'partial' ? '#9a6700' : '#cf222e');
195
+ const failedList = Array.isArray(a.failed) ? a.failed : [];
196
+ const children = [
197
+ React.createElement('div', { key: 'm', style: { fontSize: '12px', color: color } },
198
+ '上次自动更新(' + (a.updatedAtText || '') + '):' + text)
199
+ ];
200
+ const lockedList = Array.isArray(a.skippedLocked) ? a.skippedLocked : (Array.isArray(a.locked) ? a.locked : []);
201
+ const fatalList = Array.isArray(a.fatal) ? a.fatal : [];
202
+ if (fatalList.length > 0) {
203
+ children.push(React.createElement('div', { key: 'fd', style: { fontSize: '12px', color: '#cf222e', marginTop: '4px', fontWeight: 600 } },
204
+ '包目录损坏:' + fatalList.map(function (x) { return x.name + '(临时目录 ' + (x.tmpPath || '?') + ')'; }).join(';')
205
+ + '——请手动把临时目录改名回原名,或从快照恢复'));
206
+ }
207
+ if (lockedList.length > 0) {
208
+ children.push(React.createElement('div', { key: 'l', style: { fontSize: '11.5px', color: '#9a6700', marginTop: '4px' } },
209
+ '跳过(文件被 DSH 占用):' + lockedList.map(function (x) { return x.name; }).join('、') + '——关闭 DSH 后可手动更新'));
210
+ }
211
+ if (failedList.length > 0) {
212
+ children.push(React.createElement('div', { key: 'f', style: { fontSize: '11.5px', color: '#cf222e', marginTop: '4px' } },
213
+ '失败项:' + failedList.map(function (x) {
214
+ const kindText = {
215
+ 'cache-skip': '缓存跳过,磁盘版本未变',
216
+ unverified: '版本到位但安装过程报错',
217
+ failed: '安装失败',
218
+ threw: '执行异常',
219
+ warn: '成功(构建脚本告警)',
220
+ success: '成功'
221
+ }[x.kind] || x.kind;
222
+ const hint = /EPERM|EBUSY|EACCES/i.test(x.output || '') ? '(文件被 DSH 占用,需手动关闭 DSH 后重试)' : '';
223
+ return x.name + ':' + kindText + hint;
224
+ }).join(';')));
225
+ }
226
+ autoLine = React.createElement('div', { style: { marginBottom: '8px' } }, children);
227
+ }
228
+
229
+ const notice = d.error
230
+ ? React.createElement('div', { style: S.err }, d.error)
231
+ : (d.note ? React.createElement('div', { style: S.ok }, d.note) : null);
232
+
233
+ let body;
234
+ if (d.loading && rows.length === 0 && !d.error) {
235
+ body = React.createElement('div', { style: S.empty }, '正在读取 registry…');
236
+ } else if (rows.length === 0) {
237
+ body = React.createElement('div', { style: S.empty }, d.error ? '暂无数据' : '全部插件都是最新版本');
238
+ } else {
239
+ body = React.createElement('table', { style: S.table }, [
240
+ React.createElement('thead', { key: 'h' }, React.createElement('tr', null, [
241
+ React.createElement('th', { key: 'a', style: S.th }, '插件'),
242
+ React.createElement('th', { key: 'b', style: S.th }, '当前'),
243
+ React.createElement('th', { key: 'c', style: S.th }, '最新'),
244
+ React.createElement('th', { key: 'd', style: S.th }, '状态'),
245
+ React.createElement('th', { key: 'e', style: S.th }, '操作')
246
+ ])),
247
+ React.createElement('tbody', { key: 'b' }, rows.map(function (x) {
248
+ const canApply = (x.state === 'updatable' || x.state === 'major') && x.candidate;
249
+ const isBusy = !!busyMap[x.name];
250
+ return React.createElement('tr', { key: x.name }, [
251
+ React.createElement('td', { key: 'a', style: S.td }, x.name),
252
+ React.createElement('td', { key: 'b', style: Object.assign({}, S.td, S.mono) }, x.resolved || x.declared || '-'),
253
+ React.createElement('td', { key: 'c', style: Object.assign({}, S.td, S.mono) }, x.candidate || '-'),
254
+ React.createElement('td', { key: 'd', style: Object.assign({}, S.td, { color: stateColor(x.state), fontWeight: 600 }) }, stateText(x.state)),
255
+ React.createElement('td', { key: 'e', style: S.td }, canApply
256
+ ? React.createElement('button', { style: isBusy ? S.btn : S.btnOn, disabled: isBusy, onClick: function () { apply(x); } }, isBusy ? '更新中…' : '更新')
257
+ : React.createElement('span', { style: { color: '#8b949e', fontSize: '12px' } }, '—'))
258
+ ]);
259
+ }))
260
+ ]);
261
+ }
262
+
263
+ return React.createElement('div', null, [head, meta, autoLine, notice, body]);
264
+ }
265
+
266
+ /* ---------------- 快照 ---------------- */
267
+ function SnapshotsTab() {
268
+ const st = React.useState({ loading: true, list: [], error: null, note: null });
269
+ const d = st[0];
270
+ const setD = st[1];
271
+
272
+ const load = React.useCallback(function () {
273
+ setD(function (p) { return Object.assign({}, p, { loading: true, error: null }); });
274
+ get('/dsh-plugin-snapshot/api/snapshots').then(function (j) {
275
+ if (!j || !j.ok) throw new Error((j && j.error) || '请求失败');
276
+ setD({ loading: false, list: j.snapshots || [], error: null, note: null });
277
+ }).catch(function (e) {
278
+ setD({ loading: false, list: [], error: String((e && e.message) || e), note: null });
279
+ });
280
+ }, []);
281
+
282
+ React.useEffect(function () { load(); }, [load]);
283
+
284
+ function create() {
285
+ post('/dsh-plugin-snapshot/api/snapshot/create', {}).then(function (j) {
286
+ if (j && j.ok) { setD(function (p) { return Object.assign({}, p, { note: '已创建快照 ' + j.created, error: null }); }); load(); }
287
+ else setD(function (p) { return Object.assign({}, p, { error: (j && j.error) || '创建失败' }); });
288
+ });
289
+ }
290
+
291
+ function restore(name) {
292
+ if (!window.confirm('确定恢复到快照 ' + name + '?会覆盖当前 profile 的三份清单。')) return;
293
+ post('/dsh-plugin-snapshot/api/snapshot/restore', { name: name }).then(function (j) {
294
+ if (j && j.ok) { setD(function (p) { return Object.assign({}, p, { note: '已恢复 ' + j.restored.join(', ') + '(来自 ' + j.from + '),重启 DSH 后生效', error: null }); }); load(); }
295
+ else setD(function (p) { return Object.assign({}, p, { error: (j && j.error) || '恢复失败' }); });
296
+ });
297
+ }
298
+
299
+ const head = React.createElement('div', { style: S.head }, [
300
+ React.createElement('h3', { key: 't', style: S.title }, '快照'),
301
+ React.createElement('button', { key: 'b', style: S.btnOn, onClick: create }, '创建快照')
302
+ ]);
303
+
304
+ const meta = React.createElement('div', { style: S.meta }, '共 ' + d.list.length + ' 份。每份含 profile 指纹与文件哈希,恢复前会校验身份。');
305
+
306
+ let autoLine = null;
307
+ if (d.auto) {
308
+ const a = d.auto;
309
+ const map = {
310
+ 'no-update': '没有需要更新的版本',
311
+ updated: '已自动更新 ' + (a.count || 0) + ' 个插件,重启 DSH 后生效',
312
+ partial: '部分成功:成功 ' + (a.count || 0) + ' 个,失败 ' + (a.failedCount || 0) + ' 个,失败项已回退',
313
+ 'failed-rolled-back': '全部失败,已整体恢复到快照 ' + (a.snapshot || ''),
314
+ 'failed-rollback-error': '全部失败,且回滚未成功,请手动检查',
315
+ 'locked-abort': '全部目标都被 DSH 占用,本次未执行(关闭 DSH 后重试)',
316
+ error: '自动更新出错:' + (a.error || '')
317
+ };
318
+ const text = map[a.outcome] || a.outcome;
319
+ const color = a.outcome === 'updated' ? '#1a7f37' : (a.outcome === 'no-update' || a.outcome === 'locked-abort') ? '#57606a' : (a.outcome === 'partial' ? '#9a6700' : '#cf222e');
320
+ const failedList = Array.isArray(a.failed) ? a.failed : [];
321
+ const children = [
322
+ React.createElement('div', { key: 'm', style: { fontSize: '12px', color: color } },
323
+ '上次自动更新(' + (a.updatedAtText || '') + '):' + text)
324
+ ];
325
+ const lockedList = Array.isArray(a.skippedLocked) ? a.skippedLocked : (Array.isArray(a.locked) ? a.locked : []);
326
+ if (lockedList.length > 0) {
327
+ children.push(React.createElement('div', { key: 'l', style: { fontSize: '11.5px', color: '#9a6700', marginTop: '4px' } },
328
+ '跳过(文件被 DSH 占用):' + lockedList.map(function (x) { return x.name; }).join('、') + '——关闭 DSH 后可手动更新'));
329
+ }
330
+ if (failedList.length > 0) {
331
+ children.push(React.createElement('div', { key: 'f', style: { fontSize: '11.5px', color: '#cf222e', marginTop: '4px' } },
332
+ '失败项:' + failedList.map(function (x) {
333
+ const kindText = {
334
+ 'cache-skip': '缓存跳过,磁盘版本未变',
335
+ unverified: '版本到位但安装过程报错',
336
+ failed: '安装失败',
337
+ threw: '执行异常'
338
+ }[x.kind] || x.kind;
339
+ const hint = /EPERM|EBUSY|EACCES/i.test(x.output || '') ? '(文件被 DSH 占用,需手动关闭 DSH 后重试)' : '';
340
+ return x.name + ':' + kindText + hint;
341
+ }).join(';')));
342
+ }
343
+ autoLine = React.createElement('div', { style: { marginBottom: '8px' } }, children);
344
+ }
345
+
346
+ const notice = d.error
347
+ ? React.createElement('div', { style: S.err }, d.error)
348
+ : (d.note ? React.createElement('div', { style: S.ok }, d.note) : null);
349
+
350
+ let body;
351
+ if (d.loading && d.list.length === 0) body = React.createElement('div', { style: S.empty }, '读取中…');
352
+ else if (d.list.length === 0) body = React.createElement('div', { style: S.empty }, '还没有快照,点右上角创建一份');
353
+ else body = React.createElement('table', { style: S.table }, [
354
+ React.createElement('thead', { key: 'h' }, React.createElement('tr', null, [
355
+ React.createElement('th', { key: 'a', style: S.th }, '快照'),
356
+ React.createElement('th', { key: 'b', style: S.th }, '创建时间'),
357
+ React.createElement('th', { key: 'c', style: S.th }, '依赖数'),
358
+ React.createElement('th', { key: 'd', style: S.th }, '操作')
359
+ ])),
360
+ React.createElement('tbody', { key: 'b' }, d.list.map(function (s) {
361
+ return React.createElement('tr', { key: s.name }, [
362
+ React.createElement('td', { key: 'a', style: Object.assign({}, S.td, S.mono) }, s.name),
363
+ React.createElement('td', { key: 'b', style: S.td }, new Date(s.createdAt).toLocaleString()),
364
+ React.createElement('td', { key: 'c', style: S.td }, String(s.deps)),
365
+ React.createElement('td', { key: 'd', style: S.td },
366
+ React.createElement('button', { style: S.btn, onClick: function () { restore(s.name); } }, '恢复'))
367
+ ]);
368
+ }))
369
+ ]);
370
+
371
+ return React.createElement('div', null, [head, meta, notice, body]);
372
+ }
373
+
374
+ /* ---------------- 诊断 ---------------- */
375
+ function DoctorTab() {
376
+ const st = React.useState({ loading: true, data: null, error: null });
377
+ const d = st[0];
378
+ const setD = st[1];
379
+
380
+ const load = React.useCallback(function () {
381
+ setD(function (p) { return Object.assign({}, p, { loading: true, error: null }); });
382
+ get('/dsh-plugin-snapshot/api/doctor').then(function (j) {
383
+ if (!j || !j.ok) throw new Error((j && j.error) || '请求失败');
384
+ setD({ loading: false, data: j, error: null });
385
+ }).catch(function (e) {
386
+ setD({ loading: false, data: null, error: String((e && e.message) || e) });
387
+ });
388
+ }, []);
389
+
390
+ React.useEffect(function () { load(); }, [load]);
391
+
392
+ const head = React.createElement('div', { style: S.head }, [
393
+ React.createElement('h3', { key: 't', style: S.title }, '插件诊断'),
394
+ React.createElement('button', { key: 'b', style: S.btn, disabled: d.loading, onClick: function () { load(); } },
395
+ d.loading ? '扫描中…' : '重新扫描')
396
+ ]);
397
+
398
+ if (d.error) return React.createElement('div', null, [head, React.createElement('div', { style: S.err }, d.error)]);
399
+ if (!d.data) return React.createElement('div', null, [head, React.createElement('div', { style: S.empty }, '扫描中…')]);
400
+
401
+ const s = d.data.summary || {};
402
+ const meta = React.createElement('div', { style: S.meta },
403
+ '已装 ' + s.installed + ' · 已启用 ' + (s.unknownEnabled > 0 && s.enabled === 0 ? '未知' : s.enabled) + ' · 缺失 ' + s.missing
404
+ + ' · 不兼容 ' + s.incompatible + ' · 未验证 ' + s.untested + ' · 原生 ' + s.native);
405
+
406
+ const conflicts = d.data.conflicts || [];
407
+ const conflictBox = conflicts.length === 0
408
+ ? React.createElement('div', { style: S.empty }, '未发现已知冲突')
409
+ : React.createElement('table', { style: S.table }, [
410
+ React.createElement('thead', { key: 'h' }, React.createElement('tr', null, [
411
+ React.createElement('th', { key: 'a', style: S.th }, '级别'),
412
+ React.createElement('th', { key: 'b', style: S.th }, '类型'),
413
+ React.createElement('th', { key: 'c', style: S.th }, '说明')
414
+ ])),
415
+ React.createElement('tbody', { key: 'b' }, conflicts.map(function (c, i) {
416
+ const color = c.severity === 'critical' ? '#cf222e' : c.severity === 'high' ? '#9a6700' : '#57606a';
417
+ return React.createElement('tr', { key: i }, [
418
+ React.createElement('td', { key: 'a', style: Object.assign({}, S.td, { color: color, fontWeight: 600 }) }, c.severity),
419
+ React.createElement('td', { key: 'b', style: S.td }, c.kind),
420
+ React.createElement('td', { key: 'c', style: S.td }, c.message)
421
+ ]);
422
+ }))
423
+ ]);
424
+
425
+ return React.createElement('div', null, [head, meta, conflictBox]);
426
+ }
427
+
428
+ /* ---------------- 外壳 ---------------- */
429
+ function Panel() {
430
+ const st = React.useState('updates');
431
+ const tab = st[0];
432
+ const setTab = st[1];
433
+
434
+ function tabBtn(key, label) {
435
+ return React.createElement('button', {
436
+ key: key,
437
+ style: tab === key ? S.tabOn : S.tab,
438
+ onClick: function () { setTab(key); }
439
+ }, label);
440
+ }
441
+
442
+ let body;
443
+ if (tab === 'updates') body = React.createElement(UpdatesTab);
444
+ else if (tab === 'snapshots') body = React.createElement(SnapshotsTab);
445
+ else body = React.createElement(DoctorTab);
446
+
447
+ return React.createElement('div', { style: S.wrap }, [
448
+ React.createElement('div', { key: 'tabs', style: S.tabs }, [
449
+ tabBtn('updates', '插件更新'),
450
+ tabBtn('snapshots', '快照'),
451
+ tabBtn('doctor', '诊断')
452
+ ]),
453
+ React.createElement('div', { key: 'body' }, body)
454
+ ]);
455
+ }
456
+
457
+ const inject = ['slots'];
458
+
459
+ function apply(ctx) {
460
+ ctx.slots.inject('settings.section', function () {
461
+ return ctx.slots.register({
462
+ name: 'settings.section',
463
+ id: 'plugin-snapshot',
464
+ order: 50,
465
+ label: function () { return '插件更新'; }
466
+ }, Panel);
467
+ });
468
+ }
469
+
470
+ exports.name = 'dsh-plugin-snapshot';
471
+ exports.inject = inject;
472
+ exports.apply = apply;
473
+ return module.exports;
474
+ }
475
+ });
476
+
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 批量更新:一次 pnpm add 装多个包。
3
+ *
4
+ * 为什么不是「循环调单包接口」:
5
+ * - 每个包一次 pnpm add 会把依赖树重解 N 次,慢且容易在中间态失败;
6
+ * - 一次装多个只有一次解析、一次写 lock,失败也是整体失败,配合快照更好收场。
7
+ */
8
+ import { applyOne } from './auto-update.js';
9
+ import { validateInstallTargets } from './install-guard.js';
10
+ import { revertPackages } from './snapshot.js';
11
+
12
+ export function registerApplyAll(deps) {
13
+ const { reg, cfg, needProfile, send, readBody, createSnapshot, findPnpm } = deps;
14
+
15
+ reg('/dsh-plugin-snapshot/api/apply-all', async (request, response) => {
16
+ try {
17
+ const body = await readBody(request);
18
+ const list = Array.isArray(body && body.items) ? body.items.slice(0, 60) : [];
19
+ if (!list.length) return send(response, 200, { ok: false, error: '没有可更新的目标' });
20
+
21
+ const dir = needProfile(cfg);
22
+ const check = validateInstallTargets(dir, list.map((it) => ({ name: String((it && it.name) || ""), version: String((it && it.version) || "") })));
23
+ if (!check.approved.length) {
24
+ return send(response, 200, { ok: false, error: '目标全部未通过校验', rejected: check.rejected });
25
+ }
26
+
27
+ const pnpmCjs = findPnpm();
28
+ if (!pnpmCjs) return send(response, 200, { ok: false, error: '找不到 pnpm' });
29
+
30
+ const snapshot = createSnapshot(dir, cfg);
31
+ /* 逐包执行:单个包失败不影响其余,且能精确定位失败项 */
32
+ const results = [];
33
+ for (const it of check.approved) {
34
+ try {
35
+ const r = await applyOne(dir, pnpmCjs, { name: it.name, candidate: it.version, resolved: it.version }, cfg.logger);
36
+ results.push({ name: it.name, version: it.version, ok: r.ok, kind: r.kind });
37
+ } catch (e) {
38
+ results.push({ name: it.name, version: it.version, ok: false, kind: "threw", error: String((e && e.message) || e) });
39
+ }
40
+ }
41
+
42
+ const okList = results.filter((x) => x.ok);
43
+ const badList = results.filter((x) => !x.ok);
44
+
45
+ /* 部分失败:只把失败包的版本号改回快照值,成功的保留(与自动更新同一套分层回退) */
46
+ let recovery = 'none';
47
+ if (badList.length) {
48
+ try {
49
+ const reverted = revertPackages(dir, cfg, snapshot.name, badList.map((x) => x.name));
50
+ if (reverted.length === badList.length) {
51
+ recovery = 'package-level';
52
+ } else {
53
+ recovery = 'package-level-partial';
54
+ }
55
+ const inst = await runPnpm(pnpmCjs, ['install', '--no-frozen-lockfile', '--ignore-scripts'], dir, 600000);
56
+ if (inst.code !== 0) recovery += '-install-failed';
57
+ } catch (e) {
58
+ recovery = 'package-level-error';
59
+ }
60
+ }
61
+ send(response, 200, {
62
+ ok: badList.length === 0,
63
+ count: okList.length,
64
+ failedCount: badList.length,
65
+ failed: badList,
66
+ rejected: check.rejected,
67
+ snapshot: snapshot.name,
68
+ recovery,
69
+ needRestart: okList.length > 0
70
+ });
71
+ } catch (e) { send(response, 200, { ok: false, error: String((e && e.message) || e) }); }
72
+ }, { write: true });
73
+ }