@international-iot-association/plugin-cli 1.0.0-rc.1
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/dist/cli.js +961 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +507 -0
- package/package.json +95 -0
- package/src/check.d.mts +8 -0
- package/src/check.mjs +71 -0
- package/src/manifest-schema.d.mts +9 -0
- package/src/manifest-schema.mjs +224 -0
- package/src/new.ts +220 -0
- package/src/registry.ts +46 -0
- package/src/smoke.d.mts +49 -0
- package/src/smoke.mjs +413 -0
- package/src/stage.ts +86 -0
- package/src/test-runner.d.mts +11 -0
- package/src/test-runner.mjs +82 -0
- package/src/zip.ts +30 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** 插件 manifest.json 的形状,与仓内 tools/pack.ts 的 Manifest 接口一致。 */
|
|
2
|
+
interface PluginManifest {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
kind?: "station" | "global";
|
|
8
|
+
stationTypes: string[];
|
|
9
|
+
/** 可选展示元数据;写进 registry 片段时归一化为 []。 */
|
|
10
|
+
models?: string[];
|
|
11
|
+
agentApi: string;
|
|
12
|
+
permissions: string[];
|
|
13
|
+
autoStart?: boolean;
|
|
14
|
+
dependencies?: Record<string, string>;
|
|
15
|
+
/** kind=global 时可省略(无界面后台插件,不打包 ui/)。 */
|
|
16
|
+
uiEntry?: string;
|
|
17
|
+
runtimeEntry: string;
|
|
18
|
+
}
|
|
19
|
+
/** 读取并严格校验单个插件目录的 manifest.json(fail-closed,校验失败即抛错)。 */
|
|
20
|
+
declare function readPluginManifest(pluginDir: string): PluginManifest;
|
|
21
|
+
/**
|
|
22
|
+
* 把单个插件目录的构建产物组装进 stagingDir:
|
|
23
|
+
* manifest.json -> 根;schemas/ -> schemas/;ui-dist/ -> ui/;runtime-dist/ -> runtime/。
|
|
24
|
+
* 同时做入口存在性断言(uiEntry/runtimeEntry 在 staging 内必须能解析到)与产物单 React 断言。
|
|
25
|
+
* 只清理并写入调用方传入的 stagingDir,不触碰任何全仓目录。
|
|
26
|
+
*/
|
|
27
|
+
declare function stagePlugin(pluginDir: string, stagingDir: string): PluginManifest;
|
|
28
|
+
|
|
29
|
+
interface ZipResult {
|
|
30
|
+
zipPath: string;
|
|
31
|
+
sha256: string;
|
|
32
|
+
sizeBytes: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 把 stagingDir 的内容打到 zip 根目录(过滤 .DS_Store),写到 outZip,
|
|
36
|
+
* 返回 zip 路径、sha256、字节数。
|
|
37
|
+
*
|
|
38
|
+
* 注意:AdmZip.addLocalFolder 的条目带 mtime,且调用方通常会在 registry 片段里
|
|
39
|
+
* 写入 generatedAt,所以同一份插件连打两次 sha256 必不同——这是预期行为,
|
|
40
|
+
* 不是本函数的 bug(等价性验收走「解包后逐文件 sha256」,不是「同 sha256」)。
|
|
41
|
+
*/
|
|
42
|
+
declare function zipStaging(stagingDir: string, outZip: string): ZipResult;
|
|
43
|
+
|
|
44
|
+
interface RegistryEntry {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
version: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
kind?: "station" | "global";
|
|
50
|
+
stationTypes: string[];
|
|
51
|
+
models: string[];
|
|
52
|
+
agentApi: string;
|
|
53
|
+
permissions: string[];
|
|
54
|
+
autoStart?: boolean;
|
|
55
|
+
dependencies?: Record<string, string>;
|
|
56
|
+
fileName: string;
|
|
57
|
+
sha256: string;
|
|
58
|
+
sizeBytes: number;
|
|
59
|
+
}
|
|
60
|
+
/** 由 manifest + zip 结果组装单插件 registry 片段(不含 signature,不含顶层 generatedAt)。 */
|
|
61
|
+
declare function buildRegistryEntry(manifest: PluginManifest, fileName: string, sha256: string, sizeBytes: number): RegistryEntry;
|
|
62
|
+
|
|
63
|
+
// check.mjs 的类型声明(供 tools/verify.mjs、tools/pack.ts 等 TS/JS 消费者取类型)。
|
|
64
|
+
interface CheckResult {
|
|
65
|
+
ok: boolean;
|
|
66
|
+
errors: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
declare function isEmptyTestScript(script: unknown): boolean;
|
|
70
|
+
declare function checkPlugin(pluginDir: string): CheckResult;
|
|
71
|
+
|
|
72
|
+
interface ScaffoldOptions {
|
|
73
|
+
/** plugins/ 下的目标目录名(安全字符集)。 */
|
|
74
|
+
dirName: string;
|
|
75
|
+
/** 插件显示名,用于替换 __PLUGIN_NAME__。 */
|
|
76
|
+
displayName: string;
|
|
77
|
+
/** 插件 id,用于替换 __PLUGIN_ID__ 与 package.json.name。 */
|
|
78
|
+
id: string;
|
|
79
|
+
/** 模板来源:目录路径 / .tgz 路径;缺省时从 npm 拉取默认模板包。 */
|
|
80
|
+
template?: string;
|
|
81
|
+
/**
|
|
82
|
+
* 默认模板包的版本或 dist-tag(仅在未给 template 时生效)。缺省按本 CLI 自身
|
|
83
|
+
* 版本推:预发布版(含 "-")取 "next",正式版取 "latest"——npm 的 "*" 永远不
|
|
84
|
+
* 匹配预发布版本,rc 阶段不显式给 spec 会 ETARGET(3.0.0-rc.1 实测)。
|
|
85
|
+
*/
|
|
86
|
+
templateVersion?: string;
|
|
87
|
+
/** 目标父目录(生成到 <outParentDir>/<dirName>);缺省为 cwd 下 plugins/。 */
|
|
88
|
+
outParentDir: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 取路径最后一段——必须同时兼容 POSIX 与 Windows 分隔符。
|
|
92
|
+
* 原实现用 `src.split("/").pop()`,在 Windows 上传入反斜杠路径时不会真正
|
|
93
|
+
* 按分隔符切开,导致 SKIP 目录名恒不命中、node_modules 等构建产物被一并
|
|
94
|
+
* 拷进生成的插件目录(Windows 路径 bug,WP-3b 明确要求修复)。
|
|
95
|
+
*/
|
|
96
|
+
declare function lastPathSegment(path: string): string;
|
|
97
|
+
/** cpSync filter:跳过构建产物与依赖目录,需兼容 Windows 反斜杠路径。 */
|
|
98
|
+
declare function shouldSkipCopy(src: string): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* 决定默认模板包的版本 spec:显式给了就用显式的;否则 CLI 自身是预发布版
|
|
101
|
+
* 取 dist-tag "next"(发布手册约定 rc 一律打 next),正式版取 "latest"。
|
|
102
|
+
*/
|
|
103
|
+
declare function resolveDefaultTemplateSpec(cliVersion: string, override?: string): string;
|
|
104
|
+
/**
|
|
105
|
+
* 生成一个新插件目录。
|
|
106
|
+
* 校验顺序:dirName 安全字符 -> id 合法 -> 目标未占用 -> 拷贝模板 -> 占位符替换 ->
|
|
107
|
+
* package.json.name 写回 -> 生成产物的 manifest.json 严格自检(不通过则回滚删除
|
|
108
|
+
* 已生成目录,避免留下半成品)。成功时返回生成目录的绝对路径。
|
|
109
|
+
*/
|
|
110
|
+
declare function scaffoldPlugin(opts: ScaffoldOptions): string;
|
|
111
|
+
|
|
112
|
+
// test-runner.mjs 的类型声明。
|
|
113
|
+
interface TestRunResult {
|
|
114
|
+
ok: boolean;
|
|
115
|
+
testCount: number | null;
|
|
116
|
+
output: string;
|
|
117
|
+
error?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
declare function parseTapTestCount(output: string): number | null;
|
|
121
|
+
declare function runPluginTestSuite(pluginDir: string): TestRunResult;
|
|
122
|
+
|
|
123
|
+
export { type CheckResult, type PluginManifest, type RegistryEntry, type ScaffoldOptions, type TestRunResult, type ZipResult, buildRegistryEntry, checkPlugin, isEmptyTestScript, lastPathSegment, parseTapTestCount, readPluginManifest, resolveDefaultTemplateSpec, runPluginTestSuite, scaffoldPlugin, shouldSkipCopy, stagePlugin, zipStaging };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
// src/stage.ts
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { assertPluginUiSingleReact } from "@international-iot-association/plugin-vite-config";
|
|
5
|
+
|
|
6
|
+
// src/manifest-schema.mjs
|
|
7
|
+
var SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
8
|
+
var PLUGIN_ID_RE = /^[a-z][a-z0-9]*(\.[a-z0-9][a-z0-9-]*)+$/;
|
|
9
|
+
var PERMISSION_RE = /^[a-z][a-z0-9]*([.:][a-z0-9][a-z0-9-]*)*$/;
|
|
10
|
+
var DEP_RANGE_RE = /^[0-9a-zA-Z.^~*<>=| -]+$/;
|
|
11
|
+
var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
12
|
+
"con",
|
|
13
|
+
"prn",
|
|
14
|
+
"aux",
|
|
15
|
+
"nul",
|
|
16
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
17
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`)
|
|
18
|
+
]);
|
|
19
|
+
var KNOWN_FIELDS = /* @__PURE__ */ new Set([
|
|
20
|
+
"id",
|
|
21
|
+
"name",
|
|
22
|
+
"version",
|
|
23
|
+
"description",
|
|
24
|
+
"kind",
|
|
25
|
+
"stationTypes",
|
|
26
|
+
"models",
|
|
27
|
+
"agentApi",
|
|
28
|
+
"uiEntry",
|
|
29
|
+
"runtimeEntry",
|
|
30
|
+
"permissions",
|
|
31
|
+
"autoStart",
|
|
32
|
+
"dependencies",
|
|
33
|
+
"resultSchema",
|
|
34
|
+
"steps",
|
|
35
|
+
"checksum",
|
|
36
|
+
"storage",
|
|
37
|
+
"readinessTimeoutMs"
|
|
38
|
+
]);
|
|
39
|
+
function isStrictSemver(value) {
|
|
40
|
+
return typeof value === "string" && SEMVER_RE.test(value);
|
|
41
|
+
}
|
|
42
|
+
function isValidPluginId(value) {
|
|
43
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 128) return false;
|
|
44
|
+
if (!PLUGIN_ID_RE.test(value)) return false;
|
|
45
|
+
const firstSegment = value.split(".", 1)[0];
|
|
46
|
+
if (WINDOWS_RESERVED_NAMES.has(firstSegment.toLowerCase())) return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
function isSafeRelativeEntry(value) {
|
|
50
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 512) return false;
|
|
51
|
+
if (value.includes("\\") || value.includes("\0")) return false;
|
|
52
|
+
if (value.startsWith("/") || /^[a-zA-Z]:/.test(value) || value.startsWith("//")) return false;
|
|
53
|
+
const segments = value.split("/");
|
|
54
|
+
for (const segment of segments) {
|
|
55
|
+
if (segment === "" || segment === "." || segment === "..") return false;
|
|
56
|
+
if (segment !== segment.trim() || segment.endsWith(".")) return false;
|
|
57
|
+
const stem = segment.split(".", 1)[0];
|
|
58
|
+
if (WINDOWS_RESERVED_NAMES.has(stem.toLowerCase())) return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
function isStringArray(value) {
|
|
63
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
64
|
+
}
|
|
65
|
+
function validatePluginManifest(manifest) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
const push = (msg) => errors.push(msg);
|
|
68
|
+
if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
69
|
+
return { ok: false, errors: ["manifest \u5FC5\u987B\u662F JSON object"] };
|
|
70
|
+
}
|
|
71
|
+
const m = (
|
|
72
|
+
/** @type {Record<string, unknown>} */
|
|
73
|
+
manifest
|
|
74
|
+
);
|
|
75
|
+
for (const key of Object.keys(m)) {
|
|
76
|
+
if (!KNOWN_FIELDS.has(key))
|
|
77
|
+
push(
|
|
78
|
+
`\u672A\u77E5\u5B57\u6BB5 "${key}"\uFF08fail-closed\uFF1A\u65B0\u5B57\u6BB5\u9700\u540C\u6B65\u66F4\u65B0 manifest-schema \u4E0E plugin-contracts\uFF09`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (!isValidPluginId(m.id))
|
|
82
|
+
push(`id \u975E\u6CD5\uFF1A"${m.id}"\uFF08\u8981\u6C42\u5168\u5C0F\u5199\u3001\u81F3\u5C11\u4E24\u6BB5\u3001\u65E0\u4FDD\u7559\u540D\uFF0C\u5982 rti.demo.example\uFF09`);
|
|
83
|
+
if (typeof m.name !== "string" || m.name.trim() === "") push("name \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32");
|
|
84
|
+
if (!isStrictSemver(m.version)) push(`version \u5FC5\u987B\u662F strict SemVer\uFF1A"${m.version}"`);
|
|
85
|
+
if (!isStrictSemver(m.agentApi)) push(`agentApi \u5FC5\u987B\u662F strict SemVer\uFF1A"${m.agentApi}"`);
|
|
86
|
+
const kind = m.kind ?? "station";
|
|
87
|
+
if (kind !== "station" && kind !== "global") push(`kind \u53EA\u80FD\u662F station/global\uFF1A"${m.kind}"`);
|
|
88
|
+
if (!isStringArray(m.stationTypes)) push("stationTypes \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\u6570\u7EC4\uFF08\u53EF\u4E3A\u7A7A\u6570\u7EC4\uFF09");
|
|
89
|
+
if (m.models !== void 0 && !isStringArray(m.models))
|
|
90
|
+
push("models \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\u6570\u7EC4\uFF08\u53EF\u7701\u7565\uFF1B\u7701\u7565\u7B49\u4EF7\u4E8E\u7A7A\u6570\u7EC4\uFF09");
|
|
91
|
+
if (!isSafeRelativeEntry(m.runtimeEntry)) push(`runtimeEntry \u975E\u6CD5\uFF1A"${m.runtimeEntry}"`);
|
|
92
|
+
if (kind === "station") {
|
|
93
|
+
if (!isSafeRelativeEntry(m.uiEntry)) push(`station \u63D2\u4EF6\u5FC5\u987B\u63D0\u4F9B\u5408\u6CD5 uiEntry\uFF1A"${m.uiEntry}"`);
|
|
94
|
+
} else if (m.uiEntry !== void 0 && !isSafeRelativeEntry(m.uiEntry)) {
|
|
95
|
+
push(`uiEntry \u975E\u6CD5\uFF1A"${m.uiEntry}"`);
|
|
96
|
+
}
|
|
97
|
+
if (!isStringArray(m.permissions)) {
|
|
98
|
+
push("permissions \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\u6570\u7EC4\uFF08\u53EF\u4E3A\u7A7A\u6570\u7EC4\uFF09");
|
|
99
|
+
} else {
|
|
100
|
+
for (const perm of m.permissions) {
|
|
101
|
+
if (!PERMISSION_RE.test(perm)) push(`permission \u975E\u6CD5\uFF1A"${perm}"`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (m.autoStart !== void 0 && typeof m.autoStart !== "boolean")
|
|
105
|
+
push("autoStart \u5FC5\u987B\u662F boolean");
|
|
106
|
+
if (m.description !== void 0 && typeof m.description !== "string")
|
|
107
|
+
push("description \u5FC5\u987B\u662F\u5B57\u7B26\u4E32");
|
|
108
|
+
if (m.checksum !== void 0 && typeof m.checksum !== "string") push("checksum \u5FC5\u987B\u662F\u5B57\u7B26\u4E32");
|
|
109
|
+
if (m.storage !== void 0) {
|
|
110
|
+
if (m.storage === null || typeof m.storage !== "object" || Array.isArray(m.storage)) {
|
|
111
|
+
push("storage \u5FC5\u987B\u662F { scope?: 'version' | 'shared', migrateLegacy?: boolean } \u5BF9\u8C61");
|
|
112
|
+
} else {
|
|
113
|
+
for (const key of Object.keys(m.storage)) {
|
|
114
|
+
if (key !== "scope" && key !== "migrateLegacy") {
|
|
115
|
+
push(`storage \u672A\u77E5\u5B57\u6BB5 "${key}"\uFF08\u53EA\u5141\u8BB8 scope/migrateLegacy\uFF09`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const scope = m.storage.scope;
|
|
119
|
+
if (scope !== void 0 && scope !== "version" && scope !== "shared") {
|
|
120
|
+
push(`storage.scope \u53EA\u80FD\u662F version/shared\uFF1A"${scope}"`);
|
|
121
|
+
}
|
|
122
|
+
if (m.storage.migrateLegacy !== void 0 && typeof m.storage.migrateLegacy !== "boolean") {
|
|
123
|
+
push(`storage.migrateLegacy \u5FC5\u987B\u662F boolean\uFF1A"${m.storage.migrateLegacy}"`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (m.readinessTimeoutMs !== void 0) {
|
|
128
|
+
if (typeof m.readinessTimeoutMs !== "number" || !Number.isInteger(m.readinessTimeoutMs) || m.readinessTimeoutMs <= 0) {
|
|
129
|
+
push(`readinessTimeoutMs \u5FC5\u987B\u662F\u6B63\u6574\u6570\u6BEB\u79D2\u6570\uFF1A"${m.readinessTimeoutMs}"`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (m.resultSchema !== void 0 && !isSafeRelativeEntry(m.resultSchema)) {
|
|
133
|
+
push(`resultSchema \u975E\u6CD5\uFF1A"${m.resultSchema}"`);
|
|
134
|
+
}
|
|
135
|
+
if (m.dependencies !== void 0) {
|
|
136
|
+
if (m.dependencies === null || typeof m.dependencies !== "object" || Array.isArray(m.dependencies)) {
|
|
137
|
+
push("dependencies \u5FC5\u987B\u662F { pluginId: versionRange } \u5BF9\u8C61");
|
|
138
|
+
} else {
|
|
139
|
+
for (const [depId, range] of Object.entries(m.dependencies)) {
|
|
140
|
+
if (!isValidPluginId(depId)) push(`dependencies \u952E\u975E\u6CD5\uFF1A"${depId}"`);
|
|
141
|
+
if (typeof range !== "string" || range.trim() === "" || !DEP_RANGE_RE.test(range)) {
|
|
142
|
+
push(`dependencies["${depId}"] \u7248\u672C\u8303\u56F4\u975E\u6CD5\uFF1A"${range}"`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (m.steps !== void 0) {
|
|
148
|
+
if (!Array.isArray(m.steps)) {
|
|
149
|
+
push("steps \u5FC5\u987B\u662F\u6570\u7EC4");
|
|
150
|
+
} else {
|
|
151
|
+
m.steps.forEach((step, i) => {
|
|
152
|
+
if (step === null || typeof step !== "object" || Array.isArray(step)) {
|
|
153
|
+
push(`steps[${i}] \u5FC5\u987B\u662F\u5BF9\u8C61`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (typeof step.key !== "string" || step.key.trim() === "")
|
|
157
|
+
push(`steps[${i}].key \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32`);
|
|
158
|
+
if (typeof step.label !== "string" || step.label.trim() === "")
|
|
159
|
+
push(`steps[${i}].label \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32`);
|
|
160
|
+
if (step.description !== void 0 && typeof step.description !== "string") {
|
|
161
|
+
push(`steps[${i}].description \u5FC5\u987B\u662F\u5B57\u7B26\u4E32`);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { ok: errors.length === 0, errors };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/stage.ts
|
|
170
|
+
function assertExists(path, label) {
|
|
171
|
+
if (!existsSync(path)) throw new Error(`${label} not found: ${path}`);
|
|
172
|
+
}
|
|
173
|
+
function readPluginManifest(pluginDir) {
|
|
174
|
+
const manifestPath = join(pluginDir, "manifest.json");
|
|
175
|
+
if (!existsSync(manifestPath)) {
|
|
176
|
+
throw new Error(`missing manifest.json in ${pluginDir}`);
|
|
177
|
+
}
|
|
178
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
179
|
+
const { ok, errors } = validatePluginManifest(manifest);
|
|
180
|
+
if (!ok) {
|
|
181
|
+
throw new Error(`manifest \u6821\u9A8C\u5931\u8D25\uFF08${manifestPath}\uFF09\uFF1A
|
|
182
|
+
- ${errors.join("\n - ")}`);
|
|
183
|
+
}
|
|
184
|
+
return manifest;
|
|
185
|
+
}
|
|
186
|
+
function stagePlugin(pluginDir, stagingDir) {
|
|
187
|
+
const manifest = readPluginManifest(pluginDir);
|
|
188
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
189
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
190
|
+
cpSync(join(pluginDir, "manifest.json"), join(stagingDir, "manifest.json"));
|
|
191
|
+
const schemas = join(pluginDir, "schemas");
|
|
192
|
+
if (existsSync(schemas)) cpSync(schemas, join(stagingDir, "schemas"), { recursive: true });
|
|
193
|
+
if (manifest.uiEntry) {
|
|
194
|
+
const uiDist = join(pluginDir, "ui-dist");
|
|
195
|
+
assertExists(uiDist, `ui build output (run "pnpm --filter ${manifest.id} build")`);
|
|
196
|
+
const reactGate = assertPluginUiSingleReact(pluginDir);
|
|
197
|
+
if (!reactGate.ok) {
|
|
198
|
+
throw new Error(reactGate.error);
|
|
199
|
+
}
|
|
200
|
+
cpSync(uiDist, join(stagingDir, "ui"), { recursive: true });
|
|
201
|
+
}
|
|
202
|
+
const runtimeDist = join(pluginDir, "runtime-dist");
|
|
203
|
+
assertExists(runtimeDist, `runtime build output (run "pnpm --filter ${manifest.id} build")`);
|
|
204
|
+
cpSync(runtimeDist, join(stagingDir, "runtime"), { recursive: true });
|
|
205
|
+
if (manifest.uiEntry) assertExists(join(stagingDir, manifest.uiEntry), `uiEntry "${manifest.uiEntry}"`);
|
|
206
|
+
assertExists(join(stagingDir, manifest.runtimeEntry), `runtimeEntry "${manifest.runtimeEntry}"`);
|
|
207
|
+
return manifest;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/zip.ts
|
|
211
|
+
import { createHash } from "crypto";
|
|
212
|
+
import { readFileSync as readFileSync2, statSync } from "fs";
|
|
213
|
+
import AdmZip from "adm-zip";
|
|
214
|
+
function zipStaging(stagingDir, outZip) {
|
|
215
|
+
const zip = new AdmZip();
|
|
216
|
+
zip.addLocalFolder(stagingDir, "", (entryName) => !entryName.includes(".DS_Store"));
|
|
217
|
+
zip.writeZip(outZip);
|
|
218
|
+
const bytes = readFileSync2(outZip);
|
|
219
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
220
|
+
const sizeBytes = statSync(outZip).size;
|
|
221
|
+
return { zipPath: outZip, sha256, sizeBytes };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/registry.ts
|
|
225
|
+
function buildRegistryEntry(manifest, fileName, sha256, sizeBytes) {
|
|
226
|
+
return {
|
|
227
|
+
id: manifest.id,
|
|
228
|
+
name: manifest.name,
|
|
229
|
+
version: manifest.version,
|
|
230
|
+
description: manifest.description,
|
|
231
|
+
kind: manifest.kind,
|
|
232
|
+
stationTypes: manifest.stationTypes,
|
|
233
|
+
models: manifest.models ?? [],
|
|
234
|
+
agentApi: manifest.agentApi,
|
|
235
|
+
permissions: manifest.permissions,
|
|
236
|
+
autoStart: manifest.autoStart,
|
|
237
|
+
dependencies: manifest.dependencies,
|
|
238
|
+
fileName,
|
|
239
|
+
sha256,
|
|
240
|
+
sizeBytes
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/check.mjs
|
|
245
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync } from "fs";
|
|
246
|
+
import { join as join2 } from "path";
|
|
247
|
+
import { assertPluginUiSingleReact as assertPluginUiSingleReact2 } from "@international-iot-association/plugin-vite-config";
|
|
248
|
+
function isEmptyTestScript(script) {
|
|
249
|
+
if (typeof script !== "string") return true;
|
|
250
|
+
const trimmed = script.trim();
|
|
251
|
+
if (trimmed.length === 0) return true;
|
|
252
|
+
return /^(echo\b|true$|exit\s+0$|:$)/.test(trimmed);
|
|
253
|
+
}
|
|
254
|
+
function checkPlugin(pluginDir) {
|
|
255
|
+
const errors = [];
|
|
256
|
+
const manifestPath = join2(pluginDir, "manifest.json");
|
|
257
|
+
if (!existsSync2(manifestPath)) {
|
|
258
|
+
return { ok: false, errors: [`missing manifest.json in ${pluginDir}`] };
|
|
259
|
+
}
|
|
260
|
+
const manifest = JSON.parse(readFileSync3(manifestPath, "utf8"));
|
|
261
|
+
const { ok: manifestOk, errors: manifestErrors } = validatePluginManifest(manifest);
|
|
262
|
+
if (!manifestOk) {
|
|
263
|
+
for (const e of manifestErrors) errors.push(`manifest: ${e}`);
|
|
264
|
+
}
|
|
265
|
+
const pkgPath = join2(pluginDir, "package.json");
|
|
266
|
+
if (!existsSync2(pkgPath)) {
|
|
267
|
+
errors.push("missing package.json");
|
|
268
|
+
} else {
|
|
269
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
270
|
+
if (manifestOk && pkg.name !== manifest.id) {
|
|
271
|
+
errors.push(`package.json.name (${pkg.name}) !== manifest.id (${manifest.id})`);
|
|
272
|
+
}
|
|
273
|
+
if (isEmptyTestScript(pkg.scripts?.test)) {
|
|
274
|
+
errors.push("package.json.scripts.test \u7F3A\u5931\u6216\u662F\u7A7A\u58F3");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const testDir = join2(pluginDir, "test");
|
|
278
|
+
const hasTestFile = existsSync2(testDir) && readdirSync(testDir).some((f) => f.endsWith(".test.mjs"));
|
|
279
|
+
if (!hasTestFile) errors.push("\u7F3A\u5C11 test/*.test.mjs\uFF08\u81F3\u5C11 1 \u4E2A\uFF09");
|
|
280
|
+
if (!existsSync2(join2(pluginDir, "smoke.mjs"))) {
|
|
281
|
+
errors.push("\u7F3A\u5C11 smoke.mjs");
|
|
282
|
+
}
|
|
283
|
+
const reactGate = assertPluginUiSingleReact2(pluginDir);
|
|
284
|
+
if (!reactGate.ok) errors.push(reactGate.error);
|
|
285
|
+
return { ok: errors.length === 0, errors };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/new.ts
|
|
289
|
+
import { execFileSync } from "child_process";
|
|
290
|
+
import {
|
|
291
|
+
cpSync as cpSync2,
|
|
292
|
+
existsSync as existsSync3,
|
|
293
|
+
mkdtempSync,
|
|
294
|
+
readFileSync as readFileSync4,
|
|
295
|
+
readdirSync as readdirSync2,
|
|
296
|
+
rmSync as rmSync2,
|
|
297
|
+
statSync as statSync2,
|
|
298
|
+
writeFileSync
|
|
299
|
+
} from "fs";
|
|
300
|
+
import { tmpdir } from "os";
|
|
301
|
+
import { dirname, join as join3, resolve } from "path";
|
|
302
|
+
import { fileURLToPath } from "url";
|
|
303
|
+
var DEFAULT_TEMPLATE_PACKAGE = "@international-iot-association/plugin-template";
|
|
304
|
+
var SKIP_ENTRIES = /* @__PURE__ */ new Set(["node_modules", "ui-dist", "runtime-dist", ".turbo"]);
|
|
305
|
+
var TEXT_FILE_RE = /\.(ts|tsx|json|css|html|mjs|md)$/;
|
|
306
|
+
var DIR_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
307
|
+
function lastPathSegment(path) {
|
|
308
|
+
const parts = path.split(/[\\/]/);
|
|
309
|
+
return parts[parts.length - 1] ?? "";
|
|
310
|
+
}
|
|
311
|
+
function shouldSkipCopy(src) {
|
|
312
|
+
return SKIP_ENTRIES.has(lastPathSegment(src));
|
|
313
|
+
}
|
|
314
|
+
function assertSafeDirName(dirName) {
|
|
315
|
+
if (!DIR_NAME_RE.test(dirName)) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`\u76EE\u5F55\u540D\u975E\u6CD5\uFF1A"${dirName}"\uFF08\u53EA\u5141\u8BB8\u5C0F\u5199\u5B57\u6BCD/\u6570\u5B57/._-\uFF0C\u4E14\u4E0D\u80FD\u4EE5 . \u6216 - \u5F00\u5934\uFF09`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function extractTarball(tgzPath) {
|
|
322
|
+
const extractDir = mkdtempSync(join3(tmpdir(), "plugin-cli-tpl-extract-"));
|
|
323
|
+
execFileSync("tar", ["-xzf", tgzPath, "-C", extractDir], { stdio: "pipe" });
|
|
324
|
+
const packageDir = join3(extractDir, "package");
|
|
325
|
+
if (!existsSync3(packageDir)) {
|
|
326
|
+
throw new Error(`\u6A21\u677F\u538B\u7F29\u5305\u89E3\u538B\u540E\u672A\u627E\u5230 package/ \u76EE\u5F55\uFF08${tgzPath}\uFF09\uFF1A\u4E0D\u662F\u5408\u6CD5\u7684 npm \u5305\u4EA7\u7269`);
|
|
327
|
+
}
|
|
328
|
+
return packageDir;
|
|
329
|
+
}
|
|
330
|
+
function readOwnVersion() {
|
|
331
|
+
try {
|
|
332
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
333
|
+
const pkg = JSON.parse(readFileSync4(join3(here, "..", "package.json"), "utf8"));
|
|
334
|
+
return typeof pkg.version === "string" ? pkg.version : "";
|
|
335
|
+
} catch {
|
|
336
|
+
return "";
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function resolveDefaultTemplateSpec(cliVersion, override) {
|
|
340
|
+
if (override && override.trim()) return override.trim();
|
|
341
|
+
return cliVersion.includes("-") ? "next" : "latest";
|
|
342
|
+
}
|
|
343
|
+
function fetchDefaultTemplate(templateVersion) {
|
|
344
|
+
const spec = resolveDefaultTemplateSpec(readOwnVersion(), templateVersion);
|
|
345
|
+
const packDestDir = mkdtempSync(join3(tmpdir(), "plugin-cli-tpl-pack-"));
|
|
346
|
+
let stdout;
|
|
347
|
+
try {
|
|
348
|
+
stdout = execFileSync(
|
|
349
|
+
"npm",
|
|
350
|
+
["pack", `${DEFAULT_TEMPLATE_PACKAGE}@${spec}`, "--pack-destination", packDestDir],
|
|
351
|
+
{
|
|
352
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
353
|
+
encoding: "utf8",
|
|
354
|
+
// Windows 上 npm 实际是 npm.cmd,不带 shell 直接 spawn 会 EINVAL/ENOENT
|
|
355
|
+
// (Node ≥18.20.2/20.12.2 的 CVE-2024-27980 修复后行为);
|
|
356
|
+
// 与 test-runner.mjs 里 pnpm 的处理保持一致。
|
|
357
|
+
shell: process.platform === "win32"
|
|
358
|
+
}
|
|
359
|
+
);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`\u62C9\u53D6\u9ED8\u8BA4\u6A21\u677F\u5305 ${DEFAULT_TEMPLATE_PACKAGE}@${spec} \u5931\u8D25\uFF1A\u53EF\u80FD\u662F\u7F51\u7EDC / registry \u4E0D\u53EF\u8FBE\uFF0C\u6216\u8BE5\u7248\u672C / dist-tag \u4E0B\u6CA1\u6709\u53D1\u5E03\u8FC7\u6A21\u677F\u5305\u3002\u53EF\u7528 --template-version <\u7248\u672C\u6216 dist-tag> \u6307\u5B9A\uFF0C\u6216\u6539\u7528 --template <\u672C\u5730\u6A21\u677F\u76EE\u5F55\u6216 .tgz>\uFF08\u4ED3\u5185\u4E3A --template templates/plugin\uFF09\u3002
|
|
363
|
+
\u539F\u59CB\u9519\u8BEF\uFF1A${err.message}`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
const lastLine = stdout.trim().split("\n").pop() ?? "";
|
|
367
|
+
const tgzPath = join3(packDestDir, lastLine.trim());
|
|
368
|
+
if (!lastLine || !existsSync3(tgzPath)) {
|
|
369
|
+
throw new Error(`npm pack ${DEFAULT_TEMPLATE_PACKAGE} \u672A\u4EA7\u51FA\u53EF\u8BC6\u522B\u7684 .tgz \u6587\u4EF6`);
|
|
370
|
+
}
|
|
371
|
+
return extractTarball(tgzPath);
|
|
372
|
+
}
|
|
373
|
+
function resolveTemplateDir(template, templateVersion) {
|
|
374
|
+
if (!template) return fetchDefaultTemplate(templateVersion);
|
|
375
|
+
const resolved = resolve(template);
|
|
376
|
+
if (resolved.endsWith(".tgz")) {
|
|
377
|
+
if (!existsSync3(resolved)) throw new Error(`\u6A21\u677F\u538B\u7F29\u5305\u4E0D\u5B58\u5728\uFF1A${resolved}`);
|
|
378
|
+
return extractTarball(resolved);
|
|
379
|
+
}
|
|
380
|
+
if (!existsSync3(resolved) || !statSync2(resolved).isDirectory()) {
|
|
381
|
+
throw new Error(`--template \u6307\u5411\u7684\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u662F\u76EE\u5F55\uFF1A${resolved}`);
|
|
382
|
+
}
|
|
383
|
+
return resolved;
|
|
384
|
+
}
|
|
385
|
+
function replacePlaceholders(dest, id, displayName) {
|
|
386
|
+
const walk = (d) => {
|
|
387
|
+
for (const e of readdirSync2(d, { withFileTypes: true })) {
|
|
388
|
+
const p = join3(d, e.name);
|
|
389
|
+
if (e.isDirectory()) {
|
|
390
|
+
walk(p);
|
|
391
|
+
} else if (TEXT_FILE_RE.test(e.name)) {
|
|
392
|
+
const s = readFileSync4(p, "utf8");
|
|
393
|
+
const next = s.split("__PLUGIN_ID__").join(id).split("__PLUGIN_NAME__").join(displayName);
|
|
394
|
+
if (next !== s) writeFileSync(p, next);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
walk(dest);
|
|
399
|
+
}
|
|
400
|
+
function scaffoldPlugin(opts) {
|
|
401
|
+
assertSafeDirName(opts.dirName);
|
|
402
|
+
if (!isValidPluginId(opts.id)) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`\u63D2\u4EF6 id \u975E\u6CD5\uFF1A"${opts.id}"\uFF08\u8981\u6C42\u5168\u5C0F\u5199\u3001\u81F3\u5C11\u4E24\u6BB5\u3001\u65E0 Windows \u4FDD\u7559\u540D\uFF0C\u5982 rti.demo.water-sensor\uFF09`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const dest = join3(resolve(opts.outParentDir), opts.dirName);
|
|
408
|
+
if (existsSync3(dest)) {
|
|
409
|
+
throw new Error(`\u76EE\u6807\u5DF2\u5B58\u5728\uFF0C\u62D2\u7EDD\u8986\u76D6\uFF1A${dest}`);
|
|
410
|
+
}
|
|
411
|
+
const templateDir = resolveTemplateDir(opts.template, opts.templateVersion);
|
|
412
|
+
cpSync2(templateDir, dest, {
|
|
413
|
+
recursive: true,
|
|
414
|
+
filter: (src) => !shouldSkipCopy(src)
|
|
415
|
+
});
|
|
416
|
+
replacePlaceholders(dest, opts.id, opts.displayName);
|
|
417
|
+
const pkgPath = join3(dest, "package.json");
|
|
418
|
+
if (existsSync3(pkgPath)) {
|
|
419
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
420
|
+
pkg.name = opts.id;
|
|
421
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
422
|
+
`);
|
|
423
|
+
}
|
|
424
|
+
const manifestPath = join3(dest, "manifest.json");
|
|
425
|
+
if (!existsSync3(manifestPath)) {
|
|
426
|
+
rmSync2(dest, { recursive: true, force: true });
|
|
427
|
+
throw new Error(`\u6A21\u677F\u7F3A\u5C11 manifest.json\uFF08\u6A21\u677F\u76EE\u5F55\uFF1A${templateDir}\uFF09`);
|
|
428
|
+
}
|
|
429
|
+
const generatedManifest = JSON.parse(readFileSync4(manifestPath, "utf8"));
|
|
430
|
+
const { ok, errors } = validatePluginManifest(generatedManifest);
|
|
431
|
+
if (!ok) {
|
|
432
|
+
rmSync2(dest, { recursive: true, force: true });
|
|
433
|
+
throw new Error(`\u751F\u6210\u7684 manifest \u672A\u901A\u8FC7\u6821\u9A8C\uFF08\u6A21\u677F\u4E0E schema \u4E0D\u540C\u6B65\uFF09\uFF1A
|
|
434
|
+
- ${errors.join("\n - ")}`);
|
|
435
|
+
}
|
|
436
|
+
return dest;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/test-runner.mjs
|
|
440
|
+
import { spawnSync } from "child_process";
|
|
441
|
+
function parseTapTestCount(output) {
|
|
442
|
+
const matches = [...output.matchAll(/^# tests (\d+)$/gm)];
|
|
443
|
+
const last = matches.at(-1);
|
|
444
|
+
return last ? Number(last[1]) : null;
|
|
445
|
+
}
|
|
446
|
+
function parseTapFailureCount(output) {
|
|
447
|
+
const fail = [...output.matchAll(/^# fail (\d+)$/gm)].at(-1);
|
|
448
|
+
if (!fail) return null;
|
|
449
|
+
const cancelled = [...output.matchAll(/^# cancelled (\d+)$/gm)].at(-1);
|
|
450
|
+
return Number(fail[1]) + (cancelled ? Number(cancelled[1]) : 0);
|
|
451
|
+
}
|
|
452
|
+
function runPluginTestSuite(pluginDir) {
|
|
453
|
+
const result = spawnSync("pnpm", ["test"], {
|
|
454
|
+
cwd: pluginDir,
|
|
455
|
+
encoding: "utf8",
|
|
456
|
+
// Windows 上 pnpm 是 pnpm.cmd,不带 shell 直接 spawn 会 ENOENT;
|
|
457
|
+
// 与仓内 tools/verify.mjs 的既定写法(run()/runCaptured())保持一致。
|
|
458
|
+
shell: process.platform === "win32",
|
|
459
|
+
env: {
|
|
460
|
+
...process.env,
|
|
461
|
+
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --test-reporter=tap`.trim()
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
if (result.error) {
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
testCount: null,
|
|
468
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
|
469
|
+
error: `\u65E0\u6CD5\u542F\u52A8\u6D4B\u8BD5\u8FDB\u7A0B\uFF1A${result.error.message}`
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
473
|
+
const testCount = parseTapTestCount(output);
|
|
474
|
+
if (testCount === null) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
testCount: null,
|
|
478
|
+
output,
|
|
479
|
+
error: "\u6D4B\u8BD5\u8F93\u51FA\u4E2D\u6CA1\u6709 node --test TAP \u6C47\u603B\uFF08\u811A\u672C\u6CA1\u6709\u771F\u6B63\u8DD1\u6D4B\u8BD5\uFF0C\u6216\u672A\u4F7F\u7528 node --test\uFF09"
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (testCount === 0) {
|
|
483
|
+
return { ok: false, testCount, output, error: "0 \u4E2A\u6D4B\u8BD5\u7528\u4F8B" };
|
|
484
|
+
}
|
|
485
|
+
const failCount = parseTapFailureCount(output);
|
|
486
|
+
if (failCount !== null && failCount > 0) {
|
|
487
|
+
return { ok: false, testCount, output, error: `TAP \u6C47\u603B\u6709 ${failCount} \u4E2A\u5931\u8D25/\u53D6\u6D88\u7528\u4F8B` };
|
|
488
|
+
}
|
|
489
|
+
if (result.status !== 0) {
|
|
490
|
+
return { ok: false, testCount, output, error: `\u6D4B\u8BD5\u8FDB\u7A0B\u9000\u51FA\u7801 ${result.status}` };
|
|
491
|
+
}
|
|
492
|
+
return { ok: true, testCount, output };
|
|
493
|
+
}
|
|
494
|
+
export {
|
|
495
|
+
buildRegistryEntry,
|
|
496
|
+
checkPlugin,
|
|
497
|
+
isEmptyTestScript,
|
|
498
|
+
lastPathSegment,
|
|
499
|
+
parseTapTestCount,
|
|
500
|
+
readPluginManifest,
|
|
501
|
+
resolveDefaultTemplateSpec,
|
|
502
|
+
runPluginTestSuite,
|
|
503
|
+
scaffoldPlugin,
|
|
504
|
+
shouldSkipCopy,
|
|
505
|
+
stagePlugin,
|
|
506
|
+
zipStaging
|
|
507
|
+
};
|