failproofai 1.0.2-beta.3 → 1.0.2-beta.5

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 (210) 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 +3 -3
  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.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js +2 -3
  24. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -6
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +3 -4
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  39. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/.next/server/app/index.html +1 -1
  43. package/.next/standalone/.next/server/app/index.rsc +14 -14
  44. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  45. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  46. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  47. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  48. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  49. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  52. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  53. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  54. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  57. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  68. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +3 -3
  72. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  73. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[externals]__0e32-7c._.js → [externals]__1qzqcio._.js} +1 -1
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__03r-lqq._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +3 -0
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__12021ot._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0igxt32._.js → [root-of-the-server]__1p8poxd._.js} +2 -2
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1zkek3z._.js +3 -0
  86. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +3 -0
  87. package/.next/standalone/.next/server/chunks/_12moi5-._.js +3 -0
  88. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +6 -5
  89. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +3 -0
  90. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  91. 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_09d5c79._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  93. 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_1d2g73o._.js} +1 -1
  94. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1m84s36._.js → [root-of-the-server]__00-s7h8._.js} +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +4 -4
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  106. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__08zpry-._.js → [root-of-the-server]__0stn0gx._.js} +2 -2
  107. package/.next/standalone/.next/server/chunks/ssr/{src_hooks_08o7bqo._.js → [root-of-the-server]__15r6r7w._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0jzbk4_._.js → [root-of-the-server]__190won8._.js} +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  111. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0rsd3ll._.js → [root-of-the-server]__1sc84bb._.js} +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tag8_-._.js +5 -0
  113. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/{_1db3-8n._.js → _1if9ixi._.js} +2 -2
  116. package/.next/standalone/.next/server/chunks/ssr/{_11opd9e._.js → _1kesqs-._.js} +2 -2
  117. package/.next/standalone/.next/server/chunks/ssr/{_15xtekm._.js → _1km9-gn._.js} +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  119. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  120. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  121. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  122. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  123. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  124. package/.next/standalone/.next/server/chunks/ssr/lib_1cf5ya2._.js +3 -0
  125. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_15bjxkm._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
  127. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
  128. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  129. package/.next/standalone/.next/server/middleware.js +1 -1
  130. package/.next/standalone/.next/server/pages/404.html +1 -1
  131. package/.next/standalone/.next/server/pages/500.html +1 -1
  132. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  133. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  134. package/.next/standalone/.next/static/chunks/0tyt-3a5rtzy8.js +1 -0
  135. package/.next/standalone/.next/static/chunks/{1mk3vmmc673hu.js → 21r7iw3rmixf5.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/{10pok-mrqv426.js → 26lfjhb2kzzgb.js} +1 -1
  137. package/.next/standalone/.next/static/chunks/2_27jraat038x.js +1 -0
  138. package/.next/standalone/.next/static/chunks/2hwc8e923g5df.js +1 -0
  139. package/.next/standalone/.next/static/chunks/{3cs_rf5p765v0.js → 33np9nrh1mvj9.js} +1 -1
  140. package/.next/standalone/.next/static/chunks/{1oml7c7env4-g.js → 354-n7p7labpn.js} +1 -1
  141. package/.next/standalone/.next/static/chunks/36-8i_vbkh85w.js +1 -0
  142. package/.next/standalone/.next/static/chunks/{2jsomd79g7kn0.js → 391tpdqcas7rq.js} +1 -1
  143. package/.next/standalone/.next/static/chunks/{1yywl_a87108w.js → 3_b73tc78jwsq.js} +2 -2
  144. package/.next/standalone/.next/static/chunks/{1za08zjcctyil.js → 3i8rg2fv9nd6e.js} +2 -2
  145. package/.next/standalone/.next/static/chunks/{2ey_ih6j6cm15.js → 3jqm8ubpv3i-4.js} +1 -1
  146. package/.next/standalone/.next/static/chunks/turbopack-2x-o099q64i8h.js +1 -0
  147. package/.next/standalone/SECURITY.md +18 -3
  148. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +7 -0
  149. package/.next/standalone/fp-cloud-cli/uv.lock +6 -6
  150. package/.next/standalone/lib/opencode-db.ts +77 -0
  151. package/.next/standalone/lib/opencode-projects.ts +37 -41
  152. package/.next/standalone/lib/opencode-sessions.ts +59 -36
  153. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  154. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  155. package/.next/standalone/node_modules/next/dist/client/components/segment-cache/cache.js +23 -34
  156. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
  157. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
  158. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +1 -1
  159. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  160. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  161. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  162. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  163. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  164. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  165. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  166. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  167. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  168. package/.next/standalone/node_modules/next/package.json +10 -10
  169. package/.next/standalone/package.json +19 -19
  170. package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
  171. package/.next/standalone/sdk/python/uv.lock +23 -23
  172. package/.next/standalone/server.js +1 -1
  173. package/bin/failproofai.mjs +151 -60
  174. package/dist/cli.mjs +3670 -3005
  175. package/dist/worker.mjs +189 -164
  176. package/lib/opencode-db.ts +77 -0
  177. package/lib/opencode-projects.ts +37 -41
  178. package/lib/opencode-sessions.ts +59 -36
  179. package/package.json +19 -19
  180. package/src/audit/cache.ts +201 -40
  181. package/src/audit/cli-adapters/claude.ts +111 -1
  182. package/src/audit/cli-adapters/index.ts +31 -1
  183. package/src/audit/index.ts +131 -11
  184. package/src/audit/replay.ts +15 -91
  185. package/src/hooks/configure-wizard.ts +16 -48
  186. package/src/hooks/handler.ts +8 -1
  187. package/src/hooks/manager.ts +72 -3
  188. package/src/hooks/pack-cli.ts +793 -45
  189. package/src/hooks/pack-manifest.ts +28 -0
  190. package/src/hooks/pack-store.ts +8 -25
  191. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +0 -3
  192. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +0 -3
  193. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +0 -3
  194. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1l-khtb._.js +0 -3
  195. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mhmdzs._.js +0 -3
  196. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +0 -3
  197. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +0 -3
  198. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +0 -3
  199. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +0 -3
  200. package/.next/standalone/.next/server/chunks/_185gyk9._.js +0 -3
  201. package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +0 -12
  202. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +0 -5
  203. package/.next/standalone/.next/static/chunks/01nuy-aqshahr.js +0 -1
  204. package/.next/standalone/.next/static/chunks/1pl7h8vllvoi2.js +0 -1
  205. package/.next/standalone/.next/static/chunks/2efnffq2ipgr_.js +0 -1
  206. package/.next/standalone/.next/static/chunks/2gd07pinz0i7f.js +0 -1
  207. package/.next/standalone/.next/static/chunks/turbopack-3kjgdgwci4tqf.js +0 -1
  208. /package/.next/standalone/.next/static/{G-faMkpZioETgewZ4X4wX → 59jsH11LoyXu9UBoHvgGM}/_buildManifest.js +0 -0
  209. /package/.next/standalone/.next/static/{G-faMkpZioETgewZ4X4wX → 59jsH11LoyXu9UBoHvgGM}/_clientMiddlewareManifest.js +0 -0
  210. /package/.next/standalone/.next/static/{G-faMkpZioETgewZ4X4wX → 59jsH11LoyXu9UBoHvgGM}/_ssgManifest.js +0 -0
@@ -6,9 +6,13 @@
6
6
  * This layer decides nothing; it formats.
7
7
  */
8
8
  import { execFileSync } from "node:child_process";
9
+ import { homedir, tmpdir } from "node:os";
9
10
  import { createHash } from "node:crypto";
10
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
- import { resolve } from "node:path";
11
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { basename, dirname, join, resolve } from "node:path";
13
+ import { INTEGRATION_TYPES } from "./types";
14
+ import { PACK_VERSION_RE } from "./pack-manifest";
15
+ import { detectInstalledClis } from "./integrations";
12
16
  import { parsePackIdentity, parsePackPolicy, readInstalledPacks } from "./pack-manifest";
13
17
  import {
14
18
  PACK_CHECKSUMS_ASSET,
@@ -31,6 +35,7 @@ import {
31
35
  chip,
32
36
  emptyState,
33
37
  multiSelect,
38
+ promptText,
34
39
  note,
35
40
  nextStep,
36
41
  optsFor,
@@ -59,7 +64,7 @@ function parseList(rest: string[], flag: string): string[] | undefined {
59
64
  }
60
65
 
61
66
  /** Flags that take a separate value, so it is never mistaken for the source. */
62
- const VALUE_FLAGS = new Set(["--only", "--policy", "--category"]);
67
+ const VALUE_FLAGS = new Set(["--only", "--policy", "--category", "--cli"]);
63
68
 
64
69
  /** Find the positional source without mistaking a flag's separate value for it. */
65
70
  export function packAddSource(rest: string[]): string | undefined {
@@ -71,14 +76,21 @@ export function packAddSource(rest: string[]): string | undefined {
71
76
  }
72
77
 
73
78
  /** Names taken by our own policies, so a selection can be checked before install. */
74
- function selectionFrom(rest: string[]): { only?: string[]; categories?: string[]; all?: boolean } {
79
+ function selectionFrom(rest: string[]): {
80
+ only?: string[];
81
+ categories?: string[];
82
+ all?: boolean;
83
+ clis?: string[];
84
+ } {
75
85
  // `--policy` reads right for one ("give me this policy"), `--only` for a set.
76
86
  // They are the same switch; taking both means neither is the wrong guess.
77
87
  const only = parseList(rest, "--policy") ?? parseList(rest, "--only");
78
88
  const categories = parseList(rest, "--category");
89
+ const clis = parseList(rest, "--cli");
79
90
  return {
80
91
  ...(only ? { only } : {}),
81
92
  ...(categories ? { categories } : {}),
93
+ ...(clis ? { clis } : {}),
82
94
  ...(rest.includes("--all") ? { all: true } : {}),
83
95
  };
84
96
  }
@@ -333,10 +345,527 @@ export async function runPolicyPicker(
333
345
  }
334
346
 
335
347
 
348
+ /**
349
+ * Write a working starter policy, because the blank file is the hardest step.
350
+ *
351
+ * `publish --help` described the shape in prose, which leaves a newcomer to
352
+ * hand-write their first `customPolicies.add({...})` from a description and
353
+ * find out whether they got it right at publish time. What lands here is a
354
+ * policy that already RUNS and already blocks something real, so the first
355
+ * action is editing a working thing rather than authoring an empty one.
356
+ */
357
+ /**
358
+ * A policy filename, whatever spelling it arrived in. Discovery takes
359
+ * `.mjs`/`.js`/`.ts` and nothing else, so a name with no extension has to gain
360
+ * one or the file it names can never be found.
361
+ */
362
+ function withPolicyExtension(name: string): string {
363
+ return /\.(mjs|js|ts)$/.test(name) ? name : `${name}.mjs`;
364
+ }
365
+
366
+ async function scaffold(target: string | null): Promise<PackCliResult> {
367
+ // Ask what the pack is called when nothing was named and there is somebody to
368
+ // ask. The answer becomes the FILENAME and the header, so what lands on disk
369
+ // is already theirs rather than a file called `my-policies.mjs` that everybody
370
+ // then has to remember to rename. Skipped entirely for an explicit path or a
371
+ // non-TTY, where there is no question to ask and no one to answer it.
372
+ let chosen = target;
373
+ if (!chosen && process.stdin.isTTY && process.stdout.isTTY) {
374
+ const answer = await promptText({
375
+ message: "What is this pack called?",
376
+ hint: "used for the filename — letters, numbers, dashes",
377
+ defaultValue: "my-policies",
378
+ validate: (v) =>
379
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v.trim())
380
+ ? null
381
+ : "letters, numbers, dots, dashes and underscores",
382
+ stdin: process.stdin,
383
+ stdout: process.stdout,
384
+ });
385
+ if (answer === null) return ok(["Nothing written."]);
386
+ chosen = answer.trim();
387
+ }
388
+ // Both paths land here, because only one of them used to. A name typed at
389
+ // the prompt got `.mjs`; the same name passed as `--init myguards` was taken
390
+ // literally and wrote a file called `myguards` — which discovery skips (it
391
+ // takes .mjs/.js/.ts) and which no ESM loader will import. The starter file
392
+ // was unreachable by every command meant to pick it up.
393
+ const path = resolve(withPolicyExtension(chosen ?? "my-policies"));
394
+ if (existsSync(path)) {
395
+ return fail([`${path} already exists — name a different file, or edit that one.`]);
396
+ }
397
+ mkdirSync(resolve(path, ".."), { recursive: true });
398
+ writeFileSync(
399
+ path,
400
+ `import { customPolicies, allow, deny } from "failproofai";
401
+
402
+ // One file, no relative imports: only this entry is digest-pinned, so a pack
403
+ // that imported siblings could not honestly claim to be verified.
404
+
405
+ customPolicies.add({
406
+ name: "block-force-push",
407
+ description: "Block git push --force on any branch",
408
+ // Groups it in the picker, and is what --category selects on.
409
+ category: "Git",
410
+ // Whether it is ON for somebody who installs with no flags. Defaults to false.
411
+ defaultEnabled: true,
412
+ match: { events: ["PreToolUse"] },
413
+ fn: async (ctx) => {
414
+ if (ctx.toolName !== "Bash") return allow();
415
+ const cmd = String(ctx.toolInput?.command ?? "");
416
+ if (/\\bgit\\s+push\\b[^|;&]*\\s(-f|--force)\\b/.test(cmd)) {
417
+ return deny("Force-push rewrites history someone else may have pulled.");
418
+ }
419
+ return allow();
420
+ },
421
+ });
422
+ `,
423
+ "utf8",
424
+ );
425
+ return ok([
426
+ `Wrote ${path} — one policy, already working.`,
427
+ "",
428
+ "Try it on this machine before anyone else sees it:",
429
+ ` failproofai policies -i -c ${path}`,
430
+ " then ask your agent to force-push — it gets refused",
431
+ "",
432
+ "Then publish it:",
433
+ " failproofai publish --repo <you>/<repo>",
434
+ ]);
435
+ }
436
+
437
+ /**
438
+ * The GitHub repository this file lives in, from its own git remote.
439
+ *
440
+ * Typing `--repo you/guards` from inside the checkout of `you/guards` is asking
441
+ * somebody to restate what the directory already knows. Read from the ENTRY
442
+ * FILE's directory rather than the process cwd: publishing a policy that lives
443
+ * in another checkout is normal, and the answer must describe the file, not
444
+ * wherever the shell happens to be.
445
+ */
446
+ function inferRepo(entryPath: string): string | null {
447
+ try {
448
+ const url = execFileSync("git", ["remote", "get-url", "origin"], {
449
+ cwd: resolve(entryPath, ".."),
450
+ encoding: "utf8",
451
+ stdio: ["ignore", "pipe", "ignore"],
452
+ timeout: 5_000,
453
+ }).trim();
454
+ // Both spellings git hands out: scp-style and https.
455
+ const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/i);
456
+ return m ? `${m[1]}/${m[2]}` : null;
457
+ } catch {
458
+ return null;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * A tag on HEAD, when there is one and the file is clean.
464
+ *
465
+ * Somebody who tagged `v1.2.0` has SAID what this release is, which a counted
466
+ * number cannot. Absent a tag there is nothing here to infer — the version is
467
+ * counted from what the repository has already published instead.
468
+ */
469
+ function inferTaggedVersion(entryPath: string): string | null {
470
+ const cwd = resolve(entryPath, "..");
471
+ const git = (args: string[]): string | null => {
472
+ try {
473
+ return execFileSync("git", args, {
474
+ cwd,
475
+ encoding: "utf8",
476
+ stdio: ["ignore", "pipe", "ignore"],
477
+ timeout: 5_000,
478
+ }).trim();
479
+ } catch {
480
+ return null;
481
+ }
482
+ };
483
+ const tag = git(["describe", "--tags", "--exact-match", "HEAD"]);
484
+ if (!tag || !PACK_VERSION_RE.test(tag)) return null;
485
+ // A tag NAMES a commit, so shipping edited bytes under it publishes something
486
+ // that commit does not contain — and two artifacts would claim one version,
487
+ // which `id|version|sha256` compares in both the audit key and the pack
488
+ // upsert. A counted version names no commit and has no such problem, so this
489
+ // refusal belongs to the tagged path alone.
490
+ if (git(["status", "--porcelain", "--", entryPath])) return null;
491
+ return tag;
492
+ }
493
+
494
+ /**
495
+ * The policy file in this directory, when there is exactly one.
496
+ *
497
+ * Naming the path is restating something the directory already answers: a
498
+ * checkout with one policy file in it has no ambiguity to resolve. So the
499
+ * common case is `failproofai publish`, and the argument stays for the cases
500
+ * that genuinely need it.
501
+ *
502
+ * Identified by CONTENT, not by filename. A name convention would either miss
503
+ * `guards.mjs` or match an unrelated `policies.mjs` that configures something
504
+ * else entirely; a file that imports `failproofai` and calls
505
+ * `customPolicies.add` is a policy file whatever it is called.
506
+ *
507
+ * Returns ALL of them. Splitting policies across files is the normal thing to
508
+ * do past about three, and they are one pack — so several files is an answer,
509
+ * not an ambiguity, and they get bundled into the single artifact a pack must
510
+ * be. Naming a path explicitly still publishes exactly that file.
511
+ *
512
+ * Non-recursive on purpose. Walking the tree finds fixtures, examples and
513
+ * anything vendored, and publishing those is the failure this avoids.
514
+ */
515
+ function findEntry(dir: string): string[] {
516
+ let names: string[];
517
+ try {
518
+ names = readdirSync(dir);
519
+ } catch {
520
+ return [];
521
+ }
522
+ const found: string[] = [];
523
+ for (const name of names) {
524
+ if (!/\.(mjs|js|ts)$/.test(name)) continue;
525
+ const full = resolve(dir, name);
526
+ try {
527
+ // Policy entries are small; anything large is not one, and reading it
528
+ // would be the expensive half of this scan.
529
+ if (statSync(full).size > 512 * 1024) continue;
530
+ const text = readFileSync(full, "utf8");
531
+ if (/from\s+["']failproofai["']/.test(text) && /customPolicies\s*\.\s*add\s*\(/.test(text)) {
532
+ found.push(full);
533
+ }
534
+ } catch {
535
+ continue;
536
+ }
537
+ }
538
+ return found.sort();
539
+ }
540
+
541
+ /**
542
+ * The version after whatever this repository has already published.
543
+ *
544
+ * A commit SHA names exactly where bytes came from and tells a reader nothing
545
+ * else: it does not order, so `a1b2c3d` and `f9e8d7c` give no clue which came
546
+ * first, and nobody can say "I am on the older one". Counting instead — 1.0.0,
547
+ * then 1.0.1 — costs one API call and answers both.
548
+ *
549
+ * Reads the repository's own releases rather than anything local, because the
550
+ * releases ARE the published record: a machine that has never published from
551
+ * this checkout still computes the right next number, and two people publishing
552
+ * from different clones cannot both mint 1.0.1 without one of them seeing the
553
+ * other's release first.
554
+ *
555
+ * Non-semver tags are ignored rather than parsed heroically — a repo whose
556
+ * releases are named `nightly` has no sequence to continue, and starting a
557
+ * fresh count is more honest than inventing one.
558
+ */
559
+ async function nextVersion(owner: string, name: string, token: string): Promise<string> {
560
+ const res = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases?per_page=100`, token);
561
+ const tags = Array.isArray(res.json)
562
+ ? (res.json as unknown as Array<{ tag_name?: unknown }>)
563
+ .map((r) => (typeof r.tag_name === "string" ? r.tag_name : ""))
564
+ .filter(Boolean)
565
+ : [];
566
+ let best: [number, number, number] | null = null;
567
+ for (const tag of tags) {
568
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
569
+ if (!m) continue;
570
+ const parsed: [number, number, number] = [Number(m[1]), Number(m[2]), Number(m[3])];
571
+ if (
572
+ !best ||
573
+ parsed[0] > best[0] ||
574
+ (parsed[0] === best[0] && parsed[1] > best[1]) ||
575
+ (parsed[0] === best[0] && parsed[1] === best[1] && parsed[2] > best[2])
576
+ ) {
577
+ best = parsed;
578
+ }
579
+ }
580
+ return best ? `${best[0]}.${best[1]}.${best[2] + 1}` : "1.0.0";
581
+ }
582
+
583
+ /**
584
+ * The repository, created if it is not there.
585
+ *
586
+ * Creating it was the only step `publish` did not do, and the one that made
587
+ * "one command" untrue — it sent the publisher to a different tool and back.
588
+ *
589
+ * PUBLIC, always. A private repository publishes to nobody, because installs
590
+ * are anonymous HTTPS with no credential to offer, so creating one here would
591
+ * manufacture the exact dead end the caller then warns about. Somebody who
592
+ * wants a private pack makes it themselves and knows why.
593
+ */
594
+ async function ensureRepo(
595
+ owner: string,
596
+ name: string,
597
+ id: string,
598
+ token: string,
599
+ sourceDir?: string,
600
+ ): Promise<{ info: Awaited<ReturnType<typeof gh>>; created: boolean } | { error: string[] }> {
601
+ let info = await gh(`${GITHUB_API}/repos/${owner}/${name}`, token);
602
+ let created = false;
603
+ if (info.status === 404) {
604
+ // Personal and organisation repositories are different endpoints, and the
605
+ // only way to tell which applies is to ask who the token belongs to.
606
+ const me = await gh(`${GITHUB_API}/user`, token);
607
+ const login = typeof me.json?.login === "string" ? me.json.login : null;
608
+ const endpoint =
609
+ login && login.toLowerCase() === owner.toLowerCase()
610
+ ? `${GITHUB_API}/user/repos`
611
+ : `${GITHUB_API}/orgs/${owner}/repos`;
612
+ const made = await gh(endpoint, token, {
613
+ method: "POST",
614
+ contentType: "application/json",
615
+ body: JSON.stringify({
616
+ name,
617
+ private: false,
618
+ description: `failproofai policy pack — ${id}`,
619
+ // NOT auto_init. The author already has the commit that holds these
620
+ // policies; a repository seeded with its own "Initial commit" shares
621
+ // no history with it, so the `git push` that every publish is followed
622
+ // by is rejected as unrelated — and the way out (`pull --allow-
623
+ // unrelated-histories`, or a force-push over the seed) is a worse
624
+ // first five minutes than an empty repository, which accepts the push
625
+ // as-is. Releases do not need a commit to hang from: the tag is
626
+ // created against whatever the release API is given.
627
+ auto_init: false,
628
+ }),
629
+ });
630
+ if (made.status >= 400) {
631
+ return {
632
+ error: [
633
+ `${owner}/${name} does not exist and could not be created: ${ghError(made)}`,
634
+ login
635
+ ? `The credential belongs to ${login}. Creating under a different owner needs`
636
+ : "The credential could not be identified. Creating a repository needs",
637
+ " rights to that account or organisation — or make it by hand:",
638
+ ` gh repo create ${owner}/${name} --public`,
639
+ ],
640
+ };
641
+ }
642
+ created = true;
643
+ // A repository created with no commits has no default branch, and the
644
+ // release API tags the default branch — so something has to land on it
645
+ // before the release is cut. Pushing the author's own history is the
646
+ // version that leaves a usable repository behind; seeding is the fallback
647
+ // for a pack that is not in a git checkout at all.
648
+ if (!(sourceDir && pushExistingHistory(sourceDir, owner, name, token))) {
649
+ await seedDefaultBranch(owner, name, id, token);
650
+ }
651
+ info = await gh(`${GITHUB_API}/repos/${owner}/${name}`, token);
652
+ }
653
+ if (info.status >= 400) {
654
+ return { error: [`Could not read ${owner}/${name}: ${ghError(info)}`] };
655
+ }
656
+ return { info, created };
657
+ }
658
+
659
+ /**
660
+ * Push the checkout the policies live in to the repository just created for
661
+ * them, and report whether it landed.
662
+ *
663
+ * Why publish pushes at all: it created the repository, so it owns the one
664
+ * moment when pushing cannot conflict with anything. The repository used to be
665
+ * created with `auto_init`, which meant GitHub wrote an "Initial commit" the
666
+ * author did not have — so the `git push` that every publish is followed by was
667
+ * rejected as unrelated history, every time, for everybody. Seeding it and
668
+ * pushing it are the same job; doing only the first left the author to
669
+ * reconcile two histories by hand.
670
+ *
671
+ * It also makes the tag mean something. The release tags the default branch, so
672
+ * against a seeded repository `1.0.0` named a README commit that did not
673
+ * contain a single one of the policies it shipped.
674
+ *
675
+ * The token goes in through GIT_ASKPASS, never in the URL or the argv: a remote
676
+ * with a credential in it is written into `.git/config` in plaintext, and one on
677
+ * a command line is readable from `ps` by every user on the box.
678
+ */
679
+ export const __pushExistingHistoryForTest = (
680
+ dir: string,
681
+ owner: string,
682
+ name: string,
683
+ token: string,
684
+ ): boolean => pushExistingHistory(dir, owner, name, token);
685
+
686
+ function pushExistingHistory(dir: string, owner: string, name: string, token: string): boolean {
687
+ const git = (args: string[], env?: Record<string, string>): string | null => {
688
+ try {
689
+ return execFileSync("git", args, {
690
+ cwd: dir,
691
+ encoding: "utf8",
692
+ stdio: ["ignore", "pipe", "ignore"],
693
+ timeout: 60_000,
694
+ env: env ? { ...process.env, ...env } : process.env,
695
+ }).trim();
696
+ } catch {
697
+ return null;
698
+ }
699
+ };
700
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
701
+ // No commits yet (`HEAD` unborn) means there is nothing to push and nothing
702
+ // to conflict with later either.
703
+ if (!branch || branch === "HEAD" || !git(["rev-parse", "--verify", "HEAD"])) return false;
704
+
705
+ const askpass = join(mkdtempSync(join(tmpdir(), "fpai-askpass-")), "askpass.sh");
706
+ try {
707
+ writeFileSync(askpass, `#!/bin/sh\ncase "$1" in\n*Username*) echo x-access-token ;;\n*) echo "$FPAI_GH_TOKEN" ;;\nesac\n`, { mode: 0o700 });
708
+ const env = { GIT_ASKPASS: askpass, FPAI_GH_TOKEN: token, GIT_TERMINAL_PROMPT: "0" };
709
+ const url = `${GITHUB_GIT}/${owner}/${name}.git`;
710
+ // Name the remote FIRST when there is not one, so the push can be `-u` and
711
+ // leave the branch tracking it. Pushing to a bare URL and setting the
712
+ // upstream afterwards cannot work: `--set-upstream-to` needs a
713
+ // remote-tracking ref, and a URL push writes none.
714
+ const adopt = !git(["remote", "get-url", "origin"]);
715
+ if (adopt) git(["remote", "add", "origin", url]);
716
+ // An author who already has an `origin` keeps it, and the push goes to the
717
+ // URL directly rather than quietly redirecting their remote.
718
+ const pushed = adopt
719
+ ? git(["push", "-u", "origin", `${branch}:${branch}`], env)
720
+ : git(["push", url, `${branch}:${branch}`], env);
721
+ if (pushed === null) {
722
+ // Leave nothing behind that was not there before.
723
+ if (adopt) git(["remote", "remove", "origin"]);
724
+ return false;
725
+ }
726
+ return true;
727
+ } finally {
728
+ try {
729
+ rmSync(dirname(askpass), { recursive: true, force: true });
730
+ } catch {
731
+ /* a leftover temp dir is not worth failing a publish over */
732
+ }
733
+ }
734
+ }
735
+
736
+ /**
737
+ * The minimum that gives a repository a default branch: one commit, made
738
+ * through the API. Only for a pack published from somewhere that is not a git
739
+ * checkout — otherwise the author's own history does this job better.
740
+ */
741
+ async function seedDefaultBranch(owner: string, name: string, id: string, token: string): Promise<void> {
742
+ await gh(`${GITHUB_API}/repos/${owner}/${name}/contents/README.md`, token, {
743
+ method: "PUT",
744
+ contentType: "application/json",
745
+ body: JSON.stringify({
746
+ message: `${id}: create pack repository`,
747
+ content: Buffer.from(
748
+ `# ${name}\n\nA [failproofai](https://github.com/failproofai/failproofai) policy pack.\n\n\`\`\`bash\nfailproofai policies add ${owner}/${name}\n\`\`\`\n`,
749
+ "utf8",
750
+ ).toString("base64"),
751
+ }),
752
+ });
753
+ }
754
+
755
+ /**
756
+ * Where `bun` is, if it is anywhere.
757
+ *
758
+ * Bundling needs it, and a CLI installed from npm runs under node — so bun is
759
+ * likely present (it is in `engines`) but never guaranteed. Checked in the
760
+ * places it actually installs to, because `npm i -g bun` puts it in ONE nvm
761
+ * version's bin dir and `nvm use` then drops it off PATH.
762
+ */
763
+ function findBun(): string | null {
764
+ const candidates = [
765
+ "bun",
766
+ process.env.BUN_INSTALL ? resolve(process.env.BUN_INSTALL, "bin", "bun") : null,
767
+ resolve(homedir(), ".bun", "bin", "bun"),
768
+ "/opt/homebrew/bin/bun",
769
+ "/usr/local/bin/bun",
770
+ ].filter((c): c is string => c !== null);
771
+ for (const candidate of candidates) {
772
+ try {
773
+ execFileSync(candidate, ["--version"], { stdio: "ignore", timeout: 5_000 });
774
+ return candidate;
775
+ } catch {
776
+ continue;
777
+ }
778
+ }
779
+ return null;
780
+ }
781
+
782
+ /**
783
+ * Collapse several policy files, or one that imports its neighbours, into the
784
+ * single artifact a pack has to be.
785
+ *
786
+ * One entry file is a real constraint — only the entry is content-addressed, so
787
+ * a multi-file pack could not honestly claim to be digest-pinned — but that is a
788
+ * constraint on what gets PUBLISHED, not on how anybody writes. Splitting
789
+ * policies across files is the normal thing to do past about three of them, and
790
+ * telling someone to go and configure a bundler first is the tool refusing to do
791
+ * the one mechanical step it already does for its own pack.
792
+ *
793
+ * `--external failproofai` because the loader supplies it: bundling our own
794
+ * module into the artifact would ship a second copy of the registry, and
795
+ * policies would register into an object nothing reads.
796
+ */
797
+ function bundleEntry(
798
+ sources: string[],
799
+ outDir: string,
800
+ ): { path: string } | { error: string[] } {
801
+ const bun = findBun();
802
+ if (!bun) {
803
+ return {
804
+ error: [
805
+ sources.length > 1
806
+ ? `${sources.length} policy files here, and bundling them needs bun, which is not installed.`
807
+ : `${sources[0]} imports other files, and bundling needs bun, which is not installed.`,
808
+ "Either install bun (https://bun.sh), bundle it yourself first —",
809
+ " esbuild <entry> --bundle --format=esm --external:failproofai --outfile=pack.mjs",
810
+ "then publish the bundle — or name a single self-contained file.",
811
+ ],
812
+ };
813
+ }
814
+ mkdirSync(outDir, { recursive: true });
815
+ // A generated entry that imports each file for its SIDE EFFECT: every policy
816
+ // file registers by calling `customPolicies.add` at module scope, so importing
817
+ // it is what puts the policies in the registry. Same shape as the generated
818
+ // entry this repo builds its own pack from.
819
+ const entry = resolve(outDir, ".entry.generated.mjs");
820
+ writeFileSync(
821
+ entry,
822
+ sources.map((f) => `import ${JSON.stringify(f.startsWith("/") ? f : resolve(f))};`).join("\n") + "\n",
823
+ "utf8",
824
+ );
825
+ const bundled = resolve(outDir, ".bundled.mjs");
826
+ try {
827
+ execFileSync(
828
+ bun,
829
+ ["build", "--target=node", "--format=esm", "--external", "failproofai",
830
+ "--outfile", bundled, entry],
831
+ { stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 },
832
+ );
833
+ } catch (err) {
834
+ const detail = err instanceof Error && "stderr" in err
835
+ ? String((err as { stderr?: Buffer }).stderr ?? "").trim().split("\n").slice(0, 6)
836
+ : [];
837
+ return { error: [`Could not bundle those files:`, ...detail.map((l) => ` ${l}`)] };
838
+ }
839
+ return { path: bundled };
840
+ }
841
+
842
+ /**
843
+ * The entry path among publish's arguments, and nothing that merely follows a
844
+ * flag.
845
+ *
846
+ * `packAddSource` knows the flags `policies add` takes, not the ones `publish`
847
+ * takes — so `publish --id me/x --version 1.0.0` read `me/x` as the file to
848
+ * publish and failed on ENOENT. It only ever worked because every example wrote
849
+ * the path first.
850
+ */
851
+ const PUBLISH_VALUE_FLAGS = new Set([
852
+ "--repo", "--version", "--id", "--tag", "--notes", "--out", "--effect", "--entry", "--init",
853
+ ]);
854
+ function publishEntryArg(rest: string[]): string | undefined {
855
+ const consumed = new Set<number>();
856
+ for (let i = 0; i < rest.length; i += 1) {
857
+ if (PUBLISH_VALUE_FLAGS.has(rest[i])) consumed.add(i + 1);
858
+ }
859
+ return rest.find((a, i) => !a.startsWith("--") && !consumed.has(i));
860
+ }
861
+
336
862
  // ── publish ───────────────────────────────────────────────────────────────
337
863
 
338
864
  const GITHUB_API = process.env.FAILPROOFAI_GITHUB_API ?? "https://api.github.com";
339
865
  const GITHUB_UPLOADS = process.env.FAILPROOFAI_GITHUB_UPLOADS ?? "https://uploads.github.com";
866
+ /** Where the git remote lives, so the push path can be tested against a real
867
+ * local bare repository rather than only reasoned about. */
868
+ const GITHUB_GIT = process.env.FAILPROOFAI_GITHUB_GIT ?? "https://github.com";
340
869
 
341
870
  /**
342
871
  * The credential, from the places a developer already keeps one.
@@ -423,6 +952,60 @@ function ghError(res: { status: number; json: Record<string, unknown> | null; te
423
952
  * - a private repository, which publishes to nobody, because installs are
424
953
  * anonymous HTTPS with no credential to offer.
425
954
  */
955
+ /**
956
+ * The two things `publish` cannot work out on its own, asked instead of
957
+ * demanded as flags.
958
+ *
959
+ * Everything else it already derives: the policy files by content, the version
960
+ * by counting the repository's own releases, the id from the repository, the
961
+ * credential from the environment. What is left is genuinely a question —
962
+ * WHERE this should live, when the folder has no git remote naming somewhere —
963
+ * and a confirmation, because publishing is public and a version number can
964
+ * never be reused.
965
+ *
966
+ * Only ever on a TTY. A pipe, a CI job or a test gets the old behaviour: flags
967
+ * decide, and a missing `--repo` still means "build the assets and stop"
968
+ * rather than a prompt nobody can answer.
969
+ */
970
+ async function askWhereToPublish(
971
+ suggestion: string,
972
+ io: { stdin: TTYIn; stdout: TTYOut },
973
+ ): Promise<string | null> {
974
+ const answer = await promptText({
975
+ // The suggestion goes in the HINT, not just in `defaultValue`: the hint is
976
+ // what renders as the placeholder, while `defaultValue` is only applied on
977
+ // an empty submit and is never shown. Passing it as the default alone left
978
+ // the one keystroke that finishes this prompt — return — invisible.
979
+ message: "Where should this publish?",
980
+ hint: `${suggestion} · created if it does not exist`,
981
+ defaultValue: suggestion,
982
+ validate: (v) => {
983
+ const t = v.trim();
984
+ if (!/^[A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*$/.test(t)) return "owner/repo";
985
+ return null;
986
+ },
987
+ stdin: io.stdin,
988
+ stdout: io.stdout,
989
+ });
990
+ return answer === null ? null : answer.trim();
991
+ }
992
+
993
+ /**
994
+ * The account the credential belongs to, for the default answer above.
995
+ *
996
+ * Best-effort: a failure here costs a nicer default and nothing else, so it
997
+ * never blocks or reports. The prompt still has the folder name to fall back
998
+ * on, which is what the repository would most likely be called anyway.
999
+ */
1000
+ async function credentialLogin(token: string): Promise<string | null> {
1001
+ try {
1002
+ const me = await gh(`${GITHUB_API}/user`, token);
1003
+ return typeof me.json?.login === "string" ? me.json.login : null;
1004
+ } catch {
1005
+ return null;
1006
+ }
1007
+ }
1008
+
426
1009
  async function publish(rest: string[]): Promise<PackCliResult> {
427
1010
  const flag = (name: string): string | undefined => {
428
1011
  const i = rest.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
@@ -430,18 +1013,73 @@ async function publish(rest: string[]): Promise<PackCliResult> {
430
1013
  return rest[i].includes("=") ? rest[i].split("=").slice(1).join("=") : rest[i + 1];
431
1014
  };
432
1015
 
433
- const repo = flag("repo");
434
- const version = flag("version");
435
- const entry = packAddSource(rest.filter((a) => a !== "--dry-run")) ?? flag("entry");
1016
+ if (rest.includes("--init")) {
1017
+ return scaffold(flag("init") ?? publishEntryArg(rest) ?? null);
1018
+ }
1019
+
1020
+ let entry = publishEntryArg(rest.filter((a) => a !== "--dry-run")) ?? flag("entry");
1021
+ // Every policy file here, bundled into one artifact below. Named explicitly,
1022
+ // it is that file and only that file.
1023
+ const discovered = entry ? [] : findEntry(process.cwd());
1024
+ if (!entry && discovered.length > 0) entry = discovered[0];
1025
+ let repo = flag("repo") ?? (entry ? inferRepo(entry) : null) ?? undefined;
1026
+ // Nothing names a destination and there is somebody to ask. This is the
1027
+ // whole of "just run `failproofai publish`": the folder is a git repo full
1028
+ // of policies, and the one thing it cannot know is where they should go.
1029
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
1030
+ const askingWhere = interactive && !repo && entry && !rest.includes("--dry-run");
1031
+ if (askingWhere) {
1032
+ // What is about to be published, before being asked where to put it. The
1033
+ // question is answerable without it — but "publish" with no arguments
1034
+ // otherwise gives no sign of WHICH files it found, and finding the wrong
1035
+ // ones is the mistake that matters here.
1036
+ const found = discovered.length > 0 ? discovered : [entry!];
1037
+ process.stdout.write(
1038
+ `\n ${found.length} policy ${found.length === 1 ? "file" : "files"}: ` +
1039
+ `${found.map((f) => basename(f)).join(", ")}\n\n`,
1040
+ );
1041
+ const token = githubToken();
1042
+ const login = token ? await credentialLogin(token) : null;
1043
+ const folder = basename(resolve(entry!, ".."));
1044
+ const answered = await askWhereToPublish(
1045
+ `${login ?? "your-account"}/${folder}`,
1046
+ { stdin: process.stdin as unknown as TTYIn, stdout: process.stdout as unknown as TTYOut },
1047
+ );
1048
+ if (answered === null) return ok(["Nothing was published."]);
1049
+ repo = answered;
1050
+ }
1051
+ // A tag on HEAD is somebody SAYING what this release is, so it wins over a
1052
+ // counted one. Everything else is decided after the repository is known,
1053
+ // because the count comes from what that repository has already published.
1054
+ let version = flag("version") ?? (entry ? inferTaggedVersion(entry) : null) ?? undefined;
1055
+ let versionCounted = false;
436
1056
  // The id and the repo are usually the same words, and requiring both is asking
437
1057
  // the same question twice. Either one alone answers for the other.
438
- const id = flag("id") ?? repo;
439
- const tag = flag("tag") ?? version;
440
1058
  const dryRun = rest.includes("--dry-run") || !repo;
1059
+ // A dry run publishes nothing, so the id only has to name the artifact it
1060
+ // writes — and refusing to build one because the folder has no git remote
1061
+ // yet blocked the exact thing a dry run is for: looking at the pack BEFORE
1062
+ // committing to a repository for it. The folder name is what the repository
1063
+ // would be called anyway, and `--id` or `--repo` overrides it the moment
1064
+ // either is known.
1065
+ //
1066
+ // `local/` rather than a guessed account: the owner is genuinely not known
1067
+ // yet, and a plausible-looking one would be baked into a manifest somebody
1068
+ // could upload by hand.
1069
+ const id =
1070
+ flag("id") ?? repo ?? (dryRun && entry ? `local/${basename(resolve(entry, ".."))}` : undefined);
1071
+ const [owner, name] = (repo ?? "/").split("/");
1072
+ if (repo && (!owner || !name || repo.split("/").length !== 2)) {
1073
+ return fail([`--repo must be <owner>/<repo>, got ${JSON.stringify(repo)}`]);
1074
+ }
441
1075
 
442
- if (!entry || !version || !id) {
1076
+ if (!entry || !id) {
443
1077
  return fail([
444
- "Usage: failproofai publish <entry.mjs> --repo <owner>/<repo> --version <version>",
1078
+ "Usage: failproofai publish <entry.mjs> [--repo <owner>/<repo>] [--version <v>]",
1079
+ "",
1080
+ "Inside a git checkout with a github remote, both are inferred — the repo",
1081
+ "from the remote, the version from a tag on HEAD or the short commit SHA.",
1082
+ "Outside one, name them:",
445
1083
  " [--id <publisher/name>] [--tag <tag>] [--notes <text>]",
446
1084
  " [--out <dir>] [--effect enforce|observe] [--dry-run]",
447
1085
  "",
@@ -449,26 +1087,108 @@ async function publish(rest: string[]): Promise<PackCliResult> {
449
1087
  ]);
450
1088
  }
451
1089
 
452
- // Build first, always. There is no point authenticating against GitHub to
453
- // discover the pack does not load.
454
- const built = await build([entry, "--id", id, "--version", version, ...outFlagFrom(rest), ...effectFlagFrom(rest)]);
1090
+ // Check an EXPLICIT tag against an explicit version before anything reaches
1091
+ // the network. When the version is counted the tag simply follows it and can
1092
+ // never disagree — but a tag the user typed can, and refusing it here is what
1093
+ // stops a doomed publish from first creating a repository for itself.
1094
+ const explicitTag = flag("tag");
1095
+ if (explicitTag && version && !packTagMatchesVersion(explicitTag, version)) {
1096
+ return fail([
1097
+ `Tag ${explicitTag} does not describe version ${version}, so nobody could install it.`,
1098
+ `A pack is fetched from releases/download/${explicitTag}/… and then reports`,
1099
+ `${version} — which names no release. Use --tag ${version} (a leading v is`,
1100
+ "fine), or build the pack at the version the tag says.",
1101
+ ]);
1102
+ }
1103
+
1104
+ // The version may still be unknown here: with nothing explicit and no tag, it
1105
+ // is one past whatever the repository has already published, which cannot be
1106
+ // known without asking it. So the credential and the repository come first —
1107
+ // the reverse of the old order, where building first avoided authenticating
1108
+ // for a pack that does not load. That trade is still paid: the build runs
1109
+ // immediately after, before anything is created or uploaded.
1110
+ let token: string | null = null;
1111
+ let created = false;
1112
+ let repoInfo: Awaited<ReturnType<typeof gh>> | null = null;
1113
+ if (!dryRun) {
1114
+ token = githubToken();
1115
+ if (!token) {
1116
+ return fail([
1117
+ "No GitHub credential found.",
1118
+ "Set GITHUB_TOKEN (or GH_TOKEN), or sign in once with `gh auth login`.",
1119
+ "It needs write access to releases on that repository, and nothing else.",
1120
+ ]);
1121
+ }
1122
+ const ensured = await ensureRepo(owner, name, id, token, resolve(entry, ".."));
1123
+ if ("error" in ensured) return fail(ensured.error);
1124
+ created = ensured.created;
1125
+ repoInfo = ensured.info;
1126
+ if (!version) {
1127
+ version = await nextVersion(owner, name, token);
1128
+ versionCounted = true;
1129
+ }
1130
+ }
1131
+ // A dry run has nothing to count against, so it reports the first version. It
1132
+ // says so rather than implying the number is settled.
1133
+ if (!version) {
1134
+ version = "1.0.0";
1135
+ versionCounted = true;
1136
+ }
1137
+
1138
+ const tag = flag("tag") ?? version;
1139
+
1140
+ // One artifact, however many files it was written across. `build` refuses an
1141
+ // entry with relative imports because only the entry is digest-pinned — so
1142
+ // the fix is to make it ONE file here, not to send the author away to set up
1143
+ // a bundler for a step this tool already performs for its own pack.
1144
+ const outDirEarly = resolve(flag("out") ?? "dist-pack");
1145
+ let entryToBuild = entry;
1146
+ const needsBundle =
1147
+ discovered.length > 1 ||
1148
+ /(?:^|\n)\s*(?:import|export)[^;\n]*from\s+["']\.[^"']*["']/.test(
1149
+ readFileSync(entry, "utf8"),
1150
+ );
1151
+ if (needsBundle) {
1152
+ const sources = discovered.length > 1 ? discovered : [entry];
1153
+ const bundled = bundleEntry(sources, outDirEarly);
1154
+ if ("error" in bundled) return fail(bundled.error);
1155
+ entryToBuild = bundled.path;
1156
+ }
1157
+
1158
+ const built = await build([entryToBuild, "--id", id, "--version", version, ...outFlagFrom(rest), ...effectFlagFrom(rest)]);
455
1159
  if (built.exitCode !== 0) return built;
456
1160
 
457
- const outDir = resolve(flag("out") ?? "dist-pack");
1161
+ const outDir = outDirEarly;
1162
+ const bundleNote =
1163
+ discovered.length > 1
1164
+ ? [`Bundled ${discovered.length} files into one artifact:`,
1165
+ ...discovered.map((f) => ` ${f.replace(process.cwd() + "/", "")}`), ""]
1166
+ : [];
458
1167
  if (dryRun) {
459
1168
  return ok([
1169
+ ...bundleNote,
460
1170
  ...built.lines.slice(0, 4),
461
1171
  "",
462
1172
  ...(repo
463
1173
  ? ["Dry run — nothing was published.", `Drop --dry-run to release it on ${repo}.`]
464
1174
  : [
465
1175
  "Nothing was published: name a repository to release it on.",
466
- ` failproofai publish ${entry} --repo <owner>/<repo> --version ${version}`,
1176
+ // Just the repository. It used to spell out the entry file and the
1177
+ // version too — naming ONE of the files it had that moment finished
1178
+ // bundling, and pinning a version it works out by counting the
1179
+ // repository's own releases. Both were wrong the moment they were
1180
+ // printed, and both taught the reader that publishing needs flags
1181
+ // it does not need.
1182
+ " failproofai publish --repo <owner>/<repo>",
467
1183
  ]),
468
1184
  ]);
469
1185
  }
470
1186
 
471
- if (!packTagMatchesVersion(tag!, version)) {
1187
+ // Narrowed once: every path reaching here passed through the `!dryRun` branch
1188
+ // above, which returns when there is no credential.
1189
+ const auth = token as string;
1190
+
1191
+ if (!packTagMatchesVersion(tag, version)) {
472
1192
  return fail([
473
1193
  `Tag ${tag} does not describe version ${version}, so nobody could install it.`,
474
1194
  `A pack is fetched from releases/download/${tag}/… and then reports ${version} —`,
@@ -477,41 +1197,20 @@ async function publish(rest: string[]): Promise<PackCliResult> {
477
1197
  ]);
478
1198
  }
479
1199
 
480
- const [owner, name] = repo!.split("/");
481
- if (!owner || !name || repo!.split("/").length !== 2) {
482
- return fail([`--repo must be <owner>/<repo>, got ${JSON.stringify(repo)}`]);
483
- }
484
1200
 
485
- const token = githubToken();
486
- if (!token) {
487
- return fail([
488
- "No GitHub credential found.",
489
- "Set GITHUB_TOKEN (or GH_TOKEN), or sign in once with `gh auth login`.",
490
- "It needs write access to releases on that repository, and nothing else.",
491
- ]);
492
- }
493
-
494
- const repoInfo = await gh(`${GITHUB_API}/repos/${owner}/${name}`, token);
495
- if (repoInfo.status === 404) {
496
- return fail([
497
- `${repo} does not exist, or that credential cannot see it.`,
498
- `Create it first: gh repo create ${repo} --public`,
499
- ]);
500
- }
501
- if (repoInfo.status >= 400) return fail([`Could not read ${repo}: ${ghError(repoInfo)}`]);
502
1201
  // A private repo is not a smaller audience, it is no audience: `pack add`
503
1202
  // sends no Authorization header at all, by design, so every install 404s.
504
- const isPrivate = repoInfo.json?.private === true;
1203
+ const isPrivate = repoInfo?.json?.private === true;
505
1204
 
506
1205
  // Reuse a release on this tag rather than failing on it. Re-publishing a
507
1206
  // corrected artifact under an existing tag is a normal thing to need, and the
508
1207
  // digest change is visible to anyone who reinstalls.
509
1208
  let releaseId: number | null = null;
510
- const existing = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/tags/${encodeURIComponent(tag!)}`, token);
1209
+ const existing = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/tags/${encodeURIComponent(tag)}`, auth);
511
1210
  if (existing.status === 200 && typeof existing.json?.id === "number") {
512
1211
  releaseId = existing.json.id;
513
1212
  } else {
514
- const created = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases`, token, {
1213
+ const created = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases`, auth, {
515
1214
  method: "POST",
516
1215
  contentType: "application/json",
517
1216
  body: JSON.stringify({
@@ -533,7 +1232,7 @@ async function publish(rest: string[]): Promise<PackCliResult> {
533
1232
 
534
1233
  const assets = [PACK_MANIFEST_ASSET, PACK_ENTRY_ASSET, PACK_CHECKSUMS_ASSET];
535
1234
  const uploaded: string[] = [];
536
- const listed = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/${releaseId}/assets?per_page=100`, token);
1235
+ const listed = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/${releaseId}/assets?per_page=100`, auth);
537
1236
  const already = Array.isArray(listed.json)
538
1237
  ? (listed.json as unknown as Array<{ id: number; name: string }>)
539
1238
  : [];
@@ -543,12 +1242,12 @@ async function publish(rest: string[]): Promise<PackCliResult> {
543
1242
  // append.
544
1243
  const prior = already.find((a) => a.name === asset);
545
1244
  if (prior) {
546
- await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/assets/${prior.id}`, token, { method: "DELETE" });
1245
+ await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/assets/${prior.id}`, auth, { method: "DELETE" });
547
1246
  }
548
1247
  const bytes = readFileSync(resolve(outDir, asset));
549
1248
  const upload = await gh(
550
1249
  `${GITHUB_UPLOADS}/repos/${owner}/${name}/releases/${releaseId}/assets?name=${encodeURIComponent(asset)}`,
551
- token,
1250
+ auth,
552
1251
  {
553
1252
  method: "POST",
554
1253
  contentType: asset.endsWith(".json") ? "application/json" : "application/octet-stream",
@@ -566,7 +1265,10 @@ async function publish(rest: string[]): Promise<PackCliResult> {
566
1265
  }
567
1266
 
568
1267
  const lines = [
569
- `Published ${id}@${version} to ${repo} at tag ${tag}.`,
1268
+ ...bundleNote,
1269
+ ...(created ? [`Created ${repo} (public).`] : []),
1270
+ `Published ${id}@${version} to ${repo} at tag ${tag}.` +
1271
+ (versionCounted ? " Next publish will be one past it." : ""),
570
1272
  ` ${assets.length} assets attached`,
571
1273
  "",
572
1274
  "Anyone can now install it:",
@@ -601,6 +1303,43 @@ export async function runPublishCommand(rest: string[]): Promise<PackCliResult>
601
1303
  return publish(rest);
602
1304
  }
603
1305
 
1306
+ /**
1307
+ * Which agents should this pack guard?
1308
+ *
1309
+ * Asked BEFORE the policy list, because it is the coarser question and the
1310
+ * answer changes what the second screen is for. Setup no longer asks it: hooks
1311
+ * go into every supported agent, since hooks alone enforce nothing, so the
1312
+ * decision moved here — where the user is looking at a concrete pack instead of
1313
+ * answering in the abstract before anything is installed.
1314
+ *
1315
+ * Every supported agent is offered and all are pre-ticked. Detected ones are
1316
+ * marked, but an undetected agent is NOT excluded: installing one next week is
1317
+ * exactly the case setup's install-everywhere behaviour exists to cover, and a
1318
+ * pack that silently skipped it would undo that.
1319
+ *
1320
+ * Returns null when cancelled — distinct from picking every agent.
1321
+ */
1322
+ async function pickClis(
1323
+ io: { stdin: TTYIn; stdout: TTYOut },
1324
+ ): Promise<string[] | null> {
1325
+ const detected = new Set(detectInstalledClis());
1326
+ const choices: MultiChoice<string>[] = INTEGRATION_TYPES.map((id) => ({
1327
+ label: id,
1328
+ value: id,
1329
+ checked: true,
1330
+ hint: detected.has(id) ? "installed here" : "",
1331
+ }));
1332
+ return multiSelect<string>({
1333
+ message: "Which agents should this pack guard?",
1334
+ choices,
1335
+ minSelected: 1,
1336
+ summaryNoun: "agents",
1337
+ hint: `space toggles · ctrl+a all · ↵ confirm · ${detected.size} detected on this machine`,
1338
+ stdin: io.stdin,
1339
+ stdout: io.stdout,
1340
+ });
1341
+ }
1342
+
604
1343
  /**
605
1344
  * The pack's own list, defaults pre-ticked, before anything is installed.
606
1345
  *
@@ -683,6 +1422,15 @@ async function add(rest: string[]): Promise<PackCliResult> {
683
1422
  const io = { stdin: process.stdin as TTYIn, stdout: process.stdout as TTYOut };
684
1423
  const chose = !selection.only && !selection.categories && !selection.all;
685
1424
  if (chose && io.stdin.isTTY && io.stdout.isTTY) {
1425
+ // Agents first, then policies. Coarse before fine, and the coarse answer is
1426
+ // the one somebody can give without reading thirty-eight descriptions.
1427
+ if (!selection.clis) {
1428
+ const agents = await pickClis(io);
1429
+ if (agents === null) return ok(["Nothing installed."]);
1430
+ // Every agent ticked is the same as not narrowing at all, and writing the
1431
+ // full list would freeze this pack out of any CLI supported later.
1432
+ if (agents.length < INTEGRATION_TYPES.length) selection.clis = agents;
1433
+ }
686
1434
  const picked = await pickFromSource(resolvedSource!, io);
687
1435
  if (picked === null) return ok(["Nothing installed."]);
688
1436
  selection.only = picked;