import { lstatSync } from "node:fs"; import { hostname } from "node:os"; import { extname, isAbsolute, join, win32 } from "node:path"; import { findExecutable } from "./src/pi-ext-executable.ts"; const platforms: Record = { lnx: "linux", win32: "win32", mac: "darwin", }; const keys = ["os", "host", "path", "which", "env"] as const; type Field = (typeof keys)[number]; type Mode = "single" | "any" | "all"; export interface ConditionCheck { field: Field; mode: Mode; options: { value: string; matches: boolean }[]; matches: boolean; } export type MatchResult = | { matches: boolean; checks: ConditionCheck[]; error?: never } | { matches: false; error: string; checks?: never }; function values(value: unknown): { mode: Mode; items: string[] } | undefined { let mode: Mode; let items: unknown; if (typeof value === "string") { mode = "single"; items = [value]; } else if (Array.isArray(value)) { mode = "any"; items = value; } else if (value !== null && typeof value === "object") { const entries = Object.entries(value); if (entries.length !== 1) return undefined; const [operator, choices] = entries[0]; if (operator !== "any" && operator !== "all") return undefined; mode = operator; items = choices; } else return undefined; return Array.isArray(items) && items.length > 0 && items.every((item) => typeof item === "string" && item.length > 0) ? { mode, items } : undefined; } function validMarker(marker: string): boolean { return !( marker.includes("\\") || marker.includes("\0") || isAbsolute(marker) || win32.isAbsolute(marker) || marker .split("/") .some( (part) => !part || part === "." || part === ".." || part.includes(":"), ) ); } function markerExists(cwd: string, marker: string): boolean { let current = cwd; try { for (const part of marker.split("/")) { current = join(current, part); if (lstatSync(current).isSymbolicLink()) return false; } return true; } catch { return false; } } function commandExists(command: string, platform: string): boolean { const windows = platform === "win32"; const extensions = windows && !extname(command) ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD") .split(";") .map((ext) => ext.trim()) .filter(Boolean) : []; const names = [ command, ...extensions.flatMap((ext) => [ command + ext, command + ext.toLowerCase(), ]), ]; return Boolean(findExecutable(names, { platform })); } export function matchSkill( frontmatter: Record, cwd: string, platform = process.platform, host = hostname(), ): MatchResult { const conditions: { field: Field; mode: Mode; items: string[] }[] = []; for (const field of keys) { if (!(field in frontmatter)) continue; const condition = values(frontmatter[field]); if (!condition) return { matches: false, error: `invalid ${field} condition` }; if (field === "os" && condition.items.some((item) => !(item in platforms))) return { matches: false, error: "invalid os condition (expected lnx, win32, or mac)", }; if (field === "path" && condition.items.some((item) => !validMarker(item))) return { matches: false, error: "invalid path condition" }; if ( field === "which" && condition.items.some( (item) => item === "." || item === ".." || /[\\/:\s\0]/.test(item), ) ) return { matches: false, error: "invalid which condition" }; if ( field === "env" && condition.items.some((item) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(item)) ) return { matches: false, error: "invalid env condition" }; conditions.push({ field, mode: condition.mode, items: condition.items }); } if (conditions.length === 0) return { matches: false, error: "missing os, host, path, which, or env condition", }; const checks: ConditionCheck[] = conditions.map(({ field, mode, items }) => { const options = items.map((value) => ({ value, matches: field === "os" ? platforms[value] === platform : field === "host" ? value === host : field === "path" ? markerExists(cwd, value) : field === "which" ? commandExists(value, platform) : (process.env[value]?.length ?? 0) > 0, })); return { field, mode, options, matches: mode === "all" ? options.every((option) => option.matches) : options.some((option) => option.matches), }; }); return { matches: checks.every((check) => check.matches), checks }; }