@clossys/launcher 0.3.1 → 0.4.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 (285) hide show
  1. package/README.md +1324 -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 +453 -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 +799 -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 +157 -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 +381 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +738 -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/generated/contract-schema.generated.d.ts +97 -0
  69. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  70. package/dist/generated/contract-schema.generated.js +496 -0
  71. package/dist/generated/contract-schema.generated.js.map +1 -0
  72. package/dist/generated/package-scope.generated.d.ts +6 -0
  73. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  74. package/dist/generated/package-scope.generated.js +10 -0
  75. package/dist/generated/package-scope.generated.js.map +1 -0
  76. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  77. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  78. package/dist/generated/plan-contracts.generated.js +2840 -0
  79. package/dist/generated/plan-contracts.generated.js.map +1 -0
  80. package/dist/host.d.ts.map +1 -1
  81. package/dist/host.js +11 -0
  82. package/dist/host.js.map +1 -1
  83. package/dist/identity.d.ts +15 -0
  84. package/dist/identity.d.ts.map +1 -0
  85. package/dist/identity.js +48 -0
  86. package/dist/identity.js.map +1 -0
  87. package/dist/index.d.ts +34 -5
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +19 -2
  90. package/dist/index.js.map +1 -1
  91. package/dist/inventory-adoption.d.ts +24 -5
  92. package/dist/inventory-adoption.d.ts.map +1 -1
  93. package/dist/inventory-adoption.js +70 -25
  94. package/dist/inventory-adoption.js.map +1 -1
  95. package/dist/inventory-choice.d.ts +40 -0
  96. package/dist/inventory-choice.d.ts.map +1 -0
  97. package/dist/inventory-choice.js +156 -0
  98. package/dist/inventory-choice.js.map +1 -0
  99. package/dist/inventory-contract.d.ts +89 -0
  100. package/dist/inventory-contract.d.ts.map +1 -0
  101. package/dist/inventory-contract.js +121 -0
  102. package/dist/inventory-contract.js.map +1 -0
  103. package/dist/key-editor.d.ts +30 -0
  104. package/dist/key-editor.d.ts.map +1 -0
  105. package/dist/key-editor.js +445 -0
  106. package/dist/key-editor.js.map +1 -0
  107. package/dist/ledger-contract.d.ts +187 -0
  108. package/dist/ledger-contract.d.ts.map +1 -0
  109. package/dist/ledger-contract.js +532 -0
  110. package/dist/ledger-contract.js.map +1 -0
  111. package/dist/ledger-trust.d.ts +90 -0
  112. package/dist/ledger-trust.d.ts.map +1 -0
  113. package/dist/ledger-trust.js +198 -0
  114. package/dist/ledger-trust.js.map +1 -0
  115. package/dist/lockfile-invariants.d.ts +48 -0
  116. package/dist/lockfile-invariants.d.ts.map +1 -0
  117. package/dist/lockfile-invariants.js +375 -0
  118. package/dist/lockfile-invariants.js.map +1 -0
  119. package/dist/lockfile-readers.d.ts +72 -0
  120. package/dist/lockfile-readers.d.ts.map +1 -0
  121. package/dist/lockfile-readers.js +713 -0
  122. package/dist/lockfile-readers.js.map +1 -0
  123. package/dist/lockfile-regen.d.ts +106 -0
  124. package/dist/lockfile-regen.d.ts.map +1 -0
  125. package/dist/lockfile-regen.js +760 -0
  126. package/dist/lockfile-regen.js.map +1 -0
  127. package/dist/lockfile-tool-env.d.ts +29 -0
  128. package/dist/lockfile-tool-env.d.ts.map +1 -0
  129. package/dist/lockfile-tool-env.js +111 -0
  130. package/dist/lockfile-tool-env.js.map +1 -0
  131. package/dist/materialize.d.ts +113 -0
  132. package/dist/materialize.d.ts.map +1 -0
  133. package/dist/materialize.js +840 -0
  134. package/dist/materialize.js.map +1 -0
  135. package/dist/observe-repository.d.ts +90 -0
  136. package/dist/observe-repository.d.ts.map +1 -0
  137. package/dist/observe-repository.js +1367 -0
  138. package/dist/observe-repository.js.map +1 -0
  139. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  140. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  141. package/dist/plan-bundle-setup-fixture.js +167 -0
  142. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  143. package/dist/plan-bundle.d.ts +250 -0
  144. package/dist/plan-bundle.d.ts.map +1 -0
  145. package/dist/plan-bundle.js +827 -0
  146. package/dist/plan-bundle.js.map +1 -0
  147. package/dist/plan-command.d.ts +29 -0
  148. package/dist/plan-command.d.ts.map +1 -0
  149. package/dist/plan-command.js +493 -0
  150. package/dist/plan-command.js.map +1 -0
  151. package/dist/plan-contract.d.ts +153 -0
  152. package/dist/plan-contract.d.ts.map +1 -0
  153. package/dist/plan-contract.js +61 -0
  154. package/dist/plan-contract.js.map +1 -0
  155. package/dist/plan-digest.d.ts +25 -0
  156. package/dist/plan-digest.d.ts.map +1 -0
  157. package/dist/plan-digest.js +106 -0
  158. package/dist/plan-digest.js.map +1 -0
  159. package/dist/plan-rules.d.ts +23 -0
  160. package/dist/plan-rules.d.ts.map +1 -0
  161. package/dist/plan-rules.js +177 -0
  162. package/dist/plan-rules.js.map +1 -0
  163. package/dist/planned-bundle.d.ts +20 -0
  164. package/dist/planned-bundle.d.ts.map +1 -0
  165. package/dist/planned-bundle.js +191 -0
  166. package/dist/planned-bundle.js.map +1 -0
  167. package/dist/product-repository.d.ts +4 -0
  168. package/dist/product-repository.d.ts.map +1 -1
  169. package/dist/product-repository.js +9 -1
  170. package/dist/product-repository.js.map +1 -1
  171. package/dist/provenance-gate.d.ts +48 -0
  172. package/dist/provenance-gate.d.ts.map +1 -0
  173. package/dist/provenance-gate.js +324 -0
  174. package/dist/provenance-gate.js.map +1 -0
  175. package/dist/pull-request-body.d.ts +45 -0
  176. package/dist/pull-request-body.d.ts.map +1 -0
  177. package/dist/pull-request-body.js +232 -0
  178. package/dist/pull-request-body.js.map +1 -0
  179. package/dist/registry-snapshot.d.ts +141 -0
  180. package/dist/registry-snapshot.d.ts.map +1 -0
  181. package/dist/registry-snapshot.js +483 -0
  182. package/dist/registry-snapshot.js.map +1 -0
  183. package/dist/release-age-edit.d.ts +52 -0
  184. package/dist/release-age-edit.d.ts.map +1 -0
  185. package/dist/release-age-edit.js +413 -0
  186. package/dist/release-age-edit.js.map +1 -0
  187. package/dist/root-entries.d.ts +36 -0
  188. package/dist/root-entries.d.ts.map +1 -0
  189. package/dist/root-entries.js +80 -0
  190. package/dist/root-entries.js.map +1 -0
  191. package/dist/setup-template-scripts.d.ts +34 -0
  192. package/dist/setup-template-scripts.d.ts.map +1 -0
  193. package/dist/setup-template-scripts.js +557 -0
  194. package/dist/setup-template-scripts.js.map +1 -0
  195. package/dist/setup-templates.d.ts +54 -0
  196. package/dist/setup-templates.d.ts.map +1 -0
  197. package/dist/setup-templates.js +427 -0
  198. package/dist/setup-templates.js.map +1 -0
  199. package/dist/skills.d.ts +34 -1
  200. package/dist/skills.d.ts.map +1 -1
  201. package/dist/skills.js +129 -17
  202. package/dist/skills.js.map +1 -1
  203. package/dist/status.d.ts +63 -0
  204. package/dist/status.d.ts.map +1 -0
  205. package/dist/status.js +539 -0
  206. package/dist/status.js.map +1 -0
  207. package/dist/types.d.ts +151 -13
  208. package/dist/types.d.ts.map +1 -1
  209. package/package.json +4 -4
  210. package/skeleton/README.md +14 -9
  211. package/skeleton/package.json +2 -1
  212. package/skill/SKILL.md +23 -7
  213. package/skill-catalogue/advisor/SKILL.md +59 -6
  214. package/skill-catalogue/architect/SKILL.md +2 -2
  215. package/skill-catalogue/bouncer/SKILL.md +2 -2
  216. package/skill-catalogue/builder/SKILL.md +2 -2
  217. package/skill-catalogue/butler/SKILL.md +2 -2
  218. package/skill-catalogue/controller/SKILL.md +2 -2
  219. package/skill-catalogue/customer/SKILL.md +2 -2
  220. package/skill-catalogue/designer/SKILL.md +4 -2
  221. package/skill-catalogue/giver/SKILL.md +2 -2
  222. package/skill-catalogue/influencer/SKILL.md +2 -2
  223. package/skill-catalogue/inspector/SKILL.md +2 -2
  224. package/skill-catalogue/integrator/SKILL.md +2 -2
  225. package/skill-catalogue/keeper/SKILL.md +2 -2
  226. package/skill-catalogue/launcher/SKILL.md +23 -7
  227. package/skill-catalogue/locksmith/SKILL.md +2 -2
  228. package/skill-catalogue/messenger/SKILL.md +2 -2
  229. package/skill-catalogue/observer/SKILL.md +2 -2
  230. package/skill-catalogue/publisher/SKILL.md +2 -2
  231. package/skill-catalogue/starter/SKILL.md +3 -2
  232. package/skill-catalogue/strategist/SKILL.md +12 -4
  233. package/skill-catalogue/writer/SKILL.md +2 -2
  234. package/src/admission-fixture.ts +572 -0
  235. package/src/admission.ts +816 -0
  236. package/src/agents-guide.ts +29 -0
  237. package/src/apply-command-options.check.ts +27 -0
  238. package/src/apply-plan-cli.ts +454 -14
  239. package/src/apply-plan.ts +112 -124
  240. package/src/apply-step-fixture.ts +236 -0
  241. package/src/apply-store.ts +584 -0
  242. package/src/approval-sheet.ts +164 -0
  243. package/src/body-command.ts +162 -0
  244. package/src/change-set-contract.ts +937 -0
  245. package/src/change-set-digest.ts +70 -0
  246. package/src/check-cli.ts +14 -3
  247. package/src/cli.ts +90 -22
  248. package/src/core.ts +973 -275
  249. package/src/dry-materialize.ts +353 -0
  250. package/src/generated/contract-schema.generated.ts +520 -0
  251. package/src/generated/package-scope.generated.ts +10 -0
  252. package/src/generated/plan-contracts.generated.ts +2840 -0
  253. package/src/host.ts +10 -0
  254. package/src/identity.ts +51 -0
  255. package/src/index.ts +72 -3
  256. package/src/inventory-adoption.ts +107 -29
  257. package/src/inventory-choice.ts +172 -0
  258. package/src/inventory-contract.ts +166 -0
  259. package/src/key-editor.ts +446 -0
  260. package/src/ledger-contract.ts +637 -0
  261. package/src/ledger-trust.ts +267 -0
  262. package/src/lockfile-invariants.ts +421 -0
  263. package/src/lockfile-readers.ts +749 -0
  264. package/src/lockfile-regen.ts +851 -0
  265. package/src/lockfile-tool-env.ts +131 -0
  266. package/src/materialize.ts +886 -0
  267. package/src/observe-repository.ts +1365 -0
  268. package/src/plan-bundle-setup-fixture.ts +200 -0
  269. package/src/plan-bundle.ts +964 -0
  270. package/src/plan-command.ts +509 -0
  271. package/src/plan-contract.ts +179 -0
  272. package/src/plan-digest.ts +102 -0
  273. package/src/plan-rules.ts +188 -0
  274. package/src/planned-bundle.ts +211 -0
  275. package/src/product-repository.ts +10 -1
  276. package/src/provenance-gate.ts +352 -0
  277. package/src/pull-request-body.ts +261 -0
  278. package/src/registry-snapshot.ts +534 -0
  279. package/src/release-age-edit.ts +430 -0
  280. package/src/root-entries.ts +81 -0
  281. package/src/setup-template-scripts.ts +571 -0
  282. package/src/setup-templates.ts +471 -0
  283. package/src/skills.ts +161 -18
  284. package/src/status.ts +557 -0
  285. package/src/types.ts +148 -13
@@ -0,0 +1,164 @@
1
+ // The approval sheet (issue #1178, RFC section 12.7): what a client reads
2
+ // before approving an apply bundle, rendered from ids and digests only. It is
3
+ // a pure function of a bundle and the change sets the bundle names -- it reads
4
+ // no file, runs no process and reads no clock -- so the same inputs always
5
+ // give the same bytes.
6
+ //
7
+ // The sheet never carries brief or plan prose, the `texts` a set stores, a
8
+ // package.json key's value, or any file's contents (V5). Every value it does
9
+ // print is checked against a strict pattern first, and a value that fails one,
10
+ // or holds one of the characters that could make a row read as something
11
+ // else, refuses the whole sheet. A refusal is a fixed token and echoes
12
+ // nothing: a bundle can come from a plan a hostile author wrote, and the
13
+ // sheet is read by people and agents.
14
+ //
15
+ // The sheet asks for one approval: the line `Approve subjectDigest: <bundle
16
+ // digest>`. It computes and records no approval; that is the plan's decision,
17
+ // made in the hub.
18
+
19
+ import { validateApplyBundle, validateRepositoryChangeSet } from "./change-set-contract.js";
20
+ import type { ApplyBundle, ApplyBundleRepository, ChangeSetItem, RepositoryChangeSet } from "./change-set-contract.js";
21
+
22
+ /** Why a sheet was not rendered. The token is the whole message. */
23
+ export type ApprovalSheetRefusal = "bundle-invalid" | "change-set-invalid" | "set-mismatch" | "value-unsafe";
24
+
25
+ export class ApprovalSheetError extends Error {
26
+ constructor(readonly token: ApprovalSheetRefusal) {
27
+ super(token);
28
+ this.name = "ApprovalSheetError";
29
+ }
30
+ }
31
+
32
+ export interface ApprovalSheetInput {
33
+ readonly bundle: ApplyBundle;
34
+ /** The change set of every bundle repository that has one, in any order. */
35
+ readonly changeSets: readonly RepositoryChangeSet[];
36
+ }
37
+
38
+ const REPOSITORY = /^[A-Za-z0-9._-]{1,100}(?:\/[A-Za-z0-9._-]{1,100})?$/u;
39
+ const TOKEN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
40
+ const CHECK = /^V[1-9]$/u;
41
+ const DIGEST = /^sha256:[0-9a-f]{64}$/u;
42
+ const ITEM = /^[A-Za-z0-9._@/:~+-]{1,300}$/u;
43
+ const PATH = /^[A-Za-z0-9._@/~+-]{1,300}$/u;
44
+ const PACKAGE_NAME = /^(?:@[a-z0-9._~-]{1,100}\/)?[a-z0-9._~-]{1,100}$/u;
45
+ const VERSION = /^[0-9A-Za-z.+-]{1,100}$/u;
46
+ const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/u;
47
+ const FORBIDDEN = /[<>`|\r\n]/u;
48
+
49
+ function safe(value: unknown, pattern: RegExp): string {
50
+ if (typeof value !== "string" || FORBIDDEN.test(value) || !pattern.test(value)) throw new ApprovalSheetError("value-unsafe");
51
+ return value;
52
+ }
53
+
54
+ /** The first 12 hex digits of a digest, as RFC 12.7 names the Digest column. */
55
+ function short(digest: string): string {
56
+ return safe(digest, DIGEST).slice("sha256:".length, "sha256:".length + 12);
57
+ }
58
+
59
+ function isPackageAct(item: ChangeSetItem): item is Extract<ChangeSetItem, { act: "install" | "pin-starter" }> {
60
+ return item.act === "install" || item.act === "pin-starter";
61
+ }
62
+
63
+ function change(set: RepositoryChangeSet, item: ChangeSetItem): string {
64
+ if (isPackageAct(item)) return `${safe(item.package.name, PACKAGE_NAME)}@${safe(item.package.version, VERSION)}`;
65
+ const count = set.files.filter((file) => file.item === item.id).length;
66
+ return `${count} ${count === 1 ? "path" : "paths"}`;
67
+ }
68
+
69
+ function yesNo(value: unknown): "yes" | "no" {
70
+ if (value === true) return "yes";
71
+ if (value === false) return "no";
72
+ throw new ApprovalSheetError("value-unsafe");
73
+ }
74
+
75
+ function row(cells: readonly string[]): string {
76
+ return `| ${cells.join(" | ")} |`;
77
+ }
78
+
79
+ function checkedBundle(input: ApprovalSheetInput): { bundle: ApplyBundle; sets: Map<string, RepositoryChangeSet> } {
80
+ const { bundle, changeSets } = input;
81
+ if (!validateApplyBundle(bundle).valid) throw new ApprovalSheetError("bundle-invalid");
82
+ const sets = new Map<string, RepositoryChangeSet>();
83
+ for (const set of changeSets) {
84
+ if (!validateRepositoryChangeSet(set).valid) throw new ApprovalSheetError("change-set-invalid");
85
+ if (set.bundle !== bundle.bundleDigest || set.planDigest !== bundle.plan.digest) throw new ApprovalSheetError("set-mismatch");
86
+ if (sets.has(set.changeSetDigest)) throw new ApprovalSheetError("set-mismatch");
87
+ sets.set(set.changeSetDigest, set);
88
+ }
89
+ const held = bundle.repositories.flatMap((entry) => ("changeSet" in entry ? [{ id: entry.id, changeSetDigest: entry.changeSet }] : []));
90
+ // Exactly the sets the bundle names: none missing, none extra, each under its own repository.
91
+ if (held.length !== sets.size) throw new ApprovalSheetError("set-mismatch");
92
+ for (const entry of held) {
93
+ const set = sets.get(entry.changeSetDigest);
94
+ if (set === undefined || set.repository.id !== entry.id) throw new ApprovalSheetError("set-mismatch");
95
+ }
96
+ return { bundle, sets };
97
+ }
98
+
99
+ function repositoryRows(entry: ApplyBundleRepository, sets: ReadonlyMap<string, RepositoryChangeSet>): string[] {
100
+ if (!("changeSet" in entry)) return [];
101
+ const set = sets.get(entry.changeSet)!;
102
+ const repository = safe(entry.id, REPOSITORY);
103
+ const digest = short(set.changeSetDigest);
104
+ return set.items.map((item) => row([repository, safe(item.act, TOKEN), safe(item.id, ITEM), change(set, item), digest]));
105
+ }
106
+
107
+ /**
108
+ * Renders the approval sheet of `bundle`, LF-terminated. Throws an
109
+ * ApprovalSheetError, a fixed token that names no value, when the bundle or a
110
+ * set does not validate (the validators recompute each digest, so one that
111
+ * does not recompute is refused there), the sets are not exactly
112
+ * the bundle's, or any printed value fails its pattern.
113
+ */
114
+ export function renderApprovalSheet(input: ApprovalSheetInput): string {
115
+ const { bundle, sets } = checkedBundle(input);
116
+ const authorization =
117
+ bundle.authorization === null
118
+ ? "none"
119
+ : `plan ${safe(bundle.authorization.planDigest, DIGEST)} expires ${safe(bundle.authorization.expiresAt, DATE_TIME)}`;
120
+ const lines: string[] = [
121
+ "Clossys apply plan: approval sheet",
122
+ `Mode: ${safe(bundle.mode, TOKEN)}`,
123
+ `Plan digest: ${safe(bundle.plan.digest, DIGEST)}`,
124
+ `Plan committed: ${yesNo(bundle.plan.committed)}`,
125
+ `Bundle digest: ${safe(bundle.bundleDigest, DIGEST)}`,
126
+ `Authorization: ${authorization}`,
127
+ `Approve subjectDigest: ${safe(bundle.bundleDigest, DIGEST)}`,
128
+ "",
129
+ row(["Repository", "Kind", "Item", "Change", "Digest"]),
130
+ row(["---", "---", "---", "---", "---"]),
131
+ ...bundle.repositories.flatMap((entry) => repositoryRows(entry, sets)),
132
+ ];
133
+
134
+ const deferred: string[] = [];
135
+ const refused: string[] = [];
136
+ const skipped: string[] = [];
137
+ const unsatisfied: string[] = [];
138
+ for (const entry of bundle.repositories) {
139
+ const id = safe(entry.id, REPOSITORY);
140
+ if (!("changeSet" in entry)) {
141
+ skipped.push(`- ${id} ${safe(entry.verdict, TOKEN)} ${safe(entry.reason, TOKEN)}`);
142
+ continue;
143
+ }
144
+ const set = sets.get(entry.changeSet)!;
145
+ for (const item of set.deferred) deferred.push(`- ${id} ${safe(item.planItem, ITEM)} ${safe(item.reason, TOKEN)}`);
146
+ for (const item of set.refused) {
147
+ const where = "path" in item ? safe(item.path, PATH) : `${safe(item.file, PATH)}#${safe(item.pointer, PATH)}`;
148
+ refused.push(`- ${id} ${where} ${safe(item.reason, TOKEN)}`);
149
+ }
150
+ for (const check of entry.checks) {
151
+ if (check.verdict === "satisfied") continue;
152
+ unsatisfied.push(`- ${id} ${safe(check.check, CHECK)} ${safe(check.verdict, TOKEN)}${check.rule === undefined ? "" : ` ${safe(check.rule, TOKEN)}`}`);
153
+ }
154
+ }
155
+ for (const [title, entries] of [
156
+ ["Deferred", deferred],
157
+ ["Refused", refused],
158
+ ["Skipped", skipped],
159
+ ["Checks not satisfied", unsatisfied],
160
+ ] as const) {
161
+ if (entries.length > 0) lines.push("", `${title}:`, ...entries);
162
+ }
163
+ return `${lines.join("\n")}\n`;
164
+ }
@@ -0,0 +1,162 @@
1
+ // `launcher-apply-plan body` (issue #1716, RFC apply-approved-plan section 6): the body of the pull request for one stored
2
+ // change set, bound by what the hub decides now, with the hash of exactly the printed bytes recorded as the set's
3
+ // `pullRequest.bodySha256`. An approval must bind exactly the body that is opened: any ambiguity refuses, and a refusal prints
4
+ // nothing on standard output and one line of fixed tokens on standard error.
5
+ //
6
+ // B1. The binding is what runPreconditions decides now, from the hub. It is never an option, an argument or a stored value; a
7
+ // refusal of the preconditions keeps its own token and its own exit code.
8
+ // B2. A stored planned bundle that lists this set holds a binding for it; that binding must be exactly B1's, else
9
+ // `binding-mismatch`. A bundle that is absent, not planned, or does not list the set records no binding to hold it to.
10
+ // B3. The body is renderPullRequest over the stored set (read again here, so what is rendered is what is stored), B1's binding,
11
+ // the task record and the superseded numbers: nothing else.
12
+ // B4. `bodySha256` is the SHA-256 of exactly the bytes printed, recorded after rendering by bindChangeSetBody, which replaces
13
+ // the one stored file atomically, refuses a symbolic link, and changes nothing else. Another recorded value is
14
+ // `body-bound`; the same value is a no-op.
15
+ // B5. `--supersedes` numbers are distinct positive safe integers other than the task record (the renderer refuses the rest as
16
+ // `supersedes-invalid`), and the set must have another stored set of its repository to replace, else `supersedes-unfounded`.
17
+ // B6. Every refusal is empty standard output and one standard error line naming a fixed token, never an argument, a path, an id
18
+ // or tool output.
19
+
20
+ import { createHash } from "node:crypto";
21
+ import { realpathSync } from "node:fs";
22
+ import type { ReadinessRunner } from "./admission.js";
23
+ import { BodyBoundError, bindChangeSetBody, readStoredApplyBundle, readStoredChangeSet } from "./apply-store.js";
24
+ import type { ApprovalBinding, RepositoryChangeSet } from "./change-set-contract.js";
25
+ import { validateRepositoryChangeSet } from "./change-set-contract.js";
26
+ import { changeSetDigest } from "./change-set-digest.js";
27
+ import { runPreconditions } from "./materialize.js";
28
+ import { renderPullRequest } from "./pull-request-body.js";
29
+ import { safeReason } from "./status.js";
30
+
31
+ /** What `body` reads: no member can carry a binding. */
32
+ export interface BodyInput {
33
+ readonly clone: string;
34
+ readonly hub: string;
35
+ readonly set: RepositoryChangeSet;
36
+ readonly heldChangeSets: readonly RepositoryChangeSet[];
37
+ /** The number of the task-record issue in the target repository. */
38
+ readonly taskRecord: number;
39
+ /** The numbers of the pull requests of older change sets this one replaces. */
40
+ readonly supersedes?: readonly number[];
41
+ readonly now?: () => Date;
42
+ readonly runReadiness?: ReadinessRunner;
43
+ }
44
+
45
+ export type BodyOutcome =
46
+ | { readonly exitCode: 0; readonly body: string }
47
+ | { readonly exitCode: 1 | 2; readonly reason: string };
48
+
49
+ export class BodyUsageError extends Error {}
50
+
51
+ export const BODY_USAGE = "launcher-apply-plan body --repo <id> --task-record <n> [--supersedes <n>]...";
52
+
53
+ export interface BodyArgs {
54
+ readonly help: false;
55
+ readonly id: string;
56
+ readonly taskRecord: number;
57
+ readonly supersedes: readonly number[];
58
+ }
59
+
60
+ const REPO_ID_SHAPE = /^[^/]+\/[^/]+$/u;
61
+ const WHOLE_NUMBER = /^(?:0|[1-9][0-9]{0,15})$/u;
62
+
63
+ function wholeNumber(text: string | undefined): number {
64
+ if (text === undefined || !WHOLE_NUMBER.test(text)) throw new BodyUsageError();
65
+ const value = Number(text);
66
+ if (!Number.isSafeInteger(value)) throw new BodyUsageError();
67
+ return value;
68
+ }
69
+
70
+ /**
71
+ * Reads `--repo`, `--task-record` and any number of `--supersedes`. A usage error carries no message: nothing the caller typed is
72
+ * ever echoed. A number is digits only, so a sign, an exponent, a space or a leading zero is a usage error; whether it is a
73
+ * positive number that is not the task record is the renderer's judgment (`supersedes-invalid`, `task-record-invalid`).
74
+ */
75
+ export function parseBodyArgs(argv: readonly string[]): { help: true } | BodyArgs {
76
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) return { help: true };
77
+ let id: string | undefined;
78
+ let taskRecord: string | undefined;
79
+ const supersedes: number[] = [];
80
+ for (let index = 0; index < argv.length; index += 2) {
81
+ const name = argv[index];
82
+ const value = argv[index + 1];
83
+ if (value === undefined) throw new BodyUsageError();
84
+ if (name === "--repo" && id === undefined) id = value;
85
+ else if (name === "--task-record" && taskRecord === undefined) taskRecord = value;
86
+ else if (name === "--supersedes") supersedes.push(wholeNumber(value));
87
+ else throw new BodyUsageError();
88
+ }
89
+ if (id === undefined || taskRecord === undefined) throw new BodyUsageError();
90
+ const name = id.slice(id.indexOf("/") + 1);
91
+ if (!REPO_ID_SHAPE.test(id) || name === "." || name === "..") throw new BodyUsageError();
92
+ return { help: false, id, taskRecord: wholeNumber(taskRecord), supersedes };
93
+ }
94
+
95
+ const refuse = (reason: string): BodyOutcome => ({ exitCode: 1, reason });
96
+ const indeterminate = (reason: string): BodyOutcome => ({ exitCode: 2, reason });
97
+
98
+ const sameBinding = (left: ApprovalBinding, right: ApprovalBinding): boolean =>
99
+ left.kind === right.kind &&
100
+ left.subjectDigest === right.subjectDigest &&
101
+ (left.kind !== "admitted" || (right.kind === "admitted" && left.setupChangeSet === right.setupChangeSet));
102
+
103
+ /** Whether the hub holds a set of this repository, other than this one, that verifies under its own digest. */
104
+ function hasAnotherSet(set: RepositoryChangeSet, held: readonly RepositoryChangeSet[]): boolean {
105
+ return held.some((other) => {
106
+ if (other.repository.id !== set.repository.id || other.changeSetDigest === set.changeSetDigest) return false;
107
+ return validateRepositoryChangeSet(other).valid && changeSetDigest(other) === other.changeSetDigest;
108
+ });
109
+ }
110
+
111
+ /** Renders, binds and returns the body; never throws. */
112
+ export async function bodyRepository(input: BodyInput): Promise<BodyOutcome> {
113
+ try {
114
+ const hub = realpathSync(input.hub);
115
+ let stored: RepositoryChangeSet | null;
116
+ try {
117
+ stored = readStoredChangeSet(hub, input.set.changeSetDigest);
118
+ } catch {
119
+ return indeterminate("store-unreadable");
120
+ }
121
+ if (stored === null || stored.repository.id !== input.set.repository.id) return indeterminate("change-set-absent");
122
+
123
+ // B1
124
+ const pre = await runPreconditions(input.clone, input.hub, stored, input.heldChangeSets, { now: input.now, runReadiness: input.runReadiness });
125
+ if ("exitCode" in pre) return pre.exitCode === 1 ? refuse(safeReason(pre.reason)) : indeterminate(safeReason(pre.reason));
126
+
127
+ // B2
128
+ let bundle;
129
+ try {
130
+ bundle = readStoredApplyBundle(hub, stored.bundle);
131
+ } catch {
132
+ return indeterminate("bundle-unreadable");
133
+ }
134
+ if (bundle !== null && bundle.mode === "planned") {
135
+ for (const entry of bundle.repositories) {
136
+ if (!("changeSet" in entry) || entry.changeSet !== stored.changeSetDigest) continue;
137
+ const recorded = "binding" in entry ? entry.binding : undefined;
138
+ if (entry.id !== stored.repository.id || recorded === undefined || !sameBinding(recorded, pre.binding)) return refuse("binding-mismatch");
139
+ }
140
+ }
141
+
142
+ // B5
143
+ const supersedes = input.supersedes ?? [];
144
+ if (supersedes.length > 0 && !hasAnotherSet(stored, input.heldChangeSets)) return refuse("supersedes-unfounded");
145
+
146
+ // B3
147
+ const rendered = renderPullRequest({ set: stored, binding: pre.binding, taskRecord: input.taskRecord, ...(supersedes.length === 0 ? {} : { supersedes }) });
148
+ if (rendered.state === "refused") return refuse(safeReason(rendered.reason));
149
+
150
+ // B4: the hash of exactly the bytes that will be printed.
151
+ const hash = `sha256:${createHash("sha256").update(Buffer.from(rendered.body, "utf8")).digest("hex")}`;
152
+ if (hash !== rendered.bodySha256) return refuse("render-failed");
153
+ try {
154
+ bindChangeSetBody(hub, stored.changeSetDigest, hash);
155
+ } catch (cause) {
156
+ return cause instanceof BodyBoundError ? refuse("body-bound") : indeterminate("store-failed");
157
+ }
158
+ return { exitCode: 0, body: rendered.body };
159
+ } catch {
160
+ return indeterminate("body-failed");
161
+ }
162
+ }