diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index ff74e07..96af896 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -21,7 +21,7 @@ import { } from '../utils/post-install.js'; import { DEVFLOW_PLUGINS, LEGACY_SKILL_NAMES, LEGACY_COMMAND_NAMES, buildAssetMaps, type PluginDefinition } from '../plugins.js'; import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafeDelete } from '../utils/safe-delete.js'; -import { generateSafeDeleteBlock, isAlreadyInstalled, installToProfile } from '../utils/safe-delete-install.js'; +import { generateSafeDeleteBlock, isAlreadyInstalled, installToProfile, removeFromProfile, getInstalledVersion, SAFE_DELETE_BLOCK_VERSION } from '../utils/safe-delete-install.js'; import { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; import { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js'; @@ -507,14 +507,19 @@ export const initCommand = new Command('init') p.log.info(`Install ${color.cyan(safeDeleteInfo.command ?? 'trash')} first: ${color.dim(safeDeleteInfo.installHint)}`); p.log.info(`Then re-run ${color.cyan('devflow init')} to auto-configure safe-delete.`); } else if (safeDeleteAvailable) { - const alreadyInstalled = await isAlreadyInstalled(profilePath); - if (alreadyInstalled) { - p.log.info(`Safe-delete already configured in ${color.dim(profilePath)}`); - } else { - const trashCmd = safeDeleteInfo.command; - const block = generateSafeDeleteBlock(shell, process.platform, trashCmd); - - if (block) { + const trashCmd = safeDeleteInfo.command; + const block = generateSafeDeleteBlock(shell, process.platform, trashCmd); + + if (block) { + const installedVersion = await getInstalledVersion(profilePath); + if (installedVersion === SAFE_DELETE_BLOCK_VERSION) { + p.log.info(`Safe-delete already configured in ${color.dim(profilePath)}`); + } else if (installedVersion > 0) { + await removeFromProfile(profilePath); + await installToProfile(profilePath, block); + p.log.success(`Safe-delete upgraded in ${color.dim(profilePath)}`); + p.log.info('Restart your shell or run: ' + color.cyan(`source ${profilePath}`)); + } else { const confirm = await p.confirm({ message: `Install safe-delete to ${profilePath}? (overrides rm to use ${trashCmd ?? 'recycle bin'})`, initialValue: true, diff --git a/src/cli/utils/safe-delete-install.ts b/src/cli/utils/safe-delete-install.ts index c752ccb..69260c8 100644 --- a/src/cli/utils/safe-delete-install.ts +++ b/src/cli/utils/safe-delete-install.ts @@ -5,6 +5,9 @@ import type { Shell } from './safe-delete.js'; const START_MARKER = '# >>> DevFlow safe-delete >>>'; const END_MARKER = '# <<< DevFlow safe-delete <<<'; +/** Bump this when the safe-delete block changes. */ +export const SAFE_DELETE_BLOCK_VERSION = 2; + /** * Generate the safe-delete shell function block with markers. * Returns null for unsupported shells. @@ -20,6 +23,7 @@ export function generateSafeDeleteBlock( const cmd = trashCommand ?? 'trash'; return [ START_MARKER, + `# v${SAFE_DELETE_BLOCK_VERSION}`, `rm() {`, ` local files=()`, ` for arg in "$@"; do`, @@ -48,6 +52,7 @@ export function generateSafeDeleteBlock( const cmd = trashCommand ?? 'trash'; return [ START_MARKER, + `# v${SAFE_DELETE_BLOCK_VERSION}`, `function rm --description "Safe delete via trash"`, ` set -l files`, ` for arg in $argv`, @@ -73,6 +78,7 @@ export function generateSafeDeleteBlock( if (platform === 'win32') { return [ START_MARKER, + `# v${SAFE_DELETE_BLOCK_VERSION}`, `if (Get-Alias rm -ErrorAction SilentlyContinue) {`, ` Remove-Alias rm -Force -Scope Global`, `}`, @@ -101,6 +107,7 @@ export function generateSafeDeleteBlock( const cmd = trashCommand ?? 'trash'; return [ START_MARKER, + `# v${SAFE_DELETE_BLOCK_VERSION}`, `if (Get-Alias rm -ErrorAction SilentlyContinue) {`, ` Remove-Alias rm -Force -Scope Global`, `}`, @@ -147,6 +154,23 @@ export async function installToProfile(profilePath: string, block: string): Prom await fs.writeFile(profilePath, content, 'utf-8'); } +/** + * Extract the installed safe-delete block version from a profile file. + * Returns 0 (not installed), 1 (legacy block without version stamp), or N (versioned block). + */ +export async function getInstalledVersion(profilePath: string): Promise { + try { + const content = await fs.readFile(profilePath, 'utf-8'); + const startIdx = content.indexOf(START_MARKER); + if (startIdx === -1) return 0; + const afterMarker = content.slice(startIdx + START_MARKER.length); + const match = afterMarker.match(/^\n# v(\d+)/); + return match ? parseInt(match[1], 10) : 1; + } catch { + return 0; + } +} + /** * Remove the safe-delete block from a profile file. * Returns true if the block was found and removed, false otherwise. diff --git a/tests/safe-delete-install.test.ts b/tests/safe-delete-install.test.ts index 38d6c5d..83cf4a8 100644 --- a/tests/safe-delete-install.test.ts +++ b/tests/safe-delete-install.test.ts @@ -7,6 +7,8 @@ import { isAlreadyInstalled, installToProfile, removeFromProfile, + getInstalledVersion, + SAFE_DELETE_BLOCK_VERSION, } from '../src/cli/utils/safe-delete-install.js'; describe('generateSafeDeleteBlock', () => { @@ -64,6 +66,64 @@ describe('generateSafeDeleteBlock', () => { it('returns null for unknown shell', () => { expect(generateSafeDeleteBlock('unknown', 'darwin', 'trash')).toBeNull(); }); + + it('includes version stamp in all shell variants', () => { + const versionLine = `# v${SAFE_DELETE_BLOCK_VERSION}`; + const variants: Array<[Parameters[0], NodeJS.Platform, string | null]> = [ + ['bash', 'linux', 'trash-put'], + ['zsh', 'darwin', 'trash'], + ['fish', 'darwin', 'trash'], + ['powershell', 'win32', null], + ['powershell', 'darwin', 'trash'], + ]; + for (const [shell, platform, cmd] of variants) { + const block = generateSafeDeleteBlock(shell, platform, cmd); + expect(block, `${shell}/${platform} should include version stamp`).toContain(versionLine); + } + }); +}); + +describe('getInstalledVersion', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'safe-delete-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns 0 for missing file', async () => { + expect(await getInstalledVersion(path.join(tmpDir, 'nonexistent'))).toBe(0); + }); + + it('returns 0 for file without markers', async () => { + const filePath = path.join(tmpDir, '.zshrc'); + await fs.writeFile(filePath, 'some unrelated content\n'); + expect(await getInstalledVersion(filePath)).toBe(0); + }); + + it('returns 1 for legacy block without version line', async () => { + const filePath = path.join(tmpDir, '.zshrc'); + await fs.writeFile(filePath, [ + '# >>> DevFlow safe-delete >>>', + 'rm() { trash "$@"; }', + '# <<< DevFlow safe-delete <<<', + ].join('\n')); + expect(await getInstalledVersion(filePath)).toBe(1); + }); + + it('returns version number for versioned block', async () => { + const filePath = path.join(tmpDir, '.zshrc'); + await fs.writeFile(filePath, [ + '# >>> DevFlow safe-delete >>>', + '# v2', + 'rm() { trash "$@"; }', + '# <<< DevFlow safe-delete <<<', + ].join('\n')); + expect(await getInstalledVersion(filePath)).toBe(2); + }); }); describe('isAlreadyInstalled', () => {