failproofai 1.0.0-beta.2 → 1.0.0-beta.21

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 (381) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +2 -1
  3. package/.next/standalone/.next/build-manifest.json +6 -6
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/required-server-files.json +1 -1
  6. package/.next/standalone/.next/routes-manifest.json +6 -0
  7. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  8. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page.js +3 -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 +3 -3
  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 +3 -1
  22. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  24. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  25. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  26. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  27. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  28. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  29. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  30. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  31. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
  33. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -3
  35. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/audit/status/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +3 -1
  39. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +3 -2
  41. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -2
  43. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +3 -2
  45. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/auth/status/route.js +3 -2
  47. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  49. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/audit/page.js +5 -3
  53. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/index.html +1 -1
  56. package/.next/standalone/.next/server/app/index.rsc +15 -15
  57. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  58. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  59. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  60. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  61. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  62. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  63. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/page.js +3 -1
  65. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  68. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  69. package/.next/standalone/.next/server/app/policies/page.js +5 -3
  70. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  73. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  74. package/.next/standalone/.next/server/app/project/[name]/page.js +4 -2
  75. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  76. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  77. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  78. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  79. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  80. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -4
  81. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  82. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  83. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  84. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  85. package/.next/standalone/.next/server/app/projects/page.js +4 -2
  86. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  87. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/app/settings/page/app-paths-manifest.json +3 -0
  89. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +18 -0
  90. package/.next/standalone/.next/server/app/settings/page/next-font-manifest.json +6 -0
  91. package/.next/standalone/.next/server/app/settings/page/react-loadable-manifest.json +1 -0
  92. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +53 -0
  93. package/.next/standalone/.next/server/app/settings/page.js +18 -0
  94. package/.next/standalone/.next/server/app/settings/page.js.map +5 -0
  95. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -0
  96. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +3 -0
  97. package/.next/standalone/.next/server/app-paths-manifest.json +2 -1
  98. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  99. package/.next/standalone/.next/server/chunks/{[externals]__1nl3dvw._.js → [externals]__0r-zamh._.js} +2 -2
  100. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0l4vuqm._.js → [root-of-the-server]__00ni9za._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +3 -0
  103. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08y94xv._.js +3 -0
  104. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +3 -0
  105. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ed01_d._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0-dh5jy._.js → [root-of-the-server]__0effm9m._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g0vcwt._.js +3 -0
  109. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__18d-9oi._.js → [root-of-the-server]__0ix8gm7._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0h3lfmp._.js → [root-of-the-server]__0ji11a-._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__10oa-v9._.js → [root-of-the-server]__0ojwjxl._.js} +2 -2
  112. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__0pbp9fe._.js} +2 -2
  113. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vb5fl2._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0sb_5m8._.js → [root-of-the-server]__131ebum._.js} +4 -4
  116. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +3 -0
  117. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/[root-of-the-server]__20d06rh._.js +3 -0
  119. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +3 -0
  120. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +3 -0
  121. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +3 -0
  122. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +5 -5
  123. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  124. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  125. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  127. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-wfhrp._.js +3 -0
  128. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06bp_fg._.js +5 -0
  129. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__074qmkp._.js +3 -0
  130. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0_1tiya._.js} +2 -2
  131. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  132. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t281d4._.js +3 -0
  133. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17itmx-._.js +5 -0
  134. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__1n2vcv4._.js} +2 -2
  135. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  136. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +3 -0
  137. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tlvv9a._.js +3 -0
  138. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +4 -0
  139. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1v185mo._.js +3 -0
  140. package/.next/standalone/.next/server/chunks/ssr/_0-yi74u._.js +3 -0
  141. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +3 -0
  142. package/.next/standalone/.next/server/chunks/ssr/_0vo2qyt._.js +3 -0
  143. package/.next/standalone/.next/server/chunks/ssr/_0xej-0e._.js +3 -0
  144. package/.next/standalone/.next/server/chunks/ssr/_10399ge._.js +3 -0
  145. package/.next/standalone/.next/server/chunks/ssr/_11_p9y8._.js +1 -1
  146. package/.next/standalone/.next/server/chunks/ssr/_149k9oy._.js +3 -0
  147. package/.next/standalone/.next/server/chunks/ssr/_1_hwp06._.js +3 -0
  148. package/.next/standalone/.next/server/chunks/ssr/_1ctse6u._.js +3 -0
  149. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  150. package/.next/standalone/.next/server/chunks/ssr/_1mnhi_r._.js +3 -0
  151. package/.next/standalone/.next/server/chunks/ssr/_1ror7k9._.js +3 -0
  152. package/.next/standalone/.next/server/chunks/ssr/_1sdgprl._.js +5 -0
  153. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  154. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  155. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  156. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dwo94s.js +4 -0
  157. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  158. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +3 -0
  159. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
  160. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  161. package/.next/standalone/.next/server/middleware.js +2 -2
  162. package/.next/standalone/.next/server/pages/404.html +1 -1
  163. package/.next/standalone/.next/server/pages/500.html +1 -1
  164. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  165. package/.next/standalone/.next/server/server-reference-manifest.json +74 -20
  166. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  167. package/.next/standalone/.next/static/chunks/0km-jn9o5xyqs.css +2 -0
  168. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  169. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 113g8wtal6_c4.js} +22 -22
  170. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  171. package/.next/standalone/.next/static/chunks/18wqn0_3mzylf.js +1 -0
  172. package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +1 -0
  173. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 1xq1m8h14tnlj.js} +1 -1
  174. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 1y3vwogqb88dx.js} +1 -1
  175. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 1zmh-b0ulrqu7.js} +1 -1
  176. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  177. package/.next/standalone/.next/static/chunks/299lhw60e8gr6.js +1 -0
  178. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 2adbpdaxp5ygq.js} +1 -1
  179. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 2kbqcw-6swmav.js} +1 -1
  180. package/.next/standalone/.next/static/chunks/2n9n5m0963pdg.js +6 -0
  181. package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +2 -0
  182. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 30nrrpwymht5m.js} +1 -1
  183. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  184. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  185. package/.next/standalone/SECURITY.md +8 -2
  186. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  187. package/.next/standalone/app/actions/get-hooks-config.ts +2 -2
  188. package/.next/standalone/app/actions/get-scheduled-audit.ts +70 -0
  189. package/.next/standalone/app/actions/update-scheduled-audit.ts +44 -0
  190. package/.next/standalone/app/api/audit/run/route.ts +28 -0
  191. package/.next/standalone/app/api/audit/status/route.ts +13 -1
  192. package/.next/standalone/app/api/auth/status/route.ts +1 -1
  193. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +1 -3
  194. package/.next/standalone/app/audit/_components/audit-progress-strip.tsx +1 -1
  195. package/.next/standalone/app/audit/_components/auth-dialog.css +159 -0
  196. package/.next/standalone/app/audit/_components/auth-dialog.tsx +3 -0
  197. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +1 -1
  198. package/.next/standalone/app/audit/_components/empty-state.tsx +0 -1
  199. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +1 -1
  200. package/.next/standalone/app/audit/_components/quirks-section.tsx +0 -1
  201. package/.next/standalone/app/audit/_components/report-footer.tsx +0 -2
  202. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  203. package/.next/standalone/app/audit/_components/strengths-section.tsx +0 -1
  204. package/.next/standalone/app/audit/audit-styles.css +6 -149
  205. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  206. package/.next/standalone/app/components/raw-log-viewer.tsx +1 -1
  207. package/.next/standalone/app/components/toast.tsx +1 -1
  208. package/.next/standalone/app/policies/hooks-client.tsx +84 -8
  209. package/.next/standalone/app/settings/page.tsx +31 -0
  210. package/.next/standalone/app/settings/settings-client.tsx +487 -0
  211. package/.next/standalone/components/navbar.tsx +2 -0
  212. package/.next/standalone/lib/auth/api-server-client.ts +2 -3
  213. package/.next/standalone/lib/auth/auth-store.ts +4 -5
  214. package/.next/standalone/lib/codex-sessions.ts +2 -1
  215. package/.next/standalone/lib/dashboard-host.ts +101 -0
  216. package/.next/standalone/lib/install-check.ts +22 -7
  217. package/.next/standalone/lib/pi-sessions.ts +99 -14
  218. package/.next/standalone/lib/telemetry-enabled.ts +54 -0
  219. package/.next/standalone/lib/telemetry-id.ts +52 -1
  220. package/.next/standalone/lib/telemetry.ts +8 -4
  221. package/.next/standalone/node_modules/react/cjs/react.development.js +1 -1
  222. package/.next/standalone/node_modules/react/cjs/react.production.js +1 -1
  223. package/.next/standalone/node_modules/react/package.json +3 -3
  224. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.browser.production.js +1 -1
  225. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.node.production.js +1 -1
  226. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.browser.production.js +3 -3
  227. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.edge.production.js +3 -3
  228. package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.node.production.js +3 -3
  229. package/.next/standalone/node_modules/react-dom/cjs/react-dom.production.js +1 -1
  230. package/.next/standalone/node_modules/react-dom/package.json +4 -4
  231. package/.next/standalone/package.json +23 -12
  232. package/.next/standalone/proxy.ts +111 -1
  233. package/.next/standalone/server.js +1 -1
  234. package/bin/failproofai-worker.mjs +1 -1
  235. package/bin/failproofai.mjs +1041 -81
  236. package/bin/failproofaid-shim.mjs +4 -4
  237. package/dist/cli.mjs +18513 -10474
  238. package/dist/worker.mjs +1223 -299
  239. package/lib/auth/api-server-client.ts +2 -3
  240. package/lib/auth/auth-store.ts +4 -5
  241. package/lib/codex-sessions.ts +2 -1
  242. package/lib/dashboard-host.ts +101 -0
  243. package/lib/install-check.ts +22 -7
  244. package/lib/pi-sessions.ts +99 -14
  245. package/lib/telemetry-enabled.ts +54 -0
  246. package/lib/telemetry-id.ts +52 -1
  247. package/lib/telemetry.ts +8 -4
  248. package/package.json +23 -12
  249. package/scripts/build-daemon-packages.mjs +247 -0
  250. package/scripts/daemon-platforms.mjs +42 -0
  251. package/scripts/dev-hook.mjs +46 -5
  252. package/scripts/install-telemetry.mjs +8 -1
  253. package/scripts/launch.ts +25 -3
  254. package/scripts/parse-script-args.ts +27 -1
  255. package/scripts/prune-standalone.mjs +26 -0
  256. package/scripts/publish-aliases.mjs +62 -41
  257. package/scripts/repro-npm-install.sh +155 -0
  258. package/scripts/translate-docs/cache.ts +1 -1
  259. package/scripts/translate-docs/cli.ts +2 -2
  260. package/scripts/translate-docs/mintlify-nav.ts +1 -1
  261. package/scripts/translate-docs/readme-translator.ts +163 -4
  262. package/scripts/validate-mdx.ts +153 -13
  263. package/src/audit/audit-lock.ts +285 -0
  264. package/src/audit/audit-schedule.ts +111 -0
  265. package/src/audit/cache.ts +18 -5
  266. package/src/audit/cli-adapters/goose.ts +2 -1
  267. package/src/audit/cli-adapters/hermes.ts +28 -10
  268. package/src/audit/cli.ts +191 -35
  269. package/src/audit/dashboard-cache.ts +2 -3
  270. package/src/audit/report.ts +0 -1
  271. package/src/hooks/backfill-cli.ts +247 -0
  272. package/src/hooks/builtin-policies.ts +391 -3
  273. package/src/hooks/cloud-connection.ts +354 -0
  274. package/src/hooks/cloud-enrollment-cli.ts +439 -0
  275. package/src/hooks/cloud-enrollment.ts +284 -0
  276. package/src/hooks/cloud-introspect.ts +147 -0
  277. package/src/hooks/cloud-managed-policies.ts +206 -0
  278. package/src/hooks/collector-config.ts +218 -0
  279. package/src/hooks/configure-wizard.ts +1211 -233
  280. package/src/hooks/custom-hooks-loader.ts +243 -37
  281. package/src/hooks/daemon-client.ts +136 -33
  282. package/src/hooks/daemon-download.ts +264 -31
  283. package/src/hooks/daemon-service.ts +1124 -76
  284. package/src/hooks/first-run-gate.ts +50 -0
  285. package/src/hooks/flush-cli.ts +180 -0
  286. package/src/hooks/fp-config.ts +835 -0
  287. package/src/hooks/fp-home.ts +714 -0
  288. package/src/hooks/fp-reset.ts +1363 -0
  289. package/src/hooks/handler.ts +185 -6
  290. package/src/hooks/harness-cli.ts +386 -0
  291. package/src/hooks/hook-activity-store.ts +171 -24
  292. package/src/hooks/hook-logger.ts +2 -2
  293. package/src/hooks/hook-telemetry.ts +5 -1
  294. package/src/hooks/hooks-config.ts +4 -3
  295. package/src/hooks/integrations.ts +0 -1
  296. package/src/hooks/loader-utils.ts +172 -9
  297. package/src/hooks/manager.ts +41 -5
  298. package/src/hooks/migrations.ts +450 -0
  299. package/src/hooks/onboarding-attempt.ts +195 -0
  300. package/src/hooks/onboarding-lock.ts +118 -0
  301. package/src/hooks/policy-types.ts +28 -0
  302. package/src/hooks/session-pause-cli.ts +159 -0
  303. package/src/hooks/session-pause.ts +267 -0
  304. package/src/hooks/setup-state.ts +256 -0
  305. package/src/hooks/tui.ts +209 -9
  306. package/src/hooks/uninstall-cli.ts +418 -0
  307. package/src/hooks/worker-server.ts +134 -15
  308. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  309. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  310. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  311. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0longh-._.js +0 -3
  312. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +0 -3
  313. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +0 -3
  314. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +0 -3
  315. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +0 -3
  316. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1rhmvod._.js +0 -3
  317. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  318. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0114ewg._.js +0 -4
  319. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +0 -4
  320. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +0 -4
  321. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +0 -4
  322. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0nb0syg._.js +0 -3
  323. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +0 -4
  324. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +0 -4
  325. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0uno39i._.js +0 -3
  326. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +0 -3
  327. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +0 -4
  328. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1psjybo._.js +0 -3
  329. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +0 -4
  330. package/.next/standalone/.next/server/chunks/ssr/_1m_p7pi._.js +0 -3
  331. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_0oy_-36.js +0 -3
  332. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +0 -5
  333. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +0 -5
  334. package/.next/standalone/.next/static/chunks/0h7auy7hzjyhw.js +0 -1
  335. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  336. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  337. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  338. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  339. package/.next/standalone/.next/static/chunks/3i27c3hcriawq.css +0 -1
  340. package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
  341. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  342. package/.next/standalone/Cargo.lock +0 -428
  343. package/.next/standalone/Cargo.toml +0 -9
  344. package/.next/standalone/crates/PROTOCOL.md +0 -122
  345. package/.next/standalone/crates/failproofaid/Cargo.toml +0 -18
  346. package/.next/standalone/crates/failproofaid/src/lock.rs +0 -81
  347. package/.next/standalone/crates/failproofaid/src/main.rs +0 -83
  348. package/.next/standalone/crates/failproofaid/src/paths.rs +0 -172
  349. package/.next/standalone/crates/failproofaid/src/server.rs +0 -502
  350. package/.next/standalone/crates/failproofaid/src/worker.rs +0 -400
  351. package/.next/standalone/crates/failproofaid/tests/daemon_e2e.rs +0 -241
  352. package/.next/standalone/crates/fpai-ipc/Cargo.toml +0 -16
  353. package/.next/standalone/crates/fpai-ipc/src/envelope.rs +0 -175
  354. package/.next/standalone/crates/fpai-ipc/src/framing.rs +0 -177
  355. package/.next/standalone/crates/fpai-ipc/src/lib.rs +0 -10
  356. package/.next/standalone/crates/fpai-ipc/src/peer.rs +0 -80
  357. package/.next/standalone/docker-hook-sync/Dockerfile +0 -79
  358. package/.next/standalone/docker-hook-sync/entrypoint.sh +0 -109
  359. package/.next/standalone/integration-suite/Dockerfile +0 -47
  360. package/.next/standalone/integration-suite/README.md +0 -129
  361. package/.next/standalone/integration-suite/canary-policies.mjs +0 -45
  362. package/.next/standalone/integration-suite/capture-tokens.sh +0 -48
  363. package/.next/standalone/integration-suite/ci-entrypoint.sh +0 -179
  364. package/.next/standalone/integration-suite/inject-tokens.sh +0 -44
  365. package/.next/standalone/integration-suite/install-clis.sh +0 -136
  366. package/.next/standalone/integration-suite/probe-cli.sh +0 -248
  367. package/.next/standalone/integration-suite/report.js +0 -120
  368. package/.next/standalone/integration-suite/run.sh +0 -159
  369. package/.next/standalone/openclaw-plugin/index.js +0 -223
  370. package/.next/standalone/openclaw-plugin/openclaw.plugin.json +0 -6
  371. package/.next/standalone/openclaw-plugin/package.json +0 -12
  372. package/.next/standalone/osv-scanner.toml +0 -17
  373. package/.next/standalone/pi-extension/index.ts +0 -524
  374. package/.next/standalone/pi-extension/package.json +0 -12
  375. package/.next/standalone/postcss.config.mjs +0 -8
  376. package/.next/standalone/readme-arch-hq.gif +0 -0
  377. package/.next/standalone/rust-toolchain.toml +0 -4
  378. package/src/auth/cli.ts +0 -371
  379. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_buildManifest.js +0 -0
  380. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_clientMiddlewareManifest.js +0 -0
  381. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → VjEdIL6W5uBsvEzknXqV9}/_ssgManifest.js +0 -0
@@ -1,24 +1,40 @@
1
1
  /**
2
2
  * `failproofai config` — the interactive setup launcher.
3
3
  *
4
- * A single guided flow that sets up the whole failproofai ecosystem, hiding the
5
- * scope / cli / two-layer machinery behind three plain questions:
6
- * 1. Where? global (user) vs this project
7
- * 2. Assistants? multi-select of agent CLIs (detected + install-ahead)
8
- * 3. Policies? multi-select of themed presets (combine any) or Everything
9
- * …then a Review screen that shows exactly which files change, and Apply.
4
+ * A single guided flow that sets up the whole failproofai ecosystem:
5
+ *
6
+ * 0. Daemon — REQUIRED. Asks for sudo first, on a clean terminal.
7
+ * 1. Where — inferred from cwd, then confirmed (global / project / both)
8
+ * 2. Policiesmulti-select of themed presets (combine any) or Everything
9
+ * 3. Assistants— multi-select of agent CLIs (detected + install-ahead)
10
+ * 4. Connect — paste an API key, or stay fully local
11
+ * 5. Review — shows exactly which files change, then Apply.
10
12
  *
11
13
  * Selections REPLACE the enabled set at the chosen scope (the picker pre-checks
12
14
  * whatever is already enabled, so unticking removes). Reuses the tested
13
15
  * install/uninstall manager and the existing searchable policy picker.
16
+ *
17
+ * ## Two ordering rules that are not cosmetic
18
+ *
19
+ * **The daemon comes first because it is the only step that needs a password.**
20
+ * `sudo -v` must prompt on a clean terminal, before any TUI frame is drawn —
21
+ * fired from underneath a rendered screen the prompt is invisible and the typed
22
+ * password lands in a redrawn frame.
23
+ *
24
+ * **The daemon is INSTALLED first, before any user config is written.** Setup
25
+ * requires it, so a failure has to leave the machine exactly as it was found
26
+ * rather than half-configured. Writing hooks first and discovering the service
27
+ * will not start afterwards is the one ordering that cannot be undone cleanly.
14
28
  */
15
29
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
16
30
  import { homedir } from "node:os";
17
- import { resolve, sep } from "node:path";
31
+ import { dirname, resolve, sep } from "node:path";
18
32
 
19
33
  import {
20
34
  selectOne,
21
35
  multiSelect,
36
+ BACK,
37
+ promptText,
22
38
  intro,
23
39
  outro,
24
40
  summarize,
@@ -26,6 +42,10 @@ import {
26
42
  type TTYIn,
27
43
  type TTYOut,
28
44
  } from "./tui";
45
+ import {
46
+ DEFAULT_INGEST_URL,
47
+ validateIngestKey,
48
+ } from "./collector-config";
29
49
  import {
30
50
  detectInstalledClis,
31
51
  getIntegration,
@@ -34,30 +54,89 @@ import {
34
54
  } from "./integrations";
35
55
  import { INTEGRATION_TYPES, type IntegrationType, type HookScope } from "./types";
36
56
  import { installHooks } from "./manager";
37
- import { getConfigPathForScope, readHooksConfig } from "./hooks-config";
57
+ import { getConfigPathForScope, readHooksConfig, readScopedHooksConfig } from "./hooks-config";
38
58
  import { POLICY_PRESETS, resolvePreset, resolveEverything } from "./policy-presets";
39
59
  import { discoverPolicyFiles, findSkippedPolicyFiles } from "./custom-hooks-loader";
40
60
  import { trackHookEvent } from "./hook-telemetry";
41
61
  import { getInstanceId } from "../../lib/telemetry-id";
42
62
  import {
63
+ canElevate,
43
64
  isDaemonSupportedPlatform,
44
65
  installDaemonService,
45
66
  daemonServiceFilePath,
67
+ daemonServiceStatus,
68
+ daemonServiceNeedsUpgrade,
69
+ daemonStatusCommand,
70
+ ensureDaemonServiceCurrent,
71
+ daemonVersionSkew,
46
72
  primeElevation,
47
73
  setDaemonConfigured,
74
+ probeDaemon,
75
+ probeDaemonEndToEnd,
76
+ uninstallDaemonService,
48
77
  } from "./daemon-service";
49
78
  import { hookLogWarn } from "./hook-logger";
79
+ import {
80
+ readCloudCredentials,
81
+ resolveMachineId,
82
+ resolveMachineLabel,
83
+ validateCloudUrl,
84
+ } from "./cloud-enrollment";
85
+ import {
86
+ cloudBaseFor,
87
+ ingestUrlFor,
88
+ connectToCloud,
89
+ describeOutcome,
90
+ } from "./cloud-connection";
91
+ import {
92
+ detectSetupState,
93
+ isConfigured,
94
+ buildTargetChoices,
95
+ scopesFor,
96
+ type SetupTarget,
97
+ } from "./setup-state";
98
+ import { customPoliciesDir, launcherMarker } from "./fp-home";
99
+ import { pruneOldDaemonBinaries } from "./daemon-download";
100
+ import { version as cliVersion } from "../../package.json";
101
+ import {
102
+ attemptHintLines,
103
+ blockerCleared,
104
+ clearOnboardingAttempt,
105
+ readOnboardingAttempt,
106
+ recordOnboardingAttempt,
107
+ type RetryProbe,
108
+ } from "./onboarding-attempt";
109
+ import { acquireOnboardingLock } from "./onboarding-lock";
50
110
 
51
111
  export interface WizardIO {
52
112
  stdin?: TTYIn;
53
113
  stdout?: TTYOut;
54
114
  }
55
115
 
116
+ /**
117
+ * Why a wizard run ended without applying. Distinguished so the caller can
118
+ * pick an exit code — a user who pressed Esc did nothing wrong (exit 0), a
119
+ * machine that could not install the required daemon did not get set up
120
+ * (exit 1), and a fleet script needs to tell those apart.
121
+ */
122
+ export type WizardAbort =
123
+ | "cancelled"
124
+ | "needs_root"
125
+ | "daemon_failed"
126
+ | "unsupported_platform"
127
+ | "not_a_tty"
128
+ | "running_as_sudo";
129
+
56
130
  export interface WizardResult {
57
131
  applied: boolean;
58
- scope?: HookScope;
132
+ /** Present only when `applied` is false. */
133
+ abort?: WizardAbort;
134
+ target?: SetupTarget;
135
+ scopes?: HookScope[];
59
136
  clis?: IntegrationType[];
60
137
  policies?: string[];
138
+ daemonInstalled?: boolean;
139
+ connected?: boolean;
61
140
  }
62
141
 
63
142
  async function emit(event: string, props: Record<string, unknown>): Promise<void> {
@@ -80,21 +159,6 @@ function homeify(p: string): string {
80
159
 
81
160
  // ── Pure builders (exported for tests) ───────────────────────────────────────
82
161
 
83
- export function buildScopeChoices(cwd: string) {
84
- return [
85
- {
86
- label: "Everywhere I code",
87
- value: "user" as HookScope,
88
- hint: "global · applies in every project on this machine",
89
- },
90
- {
91
- label: "Just this project",
92
- value: "project" as HookScope,
93
- hint: homeify(cwd),
94
- },
95
- ];
96
- }
97
-
98
162
  /** The CLIs that can actually be configured at `scope`. Hermes and OpenClaw
99
163
  * are gateways with no project-level config, so they are user-scope only. */
100
164
  export function clisSupportingScope(scope: HookScope): IntegrationType[] {
@@ -167,19 +231,90 @@ const ALL_CLIS = "__all_clis__";
167
231
  /** Sentinel for the locked "Custom" row — informational, never resolves to
168
232
  * builtin policy names (custom policies load by convention, not by config). */
169
233
  const CUSTOM = "__custom__";
234
+ /**
235
+ * Sentinel for the locked "enabled individually" row.
236
+ *
237
+ * Policies enabled one at a time (`failproofai policies add <name>`) need not map
238
+ * onto any preset, so seeding the preset boxes cannot represent them. The wizard
239
+ * writes with `replace: true`, which makes the ticked set the WHOLE enabled set —
240
+ * so anything this row stands for must be unioned back in, or confirming the
241
+ * wizard would silently drop it. Locked and pre-checked, because it reports a
242
+ * state rather than offering a choice.
243
+ */
244
+ const INDIVIDUAL = "__individual__";
245
+
246
+ /**
247
+ * Split what is enabled now into the bundles that cover it and the leftovers.
248
+ *
249
+ * A pure function, and the SINGLE definition of that split — `buildPresetChoices`
250
+ * renders it and the wizard writes from it, so the row the user sees and the set
251
+ * that gets written can never disagree. The first version of this derived the
252
+ * leftovers by parsing them back out of the row's hint text, which coupled a
253
+ * display string to enforcement behaviour and would have broken on any policy
254
+ * name containing the separator.
255
+ */
256
+ export function splitEnabled(currentlyEnabled: readonly string[] = []): {
257
+ /** Preset ids (or `EVERYTHING`) whose policies are all already enabled. */
258
+ presets: string[];
259
+ /** Enabled policies no ticked bundle accounts for. */
260
+ individual: string[];
261
+ } {
262
+ const current = new Set(currentlyEnabled);
263
+ // A bundle is ticked when everything it turns on is already on. Not "any", or
264
+ // one shared policy would tick every bundle containing it and confirming would
265
+ // enable all of them.
266
+ const isOn = (policies: string[]) =>
267
+ policies.length > 0 && policies.every((name) => current.has(name));
268
+
269
+ const everything = resolveEverything();
270
+ const presets = isOn(everything)
271
+ ? [EVERYTHING]
272
+ : POLICY_PRESETS.filter((p) => isOn(resolvePreset(p.id))).map((p) => p.id);
273
+
274
+ // Against the TICKED bundles, not all of them: a policy belonging only to a
275
+ // bundle the user has NOT enabled is still enabled, and that is the fact the
276
+ // locked row exists to make visible.
277
+ const accounted = new Set(
278
+ presets.flatMap((id) => (id === EVERYTHING ? everything : resolvePreset(id))),
279
+ );
280
+ const individual = [...current].filter((name) => !accounted.has(name)).sort();
281
+ return { presets, individual };
282
+ }
170
283
 
171
284
  /** The themed preset bundles for the wizard's multi-select, plus an "Everything"
172
285
  * option that enables the full builtin policy set. */
173
- export function buildPresetChoices(cwd: string = process.cwd(), enabled = true) {
286
+ export function buildPresetChoices(
287
+ cwd: string = process.cwd(),
288
+ enabled = true,
289
+ /**
290
+ * What is enabled at this scope RIGHT NOW, used to tick the boxes.
291
+ *
292
+ * Without it every row rendered unticked on every run while the wizard wrote
293
+ * with `replace: true` — so re-running setup showed a blank slate and then made
294
+ * that blank slate authoritative, discarding the user's selection with nothing
295
+ * on screen to say it had happened. The comment on the Custom row below has
296
+ * always described the intended behaviour ("shows the current state rather than
297
+ * resetting it every run"); it was implemented for that one row out of eight.
298
+ *
299
+ * Optional so the first-run call sites stay unchanged: an empty set ticks
300
+ * nothing, which is the correct rendering for a machine with no selection.
301
+ */
302
+ currentlyEnabled: readonly string[] = [],
303
+ ) {
304
+ const { presets: onPresets, individual } = splitEnabled(currentlyEnabled);
305
+ const on = new Set(onPresets);
306
+
174
307
  const choices: MultiChoice<string>[] = POLICY_PRESETS.map((p) => ({
175
308
  label: p.label,
176
309
  value: p.id,
177
310
  hint: p.description,
311
+ checked: on.has(p.id),
178
312
  }));
179
313
  choices.push({
180
314
  label: "Everything",
181
315
  value: EVERYTHING,
182
316
  hint: `all ${resolveEverything().length} policies`,
317
+ checked: on.has(EVERYTHING),
183
318
  });
184
319
 
185
320
  // The Custom row is ALWAYS present, because it is the only place the feature
@@ -220,21 +355,45 @@ export function buildPresetChoices(cwd: string = process.cwd(), enabled = true)
220
355
  : "none yet · drop *-policies.mjs in .failproofai/policies/",
221
356
  });
222
357
  }
358
+ if (individual.length > 0) {
359
+ choices.push({
360
+ label: `${individual.length} enabled individually`,
361
+ value: INDIVIDUAL,
362
+ locked: true,
363
+ hint: `kept as-is · ${individual.join(", ")}`,
364
+ // Not one of the bundles being counted, like the Everything and Custom rows.
365
+ summaryExclude: true,
366
+ });
367
+ }
223
368
  return choices;
224
369
  }
225
370
 
371
+
226
372
  /**
227
373
  * Resolve the ticked options to a concrete policy set. Presets are additive —
228
374
  * the deduped union of every selected preset's policies — while "Everything"
229
375
  * enables the full policy set and wins over any presets.
230
376
  */
231
- export function resolvePresetSelection(values: string[]): string[] {
377
+ export function resolvePresetSelection(
378
+ values: string[],
379
+ /**
380
+ * What the locked "enabled individually" row stands for. Unioned in whenever
381
+ * that row is present, INCLUDING under "Everything": `resolveEverything()`
382
+ * covers the non-beta builtins only, so a beta policy someone enabled by hand
383
+ * would otherwise be dropped by the very branch meant to enable everything.
384
+ */
385
+ individual: readonly string[] = [],
386
+ ): string[] {
232
387
  // The Custom row is informational — custom policies are discovered from disk
233
388
  // by the loader, never named in the enabled-policies config — so it must not
234
- // reach resolvePreset(), which only knows builtin bundle ids.
235
- const selected = values.filter((v) => v !== CUSTOM);
236
- if (selected.includes(EVERYTHING)) return resolveEverything();
237
- return [...new Set(selected.flatMap((id) => resolvePreset(id)))];
389
+ // reach resolvePreset(), which only knows builtin bundle ids. Same for the
390
+ // locked individually-enabled row, which carries its policies in `individual`.
391
+ const selected = values.filter((v) => v !== CUSTOM && v !== INDIVIDUAL);
392
+ const carried = values.includes(INDIVIDUAL) ? individual : [];
393
+ if (selected.includes(EVERYTHING)) {
394
+ return [...new Set([...resolveEverything(), ...carried])];
395
+ }
396
+ return [...new Set([...selected.flatMap((id) => resolvePreset(id)), ...carried])];
238
397
  }
239
398
 
240
399
  const DIM_NOTE = "(auto-loaded)";
@@ -325,7 +484,12 @@ export function describeCustomPolicies(cwd: string): {
325
484
  let fileCount = 0;
326
485
  const dirs: Array<{ dir: string; label: string }> = [
327
486
  { dir: resolve(cwd, ".failproofai", "policies"), label: "project" },
328
- { dir: resolve(homedir(), ".failproofai", "policies"), label: "global" },
487
+ // `customPoliciesDir()`, not layout 1's `~/.failproofai/policies`. This
488
+ // scanned the old location while `custom-hooks-loader.ts` loads from the
489
+ // new one, so the wizard reported "no personal policies" to users whose
490
+ // policies were being enforced, and would have reported the opposite after
491
+ // the layout reset moved them.
492
+ { dir: customPoliciesDir(), label: "global" },
329
493
  ];
330
494
  for (const { dir, label } of dirs) {
331
495
  const found = discoverPolicyFiles(dir);
@@ -344,33 +508,151 @@ export function describeCustomPolicies(cwd: string): {
344
508
  return { active, warnings, fileCount, scopes };
345
509
  }
346
510
 
511
+ /**
512
+ * The wizard's one-line completion summary. Pure and exported so the widest
513
+ * real combination — every policy, every CLI, custom/daemon/reporting all
514
+ * present — can be pinned by a test without having to drive the whole wizard
515
+ * through a real chdir + on-disk custom-policy fixture.
516
+ *
517
+ * Kept inside a standard 80-column terminal: `writeLines` truncates with a
518
+ * hard cut and no ellipsis, so an over-long line doesn't just lose its tail
519
+ * — it reads as broken output. Naming all ten CLIs once took it to 182
520
+ * characters; the count alone carries the same information, and the user
521
+ * picked them two screens ago. A single grouped "· a, b, c" clause bounds the
522
+ * optional notes to one separator and short tags, rather than three
523
+ * independent " · " clauses stacking up.
524
+ */
525
+ export function buildCompletionSummary(
526
+ policiesCount: number,
527
+ harnessesCount: number,
528
+ customEnabled: boolean | undefined,
529
+ daemonInstalled: boolean,
530
+ connected: boolean,
531
+ /**
532
+ * What was ticked on the policy step, so the summary can NAME the bundles.
533
+ *
534
+ * "9 policies" is a number the user cannot check and did not choose — they
535
+ * picked two named bundles two screens earlier, and the line that confirms
536
+ * their setup should say which. Optional so the existing callers and tests
537
+ * that only have a count keep working and keep the old wording.
538
+ */
539
+ presetValues?: readonly string[],
540
+ ): string {
541
+ const extras: string[] = [];
542
+ if (customEnabled === true) extras.push("custom");
543
+ else if (customEnabled === false) extras.push("custom off");
544
+ if (daemonInstalled) extras.push("daemon");
545
+ if (connected) extras.push("reporting");
546
+ const extrasNote = extras.length > 0 ? ` · ${extras.join(", ")}` : "";
547
+ const harnesses = `${harnessesCount} harness${harnessesCount === 1 ? "" : "es"}`;
548
+ const line = (selection: string) =>
549
+ `Setup complete — ${selection} · ${harnesses}${extrasNote}`;
550
+
551
+ // Bound the WHOLE line, not just the names. `writeLines` truncates with a hard
552
+ // cut and no ellipsis, so 81 characters does not lose a tail — it reads as
553
+ // broken output. Naming is preferred and degrades to the count only when the
554
+ // full line will not fit, which is checked rather than guessed at: the extras
555
+ // clause grows too ("custom, daemon, reporting" is 25 characters), so a names
556
+ // budget alone was wrong for exactly the combinations that need it most.
557
+ const named = line(describeSelection(policiesCount, presetValues));
558
+ if (named.length <= MAX_SUMMARY_COLUMNS) return named;
559
+ return line(describeSelection(policiesCount, undefined));
560
+ }
561
+
562
+ /**
563
+ * The budget this line has to fit in.
564
+ *
565
+ * 80 columns minus the 3-column gutter the existing summary tests already assert
566
+ * (`message.length + GUTTER <= 80`) — matching that convention rather than
567
+ * inventing a second one, because two different width rules for the same line is
568
+ * how one of them ends up wrong.
569
+ */
570
+ const MAX_SUMMARY_COLUMNS = 77;
571
+
572
+ /**
573
+ * Name the bundles rather than counting the policies inside them.
574
+ *
575
+ * BOUNDED AT TWO NAMES ON PURPOSE. `writeLines` truncates with a hard cut and no
576
+ * ellipsis, so an over-long line does not lose its tail, it reads as broken
577
+ * output — the same constraint that stopped this summary naming all twelve CLIs.
578
+ * All four bundle labels joined is 57 characters, which with the prefix, the
579
+ * harness clause and the extras clause runs to about 106. Two names plus a count
580
+ * of the rest stays inside 80 for every combination, and two is also the common
581
+ * case, so most runs see every name.
582
+ *
583
+ * Falls back to the old "N policies" when nothing maps to a bundle — a machine
584
+ * whose policies were all enabled one at a time with `policies add` has no bundle
585
+ * to name, and inventing one would be worse than the count.
586
+ */
587
+ function describeSelection(policiesCount: number, presetValues?: readonly string[]): string {
588
+ const plural = `${policiesCount} polic${policiesCount === 1 ? "y" : "ies"}`;
589
+ if (!presetValues) return plural;
590
+
591
+ // "Everything" is one name for the whole set, and the count is the useful half
592
+ // of it — "Everything" alone does not say how much that is.
593
+ if (presetValues.includes(EVERYTHING)) return `Everything (${plural})`;
594
+
595
+ const named = POLICY_PRESETS.filter((p) => presetValues.includes(p.id)).map((p) => p.label);
596
+ // The locked "enabled individually" row stands for policies outside every
597
+ // bundle, so it is counted among the unnamed rest rather than named.
598
+ const individual = presetValues.includes(INDIVIDUAL) ? 1 : 0;
599
+ if (named.length === 0) return plural;
600
+
601
+ const shown = named.slice(0, 2);
602
+ const rest = named.length - shown.length + individual;
603
+ // `+N` rather than `+N more`: five characters, and they decide whether the
604
+ // mixed case (two bundles plus a policy added by hand) gets named at all — with
605
+ // "more" the line is 83 and falls back to a bare count.
606
+ return rest > 0 ? `${shown.join(", ")} +${rest}` : shown.join(", ");
607
+ }
608
+
347
609
  export function reviewLines(state: {
348
- scope: HookScope;
610
+ /** What the scope step resolved to. Expands to one or two real scopes. */
611
+ target: SetupTarget;
349
612
  clis: IntegrationType[];
350
613
  policies: string[];
351
614
  cwd: string;
352
615
  /** The Custom checkbox. `undefined` = nothing to toggle, leave as-is. */
353
616
  customEnabled?: boolean;
354
617
  /**
355
- * Step 0's answer. The daemon is machine-level, so this is a decision the
356
- * user made explicitly rather than something inferred from the scope
357
- * showing it any other way would promise a service the apply won't install.
618
+ * Whether a daemon will be installed by this run. False when one is already
619
+ * healthy (nothing to do) or the platform has no service manager showing
620
+ * it any other way would promise work the apply will not perform.
358
621
  */
359
622
  installDaemon?: boolean;
623
+ /** Whether an API key will be written and reporting turned on. */
624
+ connect?: boolean;
360
625
  }): string[] {
361
- const { scope, clis, policies, cwd, customEnabled, installDaemon } = state;
626
+ const { target, clis, policies, cwd, customEnabled, installDaemon, connect } = state;
627
+ const scopes = scopesFor(target);
362
628
  const where =
363
- scope === "project" ? `This project (${homeify(cwd)})` : "Everywhere (global)";
629
+ target === "both"
630
+ ? `Global, plus this project (${homeify(cwd)})`
631
+ : target === "project"
632
+ ? `This project (${homeify(cwd)})`
633
+ : "Everywhere (global)";
364
634
  const lines: string[] = [];
365
- const assistantNames = clis.map((c) => getIntegration(c).displayName);
635
+ const harnessNames = clis.map((c) => getIntegration(c).displayName);
366
636
  lines.push(` Where : ${where}`);
367
- lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`);
368
- lines.push(` Policies : ${policies.length} enabled`);
637
+ lines.push(` Harnesses : ${harnessNames.length ? summarize(harnessNames, "harnesses") : "(none)"}`);
638
+ // Zero is a deliberate answer, not a failed step — say so, and say where to
639
+ // change it, so the review screen doesn't read like the wizard lost the
640
+ // selection. Hooks still install; only the builtin set is empty.
641
+ lines.push(
642
+ policies.length === 0
643
+ ? " Policies : none enabled (add later: failproofai policies --install)"
644
+ : ` Policies : ${policies.length} enabled`,
645
+ );
369
646
  if (installDaemon && isDaemonSupportedPlatform()) {
370
647
  lines.push(
371
648
  ` Daemon : failproofaid, installed as a system service running as you`,
372
649
  );
373
650
  }
651
+ lines.push(
652
+ connect
653
+ ? " Reporting : on — policy decisions and session transcripts"
654
+ : " Reporting : off — nothing leaves this machine",
655
+ );
374
656
 
375
657
  // Reflect the Custom decision, not just what is on disk. Reporting
376
658
  // "1 file (project) (auto-loaded)" after the user had just unticked the row
@@ -387,15 +669,29 @@ export function reviewLines(state: {
387
669
 
388
670
  lines.push("");
389
671
  lines.push(" This will update:");
390
- for (const cli of clis) {
391
- const integration = getIntegration(cli);
392
- // Usually one path; Hermes lists one per profile so the operator sees every
393
- // home dir that is about to be written.
394
- for (const p of settingsPathsFor(integration, scope, cwd)) {
395
- lines.push(` ${homeify(p)} ${integration.displayName} hooks`);
672
+ // Deduplicated across scopes: a CLI that supports only user scope resolves to
673
+ // the same settings file under both halves of "Both", and listing it twice
674
+ // reads as two separate writes.
675
+ const seen = new Set<string>();
676
+ for (const scope of scopes) {
677
+ for (const cli of clis) {
678
+ const integration = getIntegration(cli);
679
+ if (!integration.scopes.includes(scope)) continue;
680
+ // Usually one path; Hermes lists one per profile so the operator sees
681
+ // every home dir that is about to be written.
682
+ for (const p of settingsPathsFor(integration, scope, cwd)) {
683
+ if (seen.has(p)) continue;
684
+ seen.add(p);
685
+ lines.push(` ${homeify(p)} ${integration.displayName} hooks`);
686
+ }
396
687
  }
397
688
  }
398
- lines.push(` ${homeify(getConfigPathForScope(scope, cwd))} ${policies.length} policies`);
689
+ for (const scope of scopes) {
690
+ const configPath = getConfigPathForScope(scope, cwd);
691
+ if (seen.has(configPath)) continue;
692
+ seen.add(configPath);
693
+ lines.push(` ${homeify(configPath)} ${policies.length} policies`);
694
+ }
399
695
  if (installDaemon && isDaemonSupportedPlatform()) {
400
696
  const servicePath = daemonServiceFilePath();
401
697
  if (servicePath) lines.push(` ${homeify(servicePath)} failproofaid service (needs root)`);
@@ -406,7 +702,7 @@ export function reviewLines(state: {
406
702
  // ── First-run redirect ───────────────────────────────────────────────────────
407
703
 
408
704
  function firstRunMarkerPath(): string {
409
- return resolve(homedir(), ".failproofai", ".launcher-configured");
705
+ return launcherMarker();
410
706
  }
411
707
 
412
708
  export function hasSeenLauncher(): boolean {
@@ -415,30 +711,16 @@ export function hasSeenLauncher(): boolean {
415
711
 
416
712
  export function markLauncherSeen(): void {
417
713
  try {
418
- mkdirSync(resolve(homedir(), ".failproofai"), { recursive: true });
714
+ // Layout 2 puts this under state/ with the rest of the daemon-adjacent
715
+ // markers, so the parent is a directory deeper than the home and has to be
716
+ // created rather than assumed.
717
+ mkdirSync(dirname(firstRunMarkerPath()), { recursive: true });
419
718
  writeFileSync(firstRunMarkerPath(), "1", "utf8");
420
719
  } catch {
421
720
  // best-effort
422
721
  }
423
722
  }
424
723
 
425
- /**
426
- * Whether failproofai is already set up GLOBALLY (user scope) for any agent.
427
- * Deliberately ignores project scope: project-scoped hooks in whatever repo the
428
- * user happens to be in shouldn't suppress the one-time global welcome. The
429
- * marker file is the primary "seen" gate; this is the "already set up" shortcut.
430
- */
431
- function anyHooksInstalledGlobally(): boolean {
432
- for (const id of INTEGRATION_TYPES) {
433
- try {
434
- if (getIntegration(id).hooksInstalledInSettings("user")) return true;
435
- } catch {
436
- // ignore broken settings files
437
- }
438
- }
439
- return false;
440
- }
441
-
442
724
  /**
443
725
  * On the FIRST bare `failproofai` invocation, redirect the user into the
444
726
  * configure wizard instead of the dashboard. Returns true when it handled the
@@ -450,45 +732,139 @@ function anyHooksInstalledGlobally(): boolean {
450
732
  * • non-TTY (CI/pipe) → print a one-line hint, go to dashboard
451
733
  * • fresh + TTY → mark seen, run the wizard, done
452
734
  */
453
- export async function maybeFirstRunConfigure(io: WizardIO = {}): Promise<boolean> {
735
+ export interface FirstRunOptions {
736
+ /**
737
+ * Run the post-setup audit after a completed apply. The caller sets this to
738
+ * false when the command it is about to run is `audit` itself, which would
739
+ * otherwise scan the entire history twice back to back.
740
+ */
741
+ postSetupAudit?: boolean;
742
+ /**
743
+ * Run setup even though the machine reads as already configured.
744
+ *
745
+ * Set by the caller after a layout reset. `isConfigured()` is a union that
746
+ * counts live user-scope hooks in any agent CLI, and the reset deliberately
747
+ * leaves those settings files alone — so a machine whose policy config was
748
+ * just deleted still answers "configured", the wizard is skipped, and
749
+ * `markLauncherSeen()` below back-fills the marker so every later run skips
750
+ * it too. The result is hooks firing on every tool call against no policies,
751
+ * with nothing to say so.
752
+ */
753
+ force?: boolean;
754
+ }
755
+
756
+ /**
757
+ * The live values `blockerCleared` compares an earlier attempt against.
758
+ *
759
+ * Built here rather than imported into `onboarding-attempt.ts` so that module
760
+ * stays pure and unit-testable without a service manager or a sudo binary.
761
+ */
762
+ function retryProbe(): RetryProbe {
763
+ return {
764
+ canElevate: () => {
765
+ try {
766
+ return canElevate();
767
+ } catch {
768
+ // No sudo binary at all is a blocker that has not cleared.
769
+ return false;
770
+ }
771
+ },
772
+ daemonStatus: () => {
773
+ try {
774
+ return daemonServiceStatus();
775
+ } catch {
776
+ return "";
777
+ }
778
+ },
779
+ cliVersion,
780
+ };
781
+ }
782
+
783
+ export async function maybeFirstRunConfigure(
784
+ io: WizardIO = {},
785
+ opts: FirstRunOptions = {},
786
+ ): Promise<boolean> {
454
787
  if (process.env.FAILPROOFAI_NO_FIRST_RUN === "1") return false;
455
- if (hasSeenLauncher()) return false;
456
788
 
457
789
  const stdin: TTYIn = io.stdin ?? process.stdin;
458
790
  const stdout: TTYOut = io.stdout ?? process.stdout;
459
791
 
460
- if (anyHooksInstalledGlobally()) {
461
- markLauncherSeen();
792
+ // One state read covering all three "already set up" signals — a config
793
+ // file, live user-scope hooks, or the legacy marker. See `isConfigured`.
794
+ const state = detectSetupState();
795
+ if (isConfigured(state) && !opts.force) {
796
+ // Back-fill the marker for a machine that is demonstrably configured but
797
+ // predates it, so later runs settle this with a single stat instead of
798
+ // walking every integration's settings file on every invocation.
799
+ if (!state.hasLegacyMarker) markLauncherSeen();
800
+ return false;
801
+ }
802
+
803
+ // A previous attempt that ABORTED. Setup writes nothing on those paths, by
804
+ // design, so without this record the machine is indistinguishable from one
805
+ // that has never been offered setup — and the wizard relaunches on every
806
+ // command forever. `--force` (an explicit `failproofai config`) never reaches
807
+ // here, so asking for setup by name always gets it.
808
+ const attempt = opts.force ? null : readOnboardingAttempt();
809
+ if (attempt && !blockerCleared(attempt, retryProbe())) {
810
+ for (const line of attemptHintLines(attempt)) stdout.write(`${line}\n`);
462
811
  return false;
463
812
  }
464
813
 
465
814
  if (!stdin.isTTY || !stdout.isTTY) {
815
+ // Never launch a wizard nobody can answer. This is the CI / piped path,
816
+ // and it must stay a hint rather than a failure: the command the user
817
+ // actually typed still runs.
466
818
  stdout.write(
467
819
  `\n[failproofai] Not set up yet — run \`failproofai config\` to get started.\n\n`,
468
820
  );
469
821
  return false;
470
822
  }
471
823
 
472
- // Fire-and-forget: never block the wizard's first paint on telemetry.
473
- void emit("first_run_configure_shown", {});
474
- // runConfigureWizard marks the launcher as seen only if the user completes an
475
- // apply so cancelling keeps redirecting here on the next bare run, and only
476
- // a finished setup sends the user to the dashboard afterwards.
477
- const result = await runConfigureWizard(io);
824
+ // Onboarding now fires on ANY command, so two terminals on a fresh machine
825
+ // is a real shape: both would draw a wizard, race on the same settings
826
+ // files, and both try to install the one system service. Only one gets to.
827
+ const lock = acquireOnboardingLock();
828
+ if (!lock) {
829
+ stdout.write(
830
+ `\n[failproofai] Setup is already running in another terminal — leaving it to finish.\n\n`,
831
+ );
832
+ return false;
833
+ }
834
+
835
+ try {
836
+ // Fire-and-forget: never block the wizard's first paint on telemetry.
837
+ void emit("first_run_configure_shown", {});
838
+ // runConfigureWizard marks the launcher as seen only if the user completes
839
+ // an apply — so cancelling keeps offering setup on the next run rather
840
+ // than silently never mentioning it again.
841
+ const result = await runConfigureWizard(io);
842
+ // Remember WHY, so the next command can hint instead of relaunching. Only
843
+ // on an abort: a completed apply clears the record below.
844
+ if (!result.applied && result.abort) {
845
+ recordOnboardingAttempt(result.abort, cliVersion, daemonServiceStatus());
846
+ }
478
847
 
479
848
  // Onboarding-only: after a completed first-run setup, run the audit pipeline
480
849
  // (scan + cache warm) before the caller boots the dashboard. The explicit
481
850
  // `failproofai config` command does NOT do this — only this first-run path.
482
851
  // Lazy-imported + best-effort; opt out with FAILPROOFAI_NO_AUTO_AUDIT=1.
483
- if (result.applied) {
484
- try {
485
- const { runPostSetupAudit } = await import("../audit/cli");
486
- await runPostSetupAudit();
487
- } catch {
488
- // the audit is a bonus — never let it break onboarding or the dashboard.
852
+ if (result.applied && opts.postSetupAudit !== false) {
853
+ try {
854
+ const { runPostSetupAudit } = await import("../audit/cli");
855
+ await runPostSetupAudit();
856
+ } catch {
857
+ // the audit is a bonus — never let it break onboarding or the dashboard.
858
+ }
489
859
  }
860
+ return true;
861
+ } finally {
862
+ // Released on every path, including a throw from the wizard itself —
863
+ // otherwise a crash mid-setup would leave a lock behind, and although the
864
+ // liveness check reclaims it, doing so needs the next run to reach that
865
+ // check rather than relying on it.
866
+ lock.release();
490
867
  }
491
- return true;
492
868
  }
493
869
 
494
870
  // ── The wizard ───────────────────────────────────────────────────────────────
@@ -527,133 +903,536 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
527
903
 
528
904
  // Fire-and-forget: never block the wizard's first paint on telemetry.
529
905
  void emit("configure_started", {});
530
- intro("let's set up your safety net", stdout);
906
+
907
+ // failproofaid — the only evaluator on a configured machine — only runs on
908
+ // Linux and macOS. Checked before intro() draws anything and before a
909
+ // single prompt is asked: completing setup anyway used to leave e.g. a
910
+ // Windows machine reading as configured while enforcing in-process with no
911
+ // fail-closed guarantee, which is worse than not being set up at all.
912
+ if (!isDaemonSupportedPlatform()) {
913
+ stdout.write(
914
+ `failproofai requires failproofaid, its background policy daemon, which runs on\n` +
915
+ `Linux and macOS only — not ${process.platform}. Setup cannot continue here: an\n` +
916
+ "installation with no daemon behind it would read as configured while enforcing\n" +
917
+ "nothing, which is worse than not being set up at all.\n\n" +
918
+ "Nothing was changed. This platform will be supported once failproofaid gains a\n" +
919
+ `${process.platform} service target.\n\n`,
920
+ );
921
+ void emit("configure_aborted", { reason: "unsupported_platform" });
922
+ return { applied: false, abort: "unsupported_platform" };
923
+ }
924
+
925
+ intro("let's set up failproofai", stdout);
531
926
 
532
927
  const cancel = (): WizardResult => {
533
928
  outro("Cancelled — nothing was changed.", { ok: false }, stdout);
534
- return { applied: false };
929
+ // Distinguished from the abort reasons: pressing Esc is not a failure, and
930
+ // a caller picking an exit code must not treat it as one.
931
+ return { applied: false, abort: "cancelled" };
535
932
  };
536
933
 
537
- // 0 — The background daemon, before anything else.
934
+ // 0 — The background daemon. REQUIRED, and before anything else.
538
935
  //
539
- // First because it is the only step that needs a password: asking here
540
- // means sudo prompts on a clean terminal, before any question has drawn a
541
- // screen, instead of firing from underneath a rendered TUI where the prompt
542
- // is invisible and the typed password lands in a redrawn frame. `sudo -v`
936
+ // First because it is the only step that needs a password: asking here means
937
+ // sudo prompts on a clean terminal, before any question has drawn a screen,
938
+ // instead of firing from underneath a rendered TUI where the prompt is
939
+ // invisible and the typed password lands in a redrawn frame. `sudo -v`
543
940
  // caches the credential for the rest of the run, so the actual install at
544
941
  // apply time stays non-interactive.
545
942
  //
546
943
  // Machine-level, so it is deliberately NOT gated on the scope chosen in the
547
944
  // next step: one daemon serves every project on this machine.
548
- let daemonWanted = false;
549
- let elevated = false;
550
- if (isDaemonSupportedPlatform()) {
551
- const choice = await selectOne<"install" | "skip">({
552
- message: "Install the failproofaid background daemon?",
553
- choices: [
554
- {
555
- label: "Yes — keep policy evaluation warm",
556
- value: "install",
557
- hint: "a system service, started at boot, running as you · needs your password once",
558
- },
559
- {
560
- label: "Not now",
561
- value: "skip",
562
- hint: "policies still enforce, evaluated per hook call",
563
- },
564
- ],
945
+ //
946
+ // Always true here — the guard near the top of this function already
947
+ // refused setup on anything else. Kept as a real read (not a literal
948
+ // `true`) so this block still fails safe if that guard is ever moved.
949
+ const daemonSupported = isDaemonSupportedPlatform();
950
+ // An already-healthy daemon needs no install and no password. Re-running
951
+ // setup on a configured machine must not demand sudo for work that is
952
+ // already done.
953
+ // "running" only. A unit that exists but is stopped or crash-looping is
954
+ // exactly the machine that needs this run to reinstall it, and treating
955
+ // "installed" as good enough would skip the repair and then set
956
+ // `daemonConfigured` against a service that is not answering — which fails
957
+ // closed on every tool call.
958
+ // Running is not enough — it must also be the version this CLI ships.
959
+ //
960
+ // Skipping on "running" alone was right for the case it was written for
961
+ // (never demand a password for work already done), and exactly backwards
962
+ // during an upgrade: the OLD daemon is perfectly healthy, so setup skipped
963
+ // it and the stale version survived. That made "just re-run config" — the
964
+ // remedy every message points at — silently do nothing.
965
+ //
966
+ // And running the right VERSION is still not enough: it must be able to
967
+ // answer. `ExecStart` bakes in `process.execPath` and an absolute
968
+ // `dist/worker.mjs`, so an `nvm uninstall 20` after setup leaves a unit
969
+ // systemd calls active whose worker dies on every spawn. That machine reads
970
+ // as "already running", so this wizard — the documented remedy, and the only
971
+ // caller that can rebuild the unit — skipped it and left the box denying
972
+ // every tool call with no route back but hand-editing `config.json`. A real
973
+ // hook evaluation is the only check that distinguishes the two.
974
+ const daemonSkew = daemonSupported ? daemonVersionSkew() : null;
975
+ const daemonState = daemonSupported ? daemonServiceStatus() : "unsupported-platform";
976
+ // `unknown` is "the service state could not be READ", which only macOS
977
+ // produces: a LaunchDaemon's state lives in launchd's system domain and needs
978
+ // root to read, so a sudo cache older than five minutes says nothing about
979
+ // whether the daemon is up. Treating that as "stopped" made the wizard demand
980
+ // a password and unload/reload a perfectly healthy service — a real
981
+ // fail-closed window on a `daemonConfigured` machine, opened to fix nothing,
982
+ // and a direct breach of this file's own rule that setup must not demand sudo
983
+ // for work already done. So it is probed rather than assumed: the probe is a
984
+ // real hook evaluation over the socket, needs no privileges, and answers the
985
+ // question the status check was only standing in for.
986
+ const daemonMaybeUp =
987
+ daemonSupported && (daemonState === "running" || daemonState === "unknown") && daemonSkew === null;
988
+ const daemonAnswers = daemonMaybeUp ? await probeDaemonEndToEnd() : false;
989
+ const daemonAlreadyRunning = daemonMaybeUp && daemonAnswers;
990
+ /**
991
+ * Installed and running, but its worker cannot evaluate anything.
992
+ *
993
+ * Keyed on a DEFINITE `running` reading, not on `daemonMaybeUp`: this is the
994
+ * branch that tears the service down before rebuilding, and the justification
995
+ * for that is knowing a live process is holding the singleton flock. An
996
+ * unreadable state is not that knowledge, and the plain install path already
997
+ * unloads before it writes.
998
+ */
999
+ const daemonBroken = daemonState === "running" && daemonSkew === null && !daemonAnswers;
1000
+ let daemonWanted = daemonSupported && !daemonAlreadyRunning;
1001
+ // A healthy daemon can still be running a service definition written before
1002
+ // FAILPROOFAI_CLI_CMD existed, and nothing else on the machine will ever
1003
+ // rewrite it: upgrading the npm package does not touch /etc/systemd/system.
1004
+ // Re-running setup is the one moment a user asks for their configuration to
1005
+ // be brought up to date, so it is the moment to do it.
1006
+ let daemonUnitStale = daemonAlreadyRunning && daemonServiceNeedsUpgrade();
1007
+
1008
+ if (daemonWanted) {
1009
+ stdout.write(
1010
+ daemonBroken
1011
+ ? "failproofaid is installed and running but cannot evaluate policies — its worker\n" +
1012
+ "process will not start, which on this machine denies every tool call. Rebuilding\n" +
1013
+ "the service needs root once. Your password goes to sudo, never to us.\n\n"
1014
+ : "failproofai runs a small background service (failproofaid) so policy checks\n" +
1015
+ "stay warm — without it every tool call pays a fresh startup, about 15x slower.\n" +
1016
+ "Installing it needs root once. Your password goes to sudo, never to us.\n\n",
1017
+ );
1018
+ if (!primeElevation()) {
1019
+ // Required means required: write nothing at all, so a machine that could
1020
+ // not be set up is left exactly as it was found rather than carrying
1021
+ // half a configuration. The commands are printed so an admin can do the
1022
+ // privileged half by hand.
1023
+ stdout.write(
1024
+ "\nCould not get root, so setup stopped before changing anything.\n\n" +
1025
+ " Re-run once you can use sudo: failproofai config\n" +
1026
+ ` Check what it needs: ${daemonStatusCommand() ?? "n/a"}\n\n`,
1027
+ );
1028
+ void emit("configure_aborted", { reason: "needs_root" });
1029
+ outro("Nothing was changed.", { ok: false }, stdout);
1030
+ return { applied: false, abort: "needs_root" };
1031
+ }
1032
+ } else if (daemonUnitStale) {
1033
+ stdout.write(
1034
+ "failproofaid is running, but from a service definition written by an older\n" +
1035
+ "version — it cannot start a scheduled audit. Refreshing it needs root once.\n\n",
1036
+ );
1037
+ if (!primeElevation()) {
1038
+ // NOT an abort, unlike the install branch above. There is a working
1039
+ // daemon here and hooks are enforcing; only the scheduled audit is out
1040
+ // of reach. Stopping setup over that would make an upgrade the thing
1041
+ // that locked someone out of `failproofai config`.
1042
+ stdout.write(
1043
+ "\nCould not get root, so the service definition was left as it is.\n" +
1044
+ "Everything else is set up as normal; scheduled audits stay off until it is\n" +
1045
+ `refreshed. Re-run \`failproofai config\` once you can use sudo.\n\n`,
1046
+ );
1047
+ daemonUnitStale = false;
1048
+ }
1049
+ } else if (daemonAlreadyRunning) {
1050
+ stdout.write("failproofaid is already installed and running — leaving it alone.\n\n");
1051
+ }
1052
+ if (daemonSkew) {
1053
+ stdout.write(
1054
+ `failproofaid is ${daemonSkew.installed} but this CLI ships ${daemonSkew.expected} — ` +
1055
+ `reinstalling it.\n\n`,
1056
+ );
1057
+ }
1058
+
1059
+ // 1 — Where? Inferred from cwd, then confirmed.
1060
+ //
1061
+ // Running from inside a project and running from a home directory are two
1062
+ // different intents, and asking a context-free "global or project?" made the
1063
+ // user restate something they had already expressed by choosing where to run
1064
+ // the command. So the choices are built from what actually exists here — and
1065
+ // labelled Update vs Set up accordingly — with the likelier target first.
1066
+ const setupState = detectSetupState(cwd);
1067
+ const targetChoices = buildTargetChoices(setupState);
1068
+
1069
+ let target: SetupTarget;
1070
+ if (targetChoices.length === 1) {
1071
+ // From a home directory there is no project to configure, so there is no
1072
+ // question to ask. Say what is about to happen rather than silently
1073
+ // deciding it.
1074
+ target = targetChoices[0].value;
1075
+ stdout.write(`Configuring ${targetChoices[0].label.toLowerCase()} — ${targetChoices[0].hint}.\n\n`);
1076
+ } else {
1077
+ const chosen = await selectOne<SetupTarget>({
1078
+ message: "What are we configuring?",
1079
+ choices: targetChoices.map((c) => ({
1080
+ label: c.label,
1081
+ value: c.value,
1082
+ hint: c.hint,
1083
+ })),
565
1084
  stdin,
566
1085
  stdout,
567
1086
  });
568
- if (choice === null) return cancel();
569
- daemonWanted = choice === "install";
570
-
571
- if (daemonWanted) {
572
- stdout.write("\nInstalling the failproofaid service needs root. Your password stays with sudo.\n");
573
- elevated = primeElevation();
574
- if (!elevated) {
575
- // Never fatal: the rest of setup is worth applying, and a machine with
576
- // no daemon is exactly the machine every release before this one had.
577
- stdout.write(
578
- "Could not get sudo — carrying on without the daemon.\n" +
579
- "Everything else still applies, and policies still enforce in-process.\n\n",
580
- );
581
- daemonWanted = false;
582
- }
583
- }
1087
+ if (chosen === null) return cancel();
1088
+ target = chosen;
584
1089
  }
1090
+ const scopes = scopesFor(target);
1091
+ // The scope whose CURRENT state seeds the pickers below. With "Both" the
1092
+ // project is the more specific of the two and the one the user is standing
1093
+ // in, so it wins; anything it does not define still falls back to global at
1094
+ // merge time, which is exactly the layering the policy loader already does.
1095
+ const primaryScope: HookScope = scopes.includes("project") ? "project" : "user";
585
1096
 
586
- // 1Where?
587
- const scope = await selectOne<HookScope>({
588
- message: "Where should this apply?",
589
- choices: buildScopeChoices(cwd),
590
- stdin,
591
- stdout,
592
- });
593
- if (scope === null) return cancel();
1097
+ // 2Which policies? Multi-select of themed presets — additive, so the
1098
+ // enabled set is the union of every ticked bundle.
1099
+ //
1100
+ // Before the assistants step, because "what do you want guarded" is the
1101
+ // question the user came here to answer; which CLIs to wire it into is
1102
+ // plumbing that follows from it.
1103
+ //
1104
+ // Seed the Custom checkbox AND the bundle boxes from whatever the config already
1105
+ // says, so the wizard shows the current state rather than resetting it every run.
1106
+ //
1107
+ // Read at the scope this run will WRITE to, not the merged view. `installHooks`
1108
+ // is called with `replace: true` per scope, so seeding from the merge would tick
1109
+ // a bundle because it is enabled at PROJECT scope and then write it into USER
1110
+ // scope — copying a selection between scopes as a side effect of opening the
1111
+ // wizard. `readHooksConfig()` stays for the custom flag, which is read the same
1112
+ // merged way everywhere else.
1113
+ const customEnabledBefore = readHooksConfig().customPoliciesEnabled !== false;
1114
+ const enabledHere = readScopedHooksConfig(primaryScope, cwd).enabledPolicies ?? [];
1115
+ const presetChoices = buildPresetChoices(cwd, customEnabledBefore, enabledHere);
1116
+ // The policies no ticked bundle accounts for. Derived from the SAME pure split
1117
+ // the rows are built from, so the locked row and the written set agree.
1118
+ const carriedIndividual = splitEnabled(enabledHere).individual;
1119
+ const hasCustomFiles = describeCustomPolicies(cwd).fileCount > 0;
594
1120
 
595
- // 2 Which assistants? An "Everything available" row protects every supported
596
- // CLI (detected + set-up-ahead); when ticked it wins over the individual boxes.
597
- const clisSel = await multiSelect<string>({
598
- message: "Which AI assistants should it protect?",
1121
+ // No minimum. Ticking nothing is a real answer someone who only wants their
1122
+ // own custom policies, or who intends to pick bundles later from the
1123
+ // dashboard, was previously stuck on this step with no way forward and no
1124
+ // explanation beyond "Select at least 1". An empty set is already supported
1125
+ // end to end: `installHooksImpl` documents its explicit-array path as "may be
1126
+ // empty", `replace: true` makes it the full enabled set, and `summarize([])`
1127
+ // renders "none". Hooks still install, so enforcement can be switched on
1128
+ // later without re-running setup.
1129
+ //
1130
+ // The assistants step below keeps its minimum deliberately: an empty CLI list
1131
+ // does NOT mean "no assistants" there — `installHooksImpl` falls back to
1132
+ // ["claude"], so letting it through would silently install for Claude.
1133
+ // Steps 2 and 3 are navigable: ← on the harness step returns to the policy
1134
+ // step with the previous answer still selected. Before this, changing an
1135
+ // earlier answer meant abandoning setup and starting over, because a prompt
1136
+ // had exactly one way out and it was `null`.
1137
+ //
1138
+ // The policy step itself takes no `allowBack`: the only thing before it is
1139
+ // the scope question, which is frequently not asked at all (a single choice
1140
+ // is stated, not prompted), so ← there would sometimes go nowhere.
1141
+ let presets: string[] | null = null;
1142
+ let clisSel: string[] | null = null;
1143
+ /**
1144
+ * What the harness step had ticked when ← was last pressed.
1145
+ *
1146
+ * A SEPARATE variable, because `clisSel` cannot do this job: it is the loop's
1147
+ * own condition (`while (clisSel === null)`), so it is null on every entry into
1148
+ * the body by definition, and it is assigned only on the line that ends the
1149
+ * loop. The restore that read `clisSel` was therefore unreachable — provably
1150
+ * dead, with a comment stating the opposite intent.
1151
+ *
1152
+ * The cost was not cosmetic: deselect a CLI, press ← to fix an earlier answer,
1153
+ * come back, and the step showed the detected defaults again. Pressing ↵ then —
1154
+ * reasonably, having been told the selection was carried back — re-enabled hook
1155
+ * installation for a CLI the user had explicitly turned off.
1156
+ *
1157
+ * `presets` just above works because it is assigned MID-loop and survives to the
1158
+ * next iteration; this mirrors that, filled from the prompt's `onBack`.
1159
+ */
1160
+ const carried: { clis: string[] | null } = { clis: null };
1161
+ while (clisSel === null) {
1162
+ // Re-entering after a ← must show what was picked, not a blank slate.
1163
+ // Selection state lives on each choice, so carry it back in.
1164
+ // Loop-carried: narrowed to `null` on the first pass, repopulated on a ←.
1165
+ const priorPresets = presets as string[] | null;
1166
+ presets = await multiSelect<string>({
1167
+ message: "What should we guard against?",
1168
+ choices: priorPresets
1169
+ ? presetChoices.map((c) => ({ ...c, checked: priorPresets.includes(c.value) }))
1170
+ : presetChoices,
1171
+ summaryNoun: "bundles",
1172
+ hint: "space toggles · combine presets · ↵ confirm · none is fine",
1173
+ stdin,
1174
+ stdout,
1175
+ });
1176
+ if (presets === null) return cancel();
1177
+
1178
+ // 3 — Which harnesses? An "Everything available" row protects every supported
1179
+ // CLI (detected + set-up-ahead); when ticked it wins over the individual boxes.
1180
+ // Read off a HOLDER OBJECT, not a bare `let`, and not through a cast.
1181
+ //
1182
+ // A `let` assigned only inside a callback is narrowed by control-flow analysis
1183
+ // to its initializer, so `priorClis.includes` will not compile — and the
1184
+ // original defeated that with `clisSel as string[] | null`. That cast is
1185
+ // precisely why the dead code type-checked and nobody noticed: it silenced the
1186
+ // compiler making exactly the point the reviewer later made by hand, that the
1187
+ // value could only ever be null. A property read carries the declared type
1188
+ // without suppressing anything.
1189
+ const priorClis = carried.clis;
1190
+ const picked: string[] | typeof BACK | null = await multiSelect<string>({
1191
+ message: "Which harnesses should it protect?",
599
1192
  choices: [
600
1193
  {
601
1194
  label: "Everything available",
602
1195
  value: ALL_CLIS,
603
1196
  // Counts only what this scope can actually take — expanding to all 12
604
1197
  // under project scope is what crashed the apply on Hermes.
605
- hint: `protect all ${clisSupportingScope(scope).length} CLIs configurable here`,
606
- // A selector, not an assistant. Counting it gave "13 assistants" for
1198
+ hint: `protect all ${clisSupportingScope(primaryScope).length} CLIs configurable here`,
1199
+ // A selector, not a harness. Counting it gave "13 harnesses" for
607
1200
  // the 12 supported CLIs, and listed "Everything available" among them.
608
1201
  summaryExclude: true,
609
1202
  },
610
- ...buildAgentChoices(scope, cwd),
611
- ],
612
- minSelected: 1,
613
- summaryNoun: "assistants",
614
- hint: "detected CLIs are pre-selected · space toggles · ctrl+a all · ↵ confirm",
615
- stdin,
616
- stdout,
617
- });
618
- if (clisSel === null) return cancel();
619
- // Filter to what this scope supports in BOTH branches: "Everything
620
- // available" must not expand to CLIs that cannot take this scope, and a
621
- // locked row can't be ticked but belt-and-braces keeps the invariant local
622
- // to the one place `clis` is built.
623
- const supported = new Set(clisSupportingScope(scope));
1203
+ ...buildAgentChoices(primaryScope, cwd),
1204
+ ].map((c) => (priorClis ? { ...c, checked: priorClis.includes(c.value) } : c)),
1205
+ minSelected: 1,
1206
+ summaryNoun: "harnesses",
1207
+ hint: "detected CLIs are pre-selected · space toggles · ctrl+a all · ← back · ↵ confirm",
1208
+ allowBack: true as const,
1209
+ // `BACK` is a symbol and cannot carry the selection, so the prompt reports
1210
+ // it here instead — otherwise a ← discards what the user had ticked and the
1211
+ // next pass redraws the detected defaults.
1212
+ onBack: (checkedNow) => {
1213
+ carried.clis = checkedNow;
1214
+ },
1215
+ stdin,
1216
+ stdout,
1217
+ });
1218
+ if (picked === null) return cancel();
1219
+ // ← re-runs the loop, which re-asks the policy step with its answer intact.
1220
+ if (picked === BACK) continue;
1221
+ clisSel = picked;
1222
+ }
1223
+ // Non-null by construction: the loop only exits once both are assigned.
1224
+ const chosenPresets: string[] = presets ?? [];
1225
+ const policies = resolvePresetSelection(chosenPresets, carriedIndividual);
1226
+ // Only meaningful when there are files to switch off; with none, the row is
1227
+ // locked-unchecked and must not write a disabling flag.
1228
+ const customEnabled = hasCustomFiles ? chosenPresets.includes(CUSTOM) : undefined;
1229
+ // Filter to what the chosen scopes support in BOTH branches: "Everything
1230
+ // available" must not expand to CLIs that cannot take any selected scope,
1231
+ // and a locked row can't be ticked but belt-and-braces keeps the invariant
1232
+ // local to the one place `clis` is built.
1233
+ //
1234
+ // The union across scopes, not the intersection: under "Both", a user-scope-
1235
+ // only gateway like Hermes is still installable via the user half, and
1236
+ // dropping it because project scope cannot take it would silently protect
1237
+ // less than the user asked for. `installHooks` is called per scope below and
1238
+ // skips what a given scope cannot take.
1239
+ const supported = new Set(scopes.flatMap((s) => clisSupportingScope(s)));
624
1240
  const clis: IntegrationType[] = (
625
1241
  clisSel.includes(ALL_CLIS)
626
1242
  ? [...INTEGRATION_TYPES]
627
1243
  : (clisSel.filter((v) => v !== ALL_CLIS) as IntegrationType[])
628
1244
  ).filter((id) => supported.has(id));
629
1245
 
630
- // 3Which policies? Multi-select of themed presets additive, so the
631
- // enabled set is the union of every ticked bundle.
632
- // Seed the Custom checkbox from whatever the config already says, so the
633
- // wizard shows the current state rather than resetting it every run.
634
- const customEnabledBefore = readHooksConfig().customPoliciesEnabled !== false;
635
- const presetChoices = buildPresetChoices(cwd, customEnabledBefore);
636
- const hasCustomFiles = describeCustomPolicies(cwd).fileCount > 0;
1246
+ // 4Connect this machine? Last, because by this point the user has decided
1247
+ // what to protect, so "would you like to see it in a dashboard?" follows
1248
+ // naturally asking up front interrupts setup with a question about a
1249
+ // product they may not have.
1250
+ //
1251
+ // A pasted API key rather than an interactive sign-in: it is the only form
1252
+ // that works on a headless box, in a container or over SSH, and it is the
1253
+ // same credential `failproofai config --connect` takes, so a machine set up
1254
+ // by the wizard and one set up by hand end up byte-identical on disk.
1255
+ //
1256
+ // Connecting turns on BOTH streams — policy decisions and session
1257
+ // transcripts. That is a real disclosure, not a footnote, so it is stated in
1258
+ // the body of the question itself rather than buried in an option hint.
1259
+ let connect: { url: string; token: string; machineId: string; machineLabel: string } | null = null;
637
1260
 
638
- const presets = await multiSelect<string>({
639
- message: "What should we guard against?",
640
- choices: presetChoices,
641
- minSelected: 1,
642
- summaryNoun: "bundles",
643
- hint: "space toggles · combine presets · confirm",
644
- stdin,
645
- stdout,
646
- });
647
- if (presets === null) return cancel();
648
- const policies = resolvePresetSelection(presets);
649
- // Only meaningful when there are files to switch off; with none, the row is
650
- // locked-unchecked and must not write a disabling flag.
651
- const customEnabled = hasCustomFiles ? presets.includes(CUSTOM) : undefined;
1261
+ {
1262
+ const choice = await selectOne<"key" | "local">({
1263
+ message: "Connect this machine to Failproof Cloud?",
1264
+ body: [
1265
+ " Connecting reports this machine's policy decisions AND full session",
1266
+ " transcripts prompts, file contents and command output — to your",
1267
+ " dashboard. Staying local sends nothing, anywhere, ever.",
1268
+ ],
1269
+ // Cloud first, and therefore preselected: connecting is what most people
1270
+ // running this wizard came to do, and the local path stays one keystroke
1271
+ // away. Reversing these two is the whole change — neither option's copy
1272
+ // moved, so "stay local" is still stated as plainly as it was.
1273
+ choices: [
1274
+ {
1275
+ label: "Paste an API key",
1276
+ value: "key",
1277
+ hint: "reports decisions and transcripts to your dashboard",
1278
+ },
1279
+ {
1280
+ label: "Not now — stay local",
1281
+ value: "local",
1282
+ hint: "policies still enforce · connect later with failproofai config --connect",
1283
+ },
1284
+ ],
1285
+ stdin,
1286
+ stdout,
1287
+ });
1288
+ if (choice === null) return cancel();
1289
+
1290
+ if (choice === "key") {
1291
+ // An already-enrolled machine has a URL and token that usually work.
1292
+ // Asking again is the seam that made connecting feel like two products.
1293
+ const existing = readCloudCredentials();
1294
+ let url: string | null = null;
1295
+ let token: string | null = null;
1296
+ // Reuse the enrolled id if there is one, else mint a stable key — never
1297
+ // the hostname, so two hosts with the same name do not merge. The hostname
1298
+ // becomes the human label instead.
1299
+ let machineId = resolveMachineId();
1300
+ let machineLabel = existing?.machineLabel ?? resolveMachineLabel();
1301
+
1302
+ if (existing) {
1303
+ const reuse = await selectOne<"reuse" | "other">({
1304
+ message: `Use this machine's existing connection to ${existing.url}?`,
1305
+ choices: [
1306
+ {
1307
+ label: "Yes — reuse it",
1308
+ value: "reuse",
1309
+ hint: `as ${existing.machineLabel ?? existing.machineId}, same token`,
1310
+ },
1311
+ { label: "No — different endpoint or key", value: "other", hint: "" },
1312
+ ],
1313
+ stdin,
1314
+ stdout,
1315
+ });
1316
+ if (reuse === null) return cancel();
1317
+ if (reuse === "reuse") {
1318
+ url = existing.url;
1319
+ token = existing.token;
1320
+ machineId = existing.machineId;
1321
+ machineLabel = existing.machineLabel ?? machineLabel;
1322
+ }
1323
+ }
1324
+
1325
+ if (url === null) {
1326
+ // NOT asked for. There is exactly one right answer for everybody using
1327
+ // the hosted product, and asking made it look like a decision — which
1328
+ // is how a key ends up pasted into the URL field, and how someone
1329
+ // reasonably types the dashboard's own address and gets a 404 from a
1330
+ // web app that is not the ingest endpoint. Both are real, both happened
1331
+ // within ten minutes of each other, and neither is a mistake the person
1332
+ // making it can be expected to avoid: "Failproof Cloud URL" has no
1333
+ // knowable answer other than the default it was already showing.
1334
+ //
1335
+ // The two audiences that genuinely need a different endpoint keep an
1336
+ // explicit way to say so, and neither is an interactive prompt:
1337
+ //
1338
+ // • local development / self-hosting → FAILPROOFAI_CLOUD_URL, the
1339
+ // same variable the DAEMON already reads for cloud-managed policy
1340
+ // (crates/failproofaid/src/cloud_client.rs), so one export points
1341
+ // the whole machine at one place instead of the wizard and the
1342
+ // daemon disagreeing.
1343
+ // • scripted installs → `failproofai config --connect <url> --token`,
1344
+ // unchanged.
1345
+ //
1346
+ // The env value goes through the SAME `validateCloudUrl` a typed one
1347
+ // did — it is not a trusted back door. http stays loopback-only, so a
1348
+ // bearer token still cannot be exported onto the wire in clear by
1349
+ // setting a variable.
1350
+ const override = process.env.FAILPROOFAI_CLOUD_URL?.trim();
1351
+ if (override) {
1352
+ const validated = validateCloudUrl(cloudBaseFor(override));
1353
+ if (!validated.ok) {
1354
+ // Loud, not silent-fallback-to-hosted: someone who exported this
1355
+ // wants THAT endpoint, and quietly reporting a machine to the
1356
+ // hosted service instead is the one outcome they did not ask for.
1357
+ stdout.write(
1358
+ `\nFAILPROOFAI_CLOUD_URL is set to "${override}", which cannot be used: ` +
1359
+ `${validated.reason}\n`,
1360
+ );
1361
+ return cancel();
1362
+ }
1363
+ url = validated.url;
1364
+ // Named on screen, because an env var is invisible at the moment it
1365
+ // matters and a machine reporting somewhere unexpected is exactly the
1366
+ // thing nobody notices until they go looking for data that is not
1367
+ // there.
1368
+ stdout.write(`\nUsing ${url} (from FAILPROOFAI_CLOUD_URL).\n`);
1369
+ } else {
1370
+ url = cloudBaseFor(DEFAULT_INGEST_URL);
1371
+ }
1372
+ }
652
1373
 
653
- // 4 Review & apply
1374
+ if (token === null) {
1375
+ token = await promptText({
1376
+ // The destination is in the question now that it is no longer a
1377
+ // question of its own. It is the only remaining place a person can
1378
+ // notice they are about to send a key somewhere they did not mean.
1379
+ message: `API key for ${new URL(url).host}`,
1380
+ hint: "needs events:add · policies:pull enables managed policy too",
1381
+ // Masked: setup is routinely run while screen-sharing, and a pasted
1382
+ // key would otherwise sit in the scrollback of every recording.
1383
+ mask: true,
1384
+ validate: (v) => (v.length >= 8 ? null : "that looks too short to be a key"),
1385
+ stdin,
1386
+ stdout,
1387
+ });
1388
+ if (token === null) return cancel();
1389
+ }
1390
+
1391
+ // Check BEFORE the review screen, so a typo is caught while the user is
1392
+ // still thinking about credentials rather than three screens later. The
1393
+ // apply step re-verifies and is what actually writes — nothing is
1394
+ // persisted here.
1395
+ stdout.write("\nChecking the key… ");
1396
+ const probe = await validateIngestKey({ url: ingestUrlFor(url), key: token });
1397
+ if (!probe.ok) {
1398
+ stdout.write(`\nThat did not work: ${probe.reason}\n`);
1399
+ const retry = await selectOne<"skip" | "anyway">({
1400
+ message: "Carry on without connecting?",
1401
+ choices: [
1402
+ { label: "Yes, skip it", value: "skip", hint: "everything else still applies" },
1403
+ { label: "Save it anyway", value: "anyway", hint: "if you know the server is just down" },
1404
+ ],
1405
+ stdin,
1406
+ stdout,
1407
+ });
1408
+ if (retry === null) return cancel();
1409
+ if (retry === "skip") {
1410
+ stdout.write("Staying local. Connect later with `failproofai config --connect`.\n\n");
1411
+ } else {
1412
+ connect = { url, token, machineId, machineLabel };
1413
+ }
1414
+ } else {
1415
+ stdout.write("looks good.\n\n");
1416
+ connect = { url, token, machineId, machineLabel };
1417
+ }
1418
+ }
1419
+ }
1420
+ // 5 — Review & apply
654
1421
  const decision = await selectOne<"apply" | "cancel">({
655
1422
  message: "Ready to apply?",
656
- body: reviewLines({ scope, clis, policies, cwd, customEnabled, installDaemon: daemonWanted }),
1423
+ body: reviewLines({
1424
+ target,
1425
+ clis,
1426
+ policies,
1427
+ cwd,
1428
+ customEnabled,
1429
+ // A stale-unit refresh rewrites the service file and restarts the
1430
+ // daemon, so it belongs on the list of things this run is about to
1431
+ // change. Leaving it off would make the confirmation screen of a
1432
+ // security tool quietly incomplete about a root-owned file.
1433
+ installDaemon: daemonWanted || daemonUnitStale,
1434
+ connect: connect !== null,
1435
+ }),
657
1436
  choices: [
658
1437
  { label: "Yes, apply now", value: "apply", hint: "write the config" },
659
1438
  { label: "Cancel", value: "cancel", hint: "quit, no changes" },
@@ -663,82 +1442,281 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
663
1442
  });
664
1443
  if (decision !== "apply") return cancel();
665
1444
 
666
- // Apply — REPLACE the enabled set at this scope.
667
- // Telemetry runs concurrently with the install (never rejects, 5s-bounded) so
668
- // it doesn't add dead time between "apply" and the config actually writing,
669
- // while still being awaited before the process can exit.
670
- const applied = emit("configure_applied", {
671
- scope,
672
- cli: clis,
673
- cli_count: clis.length,
674
- policy_count: policies.length,
675
- source: presets.join("+"),
676
- });
677
- // quiet: the wizard renders its own outro; replace: the chosen set becomes
678
- // the full enabled set at this scope (unticking removes).
679
- await installHooks(
680
- policies,
681
- scope,
682
- cwd,
683
- /* includeBeta */ false,
684
- "configure-wizard",
685
- /* customPoliciesPath */ undefined,
686
- /* removeCustomHooks */ false,
687
- clis,
688
- { replace: true, quiet: true },
689
- );
690
- setCustomPoliciesEnabled(scope, cwd, customEnabled);
691
-
692
- // Daemon setup — driven by step 0's answer, with the sudo credential it
693
- // already primed, so nothing here can stop to ask a question. A failure
694
- // (binary download blocked, no service manager, a daemon that won't stay
695
- // up) never fails the wizard: the rest of setup already applied, and this
696
- // machine simply stays on the in-process path, since `daemonConfigured` is
697
- // only set on success.
698
- let daemonInstalled = false;
1445
+ // ── Apply ─────────────────────────────────────────────────────────────────
1446
+ //
1447
+ // ORDER MATTERS. The daemon goes first, because setup requires it: if it
1448
+ // cannot be installed, this run must leave the machine exactly as it found
1449
+ // it rather than half-configured. Writing hooks first and discovering the
1450
+ // service will not start afterwards is the one ordering whose failure cannot
1451
+ // be undone cleanly — hooks would already be live, pointing at a machine
1452
+ // whose `daemonConfigured` flag we then could not honestly set.
1453
+ let daemonInstalled = daemonAlreadyRunning;
699
1454
  if (daemonWanted) {
700
- const daemonResult = await installDaemonService();
701
- if (daemonResult.installed) {
702
- setDaemonConfigured(true);
703
- daemonInstalled = true;
704
- } else {
705
- hookLogWarn(`failproofaid was not installed as a service: ${daemonResult.reason}`);
1455
+ // A unit that is running but cannot answer is torn down before it is
1456
+ // rebuilt, rather than installed over. Its `ExecStart` points at a binary
1457
+ // or interpreter that no longer works, and it holds the singleton flock
1458
+ // the replacement needs — install over the top and the new unit starts,
1459
+ // loses the lock race, and the machine stays exactly as broken. This is
1460
+ // the production path for `uninstallDaemonService`, which until now had
1461
+ // none: it was defined, tested, documented in CLAUDE.md as called from
1462
+ // here, and referenced by nothing.
1463
+ if (daemonBroken) {
1464
+ stdout.write("Removing the failproofaid service that cannot start…\n");
1465
+ try {
1466
+ await uninstallDaemonService();
1467
+ } catch (err) {
1468
+ // Non-fatal: the install below reports its own outcome, and it is that
1469
+ // outcome — not this one — that decides whether setup continues.
1470
+ hookLogWarn(
1471
+ `could not remove the broken failproofaid service: ${err instanceof Error ? err.message : String(err)}`,
1472
+ );
1473
+ }
706
1474
  }
1475
+ stdout.write(daemonBroken ? "Reinstalling the failproofaid service…\n" : "Installing the failproofaid service…\n");
1476
+ const daemonResult = await installDaemonService();
707
1477
  void emit("configure_daemon_install", {
708
1478
  installed: daemonResult.installed,
709
1479
  // A bounded classification, never the raw reason: on the failure path
710
1480
  // that string is an errno message from writeFileSync/execFileSync
711
1481
  // against a homedir()-derived path, so it routinely carries the OS
712
- // username and the local filesystem layout ("/home/<user>/.config/
713
- // systemd/user/failproofaid.service"). The full text stays local via
714
- // the hookLogWarn above.
1482
+ // username and the local filesystem layout. The full text stays local
1483
+ // via the hookLogWarn below.
715
1484
  reason: daemonResult.installed ? null : classifyDaemonInstallFailure(daemonResult.reason),
716
1485
  platform: process.platform,
717
1486
  });
1487
+
1488
+ if (!daemonResult.installed) {
1489
+ hookLogWarn(`failproofaid was not installed as a service: ${daemonResult.reason}`);
1490
+ // Nothing user-facing has been written yet, so there is nothing to roll
1491
+ // back — which is the entire reason this runs first.
1492
+ stdout.write(
1493
+ `\nThe failproofaid service could not be installed:\n ${daemonResult.reason ?? "unknown error"}\n\n` +
1494
+ "Setup stopped before changing anything. Once that is fixed, re-run:\n" +
1495
+ " failproofai config\n\n",
1496
+ );
1497
+ void emit("configure_aborted", { reason: "daemon_failed" });
1498
+ outro("Nothing was changed.", { ok: false }, stdout);
1499
+ return { applied: false, abort: "daemon_failed" };
1500
+ }
1501
+
1502
+ // Installed and running is the service manager's opinion, and it is not
1503
+ // what setup needs to know. `ExecStart` bakes in `process.execPath` and an
1504
+ // absolute `dist/worker.mjs`, so a unit can be perfectly active while the
1505
+ // worker behind it dies on every spawn — and every other check waves that
1506
+ // machine through: `waitForDaemonRunning()` asks systemd, `Ping` is
1507
+ // answered in `server.rs` without touching the worker, a null
1508
+ // `resolveWorkerCommand()` is best-effort, and `Worker::warm()` swallows
1509
+ // its own failure. Setting `daemonConfigured` against it denies every tool
1510
+ // call across all twelve CLIs, `UserPromptSubmit` included, so the user
1511
+ // cannot even ask their agent why.
1512
+ //
1513
+ // Checked HERE rather than inside `installDaemonService`, which can only
1514
+ // honestly report on the service: keeping them apart is also what lets the
1515
+ // install mechanics be tested against a stub binary.
1516
+ const probe = await probeDaemon();
1517
+ if (!probe.ok) {
1518
+ hookLogWarn(
1519
+ `failproofaid was installed and running but did not answer a policy evaluation (${probe.reason})`,
1520
+ );
1521
+ // Two different faults, two different things to go and look at. Saying
1522
+ // "your worker will not start" at a machine whose socket simply never
1523
+ // came up sends someone to inspect a process that is fine.
1524
+ stdout.write(
1525
+ (probe.reason === "worker"
1526
+ ? "\nfailproofaid started but cannot evaluate policies — it is listening, and\n" +
1527
+ "its worker process could not be run.\n"
1528
+ : "\nfailproofaid started but could not be reached on its socket.\n") +
1529
+ "Setup stopped before changing anything, because a machine configured to\n" +
1530
+ "require a daemon that cannot answer denies every tool call.\n\n" +
1531
+ ` Check it with: ${daemonStatusCommand() ?? "systemctl status failproofaid"}\n` +
1532
+ " Then re-run: failproofai config\n\n",
1533
+ );
1534
+ void emit("configure_aborted", { reason: `daemon_not_answering_${probe.reason}` });
1535
+ outro("Nothing was changed.", { ok: false }, stdout);
1536
+ return { applied: false, abort: "daemon_failed" };
1537
+ }
1538
+ daemonInstalled = true;
1539
+ } else if (daemonUnitStale) {
1540
+ // Deliberately after the install branch and never instead of it: this only
1541
+ // ever runs on a machine whose daemon is already up, and it must not be
1542
+ // able to abort a setup that is otherwise fine. A failure here costs the
1543
+ // scheduled audit, nothing else.
1544
+ stdout.write("Refreshing the failproofaid service definition…\n");
1545
+ const upgrade = await ensureDaemonServiceCurrent();
1546
+ void emit("configure_daemon_unit_refresh", {
1547
+ outcome: upgrade.outcome,
1548
+ daemon_running: upgrade.daemonRunning ?? true,
1549
+ platform: process.platform,
1550
+ });
1551
+ if (upgrade.outcome === "failed") {
1552
+ hookLogWarn(`failproofaid service definition could not be refreshed: ${upgrade.reason}`);
1553
+ if (upgrade.daemonRunning === false) {
1554
+ // The refresh stopped a daemon it could not start again, and its own
1555
+ // rollback could not either. Explicitly `=== false`: every failure
1556
+ // that never touched the service leaves this undefined, and those must
1557
+ // not drag a healthy machine down this branch.
1558
+ //
1559
+ // Leaving `daemonConfigured` set here is not "the audit stays off", it
1560
+ // is every tool call across all 12 CLIs denied against a socket
1561
+ // nothing is listening on, recoverable only by hand-editing
1562
+ // policies-config.json. So the machine goes back to in-process
1563
+ // evaluation — the same trade uninstallDaemonService makes, and for
1564
+ // the same reason.
1565
+ daemonInstalled = false;
1566
+ setDaemonConfigured(false);
1567
+ stdout.write(
1568
+ `\nThe service definition could not be refreshed:\n ${upgrade.reason ?? "unknown error"}\n` +
1569
+ "failproofaid is no longer running, so this machine was switched back to\n" +
1570
+ "in-process evaluation rather than left denying every tool call. Hooks keep\n" +
1571
+ "enforcing. Re-run `failproofai config` to reinstall the service.\n\n",
1572
+ );
1573
+ } else {
1574
+ stdout.write(
1575
+ `\nThe service definition could not be refreshed:\n ${upgrade.reason ?? "unknown error"}\n` +
1576
+ "Hooks keep enforcing; scheduled audits stay off until it is.\n\n",
1577
+ );
1578
+ }
1579
+ }
1580
+ }
1581
+
1582
+ // The flag that makes hooks route through the daemon — and, on a machine
1583
+ // where the daemon is unreachable, fail closed. Only ever set after a
1584
+ // verified-running service, never on intent.
1585
+ // The version is recorded HERE, from the CLI that installed it — which is by
1586
+ // construction the version that was installed, since the download URL and the
1587
+ // binary filename are both derived from it.
1588
+ if (daemonInstalled) {
1589
+ setDaemonConfigured(true, cliVersion);
1590
+ // Only now — the unit points at the new binary, so older ones are no
1591
+ // longer referenced by anything. Keeps the previous version for an
1592
+ // offline rollback.
1593
+ pruneOldDaemonBinaries();
1594
+ }
1595
+
1596
+ // Telemetry runs concurrently with the install (never rejects, 5s-bounded) so
1597
+ // it doesn't add dead time between "apply" and the config actually writing,
1598
+ // while still being awaited before the process can exit.
1599
+ const applied = emit("configure_applied", {
1600
+ target,
1601
+ scopes,
1602
+ cli: clis,
1603
+ cli_count: clis.length,
1604
+ policy_count: policies.length,
1605
+ source: chosenPresets.join("+"),
1606
+ connected: connect !== null,
1607
+ });
1608
+
1609
+ // One install per chosen scope, with the CLI list narrowed to what THAT
1610
+ // scope can take.
1611
+ //
1612
+ // The comment here used to claim `installHooks` "already skips CLIs a given
1613
+ // scope cannot take". It does not — `installHooksImpl` validates every CLI
1614
+ // against the scope up front and THROWS `Scope "project" is not supported by
1615
+ // Hermes`. `clis` is the union across scopes (deliberately, so a user-scope-
1616
+ // only gateway is still installed via the user half), so under "Both" +
1617
+ // "Everything available" the project pass got handed hermes/openclaw and
1618
+ // died. Nothing catches it, and by then the daemon is installed,
1619
+ // `daemonConfigured` is set and user-scope hooks are written — so the run
1620
+ // aborted mid-apply, before any project config or the pasted cloud key.
1621
+ // `configure-wizard.test.ts` mocks `installHooks` wholesale, which is why the
1622
+ // real validation path was never exercised.
1623
+ //
1624
+ // quiet: the wizard renders its own outro; replace: the chosen set becomes
1625
+ // the full enabled set at that scope (unticking removes).
1626
+ for (const scope of scopes) {
1627
+ const supportedHere = new Set(clisSupportingScope(scope));
1628
+ const clisForScope = clis.filter((id) => supportedHere.has(id));
1629
+ if (clisForScope.length > 0) {
1630
+ await installHooks(
1631
+ policies,
1632
+ scope,
1633
+ cwd,
1634
+ /* includeBeta */ false,
1635
+ "configure-wizard",
1636
+ /* customPoliciesPath */ undefined,
1637
+ /* removeCustomHooks */ false,
1638
+ clisForScope,
1639
+ { replace: true, quiet: true },
1640
+ );
1641
+ }
1642
+ setCustomPoliciesEnabled(scope, cwd, customEnabled);
1643
+ }
1644
+
1645
+ // Cloud connection, written after the daemon exists — the daemon is what
1646
+ // runs the collector, so a credential written for a service that is not
1647
+ // there would be a key on disk doing nothing.
1648
+ //
1649
+ // `connectToCloud` re-verifies each capability and writes only what actually
1650
+ // works, so a key revoked between the earlier probe and here degrades to a
1651
+ // reported partial rather than a connection this machine does not have.
1652
+ let connected = false;
1653
+ if (connect) {
1654
+ try {
1655
+ const outcome = await connectToCloud({
1656
+ url: connect.url,
1657
+ token: connect.token,
1658
+ machineId: connect.machineId,
1659
+ machineLabel: connect.machineLabel,
1660
+ // Both streams, as disclosed at the connect question. This is the one
1661
+ // place that decision becomes a written setting.
1662
+ sessions: true,
1663
+ });
1664
+ connected = outcome.anyConfigured;
1665
+ // Show the human label with the id in parentheses when they differ.
1666
+ const shownAs =
1667
+ connect.machineLabel === connect.machineId
1668
+ ? connect.machineId
1669
+ : `${connect.machineLabel} (${connect.machineId})`;
1670
+ for (const line of describeOutcome(outcome, shownAs, connect.url)) {
1671
+ stdout.write(`${line}\n`);
1672
+ }
1673
+ // Never the key, the URL, or the count — only that it happened and which
1674
+ // capabilities the server actually granted.
1675
+ void emit("configure_connect", {
1676
+ policy_ok: outcome.policy.ok,
1677
+ ingest_ok: outcome.ingest.ok,
1678
+ });
1679
+ } catch (err) {
1680
+ // Non-fatal, unlike the daemon: enforcement does not depend on the
1681
+ // dashboard, and a machine with no connection behaves exactly as every
1682
+ // release before this one did.
1683
+ hookLogWarn(
1684
+ `cloud connection was not written: ${err instanceof Error ? err.message : String(err)}`,
1685
+ );
1686
+ stdout.write("\nCould not connect — everything else applied. Retry with `failproofai config --connect`.\n");
1687
+ }
718
1688
  }
719
1689
 
720
1690
  await applied;
721
- // Only now — a completed apply — is the launcher considered "seen", so the
722
- // first-run bare invocation stops redirecting here and opens the dashboard.
1691
+ // Only now — a completed apply — is the launcher considered "seen", so
1692
+ // first-run onboarding stops offering itself on every command.
723
1693
  markLauncherSeen();
1694
+ // And any record of an earlier failure is now false: this machine got set up.
1695
+ clearOnboardingAttempt();
724
1696
 
725
- // Keep this inside a standard 80-column terminal. `writeLines` truncates with
726
- // a hard cut and no ellipsis, so an over-long line doesn't just lose its tail
727
- // it reads as broken output. Naming all ten CLIs took it to 182 characters;
728
- // the count alone carries the same information, and the user picked them two
729
- // screens ago.
730
- const customNote =
731
- customEnabled === true
732
- ? " + your custom policies"
733
- : customEnabled === false
734
- ? " · custom policies DISABLED"
735
- : "";
736
- const daemonNote = daemonInstalled ? " · background daemon enabled" : "";
737
- const assistants = `${clis.length} assistant${clis.length === 1 ? "" : "s"}`;
1697
+ // Every real completed setup is on a supported platform now (an unsupported
1698
+ // one aborts before this point), so the optional notes in the summary below
1699
+ // are no longer occasional additions see buildCompletionSummary's own doc
1700
+ // comment for why the widest combination still fits in 80 columns.
738
1701
  outro(
739
- `Setup complete — ${policies.length} policies${customNote} · ${assistants}${daemonNote}`,
1702
+ buildCompletionSummary(
1703
+ policies.length,
1704
+ clis.length,
1705
+ customEnabled,
1706
+ daemonInstalled,
1707
+ connected,
1708
+ chosenPresets,
1709
+ ),
740
1710
  { ok: true },
741
1711
  stdout,
742
1712
  );
743
- return { applied: true, scope, clis, policies };
1713
+ return {
1714
+ applied: true,
1715
+ target,
1716
+ scopes,
1717
+ clis,
1718
+ policies,
1719
+ daemonInstalled,
1720
+ connected,
1721
+ };
744
1722
  }