import { copyFile, lstat, mkdir, readFile, rm } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { dirname, join } from "node:path"; import writeFileAtomic from "write-file-atomic"; import { err, ok, type Result } from "../domain/result.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import { PRODUCT_IDENTITY } from "../identity.js"; import { NonRegularFileReadError, RegularFileChangedError, } from "../filesystem/RegularFile.js"; import { withRegularFile } from "./RegularFileRead.js"; import { clientSkillDirectories } from "./SupportedClients.js"; export const MANAGED_SKILL_FILES = [ "SKILL.md", "references/connection-and-recovery.md", "references/native-and-artifacts.md", "references/javascript-applications.md", "references/android-applications.md", "references/runtime-observation.md", "references/evidence-workflows.md", ] as const; export const isManagedSkillManifest = (content: string): boolean => content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) || content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`); export const readSkillFile = (path: string): Promise => readSkillFileState(path).then(({ content }) => content); interface SkillFileState { readonly content: string; readonly mode: number; } const readSkillFileState = async (path: string): Promise => withRegularFile( path, async (handle, stats) => { const bytes = await readBoundedFileBytes( handle, stats.size, undefined, stats.size, ); if (bytes === undefined || bytes.length !== stats.size) throw new RegularFileChangedError(path); return { content: bytes.toString("utf8"), mode: stats.mode & 0o7777 }; }, { symlinks: "reject" }, ); interface CanonicalSkillFile { readonly content: string; readonly destination: string; readonly original: SkillFileState | undefined; } export interface SkillInstallFileOperations { readonly mkdir: (path: string) => Promise; readonly backup: (source: string, destination: string) => Promise; readonly write: ( path: string, content: string, mode: number, ) => Promise; readonly remove: (path: string) => Promise; } export interface SkillInstallRollbackFailure { readonly path: string; readonly reason: string; } /** Expected failure while replacing a managed skill bundle. */ export class SetupSkillInstallError extends Error { readonly _tag = "SetupSkillInstallError"; readonly path: string; readonly stage: "prepare" | "backup" | "write" | "readback"; readonly reason: string; readonly rollbackFailures: readonly SkillInstallRollbackFailure[]; readonly recoveryBackupPaths: readonly string[]; constructor(input: { readonly path: string; readonly stage: "prepare" | "backup" | "write" | "readback"; readonly reason: string; readonly rollbackFailures: readonly SkillInstallRollbackFailure[]; readonly recoveryBackupPaths: readonly string[]; readonly cause: unknown; }) { const message = [ `REA skill ${input.stage} failed at ${input.path}: ${input.reason}.`, ...(input.rollbackFailures.length === 0 ? [] : [ `Rollback also failed for ${input.rollbackFailures.map(({ path, reason }) => `${path} (${reason})`).join(", ")}.`, ...(input.recoveryBackupPaths.length === 0 ? [] : [ `Existing-file backups remain at ${input.recoveryBackupPaths.join(", ")}.`, ]), ]), ].join(" "); super(message, { cause: input.cause }); this.path = input.path; this.stage = input.stage; this.reason = input.reason; this.rollbackFailures = input.rollbackFailures; this.recoveryBackupPaths = input.recoveryBackupPaths; } get remediation(): string { return `${this.message} Check permissions and rerun setup.`; } } export type SkillInstallValue = "installed" | "unchanged"; export type SkillInstallResult = Result< SkillInstallValue, SetupSkillInstallError >; const systemSkillInstallFileOperations: SkillInstallFileOperations = { mkdir: async (path) => { await mkdir(path, { recursive: true }); }, backup: async (source, destination) => { try { await copyFile(source, destination, fsConstants.COPYFILE_EXCL); } catch (cause: unknown) { if (cause instanceof Error && "code" in cause && cause.code === "EEXIST") return; throw cause; } }, write: (path, content, mode) => writeFileAtomic(path, content, { encoding: "utf8", mode }), remove: (path) => rm(path, { force: true }), }; /** Managed skill location selected for a client family. */ export interface SkillDestination { readonly client: string; readonly path: string; } /** Metadata and byte alignment observed in selected skill installations. */ export interface InstalledSkillIdentity { readonly version: string | null; readonly toolCount: number | null; readonly canonical: boolean; } /** Resolve the skill roots required by a client selection, or all owned roots. */ export const skillDestinations = ( home: string, clientIds: readonly string[] | undefined, environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): readonly SkillDestination[] => clientSkillDirectories(home, clientIds, environment, platform).map( ({ client, directory }) => ({ client, path: join(directory, PRODUCT_IDENTITY.skillName), }), ); /** Resolve existing REA-owned skill roots using setup's canonical destinations. */ export const existingSkillDestinations = async ( home: string, environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): Promise => { const existing: SkillDestination[] = []; for (const destination of skillDestinations( home, undefined, environment, platform, )) { try { const root = await lstat(destination.path); if (root.isSymbolicLink() || !root.isDirectory()) continue; const manifest = join(destination.path, "SKILL.md"); const content = await readSkillFile(manifest); if (isManagedSkillManifest(content)) existing.push(destination); } catch (cause: unknown) { if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") continue; if (cause instanceof NonRegularFileReadError) continue; throw cause; } } return existing; }; const readOptionalSkillState = async ( path: string, ): Promise => { try { return await readSkillFileState(path); } catch (cause: unknown) { if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return undefined; throw cause; } }; const canonicalSkillFiles = async ( home: string, clientIds: readonly string[], environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): Promise => Promise.all( skillDestinations(home, clientIds, environment, platform).flatMap( ({ path: root }) => MANAGED_SKILL_FILES.map(async (relativePath) => { const destination = join(root, relativePath); return { destination, content: await readFile( new URL( `../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`, import.meta.url, ), "utf8", ), original: await readOptionalSkillState(destination), }; }), ), ); /** Report whether setup would change any file in the managed REA skill bundle. */ export const canonicalSkillNeedsInstall = async ( home: string, clientIds: readonly string[] = [], environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): Promise => { try { return ( await canonicalSkillFiles(home, clientIds, environment, platform) ).some(({ content, original }) => original?.content !== content); } catch (cause: unknown) { // Unreadable skill state fails open to install so setup can repair it. void cause; return true; } }; const causeReason = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); const causePath = (cause: unknown): string | undefined => cause instanceof Error && "path" in cause && typeof cause.path === "string" ? cause.path : undefined; const restoreSkillFiles = async ( attempted: readonly CanonicalSkillFile[], operations: SkillInstallFileOperations, ): Promise => { const failures: SkillInstallRollbackFailure[] = []; for (const { destination, original } of [...attempted].reverse()) { try { if (original === undefined) await operations.remove(destination); else await operations.write(destination, original.content, original.mode); } catch (cause: unknown) { failures.push({ path: destination, reason: causeReason(cause) }); } } return failures; }; /** Transactionally install or upgrade the canonical REA skill and references. */ /** Transactionally install the canonical skill bundle and optional references. */ export const installCanonicalSkill = async ( home: string, clientIds: readonly string[] = [], environment: Readonly = {}, platform: NodeJS.Platform = process.platform, operations: SkillInstallFileOperations = systemSkillInstallFileOperations, ): Promise => { let attempted: CanonicalSkillFile[] = []; let stage: "prepare" | "backup" | "write" | "readback" = "prepare"; let failingPath = "skill bundle"; try { const canonical = await canonicalSkillFiles( home, clientIds, environment, platform, ); const changed = canonical.filter( ({ content, original }) => original?.content !== content, ); if (changed.length === 0) return ok("unchanged"); stage = "backup"; for (const { destination, original } of changed) { failingPath = destination; await operations.mkdir(dirname(destination)); if (original !== undefined) await operations.backup(destination, `${destination}.rea.backup`); } stage = "write"; for (const file of changed) { failingPath = file.destination; // An atomic writer can publish and then reject; include the target before // entering it so rollback covers that ambiguous outcome. attempted.push(file); await operations.write( file.destination, file.content, file.original?.mode ?? 0o600, ); } stage = "readback"; for (const { destination, content } of changed) { failingPath = destination; if ((await readSkillFile(destination)) !== content) throw new Error(`skill readback mismatch: ${destination}`); } return ok("installed"); } catch (cause: unknown) { failingPath = causePath(cause) ?? failingPath; const rollbackFailures = await restoreSkillFiles(attempted, operations); return err( new SetupSkillInstallError({ path: failingPath, stage, reason: causeReason(cause), rollbackFailures, recoveryBackupPaths: attempted.flatMap(({ destination, original }) => original === undefined ? [] : [`${destination}.rea.backup`], ), cause, }), ); } }; /** Read installed skill metadata and compare every managed location. */ export const readInstalledSkillIdentity = async ( home: string, clientIds: readonly string[], environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): Promise => { const destinations = skillDestinations( home, clientIds, environment, platform, ); let content: string | undefined; for (const destination of destinations) { try { content = await readSkillFile(join(destination.path, "SKILL.md")); break; } catch { // Another selected copy may provide metadata; canonical validation below // still marks missing or unreadable copies as stale. } } if (content === undefined) return undefined; const toolCount = /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1]; return { canonical: !(await canonicalSkillNeedsInstall( home, clientIds, environment, platform, )), version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null, toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10), }; };