From 0bf4385a96535ef8b9d91cc186cfc1df84ba8ab1 Mon Sep 17 00:00:00 2001 From: Phil Huffman <56phil@gmail.com> Date: Mon, 24 Aug 2026 09:09:28 -0500 Subject: [PATCH] fix(install): refuse to replace non-managed files or dirs during setup linkSkills/linkDir/linkFile/linkHook previously did rmSync(dst, {recursive:true}) before symlinking, silently destroying any pre-existing file or directory at the destination. A user's own ~/.claude/hooks/ rtk-rewrite.sh (installed by RTK itself) or a user-authored skills dir with a colliding name would be deleted without prompt or backup. Add assertReplaceable(): the destination may only be replaced when it is absent or already a symlink pointing back into this package. Anything else throws with a clear message. The recursive rmSync is dropped in favor of plain rmSync since the only removable case is now a single managed symlink. Closes #112 --- scripts/lib/install-helpers.js | 50 ++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/scripts/lib/install-helpers.js b/scripts/lib/install-helpers.js index 43b8cf7f..fb1f7480 100644 --- a/scripts/lib/install-helpers.js +++ b/scripts/lib/install-helpers.js @@ -19,6 +19,31 @@ const fs = require('fs'); const path = require('path'); +// Package root — every link the installer creates points back into this +// directory, which is how "managed" is detected (see assertReplaceable). +const REPO_ROOT = path.resolve(__dirname, '..', '..'); + +// Refuse to replace a destination that is not already a bigpowers-managed +// symlink. Prevents setup from silently destroying a user's own file or +// directory (e.g. an existing ~/.claude/hooks/rtk-rewrite.sh installed by +// RTK itself, or a user-authored skills dir with a colliding name). (#112) +function assertReplaceable(dst) { + let st; + try { + st = fs.lstatSync(dst); + } catch { + return; // nothing there — safe to create + } + if (st.isSymbolicLink()) { + const target = fs.readlinkSync(dst); + if (target.startsWith(REPO_ROOT)) return; // managed by bigpowers — safe to re-link + } + throw new Error( + `Refusing to replace ${dst}: it exists and is not a bigpowers-managed symlink. ` + + `Back it up or remove it manually, then re-run setup.` + ); +} + function linkSkills(skillsDir, targetDir) { for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; @@ -27,11 +52,8 @@ function linkSkills(skillsDir, targetDir) { const src = path.join(skillsDir, entry.name); const dst = path.join(targetDir, entry.name); - // Remove existing symlink or directory - try { - fs.rmSync(dst, { force: true, recursive: true }); - } catch {} - + assertReplaceable(dst); + fs.rmSync(dst, { force: true }); fs.symlinkSync(src, dst); } } @@ -42,18 +64,14 @@ function linkDir(src, dst) { `Link source missing: ${src} (run bash scripts/sync-skills.sh first)` ); } - // Remove existing symlink or directory - try { - fs.rmSync(dst, { force: true, recursive: true }); - } catch {} + assertReplaceable(dst); + fs.rmSync(dst, { force: true }); fs.symlinkSync(src, dst); } function linkFile(src, dst) { - // Remove existing symlink or file - try { - fs.rmSync(dst, { force: true }); - } catch {} + assertReplaceable(dst); + fs.rmSync(dst, { force: true }); fs.symlinkSync(src, dst); } @@ -82,10 +100,8 @@ function linkHook(src, dst) { `Hook source missing: ${src} (expected under repo skills/ or scripts/hooks/; fix path or restore file)` ); } - // Remove existing symlink or file - try { - fs.rmSync(dst, { force: true }); - } catch {} + assertReplaceable(dst); + fs.rmSync(dst, { force: true }); fs.symlinkSync(src, dst); try { fs.chmodSync(src, 0o755); } catch {} }