Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions src/cli/utils/safe-delete-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`,
Expand Down Expand Up @@ -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`,
Expand All @@ -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`,
`}`,
Expand Down Expand Up @@ -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`,
`}`,
Expand Down Expand Up @@ -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<number> {
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.
Expand Down
60 changes: 60 additions & 0 deletions tests/safe-delete-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
isAlreadyInstalled,
installToProfile,
removeFromProfile,
getInstalledVersion,
SAFE_DELETE_BLOCK_VERSION,
} from '../src/cli/utils/safe-delete-install.js';

describe('generateSafeDeleteBlock', () => {
Expand Down Expand Up @@ -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<typeof generateSafeDeleteBlock>[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', () => {
Expand Down