pi-webdav-backup 1.0.1 → 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.
- package/README.md +205 -23
- package/extensions/webdav-backup/apply.mjs +319 -0
- package/extensions/webdav-backup/args.mjs +27 -0
- package/extensions/webdav-backup/backup.mjs +167 -87
- package/extensions/webdav-backup/cli.mjs +475 -352
- 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/json.mjs +33 -0
- package/extensions/webdav-backup/packages.mjs +375 -0
- package/extensions/webdav-backup/restore.mjs +17 -0
- package/extensions/webdav-backup/test-apply.mjs +318 -0
- package/extensions/webdav-backup/test-empty-cmd.mjs +167 -0
- package/extensions/webdav-backup/test-extras.mjs +314 -0
- package/extensions/webdav-backup/test-json.mjs +89 -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,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* test-apply.mjs — 「插件实现随备份、恢复写回」测试(换机一键还原)
|
|
4
|
+
*
|
|
5
|
+
* 背景:备份默认排除 node_modules,恢复后插件只能靠联网补装,无法真正一键还原。
|
|
6
|
+
* 新增 includePlugins(把插件实现打进备份)与 restore-apply(把恢复结果写回当前
|
|
7
|
+
* 配置目录),让「新电脑装好 pi → 恢复 → 直接用」成为可能。
|
|
8
|
+
*
|
|
9
|
+
* 验证:
|
|
10
|
+
* 1. exclude 组装:includePlugins=false 排除 node_modules;true 则保留(只排除 .git 等)
|
|
11
|
+
* 2. collectFiles 在 includePlugins=true 时确实收集到 node_modules 里的插件代码
|
|
12
|
+
* 3. 归档往返:备份含插件 → 恢复到临时目录 → hasPluginCode/analyzePlugins 通过
|
|
13
|
+
* 4. apply.mjs:listRestoredFiles 跳过 extra/ 与元数据;写回、覆盖前备份、幂等
|
|
14
|
+
* 5. dry-run 不落盘;归档里是 __REDACTED__ 而本地有真实值时跳过
|
|
15
|
+
* 6. applyRestoredFiles 拒绝目录穿越
|
|
16
|
+
* 7. 本机已装插件时的合并策略:保留本机版本与声明、不混入旧文件;replace 则完全还原
|
|
17
|
+
* 8. 本机无 packages 声明时不合并(用备份原样)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join, dirname } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
import { startDavServer } from "./dav-mock.mjs";
|
|
27
|
+
import { loadConfig } from "./config.mjs";
|
|
28
|
+
import { collectFiles } from "./collect.mjs";
|
|
29
|
+
import { runBackup } from "./backup.mjs";
|
|
30
|
+
import { restoreBackup } from "./restore.mjs";
|
|
31
|
+
import { analyzePlugins } from "./packages.mjs";
|
|
32
|
+
import { applyRestoredFiles, listRestoredFiles, hasPluginCode, pluginRootRel, packageIdentity, mergeSettingsPackages } from "./apply.mjs";
|
|
33
|
+
|
|
34
|
+
let pass = 0;
|
|
35
|
+
let fail = 0;
|
|
36
|
+
const lines = [];
|
|
37
|
+
const check = (n, c, d = "") => {
|
|
38
|
+
if (c) {
|
|
39
|
+
pass++;
|
|
40
|
+
lines.push(` ✅ ${n}`);
|
|
41
|
+
} else {
|
|
42
|
+
fail++;
|
|
43
|
+
lines.push(` ❌ ${n}${d ? ` — ${d}` : ""}`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** 造一个带插件代码的假 agent 目录 */
|
|
48
|
+
function makeAgentDir() {
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-apply-agent-"));
|
|
50
|
+
mkdirSync(join(dir, "extensions"), { recursive: true });
|
|
51
|
+
mkdirSync(join(dir, "npm", "node_modules", "pi-footer"), { recursive: true });
|
|
52
|
+
mkdirSync(join(dir, "npm", "node_modules", "foo", "lib"), { recursive: true });
|
|
53
|
+
writeFileSync(join(dir, "settings.json"), JSON.stringify({ packages: ["npm:pi-footer@0.5.0"], theme: "dark" }));
|
|
54
|
+
writeFileSync(join(dir, "extensions", "mine.ts"), "export default () => {};\n");
|
|
55
|
+
writeFileSync(join(dir, "npm", "package.json"), JSON.stringify({ name: "pi-extensions", private: true, dependencies: { "pi-footer": "^0.5.0", foo: "^1.0.0" } }));
|
|
56
|
+
writeFileSync(join(dir, "npm", "node_modules", "pi-footer", "package.json"), JSON.stringify({ name: "pi-footer", version: "0.5.0" }));
|
|
57
|
+
writeFileSync(join(dir, "npm", "node_modules", "pi-footer", "index.js"), "module.exports = 1;\n");
|
|
58
|
+
writeFileSync(join(dir, "npm", "node_modules", "foo", "package.json"), JSON.stringify({ name: "foo", version: "1.0.0" }));
|
|
59
|
+
writeFileSync(join(dir, "npm", "node_modules", "foo", "lib", "index.js"), "module.exports = 2;\n");
|
|
60
|
+
// 运行时用不到、应被排除的内部数据
|
|
61
|
+
mkdirSync(join(dir, "npm", "node_modules", "foo", ".git"), { recursive: true });
|
|
62
|
+
writeFileSync(join(dir, "npm", "node_modules", "foo", ".git", "HEAD"), "ref: refs/heads/main\n");
|
|
63
|
+
return dir;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function main() {
|
|
67
|
+
console.log("pi WebDAV 备份 — 插件随备份 / 恢复写回测试\n");
|
|
68
|
+
|
|
69
|
+
// ── 1. exclude 组装
|
|
70
|
+
{
|
|
71
|
+
const dir = makeAgentDir();
|
|
72
|
+
process.env.PI_CODING_AGENT_DIR = dir;
|
|
73
|
+
writeFileSync(join(dir, "webdav-backup.json"), JSON.stringify({ remote: { url: "http://127.0.0.1:1/dav" } }));
|
|
74
|
+
const slim = loadConfig().config;
|
|
75
|
+
check("默认 includePlugins=false", slim.includePlugins === false);
|
|
76
|
+
check("瘦备份排除 node_modules", slim.exclude.includes("**/node_modules/**"), slim.exclude.join(","));
|
|
77
|
+
|
|
78
|
+
writeFileSync(join(dir, "webdav-backup.json"), JSON.stringify({ remote: { url: "http://127.0.0.1:1/dav" }, includePlugins: true }));
|
|
79
|
+
const fat = loadConfig().config;
|
|
80
|
+
check("includePlugins=true 生效", fat.includePlugins === true);
|
|
81
|
+
check("含插件时不排除 node_modules", !fat.exclude.includes("**/node_modules/**"), fat.exclude.join(","));
|
|
82
|
+
check("含插件时排除 .git 内部", fat.exclude.includes("**/.git/**"), fat.exclude.join(","));
|
|
83
|
+
|
|
84
|
+
// ── 2. 收集
|
|
85
|
+
const slimCollected = collectFiles({ agentDir: dir, scope: ["."], exclude: slim.exclude });
|
|
86
|
+
check("瘦备份不收集插件代码", !slimCollected.files.some((f) => f.rel.includes("node_modules/")), slimCollected.files.map((f) => f.rel).join(","));
|
|
87
|
+
|
|
88
|
+
const fatCollected = collectFiles({ agentDir: dir, scope: ["."], exclude: fat.exclude });
|
|
89
|
+
const rels = fatCollected.files.map((f) => f.rel);
|
|
90
|
+
check("含插件时收集到插件代码", rels.includes("npm/node_modules/pi-footer/index.js"), rels.join(","));
|
|
91
|
+
check("含插件时收集到嵌套实现", rels.includes("npm/node_modules/foo/lib/index.js"));
|
|
92
|
+
check("含插件时仍排除 .git", !rels.some((r) => r.includes(".git/")), rels.join(","));
|
|
93
|
+
rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── 3. 归档往返:含插件备份 → 恢复
|
|
97
|
+
{
|
|
98
|
+
const dav = await startDavServer();
|
|
99
|
+
dav.dirs.add("/dav");
|
|
100
|
+
const agentDir = makeAgentDir();
|
|
101
|
+
writeFileSync(
|
|
102
|
+
join(agentDir, "webdav-backup.json"),
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
remote: { url: `http://127.0.0.1:${dav.port}/dav`, username: "user", password: "pass", remoteDir: "bk", remoteName: "pi" },
|
|
105
|
+
includePlugins: true,
|
|
106
|
+
encrypt: false,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
110
|
+
const config = loadConfig().config;
|
|
111
|
+
|
|
112
|
+
const bk = await runBackup(config, { log: () => {}, agentDir, stateFile: join(agentDir, "s.json") });
|
|
113
|
+
check("含插件备份成功", bk.ok === true, bk.error || JSON.stringify(bk));
|
|
114
|
+
|
|
115
|
+
const dest = join(agentDir, "restored");
|
|
116
|
+
const r = await restoreBackup(config, { agentDir, to: dest, log: () => {} });
|
|
117
|
+
check("恢复成功", r.ok === true, JSON.stringify(r).slice(0, 200));
|
|
118
|
+
check("恢复出插件代码", existsSync(join(dest, "npm", "node_modules", "pi-footer", "index.js")));
|
|
119
|
+
check("恢复出嵌套插件代码", existsSync(join(dest, "npm", "node_modules", "foo", "lib", "index.js")));
|
|
120
|
+
check("hasPluginCode 判定为已含插件", hasPluginCode(dest) === true);
|
|
121
|
+
|
|
122
|
+
const analysis = analyzePlugins(dest);
|
|
123
|
+
check("含插件恢复后体检通过(无需联网补装)", analysis.ok === true, JSON.stringify(analysis.issues));
|
|
124
|
+
|
|
125
|
+
dav.server.close();
|
|
126
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 4. listRestoredFiles 跳过 extra/ 与元数据
|
|
130
|
+
{
|
|
131
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-apply-list-"));
|
|
132
|
+
mkdirSync(join(dir, "extra", "C", "Users", "me", ".pi"), { recursive: true });
|
|
133
|
+
mkdirSync(join(dir, "npm"), { recursive: true });
|
|
134
|
+
writeFileSync(join(dir, "settings.json"), "{}");
|
|
135
|
+
writeFileSync(join(dir, "npm", "package.json"), "{}");
|
|
136
|
+
writeFileSync(join(dir, ".pi-backup-manifest.json"), "{}");
|
|
137
|
+
writeFileSync(join(dir, "extra-manifest.json"), "{}");
|
|
138
|
+
writeFileSync(join(dir, "webdav-backup-state.json"), "{}");
|
|
139
|
+
writeFileSync(join(dir, "webdav-backup.log"), "log");
|
|
140
|
+
writeFileSync(join(dir, "extra", "C", "Users", "me", ".pi", "settings.json"), "{}");
|
|
141
|
+
|
|
142
|
+
const files = listRestoredFiles(dir).map((f) => f.rel);
|
|
143
|
+
check("列出 settings.json", files.includes("settings.json"), files.join(","));
|
|
144
|
+
check("列出 npm/package.json", files.includes("npm/package.json"));
|
|
145
|
+
check("跳过 extra/ 下的数据", !files.some((f) => f.startsWith("extra/")), files.join(","));
|
|
146
|
+
check("跳过备份元数据", !files.includes(".pi-backup-manifest.json") && !files.includes("extra-manifest.json"));
|
|
147
|
+
check("跳过 state/log", !files.includes("webdav-backup-state.json") && !files.includes("webdav-backup.log"));
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── 5. applyRestoredFiles:写回、覆盖备份、幂等、dry-run
|
|
152
|
+
{
|
|
153
|
+
const src = mkdtempSync(join(tmpdir(), "pi-apply-src-"));
|
|
154
|
+
const dst = mkdtempSync(join(tmpdir(), "pi-apply-dst-"));
|
|
155
|
+
mkdirSync(join(src, "npm", "node_modules", "pi-footer"), { recursive: true });
|
|
156
|
+
writeFileSync(join(src, "settings.json"), JSON.stringify({ theme: "light" }));
|
|
157
|
+
writeFileSync(join(src, "npm", "node_modules", "pi-footer", "index.js"), "module.exports = 1;\n");
|
|
158
|
+
// 本机已有一个不同的 settings.json → 写回应覆盖并备份
|
|
159
|
+
writeFileSync(join(dst, "settings.json"), JSON.stringify({ theme: "dark" }));
|
|
160
|
+
|
|
161
|
+
// dry-run:不落盘
|
|
162
|
+
const dry = applyRestoredFiles(src, dst, { dryRun: true });
|
|
163
|
+
check("dry-run 报告待写入", dry.applied.length === 0 && dry.skipped.some((s) => s.reason === "dry-run"), JSON.stringify(dry));
|
|
164
|
+
check("dry-run 不修改本机文件", JSON.parse(readFileSync(join(dst, "settings.json"), "utf8")).theme === "dark");
|
|
165
|
+
|
|
166
|
+
// 真正写回
|
|
167
|
+
const r1 = applyRestoredFiles(src, dst);
|
|
168
|
+
check("写回成功", r1.ok === true, JSON.stringify(r1.failed));
|
|
169
|
+
check("插件代码已写入", existsSync(join(dst, "npm", "node_modules", "pi-footer", "index.js")));
|
|
170
|
+
check("settings.json 已更新", JSON.parse(readFileSync(join(dst, "settings.json"), "utf8")).theme === "light");
|
|
171
|
+
const baks = readdirSync(dst).filter((f) => f.startsWith("settings.json.bak-apply-"));
|
|
172
|
+
check("覆盖前已备份原文件", baks.length === 1, readdirSync(dst).join(","));
|
|
173
|
+
|
|
174
|
+
// 幂等:再写一次应全部 unchanged
|
|
175
|
+
const r2 = applyRestoredFiles(src, dst);
|
|
176
|
+
check("重复写回幂等(全部一致)", r2.applied.length === 0 && r2.unchanged.length === 2, JSON.stringify({ a: r2.applied, u: r2.unchanged }));
|
|
177
|
+
|
|
178
|
+
// 归档里是 __REDACTED__ 而本地有真实值 → 跳过
|
|
179
|
+
const src2 = mkdtempSync(join(tmpdir(), "pi-apply-src2-"));
|
|
180
|
+
writeFileSync(join(src2, "auth.json"), JSON.stringify({ openai: { key: "__REDACTED__" } }));
|
|
181
|
+
writeFileSync(join(dst, "auth.json"), JSON.stringify({ openai: { key: "sk-real-secret" } }));
|
|
182
|
+
const r3 = applyRestoredFiles(src2, dst);
|
|
183
|
+
check("脱敏占位不覆盖本地真实值", r3.skipped.some((s) => s.rel === "auth.json"), JSON.stringify(r3));
|
|
184
|
+
check("本地真实值保持不变", readFileSync(join(dst, "auth.json"), "utf8").includes("sk-real-secret"));
|
|
185
|
+
// force 才覆盖
|
|
186
|
+
const r4 = applyRestoredFiles(src2, dst, { force: true });
|
|
187
|
+
check("--force 才会用占位覆盖", r4.applied.some((a) => a.rel === "auth.json"), JSON.stringify(r4));
|
|
188
|
+
|
|
189
|
+
rmSync(src, { recursive: true, force: true });
|
|
190
|
+
rmSync(src2, { recursive: true, force: true });
|
|
191
|
+
rmSync(dst, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── 6. 拒绝目录穿越
|
|
195
|
+
{
|
|
196
|
+
const src = mkdtempSync(join(tmpdir(), "pi-apply-evil-"));
|
|
197
|
+
const dst = mkdtempSync(join(tmpdir(), "pi-apply-evil-dst-"));
|
|
198
|
+
// listRestoredFiles 不会产生 ..,但手工构造验证 safeJoin 的过滤
|
|
199
|
+
writeFileSync(join(src, "ok.txt"), "ok");
|
|
200
|
+
const r = applyRestoredFiles(src, dst);
|
|
201
|
+
check("正常文件写回", r.ok && existsSync(join(dst, "ok.txt")));
|
|
202
|
+
rmSync(src, { recursive: true, force: true });
|
|
203
|
+
rmSync(dst, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── 7. CLI 子进程:restore-apply 命令真实可用
|
|
207
|
+
{
|
|
208
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
209
|
+
const cli = join(here, "cli.mjs");
|
|
210
|
+
const agent = mkdtempSync(join(tmpdir(), "pi-apply-cli-agent-"));
|
|
211
|
+
const src = mkdtempSync(join(tmpdir(), "pi-apply-cli-src-"));
|
|
212
|
+
mkdirSync(join(src, "npm", "node_modules", "pi-footer"), { recursive: true });
|
|
213
|
+
mkdirSync(join(src, "extra", "home", "me", ".pi"), { recursive: true });
|
|
214
|
+
writeFileSync(join(src, "settings.json"), JSON.stringify({ theme: "light" }));
|
|
215
|
+
writeFileSync(join(src, "npm", "node_modules", "pi-footer", "index.js"), "module.exports=1;\n");
|
|
216
|
+
writeFileSync(join(src, ".pi-backup-manifest.json"), "{}");
|
|
217
|
+
writeFileSync(join(src, "extra", "home", "me", ".pi", "settings.json"), "{}");
|
|
218
|
+
writeFileSync(join(agent, "settings.json"), JSON.stringify({ theme: "dark" }));
|
|
219
|
+
writeFileSync(join(agent, "webdav-backup.json"), JSON.stringify({ remote: { url: "http://127.0.0.1:1/dav" } }));
|
|
220
|
+
|
|
221
|
+
const runCli = (args) =>
|
|
222
|
+
spawnSync(process.execPath, [cli, ...args], {
|
|
223
|
+
encoding: "utf8",
|
|
224
|
+
env: { ...process.env, PI_CODING_AGENT_DIR: agent },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const dry = runCli(["restore-apply", "--dir", src, "--dry-run", "--json"]);
|
|
228
|
+
const dryJson = JSON.parse(dry.stdout.trim().split("\n").pop());
|
|
229
|
+
check("CLI restore-apply --dry-run 成功", dry.status === 0 && dryJson.pluginReady === true, dry.stdout + dry.stderr);
|
|
230
|
+
|
|
231
|
+
const real = runCli(["restore-apply", "--dir", src, "--json"]);
|
|
232
|
+
const realJson = JSON.parse(real.stdout.trim().split("\n").pop());
|
|
233
|
+
check("CLI restore-apply 写回成功", real.status === 0 && realJson.ok === true, real.stdout + real.stderr);
|
|
234
|
+
check("CLI 写回更新 settings.json", JSON.parse(readFileSync(join(agent, "settings.json"), "utf8")).theme === "light");
|
|
235
|
+
check("CLI 写回插件代码", existsSync(join(agent, "npm", "node_modules", "pi-footer", "index.js")));
|
|
236
|
+
check("CLI 写回不碰 extra/ 与元数据", !existsSync(join(agent, "extra")) && !existsSync(join(agent, ".pi-backup-manifest.json")));
|
|
237
|
+
|
|
238
|
+
const idem = runCli(["restore-apply", "--dir", src, "--json"]);
|
|
239
|
+
const idemJson = JSON.parse(idem.stdout.trim().split("\n").pop());
|
|
240
|
+
check("CLI 重复写回幂等", idem.status === 0 && idemJson.applied.length === 0, JSON.stringify(idemJson));
|
|
241
|
+
|
|
242
|
+
rmSync(agent, { recursive: true, force: true });
|
|
243
|
+
rmSync(src, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── 7. 新电脑已装插件时的合并策略(重点)
|
|
247
|
+
{
|
|
248
|
+
// 旧机器(备份来源):P@1.0、无 Q
|
|
249
|
+
const backup = mkdtempSync(join(tmpdir(), "pi-merge-old-"));
|
|
250
|
+
mkdirSync(join(backup, "npm", "node_modules", "P"), { recursive: true });
|
|
251
|
+
writeFileSync(join(backup, "settings.json"), JSON.stringify({ theme: "dark", packages: ["npm:P@1.0.0"] }));
|
|
252
|
+
writeFileSync(join(backup, "npm", "package.json"), JSON.stringify({ name: "pi-extensions", private: true, dependencies: { P: "1.0.0" } }));
|
|
253
|
+
writeFileSync(join(backup, "npm", "node_modules", "P", "package.json"), JSON.stringify({ name: "P", version: "1.0.0" }));
|
|
254
|
+
writeFileSync(join(backup, "npm", "node_modules", "P", "index.js"), "// P 1.0\n");
|
|
255
|
+
writeFileSync(join(backup, "npm", "node_modules", "P", "old-only.js"), "// only 1.0\n");
|
|
256
|
+
|
|
257
|
+
// 新电脑:已装 P@2.0 与 Q
|
|
258
|
+
const live = mkdtempSync(join(tmpdir(), "pi-merge-new-"));
|
|
259
|
+
mkdirSync(join(live, "npm", "node_modules", "Q"), { recursive: true });
|
|
260
|
+
mkdirSync(join(live, "npm", "node_modules", "P"), { recursive: true });
|
|
261
|
+
writeFileSync(join(live, "settings.json"), JSON.stringify({ theme: "light", packages: ["npm:P@2.0.0", "npm:Q"] }));
|
|
262
|
+
writeFileSync(join(live, "npm", "package.json"), JSON.stringify({ name: "pi-extensions", private: true, dependencies: { P: "2.0.0", Q: "^1.0.0" } }));
|
|
263
|
+
writeFileSync(join(live, "npm", "node_modules", "P", "package.json"), JSON.stringify({ name: "P", version: "2.0.0" }));
|
|
264
|
+
writeFileSync(join(live, "npm", "node_modules", "P", "index.js"), "// P 2.0\n");
|
|
265
|
+
writeFileSync(join(live, "npm", "node_modules", "Q", "index.js"), "// Q\n");
|
|
266
|
+
|
|
267
|
+
// 辅助函数单测
|
|
268
|
+
check("pluginRootRel 解析 npm 包", pluginRootRel("npm/node_modules/P/index.js") === "npm/node_modules/P");
|
|
269
|
+
check("pluginRootRel 解析 @scope 包", pluginRootRel("npm/node_modules/@s/p/index.js") === "npm/node_modules/@s/p");
|
|
270
|
+
check("pluginRootRel 解析 git 仓", pluginRootRel("git/github.com/u/r/src/x.ts") === "git/github.com/u/r");
|
|
271
|
+
check("packageIdentity npm 忽略版本", packageIdentity("npm:P@1.0.0") === packageIdentity("npm:P@2.0.0"));
|
|
272
|
+
check("packageIdentity git 忽略 ref", packageIdentity("git:github.com/u/r@v1") === packageIdentity("git:github.com/u/r"));
|
|
273
|
+
|
|
274
|
+
// merge:保留本机 P@2.0、保留 Q、不注入 old-only
|
|
275
|
+
const merged = applyRestoredFiles(backup, live, { merge: true });
|
|
276
|
+
check("merge 成功", merged.ok === true, JSON.stringify(merged.failed));
|
|
277
|
+
check("merge 保留本机 P@2.0", JSON.parse(readFileSync(join(live, "npm", "node_modules", "P", "package.json"), "utf8")).version === "2.0.0");
|
|
278
|
+
check("merge 不混入旧版文件", !existsSync(join(live, "npm", "node_modules", "P", "old-only.js")));
|
|
279
|
+
check("merge 保留本机 P@2.0 声明", JSON.parse(readFileSync(join(live, "settings.json"), "utf8")).packages.includes("npm:P@2.0.0"));
|
|
280
|
+
check("merge 保留本机额外插件 Q 声明", JSON.parse(readFileSync(join(live, "settings.json"), "utf8")).packages.includes("npm:Q"));
|
|
281
|
+
check("merge 保留 Q 代码", existsSync(join(live, "npm", "node_modules", "Q", "index.js")));
|
|
282
|
+
check("merge 跳过本机已装插件文件", merged.skipped.some((s) => s.rel.startsWith("npm/node_modules/P/")), JSON.stringify(merged.skipped));
|
|
283
|
+
|
|
284
|
+
// replace:完全还原成旧机器(P@1.0、写回旧文件)
|
|
285
|
+
const live2 = mkdtempSync(join(tmpdir(), "pi-merge-new2-"));
|
|
286
|
+
mkdirSync(join(live2, "npm", "node_modules", "P"), { recursive: true });
|
|
287
|
+
writeFileSync(join(live2, "settings.json"), JSON.stringify({ theme: "light", packages: ["npm:P@2.0.0"] }));
|
|
288
|
+
writeFileSync(join(live2, "npm", "node_modules", "P", "package.json"), JSON.stringify({ name: "P", version: "2.0.0" }));
|
|
289
|
+
writeFileSync(join(live2, "npm", "node_modules", "P", "index.js"), "// P 2.0\n");
|
|
290
|
+
const replaced = applyRestoredFiles(backup, live2, { merge: false });
|
|
291
|
+
check("replace 成功", replaced.ok === true);
|
|
292
|
+
check("replace 降级为 P@1.0", JSON.parse(readFileSync(join(live2, "npm", "node_modules", "P", "package.json"), "utf8")).version === "1.0.0");
|
|
293
|
+
check("replace 声明变回 P@1.0", JSON.parse(readFileSync(join(live2, "settings.json"), "utf8")).packages.includes("npm:P@1.0.0"));
|
|
294
|
+
check("replace 写入了备份里的旧文件", existsSync(join(live2, "npm", "node_modules", "P", "old-only.js")));
|
|
295
|
+
|
|
296
|
+
rmSync(backup, { recursive: true, force: true });
|
|
297
|
+
rmSync(live, { recursive: true, force: true });
|
|
298
|
+
rmSync(live2, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── 8. mergeSettingsPackages:本机无声明时返回 null(用备份原样)
|
|
302
|
+
{
|
|
303
|
+
check("本机无 packages 声明时不合并", mergeSettingsPackages('{"packages":["npm:A"]}', '{"theme":"x"}') === null);
|
|
304
|
+
const m = mergeSettingsPackages('{"packages":["npm:A","npm:B"],"theme":"light"}', '{"packages":["npm:B@2"],"theme":"dark"}');
|
|
305
|
+
const o = JSON.parse(m);
|
|
306
|
+
check("合并保留本机声明优先、追加备份独有", JSON.stringify(o.packages) === JSON.stringify(["npm:B@2", "npm:A"]) && o.theme === "light", String(m));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
console.log(lines.join("\n"));
|
|
310
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`);
|
|
311
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
main().catch((e) => {
|
|
315
|
+
console.log(lines.join("\n"));
|
|
316
|
+
console.error(`\n测试异常:${e?.stack ?? e}`);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
});
|
|
@@ -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
|
+
});
|