failproofai 1.0.2-beta.7 → 1.0.2-beta.8

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 (116) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  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/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  30. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +15 -15
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  37. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  38. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  41. package/.next/standalone/.next/server/app/policies/page.js +5 -4
  42. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  55. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__1lt47k4._.js} +1 -1
  58. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +3 -3
  61. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1jjjg6g._.js → [root-of-the-server]__1-wzs7f._.js} +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07gm3zl._.js → [root-of-the-server]__1n2ywfz._.js} +5 -19
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1opxw-7._.js → [root-of-the-server]__1wlvpe5._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/{_0_nsohj._.js → _0l7zv8b._.js} +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/{_1uvml5y._.js → _1h33neq._.js} +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +17 -0
  81. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +3 -0
  86. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  87. package/.next/standalone/.next/server/middleware.js +1 -1
  88. package/.next/standalone/.next/server/pages/404.html +1 -1
  89. package/.next/standalone/.next/server/pages/500.html +1 -1
  90. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  91. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  92. package/.next/standalone/.next/static/chunks/{3swqstog1cd9u.js → 0i3a6wf-f_yky.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{3vqddiy9tvlwf.js → 0u2ojcw25c_np.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{0icodhlwr9x_e.js → 11a_m8yksthtf.js} +2 -2
  95. package/.next/standalone/.next/static/chunks/{2-9lvhuto1iwo.js → 1ch5l9t2p16y4.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{3jt6n16p2qpex.js → 1n04wzsaes73g.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{44oamw33aso19.js → 2iryw37qfqy5y.js} +1 -1
  98. package/.next/standalone/.next/static/chunks/{1d7o3wnt3h6se.js → 34j7nmidlcuni.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{1d6c85605lsgp.js → 3927pu4ss_l4k.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0tvmd0p_1i_g6.js → 3r0udrtpn78-e.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{1mfzsja6ftim5.css → 40nr6ehj4p3cw.css} +1 -1
  102. package/.next/standalone/.next/static/chunks/{1vzyn9h2kvj-i.js → 416zumre3fvfw.js} +1 -1
  103. package/.next/standalone/package.json +9 -9
  104. package/.next/standalone/server.js +1 -1
  105. package/bin/failproofai.mjs +47 -12
  106. package/dist/cli.mjs +6070 -4131
  107. package/dist/worker.mjs +6 -3
  108. package/package.json +9 -9
  109. package/scripts/build-policy-pack.mjs +28 -4
  110. package/src/hooks/pack-cli.ts +827 -97
  111. package/src/hooks/pack-manifest.ts +34 -1
  112. package/src/hooks/pack-store.ts +208 -16
  113. package/src/hooks/tui.ts +21 -1
  114. /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → NgD5HvuyPxwgrpeIiafJL}/_buildManifest.js +0 -0
  115. /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → NgD5HvuyPxwgrpeIiafJL}/_clientMiddlewareManifest.js +0 -0
  116. /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → NgD5HvuyPxwgrpeIiafJL}/_ssgManifest.js +0 -0
@@ -9,9 +9,9 @@ import { execFileSync } from "node:child_process";
9
9
  import { homedir, tmpdir } from "node:os";
10
10
  import { createHash } from "node:crypto";
11
11
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
12
- import { basename, dirname, join, resolve } from "node:path";
12
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
13
  import { INTEGRATION_TYPES } from "./types";
14
- import { PACK_VERSION_RE } from "./pack-manifest";
14
+ import { PACK_COMMIT_RE, PACK_VERSION_RE } from "./pack-manifest";
15
15
  import { detectInstalledClis } from "./integrations";
16
16
  import { parsePackIdentity, parsePackPolicy, readInstalledPacks } from "./pack-manifest";
17
17
  import {
@@ -22,6 +22,7 @@ import {
22
22
  checkPackArtifact,
23
23
  fetchPackPreview,
24
24
  packTagMatchesVersion,
25
+ parsePackSpec,
25
26
  removePack,
26
27
  setPackPolicyEnabled,
27
28
  slugifyCategory,
@@ -48,9 +49,27 @@ import {
48
49
  export interface PackCliResult {
49
50
  lines: string[];
50
51
  exitCode: number;
52
+ /**
53
+ * What a successful `build` produced, for a caller that has to describe it
54
+ * somewhere other than the terminal — today only `publish`, writing the
55
+ * release body. Carried on the RESULT rather than recomputed by the caller,
56
+ * because recomputing means re-deriving `defaultEnabled` from a second copy
57
+ * of the same rule, and the two copies drift.
58
+ */
59
+ meta?: PackBuildMeta;
51
60
  }
52
61
 
53
- const ok = (lines: string[]): PackCliResult => ({ lines, exitCode: 0 });
62
+ export interface PackBuildMeta {
63
+ policies: number;
64
+ defaultOn: number;
65
+ commit?: string;
66
+ }
67
+
68
+ const ok = (lines: string[], meta?: PackBuildMeta): PackCliResult => ({
69
+ lines,
70
+ exitCode: 0,
71
+ ...(meta ? { meta } : {}),
72
+ });
54
73
  const fail = (lines: string[]): PackCliResult => ({ lines, exitCode: 1 });
55
74
 
56
75
  function parseList(rest: string[], flag: string): string[] | undefined {
@@ -195,6 +214,7 @@ function selectionFrom(rest: string[]): {
195
214
  categories?: string[];
196
215
  all?: boolean;
197
216
  clis?: string[];
217
+ merge?: boolean;
198
218
  /** Present when the selection itself is unusable — see `parseCliList`. */
199
219
  error?: string[];
200
220
  } {
@@ -209,6 +229,11 @@ function selectionFrom(rest: string[]): {
209
229
  ...(categories ? { categories } : {}),
210
230
  ...(parsedClis.clis ? { clis: parsedClis.clis } : {}),
211
231
  ...(rest.includes("--all") ? { all: true } : {}),
232
+ // A FLAG adds. The command's first word is `add`, and on an
233
+ // already-installed pack `--category Git` means "also turn Git on" rather
234
+ // than "make Git the only thing on". The interactive picker overrides this
235
+ // back to false, because its list is the complete answer.
236
+ merge: true,
212
237
  };
213
238
  }
214
239
 
@@ -249,6 +274,7 @@ async function build(rest: string[]): Promise<PackCliResult> {
249
274
  const entry = packAddSource(rest) ?? flag("entry");
250
275
  const id = flag("id");
251
276
  const version = flag("version");
277
+ const commit = flag("commit");
252
278
  const effect = flag("effect") ?? "enforce";
253
279
  const outDir = resolve(flag("out") ?? "dist-pack");
254
280
 
@@ -258,9 +284,9 @@ async function build(rest: string[]): Promise<PackCliResult> {
258
284
  " [--out <dir>] [--effect enforce|observe]",
259
285
  ]);
260
286
  }
261
- let identity: { id: string; version: string; effect: PolicyEffect };
287
+ let identity: { id: string; version: string; effect: PolicyEffect; commit?: string };
262
288
  try {
263
- identity = parsePackIdentity({ id, version, effect });
289
+ identity = parsePackIdentity({ id, version, effect, commit });
264
290
  } catch (err) {
265
291
  return fail([err instanceof Error ? err.message : String(err)]);
266
292
  }
@@ -338,9 +364,19 @@ async function build(rest: string[]): Promise<PackCliResult> {
338
364
  }
339
365
  }
340
366
 
367
+ // `commit` is omitted entirely rather than written as null when there is none.
368
+ // The manifest is hashed and the hash is the pin, so every byte in here is
369
+ // part of what a machine verifies — a field carrying "there was nothing to
370
+ // say" earns none of that cost. Readers already treat absence as ordinary.
341
371
  const manifest =
342
372
  JSON.stringify(
343
- { id: identity.id, version: identity.version, effect: identity.effect, policies },
373
+ {
374
+ id: identity.id,
375
+ version: identity.version,
376
+ effect: identity.effect,
377
+ ...(identity.commit ? { commit: identity.commit } : {}),
378
+ policies,
379
+ },
344
380
  null,
345
381
  2,
346
382
  ) + "\n";
@@ -365,7 +401,50 @@ async function build(rest: string[]): Promise<PackCliResult> {
365
401
  "",
366
402
  `Publish: attach all three to a GitHub release tagged ${identity.version}, then anyone runs:`,
367
403
  ` failproofai policies add <owner>/<repo>`,
368
- ]);
404
+ ], { policies: policies.length, defaultOn: on, ...(identity.commit ? { commit: identity.commit } : {}) });
405
+ }
406
+
407
+ /**
408
+ * The release body, and the format `--releases` reads back out of it.
409
+ *
410
+ * Two audiences, one string. A person opening the releases page sees what the
411
+ * release contains; `policies show --releases` parses the same lines rather
412
+ * than downloading a manifest per release. Keep them parseable — the reader is
413
+ * `parseReleaseBody` directly below, and the two have to move together.
414
+ *
415
+ * Nothing here is TRUSTED. It is publisher-controlled text on somebody else's
416
+ * repository, so it decides what a listing displays and never what a machine
417
+ * installs: the manifest inside the digest-pinned assets remains the only
418
+ * source for that.
419
+ */
420
+ export function releaseBody(id: string, version: string, meta?: PackBuildMeta): string {
421
+ const lines = [`${id}@${version}`];
422
+ if (meta) {
423
+ lines.push("", `${meta.policies} policies, ${meta.defaultOn} on by default`);
424
+ if (meta.commit) lines.push(`commit ${meta.commit}`);
425
+ }
426
+ return lines.join("\n") + "\n";
427
+ }
428
+
429
+ export interface ReleaseBodyFacts {
430
+ policies?: number;
431
+ defaultOn?: number;
432
+ commit?: string;
433
+ }
434
+
435
+ /** Read back what {@link releaseBody} wrote. Absent facts stay absent — a
436
+ * release published before this format, or by hand, simply says less. */
437
+ export function parseReleaseBody(body: string | null | undefined): ReleaseBodyFacts {
438
+ const text = typeof body === "string" ? body : "";
439
+ const facts: ReleaseBodyFacts = {};
440
+ const counts = /(\d+)\s+policies,\s*(\d+)\s+on by default/.exec(text);
441
+ if (counts) {
442
+ facts.policies = Number(counts[1]);
443
+ facts.defaultOn = Number(counts[2]);
444
+ }
445
+ const commit = /(?:^|\n)\s*commit\s+([0-9a-f]{7,40})\b/i.exec(text);
446
+ if (commit) facts.commit = commit[1].toLowerCase();
447
+ return facts;
369
448
  }
370
449
 
371
450
  /**
@@ -513,7 +592,7 @@ async function scaffold(target: string | null): Promise<PackCliResult> {
513
592
  if (!chosen && process.stdin.isTTY && process.stdout.isTTY) {
514
593
  const answer = await promptText({
515
594
  message: "What is this pack called?",
516
- hint: "used for the filename — letters, numbers, dashes",
595
+ hint: "used for the filename",
517
596
  defaultValue: "my-policies",
518
597
  validate: (v) =>
519
598
  /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v.trim())
@@ -599,12 +678,107 @@ function inferRepo(entryPath: string): string | null {
599
678
  }
600
679
  }
601
680
 
681
+ /**
682
+ * The commit the policies being published are sitting at.
683
+ *
684
+ * This DECIDES the version and gates the publish — `versionForPublish` reads
685
+ * both fields. It used to be a label that did neither, which is why the reads
686
+ * below are stricter than they look. A pack published from a directory that is
687
+ * not a git checkout has no commit; absence is ordinary and returns null the
688
+ * same way `inferRepo` does, and the refusal is spelled out one caller up.
689
+ *
690
+ * Read from the ENTRY FILE's directory rather than the process cwd, for the
691
+ * reason `inferRepo` gives: a policy that lives in another checkout is normal,
692
+ * and the answer has to describe the file rather than wherever the shell is.
693
+ *
694
+ * `dirty` still travels beyond the version: with `--version` given, a dirty
695
+ * tree publishes anyway and the recorded `commit` is then an approximation.
696
+ *
697
+ * `outDir` is the directory the build is about to write into, and everything
698
+ * under it is left OUT of the dirty read — see {@link skipOutDir} for the
699
+ * self-inflicted refusal that costs.
700
+ */
701
+ function inferCommit(entryPath: string, outDir?: string): { sha: string; dirty: boolean } | null {
702
+ const cwd = resolve(entryPath, "..");
703
+ const git = (args: string[]): string | null => {
704
+ try {
705
+ return execFileSync("git", args, {
706
+ cwd,
707
+ encoding: "utf8",
708
+ stdio: ["ignore", "pipe", "ignore"],
709
+ timeout: 5_000,
710
+ }).trim();
711
+ } catch {
712
+ return null;
713
+ }
714
+ };
715
+ const sha = git(["rev-parse", "HEAD"]);
716
+ if (!sha || !/^[0-9a-f]{40}$/.test(sha)) return null;
717
+ // `git()` answers "" for a clean tree and null when it could not run the
718
+ // command at all, and the `Boolean()` this used to be read BOTH as clean.
719
+ // Harmless while `dirty` was a label; now that it decides the version, an
720
+ // unreadable status minted a commit-named version for bytes nobody had
721
+ // checked against that commit — the one claim this scheme exists to refuse.
722
+ // Reachable, not theoretical: `rev-parse` never touches the index, so a repo
723
+ // whose index is unreadable answers the first question and fails the second,
724
+ // and `status` walks the whole worktree so it is also the one that hits the
725
+ // 5s timeout. Unknown therefore falls on the dirty side: refusing a tree we
726
+ // cannot vouch for costs one `--version`, and the alternative is publishing
727
+ // one we could not read.
728
+ const status = git(["status", "--porcelain", ...skipOutDir(git, entryPath, outDir)]);
729
+ return { sha, dirty: status !== "" };
730
+ }
731
+
732
+ /**
733
+ * The build output directory, spelled as a pathspec `git status` skips.
734
+ *
735
+ * `publish` writes its three assets to `dist-pack` under the cwd unless told
736
+ * otherwise, so the documented `cd my-policies && failproofai publish` leaves
737
+ * untracked build output INSIDE the checkout it just read. Without this, the
738
+ * next publish of an unchanged, fully committed tree reads that output as
739
+ * uncommitted changes and refuses — the command breaking its own second run,
740
+ * over a directory it wrote itself, with a remedy (`git add -A`) that commits
741
+ * build output into the pack repository.
742
+ *
743
+ * Narrow on purpose, because hiding a change from this read is hiding the one
744
+ * thing the commit version claims. Nothing is skipped when the output lands
745
+ * outside the repository, which cannot dirty it anyway, and nothing is skipped
746
+ * when the output directory CONTAINS the entry file — `--out .` in a folder of
747
+ * policies is a directory full of source, where skipping it would conceal
748
+ * exactly what the check exists to catch.
749
+ */
750
+ function skipOutDir(
751
+ git: (args: string[]) => string | null,
752
+ entryPath: string,
753
+ outDir: string | undefined,
754
+ ): string[] {
755
+ if (!outDir) return [];
756
+ const out = resolve(outDir);
757
+ if (resolve(entryPath).startsWith(out + sep)) return [];
758
+ const top = git(["rev-parse", "--show-toplevel"]);
759
+ if (!top) return [];
760
+ const rel = relative(resolve(top), out);
761
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) return [];
762
+ // `top` on the exclusion is what makes it mean the directory it names: `rel`
763
+ // is measured from the repository root, while a pathspec is read relative to
764
+ // the directory git runs in — the ENTRY's folder. Without it, publishing
765
+ // `guards/policies.mjs` would exclude `guards/dist-pack` and leave the real
766
+ // `dist-pack/` in the read, so the self-inflicted refusal comes straight
767
+ // back for anyone whose policies sit in a subdirectory.
768
+ //
769
+ // The leading `:(top)` states the whole-repository scope rather than leaning
770
+ // on git's rule that a list of exclusions alone applies to all paths, and
771
+ // `literal` stops a directory named with a glob character from taking its
772
+ // neighbours out of the read with it.
773
+ return ["--", ":(top)", `:(exclude,literal,top)${rel.split(sep).join("/")}`];
774
+ }
775
+
602
776
  /**
603
777
  * A tag on HEAD, when there is one and the file is clean.
604
778
  *
605
- * Somebody who tagged `v1.2.0` has SAID what this release is, which a counted
606
- * number cannot. Absent a tag there is nothing here to infer the version is
607
- * counted from what the repository has already published instead.
779
+ * Somebody who tagged `v1.2.0` has SAID what this release is, which a derived
780
+ * name cannot so this wins over the commit version. Absent a tag there is
781
+ * nothing here to infer, and the commit the tree sits at is used instead.
608
782
  */
609
783
  function inferTaggedVersion(entryPath: string): string | null {
610
784
  const cwd = resolve(entryPath, "..");
@@ -625,9 +799,10 @@ function inferTaggedVersion(entryPath: string): string | null {
625
799
  // A tag NAMES a commit, so shipping edited bytes under it publishes something
626
800
  // that commit does not contain — and two artifacts would claim one version,
627
801
  // which `id|version|sha256` compares in both the audit key and the pack
628
- // upsert. A counted version names no commit and has no such problem, so this
629
- // refusal belongs to the tagged path alone.
630
- if (git(["status", "--porcelain", "--", entryPath])) return null;
802
+ // upsert. Compared against "" rather than tested for truthiness for the reason
803
+ // `inferCommit` gives: null is "could not read the tree", and reading that as
804
+ // clean is how a dirty tree slips out under a tag that does not describe it.
805
+ if (git(["status", "--porcelain", "--", entryPath]) !== "") return null;
631
806
  return tag;
632
807
  }
633
808
 
@@ -679,45 +854,311 @@ function findEntry(dir: string): string[] {
679
854
  }
680
855
 
681
856
  /**
682
- * The version after whatever this repository has already published.
683
- *
684
- * A commit SHA names exactly where bytes came from and tells a reader nothing
685
- * else: it does not order, so `a1b2c3d` and `f9e8d7c` give no clue which came
686
- * first, and nobody can say "I am on the older one". Counting instead 1.0.0,
687
- * then 1.0.1 costs one API call and answers both.
688
- *
689
- * Reads the repository's own releases rather than anything local, because the
690
- * releases ARE the published record: a machine that has never published from
691
- * this checkout still computes the right next number, and two people publishing
692
- * from different clones cannot both mint 1.0.1 without one of them seeing the
693
- * other's release first.
694
- *
695
- * Non-semver tags are ignored rather than parsed heroically a repo whose
696
- * releases are named `nightly` has no sequence to continue, and starting a
697
- * fresh count is more honest than inventing one.
857
+ * Make the git claim TRUE instead of refusing it, where doing that is safe.
858
+ *
859
+ * The version names a commit, so publishing needs one — and the first version
860
+ * of this simply refused when there was not one, handing the user two commands
861
+ * to run and asking them to start again. That is a burden the tool can carry:
862
+ * it already knows the directory, and it already knows which files it is about
863
+ * to bundle.
864
+ *
865
+ * Two situations, and they are NOT the same risk, which is why only one of them
866
+ * is fully automatic:
867
+ *
868
+ * - **No checkout at all.** `git init` + commit everything. There is no history
869
+ * to disturb, no branch to confuse and no unrelated work to sweep up: the
870
+ * directory is inert until this runs. Safe. An initialised checkout that has
871
+ * never been committed to reaches this case too `rev-parse HEAD` answers
872
+ * nothing for an unborn HEAD — and it is safe on the SAME terms only at that
873
+ * checkout's root. Below the root the directory is not inert at all, so that
874
+ * is refused rather than settled; see the branch itself for what it cost.
875
+ *
876
+ *
877
+ * - **A checkout with uncommitted changes.** `git add -A` here is NOT safe. It
878
+ * sweeps up whatever else is in the tree — a half-finished edit in a sibling
879
+ * file, a scratch `.env`, a debugging change nobody had decided on — and
880
+ * "publish committed my unrelated work" is a far worse surprise than being
881
+ * asked to commit. So only the POLICY FILES are committed, the ones publish
882
+ * found and is about to bundle, and anything else dirty stops the run with
883
+ * those files named. Committing the artifact's own inputs is defensible;
884
+ * committing the rest of somebody's desk is not.
885
+ *
886
+ * TTY only. In CI a commit made here exists on the runner and nowhere else, so
887
+ * the version would name provenance nobody can resolve — `--version` is the
888
+ * answer there, and the refusal says so.
889
+ *
890
+ * Returns null when there is nothing to do or nothing safe to do, leaving
891
+ * {@link versionForPublish} to refuse with its own message.
698
892
  */
699
- async function nextVersion(owner: string, name: string, token: string): Promise<string> {
700
- const res = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases?per_page=100`, token);
701
- const tags = Array.isArray(res.json)
702
- ? (res.json as unknown as Array<{ tag_name?: unknown }>)
703
- .map((r) => (typeof r.tag_name === "string" ? r.tag_name : ""))
704
- .filter(Boolean)
705
- : [];
706
- let best: [number, number, number] | null = null;
707
- for (const tag of tags) {
708
- const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
709
- if (!m) continue;
710
- const parsed: [number, number, number] = [Number(m[1]), Number(m[2]), Number(m[3])];
711
- if (
712
- !best ||
713
- parsed[0] > best[0] ||
714
- (parsed[0] === best[0] && parsed[1] > best[1]) ||
715
- (parsed[0] === best[0] && parsed[1] === best[1] && parsed[2] > best[2])
716
- ) {
717
- best = parsed;
893
+ function settleGitState(
894
+ entryPath: string,
895
+ provenance: { sha: string; dirty: boolean } | null,
896
+ packFiles: string[],
897
+ id: string,
898
+ outDir: string | undefined,
899
+ ): { provenance: { sha: string; dirty: boolean }; lines: string[] } | { error: string[] } | null {
900
+ const cwd = resolve(entryPath, "..");
901
+ const raw = (args: string[]): string | null => {
902
+ try {
903
+ return execFileSync("git", args, {
904
+ cwd,
905
+ encoding: "utf8",
906
+ stdio: ["ignore", "pipe", "pipe"],
907
+ timeout: 30_000,
908
+ });
909
+ } catch {
910
+ return null;
911
+ }
912
+ };
913
+ const git = (args: string[]): string | null => {
914
+ const out = raw(args);
915
+ return out === null ? null : out.trim();
916
+ };
917
+ // A commit needs an identity, and a machine that has never configured one
918
+ // fails INSIDE `git commit` with a wall of advice about --global. Checked
919
+ // first so the refusal names the two commands rather than quoting git at
920
+ // somebody who did not run git.
921
+ const identity = (): string[] | null => {
922
+ if (git(["config", "user.email"]) && git(["config", "user.name"])) return null;
923
+ return [
924
+ "git has no name and email configured on this machine, so nothing can be committed.",
925
+ ' git config --global user.name "You"',
926
+ ' git config --global user.email "you@example.com"',
927
+ "Then publish again, or name the version yourself: --version <version>.",
928
+ ];
929
+ };
930
+
931
+ if (provenance === null) {
932
+ const problem = identity();
933
+ if (problem) return { error: problem };
934
+ // No commit is not the same fact as no repository. `inferCommit` reads
935
+ // `rev-parse HEAD`, and a checkout that has been initialised but never
936
+ // committed to answers nothing — so this branch is also where an UNBORN
937
+ // HEAD lands, and `git init` there is being run inside somebody's existing
938
+ // work tree.
939
+ //
940
+ // `--show-prefix` is git's own answer to "where am I in this work tree":
941
+ // null outside one, "" at its root, `policies/` below it. Asked of git
942
+ // rather than compared as paths because `cwd` is built from the entry
943
+ // argument and can carry symlinks that `--show-toplevel` resolves away,
944
+ // and a spelling difference would refuse a publish that is perfectly fine.
945
+ const prefix = git(["rev-parse", "--show-prefix"]);
946
+ if (prefix !== null && prefix !== "") {
947
+ // `git init` here created a NESTED repository inside the parent's work
948
+ // tree and committed into that. The pack was then versioned by a commit
949
+ // living in a repository the author will never push — the parent shows
950
+ // only an untracked `policies/` — so the version named provenance nobody
951
+ // could resolve, which is the one thing this whole path exists to avoid.
952
+ // Not settled automatically either: everything in an unborn checkout is
953
+ // untracked, so the only add that would work is one sweeping the parent's
954
+ // whole work tree, which is exactly the surprise the dirty branch below
955
+ // refuses to hand anyone.
956
+ return { error: [
957
+ "The version names a commit, and this folder is inside a git checkout that has no commits yet.",
958
+ ` ${prefix.replace(/\/$/, "")} — inside a repository whose first commit has not been made`,
959
+ "Make that first commit yourself, then publish again —",
960
+ "or name the version yourself: --version <version>.",
961
+ ] };
718
962
  }
963
+ // Only when there is genuinely no repository. Re-initialising one that
964
+ // exists is a no-op git tolerates, but the line reported below would then
965
+ // claim to have started something that was already there.
966
+ if (prefix === null && git(["init", "-q"]) === null) return null;
967
+ // `-A` minus the build output. A first commit that swept in `dist-pack/`
968
+ // would put the artifact inside the very commit it is supposed to name.
969
+ //
970
+ // No `--` of its own: `skipOutDir` returns one ALREADY, along with the
971
+ // `:(top)` scope it needs. Adding a second separator made git read it as a
972
+ // literal filename, the add failed, and the commit then failed on an empty
973
+ // index — reported as "could not make the first commit", which is true and
974
+ // says nothing about why.
975
+ git(["add", "-A", ...skipOutDir(git, entryPath, outDir)]);
976
+ // Counted from the INDEX, not from the policy files. The first commit takes
977
+ // everything here — a README, a .gitignore, whatever else the author has
978
+ // beside their policies — and reporting the number of policy files instead
979
+ // understated what had just been committed on their behalf.
980
+ const staged = (git(["diff", "--cached", "--name-only"]) ?? "").split("\n").filter(Boolean).length;
981
+ if (git(["commit", "-q", "-m", `publish ${id}`]) === null) {
982
+ return { error: [
983
+ `Could not make the first commit in ${cwd}.`,
984
+ "Commit by hand, or name the version yourself: --version <version>.",
985
+ ] };
986
+ }
987
+ const made = inferCommit(entryPath, outDir);
988
+ if (!made) return null;
989
+ const files = `${staged} file${staged === 1 ? "" : "s"}`;
990
+ return {
991
+ provenance: made,
992
+ lines: [
993
+ prefix === null
994
+ ? `Started a git repository here and committed ${files}.`
995
+ : `Made this repository's first commit — ${files}.`,
996
+ ],
997
+ };
998
+ }
999
+
1000
+ if (!provenance.dirty) return null;
1001
+
1002
+ // Which paths are actually dirty, as git sees them — relative to the REPO
1003
+ // ROOT, not to the entry's directory, which are different whenever the
1004
+ // policies live in a subdirectory.
1005
+ const root = git(["rev-parse", "--show-toplevel"]);
1006
+ // The SAME exclusion `inferCommit` applied when it decided the tree was
1007
+ // dirty. Without it this would try to commit the assets the last run wrote —
1008
+ // which are gitignored in every pack this tool scaffolds, and are the build
1009
+ // output either way.
1010
+ // UNTRIMMED. A porcelain line is `XY <path>`, and for an unstaged edit the X
1011
+ // column is a SPACE — so trimming the output eats the first line's leading
1012
+ // space, and `slice(3)` then eats the first character of its path too. It
1013
+ // reported `ests.mjs`, decided that was not one of the policy files, and
1014
+ // refused the publish over a file that did not exist. Only the first line is
1015
+ // affected, which is exactly the kind of wrongness that survives a casual
1016
+ // test with two dirty files in it.
1017
+ //
1018
+ // `--untracked-files=all` because the default COLLAPSES a wholly untracked
1019
+ // directory into one line naming the directory: a new `policies/` folder
1020
+ // inside an existing checkout reads as `?? policies/` and never mentions the
1021
+ // files in it. That path matches no policy file, so it landed in `foreign`
1022
+ // and publish refused a brand new folder of policies by naming the folder
1023
+ // itself as somebody else's work — the bootstrap case this whole function
1024
+ // exists for. Only reproducible below the repository root; an untracked file
1025
+ // AT the root is reported individually, which is why every fixture that
1026
+ // publishes from the root missed it. The exclusion still applies, so the
1027
+ // expansion does not drag the build output back into the read.
1028
+ const status = raw([
1029
+ "status",
1030
+ "--porcelain",
1031
+ "--untracked-files=all",
1032
+ ...skipOutDir(git, entryPath, outDir),
1033
+ ]);
1034
+ if (root === null || status === null) return null;
1035
+ const dirty = status
1036
+ .split("\n")
1037
+ .map((line) => line.slice(3).trim())
1038
+ // A rename reads `old -> new`; the new path is the one that exists.
1039
+ .map((path) => (path.includes(" -> ") ? path.split(" -> ")[1] : path))
1040
+ .map((path) => path.replace(/^"|"$/g, ""))
1041
+ .filter(Boolean)
1042
+ .map((path) => resolve(root, path));
1043
+ if (dirty.length === 0) return null;
1044
+
1045
+ const mine = new Set(packFiles.map((f) => resolve(f)));
1046
+ const foreign = dirty.filter((path) => !mine.has(path));
1047
+ if (foreign.length > 0) {
1048
+ return { error: [
1049
+ `The version names a commit, and this tree has uncommitted changes outside the policy files.`,
1050
+ ...foreign.slice(0, 8).map((path) => ` ${relative(root, path)}`),
1051
+ ...(foreign.length > 8 ? [` …and ${foreign.length - 8} more`] : []),
1052
+ "Those are not this command's to commit. Commit or stash them, then publish again —",
1053
+ "or name the version yourself: --version <version>.",
1054
+ ] };
1055
+ }
1056
+
1057
+ const problem = identity();
1058
+ if (problem) return { error: problem };
1059
+ git(["add", "--", ...dirty]);
1060
+ if (git(["commit", "-q", "-m", `publish ${id}`]) === null) {
1061
+ return { error: [
1062
+ "Could not commit the policy files.",
1063
+ "Commit by hand, or name the version yourself: --version <version>.",
1064
+ ] };
719
1065
  }
720
- return best ? `${best[0]}.${best[1]}.${best[2] + 1}` : "1.0.0";
1066
+ const made = inferCommit(entryPath, outDir);
1067
+ if (!made || made.dirty) return null;
1068
+ return {
1069
+ provenance: made,
1070
+ lines: [`Committed ${dirty.length} changed policy file${dirty.length === 1 ? "" : "s"}.`],
1071
+ };
1072
+ }
1073
+
1074
+ /**
1075
+ * How many characters of a commit a version names.
1076
+ *
1077
+ * Twelve, not git's default seven. Seven collides in a repository with enough
1078
+ * objects — git itself lengthens the abbreviation as a repo grows — and a
1079
+ * version that stops being unique is worse than a long one, because two
1080
+ * artifacts would claim the same name. Twelve is short enough to read in a
1081
+ * listing and long enough that no real pack repository will reach it.
1082
+ *
1083
+ * The FULL sha is still recorded as `commit` in the manifest, so nothing is
1084
+ * lost by abbreviating the version.
1085
+ */
1086
+ export const VERSION_SHA_LENGTH = 12;
1087
+
1088
+ /** The version a commit names. */
1089
+ export function versionFromCommit(sha: string): string {
1090
+ return sha.trim().toLowerCase().slice(0, VERSION_SHA_LENGTH);
1091
+ }
1092
+
1093
+ /**
1094
+ * The version this publish should carry: the commit it was built from.
1095
+ *
1096
+ * A version answers ONE question here — which source produced these bytes —
1097
+ * and the commit answers it exactly, with nothing to decide and nothing to
1098
+ * count. Nothing is read from the repository's releases: the version is a
1099
+ * property of the tree in front of you, so a fresh clone, an air-gapped
1100
+ * machine and a second publisher all compute the same answer for the same
1101
+ * source, and none of them has to ask GitHub what happened before.
1102
+ *
1103
+ * The costs are real and are the reason this is an explicit choice rather than
1104
+ * a default. A sha does not ORDER — `a1b2c3d` and `f9e8d7c` give no clue which
1105
+ * came first — so "am I on the newest?" is a question only the release list can
1106
+ * answer. `policies show <source> --releases` is where it is answered, newest
1107
+ * first, which is why that surface exists.
1108
+ *
1109
+ * What it REFUSES rather than approximates, all for the same reason: the
1110
+ * version claims to name a commit, so it must not be minted where that claim
1111
+ * would be false.
1112
+ *
1113
+ * - **No git.** There is no commit to name. Publishing from a directory that
1114
+ * is not a checkout used to work; under this scheme it cannot, and saying so
1115
+ * beats inventing a number that names nothing.
1116
+ * - **A dirty tree.** The bytes being published are not the bytes in that
1117
+ * commit, so the version — and `commit` in the manifest beside it — would
1118
+ * both point at source that does not contain them.
1119
+ * - **A sha that is not one.** No caller in the publish path produces one, so
1120
+ * this is a guard rather than a case; see the comment on it below.
1121
+ *
1122
+ * `--version` overrides all of it, and every refusal names it for that reason.
1123
+ */
1124
+ export function versionForPublish(
1125
+ provenance: { sha: string; dirty: boolean } | null,
1126
+ ): { version: string } | { error: string[] } {
1127
+ if (!provenance) {
1128
+ return {
1129
+ error: [
1130
+ "This pack is versioned by the commit it is built from, and this directory is not a git checkout.",
1131
+ " git init && git add -A && git commit -m \"first policies\"",
1132
+ "Then publish again. To publish without git, name the version yourself: --version <version>.",
1133
+ ],
1134
+ };
1135
+ }
1136
+ if (provenance.dirty) {
1137
+ return {
1138
+ error: [
1139
+ `The version names commit ${versionFromCommit(provenance.sha)}, and this tree has uncommitted changes —`,
1140
+ "so the bytes about to be published are not the bytes in that commit.",
1141
+ " git add -A && git commit -m \"...\"",
1142
+ "Then publish again, or name the version yourself: --version <version>.",
1143
+ ],
1144
+ };
1145
+ }
1146
+ // Same refusal as the other two, from the other direction: a version that is
1147
+ // not an abbreviated sha names no commit either. `inferCommit` only ever hands
1148
+ // over forty lower-case hex, so nothing in the publish path reaches this —
1149
+ // which is exactly why it is worth stating. Truncating a non-sha silently
1150
+ // yields a `version` the manifest validator rejects much later, or an EMPTY
1151
+ // one, and the publish then fails somewhere that says nothing about the sha.
1152
+ const sha = provenance.sha.trim().toLowerCase();
1153
+ if (!PACK_COMMIT_RE.test(sha)) {
1154
+ return {
1155
+ error: [
1156
+ `The version names the commit it was built from, and ${JSON.stringify(provenance.sha.slice(0, 64))} is not one.`,
1157
+ "Name the version yourself instead: --version <version>.",
1158
+ ],
1159
+ };
1160
+ }
1161
+ return { version: versionFromCommit(sha) };
721
1162
  }
722
1163
 
723
1164
  /**
@@ -990,6 +1431,7 @@ function bundleEntry(
990
1431
  */
991
1432
  const PUBLISH_VALUE_FLAGS = new Set([
992
1433
  "--repo", "--version", "--id", "--tag", "--notes", "--out", "--effect", "--entry", "--init",
1434
+ "--commit",
993
1435
  ]);
994
1436
  function publishEntryArg(rest: string[]): string | undefined {
995
1437
  const consumed = new Set<number>();
@@ -1001,6 +1443,19 @@ function publishEntryArg(rest: string[]): string | undefined {
1001
1443
 
1002
1444
  // ── publish ───────────────────────────────────────────────────────────────
1003
1445
 
1446
+ /**
1447
+ * The three attachments a release needs before anybody can install it. One list,
1448
+ * because `publish` uploads exactly these and `--releases` marks a release
1449
+ * `incomplete` for missing any of them — two hand-written copies of the same
1450
+ * three names would let the listing call a release installable that the
1451
+ * installer then 404s on.
1452
+ */
1453
+ const INSTALLABLE_PACK_ASSETS: readonly string[] = [
1454
+ PACK_MANIFEST_ASSET,
1455
+ PACK_ENTRY_ASSET,
1456
+ PACK_CHECKSUMS_ASSET,
1457
+ ];
1458
+
1004
1459
  const GITHUB_API = process.env.FAILPROOFAI_GITHUB_API ?? "https://api.github.com";
1005
1460
  const GITHUB_UPLOADS = process.env.FAILPROOFAI_GITHUB_UPLOADS ?? "https://uploads.github.com";
1006
1461
  /** Where the git remote lives, so the push path can be tested against a real
@@ -1034,15 +1489,22 @@ function githubToken(): string | null {
1034
1489
  }
1035
1490
  }
1036
1491
 
1492
+ /**
1493
+ * `token` is nullable because ONE caller has no credential to offer: listing a
1494
+ * public repository's releases is a read anybody can do, and requiring
1495
+ * `gh auth login` to look at what a pack has published would be a worse answer
1496
+ * than the 60-per-hour anonymous rate limit. Every WRITE still passes a token —
1497
+ * they are unreachable without one, since `publish` fails before it gets here.
1498
+ */
1037
1499
  async function gh(
1038
1500
  url: string,
1039
- token: string,
1501
+ token: string | null,
1040
1502
  init: { method?: string; body?: BodyInit; contentType?: string } = {},
1041
1503
  ): Promise<{ status: number; json: Record<string, unknown> | null; text: string }> {
1042
1504
  const response = await fetch(url, {
1043
1505
  method: init.method ?? "GET",
1044
1506
  headers: {
1045
- Authorization: `Bearer ${token}`,
1507
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
1046
1508
  Accept: "application/vnd.github+json",
1047
1509
  "X-GitHub-Api-Version": "2022-11-28",
1048
1510
  "User-Agent": "failproofai",
@@ -1097,11 +1559,10 @@ function ghError(res: { status: number; json: Record<string, unknown> | null; te
1097
1559
  * demanded as flags.
1098
1560
  *
1099
1561
  * Everything else it already derives: the policy files by content, the version
1100
- * by counting the repository's own releases, the id from the repository, the
1101
- * credential from the environment. What is left is genuinely a question —
1102
- * WHERE this should live, when the folder has no git remote naming somewhere —
1103
- * and a confirmation, because publishing is public and a version number can
1104
- * never be reused.
1562
+ * from the commit the tree sits at, the id from the repository, the credential
1563
+ * from the environment. What is left is genuinely a question — WHERE this
1564
+ * should live, when the folder has no git remote naming somewhere — and a
1565
+ * confirmation, because publishing is public and cannot be taken back.
1105
1566
  *
1106
1567
  * Only ever on a TTY. A pipe, a CI job or a test gets the old behaviour: flags
1107
1568
  * decide, and a missing `--repo` still means "build the assets and stop"
@@ -1112,12 +1573,16 @@ async function askWhereToPublish(
1112
1573
  io: { stdin: TTYIn; stdout: TTYOut },
1113
1574
  ): Promise<string | null> {
1114
1575
  const answer = await promptText({
1115
- // The suggestion goes in the HINT, not just in `defaultValue`: the hint is
1116
- // what renders as the placeholder, while `defaultValue` is only applied on
1117
- // an empty submit and is never shown. Passing it as the default alone left
1118
- // the one keystroke that finishes this prompt return invisible.
1576
+ // The suggestion is NOT repeated in the hint. `promptText` renders a
1577
+ // `defaultValue` as `↵ <value>` ahead of whatever hint it is given, so
1578
+ // spelling it here too printed it twice and the half that says which key
1579
+ // takes it belongs to every prompt with a default, not to this one.
1119
1580
  message: "Where should this publish?",
1120
- hint: `${suggestion} · created if it does not exist`,
1581
+ // Kept short deliberately: `promptText` truncates to ONE physical row, and
1582
+ // the whole line is the message plus `↵ <owner>/<repo>` plus this. At 80
1583
+ // columns the longer spelling — "created if it does not exist" — pushed
1584
+ // itself off the end, so the fact was written and never read.
1585
+ hint: "created if missing",
1121
1586
  defaultValue: suggestion,
1122
1587
  validate: (v) => {
1123
1588
  const t = v.trim();
@@ -1188,11 +1653,11 @@ async function publish(rest: string[]): Promise<PackCliResult> {
1188
1653
  if (answered === null) return ok(["Nothing was published."]);
1189
1654
  repo = answered;
1190
1655
  }
1191
- // A tag on HEAD is somebody SAYING what this release is, so it wins over a
1192
- // counted one. Everything else is decided after the repository is known,
1193
- // because the count comes from what that repository has already published.
1656
+ // A tag on HEAD is somebody SAYING what this release is, so it wins over the
1657
+ // sha, which only reports one. Both are properties of the tree, so neither
1658
+ // waits on the repository being known see the block below the usage text.
1194
1659
  let version = flag("version") ?? (entry ? inferTaggedVersion(entry) : null) ?? undefined;
1195
- let versionCounted = false;
1660
+ let versionFromSha = false;
1196
1661
  // The id and the repo are usually the same words, and requiring both is asking
1197
1662
  // the same question twice. Either one alone answers for the other.
1198
1663
  const dryRun = rest.includes("--dry-run") || !repo;
@@ -1227,10 +1692,47 @@ async function publish(rest: string[]): Promise<PackCliResult> {
1227
1692
  ]);
1228
1693
  }
1229
1694
 
1230
- // Check an EXPLICIT tag against an explicit version before anything reaches
1231
- // the network. When the version is counted the tag simply follows it and can
1232
- // never disagree — but a tag the user typed can, and refusing it here is what
1233
- // stops a doomed publish from first creating a repository for itself.
1695
+ // The version is a property of the TREE, not of the repository's history, so
1696
+ // it is settled here before the credential, before the repo, and identically
1697
+ // for a dry run. Nothing is counted and nothing is asked of GitHub.
1698
+ //
1699
+ // Provenance is read ONCE, because it decides the version AND is recorded in
1700
+ // the manifest beside it. Reading it twice is how the two come to disagree.
1701
+ //
1702
+ // The output directory is settled first only so the dirty read can skip it:
1703
+ // it is where the LAST run's assets are sitting, and inside the checkout by
1704
+ // default. See `skipOutDir`.
1705
+ const outDirEarly = resolve(flag("out") ?? "dist-pack");
1706
+ let provenance = entry ? inferCommit(entry, outDirEarly) : null;
1707
+ const gitLines: string[] = [];
1708
+ if (!version && entry && interactive) {
1709
+ // Carry the git work rather than handing it back. TTY only: a commit made
1710
+ // in CI exists on the runner and nowhere else, so the version would name
1711
+ // provenance nobody can resolve, and `--version` is the answer there.
1712
+ const settled = settleGitState(
1713
+ entry,
1714
+ provenance,
1715
+ discovered.length > 0 ? discovered : [entry],
1716
+ id,
1717
+ outDirEarly,
1718
+ );
1719
+ if (settled && "error" in settled) return fail(settled.error);
1720
+ if (settled) {
1721
+ provenance = settled.provenance;
1722
+ gitLines.push(...settled.lines);
1723
+ }
1724
+ }
1725
+ if (!version) {
1726
+ const resolved = versionForPublish(provenance);
1727
+ if ("error" in resolved) return fail(resolved.error);
1728
+ version = resolved.version;
1729
+ versionFromSha = true;
1730
+ }
1731
+
1732
+ // Check an EXPLICIT tag against the version before anything reaches the
1733
+ // network. A tag the user typed can disagree with the version, and refusing
1734
+ // it here is what stops a doomed publish from first creating a repository
1735
+ // for itself.
1234
1736
  const explicitTag = flag("tag");
1235
1737
  if (explicitTag && version && !packTagMatchesVersion(explicitTag, version)) {
1236
1738
  return fail([
@@ -1241,12 +1743,9 @@ async function publish(rest: string[]): Promise<PackCliResult> {
1241
1743
  ]);
1242
1744
  }
1243
1745
 
1244
- // The version may still be unknown here: with nothing explicit and no tag, it
1245
- // is one past whatever the repository has already published, which cannot be
1246
- // known without asking it. So the credential and the repository come first —
1247
- // the reverse of the old order, where building first avoided authenticating
1248
- // for a pack that does not load. That trade is still paid: the build runs
1249
- // immediately after, before anything is created or uploaded.
1746
+ // The version is already known, so the credential and the repository are
1747
+ // needed only to publish. The build still runs immediately after, before
1748
+ // anything is created or uploaded.
1250
1749
  let token: string | null = null;
1251
1750
  let created = false;
1252
1751
  let repoInfo: Awaited<ReturnType<typeof gh>> | null = null;
@@ -1263,25 +1762,13 @@ async function publish(rest: string[]): Promise<PackCliResult> {
1263
1762
  if ("error" in ensured) return fail(ensured.error);
1264
1763
  created = ensured.created;
1265
1764
  repoInfo = ensured.info;
1266
- if (!version) {
1267
- version = await nextVersion(owner, name, token);
1268
- versionCounted = true;
1269
- }
1270
- }
1271
- // A dry run has nothing to count against, so it reports the first version. It
1272
- // says so rather than implying the number is settled.
1273
- if (!version) {
1274
- version = "1.0.0";
1275
- versionCounted = true;
1276
1765
  }
1277
-
1278
1766
  const tag = flag("tag") ?? version;
1279
1767
 
1280
1768
  // One artifact, however many files it was written across. `build` refuses an
1281
1769
  // entry with relative imports because only the entry is digest-pinned — so
1282
1770
  // the fix is to make it ONE file here, not to send the author away to set up
1283
1771
  // a bundler for a step this tool already performs for its own pack.
1284
- const outDirEarly = resolve(flag("out") ?? "dist-pack");
1285
1772
  let entryToBuild = entry;
1286
1773
  const needsBundle =
1287
1774
  discovered.length > 1 ||
@@ -1295,7 +1782,19 @@ async function publish(rest: string[]): Promise<PackCliResult> {
1295
1782
  entryToBuild = bundled.path;
1296
1783
  }
1297
1784
 
1298
- const built = await build([entryToBuild, "--id", id, "--version", version, ...outFlagFrom(rest), ...effectFlagFrom(rest)]);
1785
+ const built = await build([
1786
+ entryToBuild,
1787
+ "--id", id,
1788
+ "--version", version,
1789
+ // NOT when the tree is dirty, even though `--version` let the publish
1790
+ // through. `commit` claims these bytes came from that commit, and on a
1791
+ // dirty tree they did not — recording it anyway put the exact false claim
1792
+ // the dirty refusal exists to prevent through the door right next to it,
1793
+ // reachable by taking the escape hatch that refusal recommends.
1794
+ ...(provenance && !provenance.dirty ? ["--commit", provenance.sha] : []),
1795
+ ...outFlagFrom(rest),
1796
+ ...effectFlagFrom(rest),
1797
+ ]);
1299
1798
  if (built.exitCode !== 0) return built;
1300
1799
 
1301
1800
  const outDir = outDirEarly;
@@ -1306,6 +1805,8 @@ async function publish(rest: string[]): Promise<PackCliResult> {
1306
1805
  : [];
1307
1806
  if (dryRun) {
1308
1807
  return ok([
1808
+ ...gitLines,
1809
+ ...(gitLines.length ? [""] : []),
1309
1810
  ...bundleNote,
1310
1811
  ...built.lines.slice(0, 4),
1311
1812
  "",
@@ -1315,10 +1816,9 @@ async function publish(rest: string[]): Promise<PackCliResult> {
1315
1816
  "Nothing was published: name a repository to release it on.",
1316
1817
  // Just the repository. It used to spell out the entry file and the
1317
1818
  // version too — naming ONE of the files it had that moment finished
1318
- // bundling, and pinning a version it works out by counting the
1319
- // repository's own releases. Both were wrong the moment they were
1320
- // printed, and both taught the reader that publishing needs flags
1321
- // it does not need.
1819
+ // bundling, and pinning a version it works out for itself. Both
1820
+ // were wrong the moment they were printed, and both taught the
1821
+ // reader that publishing needs flags it does not need.
1322
1822
  " failproofai publish --repo <owner>/<repo>",
1323
1823
  ]),
1324
1824
  ]);
@@ -1356,7 +1856,15 @@ async function publish(rest: string[]): Promise<PackCliResult> {
1356
1856
  body: JSON.stringify({
1357
1857
  tag_name: tag,
1358
1858
  name: `${id} ${version}`,
1359
- body: flag("notes") ?? `${id}@${version}`,
1859
+ // The body is what `policies show --releases` reads. Putting the counts
1860
+ // and the commit HERE rather than in the manifest is what makes that
1861
+ // command ONE request: the release list returns bodies inline, while
1862
+ // the counts otherwise cost a manifest download per release and scale
1863
+ // with history. It doubles as what a human sees on the releases page,
1864
+ // which is the second reason to write it rather than a marker nobody
1865
+ // reads. `--notes` still wins outright — an author who wrote release
1866
+ // notes gets their release notes.
1867
+ body: flag("notes") ?? releaseBody(id, version, built.meta),
1360
1868
  // A prerelease is invisible to `releases/latest`, which is how a tagless
1361
1869
  // `policies add owner/repo` resolves a version — so publishing one would
1362
1870
  // make the pack installable only by people who already knew its tag.
@@ -1370,7 +1878,7 @@ async function publish(rest: string[]): Promise<PackCliResult> {
1370
1878
  releaseId = created.json.id;
1371
1879
  }
1372
1880
 
1373
- const assets = [PACK_MANIFEST_ASSET, PACK_ENTRY_ASSET, PACK_CHECKSUMS_ASSET];
1881
+ const assets = INSTALLABLE_PACK_ASSETS;
1374
1882
  const uploaded: string[] = [];
1375
1883
  const listed = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/${releaseId}/assets?per_page=100`, auth);
1376
1884
  const already = Array.isArray(listed.json)
@@ -1405,10 +1913,15 @@ async function publish(rest: string[]): Promise<PackCliResult> {
1405
1913
  }
1406
1914
 
1407
1915
  const lines = [
1916
+ // What was done to the user's own directory, before what was done to
1917
+ // GitHub. A side effect on somebody's working tree is never silent, even
1918
+ // when it is the side effect they wanted.
1919
+ ...gitLines,
1920
+ ...(gitLines.length ? [""] : []),
1408
1921
  ...bundleNote,
1409
1922
  ...(created ? [`Created ${repo} (public).`] : []),
1410
1923
  `Published ${id}@${version} to ${repo} at tag ${tag}.` +
1411
- (versionCounted ? " Next publish will be one past it." : ""),
1924
+ (versionFromSha ? " That names the commit it was built from." : ""),
1412
1925
  ` ${assets.length} assets attached`,
1413
1926
  "",
1414
1927
  "Anyone can now install it:",
@@ -1575,6 +2088,10 @@ async function add(rest: string[]): Promise<PackCliResult> {
1575
2088
  const picked = await pickFromSource(resolvedSource!, io);
1576
2089
  if (picked === null) return ok(["Nothing installed."]);
1577
2090
  selection.only = picked;
2091
+ // The picker REPLACES. What is ticked is what should be on, so a flag's
2092
+ // additive reading must not leak into it — otherwise unticking something
2093
+ // here could never turn it off.
2094
+ selection.merge = false;
1578
2095
  // An empty pick is a real answer — install the pack, enable none of it —
1579
2096
  // but `{only: []}` is indistinguishable from "no selection" downstream, so
1580
2097
  // it is carried as an explicit empty list the resolver can see.
@@ -1599,6 +2116,10 @@ async function add(rest: string[]): Promise<PackCliResult> {
1599
2116
  defaults: "the pack's defaults",
1600
2117
  selected: "your selection",
1601
2118
  carried: "your existing selection",
2119
+ // Named differently from `selected` on purpose: it is the one outcome
2120
+ // where the set is LARGER than what was asked for, and a reader who does
2121
+ // not see that said out loud will read the number as their whole answer.
2122
+ added: "what you added, plus what was already on",
1602
2123
  all: "everything in the pack",
1603
2124
  }[result.selection];
1604
2125
  // `summarise([])` is the empty string, which would print a line ending in a
@@ -1649,6 +2170,204 @@ function remove(rest: string[]): PackCliResult {
1649
2170
  * downloading or importing the entry artifact, so looking at a stranger's pack
1650
2171
  * cannot run a stranger's code.
1651
2172
  */
2173
+ /**
2174
+ * `failproofai policies show <owner>/<repo> --releases` — every release a pack
2175
+ * has published, and which one is on this machine.
2176
+ *
2177
+ * ONE request. The obvious implementation downloads each release's manifest to
2178
+ * count its policies, which costs a request per release and gets slower the
2179
+ * longer a pack has existed; instead `publish` writes those counts into the
2180
+ * release BODY, and `GET /releases` returns bodies inline. See `releaseBody`.
2181
+ *
2182
+ * A LISTING, and never an install path. Installs construct their URLs from
2183
+ * owner/repo/tag and discover nothing, which is what leaves no index to poison
2184
+ * — this reads the API because "what else is there?" is a question that has no
2185
+ * answer without one, and the worst a wrong answer here can do is print a row.
2186
+ * Nothing on this screen is trusted: the counts and the commit are
2187
+ * publisher-controlled text, shown as claims, while what a machine actually
2188
+ * installs still comes from the digest-pinned manifest.
2189
+ */
2190
+ async function listReleases(source: string): Promise<PackCliResult> {
2191
+ const opts = optsFor(process.stdout);
2192
+ let spec;
2193
+ try {
2194
+ spec = parsePackSpec(source);
2195
+ } catch (err) {
2196
+ return fail([err instanceof Error ? err.message : String(err)]);
2197
+ }
2198
+
2199
+ // Best-effort: a token raises the rate limit from 60/hour to 5000 and lets a
2200
+ // private repo answer at all. Its absence is not an error.
2201
+ const res = await gh(
2202
+ `${GITHUB_API}/repos/${spec.owner}/${spec.repo}/releases?per_page=100`,
2203
+ githubToken(),
2204
+ );
2205
+ if (res.status === 404) {
2206
+ return fail([
2207
+ `No repository at ${spec.owner}/${spec.repo}, or it is private and this machine has no credential for it.`,
2208
+ "A pack has to be public to install anyway — installs are anonymous HTTPS with no credential to offer.",
2209
+ ]);
2210
+ }
2211
+ if (res.status === 403 && /rate limit/i.test(res.text)) {
2212
+ return fail([
2213
+ "GitHub rate-limited this listing.",
2214
+ "Sign in once with `gh auth login` (or set GITHUB_TOKEN) to raise the limit from 60 requests an hour to 5000.",
2215
+ ]);
2216
+ }
2217
+ if (res.status >= 400 || !Array.isArray(res.json)) {
2218
+ return fail([`Could not list releases for ${spec.owner}/${spec.repo}: ${ghError(res)}`]);
2219
+ }
2220
+
2221
+ const raw = res.json as unknown as Array<{
2222
+ tag_name?: unknown; body?: unknown; published_at?: unknown; created_at?: unknown;
2223
+ prerelease?: unknown; draft?: unknown; assets?: unknown;
2224
+ }>;
2225
+ // Sorted HERE, by when each was PUBLISHED, rather than trusting the order the
2226
+ // API returned.
2227
+ //
2228
+ // GitHub orders this endpoint by `created_at`, and `created_at` on a release
2229
+ // is the date of the COMMIT its tag points at — not the moment the release
2230
+ // was made. Two releases cut from one commit therefore tie, and the tie broke
2231
+ // backwards: the pack repository's own listing put a release from 14:38 above
2232
+ // the one from 15:27, and the install hint below offered the older of the
2233
+ // two as the thing to install.
2234
+ //
2235
+ // That was survivable while versions sorted by themselves and is not now. A
2236
+ // sha carries no order, so this list is the ONLY place the question "which of
2237
+ // these is newest?" gets answered, and `publish --help` sends people here to
2238
+ // ask it.
2239
+ const when = (r: { published_at?: unknown; created_at?: unknown }): number => {
2240
+ for (const value of [r.published_at, r.created_at]) {
2241
+ if (typeof value !== "string") continue;
2242
+ const parsed = Date.parse(value);
2243
+ if (Number.isFinite(parsed)) return parsed;
2244
+ }
2245
+ // A release carrying no usable date sinks rather than floats: it cannot be
2246
+ // shown as the newest thing on a claim it has not made.
2247
+ return Number.NEGATIVE_INFINITY;
2248
+ };
2249
+ const releases = [...raw].sort((a, b) => when(b) - when(a));
2250
+ if (releases.length === 0) {
2251
+ return ok(
2252
+ stack(
2253
+ title(`${spec.owner}/${spec.repo}`, "no releases", opts),
2254
+ emptyState(
2255
+ {
2256
+ what: "This repository has published no releases, so there is nothing to install.",
2257
+ hint: "Its author publishes one with:",
2258
+ cmd: "failproofai publish",
2259
+ },
2260
+ opts,
2261
+ ),
2262
+ ),
2263
+ );
2264
+ }
2265
+
2266
+ // Which of them is on THIS machine — the question somebody runs this to
2267
+ // answer. Read by pack id rather than by source string: a pack installed as
2268
+ // `github:o/r@v1` and one installed from the URL are the same pack, and
2269
+ // comparing the spellings would say they are not.
2270
+ const installedVersions = new Map<string, string>();
2271
+ try {
2272
+ for (const pack of readInstalledPacks().packs) installedVersions.set(pack.id.toLowerCase(), pack.version);
2273
+ } catch {
2274
+ /* an unreadable manifest costs the marker, not the listing */
2275
+ }
2276
+ const here = installedVersions.get(`${spec.owner}/${spec.repo}`.toLowerCase());
2277
+
2278
+ const rows: Array<string[] | { section: string }> = [];
2279
+ // The newest INSTALLABLE tag for the hint below, picked in this same pass —
2280
+ // GitHub returns releases newest-first. It used to be a second `find` over the
2281
+ // raw list, which disagreed with the loop twice: a release whose `tag_name`
2282
+ // was an empty string is skipped below but passed that `typeof === "string"`
2283
+ // test, so the hint read `policies add owner/repo@`, and a release carrying
2284
+ // none of the three assets was offered as the thing to install.
2285
+ let newest: string | undefined;
2286
+ for (const release of releases) {
2287
+ const tag = typeof release.tag_name === "string" ? release.tag_name : "";
2288
+ if (!tag) continue;
2289
+ const facts = parseReleaseBody(typeof release.body === "string" ? release.body : "");
2290
+ const when = typeof release.published_at === "string" ? release.published_at
2291
+ : typeof release.created_at === "string" ? release.created_at
2292
+ : "";
2293
+ // By NAME, not by count. Counting said "three attachments" where the claim
2294
+ // is "these three attachments", so any repository that ships three binaries
2295
+ // per release — most of them — read as installable, and one that attaches
2296
+ // nothing at all read as fine. Both then sent somebody to an install that
2297
+ // 404s on an asset that was never there.
2298
+ const attached = Array.isArray(release.assets)
2299
+ ? new Set(
2300
+ (release.assets as Array<{ name?: unknown }>)
2301
+ .map((a) => (a && typeof a.name === "string" ? a.name : ""))
2302
+ .filter(Boolean),
2303
+ )
2304
+ : new Set<string>();
2305
+ const installable = INSTALLABLE_PACK_ASSETS.every((name) => attached.has(name));
2306
+ const flags = [
2307
+ release.draft === true ? "draft" : "",
2308
+ release.prerelease === true ? "prerelease" : "",
2309
+ installable ? "" : "incomplete",
2310
+ here && packTagMatchesVersion(tag, here) ? "installed" : "",
2311
+ ].filter(Boolean).join(" · ");
2312
+ if (newest === undefined && installable && release.draft !== true) newest = tag;
2313
+ rows.push([
2314
+ tag,
2315
+ when ? relativeAge(when) : "—",
2316
+ facts.commit ? facts.commit.slice(0, 7) : "—",
2317
+ facts.policies === undefined ? "—" : String(facts.policies),
2318
+ facts.defaultOn === undefined ? "—" : String(facts.defaultOn),
2319
+ flags,
2320
+ ]);
2321
+ }
2322
+ return ok(
2323
+ stack(
2324
+ title(`${spec.owner}/${spec.repo}`, `${rows.length} release${rows.length === 1 ? "" : "s"}`, opts),
2325
+ table(
2326
+ { head: ["version", "published", "commit", "policies", "default", ""], rows },
2327
+ opts,
2328
+ ),
2329
+ // `—` is load-bearing: it means "this release did not say", which is what
2330
+ // a release published before this format did, and what anybody else's
2331
+ // hand-made release does. Filling those in would cost a manifest download
2332
+ // each, and a listing that silently costs a hundred downloads is worse
2333
+ // than one with gaps in it.
2334
+ note("— means the release did not record it.", opts),
2335
+ nextStep(
2336
+ `failproofai policies add ${spec.owner}/${spec.repo}@${newest ?? "<tag>"}`,
2337
+ "Install a particular one with:",
2338
+ opts,
2339
+ ),
2340
+ ),
2341
+ );
2342
+ }
2343
+
2344
+ /** `2 hours ago`, `6 days ago`. Coarse on purpose — the question is "how stale",
2345
+ * and a timestamp to the second answers a question nobody asked. */
2346
+ function relativeAge(iso: string): string {
2347
+ const then = Date.parse(iso);
2348
+ if (!Number.isFinite(then)) return "—";
2349
+ const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
2350
+ // Each pair is "divide by this, and the result is measured in THAT" — so the
2351
+ // label belongs to the unit arrived at, never the one left behind. Written
2352
+ // the other way round it was off by a whole unit across the entire range: a
2353
+ // release from yesterday read `1 hour ago`, one from six months ago read
2354
+ // `5 weeks ago`, and 90 seconds read `just now`. The column exists to answer
2355
+ // "how stale is this pack", so an answer two units too fresh is worse than no
2356
+ // column at all.
2357
+ const units: Array<[number, string]> = [
2358
+ [60, "minute"], [60, "hour"], [24, "day"], [7, "week"], [4.35, "month"], [12, "year"],
2359
+ ];
2360
+ let value = seconds;
2361
+ let label = "second";
2362
+ for (const [size, next] of units) {
2363
+ if (value < size) break;
2364
+ value = Math.floor(value / size);
2365
+ label = next;
2366
+ }
2367
+ if (label === "second" && value < 60) return value < 10 ? "just now" : `${value} seconds ago`;
2368
+ return `${value} ${label}${value === 1 ? "" : "s"} ago`;
2369
+ }
2370
+
1652
2371
  async function listRemote(source: string): Promise<PackCliResult> {
1653
2372
  const opts = optsFor(process.stdout);
1654
2373
  let preview;
@@ -1836,8 +2555,19 @@ export async function runPackCommand(argv: string[]): Promise<PackCliResult> {
1836
2555
  case "list":
1837
2556
  case undefined: {
1838
2557
  // `pack list` is what is installed here; `pack list <source>` is what a
1839
- // pack out there contains.
2558
+ // pack out there contains; `--releases` is what it has published over
2559
+ // time. All three are "show me", which is why they are one word with a
2560
+ // flag rather than a third subcommand nobody would find.
1840
2561
  const source = packAddSource(rest);
2562
+ if (rest.includes("--releases")) {
2563
+ if (!source) {
2564
+ return fail([
2565
+ "Usage: failproofai policies show <owner>/<repo> --releases",
2566
+ "`--releases` reports what a pack out there has published; run `failproofai policies` for what is installed here.",
2567
+ ]);
2568
+ }
2569
+ return listReleases(source);
2570
+ }
1841
2571
  return source ? listRemote(source) : list();
1842
2572
  }
1843
2573
  default: