import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import { parseQqbotPluginManifest, type QqbotPluginManifest, } from '../../src/modules/qqbot/plugin-platform/domain/manifest'; export type QqbotPluginCliCommand = | 'create' | 'install-local' | 'pack' | 'validate'; export type QqbotPluginCliOptions = { cwd?: string; stderr?: (message: string) => void; stdout?: (message: string) => void; }; export type QqbotPluginCliResult = { command: QqbotPluginCliCommand; exitCode: number; installationId?: string; packageHash?: string; packagePath?: string; pluginKey?: string; pluginRoot?: string; version?: string; versionId?: string; }; type PackedPluginFile = { path: string; sha256: string; }; type PackedPlugin = { contentHash: string; files: PackedPluginFile[]; manifest: QqbotPluginManifest; }; const templateRoot = path.join(__dirname, 'templates', 'basic'); const packageExtension = '.qqbot-plugin.json'; const maxPackageFileBytes = 5 * 1024 * 1024; const pluginKeyPattern = /^[a-z][a-z0-9-]{2,63}$/; const allowedTopLevelEntries = new Set([ 'LICENSE', 'LICENSE.txt', 'README.md', 'assets', 'migrations', 'plugin.json', 'src', 'tests', ]); /** * 执行 脚本或 CLI流程。 * @param content - 待处理内容;驱动 `crypto.createHash()` 的 CLI步骤。 */ const sha256 = (content: Buffer | string) => crypto.createHash('sha256').update(content).digest('hex'); /** * 执行 脚本或 CLI流程。 * @param value - 待稳定序列化的值;转换 CLI列表项。 * @returns 脚本或 CLI渲染后的图片、画布或文本。 */ const stableStringify = (value: unknown): string => { if (Array.isArray(value)) { return `[${value.map((item) => stableStringify(item)).join(',')}]`; } if (value && typeof value === 'object') { const record = value as Record; return `{${Object.keys(record) .filter((key) => record[key] !== undefined) .sort() .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) .join(',')}}`; } return JSON.stringify(value); }; /** * 转换 脚本或 CLI输入。 * @param pluginKey - pluginKey 输入;影响 formatPluginName 的返回值。 */ const formatPluginName = (pluginKey: string) => { return pluginKey .split('-') .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(' '); }; /** * 查询 脚本或 CLI数据。 * @param argv - CLI 参数数组;执行 `argv.indexOf()` 对应的 CLI步骤。 * @param optionName - optionName 输入;驱动 `argv.indexOf()` 的 CLI步骤。 */ const getOptionValue = (argv: string[], optionName: string) => { const optionIndex = argv.indexOf(optionName); if (optionIndex < 0) return undefined; const value = argv[optionIndex + 1]; if (!value || value.startsWith('--')) { throw new Error(`Usage: ${optionName} `); } return value; }; /** * 判断 脚本或 CLI条件。 * @param parent - parent 输入;定位文件系统路径。 * @param child - child 输入;定位文件系统路径。 */ const isInsideDirectory = (parent: string, child: string) => { const relativePath = path.relative(parent, child); return ( relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) ); }; /** * 查询 脚本或 CLI数据。 * @param cwd - 当前工作目录;定位文件系统路径。 */ const findWorkspaceRoot = (cwd: string) => { let current = path.resolve(cwd); while (true) { if (fs.existsSync(path.join(current, 'AGENTS.md'))) return current; const parent = path.dirname(current); if (parent === current) return null; current = parent; } }; /** * 查询 脚本或 CLI数据。 * @param options - CLI列表;使用 `cwd` 字段生成结果。 */ const getControlledRoots = (options: Required) => { const cwd = path.resolve(options.cwd); const roots = [cwd]; if (fs.existsSync(path.join(cwd, 'package.json'))) { const workspaceRoot = findWorkspaceRoot(cwd); const artifactRoot = workspaceRoot || path.resolve(cwd, '..', '..'); roots.push(path.join(artifactRoot, '.kt-workspace')); } return roots; }; /** * 解析Controlled Path。 * @param pathArg - CLI路径;定位文件系统路径。 * @param options - CLI列表;使用 `cwd` 字段生成结果。 * @param label - 字典展示文本;影响 resolveControlledPath 的返回值。 */ const resolveControlledPath = ( pathArg: string, options: Required, label: string, ) => { const resolvedPath = path.resolve(options.cwd, pathArg); const allowed = getControlledRoots(options).some((root) => isInsideDirectory(root, resolvedPath), ); if (!allowed) { throw new Error(`Unsafe ${label} output path: ${resolvedPath}`); } return resolvedPath; }; /** * 执行 脚本或 CLI流程。 * @param pluginKey - pluginKey 输入;决定 CLI条件分支。 * @returns 脚本或 CLI渲染后的图片、画布或文本。 */ function assertPluginKey( pluginKey: string | undefined, ): asserts pluginKey is string { if (!pluginKey) throw new Error('Usage: qqbot-plugin create '); if (!pluginKeyPattern.test(pluginKey)) { throw new Error(`Invalid QQBot plugin key: ${pluginKey}`); } } /** * 读取 脚本或 CLI资源。 * @param pluginRoot - pluginRoot 输入;定位文件系统路径。 */ const readManifest = (pluginRoot: string) => { const manifestPath = path.join(pluginRoot, 'plugin.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown; return parseQqbotPluginManifest(manifest, { pluginRoot }); }; /** * 执行 脚本或 CLI流程。 * @param sourcePath - CLI路径;读取本地文件内容。 * @param targetPath - CLI路径;驱动 `fs.mkdirSync()`、`fs.writeFileSync()` 的 CLI步骤。 * @param replacements - CLI列表;驱动 `Object.entries()` 的 CLI步骤。 */ const copyTemplateFile = ( sourcePath: string, targetPath: string, replacements: Record, ) => { const content = fs.readFileSync(sourcePath); const textContent = content.toString('utf8'); const rendered = Object.entries(replacements).reduce( (result, [key, value]) => result.replaceAll(key, value), textContent, ); fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.writeFileSync(targetPath, rendered); }; /** * 执行 脚本或 CLI流程。 * @param sourceRoot - sourceRoot 输入;定位文件系统路径。 * @param targetRoot - targetRoot 输入;定位文件系统路径。 * @param replacements - CLI列表;驱动 `copyTemplateDirectory()`、`copyTemplateFile()` 的 CLI步骤。 */ const copyTemplateDirectory = ( sourceRoot: string, targetRoot: string, replacements: Record, ) => { for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) { const sourcePath = path.join(sourceRoot, entry.name); const targetPath = path.join(targetRoot, entry.name); if (entry.isDirectory()) { copyTemplateDirectory(sourcePath, targetPath, replacements); continue; } copyTemplateFile(sourcePath, targetPath, replacements); } }; /** * 列出Package Files。 * @param pluginRoot - pluginRoot 输入;驱动 `relative()`、`visit()` 的 CLI步骤。 * @returns 脚本或 CLI查询结果。 */ const listPackageFiles = (pluginRoot: string): PackedPluginFile[] => { const files: PackedPluginFile[] = []; const ignoredRoots = new Set(['dist', 'node_modules']); /** * 遍历 脚本或 CLI结构。 * @param current - current 输入;定位文件系统路径。 */ const visit = (current: string) => { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { if (current === pluginRoot && ignoredRoots.has(entry.name)) continue; if (entry.name.startsWith('.')) { throw new Error( `Hidden files are not allowed in plugin packages: ${entry.name}`, ); } if (entry.isSymbolicLink()) { throw new Error( `Symbolic links are not allowed in plugin packages: ${entry.name}`, ); } const absolutePath = path.join(current, entry.name); const relativePath = path .relative(pluginRoot, absolutePath) .replace(/\\/g, '/'); const topLevelEntry = relativePath.split('/')[0]; if (!allowedTopLevelEntries.has(topLevelEntry)) { throw new Error( `Plugin package path is not whitelisted: ${relativePath}`, ); } if (entry.isDirectory()) { visit(absolutePath); continue; } const fileSize = fs.statSync(absolutePath).size; if (fileSize > maxPackageFileBytes) { throw new Error(`Plugin package file is too large: ${relativePath}`); } files.push({ path: relativePath, sha256: sha256(fs.readFileSync(absolutePath)), }); } }; visit(pluginRoot); return files.sort((a, b) => a.path.localeCompare(b.path)); }; /** * 执行 脚本或 CLI流程。 * @param pluginRoot - pluginRoot 输入;定位文件系统路径。 */ const assertPluginSourceBoundary = (pluginRoot: string) => { const forbiddenPatterns: Array<[RegExp, string]> = [ [/\bfrom\s+['"]@nestjs\//, '@nestjs imports'], [/\bfrom\s+['"](?:node:)?fs['"]/, 'fs imports'], [/\brequire\(['"](?:node:)?fs['"]\)/, 'fs require'], [/\bfrom\s+['"]axios['"]/, 'axios imports'], [/\brequire\(['"]axios['"]\)/, 'axios require'], [/\bprocess\.env\b/, 'process.env access'], ]; for (const file of listPackageFiles(pluginRoot)) { if (!/\.[cm]?[tj]sx?$/.test(file.path)) continue; const filePath = path.join(pluginRoot, file.path); const source = fs.readFileSync(filePath, 'utf8'); const violation = forbiddenPatterns.find(([pattern]) => pattern.test(source), ); if (violation) { throw new Error( `Forbidden plugin source boundary violation in ${file.path}: ${violation[1]}`, ); } } }; /** * 创建 脚本或 CLI对象或配置。 * @param pluginRoot - pluginRoot 输入;驱动 `readManifest()`、`assertPluginSourceBoundary()`、`listPackageFiles()` 的 CLI步骤。 * @returns 创建后的 脚本或 CLI对象或配置。 */ const buildPackedPlugin = (pluginRoot: string): PackedPlugin => { const manifest = readManifest(pluginRoot); assertPluginSourceBoundary(pluginRoot); const files = listPackageFiles(pluginRoot); const contentHash = sha256( stableStringify({ files, manifest, }), ); return { contentHash, files, manifest, }; }; /** * 执行 脚本或 CLI流程。 * @param packedPlugin - packedPlugin 输入;使用 `files`、`manifest`、`contentHash` 字段生成结果。 */ const assertPackageIntegrity = (packedPlugin: PackedPlugin) => { const expectedHash = sha256( stableStringify({ files: packedPlugin.files, manifest: packedPlugin.manifest, }), ); if (packedPlugin.contentHash !== expectedHash) { throw new Error('QQBot plugin package content hash mismatch.'); } parseQqbotPluginManifest(packedPlugin.manifest); }; /** * 创建 脚本或 CLI对象或配置。 * @param pluginKey - pluginKey 输入;驱动 `assertPluginKey()`、`resolveControlledPath()`、`formatPluginName()` 的 CLI步骤。 * @param options - CLI列表;使用 `cwd` 字段生成结果。 * @param outputPathArg - CLI路径;驱动 `resolveControlledPath()` 的 CLI步骤。 * @returns 创建后的 脚本或 CLI对象或配置。 */ const createPlugin = ( pluginKey: string | undefined, options: Required, outputPathArg?: string, ): QqbotPluginCliResult => { assertPluginKey(pluginKey); const pluginRoot = outputPathArg ? resolveControlledPath(outputPathArg, options, 'create') : path.join(options.cwd, 'plugins', pluginKey); if (fs.existsSync(pluginRoot)) { throw new Error(`Plugin already exists: ${pluginRoot}`); } copyTemplateDirectory(templateRoot, pluginRoot, { __PLUGIN_KEY__: pluginKey, __PLUGIN_NAME__: formatPluginName(pluginKey), }); fs.mkdirSync(path.join(pluginRoot, 'assets'), { recursive: true }); fs.mkdirSync(path.join(pluginRoot, 'migrations'), { recursive: true }); fs.mkdirSync(path.join(pluginRoot, 'src', 'application'), { recursive: true, }); fs.mkdirSync(path.join(pluginRoot, 'src', 'domain'), { recursive: true }); fs.mkdirSync(path.join(pluginRoot, 'src', 'events'), { recursive: true }); fs.mkdirSync(path.join(pluginRoot, 'src', 'infrastructure'), { recursive: true, }); fs.mkdirSync(path.join(pluginRoot, 'src', 'operations'), { recursive: true }); fs.mkdirSync(path.join(pluginRoot, 'tests'), { recursive: true }); readManifest(pluginRoot); options.stdout(`Created QQBot plugin: ${pluginRoot}`); return { command: 'create', exitCode: 0, pluginKey, pluginRoot, version: '0.1.0', }; }; /** * 判断 脚本或 CLI条件。 * @param pluginRootArg - pluginRootArg 输入;定位文件系统路径。 * @param options - CLI列表;使用 `cwd` 字段生成结果。 * @returns 脚本或 CLI产出的 QqbotPluginCliResult。 */ const validatePlugin = ( pluginRootArg: string | undefined, options: Required, ): QqbotPluginCliResult => { if (!pluginRootArg) throw new Error('Usage: qqbot-plugin validate '); const pluginRoot = path.resolve(options.cwd, pluginRootArg); const manifest = readManifest(pluginRoot); assertPluginSourceBoundary(pluginRoot); options.stdout( `Validated QQBot plugin: ${manifest.pluginKey}@${manifest.version}`, ); return { command: 'validate', exitCode: 0, pluginKey: manifest.pluginKey, pluginRoot, version: manifest.version, }; }; /** * 执行 脚本或 CLI流程。 * @param pluginRootArg - pluginRootArg 输入;定位文件系统路径。 * @param options - CLI列表;使用 `cwd` 字段生成结果。 * @param outputPathArg - CLI路径;驱动 `resolveControlledPath()` 的 CLI步骤。 * @returns 脚本或 CLI产出的 QqbotPluginCliResult。 */ const packPlugin = ( pluginRootArg: string | undefined, options: Required, outputPathArg?: string, ): QqbotPluginCliResult => { if (!pluginRootArg) throw new Error('Usage: qqbot-plugin pack '); const pluginRoot = path.resolve(options.cwd, pluginRootArg); const packedPlugin = buildPackedPlugin(pluginRoot); const shortHash = packedPlugin.contentHash.slice(0, 12); const packageRoot = outputPathArg ? resolveControlledPath(outputPathArg, options, 'pack') : path.join(pluginRoot, 'dist'); const packagePath = path.join( packageRoot, `${packedPlugin.manifest.pluginKey}-${packedPlugin.manifest.version}-${shortHash}${packageExtension}`, ); fs.mkdirSync(path.dirname(packagePath), { recursive: true }); fs.writeFileSync(packagePath, `${JSON.stringify(packedPlugin, null, 2)}\n`); options.stdout(`Packed QQBot plugin: ${packagePath}`); return { command: 'pack', exitCode: 0, packageHash: packedPlugin.contentHash, packagePath, pluginKey: packedPlugin.manifest.pluginKey, pluginRoot, version: packedPlugin.manifest.version, }; }; /** * 执行 脚本或 CLI流程。 * @param packagePathArg - CLI路径;定位文件系统路径。 * @param options - CLI列表;使用 `cwd` 字段生成结果。 * @returns 脚本或 CLI产出的 QqbotPluginCliResult。 */ const installLocalPlugin = ( packagePathArg: string | undefined, options: Required, ): QqbotPluginCliResult => { if (!packagePathArg) { throw new Error('Usage: qqbot-plugin install-local '); } const packagePath = path.resolve(options.cwd, packagePathArg); const packedPlugin = JSON.parse( fs.readFileSync(packagePath, 'utf8'), ) as PackedPlugin; assertPackageIntegrity(packedPlugin); const versionId = sha256( `version:${packedPlugin.manifest.pluginKey}:${packedPlugin.manifest.version}:${packedPlugin.contentHash}`, ).slice(0, 16); const installationId = sha256( `installation:${packagePath}:${packedPlugin.contentHash}`, ).slice(0, 16); options.stdout( `Installed local QQBot plugin package: ${packedPlugin.manifest.pluginKey}@${packedPlugin.manifest.version}`, ); return { command: 'install-local', exitCode: 0, installationId, packageHash: packedPlugin.contentHash, packagePath, pluginKey: packedPlugin.manifest.pluginKey, version: packedPlugin.manifest.version, versionId, }; }; /** * 执行Qqbot Plugin Cli。 * @param argv - CLI 参数数组;驱动 `createPlugin()`、`installLocalPlugin()`、`packPlugin()`、`validatePlugin()` 的 CLI步骤。 * @param options - CLI列表;使用 `cwd`、`stderr`、`stdout` 字段生成结果。 * @returns 异步完成后的 脚本或 CLI结果。 */ export const runQqbotPluginCli = async ( argv: string[], options: QqbotPluginCliOptions = {}, ): Promise => { const command = argv[0] as QqbotPluginCliCommand | undefined; const resolvedOptions: Required = { cwd: options.cwd || process.cwd(), stderr: options.stderr || ((message) => process.stderr.write(`${message}\n`)), stdout: options.stdout || ((message) => process.stdout.write(`${message}\n`)), }; switch (command) { case 'create': return createPlugin( argv[1], resolvedOptions, getOptionValue(argv, '--out'), ); case 'install-local': return installLocalPlugin(argv[1], resolvedOptions); case 'pack': return packPlugin( argv[1], resolvedOptions, getOptionValue(argv, '--out'), ); case 'validate': return validatePlugin(argv[1], resolvedOptions); default: throw new Error( 'Usage: qqbot-plugin [args]', ); } }; if (require.main === module) { runQqbotPluginCli(process.argv.slice(2)).catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }); }