canopycms 0.0.67-int.90 → 0.0.67

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 (539) hide show
  1. package/README.md +30 -3
  2. package/dist/ai/generate.js +1 -27
  3. package/dist/ai/handler.d.ts +0 -4
  4. package/dist/ai/handler.js +0 -11
  5. package/dist/ai/index.d.ts +1 -8
  6. package/dist/ai/index.js +1 -8
  7. package/dist/ai/json-to-markdown.js +15 -58
  8. package/dist/ai/resolve-branch.d.ts +2 -7
  9. package/dist/ai/resolve-branch.js +2 -8
  10. package/dist/ai/strip-mdx.d.ts +5 -13
  11. package/dist/ai/strip-mdx.js +5 -17
  12. package/dist/ai/to-plain-text.d.ts +10 -28
  13. package/dist/ai/to-plain-text.js +10 -28
  14. package/dist/ai/transform-components.d.ts +2 -4
  15. package/dist/ai/transform-components.js +8 -19
  16. package/dist/ai/types.d.ts +5 -10
  17. package/dist/api/admin-branch-health.d.ts +4 -12
  18. package/dist/api/admin-branch-health.js +26 -66
  19. package/dist/api/admin.d.ts +4 -12
  20. package/dist/api/admin.js +10 -26
  21. package/dist/api/assets.d.ts +0 -25
  22. package/dist/api/assets.js +50 -91
  23. package/dist/api/branch-merge.d.ts +1 -1
  24. package/dist/api/branch-merge.js +0 -2
  25. package/dist/api/branch-review.d.ts +0 -8
  26. package/dist/api/branch-review.js +0 -13
  27. package/dist/api/branch-status.d.ts +0 -3
  28. package/dist/api/branch-status.js +8 -33
  29. package/dist/api/branch-withdraw.d.ts +0 -1
  30. package/dist/api/branch-withdraw.js +6 -9
  31. package/dist/api/branch.d.ts +7 -12
  32. package/dist/api/branch.js +7 -44
  33. package/dist/api/client.d.ts +71 -224
  34. package/dist/api/client.js +82 -233
  35. package/dist/api/comments.d.ts +2 -4
  36. package/dist/api/comments.js +0 -17
  37. package/dist/api/content.d.ts +0 -6
  38. package/dist/api/content.js +66 -142
  39. package/dist/api/entries-constants.d.ts +5 -9
  40. package/dist/api/entries-constants.js +5 -9
  41. package/dist/api/entries.d.ts +8 -13
  42. package/dist/api/entries.js +20 -51
  43. package/dist/api/github-sync.d.ts +2 -4
  44. package/dist/api/github-sync.js +4 -8
  45. package/dist/api/groups.d.ts +3 -0
  46. package/dist/api/groups.js +8 -21
  47. package/dist/api/guards.d.ts +4 -16
  48. package/dist/api/guards.js +3 -22
  49. package/dist/api/index.d.ts +4 -4
  50. package/dist/api/permissions.d.ts +2 -0
  51. package/dist/api/permissions.js +18 -40
  52. package/dist/api/reference-options.d.ts +0 -1
  53. package/dist/api/reference-options.js +3 -11
  54. package/dist/api/request-body-hash.d.ts +12 -23
  55. package/dist/api/request-body-hash.js +12 -23
  56. package/dist/api/resolve-references.d.ts +0 -1
  57. package/dist/api/resolve-references.js +13 -28
  58. package/dist/api/route-builder.d.ts +3 -7
  59. package/dist/api/route-builder.js +1 -1
  60. package/dist/api/routes.d.ts +12 -0
  61. package/dist/api/routes.js +47 -0
  62. package/dist/api/schema.d.ts +23 -33
  63. package/dist/api/schema.js +20 -74
  64. package/dist/api/settings-helpers.js +4 -5
  65. package/dist/api/types.d.ts +0 -5
  66. package/dist/api/user.js +0 -5
  67. package/dist/api/validators.d.ts +14 -52
  68. package/dist/api/validators.js +14 -52
  69. package/dist/assets/asset-prefixes.d.ts +4 -8
  70. package/dist/assets/asset-prefixes.js +4 -8
  71. package/dist/assets/asset-src.d.ts +5 -22
  72. package/dist/assets/asset-src.js +5 -21
  73. package/dist/assets/factory.d.ts +1 -2
  74. package/dist/assets/factory.js +1 -2
  75. package/dist/assets/finalize.d.ts +3 -1
  76. package/dist/assets/finalize.js +3 -1
  77. package/dist/assets/index.d.ts +3 -3
  78. package/dist/assets/index.js +1 -1
  79. package/dist/assets/keys.d.ts +5 -3
  80. package/dist/assets/keys.js +6 -3
  81. package/dist/assets/pipeline.d.ts +3 -2
  82. package/dist/assets/pipeline.js +22 -56
  83. package/dist/assets/sharp-loader.d.ts +2 -10
  84. package/dist/assets/sharp-loader.js +2 -10
  85. package/dist/assets/store-local.d.ts +1 -2
  86. package/dist/assets/store-local.js +1 -2
  87. package/dist/assets/store-s3.d.ts +3 -4
  88. package/dist/assets/svg-sanitizer.d.ts +6 -14
  89. package/dist/assets/svg-sanitizer.js +6 -14
  90. package/dist/assets/transform-directives.d.ts +3 -4
  91. package/dist/assets/transform-directives.js +1 -1
  92. package/dist/assets/transform.d.ts +19 -30
  93. package/dist/assets/transform.js +18 -31
  94. package/dist/auth/cache.d.ts +2 -3
  95. package/dist/auth/cache.js +2 -3
  96. package/dist/auth/caching-auth-plugin.d.ts +11 -22
  97. package/dist/auth/caching-auth-plugin.js +12 -14
  98. package/dist/auth/context-helpers.d.ts +6 -20
  99. package/dist/auth/context-helpers.js +5 -17
  100. package/dist/auth/file-based-auth-cache.d.ts +10 -21
  101. package/dist/auth/file-based-auth-cache.js +23 -51
  102. package/dist/auth/plugin.d.ts +29 -61
  103. package/dist/auth/plugin.js +9 -13
  104. package/dist/auth/types.d.ts +11 -20
  105. package/dist/authorization/branch.d.ts +30 -49
  106. package/dist/authorization/branch.js +16 -42
  107. package/dist/authorization/content.d.ts +11 -40
  108. package/dist/authorization/content.js +13 -45
  109. package/dist/authorization/groups/index.d.ts +0 -3
  110. package/dist/authorization/groups/index.js +0 -3
  111. package/dist/authorization/groups/loader.d.ts +14 -28
  112. package/dist/authorization/groups/loader.js +14 -38
  113. package/dist/authorization/groups/schema.d.ts +0 -6
  114. package/dist/authorization/groups/schema.js +4 -7
  115. package/dist/authorization/helpers.d.ts +9 -28
  116. package/dist/authorization/helpers.js +9 -28
  117. package/dist/authorization/index.d.ts +3 -25
  118. package/dist/authorization/index.js +3 -35
  119. package/dist/authorization/path.d.ts +6 -13
  120. package/dist/authorization/path.js +8 -25
  121. package/dist/authorization/permissions/index.d.ts +0 -3
  122. package/dist/authorization/permissions/index.js +0 -3
  123. package/dist/authorization/permissions/loader.d.ts +14 -27
  124. package/dist/authorization/permissions/loader.js +14 -32
  125. package/dist/authorization/permissions/schema.d.ts +1 -6
  126. package/dist/authorization/permissions/schema.js +6 -13
  127. package/dist/authorization/protected-branch.d.ts +36 -65
  128. package/dist/authorization/protected-branch.js +29 -41
  129. package/dist/authorization/settings-file-store.d.ts +38 -81
  130. package/dist/authorization/settings-file-store.js +38 -79
  131. package/dist/authorization/types.d.ts +4 -26
  132. package/dist/authorization/types.js +0 -6
  133. package/dist/authorization/validation.d.ts +1 -23
  134. package/dist/authorization/validation.js +1 -24
  135. package/dist/branch-health.d.ts +39 -67
  136. package/dist/branch-health.js +27 -43
  137. package/dist/branch-metadata-file.d.ts +16 -32
  138. package/dist/branch-metadata-file.js +13 -26
  139. package/dist/branch-metadata.d.ts +49 -80
  140. package/dist/branch-metadata.js +57 -87
  141. package/dist/branch-registry.d.ts +26 -64
  142. package/dist/branch-registry.js +44 -93
  143. package/dist/branch-schema-cache.d.ts +34 -124
  144. package/dist/branch-schema-cache.js +49 -155
  145. package/dist/branch-workspace.d.ts +4 -4
  146. package/dist/branch-workspace.js +15 -19
  147. package/dist/build/generate-ai-content.d.ts +1 -0
  148. package/dist/build/generate-ai-content.js +12 -25
  149. package/dist/build-canopy.d.ts +17 -27
  150. package/dist/build-canopy.js +16 -23
  151. package/dist/build-mode.d.ts +25 -27
  152. package/dist/build-mode.js +25 -29
  153. package/dist/cli/cli.d.ts +15 -26
  154. package/dist/cli/cli.js +601 -978
  155. package/dist/cli/generate-ai-content.js +351 -628
  156. package/dist/cli/github-app-manifest.d.ts +84 -0
  157. package/dist/cli/github-app-manifest.js +161 -0
  158. package/dist/cli/init-github-app.d.ts +110 -444
  159. package/dist/cli/init-github-app.js +236 -773
  160. package/dist/cli/init.js +39 -80
  161. package/dist/cli/migrate.d.ts +13 -10
  162. package/dist/cli/migrate.js +22 -30
  163. package/dist/cli/project-detect.d.ts +17 -31
  164. package/dist/cli/project-detect.js +31 -42
  165. package/dist/cli/project-root.d.ts +4 -11
  166. package/dist/cli/project-root.js +4 -11
  167. package/dist/cli/prompt.d.ts +10 -0
  168. package/dist/cli/prompt.js +67 -0
  169. package/dist/cli/sync.d.ts +0 -8
  170. package/dist/cli/sync.js +8 -28
  171. package/dist/cli/template-files/middleware.ts.template +15 -29
  172. package/dist/cli/templates.d.ts +2 -12
  173. package/dist/cli/templates.js +4 -4
  174. package/dist/client.d.ts +1 -1
  175. package/dist/comment-store.d.ts +32 -53
  176. package/dist/comment-store.js +32 -52
  177. package/dist/config/flatten.d.ts +0 -3
  178. package/dist/config/flatten.js +2 -12
  179. package/dist/config/helpers.js +2 -2
  180. package/dist/config/index.d.ts +2 -2
  181. package/dist/config/index.js +1 -4
  182. package/dist/config/schemas/collection.d.ts +0 -8
  183. package/dist/config/schemas/collection.js +0 -10
  184. package/dist/config/schemas/config.d.ts +6 -78
  185. package/dist/config/schemas/config.js +38 -54
  186. package/dist/config/schemas/field.d.ts +2 -242
  187. package/dist/config/schemas/field.js +9 -20
  188. package/dist/config/schemas/media.js +1 -1
  189. package/dist/config/schemas/permissions.js +0 -2
  190. package/dist/config/schemas/url.d.ts +5 -6
  191. package/dist/config/schemas/url.js +5 -6
  192. package/dist/config/types.d.ts +45 -33
  193. package/dist/config/types.js +0 -1
  194. package/dist/config.d.ts +1 -8
  195. package/dist/config.js +1 -9
  196. package/dist/content-id-index.d.ts +76 -188
  197. package/dist/content-id-index.js +79 -230
  198. package/dist/content-index-generation.d.ts +27 -48
  199. package/dist/content-index-generation.js +35 -84
  200. package/dist/content-index-registry.d.ts +15 -24
  201. package/dist/content-index-registry.js +5 -9
  202. package/dist/content-listing.d.ts +64 -107
  203. package/dist/content-listing.js +68 -118
  204. package/dist/content-reader.d.ts +20 -29
  205. package/dist/content-reader.js +10 -18
  206. package/dist/content-store.d.ts +210 -336
  207. package/dist/content-store.js +413 -657
  208. package/dist/content-tree.d.ts +44 -86
  209. package/dist/content-tree.js +26 -54
  210. package/dist/context.d.ts +67 -94
  211. package/dist/context.js +53 -72
  212. package/dist/dev-content-watcher.d.ts +18 -18
  213. package/dist/dev-content-watcher.js +41 -44
  214. package/dist/editor/BranchManager.d.ts +3 -6
  215. package/dist/editor/BranchManager.js +24 -44
  216. package/dist/editor/CanopyEditor.js +5 -2
  217. package/dist/editor/CommentsPanel.js +0 -2
  218. package/dist/editor/Editor.js +19 -76
  219. package/dist/editor/EditorAuthGate.d.ts +27 -0
  220. package/dist/editor/EditorAuthGate.js +140 -0
  221. package/dist/editor/EntryNavigator.js +0 -20
  222. package/dist/editor/FormRenderer.d.ts +1 -2
  223. package/dist/editor/FormRenderer.js +8 -23
  224. package/dist/editor/admin/SystemHealthPanel.js +20 -37
  225. package/dist/editor/admin/useSystemHealth.d.ts +2 -3
  226. package/dist/editor/admin/useSystemHealth.js +10 -12
  227. package/dist/editor/client-reference-resolver.d.ts +2 -5
  228. package/dist/editor/client-reference-resolver.js +2 -19
  229. package/dist/editor/comments/BranchComments.js +0 -4
  230. package/dist/editor/comments/EntryComments.js +0 -4
  231. package/dist/editor/comments/FieldWrapper.js +0 -4
  232. package/dist/editor/comments/InlineCommentThread.js +0 -2
  233. package/dist/editor/comments/ThreadCarousel.d.ts +0 -58
  234. package/dist/editor/comments/ThreadCarousel.js +0 -12
  235. package/dist/editor/components/EditorFooter.d.ts +0 -8
  236. package/dist/editor/components/EditorFooter.js +0 -5
  237. package/dist/editor/components/EditorHeader.d.ts +0 -31
  238. package/dist/editor/components/EditorHeader.js +21 -71
  239. package/dist/editor/components/EditorSidebar.d.ts +0 -19
  240. package/dist/editor/components/EditorSidebar.js +0 -16
  241. package/dist/editor/components/EntryCreateModal.d.ts +4 -4
  242. package/dist/editor/components/EntryCreateModal.js +7 -22
  243. package/dist/editor/components/RenameEntryModal.js +0 -4
  244. package/dist/editor/components/UserBadge.js +0 -10
  245. package/dist/editor/components/index.js +0 -2
  246. package/dist/editor/context/ApiClientContext.d.ts +8 -7
  247. package/dist/editor/context/ApiClientContext.js +39 -11
  248. package/dist/editor/context/AssetContext.d.ts +7 -17
  249. package/dist/editor/context/AssetContext.js +7 -17
  250. package/dist/editor/context/EditorIdentityContext.d.ts +13 -0
  251. package/dist/editor/context/EditorIdentityContext.js +7 -0
  252. package/dist/editor/context/EditorStateContext.d.ts +10 -41
  253. package/dist/editor/context/EditorStateContext.js +7 -23
  254. package/dist/editor/context/SWRProvider.d.ts +3 -6
  255. package/dist/editor/context/index.d.ts +1 -20
  256. package/dist/editor/context/index.js +1 -20
  257. package/dist/editor/editor-utils.d.ts +8 -47
  258. package/dist/editor/editor-utils.js +19 -72
  259. package/dist/editor/fields/BlockField.d.ts +2 -2
  260. package/dist/editor/fields/BlockField.js +1 -2
  261. package/dist/editor/fields/CodeField.d.ts +0 -1
  262. package/dist/editor/fields/CodeField.js +0 -1
  263. package/dist/editor/fields/DateTimeField.d.ts +15 -22
  264. package/dist/editor/fields/DateTimeField.js +17 -26
  265. package/dist/editor/fields/ImageField.d.ts +2 -2
  266. package/dist/editor/fields/ImageField.js +8 -14
  267. package/dist/editor/fields/InlineGroupField.d.ts +0 -1
  268. package/dist/editor/fields/InlineGroupField.js +0 -1
  269. package/dist/editor/fields/MarkdownField.d.ts +0 -1
  270. package/dist/editor/fields/MarkdownField.js +6 -11
  271. package/dist/editor/fields/MdxImageDialog.d.ts +1 -1
  272. package/dist/editor/fields/MdxImageDialog.js +0 -1
  273. package/dist/editor/fields/NumberField.d.ts +9 -19
  274. package/dist/editor/fields/NumberField.js +11 -22
  275. package/dist/editor/fields/NumberListField.d.ts +7 -10
  276. package/dist/editor/fields/NumberListField.js +7 -10
  277. package/dist/editor/fields/ObjectField.d.ts +5 -8
  278. package/dist/editor/fields/ObjectField.js +0 -1
  279. package/dist/editor/fields/ReferenceField.d.ts +1 -1
  280. package/dist/editor/fields/ReferenceField.js +11 -16
  281. package/dist/editor/fields/SelectField.d.ts +2 -2
  282. package/dist/editor/fields/SelectField.js +0 -1
  283. package/dist/editor/fields/StringListField.d.ts +0 -4
  284. package/dist/editor/fields/StringListField.js +4 -9
  285. package/dist/editor/fields/TextField.d.ts +0 -1
  286. package/dist/editor/fields/TextField.js +0 -1
  287. package/dist/editor/fields/ToggleField.d.ts +0 -1
  288. package/dist/editor/fields/ToggleField.js +0 -1
  289. package/dist/editor/fields/entry-link/EntryLinkContext.js +2 -4
  290. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +0 -3
  291. package/dist/editor/fields/entry-link/InsertEntryLink.js +0 -4
  292. package/dist/editor/group-manager/ExternalGroupsTab.d.ts +0 -3
  293. package/dist/editor/group-manager/ExternalGroupsTab.js +0 -1
  294. package/dist/editor/group-manager/GroupCard.d.ts +0 -3
  295. package/dist/editor/group-manager/GroupForm.d.ts +0 -3
  296. package/dist/editor/group-manager/InternalGroupsTab.d.ts +0 -3
  297. package/dist/editor/group-manager/InternalGroupsTab.js +0 -2
  298. package/dist/editor/group-manager/MemberList.d.ts +0 -3
  299. package/dist/editor/group-manager/hooks/useExternalGroupSearch.d.ts +0 -3
  300. package/dist/editor/group-manager/hooks/useExternalGroupSearch.js +0 -3
  301. package/dist/editor/group-manager/hooks/useGroupState.d.ts +0 -3
  302. package/dist/editor/group-manager/hooks/useGroupState.js +0 -5
  303. package/dist/editor/group-manager/hooks/useUserSearch.d.ts +0 -3
  304. package/dist/editor/group-manager/hooks/useUserSearch.js +0 -3
  305. package/dist/editor/group-manager/index.d.ts +0 -6
  306. package/dist/editor/group-manager/index.js +0 -10
  307. package/dist/editor/group-manager/types.d.ts +2 -5
  308. package/dist/editor/group-manager/types.js +0 -3
  309. package/dist/editor/hooks/index.d.ts +6 -11
  310. package/dist/editor/hooks/index.js +6 -12
  311. package/dist/editor/hooks/useBranchActions.d.ts +0 -14
  312. package/dist/editor/hooks/useBranchActions.js +0 -22
  313. package/dist/editor/hooks/useBranchManager.d.ts +2 -48
  314. package/dist/editor/hooks/useBranchManager.js +4 -64
  315. package/dist/editor/hooks/useBranchesData.d.ts +7 -6
  316. package/dist/editor/hooks/useBranchesData.js +7 -6
  317. package/dist/editor/hooks/useCommentSystem.d.ts +0 -49
  318. package/dist/editor/hooks/useCommentSystem.js +1 -36
  319. package/dist/editor/hooks/useDraftManager.d.ts +2 -35
  320. package/dist/editor/hooks/useDraftManager.js +23 -64
  321. package/dist/editor/hooks/useEditorLayout.d.ts +0 -20
  322. package/dist/editor/hooks/useEditorLayout.js +0 -23
  323. package/dist/editor/hooks/useEntriesData.d.ts +1 -0
  324. package/dist/editor/hooks/useEntriesData.js +2 -3
  325. package/dist/editor/hooks/useEntryLinkResolution.d.ts +2 -2
  326. package/dist/editor/hooks/useEntryLinkResolution.js +2 -2
  327. package/dist/editor/hooks/useEntryManager.d.ts +2 -27
  328. package/dist/editor/hooks/useEntryManager.js +29 -78
  329. package/dist/editor/hooks/useGroupManager.d.ts +0 -21
  330. package/dist/editor/hooks/useGroupManager.js +0 -21
  331. package/dist/editor/hooks/usePermissionManager.d.ts +0 -20
  332. package/dist/editor/hooks/usePermissionManager.js +0 -20
  333. package/dist/editor/hooks/useReferenceResolution.d.ts +2 -33
  334. package/dist/editor/hooks/useReferenceResolution.js +1 -48
  335. package/dist/editor/hooks/useSchemaManager.d.ts +8 -27
  336. package/dist/editor/hooks/useSchemaManager.js +0 -19
  337. package/dist/editor/hooks/useUserContext.d.ts +2 -9
  338. package/dist/editor/hooks/useUserContext.js +10 -10
  339. package/dist/editor/hooks/useUserMetadata.d.ts +0 -2
  340. package/dist/editor/hooks/useUserMetadata.js +0 -3
  341. package/dist/editor/media/AssetCard.d.ts +0 -1
  342. package/dist/editor/media/AssetCard.js +1 -2
  343. package/dist/editor/media/CropStep.d.ts +0 -1
  344. package/dist/editor/media/CropStep.js +0 -1
  345. package/dist/editor/media/MediaLibrary.d.ts +1 -2
  346. package/dist/editor/media/MediaLibrary.js +1 -2
  347. package/dist/editor/media/MediaLibraryBody.d.ts +0 -1
  348. package/dist/editor/media/MediaLibraryBody.js +0 -1
  349. package/dist/editor/media/crop-math.d.ts +1 -1
  350. package/dist/editor/media/crop-math.js +1 -1
  351. package/dist/editor/permission-manager/GroupSelector.d.ts +0 -3
  352. package/dist/editor/permission-manager/PermissionEditor.d.ts +0 -4
  353. package/dist/editor/permission-manager/PermissionEditor.js +0 -1
  354. package/dist/editor/permission-manager/PermissionLevelBadge.d.ts +0 -7
  355. package/dist/editor/permission-manager/PermissionLevelBadge.js +0 -4
  356. package/dist/editor/permission-manager/PermissionTree.d.ts +0 -4
  357. package/dist/editor/permission-manager/UserSelector.d.ts +0 -3
  358. package/dist/editor/permission-manager/constants.d.ts +0 -3
  359. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.d.ts +0 -3
  360. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.js +0 -10
  361. package/dist/editor/permission-manager/hooks/usePermissionTree.d.ts +0 -14
  362. package/dist/editor/permission-manager/hooks/usePermissionTree.js +0 -11
  363. package/dist/editor/permission-manager/index.d.ts +0 -7
  364. package/dist/editor/permission-manager/index.js +0 -16
  365. package/dist/editor/permission-manager/types.d.ts +2 -12
  366. package/dist/editor/permission-manager/types.js +0 -3
  367. package/dist/editor/permission-manager/utils.d.ts +0 -28
  368. package/dist/editor/permission-manager/utils.js +9 -23
  369. package/dist/editor/preview-bridge.d.ts +5 -1
  370. package/dist/editor/preview-bridge.js +4 -4
  371. package/dist/editor/schema-editor/CollectionEditor.d.ts +0 -5
  372. package/dist/editor/schema-editor/CollectionEditor.js +9 -31
  373. package/dist/editor/schema-editor/EntryTypeEditor.d.ts +0 -6
  374. package/dist/editor/schema-editor/EntryTypeEditor.js +3 -24
  375. package/dist/editor/schema-editor/index.d.ts +0 -7
  376. package/dist/editor/schema-editor/index.js +0 -7
  377. package/dist/editor/utils/env.d.ts +3 -18
  378. package/dist/editor/utils/env.js +6 -25
  379. package/dist/entry-link-resolver.d.ts +13 -35
  380. package/dist/entry-link-resolver.js +20 -53
  381. package/dist/entry-schema-registry.d.ts +20 -60
  382. package/dist/entry-schema-registry.js +20 -69
  383. package/dist/entry-schema.d.ts +118 -223
  384. package/dist/entry-schema.js +46 -91
  385. package/dist/git-manager.d.ts +101 -200
  386. package/dist/git-manager.js +211 -384
  387. package/dist/github-service.d.ts +34 -75
  388. package/dist/github-service.js +37 -91
  389. package/dist/http/handler.d.ts +6 -37
  390. package/dist/http/handler.js +65 -120
  391. package/dist/http/router.d.ts +15 -32
  392. package/dist/http/router.js +34 -99
  393. package/dist/http/types.d.ts +15 -40
  394. package/dist/http/types.js +3 -7
  395. package/dist/id.d.ts +4 -10
  396. package/dist/id.js +4 -10
  397. package/dist/index.js +6 -9
  398. package/dist/operating-mode/client-safe-strategy.d.ts +7 -14
  399. package/dist/operating-mode/client-safe-strategy.js +8 -28
  400. package/dist/operating-mode/client-unsafe-strategy.d.ts +6 -16
  401. package/dist/operating-mode/client-unsafe-strategy.js +9 -34
  402. package/dist/operating-mode/client.d.ts +3 -10
  403. package/dist/operating-mode/client.js +3 -8
  404. package/dist/operating-mode/deployment-name-fixtures.d.ts +14 -18
  405. package/dist/operating-mode/deployment-name-fixtures.js +14 -18
  406. package/dist/operating-mode/deployment-name.d.ts +19 -29
  407. package/dist/operating-mode/deployment-name.js +29 -47
  408. package/dist/operating-mode/index.d.ts +4 -25
  409. package/dist/operating-mode/index.js +9 -31
  410. package/dist/operating-mode/mode-env.d.ts +32 -54
  411. package/dist/operating-mode/mode-env.js +36 -58
  412. package/dist/operating-mode/types.d.ts +24 -72
  413. package/dist/operating-mode/types.js +1 -7
  414. package/dist/paths/branch-name.d.ts +30 -40
  415. package/dist/paths/branch-name.js +30 -40
  416. package/dist/paths/branch.d.ts +4 -18
  417. package/dist/paths/branch.js +6 -22
  418. package/dist/paths/index.d.ts +2 -13
  419. package/dist/paths/index.js +3 -23
  420. package/dist/paths/normalize-server.d.ts +3 -15
  421. package/dist/paths/normalize-server.js +3 -15
  422. package/dist/paths/normalize.d.ts +13 -33
  423. package/dist/paths/normalize.js +13 -33
  424. package/dist/paths/resolve.d.ts +4 -17
  425. package/dist/paths/resolve.js +4 -22
  426. package/dist/paths/types.d.ts +12 -34
  427. package/dist/paths/types.js +5 -7
  428. package/dist/paths/validation.d.ts +22 -119
  429. package/dist/paths/validation.js +30 -148
  430. package/dist/reference-resolver.d.ts +0 -13
  431. package/dist/reference-resolver.js +0 -18
  432. package/dist/resolve-canopy-user.d.ts +16 -18
  433. package/dist/resolve-canopy-user.js +17 -26
  434. package/dist/resource-generation.d.ts +16 -25
  435. package/dist/resource-generation.js +34 -97
  436. package/dist/schema/meta-loader.d.ts +2 -14
  437. package/dist/schema/meta-loader.js +15 -64
  438. package/dist/schema/resolver.d.ts +1 -4
  439. package/dist/schema/resolver.js +1 -6
  440. package/dist/schema/schema-store-types.d.ts +0 -12
  441. package/dist/schema/schema-store.d.ts +61 -138
  442. package/dist/schema/schema-store.js +85 -282
  443. package/dist/schema/types.d.ts +0 -4
  444. package/dist/server.d.ts +62 -93
  445. package/dist/server.js +63 -94
  446. package/dist/services.d.ts +23 -47
  447. package/dist/services.js +55 -98
  448. package/dist/settings-workspace.d.ts +17 -30
  449. package/dist/settings-workspace.js +51 -81
  450. package/dist/static/index.d.ts +26 -52
  451. package/dist/static/index.js +33 -67
  452. package/dist/{worker/task-queue.d.ts → task-queue/cms-task-queue.d.ts} +7 -9
  453. package/dist/task-queue/cms-task-queue.js +13 -0
  454. package/dist/task-queue/task-queue-config.d.ts +3 -0
  455. package/dist/{worker → task-queue}/task-queue-config.js +1 -6
  456. package/dist/task-queue/task-queue.d.ts +16 -22
  457. package/dist/task-queue/task-queue.js +20 -44
  458. package/dist/task-queue/types.d.ts +0 -1
  459. package/dist/task-queue/worker-status.d.ts +35 -0
  460. package/dist/task-queue/worker-status.js +40 -0
  461. package/dist/types.d.ts +40 -51
  462. package/dist/url-collision.d.ts +26 -40
  463. package/dist/url-collision.js +47 -63
  464. package/dist/url-exclusivity-fixtures.d.ts +15 -20
  465. package/dist/url-exclusivity-fixtures.js +14 -19
  466. package/dist/url-path-resolver.d.ts +8 -16
  467. package/dist/url-path-resolver.js +19 -29
  468. package/dist/user.d.ts +8 -21
  469. package/dist/user.js +10 -19
  470. package/dist/utils/async-mutex.js +4 -6
  471. package/dist/utils/atomic-write.d.ts +2 -11
  472. package/dist/utils/atomic-write.js +2 -11
  473. package/dist/utils/body-field.d.ts +8 -15
  474. package/dist/utils/body-field.js +8 -15
  475. package/dist/utils/content-serialize.d.ts +18 -24
  476. package/dist/utils/content-serialize.js +109 -160
  477. package/dist/utils/content-write-lock.d.ts +51 -106
  478. package/dist/utils/content-write-lock.js +66 -111
  479. package/dist/utils/debug.d.ts +7 -23
  480. package/dist/utils/debug.js +7 -23
  481. package/dist/utils/entry-url.d.ts +11 -23
  482. package/dist/utils/entry-url.js +11 -26
  483. package/dist/utils/error.d.ts +25 -90
  484. package/dist/utils/error.js +60 -147
  485. package/dist/utils/flatten-group-fields.d.ts +5 -7
  486. package/dist/utils/flatten-group-fields.js +5 -7
  487. package/dist/utils/format.d.ts +1 -5
  488. package/dist/utils/format.js +1 -5
  489. package/dist/utils/fs.d.ts +3 -4
  490. package/dist/utils/fs.js +3 -4
  491. package/dist/utils/git.d.ts +52 -83
  492. package/dist/utils/git.js +79 -125
  493. package/dist/utils/logger.d.ts +35 -50
  494. package/dist/utils/logger.js +35 -52
  495. package/dist/utils/provisioning-lock.d.ts +23 -35
  496. package/dist/utils/provisioning-lock.js +49 -69
  497. package/dist/utils/sanitize-href.d.ts +5 -10
  498. package/dist/utils/sanitize-href.js +8 -18
  499. package/dist/utils/title-field.d.ts +17 -20
  500. package/dist/utils/title-field.js +18 -27
  501. package/dist/utils/typed-filename.d.ts +20 -48
  502. package/dist/utils/typed-filename.js +25 -61
  503. package/dist/utils/url-prefix.d.ts +3 -3
  504. package/dist/utils/url-prefix.js +3 -3
  505. package/dist/validation/block-structural-keys.d.ts +16 -31
  506. package/dist/validation/block-structural-keys.js +19 -29
  507. package/dist/validation/deletion-checker.d.ts +0 -34
  508. package/dist/validation/deletion-checker.js +1 -43
  509. package/dist/validation/entry-link-validator.d.ts +6 -13
  510. package/dist/validation/entry-link-validator.js +4 -13
  511. package/dist/validation/entry-type-reference-validator.d.ts +13 -18
  512. package/dist/validation/entry-type-reference-validator.js +13 -18
  513. package/dist/validation/entry-validator.d.ts +13 -25
  514. package/dist/validation/entry-validator.js +15 -32
  515. package/dist/validation/field-traversal.d.ts +3 -30
  516. package/dist/validation/field-traversal.js +1 -32
  517. package/dist/validation/reference-validator.d.ts +5 -24
  518. package/dist/validation/reference-validator.js +5 -30
  519. package/dist/worker/cms-worker.d.ts +172 -232
  520. package/dist/worker/cms-worker.js +241 -333
  521. package/dist/worker/git-sync.d.ts +32 -133
  522. package/dist/worker/git-sync.js +129 -191
  523. package/dist/worker/github-auth.d.ts +13 -19
  524. package/dist/worker/github-auth.js +13 -19
  525. package/dist/worker/history-rewrite.d.ts +53 -66
  526. package/dist/worker/history-rewrite.js +54 -60
  527. package/dist/worker/log.d.ts +24 -41
  528. package/dist/worker/log.js +25 -42
  529. package/dist/worker/rebase.d.ts +20 -110
  530. package/dist/worker/rebase.js +279 -354
  531. package/dist/worker/task-runner.d.ts +32 -66
  532. package/dist/worker/task-runner.js +126 -162
  533. package/dist/worker/worker-context.d.ts +41 -81
  534. package/package.json +4 -4
  535. package/dist/cli/template-files/middleware-clerk.ts.template +0 -37
  536. package/dist/worker/task-queue-config.d.ts +0 -8
  537. package/dist/worker/task-queue.js +0 -19
  538. package/dist/worker/worker-status.d.ts +0 -47
  539. package/dist/worker/worker-status.js +0 -52
@@ -1,93 +1,27 @@
1
1
  /**
2
- * `canopycms init-github-app <create|verify>` — registers the GitHub App the CMS
3
- * worker authenticates as, and reads its grant back.
4
- *
5
- * WHY THIS EXISTS: THE PERMISSIONS ARE CODE HERE
6
- *
7
- * The worker can authenticate as a GitHub App instead of a personal access token
8
- * (adopter request #45). What did not ship with it was any machine-checkable
9
- * statement of which permissions that App needs — the answer lived only as prose
10
- * in `docs/deploying-to-aws.md`, which nothing derives and nothing verifies.
11
- *
12
- * A PAT's scope only ever exists as checkboxes somebody ticked, so "is this
13
- * credential wider than intended?" is unanswerable from the repository. The
14
- * manifest flow takes the permission set as JSON, so `CANOPY_APP_PERMISSIONS`
15
- * below is reviewable, diffable, and a desired state a program can check —
16
- * against the call sites that force each entry (see the constant) and against
17
- * what an installation actually holds (see `verify`).
18
- *
19
- * And guessing too narrow does not fail loudly. `task-runner.ts`'s
20
- * `convert-to-draft` runs a GraphQL mutation, and a GraphQL failure answers HTTP
21
- * 200 with a body-level error carrying NO numeric status — so
22
- * `isPermanentTaskFailure` reads a permission denial as transient, retries to the
23
- * cap, and wedges the branch in `sync-failed` with nothing naming a permission.
24
- * `createOrUpdatePullRequest` swallows a failed `markPullRequestReadyForReview`
25
- * into a warning by design (`github-service.ts`), leaving the PR stuck as a
26
- * draft. Both surface hours after setup. `verify` moves that discovery to setup
27
- * time, which is the only cheap place to find it.
28
- *
29
- * ONE APP PER SITE. NOT ONE APP PER ORGANISATION.
30
- *
31
- * Worth stating at length because the obvious economy is to register one App and
32
- * install it on every repository that needs it, and for a READ-ONLY App whose key
33
- * never leaves GitHub that is right. It is wrong here, and the reason is
34
- * structural rather than a matter of taste:
35
- *
36
- * **A GitHub App's private key is App-level. Installation scoping is not a
37
- * boundary GitHub enforces against whoever holds the key — it is a choice the
38
- * key-holder makes at mint time.** Anyone with the key can sign a JWT, call
39
- * `GET /app/installations` to enumerate every installation, and mint an access
40
- * token for any of them.
41
- *
42
- * Our key does not stay in one place: each site's worker reads it at runtime,
43
- * from that site's own secret store, on that site's own host. So one shared App
44
- * with `contents: write` would mean that reading the marketing site's secret
45
- * store hands you write access to the documentation site's repository. One App
46
- * per site, one installation each, one key each — blast radius contained to the
47
- * site that leaked.
48
- *
49
- * The cost is real and is not pretended away: N sites means N Apps, N keys and N
50
- * things to rotate. That is affordable only because creating one is cheap, which
51
- * is most of why this command exists.
52
- *
53
- * WHAT THE MANIFEST FLOW BUYS, WHICH IS MORE THAN IT LOOKS
54
- *
55
- * `redirect_url` is the point of it. GitHub redirects there with a one-hour code,
56
- * and `POST /app-manifests/{code}/conversions` exchanges that code for the App's
57
- * id, client id and **the private key itself**. So `create` runs a one-shot
58
- * loopback server, captures the key in memory, and hands it straight to a
59
- * destination the operator named. Sent to a command's stdin — the recommended
60
- * route — **the key never touches disk**, and nobody has to find a downloaded
61
- * `.pem`. `--key-out` deliberately does write it to a file, for an operator with
62
- * no such command; that is the trade, not an oversight.
63
- *
64
- * MEASURED (2026-09-05, in a sibling project) and it contradicts the
65
- * documentation: GitHub's REST docs list `redirect_url` as OPTIONAL, and the
66
- * organisation App-creation form refuses a manifest without it — "Invalid GitHub
67
- * App configuration ... Error 'redirect_url' wasn't supplied."
68
- *
69
- * THIS FILE TALKS TO GITHUB WITH `fetch`, NOT OCTOKIT, DELIBERATELY
70
- *
71
- * Two reasons. An App JWT requires `Authorization: Bearer <jwt>`, and a client
72
- * that sends `token <value>` is answered with `401 A JSON web token could not be
73
- * decoded` — measured in the sibling project, where it presented as "no
74
- * installation found" and sent the operator to check a page that was correct.
75
- * And Octokit here would add `octokit.apps.*` call sites to this package, which
76
- * the guard in `github-app-permission-drift.test.ts` would then have to carve an
77
- * exception for — an exception being exactly how a guard goes blind.
78
- *
79
- * The JWT is hand-rolled over `node:crypto` for a third reason that is not
80
- * optional: `.dependency-cruiser.mjs`'s `core-no-github-app-auth` rule scopes to
81
- * every package's own `src/`, so importing `@octokit/auth-app` here is a lint
82
- * error, same as anywhere else in this package.
83
- *
84
- * THE KEY'S DESTINATION IS NOT THIS TOOL'S BUSINESS
85
- *
86
- * `create` takes either a command to pipe the PEM into (`-- <cmd>`) or a file to
87
- * write (`--key-out`), and asks for a file path if that first one fails. It
88
- * knows nothing about AWS, or any other secret store: an adopter may not deploy
89
- * to AWS at all, and a setup tool that hardcodes one cloud is a setup tool for
90
- * one adopter. `docs/deploying-to-aws.md` carries the worked invocation.
2
+ * `canopycms init-github-app <create|verify>` — registers the GitHub App the
3
+ * CMS worker authenticates as, and reads its grant back. A PAT's scope is
4
+ * unauditable — checkboxes somebody ticked, nowhere recorded.
5
+ * `CANOPY_APP_PERMISSIONS` in github-app-manifest.ts is the machine-checkable statement of what
6
+ * this App needs instead, checked against the call sites that force each
7
+ * entry (see the constant) and what an installation holds (see `verify`). A
8
+ * missing permission fails silently — a GraphQL denial answers HTTP 200 with
9
+ * no numeric status — so `verify` exists to catch that at setup time.
10
+ * One App per site, never shared across an organisation: a GitHub App's
11
+ * private key is App-level — GitHub does not enforce per-installation
12
+ * scoping against the key-holder — so anyone holding it can mint a token for
13
+ * every installation. Each site's worker reads its own key from its own
14
+ * secret store, so sharing one App would let one leaked store reach every
15
+ * other site's repository.
16
+ * `create` runs a one-shot loopback server, exchanges GitHub's redirect code
17
+ * for the App's credentials including the private key, then hands it to the
18
+ * destination the operator named — over a command's stdin so it never
19
+ * touches disk, or to a file with `--key-out`. That destination is not this
20
+ * tool's business: it knows nothing about AWS or any other secret store.
21
+ * This file talks to GitHub with `fetch`, not Octokit: an App JWT needs
22
+ * `Authorization: Bearer <jwt>` (sent as `token <value>` it gets a misleading
23
+ * 401), and `.dependency-cruiser.mjs` forbids importing `@octokit/auth-app`
24
+ * from this package's `src/` anyway.
91
25
  */
92
26
  import { spawn } from 'node:child_process';
93
27
  import { createSign, randomUUID } from 'node:crypto';
@@ -95,140 +29,19 @@ import { createServer } from 'node:http';
95
29
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
96
30
  import { tmpdir } from 'node:os';
97
31
  import path from 'node:path';
98
- import { createInterface } from 'node:readline';
99
32
  import { Readable } from 'node:stream';
100
33
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
101
34
  import { normalizeGitHubAppPrivateKey } from '../worker/github-auth.js';
102
- /** Permission levels GitHub uses for App repository permissions, weakest first. */
103
- export const PERMISSION_LEVELS = ['read', 'write', 'admin'];
104
- /**
105
- * The App's entire security surface, and the derivation behind it.
106
- *
107
- * Every entry names the call site that forces it, because a permission whose
108
- * justification lives only in a commit message cannot be re-checked. Enumerated
109
- * from the only two modules in this package that issue GitHub calls —
110
- * `github-service.ts` and `worker/{task-runner,rebase}.ts` — plus every git
111
- * operation that reaches github.com. `canopycms-cdk` makes no REST calls at all.
112
- *
113
- * - `contents: write`
114
- * - git over HTTPS: the first-boot bare clone (`worker/cms-worker.ts`), the
115
- * per-cycle fetch of all branches (`worker/git-sync.ts`), and the branch
116
- * pushes including `--force-with-lease` (`worker/task-runner.ts`).
117
- * - `octokit.git.deleteRef` (`github-service.ts`, `worker/task-runner.ts`).
118
- * GitHub's permissions reference puts `DELETE /repos/{o}/{r}/git/refs/{ref}`
119
- * under Contents/write — NOT `administration`, which is the plausible wrong
120
- * guess. It is also dead code today: nothing enqueues `delete-remote-branch`
121
- * and `GitHubService.deleteBranch`'s only call site is commented out, so it
122
- * forces nothing that pushing does not already force. Declared anyway
123
- * because the handler is live the moment a producer appears.
124
- *
125
- * - `pull_requests: write`
126
- * - `pulls.create` and `pulls.update` (`github-service.ts`,
127
- * `worker/task-runner.ts`). GitHub's reference: Create and Update a pull
128
- * request are Pull requests/write. `pulls.list` and `pulls.get` are
129
- * Pull requests/read, subsumed by this.
130
- * - the GraphQL mutations `markPullRequestReadyForReview` and
131
- * `convertPullRequestToDraft`. **This is the one entry with no
132
- * documentation line behind it** — GitHub's permissions reference
133
- * enumerates REST endpoints only — so it is derived by analogy with the
134
- * REST PR mutations. A live run is the oracle, and until one has happened
135
- * this comment is the honest state of it.
136
- *
137
- * - `metadata: read` — implied: GitHub grants it alongside any repository
138
- * permission. Declared explicitly so this object states the whole surface
139
- * rather than the part that is not automatic.
140
- *
141
- * Nothing else. No `issues` (there are no `issues.*` calls — nothing sets labels
142
- * or assignees, which is the usual reason a PR bot needs it), no `administration`,
143
- * no `actions`, and no organisation permissions. No `workflows`: nothing in the
144
- * worker edits `.github/workflows/`, but GitHub may still refuse to push rebased
145
- * history that carries a base-branch change to it — see
146
- * .claude/future-tasks/worker-push-refused-when-base-changes-workflows.md, an
147
- * open P1.
148
- */
149
- export const CANOPY_APP_PERMISSIONS = {
150
- contents: 'write',
151
- pull_requests: 'write',
152
- metadata: 'read',
153
- };
154
- /**
155
- * GitHub's limit on an App's display name.
156
- *
157
- * MEASURED 2026-09-06 in a sibling project, by having a 39-character name
158
- * refused while a 33-character one was accepted; the documentation states no
159
- * limit at all. So the true bound is somewhere in 33..38 and 34 is the safe
160
- * direction: a wrongly-refused name costs the operator one `--name` flag, while a
161
- * wrongly-accepted one costs a browser round trip to find out. Re-measure when a
162
- * real App is created from this file.
163
- */
164
- export const APP_NAME_MAX_LENGTH = 34;
165
- /**
166
- * How much of a description the account's App LIST renders before truncating —
167
- * mid-word, with an ellipsis.
168
- *
169
- * MEASURED 2026-09-06 in the same project: a description opening "Read-only,
170
- * organisation-wide. Lets th…" was cut at 37 characters. The list appears to
171
- * truncate by CHARACTER rather than by line, so a newline does not rescue a long
172
- * opening sentence. Hence the shape of `appDescription`: a standalone summary
173
- * inside this budget, a blank line, then the detail only the App's own page
174
- * shows.
175
- */
176
- export const APP_SUMMARY_MAX_LENGTH = 37;
35
+ import { APP_NAME_MAX_LENGTH, CANOPY_APP_PERMISSIONS, appDescription, appName, appSlug, readbackVerdict, } from './github-app-manifest.js';
36
+ import { askLine, pressEnter } from './prompt.js';
177
37
  /** How long `create` waits for the browser round trip before giving up. */
178
- export const CALLBACK_TIMEOUT_MS = 10 * 60 * 1000;
38
+ const CALLBACK_TIMEOUT_MS = 10 * 60 * 1000;
179
39
  /** How long any single call to api.github.com may take. */
180
40
  const REQUEST_TIMEOUT_MS = 30_000;
181
41
  /**
182
- * The App's display name, and the slug GitHub derives from it.
183
- *
184
- * Named after the REPOSITORY, because the App is per-site (see the file header):
185
- * an App named after the organisation would suggest it is shared, which is the
186
- * arrangement this file exists to avoid.
187
- *
188
- * GitHub App names are unique across the WHOLE of GitHub, not merely within an
189
- * account, so a short generic name is plausibly already taken by somebody else.
190
- * `create` pre-checks what it can and `--name` overrides; see
191
- * `checkNameAvailable`.
192
- */
193
- export function appName(repo) {
194
- return `${repo} CanopyCMS`;
195
- }
196
- /**
197
- * GitHub's slug derivation, as far as this needs it: lowercase, and runs of
198
- * anything that is not alphanumeric collapse to single hyphens.
199
- *
200
- * Pinned by a test. If GitHub ever derives differently the name pre-check goes
201
- * blind rather than loud, so this is not something to assume.
202
- */
203
- export function appSlug(name) {
204
- return name
205
- .toLowerCase()
206
- .replace(/[^a-z0-9]+/g, '-')
207
- .replace(/^-|-$/g, '');
208
- }
209
- /**
210
- * A short summary that survives the App list's truncation, then the detail.
211
- *
212
- * Two audiences, two parts. Deliberately generic in both: this text is stored ON
213
- * GITHUB rather than here, so anything in it that can go stale goes stale
214
- * silently, with nothing in this repository able to detect or fix it. It
215
- * therefore says only what stays true of the App itself — what it does, that it
216
- * is installed on one repository, and that it is registered per site.
217
- */
218
- export function appDescription() {
219
- return [
220
- 'Commits content edits, opens PRs',
221
- '',
222
- 'Lets CanopyCMS publish edits made in its editor: it pushes content branches to this ' +
223
- 'repository and opens or updates the pull request that carries them. Installed on one ' +
224
- 'repository, and registered per site — so this key cannot reach another site’s repository.',
225
- ].join('\n');
226
- }
227
- /**
228
- * Where the manifest form posts, which differs by account type.
229
- *
230
- * A solo adopter who owns the content repository personally needs the user-level
231
- * URL; posting an organisation manifest to it (or the reverse) fails at the form.
42
+ * Where the manifest form posts: user-owned and organisation repositories use
43
+ * different URLs, and posting the wrong one fails at the form.
44
+ * @internal Exported for tests.
232
45
  */
233
46
  export function manifestPostUrl(target, state) {
234
47
  const base = target.isOrganization
@@ -240,12 +53,10 @@ export function manifestPostUrl(target, state) {
240
53
  return `${base}?state=${encodeURIComponent(state)}`;
241
54
  }
242
55
  /**
243
- * The App as GitHub's manifest flow takes it.
244
- *
245
- * `default_permissions` is the entire security surface and lives in
246
- * `CANOPY_APP_PERMISSIONS`, where each entry carries the call site that forces
247
- * it. `default_events` is empty and the webhook is inactive: nothing is ever
248
- * delivered to this App — it is only ever assumed outward by the worker.
56
+ * The App as GitHub's manifest flow takes it. `default_permissions` is the
57
+ * entire security surface, defined in `CANOPY_APP_PERMISSIONS`. The webhook is
58
+ * declared but inactive: nothing is ever delivered to this App.
59
+ * @internal Exported for tests.
249
60
  */
250
61
  export function appManifest(target, redirectUrl, name = appName(target.repo)) {
251
62
  return {
@@ -269,12 +80,10 @@ function escapeHtml(value) {
269
80
  .replace(/'/g, '&#39;');
270
81
  }
271
82
  /**
272
- * The auto-submitting form that carries the manifest to GitHub.
273
- *
274
- * A manifest can only be delivered as a form POST from a browser, which is why
275
- * this exists at all. Both interpolations are escaped: the manifest embeds
276
- * adopter-supplied owner and repository names, and this string is written to a
277
- * file that a browser then executes.
83
+ * The auto-submitting form that carries the manifest to GitHub — a manifest
84
+ * can only be delivered as a browser form POST. Both interpolations are
85
+ * escaped: this embeds adopter-supplied names into a file a browser executes.
86
+ * @internal Exported for tests.
278
87
  */
279
88
  export function creationForm(target, manifest) {
280
89
  return `<!doctype html><meta charset="utf-8"><title>Create the CanopyCMS App</title>
@@ -287,12 +96,11 @@ export function creationForm(target, manifest) {
287
96
  </body>`;
288
97
  }
289
98
  /**
290
- * A short-lived App JWT, signed RS256 with `node:crypto`.
291
- *
292
- * `iat` is backdated 60s because GitHub rejects a JWT whose `iat` is in its own
293
- * future, and a second of clock skew between here and GitHub is entirely
294
- * ordinary. `exp` leaves headroom rather than sitting on GitHub's stated
295
- * ten-minute maximum for no benefit.
99
+ * A short-lived App JWT, signed RS256 with `node:crypto`. `iat` is backdated
100
+ * 60s because GitHub rejects a JWT whose `iat` is in its own future, and a
101
+ * second of clock skew is ordinary; `exp` leaves headroom rather than sitting
102
+ * on GitHub's stated ten-minute maximum for no benefit.
103
+ * @internal Exported for tests.
296
104
  */
297
105
  export function appJwt(issuer, privateKey, now = Math.floor(Date.now() / 1000)) {
298
106
  const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
@@ -306,15 +114,12 @@ export function appJwt(issuer, privateKey, now = Math.floor(Date.now() / 1000))
306
114
  return `${body}.${signer.sign(privateKey).toString('base64url')}`;
307
115
  }
308
116
  /**
309
- * One call to api.github.com.
310
- *
311
- * Returns the status and body rather than collapsing failure to `null`, because
312
- * the two failures a caller has to tell apart here — "this credential cannot
313
- * authenticate" and "this App is not installed on that repository" — send an
314
- * operator to entirely different pages. Collapsing them is measured to have sent
315
- * a real run's operator to check a page that was correct.
117
+ * One call to api.github.com. Returns the status and body rather than
118
+ * collapsing failure to `null`: "cannot authenticate" and "not installed"
119
+ * send an operator to different pages, and collapsing them has sent an
120
+ * operator to check a page that was correct.
316
121
  */
317
- export async function githubRequest(apiPath, init = {}) {
122
+ async function githubRequest(apiPath, init = {}) {
318
123
  const headers = {
319
124
  accept: 'application/vnd.github+json',
320
125
  'x-github-api-version': '2022-11-28',
@@ -337,11 +142,10 @@ export async function githubRequest(apiPath, init = {}) {
337
142
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
338
143
  ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
339
144
  });
340
- // Reading the BODY is inside the try as well, not just the fetch. It is a
341
- // second chance to reject — a connection reset after the headers, or the
342
- // timeout above firing mid-read — and a rejection escaping here would
343
- // propagate out of `create` past the point where the private key is still
344
- // held. Every failure this function can have is a returned value.
145
+ // Reading the BODY is inside the try too, not just the fetch: a connection
146
+ // reset or the timeout above can still reject here, and letting that escape
147
+ // would propagate past the point in `create` where the key is still held.
148
+ // Every failure this function can have is a returned value, not a throw.
345
149
  text = await response.text();
346
150
  }
347
151
  catch (err) {
@@ -354,146 +158,34 @@ export async function githubRequest(apiPath, init = {}) {
354
158
  catch {
355
159
  body = null;
356
160
  }
357
- // Computed only for a FAILURE. On success the body is the App's credentials,
358
- // and a 200-character prefix of it is not something to hand a caller that
359
- // might print it — `redactCredentials` covers a PEM and a `ghs_` token but
360
- // would not save a truncated `client_secret`.
161
+ // Computed only for a FAILURE: on success the body is App credentials, not
162
+ // something to hand a caller that might print it. `redactCredentials` covers
163
+ // a PEM and a `ghs_` token but would not save a truncated `client_secret`.
361
164
  const asRecord = body;
362
165
  const message = response.ok
363
166
  ? ''
364
167
  : redactCredentials(typeof asRecord?.message === 'string' ? asRecord.message : text.slice(0, 200));
365
168
  return { ok: response.ok, status: response.status, body, message };
366
169
  }
367
- function isPermissionLevel(value) {
368
- return PERMISSION_LEVELS.includes(value);
369
- }
370
- function rank(level) {
371
- return PERMISSION_LEVELS.indexOf(level);
372
- }
373
- /**
374
- * Compare what an installation actually holds against what this package needs.
375
- *
376
- * Pure, so the whole judgement is testable without a live App — which matters,
377
- * because everything around it can only ever be exercised by hand.
378
- *
379
- * Reports in BOTH directions. A credential does not usually fail by being
380
- * unusable; that gets noticed immediately. It fails by being one permission too
381
- * wide, which works perfectly and is never noticed. So an extra permission, or
382
- * an installation scoped to every repository in the account, is an error here
383
- * and not a shrug.
384
- */
385
- export function readbackVerdict(installation, desired = CANOPY_APP_PERMISSIONS) {
386
- const findings = [];
387
- const held = installation.permissions ?? {};
388
- if (installation.suspended_at) {
389
- findings.push({
390
- severity: 'error',
391
- message: `the installation is SUSPENDED (since ${installation.suspended_at}). ` +
392
- 'A suspended installation authenticates and then refuses everything, so this ' +
393
- 'presents as a permission problem that no permission change fixes.',
394
- });
395
- }
396
- // `undefined` is NOT treated as `selected`. An absent field means "GitHub did
397
- // not tell us", and reporting an unknown scope as the narrow one would be the
398
- // check quietly passing itself.
399
- if (installation.repository_selection !== 'selected') {
400
- findings.push({
401
- severity: 'error',
402
- message: `repository_selection is ${JSON.stringify(installation.repository_selection ?? null)}, ` +
403
- 'expected "selected". This App is registered per site: an installation covering every ' +
404
- 'repository in the account means its key reaches repositories it was never meant to.',
405
- });
406
- }
407
- for (const [name, level] of Object.entries(desired)) {
408
- const actual = held[name];
409
- if (actual === undefined) {
410
- findings.push({
411
- severity: 'error',
412
- message: `missing permission ${name}: ${level} — the installation holds no ${name} grant at all.`,
413
- });
414
- continue;
415
- }
416
- if (actual === level)
417
- continue;
418
- // An exact match is the only pass. A level GitHub adds later is reported
419
- // rather than ranked against levels this code happens to know, because
420
- // guessing where an unknown level sits is exactly how a check quietly
421
- // starts passing things it was written to catch.
422
- if (!isPermissionLevel(actual)) {
423
- findings.push({
424
- severity: 'error',
425
- message: `permission ${name} is "${actual}", which is not a level this check knows ` +
426
- `(${PERMISSION_LEVELS.join('/')}). Compare it against "${level}" by hand.`,
427
- });
428
- continue;
429
- }
430
- if (rank(actual) < rank(level)) {
431
- findings.push({
432
- severity: 'error',
433
- message: `permission ${name} is "${actual}", which is weaker than the required "${level}".`,
434
- });
435
- continue;
436
- }
437
- // Stronger than required is still wider than intended, and is reported for
438
- // the same reason an extra permission is: it works perfectly, so nothing
439
- // else will ever notice it.
440
- findings.push({
441
- severity: 'error',
442
- message: `permission ${name} is "${actual}", which is STRONGER than the required "${level}". ` +
443
- 'Wider than intended is the failure that works perfectly and is never noticed.',
444
- });
445
- }
446
- for (const [name, level] of Object.entries(held)) {
447
- if (desired[name] === undefined) {
448
- findings.push({
449
- severity: 'error',
450
- message: `permission ${name}: ${level} is held but NOT needed. Wider than intended is the ` +
451
- 'failure that works perfectly and is never noticed — remove it from the App.',
452
- });
453
- }
454
- }
455
- return findings;
456
- }
457
170
  /** Whether a destination command is fed through `KEY_INPUT_BRIDGE`: everywhere but Windows. */
458
171
  const BRIDGE_KEY_INPUT = process.platform !== 'win32';
459
172
  /**
460
- * The script `/bin/sh` runs to give a destination command the key on a REAL pipe.
461
- *
462
- * WHY A BRIDGE AT ALL. Node's `stdio: 'pipe'` is not a pipe on Unix: libuv
463
- * gives the child one end of a SOCKET pair. MEASURED on macOS through the
464
- * previous direct spawn: `sh -c '[ -S /dev/stdin ] && echo SOCKET; [ -p
465
- * /dev/stdin ] && echo PIPE'` printed SOCKET, and `cp /dev/stdin <out>` printed
466
- * "/dev/stdin is a socket (not copied)", exited 0, and was reported stored with
467
- * no file written. On Linux, opening `/dev/stdin` on a socket fails with ENXIO —
468
- * reasoned, not run — which breaks `--secret-string file:///dev/stdin`. `cat`
469
- * reads a socket like anything else, and what it writes into is a real pipe.
470
- *
471
- * NO SHELL PARSES THE OPERATOR'S ARGV. `sh -c SCRIPT sh <argv…>` makes argv the
472
- * positional parameters, and `"$@"` hands them on quoted: no word splitting, no
473
- * globbing. The script itself is this constant. The key travels only on stdin, so
474
- * nothing `sh`, `env` or `cat` prints about a failure can contain it. It is
475
- * `exec env -- "$@"` rather than a bare `"$@"` so the first word is always a
476
- * program looked up on PATH, as the direct spawn did. MEASURED in macOS
477
- * `/bin/sh` (bash 3.2) and `/bin/dash`: with a bare `exec "$@"`, bash took a
478
- * first word of `-c` as `exec`'s own option, ran nothing and exited 0; without
479
- * `exec`, a first word of `eval` would run the rest as shell code. Through
480
- * `env --`, both shells report `-c`, `eval` and `set` as "No such file or
481
- * directory", exit 127. `--` goes to `env` because dash's `exec` rejects it
482
- * ("exec: --: not found").
483
- *
484
- * WHY `cat`'S STATUS COMES BACK ON fd 3. A pipeline's exit status is its LAST
485
- * command's, so `cat | cmd` alone reports only `cmd` — and hides `cat` dying of
486
- * SIGPIPE because `cmd` closed its input before the whole key was written. The
487
- * direct spawn could see that case only as a write error on the child's stdin;
488
- * through the bridge, `sh` itself holds that socket open until it exits, so
489
- * `cat` is the witness left. `set -o pipefail` would expose it but is not something every
490
- * `/bin/sh` has: macOS `/bin/dash` rejects it ("set: Illegal option -o
491
- * pipefail"), and dash is `/bin/sh` on Debian and Ubuntu. So the left side
492
- * writes `cat`'s own status to fd 3, a channel back to Node, and the verdict in
493
- * `handOffKey` requires it to read `0`. A status that never arrives is not a
494
- * `0`. `3>&-` closes fd 3 in the destination — MEASURED closed there in both
495
- * shells — so nothing it leaves running can hold that channel open, or write a
496
- * status into it.
173
+ * The script `/bin/sh` runs to give a destination command the key on a REAL
174
+ * pipe. Node's `stdio: 'pipe'` is a SOCKET pair on Unix, not a real pipe:
175
+ * `cat` reads it like anything else and writes into a real one. On Linux a
176
+ * socket can't even be opened as `/dev/stdin`, which would break
177
+ * `--secret-string file:///dev/stdin`. No shell parses the operator's argv:
178
+ * `sh -c SCRIPT sh <argv…>` makes argv the positional parameters, and `"$@"`
179
+ * hands them on quoted with no word-splitting or globbing. It is
180
+ * `exec env -- "$@"` rather than a bare `"$@"` so the first word is always
181
+ * looked up on PATH, not interpreted as a shell built-in.
182
+ *
183
+ * A pipeline's exit status is its LAST command's, so `cat | cmd` alone would
184
+ * hide `cat` dying of SIGPIPE if `cmd` closed its input early, and `set -o
185
+ * pipefail` isn't available in every `/bin/sh` (`/bin/dash`, `/bin/sh` on
186
+ * Debian/Ubuntu, rejects it). So the left side writes `cat`'s own status to
187
+ * fd 3, a channel `handOffKey` requires to read `0`; `3>&-` closes fd 3 in
188
+ * the destination so nothing it leaves running can hold that channel open.
497
189
  */
498
190
  const KEY_INPUT_BRIDGE = '{ cat; echo "$?" >&3; } | exec env -- "$@" 3>&-';
499
191
  function spawnKeyDestination(command, args) {
@@ -505,11 +197,9 @@ function spawnKeyDestination(command, args) {
505
197
  });
506
198
  }
507
199
  /**
508
- * The detail for a destination command that exited non-zero. Through the bridge,
509
- * `env` reports a program it cannot find as 127 and one it cannot execute as 126,
510
- * where the direct spawn raised ENOENT or EACCES. A command may also exit with
511
- * either code on its own account, so these say how the code is reported rather
512
- * than asserting which happened.
200
+ * The detail for a destination command that exited non-zero. Through the
201
+ * bridge, `env` reports "not found" as 127 and "not executable" as 126 — but
202
+ * a command may exit either code on its own, so this states how it's reported.
513
203
  */
514
204
  function destinationExitDetail(command, code) {
515
205
  if (BRIDGE_KEY_INPUT && code === 127) {
@@ -523,48 +213,30 @@ function destinationExitDetail(command, code) {
523
213
  return `\`${command}\` exited ${code ?? 'by signal'}`;
524
214
  }
525
215
  /**
526
- * Send the captured private key to the destination the operator named.
527
- *
528
- * For a command: no shell parses the operator's argv (see `KEY_INPUT_BRIDGE`
529
- * for how `sh` is used without doing so). The PEM goes over **stdin**, never
530
- * argv — an argument is visible in `ps` and lands in shell history. The
531
- * child's stdout and stderr are INHERITED rather than captured, and that is
532
- * load-bearing rather than lazy: `aws secretsmanager create-secret` prints the
533
- * ARN, and CDK's `Secret.fromSecretCompleteArn` needs the full ARN including its
534
- * six-character suffix. Swallowing the child's output would leave the operator
535
- * holding a stored secret with no way to learn the one string that wires it up.
536
- *
537
- * The `'error'` listener on `child.stdin` is not optional. A child that exits or
538
- * closes stdin early can make the write fail with EPIPE, and an unhandled error
539
- * on a stream takes the process down — carrying with it the only copy of a
540
- * private key for an App that already exists.
541
- *
542
- * WHAT `stored: true` DOES AND DOES NOT MEAN. Everywhere but Windows it means
543
- * three things: the command exited 0, the `cat` feeding it exited 0, and nothing
544
- * went wrong writing to it. `cat` exiting 0 means every byte of the key went into
545
- * the pipe. It still does NOT prove the command READ them, and cannot: a pipe
546
- * write lands in the kernel's buffer, and a key this size fits in one write, so a
547
- * command that exits without reading races `cat`'s write and can lose or win.
548
- * MEASURED on macOS with a real 1.7KB key, 100 runs each through this function,
549
- * with the same result before the bridge and after it: `true`, `head -c 10`,
550
- * `sh -c 'exec 0<&-; exit 0'` and `sh -c 'head -c 5 >/dev/null; exit 0'` were
551
- * each reported stored 100 times of 100. What IS caught, whatever the timing,
552
- * is a command that leaves more unread than a pipe buffer holds: ~100KB into
553
- * `true` was reported not stored 20 times of 20, by `cat` exiting 141 — the
554
- * same case the direct spawn caught as a write EPIPE.
555
- *
556
- * So the command's EXIT CODE is still the contract, and the operator is still
557
- * responsible for naming a command that fails loudly. That is stated here rather
558
- * than papered over, because the tempting fix — waiting for the stream to flush
559
- * — measures the kernel buffer rather than the command, and would look like a
560
- * check while being one.
216
+ * Send the captured private key to the destination the operator named. For a
217
+ * command, the PEM goes over **stdin**, never argv — an argument is visible
218
+ * in `ps` and lands in shell history (see `KEY_INPUT_BRIDGE` for how `sh`
219
+ * runs it without parsing that argv itself). Stdout/stderr are INHERITED,
220
+ * not captured: `aws secretsmanager create-secret` prints the ARN the
221
+ * operator needs, which this function must not swallow. The `'error'`
222
+ * listener on `child.stdin` is not optional: a child that exits or closes
223
+ * stdin early can fail the write with EPIPE, an unhandled stream error that
224
+ * would take the process — and the key — down with it.
225
+ * Everywhere but Windows, `stored: true` means the command exited 0, `cat`
226
+ * exited 0, and nothing went wrong writing — every byte reached the pipe. It
227
+ * does NOT prove the command read them: a write lands in the kernel buffer,
228
+ * and a key this size fits in one write, so a command that exits without
229
+ * reading races `cat`'s write and can win or lose silently. What IS caught is
230
+ * a command leaving more unread than the buffer holds, which fails loudly as
231
+ * `cat` exiting on SIGPIPE — so the exit code is still the contract, and
232
+ * naming a command that fails loudly is the operator's job.
233
+ * @internal Exported for tests.
561
234
  */
562
235
  export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination) {
563
236
  if (destination.kind === 'file') {
564
237
  try {
565
- // `wx` so an existing file is never clobbered: the operator may well be
566
- // pointing at a directory that already holds a key, and overwriting one
567
- // credential with another is not a recoverable mistake.
238
+ // `wx` so an existing file is never clobbered: overwriting one credential
239
+ // with another, at a path that may already hold a key, is not recoverable.
568
240
  await writeFile(destination.filePath, pem, { mode: 0o600, flag: 'wx' });
569
241
  return { stored: true, detail: `written to ${destination.filePath} (mode 0600)` };
570
242
  }
@@ -584,11 +256,9 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
584
256
  if (destination.argv.length === 0) {
585
257
  return { stored: false, detail: 'no command was given to send the key to' };
586
258
  }
587
- // Refused before spawning, for parity with the direct spawn this replaced:
588
- // `env` reads a leading word containing `=` as a variable assignment, not a
589
- // command, so `-- FOO=bar` (or a whole command mis-quoted into one word) made
590
- // it print the environment and exit 0 -- the key reported stored, and gone.
591
- // A direct spawn of such a word always failed with ENOENT instead.
259
+ // Refused before spawning: `env` reads a leading word containing `=` as a
260
+ // variable assignment, not a command, so `-- FOO=bar` (or a mis-quoted whole
261
+ // command) would print the environment, exit 0, and report the key stored.
592
262
  if (destination.argv[0].includes('=')) {
593
263
  return {
594
264
  stored: false,
@@ -616,17 +286,15 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
616
286
  resolve(result);
617
287
  };
618
288
  // Recorded rather than settled on, so the child's EXIT CODE stays the
619
- // authority on whether the key was stored. A child that consumed the key,
620
- // stored it and exited 0 must not be reported as a failure because its
621
- // pipe errored on the way down — the operator would generate a new key for
622
- // nothing. It does veto a zero exit, though: a write that did not complete
623
- // means the child cannot have received the whole PEM, whatever it claims.
289
+ // authority: a child that stored the key and exited 0 must not be reported
290
+ // failed because its pipe errored on the way down. It does veto a zero
291
+ // exit, though — an incomplete write means the child cannot have received
292
+ // the whole PEM, whatever it claims.
624
293
  let writeError;
625
294
  // `cat`'s exit status as the bridge reports it on fd 3 (see
626
- // `KEY_INPUT_BRIDGE`). Recorded, like `writeError`, for the verdict on
627
- // 'close' — by which time this channel has closed too, because a child's
628
- // 'close' waits for every stdio stream after stdin. Left empty if the
629
- // channel is missing, which the verdict counts as no `0`.
295
+ // `KEY_INPUT_BRIDGE`), read for the verdict on 'close' — by then this
296
+ // channel has closed too, since 'close' waits for every stdio stream after
297
+ // stdin. Left empty (counted as no `0`) if the channel is missing.
630
298
  let catStatus = '';
631
299
  if (BRIDGE_KEY_INPUT) {
632
300
  const channel = child.stdio?.[3];
@@ -635,8 +303,8 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
635
303
  channel.on('data', (chunk) => {
636
304
  catStatus += chunk;
637
305
  });
638
- // Not optional, for the same reason as the listener on stdin below. An
639
- // error cannot fake a status: at worst it leaves this short of a `0`.
306
+ // Not optional, same reason as the stdin listener below: an error
307
+ // cannot fake a status, at worst leaving this short of a `0`.
640
308
  channel.on('error', () => { });
641
309
  }
642
310
  }
@@ -645,11 +313,10 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
645
313
  settle({ stored: false, detail: `could not run \`${command}\`: ${getErrorMessage(err)}` });
646
314
  });
647
315
  child.on('close', (code) => {
648
- // Deferred one turn of the event loop so a stdin error that is already
649
- // pending is recorded BEFORE the verdict is taken. A broken pipe and the
650
- // child's exit are two independent events and `close` can win the race,
651
- // which would report a key as stored on the strength of an exit code
652
- // while the write that carried it had failed.
316
+ // Deferred one turn of the event loop so a pending stdin error is
317
+ // recorded BEFORE the verdict: a broken pipe and the child's exit are
318
+ // independent events, and `close` can win that race and report success
319
+ // on an exit code whose write actually failed.
653
320
  setImmediate(() => {
654
321
  if (writeError) {
655
322
  settle({
@@ -684,9 +351,8 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
684
351
  settle({ stored: false, detail: `\`${command}\` has no stdin to write the key to` });
685
352
  return;
686
353
  }
687
- // This listener is not optional. Without it an EPIPE from an early-closing
688
- // child is an unhandled stream error, which takes the process down — and
689
- // with it the only copy of a private key for an App that already exists.
354
+ // Not optional: without it an EPIPE from an early-closing child is an
355
+ // unhandled stream error that takes the process down, and the key with it.
690
356
  stdin.on('error', (err) => {
691
357
  writeError = err;
692
358
  });
@@ -694,24 +360,18 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
694
360
  });
695
361
  }
696
362
  /**
697
- * One request, then closed — closed by the handler itself, as soon as it has the
698
- * code. Bound to loopback only.
699
- *
700
- * The standard CLI pattern (`gh auth login` does the same): it exists solely so
701
- * the private key can arrive over the redirect rather than through a downloads
702
- * folder.
703
- *
704
- * A REJECTED REQUEST IS ANSWERED AND IGNORED, never fatal. The `state` check is
705
- * the CSRF guard the manifest flow provides, but rejecting the promise on a
706
- * mismatch ends the run on the first stray loopback request — a browser
707
- * prefetch, an extension, a retried tab — possibly seconds before GitHub's real
708
- * redirect arrives, and AFTER the App has been created. Aborting there leaves an
709
- * App nobody holds a key for, over a request that was never GitHub's.
363
+ * One request, then closed by the handler itself, bound to loopback only —
364
+ * the standard CLI pattern (`gh auth login` does the same) so the key
365
+ * arrives over the redirect, not a downloads folder. A rejected request is
366
+ * answered and ignored, never fatal: the `state` check
367
+ * is the CSRF guard the manifest flow provides, but rejecting the promise on
368
+ * a mismatch would end the run on a stray loopback request, possibly before
369
+ * GitHub's real redirect, leaving an App nobody holds a key for.
370
+ * @internal Exported for tests.
710
371
  */
711
372
  export function startCallbackServer(state, timeoutMs = CALLBACK_TIMEOUT_MS,
712
373
  // Injectable ONLY so the bind-failure path is testable. The host stays
713
- // hardcoded to 127.0.0.1 below, so this seam cannot be used to bind
714
- // somewhere reachable.
374
+ // hardcoded to 127.0.0.1 below, so this seam cannot bind somewhere reachable.
715
375
  createServerFn = createServer) {
716
376
  let settle = () => { };
717
377
  let fail = () => { };
@@ -720,28 +380,21 @@ createServerFn = createServer) {
720
380
  fail = reject;
721
381
  });
722
382
  // Marks `code` as handled without consuming it: `.catch()` returns a NEW
723
- // promise and leaves this one rejectable for the real caller. Needed because
724
- // the bind below can fail before anyone has awaited `code`, and an
725
- // unhandled rejection there would be reported as a crash in a flow whose
726
- // actual problem is "the port could not be bound".
383
+ // promise, leaving this one rejectable for the real caller. Needed because
384
+ // the bind below can fail before anyone has awaited `code`, which would
385
+ // otherwise report as an unhandled rejection instead of a bind failure.
727
386
  code.catch(() => { });
728
387
  const page = (title, detail) => `<!doctype html><meta charset="utf-8"><title>${escapeHtml(title)}</title>` +
729
388
  `<body style="font:16px system-ui;padding:3rem"><h1>${escapeHtml(title)}</h1>` +
730
389
  `<p>${escapeHtml(detail)}</p></body>`;
731
- // Assigned once the socket is listening, and CALLED by the request handler as
732
- // soon as it has the code — so the listener closes itself rather than waiting
733
- // for the caller's `finally`. Declared out here only so both can reach it.
390
+ // Assigned once listening, and CALLED by the handler once it has the code, so
391
+ // the listener closes itself rather than waiting on the caller's `finally`.
734
392
  let stopListening = () => { };
735
393
  const server = createServerFn((req, res) => {
736
- // EVERY path through this handler is inside the try. A throw in a
737
- // 'request' listener is an uncaught exception that kills the process, and
738
- // by the time this server matters the process is the only thing holding a
739
- // private key for an App that already exists.
740
- //
741
- // `new URL` is the specific hazard, and it is not hypothetical: Node's HTTP
742
- // parser accepts request targets `new URL` rejects, so `GET //[x HTTP/1.1`
743
- // arrives as `req.url === '//[x'` and throws ERR_INVALID_URL. Verified
744
- // directly — `//` alone throws too.
394
+ // EVERY path through this handler is inside the try, since a throw in a
395
+ // 'request' listener would kill the process while it holds the App's
396
+ // private key — including `new URL`, which rejects some request targets
397
+ // Node's HTTP parser accepts (e.g. `GET //[x HTTP/1.1`).
745
398
  try {
746
399
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
747
400
  if (url.pathname !== '/callback') {
@@ -767,11 +420,9 @@ createServerFn = createServer) {
767
420
  .writeHead(200, { 'content-type': 'text/html', connection: 'close' })
768
421
  .end(page('App created', 'The private key was captured. Return to the terminal.'));
769
422
  settle(received);
770
- // Closed HERE, not in the caller's `finally`. The caller's window spans
771
- // the conversion, a human install step of unbounded length and the
772
- // readback, and for all of that the port would keep accepting
773
- // connections — loopback is not per-user, so on a shared host another
774
- // UID can reach it.
423
+ // Closed HERE, not in the caller's `finally`: that window spans a human
424
+ // install step of unbounded length, during which loopback — not
425
+ // per-user — would keep accepting connections another UID could reach.
775
426
  stopListening();
776
427
  }
777
428
  catch {
@@ -787,10 +438,9 @@ createServerFn = createServer) {
787
438
  });
788
439
  return new Promise((resolve, reject) => {
789
440
  let listening = false;
790
- // Without this the bind failing is an unhandled 'error' event — which takes
791
- // the process down — and the promise below never settles either way, so
792
- // `create` hangs instead of saying the port could not be bound. Both were
793
- // measured: a sandbox that denies listen() produced exactly that.
441
+ // Without this, a bind failure is an unhandled 'error' event that crashes
442
+ // the process, and the promise below never settles — `create` would hang
443
+ // instead of reporting that the port could not be bound.
794
444
  server.on('error', (err) => {
795
445
  if (listening) {
796
446
  fail(err);
@@ -817,17 +467,13 @@ createServerFn = createServer) {
817
467
  });
818
468
  }
819
469
  /**
820
- * The exchange. Unauthenticated by design: the one-hour code IS the credential.
821
- *
822
- * Only the four fields this tool needs are lifted out. The response also carries
823
- * `client_secret` and `webhook_secret`, which this App never uses — so the body
824
- * is never logged, never returned whole, and never reaches an error message.
825
- * Failures collapse to `null` and nothing from the response is surfaced, for
826
- * the same reason: the code itself is exchangeable for the private key for a
827
- * full hour. That also drops the HTTP status, which the caller does not report
828
- * (.claude/future-tasks/init-github-app-review-round-1-lows.md, item 2).
470
+ * The exchange. Unauthenticated by design: the one-hour code IS the
471
+ * credential. Only the four fields this tool needs are lifted out — the
472
+ * response also carries `client_secret`/`webhook_secret`, never logged or
473
+ * surfaced. Failures collapse to `null` for the same reason: the code itself
474
+ * is exchangeable for the private key for a full hour.
829
475
  */
830
- export async function convertManifest(code) {
476
+ async function convertManifest(code) {
831
477
  const response = await githubRequest(`/app-manifests/${encodeURIComponent(code)}/conversions`, { method: 'POST' });
832
478
  if (!response.ok || !response.body)
833
479
  return null;
@@ -837,31 +483,26 @@ export async function convertManifest(code) {
837
483
  return { id, slug, clientId, pem };
838
484
  }
839
485
  /**
840
- * Whether the account is an organisation, which decides where the manifest posts.
841
- *
842
- * Unauthenticated, so it needs no credential at a point in the flow where we have
843
- * none. `null` means "could not tell" and the caller says so rather than
844
- * guessing — guessing wrong sends the operator to a form that refuses the
845
- * manifest with no useful explanation.
486
+ * Whether the account is an organisation, which decides where the manifest
487
+ * posts. Unauthenticated, since nothing has a credential yet. `null` means
488
+ * "could not tell", and the caller says so rather than guessing — a wrong
489
+ * guess sends the operator to a form that refuses the manifest silently.
846
490
  */
847
- export async function detectAccountType(owner) {
491
+ async function detectAccountType(owner) {
848
492
  const response = await githubRequest(`/users/${encodeURIComponent(owner)}`);
849
493
  if (!response.ok || !response.body?.type)
850
494
  return null;
851
495
  return response.body.type === 'Organization';
852
496
  }
853
497
  /**
854
- * Best-effort check that the App name is not already taken.
855
- *
856
- * `true` = available as far as we can tell, `false` = definitely taken, `null` =
857
- * could not check. The three are kept apart deliberately: a PRIVATE App with
858
- * this slug also answers 404, so a 404 is not proof of availability, and
859
- * reporting "could not check" as "checked, it is free" is how a guard goes
860
- * blind. This exists because name collisions can otherwise only be discovered by
861
- * the creation form — with the CLI already sitting on a loopback server waiting
862
- * for a redirect that will never arrive.
498
+ * Best-effort check that the App name is not already taken. `true`/`false`/
499
+ * `null` (could not check) are kept apart: a PRIVATE App with this slug also
500
+ * answers 404, so 404 isn't proof of availability, and reporting "could not
501
+ * check" as "free" is how a guard goes blind. Exists because a name collision
502
+ * would otherwise surface only at the creation form, with the CLI already on
503
+ * a loopback server waiting for a redirect that will never arrive.
863
504
  */
864
- export async function checkNameAvailable(slug) {
505
+ async function checkNameAvailable(slug) {
865
506
  const response = await githubRequest(`/apps/${encodeURIComponent(slug)}`);
866
507
  if (response.status === 200)
867
508
  return false;
@@ -870,148 +511,29 @@ export async function checkNameAvailable(slug) {
870
511
  return null;
871
512
  }
872
513
  /**
873
- * Whether stdin has already delivered end-of-input to an earlier prompt.
874
- *
875
- * `process.stdin` can only end ONCE, and a `readline` interface created after it
876
- * has ended never emits `'line'` or `'close'` — the stream is already
877
- * `endEmitted` and the constructor's `resume()` cannot re-deliver it. So a
878
- * per-prompt interface is a footgun the moment a command has two prompts: the
879
- * second one waits forever.
880
- *
881
- * Measured, on a real pty as well as a pipe: with `--key-out` pointing at a bad
882
- * path, an operator who answers the retry prompt with Ctrl-D (the other reflex
883
- * to "leave blank to give up") ended stdin in `askLine`, and the later
884
- * `pressEnter` never resolved. `createCommand` never returned, so
885
- * `process.exitCode` was never assigned and node exited **0** — on the one
886
- * outcome where the App exists and its only key was just discarded — while the
887
- * `finally` that removes the temp directory never ran and the block printing the
888
- * App id, the operator's only handle for generating a replacement key, was never
889
- * reached.
890
- *
891
- * A SECOND, subtler way to reach the same hole: EOF landing on a line that DID
892
- * get answered. Node's readline flushes a pending partial line as a final
893
- * `'line'` event BEFORE `'close'` when the input ends, so "text then EOF" (a
894
- * pipe like `printf 'abc'` with no trailing newline, or a TTY operator typing
895
- * text and pressing Ctrl-D twice) answers the prompt normally — `answered` is
896
- * true when `'close'` fires. The stream having also ended in that same moment
897
- * used to go unrecorded, because the `'close'` handler only ever set this flag
898
- * on the `!answered` branch. The next prompt then created a readline interface
899
- * on an already-ended stream that never emits, and — with nothing else keeping
900
- * it alive — the event loop emptied and node exited 0 silently: no "Giving up",
901
- * no "Stopping here … GITHUB_APP_ID" block, no readback, no `finally`.
902
- * MEASURED with a scratch driver: `printf 'abc' | node --import tsx <driver>`
903
- * resolved the first prompt to `"abc"` and left the second prompt printed but
904
- * never resolved, node exiting 0 with nothing after it.
905
- *
906
- * So the flag must be set from whether the STREAM ended, not from whether THIS
907
- * prompt got an answer — the two are independent. `readableEnded` is checked
908
- * unconditionally in the `'close'` handler below, before the `answered` branch,
909
- * so it applies whether or not a line came back. This still tells apart the
910
- * ordinary case from this one: our OWN `rl.close()` call after a line, by
911
- * itself, fires `'close'` while stdin usually remains open and `readableEnded`
912
- * stays false — only an input stream that has actually finished sets it.
913
- *
914
- * Every prompt below consults this first. Nothing else may create a readline
915
- * interface on `process.stdin` in this file.
916
- */
917
- let stdinEnded = false;
918
- function readLineOnce(prompt) {
919
- if (stdinEnded) {
920
- // Already at EOF: answer immediately rather than waiting for input that can
921
- // never arrive.
922
- console.log(prompt);
923
- return Promise.resolve(null);
924
- }
925
- const rl = createInterface({ input: process.stdin, terminal: false });
926
- return new Promise((resolve) => {
927
- console.log(prompt);
928
- let answered = false;
929
- rl.once('line', (line) => {
930
- answered = true;
931
- rl.close();
932
- resolve(line);
933
- });
934
- rl.once('close', () => {
935
- // Checked FIRST and unconditionally: `'close'` fires both when WE call
936
- // `rl.close()` after a line (stdin can still be open) and when the
937
- // stream itself ends — including the case where a final partial line was
938
- // just flushed as `'line'`, so `answered` is true but the stream is
939
- // ALSO finished. `readableEnded` is the one signal that distinguishes
940
- // "we closed the interface" from "the stream is actually done", and by
941
- // the time readline's own `'close'` fires, the input stream has already
942
- // finished emitting `'end'` (readline listens for it internally before
943
- // closing itself), so the flag is reliable here.
944
- if (process.stdin.readableEnded) {
945
- stdinEnded = true;
946
- }
947
- if (answered)
948
- return;
949
- stdinEnded = true;
950
- resolve(null);
951
- });
952
- });
953
- }
954
- /** Wait for the operator to continue. Returns at once if stdin has ended. */
955
- export async function pressEnter(prompt) {
956
- await readLineOnce(prompt);
957
- }
958
- /** Read one line from the operator. `null` when stdin ended instead. */
959
- export async function askLine(prompt) {
960
- const line = await readLineOnce(prompt);
961
- return line === null ? null : line.trim();
962
- }
963
- /** Reset between tests. Not used by the command itself. */
964
- export function resetStdinStateForTesting() {
965
- stdinEnded = false;
966
- }
967
- /**
968
- * Route one line of operator input at the retry prompt, which accepts a FILE
969
- * PATH and nothing else. Exported and pure so every rule below is a table test
970
- * rather than something only reachable by driving a live prompt end to end.
971
- *
972
- * WHY NO COMMANDS. The first destination can be a command because the
973
- * operator's own shell parsed `-- <command…>` into argv. A typed line here has
974
- * no such parser, and three consecutive review rounds each found splitting it on
975
- * whitespace sending the key somewhere unintended: a one-word answer written as
976
- * a file in the working directory; a `|` inside the argv becoming a file name;
977
- * then `>`, `;`, `&&` and quotes each becoming literal argv words, so
978
- * `tee key.pem > /dev/null` wrote 0644 copies of the key into the repository
979
- * root and reported it stored. Each fix closed one spelling. Accepting a path
980
- * only closes the class: an operator who wants a command writes the file, runs
981
- * the command against it, and deletes the file.
982
- *
983
- * The rules, in order:
984
- *
985
- * - `null` (stdin has already ended — see `stdinEnded` above) gives up: there
986
- * is nobody left who could answer.
987
- * - A blank or whitespace-only line MUST NOT give up. Nothing reads stdin
988
- * until a readline interface exists, so an Enter pressed during the long
989
- * wait for GitHub's redirect — or while the FIRST destination's command was
990
- * running — sits in the terminal's line buffer. When that first hand-off
991
- * then fails, `askLine` reads the buffered blank line immediately, before
992
- * the operator has even seen this prompt. Treating that as "give up"
993
- * discarded the only copy of the key without anyone actually answering.
994
- * Re-prompting instead costs nothing when the blank line really was
995
- * intentional, because the very next prompt still offers "give up" as
996
- * something the operator has to type.
997
- * - The words "give up", case-insensitively and with whitespace normalised, is
998
- * the one deliberate way to give up once stdin is live.
999
- * - Any whitespace inside the answer, or a leading `|`, is re-prompted with
1000
- * "commands are not accepted here" and the write-run-delete route. Every
1001
- * command-shaped answer above was spelled with whitespace, and nothing about
1002
- * a typed line tells a command apart from a path that contains spaces — so
1003
- * the cost, stated in the reason, is that such a path cannot be entered here.
1004
- * - A leading `~` is re-prompted: nothing here expands it, so `~/key.pem`
1005
- * would name a directory literally called `~` under the working directory.
1006
- * - A single token with NO `/` or `\` — `pbcopy`, `wl-copy`, any script on
1007
- * PATH — is re-prompted, suggesting `./<token>`. A "one word is a path" rule
1008
- * once let `writeFile('pbcopy', pem, { flag: 'wx' })` succeed in
1009
- * `process.cwd()` — normally the git repository root — leaving a
1010
- * `contents: write` private key sitting untracked on disk, while the
1011
- * operator read "written to pbcopy (mode 0600)" and believed it had gone
1012
- * into the command they meant.
1013
- * - Anything else is a path, which `handOffKey` creates with mode 0600 and
514
+ * Route one line of operator input at the retry prompt, accepting a FILE
515
+ * PATH and nothing else (exported and pure, so every rule is table-tested
516
+ * directly). The first destination can be a command because the operator's
517
+ * shell parses `-- <command…>` into argv; a typed line has no such parser,
518
+ * and splitting on whitespace sends the key somewhere unintended (a stray
519
+ * `|`, `>`, `;`, `&&` or quote becomes a literal argv word). Accepting only a
520
+ * path closes that class — a command means write, run, then delete. Rules,
521
+ * in order:
522
+ * - `null` (stdin has already ended — see prompt.ts) gives up.
523
+ * - A blank or whitespace-only line MUST NOT give up — a stray Enter pressed
524
+ * earlier sits buffered and is read back before the operator has seen this
525
+ * prompt — but the words "give up", case-insensitively and
526
+ * whitespace-normalised, deliberately do once stdin is live.
527
+ * - Whitespace inside the answer, or a leading `|`, is re-prompted: nothing
528
+ * tells a command apart from a path containing spaces, so such paths
529
+ * aren't accepted either.
530
+ * - A leading `~` is re-prompted (unexpanded, `~/key.pem` would name a
531
+ * directory literally called `~`), as is a single token with no `/` or `\`
532
+ * (`pbcopy`, any PATH script): as a bare word it would write the key into
533
+ * the current directory while the operator believes it reached the command.
534
+ * - Anything else is a path: `handOffKey` creates it with mode 0600 and
1014
535
  * refuses to write if something already exists there.
536
+ * @internal Exported for tests.
1015
537
  */
1016
538
  export function parseKeyRetryAnswer(answer) {
1017
539
  if (answer === null)
@@ -1047,21 +569,13 @@ export function parseKeyRetryAnswer(answer) {
1047
569
  }
1048
570
  /**
1049
571
  * Hand the key over, and keep asking while the operator still has a chance.
1050
- *
1051
- * The pre-flight can only refuse a MISSING destination — it cannot know whether
1052
- * a command exists, whether a path is writable, or whether a secret store will
1053
- * accept the call. Measured: `--key-out` at a directory, `--key-out` under a
1054
- * parent that does not exist, and a command not on PATH all pass the pre-flight
1055
- * and fail here. Exiting at that point destroys the only copy of a private key
1056
- * for an App that already exists, over a typo.
1057
- *
1058
- * So a failure asks for a file path instead, while the key is still in memory —
1059
- * a path only, never a command, for the reasons on `parseKeyRetryAnswer`.
1060
- * `create` already requires a TTY, so there is someone there to answer. Only a
1061
- * closed stdin (nobody left to answer) or the operator typing "give up" ends the
1062
- * loop without a destination — a blank line re-prompts instead, since that is
1063
- * exactly what a stray Enter pressed before this prompt existed leaves buffered.
1064
- * See `parseKeyRetryAnswer` for the exact rules.
572
+ * The pre-flight can only refuse a MISSING destination — a bad path or a
573
+ * command not on PATH fails only here, and exiting on that would destroy the
574
+ * only copy of a private key for an App that already exists, over a typo. So
575
+ * a failure asks for a file path instead, while the key is still in memory —
576
+ * a path only, never a command, per `parseKeyRetryAnswer`'s rules. Only a
577
+ * closed stdin or "give up" ends the loop without a destination.
578
+ * @internal Exported for tests.
1065
579
  */
1066
580
  export async function handOffWithRetry(pem, destination) {
1067
581
  let attempt = destination;
@@ -1099,16 +613,11 @@ function describeDestination(destination) {
1099
613
  : `\`${destination.argv.join(' ')}\``;
1100
614
  }
1101
615
  /**
1102
- * How many accounts this App is installed on, or `null` when it cannot be read.
1103
- *
1104
- * The one-App-per-site rule (see the file header) is only worth stating if
1105
- * something checks it, and `repository_selection: 'selected'` does not: it is
1106
- * equally true of an installation scoped to this repository and one scoped to
1107
- * this repository plus nine others. This is the check that actually observes
1108
- * the invariant, and it costs one call with the JWT already in hand.
1109
- *
1110
- * `null` and a number are kept apart: "could not read the installation list" must
1111
- * never be reported as "checked, there is exactly one".
616
+ * How many accounts this App is installed on, or `null` when it cannot be
617
+ * read — the one-App-per-site rule (see the file header) is only worth
618
+ * stating if something checks it, and `repository_selection: 'selected'`
619
+ * doesn't (equally true scoped here plus nine others). `null` and a number
620
+ * are kept apart: "could not read" must never report as "exactly one".
1112
621
  */
1113
622
  async function installationCount(jwt) {
1114
623
  const listed = await githubRequest('/app/installations?per_page=100', { jwt });
@@ -1118,28 +627,20 @@ async function installationCount(jwt) {
1118
627
  }
1119
628
  /**
1120
629
  * Mint an installation token narrowed to this repository and the declared
1121
- * permissions, then revoke it.
1122
- *
1123
- * This is NOT a second reading of the grant — `readbackVerdict` already has that
1124
- * from the installation object. It is the end-to-end proof that the key signs,
1125
- * that the App is not suspended, and that a token can actually be issued: the
1126
- * same thing the worker does at boot. It also catches a GitHub behaviour that
1127
- * surfaces nowhere else — **adding a permission to an App does not apply to
1128
- * existing installations until an account owner ACCEPTS the new request**, so an
1129
- * App whose settings page looks correct can hold a stale grant, and the mint
1130
- * fails with a 422 that sounds like the App is misconfigured.
1131
- *
1132
- * The token is revoked immediately. Leaving a live `contents: write` credential
1133
- * valid for an hour after a read-only check exits would be careless for no gain.
630
+ * permissions, then revoke it — proof the key signs, the App isn't
631
+ * suspended, and a token issues. Also catches a GitHub quirk found nowhere
632
+ * else: a new permission doesn't apply to existing installations until an
633
+ * owner ACCEPTS it, so a correct-looking App can still fail with a
634
+ * misleading 422. Revoked at once — no reason to leave `contents: write`
635
+ * live for an hour after a read-only check.
1134
636
  */
1135
637
  async function proveTokenMint(jwt, installationId, repo) {
1136
638
  const minted = await githubRequest(`/app/installations/${installationId}/access_tokens`, {
1137
639
  jwt,
1138
640
  method: 'POST',
1139
- // Scoped by repository NAME. The access-token endpoint accepts names and
1140
- // applies the same narrowing as ids — and resolving a name to an id would
1141
- // need `GET /repos/{o}/{r}`, which an App JWT cannot reach (measured: it
1142
- // answers a JWT with 401).
641
+ // Scoped by repository NAME: the endpoint applies the same narrowing as
642
+ // ids, and resolving a name to an id would need `GET /repos/{o}/{r}`,
643
+ // which an App JWT cannot reach (401).
1143
644
  body: { repositories: [repo], permissions: { ...CANOPY_APP_PERMISSIONS } },
1144
645
  });
1145
646
  if (!minted.ok || !minted.body?.token) {
@@ -1152,32 +653,24 @@ async function proveTokenMint(jwt, installationId, repo) {
1152
653
  const note = revoked.status === 204 ? 'revoked' : `NOT revoked (HTTP ${revoked.status})`;
1153
654
  return { ok: true, detail: `minted and ${note}` };
1154
655
  }
1155
- /**
1156
- * Read an installation back and report on it. Shared by `create` (right after
1157
- * the operator installs the App) and `verify` (any time afterwards).
1158
- */
656
+ /** Read an installation back and report on it. Shared by `create` (after install) and `verify`. */
1159
657
  async function readBackInstallation(appId, privateKey, target) {
1160
658
  // Normalised the same way the worker normalises its own key
1161
- // (`normalizeGitHubAppPrivateKey`, `worker/github-auth.ts`) BEFORE it is ever
1162
- // used to sign — trims it, unescapes a literal `\n`, unwraps a base64-wrapped
1163
- // PEM, and re-exports PKCS#8. `cli.ts` reads `--key-file`/`--key-stdin`
1164
- // verbatim, and `docs/deploying-to-aws.md` tells operators to pipe that exact
1165
- // secret — `\n`-escaped or base64-wrapped, however it left Secrets Manager —
1166
- // into `verify --key-stdin`. Without this, `verify` failed with "could not
1167
- // sign a JWT" on a key the worker boots on fine. Called here rather than in
1168
- // `cli.ts` so `create` goes through it too: GitHub's own freshly-minted PEM is
1169
- // PKCS#1, so this re-exports it as PKCS#8 — the same key, differently encoded.
659
+ // (`normalizeGitHubAppPrivateKey`, `worker/github-auth.ts`) before it signs:
660
+ // trims it, unescapes a literal `\n`, unwraps a base64-wrapped PEM, and
661
+ // re-exports PKCS#8. `docs/deploying-to-aws.md` has operators pipe that
662
+ // exact secret, however it left Secrets Manager, into `verify --key-stdin`,
663
+ // which would otherwise fail to sign on a key the worker boots on fine.
664
+ // Called here too so `create` re-exports GitHub's PKCS#1 PEM as PKCS#8.
1170
665
  let normalizedKey;
1171
666
  try {
1172
667
  normalizedKey = normalizeGitHubAppPrivateKey(privateKey);
1173
668
  }
1174
669
  catch (err) {
1175
670
  // Distinct from the "could not sign a JWT" failure below: this key does not
1176
- // even parse as a PEM once the common manglings are undone, so signing was
1177
- // never reached. `getErrorMessage` here is `createPrivateKey`'s own parse
1178
- // error, which names a PEM format problem, never key material — redacted
1179
- // anyway, on the same belt-and-braces basis as every other error surfaced
1180
- // in this file.
671
+ // even parse as a PEM once the common manglings are undone. `getErrorMessage`
672
+ // here names a PEM format problem, never key material, but is redacted
673
+ // anyway for consistency with every other error surfaced in this file.
1181
674
  console.error(`\nThe private key could not be normalised: ${redactCredentials(getErrorMessage(err))}\n` +
1182
675
  ' If you supplied this key, check that it is the App private key (the downloaded\n' +
1183
676
  ' .pem), NOT the "client secret" listed a few sections above it on the App\'s\n' +
@@ -1204,12 +697,9 @@ async function readBackInstallation(appId, privateKey, target) {
1204
697
  // Say WHICH of the two it is. Reporting "not installed" for an auth failure
1205
698
  // sends the operator to check a page that is correct.
1206
699
  if (found.status === 0) {
1207
- // THREE cases, not two. `githubRequest` reports a transport failure as
1208
- // status 0 with the real cause in `message`, and folding that into the
1209
- // else below produced a confident "not installed on this repository" for
1210
- // a DNS failure, a proxy refusal or a timeout — with the actual error
1211
- // discarded. Reading the body inside the try made this strictly more
1212
- // reachable, since a reset mid-body now lands here instead of throwing.
700
+ // THREE cases, not two: `githubRequest` reports a transport failure as
701
+ // status 0 with the cause in `message`, which the else below would
702
+ // otherwise misreport as a confident "not installed".
1213
703
  console.error(`\nCould not reach api.github.com: ${found.message}\n` +
1214
704
  ' This says nothing about the App or its installation. Check network access and\n' +
1215
705
  ' run `canopycms init-github-app verify` again.');
@@ -1241,16 +731,9 @@ async function readBackInstallation(appId, privateKey, target) {
1241
731
  console.error(` ✗ ${finding.message}`);
1242
732
  }
1243
733
  }
1244
- // The one-App-per-site invariant, observed rather than asserted. A second
1245
- // installation means this App's key reaches a second account's repositories,
1246
- // which is the arrangement the whole design exists to avoid.
1247
- // EXACTLY one is the pass, and everything else fails — including "could not
1248
- // check". The looser first version printed a ✓ for a count of 0 (a state that
1249
- // should not occur, but a tick for an unobserved fact is the failure these
1250
- // comments exist to prevent) and let `null` leave the overall verdict at
1251
- // "All checks passed", which is a check that did not run reported as one that
1252
- // did. `repository_selection` is strict about `undefined` for the same
1253
- // reason, and these two now agree.
734
+ // The one-App-per-site invariant, observed not asserted: EXACTLY one
735
+ // installation is the pass and everything else fails, including "could not
736
+ // check", matching how `repository_selection` treats `undefined`.
1254
737
  let installationsOk = false;
1255
738
  const installations = await installationCount(jwt);
1256
739
  if (installations === null) {
@@ -1307,13 +790,10 @@ async function resolveTarget(options) {
1307
790
  ' Pass them explicitly: --owner <account> --repo <repository>');
1308
791
  return null;
1309
792
  }
1310
- // Only `create` needs the account type, and only to choose which URL the
1311
- // manifest form posts to. `verify` never reaches `manifestPostUrl`, so making
1312
- // it depend on an unauthenticated lookup gave the read-only, non-interactive,
1313
- // CI-safe command a way to hard-fail that had nothing to do with the App —
1314
- // unauthenticated requests are rate-limited at 60/hour per IP, which a shared
1315
- // egress address reaches without anyone doing anything wrong. `verify`
1316
- // validates the owner far better anyway, by looking the installation up.
793
+ // Only `create` needs the account type, to pick the manifest form's URL.
794
+ // Making `verify` depend on this unauthenticated (60/hour per IP) lookup
795
+ // would give the read-only, non-interactive command a failure unrelated to
796
+ // the App — it validates the owner far better anyway, via the installation.
1317
797
  if (options.mode !== 'create') {
1318
798
  return { owner, repo, isOrganization: false };
1319
799
  }
@@ -1339,9 +819,8 @@ async function createCommand(options) {
1339
819
  'the only copy of its key and has nowhere to put it.');
1340
820
  return 1;
1341
821
  }
1342
- // Refuse rather than hang. This flow stops twice for a human, and in CI it
1343
- // would block forever at "press Enter" with a live App already created and the
1344
- // only copy of its key about to die with the job.
822
+ // Refuse rather than hang: in CI this would block forever at "press Enter"
823
+ // with a live App already created and its only key about to die with the job.
1345
824
  if (!process.stdin.isTTY) {
1346
825
  console.error('`init-github-app create` needs an interactive terminal: it waits while you create the\n' +
1347
826
  ' App in a browser and then install it. Run it from a terminal, not from CI.\n\n' +
@@ -1372,11 +851,9 @@ async function createCommand(options) {
1372
851
  if (available === null) {
1373
852
  console.log(`! Could not check whether the name "${name}" is already taken, so this is unverified.\n`);
1374
853
  }
1375
- // PRINTED BEFORE THE BROWSER OPENS, not on failure, because the failure mode is
1376
- // asymmetric: if the redirect never arrives -- browser closed, timeout, state
1377
- // mismatch, code expired -- the App HAS been created and we hold no key.
1378
- // Printing the recovery only when that happens would put it in a terminal that
1379
- // may already be gone.
854
+ // PRINTED BEFORE THE BROWSER OPENS, not on failure: if the redirect never
855
+ // arrives, the App HAS still been created with no key held, and printing
856
+ // the recovery only then could target a terminal that is already gone.
1380
857
  console.log('IF THIS GOES WRONG AFTER YOU CLICK CREATE, read this first:\n' +
1381
858
  ' The App will exist and this command will not have its private key. That is\n' +
1382
859
  ' recoverable. Either:\n' +
@@ -1392,9 +869,9 @@ async function createCommand(options) {
1392
869
  callback = await startCallbackServer(state);
1393
870
  }
1394
871
  catch (err) {
1395
- // Nothing has been created yet, so this is the cheap failure. Said plainly
1396
- // rather than as a raw errno, because a denied bind is usually a sandbox or
1397
- // a host firewall rather than anything about GitHub.
872
+ // Nothing has been created yet, so this is the cheap failure — said plainly
873
+ // rather than as a raw errno, since a denied bind is usually a sandbox or
874
+ // host firewall, not GitHub.
1398
875
  console.error(`Could not open the local callback server: ${getErrorMessage(err)}\n\n` +
1399
876
  " This command needs to listen on 127.0.0.1 to receive GitHub's redirect, which is\n" +
1400
877
  ' how the private key is captured without ever touching disk. A sandbox or host\n' +
@@ -1406,13 +883,10 @@ async function createCommand(options) {
1406
883
  const redirectUrl = `http://127.0.0.1:${callback.port}/callback`;
1407
884
  const manifest = appManifest(target, redirectUrl, name);
1408
885
  const postUrl = manifestPostUrl(target, state);
1409
- // A private directory rather than a predictable name in a shared one. On
1410
- // Linux `tmpdir()` is `/tmp` for every user and the slug derives from a
1411
- // public repository name, so `create-<slug>.html` is guessable — and
1412
- // `writeFile`'s default `w` follows an existing symlink and truncates its
1413
- // target, while `mode` only applies when it creates the file. The form is
1414
- // not secret (manifest, state, port), but it is what the operator's browser
1415
- // is about to POST to GitHub.
886
+ // A private directory, not a predictable name in a shared one: on Linux
887
+ // `tmpdir()` is `/tmp` for every user and the slug derives from a public
888
+ // repo name, so `create-<slug>.html` is guessable, and `writeFile`'s
889
+ // default `w` follows an existing symlink and truncates its target.
1416
890
  formDir = await mkdtemp(path.join(tmpdir(), 'canopycms-app-'));
1417
891
  const formFile = path.join(formDir, `create-${slug}.html`);
1418
892
  await writeFile(formFile, creationForm(postUrl, manifest), { mode: 0o600, flag: 'wx' });
@@ -1458,24 +932,13 @@ async function createCommand(options) {
1458
932
  console.log(` created App ${created.slug} (id ${created.id}) — private key captured; sending it to` +
1459
933
  ` ${describeDestination(destination)}`);
1460
934
  // THE KEY IS HANDED OFF FIRST, before the install prompt and the readback,
1461
- // and the ordering is the whole point.
1462
- //
1463
- // From here until the hand-off returns, this process holds the ONLY copy of
1464
- // a private key for an App that already exists. Everything between the
1465
- // conversion and the hand-off is therefore a window in which losing the
1466
- // process loses the key: the install step waits on a human for an unbounded
1467
- // time (one Ctrl-C and it is gone), and the readback makes two network
1468
- // calls that can reject rather than return — a stalled response body or the
1469
- // request timeout firing mid-read would unwind straight past the hand-off.
1470
- //
1471
- // Nothing in the hand-off depends on the readback, so there is no reason to
1472
- // carry the key across either. Doing it here reduces the window to the
1473
- // conversion call itself.
935
+ // because until it returns this process holds the ONLY copy of the key
936
+ // while the install step waits on a human indefinitely and the readback
937
+ // makes network calls that can reject instead of return.
1474
938
  const stored = await handOffWithRetry(created.pem, destination);
1475
939
  if (!stored) {
1476
- // No point asking anyone to install an App whose key was just discarded:
1477
- // the installation could not be used. Say what IS still actionable — the
1478
- // App id, which is the handle for generating a replacement key — and stop.
940
+ // No point installing an App whose key was just discarded. Say what IS
941
+ // still actionable — the App id, the handle for a replacement key.
1479
942
  console.error(`\nStopping here: the App exists but its key was not stored, so installing it now\n` +
1480
943
  ' would achieve nothing. To recover, open the App and generate a private key from\n' +
1481
944
  ' its "Private keys" section, then run `verify`; or delete the App and start again.\n\n' +
@@ -1488,9 +951,9 @@ async function createCommand(options) {
1488
951
  ' That page shows an Install button first — the repository picker only appears after\n' +
1489
952
  ' you click it. Choose "Only select repositories" and pick that one repository.\n');
1490
953
  await pressEnter(' Press Enter once it is installed.');
1491
- // Wrapped because a rejection here must not be able to change what was
1492
- // already reported about the key. By this point the key is stored (or
1493
- // deliberately not), and a readback failure is only ever advisory.
954
+ // Wrapped so a rejection here cannot change what was already reported about
955
+ // the key: by this point it is stored (or deliberately not), and a
956
+ // readback failure is only ever advisory.
1494
957
  let readback;
1495
958
  try {
1496
959
  readback = await readBackInstallation(String(created.id), created.pem, target);