pi-webdav-backup 1.0.2 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -23
- package/extensions/webdav-backup/apply.mjs +319 -0
- package/extensions/webdav-backup/backup.mjs +167 -87
- package/extensions/webdav-backup/cli.mjs +475 -357
- package/extensions/webdav-backup/collect.mjs +104 -2
- package/extensions/webdav-backup/config.mjs +53 -7
- package/extensions/webdav-backup/dav-mock.mjs +166 -145
- package/extensions/webdav-backup/extras.mjs +420 -0
- package/extensions/webdav-backup/packages.mjs +397 -0
- package/extensions/webdav-backup/restore.mjs +17 -0
- package/extensions/webdav-backup/test-apply.mjs +318 -0
- package/extensions/webdav-backup/test-extras.mjs +314 -0
- package/extensions/webdav-backup/test-plugins.mjs +363 -0
- package/extensions/webdav-backup/test-restore.mjs +286 -219
- package/extensions/webdav-backup/webdav-backup.example.json +4 -9
- package/extensions/webdav-backup/webdav.mjs +258 -218
- package/extensions/webdav-backup.ts +1024 -766
- package/package.json +2 -2
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extras.mjs — agentDir 之外的额外数据(`extra/**`)的归档、映射与归位
|
|
3
|
+
*
|
|
4
|
+
* 背景:pi 的 agentDir 默认是 ~/.pi/agent,但有些配置/插件数据写在别处:
|
|
5
|
+
* ~/.pi/settings.json (旧版 pi 遗留的包声明)
|
|
6
|
+
* ~/.pi/web-search.json (pi-web-access 的回退位置)
|
|
7
|
+
* ~/.config/rpiv-<name>/config.json (XDG 配置,rpiv-* 插件)
|
|
8
|
+
* 这些路径通过 webdav-backup.json 的 extraScope 被打进归档的 `extra/` 前缀下。
|
|
9
|
+
*
|
|
10
|
+
* 跨机可移植(本模块核心):
|
|
11
|
+
* 备份时记录 `extra-manifest.json`,标明每个文件“来自哪个锚点”(home / config / appdata)
|
|
12
|
+
* 以及相对锚点的尾巴。恢复时按【当前机器】的锚点重算目标路径:
|
|
13
|
+
* 旧机 /home/alice/.pi/settings.json → 新机 /home/bob/.pi/settings.json
|
|
14
|
+
* 旧机 C:/Users/alice/.pi/settings.json → 新机 D:/Users/carol/.pi/settings.json
|
|
15
|
+
* 不在任何锚点下的绝对路径(如 /etc/foo)保持原样,并标记为“未映射”以便提醒。
|
|
16
|
+
*
|
|
17
|
+
* 安全:只识别本工具生成的 extra/ 布局;目标路径由锚点 + 尾巴拼出,拒绝 .. 穿越。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
statSync,
|
|
27
|
+
lstatSync,
|
|
28
|
+
} from "node:fs";
|
|
29
|
+
import { homedir, hostname } from "node:os";
|
|
30
|
+
import { join, dirname, resolve, sep } from "node:path";
|
|
31
|
+
|
|
32
|
+
export const EXTRA_PREFIX = "extra/";
|
|
33
|
+
export const EXTRA_MANIFEST_NAME = "extra-manifest.json";
|
|
34
|
+
|
|
35
|
+
/** 归档内统一用 `/` 分隔 */
|
|
36
|
+
function norm(p) {
|
|
37
|
+
return String(p).replace(/\\/g, "/");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** child 是否在 parent 之下;返回尾巴(相对 parent),否则 null。Windows 大小写不敏感。 */
|
|
41
|
+
function stripPrefix(child, parent) {
|
|
42
|
+
const c = norm(child);
|
|
43
|
+
const p = norm(parent).replace(/\/+$/, "");
|
|
44
|
+
if (!p) return null;
|
|
45
|
+
const ci = process.platform === "win32";
|
|
46
|
+
const cc = ci ? c.toLowerCase() : c;
|
|
47
|
+
const pp = ci ? p.toLowerCase() : p;
|
|
48
|
+
if (cc === pp) return "";
|
|
49
|
+
if (cc.startsWith(`${pp}/`)) return c.slice(p.length + 1);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 把锚点尾巴拼成当前机器的原生绝对路径(拒绝 .. 穿越) */
|
|
54
|
+
function nativeJoin(root, tail) {
|
|
55
|
+
const parts = String(tail)
|
|
56
|
+
.split("/")
|
|
57
|
+
.filter((s) => s && s !== "." && s !== "..");
|
|
58
|
+
return join(root, ...parts);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 把 agentDir 之外的绝对路径映射成归档内安全、无歧义的相对路径。
|
|
63
|
+
* C:\Users\me\.pi\settings.json → extra/C/Users/me/.pi/settings.json
|
|
64
|
+
* /home/me/.pi/settings.json → extra/home/me/.pi/settings.json
|
|
65
|
+
*/
|
|
66
|
+
export function extraArchivePath(absPath) {
|
|
67
|
+
let p = norm(resolve(String(absPath)));
|
|
68
|
+
p = p.replace(/^([A-Za-z]):\//, "$1/"); // 去掉盘符后的冒号,保留盘符作为首段
|
|
69
|
+
const segs = p.split("/").filter((s) => s && s !== "." && s !== "..");
|
|
70
|
+
return `${EXTRA_PREFIX}${segs.join("/")}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 该额外路径是否是“配置/数据类”文件(值得一并脱敏保护) */
|
|
74
|
+
export function isDataLikeExtraPath(absPath) {
|
|
75
|
+
const b = norm(absPath).split("/").pop() ?? "";
|
|
76
|
+
const lower = b.toLowerCase();
|
|
77
|
+
return (
|
|
78
|
+
lower.endsWith(".json") ||
|
|
79
|
+
lower === ".env" ||
|
|
80
|
+
lower.startsWith(".env.") ||
|
|
81
|
+
/\.(ya?ml|toml|ini|conf|cfg|txt)$/i.test(lower) ||
|
|
82
|
+
["config", "config.json", "settings", "auth", "secret", "secrets", "credentials"].includes(lower)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 当前机器上各锚点(锚点种类 → 真实根目录) */
|
|
87
|
+
export function currentAnchors() {
|
|
88
|
+
const home = resolve(homedir());
|
|
89
|
+
const config = process.env.XDG_CONFIG_HOME ? resolve(process.env.XDG_CONFIG_HOME) : join(home, ".config");
|
|
90
|
+
const out = [
|
|
91
|
+
{ kind: "home", path: home },
|
|
92
|
+
{ kind: "config", path: config },
|
|
93
|
+
];
|
|
94
|
+
if (process.platform === "win32") {
|
|
95
|
+
const appdata = process.env.APPDATA ? resolve(process.env.APPDATA) : join(home, "AppData", "Roaming");
|
|
96
|
+
out.push({ kind: "appdata", path: appdata });
|
|
97
|
+
}
|
|
98
|
+
// 去重(config 常与 home 重合),保留路径最长者优先的顺序
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
return out.filter((a) => {
|
|
101
|
+
const k = norm(a.path).toLowerCase();
|
|
102
|
+
if (seen.has(k)) return false;
|
|
103
|
+
seen.add(k);
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 备份侧:把绝对路径转成可移植表示(供展示/调试;恢复实际按锚点重算) */
|
|
109
|
+
export function toPortable(absPath, anchors = currentAnchors()) {
|
|
110
|
+
const p = norm(absPath);
|
|
111
|
+
let best = null;
|
|
112
|
+
for (const a of anchors) {
|
|
113
|
+
const tail = stripPrefix(p, a.path);
|
|
114
|
+
if (tail !== null && (!best || a.path.length > best.path.length)) best = { a, tail };
|
|
115
|
+
}
|
|
116
|
+
if (!best) return p;
|
|
117
|
+
return best.a.kind === "home" ? `~/${best.tail}` : `{${best.a.kind}}/${best.tail}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 备份侧:根据已收集的文件构建 extra 映射清单。
|
|
122
|
+
* @param {{rel: string, abs: string}[]} files collectFiles 的结果
|
|
123
|
+
* @returns {{version: number, createdAt: string, source: object, entries: object[]}}
|
|
124
|
+
*/
|
|
125
|
+
export function buildExtraMap(files, opts = {}) {
|
|
126
|
+
const anchors = opts.anchors ?? currentAnchors();
|
|
127
|
+
const entries = [];
|
|
128
|
+
for (const f of files) {
|
|
129
|
+
const rel = norm(f.rel);
|
|
130
|
+
if (!rel.startsWith(EXTRA_PREFIX)) continue;
|
|
131
|
+
const orig = norm(f.abs);
|
|
132
|
+
entries.push({ rel, orig, portable: toPortable(orig, anchors) });
|
|
133
|
+
}
|
|
134
|
+
// 只保留确有文件落入的锚点
|
|
135
|
+
const used = anchors.filter((a) => entries.some((e) => stripPrefix(e.orig, a.path) !== null));
|
|
136
|
+
return {
|
|
137
|
+
version: 1,
|
|
138
|
+
createdAt: new Date().toISOString(),
|
|
139
|
+
source: {
|
|
140
|
+
platform: process.platform,
|
|
141
|
+
hostname: hostname(),
|
|
142
|
+
home: opts.home ?? norm(resolve(homedir())),
|
|
143
|
+
anchors: used.map((a) => ({ kind: a.kind, path: norm(a.path) })),
|
|
144
|
+
},
|
|
145
|
+
entries,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 恢复侧:把一个 entry 解析成【当前机器】上的目标绝对路径。
|
|
151
|
+
* 优先用源锚点重算;无法映射时退回 orig 原样。
|
|
152
|
+
* @returns {{target: string|null, mapped: boolean, anchor: string|null}}
|
|
153
|
+
*/
|
|
154
|
+
export function resolveExtraEntry(entry, source, opts = {}) {
|
|
155
|
+
const orig = entry?.orig ? norm(entry.orig) : null;
|
|
156
|
+
if (!orig) return { target: null, mapped: false, anchor: null };
|
|
157
|
+
|
|
158
|
+
const srcAnchors = Array.isArray(source?.anchors) ? source.anchors : [];
|
|
159
|
+
const cur = new Map((opts.anchors ?? currentAnchors()).map((a) => [a.kind, a.path]));
|
|
160
|
+
|
|
161
|
+
let best = null;
|
|
162
|
+
for (const a of srcAnchors) {
|
|
163
|
+
const tail = stripPrefix(orig, a.path);
|
|
164
|
+
if (tail !== null && (!best || norm(a.path).length > norm(best.path).length)) best = { a, tail };
|
|
165
|
+
}
|
|
166
|
+
if (best) {
|
|
167
|
+
const root = cur.get(best.a.kind);
|
|
168
|
+
if (root) return { target: nativeJoin(root, best.tail), mapped: true, anchor: best.a.kind };
|
|
169
|
+
}
|
|
170
|
+
// 兜底:按原绝对路径还原(可能因盘符/机器不同而不存在)
|
|
171
|
+
return { target: orig.replace(/\//g, sep), mapped: false, anchor: null };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 从归档内 `extra/**` 相对路径反推真实绝对路径(**旧版归档的兜底**)。
|
|
176
|
+
* 新归档请走 resolveExtraEntry(支持跨机重映射)。
|
|
177
|
+
*/
|
|
178
|
+
export function extraPathToAbsolute(rel) {
|
|
179
|
+
const r = norm(rel);
|
|
180
|
+
if (!r.startsWith(EXTRA_PREFIX)) return null;
|
|
181
|
+
const segs = r.slice(EXTRA_PREFIX.length).split("/").filter((s) => s && s !== "." && s !== "..");
|
|
182
|
+
if (segs.length === 0) return null;
|
|
183
|
+
|
|
184
|
+
if (process.platform === "win32") {
|
|
185
|
+
if (!/^[A-Za-z]$/.test(segs[0])) return null;
|
|
186
|
+
return `${segs[0]}:/${segs.slice(1).join("/")}`;
|
|
187
|
+
}
|
|
188
|
+
return `/${segs.join("/")}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 读取恢复目录里的 extra-manifest.json(不存在/损坏返回 null) */
|
|
192
|
+
export function readExtraManifest(restoredDir) {
|
|
193
|
+
const p = join(restoredDir, EXTRA_MANIFEST_NAME);
|
|
194
|
+
try {
|
|
195
|
+
if (!existsSync(p)) return null;
|
|
196
|
+
const j = JSON.parse(readFileSync(p, "utf8").replace(/^\uFEFF/, ""));
|
|
197
|
+
return j && typeof j === "object" ? j : null;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 递归收集 restoredDir/extra 下的所有文件。
|
|
205
|
+
* 若存在 extra-manifest.json,则按锚点重算目标(跨机可移植);否则退回旧版反推。
|
|
206
|
+
* @returns {{rel: string, abs: string, target: string|null, orig: string|null, portable: string|null, mapped: boolean, anchor: string|null}[]}
|
|
207
|
+
*/
|
|
208
|
+
export function listExtraFiles(restoredDir, opts = {}) {
|
|
209
|
+
const root = join(restoredDir, EXTRA_PREFIX.replace(/\/$/, ""));
|
|
210
|
+
const out = [];
|
|
211
|
+
if (!existsSync(root)) return out;
|
|
212
|
+
|
|
213
|
+
const manifest = readExtraManifest(restoredDir);
|
|
214
|
+
const byRel = new Map();
|
|
215
|
+
if (manifest?.entries) {
|
|
216
|
+
for (const e of manifest.entries) byRel.set(norm(e.rel), e);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const walk = (dir) => {
|
|
220
|
+
let entries;
|
|
221
|
+
try {
|
|
222
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
223
|
+
} catch {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
for (const ent of entries) {
|
|
227
|
+
const full = join(dir, ent.name);
|
|
228
|
+
if (ent.isSymbolicLink()) continue;
|
|
229
|
+
if (ent.isDirectory()) {
|
|
230
|
+
walk(full);
|
|
231
|
+
} else if (ent.isFile()) {
|
|
232
|
+
const rel = `${EXTRA_PREFIX}${full.slice(root.length + 1).split(sep).join("/")}`;
|
|
233
|
+
const entry = byRel.get(rel);
|
|
234
|
+
let target = null;
|
|
235
|
+
let mapped = false;
|
|
236
|
+
let anchor = null;
|
|
237
|
+
if (entry?.orig) {
|
|
238
|
+
const r = resolveExtraEntry(entry, manifest.source, opts);
|
|
239
|
+
target = r.target;
|
|
240
|
+
mapped = r.mapped;
|
|
241
|
+
anchor = r.anchor;
|
|
242
|
+
} else {
|
|
243
|
+
target = extraPathToAbsolute(rel);
|
|
244
|
+
}
|
|
245
|
+
out.push({
|
|
246
|
+
rel,
|
|
247
|
+
abs: full,
|
|
248
|
+
target,
|
|
249
|
+
orig: entry?.orig ?? null,
|
|
250
|
+
portable: entry?.portable ?? null,
|
|
251
|
+
mapped,
|
|
252
|
+
anchor,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
walk(root);
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function sameContent(a, b) {
|
|
262
|
+
try {
|
|
263
|
+
const sa = statSync(a);
|
|
264
|
+
const sb = statSync(b);
|
|
265
|
+
if (!sa.isFile() || !sb.isFile()) return false;
|
|
266
|
+
if (sa.size !== sb.size) return false;
|
|
267
|
+
return readFileSync(a).equals(readFileSync(b));
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 源(恢复出来的)是否含脱敏占位符 */
|
|
274
|
+
function hasRedaction(buf) {
|
|
275
|
+
try {
|
|
276
|
+
return buf.includes(Buffer.from("__REDACTED__"));
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 覆盖前把现有文件另存为 <name>.bak-<tag>-<ts>,避免不可逆丢失 */
|
|
283
|
+
function backupExisting(target, tag = "extras") {
|
|
284
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
285
|
+
const bak = `${target}.bak-${tag}-${ts}`;
|
|
286
|
+
try {
|
|
287
|
+
writeFileSync(bak, readFileSync(target));
|
|
288
|
+
return bak;
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export { sameContent, hasRedaction, backupExisting };
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 把 extra/ 的文件归位到真实路径(跨机按锚点重映射)。
|
|
298
|
+
* @param {string} restoredDir 恢复出来的目录
|
|
299
|
+
* @param {{log?: Function, dryRun?: boolean, only?: string[], force?: boolean}} opts
|
|
300
|
+
* @returns {{ok: boolean, applied: Array, skipped: Array, unchanged: Array, failed: Array, unparsed: Array, unmapped: Array, source: object|null}}
|
|
301
|
+
*/
|
|
302
|
+
export function applyExtraFiles(restoredDir, opts = {}) {
|
|
303
|
+
const log = opts.log ?? (() => {});
|
|
304
|
+
const dryRun = opts.dryRun === true;
|
|
305
|
+
const force = opts.force === true;
|
|
306
|
+
const only = opts.only ? new Set(opts.only) : null;
|
|
307
|
+
|
|
308
|
+
const manifest = readExtraManifest(restoredDir);
|
|
309
|
+
const files = listExtraFiles(restoredDir, opts);
|
|
310
|
+
const applied = [];
|
|
311
|
+
const skipped = [];
|
|
312
|
+
const unchanged = [];
|
|
313
|
+
const failed = [];
|
|
314
|
+
const unparsed = [];
|
|
315
|
+
const unmapped = [];
|
|
316
|
+
|
|
317
|
+
for (const f of files) {
|
|
318
|
+
if (only && !only.has(f.rel)) continue;
|
|
319
|
+
if (!f.target) {
|
|
320
|
+
unparsed.push({ rel: f.rel, reason: "无法从归档路径反推目标位置" });
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
// 有 orig 但未能用锚点重映射 → 目标仍是旧机的绝对路径,提醒用户
|
|
324
|
+
if (f.orig && !f.mapped) {
|
|
325
|
+
unmapped.push({ rel: f.rel, orig: f.orig, target: f.target });
|
|
326
|
+
}
|
|
327
|
+
const target = resolve(f.target);
|
|
328
|
+
|
|
329
|
+
// 已存在且内容一致 → 无需处理
|
|
330
|
+
if (existsSync(target) && sameContent(f.abs, target)) {
|
|
331
|
+
unchanged.push({ rel: f.rel, target: f.target });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 安全防护:源含脱敏占位符,而本地目标已有“真实值”时,
|
|
336
|
+
// 直接覆盖会把真秘密换成 __REDACTED__(同机误操作会弄坏配置)。
|
|
337
|
+
// 除非显式 --force,否则跳过。
|
|
338
|
+
if (!force && existsSync(target)) {
|
|
339
|
+
const src = readFileSync(f.abs);
|
|
340
|
+
if (hasRedaction(src)) {
|
|
341
|
+
const local = readFileSync(target);
|
|
342
|
+
if (!hasRedaction(local)) {
|
|
343
|
+
skipped.push({
|
|
344
|
+
rel: f.rel,
|
|
345
|
+
target: f.target,
|
|
346
|
+
reason: "本地已有真实值,跳过以免用脱敏占位覆盖(加 --force 强制)",
|
|
347
|
+
});
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (dryRun) {
|
|
354
|
+
skipped.push({ rel: f.rel, target: f.target, reason: "dry-run" });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
360
|
+
if (existsSync(target) && lstatSync(target).isDirectory()) {
|
|
361
|
+
failed.push({ rel: f.rel, target: f.target, error: "目标是一个目录,拒绝覆盖" });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
let backup = null;
|
|
365
|
+
if (existsSync(target)) backup = backupExisting(target);
|
|
366
|
+
writeFileSync(target, readFileSync(f.abs));
|
|
367
|
+
applied.push({ rel: f.rel, target: f.target, orig: f.orig, mapped: f.mapped, backup });
|
|
368
|
+
log(` 已归位 ${f.rel} → ${f.target}${f.mapped ? "(跨机重映射)" : ""}${backup ? `(原文件已备份为 ${backup})` : ""}`);
|
|
369
|
+
} catch (e) {
|
|
370
|
+
failed.push({ rel: f.rel, target: f.target, error: e instanceof Error ? e.message : String(e) });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
ok: failed.length === 0,
|
|
376
|
+
applied,
|
|
377
|
+
skipped,
|
|
378
|
+
unchanged,
|
|
379
|
+
failed,
|
|
380
|
+
unparsed,
|
|
381
|
+
unmapped,
|
|
382
|
+
source: manifest?.source ?? null,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 探测 agentDir 之外、但可能影响插件加载的遗留配置文件。
|
|
388
|
+
* 现在识别:
|
|
389
|
+
* <parent>/settings.json —— pi 旧版本 / 某些工具写在 ~/.pi/settings.json 的包声明
|
|
390
|
+
* <parent>/web-search.json —— pi-web-access 的回退位置
|
|
391
|
+
* 只读探测,不修改。
|
|
392
|
+
*/
|
|
393
|
+
export function detectLegacyConfig(agentDir) {
|
|
394
|
+
const out = [];
|
|
395
|
+
const parent = dirname(resolve(agentDir));
|
|
396
|
+
const candidates = [
|
|
397
|
+
{ path: join(parent, "settings.json"), kind: "legacy-settings", note: "与 agentDir 同级(如 ~/.pi/settings.json)" },
|
|
398
|
+
{ path: join(parent, "web-search.json"), kind: "web-search", note: "pi-web-access 的回退位置" },
|
|
399
|
+
];
|
|
400
|
+
for (const c of candidates) {
|
|
401
|
+
try {
|
|
402
|
+
if (!existsSync(c.path)) continue;
|
|
403
|
+
const st = statSync(c.path);
|
|
404
|
+
if (!st.isFile()) continue;
|
|
405
|
+
let packages = null;
|
|
406
|
+
if (c.kind === "legacy-settings") {
|
|
407
|
+
try {
|
|
408
|
+
const j = JSON.parse(readFileSync(c.path, "utf8").replace(/^\uFEFF/, ""));
|
|
409
|
+
packages = Array.isArray(j?.packages) ? j.packages.map(String) : [];
|
|
410
|
+
} catch {
|
|
411
|
+
packages = null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
out.push({ ...c, size: st.size, packages, portable: toPortable(c.path) });
|
|
415
|
+
} catch {
|
|
416
|
+
/* ignore */
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return out;
|
|
420
|
+
}
|