dsh-plugin-snapshot 0.1.1 → 0.3.6

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.
@@ -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
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * 自动更新:DSH 启动后检查一次,逐包安装,退出码不可信,失败分层回退。
3
+ *
4
+ * 这一版的执行模型对齐桌面工具 v3.2.6 的升级链路:
5
+ * - 逐包执行:单包失败不影响其余,天然支持部分成功;
6
+ * - 退出码四态判定:回读 node_modules 里的真实版本,不信 pnpm 的退出码;
7
+ * - 瞬时失败重试一次(8 秒),确定性失败不重试;
8
+ * - 分层回退:部分成功走包级回退,全失败走整体恢复,恢复失败就停手。
9
+ *
10
+ * 插件形态做不到的那一条:EPERM 后关闭 DSH 再重试——关掉 DSH 等于自杀。
11
+ * 这种情况会如实标为 failed 并提示需要手动关闭后重试。
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { checkUpdates } from './updates.js';
16
+ import { createSnapshot, restoreSnapshot, revertPackages } from './snapshot.js';
17
+ import { parseSemver, atomicWrite, dataDir, tsStr } from './util.js';
18
+ import { findPnpm, runPnpm } from './exec.js';
19
+ import { partitionByProbe } from './probe.js';
20
+
21
+ const FILE = 'auto-update.json';
22
+
23
+ /** 网络类瞬时失败:值得再试一次 */
24
+ const TRANSIENT = /ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|Tarball|ERR_PNPM_FETCH(?!_404)/i;
25
+ /** 确定性失败:重试不会改变结果,直接放弃 */
26
+ const DETERMINISTIC = /Unable to detect compiler type|ERR_PNPM_UNSUPPORTED_ENGINE|ERR_PNPM_NO_MATCHING_VERSION|ERR_PNPM_FETCH_404|404 Not Found|ERR_PNPM_OUTDATED_LOCKFILE/i;
27
+ /** 已知无害的退出码 1:构建脚本被忽略,但文件已到位 */
28
+
29
+ export function statePath(cfg) {
30
+ const base = (cfg && cfg.dataDir) ? cfg.dataDir : dataDir();
31
+ return join(base, FILE);
32
+ }
33
+
34
+ export function readAutoState(cfg) {
35
+ try { return JSON.parse(readFileSync(statePath(cfg), 'utf8')); }
36
+ catch (e) { return null; }
37
+ }
38
+
39
+ function writeAutoState(cfg, patch) {
40
+ const next = Object.assign({ updatedAt: Date.now(), updatedAtText: tsStr() }, patch);
41
+ try { atomicWrite(statePath(cfg), JSON.stringify(next, null, 2)); } catch (e) { /* 记录失败不影响主流程 */ }
42
+ return next;
43
+ }
44
+
45
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
46
+
47
+ /** 判定候选版本是否在允许的升级档位内;预发布永不自动安装 */
48
+ export function isAllowedLevel(resolved, candidate, scope) {
49
+ const a = parseSemver(resolved);
50
+ const b = parseSemver(candidate);
51
+ if (!a || !b) return false;
52
+ if (b.pre) return false;
53
+ const s = scope || 'patch';
54
+ if (s === 'patch') return a.major === b.major && a.minor === b.minor && b.patch > a.patch;
55
+ if (s === 'minor') {
56
+ if (a.major !== b.major) return false;
57
+ if (b.minor > a.minor) return true;
58
+ return b.minor === a.minor && b.patch > a.patch;
59
+ }
60
+ return b.major > a.major || b.minor > a.minor || b.patch > a.patch;
61
+ }
62
+
63
+ /** 兼容旧名 */
64
+ export function isPatchLevel(resolved, candidate) {
65
+ return isAllowedLevel(resolved, candidate, 'patch');
66
+ }
67
+
68
+ /** 回读磁盘上的真实版本——这是「退出码不可信」的落地方式 */
69
+ export function verifyInstalled(dir, name, want) {
70
+ try {
71
+ const p = join(dir, 'node_modules', ...String(name).split('/'), 'package.json');
72
+ const v = JSON.parse(readFileSync(p, 'utf8')).version;
73
+ if (String(v) === String(want)) return true;
74
+ /* 忽略 build metadata:pnpm 可能把 1.2.3+build9 规范化成 1.2.3 */
75
+ const a = parseSemver(v);
76
+ const b = parseSemver(want);
77
+ if (!a || !b) return false;
78
+ if (a.major !== b.major || a.minor !== b.minor || a.patch !== b.patch) return false;
79
+ return String(a.pre || '') === String(b.pre || '');
80
+ } catch (e) { return false; }
81
+ }
82
+
83
+ /**
84
+ * 四态判定:把退出码与磁盘版本交叉。
85
+ * 0/到位 → 成功
86
+ * 0/未到位 → 失败(可能被缓存跳过)
87
+ * ≠0/到位 + 仅构建脚本告警 → 成功 + 警告
88
+ * ≠0/到位 + 其他错误 → 「安装结果未知」,按失败处理
89
+ * ≠0/未到位 → 失败
90
+ */
91
+ export function classifyResult(exitCode, installed, output) {
92
+ const out = String(output || '');
93
+ if (exitCode === 0 && installed) return { ok: true, kind: "success" };
94
+ if (exitCode === 0 && !installed) return { ok: false, kind: "cache-skip" };
95
+ if (exitCode !== 0 && installed) {
96
+ /* 磁盘版本到位 = 目标达成,这是唯一可信的判据。
97
+ 只有「目标包根本没装上」类错误才判失败。
98
+ 不要用白名单反向枚举:PEER_DEP_ISSUES、EPERM、超时后输出为空、
99
+ LOCKFILE_BREAKING_CHANGE 都会命中——而那时包其实已经装好了。 */
100
+ if (/ERR_PNPM_NO_MATCHING_VERSION|ERR_PNPM_UNSUPPORTED_ENGINE|ERR_PNPM_FETCH_404|404 Not Found/i.test(out)) {
101
+ return { ok: false, kind: "unverified" };
102
+ }
103
+ return { ok: true, kind: "warn", warning: "退出码非 0,但磁盘版本已到位" };
104
+ }
105
+ return { ok: false, kind: "failed" };
106
+ }
107
+
108
+ /** 输出里是否只有「构建脚本被忽略」这类无害告警 */
109
+ export function onlyBenignBuildWarnings(output) {
110
+ const out = String(output || '');
111
+ /* 白名单判定:只认已知无害的 pnpm 错误码,其余一律判失败。
112
+ 早期实现是黑名单 + 末行无条件 return true,任何未知错误码都会被吞成成功。 */
113
+ const benign = ["ERR_PNPM_IGNORED_BUILDS", "ERR_PNPM_APPROVE_SCRIPTS", "ERR_PNPM_WARN_ONLY"];
114
+ const codes = out.match(/ERR_PNPM_[A-Z_]+/g) || [];
115
+ if (codes.some((c) => benign.indexOf(c) < 0)) return false;
116
+ /* 没有 pnpm 错误码时,只有明确的「构建脚本被忽略」提示才算无害 */
117
+ if (codes.length === 0) return /Ignored build scripts/i.test(out);
118
+ return true;
119
+ }
120
+
121
+ /** 装一个包:执行 → 校验 → 瞬时失败重试一次 → 四态判定 */
122
+ export async function applyOne(dir, pnpmCjs, t, logger) {
123
+ const spec = t.name + '@' + t.candidate;
124
+ const once = async () => {
125
+ const r = await runPnpm(pnpmCjs, ['add', spec, '--save-exact', '--ignore-scripts'], dir, 900000);
126
+ const installed = verifyInstalled(dir, t.name, t.candidate);
127
+ return { code: r.code, output: r.output, installed, timedOut: r.timedOut === true };
128
+ };
129
+
130
+ /* 三次退避:registry 抖动往往持续几十秒,单次 8 秒不够 */
131
+ const DELAYS = [8000, 30000, 90000];
132
+ let res = await once();
133
+ let verdict = classifyResult(res.code, res.installed, res.output);
134
+
135
+ for (let i = 0; i < DELAYS.length && !verdict.ok; i++) {
136
+ if (!TRANSIENT.test(res.output) || DETERMINISTIC.test(res.output)) break;
137
+ if (logger) logger.warn('[auto] ' + t.name + ' 瞬时失败,' + (DELAYS[i] / 1000) + ' 秒后第 ' + (i + 1) + ' 次重试');
138
+ await sleep(DELAYS[i]);
139
+ res = await once();
140
+ verdict = classifyResult(res.code, res.installed, res.output);
141
+ }
142
+
143
+ return Object.assign({}, t, verdict, {
144
+ exitCode: res.code,
145
+ timedOut: res.timedOut === true,
146
+ output: String(res.output).slice(-1200)
147
+ });
148
+ }
149
+
150
+ /**
151
+ * 跑一次自动更新。
152
+ * @returns {Promise<object>} 结果对象,同时写入 auto-update.json
153
+ */
154
+ export async function runAutoUpdate(cfg, needProfile, logger) {
155
+ const started = Date.now();
156
+ let dir;
157
+ try {
158
+ dir = needProfile(cfg);
159
+ } catch (e) {
160
+ return writeAutoState(cfg, { outcome: 'error', error: String((e && e.message) || e), durationMs: Date.now() - started });
161
+ }
162
+
163
+ let report;
164
+ try {
165
+ report = await checkUpdates(dir, cfg);
166
+ } catch (e) {
167
+ return writeAutoState(cfg, { outcome: 'error', error: String((e && e.message) || e), durationMs: Date.now() - started });
168
+ }
169
+
170
+ const exclude = Array.isArray(cfg && cfg.autoExclude) ? cfg.autoExclude : [];
171
+ const targets = (report.items || []).filter((x) => {
172
+ if (exclude.indexOf(x.name) >= 0) return false;
173
+ if (x.state !== 'updatable') return false;
174
+ return isAllowedLevel(x.resolved || x.declared, x.candidate, cfg && cfg.autoUpdateScope);
175
+ });
176
+
177
+ if (!targets.length) {
178
+ return writeAutoState(cfg, {
179
+ outcome: 'no-update',
180
+ checked: (report.items || []).length,
181
+ durationMs: Date.now() - started
182
+ });
183
+ }
184
+
185
+ const pnpmCjs = findPnpm();
186
+ if (!pnpmCjs) {
187
+ return writeAutoState(cfg, { outcome: 'error', error: '找不到 pnpm', targets: targets.map((t) => t.name) });
188
+ }
189
+
190
+ /* 占用探测:把正被 DSH 锁住的包从本轮计划里剔掉,避免装到一半才失败 */
191
+ const probe = partitionByProbe(dir, targets.map((x) => x.name));
192
+ const runnable = targets.filter((x) => probe.runnable.indexOf(x.name) >= 0);
193
+ if (probe.fatal && probe.fatal.length) {
194
+ /* 探针回滚失败:包目录已停留在临时名,本轮必须中止且明确告警 */
195
+ if (logger) logger.error("[auto] 探针回滚失败,已中止本轮: " + JSON.stringify(probe.fatal));
196
+ return writeAutoState(cfg, {
197
+ outcome: "probe-damaged",
198
+ fatal: probe.fatal,
199
+ durationMs: Date.now() - started
200
+ });
201
+ }
202
+ if (!runnable.length) {
203
+ if (logger) logger.warn("[auto] 全部目标都被占用,本次跳过");
204
+ return writeAutoState(cfg, {
205
+ outcome: "locked-abort",
206
+ locked: probe.locked,
207
+ blockedCount: targets.length,
208
+ durationMs: Date.now() - started
209
+ });
210
+ }
211
+ if (probe.locked.length && logger) {
212
+ logger.warn("[auto] 跳过被占用的 " + probe.locked.length + " 个: " + probe.locked.map((x) => x.name).join(", "));
213
+ }
214
+
215
+ let snapshot;
216
+ try {
217
+ snapshot = createSnapshot(dir, cfg);
218
+ } catch (e) {
219
+ return writeAutoState(cfg, { outcome: 'error', error: '快照失败,已放弃本次自动更新:' + String((e && e.message) || e) });
220
+ }
221
+
222
+ if (logger) logger.info('[auto] 开始逐包更新 ' + runnable.length + ' 个,快照 ' + snapshot.name);
223
+
224
+ const results = [];
225
+ for (const t of runnable) {
226
+ try {
227
+ results.push(await applyOne(dir, pnpmCjs, t, logger));
228
+ } catch (e) {
229
+ results.push(Object.assign({}, t, { ok: false, kind: 'threw', error: String((e && e.message) || e) }));
230
+ }
231
+ }
232
+
233
+ const succeeded = results.filter((r) => r.ok);
234
+ const failed = results.filter((r) => !r.ok);
235
+
236
+ /* ---- 分层回退 ---- */
237
+ let recovery = 'none';
238
+ if (failed.length && succeeded.length) {
239
+ /* 部分成功:只把失败包的版本号改回去,成功的保留 */
240
+ const names = failed.map((f) => f.name);
241
+ const reverted = revertPackages(dir, cfg, snapshot.name, names);
242
+ /* 回退数量对不上(例如失败包不在任何依赖节里)时不能声称「已包级回退」 */
243
+ const level = reverted.length === names.length ? 'package-level' : 'package-level-partial';
244
+ if (logger) logger.warn('[auto] 部分成功,包级回退 ' + reverted.length + '/' + names.length + ' 个');
245
+ const inst = await runPnpm(pnpmCjs, ['install', '--no-frozen-lockfile', '--ignore-scripts'], dir, 600000);
246
+ const instOk = inst.code === 0 || onlyBenignBuildWarnings(inst.output);
247
+ recovery = instOk ? level : level + '-install-failed';
248
+ } else if (failed.length && !succeeded.length) {
249
+ /* 全部失败:整体恢复 */
250
+ try {
251
+ restoreSnapshot(dir, cfg, snapshot.name);
252
+ recovery = 'full';
253
+ /* 清单回退了,磁盘也必须回退 —— 否则是「清单旧、node_modules 新」的假回滚 */
254
+ const inst1 = await runPnpm(pnpmCjs, ['install', '--frozen-lockfile', '--ignore-scripts'], dir, 900000);
255
+ if (inst1.code !== 0) {
256
+ const inst2 = await runPnpm(pnpmCjs, ['install', '--no-frozen-lockfile', '--ignore-scripts'], dir, 900000);
257
+ if (inst2.code !== 0) recovery = 'full-manifest-only';
258
+ }
259
+ if (logger) logger.warn('[auto] 全部失败,已整体恢复快照 ' + snapshot.name);
260
+ } catch (e) {
261
+ recovery = 'full-failed';
262
+ if (logger) logger.error('[auto] 整体恢复失败,需人工介入:' + String((e && e.message) || e));
263
+ }
264
+ }
265
+
266
+ const outcome = failed.length === 0
267
+ ? 'updated'
268
+ : (succeeded.length > 0 ? 'partial' : (recovery === 'full' ? 'failed-rolled-back' : 'failed-rollback-error'));
269
+
270
+ const state = writeAutoState(cfg, {
271
+ outcome,
272
+ snapshot: snapshot.name,
273
+ recovery,
274
+ count: succeeded.length,
275
+ failedCount: failed.length,
276
+ skippedLocked: probe.locked,
277
+ succeeded: succeeded.map((r) => ({ name: r.name, version: r.candidate, kind: r.kind })),
278
+ failed: failed.map((r) => ({ name: r.name, version: r.candidate, kind: r.kind, exitCode: r.exitCode, output: r.output })),
279
+ needRestart: succeeded.length > 0,
280
+ durationMs: Date.now() - started
281
+ });
282
+
283
+ if (logger) logger.info('[auto] 结束: ' + outcome + ',成功 ' + succeeded.length + ',失败 ' + failed.length);
284
+ return state;
285
+ }
286
+
package/lib/doctor.js CHANGED
@@ -62,28 +62,41 @@ export function judgeCompatibility(pkg, dshVersion) {
62
62
  }
63
63
 
64
64
  function patchIds(text) {
65
+ /* 早期实现用「缩进 >= 4 空格」判断 insert 块边界,2 空格缩进的合法 YAML 会漏采。
66
+ 改为记录 insert 行自身的缩进深度,块结束条件与宽度无关。 */
65
67
  const ids = [];
66
68
  let inInsert = false;
69
+ let insertIndent = -1;
67
70
  for (const raw of String(text).split(/\r?\n/)) {
68
71
  const line = raw.replace(/\s+$/, '');
69
- if (/^\s*-?\s*insert:\s*$/.test(line)) { inInsert = true; continue; }
72
+ const indent = (line.match(/^\s*/) || [''])[0].length;
73
+ if (/^\s*-?\s*insert:\s*$/.test(line)) { inInsert = true; insertIndent = indent; continue; }
70
74
  if (!inInsert) continue;
71
75
  if (/^\s*$/.test(line) || /^\s*#/.test(line)) continue;
72
- const m = /^\s*-?\s*id:\s*([^\s#]+)\s*$/.exec(line);
76
+ const m = /^\s*-?\s*id:\s*['"]?([^\s#'"]+)['"]?\s*$/.exec(line);
73
77
  if (m) { ids.push(m[1]); continue; }
74
- if (/^\s*-?\s*[a-zA-Z_]+:/.test(line) && !/^\s{4,}/.test(line)) inInsert = false;
78
+ /* 出现与 insert 同级或更浅的键,说明块已结束 */
79
+ if (indent <= insertIndent && /^\s*-?\s*[a-zA-Z_]+:/.test(line)) { inInsert = false; }
75
80
  }
76
81
  return Array.from(new Set(ids));
77
82
  }
78
83
 
79
84
  function hasNative(dir) {
85
+ /* 早期实现只扫 build/prebuilds/native 三个目录名,.node 放在 lib/ 或 dist/ 会漏报。
86
+ 改为全树递归(深度上限 4、条目上限 2000),每层 readdir 单独 catch。 */
87
+ let seen = 0;
80
88
  const walk = (d, depth) => {
81
- if (depth > 2) return false;
89
+ if (depth > 4 || seen > 2000) return false;
82
90
  let items = [];
83
91
  try { items = readdirSync(d, { withFileTypes: true }); } catch (e) { return false; }
84
92
  for (const e of items) {
93
+ if (++seen > 2000) return false;
85
94
  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;
95
+ if (e.isDirectory()) {
96
+ const n = e.name;
97
+ if (n === 'node_modules' || n === '.git' || n === 'test' || n === 'tests' || n === 'docs') continue;
98
+ if (walk(join(d, n), depth + 1)) return true;
99
+ }
87
100
  }
88
101
  return false;
89
102
  };
@@ -97,7 +110,9 @@ export function scanPlugins(profileDir, dshVersion) {
97
110
  const items = [];
98
111
  for (const name of Object.keys(prof.deps)) {
99
112
  const dir = join(nm, ...name.split('/'));
100
- const enabled = prof.bundles.indexOf(name) >= 0;
113
+ /* bundles 不存在或为空时无法判断启用状态:标 null 而不是一律 false */
114
+ const hasBundleList = Array.isArray(prof.bundles) && prof.bundles.length > 0;
115
+ const enabled = hasBundleList ? (prof.bundles.indexOf(name) >= 0) : null;
101
116
  const pkgFile = join(dir, 'package.json');
102
117
  if (!existsSync(pkgFile)) {
103
118
  items.push({ name, enabled, installed: false, error: '已声明但未安装(node_modules 缺失)', declared: String(prof.deps[name]) });
package/lib/exec.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 执行外部命令:定位 pnpm 并用受控参数调用。
3
+ *
4
+ * 注意:本文件是 ESM,不能出现 require(那会在运行时抛错并被 catch 吞掉,
5
+ * 表现为「找不到 pnpm」)。
6
+ */
7
+ import { execFile } from 'node:child_process';
8
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+
12
+ /** 传给 pnpm 子进程的环境变量白名单:剔除 NPM_TOKEN 等凭据 */
13
+ const SAFE_ENV = [
14
+ 'PATH', 'Path', 'PATHEXT', 'SystemRoot', 'windir', 'ComSpec', 'TEMP', 'TMP',
15
+ 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'HOMEDRIVE', 'HOMEPATH',
16
+ 'LANG', 'LC_ALL', 'TZ',
17
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
18
+ 'NPM_CONFIG_REGISTRY', 'npm_config_registry',
19
+ 'NPM_CONFIG_USERCONFIG', 'npm_config_userconfig', 'NPM_CONFIG_CACHE', 'npm_config_cache', 'COREPACK_HOME'
20
+ ];
21
+
22
+ function safeEnv() {
23
+ const out = {};
24
+ for (const k of SAFE_ENV) { if (process.env[k] !== undefined) out[k] = process.env[k]; }
25
+ out.ELECTRON_RUN_AS_NODE = '1';
26
+ return out;
27
+ }
28
+
29
+ /** 定位 pnpm 的 JS 入口:优先 DSH 运行时,其次 profile 自带的 */
30
+ export function findPnpm() {
31
+ const cands = [];
32
+ try {
33
+ const base = join(homedir(), '.dsh', 'dsh-runtimes');
34
+ for (const name of readdirSync(base)) {
35
+ cands.push(join(base, name, 'dependencies', 'pnpm', 'bin', 'pnpm.cjs'));
36
+ }
37
+ } catch (e) { /* 没有 runtimes 目录 */ }
38
+ cands.push(join(homedir(), '.dsh', 'profiles', 'desktop', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs'));
39
+ for (const c of cands) if (existsSync(c)) return c;
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * 从 node_modules/.modules.yaml 读 pnpm 的实际 store 目录。
45
+ * 不传 --store-dir 时 pnpm 会用自己的默认 store,与已装的 node_modules 不匹配,
46
+ * 直接报 ERR_PNPM_UNEXPECTED_STORE —— 这是自动更新全部失败的真实原因。
47
+ */
48
+ export function detectStoreDir(profileDir) {
49
+ const BS = String.fromCharCode(92);
50
+ try {
51
+ const f = join(profileDir, 'node_modules', '.modules.yaml');
52
+ const txt = readFileSync(f, 'utf8');
53
+ /* pnpm 8 写 JSON 风格(带引号);pnpm 9+ 写普通 YAML(不带引号)。两种都要认。 */
54
+ let v = null;
55
+ const m1 = new RegExp(String.fromCharCode(34) + "storeDir" + String.fromCharCode(34) + BS + "s*:" + BS + "s*" + String.fromCharCode(34) + "([^" + String.fromCharCode(34) + "]+)" + String.fromCharCode(34)).exec(txt);
56
+ if (m1) v = m1[1];
57
+ if (!v) {
58
+ const m2 = new RegExp("^storeDir:" + BS + "s*(.+)$", "m").exec(txt);
59
+ if (m2) {
60
+ let raw = m2[1].trim();
61
+ if (raw[0] === String.fromCharCode(34) || raw[0] === String.fromCharCode(39)) raw = raw.slice(1);
62
+ if (raw[raw.length - 1] === String.fromCharCode(34) || raw[raw.length - 1] === String.fromCharCode(39)) raw = raw.slice(0, -1);
63
+ if (raw) v = raw;
64
+ }
65
+ }
66
+ if (v) {
67
+ const out = v.split(BS + BS).join(BS).trim();
68
+ if (out) return out;
69
+ }
70
+ } catch (e) { /* 解析失败 */ }
71
+ /* 解析不出来就返回 null,让 pnpm 用默认 store。
72
+ 猜一个路径比不猜更危险:猜错会 ERR_PNPM_UNEXPECTED_STORE,每个包都失败。 */
73
+ return null;
74
+ }
75
+
76
+ /** 跑一次 pnpm,返回 { code, output },不抛异常 */
77
+ export function runPnpm(pnpmCjs, args, cwd, timeoutMs) {
78
+ /* 自动补 --store-dir:让 pnpm 用 node_modules 实际链的那个 store */
79
+ let finalArgs = args;
80
+ try {
81
+ if (args.indexOf('--store-dir') < 0) {
82
+ const store = detectStoreDir(cwd);
83
+ if (store) finalArgs = args.concat(['--store-dir', store]);
84
+ }
85
+ } catch (e) { /* 探测失败就用默认 */ }
86
+
87
+ return new Promise((resolve) => {
88
+ execFile(process.execPath, [pnpmCjs].concat(finalArgs), {
89
+ cwd,
90
+ timeout: timeoutMs || 600000,
91
+ maxBuffer: 32 * 1024 * 1024,
92
+ env: safeEnv()
93
+ }, (err, stdout, stderr) => {
94
+ const code = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
95
+ /* killed=true 表示被超时杀掉 */
96
+ const timedOut = !!(err && err.killed);
97
+ resolve({ code, timedOut, output: String(stdout || '') + String(stderr || '') });
98
+ });
99
+ });
100
+ }
101
+
package/lib/index.js CHANGED
@@ -19,12 +19,16 @@ try {
19
19
  defineTool = null;
20
20
  }
21
21
  if (typeof defineTool !== 'function') {
22
+ /* 宿主未提供 @deepseek-ai/dsh-tools 时降级为恒等函数:工具仍会注册,但不做 schema 校验。至少留一条日志。 */
23
+ console.warn('[dsh-plugin-snapshot] 未加载到 @deepseek-ai/dsh-tools,工具定义降级为恒等映射(无 schema 校验)');
22
24
  defineTool = (options) => options;
23
25
  }
24
26
  import { resolveProfileDir, readProfile } from './profile.js';
25
27
  import { createSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
26
28
  import { checkUpdates } from './updates.js';
27
29
  import { scanPlugins, detectConflicts, priorityOf } from './doctor.js';
30
+ import { mountRoutes } from './routes.js';
31
+ import { runAutoUpdate } from './auto-update.js';
28
32
  import { Logger, dataDir } from './util.js';
29
33
 
30
34
  export const name = 'dsh-plugin-snapshot';
@@ -39,14 +43,22 @@ const TOOL = {
39
43
 
40
44
  function makeCtx(config) {
41
45
  const cfg = config || {};
42
- return {
46
+ /* 透传用户的全部配置,再对已知字段做归一化。
47
+ 审查发现:早期实现只挑 6 个字段复制,导致 autoExclude / autoUpdateScope 全部丢失,
48
+ 用户在配置里写什么都无效。 */
49
+ return Object.assign({}, cfg, {
43
50
  profileDir: cfg.profileDir || '',
44
51
  registry: cfg.registry || '',
45
52
  dataDir: cfg.dataDir || dataDir(),
46
- snapshotsKeep: cfg.snapshotsKeep || 7,
47
- concurrency: cfg.concurrency || 8,
53
+ snapshotsKeep: Number(cfg.snapshotsKeep) > 0 ? Number(cfg.snapshotsKeep) : 7,
54
+ concurrency: Number(cfg.concurrency) > 0 ? Number(cfg.concurrency) : 8,
55
+ /* 默认关闭:安装本插件不应静默修改整个 profile 的依赖树 */
56
+ /* 配置可能来自 JSON5/YAML,写成字符串 "true" 时也应生效 */
57
+ autoUpdate: cfg.autoUpdate === true || String(cfg.autoUpdate).toLowerCase() === 'true',
58
+ autoUpdateScope: cfg.autoUpdateScope || 'patch',
59
+ autoExclude: Array.isArray(cfg.autoExclude) ? cfg.autoExclude : [],
48
60
  logger: new Logger()
49
- };
61
+ });
50
62
  }
51
63
 
52
64
  function needProfile(cfg) {
@@ -119,10 +131,11 @@ export function apply(ctx, config) {
119
131
  },
120
132
  async execute(args) {
121
133
  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);
134
+ /* 不改 cfg:registry 是单次调用的参数,写回会永久污染后续调用与自动更新 */
135
+ const localCfg = a.registry ? Object.assign({}, cfg, { registry: String(a.registry) }) : cfg;
136
+ const dir = needProfile(localCfg);
137
+ localCfg.logger.info('check updates in ' + dir);
138
+ const r = await checkUpdates(dir, localCfg);
126
139
  const items = a.onlyUpdatable ? r.items.filter((x) => x.state === 'updatable') : r.items;
127
140
  return { profile: r.profile, registry: r.registry, summary: r.summary, items: items.map((x) => ({
128
141
  name: x.name, declared: x.declared, resolved: x.resolved || '', candidate: x.candidate || '', state: x.state, reason: x.reason || ''
@@ -293,7 +306,31 @@ export function apply(ctx, config) {
293
306
  }
294
307
  }));
295
308
 
296
- ctx.logger?.info?.('[dsh-plugin-snapshot] tools registered on ' + needProfileSafe(cfg));
309
+ /* 设置页 UI 通过同源 HTTP 取数据:客户端不能直接调工具,只能走路由 */
310
+ ctx.inject(['webServer'], (hostCtx) => {
311
+ try {
312
+ mountRoutes(hostCtx, cfg, needProfile);
313
+ ctx.logger?.info?.('[dsh-plugin-snapshot] panel routes mounted');
314
+ } catch (e) {
315
+ ctx.logger?.warn?.('[dsh-plugin-snapshot] panel routes failed: ' + String((e && e.message) || e));
316
+ }
317
+ });
318
+ /* 自动更新默认不启用。只有配置里显式写 autoUpdate: true 才会注册定时器,
319
+ 避免「装一个快照工具,45 秒后它静默改了整个依赖树」。
320
+ 启用后的策略:默认仅补丁版、不自动重启、失败回滚到更新前的快照。 */
321
+ if (cfg.autoUpdate === true) {
322
+ ctx.effect(() => {
323
+ const autoTimer = setTimeout(() => {
324
+ runAutoUpdate(cfg, needProfile, cfg.logger).catch(() => {});
325
+ }, 45000);
326
+ return () => clearTimeout(autoTimer);
327
+ }, 'dsh-plugin-snapshot: auto update');
328
+ ctx.logger?.info?.('[dsh-plugin-snapshot] 自动更新已启用,scope=' + cfg.autoUpdateScope);
329
+ } else {
330
+ ctx.logger?.info?.('[dsh-plugin-snapshot] 自动更新未启用(配置 autoUpdate: true 可开启)');
331
+ }
332
+
333
+ ctx.logger?.info?.('[dsh-plugin-snapshot] tools registered on ' + needProfileSafe(cfg));
297
334
  });
298
335
 
299
336
  function needProfileSafe(c) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 安装目标校验。
3
+ *
4
+ * 审查发现的问题:写接口只校验包名/版本号格式,因此可以安装任意 npm 包、
5
+ * 可以降级、可以装 dist-tag(latest / next),与「不做安装」的边界声明矛盾。
6
+ *
7
+ * 这里收紧为:
8
+ * 1) 包必须已在 profile.dependencies 中(拒绝新增依赖);
9
+ * 2) 版本必须是精确的语义化版本(拒绝 latest / next 等 dist-tag);
10
+ * 3) 拒绝预发布版本;
11
+ * 4) 目标版本必须严格高于当前版本(拒绝降级与重复安装)。
12
+ */
13
+ import { readProfile, readLockVersions } from './profile.js';
14
+ import { parseSemver, cmpSemver, isValidPkgName } from './util.js';
15
+
16
+ /** 精确版本:1.2.3 / 1.2.3-beta.1 / 1.2.3+build */
17
+ const EXACT = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
18
+
19
+ export function validateInstallTargets(profileDir, items) {
20
+ const approved = [];
21
+ const rejected = [];
22
+
23
+ let deps = {};
24
+ let lock = {};
25
+ try { deps = readProfile(profileDir).deps || {}; } catch (e) { deps = {}; }
26
+ try { lock = readLockVersions(profileDir) || {}; } catch (e) { lock = {}; }
27
+
28
+ for (const it of items || []) {
29
+ const name = String((it && it.name) || '');
30
+ const version = String((it && it.version) || '');
31
+ const push = (reason) => rejected.push({ name, version, reason });
32
+
33
+ if (!isValidPkgName(name)) { push('非法包名'); continue; }
34
+ if (!EXACT.test(version)) { push('版本号必须是精确语义化版本,不接受 dist-tag'); continue; }
35
+ if (!Object.prototype.hasOwnProperty.call(deps, name)) { push('该包不在 profile 依赖中,拒绝新增'); continue; }
36
+
37
+ const curStr = String(lock[name] || deps[name]);
38
+ const next = parseSemver(version);
39
+ if (!next) { push('目标版本 ' + version + ' 无法解析'); continue; }
40
+ if (!parseSemver(curStr)) { push('当前声明 ' + curStr + ' 无法解析,无法判定升级方向'); continue; }
41
+ if (next.pre) { push('拒绝预发布版本'); continue; }
42
+ const cmp = cmpSemver(version, curStr);
43
+ if (cmp === null) { push('版本号无法解析'); continue; }
44
+ if (cmp <= 0) { push('目标版本不高于当前安装版本,拒绝降级/重复安装'); continue; }
45
+
46
+ approved.push({ name, version });
47
+ }
48
+
49
+ return { approved, rejected };
50
+ }
51
+