Skip to content
Open
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
67 changes: 67 additions & 0 deletions __tests__/scripts/postuninstall.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, vi } from "vitest";
import { cleanClaudeSettings } from "../../scripts/postuninstall.mjs";

vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
let mockStore: Record<string, string> = {};
return {
...actual,
existsSync: vi.fn((path: string) => path in mockStore),
readFileSync: vi.fn((path: string) => mockStore[path] ?? "{}"),
writeFileSync: vi.fn((path: string, content: string) => {
mockStore[path] = content;
}),
__resetMockStore: () => {
mockStore = {};
},
__setMockFile: (path: string, content: string) => {
mockStore[path] = content;
},
__getMockFile: (path: string) => mockStore[path],
};
});

describe("scripts/postuninstall", () => {
beforeEach(async () => {
vi.resetAllMocks();
const fs = await import("node:fs");
(fs as unknown as { __resetMockStore: () => void }).__resetMockStore();
});

it("returns 0 when settings file does not exist", async () => {
const count = cleanClaudeSettings("/nonexistent/settings.json");
expect(count).toBe(0);
});

it("removes failproofai hook entries from Claude settings and writes clean file", async () => {
const fs = await import("node:fs");
const testPath = "/mock/.claude/settings.json";
const initialSettings = {
hooks: {
PreToolUse: [
{
hooks: [
{
type: "command",
command: "failproofai --hook PreToolUse",
is_failproofai: true,
},
],
},
],
},
};
(fs as unknown as { __setMockFile: (p: string, c: string) => void }).__setMockFile(
testPath,
JSON.stringify(initialSettings),
);

const count = cleanClaudeSettings(testPath);
expect(count).toBe(1);

const updatedRaw = (fs as unknown as { __getMockFile: (p: string) => string }).__getMockFile(testPath);
const updated = JSON.parse(updatedRaw);
expect(updated.hooks).toBeUndefined();
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"test:run": "vitest run",
"lint": "eslint . --config eslint.config.mjs",
"prepare": "bun run build",
"postuninstall": "node scripts/postuninstall.mjs",
"test:e2e": "vitest run --config vitest.config.e2e.mts",
"test:e2e:watch": "vitest --config vitest.config.e2e.mts",
"translate": "bun scripts/translate-docs/cli.ts",
Expand Down
63 changes: 63 additions & 0 deletions scripts/postuninstall.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Postuninstall script for failproofai.
* Automatically cleans up failproofai hook entries from Claude Code & CLI settings files
* when `npm uninstall -g failproofai` or `bun remove -g failproofai` is executed.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { homedir } from "node:os";

function isFailproofaiHook(hook) {
if (!hook || typeof hook !== "object") return false;
if (hook.is_failproofai === true || hook.__failproofai_hook === true) return true;
const cmd = typeof hook.command === "string" ? hook.command : "";
return cmd.includes("failproofai") && cmd.includes("--hook");
Comment on lines +11 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Repository files matching postuninstall:\n'
fd -a 'postuninstall|postinstall' . || true

printf '\nRelevant file outlines:\n'
for f in $(fd 'postuninstall|mjs$|test\.ts$' . | sed 's#^\./##' | head -50); do
  if grep -qE 'isFailproofaiHook|cleanClaudeSettings|hook' "$f" 2>/dev/null; then
    printf '\n--- %s\n' "$f"
    wc -l "$f"
    ast-grep outline "$f" --match cleanClaudeSettings || true
  fi
done

printf '\nSearch hook classifier usages and test hook fixtures:\n'
rg -n "isFailproofaiHook|failproofai|__failproofai_hook|cleanClaudeSettings|hooks" scripts __tests__ package.json 2>/dev/null || true

printf '\nShow relevant source/tests:\n'
if [ -f scripts/postuninstall.mjs ]; then
  cat -n scripts/postuninstall.mjs
fi
if [ -f __tests__/scripts/postuninstall.test.ts ]; then
  sed -n '1,120p' __tests__/scripts/postuninstall.test.ts | cat -n
fi

Repository: FailproofAI/failproofai

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'scripts/postuninstall.mjs:\n'
cat -n scripts/postuninstall.mjs

printf '\n__tests__/scripts/postuninstall.test.ts first 140 lines:\n'
sed -n '1,140p' __tests__/scripts/postuninstall.test.ts | cat -n

printf '\nBehavioral probe for isFailproofaiHook classifying fixtures:\n'
node - <<'JS'
function isFailproofaiHook(hook) {
  if (!hook || typeof hook !== "object") return false;
  if (hook.is_failproofai === true || hook.__failproofai_hook === true) return true;
  const cmd = typeof hook.command === "string" ? hook.command : "";
  return cmd.includes("failproofai") && cmd.includes("--hook");
}

const hooks = [
  { command: "failproofai --hook PreToolUse" },
  { is_failproofai: true, command: "failproofai --hook PreToolUse" },
  { __failproofai_hook: true, command: "failproofai --hook PreToolUse" },
  { command: "npx -y failproofai --hook PreToolUse" },
  { command: "npx -y failproofai policies --install block-rm-rf" },
  { command: "my-failproofai-helper --hook PreToolUse" },
  { name: "failproofai", type: "task", args: [] },
];
for (const hook of hooks) {
  console.log(JSON.stringify(hook), '=>', isFailproofaiHook(hook));
}
JS

Repository: FailproofAI/failproofai

Length of output: 6150


Use a precise FailproofAI hook identity contract. The substring classifier will clean hooks just because they contain failproofai and --hook, e.g. my-failproofai-helper --hook PreToolUse. Match only explicit FailproofAI markers or the exact installer-generated command form, and add a test case that asserts non-FailproofAI hooks with similar text are preserved.

📍 Affects 2 files
  • scripts/postuninstall.mjs#L11-L15 (this comment)
  • __tests__/scripts/postuninstall.test.ts#L37-L66
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/postuninstall.mjs` around lines 11 - 15, The isFailproofaiHook
classifier is too broad and can remove unrelated hooks containing similar text.
In scripts/postuninstall.mjs lines 11-15, match only the explicit FailproofAI
marker properties or the exact installer-generated command form; in
__tests__/scripts/postuninstall.test.ts lines 37-66, add coverage proving a
non-FailproofAI hook with similar command text is preserved.

Source: Coding guidelines

}

export function cleanClaudeSettings(settingsPath) {
if (!existsSync(settingsPath)) return 0;
try {
const raw = readFileSync(settingsPath, "utf8");
const settings = JSON.parse(raw);
if (!settings.hooks) return 0;

let removed = 0;
for (const eventType of Object.keys(settings.hooks)) {
const matchers = settings.hooks[eventType];
if (!Array.isArray(matchers)) continue;
for (let i = matchers.length - 1; i >= 0; i--) {
const matcher = matchers[i];
if (!matcher.hooks) continue;
const before = matcher.hooks.length;
matcher.hooks = matcher.hooks.filter((h) => !isFailproofaiHook(h));
removed += before - matcher.hooks.length;
if (matcher.hooks.length === 0) matchers.splice(i, 1);
}
if (matchers.length === 0) delete settings.hooks[eventType];
}
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;

writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
return removed;
} catch {
return 0;
}
}

try {
const userClaudePath = resolve(homedir(), ".claude", "settings.json");
const projectClaudePath = resolve(process.cwd(), ".claude", "settings.json");
const localClaudePath = resolve(process.cwd(), ".claude", "settings.local.json");

let count = 0;
count += cleanClaudeSettings(userClaudePath);
count += cleanClaudeSettings(projectClaudePath);
count += cleanClaudeSettings(localClaudePath);

if (count > 0) {
console.log(`[failproofai] Postuninstall: Cleaned up ${count} hook entry(ies) from Claude settings.`);
}
} catch {
// Fail-open: postuninstall script must never throw
}