247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import type {
|
|
DevicePackageKind,
|
|
DevicePackageSkillEntry,
|
|
} from '../../shared/device-packages';
|
|
|
|
export type {
|
|
DevicePackageIndexV1,
|
|
DevicePackageKind,
|
|
DevicePackageRecordV1,
|
|
DevicePackageSkillEntry,
|
|
InstallPreviewV1,
|
|
} from '../../shared/device-packages';
|
|
|
|
export type DevicePackageSource =
|
|
| Readonly<{ kind: 'npm'; spec: string }>
|
|
| Readonly<{ kind: 'git'; spec: string }>
|
|
| Readonly<{ kind: 'file'; absolutePath: string }>;
|
|
|
|
export interface InspectedDevicePackage {
|
|
packageId: string;
|
|
displayName: string;
|
|
resolvedVersion: string | null;
|
|
skillEntries: DevicePackageSkillEntry[];
|
|
extensionEntries: string[];
|
|
hasSkillScripts: boolean;
|
|
ignoredLifecycleScripts: string[];
|
|
}
|
|
|
|
const PACKAGE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
|
|
const SKILL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
const LIFECYCLE_SCRIPT_NAMES = Object.freeze([
|
|
'preinstall',
|
|
'install',
|
|
'postinstall',
|
|
'prepare',
|
|
] as const);
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function nonempty(value: unknown, maximum: number): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const normalized = value.trim();
|
|
return normalized && normalized.length <= maximum ? normalized : null;
|
|
}
|
|
|
|
function containsControlCharacter(value: string): boolean {
|
|
return Array.from(value).some((character) => {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
return codePoint <= 0x1f || codePoint === 0x7f;
|
|
});
|
|
}
|
|
|
|
export function parseDevicePackageSource(value: string): DevicePackageSource {
|
|
const source = nonempty(value, 2048);
|
|
if (!source || containsControlCharacter(source)) {
|
|
throw new Error('Device package source is invalid');
|
|
}
|
|
if (source.startsWith('npm:')) {
|
|
const spec = nonempty(source.slice(4), 1024);
|
|
if (!spec || /\s/u.test(spec)) throw new Error('npm package source is invalid');
|
|
return { kind: 'npm', spec: `npm:${spec}` };
|
|
}
|
|
if (source.startsWith('git:') || /^https?:\/\//iu.test(source)
|
|
|| /^ssh:\/\//iu.test(source) || /^git@[^:]+:/u.test(source)) {
|
|
if (/\s/u.test(source)) throw new Error('Git package source is invalid');
|
|
return { kind: 'git', spec: source };
|
|
}
|
|
if (!path.isAbsolute(source)) throw new Error('Local package source must be an absolute path');
|
|
return { kind: 'file', absolutePath: path.resolve(source) };
|
|
}
|
|
|
|
export function safeDevicePackageId(value: string): string {
|
|
const normalized = value.trim().toLowerCase()
|
|
.replace(/^@/u, '')
|
|
.replaceAll('/', '.')
|
|
.replace(/[^a-z0-9._-]+/gu, '-')
|
|
.replace(/^[^a-z0-9]+|[^a-z0-9._-]+$/gu, '')
|
|
.slice(0, 128);
|
|
if (!PACKAGE_ID_PATTERN.test(normalized)) throw new Error('Device package id is invalid');
|
|
return normalized;
|
|
}
|
|
|
|
export function safeStorageSegment(value: string): string {
|
|
const normalized = value.trim().replace(/[^A-Za-z0-9._-]+/gu, '-').slice(0, 128);
|
|
if (!normalized || normalized === '.' || normalized === '..') {
|
|
throw new Error('Device package version is invalid');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function relativeContainedPath(root: string, candidate: string): string {
|
|
const resolvedRoot = path.resolve(root);
|
|
const resolvedCandidate = path.resolve(candidate);
|
|
const relative = path.relative(resolvedRoot, resolvedCandidate);
|
|
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
throw new Error('Device package entry escapes its package root');
|
|
}
|
|
return relative.split(path.sep).join('/');
|
|
}
|
|
|
|
async function readJson(filePath: string): Promise<Record<string, unknown> | null> {
|
|
try {
|
|
const value = JSON.parse(await readFile(filePath, 'utf8')) as unknown;
|
|
return isRecord(value) ? value : null;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function existsFile(filePath: string): Promise<boolean> {
|
|
return await stat(filePath).then((value) => value.isFile()).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return false;
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
async function skillId(skillPath: string): Promise<string> {
|
|
const source = await readFile(skillPath, 'utf8');
|
|
const frontmatter = /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(source)?.[1] ?? '';
|
|
const declared = /^name:\s*['"]?([^'"\r\n]+?)['"]?\s*$/imu.exec(frontmatter)?.[1]?.trim();
|
|
const fallback = path.basename(path.dirname(skillPath));
|
|
const id = declared || fallback;
|
|
if (!SKILL_ID_PATTERN.test(id)) throw new Error(`Skill id is invalid: ${id}`);
|
|
return id;
|
|
}
|
|
|
|
async function collectSkillFiles(root: string): Promise<string[]> {
|
|
const result: string[] = [];
|
|
const visit = async (directory: string): Promise<void> => {
|
|
if (result.length > 100) throw new Error('Device package contains too many Skills');
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
const candidate = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) await visit(candidate);
|
|
else if (entry.isFile() && entry.name === 'SKILL.md') result.push(candidate);
|
|
}
|
|
};
|
|
await visit(root);
|
|
return result;
|
|
}
|
|
|
|
async function directoryContainsFile(directory: string): Promise<boolean> {
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
if (entry.isFile() || entry.isSymbolicLink()) return true;
|
|
if (entry.isDirectory() && await directoryContainsFile(path.join(directory, entry.name))) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function skillHasScripts(skillPath: string): Promise<boolean> {
|
|
const scriptsPath = path.join(path.dirname(skillPath), 'scripts');
|
|
const metadata = await stat(scriptsPath).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return null;
|
|
throw error;
|
|
});
|
|
return metadata?.isDirectory() === true && await directoryContainsFile(scriptsPath);
|
|
}
|
|
|
|
function manifestEntries(value: unknown, field: string): string[] {
|
|
if (value === undefined) return [];
|
|
const values = typeof value === 'string' ? [value] : value;
|
|
if (!Array.isArray(values) || values.length > 100
|
|
|| values.some((entry) => typeof entry !== 'string' || !entry.trim())) {
|
|
throw new Error(`${field} is invalid`);
|
|
}
|
|
return values.map((entry) => (entry as string).trim());
|
|
}
|
|
|
|
async function expandSkillManifestEntry(packageRoot: string, entry: string): Promise<string[]> {
|
|
const candidate = path.resolve(packageRoot, entry);
|
|
relativeContainedPath(packageRoot, candidate);
|
|
const metadata = await stat(candidate).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return null;
|
|
throw error;
|
|
});
|
|
if (!metadata) throw new Error(`Skill entry does not exist: ${entry}`);
|
|
if (metadata.isFile()) {
|
|
if (path.basename(candidate) !== 'SKILL.md') throw new Error(`Skill entry is not SKILL.md: ${entry}`);
|
|
return [candidate];
|
|
}
|
|
if (!metadata.isDirectory()) throw new Error(`Skill entry is invalid: ${entry}`);
|
|
return await collectSkillFiles(candidate);
|
|
}
|
|
|
|
export async function inspectDevicePackage(
|
|
packageRoot: string,
|
|
fallbackName = path.basename(path.resolve(packageRoot)),
|
|
): Promise<InspectedDevicePackage> {
|
|
const root = path.resolve(packageRoot);
|
|
const packageJson = await readJson(path.join(root, 'package.json'));
|
|
const codexManifest = await readJson(path.join(root, '.codex-plugin', 'plugin.json'));
|
|
const pi = isRecord(packageJson?.pi) ? packageJson.pi : null;
|
|
const declaredExtensions = manifestEntries(pi?.extensions, 'pi.extensions');
|
|
const declaredSkills = manifestEntries(pi?.skills, 'pi.skills');
|
|
const extensionFiles: string[] = [];
|
|
for (const entry of declaredExtensions) {
|
|
const candidate = path.resolve(root, entry);
|
|
relativeContainedPath(root, candidate);
|
|
if (!await existsFile(candidate) || !/\.(?:js|ts)$/iu.test(candidate)) {
|
|
throw new Error(`Extension entry is invalid: ${entry}`);
|
|
}
|
|
extensionFiles.push(candidate);
|
|
}
|
|
const skillFiles: string[] = [];
|
|
for (const entry of declaredSkills) skillFiles.push(...await expandSkillManifestEntry(root, entry));
|
|
const codexSkills = manifestEntries(codexManifest?.skills, 'plugin.skills');
|
|
for (const entry of codexSkills) skillFiles.push(...await expandSkillManifestEntry(root, entry));
|
|
if (await existsFile(path.join(root, 'SKILL.md'))) skillFiles.push(path.join(root, 'SKILL.md'));
|
|
|
|
const uniqueSkills = new Map<string, DevicePackageSkillEntry>();
|
|
const uniqueSkillPaths = [...new Set(skillFiles.map((entry) => path.resolve(entry)))];
|
|
for (const skillPath of uniqueSkillPaths) {
|
|
const id = await skillId(skillPath);
|
|
if (uniqueSkills.has(id)) throw new Error(`Duplicate Skill id: ${id}`);
|
|
uniqueSkills.set(id, { id, entryPath: relativeContainedPath(root, skillPath) });
|
|
}
|
|
const extensionEntries = [...new Set(extensionFiles.map((entry) => relativeContainedPath(root, entry)))];
|
|
if (uniqueSkills.size === 0 && extensionEntries.length === 0) {
|
|
throw new Error('Package does not expose a Pi extension or Skill');
|
|
}
|
|
const displayName = nonempty(packageJson?.name, 256)
|
|
?? nonempty(codexManifest?.name, 256)
|
|
?? fallbackName;
|
|
const scripts = isRecord(packageJson?.scripts) ? packageJson.scripts : {};
|
|
return {
|
|
packageId: safeDevicePackageId(displayName),
|
|
displayName,
|
|
resolvedVersion: nonempty(packageJson?.version, 128) ?? nonempty(codexManifest?.version, 128),
|
|
skillEntries: [...uniqueSkills.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
|
extensionEntries: extensionEntries.sort(),
|
|
hasSkillScripts: (await Promise.all(uniqueSkillPaths.map(skillHasScripts))).some(Boolean),
|
|
ignoredLifecycleScripts: LIFECYCLE_SCRIPT_NAMES.filter((name) => typeof scripts[name] === 'string'),
|
|
};
|
|
}
|
|
|
|
export function devicePackageKind(input: Pick<InspectedDevicePackage, 'skillEntries' | 'extensionEntries'>): DevicePackageKind {
|
|
if (input.skillEntries.length > 0 && input.extensionEntries.length > 0) return 'mixed';
|
|
return input.extensionEntries.length > 0 ? 'pi-extension' : 'skill-only';
|
|
}
|