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 { 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 | 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 { 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 { 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 { const result: string[] = []; const visit = async (directory: string): Promise => { 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 { 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 { 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 { 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 { 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(); 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): DevicePackageKind { if (input.skillEntries.length > 0 && input.extensionEntries.length > 0) return 'mixed'; return input.extensionEntries.length > 0 ? 'pi-extension' : 'skill-only'; }