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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

### Features
- Expand PostHog telemetry coverage to close the 16 server-side and 12 web-UI gaps surfaced by the May audit (#376). Server-side adds `cli_install_success` / `cli_install_failure` / `cli_uninstall_success` / `cli_uninstall_failure` / `cli_list_invoked` / `cli_parse_error` / `cli_unexpected_error` / `hook_dispatch_error` (CLI lifecycle outcomes in `bin/failproofai.mjs`), `hook_stdin_error` / `hook_payload_parse_error` (hook handler input errors in `src/hooks/handler.ts`), `policy_evaluation_error` (builtin policy crashes in `src/hooks/policy-evaluator.ts`, distinct from the existing `custom_hook_error`), `custom_policy_validation_failed` / `custom_hooks_load_error` / `policy_params_validation_warning` / `scope_validation_failed` / `hook_write_failed` / `multi_scope_warning_shown` / `cli_detection_summary` / `beta_policies_installed` (manager / loader / install-prompt internals), and `first_install` / `version_changed` (lifecycle detection in `scripts/postinstall.mjs` via a new `~/.failproofai/last-version` file). Web-UI adds `policies_tab_switched` / `activity_filter_changed` (debounced) / `activity_row_toggled` / `activity_copy_clicked` / `activity_pagination_changed` / `cli_selection_toggled` / `cli_install_remove_submitted` / `cli_reinstall_submitted` / `policy_config_modal_opened` / `policy_config_modal_closed` / `action_error_displayed` / `hooks_install_from_error_clicked` via `usePostHog()` in `app/policies/hooks-client.tsx`. The deny-/instruct-only condition at `handler.ts:344` (allow-path tracking) is intentionally left unchanged. All events go through the existing helpers (`trackHookEvent`, `trackInstallEvent`, `captureClientEvent`) and honor `FAILPROOFAI_TELEMETRY_DISABLED=1`.
- Add a first-run install prompt on bare `failproofai` invocations. PostHog showed only ~10% of npm-installed users ever ran `failproofai policies --install`; the no-args dashboard launch now detects "zero hooks installed across any detected CLI" and offers to run the existing interactive policy-selection inline (covering all of Claude Code, Codex, Copilot, Cursor, OpenCode, Pi, Gemini). Non-TTY contexts (CI, piped invocations) print a short stderr hint and fall through to the dashboard. New `src/hooks/first-run-nudge.ts` module, a guard in `bin/failproofai.mjs` before `launch("start")`, plus four new PostHog events (`first_run_nudge_shown`, `_accepted`, `_declined`, `_skipped_noninteractive`) so the uplift is measurable. Postinstall message extended with a "Next steps" block when the brand-new-user case is detected (`!configured && !registered`). Opt-out via `FAILPROOFAI_NO_FIRST_RUN=1`.

### Docs
- Document the new first-run prompt in the README and `docs/introduction.mdx` quickstart snippets (calling out that `failproofai policies --install` is now optional — running bare `failproofai` will offer to do it), and add a new "First-run prompt" section to `docs/cli/environment-variables.mdx` for `FAILPROOFAI_NO_FIRST_RUN=1`. Chinese mirror and the 14 translated env-vars files left for the translation-sync workflow.

### Breaking
- Remove the undocumented cloud auth + event relay subsystem ahead of a from-scratch redesign. Deletes `src/auth/` (OAuth 2.0 device-flow login against `api.befailproof.ai`, `~/.failproofai/auth.json` token store) and `src/relay/` (WebSocket event relay daemon, sanitized JSONL queue at `~/.failproofai/cache/server-queue/`, PID tracking). Strips the `failproofai login` / `logout` / `whoami` / `relay start|stop|status` / `sync` subcommands and the internal `--relay-daemon` mode from `bin/failproofai.mjs`, along with their `--help` entries and "did you mean" suggestions. Removes the fire-and-forget `appendToServerQueue` + `ensureRelayRunning` calls from `src/hooks/handler.ts` so hook evaluation no longer enqueues events or lazy-spawns a daemon. The whole subsystem had zero references in `README.md`, `docs/`, `examples/`, or `__tests__/`, and only had internal cross-imports — `tsc`, `eslint`, `vitest` (1623 tests), and the `bun run build` bundles all stay green. Users who ran `failproofai login` should also wipe `~/.failproofai/{auth.json,cache/server-queue,relay.pid}` and stop any running relay daemon by hand; new auth/cloud surface will land in a follow-up.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ before they become incidents. Zero latency. Runs locally.

```sh
npm install -g failproofai
failproofai policies --install
failproofai policies --install # or just run `failproofai` and accept the first-run prompt
failproofai
```

30 built-in policies activate immediately. Dashboard at `localhost:8020`.
30 built-in policies activate immediately. Dashboard at `localhost:8020`. Disable the first-run prompt with `FAILPROOFAI_NO_FIRST_RUN=1`.

---

Expand Down
282 changes: 282 additions & 0 deletions __tests__/hooks/first-run-nudge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { PassThrough } from "node:stream";

vi.mock("../../src/hooks/integrations", () => ({
detectInstalledClis: vi.fn(),
getIntegration: vi.fn(),
}));

vi.mock("../../src/hooks/manager", () => ({
installHooks: vi.fn(async () => undefined),
}));

vi.mock("../../src/hooks/hook-telemetry", () => ({
trackHookEvent: vi.fn(async () => undefined),
}));

vi.mock("../../lib/telemetry-id", () => ({
getInstanceId: vi.fn(() => "test-distinct-id"),
}));

function makeIntegration(displayName: string, scopes: readonly string[], installed: boolean) {
return {
id: displayName.toLowerCase(),
displayName,
scopes,
eventTypes: [],
getSettingsPath: vi.fn(),
readSettings: vi.fn(),
writeSettings: vi.fn(),
buildHookEntry: vi.fn(),
isFailproofaiHook: vi.fn(),
writeHookEntries: vi.fn(),
removeHooksFromFile: vi.fn(),
hooksInstalledInSettings: vi.fn(() => installed),
detectInstalled: vi.fn(),
};
}

interface IO {
stdin: PassThrough & { isTTY?: boolean };
stdout: PassThrough & { isTTY?: boolean };
output: string;
}

function makeIO(isTTY: boolean): IO {
const stdin = new PassThrough() as PassThrough & { isTTY?: boolean };
const stdout = new PassThrough() as PassThrough & { isTTY?: boolean };
stdin.isTTY = isTTY;
stdout.isTTY = isTTY;
let output = "";
stdout.on("data", (chunk) => {
output += chunk.toString();
});
const io = { stdin, stdout } as IO;
Object.defineProperty(io, "output", { get: () => output });
return io;
}

async function importModule() {
return await import("../../src/hooks/first-run-nudge");
}

async function importMocks() {
const integrations = await import("../../src/hooks/integrations");
const manager = await import("../../src/hooks/manager");
const telemetry = await import("../../src/hooks/hook-telemetry");
return { integrations, manager, telemetry };
}

describe("hooks/first-run-nudge", () => {
let exitSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
delete process.env.FAILPROOFAI_NO_FIRST_RUN;
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${code ?? 0}`);
}) as never);
});

afterEach(() => {
exitSpy.mockRestore();
});

it("returns immediately when FAILPROOFAI_NO_FIRST_RUN=1 (no detection, no telemetry)", async () => {
process.env.FAILPROOFAI_NO_FIRST_RUN = "1";
const { maybeRunFirstRunNudge } = await importModule();
const { integrations, manager, telemetry } = await importMocks();

await maybeRunFirstRunNudge(makeIO(true));

expect(integrations.detectInstalledClis).not.toHaveBeenCalled();
expect(manager.installHooks).not.toHaveBeenCalled();
expect(telemetry.trackHookEvent).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});

it("returns when no CLIs are detected", async () => {
const { maybeRunFirstRunNudge } = await importModule();
const { integrations, manager, telemetry } = await importMocks();
vi.mocked(integrations.detectInstalledClis).mockReturnValue([]);

await maybeRunFirstRunNudge(makeIO(true));

expect(manager.installHooks).not.toHaveBeenCalled();
expect(telemetry.trackHookEvent).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});

it("returns when any detected CLI already has hooks installed in any scope", async () => {
const { maybeRunFirstRunNudge } = await importModule();
const { integrations, manager, telemetry } = await importMocks();
vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude", "codex"] as never);
const claudeInt = makeIntegration("Claude Code", ["user", "project", "local"], false);
const codexInt = makeIntegration("Codex", ["user", "project"], true);
vi.mocked(integrations.getIntegration).mockImplementation(
(id: string) => (id === "claude" ? claudeInt : codexInt) as never,
);

await maybeRunFirstRunNudge(makeIO(true));

expect(manager.installHooks).not.toHaveBeenCalled();
expect(telemetry.trackHookEvent).not.toHaveBeenCalled();
});

it("non-TTY: prints hint and fires _skipped_noninteractive", async () => {
const { maybeRunFirstRunNudge } = await importModule();
const { integrations, manager, telemetry } = await importMocks();
vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude"] as never);
vi.mocked(integrations.getIntegration).mockReturnValue(
makeIntegration("Claude Code", ["user"], false) as never,
);

const io = makeIO(false);
await maybeRunFirstRunNudge(io);

expect(io.output).toContain("No policies are installed");
expect(io.output).toContain("Launching dashboard");
expect(manager.installHooks).not.toHaveBeenCalled();
expect(telemetry.trackHookEvent).toHaveBeenCalledWith(
"test-distinct-id",
"first_run_nudge_skipped_noninteractive",
{ detected_clis: ["claude"], detected_count: 1 },
);
});

it("TTY accept (Y): fires _shown then _accepted, calls installHooks with detected CLIs, exits 0", async () => {
const { maybeRunFirstRunNudge } = await importModule();
const { integrations, manager, telemetry } = await importMocks();
vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude", "codex"] as never);
const intMap: Record<string, ReturnType<typeof makeIntegration>> = {
claude: makeIntegration("Claude Code", ["user"], false),
codex: makeIntegration("Codex", ["user"], false),
};
vi.mocked(integrations.getIntegration).mockImplementation(
(id: string) => intMap[id] as never,
);

const io = makeIO(true);
setTimeout(() => io.stdin.write("y\n"), 10);

await expect(maybeRunFirstRunNudge(io)).rejects.toThrow("exit:0");

expect(io.output).toContain("Failproof AI — first-run setup");
expect(io.output).toContain("Claude Code, Codex");

const calls = vi.mocked(telemetry.trackHookEvent).mock.calls;
const events = calls.map((c) => c[1]);
expect(events).toEqual(["first_run_nudge_shown", "first_run_nudge_accepted"]);
expect(calls[1][2]).toMatchObject({
detected_clis: ["claude", "codex"],
detected_count: 2,
target_scope: "user",
source: "first-run-nudge",
});

expect(manager.installHooks).toHaveBeenCalledWith(
undefined,
"user",
undefined,
false,
"first-run-nudge",
undefined,
false,
["claude", "codex"],
);
});

it("TTY accept on empty Enter (default Y): runs installHooks", async () => {
const { maybeRunFirstRunNudge } = await importModule();
const { integrations, manager } = await importMocks();
vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude"] as never);
vi.mocked(integrations.getIntegration).mockReturnValue(
makeIntegration("Claude Code", ["user"], false) as never,
);

const io = makeIO(true);
setTimeout(() => io.stdin.write("\n"), 10);

await expect(maybeRunFirstRunNudge(io)).rejects.toThrow("exit:0");
expect(manager.installHooks).toHaveBeenCalled();
});

it("TTY decline (n): fires _declined with reason user_no, does NOT call installHooks, does NOT exit", async () => {
const { maybeRunFirstRunNudge } = await importModule();
const { integrations, manager, telemetry } = await importMocks();
vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude"] as never);
vi.mocked(integrations.getIntegration).mockReturnValue(
makeIntegration("Claude Code", ["user"], false) as never,
);

const io = makeIO(true);
setTimeout(() => io.stdin.write("n\n"), 10);

await maybeRunFirstRunNudge(io);

const events = vi.mocked(telemetry.trackHookEvent).mock.calls.map((c) => c[1]);
expect(events).toEqual(["first_run_nudge_shown", "first_run_nudge_declined"]);
const declined = vi
.mocked(telemetry.trackHookEvent)
.mock.calls.find((c) => c[1] === "first_run_nudge_declined")?.[2];
expect(declined).toMatchObject({ reason: "user_no" });
expect(manager.installHooks).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});

it("TTY SIGINT: fires _declined with reason sigint and exits 130", async () => {
// Mock readline so we can trigger the SIGINT handler directly — emulating
// ^C through a PassThrough is brittle across Node versions.
vi.doMock("node:readline", () => ({
createInterface: () => {
const handlers: Record<string, () => void> = {};
return {
on: (ev: string, cb: () => void) => {
handlers[ev] = cb;
},
question: (_q: string, _cb: () => void) => {
setImmediate(() => handlers["SIGINT"]?.());
},
close: () => {},
};
},
}));

const { maybeRunFirstRunNudge } = await importModule();
const { integrations, manager, telemetry } = await importMocks();
vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude"] as never);
vi.mocked(integrations.getIntegration).mockReturnValue(
makeIntegration("Claude Code", ["user"], false) as never,
);

const io = makeIO(true);
await expect(maybeRunFirstRunNudge(io)).rejects.toThrow("exit:130");

const declined = vi
.mocked(telemetry.trackHookEvent)
.mock.calls.find((c) => c[1] === "first_run_nudge_declined")?.[2];
expect(declined).toMatchObject({ reason: "sigint" });
expect(manager.installHooks).not.toHaveBeenCalled();
vi.doUnmock("node:readline");
});

it("survives a broken integration.hooksInstalledInSettings (treats it as not-installed)", async () => {
const { maybeRunFirstRunNudge } = await importModule();
const { integrations, manager } = await importMocks();
vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude"] as never);
const broken = makeIntegration("Claude Code", ["user"], false);
broken.hooksInstalledInSettings = vi.fn(() => {
throw new Error("boom");
});
vi.mocked(integrations.getIntegration).mockReturnValue(broken as never);

const io = makeIO(true);
setTimeout(() => io.stdin.write("n\n"), 10);

await maybeRunFirstRunNudge(io);

expect(manager.installHooks).not.toHaveBeenCalled();
});
});
Loading