pi-webdav-backup 1.0.1 → 1.0.2
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 +5 -1
- package/extensions/webdav-backup/args.mjs +27 -0
- package/extensions/webdav-backup/cli.mjs +8 -3
- package/extensions/webdav-backup/json.mjs +33 -0
- package/extensions/webdav-backup/test-empty-cmd.mjs +167 -0
- package/extensions/webdav-backup/test-json.mjs +89 -0
- package/extensions/webdav-backup.ts +14 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,6 +88,8 @@ node ~/.pi/agent/extensions/webdav-backup/cli.mjs restore --list
|
|
|
88
88
|
node ~/.pi/agent/extensions/webdav-backup/cli.mjs restore --file pi-agent-20260924-120000.tar.gz --to D:\restore
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
输出末行固定是 JSON 汇总(`--json` 时只输出该行,人类模式则前面还有日志行),脚本可直接解析;退出码 0 成功 / 1 失败。
|
|
92
|
+
|
|
91
93
|
---
|
|
92
94
|
|
|
93
95
|
## 密钥不明文落盘
|
|
@@ -326,7 +328,7 @@ npm test
|
|
|
326
328
|
node extensions/webdav-backup/verify-load.mjs
|
|
327
329
|
```
|
|
328
330
|
|
|
329
|
-
|
|
331
|
+
8 个测试文件、310 项断言,用内置的内存 WebDAV 服务端做真实 HTTP 往返:
|
|
330
332
|
|
|
331
333
|
| 文件 | 覆盖 |
|
|
332
334
|
|---|---|
|
|
@@ -334,6 +336,8 @@ node extensions/webdav-backup/verify-load.mjs
|
|
|
334
336
|
| `test-cli.mjs` | 真实子进程调用 CLI、退出备份、DPAPI 端到端、doctor、日志、状态 |
|
|
335
337
|
| `test-restore.mjs` | 归档/加密/快照恢复、目录穿越防护 |
|
|
336
338
|
| `test-redact.mjs` | 脱敏(含「字段名无关但值是密钥」的回归用例) |
|
|
339
|
+
| `test-json.mjs` | CLI 输出 JSON 解析容错(回归:空输出曾抛裸 JSON 错误吞掉 stderr) |
|
|
340
|
+
| `test-empty-cmd.mjs` | 空参数/子命令解析、输出契约(回归:`/backup` 不带参数曾报「未知命令:」、完成提醒拿不到 JSON 汇总) |
|
|
337
341
|
| `test-secrets.mjs` | dpapi/$ENV/file/!命令/plain 各分支、明文告警、三种迁移方式 |
|
|
338
342
|
| `test-auth-migrate.mjs` | `auth.json` 明文密钥审计与迁移(迁移前自动备份、可回滚) |
|
|
339
343
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* args.mjs — 子命令解析(扩展入口 webdav-backup.ts 与 cli.mjs 共用)
|
|
3
|
+
*
|
|
4
|
+
* 背景:早期写法 `tokens[0] ?? fallback` 在 tokens[0] 为空串时不会兜底
|
|
5
|
+
* (`??` 只认 null/undefined,空串是"有值"),于是 `/backup` 不带参数时
|
|
6
|
+
* `"".trim().split(/\s+/)` 得到 `[""]`,空串被当成命令传给 CLI,
|
|
7
|
+
* 最终报出冒号后为空的「未知命令:」。这里统一做空 token 容错。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 从 token 列表里取第一个非空 token(小写)作为子命令;
|
|
12
|
+
* 全是空 token(或列表为空/缺失)时返回 fallback。
|
|
13
|
+
*
|
|
14
|
+
* 保持「第一个参数就是命令」的既有语义:flag(`-` 开头)不当作空 token
|
|
15
|
+
* 跳过,它照样会被当成命令并得到明确的「未知命令:xxx」报错。
|
|
16
|
+
*
|
|
17
|
+
* @param {readonly (string | null | undefined)[] | null | undefined} tokens
|
|
18
|
+
* @param {string} fallback
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function pickCommand(tokens, fallback) {
|
|
22
|
+
for (const t of tokens ?? []) {
|
|
23
|
+
const s = String(t ?? "").trim();
|
|
24
|
+
if (s) return s.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* node cli.mjs install-cron [--time HH:MM] 安装 Windows 计划任务(每日备份)
|
|
19
19
|
* node cli.mjs uninstall-cron 移除计划任务
|
|
20
20
|
*
|
|
21
|
-
*
|
|
21
|
+
* 输出人类可读文本,末行固定是 JSON 汇总;加 --json 则只输出该汇总行。
|
|
22
|
+
* 退出码 0 成功 / 1 失败。
|
|
22
23
|
* --value 优先,其次读 stdin(避免密钥出现在进程列表里)
|
|
23
24
|
*/
|
|
24
25
|
|
|
@@ -36,9 +37,11 @@ import {
|
|
|
36
37
|
resolveSecret,
|
|
37
38
|
isPlaintextSecret,
|
|
38
39
|
} from "./secrets.mjs";
|
|
40
|
+
import { pickCommand } from "./args.mjs";
|
|
39
41
|
|
|
40
42
|
const args = process.argv.slice(2);
|
|
41
|
-
|
|
43
|
+
// 注意:不能写 `args[0] ?? "backup"`——空串会绕过 ?? 兜底(见 args.mjs)
|
|
44
|
+
const cmd = pickCommand(args, "backup");
|
|
42
45
|
const has = (f) => args.includes(f);
|
|
43
46
|
const val = (f, d = null) => {
|
|
44
47
|
const i = args.indexOf(f);
|
|
@@ -58,7 +61,9 @@ function logTo(line) {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
function finish(payload, code) {
|
|
61
|
-
|
|
64
|
+
// JSON 汇总固定作为最后一行输出:扩展入口按「最后一行 JSON」解析结果
|
|
65
|
+
// 并渲染完成提醒;--json 模式下前面没有日志行,只有这一行。
|
|
66
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
62
67
|
process.exit(code ?? (payload.ok ? 0 : 1));
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* json.mjs — 安全解析子进程 CLI 输出的 JSON
|
|
3
|
+
*
|
|
4
|
+
* 背景:早期实现直接 JSON.parse(输出最后一行),CLI 一旦 spawn 失败
|
|
5
|
+
* 或输出为空,就抛裸的 "Unexpected end of JSON input",
|
|
6
|
+
* 把真正有用的错误(stderr)吞掉。这里做统一容错。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 解析 CLI 输出中最后一个非空行的 JSON。
|
|
11
|
+
* 解析失败时抛出带【真实原因】的错误(stderr 优先),
|
|
12
|
+
* 绝不抛裸的 JSON 语法错误。
|
|
13
|
+
*
|
|
14
|
+
* @param {{stdout?: string, stderr?: string, code?: number|null}} result
|
|
15
|
+
* @returns {any} 解析成功的 JSON 值
|
|
16
|
+
*/
|
|
17
|
+
export function parseCliJson(result) {
|
|
18
|
+
const { stdout = "", stderr = "", code = null } = result ?? {};
|
|
19
|
+
const line = String(stdout)
|
|
20
|
+
.split("\n")
|
|
21
|
+
.map((s) => s.trim())
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.pop();
|
|
24
|
+
if (line) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(line);
|
|
27
|
+
} catch {
|
|
28
|
+
/* 继续走下面的友好错误 */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const hint = String(stderr).trim() || String(stdout).trim();
|
|
32
|
+
throw new Error(hint || `命令失败(退出码 ${code})`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* test-empty-cmd.mjs — 空参数 / 子命令解析回归测试
|
|
4
|
+
*
|
|
5
|
+
* 事故回归:`/backup` 不带参数时 `"".trim().split(/\s+/)[0]` 是 `""`
|
|
6
|
+
* (不是 undefined),`?? "run"` 兜底失效,空串被当命令传给 CLI,
|
|
7
|
+
* 报出「未知命令:」(冒号后为空)。这里锁住三层防御:
|
|
8
|
+
* 1. pickCommand() 的单元语义(ts 入口与 cli.mjs 共用)
|
|
9
|
+
* 2. 源码不变量:两个入口都必须走 pickCommand,禁止退回 ?? 写法
|
|
10
|
+
* 3. cli.mjs 真实子进程:空 token 默认 backup / 被跳过 / 真未知命令仍报错
|
|
11
|
+
*
|
|
12
|
+
* 用法:node test-empty-cmd.mjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join, dirname } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
import { pickCommand } from "./args.mjs";
|
|
22
|
+
import { startDavServer } from "./dav-mock.mjs";
|
|
23
|
+
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const CLI = join(here, "cli.mjs");
|
|
26
|
+
|
|
27
|
+
let pass = 0;
|
|
28
|
+
let fail = 0;
|
|
29
|
+
const check = (name, cond, detail = "") => {
|
|
30
|
+
if (cond) {
|
|
31
|
+
pass++;
|
|
32
|
+
console.log(` ✅ ${name}`);
|
|
33
|
+
} else {
|
|
34
|
+
fail++;
|
|
35
|
+
console.log(` ❌ ${name}${detail ? ` — ${detail}` : ""}`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function runCli(args, env) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const child = spawn(process.execPath, [CLI, ...args], {
|
|
42
|
+
env: { ...process.env, ...env },
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
});
|
|
45
|
+
let stdout = "";
|
|
46
|
+
let stderr = "";
|
|
47
|
+
child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
|
|
48
|
+
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
|
49
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const lastJson = (out) => {
|
|
54
|
+
const line = out.trim().split("\n").filter((l) => l.trim().startsWith("{")).pop();
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(line ?? "{}");
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
async function main() {
|
|
63
|
+
console.log("pi WebDAV 备份 — 空参数解析回归测试\n");
|
|
64
|
+
|
|
65
|
+
// ── 1. pickCommand 单元语义
|
|
66
|
+
check('pickCommand(["run"], "backup") → "run"', pickCommand(["run"], "backup") === "run");
|
|
67
|
+
check('pickCommand([""], "run") → 兜底 "run"(事故场景)', pickCommand([""], "run") === "run");
|
|
68
|
+
check('pickCommand([], "run") → 兜底', pickCommand([], "run") === "run");
|
|
69
|
+
check('pickCommand(null, "backup") → 兜底', pickCommand(null, "backup") === "backup");
|
|
70
|
+
check('pickCommand(["", "", ""], "run") → 兜底', pickCommand(["", "", ""], "run") === "run");
|
|
71
|
+
check('pickCommand(["", "check", "--json"], "run") → "check"', pickCommand(["", "check", "--json"], "run") === "check");
|
|
72
|
+
check('pickCommand(["", "backup", "--reason", "manual"], "run") → "backup"', pickCommand(["", "backup", "--reason", "manual"], "run") === "backup");
|
|
73
|
+
check('pickCommand([" Run "], "run") → "run"(trim + 小写)', pickCommand([" Run "], "run") === "run");
|
|
74
|
+
|
|
75
|
+
// ── 2. 源码不变量:两个入口都必须走 pickCommand(防退回 ?? 空串绕过的写法)
|
|
76
|
+
// 反模式断言只查代码行:注释里引用的反例不算
|
|
77
|
+
const codeOnly = (src) =>
|
|
78
|
+
src
|
|
79
|
+
.split("\n")
|
|
80
|
+
.filter((l) => !l.trim().startsWith("//"))
|
|
81
|
+
.join("\n");
|
|
82
|
+
const tsSrc = readFileSync(join(here, "..", "webdav-backup.ts"), "utf8");
|
|
83
|
+
const cliSrc = readFileSync(CLI, "utf8");
|
|
84
|
+
check("入口 webdav-backup.ts 用 pickCommand 解析子命令", tsSrc.includes("pickCommand(args.trim()"));
|
|
85
|
+
check('入口不再有 `?? "run"` 兜底写法', !codeOnly(tsSrc).includes('?? "run"'));
|
|
86
|
+
check('cli.mjs 用 pickCommand 解析命令', cliSrc.includes('pickCommand(args, "backup")'));
|
|
87
|
+
check('cli.mjs 不再有 `args[0] ?? "backup"` 写法', !codeOnly(cliSrc).includes('args[0] ?? "backup"'));
|
|
88
|
+
|
|
89
|
+
// ── 3. cli.mjs 真实子进程行为(内存 WebDAV + 临时 agentDir)
|
|
90
|
+
const dav = await startDavServer();
|
|
91
|
+
const baseUrl = `http://127.0.0.1:${dav.port}/dav`;
|
|
92
|
+
dav.dirs.add("/dav");
|
|
93
|
+
|
|
94
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-emptycmd-test-"));
|
|
95
|
+
mkdirSync(join(agentDir, "extensions"), { recursive: true });
|
|
96
|
+
writeFileSync(join(agentDir, "extensions", "a.ts"), "export default () => {};\n");
|
|
97
|
+
writeFileSync(join(agentDir, "settings.json"), '{"theme":"dark"}\n');
|
|
98
|
+
writeFileSync(
|
|
99
|
+
join(agentDir, "webdav-backup.json"),
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
enabled: true,
|
|
102
|
+
remote: { url: baseUrl, username: "user", password: "pass", remoteDir: "bk", remoteName: "pi" },
|
|
103
|
+
scope: ["."],
|
|
104
|
+
includeSessions: false,
|
|
105
|
+
encrypt: false,
|
|
106
|
+
keepVersions: 3,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
const env = { PI_CODING_AGENT_DIR: agentDir };
|
|
110
|
+
|
|
111
|
+
// 3a. 事故路径:命令 token 为空串 → 默认 backup,绝不报「未知命令:」
|
|
112
|
+
const empty = await runCli([""], env);
|
|
113
|
+
check(
|
|
114
|
+
'cli.mjs [""] 默认执行 backup(事故路径回归)',
|
|
115
|
+
empty.code === 0 && empty.stdout.includes("完成"),
|
|
116
|
+
`code=${empty.code} out=${empty.stdout.trim()} err=${empty.stderr.trim()}`,
|
|
117
|
+
);
|
|
118
|
+
check(
|
|
119
|
+
"空命令不再出现「未知命令:」",
|
|
120
|
+
!empty.stdout.includes("未知命令"),
|
|
121
|
+
empty.stdout.trim(),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// 3b. 空 token 被跳过,其后的真命令正常执行
|
|
125
|
+
const chk = await runCli(["", "check", "--json"], env);
|
|
126
|
+
const chkJson = lastJson(chk.stdout);
|
|
127
|
+
check(
|
|
128
|
+
'cli.mjs ["", "check"] 跳过空 token 执行 check',
|
|
129
|
+
chk.code === 0 && chkJson.ok === true,
|
|
130
|
+
`code=${chk.code} out=${chk.stdout.trim()}`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// 3c. 真正的未知命令仍要明确报错(错误里带命令名)
|
|
134
|
+
const bad = await runCli(["no-such-cmd", "--json"], env);
|
|
135
|
+
const badJson = lastJson(bad.stdout);
|
|
136
|
+
check(
|
|
137
|
+
"未知命令仍报错且带命令名",
|
|
138
|
+
bad.code === 1 && badJson.ok === false && String(badJson.error).includes("no-such-cmd"),
|
|
139
|
+
JSON.stringify(badJson),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// 3d. 输出契约:人类模式末行必须是 JSON 汇总(/backup 的「备份完成 ✅」提醒靠它)
|
|
143
|
+
const sum = lastJson(empty.stdout);
|
|
144
|
+
check(
|
|
145
|
+
"人类模式末行是 JSON 汇总(ok + remotePath + files)",
|
|
146
|
+
sum.ok === true && typeof sum.remotePath === "string" && Number(sum.files) >= 1,
|
|
147
|
+
empty.stdout.trim().split("\n").pop(),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// 3e. 失败时汇总必须带非空 error(否则前端只能弹「失败:」空提醒)
|
|
151
|
+
const noConfDir = mkdtempSync(join(tmpdir(), "pi-emptycmd-noconf-"));
|
|
152
|
+
const noConf = await runCli(["backup"], { PI_CODING_AGENT_DIR: noConfDir });
|
|
153
|
+
const noConfJson = lastJson(noConf.stdout);
|
|
154
|
+
check(
|
|
155
|
+
"失败时 JSON 汇总带非空 error",
|
|
156
|
+
noConf.code === 1 && noConfJson.ok === false && Boolean(String(noConfJson.error).trim()),
|
|
157
|
+
JSON.stringify(noConfJson),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`);
|
|
161
|
+
process.exit(fail ? 1 : 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
main().catch((e) => {
|
|
165
|
+
console.error(e);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* test-json.mjs — CLI 输出 JSON 解析的容错性
|
|
4
|
+
*
|
|
5
|
+
* 回归背景:空输出曾抛 "Unexpected end of JSON input",
|
|
6
|
+
* 在 pi 里显示为 Extension "command:backup" error,真实错误被吞掉。
|
|
7
|
+
*/
|
|
8
|
+
import { parseCliJson } from "./json.mjs";
|
|
9
|
+
|
|
10
|
+
let pass = 0;
|
|
11
|
+
let fail = 0;
|
|
12
|
+
const check = (n, c, d = "") => {
|
|
13
|
+
if (c) {
|
|
14
|
+
pass++;
|
|
15
|
+
console.log(` ✅ ${n}`);
|
|
16
|
+
} else {
|
|
17
|
+
fail++;
|
|
18
|
+
console.log(` ❌ ${n}${d ? ` — ${d}` : ""}`);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
console.log("pi WebDAV 备份 — CLI JSON 解析容错测试\n");
|
|
23
|
+
|
|
24
|
+
// 正常:取最后一个非空行解析
|
|
25
|
+
const r1 = parseCliJson({ stdout: '日志行\n{"ok":true,"files":3}\n', stderr: "", code: 0 });
|
|
26
|
+
check("取最后一个非空行解析", r1 && r1.ok === true && r1.files === 3);
|
|
27
|
+
|
|
28
|
+
const r2 = parseCliJson({ stdout: '{"a":{"b":1}}' });
|
|
29
|
+
check("单行 JSON 也正常", r2 && r2.a && r2.a.b === 1);
|
|
30
|
+
|
|
31
|
+
// 回归:空输出 + stderr → 抛出含真实原因的错误
|
|
32
|
+
let thrown = null;
|
|
33
|
+
try {
|
|
34
|
+
parseCliJson({ stdout: "", stderr: "Cannot find module cli.mjs", code: 1 });
|
|
35
|
+
} catch (e) {
|
|
36
|
+
thrown = e;
|
|
37
|
+
}
|
|
38
|
+
check(
|
|
39
|
+
"空输出抛出含 stderr 的错误(回归)",
|
|
40
|
+
thrown !== null &&
|
|
41
|
+
/Cannot find module/.test(thrown.message) &&
|
|
42
|
+
!/Unexpected end of JSON/.test(thrown.message),
|
|
43
|
+
thrown?.message,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
thrown = null;
|
|
47
|
+
try {
|
|
48
|
+
parseCliJson({ stdout: "\n \n", stderr: "boom", code: 2 });
|
|
49
|
+
} catch (e) {
|
|
50
|
+
thrown = e;
|
|
51
|
+
}
|
|
52
|
+
check("纯空白输出同样容错", thrown !== null && /boom/.test(thrown.message), thrown?.message);
|
|
53
|
+
|
|
54
|
+
thrown = null;
|
|
55
|
+
try {
|
|
56
|
+
parseCliJson({ stdout: "", stderr: "", code: 7 });
|
|
57
|
+
} catch (e) {
|
|
58
|
+
thrown = e;
|
|
59
|
+
}
|
|
60
|
+
check("无输出无 stderr 时提示退出码", thrown !== null && /退出码 7/.test(thrown.message), thrown?.message);
|
|
61
|
+
|
|
62
|
+
// 非 JSON 文本 → 友好错误,不抛裸 JSON 语法错
|
|
63
|
+
thrown = null;
|
|
64
|
+
try {
|
|
65
|
+
parseCliJson({ stdout: "连接失败:401", stderr: "", code: 1 });
|
|
66
|
+
} catch (e) {
|
|
67
|
+
thrown = e;
|
|
68
|
+
}
|
|
69
|
+
check(
|
|
70
|
+
"非 JSON 输出不抛裸语法错误",
|
|
71
|
+
thrown !== null &&
|
|
72
|
+
!/Unexpected end of JSON/.test(thrown.message) &&
|
|
73
|
+
/连接失败/.test(thrown.message),
|
|
74
|
+
thrown?.message,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// 边界
|
|
78
|
+
thrown = null;
|
|
79
|
+
try {
|
|
80
|
+
parseCliJson(null);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
thrown = e;
|
|
83
|
+
}
|
|
84
|
+
check("null 输入不崩溃", thrown !== null);
|
|
85
|
+
|
|
86
|
+
check("JSON 标量也可返回", parseCliJson({ stdout: "42" }) === 42);
|
|
87
|
+
|
|
88
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`);
|
|
89
|
+
process.exit(fail ? 1 : 0);
|
|
@@ -33,6 +33,8 @@ import { spawn } from "node:child_process";
|
|
|
33
33
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
34
34
|
import { dirname, join } from "node:path";
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
36
|
+
import { parseCliJson } from "./webdav-backup/json.mjs";
|
|
37
|
+
import { pickCommand } from "./webdav-backup/args.mjs";
|
|
36
38
|
|
|
37
39
|
// bundler/jiti 下 import.meta.url 可能不可用,做兜底
|
|
38
40
|
const EXT_DIR = (() => {
|
|
@@ -104,7 +106,9 @@ function runCli(
|
|
|
104
106
|
const text = d.toString("utf8");
|
|
105
107
|
stdout += text;
|
|
106
108
|
for (const line of text.split("\n")) {
|
|
107
|
-
|
|
109
|
+
const t = line.trim();
|
|
110
|
+
// 末行 JSON 汇总是给程序读的,不刷到状态栏
|
|
111
|
+
if (t && !t.startsWith("{")) ctx.ui.setStatus("webdav-backup", line.slice(0, 90));
|
|
108
112
|
}
|
|
109
113
|
});
|
|
110
114
|
child.stderr?.on("data", (d: Buffer) => {
|
|
@@ -210,7 +214,8 @@ export default function webdavBackupExtension(pi: ExtensionAPI) {
|
|
|
210
214
|
return f.length ? f.map((s) => ({ value: s, label: s })) : null;
|
|
211
215
|
},
|
|
212
216
|
handler: async (args, ctx) => {
|
|
213
|
-
|
|
217
|
+
// 注意:不能写 `split(/\s+/)[0] ?? "run"`——空串会绕过 ?? 兜底(见 args.mjs)
|
|
218
|
+
const sub = pickCommand(args.trim().split(/\s+/), "run");
|
|
214
219
|
|
|
215
220
|
if (sub === "help") {
|
|
216
221
|
ctx.ui.notify(
|
|
@@ -245,8 +250,7 @@ export default function webdavBackupExtension(pi: ExtensionAPI) {
|
|
|
245
250
|
// ── 体检:报告密钥来源与明文告警
|
|
246
251
|
if (sub === "verify") {
|
|
247
252
|
const r = await runCli(["doctor", "--json"], ctx, { timeoutMs: 60_000 });
|
|
248
|
-
const
|
|
249
|
-
const d = JSON.parse(line) as {
|
|
253
|
+
const d = parseCliJson(r) as {
|
|
250
254
|
ok?: boolean;
|
|
251
255
|
url?: string;
|
|
252
256
|
username?: string;
|
|
@@ -279,8 +283,7 @@ export default function webdavBackupExtension(pi: ExtensionAPI) {
|
|
|
279
283
|
|
|
280
284
|
if (action === "list") {
|
|
281
285
|
const r = await runCli(["list-secrets", "--json"], ctx, { timeoutMs: 60_000 });
|
|
282
|
-
const
|
|
283
|
-
const d = JSON.parse(line) as {
|
|
286
|
+
const d = parseCliJson(r) as {
|
|
284
287
|
available?: boolean;
|
|
285
288
|
secrets?: { name: string; updatedAt?: string }[];
|
|
286
289
|
};
|
|
@@ -399,8 +402,7 @@ export default function webdavBackupExtension(pi: ExtensionAPI) {
|
|
|
399
402
|
if (sub === "restore") {
|
|
400
403
|
const autoYes = /(^|\s)(-y|--yes)(\s|$)/.test(args);
|
|
401
404
|
const listRun = await runCli(["restore", "--list", "--json"], ctx, { timeoutMs: 60_000 });
|
|
402
|
-
const
|
|
403
|
-
const listing = JSON.parse(raw) as {
|
|
405
|
+
const listing = parseCliJson(listRun) as {
|
|
404
406
|
archives?: { name: string; size: number }[];
|
|
405
407
|
snapshot?: { name: string; fileCount: number } | null;
|
|
406
408
|
};
|
|
@@ -441,7 +443,7 @@ export default function webdavBackupExtension(pi: ExtensionAPI) {
|
|
|
441
443
|
if (rr.ok && info.ok) {
|
|
442
444
|
ctx.ui.notify(`恢复完成:${info.files} 个文件 → ${info.dest}`, "info");
|
|
443
445
|
} else {
|
|
444
|
-
ctx.ui.notify(`恢复失败:${info.error
|
|
446
|
+
ctx.ui.notify(`恢复失败:${info.error || rr.stderr.trim() || `退出码 ${rr.code}`}`, "error");
|
|
445
447
|
}
|
|
446
448
|
return;
|
|
447
449
|
}
|
|
@@ -451,8 +453,7 @@ export default function webdavBackupExtension(pi: ExtensionAPI) {
|
|
|
451
453
|
|
|
452
454
|
// status/list/check 用 JSON 结果做友好展示
|
|
453
455
|
if (sub === "status") {
|
|
454
|
-
const
|
|
455
|
-
const parsed = JSON.parse(line) as { state?: BackupState };
|
|
456
|
+
const parsed = parseCliJson(r) as { state?: BackupState };
|
|
456
457
|
ctx.ui.notify(
|
|
457
458
|
parsed.state?.lastBackupAt
|
|
458
459
|
? `上次备份:${new Date(parsed.state.lastBackupAt).toLocaleString()}(${parsed.state.lastReason ?? "?"})`
|
|
@@ -463,8 +464,7 @@ export default function webdavBackupExtension(pi: ExtensionAPI) {
|
|
|
463
464
|
}
|
|
464
465
|
|
|
465
466
|
if (sub === "check" || sub === "list") {
|
|
466
|
-
const
|
|
467
|
-
const parsed = JSON.parse(line) as {
|
|
467
|
+
const parsed = parseCliJson(r) as {
|
|
468
468
|
ok?: boolean;
|
|
469
469
|
error?: string;
|
|
470
470
|
status?: number;
|
|
@@ -526,7 +526,7 @@ export default function webdavBackupExtension(pi: ExtensionAPI) {
|
|
|
526
526
|
ctx.ui.notify("完成", "info");
|
|
527
527
|
}
|
|
528
528
|
} else {
|
|
529
|
-
ctx.ui.notify(`失败:${summary.error
|
|
529
|
+
ctx.ui.notify(`失败:${summary.error || r.stderr.trim() || `退出码 ${r.code}`}`, "error");
|
|
530
530
|
}
|
|
531
531
|
},
|
|
532
532
|
});
|