dbx-plugin-skill 0.1.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/LICENSE +201 -0
- package/README.md +238 -0
- package/bin/dbx-plugin-skill.mjs +330 -0
- package/lib/installer.mjs +256 -0
- package/package.json +49 -0
- package/skill/SKILL.md +268 -0
- package/skill/references/cli.md +225 -0
- package/skill/references/contributions.md +229 -0
- package/skill/references/debugging.md +210 -0
- package/skill/references/host-api.md +195 -0
- package/skill/references/manifest.md +229 -0
- package/skill/references/packaging.md +183 -0
- package/skill/references/publishing.md +374 -0
- package/skill/references/sidecar-protocol.md +242 -0
- package/skill/references/troubleshooting.md +171 -0
- package/skill/scripts/check-project.mjs +915 -0
- package/skill/scripts/dev-logs.mjs +191 -0
- package/skill/scripts/inspect-dbxp.mjs +400 -0
- package/skill/scripts/make-candidate.mjs +413 -0
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-project.mjs — DBX 插件项目打包前预检(零依赖,Node.js 18+)
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node check-project.mjs [项目目录] [--json] [--quiet]
|
|
7
|
+
*
|
|
8
|
+
* 覆盖:manifest v1 字段/格式/权限/入口/贡献点、dbx-plugin.toml、
|
|
9
|
+
* [package].include 覆盖范围、资源存在性、前后端声明一致性。
|
|
10
|
+
*
|
|
11
|
+
* 退出码: 0 = 无 error(可能有 warning),1 = 存在 error
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
|
|
15
|
+
import { join, resolve, isAbsolute, posix, relative } from "node:path";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------- 常量
|
|
18
|
+
|
|
19
|
+
const MANIFEST_TOP_FIELDS = [
|
|
20
|
+
"$schema",
|
|
21
|
+
"manifest_version",
|
|
22
|
+
"id",
|
|
23
|
+
"name",
|
|
24
|
+
"icon",
|
|
25
|
+
"version",
|
|
26
|
+
"publisher",
|
|
27
|
+
"description",
|
|
28
|
+
"source",
|
|
29
|
+
"homepage",
|
|
30
|
+
"engines",
|
|
31
|
+
"permissions",
|
|
32
|
+
"entrypoints",
|
|
33
|
+
"contributions",
|
|
34
|
+
"localizations",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const STATIC_PERMISSIONS = ["host.events", "host.binary", "host.workbench", "host.filesystem"];
|
|
38
|
+
const NETWORK_PERMISSION = /^host\.network:https:\/\/[A-Za-z0-9._-]+(?::[0-9]+)?$/;
|
|
39
|
+
const MAX_NETWORK_PERMISSIONS = 8;
|
|
40
|
+
|
|
41
|
+
const IDENTIFIER = /^[a-z0-9][a-z0-9._-]*$/;
|
|
42
|
+
const SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
43
|
+
const FIELD_TYPES = ["text", "password", "number", "boolean", "select", "radio", "textarea"];
|
|
44
|
+
const BINDINGS = ["config", "secret", "name", "host", "port", "username", "password", "database"];
|
|
45
|
+
const TEXTUAL_BINDINGS = ["secret", "name", "host", "username", "password", "database"];
|
|
46
|
+
const TEXTUAL_TYPES = ["text", "password", "select", "radio", "textarea"];
|
|
47
|
+
const CAPABILITIES = ["test", "connect", "disconnect"];
|
|
48
|
+
const ACTION_VARIANTS = ["default", "outline", "secondary", "destructive", "ghost"];
|
|
49
|
+
const ACTION_WHEN = ["always", "create", "edit"];
|
|
50
|
+
const FS_CAPABILITIES = ["read", "write", "delete", "rename", "mkdir"];
|
|
51
|
+
const CONTRIBUTION_TYPES = [
|
|
52
|
+
"connection-provider",
|
|
53
|
+
"workbench",
|
|
54
|
+
"filesystem-provider",
|
|
55
|
+
"context-menu",
|
|
56
|
+
"result-view",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------- 输出
|
|
60
|
+
|
|
61
|
+
const findings = [];
|
|
62
|
+
let quiet = false;
|
|
63
|
+
let jsonMode = false;
|
|
64
|
+
|
|
65
|
+
function report(level, message, hint) {
|
|
66
|
+
findings.push({ level, message, hint });
|
|
67
|
+
if (jsonMode || (quiet && level !== "error")) return;
|
|
68
|
+
const tag = level === "error" ? "ERROR" : level === "warn" ? "WARN " : "info ";
|
|
69
|
+
const stream = level === "error" ? process.stderr : process.stdout;
|
|
70
|
+
stream.write(`[${tag}] ${message}${hint ? `\n → ${hint}` : ""}\n`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const error = (m, h) => report("error", m, h);
|
|
74
|
+
const warn = (m, h) => report("warn", m, h);
|
|
75
|
+
const info = (m, h) => report("info", m, h);
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------- 小工具
|
|
78
|
+
|
|
79
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
80
|
+
|
|
81
|
+
/** 包内路径安全规则:相对、无 \、无 . / .. 段、无 // */
|
|
82
|
+
function unsafePathReason(value) {
|
|
83
|
+
if (typeof value !== "string" || value.length === 0) return "不是非空字符串";
|
|
84
|
+
if (value.startsWith("/")) return "不能以 / 开头";
|
|
85
|
+
if (value.includes("\\")) return "不能包含反斜杠";
|
|
86
|
+
if (value.includes("//")) return "不能包含重复斜杠";
|
|
87
|
+
const segments = value.split("/");
|
|
88
|
+
if (segments.some((s) => s === "." || s === "..")) return "不能包含 . 或 .. 路径段";
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function safeRelativeReason(value) {
|
|
93
|
+
if (typeof value !== "string" || value.length === 0) return "不是非空字符串";
|
|
94
|
+
if (isAbsolute(value)) return "不能是绝对路径";
|
|
95
|
+
const segments = value.split(/[\\/]+/);
|
|
96
|
+
if (segments.some((s) => s === "." || s === "..")) return "不能包含 . 或 .. 路径段";
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walkFiles(root, base = root, out = []) {
|
|
101
|
+
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
102
|
+
const full = join(base, entry.name);
|
|
103
|
+
if (entry.isDirectory()) walkFiles(root, full, out);
|
|
104
|
+
else if (entry.isFile()) out.push(relative(root, full).split("\\").join("/"));
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function walkDirs(root, base = root, out = []) {
|
|
110
|
+
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
111
|
+
if (!entry.isDirectory()) continue;
|
|
112
|
+
const full = join(base, entry.name);
|
|
113
|
+
out.push(relative(root, full).split("\\").join("/"));
|
|
114
|
+
walkDirs(root, full, out);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------- 极简 TOML 解析(只支持本项目配置形状)
|
|
120
|
+
|
|
121
|
+
function stripComment(line) {
|
|
122
|
+
let inSingle = false;
|
|
123
|
+
let inDouble = false;
|
|
124
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
125
|
+
const ch = line[i];
|
|
126
|
+
if (ch === "'" && !inDouble) inSingle = !inSingle;
|
|
127
|
+
else if (ch === '"' && !inSingle) inDouble = !inDouble;
|
|
128
|
+
else if (ch === "#" && !inSingle && !inDouble) return line.slice(0, i);
|
|
129
|
+
}
|
|
130
|
+
return line;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseValue(raw, lineNo) {
|
|
134
|
+
const text = raw.trim();
|
|
135
|
+
if (text === "") throw new Error(`第 ${lineNo} 行: 缺少值`);
|
|
136
|
+
if (text.startsWith('"') || text.startsWith("'")) {
|
|
137
|
+
const quote = text[0];
|
|
138
|
+
if (!text.endsWith(quote) || text.length < 2) throw new Error(`第 ${lineNo} 行: 未闭合的字符串`);
|
|
139
|
+
return text.slice(1, -1);
|
|
140
|
+
}
|
|
141
|
+
if (text.startsWith("[")) {
|
|
142
|
+
if (!text.endsWith("]")) throw new Error(`第 ${lineNo} 行: 未闭合的数组`);
|
|
143
|
+
const inner = text.slice(1, -1).trim();
|
|
144
|
+
if (inner === "") return [];
|
|
145
|
+
const items = [];
|
|
146
|
+
let current = "";
|
|
147
|
+
let quote = null;
|
|
148
|
+
for (const ch of inner) {
|
|
149
|
+
if (quote) {
|
|
150
|
+
if (ch === quote) quote = null;
|
|
151
|
+
else current += ch;
|
|
152
|
+
} else if (ch === '"' || ch === "'") {
|
|
153
|
+
quote = ch;
|
|
154
|
+
} else if (ch === ",") {
|
|
155
|
+
items.push(current.trim());
|
|
156
|
+
current = "";
|
|
157
|
+
} else {
|
|
158
|
+
current += ch;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
items.push(current.trim());
|
|
162
|
+
return items.filter((v) => v !== "").map((v) => v.replace(/^["']|["']$/g, ""));
|
|
163
|
+
}
|
|
164
|
+
if (text === "true") return true;
|
|
165
|
+
if (text === "false") return false;
|
|
166
|
+
if (/^[+-]?[0-9]+$/.test(text)) return Number(text);
|
|
167
|
+
throw new Error(`第 ${lineNo} 行: 不支持的值 "${text}"(本脚本仅支持字符串/整数/布尔/字符串数组)`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseToml(text) {
|
|
171
|
+
const root = {};
|
|
172
|
+
let current = root;
|
|
173
|
+
const lines = text.split(/\r?\n/);
|
|
174
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
175
|
+
const lineNo = i + 1;
|
|
176
|
+
const line = stripComment(lines[i]).trim();
|
|
177
|
+
if (line === "") continue;
|
|
178
|
+
const section = /^\[([^\]]+)\]$/.exec(line);
|
|
179
|
+
if (section) {
|
|
180
|
+
const path = section[1].trim().split(".");
|
|
181
|
+
current = root;
|
|
182
|
+
for (const part of path) {
|
|
183
|
+
if (!isPlainObject(current[part])) current[part] = {};
|
|
184
|
+
current = current[part];
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const eq = line.indexOf("=");
|
|
189
|
+
if (eq < 0) throw new Error(`第 ${lineNo} 行: 不是 key = value 形式`);
|
|
190
|
+
const key = line.slice(0, eq).trim().replace(/^["']|["']$/g, "");
|
|
191
|
+
if (key === "") throw new Error(`第 ${lineNo} 行: 缺少键名`);
|
|
192
|
+
current[key] = parseValue(line.slice(eq + 1), lineNo);
|
|
193
|
+
}
|
|
194
|
+
return root;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------- 校验主体
|
|
198
|
+
|
|
199
|
+
function checkManifest(dir, manifest) {
|
|
200
|
+
const unknown = Object.keys(manifest).filter((k) => !MANIFEST_TOP_FIELDS.includes(k));
|
|
201
|
+
if (unknown.length) {
|
|
202
|
+
error(
|
|
203
|
+
`manifest.json 含未知顶层字段: ${unknown.join(", ")}`,
|
|
204
|
+
"Manifest v1 拒绝未声明字段。常见误加:signingKeyId、verified、license。",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (manifest.manifest_version !== 1) {
|
|
209
|
+
error(`manifest_version 必须为数字 1(当前 ${JSON.stringify(manifest.manifest_version)})`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (typeof manifest.id !== "string" || !IDENTIFIER.test(manifest.id)) {
|
|
213
|
+
error(`id 非法: ${JSON.stringify(manifest.id)}`, "只允许小写字母、数字和 . _ -,首字符必须是字母或数字。");
|
|
214
|
+
} else if (manifest.id.length > 128) {
|
|
215
|
+
error(`id 长度 ${manifest.id.length} 超过商店上限 128`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (typeof manifest.name !== "string" || manifest.name.trim() === "") error("name 必须是非空字符串");
|
|
219
|
+
else if (/[\u0000-\u001f\u007f]/.test(manifest.name)) error("name 含控制字符");
|
|
220
|
+
|
|
221
|
+
if (typeof manifest.version !== "string" || !SEMVER.test(manifest.version)) {
|
|
222
|
+
error(`version 不是合法 SemVer: ${JSON.stringify(manifest.version)}`, "例如 1.0.0 或 1.0.0-beta.1");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (typeof manifest.publisher !== "string" || manifest.publisher.trim() === "") {
|
|
226
|
+
error("publisher 必须是非空字符串");
|
|
227
|
+
} else if (!IDENTIFIER.test(manifest.publisher)) {
|
|
228
|
+
warn(
|
|
229
|
+
`publisher "${manifest.publisher}" 不符合商店标识符规则`,
|
|
230
|
+
"manifest 本身不限制格式,但上架时候选 publisher 必须匹配 ^[a-z0-9][a-z0-9._-]{0,127}$ 且已登记。",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const field of ["description"]) {
|
|
235
|
+
if (manifest[field] !== undefined && typeof manifest[field] !== "string") {
|
|
236
|
+
error(`${field} 必须是字符串`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const field of ["source", "homepage"]) {
|
|
240
|
+
if (manifest[field] !== undefined && !/^https?:\/\/\S+$/.test(String(manifest[field]))) {
|
|
241
|
+
warn(`${field} 建议使用 http(s) URL(当前 ${JSON.stringify(manifest[field])})`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (typeof manifest.$schema === "string" && manifest.$schema.includes("plugin-sdk-v1")) {
|
|
246
|
+
warn(
|
|
247
|
+
"$schema 指向的 ref plugin-sdk-v1 不存在(HTTP 404)",
|
|
248
|
+
"改为 https://raw.githubusercontent.com/t8y2/dbx/main/plugins/manifest.schema.json 或具体版本 tag。",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// engines
|
|
253
|
+
if (!isPlainObject(manifest.engines)) {
|
|
254
|
+
error("engines 是必需字段且必须是对象");
|
|
255
|
+
} else {
|
|
256
|
+
if (typeof manifest.engines.host_api !== "string" || manifest.engines.host_api === "") {
|
|
257
|
+
error("engines.host_api 是必需字段且必须是非空字符串");
|
|
258
|
+
}
|
|
259
|
+
const extra = Object.keys(manifest.engines).filter((k) => k !== "dbx" && k !== "host_api");
|
|
260
|
+
if (extra.length) error(`engines 含未知字段: ${extra.join(", ")}`);
|
|
261
|
+
if (manifest.engines.dbx !== undefined && typeof manifest.engines.dbx !== "string") {
|
|
262
|
+
error("engines.dbx 必须是字符串");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// permissions
|
|
267
|
+
if (manifest.permissions !== undefined) {
|
|
268
|
+
if (!Array.isArray(manifest.permissions)) {
|
|
269
|
+
error("permissions 必须是数组");
|
|
270
|
+
} else {
|
|
271
|
+
const seen = new Set();
|
|
272
|
+
let networkCount = 0;
|
|
273
|
+
for (const permission of manifest.permissions) {
|
|
274
|
+
if (typeof permission !== "string" || permission === "") {
|
|
275
|
+
error(`permissions 含非法项: ${JSON.stringify(permission)}`);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (seen.has(permission)) error(`permissions 重复项: ${permission}`);
|
|
279
|
+
seen.add(permission);
|
|
280
|
+
if (STATIC_PERMISSIONS.includes(permission)) continue;
|
|
281
|
+
if (NETWORK_PERMISSION.test(permission)) {
|
|
282
|
+
networkCount += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
error(
|
|
286
|
+
`permissions 含未支持的权限: ${permission}`,
|
|
287
|
+
`固定权限: ${STATIC_PERMISSIONS.join(", ")};网络权限形如 host.network:https://host[:port](HTTPS、无路径/通配符/Token)。`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (networkCount > MAX_NETWORK_PERMISSIONS) {
|
|
291
|
+
error(`host.network 权限数量 ${networkCount} 超过上限 ${MAX_NETWORK_PERMISSIONS}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// entrypoints + 资源
|
|
297
|
+
const include = state.include;
|
|
298
|
+
const covered = (p) => include.some((inc) => p === inc || p.startsWith(`${inc}/`));
|
|
299
|
+
|
|
300
|
+
const ui = isPlainObject(manifest.entrypoints) ? manifest.entrypoints.ui : undefined;
|
|
301
|
+
if (ui !== undefined) {
|
|
302
|
+
if (!isPlainObject(ui)) {
|
|
303
|
+
error("entrypoints.ui 必须是对象");
|
|
304
|
+
} else {
|
|
305
|
+
const extra = Object.keys(ui).filter((k) => k !== "root" && k !== "entry");
|
|
306
|
+
if (extra.length) error(`entrypoints.ui 含未知字段: ${extra.join(", ")}`);
|
|
307
|
+
if (ui.kind !== undefined) {
|
|
308
|
+
error("entrypoints.ui.kind 已废弃", "DBX 插件 UI 永远是沙箱化的,请删除该字段。");
|
|
309
|
+
}
|
|
310
|
+
const entry = ui.entry;
|
|
311
|
+
if (typeof entry !== "string" || entry === "") {
|
|
312
|
+
error("entrypoints.ui.entry 是必需的非空字符串");
|
|
313
|
+
} else {
|
|
314
|
+
const reason = unsafePathReason(entry);
|
|
315
|
+
if (reason) error(`entrypoints.ui.entry 路径不安全: ${reason}`);
|
|
316
|
+
const root = typeof ui.root === "string" && ui.root !== "" ? ui.root : "ui";
|
|
317
|
+
const rootReason = unsafePathReason(root);
|
|
318
|
+
if (rootReason) error(`entrypoints.ui.root 路径不安全: ${rootReason}`);
|
|
319
|
+
if (!entry.startsWith(root)) {
|
|
320
|
+
error(
|
|
321
|
+
`entrypoints.ui.entry "${entry}" 不在 root "${root}" 内`,
|
|
322
|
+
'root 为 "ui" 时入口应写 "ui/index.html",不是 "index.html"。',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (state.include.length === 0) {
|
|
326
|
+
warn("缺少 [package].include,无法判断 UI 入口是否会被打包");
|
|
327
|
+
} else {
|
|
328
|
+
checkPackagedFile(entry, "entrypoints.ui.entry");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const backend = isPlainObject(manifest.entrypoints) ? manifest.entrypoints.backend : undefined;
|
|
335
|
+
if (backend !== undefined && backend !== null) {
|
|
336
|
+
if (!isPlainObject(backend)) {
|
|
337
|
+
error("entrypoints.backend 必须是对象");
|
|
338
|
+
} else {
|
|
339
|
+
const extra = Object.keys(backend).filter(
|
|
340
|
+
(k) => k !== "protocol_versions" && k !== "transport" && k !== "executable",
|
|
341
|
+
);
|
|
342
|
+
if (extra.length) error(`entrypoints.backend 含未知字段: ${extra.join(", ")}`);
|
|
343
|
+
if (backend.binaries !== undefined) {
|
|
344
|
+
error("entrypoints.backend.binaries 已废弃", "每个平台打一个包,改用 executable。");
|
|
345
|
+
}
|
|
346
|
+
if (backend.protocol !== undefined) {
|
|
347
|
+
error("entrypoints.backend.protocol 已废弃", "Manifest v1 使用 DBX JSON-RPC 协议,删除该字段。");
|
|
348
|
+
}
|
|
349
|
+
if (backend.transport !== undefined && !["stdio-jsonl", "stdio-framed"].includes(backend.transport)) {
|
|
350
|
+
error(`entrypoints.backend.transport 非法: ${JSON.stringify(backend.transport)}`);
|
|
351
|
+
}
|
|
352
|
+
if (backend.protocol_versions !== undefined) {
|
|
353
|
+
if (
|
|
354
|
+
!Array.isArray(backend.protocol_versions) ||
|
|
355
|
+
backend.protocol_versions.length === 0 ||
|
|
356
|
+
backend.protocol_versions.some((v) => !Number.isInteger(v) || v < 1)
|
|
357
|
+
) {
|
|
358
|
+
error("entrypoints.backend.protocol_versions 必须是非空正整数数组");
|
|
359
|
+
} else if (new Set(backend.protocol_versions).size !== backend.protocol_versions.length) {
|
|
360
|
+
error("entrypoints.backend.protocol_versions 不能有重复项");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (typeof backend.executable !== "string" || backend.executable === "") {
|
|
364
|
+
error("entrypoints.backend.executable 是必需的非空字符串");
|
|
365
|
+
} else {
|
|
366
|
+
const reason = unsafePathReason(backend.executable);
|
|
367
|
+
if (reason) error(`entrypoints.backend.executable 路径不安全: ${reason}`);
|
|
368
|
+
if (backend.transport === "stdio-framed" && !(manifest.permissions ?? []).includes("host.binary")) {
|
|
369
|
+
error(
|
|
370
|
+
"声明了 stdio-framed 但没有 host.binary 权限",
|
|
371
|
+
"二进制帧需要 host.binary。",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const hasManifestBackend = isPlainObject(backend);
|
|
379
|
+
const hasTomlBackend = isPlainObject(state.toml?.backend);
|
|
380
|
+
if (hasManifestBackend !== hasTomlBackend) {
|
|
381
|
+
error(
|
|
382
|
+
"manifest.json 与 dbx-plugin.toml 的 backend 声明不一致",
|
|
383
|
+
"要么都声明,要么都省略;CLI 会在打包时直接报错。",
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (typeof manifest.icon === "string" && manifest.icon !== "") {
|
|
388
|
+
const reason = unsafePathReason(manifest.icon);
|
|
389
|
+
if (reason) error(`icon 路径不安全: ${reason}`);
|
|
390
|
+
else checkPackagedFile(manifest.icon, "manifest icon");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
checkContributions(manifest, dir);
|
|
394
|
+
checkLocalizations(manifest);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function checkPackagedFile(relPath, label) {
|
|
398
|
+
const files = state.files;
|
|
399
|
+
if (!files.has(relPath)) {
|
|
400
|
+
error(`${label} "${relPath}" 在项目中不存在`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (state.include.length === 0) return;
|
|
404
|
+
const covered = state.include.some((inc) => relPath === inc || relPath.startsWith(`${inc}/`));
|
|
405
|
+
if (!covered) {
|
|
406
|
+
error(
|
|
407
|
+
`${label} "${relPath}" 未被 [package].include 覆盖`,
|
|
408
|
+
`请把所在目录加入 include(当前: [${state.include.join(", ")}])。`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function checkContributions(manifest, dir) {
|
|
414
|
+
if (manifest.contributions === undefined) return;
|
|
415
|
+
if (!Array.isArray(manifest.contributions)) {
|
|
416
|
+
error("contributions 必须是数组");
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const declared = new Set();
|
|
420
|
+
const usage = new Set();
|
|
421
|
+
for (const contribution of manifest.contributions) {
|
|
422
|
+
if (isPlainObject(contribution) && typeof contribution.id === "string") {
|
|
423
|
+
if (declared.has(contribution.id)) error(`contributions 里 id 重复: ${contribution.id}`);
|
|
424
|
+
declared.add(contribution.id);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
manifest.contributions.forEach((contribution, index) => {
|
|
428
|
+
const at = `contributions[${index}]`;
|
|
429
|
+
if (!isPlainObject(contribution)) {
|
|
430
|
+
error(`${at} 必须是对象`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const { type } = contribution;
|
|
434
|
+
if (!CONTRIBUTION_TYPES.includes(type)) {
|
|
435
|
+
error(`${at}.type 非法: ${JSON.stringify(type)}`, `允许: ${CONTRIBUTION_TYPES.join(", ")}`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (typeof contribution.id !== "string" || !IDENTIFIER.test(contribution.id)) {
|
|
439
|
+
error(`${at}.id 非法: ${JSON.stringify(contribution.id)}`);
|
|
440
|
+
}
|
|
441
|
+
if (contribution.icon !== undefined) {
|
|
442
|
+
const reason = unsafePathReason(contribution.icon);
|
|
443
|
+
if (reason) error(`${at}.icon 路径不安全: ${reason}`);
|
|
444
|
+
else if (!state.files.has(contribution.icon)) error(`${at}.icon "${contribution.icon}" 不存在`);
|
|
445
|
+
}
|
|
446
|
+
if (contribution.type !== "connection-provider" && (typeof contribution.label !== "string" || contribution.label === "")) {
|
|
447
|
+
error(`${at}.label 是必需的非空字符串`);
|
|
448
|
+
}
|
|
449
|
+
if (["workbench", "filesystem-provider", "context-menu", "result-view"].includes(type)) {
|
|
450
|
+
const extra = Object.keys(contribution).filter(
|
|
451
|
+
(k) => !["type", "id", "label", "description", "icon"].includes(k),
|
|
452
|
+
);
|
|
453
|
+
if (type !== "filesystem-provider" && extra.length) {
|
|
454
|
+
error(`${at} 含未知字段: ${extra.join(", ")}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
switch (type) {
|
|
459
|
+
case "connection-provider":
|
|
460
|
+
checkConnectionProvider(contribution, at, declared, usage);
|
|
461
|
+
break;
|
|
462
|
+
case "filesystem-provider":
|
|
463
|
+
checkFilesystemProvider(contribution, at);
|
|
464
|
+
break;
|
|
465
|
+
case "context-menu":
|
|
466
|
+
if (contribution.menu !== "connection") {
|
|
467
|
+
error(`${at}.menu 必须是 "connection"(v1 仅支持该菜单)`);
|
|
468
|
+
}
|
|
469
|
+
break;
|
|
470
|
+
default:
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
for (const ref of usage) {
|
|
476
|
+
if (!declared.has(ref)) {
|
|
477
|
+
error(`贡献点引用 "${ref}" 但未在本插件声明`, "引用的 workbench / filesystem-provider 必须有对应的 contributions 条目。");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function checkConnectionProvider(contribution, at, declared, usage) {
|
|
483
|
+
const allowed = [
|
|
484
|
+
"type",
|
|
485
|
+
"id",
|
|
486
|
+
"label",
|
|
487
|
+
"icon",
|
|
488
|
+
"database_type",
|
|
489
|
+
"description",
|
|
490
|
+
"fields",
|
|
491
|
+
"workbench",
|
|
492
|
+
"filesystem_provider",
|
|
493
|
+
"capabilities",
|
|
494
|
+
"actions",
|
|
495
|
+
];
|
|
496
|
+
const extra = Object.keys(contribution).filter((k) => !allowed.includes(k));
|
|
497
|
+
if (extra.length) error(`${at} 含未知字段: ${extra.join(", ")}`);
|
|
498
|
+
|
|
499
|
+
if (typeof contribution.database_type !== "string" || !IDENTIFIER.test(contribution.database_type)) {
|
|
500
|
+
error(`${at}.database_type 是必需字段且必须是小写标识符(如 ssh、example-files)`);
|
|
501
|
+
}
|
|
502
|
+
if (!Array.isArray(contribution.fields)) {
|
|
503
|
+
error(`${at}.fields 是必需字段且必须是数组`);
|
|
504
|
+
} else {
|
|
505
|
+
const keys = new Set();
|
|
506
|
+
contribution.fields.forEach((field, i) => {
|
|
507
|
+
const fAt = `${at}.fields[${i}]`;
|
|
508
|
+
if (!isPlainObject(field)) {
|
|
509
|
+
error(`${fAt} 必须是对象`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const fieldAllowed = [
|
|
513
|
+
"key",
|
|
514
|
+
"label",
|
|
515
|
+
"type",
|
|
516
|
+
"description",
|
|
517
|
+
"placeholder",
|
|
518
|
+
"required",
|
|
519
|
+
"default",
|
|
520
|
+
"options",
|
|
521
|
+
"binding",
|
|
522
|
+
"visible_when",
|
|
523
|
+
"required_when",
|
|
524
|
+
];
|
|
525
|
+
const fieldExtra = Object.keys(field).filter((k) => !fieldAllowed.includes(k));
|
|
526
|
+
if (fieldExtra.length) error(`${fAt} 含未知字段: ${fieldExtra.join(", ")}`);
|
|
527
|
+
|
|
528
|
+
if (typeof field.key !== "string" || !IDENTIFIER.test(field.key)) error(`${fAt}.key 非法`);
|
|
529
|
+
else if (keys.has(field.key)) error(`${fAt}.key 重复: ${field.key}`);
|
|
530
|
+
else keys.add(field.key);
|
|
531
|
+
if (typeof field.label !== "string" || field.label === "") error(`${fAt}.label 必须是非空字符串`);
|
|
532
|
+
if (!FIELD_TYPES.includes(field.type)) {
|
|
533
|
+
error(`${fAt}.type 非法: ${JSON.stringify(field.type)}`, `允许: ${FIELD_TYPES.join(", ")}`);
|
|
534
|
+
}
|
|
535
|
+
if (["select", "radio"].includes(field.type)) {
|
|
536
|
+
if (!Array.isArray(field.options) || field.options.length === 0) {
|
|
537
|
+
error(`${fAt} 是 ${field.type},必须提供非空 options`);
|
|
538
|
+
} else {
|
|
539
|
+
field.options.forEach((option, oi) => {
|
|
540
|
+
if (!isPlainObject(option) || typeof option.label !== "string" || option.label === "" || typeof option.value !== "string") {
|
|
541
|
+
error(`${fAt}.options[${oi}] 必须是 { label: 非空字符串, value: 字符串 }`);
|
|
542
|
+
} else if (Object.keys(option).some((k) => !["label", "value"].includes(k))) {
|
|
543
|
+
error(`${fAt}.options[${oi}] 含未知字段`);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
} else if (field.options !== undefined) {
|
|
548
|
+
error(`${fAt} 的 type 是 ${field.type},不允许出现 options`);
|
|
549
|
+
}
|
|
550
|
+
if (field.binding !== undefined) {
|
|
551
|
+
if (!BINDINGS.includes(field.binding)) {
|
|
552
|
+
error(`${fAt}.binding 非法: ${JSON.stringify(field.binding)}`, `允许: ${BINDINGS.join(", ")}`);
|
|
553
|
+
} else {
|
|
554
|
+
if (field.binding === "port" && field.type !== "number") error(`${fAt} binding 为 port 时 type 必须是 number`);
|
|
555
|
+
if (TEXTUAL_BINDINGS.includes(field.binding) && !TEXTUAL_TYPES.includes(field.type)) {
|
|
556
|
+
error(`${fAt} binding 为 ${field.binding} 时 type 只能是 ${TEXTUAL_TYPES.join("/")}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
for (const conditionKey of ["visible_when", "required_when"]) {
|
|
561
|
+
const condition = field[conditionKey];
|
|
562
|
+
if (condition === undefined) continue;
|
|
563
|
+
if (!isPlainObject(condition) || typeof condition.field !== "string" || !Array.isArray(condition.one_of) || condition.one_of.length === 0) {
|
|
564
|
+
error(`${fAt}.${conditionKey} 必须是 { field: string, one_of: [至少 1 项] }`);
|
|
565
|
+
} else if (Object.keys(condition).some((k) => !["field", "one_of"].includes(k))) {
|
|
566
|
+
error(`${fAt}.${conditionKey} 含未知字段`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (contribution.capabilities !== undefined) {
|
|
573
|
+
if (!Array.isArray(contribution.capabilities) || contribution.capabilities.some((c) => !CAPABILITIES.includes(c))) {
|
|
574
|
+
error(`${at}.capabilities 只能是 ${CAPABILITIES.join(" / ")}`);
|
|
575
|
+
} else if (new Set(contribution.capabilities).size !== contribution.capabilities.length) {
|
|
576
|
+
error(`${at}.capabilities 不能有重复项`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (contribution.actions !== undefined) {
|
|
581
|
+
if (!Array.isArray(contribution.actions)) error(`${at}.actions 必须是数组`);
|
|
582
|
+
else {
|
|
583
|
+
const actionIds = new Set();
|
|
584
|
+
contribution.actions.forEach((action, ai) => {
|
|
585
|
+
const aAt = `${at}.actions[${ai}]`;
|
|
586
|
+
if (!isPlainObject(action)) {
|
|
587
|
+
error(`${aAt} 必须是对象`);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const actionAllowed = [
|
|
591
|
+
"id",
|
|
592
|
+
"label",
|
|
593
|
+
"description",
|
|
594
|
+
"variant",
|
|
595
|
+
"when",
|
|
596
|
+
"close_on_success",
|
|
597
|
+
"requires_valid_form",
|
|
598
|
+
"timeout_ms",
|
|
599
|
+
];
|
|
600
|
+
const actionExtra = Object.keys(action).filter((k) => !actionAllowed.includes(k));
|
|
601
|
+
if (actionExtra.length) error(`${aAt} 含未知字段: ${actionExtra.join(", ")}`);
|
|
602
|
+
if (typeof action.id !== "string" || !IDENTIFIER.test(action.id)) error(`${aAt}.id 非法`);
|
|
603
|
+
else if (actionIds.has(action.id)) error(`${aAt}.id 重复`);
|
|
604
|
+
else actionIds.add(action.id);
|
|
605
|
+
if (typeof action.label !== "string" || action.label === "") error(`${aAt}.label 必须是非空字符串`);
|
|
606
|
+
if (action.variant !== undefined && !ACTION_VARIANTS.includes(action.variant)) {
|
|
607
|
+
error(`${aAt}.variant 非法: ${JSON.stringify(action.variant)}`);
|
|
608
|
+
}
|
|
609
|
+
if (action.when !== undefined && !ACTION_WHEN.includes(action.when)) {
|
|
610
|
+
error(`${aAt}.when 非法: ${JSON.stringify(action.when)}`);
|
|
611
|
+
}
|
|
612
|
+
if (
|
|
613
|
+
action.timeout_ms !== undefined &&
|
|
614
|
+
(!Number.isInteger(action.timeout_ms) || action.timeout_ms < 1 || action.timeout_ms > 120000)
|
|
615
|
+
) {
|
|
616
|
+
error(`${aAt}.timeout_ms 必须是 1–120000 的整数`);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
for (const refKey of ["workbench", "filesystem_provider"]) {
|
|
623
|
+
const ref = contribution[refKey];
|
|
624
|
+
if (ref === undefined) continue;
|
|
625
|
+
if (typeof ref !== "string" || !IDENTIFIER.test(ref)) error(`${at}.${refKey} 非法: ${JSON.stringify(ref)}`);
|
|
626
|
+
else usage.add(ref);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function checkFilesystemProvider(contribution, at) {
|
|
631
|
+
const allowed = ["type", "id", "label", "description", "icon", "schemes", "root_uri", "capabilities"];
|
|
632
|
+
const extra = Object.keys(contribution).filter((k) => !allowed.includes(k));
|
|
633
|
+
if (extra.length) error(`${at} 含未知字段: ${extra.join(", ")}`);
|
|
634
|
+
|
|
635
|
+
if (!Array.isArray(contribution.schemes) || contribution.schemes.length === 0) {
|
|
636
|
+
error(`${at}.schemes 是必需字段且必须是非空数组`);
|
|
637
|
+
} else {
|
|
638
|
+
for (const scheme of contribution.schemes) {
|
|
639
|
+
if (typeof scheme !== "string" || !/^[a-z0-9][a-z0-9._:-]*$/.test(scheme)) {
|
|
640
|
+
error(`${at}.schemes 含非法项: ${JSON.stringify(scheme)}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (new Set(contribution.schemes).size !== contribution.schemes.length) {
|
|
644
|
+
error(`${at}.schemes 不能有重复项`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (contribution.root_uri !== undefined) {
|
|
648
|
+
const uri = contribution.root_uri;
|
|
649
|
+
if (typeof uri !== "string" || uri.length < 2 || uri.length > 4096 || !/^[a-z0-9._-]+:.+$/.test(uri)) {
|
|
650
|
+
error(`${at}.root_uri 非法: ${JSON.stringify(uri)}`, '形如 "s3://bucket"。');
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (contribution.capabilities !== undefined) {
|
|
654
|
+
if (
|
|
655
|
+
!Array.isArray(contribution.capabilities) ||
|
|
656
|
+
contribution.capabilities.some((c) => !FS_CAPABILITIES.includes(c))
|
|
657
|
+
) {
|
|
658
|
+
error(`${at}.capabilities 只能是 ${FS_CAPABILITIES.join(" / ")}`);
|
|
659
|
+
} else if (new Set(contribution.capabilities).size !== contribution.capabilities.length) {
|
|
660
|
+
error(`${at}.capabilities 不能有重复项`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function checkLocalizations(manifest) {
|
|
666
|
+
const localizations = manifest.localizations;
|
|
667
|
+
if (localizations === undefined) return;
|
|
668
|
+
if (!isPlainObject(localizations)) {
|
|
669
|
+
error("localizations 必须是对象");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
for (const [locale, entry] of Object.entries(localizations)) {
|
|
673
|
+
if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(locale)) {
|
|
674
|
+
warn(`localizations 的 locale "${locale}" 不符合常见 BCP-47 形态`);
|
|
675
|
+
}
|
|
676
|
+
if (!isPlainObject(entry)) {
|
|
677
|
+
error(`localizations["${locale}"] 必须是对象`);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
const extra = Object.keys(entry).filter((k) => !["name", "description", "contributions"].includes(k));
|
|
681
|
+
if (extra.length) error(`localizations["${locale}"] 含未知字段: ${extra.join(", ")}`);
|
|
682
|
+
if (entry.contributions === undefined) continue;
|
|
683
|
+
if (!isPlainObject(entry.contributions)) {
|
|
684
|
+
error(`localizations["${locale}"].contributions 必须是对象`);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
for (const [id, override] of Object.entries(entry.contributions)) {
|
|
688
|
+
const at = `localizations["${locale}"].contributions["${id}"]`;
|
|
689
|
+
if (!isPlainObject(override)) {
|
|
690
|
+
error(`${at} 必须是对象`);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
const overrideExtra = Object.keys(override).filter(
|
|
694
|
+
(k) => !["label", "description", "fields", "actions"].includes(k),
|
|
695
|
+
);
|
|
696
|
+
if (overrideExtra.length) error(`${at} 含未知字段: ${overrideExtra.join(", ")}`);
|
|
697
|
+
if (override.fields !== undefined && !isPlainObject(override.fields)) error(`${at}.fields 必须是对象`);
|
|
698
|
+
if (override.actions !== undefined && !isPlainObject(override.actions)) error(`${at}.actions 必须是对象`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function checkToml(toml) {
|
|
704
|
+
if (toml === undefined) {
|
|
705
|
+
error("缺少 dbx-plugin.toml", "该文件决定打包包含哪些目录与是否构建原生后端。");
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (toml.schema_version !== 1) {
|
|
709
|
+
error(`dbx-plugin.toml 的 schema_version 必须是 1(当前 ${JSON.stringify(toml.schema_version)})`);
|
|
710
|
+
}
|
|
711
|
+
const topExtra = Object.keys(toml).filter((k) => !["schema_version", "backend", "package", "dev"].includes(k));
|
|
712
|
+
if (topExtra.length) warn(`dbx-plugin.toml 含未知顶层键: ${topExtra.join(", ")}`);
|
|
713
|
+
|
|
714
|
+
if (toml.backend !== undefined) {
|
|
715
|
+
const backend = toml.backend;
|
|
716
|
+
if (!isPlainObject(backend)) {
|
|
717
|
+
error("dbx-plugin.toml 的 [backend] 必须是表");
|
|
718
|
+
} else {
|
|
719
|
+
const lang = backend.language;
|
|
720
|
+
if (!["rust", "go", "golang"].includes(lang)) {
|
|
721
|
+
error(`[backend].language 非法: ${JSON.stringify(lang)}`, "允许 rust / go / golang。");
|
|
722
|
+
}
|
|
723
|
+
const backendExtra = Object.keys(backend).filter((k) => !["language", "directory", "binary"].includes(k));
|
|
724
|
+
if (backendExtra.length) warn(`[backend] 含未知键: ${backendExtra.join(", ")}`);
|
|
725
|
+
const dirReason = safeRelativeReason(backend.directory);
|
|
726
|
+
if (dirReason) error(`[backend].directory 非法: ${dirReason}`);
|
|
727
|
+
if (typeof backend.binary !== "string" || backend.binary === "" || /[\\/]/.test(backend.binary) || backend.binary === "." || backend.binary === "..") {
|
|
728
|
+
error(`[backend].binary 必须是纯文件名: ${JSON.stringify(backend.binary)}`);
|
|
729
|
+
}
|
|
730
|
+
const dir = typeof backend.directory === "string" ? backend.directory : "backend";
|
|
731
|
+
if (lang === "rust" && !state.files.has(posix.join(dir, "Cargo.toml"))) {
|
|
732
|
+
error(`Rust 后端缺少 ${dir}/Cargo.toml`);
|
|
733
|
+
}
|
|
734
|
+
if ((lang === "go" || lang === "golang") && !state.files.has(posix.join(dir, "go.mod"))) {
|
|
735
|
+
error(`Go 后端缺少 ${dir}/go.mod`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (toml.package === undefined || !isPlainObject(toml.package)) {
|
|
741
|
+
error("dbx-plugin.toml 缺少 [package] 段(需要 include)");
|
|
742
|
+
} else if (!Array.isArray(toml.package.include)) {
|
|
743
|
+
error("[package].include 必须是字符串数组");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (toml.dev !== undefined) {
|
|
747
|
+
if (!isPlainObject(toml.dev)) {
|
|
748
|
+
error("dbx-plugin.toml 的 [dev] 必须是表");
|
|
749
|
+
} else {
|
|
750
|
+
const devExtra = Object.keys(toml.dev).filter((k) => !["ui_build", "ui_watch"].includes(k));
|
|
751
|
+
if (devExtra.length) {
|
|
752
|
+
error(
|
|
753
|
+
`[dev] 含未支持的键: ${devExtra.join(", ")}`,
|
|
754
|
+
"[dev] 使用 deny_unknown_fields,只允许 ui_build / ui_watch。",
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
for (const key of ["ui_build", "ui_watch"]) {
|
|
758
|
+
const value = toml.dev[key];
|
|
759
|
+
if (value === undefined) continue;
|
|
760
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
761
|
+
error(`[dev].${key} 必须是字符串数组(命令按参数数组执行,不经过 shell)`);
|
|
762
|
+
} else if (value.length > 0 && value[0].trim() === "") {
|
|
763
|
+
error(`[dev].${key} 的第一项必须是非空可执行文件名`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function checkIncludes(toml) {
|
|
771
|
+
const include = Array.isArray(toml?.package?.include) ? toml.package.include : [];
|
|
772
|
+
for (const entry of include) {
|
|
773
|
+
const reason = safeRelativeReason(entry);
|
|
774
|
+
if (reason) {
|
|
775
|
+
error(`[package].include 项 "${entry}" 非法: ${reason}`);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const segments = entry.split(/[\\/]+/);
|
|
779
|
+
if (segments.includes(".dbx-dev")) {
|
|
780
|
+
error(`[package].include 不能包含 .dbx-dev: ${entry}`, "打包会直接拒绝开发数据目录。");
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
const exists =
|
|
784
|
+
state.files.has(entry) ||
|
|
785
|
+
state.dirs.has(entry) ||
|
|
786
|
+
[...state.files].some((f) => f.startsWith(`${entry}/`));
|
|
787
|
+
if (!exists) error(`[package].include 项 "${entry}" 在项目中不存在`);
|
|
788
|
+
}
|
|
789
|
+
// 开发数据泄露检查
|
|
790
|
+
const devFiles = [...state.files].filter((f) => f === ".dbx-dev" || f.startsWith(".dbx-dev/"));
|
|
791
|
+
if (devFiles.length && include.some((inc) => inc === ".dbx-dev" || ".dbx-dev".startsWith(`${inc}/`))) {
|
|
792
|
+
error("`.dbx-dev/` 会被打进包", "把 `.dbx-dev/` 加入 .gitignore 并从 [package].include 移除。");
|
|
793
|
+
}
|
|
794
|
+
if (devFiles.length && !existsSync(join(state.dir, ".gitignore"))) {
|
|
795
|
+
warn("项目存在 `.dbx-dev/` 但没有 .gitignore", "该目录可能含明文凭据,请加入 .gitignore。");
|
|
796
|
+
} else if (devFiles.length) {
|
|
797
|
+
const gitignore = readFileSync(join(state.dir, ".gitignore"), "utf8");
|
|
798
|
+
if (!gitignore.includes(".dbx-dev")) warn("`.dbx-dev/` 未出现在 .gitignore 中", "它可能含明文凭据。");
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ---------------------------------------------------------------- 主流程
|
|
803
|
+
|
|
804
|
+
const state = { dir: process.cwd(), files: new Set(), dirs: new Set(), include: [], toml: undefined };
|
|
805
|
+
|
|
806
|
+
function main() {
|
|
807
|
+
const args = process.argv.slice(2);
|
|
808
|
+
const positional = [];
|
|
809
|
+
let json = false;
|
|
810
|
+
for (const arg of args) {
|
|
811
|
+
if (arg === "--json") {
|
|
812
|
+
json = true;
|
|
813
|
+
jsonMode = true;
|
|
814
|
+
} else if (arg === "--quiet") quiet = true;
|
|
815
|
+
else if (arg === "-h" || arg === "--help") {
|
|
816
|
+
process.stdout.write(
|
|
817
|
+
"用法: node check-project.mjs [项目目录] [--json] [--quiet]\n\n" +
|
|
818
|
+
"打包前预检 DBX 插件项目:manifest.json / dbx-plugin.toml / include 覆盖 / 资源存在性。\n",
|
|
819
|
+
);
|
|
820
|
+
return;
|
|
821
|
+
} else if (arg.startsWith("-")) {
|
|
822
|
+
process.stderr.write(`未知选项: ${arg}\n`);
|
|
823
|
+
process.exit(2);
|
|
824
|
+
} else positional.push(arg);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
let dir = resolve(positional[0] ?? ".");
|
|
828
|
+
if (!existsSync(dir)) {
|
|
829
|
+
process.stderr.write(`目录不存在: ${dir}\n`);
|
|
830
|
+
process.exit(2);
|
|
831
|
+
}
|
|
832
|
+
if (!statSync(dir).isDirectory()) {
|
|
833
|
+
process.stderr.write(`不是目录: ${dir}\n`);
|
|
834
|
+
process.exit(2);
|
|
835
|
+
}
|
|
836
|
+
state.dir = dir;
|
|
837
|
+
|
|
838
|
+
if (!existsSync(join(dir, "manifest.json"))) {
|
|
839
|
+
error(`缺少 manifest.json(${dir})`, "manifest.json 必须位于插件包根目录。是否为项目目录?");
|
|
840
|
+
finish(json);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
try {
|
|
845
|
+
state.files = new Set(walkFiles(dir));
|
|
846
|
+
state.dirs = new Set(walkDirs(dir));
|
|
847
|
+
} catch (err) {
|
|
848
|
+
error(`遍历项目文件失败: ${err.message}`);
|
|
849
|
+
finish(json);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
// 不把 dist / node_modules / target 当作项目内容,但保留用于 include 校验的判断
|
|
853
|
+
const ignored = ["dist", "node_modules", "target", ".git", ".dbx-dev"];
|
|
854
|
+
const notIgnored = (f) => !ignored.some((prefix) => f === prefix || f.startsWith(`${prefix}/`));
|
|
855
|
+
state.files = new Set([...state.files].filter(notIgnored));
|
|
856
|
+
state.dirs = new Set([...state.dirs].filter(notIgnored));
|
|
857
|
+
|
|
858
|
+
let manifest;
|
|
859
|
+
try {
|
|
860
|
+
manifest = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
|
|
861
|
+
if (!isPlainObject(manifest)) {
|
|
862
|
+
error("manifest.json 顶层必须是对象");
|
|
863
|
+
finish(json);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
} catch (err) {
|
|
867
|
+
error(`manifest.json 解析失败: ${err.message}`);
|
|
868
|
+
finish(json);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
let toml;
|
|
873
|
+
if (existsSync(join(dir, "dbx-plugin.toml"))) {
|
|
874
|
+
try {
|
|
875
|
+
toml = parseToml(readFileSync(join(dir, "dbx-plugin.toml"), "utf8"));
|
|
876
|
+
} catch (err) {
|
|
877
|
+
error(`dbx-plugin.toml 解析失败: ${err.message}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
state.toml = toml;
|
|
881
|
+
state.include = Array.isArray(toml?.package?.include) ? toml.package.include : [];
|
|
882
|
+
|
|
883
|
+
checkToml(toml);
|
|
884
|
+
if (toml) checkIncludes(toml);
|
|
885
|
+
checkManifest(dir, manifest);
|
|
886
|
+
|
|
887
|
+
finish(json);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function finish(json) {
|
|
891
|
+
const errors = findings.filter((f) => f.level === "error");
|
|
892
|
+
const warnings = findings.filter((f) => f.level === "warn");
|
|
893
|
+
if (json) {
|
|
894
|
+
process.stdout.write(
|
|
895
|
+
`${JSON.stringify(
|
|
896
|
+
{
|
|
897
|
+
ok: errors.length === 0,
|
|
898
|
+
directory: state.dir,
|
|
899
|
+
errors: errors.length,
|
|
900
|
+
warnings: warnings.length,
|
|
901
|
+
findings,
|
|
902
|
+
},
|
|
903
|
+
null,
|
|
904
|
+
2,
|
|
905
|
+
)}\n`,
|
|
906
|
+
);
|
|
907
|
+
} else if (!quiet) {
|
|
908
|
+
process.stdout.write(
|
|
909
|
+
`\n预检完成: ${errors.length} 个错误, ${warnings.length} 个警告(${state.dir})\n`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
process.exit(errors.length === 0 ? 0 : 1);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
main();
|