dsh-plugin-pack 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +537 -3
- package/bin/dsh-plugin-pack.js +200 -0
- package/client.js +2320 -0
- package/cordis.patch.yml +24 -0
- package/hotload.js +230 -0
- package/lib/export.js +444 -0
- package/lib/home.js +75 -0
- package/lib/import-plugin.js +356 -0
- package/lib/import-tgz.js +115 -0
- package/lib/import.js +831 -0
- package/lib/inbox.js +194 -0
- package/lib/index.js +725 -0
- package/lib/jobs.js +208 -0
- package/lib/patch-text.js +672 -0
- package/lib/scan.js +488 -0
- package/lib/server.js +926 -0
- package/lib/tar.js +241 -0
- package/lib/util.js +333 -0
- package/lib/zip-stream.js +166 -0
- package/lib/zip.js +514 -0
- package/package.json +72 -6
package/lib/server.js
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主 HTTP 桥接:给客户端界面提供 /plugin-pack/* 接口。
|
|
3
|
+
*
|
|
4
|
+
* 客户端(桌面端或 Web GUI)通过 fetch 调这些路由,界面只负责展示与触发,
|
|
5
|
+
* 真正的文件操作全部在宿主侧完成(客户端拿不到文件系统)。
|
|
6
|
+
*
|
|
7
|
+
* 路由:
|
|
8
|
+
* GET /plugin-pack/status 当前 profile、插件清单、备份列表、配置
|
|
9
|
+
* POST /plugin-pack/export 导出(body: { out?, include?, exclude?, includeData?, includeSecrets?, dryRun? })
|
|
10
|
+
* POST /plugin-pack/inspect 读包(body: { file })
|
|
11
|
+
* POST /plugin-pack/import 导入(body: { file, dryRun?, mode?, restoreData?, linkNodeModules?, only? })
|
|
12
|
+
* POST /plugin-pack/rollback 回滚(body: { backup? })
|
|
13
|
+
* POST /plugin-pack/browse 列目录,给界面做文件选择器(body: { dir })
|
|
14
|
+
* POST /plugin-pack/hotload 热加载某个插件的客户端界面(body: { package?, dir? })
|
|
15
|
+
* GET /plugin-pack/client 查客户端图表状态(?package=)
|
|
16
|
+
* GET /plugin-pack/config 读插件配置
|
|
17
|
+
* POST /plugin-pack/config 写插件配置
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-plugin-pack/server
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { scanProfile } from './scan.js';
|
|
26
|
+
import { exportBundle } from './export.js';
|
|
27
|
+
import { importBundle, inspectBundle, rollbackImport, listBackups } from './import.js';
|
|
28
|
+
import { reloadClientPackage, clientStatus, localVerdict } from '../hotload.js';
|
|
29
|
+
import { importLocalPlugin } from './import-plugin.js';
|
|
30
|
+
import { importTarball, isTarball } from './import-tgz.js';
|
|
31
|
+
import { parseMultipart, boundaryOf, saveIncomingZip, saveIncomingDir, pruneInbox } from './inbox.js';
|
|
32
|
+
import { runJob, listJobs, getJob, runningJobs, clearFinished } from './jobs.js';
|
|
33
|
+
import { dshHome as resolveDshHome, profileDir as resolveProfileDir, profileName as resolveProfileName, humanBytes } from './home.js';
|
|
34
|
+
import { readJson, writeJson, isDir, isFile } from './util.js';
|
|
35
|
+
|
|
36
|
+
const PREFIX = '/plugin-pack';
|
|
37
|
+
|
|
38
|
+
/** 读 JSON body,带体积上限。 */
|
|
39
|
+
async function readJsonBody(request, maxBytes = 1024 * 1024) {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
for await (const chunk of request) {
|
|
43
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
44
|
+
size += buffer.length;
|
|
45
|
+
if (size > maxBytes) {
|
|
46
|
+
const error = new Error(`请求体过大: ${size} > ${maxBytes}`);
|
|
47
|
+
error.status = 413;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
chunks.push(buffer);
|
|
51
|
+
}
|
|
52
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
53
|
+
if (!raw) return {};
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
57
|
+
const error = new Error('请求体必须是 JSON 对象');
|
|
58
|
+
error.status = 400;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error.status) throw error;
|
|
64
|
+
const wrapped = new Error(`JSON 解析失败: ${error?.message ?? error}`);
|
|
65
|
+
wrapped.status = 400;
|
|
66
|
+
throw wrapped;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 统一 JSON 响应。 */
|
|
71
|
+
function sendJson(response, status, value) {
|
|
72
|
+
const body = JSON.stringify(value);
|
|
73
|
+
response.writeHead(status, {
|
|
74
|
+
'content-type': 'application/json; charset=utf-8',
|
|
75
|
+
'cache-control': 'no-store',
|
|
76
|
+
'content-length': Buffer.byteLength(body),
|
|
77
|
+
});
|
|
78
|
+
response.end(body);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 把异常翻译成 HTTP 状态;其余仍是 500。 */
|
|
82
|
+
function statusOf(error) {
|
|
83
|
+
const explicit = Number(error?.status ?? error?.statusCode);
|
|
84
|
+
if (Number.isFinite(explicit) && explicit >= 400 && explicit < 600) return explicit;
|
|
85
|
+
const code = String(error?.code ?? '');
|
|
86
|
+
if (code === 'NOT_FOUND') return 404;
|
|
87
|
+
if (
|
|
88
|
+
code === 'BAD_PACK' ||
|
|
89
|
+
code === 'BAD_MANIFEST' ||
|
|
90
|
+
code === 'BAD_FORMAT' ||
|
|
91
|
+
code === 'BAD_BACKUP' ||
|
|
92
|
+
// "这不是整合包" 归为 400:是用户拖错文件,不是服务端故障
|
|
93
|
+
code === 'NOT_A_ZIP' ||
|
|
94
|
+
code === 'NOT_A_BUNDLE' ||
|
|
95
|
+
code === 'BAD_PLUGIN'
|
|
96
|
+
) {
|
|
97
|
+
return 400;
|
|
98
|
+
}
|
|
99
|
+
if (code === 'TOO_LARGE') return 413;
|
|
100
|
+
if (code === 'ALREADY_DONE' || code === 'EMPTY_SELECTION' || code === 'NO_PLUGINS') return 409;
|
|
101
|
+
return 500;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 同源校验:拒绝跨站请求,避免网页面板被别人利用。 */
|
|
105
|
+
function sameOrigin(request) {
|
|
106
|
+
const site = String(request?.headers?.['sec-fetch-site'] ?? '').toLowerCase();
|
|
107
|
+
if (site === 'cross-site') return false;
|
|
108
|
+
const origin = String(request?.headers?.origin ?? '').trim();
|
|
109
|
+
if (!origin || origin === 'null') return origin === '' ? true : false;
|
|
110
|
+
try {
|
|
111
|
+
const parsed = new URL(origin);
|
|
112
|
+
const host = String(request?.headers?.host ?? '');
|
|
113
|
+
return parsed.host === host;
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 当前环境上下文。 */
|
|
120
|
+
function contextOf(config) {
|
|
121
|
+
return {
|
|
122
|
+
home: resolveDshHome(),
|
|
123
|
+
profileDir: resolveProfileDir(),
|
|
124
|
+
profileName: resolveProfileName(),
|
|
125
|
+
config,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 按包名找包目录。 */
|
|
130
|
+
function packageDirOf(packageName, local) {
|
|
131
|
+
const segments = String(packageName).split('/');
|
|
132
|
+
const candidates = [
|
|
133
|
+
path.join(local.profileDir, 'node_modules', ...segments),
|
|
134
|
+
path.join(local.profileDir, 'local-plugins', ...segments),
|
|
135
|
+
];
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
try {
|
|
138
|
+
if (isFile(path.join(candidate, 'package.json'))) return candidate;
|
|
139
|
+
} catch {
|
|
140
|
+
/* 下一个 */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 插件自己的偏好文件(写在 $DSH_HOME 下,跨 profile 共享)。 */
|
|
147
|
+
function settingsFile(home) {
|
|
148
|
+
return path.join(home, 'plugin-pack', 'settings.json');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 读插件偏好。 */
|
|
152
|
+
export function readSettings(home = resolveDshHome()) {
|
|
153
|
+
return readJson(settingsFile(home), {}) ?? {};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 写插件偏好。 */
|
|
157
|
+
export function writeSettings(home, patch) {
|
|
158
|
+
const next = { ...readSettings(home), ...(patch ?? {}) };
|
|
159
|
+
writeJson(settingsFile(home), next);
|
|
160
|
+
return next;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 生成状态快照(界面首屏就靠这一个接口)。
|
|
165
|
+
*
|
|
166
|
+
* @param {object} [config]
|
|
167
|
+
* @returns {Promise<object>}
|
|
168
|
+
*/
|
|
169
|
+
export async function snapshot(config = {}) {
|
|
170
|
+
const ctx = contextOf(config);
|
|
171
|
+
const scan = await scanProfile({
|
|
172
|
+
includeData: Boolean(config.includeData),
|
|
173
|
+
includeSecrets: Boolean(config.includeSecrets),
|
|
174
|
+
});
|
|
175
|
+
const backups = listBackups(ctx.profileDir);
|
|
176
|
+
const settings = readSettings(ctx.home);
|
|
177
|
+
const exportsDir = config.exportDir || path.join(ctx.home, 'exports');
|
|
178
|
+
let recent = [];
|
|
179
|
+
try {
|
|
180
|
+
recent = fs
|
|
181
|
+
.readdirSync(exportsDir, { withFileTypes: true })
|
|
182
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.zip'))
|
|
183
|
+
.map((entry) => {
|
|
184
|
+
const full = path.join(exportsDir, entry.name);
|
|
185
|
+
let size = 0;
|
|
186
|
+
let mtime = 0;
|
|
187
|
+
try {
|
|
188
|
+
const stat = fs.statSync(full);
|
|
189
|
+
size = stat.size;
|
|
190
|
+
mtime = stat.mtimeMs;
|
|
191
|
+
} catch {
|
|
192
|
+
/* 读不到就显示 0 */
|
|
193
|
+
}
|
|
194
|
+
return { name: entry.name, path: full, bytes: size, mtime };
|
|
195
|
+
})
|
|
196
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
197
|
+
.slice(0, 20);
|
|
198
|
+
} catch {
|
|
199
|
+
recent = [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
profile: {
|
|
205
|
+
name: scan.profile.name,
|
|
206
|
+
dir: scan.profile.dir,
|
|
207
|
+
dshHome: scan.profile.dshHome,
|
|
208
|
+
patchFile: scan.profile.patchFile,
|
|
209
|
+
},
|
|
210
|
+
exportsDir,
|
|
211
|
+
plugins: scan.plugins.map((plugin) => ({
|
|
212
|
+
name: plugin.name,
|
|
213
|
+
version: plugin.version,
|
|
214
|
+
description: plugin.description,
|
|
215
|
+
kind: plugin.kind,
|
|
216
|
+
inBundles: plugin.inBundles,
|
|
217
|
+
sources: plugin.sources,
|
|
218
|
+
deps: plugin.deps.length,
|
|
219
|
+
depBytes: plugin.deps.reduce((sum, dep) => sum + dep.bytes, 0),
|
|
220
|
+
patchRows: plugin.patchRows.length,
|
|
221
|
+
insertRows: plugin.insertRows.length,
|
|
222
|
+
dataCandidates: plugin.dataCandidates,
|
|
223
|
+
bytes: plugin.bytes,
|
|
224
|
+
files: plugin.fileCount,
|
|
225
|
+
bytesText: humanBytes(plugin.bytes),
|
|
226
|
+
warnings: plugin.warnings,
|
|
227
|
+
skipped: plugin.skipped ?? null,
|
|
228
|
+
entry: plugin.entry,
|
|
229
|
+
hasPatch: plugin.hasPatch,
|
|
230
|
+
})),
|
|
231
|
+
totals: {
|
|
232
|
+
plugins: scan.selected.length,
|
|
233
|
+
bytes: scan.totals.bytes,
|
|
234
|
+
files: scan.totals.files,
|
|
235
|
+
bytesText: humanBytes(scan.totals.bytes),
|
|
236
|
+
},
|
|
237
|
+
backups: backups.map((item) => ({
|
|
238
|
+
name: item.name,
|
|
239
|
+
createdAt: item.createdAt,
|
|
240
|
+
source: item.source,
|
|
241
|
+
plugins: item.plugins,
|
|
242
|
+
})),
|
|
243
|
+
recentExports: recent,
|
|
244
|
+
settings: {
|
|
245
|
+
exportDir: exportsDir,
|
|
246
|
+
includeData: Boolean(config.includeData),
|
|
247
|
+
includeSecrets: Boolean(config.includeSecrets),
|
|
248
|
+
maxBytes: config.maxBytes ?? 512 * 1024 * 1024,
|
|
249
|
+
defaultOut: settings.defaultOut ?? '',
|
|
250
|
+
...settings,
|
|
251
|
+
},
|
|
252
|
+
warnings: scan.warnings,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 列目录(界面上的文件选择器用)。
|
|
258
|
+
*
|
|
259
|
+
* 比普通选择器多做三件事,都是为了让"找路径"不那么费劲:
|
|
260
|
+
* - 返回面包屑(ancestors),点哪一级跳哪一级,不用一级一级往上按
|
|
261
|
+
* - 返回盘符根(roots),Windows 上能直接从 C:\ D:\ 起步
|
|
262
|
+
* - 同时返回子目录和 .zip,一次目录走完(mode 参数只是排序偏好)
|
|
263
|
+
*
|
|
264
|
+
* 关于"不存在的目录":
|
|
265
|
+
* 默认**不创建**、直接报错——否则用户粘错一个路径,磁盘上会多出一堆空目录。
|
|
266
|
+
* 只有 `create: true`(导出目标目录)才创建。
|
|
267
|
+
*
|
|
268
|
+
* @param {string} [dir]
|
|
269
|
+
* @param {{ create?: boolean }} [options]
|
|
270
|
+
* @returns {object}
|
|
271
|
+
*/
|
|
272
|
+
export function browse(dir, options = {}) {
|
|
273
|
+
const create = Boolean(options.create);
|
|
274
|
+
const roots = listRoots();
|
|
275
|
+
let target = dir && String(dir).trim() ? path.resolve(String(dir).trim()) : roots[0] ?? process.cwd();
|
|
276
|
+
|
|
277
|
+
// 用户可能直接粘了一个文件路径:自动跳到它的目录,并把该文件选中
|
|
278
|
+
let selectFile = null;
|
|
279
|
+
try {
|
|
280
|
+
const stat = fs.statSync(target);
|
|
281
|
+
if (stat.isFile()) {
|
|
282
|
+
selectFile = target;
|
|
283
|
+
target = path.dirname(target);
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
/* 不存在,下面再处理 */
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!isDir(target)) {
|
|
290
|
+
if (!create) {
|
|
291
|
+
const error = new Error(`路径不存在: ${target}`);
|
|
292
|
+
error.code = 'NOT_FOUND';
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
fs.mkdirSync(target, { recursive: true });
|
|
297
|
+
} catch {
|
|
298
|
+
const error = new Error(`目录不存在且无法创建: ${target}`);
|
|
299
|
+
error.code = 'NOT_FOUND';
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 面包屑:从盘符根一路到当前目录
|
|
305
|
+
const ancestors = [];
|
|
306
|
+
{
|
|
307
|
+
let cursor = target;
|
|
308
|
+
for (let i = 0; i < 64; i += 1) {
|
|
309
|
+
ancestors.unshift({ name: path.basename(cursor) || cursor, path: cursor });
|
|
310
|
+
const up = path.dirname(cursor);
|
|
311
|
+
if (up === cursor) break;
|
|
312
|
+
cursor = up;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// 大小写统一:面包屑与 dir 都换成真实大小写,否则界面上会一段大写一段小写
|
|
316
|
+
const realDir = realOrSelf(target);
|
|
317
|
+
for (const crumb of ancestors) crumb.path = realOrSelf(crumb.path);
|
|
318
|
+
|
|
319
|
+
const items = [];
|
|
320
|
+
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
|
|
321
|
+
if (entry.name.startsWith('.')) continue;
|
|
322
|
+
const full = path.join(target, entry.name);
|
|
323
|
+
let isDirectory = entry.isDirectory();
|
|
324
|
+
let size = 0;
|
|
325
|
+
if (entry.isSymbolicLink()) {
|
|
326
|
+
try {
|
|
327
|
+
const stat = fs.statSync(full);
|
|
328
|
+
isDirectory = stat.isDirectory();
|
|
329
|
+
size = stat.size;
|
|
330
|
+
} catch {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
} else if (entry.isFile()) {
|
|
334
|
+
try {
|
|
335
|
+
size = fs.statSync(full).size;
|
|
336
|
+
} catch {
|
|
337
|
+
size = 0;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (isDirectory) {
|
|
341
|
+
items.push({ name: entry.name, path: full, dir: true, size: 0 });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
// 目录里只列 .zip 和其他可能有用的单文件,避免一屏几百个无关文件
|
|
345
|
+
const lower = entry.name.toLowerCase();
|
|
346
|
+
if (lower.endsWith('.zip') || lower.endsWith('.tgz') || lower.endsWith('.tar.gz')) {
|
|
347
|
+
items.push({ name: entry.name, path: full, dir: false, size });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
items.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1));
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
// 传真实大小写:用户粘 'D:\TEMP\X' 时,界面显示和后续比较都应该用它实际的名字
|
|
355
|
+
dir: realDir,
|
|
356
|
+
parent: path.dirname(target) === target ? null : realOrSelf(path.dirname(target)),
|
|
357
|
+
ancestors,
|
|
358
|
+
roots,
|
|
359
|
+
items,
|
|
360
|
+
selectFile,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 取真实大小写的路径;失败时退回原值(大写在 Windows 上不敏感,但显示要一致)。 */
|
|
365
|
+
function realOrSelf(target) {
|
|
366
|
+
try {
|
|
367
|
+
return fs.realpathSync.native(target);
|
|
368
|
+
} catch {
|
|
369
|
+
try {
|
|
370
|
+
return fs.realpathSync(target);
|
|
371
|
+
} catch {
|
|
372
|
+
return target;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 可用的盘符 / 根目录(Windows 上遍历 A-Z,其他平台返回 / 与 home)。 */
|
|
378
|
+
export function listRoots() {
|
|
379
|
+
if (process.platform !== 'win32') {
|
|
380
|
+
const list = [{ name: '/', path: '/' }];
|
|
381
|
+
try {
|
|
382
|
+
list.push({ name: 'home', path: os.homedir() });
|
|
383
|
+
} catch {
|
|
384
|
+
/* 忽略 */
|
|
385
|
+
}
|
|
386
|
+
return list;
|
|
387
|
+
}
|
|
388
|
+
const roots = [];
|
|
389
|
+
for (let code = 65; code <= 90; code += 1) {
|
|
390
|
+
const drive = `${String.fromCharCode(code)}:\\`;
|
|
391
|
+
try {
|
|
392
|
+
fs.accessSync(drive);
|
|
393
|
+
roots.push({ name: drive, path: drive });
|
|
394
|
+
} catch {
|
|
395
|
+
/* 该盘不存在 */
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return roots;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 把路由挂到宿主的 webServer 上。
|
|
403
|
+
*
|
|
404
|
+
* @param {object} ctx cordis 上下文(需要 webServer 服务)
|
|
405
|
+
* @param {object} config 插件配置
|
|
406
|
+
*/
|
|
407
|
+
export function mountRoutes(ctx, config = {}) {
|
|
408
|
+
ctx.inject(['webServer'], (host) => {
|
|
409
|
+
host.effect(() => {
|
|
410
|
+
const disposers = [];
|
|
411
|
+
const register = (spec, label) => {
|
|
412
|
+
const dispose = host.webServer.register(
|
|
413
|
+
{
|
|
414
|
+
...spec,
|
|
415
|
+
handler: async (request, response) => {
|
|
416
|
+
if (!sameOrigin(request)) {
|
|
417
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin blocked' });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
return await spec.handler(request, response);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
sendJson(response, statusOf(error), { ok: false, error: String(error?.message ?? error), code: error?.code });
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
label,
|
|
428
|
+
);
|
|
429
|
+
if (typeof dispose === 'function') disposers.push(dispose);
|
|
430
|
+
return dispose;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
register(
|
|
434
|
+
{
|
|
435
|
+
kind: 'exact',
|
|
436
|
+
path: `${PREFIX}/status`,
|
|
437
|
+
handler: async (request, response) => {
|
|
438
|
+
if (request.method !== 'GET') {
|
|
439
|
+
sendJson(response, 405, { ok: false, error: '只支持 GET' });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
sendJson(response, 200, await snapshot(config));
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
'plugin-pack: status',
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
register(
|
|
449
|
+
{
|
|
450
|
+
kind: 'exact',
|
|
451
|
+
path: `${PREFIX}/export`,
|
|
452
|
+
handler: async (request, response) => {
|
|
453
|
+
if (request.method !== 'POST') {
|
|
454
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const body = await readJsonBody(request);
|
|
458
|
+
const ctx2 = contextOf(config);
|
|
459
|
+
const dryRun = Boolean(body.dryRun);
|
|
460
|
+
const include = Array.isArray(body.include) && body.include.length ? body.include : null;
|
|
461
|
+
|
|
462
|
+
const doExport = (report) =>
|
|
463
|
+
exportBundle({
|
|
464
|
+
out: body.out || config.exportDir || path.join(ctx2.home, 'exports'),
|
|
465
|
+
include,
|
|
466
|
+
exclude: Array.isArray(body.exclude) ? body.exclude : [],
|
|
467
|
+
includeData: Boolean(body.includeData) || Boolean(config.includeData),
|
|
468
|
+
includeSecrets: Boolean(body.includeSecrets) || Boolean(config.includeSecrets),
|
|
469
|
+
maxBytes: Number(body.maxBytes) || config.maxBytes,
|
|
470
|
+
dryRun,
|
|
471
|
+
onProgress: report,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// dryRun 很快,不当后台任务;真实导出登记成任务,切走画面也能查结果
|
|
475
|
+
if (dryRun) {
|
|
476
|
+
const result = await doExport(() => {});
|
|
477
|
+
sendJson(response, 200, {
|
|
478
|
+
ok: true,
|
|
479
|
+
file: result.file,
|
|
480
|
+
bytes: result.bytes,
|
|
481
|
+
rawBytes: result.rawBytes,
|
|
482
|
+
entries: result.entries,
|
|
483
|
+
dryRun: true,
|
|
484
|
+
plugins: result.plugins,
|
|
485
|
+
warnings: result.warnings,
|
|
486
|
+
});
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const title = include
|
|
491
|
+
? `导出 ${include.length} 个插件`
|
|
492
|
+
: `导出全部插件`;
|
|
493
|
+
const { id, result } = await runJob({
|
|
494
|
+
kind: 'export',
|
|
495
|
+
title,
|
|
496
|
+
meta: { include, out: body.out ?? null },
|
|
497
|
+
task: (report) => doExport(report),
|
|
498
|
+
});
|
|
499
|
+
sendJson(response, 200, {
|
|
500
|
+
ok: true,
|
|
501
|
+
jobId: id,
|
|
502
|
+
file: result.file,
|
|
503
|
+
bytes: result.bytes,
|
|
504
|
+
rawBytes: result.rawBytes,
|
|
505
|
+
entries: result.entries,
|
|
506
|
+
kind: result.kind,
|
|
507
|
+
dryRun: false,
|
|
508
|
+
plugins: result.plugins,
|
|
509
|
+
warnings: result.warnings,
|
|
510
|
+
});
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
'plugin-pack: export',
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
register(
|
|
517
|
+
{
|
|
518
|
+
kind: 'exact',
|
|
519
|
+
path: `${PREFIX}/inspect`,
|
|
520
|
+
handler: async (request, response) => {
|
|
521
|
+
if (request.method !== 'POST') {
|
|
522
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const body = await readJsonBody(request);
|
|
526
|
+
if (!body.file) {
|
|
527
|
+
sendJson(response, 400, { ok: false, error: '需要 file' });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const info = inspectBundle(body.file);
|
|
531
|
+
sendJson(response, 200, {
|
|
532
|
+
ok: true,
|
|
533
|
+
file: info.file,
|
|
534
|
+
bytes: info.bytes,
|
|
535
|
+
createdAt: info.createdAt,
|
|
536
|
+
source: info.source,
|
|
537
|
+
entryCount: info.entryCount,
|
|
538
|
+
plugins: info.plugins.map(({ manifest, ...rest }) => rest),
|
|
539
|
+
warnings: info.warnings,
|
|
540
|
+
});
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
'plugin-pack: inspect',
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
register(
|
|
547
|
+
{
|
|
548
|
+
kind: 'exact',
|
|
549
|
+
path: `${PREFIX}/import`,
|
|
550
|
+
handler: async (request, response) => {
|
|
551
|
+
if (request.method !== 'POST') {
|
|
552
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const body = await readJsonBody(request);
|
|
556
|
+
if (!body.file) {
|
|
557
|
+
sendJson(response, 400, { ok: false, error: '需要 file' });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
// 按扩展名分流:.tgz/.tar.gz 是单插件的 npm tarball,其余按整合包 zip 处理
|
|
561
|
+
if (isTarball(body.file)) {
|
|
562
|
+
const local = contextOf(config);
|
|
563
|
+
const dryRun = Boolean(body.dryRun);
|
|
564
|
+
const doImport = (report) =>
|
|
565
|
+
importTarball({
|
|
566
|
+
file: body.file,
|
|
567
|
+
home: local.home,
|
|
568
|
+
profileDir: local.profileDir,
|
|
569
|
+
dryRun,
|
|
570
|
+
mode: body.mode === 'skip' ? 'skip' : 'merge',
|
|
571
|
+
decisions: body.decisions && typeof body.decisions === 'object' ? body.decisions : {},
|
|
572
|
+
onProgress: report,
|
|
573
|
+
});
|
|
574
|
+
if (dryRun) {
|
|
575
|
+
sendJson(response, 200, doImport(() => {}));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const { id, result } = await runJob({
|
|
579
|
+
kind: 'import',
|
|
580
|
+
title: `导入单个插件(tgz)`,
|
|
581
|
+
meta: { file: body.file },
|
|
582
|
+
task: (report) => doImport(report),
|
|
583
|
+
});
|
|
584
|
+
sendJson(response, 200, { ...result, jobId: id });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const dryRun = Boolean(body.dryRun);
|
|
588
|
+
const doImportBundle = (report) =>
|
|
589
|
+
importBundle({
|
|
590
|
+
file: body.file,
|
|
591
|
+
dryRun,
|
|
592
|
+
mode: body.mode === 'skip' ? 'skip' : 'merge',
|
|
593
|
+
overwritePatchConfig: Boolean(body.overwritePatchConfig) || Boolean(config.overwritePatchConfig),
|
|
594
|
+
restoreData: body.restoreData !== false,
|
|
595
|
+
linkNodeModules: body.linkNodeModules !== false,
|
|
596
|
+
only: Array.isArray(body.only) && body.only.length ? body.only : null,
|
|
597
|
+
// 逐插件决策:界面弹窗后传进来,优先级高于全局 mode
|
|
598
|
+
decisions: body.decisions && typeof body.decisions === 'object' ? body.decisions : {},
|
|
599
|
+
onProgress: report,
|
|
600
|
+
});
|
|
601
|
+
if (dryRun) {
|
|
602
|
+
sendJson(response, 200, doImportBundle(() => {}));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const { id, result } = await runJob({
|
|
606
|
+
kind: 'import',
|
|
607
|
+
title: `导入整合包`,
|
|
608
|
+
meta: { file: body.file },
|
|
609
|
+
task: (report) => doImportBundle(report),
|
|
610
|
+
});
|
|
611
|
+
sendJson(response, 200, { ...result, jobId: id });
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
'plugin-pack: import',
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
register(
|
|
618
|
+
{
|
|
619
|
+
kind: 'exact',
|
|
620
|
+
path: `${PREFIX}/rollback`,
|
|
621
|
+
handler: async (request, response) => {
|
|
622
|
+
if (request.method !== 'POST') {
|
|
623
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const body = await readJsonBody(request);
|
|
627
|
+
const { id, result } = await runJob({
|
|
628
|
+
kind: 'rollback',
|
|
629
|
+
title: `回滚 ${body.backup ?? '(最新备份)'}`,
|
|
630
|
+
meta: { backup: body.backup ?? null },
|
|
631
|
+
task: () => rollbackImport({ backup: body.backup }),
|
|
632
|
+
});
|
|
633
|
+
sendJson(response, 200, { ...result, jobId: id });
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
'plugin-pack: rollback',
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
register(
|
|
640
|
+
{
|
|
641
|
+
kind: 'exact',
|
|
642
|
+
path: `${PREFIX}/browse`,
|
|
643
|
+
handler: async (request, response) => {
|
|
644
|
+
if (request.method !== 'POST') {
|
|
645
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const body = await readJsonBody(request);
|
|
649
|
+
// 选目录(导出目标)时才允许自动创建;找 zip 时不创建,免得粘错路径造出空目录
|
|
650
|
+
sendJson(response, 200, browse(body.dir, { create: body.create === true }));
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
'plugin-pack: browse',
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
/* 拖放上传:浏览器拿不到绝对路径,只能把内容传上来,宿主落盘后再走导入流程。
|
|
657
|
+
* 用 multipart 而不是 base64 JSON —— 几百 MB 的包 base64 会膨胀 33%。 */
|
|
658
|
+
register(
|
|
659
|
+
{
|
|
660
|
+
kind: 'exact',
|
|
661
|
+
path: `${PREFIX}/upload-zip`,
|
|
662
|
+
handler: async (request, response) => {
|
|
663
|
+
if (request.method !== 'POST') {
|
|
664
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const boundary = boundaryOf(request.headers?.['content-type']);
|
|
668
|
+
if (!boundary) {
|
|
669
|
+
sendJson(response, 400, { ok: false, error: '缺少 multipart boundary' });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const chunks = [];
|
|
673
|
+
let size = 0;
|
|
674
|
+
for await (const chunk of request) {
|
|
675
|
+
size += chunk.length;
|
|
676
|
+
if (size > 1024 * 1024 * 1024) {
|
|
677
|
+
sendJson(response, 413, { ok: false, error: '文件超过 1 GiB' });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
681
|
+
}
|
|
682
|
+
const parts = parseMultipart(Buffer.concat(chunks), boundary);
|
|
683
|
+
const filePart = parts.find((part) => part.filename);
|
|
684
|
+
if (!filePart) {
|
|
685
|
+
sendJson(response, 400, { ok: false, error: '请求里没有文件' });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const home = resolveDshHome();
|
|
689
|
+
pruneInbox(home);
|
|
690
|
+
const saved = saveIncomingZip({ home, filename: filePart.filename, data: filePart.data });
|
|
691
|
+
sendJson(response, 200, { ok: true, ...saved, filename: filePart.filename });
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
'plugin-pack: upload-zip',
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
register(
|
|
698
|
+
{
|
|
699
|
+
kind: 'exact',
|
|
700
|
+
path: `${PREFIX}/upload-dir`,
|
|
701
|
+
handler: async (request, response) => {
|
|
702
|
+
if (request.method !== 'POST') {
|
|
703
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const boundary = boundaryOf(request.headers?.['content-type']);
|
|
707
|
+
if (!boundary) {
|
|
708
|
+
sendJson(response, 400, { ok: false, error: '缺少 multipart boundary' });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const chunks = [];
|
|
712
|
+
let size = 0;
|
|
713
|
+
for await (const chunk of request) {
|
|
714
|
+
size += chunk.length;
|
|
715
|
+
if (size > 1024 * 1024 * 1024) {
|
|
716
|
+
sendJson(response, 413, { ok: false, error: '目录超过 1 GiB' });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
720
|
+
}
|
|
721
|
+
const parts = parseMultipart(Buffer.concat(chunks), boundary);
|
|
722
|
+
// 每个 part 的 name 是相对路径(如 "my-plugin/lib/index.js")
|
|
723
|
+
const files = parts
|
|
724
|
+
.filter((part) => part.filename !== null && part.name)
|
|
725
|
+
.map((part) => ({ rel: part.name, data: part.data }));
|
|
726
|
+
if (!files.length) {
|
|
727
|
+
sendJson(response, 400, { ok: false, error: '请求里没有文件' });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const rootName = String(parts[0]?.name ?? '').split('/')[0] || 'plugin';
|
|
731
|
+
const home = resolveDshHome();
|
|
732
|
+
pruneInbox(home);
|
|
733
|
+
const saved = saveIncomingDir({ home, rootName, files });
|
|
734
|
+
sendJson(response, 200, { ok: true, ...saved, rootName });
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
'plugin-pack: upload-dir',
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
register(
|
|
741
|
+
{
|
|
742
|
+
kind: 'exact',
|
|
743
|
+
path: `${PREFIX}/hotload`,
|
|
744
|
+
handler: async (request, response) => {
|
|
745
|
+
if (request.method !== 'POST') {
|
|
746
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const body = await readJsonBody(request);
|
|
750
|
+
const packageName = String(body.package ?? '').trim() || 'dsh-plugin-pack';
|
|
751
|
+
const config2 = config;
|
|
752
|
+
const ctx2 = contextOf(config2);
|
|
753
|
+
const packageDir = String(body.dir ?? '').trim() || packageDirOf(packageName, ctx2);
|
|
754
|
+
if (!packageDir) {
|
|
755
|
+
sendJson(response, 404, { ok: false, error: `找不到包目录: ${packageName}` });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const verdict = localVerdict(packageDir);
|
|
759
|
+
const steps = [];
|
|
760
|
+
const result = reloadClientPackage(ctx, {
|
|
761
|
+
packageName,
|
|
762
|
+
packageDir,
|
|
763
|
+
log: (message) => steps.push(message),
|
|
764
|
+
});
|
|
765
|
+
sendJson(response, 200, {
|
|
766
|
+
ok: result.ok,
|
|
767
|
+
package: packageName,
|
|
768
|
+
dir: packageDir,
|
|
769
|
+
verdict,
|
|
770
|
+
rev: result.rev,
|
|
771
|
+
inTable: result.inTable,
|
|
772
|
+
clearedCaches: result.clearedCaches,
|
|
773
|
+
steps,
|
|
774
|
+
failures: result.failures,
|
|
775
|
+
error: result.error,
|
|
776
|
+
note: result.ok ? '刷新页面(Ctrl+F5)即可看到界面' : undefined,
|
|
777
|
+
});
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
'plugin-pack: hotload',
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
register(
|
|
784
|
+
{
|
|
785
|
+
kind: 'exact',
|
|
786
|
+
path: `${PREFIX}/client`,
|
|
787
|
+
handler: async (request, response) => {
|
|
788
|
+
if (request.method !== 'GET') {
|
|
789
|
+
sendJson(response, 405, { ok: false, error: '只支持 GET' });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
793
|
+
const packageName = url.searchParams.get('package') || 'dsh-plugin-pack';
|
|
794
|
+
const status = clientStatus(ctx, packageName);
|
|
795
|
+
sendJson(response, 200, { ok: true, package: packageName, ...status });
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
'plugin-pack: client',
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
/* 后台任务:切走画面再回来,靠这个接口把进度和结果捡回来。
|
|
802
|
+
* GET /plugin-pack/jobs 列出任务(含进行中、已完成)
|
|
803
|
+
* GET /plugin-pack/jobs?id=37a1159f 查单个(id 是任务返回的那个)
|
|
804
|
+
* POST /plugin-pack/jobs/clear 清空已完成记录 */
|
|
805
|
+
register(
|
|
806
|
+
{
|
|
807
|
+
kind: 'exact',
|
|
808
|
+
path: `${PREFIX}/jobs`,
|
|
809
|
+
handler: async (request, response) => {
|
|
810
|
+
if (request.method !== 'GET') {
|
|
811
|
+
sendJson(response, 405, { ok: false, error: '只支持 GET' });
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
815
|
+
const id = url.searchParams.get('id');
|
|
816
|
+
if (id) {
|
|
817
|
+
const job = getJob(id);
|
|
818
|
+
if (!job) {
|
|
819
|
+
sendJson(response, 404, { ok: false, error: `没有这个任务: ${id}` });
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
sendJson(response, 200, { ok: true, job });
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const limit = Number(url.searchParams.get('limit')) || 20;
|
|
826
|
+
sendJson(response, 200, {
|
|
827
|
+
ok: true,
|
|
828
|
+
jobs: listJobs({ limit }),
|
|
829
|
+
running: runningJobs(),
|
|
830
|
+
});
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
'plugin-pack: jobs',
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
register(
|
|
837
|
+
{
|
|
838
|
+
kind: 'exact',
|
|
839
|
+
path: `${PREFIX}/jobs/clear`,
|
|
840
|
+
handler: async (request, response) => {
|
|
841
|
+
if (request.method !== 'POST') {
|
|
842
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
sendJson(response, 200, { ok: true, removed: clearFinished() });
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
'plugin-pack: jobs-clear',
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
register(
|
|
852
|
+
{
|
|
853
|
+
kind: 'exact',
|
|
854
|
+
path: `${PREFIX}/import-plugin`,
|
|
855
|
+
handler: async (request, response) => {
|
|
856
|
+
if (request.method !== 'POST') {
|
|
857
|
+
sendJson(response, 405, { ok: false, error: '只支持 POST' });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const body = await readJsonBody(request);
|
|
861
|
+
if (!body.dir) {
|
|
862
|
+
sendJson(response, 400, { ok: false, error: '需要 dir(插件目录)' });
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const local = contextOf(config);
|
|
866
|
+
const dryRun = Boolean(body.dryRun);
|
|
867
|
+
const doImport = (report) =>
|
|
868
|
+
importLocalPlugin({
|
|
869
|
+
dir: body.dir,
|
|
870
|
+
profileDir: local.profileDir,
|
|
871
|
+
dryRun,
|
|
872
|
+
mode: body.mode === 'skip' ? 'skip' : 'merge',
|
|
873
|
+
linkNodeModules: body.linkNodeModules !== false,
|
|
874
|
+
decisions: body.decisions && typeof body.decisions === 'object' ? body.decisions : {},
|
|
875
|
+
onProgress: report,
|
|
876
|
+
});
|
|
877
|
+
if (dryRun) {
|
|
878
|
+
sendJson(response, 200, doImport(() => {}));
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const { id, result } = await runJob({
|
|
882
|
+
kind: 'import-plugin',
|
|
883
|
+
title: `导入本地插件`,
|
|
884
|
+
meta: { dir: body.dir },
|
|
885
|
+
task: (report) => doImport(report),
|
|
886
|
+
});
|
|
887
|
+
sendJson(response, 200, { ...result, jobId: id });
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
'plugin-pack: import-plugin',
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
register(
|
|
894
|
+
{
|
|
895
|
+
kind: 'exact',
|
|
896
|
+
path: `${PREFIX}/config`,
|
|
897
|
+
handler: async (request, response) => {
|
|
898
|
+
const home = resolveDshHome();
|
|
899
|
+
if (request.method === 'GET') {
|
|
900
|
+
sendJson(response, 200, { ok: true, settings: readSettings(home) });
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (request.method === 'POST') {
|
|
904
|
+
const body = await readJsonBody(request);
|
|
905
|
+
const saved = writeSettings(home, body);
|
|
906
|
+
sendJson(response, 200, { ok: true, settings: saved, note: '重启 dsh 后对导出默认值生效' });
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
sendJson(response, 405, { ok: false, error: '只支持 GET / POST' });
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
'plugin-pack: config',
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
return () => {
|
|
916
|
+
for (const dispose of disposers) {
|
|
917
|
+
try {
|
|
918
|
+
dispose();
|
|
919
|
+
} catch {
|
|
920
|
+
/* 单条撤销失败不影响其它 */
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
}, 'plugin-pack: http routes');
|
|
925
|
+
});
|
|
926
|
+
}
|