kt-template-online-api/scripts/qqbot-plugin/cli.ts

587 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
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} <path>`);
}
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<QqbotPluginCliOptions>) => {
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<QqbotPluginCliOptions>,
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 <pluginKey>');
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<string, string>,
) => {
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<string, string>,
) => {
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<QqbotPluginCliOptions>,
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<QqbotPluginCliOptions>,
): QqbotPluginCliResult => {
if (!pluginRootArg) throw new Error('Usage: qqbot-plugin validate <path>');
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<QqbotPluginCliOptions>,
outputPathArg?: string,
): QqbotPluginCliResult => {
if (!pluginRootArg) throw new Error('Usage: qqbot-plugin pack <path>');
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<QqbotPluginCliOptions>,
): QqbotPluginCliResult => {
if (!packagePathArg) {
throw new Error('Usage: qqbot-plugin install-local <package>');
}
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<QqbotPluginCliResult> => {
const command = argv[0] as QqbotPluginCliCommand | undefined;
const resolvedOptions: Required<QqbotPluginCliOptions> = {
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 <create|validate|pack|install-local> [args]',
);
}
};
if (require.main === module) {
runQqbotPluginCli(process.argv.slice(2)).catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}