@qomicex/cli 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/README.md +86 -0
- package/dist/commands/create.js +74 -0
- package/dist/commands/dev.js +59 -0
- package/dist/commands/pack.js +85 -0
- package/dist/commands/publish.js +126 -0
- package/dist/commands/verify.js +80 -0
- package/dist/index.js +111 -0
- package/dist/lib/io.js +35 -0
- package/dist/lib/manifest.js +93 -0
- package/dist/lib/permissions.js +51 -0
- package/dist/lib/project.js +35 -0
- package/dist/lib/scan.js +104 -0
- package/dist/lib/signature.js +171 -0
- package/dist/lib/store.js +100 -0
- package/dist/lib/zip.js +126 -0
- package/package.json +27 -0
- package/templates/plugin/README.md +20 -0
- package/templates/plugin/index.html +12 -0
- package/templates/plugin/manifest.json +22 -0
- package/templates/plugin/package.json +25 -0
- package/templates/plugin/pnpm-workspace.yaml +4 -0
- package/templates/plugin/postcss.config.js +6 -0
- package/templates/plugin/src/App.tsx +55 -0
- package/templates/plugin/src/api.ts +35 -0
- package/templates/plugin/src/index.css +9 -0
- package/templates/plugin/src/main.tsx +7 -0
- package/templates/plugin/tailwind.config.js +8 -0
- package/templates/plugin/theme.css +7 -0
- package/templates/plugin/tsconfig.json +19 -0
- package/templates/plugin/vite.config.ts +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @qomicex/cli — qomicex 插件 CLI
|
|
2
|
+
|
|
3
|
+
Qomicex 插件生态脚手架:`create` / `dev` / `pack` / `verify` / `publish`。零运行时依赖(Node ≥ 20,仅用内置 `node:fs`/`node:zlib`/WebCrypto)。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 仓库内(pnpm workspace 成员,`packages/qomicex-cli`)
|
|
9
|
+
pnpm install
|
|
10
|
+
pnpm --filter @qomicex/cli build
|
|
11
|
+
# 本地链接到 PATH
|
|
12
|
+
pnpm --filter @qomicex/cli link
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 命令
|
|
16
|
+
|
|
17
|
+
### create
|
|
18
|
+
```bash
|
|
19
|
+
qomicex create com.example.demo
|
|
20
|
+
```
|
|
21
|
+
从内置模板生成合法插件项目(Vite + React 19 + TS + Tailwind + `@qomicex/plugin-ui`),自动替换 `manifest.json` / `package.json` 中的 id。模板 `layers:["l2"]`(iframe 沙箱),权限最小集 `config:read / ui:toast / network:cors_proxy`。生成后提示 `pnpm install`。
|
|
22
|
+
|
|
23
|
+
### dev
|
|
24
|
+
```bash
|
|
25
|
+
qomicex dev # 默认 5173
|
|
26
|
+
qomicex dev --port 3000
|
|
27
|
+
```
|
|
28
|
+
起本地插件调试环境:
|
|
29
|
+
- **仓库内(harness 模式)**:插件位于仓库 `plugins-dev/{id}` 时,自动检测并 spawn `scripts/harness/run.mjs`,进入完整调试环境——Playwright 起 Vite(:1420) + 注入 Tauri mock + stub mock server(:5100) + 源码变更热重载(重建后整页 reload)。此模式 `--port` 不生效(harness 固定端口),`Ctrl+C` 退出时一并清理 stub/Vite。
|
|
30
|
+
- **仓库外(裸 Vite)**:未检测到 harness 时回退为起 Vite dev server + 在项目根写 `.qomicex-dev.json`(dev 源插件配置:插件 id → localhost URL 映射),供手动注册 dev 源使用。
|
|
31
|
+
|
|
32
|
+
### pack
|
|
33
|
+
```bash
|
|
34
|
+
qomicex pack # tsc && vite build → release/<id>-<version>.qplugin
|
|
35
|
+
qomicex pack --version 0.2.0 # 覆盖 manifest 版本号
|
|
36
|
+
qomicex pack --key ./dev-key.pem # 附签名(仅 signature.json;完整证书链请用 publish)
|
|
37
|
+
qomicex pack --skip-build # 跳过构建(复用现有 dist)
|
|
38
|
+
```
|
|
39
|
+
`.qplugin` = zip,`manifest.json` 在根 + `dist/**`。`entry.theme` / `contributes.overlay.file` 若引用 `dist/` 下文件但源码在根目录,会自动拷入。
|
|
40
|
+
|
|
41
|
+
### verify
|
|
42
|
+
```bash
|
|
43
|
+
qomicex verify # 目录模式:manifest + 权限 + 长循环
|
|
44
|
+
qomicex verify --package ./release/x.qplugin # 包模式:manifest + 签名验签
|
|
45
|
+
```
|
|
46
|
+
- **manifest 合法性**:id(反向域名/安全字符)、name、version(semver)、minLauncherVersion、layers、permissions、entry、contributes。
|
|
47
|
+
- **权限最小化**:对比 `manifest.permissions` 与源码实际调用的桥方法(`METHOD_PERMISSIONS` 表)。声明未用 / 用了未声明都会报错。
|
|
48
|
+
- **长循环告警**:`while(true)`、`for(;;)`、`setInterval` 无界轮询(提示放 Worker/WASM/后端)。
|
|
49
|
+
- **签名检查**:包模式用内置商店根公钥验签(ADR-050);无签名提示"未签名"不拒绝。
|
|
50
|
+
|
|
51
|
+
### publish
|
|
52
|
+
```bash
|
|
53
|
+
export QOMICEX_SIGN_KEY=<私钥 base64/PEM> # 或 --key ./key.pem
|
|
54
|
+
qomicex publish # 设备流登录 → 签名 → 上传
|
|
55
|
+
qomicex publish --changelog "修复 X" --yes
|
|
56
|
+
qomicex publish --api http://127.0.0.1:8787/api/v1 # 本地商店(wrangler dev)调试
|
|
57
|
+
```
|
|
58
|
+
流程(对齐商店契约,见 `docs/junsi-dev-docs/2-架构设计/插件生态技术提案.md` §3.1/§4.1 与 ADR-050):
|
|
59
|
+
1. `POST /api/v1/auth/device/code` → 打印授权码与验证 URL → 轮询 `device/token` 拿访问令牌(RFC 8628)。
|
|
60
|
+
2. `POST /api/v1/developer/keys` 上传 Ed25519 公钥 → 商店根钥签发开发者证书(`keyId` + `signature.cert.json`)。
|
|
61
|
+
3. 用私钥对包体签名(规范化 JSON 载荷 → Ed25519 → `signature.json`),与证书一起打进 `.qplugin`。
|
|
62
|
+
4. 查找/创建插件记录(`/plugins/mine` → 无则 `POST /plugins`),确认后 `POST /plugins/:id/versions` multipart 上传。
|
|
63
|
+
5. 成功后将签名包存为 `release/<id>-<version>.signed.qplugin` 供复验。
|
|
64
|
+
|
|
65
|
+
## 签名密钥
|
|
66
|
+
|
|
67
|
+
生成 Ed25519 密钥对(PKCS#8 PEM 或 raw 32 字节 seed base64):
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
openssl genpkey -algorithm Ed25519 -out dev-key.pem
|
|
71
|
+
# raw seed 提取(publish 也接受 PEM,通常不必)
|
|
72
|
+
openssl pkey -in dev-key.pem -outform DER | tail -c 32 | base64
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 开发
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pnpm --filter @qomicex/cli build # tsc → dist/(rewriteRelativeImportExtensions 输出可运行 ESM)
|
|
79
|
+
pnpm --filter @qomicex/cli typecheck
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 设计说明
|
|
83
|
+
|
|
84
|
+
- **零依赖**:zip 读写(`node:zlib` + 手写 CRC32/目录结构)、Ed25519(WebCrypto)、参数解析(手写)均不引入第三方包。
|
|
85
|
+
- **签名规范化** 与 store `src/lib/signature.ts`、launcher `plugin_signature.rs` 字节级一致(键序/无空白 canonicalJson,`signedHash` = 载荷 SHA-256)。
|
|
86
|
+
- **不改商店上传接口**:只读参考契约实现客户端。
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// qomicex create <id> — 从内置模板生成合法插件项目。
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { checkId } from "../lib/manifest.js";
|
|
6
|
+
import { fail, info } from "../lib/io.js";
|
|
7
|
+
export function templateDir() {
|
|
8
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '../../templates/plugin');
|
|
9
|
+
}
|
|
10
|
+
function humanizeName(id) {
|
|
11
|
+
const last = id.split('.').pop() ?? id;
|
|
12
|
+
const words = last.split('-').filter(Boolean);
|
|
13
|
+
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
14
|
+
}
|
|
15
|
+
export async function createCommand(id) {
|
|
16
|
+
if (!id || id.length === 0)
|
|
17
|
+
fail('用法: qomicex create <id>(如 qomicex create com.example.demo)');
|
|
18
|
+
const errors = checkId(id).filter((e) => !e.startsWith('id 建议'));
|
|
19
|
+
if (errors.length > 0)
|
|
20
|
+
fail(`非法插件 id:${errors.join(';')}`);
|
|
21
|
+
const dest = resolve(process.cwd(), id);
|
|
22
|
+
if (existsSync(dest) && readdirSync(dest).length > 0) {
|
|
23
|
+
fail(`目标目录已存在且非空: ${dest}`);
|
|
24
|
+
}
|
|
25
|
+
const src = templateDir();
|
|
26
|
+
if (!existsSync(src))
|
|
27
|
+
fail(`模板目录不存在: ${src}(CLI 安装不完整)`);
|
|
28
|
+
mkdirSync(dest, { recursive: true });
|
|
29
|
+
const name = humanizeName(id);
|
|
30
|
+
copyReplace(src, dest, { __QOMICEX_PLUGIN_ID__: id, __QOMICEX_PLUGIN_NAME__: name });
|
|
31
|
+
info(`✔ 已生成插件项目 ${dest}`);
|
|
32
|
+
info(` id : ${id}`);
|
|
33
|
+
info(` name : ${name}`);
|
|
34
|
+
info('');
|
|
35
|
+
info('下一步:');
|
|
36
|
+
info(` cd ${id}`);
|
|
37
|
+
info(' pnpm install # 安装依赖');
|
|
38
|
+
info(' pnpm run dev # 本地热重载预览');
|
|
39
|
+
info(' qomicex verify # manifest / 权限 / 长循环 校验');
|
|
40
|
+
info(' qomicex pack # 构建并打包 .qplugin');
|
|
41
|
+
info(' qomicex publish # 设备流登录后签名上传到商店');
|
|
42
|
+
info('');
|
|
43
|
+
info('提示:模板依赖已发布的 @qomicex/plugin-ui,可在任意目录独立构建;');
|
|
44
|
+
info('若项目创建在仓库 plugins-dev/ 内(pnpm workspace 覆盖区),请用 npm install 安装依赖。');
|
|
45
|
+
}
|
|
46
|
+
function copyReplace(src, dest, subs) {
|
|
47
|
+
for (const entry of readdirSync(src)) {
|
|
48
|
+
const s = join(src, entry);
|
|
49
|
+
const d = join(dest, entry);
|
|
50
|
+
if (statSync(s).isDirectory()) {
|
|
51
|
+
mkdirSync(d, { recursive: true });
|
|
52
|
+
copyReplace(s, d, subs);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
cpSync(s, d);
|
|
56
|
+
// 只在需要插值的文件里替换(避免误伤 src/ 里运行时全局 window.__PLUGIN_ID__)
|
|
57
|
+
if (/\.(json|html|md)$/.test(entry)) {
|
|
58
|
+
applySubs(d, subs);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function applySubs(file, subs) {
|
|
64
|
+
let content = readFileSync(file, 'utf8');
|
|
65
|
+
let changed = false;
|
|
66
|
+
for (const [k, v] of Object.entries(subs)) {
|
|
67
|
+
if (content.includes(k)) {
|
|
68
|
+
content = content.split(k).join(v);
|
|
69
|
+
changed = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (changed)
|
|
73
|
+
writeFileSync(file, content, 'utf8');
|
|
74
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// qomicex dev — 起本地插件调试环境。
|
|
2
|
+
// 在仓库内(plugins-dev/ 等)检测到 scripts/harness/run.mjs 时,spawn harness
|
|
3
|
+
// 走完整调试环境(Tauri mock + stub + 热重载);否则回退裸 Vite + 输出 dev 源配置。
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fail, info, runShell } from "../lib/io.js";
|
|
7
|
+
/** 从 cwd 逐级向上查找仓库根下的 scripts/harness/run.mjs;找不到返回 null */
|
|
8
|
+
function findHarnessStart() {
|
|
9
|
+
let dir = resolve(process.cwd());
|
|
10
|
+
while (true) {
|
|
11
|
+
const candidate = join(dir, 'scripts', 'harness', 'run.mjs');
|
|
12
|
+
if (existsSync(candidate))
|
|
13
|
+
return candidate;
|
|
14
|
+
const parent = dirname(dir);
|
|
15
|
+
if (parent === dir)
|
|
16
|
+
return null;
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function devCommand(opts = {}) {
|
|
21
|
+
const root = process.cwd();
|
|
22
|
+
if (!existsSync(join(root, 'manifest.json')))
|
|
23
|
+
fail('当前目录不是插件项目(缺少 manifest.json)');
|
|
24
|
+
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
|
|
25
|
+
const pluginId = manifest.id ?? 'unknown';
|
|
26
|
+
// 仓库内检测到 harness → 走完整调试环境(Tauri mock + stub + 热重载)。
|
|
27
|
+
// 注意:harness 固定使用仓库根 Vite(:1420) + stub(:5100),CLI 的 --port 在此模式不生效。
|
|
28
|
+
const harness = findHarnessStart();
|
|
29
|
+
if (harness) {
|
|
30
|
+
info(`✔ 检测到调试 harness(${harness}),进入完整调试环境(Tauri mock + stub + 热重载)`);
|
|
31
|
+
info(' 插件须位于仓库 plugins-dev/{id} 供 harness 定位;--port 参数在此模式不生效(固定 1420)');
|
|
32
|
+
info(' 停止:Ctrl+C(会一并清理 stub / Vite)');
|
|
33
|
+
const code = await runShell(`node ${JSON.stringify(harness)} --plugin ${JSON.stringify(pluginId)}`, root);
|
|
34
|
+
if (code !== 0)
|
|
35
|
+
fail(`harness 退出(code ${code})`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const port = opts.port ?? 5173;
|
|
39
|
+
const devUrl = `http://localhost:${port}`;
|
|
40
|
+
const devConfig = {
|
|
41
|
+
kind: 'dev-source-plugin',
|
|
42
|
+
pluginId,
|
|
43
|
+
vite: { url: devUrl, port },
|
|
44
|
+
entry: 'index.html',
|
|
45
|
+
launcherHint: '将本插件的 dev 源(registryUrl / 本地插件映射)指向上述地址,或在调试 harness 中打开该 URL。',
|
|
46
|
+
note: 'v1:仅输出配置产物与使用说明,不自动挂载到启动器。',
|
|
47
|
+
};
|
|
48
|
+
const outFile = join(root, '.qomicex-dev.json');
|
|
49
|
+
writeFileSync(outFile, JSON.stringify(devConfig, null, 2));
|
|
50
|
+
info(`✔ 已生成 dev 源插件配置 ${outFile}`);
|
|
51
|
+
info('');
|
|
52
|
+
info(`正在启动 Vite dev server: ${devUrl}`);
|
|
53
|
+
info(' 插件入口(与 manifest.entry.frontend 对应):本机预览打开 index.html 即可');
|
|
54
|
+
info(' 热重载:保存源码自动刷新;沙箱 API 桥在浏览器直开时降级为 null');
|
|
55
|
+
info(' 停止:Ctrl+C');
|
|
56
|
+
const code = await runShell(`pnpm exec vite --port ${port} --strictPort`, root);
|
|
57
|
+
if (code !== 0)
|
|
58
|
+
fail(`Vite dev server 退出(code ${code})`);
|
|
59
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// qomicex pack — 构建(tsc && vite build)并打 .qplugin(manifest.json 在 zip 根)。
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { readManifestFile, collectDir } from "../lib/project.js";
|
|
5
|
+
import { zipWrite } from "../lib/zip.js";
|
|
6
|
+
import { fail, info, runShell, warn } from "../lib/io.js";
|
|
7
|
+
import { parsePrivateKey, signPackage } from "../lib/signature.js";
|
|
8
|
+
/** 组装包内条目:manifest.json + dist/**,并把入口引用的根文件(theme.css/overlay.html)拷进 dist。 */
|
|
9
|
+
export function buildPackageEntries(root, manifest) {
|
|
10
|
+
const entries = {};
|
|
11
|
+
entries['manifest.json'] = new TextEncoder().encode(JSON.stringify(manifest));
|
|
12
|
+
const distDir = join(root, 'dist');
|
|
13
|
+
if (!existsSync(distDir))
|
|
14
|
+
fail('缺少 dist/ 目录,请先构建(pnpm run build 或使用 qomicex pack)');
|
|
15
|
+
for (const f of collectDir(distDir, 'dist/'))
|
|
16
|
+
entries[f.name] = f.data;
|
|
17
|
+
// entry.theme / contributes.overlay.file 引用 dist/ 下文件但源码在根目录时,自动拷入
|
|
18
|
+
const entry = manifest.entry;
|
|
19
|
+
const overlay = manifest.contributes?.overlay;
|
|
20
|
+
for (const ref of [entry?.theme, overlay?.file]) {
|
|
21
|
+
if (typeof ref !== 'string' || !ref.startsWith('dist/'))
|
|
22
|
+
continue;
|
|
23
|
+
const target = join(root, ref);
|
|
24
|
+
if (existsSync(target))
|
|
25
|
+
continue;
|
|
26
|
+
const rootSrc = join(root, ref.slice('dist/'.length));
|
|
27
|
+
if (existsSync(rootSrc)) {
|
|
28
|
+
writeFileSync(target, readFileSync(rootSrc));
|
|
29
|
+
entries[ref] = readFileSync(target);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return entries;
|
|
33
|
+
}
|
|
34
|
+
export async function packCommand(opts = {}) {
|
|
35
|
+
const root = process.cwd();
|
|
36
|
+
const manifestFile = join(root, 'manifest.json');
|
|
37
|
+
if (!existsSync(manifestFile))
|
|
38
|
+
fail('当前目录不是插件项目(缺少 manifest.json)');
|
|
39
|
+
if (!opts.skipBuild) {
|
|
40
|
+
info('==> 构建前端(tsc + vite build)');
|
|
41
|
+
const code = await runShell('pnpm run build', root);
|
|
42
|
+
if (code !== 0)
|
|
43
|
+
fail('构建失败,请先修复编译错误');
|
|
44
|
+
}
|
|
45
|
+
const manifest = readManifestFile(manifestFile);
|
|
46
|
+
const id = String(manifest.id ?? '');
|
|
47
|
+
const version = opts.version ?? String(manifest.version ?? '');
|
|
48
|
+
if (!id)
|
|
49
|
+
fail('manifest.json 缺少 id');
|
|
50
|
+
let entries = buildPackageEntries(root, manifest);
|
|
51
|
+
// 可选签名(仅 signature.json;需要证书链请用 publish)
|
|
52
|
+
if (opts.key) {
|
|
53
|
+
const keyContent = await readKeyInput(opts.key);
|
|
54
|
+
const priv = parsePrivateKey(keyContent);
|
|
55
|
+
const { derivePublicKey } = await import("../lib/signature.js");
|
|
56
|
+
const pub = await derivePublicKey(priv);
|
|
57
|
+
const keyId = `ed25519:${pub.slice(0, 8)}`;
|
|
58
|
+
const localCert = join(root, 'signature.cert.json');
|
|
59
|
+
let certJson;
|
|
60
|
+
if (existsSync(localCert))
|
|
61
|
+
certJson = readFileSync(localCert, 'utf8');
|
|
62
|
+
const sig = await signPackage(entries, priv, keyId, certJson);
|
|
63
|
+
entries = { ...entries, ...sig };
|
|
64
|
+
if (!certJson)
|
|
65
|
+
warn('缺少本地 signature.cert.json,商店上传仍需证书;建议改用 qomicex publish 走完整签名');
|
|
66
|
+
}
|
|
67
|
+
const outDir = resolve(root, opts.outDir ?? 'release');
|
|
68
|
+
mkdirSync(outDir, { recursive: true });
|
|
69
|
+
const outPath = join(outDir, `${id}-${version}.qplugin`);
|
|
70
|
+
writeFileSync(outPath, zipWrite(Object.entries(entries).map(([name, data]) => ({ name, data }))));
|
|
71
|
+
info(`✔ 已生成 ${outPath} (${entriesCount(entries)} 个文件)`);
|
|
72
|
+
return { outPath, entries };
|
|
73
|
+
}
|
|
74
|
+
async function readKeyInput(key) {
|
|
75
|
+
if (key.trim().startsWith('-----BEGIN') || /^[A-Za-z0-9+/=]{20,}$/.test(key.trim())) {
|
|
76
|
+
// 内联 PEM / base64
|
|
77
|
+
return key.trim();
|
|
78
|
+
}
|
|
79
|
+
const { readFileSync } = await import('node:fs');
|
|
80
|
+
const p = resolve(process.cwd(), key);
|
|
81
|
+
return readFileSync(p, 'utf8').trim();
|
|
82
|
+
}
|
|
83
|
+
function entriesCount(entries) {
|
|
84
|
+
return Object.keys(entries).length;
|
|
85
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// qomicex publish — 设备流登录(RFC 8628)→ 注册签名公钥 → 签名打包 → 上传到商店。
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { buildPackageEntries, packCommand } from "./pack.js";
|
|
5
|
+
import { readManifestFile } from "../lib/project.js";
|
|
6
|
+
import { zipRead, zipWrite } from "../lib/zip.js";
|
|
7
|
+
import { DEFAULT_API_BASE, StoreApiError, requestDeviceCode, pollDeviceLogin, fetchMinePlugins, createPlugin, registerDevKey, uploadVersion, } from "../lib/store.js";
|
|
8
|
+
import { parsePrivateKey, derivePublicKey, signPackage } from "../lib/signature.js";
|
|
9
|
+
import { confirm, fail, info, warn } from "../lib/io.js";
|
|
10
|
+
export async function publishCommand(opts = {}) {
|
|
11
|
+
const root = process.cwd();
|
|
12
|
+
const manifestFile = join(root, 'manifest.json');
|
|
13
|
+
if (!existsSync(manifestFile))
|
|
14
|
+
fail('当前目录不是插件项目(缺少 manifest.json)');
|
|
15
|
+
const manifest = readManifestFile(manifestFile);
|
|
16
|
+
const slug = opts.slug ?? String(manifest.id ?? '');
|
|
17
|
+
const version = opts.version ?? String(manifest.version ?? '');
|
|
18
|
+
if (!slug)
|
|
19
|
+
fail('manifest.json 缺少 id,无法发布');
|
|
20
|
+
// 1) 私钥(必须,商店要求签名包)
|
|
21
|
+
const keyContent = process.env.QOMICEX_SIGN_KEY || (opts.key ? await readKeyFile(opts.key) : '');
|
|
22
|
+
if (!keyContent) {
|
|
23
|
+
fail('缺少签名私钥:请通过环境变量 QOMICEX_SIGN_KEY 或 --key <私钥文件> 提供(PKCS#8 PEM / raw base64 seed)');
|
|
24
|
+
}
|
|
25
|
+
const priv = parsePrivateKey(keyContent);
|
|
26
|
+
const publicKey = await derivePublicKey(priv);
|
|
27
|
+
// 2) 打包(复用 pack 管线;已有 --package 则直接读取)
|
|
28
|
+
let entries;
|
|
29
|
+
if (opts.package) {
|
|
30
|
+
const p = resolve(root, opts.package);
|
|
31
|
+
if (!existsSync(p))
|
|
32
|
+
fail(`找不到包文件: ${p}`);
|
|
33
|
+
entries = zipRead(readFileSync(p));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const packOpts = { ...opts };
|
|
37
|
+
await packCommand({ ...packOpts, key: undefined });
|
|
38
|
+
entries = buildPackageEntries(root, manifest);
|
|
39
|
+
}
|
|
40
|
+
// 清掉旧签名文件,避免脏残留
|
|
41
|
+
for (const k of ['signature.json', 'signature.cert.json'])
|
|
42
|
+
delete entries[k];
|
|
43
|
+
// 3) 设备流登录
|
|
44
|
+
const api = opts.api ?? process.env.QOMICEX_STORE_API ?? DEFAULT_API_BASE;
|
|
45
|
+
info('==> 设备流登录');
|
|
46
|
+
const code = await requestDeviceCode(api);
|
|
47
|
+
info(` 请在浏览器打开(或扫码)并登录确认:`);
|
|
48
|
+
info(` ${code.verificationUriComplete}`);
|
|
49
|
+
info(` 授权码: ${code.userCode}`);
|
|
50
|
+
const login = await pollDeviceLogin(api, code);
|
|
51
|
+
info(`✔ 登录成功:${login.user.username}`);
|
|
52
|
+
// 4) 注册/刷新签名公钥,获取商店根钥签发的证书
|
|
53
|
+
info('==> 注册签名公钥');
|
|
54
|
+
const keyRes = await registerDevKey(api, login.accessToken, publicKey);
|
|
55
|
+
const certJson = JSON.stringify(keyRes.cert);
|
|
56
|
+
info(`✔ 签名证书就绪(keyId: ${keyRes.keyId})`);
|
|
57
|
+
// 5) 签名
|
|
58
|
+
const sig = await signPackage(entries, priv, keyRes.keyId, certJson);
|
|
59
|
+
entries = { ...entries, ...sig };
|
|
60
|
+
const signedBytes = zipWrite(Object.entries(entries).map(([name, data]) => ({ name, data })));
|
|
61
|
+
// 6) 查找/创建插件记录
|
|
62
|
+
info('==> 确认插件记录');
|
|
63
|
+
let pluginId = '';
|
|
64
|
+
const mine = await fetchMinePlugins(api, login.accessToken);
|
|
65
|
+
const existing = mine.find((p) => p.slug === slug);
|
|
66
|
+
if (existing) {
|
|
67
|
+
pluginId = existing.id;
|
|
68
|
+
info(`✔ 找到已有插件 ${slug}(id: ${pluginId})`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
try {
|
|
72
|
+
const created = await createPlugin(api, login.accessToken, {
|
|
73
|
+
slug,
|
|
74
|
+
name: String(manifest.name ?? slug),
|
|
75
|
+
description: String(manifest.description ?? ''),
|
|
76
|
+
orgId: opts.orgId,
|
|
77
|
+
});
|
|
78
|
+
pluginId = created.id;
|
|
79
|
+
info(`✔ 已创建插件记录 ${slug}(id: ${pluginId},等待商店审核)`);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
if (e instanceof StoreApiError && e.status === 409) {
|
|
83
|
+
const again = await fetchMinePlugins(api, login.accessToken);
|
|
84
|
+
const found = again.find((p) => p.slug === slug);
|
|
85
|
+
if (!found)
|
|
86
|
+
throw e;
|
|
87
|
+
pluginId = found.id;
|
|
88
|
+
info(`✔ slug 已存在,复用插件 ${slug}(id: ${pluginId})`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// 7) 确认后上传
|
|
96
|
+
info('');
|
|
97
|
+
info(`发布预览:`);
|
|
98
|
+
info(` 插件: ${slug} v${version}`);
|
|
99
|
+
info(` keyId: ${keyRes.keyId}`);
|
|
100
|
+
info(` 包体: ${signedBytes.length} 字节(已签名)`);
|
|
101
|
+
const ok = await confirm('确认发布到商店?', opts.yes ?? false);
|
|
102
|
+
if (!ok) {
|
|
103
|
+
warn('已取消发布');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
info('==> 上传版本');
|
|
107
|
+
const result = await uploadVersion(api, login.accessToken, pluginId, signedBytes, opts.changelog);
|
|
108
|
+
info(`✔ 上传完成:${slug} v${version}`);
|
|
109
|
+
info(` 商店地址: ${api.replace('/api/v1', '')}`);
|
|
110
|
+
if (typeof result === 'object' && result && 'status' in result) {
|
|
111
|
+
info(` 状态: ${String(result.status)}`);
|
|
112
|
+
}
|
|
113
|
+
// 保存签名包到 release/,方便复验
|
|
114
|
+
const outDir = resolve(root, 'release');
|
|
115
|
+
const { mkdirSync } = await import('node:fs');
|
|
116
|
+
mkdirSync(outDir, { recursive: true });
|
|
117
|
+
const outPath = join(outDir, `${slug}-${version}.signed.qplugin`);
|
|
118
|
+
writeFileSync(outPath, signedBytes);
|
|
119
|
+
info(`✔ 已保存签名包 ${outPath}(可 qomicex verify --package 复验)`);
|
|
120
|
+
}
|
|
121
|
+
async function readKeyFile(key) {
|
|
122
|
+
const p = resolve(process.cwd(), key);
|
|
123
|
+
if (existsSync(p))
|
|
124
|
+
return readFileSync(p, 'utf8').trim();
|
|
125
|
+
return key.trim();
|
|
126
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// qomicex verify — manifest 合法性 + 权限最小化 + 长循环告警 + 签名检查。
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { validateManifest } from "../lib/manifest.js";
|
|
5
|
+
import { checkPermissions, checkLongLoops } from "../lib/scan.js";
|
|
6
|
+
import { zipRead } from "../lib/zip.js";
|
|
7
|
+
import { verifyPackageSignature } from "../lib/signature.js";
|
|
8
|
+
import { fail, info, warn } from "../lib/io.js";
|
|
9
|
+
export async function verifyCommand(opts = {}) {
|
|
10
|
+
if (opts.package) {
|
|
11
|
+
await verifyPackage(opts.package);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const root = process.cwd();
|
|
15
|
+
const manifestFile = join(root, 'manifest.json');
|
|
16
|
+
if (!existsSync(manifestFile))
|
|
17
|
+
fail('当前目录不是插件项目(缺少 manifest.json)');
|
|
18
|
+
const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
|
19
|
+
const findings = [];
|
|
20
|
+
let errors = 0;
|
|
21
|
+
const mCheck = validateManifest(manifest);
|
|
22
|
+
findings.push(...mCheck.issues);
|
|
23
|
+
errors += mCheck.issues.filter((i) => i.severity === 'error').length;
|
|
24
|
+
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
|
|
25
|
+
const permCheck = checkPermissions(root, permissions);
|
|
26
|
+
findings.push(...permCheck.findings);
|
|
27
|
+
errors += permCheck.findings.filter((f) => f.severity === 'error').length;
|
|
28
|
+
const loops = checkLongLoops(root);
|
|
29
|
+
findings.push(...loops);
|
|
30
|
+
for (const f of findings) {
|
|
31
|
+
const tag = f.severity === 'error' ? '✗' : '⚠';
|
|
32
|
+
const loc = 'file' in f && f.file ? ` (${f.file})` : '';
|
|
33
|
+
if (f.severity === 'error')
|
|
34
|
+
console.error(`${tag} ${f.message}${loc}`);
|
|
35
|
+
else
|
|
36
|
+
console.warn(`${tag} ${f.message}${loc}`);
|
|
37
|
+
}
|
|
38
|
+
info(`\n校验完成:${mCheck.issues.length} manifest 项,权限 ${permissions.length} 项(未用 ${permCheck.unused.length},缺声明 ${permCheck.missing.length}),长循环告警 ${loops.length}`);
|
|
39
|
+
if (errors > 0) {
|
|
40
|
+
console.error(`发现 ${errors} 个错误,请修复后重试`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
info('✔ verify 通过');
|
|
44
|
+
}
|
|
45
|
+
async function verifyPackage(pkg) {
|
|
46
|
+
const file = resolve(process.cwd(), pkg);
|
|
47
|
+
if (!existsSync(file))
|
|
48
|
+
fail(`找不到文件: ${file}`);
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = zipRead(readFileSync(file));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
fail(`不是有效的 .qplugin(zip): ${e instanceof Error ? e.message : String(e)}`);
|
|
55
|
+
}
|
|
56
|
+
if (!entries['manifest.json'])
|
|
57
|
+
fail('包内缺少根级 manifest.json');
|
|
58
|
+
const manifest = JSON.parse(new TextDecoder().decode(entries['manifest.json']));
|
|
59
|
+
const mCheck = validateManifest(manifest);
|
|
60
|
+
for (const i of mCheck.issues) {
|
|
61
|
+
if (i.severity === 'error')
|
|
62
|
+
console.error(`✗ [manifest.${i.path}] ${i.message}`);
|
|
63
|
+
else
|
|
64
|
+
warn(`[manifest.${i.path}] ${i.message}`);
|
|
65
|
+
}
|
|
66
|
+
const sig = await verifyPackageSignature(entries);
|
|
67
|
+
if (sig.ok) {
|
|
68
|
+
info(`✔ 签名有效(开发者: ${sig.cert.developerName}, keyId: ${sig.cert.keyId})`);
|
|
69
|
+
}
|
|
70
|
+
else if (sig.code === 'signature_required') {
|
|
71
|
+
warn(sig.reason);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
console.error(`✗ 签名无效: ${sig.reason}`);
|
|
75
|
+
}
|
|
76
|
+
if (!mCheck.ok || (sig.ok === false && sig.code === 'signature_invalid')) {
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
info('✔ verify 通过');
|
|
80
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// qomicex CLI 入口:create / dev / pack / verify / publish
|
|
3
|
+
import { createCommand } from "./commands/create.js";
|
|
4
|
+
import { devCommand } from "./commands/dev.js";
|
|
5
|
+
import { packCommand } from "./commands/pack.js";
|
|
6
|
+
import { verifyCommand } from "./commands/verify.js";
|
|
7
|
+
import { publishCommand } from "./commands/publish.js";
|
|
8
|
+
import { fail } from "./lib/io.js";
|
|
9
|
+
const VERSION = '0.1.0';
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const positional = [];
|
|
12
|
+
const options = {};
|
|
13
|
+
const command = argv[0] ?? 'help';
|
|
14
|
+
for (let i = 1; i < argv.length; i++) {
|
|
15
|
+
const a = argv[i];
|
|
16
|
+
if (a.startsWith('--')) {
|
|
17
|
+
const eq = a.indexOf('=');
|
|
18
|
+
if (eq !== -1) {
|
|
19
|
+
options[a.slice(2, eq)] = a.slice(eq + 1);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const next = argv[i + 1];
|
|
23
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
24
|
+
options[a.slice(2)] = next;
|
|
25
|
+
i++;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
options[a.slice(2)] = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else if (a.startsWith('-') && a.length === 2) {
|
|
33
|
+
options[a.slice(1)] = true;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
positional.push(a);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { command, positional, options };
|
|
40
|
+
}
|
|
41
|
+
function helpText() {
|
|
42
|
+
return `
|
|
43
|
+
qomicex v${VERSION} — Qomicex 插件生态 CLI
|
|
44
|
+
|
|
45
|
+
用法:
|
|
46
|
+
qomicex create <id> 从内置模板生成合法插件项目(Vite+React+TS+plugin-ui+tailwind)
|
|
47
|
+
qomicex dev [--port <n>] 起本地 Vite dev server + 生成 dev 源插件配置(默认 5173)
|
|
48
|
+
qomicex pack [--out-dir <d>] [--version <v>] [--key <k>] [--skip-build]
|
|
49
|
+
构建并打 .qplugin(manifest.json 在 zip 根)
|
|
50
|
+
qomicex verify [--package <f>]
|
|
51
|
+
manifest 合法性 + 权限最小化 + 长循环告警 + 签名检查
|
|
52
|
+
qomicex publish [--key <k>] [--slug <s>] [--changelog <c>] [--api <url>] [--org-id <id>] [--package <f>] [--yes]
|
|
53
|
+
设备流登录 → 注册签名公钥 → 签名 → 上传到商店
|
|
54
|
+
qomicex --help | -h 显示帮助
|
|
55
|
+
qomicex --version | -v 显示版本
|
|
56
|
+
|
|
57
|
+
环境变量:
|
|
58
|
+
QOMICEX_SIGN_KEY 签名私钥(PKCS#8 PEM 或 raw base64 seed),publish 必填
|
|
59
|
+
QOMICEX_STORE_API 商店 API base(默认 https://plugins.qomicex.top/api/v1)
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
async function main() {
|
|
63
|
+
const { command, positional, options } = parseArgs(process.argv.slice(2));
|
|
64
|
+
if (command === 'help' || command === '--help' || command === '-h' || options['help']) {
|
|
65
|
+
console.log(helpText());
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (command === '--version' || command === '-v' || command === 'version') {
|
|
69
|
+
console.log(VERSION);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const num = (v, d) => (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : d);
|
|
73
|
+
try {
|
|
74
|
+
switch (command) {
|
|
75
|
+
case 'create':
|
|
76
|
+
await createCommand(positional[0] ?? '');
|
|
77
|
+
break;
|
|
78
|
+
case 'dev':
|
|
79
|
+
await devCommand({ port: num(options['port'], 5173) });
|
|
80
|
+
break;
|
|
81
|
+
case 'pack':
|
|
82
|
+
await packCommand({
|
|
83
|
+
outDir: typeof options['out-dir'] === 'string' ? options['out-dir'] : undefined,
|
|
84
|
+
version: typeof options['version'] === 'string' ? options['version'] : undefined,
|
|
85
|
+
key: typeof options['key'] === 'string' ? options['key'] : undefined,
|
|
86
|
+
skipBuild: options['skip-build'] === true,
|
|
87
|
+
});
|
|
88
|
+
break;
|
|
89
|
+
case 'verify':
|
|
90
|
+
await verifyCommand({ package: typeof options['package'] === 'string' ? options['package'] : undefined });
|
|
91
|
+
break;
|
|
92
|
+
case 'publish':
|
|
93
|
+
await publishCommand({
|
|
94
|
+
key: typeof options['key'] === 'string' ? options['key'] : undefined,
|
|
95
|
+
slug: typeof options['slug'] === 'string' ? options['slug'] : undefined,
|
|
96
|
+
changelog: typeof options['changelog'] === 'string' ? options['changelog'] : undefined,
|
|
97
|
+
api: typeof options['api'] === 'string' ? options['api'] : undefined,
|
|
98
|
+
orgId: typeof options['org-id'] === 'string' ? options['org-id'] : undefined,
|
|
99
|
+
package: typeof options['package'] === 'string' ? options['package'] : undefined,
|
|
100
|
+
yes: options['yes'] === true,
|
|
101
|
+
});
|
|
102
|
+
break;
|
|
103
|
+
default:
|
|
104
|
+
fail(`未知命令: ${command}\n${helpText()}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
void main();
|
package/dist/lib/io.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// CLI 通用 IO:输出 / 退出 / 子进程。
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
export function info(msg) {
|
|
4
|
+
console.log(msg);
|
|
5
|
+
}
|
|
6
|
+
export function warn(msg) {
|
|
7
|
+
console.warn(`⚠ ${msg}`);
|
|
8
|
+
}
|
|
9
|
+
export function fail(msg) {
|
|
10
|
+
console.error(`✗ ${msg}`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
export function sleep(ms) {
|
|
14
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
}
|
|
16
|
+
/** 以 shell 方式运行命令(继承 stdio),返回退出码。Windows/Linux/macOS 通用。 */
|
|
17
|
+
export function runShell(cmdLine, cwd) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const child = spawn(cmdLine, { cwd, stdio: 'inherit', shell: true });
|
|
20
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
21
|
+
child.on('error', () => resolve(1));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** 非交互确认:默认 N,回答 y/yes 才放行。 */
|
|
25
|
+
export async function confirm(question, yesFlag) {
|
|
26
|
+
if (yesFlag)
|
|
27
|
+
return true;
|
|
28
|
+
process.stdout.write(`${question} [y/N] `);
|
|
29
|
+
return await new Promise((resolve) => {
|
|
30
|
+
process.stdin.once('data', (d) => {
|
|
31
|
+
const a = d.toString().trim().toLowerCase();
|
|
32
|
+
resolve(a === 'y' || a === 'yes');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|