pi-webdav-backup 1.0.2 → 1.3.0

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.
@@ -1,357 +1,475 @@
1
- #!/usr/bin/env node
2
- /**
3
- * cli.mjs — 备份引擎命令行入口
4
- *
5
- * 用法:
6
- * node cli.mjs backup [--reason manual|exit] [--dry-run] [--json]
7
- * node cli.mjs check [--json] 测试 WebDAV 连通性与认证
8
- * node cli.mjs list [--json] 列出远端已有备份
9
- * node cli.mjs prune [--keep N] 清理旧备份
10
- * node cli.mjs restore [--file 名称] [--to 目录] [--list] [--json] 恢复备份
11
- * node cli.mjs set-secret --name webdav-password [--value X] 用 DPAPI 加密保存密钥
12
- * node cli.mjs list-secrets [--json] 列出已存的 DPAPI 密钥(不含明文)
13
- * node cli.mjs rm-secret --name X 删除 DPAPI 密钥
14
- * node cli.mjs migrate [--method dpapi|env|file] [--dry-run] 把配置明文密钥迁成引用
15
- * node cli.mjs auth-scan [--json] 扫描 auth.json 里的明文 API 密钥
16
- * node cli.mjs auth-migrate [--method env|dpapi] [--dry-run] 迁移 auth.json 密钥
17
- * node cli.mjs doctor [--json] 体检:配置、密钥来源、连接性
18
- * node cli.mjs install-cron [--time HH:MM] 安装 Windows 计划任务(每日备份)
19
- * node cli.mjs uninstall-cron 移除计划任务
20
- *
21
- * 输出人类可读文本,末行固定是 JSON 汇总;加 --json 则只输出该汇总行。
22
- * 退出码 0 成功 / 1 失败。
23
- * --value 优先,其次读 stdin(避免密钥出现在进程列表里)
24
- */
25
-
26
- import { appendFileSync, existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
27
- import { tmpdir } from "node:os";
28
- import { join } from "node:path";
29
- import { loadConfig, configPath, statePath, logPath, resolveAgentDir } from "./config.mjs";
30
- import { runBackup, pruneOldArchives, readState } from "./backup.mjs";
31
- import { WebDAVClient } from "./webdav.mjs";
32
- import {
33
- setDpapiSecret,
34
- listDpapiSecrets,
35
- deleteDpapiSecret,
36
- dpapiAvailable,
37
- resolveSecret,
38
- isPlaintextSecret,
39
- } from "./secrets.mjs";
40
- import { pickCommand } from "./args.mjs";
41
-
42
- const args = process.argv.slice(2);
43
- // 注意:不能写 `args[0] ?? "backup"`——空串会绕过 ?? 兜底(见 args.mjs)
44
- const cmd = pickCommand(args, "backup");
45
- const has = (f) => args.includes(f);
46
- const val = (f, d = null) => {
47
- const i = args.indexOf(f);
48
- return i >= 0 && args[i + 1] ? args[i + 1] : d;
49
- };
50
- const asJson = has("--json");
51
-
52
- function logTo(line) {
53
- const stamp = new Date().toISOString();
54
- const text = `[${stamp}] ${line}\n`;
55
- try {
56
- appendFileSync(logPath(), text, "utf8");
57
- } catch {
58
- /* ignore */
59
- }
60
- if (!asJson) process.stdout.write(`${line}\n`);
61
- }
62
-
63
- function finish(payload, code) {
64
- // JSON 汇总固定作为最后一行输出:扩展入口按「最后一行 JSON」解析结果
65
- // 并渲染完成提醒;--json 模式下前面没有日志行,只有这一行。
66
- process.stdout.write(`${JSON.stringify(payload)}\n`);
67
- process.exit(code ?? (payload.ok ? 0 : 1));
68
- }
69
-
70
- /** 从 --value 或 stdin 读取密钥,避免出现在命令行历史/进程列表中 */
71
- async function readSecretInput() {
72
- const v = val("--value", null);
73
- if (v !== null) return v;
74
- if (process.stdin.isTTY) return null;
75
- const chunks = [];
76
- for await (const c of process.stdin) chunks.push(c);
77
- return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
78
- }
79
-
80
- async function main() {
81
- const agentDir = resolveAgentDir();
82
-
83
- // ── 密钥管理命令(不需要已加载配置)
84
- if (cmd === "set-secret") {
85
- const name = val("--name", null);
86
- if (!name) {
87
- return finish({ ok: false, error: "缺少 --name" }, 1);
88
- }
89
- if (!dpapiAvailable()) {
90
- return finish({ ok: false, error: "DPAPI 仅支持 Windows;其他平台请用 --method file 或环境变量" }, 1);
91
- }
92
- const value = await readSecretInput();
93
- if (value === null || value === "") {
94
- return finish(
95
- { ok: false, error: "缺少密钥值:用 --value 传入,或通过 stdin 输入(推荐)" },
96
- 1,
97
- );
98
- }
99
- try {
100
- setDpapiSecret(name, value);
101
- logTo(`已用 DPAPI 加密保存密钥「${name}」`);
102
- return finish({ ok: true, name, store: "dpapi" }, 0);
103
- } catch (e) {
104
- return finish({ ok: false, error: e instanceof Error ? e.message : String(e) }, 1);
105
- }
106
- }
107
-
108
- if (cmd === "list-secrets") {
109
- const items = listDpapiSecrets();
110
- if (!asJson) {
111
- if (items.length === 0) logTo("没有已保存的 DPAPI 密钥。");
112
- else for (const s of items) logTo(` ${s.name} ${s.updatedAt ?? ""} [${s.scope}]`);
113
- }
114
- return finish({ ok: true, secrets: items, available: dpapiAvailable() }, 0);
115
- }
116
-
117
- if (cmd === "rm-secret") {
118
- const name = val("--name", null);
119
- if (!name) return finish({ ok: false, error: "缺少 --name" }, 1);
120
- const removed = deleteDpapiSecret(name);
121
- logTo(removed ? `已删除密钥「${name}」` : `未找到密钥「${name}」`);
122
- return finish({ ok: removed, name, removed }, removed ? 0 : 1);
123
- }
124
-
125
- if (cmd === "migrate") {
126
- const { migrateSecrets } = await import("./migrate.mjs");
127
- const method = String(val("--method", "dpapi"));
128
- const r = await migrateSecrets({ method, log: logTo, dryRun: has("--dry-run") });
129
- if (!r.ok) logTo(`迁移失败:${r.error}`);
130
- return finish(r, r.ok ? 0 : 1);
131
- }
132
-
133
- // ── auth.json 明文 API 密钥迁移
134
- if (cmd === "auth-scan") {
135
- const { scanAuthSecrets } = await import("./auth-migrate.mjs");
136
- const scan = scanAuthSecrets();
137
- if (!asJson) {
138
- if (!scan.ok) logTo(`扫描失败:${scan.error}`);
139
- else if (scan.items.length === 0) logTo("auth.json 没有条目。");
140
- else {
141
- logTo(`auth.json: ${scan.path}`);
142
- for (const it of scan.items) {
143
- if (it.kind === "plaintext")
144
- logTo(` ⚠️ ${it.provider}: 明文密钥 (${it.keyLength} 字符) → 建议 $${it.suggestedEnv}`);
145
- else if (it.kind === "reference") logTo(` ✅ ${it.provider}: ${it.current}`);
146
- else logTo(` · ${it.provider}: ${it.reason}`);
147
- }
148
- }
149
- }
150
- return finish({ ok: scan.ok, ...scan }, scan.ok ? 0 : 1);
151
- }
152
-
153
- if (cmd === "auth-migrate") {
154
- const { migrateAuthJson } = await import("./auth-migrate.mjs");
155
- const method = String(val("--method", "env"));
156
- const r = await migrateAuthJson({ method, log: logTo, dryRun: has("--dry-run") });
157
- if (!r.ok && r.error) logTo(`迁移失败:${r.error}`);
158
- else if (r.message) logTo(r.message);
159
- if (r.warnings?.length) for (const w of r.warnings) logTo(`警告:${w}`);
160
- return finish(r, r.ok ? 0 : 1);
161
- }
162
-
163
- const loaded = loadConfig();
164
-
165
- if (!loaded.ok) {
166
- const err = loaded.errors.join("; ");
167
- logTo(`配置错误:${err}`);
168
- finish({ ok: false, error: err, errors: loaded.errors, configPath: configPath() }, 1);
169
- return;
170
- }
171
- const { config, warnings } = loaded;
172
- for (const w of warnings) logTo(`警告:${w}`);
173
-
174
- if (cmd === "doctor") {
175
- const report = {
176
- ok: loaded.ok,
177
- configPath: configPath(),
178
- url: config.url,
179
- username: config.username || "(未设置)",
180
- password: {
181
- source: loaded.secrets?.passwordSource ?? "empty",
182
- plaintext: isPlaintextSecret(loaded.raw?.remote?.password),
183
- },
184
- encryptKey: {
185
- configured: !!config.encryptKey,
186
- source: loaded.secrets?.encryptKeySource ?? "empty",
187
- plaintext: isPlaintextSecret(loaded.raw?.encryptKey),
188
- },
189
- errors: loaded.errors,
190
- warnings: loaded.warnings,
191
- dpapiAvailable: dpapiAvailable(),
192
- connectivity: null,
193
- };
194
-
195
- if (loaded.ok && config.url) {
196
- try {
197
- const c = new WebDAVClient({
198
- baseUrl: config.url,
199
- username: config.username,
200
- password: config.password,
201
- timeoutMs: config.timeoutMs,
202
- insecureTls: config.insecureTls,
203
- });
204
- const r = await c.check();
205
- report.connectivity = { ok: r.ok, status: r.status, error: r.error };
206
- } catch (e) {
207
- report.connectivity = { ok: false, error: e instanceof Error ? e.message : String(e) };
208
- }
209
- }
210
-
211
- if (!asJson) {
212
- logTo("配置体检");
213
- logTo(` 配置文件 ${report.configPath}`);
214
- logTo(` WebDAV ${report.url || "(未设置)"}`);
215
- logTo(` 用户名 ${report.username}`);
216
- logTo(
217
- ` 密码来源 ${report.password.source}${report.password.plaintext ? " ⚠️ 明文" : ""}`,
218
- );
219
- logTo(
220
- ` 加密密钥 ${report.encryptKey.configured ? report.encryptKey.source : "(未配置)"}${
221
- report.encryptKey.plaintext ? " ⚠️ 明文" : ""
222
- }`,
223
- );
224
- logTo(` DPAPI 可用 ${report.dpapiAvailable ? "是" : "否(非 Windows)"}`);
225
- logTo(
226
- ` 连接测试 ${
227
- report.connectivity
228
- ? report.connectivity.ok
229
- ? "通过 ✅"
230
- : `失败:${report.connectivity.error}`
231
- : "未执行"
232
- }`,
233
- );
234
- if (report.errors.length) logTo(` 错误:${report.errors.join(";")}`);
235
- if (report.warnings.length) logTo(` 告警:${report.warnings.join(";")}`);
236
- }
237
- return finish(report, report.ok ? 0 : 1);
238
- }
239
-
240
- if (cmd === "check") {
241
- logTo(`检查 ${config.url} …`);
242
- const client = new WebDAVClient({
243
- baseUrl: config.url,
244
- username: config.username,
245
- password: config.password,
246
- timeoutMs: config.timeoutMs,
247
- insecureTls: config.insecureTls,
248
- });
249
- const r = await client.check();
250
- if (r.ok) {
251
- logTo("WebDAV 连接正常,认证通过。");
252
- finish({ ok: true, status: r.status, url: config.url });
253
- } else {
254
- logTo(`连接失败:${r.error}`);
255
- finish({ ok: false, error: r.error, status: r.status }, 1);
256
- }
257
- return;
258
- }
259
-
260
- if (cmd === "list") {
261
- const client = new WebDAVClient({
262
- baseUrl: config.url,
263
- username: config.username,
264
- password: config.password,
265
- timeoutMs: config.timeoutMs,
266
- insecureTls: config.insecureTls,
267
- });
268
- const entries = await client.list(config.remoteDir);
269
- const files = entries.filter((e) => !e.isCollection);
270
- if (!asJson) {
271
- if (files.length === 0) logTo(`远端 ${config.remoteDir}/ 暂无备份文件。`);
272
- else {
273
- logTo(`远端 ${config.remoteDir}/ 共 ${files.length} 个文件:`);
274
- for (const f of files) {
275
- logTo(` ${f.name} ${(f.size / 1024).toFixed(1)} KB ${f.lastModified ?? ""}`);
276
- }
277
- }
278
- }
279
- finish({ ok: true, files });
280
- return;
281
- }
282
-
283
- if (cmd === "prune") {
284
- const keep = Number(val("--keep", config.keepVersions));
285
- const r = await pruneOldArchives(config, keep, logTo);
286
- finish({ ok: true, ...r });
287
- return;
288
- }
289
-
290
- if (cmd === "restore") {
291
- const { restoreBackup } = await import("./restore.mjs");
292
- const r = await restoreBackup(config, {
293
- agentDir,
294
- file: val("--file", null),
295
- to: val("--to", null),
296
- listOnly: has("--list"),
297
- log: logTo,
298
- });
299
- finish(r, r.ok ? 0 : 1);
300
- return;
301
- }
302
-
303
- if (cmd === "status") {
304
- const state = readState(statePath());
305
- finish({ ok: true, state, configPath: configPath() });
306
- return;
307
- }
308
-
309
- if (cmd === "install-cron" || cmd === "uninstall-cron") {
310
- const { installCron, uninstallCron } = await import("./cron.mjs");
311
- if (cmd === "install-cron") {
312
- const r = await installCron({ time: val("--time", "03:00"), log: logTo });
313
- finish({ ok: r.ok, ...r });
314
- } else {
315
- const r = await uninstallCron({ log: logTo });
316
- finish({ ok: r.ok, ...r });
317
- }
318
- return;
319
- }
320
-
321
- if (cmd === "backup" || cmd === "run") {
322
- const reason = val("--reason", "manual");
323
- if (!config.enabled && reason === "exit") {
324
- logTo("自动备份已禁用(enabled: false),跳过。");
325
- finish({ ok: true, skipped: true, reason: "disabled" });
326
- return;
327
- }
328
- logTo(`开始备份(触发方式:${reason}${has("--dry-run") ? ",试运行" : ""})`);
329
- const result = await runBackup(config, {
330
- log: logTo,
331
- agentDir,
332
- stateFile: statePath(),
333
- dryRun: has("--dry-run"),
334
- reason,
335
- });
336
-
337
- if (result.ok && !result.dryRun && reason !== "exit") {
338
- try {
339
- await pruneOldArchives(config, config.keepVersions, logTo);
340
- } catch (e) {
341
- logTo(`清理旧版本失败(不影响本次备份):${e.message}`);
342
- }
343
- }
344
-
345
- if (!result.ok) logTo(`备份失败:${result.error}`);
346
- finish(result, result.ok ? 0 : 1);
347
- return;
348
- }
349
-
350
- logTo(`未知命令:${cmd}`);
351
- finish({ ok: false, error: `未知命令:${cmd}` }, 1);
352
- }
353
-
354
- main().catch((e) => {
355
- logTo(`未捕获错误:${e?.stack ?? e?.message ?? String(e)}`);
356
- finish({ ok: false, error: String(e?.message ?? e) }, 1);
357
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cli.mjs — 备份引擎命令行入口
4
+ *
5
+ * 用法:
6
+ * node cli.mjs backup [--reason manual|exit] [--dry-run] [--json]
7
+ * node cli.mjs check [--json] 测试 WebDAV 连通性与认证
8
+ * node cli.mjs list [--json] 列出远端已有备份
9
+ * node cli.mjs prune [--keep N] 清理旧备份
10
+ * node cli.mjs restore [--file 名称] [--to 目录] [--list] [--json] 恢复备份
11
+ * node cli.mjs plugins [--dir 目录] [--json] 体检:恢复后哪些插件需要补装
12
+ * node cli.mjs repair-plugins [--dir 目录] [--dry-run] 补装缺失的插件 / 依赖(npm install)
13
+ * node cli.mjs apply-extras --dir 恢复目录 [--dry-run] [--force] 把 extra/ 数据归位到真实路径
14
+ * node cli.mjs restore-apply --dir 恢复目录 [--dry-run] [--force] [--mode merge|replace] [--json] 把恢复结果写回当前 agent 目录
15
+ * node cli.mjs detect-legacy [--json] 探测 agentDir 之外的遗留配置(~/.pi/settings.json 等)
16
+ * node cli.mjs set-secret --name webdav-password [--value X] 用 DPAPI 加密保存密钥
17
+ * node cli.mjs list-secrets [--json] 列出已存的 DPAPI 密钥(不含明文)
18
+ * node cli.mjs rm-secret --name X 删除 DPAPI 密钥
19
+ * node cli.mjs migrate [--method dpapi|env|file] [--dry-run] 把配置明文密钥迁成引用
20
+ * node cli.mjs auth-scan [--json] 扫描 auth.json 里的明文 API 密钥
21
+ * node cli.mjs auth-migrate [--method env|dpapi] [--dry-run] 迁移 auth.json 密钥
22
+ * node cli.mjs doctor [--json] 体检:配置、密钥来源、连接性
23
+ * node cli.mjs install-cron [--time HH:MM] 安装 Windows 计划任务(每日备份)
24
+ * node cli.mjs uninstall-cron 移除计划任务
25
+ *
26
+ * 输出人类可读文本,末行固定是 JSON 汇总;加 --json 则只输出该汇总行。
27
+ * 退出码 0 成功 / 1 失败。
28
+ * --value 优先,其次读 stdin(避免密钥出现在进程列表里)
29
+ */
30
+
31
+ import { appendFileSync, existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { join } from "node:path";
34
+ import { loadConfig, configPath, statePath, logPath, resolveAgentDir } from "./config.mjs";
35
+ import { runBackup, pruneOldArchives, readState } from "./backup.mjs";
36
+ import { WebDAVClient } from "./webdav.mjs";
37
+ import { applyExtraFiles, detectLegacyConfig } from "./extras.mjs";
38
+ import { applyRestoredFiles, hasPluginCode } from "./apply.mjs";
39
+ import {
40
+ setDpapiSecret,
41
+ listDpapiSecrets,
42
+ deleteDpapiSecret,
43
+ dpapiAvailable,
44
+ resolveSecret,
45
+ isPlaintextSecret,
46
+ } from "./secrets.mjs";
47
+ import { pickCommand } from "./args.mjs";
48
+
49
+ const args = process.argv.slice(2);
50
+ // 注意:不能写 `args[0] ?? "backup"`——空串会绕过 ?? 兜底(见 args.mjs)
51
+ const cmd = pickCommand(args, "backup");
52
+ const has = (f) => args.includes(f);
53
+ const val = (f, d = null) => {
54
+ const i = args.indexOf(f);
55
+ return i >= 0 && args[i + 1] ? args[i + 1] : d;
56
+ };
57
+ const asJson = has("--json");
58
+
59
+ function logTo(line) {
60
+ const stamp = new Date().toISOString();
61
+ const text = `[${stamp}] ${line}\n`;
62
+ try {
63
+ appendFileSync(logPath(), text, "utf8");
64
+ } catch {
65
+ /* ignore */
66
+ }
67
+ if (!asJson) process.stdout.write(`${line}\n`);
68
+ }
69
+
70
+ function finish(payload, code) {
71
+ // JSON 汇总固定作为最后一行输出:扩展入口按「最后一行 JSON」解析结果
72
+ // 并渲染完成提醒;--json 模式下前面没有日志行,只有这一行。
73
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
74
+ process.exit(code ?? (payload.ok ? 0 : 1));
75
+ }
76
+
77
+ /** 从 --value 或 stdin 读取密钥,避免出现在命令行历史/进程列表中 */
78
+ async function readSecretInput() {
79
+ const v = val("--value", null);
80
+ if (v !== null) return v;
81
+ if (process.stdin.isTTY) return null;
82
+ const chunks = [];
83
+ for await (const c of process.stdin) chunks.push(c);
84
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
85
+ }
86
+
87
+ async function main() {
88
+ const agentDir = resolveAgentDir();
89
+
90
+ // ── 密钥管理命令(不需要已加载配置)
91
+ if (cmd === "set-secret") {
92
+ const name = val("--name", null);
93
+ if (!name) {
94
+ return finish({ ok: false, error: "缺少 --name" }, 1);
95
+ }
96
+ if (!dpapiAvailable()) {
97
+ return finish({ ok: false, error: "DPAPI 仅支持 Windows;其他平台请用 --method file 或环境变量" }, 1);
98
+ }
99
+ const value = await readSecretInput();
100
+ if (value === null || value === "") {
101
+ return finish(
102
+ { ok: false, error: "缺少密钥值:用 --value 传入,或通过 stdin 输入(推荐)" },
103
+ 1,
104
+ );
105
+ }
106
+ try {
107
+ setDpapiSecret(name, value);
108
+ logTo(`已用 DPAPI 加密保存密钥「${name}」`);
109
+ return finish({ ok: true, name, store: "dpapi" }, 0);
110
+ } catch (e) {
111
+ return finish({ ok: false, error: e instanceof Error ? e.message : String(e) }, 1);
112
+ }
113
+ }
114
+
115
+ if (cmd === "list-secrets") {
116
+ const items = listDpapiSecrets();
117
+ if (!asJson) {
118
+ if (items.length === 0) logTo("没有已保存的 DPAPI 密钥。");
119
+ else for (const s of items) logTo(` ${s.name} ${s.updatedAt ?? ""} [${s.scope}]`);
120
+ }
121
+ return finish({ ok: true, secrets: items, available: dpapiAvailable() }, 0);
122
+ }
123
+
124
+ if (cmd === "rm-secret") {
125
+ const name = val("--name", null);
126
+ if (!name) return finish({ ok: false, error: "缺少 --name" }, 1);
127
+ const removed = deleteDpapiSecret(name);
128
+ logTo(removed ? `已删除密钥「${name}」` : `未找到密钥「${name}」`);
129
+ return finish({ ok: removed, name, removed }, removed ? 0 : 1);
130
+ }
131
+
132
+ if (cmd === "migrate") {
133
+ const { migrateSecrets } = await import("./migrate.mjs");
134
+ const method = String(val("--method", "dpapi"));
135
+ const r = await migrateSecrets({ method, log: logTo, dryRun: has("--dry-run") });
136
+ if (!r.ok) logTo(`迁移失败:${r.error}`);
137
+ return finish(r, r.ok ? 0 : 1);
138
+ }
139
+
140
+ // ── auth.json 明文 API 密钥迁移
141
+ if (cmd === "auth-scan") {
142
+ const { scanAuthSecrets } = await import("./auth-migrate.mjs");
143
+ const scan = scanAuthSecrets();
144
+ if (!asJson) {
145
+ if (!scan.ok) logTo(`扫描失败:${scan.error}`);
146
+ else if (scan.items.length === 0) logTo("auth.json 没有条目。");
147
+ else {
148
+ logTo(`auth.json: ${scan.path}`);
149
+ for (const it of scan.items) {
150
+ if (it.kind === "plaintext")
151
+ logTo(` ⚠️ ${it.provider}: 明文密钥 (${it.keyLength} 字符) → 建议 $${it.suggestedEnv}`);
152
+ else if (it.kind === "reference") logTo(` ✅ ${it.provider}: ${it.current}`);
153
+ else logTo(` · ${it.provider}: ${it.reason}`);
154
+ }
155
+ }
156
+ }
157
+ return finish({ ok: scan.ok, ...scan }, scan.ok ? 0 : 1);
158
+ }
159
+
160
+ if (cmd === "auth-migrate") {
161
+ const { migrateAuthJson } = await import("./auth-migrate.mjs");
162
+ const method = String(val("--method", "env"));
163
+ const r = await migrateAuthJson({ method, log: logTo, dryRun: has("--dry-run") });
164
+ if (!r.ok && r.error) logTo(`迁移失败:${r.error}`);
165
+ else if (r.message) logTo(r.message);
166
+ if (r.warnings?.length) for (const w of r.warnings) logTo(`警告:${w}`);
167
+ return finish(r, r.ok ? 0 : 1);
168
+ }
169
+
170
+ // ── 插件修复(不需要 WebDAV 配置;--dir 指向刚恢复出来的目录,不加则看当前 agent 目录)
171
+ if (cmd === "plugins" || cmd === "repair-plugins") {
172
+ const mod = await import("./packages.mjs");
173
+ const pluginDir = val("--dir", null) ?? agentDir;
174
+ if (cmd === "plugins") {
175
+ const a = mod.analyzePlugins(pluginDir);
176
+ if (!asJson) {
177
+ if (a.packages.length === 0) logTo(`${pluginDir} 的 settings.json 里没有声明任何插件。`);
178
+ else {
179
+ logTo(`共 ${a.packages.length} 个已声明插件:`);
180
+ for (const p of a.packages) {
181
+ const mark = p.status === "ok" ? "✅" : p.status === "skip" ? "➖" : "⚠️";
182
+ logTo(` ${mark} ${p.source}${p.status === "ok" || p.status === "skip" ? "" : ` — ${p.detail}`}`);
183
+ }
184
+ }
185
+ if (a.issues.length) logTo(`\n有 ${a.issues.length} 个插件需要补装,运行 /backup repair-plugins 修复。`);
186
+ }
187
+ finish({ ok: a.ok, dir: pluginDir, ...a }, a.ok ? 0 : 1);
188
+ return;
189
+ }
190
+
191
+ const r = mod.repairPlugins(pluginDir, { log: logTo, dryRun: has("--dry-run") });
192
+ if (!asJson) {
193
+ if (r.repaired.length === 0 && r.failed.length === 0 && r.skipped.length === 0) {
194
+ logTo("所有插件都已安装完整,无需修复。");
195
+ } else {
196
+ for (const x of r.repaired) logTo(` ✅ ${x.source} — ${x.action}`);
197
+ for (const x of r.skipped) logTo(` ➖ ${x.source} — ${x.reason}`);
198
+ for (const x of r.failed) logTo(` ❌ ${x.source} — ${x.error}`);
199
+ }
200
+ }
201
+ finish({ ok: r.ok, dir: pluginDir, repaired: r.repaired, failed: r.failed, skipped: r.skipped }, r.ok ? 0 : 1);
202
+ return;
203
+ }
204
+
205
+ // ── agentDir 之外的额外数据归位(extra/)与遗配置探测;不需 WebDAV 配置
206
+ if (cmd === "apply-extras") {
207
+ const dir = val("--dir", null);
208
+ if (!dir) return finish({ ok: false, error: "缺少 --dir(指向恢复出来的目录)" }, 1);
209
+ const r = applyExtraFiles(dir, { log: logTo, dryRun: has("--dry-run"), force: has("--force") });
210
+ if (!asJson) {
211
+ const dryNote = has("--dry-run") ? "(dry-run,未写入)" : "";
212
+ if (r.source) {
213
+ const anchors = (r.source.anchors || []).map((a) => `${a.kind}=${a.path}`).join(",");
214
+ logTo(`来源机器:${r.source.hostname || "?"} (${r.source.platform}) home=${r.source.home}`);
215
+ if (anchors) logTo(`源锚点:${anchors}`);
216
+ }
217
+ if (r.applied.length === 0 && r.skipped.length === 0 && r.unchanged.length === 0 && r.unparsed.length === 0) {
218
+ logTo("没有需要归位的额外数据(extra/ 为空)。");
219
+ } else {
220
+ for (const x of r.applied) logTo(` ✅ ${x.rel} → ${x.target}${x.mapped ? "(跨机重映射)" : ""}`);
221
+ for (const x of r.skipped) logTo(` ➖ ${x.rel} → ${x.target}(${x.reason})`);
222
+ for (const x of r.unchanged) logTo(` = ${x.rel}(已一致)`);
223
+ for (const x of r.unmapped) logTo(` ⚠️ ${x.rel} 未匹配锚点,按原路径归位:${x.orig} → ${x.target}`);
224
+ for (const x of r.unparsed) logTo(` ⚠️ ${x.rel} — ${x.reason}`);
225
+ if (dryNote) logTo(` ${dryNote}`);
226
+ }
227
+ }
228
+ finish({ ok: r.ok, dir, dryRun: has("--dry-run"), ...r }, r.ok ? 0 : 1);
229
+ return;
230
+ }
231
+
232
+ // ── 把恢复结果写回当前 agent 目录(换机一键还原);不需 WebDAV 配置
233
+ if (cmd === "restore-apply") {
234
+ const dir = val("--dir", null);
235
+ if (!dir) return finish({ ok: false, error: "缺少 --dir(指向恢复出来的目录)" }, 1);
236
+ if (!existsSync(dir)) return finish({ ok: false, error: `恢复目录不存在:${dir}` }, 1);
237
+ const mergeMode = String(val("--mode", "merge")) !== "replace";
238
+ const r = applyRestoredFiles(dir, agentDir, {
239
+ log: logTo,
240
+ dryRun: has("--dry-run"),
241
+ force: has("--force"),
242
+ merge: mergeMode,
243
+ });
244
+ const pluginReady = hasPluginCode(dir);
245
+ if (!asJson) {
246
+ if (!has("--dry-run")) {
247
+ logTo(`已写回 agent 目录:${agentDir}(${mergeMode ? "合并模式" : "替换模式"})`);
248
+ logTo(` 写入/覆盖 ${r.applied.length} 个,内容一致 ${r.unchanged.length} 个,失败 ${r.failed.length} 个`);
249
+ if (r.merged?.length) logTo(` 🔀 settings.json 已合并 packages,保留本机已有插件声明`);
250
+ for (const x of r.skipped) logTo(` ➖ ${x.rel}(${x.reason})`);
251
+ for (const x of r.failed) logTo(` ❌ ${x.rel} — ${x.error}`);
252
+ if (pluginReady) logTo("插件实现代码已随备份恢复,无需联网重装。");
253
+ else logTo("备份里不含插件实现代码,可运行 /backup repair-plugins 联网补装。");
254
+ } else {
255
+ logTo(`dry-run:将把 ${r.total} 个文件写回 ${agentDir}(合并模式,${r.skipped.length} 个会跳过)`);
256
+ }
257
+ }
258
+ return finish(
259
+ { ok: r.ok, dir, agentDir, dryRun: has("--dry-run"), mode: mergeMode ? "merge" : "replace", pluginReady, ...r },
260
+ r.ok ? 0 : 1,
261
+ );
262
+ }
263
+
264
+ if (cmd === "detect-legacy") {
265
+ const found = detectLegacyConfig(agentDir);
266
+ if (!asJson) {
267
+ if (found.length === 0) logTo("未发现 agentDir 之外的遗留配置。");
268
+ else {
269
+ for (const f of found) {
270
+ logTo(` ⚠️ ${f.path}(${f.note})`);
271
+ if (f.portable) logTo(` 可移植写法:${f.portable}`);
272
+ if (f.packages && f.packages.length) logTo(` 包声明:${f.packages.join("、")}`);
273
+ }
274
+ logTo("建议把这些文件加入 extraScope,以便随备份一起同步。");
275
+ }
276
+ }
277
+ finish({ ok: true, agentDir, files: found }, 0);
278
+ return;
279
+ }
280
+
281
+ const loaded = loadConfig();
282
+
283
+ if (!loaded.ok) {
284
+ const err = loaded.errors.join("; ");
285
+ logTo(`配置错误:${err}`);
286
+ finish({ ok: false, error: err, errors: loaded.errors, configPath: configPath() }, 1);
287
+ return;
288
+ }
289
+ const { config, warnings } = loaded;
290
+ for (const w of warnings) logTo(`警告:${w}`);
291
+
292
+ if (cmd === "doctor") {
293
+ const report = {
294
+ ok: loaded.ok,
295
+ configPath: configPath(),
296
+ url: config.url,
297
+ username: config.username || "(未设置)",
298
+ password: {
299
+ source: loaded.secrets?.passwordSource ?? "empty",
300
+ plaintext: isPlaintextSecret(loaded.raw?.remote?.password),
301
+ },
302
+ encryptKey: {
303
+ configured: !!config.encryptKey,
304
+ source: loaded.secrets?.encryptKeySource ?? "empty",
305
+ plaintext: isPlaintextSecret(loaded.raw?.encryptKey),
306
+ },
307
+ errors: loaded.errors,
308
+ warnings: loaded.warnings,
309
+ dpapiAvailable: dpapiAvailable(),
310
+ connectivity: null,
311
+ };
312
+
313
+ if (loaded.ok && config.url) {
314
+ try {
315
+ const c = new WebDAVClient({
316
+ baseUrl: config.url,
317
+ username: config.username,
318
+ password: config.password,
319
+ timeoutMs: config.timeoutMs,
320
+ insecureTls: config.insecureTls,
321
+ });
322
+ const r = await c.check();
323
+ report.connectivity = { ok: r.ok, status: r.status, error: r.error };
324
+ } catch (e) {
325
+ report.connectivity = { ok: false, error: e instanceof Error ? e.message : String(e) };
326
+ }
327
+ }
328
+
329
+ if (!asJson) {
330
+ logTo("配置体检");
331
+ logTo(` 配置文件 ${report.configPath}`);
332
+ logTo(` WebDAV ${report.url || "(未设置)"}`);
333
+ logTo(` 用户名 ${report.username}`);
334
+ logTo(
335
+ ` 密码来源 ${report.password.source}${report.password.plaintext ? " ⚠️ 明文" : ""}`,
336
+ );
337
+ logTo(
338
+ ` 加密密钥 ${report.encryptKey.configured ? report.encryptKey.source : "(未配置)"}${
339
+ report.encryptKey.plaintext ? " ⚠️ 明文" : ""
340
+ }`,
341
+ );
342
+ logTo(` DPAPI 可用 ${report.dpapiAvailable ? "是" : "否(非 Windows)"}`);
343
+ logTo(
344
+ ` 连接测试 ${
345
+ report.connectivity
346
+ ? report.connectivity.ok
347
+ ? "通过 ✅"
348
+ : `失败:${report.connectivity.error}`
349
+ : "未执行"
350
+ }`,
351
+ );
352
+ if (report.errors.length) logTo(` 错误:${report.errors.join(";")}`);
353
+ if (report.warnings.length) logTo(` 告警:${report.warnings.join(";")}`);
354
+ }
355
+ return finish(report, report.ok ? 0 : 1);
356
+ }
357
+
358
+ if (cmd === "check") {
359
+ logTo(`检查 ${config.url} …`);
360
+ const client = new WebDAVClient({
361
+ baseUrl: config.url,
362
+ username: config.username,
363
+ password: config.password,
364
+ timeoutMs: config.timeoutMs,
365
+ insecureTls: config.insecureTls,
366
+ });
367
+ const r = await client.check();
368
+ if (r.ok) {
369
+ logTo("WebDAV 连接正常,认证通过。");
370
+ finish({ ok: true, status: r.status, url: config.url });
371
+ } else {
372
+ logTo(`连接失败:${r.error}`);
373
+ finish({ ok: false, error: r.error, status: r.status }, 1);
374
+ }
375
+ return;
376
+ }
377
+
378
+ if (cmd === "list") {
379
+ const client = new WebDAVClient({
380
+ baseUrl: config.url,
381
+ username: config.username,
382
+ password: config.password,
383
+ timeoutMs: config.timeoutMs,
384
+ insecureTls: config.insecureTls,
385
+ });
386
+ const entries = await client.list(config.remoteDir);
387
+ const files = entries.filter((e) => !e.isCollection);
388
+ if (!asJson) {
389
+ if (files.length === 0) logTo(`远端 ${config.remoteDir}/ 暂无备份文件。`);
390
+ else {
391
+ logTo(`远端 ${config.remoteDir}/ 共 ${files.length} 个文件:`);
392
+ for (const f of files) {
393
+ logTo(` ${f.name} ${(f.size / 1024).toFixed(1)} KB ${f.lastModified ?? ""}`);
394
+ }
395
+ }
396
+ }
397
+ finish({ ok: true, files });
398
+ return;
399
+ }
400
+
401
+ if (cmd === "prune") {
402
+ const keep = Number(val("--keep", config.keepVersions));
403
+ const r = await pruneOldArchives(config, keep, logTo);
404
+ finish({ ok: true, ...r });
405
+ return;
406
+ }
407
+
408
+ if (cmd === "restore") {
409
+ const { restoreBackup } = await import("./restore.mjs");
410
+ const r = await restoreBackup(config, {
411
+ agentDir,
412
+ file: val("--file", null),
413
+ to: val("--to", null),
414
+ listOnly: has("--list"),
415
+ log: logTo,
416
+ });
417
+ finish(r, r.ok ? 0 : 1);
418
+ return;
419
+ }
420
+
421
+ if (cmd === "status") {
422
+ const state = readState(statePath());
423
+ finish({ ok: true, state, configPath: configPath() });
424
+ return;
425
+ }
426
+
427
+ if (cmd === "install-cron" || cmd === "uninstall-cron") {
428
+ const { installCron, uninstallCron } = await import("./cron.mjs");
429
+ if (cmd === "install-cron") {
430
+ const r = await installCron({ time: val("--time", "03:00"), log: logTo });
431
+ finish({ ok: r.ok, ...r });
432
+ } else {
433
+ const r = await uninstallCron({ log: logTo });
434
+ finish({ ok: r.ok, ...r });
435
+ }
436
+ return;
437
+ }
438
+
439
+ if (cmd === "backup" || cmd === "run") {
440
+ const reason = val("--reason", "manual");
441
+ if (!config.enabled && reason === "exit") {
442
+ logTo("自动备份已禁用(enabled: false),跳过。");
443
+ finish({ ok: true, skipped: true, reason: "disabled" });
444
+ return;
445
+ }
446
+ logTo(`开始备份(触发方式:${reason}${has("--dry-run") ? ",试运行" : ""})`);
447
+ const result = await runBackup(config, {
448
+ log: logTo,
449
+ agentDir,
450
+ stateFile: statePath(),
451
+ dryRun: has("--dry-run"),
452
+ reason,
453
+ });
454
+
455
+ if (result.ok && !result.dryRun && reason !== "exit") {
456
+ try {
457
+ await pruneOldArchives(config, config.keepVersions, logTo);
458
+ } catch (e) {
459
+ logTo(`清理旧版本失败(不影响本次备份):${e.message}`);
460
+ }
461
+ }
462
+
463
+ if (!result.ok) logTo(`备份失败:${result.error}`);
464
+ finish(result, result.ok ? 0 : 1);
465
+ return;
466
+ }
467
+
468
+ logTo(`未知命令:${cmd}`);
469
+ finish({ ok: false, error: `未知命令:${cmd}` }, 1);
470
+ }
471
+
472
+ main().catch((e) => {
473
+ logTo(`未捕获错误:${e?.stack ?? e?.message ?? String(e)}`);
474
+ finish({ ok: false, error: String(e?.message ?? e) }, 1);
475
+ });