@clossys/launcher 0.3.0 → 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 (286) hide show
  1. package/README.md +1328 -60
  2. package/contracts/conversation-contract.md +2 -1
  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 -5
  210. package/skeleton/README.md +14 -9
  211. package/skeleton/package.json +2 -1
  212. package/skill/SKILL.md +20 -16
  213. package/skill-catalogue/advisor/SKILL.md +100 -11
  214. package/skill-catalogue/architect/SKILL.md +2 -13
  215. package/skill-catalogue/bouncer/SKILL.md +2 -13
  216. package/skill-catalogue/builder/SKILL.md +2 -13
  217. package/skill-catalogue/butler/SKILL.md +2 -13
  218. package/skill-catalogue/controller/SKILL.md +2 -13
  219. package/skill-catalogue/customer/SKILL.md +4 -13
  220. package/skill-catalogue/designer/SKILL.md +9 -14
  221. package/skill-catalogue/giver/SKILL.md +2 -13
  222. package/skill-catalogue/influencer/SKILL.md +2 -13
  223. package/skill-catalogue/inspector/SKILL.md +2 -13
  224. package/skill-catalogue/integrator/SKILL.md +2 -13
  225. package/skill-catalogue/keeper/SKILL.md +2 -13
  226. package/skill-catalogue/launcher/SKILL.md +20 -16
  227. package/skill-catalogue/locksmith/SKILL.md +2 -13
  228. package/skill-catalogue/messenger/SKILL.md +2 -13
  229. package/skill-catalogue/observer/SKILL.md +2 -13
  230. package/skill-catalogue/publisher/SKILL.md +11 -17
  231. package/skill-catalogue/starter/SKILL.md +3 -13
  232. package/skill-catalogue/strategist/SKILL.md +14 -17
  233. package/skill-catalogue/writer/SKILL.md +7 -14
  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
  286. package/CHANGELOG.md +0 -131
@@ -0,0 +1,572 @@
1
+ // Test support for the admission tests (#1178): builders for an approved plan, a
2
+ // setup/apply pair from the shared corpus, apply bundles, a temporary git hub
3
+ // and clone, and a stub readiness executable. Nothing here is imported by the
4
+ // shipped code, and every name, id and digest is an example value.
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ import type { AdmissionReaders, BaseTreeEntry, HubAuthority } from "./admission.js";
11
+ import { storeApplyBundle, storeChangeSet } from "./apply-store.js";
12
+ import { LEDGER_PATH, contentDigest, discoveryLinkRole, discoveryLinkTarget, lockfilePath } from "./change-set-contract.js";
13
+ import type { ApplyBundle, RepositoryChangeSet } from "./change-set-contract.js";
14
+ import { bundleDigest, changeSetDigest } from "./change-set-digest.js";
15
+ import { readInstalledLedger, renderInstalledLedger, serializeInstalledLedger } from "./ledger-contract.js";
16
+ import type { InstalledLedger } from "./ledger-contract.js";
17
+ import { approvedSubject } from "./apply-plan.js";
18
+ import type { AdvisorPlan } from "./plan-contract.js";
19
+ import { planDigest } from "./plan-digest.js";
20
+
21
+ export type Loose = Record<string, any>;
22
+
23
+ export const SITE_ID = "example-owner/site";
24
+ export const SITE_NODE_ID = "R_exampleSite1";
25
+ export const DOCS_ID = "example-owner/docs";
26
+ export const STARTER = "example-owner/site:@example/starter";
27
+ export const STRATEGIST = "example-owner/site:@example/strategist";
28
+ export const WRITER = "example-owner/site:@example/writer";
29
+ export const PLAN_FILE = "clossys/advisor/plan.json";
30
+ export const ASSESSMENT_FILE = "clossys/advisor/assessment-input.json";
31
+ export const READINESS_BIN = "node_modules/.bin/advisor-execution-readiness";
32
+ export const FAR_FUTURE = "2999-01-01T00:00:00Z";
33
+
34
+ const REPO = new URL("../../../", import.meta.url);
35
+ const readRepo = (path: string): string => readFileSync(new URL(path, REPO), "utf8");
36
+ export const clone = <T>(value: T): T => structuredClone(value);
37
+
38
+ /** Recomputes a set's own digest, and the branch and title that follow from it. */
39
+ export function reseal(set: Loose): RepositoryChangeSet {
40
+ const digest = changeSetDigest(set as RepositoryChangeSet);
41
+ set.changeSetDigest = digest;
42
+ set.branch = `clossys/apply-${digest.slice(7, 19)}`;
43
+ set.pullRequest = { title: `Clossys: apply plan ${digest.slice(7, 19)}` };
44
+ return set as RepositoryChangeSet;
45
+ }
46
+
47
+ const CORPUS = JSON.parse(readRepo("docs/contracts/apply-change-set-digest.fixture.json")) as { changeSets: { name: string; changeSet: RepositoryChangeSet }[] };
48
+ const PLAN_CORPUS = JSON.parse(readRepo("docs/contracts/advisor-plan-digest.fixture.json")) as { plans: { name: string; plan: AdvisorPlan }[] };
49
+ export const corpusSet = (name: string): Loose => clone(CORPUS.changeSets.find((entry) => entry.name === name)!.changeSet) as unknown as Loose;
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // git
53
+
54
+ const gitEnv = {
55
+ ...process.env,
56
+ PATH: process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
57
+ GIT_CONFIG_GLOBAL: "/dev/null",
58
+ GIT_CONFIG_NOSYSTEM: "1",
59
+ GIT_AUTHOR_NAME: "Example Author",
60
+ GIT_AUTHOR_EMAIL: "author@example.com",
61
+ GIT_COMMITTER_NAME: "Example Author",
62
+ GIT_COMMITTER_EMAIL: "author@example.com",
63
+ };
64
+
65
+ export function git(cwd: string, ...args: string[]): string {
66
+ return execFileSync("git", ["-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", ...args], {
67
+ cwd,
68
+ env: gitEnv,
69
+ encoding: "utf8",
70
+ stdio: ["ignore", "pipe", "pipe"],
71
+ });
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // the plan
76
+
77
+ export interface DecisionInput {
78
+ readonly at: string;
79
+ readonly chosen: string;
80
+ readonly subjectDigest?: string;
81
+ }
82
+
83
+ export function decision(at: string, chosen: string, subjectDigest?: string): Loose {
84
+ return { at, recommended: "the Launch kit", chosen, by: "sponsor", ...(subjectDigest === undefined ? {} : { subjectDigest }) };
85
+ }
86
+
87
+ /** The corpus plan with staffing and exact packages, with its decisions replaced. */
88
+ export function withDecisions(plan: AdvisorPlan, decisions: readonly Loose[]): AdvisorPlan {
89
+ return { ...clone(plan), decisions: decisions.map((entry) => clone(entry)) } as unknown as AdvisorPlan;
90
+ }
91
+
92
+ /** The corpus plan before any decision: its digest does not depend on decisions. */
93
+ export function basePlan(): Loose {
94
+ return { ...clone(PLAN_CORPUS.plans.find((entry) => entry.name === "staffed-with-packages")!.plan), decisions: [] } as unknown as Loose;
95
+ }
96
+
97
+ export const FIRST_AT = "2026-09-24T10:00:00Z";
98
+ export const LATER_AT = "2026-09-25T10:00:00Z";
99
+
100
+ /** A plan whose latest decision approves `subject`. */
101
+ export function approvedPlan(subject: string, base: AdvisorPlan = basePlan() as unknown as AdvisorPlan): AdvisorPlan {
102
+ return withDecisions(base, [decision(FIRST_AT, "approved", subject)]);
103
+ }
104
+
105
+ /** `decide(plan, chosen, at, subject?)`: the plan with one more decision. */
106
+ export function decide(plan: AdvisorPlan, chosen: string, at: string, subject?: string): AdvisorPlan {
107
+ return withDecisions(plan, [...plan.decisions, decision(at, chosen, subject)]);
108
+ }
109
+
110
+ /** The head commit of each hub hubRepo built, by the hub's path, so two hubs for one plan keep their own heads. */
111
+ const HUB_HEADS = new Map<string, string>();
112
+ /** The head of the hub most recently built for a plan digest, for a test that names the plan and no hub. */
113
+ const LAST_HEADS = new Map<string, string>();
114
+
115
+ /** What readHubAuthority returns for the plan: the head is that of `hub`, else of the hub last built for this plan, or a placeholder commit id when none was. */
116
+ export function authorityOf(plan: AdvisorPlan, hub?: string): HubAuthority {
117
+ const subject = approvedSubject(plan);
118
+ if (subject === null) throw new Error("the fixture plan is not approved");
119
+ const digest = planDigest(plan);
120
+ return { plan, planDigest: digest, subject, head: (hub === undefined ? LAST_HEADS.get(digest) : HUB_HEADS.get(hub)) ?? "0".repeat(40) };
121
+ }
122
+
123
+ /** The plan's identity for each package act of one repository. */
124
+ export function committedPlanPackages(plan: AdvisorPlan, repositoryId: string) {
125
+ return (plan.packages ?? [])
126
+ .filter((act) => act.repository === repositoryId)
127
+ .map(({ planItem, act, name, version, integrity, placement }) => ({ planItem, act, name, version, integrity, placement }));
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // bundles
132
+
133
+ export interface BundleEntryInput {
134
+ readonly id: string;
135
+ readonly set?: RepositoryChangeSet;
136
+ readonly changeSet?: string;
137
+ readonly phase?: "setup" | "apply";
138
+ }
139
+
140
+ /** A valid report bundle for `plan`, holding the given change sets, with its digest computed. */
141
+ export function bundleOf(plan: AdvisorPlan, entries: readonly BundleEntryInput[], extra: { planDigest?: string; computedAt?: string } = {}): ApplyBundle {
142
+ const digestOfPlan = extra.planDigest ?? planDigest(plan);
143
+ const held = entries.map((entry) => ({ id: entry.id, changeSet: entry.set?.changeSetDigest ?? entry.changeSet!, phase: entry.phase ?? entry.set?.phase ?? "apply" }));
144
+ return {
145
+ schemaVersion: 1,
146
+ kind: "clossys.apply-bundle",
147
+ mode: "report",
148
+ plan: { path: "clossys/advisor/plan.json", digest: digestOfPlan, committed: true },
149
+ snapshot: { path: "clossys/.state/apply/registry-snapshot.json", digest: plan.resolution!.snapshotDigest },
150
+ engine: { name: "@example/advisor", version: "0.8.0", integrity: plan.packages![0]!.integrity },
151
+ authorization: { planDigest: digestOfPlan, expiresAt: FAR_FUTURE },
152
+ computedAt: extra.computedAt ?? "2026-09-24T12:00:00Z",
153
+ repositories: held.map((entry) => ({ id: entry.id, verdict: "satisfied", phase: entry.phase, changeSet: entry.changeSet, checks: [] })),
154
+ bundleDigest: bundleDigest(digestOfPlan, held.map((entry) => ({ id: entry.id, changeSetDigest: entry.changeSet }))),
155
+ } as ApplyBundle;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // the base tree
160
+
161
+ export type BaseTree = Map<string, BaseTreeEntry>;
162
+
163
+ const utf8 = (text: string): Buffer => Buffer.from(text, "utf8");
164
+ export const MANIFEST = `${JSON.stringify({ name: "site", private: true, devDependencies: { "@example/starter": "0.9.2" } }, null, 2)}\n`;
165
+
166
+ export function lockfileText(packages: readonly { name: string; version: string; integrity: string }[], placement: "devDependencies" | "dependencies" = "devDependencies"): string {
167
+ const root: Loose = { name: "site", version: "1.0.0", [placement]: Object.fromEntries(packages.map((pkg) => [pkg.name, pkg.version])) };
168
+ const entries = Object.fromEntries(
169
+ packages.map((pkg) => [
170
+ `node_modules/${pkg.name}`,
171
+ { version: pkg.version, resolved: `https://registry.npmjs.org/${pkg.name}/-/${pkg.name.split("/")[1]}-${pkg.version}.tgz`, integrity: pkg.integrity, dev: true },
172
+ ]),
173
+ );
174
+ return `${JSON.stringify({ name: "site", version: "1.0.0", lockfileVersion: 3, requires: true, packages: { "": root, ...entries } }, null, 2)}\n`;
175
+ }
176
+
177
+ /** The in-memory readers the pure core takes over a base tree, and the hub's stored bundles and sets. */
178
+ export function memoryReaders(state: { bundles: readonly ApplyBundle[]; sets: readonly RepositoryChangeSet[]; tree: BaseTree }): AdmissionReaders {
179
+ return {
180
+ bundle: (digest) => state.bundles.find((bundle) => bundle.bundleDigest === digest) ?? null,
181
+ setupSet: (digest) => state.sets.find((set) => set.changeSetDigest === digest) ?? null,
182
+ baseEntry: (path) => {
183
+ const entry = state.tree.get(path);
184
+ if (entry !== undefined) return entry;
185
+ const isDirectory = [...state.tree.keys()].some((key) => key.startsWith(`${path}/`));
186
+ return isDirectory ? { mode: "040000", bytes: new Uint8Array(0) } : null;
187
+ },
188
+ baseDirectory: (dir) => {
189
+ const prefix = dir === "" ? "" : `${dir}/`;
190
+ const names = new Set<string>();
191
+ for (const key of state.tree.keys()) if (key.startsWith(prefix)) names.add(key.slice(prefix.length).split("/")[0]!);
192
+ return names.size === 0 ? null : [...names];
193
+ },
194
+ };
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // the pair
199
+
200
+ export interface WorldOptions {
201
+ /** Edits the plan before its digest is taken: everything the digest covers. */
202
+ readonly editPlan?: (plan: Loose) => void;
203
+ /** Edits the setup set (and its texts) before it is sealed. */
204
+ readonly editSetup?: (set: Loose, texts: Record<string, string>) => void;
205
+ /** Edits the apply set before it is sealed. */
206
+ readonly editApply?: (set: Loose, texts: Record<string, string>) => void;
207
+ /** Called with the base tree once it exists, returning the commit it lives at. */
208
+ readonly commitBase?: (tree: BaseTree) => string;
209
+ }
210
+
211
+ export interface World {
212
+ readonly plan: AdvisorPlan;
213
+ readonly planDigest: string;
214
+ readonly authority: HubAuthority;
215
+ readonly setup: RepositoryChangeSet;
216
+ readonly apply: RepositoryChangeSet;
217
+ readonly texts: Record<string, string>;
218
+ /** The approved bundle: it holds the setup set. */
219
+ readonly approvedBundle: ApplyBundle;
220
+ /** The later run's bundle: it holds the apply set, and is what the apply set's `bundle` names. */
221
+ readonly applyBundle: ApplyBundle;
222
+ readonly ledgerBytes: Buffer;
223
+ readonly ledger: InstalledLedger;
224
+ readonly tree: BaseTree;
225
+ readonly baseCommit: string;
226
+ }
227
+
228
+ export const DEFAULT_BASE_COMMIT = "c".repeat(40);
229
+
230
+ /** Whole-file bytes for every whole file of a set: a discovery link's target, or a line of text. */
231
+ export function textsFor(set: Loose): Record<string, string> {
232
+ const texts: Record<string, string> = {};
233
+ for (const file of set.files as Loose[]) {
234
+ if (file.derived === true) continue;
235
+ if (file.mode === "120000") texts[file.path] = discoveryLinkTarget(discoveryLinkRole(file.path)!);
236
+ else texts[file.path] = `fixture ${file.path}\n`;
237
+ }
238
+ return texts;
239
+ }
240
+
241
+ function applyTexts(set: Loose, texts: Record<string, string>, keep: boolean): void {
242
+ for (const file of set.files as Loose[]) {
243
+ if (file.derived === true) continue;
244
+ const text = texts[file.path]!;
245
+ file.after = contentDigest(text);
246
+ file.before = keep ? file.after : null;
247
+ }
248
+ }
249
+
250
+ /** The ledger a setup set writes from nothing: `renderInstalledLedger(null, ...)` with the plan's identities. */
251
+ export function setupLedgerBytes(set: RepositoryChangeSet, subject: string, plan: AdvisorPlan): Buffer {
252
+ return Buffer.from(renderInstalledLedger(null, set, { kind: "approved", subjectDigest: subject }, committedPlanPackages(plan, set.repository.id)), "utf8");
253
+ }
254
+
255
+ /** Re-serializes a ledger after `edit`, canonically: for rows that must still pass L1 to L10. */
256
+ export function editLedger(bytes: Uint8Array, edit: (ledger: Loose) => void): Buffer {
257
+ const ledger = clone(readInstalledLedger(bytes)) as unknown as Loose;
258
+ edit(ledger);
259
+ return Buffer.from(serializeInstalledLedger(ledger as InstalledLedger), "utf8");
260
+ }
261
+
262
+ /**
263
+ * The corpus pair `setup-site` / `apply-after-setup`, retargeted to a plan and
264
+ * resealed, with the approved bundle, the later run's bundle, the ledger the
265
+ * merged setup leaves, and the tree of the base it lives in.
266
+ */
267
+ export function buildWorld(options: WorldOptions = {}): World {
268
+ const plan0 = basePlan();
269
+ options.editPlan?.(plan0);
270
+ const digestOfPlan = planDigest(plan0 as unknown as AdvisorPlan);
271
+
272
+ const setup = corpusSet("setup-site");
273
+ setup.planDigest = digestOfPlan;
274
+ const texts = textsFor(setup);
275
+ options.editSetup?.(setup, texts);
276
+ applyTexts(setup, texts, false);
277
+ reseal(setup);
278
+ const sealedSetup = setup as unknown as RepositoryChangeSet;
279
+
280
+ const approvedBundle = bundleOf(plan0 as unknown as AdvisorPlan, [{ id: SITE_ID, set: sealedSetup }]);
281
+ const setupWithBundle = { ...sealedSetup, bundle: approvedBundle.bundleDigest } as RepositoryChangeSet;
282
+ const plan = approvedPlan(approvedBundle.bundleDigest, plan0 as unknown as AdvisorPlan);
283
+ const authority = authorityOf(plan);
284
+
285
+ const ledgerBytes = setupLedgerBytes(setupWithBundle, approvedBundle.bundleDigest, plan);
286
+ const ledger = readInstalledLedger(ledgerBytes)!;
287
+
288
+ const tree: BaseTree = new Map();
289
+ tree.set("README.md", { mode: "100644", bytes: utf8("# Site\n") });
290
+ for (const file of setup.files as Loose[]) {
291
+ if (file.derived === true) continue;
292
+ tree.set(file.path, { mode: file.mode, bytes: utf8(texts[file.path]!) });
293
+ }
294
+ tree.set("package.json", { mode: "100644", bytes: utf8(MANIFEST) });
295
+ const starter = (setup.items as Loose[]).find((item) => item.act === "pin-starter")!;
296
+ tree.set(lockfilePath(setup.observed)!, { mode: "100644", bytes: utf8(lockfileText([{ name: starter.package.name, version: starter.package.version, integrity: starter.package.integrity }])) });
297
+ tree.set(LEDGER_PATH, { mode: "100644", bytes: ledgerBytes });
298
+ const baseCommit = options.commitBase?.(tree) ?? DEFAULT_BASE_COMMIT;
299
+
300
+ const apply = corpusSet("apply-after-setup");
301
+ apply.planDigest = digestOfPlan;
302
+ apply.repository.baseCommit = baseCommit;
303
+ const applyTextsMap = { ...texts };
304
+ applyTexts(apply, applyTextsMap, true);
305
+ options.editApply?.(apply, applyTextsMap);
306
+ reseal(apply);
307
+ const sealedApply = apply as unknown as RepositoryChangeSet;
308
+ const applyBundle = bundleOf(plan0 as unknown as AdvisorPlan, [{ id: SITE_ID, set: sealedApply }], { computedAt: "2026-09-26T12:00:00Z" });
309
+ const applyWithBundle = { ...sealedApply, bundle: applyBundle.bundleDigest } as RepositoryChangeSet;
310
+
311
+ return { plan, planDigest: digestOfPlan, authority, setup: setupWithBundle, apply: applyWithBundle, texts, approvedBundle, applyBundle, ledgerBytes, ledger, tree, baseCommit };
312
+ }
313
+
314
+ /** The same world's apply set as a fresh copy that changed `edit` and was resealed (its `bundle` kept). */
315
+ export function mutateSet(set: RepositoryChangeSet, edit: (set: Loose) => void): RepositoryChangeSet {
316
+ const copy = clone(set) as unknown as Loose;
317
+ edit(copy);
318
+ const bundle = copy.bundle;
319
+ reseal(copy);
320
+ copy.bundle = bundle;
321
+ return copy as unknown as RepositoryChangeSet;
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // a temporary hub and a temporary clone
326
+
327
+ export function makeRoot(roots: string[], prefix = "launcher-admission-"): string {
328
+ const root = mkdtempSync(join(tmpdir(), prefix));
329
+ roots.push(root);
330
+ return root;
331
+ }
332
+
333
+ function writeTreeEntry(root: string, path: string, entry: BaseTreeEntry): void {
334
+ const target = join(root, path);
335
+ mkdirSync(dirname(target), { recursive: true });
336
+ if (entry.mode === "120000") {
337
+ symlinkSync(Buffer.from(entry.bytes).toString("utf8"), target);
338
+ return;
339
+ }
340
+ writeFileSync(target, entry.bytes);
341
+ if (entry.mode === "100755") chmodSync(target, 0o755);
342
+ }
343
+
344
+ export interface SiteRepoOptions {
345
+ readonly mergeStyle?: "direct" | "squash";
346
+ }
347
+
348
+ /**
349
+ * A local clone whose default branch holds exactly `tree`. With `squash`, the
350
+ * files arrive on a side branch and land as one new commit with no ancestor on
351
+ * that branch, the way a squash merge leaves them.
352
+ */
353
+ export function siteRepo(roots: string[], tree: BaseTree, options: SiteRepoOptions = {}): { clone: string; baseCommit: string; sideTip: string | null } {
354
+ const root = makeRoot(roots, "launcher-admission-site-");
355
+ const clonePath = join(root, "site");
356
+ mkdirSync(clonePath);
357
+ git(clonePath, "init", "-b", "main");
358
+ git(clonePath, "config", "core.autocrlf", "false");
359
+ writeFileSync(join(clonePath, "README.md"), "# Site\n");
360
+ git(clonePath, "add", "README.md");
361
+ git(clonePath, "commit", "-m", "init");
362
+ const writeAll = () => {
363
+ for (const [path, entry] of tree) if (path !== "README.md") writeTreeEntry(clonePath, path, entry);
364
+ git(clonePath, "add", "-A");
365
+ };
366
+ if (options.mergeStyle === "squash") {
367
+ git(clonePath, "checkout", "-b", "setup");
368
+ writeAll();
369
+ git(clonePath, "commit", "-m", "setup");
370
+ const sideTip = git(clonePath, "rev-parse", "HEAD").trim();
371
+ git(clonePath, "checkout", "main");
372
+ // An unrelated commit first, so the squashed commit is not a fast-forward of the side branch.
373
+ writeFileSync(join(clonePath, "NOTES.md"), "notes\n");
374
+ git(clonePath, "add", "NOTES.md");
375
+ git(clonePath, "commit", "-m", "notes");
376
+ git(clonePath, "merge", "--squash", "setup");
377
+ git(clonePath, "commit", "-m", "setup (squashed)");
378
+ return { clone: realpathSync(clonePath), baseCommit: git(clonePath, "rev-parse", "HEAD").trim(), sideTip };
379
+ }
380
+ writeAll();
381
+ git(clonePath, "commit", "-m", "setup");
382
+ return { clone: realpathSync(clonePath), baseCommit: git(clonePath, "rev-parse", "HEAD").trim(), sideTip: null };
383
+ }
384
+
385
+ /** A stub for advisor-execution-readiness: exit 0 when expiresAt is later than argv[3], 1 when not, 2 when unreadable. */
386
+ export const READINESS_STUB = `#!/usr/bin/env node
387
+ const fs = require("node:fs");
388
+ let document;
389
+ try {
390
+ document = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
391
+ } catch {
392
+ process.exit(2);
393
+ }
394
+ const engagement = document && document.engagement;
395
+ if (engagement && engagement.hang === true) setTimeout(() => process.exit(0), 60000);
396
+ else {
397
+ const authorization = engagement && engagement.executionAuthorization;
398
+ const expires = authorization ? Date.parse(authorization.expiresAt) : NaN;
399
+ const asOf = Date.parse(process.argv[3]);
400
+ if (Number.isNaN(expires) || Number.isNaN(asOf)) process.exit(2);
401
+ process.exit(expires > asOf ? 0 : 1);
402
+ }
403
+ `;
404
+
405
+ export function writeReadinessStub(hub: string): string {
406
+ const bin = join(hub, READINESS_BIN);
407
+ mkdirSync(dirname(bin), { recursive: true });
408
+ writeFileSync(bin, READINESS_STUB, { mode: 0o755 });
409
+ chmodSync(bin, 0o755);
410
+ return bin;
411
+ }
412
+
413
+ /** An assessment-input document carrying an execution authorization for a plan. */
414
+ export function assessmentFor(plan: AdvisorPlan, patch: Loose = {}, permitted: readonly string[] = [SITE_ID]): Loose {
415
+ // Every distinct package of the plan is permitted, whatever the repository: admission requires the list to equal the plan's packages exactly.
416
+ const packages = [...new Map((plan.packages ?? []).map(({ name, version, integrity }) => [`${name}@${version}#${integrity}`, { name, version, integrity }] as const)).values()];
417
+ return {
418
+ engagement: {
419
+ executionAuthorization: {
420
+ planDigest: planDigest(plan),
421
+ permittedRepositoryIds: [...permitted],
422
+ permittedPackages: packages,
423
+ grantedAt: "2026-09-24T12:00:00Z",
424
+ expiresAt: FAR_FUTURE,
425
+ ...patch,
426
+ },
427
+ },
428
+ };
429
+ }
430
+
431
+ export interface HubOptions {
432
+ /** The plans committed in order; the last one is the head. */
433
+ readonly plans: readonly AdvisorPlan[];
434
+ /** An uncommitted edit of plan.json in the worktree. */
435
+ readonly worktreePlan?: AdvisorPlan;
436
+ /** Commits plan.json as this instead of the JSON of a plan. */
437
+ readonly planBytes?: string;
438
+ readonly planMode?: "regular" | "symlink" | "executable" | "absent";
439
+ /** Detach HEAD at the commit with this index (0 is the first). */
440
+ readonly detachAt?: number;
441
+ /** The committed assessment-input.json: a document, or null to commit none. Default: a current authorization for the last plan. */
442
+ readonly assessment?: Loose | null;
443
+ /** Write the stub readiness executable into the hub's node_modules/.bin. Default true. */
444
+ readonly readiness?: boolean;
445
+ readonly sets?: readonly RepositoryChangeSet[];
446
+ readonly bundles?: readonly ApplyBundle[];
447
+ /** Put the hub in a subdirectory of the repository that holds it. */
448
+ readonly nested?: boolean;
449
+ /** Give the hub's branch an upstream: a bare origin the commits are pushed to. Default true; false leaves the branch with none. */
450
+ readonly upstream?: boolean;
451
+ }
452
+
453
+ export interface HubFixture {
454
+ readonly hub: string;
455
+ readonly repository: string;
456
+ readonly commits: readonly string[];
457
+ /** The bare origin the hub's branch tracks, or null when it has none. */
458
+ readonly origin: string | null;
459
+ }
460
+
461
+ /** A current authorization for the plan; none when the plan has no digest (it is invalid). */
462
+ function defaultAssessment(plan: AdvisorPlan): Loose | null {
463
+ try {
464
+ return assessmentFor(plan);
465
+ } catch {
466
+ return null;
467
+ }
468
+ }
469
+
470
+ const jsonText = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
471
+
472
+ /** A temporary git hub with plan.json (and assessment-input.json) committed, and sets and bundles stored in it. */
473
+ export function hubRepo(roots: string[], options: HubOptions): HubFixture {
474
+ const repository = realpathSync(makeRoot(roots, "launcher-admission-hub-"));
475
+ git(repository, "init", "-b", "main");
476
+ git(repository, "config", "core.autocrlf", "false");
477
+ const hub = options.nested === true ? join(repository, "nested", "hub") : repository;
478
+ mkdirSync(join(hub, "clossys", "advisor"), { recursive: true });
479
+ const commits: string[] = [];
480
+ const mode = options.planMode ?? "regular";
481
+ const last = options.plans[options.plans.length - 1]!;
482
+ const relative = (path: string) => (options.nested === true ? join("nested", "hub", path) : path);
483
+ for (const [index, plan] of options.plans.entries()) {
484
+ const target = join(hub, PLAN_FILE);
485
+ const text = options.planBytes ?? jsonText(plan);
486
+ const paths: string[] = [];
487
+ if (mode === "absent") {
488
+ writeFileSync(join(hub, "clossys", "advisor", "placeholder.txt"), `${index}\n`);
489
+ paths.push(relative("clossys/advisor/placeholder.txt"));
490
+ } else if (mode === "symlink") {
491
+ writeFileSync(join(hub, "clossys", "advisor", "plan-real.json"), text);
492
+ rmSync(target, { force: true });
493
+ symlinkSync("plan-real.json", target);
494
+ paths.push(relative(PLAN_FILE), relative("clossys/advisor/plan-real.json"));
495
+ } else {
496
+ writeFileSync(target, text);
497
+ if (mode === "executable") chmodSync(target, 0o755);
498
+ paths.push(relative(PLAN_FILE));
499
+ }
500
+ if (index === options.plans.length - 1) {
501
+ const document = options.assessment === undefined ? defaultAssessment(last) : options.assessment;
502
+ if (document !== null) {
503
+ writeFileSync(join(hub, ASSESSMENT_FILE), jsonText(document));
504
+ paths.push(relative(ASSESSMENT_FILE));
505
+ }
506
+ }
507
+ git(repository, "add", "-f", ...paths);
508
+ git(repository, "commit", "-m", `plan ${index}`);
509
+ commits.push(git(repository, "rev-parse", "HEAD").trim());
510
+ }
511
+ let origin: string | null = null;
512
+ if (options.upstream !== false) {
513
+ origin = join(makeRoot(roots, "launcher-admission-origin-"), "hub.git");
514
+ execFileSync("git", ["init", "--bare", "-b", "main", origin], { env: gitEnv, stdio: "ignore" });
515
+ git(repository, "remote", "add", "origin", origin);
516
+ git(repository, "push", "-q", "-u", "origin", "main");
517
+ }
518
+ if (options.detachAt !== undefined) git(repository, "checkout", "-q", "--detach", commits[options.detachAt]!);
519
+ if (options.worktreePlan !== undefined) {
520
+ rmSync(join(hub, PLAN_FILE), { force: true });
521
+ writeFileSync(join(hub, PLAN_FILE), jsonText(options.worktreePlan));
522
+ }
523
+ if (mode === "absent") writeFileSync(join(hub, PLAN_FILE), jsonText(last));
524
+ if (options.readiness !== false) writeReadinessStub(hub);
525
+ const realHub = realpathSync(hub);
526
+ HUB_HEADS.set(realHub, commits[commits.length - 1]!);
527
+ try {
528
+ LAST_HEADS.set(planDigest(last), commits[commits.length - 1]!);
529
+ } catch {
530
+ // An invalid plan has no digest, and no authority is built for it.
531
+ }
532
+ for (const set of options.sets ?? []) storeChangeSet(realHub, set);
533
+ for (const bundle of options.bundles ?? []) storeApplyBundle(realHub, bundle);
534
+ return { hub: realHub, repository, commits, origin };
535
+ }
536
+
537
+ /** Whether the hub's current branch has an upstream. */
538
+ function hasUpstream(hub: string): boolean {
539
+ try {
540
+ git(hub, "rev-parse", "--verify", "-q", "@{upstream}");
541
+ return true;
542
+ } catch {
543
+ return false;
544
+ }
545
+ }
546
+
547
+ /** Commits `plan` as clossys/advisor/plan.json at the hub's current branch, and pushes it when the branch has an upstream: one more decision, or a whole other plan. */
548
+ export function commitHubPlan(hub: string, plan: AdvisorPlan, message = "plan decision"): string {
549
+ writeFileSync(join(hub, PLAN_FILE), jsonText(plan));
550
+ git(hub, "add", "-f", PLAN_FILE);
551
+ git(hub, "commit", "-m", message);
552
+ if (hasUpstream(hub)) git(hub, "push", "-q");
553
+ return git(hub, "rev-parse", "HEAD").trim();
554
+ }
555
+
556
+ /** Commits a new assessment-input.json at the hub's current branch, and pushes it when the branch has an upstream. */
557
+ export function commitHubAssessment(hub: string, document: Loose, message = "assessment"): string {
558
+ writeFileSync(join(hub, ASSESSMENT_FILE), jsonText(document));
559
+ git(hub, "add", "-f", ASSESSMENT_FILE);
560
+ git(hub, "commit", "-m", message);
561
+ if (hasUpstream(hub)) git(hub, "push", "-q");
562
+ return git(hub, "rev-parse", "HEAD").trim();
563
+ }
564
+
565
+ /** Puts one more commit on the hub's origin from a separate clone, then fetches it: the hub's branch is now behind its upstream. */
566
+ export function advanceHubUpstream(roots: string[], hub: string, origin: string): void {
567
+ const other = join(makeRoot(roots, "launcher-admission-other-"), "hub");
568
+ execFileSync("git", ["clone", "-q", origin, other], { env: gitEnv, stdio: "ignore" });
569
+ git(other, "commit", "-q", "--allow-empty", "-m", "elsewhere");
570
+ git(other, "push", "-q", "origin", "main");
571
+ git(hub, "fetch", "-q", "origin");
572
+ }