failproofai 1.0.2-beta.2 → 1.0.2-beta.4

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 (183) 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 +4 -5
  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.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  38. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +14 -14
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  45. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  46. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  47. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  48. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  52. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  53. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  56. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  60. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  65. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  66. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  67. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  68. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +3 -3
  69. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  70. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/chunks/{[externals]__0e32-7c._.js → [externals]__1qzqcio._.js} +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +3 -0
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +3 -0
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0clqfab._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +3 -0
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0igxt32._.js → [root-of-the-server]__1p8poxd._.js} +2 -2
  80. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +6 -5
  82. package/.next/standalone/.next/server/chunks/_1lbh81u._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  85. 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
  86. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  87. 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
  88. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +4 -4
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14muoc4._.js +3 -0
  100. package/.next/standalone/.next/server/chunks/ssr/{src_hooks_08o7bqo._.js → [root-of-the-server]__15r6r7w._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tag8_-._.js +5 -0
  103. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/{_1oggjel._.js → _0ovx4lz._.js} +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/_1dkh76z._.js +23 -0
  106. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
  114. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  115. package/.next/standalone/.next/server/middleware.js +1 -1
  116. package/.next/standalone/.next/server/pages/404.html +1 -1
  117. package/.next/standalone/.next/server/pages/500.html +1 -1
  118. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  120. package/.next/standalone/.next/static/chunks/{1yw71gn8vqco1.js → 00_t-xvc59tkb.js} +2 -2
  121. package/.next/standalone/.next/static/chunks/{3jjmdqqzem21x.js → 07j2u70_3qsx8.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{2fzxl7n717pbc.js → 0d_re32wscs0b.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/{0ykd_go4oahan.js → 0ds64uculrze8.js} +1 -1
  124. package/.next/standalone/.next/static/chunks/{0nnbtp0w4sxrr.js → 0k_yvjc1yz0pq.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/0lshnlpy1m6bc.js +1 -0
  126. package/.next/standalone/.next/static/chunks/{1da5a_a-3btas.js → 1o0ahew9568ls.js} +1 -1
  127. package/.next/standalone/.next/static/chunks/{1za08zjcctyil.js → 1oz0svxwt9574.js} +2 -2
  128. package/.next/standalone/.next/static/chunks/{29eh4k77otcw6.js → 1q76jk_1_wgjq.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/{2axi5ehjbld9h.js → 2817_8e94ah9z.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{1nzy5jc_vf9e0.js → 2dbi_x0c8n8fi.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{1fac8krph-zgp.js → 354-n7p7labpn.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/turbopack-17utl_jw0wj11.js +1 -0
  133. package/.next/standalone/SECURITY.md +18 -3
  134. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +7 -0
  135. package/.next/standalone/fp-cloud-cli/uv.lock +6 -6
  136. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  137. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  138. package/.next/standalone/node_modules/next/dist/client/components/segment-cache/cache.js +23 -34
  139. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
  140. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
  141. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +1 -1
  142. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  143. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  144. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  145. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  146. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  147. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  148. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  149. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  150. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  151. package/.next/standalone/node_modules/next/package.json +10 -10
  152. package/.next/standalone/package.json +19 -19
  153. package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
  154. package/.next/standalone/sdk/python/uv.lock +23 -23
  155. package/.next/standalone/server.js +1 -1
  156. package/bin/failproofai.mjs +131 -55
  157. package/dist/cli.mjs +12940 -12627
  158. package/dist/worker.mjs +11 -1
  159. package/package.json +19 -19
  160. package/src/audit/cache.ts +34 -29
  161. package/src/audit/replay.ts +15 -91
  162. package/src/hooks/configure-wizard.ts +16 -48
  163. package/src/hooks/handler.ts +8 -1
  164. package/src/hooks/manager.ts +24 -2
  165. package/src/hooks/pack-cli.ts +572 -43
  166. package/src/hooks/pack-manifest.ts +28 -0
  167. package/src/hooks/pack-store.ts +20 -28
  168. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +0 -3
  169. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +0 -3
  170. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +0 -3
  171. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +0 -3
  172. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +0 -3
  173. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +0 -3
  174. package/.next/standalone/.next/server/chunks/_185gyk9._.js +0 -3
  175. package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +0 -12
  176. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__090sxxw._.js +0 -3
  177. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +0 -5
  178. package/.next/standalone/.next/server/chunks/ssr/_0wb8osu._.js +0 -23
  179. package/.next/standalone/.next/static/chunks/140ip21rqmelt.js +0 -1
  180. package/.next/standalone/.next/static/chunks/turbopack-2vqpa-dts1gy4.js +0 -1
  181. /package/.next/standalone/.next/static/{A7czO1PZdTcNzLGa_H1sm → 0iNzkTsP6rh14Rj5WL17o}/_buildManifest.js +0 -0
  182. /package/.next/standalone/.next/static/{A7czO1PZdTcNzLGa_H1sm → 0iNzkTsP6rh14Rj5WL17o}/_clientMiddlewareManifest.js +0 -0
  183. /package/.next/standalone/.next/static/{A7czO1PZdTcNzLGa_H1sm → 0iNzkTsP6rh14Rj5WL17o}/_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 } from "node:os";
9
10
  import { createHash } from "node:crypto";
10
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
11
12
  import { 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,6 +345,393 @@ 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
+ async function scaffold(target: string | null): Promise<PackCliResult> {
358
+ // Ask what the pack is called when nothing was named and there is somebody to
359
+ // ask. The answer becomes the FILENAME and the header, so what lands on disk
360
+ // is already theirs rather than a file called `my-policies.mjs` that everybody
361
+ // then has to remember to rename. Skipped entirely for an explicit path or a
362
+ // non-TTY, where there is no question to ask and no one to answer it.
363
+ let chosen = target;
364
+ if (!chosen && process.stdin.isTTY && process.stdout.isTTY) {
365
+ const answer = await promptText({
366
+ message: "What is this pack called?",
367
+ hint: "used for the filename — letters, numbers, dashes",
368
+ defaultValue: "my-policies",
369
+ validate: (v) =>
370
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v.trim())
371
+ ? null
372
+ : "letters, numbers, dots, dashes and underscores",
373
+ stdin: process.stdin,
374
+ stdout: process.stdout,
375
+ });
376
+ if (answer === null) return ok(["Nothing written."]);
377
+ chosen = `./${answer.trim().replace(/\.(mjs|js|ts)$/, "")}.mjs`;
378
+ }
379
+ const path = resolve(chosen ?? "./my-policies.mjs");
380
+ if (existsSync(path)) {
381
+ return fail([`${path} already exists — name a different file, or edit that one.`]);
382
+ }
383
+ mkdirSync(resolve(path, ".."), { recursive: true });
384
+ writeFileSync(
385
+ path,
386
+ `import { customPolicies, allow, deny } from "failproofai";
387
+
388
+ // One file, no relative imports: only this entry is digest-pinned, so a pack
389
+ // that imported siblings could not honestly claim to be verified.
390
+
391
+ customPolicies.add({
392
+ name: "block-force-push",
393
+ description: "Block git push --force on any branch",
394
+ // Groups it in the picker, and is what --category selects on.
395
+ category: "Git",
396
+ // Whether it is ON for somebody who installs with no flags. Defaults to false.
397
+ defaultEnabled: true,
398
+ match: { events: ["PreToolUse"] },
399
+ fn: async (ctx) => {
400
+ if (ctx.toolName !== "Bash") return allow();
401
+ const cmd = String(ctx.toolInput?.command ?? "");
402
+ if (/\\bgit\\s+push\\b[^|;&]*\\s(-f|--force)\\b/.test(cmd)) {
403
+ return deny("Force-push rewrites history someone else may have pulled.");
404
+ }
405
+ return allow();
406
+ },
407
+ });
408
+ `,
409
+ "utf8",
410
+ );
411
+ return ok([
412
+ `Wrote ${path} — one policy, already working.`,
413
+ "",
414
+ "Try it on this machine before anyone else sees it:",
415
+ ` failproofai policies -i -c ${path}`,
416
+ " then ask your agent to force-push — it gets refused",
417
+ "",
418
+ "Then publish it:",
419
+ ` failproofai publish ${path} --repo <you>/<repo> --version 1.0.0`,
420
+ ]);
421
+ }
422
+
423
+ /**
424
+ * The GitHub repository this file lives in, from its own git remote.
425
+ *
426
+ * Typing `--repo you/guards` from inside the checkout of `you/guards` is asking
427
+ * somebody to restate what the directory already knows. Read from the ENTRY
428
+ * FILE's directory rather than the process cwd: publishing a policy that lives
429
+ * in another checkout is normal, and the answer must describe the file, not
430
+ * wherever the shell happens to be.
431
+ */
432
+ function inferRepo(entryPath: string): string | null {
433
+ try {
434
+ const url = execFileSync("git", ["remote", "get-url", "origin"], {
435
+ cwd: resolve(entryPath, ".."),
436
+ encoding: "utf8",
437
+ stdio: ["ignore", "pipe", "ignore"],
438
+ timeout: 5_000,
439
+ }).trim();
440
+ // Both spellings git hands out: scp-style and https.
441
+ const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/i);
442
+ return m ? `${m[1]}/${m[2]}` : null;
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * A tag on HEAD, when there is one and the file is clean.
450
+ *
451
+ * Somebody who tagged `v1.2.0` has SAID what this release is, which a counted
452
+ * number cannot. Absent a tag there is nothing here to infer — the version is
453
+ * counted from what the repository has already published instead.
454
+ */
455
+ function inferTaggedVersion(entryPath: string): string | null {
456
+ const cwd = resolve(entryPath, "..");
457
+ const git = (args: string[]): string | null => {
458
+ try {
459
+ return execFileSync("git", args, {
460
+ cwd,
461
+ encoding: "utf8",
462
+ stdio: ["ignore", "pipe", "ignore"],
463
+ timeout: 5_000,
464
+ }).trim();
465
+ } catch {
466
+ return null;
467
+ }
468
+ };
469
+ const tag = git(["describe", "--tags", "--exact-match", "HEAD"]);
470
+ if (!tag || !PACK_VERSION_RE.test(tag)) return null;
471
+ // A tag NAMES a commit, so shipping edited bytes under it publishes something
472
+ // that commit does not contain — and two artifacts would claim one version,
473
+ // which `id|version|sha256` compares in both the audit key and the pack
474
+ // upsert. A counted version names no commit and has no such problem, so this
475
+ // refusal belongs to the tagged path alone.
476
+ if (git(["status", "--porcelain", "--", entryPath])) return null;
477
+ return tag;
478
+ }
479
+
480
+ /**
481
+ * The policy file in this directory, when there is exactly one.
482
+ *
483
+ * Naming the path is restating something the directory already answers: a
484
+ * checkout with one policy file in it has no ambiguity to resolve. So the
485
+ * common case is `failproofai publish`, and the argument stays for the cases
486
+ * that genuinely need it.
487
+ *
488
+ * Identified by CONTENT, not by filename. A name convention would either miss
489
+ * `guards.mjs` or match an unrelated `policies.mjs` that configures something
490
+ * else entirely; a file that imports `failproofai` and calls
491
+ * `customPolicies.add` is a policy file whatever it is called.
492
+ *
493
+ * Returns ALL of them. Splitting policies across files is the normal thing to
494
+ * do past about three, and they are one pack — so several files is an answer,
495
+ * not an ambiguity, and they get bundled into the single artifact a pack must
496
+ * be. Naming a path explicitly still publishes exactly that file.
497
+ *
498
+ * Non-recursive on purpose. Walking the tree finds fixtures, examples and
499
+ * anything vendored, and publishing those is the failure this avoids.
500
+ */
501
+ function findEntry(dir: string): string[] {
502
+ let names: string[];
503
+ try {
504
+ names = readdirSync(dir);
505
+ } catch {
506
+ return [];
507
+ }
508
+ const found: string[] = [];
509
+ for (const name of names) {
510
+ if (!/\.(mjs|js|ts)$/.test(name)) continue;
511
+ const full = resolve(dir, name);
512
+ try {
513
+ // Policy entries are small; anything large is not one, and reading it
514
+ // would be the expensive half of this scan.
515
+ if (statSync(full).size > 512 * 1024) continue;
516
+ const text = readFileSync(full, "utf8");
517
+ if (/from\s+["']failproofai["']/.test(text) && /customPolicies\s*\.\s*add\s*\(/.test(text)) {
518
+ found.push(full);
519
+ }
520
+ } catch {
521
+ continue;
522
+ }
523
+ }
524
+ return found.sort();
525
+ }
526
+
527
+ /**
528
+ * The version after whatever this repository has already published.
529
+ *
530
+ * A commit SHA names exactly where bytes came from and tells a reader nothing
531
+ * else: it does not order, so `a1b2c3d` and `f9e8d7c` give no clue which came
532
+ * first, and nobody can say "I am on the older one". Counting instead — 1.0.0,
533
+ * then 1.0.1 — costs one API call and answers both.
534
+ *
535
+ * Reads the repository's own releases rather than anything local, because the
536
+ * releases ARE the published record: a machine that has never published from
537
+ * this checkout still computes the right next number, and two people publishing
538
+ * from different clones cannot both mint 1.0.1 without one of them seeing the
539
+ * other's release first.
540
+ *
541
+ * Non-semver tags are ignored rather than parsed heroically — a repo whose
542
+ * releases are named `nightly` has no sequence to continue, and starting a
543
+ * fresh count is more honest than inventing one.
544
+ */
545
+ async function nextVersion(owner: string, name: string, token: string): Promise<string> {
546
+ const res = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases?per_page=100`, token);
547
+ const tags = Array.isArray(res.json)
548
+ ? (res.json as unknown as Array<{ tag_name?: unknown }>)
549
+ .map((r) => (typeof r.tag_name === "string" ? r.tag_name : ""))
550
+ .filter(Boolean)
551
+ : [];
552
+ let best: [number, number, number] | null = null;
553
+ for (const tag of tags) {
554
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
555
+ if (!m) continue;
556
+ const parsed: [number, number, number] = [Number(m[1]), Number(m[2]), Number(m[3])];
557
+ if (
558
+ !best ||
559
+ parsed[0] > best[0] ||
560
+ (parsed[0] === best[0] && parsed[1] > best[1]) ||
561
+ (parsed[0] === best[0] && parsed[1] === best[1] && parsed[2] > best[2])
562
+ ) {
563
+ best = parsed;
564
+ }
565
+ }
566
+ return best ? `${best[0]}.${best[1]}.${best[2] + 1}` : "1.0.0";
567
+ }
568
+
569
+ /**
570
+ * The repository, created if it is not there.
571
+ *
572
+ * Creating it was the only step `publish` did not do, and the one that made
573
+ * "one command" untrue — it sent the publisher to a different tool and back.
574
+ *
575
+ * PUBLIC, always. A private repository publishes to nobody, because installs
576
+ * are anonymous HTTPS with no credential to offer, so creating one here would
577
+ * manufacture the exact dead end the caller then warns about. Somebody who
578
+ * wants a private pack makes it themselves and knows why.
579
+ */
580
+ async function ensureRepo(
581
+ owner: string,
582
+ name: string,
583
+ id: string,
584
+ token: string,
585
+ ): Promise<{ info: Awaited<ReturnType<typeof gh>>; created: boolean } | { error: string[] }> {
586
+ let info = await gh(`${GITHUB_API}/repos/${owner}/${name}`, token);
587
+ let created = false;
588
+ if (info.status === 404) {
589
+ // Personal and organisation repositories are different endpoints, and the
590
+ // only way to tell which applies is to ask who the token belongs to.
591
+ const me = await gh(`${GITHUB_API}/user`, token);
592
+ const login = typeof me.json?.login === "string" ? me.json.login : null;
593
+ const endpoint =
594
+ login && login.toLowerCase() === owner.toLowerCase()
595
+ ? `${GITHUB_API}/user/repos`
596
+ : `${GITHUB_API}/orgs/${owner}/repos`;
597
+ const made = await gh(endpoint, token, {
598
+ method: "POST",
599
+ contentType: "application/json",
600
+ body: JSON.stringify({
601
+ name,
602
+ private: false,
603
+ description: `failproofai policy pack — ${id}`,
604
+ auto_init: true,
605
+ }),
606
+ });
607
+ if (made.status >= 400) {
608
+ return {
609
+ error: [
610
+ `${owner}/${name} does not exist and could not be created: ${ghError(made)}`,
611
+ login
612
+ ? `The credential belongs to ${login}. Creating under a different owner needs`
613
+ : "The credential could not be identified. Creating a repository needs",
614
+ " rights to that account or organisation — or make it by hand:",
615
+ ` gh repo create ${owner}/${name} --public`,
616
+ ],
617
+ };
618
+ }
619
+ created = true;
620
+ info = await gh(`${GITHUB_API}/repos/${owner}/${name}`, token);
621
+ }
622
+ if (info.status >= 400) {
623
+ return { error: [`Could not read ${owner}/${name}: ${ghError(info)}`] };
624
+ }
625
+ return { info, created };
626
+ }
627
+
628
+ /**
629
+ * Where `bun` is, if it is anywhere.
630
+ *
631
+ * Bundling needs it, and a CLI installed from npm runs under node — so bun is
632
+ * likely present (it is in `engines`) but never guaranteed. Checked in the
633
+ * places it actually installs to, because `npm i -g bun` puts it in ONE nvm
634
+ * version's bin dir and `nvm use` then drops it off PATH.
635
+ */
636
+ function findBun(): string | null {
637
+ const candidates = [
638
+ "bun",
639
+ process.env.BUN_INSTALL ? resolve(process.env.BUN_INSTALL, "bin", "bun") : null,
640
+ resolve(homedir(), ".bun", "bin", "bun"),
641
+ "/opt/homebrew/bin/bun",
642
+ "/usr/local/bin/bun",
643
+ ].filter((c): c is string => c !== null);
644
+ for (const candidate of candidates) {
645
+ try {
646
+ execFileSync(candidate, ["--version"], { stdio: "ignore", timeout: 5_000 });
647
+ return candidate;
648
+ } catch {
649
+ continue;
650
+ }
651
+ }
652
+ return null;
653
+ }
654
+
655
+ /**
656
+ * Collapse several policy files, or one that imports its neighbours, into the
657
+ * single artifact a pack has to be.
658
+ *
659
+ * One entry file is a real constraint — only the entry is content-addressed, so
660
+ * a multi-file pack could not honestly claim to be digest-pinned — but that is a
661
+ * constraint on what gets PUBLISHED, not on how anybody writes. Splitting
662
+ * policies across files is the normal thing to do past about three of them, and
663
+ * telling someone to go and configure a bundler first is the tool refusing to do
664
+ * the one mechanical step it already does for its own pack.
665
+ *
666
+ * `--external failproofai` because the loader supplies it: bundling our own
667
+ * module into the artifact would ship a second copy of the registry, and
668
+ * policies would register into an object nothing reads.
669
+ */
670
+ function bundleEntry(
671
+ sources: string[],
672
+ outDir: string,
673
+ ): { path: string } | { error: string[] } {
674
+ const bun = findBun();
675
+ if (!bun) {
676
+ return {
677
+ error: [
678
+ sources.length > 1
679
+ ? `${sources.length} policy files here, and bundling them needs bun, which is not installed.`
680
+ : `${sources[0]} imports other files, and bundling needs bun, which is not installed.`,
681
+ "Either install bun (https://bun.sh), bundle it yourself first —",
682
+ " esbuild <entry> --bundle --format=esm --external:failproofai --outfile=pack.mjs",
683
+ "then publish the bundle — or name a single self-contained file.",
684
+ ],
685
+ };
686
+ }
687
+ mkdirSync(outDir, { recursive: true });
688
+ // A generated entry that imports each file for its SIDE EFFECT: every policy
689
+ // file registers by calling `customPolicies.add` at module scope, so importing
690
+ // it is what puts the policies in the registry. Same shape as the generated
691
+ // entry this repo builds its own pack from.
692
+ const entry = resolve(outDir, ".entry.generated.mjs");
693
+ writeFileSync(
694
+ entry,
695
+ sources.map((f) => `import ${JSON.stringify(f.startsWith("/") ? f : resolve(f))};`).join("\n") + "\n",
696
+ "utf8",
697
+ );
698
+ const bundled = resolve(outDir, ".bundled.mjs");
699
+ try {
700
+ execFileSync(
701
+ bun,
702
+ ["build", "--target=node", "--format=esm", "--external", "failproofai",
703
+ "--outfile", bundled, entry],
704
+ { stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 },
705
+ );
706
+ } catch (err) {
707
+ const detail = err instanceof Error && "stderr" in err
708
+ ? String((err as { stderr?: Buffer }).stderr ?? "").trim().split("\n").slice(0, 6)
709
+ : [];
710
+ return { error: [`Could not bundle those files:`, ...detail.map((l) => ` ${l}`)] };
711
+ }
712
+ return { path: bundled };
713
+ }
714
+
715
+ /**
716
+ * The entry path among publish's arguments, and nothing that merely follows a
717
+ * flag.
718
+ *
719
+ * `packAddSource` knows the flags `policies add` takes, not the ones `publish`
720
+ * takes — so `publish --id me/x --version 1.0.0` read `me/x` as the file to
721
+ * publish and failed on ENOENT. It only ever worked because every example wrote
722
+ * the path first.
723
+ */
724
+ const PUBLISH_VALUE_FLAGS = new Set([
725
+ "--repo", "--version", "--id", "--tag", "--notes", "--out", "--effect", "--entry", "--init",
726
+ ]);
727
+ function publishEntryArg(rest: string[]): string | undefined {
728
+ const consumed = new Set<number>();
729
+ for (let i = 0; i < rest.length; i += 1) {
730
+ if (PUBLISH_VALUE_FLAGS.has(rest[i])) consumed.add(i + 1);
731
+ }
732
+ return rest.find((a, i) => !a.startsWith("--") && !consumed.has(i));
733
+ }
734
+
336
735
  // ── publish ───────────────────────────────────────────────────────────────
337
736
 
338
737
  const GITHUB_API = process.env.FAILPROOFAI_GITHUB_API ?? "https://api.github.com";
@@ -430,18 +829,37 @@ async function publish(rest: string[]): Promise<PackCliResult> {
430
829
  return rest[i].includes("=") ? rest[i].split("=").slice(1).join("=") : rest[i + 1];
431
830
  };
432
831
 
433
- const repo = flag("repo");
434
- const version = flag("version");
435
- const entry = packAddSource(rest.filter((a) => a !== "--dry-run")) ?? flag("entry");
832
+ if (rest.includes("--init")) {
833
+ return scaffold(flag("init") ?? publishEntryArg(rest) ?? null);
834
+ }
835
+
836
+ let entry = publishEntryArg(rest.filter((a) => a !== "--dry-run")) ?? flag("entry");
837
+ // Every policy file here, bundled into one artifact below. Named explicitly,
838
+ // it is that file and only that file.
839
+ const discovered = entry ? [] : findEntry(process.cwd());
840
+ if (!entry && discovered.length > 0) entry = discovered[0];
841
+ const repo = flag("repo") ?? (entry ? inferRepo(entry) : null) ?? undefined;
842
+ // A tag on HEAD is somebody SAYING what this release is, so it wins over a
843
+ // counted one. Everything else is decided after the repository is known,
844
+ // because the count comes from what that repository has already published.
845
+ let version = flag("version") ?? (entry ? inferTaggedVersion(entry) : null) ?? undefined;
846
+ let versionCounted = false;
436
847
  // The id and the repo are usually the same words, and requiring both is asking
437
848
  // the same question twice. Either one alone answers for the other.
438
849
  const id = flag("id") ?? repo;
439
- const tag = flag("tag") ?? version;
440
850
  const dryRun = rest.includes("--dry-run") || !repo;
851
+ const [owner, name] = (repo ?? "/").split("/");
852
+ if (repo && (!owner || !name || repo.split("/").length !== 2)) {
853
+ return fail([`--repo must be <owner>/<repo>, got ${JSON.stringify(repo)}`]);
854
+ }
441
855
 
442
- if (!entry || !version || !id) {
856
+ if (!entry || !id) {
443
857
  return fail([
444
- "Usage: failproofai publish <entry.mjs> --repo <owner>/<repo> --version <version>",
858
+ "Usage: failproofai publish <entry.mjs> [--repo <owner>/<repo>] [--version <v>]",
859
+ "",
860
+ "Inside a git checkout with a github remote, both are inferred — the repo",
861
+ "from the remote, the version from a tag on HEAD or the short commit SHA.",
862
+ "Outside one, name them:",
445
863
  " [--id <publisher/name>] [--tag <tag>] [--notes <text>]",
446
864
  " [--out <dir>] [--effect enforce|observe] [--dry-run]",
447
865
  "",
@@ -449,14 +867,86 @@ async function publish(rest: string[]): Promise<PackCliResult> {
449
867
  ]);
450
868
  }
451
869
 
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)]);
870
+ // Check an EXPLICIT tag against an explicit version before anything reaches
871
+ // the network. When the version is counted the tag simply follows it and can
872
+ // never disagree but a tag the user typed can, and refusing it here is what
873
+ // stops a doomed publish from first creating a repository for itself.
874
+ const explicitTag = flag("tag");
875
+ if (explicitTag && version && !packTagMatchesVersion(explicitTag, version)) {
876
+ return fail([
877
+ `Tag ${explicitTag} does not describe version ${version}, so nobody could install it.`,
878
+ `A pack is fetched from releases/download/${explicitTag}/… and then reports`,
879
+ `${version} — which names no release. Use --tag ${version} (a leading v is`,
880
+ "fine), or build the pack at the version the tag says.",
881
+ ]);
882
+ }
883
+
884
+ // The version may still be unknown here: with nothing explicit and no tag, it
885
+ // is one past whatever the repository has already published, which cannot be
886
+ // known without asking it. So the credential and the repository come first —
887
+ // the reverse of the old order, where building first avoided authenticating
888
+ // for a pack that does not load. That trade is still paid: the build runs
889
+ // immediately after, before anything is created or uploaded.
890
+ let token: string | null = null;
891
+ let created = false;
892
+ let repoInfo: Awaited<ReturnType<typeof gh>> | null = null;
893
+ if (!dryRun) {
894
+ token = githubToken();
895
+ if (!token) {
896
+ return fail([
897
+ "No GitHub credential found.",
898
+ "Set GITHUB_TOKEN (or GH_TOKEN), or sign in once with `gh auth login`.",
899
+ "It needs write access to releases on that repository, and nothing else.",
900
+ ]);
901
+ }
902
+ const ensured = await ensureRepo(owner, name, id, token);
903
+ if ("error" in ensured) return fail(ensured.error);
904
+ created = ensured.created;
905
+ repoInfo = ensured.info;
906
+ if (!version) {
907
+ version = await nextVersion(owner, name, token);
908
+ versionCounted = true;
909
+ }
910
+ }
911
+ // A dry run has nothing to count against, so it reports the first version. It
912
+ // says so rather than implying the number is settled.
913
+ if (!version) {
914
+ version = "1.0.0";
915
+ versionCounted = true;
916
+ }
917
+
918
+ const tag = flag("tag") ?? version;
919
+
920
+ // One artifact, however many files it was written across. `build` refuses an
921
+ // entry with relative imports because only the entry is digest-pinned — so
922
+ // the fix is to make it ONE file here, not to send the author away to set up
923
+ // a bundler for a step this tool already performs for its own pack.
924
+ const outDirEarly = resolve(flag("out") ?? "dist-pack");
925
+ let entryToBuild = entry;
926
+ const needsBundle =
927
+ discovered.length > 1 ||
928
+ /(?:^|\n)\s*(?:import|export)[^;\n]*from\s+["']\.[^"']*["']/.test(
929
+ readFileSync(entry, "utf8"),
930
+ );
931
+ if (needsBundle) {
932
+ const sources = discovered.length > 1 ? discovered : [entry];
933
+ const bundled = bundleEntry(sources, outDirEarly);
934
+ if ("error" in bundled) return fail(bundled.error);
935
+ entryToBuild = bundled.path;
936
+ }
937
+
938
+ const built = await build([entryToBuild, "--id", id, "--version", version, ...outFlagFrom(rest), ...effectFlagFrom(rest)]);
455
939
  if (built.exitCode !== 0) return built;
456
940
 
457
- const outDir = resolve(flag("out") ?? "dist-pack");
941
+ const outDir = outDirEarly;
942
+ const bundleNote =
943
+ discovered.length > 1
944
+ ? [`Bundled ${discovered.length} files into one artifact:`,
945
+ ...discovered.map((f) => ` ${f.replace(process.cwd() + "/", "")}`), ""]
946
+ : [];
458
947
  if (dryRun) {
459
948
  return ok([
949
+ ...bundleNote,
460
950
  ...built.lines.slice(0, 4),
461
951
  "",
462
952
  ...(repo
@@ -468,7 +958,11 @@ async function publish(rest: string[]): Promise<PackCliResult> {
468
958
  ]);
469
959
  }
470
960
 
471
- if (!packTagMatchesVersion(tag!, version)) {
961
+ // Narrowed once: every path reaching here passed through the `!dryRun` branch
962
+ // above, which returns when there is no credential.
963
+ const auth = token as string;
964
+
965
+ if (!packTagMatchesVersion(tag, version)) {
472
966
  return fail([
473
967
  `Tag ${tag} does not describe version ${version}, so nobody could install it.`,
474
968
  `A pack is fetched from releases/download/${tag}/… and then reports ${version} —`,
@@ -477,41 +971,20 @@ async function publish(rest: string[]): Promise<PackCliResult> {
477
971
  ]);
478
972
  }
479
973
 
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
-
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
974
 
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
975
  // A private repo is not a smaller audience, it is no audience: `pack add`
503
976
  // sends no Authorization header at all, by design, so every install 404s.
504
- const isPrivate = repoInfo.json?.private === true;
977
+ const isPrivate = repoInfo?.json?.private === true;
505
978
 
506
979
  // Reuse a release on this tag rather than failing on it. Re-publishing a
507
980
  // corrected artifact under an existing tag is a normal thing to need, and the
508
981
  // digest change is visible to anyone who reinstalls.
509
982
  let releaseId: number | null = null;
510
- const existing = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/tags/${encodeURIComponent(tag!)}`, token);
983
+ const existing = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/tags/${encodeURIComponent(tag)}`, auth);
511
984
  if (existing.status === 200 && typeof existing.json?.id === "number") {
512
985
  releaseId = existing.json.id;
513
986
  } else {
514
- const created = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases`, token, {
987
+ const created = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases`, auth, {
515
988
  method: "POST",
516
989
  contentType: "application/json",
517
990
  body: JSON.stringify({
@@ -533,7 +1006,7 @@ async function publish(rest: string[]): Promise<PackCliResult> {
533
1006
 
534
1007
  const assets = [PACK_MANIFEST_ASSET, PACK_ENTRY_ASSET, PACK_CHECKSUMS_ASSET];
535
1008
  const uploaded: string[] = [];
536
- const listed = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/${releaseId}/assets?per_page=100`, token);
1009
+ const listed = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/${releaseId}/assets?per_page=100`, auth);
537
1010
  const already = Array.isArray(listed.json)
538
1011
  ? (listed.json as unknown as Array<{ id: number; name: string }>)
539
1012
  : [];
@@ -543,12 +1016,12 @@ async function publish(rest: string[]): Promise<PackCliResult> {
543
1016
  // append.
544
1017
  const prior = already.find((a) => a.name === asset);
545
1018
  if (prior) {
546
- await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/assets/${prior.id}`, token, { method: "DELETE" });
1019
+ await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/assets/${prior.id}`, auth, { method: "DELETE" });
547
1020
  }
548
1021
  const bytes = readFileSync(resolve(outDir, asset));
549
1022
  const upload = await gh(
550
1023
  `${GITHUB_UPLOADS}/repos/${owner}/${name}/releases/${releaseId}/assets?name=${encodeURIComponent(asset)}`,
551
- token,
1024
+ auth,
552
1025
  {
553
1026
  method: "POST",
554
1027
  contentType: asset.endsWith(".json") ? "application/json" : "application/octet-stream",
@@ -566,7 +1039,10 @@ async function publish(rest: string[]): Promise<PackCliResult> {
566
1039
  }
567
1040
 
568
1041
  const lines = [
569
- `Published ${id}@${version} to ${repo} at tag ${tag}.`,
1042
+ ...bundleNote,
1043
+ ...(created ? [`Created ${repo} (public).`] : []),
1044
+ `Published ${id}@${version} to ${repo} at tag ${tag}.` +
1045
+ (versionCounted ? " Next publish will be one past it." : ""),
570
1046
  ` ${assets.length} assets attached`,
571
1047
  "",
572
1048
  "Anyone can now install it:",
@@ -601,6 +1077,43 @@ export async function runPublishCommand(rest: string[]): Promise<PackCliResult>
601
1077
  return publish(rest);
602
1078
  }
603
1079
 
1080
+ /**
1081
+ * Which agents should this pack guard?
1082
+ *
1083
+ * Asked BEFORE the policy list, because it is the coarser question and the
1084
+ * answer changes what the second screen is for. Setup no longer asks it: hooks
1085
+ * go into every supported agent, since hooks alone enforce nothing, so the
1086
+ * decision moved here — where the user is looking at a concrete pack instead of
1087
+ * answering in the abstract before anything is installed.
1088
+ *
1089
+ * Every supported agent is offered and all are pre-ticked. Detected ones are
1090
+ * marked, but an undetected agent is NOT excluded: installing one next week is
1091
+ * exactly the case setup's install-everywhere behaviour exists to cover, and a
1092
+ * pack that silently skipped it would undo that.
1093
+ *
1094
+ * Returns null when cancelled — distinct from picking every agent.
1095
+ */
1096
+ async function pickClis(
1097
+ io: { stdin: TTYIn; stdout: TTYOut },
1098
+ ): Promise<string[] | null> {
1099
+ const detected = new Set(detectInstalledClis());
1100
+ const choices: MultiChoice<string>[] = INTEGRATION_TYPES.map((id) => ({
1101
+ label: id,
1102
+ value: id,
1103
+ checked: true,
1104
+ hint: detected.has(id) ? "installed here" : "",
1105
+ }));
1106
+ return multiSelect<string>({
1107
+ message: "Which agents should this pack guard?",
1108
+ choices,
1109
+ minSelected: 1,
1110
+ summaryNoun: "agents",
1111
+ hint: `space toggles · ctrl+a all · ↵ confirm · ${detected.size} detected on this machine`,
1112
+ stdin: io.stdin,
1113
+ stdout: io.stdout,
1114
+ });
1115
+ }
1116
+
604
1117
  /**
605
1118
  * The pack's own list, defaults pre-ticked, before anything is installed.
606
1119
  *
@@ -683,6 +1196,15 @@ async function add(rest: string[]): Promise<PackCliResult> {
683
1196
  const io = { stdin: process.stdin as TTYIn, stdout: process.stdout as TTYOut };
684
1197
  const chose = !selection.only && !selection.categories && !selection.all;
685
1198
  if (chose && io.stdin.isTTY && io.stdout.isTTY) {
1199
+ // Agents first, then policies. Coarse before fine, and the coarse answer is
1200
+ // the one somebody can give without reading thirty-eight descriptions.
1201
+ if (!selection.clis) {
1202
+ const agents = await pickClis(io);
1203
+ if (agents === null) return ok(["Nothing installed."]);
1204
+ // Every agent ticked is the same as not narrowing at all, and writing the
1205
+ // full list would freeze this pack out of any CLI supported later.
1206
+ if (agents.length < INTEGRATION_TYPES.length) selection.clis = agents;
1207
+ }
686
1208
  const picked = await pickFromSource(resolvedSource!, io);
687
1209
  if (picked === null) return ok(["Nothing installed."]);
688
1210
  selection.only = picked;
@@ -712,7 +1234,14 @@ async function add(rest: string[]): Promise<PackCliResult> {
712
1234
  carried: "your existing selection",
713
1235
  all: "everything in the pack",
714
1236
  }[result.selection];
715
- lines.push(` enabled (${result.enabled.length}/${result.available.length}, ${why}): ${summarise(result.enabled)}`);
1237
+ // `summarise([])` is the empty string, which would print a line ending in a
1238
+ // colon and nothing else — the one outcome that most needs saying out loud,
1239
+ // rendered as if something had gone missing.
1240
+ lines.push(
1241
+ result.enabled.length === 0
1242
+ ? ` enabled (0/${result.available.length}, ${why}): none — the pack is installed and enforcing nothing`
1243
+ : ` enabled (${result.enabled.length}/${result.available.length}, ${why}): ${summarise(result.enabled)}`,
1244
+ );
716
1245
 
717
1246
  if (skipped.length > 0) {
718
1247
  lines.push(` not enabled (${skipped.length}): ${summarise(skipped)}`);