pi-web-ui 0.36.0 → 0.44.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.
@@ -0,0 +1,226 @@
1
+ /**
2
+ * 插件更新辅助(备份/回滚 + 远端 sha 对比)——纯逻辑,供 CLI(bin/pi-web-ui.mjs)
3
+ * 与单测共用。零网络依赖:远端 sha 经注入的 exec 获取(生产 = git ls-remote;
4
+ * 测试 = fake exec 或本地 git 仓库路径,git ls-remote 支持本地仓库,完全离线)。
5
+ *
6
+ * 布局:
7
+ * <dataDir>/plugins/<id>/ 安装本体(含 .pi-source.json + .pi-git-sha)
8
+ * <dataDir>/plugin-backups/<id>-<ts>/ 覆盖安装前的旧版本快照(保留最近 N 份)
9
+ */
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, cpSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { execFile } from "node:child_process";
13
+ const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
14
+ /** 保留的备份份数(超出删除最旧的)。 */
15
+ export const BACKUP_KEEP = 3;
16
+ /** 默认执行器:execFile 直跑 git(不经 shell),15s 超时。 */
17
+ export const execGit = (cmd, args) => new Promise((resolve) => {
18
+ execFile(cmd, args, { timeout: 15_000, encoding: "utf8" }, (err, stdout, stderr) => {
19
+ if (err)
20
+ resolve({ ok: false, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
21
+ else
22
+ resolve({ ok: true, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
23
+ });
24
+ });
25
+ /**
26
+ * 覆盖安装前备份旧插件目录 → <dataDir>/plugin-backups/<id>-<ts>/。
27
+ * 目标不存在/备份失败返回 null(调用方可继续——备份是尽力而为的保护)。
28
+ */
29
+ export function ensureBackup(dataDir, id, opts) {
30
+ if (!PLUGIN_ID_RE.test(id))
31
+ return null;
32
+ const target = join(dataDir, "plugins", id);
33
+ if (!existsSync(target))
34
+ return null;
35
+ const ts = stamp();
36
+ const dest = join(dataDir, "plugin-backups", `${id}-${ts}`);
37
+ try {
38
+ mkdirSync(dirnameOf(dest), { recursive: true });
39
+ cpSync(target, dest, {
40
+ recursive: true,
41
+ // 与安装一致:不备份 .git/node_modules(纯运行目录),config.json 等保留。
42
+ filter: (s) => !/(^|[\\/])(\.git|node_modules)([\\/]|$)/.test(s),
43
+ });
44
+ writeFileSync(join(dest, ".pi-backup.json"), JSON.stringify({ id, ts, source: opts?.source }, null, 2) + "\n");
45
+ pruneBackups(dataDir, id);
46
+ return ts;
47
+ }
48
+ catch (err) {
49
+ try {
50
+ rmSync(dest, { recursive: true, force: true });
51
+ }
52
+ catch {
53
+ /* 清理失败忽略 */
54
+ }
55
+ console.warn(`[plugin-updater] 备份 ${id} 失败:`, err instanceof Error ? err.message : err);
56
+ return null;
57
+ }
58
+ }
59
+ /** 该插件的备份目录列表(按时间从新到旧)。 */
60
+ export function listBackups(dataDir, id) {
61
+ if (!PLUGIN_ID_RE.test(id))
62
+ return [];
63
+ const dir = join(dataDir, "plugin-backups");
64
+ let names = [];
65
+ try {
66
+ names = readdirSync(dir);
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ const re = new RegExp(`^${id.replace(/[^A-Za-z0-9_-]/g, "")}-(\\d{8}-\\d{9})$`);
72
+ return names
73
+ .filter((n) => re.test(n) && existsSync(join(dir, n, ".pi-backup.json")))
74
+ .sort()
75
+ .reverse();
76
+ }
77
+ /**
78
+ * 回滚到最近一份备份:删当前 plugins/<id> → 拷贝备份回 → 删备份。
79
+ * 返回最近备份 ts;无备份返回 null。
80
+ */
81
+ export function restoreBackup(dataDir, id) {
82
+ const backups = listBackups(dataDir, id);
83
+ if (backups.length === 0)
84
+ return null;
85
+ const src = join(dataDir, "plugin-backups", backups[0]);
86
+ const target = join(dataDir, "plugins", id);
87
+ try {
88
+ if (existsSync(target))
89
+ rmSync(target, { recursive: true, force: true });
90
+ mkdirSync(join(dataDir, "plugins"), { recursive: true });
91
+ cpSync(src, target, { recursive: true });
92
+ rmSync(src, { recursive: true, force: true });
93
+ return backups[0];
94
+ }
95
+ catch (err) {
96
+ console.warn(`[plugin-updater] 回滚 ${id} 失败:`, err instanceof Error ? err.message : err);
97
+ return null;
98
+ }
99
+ }
100
+ /** 保留最近 BACKUP_KEEP 份,删除更旧的。 */
101
+ export function pruneBackups(dataDir, id, keep = BACKUP_KEEP) {
102
+ const backups = listBackups(dataDir, id);
103
+ for (const b of backups.slice(keep)) {
104
+ try {
105
+ rmSync(join(dataDir, "plugin-backups", b), { recursive: true, force: true });
106
+ }
107
+ catch {
108
+ /* 忽略 */
109
+ }
110
+ }
111
+ }
112
+ /**
113
+ * 解析一个安装源并取远端 HEAD sha(前 12 位)。
114
+ * - 本地 git 仓库路径 / file:// → git ls-remote <path> HEAD(离线)
115
+ * - GitHub owner/repo、URL → git ls-remote https://github.com/o/r.git HEAD
116
+ * - 无法识别 / git 不在 / 网络失败 → null(调用方标记「无法检查」)
117
+ */
118
+ export async function resolveRemoteSha(spec, exec = execGit) {
119
+ const clean = String(spec ?? "").trim();
120
+ if (!clean)
121
+ return null;
122
+ let remote = null;
123
+ if (existsSync(clean)) {
124
+ remote = clean; // 本地 git 仓库路径
125
+ }
126
+ else if (/^file:\/\//i.test(clean)) {
127
+ remote = clean.slice("file://".length);
128
+ }
129
+ else {
130
+ // GitHub 形态(owner/repo、URL、git@)
131
+ let s = clean.replace(/^git@([^:]+):/, "");
132
+ const m = s.match(/^https?:\/\/(?:www\.)?github\.com\/(.+?)(?:\.git)?\/?$/i);
133
+ if (m)
134
+ [, s] = m;
135
+ s = s.split("#")[0]; // 去掉 #分支
136
+ const segs = s.split("/").filter(Boolean);
137
+ if (segs.length < 2)
138
+ return null;
139
+ // 只取 owner/repo(/tree/<ref>/<subpath> 等后缀不影响远端 sha)
140
+ const repo = segs[0] + "/" + segs[1].replace(/\.git$/, "");
141
+ remote = `https://github.com/${repo}.git`;
142
+ }
143
+ if (!remote)
144
+ return null;
145
+ const res = await exec("git", ["ls-remote", remote, "HEAD"]);
146
+ if (!res.ok)
147
+ return null;
148
+ // 行格式: <sha>\tHEAD(可能多行——取第一行)
149
+ const sha = (res.stdout.match(/^([0-9a-f]{40,64})\s+HEAD/m)?.[1]) ?? null;
150
+ return sha ? sha.slice(0, 12) : null;
151
+ }
152
+ /** 扫描全部已装插件,对比本地 sha 与远端 sha,报告更新状态。 */
153
+ export async function checkPluginUpdates(dataDir, exec = execGit) {
154
+ const pluginsDir = join(dataDir, "plugins");
155
+ let names = [];
156
+ try {
157
+ names = readdirSync(pluginsDir).sort();
158
+ }
159
+ catch {
160
+ return [];
161
+ }
162
+ const out = [];
163
+ for (const n of names) {
164
+ if (!PLUGIN_ID_RE.test(n))
165
+ continue;
166
+ const dir = join(pluginsDir, n);
167
+ try {
168
+ const sourceJson = readFileSync(join(dir, ".pi-source.json"), "utf8");
169
+ const { source } = JSON.parse(sourceJson);
170
+ if (!source)
171
+ continue; // 无来源记录(手工拷入)→ skip
172
+ let localSha = null;
173
+ try {
174
+ localSha = readFileSync(join(dir, ".pi-git-sha"), "utf8").trim() || null;
175
+ }
176
+ catch {
177
+ localSha = null; // 无 sha 记录 → 保守认为可更新(不知道装了哪个版本)
178
+ }
179
+ let remoteSha = null;
180
+ let error;
181
+ try {
182
+ remoteSha = await resolveRemoteSha(source, exec);
183
+ }
184
+ catch (err) {
185
+ error = err instanceof Error ? err.message : String(err);
186
+ remoteSha = null;
187
+ }
188
+ if (!remoteSha && !error)
189
+ error = "无法检查(非 git 源或 git 不可用)";
190
+ let name;
191
+ let version;
192
+ try {
193
+ const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
194
+ name = m.name;
195
+ version = m.version;
196
+ }
197
+ catch {
198
+ /* 坏 manifest:仍报告 */
199
+ }
200
+ const updatable = !!remoteSha && (!localSha || localSha !== remoteSha);
201
+ out.push({
202
+ id: n,
203
+ name,
204
+ version,
205
+ source,
206
+ localSha,
207
+ remoteSha,
208
+ updatable,
209
+ error,
210
+ });
211
+ }
212
+ catch {
213
+ continue; // 坏目录跳过
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+ function stamp() {
219
+ const d = new Date();
220
+ const p = (x, n = 2) => String(x).padStart(n, "0");
221
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}${p(d.getMilliseconds(), 3)}`;
222
+ }
223
+ function dirnameOf(p) {
224
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
225
+ return i >= 0 ? p.slice(0, i) : null;
226
+ }