import os from "node:os"; import path from "node:path"; import { backup, readIfExists, removeFileAndPrune, sha256, writeAtomic } from "./fsx.ts"; import { removeBlock, upsertBlock } from "./marker.ts"; import { dropInstall, findInstall, inspectDrift, readManifest, upsertInstall } from "./manifest.ts"; import { ADAPTERS, getAdapter } from "./targets/index.ts"; import type { InstalledFile, InstallPlan, InstallRecord, Scope, SkillSource, TargetContext, TargetId, } from "./types.ts"; export interface EnvOptions { home?: string; cwd?: string; } function ctxOf(scope: Scope, skill: SkillSource, env: EnvOptions = {}): TargetContext { return { scope, home: env.home ?? os.homedir(), cwd: env.cwd ?? process.cwd(), skill, }; } /** 시스템에 흔적이 있는 타깃을 골라준다 — TUI 의 기본 체크 상태로 쓴다 */ export async function detectTargets(skill: SkillSource, env: EnvOptions = {}): Promise { const found: TargetId[] = []; for (const a of ADAPTERS) { const scope: Scope = a.scopes.includes("user") ? "user" : "project"; if (await a.detect(ctxOf(scope, skill, env))) found.push(a.id); } return found; } export async function planInstall( target: TargetId, scope: Scope, skill: SkillSource, env: EnvOptions = {}, ): Promise { const adapter = getAdapter(target); if (!adapter.scopes.includes(scope)) { const only = adapter.scopes.join("/"); return { target, scope, root: "", actions: [], alreadyInstalled: false, blocked: `${adapter.label} 은(는) ${only} 범위만 지원한다`, }; } return adapter.plan(ctxOf(scope, skill, env)); } export interface ApplyResult { record: InstallRecord; written: string[]; backedUp: string[]; /** 사용자가 고쳐서 건너뛴 파일 */ skipped: string[]; } /** * 계획을 실제로 디스크에 반영한다. * * 업그레이드 안전장치: 이전 설치 기록이 있으면 각 파일의 해시를 비교해서, * 사용자가 손댄 파일은 기본적으로 건드리지 않는다(force 로만 덮고, 덮을 때도 .orig 로 남긴다). */ export async function applyPlan( plan: InstallPlan, version: string, opts: { force?: boolean; env?: EnvOptions } = {}, ): Promise { if (plan.blocked) throw new Error(plan.blocked); const home = opts.env?.home; const prev = findInstall(await readManifest(home), plan.target, plan.scope, plan.root); const modified = new Set(); if (prev) { const drift = await inspectDrift(prev); for (const p of drift.modified) modified.add(p); } const files: InstalledFile[] = []; const written: string[] = []; const backedUp: string[] = []; const skipped: string[] = []; for (const action of plan.actions) { const isDirty = modified.has(action.path); if (isDirty && !opts.force) { // 건너뛰더라도 매니페스트에서 빠지면 uninstall 이 이 파일을 놓친다 — 기록은 남긴다. // 이때 "현재" 해시를 쓰면 안 된다. 그러면 다음 update 에서 드리프트가 사라져 // 사용자가 고친 파일을 조용히 덮어쓰게 된다. 설치 당시 해시를 그대로 유지한다. const kept = prev?.files.find((f) => f.path === action.path); files.push( kept ?? { path: action.path, sha256: sha256(action.kind === "inject" ? action.content.trim() : action.content), ...(action.kind === "inject" ? { marker: action.marker } : {}), }, ); skipped.push(action.path); continue; } if (isDirty && opts.force) { const b = await backup(action.path); if (b) backedUp.push(b); } if (action.kind === "write") { await writeAtomic(action.path, action.content); files.push({ path: action.path, sha256: sha256(action.content) }); } else { const cur = (await readIfExists(action.path)) ?? ""; const next = upsertBlock(cur, action.marker, action.content); await writeAtomic(action.path, next); // 마커 방식은 블록 안쪽만 해시한다 — 문서의 다른 부분은 사용자 자유다 files.push({ path: action.path, sha256: sha256(action.content.trim()), marker: action.marker }); } written.push(action.path); } const record: InstallRecord = { target: plan.target, scope: plan.scope, root: plan.root, version, installedAt: new Date().toISOString(), files, }; await upsertInstall(record, home); return { record, written, backedUp, skipped }; } export interface RemoveResult { removed: string[]; keptModified: string[]; } /** 매니페스트에 기록된 것만 되돌린다. 기록에 없는 파일은 손대지 않는다. */ export async function removeInstall( record: InstallRecord, opts: { force?: boolean; env?: EnvOptions } = {}, ): Promise { const drift = await inspectDrift(record); const dirty = new Set(drift.modified); const removed: string[] = []; const keptModified: string[] = []; for (const f of record.files) { if (dirty.has(f.path) && !opts.force) { keptModified.push(f.path); continue; } if (f.marker) { const cur = await readIfExists(f.path); if (cur === null) continue; const next = removeBlock(cur, f.marker); // 블록만 남아 있던 문서라면 파일째 지운다 if (next.trim().length === 0) await removeFileAndPrune(f.path, path.dirname(f.path)); else await writeAtomic(f.path, next); } else { await removeFileAndPrune(f.path, record.root); } removed.push(f.path); } if (keptModified.length === 0) { await dropInstall(record.target, record.scope, record.root, opts.env?.home); } else { // 일부만 남았으면 기록도 남은 것만 유지한다 await upsertInstall({ ...record, files: record.files.filter((f) => dirty.has(f.path)) }, opts.env?.home); } return { removed, keptModified }; }