feat: 增加通用插件包发现服务
This commit is contained in:
parent
374c1ba0de
commit
9314b303fc
@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
const DEFAULT_BUILTIN_PACKAGE_ROOT = resolve(
|
||||
process.cwd(),
|
||||
'src/modules/qqbot/plugins',
|
||||
);
|
||||
|
||||
/**
|
||||
* Restricts QQBot plugin package discovery and entry resolution to controlled package roots.
|
||||
*/
|
||||
@Injectable()
|
||||
export class QqbotPluginPackagePathPolicyService {
|
||||
private readonly controlledRoots: string[];
|
||||
|
||||
/**
|
||||
* Creates a path policy for package roots that the plugin platform may scan.
|
||||
* @param controlledRoots - Directory roots that may contain one-level QQBot plugin package folders.
|
||||
*/
|
||||
constructor(controlledRoots = [DEFAULT_BUILTIN_PACKAGE_ROOT]) {
|
||||
this.controlledRoots = controlledRoots.map((root) => resolve(root));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists controlled roots that currently exist as directories on disk.
|
||||
* @returns Absolute root directories that may be scanned for package manifests.
|
||||
*/
|
||||
listExistingRoots(): string[] {
|
||||
return this.controlledRoots.filter(
|
||||
(root) => existsSync(root) && statSync(root).isDirectory(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a manifest entry to an absolute file path inside its owning package root.
|
||||
* @param packageRoot - Package directory that owns the `plugin.json` declaring the entry.
|
||||
* @param entry - Manifest entry path that must be relative to the package root.
|
||||
* @returns Absolute entry file path safe for later worker import.
|
||||
*/
|
||||
resolveEntryFile(packageRoot: string, entry: string): string {
|
||||
const normalizedPackageRoot = resolve(packageRoot);
|
||||
const entryFile = resolve(normalizedPackageRoot, entry);
|
||||
|
||||
if (isAbsolute(entry) || this.isOutside(normalizedPackageRoot, entryFile)) {
|
||||
throw new Error('Plugin entry must stay inside the package root');
|
||||
}
|
||||
|
||||
return entryFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a package directory belongs to a configured controlled root.
|
||||
* @param packageRoot - Candidate QQBot plugin package directory discovered from a root scan.
|
||||
* @returns Normalized absolute package directory when it is inside a controlled root.
|
||||
*/
|
||||
assertControlledPackageRoot(packageRoot: string): string {
|
||||
const normalizedPackageRoot = resolve(packageRoot);
|
||||
const isControlled = this.controlledRoots.some(
|
||||
(root) =>
|
||||
normalizedPackageRoot === root ||
|
||||
!this.isOutside(root, normalizedPackageRoot),
|
||||
);
|
||||
|
||||
if (!isControlled) {
|
||||
throw new Error('Plugin package root is outside controlled roots');
|
||||
}
|
||||
|
||||
return normalizedPackageRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a candidate package path escapes a controlled root.
|
||||
* @param root - Absolute controlled root that bounds package discovery.
|
||||
* @param candidate - Absolute package directory or entry file being validated.
|
||||
* @returns Whether the candidate is outside the root.
|
||||
*/
|
||||
private isOutside(root: string, candidate: string): boolean {
|
||||
const relation = relative(root, candidate);
|
||||
return (
|
||||
relation === '..' ||
|
||||
relation.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relation)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,179 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
parseQqbotPluginManifest,
|
||||
type QqbotPluginManifest,
|
||||
} from '@/modules/qqbot/plugin-platform/domain/manifest';
|
||||
|
||||
import { QqbotPluginPackagePathPolicyService } from './plugin-package-path-policy.service';
|
||||
import type { QqbotPluginPackageDescriptor } from './plugin-package.types';
|
||||
|
||||
/**
|
||||
* Discovers QQBot plugin packages by reading package manifests without importing package code.
|
||||
*/
|
||||
@Injectable()
|
||||
export class QqbotPluginPackageSourceService {
|
||||
/**
|
||||
* Creates a manifest-only package descriptor source.
|
||||
* @param pathPolicy - Root and entry policy that keeps package discovery inside controlled directories.
|
||||
*/
|
||||
constructor(private readonly pathPolicy: QqbotPluginPackagePathPolicyService) {}
|
||||
|
||||
/**
|
||||
* Discovers package descriptors from one-level package directories under controlled roots.
|
||||
* @returns Parsed descriptors sorted by plugin key for deterministic plugin-platform startup.
|
||||
*/
|
||||
async discoverPackages(): Promise<QqbotPluginPackageDescriptor[]> {
|
||||
const descriptors: QqbotPluginPackageDescriptor[] = [];
|
||||
|
||||
for (const root of this.pathPolicy.listExistingRoots()) {
|
||||
for (const packageRoot of this.listPackageRoots(root)) {
|
||||
const descriptor = this.readDescriptor(packageRoot);
|
||||
if (descriptor) {
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return descriptors.sort((left, right) =>
|
||||
left.pluginKey.localeCompare(right.pluginKey),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one package descriptor from `plugin.json` without loading the package entry module.
|
||||
* @param packageRoot - Candidate QQBot plugin package directory that may contain `plugin.json`.
|
||||
* @returns Descriptor when a manifest exists, otherwise `null`.
|
||||
*/
|
||||
readDescriptor(packageRoot: string): QqbotPluginPackageDescriptor | null {
|
||||
const controlledPackageRoot =
|
||||
this.pathPolicy.assertControlledPackageRoot(packageRoot);
|
||||
const manifestFile = join(controlledPackageRoot, 'plugin.json');
|
||||
|
||||
if (!existsSync(manifestFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifestLike = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
||||
const entry = this.readManifestEntry(manifestLike);
|
||||
const entryFile = this.pathPolicy.resolveEntryFile(
|
||||
controlledPackageRoot,
|
||||
entry,
|
||||
);
|
||||
const manifest = parseQqbotPluginManifest(
|
||||
this.normalizeManifestForCurrentParser(manifestLike),
|
||||
{ pluginRoot: controlledPackageRoot },
|
||||
);
|
||||
const pluginKey = manifest.pluginKey;
|
||||
|
||||
return {
|
||||
entry: manifest.entry,
|
||||
entryFile,
|
||||
manifest: this.attachPackageKeyAlias(manifest, pluginKey),
|
||||
packageRoot: controlledPackageRoot,
|
||||
pluginKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists first-level child directories that may represent QQBot plugin packages.
|
||||
* @param root - Existing controlled root that contains package directories.
|
||||
* @returns Absolute candidate package directories under the root.
|
||||
*/
|
||||
private listPackageRoots(root: string): string[] {
|
||||
return readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => join(root, entry.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the raw entry string before parser normalization so path policy errors stay explicit.
|
||||
* @param manifestLike - Parsed `plugin.json` content whose entry path belongs to the package.
|
||||
* @returns Raw entry path or an empty value that the manifest parser will reject.
|
||||
*/
|
||||
private readManifestEntry(manifestLike: unknown): string {
|
||||
if (!this.isRecord(manifestLike)) return '';
|
||||
return typeof manifestLike.entry === 'string' ? manifestLike.entry : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts planned package manifest aliases to the parser contract currently used by the repo.
|
||||
* @param manifestLike - Parsed `plugin.json` content from a QQBot plugin package.
|
||||
* @returns Parser input with the package key and worker type normalized for this codebase.
|
||||
*/
|
||||
private normalizeManifestForCurrentParser(manifestLike: unknown): unknown {
|
||||
if (!this.isRecord(manifestLike)) return manifestLike;
|
||||
|
||||
const normalized: Record<string, unknown> = {
|
||||
minApiSdkVersion: '1.0.0',
|
||||
...manifestLike,
|
||||
};
|
||||
const packageKey = this.readPackageKey(manifestLike);
|
||||
|
||||
if (!this.readString(normalized.pluginKey) && packageKey) {
|
||||
normalized.pluginKey = packageKey;
|
||||
}
|
||||
if (this.isRecord(manifestLike.runtime)) {
|
||||
normalized.runtime = {
|
||||
...manifestLike.runtime,
|
||||
workerType:
|
||||
manifestLike.runtime.workerType === 'thread'
|
||||
? 'node-worker'
|
||||
: manifestLike.runtime.workerType,
|
||||
};
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the planned `manifest.key` alias while preserving the current manifest parser type.
|
||||
* @param manifest - Parsed manifest whose `pluginKey` identifies the package.
|
||||
* @param pluginKey - Package key exposed to descriptor consumers and future generic workers.
|
||||
* @returns Manifest object with a runtime `key` alias for descriptor compatibility.
|
||||
*/
|
||||
private attachPackageKeyAlias(
|
||||
manifest: QqbotPluginManifest,
|
||||
pluginKey: string,
|
||||
): QqbotPluginManifest {
|
||||
return {
|
||||
...manifest,
|
||||
key: pluginKey,
|
||||
} as QqbotPluginManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the package key from either the planned `key` field or the current `pluginKey` field.
|
||||
* @param manifestLike - Parsed `plugin.json` object that declares the package identity.
|
||||
* @returns Non-empty package key when declared.
|
||||
*/
|
||||
private readPackageKey(manifestLike: Record<string, unknown>): string {
|
||||
return (
|
||||
this.readString(manifestLike.key) ||
|
||||
this.readString(manifestLike.pluginKey) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows JSON values to object records before reading manifest fields.
|
||||
* @param value - Parsed JSON value from a QQBot plugin package manifest.
|
||||
* @returns Whether the value is a non-array object record.
|
||||
*/
|
||||
private isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes string manifest fields used by package discovery.
|
||||
* @param value - Raw JSON field value read from `plugin.json`.
|
||||
* @returns Trimmed string when the field is non-empty.
|
||||
*/
|
||||
private readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim()
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import type { QqbotPluginManifest } from '@/modules/qqbot/plugin-platform/domain/manifest';
|
||||
|
||||
export type QqbotPluginPackageDescriptor = {
|
||||
entry: string;
|
||||
entryFile: string;
|
||||
manifest: QqbotPluginManifest;
|
||||
packageRoot: string;
|
||||
pluginKey: string;
|
||||
};
|
||||
|
||||
export type QqbotPluginRuntimeConfigSnapshot = Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
@ -0,0 +1,90 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, sep } from 'node:path';
|
||||
|
||||
import { QqbotPluginPackagePathPolicyService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service';
|
||||
import { QqbotPluginPackageSourceService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-source.service';
|
||||
|
||||
describe('QqbotPluginPackageSourceService', () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), 'qqbot-plugin-source-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it('discovers packages from plugin.json without importing package code', async () => {
|
||||
const packageRoot = join(tempRoot, 'sample');
|
||||
mkdirSync(join(packageRoot, 'src'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(packageRoot, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
key: 'sample',
|
||||
name: 'Sample',
|
||||
version: '1.0.0',
|
||||
entry: 'src/index.ts',
|
||||
runtime: {
|
||||
workerType: 'thread',
|
||||
timeoutMs: 5000,
|
||||
memoryMb: 128,
|
||||
maxConcurrency: 1,
|
||||
configKeys: ['SAMPLE_TOKEN'],
|
||||
},
|
||||
operations: [],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
join(packageRoot, 'src', 'index.ts'),
|
||||
'throw new Error("entry must not be imported during discovery");',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const source = new QqbotPluginPackageSourceService(
|
||||
new QqbotPluginPackagePathPolicyService([tempRoot]),
|
||||
);
|
||||
|
||||
await expect(source.discoverPackages()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
entry: 'src/index.ts',
|
||||
entryFile: join(packageRoot, 'src', 'index.ts'),
|
||||
manifest: expect.objectContaining({ key: 'sample' }),
|
||||
packageRoot,
|
||||
pluginKey: 'sample',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects package entries that escape controlled roots', async () => {
|
||||
const packageRoot = join(tempRoot, 'escape');
|
||||
mkdirSync(packageRoot, { recursive: true });
|
||||
writeFileSync(
|
||||
join(packageRoot, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
key: 'escape',
|
||||
name: 'Escape',
|
||||
version: '1.0.0',
|
||||
entry: `..${sep}outside.ts`,
|
||||
runtime: {
|
||||
workerType: 'thread',
|
||||
timeoutMs: 5000,
|
||||
memoryMb: 128,
|
||||
maxConcurrency: 1,
|
||||
},
|
||||
operations: [],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const source = new QqbotPluginPackageSourceService(
|
||||
new QqbotPluginPackagePathPolicyService([tempRoot]),
|
||||
);
|
||||
|
||||
await expect(source.discoverPackages()).rejects.toThrow(
|
||||
'Plugin entry must stay inside the package root',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user