diff --git a/docs/development/native-appearance-welcome/BACKUPS_AND_RESET.md b/docs/development/native-appearance-welcome/BACKUPS_AND_RESET.md new file mode 100644 index 00000000..c603077f --- /dev/null +++ b/docs/development/native-appearance-welcome/BACKUPS_AND_RESET.md @@ -0,0 +1,38 @@ +# Backups and guarded reset + +Implements the supplied BRIEF-BACKUPS.md in the assigned worktree. No dependencies, PTY changes, shader generation changes, or release version changes. + +## Design +A private, bounded journal records checked before-images and expected after hashes before the existing host transaction can mutate files. All readers reject symlinks and oversized files. Restores capture current snapshots for stale checking and create another journal entry, making undo reversible. Shared studio shells render history and a four-step reset state machine; no write occurs while planning or rendering. Reset discovers ownership and delegates to existing removal planners, retaining user content and reporting refused areas. + +The shared transaction is preferred over independent backup implementations because existing snapshot checking and rollback remain authoritative. Legacy writers retain their own validation and use that transaction for their final write. State metadata needed to undo ownership changes belongs in the same journal entry as external files. + +## Implementation plan +- [x] Journal: test missing behavior, implement private atomic read-back-verified entries, caps and pruning, integrate transact, run focused tests, commit. +- [x] Writers: preserve preconditions, route external final writes through journaled transactions; daily settings checkpoint; focused regression tests, commit. +- [x] Restore and history: stale snapshots, current-to-before diff, edited-file second confirmation, undoable restore, manual snapshots; shared responsive studio panel; tests, commit. +- [x] Reset: discover owned areas, exact impact, backup location, exact typed reset confirmation, recheck all snapshots, per-area results; command/help wiring; tests, commit. +- [x] Verify affected tests using supplied runner, TypeScript and diff checks; save panel snapshots and final evidence report. + +## Review focus +Reject symlink swaps; refuse writes if backups cannot be read back; preserve user newlines and subsequent unrelated edits; refuse stale plans; keep cancellation and confirmation visible at small sizes. Tests use private home directories. Automated results do not establish physical terminal validation. + +## Implementation details and safety decisions + +`src/backups/journal.ts` stores private before-images and verified after-images. The expected after hash is present in a prepared manifest; a successful transaction verifies bytes and permissions and marks it applied. After-images let ownership-only reset reverse exact NMSh edits across saves while preserving user edits made between those saves. A restore is a new protected entry. It also checkpoints config.json without changing it unless settings were part of the chosen restore. + +Entries publish by directory rename only after staged content and manifest read back correctly. Interrupted staging is retained for inspection and counts toward storage limits. A short exclusive process lock serializes entry creation; a lock from a demonstrably dead process can be recovered. Automatic, applied entries may be pruned. Reset, restore, manual and unfinished checkpoints remain protected. A private pruning index prevents legacy resets from treating an incomplete retained history as proof of original ownership. + +Host appearance and shader removal use their existing planners. Legacy cursor, keyboard and Starship reset requires verified applied history with after-images, exact target paths, and unambiguous owned changed lines. Without that evidence, the area is skipped. Changed user lines are never guessed at. Theme Bridge and status-line writes checkpoint their ownership records in the same transaction as their host files; restoring an artifact therefore restores its ability to be managed later. + +The reset wizard merges disjoint removal plans at whole-line boundaries. Conflicting plans are skipped with a reason. Every captured dependency is rechecked before and after the global reset checkpoint. Each area then uses the checked transaction and reports done, skipped or failed; remaining areas are skipped if an application fails. The global checkpoint remains available even after partial failure. + +History uses the shared Studio layout; exact diffs, overwrite confirmation and scrollable result documents use the shared review renderer. Only optional control/subtitle parameters were added to that renderer. At small sizes the scroll position and cancellation/confirmation controls remain visible. Reset starts with no selection; settings are a separate area. A wrong final word cancels. No scheduler or animation loop was added. + +Settings saves keep their merge-preserving behavior and take at most one daily checkpoint. Checked snapshots and exclusive staging prevent a save from overwriting a change that arrived during the backup. Host writes and reset/restore require HOME-contained, non-symlink paths. Legacy fixtures that intentionally write config now declare HOME and XDG consistently. Explicit NMSh state paths retain the settings API's existing state-directory behavior. + +## Verification and handoff + +Focused tests cover journal read-back, corruption, bounds, permissions, symbolic links, live writer exclusion, backup failure, settings coexistence, stale restore, reversible restore, owned-edit preservation, shared-file reset planning, command routing, modal ownership, wrong-word cancellation, all wizard stages and responsive rendering under NO_COLOR. Existing adapter, host, cursor, tmux, prompt persistence, Ask, palette, setup and routing tests were also run. Logs and plain-text/ASCII panel snapshots are in the task evidence folder; the final report names authoritative runs. No full-suite run or physical terminal validation is claimed. + +Manual terminal checks still needed: open /changes, review and restore a harmless config change, confirm the extra warning after a later user edit, then undo the restore. In /nmsh reset select one installed area, inspect every diff and backup path, cancel, reopen and complete with the exact word. Verify personal lines and newline style survive, result scrolling works at small sizes, and /reset only explains the guarded command. Test in the supported terminal hosts and with NO_COLOR. Automatic host reload is intentionally left to the lead's integration at `TerminalApp.afterHostFilesChanged(paths)`; the panels currently explain that a reload may be needed. diff --git a/src/agents/claudeStatusLine.ts b/src/agents/claudeStatusLine.ts index 43473f34..308406e0 100644 --- a/src/agents/claudeStatusLine.ts +++ b/src/agents/claudeStatusLine.ts @@ -1,6 +1,8 @@ -import {mkdirSync, readFileSync, renameSync, rmSync, writeFileSync} from 'node:fs'; +import {snapshot, type FileChange} from '../appearance/hostFiles.js'; +import {journalDirectory} from '../backups/journal.js'; +import {readFileSync} from 'node:fs'; import {homedir} from 'node:os'; -import {dirname, isAbsolute, join} from 'node:path'; +import {isAbsolute, join} from 'node:path'; import {applyPlan, inspectFile, planCreate, planJsonSet, planReplace, sha256, type FileEditPlan} from '../ask/fileEdit.js'; import {nmshConfigDirectory} from '../configuration/paths.js'; import {posixQuote} from '../shell/adapters/ShellAdapter.js'; @@ -57,13 +59,8 @@ function readOwnership(env: NodeJS.ProcessEnv): OwnershipRecord | undefined { } catch { return undefined; } } -function writeOwnership(env: NodeJS.ProcessEnv, record: OwnershipRecord | undefined): void { - const path = ownershipPath(env); - if (!record) { rmSync(path, {force: true}); return; } - mkdirSync(dirname(path), {recursive: true, mode: 0o700}); - const temporary = `${path}.${process.pid}.tmp`; - writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, {mode: 0o600}); - renameSync(temporary, path); +function ownershipChange(env:NodeJS.ProcessEnv,record:OwnershipRecord|undefined):FileChange { + return {before:snapshot(ownershipPath(env),env.HOME || homedir(),true),after:record?`${JSON.stringify(record,null,2)}\n`:undefined}; } const allowedRoots = (env: NodeJS.ProcessEnv) => [env.HOME && isAbsolute(env.HOME) ? env.HOME : homedir(), claudeConfigDirectory(env)]; @@ -116,12 +113,12 @@ export function planClaudeBridge(launcher: string, env: NodeJS.ProcessEnv = proc } export function applyClaudeBridge(plan: FileEditPlan, launcher: string, env: NodeJS.ProcessEnv = process.env, now = new Date()): {ok: true} | {ok: false; reason: string} { - const applied = applyPlan(plan); - if (!applied.ok) return applied; - const created = plan.operation === 'create'; - writeOwnership(env, {settingsPath: plan.resolvedPath, command: bridgeCommand(launcher), inserted: plan.edits[0]!.text, - created, resultSha256: plan.resultSha256, appliedAt: now.toISOString()}); - return {ok: true}; + const created=plan.operation==='create'; + try { + const record={settingsPath:plan.resolvedPath,command:bridgeCommand(launcher),inserted:plan.edits[0]!.text,created,resultSha256:plan.resultSha256,appliedAt:now.toISOString()}; + return applyPlan(plan,{home:env.HOME || homedir(),source:'statusline',directory:journalDirectory(env.HOME || homedir(),env),label:plan.reason,companions:[ownershipChange(env,record)]}); + }catch(error){return {ok:false,reason:error instanceof Error?error.message:String(error)};} + } /** Removal deletes exactly the text NMSh inserted, only while the status line is still NMSh's. */ @@ -144,7 +141,7 @@ export function planClaudeBridgeRemoval(env: NodeJS.ProcessEnv = process.env): B } export function applyClaudeBridgeRemoval(plan: FileEditPlan, env: NodeJS.ProcessEnv = process.env): {ok: true} | {ok: false; reason: string} { - const applied = applyPlan(plan); - if (applied.ok) writeOwnership(env, undefined); - return applied; + try {return applyPlan(plan,{home:env.HOME || homedir(),source:'statusline',directory:journalDirectory(env.HOME || homedir(),env),label:plan.reason,companions:[ownershipChange(env,undefined)]});} + catch(error){return {ok:false,reason:error instanceof Error?error.message:String(error)};} + } diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index ae1b8a8b..fb6ee216 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -1,3 +1,7 @@ +import {Journal, journalDirectory} from '../backups/journal.js'; +import {createChangesPanel,changesKey,renderChangesPanel,type ChangesPanel} from '../backups/ChangesPanel.js'; +import {createResetPanel,resetKey,renderResetPanel,type ResetPanel} from '../backups/ResetPanel.js'; +import {ownedSnapshotPaths} from '../backups/reset.js'; import {inlineWelcomeReason} from '../welcome/render.js'; import {WelcomeStudioController} from '../welcome/studio.js'; import {NativeWelcomePresentation} from '../welcome/startup.js'; @@ -1060,6 +1064,8 @@ export class TerminalApp { private welcomeBlinkCount = 0; private contextGeneration = 0; private appearanceHub?: AppearanceHubState; + private changesPanel?: ChangesPanel; + private resetPanel?: ResetPanel; /** /appearance › Kitty shaders (Kitty 0.49+ pipelines); opened from the hub, Esc returns there. */ private kittyShaderPanel?: KittyShaderPanelState; /** /cursor › Host native is reading Kitty's catalog to choose the studio or the older setup; a second Enter waits for it. */ @@ -2532,6 +2538,12 @@ export class TerminalApp { this.render(); return; } + if (this.changesPanel || this.resetPanel) { + const action = this.changesPanel ? changesKey(this.changesPanel,key) : resetKey(this.resetPanel!,key); + if (action?.paths) this.afterHostFilesChanged(action.paths); + if (action?.close) { this.changesPanel=undefined;this.resetPanel=undefined;this.returnFromPanel(); } + this.render();return; + } if (this.appearanceHub) { const wasRouting = this.appearanceHub.view === 'routing'; const action = appearanceHubKey(this.appearanceHub, key, this.promptConfiguration); @@ -3278,6 +3290,21 @@ export class TerminalApp { } } else if (slash.kind === 'copySettings') { this.panelOrigin = undefined; this.rowPanel = createRowPanel('Copy', '/copy and /cp', ['copyMode', 'copyIncludeStatus', 'copyAutoExpand']); } + else if (slash.kind === 'resetGuidance') this.output.addFrontendInteraction(command.trim(), 'Use /nmsh reset to choose only NMSh-owned settings, review impact and backup, then type reset to confirm. /reset writes nothing. Browse or undo changes with /changes.', INFO); + else if (slash.kind === 'changes' || slash.kind === 'nmshReset') { + this.panelOrigin=undefined; + try { + const home=process.env.HOME || homedir(); + const journal=new Journal({home,directory:journalDirectory(home)}); + const context={env:process.env,platform:process.platform}; + if (slash.kind==='changes') { + this.changesPanel=createChangesPanel(journal,[]); + try {this.changesPanel.ownershipPaths=ownedSnapshotPaths(context,journal);} + catch(error){this.changesPanel.message=`Ownership discovery is incomplete: ${error instanceof Error?error.message:String(error)}. Journal history remains available.`;} + } + else this.resetPanel=createResetPanel(context,journal); + } catch(error) {this.output.addFrontendInteraction(command.trim(), `Cannot open backup tools: ${error instanceof Error?error.message:String(error)}`, ERROR);} + } else if (slash.kind === 'appearance') { this.panelOrigin = undefined; await this.startAppearance(); } else if (slash.kind === 'motion') { // The same Motion screen and state as /appearance → Motion. @@ -3880,6 +3907,22 @@ export class TerminalApp { this.render(); } + /** After reviewed restore/reset writes: reload the verified terminal NMSh runs in (when it is one NMSh can reload) and reload settings. */ + private afterHostFilesChanged(paths: readonly string[]): void { + if (!paths.length) return; + const host = this.cursorFacts().host; + if (host !== 'other') this.output.addFrontendInteraction('/changes', this.reloadNativeHost(host).message, INFO); + // Load disk state without the settings setter: restoring files must not regenerate the files just restored. + this.configuration=loadPromptConfiguration(); + this.effectivePromptProvider=this.configuration.provider; + this.externalPrompt=undefined; + this.externalPromptError=undefined; + this.themeStopsKey=''; + this.cursorHost=undefined; + this.renderer.setCursorStyle(cursorStyleSequence(this.configuration.cursor.shape,this.configuration.cursor.blink)); + setIconStyle(this.configuration.glyphStyle); + } + /** /appearance: the visual hub. NMSh rows always work; host window rows edit opacity/blur only where the host supports it. */ private async startAppearance(): Promise { let host: AppearanceState | undefined; @@ -6573,7 +6616,7 @@ export class TerminalApp { private hasModalInput(ignorePalette = false): boolean { return Boolean(this.welcomeStudio || this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.arcade || this.browserPanel || this.docSession || this.imageView || this.viewer || this.textEditor || this.background.panel || this.batchReview || this.pinsPanel || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || (!ignorePalette && this.paletteState) || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState - || this.resumeBrowser || this.appearanceHub || this.kittyShaderPanel || this.glslStudio.panel || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.queuePanel || this.queueHistoryPanel || this.pasteReview || this.misePanel || this.keepAwakePanel); + || this.resumeBrowser || this.changesPanel || this.resetPanel || this.appearanceHub || this.kittyShaderPanel || this.glslStudio.panel || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.queuePanel || this.queueHistoryPanel || this.pasteReview || this.misePanel || this.keepAwakePanel); } /** Complex panels declare the smallest size that shows their essential controls. */ @@ -6779,6 +6822,8 @@ export class TerminalApp { else if (!busy && this.studioPreviewClock) { this.studioPreviewClock(); this.studioPreviewClock = undefined; } return framePanel(rows, columns); } + if (this.changesPanel) return renderChangesPanel(this.changesPanel,columns,this.dimensions().rows-4); + if (this.resetPanel) return renderResetPanel(this.resetPanel,columns,this.dimensions().rows-4); if (this.appearanceHub) { const theme = themeLabel(this.promptConfiguration.nmsh.palette); const now = Date.now(); @@ -10675,7 +10720,7 @@ export class TerminalApp { let written: ManagedWrite; try { written = writeManagedFiles(native.host, resolveCursorSettings(state.draft, contextFor(this.promptConfiguration))); } catch { state.message = 'Could not write NMSh\'s managed cursor files; nothing else was changed.'; return; } if (setup.plan.kind === 'plan') { - const applied = applyPlan(setup.plan.plan); + const applied = applyPlan(setup.plan.plan, {home:process.env.HOME || homedir(),source:'cursor',label:'Set up native cursor include',directory:journalDirectory(process.env.HOME || homedir())}); if (!applied.ok) { state.message = applied.reason; return; } } this.cursorHost = hostCursorFacts(process.env, value => nativeCursorIntegrated(value), () => writtenKittyTrail({env: process.env, platform: process.platform}), () => this.glslStudio.trailFacts()); @@ -11306,7 +11351,7 @@ export class TerminalApp { return; } if (action.kind === 'applyEdit') { - const applied = applyPlan(action.plan); + const applied = applyPlan(action.plan, {home:process.env.HOME || homedir(),source:'ask',label:action.plan.reason,directory:journalDirectory(process.env.HOME || homedir())}); if (!applied.ok) { finish(applied.reason); return; } // Verify by reading back: the file must now be exactly what the preview showed. let verified = false; diff --git a/src/appearance/ghosttyShaders.ts b/src/appearance/ghosttyShaders.ts index 8b565582..242fb32f 100644 --- a/src/appearance/ghosttyShaders.ts +++ b/src/appearance/ghosttyShaders.ts @@ -394,7 +394,7 @@ export function syncGhosttyShaders(settings: GhosttyShaderSettings, context: Gho const removals: FileChange[] = [...state.passes.values()].filter(file => file.content !== undefined).map(file => ({before: file})); const {trail: _trail, ...rest} = record; const ledgerText = `${JSON.stringify({...rest, fragmentHash: digest(INACTIVE_FRAGMENT), passes: []}, null, 2)}\n`; - try { transact([...removals, {before: state.fragment, after: INACTIVE_FRAGMENT}, {before: state.ledger, after: ledgerText}], [], state.paths.home); } + try { transact([...removals, {before: state.fragment, after: INACTIVE_FRAGMENT}, {before: state.ledger, after: ledgerText}], [], state.paths.home, {source: 'ghostty-shaders', label: 'ghostty-shaders synchronization', directory: join(state.paths.directory,'..','backups')}); } catch (error) { return {kind: 'review', message: error instanceof Error ? error.message : String(error)}; } const why = context.catalog.supported ? '' : ` (${context.catalog.capability.reason ?? 'this Ghostty version is not supported'})`; return {kind: 'written', message: `NMSh’s Ghostty effects are off${why} · ${ghosttyShaderGuidance(context.platform)}`, ...(record.trail ? {trailBefore: record.trail} : {})}; @@ -441,7 +441,7 @@ export function planGhosttyShaderRemoval(context: AppearanceContext, options: {l } export function applyGhosttyShaderPlan(plan: GhosttyShaderPlan): {ok: true} | {ok: false; error: string} { - try { transact(plan.changes, plan.dependencies, plan.home); return {ok: true}; } + try { transact(plan.changes, plan.dependencies, plan.home, {source: 'ghostty-shaders', directory: join(dirname(plan.fragmentPath),'..','backups'), label: 'ghostty-shaders configuration'}); return {ok: true}; } catch (error) { return {ok: false, error: error instanceof Error ? error.message : String(error)}; } } @@ -497,7 +497,7 @@ export function setGhosttyReloadPending(context: AppearanceContext, previous: re const before = snapshot(paths.reload, paths.home, true); const after = !previous?.length ? undefined : `${JSON.stringify({version: 1, since: now, previous: [...new Set(previous)]} satisfies ReloadRecord)}\n`; if (before.content === after) return; - transact([{before, after}], [], paths.home); + transact([{before, after}], [], paths.home, {source: 'ghostty-shaders', label: 'Host reload state', directory: join(paths.directory,'..','backups')}); } /** diff --git a/src/appearance/hostAppearance.ts b/src/appearance/hostAppearance.ts index 9445e59e..c87a3cb4 100644 --- a/src/appearance/hostAppearance.ts +++ b/src/appearance/hostAppearance.ts @@ -1,3 +1,4 @@ +import {dirname, join} from 'node:path'; import {appearanceContext, appearancePaths, type AppearanceContext, type AppearanceHost} from './hostPaths.js'; import {readHostConfiguration, type Definition} from './hostConfiguration.js'; import {digest, snapshot, transact, type FileChange, type FileSnapshot} from './hostFiles.js'; @@ -170,6 +171,6 @@ export function planHostRemoval(host: AppearanceHost, context = appearanceContex } export function applyHostAppearance(plan: HostAppearancePlan): {ok: true} | {ok: false; error: string} { - try { transact(plan.changes, plan.dependencies, plan.home); return {ok: true}; } + try { transact(plan.changes, plan.dependencies, plan.home, {source: 'appearance', directory: join(dirname(plan.fragmentPath),'..','backups'), label: `${plan.host} appearance ${plan.operation}`}); return {ok: true}; } catch (error) { return {ok: false, error: error instanceof Error ? error.message : String(error)}; } } diff --git a/src/appearance/hostFiles.ts b/src/appearance/hostFiles.ts index a1ad949a..90fee1d3 100644 --- a/src/appearance/hostFiles.ts +++ b/src/appearance/hostFiles.ts @@ -2,15 +2,24 @@ import {constants, closeSync, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, type Stats} from 'node:fs'; import {randomUUID, createHash} from 'node:crypto'; import {dirname, isAbsolute, relative, resolve, sep} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {Journal, type JournalMetadata, type JournalEntry} from '../backups/journal.js'; export const digest = (content: string) => createHash('sha256').update(content).digest('hex'); export interface FileSnapshot {path: string; content?: string; identity: string; mode: number} -export interface FileChange {before: FileSnapshot; after?: string} +export interface FileChange {before: FileSnapshot; after?: string; afterMode?: number} const MAX_FILE_BYTES = 2 * 1024 * 1024; const identity = (stat: Stats) => `${stat.dev}:${stat.ino}:${stat.mode}:${stat.uid}:${stat.gid}`; -/** Reject every symbolic-link component. Missing descendants are allowed only below a verified directory. */ -export function checkPath(path: string, home: string, writable = false): void { +const within = (root: string, path: string) => { const inside = relative(root, path); return !(inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)); }; + +/** + * Reject every symbolic-link component. Missing descendants are allowed only below a verified directory. Paths must be inside the + * home, or inside NMSh's own configuration directory (XDG_CONFIG_HOME may point outside the home); the same checks apply there. + */ +export function checkPath(path: string, homeRoot: string, writable = false): void { + const own = nmshConfigDirectory(); + const home = isAbsolute(path) && !within(homeRoot, path) && within(own, path) ? own : homeRoot; const inside = relative(home, path); if (!isAbsolute(path) || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) throw new Error('The configuration path is outside your home; no changes are allowed.'); const parts = resolve(path).split(sep).filter(Boolean); @@ -41,7 +50,10 @@ export function snapshot(path: string, home: string, writable = false): FileSnap try { const stat = fstatSync(fd); if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new Error('Configuration must be a bounded regular file (at most 2 MiB).'); - return {path, content: readFileSync(fd, 'utf8'), identity: identity(stat), mode: stat.mode & 0o777}; + const bytes=readFileSync(fd); + const content=bytes.toString('utf8'); + if(bytes.length>MAX_FILE_BYTES||!Buffer.from(content,'utf8').equals(bytes))throw new Error('Configuration must be bounded UTF-8 text; no changes are allowed.'); + return {path, content, identity: identity(stat), mode: stat.mode & 0o777}; } finally { closeSync(fd); } } @@ -65,18 +77,29 @@ function replace(path: string, content: string, mode: number, home: string, expe } /** All dependencies checked before staging or mutation. On failure restore only our unchanged writes. */ -export function transact(changes: readonly FileChange[], dependencies: readonly FileSnapshot[], home: string): void { +export function transact(changes: readonly FileChange[], dependencies: readonly FileSnapshot[], home: string, metadata: JournalMetadata = {source: 'appearance', label: 'Host configuration change'}): JournalEntry | undefined { + for (const dependency of dependencies) unchanged(dependency, home); + for (const change of changes) unchanged(change.before, home, true); + const effective = ['restore','reset'].includes(metadata.source) ? [...changes] : changes.filter(change => change.after !== change.before.content || (change.afterMode!==undefined&&change.afterMode!==change.before.mode)); + if (!effective.length) return; + const journal = new Journal({home, directory: metadata.directory}); + let entry:JournalEntry; + try {entry=journal.record(effective,metadata.source,metadata.label);} + catch(error){throw new Error(`Could not create a verified backup; configuration was not changed. ${error instanceof Error?error.message:String(error)}`);} + // Backup I/O may take time: recheck every dependency before the first mutation. for (const dependency of dependencies) unchanged(dependency, home); for (const change of changes) unchanged(change.before, home, true); const done: FileChange[] = []; try { for (const change of changes) { unchanged(change.before, home, true); - if (change.after === change.before.content) continue; + if (change.after === change.before.content && (change.afterMode===undefined||change.afterMode===change.before.mode)) continue; if (change.after === undefined) unlinkSync(change.before.path); - else replace(change.before.path, change.after, change.before.mode, home, change.before); + else replace(change.before.path, change.after, change.afterMode ?? change.before.mode, home, change.before); done.push(change); } + journal.complete(entry); + return entry; } catch (error) { let incomplete = false; for (const change of done.reverse()) { diff --git a/src/appearance/kittyShaders.ts b/src/appearance/kittyShaders.ts index 2fdd1b39..53e192e8 100644 --- a/src/appearance/kittyShaders.ts +++ b/src/appearance/kittyShaders.ts @@ -482,7 +482,7 @@ export function syncKittyShaders(settings: KittyShaderSettings, context: KittySh const fragmentText = `${FRAGMENT_HEADER}# No NMSh effect is active; your own custom_shaders applies unchanged.\n`; if (state.pipeline.content === pipelineText && state.fragment.content === fragmentText) return {kind: 'unchanged'}; const ledgerText = `${JSON.stringify({...record, fragmentHash: digest(fragmentText), pipelineHash: digest(pipelineText), presets: []}, null, 2)}\n`; - try { transact([{before: state.pipeline, after: pipelineText}, {before: state.fragment, after: fragmentText}, {before: state.ledger, after: ledgerText}], [], state.paths.home); } + try { transact([{before: state.pipeline, after: pipelineText}, {before: state.fragment, after: fragmentText}, {before: state.ledger, after: ledgerText}], [], state.paths.home, {source: 'kitty-shaders', label: 'kitty-shaders synchronization', directory: join(state.paths.directory,'..','backups')}); } catch (error) { return {kind: 'review', message: error instanceof Error ? error.message : String(error)}; } return {kind: 'written', message: `NMSh's Kitty effects are off · ${kittyShaderGuidance(context.platform)}`}; } @@ -522,7 +522,7 @@ export function planKittyShaderRemoval(context: AppearanceContext): KittyShaderP } export function applyKittyShaderPlan(plan: KittyShaderPlan): {ok: true} | {ok: false; error: string} { - try { transact(plan.changes, plan.dependencies, plan.home); return {ok: true}; } + try { transact(plan.changes, plan.dependencies, plan.home, {source: 'kitty-shaders', directory: join(dirname(plan.fragmentPath),'..','backups'), label: 'kitty-shaders configuration'}); return {ok: true}; } catch (error) { return {ok: false, error: error instanceof Error ? error.message : String(error)}; } } diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 994f81f3..4140220b 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -108,6 +108,10 @@ export const CONCEPTS: readonly Concept[] = [ covers: ['/history', 'settings:History', 'family:history'], aliases: ['command history', 'history provider', 'command history provider', 'shell history', 'atuin', 'history search', 'search history', 'history'], description: 'Command history is what ↑ and /history search: NMSh\'s native journals and zsh history, or a read-only Atuin database.'}, + {id: 'changes', label: 'Backups and change history', support: 'actionable', open: '/changes', where: '/changes (also /backups and /snapshots); /nmsh reset removes only what NMSh added', + covers: ['/changes', '/backups', '/snapshots', '/reset'], + aliases: ['backups', 'backup', 'snapshots', 'change history', 'config history', 'undo config change', 'revert config', 'restore backup', 'reset nmsh', 'nmsh reset', 'reset settings'], + description: 'NMSh backs up every configuration file before it changes it. /changes lists those backups and restores one after a review; /nmsh reset removes only what NMSh added, after several confirmations and a backup.'}, {id: 'picker', label: 'Picker', support: 'actionable', open: '/providers', where: 'Settings → History → Picker provider, or /providers', covers: ['family:picker'], aliases: ['picker', 'fuzzy finder', 'fzf', 'television', 'picker provider'], description: 'The picker is the search list for history and directories: native composer search, fzf, or Television. Selections never execute.'}, @@ -386,7 +390,7 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [ notes: ['Modes: Off, Auto (used only when the deterministic resolver is unsure) and Always (consulted first). /llm shows the model, runtime and last inference.']}, {id: 'projects', title: 'Project & dev tasks', why: 'Ask reads this project\'s scripts (package.json, Makefile, Cargo, …) and can run them for you; /worktrees and /github show its worktrees, pull requests and issues, and the experimental /sidebar can keep tmux panes and worktrees beside the transcript.', concepts: ['worktrees', 'githubWorkspace', 'workspaceRegions', 'backgroundTasks'], notes: ['Try: "run the tests" · "start the dev server" · "what scripts does this project have?"', 'Dev servers Ask starts run as NMSh-managed tasks: a live status row, detected URLs to open, and "stop the dev server" to end them. /background runs any long-running command beside the shell.']}, - {id: 'files', title: 'Files, config & editor', why: 'Jump to folders, open what output mentions, and let Ask find and open config files or add/update a setting with a verified, previewed edit (it never removes settings).', concepts: ['navigation', 'editor', 'pastePreview', 'paste-batch']}, + {id: 'files', title: 'Files, config & editor', why: 'Jump to folders, open what output mentions, and let Ask find and open config files or add/update a setting with a verified, previewed edit (it never removes settings).', concepts: ['changes', 'navigation', 'editor', 'pastePreview', 'paste-batch']}, {id: 'providers', title: 'Providers & tools', why: 'Choose what powers each part of NMSh, and install optional tools with previewed recipes.', concepts: ['providers', 'welcome', 'tools', 'toolConfig', 'integrations', 'dotfiles', 'homebrew', 'agents']}, {id: 'doctorWatch', title: 'Doctor & watch', why: 'Check your setup with local, read-only checks, and watch a command change over time.', concepts: ['doctor', 'watch'], notes: ['After a failure, ask "why did that fail?" for an explanation from the recorded output.']}, diff --git a/src/ask/fileEdit.ts b/src/ask/fileEdit.ts index 2665a4a0..0a073ab5 100644 --- a/src/ask/fileEdit.ts +++ b/src/ask/fileEdit.ts @@ -1,3 +1,5 @@ +import {snapshot, transact, type FileChange} from '../appearance/hostFiles.js'; +import type {JournalMetadata} from '../backups/journal.js'; import {createHash} from 'node:crypto'; import {chmodSync, mkdirSync, lstatSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, accessSync, constants, unlinkSync} from 'node:fs'; import {dirname, join} from 'node:path'; @@ -410,7 +412,18 @@ export const ABSENT = 'absent'; /* ---------- applying and rendering ---------- */ /** NMSh's own Run: the same plan, applied without a shell. Refuses if the file changed; atomic rename; mode kept. */ -export function applyPlan(plan: FileEditPlan): {ok: true} | {ok: false; reason: string} { +export function applyPlan(plan: FileEditPlan, journal?: JournalMetadata & {home: string; companions?: FileChange[]}): {ok: true} | {ok: false; reason: string} { + if (journal) { + try { + const before = snapshot(plan.path, journal.home, true); + if ((before.content === undefined ? ABSENT : sha256(before.content)) !== plan.expectedSha256) throw new Error('Configuration changed since preview; review again.'); + let next = before.content ?? ''; + for (const edit of [...plan.edits].sort((a,b) => b.start - a.start)) next = next.slice(0,edit.start) + edit.text + next.slice(edit.end); + if (sha256(next) !== plan.resultSha256) throw new Error('The edit did not produce the previewed result.'); + transact([{before, after:next},...(journal.companions??[])], [], journal.home, journal); + return {ok:true}; + } catch (error) { return {ok:false, reason: error instanceof Error ? error.message : String(error)}; } + } if (plan.expectedSha256 === ABSENT) { try { mkdirSync(dirname(plan.resolvedPath), {recursive: true}); diff --git a/src/backups/ChangesPanel.ts b/src/backups/ChangesPanel.ts new file mode 100644 index 00000000..ccf20497 --- /dev/null +++ b/src/backups/ChangesPanel.ts @@ -0,0 +1,77 @@ +import {takeSettingsCheckpointFailure} from './settings.js'; +import {dirname,join} from 'node:path'; +import type {Key} from '../terminal/keys.js'; +import {renderStudioLayout, renderStudioReview, studioWrap, type StudioReview} from '../ui/StudioLayout.js'; +import {Journal, type JournalEntry} from './journal.js'; +import {manualSnapshot, planRestore, restore, type RestorePlan} from './restore.js'; +export interface ChangesPanel { + journal:Journal;ownershipPaths:string[];entries:JournalEntry[];statuses:Map; + selected:number;view:'list'|'review'|'confirm'|'result';review?:StudioReview;restore?:RestorePlan;message:string;shown:boolean; +} +export function createChangesPanel(journal:Journal,ownershipPaths:readonly string[]):ChangesPanel { + const state:ChangesPanel={journal,ownershipPaths:[...new Set([join(dirname(journal.directory),'config.json'),...ownershipPaths])],entries:[],statuses:new Map(),selected:0,view:'list',message:'History is capped at 200 entries / 64 MiB; newest 20 protected checkpoints and source/path anchors are kept.',shown:false}; + refresh(state);return state; +} +function refresh(state:ChangesPanel):void { + state.entries=state.journal.list();state.statuses.clear(); + const notices:string[]=[]; + if(state.journal.unreadable.length)notices.push(`${state.journal.unreadable.length} unreadable backup entries skipped.`); + try {const count=state.journal.prunedCount();if(count)notices.push(`${count} old backup entries pruned.`);}catch{notices.push('Backup pruning record unreadable.');} + const failure=takeSettingsCheckpointFailure(state.journal.directory);if(failure)notices.push(failure); + if(notices.length)state.message=notices.join(' '); + for(const entry of state.entries)for(const file of entry.files){try{state.statuses.set(`${entry.id}:${file.path}`,state.journal.status(file));}catch{state.statuses.set(`${entry.id}:${file.path}`,'unreadable / unsafe path');}} + state.selected=Math.max(0,Math.min(state.selected,state.entries.length-1)); +} +export function changesKey(state:ChangesPanel,key:Key):{close?:true;paths?:string[]}|undefined { + if(key.kind==='escape'||key.kind==='interrupt')return {close:true}; + try { + if(state.view==='list'){ + if(key.kind==='up'||key.kind==='down')state.selected=Math.max(0,Math.min(state.entries.length-1,state.selected+(key.kind==='up'?-1:1))); + if(key.kind==='text'&&key.value.toLowerCase()==='s'){ + const entry=manualSnapshot(state.journal,state.ownershipPaths);refresh(state);state.message=`Snapshot ${entry.id} saved.`; + } + if(key.kind==='enter'&&state.entries[state.selected]){ + state.restore=planRestore(state.journal,state.entries[state.selected]!.id); + state.review={plan:state.restore,offset:0};state.view='review';state.shown=false; + } + }else if(state.view==='review'){ + if(key.kind==='up'||key.kind==='down')state.review!.offset=Math.max(0,state.review!.offset+(key.kind==='up'?-1:1)); + if(key.kind==='enter'&&state.review?.shown){ + if(state.restore!.edited.length){state.view='confirm';state.shown=false;state.review={offset:0,plan:{preview:state.restore!.edited.map(path=>`WARNING: your later edits to ${path} will be lost.`),guidance:'Enter explicitly confirms losing these later edits. Esc cancels.'},controls:[['↑↓','scroll files'],['Enter','overwrite later edits'],['Esc','cancel']]};} + else return apply(state); + } + }else if(state.view==='confirm'){ + if(key.kind==='up'||key.kind==='down')state.review!.offset=Math.max(0,state.review!.offset+(key.kind==='up'?-1:1)); + if(key.kind==='enter'&&state.review?.shown)return apply(state); + }else { + if(key.kind==='up'||key.kind==='down')state.review!.offset=Math.max(0,state.review!.offset+(key.kind==='up'?-1:1)); + if(key.kind==='enter'){state.view='list';refresh(state);} + } + }catch(error){state.message=`Refused: ${error instanceof Error?error.message:String(error)}`;state.view='result';state.review=resultReview(state.message);} + return undefined; +} +function apply(state:ChangesPanel):{paths:string[]} { + const entry=restore(state.journal,state.restore!,{reviewed:true,overwrite:state.view==='confirm'}); + state.message=`Restored. Safety backup ${entry.id}. Undo: /changes → ${entry.id}. Terminal configuration may need a reload.`; + state.view='result';state.review=resultReview(state.message);return {paths:state.restore!.changes.map(change=>change.before.path)}; +} +function resultReview(message:string):StudioReview { + return {offset:0,plan:{preview:[message],guidance:'Enter returns to history. Esc closes.'},subtitle:'Completed action or refusal',controls:[['↑↓','scroll'],['Enter','history'],['Esc','close']]}; +} +export function renderChangesPanel(state:ChangesPanel,columns:number,height:number):string[] { + if(state.view==='review')return renderStudioReview(state.review!,'Restore · exact current → backup diff',columns,height); + state.shown=columns>=24&&height>=8; + const entry=state.entries[state.selected]; + if(state.view==='confirm')return renderStudioReview(state.review!,'Confirm loss of later edits',columns,height); + if(state.view==='result')return renderStudioReview(state.review!,'Changes · result',columns,height); + return renderStudioLayout({title:'Changes & backups',subtitle:'Newest first · Enter reviews a restore · S snapshots known managed files', + entries:state.entries.length?state.entries.map(item=>({group:item.time.slice(0,10),label:item.label, + render:width=>{ + const states=item.files.map(file=>state.statuses.get(`${item.id}:${file.path}`)); + const summary=states.includes('edited since')?'edited since':states.includes('missing')?'missing':states.some(value=>value!=='unchanged since')?'unsafe file':'unchanged since'; + return studioWrap(`${item.time.slice(11,16)} ${item.source} · ${summary} · ${item.files.length} files · ${item.label}`,width)[0]!; + }})):[{group:'History',label:'No backups yet'}], + selected:state.selected,notice:state.message,details:(width,budget)=>entry?[...studioWrap(`${entry.time} · ${entry.source} · ${entry.label}`,width), + ...entry.files.flatMap(file=>studioWrap(`${file.path}: ${state.statuses.get(`${entry.id}:${file.path}`)}`,width))].slice(0,budget):[], + controls:[['↑↓','select'],['Enter','review restore'],['S','snapshot now'],['Esc','close']]},columns,height); +} diff --git a/src/backups/ResetPanel.ts b/src/backups/ResetPanel.ts new file mode 100644 index 00000000..3b800271 --- /dev/null +++ b/src/backups/ResetPanel.ts @@ -0,0 +1,68 @@ +import type {Key} from '../terminal/keys.js'; +import {renderStudioLayout,renderStudioReview,studioWrap,type StudioReview} from '../ui/StudioLayout.js'; +import type {AppearanceContext} from '../appearance/hostPaths.js'; +import {Journal} from './journal.js'; +import {applyReset,discoverResetAreas,planReset,type ResetArea,type ResetPlan} from './reset.js'; +import {checkMark} from '../ui/glyphs.js'; +import {renderTextValue} from '../ui/formControls.js'; +export interface ResetPanel { + context:AppearanceContext;journal:Journal;areas:ResetArea[];selected:Set;index:number; + step:'choose'|'impact'|'backup'|'confirm'|'result';review?:StudioReview;plan?:ResetPlan;typed:string;shown:boolean;message:string;results:string[]; +} +export function createResetPanel(context:AppearanceContext,journal:Journal):ResetPanel { + return {context,journal,areas:discoverResetAreas(context,journal),selected:new Set(),index:0,step:'choose',typed:'',shown:false,message:'Nothing selected. NMSh settings are separate. Your own configuration is kept.',results:[]}; +} +export function resetKey(state:ResetPanel,key:Key):{close?:true;paths?:string[]}|undefined { + if(key.kind==='escape'||key.kind==='interrupt')return {close:true}; + try { + if(state.step==='choose'){ + if(key.kind==='up'||key.kind==='down')state.index=Math.max(0,Math.min(state.areas.length-1,state.index+(key.kind==='up'?-1:1))); + if(key.kind==='text'&&key.value===' '){const id=state.areas[state.index]!.id;if(state.selected.has(id))state.selected.delete(id);else state.selected.add(id);} + if(key.kind==='enter'&&state.selected.size){ + state.plan=planReset(state.areas,state.selected,state.context);state.review={offset:0,plan:{preview:state.plan.preview,guidance:'Only recorded NMSh changes are reset. User lines are kept. Enter advances to Backup; nothing is written.'}}; + state.step='impact';state.shown=false; + } + }else if(state.step==='impact'){ + if(key.kind==='up'||key.kind==='down')state.review!.offset=Math.max(0,state.review!.offset+(key.kind==='up'?-1:1)); + if(key.kind==='enter'&&state.review?.shown){state.step='backup';state.shown=false;state.review={offset:0,plan:{preview:[`Backup directory: ${state.journal.directory}`, 'Includes files changed by the reset and their ownership records.', + ...state.plan!.changes.map(change=>change.before.path)],guidance:'Backup must be written, read back and hash verified before any reset. Enter advances to final typed confirmation.'}};} + }else if(state.step==='backup'){ + if(key.kind==='up'||key.kind==='down')state.review!.offset=Math.max(0,state.review!.offset+(key.kind==='up'?-1:1)); + if(key.kind==='enter'&&state.review?.shown){state.step='confirm';state.shown=false;state.message='Review complete. Type reset exactly, then Enter; Esc cancels.';} + }else if(state.step==='confirm'){ + if(key.kind==='backspace')state.typed=state.typed.slice(0,-1); + else if(key.kind==='text'&&/^[a-zA-Z]+$/u.test(key.value)&&state.typed.length+key.value.length<=32)state.typed+=key.value; + else if(key.kind==='enter'){ + if(state.typed==='reset'&&state.shown){ + const result=applyReset(state.plan!,state.journal);state.results=result.results; + state.message=result.entry?`Backup ${result.entry.id}. Undo: /changes → ${result.entry.id}. Terminal configuration may need a reload.`:'Nothing to reset';state.step='result';state.review=resultReview(state); + return {paths:result.paths}; + } + state.message='Cancelled: the word did not match reset exactly. Nothing was written.'; + state.step='result';state.results=[state.message];state.review=resultReview(state);return {close:true}; + }else return {close:true}; + }else { + if(key.kind==='up'||key.kind==='down')state.review!.offset=Math.max(0,state.review!.offset+(key.kind==='up'?-1:1)); + if(key.kind==='enter')return {close:true}; + } + }catch(error){state.step='result';state.results=[`Failed: ${error instanceof Error?error.message:String(error)}`];state.message=`Nothing further was reset. ${state.results[0]}`;state.review=resultReview(state);} + return undefined; +} +function resultReview(state:ResetPanel):StudioReview { + return {offset:0,plan:{preview:[...state.results,state.message],guidance:'Undo: /changes → select the reset backup. Terminal configuration may need a reload.'}, + subtitle:'Per-area results · current state was backed up first',controls:[['↑↓','scroll'],['Enter','close'],['Esc','close']]}; +} +export function renderResetPanel(state:ResetPanel,columns:number,height:number):string[] { + if(state.step==='impact'||state.step==='backup')return renderStudioReview(state.review!,state.step==='impact'?'Step 2 · Impact':'Step 3 · Verified backup',columns,height); + state.shown=columns>=24&&height>=8; + if(state.step==='confirm')return renderStudioLayout({title:'Step 4 · Confirm reset',selected:0, + entries:[{group:'Final confirmation',label:'Type reset',render:()=>`Type reset ${renderTextValue(state.typed,'reset',{focused:true,color:!process.env.NO_COLOR})}`}], + notice:state.message,details:width=>studioWrap(`Reset only the selected NMSh-owned areas. ${state.plan!.steps.length} areas; ${state.plan!.changes.length} backup files. Type reset exactly, then Enter. Esc cancels.`,width), + controls:[['Enter','reset if word matches'],['Esc','cancel']]},columns,height); + if(state.step==='result')return renderStudioReview(state.review!,'Reset · results',columns,height); + return renderStudioLayout({title:'Step 1 · Choose reset areas',subtitle:'Space toggles · nothing is selected by default',selected:state.index, + entries:state.areas.map(item=>({group:item.id==='settings'?'NMSh settings':'Owned integrations',label:item.label, + render:()=>`${checkMark(state.selected.has(item.id))} ${item.label} · ${item.status}`})), + notice:state.message,details:width=>studioWrap(state.areas[state.index]?.reason??'Only NMSh-owned changes are offered; unrelated user lines are kept.',width), + controls:[['↑↓','select'],['Space','toggle'],['Enter','impact'],['Esc','cancel']]},columns,height); +} diff --git a/src/backups/diff.ts b/src/backups/diff.ts new file mode 100644 index 00000000..38715b39 --- /dev/null +++ b/src/backups/diff.ts @@ -0,0 +1,15 @@ +import type {FileChange} from '../appearance/hostFiles.js'; +/** Exact line diff with a common prefix/suffix; no secret masking or truncated changed lines. */ +export function changeDiff(change: FileChange): {lines: string[]; added: number; removed: number; kept: number} { + const split = (text: string | undefined) => text === undefined || text === '' ? [] : text.match(/[^\n]*\n|[^\n]+$/gu) ?? []; + const before = split(change.before.content), after = split(change.after); + let start = 0; + while (start < before.length && start < after.length && before[start] === after[start]) start++; + let end = 0; + while (end < before.length - start && end < after.length - start && before[before.length-1-end] === after[after.length-1-end]) end++; + const removed = before.slice(start,before.length-end), added = after.slice(start,after.length-end); + const display = (line: string) => line.replace(/\r\n$/u, ' ⟨CRLF⟩').replace(/\n$/u,'') + (line.endsWith('\n') ? '' : ' ⟨no final newline⟩'); + return {lines:[`File: ${change.before.path}`, `${change.after === undefined ? 'Remove file' : change.before.content === undefined ? 'Create file' : 'Replace changed lines'} · +${added.length} / -${removed.length}`, + ...removed.map(line=>`- ${display(line)}`),...added.map(line=>`+ ${display(line)}`), + `Your own unchanged lines in ${change.before.path} are kept (${start+end}).`],added:added.length,removed:removed.length,kept:start+end}; +} diff --git a/src/backups/journal.ts b/src/backups/journal.ts new file mode 100644 index 00000000..c615a132 --- /dev/null +++ b/src/backups/journal.ts @@ -0,0 +1,246 @@ +/** Private, verified before-images. A failed checkpoint must prevent its configuration write. */ +import {constants, closeSync, fchmodSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, lstatSync, writeFileSync} from 'node:fs'; +import {randomUUID} from 'node:crypto'; +import {join, dirname} from 'node:path'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {checkPath, digest, snapshot, unchanged, type FileChange} from '../appearance/hostFiles.js'; + +export interface JournalOptions {home: string; directory?: string; maxEntries?: number; maxBytes?: number} +export interface JournalFile {path: string; existed: boolean; mode: number; beforeHash: string; afterHash: string; bytes: number; afterBytes?: number; afterMode?: number} +export interface JournalEntry {version: 1; id: string; time: string; source: string; label: string; files: JournalFile[]; outcome: 'prepared' | 'applied'} +export interface JournalMetadata {source: string; label: string; directory?: string} +const protectedSource = (source: string) => ['reset', 'restore', 'manual'].includes(source); +export const ABSENT_HASH = 'absent'; +const hash = (text: string | undefined) => text === undefined ? ABSENT_HASH : digest(text); +export function journalDirectory(home: string, env: NodeJS.ProcessEnv = process.env): string { + return join(nmshConfigDirectory(env.HOME === home ? env : {HOME: home}), 'backups'); +} + +export class Journal { + readonly directory: string; + constructor(readonly options: JournalOptions) { this.directory = options.directory ?? journalDirectory(options.home); } + + private checked(path: string, writable = false): void { checkPath(path, this.options.home, writable); } + private atomic(path: string, content: string): void { + this.checked(path, true); + const temp = `${path}.${randomUUID()}.tmp`; + const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { writeFileSync(fd, content); fchmodSync(fd, 0o600); fsyncSync(fd); } + finally { closeSync(fd); } + try { this.checked(path, true); renameSync(temp, path); this.syncDirectory(dirname(path)); } + finally { try { rmSync(temp); } catch { /* renamed */ } } + } + private syncDirectory(path: string): void { + this.checked(path); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { fsyncSync(fd); } finally { closeSync(fd); } + } + private file(path: string): string { + this.checked(path); + const value = snapshot(path, this.options.home).content; + if (value === undefined) throw new Error('Backup verify failed: a journal file is missing.'); + return value; + } + read(id: string): JournalEntry { + if (!/^\d{8}T\d{9}Z-[a-f0-9-]{36}$/u.test(id)) throw new Error('Invalid backup id.'); + const base = join(this.directory, id); + const value = JSON.parse(this.file(join(base, 'manifest.json'))) as JournalEntry; + if (value.version !== 1 || value.id !== id || !['prepared','applied'].includes(value.outcome) || typeof value.source !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(value.source) || typeof value.label !== 'string' || value.label.length>512 + || typeof value.time !== 'string' || !Array.isArray(value.files) || value.files.length > 512) throw new Error('Backup manifest failed verification.'); + value.files.forEach((file, index) => { + if (typeof file.path !== 'string' || typeof file.existed !== 'boolean' || !Number.isInteger(file.mode) || file.mode < 0 || file.mode > 0o777 + || (file.afterMode!==undefined&&(!Number.isInteger(file.afterMode)||file.afterMode<0||file.afterMode>0o777)) + || typeof file.afterHash !== 'string' || !/^(?:absent|[a-f0-9]{64})$/u.test(file.afterHash)) throw new Error('Backup file metadata failed verification.'); + this.checked(file.path); + const content = this.file(join(base, 'files', String(index))); + if (file.afterBytes !== undefined) { + const after = this.file(join(base,'after',String(index))); + if (Buffer.byteLength(after) !== file.afterBytes || digest(after) !== file.afterHash) throw new Error('Backup after-image hash verification failed.'); + } + if (Buffer.byteLength(content) !== file.bytes || (file.existed ? digest(content) : ABSENT_HASH) !== file.beforeHash || (!file.existed && content !== '')) throw new Error('Backup hash verification failed.'); + }); + return value; + } + readonly unreadable: string[] = []; + list(options: {strict?: boolean} = {}): JournalEntry[] { + this.unreadable.length = 0; + this.checked(this.directory); + let names: string[]; + try { names = readdirSync(this.directory); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } + if (names.length > 4096) throw new Error('Backup directory exceeds the safe entry limit.'); + const entries: JournalEntry[] = []; + for (const name of names.filter(name => /^\d{8}T/u.test(name)).sort().reverse()) { + try { entries.push(this.read(name)); } + catch (error) { if (options.strict) throw error; this.unreadable.push(name); } + } + return entries; + } + content(entry: JournalEntry, index: number): string | undefined { + const verified = this.read(entry.id); + if (!verified.files[index]) throw new Error('Unknown backup file.'); + return verified.files[index]!.existed ? this.file(join(this.directory, entry.id, 'files', String(index))) : undefined; + } + writtenContent(entry: JournalEntry,index: number): string | undefined { + const verified=this.read(entry.id);const file=verified.files[index]; + if(!file)throw new Error('Unknown backup file.'); + if(file.afterHash===ABSENT_HASH)return undefined; + if(file.afterBytes===undefined)throw new Error('This older backup has no verified after-image; ownership-only reset is unavailable. Use /changes for an explicit restore.'); + return this.file(join(this.directory,entry.id,'after',String(index))); + } + status(file: JournalFile): 'unchanged since' | 'edited since' | 'missing' { + const current = snapshot(file.path, this.options.home); + return current.content === undefined ? 'missing' : hash(current.content) === file.afterHash && (file.afterMode===undefined||current.mode===file.afterMode) ? 'unchanged since' : 'edited since'; + } + private prunedKeys():Set { + const text=snapshot(join(this.directory,'.pruned.json'),this.options.home).content; + if(text===undefined)return new Set(); + const record = JSON.parse(text); + const keys:unknown=Array.isArray(record)?record:record.keys; + if(!Array.isArray(keys)||keys.length>24000||keys.some(key=>typeof key!=='string'||!/^[a-f0-9]{64}$/u.test(key)))throw new Error('Backup pruning record needs review.'); + return new Set(keys as string[]); + } + prunedCount(): number { + const text = snapshot(join(this.directory, '.pruned.json'), this.options.home).content; + if (text === undefined) return 0; + const record = JSON.parse(text); + return Number.isSafeInteger(record.count) && record.count >= 0 ? record.count : 0; + } + provenanceComplete(source:string,path:string):boolean { + return !this.prunedKeys().has(digest(`${source}:${path}`)); + } + /** Serialize writers across frontend processes; a crashed writer's verified lock can be recovered. */ + record(changes: readonly FileChange[], source: string, label: string): JournalEntry { + this.checked(this.directory,true);mkdirSync(this.directory,{recursive:true,mode:0o700});this.checked(this.directory,true); + if((lstatSync(this.directory).mode&0o077)!==0)throw new Error('Backup directory must be private (0700); configuration was not changed.'); + const path=join(this.directory,'.write-lock');const token=randomUUID(); + const content=JSON.stringify({owner:'nmsh-journal',version:1,pid:process.pid,token}); + const acquire=()=>openSync(path,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600); + let fd:number; + try {fd=acquire();}catch(error){ + if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error; + const before=snapshot(path,this.options.home);let lock:{owner?:string;version?:number;pid?:number}; + try {lock=JSON.parse(before.content??'');}catch{throw new Error('Backup lock is unreadable; configuration was not changed.');} + if(lock.owner!=='nmsh-journal'||lock.version!==1||!Number.isSafeInteger(lock.pid)||lock.pid!<=0)throw new Error('Backup lock is invalid; configuration was not changed.'); + let gone=false;try{process.kill(lock.pid!,0);}catch(probe){gone=(probe as NodeJS.ErrnoException).code==='ESRCH';} + if(!gone)throw new Error('Another NMSh configuration backup is in progress; retry after it finishes.'); + // A unique quarantine name prevents another stale-lock reclaimer from deleting our new lock. + unchanged(before,this.options.home); + const stale = `${path}.stale-${randomUUID()}`; + renameSync(path, stale); + try { fd=acquire(); } finally { rmSync(stale); } + } + try {writeFileSync(fd,content);fsyncSync(fd);return this.recordLocked(changes,source,label);} + finally {closeSync(fd);try{if(this.file(path)===content)rmSync(path);}catch{/* preserve an unexpected replacement */}} + } + private incompleteUsage():{bytes:number;count:number} { + const pending=readdirSync(this.directory).filter(name=>name.startsWith('.pending-')); + let files=0; + const size=(path:string,depth:number):number=>{ + this.checked(path);const stat=lstatSync(path); + if(stat.isFile()){if(++files>4096)throw new Error('Incomplete backup storage needs review.');return stat.size;} + if(!stat.isDirectory()||depth>3)throw new Error('Incomplete backup storage needs review.'); + return readdirSync(path).reduce((sum,name)=>sum+size(join(path,name),depth+1),0); + }; + let bytes=0,count=0; + for (const name of pending) { + const path=join(this.directory,name);const usage=size(path,0); + if (Date.now()-lstatSync(path).mtimeMs > 24*60*60*1000) { + // The exclusive journal lock also proves legacy staging without an owner has no active writer. + const owner=snapshot(join(path,'owner.json'),this.options.home).content; + let dead=owner===undefined; + if(owner!==undefined) { + let lock: {owner?:string;version?:number;pid?:number}; + try {lock=JSON.parse(owner);} catch {lock={};} + if(lock.owner==='nmsh-journal'&&lock.version===1&&Number.isSafeInteger(lock.pid)&&lock.pid!>0) { + try {process.kill(lock.pid!,0);} catch(error) {dead=(error as NodeJS.ErrnoException).code==='ESRCH';} + } + } + if(dead) {this.checked(path,true);rmSync(path,{recursive:true});continue;} + } + bytes+=usage;count++; + } + return {bytes,count}; + } + private recordLocked(changes: readonly FileChange[], source: string, label: string): JournalEntry { + if(!/^[a-z][a-z0-9-]{0,63}$/u.test(source)||label.length>512)throw new Error('Backup source or label is invalid.'); + if (changes.length > 512 || new Set(changes.map(change => change.before.path)).size !== changes.length) throw new Error('Backup file limit or duplicate path.'); + for (const change of changes) { + unchanged(change.before, this.options.home); + if(Buffer.byteLength(change.after??'')>2*1024*1024)throw new Error('Backup file limit is 2 MiB; configuration was not changed.'); + } + const now = new Date(); + const id = `${now.toISOString().replace(/[-:.]/gu, '')}-${randomUUID()}`; + const entry: JournalEntry = {version: 1, id, time: now.toISOString(), source, label, outcome: 'prepared', files: changes.map(change => ({ + path: change.before.path, existed: change.before.content !== undefined, mode: change.before.mode, + beforeHash: hash(change.before.content), afterHash: hash(change.after), bytes: Buffer.byteLength(change.before.content ?? ''), + ...(change.after===undefined?{}:{afterBytes:Buffer.byteLength(change.after),afterMode:change.afterMode??change.before.mode}), + }))}; + const serialized = `${JSON.stringify(entry, null, 2)}\n`; + const ownerContent=JSON.stringify({owner:'nmsh-journal',version:1,pid:process.pid}); + const size = Buffer.byteLength(ownerContent) + Buffer.byteLength(serialized) + entry.files.reduce((sum, file) => sum + (file.bytes + (file.afterBytes ?? 0)), 0); + const maxBytes = this.options.maxBytes ?? 64 * 1024 * 1024; + const maxEntries = this.options.maxEntries ?? 200; + if (size > maxBytes || maxEntries < 1) throw new Error('Backup capacity limit reached; configuration was not changed.'); + this.checked(this.directory, true); + const existing = this.list({strict:true}); + const cost = (item: JournalEntry) => Buffer.byteLength(snapshot(join(this.directory,item.id,'owner.json'),this.options.home).content??'') + Buffer.byteLength(this.file(join(this.directory,item.id,'manifest.json'))) + item.files.reduce((n,f)=>n+f.bytes+(f.afterBytes??0),0); + const incomplete=this.incompleteUsage(); + const pruned=this.prunedKeys(); + const oldMarker=Buffer.byteLength(snapshot(join(this.directory,'.pruned.json'),this.options.home).content??''); + const previousCount=this.prunedCount(); + let used = oldMarker + incomplete.bytes + existing.reduce((sum, item) => sum + cost(item), 0); + let count = existing.length + incomplete.count; + const victims: JournalEntry[] = []; + const keep = new Set(existing.filter(item=>protectedSource(item.source)).slice(0,protectedSource(source)?19:20).map(item=>item.id)); + // The incoming entry anchors its source/paths; retain the newest anchor for every other pair. + const anchored = new Set(changes.map(change=>`${source}:${change.before.path}`)); + for (const item of existing) for(const file of item.files) { + const key=`${item.source}:${file.path}`; + if(!anchored.has(key)){keep.add(item.id);anchored.add(key);} + } + const markerText = () => JSON.stringify({keys:[...pruned],count:previousCount+victims.length}); + const fits = () => used + size + (victims.length ? Buffer.byteLength(markerText()) - oldMarker : 0) <= maxBytes && count + 1 <= maxEntries; + for (const old of [...existing].reverse()) { + if (fits()) break; + if (keep.has(old.id)) continue; + used -= cost(old); count--; victims.push(old); + for(const file of old.files)pruned.add(digest(`${old.source}:${file.path}`)); + } + if (!fits()) throw new Error('Backup capacity limit reached after pruning; recent protected checkpoints and source/path anchors are kept. Review backups before retrying.'); + const marker=markerText(); + if(pruned.size>24000)throw new Error('Backup pruning metadata capacity reached; configuration was not changed.'); + if(victims.length){this.atomic(join(this.directory,'.pruned.json'),marker);if(this.file(join(this.directory,'.pruned.json'))!==marker)throw new Error('Backup pruning record failed verification.');} + // Only verified journal entries selected above are eligible; no user paths are ever pruned. + for (const old of victims) { const path = join(this.directory, old.id); this.checked(path, true); rmSync(path, {recursive: true}); } + mkdirSync(this.directory, {recursive:true, mode:0o700}); + this.checked(this.directory, true); + if ((lstatSync(this.directory).mode & 0o077) !== 0) throw new Error('Backup directory must be private (0700); configuration was not changed.'); + // Publish only a complete verified entry. Interrupted staging is ignored by history and expires after a day. + const base = join(this.directory, `.pending-${id}`); + mkdirSync(base, {mode:0o700}); mkdirSync(join(base,'files'), {mode:0o700}); mkdirSync(join(base,'after'), {mode:0o700}); + this.atomic(join(base,'owner.json'),ownerContent); + changes.forEach((change,index) => { + this.atomic(join(base,'files',String(index)),change.before.content ?? ''); + if(change.after!==undefined)this.atomic(join(base,'after',String(index)),change.after); + }); + this.atomic(join(base,'manifest.json'),serialized); + changes.forEach((change,index)=>{ + if(this.file(join(base,'files',String(index)))!==(change.before.content??''))throw new Error('Backup before-image read-back verification failed.'); + if(change.after!==undefined&&this.file(join(base,'after',String(index)))!==change.after)throw new Error('Backup after-image read-back verification failed.'); + }); + if(this.file(join(base,'manifest.json'))!==serialized)throw new Error('Backup manifest read-back verification failed.'); + this.syncDirectory(base); + this.checked(join(this.directory,id),true);renameSync(base,join(this.directory,id)); + this.syncDirectory(this.directory); + return this.read(id); + } + complete(entry: JournalEntry): void { + const verified = this.read(entry.id); + for (const file of verified.files) { + const current=snapshot(file.path,this.options.home); + if(hash(current.content)!==file.afterHash||(file.afterMode!==undefined&¤t.mode!==file.afterMode))throw new Error('Written configuration did not verify; backup is retained.'); + } + this.atomic(join(this.directory,entry.id,'manifest.json'),`${JSON.stringify({...verified,outcome:'applied'},null,2)}\n`); + this.read(entry.id); + } +} diff --git a/src/backups/reset.ts b/src/backups/reset.ts new file mode 100644 index 00000000..9f6eccbe --- /dev/null +++ b/src/backups/reset.ts @@ -0,0 +1,198 @@ +import {fragmentPath as cursorFragmentPath, hostConfigPath as cursorHostPath, shaderPath as cursorShaderPath} from '../cursor/native.js'; +/** Pure discovery and removal planning; only the wizard's final confirmation calls applyReset. */ +import {homedir} from 'node:os'; +import {join} from 'node:path'; +import {snapshot, digest, unchanged, transact, type FileChange, type FileSnapshot} from '../appearance/hostFiles.js'; +import {planHostRemoval} from '../appearance/hostAppearance.js'; +import {appearancePaths,type AppearanceContext} from '../appearance/hostPaths.js'; +import {planKittyShaderRemoval,kittyShaderPaths} from '../appearance/kittyShaders.js'; +import {planGhosttyShaderRemoval,ghosttyShaderPaths} from '../appearance/ghosttyShaders.js'; +import {planClaudeBridgeRemoval} from '../agents/claudeStatusLine.js'; +import {artifactPath, ledgerPath, loadLedger, ownership, planHookRemoval, type ManagedTarget, type HookTarget} from '../themeBridge/artifacts.js'; +import {nmshConfigDirectory} from '../configuration/paths.js'; +import {DEFAULT_PROMPT_CONFIGURATION} from '../prompt/configuration.js'; +import {Journal, type JournalEntry} from './journal.js'; +import {changeDiff} from './diff.js'; +import type {FileEditPlan} from '../ask/fileEdit.js'; + +export interface ResetArea {id: string; label: string; status: 'set up' | 'not set up' | 'nothing to reset'; changes: FileChange[]; dependencies: FileSnapshot[]; reason?: string} +export interface ResetPlan {areas: ResetArea[]; changes: FileChange[]; dependencies: FileSnapshot[]; preview: string[]; steps: Array<{area: ResetArea; changes: FileChange[]}>} +const homeOf = (context: AppearanceContext) => context.env.HOME || homedir(); +function editChange(plan: FileEditPlan, home: string): FileChange { + const before = snapshot(plan.path,home,true); + if ((before.content === undefined ? 'absent' : digest(before.content)) !== plan.expectedSha256) throw new Error('Configuration changed while planning.'); + let after = before.content ?? ''; + for (const edit of [...plan.edits].sort((a,b)=>b.start-a.start)) after = after.slice(0,edit.start)+edit.text+after.slice(edit.end); + if (digest(after) !== plan.resultSha256) throw new Error('Removal did not match the reviewed result.'); + return {before,after}; +} +function area(id: string,label: string,build:()=>{changes: readonly FileChange[]; dependencies?: readonly FileSnapshot[]}): ResetArea { + try {const plan=build();const changes=plan.changes.filter(change=>change.after!==change.before.content); + return {id,label,status:changes.length?'set up':'nothing to reset',changes,dependencies:[...(plan.dependencies??[])]}; + } catch(error) {return {id,label,status:'not set up',changes:[],dependencies:[],reason:error instanceof Error?error.message:String(error)};} +} +/** A legacy adapter without a ledger can be reset only with an applied journal record and an exact after hash. */ +function fromJournal(journal: Journal, source: string, accept: (path: string)=>boolean): FileChange[] { + const entries=journal.list({strict:true}).filter(entry=>entry.source===source && entry.outcome==='applied'); + const current=new Map();const reverted=new Map(); + // A reset can already have restored the earliest before-image for a path. + const originals=new Map(); + for(const entry of [...entries].reverse())entry.files.forEach((file,index)=>{if(accept(file.path)&&!originals.has(file.path))originals.set(file.path,journal.content(entry,index));}); + for(const entry of entries)entry.files.forEach((file,index)=>{ + if(!accept(file.path))return; + if(!journal.provenanceComplete(source,file.path))throw new Error(`${file.path}: older automatic backups were pruned; ownership-only reset is skipped. Use /changes to review an available snapshot.`); + const before=current.get(file.path)??snapshot(file.path,journal.options.home,true);current.set(file.path,before); + if(before.content===originals.get(file.path)){reverted.set(file.path,before.content);return;} + const text=reverted.has(file.path)?reverted.get(file.path):before.content; + const original=journal.content(entry,index),written=journal.writtenContent(entry,index); + reverted.set(file.path,reverseOwnedEdit(original,written,text,file.path)); + }); + if(!current.size)throw new Error('No NMSh ownership record or applied backup exists; nothing is reset.'); + return [...current].map(([path,before])=>({before,after:reverted.get(path)})); +} +/** Reverse one contiguous changed line block, retaining user edits elsewhere, including edits between NMSh saves. */ +function reverseOwnedEdit(original:string|undefined,written:string|undefined,current:string|undefined,path:string):string|undefined { + if(original===written)return current; + if(current===written)return original; + const lines=(value:string|undefined)=>value?.match(/[^\n]*\n|[^\n]+$/gu)??[]; + const old=lines(original),after=lines(written); + let start=0;while(startarea(`${host}-appearance`,`${host==='kitty'?'Kitty':'Ghostty'} appearance`,()=>{ + const result=planHostRemoval(host,context);if(!result.ok)throw new Error(result.error);return result.plan; + }); + const shaders=(host:'kitty'|'ghostty')=>area(`${host}-shaders`,`${host==='kitty'?'Kitty':'Ghostty'} shaders`,()=>{ + const result=host==='kitty'?planKittyShaderRemoval(context):planGhosttyShaderRemoval(context); + if(!result.ok)throw new Error(result.error);return result.plan; + }); + const cursor=(host:'kitty'|'ghostty')=>area(`${host}-cursor`,`${host==='kitty'?'Kitty':'Ghostty'} cursor include`,()=>{ + const paths=new Set([cursorFragmentPath(host,env),cursorHostPath(host,env,context.platform,home),...(host==='ghostty'?[cursorShaderPath(env)]:[])]); + return {changes:fromJournal(journal,'cursor',path=>paths.has(path))}; + }); + return [hostArea('kitty'),shaders('kitty'),cursor('kitty'),hostArea('ghostty'),shaders('ghostty'),cursor('ghostty'), + area('keyboard','Ghostty keyboard',()=>({changes:fromJournal(journal,'keyboard',()=>true)})), + area('tmux','tmux managed block',()=>({changes:bridgeChanges(context,['tmuxConfig','tmux'])})), + area('starship','Starship modules NMSh set',()=>({changes:fromJournal(journal,'starship',()=>true)})), + area('theme-bridge','Theme Bridge artifacts',()=>({changes:bridgeChanges(context,['neovim','vim','helix','bat','vivid'])})), + area('statusline','Claude status line hook',()=>{ + const result=planClaudeBridgeRemoval(env);if('refuse' in result)throw new Error(result.refuse);if('noop' in result)return {changes:[]}; + return {changes:[editChange(result.plan,home),{before:snapshot(join(nmshConfigDirectory(env),'agent-context/claude-status-line.json'),home,true)}]}; + }), + area('settings','NMSh settings (config.json) → defaults',()=>({changes:[{before:snapshot(join(nmshConfigDirectory(env),'config.json'),home,true),after:`${JSON.stringify(DEFAULT_PROMPT_CONFIGURATION,null,2)}\n`}]})), + ]; +} +interface Splice {start:number;end:number;text:string} +function splice(before:string,after:string):Splice { + // Whole-line boundaries keep adjacent includes disjoint even when their text shares a long prefix. + const lines=(text:string)=>text.match(/[^\n]*\n|[^\n]+$/gu)??[]; + const old=lines(before),next=lines(after);let head=0,tail=0; + while(head, context: AppearanceContext): ResetPlan { + const chosen=areas.filter(item=>selected.has(item.id));const byPath=new Map(); + const steps:ResetPlan['steps']=[];const preview:string[]=[];const virtual=new Map(); + for(const item of chosen){ + if(item.reason||!item.changes.length){preview.push(`${item.label}: skipped: ${item.reason??'nothing to reset'}`);continue;} + // Reject an entire area if any of its changes overlaps an earlier area. Never silently broaden the reset. + const additions=item.changes.map(change=>({change,patch:splice(change.before.content??'',change.after??'')})); + const conflict=additions.find(({change,patch})=>byPath.get(change.before.path)?.patches.some(old=>(patch.start===old.start&&(patch.end===patch.start||old.end===old.start))||!(patch.end<=old.start||patch.start>=old.end))); + if(conflict){item.reason=`Overlapping removal in ${conflict.change.before.path}; reset this area separately.`;preview.push(`${item.label}: skipped: ${item.reason}`);continue;} + const stepChanges:FileChange[]=[]; + for(const {change,patch} of additions){ + const prior=byPath.get(change.before.path);const patches=[...(prior?.patches??[]),patch].sort((a,b)=>b.start-a.start); + let text=change.before.content??'';for(const part of patches)text=text.slice(0,part.start)+part.text+text.slice(part.end); + const after=change.after===undefined&&text===''?undefined:text; + const before={...change.before,content:virtual.has(change.before.path)?virtual.get(change.before.path):change.before.content}; + stepChanges.push({before,after,afterMode:change.afterMode});virtual.set(change.before.path,after); + byPath.set(change.before.path,{before:prior?.before??change.before,patches,mode:change.afterMode}); + } + steps.push({area:item,changes:stepChanges});preview.push(item.label,...stepChanges.flatMap(change=>changeDiff(change).lines)); + } + const changes=[...byPath].map(([path,value])=>({before:value.before,after:virtual.get(path),afterMode:value.mode})); + return {areas:chosen,changes,dependencies:chosen.flatMap(item=>item.dependencies),preview,steps}; +} +export function applyReset(plan:ResetPlan,journal:Journal):{entry?:JournalEntry;results:string[];paths:string[]} { + if(!plan.steps.length||!plan.changes.length)return {results:['Nothing to reset'],paths:[]}; + for(const file of [...plan.dependencies,...plan.changes.map(change=>change.before)])unchanged(file,journal.options.home,true); + const entry=journal.record(plan.changes,'reset',`Reset ${plan.steps.map(step=>step.area.label).join(', ')}`); + for(const file of [...plan.dependencies,...plan.changes.map(change=>change.before)])unchanged(file,journal.options.home,true); + const results=plan.areas.filter(area=>area.reason||!area.changes.length).map(area=>`${area.label}: skipped: ${area.reason??'nothing to reset'}`); + const paths:string[]=[]; + for(const [stepIndex,step] of plan.steps.entries()){ + try { + const changes=step.changes.map(change=>{ + const current=snapshot(change.before.path,journal.options.home,true); + if(current.content!==change.before.content)throw new Error('Configuration changed during reset; remaining areas were kept.'); + return {...change,before:current}; + }); + transact(changes,[],journal.options.home,{source:'reset',label:step.area.label,directory:journal.directory}); + results.push(`${step.area.label}: done`);paths.push(...changes.map(change=>change.before.path)); + }catch(error){results.push(`${step.area.label}: failed: ${error instanceof Error?error.message:String(error)}`);results.push(...plan.steps.slice(stepIndex+1).map(next=>`${next.area.label}: skipped: an earlier area failed`));break;} + } + // A partially applied reset remains a prepared checkpoint, and is still restorable. + try {journal.complete(entry);}catch{/* per-area results remain authoritative */} + return {entry,results,paths:[...new Set(paths)]}; +} +export function ownedSnapshotPaths(context:AppearanceContext,journal:Journal):string[] { + const areas=discoverResetAreas(context,journal),home=homeOf(context),env=context.env; + const paths=new Set([join(nmshConfigDirectory(env),'config.json'),ledgerPath(env), + ...areas.flatMap(area=>[...area.changes.map(change=>change.before.path),...area.dependencies.map(file=>file.path)])]); + // Read-only capture includes damaged legacy managed files too; a refused removal must not hide them from a manual backup. + for(const host of ['kitty','ghostty'] as const){ + const appearance=appearancePaths(host,context);const shaders=host==='kitty'?kittyShaderPaths(context):ghosttyShaderPaths(context); + for(const path of [appearance.fragment,appearance.ledger,shaders.fragment,shaders.ledger,cursorFragmentPath(host,env)]){ + const file=snapshot(path,home);if(file.content!==undefined)paths.add(path); + } + for(const recordPath of [appearance.ledger,shaders.ledger]){ + const file=snapshot(recordPath,home);if(file.content===undefined)continue; + let record:{hostPath?:unknown;passes?:Array<{path?:unknown}>;shaders?:Array<{path?:unknown}>};try{record=JSON.parse(file.content);}catch{continue;} + if(typeof record.hostPath==='string'&&appearance.candidates.includes(record.hostPath))paths.add(record.hostPath); + for(const pass of [...(Array.isArray(record.passes)?record.passes:[]),...(Array.isArray(record.shaders)?record.shaders:[])])if(typeof pass.path==='string'){ + // The checked snapshot refuses paths outside HOME and symbolic links; ledger data is never executed. + snapshot(pass.path,home);paths.add(pass.path); + } + } + } + for(const path of [cursorShaderPath(env),kittyShaderPaths(context).pipeline,join(nmshConfigDirectory(env),'agent-context/claude-status-line.json')]){ + if(snapshot(path,home).content!==undefined)paths.add(path); + } + for(const entry of Object.values(loadLedger(env).entries))if(entry){paths.add(entry.artifactPath);if(entry.hook){snapshot(entry.hook.configPath,home);paths.add(entry.hook.configPath);}} + return [...paths]; +} diff --git a/src/backups/restore.ts b/src/backups/restore.ts new file mode 100644 index 00000000..73928e44 --- /dev/null +++ b/src/backups/restore.ts @@ -0,0 +1,34 @@ +import {dirname,join} from 'node:path'; +import {Journal, type JournalEntry} from './journal.js'; +import {digest, snapshot, unchanged, transact, type FileChange} from '../appearance/hostFiles.js'; +import {changeDiff} from './diff.js'; +export interface RestorePlan {entry: JournalEntry; changes: FileChange[]; edited: string[]; preview: string[]; guidance: string} +const prepared = new WeakSet(); +export function planRestore(journal: Journal, id: string): RestorePlan { + const entry = journal.read(id); + const changes = entry.files.map((file,index) => ({before:snapshot(file.path,journal.options.home,true),after:journal.content(entry,index),afterMode:file.mode})); + const edited = changes.filter((change,index)=>(change.before.content === undefined ? 'absent' : digest(change.before.content)) !== entry.files[index]!.afterHash || (entry.files[index]!.afterMode!==undefined && change.before.content!==undefined && change.before.mode!==entry.files[index]!.afterMode)).map(change=>change.before.path); + const settings=join(dirname(journal.directory),'config.json'); + if(!changes.some(change=>change.before.path===settings)){const before=snapshot(settings,journal.options.home,true);changes.push({before,after:before.content,afterMode:before.mode});} + const plan: RestorePlan = {entry,changes,edited,preview:[ + `${entry.time} · ${entry.source} · ${entry.label}`, + ...entry.files.map(file=>`${file.path}: ${journal.status(file)}`), + ...edited.map(path=>`WARNING: your later edits to ${path} will be lost.`), + ...changes.flatMap(change=>changeDiff(change).lines)], + guidance:'Restore these exact files. Current contents are backed up first, so this restore can itself be undone. Terminal configuration may need a reload.'}; + prepared.add(plan); return plan; +} +export function restore(journal: Journal, plan: RestorePlan, confirmation: {reviewed: boolean; overwrite: boolean}): JournalEntry { + if (!prepared.has(plan) || !confirmation.reviewed) throw new Error('Review this restore before confirming.'); + if (plan.edited.length && !confirmation.overwrite) throw new Error('Confirm that your later edits will be lost.'); + for (const change of plan.changes) unchanged(change.before,journal.options.home,true); + const undo = transact(plan.changes,[],journal.options.home,{source:'restore',label:`Restore ${plan.entry.label}`,directory:journal.directory}); + if (!undo) throw new Error('Restore checkpoint was not created.'); + prepared.delete(plan); return undo; +} +/** Includes known journal paths and caller-discovered ownership paths; reading/planning alone writes nothing. */ +export function manualSnapshot(journal: Journal, ownershipPaths: readonly string[]): JournalEntry { + const paths = [...new Set([...journal.list().flatMap(entry=>entry.files.map(file=>file.path)),...ownershipPaths])]; + const changes = paths.map(path=>{const before=snapshot(path,journal.options.home);return {before,after:before.content};}); + const entry = journal.record(changes,'manual','Manual snapshot of NMSh-managed files'); journal.complete(entry); return entry; +} diff --git a/src/backups/settings.ts b/src/backups/settings.ts new file mode 100644 index 00000000..e61d0d16 --- /dev/null +++ b/src/backups/settings.ts @@ -0,0 +1,36 @@ +import {dirname, join, relative, isAbsolute} from 'node:path'; +import {Journal} from './journal.js'; +import {snapshot} from '../appearance/hostFiles.js'; + +const failures = new Map(); +const reported = new Set(); +/** Deferred diagnostics keep backup errors out of the terminal transcript. Shown once in /changes. */ +export function takeSettingsCheckpointFailure(directory: string): string | undefined { + const message=failures.get(directory);failures.delete(directory);return message; +} +function failed(directory:string,error:unknown):void { + if(reported.has(directory))return; + reported.add(directory); + failures.set(directory,`Settings backup unavailable; checkpoint skipped. ${error instanceof Error?error.message:String(error)}`); +} +const checkedToday = new Map(); +/** Only the first settings save per UTC day checkpoints; this is not a per-keypress journal. */ +export function dailySettingsCheckpoint(path: string, after: string, now = new Date(), env:NodeJS.ProcessEnv=process.env): () => void { + const day = now.toISOString().slice(0,10); + if (checkedToday.get(path) === day) return () => {}; + // Own state can live under an explicitly selected XDG directory. External adapters always require HOME. + const candidate=env.HOME; + const inside=candidate?relative(candidate,path):'..'; + const home = candidate && inside!=='..' && !inside.startsWith('../') && !isAbsolute(inside) ? candidate : dirname(path); + const journal = new Journal({home, directory:join(dirname(path),'backups')}); + try { + if (journal.list({strict:true}).some(entry => entry.source === 'settings' && entry.outcome === 'applied' && entry.time.slice(0,10) === day)) { + checkedToday.set(path,day); return () => {}; + } + const entry = journal.record([{before:snapshot(path,home), after, afterMode:0o600}], 'settings', 'Daily NMSh settings checkpoint'); + return () => { + try { journal.complete(entry); checkedToday.set(path,day); } + catch(error) { failed(journal.directory,error); } + }; + } catch(error) { failed(journal.directory,error); return () => {}; } +} diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 14d11a4c..f760103d 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -29,12 +29,19 @@ const META: Record> = { '/welcome': {group: 'Providers', title: 'Configure Welcome'}, '/history-provider': {group: 'Providers', title: 'Configure History Provider'}, '/tools': {group: 'Tools & integration', title: 'Open Tools'}, '/configure': {group: 'Tools & integration', title: 'Open Tool Configuration'}, '/tmux': {group: 'Tools & integration', title: 'Configure tmux'}, '/integrations': {group: 'Tools & integration', title: 'Check Integrations'}, + '/changes': {group: 'Tools & integration', title: 'Browse Changes & Backups'}, '/backups': {alias: '/changes'}, '/snapshots': {alias: '/changes'}, + '/nmsh reset': {group: 'Tools & integration', title: 'Reset NMSh-owned settings'}, '/dotfiles': {group: 'Tools & integration', title: 'Import Dotfiles'}, '/btw': {title: 'Open local intelligence'}, '/ask': {alias: '/btw'}, '/caffeinate': {group: 'Tools & integration', title: 'Open Keep Awake'}, '/awake': {alias: '/caffeinate'}, '/zoomies': {alias: '/caffeinate'}, }; const RAW_COMMANDS: readonly SlashCommand[] = [ + {name:'/changes', insertion:'/changes', description:'Browse backed-up config changes and review a restore; S takes a manual snapshot'}, + {name:'/backups', insertion:'/backups', description:'Same as /changes'}, + {name:'/snapshots', insertion:'/snapshots', description:'Same as /changes'}, + {name:'/nmsh reset', insertion:'/nmsh reset', description:'Choose NMSh-owned areas, review impact and backup, then type reset to confirm'}, + {name:'/reset', insertion:'/reset', description:'Explain the guarded /nmsh reset; this command writes nothing'}, {name: '/actions', insertion: '/actions', description: 'Select completed output: Up/Down moves, Enter opens Actions, Esc returns; /actions N opens output N directly'}, {name: '/action', insertion: '/action', alias: '/actions', description: 'Same as /actions, including /action N'}, {name: '/effects', insertion: '/effects ', description: 'Preview sparkles, rain or confetti in owned chrome; /effects stop cancels'}, @@ -192,6 +199,7 @@ export type ParsedSlashCommand = /** /edit : NMSh's small native editor for one text file (relative to the shell's directory, ~ for home). */ | {kind: 'editFile'; target: string} | {kind: 'appearance'} + | {kind: 'changes'} | {kind: 'nmshReset'} | {kind: 'resetGuidance'} | {kind: 'motion'} | {kind: 'prompt'} | {kind: 'modules'; report?: 'explain' | 'timings'} @@ -304,6 +312,9 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (/^\/(?:copy|cp)\s+settings\s*$/u.test(input)) return {kind: 'copySettings'}; const copy = /^\/(?:copy|cp)(?:\s+(.*))?$/su.exec(input); if (copy) return {kind: 'copy', args: copy[1] ?? ''}; + if (/^\/(?:changes|backups|snapshots)\s*$/u.test(input)) return {kind:'changes'}; + if (/^\/nmsh\s+reset\s*$/u.test(input)) return {kind:'nmshReset'}; + if (/^\/reset\s*$/u.test(input)) return {kind:'resetGuidance'}; if (/^\/appearance\s*$/u.test(input)) return {kind: 'appearance'}; if (/^\/motion\s*$/u.test(input)) return {kind: 'motion'}; if (/^\/prompt\s*$/u.test(input)) return {kind: 'prompt'}; diff --git a/src/cursor/native.ts b/src/cursor/native.ts index 5c28ca60..4e37bc6a 100644 --- a/src/cursor/native.ts +++ b/src/cursor/native.ts @@ -1,6 +1,8 @@ -import {existsSync, mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; +import {journalDirectory} from '../backups/journal.js'; +import {snapshot, transact} from '../appearance/hostFiles.js'; +import {existsSync, readFileSync} from 'node:fs'; import {homedir} from 'node:os'; -import {dirname, join} from 'node:path'; +import {join} from 'node:path'; import type {CursorSettings} from '../prompt/configuration.js'; import {nmshConfigDirectory} from '../configuration/paths.js'; import {inspectFile, planAppend, planCreate, type PlanResult} from '../ask/fileEdit.js'; @@ -189,10 +191,8 @@ export function writeManagedFiles(host: NativeHost, settings: CursorSettings, en const changed: ManagedFile[] = []; const write = (kind: ManagedFile, path: string, content: string) => { try { if (readFileSync(path, 'utf8') === content) return; } catch { /* missing: written below */ } - mkdirSync(dirname(path), {recursive: true, mode: 0o700}); - const temporary = `${path}.${process.pid}.tmp`; - writeFileSync(temporary, content, {mode: 0o644}); - renameSync(temporary, path); + const home = env.HOME || homedir(); + transact([{before: snapshot(path, home, true), after: content}], [], home, {source: 'cursor', directory: journalDirectory(home,env), label: `${host} cursor ${kind}`}); changed.push(kind); }; if (host === 'ghostty') write('shader', shaderPath(env), shaderSource(settings)); diff --git a/src/dotfiles/plan.ts b/src/dotfiles/plan.ts index 9672aa83..1a273f56 100644 --- a/src/dotfiles/plan.ts +++ b/src/dotfiles/plan.ts @@ -1,6 +1,7 @@ -import {copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync} from 'node:fs'; +import {journalDirectory} from '../backups/journal.js'; +import {snapshot, transact} from '../appearance/hostFiles.js'; +import {existsSync, lstatSync, readFileSync, realpathSync} from 'node:fs'; import {homedir} from 'node:os'; -import {dirname} from 'node:path'; import {parse as parseToml} from 'smol-toml'; import {sha256} from '../ask/fileEdit.js'; import { @@ -137,19 +138,16 @@ export function reviewLines(items: readonly DotfilesItem[], include: readonly st } /** Backup then atomic replace, refusing if the destination changed since review or is a symlink. */ -function copyExact(item: DotfilesItem, now: Date): string { +function copyExact(item: DotfilesItem, now: Date, env: NodeJS.ProcessEnv): string { const destination = item.destination!; const verdict = item.file.tool.exactCopy?.(item.content!); if (!verdict?.ok) return 'no exact-copy authority for this tool; nothing was written.'; if (existsSync(destination)) { if (lstatSync(destination).isSymbolicLink()) return `${destination} is a symlink (to ${realpathSync(destination)}); not replaced.`; if (sha256(readFileSync(destination, 'utf8')) !== item.destinationSha) return `${destination} changed since review; nothing was written.`; - copyFileSync(destination, `${destination}.nmsh-backup-${now.toISOString().replace(/[:.]/gu, '-')}`); } else if (item.destinationSha !== 'absent') return `${destination} disappeared since review; nothing was written.`; - mkdirSync(dirname(destination), {recursive: true}); - const staged = `${destination}.nmsh-${process.pid}.tmp`; - writeFileSync(staged, item.content!, {encoding: 'utf8', mode: 0o644}); - renameSync(staged, destination); + const home = homeOf(env); + transact([{before: snapshot(destination, home, true), after: item.content!}], [], home, {source: 'dotfiles', directory: journalDirectory(home,env), label: `Copy ${item.file.tool.label} configuration`}); return `copied to ${destination}`; } @@ -167,7 +165,7 @@ export function applyPlan(items: readonly DotfilesItem[], env: NodeJS.ProcessEnv saveTmuxModel(model, env); const written = writeTmuxManaged(model, env); results.push(`${item.file.tool.label}: ${written.ok ? 'supported values saved to the NMSh-managed tmux file' : written.error}`); - } else if (item.mode === 'copy') results.push(`${item.file.tool.label}: ${copyExact(item, now)}`); + } else if (item.mode === 'copy') results.push(`${item.file.tool.label}: ${copyExact(item, now, env)}`); } catch (error) { results.push(`${item.file.tool.label}: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/keyboard/ghosttyKeyboard.ts b/src/keyboard/ghosttyKeyboard.ts index 6e714917..ccac12b8 100644 --- a/src/keyboard/ghosttyKeyboard.ts +++ b/src/keyboard/ghosttyKeyboard.ts @@ -1,3 +1,5 @@ +import {journalDirectory} from '../backups/journal.js'; +import {snapshot, transact} from '../appearance/hostFiles.js'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; @@ -49,7 +51,10 @@ export async function installGhosttyKeybinding(): Promise<{success: boolean; err return {success: true}; // Already installed } - await fs.writeFile(configPath, newConfig, 'utf8'); + const home = process.env.HOME || os.homedir(); + const before = snapshot(configPath, home, true); + if (before.content !== currentConfig) throw new Error('Configuration changed since preview.'); + transact([{before, after: newConfig}], [], home, {source: 'keyboard', directory: journalDirectory(home), label: 'Install enhanced keyboard bindings'}); return {success: true}; } catch (err: any) { diff --git a/src/prompt/StarshipConfigAdapter.ts b/src/prompt/StarshipConfigAdapter.ts index fbe76884..394d8a5b 100644 --- a/src/prompt/StarshipConfigAdapter.ts +++ b/src/prompt/StarshipConfigAdapter.ts @@ -1,10 +1,11 @@ +import {snapshot, transact} from '../appearance/hostFiles.js'; +import {journalDirectory} from '../backups/journal.js'; +import {homedir} from 'node:os'; import {execFile} from 'node:child_process'; import {promisify} from 'node:util'; -import {copyFile, lstat, mkdir, mkdtemp, open, readFile, rename, rm} from 'node:fs/promises'; -import {constants} from 'node:fs'; +import {lstat, mkdtemp, open, readFile, rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; -import {dirname, join} from 'node:path'; -import {randomUUID} from 'node:crypto'; +import {join} from 'node:path'; import type {StarshipStatus} from './starship.js'; const execFileAsync = promisify(execFile); @@ -45,7 +46,8 @@ function unsupportedContent(text: string, module: StarshipModule): string { /** Narrow adapter around Starship's own CLI, never a generic TOML editor. */ export class StarshipConfigAdapter { constructor(private readonly status: StarshipStatus, - private readonly run: typeof execFileAsync = execFileAsync) { + private readonly run: typeof execFileAsync = execFileAsync, + private readonly env: NodeJS.ProcessEnv = process.env) { if (!status.installed || !status.binary) throw new Error('Starship is not installed.'); } @@ -118,23 +120,10 @@ export class StarshipConfigAdapter { throw new Error('Starship config changed since preview; review it again.'); } if (proposal.proposed === proposal.original) return undefined; - await mkdir(dirname(proposal.path), {recursive: true, mode: 0o700}); - let backup: string | undefined; - if (current.exists) { - backup = `${proposal.path}.nmsh-backup-${new Date().toISOString().replace(/[:.]/gu, '-')}-${randomUUID()}`; - await copyFile(proposal.path, backup, constants.COPYFILE_EXCL); - } - const temporary = `${proposal.path}.${randomUUID()}.tmp`; - try { - const handle = await open(temporary, 'wx', current.mode); - try { await handle.writeFile(proposal.proposed, 'utf8'); await handle.sync(); } finally { await handle.close(); } - await rename(temporary, proposal.path); - const directory = await open(dirname(proposal.path), 'r'); - try { await directory.sync(); } finally { await directory.close(); } - return backup; - } catch (error) { - await rm(temporary, {force: true}); - throw error; - } + const home = this.env.HOME || homedir(); + const directory = journalDirectory(home, this.env); + const entry = transact([{before: snapshot(proposal.path, home, true), after: proposal.proposed}], [], home, + {source: 'starship', label: `${proposal.module}.disabled = ${proposal.disabled}`, directory}); + return current.exists && entry ? join(directory, entry.id, 'files', '0') : undefined; } } diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index d96f902f..54cceb54 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -1,10 +1,13 @@ +import {snapshot as configurationSnapshot, unchanged as configurationUnchanged} from '../appearance/hostFiles.js'; +import {randomUUID} from 'node:crypto'; +import {dailySettingsCheckpoint} from '../backups/settings.js'; import {DEFAULT_WELCOME_STUDIO, normalizeWelcomeStudio, normalizeWelcomePresets, type WelcomeStudio, type SavedWelcomePreset} from '../welcome/model.js'; import {CONTEXT_MODULE_REGISTRY, firstPartyDefaultModules, isPackModuleId, moduleDefinition, type ContextModuleId} from '../context/modules.js'; export {CONTEXT_MODULE_REGISTRY, moduleDefinition, type ContextModuleDefinition, type ContextModuleId} from '../context/modules.js'; import {normalizeTreatmentSettings, DEFAULT_TREATMENT_SETTINGS, validCustomStops, type TreatmentSettings} from '../chroma/treatment.js'; import {COLOR_PRESET_IDS, COLOR_PRESET_LABELS} from '../chroma/colorPresets.js'; import {DEFAULT_HOST_SHADERS, normalizeHostShaders, type HostShaderSettings} from '../appearance/kittyShaderSettings.js'; -import {mkdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; +import {mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync} from 'node:fs'; import {dirname} from 'node:path'; import {promptConfigurationPath} from '../configuration/paths.js'; import {UPDATE_CHECK_FREQUENCIES, migrateUpdateSettings, type UpdateCheckFrequency, type UpdateFrequency, type UpdateMode} from '../update/update.js'; @@ -1258,14 +1261,20 @@ function applyChanges(fresh: unknown, base: unknown, next: unknown): unknown { */ export function savePromptConfiguration(configuration: PromptConfiguration, path = promptConfigurationPath(), base?: PromptConfiguration): void { mkdirSync(dirname(path), {recursive: true, mode: 0o700}); + const before=configurationSnapshot(path,dirname(path)); const normalized = normalizePromptConfiguration(configuration); const existing = readExistingConfiguration(path); const persisted = base ? applyChanges(existing ?? {}, normalizePromptConfiguration(base), normalized) : preserveConfiguration(existing, normalized); - const temporary = `${path}.${process.pid}.tmp`; - writeFileSync(temporary, `${JSON.stringify(persisted, null, 2)}\n`, {encoding: 'utf8', mode: 0o600}); - renameSync(temporary, path); + const content = `${JSON.stringify(persisted, null, 2)}\n`; + const complete = dailySettingsCheckpoint(path, content); + configurationUnchanged(before,dirname(path),true); + const temporary = `${path}.${randomUUID()}.tmp`; + writeFileSync(temporary, content, {encoding: 'utf8', mode: 0o600, flag:'wx'}); + try {configurationUnchanged(before,dirname(path),true);renameSync(temporary,path);} + finally {try{unlinkSync(temporary);}catch{/* renamed or absent */}} + complete(); } export function hasVisibleContextModule( diff --git a/src/themeBridge/artifacts.ts b/src/themeBridge/artifacts.ts index 29ac6df6..1325b6b2 100644 --- a/src/themeBridge/artifacts.ts +++ b/src/themeBridge/artifacts.ts @@ -1,5 +1,7 @@ +import {journalDirectory} from '../backups/journal.js'; +import {snapshot, transact, type FileChange} from '../appearance/hostFiles.js'; import {createHash} from 'node:crypto'; -import {existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync} from 'node:fs'; +import {existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync} from 'node:fs'; import {homedir} from 'node:os'; import {dirname, join} from 'node:path'; import {nmshConfigDirectory} from '../configuration/paths.js'; @@ -117,6 +119,10 @@ export function saveLedger(ledger: Ledger, env: NodeJS.ProcessEnv = process.env) renameSync(staged, path); } +function ledgerChange(ledger:Ledger,env:NodeJS.ProcessEnv):FileChange { + return {before:snapshot(ledgerPath(env),env.HOME || homedir(),true),after:`${JSON.stringify(ledger,null,2)}\n`}; +} + export type Ownership = 'absent' | 'owned' | 'modified' | 'unknown'; /** Owned: the ledger recorded this exact content. Modified/unknown files are never touched. */ @@ -149,22 +155,16 @@ export function writeArtifact(target: ManagedTarget, content: string, validate: const hash = sha256(content); const previous = ledger.entries[target]; const changed = !previous || previous.sha256 !== hash || state === 'absent'; - if (changed) { - mkdirSync(dirname(path), {recursive: true, mode: 0o700}); - const staged = `${path}.${process.pid}.tmp`; - try { - writeFileSync(staged, content, {encoding: 'utf8', mode: 0o644}); - if (readFileSync(staged, 'utf8') !== content || !validate(readFileSync(staged, 'utf8'))) throw new Error('staged file did not verify'); - renameSync(staged, path); - } catch (error) { - try { unlinkSync(staged); } catch { /* nothing staged */ } - return {ok: false, error: `Writing ${path} failed: ${error instanceof Error ? error.message : String(error)}. The previous file was kept.`}; - } - } ledger.entries[target] = {...record, target, artifactPath: path, sha256: hash, adapterVersion: ADAPTER_VERSION, generatedAt: now.toISOString(), ...(previous?.hook ? {hook: previous.hook} : {}), ...(previous?.cacheApproved ? {cacheApproved: true} : {}), ...(previous?.cacheBuiltFor ? {cacheBuiltFor: previous.cacheBuiltFor} : {})}; - saveLedger(ledger, env); + if(changed){ + try { + const home=env.HOME || homedir(); + transact([{before:snapshot(path,home,true),after:content},ledgerChange(ledger,env)],[],home, + {source:target==='tmuxConfig'?'tmux':'theme-bridge',directory:journalDirectory(home,env),label:`Generate ${target}`}); + }catch(error){return {ok:false,error:`Writing ${path} failed: ${error instanceof Error?error.message:String(error)}. The previous file was kept.`};} + }else saveLedger(ledger,env); return {ok: true, path, changed}; } @@ -174,12 +174,16 @@ export function removeArtifact(target: ManagedTarget, env: NodeJS.ProcessEnv = p const state = ownership(target, ledger, env); const path = artifactPath(target, env); if (state === 'modified' || state === 'unknown') return {ok: false, error: `${path} was not removed: NMSh cannot confirm it is unchanged since NMSh wrote it.`}; - if (state === 'owned') unlinkSync(path); - // A recorded include stays recorded (it is harmless without the file: every include tolerates a missing file) until it is removed explicitly. - const hook = ledger.entries[target]?.hook; - if (hook) ledger.entries[target] = {...ledger.entries[target]!, sha256: sha256(''), hook}; + // Keep an existing hook recorded while its optional artifact is absent. + const hook=ledger.entries[target]?.hook; + if(hook)ledger.entries[target]={...ledger.entries[target]!,sha256:sha256(''),hook}; else delete ledger.entries[target]; - saveLedger(ledger, env); + if(state==='owned'){ + try {const home=env.HOME || homedir(); + transact([{before:snapshot(path,home,true)},ledgerChange(ledger,env)],[],home, + {source:'theme-bridge',directory:journalDirectory(home,env),label:`Remove ${target}`}); + }catch(error){return {ok:false,error:error instanceof Error?error.message:String(error)};} + }else saveLedger(ledger,env); return {ok: true, removed: state === 'owned'}; } @@ -276,10 +280,11 @@ export function applyHook(target: HookTarget, plan: FileEditPlan, spec: HookSpec const owner = hookOwner(target); const entry = ledger.entries[owner]; if (!entry) return {ok: false, error: 'Generate the managed file first.'}; - const result = applyPlan(plan); - if (!result.ok) return {ok: false, error: result.reason}; ledger.entries[owner] = {...entry, hook: {configPath: spec.configPath, lines: [...spec.lines], insertedAt: now.toISOString()}}; - saveLedger(ledger, env); + try { + const result=applyPlan(plan,{home:env.HOME || homedir(),source:'theme-bridge',directory:journalDirectory(env.HOME || homedir(),env),label:plan.reason,companions:[ledgerChange(ledger,env)]}); + if(!result.ok)return {ok:false,error:result.reason}; + }catch(error){return {ok:false,error:error instanceof Error?error.message:String(error)};} return {ok: true}; } @@ -298,12 +303,13 @@ export function planHookRemoval(target: HookTarget, home: string, env: NodeJS.Pr } export function applyHookRemoval(target: HookTarget, plan: FileEditPlan | undefined, env: NodeJS.ProcessEnv = process.env): {ok: true} | {ok: false; error: string} { - if (plan) { - const result = applyPlan(plan); - if (!result.ok) return {ok: false, error: result.reason}; - } - const ledger = loadLedger(env); - const entry = ledger.entries[hookOwner(target)]; - if (entry) { delete entry.hook; saveLedger(ledger, env); } + const ledger=loadLedger(env);const entry=ledger.entries[hookOwner(target)]; + if(entry)delete entry.hook; + try { + if(plan){ + const result=applyPlan(plan,{home:env.HOME || homedir(),source:'theme-bridge',directory:journalDirectory(env.HOME || homedir(),env),label:plan.reason,companions:[ledgerChange(ledger,env)]}); + if(!result.ok)return {ok:false,error:result.reason}; + }else if(entry)saveLedger(ledger,env); + }catch(error){return {ok:false,error:error instanceof Error?error.message:String(error)};} return {ok: true}; } diff --git a/src/tools/OhMyZshView.ts b/src/tools/OhMyZshView.ts index 871436f1..a2637a09 100644 --- a/src/tools/OhMyZshView.ts +++ b/src/tools/OhMyZshView.ts @@ -1,6 +1,8 @@ -import {constants, copyFileSync, lstatSync, readFileSync, renameSync, writeFileSync} from 'node:fs'; -import {randomUUID} from 'node:crypto'; -import {dirname, join} from 'node:path'; +import {Journal, journalDirectory} from '../backups/journal.js'; +import {snapshot, transact} from '../appearance/hostFiles.js'; +import {homedir} from 'node:os'; +import {lstatSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; import type {Key} from '../terminal/keys.js'; import {createConfirm, handleConfirmKey, renderConfirm, type ConfirmState} from '../ui/formControls.js'; import {fingerprint} from '../prompt/Powerlevel10kConfigurator.js'; @@ -28,7 +30,6 @@ export type OhMyZshView = | {kind: 'guided'; env: NodeJS.ProcessEnv; snapshot?: ZshrcSnapshot; verified?: string[]; unexpected?: boolean} | {kind: 'previous'; env: NodeJS.ProcessEnv; current: FileFacts; previous: FileFacts; diff: string[]; confirm?: ConfirmState; result?: string}; -const stamp = (now: Date) => now.toISOString().replace(/[:.]/gu, '-'); const hash = (content: Buffer) => fingerprint(content); export function openGuidedInstall(env: NodeJS.ProcessEnv = process.env): OhMyZshView { @@ -41,9 +42,13 @@ export function snapshotZshrc(env: NodeJS.ProcessEnv = process.env, now = new Da try { const info = lstatSync(path); if (!info.isFile()) return {path, error: `${path} is ${info.isSymbolicLink() ? 'a symlink' : 'not a regular file'}; NMSh records nothing and makes no backup. Back it up yourself before installing.`}; - const content = readFileSync(path); - const backup = `${path}.nmsh-backup-${stamp(now)}-${randomUUID()}`; - copyFileSync(path, backup, constants.COPYFILE_EXCL); + const home = env.HOME || homedir(); + const before = snapshot(path,home); + const content = Buffer.from(before.content ?? '', 'utf8'); + const journal = new Journal({home,directory:journalDirectory(home,env)}); + const entry = journal.record([{before,after:before.content}],'ohmyzsh','Before guided Oh My Zsh install'); + journal.complete(entry); + const backup = join(journal.directory,entry.id,'files','0'); return {path, sha256: hash(content), backup}; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {path}; @@ -91,15 +96,11 @@ export function restorePrevious(view: Extract, if (current.sha256 !== view.current.sha256 || previous.sha256 !== view.previous.sha256) return 'A file changed since review; nothing was written. Reopen to review again.'; if (current.symlink) return `${view.current.path} is a symlink; NMSh does not replace it. Nothing was written.`; if (!previous.exists) return `${view.previous.path} is gone; nothing was written.`; - let backup = ''; - if (current.exists) { - backup = `${view.current.path}.nmsh-backup-${stamp(now)}-${randomUUID()}`; - copyFileSync(view.current.path, backup, constants.COPYFILE_EXCL); - } - const staged = join(dirname(view.current.path), `.zshrc.nmsh-${process.pid}.tmp`); - writeFileSync(staged, readFileSync(view.previous.path), {mode: 0o644, flag: 'wx'}); - renameSync(staged, view.current.path); - return `Restored ${view.previous.path} to ${view.current.path}.${backup ? ` The replaced file is backed up at ${backup}.` : ''} ${view.previous.path} was left in place. New Zsh sessions use it; NMSh did not merge anything.`; + const home = view.env.HOME || homedir(); + const target=snapshot(view.current.path,home,true),source=snapshot(view.previous.path,home); + if((target.content===undefined?undefined:hash(Buffer.from(target.content)))!==view.current.sha256 || source.content===undefined || hash(Buffer.from(source.content))!==view.previous.sha256)return 'A file changed since review; nothing was written. Reopen to review again.'; + const entry = transact([{before:target, after:source.content}], [source], home, {source: 'restore', directory: journalDirectory(home,view.env), label: 'Restore previous .zshrc'}); + return `Restored ${view.previous.path} to ${view.current.path}. The replaced file is backed up at ${entry ? join(journalDirectory(home,view.env),entry.id,'files','0') : '/changes'}. Undo: /changes. ${view.previous.path} was left in place. New Zsh sessions use it; NMSh did not merge anything.`; } /** Returns 'back' when the view should close; 'open' with the two paths for the app to open. */ diff --git a/src/ui/StudioLayout.ts b/src/ui/StudioLayout.ts index d98f3f45..0cab56ec 100644 --- a/src/ui/StudioLayout.ts +++ b/src/ui/StudioLayout.ts @@ -103,20 +103,20 @@ export function renderStudioLayout(model: StudioLayout, columns: number, height } /** shown: false while the terminal is too small to show the plan; callers then refuse Enter (never confirm an unseen plan). */ -export interface StudioReview {plan: {preview: string[]; guidance: string}; offset: number; rowCount?: number; shown?: boolean} +export interface StudioReview {plan: {preview: string[]; guidance: string}; offset: number; rowCount?: number; shown?: boolean; controls?: readonly StudioControl[]; subtitle?: string} /** Exact-plan review, shared by host appearance and shader transactions. Only callers handle keys/writes. */ export function renderStudioReview(review: StudioReview, title: string, columns: number, height = 24): string[] { const width = Math.max(1, columns - 4); const document = [...review.plan.preview, '', 'After Enter', review.plan.guidance].flatMap(line => studioWrap(line, width)); review.rowCount = document.length; - const controls = renderControlRows([['↑↓', 'scroll diff'], ['Enter', 'confirm write'], ['Esc', 'cancel']], columns); + const controls = renderControlRows(review.controls ?? [['↑↓', 'scroll diff'], ['Enter', 'confirm write'], ['Esc', 'cancel']], columns); if (height < 8 || columns < 24) { review.shown = false; return framePanel(['Resize to review', ...renderControlRows([['Esc', 'cancel']], columns)], columns).slice(0, height); } review.shown = true; const guidance = studioWrap(review.plan.guidance, columns).map(studioMuted); - const head = [studioLabel(title), studioMuted('Review only · nothing written yet')]; + const head = [studioLabel(title), studioMuted(review.subtitle ?? 'Review only · nothing written yet')]; const available = Math.max(1, height - 1 - head.length - controls.length - Math.min(2, guidance.length) - 2); review.offset = Math.max(0, Math.min(review.offset, Math.max(0, document.length - available))); const visible = document.slice(review.offset, review.offset + available).map(line => ` ${line}`); diff --git a/tests/backupsJournal.test.ts b/tests/backupsJournal.test.ts new file mode 100644 index 00000000..218baedf --- /dev/null +++ b/tests/backupsJournal.test.ts @@ -0,0 +1,143 @@ +import fs from 'node:fs'; +import {syncBuiltinESMExports} from 'node:module'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, realpathSync, rmSync, writeFileSync, readFileSync, statSync, symlinkSync, mkdirSync, utimesSync, readdirSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {Journal} from '../src/backups/journal.js'; +import {snapshot, transact} from '../src/appearance/hostFiles.js'; + +function fixture(t: {after(fn: () => void): void}) { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'journal-'))); + t.after(() => rmSync(home, {recursive: true, force: true})); + const directory = join(home, 'state', 'backups'); + const journal = new Journal({home, directory}); + const path = join(home, 'config'); + writeFileSync(path, 'before\r\n', {mode: 0o640}); + return {home, directory, journal, path}; +} +test('journal stores private verified before bytes, mode, hashes and after state', t => { + const f = fixture(t); const before = snapshot(f.path, f.home); + const entry = f.journal.record([{before, after: 'after'}], 'appearance', 'Opacity 0.85'); + const loaded = f.journal.read(entry.id); + assert.equal(loaded.files[0].beforeHash.length, 64); + assert.equal(f.journal.content(loaded, 0), 'before\r\n'); + assert.equal(statSync(join(f.directory, entry.id)).mode & 0o777, 0o700); + assert.equal(statSync(join(f.directory, entry.id, 'files', '0')).mode & 0o777, 0o600); + assert.equal(loaded.files[0].mode, 0o640); + assert.equal(f.journal.status(loaded.files[0]), 'edited since'); + writeFileSync(f.path, 'after'); assert.equal(f.journal.status(loaded.files[0]), 'unchanged since'); + writeFileSync(join(f.directory, entry.id, 'files', '0'), 'corrupt'); + assert.throws(() => f.journal.read(entry.id), /verify|hash/iu); +}); +test('journal caps refuse before writing and prune only automatic entries', t => { + const f = fixture(t); const journal = new Journal({home:f.home, directory:f.directory, maxEntries:2, maxBytes:4096}); + const changes = [{before:snapshot(f.path,f.home),after:readFileSync(f.path,'utf8')}]; + const reset = journal.record(changes,'reset','Reset');journal.complete(reset); + const old = journal.record(changes,'appearance','Old');journal.complete(old); + const recent = journal.record(changes,'appearance','New'); + assert.deepEqual(journal.list().map(x=>x.id).sort(),[reset.id,recent.id].sort()); + assert.throws(()=>journal.read(old.id)); + assert.throws(()=> new Journal({home:f.home,directory:f.directory,maxBytes:1}).record(changes,'cursor','Large'), /capacity|limit/iu); +}); +test('symlinked storage and target files are refused', t => { + const f=fixture(t); mkdirSync(join(f.home,'elsewhere')); symlinkSync(join(f.home,'elsewhere'),join(f.home,'linked')); + assert.throws(()=>new Journal({home:f.home,directory:join(f.home,'linked','backups')}).record([{before:snapshot(f.path,f.home)}],'reset','Reset'), /symbolic/iu); + symlinkSync(f.path,join(f.home,'link')); assert.throws(()=>snapshot(join(f.home,'link'),f.home),/symbolic/iu); +}); +test('transact refuses mutation when backup fails and records before successful writes', t => { + const f=fixture(t); const before=snapshot(f.path,f.home); + writeFileSync(join(f.home,'blocked'),'file'); + assert.throws(()=>transact([{before,after:'after'}],[],f.home,{source:'cursor',label:'Cursor',directory:join(f.home,'blocked','backups')})); + assert.equal(readFileSync(f.path,'utf8'),'before\r\n'); + transact([{before,after:'after'}],[],f.home,{source:'cursor',label:'Cursor',directory:f.directory}); + assert.equal(f.journal.list().length,1); assert.equal(f.journal.content(f.journal.list()[0],0),'before\r\n'); +}); +test('incomplete staging does not prevent subsequent verified checkpoints',t=>{ + const f=fixture(t);mkdirSync(f.directory,{recursive:true,mode:0o700}); + mkdirSync(join(f.directory,'.pending-interrupted'),{mode:0o700}); + const entry=f.journal.record([{before:snapshot(f.path,f.home),after:'after'}],'cursor','Retry'); + assert.equal(f.journal.list().length,1);assert.equal(f.journal.read(entry.id).label,'Retry'); +}); +test('oversized before/after images and a symlinked saved image fail closed',t=>{ + const f=fixture(t);writeFileSync(f.path,'x'.repeat(2*1024*1024+1));assert.throws(()=>snapshot(f.path,f.home),/bounded/iu); + writeFileSync(f.path,'small');const before=snapshot(f.path,f.home); + assert.throws(()=>f.journal.record([{before,after:'x'.repeat(2*1024*1024+1)}],'cursor','Too large'),/limit/iu); + const entry=f.journal.record([{before,after:'next'}],'cursor','Small'); + const stored=join(f.directory,entry.id,'files','0');rmSync(stored);symlinkSync(f.path,stored); + assert.throws(()=>f.journal.read(entry.id),/symbolic/iu);assert.equal(readFileSync(f.path,'utf8'),'small'); +}); +test('another live journal writer blocks a checkpoint without replacing its lock',t=>{ + const f=fixture(t);mkdirSync(f.directory,{recursive:true,mode:0o700}); + const path=join(f.directory,'.write-lock');const content=JSON.stringify({owner:'nmsh-journal',version:1,pid:process.pid,token:'live'});writeFileSync(path,content,{mode:0o600}); + assert.throws(()=>f.journal.record([{before:snapshot(f.path,f.home),after:'after'}],'cursor','Concurrent'),/in progress/iu); + assert.equal(readFileSync(path,'utf8'),content);assert.equal(readFileSync(f.path,'utf8'),'before\r\n'); +}); + +test('old protected checkpoints are bounded while newest twenty and path anchors survive', t => { + const f=fixture(t);const journal=new Journal({home:f.home,directory:f.directory,maxEntries:23}); + const changes=[{before:snapshot(f.path,f.home),after:readFileSync(f.path,'utf8')}]; + const anchor=journal.record(changes,'cursor','Ownership anchor');journal.complete(anchor); + const ids:string[]=[]; + for(let n=0;n<30;n++){const entry=journal.record(changes,['manual','reset','restore'][n%3],`Snapshot ${n}`);journal.complete(entry);ids.push(entry.id);} + const kept=new Set(journal.list().map(entry=>entry.id)); + assert.equal(kept.size,23);assert.ok(kept.has(anchor.id)); + for(const id of ids.slice(-20))assert.ok(kept.has(id)); +}); +test('expired pending staging is reclaimed only with a dead recorded owner', t => { + const f=fixture(t);mkdirSync(f.directory,{recursive:true,mode:0o700}); + const stage=(name:string,pid:number)=>{const path=join(f.directory,name);mkdirSync(path,{mode:0o700});writeFileSync(join(path,'owner.json'),JSON.stringify({owner:'nmsh-journal',version:1,pid}),{mode:0o600});utimesSync(path,1,1);return path;}; + const dead=stage('.pending-dead',2147483647);const live=stage('.pending-live',process.pid); + f.journal.record([{before:snapshot(f.path,f.home),after:'after'}],'cursor','Retry'); + assert.ok(!readdirSync(f.directory).includes('.pending-dead'));assert.ok(readdirSync(f.directory).includes('.pending-live')); +}); +test('history skips corrupt entries but a dependent checkpoint still refuses', t => { + const f=fixture(t);const entry=f.journal.record([{before:snapshot(f.path,f.home),after:'next'}],'cursor','Corrupt'); + writeFileSync(join(f.directory,entry.id,'files/0'),'damaged'); + assert.deepEqual(f.journal.list(),[]); + assert.throws(()=>f.journal.record([{before:snapshot(f.path,f.home),after:'next'}],'cursor','Blocked'),/hash|verify/iu); +}); +test('a dead writer lock is recovered without leaving a reusable stale lock name', t => { + const f=fixture(t);mkdirSync(f.directory,{recursive:true,mode:0o700}); + writeFileSync(join(f.directory,'.write-lock'),JSON.stringify({owner:'nmsh-journal',version:1,pid:2147483647}),{mode:0o600}); + f.journal.record([{before:snapshot(f.path,f.home),after:'next'}],'cursor','Recovered'); + assert.ok(!readdirSync(f.directory).some(name=>name.includes('lock'))); +}); + +test('byte cap accounts for pruning metadata before deciding how many entries to remove',t=>{ + const f=fixture(t);const changes=[{before:snapshot(f.path,f.home),after:readFileSync(f.path,'utf8')}]; + for(let n=0;n<3;n++){const entry=f.journal.record(changes,'appearance',`Old ${n}`);f.journal.complete(entry);} + const total=readdirSync(f.directory,{recursive:true}).reduce((sum,name)=>{const stat=statSync(join(f.directory,String(name)));return sum+(stat.isFile()?stat.size:0);},0); + const journal=new Journal({home:f.home,directory:f.directory,maxBytes:total+30}); + journal.record(changes,'appearance','Next'); + assert.ok(journal.list().length<4);assert.ok(journal.prunedCount()>0); +}); + +test('stale recovery never unlinks a competing writer that wins exclusive acquisition',t=>{ + const f=fixture(t);mkdirSync(f.directory,{recursive:true,mode:0o700}); + const lock=join(f.directory,'.write-lock'); + writeFileSync(lock,JSON.stringify({owner:'nmsh-journal',version:1,pid:2147483647}),{mode:0o600}); + const live=JSON.stringify({owner:'nmsh-journal',version:1,pid:process.pid,token:'winner'}); + const rename=fs.renameSync;let quarantined=false; + const mocked=t.mock.method(fs,'renameSync',((from,to)=>{ + rename(from,to); + if(String(from)===lock){quarantined=true;assert.match(String(to),/\.stale-/u);writeFileSync(lock,live,{mode:0o600,flag:'wx'});} + }) as typeof fs.renameSync); + syncBuiltinESMExports(); + try { + assert.throws(()=>f.journal.record([{before:snapshot(f.path,f.home),after:'next'}],'cursor','Race'),/EEXIST/u); + assert.ok(quarantined);assert.equal(readFileSync(lock,'utf8'),live); + assert.equal(readFileSync(f.path,'utf8'),'before\r\n'); + }finally{mocked.mock.restore();syncBuiltinESMExports();} +}); + +test('the newest source/path anchor is retained even if its write was interrupted',t=>{ + const f=fixture(t);const changes=[{before:snapshot(f.path,f.home),after:readFileSync(f.path,'utf8')}]; + const old=f.journal.record(changes,'cursor','Old applied');f.journal.complete(old); + const newest=f.journal.record(changes,'cursor','Interrupted'); + const journal=new Journal({home:f.home,directory:f.directory,maxEntries:2}); + journal.record(changes,'appearance','Different source'); + assert.ok(journal.list().some(entry=>entry.id===newest.id));assert.throws(()=>journal.read(old.id)); + assert.equal(journal.provenanceComplete('cursor',f.path),false); +}); diff --git a/tests/backupsPanels.test.ts b/tests/backupsPanels.test.ts new file mode 100644 index 00000000..bb487790 --- /dev/null +++ b/tests/backupsPanels.test.ts @@ -0,0 +1,156 @@ +import {fragmentPath} from '../src/cursor/native.js'; +import {kittyShaderPaths} from '../src/appearance/kittyShaders.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, realpathSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync, mkdirSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {planReset,applyReset,discoverResetAreas,ownedSnapshotPaths,type ResetArea} from '../src/backups/reset.js'; +import {Journal} from '../src/backups/journal.js'; +import {snapshot,transact} from '../src/appearance/hostFiles.js'; +import {createChangesPanel,changesKey,renderChangesPanel} from '../src/backups/ChangesPanel.js'; +import {createResetPanel,resetKey,renderResetPanel} from '../src/backups/ResetPanel.js'; +import {parseSlashCommand, slashCommands} from '../src/commands/slashCommands.js'; +import {displayWidth,stripAnsi} from '../src/util/text.js'; +import {applyHostAppearance,planHostAppearance} from '../src/appearance/hostAppearance.js'; +function fixture(t: {after(fn:()=>void):void}) { + const home=realpathSync(mkdtempSync(join(tmpdir(),'backup-panels-')));t.after(()=>rmSync(home,{recursive:true,force:true})); + const env={HOME:home,XDG_CONFIG_HOME:join(home,'.config')}; const journal=new Journal({home,directory:join(home,'.config/nmsh/backups')}); + const path=join(home,'config');writeFileSync(path,'before'); + transact([{before:snapshot(path,home),after:'after'}],[],home,{source:'keyboard',label:'Keyboard bindings',directory:journal.directory}); + return {home,env,journal,path}; +} +const enter={kind:'enter'} as const; +test('commands preserve shell history; plain reset only returns guidance',()=>{ + for(const name of ['/changes','/backups','/snapshots'])assert.equal(parseSlashCommand(name)?.kind,'changes'); + assert.equal(parseSlashCommand('/history')?.kind,'history'); + assert.equal(parseSlashCommand('/reset')?.kind,'resetGuidance'); + assert.equal(parseSlashCommand('/nmsh reset')?.kind,'nmshReset'); + assert.ok(slashCommands.some(x=>x.name==='/nmsh reset')); +}); +test('history requires a shown review and a separate confirmation for later edits',t=>{ + const f=fixture(t);writeFileSync(f.path,'personal');const state=createChangesPanel(f.journal,[]); + changesKey(state,enter);assert.equal(state.view,'review'); + changesKey(state,enter);assert.equal(readFileSync(f.path,'utf8'),'personal','unseen review cannot confirm'); + renderChangesPanel(state,80,24);changesKey(state,enter);assert.equal(state.view,'confirm'); + assert.equal(readFileSync(f.path,'utf8'),'personal');renderChangesPanel(state,80,24);changesKey(state,enter); + assert.equal(readFileSync(f.path,'utf8'),'before');assert.equal(state.view,'result'); +}); +test('reset cannot write without selected areas, impact, backup and exact typed word',t=>{ + const f=fixture(t);let state=createResetPanel({env:f.env,platform:'linux'},f.journal); + assert.equal(state.selected.size,0);resetKey(state,enter);assert.equal(state.step,'choose'); + const settings=state.areas.findIndex(x=>x.id==='settings');state.index=settings;resetKey(state,{kind:'text',value:' '}); + resetKey(state,enter);assert.equal(state.step,'impact'); + resetKey(state,enter);assert.equal(state.step,'impact','unseen impact cannot confirm'); + renderResetPanel(state,80,24);resetKey(state,enter);assert.equal(state.step,'backup'); + renderResetPanel(state,80,24);resetKey(state,enter);assert.equal(state.step,'confirm'); + renderResetPanel(state,80,24);resetKey(state,{kind:'text',value:'RESET'});resetKey(state,enter); + assert.equal(existsSync(join(f.home,'.config/nmsh/config.json')),false); + assert.equal(state.step,'result'); + state=createResetPanel({env:f.env,platform:'linux'},f.journal);state.index=settings;resetKey(state,{kind:'text',value:' '});resetKey(state,enter); + renderResetPanel(state,80,24);resetKey(state,enter);renderResetPanel(state,80,24);resetKey(state,enter);renderResetPanel(state,80,24); + resetKey(state,{kind:'text',value:'reset'});resetKey(state,enter); + assert.equal(state.step,'result');assert.match(state.message,/Undo: \/changes/u); + assert.equal(existsSync(join(f.home,'.config/nmsh/config.json')),true); +}); +test('reset preserves user lines with overlapping owned plans, reports skips and stale review',t=>{ + const f=fixture(t);const context={env:f.env,platform:'linux' as const}; + const plan=planHostAppearance('kitty',{opacity:.7,blurMode:'Off',blurStrength:0},context);assert.ok(plan.ok); + if(!plan.ok)return;assert.equal(applyHostAppearance(plan.plan).ok,true); + const current=readFileSync(plan.plan.hostPath,'utf8');writeFileSync(plan.plan.hostPath,current+'# personal\r\n'); + const state=createResetPanel(context,f.journal);state.selected.add('kitty-appearance');state.selected.add('ghostty-appearance'); + resetKey(state,enter);renderResetPanel(state,120,40);assert.match(state.review!.plan.preview.join('\n'),/kept|skipped/u); + resetKey(state,enter);renderResetPanel(state,120,40);resetKey(state,enter);renderResetPanel(state,120,40);resetKey(state,{kind:'text',value:'reset'});resetKey(state,enter); + assert.equal(readFileSync(plan.plan.hostPath,'utf8'),'# personal\r\n');assert.match(state.results.join('\n'),/done|skipped/u); + const stale=createResetPanel(context,f.journal);stale.selected.add('settings');resetKey(stale,enter); + renderResetPanel(stale,80,24);resetKey(stale,enter);renderResetPanel(stale,80,24);resetKey(stale,enter);renderResetPanel(stale,80,24); + writeFileSync(join(f.home,'.config/nmsh/config.json'),'concurrent');resetKey(stale,{kind:'text',value:'reset'});resetKey(stale,enter); + assert.equal(readFileSync(join(f.home,'.config/nmsh/config.json'),'utf8'),'concurrent');assert.match(stale.message,/changed/u); +}); +for(const [columns,height] of [[120,40],[80,24],[60,20],[40,14]])test(`panels fit ${columns}x${height} with selection and footer`,t=>{ + const f=fixture(t);const history=createChangesPanel(f.journal,[]);const reset=createResetPanel({env:f.env,platform:'linux'},f.journal); + for(const rows of [renderChangesPanel(history,columns,height),renderResetPanel(reset,columns,height)]){ + assert.ok(rows.length<=height);assert.ok(rows.every(x=>displayWidth(x)<=columns)); + const plain=rows.map(stripAnsi).join('\n');assert.match(plain,/Esc/u);assert.match(plain,/Enter/u);assert.match(plain,/[›>❯▶]/u); + } +}); +test('reset reverses only Starship edits and keeps user edits between NMSh writes',t=>{ + const f=fixture(t);const path=join(f.home,'starship.toml');const initial='# mine\n[directory]\ndisabled = false\n';writeFileSync(path,initial); + const write=(after:string)=>transact([{before:snapshot(path,f.home),after}],[],f.home,{source:'starship',label:'Starship module',directory:f.journal.directory}); + write(initial.replace('false','true')); + writeFileSync(path,readFileSync(path,'utf8')+'[custom]\nuser = true\n'); + write(readFileSync(path,'utf8')+'[git_branch]\ndisabled = true\n'); + const state=createResetPanel({env:f.env,platform:'linux'},f.journal);state.selected.add('starship');resetKey(state,enter); + renderResetPanel(state,80,24);resetKey(state,enter);renderResetPanel(state,80,24);resetKey(state,enter);renderResetPanel(state,80,24);resetKey(state,{kind:'text',value:'reset'});resetKey(state,enter); + assert.equal(readFileSync(path,'utf8'),initial+'[custom]\nuser = true\n'); +}); + +test('wired reset guidance writes nothing and backup panels retain modal input ownership',async t=>{ + const f=fixture(t);const saved={HOME:process.env.HOME,XDG:process.env.XDG_CONFIG_HOME};process.env.HOME=f.home;process.env.XDG_CONFIG_HOME=f.env.XDG_CONFIG_HOME; + const app=new TerminalApp();Object.defineProperty(app,'render',{value:()=>{}}); + const tree=()=>readdirSync(f.home,{recursive:true}).map(String).sort(); + try { + const before=tree();await app['runSlash']('/reset',parseSlashCommand('/reset')!);assert.deepEqual(tree(),before); + app['editor'].insert('draft');await app['runSlash']('/changes',parseSlashCommand('/changes')!); + assert.equal(app['hasModalInput'](),true);app['handleKey']({kind:'text',value:'z'});assert.equal(app['editor'].text,'draft'); + app['handleKey']({kind:'escape'});assert.equal(app['changesPanel'],undefined); + await app['runSlash']('/nmsh reset',parseSlashCommand('/nmsh reset')!);app['handleKey']({kind:'escape'});assert.equal(app['resetPanel'],undefined); + }finally{app['stop'](0);app['session'].kill();if(saved.HOME===undefined)delete process.env.HOME;else process.env.HOME=saved.HOME;if(saved.XDG===undefined)delete process.env.XDG_CONFIG_HOME;else process.env.XDG_CONFIG_HOME=saved.XDG;} +}); + +test('two owned areas remove disjoint includes in one host file and keep user bytes',t=>{ + const f=fixture(t);const content='# user\r\ninclude nmsh-appearance\r\ninclude nmsh-shaders\r\nfont_size 17';writeFileSync(f.path,content); + const before=snapshot(f.path,f.home); + const areas:ResetArea[]=['appearance','shaders'].map((id,index)=>({id,label:id,status:'set up',dependencies:[],changes:[{before,after:content.replace(index===0?'include nmsh-appearance\r\n':'include nmsh-shaders\r\n','')}]})); + const plan=planReset(areas,new Set(['appearance','shaders']),{env:f.env,platform:'linux'}); + const result=applyReset(plan,f.journal);assert.equal(readFileSync(f.path,'utf8'),'# user\r\nfont_size 17');assert.equal(result.results.filter(x=>x.endsWith('done')).length,2); +}); +for(const [columns,height] of [[120,40],[80,24],[60,20],[40,14]])test(`all review stages fit ${columns}x${height}`,t=>{ + const f=fixture(t);const history=createChangesPanel(f.journal,[]);const reset=createResetPanel({env:f.env,platform:'linux'},f.journal); + const frames:Array<[string,string[]]>=[]; + const capture=(name:string,rows:string[])=>{frames.push([name,rows]);assert.ok(rows.length<=height,name);assert.ok(rows.every(row=>displayWidth(row)<=columns),name);assert.match(rows.map(stripAnsi).join('\n'),/Esc/u,name);}; + capture('history',renderChangesPanel(history,columns,height));changesKey(history,enter);capture('restore-review',renderChangesPanel(history,columns,height));changesKey(history,enter);capture('restore-result',renderChangesPanel(history,columns,height)); + reset.selected.add('settings');capture('reset-choose',renderResetPanel(reset,columns,height));resetKey(reset,enter);capture('reset-impact',renderResetPanel(reset,columns,height));resetKey(reset,enter);capture('reset-backup',renderResetPanel(reset,columns,height));resetKey(reset,enter);capture('reset-confirm',renderResetPanel(reset,columns,height));resetKey(reset,{kind:'text',value:'reset'});resetKey(reset,enter);capture('reset-result',renderResetPanel(reset,columns,height)); + if(process.env.NMSH_BACKUP_SNAPSHOTS){mkdirSync(process.env.NMSH_BACKUP_SNAPSHOTS,{recursive:true});for(const [name,rows]of frames)writeFileSync(join(process.env.NMSH_BACKUP_SNAPSHOTS,`${name}-${columns}x${height}.txt`),rows.map(stripAnsi).join('\n')+'\n');} +}); + +test('empty reset writes no journal entry and reports nothing to reset',t=>{ + const f=fixture(t);const context={env:f.env,platform:'linux' as const};const before=f.journal.list().length; + const area:ResetArea={id:'empty',label:'Empty',status:'nothing to reset',changes:[],dependencies:[]}; + const plan=planReset([area],new Set(['empty']),context);assert.deepEqual(plan.changes,[]); + const result=applyReset(plan,f.journal);assert.match(result.results.join(' '),/Nothing to reset/u);assert.equal(f.journal.list().length,before); +}); +for(const source of ['cursor','keyboard','starship'])test(`second ${source} reset is a no-op`,t=>{ + const f=fixture(t);const context={env:f.env,platform:'linux' as const}; + const path=source==='cursor'?fragmentPath('kitty',f.env):join(f.home,`${source}.conf`); + const id=source==='cursor'?'kitty-cursor':source; + mkdirSync(join(path,'..'),{recursive:true}); + writeFileSync(path,'original');transact([{before:snapshot(path,f.home),after:'nmsh'}],[],f.home,{source,label:'Set',directory:f.journal.directory}); + let areas=discoverResetAreas(context,f.journal);applyReset(planReset(areas,new Set([id]),context),f.journal); + areas=discoverResetAreas(context,f.journal);assert.equal(areas.find(area=>area.id===id)?.status,'nothing to reset'); +}); +test('outer reset checkpoint excludes unchanged settings',t=>{ + const f=fixture(t);const before=snapshot(f.path,f.home);const item:ResetArea={id:'test',label:'Test',status:'set up',dependencies:[],changes:[{before,after:'before'}]}; + const result=applyReset(planReset([item],new Set(['test']),{env:f.env,platform:'linux'}),f.journal); + assert.deepEqual(result.entry!.files.map(file=>file.path),[f.path]); +}); +test('unreadable history remains visible as a notice and other entries are browsable',t=>{ + const f=fixture(t);const entry=f.journal.list()[0];writeFileSync(join(f.journal.directory,entry.id,'files/0'),'corrupt'); + const state=createChangesPanel(f.journal,[]);assert.match(renderChangesPanel(state,100,30).map(stripAnsi).join(' '),/unreadable/iu); + assert.ok(discoverResetAreas({env:f.env,platform:'linux'},f.journal).length); +}); +test('manual snapshot discovery includes Kitty slang files even with a refused removal',t=>{ + const f=fixture(t);const context={env:f.env,platform:'linux' as const}; + const shader=join(f.home,'owned.slang');writeFileSync(shader,'shader'); + const paths=kittyShaderPaths(context);mkdirSync(paths.directory,{recursive:true}); + writeFileSync(paths.ledger,JSON.stringify({shaders:[{path:shader}]})); + assert.ok(ownedSnapshotPaths(context,f.journal).includes(shader)); +}); + +test('changes reports the number of old checkpoints pruned',t=>{ + const f=fixture(t);const journal=new Journal({home:f.home,directory:f.journal.directory,maxEntries:2}); + const changes=[{before:snapshot(f.path,f.home),after:'after'}]; + for(let n=0;n<3;n++){const entry=journal.record(changes,'keyboard',`Keyboard ${n}`);journal.complete(entry);} + assert.match(createChangesPanel(journal,[]).message,/2 old backup entries pruned/u); +}); diff --git a/tests/backupsRestore.test.ts b/tests/backupsRestore.test.ts new file mode 100644 index 00000000..df313cdd --- /dev/null +++ b/tests/backupsRestore.test.ts @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, realpathSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, chmodSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {Journal} from '../src/backups/journal.js'; +import {snapshot,transact} from '../src/appearance/hostFiles.js'; +import {planRestore, restore, manualSnapshot} from '../src/backups/restore.js'; +import {dailySettingsCheckpoint} from '../src/backups/settings.js'; +function fixture(t: {after(fn:()=>void):void}) { + const home=realpathSync(mkdtempSync(join(tmpdir(),'restore-'))); t.after(()=>rmSync(home,{recursive:true,force:true})); + const journal=new Journal({home,directory:join(home,'backups')}); const path=join(home,'config'); + writeFileSync(path,'original\r\n',{mode:0o640}); + transact([{before:snapshot(path,home),after:'nmsh'}],[],home,{source:'appearance',label:'Appearance',directory:journal.directory}); + return {home,journal,path,entry:journal.list()[0]}; +} +test('restore needs review and edited-since confirmation, and is itself undoable',t=>{ + const f=fixture(t); writeFileSync(f.path,'later personal edit'); const plan=planRestore(f.journal,f.entry.id); + assert.equal(plan.edited.length,1); assert.match(plan.preview.join('\n'), /later personal edit/); + assert.throws(()=>restore(f.journal,plan,{reviewed:false,overwrite:false}),/review/iu); + assert.throws(()=>restore(f.journal,plan,{reviewed:true,overwrite:false}),/later edits/iu); + const undo=restore(f.journal,plan,{reviewed:true,overwrite:true}); + assert.equal(readFileSync(f.path,'utf8'),'original\r\n'); assert.equal(statSync(f.path).mode&0o777,0o640); + restore(f.journal,planRestore(f.journal,undo.id),{reviewed:true,overwrite:true}); + assert.equal(readFileSync(f.path,'utf8'),'later personal edit'); +}); +test('restore refuses stale review before creating backup or writing',t=>{ + const f=fixture(t); const plan=planRestore(f.journal,f.entry.id); writeFileSync(f.path,'concurrent'); + assert.throws(()=>restore(f.journal,plan,{reviewed:true,overwrite:true}),/changed/iu); + assert.equal(f.journal.list().length,1); assert.equal(readFileSync(f.path,'utf8'),'concurrent'); +}); +test('restore of created file removes only the reviewed file; manual snapshot covers known paths',t=>{ + const f=fixture(t);const created=join(f.home,'new'); + transact([{before:snapshot(created,f.home),after:'new'}],[],f.home,{source:'cursor',label:'Create',directory:f.journal.directory}); + const entry=f.journal.list()[0];restore(f.journal,planRestore(f.journal,entry.id),{reviewed:true,overwrite:false});assert.equal(existsSync(created),false); + const manual=manualSnapshot(f.journal,[join(f.home,'config.json')]); + assert.deepEqual(manual.files.map(x=>x.path).sort(),[f.path,created,join(f.home,'config.json')].sort()); +}); +test('settings checkpoint is at most once per day and refuses symlinks through journal safety',t=>{ + const f=fixture(t);const path=join(f.home,'config.json');writeFileSync(path,'old'); + const complete=dailySettingsCheckpoint(path,'first');writeFileSync(path,'first');chmodSync(path,0o600);complete(); + const again=dailySettingsCheckpoint(path,'second');writeFileSync(path,'second');again(); + assert.equal(new Journal({home:f.home,directory:join(f.home,'backups')}).list().filter(x=>x.source==='settings').length,1); +}); +test('daily settings checkpoint can coexist with backups of files elsewhere in HOME',t=>{ + const f=fixture(t);const directory=join(f.home,'.config/nmsh'); + const env={HOME:f.home,XDG_CONFIG_HOME:join(f.home,'.config')}; + const journal=new Journal({home:f.home,directory:join(directory,'backups')}); + journal.record([{before:snapshot(f.path,f.home),after:'next'}],'appearance','Host'); + const path=join(directory,'config.json');writeFileSync(path,'old'); + const complete=dailySettingsCheckpoint(path,'new',new Date(),env);writeFileSync(path,'new');chmodSync(path,0o600);complete(); + assert.equal(journal.list().filter(entry=>entry.source==='settings').length,1); +}); +test('undoing a managed artifact update restores its ownership ledger too',async t=>{ + const f=fixture(t);const env={HOME:f.home,XDG_CONFIG_HOME:join(f.home,'.config')}; + const {writeArtifact,ownership,loadLedger}=await import('../src/themeBridge/artifacts.js'); + const journal=new Journal({home:f.home,directory:join(f.home,'.config/nmsh/backups')}); + const metadata={mode:'follow' as const,themeRef:'test',format:'vim',formatVersion:1}; + assert.equal(writeArtifact('vim','first',()=>true,metadata,env).ok,true); + assert.equal(writeArtifact('vim','second',()=>true,metadata,env).ok,true); + restore(journal,planRestore(journal,journal.list()[0].id),{reviewed:true,overwrite:false}); + assert.equal(ownership('vim',loadLedger(env),env),'owned'); +}); + +test('a prepared settings checkpoint does not suppress the daily applied checkpoint',t=>{ + const f=fixture(t);const path=join(f.home,'config.json');writeFileSync(path,'old'); + const journal=new Journal({home:f.home,directory:join(f.home,'backups')}); + journal.record([{before:snapshot(path,f.home),after:'failed'}],'settings','Interrupted'); + const complete=dailySettingsCheckpoint(path,'saved',new Date(),{HOME:f.home}); + writeFileSync(path,'saved');chmodSync(path,0o600);complete(); + assert.equal(journal.list().filter(entry=>entry.source==='settings'&&entry.outcome==='applied').length,1); +}); diff --git a/tests/configurationPersistence.test.ts b/tests/configurationPersistence.test.ts index a8bd1d22..53deca05 100644 --- a/tests/configurationPersistence.test.ts +++ b/tests/configurationPersistence.test.ts @@ -1,3 +1,5 @@ +import {Journal} from '../src/backups/journal.js'; +import {createChangesPanel} from '../src/backups/ChangesPanel.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import {chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; @@ -86,3 +88,22 @@ test('mkdir of missing parent still works', () => { mkdirSync(join(box.dir, 'y')); } finally { box.done(); } }); + +test('settings save survives a rejected checkpoint under a group writable HOME', () => { + const box=sandbox();box.path=join(box.dir,'.config/nmsh/config.json');const previous=process.env.HOME;process.env.HOME=box.dir; + try {chmodSync(box.dir,0o775);savePromptConfiguration({...loadPromptConfiguration(box.path),glyphStyle:'safe'},box.path);assert.equal(loadPromptConfiguration(box.path).glyphStyle,'safe'); + const journal=new Journal({home:box.dir,directory:join(box.dir,'.config/nmsh/backups')}); + assert.match(createChangesPanel(journal,[]).message,/Settings backup unavailable/u); + savePromptConfiguration({...loadPromptConfiguration(box.path),glyphStyle:'safe'},box.path); + assert.doesNotMatch(createChangesPanel(journal,[]).message,/Settings backup unavailable/u); + } + finally {if(previous===undefined)delete process.env.HOME;else process.env.HOME=previous;box.done();} +}); + +test('settings still save when checkpoint completion fails',t=>{ + const box=sandbox();t.after(box.done); + t.mock.method(Journal.prototype,'complete',()=>{throw new Error('completion unavailable');}); + savePromptConfiguration({...loadPromptConfiguration(box.path),glyphStyle:'safe'},box.path); + assert.equal(loadPromptConfiguration(box.path).glyphStyle,'safe'); + assert.match(createChangesPanel(new Journal({home:box.dir,directory:join(box.dir,'backups')}),[]).message,/completion unavailable/u); +}); diff --git a/tests/cursorCapabilities.test.ts b/tests/cursorCapabilities.test.ts index 7a4c358b..b1254873 100644 --- a/tests/cursorCapabilities.test.ts +++ b/tests/cursorCapabilities.test.ts @@ -386,7 +386,7 @@ test('cursor configuration migration: saved choices are preserved; new fields ap test('Ghostty refresh: only changed managed files are written, and the reload line is precise', () => { const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-cursor-reload-'))); - const environment = env({XDG_CONFIG_HOME: join(home, '.config'), NMSH_CONFIG_HOME: join(home, 'nmsh')}); + const environment = env({HOME: home, XDG_CONFIG_HOME: join(home, '.config'), NMSH_CONFIG_HOME: join(home, 'nmsh')}); try { mkdirSync(join(home, '.config', 'ghostty'), {recursive: true}); const userConfig = 'font-size = 14\ncustom-shader = /me/crt.glsl\n'; @@ -415,7 +415,8 @@ test('Ghostty refresh: only changed managed files are written, and the reload li test('integrated Ghostty: settings changes refresh only NMSh\'s managed files and report a precise reload line', () => { const isolation = isolateConfig(); const home = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-cursor-app-'))); - const saved = {XDG: process.env.XDG_CONFIG_HOME, NMSH: process.env.NMSH_CONFIG_HOME}; + const saved = {XDG: process.env.XDG_CONFIG_HOME, NMSH: process.env.NMSH_CONFIG_HOME, HOME: process.env.HOME}; + process.env.HOME = home; process.env.XDG_CONFIG_HOME = join(home, '.config'); process.env.NMSH_CONFIG_HOME = join(home, 'nmsh'); const app = new TerminalApp(); @@ -446,6 +447,7 @@ test('integrated Ghostty: settings changes refresh only NMSh\'s managed files an assert.deepEqual(writeManagedFiles('ghostty', resolveCursorSettings(app['promptConfiguration'].cursor, contextFor(app['promptConfiguration']))).changed, []); } finally { app['stop'](0); app['session'].kill(); + if (saved.HOME === undefined) delete process.env.HOME; else process.env.HOME = saved.HOME; if (saved.XDG === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = saved.XDG; if (saved.NMSH === undefined) delete process.env.NMSH_CONFIG_HOME; else process.env.NMSH_CONFIG_HOME = saved.NMSH; rmSync(home, {recursive: true, force: true}); diff --git a/tests/cursorEffects.test.ts b/tests/cursorEffects.test.ts index 466f8b25..a10b4110 100644 --- a/tests/cursorEffects.test.ts +++ b/tests/cursorEffects.test.ts @@ -124,7 +124,7 @@ test('backends: Auto is native only when set up; forced native on another host f test('native setup: one include line through a verified plan; existing shaders kept; managed files only afterwards', () => { const home = mkdtempSync(join(tmpdir(), 'nmsh-cursor-native-')); - const env = {XDG_CONFIG_HOME: join(home, '.config'), NMSH_CONFIG_HOME: join(home, 'nmsh')} as NodeJS.ProcessEnv; + const env = {HOME:home, XDG_CONFIG_HOME: join(home, '.config'), NMSH_CONFIG_HOME: join(home, 'nmsh')} as NodeJS.ProcessEnv; try { mkdirSync(join(home, '.config', 'ghostty'), {recursive: true}); const config = join(home, '.config', 'ghostty', 'config'); diff --git a/tests/ghosttyKeyboard.test.ts b/tests/ghosttyKeyboard.test.ts index b66a7c9d..60f85da7 100644 --- a/tests/ghosttyKeyboard.test.ts +++ b/tests/ghosttyKeyboard.test.ts @@ -10,6 +10,8 @@ test('Ghostty keybinding installation', async () => { // Override environment to point to our temp dir const oldXdg = process.env.XDG_CONFIG_HOME; + const oldHome = process.env.HOME; + process.env.HOME = tmpDir; process.env.XDG_CONFIG_HOME = tmpDir; try { @@ -37,6 +39,7 @@ test('Ghostty keybinding installation', async () => { assert.equal(installedConfig, installedConfig2, 'Config should not change on second install'); } finally { + if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome; process.env.XDG_CONFIG_HOME = oldXdg; await fs.rm(tmpDir, {recursive: true, force: true}); } diff --git a/tests/shellFrameworks.test.ts b/tests/shellFrameworks.test.ts index e6aebb1d..613da92f 100644 --- a/tests/shellFrameworks.test.ts +++ b/tests/shellFrameworks.test.ts @@ -1,3 +1,4 @@ +import {Journal,journalDirectory} from '../src/backups/journal.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import {chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; @@ -286,11 +287,13 @@ test('previous zshrc: compare, default No changes nothing, restore backs up and ohMyZshKey(view, {kind: 'text', value: 'r'}); ohMyZshKey(view, {kind: 'right'}); ohMyZshKey(view, enter); - assert.match(view.result!, /Restored .* backed up at .*\.zshrc\.nmsh-backup-/u); + assert.match(view.result!, /Restored .* backed up at .*backups/u); assert.equal(readFileSync(current, 'utf8'), 'alias ll="ls -l"\n', 'the previous file exactly; nothing merged'); assert.ok(existsSync(previous), 'the previous file stays'); - const backup = readdirSync(box.home).find(name => name.startsWith('.zshrc.nmsh-backup-'))!; - assert.equal(readFileSync(join(box.home, backup), 'utf8'), 'export ZSH=~/.oh-my-zsh\nsource $ZSH/oh-my-zsh.sh\n'); + const journal=new Journal({home:box.home,directory:journalDirectory(box.home,box.env)}); + const entry=journal.list()[0]; + const backup=join(journal.directory,entry.id,'files','0'); + assert.equal(readFileSync(backup, 'utf8'), 'export ZSH=~/.oh-my-zsh\nsource $ZSH/oh-my-zsh.sh\n'); // Changed since review: refused. const again = openPrevious(box.env) as Extract; diff --git a/tests/starshipConfigAdapter.test.ts b/tests/starshipConfigAdapter.test.ts index 6bb23730..ff089b08 100644 --- a/tests/starshipConfigAdapter.test.ts +++ b/tests/starshipConfigAdapter.test.ts @@ -1,3 +1,4 @@ +import {Journal} from '../src/backups/journal.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import {mkdtemp, readFile, readdir, rm, symlink, writeFile} from 'node:fs/promises'; @@ -26,7 +27,7 @@ test('Starship module changes are previewed, backed up, and preserve unrelated c }; try { const adapter = new StarshipConfigAdapter({installed: true, binary: '/fake/starship', configPath: path, configExists: true}, - run as ConstructorParameters[1]); + run as ConstructorParameters[1], {HOME: directory, XDG_CONFIG_HOME: join(directory,'.config')}); const proposal = await adapter.propose('directory', true); assert.equal(await readFile(path, 'utf8'), original); assert.match(proposal.diff.join('\n'), /disabled = true/u); @@ -50,7 +51,7 @@ test('Starship adapter rejects intervening edits and symlink configs', async () return {stdout: '[git_branch]\ndisabled = true\n', stderr: ''}; }; const adapter = new StarshipConfigAdapter({installed: true, binary: '/fake/starship', configPath: path, configExists: true}, - run as ConstructorParameters[1]); + run as ConstructorParameters[1], {HOME: directory, XDG_CONFIG_HOME: join(directory,'.config')}); try { const proposal = await adapter.propose('git_branch', true); await writeFile(path, '# changed by user\n'); @@ -74,7 +75,7 @@ test('Starship adapter leaves the user config untouched when CLI validation fail }; try { const adapter = new StarshipConfigAdapter({installed: true, binary: '/fake/starship', configPath: path, configExists: true}, - run as ConstructorParameters[1]); + run as ConstructorParameters[1], {HOME: directory, XDG_CONFIG_HOME: join(directory,'.config')}); await assert.rejects(adapter.propose('directory', true), /did not accept/u); assert.equal(await readFile(path, 'utf8'), '# keep\n'); assert.deepEqual(await readdir(directory), ['starship.toml']); @@ -98,3 +99,21 @@ test('Starship panel offers a module editor with explicit review', () => { assert.match(review, /Review Starship config change/u); assert.match(review, /Apply reviewed change/u); }); + +test('apply returns its own transaction backup even if history order changes',async t=>{ + const directory=await mkdtemp(join(tmpdir(),'nmsh-starship-own-entry-'));t.after(()=>rm(directory,{recursive:true,force:true})); + const path=join(directory,'starship.toml');await writeFile(path,'# original\n'); + const run=async (_binary:string,args:string[],options:{env?:NodeJS.ProcessEnv})=>{ + const staged=options.env!.STARSHIP_CONFIG!; + if(args[0]==='config'){await writeFile(staged,'# original\n[directory]\ndisabled = true\n');return {stdout:'',stderr:''};} + return {stdout:'[directory]\ndisabled = true\n',stderr:''}; + }; + const adapter=new StarshipConfigAdapter({installed:true,binary:'/fake/starship',configPath:path,configExists:true},run as ConstructorParameters[1],{HOME:directory,XDG_CONFIG_HOME:join(directory,'.config')}); + const proposal=await adapter.propose('directory',true); + const originalList=Journal.prototype.list; + t.mock.method(Journal.prototype,'list',function(this:Journal,...args:Parameters){ + const entries=originalList.apply(this,args); + return entries.length?[{...entries[0],id:'unrelated-newest-entry'},...entries]:entries; + }); + const backup=await adapter.apply(proposal);assert.ok(backup);assert.equal(await readFile(backup,'utf8'),'# original\n'); +}); diff --git a/tests/themeBridge.test.ts b/tests/themeBridge.test.ts index b5af4e3c..9b1e76b8 100644 --- a/tests/themeBridge.test.ts +++ b/tests/themeBridge.test.ts @@ -35,7 +35,7 @@ function sandbox(): {env: NodeJS.ProcessEnv; home: string; root: string; done: ( mkdirSync(home); // TMUX_TMPDIR keeps every tmux the product resolves (PATH falls back to standard tool directories) on a // socket directory this test owns: it can never reach the person's own tmux server. - return {root, home, env: {HOME: home, XDG_CONFIG_HOME: join(root, 'config'), PATH: '', TMUX_TMPDIR: root}, done: () => rmSync(root, {recursive: true, force: true})}; + return {root, home, env: {HOME: home, XDG_CONFIG_HOME: join(home, '.config'), PATH: '', TMUX_TMPDIR: root}, done: () => rmSync(root, {recursive: true, force: true})}; } function configWith(targets: Partial>, active = 'builtin:lavender'): PromptConfiguration { @@ -195,7 +195,7 @@ for (const {id, bin} of shells) { assert.equal(lines[2], lines[1], 'an unchanged generation is not re-applied'); assert.match(lines[3]!, id === 'fish' ? /^unset\|user-value$/u : /^\|user-value$/u, 'Independent restores the user\'s own value and removes NMSh\'s'); assert.equal(existsSync(join(box.root, 'history')), false, 'nothing is written to history'); - assert.deepEqual(readdirSync(box.home), [], 'no rc or dotfile was created or changed'); + assert.deepEqual(readdirSync(box.home).filter(name => name !== '.config'), [], 'no rc or dotfile was created or changed'); } finally { box.done(); } }); } @@ -366,7 +366,7 @@ test('applyThemeBridge: isolated per target; independent targets get nothing; fa assert.ok(existsSync(artifactPath('vim', box.env))); assert.equal(existsSync(artifactPath('neovim', box.env)), false, 'Independent: no artifact'); assert.match(readFileSync(bridgeEnvPath('zsh', box.env), 'utf8'), /nmsh_bridge_apply LESS_TERMCAP_md/u); - assert.deepEqual(readdirSync(box.home), [], 'no user config was touched'); + assert.deepEqual(readdirSync(box.home).filter(name => name !== '.config'), [], 'no user config was touched'); // Switching Vim to Independent removes the owned artifact; the env clears the pager values. config = configWith({vim: {mode: 'independent'}}); await applyThemeBridge(context(config, box.env)); @@ -426,7 +426,7 @@ test('delta: never managed; its status says whether git config pins the syntax t assert.equal(report.readiness, 'Own syntax theme'); assert.match(report.notes[0]!, /selects delta's syntax theme \(Monokai Extended\); NMSh leaves it/u); assert.equal(report.mode, 'independent'); - assert.deepEqual(readdirSync(box.home), ['.gitconfig'], 'git config is only read'); + assert.deepEqual(readdirSync(box.home).filter(name => name !== '.config'), ['.gitconfig'], 'git config is only read'); } finally { box.done(); } });