import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; export const SHIPPED_UTILITY_SCRIPT_KIND = "shippedUtility"; export const SHIPPED_UTILITY_MANIFEST = ".pi-materia-shipped-utilities.json"; export interface ShippedUtilityScriptRef { kind: typeof SHIPPED_UTILITY_SCRIPT_KIND; name: string; runtime?: "node"; } interface ManifestEntry { sourceHash: string; profileFile: string; conflict?: string; } interface ShippedUtilityManifest { version: 1; utilities: Record; } export interface ShippedUtilitySyncResult { utilitiesDir: string; manifestPath: string; copied: string[]; unchanged: string[]; conflicts: string[]; } export function isShippedUtilityScriptRef(value: unknown): value is ShippedUtilityScriptRef { return typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === SHIPPED_UTILITY_SCRIPT_KIND && typeof (value as { name?: unknown }).name === "string"; } export function getUserUtilitiesDir(profileDir: string): string { return path.join(profileDir, "utilities"); } export function resolveShippedUtilityScriptPath(profileDir: string, ref: ShippedUtilityScriptRef): string { const name = normalizeShippedUtilityName(ref.name); const utilitiesDir = getUserUtilitiesDir(profileDir); const manifest = readManifestSync(path.join(utilitiesDir, SHIPPED_UTILITY_MANIFEST)); const profileFile = manifest.utilities[name]?.profileFile ?? name; const resolved = path.resolve(utilitiesDir, profileFile); const root = path.resolve(utilitiesDir); if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { throw new Error(`Shipped utility script "${name}" resolves outside the profile utilities directory.`); } return resolved; } export async function syncShippedUtilityScripts(profileDir: string): Promise { const utilitiesDir = getUserUtilitiesDir(profileDir); const manifestPath = path.join(utilitiesDir, SHIPPED_UTILITY_MANIFEST); await mkdir(utilitiesDir, { recursive: true }); const manifest = await readManifest(manifestPath); const result: ShippedUtilitySyncResult = { utilitiesDir, manifestPath, copied: [], unchanged: [], conflicts: [] }; for (const sourcePath of bundledUtilityScriptPaths()) { const name = normalizeShippedUtilityName(path.basename(sourcePath)); const sourceHash = await sha256File(sourcePath); const existingEntry = manifest.utilities[name]; const targetFile = existingEntry?.profileFile ?? name; const targetPath = path.join(utilitiesDir, targetFile); if (!existsSync(targetPath)) { await copyFile(sourcePath, targetPath); manifest.utilities[name] = { sourceHash, profileFile: targetFile }; result.copied.push(targetFile); continue; } const targetHash = await sha256File(targetPath); if (targetHash === sourceHash) { manifest.utilities[name] = { sourceHash, profileFile: targetFile }; result.unchanged.push(targetFile); continue; } if (existingEntry && targetHash === existingEntry.sourceHash) { await copyFile(sourcePath, targetPath); manifest.utilities[name] = { sourceHash, profileFile: targetFile }; result.copied.push(targetFile); continue; } const versionedFile = versionedUtilityFileName(name, sourceHash); await copyFile(sourcePath, path.join(utilitiesDir, versionedFile)); manifest.utilities[name] = { sourceHash, profileFile: versionedFile, conflict: targetFile }; result.conflicts.push(`${targetFile} preserved; using ${versionedFile}`); } await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); return result; } function bundledUtilityScriptPaths(): string[] { const configDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "config", "utilities"); return [ path.join(configDir, "blackbelt-bootstrap.mjs"), path.join(configDir, "blackbelt-maintain.mjs"), path.join(configDir, "mime-bootstrap.mjs"), path.join(configDir, "mime-maintain.mjs"), path.join(configDir, "mime-gh-pr.mjs"), path.join(configDir, "blackbelt-gh-pr.mjs"), path.join(configDir, "blackbelt-ado-pr.mjs"), path.join(configDir, "mime-ado-pr.mjs"), path.join(configDir, "commit-sigil.mjs"), path.join(configDir, "detect-vcs.mjs"), path.join(configDir, "ensure-ignored.mjs"), ]; } function normalizeShippedUtilityName(name: string): string { if (!name || name !== path.basename(name) || name.includes("/") || name.includes("\\") || !/^[A-Za-z0-9._-]+\.mjs$/.test(name)) { throw new Error(`Invalid shipped utility script name "${name}".`); } return name; } function versionedUtilityFileName(name: string, hash: string): string { const parsed = path.parse(name); return `${parsed.name}.${hash.slice(0, 12)}${parsed.ext}`; } async function sha256File(file: string): Promise { return createHash("sha256").update(await readFile(file)).digest("hex"); } async function readManifest(file: string): Promise { if (!existsSync(file)) return { version: 1, utilities: {} }; try { const parsed = JSON.parse(await readFile(file, "utf8")); return normalizeManifest(parsed); } catch { return { version: 1, utilities: {} }; } } function readManifestSync(file: string): ShippedUtilityManifest { if (!existsSync(file)) return { version: 1, utilities: {} }; try { return normalizeManifest(JSON.parse(readFileSync(file, "utf8"))); } catch { return { version: 1, utilities: {} }; } } function normalizeManifest(value: unknown): ShippedUtilityManifest { const manifest: ShippedUtilityManifest = { version: 1, utilities: {} }; if (typeof value !== "object" || value === null || typeof (value as { utilities?: unknown }).utilities !== "object" || (value as { utilities?: unknown }).utilities === null) return manifest; for (const [name, entry] of Object.entries((value as { utilities: Record }).utilities)) { if (typeof entry !== "object" || entry === null) continue; const sourceHash = (entry as { sourceHash?: unknown }).sourceHash; const profileFile = (entry as { profileFile?: unknown }).profileFile; const conflict = (entry as { conflict?: unknown }).conflict; if (typeof sourceHash !== "string" || typeof profileFile !== "string") continue; try { manifest.utilities[normalizeShippedUtilityName(name)] = { sourceHash, profileFile: normalizeShippedUtilityName(profileFile), ...(typeof conflict === "string" ? { conflict } : {}) }; } catch { continue; } } return manifest; }