failproofai 1.0.10 → 1.0.11-beta.0

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 (296) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +4 -4
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js +4 -4
  8. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.meta +8 -0
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js +4 -4
  19. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.meta +8 -0
  23. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  24. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  26. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/invite/route.js +4 -4
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/audit/status/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
  34. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -4
  36. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -3
  38. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/auth/status/route.js +3 -3
  40. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  42. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  44. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  45. package/.next/standalone/.next/server/app/audit/page.js +4 -4
  46. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/index.html +1 -1
  49. package/.next/standalone/.next/server/app/index.meta +8 -0
  50. package/.next/standalone/.next/server/app/index.rsc +14 -14
  51. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  52. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  53. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  54. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  55. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  56. package/.next/standalone/.next/server/app/page.js +4 -4
  57. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  60. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  61. package/.next/standalone/.next/server/app/policies/page.js +8 -8
  62. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  65. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/page.js +5 -5
  67. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  68. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  70. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  71. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  72. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
  73. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  76. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  77. package/.next/standalone/.next/server/app/projects/page.js +5 -5
  78. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +3 -3
  81. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  82. package/.next/standalone/.next/server/app/settings/page.js +9 -9
  83. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  84. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  85. package/.next/standalone/.next/server/chunks/{[externals]__0qxhecv._.js → [externals]__0d7i9ea._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/{[externals]__0sg9-3p._.js → [externals]__0hjaw70._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/[externals]__1_9ze9b._.js +3 -0
  88. package/.next/standalone/.next/server/chunks/[externals]__1oenfmn._.js +3 -0
  89. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08dchfu._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ga9hs7._.js +3 -0
  91. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l3yhx4._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0moxohc._.js +22 -0
  93. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z2ll-a._.js +3 -0
  95. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +11 -11
  96. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1autwno._.js +3 -0
  97. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1v25n-6._.js +3 -0
  98. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +2 -2
  102. package/.next/standalone/.next/server/chunks/_1fz8m31._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/_1jrv3hg._.js +3 -0
  104. package/.next/standalone/.next/server/chunks/_1nx8fim._.js +3 -0
  105. package/.next/standalone/.next/server/chunks/_1ogwigf._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/{_1q5i8mb._.js → _1q27azx._.js} +2 -2
  107. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0el6rg8._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0ol5pa3._.js} +2 -2
  109. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1rqmk27._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1w7tiq4._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
  113. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1-bm5zf._.js → [root-of-the-server]__0502lat._.js} +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0_gn5iz._.js +4 -0
  115. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0f765xr._.js +4 -0
  116. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01bmjsj._.js → [root-of-the-server]__0h38gzz._.js} +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hoaz1g._.js +4 -0
  118. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ouobn4._.js → [root-of-the-server]__0mxmwtx._.js} +1 -1
  119. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__19evfi8._.js → [root-of-the-server]__0uescqv._.js} +1 -1
  120. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1484zv2._.js +3 -0
  121. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1mf3zp6._.js → [root-of-the-server]__19dutr7._.js} +4 -4
  122. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0oqrl71._.js → [root-of-the-server]__1_qv8oh._.js} +2 -2
  123. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1u6sn69._.js → [root-of-the-server]__1ab4ifb._.js} +1 -1
  124. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1fbrk56._.js +4 -0
  125. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
  126. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1um4yxn._.js +4 -0
  127. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1wb4yt2._.js +4 -0
  128. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1y3n3cn._.js +4 -0
  129. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  130. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
  131. package/.next/standalone/.next/server/chunks/ssr/{_16pa9ht._.js → _0lnpwk8._.js} +2 -2
  132. package/.next/standalone/.next/server/chunks/ssr/{_0yq5_3a._.js → _15y8myg._.js} +1 -1
  133. package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +2 -2
  134. package/.next/standalone/.next/server/chunks/ssr/{_042cgl1._.js → _1o95rf0._.js} +2 -2
  135. package/.next/standalone/.next/server/chunks/ssr/{_1zopuov._.js → _1thr5rf._.js} +2 -2
  136. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +12 -14
  137. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
  138. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  139. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  140. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  141. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
  142. package/.next/standalone/.next/server/chunks/ssr/node_modules_1hl81y4._.js +1 -1
  143. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0nkkbfv._.js +6 -0
  144. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  145. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  146. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  147. package/.next/standalone/.next/server/middleware.js +1 -1
  148. package/.next/standalone/.next/server/pages/404.html +1 -1
  149. package/.next/standalone/.next/server/pages/500.html +1 -1
  150. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  151. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  152. package/.next/standalone/.next/static/chunks/{03h3b-uomi7_d.js → 0pra9i676pym1.js} +1 -1
  153. package/.next/standalone/.next/static/chunks/{2kj826h1w9r47.js → 1-lpi0m-hc1kb.js} +1 -1
  154. package/.next/standalone/.next/static/chunks/{3ob2161ap37ul.js → 17yvyyblr_eb5.js} +1 -1
  155. package/.next/standalone/.next/static/chunks/{3nkc2jlgyygda.js → 1mylcr99gccyz.js} +1 -1
  156. package/.next/standalone/.next/static/chunks/{2j92xul6y24_v.js → 1wozchtn2etvh.js} +1 -1
  157. package/.next/standalone/.next/static/chunks/{3mjfvng11xnoo.js → 23thtelz1a-ur.js} +2 -2
  158. package/.next/standalone/.next/static/chunks/{1fqukzzowjflt.js → 2bc17tohgs4bs.js} +1 -1
  159. package/.next/standalone/.next/static/chunks/{3u1s8dsqiean5.js → 2xq-ejo8kti18.js} +1 -1
  160. package/.next/standalone/.next/static/chunks/3-_pth_c9n938.js +1 -0
  161. package/.next/standalone/.next/static/chunks/{3i8rg2fv9nd6e.js → 3n46e45atic-m.js} +1 -1
  162. package/.next/standalone/.next/static/chunks/{078dofaxvjgpd.js → 3q7sndqftqhw9.js} +1 -1
  163. package/.next/standalone/.next/static/chunks/{0542cuk_va8g1.js → 3sg07wnn969xq.js} +1 -1
  164. package/.next/standalone/.next/static/chunks/{turbopack-11dn2pegmg9_9.js → turbopack-1opxka4dtjbc5.js} +1 -1
  165. package/.next/standalone/app/actions/install-hooks-web.ts +10 -2
  166. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  167. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  168. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  169. package/.next/standalone/node_modules/next/dist/compiled/hash.js/sha256/256.js +1 -0
  170. package/.next/standalone/node_modules/next/dist/compiled/hash.js/sha256/package.json +1 -0
  171. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +17 -17
  172. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +18 -18
  173. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +3 -3
  174. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +13 -13
  175. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  176. package/.next/standalone/node_modules/next/dist/server/base-server.js +1 -2
  177. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  178. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  179. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  180. package/.next/standalone/node_modules/next/dist/server/image-optimizer.js +265 -53
  181. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  182. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +177 -19
  183. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/index.js +36 -17
  184. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/shared-cache-controls.external.js +14 -6
  185. package/.next/standalone/node_modules/next/dist/server/lib/match-next-data-pathname.js +3 -1
  186. package/.next/standalone/node_modules/next/dist/server/lib/node-fs-methods.js +14 -0
  187. package/.next/standalone/node_modules/next/dist/server/lib/route-cache-key.js +77 -0
  188. package/.next/standalone/node_modules/next/dist/server/lib/router-utils/block-cross-site-dev.js +9 -11
  189. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  190. package/.next/standalone/node_modules/next/dist/server/mcp/get-mcp-middleware.js +4 -2
  191. package/.next/standalone/node_modules/next/dist/server/next-server.js +7 -3
  192. package/.next/standalone/node_modules/next/dist/server/response-cache/index.js +32 -16
  193. package/.next/standalone/node_modules/next/dist/server/route-matcher-providers/app-page-route-matcher-provider.js +2 -2
  194. package/.next/standalone/node_modules/next/dist/server/route-modules/pages/pages-handler.js +6 -1
  195. package/.next/standalone/node_modules/next/dist/server/route-modules/route-module.js +9 -2
  196. package/.next/standalone/node_modules/next/dist/server/use-cache/use-cache-wrapper.js +30 -29
  197. package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/app-paths.js +18 -1
  198. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  199. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  200. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  201. package/.next/standalone/node_modules/next/package.json +10 -10
  202. package/.next/standalone/package.json +14 -11
  203. package/.next/standalone/sdk/python/README.md +41 -0
  204. package/.next/standalone/sdk/python/docs/README.md +1 -0
  205. package/.next/standalone/sdk/python/docs/otel/README.md +36 -0
  206. package/.next/standalone/sdk/python/docs/otel/examples/agent_trace.py +30 -0
  207. package/.next/standalone/sdk/python/docs/otel/examples/quickstart.py +22 -0
  208. package/.next/standalone/sdk/python/failproofai_sdk/__init__.py +8 -0
  209. package/.next/standalone/sdk/python/failproofai_sdk/integrations/__init__.py +3 -0
  210. package/.next/standalone/sdk/python/failproofai_sdk/integrations/otel.py +189 -0
  211. package/.next/standalone/sdk/python/pyproject.toml +2 -1
  212. package/.next/standalone/sdk/python/tests/test_docs.py +16 -3
  213. package/.next/standalone/sdk/python/tests/test_integrations.py +1 -1
  214. package/.next/standalone/sdk/python/tests/test_otel.py +147 -0
  215. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +4 -1
  216. package/.next/standalone/sdk/python/uv.lock +258 -147
  217. package/.next/standalone/sdk/typescript/README.md +43 -0
  218. package/.next/standalone/sdk/typescript/integration/ai.test.ts +9 -2
  219. package/.next/standalone/sdk/typescript/package-lock.json +57 -5
  220. package/.next/standalone/sdk/typescript/package.json +2 -0
  221. package/.next/standalone/sdk/typescript/src/edge/index.ts +9 -0
  222. package/.next/standalone/sdk/typescript/src/index.ts +2 -0
  223. package/.next/standalone/sdk/typescript/src/integrations/index.ts +4 -1
  224. package/.next/standalone/sdk/typescript/src/integrations/otel.ts +204 -0
  225. package/.next/standalone/sdk/typescript/test/integrations.test.ts +1 -1
  226. package/.next/standalone/sdk/typescript/test/otel.test.ts +125 -0
  227. package/.next/standalone/server.js +1 -1
  228. package/bin/failproofai.mjs +653 -1077
  229. package/dist/cli.mjs +8781 -4050
  230. package/dist/worker.mjs +170 -62
  231. package/hermes-plugin/plugin.yaml +1 -1
  232. package/package.json +14 -11
  233. package/scripts/launch-facts.ts +157 -0
  234. package/scripts/launch-ready.ts +228 -0
  235. package/scripts/launch-screen.ts +202 -0
  236. package/scripts/launch.ts +106 -49
  237. package/scripts/skew-log-filter.ts +38 -0
  238. package/src/audit/cli-login.ts +39 -59
  239. package/src/audit/cli.ts +308 -228
  240. package/src/audit/index.ts +45 -2
  241. package/src/audit/schedule-cli.ts +83 -120
  242. package/src/audit/types.ts +31 -0
  243. package/src/hooks/agent-selection.ts +155 -0
  244. package/src/hooks/backfill-cli.ts +151 -137
  245. package/src/hooks/backfill-request.ts +149 -0
  246. package/src/hooks/cloud-connection.ts +1 -1
  247. package/src/hooks/cloud-enrollment-cli.ts +78 -28
  248. package/src/hooks/cloud-introspect.ts +3 -0
  249. package/src/hooks/collector-config.ts +10 -1
  250. package/src/hooks/config-status.ts +717 -0
  251. package/src/hooks/configure-wizard.ts +967 -988
  252. package/src/hooks/daemon-service.ts +16 -6
  253. package/src/hooks/did-you-mean.ts +185 -0
  254. package/src/hooks/flush-cli.ts +33 -7
  255. package/src/hooks/fp-config.ts +96 -0
  256. package/src/hooks/fp-home.ts +11 -0
  257. package/src/hooks/fp-reset.ts +102 -95
  258. package/src/hooks/handler.ts +4 -0
  259. package/src/hooks/harness-cli.ts +87 -138
  260. package/src/hooks/install-prompt.ts +38 -477
  261. package/src/hooks/integrations.ts +1 -1
  262. package/src/hooks/jev-cli.ts +183 -196
  263. package/src/hooks/manager.ts +554 -358
  264. package/src/hooks/onboarding-attempt.ts +11 -9
  265. package/src/hooks/otel-cli.ts +350 -0
  266. package/src/hooks/otel-output.ts +24 -0
  267. package/src/hooks/otel-writers.ts +215 -0
  268. package/src/hooks/pack-cli.ts +219 -204
  269. package/src/hooks/report-body.ts +50 -0
  270. package/src/hooks/safe-config-write.ts +4 -2
  271. package/src/hooks/session-pause-cli.ts +93 -73
  272. package/src/hooks/tui.ts +849 -389
  273. package/src/hooks/uninstall-cli.ts +136 -149
  274. package/src/hooks/untraced-note.ts +52 -0
  275. package/.next/standalone/.next/server/chunks/[externals]__19_pzeq._.js +0 -3
  276. package/.next/standalone/.next/server/chunks/[externals]__20kzpkf._.js +0 -3
  277. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +0 -3
  278. package/.next/standalone/.next/server/chunks/[root-of-the-server]__05ja_05._.js +0 -3
  279. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0igxt32._.js +0 -3
  280. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zyc_nm._.js +0 -3
  281. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ttrwnd._.js +0 -22
  282. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +0 -3
  283. package/.next/standalone/.next/server/chunks/_185gyk9._.js +0 -3
  284. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +0 -4
  285. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +0 -4
  286. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +0 -4
  287. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0yrsbd_._.js +0 -3
  288. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +0 -4
  289. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +0 -4
  290. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +0 -4
  291. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +0 -4
  292. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_1n3w9lb._.js +0 -6
  293. package/.next/standalone/.next/static/chunks/1lyg_puttohbc.js +0 -1
  294. /package/.next/standalone/.next/static/{ECs8__hcNabYV58WfDh-3 → xtcpqBNBdjQVerPMkI2HI}/_buildManifest.js +0 -0
  295. /package/.next/standalone/.next/static/{ECs8__hcNabYV58WfDh-3 → xtcpqBNBdjQVerPMkI2HI}/_clientMiddlewareManifest.js +0 -0
  296. /package/.next/standalone/.next/static/{ECs8__hcNabYV58WfDh-3 → xtcpqBNBdjQVerPMkI2HI}/_ssgManifest.js +0 -0
@@ -1,18 +1,15 @@
1
1
  /**
2
2
  * `failproofai config` — the interactive setup launcher.
3
3
  *
4
- * A single guided flow that sets up the whole failproofai ecosystem:
4
+ * Five steps, in the order the machine needs them:
5
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. Policies — multi-select of themed presets (combine any) or Everything
9
- * 3. Assistants— multi-select of agent harnesses (detected + install-ahead)
10
- * 4. Connect — paste an API key, or stay fully local
11
- * 5. Review — shows exactly which files change, then Apply.
12
- *
13
- * Selections REPLACE the enabled set at the chosen scope (the picker pre-checks
14
- * whatever is already enabled, so unticking removes). Reuses the tested
15
- * install/uninstall manager and the existing searchable policy picker.
6
+ * 1. Daemon — REQUIRED. Asks for sudo first, on a clean terminal.
7
+ * 2. Connect — one masked API-key field; Tab for open source.
8
+ * 3. Agents — which agents failproofai traces: hooks, collection and
9
+ * backfill all follow this one saved list.
10
+ * 4. OTEL — optional, default No, on interactive Cloud setup only.
11
+ * 5. Done — applied after the last answer; there is no separate review.
12
+ * Esc at any step before that changes nothing.
16
13
  *
17
14
  * ## Two ordering rules that are not cosmetic
18
15
  *
@@ -21,10 +18,11 @@
21
18
  * fired from underneath a rendered screen the prompt is invisible and the typed
22
19
  * password lands in a redrawn frame.
23
20
  *
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.
21
+ * **Everything is written at apply, the daemon before anything else.** Setup
22
+ * requires the daemon, so a failure has to leave the machine exactly as it was
23
+ * found rather than half-configured. Writing hooks first and discovering the
24
+ * service will not start afterwards is the one ordering that cannot be undone
25
+ * cleanly.
28
26
  */
29
27
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
30
28
  import { homedir } from "node:os";
@@ -34,26 +32,33 @@ import { CORE_SOURCE } from "./pack-store";
34
32
 
35
33
  import {
36
34
  selectOne,
37
- promptText,
38
- intro,
39
- outro,
40
- summarize,
35
+ collapseAfter,
36
+ colorsEnabled,
37
+ multiSelect,
38
+ OPEN_SOURCE,
39
+ optsFor,
40
+ paint,
41
+ promptCloudKey,
42
+ screenKit,
43
+ type CloudKeyAnswer,
41
44
  type MultiChoice,
45
+ type OpenSource,
42
46
  type TTYIn,
43
47
  type TTYOut,
44
48
  } from "./tui";
45
49
  import {
46
50
  DEFAULT_INGEST_URL,
51
+ readIngestCredential,
47
52
  validateIngestKey,
48
53
  } from "./collector-config";
49
54
  import {
50
55
  detectInstalledClis,
51
56
  getIntegration,
52
57
  settingsPathsFor,
53
- unhookedHermesProfiles,
54
58
  } from "./integrations";
55
59
  import { INTEGRATION_TYPES, type IntegrationType, type HookScope } from "./types";
56
- import { installHooks } from "./manager";
60
+ import { enforcingPolicyCount, installHooks, notEnforcingReason } from "./manager";
61
+ import { readInstalledPacks } from "./pack-manifest";
57
62
  import { getConfigPathForScope, readScopedHooksConfig } from "./hooks-config";
58
63
  import { discoverPolicyFiles, findSkippedPolicyFiles } from "./custom-hooks-loader";
59
64
  import { trackHookEvent } from "./hook-telemetry";
@@ -62,7 +67,6 @@ import {
62
67
  canElevate,
63
68
  isDaemonSupportedPlatform,
64
69
  installDaemonService,
65
- daemonServiceFilePath,
66
70
  daemonServiceStatus,
67
71
  daemonServiceNeedsUpgrade,
68
72
  daemonStatusCommand,
@@ -76,6 +80,7 @@ import {
76
80
  } from "./daemon-service";
77
81
  import { hookLogWarn } from "./hook-logger";
78
82
  import {
83
+ maskToken,
79
84
  readCloudCredentials,
80
85
  resolveMachineId,
81
86
  resolveMachineLabel,
@@ -85,12 +90,15 @@ import {
85
90
  cloudBaseFor,
86
91
  ingestUrlFor,
87
92
  connectToCloud,
88
- describeOutcome,
93
+ jevLines,
89
94
  } from "./cloud-connection";
95
+ import { hasPermission, introspectKey, PERMISSION_EVENTS, PERMISSION_POLICIES } from "./cloud-introspect";
96
+ import { readConfig, readCredentials, updateConfig } from "./fp-config";
97
+ import { requestFreshStart } from "./backfill-request";
98
+ import { hookedAtUserScope } from "./agent-selection";
90
99
  import {
91
100
  detectSetupState,
92
101
  isConfigured,
93
- scopesFor,
94
102
  type SetupTarget,
95
103
  } from "./setup-state";
96
104
  import { customPoliciesDir, launcherMarker } from "./fp-home";
@@ -134,6 +142,10 @@ export interface WizardAnswers {
134
142
  machineLabel?: string;
135
143
  /** Record decisions only, no session transcripts. */
136
144
  noTranscripts?: boolean;
145
+ /** `--oss`: skip the connect step. A connection already here is left as it is. */
146
+ oss?: boolean;
147
+ /** `--agents`: the agents to trace, already validated. Skips the agents step. */
148
+ agents?: IntegrationType[];
137
149
  }
138
150
 
139
151
  /**
@@ -149,7 +161,9 @@ export type WizardAbort =
149
161
  | "unsupported_platform"
150
162
  | "running_as_sudo"
151
163
  /** A cloud key was supplied and the server refused it. Unattended only. */
152
- | "cloud_unverified";
164
+ | "cloud_unverified"
165
+ /** A key was supplied on the command line, and --url or FAILPROOFAI_CLOUD_URL cannot be used. */
166
+ | "bad_cloud_url";
153
167
 
154
168
  export interface WizardResult {
155
169
  applied: boolean;
@@ -160,7 +174,11 @@ export interface WizardResult {
160
174
  clis?: IntegrationType[];
161
175
  policies?: string[];
162
176
  daemonInstalled?: boolean;
177
+ /** A connection was written by this run. */
163
178
  connected?: boolean;
179
+ /** Whether the machine is connected once the run ends. */
180
+ mode?: "cloud" | "oss";
181
+ agents?: { selected: IntegrationType[]; added: IntegrationType[]; removed: IntegrationType[] };
164
182
  }
165
183
 
166
184
  async function emit(event: string, props: Record<string, unknown>): Promise<void> {
@@ -189,70 +207,221 @@ export function clisSupportingScope(scope: HookScope): IntegrationType[] {
189
207
  return INTEGRATION_TYPES.filter((id) => getIntegration(id).scopes.includes(scope));
190
208
  }
191
209
 
192
- export function buildAgentChoices(scope: HookScope, cwd: string): MultiChoice<IntegrationType>[] {
193
- const detected = new Set(detectInstalledClis());
194
- // Detected first, then the rest as "install ahead of time".
195
- const ordered = [
210
+ /**
211
+ * Which agents a run starts with ticked, and what it compares the answer to.
212
+ *
213
+ * Three cases, told apart by what is on disk:
214
+ * - **rerun**: a saved selection exists. It is restored exactly, plus any
215
+ * agent detected now that no earlier setup saw — ticked, and tagged new.
216
+ * - **upgrade**: no saved selection, but hooks are installed (a machine set
217
+ * up before the selection existed). What is protected today is ticked, so
218
+ * finishing setup neither widens nor narrows it.
219
+ * - **first**: neither. The detected agents are ticked.
220
+ *
221
+ * Every agent is listed, detected or not (decision D1): an agent installed
222
+ * next week can be ticked today, and an empty machine is not stuck.
223
+ */
224
+ export type AgentPlanMode = "first" | "rerun" | "upgrade";
225
+
226
+ export interface AgentPlan {
227
+ mode: AgentPlanMode;
228
+ /** Every agent: the detected ones first, then the rest. */
229
+ order: IntegrationType[];
230
+ detected: Set<IntegrationType>;
231
+ /** What the answer is compared to: the saved selection, or what is hooked today. */
232
+ baseline: Set<IntegrationType>;
233
+ /** Detected now and seen at no earlier setup. Re-runs only. */
234
+ fresh: Set<IntegrationType>;
235
+ /** Ticked when the step opens. */
236
+ initial: IntegrationType[];
237
+ /** The `seen` list to save: every agent detected at any setup so far. */
238
+ seen: IntegrationType[];
239
+ }
240
+
241
+ export function planAgents(input: {
242
+ detected: IntegrationType[];
243
+ saved?: { selected: string[]; seen: string[] };
244
+ hooked: IntegrationType[];
245
+ }): AgentPlan {
246
+ const known = (ids: string[]): IntegrationType[] =>
247
+ INTEGRATION_TYPES.filter((id) => ids.includes(id));
248
+ const detected = new Set(known(input.detected));
249
+ const order = [
196
250
  ...INTEGRATION_TYPES.filter((id) => detected.has(id)),
197
251
  ...INTEGRATION_TYPES.filter((id) => !detected.has(id)),
198
252
  ];
199
- return ordered.map((id) => {
200
- const integration = getIntegration(id);
201
- const isDetected = detected.has(id);
202
-
203
- // Not every CLI can be configured at every scope — Hermes and OpenClaw
204
- // have no project config at all. Offering them anyway meant picking "Just
205
- // this project" and applying died with `Scope "project" is not supported
206
- // by Hermes`, after the user had answered every question. Show them as
207
- // locked and unchecked with the reason, so the constraint is visible
208
- // instead of being discovered as a crash.
209
- const supported = integration.scopes.includes(scope);
210
- if (!supported) {
211
- return {
212
- label: integration.displayName,
213
- value: id,
214
- checked: false,
215
- locked: true,
216
- section: "Global only · not configurable per-project",
217
- hint: `supports ${integration.scopes.join(", ")} scope — rerun with "Everywhere I code"`,
218
- };
253
+ const ordered = (ids: Iterable<IntegrationType>): IntegrationType[] => {
254
+ const set = new Set(ids);
255
+ return order.filter((id) => set.has(id));
256
+ };
257
+ // Seen accumulates. Rebuilding it from this run's detection would let a PATH
258
+ // glitch forget an agent, and the next run would call it new — ticked — after
259
+ // somebody had deliberately unticked it.
260
+ const seen = ordered([...known(input.saved?.seen ?? []), ...detected]);
261
+
262
+ if (input.saved) {
263
+ const baseline = new Set(known(input.saved.selected));
264
+ const previouslySeen = new Set(known(input.saved.seen));
265
+ const fresh = new Set([...detected].filter((id) => !previouslySeen.has(id) && !baseline.has(id)));
266
+ return { mode: "rerun", order, detected, baseline, fresh, initial: ordered([...baseline, ...fresh]), seen };
267
+ }
268
+ const hooked = known(input.hooked);
269
+ if (hooked.length > 0) {
270
+ return { mode: "upgrade", order, detected, baseline: new Set(hooked), fresh: new Set(), initial: ordered(hooked), seen };
271
+ }
272
+ return { mode: "first", order, detected, baseline: new Set(), fresh: new Set(), initial: ordered(detected), seen };
273
+ }
274
+
275
+ /**
276
+ * The selection a run takes without asking: no terminal, or a `--token` run.
277
+ * The plan's starting ticks — and every agent when those are empty, which only
278
+ * happens on a machine where nothing is detected, hooked or saved. Hooking
279
+ * nothing there would leave setup finished and nothing guarded.
280
+ */
281
+ export function defaultSelection(plan: AgentPlan): IntegrationType[] {
282
+ // A saved answer stands even when it is empty: removing every agent's hooks
283
+ // saves an empty selection, and an unattended re-run must not put them back.
284
+ if (plan.mode === "rerun") return plan.initial;
285
+ return plan.initial.length > 0 ? plan.initial : [...plan.order];
286
+ }
287
+
288
+ /** What a chosen selection changes, against the plan's baseline. */
289
+ export function agentChanges(
290
+ plan: AgentPlan,
291
+ selected: IntegrationType[],
292
+ ): { added: IntegrationType[]; removed: IntegrationType[] } {
293
+ const chosen = new Set(selected);
294
+ return {
295
+ // On a first run nothing was traced before, so nothing is "added" back.
296
+ added: plan.mode === "first" ? [] : plan.order.filter((id) => chosen.has(id) && !plan.baseline.has(id)),
297
+ removed: plan.order.filter((id) => !chosen.has(id) && plan.baseline.has(id)),
298
+ };
299
+ }
300
+
301
+ /** The picker's rows: one per agent, in the plan's order. */
302
+ export function agentChoices(plan: AgentPlan): MultiChoice<IntegrationType>[] {
303
+ return plan.order.map((id) => ({
304
+ label: getIntegration(id).displayName,
305
+ value: id,
306
+ checked: plan.initial.includes(id),
307
+ }));
308
+ }
309
+
310
+ /**
311
+ * The tag after a row, recomputed on every toggle: `new on this machine`,
312
+ * then what the toggle changes against the baseline, then `not installed`.
313
+ * No change tags on a first run — every tick would read "will be added".
314
+ */
315
+ export function agentTag(
316
+ plan: AgentPlan,
317
+ c: ReturnType<typeof paint>,
318
+ ): (id: IntegrationType, checked: boolean) => string | undefined {
319
+ return (id, checked) => {
320
+ if (plan.fresh.has(id)) return c.guide("new on this machine");
321
+ if (plan.mode !== "first") {
322
+ const before = plan.baseline.has(id);
323
+ if (checked && !before) return c.guide("will be added");
324
+ if (!checked && before) return c.warn("will be removed");
219
325
  }
326
+ if (!plan.detected.has(id)) return c.ink3("not installed");
327
+ return undefined;
328
+ };
329
+ }
220
330
 
221
- let installedHere = false;
222
- try {
223
- installedHere = integration.hooksInstalledInSettings(scope, cwd);
224
- } catch {
225
- installedHere = false;
331
+ /** "A", "A and B", "A, B and C". */
332
+ function namesList(ids: IntegrationType[]): string {
333
+ const names = ids.map((id) => getIntegration(id).displayName);
334
+ return names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
335
+ }
336
+
337
+ /** Wrap `text` after a 2-column glyph, continuing under it, within `width`. */
338
+ function wrapAfterGlyph(glyph: string, text: string, width: number): string[] {
339
+ const out: string[] = [];
340
+ let line = "";
341
+ for (const word of text.split(" ")) {
342
+ if (line && 2 + line.length + 1 + word.length > width) {
343
+ out.push(line);
344
+ line = word;
345
+ } else {
346
+ line = line ? `${line} ${word}` : word;
226
347
  }
227
- // Hermes reports installed only when EVERY profile is hooked, so a profile
228
- // added after install flips it to false. Say which ones, otherwise a mostly
229
- // configured gateway just reads as "not configured".
230
- let partialHint: string | undefined;
231
- if (id === "hermes" && !installedHere) {
348
+ }
349
+ if (line) out.push(line);
350
+ return out.map((l, i) => (i === 0 ? `${glyph} ${l}` : ` ${l}`));
351
+ }
352
+
353
+ /**
354
+ * The AGENTS step, collapsed. Names are listed when nothing changed against
355
+ * the baseline; otherwise the count, then what will be added and removed. What a
356
+ * removal promises is only what the code does: no more hooks, and on a
357
+ * connected machine no more collection. Nothing already collected is deleted.
358
+ */
359
+ export function agentsCollapsedLines(
360
+ kit: ReturnType<typeof screenKit>,
361
+ plan: AgentPlan,
362
+ selected: IntegrationType[],
363
+ collecting: boolean,
364
+ c: ReturnType<typeof paint>,
365
+ ): string[] {
366
+ const { added, removed } = agentChanges(plan, selected);
367
+ const n = selected.length;
368
+ const tracing = `Tracing ${n} ${n === 1 ? "agent" : "agents"}`;
369
+ const lines = [kit.head("Agents")];
370
+ if (n === 0) {
371
+ lines.push(kit.caution("Tracing no agents.", "failproofai config"));
372
+ return lines;
373
+ }
374
+ if (added.length === 0 && removed.length === 0) {
375
+ const list = selected.map((id) => getIntegration(id).displayName).join(", ");
376
+ lines.push(...wrapAfterGlyph(c.guide("✓"), `${tracing}: ${list}`, kit.cols));
377
+ return lines;
378
+ }
379
+ lines.push(kit.ok(tracing));
380
+ // The choice, not the result: this prints before apply, and an apply that
381
+ // stops at the daemon changes nothing (decision D3).
382
+ if (added.length > 0) lines.push(` Adding ${namesList(added)}.`);
383
+ if (removed.length > 0) {
384
+ const they = removed.length === 1 ? "it" : "they";
385
+ lines.push(
386
+ collecting
387
+ ? ` Removing ${namesList(removed)}: ${they} will no longer be hooked or collected. Sessions already collected are kept.`
388
+ : ` Removing ${namesList(removed)}: ${they} will no longer be hooked.`,
389
+ );
390
+ }
391
+ return lines;
392
+ }
393
+
394
+ /** Lives with the selection it seeds; re-exported where setup always had it. */
395
+ export { hookedAtUserScope };
396
+
397
+ /**
398
+ * Take failproofai's hooks out of these agents' USER settings, and nothing
399
+ * else. Not `removeHooks`: that is the full uninstall, which also strips
400
+ * project and local hooks under the working directory and resets the policy
401
+ * configuration of every scope. Unticking an agent in setup must leave every
402
+ * `policies-config.json` exactly as it was.
403
+ */
404
+ export function removeUserScopeHooks(clis: IntegrationType[], cwd: string): number {
405
+ let removed = 0;
406
+ for (const id of clis) {
407
+ const integration = getIntegration(id);
408
+ if (!integration.scopes.includes("user")) continue;
409
+ for (const settingsPath of settingsPathsFor(integration, "user", cwd)) {
410
+ // Hermes is asked even without a config file, so it can clear a plugin
411
+ // directory left behind by an interrupted install.
412
+ if (id !== "hermes" && !existsSync(settingsPath)) continue;
232
413
  try {
233
- const unhooked = unhookedHermesProfiles();
234
- if (unhooked.length > 0) {
235
- partialHint = `${unhooked.length} unhooked profile(s): ${unhooked.join(", ")}`;
236
- }
237
- } catch {
238
- // Profile discovery is best-effort — never block the wizard.
414
+ removed += integration.removeHooksFromFile(settingsPath);
415
+ } catch (err) {
416
+ hookLogWarn(
417
+ `could not remove failproofai hooks from ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`,
418
+ );
239
419
  }
240
420
  }
241
- return {
242
- label: integration.displayName,
243
- value: id,
244
- checked: isDetected || installedHere,
245
- section: isDetected ? "Detected" : "Not installed · set up ahead of time",
246
- hint: installedHere
247
- ? "already configured"
248
- : (partialHint ?? (isDetected ? undefined : "not on PATH")),
249
- };
250
- });
421
+ }
422
+ return removed;
251
423
  }
252
424
 
253
-
254
- const DIM_NOTE = "(auto-loaded)";
255
-
256
425
  /**
257
426
  * Persist the Custom checkbox into the scope's config, after installHooks has
258
427
  * written it (installHooks copies the previous config forward, so writing
@@ -363,196 +532,6 @@ export function describeCustomPolicies(cwd: string): {
363
532
  return { active, warnings, fileCount, scopes };
364
533
  }
365
534
 
366
- /**
367
- * The wizard's one-line completion summary. Pure and exported so the widest
368
- * real combination — every policy, every CLI, custom/daemon/reporting all
369
- * present — can be pinned by a test without having to drive the whole wizard
370
- * through a real chdir + on-disk custom-policy fixture.
371
- *
372
- * Kept inside a standard 80-column terminal: `writeLines` truncates with a
373
- * hard cut and no ellipsis, so an over-long line doesn't just lose its tail
374
- * — it reads as broken output. Naming all ten CLIs once took it to 182
375
- * characters; the count alone carries the same information, and the user
376
- * picked them two screens ago. A single grouped "· a, b, c" clause bounds the
377
- * optional notes to one separator and short tags, rather than three
378
- * independent " · " clauses stacking up.
379
- */
380
- export function buildCompletionSummary(
381
- policiesCount: number,
382
- harnessesCount: number,
383
- customEnabled: boolean | undefined,
384
- daemonInstalled: boolean,
385
- connected: boolean,
386
- ): string {
387
- const extras: string[] = [];
388
- if (customEnabled === true) extras.push("custom");
389
- else if (customEnabled === false) extras.push("custom off");
390
- if (daemonInstalled) extras.push("daemon");
391
- if (connected) extras.push("reporting");
392
- const extrasNote = extras.length > 0 ? ` · ${extras.join(", ")}` : "";
393
- const harnesses = `${harnessesCount} harness${harnessesCount === 1 ? "" : "es"}`;
394
- const line = (selection: string) =>
395
- `Setup complete — ${selection} · ${harnesses}${extrasNote}`;
396
-
397
- // Bound the WHOLE line, not just the names. `writeLines` truncates with a hard
398
- // cut and no ellipsis, so 81 characters does not lose a tail — it reads as
399
- // broken output. Naming is preferred and degrades to the count only when the
400
- // full line will not fit, which is checked rather than guessed at: the extras
401
- // clause grows too ("custom, daemon, reporting" is 25 characters), so a names
402
- // budget alone was wrong for exactly the combinations that need it most.
403
- return line(describeSelection(policiesCount));
404
- }
405
-
406
- /**
407
- * The budget this line has to fit in.
408
- *
409
- * 80 columns minus the 3-column gutter the existing summary tests already assert
410
- * (`message.length + GUTTER <= 80`) — matching that convention rather than
411
- * inventing a second one, because two different width rules for the same line is
412
- * how one of them ends up wrong.
413
- */
414
- const MAX_SUMMARY_COLUMNS = 77;
415
-
416
- /** The column this line aligns under, matching ` Policies : `. */
417
- const POLICY_LIST_INDENT = " ".repeat(15);
418
-
419
- /**
420
- * A taste of the set, not the set: two names and a count of the rest.
421
- *
422
- * The same shape `describeSelection` uses for bundles, for the same reason —
423
- * naming everything turned a four-line review screen into a fifteen-line one,
424
- * and a screen nobody reads to the bottom is worse at conveying what is about
425
- * to happen than a short one. Two names say what KIND of thing these are;
426
- * `failproofai policies list` says the rest, for the person who wants it.
427
- *
428
- * The whole review body is rendered dim by the prompt (`tui.ts`), so this
429
- * reads as a subtitle to the count above it rather than competing with it.
430
- *
431
- * Degrades by dropping names rather than by overflowing: `writeLines`
432
- * truncates with a hard cut and no ellipsis, so a line that runs over ends
433
- * mid-slug and reads as a policy name that does not exist. Two long names
434
- * (`sanitize-private-key-content` is 28) plus the prefix is within budget, but
435
- * the guard is here rather than argued about, because a future rename is
436
- * exactly the kind of change nobody re-measures.
437
- */
438
- export function policyNamesLine(names: string[]): string[] {
439
- if (names.length === 0) return [];
440
- const budget = MAX_SUMMARY_COLUMNS - POLICY_LIST_INDENT.length;
441
- for (const take of [2, 1]) {
442
- const shown = names.slice(0, take);
443
- const rest = names.length - shown.length;
444
- const text = rest > 0 ? `${shown.join(", ")} +${rest}` : shown.join(", ");
445
- if (text.length <= budget) return [`${POLICY_LIST_INDENT}${text}`];
446
- }
447
- return [];
448
- }
449
-
450
- /** The Policies line's value. With policies no longer chosen during setup, this
451
- * reports what is enabled and nothing about how it got that way. */
452
- function describeSelection(policiesCount: number): string {
453
- return `${policiesCount} ${policiesCount === 1 ? "policy" : "policies"}`;
454
- }
455
-
456
-
457
- export function reviewLines(state: {
458
- /** What the scope step resolved to. Expands to one or two real scopes. */
459
- target: SetupTarget;
460
- clis: IntegrationType[];
461
- policies: string[];
462
- cwd: string;
463
- /** The Custom checkbox. `undefined` = nothing to toggle, leave as-is. */
464
- customEnabled?: boolean;
465
- /**
466
- * Whether a daemon will be installed by this run. False when one is already
467
- * healthy (nothing to do) or the platform has no service manager — showing
468
- * it any other way would promise work the apply will not perform.
469
- */
470
- installDaemon?: boolean;
471
- /** Whether an API key will be written and reporting turned on. */
472
- connect?: boolean;
473
- }): string[] {
474
- const { target, clis, policies, cwd, customEnabled, installDaemon, connect } = state;
475
- const scopes = scopesFor(target);
476
- const where =
477
- target === "both"
478
- ? `Global, plus this project (${homeify(cwd)})`
479
- : target === "project"
480
- ? `This project (${homeify(cwd)})`
481
- : "Everywhere (global)";
482
- const lines: string[] = [];
483
- const harnessNames = clis.map((c) => getIntegration(c).displayName);
484
- lines.push(` Where : ${where}`);
485
- lines.push(` Harnesses : ${harnessNames.length ? summarize(harnessNames, "harnesses") : "(none)"}`);
486
- // Zero is a deliberate answer, not a failed step — say so, and say where to
487
- // change it, so the review screen doesn't read like the wizard lost the
488
- // selection. Hooks still install; only the builtin set is empty.
489
- lines.push(
490
- policies.length === 0
491
- ? " Policies : none enabled (add later: failproofai policies --install)"
492
- : ` Policies : ${policies.length} enabled`,
493
- );
494
- // A taste of what they are, under the count. "15 enabled" alone is a number
495
- // the user cannot check and, on the recommended path, did not choose; two
496
- // names say what kind of thing it is without turning the review into a page.
497
- if (policies.length > 0) {
498
- lines.push(...policyNamesLine([...policies].sort()));
499
- }
500
- if (installDaemon && isDaemonSupportedPlatform()) {
501
- lines.push(
502
- ` Daemon : failproofaid, installed as a system service running as you`,
503
- );
504
- }
505
- lines.push(
506
- connect
507
- ? " Reporting : on — policy decisions and session transcripts"
508
- : " Reporting : off — nothing leaves this machine",
509
- );
510
-
511
- // Reflect the Custom decision, not just what is on disk. Reporting
512
- // "1 file (project) (auto-loaded)" after the user had just unticked the row
513
- // stated the opposite of what was about to happen.
514
- const custom = describeCustomPolicies(cwd);
515
- if (custom.active.length > 0) {
516
- lines.push(
517
- ` Custom : ${custom.active.join(" · ")} ${
518
- customEnabled === false ? "— DISABLED, will not load" : DIM_NOTE
519
- }`,
520
- );
521
- }
522
- for (const warning of custom.warnings) lines.push(` ${warning}`);
523
-
524
- lines.push("");
525
- lines.push(" This will update:");
526
- // Deduplicated across scopes: a CLI that supports only user scope resolves to
527
- // the same settings file under both halves of "Both", and listing it twice
528
- // reads as two separate writes.
529
- const seen = new Set<string>();
530
- for (const scope of scopes) {
531
- for (const cli of clis) {
532
- const integration = getIntegration(cli);
533
- if (!integration.scopes.includes(scope)) continue;
534
- // Usually one path; Hermes lists one per profile so the operator sees
535
- // every home dir that is about to be written.
536
- for (const p of settingsPathsFor(integration, scope, cwd)) {
537
- if (seen.has(p)) continue;
538
- seen.add(p);
539
- lines.push(` ${homeify(p)} ${integration.displayName} hooks`);
540
- }
541
- }
542
- }
543
- for (const scope of scopes) {
544
- const configPath = getConfigPathForScope(scope, cwd);
545
- if (seen.has(configPath)) continue;
546
- seen.add(configPath);
547
- lines.push(` ${homeify(configPath)} ${policies.length} policies`);
548
- }
549
- if (installDaemon && isDaemonSupportedPlatform()) {
550
- const servicePath = daemonServiceFilePath();
551
- if (servicePath) lines.push(` ${homeify(servicePath)} failproofaid service (needs root)`);
552
- }
553
- return lines;
554
- }
555
-
556
535
  // ── First-run redirect ───────────────────────────────────────────────────────
557
536
 
558
537
  function firstRunMarkerPath(): string {
@@ -670,7 +649,7 @@ export async function maybeFirstRunConfigure(
670
649
  // and it must stay a hint rather than a failure: the command the user
671
650
  // actually typed still runs.
672
651
  stdout.write(
673
- `\n[failproofai] Not set up yet — run \`failproofai config\` to get started.\n\n`,
652
+ `\n${screenKit(optsFor(stdout)).caution("failproofai is not set up yet.", "failproofai config")}\n\n`,
674
653
  );
675
654
  return false;
676
655
  }
@@ -681,7 +660,7 @@ export async function maybeFirstRunConfigure(
681
660
  const lock = acquireOnboardingLock();
682
661
  if (!lock) {
683
662
  stdout.write(
684
- `\n[failproofai] Setup is already running in another terminal — leaving it to finish.\n\n`,
663
+ `\n${screenKit(optsFor(stdout)).caution("Setup is already running in another terminal, so this one leaves it to finish.")}\n\n`,
685
664
  );
686
665
  return false;
687
666
  }
@@ -730,31 +709,22 @@ export async function runConfigureWizard(
730
709
  const stdin: TTYIn = io.stdin ?? process.stdin;
731
710
  const stdout: TTYOut = io.stdout ?? process.stdout;
732
711
  const cwd = process.cwd();
712
+ const home = process.env.HOME || homedir();
713
+ const started = Date.now();
733
714
  // No terminal means no questions — not a refusal.
734
715
  //
735
716
  // `failproofai config` IS the authorisation: somebody typed the command whose
736
- // entire job is to configure this machine. Requiring a flag on top of that to
737
- // permit what the command already means is asking the same question twice,
738
- // and this used to refuse outright — so a CI job, a container or an agent
739
- // could not set a machine up at all, which is the gap this closes.
740
- //
741
- // Safe to do here because the IMPLICIT path is guarded separately:
742
- // `maybeFirstRunConfigure` has its own TTY check and returns before ever
743
- // reaching this function, so setup never runs off the back of some other
744
- // command on a headless box.
717
+ // entire job is to configure this machine. Safe because the IMPLICIT path is
718
+ // guarded separately: `maybeFirstRunConfigure` has its own TTY check and
719
+ // returns before ever reaching this function, so setup never runs off the back
720
+ // of some other command on a headless box.
745
721
  const unattended = !stdin.isTTY || !stdout.isTTY;
746
722
  // A key on the command line settles the whole run, not just the question it
747
- // literally answers.
748
- //
749
- // Somebody typing `failproofai config --token <key>` has said: set this
750
- // machine up, connect it, send its data. There is nothing left to confirm —
751
- // so asking "Connect to Cloud?", then the key, then "Ready to apply?" made
752
- // the flag look like it had not been read. The only thing still worth
753
- // stopping for is the sudo password, which is a CREDENTIAL rather than a
754
- // question, and no flag can supply it.
723
+ // literally answers: set this machine up, connect it, send its data. The only
724
+ // thing still worth stopping for is the sudo password, which is a CREDENTIAL
725
+ // rather than a question, and no flag can supply it.
755
726
  const preAnswered = Boolean(answers.token);
756
727
 
757
-
758
728
  // Running the wizard itself under sudo configures the WRONG ACCOUNT, and
759
729
  // does it silently: homedir() becomes /root, so the hooks land in root's
760
730
  // settings, `daemonConfigured` is set for root, the daemon binary downloads
@@ -770,9 +740,6 @@ export async function runConfigureWizard(
770
740
  "The one step that needs root (installing the service) asks for your password\n" +
771
741
  "on its own.\n",
772
742
  );
773
- // Same reason as above: this configured nothing, and it is not a
774
- // cancellation. `running_as_sudo` has been in `WizardAbort` since it was
775
- // written and was never once assigned.
776
743
  return { applied: false, abort: "running_as_sudo" };
777
744
  }
778
745
 
@@ -780,10 +747,10 @@ export async function runConfigureWizard(
780
747
  void emit("configure_started", {});
781
748
 
782
749
  // failproofaid — the only evaluator on a configured machine — only runs on
783
- // Linux and macOS. Checked before intro() draws anything and before a
784
- // single prompt is asked: completing setup anyway used to leave e.g. a
785
- // Windows machine reading as configured while enforcing in-process with no
786
- // fail-closed guarantee, which is worse than not being set up at all.
750
+ // Linux and macOS. Checked before a single prompt is asked: completing setup
751
+ // anyway used to leave e.g. a Windows machine reading as configured while
752
+ // enforcing in-process with no fail-closed guarantee, which is worse than not
753
+ // being set up at all.
787
754
  if (!isDaemonSupportedPlatform()) {
788
755
  stdout.write(
789
756
  `failproofai requires failproofaid, its background policy daemon, which runs on\n` +
@@ -797,614 +764,440 @@ export async function runConfigureWizard(
797
764
  return { applied: false, abort: "unsupported_platform" };
798
765
  }
799
766
 
800
- intro("let's set up failproofai", stdout);
767
+ const c = paint(colorsEnabled(stdout));
768
+ const kit = screenKit({ ...optsFor(stdout), fit: Boolean(stdout.isTTY), version: cliVersion });
769
+ const write = (lines: string[]): void => {
770
+ stdout.write(lines.map((line) => `${line}\n`).join(""));
771
+ };
772
+ // A grey line saying what apply is doing right now, removed once it is done.
773
+ // Without a terminal it stays, as a log line.
774
+ const transient = async <T,>(text: string, run: () => Promise<T>): Promise<T> => {
775
+ if (!stdout.isTTY) {
776
+ write([text]);
777
+ return run();
778
+ }
779
+ write([c.ink3(text)]);
780
+ try {
781
+ return await run();
782
+ } finally {
783
+ stdout.write("\x1b[1A\x1b[2K");
784
+ }
785
+ };
786
+
787
+ // The logomark opens a config run on a terminal — one of the two places it
788
+ // appears at all.
789
+ if (stdout.isTTY) write(["", ...kit.logo()]);
790
+ write(["", kit.header("Set up this machine"), ""]);
801
791
 
802
792
  const cancel = (): WizardResult => {
803
- outro("Cancelled — nothing was changed.", { ok: false }, stdout);
793
+ write(["", c.ink3("Cancelled. Nothing was changed.")]);
804
794
  // Distinguished from the abort reasons: pressing Esc is not a failure, and
805
795
  // a caller picking an exit code must not treat it as one.
806
796
  return { applied: false, abort: "cancelled" };
807
797
  };
808
798
 
809
- // 0 — The background daemon. REQUIRED, and before anything else.
799
+ // ── DAEMON ──────────────────────────────────────────────────────────────────
810
800
  //
811
801
  // First because it is the only step that needs a password: asking here means
812
- // sudo prompts on a clean terminal, before any question has drawn a screen,
813
- // instead of firing from underneath a rendered TUI where the prompt is
814
- // invisible and the typed password lands in a redrawn frame. `sudo -v`
815
- // caches the credential for the rest of the run, so the actual install at
816
- // apply time stays non-interactive.
817
- //
818
- // Machine-level, so it is deliberately NOT gated on the scope chosen in the
819
- // next step: one daemon serves every project on this machine.
820
- //
821
- // Always true here — the guard near the top of this function already
822
- // refused setup on anything else. Kept as a real read (not a literal
823
- // `true`) so this block still fails safe if that guard is ever moved.
824
- const daemonSupported = isDaemonSupportedPlatform();
825
- // An already-healthy daemon needs no install and no password. Re-running
826
- // setup on a configured machine must not demand sudo for work that is
827
- // already done.
828
- // "running" only. A unit that exists but is stopped or crash-looping is
829
- // exactly the machine that needs this run to reinstall it, and treating
830
- // "installed" as good enough would skip the repair and then set
831
- // `daemonConfigured` against a service that is not answering — which fails
832
- // closed on every tool call.
833
- // Running is not enough — it must also be the version this CLI ships.
802
+ // sudo prompts on a clean terminal, before any question has drawn a screen.
803
+ // `sudo -v` caches the credential for the rest of the run, so the install at
804
+ // apply time stays non-interactive. Machine-level: one daemon serves every
805
+ // project on this machine.
834
806
  //
835
- // Skipping on "running" alone was right for the case it was written for
836
- // (never demand a password for work already done), and exactly backwards
837
- // during an upgrade: the OLD daemon is perfectly healthy, so setup skipped
838
- // it and the stale version survived. That made "just re-run config" — the
839
- // remedy every message points at — silently do nothing.
840
- //
841
- // And running the right VERSION is still not enough: it must be able to
842
- // answer. `ExecStart` bakes in `process.execPath` and an absolute
843
- // `dist/worker.mjs`, so an `nvm uninstall 20` after setup leaves a unit
844
- // systemd calls active whose worker dies on every spawn. That machine reads
845
- // as "already running", so this wizard — the documented remedy, and the only
846
- // caller that can rebuild the unit — skipped it and left the box denying
847
- // every tool call with no route back but hand-editing `config.json`. A real
848
- // hook evaluation is the only check that distinguishes the two.
849
- const daemonSkew = daemonSupported ? daemonVersionSkew() : null;
850
- const daemonState = daemonSupported ? daemonServiceStatus() : "unsupported-platform";
851
- // `unknown` is "the service state could not be READ", which only macOS
852
- // produces: a LaunchDaemon's state lives in launchd's system domain and needs
853
- // root to read, so a sudo cache older than five minutes says nothing about
854
- // whether the daemon is up. Treating that as "stopped" made the wizard demand
855
- // a password and unload/reload a perfectly healthy service — a real
856
- // fail-closed window on a `daemonConfigured` machine, opened to fix nothing,
857
- // and a direct breach of this file's own rule that setup must not demand sudo
858
- // for work already done. So it is probed rather than assumed: the probe is a
859
- // real hook evaluation over the socket, needs no privileges, and answers the
860
- // question the status check was only standing in for.
861
- const daemonMaybeUp =
862
- daemonSupported && (daemonState === "running" || daemonState === "unknown") && daemonSkew === null;
807
+ // An already-healthy daemon needs no install and no password — but healthy
808
+ // means running, the version this CLI ships, AND able to answer. Running
809
+ // alone skipped the stale version during an upgrade; the right version alone
810
+ // waved through a unit whose worker dies on every spawn (`ExecStart` bakes in
811
+ // `process.execPath`, so an `nvm uninstall` breaks it). A real hook
812
+ // evaluation is the only check that tells those apart. `unknown` is macOS's
813
+ // "the state could not be READ" (launchd's system domain needs root), so it is
814
+ // probed rather than assumed down: reinstalling a healthy service opens a
815
+ // fail-closed window to fix nothing.
816
+ const daemonSkew = daemonVersionSkew();
817
+ const daemonState = daemonServiceStatus();
818
+ const daemonMaybeUp = (daemonState === "running" || daemonState === "unknown") && daemonSkew === null;
863
819
  const daemonAnswers = daemonMaybeUp ? await probeDaemonEndToEnd() : false;
864
820
  const daemonAlreadyRunning = daemonMaybeUp && daemonAnswers;
865
- /**
866
- * Installed and running, but its worker cannot evaluate anything.
867
- *
868
- * Keyed on a DEFINITE `running` reading, not on `daemonMaybeUp`: this is the
869
- * branch that tears the service down before rebuilding, and the justification
870
- * for that is knowing a live process is holding the singleton flock. An
871
- * unreadable state is not that knowledge, and the plain install path already
872
- * unloads before it writes.
873
- */
821
+ // Installed and running, but its worker cannot evaluate anything. Keyed on a
822
+ // DEFINITE `running`: this is the branch that tears the service down before
823
+ // rebuilding, justified only by knowing a live process holds the flock.
874
824
  const daemonBroken = daemonState === "running" && daemonSkew === null && !daemonAnswers;
875
- let daemonWanted = daemonSupported && !daemonAlreadyRunning;
876
- // A healthy daemon can still be running a service definition written before
877
- // FAILPROOFAI_CLI_CMD existed, and nothing else on the machine will ever
878
- // rewrite it: upgrading the npm package does not touch /etc/systemd/system.
879
- // Re-running setup is the one moment a user asks for their configuration to
880
- // be brought up to date, so it is the moment to do it.
825
+ const daemonWanted = !daemonAlreadyRunning;
826
+ // A healthy daemon can still run a service definition written before
827
+ // FAILPROOFAI_CLI_CMD existed, and nothing else ever rewrites it: upgrading
828
+ // the npm package does not touch /etc/systemd/system.
881
829
  let daemonUnitStale = daemonAlreadyRunning && daemonServiceNeedsUpgrade();
882
830
 
831
+ // `primeElevation` runs `sudo -v`, which PROMPTS. An unattended run has
832
+ // nobody to type a password, so it goes straight to the `sudo -n` the install
833
+ // uses anyway, and a machine that cannot elevate gets the commands instead of
834
+ // a hung terminal.
835
+ const elevate = (ask: string | null): boolean => {
836
+ if (unattended) {
837
+ if (ask) write([ask]);
838
+ return canElevate();
839
+ }
840
+ // The prompt is erased afterwards, with the line asking for it, so the step
841
+ // collapses to the one line that says how it went.
842
+ const rooted = collapseAfter(stdout, 10, () => {
843
+ if (ask) stdout.write(`${ask}\n`);
844
+ return primeElevation();
845
+ });
846
+ // Erased on failure too, so it is said again where the failure is.
847
+ if (!rooted && ask) write([ask]);
848
+ return rooted;
849
+ };
850
+
851
+ write([kit.head("Daemon")]);
883
852
  if (daemonWanted) {
884
- // Say what is about to happen. Nothing else.
885
- //
886
- // This block explained the warm-worker architecture to somebody who is
887
- // about to type a password — three lines of mechanism answering a question
888
- // nobody had. The install is happening either way; what they need is what
889
- // it is and that it costs one sudo.
890
- //
891
- // The BROKEN case keeps its consequence, and only that: "every tool call is
853
+ // The broken case keeps its consequence on screen: "every tool call is
892
854
  // denied" is the difference between a thirty-second fix and a support
893
- // thread, so it survives any further trim of this block.
894
- stdout.write(
895
- daemonBroken
896
- ? "failproofaid can't evaluate — every tool call is denied. Rebuilding needs sudo.\n\n"
897
- : "Installing failproofaid — needs sudo once.\n\n",
898
- );
899
- // `primeElevation` runs `sudo -v`, which PROMPTS. An unattended run has
900
- // nobody to type a password, so it goes straight to the `sudo -n` that
901
- // `runPrivileged` uses anyway — exactly what `failproofai update` already
902
- // does — and a machine that cannot elevate gets the commands below instead
903
- // of a hung terminal. This is the same answer for both: sudo cannot be
904
- // conjured away, only asked for at a moment somebody can answer.
905
- if (!(unattended ? canElevate() : primeElevation())) {
855
+ // thread, so it is printed outside the region the sudo prompt is erased
856
+ // from.
857
+ if (daemonBroken) write(["failproofaid can't evaluate — every tool call is denied. Rebuilding needs sudo."]);
858
+ const ask = daemonBroken
859
+ ? null
860
+ : daemonSkew
861
+ ? `Updating failproofaid from ${daemonSkew.installed} to ${daemonSkew.expected} needs root once.`
862
+ : "Installing failproofaid needs root once.";
863
+ if (!elevate(ask)) {
906
864
  // Required means required: write nothing at all, so a machine that could
907
- // not be set up is left exactly as it was found rather than carrying
908
- // half a configuration. The commands are printed so an admin can do the
909
- // privileged half by hand.
910
- stdout.write(
911
- "\nCould not get root, so setup stopped before changing anything.\n\n" +
912
- " Re-run once you can use sudo: failproofai config\n" +
913
- ` Check what it needs: ${daemonStatusCommand() ?? "n/a"}\n\n`,
914
- );
865
+ // not be set up is left exactly as it was found.
866
+ write([
867
+ kit.fail("Couldn't get root, so setup stopped before changing anything."),
868
+ ` Re-run once you can use sudo: ${kit.cmd("failproofai config")}`,
869
+ ` Check what it needs: ${kit.cmd(daemonStatusCommand() ?? "n/a")}`,
870
+ ]);
915
871
  void emit("configure_aborted", { reason: "needs_root" });
916
- outro("Nothing was changed.", { ok: false }, stdout);
917
872
  return { applied: false, abort: "needs_root" };
918
873
  }
919
- } else if (daemonUnitStale) {
920
- stdout.write(
921
- "failproofaid is running, but from a service definition written by an older\n" +
922
- "version — it cannot start a scheduled audit. Refreshing it needs root once.\n\n",
923
- );
924
- if (!(unattended ? canElevate() : primeElevation())) {
925
- // NOT an abort, unlike the install branch above. There is a working
926
- // daemon here and hooks are enforcing; only the scheduled audit is out
927
- // of reach. Stopping setup over that would make an upgrade the thing
928
- // that locked someone out of `failproofai config`.
929
- stdout.write(
930
- "\nCould not get root, so the service definition was left as it is.\n" +
931
- "Everything else is set up as normal; scheduled audits stay off until it is\n" +
932
- `refreshed. Re-run \`failproofai config\` once you can use sudo.\n\n`,
933
- );
934
- daemonUnitStale = false;
874
+ write([
875
+ kit.ok(
876
+ daemonBroken
877
+ ? "Root granted to rebuild failproofaid"
878
+ : daemonSkew
879
+ ? `Root granted to update failproofaid to ${daemonSkew.expected}`
880
+ : "Root granted to install failproofaid",
881
+ ),
882
+ ]);
883
+ } else {
884
+ write([kit.ok(`failproofaid ${cliVersion} is already running`)]);
885
+ if (daemonUnitStale) {
886
+ if (elevate("Its service definition is from an older version. Refreshing it needs root once.")) {
887
+ write([kit.ok("Root granted to refresh its service definition")]);
888
+ } else {
889
+ // NOT an abort: hooks are enforcing through a working daemon; only the
890
+ // scheduled audit is out of reach. Stopping setup over that would make
891
+ // an upgrade the thing that locked someone out of `failproofai config`.
892
+ write([kit.caution("No root, so its service definition stays as it is. Scheduled audits stay off until it is refreshed.")]);
893
+ daemonUnitStale = false;
894
+ }
935
895
  }
936
- } else if (daemonAlreadyRunning) {
937
- stdout.write("failproofaid is already installed and running — leaving it alone.\n\n");
938
- }
939
- if (daemonSkew) {
940
- stdout.write(
941
- `failproofaid is ${daemonSkew.installed} but this CLI ships ${daemonSkew.expected} — ` +
942
- `reinstalling it.\n\n`,
943
- );
944
896
  }
897
+ write([""]);
945
898
 
946
- // 0 — Recommended, or choose everything yourself?
947
- //
948
- // ONE linear flow, and no opening fork.
949
- //
950
- // Setup used to ask "Recommended or Customize?" before anything else, which
951
- // is a question about the wizard rather than about the machine — you cannot
952
- // answer it until you know what the alternatives are, and you only learn that
953
- // by picking one. Recommended then silently took global scope, the detected
954
- // CLIs, and fifteen policies nobody had seen.
899
+ // ── CONNECT TO CLOUD ────────────────────────────────────────────────────────
955
900
  //
956
- // What is left is three questions, in the order the machine needs them:
957
- // the daemon (already installed above, because it is the only step that needs
958
- // a password), then which harnesses, then whether to connect. Scope is not
959
- // among them: it is GLOBAL, always. A project-scoped install guards the one
960
- // directory the command happened to be run from and silently leaves every
961
- // other repo on the machine unguarded — `failproofai policies --install
962
- // --scope project` is still there for someone who genuinely wants that, and
963
- // knows they do.
964
- const target: SetupTarget = "user";
965
- const scopes = scopesFor(target);
966
- const primaryScope: HookScope = scopes.includes("project") ? "project" : "user";
967
-
968
- // 2 — Which harnesses?
901
+ // One masked field, with Tab as the way out. The key is checked here and
902
+ // written at apply, after the last question, so Esc anywhere before that
903
+ // still changes nothing (decision D3).
969
904
  //
970
- // Setup no longer asks which policies to enable, and that is deliberate.
971
- // failproofai ships no policies of its own any more: they arrive as packs —
972
- // from inside this package (`policies add FailproofAI/policies`) or from anyone's GitHub release.
973
- // A wizard that pre-ticks OUR list makes a product decision on behalf of
974
- // somebody who has not seen the list yet, and not everyone wants the set we
975
- // would have chosen. So setup wires the hooks, and choosing what they
976
- // enforce is a separate act, taken later and on purpose.
977
- //
978
- // Whatever is already enabled at THIS scope is read and carried through
979
- // untouched. `installHooks` is called with `replace: true`, so passing
980
- // anything less would switch OFF policies the user had turned on — running
981
- // setup a second time must never reduce protection.
982
- const enabledHere = readScopedHooksConfig(primaryScope, cwd).enabledPolicies ?? [];
983
- const policies = enabledHere;
984
-
985
- // Left alone, in both modes. This was a checkbox on the policy step, which is
986
- // gone; with no row to read, the only honest value is "do not touch it".
987
- // Writing `false` here switched off every convention policy on disk as a side
988
- // effect of finishing setup.
989
- const customEnabled: boolean | undefined = undefined;
990
-
991
- // An "Everything available" row protects every supported CLI (detected +
992
- // set-up-ahead); when ticked it wins over the individual boxes.
993
- //
994
- // Every supported agent, detected or not — setup asks nothing about this.
995
- //
996
- // It used to be the one question left, on the reasoning that which agents to
997
- // guard is a real choice. It stopped being one when this package stopped
998
- // shipping policies: hooks alone enforce nothing, so wiring them everywhere
999
- // costs a config entry and changes no behaviour until a pack arrives. What it
1000
- // buys is that an agent installed NEXT WEEK is guarded from its first tool
1001
- // call, instead of running unguarded until somebody remembers to re-run setup
1002
- // — and nobody remembers, because nothing tells them to.
1003
- //
1004
- // ALL twelve, not the detected ones, for exactly that reason. The cost is
1005
- // honest and worth naming: hook config appears under `~/.cursor/`,
1006
- // `~/.factory/` and the rest for agents that may never be installed.
1007
- //
1008
- // Which policies run, and on which of these agents, is chosen at
1009
- // `failproofai policies add` — where the user is looking at a real list
1010
- // instead of answering in the abstract.
1011
- const clis: IntegrationType[] = [...clisSupportingScope(primaryScope)];
1012
-
1013
- // 4 — Connect this machine? Last, because by this point the user has decided
1014
- // what to protect, so "would you like to see it in a dashboard?" follows
1015
- // naturally — asking up front interrupts setup with a question about a
1016
- // product they may not have.
1017
- //
1018
- // A pasted API key rather than an interactive sign-in: it is the only form
1019
- // that works on a headless box, in a container or over SSH, and it is the
1020
- // same credential `failproofai config --connect` takes, so a machine set up
1021
- // by the wizard and one set up by hand end up byte-identical on disk.
1022
- //
1023
- // Connecting turns on BOTH streams — policy decisions and session
1024
- // transcripts. That is a real disclosure, not a footnote, so it is stated in
1025
- // the body of the question itself rather than buried in an option hint.
1026
- let connect: { url: string; token: string; machineId: string; machineLabel: string } | null = null;
1027
-
1028
- {
1029
- // Supplying a key IS asking to connect — there is no other reason to pass
1030
- // one — so an unattended run with a token skips straight past the question,
1031
- // and one without a token stays local, which is the same "Not now" branch a
1032
- // person picks. Neither needs a separate flag to say so.
1033
- const choice = answers.token
1034
- // A key on the command line IS the answer to this question. Gating it on
1035
- // there being no terminal was wrong: `--token` means "use this key", not
1036
- // "use this key only if nobody is watching", and a run that asked anyway
1037
- // made the flag look broken to the person who had just typed it.
1038
- ? "key"
1039
- : unattended
1040
- ? "local"
1041
- : await selectOne<"key" | "local">({
1042
- message: "Connect this machine to FailproofAI Cloud?",
1043
- // The hint says what you GET; the body says what LEAVES.
1044
- //
1045
- // Both have to be here and they are different jobs. "sends decisions +
1046
- // transcripts" described the plumbing, which reads like a data-collection
1047
- // notice rather than a feature — the reason anyone connects is to see
1048
- // what their agents did, so the option says that.
1049
- //
1050
- // The disclosure stays in the BODY rather than moving into the hint,
1051
- // which was the original design and is right: this screen is the only
1052
- // place it is ever made. `describeOutcome` prints "hook activity" after
1053
- // connecting and never mentions transcripts, so a person who does not
1054
- // read it here does not read it at all. One line, not three, and the
1055
- // specific nouns survive — "sessions" alone would not tell anyone their
1056
- // file contents leave the machine.
1057
- //
1058
- // The get-started page, not the dashboard host.
1059
- //
1060
- // `app.befailproof.ai` is only useful to somebody who already has an
1061
- // account, and this line addresses the person who has none — new to the
1062
- // product, so no org either, not merely missing a key. The get-started
1063
- // route walks them through creating both, so it answers the question
1064
- // actually being asked. Naming the product rather than the artefact is
1065
- // what makes it self-selecting: "No key?" reads as an error state to
1066
- // someone who simply has not signed up yet.
1067
- //
1068
- // A marketing route rather than a dashboard one is also the safer thing
1069
- // to hard-code here — it is the URL the site is expected to keep stable,
1070
- // where an in-app path can be reorganised without anyone thinking to
1071
- // update a string compiled into a CLI.
1072
- //
1073
- // The scope is gone from this screen: it is enforced at paste time, where
1074
- // a wrong key is actually caught.
1075
- body: [
1076
- " Sessions include prompts, file contents and command output.",
1077
- " New to FailproofAI? Create a key at https://befailproof.ai/get-started/",
1078
- ],
1079
- // Cloud first, and therefore preselected: connecting is what most people
1080
- // running this wizard came to do, and the local path stays one keystroke
1081
- // away. Reversing these two is the whole change — neither option's copy
1082
- // moved, so "stay local" is still stated as plainly as it was.
1083
- choices: [
1084
- {
1085
- // What the cloud gives that this machine does not ALREADY have.
1086
- //
1087
- // "see what your agents did" was wrong: the local dashboard already
1088
- // shows that, so it described something the user gets either way and
1089
- // made connecting look redundant. The cloud's two jobs are a fleet
1090
- // seen in one place and policies deployed to it from there — which is
1091
- // also exactly what the key's two scopes buy (`events:add`,
1092
- // `policies:pull`).
1093
- label: "Paste an API key",
1094
- value: "key",
1095
- hint: "central monitoring · deploy policies from the dashboard",
1096
- },
1097
- {
1098
- // "re-run config", NOT "--connect". That flag exists for scripted,
1099
- // non-interactive setup; pointing a person who just declined at a
1100
- // flag they would have to look up is worse than naming the command
1101
- // they already ran.
1102
- label: "Not now — stay local",
1103
- value: "local",
1104
- hint: "nothing leaves this machine · re-run config to add",
1105
- },
1106
- ],
1107
- stdin,
1108
- stdout,
905
+ // Tab never disconnects (decision D4): on a connected machine it leaves the
906
+ // connection as it is, and `failproofai config --disconnect` is the one way to
907
+ // remove it. Enter on an empty field keeps the saved key exactly as it is too
908
+ // — no `connectToCloud`, which would reset collector settings — after
909
+ // checking that it still works.
910
+ const saved = savedConnection();
911
+ const urlChoice = resolveCloudUrl(answers.url, saved?.url);
912
+ // An unusable --url or FAILPROOFAI_CLOUD_URL is refused loudly, never a silent
913
+ // fall-back to the hosted service: whoever set it wants THAT endpoint, and
914
+ // reporting the machine elsewhere is what they did not ask. But only where a
915
+ // key would be checked or sent. `--oss`, a keyless unattended run and Tab send
916
+ // nothing, so stopping them over an endpoint they never use set up nothing.
917
+ const url = urlChoice.ok ? urlChoice.url : undefined;
918
+ const badUrl = urlChoice.ok ? undefined : urlChoice.message;
919
+ const host = url ? hostOf(url) : "no usable endpoint";
920
+ const source = urlChoice.ok ? urlChoice.source : undefined;
921
+ const refuseUrl = (message: string): WizardResult => {
922
+ write([kit.head("Connect to cloud"), kit.fail(message)]);
923
+ void emit("configure_aborted", { reason: "bad_cloud_url" });
924
+ // A failure, not a cancel: a run that asked to connect and set up nothing
925
+ // must not exit 0.
926
+ return { applied: false, abort: "bad_cloud_url" };
927
+ };
928
+ const machineId = resolveMachineId(answers.machineId);
929
+ const machineLabel = answers.machineLabel ?? saved?.machineLabel ?? resolveMachineLabel();
930
+
931
+ let typedOrg: string | undefined;
932
+ const planFor = (answer: CloudKeyAnswer | OpenSource): CloudPlan =>
933
+ answer === OPEN_SOURCE
934
+ ? saved
935
+ ? { kind: "kept", how: "skip", verified: false }
936
+ : { kind: "oss" }
937
+ : answer.kind === "saved"
938
+ ? { kind: "kept", how: "saved", verified: answer.verified }
939
+ : { kind: "connect", token: answer.key, org: typedOrg, note: answer.note };
940
+ const cloudLines = (plan: CloudPlan): string[] =>
941
+ cloudCollapsedLines(kit, plan, {
942
+ host,
943
+ savedHost: saved ? hostOf(saved.url) : host,
944
+ savedOrg: saved?.org,
945
+ source,
1109
946
  });
1110
- if (choice === null) return cancel();
1111
-
1112
- if (choice === "key") {
1113
- // An already-enrolled machine has a URL and token that usually work.
1114
- // Asking again is the seam that made connecting feel like two products.
1115
- const existing = readCloudCredentials();
1116
- let url: string | null = null;
1117
- let token: string | null = null;
1118
- // Reuse the enrolled id if there is one, else mint a stable key — never
1119
- // the hostname, so two hosts with the same name do not merge. The hostname
1120
- // becomes the human label instead.
1121
- let machineId = resolveMachineId();
1122
- let machineLabel = existing?.machineLabel ?? resolveMachineLabel();
1123
-
1124
- if (existing && unattended && !answers.url) {
1125
- // Already enrolled, and this run did not name a different place to
1126
- // enrol. Reusing is what the person picks here too, and re-verifying a
1127
- // working credential is the one thing an unattended re-run must not
1128
- // turn into a failure.
1129
- url = existing.url;
1130
- token = answers.token ?? existing.token;
1131
- machineId = answers.machineId ?? existing.machineId;
1132
- machineLabel = answers.machineLabel ?? existing.machineLabel ?? machineLabel;
1133
- } else if (existing && !unattended) {
1134
- const reuse = await selectOne<"reuse" | "other">({
1135
- message: `Use this machine's existing connection to ${existing.url}?`,
1136
- choices: [
1137
- {
1138
- label: "Yes — reuse it",
1139
- value: "reuse",
1140
- hint: `as ${existing.machineLabel ?? existing.machineId}, same token`,
1141
- },
1142
- { label: "No — different endpoint or key", value: "other", hint: "" },
1143
- ],
1144
- stdin,
1145
- stdout,
1146
- });
1147
- if (reuse === null) return cancel();
1148
- if (reuse === "reuse") {
1149
- url = existing.url;
1150
- token = existing.token;
1151
- machineId = existing.machineId;
1152
- machineLabel = existing.machineLabel ?? machineLabel;
1153
- }
1154
- }
1155
947
 
1156
- if (url === null) {
1157
- // NOT asked for. There is exactly one right answer for everybody using
1158
- // the hosted product, and asking made it look like a decision — which
1159
- // is how a key ends up pasted into the URL field, and how someone
1160
- // reasonably types the dashboard's own address and gets a 404 from a
1161
- // web app that is not the ingest endpoint. Both are real, both happened
1162
- // within ten minutes of each other, and neither is a mistake the person
1163
- // making it can be expected to avoid: "FailproofAI Cloud URL" has no
1164
- // knowable answer other than the default it was already showing.
1165
- //
1166
- // The two audiences that genuinely need a different endpoint keep an
1167
- // explicit way to say so, and neither is an interactive prompt:
1168
- //
1169
- // • local development / self-hosting → FAILPROOFAI_CLOUD_URL, the
1170
- // same variable the DAEMON already reads for cloud-managed policy
1171
- // (crates/failproofaid/src/cloud_client.rs), so one export points
1172
- // the whole machine at one place instead of the wizard and the
1173
- // daemon disagreeing.
1174
- // • scripted installs → `failproofai config --connect <url> --token`,
1175
- // unchanged.
1176
- //
1177
- // The env value goes through the SAME `validateCloudUrl` a typed one
1178
- // did — it is not a trusted back door. http stays loopback-only, so a
1179
- // bearer token still cannot be exported onto the wire in clear by
1180
- // setting a variable.
1181
- //
1182
- // `--url` comes first. It was read into `answers.url` and then only
1183
- // ever used as a CONDITION above, never as the URL, so
1184
- // `config --token <key> --url <X>` connected to the env value or the
1185
- // hosted default and ignored X — reporting a machine somewhere its
1186
- // operator had explicitly said not to. The CLI fills `answers.url` from
1187
- // `--url`, falling back to FAILPROOFAI_CLOUD_URL, so the source named
1188
- // on screen is whichever of the two the value actually is.
1189
- const envOverride = process.env.FAILPROOFAI_CLOUD_URL?.trim();
1190
- const flagOverride = answers.url?.trim();
1191
- const override = flagOverride || envOverride;
1192
- const source = flagOverride && flagOverride !== envOverride ? "--url" : "FAILPROOFAI_CLOUD_URL";
1193
- if (override) {
1194
- const validated = validateCloudUrl(cloudBaseFor(override));
1195
- if (!validated.ok) {
1196
- // Loud, not silent-fallback-to-hosted: someone who exported this
1197
- // wants THAT endpoint, and quietly reporting a machine to the
1198
- // hosted service instead is the one outcome they did not ask for.
1199
- stdout.write(
1200
- `\n${source === "--url" ? "--url is" : "FAILPROOFAI_CLOUD_URL is set to"} "${override}", which cannot be used: ` +
1201
- `${validated.reason}\n`,
1202
- );
1203
- return cancel();
948
+ const askCloud = (): Promise<CloudKeyAnswer | OpenSource | null> =>
949
+ promptCloudKey({
950
+ message: "Connect to cloud",
951
+ // Where the key goes, in the heading: the one place somebody can notice
952
+ // they are about to send a key somewhere they did not mean to.
953
+ meta: source ? `${host} · from ${source}` : host,
954
+ tabHint: saved ? "skip" : "use open source instead",
955
+ emptyError: saved ? "Paste a key, or press tab to skip." : "Paste a key, or press tab to use open source.",
956
+ saved: saved
957
+ ? {
958
+ masked: maskToken(saved.token),
959
+ check: async () => {
960
+ const r = await checkCloudKey(saved.url, saved.token);
961
+ if (r.ok) {
962
+ const org = r.org ?? saved.org;
963
+ return { ok: true, line: `Your saved key works: ${maskToken(saved.token)}${org ? `, ${org}` : ""}` };
964
+ }
965
+ return r.refused
966
+ ? { ok: false, line: `Your saved key no longer works: ${r.reason}` }
967
+ : { ok: false, usable: true, line: `Couldn't check your saved key: ${r.reason}` };
968
+ },
1204
969
  }
1205
- url = validated.url;
1206
- // Named on screen, because an env var is invisible at the moment it
1207
- // matters and a machine reporting somewhere unexpected is exactly the
1208
- // thing nobody notices until they go looking for data that is not
1209
- // there.
1210
- stdout.write(`\nUsing ${url} (from ${source}).\n`);
1211
- } else {
1212
- url = cloudBaseFor(DEFAULT_INGEST_URL);
970
+ : undefined,
971
+ check: async (key) => {
972
+ // Kept open, so Tab (or the saved key) still finishes setup.
973
+ if (!url) return { ok: false, line: badUrl ?? "No usable endpoint." };
974
+ const r = await checkCloudKey(url, key);
975
+ if (r.ok) {
976
+ typedOrg = r.org;
977
+ return { ok: true, line: r.note };
1213
978
  }
1214
- }
1215
-
1216
- // Supplied on the command line, so nothing is asked and nothing is
1217
- // echoed — terminal or not. Prompting for a value the caller already gave
1218
- // is the failure this whole flag exists to avoid.
1219
- if (token === null) token = answers.token ?? null;
1220
- if (token === null) {
1221
- token = await promptText({
1222
- // The destination is in the question now that it is no longer a
1223
- // question of its own. It is the only remaining place a person can
1224
- // notice they are about to send a key somewhere they did not mean.
1225
- message: `API key for ${new URL(url).host}`,
1226
- hint: "needs events:add · policies:pull enables managed policy too",
1227
- // Masked: setup is routinely run while screen-sharing, and a pasted
1228
- // key would otherwise sit in the scrollback of every recording.
1229
- mask: true,
1230
- validate: (v) => (v.length >= 8 ? null : "that looks too short to be a key"),
1231
- stdin,
1232
- stdout,
1233
- });
1234
- if (token === null) return cancel();
1235
- }
979
+ return { ok: false, line: r.refused ? `That key was refused: ${r.reason}.` : `Couldn't check the key: ${r.reason}.` };
980
+ },
981
+ collapsed: (answer) => [...cloudLines(planFor(answer)), ""],
982
+ stdin,
983
+ stdout,
984
+ });
1236
985
 
1237
- // Check BEFORE the review screen, so a typo is caught while the user is
1238
- // still thinking about credentials rather than three screens later. The
1239
- // apply step re-verifies and is what actually writes — nothing is
1240
- // persisted here.
1241
- stdout.write("\nChecking the key… ");
1242
- const probe = await validateIngestKey({ url: ingestUrlFor(url), key: token });
1243
- if (!probe.ok && unattended) {
1244
- // A key that does not work is a FAILED setup, not a prompt. The
1245
- // interactive path offers "save it anyway" because a person can weigh
1246
- // an outage against their own impatience; a script cannot, and one that
1247
- // exited 0 here would leave a fleet believing it was reporting.
1248
- stdout.write(`\nThat key did not work: ${probe.reason}\n`);
1249
- return { applied: false, abort: "cloud_unverified" };
1250
- }
1251
- if (!probe.ok) {
1252
- stdout.write(`\nThat did not work: ${probe.reason}\n`);
1253
- const retry = await selectOne<"skip" | "anyway">({
1254
- message: "Carry on without connecting?",
1255
- choices: [
1256
- { label: "Yes, skip it", value: "skip", hint: "everything else still applies" },
1257
- { label: "Save it anyway", value: "anyway", hint: "if you know the server is just down" },
1258
- ],
1259
- stdin,
1260
- stdout,
1261
- });
1262
- if (retry === null) return cancel();
1263
- if (retry === "skip") {
1264
- stdout.write("Staying local. Connect later with `failproofai config --connect`.\n\n");
1265
- } else {
1266
- connect = { url, token, machineId, machineLabel };
1267
- }
1268
- } else {
1269
- stdout.write("looks good.\n\n");
1270
- connect = { url, token, machineId, machineLabel };
1271
- }
986
+ let cloud: CloudPlan;
987
+ if (answers.oss) {
988
+ cloud = saved ? { kind: "kept", how: "skip", verified: false } : { kind: "oss" };
989
+ write([...cloudLines(cloud), ""]);
990
+ } else if (answers.token) {
991
+ // A key on the command line is a request to connect, and there is nowhere
992
+ // usable to send it.
993
+ if (!url) return refuseUrl(badUrl ?? "No usable endpoint.");
994
+ const r = await checkCloudKey(url, answers.token);
995
+ if (r.ok) {
996
+ cloud = { kind: "connect", token: answers.token, org: r.org, note: r.note };
997
+ write([...cloudLines(cloud), ""]);
998
+ } else if (unattended) {
999
+ // A key that does not work is a FAILED setup, not a prompt: a script that
1000
+ // exited 0 here would leave a fleet believing it was reporting.
1001
+ write([
1002
+ kit.head("Connect to cloud", host),
1003
+ kit.fail(r.refused ? `That key was refused: ${r.reason}.` : `Couldn't check the key: ${r.reason}.`),
1004
+ ]);
1005
+ void emit("configure_aborted", { reason: "cloud_unverified" });
1006
+ return { applied: false, abort: "cloud_unverified" };
1007
+ } else {
1008
+ // Somebody is at the keyboard, so a refused --token is a question again
1009
+ // rather than the end of setup.
1010
+ write([kit.fail(r.refused ? `The --token key was refused: ${r.reason}.` : `Couldn't check the --token key: ${r.reason}.`)]);
1011
+ const answer = await askCloud();
1012
+ if (answer === null) return cancel();
1013
+ cloud = planFor(answer);
1272
1014
  }
1015
+ } else if (unattended) {
1016
+ // No key and nobody to ask: nothing about the connection changes.
1017
+ cloud = saved ? { kind: "kept", how: "skip", verified: false } : { kind: "oss" };
1018
+ write([...cloudLines(cloud), ""]);
1019
+ } else {
1020
+ // Said above the field, which stays usable for Tab and a saved key.
1021
+ if (badUrl) write([kit.fail(badUrl)]);
1022
+ const answer = await askCloud();
1023
+ if (answer === null) return cancel();
1024
+ cloud = planFor(answer);
1273
1025
  }
1274
- // 5 — Review & apply
1275
- //
1276
- // The last question, and the only one a headless run skips outright rather
1277
- // than answering from a flag. There is nothing to confirm when nobody is
1278
- // watching, and the command itself was the confirmation.
1026
+
1027
+ // ── AGENTS ──────────────────────────────────────────────────────────────────
1279
1028
  //
1280
- // NOT the same call as `uninstall`, which does require --yes: that one
1281
- // REMOVES things, so silence there would destroy on a signal as weak as a
1282
- // missing terminal. This one does exactly what its name says.
1283
- const decision = unattended || preAnswered ? "apply" : await selectOne<"apply" | "cancel">({
1284
- message: "Ready to apply?",
1285
- body: reviewLines({
1286
- target,
1287
- clis,
1288
- policies,
1289
- cwd,
1290
- customEnabled,
1291
- // A stale-unit refresh rewrites the service file and restarts the
1292
- // daemon, so it belongs on the list of things this run is about to
1293
- // change. Leaving it off would make the confirmation screen of a
1294
- // security tool quietly incomplete about a root-owned file.
1295
- installDaemon: daemonWanted || daemonUnitStale,
1296
- connect: connect !== null,
1297
- }),
1298
- choices: [
1299
- { label: "Yes, apply now", value: "apply", hint: "write the config" },
1300
- { label: "Cancel", value: "cancel", hint: "quit, no changes" },
1301
- ],
1302
- stdin,
1303
- stdout,
1304
- });
1305
- if (decision !== "apply") return cancel();
1029
+ // One saved list drives hooks, collection and backfill. See `planAgents` for
1030
+ // which ticks a run starts with.
1031
+ const fp = readConfig();
1032
+ const hookedNow = INTEGRATION_TYPES.filter((id) => hookedAtUserScope(id, cwd));
1033
+ const plan = planAgents({ detected: detectInstalledClis(), saved: fp.agents, hooked: hookedNow });
1034
+ // Whether the daemon will be collecting once this run ends: a key is already
1035
+ // on disk, or this run writes one. Removal copy and the backfill request both
1036
+ // depend on it.
1037
+ const collectingAfter = cloud.kind === "connect" || readIngestCredential() !== null;
1038
+ const sessionsOn = cloud.kind === "connect" ? answers.noTranscripts !== true : fp.collector.sessions === true;
1039
+
1040
+ let selected: IntegrationType[];
1041
+ if (answers.agents && answers.agents.length > 0) {
1042
+ const named = new Set(answers.agents);
1043
+ selected = plan.order.filter((id) => named.has(id));
1044
+ write([...agentsCollapsedLines(kit, plan, selected, collectingAfter, c), ""]);
1045
+ } else if (unattended || preAnswered) {
1046
+ selected = defaultSelection(plan);
1047
+ write([...agentsCollapsedLines(kit, plan, selected, collectingAfter, c), ""]);
1048
+ } else {
1049
+ // "from last setup", not the reference's "from your last setup": with all
1050
+ // twelve agents listed, the longer meta runs the heading to 81 columns and
1051
+ // the end of it is cut off.
1052
+ const suffix =
1053
+ plan.mode === "rerun" ? " · from last setup" : plan.mode === "upgrade" ? " · as protected today" : "";
1054
+ const picked = await multiSelect<IntegrationType>({
1055
+ message: "Which agents should failproofai trace?",
1056
+ choices: agentChoices(plan),
1057
+ minSelected: 1,
1058
+ minMessage: "Pick at least one agent.",
1059
+ meta: (checked) => `${checked.length} of ${plan.order.length} selected${suffix}`,
1060
+ tag: agentTag(plan, c),
1061
+ collapsed: (values) => [
1062
+ ...agentsCollapsedLines(kit, plan, plan.order.filter((id) => values.includes(id)), collectingAfter, c),
1063
+ "",
1064
+ ],
1065
+ stdin,
1066
+ stdout,
1067
+ });
1068
+ if (picked === null) return cancel();
1069
+ selected = plan.order.filter((id) => picked.includes(id));
1070
+ }
1071
+ // Optional OTEL choice is never implied by unattended setup or a pasted key.
1072
+ const { chooseWizardOtel, runOtelCommand } = await import("./otel-cli");
1073
+ let otelCancelled = false;
1074
+ const otelWanted = !unattended && !preAnswered && cloud.kind === "connect" && await chooseWizardOtel(
1075
+ async message => {
1076
+ const answer = await selectOne({
1077
+ message,
1078
+ choices: [
1079
+ { label: "No", value: false, hint: "keep current telemetry" },
1080
+ { label: "Yes", value: true, hint: "choose OTEL agents" },
1081
+ ],
1082
+ collapsed: value => [kit.head("OpenTelemetry"), kit.ok(value ? "Opting in to OTEL." : "Keeping current telemetry."), ""],
1083
+ stdin, stdout,
1084
+ });
1085
+ otelCancelled = answer === null;
1086
+ return answer === true;
1087
+ },
1088
+ );
1089
+ if (otelCancelled) return cancel();
1090
+ let otelAgents: string[] = [];
1091
+ if (otelWanted) {
1092
+ const picked = await multiSelect({
1093
+ message: "Which agents should send OpenTelemetry?",
1094
+ choices: [
1095
+ { label: "Gemini CLI", value: "gemini", checked: false },
1096
+ { label: "Copilot (VS Code)", value: "copilot", checked: false },
1097
+ { label: "Claude Code", value: "claude", checked: false },
1098
+ { label: "Codex", value: "codex", checked: false },
1099
+ ],
1100
+ stdin, stdout,
1101
+ });
1102
+ if (picked === null) return cancel();
1103
+ otelAgents = picked;
1104
+ }
1105
+ const { added, removed } = agentChanges(plan, selected);
1306
1106
 
1307
1107
  // ── Apply ─────────────────────────────────────────────────────────────────
1308
1108
  //
1309
- // ORDER MATTERS. The daemon goes first, because setup requires it: if it
1310
- // cannot be installed, this run must leave the machine exactly as it found
1311
- // it rather than half-configured. Writing hooks first and discovering the
1312
- // service will not start afterwards is the one ordering whose failure cannot
1313
- // be undone cleanly — hooks would already be live, pointing at a machine
1314
- // whose `daemonConfigured` flag we then could not honestly set.
1109
+ // The agents step (or the optional OTEL choice) was the last decision.
1110
+ // ORDER MATTERS here:
1111
+ //
1112
+ // 1. The daemon first, because setup requires it: if it cannot be installed
1113
+ // this run must leave the machine exactly as it found it. Writing hooks
1114
+ // first and discovering the service will not start is the one ordering
1115
+ // whose failure cannot be undone cleanly.
1116
+ // 2. Hooks for the chosen agents, and off for the rest.
1117
+ // 3. The backfill request for agents added back, BEFORE the selection that
1118
+ // ticks them: the daemon handles a pending request ahead of its config
1119
+ // compare, so one landing a tick later would let an added agent resume
1120
+ // stale cursors first.
1121
+ // 4. The selection, BEFORE any new ingest key, or the first collection runs
1122
+ // every agent.
1123
+ // 5. The connection last.
1124
+ const done: string[] = [];
1315
1125
  let daemonInstalled = daemonAlreadyRunning;
1316
1126
  if (daemonWanted) {
1317
1127
  // A unit that is running but cannot answer is torn down before it is
1318
- // rebuilt, rather than installed over. Its `ExecStart` points at a binary
1319
- // or interpreter that no longer works, and it holds the singleton flock
1320
- // the replacement needs — install over the top and the new unit starts,
1321
- // loses the lock race, and the machine stays exactly as broken. This is
1322
- // the production path for `uninstallDaemonService`, which until now had
1323
- // none: it was defined, tested, documented in CLAUDE.md as called from
1324
- // here, and referenced by nothing.
1128
+ // rebuilt: it holds the singleton flock the replacement needs, so
1129
+ // installing over it starts a unit that loses the lock race.
1325
1130
  if (daemonBroken) {
1326
- stdout.write("Removing the failproofaid service that cannot start…\n");
1327
- try {
1328
- await uninstallDaemonService();
1329
- } catch (err) {
1330
- // Non-fatal: the install below reports its own outcome, and it is that
1331
- // outcome — not this one — that decides whether setup continues.
1332
- hookLogWarn(
1333
- `could not remove the broken failproofaid service: ${err instanceof Error ? err.message : String(err)}`,
1334
- );
1335
- }
1131
+ await transient("Removing the failproofaid service that can't start…", async () => {
1132
+ try {
1133
+ await uninstallDaemonService();
1134
+ } catch (err) {
1135
+ // Non-fatal: the install below reports the outcome that decides.
1136
+ hookLogWarn(
1137
+ `could not remove the broken failproofaid service: ${err instanceof Error ? err.message : String(err)}`,
1138
+ );
1139
+ }
1140
+ });
1336
1141
  }
1337
- stdout.write(daemonBroken ? "Reinstalling the failproofaid service…\n" : "Installing the failproofaid service…\n");
1338
- const daemonResult = await installDaemonService();
1142
+ const daemonResult = await transient(daemonBroken ? "Reinstalling failproofaid…" : "Installing failproofaid…", () =>
1143
+ installDaemonService(),
1144
+ );
1339
1145
  void emit("configure_daemon_install", {
1340
1146
  installed: daemonResult.installed,
1341
- // A bounded classification, never the raw reason: on the failure path
1342
- // that string is an errno message from writeFileSync/execFileSync
1343
- // against a homedir()-derived path, so it routinely carries the OS
1344
- // username and the local filesystem layout. The full text stays local
1345
- // via the hookLogWarn below.
1147
+ // A bounded classification, never the raw reason: that string routinely
1148
+ // carries the OS username. The full text stays local via hookLogWarn.
1346
1149
  reason: daemonResult.installed ? null : classifyDaemonInstallFailure(daemonResult.reason),
1347
1150
  platform: process.platform,
1348
1151
  });
1349
-
1350
1152
  if (!daemonResult.installed) {
1351
1153
  hookLogWarn(`failproofaid was not installed as a service: ${daemonResult.reason}`);
1352
1154
  // Nothing user-facing has been written yet, so there is nothing to roll
1353
1155
  // back — which is the entire reason this runs first.
1354
- stdout.write(
1355
- `\nThe failproofaid service could not be installed:\n ${daemonResult.reason ?? "unknown error"}\n\n` +
1356
- "Setup stopped before changing anything. Once that is fixed, re-run:\n" +
1357
- " failproofai config\n\n",
1358
- );
1156
+ write([
1157
+ kit.fail("failproofaid could not be installed, so setup stopped before changing anything."),
1158
+ ` ${daemonResult.reason ?? "unknown error"}`,
1159
+ ` Once that is fixed, re-run: ${kit.cmd("failproofai config")}`,
1160
+ ]);
1359
1161
  void emit("configure_aborted", { reason: "daemon_failed" });
1360
- outro("Nothing was changed.", { ok: false }, stdout);
1361
1162
  return { applied: false, abort: "daemon_failed" };
1362
1163
  }
1363
-
1364
- // Installed and running is the service manager's opinion, and it is not
1365
- // what setup needs to know. `ExecStart` bakes in `process.execPath` and an
1366
- // absolute `dist/worker.mjs`, so a unit can be perfectly active while the
1367
- // worker behind it dies on every spawn — and every other check waves that
1368
- // machine through: `waitForDaemonRunning()` asks systemd, `Ping` is
1369
- // answered in `server.rs` without touching the worker, a null
1370
- // `resolveWorkerCommand()` is best-effort, and `Worker::warm()` swallows
1371
- // its own failure. Setting `daemonConfigured` against it denies every tool
1372
- // call across all twelve CLIs, `UserPromptSubmit` included, so the user
1373
- // cannot even ask their agent why.
1374
- //
1375
- // Checked HERE rather than inside `installDaemonService`, which can only
1376
- // honestly report on the service: keeping them apart is also what lets the
1377
- // install mechanics be tested against a stub binary.
1378
- const probe = await probeDaemon();
1164
+ // Installed and running is the service manager's opinion. Setting
1165
+ // `daemonConfigured` against a daemon whose worker cannot run denies every
1166
+ // tool call across all twelve CLIs, `UserPromptSubmit` included, so a real
1167
+ // evaluation is asked for before anything else is written.
1168
+ const probe = await transient("Checking that failproofai answers…", () => probeDaemon());
1379
1169
  if (!probe.ok) {
1380
1170
  hookLogWarn(
1381
1171
  `failproofaid was installed and running but did not answer a policy evaluation (${probe.reason})`,
1382
1172
  );
1383
- // Two different faults, two different things to go and look at. Saying
1384
- // "your worker will not start" at a machine whose socket simply never
1385
- // came up sends someone to inspect a process that is fine.
1386
- stdout.write(
1387
- (probe.reason === "worker"
1388
- ? "\nfailproofaid started but cannot evaluate policies — it is listening, and\n" +
1389
- "its worker process could not be run.\n"
1390
- : "\nfailproofaid started but could not be reached on its socket.\n") +
1391
- "Setup stopped before changing anything, because a machine configured to\n" +
1392
- "require a daemon that cannot answer denies every tool call.\n\n" +
1393
- ` Check it with: ${daemonStatusCommand() ?? "systemctl status failproofaid"}\n` +
1394
- " Then re-run: failproofai config\n\n",
1395
- );
1173
+ write([
1174
+ kit.fail(
1175
+ probe.reason === "worker"
1176
+ ? "failproofaid started but can't evaluate policies: its worker process could not be run."
1177
+ : "failproofaid started but could not be reached on its socket.",
1178
+ ),
1179
+ " Setup stopped before changing anything: a machine that requires a daemon",
1180
+ " that cannot answer denies every tool call.",
1181
+ ` Check it with: ${kit.cmd(daemonStatusCommand() ?? "systemctl status failproofaid")}`,
1182
+ ` Then re-run: ${kit.cmd("failproofai config")}`,
1183
+ ]);
1396
1184
  void emit("configure_aborted", { reason: `daemon_not_answering_${probe.reason}` });
1397
- outro("Nothing was changed.", { ok: false }, stdout);
1398
1185
  return { applied: false, abort: "daemon_failed" };
1399
1186
  }
1400
1187
  daemonInstalled = true;
1188
+ done.push(
1189
+ kit.ok(
1190
+ daemonBroken
1191
+ ? "failproofaid rebuilt and running"
1192
+ : daemonSkew
1193
+ ? `failproofaid updated to ${cliVersion} and running`
1194
+ : "failproofaid installed and running",
1195
+ ),
1196
+ );
1401
1197
  } else if (daemonUnitStale) {
1402
- // Deliberately after the install branch and never instead of it: this only
1403
- // ever runs on a machine whose daemon is already up, and it must not be
1404
- // able to abort a setup that is otherwise fine. A failure here costs the
1405
- // scheduled audit, nothing else.
1406
- stdout.write("Refreshing the failproofaid service definition…\n");
1407
- const upgrade = await ensureDaemonServiceCurrent();
1198
+ // Never instead of the install, and never able to abort a setup that is
1199
+ // otherwise fine: a failure here costs the scheduled audit, nothing else.
1200
+ const upgrade = await transient("Refreshing failproofaid's service definition…", () => ensureDaemonServiceCurrent());
1408
1201
  void emit("configure_daemon_unit_refresh", {
1409
1202
  outcome: upgrade.outcome,
1410
1203
  daemon_running: upgrade.daemonRunning ?? true,
@@ -1412,151 +1205,139 @@ export async function runConfigureWizard(
1412
1205
  });
1413
1206
  if (upgrade.outcome === "failed") {
1414
1207
  hookLogWarn(`failproofaid service definition could not be refreshed: ${upgrade.reason}`);
1208
+ done.push(kit.caution(`failproofaid's service definition could not be refreshed: ${upgrade.reason ?? "unknown error"}`));
1415
1209
  if (upgrade.daemonRunning === false) {
1416
- // The refresh stopped a daemon it could not start again, and its own
1417
- // rollback could not either. Explicitly `=== false`: every failure
1418
- // that never touched the service leaves this undefined, and those must
1419
- // not drag a healthy machine down this branch.
1420
- //
1421
- // Leaving `daemonConfigured` set here is not "the audit stays off", it
1422
- // is every tool call across all 12 CLIs denied against a socket
1423
- // nothing is listening on, recoverable only by hand-editing
1424
- // policies-config.json. So the machine goes back to in-process
1425
- // evaluation — the same trade uninstallDaemonService makes, and for
1426
- // the same reason.
1210
+ // The refresh stopped a daemon it could not start again. Leaving
1211
+ // `daemonConfigured` set would deny every tool call against a socket
1212
+ // nothing listens on, so the machine goes back to in-process evaluation.
1427
1213
  daemonInstalled = false;
1428
1214
  setDaemonConfigured(false);
1429
- stdout.write(
1430
- `\nThe service definition could not be refreshed:\n ${upgrade.reason ?? "unknown error"}\n` +
1431
- "failproofaid is no longer running, so this machine was switched back to\n" +
1432
- "in-process evaluation rather than left denying every tool call. Hooks keep\n" +
1433
- "enforcing. Re-run `failproofai config` to reinstall the service.\n\n",
1215
+ done.push(
1216
+ " It is no longer running, so this machine is back on in-process evaluation, and",
1217
+ ` hooks keep enforcing. Reinstall it with ${kit.cmd("failproofai config")}`,
1434
1218
  );
1435
1219
  } else {
1436
- stdout.write(
1437
- `\nThe service definition could not be refreshed:\n ${upgrade.reason ?? "unknown error"}\n` +
1438
- "Hooks keep enforcing; scheduled audits stay off until it is.\n\n",
1439
- );
1220
+ done.push(" Hooks keep enforcing; scheduled audits stay off until it is refreshed.");
1440
1221
  }
1222
+ } else {
1223
+ done.push(kit.ok("failproofaid's service definition is up to date"));
1441
1224
  }
1442
1225
  }
1443
1226
 
1444
- // The flag that makes hooks route through the daemon — and, on a machine
1445
- // where the daemon is unreachable, fail closed. Only ever set after a
1446
- // verified-running service, never on intent.
1447
- // The version is recorded HERE, from the CLI that installed it — which is by
1448
- // construction the version that was installed, since the download URL and the
1449
- // binary filename are both derived from it.
1227
+ // The flag that makes hooks route through the daemon — and fail closed when
1228
+ // it is unreachable. Only ever set after a verified-running service.
1450
1229
  if (daemonInstalled) {
1451
1230
  setDaemonConfigured(true, cliVersion);
1452
- // Only now — the unit points at the new binary, so older ones are no
1453
- // longer referenced by anything. Keeps the previous version for an
1454
- // offline rollback.
1231
+ // The unit points at the new binary now, so older ones are unreferenced.
1455
1232
  pruneOldDaemonBinaries();
1456
1233
  }
1457
1234
 
1458
- // Telemetry runs concurrently with the install (never rejects, 5s-bounded) so
1459
- // it doesn't add dead time between "apply" and the config actually writing,
1460
- // while still being awaited before the process can exit.
1235
+ const connectedAfterPlan = cloud.kind === "connect" || cloud.kind === "kept";
1236
+ // Telemetry runs concurrently with the rest of apply (never rejects,
1237
+ // 5s-bounded) and is awaited before returning. `cli` carries the selected
1238
+ // agent ids, as it always has (and as hooks_installed does): a fixed list of
1239
+ // product ids, never user content. The agents_* fields add counts only.
1461
1240
  const applied = emit("configure_applied", {
1462
- target,
1463
- scopes,
1464
- cli: clis,
1465
- cli_count: clis.length,
1466
- policy_count: policies.length,
1467
- connected: connect !== null,
1241
+ target: "user",
1242
+ scopes: ["user"],
1243
+ cli: selected,
1244
+ cli_count: selected.length,
1245
+ policy_count: (readScopedHooksConfig("user", cwd).enabledPolicies ?? []).length,
1246
+ connected: cloud.kind === "connect",
1247
+ mode: connectedAfterPlan ? "cloud" : "oss",
1248
+ agents_selected: selected.length,
1249
+ agents_detected: plan.detected.size,
1250
+ agents_changed: added.length + removed.length > 0,
1468
1251
  });
1469
1252
 
1470
- // One install per chosen scope, with the CLI list narrowed to what THAT
1471
- // scope can take.
1472
- //
1473
- // The comment here used to claim `installHooks` "already skips CLIs a given
1474
- // scope cannot take". It does not — `installHooksImpl` validates every CLI
1475
- // against the scope up front and THROWS `Scope "project" is not supported by
1476
- // Hermes`. `clis` is the union across scopes (deliberately, so a user-scope-
1477
- // only gateway is still installed via the user half), so under "Both" +
1478
- // "Everything available" the project pass got handed hermes/openclaw and
1479
- // died. Nothing catches it, and by then the daemon is installed,
1480
- // `daemonConfigured` is set and user-scope hooks are written — so the run
1481
- // aborted mid-apply, before any project config or the pasted cloud key.
1482
- // `configure-wizard.test.ts` mocks `installHooks` wholesale, which is why the
1483
- // real validation path was never exercised.
1484
- //
1485
- // quiet: the wizard renders its own outro; replace: the chosen set becomes
1486
- // the full enabled set at that scope (unticking removes).
1487
- for (const scope of scopes) {
1488
- const supportedHere = new Set(clisSupportingScope(scope));
1489
- const clisForScope = clis.filter((id) => supportedHere.has(id));
1490
- if (clisForScope.length > 0) {
1491
- await installHooks(
1492
- policies,
1493
- scope,
1494
- cwd,
1495
- /* includeBeta */ false,
1496
- "configure-wizard",
1497
- /* customPoliciesPath */ undefined,
1498
- /* removeCustomHooks */ false,
1499
- clisForScope,
1500
- { replace: true, quiet: true },
1501
- );
1502
- }
1503
- setCustomPoliciesEnabled(scope, cwd, customEnabled);
1504
- }
1253
+ // Hooks, at user scope, for the chosen agents. Whatever policies are enabled
1254
+ // there are carried through untouched: `replace: true` would otherwise switch
1255
+ // off policies the user had turned on, and setup must never reduce
1256
+ // protection. Setup chooses no policies of its own any more.
1257
+ const policies = readScopedHooksConfig("user", cwd).enabledPolicies ?? [];
1258
+ // Nothing to hook is a real answer (a saved empty selection), and handing
1259
+ // installHooks an empty list is not a way to say it.
1260
+ if (selected.length > 0) await transient("Installing hooks…", () =>
1261
+ installHooks(
1262
+ policies,
1263
+ "user",
1264
+ cwd,
1265
+ /* includeBeta */ false,
1266
+ "configure-wizard",
1267
+ /* customPoliciesPath */ undefined,
1268
+ /* removeCustomHooks */ false,
1269
+ selected,
1270
+ { replace: true, quiet: true },
1271
+ ),
1272
+ );
1273
+ // And off for every agent not chosen that has them: the ones unticked now,
1274
+ // and any hooked since the last setup without being part of it.
1275
+ removeUserScopeHooks(
1276
+ INTEGRATION_TYPES.filter((id) => !selected.includes(id) && (plan.baseline.has(id) || hookedNow.includes(id))),
1277
+ cwd,
1278
+ );
1505
1279
 
1506
- // Cloud connection, written after the daemon exists — the daemon is what
1507
- // runs the collector, so a credential written for a service that is not
1508
- // there would be a key on disk doing nothing.
1509
- //
1510
- // `connectToCloud` re-verifies each capability and writes only what actually
1511
- // works, so a key revoked between the earlier probe and here degrades to a
1512
- // reported partial rather than a connection this machine does not have.
1280
+ // Agents traced again start fresh (see `requestFreshStart`). Only on a
1281
+ // rerun: its baseline is the selection the daemon actually filtered on. With
1282
+ // no saved list (upgrade, first) the daemon was collecting every agent, so an
1283
+ // agent "added" here was never untraced, has no stale cursors, and a request
1284
+ // would only make the daemon re-read everything it already sent.
1285
+ requestFreshStart(plan.mode === "rerun" ? added : [], collectingAfter && sessionsOn);
1286
+
1287
+ updateConfig({ agents: { selected, seen: plan.seen } });
1288
+
1289
+ // The connection, written after the daemon exists — the daemon runs the
1290
+ // collector, so a credential written for a service that is not there would be
1291
+ // a key on disk doing nothing. `connectToCloud` re-verifies each capability
1292
+ // and writes only what works.
1513
1293
  let connected = false;
1514
- if (connect) {
1294
+ if (cloud.kind === "connect" && url) {
1295
+ const token = cloud.token;
1515
1296
  try {
1516
- const outcome = await connectToCloud({
1517
- url: connect.url,
1518
- token: connect.token,
1519
- machineId: connect.machineId,
1520
- machineLabel: connect.machineLabel,
1521
- // Both streams, as disclosed at the connect question, unless the run
1522
- // said `--no-transcripts`. This is the one place that decision becomes
1523
- // a written setting — and it used to be a literal `true`, so the flag
1524
- // was parsed, carried here in `answers`, and silently ignored: a fleet
1525
- // that asked for decisions only shipped every prompt, file and command
1526
- // output. The separate `--connect` path always honoured it.
1527
- sessions: answers.noTranscripts !== true,
1528
- });
1297
+ const outcome = await transient("Connecting…", () =>
1298
+ connectToCloud({
1299
+ url,
1300
+ token,
1301
+ machineId,
1302
+ machineLabel,
1303
+ // Both streams unless the run said `--no-transcripts`.
1304
+ sessions: answers.noTranscripts !== true,
1305
+ }),
1306
+ );
1529
1307
  connected = outcome.anyConfigured;
1530
- // Show the human label with the id in parentheses when they differ.
1531
- const shownAs =
1532
- connect.machineLabel === connect.machineId
1533
- ? connect.machineId
1534
- : `${connect.machineLabel} (${connect.machineId})`;
1535
- for (const line of describeOutcome(outcome, shownAs, connect.url)) {
1536
- stdout.write(`${line}\n`);
1537
- }
1538
- // Said when it took effect, as `--connect` says it: the default carries
1539
- // prompts and file contents, so the opt-out is worth confirming.
1308
+ done.push(...connectDoneLines(kit, outcome, host, cloud.org));
1540
1309
  if (outcome.ingest.ok && answers.noTranscripts === true) {
1541
- stdout.write(" Session transcripts are NOT being sent (--no-transcripts). Decisions only.\n");
1310
+ done.push(" Decisions only: session transcripts are not sent (--no-transcripts).");
1542
1311
  }
1543
- // Never the key, the URL, or the count — only that it happened and which
1544
- // capabilities the server actually granted.
1545
- void emit("configure_connect", {
1546
- policy_ok: outcome.policy.ok,
1547
- ingest_ok: outcome.ingest.ok,
1548
- });
1312
+ void emit("configure_connect", { policy_ok: outcome.policy.ok, ingest_ok: outcome.ingest.ok });
1549
1313
  } catch (err) {
1550
- // Non-fatal, unlike the daemon: enforcement does not depend on the
1551
- // dashboard, and a machine with no connection behaves exactly as every
1552
- // release before this one did.
1553
- hookLogWarn(
1554
- `cloud connection was not written: ${err instanceof Error ? err.message : String(err)}`,
1555
- );
1556
- stdout.write("\nCould not connect — everything else applied. Retry with `failproofai config --connect`.\n");
1314
+ // Non-fatal, unlike the daemon: enforcement does not depend on the cloud.
1315
+ hookLogWarn(`cloud connection was not written: ${err instanceof Error ? err.message : String(err)}`);
1316
+ done.push(kit.caution("Couldn't connect; everything else was applied.", "failproofai config"));
1557
1317
  }
1318
+ } else if (cloud.kind === "kept" && cloud.how === "saved" && fp.mode !== "cloud") {
1319
+ // A home migrated from layout 1 carries keys but no mode, and would read
1320
+ // as open source everywhere that asks.
1321
+ updateConfig({ mode: "cloud" });
1558
1322
  }
1559
1323
 
1324
+ if (otelWanted && connected) {
1325
+ for (const agent of otelAgents) {
1326
+ const result = await runOtelCommand(["enable", agent], {
1327
+ home,
1328
+ onEvent: emit,
1329
+ confirm: async message => await selectOne({
1330
+ message,
1331
+ choices: [
1332
+ { label: "No", value: false, hint: "keep transcript upload" },
1333
+ { label: "Yes", value: true, hint: "switch to OTEL" },
1334
+ ],
1335
+ stdin, stdout,
1336
+ }) === true,
1337
+ });
1338
+ done.push(...result.lines.map(line => result.exitCode === 0 ? kit.ok(line) : kit.fail(line)));
1339
+ }
1340
+ }
1560
1341
  await applied;
1561
1342
  // Only now — a completed apply — is the launcher considered "seen", so
1562
1343
  // first-run onboarding stops offering itself on every command.
@@ -1564,40 +1345,238 @@ export async function runConfigureWizard(
1564
1345
  // And any record of an earlier failure is now false: this machine got set up.
1565
1346
  clearOnboardingAttempt();
1566
1347
 
1567
- // Every real completed setup is on a supported platform now (an unsupported
1568
- // one aborts before this point), so the optional notes in the summary below
1569
- // are no longer occasional additions — see buildCompletionSummary's own doc
1570
- // comment for why the widest combination still fits in 80 columns.
1571
- outro(
1572
- buildCompletionSummary(
1573
- policies.length,
1574
- clis.length,
1575
- customEnabled,
1576
- daemonInstalled,
1577
- connected,
1578
- ),
1579
- { ok: true },
1580
- stdout,
1581
- );
1582
- // Setup wires the hooks and deliberately chooses NO policies, so a machine
1583
- // that has just finished it enforces almost nothing — and the summary line
1584
- // says "0 policies" without saying what to do about it. Anyone else's pack is
1585
- // typed the same way, which is the point: ours is named in full rather than
1586
- // by a short name only we could use.
1587
- if (policies.length === 0) {
1588
- stdout.write(
1589
- `\n Nothing is enforcing yet. Take ours, or anyone's:\n` +
1590
- ` failproofai policies add ${CORE_SOURCE}\n` +
1591
- ` failproofai policies show <owner>/<repo> (look first)\n\n`,
1592
- );
1593
- }
1348
+ write(doneLines(kit, { elapsedMs: Date.now() - started, done, cwd, agents: selected.length, connected: connected || cloud.kind === "kept" }));
1349
+
1594
1350
  return {
1595
1351
  applied: true,
1596
- target,
1597
- scopes,
1598
- clis,
1352
+ target: "user",
1353
+ scopes: ["user"],
1354
+ clis: selected,
1599
1355
  policies,
1600
1356
  daemonInstalled,
1601
1357
  connected,
1358
+ mode: connected || cloud.kind === "kept" ? "cloud" : "oss",
1359
+ agents: { selected, added, removed },
1602
1360
  };
1603
1361
  }
1362
+
1363
+ // ── Cloud helpers ────────────────────────────────────────────────────────────
1364
+
1365
+ /** What the CONNECT step decided. Nothing is written until apply. */
1366
+ type CloudPlan =
1367
+ | { kind: "oss" }
1368
+ | { kind: "kept"; how: "skip" | "saved"; verified: boolean }
1369
+ | { kind: "connect"; token: string; org?: string; note?: string };
1370
+
1371
+ /**
1372
+ * The connection already on this machine, from either credential: the policy
1373
+ * one and the collector's ingest key. Reading only the first, as setup used to,
1374
+ * showed a collector-only machine as unconnected.
1375
+ */
1376
+ function savedConnection(): {
1377
+ url: string;
1378
+ token: string;
1379
+ org?: string;
1380
+ machineLabel?: string;
1381
+ } | null {
1382
+ const cloud = readCloudCredentials();
1383
+ const ingest = readIngestCredential();
1384
+ const token = cloud?.token ?? ingest?.key;
1385
+ if (!token) return null;
1386
+ const url = cloud?.url ?? cloudBaseFor(ingest?.url ?? DEFAULT_INGEST_URL);
1387
+ let org: string | undefined;
1388
+ try {
1389
+ const stored = readCredentials().org;
1390
+ org = stored?.name ?? stored?.slug;
1391
+ } catch {
1392
+ org = undefined;
1393
+ }
1394
+ return { url, token, org, machineLabel: cloud?.machineLabel };
1395
+ }
1396
+
1397
+ /**
1398
+ * Where a key is sent: `--url`, else FAILPROOFAI_CLOUD_URL, else this machine's
1399
+ * existing connection, else the hosted service.
1400
+ *
1401
+ * Never asked for interactively: everybody on the hosted product has one right
1402
+ * answer, and asking made it look like a decision — which is how keys ended up
1403
+ * pasted into the URL field. The env value goes through the SAME validation a
1404
+ * typed one did, so http stays loopback-only. The source is named on screen,
1405
+ * because an env var is invisible at exactly the moment it matters.
1406
+ */
1407
+ export function resolveCloudUrl(
1408
+ flag: string | undefined,
1409
+ existing: string | undefined,
1410
+ ): { ok: true; url: string; source?: string } | { ok: false; message: string } {
1411
+ const env = process.env.FAILPROOFAI_CLOUD_URL?.trim();
1412
+ const fromFlag = flag?.trim();
1413
+ const override = fromFlag || env;
1414
+ if (!override) return { ok: true, url: existing ?? cloudBaseFor(DEFAULT_INGEST_URL) };
1415
+ const source = fromFlag && fromFlag !== env ? "--url" : "FAILPROOFAI_CLOUD_URL";
1416
+ const validated = validateCloudUrl(cloudBaseFor(override));
1417
+ if (!validated.ok) {
1418
+ return {
1419
+ ok: false,
1420
+ message: `${source === "--url" ? "--url is" : "FAILPROOFAI_CLOUD_URL is set to"} "${override}", which cannot be used: ${validated.reason}`,
1421
+ };
1422
+ }
1423
+ return { ok: true, url: validated.url, source };
1424
+ }
1425
+
1426
+ function hostOf(url: string): string {
1427
+ try {
1428
+ return new URL(url).host;
1429
+ } catch {
1430
+ return url;
1431
+ }
1432
+ }
1433
+
1434
+ /**
1435
+ * Check a key without writing anything or registering anything server-side.
1436
+ *
1437
+ * Introspect first: it says which organisation the key belongs to and what it
1438
+ * may do, with no side effect. A key that can neither send events nor pull
1439
+ * policies is refused; one that can do one of them is accepted with a note
1440
+ * saying which half is missing. A server too old for introspect is probed the
1441
+ * way setup always did, with an empty batch to the ingest endpoint.
1442
+ *
1443
+ * `refused: false` is "could not ask" (offline, a timeout, a 5xx) — a saved key
1444
+ * that fails that way may still be fine, so it is not treated as broken.
1445
+ */
1446
+ export async function checkCloudKey(
1447
+ url: string,
1448
+ key: string,
1449
+ ): Promise<{ ok: true; org?: string; note?: string } | { ok: false; refused: boolean; reason: string }> {
1450
+ const identity = await introspectKey(url, key);
1451
+ if (identity.kind === "rejected") return { ok: false, refused: true, reason: "the server did not accept it" };
1452
+ if (identity.kind === "unreachable") return { ok: false, refused: false, reason: identity.reason };
1453
+ if (identity.kind === "ok") {
1454
+ const events = hasPermission(identity.identity, PERMISSION_EVENTS);
1455
+ const policies = hasPermission(identity.identity, PERMISSION_POLICIES);
1456
+ const org = identity.identity.orgName ?? identity.identity.orgSlug;
1457
+ if (!events && !policies) {
1458
+ return { ok: false, refused: true, reason: `it is missing the ${PERMISSION_EVENTS} permission` };
1459
+ }
1460
+ const note = !events
1461
+ ? `This key can't send events (${PERMISSION_EVENTS}), so nothing will be collected.`
1462
+ : !policies
1463
+ ? `This key can't pull policies (${PERMISSION_POLICIES}), so cloud-managed policies won't arrive.`
1464
+ : undefined;
1465
+ return { ok: true, org, note };
1466
+ }
1467
+ const probe = await validateIngestKey({ url: ingestUrlFor(url), key });
1468
+ if (probe.ok) return { ok: true };
1469
+ const offline = /^could not reach|answered 5\d\d/.test(probe.reason);
1470
+ return { ok: false, refused: !offline, reason: probe.reason };
1471
+ }
1472
+
1473
+ /** The CONNECT step, collapsed: what was checked, never yet what was written. */
1474
+ export function cloudCollapsedLines(
1475
+ kit: ReturnType<typeof screenKit>,
1476
+ plan: CloudPlan,
1477
+ at: { host: string; savedHost: string; savedOrg?: string; source?: string },
1478
+ ): string[] {
1479
+ // An override is named where the key went: an env var is invisible at the
1480
+ // moment it matters, and a machine reporting somewhere unexpected is exactly
1481
+ // what nobody notices until they look for data that is not there.
1482
+ const head = kit.head("Connect to cloud", at.source ? `${at.host} · from ${at.source}` : undefined);
1483
+ switch (plan.kind) {
1484
+ case "oss":
1485
+ return [head, kit.ok("Using open source.")];
1486
+ case "kept":
1487
+ if (plan.how === "skip") return [head, kit.ok(`Not connecting. The connection to ${at.savedHost} is unchanged.`)];
1488
+ return plan.verified
1489
+ ? [head, kit.ok(at.savedOrg ? `Connected as ${at.savedOrg} to ${at.savedHost}` : `Connected to ${at.savedHost}`)]
1490
+ : [head, kit.caution(`Keeping the connection to ${at.savedHost}. Its key couldn't be checked just now.`)];
1491
+ case "connect":
1492
+ return [
1493
+ head,
1494
+ kit.ok(plan.org ? `Key accepted for ${plan.org} on ${at.host}` : `Key accepted by ${at.host}`),
1495
+ ...(plan.note ? [kit.caution(plan.note)] : []),
1496
+ ];
1497
+ }
1498
+ }
1499
+
1500
+ /** What connecting wrote, for the DONE section. */
1501
+ function connectDoneLines(
1502
+ kit: ReturnType<typeof screenKit>,
1503
+ outcome: Awaited<ReturnType<typeof connectToCloud>>,
1504
+ host: string,
1505
+ org: string | undefined,
1506
+ ): string[] {
1507
+ const name = outcome.org?.name ?? outcome.org?.slug ?? org;
1508
+ const as = name ? `Connected as ${name} to ${host}` : `Connected to ${host}`;
1509
+ // Jev's state rides along on every branch that connected: whether it is on,
1510
+ // available but off, or was cleared is decided by this connect and said
1511
+ // nowhere else.
1512
+ if (outcome.policy.ok && outcome.ingest.ok) return [kit.ok(as), ...jevLines(outcome)];
1513
+ if (outcome.ingest.ok) {
1514
+ return [kit.ok(as), kit.caution(`Cloud-managed policies won't arrive: ${outcome.policy.reason}`), ...jevLines(outcome)];
1515
+ }
1516
+ if (outcome.policy.ok) return [kit.ok(as), kit.caution(`Nothing is collected: ${outcome.ingest.reason}`), ...jevLines(outcome)];
1517
+ return [kit.caution(`Not connected: ${outcome.ingest.reason ?? outcome.policy.reason ?? "unknown error"}`, "failproofai config")];
1518
+ }
1519
+
1520
+ /** Packs that were declared and refused, and deny for it — as the policies listing counts them. */
1521
+ function failClosedPacks(): Array<{ id: string | null }> {
1522
+ try {
1523
+ return readInstalledPacks().errors.filter((err) => err.effect !== "observe" && !err.semanticOnly);
1524
+ } catch {
1525
+ return [];
1526
+ }
1527
+ }
1528
+
1529
+ /**
1530
+ * The DONE section: what apply wrote, then whether anything is enforcing — the
1531
+ * one generic warning when it is not (decision D18) — and the custom-policy
1532
+ * files that will not load, which the removed review step used to show.
1533
+ */
1534
+ export function doneLines(
1535
+ kit: ReturnType<typeof screenKit>,
1536
+ state: { elapsedMs: number; done: string[]; cwd: string; agents: number; connected: boolean },
1537
+ ): string[] {
1538
+ const lines = [kit.head("Done", `in ${(state.elapsedMs / 1000).toFixed(1)}s`), ...state.done];
1539
+ const on = `${state.agents} ${state.agents === 1 ? "agent" : "agents"}`;
1540
+ if (notEnforcingReason(state.cwd)) {
1541
+ lines.push(
1542
+ kit.caution("Policies are not enforcing yet."),
1543
+ ` Turn on ours: ${kit.cmd(`failproofai policies add ${CORE_SOURCE}`)}`,
1544
+ );
1545
+ } else {
1546
+ const { count, custom } = enforcingPolicyCount(state.cwd);
1547
+ if (count > 0 || custom) {
1548
+ lines.push(
1549
+ kit.ok(
1550
+ count === 0
1551
+ ? `Your custom policies are enforcing on ${on}.`
1552
+ : custom
1553
+ ? `${count} ${count === 1 ? "policy" : "policies"} and your custom policies are enforcing on ${on}.`
1554
+ : `${count} ${count === 1 ? "policy is" : "policies are"} enforcing on ${on}.`,
1555
+ ),
1556
+ );
1557
+ }
1558
+ // A pack that failed to load fails closed, which is why the machine reads
1559
+ // as enforcing at all when nothing above counted. Said in the words the
1560
+ // policies listing uses, never as custom policies that may not exist.
1561
+ const failed = failClosedPacks();
1562
+ if (failed.length > 0) {
1563
+ const [first] = failed;
1564
+ const many = failed.length > 1;
1565
+ lines.push(
1566
+ kit.fail(
1567
+ `${first.id ?? "A pack"}${many ? ` and ${failed.length - 1} more` : ""} failed to load, ` +
1568
+ `so the calls ${many ? "they cover" : "it covers"} are denied.`,
1569
+ first.id ? `failproofai policies remove ${first.id}` : "failproofai policies",
1570
+ ),
1571
+ );
1572
+ } else if (count === 0 && !custom) {
1573
+ lines.push(kit.ok(`Policies are enforcing on ${on}.`));
1574
+ }
1575
+ }
1576
+ if (!state.connected) lines.push(` Connect to cloud any time: ${kit.cmd("failproofai config")}`);
1577
+ for (const warning of describeCustomPolicies(state.cwd).warnings) {
1578
+ lines.push(kit.caution(warning.replace(/^! /, "")));
1579
+ }
1580
+ lines.push("");
1581
+ return lines;
1582
+ }