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', ]); const sha256 = (content: Buffer | string) => crypto.createHash('sha256').update(content).digest('hex'); 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); }; const formatPluginName = (pluginKey: string) => { return pluginKey .split('-') .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(' '); }; 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; }; const isInsideDirectory = (parent: string, child: string) => { const relativePath = path.relative(parent, child); return ( relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) ); }; 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; } }; 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; }; 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}`); } } 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 }); }; 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); }; 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); } }; const listPackageFiles = (pluginRoot: string): PackedPluginFile[] => { const files: PackedPluginFile[] = []; const ignoredRoots = new Set(['dist', 'node_modules']); 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)); }; 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]}`, ); } } }; 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, }; }; 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); }; 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', }; }; 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, }; }; 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, }; }; 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, }; }; 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; }); }