canopycms 0.0.68-int.98 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2131 -0
- package/README.md +47 -63
- package/dist/ai/generate.d.ts +5 -0
- package/dist/ai/generate.js +244 -115
- package/dist/ai/index.d.ts +1 -1
- package/dist/ai/json-to-markdown.d.ts +21 -1
- package/dist/ai/json-to-markdown.js +120 -57
- package/dist/ai/references.d.ts +72 -0
- package/dist/ai/references.js +223 -0
- package/dist/ai/resolve-branch.js +9 -2
- package/dist/ai/to-plain-text.d.ts +17 -8
- package/dist/ai/to-plain-text.js +18 -15
- package/dist/ai/types.d.ts +84 -3
- package/dist/api/admin-branch-health.d.ts +24 -1
- package/dist/api/admin-branch-health.js +52 -20
- package/dist/api/admin.d.ts +23 -5
- package/dist/api/admin.js +36 -1
- package/dist/api/assets.js +88 -78
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-merge.d.ts +1 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +16 -7
- package/dist/api/branch-status.d.ts +16 -6
- package/dist/api/branch-status.js +57 -12
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +8 -3
- package/dist/api/branch.d.ts +25 -12
- package/dist/api/branch.js +164 -103
- package/dist/api/client.d.ts +78 -58
- package/dist/api/client.js +150 -117
- package/dist/api/comments.d.ts +12 -3
- package/dist/api/comments.js +118 -9
- package/dist/api/content.d.ts +9 -9
- package/dist/api/content.js +131 -22
- package/dist/api/entries-constants.d.ts +14 -3
- package/dist/api/entries-constants.js +14 -3
- package/dist/api/entries.d.ts +40 -2
- package/dist/api/entries.js +102 -6
- package/dist/api/github-sync.d.ts +19 -0
- package/dist/api/github-sync.js +50 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/groups.js +2 -1
- package/dist/api/guards.js +23 -3
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +1 -0
- package/dist/api/permissions.d.ts +21 -2
- package/dist/api/permissions.js +50 -3
- package/dist/api/request-deadline.d.ts +59 -0
- package/dist/api/request-deadline.js +73 -0
- package/dist/api/resolve-references.js +8 -7
- package/dist/api/route-builder.d.ts +14 -0
- package/dist/api/route-builder.js +14 -1
- package/dist/api/schema.d.ts +11 -9
- package/dist/api/schema.js +2 -0
- package/dist/api/settings-helpers.d.ts +5 -1
- package/dist/api/settings-helpers.js +10 -2
- package/dist/api/types.d.ts +18 -0
- package/dist/api/users-constants.d.ts +6 -0
- package/dist/api/users-constants.js +6 -0
- package/dist/assets/asset-url.d.ts +28 -16
- package/dist/assets/asset-url.js +51 -25
- package/dist/assets/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +31 -4
- package/dist/auth/cache.d.ts +1 -1
- package/dist/auth/cache.js +1 -1
- package/dist/auth/caching-auth-plugin.d.ts +2 -0
- package/dist/auth/caching-auth-plugin.js +12 -2
- package/dist/auth/file-based-auth-cache.d.ts +8 -0
- package/dist/auth/file-based-auth-cache.js +18 -8
- package/dist/auth/person-name.d.ts +5 -0
- package/dist/auth/person-name.js +8 -0
- package/dist/auth/plugin.d.ts +10 -1
- package/dist/auth/types.d.ts +2 -0
- package/dist/auth/user-metadata-lookup.d.ts +21 -0
- package/dist/auth/user-metadata-lookup.js +116 -0
- package/dist/authorization/branch.d.ts +1 -1
- package/dist/authorization/branch.js +12 -13
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/helpers.d.ts +8 -0
- package/dist/authorization/helpers.js +8 -0
- package/dist/authorization/index.d.ts +1 -1
- package/dist/authorization/index.js +1 -1
- package/dist/authorization/protected-branch.d.ts +5 -4
- package/dist/authorization/protected-branch.js +7 -5
- package/dist/branch-health.d.ts +35 -8
- package/dist/branch-health.js +54 -18
- package/dist/branch-metadata-error.d.ts +27 -0
- package/dist/branch-metadata-error.js +23 -0
- package/dist/branch-metadata-file.d.ts +205 -24
- package/dist/branch-metadata-file.js +126 -25
- package/dist/branch-metadata.d.ts +45 -6
- package/dist/branch-metadata.js +142 -50
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-registry.d.ts +8 -0
- package/dist/branch-registry.js +61 -9
- package/dist/branch-schema-cache.d.ts +29 -12
- package/dist/branch-schema-cache.js +64 -21
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +239 -100
- package/dist/build/asset-refs.d.ts +94 -0
- package/dist/build/asset-refs.js +267 -0
- package/dist/build/generate-ai-content.js +5 -0
- package/dist/build/inline-flight.d.ts +37 -0
- package/dist/build/inline-flight.js +142 -0
- package/dist/cli/asset-refs.d.ts +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +8543 -3193
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +4244 -1072
- package/dist/cli/init.js +217 -126
- package/dist/cli/project-detect.d.ts +2 -0
- package/dist/cli/project-detect.js +2 -0
- package/dist/cli/template-files/Dockerfile.cms.template +13 -5
- package/dist/cli/template-files/cms-stack.ts.template +16 -13
- package/dist/client.d.ts +1 -2
- package/dist/client.js +1 -2
- package/dist/comment-store.d.ts +25 -0
- package/dist/comment-store.js +43 -8
- package/dist/config/flatten.js +2 -0
- package/dist/config/helpers.js +2 -2
- package/dist/config/index.d.ts +1 -1
- package/dist/config/schemas/config.d.ts +12 -29
- package/dist/config/schemas/config.js +9 -12
- package/dist/config/schemas/field.d.ts +3 -0
- package/dist/config/schemas/field.js +58 -4
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +2 -20
- package/dist/config/schemas/url.js +2 -25
- package/dist/config/types.d.ts +116 -22
- package/dist/config/types.js +3 -9
- package/dist/config/validation.d.ts +25 -4
- package/dist/config/validation.js +116 -3
- package/dist/content-id-index.js +2 -2
- package/dist/content-index-generation.d.ts +1 -1
- package/dist/content-index-generation.js +1 -1
- package/dist/content-listing.d.ts +10 -1
- package/dist/content-listing.js +22 -5
- package/dist/content-reader.d.ts +9 -0
- package/dist/content-reader.js +16 -11
- package/dist/content-store.d.ts +75 -12
- package/dist/content-store.js +249 -86
- package/dist/content-tree.js +2 -2
- package/dist/context.js +5 -2
- package/dist/dangling-reference-log.d.ts +17 -0
- package/dist/dangling-reference-log.js +29 -0
- package/dist/dev-content-watcher.d.ts +1 -1
- package/dist/dev-content-watcher.js +1 -1
- package/dist/editor/BranchManager.d.ts +23 -7
- package/dist/editor/BranchManager.js +119 -54
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -2
- package/dist/editor/CommentsPanel.d.ts +14 -0
- package/dist/editor/CommentsPanel.js +68 -43
- package/dist/editor/Editor.d.ts +11 -5
- package/dist/editor/Editor.js +891 -218
- package/dist/editor/EditorAuthGate.d.ts +7 -1
- package/dist/editor/EditorAuthGate.js +41 -9
- package/dist/editor/EntryNavigator.d.ts +22 -0
- package/dist/editor/EntryNavigator.js +353 -105
- package/dist/editor/FormHeader.d.ts +25 -0
- package/dist/editor/FormHeader.js +82 -0
- package/dist/editor/FormRenderer.d.ts +27 -3
- package/dist/editor/FormRenderer.js +171 -76
- package/dist/editor/PreviewFrame.d.ts +40 -0
- package/dist/editor/PreviewFrame.js +215 -0
- package/dist/editor/PreviewPane.d.ts +47 -0
- package/dist/editor/PreviewPane.js +147 -0
- package/dist/editor/PreviewToolbarTools.d.ts +22 -0
- package/dist/editor/PreviewToolbarTools.js +73 -0
- package/dist/editor/admin/SystemHealthPanel.d.ts +2 -1
- package/dist/editor/admin/SystemHealthPanel.js +163 -33
- package/dist/editor/admin/useSystemHealth.d.ts +18 -0
- package/dist/editor/admin/useSystemHealth.js +70 -1
- package/dist/editor/branch-status.d.ts +17 -0
- package/dist/editor/branch-status.js +29 -0
- package/dist/editor/canopy-path.d.ts +9 -6
- package/dist/editor/canopy-path.js +8 -11
- package/dist/editor/client-reference-resolver.d.ts +43 -16
- package/dist/editor/client-reference-resolver.js +155 -64
- package/dist/editor/comments/BranchComments.d.ts +1 -1
- package/dist/editor/comments/BranchComments.js +2 -2
- package/dist/editor/comments/CloseRefusedNotice.d.ts +4 -0
- package/dist/editor/comments/CloseRefusedNotice.js +7 -0
- package/dist/editor/comments/CommentThreadsPopover.d.ts +47 -0
- package/dist/editor/comments/CommentThreadsPopover.js +274 -0
- package/dist/editor/comments/CommentWriteAlert.d.ts +11 -0
- package/dist/editor/comments/CommentWriteAlert.js +13 -0
- package/dist/editor/comments/EntryComments.d.ts +10 -4
- package/dist/editor/comments/EntryComments.js +14 -17
- package/dist/editor/comments/FieldWrapper.d.ts +18 -4
- package/dist/editor/comments/FieldWrapper.js +62 -28
- package/dist/editor/comments/InlineCommentThread.d.ts +9 -2
- package/dist/editor/comments/InlineCommentThread.js +11 -47
- package/dist/editor/comments/ThreadCarousel.d.ts +1 -1
- package/dist/editor/comments/ThreadCarousel.js +18 -21
- package/dist/editor/comments/ThreadReplyControls.d.ts +28 -0
- package/dist/editor/comments/ThreadReplyControls.js +92 -0
- package/dist/editor/comments/field-comment-context.d.ts +14 -0
- package/dist/editor/comments/field-comment-context.js +14 -0
- package/dist/editor/comments/form-comments.d.ts +48 -0
- package/dist/editor/comments/form-comments.js +178 -0
- package/dist/editor/comments/item-anchor-copy.d.ts +6 -0
- package/dist/editor/comments/item-anchor-copy.js +6 -0
- package/dist/editor/comments/move-focus-if-unmoved.d.ts +6 -0
- package/dist/editor/comments/move-focus-if-unmoved.js +11 -0
- package/dist/editor/comments/popover-placement.d.ts +18 -0
- package/dist/editor/comments/popover-placement.js +58 -0
- package/dist/editor/comments/use-tracked-writes.d.ts +16 -0
- package/dist/editor/comments/use-tracked-writes.js +53 -0
- package/dist/editor/components/BranchAccessList.d.ts +15 -0
- package/dist/editor/components/BranchAccessList.js +52 -0
- package/dist/editor/components/BranchLockBanner.d.ts +22 -0
- package/dist/editor/components/BranchLockBanner.js +39 -0
- package/dist/editor/components/BranchPill.d.ts +32 -0
- package/dist/editor/components/BranchPill.js +33 -0
- package/dist/editor/components/BranchesDrawer.d.ts +11 -0
- package/dist/editor/components/BranchesDrawer.js +5 -0
- package/dist/editor/components/ConfirmDeleteModal.d.ts +6 -1
- package/dist/editor/components/ConfirmDeleteModal.js +3 -2
- package/dist/editor/components/DestructiveMenuItem.d.ts +7 -0
- package/dist/editor/components/DestructiveMenuItem.js +8 -0
- package/dist/editor/components/EditorCrashScreen.d.ts +17 -0
- package/dist/editor/components/EditorCrashScreen.js +22 -0
- package/dist/editor/components/EditorErrorBoundary.d.ts +38 -0
- package/dist/editor/components/EditorErrorBoundary.js +40 -0
- package/dist/editor/components/EditorHeader.d.ts +56 -106
- package/dist/editor/components/EditorHeader.js +130 -131
- package/dist/editor/components/EditorRail.d.ts +49 -0
- package/dist/editor/components/EditorRail.js +65 -0
- package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
- package/dist/editor/components/EntryBreadcrumb.js +56 -0
- package/dist/editor/components/EntryCreateModal.d.ts +12 -7
- package/dist/editor/components/EntryCreateModal.js +51 -57
- package/dist/editor/components/NoEditPermissionNotice.d.ts +6 -5
- package/dist/editor/components/NoEditPermissionNotice.js +5 -4
- package/dist/editor/components/QuickOpenPalette.d.ts +22 -0
- package/dist/editor/components/QuickOpenPalette.js +66 -0
- package/dist/editor/components/ReadOnlyDraftNotice.d.ts +11 -0
- package/dist/editor/components/ReadOnlyDraftNotice.js +12 -0
- package/dist/editor/components/ReasonedMenuItem.d.ts +15 -0
- package/dist/editor/components/ReasonedMenuItem.js +15 -0
- package/dist/editor/components/ReferencedByList.d.ts +11 -0
- package/dist/editor/components/ReferencedByList.js +28 -0
- package/dist/editor/components/RelativeTime.d.ts +10 -0
- package/dist/editor/components/RelativeTime.js +10 -0
- package/dist/editor/components/RenameEntryModal.d.ts +9 -1
- package/dist/editor/components/RenameEntryModal.js +19 -31
- package/dist/editor/components/RetryToastMessage.d.ts +9 -0
- package/dist/editor/components/RetryToastMessage.js +6 -0
- package/dist/editor/components/SaveAllResult.d.ts +25 -0
- package/dist/editor/components/SaveAllResult.js +86 -0
- package/dist/editor/components/SaveSplitButton.d.ts +30 -0
- package/dist/editor/components/SaveSplitButton.js +108 -0
- package/dist/editor/components/StagedChangesDrawer.d.ts +39 -0
- package/dist/editor/components/StagedChangesDrawer.js +92 -0
- package/dist/editor/components/UnavailableEntryNotice.d.ts +6 -0
- package/dist/editor/components/UnavailableEntryNotice.js +9 -0
- package/dist/editor/components/UnavailableTypeMessage.d.ts +6 -0
- package/dist/editor/components/UnavailableTypeMessage.js +9 -0
- package/dist/editor/components/UndoToastMessage.d.ts +18 -0
- package/dist/editor/components/UndoToastMessage.js +10 -0
- package/dist/editor/components/UserBadge.d.ts +2 -5
- package/dist/editor/components/UserBadge.js +62 -33
- package/dist/editor/components/ValidationSummary.d.ts +14 -0
- package/dist/editor/components/ValidationSummary.js +24 -0
- package/dist/editor/components/index.d.ts +2 -2
- package/dist/editor/components/index.js +2 -2
- package/dist/editor/context/ApiClientContext.d.ts +8 -2
- package/dist/editor/context/ApiClientContext.js +19 -8
- package/dist/editor/context/AssetContext.d.ts +13 -35
- package/dist/editor/context/AssetContext.js +12 -31
- package/dist/editor/context/EditorStateContext.d.ts +4 -4
- package/dist/editor/context/EditorStateContext.js +4 -4
- package/dist/editor/context/SWRProvider.d.ts +3 -3
- package/dist/editor/context/SiteMdxAllowContext.d.ts +4 -0
- package/dist/editor/context/SiteMdxAllowContext.js +7 -0
- package/dist/editor/context/index.d.ts +2 -1
- package/dist/editor/context/index.js +2 -1
- package/dist/editor/contrast.d.ts +19 -0
- package/dist/editor/contrast.js +41 -0
- package/dist/editor/copy.d.ts +18 -0
- package/dist/editor/copy.js +18 -0
- package/dist/editor/editor-config.js +16 -0
- package/dist/editor/editor-utils.d.ts +34 -8
- package/dist/editor/editor-utils.js +42 -20
- package/dist/editor/entry-search.d.ts +20 -0
- package/dist/editor/entry-search.js +34 -0
- package/dist/editor/field-breadcrumb.d.ts +12 -0
- package/dist/editor/field-breadcrumb.js +85 -0
- package/dist/editor/field-props.d.ts +86 -0
- package/dist/editor/field-props.js +27 -0
- package/dist/editor/field-target.d.ts +30 -0
- package/dist/editor/field-target.js +77 -0
- package/dist/editor/fields/BlockField.d.ts +12 -0
- package/dist/editor/fields/BlockField.js +89 -73
- package/dist/editor/fields/CodeField.d.ts +2 -0
- package/dist/editor/fields/CodeField.js +6 -2
- package/dist/editor/fields/DateTimeField.d.ts +4 -29
- package/dist/editor/fields/DateTimeField.js +79 -41
- package/dist/editor/fields/ExpandAllButton.d.ts +6 -0
- package/dist/editor/fields/ExpandAllButton.js +4 -0
- package/dist/editor/fields/FieldCrashFallback.d.ts +17 -0
- package/dist/editor/fields/FieldCrashFallback.js +30 -0
- package/dist/editor/fields/FieldLabel.d.ts +37 -0
- package/dist/editor/fields/FieldLabel.js +19 -0
- package/dist/editor/fields/ImageField.d.ts +4 -0
- package/dist/editor/fields/ImageField.js +29 -14
- package/dist/editor/fields/InlineGroupField.js +4 -3
- package/dist/editor/fields/ItemList.d.ts +42 -0
- package/dist/editor/fields/ItemList.js +130 -0
- package/dist/editor/fields/MarkdownField.d.ts +32 -0
- package/dist/editor/fields/MarkdownField.js +223 -46
- package/dist/editor/fields/MdxImageDialog.js +1 -1
- package/dist/editor/fields/NumberField.d.ts +2 -0
- package/dist/editor/fields/NumberField.js +8 -5
- package/dist/editor/fields/NumberListField.d.ts +2 -0
- package/dist/editor/fields/NumberListField.js +19 -16
- package/dist/editor/fields/ObjectField.d.ts +2 -7
- package/dist/editor/fields/ObjectField.js +15 -5
- package/dist/editor/fields/ObjectListField.d.ts +29 -0
- package/dist/editor/fields/ObjectListField.js +57 -0
- package/dist/editor/fields/ReferenceField.d.ts +2 -0
- package/dist/editor/fields/ReferenceField.js +34 -9
- package/dist/editor/fields/SelectField.d.ts +2 -0
- package/dist/editor/fields/SelectField.js +11 -2
- package/dist/editor/fields/StringListField.d.ts +4 -1
- package/dist/editor/fields/StringListField.js +22 -8
- package/dist/editor/fields/TextField.d.ts +2 -0
- package/dist/editor/fields/TextField.js +6 -2
- package/dist/editor/fields/ToggleField.d.ts +2 -0
- package/dist/editor/fields/ToggleField.js +8 -3
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +1 -1
- package/dist/editor/fields/form-expansion.d.ts +9 -0
- package/dist/editor/fields/form-expansion.js +2 -0
- package/dist/editor/fields/item-expansion.d.ts +15 -0
- package/dist/editor/fields/item-expansion.js +60 -0
- package/dist/editor/fields/list-item-keys.d.ts +8 -0
- package/dist/editor/fields/list-item-keys.js +50 -0
- package/dist/editor/fields/list-item-title.d.ts +20 -0
- package/dist/editor/fields/list-item-title.js +85 -0
- package/dist/editor/fields/markdown-export-options.d.ts +2 -0
- package/dist/editor/fields/markdown-export-options.js +8 -0
- package/dist/editor/fields/markdown-fidelity-visitors.d.ts +11 -0
- package/dist/editor/fields/markdown-fidelity-visitors.js +160 -0
- package/dist/editor/fields/mdx-jsx-support.d.ts +5 -1
- package/dist/editor/fields/mdx-jsx-support.js +157 -69
- package/dist/editor/fields/rich-text-failures.d.ts +5 -0
- package/dist/editor/fields/rich-text-failures.js +26 -0
- package/dist/editor/fields/toolbar-wrap.d.ts +7 -0
- package/dist/editor/fields/toolbar-wrap.js +35 -0
- package/dist/editor/form-outline.d.ts +13 -0
- package/dist/editor/form-outline.js +70 -0
- package/dist/editor/group-manager/GroupCard.d.ts +4 -1
- package/dist/editor/group-manager/GroupCard.js +12 -3
- package/dist/editor/group-manager/GroupForm.js +1 -1
- package/dist/editor/group-manager/InternalGroupsTab.d.ts +2 -1
- package/dist/editor/group-manager/InternalGroupsTab.js +4 -4
- package/dist/editor/group-manager/MemberList.d.ts +2 -1
- package/dist/editor/group-manager/MemberList.js +15 -4
- package/dist/editor/group-manager/hooks/useGroupState.d.ts +5 -0
- package/dist/editor/group-manager/hooks/useGroupState.js +30 -7
- package/dist/editor/group-manager/index.js +34 -5
- package/dist/editor/group-manager/types.d.ts +5 -2
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +54 -0
- package/dist/editor/hooks/save-all.d.ts +68 -0
- package/dist/editor/hooks/save-all.js +61 -0
- package/dist/editor/hooks/useBranchActions.d.ts +11 -1
- package/dist/editor/hooks/useBranchActions.js +42 -19
- package/dist/editor/hooks/useBranchManager.d.ts +21 -0
- package/dist/editor/hooks/useBranchManager.js +321 -125
- package/dist/editor/hooks/useBranchesData.d.ts +8 -4
- package/dist/editor/hooks/useBranchesData.js +9 -5
- package/dist/editor/hooks/useCommentSystem.d.ts +14 -2
- package/dist/editor/hooks/useCommentSystem.js +79 -55
- package/dist/editor/hooks/useCommentsData.d.ts +11 -7
- package/dist/editor/hooks/useCommentsData.js +20 -19
- package/dist/editor/hooks/useDraftManager.d.ts +77 -3
- package/dist/editor/hooks/useDraftManager.js +887 -94
- package/dist/editor/hooks/useEditorLayout.d.ts +53 -7
- package/dist/editor/hooks/useEditorLayout.js +98 -20
- package/dist/editor/hooks/useEntriesData.d.ts +2 -1
- package/dist/editor/hooks/useEntriesData.js +18 -4
- package/dist/editor/hooks/useEntryManager.d.ts +41 -2
- package/dist/editor/hooks/useEntryManager.js +227 -41
- package/dist/editor/hooks/useGroupManager.js +13 -16
- package/dist/editor/hooks/usePermissionManager.js +14 -6
- package/dist/editor/hooks/usePreviewMarks.d.ts +21 -0
- package/dist/editor/hooks/usePreviewMarks.js +33 -0
- package/dist/editor/hooks/useReferenceResolution.d.ts +10 -3
- package/dist/editor/hooks/useReferenceResolution.js +34 -177
- package/dist/editor/hooks/useSchemaManager.d.ts +25 -6
- package/dist/editor/hooks/useSchemaManager.js +97 -39
- package/dist/editor/hooks/useUserMetadata.d.ts +14 -4
- package/dist/editor/hooks/useUserMetadata.js +76 -47
- package/dist/editor/hooks/user-metadata-batcher.d.ts +8 -0
- package/dist/editor/hooks/user-metadata-batcher.js +33 -0
- package/dist/editor/media/AssetCard.d.ts +6 -7
- package/dist/editor/media/AssetCard.js +24 -31
- package/dist/editor/media/AssetListRow.d.ts +16 -0
- package/dist/editor/media/AssetListRow.js +41 -0
- package/dist/editor/media/CropStep.js +6 -1
- package/dist/editor/media/MediaLibrary.d.ts +4 -3
- package/dist/editor/media/MediaLibrary.js +11 -4
- package/dist/editor/media/MediaLibraryBody.d.ts +3 -3
- package/dist/editor/media/MediaLibraryBody.js +195 -80
- package/dist/editor/media/MediaLibrarySkeleton.d.ts +6 -0
- package/dist/editor/media/MediaLibrarySkeleton.js +14 -0
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +8 -0
- package/dist/editor/media/editor-image-src.js +10 -0
- package/dist/editor/media/format-bytes.d.ts +2 -0
- package/dist/editor/media/format-bytes.js +23 -0
- package/dist/editor/media/media-library-prefs.d.ts +23 -0
- package/dist/editor/media/media-library-prefs.js +49 -0
- package/dist/editor/media/media-library-view.d.ts +34 -0
- package/dist/editor/media/media-library-view.js +40 -0
- package/dist/editor/media/useAssetLibrary.d.ts +30 -0
- package/dist/editor/media/useAssetLibrary.js +126 -0
- package/dist/editor/media/useStableAssetOrder.d.ts +11 -0
- package/dist/editor/media/useStableAssetOrder.js +37 -0
- package/dist/editor/media/useThumbnailFailure.d.ts +6 -0
- package/dist/editor/media/useThumbnailFailure.js +16 -0
- package/dist/editor/permission-manager/GroupSelector.js +2 -2
- package/dist/editor/permission-manager/PermissionEditor.d.ts +1 -1
- package/dist/editor/permission-manager/PermissionEditor.js +15 -4
- package/dist/editor/permission-manager/PermissionLevelBadge.js +1 -1
- package/dist/editor/permission-manager/PermissionTree.d.ts +1 -1
- package/dist/editor/permission-manager/UserSelector.d.ts +1 -1
- package/dist/editor/permission-manager/UserSelector.js +3 -3
- package/dist/editor/permission-manager/index.js +12 -3
- package/dist/editor/permission-manager/types.d.ts +5 -2
- package/dist/editor/platform.d.ts +3 -0
- package/dist/editor/platform.js +9 -0
- package/dist/editor/preview-asset-base.d.ts +9 -0
- package/dist/editor/preview-asset-base.js +17 -0
- package/dist/editor/preview-bridge.d.ts +51 -27
- package/dist/editor/preview-bridge.js +103 -144
- package/dist/editor/preview-marks.d.ts +15 -0
- package/dist/editor/preview-marks.js +74 -0
- package/dist/editor/preview-path.d.ts +12 -0
- package/dist/editor/preview-path.js +45 -1
- package/dist/editor/preview-scale.d.ts +27 -0
- package/dist/editor/preview-scale.js +34 -0
- package/dist/editor/raw-asset-base.d.ts +5 -0
- package/dist/editor/raw-asset-base.js +14 -0
- package/dist/editor/relative-time.d.ts +4 -4
- package/dist/editor/relative-time.js +16 -8
- package/dist/editor/schema-editor/CollectionEditor.d.ts +20 -3
- package/dist/editor/schema-editor/CollectionEditor.js +361 -62
- package/dist/editor/schema-editor/EntryTypeEditor.d.ts +5 -1
- package/dist/editor/schema-editor/EntryTypeEditor.js +11 -6
- package/dist/editor/schema-editor/collection-draft.d.ts +22 -0
- package/dist/editor/schema-editor/collection-draft.js +40 -0
- package/dist/editor/schema-editor/index.d.ts +1 -1
- package/dist/editor/schema-label.d.ts +10 -0
- package/dist/editor/schema-label.js +17 -0
- package/dist/editor/settings-conflict.d.ts +5 -0
- package/dist/editor/settings-conflict.js +8 -0
- package/dist/editor/shell/ActivityRail.d.ts +37 -0
- package/dist/editor/shell/ActivityRail.js +91 -0
- package/dist/editor/shell/EditorShell.d.ts +17 -0
- package/dist/editor/shell/EditorShell.js +31 -0
- package/dist/editor/shell/PaneLayout.d.ts +28 -0
- package/dist/editor/shell/PaneLayout.js +116 -0
- package/dist/editor/shell/SidePanelHost.d.ts +35 -0
- package/dist/editor/shell/SidePanelHost.js +184 -0
- package/dist/editor/shell/TopBar.d.ts +12 -0
- package/dist/editor/shell/TopBar.js +14 -0
- package/dist/editor/shell/host-chrome.d.ts +16 -0
- package/dist/editor/shell/host-chrome.js +69 -0
- package/dist/editor/slug.d.ts +16 -0
- package/dist/editor/slug.js +43 -0
- package/dist/editor/theme.d.ts +2 -1
- package/dist/editor/theme.js +314 -13
- package/dist/editor/unanswered.d.ts +17 -0
- package/dist/editor/unanswered.js +26 -0
- package/dist/editor/unavailable-entry-type.d.ts +20 -0
- package/dist/editor/unavailable-entry-type.js +28 -0
- package/dist/editor/undo-removal.d.ts +57 -0
- package/dist/editor/undo-removal.js +163 -0
- package/dist/editor/user-display.d.ts +4 -0
- package/dist/editor/user-display.js +5 -0
- package/dist/editor/utils/confirm-modal.d.ts +8 -0
- package/dist/editor/utils/confirm-modal.js +12 -0
- package/dist/editor/utils/editor-errors.d.ts +19 -0
- package/dist/editor/utils/editor-errors.js +32 -0
- package/dist/editor/utils/focus-return.d.ts +56 -0
- package/dist/editor/utils/focus-return.js +106 -0
- package/dist/entry-link-resolver.d.ts +11 -1
- package/dist/entry-link-resolver.js +65 -5
- package/dist/entry-schema-registry.d.ts +3 -3
- package/dist/entry-schema-registry.js +7 -4
- package/dist/entry-schema.d.ts +49 -3
- package/dist/entry-schema.js +18 -0
- package/dist/git-manager.d.ts +172 -10
- package/dist/git-manager.js +449 -107
- package/dist/github-service.d.ts +39 -2
- package/dist/github-service.js +101 -7
- package/dist/http/handler.js +74 -9
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +9 -4
- package/dist/http/worker-not-ready.js +59 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/item-keys.d.ts +46 -0
- package/dist/item-keys.js +235 -0
- package/dist/operating-mode/editor-mode-check.d.ts +15 -0
- package/dist/operating-mode/editor-mode-check.js +24 -0
- package/dist/operating-mode/mode-env.d.ts +13 -14
- package/dist/operating-mode/mode-env.js +13 -14
- package/dist/operating-mode/types.d.ts +2 -2
- package/dist/paths/branch.d.ts +0 -1
- package/dist/paths/branch.js +1 -8
- package/dist/paths/index.d.ts +1 -1
- package/dist/paths/index.js +1 -1
- package/dist/paths/validation.js +0 -2
- package/dist/preview.d.ts +8 -0
- package/dist/preview.js +7 -0
- package/dist/reference-resolver.js +1 -0
- package/dist/resolve-canopy-user.d.ts +1 -1
- package/dist/resolve-canopy-user.js +1 -1
- package/dist/schema/meta-loader.d.ts +18 -8
- package/dist/schema/meta-loader.js +42 -9
- package/dist/schema/registry-fingerprint.d.ts +14 -0
- package/dist/schema/registry-fingerprint.js +63 -0
- package/dist/schema/resolver.d.ts +7 -4
- package/dist/schema/resolver.js +7 -6
- package/dist/schema/schema-store.d.ts +7 -10
- package/dist/schema/schema-store.js +13 -11
- package/dist/schema/schema-unavailable-error.d.ts +11 -0
- package/dist/schema/schema-unavailable-error.js +15 -0
- package/dist/schema/types.d.ts +22 -0
- package/dist/schema-registry-record.d.ts +35 -0
- package/dist/schema-registry-record.js +134 -0
- package/dist/server.d.ts +79 -9
- package/dist/server.js +62 -8
- package/dist/services.d.ts +33 -5
- package/dist/services.js +155 -37
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +159 -45
- package/dist/stamp-on-publish.d.ts +36 -0
- package/dist/stamp-on-publish.js +71 -0
- package/dist/static/index.d.ts +58 -5
- package/dist/static/index.js +133 -3
- package/dist/static/seo.d.ts +5 -5
- package/dist/static/seo.js +5 -5
- package/dist/submission-attribution.d.ts +8 -0
- package/dist/submission-attribution.js +22 -0
- package/dist/system-users.d.ts +12 -0
- package/dist/system-users.js +12 -0
- package/dist/task-queue/cms-task-queue.d.ts +1 -1
- package/dist/task-queue/cms-task-queue.js +1 -1
- package/dist/task-queue/index.d.ts +1 -1
- package/dist/task-queue/index.js +1 -1
- package/dist/task-queue/task-queue.d.ts +6 -0
- package/dist/task-queue/task-queue.js +29 -0
- package/dist/task-queue/worker-status.d.ts +35 -9
- package/dist/task-queue/worker-status.js +84 -17
- package/dist/types.d.ts +90 -9
- package/dist/url-collision.js +3 -3
- package/dist/url-exclusivity-fixtures.d.ts +8 -1
- package/dist/url-exclusivity-fixtures.js +11 -1
- package/dist/user.js +2 -1
- package/dist/utils/atomic-write.js +2 -1
- package/dist/utils/base-branch.d.ts +13 -0
- package/dist/utils/base-branch.js +23 -0
- package/dist/utils/content-serialize.d.ts +15 -7
- package/dist/utils/content-serialize.js +353 -51
- package/dist/utils/content-write-lock.d.ts +6 -1
- package/dist/utils/content-write-lock.js +21 -6
- package/dist/utils/debug.d.ts +1 -1
- package/dist/utils/debug.js +5 -3
- package/dist/utils/error.js +13 -2
- package/dist/utils/escape-text.d.ts +7 -0
- package/dist/utils/escape-text.js +27 -0
- package/dist/utils/fs.d.ts +12 -4
- package/dist/utils/fs.js +18 -5
- package/dist/utils/git.d.ts +21 -1
- package/dist/utils/git.js +63 -8
- package/dist/utils/json-source-splice.d.ts +14 -0
- package/dist/utils/json-source-splice.js +120 -0
- package/dist/utils/markdown-body-splice.d.ts +39 -0
- package/dist/utils/markdown-body-splice.js +421 -0
- package/dist/utils/occ-json-write.js +18 -12
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +32 -1
- package/dist/utils/provisioning-lock.js +78 -32
- package/dist/utils/sanitize-href.d.ts +9 -23
- package/dist/utils/sanitize-href.js +14 -28
- package/dist/utils/title-field.d.ts +10 -0
- package/dist/utils/title-field.js +47 -0
- package/dist/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -0
- package/dist/validation/block-structural-keys.d.ts +1 -1
- package/dist/validation/block-structural-keys.js +1 -1
- package/dist/validation/deletion-checker.d.ts +21 -29
- package/dist/validation/deletion-checker.js +41 -117
- package/dist/validation/entry-validator.d.ts +6 -10
- package/dist/validation/entry-validator.js +19 -26
- package/dist/validation/field-traversal.d.ts +22 -0
- package/dist/validation/field-traversal.js +39 -8
- package/dist/validation/markdown-safety.d.ts +68 -0
- package/dist/validation/markdown-safety.js +596 -0
- package/dist/validation/mdx-allowlist.d.ts +55 -0
- package/dist/validation/mdx-allowlist.js +183 -0
- package/dist/validation/reference-validator.d.ts +2 -0
- package/dist/validation/reference-validator.js +3 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +124 -0
- package/dist/worker/branch-policy.js +243 -0
- package/dist/worker/bundle-header.d.ts +117 -0
- package/dist/worker/bundle-header.js +253 -0
- package/dist/worker/cms-worker.d.ts +236 -142
- package/dist/worker/cms-worker.js +772 -285
- package/dist/worker/gateway-egress.d.ts +43 -0
- package/dist/worker/gateway-egress.js +209 -0
- package/dist/worker/gateway-instance.d.ts +42 -0
- package/dist/worker/gateway-instance.js +129 -0
- package/dist/worker/git-process.d.ts +81 -0
- package/dist/worker/git-process.js +234 -0
- package/dist/worker/git-sync.d.ts +34 -17
- package/dist/worker/git-sync.js +394 -131
- package/dist/worker/github-auth.d.ts +9 -9
- package/dist/worker/github-auth.js +13 -15
- package/dist/worker/github-gateway-http.d.ts +62 -0
- package/dist/worker/github-gateway-http.js +977 -0
- package/dist/worker/github-gateway-wire.d.ts +590 -0
- package/dist/worker/github-gateway-wire.js +488 -0
- package/dist/worker/github-gateway.d.ts +238 -0
- package/dist/worker/github-gateway.js +726 -0
- package/dist/worker/github-mirror-upkeep.d.ts +75 -0
- package/dist/worker/github-mirror-upkeep.js +266 -0
- package/dist/worker/github-mirror.d.ts +364 -0
- package/dist/worker/github-mirror.js +1030 -0
- package/dist/worker/github-transfer.d.ts +56 -0
- package/dist/worker/github-transfer.js +444 -0
- package/dist/worker/history-rewrite.d.ts +4 -5
- package/dist/worker/history-rewrite.js +12 -17
- package/dist/worker/provisioned-workspace.d.ts +5 -5
- package/dist/worker/provisioned-workspace.js +5 -5
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +58 -54
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +12 -0
- package/dist/worker/schema-gate.d.ts +49 -0
- package/dist/worker/schema-gate.js +226 -0
- package/dist/worker/self-checks.d.ts +107 -0
- package/dist/worker/self-checks.js +358 -0
- package/dist/worker/shared-repo-git.d.ts +96 -0
- package/dist/worker/shared-repo-git.js +459 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +136 -0
- package/dist/worker/stale-mirror.d.ts +82 -0
- package/dist/worker/stale-mirror.js +130 -0
- package/dist/worker/task-runner.d.ts +5 -3
- package/dist/worker/task-runner.js +287 -186
- package/dist/worker/worker-context.d.ts +36 -37
- package/package.json +35 -7
- package/dist/editor/EditorPanes.d.ts +0 -10
- package/dist/editor/EditorPanes.js +0 -98
- package/dist/editor/components/EditorFooter.d.ts +0 -11
- package/dist/editor/components/EditorFooter.js +0 -9
- package/dist/editor/components/EditorSidebar.d.ts +0 -67
- package/dist/editor/components/EditorSidebar.js +0 -22
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { simpleGit } from 'simple-git';
|
|
5
|
+
import { gitNetworkChildEnv } from '../git-manager.js';
|
|
6
|
+
import { getErrorMessage } from '../utils/error.js';
|
|
7
|
+
import { BundleHeaderError, BundleHeaderReader, hasOnlyHead, } from './bundle-header.js';
|
|
8
|
+
import { runGit } from './git-process.js';
|
|
9
|
+
import { MaintenanceMutex, findCorruption, maintainMirror, objectStoreKiB, rebuildPaths, recoverRebuild, sweepLocksAndTemporaries, } from './github-mirror-upkeep.js';
|
|
10
|
+
import { GatewayRefusalError, MAX_BRANCH_NAME_LENGTH, branchNameProblem, } from './branch-policy.js';
|
|
11
|
+
import { gatewayLog, gatewayLogError, gatewayLogWarn, redactEgress } from './gateway-egress.js';
|
|
12
|
+
import { mirrorGitOptions, mirrorPins, quarantinePins } from './shared-repo-git.js';
|
|
13
|
+
/**
|
|
14
|
+
* The worker's private bare mirror of the GitHub repository, in a state directory the CMS Lambda
|
|
15
|
+
* cannot reach. It and the quarantines beside it are the only repositories in which git ever runs
|
|
16
|
+
* with the GitHub credential, and the state directory the only place the GitHub gateway runs git
|
|
17
|
+
* at all. Every gateway git names a repository inside it, by `GIT_DIR` and working directory
|
|
18
|
+
* alike, so no config or hook the Lambda writes is ever read by it; Lambda-written objects reach
|
|
19
|
+
* it only inside a pushed bundle, checked in a quarantine. The credential reaches git only as a
|
|
20
|
+
* per-command config file ({@link withCredentialConfig}).
|
|
21
|
+
*
|
|
22
|
+
* Objects cross the boundary as git bundles, never by a fetch or push against `remote.git`:
|
|
23
|
+
* - **out:** a fetch bundles what the caller's `have` lacks into `outgoing/`, one pending bundle
|
|
24
|
+
* at a time ({@link GitHubMirror.openBundle});
|
|
25
|
+
* - **in:** a pushed bundle is written to `incoming/`, its header read in node
|
|
26
|
+
* (worker/bundle-header.ts), and fetched, object-checked, into a fresh quarantine repository
|
|
27
|
+
* that borrows the mirror's objects through `objects/info/alternates`. The push to GitHub goes
|
|
28
|
+
* out from there. Pushed objects therefore never enter the mirror, whose objects come only
|
|
29
|
+
* from GitHub; the quarantine is deleted whole.
|
|
30
|
+
*
|
|
31
|
+
* `refs/heads/*` mirrors GitHub as of the last fetch. A cache: deleting it costs one fetch of the
|
|
32
|
+
* whole repository from GitHub.
|
|
33
|
+
*
|
|
34
|
+
* One process owns it, and {@link GitHubMirror.exclusive} runs one session at a time, so a fetch's
|
|
35
|
+
* `--prune` never meets a push half way, and the mirror's heads cannot move between a push's
|
|
36
|
+
* reachability test and the push. It sweeps what a killed git left, rebuilds itself from GitHub
|
|
37
|
+
* when it would lie, and runs maintenance beside sessions under a mutex of its own
|
|
38
|
+
* (worker/github-mirror-upkeep.ts, whose module doc holds the lock order).
|
|
39
|
+
*
|
|
40
|
+
* Every transfer runs with `--progress`: the timeout is inactivity, and a quiet fetch of a whole
|
|
41
|
+
* repository would otherwise be killed for its size. The connectivity walk after a fetch still
|
|
42
|
+
* prints nothing (safeinsights/canopycms#901).
|
|
43
|
+
*/
|
|
44
|
+
export class GitHubMirror {
|
|
45
|
+
/**
|
|
46
|
+
* `maintenanceIntervalMs` (default {@link MAINTENANCE_INTERVAL_MS}) also delays the first run:
|
|
47
|
+
* a mirror just fetched is in few packs. `bundleRetentionMs` (default
|
|
48
|
+
* {@link BUNDLE_RETENTION_MS}) is how long a bundle no one opens is kept.
|
|
49
|
+
*/
|
|
50
|
+
constructor(stateDirectory, timeoutMs, maintenanceIntervalMs = MAINTENANCE_INTERVAL_MS, bundleRetentionMs = BUNDLE_RETENTION_MS) {
|
|
51
|
+
this.stateDirectory = stateDirectory;
|
|
52
|
+
this.timeoutMs = timeoutMs;
|
|
53
|
+
this.maintenanceIntervalMs = maintenanceIntervalMs;
|
|
54
|
+
this.bundleRetentionMs = bundleRetentionMs;
|
|
55
|
+
this.tail = Promise.resolve();
|
|
56
|
+
this.ingestTail = Promise.resolve();
|
|
57
|
+
this.warnedSize = false;
|
|
58
|
+
this.ready = false;
|
|
59
|
+
this.swept = false;
|
|
60
|
+
this.transferDirs = null;
|
|
61
|
+
this.pendingBundle = null;
|
|
62
|
+
this.maintenance = new MaintenanceMutex();
|
|
63
|
+
this.corruption = null;
|
|
64
|
+
/**
|
|
65
|
+
* Changes whenever the mirror stops holding what the last GitHub fetch put there: it was
|
|
66
|
+
* created anew, found corrupt, or a rebuild replaced it. A GitHub fetch is reused only within
|
|
67
|
+
* the generation it completed in.
|
|
68
|
+
*/
|
|
69
|
+
this.generation = 0;
|
|
70
|
+
this.rebuildFailedAt = 0;
|
|
71
|
+
/** A sweep the maintenance mutex prevented, done at the next session start that can take it. */
|
|
72
|
+
this.sweepOwed = false;
|
|
73
|
+
this.activityListeners = new Set();
|
|
74
|
+
this.activity = () => {
|
|
75
|
+
for (const listener of this.activityListeners)
|
|
76
|
+
listener();
|
|
77
|
+
};
|
|
78
|
+
this.lastMaintenanceAt = Date.now();
|
|
79
|
+
this.gitDir = path.join(stateDirectory, 'github.git');
|
|
80
|
+
this.incomingDir = path.join(stateDirectory, 'incoming');
|
|
81
|
+
this.outgoingDir = path.join(stateDirectory, 'outgoing');
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Run `fn` with the mirror to itself, after every session started before it. The first session
|
|
85
|
+
* of a gateway first clears what a killed one left ({@link sweepAtStart}), and any session first
|
|
86
|
+
* does a sweep that is owed ({@link sweepIfOwed}). Creates the mirror if it is missing, and
|
|
87
|
+
* checks it again (for corruption too) after any session that failed. Deletes any credential
|
|
88
|
+
* file a killed or failed cleanup left: no session is running, so none is in use.
|
|
89
|
+
*/
|
|
90
|
+
exclusive(fn) {
|
|
91
|
+
const start = async () => {
|
|
92
|
+
try {
|
|
93
|
+
if (!this.swept) {
|
|
94
|
+
await this.sweepAtStart();
|
|
95
|
+
this.swept = true;
|
|
96
|
+
}
|
|
97
|
+
await this.sweepIfOwed();
|
|
98
|
+
if (!this.ready) {
|
|
99
|
+
await this.create();
|
|
100
|
+
this.ready = true;
|
|
101
|
+
}
|
|
102
|
+
await sweepCredentialConfigs(this.stateDirectory);
|
|
103
|
+
await this.ensureTransferDirs();
|
|
104
|
+
return await fn(new MirrorSession(this.gitDir, this.stateDirectory, this.timeoutMs, this.activity));
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
this.ready = false;
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const run = this.tail.then(start, start);
|
|
112
|
+
this.tail = run.catch(() => undefined);
|
|
113
|
+
return run;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Call `listener` at each sign of life from a session's git (output, input taken) until the
|
|
117
|
+
* returned function is called: what a caller waiting on the mirror sees of progress, whoever's
|
|
118
|
+
* session it is.
|
|
119
|
+
*/
|
|
120
|
+
watchActivity(listener) {
|
|
121
|
+
this.activityListeners.add(listener);
|
|
122
|
+
return () => this.activityListeners.delete(listener);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Create the state directory if it is missing, and refuse one this process does not own outright
|
|
126
|
+
* or another user can write: the gateway's boot check, whose failure no later session could clear.
|
|
127
|
+
*/
|
|
128
|
+
async assertStateDirectory() {
|
|
129
|
+
await fs.mkdir(this.stateDirectory, { recursive: true, mode: 0o700 });
|
|
130
|
+
await assertOwnDirectory(this.stateDirectory);
|
|
131
|
+
}
|
|
132
|
+
/** Create the mirror now rather than at its first use. */
|
|
133
|
+
ensure() {
|
|
134
|
+
return this.exclusive(async () => undefined);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Clear what a killed gateway left, before any git runs: finish or undo an interrupted rebuild,
|
|
138
|
+
* then every lock and temporary in the mirror. Nothing in this process writes it yet:
|
|
139
|
+
* maintenance starts only after a session. A git a stopped dev worker left running is not
|
|
140
|
+
* covered (safeinsights/canopycms#907).
|
|
141
|
+
*/
|
|
142
|
+
async sweepAtStart() {
|
|
143
|
+
await fs.mkdir(this.stateDirectory, { recursive: true, mode: 0o700 });
|
|
144
|
+
await assertOwnDirectory(this.stateDirectory);
|
|
145
|
+
await recoverRebuild(this.gitDir);
|
|
146
|
+
const removed = await sweepLocksAndTemporaries(this.gitDir);
|
|
147
|
+
if (removed.length > 0) {
|
|
148
|
+
gatewayLogWarn(`Removed what a stopped gateway left in the GitHub mirror: ${removed.join(', ')}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Create the mirror, or recreate one that is not a readable bare repository, and record whether
|
|
153
|
+
* an existing one is corrupt ({@link findCorruption}), which the next GitHub fetch's session
|
|
154
|
+
* repairs ({@link heal}); until then the generation change keeps every caller off it.
|
|
155
|
+
*/
|
|
156
|
+
async create() {
|
|
157
|
+
await fs.mkdir(this.stateDirectory, { recursive: true, mode: 0o700 });
|
|
158
|
+
await assertOwnDirectory(this.stateDirectory);
|
|
159
|
+
if (!(await isBareRepository(this.gitDir))) {
|
|
160
|
+
await fs.rm(this.gitDir, { recursive: true, force: true });
|
|
161
|
+
await initMirror(this.gitDir);
|
|
162
|
+
this.generation++;
|
|
163
|
+
}
|
|
164
|
+
await assertOwnDirectory(this.gitDir);
|
|
165
|
+
this.corruption = await findCorruption(this.gitDir, (args, stdin) => runInRepo(this.gitDir, this.timeoutMs, args, { stdin }));
|
|
166
|
+
// No fetch's snapshot vouches for a corrupt mirror, so no caller uses it until a fetch rebuilds it.
|
|
167
|
+
if (this.corruption !== null)
|
|
168
|
+
this.generation++;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Refuse a session that cannot repair the mirror while it is corrupt: one without the credential
|
|
172
|
+
* a rebuild fetches with.
|
|
173
|
+
*/
|
|
174
|
+
assertUsable() {
|
|
175
|
+
if (this.corruption !== null) {
|
|
176
|
+
throw new GitHubTransientError('mirror-corrupt', `The GitHub mirror needs rebuilding (${this.corruption}); the next fetch from GitHub does it`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Inside a GitHub fetch's session, rebuild a mirror {@link create} found corrupt, as a plain
|
|
181
|
+
* call (the session is not re-entrant), waiting for maintenance to finish: the lock order is session, then the
|
|
182
|
+
* maintenance mutex. The replacement is fetched whole from GitHub into `github.git.rebuild`, and
|
|
183
|
+
* swapped in only once complete: the mirror is renamed to `.old`, `.rebuild` into its place, and
|
|
184
|
+
* `.old` deleted ({@link recoverRebuild} finishes or undoes a swap a crash interrupted). No
|
|
185
|
+
* quarantine is live while the session is held, so moving the objects it would borrow is safe.
|
|
186
|
+
* After a failed rebuild, sessions answer transient for {@link REBUILD_BACKOFF_MS} rather than
|
|
187
|
+
* fetch the whole repository again each.
|
|
188
|
+
*/
|
|
189
|
+
async heal(session, credential) {
|
|
190
|
+
const reason = this.corruption;
|
|
191
|
+
if (reason === null)
|
|
192
|
+
return;
|
|
193
|
+
if (Date.now() - this.rebuildFailedAt < REBUILD_BACKOFF_MS)
|
|
194
|
+
this.assertUsable();
|
|
195
|
+
const { rebuild, old } = rebuildPaths(this.gitDir);
|
|
196
|
+
const release = await this.maintenance.acquire();
|
|
197
|
+
try {
|
|
198
|
+
await fs.rm(rebuild, { recursive: true, force: true });
|
|
199
|
+
await initMirror(rebuild);
|
|
200
|
+
await session.fetchFromGitHub(credential, undefined, rebuild);
|
|
201
|
+
await fs.rm(old, { recursive: true, force: true });
|
|
202
|
+
await fs.rename(this.gitDir, old);
|
|
203
|
+
await fs.rename(rebuild, this.gitDir);
|
|
204
|
+
await fs.rm(old, { recursive: true, force: true });
|
|
205
|
+
this.corruption = null;
|
|
206
|
+
this.generation++;
|
|
207
|
+
gatewayLogWarn(`Rebuilt the GitHub mirror from GitHub: ${reason}`);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
this.rebuildFailedAt = Date.now();
|
|
211
|
+
await fs.rm(rebuild, { recursive: true, force: true }).catch(() => undefined);
|
|
212
|
+
throw new GitHubTransientError('mirror-corrupt', `The GitHub mirror needs rebuilding (${reason}), and the rebuild failed: ` +
|
|
213
|
+
redactEgress(getErrorMessage(err)).trim());
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
release();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The GitHub fetch, inside a session. A failure sweeps the mirror's locks and temporaries
|
|
221
|
+
* ({@link sweepUnlessMaintained}), so it leaves no pack of its own behind; when git failed on
|
|
222
|
+
* one of the mirror's own leftovers ({@link isOwnLockFailure}) and the sweep ran, the fetch is
|
|
223
|
+
* tried once more. While maintenance runs, which holds such files live, nothing is swept, the
|
|
224
|
+
* sweep is owed, and a leftover's failure is transient. Never waits for the mutex, so a push
|
|
225
|
+
* never waits behind a repack.
|
|
226
|
+
*/
|
|
227
|
+
async fetchFromGitHub(session, credential, signal) {
|
|
228
|
+
try {
|
|
229
|
+
await session.fetchFromGitHub(credential, signal);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
const swept = await this.sweepUnlessMaintained('Removed stale locks from the GitHub mirror');
|
|
234
|
+
if (!isOwnLockFailure(getErrorMessage(err)))
|
|
235
|
+
throw err;
|
|
236
|
+
if (!swept) {
|
|
237
|
+
throw new GitHubTransientError('github-fetch-failed', 'The GitHub mirror is being maintained, and its fetch met a lock maintenance holds; ' +
|
|
238
|
+
'retrying runs after it');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
await session.fetchFromGitHub(credential, signal);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
await this.sweepUnlessMaintained('Removed what a failed fetch left in the GitHub mirror');
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Inside a session, sweep the mirror's locks and temporaries ({@link sweepLocksAndTemporaries}),
|
|
251
|
+
* logging what went under `label`, unless maintenance holds the mutex: then the sweep is owed.
|
|
252
|
+
* Returns whether it swept.
|
|
253
|
+
*/
|
|
254
|
+
async sweepUnlessMaintained(label) {
|
|
255
|
+
const release = this.maintenance.tryAcquire();
|
|
256
|
+
if (release === null) {
|
|
257
|
+
this.sweepOwed = true;
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const removed = await sweepLocksAndTemporaries(this.gitDir);
|
|
262
|
+
if (removed.length > 0)
|
|
263
|
+
gatewayLogWarn(`${label}: ${removed.join(', ')}`);
|
|
264
|
+
this.sweepOwed = false;
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
release();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** At a session's start, the sweep a held mutex prevented, if it can take the mutex now. */
|
|
272
|
+
async sweepIfOwed() {
|
|
273
|
+
if (this.sweepOwed) {
|
|
274
|
+
await this.sweepUnlessMaintained('Removed what an interrupted git left in the GitHub mirror');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* After a completed GitHub fetch's session: warn once if the mirror has grown past
|
|
279
|
+
* {@link MIRROR_SIZE_WARN_KIB}, and start maintenance, detached, at most once per maintenance
|
|
280
|
+
* interval, unless the mutex is held (a rebuild, or maintenance itself).
|
|
281
|
+
*/
|
|
282
|
+
async afterFetch() {
|
|
283
|
+
await this.warnIfLarge().catch((err) => gatewayLogWarn(`Could not measure the GitHub mirror: ${getErrorMessage(err)}`));
|
|
284
|
+
if (Date.now() - this.lastMaintenanceAt < this.maintenanceIntervalMs)
|
|
285
|
+
return;
|
|
286
|
+
const release = this.maintenance.tryAcquire();
|
|
287
|
+
if (release === null)
|
|
288
|
+
return;
|
|
289
|
+
this.lastMaintenanceAt = Date.now();
|
|
290
|
+
void this.runMaintenance().finally(release);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* One maintenance run, holding the mutex; it never takes a session. A failed run owes a sweep
|
|
294
|
+
* of what it left, and the next run waits a whole interval from this one's end.
|
|
295
|
+
*/
|
|
296
|
+
async runMaintenance() {
|
|
297
|
+
const started = Date.now();
|
|
298
|
+
try {
|
|
299
|
+
const { repacked } = await maintainMirror((args, { deadlineMs }) => runInRepo(this.gitDir, deadlineMs, args, { deadlineMs }));
|
|
300
|
+
if (repacked)
|
|
301
|
+
gatewayLog(`GitHub mirror repacked in ${Date.now() - started}ms`);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
this.sweepOwed = true;
|
|
305
|
+
gatewayLogError(`GitHub mirror maintenance failed: ${getErrorMessage(err)}`);
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
this.lastMaintenanceAt = Date.now();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async warnIfLarge() {
|
|
312
|
+
if (this.warnedSize)
|
|
313
|
+
return;
|
|
314
|
+
const kib = await objectStoreKiB((args) => runInRepo(this.gitDir, this.timeoutMs, args));
|
|
315
|
+
if (kib > MIRROR_SIZE_WARN_KIB) {
|
|
316
|
+
this.warnedSize = true;
|
|
317
|
+
gatewayLogWarn(`The worker's GitHub mirror (${this.gitDir}) holds ${Math.round(kib / 1024)} MiB of ` +
|
|
318
|
+
`objects, on the instance's root volume. See "The worker instance" in ` +
|
|
319
|
+
`docs/deploying-to-aws.md for the headroom it assumes.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Empty `incoming/` and `outgoing/` of what an earlier gateway left, once per gateway: after
|
|
324
|
+
* that, everything in them belongs to a transfer of this one's, which removes its own, so a
|
|
325
|
+
* later call only re-creates one that has gone (the state directory removed under it).
|
|
326
|
+
*/
|
|
327
|
+
async ensureTransferDirs() {
|
|
328
|
+
this.transferDirs ??= (async () => {
|
|
329
|
+
await fs.mkdir(this.stateDirectory, { recursive: true, mode: 0o700 });
|
|
330
|
+
await assertOwnDirectory(this.stateDirectory);
|
|
331
|
+
for (const dir of [this.incomingDir, this.outgoingDir]) {
|
|
332
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
})().catch((err) => {
|
|
335
|
+
this.transferDirs = null;
|
|
336
|
+
throw err;
|
|
337
|
+
});
|
|
338
|
+
await this.transferDirs;
|
|
339
|
+
for (const dir of [this.incomingDir, this.outgoingDir]) {
|
|
340
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* git for `check-ref-format`, run in the state directory like every other gateway git. It reads
|
|
345
|
+
* the repository's config, as any git does, and its refs only to expand `@{-N}`, which
|
|
346
|
+
* `assertPlainBranchName` refuses.
|
|
347
|
+
*/
|
|
348
|
+
async checkGit() {
|
|
349
|
+
await this.ensureTransferDirs();
|
|
350
|
+
return simpleGit({ baseDir: this.stateDirectory }).env(mirrorEnv(this.gitDir));
|
|
351
|
+
}
|
|
352
|
+
/** Where a session writes a new outgoing bundle: a fresh name, never a caller's. */
|
|
353
|
+
async outgoingBundlePath() {
|
|
354
|
+
await this.ensureTransferDirs();
|
|
355
|
+
return path.join(this.outgoingDir, `${randomBytes(16).toString('hex')}.bundle`);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Make `file` the one pending outgoing bundle, or none, deleting the one it replaces (never an
|
|
359
|
+
* opened one), so a loop of fetches never fills the state directory, and deleting it after the
|
|
360
|
+
* bundle retention if no one opens it. Returns the id that opens it: a key into this object,
|
|
361
|
+
* never a path.
|
|
362
|
+
*/
|
|
363
|
+
async setPendingBundle(file) {
|
|
364
|
+
const replaced = this.pendingBundle;
|
|
365
|
+
if (replaced !== null)
|
|
366
|
+
clearTimeout(replaced.expiry);
|
|
367
|
+
this.pendingBundle = null;
|
|
368
|
+
if (file !== null) {
|
|
369
|
+
const pending = {
|
|
370
|
+
id: randomBytes(16).toString('hex'),
|
|
371
|
+
file,
|
|
372
|
+
// A bundle its caller never opens (a worker gone) is deleted at the retention's end.
|
|
373
|
+
expiry: setTimeout(() => {
|
|
374
|
+
if (this.pendingBundle !== pending)
|
|
375
|
+
return;
|
|
376
|
+
this.pendingBundle = null;
|
|
377
|
+
void fs.rm(file, { force: true }).catch(() => undefined);
|
|
378
|
+
}, this.bundleRetentionMs),
|
|
379
|
+
};
|
|
380
|
+
pending.expiry.unref();
|
|
381
|
+
this.pendingBundle = pending;
|
|
382
|
+
}
|
|
383
|
+
if (replaced !== null)
|
|
384
|
+
await fs.rm(replaced.file, { force: true });
|
|
385
|
+
return this.pendingBundle?.id ?? null;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* The pending bundle `id` names, readable once: it is no longer pending once opened, so a later
|
|
389
|
+
* fetch never deletes it, and its file is deleted when the stream closes.
|
|
390
|
+
* Throws {@link GitHubTransientError} for an id that is not the pending bundle's.
|
|
391
|
+
*/
|
|
392
|
+
async openBundle(id) {
|
|
393
|
+
const pending = this.pendingBundle;
|
|
394
|
+
const gone = () => new GitHubTransientError('bundle-gone', `The bundle ${JSON.stringify(id.slice(0, 64))} is not available: a later fetch replaced ` +
|
|
395
|
+
`it, or it was already read. Fetch again.`);
|
|
396
|
+
if (pending === null || pending.id !== id)
|
|
397
|
+
throw gone();
|
|
398
|
+
clearTimeout(pending.expiry);
|
|
399
|
+
this.pendingBundle = null;
|
|
400
|
+
let handle;
|
|
401
|
+
try {
|
|
402
|
+
handle = await fs.open(pending.file, 'r');
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
throw gone();
|
|
406
|
+
}
|
|
407
|
+
const stream = handle.createReadStream();
|
|
408
|
+
stream.once('close', () => {
|
|
409
|
+
void fs.rm(pending.file, { force: true }).catch(() => undefined);
|
|
410
|
+
});
|
|
411
|
+
return stream;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Receive a pushed bundle into a directory of its own under `incoming/`, refusing it as it
|
|
415
|
+
* arrives (header, size), run `fn` with it, then delete the directory. One at a time, so pushed
|
|
416
|
+
* bundles hold at most one cap's worth of disk, plus about as much again for the quarantine's
|
|
417
|
+
* copy of the pack.
|
|
418
|
+
*/
|
|
419
|
+
ingest(body, expected, maxBytes, fn) {
|
|
420
|
+
const start = async () => {
|
|
421
|
+
await this.ensureTransferDirs();
|
|
422
|
+
const dir = await fs.mkdtemp(path.join(this.incomingDir, 'push-'));
|
|
423
|
+
try {
|
|
424
|
+
const file = path.join(dir, 'bundle');
|
|
425
|
+
const header = await receiveBundle(body, file, expected, maxBytes);
|
|
426
|
+
return await fn({ dir, file, header });
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const run = this.ingestTail.then(start, start);
|
|
433
|
+
this.ingestTail = run.catch(() => undefined);
|
|
434
|
+
return run;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Past this the mirror is using a large share of the worker's default 8 GiB root volume, which
|
|
439
|
+
* also holds the OS, Node and the log.
|
|
440
|
+
*/
|
|
441
|
+
const MIRROR_SIZE_WARN_KIB = 2 * 1024 * 1024;
|
|
442
|
+
/** How long a fetch's bundle waits to be opened before it is deleted. */
|
|
443
|
+
const BUNDLE_RETENTION_MS = 10 * 60_000;
|
|
444
|
+
/** How often maintenance may run. */
|
|
445
|
+
const MAINTENANCE_INTERVAL_MS = 10 * 60 * 1000;
|
|
446
|
+
/** How long sessions answer transient after a failed rebuild, rather than retry it each. */
|
|
447
|
+
const REBUILD_BACKOFF_MS = 5 * 60 * 1000;
|
|
448
|
+
/** The slowest a GitHub transfer may run, sustained, before curl ends it. */
|
|
449
|
+
const LOW_SPEED_BYTES_PER_SECOND = 1024;
|
|
450
|
+
/** Where a quarantine holds the pushed commit while it is checked and pushed. */
|
|
451
|
+
const STAGING_PREFIX = 'refs/canopy/outgoing/';
|
|
452
|
+
/**
|
|
453
|
+
* A push or fetch the gateway could not complete for a reason a later attempt can clear: GitHub
|
|
454
|
+
* unreachable, a bundle that did not arrive whole or valid, a prerequisite GitHub no longer has,
|
|
455
|
+
* a bundle already gone. The task retries it. The code says where it failed, not whose doing it
|
|
456
|
+
* was: a `github-fetch-failed` or a `mirror-corrupt` can each follow a GitHub-bound git that
|
|
457
|
+
* failed, and only that arms a credential re-read (`LocalGitHubGateway.credentialed`).
|
|
458
|
+
*/
|
|
459
|
+
export class GitHubTransientError extends Error {
|
|
460
|
+
constructor(code, message) {
|
|
461
|
+
super(message);
|
|
462
|
+
this.code = code;
|
|
463
|
+
this.name = 'GitHubTransientError';
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/** The origin a credential's header is scoped to when its URL has none of its own. */
|
|
467
|
+
const GITHUB_ORIGIN = 'https://github.com';
|
|
468
|
+
/** `mkdtemp`'s prefix for a credential file's directory; nothing else in the state directory starts so. */
|
|
469
|
+
const CREDENTIAL_DIR_PREFIX = '.canopy-github-credential-';
|
|
470
|
+
const CREDENTIAL_DIR_PATTERN = /^\.canopy-github-credential-[A-Za-z0-9]{6}$/;
|
|
471
|
+
/**
|
|
472
|
+
* The global-scope config a GitHub-bound command gets: the token as an `Authorization` header for
|
|
473
|
+
* the remote's origin only, and an empty credential-helper list, so a 401 asks no helper (a
|
|
474
|
+
* developer's keychain included) and git's prompt is off besides. A URL without an http(s) origin
|
|
475
|
+
* (a test's local fixture) gets GitHub's, which it can never match. The token is written only
|
|
476
|
+
* base64-encoded, so none of its bytes can end a line or a quoted section name.
|
|
477
|
+
*/
|
|
478
|
+
function credentialConfig(credential) {
|
|
479
|
+
let origin = GITHUB_ORIGIN;
|
|
480
|
+
try {
|
|
481
|
+
const url = new URL(credential.url);
|
|
482
|
+
if (url.protocol === 'https:' || url.protocol === 'http:')
|
|
483
|
+
origin = url.origin;
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
// Not a URL: a local path.
|
|
487
|
+
}
|
|
488
|
+
const basic = Buffer.from(`x-access-token:${credential.token}`).toString('base64');
|
|
489
|
+
return (`[http "${origin}/"]\n\textraheader = AUTHORIZATION: basic ${basic}\n` +
|
|
490
|
+
`[credential]\n\thelper =\n`);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Run `fn` with the path of a fresh config file holding the credential, then delete it. The file
|
|
494
|
+
* is created exclusively, `0600`, in a new `mkdtemp` directory inside the state directory, which
|
|
495
|
+
* {@link assertOwnDirectory} proves only this user can write: a path nobody else could have
|
|
496
|
+
* created first or swapped for a link. A file a crash or a failed delete leaves behind is swept
|
|
497
|
+
* before the next session ({@link GitHubMirror.exclusive}).
|
|
498
|
+
*/
|
|
499
|
+
async function withCredentialConfig(stateDirectory, credential, fn) {
|
|
500
|
+
await assertOwnDirectory(stateDirectory);
|
|
501
|
+
const dir = await fs.mkdtemp(path.join(stateDirectory, CREDENTIAL_DIR_PREFIX));
|
|
502
|
+
try {
|
|
503
|
+
const configPath = path.join(dir, 'config');
|
|
504
|
+
const file = await fs.open(configPath, 'wx', 0o600);
|
|
505
|
+
try {
|
|
506
|
+
await file.writeFile(credentialConfig(credential));
|
|
507
|
+
}
|
|
508
|
+
finally {
|
|
509
|
+
await file.close();
|
|
510
|
+
}
|
|
511
|
+
return await fn(configPath);
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
// Never thrown: it would replace the command's own failure.
|
|
515
|
+
await fs
|
|
516
|
+
.rm(dir, { recursive: true, force: true })
|
|
517
|
+
.catch((err) => gatewayLogWarn(`Could not delete a credential file in ${dir}: ${getErrorMessage(err)}`));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/** Delete every credential directory {@link withCredentialConfig} left in `stateDirectory`. */
|
|
521
|
+
async function sweepCredentialConfigs(stateDirectory) {
|
|
522
|
+
for (const name of await fs.readdir(stateDirectory)) {
|
|
523
|
+
if (CREDENTIAL_DIR_PATTERN.test(name)) {
|
|
524
|
+
await fs.rm(path.join(stateDirectory, name), { recursive: true, force: true });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Whether git's own output, not a `remote:` line relayed from GitHub, says a lock file or
|
|
530
|
+
* `packed-refs.new` was already there: the mirror's own housekeeping holds it, or a killed git
|
|
531
|
+
* left it, which says nothing about the credential.
|
|
532
|
+
* @internal Exported for tests.
|
|
533
|
+
*/
|
|
534
|
+
export function isOwnLockFailure(message) {
|
|
535
|
+
return message
|
|
536
|
+
.split('\n')
|
|
537
|
+
.some((line) => !/^\s*remote:/.test(line) &&
|
|
538
|
+
(/Unable to create '.*\.lock': File exists/.test(line) ||
|
|
539
|
+
/unable to create file .*packed-refs\.new: File exists/.test(line)));
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Refuse a directory the worker does not own outright: another local user could have created a
|
|
543
|
+
* predictable path (the `os.tmpdir()` default) first, with a mirror config of their own.
|
|
544
|
+
*/
|
|
545
|
+
async function assertOwnDirectory(dir) {
|
|
546
|
+
const stat = await fs.lstat(dir);
|
|
547
|
+
// Windows has no POSIX owner or mode bits to check.
|
|
548
|
+
const uid = process.getuid?.();
|
|
549
|
+
if (!stat.isDirectory() ||
|
|
550
|
+
(uid !== undefined && (stat.uid !== uid || (stat.mode & 0o022) !== 0))) {
|
|
551
|
+
throw new Error(`${dir} must be a directory this worker owns and no one else can write, for the GitHub ` +
|
|
552
|
+
`credential is used in the repository there`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* A push the worker never makes: to a name that is not a plain branch name, to a protected branch
|
|
557
|
+
* (`BranchPolicy`), to a settings branch under a lease, or of an object that is not a commit.
|
|
558
|
+
*/
|
|
559
|
+
export class RefusedPushError extends GatewayRefusalError {
|
|
560
|
+
constructor(branch, reason, sha) {
|
|
561
|
+
super('push', branch, reason, reason === 'invalid'
|
|
562
|
+
? `Refusing to push ${JSON.stringify(branch)} to GitHub: it is not a valid branch name.`
|
|
563
|
+
: reason === 'protected'
|
|
564
|
+
? `Refusing to push "${branch}" to GitHub: it is protected (the base branch, GitHub's ` +
|
|
565
|
+
`default branch, or a name protectedBranches lists), which the CanopyCMS worker never ` +
|
|
566
|
+
`pushes. Nothing in CanopyCMS queues such a push; find out what wrote this task.`
|
|
567
|
+
: reason === 'settings'
|
|
568
|
+
? `Refusing to push "${branch}" to GitHub under a lease: a settings branch only ever ` +
|
|
569
|
+
`fast-forwards. Find out what set a history-rewrite marker on it.`
|
|
570
|
+
: `Refusing to push "${branch}" to GitHub: ${sha ?? 'the object asked for'} is not a ` +
|
|
571
|
+
`commit, and a branch can only point at one. Find out what put it at ` +
|
|
572
|
+
`refs/heads/${branch} in remote.git.`);
|
|
573
|
+
this.name = 'RefusedPushError';
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Throw {@link RefusedPushError} (or, for another `operation`, {@link GatewayRefusalError}) unless
|
|
578
|
+
* `branch` is a plain branch name: one `branchNameProblem` passes (with `shape` false, only its
|
|
579
|
+
* length cap and no leading `-`) and `git check-ref-format --branch` accepts unchanged. That rules out a `:` (git would read what
|
|
580
|
+
* follows it in a refspec as another destination), `..`, `^`, `~`, `@{`, spaces and a leading
|
|
581
|
+
* `-`, so `refs/heads/<branch>` is exactly the ref a push names. `check-ref-format --branch` takes
|
|
582
|
+
* exactly one argument and refuses `--end-of-options`, which is why it alone goes without one.
|
|
583
|
+
* `git` is the caller's instance: the gateway's runs in its state directory.
|
|
584
|
+
*/
|
|
585
|
+
export async function assertPlainBranchName(branch, git = simpleGit(), operation = 'push',
|
|
586
|
+
/** False for a name the gateway only passes on, never acts on: git's rules, its length, no leading `-`. */
|
|
587
|
+
shape = true) {
|
|
588
|
+
const invalid = (why) => operation === 'push'
|
|
589
|
+
? new RefusedPushError(branch, 'invalid')
|
|
590
|
+
: new GatewayRefusalError(operation, branch, 'invalid', `Refusing ${JSON.stringify(branch)}: it is not a valid branch name (${why}).`);
|
|
591
|
+
// A leading `-` is refused whatever `shape` says: git would read it as an option.
|
|
592
|
+
const problem = shape
|
|
593
|
+
? branchNameProblem(branch)
|
|
594
|
+
: branch.startsWith('-')
|
|
595
|
+
? "it starts with '-'"
|
|
596
|
+
: branch.length > MAX_BRANCH_NAME_LENGTH
|
|
597
|
+
? `it is longer than ${MAX_BRANCH_NAME_LENGTH} characters`
|
|
598
|
+
: null;
|
|
599
|
+
if (problem !== null)
|
|
600
|
+
throw invalid(problem);
|
|
601
|
+
let normalized;
|
|
602
|
+
try {
|
|
603
|
+
normalized = (await git.raw(['check-ref-format', '--branch', branch])).trim();
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
// Only git's own verdict is a refusal; a failure to run git at all is transient.
|
|
607
|
+
if (/is not a valid branch name/.test(getErrorMessage(err))) {
|
|
608
|
+
throw invalid('git check-ref-format refuses it');
|
|
609
|
+
}
|
|
610
|
+
throw err;
|
|
611
|
+
}
|
|
612
|
+
// `@{-1}` and the like resolve to some other name.
|
|
613
|
+
if (normalized !== branch)
|
|
614
|
+
throw invalid('git resolves it to another name');
|
|
615
|
+
}
|
|
616
|
+
/** What one {@link GitHubMirror.exclusive} call may do. */
|
|
617
|
+
export class MirrorSession {
|
|
618
|
+
constructor(gitDir, stateDirectory, timeoutMs, onActivity) {
|
|
619
|
+
this.gitDir = gitDir;
|
|
620
|
+
this.stateDirectory = stateDirectory;
|
|
621
|
+
this.timeoutMs = timeoutMs;
|
|
622
|
+
this.onActivity = onActivity;
|
|
623
|
+
}
|
|
624
|
+
/** simple-git in `repo`: the mirror, or a quarantine of this session's. */
|
|
625
|
+
git(signal, repo = this.gitDir) {
|
|
626
|
+
return simpleGit({
|
|
627
|
+
baseDir: repo,
|
|
628
|
+
...mirrorGitOptions(),
|
|
629
|
+
timeout: { block: this.timeoutMs },
|
|
630
|
+
abort: signal,
|
|
631
|
+
}).env(mirrorEnv(repo));
|
|
632
|
+
}
|
|
633
|
+
/** git with `stdin` fed to it, in `repo` under `config` (the mirror's pins by default). */
|
|
634
|
+
run(args, options = {}) {
|
|
635
|
+
return runInRepo(options.repo ?? this.gitDir, this.timeoutMs, args, {
|
|
636
|
+
...options,
|
|
637
|
+
onActivity: this.onActivity,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Run one GitHub-bound command in `repo`, `args` naming `credential.url` where git expects the
|
|
642
|
+
* remote. The credential reaches it only through {@link withCredentialConfig}'s file, named by
|
|
643
|
+
* `GIT_CONFIG_GLOBAL`, so it is in no argv and in no process's environment. No trace variable
|
|
644
|
+
* reaches this git: trace2 can print config values. Its failure is reported to the credential
|
|
645
|
+
* unless {@link isOwnLockFailure}.
|
|
646
|
+
*
|
|
647
|
+
* It runs through worker/git-process.ts, in a process group of its own, so an abort or a kill
|
|
648
|
+
* reaches what git spawned for the transfer too (`git-remote-https`, `pack-objects`); simple-git
|
|
649
|
+
* would signal git alone. Besides the inactivity bound, curl's low-speed limit ends a transfer
|
|
650
|
+
* that trickles: {@link LOW_SPEED_BYTES_PER_SECOND} for as long as the inactivity bound.
|
|
651
|
+
*/
|
|
652
|
+
async githubBound(credential, args, signal, repo = this.gitDir) {
|
|
653
|
+
return withCredentialConfig(this.stateDirectory, credential, async (configPath) => {
|
|
654
|
+
try {
|
|
655
|
+
return await runGit(args, {
|
|
656
|
+
cwd: repo,
|
|
657
|
+
env: { ...githubBoundEnv(repo), GIT_CONFIG_GLOBAL: configPath },
|
|
658
|
+
config: [
|
|
659
|
+
...mirrorPins(),
|
|
660
|
+
`http.lowSpeedLimit=${LOW_SPEED_BYTES_PER_SECOND}`,
|
|
661
|
+
`http.lowSpeedTime=${Math.max(1, Math.ceil(this.timeoutMs / 1000))}`,
|
|
662
|
+
],
|
|
663
|
+
inactivityTimeoutMs: this.timeoutMs,
|
|
664
|
+
signal,
|
|
665
|
+
stdoutInFailure: true,
|
|
666
|
+
onActivity: this.onActivity,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
if (!isOwnLockFailure(getErrorMessage(err)))
|
|
671
|
+
credential.onFailure();
|
|
672
|
+
throw err;
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Bring `refs/heads/*` of `repo` (the mirror, or a rebuild's replacement) to exactly what GitHub
|
|
678
|
+
* holds. Sessions fetch through {@link GitHubMirror.fetchFromGitHub}, which cleans up after it.
|
|
679
|
+
*/
|
|
680
|
+
async fetchFromGitHub(credential, signal, repo = this.gitDir) {
|
|
681
|
+
await this.githubBound(credential, [
|
|
682
|
+
'fetch',
|
|
683
|
+
'--prune',
|
|
684
|
+
'--progress',
|
|
685
|
+
'--no-write-fetch-head',
|
|
686
|
+
'--end-of-options',
|
|
687
|
+
credential.url,
|
|
688
|
+
'+refs/heads/*:refs/heads/*',
|
|
689
|
+
], signal, repo);
|
|
690
|
+
}
|
|
691
|
+
/** Every branch GitHub had at the last fetch, mapped to its tip. */
|
|
692
|
+
async heads() {
|
|
693
|
+
const out = await this.git().raw([
|
|
694
|
+
'for-each-ref',
|
|
695
|
+
'--format=%(objectname) %(refname)',
|
|
696
|
+
'--end-of-options',
|
|
697
|
+
'refs/heads/',
|
|
698
|
+
]);
|
|
699
|
+
const heads = new Map();
|
|
700
|
+
for (const line of out.split('\n')) {
|
|
701
|
+
const space = line.indexOf(' ');
|
|
702
|
+
if (space === -1)
|
|
703
|
+
continue;
|
|
704
|
+
heads.set(line.slice(space + 1 + 'refs/heads/'.length), line.slice(0, space));
|
|
705
|
+
}
|
|
706
|
+
return heads;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Bundle every branch into `file`, less what `have` already holds: each ID the mirror has as a
|
|
710
|
+
* commit is excluded with its history, and any other ID is ignored. False, with no file, when
|
|
711
|
+
* that leaves nothing to send. Each ID is validated before it reaches here, since rev-list's
|
|
712
|
+
* `--stdin` honours a pseudo-option such as `--all`.
|
|
713
|
+
*/
|
|
714
|
+
async createBundle(have, file, signal) {
|
|
715
|
+
const types = await this.objectTypes(have);
|
|
716
|
+
const exclusions = have.filter((id) => types.get(id) === 'commit');
|
|
717
|
+
try {
|
|
718
|
+
await this.run(['bundle', 'create', '--progress', '--end-of-options', file, '--branches', '--stdin'], { stdin: exclusions.map((id) => `^${id}\n`).join(''), signal });
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
await fs.rm(file, { force: true });
|
|
723
|
+
await fs.rm(`${file}.lock`, { force: true });
|
|
724
|
+
if (/Refusing to create empty bundle/.test(getErrorMessage(err)))
|
|
725
|
+
return false;
|
|
726
|
+
throw err;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Each of `ids` mapped to its object type in `repo`, or to `missing`, from one
|
|
731
|
+
* `cat-file --batch-check`, which prints `<id> missing` for an absent object where `cat-file -t`
|
|
732
|
+
* shares exit 128 with every other failure. Every ID must already be validated.
|
|
733
|
+
*/
|
|
734
|
+
async objectTypes(ids, repo = this.gitDir) {
|
|
735
|
+
const types = new Map();
|
|
736
|
+
if (ids.length === 0)
|
|
737
|
+
return types;
|
|
738
|
+
const lines = (await this.run(['cat-file', '--batch-check=%(objectname) %(objecttype)', '--end-of-options'], {
|
|
739
|
+
stdin: ids.map((id) => `${id}\n`).join(''),
|
|
740
|
+
repo,
|
|
741
|
+
})).split('\n');
|
|
742
|
+
ids.forEach((id, i) => {
|
|
743
|
+
const line = lines[i] ?? '';
|
|
744
|
+
if (line === `${id} missing`)
|
|
745
|
+
types.set(id, 'missing');
|
|
746
|
+
else if (line.startsWith(`${id} `))
|
|
747
|
+
types.set(id, line.slice(id.length + 1));
|
|
748
|
+
else
|
|
749
|
+
throw new Error(`git cat-file printed no line for ${id}`);
|
|
750
|
+
});
|
|
751
|
+
return types;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Whether a GitHub branch contained the commit `sha` at the last fetch. Only for a commit:
|
|
755
|
+
* `--contains` exits 129 on a missing or non-commit object.
|
|
756
|
+
*/
|
|
757
|
+
async reachableFromHeads(sha) {
|
|
758
|
+
const found = await this.git().raw([
|
|
759
|
+
'for-each-ref',
|
|
760
|
+
'--count=1',
|
|
761
|
+
'--format=%(refname)',
|
|
762
|
+
`--contains=${sha}`,
|
|
763
|
+
'--end-of-options',
|
|
764
|
+
'refs/heads/',
|
|
765
|
+
]);
|
|
766
|
+
return found.trim() !== '';
|
|
767
|
+
}
|
|
768
|
+
/** Of `prerequisites`, the commits a GitHub branch contained at the last fetch. */
|
|
769
|
+
async usablePrerequisites(prerequisites) {
|
|
770
|
+
const unique = [...new Set(prerequisites)];
|
|
771
|
+
const types = await this.objectTypes(unique);
|
|
772
|
+
const usable = [];
|
|
773
|
+
for (const id of unique) {
|
|
774
|
+
if (types.get(id) === 'commit' && (await this.reachableFromHeads(id)))
|
|
775
|
+
usable.push(id);
|
|
776
|
+
}
|
|
777
|
+
return usable;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Whether every one of `commits` is reachable from one of `tips`, in ONE walk: rev-list fed the
|
|
781
|
+
* commits and a `^<tip>` per tip lists what the tips do not reach, so empty output means all
|
|
782
|
+
* are reached (several `--contains` would mean "any", not "all"). Only for commits: rev-list
|
|
783
|
+
* passes a tree or blob ID silently.
|
|
784
|
+
*/
|
|
785
|
+
async allReachable(commits, tips) {
|
|
786
|
+
if (commits.length === 0)
|
|
787
|
+
return true;
|
|
788
|
+
const out = await this.run(['rev-list', '--stdin', '--max-count=1', '--end-of-options'], {
|
|
789
|
+
stdin: [...commits, ...tips.map((tip) => `^${tip}`)].map((line) => `${line}\n`).join(''),
|
|
790
|
+
});
|
|
791
|
+
return out.trim() === '';
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* The branch GitHub's HEAD names, or null for a repository with none (empty, or HEAD unborn). A
|
|
795
|
+
* failure to reach GitHub throws, so a push that cannot learn it does not happen.
|
|
796
|
+
*/
|
|
797
|
+
async githubDefaultBranch(credential, signal) {
|
|
798
|
+
const out = await this.githubBound(credential, ['ls-remote', '--symref', '--end-of-options', credential.url, 'HEAD'], signal);
|
|
799
|
+
const match = /^ref: refs\/heads\/(.+)\tHEAD$/m.exec(out);
|
|
800
|
+
return match ? match[1] : null;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Take a received bundle into a fresh quarantine repository beside it, returning the
|
|
804
|
+
* quarantine's path, with `sha` there as a commit. In order:
|
|
805
|
+
* 1. each prerequisite must be a commit the mirror holds, reachable from one of `heads` (the
|
|
806
|
+
* branches GitHub had at this session's fetch): one `cat-file --batch-check`, then one walk;
|
|
807
|
+
* 2. the quarantine borrows the mirror's objects through `objects/info/alternates`, with the
|
|
808
|
+
* mirror's pins except `core.alternateRefsCommand` ({@link quarantinePins});
|
|
809
|
+
* 3. `fetch` of the bundle's one head into a staging ref, checking every object, by a refspec
|
|
810
|
+
* built from the validated branch, never from the bundle;
|
|
811
|
+
* 4. the staging ref must be `sha`, and `sha` a commit.
|
|
812
|
+
* A refusal is {@link GitHubTransientError} (`bundle-rejected`, or `prerequisite-gone` for a
|
|
813
|
+
* prerequisite GitHub no longer has), except a non-commit `sha`, which is
|
|
814
|
+
* {@link RefusedPushError}. Nothing here writes the mirror.
|
|
815
|
+
*/
|
|
816
|
+
async quarantine(bundle, expected, heads, signal) {
|
|
817
|
+
const { prerequisites } = bundle.header;
|
|
818
|
+
const types = await this.objectTypes(prerequisites);
|
|
819
|
+
if (prerequisites.some((id) => types.get(id) === 'missing')) {
|
|
820
|
+
throw new GitHubTransientError('prerequisite-gone', `The bundle for ${expected.branch} builds on a commit GitHub no longer has. Retrying ` +
|
|
821
|
+
`sends a new one.`);
|
|
822
|
+
}
|
|
823
|
+
if (prerequisites.some((id) => types.get(id) !== 'commit')) {
|
|
824
|
+
throw new GitHubTransientError('bundle-rejected', 'Refusing the bundle: a prerequisite is not a commit');
|
|
825
|
+
}
|
|
826
|
+
if (!(await this.allReachable(prerequisites, [...heads.values()]))) {
|
|
827
|
+
throw new GitHubTransientError('prerequisite-gone', `The bundle for ${expected.branch} builds on a commit no GitHub branch contains any more. ` +
|
|
828
|
+
`Retrying sends a new one.`);
|
|
829
|
+
}
|
|
830
|
+
const repo = path.join(bundle.dir, 'repo.git');
|
|
831
|
+
await fs.mkdir(repo, { mode: 0o700 });
|
|
832
|
+
await this.run(['init', '--quiet', '--bare', '--end-of-options'], {
|
|
833
|
+
repo,
|
|
834
|
+
config: quarantinePins(),
|
|
835
|
+
});
|
|
836
|
+
await fs.writeFile(path.join(repo, 'objects', 'info', 'alternates'), `${path.join(this.gitDir, 'objects')}\n`, { mode: 0o600 });
|
|
837
|
+
const staging = `${STAGING_PREFIX}${expected.branch}`;
|
|
838
|
+
try {
|
|
839
|
+
await this.run([
|
|
840
|
+
'fetch',
|
|
841
|
+
'--progress',
|
|
842
|
+
'--no-write-fetch-head',
|
|
843
|
+
'--end-of-options',
|
|
844
|
+
bundle.file,
|
|
845
|
+
`refs/heads/${expected.branch}:${staging}`,
|
|
846
|
+
], {
|
|
847
|
+
repo,
|
|
848
|
+
// The severity pins are depth: index-pack already errors on these by default.
|
|
849
|
+
config: [
|
|
850
|
+
...quarantinePins(),
|
|
851
|
+
'fetch.fsckObjects=true',
|
|
852
|
+
'fetch.fsck.hasDot=error',
|
|
853
|
+
'fetch.fsck.hasDotdot=error',
|
|
854
|
+
'fetch.fsck.hasDotgit=error',
|
|
855
|
+
],
|
|
856
|
+
signal,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
catch (err) {
|
|
860
|
+
throw new GitHubTransientError('bundle-rejected', `Refusing the bundle for ${expected.branch}: ${redactEgress(getErrorMessage(err)).trim()}`);
|
|
861
|
+
}
|
|
862
|
+
const staged = (await this.git(signal, repo).raw(['rev-parse', '--verify', '--end-of-options', staging])).split('\n')[0];
|
|
863
|
+
if (staged !== expected.sha) {
|
|
864
|
+
throw new GitHubTransientError('bundle-rejected', `Refusing the bundle for ${expected.branch}: its head is not ${expected.sha}`);
|
|
865
|
+
}
|
|
866
|
+
if ((await this.objectTypes([expected.sha], repo)).get(expected.sha) !== 'commit') {
|
|
867
|
+
throw new RefusedPushError(expected.branch, 'not-a-commit', expected.sha);
|
|
868
|
+
}
|
|
869
|
+
return repo;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Push exactly `sha` from `repo` (the mirror, or a quarantine holding it) to GitHub's
|
|
873
|
+
* `refs/heads/<branch>`. `lease` is the commit GitHub must still be at for the push to replace
|
|
874
|
+
* it (`--force-with-lease`); without one the push is an ordinary fast-forward. Resolves with
|
|
875
|
+
* what the push did to GitHub's ref.
|
|
876
|
+
*
|
|
877
|
+
* Validation and `policy` are here, at the one place every GitHub push (plain or under a lease)
|
|
878
|
+
* goes through, so no caller can skip them. When `policy` needs GitHub's default branch read
|
|
879
|
+
* afresh, it is read here, in this session, from the repository the push goes to. A bundle's quarantine fetch
|
|
880
|
+
* names `branch` first, so the gateway validates it before the ingest too (`pushOnce`).
|
|
881
|
+
*/
|
|
882
|
+
async pushToGitHub(credential, branch, sha, options) {
|
|
883
|
+
for (const id of [sha, ...(options.lease === undefined ? [] : [options.lease])]) {
|
|
884
|
+
if (!isObjectId(id))
|
|
885
|
+
throw new Error(`Not a commit ID: ${JSON.stringify(id)}`);
|
|
886
|
+
}
|
|
887
|
+
await assertPlainBranchName(branch, this.git());
|
|
888
|
+
await options.policy.assertPushAllowed(branch, options.lease,
|
|
889
|
+
// Unknown, the policy cannot decide: neither a refusal nor a rejected push, but transient.
|
|
890
|
+
() => this.githubDefaultBranch(credential, options.signal).catch((err) => {
|
|
891
|
+
throw new GitHubTransientError('default-branch-unread', `Could not read GitHub's default branch before pushing ${branch}: ` +
|
|
892
|
+
redactEgress(getErrorMessage(err)).trim());
|
|
893
|
+
}), (reason) => new RefusedPushError(branch, reason));
|
|
894
|
+
options.onPushing?.();
|
|
895
|
+
const status = await this.githubBound(credential, [
|
|
896
|
+
'push',
|
|
897
|
+
'--porcelain',
|
|
898
|
+
'--progress',
|
|
899
|
+
...(options.lease ? [`--force-with-lease=refs/heads/${branch}:${options.lease}`] : []),
|
|
900
|
+
'--end-of-options',
|
|
901
|
+
credential.url,
|
|
902
|
+
`${sha}:refs/heads/${branch}`,
|
|
903
|
+
], options.signal, options.repo);
|
|
904
|
+
return parsePushStatus(status, branch, sha);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Write a pushed bundle to `file` as it arrives, refusing it as soon as it cannot be accepted:
|
|
909
|
+
* past `maxBytes`, or with a header that is not exactly one head, `<sha> refs/heads/<branch>`
|
|
910
|
+
* (worker/bundle-header.ts). Holds at most the header's bounds, plus one chunk, in memory.
|
|
911
|
+
*/
|
|
912
|
+
async function receiveBundle(body, file, expected, maxBytes) {
|
|
913
|
+
const rejected = (reason) => new GitHubTransientError('bundle-rejected', `Refusing the bundle for ${expected.branch}: ${reason}`);
|
|
914
|
+
const out = await fs.open(file, 'wx', 0o600);
|
|
915
|
+
const reader = new BundleHeaderReader('refuse');
|
|
916
|
+
let header = null;
|
|
917
|
+
let total = 0;
|
|
918
|
+
try {
|
|
919
|
+
for await (const chunk of body) {
|
|
920
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
921
|
+
total += bytes.length;
|
|
922
|
+
if (total > maxBytes)
|
|
923
|
+
throw rejected(`it is larger than ${maxBytes} bytes`);
|
|
924
|
+
if (header === null) {
|
|
925
|
+
header = reader.push(bytes)?.header ?? null;
|
|
926
|
+
if (header !== null && !hasOnlyHead(header, expected.sha, expected.branch)) {
|
|
927
|
+
throw rejected(`its one head must be ${expected.sha} refs/heads/${expected.branch}`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
await out.write(bytes);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
if (err instanceof GitHubTransientError)
|
|
935
|
+
throw err;
|
|
936
|
+
if (err instanceof BundleHeaderError)
|
|
937
|
+
throw rejected(err.message.replace(/^Refusing the bundle: /, ''));
|
|
938
|
+
throw rejected(`it did not arrive whole (${getErrorMessage(err)})`);
|
|
939
|
+
}
|
|
940
|
+
finally {
|
|
941
|
+
await out.close();
|
|
942
|
+
}
|
|
943
|
+
if (header === null)
|
|
944
|
+
throw rejected('it ends before its header does');
|
|
945
|
+
return header;
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Read `refs/heads/<branch>`'s line from `git push --porcelain`'s stdout: `=` up to date, ` ` a
|
|
949
|
+
* fast-forward (`old..new`), `+` a forced update (`old...new`), `*` a new branch. A rejected ref
|
|
950
|
+
* (`!`) makes git exit non-zero before this runs. Output with no such line throws, rather than
|
|
951
|
+
* report a push this did not see.
|
|
952
|
+
* @internal Exported for tests.
|
|
953
|
+
*/
|
|
954
|
+
export function parsePushStatus(stdout, branch, sha) {
|
|
955
|
+
for (const line of stdout.split('\n')) {
|
|
956
|
+
const [flag, refs, summary] = line.split('\t');
|
|
957
|
+
if (summary === undefined || !refs.endsWith(`:refs/heads/${branch}`))
|
|
958
|
+
continue;
|
|
959
|
+
if (flag === '=')
|
|
960
|
+
return { moved: false };
|
|
961
|
+
if (flag === '*')
|
|
962
|
+
return { moved: true, from: null, to: sha };
|
|
963
|
+
const range = /^([0-9a-f]+)\.\.\.?[0-9a-f]+/.exec(summary);
|
|
964
|
+
if ((flag === ' ' || flag === '+') && range)
|
|
965
|
+
return { moved: true, from: range[1], to: sha };
|
|
966
|
+
}
|
|
967
|
+
throw new Error(`git push reported no readable status for refs/heads/${branch}`);
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* The network env (proxies, CA bundles, the C locale the push-rejection classifier needs), with
|
|
971
|
+
* the repository named outright, since a bare repository found from the working directory is
|
|
972
|
+
* refused where `safe.bareRepository=explicit` is set. No object is ever fetched lazily from a
|
|
973
|
+
* promisor remote, and no `refs/replace/*` changes what any command sees.
|
|
974
|
+
*/
|
|
975
|
+
function mirrorEnv(gitDir) {
|
|
976
|
+
return {
|
|
977
|
+
...gitNetworkChildEnv(),
|
|
978
|
+
GIT_DIR: gitDir,
|
|
979
|
+
GIT_NO_LAZY_FETCH: '1',
|
|
980
|
+
GIT_NO_REPLACE_OBJECTS: '1',
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* {@link mirrorEnv} for a GitHub-bound command: without any `GIT_TRACE*` variable, and with git's
|
|
985
|
+
* terminal prompt off, so a refused credential fails rather than asking a developer's terminal.
|
|
986
|
+
*/
|
|
987
|
+
function githubBoundEnv(gitDir) {
|
|
988
|
+
const env = Object.entries(mirrorEnv(gitDir)).filter(([key]) => !key.startsWith('GIT_TRACE'));
|
|
989
|
+
return { ...Object.fromEntries(env), GIT_TERMINAL_PROMPT: '0' };
|
|
990
|
+
}
|
|
991
|
+
/** A full SHA-1 or SHA-256 object ID, so nothing else can reach a refspec or rev-list's stdin. */
|
|
992
|
+
export function isObjectId(value) {
|
|
993
|
+
return (value.length === 40 || value.length === 64) && /^[0-9a-f]+$/.test(value);
|
|
994
|
+
}
|
|
995
|
+
async function isBareRepository(gitDir) {
|
|
996
|
+
try {
|
|
997
|
+
await fs.access(gitDir);
|
|
998
|
+
// rev-parse echoes `--end-of-options`, so only the first line is the answer.
|
|
999
|
+
const out = await simpleGit({ baseDir: gitDir })
|
|
1000
|
+
.env(mirrorEnv(gitDir))
|
|
1001
|
+
.raw(['rev-parse', '--is-bare-repository', '--end-of-options']);
|
|
1002
|
+
return out.split('\n')[0] === 'true';
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Create an empty bare repository at `gitDir` under the mirror's pins, with loose and packed refs
|
|
1010
|
+
* whatever a user's config defaults to: the sweeps know that format's leftovers.
|
|
1011
|
+
*/
|
|
1012
|
+
async function initMirror(gitDir) {
|
|
1013
|
+
await fs.mkdir(gitDir, { mode: 0o700 });
|
|
1014
|
+
await simpleGit({ baseDir: gitDir, ...mirrorGitOptions() })
|
|
1015
|
+
.env(mirrorEnv(gitDir))
|
|
1016
|
+
.raw(['-c', 'init.defaultRefFormat=files', 'init', '--quiet', '--bare', '--end-of-options']);
|
|
1017
|
+
}
|
|
1018
|
+
/** git in `repo`, one of the gateway's own, under `config` (the mirror's pins by default). */
|
|
1019
|
+
function runInRepo(repo, timeoutMs, args, options = {}) {
|
|
1020
|
+
return runGit(args, {
|
|
1021
|
+
cwd: repo,
|
|
1022
|
+
env: mirrorEnv(repo),
|
|
1023
|
+
config: options.config ?? mirrorPins(),
|
|
1024
|
+
stdin: options.stdin,
|
|
1025
|
+
inactivityTimeoutMs: timeoutMs,
|
|
1026
|
+
deadlineMs: options.deadlineMs,
|
|
1027
|
+
signal: options.signal,
|
|
1028
|
+
onActivity: options.onActivity,
|
|
1029
|
+
});
|
|
1030
|
+
}
|