@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,29 @@
1
+ // The Launcher-owned guide the apply flow writes as AGENTS_GUIDE_PATH (RFC apply-approved-plan D30). Pure: no node builtin, no
2
+ // clock, no file read. The text is one constant -- no plan text, repository name or client detail -- so every repository gets the
3
+ // same bytes, and ownership is the installed-state ledger's digest of the whole file (D10): no marker sits inside the file.
4
+
5
+ /** Where the guide is written. */
6
+ export const AGENTS_GUIDE_PATH = "clossys/AGENTS.md";
7
+
8
+ /** The skill paths the guide carves out: the composed skills and the discovery links to them. */
9
+ export const CLOSSYS_SKILL_PATTERNS: readonly string[] = [".agents/skills/clossys-*", ".claude/skills/clossys-*", ".cursor/skills/clossys-*"];
10
+
11
+ /** The guide's bytes: LF line endings and one final line feed. */
12
+ export const AGENTS_GUIDE_TEXT = `# clossys/ guide
13
+
14
+ This directory and the \`clossys-*\` skills are owned by the Launcher. The Launcher writes them from an approved plan and checks
15
+ them byte for byte.
16
+
17
+ - Do not edit, rename or duplicate anything under \`clossys/\`.
18
+ - Do not edit, rename or duplicate a \`clossys-*\` skill. They are at:
19
+ ${CLOSSYS_SKILL_PATTERNS.map((pattern) => ` - \`${pattern}\``).join("\n")}
20
+ - This repository's own skill policy carves out that namespace: a rule there about editing, renaming or adding skills does not
21
+ apply to a path that matches one of the patterns above.
22
+ - A change to any of them goes through a new approved plan, never a hand edit.
23
+ - Everything outside \`clossys/\` and those patterns stays under this repository's own rules.
24
+ `;
25
+
26
+ /** True only when the text is exactly the guide: a changed byte, a line-ending change or an added line is false. */
27
+ export function verifyAgentsGuide(text: string): boolean {
28
+ return text === AGENTS_GUIDE_TEXT;
29
+ }
@@ -0,0 +1,27 @@
1
+ // Compile-time assertions (checked by tsc, never run): no command option, and
2
+ // no input of plan, materialize, verify or body, can carry an approval binding. Admission
3
+ // computes the binding from the hub (#1178); a caller cannot supply one.
4
+
5
+ import type { ApplyCommandOptions } from "./apply-plan-cli.js";
6
+ import type { BodyInput } from "./body-command.js";
7
+ import type { RepositoryChangeSet } from "./change-set-contract.js";
8
+ import type { MaterializeInput, VerifyInput } from "./materialize.js";
9
+ import type { PlanCommandOptions } from "./plan-command.js";
10
+
11
+ const forged = { kind: "approved", subjectDigest: `sha256:${"f".repeat(64)}` } as const;
12
+ const set = undefined as unknown as RepositoryChangeSet;
13
+
14
+ // @ts-expect-error ApplyCommandOptions has no binding key
15
+ export const commandOptions: ApplyCommandOptions = { binding: forged };
16
+ // @ts-expect-error MaterializeInput has no binding key
17
+ export const materializeInput: MaterializeInput = { clone: "", hub: "", set, texts: {}, binding: forged };
18
+ // @ts-expect-error VerifyInput has no binding key
19
+ export const verifyInput: VerifyInput = { clone: "", hub: "", set, binding: forged };
20
+ // @ts-expect-error BodyInput has no binding key
21
+ export const bodyInput: BodyInput = { clone: "", hub: "", set, heldChangeSets: [], taskRecord: 1, binding: forged };
22
+ // @ts-expect-error PlanCommandOptions has no binding key
23
+ export const planOptions: PlanCommandOptions = { binding: forged };
24
+ // @ts-expect-error PlanCommandOptions has no approval key
25
+ export const planApproval: PlanCommandOptions = { approval: forged };
26
+ // @ts-expect-error PlanCommandOptions has no subjectDigest key
27
+ export const planSubject: PlanCommandOptions = { subjectDigest: forged.subjectDigest };
@@ -1,21 +1,56 @@
1
1
  #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync, statSync } from "node:fs";
4
+ import { realpathSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
2
6
  import { isDirectInvocation } from "./cli.js";
7
+ import { ContractDocumentError, readContractDocument } from "./generated/contract-schema.generated.js";
3
8
  import { createNodeHost } from "./host.js";
4
9
  import { applyEngagementBrief, validateAdvisorPlan, validateEngagementBrief, type AdvisorPlan, type EngagementBrief } from "./apply-plan.js";
10
+ import {
11
+ MAX_RESPONSE_BYTES,
12
+ REGISTRY_SNAPSHOT_REL,
13
+ RegistrySnapshotError,
14
+ requestedPackageNames,
15
+ takeRegistrySnapshot,
16
+ writeRegistrySnapshot,
17
+ type FetchSnapshotOptions,
18
+ } from "./registry-snapshot.js";
19
+ import { listStoredChangeSets } from "./apply-store.js";
20
+ import { planMain } from "./plan-command.js";
21
+ import { BODY_USAGE, BodyUsageError, bodyRepository, parseBodyArgs } from "./body-command.js";
22
+ import { materializeRepository, verifyRepository, type ApplyStepResult } from "./materialize.js";
23
+ import { formatStatus, statusRepository, type StatusPorts } from "./status.js";
24
+ import type { ReadinessRunner } from "./admission.js";
25
+ import type { RepositoryChangeSet } from "./change-set-contract.js";
26
+ import type { LockfileSpawn } from "./lockfile-regen.js";
5
27
 
6
28
  export const APPLY_PLAN_USAGE = `Usage: launcher-apply-plan --plan <plan.json> --brief <brief.json> --repo <directory>
29
+ launcher-apply-plan plan [--help]
30
+ launcher-apply-plan materialize --repo <id>
31
+ launcher-apply-plan verify --repo <id>
32
+ launcher-apply-plan status --repo <id>
33
+ launcher-apply-plan body --repo <id> --task-record <n> [--supersedes <n>]...
34
+ launcher-apply-plan snapshot --request <file> [--out <file>]
35
+
36
+ The plan subcommand is described by launcher-apply-plan plan --help, and the
37
+ snapshot subcommand by launcher-apply-plan snapshot --help.
7
38
 
8
39
  Writes clossys/brief.json into <directory> from the given brief, once the
9
40
  given plan's most recent decision is "approved". Refuses, and writes
10
- nothing, otherwise.
41
+ nothing, otherwise. This is the brief-only path: it does not check what an
42
+ approval binds, so it accepts an approval with or without a subjectDigest.
11
43
 
12
44
  Deterministic mechanics only: this does not decide whether a plan should be
13
45
  approved (that is Advisor's job) and does not compute the brief's content
14
- (that is @clossys/advisor's EngagementBrief, #1193) -- it validates the
15
- exact shapes recorded on issue #1175 and writes the one file.
46
+ (that is @clossys/advisor's EngagementBrief) -- it validates both files
47
+ against the same plan and brief contracts Advisor uses, refusing any field
48
+ those contracts do not declare, writes the one file, and prints the plan's
49
+ canonical digest.
16
50
 
17
51
  Exit codes: 0 = applied, 1 = refused (not approved, or a shape does not
18
- validate), 2 = a given file could not be read as JSON.`;
52
+ validate), 2 = a given file could not be read as strict JSON (unreadable,
53
+ not valid UTF-8, not valid JSON, or an object repeats a key).`;
19
54
 
20
55
  export class ApplyPlanInputError extends Error {}
21
56
 
@@ -39,13 +74,23 @@ function parseArgs(argv: readonly string[]): { help: boolean; planPath?: string;
39
74
  return { help: false, planPath, briefPath, repoDirectory };
40
75
  }
41
76
 
42
- function readJson(readText: (path: string) => string | null, path: string, label: string): unknown {
43
- const raw = readText(path);
44
- if (raw === null) throw new ApplyPlanInputError(`${label} could not be read: ${path}`);
77
+ /**
78
+ * Reads a plan or brief file as strict JSON (#1475): invalid UTF-8, a JSON
79
+ * syntax error, or an object that repeats a key at any depth is refused, so
80
+ * the value validated and digested is exactly the one a reader of the file
81
+ * sees.
82
+ */
83
+ function readJson(path: string, label: string): unknown {
84
+ let bytes: Uint8Array;
45
85
  try {
46
- return JSON.parse(raw);
86
+ bytes = readFileSync(path);
47
87
  } catch {
48
- throw new ApplyPlanInputError(`${label} is not valid JSON: ${path}`);
88
+ throw new ApplyPlanInputError(`${label} could not be read: ${path}`);
89
+ }
90
+ try {
91
+ return readContractDocument(bytes);
92
+ } catch (cause) {
93
+ throw new ApplyPlanInputError(`${label} ${cause instanceof Error ? cause.message : String(cause)}: ${path}`);
49
94
  }
50
95
  }
51
96
 
@@ -58,8 +103,8 @@ export function main(argv: readonly string[], host: ReturnType<typeof createNode
58
103
  let planRaw: unknown;
59
104
  let briefRaw: unknown;
60
105
  try {
61
- planRaw = readJson(host.readText, parsed.planPath as string, "--plan");
62
- briefRaw = readJson(host.readText, parsed.briefPath as string, "--brief");
106
+ planRaw = readJson(parsed.planPath as string, "--plan");
107
+ briefRaw = readJson(parsed.briefPath as string, "--brief");
63
108
  } catch (cause) {
64
109
  console.error(`launcher-apply-plan: ${cause instanceof Error ? cause.message : String(cause)}`);
65
110
  return 2;
@@ -80,15 +125,410 @@ export function main(argv: readonly string[], host: ReturnType<typeof createNode
80
125
  return 1;
81
126
  }
82
127
  console.log(`wrote ${result.path}`);
128
+ console.log(`plan digest ${result.planDigest}`);
83
129
  return 0;
84
130
  }
85
131
 
86
- function run(): void {
132
+ export const SNAPSHOT_USAGE = `Usage: launcher-apply-plan snapshot --request <file> [--out <file>]
133
+
134
+ Takes the registry snapshot a plan's exact packages are resolved from
135
+ (#1178). <file> is the report advisor-package-request prints, saved to a
136
+ file: every name in it must be a package in this package's publishing scope,
137
+ named once. For each name, in name order, this fetches the package's full
138
+ registry document from the registry this package was built for, with no
139
+ registry credential, no .npmrc and no npm CLI, refusing any redirect, any response
140
+ over ${MAX_RESPONSE_BYTES / (1024 * 1024)} MiB and any request that takes too long. It records only what
141
+ the registry snapshot contract declares, validates the whole snapshot against
142
+ that contract, and writes it atomically to --out, by default
143
+ ${REGISTRY_SNAPSHOT_REL} under the current directory (the hub).
144
+
145
+ This is the only step of applying a plan that reads the package registry. A
146
+ message names a package by its position in the request, names[<n>], never by
147
+ its name, and never quotes the request or a response.
148
+
149
+ Exit codes: 0 = the snapshot was written (or --help was shown), 2 = no snapshot was written
150
+ (a usage error, an unreadable or invalid request, or any registry answer
151
+ this step cannot record: a transport error, a timeout, a redirect, an
152
+ answer other than 200 or 404, an oversize or non-JSON body, or a snapshot
153
+ the contract refuses). A package the registry does not have (404) is
154
+ recorded as not-found, not refused. On exit 2 an earlier snapshot at the
155
+ output path is left untouched, and must not be used.`;
156
+
157
+ export interface SnapshotCommandOptions extends FetchSnapshotOptions {
158
+ /** The hub root the default --out is relative to, and --request and --out resolve against; the process's cwd by default. */
159
+ readonly cwd?: string;
160
+ }
161
+
162
+ export interface ApplyCommandOptions {
163
+ readonly cwd?: string;
164
+ readonly clone?: string;
165
+ readonly set?: RepositoryChangeSet;
166
+ readonly texts?: Readonly<Record<string, string>>;
167
+ readonly heldChangeSets?: readonly RepositoryChangeSet[];
168
+ readonly spawn?: LockfileSpawn;
169
+ /** The instant the execution authorization is judged at; the wall clock by default. */
170
+ readonly now?: () => Date;
171
+ readonly toolVersion?: string | null;
172
+ /** Runs the hub's advisor-execution-readiness; the hub's own installed executable by default. */
173
+ readonly runReadiness?: ReadinessRunner;
174
+ /** The read-only GitHub questions `status` asks; read-only `gh api` calls by default. */
175
+ readonly statusPorts?: StatusPorts;
176
+ }
177
+
178
+ const REPO_ID_SHAPE = /^[^/]+\/[^/]+$/u;
179
+
180
+ function parseRepoSubcommand(argv: readonly string[], label: string): { help: true } | { help: false; id: string } {
181
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) return { help: true };
182
+ if (argv.length !== 2 || argv[0] !== "--repo") throw new ApplyPlanInputError(`usage: launcher-apply-plan ${label} --repo <id>`);
183
+ const id = argv[1]!;
184
+ const slash = id.indexOf("/");
185
+ const name = slash === -1 ? "" : id.slice(slash + 1);
186
+ if (!REPO_ID_SHAPE.test(id) || name === "." || name === "..") throw new ApplyPlanInputError(`usage: launcher-apply-plan ${label} --repo <id>`);
187
+ return { help: false, id };
188
+ }
189
+
190
+ function spawnGit(cwd: string, args: string[]): string | null {
191
+ const run = spawnSync("git", ["-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", ...args], {
192
+ cwd,
193
+ encoding: "utf8",
194
+ stdio: ["ignore", "pipe", "ignore"],
195
+ });
196
+ if ((run.status ?? 1) !== 0) return null;
197
+ return (run.stdout ?? "").trim();
198
+ }
199
+
200
+ function resolveApplyInputs(
201
+ id: string,
202
+ options: ApplyCommandOptions,
203
+ ): { hub: string; clone: string; set: RepositoryChangeSet; held: RepositoryChangeSet[]; texts: Readonly<Record<string, string>> } | ApplyStepResult {
204
+ const hub = options.cwd ?? process.cwd();
205
+ if (options.set !== undefined && options.set.repository.id !== id) {
206
+ return { exitCode: 2, verdict: "indeterminate", reason: "change-set-absent" };
207
+ }
208
+ let held: RepositoryChangeSet[] = [];
209
+ try {
210
+ held = listStoredChangeSets(realpathSync(hub));
211
+ } catch {
212
+ held = [];
213
+ }
214
+ if (options.heldChangeSets !== undefined) held = [...options.heldChangeSets];
215
+ let set = options.set;
216
+ if (set === undefined) {
217
+ const matches = held.filter((entry) => entry.repository.id === id);
218
+ if (matches.length === 0) return { exitCode: 2, verdict: "indeterminate", reason: "change-set-absent" };
219
+ if (matches.length === 1) {
220
+ set = matches[0]!;
221
+ } else {
222
+ const clonePath = options.clone ?? resolve(dirname(realpathSync(hub)), id.slice(id.indexOf("/") + 1));
223
+ let root: string;
224
+ try {
225
+ root = realpathSync(clonePath);
226
+ } catch {
227
+ return { exitCode: 2, verdict: "indeterminate", reason: "change-set-absent" };
228
+ }
229
+ const branch = matches[0]!.repository.defaultBranch;
230
+ const tip = spawnGit(root, ["rev-parse", `refs/heads/${branch}`]);
231
+ if (tip === null) return { exitCode: 2, verdict: "indeterminate", reason: "change-set-absent" };
232
+ const filtered = matches.filter((entry) => entry.repository.baseCommit === tip);
233
+ if (filtered.length !== 1) return { exitCode: 2, verdict: "indeterminate", reason: "change-set-absent" };
234
+ set = filtered[0]!;
235
+ }
236
+ }
237
+ const clone = options.clone ?? resolve(dirname(realpathSync(hub)), id.slice(id.indexOf("/") + 1));
238
+ const storedTexts =
239
+ set.texts === undefined ? {} : Object.fromEntries(set.texts.map((row) => [row.path, row.text] as const));
240
+ return { hub, clone, set, held, texts: options.texts ?? storedTexts };
241
+ }
242
+
243
+ const MATERIALIZE_HELP = `Usage: launcher-apply-plan materialize --repo <id>
244
+
245
+ Writes a stored repository change set into the repository's local clone.
246
+
247
+ Refuses, and writes nothing, unless the plan committed at the hub's HEAD
248
+ approves a bundle that holds the change set, or the change set is an apply set
249
+ admitted under the one-approval rule: it follows the approved setup set and
250
+ changes nothing that approval did not already cover. When the change set has
251
+ package acts, the execution authorization in the hub's committed assessment
252
+ must also be current at the time of the run. The approval a change set's
253
+ ledger records is decided from the hub alone; it is never taken from an
254
+ option or defaulted.`;
255
+ const VERIFY_HELP = `Usage: launcher-apply-plan verify --repo <id>
256
+
257
+ Reports whether the repository's local clone matches its stored change set.
258
+ Verify re-checks everything materialize checks before writing, including the
259
+ committed approval and, for a change set with package acts, that the execution
260
+ authorization is still current at the time of the run: a clone whose approval
261
+ was withdrawn or whose authorization expired no longer verifies.`;
262
+
263
+ const STATUS_HELP = `Usage: launcher-apply-plan status --repo <id>
264
+
265
+ Reports what the pull request for the repository's stored change set is doing,
266
+ from read-only evidence: the open pull requests, the default branch's tip, and
267
+ the commits already in the local clone. It needs a full clone: a partial clone
268
+ is refused as indeterminate (partial-clone) before any object is read. It
269
+ changes nothing but the fetch of the default branch into its remote-tracking ref
270
+ that verify also makes: it does not fetch a pull request's head, check anything
271
+ out, or write a file or an index.
272
+
273
+ The state is one of: proposed (an open pull request of this change set, made
274
+ by the person running this, whose body is the one the body command recorded and whose
275
+ head passes every check verify makes),
276
+ applied (the default branch already holds the change set), planned (neither),
277
+ diverged (its pull request or its body does not match), superseded (an older change set of
278
+ this repository has a pull request, even beside this one's) or indeterminate
279
+ (something could not be read or trusted, including a partial clone, a change set
280
+ the body command never recorded a body for, any open pull request whose body names the
281
+ marker word but is not the person's own, and a listing of 100 or more open pull
282
+ requests). It prints the state, a fixed reason
283
+ and #<number> for each pull request it is about, and nothing else. proposed
284
+ does not check the head's ancestry to the base, and nothing in this unit does.
285
+
286
+ Exit codes: 0 = proposed or applied, 1 = diverged, 2 = anything else.`;
287
+
288
+ function printApplyOutcome(label: string, outcome: ApplyStepResult): number {
289
+ const suffix = outcome.exitCode === 0 ? outcome.verdict : `${outcome.verdict} (${outcome.reason})`;
290
+ const detail = outcome.detail === undefined ? "" : `; ${outcome.detail}`;
291
+ if (outcome.exitCode === 0) console.log(`launcher-apply-plan ${label}: ${suffix}${detail}`);
292
+ else console.error(`launcher-apply-plan ${label}: ${suffix}${detail}`);
293
+ return outcome.exitCode;
294
+ }
295
+
296
+ export async function materializeMain(argv: readonly string[], options: ApplyCommandOptions = {}): Promise<number> {
297
+ try {
298
+ const parsed = parseRepoSubcommand(argv, "materialize");
299
+ if (parsed.help) {
300
+ console.log(MATERIALIZE_HELP);
301
+ return 0;
302
+ }
303
+ const resolved = resolveApplyInputs(parsed.id, options);
304
+ if ("exitCode" in resolved) return printApplyOutcome("materialize", resolved);
305
+ const outcome = await materializeRepository({
306
+ clone: resolved.clone,
307
+ hub: resolved.hub,
308
+ set: resolved.set,
309
+ texts: resolved.texts,
310
+ heldChangeSets: resolved.held,
311
+ spawn: options.spawn,
312
+ now: options.now,
313
+ toolVersion: options.toolVersion,
314
+ runReadiness: options.runReadiness,
315
+ });
316
+ return printApplyOutcome("materialize", outcome);
317
+ } catch (cause) {
318
+ console.error(`launcher-apply-plan materialize: ${cause instanceof ApplyPlanInputError ? cause.message : "usage: launcher-apply-plan materialize --repo <id>"}`);
319
+ return 2;
320
+ }
321
+ }
322
+
323
+ export async function verifyMain(argv: readonly string[], options: ApplyCommandOptions = {}): Promise<number> {
324
+ try {
325
+ const parsed = parseRepoSubcommand(argv, "verify");
326
+ if (parsed.help) {
327
+ console.log(VERIFY_HELP);
328
+ return 0;
329
+ }
330
+ const resolved = resolveApplyInputs(parsed.id, options);
331
+ if ("exitCode" in resolved) return printApplyOutcome("verify", resolved);
332
+ const outcome = await verifyRepository({
333
+ clone: resolved.clone,
334
+ hub: resolved.hub,
335
+ set: resolved.set,
336
+ heldChangeSets: resolved.held,
337
+ now: options.now,
338
+ runReadiness: options.runReadiness,
339
+ });
340
+ return printApplyOutcome("verify", outcome);
341
+ } catch (cause) {
342
+ console.error(`launcher-apply-plan verify: ${cause instanceof ApplyPlanInputError ? cause.message : "usage: launcher-apply-plan verify --repo <id>"}`);
343
+ return 2;
344
+ }
345
+ }
346
+
347
+ export async function statusMain(argv: readonly string[], options: ApplyCommandOptions = {}): Promise<number> {
348
+ try {
349
+ const parsed = parseRepoSubcommand(argv, "status");
350
+ if (parsed.help) {
351
+ console.log(STATUS_HELP);
352
+ return 0;
353
+ }
354
+ const resolved = resolveApplyInputs(parsed.id, options);
355
+ if ("exitCode" in resolved) {
356
+ console.log(formatStatus({ state: "indeterminate", reason: resolved.reason }));
357
+ return 2;
358
+ }
359
+ const outcome = await statusRepository({
360
+ clone: resolved.clone,
361
+ hub: resolved.hub,
362
+ set: resolved.set,
363
+ heldChangeSets: resolved.held,
364
+ now: options.now,
365
+ runReadiness: options.runReadiness,
366
+ ports: options.statusPorts,
367
+ });
368
+ console.log(formatStatus(outcome));
369
+ return outcome.exitCode;
370
+ } catch (cause) {
371
+ console.error(`launcher-apply-plan status: ${cause instanceof ApplyPlanInputError ? cause.message : "usage: launcher-apply-plan status --repo <id>"}`);
372
+ return 2;
373
+ }
374
+ }
375
+
376
+ const BODY_HELP = `Usage: ${BODY_USAGE}
377
+
378
+ Prints the body of the pull request for the repository's stored change set, and
379
+ nothing else, and records the SHA-256 of exactly those bytes as the change set's
380
+ pullRequest.bodySha256. The approval the body shows is decided from the hub at
381
+ the time of the run, as materialize decides it; it is never taken from an
382
+ option. A planned bundle the hub stored for the change set must hold that same
383
+ approval. --task-record is the number of the task-record issue in the
384
+ repository; each --supersedes is the number of the pull request of an older
385
+ change set of this repository that this one replaces, and needs another stored
386
+ change set of the repository. A change set already bound to another body is
387
+ refused, and one already bound to this body prints it again. There is no way to
388
+ undo a binding: a mistyped --task-record or --supersedes leaves the change set
389
+ refused as body-bound. Pass the output to the pull request as a file with
390
+ --body-file; --body "$(...)" drops the final line feed the recorded hash covers.
391
+
392
+ Exit codes: 0 = the body was printed, 1 = refused (a fixed token on standard
393
+ error, nothing on standard output), 2 = indeterminate or a usage error.`;
394
+
395
+ /**
396
+ * The `body` subcommand. Standard output is the body and only the body, after it is recorded; a refusal prints one line of fixed
397
+ * tokens to standard error and never an argument.
398
+ */
399
+ export async function bodyMain(argv: readonly string[], options: ApplyCommandOptions = {}): Promise<number> {
400
+ try {
401
+ const parsed = parseBodyArgs(argv);
402
+ if (parsed.help) {
403
+ console.log(BODY_HELP);
404
+ return 0;
405
+ }
406
+ const resolved = resolveApplyInputs(parsed.id, options);
407
+ if ("exitCode" in resolved) {
408
+ console.error(`launcher-apply-plan body: indeterminate (${resolved.reason ?? "refused"})`);
409
+ return 2;
410
+ }
411
+ const outcome = await bodyRepository({
412
+ clone: resolved.clone,
413
+ hub: resolved.hub,
414
+ set: resolved.set,
415
+ heldChangeSets: resolved.held,
416
+ taskRecord: parsed.taskRecord,
417
+ supersedes: parsed.supersedes,
418
+ now: options.now,
419
+ runReadiness: options.runReadiness,
420
+ });
421
+ if (outcome.exitCode !== 0) {
422
+ console.error(`launcher-apply-plan body: ${outcome.exitCode === 1 ? "refused" : "indeterminate"} (${outcome.reason})`);
423
+ return outcome.exitCode;
424
+ }
425
+ process.stdout.write(outcome.body);
426
+ return 0;
427
+ } catch (cause) {
428
+ console.error(cause instanceof BodyUsageError ? `launcher-apply-plan body: usage: ${BODY_USAGE}` : "launcher-apply-plan body: indeterminate (body-failed)");
429
+ return 2;
430
+ }
431
+ }
432
+
433
+ function parseSnapshotArgs(argv: readonly string[]): { help: true } | { help: false; requestPath: string; outPath?: string } {
434
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) return { help: true };
435
+ const flags = new Map<string, string>();
436
+ for (let index = 0; index < argv.length; index += 2) {
437
+ const name = argv[index] as string;
438
+ const value = argv[index + 1];
439
+ if ((name !== "--request" && name !== "--out") || value === undefined || flags.has(name)) {
440
+ throw new RegistrySnapshotError("usage: launcher-apply-plan snapshot --request <file> [--out <file>]");
441
+ }
442
+ flags.set(name, value);
443
+ }
444
+ const requestPath = flags.get("--request");
445
+ if (requestPath === undefined) throw new RegistrySnapshotError("--request is required");
446
+ const outPath = flags.get("--out");
447
+ return outPath === undefined ? { help: false, requestPath } : { help: false, requestPath, outPath };
448
+ }
449
+
450
+ /** Reads the request strictly; a refusal names the rule and a position, never the file's text. */
451
+ function readSnapshotRequest(path: string): unknown {
452
+ let bytes: Uint8Array;
453
+ try {
454
+ if (!statSync(path).isFile()) throw new RegistrySnapshotError("the --request file is not a file");
455
+ bytes = readFileSync(path);
456
+ } catch (cause) {
457
+ if (cause instanceof RegistrySnapshotError) throw cause;
458
+ throw new RegistrySnapshotError("the --request file could not be read");
459
+ }
460
+ try {
461
+ return readContractDocument(bytes);
462
+ } catch (cause) {
463
+ if (!(cause instanceof ContractDocumentError)) throw new RegistrySnapshotError("the --request file is not strict JSON");
464
+ const where = cause.position === undefined ? "" : ` at position ${cause.position}`;
465
+ const why = cause.reason === "encoding" ? "is not valid UTF-8" : cause.reason === "repeated-key" ? "repeats a key in one object" : "is not valid JSON";
466
+ throw new RegistrySnapshotError(`the --request file ${why}${where}`);
467
+ }
468
+ }
469
+
470
+ /**
471
+ * The `snapshot` subcommand. Never throws: every refusal prints one line and
472
+ * returns 2, and nothing is written unless the whole snapshot validates.
473
+ */
474
+ export async function snapshotMain(argv: readonly string[], options: SnapshotCommandOptions = {}): Promise<number> {
475
+ try {
476
+ const parsed = parseSnapshotArgs(argv);
477
+ if (parsed.help) {
478
+ console.log(SNAPSHOT_USAGE);
479
+ return 0;
480
+ }
481
+ const cwd = options.cwd ?? process.cwd();
482
+ const names = requestedPackageNames(readSnapshotRequest(resolve(cwd, parsed.requestPath)));
483
+ const snapshot = await takeRegistrySnapshot(names, options);
484
+ const out = resolve(cwd, parsed.outPath ?? REGISTRY_SNAPSHOT_REL);
485
+ try {
486
+ writeRegistrySnapshot(out, snapshot);
487
+ } catch (cause) {
488
+ if (cause instanceof RegistrySnapshotError) throw cause;
489
+ throw new RegistrySnapshotError(`the snapshot could not be written to ${out}`);
490
+ }
491
+ const notFound = snapshot.packages.filter((entry) => entry.status === "not-found").length;
492
+ console.log(`wrote ${out}: ${snapshot.packages.length} package(s), ${snapshot.packages.length - notFound} found, ${notFound} not found`);
493
+ return 0;
494
+ } catch (cause) {
495
+ const reason = cause instanceof RegistrySnapshotError ? cause.message : `failed unexpectedly (${cause instanceof Error ? cause.name : typeof cause})`;
496
+ console.error(`launcher-apply-plan snapshot: ${reason}; no snapshot was written`);
497
+ return 2;
498
+ }
499
+ }
500
+
501
+ async function run(): Promise<void> {
502
+ const argv = process.argv.slice(2);
503
+ if (argv[0] === "plan") {
504
+ process.exitCode = await planMain(argv.slice(1));
505
+ return;
506
+ }
507
+ if (argv[0] === "materialize") {
508
+ process.exitCode = await materializeMain(argv.slice(1));
509
+ return;
510
+ }
511
+ if (argv[0] === "verify") {
512
+ process.exitCode = await verifyMain(argv.slice(1));
513
+ return;
514
+ }
515
+ if (argv[0] === "status") {
516
+ process.exitCode = await statusMain(argv.slice(1));
517
+ return;
518
+ }
519
+ if (argv[0] === "body") {
520
+ process.exitCode = await bodyMain(argv.slice(1));
521
+ return;
522
+ }
523
+ if (argv[0] === "snapshot") {
524
+ process.exitCode = await snapshotMain(argv.slice(1));
525
+ return;
526
+ }
87
527
  try {
88
- process.exitCode = main(process.argv.slice(2), createNodeHost());
528
+ process.exitCode = main(argv, createNodeHost());
89
529
  } catch (cause) {
90
530
  console.error(`launcher-apply-plan: ${cause instanceof Error ? cause.message : String(cause)}`);
91
531
  process.exitCode = 2;
92
532
  }
93
533
  }
94
- if (isDirectInvocation(import.meta.url, process.argv[1])) run();
534
+ if (isDirectInvocation(import.meta.url, process.argv[1])) void run();