failproofai 0.0.14-beta.0 → 0.0.14-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/.next/standalone/.codex/hooks.json +6 -6
  2. package/.next/standalone/.cursor/hooks.json +6 -6
  3. package/.next/standalone/.factory/hooks.json +9 -9
  4. package/.next/standalone/.next/BUILD_ID +1 -1
  5. package/.next/standalone/.next/build-manifest.json +5 -5
  6. package/.next/standalone/.next/prerender-manifest.json +3 -3
  7. package/.next/standalone/.next/required-server-files.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  9. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  11. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  14. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  16. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  20. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  23. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  24. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  25. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  26. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  27. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  28. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  29. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  30. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/index.html +1 -1
  44. package/.next/standalone/.next/server/app/index.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  48. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  56. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q2nbsl._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0xuaoik._.js +3 -3
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mrihkj._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00cz9as._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__015_i4t._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03lv-pe._.js +2 -2
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__042d3cd._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09q1168._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0brjczq._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h12me5._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hdsupo._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qeuy9c._.js +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s89hoe._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07qfsfa._.js → [root-of-the-server]__0wp9w6i._.js} +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ynf7tx._.js +2 -2
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11rtg6s._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17ai7sy._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1dky4g0._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tkjqka._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__212nf49._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/app_087bt9w._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/app_1fvisnp._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/app_209u41o._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0g5r64e.js → node_modules_html-to-image_es_index_12rjfea.js} +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0-uvagv.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_03c7gi5.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_09p-8om.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0v-kfiu.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0xrgzyz.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1806lsc.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1j6dd-e.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1sa65r-.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +1 -1
  114. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  115. package/.next/standalone/.next/server/pages/404.html +1 -1
  116. package/.next/standalone/.next/server/pages/500.html +1 -1
  117. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  118. package/.next/standalone/.next/server/server-reference-manifest.json +10 -10
  119. package/.next/standalone/.next/static/chunks/{13f1kmjea-0md.js → 02jcotghcxrbu.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/{17sdztgbcc0fi.js → 0d5lypu1zs21r.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{2ufuwhv03yvtb.js → 0emg30_6svycu.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{1374x9bv_s_vg.js → 0o9wpnze1ey8y.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/{454dyizqik6t9.js → 1359o5785rmve.js} +1 -1
  124. package/.next/standalone/.next/static/chunks/{43weu8f59tlix.js → 20tuctw38jbgw.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/{0405htbxay9vd.js → 2ldhnaweu9ew3.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/{003uz088wbmz7.js → 2vav_x0_336ds.js} +1 -1
  127. package/.next/standalone/.next/static/chunks/{0iinhn4417nk1.js → 3-ogr79xqbw0h.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/36j05kb2x323i.css +2 -0
  129. package/.next/standalone/.next/static/chunks/{35-90m9py64d4.js → 3cg7x0joi2b98.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{2o3ql0n5owqsw.js → 3k87p-tijud-4.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{2m_tbl39fcedn.js → 3kmaqcp7rldad.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/{turbopack-0vavqo2d-tlt3.js → turbopack-10hj8t5pf33tu.js} +1 -1
  133. package/.next/standalone/.opencode/plugins/failproofai.mjs +30 -11
  134. package/.next/standalone/app/components/global-error-listeners.tsx +27 -0
  135. package/.next/standalone/ci/cli-integration/Dockerfile +47 -0
  136. package/.next/standalone/ci/cli-integration/README.md +69 -0
  137. package/.next/standalone/ci/cli-integration/canary-policies.mjs +45 -0
  138. package/.next/standalone/ci/cli-integration/capture-tokens.sh +48 -0
  139. package/.next/standalone/ci/cli-integration/inject-tokens.sh +44 -0
  140. package/.next/standalone/ci/cli-integration/install-clis.sh +91 -0
  141. package/.next/standalone/ci/cli-integration/probe-cli.sh +208 -0
  142. package/.next/standalone/ci/cli-integration/report.js +74 -0
  143. package/.next/standalone/ci/cli-integration/run.sh +107 -0
  144. package/.next/standalone/components/reach-developers.tsx +1 -1
  145. package/.next/standalone/docker-hook-sync/Dockerfile +8 -4
  146. package/.next/standalone/lib/error-origin.ts +67 -0
  147. package/.next/standalone/lib/install-check.ts +231 -0
  148. package/.next/standalone/package.json +1 -3
  149. package/.next/standalone/server.js +1 -1
  150. package/README.md +1 -17
  151. package/bin/failproofai.mjs +11 -1
  152. package/dist/cli.mjs +479 -217
  153. package/lib/error-origin.ts +67 -0
  154. package/lib/install-check.ts +231 -0
  155. package/package.json +1 -3
  156. package/scripts/dev-hook.mjs +292 -0
  157. package/scripts/install-diagnosis.mjs +5 -8
  158. package/scripts/install-telemetry.mjs +12 -4
  159. package/scripts/translate-docs/cli.ts +47 -1
  160. package/scripts/translate-docs/mdx-translator.ts +133 -4
  161. package/scripts/translate-docs/readme-translator.ts +13 -5
  162. package/scripts/translate-docs/translator.ts +38 -2
  163. package/scripts/validate-mdx.ts +12 -4
  164. package/src/audit/cli.ts +41 -9
  165. package/src/hooks/tui.ts +1 -1
  166. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ge8e7w._.js +0 -3
  167. package/.next/standalone/.next/static/chunks/3r--yyjkp9_7b.css +0 -2
  168. package/scripts/postinstall.mjs +0 -242
  169. package/scripts/preuninstall.mjs +0 -131
  170. /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → LkN-JjmhKGmL-WklH7WVi}/_buildManifest.js +0 -0
  171. /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → LkN-JjmhKGmL-WklH7WVi}/_clientMiddlewareManifest.js +0 -0
  172. /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → LkN-JjmhKGmL-WklH7WVi}/_ssgManifest.js +0 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Works out whether a browser error actually came from the dashboard.
3
+ *
4
+ * `window`'s "error" and "unhandledrejection" events are page-global: browser
5
+ * extensions inject content scripts into the same page and share the same
6
+ * window, so their failures reach our listeners and get reported as ours. The
7
+ * observed case was MetaMask's "Failed to connect to MetaMask" arriving as a
8
+ * failproofai `unhandled_rejection`. Extensions are user-installed and
9
+ * open-ended, so the noise is unbounded and unrelated to our code.
10
+ *
11
+ * We therefore attribute positively — report an error only when it can be
12
+ * traced to our own origin — rather than maintaining a denylist of extensions
13
+ * we happen to know about. React render errors don't depend on this: the error
14
+ * boundaries (`app/global-error.tsx`, `app/components/error-fallback.tsx`)
15
+ * report `client_error` directly, and React only invokes those for errors thrown
16
+ * inside our own tree.
17
+ */
18
+
19
+ /**
20
+ * Any `<vendor>-extension://` URL — `chrome-extension://`, `moz-extension://`,
21
+ * `safari-web-extension://`, `ms-browser-extension://`. Matching the shared
22
+ * suffix rather than an explicit vendor list means a new browser's scheme is
23
+ * filtered the day it ships.
24
+ */
25
+ const EXTENSION_URL = /\b[\w-]+-extension:\/\//i;
26
+
27
+ export type ErrorOrigin =
28
+ /** Traceable to our own origin — a real dashboard failure. */
29
+ | "app"
30
+ /** Came from a browser extension sharing the page. */
31
+ | "extension"
32
+ /** Not attributable: no stack, or a cross-origin "Script error.". */
33
+ | "unknown";
34
+
35
+ export interface ErrorOriginInput {
36
+ /** `error.stack` / `reason.stack`, when the thrown value was an Error. */
37
+ stack?: string | null;
38
+ /** `ErrorEvent.filename` — the script URL. Absent on rejections. */
39
+ filename?: string | null;
40
+ /** Typically `window.location.origin`. */
41
+ appOrigin: string;
42
+ }
43
+
44
+ /**
45
+ * Classify where an error came from, using whatever location information the
46
+ * browser gave us.
47
+ *
48
+ * Extension frames are checked first: an error whose stack passes through an
49
+ * extension is that extension's problem even if one of our frames appears
50
+ * further down.
51
+ */
52
+ export function classifyErrorOrigin({ stack, filename, appOrigin }: ErrorOriginInput): ErrorOrigin {
53
+ const text = [filename, stack].filter(Boolean).join("\n");
54
+ if (!text.trim()) return "unknown";
55
+ if (EXTENSION_URL.test(text)) return "extension";
56
+ if (appOrigin && text.includes(appOrigin)) return "app";
57
+ return "unknown";
58
+ }
59
+
60
+ /**
61
+ * Whether an error is ours to report. Only positively-attributed errors qualify:
62
+ * `unknown` covers cross-origin "Script error." and stack-less rejections, which
63
+ * are unactionable anyway — there's nothing in them to debug.
64
+ */
65
+ export function isAppError(input: ErrorOriginInput): boolean {
66
+ return classifyErrorOrigin(input) === "app";
67
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Install reporting, driven from the CLI instead of an npm lifecycle script.
3
+ *
4
+ * This used to live in `scripts/postinstall.mjs`. Every package manager now
5
+ * blocks dependency install scripts by default — npm 12 (`allowScripts`), pnpm,
6
+ * bun, and Yarn ≥4.14 — and a publisher cannot opt its own package in, so a
7
+ * postinstall hook is no longer a channel that reaches users. The first non-hook
8
+ * CLI invocation is.
9
+ *
10
+ * Two constraints shape this module:
11
+ *
12
+ * 1. It must never run on the `--hook` fast path in bin/failproofai.mjs. That
13
+ * path fires on every tool call of every agent session; the check belongs
14
+ * in runCli() only.
15
+ * 2. It must be free on the steady-state path. When the recorded version
16
+ * matches, this returns before touching anything else.
17
+ *
18
+ * Everything here is best-effort: telemetry must never break, delay, or fail a
19
+ * real command.
20
+ */
21
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
22
+ import { resolve } from "node:path";
23
+ import { platform, arch, release, homedir, hostname } from "node:os";
24
+ import { createHmac } from "node:crypto";
25
+ import { trackInstallEvent } from "../scripts/install-telemetry.mjs";
26
+
27
+ const FAILPROOFAI_HOOK_MARKER = "__failproofai_hook__";
28
+ const NAMESPACE = "failproofai-telemetry-v1";
29
+
30
+ /**
31
+ * Cap on the once-per-version report so a hanging network never stalls a command.
32
+ *
33
+ * A ceiling, not a cost: delivery resolves as soon as PostHog answers, measured
34
+ * at ~1.1s from a cold process (these events are the first thing a fresh process
35
+ * sends, so they always pay the DNS + TLS handshake). Raising this only matters
36
+ * when the network is slow — precisely when a tight budget would silently drop
37
+ * the report instead.
38
+ *
39
+ * 5s matches hook-telemetry and the postinstall script this replaces. An earlier
40
+ * 2s left barely any headroom over a 1.85s worst case seen in testing, for a
41
+ * saving nobody experiences on a healthy network.
42
+ */
43
+ const REPORT_TIMEOUT_MS = 5000;
44
+
45
+ function hashToId(raw: string): string {
46
+ return createHmac("sha256", NAMESPACE).update(raw).digest("hex");
47
+ }
48
+
49
+ function lastVersionPath(): string {
50
+ return resolve(homedir(), ".failproofai", "last-version");
51
+ }
52
+
53
+ /**
54
+ * Semver ordering. A release outranks the same version carrying a prerelease
55
+ * tag (semver §11); within a prerelease, numeric identifiers rank below
56
+ * non-numeric ones.
57
+ *
58
+ * Prerelease identifiers are separated by dots only (semver §9). A hyphen is a
59
+ * legal character *inside* an identifier, so `beta-2` is one non-numeric
60
+ * identifier compared lexically — not `beta` and `2`.
61
+ */
62
+ export function compareSemver(a: string, b: string): number {
63
+ const parse = (v: string) => {
64
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v);
65
+ if (!m) return null;
66
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null };
67
+ };
68
+ const pa = parse(a);
69
+ const pb = parse(b);
70
+ if (!pa || !pb) return a < b ? -1 : a > b ? 1 : 0;
71
+ for (let i = 0; i < 3; i++) {
72
+ if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] < pb.nums[i] ? -1 : 1;
73
+ }
74
+ if (pa.pre === null && pb.pre === null) return 0;
75
+ if (pa.pre === null) return 1;
76
+ if (pb.pre === null) return -1;
77
+ const ax = pa.pre.split(".");
78
+ const bx = pb.pre.split(".");
79
+ for (let i = 0; i < Math.max(ax.length, bx.length); i++) {
80
+ const ai = ax[i], bi = bx[i];
81
+ if (ai === undefined) return -1;
82
+ if (bi === undefined) return 1;
83
+ const aNum = /^\d+$/.test(ai), bNum = /^\d+$/.test(bi);
84
+ if (aNum && bNum) {
85
+ const d = Number(ai) - Number(bi);
86
+ if (d !== 0) return d < 0 ? -1 : 1;
87
+ } else if (aNum) {
88
+ return -1;
89
+ } else if (bNum) {
90
+ return 1;
91
+ } else if (ai !== bi) {
92
+ return ai < bi ? -1 : 1;
93
+ }
94
+ }
95
+ return 0;
96
+ }
97
+
98
+ export interface HooksState {
99
+ configured: boolean;
100
+ registered: boolean;
101
+ policyCount: number;
102
+ }
103
+
104
+ /**
105
+ * Snapshot of the user's hook configuration, carried as properties on
106
+ * package_installed.
107
+ *
108
+ * Only inspects Claude's settings, which understates `registered` for someone
109
+ * running failproofai against another CLI. Preserved as-is from the postinstall
110
+ * script so the event keeps its historical meaning.
111
+ */
112
+ export function checkHooks(): HooksState {
113
+ const hooksConfigPath = resolve(homedir(), ".failproofai", "policies-config.json");
114
+ if (!existsSync(hooksConfigPath)) {
115
+ return { configured: false, registered: false, policyCount: 0 };
116
+ }
117
+
118
+ let config;
119
+ try {
120
+ config = JSON.parse(readFileSync(hooksConfigPath, "utf8"));
121
+ } catch {
122
+ return { configured: false, registered: false, policyCount: 0 };
123
+ }
124
+
125
+ if (!Array.isArray(config.enabledPolicies) || config.enabledPolicies.length === 0) {
126
+ return { configured: false, registered: false, policyCount: 0 };
127
+ }
128
+
129
+ const policyCount = config.enabledPolicies.length;
130
+ const settingsPath = resolve(homedir(), ".claude", "settings.json");
131
+ if (!existsSync(settingsPath)) return { configured: true, registered: false, policyCount };
132
+
133
+ let settings;
134
+ try {
135
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
136
+ } catch {
137
+ return { configured: true, registered: false, policyCount };
138
+ }
139
+
140
+ if (!settings?.hooks) return { configured: true, registered: false, policyCount };
141
+
142
+ for (const matchers of Object.values(settings.hooks)) {
143
+ if (!Array.isArray(matchers)) continue;
144
+ for (const matcher of matchers) {
145
+ if (!matcher?.hooks) continue;
146
+ if (matcher.hooks.some((h: Record<string, unknown>) => h[FAILPROOFAI_HOOK_MARKER] === true)) {
147
+ return { configured: true, registered: true, policyCount };
148
+ }
149
+ }
150
+ }
151
+
152
+ return { configured: true, registered: false, policyCount };
153
+ }
154
+
155
+ /**
156
+ * Report an install or upgrade, at most once per version.
157
+ *
158
+ * Fires first_install (nothing recorded yet) or version_changed (recorded
159
+ * version differs), then package_installed, then records `version`. A same-version
160
+ * run is a no-op — unlike the postinstall script this replaces, the CLI has no
161
+ * signal that would distinguish a reinstall from an ordinary invocation, so
162
+ * `direction: "reinstall"` is no longer reported.
163
+ */
164
+ export async function maybeReportInstall(version: string): Promise<void> {
165
+ try {
166
+ const versionFile = lastVersionPath();
167
+
168
+ let previousVersion: string | null = null;
169
+ try {
170
+ if (existsSync(versionFile)) {
171
+ previousVersion = readFileSync(versionFile, "utf8").trim() || null;
172
+ }
173
+ } catch {
174
+ // Unreadable marker — treat as first run rather than give up.
175
+ }
176
+
177
+ // Steady state: same version already reported. Costs one stat() per command.
178
+ if (previousVersion === version) return;
179
+
180
+ const hooks = checkHooks();
181
+
182
+ const events: Promise<void>[] = [];
183
+ if (previousVersion === null) {
184
+ events.push(
185
+ trackInstallEvent("first_install", {
186
+ platform: platform(),
187
+ arch: arch(),
188
+ os_release: release(),
189
+ node_version: process.versions.node,
190
+ }, { version, timeoutMs: REPORT_TIMEOUT_MS }),
191
+ );
192
+ } else {
193
+ const cmp = compareSemver(previousVersion, version);
194
+ events.push(
195
+ trackInstallEvent("version_changed", {
196
+ from_version: previousVersion,
197
+ to_version: version,
198
+ direction: cmp < 0 ? "upgrade" : "downgrade",
199
+ platform: platform(),
200
+ arch: arch(),
201
+ }, { version, timeoutMs: REPORT_TIMEOUT_MS }),
202
+ );
203
+ }
204
+
205
+ events.push(
206
+ trackInstallEvent("package_installed", {
207
+ platform: platform(),
208
+ arch: arch(),
209
+ os_release: release(),
210
+ node_version: process.versions.node,
211
+ hostname_hash: hashToId(hostname()),
212
+ hooks_configured: hooks.configured,
213
+ hooks_registered: hooks.registered,
214
+ enabled_policy_count: hooks.policyCount,
215
+ }, { version, timeoutMs: REPORT_TIMEOUT_MS }),
216
+ );
217
+
218
+ // Record before awaiting: a dropped event is cheaper than re-reporting the
219
+ // same version on every command because delivery kept failing.
220
+ try {
221
+ mkdirSync(resolve(homedir(), ".failproofai"), { recursive: true });
222
+ writeFileSync(versionFile, version, "utf8");
223
+ } catch {
224
+ // best-effort
225
+ }
226
+
227
+ await Promise.all(events.map((p) => p.catch(() => {})));
228
+ } catch {
229
+ // Reporting must never break a command.
230
+ }
231
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.14-beta.0",
3
+ "version": "0.0.14-beta.1",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs"
@@ -30,8 +30,6 @@
30
30
  "test": "vitest",
31
31
  "test:run": "vitest run",
32
32
  "lint": "eslint . --config eslint.config.mjs",
33
- "postinstall": "node scripts/postinstall.mjs",
34
- "preuninstall": "node scripts/preuninstall.mjs",
35
33
  "prepare": "bun run build",
36
34
  "test:e2e": "vitest run --config vitest.config.e2e.mts",
37
35
  "test:e2e:watch": "vitest --config vitest.config.e2e.mts",
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * failproofai DEV-ONLY hook launcher — for THIS repo's dogfood configs only.
4
+ *
5
+ * End users never run this. They install `npx -y failproofai --hook <Event>`
6
+ * (see `src/hooks/integrations.ts`), which resolves the published package.
7
+ * This file exists because this repo dogfoods itself from source, and doing so
8
+ * has two prerequisites that silently rot:
9
+ *
10
+ * 1. `bun` on the hook's PATH. `bin/failproofai.mjs` cannot run under node —
11
+ * it does a bare `import { version } from "../package.json"` (node needs
12
+ * `with { type: "json" }`) and `await import("../src/hooks/handler")`,
13
+ * an extensionless TypeScript specifier node cannot resolve. But bun is
14
+ * routinely absent from a hook's PATH even when it's on yours: `npm i -g
15
+ * bun` installs into ONE nvm version's bin dir, so `nvm use <other>` drops
16
+ * it; a macOS GUI launch gets a launchd PATH built without ~/.zshrc.
17
+ * When that happens every hook exits 127 and the session runs with ZERO
18
+ * policy enforcement — silently.
19
+ *
20
+ * 2. `dist/index.js`. The committed policies in `.failproofai/policies/`
21
+ * `import from 'failproofai'`, which `findDistIndex()` resolves to that
22
+ * bundle. Without it all three fail-open and only builtins enforce.
23
+ *
24
+ * So: locate bun (installing it via npm if we must), make sure the bundle
25
+ * exists, then hand off to the real binary. If we cannot enforce, say so in
26
+ * one line rather than failing silently.
27
+ *
28
+ * INVARIANT — never write to stdout. Most CLIs' deny contracts are JSON on
29
+ * stdout (Copilot `{decision:"block"}`, Cursor `{followup_message}`, Devin,
30
+ * Factory-on-Stop, Antigravity). A single stray byte corrupts the parse and
31
+ * turns a denial into an allow. Diagnostics go to stderr; subprocesses we run
32
+ * for our own purposes get their stdout discarded, never inherited.
33
+ */
34
+ import { spawnSync } from "node:child_process";
35
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
36
+ import { constants, homedir } from "node:os";
37
+ import { delimiter, dirname, join, resolve } from "node:path";
38
+ import { fileURLToPath } from "node:url";
39
+
40
+ const HERE = dirname(fileURLToPath(import.meta.url));
41
+ const REPO_ROOT = resolve(HERE, "..");
42
+ const SRC_BIN = resolve(REPO_ROOT, "bin", "failproofai.mjs");
43
+ const BUNDLE = resolve(REPO_ROOT, "dist", "index.js");
44
+ const CACHE_DIR = resolve(REPO_ROOT, "node_modules", ".cache", "failproofai-dev");
45
+ const LOCK_DIR = join(CACHE_DIR, ".lock");
46
+
47
+ /** A lock older than this belonged to a process that died mid-install. */
48
+ const LOCK_STALE_MS = 5 * 60_000;
49
+ /** 120 × 250ms = 30s, under the tightest hook timeout (Factory/Antigravity). */
50
+ const LOCK_WAIT_TRIES = 120;
51
+ const LOCK_WAIT_MS = 250;
52
+
53
+ /**
54
+ * @typedef {object} Deps
55
+ * @property {Record<string, string | undefined>} [env]
56
+ * @property {string} [platform]
57
+ * @property {string} [execPath]
58
+ * @property {string} [home]
59
+ * @property {(p: string) => boolean} [exists]
60
+ * @property {(p: string) => string[]} [readdir]
61
+ */
62
+
63
+ function warn(msg) {
64
+ process.stderr.write(`[failproofai-dev] ${msg}\n`);
65
+ }
66
+
67
+ function sleepSync(ms) {
68
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
69
+ }
70
+
71
+ function readdirSafe(p) {
72
+ try {
73
+ return readdirSync(p);
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /** Executable basenames to probe, most specific first. */
80
+ function exeNames(base, platform) {
81
+ return platform === "win32" ? [`${base}.exe`, `${base}.cmd`, base] : [base];
82
+ }
83
+
84
+ /** First existing `<dir>/<name>` across dirs × names, or null. */
85
+ function probe(dirs, names, exists) {
86
+ for (const dir of dirs) {
87
+ for (const name of names) {
88
+ const candidate = join(dir, name);
89
+ if (exists(candidate)) return candidate;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Absolute path to a bun executable, or null. NEVER spawns a subprocess —
97
+ * this runs on every hook, so it is an existsSync walk and nothing more.
98
+ * @param {Deps} deps
99
+ */
100
+ export function findBun(deps = {}) {
101
+ const env = deps.env ?? process.env;
102
+ const platform = deps.platform ?? process.platform;
103
+ const execPath = deps.execPath ?? process.execPath;
104
+ const home = deps.home ?? homedir();
105
+ const exists = deps.exists ?? existsSync;
106
+ const readdir = deps.readdir ?? readdirSafe;
107
+
108
+ const dirs = [];
109
+ const push = (d) => {
110
+ if (d && !dirs.includes(d)) dirs.push(d);
111
+ };
112
+
113
+ // 1. PATH — the common case, and the only one that costs nothing.
114
+ for (const p of (env.PATH ?? "").split(delimiter)) push(p);
115
+ // 2. An explicit bun install root.
116
+ if (env.BUN_INSTALL) push(join(env.BUN_INSTALL, "bin"));
117
+ // 3. Where bun.sh's installer puts it. The macOS GUI-launch case: launchd's
118
+ // PATH has /usr/local/bin but never ~/.bun/bin.
119
+ push(join(home, ".bun", "bin"));
120
+ // 4. Package-manager prefixes.
121
+ if (platform !== "win32") {
122
+ push("/opt/homebrew/bin");
123
+ push("/usr/local/bin");
124
+ }
125
+ // 5. Every nvm-managed node version's bin dir. Load-bearing: `npm i -g bun`
126
+ // lands bun in exactly ONE version's bin, so `nvm use <another>` makes it
127
+ // vanish from PATH entirely while it's still perfectly usable on disk.
128
+ // Descending so the newest version wins deterministically.
129
+ const nvmRoot = join(home, ".nvm", "versions", "node");
130
+ for (const v of readdir(nvmRoot).sort().reverse()) push(join(nvmRoot, v, "bin"));
131
+ // 6. Next to the running node. For our own `node scripts/dev-hook.mjs` form
132
+ // this is a strict subset of (1) — node was resolved from PATH — so it is
133
+ // a no-op. Kept only for a caller that spawns node by absolute path.
134
+ push(dirname(execPath));
135
+
136
+ // Cache from a previous npm-install (see installBun). Last, so a real bun
137
+ // that reappears on PATH always wins over our vendored copy.
138
+ push(join(CACHE_DIR, "node_modules", ".bin"));
139
+
140
+ return probe(dirs, exeNames("bun", platform), exists);
141
+ }
142
+
143
+ /** Absolute path to npm, or null. PATH only — npm ships beside node. */
144
+ export function findNpm(deps = {}) {
145
+ const env = deps.env ?? process.env;
146
+ const platform = deps.platform ?? process.platform;
147
+ const exists = deps.exists ?? existsSync;
148
+ const dirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
149
+ return probe(dirs, exeNames("npm", platform), exists);
150
+ }
151
+
152
+ /**
153
+ * `{ cmd, args }` to run the real binary, or null when bun is unfindable.
154
+ * Shared with `.opencode/plugins/failproofai.mjs`, which spawns bun itself.
155
+ * @param {Deps} deps
156
+ */
157
+ export function resolveDevSpawn(deps = {}) {
158
+ const bun = findBun(deps);
159
+ return bun ? { cmd: bun, args: [SRC_BIN] } : null;
160
+ }
161
+
162
+ /** One actionable line. Keep it to one — this can fire on 26 events. */
163
+ export function bunMissingMessage() {
164
+ return (
165
+ "bun not found and npm unavailable to install it — this repo's dogfood hooks are NOT " +
166
+ "enforcing. Install bun: curl -fsSL https://bun.sh/install | bash"
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Run `doWork` at most once across concurrently-firing hooks. `mkdirSync` is
172
+ * atomic, so it doubles as the lock: EEXIST means another process got there.
173
+ * Losers wait for the artifact rather than duplicating the work.
174
+ */
175
+ function ensureOnce(isDone, doWork) {
176
+ if (isDone()) return true;
177
+ mkdirSync(CACHE_DIR, { recursive: true });
178
+
179
+ for (let i = 0; i < LOCK_WAIT_TRIES; i++) {
180
+ if (isDone()) return true;
181
+ try {
182
+ mkdirSync(LOCK_DIR);
183
+ } catch (err) {
184
+ if (err?.code !== "EEXIST") throw err;
185
+ try {
186
+ if (Date.now() - statSync(LOCK_DIR).mtimeMs > LOCK_STALE_MS) {
187
+ rmSync(LOCK_DIR, { recursive: true, force: true });
188
+ } else {
189
+ sleepSync(LOCK_WAIT_MS);
190
+ }
191
+ } catch {
192
+ /* lock vanished under us — retry immediately */
193
+ }
194
+ continue;
195
+ }
196
+ try {
197
+ doWork();
198
+ } finally {
199
+ try {
200
+ rmSync(LOCK_DIR, { recursive: true, force: true });
201
+ } catch {
202
+ /* best effort; a leaked lock goes stale in LOCK_STALE_MS */
203
+ }
204
+ }
205
+ return isDone();
206
+ }
207
+ return isDone();
208
+ }
209
+
210
+ /**
211
+ * Vendor bun into node_modules/.cache via npm. bun ships as an npm package, so
212
+ * npm is the one bootstrap that works when bun is genuinely absent — building
213
+ * `dist/cli.mjs` to run under node can't, because `build:cli` is itself
214
+ * `bun build`.
215
+ */
216
+ function installBun(deps = {}) {
217
+ const npm = findNpm(deps);
218
+ if (!npm) return null;
219
+ ensureOnce(
220
+ () => findBun(deps) !== null,
221
+ () => {
222
+ warn("bun not found — installing it into node_modules/.cache (one time, ~15s)");
223
+ const r = spawnSync(
224
+ npm,
225
+ ["install", "bun", "--prefix", CACHE_DIR, "--no-save", "--no-audit", "--no-fund", "--loglevel=error"],
226
+ // NOT "inherit" on stdout: npm prints there, and our stdout is a deny
227
+ // channel. stderr passes through so a failed install is visible.
228
+ { stdio: ["ignore", "ignore", "inherit"], cwd: REPO_ROOT },
229
+ );
230
+ if (r.status !== 0) warn("npm install bun failed — see the error above");
231
+ },
232
+ );
233
+ return findBun(deps);
234
+ }
235
+
236
+ /**
237
+ * Build dist/index.js if absent, so `.failproofai/policies/*.mjs` can resolve
238
+ * `import ... from 'failproofai'`. Non-fatal: builtins enforce without it.
239
+ */
240
+ export function ensureBundle(bunPath) {
241
+ return ensureOnce(
242
+ () => existsSync(BUNDLE),
243
+ () => {
244
+ warn("dist/index.js missing — building it so .failproofai/policies/ can load (one time)");
245
+ const r = spawnSync(
246
+ bunPath,
247
+ ["build", "--target=node", "--format=cjs", "--outfile=dist/index.js", "src/index.ts"],
248
+ { stdio: ["ignore", "ignore", "inherit"], cwd: REPO_ROOT },
249
+ );
250
+ if (r.status !== 0) warn("build failed — policies in .failproofai/policies/ will NOT load");
251
+ },
252
+ );
253
+ }
254
+
255
+ function main() {
256
+ const bun = findBun() ?? installBun();
257
+ if (!bun) {
258
+ warn(bunMissingMessage());
259
+ process.exit(1);
260
+ }
261
+ ensureBundle(bun);
262
+
263
+ // stdio:"inherit" hands the child our actual fds: the payload on stdin and
264
+ // any decision JSON on stdout flow byte-exact, unbuffered, unparsed by us.
265
+ // No cwd override — the child must see whatever cwd the agent CLI chose,
266
+ // exactly as it did when the config spawned bun directly.
267
+ const r = spawnSync(bun, [SRC_BIN, ...process.argv.slice(2)], { stdio: "inherit" });
268
+
269
+ // Exit codes are a contract, not a status: 2 means DENY on Claude/Factory/
270
+ // Goose. Propagate verbatim. Never synthesize 2 (would deny) or 0 (would
271
+ // silently allow).
272
+ if (r.error) {
273
+ warn(`could not run bun (${r.error.code ?? r.error.message}) — hooks are NOT enforcing`);
274
+ process.exit(1);
275
+ }
276
+ if (r.status === null && r.signal) {
277
+ // Mirror what `sh` reports for a signalled child, which is what the agent
278
+ // CLI saw before this launcher sat in between.
279
+ process.exit(128 + (constants.signals[r.signal] ?? 15));
280
+ }
281
+ process.exit(r.status ?? 1);
282
+ }
283
+
284
+ // Only run when executed, not when `.opencode/plugins/failproofai.mjs` imports
285
+ // the resolver. realpath so a symlinked or relative argv[1] still matches.
286
+ let invokedDirectly = false;
287
+ try {
288
+ invokedDirectly = realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
289
+ } catch {
290
+ invokedDirectly = false;
291
+ }
292
+ if (invokedDirectly) main();
@@ -4,11 +4,9 @@
4
4
  * a `bun install -g failproofai` whose prefix sorts ahead of npm's on PATH.
5
5
  *
6
6
  * Used by:
7
- * - scripts/postinstall.mjswarn at install time so the customer never sees
8
- * the misleading "missing build output" runtime error.
9
- * - scripts/launch.ts — when .next/standalone/server.js is missing,
10
- * produce a shadow-shaped error if the cause is a shadow rather than a
11
- * genuinely broken build.
7
+ * - scripts/launch.tswhen .next/standalone/server.js is missing, produce a
8
+ * shadow-shaped error if the cause is a shadow rather than a genuinely
9
+ * broken build.
12
10
  *
13
11
  * Pure Node.js built-ins, no external dependencies. Every probe is wrapped in
14
12
  * try/catch — diagnoseShadow() is guaranteed not to throw.
@@ -125,9 +123,8 @@ function buildRecommendation(pathFirstBin) {
125
123
  *
126
124
  * @param {{ selfPackageRoot: string, selfVersion: string | null }} self
127
125
  * The package root and version of the binary calling diagnoseShadow().
128
- * Callers (bin/failproofai.mjs, scripts/postinstall.mjs) already have these
129
- * values; passing them in keeps the helper deterministic and free of
130
- * import.meta.url assumptions.
126
+ * Callers (scripts/launch.ts) already have these values; passing them in keeps
127
+ * the helper deterministic and free of import.meta.url assumptions.
131
128
  */
132
129
  export function diagnoseShadow(self) {
133
130
  const selfPackageRoot = (() => {
@@ -80,12 +80,20 @@ function getInstanceId() {
80
80
 
81
81
  /**
82
82
  * Track a named event to PostHog. No-op when telemetry is disabled.
83
- * Uses process.env.npm_package_version (set automatically by npm in lifecycle scripts).
83
+ *
84
+ * `opts.version` must be supplied by callers outside an npm lifecycle script:
85
+ * npm only sets npm_package_version while running one, and these events now
86
+ * fire from the CLI (see lib/install-check.ts) because package managers block
87
+ * install scripts by default.
88
+ *
89
+ * @param {string} event
90
+ * @param {Record<string, unknown>} [properties]
91
+ * @param {{ version?: string, timeoutMs?: number }} [opts]
84
92
  */
85
- export async function trackInstallEvent(event, properties = {}) {
93
+ export async function trackInstallEvent(event, properties = {}, opts = {}) {
86
94
  if (process.env.FAILPROOFAI_TELEMETRY_DISABLED === "1") return;
87
95
 
88
- const version = process.env.npm_package_version ?? "unknown";
96
+ const version = opts.version ?? process.env.npm_package_version ?? "unknown";
89
97
  const body = JSON.stringify({
90
98
  api_key: process.env.FAILPROOFAI_POSTHOG_KEY ?? API_KEY,
91
99
  event,
@@ -106,7 +114,7 @@ export async function trackInstallEvent(event, properties = {}) {
106
114
  method: "POST",
107
115
  headers: { "Content-Type": "application/json" },
108
116
  body,
109
- signal: AbortSignal.timeout(5000),
117
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 5000),
110
118
  }
111
119
  );
112
120
  }