@clossys/launcher 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/README.md +1373 -60
  2. package/contracts/conversation-contract.md +2 -2
  3. package/contracts/product-ci-workflow.yml +74 -0
  4. package/contracts/repository-inventory.json +53 -0
  5. package/dist/admission-fixture.d.ts +168 -0
  6. package/dist/admission-fixture.d.ts.map +1 -0
  7. package/dist/admission-fixture.js +467 -0
  8. package/dist/admission-fixture.js.map +1 -0
  9. package/dist/admission.d.ts +124 -0
  10. package/dist/admission.d.ts.map +1 -0
  11. package/dist/admission.js +804 -0
  12. package/dist/admission.js.map +1 -0
  13. package/dist/agents-guide.d.ts +9 -0
  14. package/dist/agents-guide.d.ts.map +1 -0
  15. package/dist/agents-guide.js +26 -0
  16. package/dist/agents-guide.js.map +1 -0
  17. package/dist/apply-command-options.check.d.ts +12 -0
  18. package/dist/apply-command-options.check.d.ts.map +1 -0
  19. package/dist/apply-command-options.check.js +20 -0
  20. package/dist/apply-command-options.check.js.map +1 -0
  21. package/dist/apply-plan-cli.d.ts +39 -1
  22. package/dist/apply-plan-cli.d.ts.map +1 -1
  23. package/dist/apply-plan-cli.js +432 -15
  24. package/dist/apply-plan-cli.js.map +1 -1
  25. package/dist/apply-plan.d.ts +46 -59
  26. package/dist/apply-plan.d.ts.map +1 -1
  27. package/dist/apply-plan.js +112 -97
  28. package/dist/apply-plan.js.map +1 -1
  29. package/dist/apply-step-fixture.d.ts +87 -0
  30. package/dist/apply-step-fixture.d.ts.map +1 -0
  31. package/dist/apply-step-fixture.js +199 -0
  32. package/dist/apply-step-fixture.js.map +1 -0
  33. package/dist/apply-store.d.ts +93 -0
  34. package/dist/apply-store.d.ts.map +1 -0
  35. package/dist/apply-store.js +625 -0
  36. package/dist/apply-store.js.map +1 -0
  37. package/dist/approval-sheet.d.ts +21 -0
  38. package/dist/approval-sheet.d.ts.map +1 -0
  39. package/dist/approval-sheet.js +163 -0
  40. package/dist/approval-sheet.js.map +1 -0
  41. package/dist/body-command.d.ts +42 -0
  42. package/dist/body-command.d.ts.map +1 -0
  43. package/dist/body-command.js +143 -0
  44. package/dist/body-command.js.map +1 -0
  45. package/dist/change-set-contract.d.ts +403 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +781 -0
  48. package/dist/change-set-contract.js.map +1 -0
  49. package/dist/change-set-digest.d.ts +28 -0
  50. package/dist/change-set-digest.d.ts.map +1 -0
  51. package/dist/change-set-digest.js +65 -0
  52. package/dist/change-set-digest.js.map +1 -0
  53. package/dist/check-cli.d.ts.map +1 -1
  54. package/dist/check-cli.js +14 -3
  55. package/dist/check-cli.js.map +1 -1
  56. package/dist/cli.d.ts +17 -6
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +84 -23
  59. package/dist/cli.js.map +1 -1
  60. package/dist/core.d.ts +79 -22
  61. package/dist/core.d.ts.map +1 -1
  62. package/dist/core.js +843 -268
  63. package/dist/core.js.map +1 -1
  64. package/dist/dry-materialize.d.ts +63 -0
  65. package/dist/dry-materialize.d.ts.map +1 -0
  66. package/dist/dry-materialize.js +330 -0
  67. package/dist/dry-materialize.js.map +1 -0
  68. package/dist/existing-declaration-adoption.check.d.ts +2 -0
  69. package/dist/existing-declaration-adoption.check.d.ts.map +1 -0
  70. package/dist/existing-declaration-adoption.check.js +10 -0
  71. package/dist/existing-declaration-adoption.check.js.map +1 -0
  72. package/dist/generated/contract-schema.generated.d.ts +97 -0
  73. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  74. package/dist/generated/contract-schema.generated.js +496 -0
  75. package/dist/generated/contract-schema.generated.js.map +1 -0
  76. package/dist/generated/package-scope.generated.d.ts +6 -0
  77. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  78. package/dist/generated/package-scope.generated.js +10 -0
  79. package/dist/generated/package-scope.generated.js.map +1 -0
  80. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  81. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  82. package/dist/generated/plan-contracts.generated.js +3101 -0
  83. package/dist/generated/plan-contracts.generated.js.map +1 -0
  84. package/dist/host.d.ts.map +1 -1
  85. package/dist/host.js +11 -0
  86. package/dist/host.js.map +1 -1
  87. package/dist/identity.d.ts +15 -0
  88. package/dist/identity.d.ts.map +1 -0
  89. package/dist/identity.js +48 -0
  90. package/dist/identity.js.map +1 -0
  91. package/dist/index.d.ts +35 -5
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +19 -2
  94. package/dist/index.js.map +1 -1
  95. package/dist/inventory-adoption.d.ts +24 -5
  96. package/dist/inventory-adoption.d.ts.map +1 -1
  97. package/dist/inventory-adoption.js +70 -25
  98. package/dist/inventory-adoption.js.map +1 -1
  99. package/dist/inventory-choice.d.ts +40 -0
  100. package/dist/inventory-choice.d.ts.map +1 -0
  101. package/dist/inventory-choice.js +156 -0
  102. package/dist/inventory-choice.js.map +1 -0
  103. package/dist/inventory-contract.d.ts +89 -0
  104. package/dist/inventory-contract.d.ts.map +1 -0
  105. package/dist/inventory-contract.js +121 -0
  106. package/dist/inventory-contract.js.map +1 -0
  107. package/dist/key-editor.d.ts +30 -0
  108. package/dist/key-editor.d.ts.map +1 -0
  109. package/dist/key-editor.js +445 -0
  110. package/dist/key-editor.js.map +1 -0
  111. package/dist/ledger-contract.d.ts +190 -0
  112. package/dist/ledger-contract.d.ts.map +1 -0
  113. package/dist/ledger-contract.js +555 -0
  114. package/dist/ledger-contract.js.map +1 -0
  115. package/dist/ledger-trust.d.ts +90 -0
  116. package/dist/ledger-trust.d.ts.map +1 -0
  117. package/dist/ledger-trust.js +203 -0
  118. package/dist/ledger-trust.js.map +1 -0
  119. package/dist/lockfile-invariants.d.ts +48 -0
  120. package/dist/lockfile-invariants.d.ts.map +1 -0
  121. package/dist/lockfile-invariants.js +375 -0
  122. package/dist/lockfile-invariants.js.map +1 -0
  123. package/dist/lockfile-readers.d.ts +72 -0
  124. package/dist/lockfile-readers.d.ts.map +1 -0
  125. package/dist/lockfile-readers.js +713 -0
  126. package/dist/lockfile-readers.js.map +1 -0
  127. package/dist/lockfile-regen.d.ts +106 -0
  128. package/dist/lockfile-regen.d.ts.map +1 -0
  129. package/dist/lockfile-regen.js +760 -0
  130. package/dist/lockfile-regen.js.map +1 -0
  131. package/dist/lockfile-tool-env.d.ts +29 -0
  132. package/dist/lockfile-tool-env.d.ts.map +1 -0
  133. package/dist/lockfile-tool-env.js +111 -0
  134. package/dist/lockfile-tool-env.js.map +1 -0
  135. package/dist/materialize.d.ts +113 -0
  136. package/dist/materialize.d.ts.map +1 -0
  137. package/dist/materialize.js +881 -0
  138. package/dist/materialize.js.map +1 -0
  139. package/dist/observe-repository.d.ts +90 -0
  140. package/dist/observe-repository.d.ts.map +1 -0
  141. package/dist/observe-repository.js +1367 -0
  142. package/dist/observe-repository.js.map +1 -0
  143. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  144. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  145. package/dist/plan-bundle-setup-fixture.js +167 -0
  146. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  147. package/dist/plan-bundle.d.ts +256 -0
  148. package/dist/plan-bundle.d.ts.map +1 -0
  149. package/dist/plan-bundle.js +882 -0
  150. package/dist/plan-bundle.js.map +1 -0
  151. package/dist/plan-command.d.ts +29 -0
  152. package/dist/plan-command.d.ts.map +1 -0
  153. package/dist/plan-command.js +523 -0
  154. package/dist/plan-command.js.map +1 -0
  155. package/dist/plan-contract.d.ts +153 -0
  156. package/dist/plan-contract.d.ts.map +1 -0
  157. package/dist/plan-contract.js +61 -0
  158. package/dist/plan-contract.js.map +1 -0
  159. package/dist/plan-digest.d.ts +25 -0
  160. package/dist/plan-digest.d.ts.map +1 -0
  161. package/dist/plan-digest.js +106 -0
  162. package/dist/plan-digest.js.map +1 -0
  163. package/dist/plan-rules.d.ts +23 -0
  164. package/dist/plan-rules.d.ts.map +1 -0
  165. package/dist/plan-rules.js +177 -0
  166. package/dist/plan-rules.js.map +1 -0
  167. package/dist/planned-bundle.d.ts +20 -0
  168. package/dist/planned-bundle.d.ts.map +1 -0
  169. package/dist/planned-bundle.js +191 -0
  170. package/dist/planned-bundle.js.map +1 -0
  171. package/dist/product-repository.d.ts +4 -0
  172. package/dist/product-repository.d.ts.map +1 -1
  173. package/dist/product-repository.js +9 -1
  174. package/dist/product-repository.js.map +1 -1
  175. package/dist/provenance-gate.d.ts +48 -0
  176. package/dist/provenance-gate.d.ts.map +1 -0
  177. package/dist/provenance-gate.js +324 -0
  178. package/dist/provenance-gate.js.map +1 -0
  179. package/dist/pull-request-body.d.ts +45 -0
  180. package/dist/pull-request-body.d.ts.map +1 -0
  181. package/dist/pull-request-body.js +232 -0
  182. package/dist/pull-request-body.js.map +1 -0
  183. package/dist/registry-snapshot.d.ts +141 -0
  184. package/dist/registry-snapshot.d.ts.map +1 -0
  185. package/dist/registry-snapshot.js +483 -0
  186. package/dist/registry-snapshot.js.map +1 -0
  187. package/dist/release-age-edit.d.ts +52 -0
  188. package/dist/release-age-edit.d.ts.map +1 -0
  189. package/dist/release-age-edit.js +413 -0
  190. package/dist/release-age-edit.js.map +1 -0
  191. package/dist/root-entries.d.ts +36 -0
  192. package/dist/root-entries.d.ts.map +1 -0
  193. package/dist/root-entries.js +80 -0
  194. package/dist/root-entries.js.map +1 -0
  195. package/dist/setup-template-scripts.d.ts +36 -0
  196. package/dist/setup-template-scripts.d.ts.map +1 -0
  197. package/dist/setup-template-scripts.js +568 -0
  198. package/dist/setup-template-scripts.js.map +1 -0
  199. package/dist/setup-templates.d.ts +55 -0
  200. package/dist/setup-templates.d.ts.map +1 -0
  201. package/dist/setup-templates.js +438 -0
  202. package/dist/setup-templates.js.map +1 -0
  203. package/dist/skills.d.ts +34 -1
  204. package/dist/skills.d.ts.map +1 -1
  205. package/dist/skills.js +129 -17
  206. package/dist/skills.js.map +1 -1
  207. package/dist/status.d.ts +63 -0
  208. package/dist/status.d.ts.map +1 -0
  209. package/dist/status.js +539 -0
  210. package/dist/status.js.map +1 -0
  211. package/dist/types.d.ts +151 -13
  212. package/dist/types.d.ts.map +1 -1
  213. package/package.json +4 -4
  214. package/skeleton/README.md +14 -9
  215. package/skeleton/package.json +2 -1
  216. package/skill/SKILL.md +23 -7
  217. package/skill-catalogue/advisor/SKILL.md +59 -6
  218. package/skill-catalogue/architect/SKILL.md +2 -2
  219. package/skill-catalogue/bouncer/SKILL.md +2 -2
  220. package/skill-catalogue/builder/SKILL.md +2 -2
  221. package/skill-catalogue/butler/SKILL.md +2 -2
  222. package/skill-catalogue/controller/SKILL.md +2 -2
  223. package/skill-catalogue/customer/SKILL.md +2 -2
  224. package/skill-catalogue/designer/SKILL.md +4 -2
  225. package/skill-catalogue/giver/SKILL.md +2 -2
  226. package/skill-catalogue/influencer/SKILL.md +2 -2
  227. package/skill-catalogue/inspector/SKILL.md +2 -2
  228. package/skill-catalogue/integrator/SKILL.md +2 -2
  229. package/skill-catalogue/keeper/SKILL.md +2 -2
  230. package/skill-catalogue/launcher/SKILL.md +23 -7
  231. package/skill-catalogue/locksmith/SKILL.md +2 -2
  232. package/skill-catalogue/messenger/SKILL.md +2 -2
  233. package/skill-catalogue/observer/SKILL.md +2 -2
  234. package/skill-catalogue/publisher/SKILL.md +2 -2
  235. package/skill-catalogue/starter/SKILL.md +3 -2
  236. package/skill-catalogue/strategist/SKILL.md +12 -4
  237. package/skill-catalogue/writer/SKILL.md +2 -2
  238. package/src/admission-fixture.ts +585 -0
  239. package/src/admission.ts +819 -0
  240. package/src/agents-guide.ts +29 -0
  241. package/src/apply-command-options.check.ts +27 -0
  242. package/src/apply-plan-cli.ts +454 -14
  243. package/src/apply-plan.ts +112 -124
  244. package/src/apply-step-fixture.ts +236 -0
  245. package/src/apply-store.ts +584 -0
  246. package/src/approval-sheet.ts +170 -0
  247. package/src/body-command.ts +162 -0
  248. package/src/change-set-contract.ts +987 -0
  249. package/src/change-set-digest.ts +70 -0
  250. package/src/check-cli.ts +14 -3
  251. package/src/cli.ts +90 -22
  252. package/src/core.ts +973 -275
  253. package/src/dry-materialize.ts +353 -0
  254. package/src/existing-declaration-adoption.check.ts +12 -0
  255. package/src/generated/contract-schema.generated.ts +520 -0
  256. package/src/generated/package-scope.generated.ts +10 -0
  257. package/src/generated/plan-contracts.generated.ts +3101 -0
  258. package/src/host.ts +10 -0
  259. package/src/identity.ts +51 -0
  260. package/src/index.ts +74 -3
  261. package/src/inventory-adoption.ts +107 -29
  262. package/src/inventory-choice.ts +172 -0
  263. package/src/inventory-contract.ts +166 -0
  264. package/src/key-editor.ts +446 -0
  265. package/src/ledger-contract.ts +660 -0
  266. package/src/ledger-trust.ts +272 -0
  267. package/src/lockfile-invariants.ts +421 -0
  268. package/src/lockfile-readers.ts +749 -0
  269. package/src/lockfile-regen.ts +851 -0
  270. package/src/lockfile-tool-env.ts +131 -0
  271. package/src/materialize.ts +915 -0
  272. package/src/observe-repository.ts +1365 -0
  273. package/src/plan-bundle-setup-fixture.ts +200 -0
  274. package/src/plan-bundle.ts +1014 -0
  275. package/src/plan-command.ts +532 -0
  276. package/src/plan-contract.ts +179 -0
  277. package/src/plan-digest.ts +102 -0
  278. package/src/plan-rules.ts +188 -0
  279. package/src/planned-bundle.ts +211 -0
  280. package/src/product-repository.ts +10 -1
  281. package/src/provenance-gate.ts +352 -0
  282. package/src/pull-request-body.ts +261 -0
  283. package/src/registry-snapshot.ts +534 -0
  284. package/src/release-age-edit.ts +430 -0
  285. package/src/root-entries.ts +81 -0
  286. package/src/setup-template-scripts.ts +580 -0
  287. package/src/setup-templates.ts +479 -0
  288. package/src/skills.ts +161 -18
  289. package/src/status.ts +557 -0
  290. package/src/types.ts +148 -13
@@ -0,0 +1,584 @@
1
+ // The hub's two apply stores (issue #1178): every repository change set (kept
2
+ // append-only, bar the body hash of a set, below) and every apply bundle (the newest computation replacing the
3
+ // last under one digest) this hub has computed, kept by digest under
4
+ // clossys/.state/apply/ so a later step (the ledger, an approval, a resumed
5
+ // apply) can read back the exact document a digest names instead of trusting
6
+ // whatever is passed to it in memory.
7
+ //
8
+ // Both stores are content-addressed: a file's name is its document's own
9
+ // digest. The change-set store is append-only: a name is only ever bound to
10
+ // the first bytes stored under it, and writing over it with different bytes
11
+ // is refused rather than silently replacing history a ledger or an approval
12
+ // may already cite. The bundle store is not, by design (RFC 12.2, 12.3 and
13
+ // 12.7, issue #1693): a bundle's digest covers the plan digest and the
14
+ // change-set digests only, not its authorization, clock, mode or verdicts, so
15
+ // a rerun of `plan` over an unchanged hub yields the same digest with newer
16
+ // bytes. Storing a bundle under a digest that already names a file replaces
17
+ // that file atomically with the newest computation; only that one name is
18
+ // touched, and superseded computations are not recorded. Admission reads a
19
+ // stored bundle for its digest, its plan digest and the change sets it holds,
20
+ // never for its authorization or verdicts, which it re-checks at apply time.
21
+ // A planned bundle is the exception to that replacement (issue #1708): a report
22
+ // never replaces a stored bundle that verifies as planned under the same
23
+ // digest, because a planned file records approvals a report does not, and a
24
+ // rerun with less (an approval no longer committed, an uncommitted plan edit)
25
+ // must not quietly erase them. The refusal names no path. A planned bundle
26
+ // replaces a report or a planned one, and a file that does not verify (any
27
+ // bytes that do not read back as a valid bundle of that digest) is replaced by
28
+ // either, since it protects nothing. Writing the same bytes again is a no-op
29
+ // in both stores. The change-set store has one exception to append-only (issue
30
+ // #1716): bindChangeSetBody records the hash of the pull request body rendered
31
+ // for a stored set as that set's `pullRequest.bodySha256`, the one member the
32
+ // digest excludes, replacing that one file atomically. It never rebinds: a set
33
+ // already bound to another hash is refused, and the same hash is a no-op.
34
+ // Binds of one set are serialised by a per-digest lock file, taken exclusively
35
+ // and never waited for or broken (issue #1738), and a set that changed or was
36
+ // removed while it was being bound is refused, never replaced or recreated.
37
+ // A stored file's recomputed digest proves its integrity, not
38
+ // its provenance: anyone who can write the hub directory can add a set that
39
+ // verifies, the same way anyone who can write a git object store can add a
40
+ // commit. A read never trusts a file
41
+ // merely because it parses: the document must validate against its contract,
42
+ // recompute to the digest the file name claims, and (for a change set) to
43
+ // the digest the document itself carries -- so a renamed or hand-edited file
44
+ // reads as absent rather than as something it no longer is.
45
+ //
46
+ // Every store directory segment, from the hub root down to change-sets/ or
47
+ // bundles/, is walked one lstat at a time (issue #1545 fix 5): a symbolic
48
+ // link anywhere in that chain is refused rather than followed, so a symlinked
49
+ // clossys/, .state/, apply/, change-sets/ or bundles/ can never redirect a
50
+ // write or a read outside the hub. Every filesystem error this module
51
+ // rethrows, other than ENOENT (which a read turns into null, and a write
52
+ // turns into directory creation), names only the failing operation and the
53
+ // error's code -- never a path, digest or other value a `cause` might carry.
54
+ //
55
+ // This module does I/O (mkdirSync, open/write/link/rename/read on the hub
56
+ // directory) and so must never be imported by plan-bundle.ts, which computes
57
+ // change sets and bundles without touching a filesystem.
58
+
59
+ import { randomBytes } from "node:crypto";
60
+ import { closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeSync } from "node:fs";
61
+ import { isAbsolute, join, relative, sep } from "node:path";
62
+ import { bundleDigest, changeSetDigest } from "./change-set-digest.js";
63
+ import { validateApplyBundle, validateRepositoryChangeSet } from "./change-set-contract.js";
64
+ import type { ApplyBundle, ApplyBundleRepository, RepositoryChangeSet } from "./change-set-contract.js";
65
+ import { readContractDocument } from "./generated/contract-schema.generated.js";
66
+
67
+ /** Where every repository change set this hub has computed is kept, one file per digest, relative to the hub root. */
68
+ export const CHANGE_SET_STORE_REL = "clossys/.state/apply/change-sets";
69
+
70
+ /** Where every apply bundle this hub has computed is kept, one file per digest, relative to the hub root. */
71
+ export const BUNDLE_STORE_REL = "clossys/.state/apply/bundles";
72
+
73
+ /** A digest as every store file is named for: `sha256:` and exactly 64 lowercase hex digits. */
74
+ const DIGEST_SHAPE = /^sha256:[0-9a-f]{64}$/u;
75
+
76
+ /** Throws before any path is built from `digest`, naming no value: only a caller that already holds a well-shaped digest may reach the store. */
77
+ function assertDigestShape(digest: string): void {
78
+ if (!DIGEST_SHAPE.test(digest)) throw new TypeError("a stored digest must match sha256: followed by exactly 64 lowercase hex digits");
79
+ }
80
+
81
+ /** The file name a digest is stored under: its 64 hex digits, with no `sha256:` prefix or colon. */
82
+ function digestFileName(digest: string): string {
83
+ return `${digest.slice("sha256:".length)}.json`;
84
+ }
85
+
86
+ /** A document's bytes as every store file holds them: two-space JSON with a final newline. */
87
+ function serializeStoredDocument(value: unknown): Buffer {
88
+ return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
89
+ }
90
+
91
+ /** Wraps a filesystem error, other than ENOENT (handled by each caller), so its message names only the failing operation and the
92
+ * error's code -- never a path, digest or other value; the original error is never attached as `cause`. */
93
+ function wrapFsError(operation: string, cause: unknown): Error {
94
+ const code = typeof cause === "object" && cause !== null && "code" in cause ? String((cause as NodeJS.ErrnoException).code) : "unknown";
95
+ return new Error(`${operation} failed (${code})`);
96
+ }
97
+
98
+ /**
99
+ * `hubDirectory` must be an absolute path and its own real path: no symbolic
100
+ * link anywhere in it. Every store operation checks this first, before
101
+ * building any path under it.
102
+ */
103
+ function assertHubDirectory(hubDirectory: string): void {
104
+ if (!isAbsolute(hubDirectory)) throw new TypeError("hubDirectory must be an absolute path");
105
+ let real: string;
106
+ try {
107
+ real = realpathSync(hubDirectory);
108
+ } catch (cause) {
109
+ throw wrapFsError("hub directory resolve", cause);
110
+ }
111
+ if (real !== hubDirectory) throw new TypeError("hubDirectory must be its own real path, with no symbolic link in it");
112
+ }
113
+
114
+ /**
115
+ * Walks every path segment from `hubDirectory` down to `directory`
116
+ * (hubDirectory joined with a store's relative path), lstat-ing each one so a
117
+ * symbolic link anywhere in the chain is refused rather than followed. On a
118
+ * write (`mode: "write"`), a missing segment is created with a non-recursive
119
+ * `mkdirSync` and lstat-ed again; on a read (`mode: "read"`), a missing
120
+ * segment means the file being read is absent, and this returns `false`. A
121
+ * segment that is a symbolic link, or exists but is not a directory, throws a
122
+ * TypeError naming no path. Any other filesystem error is rethrown wrapped,
123
+ * naming only the operation and its code.
124
+ */
125
+ function ensureRealDirectory(hubDirectory: string, directory: string, mode: "read" | "write"): boolean {
126
+ const relativePath = relative(hubDirectory, directory);
127
+ const segments = relativePath === "" ? [] : relativePath.split(sep);
128
+ let current = hubDirectory;
129
+ for (const segment of segments) {
130
+ current = join(current, segment);
131
+ let info;
132
+ try {
133
+ info = lstatSync(current);
134
+ } catch (cause) {
135
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw wrapFsError("hub store directory stat", cause);
136
+ if (mode === "read") return false;
137
+ try {
138
+ mkdirSync(current);
139
+ } catch (createCause) {
140
+ if ((createCause as NodeJS.ErrnoException).code !== "EEXIST") throw wrapFsError("hub store directory create", createCause);
141
+ }
142
+ try {
143
+ info = lstatSync(current);
144
+ } catch (recheckCause) {
145
+ throw wrapFsError("hub store directory create", recheckCause);
146
+ }
147
+ }
148
+ if (!info.isDirectory()) throw new TypeError("a hub store directory segment is a symbolic link or not a directory");
149
+ }
150
+ return true;
151
+ }
152
+
153
+ /**
154
+ * Writes `bytes` under `fileName` in `hubDirectory`/`storeRel` without ever
155
+ * replacing an existing file: the bytes go to a temporary file in the same
156
+ * directory (created exclusively, flushed to disk), which is then
157
+ * hard-linked to the final name, so a name already taken fails the link with
158
+ * EEXIST rather than overwriting it. On EEXIST the existing file is
159
+ * lstat-ed -- a symbolic link there is refused -- then re-read: identical
160
+ * bytes is a no-op; when `equivalent` says the existing and incoming
161
+ * documents validate to the same digest the name claims, that is a no-op
162
+ * too; and any other different bytes is refused. The temporary file is
163
+ * always removed. Every store directory segment is a real directory, never a
164
+ * symbolic link (see `ensureRealDirectory()`). Returns the final path.
165
+ */
166
+ /** When both buffers are valid stored change sets for `digest`, true; when existing bytes do not validate to that digest, false. */
167
+ function storedChangeSetsShareDigest(existing: Buffer, incoming: Buffer, digest: string): boolean {
168
+ const digestOf = (buf: Buffer): string | null => {
169
+ try {
170
+ const document = readContractDocument(buf);
171
+ if (!validateRepositoryChangeSet(document).valid) return null;
172
+ const set = document as RepositoryChangeSet;
173
+ const recomputed = changeSetDigest(set);
174
+ if (recomputed !== set.changeSetDigest || recomputed !== digest) return null;
175
+ return recomputed;
176
+ } catch {
177
+ return null;
178
+ }
179
+ };
180
+ const left = digestOf(existing);
181
+ const right = digestOf(incoming);
182
+ return left !== null && right !== null && left === right;
183
+ }
184
+
185
+ function writeAppendOnly(hubDirectory: string, storeRel: string, fileName: string, bytes: Buffer, equivalent?: (existing: Buffer, incoming: Buffer) => boolean): string {
186
+ assertHubDirectory(hubDirectory);
187
+ const directory = join(hubDirectory, storeRel);
188
+ ensureRealDirectory(hubDirectory, directory, "write");
189
+ const finalPath = join(directory, fileName);
190
+ const temporaryPath = join(directory, `.${fileName}.${randomBytes(8).toString("hex")}.tmp`);
191
+ let descriptor: number | undefined;
192
+ try {
193
+ try {
194
+ descriptor = openSync(temporaryPath, "wx", 0o644);
195
+ } catch (cause) {
196
+ throw wrapFsError("hub store write", cause);
197
+ }
198
+ let written = 0;
199
+ try {
200
+ while (written < bytes.length) written += writeSync(descriptor, bytes, written, bytes.length - written);
201
+ fsyncSync(descriptor);
202
+ closeSync(descriptor);
203
+ } catch (cause) {
204
+ throw wrapFsError("hub store write", cause);
205
+ }
206
+ descriptor = undefined;
207
+ try {
208
+ linkSync(temporaryPath, finalPath);
209
+ } catch (cause) {
210
+ if ((cause as NodeJS.ErrnoException).code !== "EEXIST") throw wrapFsError("hub store write", cause);
211
+ let info;
212
+ try {
213
+ info = lstatSync(finalPath);
214
+ } catch (statCause) {
215
+ throw wrapFsError("hub store write", statCause);
216
+ }
217
+ if (!info.isFile()) throw new TypeError("a hub store file name is occupied by a symbolic link or a non-regular file");
218
+ let existing: Buffer;
219
+ try {
220
+ existing = readFileSync(finalPath);
221
+ } catch (readCause) {
222
+ throw wrapFsError("hub store write", readCause);
223
+ }
224
+ if (!existing.equals(bytes) && !equivalent?.(existing, bytes)) {
225
+ throw new TypeError("this digest already names a stored document with different bytes; the store is append-only");
226
+ }
227
+ }
228
+ } finally {
229
+ if (descriptor !== undefined) {
230
+ try {
231
+ closeSync(descriptor);
232
+ } catch {
233
+ // Already failing; the original error is the one reported.
234
+ }
235
+ }
236
+ try {
237
+ rmSync(temporaryPath, { force: true });
238
+ } catch {
239
+ // Best-effort cleanup; the original error (or success) is what is reported.
240
+ }
241
+ }
242
+ return finalPath;
243
+ }
244
+
245
+ /**
246
+ * Stores `bytes` under `fileName` in `hubDirectory`/`storeRel`, replacing
247
+ * whatever regular file already holds that one name (unless `refuseOver` says
248
+ * the file as it is now must not be replaced, which throws its message; with
249
+ * `refuseOver.whenMissing` a name that holds no file is refused the same way
250
+ * instead of being created, for a caller that read the file first and must not
251
+ * recreate one removed since): the bytes go to a
252
+ * temporary file in the same real directory (created exclusively, flushed to
253
+ * disk), which is then renamed over the final name, so a reader sees the old
254
+ * file or the new one and never a partial file, and an interrupted write
255
+ * leaves at most a temporary file under another name. Identical existing
256
+ * bytes are a no-op and write nothing. A name held by a symbolic link or any
257
+ * other non-regular file is refused before anything is written. Every store
258
+ * directory segment is a real directory, never a symbolic link (see
259
+ * `ensureRealDirectory()`). The temporary file is always removed. Returns the
260
+ * final path.
261
+ */
262
+ function writeReplacing(hubDirectory: string, storeRel: string, fileName: string, bytes: Buffer, refuseOver?: { readonly when: (existing: Buffer) => boolean; readonly message: string; readonly whenMissing?: boolean }): string {
263
+ assertHubDirectory(hubDirectory);
264
+ const directory = join(hubDirectory, storeRel);
265
+ ensureRealDirectory(hubDirectory, directory, "write");
266
+ const finalPath = join(directory, fileName);
267
+ let occupied = true;
268
+ try {
269
+ const info = lstatSync(finalPath);
270
+ if (!info.isFile()) throw new TypeError("a hub store file name is occupied by a symbolic link or a non-regular file");
271
+ } catch (cause) {
272
+ if (cause instanceof TypeError) throw cause;
273
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw wrapFsError("hub store write", cause);
274
+ if (refuseOver?.whenMissing === true) throw new TypeError(refuseOver.message);
275
+ occupied = false;
276
+ }
277
+ if (occupied) {
278
+ let existing: Buffer;
279
+ try {
280
+ existing = readFileSync(finalPath);
281
+ } catch (cause) {
282
+ throw wrapFsError("hub store write", cause);
283
+ }
284
+ if (refuseOver?.when(existing) === true) throw new TypeError(refuseOver.message);
285
+ if (existing.equals(bytes)) return finalPath;
286
+ }
287
+ const temporaryPath = join(directory, `.${fileName}.${randomBytes(8).toString("hex")}.tmp`);
288
+ let descriptor: number | undefined;
289
+ try {
290
+ try {
291
+ descriptor = openSync(temporaryPath, "wx", 0o644);
292
+ } catch (cause) {
293
+ throw wrapFsError("hub store write", cause);
294
+ }
295
+ let written = 0;
296
+ try {
297
+ while (written < bytes.length) written += writeSync(descriptor, bytes, written, bytes.length - written);
298
+ fsyncSync(descriptor);
299
+ closeSync(descriptor);
300
+ } catch (cause) {
301
+ throw wrapFsError("hub store write", cause);
302
+ }
303
+ descriptor = undefined;
304
+ try {
305
+ renameSync(temporaryPath, finalPath);
306
+ } catch (cause) {
307
+ throw wrapFsError("hub store write", cause);
308
+ }
309
+ } finally {
310
+ if (descriptor !== undefined) {
311
+ try {
312
+ closeSync(descriptor);
313
+ } catch {
314
+ // Already failing; the original error is the one reported.
315
+ }
316
+ }
317
+ try {
318
+ rmSync(temporaryPath, { force: true });
319
+ } catch {
320
+ // Best-effort cleanup; the original error (or success) is what is reported.
321
+ }
322
+ }
323
+ return finalPath;
324
+ }
325
+
326
+ /**
327
+ * Reads and re-reads bytes stored at `hubDirectory`/`storeRel`/`fileName`,
328
+ * strictly: a missing store directory or a missing file reads as `null`, and
329
+ * bytes that are not exactly one strict-JSON value also read as `null`
330
+ * (never thrown), because a store read never trusts a file merely because
331
+ * something is there. Every store directory segment down to `storeRel` is
332
+ * checked to be a real directory, never a symbolic link (see
333
+ * `ensureRealDirectory()`); a symbolic link found there throws rather than
334
+ * being followed.
335
+ */
336
+ function readStoredBytes(hubDirectory: string, storeRel: string, fileName: string): unknown | null {
337
+ assertHubDirectory(hubDirectory);
338
+ const directory = join(hubDirectory, storeRel);
339
+ if (!ensureRealDirectory(hubDirectory, directory, "read")) return null;
340
+ const path = join(directory, fileName);
341
+ let bytes: Buffer;
342
+ try {
343
+ bytes = readFileSync(path);
344
+ } catch (cause) {
345
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
346
+ throw wrapFsError("hub store read", cause);
347
+ }
348
+ try {
349
+ return readContractDocument(bytes);
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Stores `set` under `hubDirectory`/CHANGE_SET_STORE_REL, named for its own
357
+ * `changeSetDigest`. Throws a TypeError, naming no path, id or value, when
358
+ * the set does not validate against the change-set contract, when its
359
+ * `changeSetDigest` is not `changeSetDigest(set)`, or when that name is
360
+ * already taken by different bytes (the store is append-only). Storing the
361
+ * same set again, byte for byte, is a no-op; so is storing another document
362
+ * that validates to the same changeSetDigest. Returns the file's path.
363
+ */
364
+ export function storeChangeSet(hubDirectory: string, set: RepositoryChangeSet): string {
365
+ const validation = validateRepositoryChangeSet(set);
366
+ if (!validation.valid) throw new TypeError(`a change set must validate against its contract before it can be stored: ${validation.reason}`);
367
+ if (set.changeSetDigest !== changeSetDigest(set)) throw new TypeError("a change set's changeSetDigest must equal changeSetDigest(set) before it can be stored");
368
+ assertDigestShape(set.changeSetDigest);
369
+ const digest = set.changeSetDigest;
370
+ return writeAppendOnly(
371
+ hubDirectory,
372
+ CHANGE_SET_STORE_REL,
373
+ digestFileName(digest),
374
+ serializeStoredDocument(set),
375
+ (existing, incoming) => storedChangeSetsShareDigest(existing, incoming, digest),
376
+ );
377
+ }
378
+
379
+ /** Thrown by bindChangeSetBody when the stored set is already bound to another body. Names no path, digest or hash. */
380
+ export class BodyBoundError extends TypeError {
381
+ constructor() {
382
+ super("this stored change set is already bound to another pull request body");
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Records `bodySha256`, the hash of the pull request body rendered for the
388
+ * change set stored under `digest` (issue #1716), as that set's
389
+ * `pullRequest.bodySha256` -- the one member it changes. The digest excludes
390
+ * that member, so the file's name and every digest stay as they were. The set
391
+ * is read here, from the store, and must verify exactly as readStoredChangeSet
392
+ * verifies it; a set that is not stored, or does not verify, is a TypeError.
393
+ * A set already bound to the same hash is a no-op that writes nothing; one
394
+ * bound to another hash throws BodyBoundError and is left as it was: a bound
395
+ * body is never rebound, and storeChangeSet never replaces a stored set, so it
396
+ * cannot unbind one either. The write replaces only that one file, atomically
397
+ * (a temporary file in the same directory, renamed over the name), and is
398
+ * refused when the file is not the bytes that were read, a symbolic link or not
399
+ * a regular file, or when a store directory segment is a symbolic link. Every
400
+ * error names no path, digest or hash, and no other file is touched. Returns
401
+ * the file's path.
402
+ *
403
+ * Two binds of one set are serialised by a per-digest lock (issue #1738): a
404
+ * dot-prefixed file in the change-sets directory, created exclusively before
405
+ * the set is first read and removed once this call is done, only by the call
406
+ * that created it. A bind that finds the lock already held throws a TypeError
407
+ * and changes nothing, so of two binds with different hashes exactly one
408
+ * renames; it never waits and never breaks a lock. A crash mid-bind leaves the
409
+ * lock, and later binds of that set then refuse (fails closed). A set removed
410
+ * after this call read it is refused rather than written again already bound.
411
+ */
412
+ export function bindChangeSetBody(hubDirectory: string, digest: string, bodySha256: string): string {
413
+ assertDigestShape(digest);
414
+ if (typeof bodySha256 !== "string" || !DIGEST_SHAPE.test(bodySha256)) throw new TypeError("a body hash must match sha256: followed by exactly 64 lowercase hex digits");
415
+ assertHubDirectory(hubDirectory);
416
+ const directory = join(hubDirectory, CHANGE_SET_STORE_REL);
417
+ const absent = (): TypeError => new TypeError("no verified change set is stored under this digest");
418
+ if (!ensureRealDirectory(hubDirectory, directory, "read")) throw absent();
419
+ const fileName = digestFileName(digest);
420
+ const path = join(directory, fileName);
421
+ const lockPath = join(directory, `.${fileName}.bind.lock`);
422
+ let lockDescriptor: number;
423
+ try {
424
+ lockDescriptor = openSync(lockPath, "wx", 0o600);
425
+ } catch (cause) {
426
+ if ((cause as NodeJS.ErrnoException).code === "EEXIST") throw new TypeError("this stored change set is being bound by another call, or an earlier bind of it did not finish");
427
+ throw wrapFsError("hub store write", cause);
428
+ }
429
+ try {
430
+ try {
431
+ closeSync(lockDescriptor);
432
+ } catch (cause) {
433
+ throw wrapFsError("hub store write", cause);
434
+ }
435
+ return bindLocked(hubDirectory, digest, bodySha256, directory, fileName, path, absent);
436
+ } finally {
437
+ try {
438
+ rmSync(lockPath, { force: true });
439
+ } catch {
440
+ // Best-effort; a lock left behind makes later binds of this set refuse, which fails closed.
441
+ }
442
+ }
443
+ }
444
+
445
+ /** The body of bindChangeSetBody once the per-digest lock is held: read, verify, and replace the one file. */
446
+ function bindLocked(hubDirectory: string, digest: string, bodySha256: string, directory: string, fileName: string, path: string, absent: () => TypeError): string {
447
+ let info;
448
+ try {
449
+ info = lstatSync(path);
450
+ } catch (cause) {
451
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") throw absent();
452
+ throw wrapFsError("hub store read", cause);
453
+ }
454
+ if (!info.isFile()) throw new TypeError("a hub store file name is occupied by a symbolic link or a non-regular file");
455
+ let existing: Buffer;
456
+ try {
457
+ existing = readFileSync(path);
458
+ } catch (cause) {
459
+ throw wrapFsError("hub store read", cause);
460
+ }
461
+ let document: unknown;
462
+ try {
463
+ document = readContractDocument(existing);
464
+ } catch {
465
+ throw absent();
466
+ }
467
+ if (!validateRepositoryChangeSet(document).valid) throw absent();
468
+ const set = document as RepositoryChangeSet;
469
+ const recomputed = changeSetDigest(set);
470
+ if (recomputed !== set.changeSetDigest || recomputed !== digest) throw absent();
471
+ const current = set.pullRequest.bodySha256;
472
+ if (current === bodySha256) return path;
473
+ if (current !== undefined) throw new BodyBoundError();
474
+ const next: RepositoryChangeSet = { ...set, pullRequest: { ...set.pullRequest, bodySha256 } };
475
+ if (!validateRepositoryChangeSet(next).valid || changeSetDigest(next) !== digest) throw new TypeError("a change set must still validate, under its own digest, once its body is bound");
476
+ return writeReplacing(hubDirectory, CHANGE_SET_STORE_REL, fileName, serializeStoredDocument(next), {
477
+ when: (now) => !now.equals(existing),
478
+ message: "the stored change set changed while its body was being bound",
479
+ whenMissing: true,
480
+ });
481
+ }
482
+
483
+ /**
484
+ * Stores `bundle` under `hubDirectory`/BUNDLE_STORE_REL, named for its own
485
+ * `bundleDigest`. Throws a TypeError, naming no path, id or value, when the
486
+ * bundle does not validate against the apply-bundle contract, when the
487
+ * digest is malformed, or when a store directory segment or the file name is
488
+ * a symbolic link; nothing is written or replaced in those cases. The digest
489
+ * excludes the authorization, the clock, the mode and the verdicts (RFC
490
+ * 12.3), so a bundle whose digest already names a stored file replaces that
491
+ * one file atomically with these bytes, the newest computation; the same
492
+ * bytes again are a no-op. A report bundle never replaces a stored file that
493
+ * verifies as a planned bundle of this digest (a TypeError, naming no path);
494
+ * a planned bundle replaces anything. A bundle with any other digest never
495
+ * touches another file. Superseded computations are not recorded. Returns the
496
+ * file's path.
497
+ */
498
+ export function storeApplyBundle(hubDirectory: string, bundle: ApplyBundle): string {
499
+ const validation = validateApplyBundle(bundle);
500
+ if (!validation.valid) throw new TypeError(`an apply bundle must validate against its contract before it can be stored: ${validation.reason}`);
501
+ assertDigestShape(bundle.bundleDigest);
502
+ const digest = bundle.bundleDigest;
503
+ return writeReplacing(hubDirectory, BUNDLE_STORE_REL, digestFileName(digest), serializeStoredDocument(bundle), bundle.mode === "report" ? { when: (existing) => verifiesAsPlanned(existing, digest), message: "a stored planned apply bundle is never replaced by a report of the same digest" } : undefined);
504
+ }
505
+
506
+ /** Whether `bytes` read back as a valid, digest-verified apply bundle of `digest` whose mode is planned: exactly what a read would return. */
507
+ function verifiesAsPlanned(bytes: Buffer, digest: string): boolean {
508
+ let document: unknown;
509
+ try {
510
+ document = readContractDocument(bytes);
511
+ } catch {
512
+ return false;
513
+ }
514
+ return verifiedBundle(document, digest)?.mode === "planned";
515
+ }
516
+
517
+ /** The document as a bundle when it validates and its digest, recomputed from the plan digest and each computed repository, is `digest` and its own. */
518
+ function verifiedBundle(document: unknown, digest: string): ApplyBundle | null {
519
+ if (!validateApplyBundle(document).valid) return null;
520
+ const bundle = document as ApplyBundle;
521
+ const computed: { readonly id: string; readonly changeSetDigest: string }[] = bundle.repositories.flatMap((entry: ApplyBundleRepository) =>
522
+ "changeSet" in entry ? [{ id: entry.id, changeSetDigest: entry.changeSet }] : [],
523
+ );
524
+ const recomputed = bundleDigest(bundle.plan.digest, computed);
525
+ return recomputed === bundle.bundleDigest && recomputed === digest ? bundle : null;
526
+ }
527
+
528
+ /**
529
+ * Reads the change set stored for `digest` under `hubDirectory`/CHANGE_SET_STORE_REL.
530
+ * `digest` must match `sha256:` and 64 lowercase hex digits before any path
531
+ * is built from it; a malformed digest throws a TypeError naming no value,
532
+ * before touching the filesystem. Otherwise: a missing file, bytes that are
533
+ * not strict JSON, a document that does not validate against the change-set
534
+ * contract, or one whose `changeSetDigest` -- recomputed and read from the
535
+ * document -- does not equal both `digest` and the document's own
536
+ * `changeSetDigest`, all read as `null`. A file can therefore never be read
537
+ * back under a name, or with content, other than its own digest.
538
+ */
539
+ /**
540
+ * Every change set stored under `hubDirectory`/CHANGE_SET_STORE_REL whose file
541
+ * name is 64 lowercase hex digits and `.json`, in directory listing order.
542
+ * Entries that do not read back as a valid stored set are omitted.
543
+ */
544
+ export function listStoredChangeSets(hubDirectory: string): RepositoryChangeSet[] {
545
+ assertHubDirectory(hubDirectory);
546
+ const directory = join(hubDirectory, CHANGE_SET_STORE_REL);
547
+ if (!ensureRealDirectory(hubDirectory, directory, "read")) return [];
548
+ const sets: RepositoryChangeSet[] = [];
549
+ for (const name of readdirSync(directory)) {
550
+ if (!/^[0-9a-f]{64}\.json$/u.test(name)) continue;
551
+ const set = readStoredChangeSet(hubDirectory, `sha256:${name.slice(0, 64)}`);
552
+ if (set !== null) sets.push(set);
553
+ }
554
+ return sets;
555
+ }
556
+
557
+ export function readStoredChangeSet(hubDirectory: string, digest: string): RepositoryChangeSet | null {
558
+ assertDigestShape(digest);
559
+ const document = readStoredBytes(hubDirectory, CHANGE_SET_STORE_REL, digestFileName(digest));
560
+ if (document === null) return null;
561
+ if (!validateRepositoryChangeSet(document).valid) return null;
562
+ const set = document as RepositoryChangeSet;
563
+ const recomputed = changeSetDigest(set);
564
+ if (recomputed !== set.changeSetDigest || recomputed !== digest) return null;
565
+ return set;
566
+ }
567
+
568
+ /**
569
+ * Reads the apply bundle stored for `digest` under `hubDirectory`/BUNDLE_STORE_REL.
570
+ * `digest` must match `sha256:` and 64 lowercase hex digits before any path
571
+ * is built from it; a malformed digest throws a TypeError naming no value,
572
+ * before touching the filesystem. Otherwise: a missing file, bytes that are
573
+ * not strict JSON, a document that does not validate against the
574
+ * apply-bundle contract, or one whose bundle digest -- recomputed from its
575
+ * `plan.digest` and each repository that carries a change set, in the same
576
+ * order applyBundleRuleViolations' A2 rule computes it -- does not equal
577
+ * both `digest` and the document's own `bundleDigest`, all read as `null`.
578
+ */
579
+ export function readStoredApplyBundle(hubDirectory: string, digest: string): ApplyBundle | null {
580
+ assertDigestShape(digest);
581
+ const document = readStoredBytes(hubDirectory, BUNDLE_STORE_REL, digestFileName(digest));
582
+ if (document === null) return null;
583
+ return verifiedBundle(document, digest);
584
+ }