@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/package.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "name": "@international-iot-association/plugin-cli",
3
+ "version": "1.0.0-rc.1",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/International-IoT-Association/MES.git",
8
+ "directory": "electron-station-plugins/packages/plugin-cli"
9
+ },
10
+ "type": "module",
11
+ "description": "单插件目录的打包(pack)与预检(check)纯函数与 CLI。签名逻辑(sign.mjs)不在此包,留在仓内 electron-station-plugins/tools/。",
12
+ "bin": {
13
+ "plugin-cli": "./dist/cli.js"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./manifest-schema": {
23
+ "types": "./src/manifest-schema.d.mts",
24
+ "default": "./src/manifest-schema.mjs"
25
+ },
26
+ "./smoke": {
27
+ "types": "./src/smoke.d.mts",
28
+ "default": "./src/smoke.mjs"
29
+ },
30
+ "./new": {
31
+ "types": "./src/new.ts",
32
+ "default": "./src/new.ts"
33
+ },
34
+ "./check": {
35
+ "types": "./src/check.d.mts",
36
+ "default": "./src/check.mjs"
37
+ },
38
+ "./test-runner": {
39
+ "types": "./src/test-runner.d.mts",
40
+ "default": "./src/test-runner.mjs"
41
+ },
42
+ "./stage": {
43
+ "types": "./src/stage.ts",
44
+ "default": "./src/stage.ts"
45
+ },
46
+ "./zip": {
47
+ "types": "./src/zip.ts",
48
+ "default": "./src/zip.ts"
49
+ },
50
+ "./registry": {
51
+ "types": "./src/registry.ts",
52
+ "default": "./src/registry.ts"
53
+ }
54
+ },
55
+ "files": [
56
+ "dist",
57
+ "src/manifest-schema.mjs",
58
+ "src/manifest-schema.d.mts",
59
+ "src/smoke.mjs",
60
+ "src/smoke.d.mts",
61
+ "src/new.ts",
62
+ "src/check.mjs",
63
+ "src/check.d.mts",
64
+ "src/test-runner.mjs",
65
+ "src/test-runner.d.mts",
66
+ "src/stage.ts",
67
+ "src/zip.ts",
68
+ "src/registry.ts"
69
+ ],
70
+ "scripts": {
71
+ "build": "tsup",
72
+ "typecheck": "tsc --noEmit",
73
+ "test": "node --test test/*.test.mjs",
74
+ "clean": "rimraf dist .turbo"
75
+ },
76
+ "dependencies": {
77
+ "@international-iot-association/api-bridge": "3.0.0-rc.1",
78
+ "@international-iot-association/plugin-vite-config": "1.0.0-rc.1",
79
+ "adm-zip": "^0.5.16"
80
+ },
81
+ "devDependencies": {
82
+ "@international-iot-association/tsconfig": "1.0.0-rc.1",
83
+ "@types/adm-zip": "^0.5.7",
84
+ "@types/node": "^22.19.1",
85
+ "rimraf": "^6.0.1",
86
+ "tsup": "^8.4.0",
87
+ "typescript": "^5.9.3"
88
+ },
89
+ "x-internal-version": "1.0.0",
90
+ "x-source-revision": "410a2f3b85ef03b678f04f22ea0a47718f5ddae1",
91
+ "publishConfig": {
92
+ "access": "public",
93
+ "registry": "https://registry.npmjs.org/"
94
+ }
95
+ }
@@ -0,0 +1,8 @@
1
+ // check.mjs 的类型声明(供 tools/verify.mjs、tools/pack.ts 等 TS/JS 消费者取类型)。
2
+ export interface CheckResult {
3
+ ok: boolean;
4
+ errors: string[];
5
+ }
6
+
7
+ export declare function isEmptyTestScript(script: unknown): boolean;
8
+ export declare function checkPlugin(pluginDir: string): CheckResult;
package/src/check.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // check 子命令的核心逻辑:单插件档案,不做跨插件对账。
2
+ // 覆盖范围(§3.5-N2):manifest 严格校验、package.json.name === manifest.id、
3
+ // test 脚本非空壳、>=1 个 test/*.test.mjs、有 smoke.mjs、产物单 React。
4
+ // 明确不覆盖:跨插件 id 对账、registry 对账、全仓 secret-scan——这些只在仓内
5
+ // electron-station-plugins/tools/verify.mjs 的五方对账里做(见 CLI 输出的显式声明)。
6
+ //
7
+ // 本文件是纯 JS(非 .ts):@international-iot-association/plugin-vite-config 的 "." 导出直接指向源码
8
+ // src/index.mjs(免构建),本文件也需要能被 tools/verify.mjs 用 `node`(非 tsx)
9
+ // 直接静态导入而不依赖 Node 版本的 TS type-stripping 能力,与仓内既有的
10
+ // manifest-schema.mjs / smoke.mjs 两个免构建子路径约定保持一致(WP-3c 评审修复:
11
+ // 原 check.ts 只能经由 dist/index.js 消费,fresh clone + pnpm install 后未先
12
+ // `turbo build` 会在 verify.mjs 顶层 import 处 ERR_MODULE_NOT_FOUND)。
13
+ // 类型声明见同目录 check.d.mts。
14
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { assertPluginUiSingleReact } from "@international-iot-association/plugin-vite-config";
17
+ import { validatePluginManifest } from "./manifest-schema.mjs";
18
+
19
+ /**
20
+ * 判断一个 test 脚本字符串是否是「空壳」(未配置/仅 echo、true、exit 0、: 之类占位)。
21
+ * 与仓内权威定义 electron-station-plugins/tools/verify.mjs 的判据保持一致,
22
+ * 避免 check 子命令比五方对账更宽松、放行假绿脚本。
23
+ */
24
+ export function isEmptyTestScript(script) {
25
+ if (typeof script !== "string") return true;
26
+ const trimmed = script.trim();
27
+ if (trimmed.length === 0) return true;
28
+ return /^(echo\b|true$|exit\s+0$|:$)/.test(trimmed);
29
+ }
30
+
31
+ /** 对单个插件目录做打包前预检,返回是否通过与错误列表。 */
32
+ export function checkPlugin(pluginDir) {
33
+ const errors = [];
34
+
35
+ const manifestPath = join(pluginDir, "manifest.json");
36
+ if (!existsSync(manifestPath)) {
37
+ return { ok: false, errors: [`missing manifest.json in ${pluginDir}`] };
38
+ }
39
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
40
+ const { ok: manifestOk, errors: manifestErrors } = validatePluginManifest(manifest);
41
+ if (!manifestOk) {
42
+ for (const e of manifestErrors) errors.push(`manifest: ${e}`);
43
+ }
44
+
45
+ const pkgPath = join(pluginDir, "package.json");
46
+ if (!existsSync(pkgPath)) {
47
+ errors.push("missing package.json");
48
+ } else {
49
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
50
+ if (manifestOk && pkg.name !== manifest.id) {
51
+ errors.push(`package.json.name (${pkg.name}) !== manifest.id (${manifest.id})`);
52
+ }
53
+ if (isEmptyTestScript(pkg.scripts?.test)) {
54
+ errors.push("package.json.scripts.test 缺失或是空壳");
55
+ }
56
+ }
57
+
58
+ const testDir = join(pluginDir, "test");
59
+ const hasTestFile =
60
+ existsSync(testDir) && readdirSync(testDir).some((f) => f.endsWith(".test.mjs"));
61
+ if (!hasTestFile) errors.push("缺少 test/*.test.mjs(至少 1 个)");
62
+
63
+ if (!existsSync(join(pluginDir, "smoke.mjs"))) {
64
+ errors.push("缺少 smoke.mjs");
65
+ }
66
+
67
+ const reactGate = assertPluginUiSingleReact(pluginDir);
68
+ if (!reactGate.ok) errors.push(reactGate.error);
69
+
70
+ return { ok: errors.length === 0, errors };
71
+ }
@@ -0,0 +1,9 @@
1
+ // tools/manifest-schema.mjs 的类型声明(供 pack.ts / new-plugin.ts 等 TS 工具消费)。
2
+ export declare const WINDOWS_RESERVED_NAMES: Set<string>;
3
+ export declare function isStrictSemver(value: unknown): boolean;
4
+ export declare function isValidPluginId(value: unknown): boolean;
5
+ export declare function isSafeRelativeEntry(value: unknown): boolean;
6
+ export declare function validatePluginManifest(manifest: unknown): {
7
+ ok: boolean;
8
+ errors: string[];
9
+ };
@@ -0,0 +1,224 @@
1
+ // tools/manifest-schema.mjs —— 插件 manifest 的严格校验器(零依赖)。
2
+ //
3
+ // 依据 docs/mes-redesign-proposal/15-electron-plugin-platform-adjustment-plan.md
4
+ // P0-01 第 3 条:插件 ID 使用严格命名规则;version 必须是 strict SemVer;
5
+ // 显式拒绝 `.`/`..`、路径分隔符、Windows 保留名、尾随点/空格和 drive/UNC 变体。
6
+ //
7
+ // 使用方:tools/pack.ts(打包前校验)、tools/verify.mjs(五方对账)、
8
+ // 脚手架 new-plugin(生成后校验)。外壳安装器有等价的运行期校验。
9
+ //
10
+ // 设计:fail-closed —— 未知顶层字段一律报错,新增契约字段时必须同步更新这里
11
+ // 与 packages/plugin-contracts 的 PluginManifest 类型。
12
+
13
+ /** semver.org 官方 strict SemVer 正则。 */
14
+ const SEMVER_RE =
15
+ /^(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-]+)*))?$/;
16
+
17
+ /** 插件 ID:全小写、至少两段、段内允许数字/连字符(首段不允许连字符开头)。 */
18
+ const PLUGIN_ID_RE = /^[a-z][a-z0-9]*(\.[a-z0-9][a-z0-9-]*)+$/;
19
+
20
+ /** 权限名:`serial.dut` / `serial.fixture.control` / `ui:camera` / `storage`。 */
21
+ const PERMISSION_RE = /^[a-z][a-z0-9]*([.:][a-z0-9][a-z0-9-]*)*$/;
22
+
23
+ /** 依赖版本范围(宽松:只限制字符集,完整 range 解析由包管理器负责)。 */
24
+ const DEP_RANGE_RE = /^[0-9a-zA-Z.^~*<>=| -]+$/;
25
+
26
+ /** Windows 保留设备名(不区分大小写;作为文件名主干时非法)。 */
27
+ export const WINDOWS_RESERVED_NAMES = new Set([
28
+ 'con',
29
+ 'prn',
30
+ 'aux',
31
+ 'nul',
32
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
33
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
34
+ ]);
35
+
36
+ const KNOWN_FIELDS = new Set([
37
+ 'id',
38
+ 'name',
39
+ 'version',
40
+ 'description',
41
+ 'kind',
42
+ 'stationTypes',
43
+ 'models',
44
+ 'agentApi',
45
+ 'uiEntry',
46
+ 'runtimeEntry',
47
+ 'permissions',
48
+ 'autoStart',
49
+ 'dependencies',
50
+ 'resultSchema',
51
+ 'steps',
52
+ 'checksum',
53
+ 'storage',
54
+ 'readinessTimeoutMs',
55
+ ]);
56
+
57
+ export function isStrictSemver(value) {
58
+ return typeof value === 'string' && SEMVER_RE.test(value);
59
+ }
60
+
61
+ export function isValidPluginId(value) {
62
+ if (typeof value !== 'string' || value.length === 0 || value.length > 128) return false;
63
+ if (!PLUGIN_ID_RE.test(value)) return false;
64
+ // 完整 id 会成为安装目录名:首段不得是 Windows 保留名(CON.x.y 亦被视为设备)
65
+ const firstSegment = value.split('.', 1)[0];
66
+ if (WINDOWS_RESERVED_NAMES.has(firstSegment.toLowerCase())) return false;
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * 包内相对路径是否安全:仅正斜杠、无 `.`/`..` 段、无盘符/UNC/绝对路径、
72
+ * 每段无尾随点/空格、段主干不是 Windows 保留名。
73
+ */
74
+ export function isSafeRelativeEntry(value) {
75
+ if (typeof value !== 'string' || value.length === 0 || value.length > 512) return false;
76
+ if (value.includes('\\') || value.includes('\0')) return false;
77
+ if (value.startsWith('/') || /^[a-zA-Z]:/.test(value) || value.startsWith('//')) return false;
78
+ const segments = value.split('/');
79
+ for (const segment of segments) {
80
+ if (segment === '' || segment === '.' || segment === '..') return false;
81
+ if (segment !== segment.trim() || segment.endsWith('.')) return false;
82
+ const stem = segment.split('.', 1)[0];
83
+ if (WINDOWS_RESERVED_NAMES.has(stem.toLowerCase())) return false;
84
+ }
85
+ return true;
86
+ }
87
+
88
+ function isStringArray(value) {
89
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
90
+ }
91
+
92
+ /**
93
+ * 校验插件 manifest 对象。
94
+ * @param {unknown} manifest 已 JSON.parse 的 manifest
95
+ * @returns {{ ok: boolean, errors: string[] }}
96
+ */
97
+ export function validatePluginManifest(manifest) {
98
+ const errors = [];
99
+ const push = (msg) => errors.push(msg);
100
+
101
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
102
+ return { ok: false, errors: ['manifest 必须是 JSON object'] };
103
+ }
104
+ const m = /** @type {Record<string, unknown>} */ (manifest);
105
+
106
+ for (const key of Object.keys(m)) {
107
+ if (!KNOWN_FIELDS.has(key))
108
+ push(
109
+ `未知字段 "${key}"(fail-closed:新字段需同步更新 manifest-schema 与 plugin-contracts)`
110
+ );
111
+ }
112
+
113
+ if (!isValidPluginId(m.id))
114
+ push(`id 非法:"${m.id}"(要求全小写、至少两段、无保留名,如 rti.demo.example)`);
115
+ if (typeof m.name !== 'string' || m.name.trim() === '') push('name 必须是非空字符串');
116
+ if (!isStrictSemver(m.version)) push(`version 必须是 strict SemVer:"${m.version}"`);
117
+ if (!isStrictSemver(m.agentApi)) push(`agentApi 必须是 strict SemVer:"${m.agentApi}"`);
118
+
119
+ const kind = m.kind ?? 'station';
120
+ if (kind !== 'station' && kind !== 'global') push(`kind 只能是 station/global:"${m.kind}"`);
121
+
122
+ if (!isStringArray(m.stationTypes)) push('stationTypes 必须是字符串数组(可为空数组)');
123
+ // models 只是给人看的元数据:外壳仅在插件详情/介绍页把它渲染成「适用机型」,
124
+ // 没有任何分发、准入或匹配逻辑读它(真正决定「本工位装哪些包」的是 MES 侧
125
+ // workOrder + stationType + skuId 解析出的 packages 清单)。因此降级为可选:
126
+ // 声明了就必须是字符串数组,省略等价于空数组。
127
+ if (m.models !== undefined && !isStringArray(m.models))
128
+ push('models 必须是字符串数组(可省略;省略等价于空数组)');
129
+
130
+ if (!isSafeRelativeEntry(m.runtimeEntry)) push(`runtimeEntry 非法:"${m.runtimeEntry}"`);
131
+ if (kind === 'station') {
132
+ if (!isSafeRelativeEntry(m.uiEntry)) push(`station 插件必须提供合法 uiEntry:"${m.uiEntry}"`);
133
+ } else if (m.uiEntry !== undefined && !isSafeRelativeEntry(m.uiEntry)) {
134
+ push(`uiEntry 非法:"${m.uiEntry}"`);
135
+ }
136
+
137
+ if (!isStringArray(m.permissions)) {
138
+ push('permissions 必须是字符串数组(可为空数组)');
139
+ } else {
140
+ for (const perm of m.permissions) {
141
+ if (!PERMISSION_RE.test(perm)) push(`permission 非法:"${perm}"`);
142
+ }
143
+ }
144
+
145
+ if (m.autoStart !== undefined && typeof m.autoStart !== 'boolean')
146
+ push('autoStart 必须是 boolean');
147
+ if (m.description !== undefined && typeof m.description !== 'string')
148
+ push('description 必须是字符串');
149
+ if (m.checksum !== undefined && typeof m.checksum !== 'string') push('checksum 必须是字符串');
150
+
151
+ // W3-6(P0-06):storage 作用域声明。缺省 version scope;shared 必须显式声明。
152
+ if (m.storage !== undefined) {
153
+ if (m.storage === null || typeof m.storage !== 'object' || Array.isArray(m.storage)) {
154
+ push("storage 必须是 { scope?: 'version' | 'shared', migrateLegacy?: boolean } 对象");
155
+ } else {
156
+ for (const key of Object.keys(m.storage)) {
157
+ if (key !== 'scope' && key !== 'migrateLegacy') {
158
+ push(`storage 未知字段 "${key}"(只允许 scope/migrateLegacy)`);
159
+ }
160
+ }
161
+ const scope = m.storage.scope;
162
+ if (scope !== undefined && scope !== 'version' && scope !== 'shared') {
163
+ push(`storage.scope 只能是 version/shared:"${scope}"`);
164
+ }
165
+ if (m.storage.migrateLegacy !== undefined && typeof m.storage.migrateLegacy !== 'boolean') {
166
+ push(`storage.migrateLegacy 必须是 boolean:"${m.storage.migrateLegacy}"`);
167
+ }
168
+ }
169
+ }
170
+
171
+ // W3-4(P0-06):readiness(activate)超时毫秒数,必须是正整数。
172
+ if (m.readinessTimeoutMs !== undefined) {
173
+ if (
174
+ typeof m.readinessTimeoutMs !== 'number' ||
175
+ !Number.isInteger(m.readinessTimeoutMs) ||
176
+ m.readinessTimeoutMs <= 0
177
+ ) {
178
+ push(`readinessTimeoutMs 必须是正整数毫秒数:"${m.readinessTimeoutMs}"`);
179
+ }
180
+ }
181
+
182
+ if (m.resultSchema !== undefined && !isSafeRelativeEntry(m.resultSchema)) {
183
+ push(`resultSchema 非法:"${m.resultSchema}"`);
184
+ }
185
+
186
+ if (m.dependencies !== undefined) {
187
+ if (
188
+ m.dependencies === null ||
189
+ typeof m.dependencies !== 'object' ||
190
+ Array.isArray(m.dependencies)
191
+ ) {
192
+ push('dependencies 必须是 { pluginId: versionRange } 对象');
193
+ } else {
194
+ for (const [depId, range] of Object.entries(m.dependencies)) {
195
+ if (!isValidPluginId(depId)) push(`dependencies 键非法:"${depId}"`);
196
+ if (typeof range !== 'string' || range.trim() === '' || !DEP_RANGE_RE.test(range)) {
197
+ push(`dependencies["${depId}"] 版本范围非法:"${range}"`);
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ if (m.steps !== undefined) {
204
+ if (!Array.isArray(m.steps)) {
205
+ push('steps 必须是数组');
206
+ } else {
207
+ m.steps.forEach((step, i) => {
208
+ if (step === null || typeof step !== 'object' || Array.isArray(step)) {
209
+ push(`steps[${i}] 必须是对象`);
210
+ return;
211
+ }
212
+ if (typeof step.key !== 'string' || step.key.trim() === '')
213
+ push(`steps[${i}].key 必须是非空字符串`);
214
+ if (typeof step.label !== 'string' || step.label.trim() === '')
215
+ push(`steps[${i}].label 必须是非空字符串`);
216
+ if (step.description !== undefined && typeof step.description !== 'string') {
217
+ push(`steps[${i}].description 必须是字符串`);
218
+ }
219
+ });
220
+ }
221
+ }
222
+
223
+ return { ok: errors.length === 0, errors };
224
+ }
package/src/new.ts ADDED
@@ -0,0 +1,220 @@
1
+ // packages/plugin-cli/src/new.ts —— 插件脚手架:从模板生成新插件目录。
2
+ // 逻辑迁移自仓内 electron-station-plugins/tools/new-plugin.ts(ADR-0032 §3.3/§3.5,
3
+ // WP-3b)。默认模板源改为 npm 包 @international-iot-association/plugin-template
4
+ // (尚未发布,见 fetchDefaultTemplate 的报错文案);--template 指向目录时直接
5
+ // 从该目录实例化,不再内置模板拷贝。tools/new-plugin.ts 现为薄包装,硬编码
6
+ // template=templates/plugin 调用本模块的 scaffoldPlugin。
7
+ import { execFileSync } from "node:child_process";
8
+ import {
9
+ cpSync,
10
+ existsSync,
11
+ mkdtempSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ rmSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { isValidPluginId, validatePluginManifest } from "./manifest-schema.mjs";
22
+
23
+ const DEFAULT_TEMPLATE_PACKAGE = "@international-iot-association/plugin-template";
24
+ const SKIP_ENTRIES = new Set(["node_modules", "ui-dist", "runtime-dist", ".turbo"]);
25
+ const TEXT_FILE_RE = /\.(ts|tsx|json|css|html|mjs|md)$/;
26
+ const DIR_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
27
+
28
+ export interface ScaffoldOptions {
29
+ /** plugins/ 下的目标目录名(安全字符集)。 */
30
+ dirName: string;
31
+ /** 插件显示名,用于替换 __PLUGIN_NAME__。 */
32
+ displayName: string;
33
+ /** 插件 id,用于替换 __PLUGIN_ID__ 与 package.json.name。 */
34
+ id: string;
35
+ /** 模板来源:目录路径 / .tgz 路径;缺省时从 npm 拉取默认模板包。 */
36
+ template?: string;
37
+ /**
38
+ * 默认模板包的版本或 dist-tag(仅在未给 template 时生效)。缺省按本 CLI 自身
39
+ * 版本推:预发布版(含 "-")取 "next",正式版取 "latest"——npm 的 "*" 永远不
40
+ * 匹配预发布版本,rc 阶段不显式给 spec 会 ETARGET(3.0.0-rc.1 实测)。
41
+ */
42
+ templateVersion?: string;
43
+ /** 目标父目录(生成到 <outParentDir>/<dirName>);缺省为 cwd 下 plugins/。 */
44
+ outParentDir: string;
45
+ }
46
+
47
+ /**
48
+ * 取路径最后一段——必须同时兼容 POSIX 与 Windows 分隔符。
49
+ * 原实现用 `src.split("/").pop()`,在 Windows 上传入反斜杠路径时不会真正
50
+ * 按分隔符切开,导致 SKIP 目录名恒不命中、node_modules 等构建产物被一并
51
+ * 拷进生成的插件目录(Windows 路径 bug,WP-3b 明确要求修复)。
52
+ */
53
+ export function lastPathSegment(path: string): string {
54
+ const parts = path.split(/[\\/]/);
55
+ return parts[parts.length - 1] ?? "";
56
+ }
57
+
58
+ /** cpSync filter:跳过构建产物与依赖目录,需兼容 Windows 反斜杠路径。 */
59
+ export function shouldSkipCopy(src: string): boolean {
60
+ return SKIP_ENTRIES.has(lastPathSegment(src));
61
+ }
62
+
63
+ function assertSafeDirName(dirName: string): void {
64
+ if (!DIR_NAME_RE.test(dirName)) {
65
+ throw new Error(
66
+ `目录名非法:"${dirName}"(只允许小写字母/数字/._-,且不能以 . 或 - 开头)`,
67
+ );
68
+ }
69
+ }
70
+
71
+ /** 把一份 npm pack 产出的 .tgz 解到临时目录,返回其中 package/ 子目录的绝对路径。 */
72
+ function extractTarball(tgzPath: string): string {
73
+ const extractDir = mkdtempSync(join(tmpdir(), "plugin-cli-tpl-extract-"));
74
+ execFileSync("tar", ["-xzf", tgzPath, "-C", extractDir], { stdio: "pipe" });
75
+ const packageDir = join(extractDir, "package");
76
+ if (!existsSync(packageDir)) {
77
+ throw new Error(`模板压缩包解压后未找到 package/ 目录(${tgzPath}):不是合法的 npm 包产物`);
78
+ }
79
+ return packageDir;
80
+ }
81
+
82
+ /** 读取本 CLI 自身(发布形态下)的版本号;读不到时返回空串,走 latest。 */
83
+ function readOwnVersion(): string {
84
+ try {
85
+ const here = dirname(fileURLToPath(import.meta.url));
86
+ // 构建后本文件在 dist/ 下,源码态在 src/ 下,两者的 package.json 都在上一级。
87
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as { version?: string };
88
+ return typeof pkg.version === "string" ? pkg.version : "";
89
+ } catch {
90
+ return "";
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 决定默认模板包的版本 spec:显式给了就用显式的;否则 CLI 自身是预发布版
96
+ * 取 dist-tag "next"(发布手册约定 rc 一律打 next),正式版取 "latest"。
97
+ */
98
+ export function resolveDefaultTemplateSpec(cliVersion: string, override?: string): string {
99
+ if (override && override.trim()) return override.trim();
100
+ return cliVersion.includes("-") ? "next" : "latest";
101
+ }
102
+
103
+ /**
104
+ * 从默认模板包 @international-iot-association/plugin-template 拉取模板。
105
+ * 必须带版本 spec:npm 对不带 spec 的包名按 "*" 解析,永远不命中预发布版本。
106
+ */
107
+ function fetchDefaultTemplate(templateVersion?: string): string {
108
+ const spec = resolveDefaultTemplateSpec(readOwnVersion(), templateVersion);
109
+ const packDestDir = mkdtempSync(join(tmpdir(), "plugin-cli-tpl-pack-"));
110
+ let stdout: string;
111
+ try {
112
+ stdout = execFileSync(
113
+ "npm",
114
+ ["pack", `${DEFAULT_TEMPLATE_PACKAGE}@${spec}`, "--pack-destination", packDestDir],
115
+ {
116
+ stdio: ["ignore", "pipe", "pipe"],
117
+ encoding: "utf8",
118
+ // Windows 上 npm 实际是 npm.cmd,不带 shell 直接 spawn 会 EINVAL/ENOENT
119
+ // (Node ≥18.20.2/20.12.2 的 CVE-2024-27980 修复后行为);
120
+ // 与 test-runner.mjs 里 pnpm 的处理保持一致。
121
+ shell: process.platform === "win32",
122
+ },
123
+ );
124
+ } catch (err) {
125
+ throw new Error(
126
+ `拉取默认模板包 ${DEFAULT_TEMPLATE_PACKAGE}@${spec} 失败:可能是网络 / registry 不可达,` +
127
+ `或该版本 / dist-tag 下没有发布过模板包。可用 --template-version <版本或 dist-tag> 指定,` +
128
+ `或改用 --template <本地模板目录或 .tgz>(仓内为 --template templates/plugin)。\n` +
129
+ `原始错误:${(err as Error).message}`,
130
+ );
131
+ }
132
+ const lastLine = stdout.trim().split("\n").pop() ?? "";
133
+ const tgzPath = join(packDestDir, lastLine.trim());
134
+ if (!lastLine || !existsSync(tgzPath)) {
135
+ throw new Error(`npm pack ${DEFAULT_TEMPLATE_PACKAGE} 未产出可识别的 .tgz 文件`);
136
+ }
137
+ return extractTarball(tgzPath);
138
+ }
139
+
140
+ /** 解析 --template 参数:目录原样使用;.tgz 解压后取 package/ 子目录;缺省走默认模板包。 */
141
+ function resolveTemplateDir(template?: string, templateVersion?: string): string {
142
+ if (!template) return fetchDefaultTemplate(templateVersion);
143
+ const resolved = resolve(template);
144
+ if (resolved.endsWith(".tgz")) {
145
+ if (!existsSync(resolved)) throw new Error(`模板压缩包不存在:${resolved}`);
146
+ return extractTarball(resolved);
147
+ }
148
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
149
+ throw new Error(`--template 指向的目录不存在或不是目录:${resolved}`);
150
+ }
151
+ return resolved;
152
+ }
153
+
154
+ /** 在文本文件中替换占位符 __PLUGIN_ID__ / __PLUGIN_NAME__(与原 new-plugin.ts 逐字一致)。 */
155
+ function replacePlaceholders(dest: string, id: string, displayName: string): void {
156
+ const walk = (d: string): void => {
157
+ for (const e of readdirSync(d, { withFileTypes: true })) {
158
+ const p = join(d, e.name);
159
+ if (e.isDirectory()) {
160
+ walk(p);
161
+ } else if (TEXT_FILE_RE.test(e.name)) {
162
+ const s = readFileSync(p, "utf8");
163
+ const next = s.split("__PLUGIN_ID__").join(id).split("__PLUGIN_NAME__").join(displayName);
164
+ if (next !== s) writeFileSync(p, next);
165
+ }
166
+ }
167
+ };
168
+ walk(dest);
169
+ }
170
+
171
+ /**
172
+ * 生成一个新插件目录。
173
+ * 校验顺序:dirName 安全字符 -> id 合法 -> 目标未占用 -> 拷贝模板 -> 占位符替换 ->
174
+ * package.json.name 写回 -> 生成产物的 manifest.json 严格自检(不通过则回滚删除
175
+ * 已生成目录,避免留下半成品)。成功时返回生成目录的绝对路径。
176
+ */
177
+ export function scaffoldPlugin(opts: ScaffoldOptions): string {
178
+ assertSafeDirName(opts.dirName);
179
+ if (!isValidPluginId(opts.id)) {
180
+ throw new Error(
181
+ `插件 id 非法:"${opts.id}"(要求全小写、至少两段、无 Windows 保留名,如 rti.demo.water-sensor)`,
182
+ );
183
+ }
184
+
185
+ const dest = join(resolve(opts.outParentDir), opts.dirName);
186
+ if (existsSync(dest)) {
187
+ throw new Error(`目标已存在,拒绝覆盖:${dest}`);
188
+ }
189
+
190
+ const templateDir = resolveTemplateDir(opts.template, opts.templateVersion);
191
+
192
+ cpSync(templateDir, dest, {
193
+ recursive: true,
194
+ filter: (src) => !shouldSkipCopy(src),
195
+ });
196
+
197
+ replacePlaceholders(dest, opts.id, opts.displayName);
198
+
199
+ const pkgPath = join(dest, "package.json");
200
+ if (existsSync(pkgPath)) {
201
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
202
+ pkg.name = opts.id;
203
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
204
+ }
205
+
206
+ // 生成后自检:脚手架产物的 manifest 必须直接通过严格校验(原 new-plugin.ts:85-91 逻辑)。
207
+ const manifestPath = join(dest, "manifest.json");
208
+ if (!existsSync(manifestPath)) {
209
+ rmSync(dest, { recursive: true, force: true });
210
+ throw new Error(`模板缺少 manifest.json(模板目录:${templateDir})`);
211
+ }
212
+ const generatedManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as unknown;
213
+ const { ok, errors } = validatePluginManifest(generatedManifest);
214
+ if (!ok) {
215
+ rmSync(dest, { recursive: true, force: true });
216
+ throw new Error(`生成的 manifest 未通过校验(模板与 schema 不同步):\n - ${errors.join("\n - ")}`);
217
+ }
218
+
219
+ return dest;
220
+ }
@@ -0,0 +1,46 @@
1
+ // 单插件 registry 片段。字段形状与仓内 tools/pack.ts 产出的 registry.json 条目
2
+ // 完全一致,只去掉 signature 相关字段(签名留在仓内包,见 ADR-0032 D7)。
3
+ // 与仓外 MES 后台的字段对齐留给 WP-9(WP-0 的接口调研未做,这里先保持形状对齐)。
4
+ import type { PluginManifest } from "./stage.js";
5
+
6
+ export interface RegistryEntry {
7
+ id: string;
8
+ name: string;
9
+ version: string;
10
+ description?: string;
11
+ kind?: "station" | "global";
12
+ stationTypes: string[];
13
+ models: string[];
14
+ agentApi: string;
15
+ permissions: string[];
16
+ autoStart?: boolean;
17
+ dependencies?: Record<string, string>;
18
+ fileName: string;
19
+ sha256: string;
20
+ sizeBytes: number;
21
+ }
22
+
23
+ /** 由 manifest + zip 结果组装单插件 registry 片段(不含 signature,不含顶层 generatedAt)。 */
24
+ export function buildRegistryEntry(
25
+ manifest: PluginManifest,
26
+ fileName: string,
27
+ sha256: string,
28
+ sizeBytes: number,
29
+ ): RegistryEntry {
30
+ return {
31
+ id: manifest.id,
32
+ name: manifest.name,
33
+ version: manifest.version,
34
+ description: manifest.description,
35
+ kind: manifest.kind,
36
+ stationTypes: manifest.stationTypes,
37
+ models: manifest.models ?? [],
38
+ agentApi: manifest.agentApi,
39
+ permissions: manifest.permissions,
40
+ autoStart: manifest.autoStart,
41
+ dependencies: manifest.dependencies,
42
+ fileName,
43
+ sha256,
44
+ sizeBytes,
45
+ };
46
+ }