import { existsSync, readFileSync } from 'node:fs' import { rm } from 'node:fs/promises' import { isAbsolute, relative, resolve } from 'node:path' import { copyPreservingMode } from '@/copy' import { findInstalledOrigin, readHistoryIndex } from '@/sync/history' import { type DomainHashes, hashFile, readStamp, type StampSource, stampedHashes, toStampKey, writeStamp, } from '@/sync/stamp' import { isDirectory } from '@/target' import { intro, isNonInteractive, logAdd, logError, logInfo, logStep, logWarn, outro, palette, select, } from '@/ui' /** * One installed file in three path flavours: absolute, relative to the * domain's installed root, and relative to the target. Adapters match on * `relToRoot` and every log line prints `rel`. */ export interface InstalledFile { readonly path: string readonly relToRoot: string readonly rel: string } export interface RetiredSurface { readonly path: string readonly rel: string readonly notice: string } export type SyncChange = | { readonly kind: 'copy' readonly source: string readonly dest: string readonly rel: string } | { readonly kind: 'delete'; readonly dest: string; readonly rel: string } /** * How an installed file compares to its toolkit source, and when it differs, * who moved it. `stale` and `customized` need the stamp to tell apart, so * `drifted` stays the verdict for a difference no stamp covers. * * `orphaned` and `stranded` both mean the walk found no source, and they are * separate because they need opposite treatment. A project-authored file is * orphaned and stays that way forever. A stamped file the toolkit no longer * installs to is stranded, which is a relocation waiting on a decision. * * `missing` is the one state the walk cannot produce on its own, since the * walk only iterates files that exist. It comes from `collectMissing` * instead, an adapter naming an entitled file the target does not hold. */ export type EntryState = | 'matching' | 'stale' | 'customized' | 'drifted' | 'orphaned' | 'stranded' | 'missing' export interface ScanEntry { readonly state: EntryState readonly rel: string /** Toolkit revision this file's content came from, when history proved it. */ readonly since?: string /** * Overrides `report`'s generic text for this entry's state. Two producers * write one: `collectMissing`, since a stack name is only known to the * adapter that resolved it, and an orphan sitting outside the declared * project subfolder, since the generic line names no destination. */ readonly notice?: string } export interface SyncPlan { readonly entries: readonly ScanEntry[] readonly retired: readonly RetiredSurface[] readonly changes: readonly SyncChange[] /** Set when a file needed history to attribute it and this toolkit has none. */ readonly historyUnavailable: boolean } /** * What a headless run does once the plan has changes. Domains whose files the * toolkit owns apply them. Domains whose files a project is expected to edit * refuse, because an unattended overwrite of a customized file is data loss * with no prompt in front of it. */ export type NonInteractivePolicy = | { readonly kind: 'apply' } | { readonly kind: 'refuse' readonly message: string readonly hint: string } /** * The two holes every domain sync leaves open: where a destination file's * source lives, and what counts as a change beyond a plain content diff. * Everything else in a sync is identical across gov, snippets, and standards, * so it lives in the engine. * * The optional members exist because one adapter needed each of them. Leaving * all three unset reproduces the behavior gov and snippets already had. */ export interface SyncAdapter { readonly banner: string /** Rendered as `Scanning