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
3 changes: 1 addition & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # for npm provenance
steps:
- uses: actions/checkout@v6

Expand Down Expand Up @@ -43,6 +42,6 @@ jobs:
registry-url: "https://registry.npmjs.org"

- name: Publish
run: npm publish --provenance --ignore-scripts
run: npm publish --ignore-scripts
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"test": "vitest",
"test:run": "vitest run",
"lint": "eslint . --config eslint.config.mjs",
"postinstall": "node scripts/postinstall.mjs",
"preuninstall": "node scripts/preuninstall.mjs",
"prepublishOnly": "bun run build",
"test:e2e": "vitest run --config vitest.config.e2e.mts",
"test:e2e:watch": "vitest --config vitest.config.e2e.mts"
Expand Down
108 changes: 108 additions & 0 deletions scripts/install-telemetry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Lightweight PostHog telemetry for npm install/uninstall lifecycle scripts.
*
* Uses fetch() directly — no external dependencies, Node.js built-ins only.
* Mirrors the pattern used in src/hooks/hook-telemetry.ts.
*/
import { createHmac, randomUUID } from "node:crypto";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir, platform, arch, hostname, cpus } from "node:os";
import { join } from "node:path";

const NAMESPACE = "failproofai-telemetry-v1";
const API_KEY = "phc_Ac1Ww1GqKc0z1SyrRWbmatEeQdlOQIsDEEdP8l8JRgX";
const CAPTURE_URL = "https://us.i.posthog.com/capture/";

function hashToId(raw) {
return createHmac("sha256", NAMESPACE).update(raw).digest("hex");
}

function getInstanceId() {
// Tier 1: existing ~/.failproofai/instance-id file (consistent with server-side ID)
const idDir = join(homedir(), ".failproofai");
const idFile = join(idDir, "instance-id");
try {
const existing = readFileSync(idFile, "utf-8").trim();
if (existing) return existing;
} catch {}

// Tier 2: OS-native machine ID (hashed, survives cache deletion)
try {
const p = platform();
if (p === "linux") {
for (const machineIdPath of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
try {
const id = readFileSync(machineIdPath, "utf-8").trim();
if (id) return hashToId(id);
} catch {}
}
} else if (p === "darwin") {
const out = execSync("ioreg -rd1 -c IOPlatformExpertDevice", {
encoding: "utf-8",
timeout: 3000,
});
const m = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
if (m?.[1]) return hashToId(m[1]);
} else if (p === "win32") {
const out = execSync(
'reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid',
{ encoding: "utf-8", timeout: 3000 }
);
const m = out.match(/MachineGuid\s+REG_SZ\s+(\S+)/);
if (m?.[1]) return hashToId(m[1]);
}
} catch {}

// Tier 3: hashed system properties
try {
const sysProps = [
hostname(),
homedir(),
platform(),
arch(),
cpus()[0]?.model ?? "",
].join(":");
return hashToId(sysProps);
} catch {}

// Tier 4: random UUID written to file for future consistency
const id = randomUUID();
try {
mkdirSync(idDir, { recursive: true });
writeFileSync(idFile, id, "utf-8");
} catch {}
return id;
}

/**
* Track a named event to PostHog. No-op when telemetry is disabled.
* Uses process.env.npm_package_version (set automatically by npm in lifecycle scripts).
*/
export async function trackInstallEvent(event, properties = {}) {
if (process.env.FAILPROOFAI_TELEMETRY_DISABLED === "1") return;

const version = process.env.npm_package_version ?? "unknown";
const body = JSON.stringify({
api_key: process.env.FAILPROOFAI_POSTHOG_KEY ?? API_KEY,
event,
distinct_id: getInstanceId(),
properties: {
...properties,
$lib: "failproofai-install",
failproofai_version: version,
},
});

await fetch(
process.env.FAILPROOFAI_POSTHOG_HOST
? `${process.env.FAILPROOFAI_POSTHOG_HOST}/capture/`
: CAPTURE_URL,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: AbortSignal.timeout(5000),
}
);
}
5 changes: 4 additions & 1 deletion scripts/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*/
import { getDefaultClaudeProjectsPath } from "../lib/paths";
import { spawn } from "child_process";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parseScriptArgs } from "./parse-script-args";
import { version } from "../package.json";

Expand Down Expand Up @@ -37,7 +39,8 @@ export function launch(mode: "dev" | "start"): void {
process.env.PORT = port;
process.env.HOSTNAME = "0.0.0.0";
cmd = "node";
cmdArgs = [".next/standalone/server.js"];
// Absolute path — required so the CLI works from any working directory after install
cmdArgs = [resolve(dirname(fileURLToPath(import.meta.url)), "../.next/standalone/server.js")];
} else {
cmd = "bunx";
cmdArgs = ["--bun", "next", "dev", ...remainingArgs];
Expand Down
110 changes: 110 additions & 0 deletions scripts/postinstall.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* postinstall script for the failproofai package.
*
* 1. Warns if hooks config exists but hooks are missing from Claude Code settings.
* 2. Tracks a package_installed telemetry event.
*
* No external dependencies — Node.js built-ins only.
*/
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { platform, arch, release, homedir, hostname } from "node:os";
import { createHmac } from "node:crypto";
import { trackInstallEvent } from "./install-telemetry.mjs";

// Skip when running in development context (e.g. `bun install` in the source repo).
// INIT_CWD is set by npm/bun to the directory where install was invoked; it differs
// from process.cwd() only when we are being installed as a dependency by someone else.
if (!process.env.INIT_CWD || process.env.INIT_CWD === process.cwd()) process.exit(0);

const FAILPROOFAI_HOOK_MARKER = "__failproofai_hook__";
const NAMESPACE = "failproofai-telemetry-v1";

function hashToId(raw) {
return createHmac("sha256", NAMESPACE).update(raw).digest("hex");
}

/**
* Returns the current hooks configuration state.
* @returns {{ configured: boolean, registered: boolean, policyCount: number }}
*/
function checkHooks() {
const hooksConfigPath = resolve(homedir(), ".failproofai", "hooks-config.json");
if (!existsSync(hooksConfigPath)) {
return { configured: false, registered: false, policyCount: 0 };
}

let config;
try {
config = JSON.parse(readFileSync(hooksConfigPath, "utf8"));
} catch {
return { configured: false, registered: false, policyCount: 0 };
}

if (!Array.isArray(config.enabledPolicies) || config.enabledPolicies.length === 0) {
return { configured: false, registered: false, policyCount: 0 };
}

const policyCount = config.enabledPolicies.length;

// Check if Claude Code settings have failproofai hooks
const settingsPath = resolve(homedir(), ".claude", "settings.json");
if (!existsSync(settingsPath)) {
printHooksWarning();
return { configured: true, registered: false, policyCount };
}

let settings;
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
} catch {
printHooksWarning();
return { configured: true, registered: false, policyCount };
}

if (!settings.hooks) {
printHooksWarning();
return { configured: true, registered: false, policyCount };
}

// Walk settings.hooks looking for failproofai entries
for (const matchers of Object.values(settings.hooks)) {
if (!Array.isArray(matchers)) continue;
for (const matcher of matchers) {
if (!matcher.hooks) continue;
if (matcher.hooks.some((h) => h[FAILPROOFAI_HOOK_MARKER] === true)) {
return { configured: true, registered: true, policyCount };
}
}
}

printHooksWarning();
return { configured: true, registered: false, policyCount };
}

function printHooksWarning() {
console.log(
`\n[failproofai] Warning: hooks config exists with enabled policies, but hooks are not registered in Claude Code settings.\n` +
` To re-register hooks, run:\n` +
` failproofai --remove-policies && failproofai --install-policies\n`
);
}

let hooksResult = { configured: false, registered: false, policyCount: 0 };
try {
hooksResult = checkHooks();
} catch {
// Non-critical — don't fail the install
}

// Telemetry (best-effort, fire-and-forget)
trackInstallEvent("package_installed", {
platform: platform(),
arch: arch(),
os_release: release(),
hostname_hash: hashToId(hostname()),
hooks_configured: hooksResult.configured,
hooks_registered: hooksResult.registered,
enabled_policy_count: hooksResult.policyCount,
}).catch(() => {});
131 changes: 131 additions & 0 deletions scripts/preuninstall.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* preuninstall script for the failproofai package.
*
* Removes failproofai hook entries from all reachable Claude Code settings files:
* - ~/.claude/settings.json (user scope — always attempted)
* - {cwd}/.claude/settings.json (project scope — if it exists)
* - {cwd}/.claude/settings.local.json (local scope — if it exists)
*
* Does NOT delete ~/.failproofai/ (preserves cache, hooks-config, instance-id).
*
* Never exits non-zero — uninstall must not be blocked by cleanup failures.
* No external dependencies — Node.js built-ins only.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { homedir, platform, arch } from "node:os";
import { trackInstallEvent } from "./install-telemetry.mjs";

// Skip when running in development context (same guard as postinstall.mjs).
if (!process.env.INIT_CWD || process.env.INIT_CWD === process.cwd()) process.exit(0);

const FAILPROOFAI_HOOK_MARKER = "__failproofai_hook__";

/**
* Remove all failproofai-marked hook entries from a single settings file.
* Returns the number of hook entries removed.
* Writes the file only when at least one hook was removed.
*/
function removeHooksFromFile(settingsPath) {
if (!existsSync(settingsPath)) return 0;

let settings;
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
} catch {
return 0; // Corrupt or unreadable — nothing to do
}

if (!settings?.hooks) return 0;

let hooksRemoved = 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) => {
if (h[FAILPROOFAI_HOOK_MARKER] === true) return false; // marked entry
// Fallback for legacy installs that predate the marker
const cmd = typeof h.command === "string" ? h.command : "";
if (cmd.includes("failproofai") && cmd.includes("--hook")) return false;
return true;
});
hooksRemoved += before - matcher.hooks.length;

// Remove now-empty matchers
if (matcher.hooks.length === 0) {
matchers.splice(i, 1);
}
}

// Remove now-empty event type arrays
if (matchers.length === 0) {
delete settings.hooks[eventType];
}
}

// Remove now-empty hooks object
if (Object.keys(settings.hooks).length === 0) {
delete settings.hooks;
}

if (hooksRemoved > 0) {
try {
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
} catch {
// Best-effort — don't block uninstall
}
}

return hooksRemoved;
}

let totalRemoved = 0;

try {
const home = homedir();
const projectCwd = process.cwd();

// Build list of settings files to clean, deduped in case cwd === home
const candidates = [
resolve(home, ".claude", "settings.json"), // user scope
resolve(projectCwd, ".claude", "settings.json"), // project scope
resolve(projectCwd, ".claude", "settings.local.json"), // local scope
];
const seen = new Set();
const settingsPaths = candidates.filter((p) => {
if (seen.has(p)) return false;
seen.add(p);
return true;
});

for (const settingsPath of settingsPaths) {
const removed = removeHooksFromFile(settingsPath);
if (removed > 0) {
console.log(`[failproofai] Removed ${removed} hook(s) from ${settingsPath}.`);
totalRemoved += removed;
}
}

if (totalRemoved === 0) {
console.log("[failproofai] No hook entries found to remove.");
}
} catch {
// Never block uninstall
}

// Telemetry — best-effort, awaited so the process stays alive long enough to send
try {
await trackInstallEvent("package_uninstalled", {
platform: platform(),
arch: arch(),
hooks_removed: totalRemoved,
});
} catch {}