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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

### Fixes
- Validate every translated docs page at generation time and re-translate with the validation error fed back, instead of discovering the breakage in `consolidate` after all 14 language jobs have already spent their tokens. The daily `translate-docs` run kept failing (5 of the last 15 runs) on errors like `de/agenteye/cli-skill.mdx: There is a syntax error in your frontmatter on line 2` — the model re-emitting an escaped inner quote from a `description:` value as an unescaped `"`, which breaks the YAML. The repo's own `validate:mdx` net could not catch this class at all: `findMdxParseError` blanks the frontmatter before compiling, so a frontmatter YAML error was structurally invisible to it and only the last-step `mintlify validate` in `consolidate` ever saw it — one bad page in one language sank the whole batch and nothing published. `validate-mdx.ts` now exports `findFrontmatterError` (YAML parse via the already-present `yaml` dep, file-relative line numbers) and `findPageError` (frontmatter then body), and `validate:mdx` runs the latter, so the CI net is now a strict superset of `mintlify validate` across all 647 pages. At generation time each translator renders the exact bytes it will write (sanitizers + link-rewrite for MDX pages, disclaimer + RTL `<div>` wrapper for the README) and validates them through a shared `findTranslationError` — frontmatter YAML, frontmatter key-parity against the English source (catches a dropped or renamed block, which is still valid YAML and which `mintlify` tolerates), and MDX body — re-translating with the error appended to the prompt until it passes or `TRANSLATE_MAX_ATTEMPTS` (integer ≥ 1, default 3, distinct from the SDK-transport `TRANSLATE_MAX_RETRIES`) is reached. On exhaustion it throws *before* the write, so a broken page is never written to disk and never cached — the cache keys only the English source hash, so a cached-invalid page could otherwise never self-heal. System-prompt rule 3 now carries the same frontmatter-quoting guidance rule 2 has always given for JSX attributes, cutting the failure off at the source. (#570)
- Clear the dashboard `CopyButton`'s “copied ✓” revert timer properly: a rapid re-click now cancels the previous click's still-armed timer before arming its own (so the checkmark can't flip back to the copy icon early), and a `useEffect` cleanup cancels it on unmount — no more React state-update-on-an-unmounted-component warning when the sessions table filters a row away mid-window. The timer id lives in a `useRef`. (#528)
- Stop shipping npm lifecycle scripts, removing the `npm warn` every install printed. npm 12 (`allowScripts`, released 2026-07-08 and now the `latest` dist-tag) blocks dependency install scripts by default, so `npm install failproofai` warned `1 package had install scripts blocked … failproofai@… (postinstall: node scripts/postinstall.mjs)` and silently skipped the script; npm 11.16+ prints the advisory `allow-scripts` variant and still runs it. `allowScripts` is purely consumer-side — a package cannot opt itself in (verified: a package declaring `allowScripts`/`trustedDependencies` for *itself* is ignored) — so the only fix is to ship no install scripts. The `postinstall` script's install telemetry (`first_install` / `version_changed` / `package_installed`, with identical event names and properties) now fires from the CLI's first non-hook invocation via `lib/install-check.ts`, reporting at most once per version and no-op'ing on the steady-state path; it is deliberately kept out of `bin/failproofai.mjs`'s `--hook` fast path, which runs on every tool call. This also *recovers* telemetry already being dropped for bun, pnpm, and Yarn ≥4.14 users, which have blocked install scripts for some time. The script's `server.js` check and shadowed-PATH diagnosis were redundant — `scripts/launch.ts` already performs both at launch, with a better error message. `package_installed` now measures install→activation rather than raw installs, and same-version reinstalls (`direction: "reinstall"`) are no longer reported, as the CLI has no signal to detect them. The install-time welcome message is dropped; the `config` wizard's first-run redirect already handles onboarding. (#560)
- Remove `scripts/preuninstall.mjs`, which never ran. npm honours no uninstall lifecycle scripts (verified with a probe package: `postinstall` fired, `preuninstall` did not), so the hook cleanup it appeared to perform has never happened — uninstalling failproofai leaves `__failproofai_hook__` entries behind in settings files. Removing the dead code makes the gap visible; cleanup needs an explicit `failproofai policies --uninstall` before removing the package. (#560)
- Stop this repo's dogfood hooks from silently no-op'ing when `bun` isn't on the hook's PATH. All 75 hook commands across the 8 project-level agent-CLI configs fired `bun bin/failproofai.mjs --hook <Event>` directly, so whenever the agent CLI was handed a PATH without bun — `npm i -g bun` installs into a single nvm version's bin dir, so `nvm use <other>` drops it; a macOS GUI launch inherits a launchd PATH built without `~/.zshrc` — every event died with exit 127 and the session ran with **zero** policy enforcement, saying nothing. The configs now call a dev-only `node scripts/dev-hook.mjs` launcher that locates bun across `PATH`, `$BUN_INSTALL/bin`, `~/.bun/bin`, the node execPath sibling, Homebrew/`/usr/local`, and every `~/.nvm/versions/node/*/bin`; installs it via npm if it is genuinely absent; builds `dist/index.js` when missing so `.failproofai/policies/*.mjs` can resolve `import ... from 'failproofai'`; then re-execs the real binary with `stdio: "inherit"` (byte-exact stdout — the deny contracts are JSON on stdout) and propagates the exit code verbatim (2 still means deny; signals map to 128+signum as `sh` did). A `command -v node` pre-check fronts each command so a missing node is a loud one-liner rather than silence — exit 2 on tool events, exit 1 on stop-class events, where exit 2 would mean "retry" and loop forever. The `.opencode` dev shim shares the same resolver and no longer reports a never-run hook as `exitCode: 0` (a silent allow). Production users on `npx -y failproofai` are unaffected. A new drift-guard test reads every committed dogfood config and asserts the launcher form, guard, exit-code split, and per-file command counts — these configs are generated by nothing and read by nothing, which is how #337's opencode shim drifted and silently no-op'd `block-read-outside-cwd` repo-wide. (#564)
Expand Down
66 changes: 66 additions & 0 deletions __tests__/components/copy-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,72 @@ describe("CopyButton", () => {
vi.useRealTimers();
});

it("keeps the checkmark for the full 2s window after the last of two rapid clicks", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
vi.useFakeTimers({ shouldAdvanceTime: true });

render(<CopyButton text="test" />);
const button = screen.getByTitle("Copy to clipboard");

// Click at t=0 (timer A would revert at t=2.0s)
await user.click(button);
expect(screen.getByTestId("check-icon")).toBeInTheDocument();

// Click again at t=1.5s — should reset the window (arm timer B, clear timer A)
act(() => {
vi.advanceTimersByTime(1500);
});
await user.click(button);
expect(screen.getByTestId("check-icon")).toBeInTheDocument();

// t=2.0s: stale timer A must NOT flip the icon back early
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
expect(screen.queryByTestId("copy-icon")).not.toBeInTheDocument();

// t=3.5s (2.0s after the LAST click at t=1.5s): timer B reverts it
act(() => {
vi.advanceTimersByTime(1500);
});
expect(screen.getByTestId("copy-icon")).toBeInTheDocument();
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();

vi.useRealTimers();
});

it("clears the armed revert timer on unmount", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
vi.useFakeTimers({ shouldAdvanceTime: true });
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

const { unmount } = render(<CopyButton text="test" />);
await user.click(screen.getByTitle("Copy to clipboard"));
expect(screen.getByTestId("check-icon")).toBeInTheDocument();

// The click armed the 2s revert timer — it is pending mid-window.
act(() => {
vi.advanceTimersByTime(500);
});
expect(vi.getTimerCount()).toBe(1);

// Unmounting before the timer fires must clear it, so nothing is left
// pending to call `setCopied` on the gone component. This is the
// load-bearing assertion: it bites without the useEffect cleanup.
unmount();
expect(vi.getTimerCount()).toBe(0);

// Secondary: advancing past the original window logs no error (e.g. a
// "state update on unmounted component" warning), belt-and-suspenders.
act(() => {
vi.advanceTimersByTime(2000);
});
expect(errorSpy).not.toHaveBeenCalled();

vi.useRealTimers();
});

it("applies custom className", () => {
render(<CopyButton text="test" className="custom-class" />);
const button = screen.getByTitle("Copy to clipboard");
Expand Down
28 changes: 22 additions & 6 deletions app/components/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useCallback } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { Copy, Check } from "lucide-react";
import { cn } from "@/lib/utils";

Expand All @@ -25,6 +25,23 @@ interface CopyButtonProps {

export function CopyButton({ text, className }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Clear any pending revert timer on unmount so `setCopied` never fires
// after the component is gone.
useEffect(() => {
return () => {
if (revertTimerRef.current) clearTimeout(revertTimerRef.current);
};
}, []);

const armRevertTimer = useCallback(() => {
// A re-click resets the 2s window rather than being cut short by the
// previous (stale) timer.
if (revertTimerRef.current) clearTimeout(revertTimerRef.current);
setCopied(true);
revertTimerRef.current = setTimeout(() => setCopied(false), 2000);
}, []);

const handleCopy = useCallback(async () => {
try {
Expand All @@ -33,24 +50,23 @@ export function CopyButton({ text, className }: CopyButtonProps) {
} else if (!fallbackCopyText(text)) {
throw new Error("Both clipboard methods failed");
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
armRevertTimer();
} catch (err) {
console.error("Failed to copy to clipboard:", err);
// Try fallback if the modern API threw
try {
if (fallbackCopyText(text)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
armRevertTimer();
}
} catch {
// Both methods failed — do nothing
}
}
}, [text]);
}, [text, armRevertTimer]);

return (
<button
Comment thread
hermes-exosphere marked this conversation as resolved.
type="button"
onClick={handleCopy}
title="Copy to clipboard"
className={cn(
Expand Down