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 +19 -0
- package/README.md +28 -3
- package/client/client.js +476 -0
- package/lib/apply-all.js +73 -0
- package/lib/auto-update.js +286 -0
- package/lib/doctor.js +21 -6
- package/lib/exec.js +101 -0
- package/lib/index.js +46 -9
- package/lib/install-guard.js +51 -0
- package/lib/probe.js +118 -0
- package/lib/profile.js +6 -1
- package/lib/routes.js +244 -0
- package/lib/snapshot.js +78 -9
- package/lib/updates.js +24 -12
- package/lib/util.js +23 -2
- package/package.json +16 -4
package/lib/probe.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 占用探针:判断待更新的包是否正被运行中的 DSH 锁住。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要探:Windows 上 pnpm 替换包的实际动作是「整目录 rename」,
|
|
5
|
+
* 只要树内任意一个文件被「无删除共享」打开(已加载的 .node、被写开的数据文件),
|
|
6
|
+
* 整树 rename 就会失败。逐包更新时一旦撞上,前面的包已经写盘,后面的还没动,
|
|
7
|
+
* 只能整轮回退——探针的价值是提前把这几个包从计划里剔掉,避免白跑。
|
|
8
|
+
*
|
|
9
|
+
* 两种探测组合使用:
|
|
10
|
+
* 1) 目录级:把包目录 rename 到临时名再改回。最接近 pnpm 的真实动作。
|
|
11
|
+
* 2) 文件级:对 .node 做 open('r+')。捕获「目录可改名但原生模块被加载」的情况。
|
|
12
|
+
*
|
|
13
|
+
* 安全约定:目录 rename 成功后必须回滚;回滚失败会重试一次,
|
|
14
|
+
* 仍然失败则记 RESTORE_FAILED 并以只读方式报警(此时包目录停留在临时名)。
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readdirSync, renameSync, openSync, closeSync, statSync, lstatSync } from 'node:fs';
|
|
17
|
+
import { basename, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const MAX_DEPTH = 3;
|
|
20
|
+
const MAX_FILES = 200;
|
|
21
|
+
|
|
22
|
+
function findNodeFiles(dir, depth, out) {
|
|
23
|
+
if (depth > MAX_DEPTH || out.length >= MAX_FILES) return out;
|
|
24
|
+
let items = [];
|
|
25
|
+
try { items = readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
|
|
26
|
+
for (const e of items) {
|
|
27
|
+
if (out.length >= MAX_FILES) break;
|
|
28
|
+
const p = join(dir, e.name);
|
|
29
|
+
if (e.isDirectory()) {
|
|
30
|
+
if (e.name === '.bin') continue;
|
|
31
|
+
findNodeFiles(p, depth + 1, out);
|
|
32
|
+
} else if (e.isFile() && e.name.endsWith('.node')) {
|
|
33
|
+
out.push(p);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 探测一组包是否被占用。
|
|
41
|
+
* @param {string} profileDir profile 目录
|
|
42
|
+
* @param {string[]} names 要探测的包名
|
|
43
|
+
* @returns {{name:string, code:string, at:string}[]} 被占用的条目
|
|
44
|
+
*/
|
|
45
|
+
export function probeLocked(profileDir, names) {
|
|
46
|
+
const locked = [];
|
|
47
|
+
const stamp = Date.now().toString(36);
|
|
48
|
+
|
|
49
|
+
for (const name of names) {
|
|
50
|
+
const pkgDir = join(profileDir, 'node_modules', ...String(name).split('/'));
|
|
51
|
+
if (!existsSync(pkgDir)) continue;
|
|
52
|
+
/* pnpm 下 scoped 包常是 symlink:对它 rename 在部分平台会 EPERM,造成误报占用。
|
|
53
|
+
这种情况跳过目录级探测,只做 .node 文件级探测。 */
|
|
54
|
+
let isLink = false;
|
|
55
|
+
try { isLink = lstatSync(pkgDir).isSymbolicLink(); } catch (e) { isLink = false; }
|
|
56
|
+
if (isLink) {
|
|
57
|
+
const nodes2 = findNodeFiles(pkgDir, 0, []);
|
|
58
|
+
for (const f of nodes2) {
|
|
59
|
+
try { const fd = openSync(f, 'r+'); closeSync(fd); }
|
|
60
|
+
catch (e) { locked.push({ name, code: e.code || 'OPEN_FAILED', at: 'node:' + basename(f) }); break; }
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ---- 1) 目录级 rename 探测 ---- */
|
|
66
|
+
const tmp = pkgDir + '.__probe_' + stamp;
|
|
67
|
+
let renamed = false;
|
|
68
|
+
try {
|
|
69
|
+
renameSync(pkgDir, tmp);
|
|
70
|
+
renamed = true;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
locked.push({ name, code: e.code || 'RENAME_FAILED', at: 'dir' });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* 必须还原:还原失败重试一次,再失败就记下来 */
|
|
77
|
+
try {
|
|
78
|
+
renameSync(tmp, pkgDir);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
try {
|
|
81
|
+
renameSync(tmp, pkgDir);
|
|
82
|
+
} catch (e2) {
|
|
83
|
+
/* 目录停在临时名 = 包已损坏,必须让上层中止本轮并告警,不能当成「跳过」 */
|
|
84
|
+
locked.push({ name, code: 'RESTORE_FAILED', at: 'dir', fatal: true, tmpPath: tmp });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!renamed) continue;
|
|
89
|
+
|
|
90
|
+
/* ---- 2) .node 文件读写探测 ---- */
|
|
91
|
+
const nodes = findNodeFiles(pkgDir, 0, []);
|
|
92
|
+
for (const f of nodes) {
|
|
93
|
+
try {
|
|
94
|
+
const fd = openSync(f, 'r+');
|
|
95
|
+
closeSync(fd);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
locked.push({ name, code: e.code || 'OPEN_FAILED', at: 'node:' + basename(f) });
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return locked;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 探测并返回可执行 / 需跳过的两个集合 */
|
|
107
|
+
export function partitionByProbe(profileDir, names) {
|
|
108
|
+
const locked = probeLocked(profileDir, names);
|
|
109
|
+
const fatal = locked.filter((x) => x.fatal === true);
|
|
110
|
+
const blocked = new Set(locked.map((x) => x.name));
|
|
111
|
+
return {
|
|
112
|
+
locked,
|
|
113
|
+
fatal,
|
|
114
|
+
runnable: names.filter((n) => !blocked.has(n)),
|
|
115
|
+
skipped: names.filter((n) => blocked.has(n))
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
package/lib/profile.js
CHANGED
|
@@ -71,7 +71,12 @@ export function parseLockVersions(lockText) {
|
|
|
71
71
|
}
|
|
72
72
|
if (curName && indent === depIndent + 4) {
|
|
73
73
|
const m = /^version:\s*(.+)$/.exec(t);
|
|
74
|
-
if (m) {
|
|
74
|
+
if (m) {
|
|
75
|
+
/* pnpm 的 lockfile v9 会在版本后面挂 peer 依赖:1.2.0(@types/node@26.2.0)。
|
|
76
|
+
直接拿去比较会让所有带 peer 的包判成「未知」,必须剥掉括号部分。 */
|
|
77
|
+
out[curName] = m[1].trim().replace(/^['"]|['"]$/g, '').replace(/\(.*\)$/, '');
|
|
78
|
+
curName = null;
|
|
79
|
+
}
|
|
75
80
|
}
|
|
76
81
|
}
|
|
77
82
|
return out;
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页面板的数据与操作接口。
|
|
3
|
+
*
|
|
4
|
+
* 设计约束:
|
|
5
|
+
* - 只读接口(updates / doctor / snapshots)随时可调;
|
|
6
|
+
* - 写操作(snapshot.create / snapshot.restore / apply)都要求显式 body,
|
|
7
|
+
* 并且只动 profile 的三份清单或单个包的安装,不做批量猜测;
|
|
8
|
+
* - 所有异常都转成 { ok:false, error } 返回,不让路由 500 把前端打崩。
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from 'node:child_process';
|
|
11
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { checkUpdates } from './updates.js';
|
|
15
|
+
import { scanPlugins, detectConflicts, priorityOf } from './doctor.js';
|
|
16
|
+
import { createSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
|
|
17
|
+
import { registerApplyAll } from './apply-all.js';
|
|
18
|
+
import { readAutoState, verifyInstalled, classifyResult } from './auto-update.js';
|
|
19
|
+
import { validateInstallTargets } from './install-guard.js';
|
|
20
|
+
import { findPnpm, runPnpm } from './exec.js';
|
|
21
|
+
|
|
22
|
+
function send(response, code, payload) {
|
|
23
|
+
/* 请求体超限时调用方会 destroy socket,此时不能再写响应 */
|
|
24
|
+
if (response.writableEnded || response.destroyed) return;
|
|
25
|
+
response.statusCode = code;
|
|
26
|
+
response.setHeader('content-type', 'application/json; charset=utf-8');
|
|
27
|
+
response.setHeader('cache-control', 'no-store');
|
|
28
|
+
response.end(JSON.stringify(payload));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readBody(request, limit) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
let size = 0;
|
|
34
|
+
const chunks = [];
|
|
35
|
+
request.on('data', (c) => {
|
|
36
|
+
size += c.length;
|
|
37
|
+
if (size > (limit || 65536)) { request.destroy(); resolve(null); return; }
|
|
38
|
+
chunks.push(c);
|
|
39
|
+
});
|
|
40
|
+
request.on('end', () => {
|
|
41
|
+
if (!chunks.length) return resolve({});
|
|
42
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
43
|
+
catch (e) { resolve(null); }
|
|
44
|
+
});
|
|
45
|
+
request.on('error', () => resolve(null));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function mountRoutes(hostCtx, cfg, needProfile) {
|
|
50
|
+
const offs = [];
|
|
51
|
+
|
|
52
|
+
/* 写接口守卫。
|
|
53
|
+
跨站写防护靠三件事叠加:
|
|
54
|
+
1) 要求自定义头 X-DSH-Snapshot —— 跨源带自定义头会触发 CORS preflight,
|
|
55
|
+
而本服务不应答 preflight,浏览器会直接拦下请求;
|
|
56
|
+
2) 要求 application/json —— 跨源的 text/plain 简单请求不会被误接受;
|
|
57
|
+
3) 若带 Origin,必须与 Host 同源,且拒绝 Origin: null。 */
|
|
58
|
+
const guard = (request, response, opts) => {
|
|
59
|
+
const o = opts || {};
|
|
60
|
+
if (o.method && String(request.method || '').toUpperCase() !== o.method) {
|
|
61
|
+
send(response, 405, { ok: false, error: '方法不允许,需要 ' + o.method });
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (!o.write) return true;
|
|
65
|
+
if (String(request.headers['x-dsh-snapshot'] || '') !== '1') {
|
|
66
|
+
send(response, 403, { ok: false, error: '缺少 X-DSH-Snapshot 头,写操作被拒绝' });
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const ct = String(request.headers['content-type'] || '');
|
|
70
|
+
if (ct.indexOf('application/json') < 0) {
|
|
71
|
+
send(response, 415, { ok: false, error: '写操作只接受 application/json' });
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const origin = request.headers.origin;
|
|
75
|
+
if (origin) {
|
|
76
|
+
const host = request.headers.host;
|
|
77
|
+
let sameOrigin = false;
|
|
78
|
+
if (host && origin !== 'null') {
|
|
79
|
+
try { sameOrigin = new URL(origin).host === host; } catch (e) { sameOrigin = false; }
|
|
80
|
+
}
|
|
81
|
+
if (!sameOrigin) {
|
|
82
|
+
send(response, 403, { ok: false, error: '跨源写操作被拒绝' });
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/* 写操作节流:同接口 1.5 秒内只接受一次,避免被反复调用把快照保留位挤掉 */
|
|
90
|
+
const writeTimes = new Map();
|
|
91
|
+
const THROTTLE_MS = 1500;
|
|
92
|
+
|
|
93
|
+
const reg = (path, handler, opts) => {
|
|
94
|
+
const off = hostCtx.webServer.register({
|
|
95
|
+
kind: 'exact',
|
|
96
|
+
path,
|
|
97
|
+
handler: (request, response) => {
|
|
98
|
+
const o = Object.assign({}, opts, { path });
|
|
99
|
+
if (!guard(request, response, o)) return;
|
|
100
|
+
if (o.write) {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const prev = writeTimes.get(path) || 0;
|
|
103
|
+
if (now - prev < THROTTLE_MS) {
|
|
104
|
+
send(response, 429, { ok: false, error: '写操作过于频繁,请稍后再试' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
writeTimes.set(path, now);
|
|
108
|
+
}
|
|
109
|
+
return handler(request, response);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
offs.push(typeof off === 'function' ? off : () => {});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/* 只读:更新检测 */
|
|
116
|
+
reg('/dsh-plugin-snapshot/api/updates', async (request, response) => {
|
|
117
|
+
try {
|
|
118
|
+
const dir = needProfile(cfg);
|
|
119
|
+
const r = await checkUpdates(dir, cfg);
|
|
120
|
+
send(response, 200, {
|
|
121
|
+
ok: true,
|
|
122
|
+
profile: r.profile,
|
|
123
|
+
registry: r.registry,
|
|
124
|
+
summary: r.summary,
|
|
125
|
+
items: r.items.map((x) => ({
|
|
126
|
+
name: x.name,
|
|
127
|
+
declared: x.declared,
|
|
128
|
+
resolved: x.resolved || '',
|
|
129
|
+
candidate: x.candidate || '',
|
|
130
|
+
state: x.state,
|
|
131
|
+
reason: x.reason || ''
|
|
132
|
+
}))
|
|
133
|
+
});
|
|
134
|
+
} catch (e) { send(response, 200, { ok: false, error: String((e && e.message) || e) }); }
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/* 只读:诊断 */
|
|
138
|
+
reg('/dsh-plugin-snapshot/api/doctor', async (request, response) => {
|
|
139
|
+
try {
|
|
140
|
+
const dir = needProfile(cfg);
|
|
141
|
+
/* 宿主版本取不到就传 null,让兼容性判定返回 unknown,而不是用一个过期默认值假装兼容 */
|
|
142
|
+
const dshVersion = String((cfg && cfg.dshVersion) || process.env.DSH_VERSION || '').trim() || null;
|
|
143
|
+
const scan = scanPlugins(dir, dshVersion);
|
|
144
|
+
const conflicts = detectConflicts(scan.items);
|
|
145
|
+
const priority = scan.items
|
|
146
|
+
.map((it) => Object.assign({ name: it.name }, priorityOf(it, conflicts)))
|
|
147
|
+
.sort((a, b) => b.score - a.score)
|
|
148
|
+
.slice(0, 8)
|
|
149
|
+
.map((x) => ({ name: x.name, score: x.score, level: x.level, factors: x.factors }));
|
|
150
|
+
send(response, 200, {
|
|
151
|
+
ok: true,
|
|
152
|
+
profile: scan.profile,
|
|
153
|
+
dshVersion,
|
|
154
|
+
summary: {
|
|
155
|
+
installed: scan.items.filter((x) => x.installed !== false).length,
|
|
156
|
+
enabled: scan.items.filter((x) => x.enabled === true).length,
|
|
157
|
+
unknownEnabled: scan.items.filter((x) => x.enabled === null).length,
|
|
158
|
+
missing: scan.items.filter((x) => x.installed === false).length,
|
|
159
|
+
incompatible: scan.items.filter((x) => (x.compatibility || {}).state === 'incompatible').length,
|
|
160
|
+
untested: scan.items.filter((x) => (x.compatibility || {}).state === 'untested').length,
|
|
161
|
+
native: scan.items.filter((x) => x.hasNative).length
|
|
162
|
+
},
|
|
163
|
+
conflicts: conflicts.slice(0, 40).map((c) => ({ severity: c.severity, kind: c.kind, message: c.message })),
|
|
164
|
+
priority
|
|
165
|
+
});
|
|
166
|
+
} catch (e) { send(response, 200, { ok: false, error: String((e && e.message) || e) }); }
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/* 只读:快照列表 */
|
|
170
|
+
reg('/dsh-plugin-snapshot/api/snapshots', async (request, response) => {
|
|
171
|
+
try {
|
|
172
|
+
const list = listSnapshots(cfg).map((s) => ({
|
|
173
|
+
name: s.name,
|
|
174
|
+
createdAt: new Date(s.mtime).toISOString(),
|
|
175
|
+
deps: s.deps,
|
|
176
|
+
hasManifest: !!s.hasManifest
|
|
177
|
+
}));
|
|
178
|
+
send(response, 200, { ok: true, snapshots: list });
|
|
179
|
+
} catch (e) { send(response, 200, { ok: false, error: String((e && e.message) || e) }); }
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/* 写:创建快照 */
|
|
183
|
+
reg('/dsh-plugin-snapshot/api/snapshot/create', async (request, response) => {
|
|
184
|
+
try {
|
|
185
|
+
const dir = needProfile(cfg);
|
|
186
|
+
const s = createSnapshot(dir, cfg);
|
|
187
|
+
send(response, 200, { ok: true, created: s.name, files: s.files });
|
|
188
|
+
} catch (e) { send(response, 200, { ok: false, error: String((e && e.message) || e) }); }
|
|
189
|
+
}, { write: true });
|
|
190
|
+
|
|
191
|
+
/* 写:恢复快照 */
|
|
192
|
+
reg('/dsh-plugin-snapshot/api/snapshot/restore', async (request, response) => {
|
|
193
|
+
try {
|
|
194
|
+
const body = await readBody(request);
|
|
195
|
+
if (!body || !body.name) return send(response, 200, { ok: false, error: '缺少快照名' });
|
|
196
|
+
const dir = needProfile(cfg);
|
|
197
|
+
const r = restoreSnapshot(dir, cfg, String(body.name));
|
|
198
|
+
send(response, 200, { ok: true, from: r.from, restored: r.restored, warnings: r.warnings });
|
|
199
|
+
} catch (e) { send(response, 200, { ok: false, error: String((e && e.message) || e) }); }
|
|
200
|
+
}, { write: true });
|
|
201
|
+
|
|
202
|
+
/* 写:更新单个包 */
|
|
203
|
+
reg('/dsh-plugin-snapshot/api/apply', async (request, response) => {
|
|
204
|
+
try {
|
|
205
|
+
const body = await readBody(request);
|
|
206
|
+
if (!body || !body.name || !body.version) return send(response, 200, { ok: false, error: '缺少 name 或 version' });
|
|
207
|
+
const dir = needProfile(cfg);
|
|
208
|
+
/* 只允许:已安装的包 + 精确版本 + 严格升级 + 非预发布 */
|
|
209
|
+
const check = validateInstallTargets(dir, [{ name: body.name, version: body.version }]);
|
|
210
|
+
if (!check.approved.length) {
|
|
211
|
+
const reason = (check.rejected[0] && check.rejected[0].reason) || "目标被拒绝";
|
|
212
|
+
return send(response, 200, { ok: false, error: reason, rejected: check.rejected });
|
|
213
|
+
}
|
|
214
|
+
const target = check.approved[0];
|
|
215
|
+
const pnpmCjs = findPnpm();
|
|
216
|
+
if (!pnpmCjs) return send(response, 200, { ok: false, error: '找不到 pnpm,无法执行更新' });
|
|
217
|
+
const snapshot = createSnapshot(dir, cfg);
|
|
218
|
+
/* --ignore-scripts:不执行第三方包的生命周期脚本 */
|
|
219
|
+
const r = await runPnpm(pnpmCjs, ['add', target.name + '@' + target.version, '--save-exact', '--ignore-scripts'], dir, 600000);
|
|
220
|
+
const installed = verifyInstalled(dir, target.name, target.version);
|
|
221
|
+
const v = classifyResult(r.code, installed, r.output);
|
|
222
|
+
send(response, 200, {
|
|
223
|
+
ok: v.ok,
|
|
224
|
+
kind: v.kind,
|
|
225
|
+
code: r.code,
|
|
226
|
+
snapshot: snapshot.name,
|
|
227
|
+
output: r.output.slice(-4000),
|
|
228
|
+
needRestart: v.ok
|
|
229
|
+
});
|
|
230
|
+
} catch (e) { send(response, 200, { ok: false, error: String((e && e.message) || e) }); }
|
|
231
|
+
}, { write: true });
|
|
232
|
+
|
|
233
|
+
/* 只读:上次自动更新的结果 */
|
|
234
|
+
reg('/dsh-plugin-snapshot/api/auto', async (request, response) => {
|
|
235
|
+
try {
|
|
236
|
+
send(response, 200, { ok: true, state: readAutoState(cfg) });
|
|
237
|
+
} catch (e) { send(response, 200, { ok: false, error: String((e && e.message) || e) }); }
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
registerApplyAll({ reg, cfg, needProfile, send, readBody, createSnapshot, findPnpm, runPnpm });
|
|
241
|
+
|
|
242
|
+
return () => { for (const off of offs) { try { off(); } catch (e) { /* 忽略 */ } } };
|
|
243
|
+
}
|
|
244
|
+
|
package/lib/snapshot.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - 清理时保护正在使用的快照,按创建时间排序;
|
|
7
7
|
* - 恢复前校验 profile 身份,摘要不符只告警。
|
|
8
8
|
*/
|
|
9
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
9
|
+
import { renameSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
10
10
|
import { join, basename } from 'node:path';
|
|
11
11
|
import { atomicWrite, sha256, tsStr, isValidSnapName, dataDir, readJson } from './util.js';
|
|
12
12
|
|
|
@@ -21,6 +21,22 @@ export function snapRoot(cfg) {
|
|
|
21
21
|
function profileId(dir) {
|
|
22
22
|
return sha256(Buffer.from(String(dir))).slice(0, 16);
|
|
23
23
|
}
|
|
24
|
+
/** 与目录路径无关的 profile 标识:profile 名 + 排序后的依赖名集合 */
|
|
25
|
+
function profileKey(profileDir) {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
|
|
28
|
+
const names = Object.keys(pkg.dependencies || {}).sort().join(',');
|
|
29
|
+
return sha256(Buffer.from(String(pkg.name || '') + '|' + names)).slice(0, 16);
|
|
30
|
+
} catch (e) { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_JSON = 4 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/** 从现有文件推断缩进,写回时保留原风格 */
|
|
36
|
+
function detectIndent(text) {
|
|
37
|
+
const m = /\n([ \t]+)"/.exec(String(text || ''));
|
|
38
|
+
return m ? m[1] : ' ';
|
|
39
|
+
}
|
|
24
40
|
|
|
25
41
|
function uniqueDir(base) {
|
|
26
42
|
const stamp = tsStr();
|
|
@@ -30,7 +46,10 @@ function uniqueDir(base) {
|
|
|
30
46
|
return dir;
|
|
31
47
|
}
|
|
32
48
|
|
|
33
|
-
export function createSnapshot(profileDir, cfg) {
|
|
49
|
+
export function createSnapshot(profileDir, cfg, protectExtra) {
|
|
50
|
+
/* protectExtra:本次操作额外需要保留的快照名。
|
|
51
|
+
审查发现:restoreSnapshot 会先 createSnapshot,而 createSnapshot 内部 prune 只保护
|
|
52
|
+
新建的那一份,待恢复的目标若正好是最旧的,会在复制前被删掉(ENOENT)。 */
|
|
34
53
|
const base = snapRoot(cfg);
|
|
35
54
|
mkdirSync(base, { recursive: true });
|
|
36
55
|
const dir = uniqueDir(base);
|
|
@@ -60,16 +79,19 @@ export function createSnapshot(profileDir, cfg) {
|
|
|
60
79
|
createdAt: Date.now(),
|
|
61
80
|
created: tsStr(),
|
|
62
81
|
tool: 'dsh-plugin-snapshot',
|
|
82
|
+
profileKey: profileKey(profileDir),
|
|
63
83
|
files
|
|
64
84
|
}, null, 2));
|
|
65
85
|
} catch (e) { /* manifest 失败不影响恢复,只是少了身份校验 */ }
|
|
66
|
-
prune(cfg, [basename(dir)]);
|
|
86
|
+
prune(cfg, [basename(dir)].concat(Array.isArray(protectExtra) ? protectExtra : []));
|
|
67
87
|
return { name: basename(dir), dir, files: saved };
|
|
68
88
|
}
|
|
69
89
|
|
|
70
90
|
export function prune(cfg, protect) {
|
|
71
91
|
const base = snapRoot(cfg);
|
|
72
|
-
|
|
92
|
+
/* 审查发现:Math.max(1, NaN) 仍是 NaN,会让 over 判断永远为 false,prune 失效 */
|
|
93
|
+
const rawKeep = Number(cfg && cfg.snapshotsKeep);
|
|
94
|
+
const keep = Number.isFinite(rawKeep) && rawKeep >= 1 ? Math.floor(rawKeep) : 7;
|
|
73
95
|
const prot = new Set((protect || []).map((x) => basename(String(x))));
|
|
74
96
|
let items = [];
|
|
75
97
|
try { items = readdirSync(base); } catch (e) { return; }
|
|
@@ -103,7 +125,10 @@ export function listSnapshots(cfg) {
|
|
|
103
125
|
try { mtime = statSync(dir).mtimeMs; } catch (e) { /* 忽略 */ }
|
|
104
126
|
if (man && man.createdAt) mtime = man.createdAt;
|
|
105
127
|
let deps = 0;
|
|
106
|
-
try {
|
|
128
|
+
try {
|
|
129
|
+
const pf = join(dir, 'package.json');
|
|
130
|
+
deps = statSync(pf).size > MAX_JSON ? -1 : Object.keys(JSON.parse(readFileSync(pf, 'utf8')).dependencies || {}).length;
|
|
131
|
+
}
|
|
107
132
|
catch (e) { /* 忽略 */ }
|
|
108
133
|
out.push({ name, dir, mtime, deps, hasManifest: !!man, files: FILES.filter((f) => existsSync(join(dir, f))) });
|
|
109
134
|
}
|
|
@@ -124,10 +149,16 @@ export function restoreSnapshot(profileDir, cfg, name) {
|
|
|
124
149
|
if (!present.length) throw new Error('快照目录内没有可恢复的文件: ' + dir);
|
|
125
150
|
|
|
126
151
|
const man = readJson(join(dir, MANIFEST));
|
|
152
|
+
const warnings = [];
|
|
153
|
+
/* 注意:warnings 必须在任何 push 之前声明(TDZ) */
|
|
127
154
|
if (man && man.profileId && man.profileId !== profileId(profileDir)) {
|
|
128
|
-
|
|
155
|
+
/* 目录可能只是被移动/改名:用与路径无关的 profileKey 再判一次 */
|
|
156
|
+
const sameEnv = man.profileKey && man.profileKey === profileKey(profileDir);
|
|
157
|
+
if (!sameEnv) {
|
|
158
|
+
throw new Error("快照属于另一个 profile(" + String(man.profileDir || "?") + "),已拒绝恢复");
|
|
159
|
+
}
|
|
160
|
+
warnings.push("profile 目录路径与快照记录不同,按同一环境处理:" + String(man.profileDir || "?"));
|
|
129
161
|
}
|
|
130
|
-
const warnings = [];
|
|
131
162
|
if (man && man.files) {
|
|
132
163
|
for (const f of present) {
|
|
133
164
|
const meta = man.files[f];
|
|
@@ -138,12 +169,50 @@ export function restoreSnapshot(profileDir, cfg, name) {
|
|
|
138
169
|
}
|
|
139
170
|
}
|
|
140
171
|
|
|
141
|
-
|
|
172
|
+
/* 恢复前再拍一份「当前状态」,但必须把待恢复的目标列入保护名单,否则它可能被 prune 删除 */
|
|
173
|
+
createSnapshot(profileDir, cfg, [basename(dir)]);
|
|
174
|
+
if (!existsSync(dir)) throw new Error('待恢复的快照已不存在(可能被保留策略清理):' + basename(dir));
|
|
142
175
|
const restored = [];
|
|
143
176
|
for (const f of present) {
|
|
144
|
-
|
|
177
|
+
/* 先在目标目录写临时文件再 rename:单文件原子替换,
|
|
178
|
+
避免恢复中途崩溃留下「新旧混合」的 profile */
|
|
179
|
+
const target = join(profileDir, f);
|
|
180
|
+
const tmp = target + '.restore-' + process.pid + '-' + Date.now();
|
|
181
|
+
copyFileSync(join(dir, f), tmp);
|
|
182
|
+
renameSync(tmp, target);
|
|
145
183
|
restored.push(f);
|
|
146
184
|
}
|
|
147
185
|
return { restored, warnings, from: basename(dir) };
|
|
148
186
|
}
|
|
149
187
|
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 包级回退:只把指定包的版本号改回快照里的值,其余包的更新保留。
|
|
191
|
+
* 这是分层回退的第一层——部分成功时用它,避免让成功的包陪葬。
|
|
192
|
+
* @returns {string[]} 实际回退的包(name -> version 描述)
|
|
193
|
+
*/
|
|
194
|
+
export function revertPackages(profileDir, cfg, snapName, names) {
|
|
195
|
+
const dir = resolveSnap(cfg, snapName);
|
|
196
|
+
const curPath = join(profileDir, 'package.json');
|
|
197
|
+
const rawCur = readFileSync(curPath, 'utf8');
|
|
198
|
+
const snapPkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
199
|
+
const cur = JSON.parse(rawCur);
|
|
200
|
+
/* dependencies / devDependencies / optionalDependencies 三处都回退 */
|
|
201
|
+
const SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
202
|
+
const done = [];
|
|
203
|
+
for (const n of names) {
|
|
204
|
+
for (const sec of SECTIONS) {
|
|
205
|
+
const snapSec = snapPkg[sec] || {};
|
|
206
|
+
if (!Object.prototype.hasOwnProperty.call(snapSec, n)) continue;
|
|
207
|
+
if (!cur[sec]) cur[sec] = {};
|
|
208
|
+
cur[sec][n] = snapSec[n];
|
|
209
|
+
done.push(n + ' -> ' + snapSec[n]);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (done.length) {
|
|
214
|
+
const indent = detectIndent(rawCur);
|
|
215
|
+
atomicWrite(curPath, JSON.stringify(cur, null, indent) + String.fromCharCode(10));
|
|
216
|
+
}
|
|
217
|
+
return done;
|
|
218
|
+
}
|
package/lib/updates.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* 只读操作,不写 profile、不安装任何东西。
|
|
4
4
|
*/
|
|
5
5
|
import { readProfile, readLockVersions, specKind } from './profile.js';
|
|
6
|
-
import { parseSpec, parseSemver, cmpSemver,
|
|
6
|
+
import { parseSpec, parseSemver, cmpSemver, isValidPkgName } from './util.js';
|
|
7
|
+
import { judgeCompatibility } from './doctor.js';
|
|
7
8
|
|
|
8
9
|
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
9
10
|
|
|
@@ -46,6 +47,8 @@ async function mapLimit(items, limit, worker) {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export async function checkUpdates(profileDir, cfg) {
|
|
50
|
+
/* 宿主版本只能从外部获得;取不到时传 null,兼容性判定会返回 unknown 而不是假装兼容 */
|
|
51
|
+
const dshVersion = String((cfg && cfg.dshVersion) || process.env.DSH_VERSION || '').trim() || null;
|
|
49
52
|
const prof = readProfile(profileDir);
|
|
50
53
|
const lock = readLockVersions(profileDir);
|
|
51
54
|
const names = Object.keys(prof.deps);
|
|
@@ -68,15 +71,22 @@ export async function checkUpdates(profileDir, cfg) {
|
|
|
68
71
|
const lat = parseSemver(r.latest);
|
|
69
72
|
if (!cur || !lat) return { ...t, state: 'unknown', reason: '版本号无法解析', candidate: r.latest };
|
|
70
73
|
if (lat.pre) return { ...t, state: 'prerelease', reason: '候选是预发布版本', candidate: r.latest };
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
return {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
/* 先判方向:registry 的 latest 可能低于本地(包被回收、本地装了更高版本) */
|
|
75
|
+
const dir = cmpSemver(r.latest, cur.base);
|
|
76
|
+
if (dir === null) return { ...t, state: 'unknown', reason: '版本方向无法判断', candidate: r.latest };
|
|
77
|
+
if (dir === 0) return { ...t, state: 'uptodate', candidate: r.latest };
|
|
78
|
+
if (dir < 0) return { ...t, state: 'downgrade', reason: 'registry 最新版低于当前安装版本', candidate: r.latest };
|
|
79
|
+
|
|
80
|
+
/* 兼容性:作者声明的 dsh 兼容范围不含当前宿主版本时,不建议升级 */
|
|
81
|
+
const compat = judgeCompatibility({ dsh: r.dsh }, dshVersion);
|
|
82
|
+
if (compat.state === 'incompatible') {
|
|
83
|
+
return { ...t, state: 'incompatible', reason: compat.label, candidate: r.latest, compatibility: compat };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (cur.major !== lat.major) return { ...t, state: 'major', reason: '主版本变更,需人工确认', candidate: r.latest, compatibility: compat };
|
|
87
|
+
/* 0.x 的次版本变更按 semver 是破坏性的,等同主版本处理 */
|
|
88
|
+
if (cur.major === 0 && cur.minor !== lat.minor) return { ...t, state: 'major', reason: '0.x 次版本变更(破坏性),需人工确认', candidate: r.latest, compatibility: compat };
|
|
89
|
+
return { ...t, state: 'updatable', candidate: r.latest, compatibility: compat };
|
|
80
90
|
});
|
|
81
91
|
|
|
82
92
|
const summary = {
|
|
@@ -85,8 +95,10 @@ export async function checkUpdates(profileDir, cfg) {
|
|
|
85
95
|
major: results.filter((x) => x.state === 'major').length,
|
|
86
96
|
uptodate: results.filter((x) => x.state === 'uptodate').length,
|
|
87
97
|
failed: results.filter((x) => x.state === 'failed').length,
|
|
88
|
-
skipped: skipped.length
|
|
98
|
+
skipped: skipped.length,
|
|
99
|
+
downgrade: results.filter((x) => x.state === 'downgrade').length,
|
|
100
|
+
incompatible: results.filter((x) => x.state === 'incompatible').length
|
|
89
101
|
};
|
|
90
|
-
return { items: results, skipped, summary, profile: prof.name, registry: registryOf(cfg) };
|
|
102
|
+
return { items: results, skipped, summary, profile: prof.name, registry: registryOf(cfg), dshVersion };
|
|
91
103
|
}
|
|
92
104
|
|
package/lib/util.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* 通用工具:原子写、摘要、语义化版本、路径与日志。
|
|
3
3
|
* 本文件不依赖 Electron / DSH 内部包,可在纯 Node 下被单测直接覆盖。
|
|
4
4
|
*/
|
|
5
|
-
import { appendFileSync, existsSync, mkdirSync, openSync, closeSync, fsyncSync, renameSync, unlinkSync, writeFileSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, openSync, closeSync, fsyncSync, renameSync, unlinkSync, writeFileSync, readFileSync, readdirSync, statSync, rmSync } from 'node:fs';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
@@ -111,7 +111,13 @@ export class Logger {
|
|
|
111
111
|
this.lines = [];
|
|
112
112
|
}
|
|
113
113
|
write(level, msg) {
|
|
114
|
-
|
|
114
|
+
/* 写入前脱敏:日志可能包含 pnpm 输出的 registry 地址与凭据 */
|
|
115
|
+
const safe = String(msg)
|
|
116
|
+
.replace(/(_authToken[=:]\s*)\S+/gi, '$1***')
|
|
117
|
+
.replace(/\bnpm_[A-Za-z0-9]{16,}\b/g, 'npm_***')
|
|
118
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._-]{16,}/gi, '$1***');
|
|
119
|
+
const line = '[' + new Date().toISOString() + '] [' + level + '] ' + safe;
|
|
120
|
+
this.pruneOld();
|
|
115
121
|
this.lines.push(line);
|
|
116
122
|
if (this.lines.length > 200) this.lines.shift();
|
|
117
123
|
try {
|
|
@@ -119,6 +125,21 @@ export class Logger {
|
|
|
119
125
|
appendFileSync(this.file, line + '\n', 'utf8');
|
|
120
126
|
} catch (e) { /* 只影响日志,不影响功能 */ }
|
|
121
127
|
}
|
|
128
|
+
/** 只保留最近 7 天的日志 */
|
|
129
|
+
pruneOld() {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const SEVEN = 7 * 24 * 3600 * 1000;
|
|
132
|
+
if (this._pruned && now - this._pruned < 3600000) return;
|
|
133
|
+
this._pruned = now;
|
|
134
|
+
try {
|
|
135
|
+
for (const f of readdirSync(this.dir)) {
|
|
136
|
+
if (!f.startsWith('snapshot-') || !f.endsWith('.log')) continue;
|
|
137
|
+
const p = join(this.dir, f);
|
|
138
|
+
try { if (now - statSync(p).mtimeMs > SEVEN) rmSync(p, { force: true }); } catch (e) { /* 忽略单个文件 */ }
|
|
139
|
+
}
|
|
140
|
+
} catch (e) { /* 目录不存在时忽略 */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
122
143
|
info(m) { this.write('info', m); }
|
|
123
144
|
warn(m) { this.write('warn', m); }
|
|
124
145
|
error(m) { this.write('error', m); }
|