@mingmaple/dsh-handover 0.0.0-stage → 0.9.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.
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/docs/migrate.md +61 -0
- package/docs/troubleshoot.md +48 -0
- package/package.json +55 -3
- package/scripts/blank-dsh.mjs +203 -0
- package/scripts/blank-smoke.mjs +820 -0
- package/scripts/build.mjs +58 -0
- package/scripts/check-docs.mjs +74 -0
- package/scripts/e2e-migration.mjs +127 -0
- package/scripts/lib/register.mjs +103 -0
- package/src/client.js +887 -0
- package/src/cordis.patch.yml +16 -0
- package/src/index.mjs +631 -0
- package/src/lib/absrefs.mjs +112 -0
- package/src/lib/api.mjs +234 -0
- package/src/lib/archive.mjs +198 -0
- package/src/lib/brief.mjs +74 -0
- package/src/lib/charter.mjs +28 -0
- package/src/lib/config.mjs +36 -0
- package/src/lib/consent.mjs +84 -0
- package/src/lib/drift.mjs +65 -0
- package/src/lib/events.mjs +89 -0
- package/src/lib/extra-prompt.mjs +41 -0
- package/src/lib/facts.mjs +70 -0
- package/src/lib/gates.mjs +148 -0
- package/src/lib/guide.mjs +99 -0
- package/src/lib/handover-io.mjs +49 -0
- package/src/lib/lint.mjs +152 -0
- package/src/lib/manual.mjs +124 -0
- package/src/lib/mover.mjs +663 -0
- package/src/lib/names.mjs +33 -0
- package/src/lib/output.mjs +11 -0
- package/src/lib/pack-ops.mjs +329 -0
- package/src/lib/peer.mjs +260 -0
- package/src/lib/phase.mjs +34 -0
- package/src/lib/profile.mjs +204 -0
- package/src/lib/registry.mjs +53 -0
- package/src/lib/retarget-helper.tmpl +46 -0
- package/src/lib/role.mjs +217 -0
- package/src/lib/rpc-carrier.mjs +65 -0
- package/src/lib/scaffold.mjs +118 -0
- package/src/lib/schedule.mjs +227 -0
- package/src/lib/session-ops.mjs +362 -0
- package/src/lib/sessions.mjs +142 -0
- package/src/lib/sevenzip.mjs +48 -0
- package/src/lib/status.mjs +171 -0
- package/src/lib/text.mjs +74 -0
- package/src/lib/webroute.mjs +130 -0
- package/src/lib/workspace.mjs +23 -0
- package/src/lib/zstd-log.mjs +65 -0
- package/src/selftest.mjs +84 -0
- package/src/templates/Changelog.skeleton.md +10 -0
- package/src/templates/Charter.skeleton.md +53 -0
- package/src/templates/Handover.global.skeleton.md +56 -0
- package/src/templates/Prompt_Templates.md +96 -0
- package/src/templates/Runtime_Manual.md +166 -0
- package/src/templates/rules/Handover_Rules.md +43 -0
- package/src/templates/rules/Migration_Rules.md +32 -0
- package/src/templates/rules/Misc_Task_Standard.md +23 -0
- package/src/templates/rules/Writing_Rules.md +24 -0
- package/src/tools/ho_brief.mjs +57 -0
- package/src/tools/ho_changelog.mjs +59 -0
- package/src/tools/ho_charter.mjs +89 -0
- package/src/tools/ho_checkpoint.mjs +125 -0
- package/src/tools/ho_diag.mjs +85 -0
- package/src/tools/ho_drift.mjs +41 -0
- package/src/tools/ho_gate.mjs +69 -0
- package/src/tools/ho_handover.mjs +83 -0
- package/src/tools/ho_init.mjs +82 -0
- package/src/tools/ho_pack.mjs +38 -0
- package/src/tools/ho_phase.mjs +37 -0
- package/src/tools/ho_profile.mjs +97 -0
- package/src/tools/ho_sessions.mjs +114 -0
- package/src/tools/ho_snapshot.mjs +103 -0
- package/src/tools/ho_tokens.mjs +57 -0
- package/src/tools/ho_workspace.mjs +213 -0
- package/src/tools/peer_list.mjs +37 -0
- package/src/tools/peer_new.mjs +75 -0
- package/src/tools/peer_peek.mjs +41 -0
- package/src/tools/peer_rename.mjs +31 -0
- package/src/tools/peer_send.mjs +52 -0
- package/src/tools/peer_status.mjs +46 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全局根目录搬迁(跨平台):探针 → 复制 → 全量校验 → 派后台助手在 DSH 退出后切换 → 状态 → 清旧副本。
|
|
3
|
+
*
|
|
4
|
+
* 平台无关的做法(Windows / Linux / macOS / 安卓 PRoot Ubuntu 同一套):
|
|
5
|
+
* - **链接**:Windows 用 junction,POSIX 用目录 symlink;都用 node:fs.symlinkSync,不需要管理员。
|
|
6
|
+
* - **助手**:一个纯 Node 脚本,用 DSH 自带的运行时(process.execPath + ELECTRON_RUN_AS_NODE=1)启动,
|
|
7
|
+
* 不依赖 PowerShell / bash。等待用「父进程 pid 还在不在」轮询(process.kill(pid,0)),不按进程名匹配。
|
|
8
|
+
* - **为什么不就地做**:源目录在 DSH 运行时被占用,Windows 下改名会报占用,
|
|
9
|
+
* 所以只能「先复制校验,等 DSH 退了再改名 + 建链接」。
|
|
10
|
+
*
|
|
11
|
+
* 本模块不持有进程状态,入参显式,便于空白环境单测。
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import fsp from 'node:fs/promises';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
|
|
20
|
+
const MARKER_NAME = 'dsh-handover.move.json';
|
|
21
|
+
const HELPER_NAME = 'dsh-handover.move.mjs';
|
|
22
|
+
const CMD_NAME = 'dsh-handover.move.cmd';
|
|
23
|
+
const SH_NAME = 'dsh-handover.move.sh';
|
|
24
|
+
const STAMP_REL = path.join('_Temp', '_Global', 'Scratch', '.dsh-handover-verified.json');
|
|
25
|
+
|
|
26
|
+
function msgOf(e) { return String(e && e.message ? e.message : e); }
|
|
27
|
+
function tail(p) { return String(p || '').replace(/[\\/]+$/, ''); }
|
|
28
|
+
function canon(p) { return tail(path.resolve(String(p || ''))).toLowerCase(); }
|
|
29
|
+
function samePath(a, b) { return !!a && !!b && canon(a) === canon(b); }
|
|
30
|
+
function isInside(parent, child) {
|
|
31
|
+
const p = canon(parent) + path.sep;
|
|
32
|
+
const c = canon(child) + path.sep;
|
|
33
|
+
return c.startsWith(p) && c !== p;
|
|
34
|
+
}
|
|
35
|
+
function linkKind() { return process.platform === 'win32' ? 'junction' : 'dir'; }
|
|
36
|
+
|
|
37
|
+
function stateDir() {
|
|
38
|
+
const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
|
|
39
|
+
return path.join(home, '.dsh', 'storages');
|
|
40
|
+
}
|
|
41
|
+
function markerPath() { return path.join(stateDir(), MARKER_NAME); }
|
|
42
|
+
function switchLogPath() { return path.join(stateDir(), 'dsh-handover.move.log'); }
|
|
43
|
+
export function scriptPaths() {
|
|
44
|
+
const d = stateDir();
|
|
45
|
+
return {
|
|
46
|
+
helper: path.join(d, HELPER_NAME),
|
|
47
|
+
cmd: path.join(d, CMD_NAME),
|
|
48
|
+
sh: path.join(d, SH_NAME),
|
|
49
|
+
log: switchLogPath(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readMarker() {
|
|
54
|
+
try {
|
|
55
|
+
const text = fs.readFileSync(markerPath(), 'utf8').replace(/^\uFEFF/, '');
|
|
56
|
+
return JSON.parse(text);
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
}
|
|
59
|
+
function writeMarker(obj) {
|
|
60
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
61
|
+
const p = markerPath();
|
|
62
|
+
const tmp = p + '.tmp';
|
|
63
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf8');
|
|
64
|
+
fs.renameSync(tmp, p);
|
|
65
|
+
return p;
|
|
66
|
+
}
|
|
67
|
+
function stampNow() {
|
|
68
|
+
const d = new Date(Date.now() + 8 * 3600 * 1000);
|
|
69
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
70
|
+
return d.getUTCFullYear() + '-' + p(d.getUTCMonth() + 1) + '-' + p(d.getUTCDate()) + ' ' + p(d.getUTCHours()) + ':' + p(d.getUTCMinutes()) + ' +0800';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function measure(dir) {
|
|
74
|
+
let files = 0; let bytes = 0; let dirs = 0;
|
|
75
|
+
const walk = async (d) => {
|
|
76
|
+
for (const e of await fsp.readdir(d, { withFileTypes: true })) {
|
|
77
|
+
const p = path.join(d, e.name);
|
|
78
|
+
if (e.isDirectory()) { dirs += 1; await walk(p); continue; }
|
|
79
|
+
files += 1;
|
|
80
|
+
try { bytes += (await fsp.stat(p)).size; } catch { /* 读取失败则跳过 */ }
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
await walk(dir);
|
|
84
|
+
return { files, bytes, dirs };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function hashFile(p) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const h = crypto.createHash('sha256');
|
|
90
|
+
const s = fs.createReadStream(p);
|
|
91
|
+
s.on('error', reject);
|
|
92
|
+
s.on('data', (c) => h.update(c));
|
|
93
|
+
s.on('end', () => resolve(h.digest('hex')));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function manifest(dir, onProgress) {
|
|
98
|
+
const rows = [];
|
|
99
|
+
const walk = async (d, rel) => {
|
|
100
|
+
const ents = await fsp.readdir(d, { withFileTypes: true });
|
|
101
|
+
ents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
102
|
+
for (const e of ents) {
|
|
103
|
+
const p = path.join(d, e.name);
|
|
104
|
+
const r = rel ? rel + '/' + e.name : e.name;
|
|
105
|
+
if (e.isDirectory()) { await walk(p, r); continue; }
|
|
106
|
+
let size = 0; let sum = '-';
|
|
107
|
+
try { size = (await fsp.stat(p)).size; sum = await hashFile(p); }
|
|
108
|
+
catch (err) { sum = 'ERR:' + msgOf(err); }
|
|
109
|
+
rows.push(r + '|' + size + '|' + sum);
|
|
110
|
+
if (onProgress && rows.length % 500 === 0) onProgress(rows.length);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
await walk(dir, '');
|
|
114
|
+
const digest = crypto.createHash('sha256').update(rows.join('\n')).digest('hex');
|
|
115
|
+
return { rows, digest, files: rows.length };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function freeSpaceOf(p) {
|
|
119
|
+
try { if (typeof fs.statfsSync !== 'function') return null; } catch { return null; }
|
|
120
|
+
try { const st = fs.statfsSync(p); return Number(st.bavail) * Number(st.bsize); } catch { /* 目标可能还不存在 */ }
|
|
121
|
+
try {
|
|
122
|
+
const st2 = fs.statfsSync(path.parse(path.resolve(p)).root);
|
|
123
|
+
return Number(st2.bavail) * Number(st2.bsize);
|
|
124
|
+
} catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function linkTargetOf(p) {
|
|
128
|
+
try {
|
|
129
|
+
const st = fs.lstatSync(p);
|
|
130
|
+
if (!st.isSymbolicLink()) return null;
|
|
131
|
+
return fs.readlinkSync(p);
|
|
132
|
+
} catch { return null; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fmtBytes(n) {
|
|
136
|
+
const v = Number(n) || 0;
|
|
137
|
+
if (v >= 1024 * 1024 * 1024) return (v / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
138
|
+
if (v >= 1024 * 1024) return (v / (1024 * 1024)).toFixed(1) + ' MB';
|
|
139
|
+
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB';
|
|
140
|
+
return v + ' B';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stampPathOf(dst) { return path.join(dst, STAMP_REL); }
|
|
144
|
+
function writeStamp(marker) {
|
|
145
|
+
const p = stampPathOf(marker.dst);
|
|
146
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
147
|
+
fs.writeFileSync(p, JSON.stringify({
|
|
148
|
+
digest: marker.digest, files: marker.files, bytes: marker.bytes,
|
|
149
|
+
from: marker.src, verifiedAt: marker.copiedAt, plugin: 'dsh-handover',
|
|
150
|
+
}, null, 2), 'utf8');
|
|
151
|
+
return p;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 探针:只读。 */
|
|
155
|
+
export async function probeMove({ src, to }) {
|
|
156
|
+
const problems = [];
|
|
157
|
+
const warnings = [];
|
|
158
|
+
if (!src) throw new Error('缺少当前全局根路径');
|
|
159
|
+
const s = tail(path.resolve(String(src)));
|
|
160
|
+
let st = null;
|
|
161
|
+
try { st = fs.lstatSync(s); } catch { st = null; }
|
|
162
|
+
if (!st) throw new Error('当前全局根不存在:' + s);
|
|
163
|
+
if (!st.isDirectory()) throw new Error('当前全局根不是目录:' + s);
|
|
164
|
+
|
|
165
|
+
const asLink = linkTargetOf(s);
|
|
166
|
+
if (asLink) warnings.push('当前根已经是链接(指向 ' + asLink + '),本次以它的真实位置为复制的源');
|
|
167
|
+
let copyFrom = s;
|
|
168
|
+
if (asLink) {
|
|
169
|
+
try { copyFrom = tail(fs.realpathSync(s)); } catch { copyFrom = tail(asLink); }
|
|
170
|
+
}
|
|
171
|
+
if (!fs.existsSync(path.join(copyFrom, '_Control_Center'))) warnings.push('源目录里没有 _Control_Center:确认这是全局根吗?');
|
|
172
|
+
|
|
173
|
+
const raw = String(to || '').trim();
|
|
174
|
+
if (!raw) throw new Error('缺少目标路径(to)');
|
|
175
|
+
if (!path.isAbsolute(raw)) problems.push('目标必须是绝对路径:' + raw);
|
|
176
|
+
// 语义:to=目标**父目录**(把整个根目录搬进去);basename 与当前根同名则直接当新根。
|
|
177
|
+
const toRaw = tail(path.resolve(raw));
|
|
178
|
+
const rootName = path.basename(s);
|
|
179
|
+
const t = path.basename(toRaw).toLowerCase() === rootName.toLowerCase() ? toRaw : path.join(toRaw, rootName);
|
|
180
|
+
if (samePath(t, s)) problems.push('目标与当前根相同');
|
|
181
|
+
else if (isInside(s, t)) problems.push('目标在当前根内部:' + t);
|
|
182
|
+
else if (isInside(t, s)) problems.push('目标是当前根的上级目录:' + t);
|
|
183
|
+
|
|
184
|
+
if (fs.existsSync(t)) {
|
|
185
|
+
let dstStat = null;
|
|
186
|
+
try { dstStat = fs.lstatSync(t); } catch { dstStat = null; }
|
|
187
|
+
if (!dstStat || !dstStat.isDirectory()) problems.push('目标已存在且不是目录:' + t);
|
|
188
|
+
else {
|
|
189
|
+
let kids = [];
|
|
190
|
+
try { kids = fs.readdirSync(t); } catch { kids = []; }
|
|
191
|
+
if (kids.length) problems.push('目标已存在且非空(' + kids.length + ' 项):' + t);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const srcSize = await measure(copyFrom);
|
|
196
|
+
const freeBytes = freeSpaceOf(t);
|
|
197
|
+
if (freeBytes !== null && freeBytes < srcSize.bytes * 1.15) {
|
|
198
|
+
problems.push('目标盘空间不足:可用 ' + fmtBytes(freeBytes) + ',需要约 ' + fmtBytes(srcSize.bytes * 1.15));
|
|
199
|
+
}
|
|
200
|
+
if (!freeBytes && freeBytes !== 0) warnings.push('无法获取目标盘可用空间(继续前请自行确认)');
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
ok: problems.length === 0,
|
|
204
|
+
src: s, copyFrom, parent: toRaw, dst: t,
|
|
205
|
+
srcIsLink: !!asLink, linkTarget: asLink || '',
|
|
206
|
+
files: srcSize.files, bytes: srcSize.bytes, dirs: srcSize.dirs,
|
|
207
|
+
freeBytes, problems, warnings,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 复制 + 全量校验 + 落标记 + 写助手脚本。失败即抛,不留可被误用的标记。 */
|
|
212
|
+
export async function startMove({ src, to, onProgress }) {
|
|
213
|
+
const p = await probeMove({ src, to });
|
|
214
|
+
if (!p.ok) throw new Error('预检未通过:' + p.problems.join(';'));
|
|
215
|
+
await fsp.mkdir(p.dst, { recursive: true });
|
|
216
|
+
if (onProgress) onProgress({ stage: 'copy', done: 0, total: p.files });
|
|
217
|
+
try {
|
|
218
|
+
await fsp.cp(p.copyFrom, p.dst, { recursive: true, force: false, errorOnExist: false, dereference: false, preserveTimestamps: true });
|
|
219
|
+
} catch (e) {
|
|
220
|
+
throw new Error('复制失败:' + msgOf(e) + '(目标可能已有部分文件,可清空后重跑)');
|
|
221
|
+
}
|
|
222
|
+
if (onProgress) onProgress({ stage: 'verify', done: 0, total: p.files });
|
|
223
|
+
const a = await manifest(p.copyFrom, (n) => { if (onProgress) onProgress({ stage: 'verify-src', done: n, total: p.files }); });
|
|
224
|
+
const b = await manifest(p.dst, (n) => { if (onProgress) onProgress({ stage: 'verify-dst', done: n, total: p.files }); });
|
|
225
|
+
const am = new Set(a.rows); const bm = new Set(b.rows);
|
|
226
|
+
const missing = []; const extra = [];
|
|
227
|
+
for (const r of a.rows) if (!bm.has(r)) missing.push(r.split('|')[0]);
|
|
228
|
+
for (const r of b.rows) if (!am.has(r)) extra.push(r.split('|')[0]);
|
|
229
|
+
const identical = a.digest === b.digest && a.files === b.files;
|
|
230
|
+
const marker = {
|
|
231
|
+
version: 2, src: p.src, copyFrom: p.copyFrom, dst: p.dst,
|
|
232
|
+
phase: identical ? 'verified' : 'partial',
|
|
233
|
+
files: a.files, bytes: p.bytes, digest: a.digest, copiedAt: stampNow(),
|
|
234
|
+
verified: identical,
|
|
235
|
+
missing: missing.slice(0, 20), extra: extra.slice(0, 20),
|
|
236
|
+
missingCount: missing.length, extraCount: extra.length,
|
|
237
|
+
script: scriptPaths(),
|
|
238
|
+
};
|
|
239
|
+
writeMarker(marker);
|
|
240
|
+
if (!identical) {
|
|
241
|
+
return { ok: false, marker, report: { files: a.files, bytes: p.bytes, digest: a.digest, copies: b.files, missing: missing.slice(0, 20), extra: extra.slice(0, 20), missingCount: missing.length, extraCount: extra.length } };
|
|
242
|
+
}
|
|
243
|
+
writeStamp(marker);
|
|
244
|
+
marker.script = writeHelper(marker);
|
|
245
|
+
writeMarker(marker);
|
|
246
|
+
return { ok: true, marker, report: { files: a.files, bytes: p.bytes, digest: a.digest, copies: b.files } };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 生成跨平台助手脚本(纯 Node,不依赖 PowerShell、bash)+ 两个手工兜底启动器。 */
|
|
250
|
+
function writeHelper(marker) {
|
|
251
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
252
|
+
const sp = scriptPaths();
|
|
253
|
+
const exe = (() => { try { return String(process.execPath || ''); } catch { return ''; } })();
|
|
254
|
+
const helper = `// dsh-handover 切换助手(由插件生成,勿手改;跨平台,纯 Node)
|
|
255
|
+
import fs from 'node:fs';
|
|
256
|
+
import path from 'node:path';
|
|
257
|
+
import { spawn } from 'node:child_process';
|
|
258
|
+
|
|
259
|
+
const MARKER = ${JSON.stringify(markerPath())};
|
|
260
|
+
const LOG = ${JSON.stringify(sp.log)};
|
|
261
|
+
const WAIT_PID = Number(process.argv[2] || 0) || 0;
|
|
262
|
+
const RELAUNCH = process.argv.indexOf('--relaunch') !== -1;
|
|
263
|
+
const EXE = ${JSON.stringify(exe)};
|
|
264
|
+
const ENV_SRC = ${JSON.stringify(marker.src)};
|
|
265
|
+
const ENV_DST = ${JSON.stringify(marker.dst)};
|
|
266
|
+
const RETARGET_HELPER = ${JSON.stringify((marker.retarget && marker.retarget.needed) ? marker.retarget.script : '')};
|
|
267
|
+
|
|
268
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
269
|
+
function log(m) {
|
|
270
|
+
const line = new Date().toISOString() + ' ' + m;
|
|
271
|
+
try { fs.appendFileSync(LOG, line + '\\n', 'utf8'); } catch { /* ignore */ }
|
|
272
|
+
try { console.log(m); } catch { /* ignore */ }
|
|
273
|
+
}
|
|
274
|
+
function alive(pid) {
|
|
275
|
+
if (!pid) return false;
|
|
276
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === 'EPERM'; }
|
|
277
|
+
}
|
|
278
|
+
function stamp(text) { return String(text || '').replace(/[\\/]+$/, '').toLowerCase(); }
|
|
279
|
+
function stop(m) { log('[中止] ' + m); process.exit(1); }
|
|
280
|
+
|
|
281
|
+
(async () => {
|
|
282
|
+
log('== dsh-handover 切换助手 == pid=' + process.pid + ' 等待 pid=' + WAIT_PID);
|
|
283
|
+
if (WAIT_PID) {
|
|
284
|
+
log('等待 DSH 退出(最多 30 分钟)…');
|
|
285
|
+
for (let i = 0; i < 900; i++) { if (!alive(WAIT_PID)) break; await sleep(2000); }
|
|
286
|
+
if (alive(WAIT_PID)) stop('等待 DSH 退出超时,未做任何改动');
|
|
287
|
+
await sleep(2000);
|
|
288
|
+
log('DSH 已退出,开始切换');
|
|
289
|
+
}
|
|
290
|
+
let m = null;
|
|
291
|
+
try { m = JSON.parse(fs.readFileSync(MARKER, 'utf8').replace(/^\\uFEFF/, '')); } catch { stop('读不到标记文件:' + MARKER); }
|
|
292
|
+
const src = m.src; const dst = m.dst;
|
|
293
|
+
log('源 :' + src);
|
|
294
|
+
log('目标:' + dst);
|
|
295
|
+
if (!fs.existsSync(dst)) stop('目标不存在:' + dst);
|
|
296
|
+
if (!fs.existsSync(src)) stop('源不存在:' + src);
|
|
297
|
+
const stampFile = path.join(dst, '_Temp', '_Global', 'Scratch', '.dsh-handover-verified.json');
|
|
298
|
+
if (!fs.existsSync(stampFile)) stop('目标里没有校验印章,它可能不是这次复制出来的');
|
|
299
|
+
let sp2 = null;
|
|
300
|
+
try { sp2 = JSON.parse(fs.readFileSync(stampFile, 'utf8')); } catch { stop('校验印章读不出来'); }
|
|
301
|
+
if (String(sp2.digest) !== String(m.digest)) stop('校验印章与标记不一致,目标可能被改动过');
|
|
302
|
+
log('校验印章匹配:' + sp2.files + ' 文件 · ' + String(m.digest).slice(0, 16) + '…');
|
|
303
|
+
|
|
304
|
+
const stampName = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 13).replace(/^(\\d{8})(\\d{4})/, '$1-$2');
|
|
305
|
+
const old = src + '.old-' + stampName;
|
|
306
|
+
try { fs.renameSync(src, old); } catch (e) { stop('改名失败(多半还有进程占用):' + e.message); }
|
|
307
|
+
log('① 已改名:' + src + ' → ' + old);
|
|
308
|
+
const kind = process.platform === 'win32' ? 'junction' : 'dir';
|
|
309
|
+
try { fs.symlinkSync(dst, src, kind); } catch (e) {
|
|
310
|
+
try { fs.renameSync(old, src); } catch { /* ignore */ }
|
|
311
|
+
stop('建链接失败,已把旧目录改回原名:' + e.message);
|
|
312
|
+
}
|
|
313
|
+
let target = '';
|
|
314
|
+
try { target = fs.readlinkSync(src); } catch { target = ''; }
|
|
315
|
+
if (!target || stamp(target) !== stamp(dst)) {
|
|
316
|
+
log('链接指向不对:' + target + '(旧副本在 ' + old + ')');
|
|
317
|
+
stop('切换校验失败');
|
|
318
|
+
}
|
|
319
|
+
m.phase = 'switched';
|
|
320
|
+
m.switchedAt = new Date().toISOString();
|
|
321
|
+
m.oldPath = old;
|
|
322
|
+
m.linkKind = kind;
|
|
323
|
+
try { fs.writeFileSync(MARKER, JSON.stringify(m, null, 2), 'utf8'); } catch { /* ignore */ }
|
|
324
|
+
log('② 已建 ' + kind + ':' + src + ' → ' + dst);
|
|
325
|
+
if (RETARGET_HELPER) {
|
|
326
|
+
try {
|
|
327
|
+
spawn(process.execPath, [RETARGET_HELPER, '0'], { detached: true, stdio: 'ignore', windowsHide: true, env: Object.assign({}, process.env, { ELECTRON_RUN_AS_NODE: '1' }) }).unref();
|
|
328
|
+
log('③ 已派登记修改登记助手(把 DSH 的登记改到新位置)');
|
|
329
|
+
} catch (e) {
|
|
330
|
+
log('修改登记助手未派成功:' + e.message + '(可在设置页点「把登记迁到新位置」)');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
log('切换完成。旧副本:' + old);
|
|
334
|
+
if (RELAUNCH && EXE) {
|
|
335
|
+
try {
|
|
336
|
+
spawn(EXE, [], { detached: true, stdio: 'ignore', env: Object.assign({}, process.env, { ELECTRON_RUN_AS_NODE: undefined }) }).unref();
|
|
337
|
+
log('已重新启动:' + EXE);
|
|
338
|
+
} catch (e) { log('重启失败:' + e.message); }
|
|
339
|
+
}
|
|
340
|
+
})();
|
|
341
|
+
`;
|
|
342
|
+
fs.writeFileSync(sp.helper, helper, 'utf8');
|
|
343
|
+
// 手工兜底:Windows 与 POSIX 各一个启动器,都把 ELECTRON_RUN_AS_NODE 打开(Electron 运行时当 node 用)
|
|
344
|
+
const cmd = ['@echo off', 'set ELECTRON_RUN_AS_NODE=1', '"' + exe + '" "' + sp.helper + '" %*', 'pause', ''].join('\r\n');
|
|
345
|
+
fs.writeFileSync(sp.cmd, cmd, 'utf8');
|
|
346
|
+
const sh = ['#!/bin/sh', 'export ELECTRON_RUN_AS_NODE=1', 'exec "' + exe + '" "' + sp.helper + '" "$@"', ''].join('\n');
|
|
347
|
+
fs.writeFileSync(sp.sh, sh, 'utf8');
|
|
348
|
+
try { fs.chmodSync(sp.sh, 0o755); } catch { /* Windows 上无意义 */ }
|
|
349
|
+
return sp;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 派后台助手:等本进程(DSH)退出后自动切换。 */
|
|
353
|
+
export function stageSwitch({ relaunch, waitPid } = {}) {
|
|
354
|
+
const m = readMarker();
|
|
355
|
+
if (!m || !m.src || !m.dst) throw new Error('没有可用搬迁标记,先跑一次 move');
|
|
356
|
+
const st = moveStatus({ src: m.src });
|
|
357
|
+
if (st.phase === 'switched' || st.phase === 'switched+cleaned') return { ok: true, already: true, phase: st.phase, log: switchLogPath() };
|
|
358
|
+
if (st.phase !== 'verified') throw new Error('当前不是「已复制并校验通过」(phase=' + st.phase + '),不能切换');
|
|
359
|
+
try { m.exe = process.execPath || ''; } catch { m.exe = ''; }
|
|
360
|
+
// 先把「登记修改登记」算清楚:哪些文件里还写着旧路径。切换助手完成后会立刻派它执行。
|
|
361
|
+
try {
|
|
362
|
+
const plan = retargetPlan();
|
|
363
|
+
if (plan.needed) {
|
|
364
|
+
const rh = writeRetargetHelper(plan);
|
|
365
|
+
m.retarget = { needed: true, script: rh.helper, log: rh.log, files: rh.files };
|
|
366
|
+
} else {
|
|
367
|
+
m.retarget = { needed: false, files: [] };
|
|
368
|
+
}
|
|
369
|
+
} catch { m.retarget = { needed: false, files: [] }; }
|
|
370
|
+
m.relaunch = relaunch === true;
|
|
371
|
+
m.stagedAt = stampNow();
|
|
372
|
+
writeMarker(m);
|
|
373
|
+
const sp = writeHelper(m);
|
|
374
|
+
try { fs.writeFileSync(sp.log, '', 'utf8'); } catch { /* ignore */ }
|
|
375
|
+
const pid = Number(waitPid || process.pid) || 0;
|
|
376
|
+
const args = [sp.helper, String(pid)];
|
|
377
|
+
if (relaunch === true) args.push('--relaunch');
|
|
378
|
+
const env = Object.assign({}, process.env, { ELECTRON_RUN_AS_NODE: '1' });
|
|
379
|
+
let child = null;
|
|
380
|
+
try {
|
|
381
|
+
child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', windowsHide: true, env });
|
|
382
|
+
child.unref();
|
|
383
|
+
} catch (e) {
|
|
384
|
+
return { ok: false, spawned: false, error: '派助手失败:' + msgOf(e), helper: sp.helper, cmd: sp.cmd, sh: sp.sh, log: sp.log };
|
|
385
|
+
}
|
|
386
|
+
return { ok: true, spawned: true, pid: child.pid, waitPid: pid, helper: sp.helper, cmd: sp.cmd, sh: sp.sh, log: sp.log, relaunch: relaunch === true };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 重建链接:原路径被手工删除(或在资源管理器里误删)后,把链接按原样建回来。
|
|
391
|
+
*
|
|
392
|
+
* 只在「原路径确实不存在」或「原路径是指向别处的链接」时动手;
|
|
393
|
+
* 遇到真实目录一律拒绝——那时候谁是真的数据已经说不清,必须人来判断。
|
|
394
|
+
*/
|
|
395
|
+
export function repairLink() {
|
|
396
|
+
const m = readMarker();
|
|
397
|
+
if (!m || !m.src || !m.dst) throw new Error('没有搬迁记录,无法重建链接');
|
|
398
|
+
if (!fs.existsSync(m.dst)) throw new Error('目标不存在(' + m.dst + '),先确认数据在哪里,本工具不动文件');
|
|
399
|
+
const kind = linkKind();
|
|
400
|
+
let st = null;
|
|
401
|
+
try { st = fs.lstatSync(m.src); } catch { st = null; }
|
|
402
|
+
if (st) {
|
|
403
|
+
const t = linkTargetOf(m.src);
|
|
404
|
+
if (t && samePath(t, m.dst)) return { ok: true, already: true, src: m.src, dst: m.dst, kind };
|
|
405
|
+
if (!st.isSymbolicLink()) {
|
|
406
|
+
throw new Error('原路径已存在且是真实目录(' + m.src + '),拒绝覆盖;请先确认哪一份才是真实数据');
|
|
407
|
+
}
|
|
408
|
+
try { fs.unlinkSync(m.src); } catch (e) { throw new Error('旧的链接删不掉:' + msgOf(e)); }
|
|
409
|
+
}
|
|
410
|
+
try { fs.mkdirSync(path.dirname(m.src), { recursive: true }); } catch { /* 父目录通常还在 */ }
|
|
411
|
+
try { fs.symlinkSync(m.dst, m.src, kind); } catch (e) {
|
|
412
|
+
throw new Error('建链接失败:' + msgOf(e) + '(若在安卓 /sdcard 这类不支持符号链接的挂载点上,请改用真实位置作为工作区根)');
|
|
413
|
+
}
|
|
414
|
+
const t2 = linkTargetOf(m.src);
|
|
415
|
+
if (!t2 || !samePath(t2, m.dst)) {
|
|
416
|
+
throw new Error('重建后校验失败:链接指向 ' + String(t2));
|
|
417
|
+
}
|
|
418
|
+
m.repairedAt = stampNow();
|
|
419
|
+
if (m.phase !== 'switched+cleaned') m.phase = 'switched';
|
|
420
|
+
try { writeMarker(m); } catch { /* ignore */ }
|
|
421
|
+
return { ok: true, repaired: true, src: m.src, dst: m.dst, kind, link: t2 };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------- 登记修改登记(真正的「搬过去」)
|
|
425
|
+
|
|
426
|
+
/** 需要修改登记的 DSH 存储文件:工作区表、聊天集成的归属表。 */
|
|
427
|
+
function retargetTargets() {
|
|
428
|
+
const home = process.env.USERPROFILE || process.env.HOME || os.homedir();
|
|
429
|
+
const root = path.join(home, '.dsh');
|
|
430
|
+
const out = [path.join(root, 'storages', 'workspace.json')];
|
|
431
|
+
try {
|
|
432
|
+
const integ = path.join(root, 'integrations');
|
|
433
|
+
for (const name of fs.readdirSync(integ)) {
|
|
434
|
+
const base = path.join(integ, name);
|
|
435
|
+
const w = path.join(base, 'workspaces.json');
|
|
436
|
+
try { if (fs.statSync(w).isFile()) out.push(w); } catch { /* 不存在则跳过 */ }
|
|
437
|
+
const acc = path.join(base, 'accounts');
|
|
438
|
+
let names = [];
|
|
439
|
+
try { names = fs.readdirSync(acc); } catch { names = []; }
|
|
440
|
+
for (const a of names) {
|
|
441
|
+
const s = path.join(acc, a, 'state.json');
|
|
442
|
+
try { if (fs.statSync(s).isFile()) out.push(s); } catch { /* 不存在则跳过 */ }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} catch { /* 没装集成 */ }
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 路径在 JSON 文本里的转义形态(只替换字符串值,不碰结构)。 */
|
|
450
|
+
function escPath(p) {
|
|
451
|
+
try { return JSON.stringify(String(p)).slice(1, -1); } catch { return String(p); }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function countOf(text, needle) {
|
|
455
|
+
if (!needle) return 0;
|
|
456
|
+
let n = 0;
|
|
457
|
+
let i = -1;
|
|
458
|
+
while ((i = text.indexOf(needle, i + 1)) !== -1) n += 1;
|
|
459
|
+
return n;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** 只算不改:哪些登记文件里还有旧路径、各多少处。 */
|
|
463
|
+
export function retargetPlan({ src, dst } = {}) {
|
|
464
|
+
const m = readMarker();
|
|
465
|
+
const S = tail(path.resolve(String(src || (m && m.src) || '')));
|
|
466
|
+
const D = tail(path.resolve(String(dst || (m && m.dst) || '')));
|
|
467
|
+
if (!S || S === '.' || !D) throw new Error('缺少源或目标路径');
|
|
468
|
+
// 已经同一位置(修改登记完成后当前根就是新位置)→ 没有可改的,别误报。
|
|
469
|
+
if (samePath(S, D)) return { src: S, dst: D, files: [], total: 0, needed: false };
|
|
470
|
+
const needle = escPath(S);
|
|
471
|
+
const files = [];
|
|
472
|
+
for (const p of retargetTargets()) {
|
|
473
|
+
let text = '';
|
|
474
|
+
try { text = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
475
|
+
files.push({ path: p, count: countOf(text, needle) });
|
|
476
|
+
}
|
|
477
|
+
const total = files.reduce((a, b) => a + b.count, 0);
|
|
478
|
+
return { src: S, dst: D, files, total, needed: total > 0 };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** 真改:先备份再整篇替换;只替换字符串里的路径,结构不动。 */
|
|
482
|
+
export function applyRetarget(opts) {
|
|
483
|
+
const plan = retargetPlan(opts);
|
|
484
|
+
const from = escPath(plan.src);
|
|
485
|
+
const to = escPath(plan.dst);
|
|
486
|
+
const written = [];
|
|
487
|
+
for (const f of plan.files) {
|
|
488
|
+
if (!f.count) continue;
|
|
489
|
+
let text = '';
|
|
490
|
+
try { text = fs.readFileSync(f.path, 'utf8'); } catch { continue; }
|
|
491
|
+
const bak = f.path + '.bak-' + new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
492
|
+
try { fs.copyFileSync(f.path, bak); } catch { continue; }
|
|
493
|
+
try { fs.writeFileSync(f.path, text.split(from).join(to), 'utf8'); } catch { continue; }
|
|
494
|
+
written.push({ path: f.path, count: f.count, backup: bak });
|
|
495
|
+
}
|
|
496
|
+
const m = readMarker();
|
|
497
|
+
if (m && written.length) {
|
|
498
|
+
m.retargetedAt = stampNow();
|
|
499
|
+
m.retargeted = written.map((w) => ({ path: w.path, count: w.count }));
|
|
500
|
+
try { writeMarker(m); } catch { /* ignore */ }
|
|
501
|
+
}
|
|
502
|
+
return { ok: true, written, plan, note: written.length ? '登记已改到新位置' : '没有需要改的登记' };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** 生成「只改登记」的助手(模板在同目录 retarget-helper.tmpl)。 */
|
|
506
|
+
function writeRetargetHelper(plan) {
|
|
507
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
508
|
+
const helper = path.join(stateDir(), 'dsh-handover.retarget.mjs');
|
|
509
|
+
const log = path.join(stateDir(), 'dsh-handover.retarget.log');
|
|
510
|
+
const tmplPath = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), 'retarget-helper.tmpl');
|
|
511
|
+
let tmpl = '';
|
|
512
|
+
try { tmpl = fs.readFileSync(tmplPath, 'utf8'); } catch (e) { throw new Error('读不到修改登记模板:' + msgOf(e)); }
|
|
513
|
+
const files = plan.files.filter((x) => x.count > 0).map((x) => x.path);
|
|
514
|
+
const text = tmpl.split('%%LOG%%').join(JSON.stringify(log))
|
|
515
|
+
.split('%%FROM%%').join(JSON.stringify(plan.src))
|
|
516
|
+
.split('%%TO%%').join(JSON.stringify(plan.dst))
|
|
517
|
+
.split('%%FILES%%').join(JSON.stringify(files, null, 2));
|
|
518
|
+
fs.writeFileSync(helper, text, 'utf8');
|
|
519
|
+
return { helper, log, from: plan.src, to: plan.dst, files };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** 派修改登记助手:等本进程退出后改写 DSH 登记。 */
|
|
523
|
+
export function stageRetarget() {
|
|
524
|
+
const plan = retargetPlan();
|
|
525
|
+
if (!plan.needed) return { ok: true, already: true, plan };
|
|
526
|
+
const sp = writeRetargetHelper(plan);
|
|
527
|
+
try { fs.writeFileSync(sp.log, '', 'utf8'); } catch { /* ignore */ }
|
|
528
|
+
const env = Object.assign({}, process.env, { ELECTRON_RUN_AS_NODE: '1' });
|
|
529
|
+
let child = null;
|
|
530
|
+
try {
|
|
531
|
+
child = spawn(process.execPath, [sp.helper, String(process.pid)], { detached: true, stdio: 'ignore', windowsHide: true, env });
|
|
532
|
+
child.unref();
|
|
533
|
+
} catch (e) {
|
|
534
|
+
return { ok: false, spawned: false, error: '派助手失败:' + msgOf(e), plan, script: sp };
|
|
535
|
+
}
|
|
536
|
+
return { ok: true, spawned: true, pid: child.pid, waitPid: process.pid, plan, script: sp };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** 状态:verified、switched、partial、none。 */
|
|
540
|
+
export function moveStatus({ src } = {}) {
|
|
541
|
+
const m = readMarker();
|
|
542
|
+
const s = tail(path.resolve(String(src || (m && m.src) || '.')));
|
|
543
|
+
const dst = (m && m.dst) || '';
|
|
544
|
+
const link = linkTargetOf(s);
|
|
545
|
+
let phase = 'none';
|
|
546
|
+
if (m && m.phase === 'switched+cleaned' && link && dst && samePath(link, dst)) phase = 'switched+cleaned';
|
|
547
|
+
else if (link && dst && samePath(link, dst)) phase = 'switched';
|
|
548
|
+
else if (m && m.phase === 'partial') phase = 'partial';
|
|
549
|
+
else if (m && m.phase === 'verified' && dst && fs.existsSync(dst)) phase = 'verified';
|
|
550
|
+
else if (m && m.phase) phase = m.phase;
|
|
551
|
+
|
|
552
|
+
const olds = [];
|
|
553
|
+
try {
|
|
554
|
+
const parent = path.dirname(s);
|
|
555
|
+
const base = path.basename(s) + '.old-';
|
|
556
|
+
for (const name of fs.readdirSync(parent)) {
|
|
557
|
+
if (name.indexOf(base) !== 0) continue;
|
|
558
|
+
const p = path.join(parent, name);
|
|
559
|
+
let bytes = 0;
|
|
560
|
+
try { bytes = measureSync(p); } catch { bytes = 0; }
|
|
561
|
+
olds.push({ path: p, bytes });
|
|
562
|
+
}
|
|
563
|
+
} catch { /* 源目录读取失败则跳过 */ }
|
|
564
|
+
|
|
565
|
+
// 这三种情形要让人一眼看见:原路径被手工删掉、目标不见了、链接悬空。
|
|
566
|
+
let srcExists = false;
|
|
567
|
+
try { fs.lstatSync(s); srcExists = true; } catch { srcExists = false; }
|
|
568
|
+
const dstExists = !!dst && fs.existsSync(dst);
|
|
569
|
+
const linkBroken = !!link && dst !== '' && !dstExists;
|
|
570
|
+
|
|
571
|
+
// 登记是否还指着旧路径:这是「搬过去了」与「只是能在新位置读写」的分界线。
|
|
572
|
+
let retarget = null;
|
|
573
|
+
try {
|
|
574
|
+
const p = retargetPlan({ src: s, dst });
|
|
575
|
+
retarget = { needed: p.needed, total: p.total, files: p.files.filter((x) => x.count > 0) };
|
|
576
|
+
} catch { retarget = null; }
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
phase, marker: m, src: s, dst, linkTarget: link || '', olds, script: scriptPaths(),
|
|
580
|
+
srcExists, dstExists, linkBroken, retarget,
|
|
581
|
+
canCleanup: phase === 'switched' && olds.length > 0,
|
|
582
|
+
canRepair: !srcExists && dstExists,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function measureSync(dir) {
|
|
587
|
+
let sum = 0;
|
|
588
|
+
const walk = (d) => {
|
|
589
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
590
|
+
const p = path.join(d, e.name);
|
|
591
|
+
if (e.isDirectory()) walk(p);
|
|
592
|
+
else { try { sum += fs.statSync(p).size; } catch { /* ignore */ } }
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
walk(dir);
|
|
596
|
+
return sum;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* 删除一棵目录树:逐项删除,不用递归 rm。
|
|
601
|
+
*
|
|
602
|
+
* Windows 上 fs.rm(recursive) 遇到超长路径(本库 Gradle 中间产物最长 439 字符)会整体报 EPERM;
|
|
603
|
+
* 逐项加 \\?\ 前缀再删则稳定成功。POSIX 上路径无需前缀,同一段代码可用。
|
|
604
|
+
*/
|
|
605
|
+
export async function rmTree(dir) {
|
|
606
|
+
const target = path.resolve(String(dir || ''));
|
|
607
|
+
const ns = (p) => (process.platform === 'win32' ? '\\\\?\\' + path.resolve(p) : path.resolve(p));
|
|
608
|
+
const fails = [];
|
|
609
|
+
const rmOne = (p, isDir) => {
|
|
610
|
+
for (let i = 0; i < 3; i++) {
|
|
611
|
+
try {
|
|
612
|
+
if (isDir) fs.rmdirSync(ns(p)); else fs.unlinkSync(ns(p));
|
|
613
|
+
return true;
|
|
614
|
+
} catch (e) {
|
|
615
|
+
if (e && e.code === 'ENOENT') return true;
|
|
616
|
+
try { fs.chmodSync(ns(p), 0o666); } catch { /* 只读属性清不掉就再试 */ }
|
|
617
|
+
try { fs.rmSync(ns(p), { force: true, maxRetries: 3, retryDelay: 100 }); return true; } catch { /* 下一轮再试 */ }
|
|
618
|
+
if (i === 2) { fails.push(p + ' :: ' + msgOf(e)); return false; }
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
};
|
|
623
|
+
const walk = (d) => {
|
|
624
|
+
let ents = [];
|
|
625
|
+
try { ents = fs.readdirSync(ns(d), { withFileTypes: true }); }
|
|
626
|
+
catch (e) { if (!e || e.code !== 'ENOENT') fails.push(d + ' :: ' + msgOf(e)); return; }
|
|
627
|
+
for (const e of ents) {
|
|
628
|
+
const p = path.join(d, e.name);
|
|
629
|
+
if (e.isDirectory() && !e.isSymbolicLink()) { walk(p); rmOne(p, true); }
|
|
630
|
+
else rmOne(p, false);
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
walk(target);
|
|
634
|
+
rmOne(target, true);
|
|
635
|
+
return { ok: fails.length === 0 && !fs.existsSync(ns(target)), target, fails: fails.slice(0, 10) };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export async function cleanupOld({ src, confirm } = {}) {
|
|
639
|
+
const st = moveStatus({ src });
|
|
640
|
+
if (st.phase !== 'switched') throw new Error('当前不是已切换状态(phase=' + st.phase + '),拒绝删除任何目录');
|
|
641
|
+
if (confirm !== true) throw new Error('删除旧副本需要明确确认(confirm=true)');
|
|
642
|
+
if (!st.olds.length) return { ok: true, removed: [], bytes: 0, note: '没有找到旧副本目录' };
|
|
643
|
+
const removed = []; const failed = []; let bytes = 0;
|
|
644
|
+
const base = path.basename(st.src) + '.old-';
|
|
645
|
+
for (const o of st.olds) {
|
|
646
|
+
if (path.basename(o.path).indexOf(base) !== 0) continue;
|
|
647
|
+
if (samePath(o.path, st.src) || (st.dst && samePath(o.path, st.dst))) continue;
|
|
648
|
+
let lstat = null;
|
|
649
|
+
try { lstat = fs.lstatSync(o.path); } catch { continue; }
|
|
650
|
+
if (!lstat.isDirectory() || lstat.isSymbolicLink()) continue;
|
|
651
|
+
const del = await rmTree(o.path);
|
|
652
|
+
if (!del.ok) { failed.push({ path: o.path, fails: del.fails }); continue; }
|
|
653
|
+
removed.push(o.path); bytes += o.bytes;
|
|
654
|
+
}
|
|
655
|
+
const m = readMarker();
|
|
656
|
+
if (m && failed.length === 0) { m.phase = 'switched+cleaned'; m.cleanedAt = stampNow(); m.olds = []; writeMarker(m); }
|
|
657
|
+
return {
|
|
658
|
+
ok: failed.length === 0,
|
|
659
|
+
removed, failed, bytes,
|
|
660
|
+
note: failed.length ? ('有 ' + failed.length + ' 个旧副本没删干净,可重试')
|
|
661
|
+
: (removed.length ? '已删除旧副本' : '没有可删的旧副本'),
|
|
662
|
+
};
|
|
663
|
+
}
|